Files
weichenandCursor 6fa933ac27 fix(kb): return offline client instead of Fatalf under require-online
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>
2026-09-17 02:04:51 +00:00

548 lines
21 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}