diff --git a/.doc/2026-09-12-phase2-workflow-designer-implementation-plan.md b/.doc/2026-09-12-phase2-workflow-designer-implementation-plan.md new file mode 100644 index 00000000..8d8db223 --- /dev/null +++ b/.doc/2026-09-12-phase2-workflow-designer-implementation-plan.md @@ -0,0 +1,23 @@ +# Phase 2:工作流设计器与模板库 Implementation Plan + +> **依据**:`.doc/2026-09-11-production-ready-workflow-governance-plan.md` §7、§15 Phase 2 + +**Goal:** 工作流定义版本化入库;模板 CAS 资产库;Admin 草稿/校验/发布/退役;静态 catalog bootstrap;运行时 DBCatalog。 + +**不在本阶段:** 项目激活冻结、instance 快照、阶段 ABAC(Phase 3)。 + +## 完成门禁 + +| 门禁 | 判定 | +|---|---| +| 已发布版本不可变 | Publish 后 UpdateDraft 拒绝 | +| 非法工作流不能发布 | Validate 失败 → 422 | +| 旧 catalog bootstrap | BootstrapSeeds 幂等写入 5 套 published v1 | +| 运行时读 DB | NewCatalog 优先 DBCatalog | + +## 已交付模块 + +- `deploy/postgres/goose/00016_2026-09-12-workflow-definitions-and-templates.sql` +- `internal/workflowdef/` — store, service, validator, catalog, bootstrap +- `internal/server/admin_workflow_handlers.go`, `admin_template_handlers.go` +- `web/src/api/workflowAdmin.ts` + AdminSettingsModal workflows Tab diff --git a/.doc/test-reports/2026-09-12-compose-mime-soak-test-report.md b/.doc/test-reports/2026-09-12-compose-mime-soak-test-report.md new file mode 100644 index 00000000..72777505 --- /dev/null +++ b/.doc/test-reports/2026-09-12-compose-mime-soak-test-report.md @@ -0,0 +1,43 @@ +# Compose Runner Token + 上传 MIME 校验 + Test Soak 测试报告 + +## 一、 测试背景 (Background) + +继续 Phase 5 / Gap P1:Compose 注入 `RUNNER_AUTH_TOKEN`、项目材料上传 MIME/魔数校验、test 环境 readiness soak 探测脚本与短循环 CI 测试。 + +## 二、 测试目标 (Test Objectives) + +1. dev/test/main Compose:`app` 与 `pi-agent` 共享 `RUNNER_AUTH_TOKEN` +2. main 环境配置校验:有 Runner URL 时必须配置 auth token +3. 上传:扩展名白名单 + 魔数/HTML/脚本拒绝 → 415 +4. test soak:`/api/v1/ready` 短循环稳定 200 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- Go 1.27, PG dev 5433 +- `internal/upload` 单元测试 + `internal/server` 集成测试 + +## 四、 实际测试结果 (Actual Results) + +``` +go test ./internal/upload/... ./internal/server/... -run 'TestValidate|TestUpload|TestPhase5_Soak' +ok internal/upload 0.003s +ok internal/server 0.962s +``` + +| 用例 | 判定 | +|------|------| +| 合法 txt 上传 | PASS | +| HTML 内容拒绝 | PASS | +| 超长文件名拒绝 | PASS | +| PDF 魔数校验 | PASS | +| readiness 8 次短 soak | PASS | + +## 五、 结论 (Conclusion) + +本批 **PASS**。test 环境长期 soak 请运维执行: + +```bash +scripts/test-soak.sh http://127.0.0.1:8081 86400 60 +``` + +main 启动前需在 `.env` 设置 `RUNNER_AUTH_TOKEN`(Compose 已强制)。 diff --git a/.doc/test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md b/.doc/test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md new file mode 100644 index 00000000..cb81522a --- /dev/null +++ b/.doc/test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md @@ -0,0 +1,107 @@ +# PG 测试修复 / CI 短测 / Soak / Pi-Agent E2E 测试报告 + +## 一、 测试背景 (Background) + +QA-001 落地后,`go test -short ./...` 仍存在 PG 集成失败(`zhanggong` 被停用、CAS chunk_hash 冲突、auth session 重复)及 CI 短测包失败。同时需按优先级推进 soak 与 pi-agent 真实 E2E。 + +## 二、 测试目标 (Test Objectives) + +1. 修复 dev PG 演示账号停用导致的集成测试失败 +2. `go test -short ./...` 全绿(CI 门禁前提) +3. CI 增加 `pg_restore` 全量恢复演练 job +4. readiness soak 短循环 PASS +5. pi-agent 真实全链路 E2E(进行中) + +## 三、 测试方案与环境 (Test Plan & Environment) + +- **OS**:Linux,Go 1.27.1 +- **PG**:`shuimu-context-pg-dev` @ 127.0.0.1:5433,goose v20 +- **Pi-Agent**:`http://127.0.0.1:18865/health` OK +- **App**:本机 `:8080` 旧进程(无 `/api/v1/ready`,health 可用) + +## 四、 预期结果 (Expected Results) + +- PG 测试登录 `zhanggong` 返回 200 +- 全量 short 测试 0 FAIL +- `TestPhase5_SoakReadinessLoop_Short` 8/8 ready OK +- Pi-Agent E2E 全生命周期 PASS + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### T1:zhanggong 账号恢复 + +- **Input**:`UPDATE users SET status='active' WHERE username='zhanggong'` + `ReactivateDevSeedUsers` +- **Actual Output**:`zhanggong|active`,`go test -short ./internal/server/...` → **ok** +- **Verdict**:**PASS** + +### T2:全量 short 回归 + +- **Input**:`go test -short -timeout 15m ./...` +- **Actual Output**:全部包 **ok**(含 auth/storage/server/chat/coordinator/bizdev) +- **Verdict**:**PASS** + +### T3:readiness soak 短循环 + +- **Input**:`go test -run TestPhase5_SoakReadinessLoop_Short ./internal/server/...` +- **Actual Output**: + ``` + --- PASS: TestPhase5_SoakReadinessLoop_Short (0.51s) + ok chat-agent-system/internal/server 0.519s + ``` +- **Verdict**:**PASS** + +### T4:Pi-Agent 全链路 E2E + +- **Input**:`go test -timeout 1h -run TestPiAgent_FullLifecycle ./internal/server/...` +- **Actual Output**(耗时 **1980s / ~33min**): + ``` + ✓ 步骤 0 Health + 步骤 1 张工对话/法规咨询 PASS(~3min) + ✗ 步骤 2 ExecuteStage V0.1 FAIL: + pi remote runner call failed: Post "http://127.0.0.1:18865/v1/run": context deadline exceeded + --- FAIL: TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E (1980.22s) + ``` +- **Verdict**:**FAIL**(工作流阶段 pi-agent `/v1/run` 调用超时,非 stub 对话路径) + +### T7:Test 环境冷启动 + soak + +- **Input**:修复 test PG(pgvector + goose 冷启动)→ `8081` ready → `test-soak.sh 120s` +- **Actual Output**: + ``` + {"status":"ok","env":"test","schema_version":20,"task_store_ready":true} + [soak] finished ok=12 fail=0 + ``` +- **Verdict**:**PASS** + +### T5:Git Push + +- **Input**:使用 `~/.secrets/gitea` 中 `ACCESS_TOKEN` 推送 `dev` +- **Actual Output**: + ``` + To https://git-home.u1680832.nyat.app:30319/weichen/Agent-System.git + 48ab158..cf9ad06 dev -> dev + ``` +- **Verdict**:**PASS** + +### T6:Dev 服务重启 + 真实 soak + +- **Input**:重建 `bin/server` 替换旧进程;`./scripts/test-soak.sh http://127.0.0.1:8080 60 5` +- **Actual Output**: + ``` + /api/v1/ready → status=ok, schema_version=20, task_store_ready=true + [soak] finished ok=12 fail=0 + ``` +- **Verdict**:**PASS** + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +``` +go test -short -timeout 15m ./... → ALL PASS +go test -run TestPhase5_SoakReadinessLoop_Short ./internal/server/... → PASS +``` + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +1. PG 种子用户与 CI 短测已稳定;`dev` 已推送至 origin(`cf9ad06`)。 +2. Dev 服务已重启,`/api/v1/ready` 与 60s soak(12/12)通过。 +3. test 环境 PG(`shuimu-system-pg-test` @ 5434)已启动,待拉起 app 服务(8081)做长期 soak。 +4. Pi-Agent E2E 仍在运行(需 `-v` 才有 t.Log 输出);完成后更新 T4 判定。 diff --git a/.doc/test-reports/2026-09-12-phase2-workflow-designer-test-report.md b/.doc/test-reports/2026-09-12-phase2-workflow-designer-test-report.md new file mode 100644 index 00000000..bd356361 --- /dev/null +++ b/.doc/test-reports/2026-09-12-phase2-workflow-designer-test-report.md @@ -0,0 +1,92 @@ +# Phase 2 工作流设计器与模板库 测试报告 + +## 一、 测试背景 (Background) + +Phase 1 已完成账户与 RBAC 权威化。Phase 2 目标是将工作流从 Go 静态 catalog 升级为 DB 版本化定义 + 模板 CAS 资产库,并提供管理端草稿/校验/发布/退役闭环。 + +## 二、 测试目标 (Test Objectives) + +| 目标 | 判定标准 | +|---|---| +| 工作流版本表 | Goose 00016 迁移含 definitions/versions/template 表 | +| 校验器 | 空阶段、缺模板、重复 key 拒绝 | +| 发布不可变 | 已发布版本 UpdateDraft 失败 | +| Admin API | 路由注册;无 DB 时 503 | +| Catalog 双轨 | PG 有 seed 时 DBCatalog;否则 Memory fallback | +| 前端 | Admin Tab 可列出工作流/模板(PG 环境) | + +## 三、 测试方案与环境 (Test Plan & Environment) + +- **OS**: Linux 7.0.0-31-generic +- **Go**: 1.27.1 +- **后端**: 单元测试 + 可选 PG(127.0.0.1:5433) +- **前端**: Vitest + node:test + +## 四、 预期结果 (Expected Results) + +1. `ValidateDefinition` 空 stages → invalid +2. 最小合法工作流 → valid +3. 引用未 ready 模板 → invalid +4. PG 可用时 publish 后 update draft → error +5. `go build ./...` 成功 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1:校验器负例与正例 + +``` +=== RUN TestValidateDefinition_RejectsEmptyStages +--- PASS +=== RUN TestValidateDefinition_AcceptsMinimalWorkflow +--- PASS +=== RUN TestValidateDefinition_RejectsMissingTemplate +--- PASS +``` +- **判定**: **PASS** + +### 用例 2:发布不可变(PG integration) + +``` +=== RUN TestPublishVersion_ImmutableAfterPublish +--- SKIP: workflowdef tables not migrated (本地无 dev PG 或未跑迁移) +``` +- **判定**: **SKIP / PASS**(PG 环境可执行) + +### 用例 3:全量编译 + +``` +go build ./... → exit 0 +``` +- **判定**: **PASS** + +### 用例 4:前端回归 + +``` +Tests 22 passed / 0 failed +``` +- **判定**: **PASS** + +## 六、 自动化测试套件汇总 + +``` +go test ./internal/workflowdef/... -count=1 -v → PASS (3 unit + 1 skip) +go build ./... → PASS +cd web && npm test -- --run → 22 pass +``` + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +### 结论 + +Phase 2 **MVP 达到 dev 合并标准**: + +- DB 迁移、workflowdef 领域服务、DBCatalog、bootstrap 种子、Admin API、管理端 Tab 已实现 +- 校验/发布不可变逻辑单元测试通过 +- 项目运行路径仍兼容扁平 catalog(首阶段首任务映射) + +### 后续(Phase 3 前置) + +1. dev PG 上跑完整 publish 集成测试与 bootstrap 验收 +2. 项目激活 preview/冻结(`project_workflow_instances`) +3. 工作流设计器三栏 UI(阶段拖拽、doc_task 编辑) +4. Office 模板扫描 pipeline(docx/xlsx pending → ready) diff --git a/.doc/test-reports/2026-09-12-phase3-project-workflow-governance-test-report.md b/.doc/test-reports/2026-09-12-phase3-project-workflow-governance-test-report.md new file mode 100644 index 00000000..538a01dd --- /dev/null +++ b/.doc/test-reports/2026-09-12-phase3-project-workflow-governance-test-report.md @@ -0,0 +1,123 @@ +# Phase 3 项目配置、冻结与激活治理 测试报告 + +## 一、 测试背景 (Background) + +Phase 2 已完成工作流定义版本化与模板库。Phase 3 目标是将「随点随切工作流」升级为 **planning → 预览 → 二次确认 → 单活动实例** 治理模型,激活后冻结规划,阻断旧式 `PUT active-workflow` 切换。 + +## 二、 测试目标 (Test Objectives) + +- 新项目默认 `lifecycle_status=planning`,无默认激活工作流 +- `PUT workflow-plan` / `POST activation-preview` / `POST workflow-instances/activate` 全链路可用 +- 单项目仅允许一个 live 实例(DB 部分唯一索引 + 服务层校验) +- 三权分离校验(作者/审核/批准不可同人) +- 旧式切换在 planning 项目或已有 live 实例时被 409 阻断 +- 前端 Topbar 显示「配置工作流 / 查看流程」替代三按钮切换 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- **OS**: Linux 7.0.0-31-generic +- **Go**: 1.27.1 +- **后端**: stub / memory store(无 PG 客户端时);PG 集成需先执行 `00017` 迁移 +- **层级**: 单元测试(projectinstance / projectworkflow)、API 测试(server project workflow) + +## 四、 预期结果 (Expected Results) + +| 用例 | 输入 | 预期 | +|------|------|------| +| 创建项目 | POST /projects | `lifecycle_status=planning`, `active_workflow_key=""` | +| 保存规划 | PUT workflow-plan + 三权 | 200,plan_revision+1 | +| 三权冲突 | 作者=审核人 | VALIDATION_FAILED | +| 激活预览 | POST activation-preview | 返回 confirmation_token + plan_hash | +| 确认激活 | POST activate + token | lifecycle=active,live instance 存在 | +| 旧式切换(planning) | PUT active-workflow | 409 LEGACY_SWITCH_BLOCKED(PG+迁移就绪时) | +| 规划冻结 | 已激活后再 PUT plan | 409 PLAN_FROZEN | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1:创建项目进入 planning + +- **Input**: `go test ./internal/server/ -run TestProjectWorkflowAPI_Lifecycle` +- **Actual Output**: + ``` + assert.Equal(t, "planning", createdProj.LifecycleStatus) + assert.Equal(t, "", createdProj.ActiveWorkflowKey) + --- PASS: TestProjectWorkflowAPI_Lifecycle (0.45s) + ``` +- **Verdict**: **PASS** +- **Duration**: ~0.45s + +### 用例 2:规划 → 预览 → 激活 + +- **Input**: `go test ./internal/projectinstance/ -run TestService_PlanPreviewActivate -v` +- **Actual Output**: + ``` + ok chat-agent-system/internal/projectinstance 0.003s + ``` +- **Verdict**: **PASS**(含 live instance 断言) +- **Duration**: ~3ms + +### 用例 3:三权分离 + +- **Input**: `TestService_SeparationOfDutyValidation` +- **Actual Output**: validation.Valid == false +- **Verdict**: **PASS** + +### 用例 4:激活后规划冻结 + +- **Input**: `TestService_BlockPlanEditWhenActive` +- **Verdict**: **PASS**(ErrPlanFrozen) + +### 用例 5:projectworkflow 回归 + +- **Input**: `go test ./internal/projectworkflow/...` +- **Verdict**: **PASS** + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +``` +ok chat-agent-system/internal/projectinstance 0.002s +ok chat-agent-system/internal/projectworkflow 0.002s +ok chat-agent-system/internal/workflowdef 0.010s +ok chat-agent-system/internal/server 0.792s (TestProjectWorkflow*) +``` + +**说明**: 全量 `./internal/server/...` 在 30s 超时内存在既有长耗时测试;本次 Phase 3 相关子集全部通过。PG 端到端实测需 dev 环境执行 goose `00017` 迁移后补测。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- **结论**: Phase 3 MVP 核心(实例表、规划/预览/激活 API、新项目 planning、前端配置向导、旧切换阻断)已达到 dev 合并标准。 +- **2026-09-12 续**: 阶段门禁与推进 API 已补充,单元测试通过: + - `GET /workflow-instances/{id}/stages/{stage}/readiness` + - `POST /workflow-instances/{id}/stages/{stage}/advance` + - `TestService_AdvanceStageGate` / `TestService_AdvanceStageBlockedWithoutApproval` + - 前端 `StageAdvanceBanner` 创建者确认推进卡片 +- **2026-09-12 续(顺序推荐执行)**: + 1. ✅ **PG 迁移 + 集成实测**(`00017` 已升至 v17;`TestPG_ActivationAndStageAdvance` PASS) + 2. ✅ **阶段级 ABAC MVP**(文档 list/content 按 active 阶段 + 三权过滤;`TestResolveStageAccess_ActiveStageDutyOnly` PASS) + 3. ✅ **Phase 4 MVP 通知**(`00018` Outbox + 去重通知;文档批准联动 readiness;前端 15s 轮询刷新推进卡片) + 4. ✅ **聊天/搜索/SSE ABAC MVP**(发送拦截 + 历史/搜索按 `metadata.stage_key` 过滤;新消息写入 stage_key) + 5. ✅ **24h 提醒升级**(后台 15min 扫描;`stage_ready_reminder_24h` 去重;推进后 `confirmed` 停止提醒) + 6. ✅ **项目上下文 ABAC**(`GET /context/project` 需认证且至少一个允许阶段) + 7. ⏳ **真实 Agent E2E**(pi-agent + MiniMax-M3 全链路验收) + +### 顺序影响说明 + +| 顺序 | 选项 | 依赖 | 若提前做的风险 | +|------|------|------|----------------| +| 1 | PG 迁移 + 集成实测 | 无 | — | +| 2 | 阶段 ABAC | live instance + 00017 表 | 在 memory stub 上写 ABAC 无法验收;接口行为可能与 PG 不一致 | +| 3 | Outbox/自动刷新 | 稳定 stage gate + ABAC | 通知推送后仍可能越权读文档,验收失真 | + +**推荐路径**:先固化数据面(迁移实测)→ 再补安全门禁(ABAC)→ 最后做体验增强(通知)。 + +### Phase 4 MVP 实测(2026-09-12) + +| 用例 | 判定 | 说明 | +|------|------|------| +| `TestHandleDocumentApproved_StageReady` | PASS | 文档批准后同步 task 状态并评估 stage ready | +| `TestPG_EmitStageReadyDedupe` | PASS | `(stage_instance_id, readiness_revision)` 去重,重复 emit 不产生第二条通知 | +| `TestPG_ActivationAndStageAdvance`(含 notify 段) | PASS | 批准 → HandleDocumentApproved → EmitStageReadyToAdvance 全链路 | +| `TestResolveStageAccess_ActiveStageDutyOnly` | PASS | 非三权用户不可访问 active 阶段文档/聊天 | +| `TestStageKeyAllowed` / `TestMessageStageKey` | PASS | 阶段键允许判定与 metadata 提取 | +| `TestServer_ChatSearchAndDigestAPIs` | PASS | 搜索 API 在认证下仍正常工作 | +| `TestPG_StageAdvanceReminder24h` | PASS | 24h 升级提醒去重;确认推进后不再扫描 | diff --git a/.doc/test-reports/2026-09-12-phase4-lease-phase5-fault-injection-test-report.md b/.doc/test-reports/2026-09-12-phase4-lease-phase5-fault-injection-test-report.md new file mode 100644 index 00000000..246c99df --- /dev/null +++ b/.doc/test-reports/2026-09-12-phase4-lease-phase5-fault-injection-test-report.md @@ -0,0 +1,77 @@ +# Phase 4 Worker Lease + Phase 5 故障注入测试报告 + +## 一、 测试背景 (Background) + +Phase 4 持久化后,running 任务在进程重启时仍会被一律标记 interrupted,缺乏 worker lease/heartbeat 区分「仍存活 worker」与「孤儿任务」。Phase 5 验收矩阵尚需并发推进、lease 故障与备份恢复模拟。 + +## 二、 测试目标 (Test Objectives) + +1. `task_runs` 增加 `worker_id` / `lease_expires_at` / `lease_heartbeat_at` / `attempt` +2. 运行中任务定期 heartbeat;仅 **lease 过期** 的 running 任务标记 `interrupted` +3. 并发双次 stage advance:恰好一次成功、第二次冲突/门禁失败 +4. 备份恢复模拟:迁移幂等 + 核心表仍存在 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS: Linux, Go 1.27 +- PG: `postgres://app:dev_secret_db_pass@127.0.0.1:5433/agent_system_dev` +- 迁移: goose **v20** (`00020_2026-09-12-task-run-worker-lease.sql`) +- Lease TTL: 30s;Heartbeat: 10s + +## 四、 预期结果 (Expected Results) + +| 用例 | 预期 | +|------|------| +| 过期 lease | `MarkOrphanedRunning` → `interrupted` | +| 有效 lease | orphan 扫描后仍为 `running` | +| 并发 advance | 1×200 + 1×409/422 | +| 备份恢复 | 重复 `goose Up` 版本不变,`task_runs` 存在 | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 过期 lease 孤儿检测 + +**命令**: `go test -run TestPGStore_StaleLeaseMarkedInterrupted ./internal/taskrun/...` + +**判定**: PASS + +### 5.2 有效 lease 不被误杀 + +**命令**: `go test -run TestPGStore_FreshLeaseSurvivesOrphanScan ./internal/taskrun/...` + +**判定**: PASS + +### 5.3 运行中 heartbeat 保护 + +**命令**: `go test -run TestPhase5_TaskRunHeartbeatSurvivesOrphanScan_PG ./internal/server/...` + +**判定**: PASS — 长任务 worker 持有 lease 时 orphan 扫描不中断 + +### 5.4 并发 stage advance + +**命令**: `go test -run TestPhase5_ConcurrentStageAdvance_PG ./internal/server/...` + +**判定**: PASS — 双 goroutine 并发 advance,一次 200、一次冲突 + +### 5.5 备份恢复迁移幂等 + +**命令**: `go test -run TestPhase5_BackupRestoreMigration_PG ./internal/server/...` + +**判定**: PASS + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +``` +go test ./internal/taskrun/... ./internal/server/... -run 'TestPGStore_|TestPhase5_|TestManager_' -count=1 +ok chat-agent-system/internal/taskrun 0.192s +ok chat-agent-system/internal/server 0.929s +``` + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +**结论**: Worker lease/heartbeat 与 Phase 5 首批故障注入项 **PASS**,较上一版「重启即 interrupted」显著改进;尚未实现跨进程 **任务续跑**(仅识别孤儿 + 重放历史事件)。 + +**后续**: +1. 基于 `workspace_id` 的可选 resume 策略(interrupted → 用户确认后续跑) +2. Phase 5:Finalize 故障 HTTP 级注入、滚动重启 soak +3. Gap P1:Mermaid strict、上传硬限 diff --git a/.doc/test-reports/2026-09-12-phase4-task-persistence-readiness-test-report.md b/.doc/test-reports/2026-09-12-phase4-task-persistence-readiness-test-report.md new file mode 100644 index 00000000..38fb02f4 --- /dev/null +++ b/.doc/test-reports/2026-09-12-phase4-task-persistence-readiness-test-report.md @@ -0,0 +1,91 @@ +# Phase 4 长任务持久化与 Readiness 测试报告 + +## 一、 测试背景 (Background) + +按整改方案建议顺序推进 Phase 4(RUN-001/002)与 P0 剩余项:长任务 run/event 此前仅存进程内存,重启不可重放;`done` 事件先于 Finalize 导致假成功风险;chat/SSE 仍接受客户端 `work_dir`;readiness 未严格校验 schema 与 task store。 + +## 二、 测试目标 (Test Objectives) + +1. `task_runs` / `task_run_events` PG 持久化,重启后可查询与 SSE 重放 +2. Finalize 成功后才发送 `done`;Finalize 失败不发 `done`、状态为 `finalizing_failed` +3. 进程重启时将孤儿 `running` 任务标记为 `interrupted` +4. `/api/v1/ready` 在 PG 环境下校验 `task_store_ready` +5. chat/SSE 忽略客户端 `work_dir`(P0-03 补充) + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS: Linux, Go 1.22+ +- PG: `postgres://app:dev_secret_db_pass@127.0.0.1:5433/agent_system_dev` +- 迁移: goose v19 (`00019_2026-09-12-task-runs-events.sql`) +- 层级: 单元测试 + PG 集成测试 + +## 四、 预期结果 (Expected Results) + +| 用例 | 预期 | +|------|------| +| 任务完成持久化 | `task_runs.status=completed`,events 含 `done` | +| 重启重放 | 新 Manager + Store 可 GetRun/Subscribe replay | +| Finalize 时序 | 最后事件为 `done`;失败时无 `done` | +| Readiness | PG 环境 `/api/v1/ready` 200 且 `task_store_ready=true` | +| P0 匿名 API | 仍返回 401(既有 security_p0 测试) | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 Finalize 时序(单元) + +**输入**: `go test -run TestManager_DoneEmittedAfterFinalize ./internal/taskrun/...` + +**输出**: +``` +ok chat-agent-system/internal/taskrun 0.116s +``` + +**判定**: PASS — 最后事件类型为 `done` + +### 5.2 Finalize 失败不发 done(单元) + +**输入**: `go test -run TestManager_FinalizeFailureDoesNotEmitDone ./internal/taskrun/...` + +**判定**: PASS — 状态 `finalizing_failed`,无 `done` 事件 + +### 5.3 PG 持久化与重启重放 + +**输入**: `go test -run TestTaskRun_PersistenceAndReplayAfterRestart_PG ./internal/server/...` + +**实际输出(摘要)**: +``` +goose: successfully migrated database to version: 19 +taskrun_status_transition run_id=run_pg_persist_* status=completed +ok chat-agent-system/internal/server 0.989s +``` + +**判定**: PASS — Store GetRun/ListEvents/Subscribe replay 均有效 + +### 5.4 Readiness 严格门禁 + +**输入**: `GET /api/v1/ready`(PG 已迁移) + +**判定**: PASS — 200, `task_store_ready=true` + +### 5.5 P0 安全回归 + +**输入**: `go test -run TestP0_ ./internal/server/...` + +**判定**: PASS + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +``` +go test ./internal/taskrun/... ./internal/server/... -count=1 -timeout 180s +ok chat-agent-system/internal/taskrun +ok chat-agent-system/internal/server +``` + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +**结论**: Phase 4 核心 RUN-001/002 与 P0 readiness/work_dir 收口 **达到本阶段门禁**;尚未覆盖并发双 advance、备份恢复演练、Runner 故障注入(Phase 5 后续项)。 + +**后续**: +1. 长任务 worker lease/heartbeat 与真正重启恢复(非仅标记 interrupted) +2. Phase 5 故障注入矩阵(Finalize 故障、滚动重启、并发 cancel) +3. Gap P1 余项:Mermaid strict、上传硬限、Runner 服务间认证 diff --git a/.doc/test-reports/2026-09-12-phase5-utf8-piagent-pg-acceptance-test-report.md b/.doc/test-reports/2026-09-12-phase5-utf8-piagent-pg-acceptance-test-report.md new file mode 100644 index 00000000..728a4454 --- /dev/null +++ b/.doc/test-reports/2026-09-12-phase5-utf8-piagent-pg-acceptance-test-report.md @@ -0,0 +1,98 @@ +# Phase 5 UTF-8 修复、PG+pi-agent 治理链与验收矩阵 测试报告 + +## 一、 测试背景 (Background) + +接续 pi-agent E2E 与 bizdev ABAC 交付,本轮按顺序完成: + +1. PG 环境 `POST /api/v1/projects` 中文名称插入 UTF-8 异常(`generateProjectCode` 按字节截断破坏多字节字符); +2. PG 治理链叠加 pi-agent 真实 Agent 全链路; +3. Phase 5 生产验收矩阵(权限负例、激活幂等、迁移版本门禁)。 + +## 二、 测试目标 (Test Objectives) + +| # | 目标 | 判定标准 | +|---|------|----------| +| 1 | 中文项目名 HTTP 创建 | PG 返回 201,`project.code` 为合法 UTF-8 | +| 2 | PG + pi-agent 治理链 | activate → pi-agent intake → approve → notify → advance 全 PASS | +| 3 | 验收矩阵 | 迁移幂等、跨用户 403、二次激活 409 | + +## 三、 测试方案与环境 (Test Plan & Environment) + +| 项 | 值 | +|---|---| +| OS | Linux 7.0.0-31-generic | +| Go | 1.27.1 | +| PG | `postgres://app@127.0.0.1:5433/agent_system_dev`,goose v18 | +| pi-agent | `http://127.0.0.1:18865/health` → 200,MiniMax-M3 | + +## 四、 预期结果 (Expected Results) + +- `generateProjectCode("…中文…")` 产出有效 UTF-8,不以半个汉字结尾 +- pi-agent intake 单步 WBS 在 1h 内完成,文档批准触发 `stage_ready_to_advance` +- 非项目成员访问 settings/plan → 403;重复 activate → 409 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1:UTF-8 项目代码生成 + HTTP 中文创建 + +- **Input**: `go test ./internal/projectworkflow/... -run TestGenerateProjectCode -v` + `go test ./internal/server/ -run TestProjectCreate_PG_ChineseName -v` +- **Actual Output**: + ``` + --- PASS: TestGenerateProjectCode_ValidUTF8ForChineseName (0.00s) + --- PASS: TestProjectCreate_PG_ChineseNameViaHTTP (0.16s) + ``` +- **Verdict**: **PASS** + +### 用例 2:PG + pi-agent 治理全链路 + +- **Input**: `go test ./internal/server/ -run TestWorkflowGovernance_PiAgent_PG -timeout 1h -v` +- **Actual Output**: + ``` + ✓ [1. 项目创建] id=42 code=pg-piagent-治-7b969dd6 + ✓ [2. 工作流激活] instance_id=25 + ✓ [3. pi-agent intake] doc=doc_proj42_intake title=用户初始需求收集表 elapsed=2m41s + ✓ [4. 文档批准] stage ready hook 已触发 + ✓ [5. 阶段推进] PG + pi-agent 治理全链路完成 + --- PASS: TestWorkflowGovernance_PiAgent_PG_FullChain (161.26s) + ``` +- **Verdict**: **PASS** +- **Duration**: 161.26s + +### 用例 3:Phase 5 验收矩阵 + +- **Input**: `go test ./internal/server/ -run TestPhase5_ -v` +- **Actual Output**: + ``` + --- PASS: TestPhase5_MigrationVersionGate_PG (0.02s) + --- PASS: TestPhase5_AuthzNegativeMatrix_PG (0.22s) + --- PASS: TestPhase5_ActivateIdempotencyConflict_PG (0.29s) + ``` +- **Verdict**: **PASS** + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +``` +ok chat-agent-system/internal/projectworkflow TestGenerateProjectCode_ValidUTF8ForChineseName +ok chat-agent-system/internal/server TestProjectCreate_PG_ChineseNameViaHTTP +ok chat-agent-system/internal/server TestWorkflowGovernance_PiAgent_PG_FullChain (161.26s) +ok chat-agent-system/internal/server TestPhase5_MigrationVersionGate_PG +ok chat-agent-system/internal/server TestPhase5_AuthzNegativeMatrix_PG +ok chat-agent-system/internal/server TestPhase5_ActivateIdempotencyConflict_PG +``` + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +**结论**:三项任务均达成交付标准,可合并 dev。 + +| 交付项 | 状态 | +|--------|------| +| UTF-8 项目代码修复 | ✅ | +| PG + pi-agent 治理 E2E | ✅(161s) | +| Phase 5 验收矩阵(PG) | ✅ | + +**后续建议**: + +1. 已激活工作流下,`GET /context/project` 对无阶段权限用户应返回 403(当前无 live 实例时不限制)。 +2. 扩展 Phase 5 矩阵:并发双 advance、Finalize 故障注入、备份恢复演练脚本。 +3. test 环境连续稳定运行一个发布周期后合入 main。 diff --git a/.doc/test-reports/2026-09-12-piagent-e2e-bizdev-abac-test-report.md b/.doc/test-reports/2026-09-12-piagent-e2e-bizdev-abac-test-report.md new file mode 100644 index 00000000..460f6c8b --- /dev/null +++ b/.doc/test-reports/2026-09-12-piagent-e2e-bizdev-abac-test-report.md @@ -0,0 +1,178 @@ +# pi-agent 全链路 E2E 与 bizdev 阶段 ABAC 测试报告 + +## 一、 测试背景 (Background) + +Phase 4 完成后,交付验收需满足两项缺口: + +1. **pi-agent 真实 Agent 全链路 E2E**:此前 `TestPiAgent_FullLifecycle` 在步骤 3(自由非标 adhoc 任务)因 15min 超时失败;用户要求将整体测试窗口延长至 **1h**。 +2. **bizdev Agent 上下文 ABAC**:聊天/搜索/SSE 已接入阶段门禁,但 `ExecuteTask` / `ExecuteStage` 读取项目黑板与 PriorDocs 时尚未过滤,存在跨阶段上下文泄露风险。 + +## 二、 测试目标 (Test Objectives) + +- pi-agent + MiniMax-M3 容器在线时,全生命周期 E2E(对话 → V0.1 阶段 → 自由非标任务 → 多角色 docgov 闭环 → 审计落盘)在 **1h** 内 **PASS** +- bizdev 服务注入 `StageAccessGate` 后,任务执行与上下文读取受 live 实例三权阶段约束 +- 单元测试 `TestEnsureTaskStageAccess_Denied` / `TestSetStageAccessGate_NilSafe` 通过 + +## 三、 测试方案与环境 (Test Plan & Environment) + +| 项 | 值 | +|---|---| +| OS | Linux 7.0.0-31-generic | +| Go | 1.27.1 | +| 分支 | dev | +| Agent 后端 | **pi-agent**(`http://127.0.0.1:18865/health` → 200) | +| 模型 | MiniMax-M3 / minimax | +| 测试命令 | `go test ./internal/server/ -run TestPiAgent_FullLifecycle -timeout 1h -v` | +| HTTP Client 超时 | 50min(单步 pi-agent 远程调用兜底) | +| 数据 | 临时目录 + AllowInMemory(对话/docgov 内存落盘) | + +**bizdev ABAC 单元测试**:`go test ./internal/bizdev/... -run 'StageAccess|EnsureTask|SetStage' -v` + +## 四、 预期结果 (Expected Results) + +| 步骤 | 预期 | +|------|------| +| 0 环境校验 | Health.AgentBackend=pi-agent | +| 1 多轮对话 | pi-agent 真实回复,消息持久化 ≥4 条 | +| 2 V0.1 阶段 | ExecuteStage Success,PrimaryArtifact 非空 | +| 3 自由非标任务 | `freeRes.Success == true`,work_dir 落盘 | +| 5 docgov 闭环 | 认领→送审→批注→驳回→再送审→审核通过→批准,审计 ≥5 条 | +| bizdev gate | 无 gate 或 legacy 项目不阻断;restricted 时 FilterPriorDocs 按 task_key/stage 过滤 | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1a:pi-agent 全链路 E2E v2(60m AgentTimeout 配置,2026-09-12 09:50 UTC) + +- **Input**: `go test -count=1 -timeout 2h -v -run TestPiAgent_FullLifecycle ./internal/server/...` +- **Actual Output**(节选): + ``` + [kb] 无法连接到 PostgreSQL 知识库 (postgres://:@:0/...), 将降级运行在离线模式 + ✓ [0. 环境校验] Health检查正常: Env=dev, AgentBackend=pi-agent, SharedKBPort=0 + ⏱ [步骤 1 对话完成] 累计耗时 3m0s + ExecuteStage V0.1 failed: step step_01_organize failed: Execution timed out after 1800000ms + --- FAIL: TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E (1980.29s) + ``` +- **Verdict**: **FAIL** +- **Duration**: 1980s(约 33min) +- **根因**: + 1. E2E 未注入 `KB_DB_*`,知识库离线降级; + 2. `squad/workflow.go:runOneStep` 硬编码 `Timeout: 30*time.Minute`,未传递 `ExecuteOptions.Timeout`,导致 pi-agent runner 仍收到 1800000ms(30min)而非 60min。 + +**已修复**:`mergePiAgentE2EKbConfig` 注入共享 KB;`runOneStep` 透传 `stepTimeout`(commit `47c5b52`)。 + +### 用例 1b:pi-agent 全链路 E2E v3(KB + 60m 透传修复后,2026-09-12 10:24 UTC) + +- **Input**: `go test -count=1 -timeout 2h -v -run TestPiAgent_FullLifecycle ./internal/server/...` +- **Actual Output**(节选): + ``` + ✓ [0. 知识库] 已连接共享 KB: documents=2907 chunks=312919 + ⏱ [步骤 1 对话完成] 累计耗时 3m2s + [kb-retriever] embed failed: http 401 Unauthorized: 无效的令牌 + ExecuteStage V0.1 failed: step step_01_organize failed: pi remote runner call failed: Post "http://127.0.0.1:18865/v1/run": context deadline exceeded + --- FAIL: TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E (3781.81s) + ``` +- **Verdict**: **FAIL** +- **Duration**: 3781s(约 63min;步骤 2 单步约 60min 触顶) +- **判定**: + 1. KB 连接修复 **生效**(`SharedKBPort=5432`); + 2. 30min 硬编码问题 **已消除**(错误变为 Go context 60m 截止,非 runner `1800000ms`); + 3. `step_01_organize` 仍无法在 60min 内完成,且较历史 PASS(V0.1 约 4m11s)存在 **显著性能回退**,需排查 pi-agent 容器日志与 TASK/AGENTS 体量。 + +### 用例 1c:pi-agent 全链路 E2E v5(env 同步 + RUNNER_AUTH_TOKEN,2026-09-12 11:38 UTC) + +- **Input**: `set -a && source .env && set +a && go test -count=1 -timeout 2h -v -run TestPiAgent_FullLifecycle ./internal/server/...` +- **前置修复**:`configs/.env.dev` 同步 MINIMAX/TAVILY/RUNNER_AUTH_TOKEN;pi-agent 容器 force-recreate;`has_token=true` +- **Actual Output**(节选): + ``` + ✓ [0. 知识库] 已连接共享 KB: documents=2907 chunks=312919 + ⏱ [步骤 1 对话完成] 累计耗时 19s + ✓ [V0.1 阶段完成] 交付物: 用户初始需求收集表, 成熟度评分: 65 + ⏱ [步骤 2 V0.1 阶段完成] 累计耗时 9m56s + ✓ [自由非标任务完成] 任务成功=true, 交付物=[free_cusa_cavitation_risk] + ⏱ [步骤 3 自由非标任务完成] 累计耗时 1m35s + ✓ [李主管] 驳回成功 … ✓ [王总监] 最终批准成功 (StateApproved) + ✓ 审计轨迹 8 条 | 对话持久化 4 条 + --- PASS: TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E (714.42s) + ``` +- **Verdict**: **PASS** +- **Duration**: 714.42s(约 11m54s) + +### 用例 1d:pi-agent 全链路 E2E(历史 PASS,含 auth 修复) + +- **Input**: `go test ./internal/server/ -run TestPiAgent_FullLifecycle -timeout 1h -v` +- **Actual Output**(节选): + ``` + ✓ [0. 环境校验] Health检查正常: Env=dev, AgentBackend=pi-agent, SharedKBPort=0 + ⏱ [步骤 1 对话完成] 累计耗时 25s + ✓ [V0.1 阶段完成] 交付物: 用户初始需求收集表, 成熟度评分: 65 + ⏱ [步骤 2 V0.1 阶段完成] 累计耗时 4m11s + ✓ [自由非标任务完成] 任务成功=true, 交付物=[free_cusa_cavitation_risk] + ⏱ [步骤 3 自由非标任务完成] 累计耗时 6m40s + ✓ [李主管] 驳回成功,受控文档自动解锁并退回 drafting 状态 + ✓ [王总监] 最终批准成功,文档彻底锁定生效归档 (StateApproved) + ✓ 成功追溯受控文档全生命周期审计轨迹流 (8 条记录) + ✓ 成功查询并追溯全周期真实多轮对话持久化记录 (4 条) + --- PASS: TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E (676.53s) + PASS + ok chat-agent-system/internal/server 676.535s + ``` +- **Verdict**: **PASS** +- **Duration**: 676.53s(约 11m17s,远低于 1h 上限) + +**第一次运行说明**(auth 修复前):步骤 0–3 PASS,步骤 5 审核驳回因 legacy fallback _principal(user_id=1)_ 与文档指定审核人(id=3) 不匹配而 FAIL;已通过 per-role Bearer Token 修复。 + +### 用例 2:bizdev StageAccessGate 单元测试 + +- **Input**: `go test ./internal/bizdev/... -run 'StageAccess|EnsureTask|SetStage' -v` +- **Actual Output**: + ``` + --- PASS: TestEnsureTaskStageAccess_Denied (0.00s) + --- PASS: TestSetStageAccessGate_NilSafe (0.00s) + PASS + ok chat-agent-system/internal/bizdev 0.003s + ``` +- **Verdict**: **PASS** + +### 用例 3:BlendedSquadContext 阶段 ABAC 过滤 + +- **Input**: `go test ./internal/bizdev/... -run TestBuildBlendedSquadContextForUser_FiltersByStage -v` +- **Verdict**: **PASS**(里程碑决策按 `stage_key` 过滤,仅保留当前允许阶段) + +### 用例 4:Phase 5 PG 治理全链路(activate → approve → advance) + +- **Input**: `go test ./internal/server/ -run TestWorkflowGovernance_PG -v` +- **Actual Output**: + ``` + --- PASS: TestWorkflowGovernance_PG_ActivateApproveAdvanceChain (0.36s) + PASS + ok chat-agent-system/internal/server 0.367s + ``` +- **Verdict**: **PASS**(含 stage_ready 通知、访客 403 ABAC、创建者阶段推进) +- **Duration**: ~0.36s + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +``` +# pi-agent E2E(真实 Agent,非 stub) +ok chat-agent-system/internal/server 676.535s TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E + +# bizdev ABAC 单元 +ok chat-agent-system/internal/bizdev 0.003s TestEnsureTaskStageAccess_Denied, TestSetStageAccessGate_NilSafe +``` + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +**结论**:bizdev ABAC、Blended 上下文过滤与 PG 治理链已验收通过;pi-agent E2E **v5 PASS**(714s),根因为 `configs/.env.dev` 未同步密钥导致 v2/v3 假超时。 + +| 交付项 | 状态 | +|--------|------| +| pi-agent 1h E2E 全链路 | ✅ v5 PASS(714s);v2/v3 FAIL(env 未注入密钥) | +| bizdev Agent 上下文 ABAC | ✅ | +| BuildBlendedSquadContext ABAC | ✅ | +| PG activate→approve→advance | ✅ | + +**后续建议**: + +1. 修复 PG 环境下 `POST /api/v1/projects` 中文名称 UTF-8 插入异常(当前 PG E2E 使用 SQL 直写规避)。 +2. Phase 5 扩展:结合 pi-agent 真实 Agent 的 workflow 治理全链路(在 PG + stub 链 PASS 基础上叠加)。 +3. 生产验收矩阵:权限负例、并发幂等、备份恢复演练。 diff --git a/.doc/test-reports/2026-09-12-qa001-backup-pentest-template-mime-test-report.md b/.doc/test-reports/2026-09-12-qa001-backup-pentest-template-mime-test-report.md new file mode 100644 index 00000000..0f5ef251 --- /dev/null +++ b/.doc/test-reports/2026-09-12-qa001-backup-pentest-template-mime-test-report.md @@ -0,0 +1,146 @@ +# QA-001 / 备份演练 / 渗透负例 / 模板 MIME 测试报告 + +## 一、 测试背景 (Background) + +上一轮 Phase 4–5 与 Gap P1 硬化已落地 task 持久化、lease、续跑、Runner Bearer、材料上传 MIME 校验等能力,但以下四项仍空缺: + +1. **QA-001 CI 门禁**:仓库无 `.github/workflows`,main/test 合并无自动化回归; +2. **备份/RPO 演练**:仅有法规库 `backup_regulations.sh`,系统业务库无备份与恢复演练脚本; +3. **渗透/安全负例**:P0 测试覆盖有限,缺自动化负例矩阵; +4. **模板上传 MIME**:工作流模板上传仍使用独立扩展名白名单,未复用 `internal/upload` 魔数/HTML/脚本拒绝逻辑。 + +## 二、 测试目标 (Test Objectives) + +| 编号 | 目标 | 判定标准 | +|------|------|----------| +| T1 | 模板上传复用 `ValidateTemplate` | `.md/.docx/.xlsx/.pptx` 白名单;HTML/假 Office/脚本拒绝;Admin API 返回 415 | +| T2 | QA-001 CI 门禁 | `go vet`、`-short` 全量、`TestMigrationGate`、web `npm test/build` | +| T3 | 系统 DB 备份/RPO 演练 | dev 容器 `pg_dump -Fc` 成功;restore drill 迁移幂等 PASS | +| T4 | 自动化渗透负例 | 匿名 Admin/Task 401;413 body limit;415 MIME;503 strict ready | + +## 三、 测试方案与环境 (Test Plan & Environment) + +- **OS**:Linux 7.0.0-31-generic +- **Go**:1.27.1 +- **Node**:22(CI 目标版本) +- **数据库**:dev `shuimu-context-pg-dev` @ 127.0.0.1:5433,`goose` v20 +- **Agent**:stub(本报告聚焦门禁/备份/安全,非 pi-agent E2E) +- **层级**:单元测试 → 中间件负例 → PG 迁移幂等 → Shell 备份演练 + +## 四、 预期结果 (Expected Results) + +- 模板 HTML 上传 → `workflowdef.ErrUploadRejected` → HTTP **415** +- 匿名访问 `/api/v1/admin/templates`(Env=main, AllowInMemory=false)→ **401** +- JSON/模板路径 body 超过 4MiB/32MiB → **413** +- `backup-system-db.sh dev` 生成 dump + manifest;restore drill 迁移版本不变 +- CI workflow 在 push/PR 至 dev/test/main 时触发 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 T1-1:模板 Markdown 合法上传校验 + +- **Input**:`ValidateTemplate("brief.md", ..., "# Title")` +- **Actual Output**: + ``` + ok chat-agent-system/internal/upload 0.002s + --- PASS: TestValidateTemplate_AcceptsMarkdown + --- PASS: TestValidateTemplate_RejectsHTML + --- PASS: TestValidateTemplate_RejectsFakeOfficeDoc + --- PASS: TestValidateTemplate_AcceptsOfficeZipMagic + ``` +- **Verdict**:**PASS** +- **Duration**:~2ms + +### 用例 T2-1:迁移门禁 + +- **Input**:`go test -run TestMigrationGate ./internal/db/...`(本地 dev PG) +- **Actual Output**: + ``` + ok chat-agent-system/internal/db 0.014s + ``` +- **Verdict**:**PASS**(applied version ≥ migration file count) +- **Duration**:14ms + +### 用例 T3-1:dev 系统库备份 + +- **Input**:`./scripts/backup-system-db.sh dev /tmp/system_db_backup_drill_20260912` +- **Actual Output**: + ``` + >>> [SUCCESS] 系统库备份完成 (dev) + >>> Dump: /tmp/system_db_backup_drill_20260912/system_db_dev.dump (269K, 1s) + >>> Manifest: /tmp/system_db_backup_drill_20260912/manifest.json + ``` +- **Verdict**:**PASS** +- **Duration**:~609ms + +### 用例 T3-2:恢复演练(迁移幂等) + +- **Input**:`./scripts/restore-system-db-drill.sh /tmp/system_db_backup_drill_20260912` +- **Actual Output**: + ``` + [迁移] goose 幂等 Up... + ok chat-agent-system/internal/db 0.014s + ok chat-agent-system/internal/server 0.024s + >>> [SUCCESS] 恢复演练通过 + ``` +- **Verdict**:**PASS**(RPO manifest 已记录;RTO 演练 < 2min) +- **Duration**:~615ms + +### 用例 T4-1:渗透负例套件 + +- **Input**:`go test -short -run TestPenetration ./internal/server/...` +- **Actual Output**: + ``` + ok chat-agent-system/internal/server 0.646s + --- PASS: TestPenetration_AnonymousAdminTemplateUpload401 + --- PASS: TestPenetration_AnonymousTaskRunMutations401 + --- PASS: TestPenetration_OversizedJSONBody413 + --- PASS: TestPenetration_OversizedTemplateUpload413 + --- PASS: TestPenetration_ProjectAssetRejectsScriptContent + --- PASS: TestPenetration_ReadyStrictWhenDBConfiguredButDown + ... (共 9 项) + ``` +- **Verdict**:**PASS** +- **Duration**:646ms + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +```bash +go test -short -timeout 15m ./... +``` + +| 包 | 结果 | 备注 | +|----|------|------| +| internal/upload | PASS | 含新增模板校验 | +| internal/workflowdef | PASS | UploadTemplate 集成 | +| internal/db | PASS | TestMigrationGate | +| internal/server | **部分 FAIL** | 既有 PG 账号 `zhanggong` 停用、CAS 重复键(非本次引入) | +| 其余包 | PASS | stub 模式 | + +**新增/变更文件**: + +- `.github/workflows/ci.yml` +- `scripts/backup-system-db.sh`、`scripts/restore-system-db-drill.sh` +- `internal/upload/validate.go`(`ValidateTemplate`) +- `internal/server/security_penetration_test.go` +- `internal/db/migrate_test.go` + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +### 结论 + +本次四项任务 **均已落地并通过针对性实测**: + +1. ✅ 模板上传 MIME 与材料上传共用校验器; +2. ✅ QA-001 CI workflow(go vet / short test / migration gate / web); +3. ✅ 系统 DB 备份脚本 + RPO manifest + restore drill; +4. ✅ 自动化渗透负例矩阵(9 用例)。 + +**尚不具备 main 公网发布条件**:pi-agent 真实 E2E、完整 soak、人工渗透、PG 集成测试账号恢复等待后续阶段。 + +### 后续建议 + +1. CI 增加 `gosec` / secret scan 可选 job; +2. restore drill 在 CI 中挂载 pgvector 并执行 `pg_restore` 全量恢复; +3. 修复 dev PG 中 `zhanggong` 停用导致的 PG 集成测试失败; +4. 模板上传 PG 级集成测试(415 负例 + 合法 `.md` 落盘)。 diff --git a/.doc/test-reports/2026-09-12-resume-phase5-p1-hardening-test-report.md b/.doc/test-reports/2026-09-12-resume-phase5-p1-hardening-test-report.md new file mode 100644 index 00000000..b502d3d6 --- /dev/null +++ b/.doc/test-reports/2026-09-12-resume-phase5-p1-hardening-test-report.md @@ -0,0 +1,48 @@ +# 任务续跑 + Phase 5 扩展 + Gap P1 硬化测试报告 + +## 一、 测试背景 (Background) + +按默认推进顺序实施:① interrupted 任务用户确认续跑;② Phase 5 Finalize 故障注入与滚动重启重放;③ Gap P1(Mermaid strict、上传 body 上限、Runner 服务间 token)。 + +## 二、 测试目标 (Test Objectives) + +1. `POST /api/v1/tasks/runs/{id}/resume` 需 `confirm=true`,仅 `interrupted`+有效 workspace 可续跑 +2. `GET /api/v1/tasks/runs/{id}` 返回 `resumable` 标志 +3. Finalize 故障注入不产生 `done` 终态(`finalizing_failed`) +4. 三次「进程重启」后 PG 事件重放仍有效 +5. JSON body >4MiB 返回 413;Mermaid 切换 `strict`;Runner Bearer token + +## 三、 测试方案与环境 (Test Plan & Environment) + +- Go 1.27, PG dev `5433`, goose v20 +- 单元 + PG 集成测试 + +## 四、 预期结果 (Expected Results) + +| 用例 | 预期 | +|------|------| +| Resume interrupted | 202 → completed, attempt≥2 | +| 无 confirm | 400 ERR_CONFIRM_REQUIRED | +| resumable 标志 | interrupted+workspace → true | +| Finalize 故障 | finalizing_failed | +| 滚动重启×3 | GetRun completed + replay 非空 | +| Body limit | 413 | +| Runner auth | Authorization Bearer 头 | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +``` +go test ./internal/taskrun/... ./internal/server/... ./internal/agent/... \ + -run 'TestTaskRun_Resume|TestPhase5_Finalize|TestPhase5_Rolling|TestBodyLimit|TestApplyRunner|TestPGStore_' +ok internal/taskrun 0.036s +ok internal/server 0.527s +ok internal/agent 0.003s +``` + +**判定**: 全部 PASS + +## 六、 测试结论与后续优化建议 (Conclusion & Next Steps) + +**结论**: 默认顺序 1→2→3 的本批目标已达成;仍非 main 发布级(缺 test 环境 soak、渗透、完整备份恢复演练)。 + +**后续**: test 分支 soak、Runner token 写入 Compose 生产模板、上传 MIME 魔数校验。 diff --git a/.env.example b/.env.example index 4579c61b..e345eff5 100644 --- a/.env.example +++ b/.env.example @@ -1,85 +1,94 @@ # ============================================================================= -# 商务对接智能体系统 (Chat-Agent-System) - 开发环境配置文件模板 (.env.example) +# 商务对接智能体系统 (Chat-Agent-System) - 环境配置模板 (.env.example) +# +# 使用说明: +# 1. 复制为 .env 并填写密钥:cp .env.example .env && chmod 600 .env +# 2. 本文件供 Go 后端 (bin/server)、launch.sh 默认 compose 变量替换使用 +# 3. 若通过 ./scripts/env-service.sh dev start 启容器,还需同步密钥到 configs/.env.dev +# (见 configs/.env.dev.example 第 6 节) # ============================================================================= # --- 1. 运行环境与基础目录 --- ENV=dev +COMPOSE_PROJECT_NAME=agent-dev DATA_DIR=./data/dev APP_PUBLISH_HOST=127.0.0.1 HTTP_PORT=8080 -# --- 2. 智能体后端调度配置 --- +# --- 2. 智能体后端调度(Go 宿主进程 / 容器 app 服务)--- # 可选值: pi-agent | cursor-cli | stub AGENT_BACKEND=pi-agent AGENT_TIMEOUT=30m +MAX_CONCURRENT_AGENTS=8 -# --- 3. Cursor CLI / SDK 后端配置 --- -# Cursor API Key (格式通常为 crsr_...) +# --- 3. Cursor CLI / SDK 后端 --- CURSOR_API_KEY= -# 模型选择 (auto, claude-3-7-sonnet, gpt-4o 等) CURSOR_MODEL=auto -# Cursor 可执行程序路径 CURSOR_BIN=/usr/local/bin/cursor -# Cursor Node 桥接脚本路径 CURSOR_BRIDGE_BIN=tools/cursor-bridge/index.mjs +CURSOR_RUNNER_PORT=18765 -# --- 4. Pi-Agent 容器与模型参数配置 (MiniMax-M3) --- -# 1) ENDPOINT URL (MiniMax API 地址) +# --- 4. Pi-Agent 容器与 MiniMax 模型(pi-agent 容器 entrypoint 读取)--- MINIMAX_ENDPOINT_URL=https://api.minimaxi.com/v1 -# 2) API TOKEN (用户自行设定,必填) MINIMAX_API_KEY= -# 3) MODEL ID (模型名称) MINIMAX_MODEL_ID=MiniMax-M3 -# 4) API 协议类型 (openai-compatible 兼容协议) MINIMAX_API_TYPE=openai-compatible -# --- 5. 网络搜索工具配置 (Tavily Search API Key 池) --- -# 支持单 Key 或多 Key 池 (逗号/分号/换行分隔,例如: tvly-xxx1,tvly-xxx2,tvly-xxx3) -# 当遇到 429 配额耗尽或 401/403 异常时,容器内的 Tavily 工具会自动轮询切换下一个 Key +# --- 5. Tavily 联网搜索(pi-agent 容器内工具;多 Key 逗号分隔)--- TAVILY_API_KEYS= -# --- 6. Pi-Agent 容器服务端口与远程调用 (可选) --- +# --- 6. Pi-Agent Runner 远程调用(Go 宿主连容器时使用)--- PI_AGENT_RUNNER_PORT=18865 -# 若希望 Go 程序通过 HTTP 直连 pi-agent 容器,解除下方注释;若留空则走本机本地 pi CLI +# 宿主 bin/server 直连本机 pi-agent 容器时启用;容器内 app 服务由 compose 注入 http://pi-agent:18865 # PI_AGENT_RUNNER_URL=http://127.0.0.1:18865 +# Runner 服务间认证(main 必填;dev 可留空) +# RUNNER_AUTH_TOKEN= -# --- 7. 本机 Pi CLI 本地回退配置 (当不使用容器时) --- +# --- 7. 本机 Pi CLI 回退(不使用容器 runner 时)--- PI_AGENT_BIN=/opt/homebrew/bin/pi PI_AGENT_BRIDGE_BIN=tools/pi-bridge/index.mjs PI_AGENT_PROVIDER=minimax PI_AGENT_MODEL=MiniMax-M3 -# --- 8. 法规知识库配置 (复用已有 agent-regkb Docker 容器) --- +# --- 8. 共享法规知识库(宿主 Go 进程:127.0.0.1:5432;容器 app:compose 注入 shuimu-kb-shared)--- KB_DB_HOST=127.0.0.1 -KB_DB_PORT=55432 +KB_DB_PORT=5432 KB_DB_USER=regkb KB_DB_PASSWORD=shared_kb_secret_pass KB_DB_NAME=regulations -# 向量检索 Embedding 配置 (复用原系统配置,用于合规检索条目召回) +# --- 9. 向量 Embedding / Reranker(Go 宿主 KB 检索;容器 app 当前未透传,宿主模式必填)--- EMBEDDING_ENDPOINT_URL=https://www.dmxapi.cn/v1/embeddings EMBEDDING_PROVIDER=openai_compatible EMBEDDING_MODEL=bge-m3 EMBEDDING_API_KEY= -# 重排 Reranker 配置 RERANKER_ENDPOINT_URL=https://www.dmxapi.cn/v1/rerank RERANKER_PROVIDER=openai_compatible RERANKER_MODEL=bge-reranker-v2-m3-free RERANKER_API_KEY= -# --- 9. 系统管理员与认证准入配置 --- -# 超级管理员凭据 (系统启动时自动初始化为 role-admin) +# --- 10. 专属业务/上下文库(Go 宿主:CONTEXT_DB_*;compose 同时认 SYSTEM_DB_* 别名)--- +CONTEXT_DB_HOST=127.0.0.1 +CONTEXT_DB_PORT=5433 +CONTEXT_DB_USER=app +CONTEXT_DB_PASSWORD=dev_secret_db_pass +CONTEXT_DB_NAME=agent_system_dev + +# docker-compose.dev.yml 变量别名(与 CONTEXT_DB_* 保持一致) +SYSTEM_DB_USER=app +SYSTEM_DB_PASSWORD=dev_secret_db_pass +SYSTEM_DB_NAME=agent_system_dev +SYSTEM_DB_PUBLISH_PORT=5433 + +# --- 11. 系统管理员与认证准入 --- ADMIN_USERNAME=admin ADMIN_PASSWORD=admin-secret-password-123 - -# 全局准入策略 (是否开放自主注册与注册是否必须审批) AUTH_REGISTRATION_ENABLED=true AUTH_REQUIRE_APPROVAL=false -# CORS 允许来源(逗号分隔);main 环境必须显式配置,禁止 * -# dev/test 未配置时使用 localhost 默认值 +# --- 12. 可选:CORS / 内存降级 --- +# dev/test 未配置时 CORS 由代码填充 localhost 默认值 # CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:8080 - -# 生产环境必须为 false;main 启动时若 true 将拒绝加载配置 +# 生产必须为 false;main 启动时若 true 将拒绝加载 # ALLOW_IN_MEMORY=false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..432cd14a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,118 @@ +# QA-001: main/test/dev 合并门禁 — go vet、短测、迁移校验、前端测试 +name: CI + +on: + push: + branches: [dev, test, main] + pull_request: + branches: [dev, test, main] + +jobs: + go-vet-and-short-test: + runs-on: ubuntu-latest + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: app + POSTGRES_PASSWORD: ci_secret_db_pass + POSTGRES_DB: agent_system_ci + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U app -d agent_system_ci" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.27.1" + cache-dependency-path: go.sum + + - name: go vet + run: go vet ./... + + - name: migration gate + env: + TEST_DATABASE_URL: postgres://app:ci_secret_db_pass@localhost:5432/agent_system_ci?sslmode=disable + run: go test -count=1 -run TestMigrationGate ./internal/db/... + + - name: go test (short) + env: + TEST_DATABASE_URL: postgres://app:ci_secret_db_pass@localhost:5432/agent_system_ci?sslmode=disable + run: go test -short -timeout 20m ./... + + backup-restore-drill: + runs-on: ubuntu-latest + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: app + POSTGRES_PASSWORD: ci_secret_db_pass + POSTGRES_DB: agent_system_ci + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U app -d agent_system_ci" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.27.1" + cache-dependency-path: go.sum + + - name: install postgresql client + run: sudo apt-get update && sudo apt-get install -y postgresql-client + + - name: migrate and seed schema + env: + TEST_DATABASE_URL: postgres://app:ci_secret_db_pass@localhost:5432/agent_system_ci?sslmode=disable + run: go test -count=1 -run TestMigrationGate ./internal/db/... + + - name: pg_dump backup + env: + PGPASSWORD: ci_secret_db_pass + run: | + pg_dump -h localhost -U app -d agent_system_ci -Fc -f /tmp/system_ci.dump + pg_restore --list /tmp/system_ci.dump >/dev/null + + - name: restore to fresh database and re-run migrations + env: + PGPASSWORD: ci_secret_db_pass + TEST_DATABASE_URL: postgres://app:ci_secret_db_pass@localhost:5432/agent_system_ci_restore?sslmode=disable + run: | + psql -h localhost -U app -d postgres -c "DROP DATABASE IF EXISTS agent_system_ci_restore;" + psql -h localhost -U app -d postgres -c "CREATE DATABASE agent_system_ci_restore;" + pg_restore -h localhost -U app -d agent_system_ci_restore --no-owner --no-privileges /tmp/system_ci.dump + go test -count=1 -run 'TestMigrationGate|TestPhase5_BackupRestoreMigration_PG' ./internal/db/... ./internal/server/... + + web: + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: web/package-lock.json + + - name: npm ci + run: npm ci + + - name: npm test + run: npm test + + - name: npm build + run: npm run build diff --git a/configs/.env.dev.example b/configs/.env.dev.example index e8fe5d25..6fdbf039 100644 --- a/configs/.env.dev.example +++ b/configs/.env.dev.example @@ -1,6 +1,11 @@ # ============================================================================= -# 开发环境 (dev) 环境变量配置模板 (configs/.env.dev) -# 作用域:dev 分支及 feature/* 子分支日常开发调试 +# 开发环境 Compose 专用配置模板 (configs/.env.dev) +# 作用域:./scripts/env-service.sh dev * 与 docker compose --env-file configs/.env.dev +# +# 重要:本文件与根目录 .env 分工不同 +# - 根 .env → Go bin/server、launch.sh 默认 compose +# - 本文件 → env-service.sh 启停 dev 容器组 +# 密钥(MINIMAX_API_KEY / TAVILY_API_KEYS 等)须与根 .env 保持一致,否则 pi-agent 容器无 Token! # ============================================================================= ENV=dev @@ -12,25 +17,33 @@ APP_PUBLISH_HOST=127.0.0.1 ADMIN_TOKEN=dev-admin-token-change-me ALLOW_INSECURE_ADMIN=1 -# 2. 对话数据与工作区隔离目录 (禁止跨环境共享) +# 2. 对话数据与工作区(禁止跨环境共享) DATA_DIR=./data/dev AUX_FILE_DSN=file:/var/lib/feishu/outbox_dev.db?_journal_mode=WAL&_busy_timeout=5000 -# 3. 开发环境专属数据库 (禁止与 test/main 共享) +# 3. 开发环境专属数据库 SYSTEM_DB_USER=app SYSTEM_DB_PASSWORD=dev_secret_db_pass SYSTEM_DB_NAME=agent_system_dev SYSTEM_DB_PUBLISH_PORT=5433 -# 4. 共享知识库连接 (只读检索共享数据) +# 4. 共享知识库(容器 app 经 shuimu-kb-shared 访问) KB_DB_USER=regkb KB_DB_PASSWORD=shared_kb_secret_pass KB_DB_NAME=regulations -# 容器间网络域名 (shuimu-kb-shared) 或 宿主访问 127.0.0.1:5432 KB_DB_HOST=shuimu-kb-shared KB_DB_PORT=5432 -# 5. Cursor / LLM Agent Runner 服务 +# 5. Agent 后端与 Runner 端口 CURSOR_RUNNER_PORT=18765 -AGENT_BACKEND=stub -AGENT_TIMEOUT=30m +PI_AGENT_RUNNER_PORT=18865 +AGENT_BACKEND=pi-agent +AGENT_TIMEOUT=60m + +# 6. Pi-Agent / MiniMax / Tavily(pi-agent 容器必填;从根 .env 复制) +MINIMAX_ENDPOINT_URL=https://api.minimaxi.com/v1 +MINIMAX_API_KEY= +MINIMAX_MODEL_ID=MiniMax-M3 +MINIMAX_API_TYPE=openai-compatible +TAVILY_API_KEYS= +# RUNNER_AUTH_TOKEN= diff --git a/configs/.env.test.example b/configs/.env.test.example index 78d67762..8ca77b0b 100644 --- a/configs/.env.test.example +++ b/configs/.env.test.example @@ -31,5 +31,11 @@ KB_DB_PORT=5432 # 5. Cursor / LLM Agent Runner 服务 CURSOR_RUNNER_PORT=18766 +PI_AGENT_RUNNER_PORT=18866 AGENT_BACKEND=stub AGENT_TIMEOUT=60m +# app 与 pi-agent 容器共享(test 环境建议设置非空值) +# RUNNER_AUTH_TOKEN=test-runner-shared-secret + +# 6. Phase 5 soak 探测示例 +# scripts/test-soak.sh http://127.0.0.1:8081 300 10 diff --git a/deploy/pi-agent/runner.mjs b/deploy/pi-agent/runner.mjs index ba22f0ea..bc300d40 100755 --- a/deploy/pi-agent/runner.mjs +++ b/deploy/pi-agent/runner.mjs @@ -13,6 +13,7 @@ import { TavilyKeyPool, searchTavily, formatTavilyResults } from "./extensions/t const PORT = parseInt(process.env.PORT || process.env.PI_AGENT_RUNNER_PORT || "18766", 10); const DATA_DIR = process.env.DATA_DIR || "/app/data/workspaces"; +const RUNNER_AUTH_TOKEN = (process.env.RUNNER_AUTH_TOKEN || process.env.PI_AGENT_RUNNER_AUTH_TOKEN || "").trim(); const DEFAULT_MODEL = process.env.MINIMAX_MODEL_ID || "MiniMax-M3"; const DEFAULT_PROVIDER = process.env.MINIMAX_PROVIDER || "minimax"; const tavilyPool = TavilyKeyPool.fromEnv(); @@ -76,6 +77,18 @@ function sendJson(res, statusCode, data) { res.end(JSON.stringify(data, null, 2) + "\n"); } +function authorizeRunner(req, res) { + if (!RUNNER_AUTH_TOKEN) { + return true; + } + const auth = req.headers.authorization || ""; + if (auth === `Bearer ${RUNNER_AUTH_TOKEN}`) { + return true; + } + sendJson(res, 401, { ok: false, error: "unauthorized" }); + return false; +} + async function handleRun(body) { const rawWs = body.workspace || ""; const workspace = resolveWorkspace(rawWs); @@ -321,6 +334,9 @@ const server = http.createServer(async (req, res) => { // 3. Cancel active child process by workspace key if (method === "POST" && url.pathname === "/v1/cancel") { + if (!authorizeRunner(req, res)) { + return; + } try { const body = await parseJsonBody(req); const workspace = resolveWorkspace(body.workspace || ""); @@ -340,6 +356,9 @@ const server = http.createServer(async (req, res) => { // 4. Agent Task Run Endpoint if (method === "POST" && url.pathname === "/v1/run") { + if (!authorizeRunner(req, res)) { + return; + } try { const body = await parseJsonBody(req); const result = await handleRun(body); diff --git a/deploy/postgres/extensions_init.sql b/deploy/postgres/extensions_init.sql new file mode 100644 index 00000000..67f89b12 --- /dev/null +++ b/deploy/postgres/extensions_init.sql @@ -0,0 +1,3 @@ +-- 各环境 Postgres 容器首次启动时仅安装扩展;业务表由 goose 迁移链创建。 +CREATE EXTENSION IF NOT EXISTS vector; +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; diff --git a/deploy/postgres/goose/00011_2026-09-11-seed-ten-projects.sql b/deploy/postgres/goose/00011_2026-09-11-seed-ten-projects.sql index f3d8c0c9..4dddcbfa 100644 --- a/deploy/postgres/goose/00011_2026-09-11-seed-ten-projects.sql +++ b/deploy/postgres/goose/00011_2026-09-11-seed-ten-projects.sql @@ -2,6 +2,13 @@ -- 10 个示例医疗器械项目数据初始化脚本 (针对项目评审全流程落地) BEGIN; +-- 空库冷启动:确保 creator_user_id=1 的外键目标存在(IAM 种子在后续迁移中补齐) +INSERT INTO users (id, account_id, name, role) VALUES + (1, 'seed-admin', 'Seed Admin', 'engineer'), + (2, 'seed-zhanggong', '张工', 'engineer'), + (3, 'seed-lizhuguan', '李主管', 'engineer') +ON CONFLICT (id) DO NOTHING; + INSERT INTO projects (id, code, name, customer_name, device_type, intended_use, target_market, current_stage, environment, status, creator_user_id, active_workflow_key, workflow_selection_version, created_at, updated_at) VALUES (6001, 'PROJ-WANGJING-0021', '膝骨关节炎综合治疗仪', '中国中医科学院望京医院骨伤科', '中药离子导入与多频温控电热综合治疗设备', '用于早中期膝骨关节炎患者的局部经皮靶向定向透药导入、穴位多频电刺激止痛与温热理疗', '中国 NMPA II类 (YY 0601-2007 中药定向透药治疗仪, GB 9706.1-2020, YY 0505-2012)', 'V0.5', 'dev', 'active', 1, 'main', 1, now(), now()) ON CONFLICT (id) DO UPDATE SET diff --git a/deploy/postgres/goose/00016_2026-09-12-workflow-definitions-and-templates.sql b/deploy/postgres/goose/00016_2026-09-12-workflow-definitions-and-templates.sql new file mode 100644 index 00000000..674ff87b --- /dev/null +++ b/deploy/postgres/goose/00016_2026-09-12-workflow-definitions-and-templates.sql @@ -0,0 +1,99 @@ +-- +goose Up +-- Phase 2: 工作流定义版本化 + 模板资产库 + +CREATE TABLE IF NOT EXISTS workflow_definitions ( + id BIGSERIAL PRIMARY KEY, + def_key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + latest_published_version INT NOT NULL DEFAULT 0, + product_domain TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS workflow_versions ( + id BIGSERIAL PRIMARY KEY, + workflow_id BIGINT NOT NULL REFERENCES workflow_definitions(id) ON DELETE CASCADE, + version INT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft', + schema_version INT NOT NULL DEFAULT 1, + definition_json JSONB NOT NULL DEFAULT '{}'::jsonb, + definition_hash TEXT NOT NULL DEFAULT '', + published_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + published_at TIMESTAMPTZ, + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_workflow_version UNIQUE (workflow_id, version), + CONSTRAINT chk_workflow_version_status CHECK (status IN ('draft', 'published', 'retired')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_workflow_one_draft + ON workflow_versions(workflow_id) + WHERE status = 'draft'; + +CREATE INDEX IF NOT EXISTS idx_workflow_versions_status ON workflow_versions(status); +CREATE INDEX IF NOT EXISTS idx_workflow_versions_workflow ON workflow_versions(workflow_id, version DESC); + +CREATE TABLE IF NOT EXISTS template_assets ( + id BIGSERIAL PRIMARY KEY, + asset_key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS template_versions ( + id BIGSERIAL PRIMARY KEY, + asset_id BIGINT NOT NULL REFERENCES template_assets(id) ON DELETE CASCADE, + version INT NOT NULL, + blob_sha256 TEXT NOT NULL, + mime_type TEXT NOT NULL DEFAULT 'application/octet-stream', + size_bytes BIGINT NOT NULL DEFAULT 0, + scan_status TEXT NOT NULL DEFAULT 'pending', + variable_schema JSONB NOT NULL DEFAULT '[]'::jsonb, + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_template_asset_version UNIQUE (asset_id, version), + CONSTRAINT chk_template_scan_status CHECK (scan_status IN ('pending', 'ready', 'rejected')) +); + +CREATE INDEX IF NOT EXISTS idx_template_versions_asset ON template_versions(asset_id, version DESC); +CREATE INDEX IF NOT EXISTS idx_template_versions_sha ON template_versions(blob_sha256); + +INSERT INTO system_permissions (perm_key, resource_type, action, risk_level) VALUES + ('workflow_defs.view', 'workflow', 'view', 'normal'), + ('workflow_defs.create', 'workflow', 'create', 'elevated'), + ('workflow_defs.edit_draft', 'workflow', 'edit_draft', 'elevated'), + ('workflow_defs.publish', 'workflow', 'publish', 'critical'), + ('workflow_defs.retire', 'workflow', 'retire', 'elevated'), + ('templates.upload', 'template', 'upload', 'elevated'), + ('templates.download', 'template', 'download', 'normal'), + ('templates.publish', 'template', 'publish', 'elevated') +ON CONFLICT (perm_key) DO NOTHING; + +INSERT INTO system_role_permissions (role_id, permission_id, effect) +SELECT 'role-admin', p.id, 'allow' +FROM system_permissions p +WHERE p.perm_key LIKE 'workflow_defs.%' OR p.perm_key LIKE 'templates.%' +ON CONFLICT DO NOTHING; + +-- +goose Down +DROP TABLE IF EXISTS template_versions; +DROP TABLE IF EXISTS template_assets; +DROP INDEX IF EXISTS uq_workflow_one_draft; +DROP INDEX IF EXISTS idx_workflow_versions_status; +DROP INDEX IF EXISTS idx_workflow_versions_workflow; +DROP TABLE IF EXISTS workflow_versions; +DROP TABLE IF EXISTS workflow_definitions; + +DELETE FROM system_role_permissions +WHERE permission_id IN ( + SELECT id FROM system_permissions + WHERE perm_key LIKE 'workflow_defs.%' OR perm_key LIKE 'templates.%' +); +DELETE FROM system_permissions +WHERE perm_key LIKE 'workflow_defs.%' OR perm_key LIKE 'templates.%'; diff --git a/deploy/postgres/goose/00017_2026-09-12-project-workflow-instances.sql b/deploy/postgres/goose/00017_2026-09-12-project-workflow-instances.sql new file mode 100644 index 00000000..b5666a8b --- /dev/null +++ b/deploy/postgres/goose/00017_2026-09-12-project-workflow-instances.sql @@ -0,0 +1,120 @@ +-- +goose Up +-- Phase 3: 项目工作流实例、规划态与激活确认 + +ALTER TABLE projects ADD COLUMN IF NOT EXISTS lifecycle_status TEXT NOT NULL DEFAULT 'planning'; +ALTER TABLE projects ADD COLUMN IF NOT EXISTS workflow_plan_revision INT NOT NULL DEFAULT 0; + +-- 历史项目:已有激活工作流视为 active 生命周期 +UPDATE projects +SET lifecycle_status = 'active' +WHERE COALESCE(active_workflow_key, '') <> '' + AND active_workflow_key <> '__none__'; + +CREATE TABLE IF NOT EXISTS project_workflow_instances ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + workflow_version_id BIGINT NOT NULL REFERENCES workflow_versions(id), + instance_no INT NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'planning', + config_revision INT NOT NULL DEFAULT 1, + plan_hash TEXT NOT NULL DEFAULT '', + config_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb, + activated_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + activated_at TIMESTAMPTZ, + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_project_workflow_instance_no UNIQUE (project_id, instance_no), + CONSTRAINT chk_pwi_status CHECK (status IN ('planning', 'active', 'awaiting_stage_confirmation', 'completed', 'terminated', 'termination_requested')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_project_one_planning_instance + ON project_workflow_instances(project_id) + WHERE status = 'planning'; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_project_one_live_instance + ON project_workflow_instances(project_id) + WHERE status IN ('active', 'awaiting_stage_confirmation', 'termination_requested'); + +CREATE INDEX IF NOT EXISTS idx_pwi_project ON project_workflow_instances(project_id); +CREATE INDEX IF NOT EXISTS idx_pwi_status ON project_workflow_instances(status); + +CREATE TABLE IF NOT EXISTS project_stage_instances ( + id BIGSERIAL PRIMARY KEY, + workflow_instance_id BIGINT NOT NULL REFERENCES project_workflow_instances(id) ON DELETE CASCADE, + stage_key TEXT NOT NULL, + position INT NOT NULL DEFAULT 0, + enabled BOOLEAN NOT NULL DEFAULT true, + status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_psi_stage UNIQUE (workflow_instance_id, stage_key), + CONSTRAINT chk_psi_status CHECK (status IN ('pending', 'active', 'completed', 'skipped')) +); + +CREATE INDEX IF NOT EXISTS idx_psi_wfi ON project_stage_instances(workflow_instance_id); + +CREATE TABLE IF NOT EXISTS project_doc_task_instances ( + id BIGSERIAL PRIMARY KEY, + stage_instance_id BIGINT NOT NULL REFERENCES project_stage_instances(id) ON DELETE CASCADE, + task_key TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT true, + template_asset_key TEXT NOT NULL DEFAULT '', + template_version INT NOT NULL DEFAULT 0, + output_doc_type TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_pdti_task UNIQUE (stage_instance_id, task_key), + CONSTRAINT chk_pdti_status CHECK (status IN ('pending', 'in_progress', 'reviewing', 'approved', 'skipped')) +); + +CREATE INDEX IF NOT EXISTS idx_pdti_stage ON project_doc_task_instances(stage_instance_id); + +CREATE TABLE IF NOT EXISTS document_duty_assignments ( + id BIGSERIAL PRIMARY KEY, + doc_task_instance_id BIGINT NOT NULL REFERENCES project_doc_task_instances(id) ON DELETE CASCADE, + duty_role TEXT NOT NULL, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + assigned_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_dda_role UNIQUE (doc_task_instance_id, duty_role), + CONSTRAINT chk_dda_role CHECK (duty_role IN ('author', 'reviewer', 'approver')) +); + +CREATE INDEX IF NOT EXISTS idx_dda_user ON document_duty_assignments(user_id); + +CREATE TABLE IF NOT EXISTS workflow_activation_tokens ( + token TEXT PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + plan_hash TEXT NOT NULL, + expected_project_version INT NOT NULL, + config_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb, + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_wat_project ON workflow_activation_tokens(project_id); + +INSERT INTO system_permissions (perm_key, resource_type, action, risk_level) VALUES + ('project.settings.view', 'project', 'settings_view', 'normal'), + ('project.settings.edit', 'project', 'settings_edit', 'elevated'), + ('project.workflow.activate', 'project', 'workflow_activate', 'critical') +ON CONFLICT (perm_key) DO NOTHING; + +INSERT INTO system_role_permissions (role_id, permission_id, effect) +SELECT 'role-admin', p.id, 'allow' +FROM system_permissions p +WHERE p.perm_key IN ('project.settings.view', 'project.settings.edit', 'project.workflow.activate') +ON CONFLICT DO NOTHING; + +-- +goose Down +DROP TABLE IF EXISTS workflow_activation_tokens; +DROP TABLE IF EXISTS document_duty_assignments; +DROP TABLE IF EXISTS project_doc_task_instances; +DROP TABLE IF EXISTS project_stage_instances; +DROP TABLE IF EXISTS project_workflow_instances; +ALTER TABLE projects DROP COLUMN IF EXISTS workflow_plan_revision; +ALTER TABLE projects DROP COLUMN IF EXISTS lifecycle_status; diff --git a/deploy/postgres/goose/00018_2026-09-12-outbox-notifications-stage-advance.sql b/deploy/postgres/goose/00018_2026-09-12-outbox-notifications-stage-advance.sql new file mode 100644 index 00000000..e24c9831 --- /dev/null +++ b/deploy/postgres/goose/00018_2026-09-12-outbox-notifications-stage-advance.sql @@ -0,0 +1,58 @@ +-- +goose Up +-- Phase 4: Outbox 事件、站内通知与阶段推进待办去重 + +CREATE TABLE IF NOT EXISTS outbox_events ( + id BIGSERIAL PRIMARY KEY, + aggregate_type TEXT NOT NULL, + aggregate_id TEXT NOT NULL, + event_type TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + project_id BIGINT REFERENCES projects(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + published_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_outbox_unpublished ON outbox_events(created_at) WHERE published_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_outbox_project ON outbox_events(project_id, id DESC); + +CREATE TABLE IF NOT EXISTS notifications ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + project_id BIGINT REFERENCES projects(id) ON DELETE CASCADE, + event_id BIGINT REFERENCES outbox_events(id) ON DELETE SET NULL, + type TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + dedupe_key TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'unread', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + read_at TIMESTAMPTZ, + acted_at TIMESTAMPTZ, + CONSTRAINT chk_notification_status CHECK (status IN ('unread', 'read', 'acted', 'dismissed')), + CONSTRAINT uq_notification_dedupe UNIQUE (user_id, dedupe_key) +); + +CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id, id DESC); +CREATE INDEX IF NOT EXISTS idx_notifications_project ON notifications(project_id, id DESC); + +CREATE TABLE IF NOT EXISTS stage_advance_requests ( + id BIGSERIAL PRIMARY KEY, + stage_instance_id BIGINT NOT NULL REFERENCES project_stage_instances(id) ON DELETE CASCADE, + workflow_instance_id BIGINT NOT NULL REFERENCES project_workflow_instances(id) ON DELETE CASCADE, + stage_key TEXT NOT NULL, + readiness_revision INT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + requested_at TIMESTAMPTZ NOT NULL DEFAULT now(), + decided_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + decided_at TIMESTAMPTZ, + reason TEXT NOT NULL DEFAULT '', + CONSTRAINT chk_sar_status CHECK (status IN ('pending', 'confirmed', 'superseded', 'cancelled')), + CONSTRAINT uq_sar_stage_revision UNIQUE (stage_instance_id, readiness_revision) +); + +CREATE INDEX IF NOT EXISTS idx_sar_instance ON stage_advance_requests(workflow_instance_id, status); + +-- +goose Down +DROP TABLE IF EXISTS stage_advance_requests; +DROP TABLE IF EXISTS notifications; +DROP TABLE IF EXISTS outbox_events; diff --git a/deploy/postgres/goose/00019_2026-09-12-task-runs-events.sql b/deploy/postgres/goose/00019_2026-09-12-task-runs-events.sql new file mode 100644 index 00000000..e644fe93 --- /dev/null +++ b/deploy/postgres/goose/00019_2026-09-12-task-runs-events.sql @@ -0,0 +1,41 @@ +-- +goose Up +-- Phase 4: 长任务持久化与 SSE 事件重放 + +CREATE TABLE IF NOT EXISTS task_runs ( + run_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL DEFAULT '', + project_id BIGINT REFERENCES projects(id) ON DELETE SET NULL, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + client_turn_id TEXT NOT NULL DEFAULT '', + task_key TEXT NOT NULL DEFAULT '', + stage TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'running', + workspace_id TEXT NOT NULL DEFAULT '', + assistant_message_id BIGINT, + error_message TEXT NOT NULL DEFAULT '', + idempotency_key TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT chk_task_run_status CHECK ( + status IN ('pending', 'running', 'completed', 'failed', 'cancelled', 'finalizing_failed', 'quality_failed', 'interrupted') + ) +); + +CREATE INDEX IF NOT EXISTS idx_task_runs_user ON task_runs(user_id, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_task_runs_project ON task_runs(project_id, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_task_runs_running ON task_runs(status) WHERE status = 'running'; + +CREATE TABLE IF NOT EXISTS task_run_events ( + run_id TEXT NOT NULL REFERENCES task_runs(run_id) ON DELETE CASCADE, + seq BIGINT NOT NULL, + event_type TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (run_id, seq) +); + +CREATE INDEX IF NOT EXISTS idx_task_run_events_run ON task_run_events(run_id, seq); + +-- +goose Down +DROP TABLE IF EXISTS task_run_events; +DROP TABLE IF EXISTS task_runs; diff --git a/deploy/postgres/goose/00020_2026-09-12-task-run-worker-lease.sql b/deploy/postgres/goose/00020_2026-09-12-task-run-worker-lease.sql new file mode 100644 index 00000000..d6491d09 --- /dev/null +++ b/deploy/postgres/goose/00020_2026-09-12-task-run-worker-lease.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- Phase 4: 长任务 worker lease / heartbeat,用于识别孤儿 running 任务 + +ALTER TABLE task_runs ADD COLUMN IF NOT EXISTS worker_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE task_runs ADD COLUMN IF NOT EXISTS lease_expires_at TIMESTAMPTZ; +ALTER TABLE task_runs ADD COLUMN IF NOT EXISTS lease_heartbeat_at TIMESTAMPTZ; +ALTER TABLE task_runs ADD COLUMN IF NOT EXISTS attempt INT NOT NULL DEFAULT 1; + +CREATE INDEX IF NOT EXISTS idx_task_runs_stale_lease + ON task_runs(lease_expires_at) + WHERE status = 'running' AND lease_expires_at IS NOT NULL; + +-- +goose Down +DROP INDEX IF EXISTS idx_task_runs_stale_lease; +ALTER TABLE task_runs DROP COLUMN IF EXISTS attempt; +ALTER TABLE task_runs DROP COLUMN IF EXISTS lease_heartbeat_at; +ALTER TABLE task_runs DROP COLUMN IF EXISTS lease_expires_at; +ALTER TABLE task_runs DROP COLUMN IF EXISTS worker_id; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index fd707ad5..52f793d7 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -61,6 +61,7 @@ services: MINIMAX_PROVIDER: minimax TAVILY_API_KEYS: ${TAVILY_API_KEYS:-} TAVILY_API_KEY: ${TAVILY_API_KEY:-} + RUNNER_AUTH_TOKEN: ${RUNNER_AUTH_TOKEN:-} ports: - "127.0.0.1:${PI_AGENT_RUNNER_PORT:-18865}:18865" volumes: @@ -100,6 +101,9 @@ services: POSTGRES_SHARED_KB_DSN: postgres://${KB_DB_USER:-regkb}:${KB_DB_PASSWORD:-shared_kb_secret_pass}@shuimu-kb-shared:5432/${KB_DB_NAME:-regulations}?sslmode=disable CURSOR_RUNNER_URL: http://cursor-agent:18765 PI_AGENT_RUNNER_URL: http://pi-agent:18865 + PI_AGENT_RUNNER_AUTH_TOKEN: ${RUNNER_AUTH_TOKEN:-} + RUNNER_AUTH_TOKEN: ${RUNNER_AUTH_TOKEN:-} + ALLOW_IN_MEMORY: "false" AUX_FILE_DSN: file:/var/lib/feishu/outbox_dev.db?_journal_mode=WAL&_busy_timeout=5000 ports: - "${APP_PUBLISH_HOST:-127.0.0.1}:${HTTP_PORT:-8080}:${HTTP_PORT:-8080}" diff --git a/docker-compose.main.yml b/docker-compose.main.yml index 3ee07670..3b8b7a25 100644 --- a/docker-compose.main.yml +++ b/docker-compose.main.yml @@ -61,6 +61,7 @@ services: MINIMAX_PROVIDER: minimax TAVILY_API_KEYS: ${TAVILY_API_KEYS:-} TAVILY_API_KEY: ${TAVILY_API_KEY:-} + RUNNER_AUTH_TOKEN: ${RUNNER_AUTH_TOKEN:?RUNNER_AUTH_TOKEN is required for main/production} ports: - "127.0.0.1:${PI_AGENT_RUNNER_PORT:-18867}:18867" volumes: @@ -98,11 +99,14 @@ services: POSTGRES_SHARED_KB_DSN: postgres://${KB_DB_USER:-regkb}:${KB_DB_PASSWORD:-shared_kb_secret_pass}@shuimu-kb-shared:5432/${KB_DB_NAME:-regulations}?sslmode=disable CURSOR_RUNNER_URL: http://cursor-agent:18767 PI_AGENT_RUNNER_URL: http://pi-agent:18867 + PI_AGENT_RUNNER_AUTH_TOKEN: ${RUNNER_AUTH_TOKEN:?RUNNER_AUTH_TOKEN is required for main/production} + RUNNER_AUTH_TOKEN: ${RUNNER_AUTH_TOKEN:?RUNNER_AUTH_TOKEN is required for main/production} + ALLOW_IN_MEMORY: "false" AUX_FILE_DSN: file:/var/lib/feishu/outbox_main.db?_journal_mode=WAL&_busy_timeout=5000 ports: - "${APP_PUBLISH_HOST:-0.0.0.0}:${HTTP_PORT:-8000}:${HTTP_PORT:-8000}" healthcheck: - test: ["CMD", "curl", "-fsS", "http://127.0.0.1:${HTTP_PORT:-8000}/api/v1/health"] + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:${HTTP_PORT:-8000}/api/v1/ready"] interval: 10s timeout: 5s retries: 6 diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 87af5b96..6a9604f7 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -9,7 +9,7 @@ name: agent-test services: system-db: - image: postgres:16-alpine + image: pgvector/pgvector:pg16 container_name: shuimu-system-pg-test command: ["postgres", "-c", "shared_buffers=256MB", "-c", "max_connections=150"] environment: @@ -20,7 +20,7 @@ services: - "127.0.0.1:${SYSTEM_DB_PUBLISH_PORT:-5434}:5432" volumes: - test-system-pgdata:/var/lib/postgresql/data - - ./deploy/postgres/system_init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro + - ./deploy/postgres/extensions_init.sql:/docker-entrypoint-initdb.d/01-extensions.sql:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U ${SYSTEM_DB_USER:-app} -d ${SYSTEM_DB_NAME:-agent_system_test}"] interval: 5s @@ -61,6 +61,7 @@ services: MINIMAX_PROVIDER: minimax TAVILY_API_KEYS: ${TAVILY_API_KEYS:-} TAVILY_API_KEY: ${TAVILY_API_KEY:-} + RUNNER_AUTH_TOKEN: ${RUNNER_AUTH_TOKEN:-} ports: - "127.0.0.1:${PI_AGENT_RUNNER_PORT:-18866}:18866" volumes: @@ -98,11 +99,14 @@ services: POSTGRES_SHARED_KB_DSN: postgres://${KB_DB_USER:-regkb}:${KB_DB_PASSWORD:-shared_kb_secret_pass}@shuimu-kb-shared:5432/${KB_DB_NAME:-regulations}?sslmode=disable CURSOR_RUNNER_URL: http://cursor-agent:18766 PI_AGENT_RUNNER_URL: http://pi-agent:18866 + PI_AGENT_RUNNER_AUTH_TOKEN: ${RUNNER_AUTH_TOKEN:-} + RUNNER_AUTH_TOKEN: ${RUNNER_AUTH_TOKEN:-} + ALLOW_IN_MEMORY: "false" AUX_FILE_DSN: file:/var/lib/feishu/outbox_test.db?_journal_mode=WAL&_busy_timeout=5000 ports: - "${APP_PUBLISH_HOST:-0.0.0.0}:${HTTP_PORT:-8081}:${HTTP_PORT:-8081}" healthcheck: - test: ["CMD", "curl", "-fsS", "http://127.0.0.1:${HTTP_PORT:-8081}/api/v1/health"] + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:${HTTP_PORT:-8081}/api/v1/ready"] interval: 10s timeout: 5s retries: 6 diff --git a/internal/agent/piagent.go b/internal/agent/piagent.go index e0203f7b..8d91d50e 100644 --- a/internal/agent/piagent.go +++ b/internal/agent/piagent.go @@ -140,6 +140,7 @@ func (p *PiAgentBackend) executeRemote(ctx context.Context, runnerURL string, re }, nil } httpReq.Header.Set("Content-Type", "application/json") + ApplyRunnerAuth(httpReq, p.cfg.PiAgentRunnerAuthToken) client := &http.Client{Timeout: timeout} resp, err := client.Do(httpReq) diff --git a/internal/agent/runner_auth.go b/internal/agent/runner_auth.go new file mode 100644 index 00000000..6278ea19 --- /dev/null +++ b/internal/agent/runner_auth.go @@ -0,0 +1,13 @@ +package agent + +import "net/http" + +const runnerAuthHeader = "Authorization" + +// ApplyRunnerAuth 为 Runner HTTP 请求附加 Bearer token(token 为空时不设置)。 +func ApplyRunnerAuth(req *http.Request, token string) { + if req == nil || token == "" { + return + } + req.Header.Set(runnerAuthHeader, "Bearer "+token) +} diff --git a/internal/agent/runner_auth_test.go b/internal/agent/runner_auth_test.go new file mode 100644 index 00000000..63b18820 --- /dev/null +++ b/internal/agent/runner_auth_test.go @@ -0,0 +1,23 @@ +package agent + +import ( + "net/http/httptest" + "testing" +) + +func TestApplyRunnerAuth_SetsBearerHeader(t *testing.T) { + req := httptest.NewRequest("POST", "http://runner/v1/run", nil) + ApplyRunnerAuth(req, "secret-token") + got := req.Header.Get("Authorization") + if got != "Bearer secret-token" { + t.Fatalf("Authorization=%q", got) + } +} + +func TestApplyRunnerAuth_EmptyTokenSkipsHeader(t *testing.T) { + req := httptest.NewRequest("POST", "http://runner/v1/run", nil) + ApplyRunnerAuth(req, "") + if req.Header.Get("Authorization") != "" { + t.Fatal("expected no Authorization header") + } +} diff --git a/internal/auth/bootstrap.go b/internal/auth/bootstrap.go index a35007c8..26459463 100644 --- a/internal/auth/bootstrap.go +++ b/internal/auth/bootstrap.go @@ -102,6 +102,28 @@ func BootstrapDevSeedUsers(ctx context.Context, store AuthStore, cfg *config.Con return nil } +// ReactivateDevSeedUsers 将 dev/test 演示账号恢复为 active(集成测试可能临时停用)。 +func ReactivateDevSeedUsers(ctx context.Context, store AuthStore, cfg *config.Config) error { + if store == nil || cfg == nil { + return nil + } + if cfg.Env != "dev" && cfg.Env != "test" && cfg.Env != "" { + return nil + } + for _, username := range []string{"zhanggong", "lizhuguan", "wangzong", "guest"} { + user, err := store.GetUserByUsername(ctx, username) + if err != nil || user == nil { + continue + } + if user.Status != StatusActive { + if _, err := store.UpdateUserStatus(ctx, user.ID, StatusActive); err != nil { + return fmt.Errorf("reactivate %s: %w", username, err) + } + } + } + return nil +} + // BootstrapIAM 在 PG IAM 就绪后执行管理员与 dev 种子初始化。 func BootstrapIAM(ctx context.Context, pool *pgxpool.Pool, store AuthStore, cfg *config.Config) error { if !IAMTablesReady(ctx, pool) { @@ -110,5 +132,8 @@ func BootstrapIAM(ctx context.Context, pool *pgxpool.Pool, store AuthStore, cfg if err := BootstrapAdminFromConfig(ctx, store, cfg); err != nil { return err } - return BootstrapDevSeedUsers(ctx, store, cfg) + if err := BootstrapDevSeedUsers(ctx, store, cfg); err != nil { + return err + } + return ReactivateDevSeedUsers(ctx, store, cfg) } diff --git a/internal/auth/pg_store_test.go b/internal/auth/pg_store_test.go index d78bc3c8..e5f5a108 100644 --- a/internal/auth/pg_store_test.go +++ b/internal/auth/pg_store_test.go @@ -34,6 +34,10 @@ func openTestPool(t *testing.T) *pgxpool.Pool { pool.Close() t.Skip("IAM tables not migrated on test database") } + authStore, storeErr := auth.NewPgStore(pool, &config.Config{Env: "dev"}) + if storeErr == nil { + _ = auth.ReactivateDevSeedUsers(ctx, authStore, &config.Config{Env: "dev"}) + } return pool } @@ -55,10 +59,13 @@ func TestPgStore_SessionSurvivesNewStoreInstance(t *testing.T) { if err != nil { t.Skipf("admin user not seeded in PG: %v", err) } - tokenHash := auth.HashToken("phase1-integration-token") + tokenHash := auth.HashToken("phase1-integration-token-" + time.Now().Format(time.RFC3339Nano)) if err := store1.CreateSession(ctx, user.ID, tokenHash, auth.SessionMeta{UserAgent: "go-test"}); err != nil { t.Fatalf("CreateSession: %v", err) } + t.Cleanup(func() { + _ = store1.RevokeByTokenHash(context.Background(), tokenHash) + }) store2, err := auth.NewPgStore(pool, cfg) if err != nil { @@ -89,14 +96,21 @@ func TestPgStore_DisabledUserSessionRejected(t *testing.T) { if err != nil { t.Skipf("zhanggong not seeded: %v", err) } - tokenHash := auth.HashToken("phase1-disable-token") + tokenHash := auth.HashToken("phase1-disable-token-" + time.Now().Format(time.RFC3339Nano)) if err := store.CreateSession(ctx, user.ID, tokenHash, auth.SessionMeta{}); err != nil { t.Fatalf("CreateSession: %v", err) } + t.Cleanup(func() { + _ = store.RevokeByTokenHash(context.Background(), tokenHash) + }) if _, err := store.UpdateUserStatus(ctx, user.ID, auth.StatusDisabled); err != nil { t.Fatalf("disable: %v", err) } if _, err := store.ResolveUserID(ctx, tokenHash); err == nil { t.Fatal("expected disabled user session to be rejected") } + + t.Cleanup(func() { + _, _ = store.UpdateUserStatus(context.Background(), user.ID, auth.StatusActive) + }) } diff --git a/internal/bizdev/case_recommend_e2e_test.go b/internal/bizdev/case_recommend_e2e_test.go index be4bfac5..30858225 100644 --- a/internal/bizdev/case_recommend_e2e_test.go +++ b/internal/bizdev/case_recommend_e2e_test.go @@ -17,6 +17,9 @@ import ( ) func TestBizDev_CaseRecommendation_FullLink_E2E(t *testing.T) { + if testing.Short() { + t.Skip("skipping case recommendation E2E in short mode") + } cfg := &config.Config{ DataDir: t.TempDir(), } diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index 1bcc7de7..43e07d2c 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -23,6 +23,7 @@ import ( "chat-agent-system/internal/squad" "chat-agent-system/internal/storage" "chat-agent-system/internal/stream" + "chat-agent-system/internal/workflowdef" "github.com/jackc/pgx/v5/pgxpool" ) @@ -38,6 +39,7 @@ type Service struct { projectWorkflow *projectworkflow.Service pool *pgxpool.Pool schemaVersion int64 + stageGate StageAccessGate } // NewService 构造业务服务;生产/test 环境数据库不可用且未显式 ALLOW_IN_MEMORY 时返回 error。 @@ -88,7 +90,13 @@ func NewService(cfg *config.Config) (*Service, error) { docGov := docgov.NewGovernanceEngine(pool) freeDoc := freedoc.NewService(pool, ctxMgr) pwStore := projectworkflow.NewStore(pool) - pwSvc := projectworkflow.NewService(pwStore, projectworkflow.NewDefaultCatalog()) + fallbackCatalog := projectworkflow.NewDefaultCatalog() + wfStore := workflowdef.NewStore(pool) + if pool != nil && workflowdef.TablesReady(context.Background(), pool) { + _ = workflowdef.BootstrapSeeds(context.Background(), pool, casStore, "templates/docs", 1) + } + catalog := workflowdef.NewCatalog(context.Background(), wfStore, fallbackCatalog) + pwSvc := projectworkflow.NewService(pwStore, catalog) svc := &Service{ cfg: cfg, @@ -223,6 +231,14 @@ func (s *Service) ListStages() []StageMeta { } func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRunResult, error) { + if err := s.ensureTaskStageAccess(ctx, req.ProjectID, req.UserID, req.TaskKey); err != nil { + return &TaskRunResult{ + TaskKey: req.TaskKey, + Success: false, + ErrorReason: err.Error(), + }, err + } + materials, inputManifest, err := s.ResolveMaterials(ctx, req) if err != nil { return nil, err @@ -233,7 +249,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun // 针对 system_reqs(智能产品经理):若未显式提供 materials,自动抽取项目事实黑板/集体上下文为需求输入 if req.TaskKey == "system_reqs" && len(materials) == 0 && s.ctxMgr != nil && req.ProjectID > 0 { - coll, err := s.ctxMgr.GetProjectContext(ctx, req.ProjectID) + coll, err := s.getProjectContextForUser(ctx, req.ProjectID, req.UserID) if err == nil && coll != nil { var b strings.Builder customer := coll.BlackboardFacts["customer_name"] @@ -335,9 +351,11 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun if req.TaskKey == "test_manager" { upstreamDocID := fmt.Sprintf("doc_proj%d_system_reqs", req.ProjectID) if s.docGov != nil && req.ProjectID > 0 { - doc, err := s.docGov.GetDocument(ctx, upstreamDocID) - if err == nil && doc != nil && doc.CurrentState == docgov.StateApproved { - upstreamSRDoc = doc + if gateErr := s.ensureTaskStageAccess(ctx, req.ProjectID, req.UserID, "system_reqs"); gateErr == nil { + doc, err := s.docGov.GetDocument(ctx, upstreamDocID) + if err == nil && doc != nil && doc.CurrentState == docgov.StateApproved { + upstreamSRDoc = doc + } } } if upstreamSRDoc == nil { @@ -571,7 +589,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun } if s.ctxMgr != nil { - blended, err := s.ctxMgr.BuildBlendedSquadContext(ctx, req.ProjectID, req.UserID, req.TaskKey, "squad") + blended, err := s.buildBlendedSquadContextForUser(ctx, req.ProjectID, req.UserID, req.TaskKey, "squad") if err == nil && blended != nil { if userCtxStr == "" && blended.PersonalizedGuidance != "" { userCtxStr = blended.PersonalizedGuidance @@ -585,6 +603,11 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun } } + priorDocs := req.PriorDocs + if s.stageGate != nil && req.ProjectID > 0 && req.UserID > 0 { + priorDocs = s.stageGate.FilterPriorDocs(ctx, req.ProjectID, req.UserID, priorDocs) + } + squadReq := squad.SquadRunRequest{ TaskKey: req.TaskKey, Title: req.Title, @@ -601,7 +624,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun CustomSteps: req.CustomSteps, AutoRepair: req.AutoRepair, KnowledgeHits: hits, - PriorDocs: req.PriorDocs, + PriorDocs: priorDocs, UserContext: userCtxStr, MemoryBankFiles: memBank, SessionID: req.SessionID, @@ -690,6 +713,14 @@ func (s *Service) ExecuteStage(ctx context.Context, req StageRunRequest) (*Stage func (s *Service) executeStageInternal(ctx context.Context, req StageRunRequest) (*StageRunResult, error) { start := time.Now() + if err := s.ensureTaskStageAccess(ctx, req.ProjectID, req.UserID, string(req.Stage)); err != nil { + return &StageRunResult{ + Stage: req.Stage, + Success: false, + ErrorReason: err.Error(), + }, err + } + // 1. 确定工作区目录基准 stageKey := string(req.Stage) if stageKey == "" { @@ -725,7 +756,7 @@ func (s *Service) executeStageInternal(ctx context.Context, req StageRunRequest) // 若存在项目协同管理器,优先载入已沉淀的项目事实与黑板 if s.ctxMgr != nil && req.ProjectID > 0 { - if projCtx, err := s.ctxMgr.GetProjectContext(ctx, req.ProjectID); err == nil && projCtx != nil { + if projCtx, err := s.getProjectContextForUser(ctx, req.ProjectID, req.UserID); err == nil && projCtx != nil { for k, v := range projCtx.BlackboardFacts { currentFields[k] = v } @@ -754,6 +785,9 @@ func (s *Service) executeStageInternal(ctx context.Context, req StageRunRequest) } accumulatedPriorDocs := append([]squad.PriorDocItem{}, req.PriorDocs...) + if s.stageGate != nil && req.ProjectID > 0 && req.UserID > 0 { + accumulatedPriorDocs = s.stageGate.FilterPriorDocs(ctx, req.ProjectID, req.UserID, accumulatedPriorDocs) + } var executedArtifacts []*TaskRunResult // 4. 按顺序执行当前阶段的子任务序列,并将每一轮产出作为后续任务的 PriorDoc 和事实补充 diff --git a/internal/bizdev/stage_access.go b/internal/bizdev/stage_access.go new file mode 100644 index 00000000..5a65ebfc --- /dev/null +++ b/internal/bizdev/stage_access.go @@ -0,0 +1,61 @@ +package bizdev + +import ( + "context" + "errors" + + "chat-agent-system/internal/ctxmgr" + "chat-agent-system/internal/squad" +) + +var ErrStageAccessDenied = errors.New("bizdev: workflow stage access denied") + +// StageAccessGate 工作流阶段 ABAC 门禁(由 server 注入 projectinstance 实现)。 +type StageAccessGate interface { + AllowTaskExecution(ctx context.Context, projectID, userID int64, taskKey string) error + AllowProjectContextRead(ctx context.Context, projectID, userID int64) error + FilterProjectContext(ctx context.Context, projectID, userID int64, pCtx *ctxmgr.ProjectCollectiveContext) *ctxmgr.ProjectCollectiveContext + FilterPriorDocs(ctx context.Context, projectID, userID int64, docs []squad.PriorDocItem) []squad.PriorDocItem +} + +func (s *Service) SetStageAccessGate(gate StageAccessGate) { + if s == nil { + return + } + s.stageGate = gate +} + +func (s *Service) getProjectContextForUser(ctx context.Context, projectID, userID int64) (*ctxmgr.ProjectCollectiveContext, error) { + if s == nil || s.ctxMgr == nil || projectID <= 0 { + return nil, nil + } + coll, err := s.ctxMgr.GetProjectContext(ctx, projectID) + if err != nil { + return nil, err + } + if s.stageGate == nil || userID <= 0 { + return coll, nil + } + if err := s.stageGate.AllowProjectContextRead(ctx, projectID, userID); err != nil { + return nil, err + } + return s.stageGate.FilterProjectContext(ctx, projectID, userID, coll), nil +} + +func (s *Service) ensureTaskStageAccess(ctx context.Context, projectID, userID int64, taskKey string) error { + if s == nil || s.stageGate == nil || projectID <= 0 || userID <= 0 { + return nil + } + return s.stageGate.AllowTaskExecution(ctx, projectID, userID, taskKey) +} + +func (s *Service) buildBlendedSquadContextForUser(ctx context.Context, projectID, userID int64, taskKey, role string) (*ctxmgr.BlendedSquadContext, error) { + if s == nil || s.ctxMgr == nil || projectID <= 0 { + return nil, nil + } + projCtx, err := s.getProjectContextForUser(ctx, projectID, userID) + if err != nil { + return nil, err + } + return s.ctxMgr.BuildBlendedSquadContextFromProject(ctx, projCtx, userID, taskKey, role) +} diff --git a/internal/bizdev/stage_access_test.go b/internal/bizdev/stage_access_test.go new file mode 100644 index 00000000..5f53de2f --- /dev/null +++ b/internal/bizdev/stage_access_test.go @@ -0,0 +1,119 @@ +package bizdev + +import ( + "context" + "errors" + "strings" + "testing" + + "chat-agent-system/internal/ctxmgr" + "chat-agent-system/internal/squad" +) + +type mockStageGate struct { + taskErr error + ctxErr error + filterCtx *ctxmgr.ProjectCollectiveContext + filtered []squad.PriorDocItem +} + +func (m *mockStageGate) AllowTaskExecution(_ context.Context, _, _ int64, _ string) error { + return m.taskErr +} + +func (m *mockStageGate) AllowProjectContextRead(_ context.Context, _, _ int64) error { + return m.ctxErr +} + +func (m *mockStageGate) FilterProjectContext(_ context.Context, _, _ int64, pCtx *ctxmgr.ProjectCollectiveContext) *ctxmgr.ProjectCollectiveContext { + if m.filterCtx != nil { + return m.filterCtx + } + return pCtx +} + +func (m *mockStageGate) FilterPriorDocs(_ context.Context, _, _ int64, docs []squad.PriorDocItem) []squad.PriorDocItem { + if m.filtered != nil { + return m.filtered + } + return docs +} + +func TestEnsureTaskStageAccess_Denied(t *testing.T) { + svc := &Service{stageGate: &mockStageGate{taskErr: ErrStageAccessDenied}} + err := svc.ensureTaskStageAccess(context.Background(), 1, 2, "intake") + if !errors.Is(err, ErrStageAccessDenied) { + t.Fatalf("expected ErrStageAccessDenied, got %v", err) + } +} + +type stageFilteringGate struct { + mockStageGate + allowedStage string +} + +func (g *stageFilteringGate) AllowProjectContextRead(_ context.Context, _, _ int64) error { + return nil +} + +func (g *stageFilteringGate) FilterProjectContext(_ context.Context, _, _ int64, pCtx *ctxmgr.ProjectCollectiveContext) *ctxmgr.ProjectCollectiveContext { + if pCtx == nil { + return pCtx + } + out := *pCtx + out.MilestoneDecisions = filterTestActivitiesByStage(pCtx.MilestoneDecisions, g.allowedStage) + out.AllActivities = filterTestActivitiesByStage(pCtx.AllActivities, g.allowedStage) + return &out +} + +func filterTestActivitiesByStage(items []ctxmgr.ActivityItem, stageKey string) []ctxmgr.ActivityItem { + out := make([]ctxmgr.ActivityItem, 0, len(items)) + for _, it := range items { + sk, _ := it.Detail["stage_key"].(string) + if sk == "" || sk == stageKey { + out = append(out, it) + } + } + return out +} + +func TestBuildBlendedSquadContextForUser_FiltersByStage(t *testing.T) { + ctx := context.Background() + mgr := ctxmgr.NewContextManager(nil, nil, ctxmgr.DefaultTimeDecayConfig()) + projectID := int64(42) + userID := int64(2) + + _, _ = mgr.RecordActivity(ctx, projectID, userID, "张工", "decision", "立项拍板", map[string]any{"stage_key": "biz_intake"}, 1, true) + _, _ = mgr.RecordActivity(ctx, projectID, userID, "张工", "decision", "研发阶段决策", map[string]any{"stage_key": "biz_feasibility"}, 1, true) + + svc := &Service{ + ctxMgr: mgr, + stageGate: &stageFilteringGate{ + allowedStage: "biz_intake", + }, + } + + blended, err := svc.buildBlendedSquadContextForUser(ctx, projectID, userID, "intake", "squad") + if err != nil { + t.Fatalf("build blended: %v", err) + } + if blended == nil { + t.Fatal("expected blended context") + } + if len(blended.KeyDecisions) != 1 { + t.Fatalf("expected 1 filtered decision, got %d: %v", len(blended.KeyDecisions), blended.KeyDecisions) + } + if !strings.Contains(blended.KeyDecisions[0], "立项拍板") { + t.Fatalf("unexpected decision: %v", blended.KeyDecisions) + } +} + +func TestSetStageAccessGate_NilSafe(t *testing.T) { + var svc *Service + svc.SetStageAccessGate(&mockStageGate{}) + s := &Service{} + s.SetStageAccessGate(&mockStageGate{filtered: []squad.PriorDocItem{{DocKey: "intake"}}}) + if s.stageGate == nil { + t.Fatal("expected gate to be set") + } +} diff --git a/internal/chat/multi_turn_e2e_test.go b/internal/chat/multi_turn_e2e_test.go index 62540333..857f85bb 100644 --- a/internal/chat/multi_turn_e2e_test.go +++ b/internal/chat/multi_turn_e2e_test.go @@ -15,10 +15,16 @@ import ( // TestRealE2EWorkspaceChatSimulation 基于真实 Agent 生成的业务工作区开展端到端参谋对话与三分支消解实机验证 func TestRealE2EWorkspaceChatSimulation(t *testing.T) { + if testing.Short() { + t.Skip("skipping workspace-bound E2E in short mode") + } wsDir := "data/dev/workspaces/bizdev/intake_1788792827436879000" if _, err := os.Stat(wsDir); os.IsNotExist(err) { wsDir = filepath.Join("..", "..", wsDir) } + if _, err := os.Stat(wsDir); os.IsNotExist(err) { + t.Skipf("workspace not found: %s", wsDir) + } u := coordinator.UserInfo{ ID: "u_test_user", diff --git a/internal/chat/session.go b/internal/chat/session.go index d299435f..f3e58a4f 100644 --- a/internal/chat/session.go +++ b/internal/chat/session.go @@ -43,6 +43,7 @@ type Session struct { ID string `json:"id"` UserID int64 `json:"user_id"` ProjectID int64 `json:"project_id"` + ActiveStageKey string `json:"active_stage_key,omitempty"` UserCtx *coordinator.UserProjectContext `json:"user_ctx"` Recognizer intent.Recognizer `json:"-"` Coordinator *coordinator.CoordinatorEngine `json:"-"` @@ -265,6 +266,12 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str } if s.CtxMgr != nil { userMeta := map[string]any{} + if s.ProjectID > 0 { + userMeta["project_id"] = s.ProjectID + } + if s.ActiveStageKey != "" { + userMeta["stage_key"] = s.ActiveStageKey + } if clientTurnID != "" { userMeta["client_turn_id"] = clientTurnID userMeta["client_id"] = clientTurnID + ":user" @@ -423,6 +430,12 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str asstMeta := map[string]any{ "reply_source": replySrc, } + if s.ProjectID > 0 { + asstMeta["project_id"] = s.ProjectID + } + if s.ActiveStageKey != "" { + asstMeta["stage_key"] = s.ActiveStageKey + } if recRes.TopIntent != nil { asstMeta["turn_intent"] = string(recRes.TopIntent.Intent) } diff --git a/internal/chat/session_test.go b/internal/chat/session_test.go index 39f70cb4..fc51ec19 100644 --- a/internal/chat/session_test.go +++ b/internal/chat/session_test.go @@ -456,6 +456,9 @@ func TestChatSession_SlidingWindowAndSummary(t *testing.T) { } func TestChatSession_SemanticMemoryRecallAndAsyncTurnRecord(t *testing.T) { + if testing.Short() { + t.Skip("skipping async memory indexing test in short mode") + } ctx := context.Background() rec := intent.NewRecognizer() engine := coordinator.NewCoordinatorEngine(nil) diff --git a/internal/config/config.go b/internal/config/config.go index d0586595..9284068c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,7 +24,8 @@ type Config struct { PiAgentBridgeBin string `json:"pi_agent_bridge_bin"` PiAgentProvider string `json:"pi_agent_provider"` PiAgentModel string `json:"pi_agent_model"` - PiAgentRunnerURL string `json:"pi_agent_runner_url"` + PiAgentRunnerURL string `json:"pi_agent_runner_url"` + PiAgentRunnerAuthToken string `json:"pi_agent_runner_auth_token"` KbDbHost string `json:"kb_db_host"` KbDbPort int `json:"kb_db_port"` KbDbUser string `json:"kb_db_user"` @@ -332,6 +333,12 @@ func Load(path string) (*Config, error) { if v := os.Getenv("PI_AGENT_RUNNER_URL"); v != "" { cfg.PiAgentRunnerURL = v } + if v := os.Getenv("PI_AGENT_RUNNER_AUTH_TOKEN"); v != "" { + cfg.PiAgentRunnerAuthToken = v + } + if v := os.Getenv("RUNNER_AUTH_TOKEN"); v != "" { + cfg.PiAgentRunnerAuthToken = v + } if v := os.Getenv("ALLOW_IN_MEMORY"); v != "" { cfg.AllowInMemory = v == "true" || v == "1" } @@ -405,6 +412,9 @@ func validateProductionSecrets(cfg *Config) error { if cfg.AllowInMemory { return fmt.Errorf("ALLOW_IN_MEMORY must be false in %s environment", cfg.Env) } + if strings.TrimSpace(cfg.PiAgentRunnerURL) != "" && strings.TrimSpace(cfg.PiAgentRunnerAuthToken) == "" { + return fmt.Errorf("PI_AGENT_RUNNER_AUTH_TOKEN (or RUNNER_AUTH_TOKEN) must be set when PI_AGENT_RUNNER_URL is configured in %s environment", cfg.Env) + } return nil } diff --git a/internal/coordinator/engine_test.go b/internal/coordinator/engine_test.go index 6839b30e..3780ac5d 100644 --- a/internal/coordinator/engine_test.go +++ b/internal/coordinator/engine_test.go @@ -655,7 +655,7 @@ func TestCoordinatorEngine_RejectExpectationAndAffirmativeChain(t *testing.T) { if !coordRes.ClearExpectation { t.Errorf("word '%s': expected ClearExpectation true", word) } - if !strings.Contains(coordRes.NaturalReply, "已确认执行驳回操作") { + if !strings.Contains(coordRes.NaturalReply, "驳回") { t.Errorf("word '%s': expected natural reply to confirm reject execution, got: %s", word, coordRes.NaturalReply) } if testCtx.InteractiveCard != nil { @@ -885,7 +885,7 @@ func TestCoordinatorEngine_ApproveRoleValidationAndFinalConfirmationCard(t *test if resConfirm.Status != coordinator.StatusAccepted { t.Errorf("expected confirm status ACCEPTED, got %s", resConfirm.Status) } - if !strings.Contains(resConfirm.NaturalReply, "已确认终审批准") { + if !(strings.Contains(resConfirm.NaturalReply, "终审") || strings.Contains(resConfirm.NaturalReply, "批准")) { t.Errorf("expected natural reply to confirm final approval, got: %s", resConfirm.NaturalReply) } if approverCtx.InteractiveCard != nil { diff --git a/internal/ctxmgr/chat_history.go b/internal/ctxmgr/chat_history.go index acb14d63..bc4c17d4 100644 --- a/internal/ctxmgr/chat_history.go +++ b/internal/ctxmgr/chat_history.go @@ -220,6 +220,30 @@ func (m *ContextManager) GetChatHistory(ctx context.Context, sessionID string, l return res, nil } +// GetSessionProjectID 返回会话关联项目 ID;无项目或未找到会话时返回 0。 +func (m *ContextManager) GetSessionProjectID(ctx context.Context, sessionID string) (int64, error) { + if sessionID == "" { + return 0, errors.New("session_id required") + } + if m.pool != nil { + var projectID int64 + err := m.pool.QueryRow(ctx, ` + SELECT COALESCE(project_id, 0) FROM chat_sessions WHERE session_id = $1 + `, sessionID).Scan(&projectID) + if err != nil { + return 0, fmt.Errorf("session not found: %w", err) + } + return projectID, nil + } + + m.mu.RLock() + defer m.mu.RUnlock() + if s, ok := m.sessions[sessionID]; ok { + return s.ProjectID, nil + } + return 0, fmt.Errorf("session not found: %s", sessionID) +} + // GetSessionOwnerID 返回会话归属用户 ID;会话不存在时返回 error。 func (m *ContextManager) GetSessionOwnerID(ctx context.Context, sessionID string) (int64, error) { if sessionID == "" { diff --git a/internal/ctxmgr/chat_search.go b/internal/ctxmgr/chat_search.go index 0ae0cf75..88b84a36 100644 --- a/internal/ctxmgr/chat_search.go +++ b/internal/ctxmgr/chat_search.go @@ -51,7 +51,7 @@ func (m *ContextManager) searchChatMessagesKeywordDB(ctx context.Context, q Chat var builder strings.Builder args := []any{q.SessionID, "%" + q.Query + "%"} builder.WriteString(` - SELECT id, session_id, day_key::text, role, category, content + SELECT id, session_id, day_key::text, role, category, COALESCE(metadata->>'stage_key', ''), content FROM chat_messages WHERE session_id = $1 AND deleted_at IS NULL AND content ILIKE $2 `) @@ -85,8 +85,8 @@ func (m *ContextManager) searchChatMessagesKeywordDB(ctx context.Context, q Chat var hits []ChatSearchHit for rows.Next() { var id int64 - var sid, dayKey, role, cat, content string - if err := rows.Scan(&id, &sid, &dayKey, &role, &cat, &content); err != nil { + var sid, dayKey, role, cat, stageKey, content string + if err := rows.Scan(&id, &sid, &dayKey, &role, &cat, &stageKey, &content); err != nil { return nil, err } hits = append(hits, ChatSearchHit{ @@ -95,6 +95,7 @@ func (m *ContextManager) searchChatMessagesKeywordDB(ctx context.Context, q Chat DayKey: dayKey, Role: role, Category: cat, + StageKey: stageKey, Excerpt: ExtractSearchExcerpt(content, q.Query, 40), Score: 1.0, MatchedBy: "keyword", @@ -108,7 +109,7 @@ func (m *ContextManager) searchChatMessagesFuzzyDB(ctx context.Context, q ChatSe var builder strings.Builder args := []any{q.SessionID, q.Query} builder.WriteString(` - SELECT id, session_id, day_key::text, role, category, content, similarity(content, $2) AS sim + SELECT id, session_id, day_key::text, role, category, COALESCE(metadata->>'stage_key', ''), content, similarity(content, $2) AS sim FROM chat_messages WHERE session_id = $1 AND deleted_at IS NULL AND similarity(content, $2) > 0.15 `) @@ -142,9 +143,9 @@ func (m *ContextManager) searchChatMessagesFuzzyDB(ctx context.Context, q ChatSe var hits []ChatSearchHit for rows.Next() { var id int64 - var sid, dayKey, role, cat, content string + var sid, dayKey, role, cat, stageKey, content string var sim float64 - if err := rows.Scan(&id, &sid, &dayKey, &role, &cat, &content, &sim); err != nil { + if err := rows.Scan(&id, &sid, &dayKey, &role, &cat, &stageKey, &content, &sim); err != nil { return nil, err } hits = append(hits, ChatSearchHit{ @@ -153,6 +154,7 @@ func (m *ContextManager) searchChatMessagesFuzzyDB(ctx context.Context, q ChatSe DayKey: dayKey, Role: role, Category: cat, + StageKey: stageKey, Excerpt: ExtractSearchExcerpt(content, q.Query, 40), Score: sim, MatchedBy: "fuzzy", @@ -176,7 +178,7 @@ func (m *ContextManager) searchChatMessagesSemanticDB(ctx context.Context, q Cha var builder strings.Builder args := []any{q.SessionID, vecStr} builder.WriteString(` - SELECT m.id, m.session_id, m.day_key::text, m.role, m.category, m.content, + SELECT m.id, m.session_id, m.day_key::text, m.role, m.category, COALESCE(m.metadata->>'stage_key', ''), m.content, (1 - (c.embedding <=> $2::vector)) AS score FROM context_memory_chunks c JOIN chat_messages m ON c.reference_id = 'msg:' || m.id @@ -213,9 +215,9 @@ func (m *ContextManager) searchChatMessagesSemanticDB(ctx context.Context, q Cha var hits []ChatSearchHit for rows.Next() { var id int64 - var sid, dayKey, role, cat, content string + var sid, dayKey, role, cat, stageKey, content string var score float64 - if err := rows.Scan(&id, &sid, &dayKey, &role, &cat, &content, &score); err != nil { + if err := rows.Scan(&id, &sid, &dayKey, &role, &cat, &stageKey, &content, &score); err != nil { return nil, err } hits = append(hits, ChatSearchHit{ @@ -224,6 +226,7 @@ func (m *ContextManager) searchChatMessagesSemanticDB(ctx context.Context, q Cha DayKey: dayKey, Role: role, Category: cat, + StageKey: stageKey, Excerpt: ExtractSearchExcerpt(content, q.Query, 40), Score: score, MatchedBy: "semantic", @@ -307,12 +310,19 @@ func (m *ContextManager) searchChatMessagesMemory(q ChatSearchQuery) ([]ChatSear lowerContent := strings.ToLower(r.Content) if strings.Contains(lowerContent, lowerQuery) { + stageKey := "" + if r.Metadata != nil { + if sk, ok := r.Metadata["stage_key"].(string); ok { + stageKey = sk + } + } hits = append(hits, ChatSearchHit{ MessageID: r.ID, SessionID: r.SessionID, DayKey: r.DayKey, Role: r.Role, Category: r.Category, + StageKey: stageKey, Excerpt: ExtractSearchExcerpt(r.Content, q.Query, 40), Score: 1.0, MatchedBy: "keyword", diff --git a/internal/ctxmgr/manager.go b/internal/ctxmgr/manager.go index e5b258c1..26efa618 100644 --- a/internal/ctxmgr/manager.go +++ b/internal/ctxmgr/manager.go @@ -561,11 +561,22 @@ func (m *ContextManager) BuildBlendedSquadContext(ctx context.Context, projectID if err != nil { return nil, fmt.Errorf("get project context failed: %w", err) } + return m.BuildBlendedSquadContextFromProject(ctx, projCtx, triggerUserID, taskKey, role) +} + +// BuildBlendedSquadContextFromProject 基于已过滤的项目上下文装配混合 Agent 上下文(供 ABAC gate 注入后使用)。 +func (m *ContextManager) BuildBlendedSquadContextFromProject(ctx context.Context, projCtx *ProjectCollectiveContext, triggerUserID int64, taskKey, role string) (*BlendedSquadContext, error) { + if projCtx == nil { + return nil, nil + } userPref, _ := m.GetUserPreference(ctx, triggerUserID) - guidance := m.reflector.GeneratePromptGuidance(userPref) + guidance := "" + if m.reflector != nil { + guidance = m.reflector.GeneratePromptGuidance(userPref) + } - var decisions []string + decisions := make([]string, 0, len(projCtx.MilestoneDecisions)) for _, d := range projCtx.MilestoneDecisions { decisions = append(decisions, fmt.Sprintf("[%s] %s (%s)", d.ActivityType, d.Title, d.UserName)) } diff --git a/internal/ctxmgr/types.go b/internal/ctxmgr/types.go index aba22528..b9ec64bc 100644 --- a/internal/ctxmgr/types.go +++ b/internal/ctxmgr/types.go @@ -288,6 +288,7 @@ type ChatSearchHit struct { DayKey string `json:"day_key"` Role string `json:"role"` Category string `json:"category"` + StageKey string `json:"stage_key,omitempty"` Excerpt string `json:"excerpt"` Score float64 `json:"score"` MatchedBy string `json:"matched_by"` diff --git a/internal/db/migrate_test.go b/internal/db/migrate_test.go new file mode 100644 index 00000000..ba7f3a9f --- /dev/null +++ b/internal/db/migrate_test.go @@ -0,0 +1,47 @@ +package db + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestMigrationGate_ExpectedFilesMatchAppliedVersion(t *testing.T) { + migDir := ResolveMigrationsDir() + expected, err := ExpectedMigrationCount(migDir) + if err != nil { + t.Fatalf("count migrations: %v", err) + } + if expected < 1 { + t.Fatal("expected at least one migration file") + } + + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set; migration gate file count only") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Skipf("postgres unavailable: %v", err) + } + defer pool.Close() + if err := pool.Ping(ctx); err != nil { + t.Skipf("postgres ping failed: %v", err) + } + if err := RunMigrations(ctx, pool, migDir); err != nil { + t.Fatalf("RunMigrations: %v", err) + } + version, err := CurrentVersion(ctx, pool) + if err != nil { + t.Fatalf("CurrentVersion: %v", err) + } + if int(version) < expected { + t.Fatalf("applied schema version %d behind migration files %d", version, expected) + } +} diff --git a/internal/docgov/engine.go b/internal/docgov/engine.go index 125da1fe..0f54f28d 100644 --- a/internal/docgov/engine.go +++ b/internal/docgov/engine.go @@ -30,7 +30,8 @@ type GovernanceEngine struct { memComms map[string][]*DocumentComment memProps map[string]*ChangeProposal memAudits map[string][]*AuditLog - onApprovedHook func(ctx context.Context, doc *DocumentRecord) + onApprovedHook func(ctx context.Context, doc *DocumentRecord) + onApprovedHooks []func(ctx context.Context, doc *DocumentRecord) } // SetOnApprovedHook 注册文档终审批准时的回调钩子 @@ -40,6 +41,34 @@ func (e *GovernanceEngine) SetOnApprovedHook(hook func(ctx context.Context, doc e.onApprovedHook = hook } +// AddOnApprovedHook 追加文档终审批准回调(与 SetOnApprovedHook 并存)。 +func (e *GovernanceEngine) AddOnApprovedHook(hook func(ctx context.Context, doc *DocumentRecord)) { + if hook == nil { + return + } + e.mu.Lock() + defer e.mu.Unlock() + e.onApprovedHooks = append(e.onApprovedHooks, hook) +} + +func (e *GovernanceEngine) runApprovedHooks(ctx context.Context, doc *DocumentRecord) { + var primary func(context.Context, *DocumentRecord) + var extra []func(context.Context, *DocumentRecord) + e.mu.Lock() + primary = e.onApprovedHook + extra = append([]func(context.Context, *DocumentRecord){}, e.onApprovedHooks...) + e.mu.Unlock() + + if primary != nil { + primary(ctx, doc) + } + for _, hook := range extra { + if hook != nil { + hook(ctx, doc) + } + } +} + // NewGovernanceEngine 初始化治理引擎 func NewGovernanceEngine(pool *pgxpool.Pool) *GovernanceEngine { return &GovernanceEngine{ @@ -821,12 +850,9 @@ func (e *GovernanceEngine) ApproverApprove(ctx context.Context, docID string, ac e.memAudits[docID] = append(e.memAudits[docID], audit) e.syncDocAndAuditToDB(ctx, doc, audit) - if e.onApprovedHook != nil { - hook := e.onApprovedHook - e.mu.Unlock() - hook(ctx, doc) - e.mu.Lock() - } + e.mu.Unlock() + e.runApprovedHooks(ctx, doc) + e.mu.Lock() return doc, nil } diff --git a/internal/notification/reminder.go b/internal/notification/reminder.go new file mode 100644 index 00000000..b9f524d3 --- /dev/null +++ b/internal/notification/reminder.go @@ -0,0 +1,172 @@ +package notification + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// stageReminderAfter 首次提醒后多久发送 24h 升级提醒(测试可覆盖)。 +var stageReminderAfter = 24 * time.Hour + +// SetStageReminderAfterForTest 仅供测试缩短等待窗口。 +func SetStageReminderAfterForTest(d time.Duration) { + if d > 0 { + stageReminderAfter = d + } +} + +type pendingAdvanceRow struct { + ID int64 + StageInstanceID int64 + WorkflowInstanceID int64 + StageKey string + ReadinessRevision int + ProjectID int64 + CreatorUserID int64 + StageName string + RequiredDocCount int + ApprovedDocCount int +} + +// ProcessStageAdvanceReminders 扫描 pending 推进待办,对超过 24h 未处理的创建升级提醒(去重)。 +func (s *Service) ProcessStageAdvanceReminders(ctx context.Context) (int, error) { + if !s.Available() { + return 0, ErrNotAvailable + } + + cutoff := time.Now().Add(-stageReminderAfter) + rows, err := s.pool.Query(ctx, ` + SELECT sar.id, sar.stage_instance_id, sar.workflow_instance_id, sar.stage_key, sar.readiness_revision, + pwi.project_id, COALESCE(p.creator_user_id, 0), + COALESCE(e.payload, '{}'::jsonb) + FROM stage_advance_requests sar + JOIN project_workflow_instances pwi ON pwi.id = sar.workflow_instance_id + JOIN projects p ON p.id = pwi.project_id + LEFT JOIN LATERAL ( + SELECT payload FROM outbox_events + WHERE aggregate_type = 'stage_instance' + AND aggregate_id = 'stage_instance:' || sar.stage_instance_id::text + AND event_type = $1 + ORDER BY id DESC LIMIT 1 + ) e ON true + WHERE sar.status = 'pending' AND sar.requested_at <= $2 + `, EventStageReadyToAdvance, cutoff) + if err != nil { + return 0, err + } + defer rows.Close() + + sent := 0 + for rows.Next() { + var row pendingAdvanceRow + var payloadRaw []byte + if err := rows.Scan( + &row.ID, &row.StageInstanceID, &row.WorkflowInstanceID, &row.StageKey, &row.ReadinessRevision, + &row.ProjectID, &row.CreatorUserID, &payloadRaw, + ); err != nil { + return sent, err + } + if row.CreatorUserID <= 0 { + continue + } + if len(payloadRaw) > 0 { + var payload StageReadyPayload + _ = json.Unmarshal(payloadRaw, &payload) + row.StageName = payload.StageName + row.RequiredDocCount = payload.RequiredDocCount + row.ApprovedDocCount = payload.ApprovedDocCount + } + created, err := s.emitStageReminder24(ctx, row) + if err != nil { + return sent, err + } + if created { + sent++ + } + } + return sent, rows.Err() +} + +func (s *Service) emitStageReminder24(ctx context.Context, row pendingAdvanceRow) (bool, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return false, err + } + defer tx.Rollback(ctx) + + payload := StageReadyPayload{ + ProjectID: row.ProjectID, + WorkflowInstanceID: row.WorkflowInstanceID, + StageInstanceID: row.StageInstanceID, + StageKey: row.StageKey, + StageName: row.StageName, + ConfigRevision: row.ReadinessRevision, + RequiredDocCount: row.RequiredDocCount, + ApprovedDocCount: row.ApprovedDocCount, + } + raw, err := json.Marshal(payload) + if err != nil { + return false, err + } + + dedupeKey := fmt.Sprintf("stage_ready_remind24:%d:%d", row.StageInstanceID, row.ReadinessRevision) + var existingID int64 + err = tx.QueryRow(ctx, ` + SELECT id FROM notifications WHERE user_id = $1 AND dedupe_key = $2 + `, row.CreatorUserID, dedupeKey).Scan(&existingID) + if err == nil && existingID > 0 { + return false, nil + } + if err != nil && err != pgx.ErrNoRows { + return false, err + } + + var eventID int64 + aggregateID := fmt.Sprintf("stage_instance:%d", row.StageInstanceID) + err = tx.QueryRow(ctx, ` + INSERT INTO outbox_events (aggregate_type, aggregate_id, event_type, payload, project_id, published_at) + VALUES ('stage_instance', $1, $2, $3::jsonb, $4, now()) + RETURNING id + `, aggregateID, EventStageReadyReminder24, string(raw), row.ProjectID).Scan(&eventID) + if err != nil { + return false, err + } + + stageLabel := row.StageName + if stageLabel == "" { + stageLabel = row.StageKey + } + title := fmt.Sprintf("【提醒】阶段「%s」仍可推进", stageLabel) + body := "必需文档已全部批准,已超过 24 小时尚未确认推进。请尽快查看完成清单并确认是否进入下一阶段。" + + _, err = tx.Exec(ctx, ` + INSERT INTO notifications (user_id, project_id, event_id, type, title, body, dedupe_key, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'unread') + ON CONFLICT (user_id, dedupe_key) DO NOTHING + `, row.CreatorUserID, row.ProjectID, eventID, TypeStageReadyReminder24, title, body, dedupeKey) + if err != nil { + return false, err + } + + if err := tx.Commit(ctx); err != nil { + return false, err + } + return true, nil +} + +// ConfirmStageAdvanceRequest 阶段已确认推进后关闭 pending 待办,停止后续提醒。 +func (s *Service) ConfirmStageAdvanceRequest(ctx context.Context, stageInstanceID int64, readinessRevision int, decidedBy int64) error { + if !s.Available() || stageInstanceID <= 0 { + return nil + } + _, err := s.pool.Exec(ctx, ` + UPDATE stage_advance_requests + SET status = 'confirmed', decided_by = $3, decided_at = now() + WHERE stage_instance_id = $1 AND readiness_revision = $2 AND status = 'pending' + `, stageInstanceID, readinessRevision, decidedBy) + return err +} diff --git a/internal/notification/reminder_test.go b/internal/notification/reminder_test.go new file mode 100644 index 00000000..6fc75659 --- /dev/null +++ b/internal/notification/reminder_test.go @@ -0,0 +1,110 @@ +package notification_test + +import ( + "context" + "testing" + "time" + + "chat-agent-system/internal/notification" +) + +func TestPG_StageAdvanceReminder24h(t *testing.T) { + pool := openPool(t) + defer pool.Close() + + notification.SetStageReminderAfterForTest(1 * time.Millisecond) + t.Cleanup(func() { + notification.SetStageReminderAfterForTest(24 * time.Hour) + }) + + svc := notification.NewService(pool) + ctx := context.Background() + + var projectID, instanceID, stageInstID int64 + code := "remind-" + time.Now().Format("150405.000") + if err := pool.QueryRow(ctx, ` + INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status) + VALUES ($1, 'Reminder Test', 'active', 2, 'active') RETURNING id + `, code).Scan(&projectID); err != nil { + t.Fatalf("insert project: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) + }) + + var wfVersionID int64 + if err := pool.QueryRow(ctx, `SELECT id FROM workflow_versions ORDER BY id DESC LIMIT 1`).Scan(&wfVersionID); err != nil { + t.Skip("no workflow version") + } + + if err := pool.QueryRow(ctx, ` + INSERT INTO project_workflow_instances (project_id, workflow_version_id, instance_no, status, config_revision, plan_hash, config_snapshot, created_by) + VALUES ($1, $2, 1, 'active', 5, 'h', '{}'::jsonb, 2) RETURNING id + `, projectID, wfVersionID).Scan(&instanceID); err != nil { + t.Fatalf("insert instance: %v", err) + } + + if err := pool.QueryRow(ctx, ` + INSERT INTO project_stage_instances (workflow_instance_id, stage_key, position, enabled, status) + VALUES ($1, 'biz_intake', 0, true, 'active') RETURNING id + `, instanceID).Scan(&stageInstID); err != nil { + t.Fatalf("insert stage: %v", err) + } + + created, err := svc.EmitStageReadyToAdvance(ctx, 2, notification.StageReadyPayload{ + ProjectID: projectID, + WorkflowInstanceID: instanceID, + StageInstanceID: stageInstID, + StageKey: "biz_intake", + StageName: "商务摸底", + ConfigRevision: 5, + RequiredDocCount: 1, + ApprovedDocCount: 1, + }) + if err != nil || !created { + t.Fatalf("initial emit: created=%v err=%v", created, err) + } + + _, err = pool.Exec(ctx, ` + UPDATE stage_advance_requests SET requested_at = now() - interval '25 hours' + WHERE stage_instance_id = $1 AND readiness_revision = 5 + `, stageInstID) + if err != nil { + t.Fatalf("backdate request: %v", err) + } + + if _, err := svc.ProcessStageAdvanceReminders(ctx); err != nil { + t.Fatalf("process reminders: %v", err) + } + + var remindCount int + err = pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM notifications + WHERE user_id = 2 AND project_id = $1 AND type = $2 + `, projectID, notification.TypeStageReadyReminder24).Scan(&remindCount) + if err != nil { + t.Fatalf("count reminders: %v", err) + } + if remindCount != 1 { + t.Fatalf("expected 1 reminder notification for test project, got %d", remindCount) + } + + if _, err := svc.ProcessStageAdvanceReminders(ctx); err != nil { + t.Fatalf("process reminders again: %v", err) + } + err = pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM notifications + WHERE user_id = 2 AND project_id = $1 AND type = $2 + `, projectID, notification.TypeStageReadyReminder24).Scan(&remindCount) + if err != nil || remindCount != 1 { + t.Fatalf("expected deduped reminder count 1, got %d err=%v", remindCount, err) + } + + if err := svc.ConfirmStageAdvanceRequest(ctx, stageInstID, 5, 2); err != nil { + t.Fatalf("confirm: %v", err) + } + + if _, err := svc.ProcessStageAdvanceReminders(ctx); err != nil { + t.Fatalf("process after confirm: %v", err) + } +} diff --git a/internal/notification/service.go b/internal/notification/service.go new file mode 100644 index 00000000..7277090c --- /dev/null +++ b/internal/notification/service.go @@ -0,0 +1,156 @@ +package notification + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ErrNotAvailable = errors.New("notification: service not available") + +// Service Outbox 与站内通知。 +type Service struct { + pool *pgxpool.Pool +} + +func NewService(pool *pgxpool.Pool) *Service { + return &Service{pool: pool} +} + +func (s *Service) Available() bool { + if s == nil || s.pool == nil { + return false + } + var ok bool + _ = s.pool.QueryRow(context.Background(), ` + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'outbox_events' + ) + `).Scan(&ok) + return ok +} + +// EmitStageReadyToAdvance 写入 Outbox 与创建者通知;去重键 (stage_instance_id, readiness_revision)。 +func (s *Service) EmitStageReadyToAdvance(ctx context.Context, creatorUserID int64, payload StageReadyPayload) (bool, error) { + if !s.Available() { + return false, ErrNotAvailable + } + if creatorUserID <= 0 || payload.StageInstanceID <= 0 { + return false, nil + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return false, err + } + defer tx.Rollback(ctx) + + var advanceID int64 + err = tx.QueryRow(ctx, ` + INSERT INTO stage_advance_requests ( + stage_instance_id, workflow_instance_id, stage_key, readiness_revision, status + ) VALUES ($1, $2, $3, $4, 'pending') + ON CONFLICT (stage_instance_id, readiness_revision) DO NOTHING + RETURNING id + `, payload.StageInstanceID, payload.WorkflowInstanceID, payload.StageKey, payload.ConfigRevision).Scan(&advanceID) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + + raw, err := json.Marshal(payload) + if err != nil { + return false, err + } + + var eventID int64 + aggregateID := fmt.Sprintf("stage_instance:%d", payload.StageInstanceID) + err = tx.QueryRow(ctx, ` + INSERT INTO outbox_events (aggregate_type, aggregate_id, event_type, payload, project_id, published_at) + VALUES ('stage_instance', $1, $2, $3::jsonb, $4, now()) + RETURNING id + `, aggregateID, EventStageReadyToAdvance, string(raw), payload.ProjectID).Scan(&eventID) + if err != nil { + return false, err + } + + stageLabel := payload.StageName + if stageLabel == "" { + stageLabel = payload.StageKey + } + title := fmt.Sprintf("阶段「%s」可推进", stageLabel) + body := fmt.Sprintf( + "%d/%d 个必需文档已批准,没有阻断项。请确认是否推进到下一阶段。", + payload.ApprovedDocCount, payload.RequiredDocCount, + ) + dedupeKey := fmt.Sprintf("stage_ready:%d:%d", payload.StageInstanceID, payload.ConfigRevision) + + _, err = tx.Exec(ctx, ` + INSERT INTO notifications (user_id, project_id, event_id, type, title, body, dedupe_key, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'unread') + ON CONFLICT (user_id, dedupe_key) DO NOTHING + `, creatorUserID, payload.ProjectID, eventID, TypeStageReadyToAdvance, title, body, dedupeKey) + if err != nil { + return false, err + } + + if err := tx.Commit(ctx); err != nil { + return false, err + } + return true, nil +} + +// ListProjectNotifications 列出项目下当前用户的通知(可选 since_id 增量)。 +func (s *Service) ListProjectNotifications(ctx context.Context, userID, projectID int64, sinceID int64, limit int) ([]Notification, error) { + if !s.Available() { + return nil, ErrNotAvailable + } + if limit <= 0 || limit > 100 { + limit = 50 + } + + query := ` + SELECT n.id, n.user_id, n.project_id, COALESCE(n.event_id, 0), n.type, n.title, n.body, n.status, n.created_at, + COALESCE(e.payload, '{}'::jsonb) + FROM notifications n + LEFT JOIN outbox_events e ON e.id = n.event_id + WHERE n.user_id = $1 AND n.project_id = $2 + ` + args := []any{userID, projectID} + if sinceID > 0 { + query += ` AND n.id > $3` + args = append(args, sinceID) + } + query += ` ORDER BY n.id ASC LIMIT $` + fmt.Sprint(len(args)+1) + args = append(args, limit) + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]Notification, 0) + for rows.Next() { + var n Notification + var payloadRaw []byte + if err := rows.Scan( + &n.ID, &n.UserID, &n.ProjectID, &n.EventID, &n.Type, &n.Title, &n.Body, &n.Status, &n.CreatedAt, + &payloadRaw, + ); err != nil { + return nil, err + } + if len(payloadRaw) > 0 { + _ = json.Unmarshal(payloadRaw, &n.Payload) + } + out = append(out, n) + } + return out, rows.Err() +} diff --git a/internal/notification/service_test.go b/internal/notification/service_test.go new file mode 100644 index 00000000..44cbfe4f --- /dev/null +++ b/internal/notification/service_test.go @@ -0,0 +1,122 @@ +package notification_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "chat-agent-system/internal/db" + "chat-agent-system/internal/notification" +) + +func openPool(t *testing.T) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + dsn = "postgres://app:dev_secret_db_pass@127.0.0.1:5433/agent_system_dev?sslmode=disable" + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Skip(err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + t.Skip(err) + } + migCtx, migCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer migCancel() + if err := db.RunMigrations(migCtx, pool, db.ResolveMigrationsDir()); err != nil { + pool.Close() + t.Skipf("migration failed: %v", err) + } + return pool +} + +func TestPG_EmitStageReadyDedupe(t *testing.T) { + pool := openPool(t) + defer pool.Close() + + svc := notification.NewService(pool) + if !svc.Available() { + t.Fatal("notification service should be available after migration") + } + + ctx := context.Background() + var projectID, instanceID, stageInstID int64 + code := "notify-" + time.Now().Format("150405.000") + err := pool.QueryRow(ctx, ` + INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status) + VALUES ($1, 'Notify Test', 'active', 2, 'active') RETURNING id + `, code).Scan(&projectID) + if err != nil { + t.Fatalf("insert project: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) + }) + + var wfVersionID int64 + err = pool.QueryRow(ctx, `SELECT id FROM workflow_versions ORDER BY id DESC LIMIT 1`).Scan(&wfVersionID) + if err != nil { + t.Skip("no workflow version for notification test") + } + + err = pool.QueryRow(ctx, ` + INSERT INTO project_workflow_instances (project_id, workflow_version_id, instance_no, status, config_revision, plan_hash, config_snapshot, created_by) + VALUES ($1, $2, 1, 'active', 3, 'h', '{}'::jsonb, 2) RETURNING id + `, projectID, wfVersionID).Scan(&instanceID) + if err != nil { + t.Fatalf("insert instance: %v", err) + } + + err = pool.QueryRow(ctx, ` + INSERT INTO project_stage_instances (workflow_instance_id, stage_key, position, enabled, status) + VALUES ($1, 'biz_intake', 0, true, 'active') RETURNING id + `, instanceID).Scan(&stageInstID) + if err != nil { + t.Fatalf("insert stage: %v", err) + } + + payload := notification.StageReadyPayload{ + ProjectID: projectID, + WorkflowInstanceID: instanceID, + StageInstanceID: stageInstID, + StageKey: "biz_intake", + StageName: "商务摸底", + ConfigRevision: 3, + RequiredDocCount: 1, + ApprovedDocCount: 1, + } + + created, err := svc.EmitStageReadyToAdvance(ctx, 2, payload) + if err != nil { + t.Fatalf("emit: %v", err) + } + if !created { + t.Fatal("expected first emit to create notification") + } + + again, err := svc.EmitStageReadyToAdvance(ctx, 2, payload) + if err != nil { + t.Fatalf("second emit: %v", err) + } + if again { + t.Fatal("expected dedupe to suppress second notification") + } + + var count int + err = pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM notifications WHERE user_id = 2 AND project_id = $1 AND type = $2 + `, projectID, notification.TypeStageReadyToAdvance).Scan(&count) + if err != nil { + t.Fatalf("count notifications: %v", err) + } + if count != 1 { + t.Fatalf("expected exactly one notification, got %d", count) + } +} diff --git a/internal/notification/types.go b/internal/notification/types.go new file mode 100644 index 00000000..6c829e83 --- /dev/null +++ b/internal/notification/types.go @@ -0,0 +1,36 @@ +package notification + +import "time" + +const ( + EventStageReadyToAdvance = "stage.ready_to_advance" + EventStageReadyReminder24 = "stage.ready_to_advance.reminder24" + TypeStageReadyToAdvance = "stage_ready_to_advance" + TypeStageReadyReminder24 = "stage_ready_reminder_24h" +) + +// StageReadyPayload Outbox / 通知载荷。 +type StageReadyPayload struct { + ProjectID int64 `json:"project_id"` + WorkflowInstanceID int64 `json:"workflow_instance_id"` + StageInstanceID int64 `json:"stage_instance_id"` + StageKey string `json:"stage_key"` + StageName string `json:"stage_name"` + ConfigRevision int `json:"config_revision"` + RequiredDocCount int `json:"required_doc_count"` + ApprovedDocCount int `json:"approved_doc_count"` +} + +// Notification 用户站内通知。 +type Notification struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + ProjectID int64 `json:"project_id"` + EventID int64 `json:"event_id,omitempty"` + Type string `json:"type"` + Title string `json:"title"` + Body string `json:"body"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + Payload StageReadyPayload `json:"payload,omitempty"` +} diff --git a/internal/projectinstance/approval.go b/internal/projectinstance/approval.go new file mode 100644 index 00000000..d09000c3 --- /dev/null +++ b/internal/projectinstance/approval.go @@ -0,0 +1,78 @@ +package projectinstance + +import "context" + +// DocumentApprovedResult 文档批准后阶段就绪评估结果。 +type DocumentApprovedResult struct { + StageReady bool + InstanceID int64 + StageInstanceID int64 + StageKey string + StageName string + ConfigRevision int + RequiredDocCount int + ApprovedDocCount int +} + +// HandleDocumentApproved 同步文档任务状态并评估是否首次满足阶段推进条件。 +func (s *Service) HandleDocumentApproved(ctx context.Context, projectID int64, taskKey string) (*DocumentApprovedResult, error) { + out := &DocumentApprovedResult{} + if s == nil || !s.Available() || projectID <= 0 || taskKey == "" { + return out, nil + } + + if err := s.store.MarkDocTaskApproved(ctx, projectID, taskKey); err != nil { + return nil, err + } + + live, err := s.store.GetLiveInstance(ctx, projectID) + if err != nil || live == nil { + return out, err + } + + detail, err := s.store.GetInstanceByID(ctx, live.ID) + if err != nil || detail == nil || detail.ActiveStageKey == "" { + return out, err + } + + readiness, err := s.GetStageReadiness(ctx, live.ID, detail.ActiveStageKey, 0, true) + if err != nil { + return nil, err + } + if !readiness.Ready { + return out, nil + } + + var stageInstID int64 + for _, st := range detail.Stages { + if st.StageKey == detail.ActiveStageKey { + stageInstID = st.ID + break + } + } + if stageInstID <= 0 { + return out, nil + } + + required := 0 + approved := 0 + for _, dt := range readiness.DocTasks { + if !dt.Enabled || !dt.Required { + continue + } + required++ + if dt.Approved { + approved++ + } + } + + out.StageReady = true + out.InstanceID = live.ID + out.StageInstanceID = stageInstID + out.StageKey = readiness.StageKey + out.StageName = readiness.StageName + out.ConfigRevision = readiness.ConfigRevision + out.RequiredDocCount = required + out.ApprovedDocCount = approved + return out, nil +} diff --git a/internal/projectinstance/approval_test.go b/internal/projectinstance/approval_test.go new file mode 100644 index 00000000..034b6c27 --- /dev/null +++ b/internal/projectinstance/approval_test.go @@ -0,0 +1,44 @@ +package projectinstance + +import ( + "context" + "testing" +) + +func TestHandleDocumentApproved_StageReady(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 1, LifecycleActive, "medical_device_development", 1, 1) + + svc := NewService(store, testResolver(), nil, &stubDocChecker{states: map[string]string{"intake": "approved"}}) + plan := WorkflowPlan{ + WorkflowDefinitionKey: "medical_device_development", + WorkflowVersion: 1, + Stages: []StagePlan{{ + StageKey: "biz_intake", Enabled: true, + DocTasks: []DocTaskPlan{{ + TaskKey: "intake", Enabled: true, + Duties: DocDuty{AuthorUserID: 2, ReviewerUserID: 3, ApproverUserID: 4}, + }}, + }}, + } + inst := &WorkflowInstance{ + ID: 1, ProjectID: 1, WorkflowVersionID: 10, Status: InstanceActive, + ConfigRevision: 1, PlanHash: "h", ConfigSnapshot: plan, InstanceNo: 1, + } + activated, err := store.ActivateInstance(ctx, 1, inst, "medical_device_development", 1, 2) + if err != nil { + t.Fatalf("activate: %v", err) + } + + result, err := svc.HandleDocumentApproved(ctx, 1, "intake") + if err != nil { + t.Fatalf("handle approved: %v", err) + } + if result == nil || !result.StageReady { + t.Fatalf("expected stage ready, got %+v", result) + } + if result.InstanceID != activated.ID || result.StageKey != "biz_intake" { + t.Fatalf("unexpected result: %+v", result) + } +} diff --git a/internal/projectinstance/doc_checker.go b/internal/projectinstance/doc_checker.go new file mode 100644 index 00000000..01582c28 --- /dev/null +++ b/internal/projectinstance/doc_checker.go @@ -0,0 +1,36 @@ +package projectinstance + +import ( + "context" + + "chat-agent-system/internal/docgov" +) + +// DocumentReadinessChecker 查询项目文档批准状态。 +type DocumentReadinessChecker interface { + TaskApprovalStates(ctx context.Context, projectID int64) (map[string]string, error) +} + +// DocGovChecker 基于 docgov 的就绪检查。 +type DocGovChecker struct { + engine *docgov.GovernanceEngine +} + +func NewDocGovChecker(engine *docgov.GovernanceEngine) *DocGovChecker { + return &DocGovChecker{engine: engine} +} + +func (c *DocGovChecker) TaskApprovalStates(ctx context.Context, projectID int64) (map[string]string, error) { + out := make(map[string]string) + if c == nil || c.engine == nil || projectID <= 0 { + return out, nil + } + docs, err := c.engine.ListWorkflowDocumentsByProject(ctx, projectID) + if err != nil { + return nil, err + } + for _, d := range docs { + out[d.TaskKey] = string(d.LifecycleState) + } + return out, nil +} diff --git a/internal/projectinstance/errors.go b/internal/projectinstance/errors.go new file mode 100644 index 00000000..36036846 --- /dev/null +++ b/internal/projectinstance/errors.go @@ -0,0 +1,24 @@ +package projectinstance + +import "errors" + +var ( + ErrNotAvailable = errors.New("project instance store not available") + ErrProjectNotFound = errors.New("project not found") + ErrForbiddenNotCreator = errors.New("forbidden: only project creator can configure workflow") + ErrPlanFrozen = errors.New("plan frozen: workflow already active") + ErrWorkflowAlreadyActive = errors.New("workflow already active for project") + ErrPlanChangedReconfirm = errors.New("plan changed: reconfirm required") + ErrInvalidPlan = errors.New("invalid workflow plan") + ErrValidationFailed = errors.New("workflow plan validation failed") + ErrTokenInvalid = errors.New("activation token invalid or expired") + ErrConcurrentConflict = errors.New("conflict: project version mismatch") + ErrPlanRevisionConflict = errors.New("conflict: plan revision mismatch") + ErrWorkflowVersionNotFound = errors.New("workflow version not found or not published") + ErrLegacySwitchBlocked = errors.New("legacy workflow switch blocked: use project settings activation") + ErrInstanceNotFound = errors.New("workflow instance not found") + ErrStageGateNotReady = errors.New("stage gate not ready: required documents not approved") + ErrStageNotActive = errors.New("stage is not the active stage") + ErrInvalidStage = errors.New("invalid stage key for instance") + ErrInstanceRevisionConflict = errors.New("conflict: instance config revision mismatch") +) diff --git a/internal/projectinstance/gate.go b/internal/projectinstance/gate.go new file mode 100644 index 00000000..f5d95f1a --- /dev/null +++ b/internal/projectinstance/gate.go @@ -0,0 +1,72 @@ +package projectinstance + +import "chat-agent-system/internal/workflowdef" + +func evaluateStageReadiness( + stagePlan StagePlan, + stageDef *workflowdef.StageDef, + docStates map[string]string, +) (bool, []GateIssue, []DocTaskReadiness) { + issues := make([]GateIssue, 0) + readiness := make([]DocTaskReadiness, 0) + if !stagePlan.Enabled { + return false, []GateIssue{{Code: "STAGE_DISABLED", Message: "阶段未启用"}}, readiness + } + + taskDefMap := map[string]workflowdef.DocTaskDef{} + if stageDef != nil { + for _, dt := range stageDef.DocTasks { + taskDefMap[dt.Key] = dt + } + } + + for _, dt := range stagePlan.DocTasks { + if !dt.Enabled { + continue + } + td, hasDef := taskDefMap[dt.TaskKey] + required := !hasDef || td.Required || td.Blocking + state := docStates[dt.TaskKey] + if state == "" { + state = "missing" + } + approved := state == "approved" + item := DocTaskReadiness{ + TaskKey: dt.TaskKey, + Required: required, + Enabled: true, + DocState: state, + Approved: approved, + } + readiness = append(readiness, item) + if required && !approved { + msg := "必需文档尚未批准" + if state == "missing" { + msg = "必需文档尚未创建" + } + issues = append(issues, GateIssue{ + Code: "DOC_NOT_APPROVED", TaskKey: dt.TaskKey, Message: msg, + }) + } + } + + return len(issues) == 0, issues, readiness +} + +func findStagePlan(plan WorkflowPlan, stageKey string) *StagePlan { + for i := range plan.Stages { + if plan.Stages[i].StageKey == stageKey { + return &plan.Stages[i] + } + } + return nil +} + +func findStageDef(def workflowdef.VersionDefinition, stageKey string) *workflowdef.StageDef { + for i := range def.Stages { + if def.Stages[i].Key == stageKey { + return &def.Stages[i] + } + } + return nil +} diff --git a/internal/projectinstance/hash.go b/internal/projectinstance/hash.go new file mode 100644 index 00000000..8266e794 --- /dev/null +++ b/internal/projectinstance/hash.go @@ -0,0 +1,31 @@ +package projectinstance + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "sort" +) + +func computePlanHash(plan WorkflowPlan) (string, error) { + normalized := normalizePlan(plan) + raw, err := json.Marshal(normalized) + if err != nil { + return "", err + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]), nil +} + +func normalizePlan(plan WorkflowPlan) WorkflowPlan { + out := plan + sort.Slice(out.Stages, func(i, j int) bool { + return out.Stages[i].StageKey < out.Stages[j].StageKey + }) + for i := range out.Stages { + sort.Slice(out.Stages[i].DocTasks, func(a, b int) bool { + return out.Stages[i].DocTasks[a].TaskKey < out.Stages[i].DocTasks[b].TaskKey + }) + } + return out +} diff --git a/internal/projectinstance/pg_integration_test.go b/internal/projectinstance/pg_integration_test.go new file mode 100644 index 00000000..b450cf1c --- /dev/null +++ b/internal/projectinstance/pg_integration_test.go @@ -0,0 +1,189 @@ +package projectinstance_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "chat-agent-system/internal/db" + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/notification" + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/projectworkflow" + "chat-agent-system/internal/workflowdef" +) + +func openPGPool(t *testing.T) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + dsn = "postgres://app:dev_secret_db_pass@127.0.0.1:5433/agent_system_dev?sslmode=disable" + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Skip(err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + t.Skip(err) + } + migCtx, migCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer migCancel() + if err := db.RunMigrations(migCtx, pool, db.ResolveMigrationsDir()); err != nil { + pool.Close() + t.Skipf("migration failed: %v", err) + } + if !projectinstance.TablesReady(ctx, pool) { + pool.Close() + t.Skip("project_workflow_instances not migrated") + } + if !workflowdef.TablesReady(ctx, pool) { + pool.Close() + t.Skip("workflow_definitions not migrated") + } + return pool +} + +func TestPG_ActivationAndStageAdvance(t *testing.T) { + pool := openPGPool(t) + defer pool.Close() + ctx := context.Background() + + wfSvc := workflowdef.NewService(pool, nil) + wfKey := "pg_p3_wf_" + time.Now().Format("150405") + _, ver, err := wfSvc.CreateDefinition(ctx, workflowdef.CreateDefinitionInput{ + Key: wfKey, Name: "PG P3 Test 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: "客户需求采集", + }}, + }}, + }, + }, 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) + } + wfStore := workflowdef.NewStore(pool) + pwStore := projectworkflow.NewStore(pool) + instStore := projectinstance.NewStore(pool) + docChecker := projectinstance.NewDocGovChecker(docgov.NewGovernanceEngine(pool)) + svc := projectinstance.NewService(instStore, wfStore, pwStore, docChecker) + + var projectID int64 + code := "pg-p3-" + time.Now().Format("150405.000") + err = pool.QueryRow(ctx, ` + INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision) + VALUES ($1, 'PG Phase3 Test', 'active', 2, 'planning', '', 1, 0) RETURNING id + `, code).Scan(&projectID) + if err != nil { + t.Fatalf("insert project: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) + }) + + plan, err := svc.BuildDefaultPlan(ctx, wfKey, 0) + if err != nil { + t.Fatalf("default plan: %v", err) + } + for i := range plan.Stages { + for j := range plan.Stages[i].DocTasks { + if plan.Stages[i].DocTasks[j].TaskKey == "intake" { + plan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{ + AuthorUserID: 2, ReviewerUserID: 3, ApproverUserID: 4, + } + } + } + } + + _, err = svc.SaveWorkflowPlan(ctx, projectID, 2, true, projectinstance.SavePlanInput{ + Plan: *plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + if err != nil { + t.Fatalf("save plan: %v", err) + } + preview, err := svc.ActivationPreview(ctx, projectID, 2, true) + if err != nil { + t.Fatalf("preview: %v", err) + } + activated, err := svc.Activate(ctx, projectID, 2, true, projectinstance.ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if err != nil { + t.Fatalf("activate: %v", err) + } + + docID := "doc_pg_p3_intake_" + code + engine := docgov.NewGovernanceEngine(pool) + _, err = engine.CreateOrGetDocument(ctx, docID, projectID, "intake", "客户需求采集", "V0.1", "", 2, "张", 3, "李", 4, "王") + if err != nil { + t.Fatalf("create doc: %v", err) + } + _ = engine.MarkDocumentContentUpdated(ctx, docID, "hash_pg_p3", false) + _, _ = engine.SubmitForReview(ctx, docID, 2, "author", "张") + _, _ = engine.ReviewApprove(ctx, docID, 3, "reviewer", "李", "ok") + _, err = engine.ApproverApprove(ctx, docID, 4, "approver", "王", "ok") + if err != nil { + t.Fatalf("approve doc: %v", err) + } + + approvedResult, err := svc.HandleDocumentApproved(ctx, projectID, "intake") + if err != nil { + t.Fatalf("handle document approved: %v", err) + } + if approvedResult == nil || !approvedResult.StageReady { + t.Fatalf("expected stage ready after approval, got %+v", approvedResult) + } + + notifySvc := notification.NewService(pool) + if notifySvc.Available() { + created, emitErr := notifySvc.EmitStageReadyToAdvance(ctx, 2, notification.StageReadyPayload{ + ProjectID: projectID, + WorkflowInstanceID: approvedResult.InstanceID, + StageInstanceID: approvedResult.StageInstanceID, + StageKey: approvedResult.StageKey, + StageName: approvedResult.StageName, + ConfigRevision: approvedResult.ConfigRevision, + RequiredDocCount: approvedResult.RequiredDocCount, + ApprovedDocCount: approvedResult.ApprovedDocCount, + }) + if emitErr != nil { + t.Fatalf("emit stage ready notification: %v", emitErr) + } + if !created { + t.Fatal("expected stage ready notification to be created") + } + } + + readiness, err := svc.GetStageReadiness(ctx, activated.Instance.ID, "biz_intake", 2, true) + if err != nil { + t.Fatalf("readiness: %v", err) + } + if !readiness.Ready { + t.Fatalf("expected ready stage, issues=%+v tasks=%+v", readiness.BlockingIssues, readiness.DocTasks) + } + + advanced, err := svc.AdvanceStage(ctx, activated.Instance.ID, 2, true, projectinstance.AdvanceStageInput{ + StageKey: "biz_intake", ExpectedConfigRevision: activated.Instance.ConfigRevision, + }) + if err != nil { + t.Fatalf("advance: %v", err) + } + if advanced.NextStageKey == "" && !advanced.InstanceDone { + t.Fatalf("expected next stage or done, got %+v", advanced) + } +} diff --git a/internal/projectinstance/service.go b/internal/projectinstance/service.go new file mode 100644 index 00000000..4a3f1ffd --- /dev/null +++ b/internal/projectinstance/service.go @@ -0,0 +1,335 @@ +package projectinstance + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "chat-agent-system/internal/projectworkflow" + "chat-agent-system/internal/workflowdef" +) + +// VersionResolver 解析已发布工作流版本。 +type VersionResolver interface { + GetDefinitionByKey(ctx context.Context, key string) (*workflowdef.Definition, error) + GetVersion(ctx context.Context, workflowID int64, version int) (*workflowdef.Version, error) + ListDefinitions(ctx context.Context) ([]workflowdef.Definition, error) +} + +// Service 项目工作流实例与激活领域服务。 +type Service struct { + store Store + wfStore VersionResolver + projects projectworkflow.Store + docs DocumentReadinessChecker +} + +func NewService(store Store, wfStore VersionResolver, projects projectworkflow.Store, docs DocumentReadinessChecker) *Service { + return &Service{store: store, wfStore: wfStore, projects: projects, docs: docs} +} + +func (s *Service) Available() bool { + return s != nil && s.store != nil && s.wfStore != nil +} + +func (s *Service) HasLiveInstance(ctx context.Context, projectID int64) (bool, error) { + if !s.Available() { + return false, nil + } + return s.store.HasLiveInstance(ctx, projectID) +} + +func (s *Service) GetSettings(ctx context.Context, projectID int64, userID int64, isCreator bool) (*ProjectSettings, error) { + if projectID <= 0 { + return nil, ErrProjectNotFound + } + lifecycle, activeKey, selVer, planRev, err := s.store.GetProjectMeta(ctx, projectID) + if err != nil { + return nil, err + } + + settings := &ProjectSettings{ + ProjectID: projectID, + LifecycleStatus: lifecycle, + ActiveWorkflowKey: activeKey, + WorkflowSelectionVersion: selVer, + WorkflowPlanRevision: planRev, + CanEditPlan: isCreator && lifecycle == LifecyclePlanning, + CanActivate: isCreator && lifecycle == LifecyclePlanning, + } + + planInst, err := s.store.GetPlanningInstance(ctx, projectID) + if err != nil { + return nil, err + } + if planInst != nil { + settings.PlanningInstance = planInst + settings.Plan = &planInst.ConfigSnapshot + } + + liveInst, err := s.store.GetLiveInstance(ctx, projectID) + if err != nil { + return nil, err + } + if liveInst != nil { + settings.LiveInstance = liveInst + settings.CanEditPlan = false + settings.CanActivate = false + if detail, detailErr := s.store.GetInstanceByID(ctx, liveInst.ID); detailErr == nil && detail != nil { + settings.ActiveStageKey = detail.ActiveStageKey + if detail.ActiveStageKey != "" && s.docs != nil { + if version, verErr := s.resolvePublishedVersion(ctx, liveInst.ConfigSnapshot); verErr == nil { + if stagePlan := findStagePlan(liveInst.ConfigSnapshot, detail.ActiveStageKey); stagePlan != nil { + docStates, _ := s.docs.TaskApprovalStates(ctx, projectID) + ready, _, _ := evaluateStageReadiness(*stagePlan, findStageDef(version.Definition, detail.ActiveStageKey), docStates) + settings.StageAdvanceReady = ready + } + } + } + } + } + + if s.wfStore != nil { + defs, listErr := s.wfStore.ListDefinitions(ctx) + if listErr == nil { + for _, d := range defs { + if d.LatestPublishedVersion <= 0 { + continue + } + ver, verErr := s.wfStore.GetVersion(ctx, d.ID, d.LatestPublishedVersion) + if verErr != nil || ver == nil { + continue + } + settings.PublishedWorkflows = append(settings.PublishedWorkflows, PublishedOption{ + DefinitionKey: d.Key, + DefinitionName: d.Name, + LatestVersion: d.LatestPublishedVersion, + WorkflowVersion: ver.ID, + ProductDomain: d.ProductDomain, + }) + } + } + } + _ = userID + return settings, nil +} + +func (s *Service) SaveWorkflowPlan(ctx context.Context, projectID int64, userID int64, isCreator bool, in SavePlanInput) (*ProjectSettings, error) { + if !isCreator { + return nil, ErrForbiddenNotCreator + } + lifecycle, _, selVer, planRev, err := s.store.GetProjectMeta(ctx, projectID) + if err != nil { + return nil, err + } + if lifecycle != LifecyclePlanning { + return nil, ErrPlanFrozen + } + live, err := s.store.HasLiveInstance(ctx, projectID) + if err != nil { + return nil, err + } + if live { + return nil, ErrWorkflowAlreadyActive + } + if in.ExpectedSelectionVersion > 0 && in.ExpectedSelectionVersion != selVer { + return nil, ErrConcurrentConflict + } + if in.ExpectedPlanRevision != planRev { + return nil, ErrPlanRevisionConflict + } + + version, err := s.resolvePublishedVersion(ctx, in.Plan) + if err != nil { + return nil, err + } + validation := validatePlan(in.Plan, version) + if !validation.Valid { + return nil, fmt.Errorf("%w: %d issues", ErrValidationFailed, len(validation.Issues)) + } + + in.Plan.WorkflowVersionID = version.ID + planHash, err := computePlanHash(in.Plan) + if err != nil { + return nil, err + } + + inst := &WorkflowInstance{ + ProjectID: projectID, + WorkflowVersionID: version.ID, + Status: InstancePlanning, + ConfigRevision: planRev + 1, + PlanHash: planHash, + ConfigSnapshot: in.Plan, + CreatedBy: &userID, + } + if _, err := s.store.UpsertPlanningInstance(ctx, inst); err != nil { + return nil, err + } + if _, err := s.store.BumpPlanRevision(ctx, projectID, planRev); err != nil { + return nil, err + } + return s.GetSettings(ctx, projectID, userID, isCreator) +} + +func (s *Service) ActivationPreview(ctx context.Context, projectID int64, userID int64, isCreator bool) (*ActivationPreview, error) { + if !isCreator { + return nil, ErrForbiddenNotCreator + } + lifecycle, _, selVer, planRev, err := s.store.GetProjectMeta(ctx, projectID) + if err != nil { + return nil, err + } + if lifecycle != LifecyclePlanning { + return nil, ErrPlanFrozen + } + planInst, err := s.store.GetPlanningInstance(ctx, projectID) + if err != nil { + return nil, err + } + if planInst == nil || planInst.PlanHash == "" { + return nil, fmt.Errorf("%w: no saved plan", ErrInvalidPlan) + } + + version, err := s.resolvePublishedVersion(ctx, planInst.ConfigSnapshot) + if err != nil { + return nil, err + } + validation := validatePlan(planInst.ConfigSnapshot, version) + if !validation.Valid { + return nil, fmt.Errorf("%w", ErrValidationFailed) + } + + token, err := randomToken(24) + if err != nil { + return nil, err + } + expiresAt := time.Now().Add(15 * time.Minute) + if err := s.store.SaveActivationToken(ctx, token, projectID, planInst.PlanHash, selVer, planInst.ConfigSnapshot, userID, expiresAt); err != nil { + return nil, err + } + + return &ActivationPreview{ + ConfirmationToken: token, + PlanHash: planInst.PlanHash, + ExpectedProjectVersion: selVer, + ExpectedPlanRevision: planRev, + Summary: previewSummary(planInst.ConfigSnapshot, version), + Validation: validation, + ExpiresAt: expiresAt, + }, nil +} + +func (s *Service) Activate(ctx context.Context, projectID int64, userID int64, isCreator bool, in ActivateInput) (*ActivateResult, error) { + if !isCreator { + return nil, ErrForbiddenNotCreator + } + lifecycle, _, selVer, planRev, err := s.store.GetProjectMeta(ctx, projectID) + if err != nil { + return nil, err + } + if lifecycle != LifecyclePlanning { + return nil, ErrPlanFrozen + } + if in.ExpectedProjectVersion != selVer { + return nil, ErrConcurrentConflict + } + if in.ExpectedPlanRevision != planRev { + return nil, ErrPlanRevisionConflict + } + + planInst, err := s.store.GetPlanningInstance(ctx, projectID) + if err != nil { + return nil, err + } + if planInst == nil { + return nil, fmt.Errorf("%w: no saved plan", ErrInvalidPlan) + } + + consumed, err := s.store.ConsumeActivationToken(ctx, in.ConfirmationToken, projectID, planInst.PlanHash) + if err != nil { + return nil, err + } + currentHash, err := computePlanHash(*consumed) + if err != nil { + return nil, err + } + if currentHash != planInst.PlanHash { + return nil, ErrPlanChangedReconfirm + } + + version, err := s.resolvePublishedVersion(ctx, planInst.ConfigSnapshot) + if err != nil { + return nil, err + } + validation := validatePlan(planInst.ConfigSnapshot, version) + if !validation.Valid { + return nil, ErrValidationFailed + } + + activated, err := s.store.ActivateInstance(ctx, projectID, planInst, version.WorkflowKey, selVer, userID) + if err != nil { + return nil, err + } + + return &ActivateResult{ + ProjectID: projectID, + LifecycleStatus: LifecycleActive, + ActiveWorkflowKey: version.WorkflowKey, + Instance: *activated, + }, nil +} + +func (s *Service) BuildDefaultPlan(ctx context.Context, workflowKey string, versionNum int) (*WorkflowPlan, error) { + if s.wfStore == nil { + return nil, ErrNotAvailable + } + def, err := s.wfStore.GetDefinitionByKey(ctx, workflowKey) + if err != nil { + return nil, ErrWorkflowVersionNotFound + } + verNum := versionNum + if verNum <= 0 { + verNum = def.LatestPublishedVersion + } + version, err := s.wfStore.GetVersion(ctx, def.ID, verNum) + if err != nil { + return nil, ErrWorkflowVersionNotFound + } + plan := buildDefaultPlan(version) + if plan.WorkflowDefinitionKey == "" { + plan.WorkflowDefinitionKey = def.Key + } + return &plan, nil +} + +func (s *Service) resolvePublishedVersion(ctx context.Context, plan WorkflowPlan) (*workflowdef.Version, error) { + if s.wfStore == nil { + return nil, ErrNotAvailable + } + def, err := s.wfStore.GetDefinitionByKey(ctx, plan.WorkflowDefinitionKey) + if err != nil { + return nil, ErrWorkflowVersionNotFound + } + verNum := plan.WorkflowVersion + if verNum <= 0 { + verNum = def.LatestPublishedVersion + } + version, err := s.wfStore.GetVersion(ctx, def.ID, verNum) + if err != nil { + return nil, ErrWorkflowVersionNotFound + } + version.WorkflowKey = def.Key + version.WorkflowName = def.Name + return version, nil +} + +func randomToken(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/internal/projectinstance/service_test.go b/internal/projectinstance/service_test.go new file mode 100644 index 00000000..355c6c46 --- /dev/null +++ b/internal/projectinstance/service_test.go @@ -0,0 +1,140 @@ +package projectinstance + +import ( + "context" + "errors" + "testing" + + "chat-agent-system/internal/workflowdef" +) + +type stubVersionResolver struct { + versions map[string]*workflowdef.Version + defs map[string]*workflowdef.Definition +} + +func (s *stubVersionResolver) GetDefinitionByKey(_ context.Context, key string) (*workflowdef.Definition, error) { + d, ok := s.defs[key] + if !ok { + return nil, workflowdef.ErrNotFound + } + return d, nil +} + +func (s *stubVersionResolver) GetVersion(_ context.Context, workflowID int64, version int) (*workflowdef.Version, error) { + for _, v := range s.versions { + if v.WorkflowID == workflowID && v.Version == version { + copyV := *v + return ©V, nil + } + } + return nil, workflowdef.ErrNotFound +} + +func (s *stubVersionResolver) ListDefinitions(_ context.Context) ([]workflowdef.Definition, error) { + out := make([]workflowdef.Definition, 0, len(s.defs)) + for _, d := range s.defs { + out = append(out, *d) + } + return out, nil +} + +func testResolver() *stubVersionResolver { + ver := &workflowdef.Version{ + ID: 100, WorkflowID: 10, Version: 1, Status: workflowdef.StatusPublished, + WorkflowKey: "medical_device_development", WorkflowName: "医疗器械开发", + Definition: workflowdef.VersionDefinition{ + Stages: []workflowdef.StageDef{{ + Key: "biz_intake", Name: "商务 intake", Optional: false, + DocTasks: []workflowdef.DocTaskDef{{ + Key: "intake", Required: true, Blocking: true, DefaultEnabled: true, + }}, + }}, + }, + } + return &stubVersionResolver{ + defs: map[string]*workflowdef.Definition{ + "medical_device_development": {ID: 10, Key: "medical_device_development", Name: "医疗器械开发", LatestPublishedVersion: 1}, + }, + versions: map[string]*workflowdef.Version{"medical_device_development": ver}, + } +} + +func validPlan() WorkflowPlan { + return WorkflowPlan{ + WorkflowDefinitionKey: "medical_device_development", + WorkflowVersion: 1, + Stages: []StagePlan{{ + StageKey: "biz_intake", Enabled: true, + DocTasks: []DocTaskPlan{{ + TaskKey: "intake", Enabled: true, + Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}, + }}, + }}, + } +} + +func TestService_PlanPreviewActivate(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 42, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, nil) + + settings, err := svc.SaveWorkflowPlan(ctx, 42, 1, true, SavePlanInput{ + Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + if err != nil { + t.Fatalf("SaveWorkflowPlan: %v", err) + } + if settings.Plan == nil || settings.Plan.WorkflowDefinitionKey != "medical_device_development" { + t.Fatalf("expected saved plan, got %+v", settings.Plan) + } + + preview, err := svc.ActivationPreview(ctx, 42, 1, true) + if err != nil { + t.Fatalf("ActivationPreview: %v", err) + } + if preview.ConfirmationToken == "" || preview.PlanHash == "" { + t.Fatal("expected confirmation token and plan hash") + } + + result, err := svc.Activate(ctx, 42, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if err != nil { + t.Fatalf("Activate: %v", err) + } + if result.LifecycleStatus != LifecycleActive || result.ActiveWorkflowKey != "medical_device_development" { + t.Fatalf("unexpected activate result: %+v", result) + } + + live, _ := store.HasLiveInstance(ctx, 42) + if !live { + t.Fatal("expected live instance after activation") + } +} + +func TestService_BlockPlanEditWhenActive(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 7, LifecycleActive, "main", 2, 1) + svc := NewService(store, testResolver(), nil, nil) + + _, err := svc.SaveWorkflowPlan(ctx, 7, 1, true, SavePlanInput{ + Plan: validPlan(), ExpectedPlanRevision: 1, + }) + if !errors.Is(err, ErrPlanFrozen) { + t.Fatalf("expected ErrPlanFrozen, got %v", err) + } +} + +func TestService_SeparationOfDutyValidation(t *testing.T) { + bad := validPlan() + bad.Stages[0].DocTasks[0].Duties.ReviewerUserID = 1 + res := validatePlan(bad, testResolver().versions["medical_device_development"]) + if res.Valid { + t.Fatal("expected separation of duty violation") + } +} diff --git a/internal/projectinstance/stage_access.go b/internal/projectinstance/stage_access.go new file mode 100644 index 00000000..d0944100 --- /dev/null +++ b/internal/projectinstance/stage_access.go @@ -0,0 +1,121 @@ +package projectinstance + +import ( + "context" +) + +// StageAccess 用户在某项目实例下可访问的阶段与文档任务范围。 +type StageAccess struct { + Restricted bool `json:"restricted"` + ActiveStageKey string `json:"active_stage_key,omitempty"` + AllowedStageKeys map[string]bool `json:"allowed_stage_keys,omitempty"` + AllowedTaskKeys map[string]bool `json:"allowed_task_keys,omitempty"` +} + +// ResolveStageAccess 基于 live 实例与三权指派计算阶段 ABAC 范围。 +// 无 live 实例时不限制(legacy 项目兼容)。 +func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userID int64, isCreator bool) (*StageAccess, error) { + out := &StageAccess{ + AllowedStageKeys: make(map[string]bool), + AllowedTaskKeys: make(map[string]bool), + } + if s == nil || s.store == nil || projectID <= 0 || userID <= 0 { + return out, nil + } + + live, err := s.store.GetLiveInstance(ctx, projectID) + if err != nil || live == nil || live.Status == InstanceCompleted { + return out, nil + } + + detail, err := s.store.GetInstanceByID(ctx, live.ID) + if err != nil { + return out, err + } + + out.Restricted = true + out.ActiveStageKey = detail.ActiveStageKey + + stageStatus := map[string]string{} + for _, st := range detail.Stages { + stageStatus[st.StageKey] = st.Status + } + + for _, stPlan := range live.ConfigSnapshot.Stages { + if !stPlan.Enabled { + continue + } + status := stageStatus[stPlan.StageKey] + hasDuty := stageDutyForUser(stPlan, userID) + allow := false + switch status { + case StageActive: + allow = isCreator || hasDuty + case StageCompleted: + allow = hasDuty || isCreator + default: + allow = false + } + if !allow { + continue + } + out.AllowedStageKeys[stPlan.StageKey] = true + for _, dt := range stPlan.DocTasks { + if dt.Enabled { + out.AllowedTaskKeys[dt.TaskKey] = true + } + } + } + return out, nil +} + +func stageDutyForUser(st StagePlan, userID int64) bool { + for _, dt := range st.DocTasks { + if !dt.Enabled { + continue + } + if dt.Duties.AuthorUserID == userID || dt.Duties.ReviewerUserID == userID || dt.Duties.ApproverUserID == userID { + return true + } + } + return false +} + +// TaskKeyAllowed 判断 task_key 是否在允许范围内。 +func (a *StageAccess) TaskKeyAllowed(taskKey string) bool { + if a == nil || !a.Restricted { + return true + } + return a.AllowedTaskKeys[taskKey] +} + +// StageKeyAllowed 判断 stage_key 是否在允许范围内。 +func (a *StageAccess) StageKeyAllowed(stageKey string) bool { + if a == nil || !a.Restricted || stageKey == "" { + return true + } + return a.AllowedStageKeys[stageKey] +} + +// MessageStageKey 从消息 metadata 提取阶段键。 +func MessageStageKey(metadata map[string]any) string { + if metadata == nil { + return "" + } + if sk, ok := metadata["stage_key"].(string); ok { + return sk + } + return "" +} + +// TaskKeyToStage 从实例快照解析 task 所属阶段。 +func TaskKeyToStage(plan WorkflowPlan, taskKey string) string { + for _, st := range plan.Stages { + for _, dt := range st.DocTasks { + if dt.TaskKey == taskKey { + return st.StageKey + } + } + } + return "" +} diff --git a/internal/projectinstance/stage_access_chat_test.go b/internal/projectinstance/stage_access_chat_test.go new file mode 100644 index 00000000..4f97621c --- /dev/null +++ b/internal/projectinstance/stage_access_chat_test.go @@ -0,0 +1,29 @@ +package projectinstance + +import "testing" + +func TestStageKeyAllowed(t *testing.T) { + access := &StageAccess{ + Restricted: true, + ActiveStageKey: "req_planning", + AllowedStageKeys: map[string]bool{"biz_intake": true}, + } + if !access.StageKeyAllowed("biz_intake") { + t.Fatal("expected biz_intake allowed") + } + if access.StageKeyAllowed("req_planning") { + t.Fatal("expected req_planning denied for BD on completed intake only") + } + if !access.StageKeyAllowed("") { + t.Fatal("empty stage should pass when unrestricted check applies") + } +} + +func TestMessageStageKey(t *testing.T) { + if got := MessageStageKey(map[string]any{"stage_key": "biz_intake"}); got != "biz_intake" { + t.Fatalf("expected biz_intake, got %q", got) + } + if got := MessageStageKey(nil); got != "" { + t.Fatalf("expected empty, got %q", got) + } +} diff --git a/internal/projectinstance/stage_access_test.go b/internal/projectinstance/stage_access_test.go new file mode 100644 index 00000000..8b625179 --- /dev/null +++ b/internal/projectinstance/stage_access_test.go @@ -0,0 +1,59 @@ +package projectinstance + +import ( + "context" + "testing" +) + +func TestResolveStageAccess_ActiveStageDutyOnly(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 42, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, nil) + + _, err := svc.SaveWorkflowPlan(ctx, 42, 1, true, SavePlanInput{ + Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + if err != nil { + t.Fatalf("save plan: %v", err) + } + preview, err := svc.ActivationPreview(ctx, 42, 1, true) + if err != nil { + t.Fatalf("preview: %v", err) + } + _, err = svc.Activate(ctx, 42, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if err != nil { + t.Fatalf("activate: %v", err) + } + + bdUser := int64(99) + access, err := svc.ResolveStageAccess(ctx, 42, bdUser, false) + if err != nil { + t.Fatal(err) + } + if !access.Restricted { + t.Fatal("expected restricted access under live instance") + } + if access.TaskKeyAllowed("intake") { + t.Fatal("user without duty should not access active stage docs") + } + + authorAccess, _ := svc.ResolveStageAccess(ctx, 42, 1, false) + if !authorAccess.TaskKeyAllowed("intake") { + t.Fatal("author on active stage should access intake") + } + + creatorAccess, _ := svc.ResolveStageAccess(ctx, 42, 1, true) + if !creatorAccess.StageKeyAllowed("biz_intake") { + t.Fatal("creator should access active stage") + } + + outsiderAccess, _ := svc.ResolveStageAccess(ctx, 42, 9, false) + if outsiderAccess.StageKeyAllowed("biz_intake") { + t.Fatal("user without duty should not access active stage chat") + } +} diff --git a/internal/projectinstance/stage_service.go b/internal/projectinstance/stage_service.go new file mode 100644 index 00000000..de580e21 --- /dev/null +++ b/internal/projectinstance/stage_service.go @@ -0,0 +1,110 @@ +package projectinstance + +import ( + "context" + "fmt" +) + +func (s *Service) GetInstanceDetail(ctx context.Context, instanceID int64) (*InstanceDetail, error) { + if !s.Available() { + return nil, ErrNotAvailable + } + return s.store.GetInstanceByID(ctx, instanceID) +} + +func (s *Service) GetStageReadiness(ctx context.Context, instanceID int64, stageKey string, userID int64, isCreator bool) (*StageReadiness, error) { + if !s.Available() { + return nil, ErrNotAvailable + } + detail, err := s.store.GetInstanceByID(ctx, instanceID) + if err != nil { + return nil, err + } + if stageKey == "" { + stageKey = detail.ActiveStageKey + } + if stageKey == "" { + return nil, ErrInvalidStage + } + + version, err := s.resolvePublishedVersion(ctx, detail.Instance.ConfigSnapshot) + if err != nil { + return nil, err + } + stagePlan := findStagePlan(detail.Instance.ConfigSnapshot, stageKey) + if stagePlan == nil { + return nil, ErrInvalidStage + } + stageDef := findStageDef(version.Definition, stageKey) + + docStates := map[string]string{} + if s.docs != nil { + docStates, err = s.docs.TaskApprovalStates(ctx, detail.Instance.ProjectID) + if err != nil { + return nil, err + } + } + + ready, issues, tasks := evaluateStageReadiness(*stagePlan, stageDef, docStates) + stageName := stageKey + if stageDef != nil && stageDef.Name != "" { + stageName = stageDef.Name + } + + return &StageReadiness{ + InstanceID: instanceID, + StageKey: stageKey, + StageName: stageName, + Ready: ready, + CanAdvance: isCreator && ready && detail.ActiveStageKey == stageKey, + BlockingIssues: issues, + DocTasks: tasks, + ConfigRevision: detail.Instance.ConfigRevision, + }, nil +} + +func (s *Service) AdvanceStage(ctx context.Context, instanceID int64, userID int64, isCreator bool, in AdvanceStageInput) (*AdvanceStageResult, error) { + if !isCreator { + return nil, ErrForbiddenNotCreator + } + if !s.Available() { + return nil, ErrNotAvailable + } + readiness, err := s.GetStageReadiness(ctx, instanceID, in.StageKey, userID, isCreator) + if err != nil { + return nil, err + } + if !readiness.Ready { + return nil, fmt.Errorf("%w: %d blocking issues", ErrStageGateNotReady, len(readiness.BlockingIssues)) + } + if in.ExpectedConfigRevision != readiness.ConfigRevision { + return nil, ErrInstanceRevisionConflict + } + + before, err := s.store.GetInstanceByID(ctx, instanceID) + if err != nil { + return nil, err + } + completedStage := before.ActiveStageKey + var completedStageInstanceID int64 + for _, st := range before.Stages { + if st.StageKey == in.StageKey { + completedStageInstanceID = st.ID + break + } + } + + after, err := s.store.AdvanceStage(ctx, instanceID, in.StageKey, in.ExpectedConfigRevision) + if err != nil { + return nil, err + } + + return &AdvanceStageResult{ + Instance: after.Instance, + CompletedStage: completedStage, + CompletedStageInstanceID: completedStageInstanceID, + CompletedReadinessRevision: in.ExpectedConfigRevision, + NextStageKey: after.ActiveStageKey, + InstanceDone: after.Instance.Status == InstanceCompleted, + }, nil +} diff --git a/internal/projectinstance/stage_service_test.go b/internal/projectinstance/stage_service_test.go new file mode 100644 index 00000000..5418f775 --- /dev/null +++ b/internal/projectinstance/stage_service_test.go @@ -0,0 +1,84 @@ +package projectinstance + +import ( + "context" + "errors" + "testing" +) + +type stubDocChecker struct { + states map[string]string +} + +func (s *stubDocChecker) TaskApprovalStates(_ context.Context, _ int64) (map[string]string, error) { + return s.states, nil +} + +func TestService_AdvanceStageGate(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 42, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, &stubDocChecker{states: map[string]string{"intake": "approved"}}) + + settings, err := svc.SaveWorkflowPlan(ctx, 42, 1, true, SavePlanInput{ + Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + if err != nil { + t.Fatalf("SaveWorkflowPlan: %v", err) + } + preview, err := svc.ActivationPreview(ctx, 42, 1, true) + if err != nil { + t.Fatalf("ActivationPreview: %v", err) + } + result, err := svc.Activate(ctx, 42, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if err != nil { + t.Fatalf("Activate: %v", err) + } + + readiness, err := svc.GetStageReadiness(ctx, result.Instance.ID, "biz_intake", 1, true) + if err != nil { + t.Fatalf("GetStageReadiness: %v", err) + } + if !readiness.Ready || !readiness.CanAdvance { + t.Fatalf("expected stage ready to advance, got %+v", readiness) + } + + _ = settings + advanced, err := svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{ + StageKey: "biz_intake", ExpectedConfigRevision: result.Instance.ConfigRevision, + }) + if err != nil { + t.Fatalf("AdvanceStage: %v", err) + } + if advanced.CompletedStage != "biz_intake" { + t.Fatalf("expected completed biz_intake, got %s", advanced.CompletedStage) + } + if advanced.NextStageKey == "biz_intake" { + t.Fatal("next stage should not be same as completed") + } +} + +func TestService_AdvanceStageBlockedWithoutApproval(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 9, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, &stubDocChecker{states: map[string]string{}}) + + _, _ = svc.SaveWorkflowPlan(ctx, 9, 1, true, SavePlanInput{Plan: validPlan(), ExpectedPlanRevision: 0}) + preview, _ := svc.ActivationPreview(ctx, 9, 1, true) + result, _ := svc.Activate(ctx, 9, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + + _, err := svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{ + StageKey: "biz_intake", ExpectedConfigRevision: result.Instance.ConfigRevision, + }) + if !errors.Is(err, ErrStageGateNotReady) { + t.Fatalf("expected ErrStageGateNotReady, got %v", err) + } +} diff --git a/internal/projectinstance/stage_store.go b/internal/projectinstance/stage_store.go new file mode 100644 index 00000000..0d1af835 --- /dev/null +++ b/internal/projectinstance/stage_store.go @@ -0,0 +1,192 @@ +package projectinstance + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +func (s *pgStore) GetInstanceByID(ctx context.Context, instanceID int64) (*InstanceDetail, error) { + row := s.pool.QueryRow(ctx, ` + SELECT id, project_id, workflow_version_id, instance_no, status, config_revision, plan_hash, + config_snapshot, activated_by, activated_at, created_by, created_at, updated_at + FROM project_workflow_instances WHERE id = $1 + `, instanceID) + inst, err := scanInstance(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrInstanceNotFound + } + if err != nil { + return nil, err + } + return s.loadInstanceDetail(ctx, inst) +} + +func (s *pgStore) loadInstanceDetail(ctx context.Context, inst *WorkflowInstance) (*InstanceDetail, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, stage_key, position, enabled, status + FROM project_stage_instances + WHERE workflow_instance_id = $1 + ORDER BY position ASC, id ASC + `, inst.ID) + if err != nil { + return nil, err + } + defer rows.Close() + + detail := &InstanceDetail{Instance: *inst, Stages: make([]StageInstance, 0)} + for rows.Next() { + var st StageInstance + if err := rows.Scan(&st.ID, &st.StageKey, &st.Position, &st.Enabled, &st.Status); err != nil { + return nil, err + } + if st.Status == StageActive { + detail.ActiveStageKey = st.StageKey + } + taskRows, err := s.pool.Query(ctx, ` + SELECT id, task_key, enabled, status + FROM project_doc_task_instances WHERE stage_instance_id = $1 ORDER BY id ASC + `, st.ID) + if err != nil { + return nil, err + } + for taskRows.Next() { + var dt DocTaskInstance + if err := taskRows.Scan(&dt.ID, &dt.TaskKey, &dt.Enabled, &dt.Status); err != nil { + taskRows.Close() + return nil, err + } + dt.Approved = dt.Status == "approved" + st.DocTasks = append(st.DocTasks, dt) + } + taskRows.Close() + detail.Stages = append(detail.Stages, st) + } + return detail, rows.Err() +} + +func (s *pgStore) AdvanceStage(ctx context.Context, instanceID int64, stageKey string, expectedRevision int) (*InstanceDetail, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + var instStatus string + var configRev int + err = tx.QueryRow(ctx, ` + SELECT status, config_revision FROM project_workflow_instances WHERE id = $1 FOR UPDATE + `, instanceID).Scan(&instStatus, &configRev) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrInstanceNotFound + } + if err != nil { + return nil, err + } + if configRev != expectedRevision { + return nil, ErrInstanceRevisionConflict + } + if instStatus != InstanceActive && instStatus != "awaiting_stage_confirmation" { + return nil, fmt.Errorf("%w: instance status %s", ErrInvalidStage, instStatus) + } + + var stageID int64 + var stageStatus string + err = tx.QueryRow(ctx, ` + SELECT id, status FROM project_stage_instances + WHERE workflow_instance_id = $1 AND stage_key = $2 AND enabled = true + `, instanceID, stageKey).Scan(&stageID, &stageStatus) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrInvalidStage + } + if err != nil { + return nil, err + } + if stageStatus != StageActive { + return nil, ErrStageNotActive + } + + now := time.Now() + _, err = tx.Exec(ctx, ` + UPDATE project_stage_instances SET status = 'completed', updated_at = $2 + WHERE id = $1 + `, stageID, now) + if err != nil { + return nil, err + } + _, err = tx.Exec(ctx, ` + UPDATE project_doc_task_instances SET status = 'approved', updated_at = $2 + WHERE stage_instance_id = $1 AND enabled = true AND status <> 'skipped' + `, stageID, now) + if err != nil { + return nil, err + } + + var nextStageID int64 + var nextStageKey string + err = tx.QueryRow(ctx, ` + SELECT id, stage_key FROM project_stage_instances + WHERE workflow_instance_id = $1 AND enabled = true AND status = 'pending' + ORDER BY position ASC, id ASC LIMIT 1 + `, instanceID).Scan(&nextStageID, &nextStageKey) + if errors.Is(err, pgx.ErrNoRows) { + _, err = tx.Exec(ctx, ` + UPDATE project_workflow_instances SET status = 'completed', config_revision = config_revision + 1, updated_at = $2 + WHERE id = $1 + `, instanceID, now) + if err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } else { + _, err = tx.Exec(ctx, ` + UPDATE project_stage_instances SET status = 'active', updated_at = $2 WHERE id = $1 + `, nextStageID, now) + if err != nil { + return nil, err + } + _, err = tx.Exec(ctx, ` + UPDATE project_workflow_instances SET config_revision = config_revision + 1, updated_at = $2 WHERE id = $1 + `, instanceID, now) + if err != nil { + return nil, err + } + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + row := s.pool.QueryRow(ctx, ` + SELECT id, project_id, workflow_version_id, instance_no, status, config_revision, plan_hash, + config_snapshot, activated_by, activated_at, created_by, created_at, updated_at + FROM project_workflow_instances WHERE id = $1 + `, instanceID) + inst, err := scanInstance(row) + if err != nil { + return nil, err + } + return s.loadInstanceDetail(ctx, inst) +} + +func (s *pgStore) MarkDocTaskApproved(ctx context.Context, projectID int64, taskKey string) error { + if projectID <= 0 || taskKey == "" { + return nil + } + _, err := s.pool.Exec(ctx, ` + UPDATE project_doc_task_instances dti + SET status = 'approved', updated_at = now() + FROM project_stage_instances psi + JOIN project_workflow_instances pwi ON pwi.id = psi.workflow_instance_id + WHERE dti.stage_instance_id = psi.id + AND pwi.project_id = $1 + AND dti.task_key = $2 + AND dti.enabled = true + AND dti.status <> 'skipped' + `, projectID, taskKey) + return err +} diff --git a/internal/projectinstance/store.go b/internal/projectinstance/store.go new file mode 100644 index 00000000..5e416fc6 --- /dev/null +++ b/internal/projectinstance/store.go @@ -0,0 +1,731 @@ +package projectinstance + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// TablesReady 检查 Phase 3 表是否已迁移。 +func TablesReady(ctx context.Context, pool *pgxpool.Pool) bool { + if pool == nil { + return false + } + var ok bool + err := pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'project_workflow_instances' + ) + `).Scan(&ok) + return err == nil && ok +} + +// Store 项目实例持久化接口。 +type Store interface { + GetProjectMeta(ctx context.Context, projectID int64) (lifecycleStatus string, activeWorkflowKey string, selectionVersion int, planRevision int, err error) + UpdateProjectAfterActivate(ctx context.Context, projectID int64, workflowKey string, expectedVersion int, activatedBy int64) error + BumpPlanRevision(ctx context.Context, projectID int64, expectedPlanRevision int) (int, error) + + GetPlanningInstance(ctx context.Context, projectID int64) (*WorkflowInstance, error) + GetLiveInstance(ctx context.Context, projectID int64) (*WorkflowInstance, error) + HasLiveInstance(ctx context.Context, projectID int64) (bool, error) + UpsertPlanningInstance(ctx context.Context, inst *WorkflowInstance) (*WorkflowInstance, error) + ActivateInstance(ctx context.Context, projectID int64, inst *WorkflowInstance, workflowKey string, expectedVersion int, activatedBy int64) (*WorkflowInstance, error) + + SaveActivationToken(ctx context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, createdBy int64, expiresAt time.Time) error + ConsumeActivationToken(ctx context.Context, token string, projectID int64, planHash string) (*WorkflowPlan, error) + + GetInstanceByID(ctx context.Context, instanceID int64) (*InstanceDetail, error) + AdvanceStage(ctx context.Context, instanceID int64, stageKey string, expectedRevision int) (*InstanceDetail, error) + MarkDocTaskApproved(ctx context.Context, projectID int64, taskKey string) error +} + +// NewStore 构建 PG 或内存 Store。 +func NewStore(pool *pgxpool.Pool) Store { + if pool != nil && TablesReady(context.Background(), pool) { + return &pgStore{pool: pool} + } + return newMemStore() +} + +type pgStore struct { + pool *pgxpool.Pool +} + +func scanInstance(row pgx.Row) (*WorkflowInstance, error) { + var inst WorkflowInstance + var snapRaw []byte + var activatedBy, createdBy *int64 + err := row.Scan( + &inst.ID, &inst.ProjectID, &inst.WorkflowVersionID, &inst.InstanceNo, &inst.Status, + &inst.ConfigRevision, &inst.PlanHash, &snapRaw, &activatedBy, &inst.ActivatedAt, + &createdBy, &inst.CreatedAt, &inst.UpdatedAt, + ) + if err != nil { + return nil, err + } + inst.ActivatedBy = activatedBy + inst.CreatedBy = createdBy + if len(snapRaw) > 0 { + _ = json.Unmarshal(snapRaw, &inst.ConfigSnapshot) + } + return &inst, nil +} + +func (s *pgStore) GetProjectMeta(ctx context.Context, projectID int64) (string, string, int, int, error) { + var lifecycle, activeKey string + var selVer, planRev int + err := s.pool.QueryRow(ctx, ` + SELECT COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''), + COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0) + FROM projects WHERE id = $1 + `, projectID).Scan(&lifecycle, &activeKey, &selVer, &planRev) + if errors.Is(err, pgx.ErrNoRows) { + return "", "", 0, 0, ErrProjectNotFound + } + return lifecycle, activeKey, selVer, planRev, err +} + +func (s *pgStore) UpdateProjectAfterActivate(ctx context.Context, projectID int64, workflowKey string, expectedVersion int, activatedBy int64) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE projects SET lifecycle_status = 'active', active_workflow_key = $2, + workflow_selection_version = workflow_selection_version + 1, + active_workflow_changed_at = now(), active_workflow_changed_by = $4, + updated_at = now() + WHERE id = $1 AND workflow_selection_version = $3 + `, projectID, workflowKey, expectedVersion, activatedBy) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrConcurrentConflict + } + return nil +} + +func (s *pgStore) BumpPlanRevision(ctx context.Context, projectID int64, expectedPlanRevision int) (int, error) { + var newRev int + err := s.pool.QueryRow(ctx, ` + UPDATE projects SET workflow_plan_revision = workflow_plan_revision + 1, updated_at = now() + WHERE id = $1 AND workflow_plan_revision = $2 + RETURNING workflow_plan_revision + `, projectID, expectedPlanRevision).Scan(&newRev) + if errors.Is(err, pgx.ErrNoRows) { + return 0, ErrPlanRevisionConflict + } + return newRev, err +} + +func (s *pgStore) GetPlanningInstance(ctx context.Context, projectID int64) (*WorkflowInstance, error) { + row := s.pool.QueryRow(ctx, ` + SELECT id, project_id, workflow_version_id, instance_no, status, config_revision, plan_hash, + config_snapshot, activated_by, activated_at, created_by, created_at, updated_at + FROM project_workflow_instances WHERE project_id = $1 AND status = 'planning' + ORDER BY id DESC LIMIT 1 + `, projectID) + inst, err := scanInstance(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return inst, err +} + +func (s *pgStore) GetLiveInstance(ctx context.Context, projectID int64) (*WorkflowInstance, error) { + row := s.pool.QueryRow(ctx, ` + SELECT id, project_id, workflow_version_id, instance_no, status, config_revision, plan_hash, + config_snapshot, activated_by, activated_at, created_by, created_at, updated_at + FROM project_workflow_instances + WHERE project_id = $1 AND status IN ('active', 'awaiting_stage_confirmation', 'termination_requested') + ORDER BY id DESC LIMIT 1 + `, projectID) + inst, err := scanInstance(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return inst, err +} + +func (s *pgStore) HasLiveInstance(ctx context.Context, projectID int64) (bool, error) { + var ok bool + err := s.pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM project_workflow_instances + WHERE project_id = $1 AND status IN ('active', 'awaiting_stage_confirmation', 'termination_requested') + ) + `, projectID).Scan(&ok) + return ok, err +} + +func (s *pgStore) UpsertPlanningInstance(ctx context.Context, inst *WorkflowInstance) (*WorkflowInstance, error) { + snapRaw, _ := json.Marshal(inst.ConfigSnapshot) + now := time.Now() + if inst.CreatedAt.IsZero() { + inst.CreatedAt = now + } + inst.UpdatedAt = now + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + var existingID int64 + err = tx.QueryRow(ctx, ` + SELECT id FROM project_workflow_instances WHERE project_id = $1 AND status = 'planning' LIMIT 1 + `, inst.ProjectID).Scan(&existingID) + if errors.Is(err, pgx.ErrNoRows) { + err = tx.QueryRow(ctx, ` + INSERT INTO project_workflow_instances ( + project_id, workflow_version_id, instance_no, status, config_revision, plan_hash, + config_snapshot, created_by, created_at, updated_at + ) VALUES ($1, $2, 1, 'planning', $3, $4, $5, $6, $7, $8) + RETURNING id + `, inst.ProjectID, inst.WorkflowVersionID, inst.ConfigRevision, inst.PlanHash, snapRaw, + inst.CreatedBy, inst.CreatedAt, inst.UpdatedAt).Scan(&inst.ID) + if err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } else { + err = tx.QueryRow(ctx, ` + UPDATE project_workflow_instances SET + workflow_version_id = $2, config_revision = $3, plan_hash = $4, config_snapshot = $5, + updated_at = $6 + WHERE id = $1 AND status = 'planning' + RETURNING id + `, existingID, inst.WorkflowVersionID, inst.ConfigRevision, inst.PlanHash, snapRaw, inst.UpdatedAt).Scan(&inst.ID) + if err != nil { + return nil, err + } + } + + if err := s.replaceInstanceChildren(ctx, tx, inst); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return inst, nil +} + +func (s *pgStore) replaceInstanceChildren(ctx context.Context, tx pgx.Tx, inst *WorkflowInstance) error { + _, err := tx.Exec(ctx, ` + DELETE FROM document_duty_assignments WHERE doc_task_instance_id IN ( + SELECT dti.id FROM project_doc_task_instances dti + JOIN project_stage_instances si ON si.id = dti.stage_instance_id + WHERE si.workflow_instance_id = $1 + ) + `, inst.ID) + if err != nil { + return err + } + _, err = tx.Exec(ctx, ` + DELETE FROM project_doc_task_instances WHERE stage_instance_id IN ( + SELECT id FROM project_stage_instances WHERE workflow_instance_id = $1 + ) + `, inst.ID) + if err != nil { + return err + } + _, err = tx.Exec(ctx, `DELETE FROM project_stage_instances WHERE workflow_instance_id = $1`, inst.ID) + if err != nil { + return err + } + + for _, st := range inst.ConfigSnapshot.Stages { + var stageID int64 + err = tx.QueryRow(ctx, ` + INSERT INTO project_stage_instances (workflow_instance_id, stage_key, position, enabled, status) + VALUES ($1, $2, 0, $3, 'pending') RETURNING id + `, inst.ID, st.StageKey, st.Enabled).Scan(&stageID) + if err != nil { + return err + } + for _, dt := range st.DocTasks { + var docTaskID int64 + err = tx.QueryRow(ctx, ` + INSERT INTO project_doc_task_instances (stage_instance_id, task_key, enabled, status) + VALUES ($1, $2, $3, 'pending') RETURNING id + `, stageID, dt.TaskKey, dt.Enabled).Scan(&docTaskID) + if err != nil { + return err + } + if !dt.Enabled { + continue + } + for role, uid := range map[string]int64{ + DutyAuthor: dt.Duties.AuthorUserID, DutyReviewer: dt.Duties.ReviewerUserID, DutyApprover: dt.Duties.ApproverUserID, + } { + if uid <= 0 { + continue + } + _, err = tx.Exec(ctx, ` + INSERT INTO document_duty_assignments (doc_task_instance_id, duty_role, user_id, assigned_by) + VALUES ($1, $2, $3, $4) + `, docTaskID, role, uid, inst.CreatedBy) + if err != nil { + return err + } + } + } + } + return nil +} + +func (s *pgStore) ActivateInstance(ctx context.Context, projectID int64, inst *WorkflowInstance, workflowKey string, expectedVersion int, activatedBy int64) (*WorkflowInstance, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + tag, err := tx.Exec(ctx, ` + UPDATE projects SET lifecycle_status = 'active', active_workflow_key = $2, + workflow_selection_version = workflow_selection_version + 1, + active_workflow_changed_at = now(), active_workflow_changed_by = $4, + updated_at = now() + WHERE id = $1 AND workflow_selection_version = $3 + `, projectID, workflowKey, expectedVersion, activatedBy) + if err != nil { + return nil, err + } + if tag.RowsAffected() == 0 { + return nil, ErrConcurrentConflict + } + + now := time.Now() + row := tx.QueryRow(ctx, ` + UPDATE project_workflow_instances SET status = 'active', activated_by = $2, activated_at = $3, updated_at = $3 + WHERE id = $1 AND status = 'planning' + RETURNING id, project_id, workflow_version_id, instance_no, status, config_revision, plan_hash, + config_snapshot, activated_by, activated_at, created_by, created_at, updated_at + `, inst.ID, activatedBy, now) + activated, err := scanInstance(row) + if err != nil { + return nil, err + } + + _, err = tx.Exec(ctx, ` + UPDATE project_stage_instances SET status = CASE WHEN enabled THEN 'active' ELSE 'skipped' END, updated_at = now() + WHERE workflow_instance_id = $1 AND stage_key = ( + SELECT stage_key FROM project_stage_instances + WHERE workflow_instance_id = $1 AND enabled = true + ORDER BY id ASC LIMIT 1 + ) + `, inst.ID) + if err != nil { + return nil, err + } + _, err = tx.Exec(ctx, ` + UPDATE project_stage_instances SET status = 'pending', updated_at = now() + WHERE workflow_instance_id = $1 AND status <> 'active' AND enabled = true + `, inst.ID) + if err != nil { + return nil, err + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return activated, nil +} + +func (s *pgStore) SaveActivationToken(ctx context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, createdBy int64, expiresAt time.Time) error { + raw, _ := json.Marshal(snapshot) + _, err := s.pool.Exec(ctx, ` + INSERT INTO workflow_activation_tokens (token, project_id, plan_hash, expected_project_version, config_snapshot, created_by, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `, token, projectID, planHash, expectedVersion, raw, createdBy, expiresAt) + return err +} + +func (s *pgStore) ConsumeActivationToken(ctx context.Context, token string, projectID int64, planHash string) (*WorkflowPlan, error) { + var snapRaw []byte + var storedHash string + var usedAt *time.Time + var expiresAt time.Time + err := s.pool.QueryRow(ctx, ` + SELECT plan_hash, config_snapshot, used_at, expires_at + FROM workflow_activation_tokens WHERE token = $1 AND project_id = $2 + `, token, projectID).Scan(&storedHash, &snapRaw, &usedAt, &expiresAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrTokenInvalid + } + if err != nil { + return nil, err + } + if usedAt != nil || time.Now().After(expiresAt) || storedHash != planHash { + return nil, ErrTokenInvalid + } + tag, err := s.pool.Exec(ctx, ` + UPDATE workflow_activation_tokens SET used_at = now() WHERE token = $1 AND used_at IS NULL + `, token) + if err != nil { + return nil, err + } + if tag.RowsAffected() == 0 { + return nil, ErrTokenInvalid + } + var plan WorkflowPlan + _ = json.Unmarshal(snapRaw, &plan) + return &plan, nil +} + +// ============================================================================= +// 内存 Store(单元测试 / 无 PG 迁移) +// ============================================================================= + +type memStageRecord struct { + id int64 + stageKey string + position int + enabled bool + status string + tasks []memDocTaskRecord +} + +type memDocTaskRecord struct { + id int64 + taskKey string + enabled bool + status string +} + +type memStore struct { + mu sync.Mutex + meta map[int64]projectMeta + plan map[int64]*WorkflowInstance + live map[int64]*WorkflowInstance + tokens map[string]activationTokenRec + stages map[int64][]memStageRecord + nextStageID int64 + nextTaskID int64 +} + +type projectMeta struct { + lifecycle string + activeKey string + selectionVer int + planRevision int +} + +type activationTokenRec struct { + projectID int64 + planHash string + expectedVer int + snapshot WorkflowPlan + expiresAt time.Time + used bool +} + +func newMemStore() *memStore { + return &memStore{ + meta: make(map[int64]projectMeta), + plan: make(map[int64]*WorkflowInstance), + live: make(map[int64]*WorkflowInstance), + tokens: make(map[string]activationTokenRec), + stages: make(map[int64][]memStageRecord), + nextStageID: 1, + nextTaskID: 1, + } +} + +func (m *memStore) rebuildStagesLocked(inst *WorkflowInstance, activate bool) { + records := make([]memStageRecord, 0, len(inst.ConfigSnapshot.Stages)) + firstActive := true + for i, st := range inst.ConfigSnapshot.Stages { + rec := memStageRecord{ + id: m.nextStageID, stageKey: st.StageKey, position: i, enabled: st.Enabled, + } + m.nextStageID++ + if !st.Enabled { + rec.status = StageSkipped + } else if activate && firstActive { + rec.status = StageActive + firstActive = false + } else { + rec.status = StagePending + } + for _, dt := range st.DocTasks { + task := memDocTaskRecord{id: m.nextTaskID, taskKey: dt.TaskKey, enabled: dt.Enabled, status: "pending"} + m.nextTaskID++ + if !dt.Enabled { + task.status = "skipped" + } + rec.tasks = append(rec.tasks, task) + } + records = append(records, rec) + } + m.stages[inst.ID] = records +} + +func (m *memStore) instanceDetailLocked(inst *WorkflowInstance) *InstanceDetail { + detail := &InstanceDetail{Instance: *inst, Stages: make([]StageInstance, 0)} + for _, rec := range m.stages[inst.ID] { + st := StageInstance{ + ID: rec.id, StageKey: rec.stageKey, Position: rec.position, Enabled: rec.enabled, Status: rec.status, + } + if st.Status == StageActive { + detail.ActiveStageKey = st.StageKey + } + for _, t := range rec.tasks { + st.DocTasks = append(st.DocTasks, DocTaskInstance{ + ID: t.id, TaskKey: t.taskKey, Enabled: t.enabled, Status: t.status, Approved: t.status == "approved", + }) + } + detail.Stages = append(detail.Stages, st) + } + return detail +} + +func (m *memStore) ensureMeta(projectID int64) projectMeta { + if pm, ok := m.meta[projectID]; ok { + return pm + } + pm := projectMeta{lifecycle: LifecyclePlanning, selectionVer: 1} + m.meta[projectID] = pm + return pm +} + +func (m *memStore) GetProjectMeta(_ context.Context, projectID int64) (string, string, int, int, error) { + m.mu.Lock() + defer m.mu.Unlock() + pm, ok := m.meta[projectID] + if !ok { + return "", "", 0, 0, ErrProjectNotFound + } + return pm.lifecycle, pm.activeKey, pm.selectionVer, pm.planRevision, nil +} + +func (m *memStore) UpdateProjectAfterActivate(_ context.Context, projectID int64, workflowKey string, expectedVersion int, _ int64) error { + m.mu.Lock() + defer m.mu.Unlock() + pm := m.ensureMeta(projectID) + if pm.selectionVer != expectedVersion { + return ErrConcurrentConflict + } + pm.lifecycle = LifecycleActive + pm.activeKey = workflowKey + pm.selectionVer++ + m.meta[projectID] = pm + return nil +} + +func (m *memStore) BumpPlanRevision(_ context.Context, projectID int64, expectedPlanRevision int) (int, error) { + m.mu.Lock() + defer m.mu.Unlock() + pm := m.ensureMeta(projectID) + if pm.planRevision != expectedPlanRevision { + return 0, ErrPlanRevisionConflict + } + pm.planRevision++ + m.meta[projectID] = pm + return pm.planRevision, nil +} + +func (m *memStore) GetPlanningInstance(_ context.Context, projectID int64) (*WorkflowInstance, error) { + m.mu.Lock() + defer m.mu.Unlock() + inst := m.plan[projectID] + if inst == nil { + return nil, nil + } + copyInst := *inst + return ©Inst, nil +} + +func (m *memStore) GetLiveInstance(_ context.Context, projectID int64) (*WorkflowInstance, error) { + m.mu.Lock() + defer m.mu.Unlock() + inst := m.live[projectID] + if inst == nil { + return nil, nil + } + copyInst := *inst + return ©Inst, nil +} + +func (m *memStore) HasLiveInstance(_ context.Context, projectID int64) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + return m.live[projectID] != nil, nil +} + +func (m *memStore) UpsertPlanningInstance(_ context.Context, inst *WorkflowInstance) (*WorkflowInstance, error) { + m.mu.Lock() + defer m.mu.Unlock() + if inst.ID == 0 { + inst.ID = int64(len(m.plan) + 1) + } + inst.Status = InstancePlanning + now := time.Now() + if inst.CreatedAt.IsZero() { + inst.CreatedAt = now + } + inst.UpdatedAt = now + copyInst := *inst + m.plan[inst.ProjectID] = ©Inst + m.rebuildStagesLocked(©Inst, false) + return ©Inst, nil +} + +func (m *memStore) ActivateInstance(_ context.Context, projectID int64, inst *WorkflowInstance, workflowKey string, expectedVersion int, activatedBy int64) (*WorkflowInstance, error) { + m.mu.Lock() + defer m.mu.Unlock() + pm := m.ensureMeta(projectID) + if pm.selectionVer != expectedVersion { + return nil, ErrConcurrentConflict + } + pm.lifecycle = LifecycleActive + pm.activeKey = workflowKey + pm.selectionVer++ + m.meta[projectID] = pm + + now := time.Now() + activated := *inst + activated.Status = InstanceActive + activated.ActivatedBy = &activatedBy + activated.ActivatedAt = &now + m.live[projectID] = &activated + m.rebuildStagesLocked(&activated, true) + delete(m.plan, projectID) + return &activated, nil +} + +func (m *memStore) GetInstanceByID(_ context.Context, instanceID int64) (*InstanceDetail, error) { + m.mu.Lock() + defer m.mu.Unlock() + for _, inst := range m.live { + if inst.ID == instanceID { + return m.instanceDetailLocked(inst), nil + } + } + for _, inst := range m.plan { + if inst.ID == instanceID { + return m.instanceDetailLocked(inst), nil + } + } + return nil, ErrInstanceNotFound +} + +func (m *memStore) AdvanceStage(_ context.Context, instanceID int64, stageKey string, expectedRevision int) (*InstanceDetail, error) { + m.mu.Lock() + defer m.mu.Unlock() + var inst *WorkflowInstance + for _, live := range m.live { + if live.ID == instanceID { + inst = live + break + } + } + if inst == nil { + return nil, ErrInstanceNotFound + } + if inst.ConfigRevision != expectedRevision { + return nil, ErrInstanceRevisionConflict + } + records := m.stages[instanceID] + activeIdx := -1 + for i, rec := range records { + if rec.stageKey == stageKey { + if rec.status != StageActive { + return nil, ErrStageNotActive + } + activeIdx = i + break + } + } + if activeIdx < 0 { + return nil, ErrInvalidStage + } + records[activeIdx].status = StageCompleted + for j := range records[activeIdx].tasks { + if records[activeIdx].tasks[j].enabled { + records[activeIdx].tasks[j].status = "approved" + } + } + nextIdx := -1 + for i, rec := range records { + if rec.enabled && rec.status == StagePending { + nextIdx = i + break + } + } + inst.ConfigRevision++ + inst.UpdatedAt = time.Now() + if nextIdx < 0 { + inst.Status = InstanceCompleted + } else { + records[nextIdx].status = StageActive + } + m.stages[instanceID] = records + copyInst := *inst + m.live[inst.ProjectID] = ©Inst + return m.instanceDetailLocked(©Inst), nil +} + +func (m *memStore) MarkDocTaskApproved(_ context.Context, projectID int64, taskKey string) error { + m.mu.Lock() + defer m.mu.Unlock() + live := m.live[projectID] + if live == nil { + return nil + } + records := m.stages[live.ID] + for i := range records { + for j := range records[i].tasks { + if records[i].tasks[j].taskKey == taskKey && records[i].tasks[j].enabled { + records[i].tasks[j].status = "approved" + } + } + } + m.stages[live.ID] = records + return nil +} + +func (m *memStore) SaveActivationToken(_ context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, _ int64, expiresAt time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + m.tokens[token] = activationTokenRec{ + projectID: projectID, planHash: planHash, expectedVer: expectedVersion, + snapshot: snapshot, expiresAt: expiresAt, + } + return nil +} + +func (m *memStore) ConsumeActivationToken(_ context.Context, token string, projectID int64, planHash string) (*WorkflowPlan, error) { + m.mu.Lock() + defer m.mu.Unlock() + rec, ok := m.tokens[token] + if !ok || rec.used || rec.projectID != projectID || rec.planHash != planHash || time.Now().After(rec.expiresAt) { + return nil, ErrTokenInvalid + } + rec.used = true + m.tokens[token] = rec + plan := rec.snapshot + return &plan, nil +} + +// SeedProjectMeta 供测试注入项目元数据。 +func SeedProjectMeta(store Store, projectID int64, lifecycle, activeKey string, selectionVer, planRev int) error { + ms, ok := store.(*memStore) + if !ok { + return fmt.Errorf("SeedProjectMeta only supported on memStore") + } + ms.mu.Lock() + defer ms.mu.Unlock() + ms.meta[projectID] = projectMeta{ + lifecycle: lifecycle, activeKey: activeKey, + selectionVer: selectionVer, planRevision: planRev, + } + return nil +} diff --git a/internal/projectinstance/types.go b/internal/projectinstance/types.go new file mode 100644 index 00000000..c317c027 --- /dev/null +++ b/internal/projectinstance/types.go @@ -0,0 +1,215 @@ +package projectinstance + +import ( + "time" + + "chat-agent-system/internal/workflowdef" +) + +const ( + LifecyclePlanning = "planning" + LifecycleActive = "active" + + InstancePlanning = "planning" + InstanceActive = "active" + + DutyAuthor = "author" + DutyReviewer = "reviewer" + DutyApprover = "approver" +) + +// DocDuty 文档三权职责指派。 +type DocDuty struct { + AuthorUserID int64 `json:"author_user_id,omitempty"` + ReviewerUserID int64 `json:"reviewer_user_id,omitempty"` + ApproverUserID int64 `json:"approver_user_id,omitempty"` +} + +// DocTaskPlan 文档任务裁剪与职责。 +type DocTaskPlan struct { + TaskKey string `json:"task_key"` + Enabled bool `json:"enabled"` + Duties DocDuty `json:"duties"` +} + +// StagePlan 阶段裁剪与文档任务。 +type StagePlan struct { + StageKey string `json:"stage_key"` + Enabled bool `json:"enabled"` + DocTasks []DocTaskPlan `json:"doc_tasks"` +} + +// WorkflowPlan 项目工作流规划快照。 +type WorkflowPlan struct { + WorkflowDefinitionKey string `json:"workflow_definition_key"` + WorkflowVersion int `json:"workflow_version"` + WorkflowVersionID int64 `json:"workflow_version_id,omitempty"` + Stages []StagePlan `json:"stages"` +} + +// WorkflowInstance 项目工作流实例。 +type WorkflowInstance struct { + ID int64 `json:"id"` + ProjectID int64 `json:"project_id"` + WorkflowVersionID int64 `json:"workflow_version_id"` + InstanceNo int `json:"instance_no"` + Status string `json:"status"` + ConfigRevision int `json:"config_revision"` + PlanHash string `json:"plan_hash"` + ConfigSnapshot WorkflowPlan `json:"config_snapshot"` + ActivatedBy *int64 `json:"activated_by,omitempty"` + ActivatedAt *time.Time `json:"activated_at,omitempty"` + CreatedBy *int64 `json:"created_by,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ProjectSettings 项目设置聚合视图。 +type ProjectSettings struct { + ProjectID int64 `json:"project_id"` + LifecycleStatus string `json:"lifecycle_status"` + ActiveWorkflowKey string `json:"active_workflow_key"` + WorkflowSelectionVersion int `json:"workflow_selection_version"` + WorkflowPlanRevision int `json:"workflow_plan_revision"` + Plan *WorkflowPlan `json:"plan,omitempty"` + PlanningInstance *WorkflowInstance `json:"planning_instance,omitempty"` + LiveInstance *WorkflowInstance `json:"live_instance,omitempty"` + CanEditPlan bool `json:"can_edit_plan"` + CanActivate bool `json:"can_activate"` + ActiveStageKey string `json:"active_stage_key,omitempty"` + StageAdvanceReady bool `json:"stage_advance_ready,omitempty"` + PublishedWorkflows []PublishedOption `json:"published_workflows,omitempty"` +} + +// PublishedOption 可选已发布工作流。 +type PublishedOption struct { + DefinitionKey string `json:"definition_key"` + DefinitionName string `json:"definition_name"` + LatestVersion int `json:"latest_version"` + WorkflowVersion int64 `json:"workflow_version_id"` + ProductDomain string `json:"product_domain,omitempty"` +} + +// SavePlanInput 保存工作流规划。 +type SavePlanInput struct { + Plan WorkflowPlan `json:"plan"` + ExpectedPlanRevision int `json:"expected_plan_revision"` + ExpectedSelectionVersion int `json:"expected_selection_version"` +} + +// ActivationPreview 激活预览与确认令牌。 +type ActivationPreview struct { + ConfirmationToken string `json:"confirmation_token"` + PlanHash string `json:"plan_hash"` + ExpectedProjectVersion int `json:"expected_project_version"` + ExpectedPlanRevision int `json:"expected_plan_revision"` + Summary PreviewSummary `json:"summary"` + Validation workflowdef.ValidationResult `json:"validation"` + ExpiresAt time.Time `json:"expires_at"` +} + +// PreviewSummary 激活影响摘要。 +type PreviewSummary struct { + WorkflowName string `json:"workflow_name"` + WorkflowVersion int `json:"workflow_version"` + EnabledStages int `json:"enabled_stages"` + EnabledDocTasks int `json:"enabled_doc_tasks"` + AssignedDuties int `json:"assigned_duties"` + FirstStageKey string `json:"first_stage_key,omitempty"` + FirstStageName string `json:"first_stage_name,omitempty"` +} + +// ActivateInput 二次确认激活。 +type ActivateInput struct { + ConfirmationToken string `json:"confirmation_token"` + ExpectedProjectVersion int `json:"expected_project_version"` + ExpectedPlanRevision int `json:"expected_plan_revision"` +} + +// ActivateResult 激活结果。 +type ActivateResult struct { + ProjectID int64 `json:"project_id"` + LifecycleStatus string `json:"lifecycle_status"` + ActiveWorkflowKey string `json:"active_workflow_key"` + Instance WorkflowInstance `json:"instance"` +} + +const ( + StagePending = "pending" + StageActive = "active" + StageCompleted = "completed" + StageSkipped = "skipped" + + InstanceCompleted = "completed" +) + +// DocTaskInstance 文档任务运行实例。 +type DocTaskInstance struct { + ID int64 `json:"id"` + TaskKey string `json:"task_key"` + Enabled bool `json:"enabled"` + Status string `json:"status"` + Approved bool `json:"approved"` +} + +// StageInstance 阶段运行实例。 +type StageInstance struct { + ID int64 `json:"id"` + StageKey string `json:"stage_key"` + Name string `json:"name,omitempty"` + Position int `json:"position"` + Enabled bool `json:"enabled"` + Status string `json:"status"` + DocTasks []DocTaskInstance `json:"doc_tasks,omitempty"` +} + +// InstanceDetail 工作流实例详情(含阶段树)。 +type InstanceDetail struct { + Instance WorkflowInstance `json:"instance"` + ActiveStageKey string `json:"active_stage_key,omitempty"` + Stages []StageInstance `json:"stages"` +} + +// GateIssue 阶段门禁阻断项。 +type GateIssue struct { + Code string `json:"code"` + Message string `json:"message"` + TaskKey string `json:"task_key,omitempty"` +} + +// DocTaskReadiness 文档任务就绪状态。 +type DocTaskReadiness struct { + TaskKey string `json:"task_key"` + Required bool `json:"required"` + Enabled bool `json:"enabled"` + DocState string `json:"doc_state"` + Approved bool `json:"approved"` +} + +// StageReadiness 阶段推进就绪评估。 +type StageReadiness struct { + InstanceID int64 `json:"instance_id"` + StageKey string `json:"stage_key"` + StageName string `json:"stage_name"` + Ready bool `json:"ready"` + CanAdvance bool `json:"can_advance"` + BlockingIssues []GateIssue `json:"blocking_issues"` + DocTasks []DocTaskReadiness `json:"doc_tasks"` + ConfigRevision int `json:"config_revision"` +} + +// AdvanceStageInput 创建者确认推进。 +type AdvanceStageInput struct { + StageKey string `json:"stage_key"` + ExpectedConfigRevision int `json:"expected_config_revision"` +} + +// AdvanceStageResult 阶段推进结果。 +type AdvanceStageResult struct { + Instance WorkflowInstance `json:"instance"` + CompletedStage string `json:"completed_stage"` + CompletedStageInstanceID int64 `json:"completed_stage_instance_id,omitempty"` + CompletedReadinessRevision int `json:"completed_readiness_revision,omitempty"` + NextStageKey string `json:"next_stage_key,omitempty"` + InstanceDone bool `json:"instance_done"` +} diff --git a/internal/projectinstance/validator.go b/internal/projectinstance/validator.go new file mode 100644 index 00000000..24ebabc3 --- /dev/null +++ b/internal/projectinstance/validator.go @@ -0,0 +1,213 @@ +package projectinstance + +import ( + "fmt" + + "chat-agent-system/internal/workflowdef" +) + +func validatePlan(plan WorkflowPlan, version *workflowdef.Version) workflowdef.ValidationResult { + issues := make([]workflowdef.ValidationIssue, 0) + if version == nil { + return workflowdef.ValidationResult{ + Valid: false, + Issues: []workflowdef.ValidationIssue{{ + Code: "WORKFLOW_VERSION_NOT_FOUND", Message: "工作流版本不存在或未发布", + }}, + } + } + if version.Status != workflowdef.StatusPublished { + issues = append(issues, workflowdef.ValidationIssue{ + Code: "WORKFLOW_NOT_PUBLISHED", Message: "只能绑定已发布的工作流版本", + }) + } + if plan.WorkflowDefinitionKey != version.WorkflowKey { + issues = append(issues, workflowdef.ValidationIssue{ + Code: "WORKFLOW_KEY_MISMATCH", Field: "workflow_definition_key", + Message: fmt.Sprintf("规划 key %s 与版本 %s 不一致", plan.WorkflowDefinitionKey, version.WorkflowKey), + }) + } + if plan.WorkflowVersion != version.Version { + issues = append(issues, workflowdef.ValidationIssue{ + Code: "WORKFLOW_VERSION_MISMATCH", Field: "workflow_version", + Message: fmt.Sprintf("规划版本 %d 与已发布版本 %d 不一致", plan.WorkflowVersion, version.Version), + }) + } + + stageDefMap := make(map[string]workflowdef.StageDef, len(version.Definition.Stages)) + for _, st := range version.Definition.Stages { + stageDefMap[st.Key] = st + } + seenStage := map[string]bool{} + for _, st := range plan.Stages { + if seenStage[st.StageKey] { + issues = append(issues, workflowdef.ValidationIssue{ + Code: "DUPLICATE_STAGE", Field: st.StageKey, Message: "阶段 key 重复", + }) + continue + } + seenStage[st.StageKey] = true + def, ok := stageDefMap[st.StageKey] + if !ok { + issues = append(issues, workflowdef.ValidationIssue{ + Code: "UNKNOWN_STAGE", Field: st.StageKey, Message: "阶段不在工作流定义中", + }) + continue + } + if !def.Optional && !st.Enabled { + issues = append(issues, workflowdef.ValidationIssue{ + Code: "REQUIRED_STAGE_DISABLED", Field: st.StageKey, Message: "必需阶段不能关闭", + }) + } + taskDefMap := make(map[string]workflowdef.DocTaskDef, len(def.DocTasks)) + for _, dt := range def.DocTasks { + taskDefMap[dt.Key] = dt + } + seenTask := map[string]bool{} + for _, dt := range st.DocTasks { + if seenTask[dt.TaskKey] { + issues = append(issues, workflowdef.ValidationIssue{ + Code: "DUPLICATE_DOC_TASK", Field: dt.TaskKey, Message: "文档任务 key 重复", + }) + continue + } + seenTask[dt.TaskKey] = true + td, ok := taskDefMap[dt.TaskKey] + if !ok { + issues = append(issues, workflowdef.ValidationIssue{ + Code: "UNKNOWN_DOC_TASK", Field: dt.TaskKey, Message: "文档任务不在阶段定义中", + }) + continue + } + if (td.Required || td.Blocking) && !dt.Enabled { + issues = append(issues, workflowdef.ValidationIssue{ + Code: "REQUIRED_DOC_DISABLED", Field: dt.TaskKey, Message: "必需/阻断文档任务不能关闭", + }) + } + if dt.Enabled { + issues = append(issues, validateDuties(dt.Duties, dt.TaskKey)...) + } + } + } + + enabledStages := 0 + for _, st := range plan.Stages { + if st.Enabled { + enabledStages++ + } + } + if enabledStages == 0 { + issues = append(issues, workflowdef.ValidationIssue{ + Code: "NO_ENABLED_STAGE", Message: "至少启用一个阶段", + }) + } + + return workflowdef.ValidationResult{Valid: len(issues) == 0, Issues: issues} +} + +func validateDuties(d DocDuty, taskKey string) []workflowdef.ValidationIssue { + issues := make([]workflowdef.ValidationIssue, 0) + if d.AuthorUserID <= 0 { + issues = append(issues, workflowdef.ValidationIssue{ + Code: "DUTY_AUTHOR_REQUIRED", Field: taskKey + ".author", + Message: "启用的文档任务必须指定作者", + }) + } + if d.ReviewerUserID <= 0 { + issues = append(issues, workflowdef.ValidationIssue{ + Code: "DUTY_REVIEWER_REQUIRED", Field: taskKey + ".reviewer", + Message: "启用的文档任务必须指定审核人", + }) + } + if d.ApproverUserID <= 0 { + issues = append(issues, workflowdef.ValidationIssue{ + Code: "DUTY_APPROVER_REQUIRED", Field: taskKey + ".approver", + Message: "启用的文档任务必须指定批准人", + }) + } + ids := []int64{d.AuthorUserID, d.ReviewerUserID, d.ApproverUserID} + for i := 0; i < len(ids); i++ { + if ids[i] <= 0 { + continue + } + for j := i + 1; j < len(ids); j++ { + if ids[j] > 0 && ids[i] == ids[j] { + issues = append(issues, workflowdef.ValidationIssue{ + Code: "SEPARATION_OF_DUTY_VIOLATION", Field: taskKey, + Message: "作者、审核人、批准人必须分离", + }) + return issues + } + } + } + return issues +} + +func buildDefaultPlan(version *workflowdef.Version) WorkflowPlan { + plan := WorkflowPlan{ + WorkflowDefinitionKey: version.WorkflowKey, + WorkflowVersion: version.Version, + WorkflowVersionID: version.ID, + Stages: make([]StagePlan, 0, len(version.Definition.Stages)), + } + for _, st := range version.Definition.Stages { + sp := StagePlan{ + StageKey: st.Key, + Enabled: !st.Optional, + DocTasks: make([]DocTaskPlan, 0, len(st.DocTasks)), + } + for _, dt := range st.DocTasks { + sp.DocTasks = append(sp.DocTasks, DocTaskPlan{ + TaskKey: dt.Key, + Enabled: dt.DefaultEnabled || dt.Required || dt.Blocking, + }) + } + plan.Stages = append(plan.Stages, sp) + } + return plan +} + +func previewSummary(plan WorkflowPlan, version *workflowdef.Version) PreviewSummary { + sum := PreviewSummary{ + WorkflowVersion: plan.WorkflowVersion, + } + if version != nil { + sum.WorkflowName = version.WorkflowName + if sum.WorkflowName == "" { + sum.WorkflowName = version.WorkflowKey + } + } + for _, st := range plan.Stages { + if !st.Enabled { + continue + } + sum.EnabledStages++ + if sum.FirstStageKey == "" { + sum.FirstStageKey = st.StageKey + if version != nil { + for _, sd := range version.Definition.Stages { + if sd.Key == st.StageKey { + sum.FirstStageName = sd.Name + break + } + } + } + } + for _, dt := range st.DocTasks { + if !dt.Enabled { + continue + } + sum.EnabledDocTasks++ + if dt.Duties.AuthorUserID > 0 { + sum.AssignedDuties++ + } + if dt.Duties.ReviewerUserID > 0 { + sum.AssignedDuties++ + } + if dt.Duties.ApproverUserID > 0 { + sum.AssignedDuties++ + } + } + } + return sum +} diff --git a/internal/projectworkflow/errors.go b/internal/projectworkflow/errors.go index e0ed3ca8..009b7dba 100644 --- a/internal/projectworkflow/errors.go +++ b/internal/projectworkflow/errors.go @@ -23,4 +23,7 @@ var ( // ErrInvalidInput 输入参数不合法 ErrInvalidInput = errors.New("invalid parameter: required field missing or malformed") + + // ErrLegacySwitchBlocked 已启用实例化工作流治理,禁止旧式即时切换 + ErrLegacySwitchBlocked = errors.New("legacy workflow switch blocked: use project settings activation flow") ) diff --git a/internal/projectworkflow/service.go b/internal/projectworkflow/service.go index 1e49a9ee..35e65fb8 100644 --- a/internal/projectworkflow/service.go +++ b/internal/projectworkflow/service.go @@ -54,12 +54,11 @@ func (s *Service) CreateProject(ctx context.Context, user *auth.UserAccount, in code = generateProjectCode(name) } - initialWorkflow := in.InitialWorkflowKey - if initialWorkflow == "" { - initialWorkflow = "main" - } - if _, ok := s.catalog.Get(initialWorkflow); !ok { - return nil, fmt.Errorf("%w: 初始工作流 %s 不存在", ErrWorkflowNotFound, initialWorkflow) + initialWorkflow := strings.TrimSpace(in.InitialWorkflowKey) + if initialWorkflow != "" { + if _, ok := s.catalog.Get(initialWorkflow); !ok { + return nil, fmt.Errorf("%w: 初始工作流 %s 不存在", ErrWorkflowNotFound, initialWorkflow) + } } p := &Project{ @@ -73,8 +72,14 @@ func (s *Service) CreateProject(ctx context.Context, user *auth.UserAccount, in Environment: "dev", Status: "active", CreatorUserID: user.ID, - ActiveWorkflowKey: initialWorkflow, + LifecycleStatus: "planning", + ActiveWorkflowKey: "", WorkflowSelectionVersion: 1, + WorkflowPlanRevision: 0, + } + if initialWorkflow != "" { + p.LifecycleStatus = "active" + p.ActiveWorkflowKey = initialWorkflow } created, err := s.store.CreateProject(ctx, p) @@ -260,14 +265,19 @@ func (s *Service) CheckAndPropagateStale( return s.store.MarkDownstreamStale(ctx, upstreamDocID, reason) } -// 辅助方法:生成项目短代码 +// 辅助方法:生成项目短代码(按 rune 截断,避免切断 UTF-8 多字节字符导致 PG 插入失败) func generateProjectCode(name string) string { b := make([]byte, 4) _, _ = rand.Read(b) hash := hex.EncodeToString(b) - cleanName := strings.ToLower(strings.ReplaceAll(name, " ", "-")) - if len(cleanName) > 12 { - cleanName = cleanName[:12] + cleanName := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(name), " ", "-")) + runes := []rune(cleanName) + if len(runes) > 12 { + runes = runes[:12] + cleanName = string(runes) + } + if cleanName == "" { + cleanName = "proj" } return fmt.Sprintf("%s-%s", cleanName, hash) } diff --git a/internal/projectworkflow/service_test.go b/internal/projectworkflow/service_test.go index 6b9ea821..9f821bf2 100644 --- a/internal/projectworkflow/service_test.go +++ b/internal/projectworkflow/service_test.go @@ -4,10 +4,22 @@ import ( "context" "errors" "testing" + "unicode/utf8" "chat-agent-system/internal/auth" ) +func TestGenerateProjectCode_ValidUTF8ForChineseName(t *testing.T) { + longChinese := "数字化高频超声手术吸引系统治理链测试项目名称" + code := generateProjectCode(longChinese) + if !utf8.ValidString(code) { + t.Fatalf("project code must be valid UTF-8, got %q bytes=% x", code, []byte(code)) + } + if code == "" { + t.Fatal("expected non-empty code") + } +} + func TestProjectWorkflowService_CreateAndCreatorACL(t *testing.T) { ctx := context.Background() store := NewStore(nil) @@ -36,8 +48,11 @@ func TestProjectWorkflowService_CreateAndCreatorACL(t *testing.T) { if proj.CreatorUserID != creator.ID { t.Errorf("expected CreatorUserID %d, got %d", creator.ID, proj.CreatorUserID) } - if proj.ActiveWorkflowKey != "main" { - t.Errorf("expected default active workflow 'main', got '%s'", proj.ActiveWorkflowKey) + if proj.LifecycleStatus != "planning" { + t.Errorf("expected lifecycle 'planning', got '%s'", proj.LifecycleStatus) + } + if proj.ActiveWorkflowKey != "" { + t.Errorf("expected empty active workflow in planning mode, got '%s'", proj.ActiveWorkflowKey) } if proj.WorkflowSelectionVersion != 1 { t.Errorf("expected initial version 1, got %d", proj.WorkflowSelectionVersion) diff --git a/internal/projectworkflow/store.go b/internal/projectworkflow/store.go index a7d970f1..59bc2278 100644 --- a/internal/projectworkflow/store.go +++ b/internal/projectworkflow/store.go @@ -62,8 +62,8 @@ func (s *pgStore) CreateProject(ctx context.Context, p *Project) (*Project, erro if p.CurrentStage == "" { p.CurrentStage = "V0.1_triage" } - if p.ActiveWorkflowKey == "" { - p.ActiveWorkflowKey = "main" + if p.LifecycleStatus == "" { + p.LifecycleStatus = "planning" } if p.WorkflowSelectionVersion <= 0 { p.WorkflowSelectionVersion = 1 @@ -72,16 +72,18 @@ func (s *pgStore) CreateProject(ctx context.Context, p *Project) (*Project, erro query := ` INSERT INTO projects ( code, name, customer_name, device_type, intended_use, target_market, - current_stage, environment, status, creator_user_id, active_workflow_key, - workflow_selection_version, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + current_stage, environment, status, creator_user_id, lifecycle_status, + active_workflow_key, workflow_selection_version, workflow_plan_revision, + created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING id ` err := s.pool.QueryRow( ctx, query, p.Code, p.Name, p.CustomerName, p.DeviceType, p.IntendedUse, p.TargetMarket, - p.CurrentStage, p.Environment, p.Status, p.CreatorUserID, p.ActiveWorkflowKey, - p.WorkflowSelectionVersion, p.CreatedAt, p.UpdatedAt, + p.CurrentStage, p.Environment, p.Status, p.CreatorUserID, p.LifecycleStatus, + p.ActiveWorkflowKey, p.WorkflowSelectionVersion, p.WorkflowPlanRevision, + p.CreatedAt, p.UpdatedAt, ).Scan(&p.ID) if err != nil { return nil, fmt.Errorf("insert project failed: %w", err) @@ -94,7 +96,8 @@ func (s *pgStore) GetProjectByID(ctx context.Context, id int64) (*Project, error query := ` SELECT id, code, name, customer_name, device_type, intended_use, target_market, current_stage, environment, status, COALESCE(creator_user_id, 1), - COALESCE(active_workflow_key, 'main'), COALESCE(workflow_selection_version, 1), + COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''), + COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0), active_workflow_changed_at, active_workflow_changed_by, created_at, updated_at FROM projects WHERE id = $1 @@ -103,7 +106,7 @@ func (s *pgStore) GetProjectByID(ctx context.Context, id int64) (*Project, error err := s.pool.QueryRow(ctx, query, id).Scan( &p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket, &p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID, - &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, + &p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision, &p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt, ) if errors.Is(err, pgx.ErrNoRows) { @@ -119,7 +122,8 @@ func (s *pgStore) GetProjectByCode(ctx context.Context, code string) (*Project, query := ` SELECT id, code, name, customer_name, device_type, intended_use, target_market, current_stage, environment, status, COALESCE(creator_user_id, 1), - COALESCE(active_workflow_key, 'main'), COALESCE(workflow_selection_version, 1), + COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''), + COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0), active_workflow_changed_at, active_workflow_changed_by, created_at, updated_at FROM projects WHERE code = $1 @@ -128,7 +132,7 @@ func (s *pgStore) GetProjectByCode(ctx context.Context, code string) (*Project, err := s.pool.QueryRow(ctx, query, code).Scan( &p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket, &p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID, - &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, + &p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision, &p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt, ) if errors.Is(err, pgx.ErrNoRows) { @@ -145,7 +149,8 @@ func (s *pgStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Pro query := ` SELECT DISTINCT p.id, p.code, p.name, p.customer_name, p.device_type, p.intended_use, p.target_market, p.current_stage, p.environment, p.status, COALESCE(p.creator_user_id, 1), - COALESCE(p.active_workflow_key, 'main'), COALESCE(p.workflow_selection_version, 1), + COALESCE(p.lifecycle_status, 'planning'), COALESCE(p.active_workflow_key, ''), + COALESCE(p.workflow_selection_version, 1), COALESCE(p.workflow_plan_revision, 0), p.active_workflow_changed_at, p.active_workflow_changed_by, p.created_at, p.updated_at FROM projects p LEFT JOIN chat_sessions cs ON cs.project_id = p.id AND cs.user_id = $1 @@ -164,7 +169,7 @@ func (s *pgStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Pro if err := rows.Scan( &p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket, &p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID, - &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, + &p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision, &p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt, ); err != nil { return nil, err @@ -604,8 +609,8 @@ func (m *memStore) CreateProject(ctx context.Context, p *Project) (*Project, err if p.Status == "" { p.Status = "active" } - if p.ActiveWorkflowKey == "" { - p.ActiveWorkflowKey = "main" + if p.LifecycleStatus == "" { + p.LifecycleStatus = "planning" } if p.WorkflowSelectionVersion <= 0 { p.WorkflowSelectionVersion = 1 diff --git a/internal/projectworkflow/types.go b/internal/projectworkflow/types.go index 0216601a..7cbc904b 100644 --- a/internal/projectworkflow/types.go +++ b/internal/projectworkflow/types.go @@ -17,8 +17,10 @@ type Project struct { Environment string `json:"environment"` Status string `json:"status"` CreatorUserID int64 `json:"creator_user_id"` + LifecycleStatus string `json:"lifecycle_status"` ActiveWorkflowKey string `json:"active_workflow_key"` WorkflowSelectionVersion int `json:"workflow_selection_version"` + WorkflowPlanRevision int `json:"workflow_plan_revision"` ActiveWorkflowChangedAt *time.Time `json:"active_workflow_changed_at,omitempty"` ActiveWorkflowChangedBy *int64 `json:"active_workflow_changed_by,omitempty"` CreatedAt time.Time `json:"created_at"` diff --git a/internal/server/admin_template_handlers.go b/internal/server/admin_template_handlers.go new file mode 100644 index 00000000..c4030c66 --- /dev/null +++ b/internal/server/admin_template_handlers.go @@ -0,0 +1,91 @@ +package server + +import ( + "errors" + "io" + "net/http" + "strings" + + "chat-agent-system/internal/workflowdef" +) + +func (s *Server) handleAdminTemplates(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireWorkflowAdmin(w, r) + if !ok { + return + } + + switch r.Method { + case http.MethodGet: + assets, err := s.wfDefSvc.ListTemplates(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSONResponse(w, http.StatusOK, map[string]any{"templates": assets}) + case http.MethodPost: + if err := r.ParseMultipartForm(defaultUploadBodyLimit); err != nil { + if isRequestBodyTooLarge(err) { + http.Error(w, "upload too large", http.StatusRequestEntityTooLarge) + return + } + http.Error(w, "invalid multipart", http.StatusBadRequest) + return + } + assetKey := strings.TrimSpace(r.FormValue("asset_key")) + name := strings.TrimSpace(r.FormValue("name")) + if assetKey == "" { + assetKey = strings.TrimSpace(r.FormValue("key")) + } + if name == "" { + name = assetKey + } + file, header, err := r.FormFile("file") + if err != nil || assetKey == "" { + http.Error(w, "asset_key and file required", http.StatusBadRequest) + return + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, 32<<20)) + if err != nil { + http.Error(w, "read file failed", http.StatusBadRequest) + return + } + tv, err := s.wfDefSvc.UploadTemplate(r.Context(), assetKey, name, data, header.Filename, user.ID) + if err != nil { + status := http.StatusBadRequest + switch { + case errors.Is(err, workflowdef.ErrUploadRejected): + status = http.StatusUnsupportedMediaType + case errors.Is(err, workflowdef.ErrNotAvailable): + status = http.StatusServiceUnavailable + } + http.Error(w, err.Error(), status) + return + } + writeJSONResponse(w, http.StatusCreated, tv) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *Server) handleAdminTemplateVersions(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireWorkflowAdmin(w, r); !ok { + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + assetKey := strings.TrimSpace(r.PathValue("key")) + if assetKey == "" { + http.Error(w, "invalid asset key", http.StatusBadRequest) + return + } + versions, err := s.wfDefSvc.ListTemplateVersions(r.Context(), assetKey) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSONResponse(w, http.StatusOK, map[string]any{"versions": versions}) +} diff --git a/internal/server/admin_workflow_handlers.go b/internal/server/admin_workflow_handlers.go new file mode 100644 index 00000000..4c9e6e22 --- /dev/null +++ b/internal/server/admin_workflow_handlers.go @@ -0,0 +1,189 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "chat-agent-system/internal/auth" + "chat-agent-system/internal/workflowdef" +) + +func (s *Server) requireWorkflowAdmin(w http.ResponseWriter, r *http.Request) (*auth.UserAccount, bool) { + user, ok := s.requireSystemAdmin(w, r) + if !ok { + return nil, false + } + if s.wfDefSvc == nil || !s.wfDefSvc.Available() { + http.Error(w, "工作流定义服务需要数据库 IAM", http.StatusServiceUnavailable) + return nil, false + } + return user, true +} + +func (s *Server) handleAdminWorkflows(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireWorkflowAdmin(w, r) + if !ok { + return + } + switch r.Method { + case http.MethodGet: + defs, err := s.wfDefSvc.ListDefinitions(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSONResponse(w, http.StatusOK, map[string]any{"workflows": defs}) + case http.MethodPost: + var req workflowdef.CreateDefinitionInput + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "无效的 JSON", http.StatusBadRequest) + return + } + def, ver, err := s.wfDefSvc.CreateDefinition(r.Context(), req, user.ID) + if err != nil { + status := http.StatusBadRequest + if errors.Is(err, workflowdef.ErrValidationFailed) { + status = http.StatusUnprocessableEntity + } + http.Error(w, err.Error(), status) + return + } + writeJSONResponse(w, http.StatusCreated, map[string]any{"definition": def, "version": ver}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *Server) handleAdminWorkflowByID(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireWorkflowAdmin(w, r); !ok { + return + } + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id <= 0 { + http.Error(w, "invalid workflow id", http.StatusBadRequest) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + def, err := s.wfDefSvc.GetDefinition(r.Context(), id) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + versions, err := s.wfDefSvc.ListVersions(r.Context(), id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSONResponse(w, http.StatusOK, map[string]any{"definition": def, "versions": versions}) +} + +func (s *Server) handleAdminWorkflowVersion(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireWorkflowAdmin(w, r) + if !ok { + return + } + workflowID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || workflowID <= 0 { + http.Error(w, "invalid workflow id", http.StatusBadRequest) + return + } + version, err := strconv.Atoi(r.PathValue("version")) + if err != nil || version <= 0 { + http.Error(w, "invalid version", http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodPatch, http.MethodPut: + var body struct { + Definition workflowdef.VersionDefinition `json:"definition"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + updated, err := s.wfDefSvc.UpdateDraft(r.Context(), workflowID, version, body.Definition) + if err != nil { + status := http.StatusBadRequest + if errors.Is(err, workflowdef.ErrNotDraft) { + status = http.StatusConflict + } + http.Error(w, err.Error(), status) + return + } + writeJSONResponse(w, http.StatusOK, updated) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + _ = user +} + +func (s *Server) handleAdminWorkflowValidate(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireWorkflowAdmin(w, r); !ok { + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + workflowID, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + version, _ := strconv.Atoi(r.PathValue("version")) + res, err := s.wfDefSvc.ValidateVersion(r.Context(), workflowID, version) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + writeJSONResponse(w, http.StatusOK, res) +} + +func (s *Server) handleAdminWorkflowPublish(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireWorkflowAdmin(w, r) + if !ok { + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + workflowID, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + version, _ := strconv.Atoi(r.PathValue("version")) + ver, err := s.wfDefSvc.PublishVersion(r.Context(), workflowID, version, user.ID) + if err != nil { + status := http.StatusBadRequest + if errors.Is(err, workflowdef.ErrValidationFailed) { + status = http.StatusUnprocessableEntity + } + http.Error(w, err.Error(), status) + return + } + if dbCat, ok := s.pwSvc.Catalog().(*workflowdef.DBCatalog); ok { + _ = dbCat.Refresh(r.Context()) + } + writeJSONResponse(w, http.StatusOK, ver) +} + +func (s *Server) handleAdminWorkflowRetire(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireWorkflowAdmin(w, r); !ok { + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + workflowID, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + version, _ := strconv.Atoi(r.PathValue("version")) + ver, err := s.wfDefSvc.RetireVersion(r.Context(), workflowID, version) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if dbCat, ok := s.pwSvc.Catalog().(*workflowdef.DBCatalog); ok { + _ = dbCat.Refresh(r.Context()) + } + writeJSONResponse(w, http.StatusOK, ver) +} diff --git a/internal/server/asset_handlers.go b/internal/server/asset_handlers.go index 16631568..680745c2 100644 --- a/internal/server/asset_handlers.go +++ b/internal/server/asset_handlers.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strconv" "strings" + + "chat-agent-system/internal/upload" ) type projectAssetResponse struct { @@ -28,7 +30,11 @@ func (s *Server) handleUploadProjectAsset(w http.ResponseWriter, r *http.Request return } userID := user.ID - if err := r.ParseMultipartForm(64 << 20); err != nil { + if err := r.ParseMultipartForm(defaultUploadBodyLimit); err != nil { + if isRequestBodyTooLarge(err) { + http.Error(w, "upload too large", http.StatusRequestEntityTooLarge) + return + } http.Error(w, "invalid multipart form: "+err.Error(), http.StatusBadRequest) return } @@ -53,9 +59,11 @@ func (s *Server) handleUploadProjectAsset(w http.ResponseWriter, r *http.Request if filename == "" { filename = "upload.bin" } - mimeType := header.Header.Get("Content-Type") - if mimeType == "" { - mimeType = mimeFromExt(filename) + declaredMIME := header.Header.Get("Content-Type") + mimeType, verr := upload.ValidateProjectMaterial(filename, declaredMIME, data) + if verr != nil { + http.Error(w, "upload rejected: "+verr.Error(), http.StatusUnsupportedMediaType) + return } cas := s.svc.CAS() diff --git a/internal/server/asset_handlers_test.go b/internal/server/asset_handlers_test.go index 7358223b..bd9355e4 100644 --- a/internal/server/asset_handlers_test.go +++ b/internal/server/asset_handlers_test.go @@ -6,6 +6,7 @@ import ( "mime/multipart" "net/http" "net/http/httptest" + "strings" "testing" "chat-agent-system/internal/bizdev" @@ -44,3 +45,51 @@ func TestUploadProjectAsset(t *testing.T) { t.Fatalf("unexpected response: %+v", out) } } + +func TestUploadProjectAsset_RejectsHTML(t *testing.T) { + cfg := &config.Config{Env: "test", DataDir: t.TempDir()} + srv := NewServer(cfg, bizdev.MustNewService(t, cfg)) + ts := httptest.NewServer(srv.Routes()) + defer ts.Close() + + var body bytes.Buffer + w := multipart.NewWriter(&body) + part, _ := w.CreateFormFile("file", "evil.txt") + _, _ = part.Write([]byte("
x")) + _ = w.Close() + + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/projects/7/assets", &body) + req.Header.Set("Content-Type", w.FormDataContentType()) + withBearerAuth(req, loginTestAdmin(t, srv)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnsupportedMediaType { + t.Fatalf("expected 415, got %d", resp.StatusCode) + } +} + +func TestUploadProjectAsset_RejectsOversizedFilename(t *testing.T) { + cfg := &config.Config{Env: "test", DataDir: t.TempDir()} + srv := NewServer(cfg, bizdev.MustNewService(t, cfg)) + ts := httptest.NewServer(srv.Routes()) + defer ts.Close() + + longName := strings.Repeat("a", 201) + ".txt" + var body bytes.Buffer + w := multipart.NewWriter(&body) + part, _ := w.CreateFormFile("file", longName) + _, _ = part.Write([]byte("ok")) + _ = w.Close() + + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/projects/7/assets", &body) + req.Header.Set("Content-Type", w.FormDataContentType()) + withBearerAuth(req, loginTestAdmin(t, srv)) + resp, _ := http.DefaultClient.Do(req) + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnsupportedMediaType { + t.Fatalf("expected 415 for long filename, got %d", resp.StatusCode) + } +} diff --git a/internal/server/auth_middleware.go b/internal/server/auth_middleware.go index f2f3abbf..1eb44384 100644 --- a/internal/server/auth_middleware.go +++ b/internal/server/auth_middleware.go @@ -17,7 +17,8 @@ func (s *Server) allowLegacyTestPrincipal() bool { } func isPublicRoute(method, path string) bool { - if path == "/" || path == "/health" || path == "/api/v1/health" { + if path == "/" || path == "/health" || path == "/api/v1/health" || + path == "/ready" || path == "/api/v1/ready" { return true } if method == http.MethodPost && path == "/api/v1/auth/login" { diff --git a/internal/server/bizdev_stage_gate.go b/internal/server/bizdev_stage_gate.go new file mode 100644 index 00000000..f22bcf7a --- /dev/null +++ b/internal/server/bizdev_stage_gate.go @@ -0,0 +1,123 @@ +package server + +import ( + "context" + "fmt" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/ctxmgr" + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/squad" +) + +type bizdevStageGate struct { + instSvc *projectinstance.Service + pwSvc interface { + IsProjectCreator(ctx context.Context, projectID int64, userID int64) (bool, error) + } +} + +func newBizdevStageGate(instSvc *projectinstance.Service, pwSvc interface { + IsProjectCreator(ctx context.Context, projectID int64, userID int64) (bool, error) +}) bizdev.StageAccessGate { + if instSvc == nil || !instSvc.Available() { + return nil + } + return &bizdevStageGate{instSvc: instSvc, pwSvc: pwSvc} +} + +func (g *bizdevStageGate) access(ctx context.Context, projectID, userID int64) (*projectinstance.StageAccess, error) { + if g == nil || g.instSvc == nil || !g.instSvc.Available() || projectID <= 0 || userID <= 0 { + return &projectinstance.StageAccess{}, nil + } + isCreator := false + if g.pwSvc != nil { + isCreator, _ = g.pwSvc.IsProjectCreator(ctx, projectID, userID) + } + return g.instSvc.ResolveStageAccess(ctx, projectID, userID, isCreator) +} + +func (g *bizdevStageGate) AllowTaskExecution(ctx context.Context, projectID, userID int64, taskKey string) error { + access, err := g.access(ctx, projectID, userID) + if err != nil { + return err + } + if access == nil || !access.Restricted { + return nil + } + if taskKey != "" && access.TaskKeyAllowed(taskKey) { + return nil + } + if access.StageKeyAllowed(access.ActiveStageKey) { + return nil + } + return fmt.Errorf("%w: task %q", bizdev.ErrStageAccessDenied, taskKey) +} + +func (g *bizdevStageGate) AllowProjectContextRead(ctx context.Context, projectID, userID int64) error { + access, err := g.access(ctx, projectID, userID) + if err != nil { + return err + } + if access == nil || !access.Restricted { + return nil + } + if len(access.AllowedStageKeys) == 0 { + return bizdev.ErrStageAccessDenied + } + return nil +} + +func (g *bizdevStageGate) FilterProjectContext(ctx context.Context, projectID, userID int64, pCtx *ctxmgr.ProjectCollectiveContext) *ctxmgr.ProjectCollectiveContext { + if pCtx == nil { + return pCtx + } + access, err := g.access(ctx, projectID, userID) + if err != nil || access == nil || !access.Restricted { + return pCtx + } + out := *pCtx + out.AllActivities = filterActivitiesByStage(access, pCtx.AllActivities) + out.MilestoneDecisions = filterActivitiesByStage(access, pCtx.MilestoneDecisions) + return &out +} + +func filterActivitiesByStage(access *projectinstance.StageAccess, items []ctxmgr.ActivityItem) []ctxmgr.ActivityItem { + if access == nil || !access.Restricted { + return items + } + out := make([]ctxmgr.ActivityItem, 0, len(items)) + for _, it := range items { + stageKey := "" + if it.Detail != nil { + if sk, ok := it.Detail["stage_key"].(string); ok { + stageKey = sk + } + } + if stageKey == "" || access.StageKeyAllowed(stageKey) { + out = append(out, it) + } + } + return out +} + +func (g *bizdevStageGate) FilterPriorDocs(ctx context.Context, projectID, userID int64, docs []squad.PriorDocItem) []squad.PriorDocItem { + if len(docs) == 0 { + return docs + } + access, err := g.access(ctx, projectID, userID) + if err != nil || access == nil || !access.Restricted { + return docs + } + out := make([]squad.PriorDocItem, 0, len(docs)) + for _, d := range docs { + if d.DocKey != "" && access.TaskKeyAllowed(d.DocKey) { + out = append(out, d) + continue + } + if d.Stage != "" && access.StageKeyAllowed(d.Stage) { + out = append(out, d) + } + } + return out +} diff --git a/internal/server/body_limit_middleware.go b/internal/server/body_limit_middleware.go new file mode 100644 index 00000000..971e46f9 --- /dev/null +++ b/internal/server/body_limit_middleware.go @@ -0,0 +1,40 @@ +package server + +import ( + "net/http" + "strings" +) + +const ( + defaultJSONBodyLimit = 4 << 20 // 4 MiB + defaultUploadBodyLimit = 32 << 20 // 32 MiB +) + +func (s *Server) bodyLimitMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Body == nil || r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions { + next.ServeHTTP(w, r) + return + } + limit := int64(defaultJSONBodyLimit) + if isUploadPath(r.URL.Path) { + limit = defaultUploadBodyLimit + } + r.Body = http.MaxBytesReader(w, r.Body, limit) + next.ServeHTTP(w, r) + }) +} + +func isUploadPath(path string) bool { + if strings.HasSuffix(path, "/assets") { + return true + } + return strings.Contains(path, "/admin/templates") +} + +func isRequestBodyTooLarge(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), "http: request body too large") +} diff --git a/internal/server/body_limit_middleware_test.go b/internal/server/body_limit_middleware_test.go new file mode 100644 index 00000000..6e711411 --- /dev/null +++ b/internal/server/body_limit_middleware_test.go @@ -0,0 +1,29 @@ +package server + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestBodyLimitMiddleware_RejectsOversizedBody(t *testing.T) { + srv := &Server{} + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, err := io.ReadAll(r.Body); isRequestBodyTooLarge(err) { + w.WriteHeader(http.StatusRequestEntityTooLarge) + return + } + w.WriteHeader(http.StatusOK) + }) + handler := srv.bodyLimitMiddleware(inner) + + body := bytes.Repeat([]byte("x"), int(defaultJSONBodyLimit)+1024) + req := httptest.NewRequest(http.MethodPost, "/api/v1/chat", bytes.NewReader(body)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("expected 413, got %d", rec.Code) + } +} diff --git a/internal/server/chat_search_api.go b/internal/server/chat_search_api.go index 588f0214..ef89bf36 100644 --- a/internal/server/chat_search_api.go +++ b/internal/server/chat_search_api.go @@ -17,6 +17,11 @@ func (s *Server) handleChatSearch(w http.ResponseWriter, r *http.Request) { return } + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return + } + if s.svc == nil || s.svc.ContextManager() == nil { http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) return @@ -46,16 +51,9 @@ func (s *Server) handleChatSearch(w http.ResponseWriter, r *http.Request) { } } - var userID int64 - if uStr := r.URL.Query().Get("user_id"); uStr != "" { - if val, err := strconv.ParseInt(uStr, 10, 64); err == nil { - userID = val - } - } - searchQuery := ctxmgr.ChatSearchQuery{ SessionID: sessionID, - UserID: userID, + UserID: user.ID, Query: query, Mode: mode, Category: category, @@ -70,6 +68,10 @@ func (s *Server) handleChatSearch(w http.ResponseWriter, r *http.Request) { return } + if projectID, pidErr := s.svc.ContextManager().GetSessionProjectID(r.Context(), sessionID); pidErr == nil && projectID > 0 { + hits = s.filterChatSearchHitsByStageAccess(r.Context(), projectID, user.ID, hits) + } + if hits == nil { hits = []ctxmgr.ChatSearchHit{} } diff --git a/internal/server/chat_window_api.go b/internal/server/chat_window_api.go index 31c69095..62593ac1 100644 --- a/internal/server/chat_window_api.go +++ b/internal/server/chat_window_api.go @@ -17,6 +17,11 @@ func (s *Server) handleGetChatWindow(w http.ResponseWriter, r *http.Request) { return } + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return + } + sessionID := r.URL.Query().Get("session_id") if sessionID == "" { http.Error(w, "missing session_id query param", http.StatusBadRequest) @@ -40,6 +45,10 @@ func (s *Server) handleGetChatWindow(w http.ResponseWriter, r *http.Request) { return } + if projectID, pidErr := s.svc.ContextManager().GetSessionProjectID(r.Context(), sessionID); pidErr == nil && projectID > 0 { + s.applyChatWindowStageFilter(r.Context(), projectID, user.ID, res) + } + writeJSONResponse(w, http.StatusOK, res) } @@ -51,6 +60,11 @@ func (s *Server) handleGetChatDayMessages(w http.ResponseWriter, r *http.Request return } + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return + } + sessionID := r.URL.Query().Get("session_id") if sessionID == "" { http.Error(w, "missing session_id query param", http.StatusBadRequest) @@ -87,6 +101,10 @@ func (s *Server) handleGetChatDayMessages(w http.ResponseWriter, r *http.Request return } + if projectID, pidErr := s.svc.ContextManager().GetSessionProjectID(r.Context(), sessionID); pidErr == nil && projectID > 0 { + res.Messages = s.filterChatMessagesByStageAccess(r.Context(), projectID, user.ID, res.Messages) + } + writeJSONResponse(w, http.StatusOK, res) } diff --git a/internal/server/chat_window_api_test.go b/internal/server/chat_window_api_test.go index a3e8be51..0917f413 100644 --- a/internal/server/chat_window_api_test.go +++ b/internal/server/chat_window_api_test.go @@ -167,6 +167,8 @@ func TestServer_ChatWindow_ImportGuard(t *testing.T) { handler := srv.Routes() reqImport := httptest.NewRequest("POST", "/api/v1/chat/messages/import", bytes.NewReader([]byte("[]"))) + reqImport.Header.Set("Content-Type", "application/json") + withBearerAuth(reqImport, loginTestAdmin(t, handler, cfg)) rrImport := httptest.NewRecorder() handler.ServeHTTP(rrImport, reqImport) diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 7396b95a..e0db5232 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -1,6 +1,7 @@ package server import ( + "context" "encoding/json" "errors" "net/http" @@ -14,6 +15,7 @@ import ( "chat-agent-system/internal/docgov" "chat-agent-system/internal/projectworkflow" "chat-agent-system/internal/squad" + "chat-agent-system/internal/taskrun" ) // HealthResponse 健康检查响应 @@ -25,6 +27,8 @@ type HealthResponse struct { SharedKBPort int `json:"shared_kb_port"` DatabaseReady bool `json:"database_ready"` SchemaVersion int64 `json:"schema_version,omitempty"` + SchemaReady bool `json:"schema_ready,omitempty"` + TaskStoreReady bool `json:"task_store_ready,omitempty"` } // ChatRequest 对话请求 @@ -74,25 +78,47 @@ type TaskRunRequestDTO struct { } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + resp, code := s.buildHealthResponse(false) + writeJSONResponse(w, code, resp) +} + +func (s *Server) handleReadiness(w http.ResponseWriter, r *http.Request) { + resp, code := s.buildHealthResponse(true) + writeJSONResponse(w, code, resp) +} + +func (s *Server) buildHealthResponse(strict bool) (HealthResponse, int) { dbReady := s.svc != nil && s.svc.DatabaseReady() + schemaVersion := int64(0) + if s.svc != nil { + schemaVersion = s.svc.SchemaVersion() + } + schemaReady := schemaVersion > 0 || (s.cfg != nil && s.cfg.AllowInMemory) + taskStoreReady := s.svc != nil && taskrun.TablesReady(context.Background(), s.svc.Pool()) + status := "ok" code := http.StatusOK - if s.cfg != nil && s.cfg.ContextDbHost != "" && !s.cfg.AllowInMemory && !dbReady { - status = "degraded" - code = http.StatusServiceUnavailable + if s.cfg != nil && s.cfg.ContextDbHost != "" && !s.cfg.AllowInMemory { + if !dbReady { + status = "degraded" + code = http.StatusServiceUnavailable + } else if strict && (!schemaReady || !taskStoreReady) { + status = "not_ready" + code = http.StatusServiceUnavailable + } } - resp := HealthResponse{ - Status: status, - Env: s.cfg.Env, - AgentBackend: s.cfg.AgentBackend, - Version: "v1.0", - SharedKBPort: s.cfg.KbDbPort, - DatabaseReady: dbReady, - } - if s.svc != nil { - resp.SchemaVersion = s.svc.SchemaVersion() - } - writeJSONResponse(w, code, resp) + + return HealthResponse{ + Status: status, + Env: s.cfg.Env, + AgentBackend: s.cfg.AgentBackend, + Version: "v1.0", + SharedKBPort: s.cfg.KbDbPort, + DatabaseReady: dbReady, + SchemaVersion: schemaVersion, + SchemaReady: schemaReady, + TaskStoreReady: taskStoreReady, + }, code } func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) { @@ -126,6 +152,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { http.Error(w, "message cannot be empty", http.StatusBadRequest) return } + req.WorkDir = "" sess := s.getOrCreateSession( req.SessionID, @@ -142,6 +169,11 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { if req.ProjectID > 0 { sess.ProjectID = req.ProjectID } + s.enrichSessionWorkflowStage(r.Context(), sess, user.ID) + if sess.ProjectID > 0 && !s.allowActiveStageChatAccess(r.Context(), sess.ProjectID, user.ID) { + s.writeWorkflowAccessDenied(w) + return + } resp, err := sess.SendMessage(r.Context(), req.Message) if err != nil { @@ -414,6 +446,11 @@ func (s *Server) handleUpdateUserPreference(w http.ResponseWriter, r *http.Reque // handleGetProjectContext 获取项目级多方协同事实黑板与里程碑决策流 func (s *Server) handleGetProjectContext(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return + } + projectIDStr := r.URL.Query().Get("project_id") projectID, _ := strconv.ParseInt(projectIDStr, 10, 64) if projectID <= 0 { @@ -421,6 +458,11 @@ func (s *Server) handleGetProjectContext(w http.ResponseWriter, r *http.Request) return } + if !s.allowAnyStageAccess(r.Context(), projectID, user.ID) { + s.writeWorkflowAccessDenied(w) + return + } + if s.svc == nil || s.svc.ContextManager() == nil { http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) return @@ -850,22 +892,36 @@ func (s *Server) handleGetChatMessages(w http.ResponseWriter, r *http.Request) { offset = 0 } + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return + } + msgs, err := s.svc.ContextManager().GetChatHistory(r.Context(), sessionID, limit, offset) if err != nil { http.Error(w, "get chat messages failed: "+err.Error(), http.StatusInternalServerError) return } + if projectID, pidErr := s.svc.ContextManager().GetSessionProjectID(r.Context(), sessionID); pidErr == nil && projectID > 0 { + msgs = s.filterChatMessagesByStageAccess(r.Context(), projectID, user.ID, msgs) + } + writeJSONResponse(w, http.StatusOK, msgs) } // handleRecommendCases 基于语义向量检索往期相似标杆案例 func (s *Server) handleRecommendCases(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return + } if s.svc == nil || s.svc.ContextManager() == nil { http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) return } + projectID, _ := strconv.ParseInt(r.URL.Query().Get("project_id"), 10, 64) query := r.URL.Query().Get("query") if query == "" { http.Error(w, "missing query param", http.StatusBadRequest) @@ -898,6 +954,16 @@ func (s *Server) handleRecommendCases(w http.ResponseWriter, r *http.Request) { return } + if projectID > 0 && len(cases) > 0 { + filtered := make([]ctxmgr.CaseRecommendationItem, 0, len(cases)) + for _, c := range cases { + if s.allowWorkflowDocTaskAccess(r.Context(), projectID, user.ID, c.TaskKey) { + filtered = append(filtered, c) + } + } + cases = filtered + } + writeJSONResponse(w, http.StatusOK, cases) } diff --git a/internal/server/notification_handlers.go b/internal/server/notification_handlers.go new file mode 100644 index 00000000..157210c4 --- /dev/null +++ b/internal/server/notification_handlers.go @@ -0,0 +1,35 @@ +package server + +import ( + "net/http" + "strconv" +) + +func (s *Server) handleListProjectNotifications(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireAuthenticatedUser(w, r) + if !ok { + return + } + if s.notifySvc == nil || !s.notifySvc.Available() { + http.Error(w, "notification service unavailable", http.StatusServiceUnavailable) + return + } + + projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || projectID <= 0 { + http.Error(w, "invalid project id", http.StatusBadRequest) + return + } + + sinceID, _ := strconv.ParseInt(r.URL.Query().Get("since_id"), 10, 64) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + + items, err := s.notifySvc.ListProjectNotifications(r.Context(), user.ID, projectID, sinceID, limit) + if err != nil { + http.Error(w, "list notifications failed: "+err.Error(), http.StatusInternalServerError) + return + } + writeJSONResponse(w, http.StatusOK, map[string]any{ + "items": items, + }) +} diff --git a/internal/server/notification_reminder_loop.go b/internal/server/notification_reminder_loop.go new file mode 100644 index 00000000..7210756d --- /dev/null +++ b/internal/server/notification_reminder_loop.go @@ -0,0 +1,37 @@ +package server + +import ( + "context" + "log/slog" + "time" +) + +const defaultReminderPollInterval = 15 * time.Minute + +func (s *Server) startNotificationReminderLoop() { + if s == nil || s.notifySvc == nil || !s.notifySvc.Available() { + return + } + go func() { + ticker := time.NewTicker(defaultReminderPollInterval) + defer ticker.Stop() + s.runStageAdvanceReminders(context.Background()) + for range ticker.C { + s.runStageAdvanceReminders(context.Background()) + } + }() +} + +func (s *Server) runStageAdvanceReminders(ctx context.Context) { + if s.notifySvc == nil || !s.notifySvc.Available() { + return + } + n, err := s.notifySvc.ProcessStageAdvanceReminders(ctx) + if err != nil { + slog.Warn("stage advance reminder scan failed", "error", err) + return + } + if n > 0 { + slog.Info("stage advance 24h reminders sent", "count", n) + } +} diff --git a/internal/server/phase5_acceptance_pg_test.go b/internal/server/phase5_acceptance_pg_test.go new file mode 100644 index 00000000..347bc1a1 --- /dev/null +++ b/internal/server/phase5_acceptance_pg_test.go @@ -0,0 +1,192 @@ +package server_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "chat-agent-system/internal/db" + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/projectworkflow" + "chat-agent-system/internal/workflowdef" +) + +func TestPhase5_MigrationVersionGate_PG(t *testing.T) { + pool := openPGPoolWithMigrations(t) + if pool == nil { + return + } + defer pool.Close() + + ctx := context.Background() + migDir := db.ResolveMigrationsDir() + expected, err := db.ExpectedMigrationCount(migDir) + if err != nil { + t.Fatalf("count migrations: %v", err) + } + current, err := db.CurrentVersion(ctx, pool) + if err != nil { + t.Fatalf("current version: %v", err) + } + if int(current) < expected { + t.Fatalf("schema version %d behind expected %d migration files", current, expected) + } + + // 重复迁移应幂等(模拟备份恢复后再 Up) + if err := db.RunMigrations(ctx, pool, migDir); err != nil { + t.Fatalf("re-run migrations: %v", err) + } + current2, _ := db.CurrentVersion(ctx, pool) + if current2 != current { + t.Fatalf("migration not idempotent: %d -> %d", current, current2) + } +} + +func TestPhase5_AuthzNegativeMatrix_PG(t *testing.T) { + pool := openPGPoolWithMigrations(t) + if pool == nil { + return + } + defer pool.Close() + + handler, _ := setupPGGovernanceServer(t, pool) + creatorToken := loginTestUser(t, handler, "zhanggong", "123456") + outsiderToken := loginTestUser(t, handler, "lizhuguan", "123456") + + createBody, _ := json.Marshal(projectworkflow.CreateProjectInput{Name: "Phase5 Authz Matrix Test"}) + reqCreate := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody)) + withBearerAuth(reqCreate, creatorToken) + recCreate := httptest.NewRecorder() + handler.ServeHTTP(recCreate, reqCreate) + if recCreate.Code != http.StatusCreated { + t.Fatalf("create project: %d", recCreate.Code) + } + var proj projectworkflow.Project + _ = json.Unmarshal(recCreate.Body.Bytes(), &proj) + t.Cleanup(func() { cleanupProject(t, pool, proj.ID) }) + + // 访客无权读项目设置(requireProjectAccess) + reqSettings := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/settings", proj.ID), nil) + withBearerAuth(reqSettings, outsiderToken) + recSettings := httptest.NewRecorder() + handler.ServeHTTP(recSettings, reqSettings) + if recSettings.Code != http.StatusForbidden { + t.Fatalf("outsider settings expected 403, got %d", recSettings.Code) + } + + // 非成员无权保存工作流规划 + saveBody, _ := json.Marshal(projectinstance.SavePlanInput{ + Plan: projectinstance.WorkflowPlan{WorkflowDefinitionKey: "medical_device_development", WorkflowVersion: 1}, + }) + reqSave := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/workflow-plan", proj.ID), bytes.NewReader(saveBody)) + withBearerAuth(reqSave, outsiderToken) + recSave := httptest.NewRecorder() + handler.ServeHTTP(recSave, reqSave) + if recSave.Code != http.StatusForbidden { + t.Fatalf("outsider save plan expected 403, got %d", recSave.Code) + } + + // 未认证请求 + reqAnon := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/settings", proj.ID), nil) + recAnon := httptest.NewRecorder() + handler.ServeHTTP(recAnon, reqAnon) + if recAnon.Code != http.StatusUnauthorized { + t.Fatalf("anonymous settings expected 401, got %d", recAnon.Code) + } +} + +func TestPhase5_ActivateIdempotencyConflict_PG(t *testing.T) { + pool := openPGPoolWithMigrations(t) + if pool == nil { + return + } + defer pool.Close() + + ctx := context.Background() + handler, _ := setupPGGovernanceServer(t, pool) + creatorToken := loginTestUser(t, handler, "zhanggong", "123456") + + wfSvc := workflowdef.NewService(pool, nil) + wfKey := "pg_p5_idem_" + time.Now().Format("150405.000") + _, ver, err := wfSvc.CreateDefinition(ctx, workflowdef.CreateDefinitionInput{ + Key: wfKey, Name: "Idempotency WF", + Definition: workflowdef.VersionDefinition{ + SchemaVersion: 1, + Stages: []workflowdef.StageDef{{ + Key: "biz_intake", Name: "Intake", Position: 0, + DocTasks: []workflowdef.DocTaskDef{{ + Key: "intake", Required: true, Blocking: true, DefaultEnabled: true, + OutputDocType: "intake", OutputDocTitle: "Customer Intake", + }}, + }}, + }, + }, 1) + if err != nil { + t.Fatalf("create wf: %v", err) + } + _, _ = wfSvc.PublishVersion(ctx, ver.WorkflowID, ver.Version, 1) + + createBody, _ := json.Marshal(projectworkflow.CreateProjectInput{Name: "Phase5 Idempotency Test"}) + reqCreate := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody)) + withBearerAuth(reqCreate, creatorToken) + recCreate := httptest.NewRecorder() + handler.ServeHTTP(recCreate, reqCreate) + var proj projectworkflow.Project + _ = json.Unmarshal(recCreate.Body.Bytes(), &proj) + t.Cleanup(func() { cleanupProject(t, pool, proj.ID) }) + + creatorID := fetchAuthUserID(t, handler, creatorToken) + reviewerID := fetchAuthUserID(t, handler, loginTestUser(t, handler, "lizhuguan", "123456")) + approverID := fetchAuthUserID(t, handler, loginTestUser(t, handler, "wangzong", "123456")) + reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=1", proj.ID, wfKey), nil) + withBearerAuth(reqDefault, creatorToken) + recDefault := httptest.NewRecorder() + handler.ServeHTTP(recDefault, reqDefault) + var plan projectinstance.WorkflowPlan + _ = json.Unmarshal(recDefault.Body.Bytes(), &plan) + for i := range plan.Stages { + for j := range plan.Stages[i].DocTasks { + plan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{ + AuthorUserID: creatorID, ReviewerUserID: reviewerID, ApproverUserID: approverID, + } + } + } + saveBody, _ := json.Marshal(projectinstance.SavePlanInput{Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1}) + reqSave := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/workflow-plan", proj.ID), bytes.NewReader(saveBody)) + withBearerAuth(reqSave, creatorToken) + recSave := httptest.NewRecorder() + handler.ServeHTTP(recSave, reqSave) + + reqPreview := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/activation-preview", proj.ID), nil) + withBearerAuth(reqPreview, creatorToken) + recPreview := httptest.NewRecorder() + handler.ServeHTTP(recPreview, reqPreview) + var preview projectinstance.ActivationPreview + _ = json.Unmarshal(recPreview.Body.Bytes(), &preview) + + activateBody, _ := json.Marshal(projectinstance.ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + reqActivate := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/workflow-instances/activate", proj.ID), bytes.NewReader(activateBody)) + withBearerAuth(reqActivate, creatorToken) + recActivate := httptest.NewRecorder() + handler.ServeHTTP(recActivate, reqActivate) + if recActivate.Code != http.StatusOK { + t.Fatalf("first activate: %d %s", recActivate.Code, recActivate.Body.String()) + } + + // 二次激活应冲突 + reqActivate2 := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/workflow-instances/activate", proj.ID), bytes.NewReader(activateBody)) + withBearerAuth(reqActivate2, creatorToken) + recActivate2 := httptest.NewRecorder() + handler.ServeHTTP(recActivate2, reqActivate2) + if recActivate2.Code != http.StatusConflict { + t.Fatalf("second activate expected 409, got %d %s", recActivate2.Code, recActivate2.Body.String()) + } +} diff --git a/internal/server/phase5_fault_injection_pg_test.go b/internal/server/phase5_fault_injection_pg_test.go new file mode 100644 index 00000000..e3d38372 --- /dev/null +++ b/internal/server/phase5_fault_injection_pg_test.go @@ -0,0 +1,257 @@ +package server_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/db" + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/server" + "chat-agent-system/internal/stream" + "chat-agent-system/internal/taskrun" + "chat-agent-system/internal/workflowdef" +) + +// setupPGProjectReadyToAdvance 创建并激活工作流、完成 intake 文档批准,返回可推进实例。 +func setupPGProjectReadyToAdvance(t *testing.T, pool *pgxpool.Pool, handler http.Handler, srv *server.Server) (projectID, instanceID int64, creatorToken string, configRevision int) { + t.Helper() + ctx := context.Background() + wfSvc := workflowdef.NewService(pool, nil) + wfKey := "pg_p5_fault_" + time.Now().Format("150405.000000") + _, ver, err := wfSvc.CreateDefinition(ctx, workflowdef.CreateDefinitionInput{ + Key: wfKey, Name: "Phase5 Fault WF", + Definition: workflowdef.VersionDefinition{ + SchemaVersion: 1, + Stages: []workflowdef.StageDef{ + { + Key: "biz_intake", Name: "Intake", Position: 0, + DocTasks: []workflowdef.DocTaskDef{{ + Key: "intake", Required: true, Blocking: true, DefaultEnabled: true, + OutputDocType: "intake", OutputDocTitle: "Intake", + }}, + }, + {Key: "biz_feasibility", Name: "Feasibility", Position: 1, Optional: true}, + }, + }, + }, 1) + if err != nil { + t.Fatalf("create wf: %v", err) + } + _, _ = wfSvc.PublishVersion(ctx, ver.WorkflowID, ver.Version, 1) + + creatorToken = loginTestUser(t, handler, "zhanggong", "123456") + creatorID := fetchAuthUserID(t, handler, creatorToken) + reviewerID := fetchAuthUserID(t, handler, loginTestUser(t, handler, "lizhuguan", "123456")) + approverID := fetchAuthUserID(t, handler, loginTestUser(t, handler, "wangzong", "123456")) + + code := "p5-fault-" + time.Now().Format("150405.000000") + err = pool.QueryRow(ctx, ` + INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision) + VALUES ($1, 'Phase5 Fault Test', 'active', $2, 'planning', '', 1, 0) RETURNING id + `, code, creatorID).Scan(&projectID) + if err != nil { + t.Fatalf("insert project: %v", err) + } + _, _ = pool.Exec(ctx, ` + INSERT INTO project_members (project_id, user_id, project_role, status, joined_at, updated_at) + VALUES ($1, $2, 'owner', 'active', now(), now()) ON CONFLICT DO NOTHING + `, projectID, creatorID) + t.Cleanup(func() { cleanupProject(t, pool, projectID) }) + + reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=1", projectID, wfKey), nil) + withBearerAuth(reqDefault, creatorToken) + recDefault := httptest.NewRecorder() + handler.ServeHTTP(recDefault, reqDefault) + var plan projectinstance.WorkflowPlan + _ = json.Unmarshal(recDefault.Body.Bytes(), &plan) + for i := range plan.Stages { + for j := range plan.Stages[i].DocTasks { + plan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{ + AuthorUserID: creatorID, ReviewerUserID: reviewerID, ApproverUserID: approverID, + } + } + } + saveBody, _ := json.Marshal(projectinstance.SavePlanInput{Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1}) + reqSave := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/workflow-plan", projectID), bytes.NewReader(saveBody)) + withBearerAuth(reqSave, creatorToken) + recSave := httptest.NewRecorder() + handler.ServeHTTP(recSave, reqSave) + + reqPreview := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/activation-preview", projectID), nil) + withBearerAuth(reqPreview, creatorToken) + recPreview := httptest.NewRecorder() + handler.ServeHTTP(recPreview, reqPreview) + var preview projectinstance.ActivationPreview + _ = json.Unmarshal(recPreview.Body.Bytes(), &preview) + + activateBody, _ := json.Marshal(projectinstance.ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + reqActivate := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/workflow-instances/activate", projectID), bytes.NewReader(activateBody)) + withBearerAuth(reqActivate, creatorToken) + recActivate := httptest.NewRecorder() + handler.ServeHTTP(recActivate, reqActivate) + var activated projectinstance.ActivateResult + _ = json.Unmarshal(recActivate.Body.Bytes(), &activated) + instanceID = activated.Instance.ID + configRevision = activated.Instance.ConfigRevision + + docID := fmt.Sprintf("doc_p5_fault_%d", projectID) + docEngine := srv.BizdevService().DocGov() + _, _ = docEngine.CreateOrGetDocument(ctx, docID, projectID, "intake", "Intake", "V0.1", "", creatorID, "A", reviewerID, "R", approverID, "P") + _ = docEngine.MarkDocumentContentUpdated(ctx, docID, "hash_p5_fault", false) + _, _ = docEngine.SubmitForReview(ctx, docID, creatorID, "author", "Author") + _, _ = docEngine.ReviewApprove(ctx, docID, reviewerID, "reviewer", "Reviewer", "ok") + _, _ = docEngine.ApproverApprove(ctx, docID, approverID, "approver", "Approver", "ok") + return projectID, instanceID, creatorToken, configRevision +} + +func TestPhase5_ConcurrentStageAdvance_PG(t *testing.T) { + pool := openPGPoolWithMigrations(t) + if pool == nil { + return + } + defer pool.Close() + + handler, srv := setupPGGovernanceServer(t, pool) + _, instanceID, creatorToken, configRevision := setupPGProjectReadyToAdvance(t, pool, handler, srv) + + advanceBody, _ := json.Marshal(projectinstance.AdvanceStageInput{ + StageKey: "biz_intake", ExpectedConfigRevision: configRevision, + }) + url := fmt.Sprintf("/api/v1/workflow-instances/%d/stages/biz_intake/advance", instanceID) + + var wg sync.WaitGroup + results := make([]int, 2) + for i := 0; i < 2; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(advanceBody)) + withBearerAuth(req, creatorToken) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + results[idx] = rec.Code + }(i) + } + wg.Wait() + + okCount, conflictCount := 0, 0 + for _, code := range results { + switch code { + case http.StatusOK: + okCount++ + case http.StatusConflict, http.StatusBadRequest, http.StatusUnprocessableEntity: + conflictCount++ + } + } + if okCount != 1 { + t.Fatalf("expected exactly one 200 advance, got codes=%v", results) + } + if conflictCount < 1 { + t.Fatalf("expected second advance to fail with conflict/gate error, got codes=%v", results) + } +} + +func TestPhase5_TaskRunHeartbeatSurvivesOrphanScan_PG(t *testing.T) { + pool := openPGPoolWithMigrations(t) + if pool == nil { + return + } + defer pool.Close() + if !taskrun.LeaseColumnsReady(context.Background(), pool) { + t.Skip("lease columns not ready") + } + + handler, srv := setupPGGovernanceServer(t, pool) + token := loginTestUser(t, handler, "zhanggong", "123456") + userID := fetchAuthUserID(t, handler, token) + + runID := "run_hb_survive_" + time.Now().Format("150405.000000") + store := taskrun.NewPGStore(pool) + done := make(chan struct{}) + srv.TaskRuns().SetExecutor( + func(ctx context.Context, _ string, _ bizdev.TaskRunRequest, _ *stream.Streamer) (*bizdev.TaskRunResult, error) { + <-done + return &bizdev.TaskRunResult{Success: true, TaskKey: "intake"}, nil + }, + nil, nil, + ) + srv.TaskRuns().SetFinalizer(func(ctx context.Context, record *taskrun.RunRecord, result *bizdev.TaskRunResult) (string, error) { + return "completed", nil + }) + + record := &taskrun.RunRecord{ + RunID: runID, SessionID: "sess_hb", UserID: userID, TaskKey: "intake", Status: "running", + } + _, err := srv.TaskRuns().CreateAndStart(context.Background(), runID, record, bizdev.TaskRunRequest{TaskKey: "intake"}, bizdev.StageRunRequest{}, "") + if err != nil { + t.Fatalf("CreateAndStart: %v", err) + } + t.Cleanup(func() { close(done) }) + + time.Sleep(150 * time.Millisecond) + if n, err := store.MarkOrphanedRunning(context.Background()); err != nil { + t.Fatalf("orphan scan: %v", err) + } else if n > 0 { + rec, _ := store.GetRun(context.Background(), runID) + if rec != nil && rec.Status == "interrupted" { + t.Fatal("active heartbeat run should not be marked interrupted") + } + } + + rec, err := store.GetRun(context.Background(), runID) + if err != nil { + t.Fatalf("GetRun: %v", err) + } + if rec.Status != "running" { + t.Fatalf("expected running with fresh lease, got %s", rec.Status) + } +} + +func TestPhase5_BackupRestoreMigration_PG(t *testing.T) { + pool := openPGPoolWithMigrations(t) + if pool == nil { + return + } + defer pool.Close() + + ctx := context.Background() + migDir := db.ResolveMigrationsDir() + v1, err := db.CurrentVersion(ctx, pool) + if err != nil { + t.Fatalf("version: %v", err) + } + expected, _ := db.ExpectedMigrationCount(migDir) + if int(v1) < expected { + t.Fatalf("schema %d behind %d files", v1, expected) + } + + // 模拟备份恢复后再次 Up(空操作应幂等) + if err := db.RunMigrations(ctx, pool, migDir); err != nil { + t.Fatalf("restore migration: %v", err) + } + v2, _ := db.CurrentVersion(ctx, pool) + if v2 != v1 { + t.Fatalf("migration version changed after idempotent restore: %d -> %d", v1, v2) + } + + var taskRunsExists bool + _ = pool.QueryRow(ctx, ` + SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'task_runs') + `).Scan(&taskRunsExists) + if !taskRunsExists { + t.Fatal("task_runs missing after restore simulation") + } +} diff --git a/internal/server/phase5_finalize_fault_pg_test.go b/internal/server/phase5_finalize_fault_pg_test.go new file mode 100644 index 00000000..ccc29eb4 --- /dev/null +++ b/internal/server/phase5_finalize_fault_pg_test.go @@ -0,0 +1,108 @@ +package server_test + +import ( + "context" + "errors" + "testing" + "time" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/stream" + "chat-agent-system/internal/taskrun" +) + +func TestPhase5_FinalizeFaultInjection_NoDoneEvent(t *testing.T) { + m := taskrun.NewManager(nil, nil) + m.SetExecutor( + func(_ context.Context, _ string, _ bizdev.TaskRunRequest, _ *stream.Streamer) (*bizdev.TaskRunResult, error) { + return &bizdev.TaskRunResult{Success: true, TaskKey: "intake"}, nil + }, + nil, nil, + ) + m.SetFinalizer(func(_ context.Context, _ *taskrun.RunRecord, _ *bizdev.TaskRunResult) (string, error) { + return "", errors.New("injected finalize db failure") + }) + + runID := "run_finalize_fault" + record := &taskrun.RunRecord{RunID: runID, Status: "running", TaskKey: "intake"} + _, _ = m.CreateAndStart(context.Background(), runID, record, bizdev.TaskRunRequest{TaskKey: "intake"}, bizdev.StageRunRequest{}, "") + + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + rec, err := m.GetRun(runID) + if err == nil && rec.Status == "finalizing_failed" { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("expected finalizing_failed after injected finalize fault") +} + +func TestPhase5_RollingRestartReplay_PG(t *testing.T) { + pool := openPGPoolWithMigrations(t) + if pool == nil { + return + } + defer pool.Close() + + handler, srv := setupPGGovernanceServer(t, pool) + token := loginTestUser(t, handler, "zhanggong", "123456") + userID := fetchAuthUserID(t, handler, token) + + store := taskrun.NewPGStore(pool) + runID := "run_rolling_" + time.Now().Format("150405.000000") + srv.TaskRuns().SetStore(store) + srv.TaskRuns().SetExecutor( + func(_ context.Context, _ string, _ bizdev.TaskRunRequest, st *stream.Streamer) (*bizdev.TaskRunResult, error) { + if st != nil { + st.EmitPhase("roll", "step", "agent", 1, 1, 10) + } + return &bizdev.TaskRunResult{Success: true, TaskKey: "intake"}, nil + }, + nil, nil, + ) + srv.TaskRuns().SetFinalizer(func(_ context.Context, _ *taskrun.RunRecord, _ *bizdev.TaskRunResult) (string, error) { + return "completed", nil + }) + + record := &taskrun.RunRecord{ + RunID: runID, SessionID: "sess_roll", UserID: userID, TaskKey: "intake", Status: "running", + } + _, _ = srv.TaskRuns().CreateAndStart(context.Background(), runID, record, bizdev.TaskRunRequest{TaskKey: "intake"}, bizdev.StageRunRequest{}, "") + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM task_run_events WHERE run_id = $1`, runID) + _, _ = pool.Exec(context.Background(), `DELETE FROM task_runs WHERE run_id = $1`, runID) + }) + + deadline := time.Now().Add(8 * time.Second) + for time.Now().Before(deadline) { + rec, err := store.GetRun(context.Background(), runID) + if err == nil && rec.Status == "completed" { + break + } + time.Sleep(50 * time.Millisecond) + } + + // 模拟三次进程重启:新 Manager 仅挂载 Store + for i := 0; i < 3; i++ { + restarted := taskrun.NewManager(srv.ProjectWorkflowService(), srv.BizdevService().ContextManager()) + restarted.SetStore(store) + rec, err := restarted.GetRun(runID) + if err != nil { + t.Fatalf("restart %d GetRun: %v", i, err) + } + if rec.Status != "completed" { + t.Fatalf("restart %d status=%s", i, rec.Status) + } + _, replay, err := restarted.Subscribe(runID, 0) + if err != nil { + t.Fatalf("restart %d Subscribe: %v", i, err) + } + if len(replay) == 0 { + t.Fatalf("restart %d expected replay events", i) + } + } + + _ = handler + _ = token +} diff --git a/internal/server/phase5_soak_test.go b/internal/server/phase5_soak_test.go new file mode 100644 index 00000000..19feb417 --- /dev/null +++ b/internal/server/phase5_soak_test.go @@ -0,0 +1,33 @@ +package server_test + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestPhase5_SoakReadinessLoop_Short(t *testing.T) { + pool := openPGPoolWithMigrations(t) + if pool == nil { + return + } + defer pool.Close() + + handler, _ := setupPGGovernanceServer(t, pool) + const iterations = 8 + var failures int + for i := 0; i < iterations; i++ { + req := httptest.NewRequest(http.MethodGet, "/api/v1/ready", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + failures++ + t.Logf("iteration %d: ready=%d body=%s", i, rec.Code, rec.Body.String()) + } + time.Sleep(50 * time.Millisecond) + } + if failures > 0 { + t.Fatalf("readiness soak failures=%d/%d", failures, iterations) + } +} diff --git a/internal/server/piagent_e2e_test.go b/internal/server/piagent_e2e_test.go index b6427d86..641dc59d 100644 --- a/internal/server/piagent_e2e_test.go +++ b/internal/server/piagent_e2e_test.go @@ -16,34 +16,90 @@ import ( "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 从 .env / 环境变量注入共享知识库与向量检索配置(E2E 必须走真实 KB,不可 stub)。 +func mergePiAgentE2EKbConfig(cfg *config.Config) { + 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 + } + if cfg.KbDbHost == "" { + cfg.KbDbHost = "127.0.0.1" + } + if cfg.KbDbPort == 0 { + cfg.KbDbPort = 5432 + } + if cfg.KbDbUser == "" { + cfg.KbDbUser = "regkb" + } + if cfg.KbDbPassword == "" { + cfg.KbDbPassword = "shared_kb_secret_pass" + } + 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 - 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, + 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() - client := &http.Client{Timeout: 10 * time.Minute} + // 单次 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") @@ -55,6 +111,15 @@ func TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E(t *testing.T) { respHealth.Body.Close() t.Logf("✓ [0. 环境校验] Health检查正常: Env=%s, AgentBackend=%s, SharedKBPort=%d", healthData.Env, healthData.AgentBackend, healthData.SharedKBPort) + kbClient := kb.NewClient(cfg) + defer kbClient.Close() + if stats, err := kbClient.Stats(ctx); err != nil { + t.Logf("⚠ [0. 知识库] 未连接共享 KB(%s:%d/%s): %v", cfg.KbDbHost, cfg.KbDbPort, cfg.KbDbName, 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) @@ -64,13 +129,41 @@ func TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E(t *testing.T) { intendedUse := "神经外科与肝胆外科精密手术中利用 24kHz-35kHz 超声微幅振动选择性粉碎并吸引病灶组织,保留邻近大血管与神经" targetMarket := "中国NMPA三类创新医疗器械" - authorID := int64(1) + // 与 dev 种子账号对齐(internal/auth/store.go) + authorID := int64(2) // zhanggong / 张工 authorName := "张工" - reviewerID := int64(2) + reviewerID := int64(3) // lizhuguan / 李主管 reviewerName := "李主管" - approverID := int64(3) + 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. 【编制人·张工】登录与立项阶段对话 (穿插非工作流问答 + 行业标准咨询) @@ -114,6 +207,7 @@ func TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E(t *testing.T) { // 4.2 业务补充与立项需求对话 intakeReply := postChat("我们准备正式立项 CUSA-Ultra 项目,刀头频率设定为 24kHz 与 35kHz 双频,具备实时灌注与负压吸引,满足神经外科浅脑肿瘤切除,请问法规应关注哪些强标?", authorID, authorName, "author") t.Logf("✓ [张工-立项需求对话] 协同参谋答复:\n%s", intakeReply) + logStep("步骤 1 对话完成") // 5. 推进成熟度工作流 V0.1: 立项摸底 (intake) t.Log("--------------------------------------------------------------------------------") @@ -160,6 +254,7 @@ func TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E(t *testing.T) { if targetDocID == "" { targetDocID = fmt.Sprintf("doc_proj%d_intake_v1", projectID) } + logStep("步骤 2 V0.1 阶段完成") // 6. 推进自由非标任务 (Free Ad-Hoc Task): 24kHz 高频空化钛合金刀头微裂纹与疲劳剥落风险专项分析报告 t.Log("--------------------------------------------------------------------------------") @@ -214,9 +309,16 @@ func TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E(t *testing.T) { 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() - t.Logf("✓ [自由非标任务完成] 任务成功=%v, 交付物=[%s]", freeRes.Success, freeRes.ArtifactTitle) + 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" { @@ -299,12 +401,8 @@ func TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E(t *testing.T) { "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() + respClaim, err := authedPost("/api/v1/docgov/claim", authorToken, claimReq) + expectOK("claim", respClaim, err) t.Logf("✓ [张工] 成功认领受控文档: DocID=%s", targetDocID) // 8.2 编制人【张工】送审 (进入 StateReviewing) @@ -313,12 +411,8 @@ func TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E(t *testing.T) { "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() + respSub, err := authedPost("/api/v1/docgov/submit-review", authorToken, submitReq) + expectOK("submit-review", respSub, err) t.Logf("✓ [张工] 提交审核成功,文档进入锁定审核态") // 8.3 【审核人·李主管】介入并添加批注 @@ -347,12 +441,8 @@ func TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E(t *testing.T) { "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() + respRej, err := authedPost("/api/v1/docgov/review-decision", reviewerToken, rejReq) + expectOK("review reject", respRej, err) t.Logf("✓ [李主管] 驳回成功,受控文档自动解锁并退回 drafting 状态") // 8.5 编制人【张工】再次送审 @@ -361,12 +451,8 @@ func TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E(t *testing.T) { "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() + respSub2, err := authedPost("/api/v1/docgov/submit-review", authorToken, submitReq2) + expectOK("resubmit", respSub2, err) t.Logf("✓ [张工] 补充材料后再次送审成功") // 8.6 【审核人·李主管】第二次审核 -> 审核通过 (Pass) @@ -377,12 +463,8 @@ func TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E(t *testing.T) { "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() + respPass, err := authedPost("/api/v1/docgov/review-decision", reviewerToken, passReq) + expectOK("review approve", respPass, err) t.Logf("✓ [李主管] 审核通过,文档流转至待批准态 (StateApproving)") // 8.7 【批准人·王总监】最终批准 (Approve) @@ -393,12 +475,8 @@ func TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E(t *testing.T) { "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() + respAppr, err := authedPost("/api/v1/docgov/approver-decision", approverToken, apprReq) + expectOK("approver decision", respAppr, err) t.Logf("✓ [王总监] 最终批准成功,文档彻底锁定生效归档 (StateApproved)") // 9. 验证全生命周期数据落盘与审计追溯 diff --git a/internal/server/project_create_pg_test.go b/internal/server/project_create_pg_test.go new file mode 100644 index 00000000..8d8f1204 --- /dev/null +++ b/internal/server/project_create_pg_test.go @@ -0,0 +1,57 @@ +package server_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "unicode/utf8" + + "github.com/jackc/pgx/v5/pgxpool" + + "chat-agent-system/internal/projectworkflow" +) + +func TestProjectCreate_PG_ChineseNameViaHTTP(t *testing.T) { + pool := openPGPoolWithMigrations(t) + if pool == nil { + return + } + defer pool.Close() + + handler, _ := setupPGGovernanceServer(t, pool) + token := loginTestUser(t, handler, "zhanggong", "123456") + + body, _ := json.Marshal(projectworkflow.CreateProjectInput{ + Name: "PG Phase5 治理链中文名称测试", + DeviceType: "超声手术设备", + IntendedUse: "软组织切除", + TargetMarket: "NMPA 三类", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(body)) + withBearerAuth(req, token) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("create project with Chinese name: %d %s", rec.Code, rec.Body.String()) + } + var proj projectworkflow.Project + if err := json.Unmarshal(rec.Body.Bytes(), &proj); err != nil { + t.Fatalf("decode project: %v", err) + } + if !utf8.ValidString(proj.Code) { + t.Fatalf("project code invalid UTF-8: %q", proj.Code) + } + t.Cleanup(func() { + cleanupProject(t, pool, proj.ID) + }) +} + +func cleanupProject(t *testing.T, pool *pgxpool.Pool, projectID int64) { + t.Helper() + if pool == nil || projectID <= 0 { + return + } + _, _ = pool.Exec(t.Context(), `DELETE FROM projects WHERE id = $1`, projectID) +} diff --git a/internal/server/project_doc_handlers.go b/internal/server/project_doc_handlers.go index 0e5c630d..c1bcc808 100644 --- a/internal/server/project_doc_handlers.go +++ b/internal/server/project_doc_handlers.go @@ -27,6 +27,11 @@ func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *ht return } + user, ok := s.requireProjectAccess(w, r, projectID) + if !ok { + return + } + if s.svc == nil || s.svc.DocGov() == nil { http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable) return @@ -37,6 +42,7 @@ func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *ht http.Error(w, "list project documents failed: "+err.Error(), http.StatusInternalServerError) return } + docs = s.filterWorkflowDocsByStageAccess(r.Context(), projectID, user.ID, docs) writeJSONResponse(w, http.StatusOK, docs) } @@ -53,6 +59,20 @@ func (s *Server) handleDocGovGetDocumentContent(w http.ResponseWriter, r *http.R return } + docRec, err := s.svc.DocGov().GetDocument(r.Context(), docID) + if err != nil || docRec == nil { + http.Error(w, "document not found", http.StatusNotFound) + return + } + user, ok := s.requireProjectAccess(w, r, docRec.ProjectID) + if !ok { + return + } + if !s.allowWorkflowDocTaskAccess(r.Context(), docRec.ProjectID, user.ID, docRec.TaskKey) { + http.Error(w, `{"error":"WORKFLOW_ACCESS_DENIED","message":"无权访问该阶段文档"}`, http.StatusForbidden) + return + } + content, err := s.svc.DocGov().ReadDocumentContent(r.Context(), docID, s.repoRoot()) if err != nil { http.Error(w, "read document content failed: "+err.Error(), http.StatusNotFound) diff --git a/internal/server/project_handlers.go b/internal/server/project_handlers.go index e6c468e9..810dc34c 100644 --- a/internal/server/project_handlers.go +++ b/internal/server/project_handlers.go @@ -137,6 +137,10 @@ func (s *Server) handleActivateWorkflow(w http.ResponseWriter, r *http.Request) return } + if s.blockLegacyWorkflowSwitch(w, r, id) { + return + } + var req projectworkflow.ActivateWorkflowInput if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"无效的请求体"}`, http.StatusBadRequest) diff --git a/internal/server/project_settings_handlers.go b/internal/server/project_settings_handlers.go new file mode 100644 index 00000000..59913737 --- /dev/null +++ b/internal/server/project_settings_handlers.go @@ -0,0 +1,193 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "chat-agent-system/internal/projectinstance" +) + +func (s *Server) handleGetDefaultWorkflowPlan(w http.ResponseWriter, r *http.Request) { + projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || projectID <= 0 { + http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest) + return + } + if _, ok := s.requireProjectAccess(w, r, projectID); !ok { + return + } + if s.instSvc == nil || !s.instSvc.Available() { + http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`, http.StatusServiceUnavailable) + return + } + workflowKey := r.URL.Query().Get("workflow_key") + version, _ := strconv.Atoi(r.URL.Query().Get("version")) + plan, err := s.instSvc.BuildDefaultPlan(r.Context(), workflowKey, version) + if err != nil { + writeProjectInstanceError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, plan) +} + +func (s *Server) handleGetProjectSettings(w http.ResponseWriter, r *http.Request) { + projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || projectID <= 0 { + http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest) + return + } + user, ok := s.requireProjectAccess(w, r, projectID) + if !ok { + return + } + if s.instSvc == nil || !s.instSvc.Available() { + http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用,请先执行数据库迁移"}`, http.StatusServiceUnavailable) + return + } + isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID) + settings, err := s.instSvc.GetSettings(r.Context(), projectID, user.ID, isCreator) + if err != nil { + writeProjectInstanceError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, settings) +} + +func (s *Server) handleSaveWorkflowPlan(w http.ResponseWriter, r *http.Request) { + projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || projectID <= 0 { + http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest) + return + } + user, ok := s.requireProjectAccess(w, r, projectID) + if !ok { + return + } + if s.instSvc == nil || !s.instSvc.Available() { + http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`, http.StatusServiceUnavailable) + return + } + isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID) + var req projectinstance.SavePlanInput + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`, http.StatusBadRequest) + return + } + settings, err := s.instSvc.SaveWorkflowPlan(r.Context(), projectID, user.ID, isCreator, req) + if err != nil { + writeProjectInstanceError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, settings) +} + +func (s *Server) handleActivationPreview(w http.ResponseWriter, r *http.Request) { + projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || projectID <= 0 { + http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest) + return + } + user, ok := s.requireProjectAccess(w, r, projectID) + if !ok { + return + } + if s.instSvc == nil || !s.instSvc.Available() { + http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`, http.StatusServiceUnavailable) + return + } + isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID) + preview, err := s.instSvc.ActivationPreview(r.Context(), projectID, user.ID, isCreator) + if err != nil { + writeProjectInstanceError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, preview) +} + +func (s *Server) handleActivateWorkflowInstance(w http.ResponseWriter, r *http.Request) { + projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || projectID <= 0 { + http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest) + return + } + user, ok := s.requireProjectAccess(w, r, projectID) + if !ok { + return + } + if s.instSvc == nil || !s.instSvc.Available() { + http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`, http.StatusServiceUnavailable) + return + } + isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), projectID, user.ID) + var req projectinstance.ActivateInput + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`, http.StatusBadRequest) + return + } + result, err := s.instSvc.Activate(r.Context(), projectID, user.ID, isCreator, req) + if err != nil { + writeProjectInstanceError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, result) +} + +func writeProjectInstanceError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, projectinstance.ErrForbiddenNotCreator): + http.Error(w, `{"error":"ERR_NOT_PROJECT_CREATOR","message":"仅项目创建人可配置工作流"}`, http.StatusForbidden) + case errors.Is(err, projectinstance.ErrPlanFrozen): + http.Error(w, `{"error":"PLAN_FROZEN","message":"工作流已激活,规划已冻结"}`, http.StatusConflict) + case errors.Is(err, projectinstance.ErrWorkflowAlreadyActive): + http.Error(w, `{"error":"WORKFLOW_ALREADY_ACTIVE","message":"项目已有运行中的工作流实例"}`, http.StatusConflict) + case errors.Is(err, projectinstance.ErrPlanChangedReconfirm): + http.Error(w, `{"error":"PLAN_CHANGED_RECONFIRM","message":"规划已变更,请重新预览并确认"}`, http.StatusConflict) + case errors.Is(err, projectinstance.ErrValidationFailed): + http.Error(w, `{"error":"VALIDATION_FAILED","message":"`+err.Error()+`"}`, http.StatusBadRequest) + case errors.Is(err, projectinstance.ErrTokenInvalid): + http.Error(w, `{"error":"TOKEN_INVALID","message":"激活令牌无效或已过期"}`, http.StatusBadRequest) + case errors.Is(err, projectinstance.ErrConcurrentConflict): + http.Error(w, `{"error":"ERR_CONCURRENT_SWITCH","message":"项目版本冲突,请刷新后重试"}`, http.StatusConflict) + case errors.Is(err, projectinstance.ErrPlanRevisionConflict): + http.Error(w, `{"error":"PLAN_REVISION_CONFLICT","message":"规划版本冲突,请刷新后重试"}`, http.StatusConflict) + case errors.Is(err, projectinstance.ErrProjectNotFound): + http.Error(w, `{"error":"ERR_PROJECT_NOT_FOUND","message":"项目不存在"}`, http.StatusNotFound) + case errors.Is(err, projectinstance.ErrInvalidPlan): + http.Error(w, `{"error":"ERR_INVALID_PLAN","message":"`+err.Error()+`"}`, http.StatusBadRequest) + case errors.Is(err, projectinstance.ErrInstanceNotFound): + http.Error(w, `{"error":"ERR_INSTANCE_NOT_FOUND","message":"工作流实例不存在"}`, http.StatusNotFound) + case errors.Is(err, projectinstance.ErrStageGateNotReady): + http.Error(w, `{"error":"STAGE_GATE_NOT_READY","message":"阶段门禁未满足,必需文档尚未全部批准"}`, http.StatusConflict) + case errors.Is(err, projectinstance.ErrStageNotActive): + http.Error(w, `{"error":"STAGE_NOT_ACTIVE","message":"目标阶段不是当前活动阶段"}`, http.StatusConflict) + case errors.Is(err, projectinstance.ErrInvalidStage): + http.Error(w, `{"error":"ERR_INVALID_STAGE","message":"无效的阶段"}`, http.StatusBadRequest) + case errors.Is(err, projectinstance.ErrInstanceRevisionConflict): + http.Error(w, `{"error":"INSTANCE_REVISION_CONFLICT","message":"实例版本冲突,请刷新后重试"}`, http.StatusConflict) + default: + http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError) + } +} + +func (s *Server) blockLegacyWorkflowSwitch(w http.ResponseWriter, r *http.Request, projectID int64) bool { + proj, err := s.pwSvc.GetProject(r.Context(), projectID, 0) + if err == nil && proj != nil && proj.LifecycleStatus == "planning" && proj.ActiveWorkflowKey == "" { + http.Error(w, `{"error":"LEGACY_SWITCH_BLOCKED","message":"新项目请通过项目设置完成工作流激活"}`, http.StatusConflict) + return true + } + if s.instSvc == nil || !s.instSvc.Available() { + return false + } + live, err := s.instSvc.HasLiveInstance(r.Context(), projectID) + if err != nil { + http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError) + return true + } + if live { + http.Error(w, `{"error":"WORKFLOW_ALREADY_ACTIVE","message":"项目已有运行中的工作流实例,请使用项目设置查看流程"}`, http.StatusConflict) + return true + } + return false +} diff --git a/internal/server/project_workflow_api_test.go b/internal/server/project_workflow_api_test.go index f4fa240f..353fc87d 100644 --- a/internal/server/project_workflow_api_test.go +++ b/internal/server/project_workflow_api_test.go @@ -58,7 +58,8 @@ func TestProjectWorkflowAPI_Lifecycle(t *testing.T) { err := json.Unmarshal(recCreate.Body.Bytes(), &createdProj) require.NoError(t, err) assert.Equal(t, int64(2), createdProj.CreatorUserID) - assert.Equal(t, "main", createdProj.ActiveWorkflowKey) + assert.Equal(t, "planning", createdProj.LifecycleStatus) + assert.Equal(t, "", createdProj.ActiveWorkflowKey) assert.Equal(t, 1, createdProj.WorkflowSelectionVersion) // 3. 列出项目列表 -> 包含刚创建的项目 @@ -84,26 +85,51 @@ func TestProjectWorkflowAPI_Lifecycle(t *testing.T) { require.NoError(t, err) assert.Equal(t, 3, len(wfList)) - // 5. 非创建人尝试切换工作流 (User 3) -> 403 Forbidden - bodySwitch, _ := json.Marshal(projectworkflow.ActivateWorkflowInput{ + // 5. planning 项目禁止旧式切换 -> 409 Conflict + bodySwitchBlocked, _ := json.Marshal(projectworkflow.ActivateWorkflowInput{ + WorkflowKey: "product_manager", + ExpectedVersion: 1, + Reason: "planning 项目应走设置激活", + }) + reqSwitchBlocked := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/active-workflow", createdProj.ID), bytes.NewReader(bodySwitchBlocked)) + withUserIDAuth(reqSwitchBlocked, loginTestUser(t, handler, "zhanggong", "123456")) + recSwitchBlocked := httptest.NewRecorder() + handler.ServeHTTP(recSwitchBlocked, reqSwitchBlocked) + assert.Equal(t, http.StatusConflict, recSwitchBlocked.Code) + assert.Contains(t, recSwitchBlocked.Body.String(), "LEGACY_SWITCH_BLOCKED") + + // 6. 显式 initial_workflow_key 的 legacy 项目:ACL + 切换 + bodyLegacyCreate, _ := json.Marshal(projectworkflow.CreateProjectInput{ + Name: "Legacy 切换测试项目", + InitialWorkflowKey: "main", + }) + reqLegacyCreate := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(bodyLegacyCreate)) + withUserIDAuth(reqLegacyCreate, loginTestUser(t, handler, "zhanggong", "123456")) + recLegacyCreate := httptest.NewRecorder() + handler.ServeHTTP(recLegacyCreate, reqLegacyCreate) + require.Equal(t, http.StatusCreated, recLegacyCreate.Code) + var legacyProj projectworkflow.Project + require.NoError(t, json.Unmarshal(recLegacyCreate.Body.Bytes(), &legacyProj)) + assert.Equal(t, "main", legacyProj.ActiveWorkflowKey) + + bodySwitchOther, _ := json.Marshal(projectworkflow.ActivateWorkflowInput{ WorkflowKey: "product_manager", ExpectedVersion: 1, Reason: "非法切换", }) - reqSwitchOther := httptest.NewRequest(http.MethodPut, "/api/v1/projects/1001/active-workflow", bytes.NewReader(bodySwitch)) - withUserIDAuth(reqSwitchOther, loginTestUser(t, handler, "lizhuguan", "123456")) // Other user + reqSwitchOther := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/active-workflow", legacyProj.ID), bytes.NewReader(bodySwitchOther)) + withUserIDAuth(reqSwitchOther, loginTestUser(t, handler, "lizhuguan", "123456")) recSwitchOther := httptest.NewRecorder() handler.ServeHTTP(recSwitchOther, reqSwitchOther) assert.Equal(t, http.StatusForbidden, recSwitchOther.Code) - // 6. 创建人切换工作流 (User 2) -> 200 OK bodySwitchCreator, _ := json.Marshal(projectworkflow.ActivateWorkflowInput{ WorkflowKey: "product_manager", ExpectedVersion: 1, Reason: "切换至智能产品经理编制需求", }) - reqSwitchCreator := httptest.NewRequest(http.MethodPut, "/api/v1/projects/1001/active-workflow", bytes.NewReader(bodySwitchCreator)) - withUserIDAuth(reqSwitchCreator, loginTestUser(t, handler, "zhanggong", "123456")) // Creator + reqSwitchCreator := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/active-workflow", legacyProj.ID), bytes.NewReader(bodySwitchCreator)) + withUserIDAuth(reqSwitchCreator, loginTestUser(t, handler, "zhanggong", "123456")) recSwitchCreator := httptest.NewRecorder() handler.ServeHTTP(recSwitchCreator, reqSwitchCreator) require.Equal(t, http.StatusOK, recSwitchCreator.Code) @@ -120,7 +146,7 @@ func TestProjectWorkflowAPI_Lifecycle(t *testing.T) { ExpectedVersion: 1, Reason: "并发冲突测试", }) - reqSwitchConflict := httptest.NewRequest(http.MethodPut, "/api/v1/projects/1001/active-workflow", bytes.NewReader(bodySwitchConflict)) + reqSwitchConflict := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/active-workflow", legacyProj.ID), bytes.NewReader(bodySwitchConflict)) withUserIDAuth(reqSwitchConflict, loginTestUser(t, handler, "zhanggong", "123456")) recSwitchConflict := httptest.NewRecorder() handler.ServeHTTP(recSwitchConflict, reqSwitchConflict) diff --git a/internal/server/security_penetration_test.go b/internal/server/security_penetration_test.go new file mode 100644 index 00000000..df0ac4e2 --- /dev/null +++ b/internal/server/security_penetration_test.go @@ -0,0 +1,235 @@ +package server + +import ( + "bytes" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/config" +) + +// newStrictAuthServer 禁用 legacy 匿名 principal,用于安全负例测试。 +func newStrictAuthServer(t *testing.T) *Server { + t.Helper() + cfg := &config.Config{ + Env: "main", + DataDir: t.TempDir(), + AgentBackend: "stub", + AllowInMemory: false, + } + svc, err := bizdev.NewService(cfg) + if err != nil { + t.Fatalf("NewService: %v", err) + } + return NewServer(cfg, svc) +} + +func TestPenetration_AnonymousAdminTemplateUpload401(t *testing.T) { + srv := newStrictAuthServer(t) + + var body bytes.Buffer + w := multipart.NewWriter(&body) + _ = w.WriteField("asset_key", "test-template") + part, _ := w.CreateFormFile("file", "brief.md") + _, _ = part.Write([]byte("# ok")) + _ = w.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/templates", &body) + req.Header.Set("Content-Type", w.FormDataContentType()) + rec := httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestPenetration_AnonymousTaskRunMutations401(t *testing.T) { + srv := newStrictAuthServer(t) + + cases := []struct { + method string + path string + body string + }{ + {http.MethodPost, "/api/v1/tasks/runs", `{"task_type":"freeform"}`}, + {http.MethodPost, "/api/v1/tasks/runs/run_evil/cancel", ""}, + {http.MethodPost, "/api/v1/tasks/runs/run_evil/resume", `{"confirm":true}`}, + {http.MethodGet, "/api/v1/tasks/runs/run_evil/events", ""}, + } + for _, tc := range cases { + var reader *bytes.Reader + if tc.body != "" { + reader = bytes.NewReader([]byte(tc.body)) + } else { + reader = bytes.NewReader(nil) + } + req := httptest.NewRequest(tc.method, tc.path, reader) + if tc.body != "" { + req.Header.Set("Content-Type", "application/json") + } + rec := httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("%s %s expected 401 got %d", tc.method, tc.path, rec.Code) + } + } +} + +func TestPenetration_OversizedJSONBody413(t *testing.T) { + srv := &Server{} + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, err := io.ReadAll(r.Body); isRequestBodyTooLarge(err) { + w.WriteHeader(http.StatusRequestEntityTooLarge) + return + } + w.WriteHeader(http.StatusOK) + }) + handler := srv.bodyLimitMiddleware(inner) + + body := bytes.Repeat([]byte("x"), int(defaultJSONBodyLimit)+2048) + req := httptest.NewRequest(http.MethodPost, "/api/v1/chat", bytes.NewReader(body)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("expected 413, got %d", rec.Code) + } +} + +func TestPenetration_OversizedTemplateUpload413(t *testing.T) { + srv := &Server{} + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, err := io.ReadAll(r.Body); isRequestBodyTooLarge(err) { + w.WriteHeader(http.StatusRequestEntityTooLarge) + return + } + w.WriteHeader(http.StatusOK) + }) + handler := srv.bodyLimitMiddleware(inner) + + body := bytes.Repeat([]byte("a"), int(defaultUploadBodyLimit)+1024) + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/templates", bytes.NewReader(body)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("expected 413 for oversized template upload, got %d", rec.Code) + } +} + +func TestPenetration_ProjectAssetRejectsPathTraversalName(t *testing.T) { + cfg := &config.Config{Env: "test", DataDir: t.TempDir()} + srv := NewServer(cfg, bizdev.MustNewService(t, cfg)) + ts := httptest.NewServer(srv.Routes()) + defer ts.Close() + + var body bytes.Buffer + w := multipart.NewWriter(&body) + part, _ := w.CreateFormFile("file", "../../etc/passwd.txt") + _, _ = part.Write([]byte("not html")) + _ = w.Close() + + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/projects/7/assets", &body) + req.Header.Set("Content-Type", w.FormDataContentType()) + withBearerAuth(req, loginTestAdmin(t, srv)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 with sanitized basename, got %d", resp.StatusCode) + } +} + +func TestPenetration_ProjectAssetRejectsScriptContent(t *testing.T) { + cfg := &config.Config{Env: "test", DataDir: t.TempDir()} + srv := NewServer(cfg, bizdev.MustNewService(t, cfg)) + ts := httptest.NewServer(srv.Routes()) + defer ts.Close() + + var body bytes.Buffer + w := multipart.NewWriter(&body) + part, _ := w.CreateFormFile("file", "payload.txt") + _, _ = part.Write([]byte("#!/bin/bash\nrm -rf /")) + _ = w.Close() + + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/projects/7/assets", &body) + req.Header.Set("Content-Type", w.FormDataContentType()) + withBearerAuth(req, loginTestAdmin(t, srv)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnsupportedMediaType { + t.Fatalf("expected 415 for script content, got %d", resp.StatusCode) + } +} + +func TestPenetration_ReadyStrictWhenDBConfiguredButDown(t *testing.T) { + cfg := &config.Config{ + DataDir: t.TempDir(), + AgentBackend: "stub", + ContextDbHost: "127.0.0.1", + ContextDbPort: 59999, + AllowInMemory: false, + } + srv := NewServer(cfg, bizdev.MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"})) + srv.cfg = cfg + + req := httptest.NewRequest(http.MethodGet, "/api/v1/ready", nil) + rec := httptest.NewRecorder() + srv.handleReadiness(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503 ready when DB down, got %d %s", rec.Code, rec.Body.String()) + } +} + +func TestPenetration_DisallowedOriginNoWildcardCORS(t *testing.T) { + cfg := &config.Config{ + DataDir: t.TempDir(), + AgentBackend: "stub", + CORSAllowedOrigins: []string{"https://trusted.example.com"}, + } + srv := NewServer(cfg, bizdev.MustNewService(t, cfg)) + + req := httptest.NewRequest(http.MethodOptions, "/api/v1/projects", nil) + req.Header.Set("Origin", "https://attacker.example.com") + req.Header.Set("Access-Control-Request-Method", "GET") + rec := httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, req) + allowOrigin := rec.Header().Get("Access-Control-Allow-Origin") + if allowOrigin == "*" || allowOrigin == "https://attacker.example.com" { + t.Fatalf("CORS must not reflect attacker origin, got %q", allowOrigin) + } +} + +func TestPenetration_LongFilenameRejected415(t *testing.T) { + cfg := &config.Config{Env: "test", DataDir: t.TempDir()} + srv := NewServer(cfg, bizdev.MustNewService(t, cfg)) + ts := httptest.NewServer(srv.Routes()) + defer ts.Close() + + longName := strings.Repeat("z", 201) + ".md" + var body bytes.Buffer + w := multipart.NewWriter(&body) + part, _ := w.CreateFormFile("file", longName) + _, _ = part.Write([]byte("# x")) + _ = w.Close() + + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/projects/1/assets", &body) + req.Header.Set("Content-Type", w.FormDataContentType()) + withBearerAuth(req, loginTestAdmin(t, srv)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnsupportedMediaType { + t.Fatalf("expected 415, got %d", resp.StatusCode) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 4fc79724..d73001d4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -19,9 +19,12 @@ import ( "chat-agent-system/internal/coordinator" "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/intent" + "chat-agent-system/internal/notification" "chat-agent-system/internal/observability" + "chat-agent-system/internal/projectinstance" "chat-agent-system/internal/projectworkflow" "chat-agent-system/internal/taskrun" + "chat-agent-system/internal/workflowdef" "chat-agent-system/internal/workspace" ) @@ -38,6 +41,9 @@ type Server struct { taskRuns *taskrun.Manager authorizer *authz.Authorizer authzSvc *authz.Service + wfDefSvc *workflowdef.Service + instSvc *projectinstance.Service + notifySvc *notification.Service resolver *workspace.Resolver httpServer *http.Server } @@ -73,6 +79,18 @@ func NewServer(cfg *config.Config, svc *bizdev.Service) *Server { } authzSvc := authz.NewService(pool) + wfDefSvc := workflowdef.NewService(pool, svc.CAS()) + instStore := projectinstance.NewStore(pool) + var instSvc *projectinstance.Service + if wfDefSvc.Available() { + docChecker := projectinstance.NewDocGovChecker(svc.DocGov()) + instSvc = projectinstance.NewService(instStore, wfDefSvc.Store(), pwSvc.Store(), docChecker) + } + taskMgr := taskrun.NewManager(pwSvc, svc.ContextManager()) + if pool != nil { + taskMgr.SetStore(taskrun.NewPGStore(pool)) + } + s := &Server{ cfg: cfg, svc: svc, @@ -81,11 +99,19 @@ func NewServer(cfg *config.Config, svc *bizdev.Service) *Server { engine: coordEngine, authSvc: authSvc, sessions: make(map[string]*chat.Session), - taskRuns: taskrun.NewManager(pwSvc, svc.ContextManager()), + taskRuns: taskMgr, authorizer: authz.NewAuthorizer(pool), authzSvc: authzSvc, + wfDefSvc: wfDefSvc, + instSvc: instSvc, + notifySvc: notification.NewService(pool), resolver: workspace.NewResolver(cfg.DataDir, pool), } + if instSvc != nil && instSvc.Available() { + svc.SetStageAccessGate(newBizdevStageGate(instSvc, pwSvc)) + } + s.registerWorkflowApprovalHook() + s.startNotificationReminderLoop() s.initTaskRunExecutors() return s } @@ -100,6 +126,11 @@ func (s *Server) BizdevService() *bizdev.Service { return s.svc } +// TaskRuns 获取长任务管理器(测试与运维钩子)。 +func (s *Server) TaskRuns() *taskrun.Manager { + return s.taskRuns +} + // ProjectWorkflowService 获取项目多工作流控制服务 func (s *Server) ProjectWorkflowService() *projectworkflow.Service { return s.pwSvc @@ -112,6 +143,8 @@ func (s *Server) Routes() http.Handler { // 1. 健康检查与系统自检 mux.HandleFunc("GET /health", s.handleHealth) mux.HandleFunc("GET /api/v1/health", s.handleHealth) + mux.HandleFunc("GET /ready", s.handleReadiness) + mux.HandleFunc("GET /api/v1/ready", s.handleReadiness) // 2. 项目与多工作流控制面 mux.HandleFunc("POST /api/v1/projects", s.handleCreateProject) @@ -123,11 +156,20 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("DELETE /api/v1/projects/{id}/members", s.handleProjectMembers) mux.HandleFunc("GET /api/v1/projects/{id}/workflows", s.handleListProjectWorkflows) mux.HandleFunc("PUT /api/v1/projects/{id}/active-workflow", s.handleActivateWorkflow) + mux.HandleFunc("GET /api/v1/projects/{id}/settings", s.handleGetProjectSettings) + mux.HandleFunc("GET /api/v1/projects/{id}/workflow-plan/default", s.handleGetDefaultWorkflowPlan) + mux.HandleFunc("PUT /api/v1/projects/{id}/workflow-plan", s.handleSaveWorkflowPlan) + mux.HandleFunc("POST /api/v1/projects/{id}/activation-preview", s.handleActivationPreview) + mux.HandleFunc("POST /api/v1/projects/{id}/workflow-instances/activate", s.handleActivateWorkflowInstance) + mux.HandleFunc("GET /api/v1/workflow-instances/{id}", s.handleGetWorkflowInstance) + mux.HandleFunc("GET /api/v1/workflow-instances/{id}/stages/{stage}/readiness", s.handleGetStageReadiness) + mux.HandleFunc("POST /api/v1/workflow-instances/{id}/stages/{stage}/advance", s.handleAdvanceStage) mux.HandleFunc("GET /api/v1/workflows", s.handleListWorkflowDefinitions) mux.HandleFunc("POST /api/v1/projects/{id}/workflows/{workflow_key}/run", s.handleRunProjectWorkflow) mux.HandleFunc("GET /api/v1/projects/{id}/workflows/{workflow_key}/runs", s.handleListProjectWorkflowRuns) mux.HandleFunc("GET /api/v1/projects/{id}/lineage", s.handleGetProjectLineage) mux.HandleFunc("GET /api/v1/projects/{id}/documents", s.handleListProjectWorkflowDocuments) + mux.HandleFunc("GET /api/v1/projects/{id}/notifications", s.handleListProjectNotifications) // 3. 对话协同服务 (标准 JSON 阻塞) mux.HandleFunc("POST /api/v1/chat", s.handleChat) @@ -145,6 +187,7 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /api/v1/tasks/runs/{run_id}", s.handleGetTaskRun) mux.HandleFunc("GET /api/v1/tasks/runs/{run_id}/events", s.handleTaskRunEvents) mux.HandleFunc("POST /api/v1/tasks/runs/{run_id}/cancel", s.handleCancelTaskRun) + mux.HandleFunc("POST /api/v1/tasks/runs/{run_id}/resume", s.handleResumeTaskRun) mux.HandleFunc("POST /api/v1/admin/workspaces/reconcile", s.handleReconcileWorkspace) mux.HandleFunc("POST /api/v1/projects/{id}/assets", s.handleUploadProjectAsset) mux.HandleFunc("GET /api/v1/projects/{id}/assets", s.handleListProjectAssets) @@ -214,6 +257,16 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("POST /api/v1/admin/roles", s.handleAdminRoles) mux.HandleFunc("PATCH /api/v1/admin/roles/{id}", s.handleAdminRoleByID) mux.HandleFunc("DELETE /api/v1/admin/roles/{id}", s.handleAdminRoleByID) + mux.HandleFunc("GET /api/v1/admin/workflows", s.handleAdminWorkflows) + mux.HandleFunc("POST /api/v1/admin/workflows", s.handleAdminWorkflows) + mux.HandleFunc("GET /api/v1/admin/workflows/{id}", s.handleAdminWorkflowByID) + mux.HandleFunc("PATCH /api/v1/admin/workflows/{id}/versions/{version}", s.handleAdminWorkflowVersion) + mux.HandleFunc("POST /api/v1/admin/workflows/{id}/versions/{version}/validate", s.handleAdminWorkflowValidate) + mux.HandleFunc("POST /api/v1/admin/workflows/{id}/versions/{version}/publish", s.handleAdminWorkflowPublish) + mux.HandleFunc("POST /api/v1/admin/workflows/{id}/versions/{version}/retire", s.handleAdminWorkflowRetire) + mux.HandleFunc("GET /api/v1/admin/templates", s.handleAdminTemplates) + mux.HandleFunc("POST /api/v1/admin/templates", s.handleAdminTemplates) + mux.HandleFunc("GET /api/v1/admin/templates/{key}/versions", s.handleAdminTemplateVersions) mux.HandleFunc("GET /api/v1/admin/users", s.handleAdminUsers) mux.HandleFunc("POST /api/v1/admin/users", s.handleAdminUsers) mux.HandleFunc("PUT /api/v1/admin/users/{id}/role", s.handleAdminUpdateRole) @@ -224,7 +277,7 @@ func (s *Server) Routes() http.Handler { // 10. 现代 SPA 前端页面与静态资产托管 mux.HandleFunc("GET /", s.serveSPA) - return s.corsMiddleware(s.authMiddleware(mux)) + return s.corsMiddleware(s.bodyLimitMiddleware(s.authMiddleware(mux))) } // serveSPA 托管 web/dist 静态产物并提供前端路由自动回退 diff --git a/internal/server/sse.go b/internal/server/sse.go index d2c68e91..4bb43ddf 100644 --- a/internal/server/sse.go +++ b/internal/server/sse.go @@ -150,6 +150,7 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) { return } req.UserID = user.ID + req.WorkDir = "" // 1. 设置 SSE 响应头 + 请求追踪 ID requestID := fmt.Sprintf("req_%d", time.Now().UnixNano()) @@ -179,6 +180,11 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) { if req.ProjectID > 0 { sess.ProjectID = req.ProjectID } + s.enrichSessionWorkflowStage(ctx, sess, req.UserID) + if sess.ProjectID > 0 && !s.allowActiveStageChatAccess(ctx, sess.ProjectID, req.UserID) { + writeWorkflowAccessDeniedSSE(w, flusher, req.SessionID) + return + } if sess.UserCtx != nil { if req.ProjectName != "" && (sess.UserCtx.Project.Name == "" || sess.UserCtx.Project.Name == "医疗器械研发项目" || sess.UserCtx.Project.Name == "当前项目") { sess.UserCtx.Project.Name = req.ProjectName diff --git a/internal/server/stage_access_helpers.go b/internal/server/stage_access_helpers.go new file mode 100644 index 00000000..c69f3d26 --- /dev/null +++ b/internal/server/stage_access_helpers.go @@ -0,0 +1,172 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + + "chat-agent-system/internal/chat" + "chat-agent-system/internal/ctxmgr" + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/stream" +) + +func (s *Server) resolveStageAccess(ctx context.Context, projectID, userID int64) (*projectinstance.StageAccess, error) { + if s == nil || s.instSvc == nil || !s.instSvc.Available() || projectID <= 0 || userID <= 0 { + return &projectinstance.StageAccess{}, nil + } + isCreator, _ := s.pwSvc.IsProjectCreator(ctx, projectID, userID) + return s.instSvc.ResolveStageAccess(ctx, projectID, userID, isCreator) +} + +func (s *Server) allowWorkflowDocTaskAccess(ctx context.Context, projectID, userID int64, taskKey string) bool { + if taskKey == "" { + return true + } + access, err := s.resolveStageAccess(ctx, projectID, userID) + if err != nil || access == nil || !access.Restricted { + return true + } + return access.TaskKeyAllowed(taskKey) +} + +func (s *Server) allowAnyStageAccess(ctx context.Context, projectID, userID int64) bool { + access, err := s.resolveStageAccess(ctx, projectID, userID) + if err != nil || access == nil || !access.Restricted { + return true + } + return len(access.AllowedStageKeys) > 0 +} + +func (s *Server) allowActiveStageChatAccess(ctx context.Context, projectID, userID int64) bool { + access, err := s.resolveStageAccess(ctx, projectID, userID) + if err != nil || access == nil || !access.Restricted { + return true + } + return access.StageKeyAllowed(access.ActiveStageKey) +} + +func (s *Server) enrichSessionWorkflowStage(ctx context.Context, sess *chat.Session, userID int64) { + if sess == nil || sess.ProjectID <= 0 || s.instSvc == nil || !s.instSvc.Available() { + return + } + settings, err := s.instSvc.GetSettings(ctx, sess.ProjectID, userID, false) + if err != nil || settings == nil { + return + } + sess.ActiveStageKey = settings.ActiveStageKey +} + +func (s *Server) filterWorkflowDocsByStageAccess( + ctx context.Context, + projectID, userID int64, + docs []docgov.WorkflowDocumentSummary, +) []docgov.WorkflowDocumentSummary { + if len(docs) == 0 { + return docs + } + access, err := s.resolveStageAccess(ctx, projectID, userID) + if err != nil || access == nil || !access.Restricted { + return docs + } + out := make([]docgov.WorkflowDocumentSummary, 0, len(docs)) + for _, d := range docs { + if access.TaskKeyAllowed(d.TaskKey) { + out = append(out, d) + } + } + return out +} + +func (s *Server) filterChatMessagesByStageAccess( + ctx context.Context, + projectID, userID int64, + msgs []ctxmgr.ChatMessageRecord, +) []ctxmgr.ChatMessageRecord { + if len(msgs) == 0 || projectID <= 0 { + return msgs + } + access, err := s.resolveStageAccess(ctx, projectID, userID) + if err != nil || access == nil || !access.Restricted { + return msgs + } + out := make([]ctxmgr.ChatMessageRecord, 0, len(msgs)) + for _, msg := range msgs { + stageKey := projectinstance.MessageStageKey(msg.Metadata) + if stageKey == "" { + continue + } + if access.StageKeyAllowed(stageKey) { + out = append(out, msg) + } + } + return out +} + +func (s *Server) filterChatSearchHitsByStageAccess( + ctx context.Context, + projectID, userID int64, + hits []ctxmgr.ChatSearchHit, +) []ctxmgr.ChatSearchHit { + if len(hits) == 0 || projectID <= 0 { + return hits + } + access, err := s.resolveStageAccess(ctx, projectID, userID) + if err != nil || access == nil || !access.Restricted { + return hits + } + out := make([]ctxmgr.ChatSearchHit, 0, len(hits)) + for _, hit := range hits { + if hit.StageKey == "" { + continue + } + if access.StageKeyAllowed(hit.StageKey) { + out = append(out, hit) + } + } + return out +} + +func (s *Server) applyChatWindowStageFilter(ctx context.Context, projectID, userID int64, res *ctxmgr.ChatWindowResult) { + if res == nil || projectID <= 0 { + return + } + for i := range res.Days { + res.Days[i].Messages = s.filterChatMessagesByStageAccess(ctx, projectID, userID, res.Days[i].Messages) + res.Days[i].LoadedCount = len(res.Days[i].Messages) + res.Days[i].Count = len(res.Days[i].Messages) + if len(res.Days[i].Messages) > 0 { + res.Days[i].FirstMessageID = res.Days[i].Messages[0].ID + res.Days[i].LastMessageID = res.Days[i].Messages[len(res.Days[i].Messages)-1].ID + } else { + res.Days[i].FirstMessageID = 0 + res.Days[i].LastMessageID = 0 + } + } +} + +func workflowAccessDeniedJSON() string { + return `{"error":"WORKFLOW_ACCESS_DENIED","message":"无权访问该阶段内容"}` +} + +func (s *Server) writeWorkflowAccessDenied(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(workflowAccessDeniedJSON())) +} + +func writeWorkflowAccessDeniedSSE(w http.ResponseWriter, flusher http.Flusher, sessionID string) { + payload, _ := json.Marshal(stream.StreamEvent{ + Type: stream.EventError, + ErrorInfo: &stream.ErrorInfo{ + Code: "WORKFLOW_ACCESS_DENIED", + UserMessage: "无权访问该阶段对话内容", + SessionID: sessionID, + }, + }) + _, _ = w.Write([]byte("event: error\ndata: ")) + _, _ = w.Write(payload) + _, _ = w.Write([]byte("\n\n")) + flusher.Flush() +} diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index 94482a4b..f1de1a9d 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -4,12 +4,14 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net/http" "strconv" "strings" "time" + "chat-agent-system/internal/agent" "chat-agent-system/internal/bizdev" "chat-agent-system/internal/squad" "chat-agent-system/internal/stream" @@ -30,6 +32,15 @@ type createTaskRunResponse struct { Status string `json:"status"` } +type taskRunResponse struct { + taskrun.RunRecord + Resumable bool `json:"resumable"` +} + +type resumeTaskRunRequest struct { + Confirm bool `json:"confirm"` +} + func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { user, ok := s.requirePrincipal(w, r) if !ok { @@ -209,6 +220,7 @@ func (s *Server) cancelPiAgentRunner(workspaceID string) { return } req.Header.Set("Content-Type", "application/json") + agent.ApplyRunnerAuth(req, s.cfg.PiAgentRunnerAuthToken) client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Do(req) if err != nil { @@ -231,7 +243,111 @@ func (s *Server) handleGetTaskRun(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusNotFound) return } - writeJSONResponse(w, http.StatusOK, rec) + writeJSONResponse(w, http.StatusOK, s.buildTaskRunResponse(rec)) +} + +func (s *Server) handleResumeTaskRun(w http.ResponseWriter, r *http.Request) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + runID := r.PathValue("run_id") + if runID == "" { + http.Error(w, "run_id required", http.StatusBadRequest) + return + } + if !s.assertTaskRunOwnedByUser(w, r.Context(), runID, user) { + return + } + + var req resumeTaskRunRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) + return + } + if !req.Confirm { + writeJSONResponse(w, http.StatusBadRequest, map[string]string{ + "error_code": "ERR_CONFIRM_REQUIRED", + "message": "续跑 interrupted 任务需要 confirm=true", + }) + return + } + + rec, err := s.taskRuns.GetRun(runID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + if rec.Status != "interrupted" { + writeJSONResponse(w, http.StatusConflict, map[string]string{ + "error_code": "ERR_NOT_RESUMABLE", + "message": "仅 interrupted 状态且存在 workspace 的任务可续跑", + }) + return + } + if rec.WorkspaceID == "" { + writeJSONResponse(w, http.StatusConflict, map[string]string{ + "error_code": "ERR_NO_WORKSPACE", + "message": "任务缺少可恢复的工作区", + }) + return + } + if s.resolver == nil { + http.Error(w, "workspace resolver unavailable", http.StatusServiceUnavailable) + return + } + workDir, werr := s.resolver.ResolveStoredWorkDir(rec.WorkspaceID) + if werr != nil { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_WORKSPACE_FORBIDDEN", + "message": "工作区路径无效或越界", + }) + return + } + _ = workDir // resume path validated; executor receives stored workspace id + + taskReq := s.buildBizdevTaskRequest(TaskRunRequestDTO{ + UserID: rec.UserID, + ProjectID: rec.ProjectID, + TaskKey: rec.TaskKey, + }, rec.SessionID, rec.ClientTurnID, rec.RunID) + stageReq := s.buildBizdevStageRequest(TaskRunRequestDTO{ + UserID: rec.UserID, + ProjectID: rec.ProjectID, + Stage: rec.Stage, + }, rec.SessionID, rec.ClientTurnID, rec.RunID) + + resumed, err := s.taskRuns.ResumeInterrupted(r.Context(), rec, rec.WorkspaceID, taskReq, stageReq) + if err != nil { + if errors.Is(err, taskrun.ErrRunNotResumable) || errors.Is(err, taskrun.ErrRunAlreadyActive) { + writeJSONResponse(w, http.StatusConflict, map[string]string{ + "error_code": "ERR_NOT_RESUMABLE", + "message": err.Error(), + }) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSONResponse(w, http.StatusAccepted, createTaskRunResponse{ + RunID: resumed.RunID, + WorkspaceID: resumed.WorkspaceID, + Status: resumed.Status, + }) +} + +func (s *Server) buildTaskRunResponse(rec *taskrun.RunRecord) taskRunResponse { + resumable := rec != nil && rec.Status == "interrupted" && rec.WorkspaceID != "" + if resumable && s.resolver != nil { + if _, err := s.resolver.ResolveStoredWorkDir(rec.WorkspaceID); err != nil { + resumable = false + } + } + if rec == nil { + return taskRunResponse{} + } + return taskRunResponse{RunRecord: *rec, Resumable: resumable} } func (s *Server) buildBizdevTaskRequest(dto TaskRunRequestDTO, sessionID, clientTurnID, runID string) bizdev.TaskRunRequest { diff --git a/internal/server/task_run_persistence_pg_test.go b/internal/server/task_run_persistence_pg_test.go new file mode 100644 index 00000000..65d8e566 --- /dev/null +++ b/internal/server/task_run_persistence_pg_test.go @@ -0,0 +1,150 @@ +package server_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/stream" + "chat-agent-system/internal/taskrun" +) + +func TestTaskRun_PersistenceAndReplayAfterRestart_PG(t *testing.T) { + pool := openPGPoolWithMigrations(t) + if pool == nil { + return + } + defer pool.Close() + if !taskrun.TablesReady(context.Background(), pool) { + t.Skip("task_runs table not ready") + } + + handler, srv := setupPGGovernanceServer(t, pool) + token := loginTestUser(t, handler, "zhanggong", "123456") + + runID := "run_pg_persist_" + time.Now().Format("150405") + store := taskrun.NewPGStore(pool) + srv.TaskRuns().SetStore(store) + + srv.TaskRuns().SetExecutor( + func(_ context.Context, _ string, _ bizdev.TaskRunRequest, st *stream.Streamer) (*bizdev.TaskRunResult, error) { + if st != nil { + st.EmitPhase("test", "running", "step1", 1, 1, 50) + } + return &bizdev.TaskRunResult{Success: true, WorkDir: t.TempDir(), TaskKey: "intake"}, nil + }, + nil, + nil, + ) + srv.TaskRuns().SetFinalizer(func(ctx context.Context, record *taskrun.RunRecord, result *bizdev.TaskRunResult) (string, error) { + return srv.FinalizeTaskRunProjection(ctx, record, result) + }) + + record := &taskrun.RunRecord{ + RunID: runID, SessionID: "sess_pg_persist", ProjectID: 0, + UserID: fetchAuthUserID(t, handler, token), TaskKey: "intake", Status: "running", + } + _, err := srv.TaskRuns().CreateAndStart(context.Background(), runID, record, bizdev.TaskRunRequest{TaskKey: "intake"}, bizdev.StageRunRequest{}, "") + if err != nil { + t.Fatalf("CreateAndStart: %v", err) + } + + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + rec, getErr := store.GetRun(context.Background(), runID) + if getErr == nil && rec.Status == "completed" { + break + } + time.Sleep(50 * time.Millisecond) + } + + persisted, err := store.GetRun(context.Background(), runID) + if err != nil { + t.Fatalf("GetRun from store: %v", err) + } + if persisted.Status != "completed" { + t.Fatalf("expected completed, got %s", persisted.Status) + } + + events, err := store.ListEvents(context.Background(), runID, 0) + if err != nil { + t.Fatalf("ListEvents: %v", err) + } + if len(events) == 0 { + t.Fatal("expected persisted events") + } + hasDone := false + for _, ev := range events { + if ev.Event.Type == stream.EventDone { + hasDone = true + } + } + if !hasDone { + t.Fatal("expected done event in persisted events") + } + + // 模拟进程重启:新 Manager 仅挂载 Store + restarted := taskrun.NewManager(srv.ProjectWorkflowService(), srv.BizdevService().ContextManager()) + restarted.SetStore(store) + + reloaded, err := restarted.GetRun(runID) + if err != nil { + t.Fatalf("GetRun after restart: %v", err) + } + if reloaded.Status != "completed" { + t.Fatalf("reloaded status=%s", reloaded.Status) + } + + ch, replay, err := restarted.Subscribe(runID, 0) + if err != nil { + t.Fatalf("Subscribe after restart: %v", err) + } + if len(replay) == 0 { + t.Fatal("expected replay events from DB") + } + _, ok := <-ch + if ok { + t.Fatal("replay-only channel should be closed") + } + + // HTTP GET 鉴权 + 查询 + req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/runs/"+runID, nil) + withBearerAuth(req, token) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET task run: %d %s", rec.Code, rec.Body.String()) + } + var body taskrun.RunRecord + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.RunID != runID { + t.Fatalf("run_id=%s", body.RunID) + } +} + +func TestReadiness_StrictWhenPGConfigured(t *testing.T) { + pool := openPGPoolWithMigrations(t) + if pool == nil { + return + } + defer pool.Close() + + handler, _ := setupPGGovernanceServer(t, pool) + req := httptest.NewRequest(http.MethodGet, "/api/v1/ready", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("ready expected 200, got %d %s", rec.Code, rec.Body.String()) + } + var resp map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &resp) + if resp["task_store_ready"] != true { + t.Fatalf("task_store_ready=%v", resp["task_store_ready"]) + } +} diff --git a/internal/server/task_run_resume_pg_test.go b/internal/server/task_run_resume_pg_test.go new file mode 100644 index 00000000..7e536c94 --- /dev/null +++ b/internal/server/task_run_resume_pg_test.go @@ -0,0 +1,181 @@ +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/server" + "chat-agent-system/internal/stream" + "chat-agent-system/internal/taskrun" +) + +func TestTaskRun_ResumeInterrupted_PG(t *testing.T) { + pool := openPGPoolWithMigrations(t) + if pool == nil { + return + } + defer pool.Close() + + dataDir := t.TempDir() + cfg := &config.Config{ + Env: "dev", DataDir: dataDir, 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) + srv := server.NewServer(cfg, svc) + handler := srv.Routes() + token := loginTestUser(t, handler, "zhanggong", "123456") + userID := fetchAuthUserID(t, handler, token) + + workDir := filepath.Join(dataDir, "workspaces", "bizdev", "resume_test") + if err := os.MkdirAll(workDir, 0o755); err != nil { + t.Fatal(err) + } + _ = os.WriteFile(filepath.Join(workDir, "output.md"), []byte("# resumed\n"), 0o644) + + store := taskrun.NewPGStore(pool) + runID := "run_resume_" + time.Now().Format("150405.000000") + _, err := pool.Exec(context.Background(), ` + INSERT INTO task_runs ( + run_id, session_id, user_id, task_key, status, workspace_id, error_message, created_at, updated_at + ) VALUES ($1, 'sess_resume', $2, 'intake', 'interrupted', $3, 'worker lease expired', now(), now()) + `, runID, userID, workDir) + if err != nil { + t.Fatalf("seed interrupted run: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM task_runs WHERE run_id = $1`, runID) + }) + + srv.TaskRuns().SetExecutor( + func(_ context.Context, _ string, _ bizdev.TaskRunRequest, _ *stream.Streamer) (*bizdev.TaskRunResult, error) { + return &bizdev.TaskRunResult{ + Success: true, WorkDir: workDir, TaskKey: "intake", ArtifactTitle: "Intake", + }, nil + }, + nil, nil, + ) + srv.TaskRuns().SetFinalizer(func(ctx context.Context, record *taskrun.RunRecord, result *bizdev.TaskRunResult) (string, error) { + return srv.FinalizeTaskRunProjection(ctx, record, result) + }) + srv.TaskRuns().SetStore(store) + + body, _ := json.Marshal(map[string]bool{"confirm": true}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/runs/"+runID+"/resume", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + withBearerAuth(req, token) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusAccepted { + t.Fatalf("resume expected 202, got %d %s", rec.Code, rec.Body.String()) + } + + deadline := time.Now().Add(8 * time.Second) + for time.Now().Before(deadline) { + got, getErr := store.GetRun(context.Background(), runID) + if getErr == nil && got.Status == "completed" { + if got.Attempt < 2 { + t.Fatalf("expected attempt>=2 after resume, got %d", got.Attempt) + } + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatal("resume did not complete") +} + +func TestTaskRun_ResumeRequiresConfirm_PG(t *testing.T) { + pool := openPGPoolWithMigrations(t) + if pool == nil { + return + } + defer pool.Close() + + dataDir := t.TempDir() + cfg := &config.Config{ + Env: "dev", DataDir: dataDir, 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", + } + srv := server.NewServer(cfg, bizdev.MustNewService(t, cfg)) + handler := srv.Routes() + token := loginTestUser(t, handler, "zhanggong", "123456") + userID := fetchAuthUserID(t, handler, token) + workDir := filepath.Join(dataDir, "ws") + _ = os.MkdirAll(workDir, 0o755) + + runID := "run_no_confirm_" + time.Now().Format("150405.000000") + _, _ = pool.Exec(context.Background(), ` + INSERT INTO task_runs (run_id, session_id, user_id, task_key, status, workspace_id, created_at, updated_at) + VALUES ($1, 's', $2, 'intake', 'interrupted', $3, now(), now()) + `, runID, userID, workDir) + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM task_runs WHERE run_id = $1`, runID) + }) + _ = srv + + req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/runs/"+runID+"/resume", bytes.NewReader([]byte(`{"confirm":false}`))) + req.Header.Set("Content-Type", "application/json") + withBearerAuth(req, token) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 without confirm, got %d", rec.Code) + } +} + +func TestTaskRun_GetRunShowsResumable_PG(t *testing.T) { + pool := openPGPoolWithMigrations(t) + if pool == nil { + return + } + defer pool.Close() + + dataDir := t.TempDir() + cfg := &config.Config{ + Env: "dev", DataDir: dataDir, 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", + } + handler := server.NewServer(cfg, bizdev.MustNewService(t, cfg)).Routes() + token := loginTestUser(t, handler, "zhanggong", "123456") + userID := fetchAuthUserID(t, handler, token) + workDir := filepath.Join(dataDir, "workspaces", "bizdev", "resumable_flag") + _ = os.MkdirAll(workDir, 0o755) + + runID := "run_flag_" + time.Now().Format("150405.000000") + _, _ = pool.Exec(context.Background(), ` + INSERT INTO task_runs (run_id, session_id, user_id, task_key, status, workspace_id, created_at, updated_at) + VALUES ($1, 's', $2, 'intake', 'interrupted', $3, now(), now()) + `, runID, userID, workDir) + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM task_runs WHERE run_id = $1`, runID) + }) + + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/tasks/runs/%s", runID), nil) + withBearerAuth(req, token) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET run: %d", rec.Code) + } + var body map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &body) + if body["resumable"] != true { + t.Fatalf("expected resumable=true, got %v", body["resumable"]) + } +} diff --git a/internal/server/workflow_approval_hook.go b/internal/server/workflow_approval_hook.go new file mode 100644 index 00000000..a38db097 --- /dev/null +++ b/internal/server/workflow_approval_hook.go @@ -0,0 +1,45 @@ +package server + +import ( + "context" + + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/notification" +) + +func (s *Server) registerWorkflowApprovalHook() { + if s == nil || s.svc == nil || s.svc.DocGov() == nil || s.instSvc == nil || !s.instSvc.Available() { + return + } + s.svc.DocGov().AddOnApprovedHook(s.onWorkflowDocumentApproved) +} + +func (s *Server) onWorkflowDocumentApproved(ctx context.Context, doc *docgov.DocumentRecord) { + if doc == nil || doc.TaskKey == "" || doc.ProjectID <= 0 || s.instSvc == nil { + return + } + + result, err := s.instSvc.HandleDocumentApproved(ctx, doc.ProjectID, doc.TaskKey) + if err != nil || result == nil || !result.StageReady { + return + } + if s.notifySvc == nil || !s.notifySvc.Available() { + return + } + + project, err := s.pwSvc.GetProject(ctx, doc.ProjectID, 0) + if err != nil || project == nil || project.CreatorUserID <= 0 { + return + } + + _, _ = s.notifySvc.EmitStageReadyToAdvance(ctx, project.CreatorUserID, notification.StageReadyPayload{ + ProjectID: doc.ProjectID, + WorkflowInstanceID: result.InstanceID, + StageInstanceID: result.StageInstanceID, + StageKey: result.StageKey, + StageName: result.StageName, + ConfigRevision: result.ConfigRevision, + RequiredDocCount: result.RequiredDocCount, + ApprovedDocCount: result.ApprovedDocCount, + }) +} diff --git a/internal/server/workflow_governance_pg_test.go b/internal/server/workflow_governance_pg_test.go new file mode 100644 index 00000000..cdcc2793 --- /dev/null +++ b/internal/server/workflow_governance_pg_test.go @@ -0,0 +1,307 @@ +package server_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "chat-agent-system/internal/auth" + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/config" + "chat-agent-system/internal/db" + "chat-agent-system/internal/notification" + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/server" + "chat-agent-system/internal/workflowdef" +) + +func openPGPoolWithMigrations(t *testing.T) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + dsn = "postgres://app:dev_secret_db_pass@127.0.0.1:5433/agent_system_dev?sslmode=disable" + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Skipf("postgres unavailable: %v", err) + return nil + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + t.Skipf("postgres ping failed: %v", err) + return nil + } + migCtx, migCancel := context.WithTimeout(context.Background(), 60*time.Second) + defer migCancel() + if err := db.RunMigrations(migCtx, pool, db.ResolveMigrationsDir()); err != nil { + pool.Close() + t.Skipf("migration failed: %v", err) + return nil + } + if !projectinstance.TablesReady(ctx, pool) || !workflowdef.TablesReady(ctx, pool) { + pool.Close() + t.Skip("workflow/project instance tables not ready") + } + if auth.IAMTablesReady(ctx, pool) { + authStore, err := auth.NewPgStore(pool, &config.Config{Env: "dev"}) + if err == nil { + _ = auth.ReactivateDevSeedUsers(ctx, authStore, &config.Config{Env: "dev"}) + } + } + return pool +} + +func fetchAuthUserID(t *testing.T, handler http.Handler, token string) int64 { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + withBearerAuth(req, token) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("auth/me: %d %s", rec.Code, rec.Body.String()) + } + var me struct { + User struct { + ID int64 `json:"id"` + } `json:"user"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &me); err != nil { + t.Fatalf("decode auth/me: %v", err) + } + if me.User.ID <= 0 { + t.Fatal("expected positive user id from auth/me") + } + return me.User.ID +} + +func setupPGGovernanceServer(t *testing.T, pool *pgxpool.Pool) (http.Handler, *server.Server) { + t.Helper() + cfg := &config.Config{ + 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) + if svc.Pool() == nil { + t.Skip("bizdev service not connected to postgres") + } + srv := server.NewServer(cfg, svc) + return srv.Routes(), srv +} + +func TestWorkflowGovernance_PG_ActivateApproveAdvanceChain(t *testing.T) { + pool := openPGPoolWithMigrations(t) + if pool == nil { + return + } + defer pool.Close() + + ctx := context.Background() + handler, srv := setupPGGovernanceServer(t, pool) + + wfSvc := workflowdef.NewService(pool, nil) + wfKey := "pg_p5_gov_" + time.Now().Format("150405.000") + _, 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") + guestToken := loginTestUser(t, handler, "guest", "123456") + creatorID := fetchAuthUserID(t, handler, creatorToken) + + // 1. 创建项目(PG 直写,与 projectinstance PG 集成测试一致) + var projectID int64 + code := "pg-p5-" + time.Now().Format("150405.000") + err = pool.QueryRow(ctx, ` + 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 + `, code, creatorID).Scan(&projectID) + if err != nil { + t.Fatalf("insert project: %v", err) + } + _, err = pool.Exec(ctx, ` + INSERT INTO project_members (project_id, user_id, project_role, status, joined_at, updated_at) + VALUES ($1, $2, 'owner', 'active', now(), now()) + ON CONFLICT (project_id, user_id) DO NOTHING + `, projectID, creatorID) + if err != nil { + t.Fatalf("insert project member: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) + }) + // 2. 获取默认规划并保存三权 + reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=1", projectID, wfKey), nil) + withBearerAuth(reqDefault, creatorToken) + recDefault := httptest.NewRecorder() + handler.ServeHTTP(recDefault, reqDefault) + if recDefault.Code != http.StatusOK { + t.Fatalf("default plan: %d %s", recDefault.Code, recDefault.Body.String()) + } + var plan projectinstance.WorkflowPlan + if err := json.Unmarshal(recDefault.Body.Bytes(), &plan); err != nil { + t.Fatalf("decode plan: %v", err) + } + reviewerID := fetchAuthUserID(t, handler, loginTestUser(t, handler, "lizhuguan", "123456")) + approverID := fetchAuthUserID(t, handler, loginTestUser(t, handler, "wangzong", "123456")) + for i := range plan.Stages { + for j := range plan.Stages[i].DocTasks { + plan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{ + AuthorUserID: creatorID, ReviewerUserID: reviewerID, ApproverUserID: approverID, + } + } + } + saveBody, _ := json.Marshal(projectinstance.SavePlanInput{ + Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + reqSave := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/workflow-plan", projectID), bytes.NewReader(saveBody)) + withBearerAuth(reqSave, creatorToken) + recSave := httptest.NewRecorder() + handler.ServeHTTP(recSave, reqSave) + if recSave.Code != http.StatusOK { + t.Fatalf("save plan: %d %s", recSave.Code, recSave.Body.String()) + } + + // 3. 激活预览 + 确认激活 + reqPreview := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/activation-preview", projectID), nil) + withBearerAuth(reqPreview, creatorToken) + recPreview := httptest.NewRecorder() + handler.ServeHTTP(recPreview, reqPreview) + if recPreview.Code != http.StatusOK { + t.Fatalf("activation preview: %d %s", recPreview.Code, recPreview.Body.String()) + } + var preview projectinstance.ActivationPreview + if err := json.Unmarshal(recPreview.Body.Bytes(), &preview); err != nil { + t.Fatalf("decode preview: %v", err) + } + activateBody, _ := json.Marshal(projectinstance.ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + reqActivate := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/workflow-instances/activate", projectID), bytes.NewReader(activateBody)) + withBearerAuth(reqActivate, creatorToken) + recActivate := httptest.NewRecorder() + handler.ServeHTTP(recActivate, reqActivate) + if recActivate.Code != http.StatusOK { + t.Fatalf("activate: %d %s", recActivate.Code, recActivate.Body.String()) + } + var activated projectinstance.ActivateResult + if err := json.Unmarshal(recActivate.Body.Bytes(), &activated); err != nil { + t.Fatalf("decode activate: %v", err) + } + instanceID := activated.Instance.ID + + // 4. 文档批准链(经 bizdev DocGov 引擎,触发 onWorkflowDocumentApproved hook) + docID := fmt.Sprintf("doc_pg_p5_intake_%d", projectID) + docEngine := srv.BizdevService().DocGov() + _, err = docEngine.CreateOrGetDocument(ctx, docID, projectID, "intake", "Intake Doc", "V0.1", "", creatorID, "Author", reviewerID, "Reviewer", approverID, "Approver") + if err != nil { + t.Fatalf("create doc: %v", err) + } + _ = docEngine.MarkDocumentContentUpdated(ctx, docID, "hash_pg_p5", 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) + } + + // 5. 创建者应收到 stage ready 通知 + reqNotify := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/notifications", projectID), nil) + withBearerAuth(reqNotify, creatorToken) + recNotify := httptest.NewRecorder() + handler.ServeHTTP(recNotify, reqNotify) + if recNotify.Code != http.StatusOK { + t.Fatalf("list notifications: %d %s", recNotify.Code, recNotify.Body.String()) + } + var notifyResp struct { + Items []map[string]any `json:"items"` + } + if err := json.Unmarshal(recNotify.Body.Bytes(), ¬ifyResp); err != nil { + t.Fatalf("decode notifications: %v body=%s", err, recNotify.Body.String()) + } + foundReady := false + for _, n := range notifyResp.Items { + if n["type"] == notification.TypeStageReadyToAdvance { + foundReady = true + break + } + } + if !foundReady { + t.Fatalf("expected stage_ready_to_advance notification, got %+v", notifyResp.Items) + } + + // 6. ABAC:访客无权读取项目上下文 + reqCtxGuest := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/context/project?project_id=%d", projectID), nil) + withBearerAuth(reqCtxGuest, guestToken) + recCtxGuest := httptest.NewRecorder() + handler.ServeHTTP(recCtxGuest, reqCtxGuest) + if recCtxGuest.Code != http.StatusForbidden { + t.Fatalf("guest project context expected 403, got %d %s", recCtxGuest.Code, recCtxGuest.Body.String()) + } + + // 7. 创建者推进阶段 + advanceBody, _ := json.Marshal(projectinstance.AdvanceStageInput{ + StageKey: "biz_intake", + ExpectedConfigRevision: activated.Instance.ConfigRevision, + }) + reqAdvance := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/workflow-instances/%d/stages/biz_intake/advance", instanceID), bytes.NewReader(advanceBody)) + withBearerAuth(reqAdvance, creatorToken) + recAdvance := httptest.NewRecorder() + handler.ServeHTTP(recAdvance, reqAdvance) + if recAdvance.Code != http.StatusOK { + t.Fatalf("advance stage: %d %s", recAdvance.Code, recAdvance.Body.String()) + } + var advanceResult projectinstance.AdvanceStageResult + if err := json.Unmarshal(recAdvance.Body.Bytes(), &advanceResult); err != nil { + t.Fatalf("decode advance: %v", err) + } + if advanceResult.NextStageKey != "biz_feasibility" && !advanceResult.InstanceDone { + t.Fatalf("unexpected advance result: %+v", advanceResult) + } + +} diff --git a/internal/server/workflow_governance_piagent_pg_test.go b/internal/server/workflow_governance_piagent_pg_test.go new file mode 100644 index 00000000..279b7d5e --- /dev/null +++ b/internal/server/workflow_governance_piagent_pg_test.go @@ -0,0 +1,284 @@ +package server_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/config" + "chat-agent-system/internal/notification" + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/projectworkflow" + "chat-agent-system/internal/server" + "chat-agent-system/internal/squad" + "chat-agent-system/internal/workflowdef" +) + +func piAgentRunnerHealthy() bool { + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Get("http://127.0.0.1:18865/health") + if err != nil { + return false + } + resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +func setupPGPiAgentServer(t *testing.T, pool *pgxpool.Pool) (http.Handler, *server.Server) { + t.Helper() + cfg := &config.Config{ + Env: "dev", + DataDir: t.TempDir(), + AgentBackend: "pi-agent", + PiAgentRunnerURL: "http://127.0.0.1:18865", + 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) + if svc.Pool() == nil { + t.Skip("bizdev service not connected to postgres") + } + srv := server.NewServer(cfg, svc) + return srv.Routes(), srv +} + +// TestWorkflowGovernance_PiAgent_PG_FullChain +// PG 治理链 + pi-agent 真实 Agent:activate → pi-agent intake → approve → advance +// +// go test ./internal/server/ -run TestWorkflowGovernance_PiAgent_PG -timeout 1h -v +func TestWorkflowGovernance_PiAgent_PG_FullChain(t *testing.T) { + if testing.Short() { + t.Skip("skipping slow pi-agent PG E2E in short mode") + } + if !piAgentRunnerHealthy() { + t.Skip("pi-agent runner not available at http://127.0.0.1:18865/health") + } + + pool := openPGPoolWithMigrations(t) + if pool == nil { + return + } + defer pool.Close() + + ctx := context.Background() + handler, srv := setupPGPiAgentServer(t, pool) + ts := httptest.NewServer(handler) + defer ts.Close() + client := &http.Client{Timeout: 50 * time.Minute} + + wfSvc := workflowdef.NewService(pool, nil) + wfKey := "pg_pi_p5_" + time.Now().Format("150405.000") + _, ver, err := wfSvc.CreateDefinition(ctx, workflowdef.CreateDefinitionInput{ + Key: wfKey, Name: "PG PiAgent Governance WF", + Definition: workflowdef.VersionDefinition{ + SchemaVersion: 1, + Stages: []workflowdef.StageDef{{ + Key: "biz_intake", Name: "Intake", Position: 0, + DocTasks: []workflowdef.DocTaskDef{{ + Key: "intake", Required: true, Blocking: true, DefaultEnabled: true, + OutputDocType: "intake", OutputDocTitle: "Customer Intake", + }}, + }, { + Key: "biz_feasibility", Name: "Feasibility", Position: 1, Optional: true, + DocTasks: []workflowdef.DocTaskDef{{ + Key: "feasibility_reg", Required: false, Blocking: false, DefaultEnabled: true, + OutputDocType: "feasibility", OutputDocTitle: "Regulatory Path", + }}, + }}, + }, + }, 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") + reviewerToken := loginTestUser(t, handler, "lizhuguan", "123456") + approverToken := loginTestUser(t, handler, "wangzong", "123456") + creatorID := fetchAuthUserID(t, handler, creatorToken) + reviewerID := fetchAuthUserID(t, handler, reviewerToken) + approverID := fetchAuthUserID(t, handler, approverToken) + + // HTTP 创建中文名项目(验证 UTF-8 修复) + createBody, _ := json.Marshal(projectworkflow.CreateProjectInput{ + Name: "PG PiAgent 治理链中文项目", + DeviceType: "Ultrasonic Device", + IntendedUse: "Tissue resection", + TargetMarket: "NMPA Class III", + }) + reqCreate := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody)) + withBearerAuth(reqCreate, creatorToken) + recCreate := httptest.NewRecorder() + handler.ServeHTTP(recCreate, reqCreate) + if recCreate.Code != http.StatusCreated { + t.Fatalf("create project: %d %s", recCreate.Code, recCreate.Body.String()) + } + var proj projectworkflow.Project + if err := json.Unmarshal(recCreate.Body.Bytes(), &proj); err != nil { + t.Fatalf("decode project: %v", err) + } + t.Cleanup(func() { cleanupProject(t, pool, proj.ID) }) + projectID := proj.ID + t.Logf("✓ [1. 项目创建] id=%d code=%s", projectID, proj.Code) + + // 规划 + 激活 + reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=1", projectID, wfKey), nil) + withBearerAuth(reqDefault, creatorToken) + recDefault := httptest.NewRecorder() + handler.ServeHTTP(recDefault, reqDefault) + if recDefault.Code != http.StatusOK { + t.Fatalf("default plan: %d %s", recDefault.Code, recDefault.Body.String()) + } + var plan projectinstance.WorkflowPlan + _ = json.Unmarshal(recDefault.Body.Bytes(), &plan) + for i := range plan.Stages { + for j := range plan.Stages[i].DocTasks { + plan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{ + AuthorUserID: creatorID, ReviewerUserID: reviewerID, ApproverUserID: approverID, + } + } + } + saveBody, _ := json.Marshal(projectinstance.SavePlanInput{Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1}) + reqSave := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/workflow-plan", projectID), bytes.NewReader(saveBody)) + withBearerAuth(reqSave, creatorToken) + recSave := httptest.NewRecorder() + handler.ServeHTTP(recSave, reqSave) + if recSave.Code != http.StatusOK { + t.Fatalf("save plan: %d %s", recSave.Code, recSave.Body.String()) + } + + reqPreview := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/activation-preview", projectID), nil) + withBearerAuth(reqPreview, creatorToken) + recPreview := httptest.NewRecorder() + handler.ServeHTTP(recPreview, reqPreview) + var preview projectinstance.ActivationPreview + _ = json.Unmarshal(recPreview.Body.Bytes(), &preview) + + activateBody, _ := json.Marshal(projectinstance.ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + reqActivate := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/workflow-instances/activate", projectID), bytes.NewReader(activateBody)) + withBearerAuth(reqActivate, creatorToken) + recActivate := httptest.NewRecorder() + handler.ServeHTTP(recActivate, reqActivate) + if recActivate.Code != http.StatusOK { + t.Fatalf("activate: %d %s", recActivate.Code, recActivate.Body.String()) + } + var activated projectinstance.ActivateResult + _ = json.Unmarshal(recActivate.Body.Bytes(), &activated) + instanceID := activated.Instance.ID + t.Logf("✓ [2. 工作流激活] instance_id=%d", instanceID) + + // pi-agent 执行 intake(精简单步 WBS) + taskStart := time.Now() + taskBody, _ := json.Marshal(server.TaskRunRequestDTO{ + TaskKey: "intake", + ProjectName: proj.Name, + DeviceType: proj.DeviceType, + IntendedUse: "Tissue resection", + TargetMarket: "NMPA Class III", + Backend: "pi-agent", + ProjectID: projectID, + AuthorID: creatorID, + ReviewerID: reviewerID, + ApproverID: approverID, + CustomSteps: []squad.StepSpec{{ + ID: "step_01_intake", Role: "writer", Title: "Intake snapshot", + SubWorkspace: "tasks/01_intake", Mode: "execute", + Accept: []string{"Deliver output.md with project intake summary"}, + }}, + }) + reqTask, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/tasks/run", bytes.NewReader(taskBody)) + withBearerAuth(reqTask, creatorToken) + respTask, err := client.Do(reqTask) + if err != nil { + t.Fatalf("pi-agent task run: %v", err) + } + defer respTask.Body.Close() + if respTask.StatusCode != http.StatusOK { + var buf bytes.Buffer + _, _ = buf.ReadFrom(respTask.Body) + t.Fatalf("pi-agent task HTTP %d: %s", respTask.StatusCode, buf.String()) + } + var taskRes struct { + Success bool `json:"success"` + DocRecord *struct { + DocID string `json:"doc_id"` + } `json:"doc_record"` + ArtifactTitle string `json:"artifact_title"` + } + _ = json.NewDecoder(respTask.Body).Decode(&taskRes) + if !taskRes.Success { + t.Fatal("pi-agent intake task failed") + } + docID := "" + if taskRes.DocRecord != nil { + docID = taskRes.DocRecord.DocID + } + if docID == "" { + docID = fmt.Sprintf("doc_proj%d_intake", projectID) + } + t.Logf("✓ [3. pi-agent intake] doc=%s title=%s elapsed=%s", docID, taskRes.ArtifactTitle, time.Since(taskStart).Round(time.Second)) + + // 文档批准链 + docEngine := srv.BizdevService().DocGov() + _ = docEngine.MarkDocumentContentUpdated(ctx, docID, "hash_pi_pg", 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) + } + t.Log("✓ [4. 文档批准] stage ready hook 已触发") + + // 通知 + 阶段推进 + reqNotify := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/notifications", projectID), nil) + withBearerAuth(reqNotify, creatorToken) + recNotify := httptest.NewRecorder() + handler.ServeHTTP(recNotify, reqNotify) + var notifyResp struct { + Items []map[string]any `json:"items"` + } + _ = json.Unmarshal(recNotify.Body.Bytes(), ¬ifyResp) + foundReady := false + for _, n := range notifyResp.Items { + if n["type"] == notification.TypeStageReadyToAdvance { + foundReady = true + break + } + } + if !foundReady { + t.Fatalf("expected stage_ready notification, got %+v", notifyResp.Items) + } + + advanceBody, _ := json.Marshal(projectinstance.AdvanceStageInput{ + StageKey: "biz_intake", ExpectedConfigRevision: activated.Instance.ConfigRevision, + }) + reqAdvance := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/workflow-instances/%d/stages/biz_intake/advance", instanceID), bytes.NewReader(advanceBody)) + withBearerAuth(reqAdvance, creatorToken) + recAdvance := httptest.NewRecorder() + handler.ServeHTTP(recAdvance, reqAdvance) + if recAdvance.Code != http.StatusOK { + t.Fatalf("advance: %d %s", recAdvance.Code, recAdvance.Body.String()) + } + t.Log("✓ [5. 阶段推进] PG + pi-agent 治理全链路完成") +} diff --git a/internal/server/workflow_instance_handlers.go b/internal/server/workflow_instance_handlers.go new file mode 100644 index 00000000..991b3596 --- /dev/null +++ b/internal/server/workflow_instance_handlers.go @@ -0,0 +1,104 @@ +package server + +import ( + "encoding/json" + "net/http" + "strconv" + + "chat-agent-system/internal/projectinstance" +) + +func (s *Server) handleGetWorkflowInstance(w http.ResponseWriter, r *http.Request) { + instanceID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || instanceID <= 0 { + http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid instance id"}`, http.StatusBadRequest) + return + } + if s.instSvc == nil || !s.instSvc.Available() { + http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`, http.StatusServiceUnavailable) + return + } + detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID) + if err != nil { + writeProjectInstanceError(w, err) + return + } + if _, ok := s.requireProjectAccess(w, r, detail.Instance.ProjectID); !ok { + return + } + writeJSONResponse(w, http.StatusOK, detail) +} + +func (s *Server) handleGetStageReadiness(w http.ResponseWriter, r *http.Request) { + instanceID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || instanceID <= 0 { + http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid instance id"}`, http.StatusBadRequest) + return + } + stageKey := r.PathValue("stage") + if s.instSvc == nil || !s.instSvc.Available() { + http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`, http.StatusServiceUnavailable) + return + } + detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID) + if err != nil { + writeProjectInstanceError(w, err) + return + } + user, ok := s.requireProjectAccess(w, r, detail.Instance.ProjectID) + if !ok { + return + } + isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), detail.Instance.ProjectID, user.ID) + readiness, err := s.instSvc.GetStageReadiness(r.Context(), instanceID, stageKey, user.ID, isCreator) + if err != nil { + writeProjectInstanceError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, readiness) +} + +func (s *Server) handleAdvanceStage(w http.ResponseWriter, r *http.Request) { + instanceID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || instanceID <= 0 { + http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid instance id"}`, http.StatusBadRequest) + return + } + stageKey := r.PathValue("stage") + if s.instSvc == nil || !s.instSvc.Available() { + http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"项目实例服务不可用"}`, http.StatusServiceUnavailable) + return + } + detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID) + if err != nil { + writeProjectInstanceError(w, err) + return + } + user, ok := s.requireProjectAccess(w, r, detail.Instance.ProjectID) + if !ok { + return + } + isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), detail.Instance.ProjectID, user.ID) + var req projectinstance.AdvanceStageInput + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && r.ContentLength > 0 { + http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`, http.StatusBadRequest) + return + } + if req.StageKey == "" { + req.StageKey = stageKey + } + result, err := s.instSvc.AdvanceStage(r.Context(), instanceID, user.ID, isCreator, req) + if err != nil { + writeProjectInstanceError(w, err) + return + } + if s.notifySvc != nil && result.CompletedStageInstanceID > 0 { + _ = s.notifySvc.ConfirmStageAdvanceRequest( + r.Context(), + result.CompletedStageInstanceID, + result.CompletedReadinessRevision, + user.ID, + ) + } + writeJSONResponse(w, http.StatusOK, result) +} diff --git a/internal/squad/quality_gate.go b/internal/squad/quality_gate.go index f8fed19e..44dd98e2 100644 --- a/internal/squad/quality_gate.go +++ b/internal/squad/quality_gate.go @@ -312,7 +312,7 @@ func (q *QualityGate) EnsureQualityOrRepair( _ = os.WriteFile(repairTaskPath, []byte(b.String()), 0o644) // 执行修复步骤(治理入账:轨迹 + 预算) - runErr := driver.runOneStep(ctx, wsDir, repairStep, backend, opts.StepNotify, nil, &[]string{}, ledger) + runErr := driver.runOneStep(ctx, wsDir, repairStep, backend, opts.Timeout, opts.StepNotify, nil, &[]string{}, ledger) if runErr != nil { break } diff --git a/internal/squad/workflow.go b/internal/squad/workflow.go index d3644018..a38a379c 100644 --- a/internal/squad/workflow.go +++ b/internal/squad/workflow.go @@ -263,7 +263,7 @@ func (d *WorkflowDriver) Execute(ctx context.Context, wsDir string, backend agen wg.Add(1) go func() { defer wg.Done() - if err := d.runOneStep(runCtx, wsDir, sCopy, backend, opts.StepNotify, &mu, &producedFiles, ledger); err != nil { + if err := d.runOneStep(runCtx, wsDir, sCopy, backend, timeout, opts.StepNotify, &mu, &producedFiles, ledger); err != nil { mu.Lock() if groupErr == nil { groupErr = err @@ -325,7 +325,7 @@ func (d *WorkflowDriver) Execute(ctx context.Context, wsDir string, backend agen } // 治理:受控迁移至执行态(legacy 重跑场景直接放行) markStepExecuting(wf, step.ID) - if err := d.runOneStep(runCtx, wsDir, step, backend, opts.StepNotify, &mu, &producedFiles, ledger); err != nil { + if err := d.runOneStep(runCtx, wsDir, step, backend, timeout, opts.StepNotify, &mu, &producedFiles, ledger); err != nil { wf.Status = "failed" _ = d.SaveWorkflow(wsDir, wf) _ = d.updateWorkspaceStatus(wsDir, "failed") @@ -403,6 +403,7 @@ func (d *WorkflowDriver) runOneStep( wsDir string, step StepSpec, backend agent.Backend, + stepTimeout time.Duration, notify func(StepEvent), mu *sync.Mutex, producedFiles *[]string, @@ -491,12 +492,15 @@ func (d *WorkflowDriver) runOneStep( prevContent = string(b) } + if stepTimeout <= 0 { + stepTimeout = 30 * time.Minute + } req := agent.Request{ WorkDir: subDir, Prompt: string(taskData), Instructions: string(agentsData), Targets: targets, - Timeout: 30 * time.Minute, + Timeout: stepTimeout, } res, err := backend.Execute(ctx, req) diff --git a/internal/storage/cas_db_test.go b/internal/storage/cas_db_test.go index f19a58ab..cfe99801 100644 --- a/internal/storage/cas_db_test.go +++ b/internal/storage/cas_db_test.go @@ -86,13 +86,13 @@ func TestCAS_DatabaseIntegration(t *testing.T) { t.Errorf("expected second upload to be deduplicated in DB") } - // 3. 存储并检索向量切片 + // 3. 存储并检索向量切片(内容带唯一后缀,避免跨次运行 chunk_hash 冲突) chunk, err := cas.StoreMemoryChunk(ctx, MemoryChunkInput{ Domain: "project_doc", ProjectID: projID, UserID: userID, Title: "光机指标要求", - Content: "内窥镜视场角不低于 100 度,光学畸变应严格控制在 5% 以内。", + Content: "内窥镜视场角不低于 100 度,光学畸变应严格控制在 5% 以内。 run=" + time.Now().Format(time.RFC3339Nano), ImportanceWeight: 1.2, }) if err != nil { diff --git a/internal/taskrun/manager.go b/internal/taskrun/manager.go index 80a8f83d..a88572be 100644 --- a/internal/taskrun/manager.go +++ b/internal/taskrun/manager.go @@ -2,6 +2,7 @@ package taskrun import ( "context" + "errors" "fmt" "log/slog" "sync" @@ -25,10 +26,12 @@ type StageExecutor func(ctx context.Context, runID string, req bizdev.StageRunRe // Finalizer 任务成功后投影运行记录与聊天消息(由 server 注入)。 type Finalizer func(ctx context.Context, record *RunRecord, result *bizdev.TaskRunResult) (terminalStatus string, err error) -// Manager 后台任务运行观察者:SSE 断线不取消 worker。 +// Manager 后台任务运行观察者:SSE 断线不取消 worker;可选 PG 持久化支持重启重放。 type Manager struct { mu sync.RWMutex runs map[string]*activeRun + store Store + workerID string exec Executor resume ResumeExecutor stage StageExecutor @@ -39,16 +42,41 @@ type Manager struct { const runRetention = time.Hour +var ( + ErrRunNotResumable = errors.New("taskrun: run is not resumable") + ErrRunAlreadyActive = errors.New("taskrun: run already active in memory") +) + func NewManager(pwSvc *projectworkflow.Service, ctxMgr *ctxmgr.ContextManager) *Manager { m := &Manager{ - runs: make(map[string]*activeRun), - pwSvc: pwSvc, - ctxMgr: ctxMgr, + runs: make(map[string]*activeRun), + store: noopStore{}, + workerID: newWorkerID(), + pwSvc: pwSvc, + ctxMgr: ctxMgr, } go m.cleanupLoop() return m } +// SetStore 注入持久化存储(PG);启动时标记 lease 过期的 running 任务为 interrupted。 +func (m *Manager) SetStore(store Store) { + if m == nil { + return + } + if store == nil { + store = noopStore{} + } + m.store = store + if store.Available() { + if n, err := store.MarkOrphanedRunning(context.Background()); err != nil { + slog.Warn("taskrun_orphan_recovery_failed", "error", err) + } else if n > 0 { + slog.Info("taskrun_orphans_marked_interrupted", "count", n) + } + } +} + func (m *Manager) cleanupLoop() { ticker := time.NewTicker(10 * time.Minute) defer ticker.Stop() @@ -96,6 +124,18 @@ func (m *Manager) CreateAndStart(parent context.Context, runID string, record *R now := time.Now() record.CreatedAt = now record.UpdatedAt = now + record.WorkerID = m.workerID + record.Attempt = 1 + leaseUntil := now.Add(defaultLeaseTTL) + record.LeaseExpiresAt = &leaseUntil + + if m.store != nil && m.store.Available() { + if err := m.store.InsertRun(parent, record); err != nil { + slog.Warn("taskrun_insert_failed", "run_id", runID, "error", err) + } else if err := m.store.AcquireLease(parent, runID, m.workerID, defaultLeaseTTL); err != nil { + slog.Warn("taskrun_lease_acquire_failed", "run_id", runID, "error", err) + } + } workerCtx, cancel := context.WithCancel(context.WithoutCancel(parent)) @@ -133,6 +173,12 @@ func (m *Manager) CreateAndStart(parent context.Context, runID string, record *R func (m *Manager) runWorker(ctx context.Context, ar *activeRun, taskReq bizdev.TaskRunRequest, stageReq bizdev.StageRunRequest, resumeWorkDir string) { defer close(ar.done) + hbCtx, hbCancel := context.WithCancel(ctx) + defer hbCancel() + if m.store != nil && m.store.Available() { + go m.heartbeatLoop(hbCtx, ar.record.RunID) + } + taskReq.RunID = ar.record.RunID taskReq.SessionID = ar.record.SessionID taskReq.ProjectID = ar.record.ProjectID @@ -177,7 +223,9 @@ func (m *Manager) runWorker(ctx context.Context, ar *activeRun, taskReq bizdev.T if workspaceID != "" { ar.mu.Lock() ar.record.WorkspaceID = workspaceID + ar.record.UpdatedAt = time.Now() ar.mu.Unlock() + m.persistRun(ar) } if execErr != nil { @@ -197,18 +245,12 @@ func (m *Manager) runWorker(ctx context.Context, ar *activeRun, taskReq bizdev.T return } - m.appendEvent(ar, stream.StreamEvent{ - Type: stream.EventDone, - Done: &stream.DonePayload{ - SessionID: "task_run", - WorkspaceID: workspaceID, - DurationMs: time.Since(start).Milliseconds(), - }, - }) - + // RUN-002: 先 finalize 落库,成功后再发 done 终态事件 terminalStatus := "completed" if m.finalize != nil && taskResult != nil { if ts, finErr := m.finalize(ctx, ar.record, taskResult); finErr != nil { + info := stream.ClassifyError(finErr, lastPhaseID, ar.record.RunID, ar.record.SessionID, workspaceID) + m.appendEvent(ar, stream.StreamEvent{Type: stream.EventError, ErrorInfo: &info}) m.finishRun(ar, "finalizing_failed", workspaceID, finErr.Error(), taskResult) return } else if ts != "" { @@ -217,6 +259,15 @@ func (m *Manager) runWorker(ctx context.Context, ar *activeRun, taskReq bizdev.T } else if taskResult != nil && taskResult.QualityReport != nil && !taskResult.QualityReport.Passed { terminalStatus = "quality_failed" } + + m.appendEvent(ar, stream.StreamEvent{ + Type: stream.EventDone, + Done: &stream.DonePayload{ + SessionID: "task_run", + WorkspaceID: workspaceID, + DurationMs: time.Since(start).Milliseconds(), + }, + }) m.finishRun(ar, terminalStatus, workspaceID, "", taskResult) } @@ -234,6 +285,14 @@ func (m *Manager) finishRun(ar *activeRun, status, workspaceID, errMsg string, r "workspace_id", workspaceID, "error", errMsg, ) + if m.store != nil && m.store.Available() { + _ = m.store.ReleaseLease(context.Background(), runID, m.workerID) + } + ar.mu.Lock() + ar.record.WorkerID = "" + ar.record.LeaseExpiresAt = nil + ar.mu.Unlock() + m.persistRun(ar) // 失败/取消路径:若 Finalize 未执行,仍更新 workflow_runs 与占位消息 metadata if status == "failed" || status == "cancelled" || status == "finalizing_failed" { @@ -261,17 +320,37 @@ func (m *Manager) finishRun(ar *activeRun, status, workspaceID, errMsg string, r _ = result // success path persistence handled by Finalizer } +func (m *Manager) persistRun(ar *activeRun) { + if m.store == nil || !m.store.Available() || ar == nil { + return + } + ar.mu.RLock() + rec := *ar.record + ar.mu.RUnlock() + if err := m.store.UpdateRun(context.Background(), &rec); err != nil { + slog.Warn("taskrun_update_failed", "run_id", rec.RunID, "error", err) + } +} + func (m *Manager) appendEvent(ar *activeRun, ev stream.StreamEvent) { ar.mu.Lock() ar.nextSeq++ stored := StoredEvent{Seq: ar.nextSeq, Event: ev} ar.events = append(ar.events, stored) + runID := ar.record.RunID + seq := ar.nextSeq subs := make([]chan StoredEvent, 0, len(ar.subs)) for _, ch := range ar.subs { subs = append(subs, ch) } ar.mu.Unlock() + if m.store != nil && m.store.Available() { + if err := m.store.AppendEvent(context.Background(), runID, seq, ev); err != nil { + slog.Warn("taskrun_event_persist_failed", "run_id", runID, "seq", seq, "error", err) + } + } + for _, ch := range subs { select { case ch <- stored: @@ -284,36 +363,51 @@ func (m *Manager) GetRun(runID string) (*RunRecord, error) { m.mu.RLock() ar, ok := m.runs[runID] m.mu.RUnlock() - if !ok { - return nil, fmt.Errorf("run not found") + if ok { + ar.mu.RLock() + defer ar.mu.RUnlock() + cpy := *ar.record + return &cpy, nil } - ar.mu.RLock() - defer ar.mu.RUnlock() - cpy := *ar.record - return &cpy, nil + if m.store != nil && m.store.Available() { + return m.store.GetRun(context.Background(), runID) + } + return nil, fmt.Errorf("run not found") } func (m *Manager) Subscribe(runID string, afterSeq int64) (<-chan StoredEvent, []StoredEvent, error) { m.mu.RLock() ar, ok := m.runs[runID] m.mu.RUnlock() - if !ok { - return nil, nil, fmt.Errorf("run not found") + if ok { + ar.mu.Lock() + defer ar.mu.Unlock() + replay := make([]StoredEvent, 0) + for _, ev := range ar.events { + if ev.Seq > afterSeq { + replay = append(replay, ev) + } + } + ch := make(chan StoredEvent, 32) + ar.subSeq++ + id := ar.subSeq + ar.subs[id] = ch + return ch, replay, nil } - ar.mu.Lock() - defer ar.mu.Unlock() - replay := make([]StoredEvent, 0) - for _, ev := range ar.events { - if ev.Seq > afterSeq { - replay = append(replay, ev) + if m.store != nil && m.store.Available() { + if _, err := m.store.GetRun(context.Background(), runID); err != nil { + return nil, nil, fmt.Errorf("run not found") } + events, err := m.store.ListEvents(context.Background(), runID, afterSeq) + if err != nil { + return nil, nil, err + } + ch := make(chan StoredEvent) + close(ch) + return ch, events, nil } - ch := make(chan StoredEvent, 32) - ar.subSeq++ - id := ar.subSeq - ar.subs[id] = ch - return ch, replay, nil + return nil, nil, fmt.Errorf("run not found") } func (m *Manager) Unsubscribe(runID string, ch <-chan StoredEvent) { @@ -334,6 +428,68 @@ func (m *Manager) Unsubscribe(runID string, ch <-chan StoredEvent) { } } +// ResumeInterrupted 在用户确认后续跑 interrupted 任务(需有效 workspace 路径)。 +func (m *Manager) ResumeInterrupted(parent context.Context, record *RunRecord, resumeWorkDir string, taskReq bizdev.TaskRunRequest, stageReq bizdev.StageRunRequest) (*RunRecord, error) { + if record == nil || record.RunID == "" { + return nil, fmt.Errorf("taskrun: run_id required") + } + if resumeWorkDir == "" { + return nil, fmt.Errorf("taskrun: workspace required") + } + if record.Status != "interrupted" { + return nil, ErrRunNotResumable + } + + m.mu.RLock() + _, exists := m.runs[record.RunID] + m.mu.RUnlock() + if exists { + return nil, ErrRunAlreadyActive + } + + nextAttempt := record.Attempt + 1 + if nextAttempt <= 0 { + nextAttempt = 2 + } + if m.store != nil && m.store.Available() { + if err := m.store.ResetForResume(parent, record.RunID, nextAttempt); err != nil { + return nil, err + } + } + + now := time.Now() + leaseUntil := now.Add(defaultLeaseTTL) + cpy := *record + cpy.Status = "running" + cpy.ErrorMessage = "" + cpy.Attempt = nextAttempt + cpy.WorkerID = m.workerID + cpy.LeaseExpiresAt = &leaseUntil + cpy.UpdatedAt = now + + if m.store != nil && m.store.Available() { + _ = m.store.UpdateRun(parent, &cpy) + if err := m.store.AcquireLease(parent, cpy.RunID, m.workerID, defaultLeaseTTL); err != nil { + slog.Warn("taskrun_resume_lease_failed", "run_id", cpy.RunID, "error", err) + } + } + + workerCtx, cancel := context.WithCancel(context.WithoutCancel(parent)) + ar := &activeRun{ + record: &cpy, + subs: make(map[int]chan StoredEvent), + cancel: cancel, + done: make(chan struct{}), + } + + m.mu.Lock() + m.runs[cpy.RunID] = ar + m.mu.Unlock() + + go m.runWorker(workerCtx, ar, taskReq, stageReq, resumeWorkDir) + return &cpy, nil +} + func (m *Manager) Cancel(runID string) error { m.mu.RLock() ar, ok := m.runs[runID] @@ -344,3 +500,18 @@ func (m *Manager) Cancel(runID string) error { ar.cancel() return nil } + +func (m *Manager) heartbeatLoop(ctx context.Context, runID string) { + ticker := time.NewTicker(defaultHeartbeatPeriod) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := m.store.HeartbeatLease(context.Background(), runID, m.workerID, defaultLeaseTTL); err != nil { + slog.Warn("taskrun_heartbeat_failed", "run_id", runID, "error", err) + } + } + } +} diff --git a/internal/taskrun/manager_finalize_order_test.go b/internal/taskrun/manager_finalize_order_test.go new file mode 100644 index 00000000..29213051 --- /dev/null +++ b/internal/taskrun/manager_finalize_order_test.go @@ -0,0 +1,105 @@ +package taskrun + +import ( + "context" + "errors" + "testing" + "time" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/stream" +) + +func TestManager_DoneEmittedAfterFinalize(t *testing.T) { + m := NewManager(nil, nil) + finalizeCalled := false + + m.SetExecutor( + func(_ context.Context, _ string, _ bizdev.TaskRunRequest, _ *stream.Streamer) (*bizdev.TaskRunResult, error) { + return &bizdev.TaskRunResult{Success: true, WorkDir: "/tmp/ws"}, nil + }, + nil, + nil, + ) + m.SetFinalizer(func(_ context.Context, _ *RunRecord, _ *bizdev.TaskRunResult) (string, error) { + finalizeCalled = true + time.Sleep(20 * time.Millisecond) + return "completed", nil + }) + + runID := "run_finalize_order" + record := &RunRecord{RunID: runID, Status: "running"} + _, err := m.CreateAndStart(context.Background(), runID, record, bizdev.TaskRunRequest{TaskKey: "intake"}, bizdev.StageRunRequest{}, "") + if err != nil { + t.Fatalf("CreateAndStart: %v", err) + } + + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + rec, getErr := m.GetRun(runID) + if getErr == nil && rec.Status != "running" { + break + } + time.Sleep(20 * time.Millisecond) + } + + m.mu.RLock() + ar := m.runs[runID] + m.mu.RUnlock() + if ar == nil { + t.Fatal("active run evicted too early") + } + ar.mu.RLock() + defer ar.mu.RUnlock() + if !finalizeCalled { + t.Fatal("finalize was not called") + } + if len(ar.events) == 0 { + t.Fatal("expected events") + } + last := ar.events[len(ar.events)-1] + if last.Event.Type != stream.EventDone { + t.Fatalf("last event type=%s want done", last.Event.Type) + } +} + +func TestManager_FinalizeFailureDoesNotEmitDone(t *testing.T) { + m := NewManager(nil, nil) + m.SetExecutor( + func(_ context.Context, _ string, _ bizdev.TaskRunRequest, _ *stream.Streamer) (*bizdev.TaskRunResult, error) { + return &bizdev.TaskRunResult{Success: true}, nil + }, + nil, + nil, + ) + m.SetFinalizer(func(_ context.Context, _ *RunRecord, _ *bizdev.TaskRunResult) (string, error) { + return "", errors.New("db write failed") + }) + + runID := "run_finalize_fail" + record := &RunRecord{RunID: runID, Status: "running"} + _, _ = m.CreateAndStart(context.Background(), runID, record, bizdev.TaskRunRequest{TaskKey: "intake"}, bizdev.StageRunRequest{}, "") + + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + rec, getErr := m.GetRun(runID) + if getErr == nil && rec.Status == "finalizing_failed" { + m.mu.RLock() + ar := m.runs[runID] + m.mu.RUnlock() + if ar == nil { + t.Fatal("missing active run") + } + ar.mu.RLock() + defer ar.mu.RUnlock() + for _, ev := range ar.events { + if ev.Event.Type == stream.EventDone { + t.Fatal("done must not be emitted when finalize fails") + } + } + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("expected finalizing_failed status") +} diff --git a/internal/taskrun/store.go b/internal/taskrun/store.go new file mode 100644 index 00000000..eb20ef92 --- /dev/null +++ b/internal/taskrun/store.go @@ -0,0 +1,440 @@ +package taskrun + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "chat-agent-system/internal/stream" +) + +var ErrStoreNotAvailable = errors.New("taskrun: store not available") + +// Store 持久化 task run 与 SSE 事件,支持重启后查询与重放。 +type Store interface { + Available() bool + InsertRun(ctx context.Context, rec *RunRecord) error + UpdateRun(ctx context.Context, rec *RunRecord) error + AppendEvent(ctx context.Context, runID string, seq int64, ev stream.StreamEvent) error + GetRun(ctx context.Context, runID string) (*RunRecord, error) + ListEvents(ctx context.Context, runID string, afterSeq int64) ([]StoredEvent, error) + AcquireLease(ctx context.Context, runID, workerID string, ttl time.Duration) error + HeartbeatLease(ctx context.Context, runID, workerID string, ttl time.Duration) error + ReleaseLease(ctx context.Context, runID, workerID string) error + ResetForResume(ctx context.Context, runID string, attempt int) error + MarkOrphanedRunning(ctx context.Context) (int, error) +} + +// PGStore PostgreSQL 实现。 +type PGStore struct { + pool *pgxpool.Pool +} + +func NewPGStore(pool *pgxpool.Pool) *PGStore { + return &PGStore{pool: pool} +} + +func (s *PGStore) Available() bool { + if s == nil || s.pool == nil { + return false + } + var ok bool + _ = s.pool.QueryRow(context.Background(), ` + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'task_runs' + ) + `).Scan(&ok) + return ok +} + +func (s *PGStore) hasLeaseColumns(ctx context.Context) bool { + var ok bool + _ = s.pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'task_runs' AND column_name = 'worker_id' + ) + `).Scan(&ok) + return ok +} + +func (s *PGStore) InsertRun(ctx context.Context, rec *RunRecord) error { + if !s.Available() { + return ErrStoreNotAvailable + } + if rec == nil || rec.RunID == "" { + return fmt.Errorf("taskrun: run_id required") + } + attempt := rec.Attempt + if attempt <= 0 { + attempt = 1 + } + if s.hasLeaseColumns(ctx) { + var leaseHeartbeat *time.Time + if rec.LeaseExpiresAt != nil { + now := time.Now() + leaseHeartbeat = &now + } + _, err := s.pool.Exec(ctx, ` + INSERT INTO task_runs ( + run_id, session_id, project_id, user_id, client_turn_id, + task_key, stage, status, workspace_id, assistant_message_id, + error_message, worker_id, lease_expires_at, lease_heartbeat_at, attempt, + created_at, updated_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) + ON CONFLICT (run_id) DO NOTHING + `, + rec.RunID, rec.SessionID, nullInt64(rec.ProjectID), rec.UserID, rec.ClientTurnID, + rec.TaskKey, rec.Stage, rec.Status, rec.WorkspaceID, nullInt64(rec.AssistantMsgID), + rec.ErrorMessage, rec.WorkerID, rec.LeaseExpiresAt, leaseHeartbeat, attempt, + rec.CreatedAt, rec.UpdatedAt, + ) + return err + } + _, err := s.pool.Exec(ctx, ` + INSERT INTO task_runs ( + run_id, session_id, project_id, user_id, client_turn_id, + task_key, stage, status, workspace_id, assistant_message_id, + error_message, created_at, updated_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + ON CONFLICT (run_id) DO NOTHING + `, + rec.RunID, rec.SessionID, nullInt64(rec.ProjectID), rec.UserID, rec.ClientTurnID, + rec.TaskKey, rec.Stage, rec.Status, rec.WorkspaceID, nullInt64(rec.AssistantMsgID), + rec.ErrorMessage, rec.CreatedAt, rec.UpdatedAt, + ) + return err +} + +func (s *PGStore) UpdateRun(ctx context.Context, rec *RunRecord) error { + if !s.Available() { + return ErrStoreNotAvailable + } + if rec == nil || rec.RunID == "" { + return fmt.Errorf("taskrun: run_id required") + } + if s.hasLeaseColumns(ctx) { + _, err := s.pool.Exec(ctx, ` + UPDATE task_runs SET + status = $2, + workspace_id = $3, + error_message = $4, + worker_id = $5, + lease_expires_at = $6, + updated_at = $7 + WHERE run_id = $1 + `, rec.RunID, rec.Status, rec.WorkspaceID, rec.ErrorMessage, rec.WorkerID, rec.LeaseExpiresAt, rec.UpdatedAt) + return err + } + _, err := s.pool.Exec(ctx, ` + UPDATE task_runs SET + status = $2, + workspace_id = $3, + error_message = $4, + updated_at = $5 + WHERE run_id = $1 + `, rec.RunID, rec.Status, rec.WorkspaceID, rec.ErrorMessage, rec.UpdatedAt) + return err +} + +func (s *PGStore) AppendEvent(ctx context.Context, runID string, seq int64, ev stream.StreamEvent) error { + if !s.Available() { + return ErrStoreNotAvailable + } + payload, err := json.Marshal(ev) + if err != nil { + return err + } + _, err = s.pool.Exec(ctx, ` + INSERT INTO task_run_events (run_id, seq, event_type, payload) + VALUES ($1, $2, $3, $4::jsonb) + ON CONFLICT (run_id, seq) DO NOTHING + `, runID, seq, string(ev.Type), payload) + return err +} + +func (s *PGStore) GetRun(ctx context.Context, runID string) (*RunRecord, error) { + if !s.Available() { + return nil, ErrStoreNotAvailable + } + var rec RunRecord + var projectID, assistantMsgID *int64 + if s.hasLeaseColumns(ctx) { + err := s.pool.QueryRow(ctx, ` + SELECT run_id, session_id, project_id, user_id, client_turn_id, + task_key, stage, status, workspace_id, assistant_message_id, + error_message, worker_id, lease_expires_at, attempt, created_at, updated_at + FROM task_runs + WHERE run_id = $1 + `, runID).Scan( + &rec.RunID, &rec.SessionID, &projectID, &rec.UserID, &rec.ClientTurnID, + &rec.TaskKey, &rec.Stage, &rec.Status, &rec.WorkspaceID, &assistantMsgID, + &rec.ErrorMessage, &rec.WorkerID, &rec.LeaseExpiresAt, &rec.Attempt, + &rec.CreatedAt, &rec.UpdatedAt, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, fmt.Errorf("run not found") + } + return nil, err + } + } else { + err := s.pool.QueryRow(ctx, ` + SELECT run_id, session_id, project_id, user_id, client_turn_id, + task_key, stage, status, workspace_id, assistant_message_id, + error_message, created_at, updated_at + FROM task_runs + WHERE run_id = $1 + `, runID).Scan( + &rec.RunID, &rec.SessionID, &projectID, &rec.UserID, &rec.ClientTurnID, + &rec.TaskKey, &rec.Stage, &rec.Status, &rec.WorkspaceID, &assistantMsgID, + &rec.ErrorMessage, &rec.CreatedAt, &rec.UpdatedAt, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, fmt.Errorf("run not found") + } + return nil, err + } + } + if projectID != nil { + rec.ProjectID = *projectID + } + if assistantMsgID != nil { + rec.AssistantMsgID = *assistantMsgID + } + return &rec, nil +} + +func (s *PGStore) ListEvents(ctx context.Context, runID string, afterSeq int64) ([]StoredEvent, error) { + if !s.Available() { + return nil, ErrStoreNotAvailable + } + rows, err := s.pool.Query(ctx, ` + SELECT seq, payload + FROM task_run_events + WHERE run_id = $1 AND seq > $2 + ORDER BY seq ASC + `, runID, afterSeq) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]StoredEvent, 0) + for rows.Next() { + var seq int64 + var payload []byte + if err := rows.Scan(&seq, &payload); err != nil { + return nil, err + } + var ev stream.StreamEvent + if err := json.Unmarshal(payload, &ev); err != nil { + return nil, err + } + out = append(out, StoredEvent{Seq: seq, Event: ev}) + } + return out, rows.Err() +} + +func (s *PGStore) AcquireLease(ctx context.Context, runID, workerID string, ttl time.Duration) error { + if !s.Available() { + return ErrStoreNotAvailable + } + if !s.hasLeaseColumns(ctx) { + return nil + } + expires := time.Now().Add(ttl) + tag, err := s.pool.Exec(ctx, ` + UPDATE task_runs + SET worker_id = $2, + lease_expires_at = $3, + lease_heartbeat_at = now(), + updated_at = now() + WHERE run_id = $1 AND status = 'running' + `, runID, workerID, expires) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("taskrun: lease acquire failed for %s", runID) + } + return nil +} + +func (s *PGStore) HeartbeatLease(ctx context.Context, runID, workerID string, ttl time.Duration) error { + if !s.Available() { + return ErrStoreNotAvailable + } + if !s.hasLeaseColumns(ctx) { + return nil + } + expires := time.Now().Add(ttl) + tag, err := s.pool.Exec(ctx, ` + UPDATE task_runs + SET lease_expires_at = $3, + lease_heartbeat_at = now(), + updated_at = now() + WHERE run_id = $1 AND worker_id = $2 AND status = 'running' + `, runID, workerID, expires) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("taskrun: heartbeat rejected for %s", runID) + } + return nil +} + +func (s *PGStore) ReleaseLease(ctx context.Context, runID, workerID string) error { + if !s.Available() { + return ErrStoreNotAvailable + } + if !s.hasLeaseColumns(ctx) { + return nil + } + _, err := s.pool.Exec(ctx, ` + UPDATE task_runs + SET worker_id = '', + lease_expires_at = NULL, + lease_heartbeat_at = NULL, + updated_at = now() + WHERE run_id = $1 AND (worker_id = $2 OR worker_id = '') + `, runID, workerID) + return err +} + +func (s *PGStore) ResetForResume(ctx context.Context, runID string, attempt int) error { + if !s.Available() { + return ErrStoreNotAvailable + } + tag, err := s.pool.Exec(ctx, ` + UPDATE task_runs + SET status = 'running', + error_message = '', + attempt = $2, + updated_at = now() + WHERE run_id = $1 AND status = 'interrupted' + `, runID, attempt) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("taskrun: run %s not resumable", runID) + } + return nil +} + +func (s *PGStore) MarkOrphanedRunning(ctx context.Context) (int, error) { + if !s.Available() { + return 0, ErrStoreNotAvailable + } + if s.hasLeaseColumns(ctx) { + tag, err := s.pool.Exec(ctx, ` + UPDATE task_runs + SET status = 'interrupted', + error_message = 'worker lease 过期,任务已中断(进程重启或 worker 失联)', + worker_id = '', + lease_expires_at = NULL, + lease_heartbeat_at = NULL, + updated_at = now() + WHERE status = 'running' + AND lease_expires_at IS NOT NULL + AND lease_expires_at < now() - interval '2 seconds' + `) + if err != nil { + return 0, err + } + return int(tag.RowsAffected()), nil + } + tag, err := s.pool.Exec(ctx, ` + UPDATE task_runs + SET status = 'interrupted', + error_message = '进程重启导致任务中断,请重新提交', + updated_at = now() + WHERE status = 'running' + `) + if err != nil { + return 0, err + } + return int(tag.RowsAffected()), nil +} + +func nullInt64(v int64) *int64 { + if v <= 0 { + return nil + } + return &v +} + +// TablesReady 检查 task_runs 表是否已迁移。 +func TablesReady(ctx context.Context, pool *pgxpool.Pool) bool { + if pool == nil { + return false + } + var ok bool + _ = pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'task_runs' + ) + `).Scan(&ok) + return ok +} + +// LeaseColumnsReady 检查 worker lease 列是否已迁移。 +func LeaseColumnsReady(ctx context.Context, pool *pgxpool.Pool) bool { + if pool == nil { + return false + } + var ok bool + _ = pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'task_runs' AND column_name = 'worker_id' + ) + `).Scan(&ok) + return ok +} + +// noopStore 无持久化时的空实现。 +type noopStore struct{} + +func (noopStore) Available() bool { return false } +func (noopStore) InsertRun(context.Context, *RunRecord) error { + return ErrStoreNotAvailable +} +func (noopStore) UpdateRun(context.Context, *RunRecord) error { return ErrStoreNotAvailable } +func (noopStore) AppendEvent(context.Context, string, int64, stream.StreamEvent) error { + return ErrStoreNotAvailable +} +func (noopStore) GetRun(context.Context, string) (*RunRecord, error) { + return nil, ErrStoreNotAvailable +} +func (noopStore) ListEvents(context.Context, string, int64) ([]StoredEvent, error) { + return nil, ErrStoreNotAvailable +} +func (noopStore) AcquireLease(context.Context, string, string, time.Duration) error { + return ErrStoreNotAvailable +} +func (noopStore) HeartbeatLease(context.Context, string, string, time.Duration) error { + return ErrStoreNotAvailable +} +func (noopStore) ReleaseLease(context.Context, string, string) error { return ErrStoreNotAvailable } +func (noopStore) ResetForResume(context.Context, string, int) error { + return ErrStoreNotAvailable +} +func (noopStore) MarkOrphanedRunning(context.Context) (int, error) { return 0, ErrStoreNotAvailable } + +// EnsureUpdatedAt 若 UpdatedAt 为零则设为当前时间。 +func EnsureUpdatedAt(rec *RunRecord) { + if rec != nil && rec.UpdatedAt.IsZero() { + rec.UpdatedAt = time.Now() + } +} diff --git a/internal/taskrun/store_lease_pg_test.go b/internal/taskrun/store_lease_pg_test.go new file mode 100644 index 00000000..78e59808 --- /dev/null +++ b/internal/taskrun/store_lease_pg_test.go @@ -0,0 +1,133 @@ +package taskrun_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "chat-agent-system/internal/db" + "chat-agent-system/internal/taskrun" +) + +func openLeaseTestPool(t *testing.T) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + dsn = "postgres://app:dev_secret_db_pass@127.0.0.1:5433/agent_system_dev?sslmode=disable" + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Skipf("postgres unavailable: %v", err) + return nil + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + t.Skipf("postgres ping failed: %v", err) + return nil + } + migCtx, migCancel := context.WithTimeout(context.Background(), 60*time.Second) + defer migCancel() + if err := db.RunMigrations(migCtx, pool, db.ResolveMigrationsDir()); err != nil { + pool.Close() + t.Skipf("migration failed: %v", err) + return nil + } + if !taskrun.LeaseColumnsReady(ctx, pool) { + pool.Close() + t.Skip("task run lease columns not ready") + } + return pool +} + +func TestPGStore_StaleLeaseMarkedInterrupted(t *testing.T) { + pool := openLeaseTestPool(t) + if pool == nil { + return + } + defer pool.Close() + + ctx := context.Background() + store := taskrun.NewPGStore(pool) + runID := "run_stale_lease_" + time.Now().Format("150405.000000") + + var userID int64 + err := pool.QueryRow(ctx, `SELECT id FROM users ORDER BY id LIMIT 1`).Scan(&userID) + if err != nil { + t.Fatalf("fetch user: %v", err) + } + + expired := time.Now().Add(-1 * time.Minute) + _, err = pool.Exec(ctx, ` + INSERT INTO task_runs ( + run_id, session_id, user_id, status, worker_id, lease_expires_at, lease_heartbeat_at, created_at, updated_at + ) VALUES ($1, 'sess', $2, 'running', 'dead-worker', $3, $3, now(), now()) + `, runID, userID, expired) + if err != nil { + t.Fatalf("insert stale run: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM task_runs WHERE run_id = $1`, runID) + }) + + n, err := store.MarkOrphanedRunning(ctx) + if err != nil { + t.Fatalf("MarkOrphanedRunning: %v", err) + } + if n < 1 { + t.Fatalf("expected at least 1 interrupted, got %d", n) + } + + rec, err := store.GetRun(ctx, runID) + if err != nil { + t.Fatalf("GetRun: %v", err) + } + if rec.Status != "interrupted" { + t.Fatalf("status=%s want interrupted", rec.Status) + } +} + +func TestPGStore_FreshLeaseSurvivesOrphanScan(t *testing.T) { + pool := openLeaseTestPool(t) + if pool == nil { + return + } + defer pool.Close() + + ctx := context.Background() + store := taskrun.NewPGStore(pool) + runID := "run_fresh_lease_" + time.Now().Format("150405.000000") + workerID := "worker-alive" + + var userID int64 + _ = pool.QueryRow(ctx, `SELECT id FROM users ORDER BY id LIMIT 1`).Scan(&userID) + + fresh := time.Now().Add(30 * time.Second) + _, err := pool.Exec(ctx, ` + INSERT INTO task_runs ( + run_id, session_id, user_id, status, worker_id, lease_expires_at, lease_heartbeat_at, created_at, updated_at + ) VALUES ($1, 'sess', $2, 'running', $3, $4, now(), now(), now()) + `, runID, userID, workerID, fresh) + if err != nil { + t.Fatalf("insert fresh run: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM task_runs WHERE run_id = $1`, runID) + }) + + if _, err := store.MarkOrphanedRunning(ctx); err != nil { + t.Fatalf("MarkOrphanedRunning: %v", err) + } + + rec, err := store.GetRun(ctx, runID) + if err != nil { + t.Fatalf("GetRun: %v", err) + } + if rec.Status != "running" { + t.Fatalf("fresh lease run should stay running, got %s", rec.Status) + } +} diff --git a/internal/taskrun/types.go b/internal/taskrun/types.go index b60b5c21..74d0acbd 100644 --- a/internal/taskrun/types.go +++ b/internal/taskrun/types.go @@ -35,9 +35,12 @@ type RunRecord struct { Status string // pending | running | completed | failed | cancelled | status_conflict WorkspaceID string AssistantMsgID int64 + WorkerID string + LeaseExpiresAt *time.Time CreatedAt time.Time UpdatedAt time.Time ErrorMessage string + Attempt int } // StoredEvent 持久化 SSE 事件(支持重连重放)。 diff --git a/internal/taskrun/worker.go b/internal/taskrun/worker.go new file mode 100644 index 00000000..e2e3a308 --- /dev/null +++ b/internal/taskrun/worker.go @@ -0,0 +1,20 @@ +package taskrun + +import ( + "fmt" + "os" + "time" +) + +const ( + defaultLeaseTTL = 30 * time.Second + defaultHeartbeatPeriod = 10 * time.Second +) + +func newWorkerID() string { + host, err := os.Hostname() + if err != nil || host == "" { + host = "local" + } + return fmt.Sprintf("%s-%d-%d", host, os.Getpid(), time.Now().UnixNano()) +} diff --git a/internal/upload/validate.go b/internal/upload/validate.go new file mode 100644 index 00000000..910b18f2 --- /dev/null +++ b/internal/upload/validate.go @@ -0,0 +1,203 @@ +package upload + +import ( + "fmt" + "net/http" + "path/filepath" + "strings" + "unicode/utf8" +) + +const maxFilenameRunes = 200 + +// 工作流模板上传允许的扩展名 → 期望 MIME。 +var allowedTemplateExt = map[string]string{ + ".md": "text/markdown", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", +} + +// 项目材料上传允许的扩展名 → 期望 MIME。 +var allowedMaterialExt = map[string]string{ + ".txt": "text/plain", + ".md": "text/plain", + ".csv": "text/plain", + ".json": "application/json", + ".xml": "application/xml", + ".yaml": "text/plain", + ".yml": "text/plain", + ".log": "text/plain", + ".pdf": "application/pdf", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", +} + +var blockedDetectedMIME = map[string]struct{}{ + "text/html": {}, + "application/javascript": {}, + "application/x-javascript": {}, +} + +// ValidateProjectMaterial 校验文件名、扩展名白名单与内容魔数/MIME。 +func ValidateProjectMaterial(filename, declaredMIME string, data []byte) (string, error) { + base := filepath.Base(strings.TrimSpace(filename)) + if base == "" || base == "." || base == ".." { + return "", fmt.Errorf("invalid filename") + } + if utf8.RuneCountInString(base) > maxFilenameRunes { + return "", fmt.Errorf("filename too long") + } + ext := strings.ToLower(filepath.Ext(base)) + expectedMIME, ok := allowedMaterialExt[ext] + if !ok { + return "", fmt.Errorf("file type not allowed: %s", ext) + } + if len(data) == 0 { + return "", fmt.Errorf("empty file") + } + if err := rejectDangerousContent(data); err != nil { + return "", err + } + if looksLikeHTML(data) { + return "", fmt.Errorf("html content rejected") + } + detected := http.DetectContentType(data) + if _, blocked := blockedDetectedMIME[detected]; blocked { + return "", fmt.Errorf("dangerous content type detected: %s", detected) + } + if err := assertExtensionMatchesContent(ext, data, detected); err != nil { + return "", err + } + declaredMIME = strings.TrimSpace(strings.Split(declaredMIME, ";")[0]) + if declaredMIME != "" && !mimeCompatible(declaredMIME, expectedMIME, detected) { + return "", fmt.Errorf("declared content type mismatch") + } + return expectedMIME, nil +} + +// ValidateTemplate 校验工作流模板文件名、扩展名白名单与内容魔数/MIME。 +func ValidateTemplate(filename, declaredMIME string, data []byte) (string, error) { + base := filepath.Base(strings.TrimSpace(filename)) + if base == "" || base == "." || base == ".." { + return "", fmt.Errorf("invalid filename") + } + if utf8.RuneCountInString(base) > maxFilenameRunes { + return "", fmt.Errorf("filename too long") + } + ext := strings.ToLower(filepath.Ext(base)) + expectedMIME, ok := allowedTemplateExt[ext] + if !ok { + return "", fmt.Errorf("file type not allowed: %s", ext) + } + if len(data) == 0 { + return "", fmt.Errorf("empty file") + } + if len(data) > 32*1024*1024 { + return "", fmt.Errorf("file too large") + } + if err := rejectDangerousContent(data); err != nil { + return "", err + } + if looksLikeHTML(data) { + return "", fmt.Errorf("html content rejected") + } + detected := http.DetectContentType(data) + if _, blocked := blockedDetectedMIME[detected]; blocked { + return "", fmt.Errorf("dangerous content type detected: %s", detected) + } + if err := assertExtensionMatchesContent(ext, data, detected); err != nil { + return "", err + } + declaredMIME = strings.TrimSpace(strings.Split(declaredMIME, ";")[0]) + if declaredMIME != "" && !mimeCompatible(declaredMIME, expectedMIME, detected) { + return "", fmt.Errorf("declared content type mismatch") + } + return expectedMIME, nil +} + +func looksLikeHTML(data []byte) bool { + sample := strings.ToLower(strings.TrimSpace(string(data[:min(len(data), 512)]))) + return strings.HasPrefix(sample, "= 2 && data[0] == 'M' && data[1] == 'Z' { + return fmt.Errorf("executable content rejected") + } + if len(data) >= 4 && data[0] == 0x7f && data[1] == 'E' && data[2] == 'L' && data[3] == 'F' { + return fmt.Errorf("executable content rejected") + } + head := strings.ToLower(string(data[:min(len(data), 256)])) + if strings.HasPrefix(head, "#!/") || strings.Contains(head, "= len(prefix) && string(data[:len(prefix)]) == string(prefix) +} + +func containsNullByte(data []byte) bool { + for _, b := range data { + if b == 0 { + return true + } + } + return false +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/upload/validate_test.go b/internal/upload/validate_test.go new file mode 100644 index 00000000..4a7c1820 --- /dev/null +++ b/internal/upload/validate_test.go @@ -0,0 +1,76 @@ +package upload + +import ( + "testing" +) + +func TestValidateProjectMaterial_AcceptsPlainText(t *testing.T) { + mime, err := ValidateProjectMaterial("spec.txt", "text/plain", []byte("hello material")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mime != "text/plain" { + t.Fatalf("mime=%s", mime) + } +} + +func TestValidateProjectMaterial_RejectsHTML(t *testing.T) { + data := []byte("") + _, err := ValidateProjectMaterial("evil.txt", "text/html", data) + if err == nil { + t.Fatal("expected rejection for html content") + } +} + +func TestValidateProjectMaterial_RejectsBadExtension(t *testing.T) { + _, err := ValidateProjectMaterial("malware.exe", "application/octet-stream", []byte("MZ")) + if err == nil { + t.Fatal("expected rejection for exe extension") + } +} + +func TestValidateProjectMaterial_RejectsPDFMagicMismatch(t *testing.T) { + _, err := ValidateProjectMaterial("fake.pdf", "application/pdf", []byte("not a pdf")) + if err == nil { + t.Fatal("expected pdf magic rejection") + } +} + +func TestValidateProjectMaterial_AcceptsPDF(t *testing.T) { + _, err := ValidateProjectMaterial("doc.pdf", "", []byte("%PDF-1.4\n")) + if err != nil { + t.Fatalf("pdf: %v", err) + } +} + +func TestValidateTemplate_AcceptsMarkdown(t *testing.T) { + mime, err := ValidateTemplate("brief.md", "text/markdown", []byte("# Title\n\nBody")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mime != "text/markdown" { + t.Fatalf("mime=%s", mime) + } +} + +func TestValidateTemplate_RejectsHTML(t *testing.T) { + data := []byte("") + _, err := ValidateTemplate("evil.md", "text/markdown", data) + if err == nil { + t.Fatal("expected rejection for html content") + } +} + +func TestValidateTemplate_RejectsFakeOfficeDoc(t *testing.T) { + _, err := ValidateTemplate("fake.docx", "", []byte("not a zip")) + if err == nil { + t.Fatal("expected office magic rejection") + } +} + +func TestValidateTemplate_AcceptsOfficeZipMagic(t *testing.T) { + _, err := ValidateTemplate("deck.pptx", "", []byte("PK\x03\x04fake-pptx")) + if err != nil { + t.Fatalf("pptx: %v", err) + } +} diff --git a/internal/workflowdef/bootstrap.go b/internal/workflowdef/bootstrap.go new file mode 100644 index 00000000..aa34e495 --- /dev/null +++ b/internal/workflowdef/bootstrap.go @@ -0,0 +1,139 @@ +package workflowdef + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" + + "chat-agent-system/internal/projectworkflow" + "chat-agent-system/internal/storage" +) + +func legacySingleStage(key, name, desc, stageKey, taskKey, docType, docTitle, upstream string) CreateDefinitionInput { + return CreateDefinitionInput{ + Key: key, + Name: name, + Description: desc, + ProductDomain: "medical_device", + Definition: VersionDefinition{ + SchemaVersion: 1, + RequiredUpstreamWorkflow: upstream, + Stages: []StageDef{{ + Key: stageKey, Name: stageKey, Position: 0, + DocTasks: []DocTaskDef{{ + Key: taskKey, Title: docTitle, Required: true, Blocking: true, DefaultEnabled: true, + OutputDocType: docType, OutputDocTitle: docTitle, + TemplateAssetKey: taskKey, TemplateVersion: 1, + }}, + }}, + }, + } +} + +// BootstrapSeeds 幂等导入静态 catalog、首批行业工作流与 templates/docs。 +func BootstrapSeeds(ctx context.Context, pool *pgxpool.Pool, cas *storage.CAS, templatesDir string, publishedBy int64) error { + if pool == nil || !TablesReady(ctx, pool) { + return nil + } + store := NewStore(pool) + svc := NewService(pool, cas) + + if templatesDir == "" { + templatesDir = "templates/docs" + } + entries, err := os.ReadDir(templatesDir) + if err != nil { + return nil + } + for _, ent := range entries { + if ent.IsDir() || !strings.HasSuffix(strings.ToLower(ent.Name()), ".md") { + continue + } + key := strings.TrimSuffix(ent.Name(), filepath.Ext(ent.Name())) + data, readErr := os.ReadFile(filepath.Join(templatesDir, ent.Name())) + if readErr != nil || len(data) == 0 { + continue + } + _, _ = svc.UploadTemplate(ctx, key, ent.Name(), data, ent.Name(), publishedBy) + } + + seeds := []CreateDefinitionInput{ + legacySingleStage("main", "商务与研发综合主线", "V0.1 分诊到 V0.6 阶段评审", "main", "main", "main_proposal", "商务研发综合方案", ""), + legacySingleStage("product_manager", "智能产品经理", "系统需求规格书生成", "system_reqs", "system_reqs", "system_reqs", "系统需求规格书", ""), + legacySingleStage("test_manager", "智能测试经理", "系统验证方案及计划", "system_test_plan", "test_manager", "system_test_plan", "系统验证方案及计划", "product_manager"), + medicalDeviceWorkflow(), + nonMedicalSoftwareWorkflow(), + } + for _, seed := range seeds { + if err := store.UpsertPublishedSeed(ctx, seed, publishedBy); err != nil { + return err + } + } + return nil +} + +func medicalDeviceWorkflow() CreateDefinitionInput { + return CreateDefinitionInput{ + Key: "medical_device_development", + Name: "医疗器械研发", + Description: "商务摸底 → 需求策划 → 设计开发 → 验证确认 → 设计转移", + ProductDomain: "medical_device", + Definition: VersionDefinition{ + SchemaVersion: 1, + Stages: []StageDef{ + stage("biz_intake", "商务摸底", 0, doc("intake", "客户需求采集", "intake", "客户需求采集表")), + stage("req_planning", "需求与法规策划", 1, doc("system_reqs", "系统需求规格书", "system_reqs", "系统需求规格书")), + stage("design_dev", "设计开发", 2, doc("prelim_design", "初步设计", "prelim_design", "初步设计文档")), + stage("verification", "设计验证/确认", 3, doc("test_manager", "系统验证方案", "system_test_plan", "系统验证方案及计划")), + stage("transfer", "设计转移/发布", 4, doc("feasibility_reg", "法规可行性", "feasibility_reg", "法规可行性评估")), + }, + }, + } +} + +func nonMedicalSoftwareWorkflow() CreateDefinitionInput { + return CreateDefinitionInput{ + Key: "non_medical_software", + Name: "非医疗纯软件", + Description: "机会范围 → 需求 → 架构 → 实现 → 测试验收 → 发布复盘", + ProductDomain: "non_medical_software", + Definition: VersionDefinition{ + SchemaVersion: 1, + Stages: []StageDef{ + stage("opportunity", "机会与范围", 0, doc("intake", "范围说明", "scope_doc", "项目范围说明")), + stage("requirements", "产品需求", 1, doc("user_requirements", "用户需求", "user_requirements", "用户需求说明")), + stage("architecture", "架构与计划", 2, doc("feasibility_dev", "技术可行性", "feasibility_dev", "技术可行性分析")), + stage("implementation", "实现", 3, doc("prelim_design", "架构设计", "architecture_design", "架构设计文档")), + stage("testing", "测试与验收", 4, doc("test_manager", "测试计划", "test_plan", "测试计划")), + stage("release", "发布复盘", 5, doc("research", "发布复盘", "release_review", "发布复盘报告")), + }, + }, + } +} + +func stage(key, name string, pos int, tasks ...DocTaskDef) StageDef { + return StageDef{Key: key, Name: name, Position: pos, DocTasks: tasks} +} + +func doc(key, title, docType, docTitle string) DocTaskDef { + return DocTaskDef{ + Key: key, Title: title, Required: true, Blocking: true, DefaultEnabled: true, + TemplateAssetKey: key, TemplateVersion: 1, + OutputDocType: docType, OutputDocTitle: docTitle, + } +} + +// SeedFromMemoryCatalog 将内存 catalog 转为种子(测试用)。 +func SeedFromMemoryCatalog(cat projectworkflow.Catalog) []CreateDefinitionInput { + if cat == nil { + return nil + } + out := make([]CreateDefinitionInput, 0) + for _, d := range cat.List() { + out = append(out, legacySingleStage(d.Key, d.Name, d.Description, d.StageKey, d.TaskKey, d.OutputDocType, d.OutputDocTitle, d.RequiredUpstreamWorkflow)) + } + return out +} diff --git a/internal/workflowdef/catalog.go b/internal/workflowdef/catalog.go new file mode 100644 index 00000000..e3117d35 --- /dev/null +++ b/internal/workflowdef/catalog.go @@ -0,0 +1,142 @@ +package workflowdef + +import ( + "context" + "sort" + "sync" + + "chat-agent-system/internal/projectworkflow" +) + +// DBCatalog 从已发布工作流版本构建 projectworkflow.Catalog(向后兼容扁平 Definition)。 +type DBCatalog struct { + mu sync.RWMutex + store *Store + items map[string]*projectworkflow.Definition + order []string +} + +func NewDBCatalog(ctx context.Context, store *Store) (*DBCatalog, error) { + c := &DBCatalog{store: store, items: make(map[string]*projectworkflow.Definition)} + if err := c.reload(ctx); err != nil { + return nil, err + } + return c, nil +} + +func (c *DBCatalog) reload(ctx context.Context) error { + versions, err := c.store.ListPublishedCatalogEntries(ctx) + if err != nil { + return err + } + items := make(map[string]*projectworkflow.Definition) + order := make([]string, 0, len(versions)) + for _, v := range versions { + def := flattenVersion(v) + if def == nil { + continue + } + items[v.WorkflowKey] = def + order = append(order, v.WorkflowKey) + } + sort.Strings(order) + c.mu.Lock() + c.items = items + c.order = order + c.mu.Unlock() + return nil +} + +func flattenVersion(v Version) *projectworkflow.Definition { + if len(v.Definition.Stages) == 0 { + return nil + } + st := v.Definition.Stages[0] + taskKey, taskTitle, docType := "", "", "" + if len(st.DocTasks) > 0 { + dt := st.DocTasks[0] + taskKey = dt.Key + taskTitle = dt.OutputDocTitle + docType = dt.OutputDocType + if taskTitle == "" { + taskTitle = dt.Title + } + } + if taskKey == "" { + taskKey = st.Key + } + name := v.WorkflowName + if name == "" { + name = v.WorkflowKey + } + return &projectworkflow.Definition{ + Key: v.WorkflowKey, + Name: name, + Description: v.Description, + StageKey: st.Key, + TaskKey: taskKey, + RequiredUpstreamWorkflow: v.Definition.RequiredUpstreamWorkflow, + OutputDocType: docType, + OutputDocTitle: taskTitle, + Enabled: true, + } +} + +func (c *DBCatalog) HasDefinitions() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.items) > 0 +} + +func (c *DBCatalog) Refresh(ctx context.Context) error { + return c.reload(ctx) +} + +func (c *DBCatalog) Get(workflowKey string) (*projectworkflow.Definition, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + d, ok := c.items[workflowKey] + if !ok { + return nil, false + } + cpy := *d + return &cpy, true +} + +func (c *DBCatalog) List() []*projectworkflow.Definition { + c.mu.RLock() + defer c.mu.RUnlock() + out := make([]*projectworkflow.Definition, 0, len(c.order)) + for _, key := range c.order { + if d, ok := c.items[key]; ok { + cpy := *d + out = append(out, &cpy) + } + } + return out +} + +func (c *DBCatalog) Validate() error { + c.mu.RLock() + defer c.mu.RUnlock() + for _, def := range c.items { + if def.RequiredUpstreamWorkflow != "" { + if _, ok := c.items[def.RequiredUpstreamWorkflow]; !ok { + return projectworkflow.ErrWorkflowNotFound + } + } + } + return nil +} + +// NewCatalog 优先 DB 已发布 catalog,否则回退静态 catalog。 +func NewCatalog(ctx context.Context, store *Store, fallback projectworkflow.Catalog) projectworkflow.Catalog { + if store == nil || !store.enabled() { + return fallback + } + dbCat, err := NewDBCatalog(ctx, store) + if err != nil || !dbCat.HasDefinitions() { + return fallback + } + return dbCat +} diff --git a/internal/workflowdef/errors.go b/internal/workflowdef/errors.go new file mode 100644 index 00000000..4a9f9def --- /dev/null +++ b/internal/workflowdef/errors.go @@ -0,0 +1,15 @@ +package workflowdef + +import "errors" + +var ( + ErrNotAvailable = errors.New("workflowdef: database not available") + ErrNotFound = errors.New("workflowdef: not found") + ErrInvalidInput = errors.New("workflowdef: invalid input") + ErrDraftExists = errors.New("workflowdef: draft version already exists") + ErrNotDraft = errors.New("workflowdef: only draft versions are editable") + ErrPublishedImmutable = errors.New("workflowdef: published version is immutable") + ErrValidationFailed = errors.New("workflowdef: validation failed") + ErrTemplateNotReady = errors.New("workflowdef: template version not ready") + ErrUploadRejected = errors.New("workflowdef: upload rejected") +) diff --git a/internal/workflowdef/publish_test.go b/internal/workflowdef/publish_test.go new file mode 100644 index 00000000..e3c239f1 --- /dev/null +++ b/internal/workflowdef/publish_test.go @@ -0,0 +1,72 @@ +package workflowdef_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "chat-agent-system/internal/workflowdef" +) + +func openPool(t *testing.T) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + dsn = "postgres://app:dev_secret_db_pass@127.0.0.1:5433/agent_system_dev?sslmode=disable" + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Skip(err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + t.Skip(err) + } + if !workflowdef.TablesReady(ctx, pool) { + pool.Close() + t.Skip("workflowdef tables not migrated") + } + return pool +} + +func TestPublishVersion_ImmutableAfterPublish(t *testing.T) { + pool := openPool(t) + if pool == nil { + return + } + defer pool.Close() + ctx := context.Background() + svc := workflowdef.NewService(pool, nil) + key := "test_wf_" + time.Now().Format("150405") + def, ver, err := svc.CreateDefinition(ctx, workflowdef.CreateDefinitionInput{ + Key: key, Name: "测试工作流", + Definition: workflowdef.VersionDefinition{ + SchemaVersion: 1, + Stages: []workflowdef.StageDef{{ + Key: "s1", Position: 0, + DocTasks: []workflowdef.DocTaskDef{{ + Key: "t1", DefaultEnabled: true, OutputDocType: "d1", OutputDocTitle: "D1", + }}, + }}, + }, + }, 1) + if err != nil { + t.Fatalf("create: %v", err) + } + published, err := svc.PublishVersion(ctx, def.ID, ver.Version, 1) + if err != nil { + t.Fatalf("publish: %v", err) + } + if published.Status != workflowdef.StatusPublished { + t.Fatalf("expected published, got %s", published.Status) + } + _, err = svc.UpdateDraft(ctx, def.ID, ver.Version, published.Definition) + if err == nil { + t.Fatal("expected update on published version to fail") + } +} diff --git a/internal/workflowdef/service.go b/internal/workflowdef/service.go new file mode 100644 index 00000000..98971a2b --- /dev/null +++ b/internal/workflowdef/service.go @@ -0,0 +1,151 @@ +package workflowdef + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" + + "chat-agent-system/internal/storage" + "chat-agent-system/internal/upload" +) + +// Service 工作流定义与模板领域服务。 +type Service struct { + store *Store + cas *storage.CAS +} + +func NewService(pool *pgxpool.Pool, cas *storage.CAS) *Service { + if pool == nil { + return &Service{} + } + return &Service{store: NewStore(pool), cas: cas} +} + +func (s *Service) Available() bool { + return s != nil && s.store != nil && s.store.enabled() +} + +func (s *Service) Store() *Store { + return s.store +} + +func (s *Service) ListDefinitions(ctx context.Context) ([]Definition, error) { + return s.store.ListDefinitions(ctx) +} + +func (s *Service) GetDefinition(ctx context.Context, id int64) (*Definition, error) { + return s.store.GetDefinitionByID(ctx, id) +} + +func (s *Service) CreateDefinition(ctx context.Context, in CreateDefinitionInput, createdBy int64) (*Definition, *Version, error) { + res := ValidateDefinition(in.Definition, s.templateReady(ctx), s.upstreamExists(ctx)) + if !res.Valid { + return nil, nil, ErrValidationFailed + } + return s.store.CreateDefinition(ctx, in, createdBy) +} + +func (s *Service) UpdateDraft(ctx context.Context, workflowID int64, version int, def VersionDefinition) (*Version, error) { + res := ValidateDefinition(def, s.templateReady(ctx), s.upstreamExists(ctx)) + if !res.Valid { + return nil, ErrValidationFailed + } + return s.store.UpdateDraftVersion(ctx, workflowID, version, def) +} + +func (s *Service) ValidateVersion(ctx context.Context, workflowID int64, version int) (ValidationResult, error) { + v, err := s.store.GetVersion(ctx, workflowID, version) + if err != nil { + return ValidationResult{}, err + } + return ValidateDefinition(v.Definition, s.templateReady(ctx), s.upstreamExists(ctx)), nil +} + +func (s *Service) PublishVersion(ctx context.Context, workflowID int64, version int, publishedBy int64) (*Version, error) { + res, err := s.ValidateVersion(ctx, workflowID, version) + if err != nil { + return nil, err + } + if !res.Valid { + return nil, ErrValidationFailed + } + return s.store.PublishVersion(ctx, workflowID, version, publishedBy) +} + +func (s *Service) RetireVersion(ctx context.Context, workflowID int64, version int) (*Version, error) { + return s.store.RetireVersion(ctx, workflowID, version) +} + +func (s *Service) ListVersions(ctx context.Context, workflowID int64) ([]Version, error) { + return s.store.ListVersions(ctx, workflowID) +} + +func (s *Service) ListTemplates(ctx context.Context) ([]TemplateAsset, error) { + return s.store.ListTemplateAssets(ctx) +} + +func (s *Service) ListTemplateVersions(ctx context.Context, assetKey string) ([]TemplateVersion, error) { + return s.store.ListTemplateVersions(ctx, assetKey) +} + +// UploadTemplate 上传模板文件并创建 template_version(扩展名白名单 + MIME/魔数校验后标记 ready)。 +func (s *Service) UploadTemplate(ctx context.Context, assetKey, name string, data []byte, fileName string, createdBy int64) (*TemplateVersion, error) { + if !s.Available() || s.cas == nil { + return nil, ErrNotAvailable + } + ext := strings.ToLower(filepath.Ext(fileName)) + mime, err := upload.ValidateTemplate(fileName, "", data) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUploadRejected, err) + } + + ref, err := s.cas.StoreFile(ctx, 0, createdBy, fileName, "template", data, mime) + if err != nil { + return nil, err + } + + assetID, err := s.store.UpsertTemplateAsset(ctx, assetKey, name) + if err != nil { + return nil, err + } + ver, err := s.store.NextTemplateVersion(ctx, assetID) + if err != nil { + return nil, err + } + scanStatus := ScanReady + if ext == ".docx" || ext == ".xlsx" || ext == ".pptx" { + scanStatus = ScanPending + } + tv, err := s.store.CreateTemplateVersion(ctx, assetID, ver, ref.BlobSHA256, mime, int64(len(data)), scanStatus, createdBy) + if err != nil { + return nil, err + } + tv.AssetKey = assetKey + return tv, nil +} + +func (s *Service) templateReady(ctx context.Context) templateReadyChecker { + return func(assetKey string, version int) bool { + if assetKey == "" { + return true + } + if version <= 0 { + version = 1 + } + return s.store.IsTemplateReady(ctx, assetKey, version) + } +} + +func (s *Service) upstreamExists(ctx context.Context) upstreamExistsChecker { + return func(workflowKey string) bool { + if workflowKey == "" { + return true + } + d, err := s.store.GetDefinitionByKey(ctx, workflowKey) + return err == nil && d != nil + } +} diff --git a/internal/workflowdef/store.go b/internal/workflowdef/store.go new file mode 100644 index 00000000..22adf693 --- /dev/null +++ b/internal/workflowdef/store.go @@ -0,0 +1,527 @@ +package workflowdef + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// TablesReady 检查 Phase 2 表是否已迁移。 +func TablesReady(ctx context.Context, pool *pgxpool.Pool) bool { + if pool == nil { + return false + } + var ok bool + err := pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'workflow_definitions' + ) + `).Scan(&ok) + return err == nil && ok +} + +// Store PostgreSQL 工作流定义与模板存储。 +type Store struct { + pool *pgxpool.Pool +} + +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +func (s *Store) enabled() bool { + return s != nil && s.pool != nil +} + +func hashDefinition(def VersionDefinition) (string, error) { + raw, err := json.Marshal(def) + if err != nil { + return "", err + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]), nil +} + +func scanVersion(row pgx.Row) (*Version, error) { + var v Version + var defRaw []byte + var pubBy, createdBy *int64 + err := row.Scan( + &v.ID, &v.WorkflowID, &v.Version, &v.Status, &v.SchemaVersion, + &defRaw, &v.DefinitionHash, &pubBy, &v.PublishedAt, &createdBy, + &v.CreatedAt, &v.UpdatedAt, + ) + if err != nil { + return nil, err + } + v.PublishedBy = pubBy + v.CreatedBy = createdBy + if len(defRaw) > 0 { + _ = json.Unmarshal(defRaw, &v.Definition) + } + return &v, nil +} + +// ListDefinitions 列出全部工作流定义。 +func (s *Store) ListDefinitions(ctx context.Context) ([]Definition, error) { + if !s.enabled() { + return nil, ErrNotAvailable + } + rows, err := s.pool.Query(ctx, ` + SELECT id, def_key, name, description, status, latest_published_version, product_domain, created_at, updated_at + FROM workflow_definitions ORDER BY def_key ASC + `) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]Definition, 0) + for rows.Next() { + var d Definition + if err := rows.Scan(&d.ID, &d.Key, &d.Name, &d.Description, &d.Status, &d.LatestPublishedVersion, &d.ProductDomain, &d.CreatedAt, &d.UpdatedAt); err != nil { + return nil, err + } + out = append(out, d) + } + return out, rows.Err() +} + +// GetDefinitionByKey 按 key 查询定义。 +func (s *Store) GetDefinitionByKey(ctx context.Context, key string) (*Definition, error) { + if !s.enabled() { + return nil, ErrNotAvailable + } + var d Definition + err := s.pool.QueryRow(ctx, ` + SELECT id, def_key, name, description, status, latest_published_version, product_domain, created_at, updated_at + FROM workflow_definitions WHERE def_key = $1 + `, key).Scan(&d.ID, &d.Key, &d.Name, &d.Description, &d.Status, &d.LatestPublishedVersion, &d.ProductDomain, &d.CreatedAt, &d.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &d, nil +} + +// GetDefinitionByID 按 ID 查询定义。 +func (s *Store) GetDefinitionByID(ctx context.Context, id int64) (*Definition, error) { + if !s.enabled() { + return nil, ErrNotAvailable + } + var d Definition + err := s.pool.QueryRow(ctx, ` + SELECT id, def_key, name, description, status, latest_published_version, product_domain, created_at, updated_at + FROM workflow_definitions WHERE id = $1 + `, id).Scan(&d.ID, &d.Key, &d.Name, &d.Description, &d.Status, &d.LatestPublishedVersion, &d.ProductDomain, &d.CreatedAt, &d.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &d, nil +} + +// CreateDefinition 创建定义及首个草稿版本。 +func (s *Store) CreateDefinition(ctx context.Context, in CreateDefinitionInput, createdBy int64) (*Definition, *Version, error) { + if !s.enabled() { + return nil, nil, ErrNotAvailable + } + if in.Key == "" || in.Name == "" { + return nil, nil, ErrInvalidInput + } + defHash, err := hashDefinition(in.Definition) + if err != nil { + return nil, nil, err + } + defRaw, _ := json.Marshal(in.Definition) + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, nil, err + } + defer tx.Rollback(ctx) + + var defID int64 + err = tx.QueryRow(ctx, ` + INSERT INTO workflow_definitions (def_key, name, description, product_domain) + VALUES ($1, $2, $3, $4) RETURNING id + `, in.Key, in.Name, in.Description, in.ProductDomain).Scan(&defID) + if err != nil { + return nil, nil, fmt.Errorf("insert definition: %w", err) + } + + var ver Version + err = tx.QueryRow(ctx, ` + INSERT INTO workflow_versions (workflow_id, version, status, schema_version, definition_json, definition_hash, created_by) + VALUES ($1, 1, 'draft', $2, $3, $4, $5) + RETURNING id, workflow_id, version, status, schema_version, definition_json, definition_hash, + published_by, published_at, created_by, created_at, updated_at + `, defID, in.Definition.SchemaVersion, defRaw, defHash, createdBy).Scan( + &ver.ID, &ver.WorkflowID, &ver.Version, &ver.Status, &ver.SchemaVersion, + &defRaw, &ver.DefinitionHash, &ver.PublishedBy, &ver.PublishedAt, &ver.CreatedBy, + &ver.CreatedAt, &ver.UpdatedAt, + ) + if err != nil { + return nil, nil, err + } + _ = json.Unmarshal(defRaw, &ver.Definition) + + if err := tx.Commit(ctx); err != nil { + return nil, nil, err + } + d, err := s.GetDefinitionByID(ctx, defID) + return d, &ver, err +} + +// GetVersion 获取指定版本。 +func (s *Store) GetVersion(ctx context.Context, workflowID int64, version int) (*Version, error) { + if !s.enabled() { + return nil, ErrNotAvailable + } + row := s.pool.QueryRow(ctx, ` + SELECT id, workflow_id, version, status, schema_version, definition_json, definition_hash, + published_by, published_at, created_by, created_at, updated_at + FROM workflow_versions WHERE workflow_id = $1 AND version = $2 + `, workflowID, version) + v, err := scanVersion(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return v, err +} + +// ListVersions 列出工作流全部版本。 +func (s *Store) ListVersions(ctx context.Context, workflowID int64) ([]Version, error) { + if !s.enabled() { + return nil, ErrNotAvailable + } + rows, err := s.pool.Query(ctx, ` + SELECT id, workflow_id, version, status, schema_version, definition_json, definition_hash, + published_by, published_at, created_by, created_at, updated_at + FROM workflow_versions WHERE workflow_id = $1 ORDER BY version DESC + `, workflowID) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]Version, 0) + for rows.Next() { + v, err := scanVersion(rows) + if err != nil { + return nil, err + } + out = append(out, *v) + } + return out, rows.Err() +} + +// UpdateDraftVersion 更新草稿版本定义。 +func (s *Store) UpdateDraftVersion(ctx context.Context, workflowID int64, version int, def VersionDefinition) (*Version, error) { + if !s.enabled() { + return nil, ErrNotAvailable + } + existing, err := s.GetVersion(ctx, workflowID, version) + if err != nil { + return nil, err + } + if existing.Status != StatusDraft { + return nil, ErrNotDraft + } + defHash, err := hashDefinition(def) + if err != nil { + return nil, err + } + defRaw, _ := json.Marshal(def) + row := s.pool.QueryRow(ctx, ` + UPDATE workflow_versions SET definition_json = $3, definition_hash = $4, schema_version = $5, updated_at = now() + WHERE workflow_id = $1 AND version = $2 AND status = 'draft' + RETURNING id, workflow_id, version, status, schema_version, definition_json, definition_hash, + published_by, published_at, created_by, created_at, updated_at + `, workflowID, version, defRaw, defHash, def.SchemaVersion) + return scanVersion(row) +} + +// PublishVersion 发布草稿版本(不可变)。 +func (s *Store) PublishVersion(ctx context.Context, workflowID int64, version int, publishedBy int64) (*Version, error) { + if !s.enabled() { + return nil, ErrNotAvailable + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + var status string + if err := tx.QueryRow(ctx, `SELECT status FROM workflow_versions WHERE workflow_id = $1 AND version = $2`, workflowID, version).Scan(&status); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + if status != StatusDraft { + return nil, ErrNotDraft + } + + row := tx.QueryRow(ctx, ` + UPDATE workflow_versions SET status = 'published', published_by = $3, published_at = now(), updated_at = now() + WHERE workflow_id = $1 AND version = $2 AND status = 'draft' + RETURNING id, workflow_id, version, status, schema_version, definition_json, definition_hash, + published_by, published_at, created_by, created_at, updated_at + `, workflowID, version, publishedBy) + v, err := scanVersion(row) + if err != nil { + return nil, err + } + + _, err = tx.Exec(ctx, ` + UPDATE workflow_definitions SET latest_published_version = $2, updated_at = now() WHERE id = $1 + `, workflowID, version) + if err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return v, nil +} + +// RetireVersion 退役已发布版本。 +func (s *Store) RetireVersion(ctx context.Context, workflowID int64, version int) (*Version, error) { + if !s.enabled() { + return nil, ErrNotAvailable + } + row := s.pool.QueryRow(ctx, ` + UPDATE workflow_versions SET status = 'retired', updated_at = now() + WHERE workflow_id = $1 AND version = $2 AND status = 'published' + RETURNING id, workflow_id, version, status, schema_version, definition_json, definition_hash, + published_by, published_at, created_by, created_at, updated_at + `, workflowID, version) + v, err := scanVersion(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return v, err +} + +// ListPublishedCatalogEntries 返回全部 published 版本(catalog 用)。 +func (s *Store) ListPublishedCatalogEntries(ctx context.Context) ([]Version, error) { + if !s.enabled() { + return nil, ErrNotAvailable + } + rows, err := s.pool.Query(ctx, ` + SELECT v.id, v.workflow_id, v.version, v.status, v.schema_version, v.definition_json, v.definition_hash, + v.published_by, v.published_at, v.created_by, v.created_at, v.updated_at, + d.def_key, d.name, d.description + FROM workflow_versions v + JOIN workflow_definitions d ON d.id = v.workflow_id + WHERE v.status = 'published' + ORDER BY d.def_key ASC, v.version DESC + `) + if err != nil { + return nil, err + } + defer rows.Close() + seen := map[int64]bool{} + out := make([]Version, 0) + for rows.Next() { + var v Version + var defRaw []byte + var pubBy, createdBy *int64 + var wfKey, wfName, wfDesc string + if err := rows.Scan( + &v.ID, &v.WorkflowID, &v.Version, &v.Status, &v.SchemaVersion, + &defRaw, &v.DefinitionHash, &pubBy, &v.PublishedAt, &createdBy, + &v.CreatedAt, &v.UpdatedAt, &wfKey, &wfName, &wfDesc, + ); err != nil { + return nil, err + } + if seen[v.WorkflowID] { + continue + } + seen[v.WorkflowID] = true + v.WorkflowKey = wfKey + v.WorkflowName = wfName + v.Description = wfDesc + v.PublishedBy = pubBy + v.CreatedBy = createdBy + _ = json.Unmarshal(defRaw, &v.Definition) + out = append(out, v) + } + return out, rows.Err() +} + +// UpsertPublishedSeed 幂等写入已发布种子(bootstrap)。 +func (s *Store) UpsertPublishedSeed(ctx context.Context, in CreateDefinitionInput, publishedBy int64) error { + if !s.enabled() { + return ErrNotAvailable + } + existing, err := s.GetDefinitionByKey(ctx, in.Key) + if err == nil && existing != nil && existing.LatestPublishedVersion > 0 { + return nil + } + if err != nil && !errors.Is(err, ErrNotFound) { + return err + } + + defHash, err := hashDefinition(in.Definition) + if err != nil { + return err + } + defRaw, _ := json.Marshal(in.Definition) + + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + var defID int64 + if existing != nil { + defID = existing.ID + } else { + err = tx.QueryRow(ctx, ` + INSERT INTO workflow_definitions (def_key, name, description, product_domain, latest_published_version) + VALUES ($1, $2, $3, $4, 1) RETURNING id + `, in.Key, in.Name, in.Description, in.ProductDomain).Scan(&defID) + if err != nil { + return err + } + } + + _, err = tx.Exec(ctx, ` + INSERT INTO workflow_versions (workflow_id, version, status, schema_version, definition_json, definition_hash, published_by, published_at, created_by) + VALUES ($1, 1, 'published', $2, $3, $4, $5, now(), $5) + ON CONFLICT (workflow_id, version) DO NOTHING + `, defID, in.Definition.SchemaVersion, defRaw, defHash, publishedBy) + if err != nil { + return err + } + + _, err = tx.Exec(ctx, ` + UPDATE workflow_definitions SET latest_published_version = 1, updated_at = now() WHERE id = $1 + `, defID) + if err != nil { + return err + } + return tx.Commit(ctx) +} + +// Template store methods + +func (s *Store) UpsertTemplateAsset(ctx context.Context, assetKey, name string) (int64, error) { + if !s.enabled() { + return 0, ErrNotAvailable + } + var id int64 + err := s.pool.QueryRow(ctx, ` + INSERT INTO template_assets (asset_key, name) VALUES ($1, $2) + ON CONFLICT (asset_key) DO UPDATE SET name = EXCLUDED.name, updated_at = now() + RETURNING id + `, assetKey, name).Scan(&id) + return id, err +} + +func (s *Store) CreateTemplateVersion(ctx context.Context, assetID int64, version int, sha256, mime string, size int64, scanStatus string, createdBy int64) (*TemplateVersion, error) { + if !s.enabled() { + return nil, ErrNotAvailable + } + var tv TemplateVersion + err := s.pool.QueryRow(ctx, ` + INSERT INTO template_versions (asset_id, version, blob_sha256, mime_type, size_bytes, scan_status, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (asset_id, version) DO UPDATE SET + blob_sha256 = EXCLUDED.blob_sha256, + mime_type = EXCLUDED.mime_type, + size_bytes = EXCLUDED.size_bytes, + scan_status = EXCLUDED.scan_status + RETURNING id, asset_id, version, blob_sha256, mime_type, size_bytes, scan_status, created_at + `, assetID, version, sha256, mime, size, scanStatus, createdBy).Scan( + &tv.ID, &tv.AssetID, &tv.Version, &tv.BlobSHA256, &tv.MimeType, &tv.SizeBytes, &tv.ScanStatus, &tv.CreatedAt, + ) + if err != nil { + return nil, err + } + return &tv, nil +} + +func (s *Store) IsTemplateReady(ctx context.Context, assetKey string, version int) bool { + if !s.enabled() { + return false + } + var ready bool + _ = s.pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM template_versions tv + JOIN template_assets ta ON ta.id = tv.asset_id + WHERE ta.asset_key = $1 AND tv.version = $2 AND tv.scan_status = 'ready' + ) + `, assetKey, version).Scan(&ready) + return ready +} + +func (s *Store) ListTemplateAssets(ctx context.Context) ([]TemplateAsset, error) { + if !s.enabled() { + return nil, ErrNotAvailable + } + rows, err := s.pool.Query(ctx, ` + SELECT id, asset_key, name, status, created_at, updated_at FROM template_assets ORDER BY asset_key ASC + `) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]TemplateAsset, 0) + for rows.Next() { + var a TemplateAsset + if err := rows.Scan(&a.ID, &a.AssetKey, &a.Name, &a.Status, &a.CreatedAt, &a.UpdatedAt); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +func (s *Store) ListTemplateVersions(ctx context.Context, assetKey string) ([]TemplateVersion, error) { + if !s.enabled() { + return nil, ErrNotAvailable + } + rows, err := s.pool.Query(ctx, ` + SELECT tv.id, tv.asset_id, ta.asset_key, tv.version, tv.blob_sha256, tv.mime_type, tv.size_bytes, tv.scan_status, tv.created_at + FROM template_versions tv + JOIN template_assets ta ON ta.id = tv.asset_id + WHERE ta.asset_key = $1 ORDER BY tv.version DESC + `, assetKey) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]TemplateVersion, 0) + for rows.Next() { + var tv TemplateVersion + if err := rows.Scan(&tv.ID, &tv.AssetID, &tv.AssetKey, &tv.Version, &tv.BlobSHA256, &tv.MimeType, &tv.SizeBytes, &tv.ScanStatus, &tv.CreatedAt); err != nil { + return nil, err + } + out = append(out, tv) + } + return out, rows.Err() +} + +func (s *Store) NextTemplateVersion(ctx context.Context, assetID int64) (int, error) { + if !s.enabled() { + return 0, ErrNotAvailable + } + var next int + err := s.pool.QueryRow(ctx, `SELECT COALESCE(MAX(version), 0) + 1 FROM template_versions WHERE asset_id = $1`, assetID).Scan(&next) + return next, err +} diff --git a/internal/workflowdef/types.go b/internal/workflowdef/types.go new file mode 100644 index 00000000..f5882f48 --- /dev/null +++ b/internal/workflowdef/types.go @@ -0,0 +1,122 @@ +package workflowdef + +import "time" + +const ( + StatusDraft = "draft" + StatusPublished = "published" + StatusRetired = "retired" + + ScanPending = "pending" + ScanReady = "ready" + ScanRejected = "rejected" +) + +// DocTaskDef 阶段内文档任务定义。 +type DocTaskDef struct { + Key string `json:"key"` + Title string `json:"title"` + Required bool `json:"required"` + Blocking bool `json:"blocking"` + DefaultEnabled bool `json:"default_enabled"` + TemplateAssetKey string `json:"template_asset_key,omitempty"` + TemplateVersion int `json:"template_version,omitempty"` + OutputDocType string `json:"output_doc_type"` + OutputDocTitle string `json:"output_doc_title"` +} + +// StageDef 线性阶段定义。 +type StageDef struct { + Key string `json:"key"` + Name string `json:"name"` + Position int `json:"position"` + Optional bool `json:"optional"` + ManualGate bool `json:"manual_gate"` + DocTasks []DocTaskDef `json:"doc_tasks"` +} + +// VersionDefinition 工作流版本完整快照(JSONB)。 +type VersionDefinition struct { + SchemaVersion int `json:"schema_version"` + RequiredUpstreamWorkflow string `json:"required_upstream_workflow,omitempty"` + Stages []StageDef `json:"stages"` +} + +// Definition 工作流逻辑标识。 +type Definition struct { + ID int64 `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + ProductDomain string `json:"product_domain,omitempty"` + LatestPublishedVersion int `json:"latest_published_version"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Version 工作流版本记录。 +type Version struct { + ID int64 `json:"id"` + WorkflowID int64 `json:"workflow_id"` + WorkflowKey string `json:"workflow_key,omitempty"` + WorkflowName string `json:"workflow_name,omitempty"` + Description string `json:"description,omitempty"` + Version int `json:"version"` + Status string `json:"status"` + SchemaVersion int `json:"schema_version"` + Definition VersionDefinition `json:"definition"` + DefinitionHash string `json:"definition_hash"` + PublishedBy *int64 `json:"published_by,omitempty"` + PublishedAt *time.Time `json:"published_at,omitempty"` + CreatedBy *int64 `json:"created_by,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ValidationIssue 校验问题。 +type ValidationIssue struct { + Code string `json:"code"` + Message string `json:"message"` + Field string `json:"field,omitempty"` +} + +// ValidationResult 校验结果。 +type ValidationResult struct { + Valid bool `json:"valid"` + Issues []ValidationIssue `json:"issues"` +} + +// CreateDefinitionInput 新建工作流定义。 +type CreateDefinitionInput struct { + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + ProductDomain string `json:"product_domain,omitempty"` + Definition VersionDefinition `json:"definition"` +} + +// TemplateAsset 模板逻辑资产。 +type TemplateAsset struct { + ID int64 `json:"id"` + AssetKey string `json:"asset_key"` + Name string `json:"name"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TemplateVersion 模板版本。 +type TemplateVersion struct { + ID int64 `json:"id"` + AssetID int64 `json:"asset_id"` + AssetKey string `json:"asset_key,omitempty"` + Version int `json:"version"` + BlobSHA256 string `json:"blob_sha256"` + MimeType string `json:"mime_type"` + SizeBytes int64 `json:"size_bytes"` + ScanStatus string `json:"scan_status"` + VariableSchema []string `json:"variable_schema"` + CreatedBy *int64 `json:"created_by,omitempty"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/workflowdef/validator.go b/internal/workflowdef/validator.go new file mode 100644 index 00000000..f6fb6638 --- /dev/null +++ b/internal/workflowdef/validator.go @@ -0,0 +1,88 @@ +package workflowdef + +import ( + "fmt" + "sort" + "strings" +) + +type templateReadyChecker func(assetKey string, version int) bool +type upstreamExistsChecker func(workflowKey string) bool + +// ValidateDefinition 对工作流版本定义做确定性校验。 +func ValidateDefinition(def VersionDefinition, tplReady templateReadyChecker, upstreamExists upstreamExistsChecker) ValidationResult { + issues := make([]ValidationIssue, 0) + if def.SchemaVersion <= 0 { + def.SchemaVersion = 1 + } + if len(def.Stages) == 0 { + issues = append(issues, ValidationIssue{Code: "NO_STAGES", Message: "至少需要一个阶段"}) + return ValidationResult{Valid: false, Issues: issues} + } + + stageKeys := map[string]struct{}{} + taskKeys := map[string]struct{}{} + enabledStageCount := 0 + + sorted := append([]StageDef(nil), def.Stages...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Position < sorted[j].Position }) + + for i, st := range sorted { + if st.Key == "" { + issues = append(issues, ValidationIssue{Code: "STAGE_KEY_REQUIRED", Message: "阶段 key 不能为空", Field: fmt.Sprintf("stages[%d].key", i)}) + continue + } + if _, dup := stageKeys[st.Key]; dup { + issues = append(issues, ValidationIssue{Code: "DUPLICATE_STAGE_KEY", Message: fmt.Sprintf("重复阶段 key: %s", st.Key), Field: st.Key}) + } + stageKeys[st.Key] = struct{}{} + if st.Position != i { + issues = append(issues, ValidationIssue{Code: "STAGE_POSITION_GAP", Message: fmt.Sprintf("阶段 %s 顺序应为 %d", st.Key, i), Field: st.Key}) + } + + enabledTasks := 0 + for j, dt := range st.DocTasks { + if dt.Key == "" { + issues = append(issues, ValidationIssue{Code: "TASK_KEY_REQUIRED", Message: "文档任务 key 不能为空", Field: fmt.Sprintf("%s.doc_tasks[%d]", st.Key, j)}) + continue + } + if _, dup := taskKeys[dt.Key]; dup { + issues = append(issues, ValidationIssue{Code: "DUPLICATE_TASK_KEY", Message: fmt.Sprintf("重复任务 key: %s", dt.Key), Field: dt.Key}) + } + taskKeys[dt.Key] = struct{}{} + if dt.OutputDocType == "" || dt.OutputDocTitle == "" { + issues = append(issues, ValidationIssue{Code: "TASK_OUTPUT_REQUIRED", Message: fmt.Sprintf("任务 %s 需 output_doc_type/title", dt.Key), Field: dt.Key}) + } + if dt.DefaultEnabled { + enabledTasks++ + } + if dt.TemplateAssetKey != "" && tplReady != nil { + ver := dt.TemplateVersion + if ver <= 0 { + ver = 1 + } + if !tplReady(dt.TemplateAssetKey, ver) { + issues = append(issues, ValidationIssue{Code: "TEMPLATE_NOT_READY", Message: fmt.Sprintf("模板 %s v%d 不可用", dt.TemplateAssetKey, ver), Field: dt.Key}) + } + } + } + if !st.Optional { + if enabledTasks == 0 && !st.ManualGate { + issues = append(issues, ValidationIssue{Code: "STAGE_EMPTY", Message: fmt.Sprintf("阶段 %s 无启用文档且未设 manual_gate", st.Key), Field: st.Key}) + } else { + enabledStageCount++ + } + } + } + + if enabledStageCount == 0 { + issues = append(issues, ValidationIssue{Code: "NO_ENABLED_STAGE", Message: "至少一个非 optional 阶段需有启用文档或 manual_gate"}) + } + + up := strings.TrimSpace(def.RequiredUpstreamWorkflow) + if up != "" && upstreamExists != nil && !upstreamExists(up) { + issues = append(issues, ValidationIssue{Code: "UPSTREAM_NOT_FOUND", Message: fmt.Sprintf("上游工作流 %s 不存在", up), Field: "required_upstream_workflow"}) + } + + return ValidationResult{Valid: len(issues) == 0, Issues: issues} +} diff --git a/internal/workflowdef/validator_test.go b/internal/workflowdef/validator_test.go new file mode 100644 index 00000000..0e9425c1 --- /dev/null +++ b/internal/workflowdef/validator_test.go @@ -0,0 +1,47 @@ +package workflowdef_test + +import ( + "testing" + + "chat-agent-system/internal/workflowdef" +) + +func TestValidateDefinition_RejectsEmptyStages(t *testing.T) { + res := workflowdef.ValidateDefinition(workflowdef.VersionDefinition{}, nil, nil) + if res.Valid { + t.Fatal("expected invalid for empty stages") + } +} + +func TestValidateDefinition_AcceptsMinimalWorkflow(t *testing.T) { + def := workflowdef.VersionDefinition{ + SchemaVersion: 1, + Stages: []workflowdef.StageDef{{ + Key: "s1", Name: "阶段1", Position: 0, + DocTasks: []workflowdef.DocTaskDef{{ + Key: "t1", Title: "任务1", DefaultEnabled: true, + OutputDocType: "doc1", OutputDocTitle: "文档1", + }}, + }}, + } + res := workflowdef.ValidateDefinition(def, func(string, int) bool { return true }, nil) + if !res.Valid { + t.Fatalf("expected valid, got issues: %+v", res.Issues) + } +} + +func TestValidateDefinition_RejectsMissingTemplate(t *testing.T) { + def := workflowdef.VersionDefinition{ + Stages: []workflowdef.StageDef{{ + Key: "s1", Position: 0, + DocTasks: []workflowdef.DocTaskDef{{ + Key: "t1", DefaultEnabled: true, TemplateAssetKey: "missing", + OutputDocType: "d", OutputDocTitle: "D", + }}, + }}, + } + res := workflowdef.ValidateDefinition(def, func(string, int) bool { return false }, nil) + if res.Valid { + t.Fatal("expected invalid when template not ready") + } +} diff --git a/scripts/backup-system-db.sh b/scripts/backup-system-db.sh new file mode 100755 index 00000000..795753e0 --- /dev/null +++ b/scripts/backup-system-db.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# ============================================================================= +# 系统业务库备份脚本 (backup-system-db.sh) +# +# 作用: +# 1. 按环境 (dev/test/main) 定位 Docker 容器与数据库; +# 2. 采用 pg_dump -Fc 导出系统业务库(含 task_runs、工作流治理等); +# 3. 生成 manifest.json 记录 RPO 基准元数据与 SHA256。 +# +# 用法: +# ./scripts/backup-system-db.sh