Unify auth middleware and remove X-User-ID trust, add project_members authorization, sandbox workspaces via resolver, and admin-only reconcile by run_id. Introduce goose migrations with startup schema gate, fail closed when DB unavailable outside explicit ALLOW_IN_MEMORY, bundle web/dist in app Dockerfile, and remove frontend pseudo-login fallback.
459 lines
18 KiB
Go
459 lines
18 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/server"
|
|
"chat-agent-system/internal/squad"
|
|
)
|
|
|
|
// TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E
|
|
// 贯穿:新建项目 -> 角色权限配置 -> 分角色材料补充与真实对话 -> 自由非标任务执行 -> 工作流推进 -> 审核驳回/再批 -> 数据库落盘与全量审计追溯
|
|
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
|
|
cfg := &config.Config{
|
|
Env: "dev",
|
|
AgentBackend: "pi-agent",
|
|
PiAgentRunnerURL: "http://127.0.0.1:18865",
|
|
PiAgentModel: "MiniMax-M3",
|
|
PiAgentProvider: "minimax",
|
|
DataDir: t.TempDir(),
|
|
AllowInMemory: true,
|
|
}
|
|
|
|
svc := bizdev.MustNewService(t, cfg)
|
|
srv := server.NewServer(cfg, svc)
|
|
ts := httptest.NewServer(srv.Routes())
|
|
defer ts.Close()
|
|
|
|
client := &http.Client{Timeout: 10 * time.Minute}
|
|
ctx := context.Background()
|
|
|
|
// 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",
|
|
healthData.Env, healthData.AgentBackend, healthData.SharedKBPort)
|
|
|
|
// 3. 项目基本信息与三级角色定义
|
|
projectID := int64(time.Now().UnixNano()%900000 + 100000)
|
|
projectName := "数字化高频超声手术吸引系统 (CUSA-Ultra)"
|
|
customerName := "北京协和神外微创转化中心"
|
|
deviceType := "高频超声手术吸引系统"
|
|
intendedUse := "神经外科与肝胆外科精密手术中利用 24kHz-35kHz 超声微幅振动选择性粉碎并吸引病灶组织,保留邻近大血管与神经"
|
|
targetMarket := "中国NMPA三类创新医疗器械"
|
|
|
|
authorID := int64(1)
|
|
authorName := "张工"
|
|
reviewerID := int64(2)
|
|
reviewerName := "李主管"
|
|
approverID := int64(3)
|
|
approverName := "王总监"
|
|
|
|
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)
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
_ = json.NewDecoder(freeResp.Body).Decode(&freeRes)
|
|
freeResp.Body.Close()
|
|
t.Logf("✓ [自由非标任务完成] 任务成功=%v, 交付物=[%s]", freeRes.Success, freeRes.ArtifactTitle)
|
|
|
|
// 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,
|
|
}
|
|
cb, _ := json.Marshal(claimReq)
|
|
respClaim, err := client.Post(ts.URL+"/api/v1/docgov/claim", "application/json", bytes.NewReader(cb))
|
|
if err != nil || respClaim.StatusCode != http.StatusOK {
|
|
t.Fatalf("claim failed: %v", err)
|
|
}
|
|
respClaim.Body.Close()
|
|
t.Logf("✓ [张工] 成功认领受控文档: DocID=%s", targetDocID)
|
|
|
|
// 8.2 编制人【张工】送审 (进入 StateReviewing)
|
|
submitReq := map[string]any{
|
|
"doc_id": targetDocID,
|
|
"author_id": authorID,
|
|
"note": "初稿编制完成,已包含 24kHz/35kHz 双频超声与灌注吸引合规说明,请主管审核。",
|
|
}
|
|
sb, _ := json.Marshal(submitReq)
|
|
respSub, err := client.Post(ts.URL+"/api/v1/docgov/submit-review", "application/json", bytes.NewReader(sb))
|
|
if err != nil || respSub.StatusCode != http.StatusOK {
|
|
t.Fatalf("submit-review failed: %v", err)
|
|
}
|
|
respSub.Body.Close()
|
|
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": "缺少高频电刀共模互扰防范机制论证,打回草稿态由编制人张工补充后重新送审。",
|
|
}
|
|
rjb, _ := json.Marshal(rejReq)
|
|
respRej, err := client.Post(ts.URL+"/api/v1/docgov/review-decision", "application/json", bytes.NewReader(rjb))
|
|
if err != nil || respRej.StatusCode != http.StatusOK {
|
|
t.Fatalf("review reject failed: %v", err)
|
|
}
|
|
respRej.Body.Close()
|
|
t.Logf("✓ [李主管] 驳回成功,受控文档自动解锁并退回 drafting 状态")
|
|
|
|
// 8.5 编制人【张工】再次送审
|
|
submitReq2 := map[string]any{
|
|
"doc_id": targetDocID,
|
|
"author_id": authorID,
|
|
"note": "已补充 GB 9706.202 绝缘隔离与高频消磁屏蔽方案,重新送审。",
|
|
}
|
|
sb2, _ := json.Marshal(submitReq2)
|
|
respSub2, err := client.Post(ts.URL+"/api/v1/docgov/submit-review", "application/json", bytes.NewReader(sb2))
|
|
if err != nil || respSub2.StatusCode != http.StatusOK {
|
|
t.Fatalf("resubmit failed: %v", err)
|
|
}
|
|
respSub2.Body.Close()
|
|
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 检验项齐备,同意流转至王总监终审。",
|
|
}
|
|
pb, _ := json.Marshal(passReq)
|
|
respPass, err := client.Post(ts.URL+"/api/v1/docgov/review-decision", "application/json", bytes.NewReader(pb))
|
|
if err != nil || respPass.StatusCode != http.StatusOK {
|
|
t.Fatalf("review approve failed: %v", err)
|
|
}
|
|
respPass.Body.Close()
|
|
t.Logf("✓ [李主管] 审核通过,文档流转至待批准态 (StateApproving)")
|
|
|
|
// 8.7 【批准人·王总监】最终批准 (Approve)
|
|
apprReq := map[string]any{
|
|
"doc_id": targetDocID,
|
|
"user_id": approverID,
|
|
"user_name": approverName,
|
|
"action": "approve",
|
|
"comment": "符合创新微创手术吸引系统三年规划,同意立项并进入工程实施与核价锁定。",
|
|
}
|
|
apb, _ := json.Marshal(apprReq)
|
|
respAppr, err := client.Post(ts.URL+"/api/v1/docgov/approver-decision", "application/json", bytes.NewReader(apb))
|
|
if err != nil || respAppr.StatusCode != http.StatusOK {
|
|
t.Fatalf("approver decision failed: %v", err)
|
|
}
|
|
respAppr.Body.Close()
|
|
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)
|
|
}
|
|
}
|