feat(projectcontext): bizdev 注入项目对话并完成 PG 治理 E2E

新增 ctxmgr 项目对话采集并接入 PM/TE BuildSnapshot;PG 测试统一 TEST_DATABASE_URL 并使用三正式工作流 key。
This commit is contained in:
weichen
2026-09-13 02:50:23 +00:00
committed by weichen
parent a6501e5b31
commit d6f5e0e28a
12 changed files with 468 additions and 115 deletions
+2 -2
View File
@@ -18,9 +18,9 @@
| BUG-10 | 无审计视图 | PASS | `recent_workflow_events` in settings + UI | 已完成 | | BUG-10 | 无审计视图 | PASS | `recent_workflow_events` in settings + UI | 已完成 |
| BUG-11 | 旧 API 未下线 | PARTIAL | 治理开启时 410 | 保持 + 文档标注 | | BUG-11 | 旧 API 未下线 | PARTIAL | 治理开启时 410 | 保持 + 文档标注 |
| BUG-12 | 前端名称硬编码 | PASS | `ProjectWorkflowSwitcher` catalog API | 已完成 | | BUG-12 | 前端名称硬编码 | PASS | `ProjectWorkflowSwitcher` catalog API | 已完成 |
| MISSING-01 | 上下文快照 0% | PARTIAL | `projectcontext/service.go` 五层源 + 私有对话排除计数 | PM/TE bizdev 传入 Dialogue 待加深 | | MISSING-01 | 上下文快照 0% | PASS | `ctxmgr.CollectProjectDialogue` + bizdev `collectSnapshotDialogueInputs` → `BuildSnapshot` | 已完成 |
| MISSING-02 | 终止 0% | PASS | `GetTerminationPreview` + 取消在途 + 前端 | 已完成 | | MISSING-02 | 终止 0% | PASS | `GetTerminationPreview` + 取消在途 + 前端 | 已完成 |
| MISSING-04 | E2E 0% | PARTIAL | `e2e_workflow_governance_test.go` 单工作流完成→planning | 三正式工作流 + 真实 PI Agent 待 test 分支补跑 | | MISSING-04 | E2E 0% | PARTIAL | PG E2E:`test_manager` 完成→planning + `main` 激活/批准/推进(test 库 PASS) | 三流程 + 真实 PI Agent 全链路待补跑 |
**结论**: Phase A 全部 PASS;Phase B/C 核心项 PASS,E2E 与上下文快照仍有 PARTIAL。test 分支合并前需 `TEST_DATABASE_URL` + 真实 PI Agent 全链路验收。 **结论**: Phase A 全部 PASS;Phase B/C 核心项 PASS,E2E 与上下文快照仍有 PARTIAL。test 分支合并前需 `TEST_DATABASE_URL` + 真实 PI Agent 全链路验收。
@@ -7,7 +7,7 @@
## 二、 测试目标 (Test Objectives) ## 二、 测试目标 (Test Objectives)
- Phase A:生命周期补测、推进不改文档、禁用文档、对话不切 bizdev、token 一次性、DocumentAccessDecider + HTTP 409 - Phase A:生命周期补测、推进不改文档、禁用文档、对话不切 bizdev、token 一次性、DocumentAccessDecider + HTTP 409
- Phase B:终止 preview、E2E 完成→planning、上下文快照可复现(含私有对话排除计数) - Phase B:终止 preview、E2E 完成→planning、上下文快照可复现(bizdev→projectcontext Dialogue 集成)
- Phase C:catalog 驱动名称、版本冲突提示、settings 审计事件 - Phase C:catalog 驱动名称、版本冲突提示、settings 审计事件
## 三、 测试方案与环境 (Test Plan & Environment) ## 三、 测试方案与环境 (Test Plan & Environment)
@@ -38,25 +38,47 @@
**判定**: PASS — live 实例存在时 `ExecuteStage` 调用次数为 0 **判定**: PASS — live 实例存在时 `ExecuteStage` 调用次数为 0
### 5.3 上下文快照 ### 5.3 上下文快照与 Dialogue 集成
**命令**: `go test ./internal/projectcontext/... -count=1` **命令**:
```bash
go test ./internal/ctxmgr/... ./internal/bizdev/... ./internal/projectcontext/... \
-run 'CollectProjectDialogue|CollectSnapshot|BuildSnapshot' -count=1 -v
```
**判定**: PASS — 同输入 hash 可复现;`[private]`/`[draft]` 对话计入 `ExcludedPrivateCount` 且不进入来源清单 **判定**: PASS — bizdev `system_reqs`/`test_manager` 执行路径经 `CollectProjectDialogue` 注入共享对话与里程碑决策;私有/禁用导出对话被排除
### 5.4 PG E2E(需 TEST_DATABASE_URL) ### 5.4 PG E2E(隔离 test 库)
**命令**: `TEST_DATABASE_URL=... go test ./internal/server/... -run TestE2E_WorkflowGovernance_CompleteAndReplan -count=1 -v` **环境**: `TEST_DATABASE_URL=postgres://app:***@127.0.0.1:5434/agent_system_test`(configs/.env.test 对齐)
**判定**: SKIP 或 PASS(视 CI 是否配置隔离库) **命令**:
```bash
TEST_DATABASE_URL='postgres://app:test_secret_db_pass@127.0.0.1:5434/agent_system_test' \
go test ./internal/server/... \
-run 'TestE2E_WorkflowGovernance_CompleteAndReplan|TestWorkflowGovernance_PG_ActivateApproveAdvanceChain' \
-count=1 -v
```
**实际输出**:
```
--- PASS: TestE2E_WorkflowGovernance_CompleteAndReplan (0.25s)
--- PASS: TestWorkflowGovernance_PG_ActivateApproveAdvanceChain (0.26s)
PASS ok chat-agent-system/internal/server 0.514s
```
**评价判定**: PASS — 正式工作流 `test_manager` 末阶段完成→`planning`;正式工作流 `main` 激活/DocGov 批准/阶段推进链通过
## 六、 自动化测试套件汇总 (Test Suite Execution) ## 六、 自动化测试套件汇总 (Test Suite Execution)
``` ```
go test ./internal/projectinstance/... ./internal/coordinator/... ./internal/projectcontext/... -count=1 go test ./internal/projectinstance/... ./internal/coordinator/... ./internal/projectcontext/... \
./internal/ctxmgr/... ./internal/bizdev/... -count=1
TEST_DATABASE_URL=... go test ./internal/server/... -run 'TestE2E_WorkflowGovernance|TestWorkflowGovernance_PG' -count=1
``` ```
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) ## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
- 整改代码路径已用单元测试锁定;PG 全链路需在 `test` 分支部署前用真实 PI Agent 补跑并更新本报告第五节 5.4 为 PASS 实录。 - Phase A–C 核心路径与 PG 治理 E2E 已通过;`test_manager`/`main` 正式工作流在隔离 test 库验证 PASS。
- 仍待:三流程 + 真实 PI Agent(MiniMax-M3)全链路实测(`TestWorkflowGovernance_PiAgent_PG_FullChain`)写入本报告后再合 main。
- 建议每周运行 `scripts/fix-completed-instance-lifecycle.sql` dry-run 检查历史不一致。 - 建议每周运行 `scripts/fix-completed-instance-lifecycle.sql` dry-run 检查历史不一致。
+39
View File
@@ -0,0 +1,39 @@
package bizdev
import (
"context"
"fmt"
"chat-agent-system/internal/ctxmgr"
)
type snapshotDialogueInputs struct {
Dialogue []string
Decisions []string
ExcludedPrivate int
ExcludedNoAccess int
}
func (s *Service) collectSnapshotDialogueInputs(ctx context.Context, projectID, userID int64) snapshotDialogueInputs {
out := snapshotDialogueInputs{}
if s == nil || projectID <= 0 || s.ctxMgr == nil {
return out
}
if coll, err := s.getProjectContextForUser(ctx, projectID, userID); err == nil && coll != nil {
for _, d := range coll.MilestoneDecisions {
out.Decisions = append(out.Decisions, fmt.Sprintf("[%s] %s (%s)", d.ActivityType, d.Title, d.UserName))
}
}
dialogue, err := s.ctxMgr.CollectProjectDialogue(ctx, projectID, ctxmgr.CollectProjectDialogueOptions{
Limit: 120,
})
if err != nil || dialogue == nil {
return out
}
out.Dialogue = dialogue.Lines
out.ExcludedPrivate = dialogue.ExcludedPrivate
out.ExcludedNoAccess = dialogue.ExcludedNoAccess
return out
}
@@ -0,0 +1,48 @@
package bizdev
import (
"context"
"testing"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/projectcontext"
"chat-agent-system/internal/replyexport"
)
func TestCollectSnapshotDialogueInputs_FeedsBuildSnapshot(t *testing.T) {
ctx := context.Background()
mgr := ctxmgr.NewContextManager(nil, nil, ctxmgr.DefaultTimeDecayConfig())
projectID := int64(601)
meta := map[string]any{"project_id": projectID}
_, _ = mgr.RecordUserMessageWithID(ctx, "sess-601", 1, "user", "测试经理需要覆盖注册分类路径", "", "", meta)
_, _ = mgr.RecordUserMessageWithID(ctx, "sess-601", 1, "assistant", "我会整理测试验证范围与风险点。", "", "", map[string]any{
"project_id": projectID,
"export_policy": string(replyexport.PolicyFull),
"content_kind": string(replyexport.KindDocumentText),
})
_, _ = mgr.RecordActivity(ctx, projectID, 1, "张工", "decision", "确认先做注册分类调研", map[string]any{}, 1, true)
svc := &Service{ctxMgr: mgr}
inputs := svc.collectSnapshotDialogueInputs(ctx, projectID, 1)
if len(inputs.Dialogue) == 0 {
t.Fatalf("expected dialogue lines, got none")
}
if len(inputs.Decisions) != 1 {
t.Fatalf("expected 1 decision, got %d", len(inputs.Decisions))
}
ctxSvc := projectcontext.NewService(nil)
snap, err := ctxSvc.BuildSnapshot(ctx, projectcontext.BuildInput{
ProjectID: projectID,
TaskKey: "test_manager",
Dialogue: inputs.Dialogue,
Decisions: inputs.Decisions,
})
if err != nil {
t.Fatalf("BuildSnapshot: %v", err)
}
if snap.SourceCount < 2 {
t.Fatalf("expected dialogue + decision sources, got %d", snap.SourceCount)
}
}
+6 -1
View File
@@ -378,12 +378,17 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
} }
} }
} }
dialogueInputs := s.collectSnapshotDialogueInputs(ctx, req.ProjectID, req.UserID)
ctxSvc := projectcontext.NewService(s.pool) ctxSvc := projectcontext.NewService(s.pool)
snap, _ := ctxSvc.BuildSnapshot(ctx, projectcontext.BuildInput{ snap, _ := ctxSvc.BuildSnapshot(ctx, projectcontext.BuildInput{
ProjectID: req.ProjectID, TaskKey: req.TaskKey, UserID: req.UserID, ProjectID: req.ProjectID, TaskKey: req.TaskKey, UserID: req.UserID,
ProjectName: req.ProjectName, Facts: facts, Materials: materials, PriorDocs: priorDocs, ProjectName: req.ProjectName, Facts: facts, Materials: materials, PriorDocs: priorDocs,
Knowledge: hits, Knowledge: hits, Dialogue: dialogueInputs.Dialogue, Decisions: dialogueInputs.Decisions,
}) })
if snap != nil {
snap.ExcludedPrivateCount += dialogueInputs.ExcludedPrivate
snap.ExcludedNoAccessCount += dialogueInputs.ExcludedNoAccess
}
if snap != nil { if snap != nil {
if extra := projectcontext.MaterialsForWorkspace(snap, 12); len(extra) > 0 { if extra := projectcontext.MaterialsForWorkspace(snap, 12); len(extra) > 0 {
materials = append(materials, extra...) materials = append(materials, extra...)
+7
View File
@@ -226,6 +226,13 @@ func (m *ContextManager) RecordUserMessageWithID(ctx context.Context, sessionID
} }
m.sessions[sessionID] = sessSum m.sessions[sessionID] = sessSum
} }
if meta != nil {
if pVal, ok := meta["project_id"].(int64); ok && pVal > 0 {
sessSum.ProjectID = pVal
} else if pValFloat, ok := meta["project_id"].(float64); ok && pValFloat > 0 {
sessSum.ProjectID = int64(pValFloat)
}
}
sessSum.MessageCount = len(m.messages[sessionID]) sessSum.MessageCount = len(m.messages[sessionID])
sessSum.LastMessage = content sessSum.LastMessage = content
sessSum.UpdatedAt = time.Now() sessSum.UpdatedAt = time.Now()
+169
View File
@@ -0,0 +1,169 @@
package ctxmgr
import (
"context"
"encoding/json"
"fmt"
"strings"
"chat-agent-system/internal/replyexport"
)
const defaultProjectDialogueLimit = 120
// ProjectDialogueCollectResult 项目级对话采集结果(供上下文快照使用)。
type ProjectDialogueCollectResult struct {
Lines []string
ExcludedPrivate int
ExcludedNoAccess int
AllowedStageKey string
StageFilteredCount int
}
// CollectProjectDialogueOptions 采集选项。
type CollectProjectDialogueOptions struct {
Limit int
AllowedStageKey string // 非空时仅保留无 stage_key 或匹配的对话
}
// CollectProjectDialogue 收集进入项目知识边界的共享对话行。
func (m *ContextManager) CollectProjectDialogue(ctx context.Context, projectID int64, opts CollectProjectDialogueOptions) (*ProjectDialogueCollectResult, error) {
out := &ProjectDialogueCollectResult{AllowedStageKey: opts.AllowedStageKey}
if m == nil || projectID <= 0 {
return out, nil
}
limit := opts.Limit
if limit <= 0 {
limit = defaultProjectDialogueLimit
}
var msgs []ChatMessageRecord
if m.pool != nil {
rows, err := m.pool.Query(ctx, `
SELECT cm.id, cm.session_id, COALESCE(cm.user_id, 0), cm.role, cm.content,
cm.intent, cm.branch, cm.metadata, COALESCE(cm.day_key::text, ''),
COALESCE(cm.category, ''), COALESCE(cm.content_kind, ''),
COALESCE(cm.export_policy, 'none'), cm.created_at
FROM chat_messages cm
JOIN chat_sessions cs ON cs.session_id = cm.session_id
WHERE cs.project_id = $1 AND cm.deleted_at IS NULL
ORDER BY cm.created_at ASC
LIMIT $2
`, projectID, limit)
if err != nil {
return nil, fmt.Errorf("collect project dialogue: %w", err)
}
defer rows.Close()
for rows.Next() {
var rec ChatMessageRecord
var metaJSON []byte
if err := rows.Scan(
&rec.ID, &rec.SessionID, &rec.UserID, &rec.Role, &rec.Content,
&rec.Intent, &rec.Branch, &metaJSON, &rec.DayKey, &rec.Category,
&rec.ContentKind, &rec.ExportPolicy, &rec.CreatedAt,
); err != nil {
return nil, err
}
if len(metaJSON) > 0 {
_ = json.Unmarshal(metaJSON, &rec.Metadata)
}
msgs = append(msgs, rec)
}
} else {
m.mu.RLock()
for sessionID, sessionMsgs := range m.messages {
sess := m.sessions[sessionID]
if sess != nil && sess.ProjectID != projectID {
continue
}
for _, rec := range sessionMsgs {
if sess == nil || sess.ProjectID == 0 {
if pid := messageProjectID(rec); pid != projectID {
continue
}
}
msgs = append(msgs, rec)
}
}
m.mu.RUnlock()
}
for _, rec := range msgs {
line, include, excludedPrivate, excludedNoAccess := classifyDialogueLine(rec, opts.AllowedStageKey)
if excludedPrivate {
out.ExcludedPrivate++
}
if excludedNoAccess {
out.ExcludedNoAccess++
out.StageFilteredCount++
}
if !include || line == "" {
continue
}
out.Lines = append(out.Lines, line)
}
return out, nil
}
func messageProjectID(rec ChatMessageRecord) int64 {
if rec.Metadata == nil {
return 0
}
if pVal, ok := rec.Metadata["project_id"].(int64); ok {
return pVal
}
if pVal, ok := rec.Metadata["project_id"].(float64); ok {
return int64(pVal)
}
return 0
}
func classifyDialogueLine(rec ChatMessageRecord, allowedStageKey string) (line string, include bool, excludedPrivate bool, excludedNoAccess bool) {
content := strings.TrimSpace(rec.Content)
if content == "" {
return "", false, true, false
}
if strings.HasPrefix(content, "[private]") || strings.HasPrefix(content, "[draft]") {
return "", false, true, false
}
if vis, _ := rec.Metadata["visibility"].(string); vis == "private" {
return "", false, true, false
}
stageKey := MessageStageKey(rec.Metadata)
if allowedStageKey != "" && stageKey != "" && stageKey != allowedStageKey {
return "", false, false, true
}
switch rec.Role {
case "user":
line = fmt.Sprintf("用户: %s", content)
return line, true, false, false
case "assistant":
switch rec.ContentKind {
case string(replyexport.KindActionCard), string(replyexport.KindStatusUpdate),
string(replyexport.KindErrorNotice), string(replyexport.KindSystemNotice):
return "", false, true, false
}
switch rec.ExportPolicy {
case string(replyexport.PolicyFull), string(replyexport.PolicyCopyOnly):
line = fmt.Sprintf("助手: %s", content)
return line, true, false, false
default:
return "", false, true, false
}
default:
return "", false, true, false
}
}
// MessageStageKey 从消息 metadata 提取阶段键。
func MessageStageKey(metadata map[string]any) string {
if metadata == nil {
return ""
}
if sk, ok := metadata["stage_key"].(string); ok {
return sk
}
return ""
}
+60
View File
@@ -0,0 +1,60 @@
package ctxmgr
import (
"context"
"testing"
"chat-agent-system/internal/replyexport"
)
func TestCollectProjectDialogue_ExcludesPrivateAndIncludesShared(t *testing.T) {
ctx := context.Background()
mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig())
projectID := int64(501)
meta := map[string]any{"project_id": projectID}
_, _ = mgr.RecordUserMessageWithID(ctx, "sess-501", 1, "user", "[private] 不应纳入", "", "", meta)
_, _ = mgr.RecordUserMessageWithID(ctx, "sess-501", 1, "user", "项目需要支持多用户协作", "", "", meta)
_, _ = mgr.RecordUserMessageWithID(ctx, "sess-501", 1, "assistant", "好的,我会整理需求要点。", "", "", map[string]any{
"project_id": projectID,
"export_policy": string(replyexport.PolicyFull),
"content_kind": string(replyexport.KindDocumentText),
})
res, err := mgr.CollectProjectDialogue(ctx, projectID, CollectProjectDialogueOptions{})
if err != nil {
t.Fatalf("CollectProjectDialogue: %v", err)
}
if len(res.Lines) != 2 {
t.Fatalf("expected 2 shared lines, got %d: %v", len(res.Lines), res.Lines)
}
if res.ExcludedPrivate < 1 {
t.Fatalf("expected excluded private >= 1, got %d", res.ExcludedPrivate)
}
}
func TestCollectProjectDialogue_StageFilter(t *testing.T) {
ctx := context.Background()
mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig())
projectID := int64(502)
_, _ = mgr.RecordUserMessageWithID(ctx, "sess-502", 1, "user", "立项阶段讨论", "", "", map[string]any{
"project_id": projectID, "stage_key": "biz_intake",
})
_, _ = mgr.RecordUserMessageWithID(ctx, "sess-502", 1, "user", "研发阶段讨论", "", "", map[string]any{
"project_id": projectID, "stage_key": "biz_feasibility",
})
res, err := mgr.CollectProjectDialogue(ctx, projectID, CollectProjectDialogueOptions{
AllowedStageKey: "biz_intake",
})
if err != nil {
t.Fatalf("CollectProjectDialogue: %v", err)
}
if len(res.Lines) != 1 {
t.Fatalf("expected 1 stage-filtered line, got %d: %v", len(res.Lines), res.Lines)
}
if res.ExcludedNoAccess != 1 {
t.Fatalf("expected 1 excluded by stage, got %d", res.ExcludedNoAccess)
}
}
+19 -36
View File
@@ -26,36 +26,17 @@ func TestE2E_WorkflowGovernance_CompleteAndReplan(t *testing.T) {
defer pool.Close() defer pool.Close()
ctx := context.Background() ctx := context.Background()
handler, _ := setupPGGovernanceServer(t, pool) handler, srv := setupPGGovernanceServer(t, pool)
creatorToken := loginTestUser(t, handler, "zhanggong", "123456") creatorToken := loginTestUser(t, handler, "zhanggong", "123456")
creatorID := fetchAuthUserID(t, handler, creatorToken) creatorID := fetchAuthUserID(t, handler, creatorToken)
wfSvc := workflowdef.NewService(pool, nil) wfKey := workflowdef.OfficialKeyTestManager
wfKey := "e2e_gov_" + time.Now().Format("150405.000") seedOfficialWorkflowManifest(t, pool, wfKey)
_, ver, err := wfSvc.CreateDefinition(ctx, workflowdef.CreateDefinitionInput{
Key: wfKey, Name: "E2E Governance WF",
Definition: workflowdef.VersionDefinition{
SchemaVersion: 1,
Stages: []workflowdef.StageDef{{
Key: "only_stage", Name: "唯一阶段", Position: 0,
DocTasks: []workflowdef.DocTaskDef{{
Key: "intake", Required: true, Blocking: true, DefaultEnabled: true,
OutputDocType: "intake", OutputDocTitle: "Intake",
}},
}},
},
}, 1)
if err != nil {
t.Fatalf("create workflow: %v", err)
}
if _, err := wfSvc.PublishVersion(ctx, ver.WorkflowID, ver.Version, 1); err != nil {
t.Fatalf("publish workflow: %v", err)
}
var projectID int64 var projectID int64
code := "e2e-gov-" + time.Now().Format("150405.000") code := "e2e-gov-" + time.Now().Format("150405.000")
err = pool.QueryRow(ctx, ` err := pool.QueryRow(ctx, `
INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision) INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision)
VALUES ($1, 'E2E Governance', 'active', $2, 'planning', '', 1, 0) RETURNING id VALUES ($1, 'E2E Governance', 'active', $2, 'planning', '', 1, 0) RETURNING id
`, code, creatorID).Scan(&projectID) `, code, creatorID).Scan(&projectID)
@@ -66,7 +47,7 @@ func TestE2E_WorkflowGovernance_CompleteAndReplan(t *testing.T) {
_, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID)
}) })
reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=1", projectID, wfKey), nil) reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=0", projectID, wfKey), nil)
withBearerAuth(reqDefault, creatorToken) withBearerAuth(reqDefault, creatorToken)
recDefault := httptest.NewRecorder() recDefault := httptest.NewRecorder()
handler.ServeHTTP(recDefault, reqDefault) handler.ServeHTTP(recDefault, reqDefault)
@@ -114,23 +95,25 @@ func TestE2E_WorkflowGovernance_CompleteAndReplan(t *testing.T) {
var activated projectinstance.ActivateResult var activated projectinstance.ActivateResult
_ = json.Unmarshal(recActivate.Body.Bytes(), &activated) _ = json.Unmarshal(recActivate.Body.Bytes(), &activated)
// 批准并推进至完成(复用 governance 测试中的 docgov 路径) docID := fmt.Sprintf("doc_proj%d_test_manager", projectID)
docID := fmt.Sprintf("doc_e2e_%d", projectID) docEngine := srv.BizdevService().DocGov()
// 文档批准链在 workflow_governance_pg_test 已覆盖;此处直接标记实例文档 approved 并推进 _, err = docEngine.CreateOrGetDocument(ctx, docID, projectID, "test_manager", "系统验证方案及计划", "system_verification_planning", "",
_, err = pool.Exec(ctx, ` creatorID, "Author", reviewerID, "Reviewer", approverID, "Approver")
UPDATE project_doc_task_instances dti SET status = 'approved', updated_at = now()
FROM project_stage_instances psi
WHERE dti.stage_instance_id = psi.id AND psi.workflow_instance_id = $1 AND dti.task_key = 'intake'
`, activated.Instance.ID)
if err != nil { if err != nil {
t.Fatalf("mark approved: %v", err) t.Fatalf("create doc: %v", err)
}
_ = docEngine.MarkDocumentContentUpdated(ctx, docID, "hash_e2e_te", false)
_, _ = docEngine.SubmitForReview(ctx, docID, creatorID, "author", "Author")
_, _ = docEngine.ReviewApprove(ctx, docID, reviewerID, "reviewer", "Reviewer", "ok")
_, err = docEngine.ApproverApprove(ctx, docID, approverID, "approver", "Approver", "ok")
if err != nil {
t.Fatalf("approve doc: %v", err)
} }
_ = docID
advanceBody, _ := json.Marshal(projectinstance.AdvanceStageInput{ advanceBody, _ := json.Marshal(projectinstance.AdvanceStageInput{
StageKey: "only_stage", ExpectedConfigRevision: activated.Instance.ConfigRevision, StageKey: "system_verification_planning", ExpectedConfigRevision: activated.Instance.ConfigRevision,
}) })
reqAdvance := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/workflow-instances/%d/stages/only_stage/advance", activated.Instance.ID), bytes.NewReader(advanceBody)) reqAdvance := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/workflow-instances/%d/stages/system_verification_planning/advance", activated.Instance.ID), bytes.NewReader(advanceBody))
withBearerAuth(reqAdvance, creatorToken) withBearerAuth(reqAdvance, creatorToken)
recAdvance := httptest.NewRecorder() recAdvance := httptest.NewRecorder()
handler.ServeHTTP(recAdvance, reqAdvance) handler.ServeHTTP(recAdvance, reqAdvance)
+71
View File
@@ -2,13 +2,24 @@ package server_test
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"os"
"strconv"
"strings"
"testing" "testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"chat-agent-system/internal/auth" "chat-agent-system/internal/auth"
"chat-agent-system/internal/config" "chat-agent-system/internal/config"
"chat-agent-system/internal/db"
"chat-agent-system/internal/workflowdef"
) )
func loginTestAdmin(t *testing.T, handler http.Handler, cfg *config.Config) string { func loginTestAdmin(t *testing.T, handler http.Handler, cfg *config.Config) string {
@@ -67,3 +78,63 @@ func loginTestUser(t *testing.T, handler http.Handler, username, password string
func withUserIDAuth(req *http.Request, token string) { func withUserIDAuth(req *http.Request, token string) {
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Authorization", "Bearer "+token)
} }
func configFromTestDatabaseURL(t *testing.T) *config.Config {
t.Helper()
dsn := db.SkipIfNoTestDatabaseURL(t)
parsed, err := url.Parse(dsn)
if err != nil {
t.Fatalf("parse TEST_DATABASE_URL: %v", err)
}
host, portStr, splitErr := net.SplitHostPort(parsed.Host)
if splitErr != nil {
host = parsed.Host
portStr = "5432"
}
port, _ := strconv.Atoi(portStr)
user := parsed.User.Username()
password, _ := parsed.User.Password()
dbName := strings.TrimPrefix(parsed.Path, "/")
return &config.Config{
Env: "test",
DataDir: t.TempDir(),
AgentBackend: "stub",
AllowInMemory: false,
ContextDbHost: host,
ContextDbPort: port,
ContextDbUser: user,
ContextDbPassword: password,
ContextDbName: dbName,
AdminUsername: envOr("ADMIN_USERNAME", "admin"),
AdminPassword: envOr("ADMIN_PASSWORD", "admin-secret-password-123"),
}
}
func envOr(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return fallback
}
func seedOfficialWorkflowManifest(t *testing.T, pool *pgxpool.Pool, workflowKey string) {
t.Helper()
filename, ok := workflowdef.V2ManifestFiles[workflowKey]
if !ok {
t.Fatalf("unknown official workflow key %q", workflowKey)
}
def, err := workflowdef.LoadManifestDefinition(filename)
if err != nil {
t.Fatalf("load manifest %s: %v", filename, err)
}
wfStore := workflowdef.NewStore(pool)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := wfStore.UpsertPublishedSeed(ctx, workflowdef.CreateDefinitionInput{
Key: workflowKey,
Name: workflowdef.OfficialWorkflowName(workflowKey),
Definition: def,
}, 1); err != nil {
t.Fatalf("seed official workflow %s: %v", workflowKey, err)
}
}
+11 -50
View File
@@ -14,7 +14,6 @@ import (
"chat-agent-system/internal/auth" "chat-agent-system/internal/auth"
"chat-agent-system/internal/bizdev" "chat-agent-system/internal/bizdev"
"chat-agent-system/internal/config"
"chat-agent-system/internal/db" "chat-agent-system/internal/db"
"chat-agent-system/internal/notification" "chat-agent-system/internal/notification"
"chat-agent-system/internal/projectinstance" "chat-agent-system/internal/projectinstance"
@@ -49,9 +48,10 @@ func openPGPoolWithMigrations(t *testing.T) *pgxpool.Pool {
t.Skip("workflow/project instance tables not ready") t.Skip("workflow/project instance tables not ready")
} }
if auth.IAMTablesReady(ctx, pool) { if auth.IAMTablesReady(ctx, pool) {
authStore, err := auth.NewPgStore(pool, &config.Config{Env: "dev"}) testCfg := configFromTestDatabaseURL(t)
authStore, err := auth.NewPgStore(pool, testCfg)
if err == nil { if err == nil {
_ = auth.ReactivateDevSeedUsers(ctx, authStore, &config.Config{Env: "dev"}) _ = auth.ReactivateDevSeedUsers(ctx, authStore, testCfg)
} }
} }
return pool return pool
@@ -82,19 +82,7 @@ func fetchAuthUserID(t *testing.T, handler http.Handler, token string) int64 {
func setupPGGovernanceServer(t *testing.T, pool *pgxpool.Pool) (http.Handler, *server.Server) { func setupPGGovernanceServer(t *testing.T, pool *pgxpool.Pool) (http.Handler, *server.Server) {
t.Helper() t.Helper()
cfg := &config.Config{ cfg := configFromTestDatabaseURL(t)
Env: "dev",
DataDir: t.TempDir(),
AgentBackend: "stub",
AllowInMemory: false,
ContextDbHost: "127.0.0.1",
ContextDbPort: 5433,
ContextDbUser: "app",
ContextDbPassword: "dev_secret_db_pass",
ContextDbName: "agent_system_dev",
AdminUsername: "admin",
AdminPassword: "admin-secret-password-123",
}
svc := bizdev.MustNewService(t, cfg) svc := bizdev.MustNewService(t, cfg)
if svc.Pool() == nil { if svc.Pool() == nil {
t.Skip("bizdev service not connected to postgres") t.Skip("bizdev service not connected to postgres")
@@ -113,36 +101,8 @@ func TestWorkflowGovernance_PG_ActivateApproveAdvanceChain(t *testing.T) {
ctx := context.Background() ctx := context.Background()
handler, srv := setupPGGovernanceServer(t, pool) handler, srv := setupPGGovernanceServer(t, pool)
wfSvc := workflowdef.NewService(pool, nil) wfKey := workflowdef.OfficialKeyMain
wfKey := "pg_p5_gov_" + time.Now().Format("150405.000") seedOfficialWorkflowManifest(t, pool, wfKey)
_, ver, err := wfSvc.CreateDefinition(ctx, workflowdef.CreateDefinitionInput{
Key: wfKey, Name: "PG Phase5 Governance WF",
Definition: workflowdef.VersionDefinition{
SchemaVersion: 1,
Stages: []workflowdef.StageDef{
{
Key: "biz_intake", Name: "商务摸底", Position: 0,
DocTasks: []workflowdef.DocTaskDef{{
Key: "intake", Required: true, Blocking: true, DefaultEnabled: true,
OutputDocType: "intake", OutputDocTitle: "客户需求采集",
}},
},
{
Key: "biz_feasibility", Name: "法规可行性", Position: 1, Optional: true,
DocTasks: []workflowdef.DocTaskDef{{
Key: "feasibility_reg", Required: false, Blocking: false, DefaultEnabled: true,
OutputDocType: "feasibility", OutputDocTitle: "法规路径评估",
}},
},
},
},
}, 1)
if err != nil {
t.Fatalf("create workflow def: %v", err)
}
if _, err := wfSvc.PublishVersion(ctx, ver.WorkflowID, ver.Version, 1); err != nil {
t.Fatalf("publish workflow: %v", err)
}
creatorToken := loginTestUser(t, handler, "zhanggong", "123456") creatorToken := loginTestUser(t, handler, "zhanggong", "123456")
guestToken := loginTestUser(t, handler, "guest", "123456") guestToken := loginTestUser(t, handler, "guest", "123456")
@@ -151,6 +111,7 @@ func TestWorkflowGovernance_PG_ActivateApproveAdvanceChain(t *testing.T) {
// 1. 创建项目(PG 直写,与 projectinstance PG 集成测试一致) // 1. 创建项目(PG 直写,与 projectinstance PG 集成测试一致)
var projectID int64 var projectID int64
code := "pg-p5-" + time.Now().Format("150405.000") code := "pg-p5-" + time.Now().Format("150405.000")
var err error
err = pool.QueryRow(ctx, ` err = pool.QueryRow(ctx, `
INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision) INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision)
VALUES ($1, 'PG Phase5 Governance Test', 'active', $2, 'planning', '', 1, 0) RETURNING id VALUES ($1, 'PG Phase5 Governance Test', 'active', $2, 'planning', '', 1, 0) RETURNING id
@@ -170,7 +131,7 @@ func TestWorkflowGovernance_PG_ActivateApproveAdvanceChain(t *testing.T) {
_, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID)
}) })
// 2. 获取默认规划并保存三权 // 2. 获取默认规划并保存三权
reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=1", projectID, wfKey), nil) reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=0", projectID, wfKey), nil)
withBearerAuth(reqDefault, creatorToken) withBearerAuth(reqDefault, creatorToken)
recDefault := httptest.NewRecorder() recDefault := httptest.NewRecorder()
handler.ServeHTTP(recDefault, reqDefault) handler.ServeHTTP(recDefault, reqDefault)
@@ -282,10 +243,10 @@ func TestWorkflowGovernance_PG_ActivateApproveAdvanceChain(t *testing.T) {
// 7. 创建者推进阶段 // 7. 创建者推进阶段
advanceBody, _ := json.Marshal(projectinstance.AdvanceStageInput{ advanceBody, _ := json.Marshal(projectinstance.AdvanceStageInput{
StageKey: "biz_intake", StageKey: "V0.1_triage",
ExpectedConfigRevision: activated.Instance.ConfigRevision, ExpectedConfigRevision: activated.Instance.ConfigRevision,
}) })
reqAdvance := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/workflow-instances/%d/stages/biz_intake/advance", instanceID), bytes.NewReader(advanceBody)) reqAdvance := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/workflow-instances/%d/stages/V0.1_triage/advance", instanceID), bytes.NewReader(advanceBody))
withBearerAuth(reqAdvance, creatorToken) withBearerAuth(reqAdvance, creatorToken)
recAdvance := httptest.NewRecorder() recAdvance := httptest.NewRecorder()
handler.ServeHTTP(recAdvance, reqAdvance) handler.ServeHTTP(recAdvance, reqAdvance)
@@ -296,7 +257,7 @@ func TestWorkflowGovernance_PG_ActivateApproveAdvanceChain(t *testing.T) {
if err := json.Unmarshal(recAdvance.Body.Bytes(), &advanceResult); err != nil { if err := json.Unmarshal(recAdvance.Body.Bytes(), &advanceResult); err != nil {
t.Fatalf("decode advance: %v", err) t.Fatalf("decode advance: %v", err)
} }
if advanceResult.NextStageKey != "biz_feasibility" && !advanceResult.InstanceDone { if advanceResult.NextStageKey != "V0.5_feasibility" && !advanceResult.InstanceDone {
t.Fatalf("unexpected advance result: %+v", advanceResult) t.Fatalf("unexpected advance result: %+v", advanceResult)
} }
@@ -13,7 +13,6 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"chat-agent-system/internal/bizdev" "chat-agent-system/internal/bizdev"
"chat-agent-system/internal/config"
"chat-agent-system/internal/notification" "chat-agent-system/internal/notification"
"chat-agent-system/internal/projectinstance" "chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/projectworkflow" "chat-agent-system/internal/projectworkflow"
@@ -34,22 +33,11 @@ func piAgentRunnerHealthy() bool {
func setupPGPiAgentServer(t *testing.T, pool *pgxpool.Pool) (http.Handler, *server.Server) { func setupPGPiAgentServer(t *testing.T, pool *pgxpool.Pool) (http.Handler, *server.Server) {
t.Helper() t.Helper()
cfg := &config.Config{ cfg := configFromTestDatabaseURL(t)
Env: "dev", cfg.AgentBackend = "pi-agent"
DataDir: t.TempDir(), cfg.PiAgentRunnerURL = "http://127.0.0.1:18865"
AgentBackend: "pi-agent", cfg.PiAgentModel = "MiniMax-M3"
PiAgentRunnerURL: "http://127.0.0.1:18865", cfg.PiAgentProvider = "minimax"
PiAgentModel: "MiniMax-M3",
PiAgentProvider: "minimax",
AllowInMemory: false,
ContextDbHost: "127.0.0.1",
ContextDbPort: 5433,
ContextDbUser: "app",
ContextDbPassword: "dev_secret_db_pass",
ContextDbName: "agent_system_dev",
AdminUsername: "admin",
AdminPassword: "admin-secret-password-123",
}
svc := bizdev.MustNewService(t, cfg) svc := bizdev.MustNewService(t, cfg)
if svc.Pool() == nil { if svc.Pool() == nil {
t.Skip("bizdev service not connected to postgres") t.Skip("bizdev service not connected to postgres")