Library must not abort the process; E2E callers use EnsureOnline/t.Fatal so ambient RUN_PI_E2E no longer kills short server tests. Co-authored-by: Cursor <cursoragent@cursor.com>
548 lines
21 KiB
Go
548 lines
21 KiB
Go
package server_test
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
"time"
|
||
|
||
"chat-agent-system/internal/bizdev"
|
||
"chat-agent-system/internal/config"
|
||
"chat-agent-system/internal/ctxmgr"
|
||
"chat-agent-system/internal/docgov"
|
||
"chat-agent-system/internal/kb"
|
||
"chat-agent-system/internal/server"
|
||
"chat-agent-system/internal/squad"
|
||
)
|
||
|
||
// mergePiAgentE2EKbConfig 从分层 Load(根 .env + configs/.env.{ENV})注入共享知识库配置。
|
||
// E2E 默认 fail-closed:须在线 KB;仅 KB_ALLOW_OFFLINE=1 时允许降级并打印 NOTE。
|
||
func mergePiAgentE2EKbConfig(cfg *config.Config) {
|
||
if cfg == nil {
|
||
return
|
||
}
|
||
_ = os.Setenv("KB_REQUIRE_ONLINE", "1")
|
||
envCfg, err := config.Load("")
|
||
if err == nil && envCfg != nil {
|
||
cfg.KbDbHost = envCfg.KbDbHost
|
||
cfg.KbDbPort = envCfg.KbDbPort
|
||
cfg.KbDbUser = envCfg.KbDbUser
|
||
cfg.KbDbPassword = envCfg.KbDbPassword
|
||
cfg.KbDbName = envCfg.KbDbName
|
||
cfg.EmbeddingEndpointURL = envCfg.EmbeddingEndpointURL
|
||
cfg.EmbeddingAPIKey = envCfg.EmbeddingAPIKey
|
||
cfg.EmbeddingModel = envCfg.EmbeddingModel
|
||
cfg.RerankerEndpointURL = envCfg.RerankerEndpointURL
|
||
cfg.RerankerAPIKey = envCfg.RerankerAPIKey
|
||
cfg.RerankerModel = envCfg.RerankerModel
|
||
}
|
||
config.NormalizeKBHostForRuntime(cfg)
|
||
if cfg.KbDbHost == "" {
|
||
cfg.KbDbHost = "127.0.0.1"
|
||
}
|
||
if cfg.KbDbPort == 0 {
|
||
cfg.KbDbPort = 5432
|
||
}
|
||
if cfg.KbDbUser == "" {
|
||
cfg.KbDbUser = "regkb"
|
||
}
|
||
if cfg.KbDbName == "" {
|
||
cfg.KbDbName = "regulations"
|
||
}
|
||
}
|
||
|
||
// TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E
|
||
// 贯穿:新建项目 -> 角色权限配置 -> 分角色材料补充与真实对话 -> 自由非标任务执行 -> 工作流推进 -> 审核驳回/再批 -> 数据库落盘与全量审计追溯
|
||
//
|
||
// 运行方式(需 pi-agent runner 在线,建议 1h 超时):
|
||
//
|
||
// go test ./internal/server/ -run TestPiAgent_FullLifecycle -timeout 2h -v
|
||
func TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping slow real-world E2E test in short mode")
|
||
}
|
||
// 1. 初始化配置并强制指定 pi-agent 后端与真实 Runner
|
||
runnerURL := os.Getenv("PI_AGENT_RUNNER_URL")
|
||
if runnerURL == "" {
|
||
runnerURL = "http://127.0.0.1:18865"
|
||
}
|
||
runnerToken := os.Getenv("PI_AGENT_RUNNER_AUTH_TOKEN")
|
||
if runnerToken == "" {
|
||
runnerToken = os.Getenv("RUNNER_AUTH_TOKEN")
|
||
}
|
||
cfg := &config.Config{
|
||
Env: "dev",
|
||
AgentBackend: "pi-agent",
|
||
AgentTimeout: 60 * time.Minute,
|
||
PiAgentRunnerURL: runnerURL,
|
||
PiAgentRunnerAuthToken: runnerToken,
|
||
PiAgentModel: "MiniMax-M3",
|
||
PiAgentProvider: "minimax",
|
||
DataDir: t.TempDir(),
|
||
AllowInMemory: true,
|
||
}
|
||
mergePiAgentE2EKbConfig(cfg)
|
||
|
||
svc := bizdev.MustNewService(t, cfg)
|
||
srv := server.NewServer(cfg, svc)
|
||
ts := httptest.NewServer(srv.Routes())
|
||
defer ts.Close()
|
||
|
||
// 单次 pi-agent 远程调用可能超过 30min;HTTP client 与 AgentTimeout 对齐,整体由 go test -timeout 2h 兜底。
|
||
client := &http.Client{Timeout: 65 * time.Minute}
|
||
ctx := context.Background()
|
||
stepStart := time.Now()
|
||
logStep := func(label string) {
|
||
elapsed := time.Since(stepStart).Round(time.Second)
|
||
t.Logf("⏱ [%s] 累计耗时 %s", label, elapsed)
|
||
stepStart = time.Now()
|
||
}
|
||
|
||
// 2. 验证 Health 确认 AgentBackend 为 pi-agent
|
||
respHealth, err := client.Get(ts.URL + "/api/v1/health")
|
||
if err != nil {
|
||
t.Fatalf("health check failed: %v", err)
|
||
}
|
||
var healthData server.HealthResponse
|
||
_ = json.NewDecoder(respHealth.Body).Decode(&healthData)
|
||
respHealth.Body.Close()
|
||
t.Logf("✓ [0. 环境校验] Health检查正常: Env=%s, AgentBackend=%s, SharedKBPort=%d kb_ready=%v",
|
||
healthData.Env, healthData.AgentBackend, healthData.SharedKBPort, healthData.KBReady)
|
||
if !kb.RequireOnline() && !healthData.KBReady {
|
||
t.Logf("NOTE: KB offline degradation test (KB_ALLOW_OFFLINE=1)")
|
||
}
|
||
kbClient := kb.NewClient(cfg)
|
||
defer kbClient.Close()
|
||
if err := kb.EnsureOnline(kbClient); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !kbClient.Online() {
|
||
t.Logf("NOTE: KB offline degradation test — continuing without shared KB")
|
||
} else if stats, err := kbClient.Stats(ctx); err != nil {
|
||
t.Fatalf("KB online but Stats failed: %v", err)
|
||
} else if reg, ok := stats[kb.DomainRegulatory]; ok {
|
||
t.Logf("✓ [0. 知识库] 已连接共享 KB: documents=%d chunks=%d", reg.DocumentCount, reg.ChunkCount)
|
||
} else {
|
||
t.Logf("✓ [0. 知识库] 已连接共享 KB")
|
||
}
|
||
|
||
// 3. 项目基本信息与三级角色定义
|
||
projectID := int64(time.Now().UnixNano()%900000 + 100000)
|
||
projectName := "数字化高频超声手术吸引系统 (CUSA-Ultra)"
|
||
customerName := "北京协和神外微创转化中心"
|
||
deviceType := "高频超声手术吸引系统"
|
||
intendedUse := "神经外科与肝胆外科精密手术中利用 24kHz-35kHz 超声微幅振动选择性粉碎并吸引病灶组织,保留邻近大血管与神经"
|
||
targetMarket := "中国NMPA三类创新医疗器械"
|
||
|
||
// 与 dev 种子账号对齐(internal/auth/store.go)
|
||
authorID := int64(2) // zhanggong / 张工
|
||
authorName := "张工"
|
||
reviewerID := int64(3) // lizhuguan / 李主管
|
||
reviewerName := "李主管"
|
||
approverID := int64(4) // wangzong / 王总监
|
||
approverName := "王总监"
|
||
|
||
authorToken := loginTestUser(t, ts.Config.Handler, "zhanggong", "123456")
|
||
reviewerToken := loginTestUser(t, ts.Config.Handler, "lizhuguan", "123456")
|
||
approverToken := loginTestUser(t, ts.Config.Handler, "wangzong", "123456")
|
||
|
||
authedPost := func(path, token string, payload any) (*http.Response, error) {
|
||
b, _ := json.Marshal(payload)
|
||
req, err := http.NewRequest(http.MethodPost, ts.URL+path, bytes.NewReader(b))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
withBearerAuth(req, token)
|
||
return client.Do(req)
|
||
}
|
||
expectOK := func(label string, resp *http.Response, err error) {
|
||
t.Helper()
|
||
if err != nil {
|
||
t.Fatalf("%s: %v", label, err)
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
var buf bytes.Buffer
|
||
_, _ = buf.ReadFrom(resp.Body)
|
||
t.Fatalf("%s: HTTP %d body=%s", label, resp.StatusCode, buf.String())
|
||
}
|
||
}
|
||
|
||
sessionID := fmt.Sprintf("sess_cusa_%d", time.Now().UnixNano())
|
||
|
||
// 4. 【编制人·张工】登录与立项阶段对话 (穿插非工作流问答 + 行业标准咨询)
|
||
t.Log("--------------------------------------------------------------------------------")
|
||
t.Log(">>> 步骤 1: 【编制人·张工】开展立项对接对话与标准咨询 (含非工作流穿插交互)")
|
||
t.Log("--------------------------------------------------------------------------------")
|
||
|
||
postChat := func(msg string, uID int64, uName, uRole string) string {
|
||
chatReq := server.ChatRequest{
|
||
SessionID: sessionID,
|
||
Message: msg,
|
||
ProjectName: projectName,
|
||
CustomerName: customerName,
|
||
DeviceType: deviceType,
|
||
IntendedUse: intendedUse,
|
||
TargetMarket: targetMarket,
|
||
UserName: uName,
|
||
UserRole: uRole,
|
||
UserID: uID,
|
||
ProjectID: projectID,
|
||
}
|
||
b, _ := json.Marshal(chatReq)
|
||
res, err := client.Post(ts.URL+"/api/v1/chat", "application/json", bytes.NewReader(b))
|
||
if err != nil {
|
||
t.Fatalf("post chat failed: %v", err)
|
||
}
|
||
defer res.Body.Close()
|
||
var cr struct {
|
||
ReplyText string `json:"reply_text"`
|
||
Intent string `json:"intent"`
|
||
Branch string `json:"branch"`
|
||
}
|
||
_ = json.NewDecoder(res.Body).Decode(&cr)
|
||
return cr.ReplyText
|
||
}
|
||
|
||
// 4.1 穿插闲聊 / 非工作流技术探讨论述
|
||
casualReply := postChat("参谋,你觉得明天的天气怎么样?另外帮我解释一下什么叫压电换能器的逆压电效应?", authorID, authorName, "author")
|
||
t.Logf("✓ [张工-闲聊与技术咨询] 协同参谋答复:\n%s", casualReply)
|
||
|
||
// 4.2 业务补充与立项需求对话
|
||
intakeReply := postChat("我们准备正式立项 CUSA-Ultra 项目,刀头频率设定为 24kHz 与 35kHz 双频,具备实时灌注与负压吸引,满足神经外科浅脑肿瘤切除,请问法规应关注哪些强标?", authorID, authorName, "author")
|
||
t.Logf("✓ [张工-立项需求对话] 协同参谋答复:\n%s", intakeReply)
|
||
logStep("步骤 1 对话完成")
|
||
|
||
// 5. 推进成熟度工作流 V0.1: 立项摸底 (intake)
|
||
t.Log("--------------------------------------------------------------------------------")
|
||
t.Log(">>> 步骤 2: 推进工作流成熟度阶段 V0.1 (立项需求摸底与盲区探测)")
|
||
t.Log("--------------------------------------------------------------------------------")
|
||
|
||
stageReqV01 := bizdev.StageRunRequest{
|
||
Stage: bizdev.StageV01_Triage,
|
||
ProjectID: projectID,
|
||
ProjectName: projectName,
|
||
CustomerName: customerName,
|
||
DeviceType: deviceType,
|
||
IntendedUse: intendedUse,
|
||
TargetMarket: targetMarket,
|
||
Backend: "pi-agent",
|
||
UserID: authorID,
|
||
AuthorID: authorID,
|
||
AuthorName: authorName,
|
||
ReviewerID: reviewerID,
|
||
ReviewerName: reviewerName,
|
||
ApproverID: approverID,
|
||
ApproverName: approverName,
|
||
Fields: map[string]string{
|
||
"operating_frequency": "24kHz / 35kHz",
|
||
"transducer_type": "PZT-8 压电陶瓷复合换能器",
|
||
"cooling_flow_rate": "10-50 mL/min 生理盐水冲洗",
|
||
},
|
||
}
|
||
|
||
resV01, err := svc.ExecuteStage(ctx, stageReqV01)
|
||
if err != nil {
|
||
t.Fatalf("ExecuteStage V0.1 failed: %v", err)
|
||
}
|
||
if !resV01.Success {
|
||
t.Fatalf("Stage V0.1 failed: %s", resV01.ErrorReason)
|
||
}
|
||
t.Logf("✓ [V0.1 阶段完成] 交付物: %s, 成熟度评分: %d, 标题: %s",
|
||
resV01.PrimaryArtifact.ArtifactTitle, resV01.AdvisorBrief.MaturityScore, resV01.AdvisorBrief.MaturityHeadline)
|
||
|
||
targetDocID := ""
|
||
if resV01.PrimaryArtifact != nil && resV01.PrimaryArtifact.DocRecord != nil {
|
||
targetDocID = resV01.PrimaryArtifact.DocRecord.DocID
|
||
}
|
||
if targetDocID == "" {
|
||
targetDocID = fmt.Sprintf("doc_proj%d_intake_v1", projectID)
|
||
}
|
||
logStep("步骤 2 V0.1 阶段完成")
|
||
|
||
// 6. 推进自由非标任务 (Free Ad-Hoc Task): 24kHz 高频空化钛合金刀头微裂纹与疲劳剥落风险专项分析报告
|
||
t.Log("--------------------------------------------------------------------------------")
|
||
t.Log(">>> 步骤 3: 触发自由非标任务 (Free Ad-Hoc Task) - 超声空化微射流与钛合金疲劳断裂专项研判")
|
||
t.Log("--------------------------------------------------------------------------------")
|
||
|
||
freeTaskReq := server.TaskRunRequestDTO{
|
||
TaskKey: "free_cusa_cavitation_risk",
|
||
ProjectName: projectName,
|
||
CustomerName: customerName,
|
||
DeviceType: deviceType,
|
||
IntendedUse: intendedUse,
|
||
TargetMarket: targetMarket,
|
||
Backend: "pi-agent",
|
||
ProjectID: projectID,
|
||
AuthorID: authorID,
|
||
AuthorName: authorName,
|
||
ReviewerID: reviewerID,
|
||
ReviewerName: reviewerName,
|
||
ApproverID: approverID,
|
||
ApproverName: approverName,
|
||
Fields: map[string]string{
|
||
"material_grade": "Ti-6Al-4V ELI (超低间隙五级钛合金)",
|
||
"tip_amplitude_um": "300 微米纵向振幅峰峰值",
|
||
"cavitation_cycles": "10^8 次高周疲劳循环",
|
||
},
|
||
}
|
||
// 为自由非标任务定制快速精简 WBS 步骤,确保单测试验证效率
|
||
if os.Getenv("RUN_FULL_ADHOC_WBS") != "1" {
|
||
freeTaskReq.CustomSteps = []squad.StepSpec{
|
||
{
|
||
ID: "step_01_cavitation_analysis",
|
||
Role: "writer",
|
||
Title: "钛合金刀头高周超声疲劳与空化剥落风险专项研判",
|
||
SubWorkspace: "tasks/01_analysis",
|
||
Mode: "execute",
|
||
Accept: []string{
|
||
"分析 Ti-6Al-4V ELI 在 24kHz/35kHz 空化微射流下的疲劳断裂机理",
|
||
"给出刀头表面喷丸强化与声学阻抗匹配方案",
|
||
"交付正文 output.md",
|
||
},
|
||
},
|
||
}
|
||
}
|
||
freeBytes, _ := json.Marshal(freeTaskReq)
|
||
freeResp, err := client.Post(ts.URL+"/api/v1/tasks/run", "application/json", bytes.NewReader(freeBytes))
|
||
if err != nil {
|
||
t.Fatalf("run free task failed: %v", err)
|
||
}
|
||
var freeRes struct {
|
||
Success bool `json:"success"`
|
||
ArtifactTitle string `json:"artifact_title"`
|
||
WorkDir string `json:"work_dir"`
|
||
}
|
||
if freeResp.StatusCode != http.StatusOK {
|
||
t.Fatalf("run free task HTTP %d", freeResp.StatusCode)
|
||
}
|
||
_ = json.NewDecoder(freeResp.Body).Decode(&freeRes)
|
||
freeResp.Body.Close()
|
||
if !freeRes.Success {
|
||
t.Fatalf("free ad-hoc task failed: artifact=%q work_dir=%q", freeRes.ArtifactTitle, freeRes.WorkDir)
|
||
}
|
||
t.Logf("✓ [自由非标任务完成] 任务成功=%v, 交付物=[%s], work_dir=%s", freeRes.Success, freeRes.ArtifactTitle, freeRes.WorkDir)
|
||
logStep("步骤 3 自由非标任务完成")
|
||
|
||
// 7. 若开启全量阶段环境变量 (RUN_HEAVY_STAGES=1),则继续演练 V0.5 与 V1.0
|
||
if os.Getenv("RUN_HEAVY_STAGES") == "1" {
|
||
t.Log("--------------------------------------------------------------------------------")
|
||
t.Log(">>> 步骤 4: 推进工作流成熟度阶段 V0.5 (法规合规与注册可行性论证)")
|
||
t.Log("--------------------------------------------------------------------------------")
|
||
|
||
stageReqV05 := bizdev.StageRunRequest{
|
||
Stage: bizdev.StageV05_Feasibility,
|
||
ProjectID: projectID,
|
||
ProjectName: projectName,
|
||
CustomerName: customerName,
|
||
DeviceType: deviceType,
|
||
IntendedUse: intendedUse,
|
||
TargetMarket: targetMarket,
|
||
Backend: "pi-agent",
|
||
UserID: authorID,
|
||
AuthorID: authorID,
|
||
AuthorName: authorName,
|
||
ReviewerID: reviewerID,
|
||
ReviewerName: reviewerName,
|
||
ApproverID: approverID,
|
||
ApproverName: approverName,
|
||
}
|
||
|
||
resV05, err := svc.ExecuteStage(ctx, stageReqV05)
|
||
if err != nil {
|
||
t.Fatalf("Stage V0.5 execution failed: %v", err)
|
||
}
|
||
if !resV05.Success {
|
||
t.Fatalf("Stage V0.5 should succeed, got error: %s", resV05.ErrorReason)
|
||
}
|
||
if resV05.PrimaryArtifact != nil && resV05.PrimaryArtifact.DocRecord != nil {
|
||
targetDocID = resV05.PrimaryArtifact.DocRecord.DocID
|
||
t.Logf("✓ [V0.5 阶段完成] 法规可行性交付物: %s, DocID=%s, 成熟度评分=%d",
|
||
resV05.PrimaryArtifact.ArtifactTitle, targetDocID, resV05.AdvisorBrief.MaturityScore)
|
||
}
|
||
|
||
t.Log("--------------------------------------------------------------------------------")
|
||
t.Log(">>> 步骤 4.2: 推进成熟度阶段 V1.0 (商业锁定与 CDO 报价 Excel 自动测算)")
|
||
t.Log("--------------------------------------------------------------------------------")
|
||
|
||
stageReqV10 := bizdev.StageRunRequest{
|
||
Stage: bizdev.StageV10_Quoting,
|
||
ProjectID: projectID,
|
||
ProjectName: projectName,
|
||
CustomerName: customerName,
|
||
DeviceType: deviceType,
|
||
IntendedUse: intendedUse,
|
||
TargetMarket: targetMarket,
|
||
Backend: "pi-agent",
|
||
UserID: authorID,
|
||
AuthorID: authorID,
|
||
AuthorName: authorName,
|
||
ReviewerID: reviewerID,
|
||
ReviewerName: reviewerName,
|
||
ApproverID: approverID,
|
||
ApproverName: approverName,
|
||
}
|
||
|
||
resV10, err := svc.ExecuteStage(ctx, stageReqV10)
|
||
if err != nil {
|
||
t.Fatalf("Stage V1.0 execution failed: %v", err)
|
||
}
|
||
if !resV10.Success {
|
||
t.Fatalf("Stage V1.0 should succeed, got error: %s", resV10.ErrorReason)
|
||
}
|
||
t.Logf("✓ [V1.0 阶段完成] 商业报价交付物: %s, 成熟度评分=%d (%s)",
|
||
resV10.PrimaryArtifact.ArtifactTitle, resV10.AdvisorBrief.MaturityScore, resV10.AdvisorBrief.MaturityLevel)
|
||
}
|
||
|
||
// 8. 受控文档生命周期演练: 编制人认领 -> 提交审核 -> 审核人批注 -> 审核驳回 -> 编制人修改并再提交 -> 审核人通过 -> 批准人最终批准
|
||
t.Log("--------------------------------------------------------------------------------")
|
||
t.Log(">>> 步骤 5: 受控文档闭环治理演练 (认领 -> 批注 -> 审核驳回 -> 修正再送审 -> 批准)")
|
||
t.Log("--------------------------------------------------------------------------------")
|
||
|
||
// 8.1 编制人【张工】查阅并显式认领
|
||
claimReq := map[string]any{
|
||
"doc_id": targetDocID,
|
||
"user_id": authorID,
|
||
"user_name": authorName,
|
||
}
|
||
respClaim, err := authedPost("/api/v1/docgov/claim", authorToken, claimReq)
|
||
expectOK("claim", respClaim, err)
|
||
t.Logf("✓ [张工] 成功认领受控文档: DocID=%s", targetDocID)
|
||
|
||
// 8.2 编制人【张工】送审 (进入 StateReviewing)
|
||
submitReq := map[string]any{
|
||
"doc_id": targetDocID,
|
||
"author_id": authorID,
|
||
"note": "初稿编制完成,已包含 24kHz/35kHz 双频超声与灌注吸引合规说明,请主管审核。",
|
||
}
|
||
respSub, err := authedPost("/api/v1/docgov/submit-review", authorToken, submitReq)
|
||
expectOK("submit-review", respSub, err)
|
||
t.Logf("✓ [张工] 提交审核成功,文档进入锁定审核态")
|
||
|
||
// 8.3 【审核人·李主管】介入并添加批注
|
||
commReq := map[string]any{
|
||
"doc_id": targetDocID,
|
||
"user_id": reviewerID,
|
||
"user_name": reviewerName,
|
||
"user_role": "reviewer",
|
||
"target_section": "第三章:注册检验与电气安全合规路径",
|
||
"comment_type": "risk",
|
||
"content": "请补充说明超声刀头与高频手术设备(高频电刀)联合使用时,GB 9706.202 及 YY 0644 隔离抗扰度测试要求。",
|
||
}
|
||
cmb, _ := json.Marshal(commReq)
|
||
respComm, err := client.Post(ts.URL+"/api/v1/docgov/comments", "application/json", bytes.NewReader(cmb))
|
||
if err != nil || respComm.StatusCode != http.StatusOK {
|
||
t.Fatalf("add comment failed: %v", err)
|
||
}
|
||
respComm.Body.Close()
|
||
t.Logf("✓ [李主管] 审核批注已录入")
|
||
|
||
// 8.4 【审核人·李主管】第一次执行【驳回 (Reject)】以验证受控纠偏流
|
||
rejReq := map[string]any{
|
||
"doc_id": targetDocID,
|
||
"user_id": reviewerID,
|
||
"user_name": reviewerName,
|
||
"action": "reject",
|
||
"reason": "缺少高频电刀共模互扰防范机制论证,打回草稿态由编制人张工补充后重新送审。",
|
||
}
|
||
respRej, err := authedPost("/api/v1/docgov/review-decision", reviewerToken, rejReq)
|
||
expectOK("review reject", respRej, err)
|
||
t.Logf("✓ [李主管] 驳回成功,受控文档自动解锁并退回 drafting 状态")
|
||
|
||
// 8.5 编制人【张工】再次送审
|
||
submitReq2 := map[string]any{
|
||
"doc_id": targetDocID,
|
||
"author_id": authorID,
|
||
"note": "已补充 GB 9706.202 绝缘隔离与高频消磁屏蔽方案,重新送审。",
|
||
}
|
||
respSub2, err := authedPost("/api/v1/docgov/submit-review", authorToken, submitReq2)
|
||
expectOK("resubmit", respSub2, err)
|
||
t.Logf("✓ [张工] 补充材料后再次送审成功")
|
||
|
||
// 8.6 【审核人·李主管】第二次审核 -> 审核通过 (Pass)
|
||
passReq := map[string]any{
|
||
"doc_id": targetDocID,
|
||
"user_id": reviewerID,
|
||
"user_name": reviewerName,
|
||
"action": "approve",
|
||
"comment": "方案已完善,GB 9706.202 及 YY 0644 检验项齐备,同意流转至王总监终审。",
|
||
}
|
||
respPass, err := authedPost("/api/v1/docgov/review-decision", reviewerToken, passReq)
|
||
expectOK("review approve", respPass, err)
|
||
t.Logf("✓ [李主管] 审核通过,文档流转至待批准态 (StateApproving)")
|
||
|
||
// 8.7 【批准人·王总监】最终批准 (Approve)
|
||
apprReq := map[string]any{
|
||
"doc_id": targetDocID,
|
||
"user_id": approverID,
|
||
"user_name": approverName,
|
||
"action": "approve",
|
||
"comment": "符合创新微创手术吸引系统三年规划,同意立项并进入工程实施与核价锁定。",
|
||
}
|
||
respAppr, err := authedPost("/api/v1/docgov/approver-decision", approverToken, apprReq)
|
||
expectOK("approver decision", respAppr, err)
|
||
t.Logf("✓ [王总监] 最终批准成功,文档彻底锁定生效归档 (StateApproved)")
|
||
|
||
// 9. 验证全生命周期数据落盘与审计追溯
|
||
t.Log("--------------------------------------------------------------------------------")
|
||
t.Log(">>> 步骤 6: 审计与数据库落盘全面核查 (会话、消息、文档状态、审计日志)")
|
||
t.Log("--------------------------------------------------------------------------------")
|
||
|
||
// 9.1 验证审计日志 API (GET /api/v1/docgov/audit-logs)
|
||
respAudit, err := client.Get(fmt.Sprintf("%s/api/v1/docgov/audit-logs?doc_id=%s", ts.URL, targetDocID))
|
||
if err != nil || respAudit.StatusCode != http.StatusOK {
|
||
t.Fatalf("list audit logs failed: %v", err)
|
||
}
|
||
var auditLogs []docgov.AuditLog
|
||
_ = json.NewDecoder(respAudit.Body).Decode(&auditLogs)
|
||
respAudit.Body.Close()
|
||
|
||
if len(auditLogs) < 5 {
|
||
t.Errorf("expected at least 5 audit logs, got %d", len(auditLogs))
|
||
}
|
||
t.Logf("✓ 成功追溯受控文档全生命周期审计轨迹流 (%d 条记录):", len(auditLogs))
|
||
for idx, log := range auditLogs {
|
||
t.Logf(" [%d] %s | 操作人: %s (%s) | 动作: %s -> %s",
|
||
idx+1, log.CreatedAt.Format("15:04:05"), log.ActorName, log.ActorRole, log.FromState, log.ToState)
|
||
}
|
||
|
||
// 10.2 验证历史对话消息持久化 (GET /api/v1/chat/messages)
|
||
respMsgs, err := client.Get(fmt.Sprintf("%s/api/v1/chat/messages?session_id=%s", ts.URL, sessionID))
|
||
if err != nil || respMsgs.StatusCode != http.StatusOK {
|
||
t.Fatalf("get chat messages failed: %v", err)
|
||
}
|
||
var msgList []ctxmgr.ChatMessageRecord
|
||
_ = json.NewDecoder(respMsgs.Body).Decode(&msgList)
|
||
respMsgs.Body.Close()
|
||
|
||
if len(msgList) < 4 {
|
||
t.Errorf("expected at least 4 recorded messages, got %d", len(msgList))
|
||
}
|
||
t.Logf("✓ 成功查询并追溯全周期真实多轮对话持久化记录 (%d 条):", len(msgList))
|
||
for idx, m := range msgList {
|
||
roleName := "智能体参谋 (pi-agent/MiniMax)"
|
||
if m.Role == "user" {
|
||
roleName = "用户 (张工)"
|
||
}
|
||
summaryContent := m.Content
|
||
if len(summaryContent) > 60 {
|
||
summaryContent = summaryContent[:60] + "..."
|
||
}
|
||
t.Logf(" [%d] [%s] %s: %s (意图: %s, 分支: %s)",
|
||
idx+1, m.CreatedAt.Format("15:04:05"), roleName, summaryContent, m.Intent, m.Branch)
|
||
}
|
||
|
||
// 10.3 验证工作区物理产物存在
|
||
wsPath := filepath.Join(cfg.DataDir, "workspaces")
|
||
if fi, err := os.Stat(wsPath); err == nil && fi.IsDir() {
|
||
t.Logf("✓ 物理工作区目录正常落盘: %s", wsPath)
|
||
}
|
||
}
|