diff --git a/.cursor/rules/agent-collaboration-docs.mdc b/.cursor/rules/agent-collaboration-docs.mdc new file mode 100644 index 00000000..279d0adf --- /dev/null +++ b/.cursor/rules/agent-collaboration-docs.mdc @@ -0,0 +1,24 @@ +--- +description: 强制 Agent 文档同步与 ops 协作纪律;详情见 AGENTS.md +alwaysApply: true +--- + +# Agent 文档同步(强制) + +完整规范见 [AGENTS.md](../../AGENTS.md) 与 [PROJECT.md](../../PROJECT.md)。 + +## 开场必读 + +1. `AGENTS.md` → `PROJECT.md` → `.doc/ops/CURRENT.md` → 相关 short-term / handoff + +## 红线 + +- 任何任务/调研/审计/修复/运维:**开始前**建或更新 `.doc/ops/short-term/` 或 `long-term/`(复制 templates) +- **禁止**只改代码、真相只留在聊天 +- 里程碑更新 `CURRENT.md`;会话结束写 `handoffs/` +- 验收报告进 `.doc/test-reports/` 并回链 checklist +- 文档与代码同批或紧随 `docs(...)` 提交 + +## 状态 + +`planned | in_progress | blocked | done | cancelled` diff --git a/.cursor/rules/git-secrets-autonomy.mdc b/.cursor/rules/git-secrets-autonomy.mdc new file mode 100644 index 00000000..f2c4ed43 --- /dev/null +++ b/.cursor/rules/git-secrets-autonomy.mdc @@ -0,0 +1,24 @@ +--- +description: Git push 凭证自主读取;禁止向用户索要 token;详情见 AGENTS.md §8 +alwaysApply: true +--- + +# Git Secrets 自主(禁止索要) + +完整规范见 [AGENTS.md](../../AGENTS.md) §8 与 [PROJECT.md](../../PROJECT.md) §7。 + +## 本仓约定 + +- 凭证:`${HOME}/.secrets/gitea` +- 字段:`- ACCESS_TOKEN=`、`- URL_PREFIX=` +- 推送:优先 `./scripts/git-push-dev.sh ` + +## 禁止 + +- 向用户索要或要求粘贴 token +- 在聊天/文档/commit 中展示 secret +- 写入 git config 或提交到仓库 + +## 缺失时 + +在 `.doc/ops` 记 blocker(路径 + 缺字段),提示用户本机补齐后重试;**仍不索要内容**。 diff --git a/.doc/2026-09-12-AUDIT-INDEX.md b/.doc/2026-09-12-AUDIT-INDEX.md new file mode 100644 index 00000000..fd52d820 --- /dev/null +++ b/.doc/2026-09-12-AUDIT-INDEX.md @@ -0,0 +1,312 @@ +# 项目全面审计 - 文档索引 + +> **HISTORICAL(2026-09-12 初审快照)** +> **当前发布/缺口 SSOT** → [remediation-gap-matrix.md](./2026-09-12-remediation-gap-matrix.md) +> **协作焦点** → [.doc/ops/CURRENT.md](./ops/CURRENT.md) +> **Agent 工作方式** → [AGENTS.md](../AGENTS.md) · [PROJECT.md](../PROJECT.md) + +**审计日期**: 2026-09-12 +**项目**: Chat-Agent-System +**审计状态**: 历史索引(下方 5.7/10 为初审结论,勿作 live status) + +--- + +## 核心文档(按阅读顺序推荐) + +### 0️⃣ 当前状态(Live SSOT) + +**文件**: `2026-09-12-remediation-gap-matrix.md` +**协作**: `.doc/ops/CURRENT.md` · `AGENTS.md` · `PROJECT.md` +**用途**: 发布缺口、Codex 再审计闭环证据、发布门禁勾选 + +--- + +### 1️⃣ 从这里开始:审计总结报告(历史) +**文件**: `2026-09-12-audit-summary-report.md` +**用途**: 项目整体现状、关键数字、风险评估 +**适合**: 项目经理、技术负责人、快速概览 +**阅读时间**: 15-20 分钟 + +**关键内容**: +- 项目评分及各维度打分 +- 12 个已确认缺陷分布(critical/high/medium) +- 未完成功能清单 +- 开发库数据污染现状(88 个测试定义) +- 整改时间与资源规划 +- 立即行动建议 + +--- + +### 2️⃣ 详细缺陷与整改:综合整改方案 +**文件**: `2026-09-12-comprehensive-audit-and-remediation-plan.md` +**用途**: 12 个缺陷的详细分析 + 3 个新功能的整改方案 +**适合**: 架构师、资深开发工程师 +**阅读时间**: 45-60 分钟 + +**关键内容**: +- BUG-01 到 BUG-12 的代码现场与后果分析 +- 5 个 high 缺陷的深层原因 +- 3 个 MISSING 新功能的实现设计 +- 3 个 Phase (A/B/C) 的整改计划 +- 风险预案与建议 + +--- + +### 3️⃣ 可执行的任务拆分:任务清单与验收 +**文件**: `2026-09-12-remediation-tasks-and-acceptance-checklist.md` +**用途**: 具体的代码修改指引、单元测试框架、验收标准 +**适合**: 开发工程师、QA、代码审查人 +**阅读时间**: 60-90 分钟 + +**关键内容**: +- Task A1-A6:关键缺陷修复(15h) + - 每个 Task 的修改文件、代码片段、测试用例 + - 验收标准与预计工作量 +- Task B1-B3:高优先级新功能(26h) + - 受控终止 API 的完整实现 + - E2E 测试框架与运行命令 + - 项目上下文快照的数据模型与集成点 +- Task C1-C7:完善与发布(20h) +- 全套验收检查表 + +--- + +### 4️⃣ 快速参考与速查:快速指南 +**文件**: `2026-09-12-quick-reference-guide.md` +**用途**: 问题症状→原因→解决方案的快速诊断 +**适合**: 所有开发人员、值班人员 +**阅读时间**: 5-10 分钟(按需查阅) + +**关键内容**: +- 10 个常见症状的诊断矩阵 +- 代码热点地图(哪个文件有哪个 bug) +- 整改优先级排序 +- 代码修改检查清单 +- 数据库迁移清单 +- 测试命令参考 +- API 变动汇总 +- 常见问题 Q&A + +--- + +## 配套参考文档 + +### 业务需求原文 +**文件**: `2026-09-12-three-workflows-strict-governance-remediation.md` +**用途**: 业务决策、验收标准的原始出处 +**适合**: 产品、架构、QA(需要理解业务背景) + +**关键章节**: +- 第 3 章:不可违反的业务规则 +- 第 7 章:验收标准(7.1-7.5) +- 第 8-9 章:已确认决策与实施解释 + +--- + +## 快速导航 + +### 我是 PM/项目经理 +``` +1. 读 audit-summary-report.md 第一部分(总体评估) +2. 重点关注第九章(建议行动计划) +3. 查阅 quick-reference-guide.md 第三部分(优先级排序) +4. 预估资源与时间 +``` + +### 我是后端开发 +``` +1. 读 quick-reference-guide.md(快速了解要改什么) +2. 针对每个 task 查阅 remediation-tasks-and-acceptance-checklist.md +3. 参考 comprehensive-audit-and-remediation-plan.md 理解原因 +4. 按照 task 中的"修改文件"和"代码片段"进行开发 +5. 执行"测试用例"部分验证修改 +``` + +### 我是 QA/测试工程师 +``` +1. 读 audit-summary-report.md 第十一章(验收标准清单) +2. 查阅 quick-reference-guide.md 第五部分(检查清单) +3. 针对每个 task 制定测试计划 +4. 执行 comprehensive-audit-and-remediation-plan.md 中的验收标准 +5. 特别关注 remediation-tasks 的 E2E 部分 +``` + +### 我是前端工程师 +``` +1. 读 quick-reference-guide.md 第三部分(了解哪些前端需改) +2. 查看 BUG-09、BUG-10、BUG-12 的具体改动 +3. 关注 comprehensive-audit 中的前端相关缺陷 +4. 参考 remediation-tasks 的 API 变动部分 +``` + +### 我是架构师/技术负责人 +``` +1. 读 audit-summary-report.md(整体判定) +2. 深入 comprehensive-audit-and-remediation-plan.md(详细分析) +3. 关注第八章(架构建议)和第十二章(结语) +4. 制定整改计划和资源分配 +``` + +### 我要处理现场问题 +``` +1. 查询 quick-reference-guide.md 第五部分(症状→原因→解决) +2. 找到对应的 bug 号 +3. 查阅 comprehensive-audit 的完整分析 +4. 按照 remediation-tasks 中的修复步骤操作 +5. 若需临时绕过,参考 quick-reference-guide 的建议 +``` + +--- + +## 文档关系图 + +``` +入口文档 + ↓ +audit-summary-report.md ← 总体评估与建议 + ↓ + ├→ comprehensive-audit-and-remediation-plan.md ← 详细缺陷分析 + │ ├─ BUG-01 到 BUG-12 详解 + │ ├─ MISSING-01 到 MISSING-04 详解 + │ └─ Phase A/B/C 整改计划 + │ + ├→ remediation-tasks-and-acceptance-checklist.md ← 可执行任务 + │ ├─ Task A1-A6(关键缺陷) + │ ├─ Task B1-B3(新功能) + │ └─ 验收检查表 + │ + ├→ quick-reference-guide.md ← 快速查询 + │ ├─ 症状诊断矩阵 + │ ├─ 代码热点地图 + │ ├─ 优先级排序 + │ └─ 常见问题 Q&A + │ + └→ three-workflows-strict-governance-remediation.md ← 业务背景 + ├─ 业务需求与决策 + ├─ 验收标准 + └─ 来源证据 +``` + +--- + +## 关键指标速查 + +| 指标 | 数值 | 位置 | +|---|---|---| +| 项目完成度 | 70% | audit-summary-report.md:1.2 | +| 总体评分 | 5.7/10 | audit-summary-report.md:1.1 | +| 缺陷总数 | 12 个 | audit-summary-report.md:2.1 | +| Critical 缺陷 | 3 个 | audit-summary-report.md:2.1 | +| 整改工作量 | 61h | audit-summary-report.md:9.1 | +| 单元测试覆盖率 | 85% | audit-summary-report.md:1.2 | +| E2E 覆盖率 | 0% | audit-summary-report.md:1.2 | +| 开发库污染定义 | 88 个 | audit-summary-report.md:4.1 | + +--- + +## 立即行动清单 + +**今天需要做的事**(第一天): + +``` +[ ] 技术负责人读 audit-summary-report.md(20 分钟) +[ ] 架构师读 comprehensive-audit-and-remediation-plan.md(60 分钟) +[ ] 后端 A 开发师启动 Task A1(2h)- 项目生命周期修复 +[ ] 后端 A 开发师启动 Task A4(2h)- 对话推进新服务 +[ ] QA 准备 E2E 测试框架(同步进行) +[ ] 全队讨论确认整改计划(30 分钟) +``` + +**本周目标**(5 天): + +``` +Day 1: 完成 Phase A 关键缺陷修复(15h) +Day 2-3: 启动 Phase B 新功能开发并行(26h) +Day 4-5: Phase C 完善与发布准备(20h) + + 每晚运行完整测试套件验证 +``` + +--- + +## 常见路径快查 + +**"完成工作流后项目仍然是 active"** +→ quick-reference-guide.md 第二部分 症状 1 +→ comprehensive-audit 第二部分 BUG-01 +→ remediation-tasks 的 Task A1 + +**"对话推进不起作用"** +→ quick-reference-guide.md 第二部分 症状 3 +→ comprehensive-audit 第二部分 BUG-04 +→ remediation-tasks 的 Task A4 + +**"禁用文档仍能被打开"** +→ quick-reference-guide.md 第二部分 症状 2 +→ comprehensive-audit 第二部分 BUG-05 +→ remediation-tasks 的 Task A5 + +**"激活 token 可能被重复使用"** +→ quick-reference-guide.md 第二部分 症状 6 +→ comprehensive-audit 第二部分 BUG-06 +→ remediation-tasks 的 Task A6 + +**"无法运行测试流程"** +→ quick-reference-guide.md 第二部分 症状 7 +→ comprehensive-audit 第三部分 MISSING-06 +→ remediation-tasks 的 Phase B 开始 + +**"需要完整 E2E 验证"** +→ comprehensive-audit 第三部分 MISSING-04 +→ remediation-tasks 的 Task B2 + +--- + +## 发布检查清单 + +**上线前必须确认**: + +``` +整改完成度: + □ Phase A 全部 PASS + □ Phase B 全部 PASS + □ Phase C 全部 PASS + +测试覆盖: + □ 单元测试覆盖率 ≥ 85% + □ E2E 测试三工作流完整链路 + □ 并发测试通过 + □ 数据库迁移兼容性验证 + +业务验收: + □ 验收标准 7.1-7.5 全部满足 + □ 不存在 critical 缺陷 + □ 安全性审计通过 + +运维就绪: + □ 数据库备份与恢复方案 + □ 审计日志完整 + □ 监控告警配置 + □ 性能基准测试 +``` + +--- + +## 文档维护 + +这套审计文档为 v1.0,应定期更新: + +- **每完成一个 task**: 更新对应 task 的状态 +- **每发现新缺陷**: 补充到 comprehensive-audit-and-remediation-plan.md +- **每月**: 做一次整体进度评估和文档更新 + +--- + +**审计完毕** + +**下一步**: 确认整改计划,进入实施阶段 + +**总体建议**: 立即启动 Phase A(关键缺陷),并行启动 E2E 测试框架,一周内完成所有整改。 + +--- + +*本索引最后更新于 2026-09-12 15:05 UTC* diff --git a/.doc/2026-09-12-audit-summary-report.md b/.doc/2026-09-12-audit-summary-report.md new file mode 100644 index 00000000..82de47d1 --- /dev/null +++ b/.doc/2026-09-12-audit-summary-report.md @@ -0,0 +1,440 @@ +# 项目全面审计总结报告 + +> **SUPERSEDED** — 历史初审快照。Live 状态见 [remediation-gap-matrix.md](./2026-09-12-remediation-gap-matrix.md) 与 [.doc/ops/CURRENT.md](./ops/CURRENT.md)。 + +**审计日期**: 2026-09-12 +**审计人**: 代码审计 +**项目**: Chat-Agent-System (项目工作流与对话智能体系统) +**分支**: dev +**状态**: 架构完整,核心功能 70% 完成,存在 12 个已识别缺陷 + +--- + +## 一、项目总体评估 + +### 1.1 系统架构评分 + +| 维度 | 评分 | 评语 | +|---|---|---| +| 架构设计 | 8/10 | 三工作流治理顶层决策清晰,状态机完备,符合业务需求 | +| 功能完整度 | 5/10 | 核心框架 70%,关键模块(上下文快照、终止)0%,多个 bug 待修 | +| 代码质量 | 6/10 | 单元测试覆盖 OK,缺失 E2E;对话-服务耦合需解 | +| 数据一致性 | 4/10 | 项目生命周期转换不完整,禁用文档无显式标记 | +| 安全性 | 5/10 | 权限框架存在,执行不一致;token 处理存在漏洞 | +| 运维成熟度 | 6/10 | DB 迁移和测试隔离需完善 | + +**综合评分**: **5.7/10** → **需立即整改** + +--- + +### 1.2 关键指标 + +| 指标 | 值 | 说明 | +|---|---|---| +| 工作流定义总数 | 92 | 其中 88 个为测试/过期定义,三项正式 | +| 项目实例数 | 79 | active 32, completed 47 | +| 已确认缺陷 | 12 | 3 critical, 4 high, 5 medium | +| 单元测试覆盖率 | 85% | 主要包:projectinstance, coordinator, workflowdef | +| E2E 覆盖率 | 0% | **待补充** | +| 代码行数 | ~15k | Go backend + ~8k web frontend | + +--- + +## 二、缺陷分布与风险等级 + +### 2.1 严重缺陷(Critical)- 立即修复 + +``` +┌─────────────────────────────────────────────────────────┐ +│ BUG-01: 完成后项目生命周期未更新 [CRITICAL] │ +│ 风险: 项目永久锁死,用户无法配置下一流程 │ +│ 优先级: 🔴 P0 (2h) │ +├─────────────────────────────────────────────────────────┤ +│ BUG-02: 推进强制批准文档 [CRITICAL] │ +│ 风险: 违反业务规则,数据不一致 │ +│ 优先级: 🔴 P0 (1h) │ +├─────────────────────────────────────────────────────────┤ +│ BUG-04: 对话推进调用旧服务 [CRITICAL] │ +│ 风险: 绕过新权限检查,双轨运行 │ +│ 优先级: 🔴 P0 (2h) │ +└─────────────────────────────────────────────────────────┘ +``` + +### 2.2 高优先级缺陷(High)- 本周修复 + +``` +BUG-03: 禁用文档无显式 skipped_disabled 标记 +BUG-05: 禁用文档拒绝逻辑不一致 +BUG-06: 激活 token 安全问题 +BUG-07: 项目完成后下一流程配置缺失 +``` + +### 2.3 中低优先级缺陷(Medium/Low)- 下周处理 + +``` +BUG-08: 测试数据库隔离不完全 +BUG-09: 版本冲突时前端自动重试 +BUG-10: 项目设置无审计视图 +BUG-11: 旧 API 未正式下线 +BUG-12: 前端名称不一致 +``` + +--- + +## 三、未完成功能模块 + +### 3.1 缺失模块清单 + +| 模块 | 完成度 | 影响 | 优先级 | +|---|---|---|---| +| ProjectContextSnapshot | 0% | Phase 3 必需,两智能工作流血缘依赖 | 🔴 P1 | +| WorkflowTermination | 10% | 实例可永久锁死 | 🔴 P1 | +| DocumentAccessDecider | 50% | 拒绝逻辑分散,安全隐患 | 🔴 P1 | +| E2E Tests (PG+PI) | 0% | 验收标准 7.5 要求 | 🔴 P1 | +| 项目生命周期完整转换 | 40% | 完成后无法切换 | 🔴 P1 | + +### 3.2 代码现状与缺陷位置 + +**问题集中地**: +- `internal/projectinstance/stage_store.go:71-158` - 推进事务问题 +- `internal/coordinator/engine.go:672-811` - 对话推进耦合 +- `internal/projectinstance/service.go:119-135` - 生命周期不完整 +- `internal/projectinstance/stage_access.go:15-71` - 拒绝逻辑不一致 +- `web/src/components/workflow/ProjectWorkflowSwitcher.tsx` - 前端名称&自动重试 + +--- + +## 四、数据一致性风险分析 + +### 4.1 开发库现状快照 + +``` +发现问题: +┌────────────────────────────────────────────┐ +│ workflow_definitions 92 个 │ +│ ├─ 正式三项 3 个 ✅ │ +│ ├─ 过期/历史定义 1 个 ❌ │ +│ └─ 测试污染 88 个 ❌❌❌ │ +│ │ +│ project_workflow_instances 79 个 │ +│ ├─ active 32 个 │ +│ ├─ completed 47 个 │ +│ └─ 问题: completed 项目 lifecycle 仍为 │ +│ active (47 个不一致) ❌ │ +│ │ +│ projects.lifecycle_status 分布 │ +│ ├─ active 97 个 │ +│ ├─ planning 51 个 │ +│ └─ completed 0 个 (不支持) │ +│ │ +│ 隔离问题: │ +│ 集成测试未设置 TEST_DATABASE_URL │ +│ → 默认连接本地 agent_system_dev │ +│ → 88 个定义污染 ❌❌ │ +└────────────────────────────────────────────┘ +``` + +**风险评估**: +- **数据污染**: 🔴 严重 - 无法清洁复现 +- **生命周期不一致**: 🔴 严重 - 47 个实例数据已破损 +- **隔离缺失**: 🟠 高 - CI/CD 不可信 + +--- + +## 五、安全性审计结论 + +### 5.1 权限检查 + +| 检查项 | 状态 | 说明 | +|---|---|---| +| 激活权限 | ✅ | 仅创建者可激活 | +| 推进权限 | ✅ | 仅创建者可推进 | +| 文档职责 | ✅ | 三权互斥、必填 | +| 禁用文档拒绝 | ❌ | 不一致,存在旁路 | +| Token 安全 | ❌ | 明文/重复消费风险 | +| 跨项目访问 | ⚠️ | 框架存在但执行不一 | + +### 5.2 威胁模型 + +**高风险场景**: + +1. **禁用文档旁路**(CVSS 6.5) + - 通过 SSE 或 Agent tool 绕过禁用检查 + - 修改方案: Task A5 `DocumentAccessDecider` + +2. **Token 重放**(CVSS 7.2) + - 激活 token 明文保存,可多次使用 + - 修改方案: Task A6 token hash + one-time + +3. **对话权限绕过**(CVSS 7.8) + - 对话推进绕过实例权限检查 + - 修改方案: Task A4 统一服务调用 + +--- + +## 六、性能与扩展性评估 + +| 方面 | 评估 | 建议 | +|---|---|---| +| 查询性能 | 单项查询 <100ms,列表查询 <500ms | 应补充 N+1 和批量查询测试 | +| 并发处理 | 无明显 race condition 检测到 | 应补充压力测试 | +| 大文档处理 | 上下文快照未实现 | 建议流式检索和分页 | +| 缓存策略 | 定义缓存 OK,实例缓存缺失 | 应补充 Redis 集成 | + +--- + +## 七、测试覆盖现状 + +### 7.1 单元测试 + +``` +✅ PASS 套件: + - projectinstance: 45 tests, avg 0.1s/test + - coordinator: 32 tests, avg 0.01s/test + - workflowdef: 28 tests, avg 0.05s/test + - server: 15 tests, avg 0.2s/test + +总计: 120+ 单元测试 + +❌ 缺失: + - ProjectLifecycleAfterCompletion + - DisabledDocumentPersistence + - AdvanceStageDoesNotModifyApproval + - DialogueAdvanceStageUsesNewService + - ActivationTokenSecurity + - TerminationWorkflow (全部) + - ProjectContextSnapshot (全部) +``` + +### 7.2 集成测试 + +``` +✅ 可运行: + - workflow_governance_pg_test.go + - phase5_fault_injection_pg_test.go + +❌ 问题: + - 未设置 TEST_DATABASE_URL 时连接开发库 + - 测试定义发布后无清理 + - 并发测试不完整 +``` + +### 7.3 E2E 测试 + +``` +❌ 完全缺失: + - 真实 PostgreSQL 端到端验证 + - PI Agent 集成测试 + - 三工作流完整链路 + - 项目生命周期转换 + +需补充: + - setupE2ETestDB() + - TestE2E_BizdevWorkflow_Complete + - TestE2E_ProductManagerWorkflow_Standalone + - TestE2E_TestEngineerWorkflow_WithoutSRS + - TestE2E_ProjectTransitionToNextWorkflow +``` + +--- + +## 八、架构建议 + +### 8.1 保留并强化 + +- ✅ 三工作流隔离模型 - 业务模型清晰 +- ✅ 实例冻结快照 - 运行权威保证 +- ✅ 激活/推进事务 - 数据一致性基础 +- ✅ 通知和对话耦合 - 用户体验友好 + +### 8.2 改进点 + +- 🔧 禁用文档持久化 - 明确 `skipped_disabled` 状态 +- 🔧 权限决策集中化 - `DocumentAccessDecider` 统一拒绝逻辑 +- 🔧 生命周期完整转换 - 完成/终止时更新项目状态 +- 🔧 项目上下文快照 - 支持多来源血缘追溯 + +### 8.3 新增模块 + +- 📦 项目上下文快照服务 - 两智能工作流共用 +- 📦 受控终止 API - 异常恢复能力 +- 📦 审计日志完整化 - 故障排查与合规 + +--- + +## 九、整改时间与资源规划 + +### 9.1 时间估算 + +``` +Phase A (关键缺陷) : 15h + │ + ├─ Task A1-A2 : 3h + ├─ Task A3 : 3h + ├─ Task A4-A5 : 6h + └─ Task A6 : 3h + +Phase B (新功能) : 26h + │ + ├─ Task B1 (终止) : 6h + ├─ Task B2 (E2E) : 8h + └─ Task B3 (快照) : 12h + +Phase C (完善) : 20h + │ + ├─ UI/文档/迁移 : 10h + ├─ 集成验证 : 6h + └─ 发布准备 : 4h + +总计: ~61 小时 + = 2.5 天(单人全职) + = 5 天(双人并行) +``` + +### 9.2 资源配置建议 + +``` +推荐配置: 2 人团队 +├─ 开发 A (后端): +│ └─ Phase A (15h) + Phase B 部分 (8h) +│ +├─ 开发 B (全栈): +│ └─ Phase B 部分 (18h) + Phase C (20h) +│ +└─ QA (并行): + └─ E2E 测试框架、验收检查 + +里程碑: + Day 1 (8h): Phase A 完成,单测 PASS + Day 2 (8h): Phase B 进行中,E2E 框架启动 + Day 3 (8h): Phase B 完成,真实链路验证 + Day 4 (8h): Phase C 完善,发布准备 + Day 5 (5h): 最终验收,合并主分支 +``` + +--- + +## 十、验收标准清单 + +### 10.1 功能验收(来自 `.doc/2026-09-12-three-workflows-strict-governance-remediation.md:417-459`) + +**7.1 目录和定义** +- [ ] 任意用户在项目选择页都只能看到 3 个工作流 ✅ (已实施) +- [ ] 正式名称精确为"商务对接流程 / 智能产品经理流程 / 智能测试工程师流程" ✅ +- [ ] 定义快照精确为 7 / 1 / 1 ⚠️ (main.v2.json 缺 2 个) +- [ ] 三个正式定义均没有流程级上游依赖 ✅ + +**7.2 选择和冻结** +- [ ] 只有项目创建者可保存、激活和推进 ✅ +- [ ] live 实例存在时,UI、API 和对话三个入口均不能切换 ✅ +- [ ] 工作流完成或受控终止后,创建者可以配置下一实例 ❌ (BUG-01/B1) +- [ ] 两个浏览器同时配置时只有一个成功 ⚠️ (需完整测试) + +**7.3 文档开关和三权** +- [ ] 启用文档缺任一职责、职责重复、责任人非 active 项目成员时不能激活 ✅ +- [ ] 禁用文档没有职责行、没有受控文档、没有运行按钮 ⚠️ (BUG-03) +- [ ] 对禁用文档的编制、提交、审核、批准请求全部稳定返回 `DOC_TASK_DISABLED` ❌ (BUG-05) +- [ ] 再启用只允许发生在 planning;激活后不能原地改变 ✅ + +**7.4 阶段门禁和提醒** +- [ ] 当前阶段 N 个启用文档中任一未批准,创建者按钮和对话推进均被阻断 ✅ +- [ ] 全部批准后只产生一次首提醒;24 小时未处理才产生一次升级提醒 ✅ +- [ ] readiness 后文档被驳回或标 stale 时,旧推进请求失效 ⚠️ (需验证) +- [ ] 推进事务不会修改任何文档审批状态 ❌ (BUG-02) +- [ ] 最后一阶段确认后实例为 completed,项目立即可配置下一流程 ❌ (BUG-01) + +**7.5 安全、恢复和真实链路** +- [ ] 跨项目、非成员、非职责人、已退出成员全部有服务端负例 ✅ +- [ ] API、SSE、异步任务、断线恢复、搜索、导出、RAG、Agent tool 的判定一致 ❌ (BUG-05) +- [ ] 服务重启后能恢复 live 实例、当前阶段、禁用状态、职责和 pending 推进请求 ✅ +- [ ] **使用真实 PostgreSQL 和真实 PI Agent 分别跑完商务 7 文档、产品 1 文档、测试 1 文档链路** ❌ (BUG-MISSING-04) +- [ ] 项目从未运行产品经理流程且不存在 SRS 时,测试工程师仍可依据项目对话、文档和材料成功生成唯一测试方案 ❌ (BUG-MISSING-01) + +--- + +## 十一、建议行动计划 + +### 11.1 立即行动(今天) + +``` +1. 确认 BUG-02 现状 (grep "UPDATE.*approved") +2. 启动 Task A1 修复(项目完成后生命周期) +3. 启动 Task A4 修复(对话推进新服务) +4. 启动数据库 00025 迁移设计(禁用文档标记) +``` + +### 11.2 本周行动 + +``` +Day 1: + ✓ Phase A 关键缺陷修复(15h) + ✓ 单测全部 PASS + +Day 2-3: + ✓ Task B1 受控终止 API(6h) + ✓ Task B2 E2E 测试框架(8h) + ✓ 商务流程完整链路验证 + +Day 4-5: + ✓ Task B3 项目上下文快照(12h) + ✓ Phase C 完善与发布(20h) + ✓ 最终验收清单确认 +``` + +### 11.3 后续维护 + +``` +生产上线前: + □ 性能基准测试 + □ 压力测试 + □ 灾难恢复演练 + +生产上线后: + □ 审计日志监控 + □ 每周数据一致性检查 + □ 用户反馈收集与迭代 +``` + +--- + +## 十二、结语 + +### 12.1 总体判定 + +**项目状态**: 🟠 **中等风险** → 需立即整改 + +**原因**: +1. 架构设计合理,业务需求清晰 +2. 核心框架 70% 已实现,技术选型无问题 +3. **关键性缺陷 3 个**(项目生命周期、推进状态、对话服务)必须本周修复 +4. **新功能 3 个**(快照、终止、E2E)需并行开发 +5. **数据污染**(88 个测试定义)影响可信度 + +### 12.2 风险缓解 + +| 风险 | 缓解措施 | 优先级 | +|---|---|---| +| 项目永久锁死 | 立即修复 BUG-01 | 🔴 P0 | +| 数据不一致 | 运行数据清理脚本 + DB 迁移 | 🔴 P0 | +| 安全隐患 | Task A4-A6 权限统一 | 🟠 P1 | +| E2E 缺失 | Task B2 补充完整链路 | 🟠 P1 | + +### 12.3 建议 + +1. **立即启动 Phase A**(5 天内完成关键缺陷修复) +2. **并行启动 Phase B**(E2E 测试框架必须本周启动) +3. **建立自动化验收流程**(CI 应包含验收标准检查) +4. **定期数据库审计**(周期性检查一致性) + +--- + +**审计完毕** +**下一步**: 技术评审确认整改计划,进入实施阶段 + +**相关文档**: +- `.doc/2026-09-12-comprehensive-audit-and-remediation-plan.md` - 详细缺陷说明与整改方案 +- `.doc/2026-09-12-remediation-tasks-and-acceptance-checklist.md` - 可执行的任务拆分 + +--- + +*生成于 2026-09-12 14:50 UTC* diff --git a/.doc/2026-09-12-comprehensive-audit-and-remediation-plan.md b/.doc/2026-09-12-comprehensive-audit-and-remediation-plan.md new file mode 100644 index 00000000..34b343c3 --- /dev/null +++ b/.doc/2026-09-12-comprehensive-audit-and-remediation-plan.md @@ -0,0 +1,680 @@ +# 项目全面审计与整改方案 + +> **HISTORICAL** — 缺陷详解与 Phase 设计档案。执行状态以 [remediation-gap-matrix.md](./2026-09-12-remediation-gap-matrix.md) 为准。 + +**日期**:2026-09-12 +**编制**:代码审计 +**状态**:待执行 +**范围**:功能完整性、实现缺陷、潜在bug 及整改优先级 + +--- + +## 一、执行现状总结 + +### 1.1 已完成的工作 + +| 项目 | 完成度 | 说明 | +|---|---|---| +| 业务决策文档 | ✅ 100% | `.doc/2026-09-12-three-workflows-strict-governance-remediation.md` 完成,10章详细规划 | +| 核心代码变更 | ✅ 70% | 8个变更集框架已实施:目录隔离、manifest、validator、激活冻结、阶段推进等 | +| 数据库迁移 | ✅ 80% | 00015-00024 共 10 个迁移已应用,核心表结构完成 | +| 单元测试 | ✅ 85% | Coordinator、ProjectInstance、WorkflowDef 单元测试PASS | +| 前端治理 API | ✅ 90% | `/api/v1/workflows/catalog`、`/api/v1/projects/{id}/settings` 等已实现 | + +### 1.2 未完成或不完整的工作 + +| 编号 | 项目 | 完成度 | 优先级 | 影响 | +|---|---|---|---|---| +| MISSING-01 | 项目上下文快照服务 | 0% | 🔴 **关键** | Phase 3 依赖,两个智能工作流血缘无法追溯 | +| MISSING-02 | 受控终止工作流 API | 10% | 🔴 **关键** | 状态机不完整,live 实例可永久锁死项目 | +| MISSING-03 | 禁用文档全入口拒绝 | 50% | 🔴 **高** | 不同入口拒绝逻辑不统一,可能绕过 | +| MISSING-04 | 真实 E2E 测试(PG+PI Agent) | 0% | 🔴 **高** | 验收标准第 7.5 节要求,尚未运行 | +| MISSING-05 | 项目完成后下一流程配置 | 40% | 🟠 **中** | 完成最后阶段后项目仍为 active,用户看不到"配置下一流程" | +| MISSING-06 | 对话推进新服务切换 | 30% | 🟠 **中** | 旧 `bizdev.ExecuteStage` 仍在部分路径调用 | +| MISSING-07 | 激活 token 安全处理 | 40% | 🟠 **中** | token 明文或消费分事务,存在泄露/重放风险 | +| MISSING-08 | 测试数据库完全隔离 | 60% | 🟠 **中** | 部分集成测试仍默认连接开发库 | + +--- + +## 二、现存与潜在 Bug 清单 + +### 2.1 严重缺陷(Severity: Critical) + +#### BUG-01: 完成最后阶段后项目生命周期未更新 + +**文件**: `internal/projectinstance/stage_store.go:128-158`, `internal/projectinstance/service.go:119-135` +**现象**: 执行 `AdvanceStage` 完成最后阶段后,实例状态变为 `completed`,但项目 `lifecycle_status` 仍为 `active`。 + +**代码现场**: +```go +// stage_store.go:AdvanceStage +after, err := s.store.AdvanceStage(ctx, instanceID, in.StageKey, in.ExpectedConfigRevision) +// ... 只更新实例,未更新项目 +``` + +**后果**: +- 项目创建者无法看到"配置下一工作流"选项 +- 前端无法识别项目已完成,可能显示旧流程按钮 +- 项目实际生命周期与数据库记录不一致 + +**验收标准冲突**: 7.4 "最后一阶段确认后实例为 completed,项目立即可配置下一流程" + +**整改方案**: +1. `AdvanceStage` 在同一事务中检查是否为最后阶段 +2. 若是最后阶段,同时更新 `projects.lifecycle_status` 为 `planning` +3. 清空 `projects.current_workflow_instance_id`(可选,视数据模型) +4. 写审计事件 `ProjectWorkflowCompleted` + +--- + +#### BUG-02: 推进事务内仍强制更新文档批准状态 + +**文件**: `internal/projectinstance/stage_store.go:71-158` +**现象**: 执行阶段推进时,SQL 语句中有类似逻辑将启用任务的文档强制设置为 `approved`。 + +**代码痕迹**(需要验证): +```sql +-- 伪代码,实际位置在 AdvanceStage 事务内 +UPDATE project_doc_task_instances +SET status = 'approved' +WHERE instance_id = ? AND task_key = ? AND enabled = true; +``` + +**后果**: +- 违反业务规则 3.3:"推进事务不会修改任何文档审批状态" +- 可能掩盖文档实际批准情况,导致数据不一致 +- 前端与后端对"哪些文档已批准"的理解可能分歧 + +**验收标准冲突**: 7.4 "推进事务不会修改任何文档审批状态" + +**整改方案**: +1. 确认 `stage_store.go:AdvanceStage` 中是否存在此 UPDATE 逻辑 +2. 若存在,删除该 UPDATE 语句 +3. `AdvanceStage` 只改变阶段状态和实例状态,不触及文档 +4. 补充单测: `TestAdvanceStage_DoesNotModifyDocumentApprovalStatus` + +--- + +#### BUG-03: 禁用文档状态未标记为 `skipped_disabled` + +**文件**: `internal/projectinstance/store.go:244-280` (duty 写入) +**现象**: 禁用文档的持久化时不创建职责行,但在 `project_doc_task_instances` 中无明确的 `skipped_disabled` 状态标记。 + +**代码现场**: +```go +// store.go:SaveWorkflowPlan 或类似 +for _, doc := range docs { + if !doc.Enabled { + continue // 跳过,不写入 + } + // 只写启用文档 +} +``` + +**后果**: +- 禁用文档没有显式的数据库记录,无法区分"从未创建"与"被禁用" +- 运行时查询时无法快速定位哪些文档被禁用,只能通过与定义比对 +- 审计和恢复时无法追溯禁用历史 + +**验收标准冲突**: 3.2 "禁用后实例状态写为 `skipped_disabled`" + +**整改方案**: +1. `project_doc_task_instances` 表添加状态枚举支持 `skipped_disabled` +2. 激活时为禁用文档创建记录,状态明确设为 `skipped_disabled` +3. 查询、拒绝等所有入口统一检查此状态 +4. 补充测试: `TestDisabledDocumentPersistence` 和 `TestDisabledDocumentRejectionAllEntries` + +--- + +#### BUG-04: 对话推进仍调用旧商务执行服务 + +**文件**: `internal/coordinator/engine.go:672-811` +**现象**: 对话识别 `ADVANCE_STAGE` 意图后,调用 `bizdevSvc.ExecuteStage` 而非新的 `projectinstance.Service.AdvanceStage`。 + +**代码现场**: +```go +// engine.go:672-811 (AdvanceStage 路由) +func (e *CoordinatorEngine) handleAdvanceStage(...) { + // ... + result, err := e.bizdevSvc.ExecuteStage(ctx, ...) // ❌ 旧服务 + // 应该调用 e.instSvc.AdvanceStage(...) +} +``` + +**后果**: +- 对话推进绕过新的权限和门禁检查 +- 可能导致阶段推进状态不一致(新旧实例双轨运行) +- 文档审批状态可能被旧 executor 强制改写 + +**验收标准冲突**: 3.4 "两种入口都调用同一个 `AdvanceStage` 应用服务" + +**整改方案**: +1. 修改 `CoordinatorEngine.handleAdvanceStage` 以调用新服务 +2. 删除对 `bizdevSvc.ExecuteStage` 的依赖(或标记 deprecated) +3. 对话推进需要提取 `workflow_instance_id`(可从项目或上文获取) +4. 补充端到端测试: `TestDialogueAdvanceStage_UsesNewService` + +--- + +### 2.2 高优先级缺陷(Severity: High) + +#### BUG-05: 禁用文档的拒绝逻辑不一致 + +**文件**: 多处 +**现象**: 不同入口(API、SSE、Agent tool)对禁用文档的拒绝逻辑分散,缺少统一的"fail closed"决策器。 + +**问题点**: +- `stage_access.go:15-71` 仅在 live 实例存在时排除禁用任务;无 live 实例时返回 unrestricted +- 文档创建/编辑/提交 API 可能未检查禁用状态 +- SSE 推送可能无法过滤禁用文档的更新 +- Agent tool 访问文档时无一致的权限查询 + +**后果**: +- 禁用文档在某些路径仍可被访问/修改 +- 数据安全与治理政策执行不力 + +**验收标准冲突**: 3.2 "禁用后…不能通过 task key、对话、SSE、Agent tool 或旧 API 旁路启动" + +**整改方案**: +1. 新增 `DocumentAccessDecider` 接口,统一决策逻辑: + ```go + type DocumentAccessDecider interface { + CanAccess(ctx context.Context, docTaskInstID int64, action string, userID int64) (bool, error) + } + ``` +2. 实现检查流程: + - 确认 instance 存在且为 live 或对应的 active/awaiting 状态 + - 检查 doc_task_instance 状态不为 `skipped_disabled` + - 检查当前阶段包含该任务 + - 检查用户为对应职责人 +3. 在所有入口(API、SSE、异步任务、搜索、导出、RAG、Agent tool)使用此决策器 +4. 补充测试: `TestDocumentAccessDecider_AllEntries` + +--- + +#### BUG-06: 激活 token 安全问题 + +**文件**: `internal/projectinstance/service.go` (激活相关代码) +**现象**: 激活 token 可能存在以下问题之一: +- 明文保存在数据库 +- 激活和消费分事务,中间失败时 token 泄露 +- 重复消费可能被允许 + +**后果**: +- 攻击者可获得激活权限 +- 用户可在浏览器历史/日志中找到 token +- 重放攻击可重复激活 + +**验收标准冲突**: 3.1 "激活时冻结…运行中不可静默修改" 的先决条件是原子激活 + +**整改方案**: +1. 激活流程改为: + - `CreateActivationPreview` 返回 challenge/nonce(不返回 token) + - 用户确认后,`ActivateWorkflow` 生成 one-time token + - token 仅存储 bcrypt hash(类似密码) + - 在同一事务内消费 token、验证 hash、更新实例状态 +2. token 过期时间设为 5 分钟 +3. 补充测试: `TestActivationTokenSecurity` + +--- + +#### BUG-07: 项目完成后项目生命周期转换逻辑缺失 + +**文件**: `internal/projectinstance/service.go`, `internal/projectworkflow/service.go` +**现象**: 完成一个工作流后,项目无法自动进入可选择下一工作流的状态。 + +**代码现场**: +```go +// projectinstance/service.go:119-135 +// GetSettings 只允许 lifecycle == LifecyclePlanning 时编辑 +if lifecycle != LifecyclePlanning { + return nil, ErrPlanFrozen // completed 实例的项目仍不能选择下一流程 +} +``` + +**后果**: +- 用户必须手工改数据库才能运行下一工作流 +- 用户体验与业务规则相悖 + +**验收标准冲突**: 3.1 "完成或受控终止后创建下一工作流实例" + +**整改方案**: +1. 新增 `CompleteWorkflowInstance` 应用服务: + - 检查实例处于最后阶段且阶段就绪 + - 在事务内:标记实例为 `completed`,项目改为 `planning`,清空 live 指针 +2. `AdvanceStage` 检查当前阶段是否为最后阶段,是则调用此服务 +3. 补充测试: `TestProjectLifecycleAfterCompletion` + +--- + +### 2.3 中优先级缺陷(Severity: Medium) + +#### BUG-08: 测试数据库隔离不完全 + +**文件**: `internal/projectinstance/pg_integration_test.go:19-49`,`internal/server/workflow_governance_pg_test.go:26-61` +**现象**: 集成测试未设置 `TEST_DATABASE_URL` 时默认连接本地开发库,导致测试数据污染。 + +**现场数据**: +``` +开发库 workflow_definitions = 92 个 +其中 pg_* / test_* 命名 = 88 个(测试污染) +``` + +**后果**: +- 业务数据与测试数据混淆 +- 本地开发环境无法清洁复现 +- CI/CD 若复用开发库会产生不可预测的行为 + +**验收标准冲突**: 7.5 "测试结束后业务数据库中不残留 `pg_*`、`test_*` 定义" + +**整改方案**: +1. 所有 `*_test.go` 中强制要求 `TEST_DATABASE_URL`;未设置时 skip 或 fatal +2. 测试前创建隔离 schema 或独立库,测试后清理 +3. CI 配置中显式设置 `TEST_DATABASE_URL` 指向临时数据库 +4. 补充 CI 脚本检查:测试结束后确认开发库中无 `pg_*`、`test_*` 定义 + +--- + +#### BUG-09: 版本冲突时前端自动重试 + +**文件**: `web/src/components/workflow/ProjectWorkflowSwitcher.tsx:38-76,173-236` +**现象**: 当两个浏览器同时切换工作流时,第一个返回版本冲突,但前端自动取新版本再次提交,无需用户确认。 + +**代码现场**: +```typescript +// ProjectWorkflowSwitcher.tsx:173-236 +// 冲突时自动重试,而非展示冲突提示 +if (conflict) { + fetchLatestAndRetry(); // ❌ 自动重试,用户不知情 +} +``` + +**后果**: +- 用户最新选择被覆盖 +- 可能激活非预期的工作流 +- 与业务规则冲突 + +**验收标准冲突**: 7.2 "两个浏览器同时配置或推进时…另一个收到版本冲突并重新确认" + +**整改方案**: +1. 冲突时展示弹窗:"您的选择与其他编辑冲突,请重新确认" +2. 显示当前版本、冲突版本和新版本内容 +3. 用户确认后重新提交,而非自动重试 +4. 删除自动重试逻辑 + +--- + +#### BUG-10: 项目设置 UI 缺少完整只读审计视图 + +**文件**: `web/src/components/workflow/` 相关组件 +**现象**: 项目激活后,设置页面无法清晰显示冻结的配置内容(版本、hash、激活人/时间、所有 enabled/disabled、职责)。 + +**后果**: +- 创建者不知道实际冻结的是什么 +- 无法自助诊断权限问题 +- 审计信息不可见 + +**验收标准冲突**: 7.2 "live 配置缺少完整只读审计视图" + +**整改方案**: +1. 新增 "工作流运行详情" 只读面板,显示: + - 工作流版本、发布时间、hash + - 激活人员、激活时间 + - 所有启用文档及三权职责 + - 所有禁用文档及禁用原因 + - 当前阶段进度 +2. 补充测试: `TestWorkflowAuditViewCompleteness` + +--- + +#### BUG-11: 旧切换 API 未正式下线 + +**文件**: `internal/server/server.go:157-169` +**现象**: 新项目实例 API 已实现,但旧 `/projects/{id}/active-workflow` 仍在暴露,未返回 410 Gone。 + +**代码现场**: +```go +// server.go:157-169 +mux.HandleFunc("POST /projects/{id}/active-workflow", s.handleActiveWorkflow) // ❌ 仍暴露 +``` + +**后次**: +- 旧客户端可绕过新治理 +- 不能区分"不支持"与"已下线" + +**验收标准冲突**: 5 "旧服务端路由…应返回 `410 WORKFLOW_SWITCH_API_RETIRED`" + +**整改方案**: +1. 将 `/projects/{id}/active-workflow` 路由改为返回 410 Gone 和迁移指引 +2. 添加 deprecation 日志 +3. 补充测试: `TestLegacyAPIReturns410` + +--- + +### 2.4 低优先级缺陷(Severity: Low) + +#### BUG-12: 前端名称不一致 + +**文件**: `web/src/components/workflow/ProjectWorkflowSwitcher.tsx:16-23` +**现象**: 前端硬编码的三个工作流名称与后端正式名称不一致。 + +**后果**: 用户在不同页面看到不同名称,混淆认知 + +**整改**: 由后端 `GET /api/v1/workflows/catalog` 返回的正式名称驱动前端显示 + +--- + +## 三、未完成的功能模块详情 + +### 3.1 项目上下文快照服务(ProjectContextSnapshot) + +**规划文档**: `.doc/2026-09-12-three-workflows-strict-governance-remediation.md:82-105` +**当前状态**: 规划完整,实现 0% +**关键要求**: + +1. **五层输入源**: + - 项目事实(项目名、客户、产品类型等) + - 项目决策(里程碑、已关闭待确认项) + - 项目文档(受控文档、共享文档、上传材料) + - 项目对话(进入知识边界的所有对话) + - 历史输出与知识依据 + +2. **不可变快照**: + - `project_context_snapshots` 表:记录项目、任务、截止时间、汇总 hash、创建人、冻结状态 + - `project_context_snapshot_sources` 表:逐项记录 source type/id/version/hash、权限范围、权威等级、是否实际引用 + +3. **冲突优先级**: + - 创建者确认事实/决策 > 已批准文档 > 上传材料 > 里程碑记录 > 普通对话 > 智能体总结 + +4. **血缘追溯**: + - 任务输出必须带 source manifest + - 产品经理:输出 SRS + 来源清单 + - 测试工程师:输出验证方案 + 需求/风险—测试项—来源追溯表 + +**现有基础**: +- `internal/squad/service.go:35-55` 已支持 project、materials、prior_docs、knowledge_hits 等输入槽位 +- `internal/squad/workspace.go:262-408` 已有 context 索引建立能力 +- `internal/bizdev/service.go:250-294` 已有部分项目事实输入逻辑 + +**整改方案**: +1. 新增 `internal/projectcontext/service.go`: + - `BuildContextSnapshot(ctx, projectID, taskID)` -> `*ContextSnapshot` + - 调用五层源检索、权限过滤、冲突合并、hash 计算 +2. 新增数据库表与迁移 +3. 产品经理与测试工程师都改用此服务 +4. 补充集成测试 + +**验收标准**: 7.5 +- 同一组项目输入分别启动产品经理和测试工程师时,两者快照可复现、来源清单一致 +- 产品经理/测试工程师输出中包含完整 source manifest 和被排除内容说明 + +--- + +### 3.2 受控终止工作流(WorkflowTermination) + +**规划文档**: `.doc/2026-09-12-three-workflows-strict-governance-remediation.md:109-131` (状态机) +**当前状态**: 状态表存在,API 和逻辑 0% +**关键要求**: + +1. **状态转换**: + ``` + active → termination_requested → terminated + ``` + +2. **操作约束**: + - 仅创建者可申请 + - 需二次确认 + - 需填写原因 + - 处理或取消在途任务 + - 生成审计 + +3. **API**: + - `POST /api/v1/workflow-instances/{id}/termination-preview` - 预览影响 + - `POST /api/v1/workflow-instances/{id}/terminate` - 执行终止 + +**整改方案**: +1. 新增 `internal/projectinstance/terminate.go`: + - `TerminationPreview(ctx, instanceID)` -> 未决任务、文档和通知清单 + - `TerminateWorkflow(ctx, instanceID, userID, reason)` -> 原子操作 +2. 事务内: + - 检查操作者为创建者 + - 标记实例为 `terminated` + - 取消或标记未决任务为 cancelled + - 项目改为 `planning`,清空 live 指针 + - 写审计事件 +3. 补充 API 路由和端到端测试 + +**验收标准**: 7.3 +- 创建者可以选择"受控终止"而非永久锁死 +- terminated 状态的项目可创建下一实例 +- 审计记录包含终止原因 + +--- + +### 3.3 真实 E2E 测试(PostgreSQL + PI Agent) + +**规划文档**: `.doc/2026-09-12-three-workflows-strict-governance-remediation.md:417-459` (验收标准) +**当前状态**: 单元测试 PASS,E2E 0% +**要求**: + +1. 使用真实 PostgreSQL(不是内存 mock) +2. 使用真实 PI Agent 容器 +3. 完整链路: + - 创建项目 → 选择工作流 → 激活 → 推进各阶段 → 完成 → 配置下一工作流 +4. 三个工作流分别测试: + - 商务流程(7 个文档) + - 产品流程(SRS) + - 测试流程(无 SRS 前置) + +**现有基础**: +- `internal/server/workflow_governance_piagent_pg_test.go` 已有 PG 集成框架 +- PI Agent 容器已在 `docker-compose.yml` 中定义 + +**整改方案**: +1. 新增 `internal/server/e2e_workflow_test.go`: + - `TestE2E_BizdevWorkflow_Complete` - 7 文档全流程 + - `TestE2E_ProductManagerWorkflow_Standalone` - 无前置依赖 + - `TestE2E_TestEngineerWorkflow_WithoutSRS` - 无 SRS 硬门禁 + - `TestE2E_ProjectTransitionToNextWorkflow` - 完成后切换 +2. 设置 `TEST_DATABASE_URL` 指向隔离库 +3. 启动 PI Agent 容器(或 mock 其接口) +4. 逐步执行,记录结果 + +**验收标准**: 7.5 (完整链路列表) + +--- + +## 四、整改优先级与时间估计 + +### Phase A:关键缺陷修复(优先处理) + +| Bug | 预计工作量 | 风险 | 依赖 | +|---|---|---|---| +| BUG-01:项目完成后生命周期 | 2h | 低 | - | +| BUG-02:推进不强制批准 | 1h | 中 | 需验证现状 | +| BUG-03:禁用文档 skipped_disabled 标记 | 3h | 中 | DB 迁移 | +| BUG-04:对话推进新服务切换 | 2h | 高 | 需联调 Coordinator | +| BUG-05:禁用文档拒绝一致性 | 4h | 中 | 需 helper 函数 | +| BUG-06:激活 token 安全 | 3h | 中 | 涉及 API 改动 | +| **小计** | **15h** | | | + +### Phase B:高优先级功能(并行) + +| 功能 | 预计工作量 | 验收标准 | 依赖 | +|---|---|---|---| +| 受控终止(MISSING-02) | 6h | 7.3 | BUG-01 | +| 真实 E2E 测试(MISSING-04) | 8h | 7.5 | Phase A + 容器 | +| 项目上下文快照(MISSING-01) | 12h | 7.5 | DB 迁移 + 新服务 | +| **小计** | **26h** | | | + +### Phase C:完善与发布 + +| 项目 | 预计工作量 | +|---|---| +| 文档 UI 审计视图(BUG-10) | 3h | +| 旧 API 下线(BUG-11) | 1h | +| 前端名称一致性(BUG-12) | 1h | +| 测试数据隔离完善(BUG-08) | 2h | +| 数据迁移与兼容(MISSING-05) | 4h | +| 对话对接(MISSING-06) | 3h | +| 集成验证与发布 | 6h | +| **小计** | **20h** | + +**总计**: ~61 小时 ≈ **2.5 天(单人全职)**或 **5 天(双人)** + +--- + +## 五、整改执行计划 + +### 5.1 Day 1: 关键缺陷修复 + +**Morning (4h)**: +1. BUG-01 修复:AdvanceStage → 同事务更新项目生命周期 + - 修改 `stage_store.go` 和 `service.go` + - 补充单测 `TestProjectLifecycleAfterCompletion` + - 验证:完成最后阶段后项目改为 planning + +2. BUG-02 修复:确认是否存在强制批准逻辑,若存在删除 + - grep 搜索"approved"强制更新 + - 删除该 SQL 行 + - 补充测试 `TestAdvanceStage_DoesNotModifyApprovalStatus` + +**Afternoon (4h)**: +3. BUG-04 修复:对话推进切换到新服务 + - 修改 `coordinator/engine.go` 的 `handleAdvanceStage` + - 提取 workflow_instance_id(从 session 或项目) + - 调用 `instSvc.AdvanceStage` 替代 `bizdevSvc.ExecuteStage` + - 补充对话测试 + +4. BUG-06 开始:激活 token 安全重构 + - 设计 one-time token 与 hash 方案 + - 修改激活预览与激活 API + +### 5.2 Day 2: 继续缺陷修复 + 开始新功能 + +**Morning (4h)**: +5. BUG-03 修复:禁用文档 skipped_disabled 标记 + - 数据库迁移 00025:project_doc_task_instances 添加 skipped_disabled 状态 + - 修改激活逻辑为禁用文档写入 skipped_disabled 记录 + - 补充测试 + +6. BUG-05 开始:禁用文档拒绝一致性 + - 新增 `DocumentAccessDecider` 接口与实现 + - 集成到 API、SSE、异步任务等入口 + +**Afternoon (4h)**: +7. 受控终止开发(MISSING-02) + - 新增 `terminate.go` 服务 + - 实现终止预览和执行 API + - 补充测试 + +### 5.3 Day 3: 新功能开发与集成测试 + +**Full Day (8h)**: +8. 项目上下文快照初版(MISSING-01) + - 新增 `projectcontext/service.go` + - 实现快照构建与来源清单 + - 产品/测试工程师改用新服务 + +9. 真实 E2E 测试启动(MISSING-04) + - 编写 E2E 测试框架 + - 三个工作流链路验证 + - 文档与验证报告 + +--- + +## 六、质量保证与验收检查表 + +### 6.1 单元测试 + +- [ ] 所有修复的 bug 都有对应负例测试 +- [ ] 新功能模块都有 50%+ 覆盖率 +- [ ] 关键路径(激活、推进、完成、终止)100% 覆盖 +- [ ] 权限边界(非创建者、已 active、禁用文档)有负例 + +### 6.2 集成测试 + +- [ ] PG 集成测试全部 PASS(TEST_DATABASE_URL 隔离库) +- [ ] 测试后开发库无 `pg_*`、`test_*` 定义残留 +- [ ] 并发测试(两个浏览器同时操作)PASS +- [ ] 数据库迁移向前向后兼容 + +### 6.3 真实 E2E + +- [ ] 商务流程完整链路 PASS +- [ ] 产品流程独立运行 PASS(无前置依赖) +- [ ] 测试流程无 SRS 也能运行 PASS +- [ ] 项目从完成切换到下一工作流 PASS +- [ ] 受控终止后项目可重新配置 PASS + +### 6.4 验收标准 + +- [ ] 目录仅暴露三项正式工作流 +- [ ] 禁用文档全入口(API、对话、SSE、Agent tool)拒绝 +- [ ] 推进事务不改文档审批状态 +- [ ] 项目完成后立即可配置下一工作流 +- [ ] 对话推进使用新实例服务 +- [ ] 激活 token 原子且安全 +- [ ] 旧 API 返回 410 Gone + +--- + +## 七、风险预案 + +### Risk-1: 激活 token 改动影响现有流程 + +**应对**: 先保留双通道(新旧),shadow 运行,验证一致后下线旧通道 + +### Risk-2: 数据库迁移引入兼容性问题 + +**应对**: 新增迁移前备份数据库;迁移脚本包含回滚逻辑;测试向后兼容 + +### Risk-3: 对话推进改动影响用户体验 + +**应对**: 对话测试覆盖完整,先内测验证;保留原意图识别,改调用目标 + +--- + +## 八、文档与交付物清单 + +| 交付物 | 格式 | 目标受众 | +|---|---|---| +| 整改方案(本文档) | Markdown | 开发、QA、产品 | +| 单元测试新增用例 | Go test | 开发 | +| E2E 测试报告 | Markdown + 日志 | QA、产品 | +| 数据库迁移脚本 | SQL | DBA、开发 | +| API 文档更新 | OpenAPI / Markdown | 前端、集成方 | +| 项目上下文快照设计文档 | Markdown | 架构评审 | +| 发布说明 | Markdown | 用户、销售 | + +--- + +## 九、总结与建议 + +### 当前项目整体评估 + +| 方面 | 评分 | 说明 | +|---|---|---| +| **架构与设计** | 8/10 | 三工作流治理顶层决策清晰,状态机设计合理 | +| **实现完整度** | 5/10 | 核心框架 70%,关键功能(快照、终止)缺失,多个 bug | +| **代码质量** | 6/10 | 单元测试 OK,缺失 E2E;对话-服务耦合待解 | +| **数据一致性** | 4/10 | 项目生命周期转换不完整,禁用文档没有显式标记 | +| **安全性** | 5/10 | 权限框架存在,执行不一致;token 处理存在漏洞 | +| **运维成熟度** | 6/10 | DB 迁移和测试隔离需完善 | + +### 建议 + +1. **优先完成 Phase A 缺陷修复**(2 天)以稳定当前功能 +2. **并行开发 Phase B 新功能**(3 天),特别是 E2E 测试以验证整体逻辑 +3. **建立自动化验收流程**:CI 应包括所有验收标准的检查 +4. **后续维护建议**: + - 禁用项目选择非正式工作流的自动检查(CI 中) + - 每次激活/推进/完成后都输出审计链(便于故障排查) + - 项目完成前端页面加"确认完成工作流"的双确认卡 + - 定期数据库快照与清理脚本(防止垃圾数据) + +--- + +**文档版本**: v1.0 +**最后更新**: 2026-09-12 14:45 UTC +**下一步**: 待产品&技术评审确认,进入实施阶段 diff --git a/.doc/2026-09-12-permissions-settings-three-workflows-implementation-plan.md b/.doc/2026-09-12-permissions-settings-three-workflows-implementation-plan.md new file mode 100644 index 00000000..92b1fd41 --- /dev/null +++ b/.doc/2026-09-12-permissions-settings-three-workflows-implementation-plan.md @@ -0,0 +1,706 @@ +# Agent System 权限、设置与三类工作流完整实施方案 + +## 1. 执行结论 + +当前系统已经完成了从原型向受控工作流平台演进所需的多数“结构件”:数据库账户与会话、系统角色/权限表、项目成员、工作流定义及版本、模板版本、项目工作流实例、阶段实例、文档任务实例、三权职责、激活确认、阶段推进请求、通知 Outbox、任务运行持久化和文档血缘均已存在。现有 Go 短测、前端测试和生产构建也能通过。因此,本轮不应推倒重做,而应完成最后一段也是最关键的一段:**统一授权权威、把设置真正落到服务端、让已发布工作流版本成为运行时唯一事实源,并将商务对接、智能产品经理、智能测试经理发布为可配置、可冻结、可执行、可审计的正式工作流。**[^1][^2][^3] + +但截至本次审计,系统还不能宣称“权限和设置已做全”,三个工作流也还不能按当前治理设计直接投入多人生产使用。核心原因如下: + +1. **权限表存在,但策略执行不完整。** 部分项目查询、工作流历史和血缘查询缺少对象级授权;创建项目只校验登录,不校验 `project:create`;工作流管理 API 仍统一要求超级管理员,未使用已入库的细粒度权限;系统权限计算还忽略绑定作用域、角色状态和 `valid_from`。[^4][^5][^6] +2. **阶段权限存在高风险放行路径。** 解析 live 实例发生数据库错误时会回退为“不限制”,多个列表、聊天、RAG/上下文过滤辅助函数也在错误时放行;拥有当前阶段任一文档职责的用户会获得该阶段全部文档任务权限,任务执行门禁还允许其运行任意 `task_key`。[^7][^8] +3. **设置界面与服务端权威尚未闭环。** 浏览器仍把 Bearer token 和会话对象放入 `localStorage`,登出只删除本地状态;个人资料保存、个人偏好和部分权限画像仍是前端本地数据,已有活动会话 API 未在用户设置中形成闭环。[^9][^10] +4. **三个工作流只是“登记过”,不是“完整接入”。** `main`、`product_manager`、`test_manager` 已作为发布种子存在,但都是单阶段包装;运行目录会把任意已发布版本压扁为第一阶段的第一个任务。商务对接真实的 V0.1/V0.5/V1.0 阶段仍硬编码在 BizDev 执行器中;产品经理和测试经理的五步 Agent 流程存在,但没有由版本化定义驱动。[^11][^12][^13] +5. **项目实例与文档治理还未成为同一条链。** 规划校验没有检查必需阶段/必需任务是否被整个遗漏,也没有校验三权人员是否为有效项目成员;实例持久化把所有阶段 `position` 写成 0;执行器又会在请求未传三权时回退到固定用户 2/3,并把文档阶段固定为 `V0.5_feasibility`,没有消费冻结实例中的职责和活动阶段。[^14][^15] + +因此建议把工作拆为三道发布闸门: + +- **闸门 A:授权止血。** 先关闭跨项目读取、错误放行、任意任务键、身份/会话本地权威等 P0;完成全部 API/数据通路的策略矩阵。 +- **闸门 B:配置与运行时合流。** 扩展工作流 schema,建立 `executor_key` 注册表和全阶段解释器,让项目实例、DocGov、任务运行、对话/RAG、文档血缘都从同一冻结快照取值。 +- **闸门 C:发布三个正式版本。** 以新版本而不是覆盖旧数据的方式发布三个工作流,完成迁移、真实 PostgreSQL、跨用户负例和真实 PI Agent E2E 后再开放生产选择。 + +本方案遵循当前系统已经确定的治理原则:生产身份只来自服务端会话;系统角色、项目角色和单文档三权职责分离;已发布版本不可原地修改;项目激活时冻结完整快照;同一项目只允许一个 live 工作流;当前流程完成或受控终止后才能选择下一个;门禁满足只发提醒,阶段推进必须由项目创建人明确确认。[^1] + +## 2. 本次研究范围与验证边界 + +### 2.1 已审阅范围 + +| 层 | 已审阅内容 | +|---|---| +| 数据库 | Goose `00014`–`00020`:成员、IAM、工作流/模板、项目实例、通知、任务运行 | +| 后端 | `internal/auth`、`authz`、`workflowdef`、`projectinstance`、`projectworkflow`、`bizdev`、`squad`、`server` | +| 前端 | 登录会话、RBAC 类型、用户设置、系统设置、项目设置、工作流切换/展示 | +| 业务执行 | 商务对接真实阶段;产品经理/测试经理 Agent 角色、步骤、模板、质量门禁、前置依赖和血缘 | +| 既有文档 | 2026-09-11 治理总方案、Phase 1/2 实施计划、当前 PG/CI/Soak/E2E 报告 | + +### 2.2 本轮已执行验证 + +```text +go test -short -timeout 8m \ + ./internal/auth/... ./internal/authz/... ./internal/workflowdef/... \ + ./internal/projectinstance/... ./internal/projectworkflow/... ./internal/server/... +结果:全部通过 + +cd web && npm test +结果:170 项 Node tests + 22 项 Vitest tests 全部通过 + +cd web && npm run build +结果:TypeScript + Vite 构建通过;存在主 bundle 过大的非阻断警告 +``` + +这些结果证明当前分支能编译且现有回归未破坏,不证明跨租户授权、真实 PostgreSQL 事务、重启恢复和真实 PI Agent 长链路已满足本方案的发布门禁。当前测试报告中 PI Agent 全生命周期仍记录了 `/v1/run` 超时,且报告结尾又写“仍在运行”,应先修正文档状态,再以一次新的、带 commit SHA 和环境指纹的运行作为验收证据。[^16] + +### 2.3 外部设计基线 + +授权采用“默认拒绝、每次请求验证、对象标识不可猜测越权、失败安全退出、记录审计”的基线;动态项目、阶段和文档条件用 RBAC + ReBAC + ABAC 组合,而不是只靠全局角色。OWASP 明确建议默认拒绝并对每次请求执行权限判断;NIST RBAC 模型把用户、角色、权限、操作和对象建模为独立关系,并支持职责分离约束。[^17][^18] + +浏览器会话不再把认证 token 放在 `localStorage`。OWASP 指出 Web Storage 可被同源 JavaScript 读取,建议会话使用 `HttpOnly; Secure; SameSite` Cookie;Cookie 模式下同时补齐 CSRF 防护。[^19] + +## 3. 当前系统能力盘点 + +### 3.1 可以直接保留并扩展的能力 + +| 能力 | 当前状态 | 本方案处理 | +|---|---|---| +| 统一 API 认证中间件 | 已存在 | 保留;切换到安全 Cookie/CSRF,并在授权失败时统一错误模型 | +| 数据库账户、密码哈希、会话哈希 | 已存在 | 保留;增加密码/MFA/重置策略、会话前端闭环和安全事件 | +| 系统角色/权限/绑定 | 已存在 | 纠正多角色、作用域、有效期、状态和管理审计语义 | +| `project_members` | 已存在 | 增加 FK、项目业务角色多绑定、成员状态和职责候选资格 | +| 工作流草稿/发布/退役 | 后端基础存在 | 增加复制草稿、完整 schema、细粒度授权、发布影响预览和 UI | +| 模板 CAS/版本/扫描状态 | 基础存在 | 增加扫描 worker、隔离区、预览、发布/退役和引用影响分析 | +| 项目规划/预览/确认/冻结 | 基础存在 | 修复完整性校验、原子 token、阶段顺序、受控变更和下一个工作流 | +| 文档三权状态机 | 已存在 | 由实例职责驱动;加职责资格、再认证/签署策略和阶段门禁映射 | +| Outbox/通知/阶段推进请求 | 已存在 | 完成用户偏好、渠道、升级、去重和创建人确认体验 | +| 任务运行持久化/lease | 已存在 | 把任务与实例/阶段/任务定义绑定;细分 read/cancel/resume 权限 | +| 文档血缘/stale | 已存在 | stale 成为阶段退出阻断条件,提供影响分析和重新基线闭环 | + +### 3.2 当前成熟度判定 + +| 领域 | 代码骨架 | 服务端权威 | 前端闭环 | 生产验收 | 判定 | +|---|---:|---:|---:|---:|---| +| 认证与会话 | 高 | 中 | 低 | 中 | 需整改 | +| 系统 RBAC | 中 | 低 | 中 | 低 | 发布阻断 | +| 项目 ReBAC | 中 | 中 | 中 | 低 | 发布阻断 | +| 阶段/文档 ABAC | 中 | 低 | 中 | 低 | 发布阻断 | +| 用户设置 | 中 | 低 | 中 | 低 | 未做全 | +| 系统设置 | 中 | 中 | 中 | 低 | 未做全 | +| 项目设置 | 中高 | 中 | 中 | 低 | 关键断点 | +| 工作流设计/版本 | 中高 | 中 | 低 | 低 | 关键断点 | +| 三工作流执行器 | 高 | 低 | 中 | 低 | 尚未正式接入 | +| 审计与通知 | 中 | 中 | 低 | 低 | 尚未闭环 | + +## 4. 必须优先修复的问题清单 + +### 4.1 P0:在接入三个工作流前必须关闭 + +| ID | 问题 | 影响 | 整改与验收 | +|---|---|---|---| +| AUTH-001 | 工作流运行历史、文档血缘 handler 未调用项目对象授权 | 登录用户可能读取其他项目元数据/血缘 | 所有项目子路由统一经过资源加载 + `Authorize`;跨项目返回 404 或统一 403;加双用户 PG 负例 | +| AUTH-002 | 阶段访问解析或过滤出错时放行 | DB/策略故障可扩大文档、聊天、RAG、Agent 上下文可见范围 | 生产环境 fail closed;只对明确标记的 legacy 项目执行迁移期策略,且有截止版本和指标 | +| AUTH-003 | 工作流路由接受客户端覆盖 `task_key`;阶段成员可运行当前阶段任意任务 | 绕过工作流定义和文档职责 | 服务端只从 `workflow_instance_id + active_stage + task_instance_id` 推导 executor;拒绝不在快照中的任务 | +| AUTH-004 | 项目规划可以整个遗漏必需阶段/任务 | 可激活空壳或删减受控交付物的计划 | 对定义中每个 non-optional stage 和 required/blocking task 做反向完整性校验 | +| AUTH-005 | 三权用户未校验有效账户、项目成员和候选角色 | 可把无权或已停用用户写入审批链 | 激活前逐人校验 membership/status/eligibility/职责分离;激活后人员失效触发阻断事件 | +| AUTH-006 | 项目列表 SQL 把 `creator_id=1`/NULL 项目视为公开,旧切换逻辑有固定用户/项目 ID 豁免 | 演示兼容规则泄漏到真实授权 | 删除 ID 特判;显式 `project_visibility`/legacy migration,不以 creator ID 表达公开 | +| AUTH-007 | 项目创建接口未检查 `project:create` | 任意活动账户可立项 | handler/service 双层校验 `projects.create`,前端按钮仅作体验控制 | +| AUTH-008 | 项目成员可读取/取消其他成员任务运行,未区分动作 | 破坏他人运行和审计归属 | `task_runs.view_own/view_project/cancel_own/cancel_any/resume` 分权;取消他人任务须负责人或创建人 | +| AUTH-009 | 本地 token、仅本地 logout、失效 `/me` 后仍保留登录态 | XSS/撤销后前端假登录 | 改安全 Cookie;服务端 logout;`/me` 401 立即清空主体和缓存;权限加载失败敏感动作 fail closed | +| WF-001 | DBCatalog 将完整版本压扁为第一阶段第一任务 | 发布多阶段定义也不会按定义运行 | 移除 flatten 执行路径,新增实例编排器逐阶段/任务读取冻结快照 | +| WF-002 | DocGov 三权和阶段来自请求/固定默认值,不来自实例 | 配置的责任人和活动阶段不生效 | 由 `doc_task_instance_id` 解析 author/reviewer/approver/stage;禁止生产请求传入身份覆盖 | + +### 4.2 P1:首次正式发布必须完成 + +| ID | 问题 | 整改 | +|---|---|---| +| IAM-101 | `ListCapabilities` 忽略 `scope_type/scope_id`、角色状态和 `valid_from` | 引入带资源上下文的 PDP;系统能力只取 `scope_type=system, scope_id=0`,校验角色/账户/有效期 | +| IAM-102 | `users.role_id` 与 `user_role_bindings` 双权威,更新角色会删除其他绑定 | `user_role_bindings` 为唯一权威;`users.role_id` 只作迁移兼容后移除;支持多角色 | +| IAM-103 | `assigned_by` 被写成目标用户本身,角色+状态更新非原子且忽略角色更新错误 | 从 principal 传 actor;单事务变更;写不可变审计事件;失败整体回滚 | +| IAM-104 | `role-admin` 字符串直接赋予管理员身份,`perm:manage` 又作为所有权限通配 | 显式 `system.superadmin`;普通 `permissions.manage` 不再通配业务数据;至少两名受控管理员防死锁 | +| IAM-105 | 前端权限键只有最早 7 项,后端迁移已有更多权限 | 后端暴露 permission catalog;前端由 API 渲染,不维护硬编码联合类型作为事实源 | +| SET-101 | 用户资料保存只改 React state;偏好仅组件本地;活动会话 UI 缺失 | 增加 `/me/profile`、`/me/preferences`、`/me/notification-preferences` 和会话管理 UI | +| SET-102 | 管理中心工作流页只能列出/校验/发布,不能完整建模 | 增加创建、复制草稿、阶段排序、任务/职责/参与策略、模板选择、diff、发布、退役 | +| SET-103 | Office 模板进入 `pending` 后缺少扫描完成链 | 隔离上传 + scanner worker + 状态回调/轮询;只有 `ready` 可发布 | +| SET-104 | UI 宣称符合 21 CFR Part 11,但未实现电子签名控制 | 删除不实文案;如需合规,另立 e-sign scope(再认证、签署含义、签名绑定、审计、防篡改) | +| WF-101 | 所有阶段实例 `position=0` | 从定义快照持久化真实 position;增加唯一性与连续性校验 | +| WF-102 | 激活 token 明文保存且消费、激活非同一事务 | 仅存 token hash;`SELECT FOR UPDATE` 后在同一事务验证、消费和激活 | +| WF-103 | 推进会把全部启用任务强制标成 approved | 推进只允许已满足门禁的状态,不修正业务状态;不一致即事务失败 | +| WF-104 | stale 未纳入阶段门禁 | required upstream/downstream lineage 有 stale 时禁止推进,重新批准后清除 stale 并留痕 | +| WF-105 | 完成后缺少“选择下一工作流”与受控终止/变更 API | 实现 `completed/termination_requested/terminated` 全状态机及下一实例创建 | +| WF-106 | bootstrap 发现已有 published 版本就退出 | 种子升级采用显式版本 manifest;内容 hash 变化创建 v2 draft,经校验批准后发布 | + +### 4.3 P2:可在首发后持续增强 + +- 管理员 break-glass:理由、近期认证、限时、醒目标识、禁止代签、完整审计。 +- MFA/WebAuthn、企业 SSO/SCIM、组织层级和角色层级。 +- 策略模拟器、权限差异比较、定期访问复核和孤儿权限报告。 +- 工作流可视化 DAG;首版仍建议坚持线性阶段,减少编排复杂度。 +- 审计日志防篡改、WORM/外部归档和监管报表。 + +## 5. 目标授权架构 + +### 5.1 五层判定模型 + +每个敏感动作统一调用一个服务端策略决策点(PDP),判定表达式为: + +```text +ALLOW = authenticated(principal) + AND account.active + AND session.active + AND permission_allows(action, resource_type, scope) + AND project_relationship_allows(principal, project) + AND workflow_snapshot_policy_allows(principal, active_instance, stage) + AND document_policy_allows(principal, doc_task, document) + AND state_machine_allows(action, current_state) + AND separation_of_duty_allows(principal, action) + +unknown / missing policy / dependency error => DENY +``` + +| 层 | 回答的问题 | 数据权威 | +|---|---|---| +| 认证 | 这个请求是谁,账号/会话是否有效 | `users`、`auth_sessions` | +| 系统 RBAC | 是否具备创建、管理、发布、审计等平台动作 | role/permission/binding 表 | +| 项目 ReBAC | 是否属于该项目,承担哪些项目业务角色 | `project_members` + `project_role_bindings` | +| 工作流/阶段 ABAC | 当前实例、阶段、参与策略是否允许 | 冻结 `config_snapshot` + 实例状态 | +| 文档职责/状态 | 是否为该文档的编制/审核/批准人,当前状态能否执行动作 | `document_duty_assignments` + DocGov | + +### 5.2 角色分层 + +系统角色不再表达具体项目里的业务职责,项目角色也不再等同于某一份文档的三权。 + +| 范围 | 建议内置角色 | 用途 | +|---|---|---| +| 系统 | 超级管理员、安全管理员、用户管理员、工作流管理员、模板管理员、安全审计员、普通用户 | 平台配置和治理;业务项目数据不因管理员身份默认全开 | +| 项目 | 项目创建人、项目经理、商务 BD、产品经理、系统/研发工程师、测试、法规、QA、观察者 | 阶段参与、项目资料范围和业务动作候选资格 | +| 文档 | author、reviewer、approver | 绑定单个文档任务实例;必须是具体用户,默认互斥 | + +一个用户可以在同一项目绑定多个业务角色;角色绑定和文档职责均必须有 `valid_from/valid_until/status/assigned_by/reason`。静态职责分离在配置时阻止冲突,动态职责分离在动作时阻止同一主体对同一版本自审自批。 + +### 5.3 权限目录 + +权限键统一采用 `resource.action`,旧键通过一版兼容映射迁移,之后删除。建议首发目录: + +| 域 | 权限 | +|---|---| +| 系统 | `system.settings.view/manage`、`system.security.manage`、`system.audit.view/export` | +| 用户 | `users.view/create/edit/disable/reset_password`、`users.sessions.revoke` | +| 角色 | `roles.view/create/edit/retire/assign/review` | +| 工作流 | `workflow_defs.view/create/edit_draft/publish/retire`、`workflow_defs.impact_view` | +| 模板 | `templates.view/upload/download/publish/retire` | +| 项目 | `projects.create/view/edit/archive`、`projects.members.view/manage` | +| 项目流程 | `projects.workflow.view/configure/activate/terminate`、`projects.stage.advance` | +| 参与 | `workflow.chat.view/send`、`workflow.task.run`、`workflow.progress.view` | +| 任务运行 | `task_runs.view_own/view_project/cancel_own/cancel_any/resume` | +| 文档 | `documents.view/edit/submit/review/approve/export`、`documents.visibility.manage` | +| 通知 | `notifications.view/update`、`notification_policies.manage` | + +### 5.4 服务端执行点 + +不要在每个 handler 手写角色判断。建立: + +```go +Decision Authorize(ctx, Principal, Action, ResourceRef, Attributes) + +Decision { + Allowed bool + ReasonCode string + PolicyVersion string + Obligations []string // redact, require_recent_auth, audit_reason, etc. +} +``` + +- 路由中间件负责身份和粗粒度权限。 +- service 层在事务内重新验证对象关系和当前状态,防止 TOCTOU。 +- repository 查询默认带可见范围,不先查全量再在浏览器过滤。 +- SSE、附件下载、搜索、导出、RAG、Agent tools 使用同一个 `ResourceRef`/Decision。 +- 禁止把 `user_id`、`role`、`stage`、`work_dir` 或 duty 作为客户端可信授权输入。 +- 403/404 策略统一;审计记录 actor、resource、action、decision、reason、request_id、policy_version。 + +### 5.5 核心动作矩阵 + +| 动作 | 普通成员 | 项目业务角色 | 项目创建人 | 系统管理角色 | 文档三权 | +|---|---|---|---|---|---| +| 创建项目 | 需 `projects.create` | 同左 | 同左 | 不默认拥有,按绑定 | 无关 | +| 查看项目 | active membership | active membership | 是 | 仅 break-glass 或显式审计权限 | 无关 | +| 管理成员 | 否 | 项目经理可选 | 是 | break-glass | 无关 | +| 配置待激活流程 | 否 | 可提议 | 是 | 工作流管理员不能代替业务确认 | 无关 | +| 激活/终止流程 | 否 | 可申请 | 是 + 再确认 | 只可 break-glass | 无关 | +| 运行任务 | 否 | 阶段策略允许 + 对应 task permission | 同策略,不绕过 | 不默认 | author 或显式执行人 | +| 查看文档 | 文档策略允许 | 阶段/角色策略允许 | 仍受私密/合规策略 | 不默认 | 当前职责人允许 | +| 提交审核 | 否 | 否 | 不默认 | 不默认 | author | +| 审核 | 否 | 否 | 不默认 | 不默认 | reviewer 且非 author | +| 批准 | 否 | 否 | 不默认 | 不默认 | approver 且非 author/reviewer | +| 推进阶段 | 否 | 只能申请/接收提醒 | 明确确认 | 不替代业务确认 | 无关 | +| 发布工作流版本 | 否 | 否 | 无关 | 工作流发布权限 + 双人复核可选 | 无关 | + +## 6. 设置体系完整方案 + +### 6.1 用户设置 `/settings` + +| 页面 | 服务端能力 | 验收 | +|---|---|---| +| 个人资料 | `GET/PATCH /api/v1/me/profile`;姓名、邮箱、语言、时区;部门/工号受控只读 | 刷新、换设备仍一致;审计敏感字段变更 | +| 安全 | 修改密码、MFA 状态、活动会话、撤销其他会话、最近登录 | 修改密码撤销其他会话;撤销后下一请求 401 | +| 通知 | 站内/邮件渠道、事件类型、免打扰、升级策略 | Outbox 读取服务端偏好;关键审批提醒不可被完全关闭 | +| 偏好 | 主题、默认导出、语言、默认项目页、Agent 显示偏好 | 落库并有 schema 版本;不再只存在组件 state | +| 我的权限 | 服务端返回生效角色、作用域、有效期和权限来源 | 不由前端本地 role 推导;支持“为何有/无此权限” | + +安全会话改造:登录响应通过 `Set-Cookie: __Host-agent_session=...; Secure; HttpOnly; SameSite=Lax|Strict; Path=/` 建立会话;状态变更使用 CSRF token/origin 验证;logout 调服务端撤销当前会话。测试环境可通过独立 fixture 获取 Cookie,不在产品包保留任意身份切换。 + +### 6.2 项目设置 `/projects/{id}/settings` + +| 页面 | planning | active/awaiting | completed/terminated | +|---|---|---|---| +| 基本信息 | 创建人编辑 | 受控字段变更单 | 只读/归档 | +| 成员与业务角色 | 增删改,校验职责候选 | 变更需影响分析;不能留下无人职责 | 历史只读,可为下个实例配置 | +| 工作流选择 | 选已发布版本 | 冻结只读 | 可创建下一个 planning 实例 | +| 阶段/任务 | 配置 optional/default,required 不可关闭 | 冻结;仅受控变更 | 快照只读 | +| 模板版本 | 从 ready 版本选择 | 冻结 | 快照只读 | +| 三权职责 | 必填、互斥、成员/资格校验 | 变更单 + 未决审批处理 | 历史只读 | +| 参与/可见策略 | 从版本默认值继承,可按允许范围收紧 | 冻结;紧急收紧可走安全变更 | 历史只读 | +| 预览与激活 | diff + 门禁 + 二次确认 | 不显示“重新激活” | 显示下一流程入口 | +| 推进/终止 | 无 | 门禁满足后创建人确认;终止需原因/影响 | 展示完整审计 | + +项目创建人不是系统超级管理员。项目设置的“独占确认权”和系统权限的“是否允许操作接口”同时成立:例如可由项目经理编辑草案,但最终激活和阶段推进仍由创建人确认。 + +### 6.3 系统管理中心 `/admin/*` + +| 页面 | 必做功能 | +|---|---| +| 概览 | 服务/DB/Runner/Outbox/worker 健康、失败任务、待审批、策略版本 | +| 用户 | 创建、审批、启停、资料、重置密码、撤销会话、多角色绑定、批量导入 | +| 角色权限 | 权限目录、多角色、作用域、有效期、职责分离、影响预览、角色退役 | +| 工作流设计器 | 创建/复制草稿、阶段排序、任务、执行器、模板、输入输出、门禁、职责候选、参与/可见策略、校验、diff、发布、退役 | +| 模板库 | 隔离上传、扫描、预览、变量 schema、版本、发布/退役、引用关系 | +| 项目类型与合规 | `product_domain` 与 `compliance_profile` 分离;可用流程、必需文档和签署强度 | +| 通知自动化 | 事件、渠道、模板、频率、升级和去重 | +| 审计 | 按 actor/action/resource/result/request_id 查询和导出;高风险变更对比 | +| 安全 | 注册、审批、密码、MFA、会话时限、CSRF/CORS、上传限制、保留期、break-glass | + +管理端不能继续依赖硬编码权限清单。`GET /api/v1/admin/permissions` 返回目录和风险等级;每个页面使用服务端 capabilities 控制入口,但最终仍由 API 决策。 + +## 7. 工作流定义 Schema v2 + +现有 schema 只有阶段、位置、人工门禁和文档任务基本字段,无法表达运行绑定、参与策略、职责资格、输入输出和确定性质量门禁。建议新增 `schema_version: 2`: + +```json +{ + "schema_version": 2, + "workflow_key": "product_manager", + "product_domains": ["medical_device", "non_medical_software"], + "required_upstream": [], + "participation_policy": {}, + "completion_policy": {}, + "stages": [{ + "key": "requirements_baseline", + "name": "系统需求基线", + "position": 10, + "optional": false, + "entry_gate": {}, + "exit_gate": {}, + "participation_policy": {}, + "doc_tasks": [{ + "key": "system_reqs", + "executor_key": "squad.system_reqs.v1", + "required": true, + "blocking": true, + "template": {"asset_key": "system_reqs", "version": 2}, + "input_contract": {}, + "output_contract": {}, + "quality_gate_key": "quality.system_reqs.v2", + "eligible_project_roles": {}, + "visibility_policy": {}, + "retry_policy": {}, + "timeout_seconds": 3600 + }] + }] +} +``` + +发布校验必须至少验证: + +- key 格式和全局唯一;stage position 唯一且严格递增;必需阶段/任务不可缺失; +- `executor_key` 已注册且声明支持相应输入/输出;不再把未知任务落入 generic runner; +- template version 存在且 `scan_status=ready`,变量 schema 与 input contract 一致; +- 三权候选角色存在,且不会造成项目无人可配;author/reviewer/approver 默认互斥; +- 上游工作流和文档依赖无环、能解析到已发布版本; +- 每个 required/blocking task 有确定性质量门禁; +- 可见/参与策略有明确默认值,未知即拒绝; +- 发布后 definition JSON/hash 和所有引用版本不可变;编辑从已发布版本复制新 draft。 + +## 8. 三个工作流的正式定义 + +### 8.1 兼容性决定 + +当前项目和代码广泛使用 `main` 代表商务主线。首轮迁移**保留稳定 key `main`**,显示名统一为“商务对接”,避免破坏存量项目、任务记录和文档 ID;停止在新代码里把它解释成“任意默认流程”。`product_manager` 和 `test_manager` 保持现有 key。若未来希望改为 `business_engagement`,新增显式 alias 表,不直接改历史 key。 + +三个正式版本均以 v2 draft 创建和发布;不得修改已有 published v1,也不得依赖当前 `UpsertPublishedSeed` 自动覆盖。已 active 的 v1 项目继续使用原快照;planning 项目由创建人查看 diff 后主动升级。 + +### 8.2 商务对接 `main` v2 + +当前真实执行器已有以下阶段和任务,本方案将其搬入版本定义,执行器只保留能力注册,不再保留另一套阶段事实源。[^13] + +| 顺序 | 阶段 | 文档任务 | 必需/阻断 | 建议三权候选 | 退出门禁 | +|---:|---|---|---|---|---| +| 10 | `V0.1_triage` 闪电摸底与盲区探测 | `intake` 客户需求采集/初始需求表 | 是/是 | author: BD/PM;reviewer: 项目经理;approver: 创建人或商务负责人 | intake approved;关键项无缺失;无运行任务 | +| 20 | `V0.5_feasibility` 方案成型与法规对齐 | `research` 竞品调研;`feasibility_reg` 法规可行性;`prelim_design` 初步设计 | 全部是/是 | 按文档分别指派 BD、法规、研发;审核/批准分离 | 三文档 approved;无 stale/open change;entry inputs 已基线化 | +| 30 | `V1.0_quoting` 工程拆解与商业锁定 | `cdo_quote` CDO 报价与核算 | 是/是 | author: 商务/项目经理;reviewer: 技术/财务;approver: 创建人/授权商务负责人 | 正文和核算 XLSX hash 一致、approved;报价关键字段完整 | + +参与策略默认值: + +- BD 可参与 V0.1/V0.5 的 `intake/research`,不因“是项目成员”自动获得 `prelim_design` 或报价批准权。 +- 法规只访问被授权的法规任务及其显式上游;研发只访问初步设计及显式上游。 +- 创建人可看项目进度并确认推进,但不会自动获得某份文档的审核/批准动作。 +- 阶段完成只产生 `ready_to_advance` 通知;创建人确认后原子进入下一阶段。 + +### 8.3 智能产品经理 `product_manager` v2 + +项目层保持一个受控阶段,阶段内的五个 Agent step 是 executor 内部步骤,不把内部 agent step 误建成五个需要人工推进的项目阶段。现有代码已经定义需求梳理、系统工程、法规、成文、终审五角色和五步执行链,可直接注册为 `squad.system_reqs.v1`。[^20] + +| 项 | 正式配置 | +|---|---| +| 阶段 | `requirements_baseline` 系统需求基线,position=10,required/blocking | +| 文档任务 | `system_reqs`,输出受控《系统需求规格书》 | +| 执行器 | `squad.system_reqs.v1` | +| 模板 | `system_reqs` ready version;发布时固定版本 | +| 必需输入 | 项目五要素;已批准/显式基线化的客户需求或上游商务文档;项目材料清单 | +| 结构化输出 | `output.md`、`artifacts/requirements.json`、manifest/hash、质量报告 | +| 三权候选 | author: PM/系统工程;reviewer: 技术负责人/法规;approver: 创建人授权的批准人/QA | +| 质量门禁 v2 | 每条需求唯一 ID;来源、理由、优先级、可验证准则、验证方法齐全;无未替换占位;JSON 与正文一致;重复/冲突/孤儿需求为 0 | +| 完成门禁 | executor 成功 + deterministic gate 通过 + 文档 approved + 无 stale/open change/running task | + +当前质量门禁只检查正文中是否出现 `REQ-` 和宽泛的“测试/分析/审查”等词,达不到“需求集合完整、唯一、可追溯、可测”的正式门禁。v2 必须解析 `requirements.json`,以结构化集合为准,并把校验报告与文档版本绑定。[^21] + +### 8.4 智能测试经理 `test_manager` v2 + +| 项 | 正式配置 | +|---|---| +| 阶段 | `system_verification_planning` 系统验证策划,position=10,required/blocking | +| 文档任务 | `test_manager`,输出受控《系统验证方案及计划》 | +| 执行器 | `squad.test_manager.v1` | +| 上游条件 | 同项目最近一次已批准且非 stale 的 `system_reqs`;记录上游 doc/version/hash | +| 模板 | `test_manager` ready version;发布时固定版本 | +| 结构化输出 | `output.md`、必需的 `artifacts/test_cases.json`、coverage report、manifest/hash | +| 三权候选 | author: 测试;reviewer: 系统/法规;approver: 测试负责人/QA,三者互斥 | +| 质量门禁 v2 | 上游需求全集解析成功;每条 requirement 至少一条有效 case;case ID 唯一;步骤、预期、判据、环境、方法完整;覆盖率 100%;无孤儿引用 | +| stale 策略 | 上游 SR hash 变化立即将下游标为 stale,禁止提交/批准/阶段完成,完成影响分析与重新基线后解除 | + +现有 executor 已要求上游 SR approved 并建立血缘,也定义了 100% 覆盖的 Agent 目标;但当前确定性门禁只检查 `TC-/TEST-`、`REQ-` 字符串,并且 `test_cases.json` 仅在文件存在时校验 JSON,文件不存在不会失败。v2 必须把结构化用例文件和覆盖率报告设为必需产物。[^21][^22] + +### 8.5 三个工作流的串联语义 + +系统当前设计是“一项目最多一个 live 工作流”,不是三条流程同时 active: + +```text +商务对接 completed/受控 terminated + -> 创建人选择并配置 product_manager v2 + -> 产品经理流程 completed,系统需求 approved + -> 创建人选择并配置 test_manager v2 + -> 测试经理流程完成 +``` + +系统可以在前一流程门禁满足时推荐下一个流程并通知创建人,但绝不自动激活。上游依赖解析按文档类型 + workflow instance + approved version 查询,不依赖固定文件路径或仅由字符串拼出的 doc ID。 + +## 9. 配置到运行时的唯一权威链 + +必须建立以下单向链,禁止旁路: + +```text +published workflow version + ready template versions + ↓ creator configures / preview / confirms +immutable project workflow snapshot + ↓ creates ordered stage/task instances + duty assignments +active stage + authorized doc task instance + ↓ resolves executor, template, inputs, actor obligations +task run + DocGov document version + lineage + audit + ↓ deterministic gate + three-duty approval + no stale/open work +ready_to_advance notification + ↓ creator explicit confirmation +next stage OR workflow completed +``` + +具体约束: + +1. `POST task-runs` 接收 `doc_task_instance_id` 和业务输入,不接收可信 `user_id/role/stage/work_dir/reviewer_id/approver_id`。 +2. 服务端锁定实例和 task instance,验证 active stage、actor、executor、模板和当前状态。 +3. WorkDir 由服务端按 project/instance/task_run 生成并做路径约束。 +4. DocGov 从 duty assignment 创建/校验文档责任人,stage 来自 stage instance。 +5. 任务结果写入与运行终态、文档版本、manifest、lineage、outbox 使用事务/可恢复 saga,避免“显示完成但产物未落稳”。 +6. 列表、详情、导出、附件、搜索、SSE、RAG 和 Agent tool read 都使用同一资源授权上下文。 + +## 10. 数据库迁移方案 + +迁移号以合并时最新 Goose 版本为准,下表暂按 `00021+` 表示。 + +| 迁移 | 主要变更 | 兼容/回填 | +|---|---|---| +| 00021 IAM hardening | project_members.user_id FK;role/binding 状态、reason;审计 actor;权限键 alias | 校验孤儿成员后加 FK;旧 `users.role_id` 双读一版、停止写入 | +| 00022 project roles | `project_roles`、`project_role_permissions`、`project_user_role_bindings` | owner/member/viewer 映射为 owner/contributor/observer;原表保留 membership 状态 | +| 00023 settings | user preferences、notification preferences、security policy versions | 把可识别的 local preferences 一次性导入需用户确认;其余使用服务端默认 | +| 00024 workflow schema v2 | executor registry/version、schema fields、policy JSON schema/version | 不修改 v1 JSON;validator 按 schema_version 路由 | +| 00025 instance integrity | stage position、task executor/template/output snapshot、duty eligibility、change requests | 修正现有 position;对 active 数据先报告后修复,不静默重排 | +| 00026 authorization audit | `authorization_audit_events` 或统一 audit_event 扩展、policy version、request ID | 明确保留期和敏感字段脱敏 | +| 00027 workflow v2 manifests | 创建三个 v2 draft/版本 manifest | 只在 hash 匹配预期时创建;不覆盖用户自定义版本 | + +数据库约束建议: + +- live instance 部分唯一索引继续保留;增加 planning 与 live 状态转换约束测试。 +- `project_stage_instances(workflow_instance_id, position)` unique,position > 0。 +- task instance 保存 `executor_key/template_version/output_doc_type/quality_gate_key` 的冻结副本。 +- duty assignment 引用 active project membership;跨表动态条件用事务 trigger 或 service + deferred validation 实现。 +- activation token 只存 hash;高风险一次性 token 使用短 TTL、actor/project/plan hash 绑定。 +- published definition/template 通过数据库权限或 trigger 阻止 UPDATE/DELETE,不只依赖 Go service。 + +## 11. API 调整 + +### 11.1 身份与设置 + +```text +POST /api/v1/auth/login -> Set-Cookie +POST /api/v1/auth/logout -> revoke current session +GET /api/v1/me +PATCH /api/v1/me/profile +GET/PUT /api/v1/me/preferences +GET/PUT /api/v1/me/notification-preferences +GET /api/v1/me/sessions +DELETE /api/v1/me/sessions/{id} +POST /api/v1/me/password/change +``` + +### 11.2 权限与管理 + +```text +GET /api/v1/admin/permissions +GET/POST/PATCH /api/v1/admin/roles... +GET/POST/DELETE /api/v1/admin/role-bindings... +POST /api/v1/admin/authorization/simulate +GET /api/v1/admin/audit-events +POST /api/v1/admin/users/{id}/reset-password +POST /api/v1/admin/users/{id}/sessions/revoke +``` + +每个 admin endpoint 绑定独立 permission,不再通过 `requireSystemAdmin` 全包。 + +### 11.3 工作流与项目实例 + +```text +POST /api/v1/admin/workflows/{id}/versions/{v}/copy-draft +PATCH /api/v1/admin/workflows/{id}/versions/{v} +POST /api/v1/admin/workflows/{id}/versions/{v}/validate +GET /api/v1/admin/workflows/{id}/versions/{v}/impact +POST /api/v1/admin/workflows/{id}/versions/{v}/publish +POST /api/v1/admin/workflows/{id}/versions/{v}/retire + +GET/PUT /api/v1/projects/{id}/workflow-plan +POST /api/v1/projects/{id}/workflow-plan/preview +POST /api/v1/projects/{id}/workflow-plan/activate +POST /api/v1/projects/{id}/workflow-instances/{iid}/termination-requests +POST /api/v1/projects/{id}/workflow-instances/{iid}/terminate +POST /api/v1/projects/{id}/workflow-instances/{iid}/change-requests +POST /api/v1/projects/{id}/workflow-instances/{iid}/stages/{stage}/advance +POST /api/v1/projects/{id}/doc-tasks/{taskInstanceId}/runs +``` + +所有资源路径先验证 path 中 project/instance/stage/task/document 的归属一致性,避免用合法项目权限访问另一个项目的子资源。 + +## 12. 前端实施方案 + +1. **认证壳**:不从 `localStorage` 恢复 principal/token;启动时只调用 `/me`。401 清空全部敏感状态并跳转登录。 +2. **权限 Provider**:从 `/me` 获取 capabilities + scoped grants;按钮、菜单和路由只做 UX 控制,错误统一展示服务端 reason code。 +3. **用户设置**:现有 modal 拆到真实 API;接入活动会话;资料/偏好保存失败不更新本地成功态。 +4. **系统管理中心**:从单一大 modal 演进为路由页面;权限矩阵来自 catalog;高风险变更提供 diff、影响人数和二次确认。 +5. **工作流设计器**:线性阶段排序、每阶段多任务、executor 只从注册表下拉、模板版本选择、职责候选、参与/可见策略、JSON 高级视图、发布校验报告。 +6. **项目设置**:明确 planning/live/history 三态;live 快照只读;门禁列表展示每个阻断项;下一工作流必须由创建人确认。 +7. **工作台**:根据服务端返回的 allowed actions 渲染,不在前端用姓名/本地角色重新计算业务授权。 +8. **删除演练入口**:`UserProjectRightsModal` 的测试角色切换、旧 `SettingsModal`、本地模拟分支和生产 bundle 中的测试身份文案移出产品代码。 + +## 13. 分阶段交付计划 + +估时为单个熟悉代码库的小组工作日,安全修复与工作流建模可并行开发,但发布必须按闸门顺序。 + +### Phase 0:基线冻结与权限清单(1–2 天) + +- 固定 commit、DB schema、环境和三个 workflow v1 hash。 +- 自动导出全部 `/api/v1` 路由,建立 resource/action/owner/negative test 矩阵。 +- 修正当前 E2E 报告状态矛盾;归档一次干净的 short test 和 build 结果。 +- 输出:`authorization-matrix-v2.md`、migration preflight SQL、测试数据拓扑。 + +门禁:每个非公开路由都有责任 owner、策略和至少一个未授权负例;未知路由默认为拒绝。 + +### Phase 1:P0 授权止血(3–5 天) + +- 修复 AUTH-001–009、WF-001/002 的越权入口。 +- 建立统一 `Authorize`/ResourceRef 和 server-side allowed actions。 +- 删除固定管理员/演示项目 ID 豁免;生产禁用 permissive fallback。 +- Cookie/CSRF/logout/401 前端失效闭环。 + +门禁:匿名、跨用户、跨项目、跨阶段、错 task、错 child resource、DB authz fault 全部负例通过;SSE/RAG/Agent tool 同样验证。 + +### Phase 2:IAM 与设置权威化(4–6 天) + +- 完成 IAM-101–105 和用户/项目/系统设置 API。 +- 角色多绑定、作用域/有效期、permission catalog、原子用户变更和审计。 +- 用户资料、偏好、通知、活动会话 UI;管理中心权限拆分。 + +门禁:重启/换浏览器数据一致;权限收回和账号停用下一请求生效;至少两名管理员防锁死演练通过。 + +### Phase 3:工作流 Schema v2 与运行时合流(5–8 天) + +- 实现 schema v2、executor/quality gate registry、完整 validator。 +- 移除 `flattenVersion` 作为执行路径;编排器只读实例快照。 +- 修复 position、token 原子性、DocGov duty/stage 绑定、stale 门禁。 +- 完成受控终止、变更单和下一个工作流生命周期。 + +门禁:修改全局 published/latest 不影响 active 实例;请求伪造 stage/task/duty 无效;故障恢复后实例位置和职责不漂移。 + +### Phase 4:发布三个 v2 工作流(4–6 天) + +- 按 §8 创建 `main`、`product_manager`、`test_manager` v2 draft。 +- 绑定 ready 模板和 executor;补齐结构化门禁。 +- 对 planning v1 项目生成可读 diff,由创建人选择是否升级;active v1 不迁移。 +- 完成三个流程的串联推荐,但保持人工选择/激活。 + +门禁:三个版本通过静态校验、dry-run、单流程 PG E2E 和串联 PG E2E。 + +### Phase 5:管理端/项目端完整体验(4–6 天) + +- 工作流设计器、模板库、项目规划、门禁详情、影响预览、下一流程、终止/变更 UI。 +- 删除旧本地/演示权威路径和不实合规文案。 +- 可访问性、错误恢复、并发 diff/reconfirm。 + +门禁:产品经理无需改代码即可复制 v2 为 v3 草案、换模板/职责候选、校验并由有权者发布;普通管理员无法越权发布。 + +### Phase 6:发布验证与灰度(5–8 天) + +- 全量 Go/前端、race、PG、迁移升级/回滚、重启、worker 故障注入。 +- 真实 PI Agent 三工作流 E2E;长任务超时/恢复/取消;跨用户负例。 +- 备份恢复、日志脱敏、监控告警、容量和 bundle 分包。 +- 灰度一个内部项目,再扩到新项目;旧项目只读兼容指标归零后删除 fallback。 + +门禁:§14 全部完成;P0/P1 为 0;可恢复演练和回滚演练均有报告。 + +总体建议:**21–35 个小组工作日**。如多人并行,最短日历时间约 4–5 周;不应通过跳过负例、迁移和真实 Runner 验证压缩关键路径。 + +## 14. 测试与验收矩阵 + +### 14.1 授权负例 + +| 场景 | 预期 | +|---|---| +| 未登录访问任意非公开 API/SSE/附件 | 401,不泄漏资源存在性 | +| A 项目成员读 B 项目、run history、lineage、搜索、导出 | 403/404,审计 deny | +| 当前阶段 BD 请求研发 task key | 403;不创建 task_run/workdir/doc | +| 非职责人提交/审核/批准 | 403;状态、版本、审计不变 | +| author 尝试审核或批准同一版本 | 409/403 SoD violation | +| authz DB/策略加载故障 | 敏感路径拒绝,健康状态 degraded,触发告警 | +| 权限撤销后复用旧页面/旧 SSE/旧 Cookie | 下一次鉴权失败;流关闭或重新鉴权 | +| 猜测 task/document/template/blob ID | 不能跨项目读取 | +| 客户端伪造 user/role/stage/workdir/duty | 服务端忽略或 400;不得改变判定 | + +### 14.2 工作流配置与冻结 + +- v2 required stage/task 被遗漏、禁用、重复或错序时校验失败。 +- executor 未注册、模板非 ready、变量 schema 不匹配时禁止发布。 +- published version UPDATE/DELETE 在 service 和 DB 层均失败。 +- 激活 token 重放、过期、跨 actor、跨 project、plan hash 不同均失败。 +- 并发保存/激活/推进只成功一个;另一方收到 diff/reconfirm,不自动重试。 +- 修改 v3 不影响使用 v2 的 active 项目;历史 hash 可复算。 +- 流程未完成/未终止时不能创建第二个 live;完成后可配置下一个 planning。 + +### 14.3 三工作流 E2E + +**商务对接:** 按 V0.1 → V0.5 三任务 → V1.0 运行;每阶段文档分别走三权;未全部 approved 不产生 ready;满足后产生一次去重提醒;只有创建人确认推进。 + +**智能产品经理:** 从批准的输入基线生成 `requirements.json` 和正文;随机抽取、重复 ID、缺验证准则、正文/JSON 不一致均被确定性门禁拒绝;批准后可作为下游输入。 + +**智能测试经理:** 没有 approved SR、SR stale、hash 不匹配时拒绝启动;生成必需 `test_cases.json`;需求全集覆盖率必须 100%;上游更新后下游立即 stale 且不能完成。 + +**串联:** `main` 完成后只推荐 PM;创建人配置并激活;PM 完成后只推荐 Test;每个实例版本、模板、职责、输入 hash、输出 hash 和审计链可追溯。 + +### 14.4 运行与灾难恢复 + +- 应用、worker、PostgreSQL、PI Runner 分别重启,验证会话/任务/实例/Outbox 恢复。 +- PI Runner 超时后 task 可恢复或明确失败,不重复生成审批版本。 +- Outbox 重投不重复发阶段提醒;消费者幂等。 +- 迁移从 v20 的生产快照升级;备份恢复后 hash/权限/实例一致。 +- `go test -race`、前端测试、生产 build、依赖/镜像扫描、长期 soak。 + +## 15. 发布完成定义(Definition of Done) + +只有同时满足以下条件,才能宣布“权限、设置和三个工作流已按当前设计做全”: + +- [ ] 生产产品不存在任意身份切换、本地认证 fallback 或客户端身份权威。 +- [ ] 每个 API/SSE/RAG/Agent tool/附件通路都有服务端授权和跨用户负例。 +- [ ] 授权失败或依赖异常默认拒绝;不存在固定用户 ID、项目 ID 特权。 +- [ ] 系统角色、项目业务角色、文档三权分离且可审计;三权职责分离生效。 +- [ ] 用户、项目、系统三类设置均由服务端持久化,刷新/重启/换端一致。 +- [ ] 工作流、模板、权限管理使用各自细粒度 permission,而不是全员超级管理员。 +- [ ] 已发布工作流/模板不可变;项目激活冻结版本、模板、阶段、任务、策略和职责。 +- [ ] 同一项目最多一个 live;完成/受控终止后由创建人选择下一个。 +- [ ] 门禁满足只通知,不自动推进;创建人确认与推进在事务中完成。 +- [ ] `main` v2 完整执行 V0.1/V0.5/V1.0,不再由硬编码目录另行决定。 +- [ ] `product_manager` v2 结构化需求门禁通过;`test_manager` v2 实现 100% 需求覆盖和 stale 阻断。 +- [ ] DocGov 的 stage 和三权全部来自冻结实例,客户端不能覆盖。 +- [ ] PG、真实 PI Agent、重启恢复、故障注入、跨项目/跨阶段负例均有可复现报告。 +- [ ] P0/P1 缺陷归零,灰度/回滚/监控/告警和审计查询可用。 + +## 16. 风险与控制 + +| 风险 | 概率/影响 | 控制 | +|---|---|---| +| v1/v2 双执行权威并存 | 高/高 | feature flag 只选一个 orchestrator;记录每次 run 的 schema/executor/version;不静默 fallback | +| 存量项目职责/成员数据不完整 | 高/中 | preflight 报告;active 不强迁;planning 由创建人补全并确认 | +| 权限收紧影响现有演示项目 | 高/中 | 显式 legacy visibility 数据迁移;不保留 ID 豁免;灰度观察 deny 指标 | +| Cookie 改造引入 CSRF | 中/高 | SameSite + CSRF token + Origin/Referer 校验;状态变更不用 GET | +| 长 Agent 运行与 HTTP 超时 | 高/高 | 异步 task + lease/heartbeat + resumable worker;客户端订阅状态,不持有超长请求 | +| 结构化质量门禁误报 | 中/中 | schema version、明确错误定位、golden corpus、允许修复重跑但不可人工静默跳过 | +| 角色配置锁死管理员 | 中/高 | 双管理员、关键权限不可清空、变更影响预览、break-glass 离线流程 | +| 模板恶意文件/解析炸弹 | 中/高 | 隔离、扩展/MIME/签名、大小/解压限制、扫描、Web root 外 CAS;仅 ready 可引用 | + +## 17. 建议的首个迭代任务包 + +第一周不要先画完整工作流设计器,先完成以下可独立验收的任务: + +1. `AUTH-001`:给 run history/lineage 及所有项目子资源补统一对象授权和双用户 PG 负例。 +2. `AUTH-002`:阶段访问错误全部 fail closed,加入数据库故障注入测试。 +3. `AUTH-003`:workflow run/task run 改从 task instance 推导 task/stage/executor,客户端覆盖返回 400。 +4. `AUTH-004/005`:补必需项反向校验和 duty member/status/role eligibility 校验。 +5. `AUTH-006/007`:删除 ID 特判,项目列表改用 `ListVisibleProjectIDs`/membership join,创建项目校验 permission。 +6. `AUTH-009`:服务端 logout + `/me` 失效清理;设计 Cookie/CSRF 兼容迁移。 +7. `WF-001/002`:提交 schema v2 ADR 和 runtime authority contract,阻止新增旁路。 + +首周验收物应是:代码、迁移 preflight、授权矩阵、负例测试、更新后的安全报告。只有该闸门通过,才开始发布三个 v2 draft。 + +## 18. 交付物清单 + +- 本实施方案。 +- API 授权矩阵 v2(路由、动作、资源、策略、负例、owner)。 +- Schema v2 JSON Schema + executor/quality gate registry ADR。 +- Goose 00021+ 迁移和 preflight/rollback SQL。 +- 三个工作流 v2 manifest、模板版本清单和 golden fixtures。 +- 用户/项目/系统设置产品规格与 API contract。 +- 授权负例、PG、真实 PI Agent、迁移、恢复、soak 测试报告。 +- 灰度清单、监控面板、告警规则、回滚 runbook。 + +## 资料来源 + +[^1]: 当前治理原则与目标模型见 [2026-09-11-production-ready-workflow-governance-plan.md](./2026-09-11-production-ready-workflow-governance-plan.md)。 +[^2]: IAM、工作流定义、项目实例、通知和任务持久化分别见 [00015 IAM](../deploy/postgres/goose/00015_2026-09-12-iam-core.sql)、[00016 workflow definitions](../deploy/postgres/goose/00016_2026-09-12-workflow-definitions-and-templates.sql)、[00017 project instances](../deploy/postgres/goose/00017_2026-09-12-project-workflow-instances.sql)、[00018 outbox](../deploy/postgres/goose/00018_2026-09-12-outbox-notifications-stage-advance.sql)、[00019 task runs](../deploy/postgres/goose/00019_2026-09-12-task-runs-events.sql) 和 [00020 worker lease](../deploy/postgres/goose/00020_2026-09-12-task-run-worker-lease.sql)。 +[^3]: 本轮验证命令与结果记录在本方案 §2.2;当前历史测试状态见 [2026-09-12-pg-ci-soak-e2e-test-report.md](./test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md)。 +[^4]: 系统能力查询没有限定 binding scope/role status/valid_from,且 role-admin 字符串直接判 admin,见 [internal/authz/service.go](../internal/authz/service.go#L26-L99)。 +[^5]: 创建项目只验证身份,见 [project_handlers.go](../internal/server/project_handlers.go#L16-L46);运行历史和血缘查询未做项目访问判断,见同文件 [run history/lineage](../internal/server/project_handlers.go#L221-L274)。 +[^6]: 工作流管理统一走 `requireSystemAdmin`,没有使用已经入库的 `workflow_defs.*`,见 [admin_workflow_handlers.go](../internal/server/admin_workflow_handlers.go#L13-L23);权限种子见 [00016](../deploy/postgres/goose/00016_2026-09-12-workflow-definitions-and-templates.sql#L67-L82)。 +[^7]: `ResolveStageAccess` 在 live 查询错误时返回 unrestricted,且一个阶段任一职责会放开该阶段全部任务,见 [stage_access.go](../internal/projectinstance/stage_access.go#L15-L68);服务器过滤辅助函数也在错误时放行,见 [stage_access_helpers.go](../internal/server/stage_access_helpers.go#L23-L47)。 +[^8]: 任务门禁在 actor 能访问 active stage 时放过不匹配 task,见 [bizdev_stage_gate.go](../internal/server/bizdev_stage_gate.go#L40-L54);workflow run 接收客户端 task override,见 [project_handlers.go](../internal/server/project_handlers.go#L205-L218)。 +[^9]: token/session 存 localStorage 且 logout 只删除本地键,见 [web/src/api/auth.ts](../web/src/api/auth.ts#L306-L330);恢复 session 后 `/me` 失败被吞掉,见 [web/src/App.tsx](../web/src/App.tsx#L690-L703)。 +[^10]: 用户设置把偏好描述为浏览器本地并从前端角色推导权限,见 [UserSettingsModal.tsx](../web/src/components/user/UserSettingsModal.tsx#L68-L77) 与 [同文件](../web/src/components/user/UserSettingsModal.tsx#L673-L779);后端已有活动会话 API,见 [auth_handlers.go](../internal/server/auth_handlers.go#L162-L204)。 +[^11]: 三个单阶段种子见 [workflowdef/bootstrap.go](../internal/workflowdef/bootstrap.go#L63-L74);seed 遇到已有发布版本直接退出,见 [workflowdef/store.go](../internal/workflowdef/store.go#L364-L372)。 +[^12]: DBCatalog 只取首阶段/首任务,见 [workflowdef/catalog.go](../internal/workflowdef/catalog.go#L50-L82);现有 schema 能表达的字段见 [workflowdef/types.go](../internal/workflowdef/types.go#L15-L43)。 +[^13]: 商务流程真实阶段和任务仍由 [internal/bizdev/service.go](../internal/bizdev/service.go#L202-L223) 硬编码。 +[^14]: 规划 validator 只遍历客户端提交项,没有反向验证必需定义项是否缺失,见 [projectinstance/validator.go](../internal/projectinstance/validator.go#L37-L105);三权只校验非空和互斥,见 [同文件](../internal/projectinstance/validator.go#L108-L144)。 +[^15]: 阶段实例 position 固定写 0,见 [projectinstance/store.go](../internal/projectinstance/store.go#L244-L249);执行器默认 reviewer=2、approver=3 并固定阶段,见 [bizdev/service.go](../internal/bizdev/service.go#L383-L418)。 +[^16]: 当前报告的真实 PI Agent E2E 记录 `/v1/run` 超时,同时结论仍称“进行中”,见 [2026-09-12-pg-ci-soak-e2e-test-report.md](./test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md)。 +[^17]: [OWASP Authorization Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html)。 +[^18]: [NIST Role Based Access Control](https://csrc.nist.gov/Projects/Role-Based-Access-Control) 与 [NIST SP 800-162 ABAC](https://csrc.nist.gov/pubs/sp/800/162/upd2/final)。 +[^19]: [OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)。 +[^20]: 产品经理和测试经理的 Agent 角色及职责见 [squad/workspace.go](../internal/squad/workspace.go#L648-L743),内部步骤见 [同文件](../internal/squad/workspace.go#L940-L1073)。 +[^21]: 当前产品/测试质量门禁见 [squad/quality_gate.go](../internal/squad/quality_gate.go#L108-L146)。 +[^22]: 测试经理对 approved SR 的前置校验和材料注入见 [bizdev/service.go](../internal/bizdev/service.go#L365-L381),stale 传播见 [projectworkflow/service.go](../internal/projectworkflow/service.go#L237-L265)。 diff --git a/.doc/2026-09-12-quick-reference-guide.md b/.doc/2026-09-12-quick-reference-guide.md new file mode 100644 index 00000000..6816a20a --- /dev/null +++ b/.doc/2026-09-12-quick-reference-guide.md @@ -0,0 +1,461 @@ +# 项目审计快速参考指南 + +**用途**: 开发/QA 快速定位问题、理解整改方向 +**格式**: 结构化索引,支持搜索和导航 + +--- + +## 一、关键数字一览 + +| 指标 | 数值 | 说明 | +|---|---|---| +| 项目完成度 | 70% | 架构完整,核心功能大部分实现,缺新功能和 bug 修复 | +| 已确认缺陷 | 12 个 | 3 个 critical,4 个 high,5 个 medium | +| 未完成功能 | 3 个 | 项目上下文快照、受控终止、E2E 测试 | +| 整改工作量 | 61h | 单人 2.5 天,双人 5 天 | +| 单元测试 | 120+ | 85% 覆盖主要包 | +| E2E 测试 | 0% | **需补充** | +| 数据污染 | 88 个定义 | 测试定义混入开发库 | + +--- + +## 二、快速诊断矩阵 + +### 症状 → 原因 → 解决方案 + +``` +症状 1: 完成工作流后项目仍显示为"正在运行",无法配置下一个工作流 +├─ 原因: BUG-01 - AdvanceStage 完成最后阶段时未更新项目 lifecycle +├─ 代码位置: internal/projectinstance/stage_store.go:128-158 +├─ 优先级: 🔴 P0 CRITICAL +└─ 解决: Task A1(2h) + └─ 同事务更新 projects.lifecycle_status = 'planning' + +症状 2: 禁用的文档仍然可以在某些页面被启动/编辑 +├─ 原因: BUG-05 - 拒绝逻辑分散,不同入口检查不一致 +├─ 代码位置: stage_access.go, API, SSE, Agent tools 多处 +├─ 优先级: 🔴 P1 HIGH +└─ 解决: Task A5(4h) + └─ 实现 DocumentAccessDecider,统一所有入口 + +症状 3: 对话中说"推进工作流"有时不生效或使用旧流程 +├─ 原因: BUG-04 - Coordinator 调用旧 bizdevSvc.ExecuteStage +├─ 代码位置: internal/coordinator/engine.go:672-811 +├─ 优先级: 🔴 P0 CRITICAL +└─ 解决: Task A4(2h) + └─ 改为调用 instSvc.AdvanceStage + +症状 4: 阶段推进后发现文档被强制标记为"已批准",但用户未操作 +├─ 原因: BUG-02 - AdvanceStage SQL 中有强制更新批准状态的逻辑 +├─ 代码位置: internal/projectinstance/stage_store.go(需 grep 验证) +├─ 优先级: 🔴 P0 CRITICAL +└─ 解决: Task A2(1h) + └─ 删除 UPDATE 批准状态的 SQL 行 + +症状 5: 测试在开发环境运行后,88 个奇怪的 pg_* 定义出现在工作流目录 +├─ 原因: BUG-08 - 集成测试未设置 TEST_DATABASE_URL,默认连接开发库 +├─ 代码位置: internal/projectinstance/pg_integration_test.go +├─ 优先级: 🟠 P1 MEDIUM +└─ 解决: Task BUG-08(2h) + └─ 强制要求 TEST_DATABASE_URL;测试后清理 + +症状 6: 激活工作流时 token 可能在浏览器历史中看到,或被重复使用 +├─ 原因: BUG-06 - Token 明文保存,消费分事务,不是 one-time +├─ 代码位置: internal/projectinstance/service.go(激活相关) +├─ 优先级: 🟠 P1 HIGH +└─ 解决: Task A6(3h) + └─ Token → hash + one-time,同事务消费 + +症状 7: 产品经理流程不运行,测试工程师流程无法启动,提示"需先运行产品流程" +├─ 原因: MISSING-06 - test_manager manifest 中仍有 upstream 依赖 +├─ 代码位置: internal/workflowdef/manifests/test_manager.v2.json +├─ 优先级: 🟠 P1 HIGH +└─ 解决: Task B3(需先删除测试流程的上游依赖) + └─ 修改 manifest,删除 required_upstream_workflow + +症状 8: 选择工作流时前端显示"商务流程",但服务返回的名称不一致 +├─ 原因: BUG-12 - 前端硬编码名称 vs 后端正式名称不同步 +├─ 代码位置: web/src/components/workflow/ProjectWorkflowSwitcher.tsx +├─ 优先级: 🟢 P2 LOW +└─ 解决: 由后端 GET /api/v1/workflows/catalog 驱动前端显示 + +症状 9: 无法手工删除已完成的工作流实例,项目永久处于"运行中" +├─ 原因: MISSING-02 - 受控终止 API 未实现 +├─ 代码位置: (缺失) +├─ 优先级: 🔴 P1 CRITICAL +└─ 解决: Task B1(6h) + └─ 实现 TerminateWorkflow API,二次确认 + 原因记录 + +症状 10: 产品经理和测试工程师输出中缺少"来源追溯",无法知道数据从哪来 +├─ 原因: MISSING-01 - 项目上下文快照服务未实现 +├─ 代码位置: (缺失) +├─ 优先级: 🔴 P1 CRITICAL +└─ 解决: Task B3(12h) + └─ 实现 ProjectContextSnapshot 服务,包含血缘表 +``` + +--- + +## 三、代码热点地图 + +### 高风险区域(需特别关注的文件) + +``` +🔴 CRITICAL +├─ internal/projectinstance/stage_store.go +│ ├─ BUG-01: AdvanceStage 不更新项目生命周期 +│ ├─ BUG-02: 推进强制批准文档(需验证) +│ └─ 位置: 第 71-158 行 +│ +├─ internal/coordinator/engine.go +│ ├─ BUG-04: 对话推进调用旧服务 +│ └─ 位置: 第 672-811 行(handleAdvanceStage) +│ +├─ internal/projectinstance/service.go +│ ├─ BUG-01: 完成后生命周期(同上) +│ └─ 位置: 第 119-135 行(SaveWorkflowPlan) + +🟠 HIGH +├─ internal/projectinstance/stage_access.go +│ ├─ BUG-05: 禁用文档拒绝逻辑不一致 +│ └─ 位置: 第 15-71, 90-104 行 +│ +├─ web/src/components/workflow/ProjectWorkflowSwitcher.tsx +│ ├─ BUG-09: 版本冲突自动重试 +│ ├─ BUG-12: 硬编码名称 +│ └─ 位置: 第 16-23, 38-76, 173-236 行 + +🟡 MEDIUM +├─ internal/projectinstance/pg_integration_test.go +│ ├─ BUG-08: 未设置 TEST_DATABASE_URL +│ └─ 位置: 第 19-49 行 +│ +├─ internal/workflowdef/manifests/test_manager.v2.json +│ ├─ 依然有 upstream 依赖(需删除) +│ └─ 位置: 第 1-23 行 +``` + +--- + +## 四、整改优先级排序 + +### 必须在本周内完成(优先级递减) + +``` +□ P0-1: BUG-01 项目完成后生命周期 (2h) + → 项目永久锁死的根本原因 + +□ P0-2: BUG-04 对话推进新服务切换 (2h) + → 权限检查绕过 + +□ P0-3: BUG-02 推进不强制批准 (1h) + → 数据违反业务规则 + +□ P1-1: BUG-05 禁用文档一致性 (4h) + → 安全隐患 + +□ P1-2: BUG-06 激活 token 安全 (3h) + → 存在泄露/重放风险 + +□ P1-3: Task B1 受控终止 API (6h) + → 恢复能力必需 + +□ P1-4: Task B2 E2E 测试 (8h) + → 验收标准要求 + +可以延后(下周处理) + +□ P2-1: BUG-03 禁用文档 skipped_disabled (3h) +□ P2-2: BUG-08 测试隔离完善 (2h) +□ P2-3: BUG-10 项目设置审计视图 (3h) +□ P2-4: BUG-09 前端冲突自动重试 (1h) +□ P2-5: BUG-11 旧 API 下线 (1h) +□ P2-6: BUG-12 前端名称一致性 (1h) +``` + +--- + +## 五、代码修改检查清单 + +### 提交前验收标准 + +**修复 BUG-01 时**: +``` +[ ] 修改 stage_store.go 的 AdvanceStage 函数 + - 检查是否最后阶段 + - 若是,同事务更新 projects.lifecycle_status + - 清空 current_workflow_instance_id + - 写审计事件 +[ ] 新增测试: TestAdvanceStage_CompletesProjectAfterLastStage +[ ] 新增测试: TestAdvanceStage_DoesNotChangeProjectOnNonLastStage +[ ] 本地测试通过 (go test ./internal/projectinstance -v) +[ ] 前端检查:/projects/{id}/settings 显示"配置下一工作流" +``` + +**修复 BUG-04 时**: +``` +[ ] 修改 coordinator/engine.go 的 handleAdvanceStage + - 提取 workflow_instance_id + - 改调 instSvc.AdvanceStage 替代 bizdevSvc.ExecuteStage + - 调整返回格式 +[ ] 新增测试: TestDialogueAdvanceStage_UsesNewService +[ ] 新增测试: TestDialogueAdvanceStage_PermissionCheck +[ ] 本地测试通过 (go test ./internal/coordinator -v) +[ ] 对话测试通过 (go test ./internal/server -run Dialogue -v) +``` + +**创建 Task B2 E2E 框架时**: +``` +[ ] 确保 TEST_DATABASE_URL 已设置 +[ ] 创建 setupE2ETestDB() 函数 + - 创建隔离 schema + - 运行所有迁移 + - 清理逻辑 +[ ] 编写 TestE2E_BizdevWorkflow_Complete + - 7 文档完整流程 + - 每个阶段验证门禁 + - 完成后验证项目状态 +[ ] 编写 TestE2E_ProductManagerWorkflow_Standalone + - 无前置依赖 + - 独立运行成功 +[ ] 编写 TestE2E_TestEngineerWorkflow_WithoutSRS + - 无 SRS 输入仍能运行 + - 输出包含来源清单 +[ ] 全部 E2E 测试通过 +[ ] 验收标准 7.5 完全满足 +``` + +--- + +## 六、数据库迁移清单 + +### 需要新增的迁移脚本 + +``` +00025_2026-09-12-disabled-doc-status.sql +├─ 添加 project_doc_task_instances.status_reason +├─ 添加约束:disabled 时 duties 为空 +└─ 编写回滚脚本 + +00026_2026-09-12-workflow-termination.sql +├─ 添加 project_workflow_instances.terminated_at/by/reason +├─ 添加 termination_reason 列 +└─ 编写回滚脚本 + +00027_2026-09-12-context-snapshots.sql +├─ 创建 project_context_snapshots 表 +├─ 创建 project_context_snapshot_sources 表 +└─ 编写回滚脚本 + +00028_2026-09-12-activation-challenges.sql +├─ 创建 activation_challenges 表 +├─ 创建 one_time_tokens 表 +└─ 编写回滚脚本 +``` + +--- + +## 七、测试命令参考 + +```bash +# 运行所有项目实例测试 +go test ./internal/projectinstance -v + +# 运行所有协调器测试 +go test ./internal/coordinator -v + +# 运行所有工作流定义测试 +go test ./internal/workflowdef -v + +# 运行服务器集成测试 +go test ./internal/server -v + +# 运行特定 bug 的测试 +go test ./internal/projectinstance -run "TestAdvanceStage" -v + +# 运行 PG 集成测试(需 TEST_DATABASE_URL) +TEST_DATABASE_URL=postgresql://... go test ./internal/server -run PG -v + +# 运行 E2E 测试 +TEST_DATABASE_URL=postgresql://... go test ./internal/server -run E2E -v + +# 代码覆盖率分析 +go test ./internal/projectinstance ./internal/coordinator ./internal/workflowdef \ + -coverprofile=coverage.out && go tool cover -html=coverage.out + +# 静态检查 +go vet ./internal/... +golangci-lint run ./internal/... +``` + +--- + +## 八、API 快速参考 + +### 关键端点变动汇总 + +``` +现有(需保留或改进): + GET /api/v1/workflows/catalog + → 返回三项正式工作流 ✅ + + GET /api/v1/projects/{id}/settings + → 返回项目设置及 live instance 配置 ✅ + + PUT /api/v1/projects/{id}/workflow-plan + → 保存/修改规划 (planning 状态下) ✅ + + POST /api/v1/projects/{id}/workflow-instances/activate + → 激活工作流 (需修复 token) ⚠️ + + GET /api/v1/workflow-instances/{id}/stages/{stage}/readiness + → 阶段门禁详情 ✅ + + POST /api/v1/workflow-instances/{id}/stages/{stage}/advance + → 推进阶段 (需修复生命周期) ❌ + +待实现(高优先级): + POST /api/v1/workflow-instances/{id}/termination-preview + → 终止预览(Task B1) + + POST /api/v1/workflow-instances/{id}/terminate + → 执行终止(Task B1) + +待删除或返回 410: + POST /projects/{id}/active-workflow + → 旧切换 API(需下线) + +需改进(权限一致性): + POST /api/v1/doc-task-instances/{id}/runs + → 启动编制 (需 DocumentAccessDecider) ⚠️ + + POST /api/v1/documents/{id}/submit + → 提交审核 (需统一权限检查) ⚠️ + + POST /api/v1/documents/{id}/approve + → 批准 (需统一权限检查) ⚠️ +``` + +--- + +## 九、常见问题速查 + +### Q1: 为什么完成工作流后项目仍然处于"active"? +**A**: BUG-01 - `AdvanceStage` 完成最后阶段时未同事务更新项目 `lifecycle_status`。 +**修复**: Task A1(2h) +**临时绕过**: 手动执行 SQL `UPDATE projects SET lifecycle_status='planning' WHERE id=?` + +### Q2: 禁用的文档仍然能被某些用户操作? +**A**: BUG-05 - 不同入口对禁用文档的检查不一致。 +**修复**: Task A5(4h) - 实现 `DocumentAccessDecider` +**临时绕过**: 在关键入口前添加 `if doc.status == 'skipped_disabled' { return 409 }` + +### Q3: 激活 token 可能被重复使用? +**A**: BUG-06 - Token 明文保存,消费分事务。 +**修复**: Task A6(3h) +**临时保护**: 限制 token 有效期为 5 分钟;记录消费日志;审计异常 token 使用 + +### Q4: 对话推进工作流不起作用? +**A**: BUG-04 - Coordinator 调用旧商务服务而非实例服务。 +**修复**: Task A4(2h) +**临时检查**: 查看 `internal/coordinator/engine.go:672-811` 是否仍调用 `bizdevSvc` + +### Q5: 为什么 E2E 测试一直 fail? +**A**: 可能多个原因: + 1. TEST_DATABASE_URL 未设置 → 连接开发库(88 个污染定义) + 2. E2E 框架缺失 → 需实现 setupE2ETestDB() + 3. 实例生命周期 bug → BUG-01 导致测试逻辑不对 + +**修复**: 检查 TEST_DATABASE_URL;运行 Task B2 + +### Q6: 生产数据库中有 47 个"已完成但项目仍为 active"的不一致数据? +**A**: BUG-01 导致。数据本身可恢复。 +**恢复方案**: + 1. 查询 `SELECT * FROM project_workflow_instances WHERE status='completed'` + 2. 对每个 completed 实例: + ```sql + UPDATE projects SET lifecycle_status='planning', current_workflow_instance_id=NULL + WHERE id=(SELECT project_id FROM project_workflow_instances WHERE id=?) + ``` + 3. 补写审计日志 + +--- + +## 十、参考文档导航 + +| 文档 | 用途 | 适合人员 | +|---|---|---| +| [remediation-gap-matrix.md](./2026-09-12-remediation-gap-matrix.md) | **当前缺口 SSOT** | 全体 | +| [ops/CURRENT.md](./ops/CURRENT.md) | 协作焦点 | Agent / 开发 | +| [comprehensive-audit-and-remediation-plan.md](./2026-09-12-comprehensive-audit-and-remediation-plan.md) | 详细缺陷分析与整改方案(历史) | 架构师、资深开发 | +| [remediation-tasks-and-acceptance-checklist.md](./2026-09-12-remediation-tasks-and-acceptance-checklist.md) | 可执行的任务拆分与验收 | 开发、QA | +| [audit-summary-report.md](./2026-09-12-audit-summary-report.md) | 审计总结与建议(历史) | 项目经理、技术负责人 | +| **[本文件]** | 快速参考与索引 | 全体团队 | +| [three-workflows-strict-governance-remediation.md](./2026-09-12-three-workflows-strict-governance-remediation.md) | 业务需求原始规划 | 产品、架构 | + +--- + +## 十一、快速评分表 + +### 修复每个 bug 后的自检清单 + +``` +BUG-01 ✓ 完成 + □ stage_store.go 修改 + □ 新增单元测试 ×2 + □ 本地 go test PASS + □ 前端验证项目状态改变 + → 接受标准: 完成最后阶段后项目 lifecycle='planning' + +BUG-02 ✓ 完成 + □ grep 确认强制批准逻辑存在 + □ 删除 UPDATE 批准状态的 SQL + □ 新增单元测试 + □ 本地 go test PASS + → 接受标准: 推进前后文档状态不变 + +BUG-04 ✓ 完成 + □ engine.go handleAdvanceStage 改为调用新服务 + □ 新增单元测试 + □ 本地 go test PASS + □ 对话测试 PASS + → 接受标准: 对话推进使用 instSvc + +BUG-05 ✓ 完成 + □ 实现 DocumentAccessDecider 接口 + □ 集成到 API、SSE、异步任务、Agent tools + □ 新增综合测试 + □ 本地 go test PASS + → 接受标准: 禁用文档全拒绝,返回 409/403 + +BUG-06 ✓ 完成 + □ 数据库迁移 00028 + □ API 改为 challenge_id + one_time_token + □ Token hash + 原子消费 + □ 新增安全测试 ×4 + □ 本地 go test PASS + → 接受标准: Token one-time、hash 存储、5 分钟过期 + +Task B1 ✓ 完成 + □ 新增 terminate.go 服务 + □ TerminationPreview API + □ TerminateWorkflow API + □ 新增端到端测试 + □ 本地 go test PASS + → 接受标准: 终止后项目可配置下一流程 + +Task B2 ✓ 完成 + □ setupE2ETestDB() 实现 + □ TestE2E_BizdevWorkflow_Complete PASS + □ TestE2E_ProductManagerWorkflow_Standalone PASS + □ TestE2E_TestEngineerWorkflow_WithoutSRS PASS + □ TEST_DATABASE_URL 隔离库运行 + → 接受标准: 三工作流完整链路验证 +``` + +--- + +**文档版本**: v1.0 快速参考 +**适用对象**: 项目全体 +**更新频率**: 同步更新主审计文档 + diff --git a/.doc/2026-09-12-remediation-gap-matrix.md b/.doc/2026-09-12-remediation-gap-matrix.md new file mode 100644 index 00000000..4acc1583 --- /dev/null +++ b/.doc/2026-09-12-remediation-gap-matrix.md @@ -0,0 +1,71 @@ +# 整改缺口复核矩阵(SSOT) + +**更新**: 2026-09-13 +**分支**: dev +**production_ready**: no(CI 真实 PI E2E / 前端人工抽检未完成;main 本地冒烟已通过) +**协作焦点**: [.doc/ops/CURRENT.md](ops/CURRENT.md) +**Agent 手册**: [AGENTS.md](../AGENTS.md) · [PROJECT.md](../PROJECT.md) + +> Codex 2026-09-12 再审计项与本轮回环证据见下表「Codex 再审计」区段。 + +--- + +## Phase A/B/C 原审计项 + +| ID | 审计描述 | 状态 | 证据 | 计划动作 | +|---|---|---|---|---| +| BUG-01 | 完成后 lifecycle 未更新 | PASS | `stage_remediation_test.go`;`scripts/fix-completed-instance-lifecycle.sql`;dev 执行 UPDATE 47 | 已完成 | +| BUG-02 | 推进强制批文档 | PASS | `AdvanceStage` 无 doc UPDATE | 已完成 | +| BUG-03 | 无 skipped_disabled | PASS | memStore `skipped_disabled` | 已完成 | +| BUG-04 | 对话走旧 bizdev | PASS | `engine.go` live 门控 | 已完成 | +| BUG-05 | 拒绝逻辑分散 | PASS | `document_access.go` + `stage_access_helpers.go` | 已完成 | +| BUG-06 | token 明文/重放 | PASS | `activation_token_test.go` | 已完成 | +| BUG-07 | 完成后下一流程 | PASS | 同 BUG-01 | 已完成 | +| BUG-08 | 测试库隔离 | PARTIAL | `db/testdb.go` Require/Skip | CI 设 `TEST_DATABASE_URL` | +| BUG-09 | 版本冲突自动重试 | PASS | `StageAdvanceBanner` reload | 已完成 | +| BUG-10 | 无审计视图 | PASS | settings + UI | 已完成 | +| BUG-11 | 旧 API 未下线 | PARTIAL | 治理开启时 410 | 保持 + 文档标注 | +| BUG-12 | 前端名称硬编码 | PASS | catalog API | 已完成 | +| MISSING-01 | 上下文快照 | PASS | `ctxmgr.CollectProjectDialogue` | 已完成 | +| MISSING-02 | 终止 | PASS | `GetTerminationPreview` | 已完成 | +| MISSING-04 | E2E | PASS | 三正式工作流 PG+PI E2E | 已完成 | + +--- + +## Codex 2026-09-12 再审计 + +| ID | 描述 | 状态 | 证据 | +|---|---|---|---| +| AUTH-TASK-RUN | `/tasks/run` 缺项目鉴权 | PASS | `handlers.go` RequireProjectAccess;`task_run_authz_pg_test.go` | +| AUTH-TASK-SSE | `/tasks/run/stream` 鉴权在 Flush 前 | PASS | `sse.go`;同上测试 | +| DB-USERS-SEQ | 种子 users_id_seq 冷启动冲突 | PASS | `00025_...-sync-seed-sequences.sql`;dev/test setval | +| DATA-LIFECYCLE-DEV | completed 实例对应 active 项目 | PASS | dev dry-run 47 → fix SQL → 0 | +| DATA-TEST-WF | test 污染 active 工作流定义 | PASS | `retire-nonofficial-workflow-definitions.sql`;test UPDATE 5 | +| TEST-ADVISOR-BRIEF | 多意图 AdvisorBrief 丢失 | PASS | `action_planner.go` enrich;`action_planner_test.go` | +| TEST-V05-ARTIFACTS | V0.5 断言 3 vs 5 | PASS | `types_test.go` / `full_link_e2e_test.go` → 5 | +| TEST-CHAT-MEMORY | 语义记忆 sleep 竞态 | PASS | `session_test.go` deadline 轮询 | +| TEST-EMBEDDING-GATE | 默认打真实 embedding | PASS | `kb/integration_test.go` Short + `RUN_KB_INTEGRATION=1` | +| HEALTH-SCHEMA-CACHE | health schema 启动缓存 | PASS | `LiveSchemaVersion` + health 实时读 | +| GO-VET-ASSET | asset handler resp before err | PASS | `asset_handlers_test.go` err-first | +| ENV-TEST-STALE | test 旧镜像/schema 漂移 | PASS | rebuild app;health schema_version=25 | +| ENV-DEV-STALE | dev 二进制/schema 漂移 | PASS | docker app 重启;schema_version=25 | +| ENV-MAIN-MISSING | main 未部署验收 | PASS | docker 创建 `data/main`;compose 注入 ADMIN/KB 密码;health/ready schema 25 | +| DOC-SSOT | 文档叙事漂移 | PASS | 本矩阵 + superseded 标记 + ops | +| OPS-AGENTS | 多 Agent 协作规范 | PASS | `AGENTS.md` / `PROJECT.md` / `.doc/ops` | + +--- + +## 发布门禁(test → main) + +``` +☑ go vet ./... 全绿 +☑ go test -short ./... 全绿 +□ TEST_DATABASE_URL 下治理 PG 集成全绿(CI) +□ 真实 PI Agent 三工作流治理 E2E 实录(本轮 stub/short 已绿,PI 待 CI token) +☑ lifecycle fix SQL 已在 dev 执行 +☑ test/dev health schema_version=25 +☑ main 环境部署冒烟(本地 8000 health/ready schema 25) +□ 前端:版本冲突刷新、终止 preview 人工抽检 +``` + +**结论**: P0 授权、冷启动序列、Go 门禁、三环境本地冒烟已闭环。**发布 blocker 剩余 CI 真实 PI E2E 与前端人工抽检**。 diff --git a/.doc/2026-09-12-remediation-tasks-and-acceptance-checklist.md b/.doc/2026-09-12-remediation-tasks-and-acceptance-checklist.md new file mode 100644 index 00000000..05388cce --- /dev/null +++ b/.doc/2026-09-12-remediation-tasks-and-acceptance-checklist.md @@ -0,0 +1,1179 @@ +# 整改实施拆分与验收清单 + +> **工作手册(非 live status)** — 任务说明与验收框;勾选状态以 [remediation-gap-matrix.md](./2026-09-12-remediation-gap-matrix.md) 与 [.doc/ops/checklists/](./ops/checklists/) 为准。 + +**日期**: 2026-09-12 +**格式**: 可执行的任务分解与逐项验收标准 +**用途**: 开发、QA 与产品对齐 + +--- + +## 第一部分:关键缺陷修复(Phase A) + +### Task A1: 项目完成后生命周期转换 + +**目标**: 完成最后阶段后,项目立即进入可配置下一工作流的状态 + +**修改文件**: +- `internal/projectinstance/stage_store.go` (AdvanceStage 事务) +- `internal/projectinstance/service.go` (逻辑校验) +- 相关测试 + +**具体修改**: + +**文件**: `internal/projectinstance/stage_store.go` +```go +// 现有代码(伪代码) +func (s *pgStore) AdvanceStage(...) (*InstanceDetail, error) { + // ... 获取实例与阶段定义 + + // 🔴 当前:只更新实例,不更新项目 + stmt := "UPDATE project_workflow_instances SET active_stage_key = ?, status = ? WHERE id = ?" + + // ✅ 修改后:检查是否最后阶段,若是同时更新项目 + isLastStage := checkLastStageInDefinition(...) + if isLastStage { + // 同事务操作 + stmtProject := "UPDATE projects SET lifecycle_status = ?, current_workflow_instance_id = NULL WHERE id = ?" + // 写审计 + auditStmt := "INSERT INTO workflow_events ..." + } +} +``` + +**测试用例**: +```go +// 新增测试 +func TestAdvanceStage_CompletesProjectAfterLastStage(t *testing.T) { + // 1. 创建并激活工作流 + // 2. 推进到最后一个启用阶段 + // 3. 所有文档批准后确认推进 + // 4. 验证: + // - workflow_instance.status = "completed" + // - projects.lifecycle_status = "planning" + // - projects.current_workflow_instance_id = NULL + // - 审计事件已记录 +} + +func TestAdvanceStage_DoesNotChangeProjectOnNonLastStage(t *testing.T) { + // 验证:非最后阶段的推进不改变项目状态 +} +``` + +**验收标准**: +- [ ] 单元测试 PASS +- [ ] 完成最后阶段后项目 `lifecycle_status` 变为 `planning` +- [ ] 前端 /projects/{id}/settings 立即显示"配置下一工作流"按钮 +- [ ] 审计日志记录 `ProjectWorkflowCompleted` 事件 + +**预计工作量**: 2h + +--- + +### Task A2: 推进事务不强制更新文档审批状态 + +**目标**: 验证并删除推进时强制批准文档的逻辑 + +**步骤**: +1. 搜索 `AdvanceStage` 中是否存在 UPDATE 文档状态的 SQL +2. 若存在,删除该逻辑 +3. 补充负例测试 + +**搜索命令**: +```bash +grep -n "UPDATE.*doc.*approved\|UPDATE.*approval.*approved" \ + internal/projectinstance/stage_store.go +``` + +**如果存在修改**: +```go +// 🔴 删除以下逻辑(如果存在) +stmt := `UPDATE project_doc_task_instances + SET status = 'approved' + WHERE instance_id = ? AND enabled = true` + +// ✅ 替换为:什么都不改,只改阶段状态 +stmt := `UPDATE project_workflow_instances + SET active_stage_key = ? WHERE id = ?` +``` + +**测试**: +```go +func TestAdvanceStage_DoesNotModifyDocumentApprovalStatus(t *testing.T) { + // 1. 创建、激活、推进到阶段就绪 + // 2. 记录所有文档的当前 approval_status + // 3. 执行 AdvanceStage + // 4. 验证:所有文档 approval_status 未变 +} +``` + +**验收标准**: +- [ ] 单元测试 PASS +- [ ] 推进前后文档状态一致 +- [ ] 阶段状态改变,文档状态不改变 + +**预计工作量**: 1h + +--- + +### Task A3: 禁用文档状态标记为 skipped_disabled + +**目标**: 禁用文档在数据库中有明确的 `skipped_disabled` 状态记录 + +**数据库迁移** (`00025_2026-09-12-disabled-doc-status.sql`): +```sql +-- 新增迁移 +ALTER TABLE project_doc_task_instances +ADD COLUMN status_reason VARCHAR(50) DEFAULT NULL; + +-- 补充约束:disabled 时 duties 必须为空 +ALTER TABLE project_doc_task_instances +ADD CONSTRAINT chk_disabled_no_duties +CHECK (status != 'skipped_disabled' OR + (author_user_id IS NULL AND + reviewer_user_id IS NULL AND + approver_user_id IS NULL)); +``` + +**代码修改** (`internal/projectinstance/store.go`): +```go +func (s *pgStore) SaveWorkflowPlan(...) error { + // 激活时为所有文档创建记录 + for _, stage := range plan.Stages { + for _, doc := range stage.DocTasks { + if doc.Enabled { + // 写启用文档 + stmt := `INSERT INTO project_doc_task_instances + (instance_id, task_key, status, author_user_id, ...) + VALUES (?, ?, 'awaiting_edit', ?, ...)` + } else { + // ✅ 新增:写禁用文档 + stmt := `INSERT INTO project_doc_task_instances + (instance_id, task_key, status, status_reason) + VALUES (?, ?, 'skipped_disabled', 'creator_disabled')` + } + } + } +} +``` + +**测试**: +```go +func TestDisabledDocumentPersistence(t *testing.T) { + // 1. 创建计划,禁用某文档 + // 2. 激活 + // 3. 验证: + // - 禁用文档在 project_doc_task_instances 中存在 + // - status = 'skipped_disabled' + // - 无职责行 +} + +func TestDisabledDocumentRejectionAllEntries(t *testing.T) { + // 针对不同入口测试禁用文档拒绝 + // - API 启动:409 DOC_TASK_DISABLED + // - 提交/审核/批准:403 DOC_DUTY_FORBIDDEN +} +``` + +**验收标准**: +- [ ] 数据库迁移成功应用 +- [ ] 禁用文档在 project_doc_task_instances 中有记录 +- [ ] 禁用文档的所有操作返回 409 或 403 +- [ ] 审计查询显示哪些文档被禁用 + +**预计工作量**: 3h + +--- + +### Task A4: 对话推进切换到新实例服务 + +**目标**: 对话中的"推进阶段"调用新的实例服务,而非旧的商务执行器 + +**修改文件**: `internal/coordinator/engine.go:672-811` + +**原代码**: +```go +func (e *CoordinatorEngine) handleAdvanceStage(...) { + // ...识别成功后 + + // 🔴 旧逻辑:调用商务服务 + result, err := e.bizdevSvc.ExecuteStage(ctx, projectID, ...) +} +``` + +**新代码**: +```go +func (e *CoordinatorEngine) handleAdvanceStage(...) { + // ...识别成功后 + + // ✅ 新逻辑:调用实例服务 + + // 1. 从项目或上文获取 workflow_instance_id + instanceID, err := extractWorkflowInstanceID(ctx, projectID, ...) + if err != nil { + return rejectResponse("Cannot determine active workflow instance") + } + + // 2. 调用新服务 + result, err := e.instSvc.AdvanceStage( + ctx, instanceID, userID, isCreator, + projectinstance.AdvanceStageInput{ + StageKey: stageKey, + ExpectedConfigRevision: configRev, + }, + ) + if err != nil { + return rejectResponse(err.Error()) + } + + // 3. 返回成功响应 + return confirmResponse("Stage advanced successfully", result) +} +``` + +**辅助函数**: +```go +func (e *CoordinatorEngine) extractWorkflowInstanceID( + ctx context.Context, + projectID int64, + dialogueContext map[string]any, +) (int64, error) { + // 优先级: + // 1. 上下文中显式提供 + if id, ok := dialogueContext["workflow_instance_id"].(int64); ok && id > 0 { + return id, nil + } + + // 2. 从项目设置查询 live instance + settings, err := e.instSvc.GetSettings(ctx, projectID, userID, true) + if err != nil || settings.LiveInstance == nil { + return 0, fmt.Errorf("no active workflow instance") + } + return settings.LiveInstance.ID, nil +} +``` + +**测试**: +```go +func TestDialogueAdvanceStage_UsesNewService(t *testing.T) { + // 1. 激活工作流实例 + // 2. 对话说"推进到下一阶段" + // 3. 验证: + // - 调用 instSvc.AdvanceStage 而非 bizdevSvc.ExecuteStage + // - 返回新格式响应 + // - 实例状态正确更新 +} +``` + +**验收标准**: +- [ ] 对话推进测试 PASS +- [ ] 新实例服务被调用(可通过 mock 验证) +- [ ] 权限检查一致(仅创建者可推进) +- [ ] 旧商务服务逻辑不再触发 + +**预计工作量**: 2h + +--- + +### Task A5: 禁用文档拒绝逻辑一致性(DocumentAccessDecider) + +**目标**: 统一所有入口对禁用文档的拒绝决策 + +**新增文件**: `internal/projectinstance/document_access.go` + +```go +package projectinstance + +import "context" + +type DocumentAccessDecider interface { + // CanAccess 判断用户是否可访问指定文档任务 + // action: "edit", "submit", "review", "approve", "view" + CanAccess( + ctx context.Context, + docTaskInstID int64, + action string, + userID int64, + ) (bool, error) + + // GetAccessDenialReason 返回具体的拒绝原因 + GetAccessDenialReason( + ctx context.Context, + docTaskInstID int64, + userID int64, + ) (code string, message string) +} + +type defaultAccessDecider struct { + store Store + docs DocumentReadinessChecker +} + +func (d *defaultAccessDecider) CanAccess( + ctx context.Context, + docTaskInstID int64, + action string, + userID int64, +) (bool, error) { + // 1. 查询 doc_task_instance + docTask, err := d.store.GetDocTaskInstance(ctx, docTaskInstID) + if err != nil { + return false, err + } + + // 2. 检查状态 + if docTask.Status == "skipped_disabled" { + return false, nil // 禁用文档,拒绝所有操作 + } + + // 3. 获取实例详情 + instance, err := d.store.GetInstanceByID(ctx, docTask.InstanceID) + if err != nil { + return false, err + } + + // 4. 检查实例状态 + if instance.Instance.Status != InstanceActive && + instance.Instance.Status != InstanceAwaiting { + return false, nil // 实例非运行状态 + } + + // 5. 检查当前阶段 + if !stageContainsTask(instance, docTask.StageKey, docTaskInstID) { + return false, nil // 不在当前阶段 + } + + // 6. 检查职责匹配 + if !userHasRole(docTask, userID, action) { + return false, nil // 用户非对应职责人 + } + + return true, nil +} +``` + +**集成点**: +- API 路由: `POST /api/v1/doc-task-instances/{id}/runs` +- SSE 推送: 过滤禁用/非当前阶段的文档更新 +- 异步任务: 启动前校验 +- Agent tool: 文档访问 wrapper + +**测试**: +```go +func TestDocumentAccessDecider_DisabledDocumentRejected(t *testing.T) { + // 禁用文档的所有操作被拒绝 +} + +func TestDocumentAccessDecider_NonCurrentStageRejected(t *testing.T) { + // 不在当前阶段的文档被拒绝 +} + +func TestDocumentAccessDecider_NonResponsibleUserRejected(t *testing.T) { + // 非职责人的操作被拒绝 +} + +func TestDocumentAccessDecider_ValidAccessAllowed(t *testing.T) { + // 满足所有条件时允许 +} +``` + +**验收标准**: +- [ ] 所有入口(API、SSE、异步任务、Agent tool)使用同一决策器 +- [ ] 禁用文档全拒绝 +- [ ] 非职责人拒绝 +- [ ] 非当前阶段拒绝 + +**预计工作量**: 4h + +--- + +### Task A6: 激活 Token 安全处理 + +**目标**: Token 改为 one-time、hash 存储、原子消费 + +**修改流程**: + +**步骤 1**: 修改激活预览 API +```go +// 旧:返回 activation_token +GET /api/v1/projects/{id}/activation-preview +→ { token: "xyz123", ... } + +// ✅ 新:返回 challenge 和 nonce +GET /api/v1/projects/{id}/activation-preview +→ { challenge_id: "ch_abc", nonce: "n123", ... } +``` + +**步骤 2**: 修改激活 API +```go +// 旧:明文 token +POST /api/v1/projects/{id}/workflow-instances/activate +{ token: "xyz123" } + +// ✅ 新:挑战响应 +POST /api/v1/projects/{id}/workflow-instances/activate +{ + challenge_id: "ch_abc", + token: "at_oneTimeXyz123" // 由服务端生成 +} +``` + +**步骤 3**: 数据库变更 +```sql +-- 激活挑战表 +CREATE TABLE activation_challenges ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL, + created_by BIGINT NOT NULL, + challenge_id VARCHAR(32) UNIQUE, + nonce VARCHAR(32), + config_hash VARCHAR(64), -- 冻结内容的 hash + created_at TIMESTAMP DEFAULT NOW(), + expires_at TIMESTAMP, + consumed_at TIMESTAMP, + FOREIGN KEY (project_id) REFERENCES projects(id) +); + +-- 一次性 token 表 +CREATE TABLE one_time_tokens ( + id BIGSERIAL PRIMARY KEY, + challenge_id VARCHAR(32), + token_hash VARCHAR(255), -- bcrypt hash + created_at TIMESTAMP, + expires_at TIMESTAMP, + consumed_at TIMESTAMP +); +``` + +**激活逻辑**: +```go +func (s *Service) ActivateWorkflow( + ctx context.Context, + projectID int64, + userID int64, + challengeID string, + token string, +) (*ActivateResult, error) { + return s.store.ActivateInTx(ctx, func(tx Tx) error { + // 1. 查询 challenge(5 分钟未过期) + challenge, err := tx.GetChallenge(ctx, challengeID) + if err != nil || challenge.ExpiresAt < now { + return ErrChallengeExpired + } + + // 2. 验证 token hash + if err := bcrypt.CompareHashAndPassword(challenge.TokenHash, token); err != nil { + return ErrInvalidToken + } + + // 3. 检查 token 未消费 + if challenge.ConsumedAt != nil { + return ErrTokenAlreadyConsumed + } + + // 4. 在同一事务内激活实例 + instance, err := tx.ActivateInstance(ctx, projectID, ...) + if err != nil { + return err + } + + // 5. 标记 challenge 和 token 已消费 + tx.MarkChallengeConsumed(ctx, challengeID) + + return nil + }) +} +``` + +**测试**: +```go +func TestActivationTokenSecurity_OneTimeUse(t *testing.T) { + // 同一 token 不能消费两次 +} + +func TestActivationTokenSecurity_Expiration(t *testing.T) { + // 5 分钟后 token 过期 +} + +func TestActivationTokenSecurity_InvalidTokenRejected(t *testing.T) { + // 错误 token 被拒绝 +} + +func TestActivationTokenSecurity_AtomicConsumption(t *testing.T) { + // token 消费与实例激活在同一事务内 +} +``` + +**验收标准**: +- [ ] Token 为 one-time,消费后不可重用 +- [ ] Token 5 分钟过期 +- [ ] Token hash 存储,明文不保存 +- [ ] 激活原子性(token 消费与实例激活同事务) +- [ ] 中间故障时 token 仍有效(可重试) + +**预计工作量**: 3h + +--- + +## 第二部分:高优先级新功能(Phase B) + +### Task B1: 受控终止工作流 API + +**目标**: 工作流实例可被创建者受控终止,写入原因并审计 + +**新增文件**: `internal/projectinstance/terminate.go` + +```go +package projectinstance + +import "context" + +type TerminationPreviewInput struct { + InstanceID int64 `json:"instance_id"` +} + +type TerminationPreviewResult struct { + InstanceID int64 `json:"instance_id"` + PendingDocuments []DocTaskSummary `json:"pending_documents"` + PendingNotifications []string `json:"pending_notifications"` + InProgressRunTasks []string `json:"in_progress_run_tasks"` + CanTerminate bool `json:"can_terminate"` + Impact string `json:"impact,omitempty"` +} + +type TerminateInput struct { + InstanceID int64 `json:"instance_id"` + Reason string `json:"reason"` // 必填,最多 500 字 + Confirm bool `json:"confirm"` // 二次确认 +} + +type TerminateResult struct { + InstanceID int64 `json:"instance_id"` + Status string `json:"status"` // "terminated" + TerminatedAt time.Time `json:"terminated_at"` + TerminatedBy int64 `json:"terminated_by"` + Reason string `json:"reason"` +} + +func (s *Service) GetTerminationPreview( + ctx context.Context, + instanceID int64, +) (*TerminationPreviewResult, error) { + instance, err := s.store.GetInstanceByID(ctx, instanceID) + if err != nil { + return nil, err + } + + // 收集影响项 + pending := []DocTaskSummary{} + notifs := []string{} + tasks := []string{} + + // ...查询待审批文档、待发送通知、在途任务... + + return &TerminationPreviewResult{ + InstanceID: instanceID, + PendingDocuments: pending, + PendingNotifications: notifs, + InProgressRunTasks: tasks, + CanTerminate: true, + Impact: fmt.Sprintf( + "%d pending docs, %d notifications, %d task runs will be cancelled", + len(pending), len(notifs), len(tasks), + ), + }, nil +} + +func (s *Service) TerminateWorkflow( + ctx context.Context, + instanceID int64, + userID int64, + isCreator bool, + input TerminateInput, +) (*TerminateResult, error) { + if !isCreator { + return nil, ErrForbiddenNotCreator + } + if !input.Confirm { + return nil, ErrTerminationNotConfirmed + } + if len(strings.TrimSpace(input.Reason)) == 0 { + return nil, ErrTerminationReasonRequired + } + + result, err := s.store.TerminateInTx(ctx, func(tx Tx) error { + // 1. 获取实例 + instance, err := tx.GetInstanceByID(ctx, instanceID) + if err != nil { + return err + } + + // 2. 检查状态可终止 + if instance.Instance.Status != InstanceActive && + instance.Instance.Status != InstanceAwaiting { + return ErrCannotTerminateInstance + } + + // 3. 标记实例为 terminated + if err := tx.UpdateInstance(ctx, instanceID, map[string]any{ + "status": "terminated", + "terminated_at": now, + "terminated_by": userID, + "termination_reason": input.Reason, + }); err != nil { + return err + } + + // 4. 取消在途任务 + if err := tx.CancelPendingTaskRuns(ctx, instanceID); err != nil { + return err + } + + // 5. 项目改为 planning + if err := tx.UpdateProject(ctx, instance.Instance.ProjectID, map[string]any{ + "lifecycle_status": "planning", + "current_workflow_instance_id": nil, + }); err != nil { + return err + } + + // 6. 写审计 + if err := tx.LogEvent(ctx, "WorkflowTerminated", map[string]any{ + "instance_id": instanceID, + "reason": input.Reason, + "by": userID, + }); err != nil { + return err + } + + return nil + }) + + return &TerminateResult{ + InstanceID: instanceID, + Status: "terminated", + TerminatedAt: now, + TerminatedBy: userID, + Reason: input.Reason, + }, err +} +``` + +**API 路由**: +```go +// server.go +mux.HandleFunc("GET /api/v1/workflow-instances/{id}/termination-preview", + s.handleTerminationPreview) +mux.HandleFunc("POST /api/v1/workflow-instances/{id}/terminate", + s.handleTerminate) +``` + +**测试**: +```go +func TestTerminationPreview_ShowsImpact(t *testing.T) { + // 显示待审批文档、通知、任务清单 +} + +func TestTerminate_RequiresCreator(t *testing.T) { + // 非创建者被拒绝 +} + +func TestTerminate_RequiresConfirmation(t *testing.T) { + // confirm = false 时被拒绝 +} + +func TestTerminate_UpdatesProjectLifecycle(t *testing.T) { + // 终止后项目可配置下一工作流 +} + +func TestTerminate_CancelsPendingTasks(t *testing.T) { + // 在途任务被取消 +} +``` + +**验收标准**: +- [ ] 预览 API 显示完整影响 +- [ ] 仅创建者可终止 +- [ ] 需二次确认和原因 +- [ ] 终止后项目立即可选择下一流程 +- [ ] 审计记录终止原因 + +**预计工作量**: 6h + +--- + +### Task B2: 真实 E2E 测试(PostgreSQL + PI Agent) + +**目标**: 使用真实数据库和 PI Agent 验证三个工作流完整链路 + +**新增文件**: `internal/server/e2e_workflow_integration_test.go` + +**框架**: +```go +package server + +import ( + "context" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func setupE2ETestDB(t *testing.T) *pgxpool.Pool { + dbURL := os.Getenv("TEST_DATABASE_URL") + if dbURL == "" { + t.Skip("TEST_DATABASE_URL not set, skipping E2E tests") + } + + pool, err := pgxpool.New(context.Background(), dbURL) + if err != nil { + t.Fatalf("Failed to connect to E2E test DB: %v", err) + } + + // 创建隔离 schema + schema := "e2e_test_" + randomID() + _, err = pool.Exec(context.Background(), "CREATE SCHEMA "+schema) + if err != nil { + t.Fatalf("Failed to create schema: %v", err) + } + + // 运行所有迁移 + if err := runMigrations(t, pool, schema); err != nil { + t.Fatalf("Failed to run migrations: %v", err) + } + + t.Cleanup(func() { + pool.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE") + pool.Close() + }) + + return pool +} + +func TestE2E_BizdevWorkflow_Complete(t *testing.T) { + pool := setupE2ETestDB(t) + ctx := context.Background() + + // 1. 创建项目 + projectID := createProject(t, ctx, pool, "Project-Bizdev", "weichen") + + // 2. 获取三工作流目录 + workflows := listWorkflows(t, ctx, pool) + if len(workflows) != 3 { + t.Fatalf("Expected 3 workflows, got %d", len(workflows)) + } + + // 3. 选择商务对接流程 + bizdevWF := findWorkflow(workflows, "main") + plan := createPlan(t, ctx, pool, projectID, bizdevWF.Key) + + // 4. 激活 + instance := activateWorkflow(t, ctx, pool, projectID, plan) + if instance.Status != "active" { + t.Fatalf("Expected instance.status=active, got %s", instance.Status) + } + + // 5. 逐个批准 7 个文档 + docKeys := []string{ + "intake", "research", "feasibility_dev", "feasibility_reg", + "user_requirements", "prelim_design", "cdo_quote", + } + for i, docKey := range docKeys { + approveDocument(t, ctx, pool, instance.ID, docKey) + + // 检查门禁 + readiness := getStageReadiness(t, ctx, pool, instance.ID, "stage_v01") + if i < len(docKeys)-1 { + if readiness.Ready { + t.Fatalf("Stage should not be ready with %d/%d docs approved", + i+1, len(docKeys)) + } + } else { + if !readiness.Ready { + t.Fatalf("Stage should be ready after all docs approved") + } + } + } + + // 6. 推进各阶段 + advanceStage(t, ctx, pool, instance.ID, "stage_v01", 0) + advanceStage(t, ctx, pool, instance.ID, "stage_v05", 0) + + // 7. 完成最后阶段 + result := advanceStage(t, ctx, pool, instance.ID, "stage_v10", 0) + if result.CompletedAt == nil { + t.Fatal("Expected instance.completed_at to be set") + } + + // 8. 验证项目已返回 planning + settings := getProjectSettings(t, ctx, pool, projectID) + if settings.LifecycleStatus != "planning" { + t.Fatalf("Expected project lifecycle=planning, got %s", + settings.LifecycleStatus) + } + + // 9. 可配置下一个工作流 + workflows2 := listWorkflows(t, ctx, pool) + if len(workflows2) != 3 { + t.Fatalf("Cannot select next workflow") + } + + t.Logf("✅ Bizdev workflow complete chain PASSED") +} + +func TestE2E_ProductManagerWorkflow_Standalone(t *testing.T) { + pool := setupE2ETestDB(t) + ctx := context.Background() + + projectID := createProject(t, ctx, pool, "Project-PM", "weichen") + + // 选择产品经理流程(未运行商务流程) + pmWF := findWorkflow(listWorkflows(t, ctx, pool), "product_manager") + plan := createPlan(t, ctx, pool, projectID, pmWF.Key) + instance := activateWorkflow(t, ctx, pool, projectID, plan) + + // 批准 SRS 文档 + approveDocument(t, ctx, pool, instance.ID, "system_reqs") + + // 推进 + advanceStage(t, ctx, pool, instance.ID, "stage_pm", 0) + + // 完成 + result := advanceStage(t, ctx, pool, instance.ID, "stage_pm", 0) + if result.CompletedAt == nil { + t.Fatal("PM workflow should complete") + } + + t.Logf("✅ ProductManager workflow standalone PASSED") +} + +func TestE2E_TestEngineerWorkflow_WithoutSRS(t *testing.T) { + pool := setupE2ETestDB(t) + ctx := context.Background() + + projectID := createProject(t, ctx, pool, "Project-TE", "weichen") + + // 直接选择测试工程师流程,不需要先运行产品经理 + teWF := findWorkflow(listWorkflows(t, ctx, pool), "test_manager") + plan := createPlan(t, ctx, pool, projectID, teWF.Key) + instance := activateWorkflow(t, ctx, pool, projectID, plan) + + // PI Agent 生成测试方案(集成或 mock) + // ... + + // 批准测试方案 + approveDocument(t, ctx, pool, instance.ID, "test_manager") + + // 推进完成 + result := advanceStage(t, ctx, pool, instance.ID, "stage_te", 0) + if result.CompletedAt == nil { + t.Fatal("TE workflow should complete") + } + + t.Logf("✅ TestEngineer workflow without SRS PASSED") +} +``` + +**验收标准**: +- [ ] 商务流程 7 文档全流程完成 +- [ ] 产品流程独立运行无前置依赖 +- [ ] 测试流程无 SRS 也能运行 +- [ ] 每个完成流程后项目可配置下一个 +- [ ] 所有 E2E 测试 PASS +- [ ] 真实 PG + PI Agent 完整链路验证 + +**预计工作量**: 8h + +--- + +### Task B3: 项目上下文快照服务(ProjectContextSnapshot) + +**目标**: 为产品/测试流程提供统一、可复现的项目上下文 + +**新增文件**: `internal/projectcontext/service.go` + +**核心结构**: +```go +package projectcontext + +import "context" + +type ContextSnapshot struct { + ID int64 `json:"id"` + ProjectID int64 `json:"project_id"` + TaskID int64 `json:"task_id"` // 引用的任务(可选) + SnapshotHash string `json:"snapshot_hash"` + CreatedAt time.Time `json:"created_at"` + CreatedBy int64 `json:"created_by"` + + // 五层内容 + ProjectFacts ProjectFacts `json:"project_facts"` + ProjectDecisions ProjectDecisions `json:"project_decisions"` + ProjectDocuments ProjectDocuments `json:"project_documents"` + ProjectDialogue []DialogueEntry `json:"project_dialogue"` + HistoryOutputs []HistoryOutput `json:"history_outputs"` + KnowledgeHits []KnowledgeRef `json:"knowledge_hits"` + + // 血缘 + Sources []SourceManifest `json:"sources"` + ExcludedPrivate int `json:"excluded_private_count"` + ExcludedNoAccess int `json:"excluded_no_access_count"` +} + +type SourceManifest struct { + SourceType string // "project_fact", "document", "dialogue", etc. + SourceID int64 + SourceVersion int + SourceHash string + AuthorityLevel string // "confirmed", "approved", "reference", "evidence" + Permission string // "all_members", "shared_to_project", "private" + WasReferenced bool // 是否实际被输出引用 +} + +// Service 构建和查询项目上下文快照 +type Service struct { + store Store + projectStore projectworkflow.Store + docStore DocumentStore + dialogueStore DialogueStore +} + +func (s *Service) BuildContextSnapshot( + ctx context.Context, + projectID int64, + taskID int64, // 可选,用于任务的输入 +) (*ContextSnapshot, error) { + // 1. 收集五层源 + facts, err := s.gatherProjectFacts(ctx, projectID) + if err != nil { + return nil, err + } + + decisions, err := s.gatherProjectDecisions(ctx, projectID) + if err != nil { + return nil, err + } + + docs, sources, err := s.gatherProjectDocuments(ctx, projectID) + if err != nil { + return nil, err + } + + dialogue, excluded, err := s.gatherProjectDialogue(ctx, projectID) + if err != nil { + return nil, err + } + + history, err := s.gatherHistoryOutputs(ctx, projectID) + if err != nil { + return nil, err + } + + knowledge, err := s.gatherKnowledgeHits(ctx, projectID) + if err != nil { + return nil, err + } + + // 2. 冲突合并 + merged := s.mergeConflicts(facts, decisions, docs, dialogue, history) + + // 3. 计算 hash + hash := computeContentHash(merged) + + // 4. 保存快照 + snapshot := &ContextSnapshot{ + ProjectID: projectID, + TaskID: taskID, + SnapshotHash: hash, + CreatedAt: now, + CreatedBy: userID, + ProjectFacts: facts, + ProjectDecisions: decisions, + ProjectDocuments: docs, + ProjectDialogue: dialogue, + HistoryOutputs: history, + KnowledgeHits: knowledge, + Sources: sources, + ExcludedPrivate: excluded.PrivateCount, + ExcludedNoAccess: excluded.NoAccessCount, + } + + if err := s.store.SaveSnapshot(ctx, snapshot); err != nil { + return nil, err + } + + return snapshot, nil +} + +func (s *Service) gatherProjectFacts( + ctx context.Context, + projectID int64, +) (ProjectFacts, error) { + // 查询 projects 表和结构化事实黑板 + project, err := s.projectStore.GetProject(ctx, projectID) + if err != nil { + return ProjectFacts{}, err + } + + return ProjectFacts{ + ProjectName: project.Name, + CustomerName: project.Customer, + ProductType: project.ProductType, + IntendedUse: project.IntendedUse, + TargetMarket: project.TargetMarket, + StructuredFacts: project.StructuredFacts, + AuthorityLevel: "confirmed", // 项目元数据最权威 + }, nil +} + +func (s *Service) gatherProjectDocuments( + ctx context.Context, + projectID int64, +) (ProjectDocuments, []SourceManifest, error) { + // 查询当前有效版本的受控文档、共享文档、上传材料 + docs, err := s.docStore.ListProjectDocuments(ctx, projectID) + if err != nil { + return ProjectDocuments{}, nil, err + } + + sources := make([]SourceManifest, 0) + for _, doc := range docs { + sources = append(sources, SourceManifest{ + SourceType: "document", + SourceID: doc.ID, + SourceVersion: doc.LatestApprovedVersion, + SourceHash: doc.ContentHash, + AuthorityLevel: "approved", // 已批准文档 + Permission: "all_members", + WasReferenced: false, // 待填充 + }) + } + + return ProjectDocuments{ + ControlledDocs: docs, + AuthorityLevel: "approved", + }, sources, nil +} + +func (s *Service) gatherProjectDialogue( + ctx context.Context, + projectID int64, +) ([]DialogueEntry, ExcludedCounts, error) { + // 查询所有进入项目知识边界的对话 + // 过滤:私人草稿、个人私聊(未共享)不纳入 + + dialogue, excluded, err := s.dialogueStore.ListProjectDialogue( + ctx, projectID, true, // 仅已共享 + ) + + return dialogue, excluded, err +} + +// 冲突优先级: +// 确认事实/决策 > 已批准文档 > 上传材料 > 里程碑 > 普通对话 > 总结 +func (s *Service) mergeConflicts( + facts ProjectFacts, + decisions ProjectDecisions, + docs ProjectDocuments, + dialogue []DialogueEntry, + history []HistoryOutput, +) MergedContext { + // 若多个来源对同一概念有不同看法,按优先级择一 + // 例:需求既在 SRS 中又在对话中,选 SRS + + return MergedContext{ + // ... + } +} +``` + +**与现有系统集成**: +- 产品经理:调用 `ctxSvc.BuildContextSnapshot` 取数代替现有部分逻辑 +- 测试工程师:同样使用新快照,而非硬查 SRS +- 血缘追溯:输出文档内包含 source manifest 和追溯表 + +**测试**: +```go +func TestProjectContextSnapshot_ConsistentForSameInput(t *testing.T) { + // 同一项目在同一时刻的快照 hash 相同 +} + +func TestProjectContextSnapshot_ChangeWhenSourceUpdated(t *testing.T) { + // 新批准文档后快照 hash 改变 +} + +func TestProjectContextSnapshot_ExcludesPrivateContent(t *testing.T) { + // 私人草稿和未共享对话不纳入 +} + +func TestProjectContextSnapshot_LineageComplete(t *testing.T) { + // 输出包含所有被引用来源的 manifest +} +``` + +**验收标准**: +- [ ] 快照服务实现完整 +- [ ] 产品/测试工程师都使用新服务 +- [ ] 快照可复现(同输入 → 同快照) +- [ ] 血缘追溯表完整 + +**预计工作量**: 12h + +--- + +## 第三部分:验收与发布(Phase C) + +### Task C1-C7: 见前面 Phase C 表 + +**总体验收检查表**: + +``` +□ 单元测试覆盖 + □ 所有 bug fix 有负例测试 + □ 新功能覆盖率 > 50% + □ 关键路径覆盖率 = 100% + +□ 集成测试 + □ PG 集成测试全 PASS + □ 并发测试 PASS + □ 数据库迁移向前向后兼容 + +□ 真实 E2E(PostgreSQL + PI Agent) + □ 商务流程 PASS + □ 产品流程 PASS + □ 测试流程 PASS + □ 项目完成后切换 PASS + □ 受控终止 PASS + +□ 验收标准(见 7.1-7.5) + □ 目录仅三项 + □ 禁用文档全拒绝 + □ 推进不改文档状态 + □ 完成后可配置下一流程 + □ 安全与审计完整 + +□ 文档与发布 + □ API 文档更新 + □ 发布说明完成 + □ 迁移脚本与回滚 + □ 用户指南 + +□ 生产就绪检查 + □ 性能基准测试 + □ 压力测试 + □ 故障场景演练 + □ 审计日志覆盖 +``` + +--- + +**完整拆分总结**: +- **Phase A** (Day 1): 15 小时 - 关键缺陷修复 +- **Phase B** (Day 2-3): 26 小时 - 新功能开发 +- **Phase C** (Day 4-5): 20 小时 - 完善与发布 + +**总计**: ~61 小时 + +--- + +**下一步**: 选择一项任务开始实施,或全队并行进行。 diff --git a/.doc/2026-09-12-three-workflows-strict-governance-remediation.md b/.doc/2026-09-12-three-workflows-strict-governance-remediation.md new file mode 100644 index 00000000..8018467f --- /dev/null +++ b/.doc/2026-09-12-three-workflows-strict-governance-remediation.md @@ -0,0 +1,525 @@ +# 三工作流严格治理与现有系统融合整改方案 + +- 日期:2026-09-12 +- 状态:核心业务决策已确认 / 可进入实施拆分 +- 范围:工作流定义、项目实例、阶段门禁、文档三权、通知与对话推进、运行时授权、数据迁移和验收 +- 本文性质:当前代码与开发数据库的只读核验结论及整改设计;本轮未修改业务代码 + +## 1. 结论先行 + +系统面向项目创建者和项目成员时,只应存在以下 **3 个可选择工作流**: + +1. **商务对接流程**:7 个文档,3 个阶段; +2. **智能产品经理流程**:1 个输出文档,1 个阶段; +3. **智能测试工程师流程**:1 个输出文档,1 个阶段。 + +商务对接流程的文档数量可以定案为 **7 个,不是 6 个**。前端模板工厂和测试固定断言了 7 个任务键,后端标准任务目录也包含同一组 7 个商务文档,历史专项测试报告同样把它们定义为标准全生命周期交付物。[^biz-seven-test][^biz-seven-backend][^biz-seven-report] + +当前系统尚未满足这一目标,且不是简单改几个显示名称即可解决: + +- 启动代码实际发布 5 个默认工作流,除目标三项外还包含“医疗器械研发”和“非医疗纯软件”;[^bootstrap-five] +- 当前开发库已有 92 个工作流定义,其中大量为测试定义;三个目标工作流仍都是单阶段的 schema v1; +- 仓库虽然有三个 schema v2 清单,但商务清单只有 5 个文档,漏了 `feasibility_dev` 和 `user_requirements`;[^main-v2-missing] +- 三个 v2 清单仅被嵌入和登记,没有进入启动发布链;已有发布版又会让 seed 逻辑直接退出,因此现有库不会自动升级;[^manifest-loader][^seed-no-upgrade] +- 旧运行入口会把多阶段版本压成“第一个阶段的第一个任务”,与项目实例运行时并存,形成双轨权威;[^catalog-flatten] +- 配置页面允许勾选关闭文档,但后端校验禁止关闭所有 `required/blocking` 文档;当前清单又把所有文档都标成这两类,因此界面能力实际上不可用;[^plan-disable-conflict] +- 智能测试工程师当前被 manifest、执行服务和前端提示硬绑定到产品经理/SRS,与已确认的两个智能工作流独立运行原则冲突;[^test-upstream][^test-hard-dependency] +- 阶段推进虽然先检查批准状态,但事务内会把所有启用任务强制写成 `approved`,并且最后阶段完成后没有把项目恢复到可选择下一工作流的状态;[^advance-forces-approved][^project-lifecycle-gap] +- 对话中的“推进阶段”仍调用旧商务成熟度执行器,并没有调用项目工作流实例的阶段推进服务;[^dialogue-old-advance] + +因此本次整改的核心是:**以已激活项目工作流实例的冻结快照作为唯一运行时权威**。工作流目录、项目配置、文档三权、任务启动、审批、阶段门禁、通知和对话都必须围绕同一个实例标识运行,旧的直接切换和扁平执行入口退出生产路径。 + +## 2. 已确认的业务基线 + +### 2.1 唯一允许对项目展示和选择的工作流 + +| 稳定内部 key | 对外名称 | 阶段数 | 输出文档数 | 说明 | +|---|---|---:|---:|---| +| `main` | 商务对接流程 | 3 | 7 | 保留现有 key 以避免破坏历史数据,只改正式名称 | +| `product_manager` | 智能产品经理流程 | 1 | 1 | 输出《系统需求规格书》 | +| `test_manager` | 智能测试工程师流程 | 1 | 1 | 保留现有 key,只把“测试经理”统一更名为“测试工程师” | + +“系统只需要三个流程”的含义应同时落实到三个层面: + +- 项目设置 API 只返回这 3 个可选项; +- 前端任何入口只展示这 3 个可选项; +- 服务端只允许为这 3 个正式、已发布版本创建项目工作流实例。 + +后台可以保留历史定义和草稿以满足审计,但必须标记为 `retired` 或 `project_selectable=false`,不能继续出现在项目目录中。不能仅靠前端白名单隐藏,因为当前设置服务会枚举数据库里全部有发布版本的定义。[^settings-all-definitions] + +### 2.2 商务对接流程:7 个文档,3 个阶段 + +沿用系统已经形成的 V0.1 / V0.5 / V1.0 业务阶段和模板归属,目标定义如下。当前模板工厂已按 `1 + 5 + 1` 归档这 7 个文档。[^template-stage-map] + +| 阶段 | 顺序 | task key | 正式文档名 | 默认状态 | 阶段门禁 | +|---|---:|---|---|---|---| +| V0.1 需求摸底 | 10 | `intake` | 用户初始需求收集表 | 启用 | 启用时必须批准 | +| V0.5 方案与可行性 | 20 | `research` | 调研报告 | 启用 | 启用时必须批准 | +| V0.5 方案与可行性 | 21 | `feasibility_dev` | 设计开发可行性评估报告 | 启用 | 启用时必须批准 | +| V0.5 方案与可行性 | 22 | `feasibility_reg` | 注册路径可行性评估报告 | 启用 | 启用时必须批准 | +| V0.5 方案与可行性 | 23 | `user_requirements` | 用户需求表 | 启用 | 启用时必须批准 | +| V0.5 方案与可行性 | 24 | `prelim_design` | 系统初步设计方案 | 启用 | 启用时必须批准 | +| V1.0 商务报价 | 30 | `cdo_quote` | CDO 报价单 | 启用 | 启用时必须批准 | + +当前后端商务阶段只执行 `intake`、`research`、`feasibility_reg`、`prelim_design`、`cdo_quote` 五项,正好缺少另外两项,必须同步补齐阶段执行映射和 executor 注册。[^biz-stage-five][^executor-missing] + +### 2.3 智能产品经理流程 + +| 阶段 | task key | 唯一输出文档 | 默认状态 | 门禁 | +|---|---|---|---|---| +| 系统需求基线 | `system_reqs` | 系统需求规格书 | 启用 | 文档批准后,创建者确认完成工作流 | + +### 2.4 智能测试工程师流程 + +| 阶段 | task key | 唯一输出文档 | 默认状态 | 门禁 | +|---|---|---|---|---| +| 系统验证策划 | `test_manager` | 系统验证方案及计划 | 启用 | 文档批准后,创建者确认完成工作流 | + +**智能产品经理流程和智能测试工程师流程相互解耦。** 智能测试工程师流程不再要求项目曾运行智能产品经理流程,也不再要求必须存在该流程输出的《系统需求规格书》。它直接从当前项目可用于工作流的对话、文档、上传材料、项目事实和决策记录中提取需求及验证依据,独立生成《系统验证方案及计划》。 + +如果项目中恰好存在已批准的《系统需求规格书》,它只是项目输入集合中的一项高权威来源,不是启动前置条件,也不产生流程级耦合。当前 manifest、执行服务、血缘逻辑和前端提示仍硬编码了这一依赖,整改时必须一起删除。[^test-upstream][^test-hard-dependency] + +### 2.5 两个智能工作流的统一项目上下文输入 + +智能产品经理和智能测试工程师使用同一个“项目上下文快照服务”,但采用不同的分析目标和输出模板: + +| 输入类别 | 纳入内容 | 建议权威等级 | +|---|---|---| +| 项目事实 | 项目名称、客户、产品类型、预期用途、目标市场、结构化事实黑板 | 已确认事实 | +| 项目决策 | 创建者确认事项、里程碑决策、已关闭的待确认项 | 已确认决策 | +| 项目文档 | 当前有效版本的受控文档、项目共享文档、上传材料 | 按批准状态和来源分级 | +| 项目对话 | 所有已进入项目共享知识边界的成员对话及其附件 | 原始沟通证据 | +| 历史输出 | 商务、产品、测试或自由任务形成的项目交付物 | 按状态和版本分级 | +| 知识依据 | 系统检索到的法规、标准和内部知识库命中 | 外部参考依据 | + +这里的“所有”是指 **当前项目知识边界内全部有资格被工作流使用的内容**,不是把数据库中所有原文无差别拼接到一次模型调用中。建议遵循以下规则: + +1. 只纳入当前项目,绝不跨项目召回; +2. 私人草稿、个人私聊只有在明确“共享到项目”后才进入项目级工作流输入,避免流程执行扩大原有可见范围; +3. 任务启动时生成不可变 `project_context_snapshot`,记录截止时间、来源 ID、版本、内容 hash、权限判定和引用位置; +4. 全量来源先建立清单,再按任务目标检索相关片段;已确认事实和已批准文档必须进入固定核心上下文,普通对话按相关性分批读取; +5. 冲突时不静默覆盖,优先级建议为:创建者确认事实/决策 > 当前已批准文档 > 用户上传原始材料 > 里程碑记录 > 普通项目对话 > 智能体历史总结; +6. 输出必须带 source manifest。智能测试工程师需在唯一输出文档内部生成“需求/风险—测试项—来源”追溯表,不额外创建第二个受控输出文档; +7. 任务运行后项目来源发生变化时,依据被引用来源的 hash 判断 `input_changed/stale`,而不是只监听《系统需求规格书》。未被本次输出引用的无关对话变化不应把文档误标为 stale。 + +现有 Squad 工作区已经支持 project context、materials、prior docs、knowledge hits 等输入槽位,也会建立带权威等级的 context 索引;产品经理还已有从项目事实和里程碑生成输入材料的局部实现。因此应把这些能力上移为通用项目上下文快照服务,而不是为测试流程继续维护单一 SRS 特例。[^context-input-foundation] + +## 3. 不可违反的业务规则 + +### 3.1 选择、启动和切换 + +1. 只有项目创建者可以选择、配置、激活、推进或受控终止项目工作流。 +2. 一个项目同一时刻最多只能有一个 live 实例。 +3. 在 `active`、`awaiting_stage_confirmation` 或 `termination_requested` 状态下,不允许选择或切换工作流。 +4. “切换工作流”不是修改当前实例的 `workflow_key`,而是当前实例 `completed` 或 `terminated` 后创建一个新的 planning 实例。 +5. 激活时冻结工作流版本、阶段、文档开关、模板版本、执行器、质量门禁和三权责任人。运行中不可静默修改。 +6. 管理员不因系统身份自动替代项目创建者执行推进;如以后引入 break-glass,必须单独设计理由、再认证和审计,且不能代替文档签署人。 + +目标状态机: + +```mermaid +stateDiagram-v2 + [*] --> planning: 创建者选择并配置 + planning --> active: 预览后确认激活 + active --> awaiting_stage_confirmation: 当前阶段所有启用文档已批准 + awaiting_stage_confirmation --> active: 创建者确认进入下一阶段 + awaiting_stage_confirmation --> completed: 创建者确认完成最后阶段 + active --> termination_requested: 创建者申请终止 + termination_requested --> terminated: 二次确认并记录原因 + completed --> planning: 创建下一工作流实例 + terminated --> planning: 创建下一工作流实例 +``` + +数据库已经为一个 planning 实例和一个 live 实例设置了部分唯一索引,也预留了 `awaiting_stage_confirmation`、`terminated` 等状态,适合沿此模型完善,不必另造一套表。[^instance-schema] + +### 3.2 文档启用与禁用 + +项目创建者可以在 planning 状态逐文档启用或禁用。严格语义如下: + +| 文档状态 | 三权配置 | 可创建/启动编制 | 可提交审核 | 可审核 | 可批准 | 是否计入阶段门禁 | +|---|---|---:|---:|---:|---:|---:| +| enabled | 作者、审核人、批准人均必填且互斥 | 是,仅作者或明确执行人 | 是,仅作者 | 是,仅审核人 | 是,仅批准人 | 是 | +| disabled | 必须为空 | 否 | 否 | 否 | 否 | 否 | + +关键约束: + +- `default_enabled` 只表示默认值,不表示不可关闭; +- `blocking_when_enabled=true` 表示文档一旦启用,未批准就阻断阶段; +- 新增明确字段 `creator_configurable=true/false`,不要继续混用现有 `required`、`blocking`、`default_enabled` 三个布尔值表达四种含义; +- 禁用后实例状态写为 `skipped_disabled`,不创建 DocGov 文档、不生成三权行,也不允许通过 task key、对话、SSE、Agent tool 或旧 API 旁路启动; +- 对禁用文档发起动作时统一返回 `409 DOC_TASK_DISABLED`;对启用文档但责任人不匹配返回 `403 DOC_DUTY_FORBIDDEN`; +- 激活计划必须至少包含一个启用文档。这样一文档流程仍可在配置时关闭唯一文档,但关闭后不能激活一个没有产出的空工作流。 + +现有数据结构已经能表达 `enabled` 和三权,且持久化时会跳过禁用文档的职责写入;可复用这部分。问题在于定义校验把 required/blocking 文档一律视为不可关闭,需要先修正语义。[^plan-structure][^duty-write-enabled-only] + +### 3.3 阶段完成和推进 + +阶段就绪的唯一业务判定应为: + +```text +stage_ready = 当前阶段处于 active + AND 当前阶段至少有一个 enabled 文档 + AND 每个 enabled 文档的当前受控版本都为 approved + AND 没有正在执行、待审核、待批准或 stale 的同任务实例 +``` + +推进动作必须满足: + +- 服务端在同一数据库事务中锁定 workflow instance 和 stage instance; +- 重新计算门禁,不能只信几秒前的 readiness 响应; +- 校验操作者仍是项目创建者; +- 校验 `expected_config_revision` 和幂等键; +- 只改变阶段和实例状态,绝不把文档“补写”为 approved; +- 进入下一启用阶段;若没有下一阶段,则把实例标为 completed,并把项目切回“可配置下一个工作流”的状态; +- 同事务关闭对应 `stage_advance_request`,写审计和 outbox 事件。 + +当前 readiness 已经忽略禁用任务、检查启用必需文档是否批准,并限制创建者推进,这一骨架可以保留;但它按 `project_id + task_key` 汇总文档状态,仍需升级到 `doc_task_instance_id + document_version_id`,避免跨阶段或重复实例串线。[^readiness-current][^approval-key-too-wide] + +### 3.4 主动提醒和对话推进 + +系统在 readiness 从 false 变为 true 时生成一次 `stage.ready_to_advance` 事件,并只通知项目创建者。已有 outbox、站内通知、去重键以及 24 小时再次提醒机制可以复用。[^notify-ready][^notify-reminder] + +建议闭环: + +1. 最后一份启用文档批准; +2. 事务后事件处理器重新计算 readiness; +3. 实例进入 `awaiting_stage_confirmation`; +4. 创建者收到站内消息和可操作确认卡:阶段名、启用/已批准文档清单、下一阶段或“完成工作流”; +5. 创建者点击按钮,或在项目对话中说“推进到下一阶段 / 确认推进”; +6. 两种入口都调用同一个 `AdvanceStage` 应用服务; +7. 成功后通知状态改为 acted,推进请求改为 confirmed;若条件在确认前失效,则返回最新阻断项并把旧请求标为 superseded。 + +当前前端已经每 15 秒拉取通知并显示“确认推进”按钮,但对话入口仍走旧的 `bizdev.ExecuteStage`,所以需要把自然语言意图解析与执行彻底分开:识别器只产生命令,实例服务才拥有推进权。[^stage-banner][^dialogue-old-advance] + +## 4. 当前系统与目标的差距 + +### 4.1 定义和目录 + +| 编号 | 当前事实 | 风险 | 整改 | +|---|---|---|---| +| WF-CAT-01 | Bootstrap 发布 5 个工作流 | 项目会看到目标外流程 | 只发布三项;其余退役且不可选 | +| WF-CAT-02 | 设置服务返回所有有发布版的定义 | 测试/历史流程进入项目 | 增加 `project_selectable`,查询端白名单 + 状态双重校验 | +| WF-CAT-03 | 开发库有 92 个定义,88 个 key 为 `pg_*` 或 `test_*` | 测试污染业务目录 | 测试强制独立数据库/schema,fixture 必须清理 | +| WF-DEF-01 | 三个正式 key 当前均为 schema v1 单阶段 | 实际运行不是目标结构 | 以新版本发布 schema v2/v3,禁止覆盖旧版本 | +| WF-DEF-02 | `main.v2.json` 只有 5 个文档 | 商务流程缺 2 个输出 | 补齐 7 个并增加 executor、模板和质量门禁校验 | +| WF-DEF-03 | v2 manifest 未进入 bootstrap | 仓库文件与运行数据库不一致 | 建立 manifest 迁移器,按 hash 创建并发布新版本 | +| WF-DEF-04 | seed 发现任何 published 就跳过 | 永远停留旧版本 | 按内容 hash 和目标版本升级,写版本迁移审计 | +| WF-DEF-05 | DBCatalog 压扁完整定义 | 多阶段配置不能驱动运行 | 删除生产 flatten 路径,运行只读取冻结实例 | +| WF-DEF-06 | 测试流程声明 product_manager 上游,执行服务又硬查 approved SRS | 两个智能工作流不能独立运行 | 删除流程级依赖;改用统一项目上下文快照和多来源血缘 | + +### 4.2 项目实例和生命周期 + +| 编号 | 当前事实 | 风险 | 整改 | +|---|---|---|---| +| WF-INS-01 | planning / active 和唯一 live 骨架已存在 | 可复用但未闭环 | 扩展 awaiting/completed/terminated 完整转换 | +| WF-INS-02 | 完成最后阶段只更新实例 | 项目仍为 active,无法配置下一流程 | 同事务更新项目可选择状态并清空 current live 指针 | +| WF-INS-03 | 无受控终止 API | 异常流程永久锁死或只能改库 | 增加申请、影响预览、创建者二次确认和终止原因 | +| WF-INS-04 | 激活 token 明文保存,消费与激活分事务 | 失败后 token 已失效或可泄露 | 只存 hash,并在激活事务内锁定、消费 | +| WF-INS-05 | 冻结子任务只写 key/enabled/status | executor、模板、质量门禁仍依赖可变定义 | 激活时把完整运行元数据写入实例表并以实例表为权威 | + +数据库已添加 executor/template/quality gate 列,但当前实例写入 SQL 仍只插入 task key、enabled 和 status,证明冻结快照尚未真正成为执行权威。[^instance-runtime-metadata] + +### 4.3 文档、三权和运行时授权 + +| 编号 | 当前事实 | 风险 | 整改 | +|---|---|---|---| +| WF-DOC-01 | 启用文档要求三权且互斥 | 基础规则正确 | 再校验三人均为 active 项目成员且账户有效 | +| WF-DOC-02 | 禁用文档不会写三权 | 基础持久化正确 | 增加 `skipped_disabled` 和全入口拒绝 | +| WF-DOC-03 | 访问过滤按 task key 判断 | 同 key 重复实例可能串权 | 所有动作使用 `doc_task_instance_id` | +| WF-DOC-04 | 无 live 实例时访问过滤默认不限制 | 完成后旧任务入口可能恢复可用 | 正式项目按治理模式 fail closed;legacy 仅限显式迁移标记 | +| WF-DOC-05 | 运行入口仍接受 task key 并查扁平目录 | 可能绕过阶段和冻结绑定 | 请求只传 doc task instance id,服务端解析 executor/template/duty | +| WF-DOC-06 | DocGov 状态机可验证具体审核/批准人 | 可复用 | 文档必须外键绑定 doc task instance 和版本 | + +现有阶段访问代码确实会从 live 快照中排除禁用任务,这是可保留的基础;但当没有 live 实例或实例 completed 时会返回 unrestricted,因此不能把它视为禁用文档全生命周期的完整保障。[^stage-access] + +### 4.4 前端、通知和对话 + +| 编号 | 当前事实 | 风险 | 整改 | +|---|---|---|---| +| WF-UI-01 | 旧切换器硬编码三项,但名称不一致 | 用户认知混乱 | 统一为三个正式名称,数据来自服务端目录 | +| WF-UI-02 | 新治理 API 失败时回退旧直接切换 UI | 可重新打开不合规路径 | 删除回退;治理不可用时只读并明确报错 | +| WF-UI-03 | 旧切换在版本冲突后自动重试 | 可能覆盖用户最新选择 | 展示冲突并要求重新确认,不自动提交高风险操作 | +| WF-UI-04 | 项目设置只显示 stage/task key | 不利于业务确认 | 显示正式阶段名、文档名、启用状态、三权和影响摘要 | +| WF-UI-05 | live 配置缺少完整只读审计视图 | 创建者不清楚冻结内容 | 展示版本、hash、激活人/时间、所有 enabled/disabled 和职责 | +| WF-NOTIFY-01 | readiness 通知和 24h 提醒已存在 | 基础可复用 | 增加状态转换、已读/已处理、失效和重算 | +| WF-DLG-01 | 对话推进旧商务阶段 | 可越过新实例门禁 | 对话调用同一实例 AdvanceStage command | +| WF-CTX-01 | 产品经理仅在无材料时拼装部分项目事实;测试工程师只注入 SRS | “全部项目内容”没有统一、可复现的输入语义 | 建立项目上下文快照、来源清单、权限过滤、冲突优先级和 hash 血缘 | + +旧前端切换器在治理不可用时仍调用 `/active-workflow`,并在冲突后自动取新版本再次切换;这两个行为都应删除。[^legacy-switch-ui] 旧服务端路由在迁移完成后应返回 `410 WORKFLOW_SWITCH_API_RETIRED`,而不是继续作为备用入口。[^legacy-route] + +### 4.5 开发数据库现场快照 + +2026-09-12 对当前 `shuimu-context-pg-dev / agent_system_dev` 做只读查询得到: + +```text +workflow_definitions = 92 +published workflow versions = 92 +main = version 1, schema 1, 1 stage +product_manager = version 1, schema 1, 1 stage +test_manager = version 1, schema 1, 1 stage +project_workflow_instances: active 32, completed 47 +projects.lifecycle_status: active 97, planning 51 +completed 实例已存在、无 live 实例但项目仍为 active:47 个 +测试命名定义(pg_* / test_*):88 个 +``` + +这些数字主要反映当前开发环境和集成测试污染,不能直接当成生产数据;但它们证明目录查询没有隔离正式定义与测试定义,也直接复现了“实例已完成而项目仍 active”的生命周期断点。PG 测试在未设置 `TEST_DATABASE_URL` 时默认连接开发库,部分定义发布测试又没有清理工作流定义,因此必须纳入整改。[^test-db-default][^test-definition-no-cleanup] + +## 5. 目标技术模型 + +### 5.1 单一运行权威 + +```text +Published Workflow Version + │ 创建 planning plan + ▼ +Project Workflow Instance + immutable config snapshot + │ 激活时物化 + ├── Stage Instances + │ └── Doc Task Instances + │ ├── frozen executor/template/quality gate + │ ├── duty assignments + │ ├── controlled document versions + │ └── task runs + ├── Stage Advance Requests / Notifications + └── Audit / Outbox Events +``` + +激活后,任何运行请求不得再从“最新发布定义”、前端 task key 或 legacy catalog 推导行为。最新定义只用于创建未来实例;已运行实例永远引用自己的冻结数据。 + +### 5.2 定义 schema 建议 + +文档任务应至少明确以下字段: + +```json +{ + "key": "research", + "title": "调研报告", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "bizdev.research.v1", + "quality_gate_key": "quality.generic.v1", + "template": {"asset_key": "research", "version": 1}, + "output_doc_type": "research", + "output_doc_title": "调研报告" +} +``` + +发布校验必须增加: + +- workflow key 属于三个正式 key 之一,且对外名称准确; +- 阶段 key、position、任务 key 均唯一且非空; +- 每个阶段至少一个文档任务; +- executor、quality gate 和 ready 模板版本全部存在; +- 三个正式流程精确满足 7 / 1 / 1 文档清单; +- 三个正式工作流均不声明流程级 `required_upstream_workflow`; +- 项目上下文输入策略、来源权限和冲突优先级完整; +- 禁用策略、完成策略和参与策略完整; +- 发布后版本不可原地修改。 + +当前 v2 校验只检查 schema 版本、阶段位置、executor/quality gate 注册和输出类型,尚不足以守住上述业务契约。[^validator-v2] + +### 5.3 数据结构增量 + +优先扩展现有表,不重复建模: + +- `workflow_definitions`:增加 `project_selectable`、`catalog_order`、`official_code`; +- `workflow_versions.definition_json`:采用新禁用语义并保留版本 hash; +- `project_workflow_instances`:增加 `completed_at/by`、`terminated_at/by/reason`、`previous_instance_id`; +- `project_stage_instances`:增加 `ready_at`、`completed_at/by`; +- `project_doc_task_instances`:持久化 `executor_key`、`quality_gate_key`、模板 asset/version、output type/title;禁用任务状态为 `skipped_disabled`; +- `documents`:增加 `doc_task_instance_id`,受控文档版本唯一绑定; +- `project_context_snapshots`:记录项目、任务、截止时间、汇总 hash、创建人和冻结状态; +- `project_context_snapshot_sources`:逐项记录 source type/id/version/hash、权限范围、权威等级、是否实际引用; +- `task_runs`:增加 `workflow_instance_id`、`stage_instance_id`、`doc_task_instance_id`、`context_snapshot_id`; +- `document_lineages`:从单一“上游 SRS”扩展为多来源 lineage,允许对话、材料、事实、决策和文档版本作为来源; +- `stage_advance_requests`:保留 readiness revision,补 `idempotency_key`、`superseded_at`; +- 审计事件:至少记录 plan saved、activated、document disabled/enabled、duty assigned、stage ready、stage advanced、workflow completed/terminated。 + +### 5.4 API 收敛 + +建议正式接口: + +| 方法与路径 | 用途 | 关键约束 | +|---|---|---| +| `GET /api/v1/workflows/catalog` | 返回严格三项正式目录 | 只含 project_selectable + published | +| `GET /api/v1/projects/{id}/settings` | 规划或冻结配置视图 | 项目访问控制 | +| `PUT /api/v1/projects/{id}/workflow-plan` | 选择流程、开关文档、设置三权 | 仅创建者、仅 planning、乐观锁 | +| `POST /api/v1/projects/{id}/activation-preview` | 完整影响与校验 | 无空流程、三权和上下文策略通过 | +| `POST /api/v1/projects/{id}/workflow-instances/activate` | 冻结并激活 | 创建者二次确认、原子 token | +| `GET /api/v1/workflow-instances/{id}` | 运行和历史详情 | 项目 + 阶段可见性 | +| `GET /api/v1/workflow-instances/{id}/stages/{key}/readiness` | 门禁详情 | 返回所有启用文档状态 | +| `POST /api/v1/workflow-instances/{id}/stages/{key}/advance` | 创建者推进或完成 | 事务内重算、幂等 | +| `POST /api/v1/workflow-instances/{id}/termination-preview` | 终止影响 | 未决任务、文档和通知清单 | +| `POST /api/v1/workflow-instances/{id}/terminate` | 受控终止 | 创建者、原因、二次确认 | +| `POST /api/v1/doc-task-instances/{id}/runs` | 启动编制 | enabled + active stage + duty | + +旧 `/projects/{id}/active-workflow` 和依赖扁平 workflow key 的 run 接口先记录迁移告警,完成客户端切换后返回 410。 + +## 6. 实施顺序 + +### Phase 0:冻结错误扩散和数据盘点 + +1. 给项目目录增加服务端正式白名单,立即只暴露三个目标 key。 +2. 禁止测试默认连接开发库;CI 必须显式提供隔离的 `TEST_DATABASE_URL`。 +3. 备份并分类 92 个定义及其引用:正式、历史、测试、被实例引用。 +4. 未被任何实例引用的测试定义在开发环境清理;被引用定义只退役,不物理删除。 +5. 修复开发环境中 completed instance 与 project lifecycle 不一致的数据,但先输出 dry-run 清单。 + +### Phase 1:发布三个唯一正式定义 + +1. 修订三个 manifest 的正式名称与 schema;商务流程补齐 7 个文档。 +2. 为 `feasibility_dev` 和 `user_requirements` 注册专用 executor,或明确映射到通用任务 executor;禁止使用含混的法规 executor 代替。 +3. 从 `test_manager` manifest、默认目录和校验模型中删除 `required_upstream_workflow=product_manager`,使三个流程在流程级完全独立。 +4. 增加严格发布校验和 7 / 1 / 1 契约测试。 +5. 实现 hash 驱动的版本升级:已有 v1 时创建 v2/v3,而不是退出或覆盖。 +6. `medical_device_development`、`non_medical_software` 及其他定义全部 `project_selectable=false` 并退役。 + +### Phase 2:补齐实例状态机和禁用语义 + +1. 重构 plan validator:creator-configurable 文档可关闭;启用文档三权必填;空工作流不可激活。 +2. 校验三权人员为有效项目成员、账户有效且三权互斥。 +3. 激活时完整物化运行元数据,以实例为唯一权威。 +4. 阶段推进事务内重算门禁,删除“推进时强制批准文档”的 SQL。 +5. 完成最后阶段时同步更新项目状态,使其可创建下一 planning 实例。 +6. 实现受控终止,保证 live 实例不会永久锁住项目。 + +### Phase 3:运行、文档治理和权限合流 + +1. 建立通用项目上下文快照服务,覆盖项目事实、项目级对话、文档、材料、决策、历史输出和知识命中。 +2. 产品经理和测试工程师分别从同一快照服务取数;删除测试任务中“查找已批准 system_reqs,否则拒绝”的特例。 +3. 所有任务启动改用 `doc_task_instance_id`;服务端解析 task/executor/template/stage/duty/context snapshot。 +4. 文档创建、查看、编辑、提交、审核、批准均校验同一 doc task instance。 +5. disabled、非当前阶段、非责任人、实例非 live 均 fail closed。 +6. SSE、异步 task run、恢复/取消、RAG 检索、Agent tools 使用同一策略决策。 +7. 审批回调按具体文档版本更新具体 doc task instance,不再按项目 + task key 批量更新。 +8. 把 SRS 单来源 lineage 改成 source manifest 多来源血缘;只在实际引用来源变化时标记输出待复核。 + +### Phase 4:通知、对话和前端闭环 + +1. readiness 边沿触发状态和通知,补 read/acted/dismissed API。 +2. 把对话“推进/完成工作流”绑定到项目实例 command;保留明确确认卡。 +3. 项目设置只显示三项,按正式名称显示阶段和文档。 +4. active/awaiting 时显示冻结只读视图,隐藏所有切换动作。 +5. completed/terminated 后显示“配置下一个工作流”,而不是修改旧实例。 +6. 删除旧切换回退、冲突自动重试和 `WorkflowPrerequisiteBanner` 的产品经理前置提示。 +7. 在运行预览中展示项目上下文截止时间、来源数量、被排除的私有/无权内容和冲突项。 + +### Phase 5:迁移和发布 + +1. 新项目只允许使用新的三个正式版本。 +2. 尚未激活的 planning v1 计划自动重建为新版本草稿,并要求创建者重新确认三权。 +3. 已激活 v1 实例不静默改写:真实业务实例由创建者选择继续兼容完成或受控终止后重建;开发/测试垃圾实例按确认后的清理脚本处理。 +4. 先 shadow 计算新旧 readiness 差异,再开启新推进服务。 +5. 通过全套验收后关闭 legacy route 和 flatten catalog。 + +## 7. 验收标准 + +### 7.1 目录和定义 + +- 任意项目、任意普通用户或管理员在项目选择页都只能看到 3 个工作流。 +- 正式名称精确为“商务对接流程 / 智能产品经理流程 / 智能测试工程师流程”。 +- 定义快照精确为 7 / 1 / 1;缺一、多一、重复 key、模板未 ready 或 executor 未注册均无法发布。 +- 三个正式定义均没有流程级上游依赖;产品经理和测试工程师可被分别独立选择和运行。 +- 服务重启、数据库迁移和旧数据存在时,目录结果仍稳定为三项。 + +### 7.2 选择和冻结 + +- 只有项目创建者可保存、激活和推进;普通成员、系统管理员默认均被拒绝。 +- live 实例存在时,UI、API 和对话三个入口均不能切换。 +- 工作流完成或受控终止后,创建者可以配置下一实例;旧实例保持只读可追溯。 +- 两个浏览器同时配置或推进时只有一个成功,另一个收到版本冲突并重新确认。 + +### 7.3 文档开关和三权 + +- 启用文档缺任一职责、职责重复、责任人非 active 项目成员时不能激活。 +- 禁用文档没有职责行、没有受控文档、没有运行按钮,也不能通过手工 API、SSE 或对话启动。 +- 对禁用文档的编制、提交、审核、批准请求全部稳定返回 `DOC_TASK_DISABLED`。 +- 再启用只允许发生在 planning;激活后不能原地改变。 + +### 7.4 阶段门禁和提醒 + +- 当前阶段 N 个启用文档中任一未批准,创建者按钮和对话推进均被阻断并返回清单。 +- 全部批准后只产生一次首提醒;24 小时未处理才产生一次升级提醒。 +- readiness 后文档被驳回或标 stale 时,旧推进请求失效,确认动作不能成功。 +- 推进事务不会修改任何文档审批状态。 +- 最后一阶段确认后实例为 completed,项目立即可配置下一流程。 + +### 7.5 安全、恢复和真实链路 + +- 跨项目、非成员、非职责人、未来阶段成员、已退出成员全部有服务端负例。 +- API、SSE、异步任务、断线恢复、搜索、导出、RAG 和 Agent tool 的判定一致。 +- 服务重启后能恢复 live 实例、当前阶段、禁用状态、职责和 pending 推进请求。 +- 使用真实 PostgreSQL 和真实 PI Agent 分别跑完商务 7 文档、产品 1 文档、测试 1 文档链路。 +- 项目从未运行产品经理流程且不存在 SRS 时,测试工程师仍可依据项目对话、文档和材料成功生成唯一测试方案;输出包含逐项来源追溯和待确认清单。 +- 项目已有 SRS 时,该文档被当作高权威来源纳入,但移除它不会导致工作流定义级依赖错误;是否需要重新生成由本次 source manifest 判断。 +- 项目私聊/个人草稿未共享时不进入上下文;共享后可被新快照发现。跨项目内容在检索和快照层都有负例。 +- 同一组项目输入分别启动产品经理和测试工程师时,两者快照可复现、来源清单一致,输出目标和模板不同。 +- 测试结束后业务数据库中不残留 `pg_*`、`test_*` 定义或测试项目。 + +## 8. 已确认的业务决策及实施解释 + +2026-09-12 已确认以下决策,后续实现和验收以此为准: + +1. **允许关闭单文档流程的唯一文档,但空工作流不能激活。** 配置可以保存,激活预览必须明确阻断并提示重新启用文档或选择其他工作流。 +2. **智能产品经理和智能测试工程师解耦。** 两者都直接使用当前项目知识边界内的对话、文档、材料、事实和决策。任何一方的产出都不是另一方的启动前置;已有 SRS 仅作为高权威输入来源。 +3. **开放项目创建者受控终止工作流。** 必须二次确认、填写原因、处理或取消在途任务并形成审计;completed 和 terminated 都允许创建下一工作流实例,但历史含义必须区分。 + +这些决策合理且彼此一致。第 2 项的实施建议是“流程解耦、数据可组合、来源可追溯”,而不是取消质量控制:测试工程师必须自己从项目上下文抽取需求和风险,生成文档内追溯矩阵,并将缺少判据的内容列为待确认,不能因为没有 SRS 就编造需求。 + +商务流程按 7 个文档落地、系统只暴露三个正式流程、运行中不可切换、阶段推进必须由创建者确认、禁用文档不得参与任何编审批动作,也均为已确认要求。 + +## 9. 建议的实施交付拆分 + +建议按以下 8 个可独立评审的变更集提交,避免一次大改难以回滚: + +1. 正式目录隔离与测试数据库隔离; +2. 三个正式 manifest、严格 validator 和版本升级器; +3. planning 配置、文档开关和成员/三权校验; +4. 激活冻结与完整实例物化; +5. doc task instance 驱动的执行、DocGov 和统一授权; +6. 原子 readiness / advance / complete / terminate 状态机; +7. 通知、对话命令与前端只读/下一流程体验; +8. 数据迁移、兼容退出、真实 PG/PI Agent E2E 和发布门禁。 + +每个变更集都应包含正向测试、越权负例、并发负例和重启恢复验证。只有第 8 项完成后,才应删除旧兼容路径并宣布三工作流治理正式生效。 + +## 10. 来源与证据 + +[^biz-seven-test]: `web/src/engine/templateFactory.test.ts:8-33`,测试精确断言 7 个任务键;读取于 2026-09-12。 +[^biz-seven-backend]: `internal/squad/service.go:128-138`,后端标准任务目录包含 7 个商务任务及产品、测试任务;读取于 2026-09-12。 +[^biz-seven-report]: `.doc/test-reports/2026-09-08-project-init-template-cloning-test-report.md:12-25,51-60`,历史专项测试把商务全生命周期固定为 7 个受控交付物;读取于 2026-09-12。 +[^bootstrap-five]: `internal/workflowdef/bootstrap.go:63-74,78-115`,启动种子包含三个旧流程及两个额外行业流程;读取于 2026-09-12。 +[^main-v2-missing]: `internal/workflowdef/manifests/main.v2.json:1-50`,清单只定义 intake、research、feasibility_reg、prelim_design、cdo_quote;读取于 2026-09-12。 +[^manifest-loader]: `internal/workflowdef/manifest_loader.go:9-32` 仅提供嵌入读取和文件名映射;仓库检索未发现 `V2ManifestFiles` 的运行时调用;读取于 2026-09-12。 +[^seed-no-upgrade]: `internal/workflowdef/store.go:364-372`,已有任意 latest published version 时直接返回;读取于 2026-09-12。 +[^catalog-flatten]: `internal/workflowdef/catalog.go:27-41,50-82`,数据库目录将版本压为首阶段首任务;读取于 2026-09-12。 +[^plan-disable-conflict]: `internal/projectinstance/validator.go:37-90,105-139`,required 或 blocking 任务关闭时校验失败;读取于 2026-09-12。 +[^advance-forces-approved]: `internal/projectinstance/stage_store.go:71-158`,推进事务把启用任务状态强制更新为 approved;读取于 2026-09-12。 +[^project-lifecycle-gap]: `internal/projectinstance/stage_store.go:128-158` 只完成实例,`internal/projectinstance/service.go:119-135` 又只允许 planning 项目保存下一计划;读取于 2026-09-12。 +[^dialogue-old-advance]: `internal/coordinator/engine.go:672-811`,对话推进调用 `bizdevSvc.ExecuteStage` 并直接改变旧成熟度上下文;读取于 2026-09-12。 +[^settings-all-definitions]: `internal/projectinstance/service.go:94-113`,项目设置枚举全部有 latest published version 的定义;读取于 2026-09-12。 +[^template-stage-map]: `web/src/engine/templateFactory.ts:37-39,87-89,158-160,228-230,286-288,331-333,392-394`,7 个商务模板当前归属为 V0.1 一项、V0.5 五项、V1.0 一项;读取于 2026-09-12。 +[^biz-stage-five]: `internal/bizdev/service.go:204-223`,旧商务阶段仅映射 1 + 3 + 1 个任务;读取于 2026-09-12。 +[^executor-missing]: `internal/workflow/registry.go:5-15`,商务 executor 缺少 feasibility_dev 和 user_requirements;读取于 2026-09-12。 +[^test-upstream]: `internal/workflowdef/manifests/test_manager.v2.json:1-23`,测试流程声明 product_manager 前置依赖;读取于 2026-09-12。 +[^test-hard-dependency]: `internal/bizdev/service.go:349-380,673-687`,测试任务硬查同项目 approved SRS、自动注入并记录单一 SRS 血缘;读取于 2026-09-12。 +[^context-input-foundation]: `internal/squad/service.go:35-55`、`internal/squad/workspace.go:262-408` 和 `internal/bizdev/service.go:250-294`,现有运行参数及工作区已支持项目、材料、前序文档、知识和部分项目事实输入;读取于 2026-09-12。 +[^instance-schema]: `deploy/postgres/goose/00017_2026-09-12-project-workflow-instances.sql:13-52`,实例状态和 one planning/one live 唯一索引;读取于 2026-09-12。 +[^plan-structure]: `internal/projectinstance/types.go:21-48`,计划已包含文档 enabled 和三权;读取于 2026-09-12。 +[^duty-write-enabled-only]: `internal/projectinstance/store.go:244-280`,禁用文档不会写职责行;读取于 2026-09-12。 +[^readiness-current]: `internal/projectinstance/gate.go:5-53` 和 `internal/projectinstance/stage_service.go:15-63`,现有门禁忽略禁用任务并限制创建者;读取于 2026-09-12。 +[^approval-key-too-wide]: `internal/projectinstance/stage_store.go:176-191`,批准同步按 project_id + task_key 更新;读取于 2026-09-12。 +[^notify-ready]: `internal/server/workflow_approval_hook.go:17-44` 和 `internal/notification/service.go:38-107`,批准回调、推进请求、outbox 与创建者通知;读取于 2026-09-12。 +[^notify-reminder]: `internal/notification/reminder.go:35-91,94-171`,24 小时提醒和 confirmed 状态;读取于 2026-09-12。 +[^stage-banner]: `web/src/components/workflow/StageAdvanceBanner.tsx:29-95,111-151`,前端轮询 readiness/通知并提供确认按钮;读取于 2026-09-12。 +[^instance-runtime-metadata]: `deploy/postgres/goose/00023_2026-09-12-instance-integrity.sql:8-11` 已添加运行元数据列,而 `internal/projectinstance/store.go:254-259` 插入时未写这些列;读取于 2026-09-12。 +[^stage-access]: `internal/projectinstance/stage_access.go:15-71,90-104`,live 实例中排除禁用任务,但无 live/completed 时不限制;读取于 2026-09-12。 +[^legacy-switch-ui]: `web/src/components/workflow/ProjectWorkflowSwitcher.tsx:16-23,38-76,173-236`,旧名称、旧直切换和冲突自动重试仍存在;读取于 2026-09-12。 +[^legacy-route]: `internal/server/server.go:157-169`,新项目实例 API 与旧 active-workflow/run API 同时暴露;读取于 2026-09-12。 +[^validator-v2]: `internal/workflowdef/validator_v2.go:9-47`,现有 v2 校验范围有限;读取于 2026-09-12。 +[^test-db-default]: `internal/projectinstance/pg_integration_test.go:19-49` 和 `internal/server/workflow_governance_pg_test.go:26-61`,未设置 TEST_DATABASE_URL 时连接本地开发库;读取于 2026-09-12。 +[^test-definition-no-cleanup]: `internal/workflowdef/publish_test.go:37-72`,发布测试创建带时间戳定义但未注册清理;读取于 2026-09-12。 + +开发数据库数字来源:2026-09-12 在本机容器内对 `workflow_definitions`、`workflow_versions`、`project_workflow_instances` 和 `projects` 执行只读聚合查询。该快照仅用于诊断当前开发环境,不代表生产环境。 diff --git a/.doc/adr/2026-09-12-workflow-runtime-authority.md b/.doc/adr/2026-09-12-workflow-runtime-authority.md new file mode 100644 index 00000000..7742f0d0 --- /dev/null +++ b/.doc/adr/2026-09-12-workflow-runtime-authority.md @@ -0,0 +1,21 @@ +# ADR:工作流运行时唯一权威链 + +## 状态 + +已接受(Phase 1 入口封堵;Phase 3 完整编排器) + +## 背景 + +配置(published version + 模板)与运行时(BizDev 硬编码阶段、flatten catalog、客户端 task_key/三权覆盖)存在双权威,导致已发布多阶段定义无法按设计执行。 + +## 决策 + +1. **禁止新增 flatten 旁路**;`DBCatalog.flattenVersion` 仅用于 legacy 列表,不得作为 task run 执行依据。 +2. **Task run 输入**:`doc_task_instance_id` + 业务 payload;拒绝客户端 `task_key`/三权/stage/work_dir 作为授权依据。 +3. **DocGov**:stage 与 author/reviewer/approver 来自冻结实例 duty assignment。 +4. **失败策略**:授权/实例解析错误 fail closed(非 `AllowInMemory` 环境)。 + +## 后果 + +- Phase 3 必须交付 instance orchestrator 并移除 BizDev `SupportedStages` 作为事实源。 +- Phase 4 通过 v2 manifest 发布,不覆盖 v1 published hash。 diff --git a/.doc/authorization-baseline-2026-09-12.md b/.doc/authorization-baseline-2026-09-12.md new file mode 100644 index 00000000..0ed74bb3 --- /dev/null +++ b/.doc/authorization-baseline-2026-09-12.md @@ -0,0 +1,35 @@ +# 授权实施基线快照(Phase 0) + +- **分支**:`dev` +- **Commit SHA**:`bb3f3a1b93eaf4bbd4a7c26a70714a3320d455d3` +- **Goose schema**:`00020`(`00020_2026-09-12-task-run-worker-lease.sql`) +- **规格文档**:`.doc/2026-09-12-permissions-settings-three-workflows-implementation-plan.md` + +## 环境矩阵 + +| 环境 | 分支 | Compose | HTTP | DB | 数据路径 | +|---|---|---|---|---|---| +| 开发 | dev | agent-dev | 8080 | 5433 | data/dev | +| 测试 | test | agent-test | 8081 | 5434 | data/test | +| 生产 | main | agent-main | 8000 | 5435 | data/main | +| 共享 KB | — | agent-infra | — | 5432 | data/shared/kb | + +## 工作流 v1 种子(published) + +| workflow_key | 说明 | 备注 | +|---|---|---| +| `main` | 商务对接 v1 单阶段包装 | 真实阶段仍在 bizdev 硬编码 | +| `product_manager` | 产品经理 v1 单阶段 | 未驱动 squad 五步 | +| `test_manager` | 测试经理 v1 单阶段 | 未驱动结构化 coverage | + +v2 将以新 version 发布,不覆盖 v1 hash。 + +## 验证命令(基线通过) + +```bash +go test -short -timeout 8m \ + ./internal/auth/... ./internal/authz/... ./internal/workflowdef/... \ + ./internal/projectinstance/... ./internal/projectworkflow/... ./internal/server/... + +cd web && npm test && npm run build +``` diff --git a/.doc/authorization-matrix-v2.md b/.doc/authorization-matrix-v2.md new file mode 100644 index 00000000..8c44adc7 --- /dev/null +++ b/.doc/authorization-matrix-v2.md @@ -0,0 +1,23 @@ +# API 授权矩阵 v2(Phase 0 基线) + +> 基线 commit:`bb3f3a1b93eaf4bbd4a7c26a70714a3320d455d3` +> 策略:默认拒绝;未知路由拒绝;跨项目 403/404 统一。 + +| 路由 | Resource | Action | Owner | 负例(未授权) | +|---|---|---|---|---| +| POST /api/v1/auth/login | session | create | auth | 匿名可访问(公开) | +| GET /api/v1/auth/me | user | read_self | auth | 401 无 token | +| GET /api/v1/projects | project | list | projectworkflow | 401 匿名 | +| POST /api/v1/projects | project | create | projectworkflow | 401;403 无 project:create | +| GET /api/v1/projects/{id} | project | read | authz | 403 非成员 | +| GET /api/v1/projects/{id}/lineage | lineage | read | projectworkflow | 403 跨项目 | +| GET /api/v1/projects/{id}/workflows/{key}/runs | workflow_run | list | projectworkflow | 403 跨项目 | +| POST /api/v1/projects/{id}/workflows/{key}/run | task | run | bizdev+stage | 403 错 task/stage | +| GET /api/v1/tasks/runs/{id} | task_run | view | taskrun | 403 非 owner/非 owner 成员 | +| POST /api/v1/tasks/runs/{id}/cancel | task_run | cancel | taskrun | 403 取消他人 | +| POST /api/v1/chat/stream | chat | send | chat+stage | 403 无阶段权限 | +| GET /api/v1/docgov/* | document | * | docgov+duty | 403 非职责人 | +| GET /api/v1/admin/* | admin | * | authz RBAC | 403 非 admin/细粒度 perm | +| GET /health, /api/v1/health | system | health | server | 公开 | + +完整路由枚举见 `internal/server/server.go` Routes();Phase 1 起每个项目子资源必须经 `requireProjectAccess`。 diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md new file mode 100644 index 00000000..05326424 --- /dev/null +++ b/.doc/ops/CURRENT.md @@ -0,0 +1,30 @@ +# 当前焦点 + +**更新**: 2026-09-13 +**production_ready**: no(CI 真实 PI E2E + 前端人工抽检待完成) + +## 焦点 + +1. **已修复**:dev pi-agent provider 误为 `cc-switch-kimi-for-coding` → 已对齐 `.env` 的 `minimax` + `MiniMax-M2.7-highspeed`(见 short-term) +2. Codex 审计 P0 整改 **Wave 0–D 已全部完成**;main 本地冒烟已通过 + +## 剩余(非 P0) + +1. **CI**: `TEST_DATABASE_URL` + 真实 PI E2E 实录 +2. **BACKLOG**: session cookie / bundle 拆分 / 容量治理 + +## 环境快照 + +| 环境 | 端口 | schema | 状态 | +|------|------|--------|------| +| dev | 8080 | 25 | healthy | +| test | 8081 | 25 | healthy | +| main | 8000 | 25 | healthy(本地冒烟) | + +## SSOT + +| 类型 | 路径 | +|------|------| +| 发布缺口 | [gap-matrix](../2026-09-12-remediation-gap-matrix.md) | +| 测试报告 | [test-report](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md) | +| Agent 手册 | [AGENTS.md](../../AGENTS.md) | diff --git a/.doc/ops/README.md b/.doc/ops/README.md new file mode 100644 index 00000000..7ee08f40 --- /dev/null +++ b/.doc/ops/README.md @@ -0,0 +1,24 @@ +# 活协作区(.doc/ops) + +Agent 会话级协作文档,与 Git 跟踪的审计/设计文档分离。 + +## 用法 + +1. 会话开始读 [CURRENT.md](./CURRENT.md) +2. 新任务复制 [templates/](./templates/) 到对应目录 +3. 完成或切换 Agent 时写 [handoffs/](./handoffs/) + +## 状态 + +- **current**:进行中条目 + CURRENT.md +- **historical**:已完成条目保留,标 `done` 或移入归档 + +## 与 SSOT 关系 + +| 类型 | 位置 | +|------|------| +| 协作焦点 | 本目录 CURRENT | +| 发布/审计缺口 | [gap-matrix](../2026-09-12-remediation-gap-matrix.md) | +| 验收报告 | [test-reports](../test-reports/) | + +通用纪律见仓库根 [AGENTS.md](../../AGENTS.md)。 diff --git a/.doc/ops/checklists/2026-09-13-codex-audit-p0-remediation.md b/.doc/ops/checklists/2026-09-13-codex-audit-p0-remediation.md new file mode 100644 index 00000000..2def7995 --- /dev/null +++ b/.doc/ops/checklists/2026-09-13-codex-audit-p0-remediation.md @@ -0,0 +1,47 @@ +--- +title: "Codex 审计 P0 整改 Checklist" +status: done +created: 2026-09-13 +updated: 2026-09-13 +related_task: short-term/2026-09-13-codex-audit-p0-remediation.md +--- + +# Codex 审计 P0 整改 Checklist + +## Wave 0 — Agent 文档 + +- [x] AGENTS.md / PROJECT.md / CLAUDE.md — commit 31fe607 +- [x] .cursor/rules — agent-collaboration + git-secrets +- [x] .doc/ops templates + CURRENT + +## Wave A — P0 + +- [x] AUTH tasks/run + stream — commit d82021e;`task_run_authz_pg_test.go` +- [x] DB 00025 — commit b13ced6;dev/test setval +- [x] DATA lifecycle — dev UPDATE 47 → 0 +- [x] DATA test wf — UPDATE 5 retired + +## Wave B — Go 门禁 + +- [x] go vet ./... PASS — commit 91985a2 +- [x] go test -short ./... PASS +- [x] AdvisorBrief / V0.5 / chat / kb / health / asset + +## Wave C — 环境 + +- [x] test rebuild schema 25 — health 8081 +- [x] dev docker app schema 25 — health 8080 +- [x] main 冒烟 — health/ready 8000 schema 25;restart 恢复 +- [x] HTTP 超时 + env status 只读 — commit 505c419 +- [x] .env.dev/.env.test chmod 600 + +## Wave D — 文档 + +- [x] gap-matrix Codex 项 +- [x] AUDIT-INDEX + superseded 横幅 +- [x] quick-ref 断链修复 +- [x] [test-report](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md) + +## 完成定义 + +- [x] P0/门禁项已闭环(main 除外,已记 OPEN) diff --git a/.doc/ops/research/2026-09-12-codex-reaudit-summary.md b/.doc/ops/research/2026-09-12-codex-reaudit-summary.md new file mode 100644 index 00000000..4dc2dc65 --- /dev/null +++ b/.doc/ops/research/2026-09-12-codex-reaudit-summary.md @@ -0,0 +1,30 @@ +--- +title: "Codex 2026-09-12 再审计摘要" +created: 2026-09-13 +status: done +confidence: high +--- + +# 调研:Codex 2026-09-12 再审计 + +## 结论 + +系统功能已达 RC 级,但**不具备生产发布条件**。主因:授权链缺口、冷启动 DB 缺陷、Go 门禁失败、环境/文档漂移。 + +## 证据 + +| 项 | 说明 | +|----|------| +| P0-1 | sse.go:287 / handlers.go:187 任务入口缺项目成员鉴权 | +| P0-2 | 00011 seed 未 setval users_id_seq → test duplicate key | +| P0-3 | main 未运行;test 旧镜像 stub | +| P0-4 | 47 completed 实例对应 active 项目 | +| 门禁 | go test/vet 失败;BizDev brief、V0.5 3vs5、chat memory、embedding 401 | + +## 开放问题 + +- gap-matrix「核心 PASS」与再审计冲突 → Wave D 对齐 + +## 建议动作 + +按 short-term 任务 Wave A–D 顺序执行。 diff --git a/.doc/ops/short-term/2026-09-13-codex-audit-p0-remediation.md b/.doc/ops/short-term/2026-09-13-codex-audit-p0-remediation.md new file mode 100644 index 00000000..a2bb4d8b --- /dev/null +++ b/.doc/ops/short-term/2026-09-13-codex-audit-p0-remediation.md @@ -0,0 +1,44 @@ +--- +title: "Codex 审计 P0 整改" +status: done +created: 2026-09-13 +updated: 2026-09-13 +owner: agent +--- + +# Codex 审计 P0 整改 + +## 目标 + +闭环 Codex 2026-09-12 再审计 P0/门禁/环境项,并完成文档 SSOT 对齐。 + +## 范围 + +- Wave A: 任务鉴权、00025 序列、lifecycle 脏数据 +- Wave B: Go vet + short 全绿 +- Wave C: test 重建、main 冒烟 +- Wave D: gap-matrix、superseded、test-report + +## 非目标 + +- session cookie 重构 +- bundle 拆分 / 23GB 容量治理 + +## 步骤 + +1. [x] Wave 0: AGENTS.md / PROJECT.md / CLAUDE.md / ops / rules +2. [x] Wave A1: handleRunTask + SSE 鉴权 + 负例测试 +3. [x] Wave A2: goose 00025 +4. [x] Wave A3: lifecycle SQL + test 工作流退休 +5. [x] Wave B: AdvisorBrief / V0.5 / chat memory / kb gate / health +6. [x] Wave C: 环境一致性(main OPEN) +7. [x] Wave D: 文档对齐 + +## 验收标准 + +见 [checklist](../checklists/2026-09-13-codex-audit-p0-remediation.md) + +## 相关链接 + +- 调研: [research/2026-09-12-codex-reaudit-summary.md](../research/2026-09-12-codex-reaudit-summary.md) +- Gap matrix: [../../2026-09-12-remediation-gap-matrix.md](../../2026-09-12-remediation-gap-matrix.md) diff --git a/.doc/ops/short-term/2026-09-13-fix-piagent-provider-minimax.md b/.doc/ops/short-term/2026-09-13-fix-piagent-provider-minimax.md new file mode 100644 index 00000000..fd492f8b --- /dev/null +++ b/.doc/ops/short-term/2026-09-13-fix-piagent-provider-minimax.md @@ -0,0 +1,50 @@ +--- +title: "修复 pi-agent Unknown provider cc-switch-kimi-for-coding" +status: done +created: 2026-09-13 +updated: 2026-09-13 +owner: agent +--- + +# 修复 pi-agent Unknown provider cc-switch-kimi-for-coding + +## 目标 + +任务 `run_1789263149536199173` / workspace `intake_1789263149571817685` 因错误 provider 中断;恢复为与根 `.env` 一致的 `minimax` + `MiniMax-M2.7-highspeed`。 + +## 范围 + +- 修正 `internal/config/config.go` 错误默认值 +- 同步 `configs/.env.dev` 与 compose 注入(不编造新密钥/新 endpoint) +- 对齐根 `.env` 中 `PI_AGENT_MODEL` 与 `MINIMAX_MODEL_ID` +- 重启 / 重建 dev 的 app / pi-agent 使配置生效 + +## 非目标 + +- 不改动 test/main 环境密钥 +- 不擅自更换 API endpoint / API key +- 不在本任务内重跑完整业务长任务(修复后由用户 continue/restart) + +## 步骤 + +1. [x] 根因定位:Go 默认 `cc-switch-kimi-for-coding`;app 容器无 `PI_AGENT_*` 覆盖 +2. [x] 修正代码默认值与 `.env` / `.env.dev` / compose +3. [x] force-recreate + rebuild;health 已显示 `MiniMax-M2.7-highspeed` +4. [x] 提交 `8f3aa0f` + +## 验收标准 + +- [x] `shuimu-pi-agent-dev` health 显示 `MiniMax-M2.7-highspeed` +- [x] app 进程生效 `PI_AGENT_PROVIDER=minimax`、`PI_AGENT_MODEL=MiniMax-M2.7-highspeed` +- [x] 配置链不再默认 `cc-switch-kimi-for-coding`(业务 continue/restart 由用户触发验证) + +## 相关链接 + +- 失败 workspace: `data/dev/workspaces/bizdev/intake_1789263149571817685` +- 根配置: `.env`(`MINIMAX_MODEL_ID=MiniMax-M2.7-highspeed`) + +## 证据 / 备注 + +- upstream: `step step_01_organize failed: Error: Unknown provider "cc-switch-kimi-for-coding"` +- blame: `abd0893e` 将默认从 anthropic 误改为 kimi +- 验证:`curl :18865/health` → `model_id=MiniMax-M2.7-highspeed`;app env 含 `PI_AGENT_PROVIDER=minimax` diff --git a/.doc/ops/templates/checklist.md b/.doc/ops/templates/checklist.md new file mode 100644 index 00000000..f43a8f44 --- /dev/null +++ b/.doc/ops/templates/checklist.md @@ -0,0 +1,20 @@ +--- +title: "" +status: in_progress +created: YYYY-MM-DD +updated: YYYY-MM-DD +related_task: "" +--- + +# + +## Wave / 阶段 + +### 阶段名 + +- [ ] 项 1 — 证据: +- [ ] 项 2 — 证据: + +## 完成定义 + +- [ ] 全部勾选且有证据链接 diff --git a/.doc/ops/templates/handoff.md b/.doc/ops/templates/handoff.md new file mode 100644 index 00000000..ca475420 --- /dev/null +++ b/.doc/ops/templates/handoff.md @@ -0,0 +1,41 @@ +--- +title: "<交接标题>" +created: YYYY-MM-DD +from: agent +to: next-agent +status: open +--- + +# 交接:<标题> + +## 目标(下一位 Agent 应完成什么) + +## 已完成 + +- + +## 未完成 + +- + +## 关键文件 + +| 路径 | 说明 | +|------|------| +| | | + +## 如何验证 + +```bash +# 命令 +``` + +## 风险 / Blocker + +- + +## 相关链接 + +- CURRENT: [CURRENT.md](../CURRENT.md) +- Task: +- Checklist: diff --git a/.doc/ops/templates/long-term-task.md b/.doc/ops/templates/long-term-task.md new file mode 100644 index 00000000..bfc8a2e6 --- /dev/null +++ b/.doc/ops/templates/long-term-task.md @@ -0,0 +1,20 @@ +--- +title: "<长期任务标题>" +status: planned +created: YYYY-MM-DD +updated: YYYY-MM-DD +--- + +# <长期任务标题> + +## 动机 + +## 成功标准 + +## 依赖 + +## 暂不启动原因 + +(若 status 为 planned) + +## 相关链接 diff --git a/.doc/ops/templates/research-note.md b/.doc/ops/templates/research-note.md new file mode 100644 index 00000000..c1290d31 --- /dev/null +++ b/.doc/ops/templates/research-note.md @@ -0,0 +1,26 @@ +--- +title: "<调研标题>" +created: YYYY-MM-DD +status: done +confidence: high +--- + +# 调研:<标题> + +## 结论 + +(1–3 句) + +## 证据 + +| 项 | 路径/命令 | 结果 | +|----|-----------|------| +| | | | + +## 开放问题 + +- + +## 建议动作 + +- diff --git a/.doc/ops/templates/short-term-task.md b/.doc/ops/templates/short-term-task.md new file mode 100644 index 00000000..b24ee85a --- /dev/null +++ b/.doc/ops/templates/short-term-task.md @@ -0,0 +1,40 @@ +--- +title: "<任务标题>" +status: planned +created: YYYY-MM-DD +updated: YYYY-MM-DD +owner: agent +--- + +# <任务标题> + +## 目标 + +(一句话可验证目标) + +## 范围 + +- + +## 非目标 + +- + +## 步骤 + +1. [ ] +2. [ ] + +## 验收标准 + +- [ ] + +## 相关链接 + +- Checklist: +- Handoff: +- Test report: + +## 证据 / 备注 + +(命令输出、commit、文件路径) diff --git a/.doc/test-fixtures/authz-test-topology.md b/.doc/test-fixtures/authz-test-topology.md new file mode 100644 index 00000000..b5021555 --- /dev/null +++ b/.doc/test-fixtures/authz-test-topology.md @@ -0,0 +1,37 @@ +# 授权负例测试数据拓扑(Phase 0) + +## Fixture A:双用户跨项目 + +| 实体 | 值 | +|---|---| +| User A | `zhanggong`(项目 owner) | +| User B | `lisi` 或独立测试用户(非成员) | +| Project A | A 创建,`creator_user_id = A` | +| Project B | B 不可见项目 | + +**负例**:B 调用 `GET /api/v1/projects/{A}/lineage`、`GET .../workflows/main/runs` → 403/404。 + +## Fixture B:同项目阶段职责 + +| 实体 | 值 | +|---|---| +| Live instance | `main` v2(Phase 4 后)或 v1 + duty | +| User BD | 仅 `V0.1_triage/intake` 三权 author | +| User RD | 仅 `prelim_design` 职责 | + +**负例**:BD 请求 `prelim_design` task_key → 403;非职责人 submit/review → 403。 + +## Fixture C:双管理员防锁死 + +| 实体 | 值 | +|---|---| +| Admin 1 | `role-admin` + `perm:manage` | +| Admin 2 | 同等绑定 | + +**演练**:Admin 1 停用 Admin 2 前 Admin 2 仍可操作;至少两名 active admin 存在。 + +## PG 测试入口 + +- `internal/server/project_members_authz_test.go` +- `internal/server/phase5_acceptance_pg_test.go` +- Phase 1 新增:`TestAuth001_CrossProjectLineageAndRunsDenied` diff --git a/.doc/test-reports/2026-09-12-permissions-workflows-six-phase-test-report.md b/.doc/test-reports/2026-09-12-permissions-workflows-six-phase-test-report.md new file mode 100644 index 00000000..f496f03a --- /dev/null +++ b/.doc/test-reports/2026-09-12-permissions-workflows-six-phase-test-report.md @@ -0,0 +1,63 @@ +# 权限、设置与三类工作流六阶段实施测试报告 + +## 一、 测试背景 (Background) + +按六阶段总执行计划完成 Phase 0–6 首版落地:授权止血、IAM/设置 API、Schema v2 骨架、三工作流 manifest、前端 logout/401 闭环。 + +## 二、 测试目标 (Test Objectives) + +- Phase 0 交付物:基线、授权矩阵、preflight SQL +- Phase 1 闸门 A:跨项目 lineage/runs 授权、fail closed、project:create、task run 分权 +- Phase 2–4:迁移 00021–23、设置 API、manifest/validator v2 +- 全量 Go short 测试与前端测试通过 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- **OS**:Linux,Go 1.27.1 +- **基线 commit**:实施分支 dev(实施后) +- **PG**:goose 00021–00023(本地有 PG 时迁移验证) +- **Agent**:stub(单元/集成);PI Agent E2E 留 Phase 6 真实重跑 + +## 四、 预期结果 (Expected Results) + +- `go test -short ./internal/...` 全绿 +- `cd web && npm test && npm run build` 通过 +- 匿名访问 lineage/runs → 401;跨项目 → 403 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### T1:Go 内部包 short 回归 + +- **Input**:`go test -short -count=1 -timeout 8m ./internal/...` +- **Actual Output**:全部 **ok**(含 server、authz、projectinstance、workflowdef) +- **Verdict**:**PASS** + +### T2:闸门 A 负例 + +- **Input**:`TestGateA_LineageAndRunsRequireProjectAccess`、`TestGateA_CreateProjectRequiresPermission` +- **Actual Output**:PASS +- **Verdict**:**PASS** + +### T3:Schema v2 manifest 加载 + +- **Input**:`go test ./internal/workflowdef/...` +- **Actual Output**:ok(embed manifests 编译通过) +- **Verdict**:**PASS** + +### T4:Pi-Agent 三工作流 E2E + +- **Input**:真实 PI Agent 全链路(计划 Phase 6 门禁) +- **Actual Output**:沿用 2026-09-12 报告 FAIL(/v1/run 超时),本轮未重跑 +- **Verdict**:**待 Phase 6 重测** + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +``` +go test -short -count=1 ./internal/... → ALL PASS +``` + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +1. 闸门 A 核心 P0 已关闭并具回归测试;Cookie/CSRF 全量迁移留后续迭代。 +2. Schema v2 manifest 与 orchestrator 骨架就绪;BizDev 硬编码阶段完全移除需在已激活 v2 实例上继续 E2E。 +3. Phase 6 必须:真实 PG 双用户负例、PI Agent 三工作流 E2E、迁移 00021+ 升级演练、灰度 runbook。 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 index cb81522a..b71556b2 100644 --- 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 @@ -10,7 +10,7 @@ QA-001 落地后,`go test -short ./...` 仍存在 PG 集成失败(`zhanggong 2. `go test -short ./...` 全绿(CI 门禁前提) 3. CI 增加 `pg_restore` 全量恢复演练 job 4. readiness soak 短循环 PASS -5. pi-agent 真实全链路 E2E(进行中) +5. pi-agent 真实全链路 E2E(独立重跑,带 commit SHA) ## 三、 测试方案与环境 (Test Plan & Environment) @@ -18,13 +18,14 @@ QA-001 落地后,`go test -short ./...` 仍存在 PG 集成失败(`zhanggong - **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 可用) +- **基线 commit**:`bb3f3a1b93eaf4bbd4a7c26a70714a3320d455d3`(dev) ## 四、 预期结果 (Expected Results) - PG 测试登录 `zhanggong` 返回 200 - 全量 short 测试 0 FAIL - `TestPhase5_SoakReadinessLoop_Short` 8/8 ready OK -- Pi-Agent E2E 全生命周期 PASS +- Pi-Agent E2E 全生命周期 PASS(或明确 FAIL 并记录根因) ## 五、 实际测试结果与判定 (Actual Results & Evaluation) @@ -61,6 +62,7 @@ QA-001 落地后,`go test -short ./...` 仍存在 PG 集成失败(`zhanggong --- FAIL: TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E (1980.22s) ``` - **Verdict**:**FAIL**(工作流阶段 pi-agent `/v1/run` 调用超时,非 stub 对话路径) +- **状态说明**:本轮已结束,**非进行中**。后续 Phase 6 需带 commit SHA 与环境指纹重跑并更新本条目。 ### T7:Test 环境冷启动 + soak @@ -97,6 +99,7 @@ QA-001 落地后,`go test -short ./...` 仍存在 PG 集成失败(`zhanggong ``` go test -short -timeout 15m ./... → ALL PASS go test -run TestPhase5_SoakReadinessLoop_Short ./internal/server/... → PASS +TestPiAgent_FullLifecycle → FAIL (v1/run timeout, 见 T4) ``` ## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) @@ -104,4 +107,4 @@ go test -run TestPhase5_SoakReadinessLoop_Short ./internal/server/... → PASS 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 判定。 +4. **Pi-Agent E2E 已判定 FAIL**(`/v1/run` 超时),非“仍在运行”;Phase 6 门禁前须重跑并归档新报告。 diff --git a/.doc/test-reports/2026-09-12-three-workflows-governance-remediation-test-report.md b/.doc/test-reports/2026-09-12-three-workflows-governance-remediation-test-report.md new file mode 100644 index 00000000..76abc5d0 --- /dev/null +++ b/.doc/test-reports/2026-09-12-three-workflows-governance-remediation-test-report.md @@ -0,0 +1,49 @@ +# 三工作流严格治理整改 测试报告 + +## 一、 测试背景 (Background) + +依据 `.doc/2026-09-12-three-workflows-strict-governance-remediation.md`,系统需收敛为 3 个正式工作流、实例唯一运行权威、PM/TE 解耦及项目上下文快照。 + +## 二、 测试目标 (Test Objectives) + +- 目录 API 仅暴露 3 个正式工作流 +- 单文档流程可保存但不可激活空工作流 +- TE 无 SRS 硬门禁,可选 SRS 作为高权威来源 +- 阶段推进不强制改写文档 approved 状态 +- 集成测试禁止默认连接开发库 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS: Linux +- 单元测试: `go test ./internal/workflowdef/... ./internal/projectinstance/... ./internal/coordinator/...` +- PG 集成: 需 `TEST_DATABASE_URL` 指向隔离库 + +## 四、 预期结果 (Expected Results) + +- `ValidateDefinitionV2` 拒绝流程级 upstream 与错误文档数 +- `validateActivationPlan` 拒绝零启用文档激活 +- `GET /api/v1/workflows/catalog` 返回三项 +- `PUT active-workflow` 在实例治理可用时返回 410 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1: 正式 manifest 契约 + +- **Input**: `go test ./internal/workflowdef -run Official -v` +- **判定**: 见 CI/本地输出 + +### 用例 2: 空工作流激活阻断 + +- **Input**: `go test ./internal/projectinstance -run EmptyWorkflow -v` +- **判定**: PASS(save 允许 / activate 拒绝) + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +```bash +go test ./internal/workflowdef/... ./internal/projectinstance/... ./internal/coordinator/... ./internal/projectcontext/... +``` + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- 代码变更已落地 8 个变更集核心路径 +- 真实 PG + PI Agent 全链路 E2E 需在 `TEST_DATABASE_URL` 与 PI 容器就绪后补跑并更新本报告第五节真实控制台输出 diff --git a/.doc/test-reports/2026-09-12-workflow-governance-remediation-test-report.md b/.doc/test-reports/2026-09-12-workflow-governance-remediation-test-report.md new file mode 100644 index 00000000..29b865be --- /dev/null +++ b/.doc/test-reports/2026-09-12-workflow-governance-remediation-test-report.md @@ -0,0 +1,132 @@ +# 三工作流治理整改 测试报告 + +## 一、 测试背景 (Background) + +2026-09-12 全面审计发现治理链路存在文档拒绝分散、对话双轨推进、终止 preview 缺失等问题。本报告记录 Phase A–C 整改后的单元/集成验证。 + +## 二、 测试目标 (Test Objectives) + +- Phase A:生命周期补测、推进不改文档、禁用文档、对话不切 bizdev、token 一次性、DocumentAccessDecider + HTTP 409 +- Phase B:终止 preview、E2E 完成→planning、上下文快照可复现(bizdev→projectcontext Dialogue 集成) +- Phase C:catalog 驱动名称、版本冲突提示、settings 审计事件 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS: Linux +- Go: 项目默认 toolchain +- 单元测试:内存 Store(`internal/projectinstance`) +- PG 集成:`TEST_DATABASE_URL` 设置时运行 `internal/server/e2e_workflow_governance_test.go` + +## 四、 预期结果 (Expected Results) + +- 末阶段推进后 `lifecycle_status=planning` +- live 实例对话推进零次 `bizdev.ExecuteStage` +- 禁用文档返回 `DOC_TASK_DISABLED`(task run HTTP 409) +- 终止后项目可重新规划 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 单元测试(内存 Store) + +**命令**: `go test ./internal/projectinstance/... -count=1` + +**判定**: PASS(含 `TestAdvanceStage_CompletesProjectAfterLastStage`、`TestDisabledDocumentPersistence`、`TestActivationToken_OneTimeUse`、`TestTerminateInstance_UpdatesProjectLifecycle`、`TestCheckDocTaskAccess_DisabledDocumentRejected`) + +### 5.2 Coordinator 治理测试 + +**命令**: `go test ./internal/coordinator/... -run TestDialogueAdvanceStage_LiveInstanceDoesNotUseBizdev -count=1` + +**判定**: PASS — live 实例存在时 `ExecuteStage` 调用次数为 0 + +### 5.3 上下文快照与 Dialogue 集成 + +**命令**: +```bash +go test ./internal/ctxmgr/... ./internal/bizdev/... ./internal/projectcontext/... \ + -run 'CollectProjectDialogue|CollectSnapshot|BuildSnapshot' -count=1 -v +``` + +**判定**: PASS — bizdev `system_reqs`/`test_manager` 执行路径经 `CollectProjectDialogue` 注入共享对话与里程碑决策;私有/禁用导出对话被排除 + +### 5.4 PG E2E(隔离 test 库) + +**环境**: `TEST_DATABASE_URL=postgres://app:***@127.0.0.1:5434/agent_system_test`(configs/.env.test 对齐) + +**命令**: +```bash +TEST_DATABASE_URL='postgres://app:test_secret_db_pass@127.0.0.1:5434/agent_system_test' \ + go test ./internal/server/... \ + -run 'TestE2E_WorkflowGovernance_CompleteAndReplan|TestWorkflowGovernance_PG_ActivateApproveAdvanceChain' \ + -count=1 -v +``` + +**实际输出**: +``` +--- PASS: TestE2E_WorkflowGovernance_CompleteAndReplan (0.25s) +--- PASS: TestWorkflowGovernance_PG_ActivateApproveAdvanceChain (0.26s) +PASS ok chat-agent-system/internal/server 0.514s +``` + +**评价判定**: PASS — 正式工作流 `test_manager` 末阶段完成→`planning`;正式工作流 `main` 激活/DocGov 批准/阶段推进链通过 + +### 5.5 PI Agent 真实全链路(隔离 test 库 + MiniMax-M3) + +**环境**: +- `TEST_DATABASE_URL` → `agent_system_test@127.0.0.1:5434` +- `RUNNER_AUTH_TOKEN` → dev runner(`18865/health` 200) +- 工作区挂载 → `data/dev/workspaces`(与 pi-agent 容器共享) + +**命令**: +```bash +RUNNER_AUTH_TOKEN='dev_runner_secret_token' \ +TEST_DATABASE_URL='postgres://app:test_secret_db_pass@127.0.0.1:5434/agent_system_test' \ + go test ./internal/server/... -run TestWorkflowGovernance_PiAgent_PG_FullChain -count=1 -timeout 1h -v +``` + +**实际输出**: +``` +✓ [1. 项目创建] id=10 code=pg-piagent-治-dbbbb9f7 +✓ [2. 工作流激活] instance_id=6 +✓ [3. pi-agent intake] doc=doc_proj10_intake title=用户初始需求收集表 elapsed=6m28s +✓ [4. 文档批准] stage ready hook 已触发 +✓ [5. 阶段推进] PG + pi-agent 治理全链路完成 +--- PASS: TestWorkflowGovernance_PiAgent_PG_FullChain (387.93s) +``` + +**评价判定**: PASS — 正式工作流 `main` + 真实 pi-agent intake 三步骤 + DocGov 批准 + 阶段推进 + +### 5.6 PI Agent 产品经理工作流(product_manager + system_reqs) + +**命令**: +```bash +RUNNER_AUTH_TOKEN='dev_runner_secret_token' \ +TEST_DATABASE_URL='postgres://app:test_secret_db_pass@127.0.0.1:5434/agent_system_test' \ + go test ./internal/server/... -run TestWorkflowGovernance_PiAgent_PG_ProductManager_FullChain -count=1 -timeout 1h -v +``` + +**实际输出**: +``` +✓ [1. 项目创建] id=15 code=pg-piagent-产-f61d0a40 wf=product_manager +✓ [2. 工作流激活] instance_id=11 +✓ [3. pi-agent system_reqs] doc=doc_proj15_system_reqs title=系统需求规格书 elapsed=18m54s +✓ [4. 文档批准] stage ready hook 已触发 +✓ [5. 阶段推进] PG + pi-agent 治理全链路完成 wf=product_manager +--- PASS: TestWorkflowGovernance_PiAgent_PG_ProductManager_FullChain (1134.40s) +``` + +**评价判定**: PASS — 单阶段完成后 `lifecycle_status=planning`;上下文快照经 bizdev Dialogue 集成注入 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +``` +go test ./internal/projectinstance/... ./internal/coordinator/... ./internal/projectcontext/... \ + ./internal/ctxmgr/... ./internal/bizdev/... -count=1 +TEST_DATABASE_URL=... RUNNER_AUTH_TOKEN=... go test ./internal/server/... \ + -run 'TestE2E_WorkflowGovernance|TestWorkflowGovernance' -count=1 -timeout 2h +``` + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- Phase A–C 核心路径与三正式工作流(`main`/`product_manager`/`test_manager`)PG+PI 全链路均已 PASS。 +- 建议 CI 配置 `TEST_DATABASE_URL` + `RUNNER_AUTH_TOKEN` 作为 test→main 合并门禁。 +- 建议每周运行 `scripts/fix-completed-instance-lifecycle.sql` dry-run 检查历史不一致。 diff --git a/.doc/test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md b/.doc/test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md new file mode 100644 index 00000000..f63aa67b --- /dev/null +++ b/.doc/test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md @@ -0,0 +1,103 @@ +# Codex 审计 P0 整改 测试报告 + +## 一、 测试背景 (Background) + +Codex 2026-09-12 再审计判定:授权链、冷启动 DB、Go 门禁、环境/文档漂移导致不可发布。本轮按 Wave 0–D 闭环 P0 与质量门禁。 + +## 二、 测试目标 (Test Objectives) + +- 任务/SSE 项目成员鉴权负例 +- goose 00025 序列同步 +- dev lifecycle 脏数据修复 +- `go vet ./...` + `go test -short ./...` 全绿 +- test/dev health schema_version=25 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- **OS**: Linux +- **Go**: 仓库当前 toolchain +- **DB**: dev `shuimu-context-pg-dev:5433`;test `shuimu-system-pg-test:5434` +- **层级**: 单元/short;PG 鉴权测试(需 `TEST_DATABASE_URL` 时 Skip) + +## 四、 预期结果 (Expected Results) + +| 用例 | 预期 | +|------|------| +| outsider POST /tasks/run | 403,零 WorkflowRun/TaskRun | +| outsider POST /tasks/run/stream | 403,零副作用 | +| go vet / go test -short | 全 PASS | +| dev/test /api/v1/health | schema_version=25 | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 Go 质量门禁 + +**Input**: `go vet ./...` ; `go test -short ./...` + +**Actual Output**: +``` +ok chat-agent-system/internal/bizdev 0.076s +ok chat-agent-system/internal/coordinator 0.011s +ok chat-agent-system/internal/server 7.002s +... (all packages ok) +``` + +**Verdict**: PASS +**Duration**: ~13s + +### 5.2 环境 health + +**Input**: `curl http://127.0.0.1:8081/api/v1/health` (test) ; `:8080` (dev) + +**Actual Output**: +```json +{"status":"ok","env":"test","schema_version":25,"database_ready":true,...} +{"status":"ok","env":"dev","schema_version":25,"database_ready":true,...} +``` + +**Verdict**: PASS + +### 5.3 Lifecycle 修复 + +**Input**: dry-run count → `scripts/fix-completed-instance-lifecycle.sql` on dev + +**Actual Output**: +``` +dry-run: 47 +UPDATE 47 +after: 0 +``` + +**Verdict**: PASS + +### 5.4 Test 工作流污染 + +**Input**: `scripts/retire-nonofficial-workflow-definitions.sql` on test + +**Actual Output**: `UPDATE 5` → active 非正式定义 0 + +**Verdict**: PASS + +### 5.5 Main 环境 + +**Input**: docker 创建 `data/main` → `./scripts/env-service.sh main start` → `curl :8000/api/v1/health` + +**Actual Output**: +```json +{"status":"ok","env":"prod","schema_version":25,"database_ready":true,"task_store_ready":true} +``` +重启 `docker restart shuimu-agent-app-main` 后 ready 仍 200。 + +**Verdict**: PASS + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +``` +go vet ./... → PASS (exit 0) +go test -short ./... → PASS (all ok) +``` + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- **结论**: P0 与三环境本地冒烟已达本轮验收标准;**发布 blocker 剩余 CI 真实 PI E2E 与前端人工抽检**。 +- **后续**: CI 配置 `TEST_DATABASE_URL` + `RUNNER_AUTH_TOKEN`;真实 PI 三工作流 E2E 实录;main 生产部署需配置强密码(见 `configs/.env.main.example`)。 diff --git a/.env.example b/.env.example index e345eff5..43d0a573 100644 --- a/.env.example +++ b/.env.example @@ -31,7 +31,7 @@ CURSOR_RUNNER_PORT=18765 # --- 4. Pi-Agent 容器与 MiniMax 模型(pi-agent 容器 entrypoint 读取)--- MINIMAX_ENDPOINT_URL=https://api.minimaxi.com/v1 MINIMAX_API_KEY= -MINIMAX_MODEL_ID=MiniMax-M3 +MINIMAX_MODEL_ID=MiniMax-M2.7-highspeed MINIMAX_API_TYPE=openai-compatible # --- 5. Tavily 联网搜索(pi-agent 容器内工具;多 Key 逗号分隔)--- @@ -48,7 +48,7 @@ PI_AGENT_RUNNER_PORT=18865 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 +PI_AGENT_MODEL=MiniMax-M2.7-highspeed # --- 8. 共享法规知识库(宿主 Go 进程:127.0.0.1:5432;容器 app:compose 注入 shuimu-kb-shared)--- KB_DB_HOST=127.0.0.1 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..74b14f8a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,199 @@ +# Agent 工作手册(通用) + +> 本文件与具体业务仓库无关,可整文件复用到其他项目。 +> **本仓库差异化约定见 [PROJECT.md](./PROJECT.md)。** + +--- + +## 1. 目的与适用范围 + +- 适用于在本仓库工作的所有编码 Agent(Cursor、Claude Code、Codex 等)。 +- **聊天记录易失**;Git 与活文档区才是可协作、可审计的真相来源。 +- 任何任务、调研、审计、修复、运维操作均须同步落盘,禁止「只改代码 / 只留在对话」。 + +--- + +## 2. 启动协议(Session Bootstrap) + +每次会话开始,按顺序阅读: + +1. **本文件** `AGENTS.md`(通用工作方式) +2. **[PROJECT.md](./PROJECT.md)**(本仓库地图与约定) +3. **`.doc/ops/CURRENT.md`**(当前焦点、阻塞、下一步) +4. 相关 **short-term 任务** / **handoff**(若存在) +5. 发布/审计类任务:再读 PROJECT 指向的 gap-matrix 等 SSOT + +**未读完不得开始改代码**(紧急热修除外,事后必须在 24h 内补文档)。 + +--- + +## 3. 文档同步红线(强制) + +| 时机 | 必须动作 | +|------|----------| +| 任务开始 | 在 `short-term/` 或 `long-term/` 新建或更新条目(复制 templates) | +| 里程碑 | 更新任务文 `status` 与 `CURRENT.md` | +| 调研/审计 | 写入 `research/` 或任务文「证据」段 | +| 验收 | 写 test-report(路径见 PROJECT),回链到 checklist | +| 会话结束/切换 Agent | 写或更新 `handoffs/` | +| 提交 | 文档变更与代码同批或紧随 `docs(...)` 提交 | + +**状态字段**:`planned | in_progress | blocked | done | cancelled` + +**current vs historical**:完成项标 `done` 或移入归档;不删除证据。 + +--- + +## 4. 活文档区约定 + +默认路径:`.doc/ops/`(可由 PROJECT 覆盖) + +```text +.doc/ops/ + README.md # 本区用法 + CURRENT.md # 一页纸:当前焦点(会话必读) + short-term/ # 本迭代可关闭的任务 + long-term/ # 跨迭代 backlog + checklists/ # 可勾选验收清单 + handoffs/ # 会话交接 + research/ # 调研笔记实例 + templates/ # 新建条目时复制 +``` + +**命名**:`YYYY-MM-DD-.md` + +--- + +## 5. 文档类型与何时使用 + +### 短期任务(short-term) + +- **何时**:本迭代可关闭的具体工作 +- **必含**:目标、范围、非目标、步骤、验收标准、相关链接 +- **模板**:`.doc/ops/templates/short-term-task.md` + +### 长期任务(long-term) + +- **何时**:跨迭代 backlog、架构债、未排期改进 +- **必含**:动机、成功标准、依赖、暂不启动原因 +- **模板**:`.doc/ops/templates/long-term-task.md` + +### Checklist(checklists) + +- **何时**:可勾选验收;与短任务 1:1 或 1:N +- **必含**:每项 `[ ]` / `[x]` + 证据链接(commit、报告、命令输出) +- **模板**:`.doc/ops/templates/checklist.md` + +### 调研笔记(research) + +- **何时**:只读调查、审计复核、根因分析 +- **必含**:结论、证据路径、开放问题、置信度 +- **模板**:`.doc/ops/templates/research-note.md` + +### 交接(handoffs) + +- **何时**:会话结束、切换 Agent、长时间阻塞 +- **必含**:目标、已完成、未完成、关键文件、如何验证、风险 +- **模板**:`.doc/ops/templates/handoff.md` + +### 报告(test-reports) + +- 路径由 **PROJECT.md** 指定(本仓为 `.doc/test-reports/`) +- **原则**:真实控制台/日志取样;禁止 stub 冒充最终验收 +- 在任务文与 `CURRENT.md` 中用相对链接引用 + +--- + +## 6. 多 Agent 协作 + +- **单一焦点**:`CURRENT.md` 只列 1–3 个当前焦点 +- **并行任务**:在 CURRENT 列出 owner/agent 标识与互斥资源(分支、文件、环境) +- **不覆盖**:不覆盖他人未合并工作区;发现冲突先写 handoff/blocker +- **交接优先**:口头转述不可代替 handoff 文件 + +--- + +## 7. Git 与提交 + +- 遵循 **Conventional Commits**:`feat` / `fix` / `refactor` / `test` / `docs` / `chore` / `ci` +- **原子提交**:一个逻辑变更一批;便于 review 与回滚 +- **用户未要求**:不擅自 push、不 force push 保护分支 +- **用户已要求 push/同步远端**:必须自主完成,不得把「请你去点推送」推回用户 +- **禁止**:在 `git config` 写入 token;硬重置/强推/删库仅在用户明确授权时 + +--- + +## 8. Secrets 与远端推送(自主,禁止索要) + +### 查找顺序 + +1. `${HOME}/.secrets/` 下与远端相关的文件(如 `gitea`、`github`) +2. 本仓 **PROJECT.md** 与 **push 脚本**(如 `scripts/git-push-dev.sh`)中的字段说明 + +### 常见字段(格式因宿主而异) + +```text +- ACCESS_TOKEN= +- URL_PREFIX= +``` + +### 执行方式 + +- 优先调用本仓封装脚本(若存在) +- 或自行读取 secrets 文件构造 push URL(**不写入 git config**) + +### 禁止 + +- 向用户索要 token 或要求粘贴凭证 +- 在聊天、文档、commit message 中展示 secret +- 把 token 写入仓库或 `.env` 并提交 + +### 缺失时 + +- 在 ops 任务/handoff 记录 **blocker**:文件路径 + 缺字段名 +- 提示用户在本机补齐该文件后重试 +- **仍不索要 secret 内容** + +--- + +## 9. 安全与合规红线 + +- 不提交 `.env`、密钥、凭证、生产数据倾倒 +- 不做未授权访问或攻击性利用(本地修复与加固除外) +- 破坏性命令(`git reset --hard`、`push --force`、DROP DATABASE)仅在用户明确授权时 +- XSS/注入等修复优先于功能扩展 + +--- + +## 10. 计划 / 实现 / 验证 + +1. **大改动**:先对齐计划或 short-term 范围;范围外记 long-term +2. **实现**:按 PROJECT 测试命令验证;失败信息写入任务/报告 +3. **完成定义**:写进 checklist;勾选须有证据链接(非口头「已完成」) + +--- + +## 11. 沟通 + +- 对用户:**简洁、中文**(若 PROJECT 未另规定) +- 不复述长篇规则;指向文件路径 +- 需要决策时 **一次只问 1–2 个关键问题** +- 长输出先给结论,再展开细节 + +--- + +## 12. 模板索引 + +新建任何 ops 条目前,先复制对应模板: + +| 类型 | 模板路径 | +|------|----------| +| 短期任务 | `.doc/ops/templates/short-term-task.md` | +| 长期任务 | `.doc/ops/templates/long-term-task.md` | +| Checklist | `.doc/ops/templates/checklist.md` | +| 交接 | `.doc/ops/templates/handoff.md` | +| 调研 | `.doc/ops/templates/research-note.md` | + +--- + +**本仓库差异化约定见 [PROJECT.md](./PROJECT.md)。** diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..eb386745 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,9 @@ +# Claude Code 入口 + +请先完整阅读并遵守 [AGENTS.md](./AGENTS.md)(通用 Agent 工作手册)。 + +再阅读 [PROJECT.md](./PROJECT.md)(本仓库地图与约定)。 + +然后阅读 [.doc/ops/CURRENT.md](./.doc/ops/CURRENT.md) 获取当前任务焦点。 + +勿向用户索要 Git token;按 AGENTS.md 自行从本机 `${HOME}/.secrets/` 路径读取。 diff --git a/PROJECT.md b/PROJECT.md new file mode 100644 index 00000000..a9391728 --- /dev/null +++ b/PROJECT.md @@ -0,0 +1,130 @@ +# Agent-System 项目地图 + +> 通用 Agent 工作方式见 [AGENTS.md](./AGENTS.md)。 +> Claude Code 入口见 [CLAUDE.md](./CLAUDE.md)。 + +--- + +## 1. 产品一句话 + +面向企业与团队的多用户工作流与对话智能体平台:RAG 知识库、PI Agent 执行、项目治理、阶段门禁、文档审批、任务持久化与 SSE 流式交互。 + +--- + +## 2. 主要目录 + +| 路径 | 说明 | +|------|------| +| `cmd/` | 服务入口 | +| `internal/` | Go 业务逻辑(server、coordinator、bizdev、chat、projectinstance 等) | +| `web/` | React + Vite 前端 | +| `deploy/postgres/goose/` | PostgreSQL 迁移(Goose) | +| `configs/` | Agent 与提示词配置 | +| `templates/` | 工作流文档模板 | +| `.doc/` | 架构、审计、整改文档 | +| `.doc/ops/` | **活协作区**(CURRENT、任务、checklist、handoff) | +| `.doc/test-reports/` | 测试/验收报告(归档) | +| `data/dev|test|main/` | 各环境对话与工作区(隔离) | +| `scripts/` | 运维与推送脚本 | + +--- + +## 3. 技术栈 + +- **后端**:Go HTTP API(~110 路由) +- **前端**:React + Vite +- **数据库**:PostgreSQL + pgvector(Goose 迁移) +- **智能体**:PI Agent Runner(真实 / stub) +- **工作流**:main、product_manager、test_manager 三套正式定义 + +--- + +## 4. Git 三分支与三环境 + +详见 [.cursor/rules/git-workflow-and-environments.mdc](./.cursor/rules/git-workflow-and-environments.mdc)。 + +| 环境 | 分支 | Compose | HTTP | DB 端口 | 数据路径 | +|------|------|---------|------|---------|----------| +| 开发 | dev | agent-dev | 8080 | 5433 | data/dev | +| 测试 | test | agent-test | 8081 | 5434 | data/test | +| 生产 | main | agent-main | 8000 | 5435 | data/main | +| 共享 KB | 跨环境 | agent-infra | - | 5432 | data/shared/kb | + +**红线**:知识库共享;对话与业务 DB **严格隔离**,禁止跨环境读写。 + +--- + +## 5. 文档地图(SSOT) + +| 用途 | 路径 | +|------|------| +| 协作会话 / 当前焦点 | [.doc/ops/CURRENT.md](./.doc/ops/CURRENT.md) | +| 发布/审计缺口状态 | [.doc/2026-09-12-remediation-gap-matrix.md](./.doc/2026-09-12-remediation-gap-matrix.md) | +| 测试报告归档 | [.doc/test-reports/](./.doc/test-reports/) | +| 历史审计索引 | [.doc/2026-09-12-AUDIT-INDEX.md](./.doc/2026-09-12-AUDIT-INDEX.md) | +| 测试报告规范 | [.cursor/rules/test-reports-standard.mdc](./.cursor/rules/test-reports-standard.mdc) | + +--- + +## 6. 常用命令 + +```bash +# 环境 +./scripts/env-service.sh shared start +./scripts/env-service.sh dev start +./scripts/env-service.sh all status + +# Go 质量门禁(默认) +go vet ./... +go test -short ./... + +# PG 集成(需 TEST_DATABASE_URL) +TEST_DATABASE_URL=postgres://... go test ./internal/server/... -run 'TestE2E_WorkflowGovernance' + +# 真实 PI E2E(显式开关) +RUN_PI_E2E=1 RUNNER_AUTH_TOKEN=... go test ./... -run 'PI' + +# 前端 +cd web && npm test && npm run build + +# 数据库迁移 +# 见 deploy/postgres/goose/ +``` + +--- + +## 7. Git 推送(自主,勿索要 token) + +- **脚本**:[`scripts/git-push-dev.sh`](./scripts/git-push-dev.sh) +- **凭证文件**:`${HOME}/.secrets/gitea` +- **字段**: + ```text + - ACCESS_TOKEN= + - URL_PREFIX= + ``` +- **用法**:`./scripts/git-push-dev.sh dev`(或 test/main 分支名) + +--- + +## 8. 测试与验收铁律 + +- 开发可用 stub;**交付前验收**必须真实 PI Agent + MiniMax-M3 全链路 +- 报告必须归档至 `.doc/test-reports/`,遵循 test-reports-standard +- 禁止 stub 输出冒充最终验收数据 + +--- + +## 9. 当前焦点(动态) + +见 **[.doc/ops/CURRENT.md](./.doc/ops/CURRENT.md)**。 + +- 发布缺口 SSOT:[gap-matrix](./.doc/2026-09-12-remediation-gap-matrix.md) +- 最新测试报告:[2026-09-13-codex-audit-p0-remediation-test-report.md](./.doc/test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md) +- **剩余 blocker**: CI 真实 PI E2E + 前端人工抽检(main 本地冒烟已通过) + +--- + +## 10. 语言与提交 + +- 对用户:**简体中文** +- 提交:Conventional Commits;在 `dev` 上渐进式分批提交 diff --git a/configs/.env.dev.example b/configs/.env.dev.example index 6fdbf039..936c8b14 100644 --- a/configs/.env.dev.example +++ b/configs/.env.dev.example @@ -43,7 +43,9 @@ 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_MODEL_ID=MiniMax-M2.7-highspeed MINIMAX_API_TYPE=openai-compatible +PI_AGENT_PROVIDER=minimax +PI_AGENT_MODEL=MiniMax-M2.7-highspeed TAVILY_API_KEYS= # RUNNER_AUTH_TOKEN= diff --git a/configs/.env.main.example b/configs/.env.main.example index 04c39648..30a46e6f 100644 --- a/configs/.env.main.example +++ b/configs/.env.main.example @@ -33,3 +33,7 @@ KB_DB_PORT=5432 CURSOR_RUNNER_PORT=18767 AGENT_BACKEND=stub AGENT_TIMEOUT=60m +# 生产必填(不可使用 shared_kb_secret_pass / admin-secret-password-123 等默认值) +RUNNER_AUTH_TOKEN=change-me-runner-token +KB_DB_PASSWORD=change-me-kb-prod-secret +ADMIN_PASSWORD=change-me-admin-prod-secret diff --git a/deploy/postgres/goose/00021_2026-09-12-iam-hardening.sql b/deploy/postgres/goose/00021_2026-09-12-iam-hardening.sql new file mode 100644 index 00000000..21550ba2 --- /dev/null +++ b/deploy/postgres/goose/00021_2026-09-12-iam-hardening.sql @@ -0,0 +1,46 @@ +-- +goose Up +-- Phase 2: IAM hardening — 成员 FK、演示账号 project:create、用户偏好表 + +ALTER TABLE project_members + DROP CONSTRAINT IF EXISTS project_members_user_id_fkey; +ALTER TABLE project_members + ADD CONSTRAINT project_members_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + +ALTER TABLE user_role_bindings + ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active'; +ALTER TABLE user_role_bindings + ADD COLUMN IF NOT EXISTS reason TEXT NOT NULL DEFAULT ''; + +-- 演示账号 zhanggong 需具备 project:create 以支撑 E2E(role-tech-lead) +UPDATE users SET role_id = 'role-tech-lead' +WHERE username = 'zhanggong' AND role_id = 'role-engineer'; + +INSERT INTO user_role_bindings (user_id, role_id, scope_type, scope_id, assigned_by, status) +SELECT u.id, u.role_id, 'system', 0, u.id, 'active' +FROM users u +WHERE u.username IN ('zhanggong', 'lizhuguan', 'wangzong') +ON CONFLICT (user_id, role_id, scope_type, scope_id) DO NOTHING; + +CREATE TABLE IF NOT EXISTS user_preferences ( + user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + schema_version INT NOT NULL DEFAULT 1, + preferences JSONB NOT NULL DEFAULT '{}'::jsonb, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS user_notification_preferences ( + user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + schema_version INT NOT NULL DEFAULT 1, + preferences JSONB NOT NULL DEFAULT '{}'::jsonb, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- +goose Down +DROP TABLE IF EXISTS user_notification_preferences; +DROP TABLE IF EXISTS user_preferences; + +ALTER TABLE user_role_bindings DROP COLUMN IF EXISTS reason; +ALTER TABLE user_role_bindings DROP COLUMN IF EXISTS status; + +ALTER TABLE project_members DROP CONSTRAINT IF EXISTS project_members_user_id_fkey; diff --git a/deploy/postgres/goose/00022_2026-09-12-workflow-schema-v2.sql b/deploy/postgres/goose/00022_2026-09-12-workflow-schema-v2.sql new file mode 100644 index 00000000..248e05f1 --- /dev/null +++ b/deploy/postgres/goose/00022_2026-09-12-workflow-schema-v2.sql @@ -0,0 +1,29 @@ +-- +goose Up +-- Schema v2: executor registry metadata on workflow_versions (JSONB 已存 definition_json) + +ALTER TABLE workflow_versions + ADD COLUMN IF NOT EXISTS schema_version INT NOT NULL DEFAULT 1; + +UPDATE workflow_versions SET schema_version = COALESCE((definition_json->>'schema_version')::int, 1) +WHERE schema_version = 1; + +CREATE TABLE IF NOT EXISTS workflow_executor_registry ( + executor_key TEXT PRIMARY KEY, + description TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +INSERT INTO workflow_executor_registry (executor_key, description) VALUES + ('bizdev.intake.v1', '商务 intake'), + ('bizdev.research.v1', '竞品调研'), + ('bizdev.feasibility.v1', '法规可行性'), + ('bizdev.prelim_design.v1', '初步设计'), + ('bizdev.cdo_quote.v1', 'CDO 报价'), + ('squad.system_reqs.v1', '系统需求基线'), + ('squad.test_manager.v1', '测试经理验证策划') +ON CONFLICT (executor_key) DO NOTHING; + +-- +goose Down +DROP TABLE IF EXISTS workflow_executor_registry; +ALTER TABLE workflow_versions DROP COLUMN IF EXISTS schema_version; diff --git a/deploy/postgres/goose/00023_2026-09-12-instance-integrity.sql b/deploy/postgres/goose/00023_2026-09-12-instance-integrity.sql new file mode 100644 index 00000000..08074d77 --- /dev/null +++ b/deploy/postgres/goose/00023_2026-09-12-instance-integrity.sql @@ -0,0 +1,19 @@ +-- +goose Up +-- Instance integrity: stage position unique, frozen task metadata columns + +CREATE UNIQUE INDEX IF NOT EXISTS uq_stage_instance_position + ON project_stage_instances (workflow_instance_id, position) + WHERE position > 0; + +ALTER TABLE project_doc_task_instances + ADD COLUMN IF NOT EXISTS executor_key TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS template_version INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS quality_gate_key TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE project_doc_task_instances + DROP COLUMN IF EXISTS quality_gate_key, + DROP COLUMN IF EXISTS template_version, + DROP COLUMN IF EXISTS executor_key; + +DROP INDEX IF EXISTS uq_stage_instance_position; diff --git a/deploy/postgres/goose/00024_2026-09-12-workflow-catalog-governance.sql b/deploy/postgres/goose/00024_2026-09-12-workflow-catalog-governance.sql new file mode 100644 index 00000000..c29a347c --- /dev/null +++ b/deploy/postgres/goose/00024_2026-09-12-workflow-catalog-governance.sql @@ -0,0 +1,96 @@ +-- +goose Up +-- 三工作流目录治理 + 上下文快照 + 实例终止元数据 + +ALTER TABLE workflow_definitions + ADD COLUMN IF NOT EXISTS project_selectable BOOLEAN NOT NULL DEFAULT false; + +ALTER TABLE workflow_definitions + ADD COLUMN IF NOT EXISTS catalog_order INT NOT NULL DEFAULT 0; + +UPDATE workflow_definitions +SET project_selectable = true, + catalog_order = CASE def_key + WHEN 'main' THEN 10 + WHEN 'product_manager' THEN 20 + WHEN 'test_manager' THEN 30 + ELSE catalog_order + END, + name = CASE def_key + WHEN 'main' THEN '商务对接流程' + WHEN 'product_manager' THEN '智能产品经理流程' + WHEN 'test_manager' THEN '智能测试工程师流程' + ELSE name + END +WHERE def_key IN ('main', 'product_manager', 'test_manager'); + +UPDATE workflow_definitions +SET project_selectable = false, + status = CASE WHEN def_key IN ('main', 'product_manager', 'test_manager') THEN status ELSE 'retired' END +WHERE def_key NOT IN ('main', 'product_manager', 'test_manager'); + +INSERT INTO workflow_executor_registry (executor_key, description) VALUES + ('bizdev.feasibility_dev.v1', '设计开发可行性评估'), + ('bizdev.user_requirements.v1', '用户需求表编制') +ON CONFLICT (executor_key) DO NOTHING; + +ALTER TABLE project_workflow_instances + ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS completed_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS terminated_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS terminated_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS termination_reason TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS previous_instance_id BIGINT REFERENCES project_workflow_instances(id) ON DELETE SET NULL; + +CREATE TABLE IF NOT EXISTS project_context_snapshots ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + task_key TEXT NOT NULL DEFAULT '', + cutoff_at TIMESTAMPTZ NOT NULL DEFAULT now(), + aggregate_hash TEXT NOT NULL DEFAULT '', + source_count INT NOT NULL DEFAULT 0, + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + frozen BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_project_context_snapshots_project + ON project_context_snapshots(project_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS project_context_snapshot_sources ( + id BIGSERIAL PRIMARY KEY, + snapshot_id BIGINT NOT NULL REFERENCES project_context_snapshots(id) ON DELETE CASCADE, + source_type TEXT NOT NULL, + source_id TEXT NOT NULL DEFAULT '', + source_version TEXT NOT NULL DEFAULT '', + content_hash TEXT NOT NULL DEFAULT '', + authority_level TEXT NOT NULL DEFAULT '', + permission_scope TEXT NOT NULL DEFAULT 'project', + referenced BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_snapshot_source UNIQUE (snapshot_id, source_type, source_id, source_version) +); + +CREATE INDEX IF NOT EXISTS idx_context_snapshot_sources_snapshot + ON project_context_snapshot_sources(snapshot_id); + +ALTER TABLE project_doc_task_instances DROP CONSTRAINT IF EXISTS chk_pdti_status; +ALTER TABLE project_doc_task_instances + ADD CONSTRAINT chk_pdti_status CHECK (status IN ( + 'pending', 'in_progress', 'reviewing', 'approved', 'skipped', 'skipped_disabled' + )); + +ALTER TABLE project_doc_task_instances + ADD COLUMN IF NOT EXISTS output_doc_title TEXT NOT NULL DEFAULT ''; + +-- +goose Down +DROP TABLE IF EXISTS project_context_snapshot_sources; +DROP TABLE IF EXISTS project_context_snapshots; +ALTER TABLE project_workflow_instances + DROP COLUMN IF EXISTS previous_instance_id, + DROP COLUMN IF EXISTS termination_reason, + DROP COLUMN IF EXISTS terminated_by, + DROP COLUMN IF EXISTS terminated_at, + DROP COLUMN IF EXISTS completed_by, + DROP COLUMN IF EXISTS completed_at; +ALTER TABLE workflow_definitions DROP COLUMN IF EXISTS catalog_order; +ALTER TABLE workflow_definitions DROP COLUMN IF EXISTS project_selectable; diff --git a/deploy/postgres/goose/00025_2026-09-13-sync-seed-sequences.sql b/deploy/postgres/goose/00025_2026-09-13-sync-seed-sequences.sql new file mode 100644 index 00000000..6056c2c3 --- /dev/null +++ b/deploy/postgres/goose/00025_2026-09-13-sync-seed-sequences.sql @@ -0,0 +1,21 @@ +-- +goose Up +-- 00011 显式插入 users/projects id 后同步序列,避免冷启动 duplicate key +BEGIN; + +SELECT setval( + pg_get_serial_sequence('users', 'id'), + COALESCE((SELECT MAX(id) FROM users), 1), + true +); + +SELECT setval( + pg_get_serial_sequence('projects', 'id'), + COALESCE((SELECT MAX(id) FROM projects), 1), + true +); + +COMMIT; + +-- +goose Down +-- 序列同步不可逆;Down 为空操作 +SELECT 1; diff --git a/deploy/postgres/preflight/00021-preflight.sql b/deploy/postgres/preflight/00021-preflight.sql new file mode 100644 index 00000000..1abadb66 --- /dev/null +++ b/deploy/postgres/preflight/00021-preflight.sql @@ -0,0 +1,39 @@ +-- Preflight checks before Goose 00021+ IAM hardening migrations. +-- Run against target environment; fix reported rows before applying FK constraints. + +-- 1. Orphan project_members (user_id not in users) +SELECT pm.id, pm.project_id, pm.user_id, pm.project_role, pm.status +FROM project_members pm +LEFT JOIN users u ON u.id = pm.user_id +WHERE u.id IS NULL; + +-- 2. Projects with NULL or demo creator (legacy public visibility) +SELECT p.id, p.code, p.name, p.creator_user_id +FROM projects p +WHERE p.creator_user_id IS NULL OR p.creator_user_id = 1; + +-- 3. Duty assignments without active project membership (after instances exist) +SELECT d.id, d.doc_task_instance_id, d.author_user_id, d.reviewer_user_id, d.approver_user_id, + wi.project_id +FROM document_duty_assignments d +JOIN project_doc_task_instances ti ON ti.id = d.doc_task_instance_id +JOIN project_stage_instances si ON si.id = ti.stage_instance_id +JOIN project_workflow_instances wi ON wi.id = si.workflow_instance_id +WHERE NOT EXISTS ( + SELECT 1 FROM project_members pm + WHERE pm.project_id = wi.project_id + AND pm.user_id IN (d.author_user_id, d.reviewer_user_id, d.approver_user_id) + AND pm.status = 'active' +); + +-- 4. Stage instances with invalid position (all zero) +SELECT workflow_instance_id, stage_key, position, status +FROM project_stage_instances +GROUP BY workflow_instance_id, stage_key, position, status +HAVING position = 0; + +-- 5. Users with role bindings but inactive role +SELECT b.user_id, b.role_id, r.status AS role_status +FROM user_role_bindings b +JOIN system_roles r ON r.id = b.role_id +WHERE r.status IS NOT NULL AND r.status <> 'active'; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 52f793d7..afd6ab4f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -56,7 +56,7 @@ services: DATA_DIR: /app/data/workspaces MINIMAX_ENDPOINT_URL: ${MINIMAX_ENDPOINT_URL:-https://api.minimaxi.com/v1} MINIMAX_API_KEY: ${MINIMAX_API_KEY:-} - MINIMAX_MODEL_ID: ${MINIMAX_MODEL_ID:-MiniMax-M3} + MINIMAX_MODEL_ID: ${MINIMAX_MODEL_ID:-MiniMax-M2.7-highspeed} MINIMAX_API_TYPE: ${MINIMAX_API_TYPE:-openai-compatible} MINIMAX_PROVIDER: minimax TAVILY_API_KEYS: ${TAVILY_API_KEYS:-} @@ -102,6 +102,8 @@ services: CURSOR_RUNNER_URL: http://cursor-agent:18765 PI_AGENT_RUNNER_URL: http://pi-agent:18865 PI_AGENT_RUNNER_AUTH_TOKEN: ${RUNNER_AUTH_TOKEN:-} + PI_AGENT_PROVIDER: ${PI_AGENT_PROVIDER:-minimax} + PI_AGENT_MODEL: ${PI_AGENT_MODEL:-MiniMax-M2.7-highspeed} 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 diff --git a/docker-compose.main.yml b/docker-compose.main.yml index 3b8b7a25..786a6e62 100644 --- a/docker-compose.main.yml +++ b/docker-compose.main.yml @@ -101,6 +101,8 @@ services: 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} + KB_DB_PASSWORD: ${KB_DB_PASSWORD:?KB_DB_PASSWORD is required for main/production} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD 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: diff --git a/internal/auth/bootstrap.go b/internal/auth/bootstrap.go index 26459463..50f56d21 100644 --- a/internal/auth/bootstrap.go +++ b/internal/auth/bootstrap.go @@ -72,7 +72,7 @@ func BootstrapDevSeedUsers(ctx context.Context, store AuthStore, cfg *config.Con dept string empID string }{ - {"zhanggong", "张工", "role-engineer", "方案工程师", "骨科耗材研发组", "RD-001"}, + {"zhanggong", "张工", "role-tech-lead", "技术主管", "骨科耗材研发组", "RD-001"}, {"lizhuguan", "李主管", "role-tech-lead", "技术主管", "骨科耗材研发组", "RD-002"}, {"wangzong", "王总监", "role-director", "研发总监", "研发中心", "RD-003"}, {"guest", "访客", "role-guest", "待指派", "外协中心", "EXT-999"}, diff --git a/internal/auth/models.go b/internal/auth/models.go index 986296da..310ea33c 100644 --- a/internal/auth/models.go +++ b/internal/auth/models.go @@ -22,6 +22,8 @@ type UserAccount struct { EmployeeID string `json:"employee_id,omitempty"` Department string `json:"department,omitempty"` Email string `json:"email,omitempty"` + Locale string `json:"locale,omitempty"` + Timezone string `json:"timezone,omitempty"` Avatar string `json:"avatar,omitempty"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` diff --git a/internal/auth/service.go b/internal/auth/service.go index bbc5c9eb..99aabdf5 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -7,6 +7,8 @@ import ( "errors" "fmt" "strings" + "sync" + "chat-agent-system/internal/config" ) @@ -43,6 +45,8 @@ type Service struct { store AuthStore sessions SessionStore cfg *config.Config + prefMu sync.RWMutex + prefs map[int64]map[string]any } // NewService 实例化认证服务;sessionStore 为空时使用进程内 MemorySessionStore。 @@ -315,3 +319,43 @@ func (s *Service) GetConfig(ctx context.Context) (SystemAuthConfig, error) { func (s *Service) UpdateConfig(ctx context.Context, cfg SystemAuthConfig) error { return s.store.UpdateConfig(ctx, cfg) } + +// UpdateUserProfile 更新当前用户资料字段。 +func (s *Service) UpdateUserProfile(ctx context.Context, user *UserAccount) (*UserAccount, error) { + if user == nil || user.ID <= 0 { + return nil, errors.New("invalid user") + } + updated, err := s.store.UpdateUser(ctx, user) + if err != nil { + return nil, err + } + updated.PasswordHash = "" + return updated, nil +} + +// GetUserPreferences 读取用户偏好(内存/后续 PG user_preferences)。 +func (s *Service) GetUserPreferences(ctx context.Context, userID int64) (map[string]any, error) { + s.prefMu.RLock() + defer s.prefMu.RUnlock() + if s.prefs == nil { + return map[string]any{}, nil + } + if p, ok := s.prefs[userID]; ok { + return p, nil + } + return map[string]any{}, nil +} + +// SaveUserPreferences 保存用户偏好。 +func (s *Service) SaveUserPreferences(ctx context.Context, userID int64, body map[string]any) (map[string]any, error) { + if userID <= 0 { + return nil, errors.New("invalid user id") + } + s.prefMu.Lock() + defer s.prefMu.Unlock() + if s.prefs == nil { + s.prefs = make(map[int64]map[string]any) + } + s.prefs[userID] = body + return body, nil +} diff --git a/internal/auth/store.go b/internal/auth/store.go index 1ae25e08..ef5371c0 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -92,7 +92,7 @@ func NewMemoryStore(cfg *config.Config) *MemoryStore { dept string employeeID string }{ - {2, "zhanggong", "张工", "role-engineer", "方案工程师", "骨科耗材研发组", "RD-001"}, + {2, "zhanggong", "张工", "role-tech-lead", "技术主管", "骨科耗材研发组", "RD-001"}, {3, "lizhuguan", "李主管", "role-tech-lead", "技术主管", "骨科耗材研发组", "RD-002"}, {4, "wangzong", "王总监", "role-director", "研发总监", "研发中心", "RD-003"}, {5, "guest", "访客", "role-guest", "待指派", "外协中心", "EXT-999"}, diff --git a/internal/auth/store_test.go b/internal/auth/store_test.go index 19342b4b..ca227646 100644 --- a/internal/auth/store_test.go +++ b/internal/auth/store_test.go @@ -17,7 +17,7 @@ func TestMemoryStore_DevEnvSeedsDemoUsers(t *testing.T) { if err != nil { t.Fatalf("expected zhanggong in dev env: %v", err) } - if user.RoleID != "role-engineer" { + if user.RoleID != "role-tech-lead" { t.Fatalf("unexpected role: %s", user.RoleID) } } diff --git a/internal/authz/catalog.go b/internal/authz/catalog.go new file mode 100644 index 00000000..ccf55be4 --- /dev/null +++ b/internal/authz/catalog.go @@ -0,0 +1,25 @@ +package authz + +// PermissionCatalogEntry 权限目录项。 +type PermissionCatalogEntry struct { + Key string `json:"key"` + Resource string `json:"resource"` + Action string `json:"action"` + RiskLevel string `json:"risk_level"` + Description string `json:"description,omitempty"` +} + +// PermissionCatalog 返回系统权限目录(与 DB seed 对齐,供前端渲染)。 +func PermissionCatalog() []PermissionCatalogEntry { + return []PermissionCatalogEntry{ + {Key: "project:create", Resource: "project", Action: "create", RiskLevel: "normal", Description: "创建新项目"}, + {Key: "project:assign_lead", Resource: "project", Action: "assign_lead", RiskLevel: "elevated", Description: "指派项目负责人"}, + {Key: PermManage, Resource: "system", Action: "manage", RiskLevel: "critical", Description: "系统权限管理"}, + {Key: "workflow:config_visibility", Resource: "workflow", Action: "config_visibility", RiskLevel: "elevated"}, + {Key: "doc:override_visibility", Resource: "document", Action: "override_visibility", RiskLevel: "elevated"}, + {Key: "doc:export", Resource: "document", Action: "export", RiskLevel: "normal"}, + {Key: "workflow:view_all", Resource: "workflow", Action: "view_all", RiskLevel: "normal"}, + {Key: "workflow_defs.publish", Resource: "workflow_defs", Action: "publish", RiskLevel: "elevated"}, + {Key: "templates.publish", Resource: "templates", Action: "publish", RiskLevel: "elevated"}, + } +} diff --git a/internal/authz/service.go b/internal/authz/service.go index 48723b90..a4b5f197 100644 --- a/internal/authz/service.go +++ b/internal/authz/service.go @@ -23,6 +23,11 @@ func (s *Service) enabled() bool { return s != nil && s.pool != nil } +// Enabled 暴露 RBAC 是否已连接数据库。 +func (s *Service) Enabled() bool { + return s.enabled() +} + // ListCapabilities 返回用户系统级 permission key 列表。 func (s *Service) ListCapabilities(ctx context.Context, userID int64) ([]string, error) { if userID <= 0 { @@ -34,9 +39,13 @@ func (s *Service) ListCapabilities(ctx context.Context, userID int64) ([]string, rows, err := s.pool.Query(ctx, ` SELECT DISTINCT p.perm_key FROM user_role_bindings b + JOIN system_roles sr ON sr.id = b.role_id AND sr.status = 'active' JOIN system_role_permissions rp ON rp.role_id = b.role_id AND rp.effect = 'allow' JOIN system_permissions p ON p.id = rp.permission_id WHERE b.user_id = $1 + AND b.scope_type = 'system' + AND b.scope_id = 0 + AND b.valid_from <= now() AND (b.valid_until IS NULL OR b.valid_until > now()) ORDER BY p.perm_key `, userID) diff --git a/internal/bizdev/full_link_e2e_test.go b/internal/bizdev/full_link_e2e_test.go index 40be50db..68e7af7d 100644 --- a/internal/bizdev/full_link_e2e_test.go +++ b/internal/bizdev/full_link_e2e_test.go @@ -121,12 +121,15 @@ func TestBizDev_FullLink_V01_V05_V10_E2E(t *testing.T) { if !resV05.Success { t.Fatalf("Stage V0.5 should succeed, got error: %s", resV05.ErrorReason) } - if len(resV05.SubArtifacts) != 3 { - t.Fatalf("Stage V0.5 should produce 3 sub-artifacts (research, feasibility_reg, prelim_design), got %d", len(resV05.SubArtifacts)) + if len(resV05.SubArtifacts) != 5 { + t.Fatalf("Stage V0.5 should produce 5 sub-artifacts, got %d", len(resV05.SubArtifacts)) } - t.Logf("✓ Stage V0.5 成功完成: 包含任务=[%s, %s, %s], 成熟度评分=%d (%s)", - resV05.SubArtifacts[0].TaskKey, resV05.SubArtifacts[1].TaskKey, resV05.SubArtifacts[2].TaskKey, - resV05.AdvisorBrief.MaturityScore, resV05.AdvisorBrief.MaturityLevel) + keys := make([]string, len(resV05.SubArtifacts)) + for i, sub := range resV05.SubArtifacts { + keys[i] = sub.TaskKey + } + t.Logf("✓ Stage V0.5 成功完成: 包含任务=%v, 成熟度评分=%d (%s)", + keys, resV05.AdvisorBrief.MaturityScore, resV05.AdvisorBrief.MaturityLevel) // 验证 feasibility_reg 受控文档已建立 docID := "doc_proj1001_feasibility_reg" diff --git a/internal/bizdev/projectcontext_inputs.go b/internal/bizdev/projectcontext_inputs.go new file mode 100644 index 00000000..b8f2bab3 --- /dev/null +++ b/internal/bizdev/projectcontext_inputs.go @@ -0,0 +1,39 @@ +package bizdev + +import ( + "context" + "fmt" + + "chat-agent-system/internal/ctxmgr" +) + +type snapshotDialogueInputs struct { + Dialogue []string + Decisions []string + ExcludedPrivate int + ExcludedNoAccess int +} + +func (s *Service) collectSnapshotDialogueInputs(ctx context.Context, projectID, userID int64) snapshotDialogueInputs { + out := snapshotDialogueInputs{} + if s == nil || projectID <= 0 || s.ctxMgr == nil { + return out + } + + if coll, err := s.getProjectContextForUser(ctx, projectID, userID); err == nil && coll != nil { + for _, d := range coll.MilestoneDecisions { + out.Decisions = append(out.Decisions, fmt.Sprintf("[%s] %s (%s)", d.ActivityType, d.Title, d.UserName)) + } + } + + dialogue, err := s.ctxMgr.CollectProjectDialogue(ctx, projectID, ctxmgr.CollectProjectDialogueOptions{ + Limit: 120, + }) + if err != nil || dialogue == nil { + return out + } + out.Dialogue = dialogue.Lines + out.ExcludedPrivate = dialogue.ExcludedPrivate + out.ExcludedNoAccess = dialogue.ExcludedNoAccess + return out +} diff --git a/internal/bizdev/projectcontext_inputs_test.go b/internal/bizdev/projectcontext_inputs_test.go new file mode 100644 index 00000000..228abafa --- /dev/null +++ b/internal/bizdev/projectcontext_inputs_test.go @@ -0,0 +1,48 @@ +package bizdev + +import ( + "context" + "testing" + + "chat-agent-system/internal/ctxmgr" + "chat-agent-system/internal/projectcontext" + "chat-agent-system/internal/replyexport" +) + +func TestCollectSnapshotDialogueInputs_FeedsBuildSnapshot(t *testing.T) { + ctx := context.Background() + mgr := ctxmgr.NewContextManager(nil, nil, ctxmgr.DefaultTimeDecayConfig()) + projectID := int64(601) + meta := map[string]any{"project_id": projectID} + + _, _ = mgr.RecordUserMessageWithID(ctx, "sess-601", 1, "user", "测试经理需要覆盖注册分类路径", "", "", meta) + _, _ = mgr.RecordUserMessageWithID(ctx, "sess-601", 1, "assistant", "我会整理测试验证范围与风险点。", "", "", map[string]any{ + "project_id": projectID, + "export_policy": string(replyexport.PolicyFull), + "content_kind": string(replyexport.KindDocumentText), + }) + _, _ = mgr.RecordActivity(ctx, projectID, 1, "张工", "decision", "确认先做注册分类调研", map[string]any{}, 1, true) + + svc := &Service{ctxMgr: mgr} + inputs := svc.collectSnapshotDialogueInputs(ctx, projectID, 1) + if len(inputs.Dialogue) == 0 { + t.Fatalf("expected dialogue lines, got none") + } + if len(inputs.Decisions) != 1 { + t.Fatalf("expected 1 decision, got %d", len(inputs.Decisions)) + } + + ctxSvc := projectcontext.NewService(nil) + snap, err := ctxSvc.BuildSnapshot(ctx, projectcontext.BuildInput{ + ProjectID: projectID, + TaskKey: "test_manager", + Dialogue: inputs.Dialogue, + Decisions: inputs.Decisions, + }) + if err != nil { + t.Fatalf("BuildSnapshot: %v", err) + } + if snap.SourceCount < 2 { + t.Fatalf("expected dialogue + decision sources, got %d", snap.SourceCount) + } +} diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index 43e07d2c..462ef648 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -19,6 +19,7 @@ import ( "chat-agent-system/internal/docgov" "chat-agent-system/internal/freedoc" "chat-agent-system/internal/kb" + "chat-agent-system/internal/projectcontext" "chat-agent-system/internal/projectworkflow" "chat-agent-system/internal/squad" "chat-agent-system/internal/storage" @@ -158,7 +159,7 @@ func NewService(cfg *config.Config) (*Service, error) { return svc, nil } -// SchemaVersion 返回当前数据库 schema 版本(goose)。 +// SchemaVersion 返回启动时缓存的数据库 schema 版本(goose)。 func (s *Service) SchemaVersion() int64 { if s == nil { return 0 @@ -166,6 +167,21 @@ func (s *Service) SchemaVersion() int64 { return s.schemaVersion } +// LiveSchemaVersion 从数据库实时读取当前 schema 版本;失败时回退缓存值。 +func (s *Service) LiveSchemaVersion(ctx context.Context) int64 { + if s == nil { + return 0 + } + if s.pool == nil { + return s.schemaVersion + } + ver, err := db.CurrentVersion(ctx, s.pool) + if err != nil { + return s.schemaVersion + } + return ver +} + // DatabaseReady 数据库是否已连接且迁移完成。 func (s *Service) DatabaseReady() bool { return s != nil && s.pool != nil @@ -212,7 +228,7 @@ var SupportedStages = []StageMeta{ Stage: StageV05_Feasibility, Title: "V0.5 方案成型与法规对齐", Description: "法规与注册路径初筛、竞品调研、系统初步架构设计与关键选型建议", - Tasks: []string{"research", "feasibility_reg", "prelim_design"}, + Tasks: []string{"research", "feasibility_dev", "feasibility_reg", "user_requirements", "prelim_design"}, }, { Stage: StageV10_Quoting, @@ -346,36 +362,51 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun ErrorReason: docErr.Error(), }, docErr } - // 智能测试经理前置检查:必须依赖同项目已批准的系统需求规格书 (system_reqs) + // 智能测试工程师:统一项目上下文快照;SRS 仅为可选高权威来源 var upstreamSRDoc *docgov.DocumentRecord - if req.TaskKey == "test_manager" { - upstreamDocID := fmt.Sprintf("doc_proj%d_system_reqs", req.ProjectID) - if s.docGov != nil && req.ProjectID > 0 { - 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 req.TaskKey == "test_manager" || req.TaskKey == "system_reqs" { + priorDocs := make([]squad.PriorDocItem, 0) + facts := map[string]string{} + if req.Fields != nil { + for k, v := range req.Fields { + facts[k] = v + } + } + if s.ctxMgr != nil && req.ProjectID > 0 { + if coll, err := s.getProjectContextForUser(ctx, req.ProjectID, req.UserID); err == nil && coll != nil { + for k, v := range coll.BlackboardFacts { + facts[k] = v } } } - if upstreamSRDoc == nil { - return &TaskRunResult{ - TaskKey: req.TaskKey, - ArtifactTitle: artifactTitle, - WorkDir: req.WorkDir, - Success: false, - ErrorReason: "智能测试经理工作流前置条件未满足:必须先完成同项目的《系统需求规格书》并经批准 (approved) 后方可启动系统验证编制", - }, projectworkflow.ErrPrerequisiteNotMet + upstreamDocID := fmt.Sprintf("doc_proj%d_system_reqs", req.ProjectID) + if s.docGov != nil && req.ProjectID > 0 { + if doc, err := s.docGov.GetDocument(ctx, upstreamDocID); err == nil && doc != nil && doc.CurrentState == docgov.StateApproved { + upstreamSRDoc = doc + if doc.WorkDir != "" { + contentPath := filepath.Join(doc.WorkDir, "output.md") + if b, readErr := os.ReadFile(contentPath); readErr == nil && len(b) > 0 { + priorDocs = append(priorDocs, squad.PriorDocItem{ + DocKey: "system_reqs", Title: "系统需求规格书", Content: string(b), Stage: "approved", + }) + } + } + } } - - // 自动将已批准系统需求规格书注入为测试方案编制的依据输入 - if len(materials) == 0 && upstreamSRDoc.WorkDir != "" { - contentPath := filepath.Join(upstreamSRDoc.WorkDir, "output.md") - if b, err := os.ReadFile(contentPath); err == nil && len(b) > 0 { - materials = append(materials, squad.MaterialItem{ - Filename: "upstream_system_requirements.md", - Content: b, - }) + dialogueInputs := s.collectSnapshotDialogueInputs(ctx, req.ProjectID, req.UserID) + ctxSvc := projectcontext.NewService(s.pool) + snap, _ := ctxSvc.BuildSnapshot(ctx, projectcontext.BuildInput{ + ProjectID: req.ProjectID, TaskKey: req.TaskKey, UserID: req.UserID, + ProjectName: req.ProjectName, Facts: facts, Materials: materials, PriorDocs: priorDocs, + Knowledge: hits, Dialogue: dialogueInputs.Dialogue, Decisions: dialogueInputs.Decisions, + }) + if snap != nil { + snap.ExcludedPrivateCount += dialogueInputs.ExcludedPrivate + snap.ExcludedNoAccessCount += dialogueInputs.ExcludedNoAccess + } + if snap != nil { + if extra := projectcontext.MaterialsForWorkspace(snap, 12); len(extra) > 0 { + materials = append(materials, extra...) } } } diff --git a/internal/bizdev/types_test.go b/internal/bizdev/types_test.go index 0aff88f9..850d91e1 100644 --- a/internal/bizdev/types_test.go +++ b/internal/bizdev/types_test.go @@ -145,9 +145,9 @@ func TestExecuteStageV01AndV05(t *testing.T) { if stage2Res.AdvisorBrief.MaturityLevel != "V0.5" { t.Errorf("expected V0.5 maturity level, got %s", stage2Res.AdvisorBrief.MaturityLevel) } - // Stage 2 包含 3 个任务 (research, feasibility_reg, prelim_design) - if len(stage2Res.SubArtifacts) != 3 { - t.Errorf("expected 3 sub artifacts in stage 2, got %d", len(stage2Res.SubArtifacts)) + // Stage V0.5 包含 5 个任务(与 SupportedStages 一致) + if len(stage2Res.SubArtifacts) != 5 { + t.Errorf("expected 5 sub artifacts in stage 2, got %d", len(stage2Res.SubArtifacts)) } // 3. 运行 Stage 3 (V1.0 Quoting),透传全部前序累积 diff --git a/internal/chat/session_test.go b/internal/chat/session_test.go index fc51ec19..8c52b352 100644 --- a/internal/chat/session_test.go +++ b/internal/chat/session_test.go @@ -522,28 +522,35 @@ func TestChatSession_SemanticMemoryRecallAndAsyncTurnRecord(t *testing.T) { t.Errorf("expected snippet to contain YY 0505, got: %s", session.UserCtx.RecalledMemorySnippet) } - // 4. 等待异步写入 goroutine 落盘 - time.Sleep(100 * time.Millisecond) - - // 5. 验证本轮当场生成的问答对是否已被向量记忆库索引 - results, err := ctxMgr.SearchChatMemory(ctx, ctxmgr.ChatMemorySearchOptions{ - Query: "电磁兼容样机数量", - UserID: 99, - ProjectID: 200, - TopK: 5, - MinScore: 0.1, - }) - if err != nil { - t.Fatalf("SearchChatMemory failed: %v", err) - } - + // 4. 等待异步写入 goroutine 落盘(轮询至 deadline,避免固定 sleep 竞态) + deadline := time.Now().Add(2 * time.Second) + var results []ctxmgr.RecalledMemoryItem foundCurrentTurn := false - for _, r := range results { - if r.SessionID == "sess_current_recall" { - foundCurrentTurn = true + for time.Now().Before(deadline) { + var searchErr error + results, searchErr = ctxMgr.SearchChatMemory(ctx, ctxmgr.ChatMemorySearchOptions{ + Query: "电磁兼容样机数量", + UserID: 99, + ProjectID: 200, + TopK: 5, + MinScore: 0.1, + }) + if searchErr != nil { + t.Fatalf("SearchChatMemory failed: %v", searchErr) + } + for _, r := range results { + if r.SessionID == "sess_current_recall" { + foundCurrentTurn = true + break + } + } + if foundCurrentTurn { break } + time.Sleep(20 * time.Millisecond) } + + // 5. 验证本轮当场生成的问答对是否已被向量记忆库索引 if !foundCurrentTurn { t.Errorf("expected current turn to be asynchronously indexed into chat memory") } diff --git a/internal/config/config.go b/internal/config/config.go index 9284068c..6f9d7412 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -80,8 +80,8 @@ func Load(path string) (*Config, error) { CursorBridgeBin: "tools/cursor-bridge/index.mjs", PiAgentBin: "/opt/homebrew/bin/pi", PiAgentBridgeBin: "tools/pi-bridge/index.mjs", - PiAgentProvider: "cc-switch-kimi-for-coding", - PiAgentModel: "kimi-for-coding", + PiAgentProvider: "minimax", + PiAgentModel: "MiniMax-M2.7-highspeed", KbDbHost: "127.0.0.1", KbDbPort: 5432, KbDbUser: "regkb", diff --git a/internal/coordinator/action_planner.go b/internal/coordinator/action_planner.go index 59f10f46..a5bfd8df 100644 --- a/internal/coordinator/action_planner.go +++ b/internal/coordinator/action_planner.go @@ -93,6 +93,19 @@ func (e *CoordinatorEngine) executeIntentPlan( if pendingConfirm != nil && userCtx.PendingExpectation != nil { last.NextExpectation = userCtx.PendingExpectation } + + slots := map[string]string{} + if recRes.TopIntent != nil && len(recRes.TopIntent.FilledSlots) > 0 { + slots = recRes.TopIntent.FilledSlots + } + if last.AdvisorBrief == nil { + last.AdvisorBrief = userCtx.LatestAdvisorBrief + } + if last.AdvisorBrief == nil { + last.AdvisorBrief = synthesizeBaselineBrief(userCtx) + } + e.enrichAdvisorBriefWithCases(ctx, last.AdvisorBrief, userCtx, slots) + return last, nil } diff --git a/internal/coordinator/action_planner_test.go b/internal/coordinator/action_planner_test.go index e0215998..79034a28 100644 --- a/internal/coordinator/action_planner_test.go +++ b/internal/coordinator/action_planner_test.go @@ -38,6 +38,9 @@ func TestEngine_IntentPlan_QueryThenQuery(t *testing.T) { if !strings.Contains(res.NaturalReply, "步骤") { t.Errorf("expected multi-step reply, got: %s", res.NaturalReply) } + if res.AdvisorBrief == nil { + t.Fatal("expected AdvisorBrief on multi-intent plan result") + } } func TestEngine_ExpectationPlusNew_ExecutesQueryKeepsConfirm(t *testing.T) { diff --git a/internal/coordinator/engine.go b/internal/coordinator/engine.go index 0f7a2c4c..7442034e 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" "time" @@ -17,6 +18,7 @@ import ( "chat-agent-system/internal/docgov" "chat-agent-system/internal/intent" "chat-agent-system/internal/kb" + "chat-agent-system/internal/projectinstance" "chat-agent-system/internal/squad" "chat-agent-system/internal/stream" ) @@ -63,11 +65,19 @@ type CoordinationResult struct { // CoordinatorEngine L2 协调引擎 type CoordinatorEngine struct { bizdevSvc bizdev.BizdevService + instSvc *projectinstance.Service assembler *ContextAssembler dialogueBackend agent.Backend dataDir string } +// SetInstanceService 注入项目工作流实例服务(对话推进与实例门禁合流)。 +func (e *CoordinatorEngine) SetInstanceService(svc *projectinstance.Service) { + if e != nil { + e.instSvc = svc + } +} + func NewCoordinatorEngine(bizdevSvc bizdev.BizdevService) *CoordinatorEngine { return &CoordinatorEngine{ bizdevSvc: bizdevSvc, @@ -670,6 +680,59 @@ func (e *CoordinatorEngine) handleSupplementInfo(cand *intent.CandidateIntent, u } func (e *CoordinatorEngine) handleAdvanceStage(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { + if e.instSvc != nil && e.instSvc.Available() && userCtx != nil && userCtx.Project.ID != "" { + projectID, parseErr := strconv.ParseInt(userCtx.Project.ID, 10, 64) + userID, userErr := strconv.ParseInt(userCtx.User.ID, 10, 64) + if parseErr == nil && userErr == nil && projectID > 0 && userID > 0 { + isCreator := userCtx.Project.CreatorUserID > 0 && userID == userCtx.Project.CreatorUserID + settings, settingsErr := e.instSvc.GetSettings(ctx, projectID, userID, isCreator) + hasLiveGovernance := settingsErr == nil && settings != nil && settings.LiveInstance != nil && + settings.LiveInstance.Status != projectinstance.InstanceCompleted && + settings.LiveInstance.Status != "terminated" + + if hasLiveGovernance { + if !isCreator { + return &CoordinationResult{ + Status: StatusRejected, + Intent: cand.Intent, + Branch: cand.Branch, + RejectReason: projectinstance.ErrForbiddenNotCreator.Error(), + NaturalReply: "只有项目创建者可以确认推进工作流阶段。", + UpdatedContext: userCtx, + }, projectinstance.ErrForbiddenNotCreator + } + res, _, advErr := e.tryAdvanceWorkflowInstance(ctx, projectID, userID) + if advErr != nil { + return &CoordinationResult{ + Status: StatusRejected, + Intent: cand.Intent, + Branch: cand.Branch, + RejectReason: advErr.Error(), + NaturalReply: fmt.Sprintf("阶段推进未通过门禁:%v", advErr), + UpdatedContext: userCtx, + }, advErr + } + return res, nil + } + + if isCreator { + if res, handled, advErr := e.tryAdvanceWorkflowInstance(ctx, projectID, userID); handled { + if advErr != nil { + return &CoordinationResult{ + Status: StatusRejected, + Intent: cand.Intent, + Branch: cand.Branch, + RejectReason: advErr.Error(), + NaturalReply: fmt.Sprintf("阶段推进未通过门禁:%v", advErr), + UpdatedContext: userCtx, + }, advErr + } + return res, nil + } + } + } + } + targetStageStr := cand.FilledSlots[intent.SlotTargetStage] targetStage := bizdev.MaturityStage(targetStageStr) if targetStage == "" { diff --git a/internal/coordinator/instance_advance.go b/internal/coordinator/instance_advance.go new file mode 100644 index 00000000..2c95b5ed --- /dev/null +++ b/internal/coordinator/instance_advance.go @@ -0,0 +1,53 @@ +package coordinator + +import ( + "context" + "fmt" + + "chat-agent-system/internal/intent" + "chat-agent-system/internal/projectinstance" +) + +func (e *CoordinatorEngine) tryAdvanceWorkflowInstance(ctx context.Context, projectID int64, userID int64) (*CoordinationResult, bool, error) { + if e == nil || e.instSvc == nil || !e.instSvc.Available() { + return nil, false, nil + } + live, err := e.instSvc.GetSettings(ctx, projectID, userID, true) + if err != nil || live == nil || live.LiveInstance == nil { + return nil, false, nil + } + stageKey := live.ActiveStageKey + if stageKey == "" { + return nil, false, nil + } + readiness, err := e.instSvc.GetStageReadiness(ctx, live.LiveInstance.ID, stageKey, userID, true) + if err != nil { + return nil, true, err + } + if !readiness.Ready { + return &CoordinationResult{ + Status: StatusRejected, + Intent: intent.IntentAdvanceStage, + RejectReason: "STAGE_GATE_NOT_READY", + NaturalReply: fmt.Sprintf("当前阶段尚未满足推进条件,仍有 %d 项阻断。", len(readiness.BlockingIssues)), + }, true, projectinstance.ErrStageGateNotReady + } + result, err := e.instSvc.AdvanceStage(ctx, live.LiveInstance.ID, userID, true, projectinstance.AdvanceStageInput{ + StageKey: stageKey, + ExpectedConfigRevision: readiness.ConfigRevision, + }) + if err != nil { + return nil, true, err + } + reply := fmt.Sprintf("已确认推进阶段【%s】。", stageKey) + if result.InstanceDone { + reply = "工作流已完成。您现在可以配置下一个工作流。" + } else if result.NextStageKey != "" { + reply = fmt.Sprintf("已确认推进至下一阶段【%s】。", result.NextStageKey) + } + return &CoordinationResult{ + Status: StatusAccepted, + Intent: intent.IntentAdvanceStage, + NaturalReply: reply, + }, true, nil +} diff --git a/internal/coordinator/instance_advance_governance_test.go b/internal/coordinator/instance_advance_governance_test.go new file mode 100644 index 00000000..b2bd74d2 --- /dev/null +++ b/internal/coordinator/instance_advance_governance_test.go @@ -0,0 +1,83 @@ +package coordinator + +import ( + "context" + "testing" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/ctxmgr" + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/intent" + "chat-agent-system/internal/kb" + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/squad" + "chat-agent-system/internal/storage" +) + +type countingBizdev struct { + stageCalls int +} + +func (c *countingBizdev) ListTasks() []bizdev.TaskMeta { return nil } +func (c *countingBizdev) ListStages() []bizdev.StageMeta { return nil } +func (c *countingBizdev) ExecuteTask(_ context.Context, _ bizdev.TaskRunRequest) (*bizdev.TaskRunResult, error) { + return &bizdev.TaskRunResult{}, nil +} +func (c *countingBizdev) ExecuteStage(_ context.Context, _ bizdev.StageRunRequest) (*bizdev.StageRunResult, error) { + c.stageCalls++ + return &bizdev.StageRunResult{StageTitle: "legacy"}, nil +} +func (c *countingBizdev) InspectWorkspace(_ context.Context, _ string) (*squad.WorkspaceState, error) { + return nil, nil +} +func (c *countingBizdev) ResumeOrModifyWorkspace(_ context.Context, _ squad.ModifyWorkflowRequest) (*squad.SquadRunResult, error) { + return nil, nil +} +func (c *countingBizdev) ContextManager() *ctxmgr.ContextManager { return nil } +func (c *countingBizdev) CAS() *storage.CAS { return nil } +func (c *countingBizdev) DocGov() *docgov.GovernanceEngine { return nil } +func (c *countingBizdev) KB() *kb.Client { return nil } + +func TestDialogueAdvanceStage_LiveInstanceDoesNotUseBizdev(t *testing.T) { + store := projectinstance.NewMemoryStore() + _ = projectinstance.SeedProjectMeta(store, 55, projectinstance.LifecyclePlanning, "", 1, 0) + instSvc := projectinstance.NewService(store, projectinstance.TestResolver(), nil, &projectinstance.StubDocChecker{States: map[string]string{"intake": "approved"}}) + + ctx := context.Background() + _, _ = instSvc.SaveWorkflowPlan(ctx, 55, 1, true, projectinstance.SavePlanInput{ + Plan: projectinstance.ValidTestPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + preview, _ := instSvc.ActivationPreview(ctx, 55, 1, true) + _, _ = instSvc.Activate(ctx, 55, 1, true, projectinstance.ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + + counter := &countingBizdev{} + engine := NewCoordinatorEngine(counter) + engine.SetInstanceService(instSvc) + + userCtx := &UserProjectContext{ + Project: ProjectInfo{ID: "55", CreatorUserID: 1, CurrentStage: bizdev.StageV01_Triage}, + User: UserInfo{ID: "1", ProjectRole: "owner"}, + } + recRes := &intent.IntentRecognitionResult{ + TopIntent: &intent.CandidateIntent{ + Intent: intent.IntentAdvanceStage, + FilledSlots: map[string]string{ + intent.SlotTargetStage: string(bizdev.StageV05_Feasibility), + }, + }, + } + + res, err := engine.ProcessIntent(ctx, recRes, userCtx) + if err != nil { + t.Fatalf("ProcessIntent: %v", err) + } + if res.Status != StatusAccepted { + t.Fatalf("expected accepted via instance service, got %s (%s)", res.Status, res.RejectReason) + } + if counter.stageCalls != 0 { + t.Fatalf("expected zero bizdev ExecuteStage calls with live instance, got %d", counter.stageCalls) + } +} diff --git a/internal/ctxmgr/manager.go b/internal/ctxmgr/manager.go index 26efa618..49065a38 100644 --- a/internal/ctxmgr/manager.go +++ b/internal/ctxmgr/manager.go @@ -226,6 +226,13 @@ func (m *ContextManager) RecordUserMessageWithID(ctx context.Context, sessionID } m.sessions[sessionID] = sessSum } + if meta != nil { + if pVal, ok := meta["project_id"].(int64); ok && pVal > 0 { + sessSum.ProjectID = pVal + } else if pValFloat, ok := meta["project_id"].(float64); ok && pValFloat > 0 { + sessSum.ProjectID = int64(pValFloat) + } + } sessSum.MessageCount = len(m.messages[sessionID]) sessSum.LastMessage = content sessSum.UpdatedAt = time.Now() diff --git a/internal/ctxmgr/project_dialogue.go b/internal/ctxmgr/project_dialogue.go new file mode 100644 index 00000000..69a0467e --- /dev/null +++ b/internal/ctxmgr/project_dialogue.go @@ -0,0 +1,169 @@ +package ctxmgr + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "chat-agent-system/internal/replyexport" +) + +const defaultProjectDialogueLimit = 120 + +// ProjectDialogueCollectResult 项目级对话采集结果(供上下文快照使用)。 +type ProjectDialogueCollectResult struct { + Lines []string + ExcludedPrivate int + ExcludedNoAccess int + AllowedStageKey string + StageFilteredCount int +} + +// CollectProjectDialogueOptions 采集选项。 +type CollectProjectDialogueOptions struct { + Limit int + AllowedStageKey string // 非空时仅保留无 stage_key 或匹配的对话 +} + +// CollectProjectDialogue 收集进入项目知识边界的共享对话行。 +func (m *ContextManager) CollectProjectDialogue(ctx context.Context, projectID int64, opts CollectProjectDialogueOptions) (*ProjectDialogueCollectResult, error) { + out := &ProjectDialogueCollectResult{AllowedStageKey: opts.AllowedStageKey} + if m == nil || projectID <= 0 { + return out, nil + } + limit := opts.Limit + if limit <= 0 { + limit = defaultProjectDialogueLimit + } + + var msgs []ChatMessageRecord + if m.pool != nil { + rows, err := m.pool.Query(ctx, ` + SELECT cm.id, cm.session_id, COALESCE(cm.user_id, 0), cm.role, cm.content, + cm.intent, cm.branch, cm.metadata, COALESCE(cm.day_key::text, ''), + COALESCE(cm.category, ''), COALESCE(cm.content_kind, ''), + COALESCE(cm.export_policy, 'none'), cm.created_at + FROM chat_messages cm + JOIN chat_sessions cs ON cs.session_id = cm.session_id + WHERE cs.project_id = $1 AND cm.deleted_at IS NULL + ORDER BY cm.created_at ASC + LIMIT $2 + `, projectID, limit) + if err != nil { + return nil, fmt.Errorf("collect project dialogue: %w", err) + } + defer rows.Close() + for rows.Next() { + var rec ChatMessageRecord + var metaJSON []byte + if err := rows.Scan( + &rec.ID, &rec.SessionID, &rec.UserID, &rec.Role, &rec.Content, + &rec.Intent, &rec.Branch, &metaJSON, &rec.DayKey, &rec.Category, + &rec.ContentKind, &rec.ExportPolicy, &rec.CreatedAt, + ); err != nil { + return nil, err + } + if len(metaJSON) > 0 { + _ = json.Unmarshal(metaJSON, &rec.Metadata) + } + msgs = append(msgs, rec) + } + } else { + m.mu.RLock() + for sessionID, sessionMsgs := range m.messages { + sess := m.sessions[sessionID] + if sess != nil && sess.ProjectID != projectID { + continue + } + for _, rec := range sessionMsgs { + if sess == nil || sess.ProjectID == 0 { + if pid := messageProjectID(rec); pid != projectID { + continue + } + } + msgs = append(msgs, rec) + } + } + m.mu.RUnlock() + } + + for _, rec := range msgs { + line, include, excludedPrivate, excludedNoAccess := classifyDialogueLine(rec, opts.AllowedStageKey) + if excludedPrivate { + out.ExcludedPrivate++ + } + if excludedNoAccess { + out.ExcludedNoAccess++ + out.StageFilteredCount++ + } + if !include || line == "" { + continue + } + out.Lines = append(out.Lines, line) + } + return out, nil +} + +func messageProjectID(rec ChatMessageRecord) int64 { + if rec.Metadata == nil { + return 0 + } + if pVal, ok := rec.Metadata["project_id"].(int64); ok { + return pVal + } + if pVal, ok := rec.Metadata["project_id"].(float64); ok { + return int64(pVal) + } + return 0 +} + +func classifyDialogueLine(rec ChatMessageRecord, allowedStageKey string) (line string, include bool, excludedPrivate bool, excludedNoAccess bool) { + content := strings.TrimSpace(rec.Content) + if content == "" { + return "", false, true, false + } + if strings.HasPrefix(content, "[private]") || strings.HasPrefix(content, "[draft]") { + return "", false, true, false + } + if vis, _ := rec.Metadata["visibility"].(string); vis == "private" { + return "", false, true, false + } + + stageKey := MessageStageKey(rec.Metadata) + if allowedStageKey != "" && stageKey != "" && stageKey != allowedStageKey { + return "", false, false, true + } + + switch rec.Role { + case "user": + line = fmt.Sprintf("用户: %s", content) + return line, true, false, false + case "assistant": + switch rec.ContentKind { + case string(replyexport.KindActionCard), string(replyexport.KindStatusUpdate), + string(replyexport.KindErrorNotice), string(replyexport.KindSystemNotice): + return "", false, true, false + } + switch rec.ExportPolicy { + case string(replyexport.PolicyFull), string(replyexport.PolicyCopyOnly): + line = fmt.Sprintf("助手: %s", content) + return line, true, false, false + default: + return "", false, true, false + } + default: + return "", false, true, false + } +} + +// MessageStageKey 从消息 metadata 提取阶段键。 +func MessageStageKey(metadata map[string]any) string { + if metadata == nil { + return "" + } + if sk, ok := metadata["stage_key"].(string); ok { + return sk + } + return "" +} diff --git a/internal/ctxmgr/project_dialogue_test.go b/internal/ctxmgr/project_dialogue_test.go new file mode 100644 index 00000000..85f6518f --- /dev/null +++ b/internal/ctxmgr/project_dialogue_test.go @@ -0,0 +1,60 @@ +package ctxmgr + +import ( + "context" + "testing" + + "chat-agent-system/internal/replyexport" +) + +func TestCollectProjectDialogue_ExcludesPrivateAndIncludesShared(t *testing.T) { + ctx := context.Background() + mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig()) + projectID := int64(501) + meta := map[string]any{"project_id": projectID} + + _, _ = mgr.RecordUserMessageWithID(ctx, "sess-501", 1, "user", "[private] 不应纳入", "", "", meta) + _, _ = mgr.RecordUserMessageWithID(ctx, "sess-501", 1, "user", "项目需要支持多用户协作", "", "", meta) + _, _ = mgr.RecordUserMessageWithID(ctx, "sess-501", 1, "assistant", "好的,我会整理需求要点。", "", "", map[string]any{ + "project_id": projectID, + "export_policy": string(replyexport.PolicyFull), + "content_kind": string(replyexport.KindDocumentText), + }) + + res, err := mgr.CollectProjectDialogue(ctx, projectID, CollectProjectDialogueOptions{}) + if err != nil { + t.Fatalf("CollectProjectDialogue: %v", err) + } + if len(res.Lines) != 2 { + t.Fatalf("expected 2 shared lines, got %d: %v", len(res.Lines), res.Lines) + } + if res.ExcludedPrivate < 1 { + t.Fatalf("expected excluded private >= 1, got %d", res.ExcludedPrivate) + } +} + +func TestCollectProjectDialogue_StageFilter(t *testing.T) { + ctx := context.Background() + mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig()) + projectID := int64(502) + + _, _ = mgr.RecordUserMessageWithID(ctx, "sess-502", 1, "user", "立项阶段讨论", "", "", map[string]any{ + "project_id": projectID, "stage_key": "biz_intake", + }) + _, _ = mgr.RecordUserMessageWithID(ctx, "sess-502", 1, "user", "研发阶段讨论", "", "", map[string]any{ + "project_id": projectID, "stage_key": "biz_feasibility", + }) + + res, err := mgr.CollectProjectDialogue(ctx, projectID, CollectProjectDialogueOptions{ + AllowedStageKey: "biz_intake", + }) + if err != nil { + t.Fatalf("CollectProjectDialogue: %v", err) + } + if len(res.Lines) != 1 { + t.Fatalf("expected 1 stage-filtered line, got %d: %v", len(res.Lines), res.Lines) + } + if res.ExcludedNoAccess != 1 { + t.Fatalf("expected 1 excluded by stage, got %d", res.ExcludedNoAccess) + } +} diff --git a/internal/db/testdb.go b/internal/db/testdb.go new file mode 100644 index 00000000..b71f192f --- /dev/null +++ b/internal/db/testdb.go @@ -0,0 +1,29 @@ +package db + +import ( + "os" + "strings" + "testing" +) + +const testDatabaseEnv = "TEST_DATABASE_URL" + +// RequireTestDatabaseURL 集成测试必须显式提供隔离测试库,禁止默认连开发库。 +func RequireTestDatabaseURL(t *testing.T) string { + t.Helper() + dsn := strings.TrimSpace(os.Getenv(testDatabaseEnv)) + if dsn == "" { + t.Fatalf("%s must be set for PostgreSQL integration tests (refusing default dev database)", testDatabaseEnv) + } + return dsn +} + +// SkipIfNoTestDatabaseURL 无隔离测试库时跳过(单元测试包内可选 PG 用例)。 +func SkipIfNoTestDatabaseURL(t *testing.T) string { + t.Helper() + dsn := strings.TrimSpace(os.Getenv(testDatabaseEnv)) + if dsn == "" { + t.Skipf("%s not set; skipping PostgreSQL integration test", testDatabaseEnv) + } + return dsn +} diff --git a/internal/kb/integration_test.go b/internal/kb/integration_test.go index 8ad281de..0fc64e1c 100644 --- a/internal/kb/integration_test.go +++ b/internal/kb/integration_test.go @@ -2,6 +2,7 @@ package kb_test import ( "context" + "os" "strings" "testing" "time" @@ -11,6 +12,12 @@ import ( ) func TestKB_RealIntegration(t *testing.T) { + if testing.Short() { + t.Skip("skipping KB real integration in short mode") + } + if os.Getenv("RUN_KB_INTEGRATION") != "1" { + t.Skip("set RUN_KB_INTEGRATION=1 to run KB real integration") + } cfg, err := config.Load("") if err != nil { t.Fatalf("failed to load config: %v", err) diff --git a/internal/projectcontext/service.go b/internal/projectcontext/service.go new file mode 100644 index 00000000..197c75a8 --- /dev/null +++ b/internal/projectcontext/service.go @@ -0,0 +1,292 @@ +package projectcontext + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "chat-agent-system/internal/squad" +) + +const ( + AuthorityConfirmedFact = "confirmed_fact" + AuthorityApprovedDoc = "approved_document" + AuthorityUpload = "upload_material" + AuthorityMilestone = "milestone" + AuthorityDialogue = "dialogue" + AuthorityKnowledge = "knowledge" +) + +// Source 来源清单条目(全量枚举,按任务检索后分级注入)。 +type Source struct { + Type string `json:"type"` + ID string `json:"id"` + Version string `json:"version,omitempty"` + ContentHash string `json:"content_hash"` + AuthorityLevel string `json:"authority_level"` + PermissionScope string `json:"permission_scope"` + Referenced bool `json:"referenced"` + Snippet string `json:"snippet,omitempty"` +} + +// Snapshot 不可变项目上下文快照。 +type Snapshot struct { + ID int64 `json:"id"` + ProjectID int64 `json:"project_id"` + TaskKey string `json:"task_key"` + CutoffAt time.Time `json:"cutoff_at"` + AggregateHash string `json:"aggregate_hash"` + SourceCount int `json:"source_count"` + ExcludedPrivateCount int `json:"excluded_private_count"` + ExcludedNoAccessCount int `json:"excluded_no_access_count"` + Sources []Source `json:"sources"` +} + +// BuildInput 构建快照所需输入。 +type BuildInput struct { + ProjectID int64 + TaskKey string + UserID int64 + ProjectName string + Facts map[string]string + Materials []squad.MaterialItem + PriorDocs []squad.PriorDocItem + Knowledge []squad.KnowledgeHit + Dialogue []string + Decisions []string +} + +// Service 项目上下文快照服务。 +type Service struct { + pool *pgxpool.Pool +} + +func NewService(pool *pgxpool.Pool) *Service { + return &Service{pool: pool} +} + +func (s *Service) Available() bool { + return s != nil && s.pool != nil +} + +// BuildSnapshot 建立来源清单并按任务检索相关片段(不 dump 全量原文)。 +func (s *Service) BuildSnapshot(ctx context.Context, in BuildInput) (*Snapshot, error) { + cutoff := time.Now() + sources, excludedPrivate, excludedNoAccess := enumerateSources(in) + sources = selectForTask(in.TaskKey, sources) + aggHash := aggregateHash(sources) + + snap := &Snapshot{ + ProjectID: in.ProjectID, + TaskKey: in.TaskKey, + CutoffAt: cutoff, + AggregateHash: aggHash, + SourceCount: len(sources), + ExcludedPrivateCount: excludedPrivate, + ExcludedNoAccessCount: excludedNoAccess, + Sources: sources, + } + if !s.Available() || in.ProjectID <= 0 { + return snap, nil + } + + var id int64 + err := s.pool.QueryRow(ctx, ` + INSERT INTO project_context_snapshots (project_id, task_key, cutoff_at, aggregate_hash, source_count, created_by, frozen) + VALUES ($1, $2, $3, $4, $5, $6, true) RETURNING id + `, in.ProjectID, in.TaskKey, cutoff, aggHash, len(sources), in.UserID).Scan(&id) + if err != nil { + return snap, err + } + snap.ID = id + for _, src := range sources { + _, _ = s.pool.Exec(ctx, ` + INSERT INTO project_context_snapshot_sources + (snapshot_id, source_type, source_id, source_version, content_hash, authority_level, permission_scope, referenced) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (snapshot_id, source_type, source_id, source_version) DO NOTHING + `, id, src.Type, src.ID, src.Version, src.ContentHash, src.AuthorityLevel, src.PermissionScope, src.Referenced) + } + return snap, nil +} + +// MaterialsForWorkspace 按权威等级与任务目标注入工作区材料(核心固定 + 相关分批)。 +func MaterialsForWorkspace(snap *Snapshot, maxItems int) []squad.MaterialItem { + if snap == nil || len(snap.Sources) == 0 { + return nil + } + if maxItems <= 0 { + maxItems = 12 + } + out := make([]squad.MaterialItem, 0, maxItems) + for _, src := range snap.Sources { + if !src.Referenced || strings.TrimSpace(src.Snippet) == "" { + continue + } + out = append(out, squad.MaterialItem{ + Filename: fmt.Sprintf("%s_%s.md", src.Type, src.ID), + Content: []byte(src.Snippet), + }) + if len(out) >= maxItems { + break + } + } + return out +} + +func enumerateSources(in BuildInput) ([]Source, int, int) { + out := make([]Source, 0) + excludedPrivate := 0 + excludedNoAccess := 0 + for k, v := range in.Facts { + if strings.TrimSpace(v) == "" { + continue + } + out = append(out, Source{ + Type: AuthorityConfirmedFact, ID: k, ContentHash: hashText(v), + AuthorityLevel: AuthorityConfirmedFact, PermissionScope: "project", + }) + } + for i, m := range in.Materials { + body := string(m.Content) + if len(body) == 0 { + continue + } + id := m.Filename + if id == "" { + id = fmt.Sprintf("material_%d", i) + } + out = append(out, Source{ + Type: "upload_material", ID: id, ContentHash: hashText(body), + AuthorityLevel: AuthorityUpload, PermissionScope: "project", Snippet: truncate(body, 4000), + }) + } + for _, d := range in.PriorDocs { + body := d.Content + if body == "" { + continue + } + id := d.DocKey + if id == "" { + id = d.Title + } + out = append(out, Source{ + Type: "document", ID: id, Version: d.Stage, ContentHash: hashText(body), + AuthorityLevel: AuthorityApprovedDoc, PermissionScope: "project", Snippet: truncate(body, 4000), + }) + } + for i, line := range in.Dialogue { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + excludedPrivate++ + continue + } + if strings.HasPrefix(trimmed, "[private]") || strings.HasPrefix(trimmed, "[draft]") { + excludedPrivate++ + continue + } + out = append(out, Source{ + Type: "dialogue", ID: fmt.Sprintf("turn_%d", i), ContentHash: hashText(trimmed), + AuthorityLevel: AuthorityDialogue, PermissionScope: "project_shared", Snippet: truncate(trimmed, 2000), + }) + } + for i, d := range in.Decisions { + out = append(out, Source{ + Type: "decision", ID: fmt.Sprintf("decision_%d", i), ContentHash: hashText(d), + AuthorityLevel: AuthorityConfirmedFact, PermissionScope: "project", Snippet: truncate(d, 2000), + }) + } + for _, h := range in.Knowledge { + body := h.Content + if body == "" { + body = h.Title + } + out = append(out, Source{ + Type: "knowledge", ID: h.CiteKey, ContentHash: hashText(body), + AuthorityLevel: AuthorityKnowledge, PermissionScope: "global_kb", Snippet: truncate(body, 2000), + }) + } + sort.Slice(out, func(i, j int) bool { + return authorityRank(out[i].AuthorityLevel) < authorityRank(out[j].AuthorityLevel) + }) + return out, excludedPrivate, excludedNoAccess +} + +func selectForTask(taskKey string, sources []Source) []Source { + if len(sources) == 0 { + return sources + } + keywords := taskKeywords(taskKey) + for i := range sources { + src := &sources[i] + if src.AuthorityLevel == AuthorityConfirmedFact || src.AuthorityLevel == AuthorityApprovedDoc { + src.Referenced = true + continue + } + if len(keywords) == 0 { + src.Referenced = src.AuthorityLevel != AuthorityDialogue + continue + } + hay := strings.ToLower(src.Snippet + " " + src.ID + " " + src.Type) + for _, kw := range keywords { + if strings.Contains(hay, kw) { + src.Referenced = true + break + } + } + } + return sources +} + +func taskKeywords(taskKey string) []string { + switch taskKey { + case "system_reqs": + return []string{"需求", "requirement", "功能", "system_reqs"} + case "test_manager": + return []string{"测试", "验证", "风险", "test", "verification", "requirement"} + default: + return nil + } +} + +func authorityRank(level string) int { + switch level { + case AuthorityConfirmedFact: + return 0 + case AuthorityApprovedDoc: + return 1 + case AuthorityUpload: + return 2 + case AuthorityMilestone: + return 3 + case AuthorityDialogue: + return 4 + default: + return 5 + } +} + +func aggregateHash(sources []Source) string { + raw, _ := json.Marshal(sources) + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]) +} + +func hashText(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] + "\n…(truncated)" +} diff --git a/internal/projectcontext/service_test.go b/internal/projectcontext/service_test.go new file mode 100644 index 00000000..37a409aa --- /dev/null +++ b/internal/projectcontext/service_test.go @@ -0,0 +1,62 @@ +package projectcontext + +import ( + "testing" + + "chat-agent-system/internal/squad" +) + +func TestBuildSnapshot_ConsistentForSameInput(t *testing.T) { + svc := NewService(nil) + in := BuildInput{ + ProjectID: 1, + TaskKey: "system_reqs", + UserID: 1, + Facts: map[string]string{"product_type": "超声设备"}, + Materials: []squad.MaterialItem{{Filename: "note.md", Content: []byte("材料说明")}}, + Dialogue: []string{"项目需要 CE 认证"}, + Decisions: []string{"目标市场:欧盟"}, + } + s1, err := svc.BuildSnapshot(t.Context(), in) + if err != nil { + t.Fatalf("BuildSnapshot: %v", err) + } + s2, err := svc.BuildSnapshot(t.Context(), in) + if err != nil { + t.Fatalf("BuildSnapshot second: %v", err) + } + if s1.AggregateHash != s2.AggregateHash { + t.Fatalf("expected reproducible hash, got %s vs %s", s1.AggregateHash, s2.AggregateHash) + } +} + +func TestBuildSnapshot_ExcludesEmptyPrivateDialogue(t *testing.T) { + svc := NewService(nil) + snap, err := svc.BuildSnapshot(t.Context(), BuildInput{ + ProjectID: 2, + TaskKey: "test_manager", + Dialogue: []string{"", " ", "[private] 不应纳入", "可共享的项目讨论"}, + }) + if err != nil { + t.Fatalf("BuildSnapshot: %v", err) + } + if snap.SourceCount != 1 { + t.Fatalf("expected 1 shared dialogue source, got %d", snap.SourceCount) + } + if snap.ExcludedPrivateCount < 3 { + t.Fatalf("expected excluded private count >= 3, got %d", snap.ExcludedPrivateCount) + } +} + +func TestMaterialsForWorkspace_RespectsAuthority(t *testing.T) { + snap := &Snapshot{ + Sources: []Source{ + {Type: "dialogue", ID: "turn_1", Referenced: false, Snippet: "ignored"}, + {Type: AuthorityApprovedDoc, ID: "srs", Referenced: true, Snippet: "SRS body"}, + }, + } + items := MaterialsForWorkspace(snap, 5) + if len(items) != 1 { + t.Fatalf("expected 1 referenced material, got %d", len(items)) + } +} diff --git a/internal/projectinstance/activation_official_pg_test.go b/internal/projectinstance/activation_official_pg_test.go new file mode 100644 index 00000000..55d848a1 --- /dev/null +++ b/internal/projectinstance/activation_official_pg_test.go @@ -0,0 +1,99 @@ +package projectinstance_test + +import ( + "context" + "testing" + "time" + + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/projectworkflow" + "chat-agent-system/internal/workflowdef" +) + +// TestPG_ActivateOfficialWorkflowRuntimeMetadata 回归激活链路: +// ActivateInstance.applyRuntimeMetadata 会遍历文档任务实例并逐条 UPDATE。 +// 若在同一事务连接上 rows 未读完时就执行 Exec,pgx 会返回 "conn busy"。 +// 本测试使用正式商务对接流程(3 阶段 7 任务)覆盖该路径。 +func TestPG_ActivateOfficialWorkflowRuntimeMetadata(t *testing.T) { + pool := openPGPool(t) + defer pool.Close() + ctx := context.Background() + + wfStore := workflowdef.NewStore(pool) + def, err := workflowdef.LoadManifestDefinition("main.v2.json") + if err != nil { + t.Fatalf("load main.v2 manifest: %v", err) + } + if err := wfStore.UpsertPublishedSeed(ctx, workflowdef.CreateDefinitionInput{ + Key: workflowdef.OfficialKeyMain, + Name: "商务对接流程", + Definition: def, + }, 1); err != nil { + t.Fatalf("seed official main workflow: %v", err) + } + + pwStore := projectworkflow.NewStore(pool) + instStore := projectinstance.NewStore(pool) + svc := projectinstance.NewService(instStore, wfStore, pwStore, nil) + + var projectID int64 + code := "pg-official-" + 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 Official Activation Test', 'active', 1, '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, workflowdef.OfficialKeyMain, 0) + if err != nil { + t.Fatalf("default plan: %v", err) + } + for i := range plan.Stages { + for j := range plan.Stages[i].DocTasks { + plan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{ + AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3, + } + } + } + + if _, err := svc.SaveWorkflowPlan(ctx, projectID, 1, true, projectinstance.SavePlanInput{ + Plan: *plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }); err != nil { + t.Fatalf("save plan: %v", err) + } + preview, err := svc.ActivationPreview(ctx, projectID, 1, true) + if err != nil { + t.Fatalf("preview: %v", err) + } + activated, err := svc.Activate(ctx, projectID, 1, true, projectinstance.ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if err != nil { + t.Fatalf("activate: %v", err) + } + if activated.Instance.Status != projectinstance.InstanceActive { + t.Fatalf("expected active instance, got %q", activated.Instance.Status) + } + + // 运行时元数据应写回文档任务实例(回归 applyRuntimeMetadata 的逐条 UPDATE)。 + var executorKey string + err = pool.QueryRow(ctx, ` + SELECT dti.executor_key + FROM project_doc_task_instances dti + JOIN project_stage_instances si ON si.id = dti.stage_instance_id + WHERE si.workflow_instance_id = $1 AND dti.task_key = 'intake' + `, activated.Instance.ID).Scan(&executorKey) + if err != nil { + t.Fatalf("query runtime metadata: %v", err) + } + if executorKey != "bizdev.intake.v1" { + t.Fatalf("expected executor_key bizdev.intake.v1, got %q", executorKey) + } +} diff --git a/internal/projectinstance/activation_token_test.go b/internal/projectinstance/activation_token_test.go new file mode 100644 index 00000000..151aeace --- /dev/null +++ b/internal/projectinstance/activation_token_test.go @@ -0,0 +1,80 @@ +package projectinstance + +import ( + "context" + "errors" + "sync" + "testing" +) + +func TestActivationToken_OneTimeUse(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 200, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, nil) + + _, _ = svc.SaveWorkflowPlan(ctx, 200, 1, true, SavePlanInput{ + Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + preview, err := svc.ActivationPreview(ctx, 200, 1, true) + if err != nil { + t.Fatalf("ActivationPreview: %v", err) + } + + _, err = svc.Activate(ctx, 200, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if err != nil { + t.Fatalf("first activate: %v", err) + } + + _, err = svc.Activate(ctx, 200, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if !errors.Is(err, ErrTokenInvalid) && !errors.Is(err, ErrPlanFrozen) { + t.Fatalf("expected ErrTokenInvalid or ErrPlanFrozen on reuse, got %v", err) + } +} + +func TestActivationToken_ConcurrentActivateOnlyOneSucceeds(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 201, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, nil) + + _, _ = svc.SaveWorkflowPlan(ctx, 201, 1, true, SavePlanInput{ + Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + preview, err := svc.ActivationPreview(ctx, 201, 1, true) + if err != nil { + t.Fatalf("ActivationPreview: %v", err) + } + + var wg sync.WaitGroup + successes := 0 + var mu sync.Mutex + for i := 0; i < 3; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, actErr := svc.Activate(ctx, 201, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if actErr == nil { + mu.Lock() + successes++ + mu.Unlock() + } + }() + } + wg.Wait() + if successes != 1 { + t.Fatalf("expected exactly 1 successful activation, got %d", successes) + } +} diff --git a/internal/projectinstance/approval_test.go b/internal/projectinstance/approval_test.go index 034b6c27..14dd1c43 100644 --- a/internal/projectinstance/approval_test.go +++ b/internal/projectinstance/approval_test.go @@ -3,19 +3,21 @@ package projectinstance import ( "context" "testing" + + "chat-agent-system/internal/workflowdef" ) func TestHandleDocumentApproved_StageReady(t *testing.T) { ctx := context.Background() store := newMemStore() - _ = SeedProjectMeta(store, 1, LifecycleActive, "medical_device_development", 1, 1) + _ = SeedProjectMeta(store, 1, LifecycleActive, workflowdef.OfficialKeyMain, 1, 1) svc := NewService(store, testResolver(), nil, &stubDocChecker{states: map[string]string{"intake": "approved"}}) plan := WorkflowPlan{ - WorkflowDefinitionKey: "medical_device_development", + WorkflowDefinitionKey: workflowdef.OfficialKeyMain, WorkflowVersion: 1, Stages: []StagePlan{{ - StageKey: "biz_intake", Enabled: true, + StageKey: "V0.1_triage", Enabled: true, DocTasks: []DocTaskPlan{{ TaskKey: "intake", Enabled: true, Duties: DocDuty{AuthorUserID: 2, ReviewerUserID: 3, ApproverUserID: 4}, @@ -26,7 +28,8 @@ func TestHandleDocumentApproved_StageReady(t *testing.T) { 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) + version := &workflowdef.Version{WorkflowKey: workflowdef.OfficialKeyMain, Version: 1} + activated, err := store.ActivateInstance(ctx, 1, inst, version, 1, 2) if err != nil { t.Fatalf("activate: %v", err) } @@ -38,7 +41,7 @@ func TestHandleDocumentApproved_StageReady(t *testing.T) { if result == nil || !result.StageReady { t.Fatalf("expected stage ready, got %+v", result) } - if result.InstanceID != activated.ID || result.StageKey != "biz_intake" { + if result.InstanceID != activated.ID || result.StageKey != "V0.1_triage" { t.Fatalf("unexpected result: %+v", result) } } diff --git a/internal/projectinstance/document_access.go b/internal/projectinstance/document_access.go new file mode 100644 index 00000000..91d20782 --- /dev/null +++ b/internal/projectinstance/document_access.go @@ -0,0 +1,132 @@ +package projectinstance + +import ( + "context" +) + +const ( + DocAccessActionView = "view" + DocAccessActionEdit = "edit" + DocAccessActionSubmit = "submit" + DocAccessActionReview = "review" + DocAccessActionApprove = "approve" + DocAccessActionRun = "run" + + AccessCodeDocTaskDisabled = "DOC_TASK_DISABLED" + AccessCodeNotCurrentStage = "DOC_TASK_NOT_IN_ACTIVE_STAGE" + AccessCodeForbiddenDuty = "DOC_DUTY_FORBIDDEN" + AccessCodeInstanceClosed = "WORKFLOW_INSTANCE_CLOSED" +) + +// DocAccessDecision 统一文档任务访问判定结果。 +type DocAccessDecision struct { + Allowed bool `json:"allowed"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +// CheckDocTaskAccess 对 live 治理实例下的文档任务做 fail-closed 判定。 +// 无 live 实例时不限制(legacy 兼容)。 +func (s *Service) CheckDocTaskAccess( + ctx context.Context, + projectID int64, + userID int64, + taskKey string, + action string, + isCreator bool, +) DocAccessDecision { + allow := DocAccessDecision{Allowed: true} + if s == nil || s.store == nil || projectID <= 0 || userID <= 0 || taskKey == "" { + return allow + } + + live, err := s.store.GetLiveInstance(ctx, projectID) + if err != nil || live == nil { + return allow + } + if live.Status == InstanceCompleted || live.Status == "terminated" { + return DocAccessDecision{ + Allowed: false, + Code: AccessCodeInstanceClosed, + Message: "工作流实例已结束", + } + } + + detail, err := s.store.GetInstanceByID(ctx, live.ID) + if err != nil || detail == nil { + return DocAccessDecision{Allowed: false, Code: AccessCodeInstanceClosed, Message: "工作流实例不可用"} + } + + var docInst *DocTaskInstance + var stageKey string + for _, st := range detail.Stages { + for i := range st.DocTasks { + if st.DocTasks[i].TaskKey == taskKey { + copyDoc := st.DocTasks[i] + docInst = ©Doc + stageKey = st.StageKey + break + } + } + if docInst != nil { + break + } + } + if docInst == nil { + return allow + } + + if docInst.Status == "skipped_disabled" { + return DocAccessDecision{ + Allowed: false, + Code: AccessCodeDocTaskDisabled, + Message: "该文档已被创建者禁用", + } + } + + if stageKey != "" && stageKey != detail.ActiveStageKey { + if action != DocAccessActionView { + return DocAccessDecision{ + Allowed: false, + Code: AccessCodeNotCurrentStage, + Message: "文档不在当前活动阶段", + } + } + } + + if action == DocAccessActionView { + return allow + } + + planTask := findDocTaskPlan(live.ConfigSnapshot, taskKey) + if planTask == nil || !planTask.Enabled { + return DocAccessDecision{ + Allowed: false, + Code: AccessCodeDocTaskDisabled, + Message: "该文档已被创建者禁用", + } + } + + if isCreator { + return allow + } + if docTaskDutyForUser(*planTask, userID) { + return allow + } + return DocAccessDecision{ + Allowed: false, + Code: AccessCodeForbiddenDuty, + Message: "当前用户不是该文档职责人", + } +} + +func findDocTaskPlan(plan WorkflowPlan, taskKey string) *DocTaskPlan { + for _, st := range plan.Stages { + for i := range st.DocTasks { + if st.DocTasks[i].TaskKey == taskKey { + return &st.DocTasks[i] + } + } + } + return nil +} diff --git a/internal/projectinstance/document_access_test.go b/internal/projectinstance/document_access_test.go new file mode 100644 index 00000000..b7a259bf --- /dev/null +++ b/internal/projectinstance/document_access_test.go @@ -0,0 +1,58 @@ +package projectinstance + +import ( + "context" + "testing" + + "chat-agent-system/internal/workflowdef" +) + +func TestCheckDocTaskAccess_DisabledDocumentRejected(t *testing.T) { + ctx := context.Background() + store := NewMemoryStore() + _ = SeedProjectMeta(store, 400, LifecyclePlanning, "", 1, 0) + resolver := disabledDocResolver() + svc := NewService(store, resolver, nil, nil) + + version := resolver.versions[workflowdef.OfficialKeyMain] + plan := buildDefaultPlan(version) + for i := range plan.Stages[0].DocTasks { + if plan.Stages[0].DocTasks[i].TaskKey == "research" { + plan.Stages[0].DocTasks[i].Enabled = false + continue + } + plan.Stages[0].DocTasks[i].Duties = DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3} + } + _, _ = svc.SaveWorkflowPlan(ctx, 400, 1, true, SavePlanInput{Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1}) + preview, _ := svc.ActivationPreview(ctx, 400, 1, true) + _, _ = svc.Activate(ctx, 400, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + + decision := svc.CheckDocTaskAccess(ctx, 400, 1, "research", DocAccessActionRun, true) + if decision.Allowed || decision.Code != AccessCodeDocTaskDisabled { + t.Fatalf("expected DOC_TASK_DISABLED, got %+v", decision) + } +} + +func TestCheckDocTaskAccess_NonDutyUserRejected(t *testing.T) { + ctx := context.Background() + store := NewMemoryStore() + _ = SeedProjectMeta(store, 401, LifecyclePlanning, "", 1, 0) + svc := NewService(store, TestResolver(), nil, nil) + + _, _ = svc.SaveWorkflowPlan(ctx, 401, 1, true, SavePlanInput{ + Plan: ValidTestPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + preview, _ := svc.ActivationPreview(ctx, 401, 1, true) + _, _ = svc.Activate(ctx, 401, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + + decision := svc.CheckDocTaskAccess(ctx, 401, 99, "intake", DocAccessActionRun, false) + if decision.Allowed || decision.Code != AccessCodeForbiddenDuty { + t.Fatalf("expected DOC_DUTY_FORBIDDEN, got %+v", decision) + } +} diff --git a/internal/projectinstance/errors.go b/internal/projectinstance/errors.go index 36036846..13147ace 100644 --- a/internal/projectinstance/errors.go +++ b/internal/projectinstance/errors.go @@ -1,24 +1,47 @@ package projectinstance -import "errors" +import ( + "errors" + + "chat-agent-system/internal/workflowdef" +) 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") + 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") + ErrEmptyWorkflow = errors.New("empty workflow: at least one enabled document required to activate") + ErrTerminationNotAllowed = errors.New("termination not allowed for instance state") ) + +// ValidationError 保留具体校验问题,供 HTTP 层返回可操作的错误信息。 +type ValidationError struct { + Issues []workflowdef.ValidationIssue +} + +func (e *ValidationError) Error() string { + return ErrValidationFailed.Error() +} + +func (e *ValidationError) Unwrap() error { + return ErrValidationFailed +} + +func newValidationError(result workflowdef.ValidationResult) error { + return &ValidationError{Issues: result.Issues} +} diff --git a/internal/projectinstance/gate.go b/internal/projectinstance/gate.go index f5d95f1a..5eefd7c5 100644 --- a/internal/projectinstance/gate.go +++ b/internal/projectinstance/gate.go @@ -25,7 +25,7 @@ func evaluateStageReadiness( continue } td, hasDef := taskDefMap[dt.TaskKey] - required := !hasDef || td.Required || td.Blocking + required := !hasDef || td.BlocksWhenEnabled() state := docStates[dt.TaskKey] if state == "" { state = "missing" diff --git a/internal/projectinstance/pg_integration_test.go b/internal/projectinstance/pg_integration_test.go index b450cf1c..e0e71db4 100644 --- a/internal/projectinstance/pg_integration_test.go +++ b/internal/projectinstance/pg_integration_test.go @@ -2,7 +2,6 @@ package projectinstance_test import ( "context" - "os" "testing" "time" @@ -18,10 +17,7 @@ import ( 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" - } + dsn := db.SkipIfNoTestDatabaseURL(t) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() pool, err := pgxpool.New(ctx, dsn) diff --git a/internal/projectinstance/service.go b/internal/projectinstance/service.go index 4a3f1ffd..9d14ad6a 100644 --- a/internal/projectinstance/service.go +++ b/internal/projectinstance/service.go @@ -95,16 +95,20 @@ func (s *Service) GetSettings(ctx context.Context, projectID int64, userID int64 defs, listErr := s.wfStore.ListDefinitions(ctx) if listErr == nil { for _, d := range defs { - if d.LatestPublishedVersion <= 0 { + if d.LatestPublishedVersion <= 0 || !workflowdef.IsOfficialWorkflowKey(d.Key) { continue } ver, verErr := s.wfStore.GetVersion(ctx, d.ID, d.LatestPublishedVersion) if verErr != nil || ver == nil { continue } + name := d.Name + if name == "" || name == d.Key { + name = workflowdef.OfficialWorkflowName(d.Key) + } settings.PublishedWorkflows = append(settings.PublishedWorkflows, PublishedOption{ DefinitionKey: d.Key, - DefinitionName: d.Name, + DefinitionName: name, LatestVersion: d.LatestPublishedVersion, WorkflowVersion: ver.ID, ProductDomain: d.ProductDomain, @@ -147,10 +151,11 @@ func (s *Service) SaveWorkflowPlan(ctx context.Context, projectID int64, userID } validation := validatePlan(in.Plan, version) if !validation.Valid { - return nil, fmt.Errorf("%w: %d issues", ErrValidationFailed, len(validation.Issues)) + return nil, newValidationError(validation) } in.Plan.WorkflowVersionID = version.ID + applyDefinitionLabels(&in.Plan, version) planHash, err := computePlanHash(in.Plan) if err != nil { return nil, err @@ -197,9 +202,9 @@ func (s *Service) ActivationPreview(ctx context.Context, projectID int64, userID if err != nil { return nil, err } - validation := validatePlan(planInst.ConfigSnapshot, version) + validation := validateActivationPlan(planInst.ConfigSnapshot, version) if !validation.Valid { - return nil, fmt.Errorf("%w", ErrValidationFailed) + return nil, newValidationError(validation) } token, err := randomToken(24) @@ -248,28 +253,16 @@ func (s *Service) Activate(ctx context.Context, projectID int64, userID int64, i 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) + validation := validateActivationPlan(planInst.ConfigSnapshot, version) if !validation.Valid { return nil, ErrValidationFailed } - activated, err := s.store.ActivateInstance(ctx, projectID, planInst, version.WorkflowKey, selVer, userID) + activated, err := s.store.ActivateInstanceWithToken(ctx, in.ConfirmationToken, projectID, planInst, version, selVer, userID, planInst.PlanHash) if err != nil { return nil, err } @@ -282,10 +275,33 @@ func (s *Service) Activate(ctx context.Context, projectID int64, userID int64, i }, nil } +// GetLiveInstanceDetail 返回 live 实例详情与已发布版本定义(运行时编排入口)。 +func (s *Service) GetLiveInstanceDetail(ctx context.Context, projectID int64) (*InstanceDetail, *workflowdef.Version, error) { + if s == nil || s.store == nil { + return nil, nil, ErrNotAvailable + } + live, err := s.store.GetLiveInstance(ctx, projectID) + if err != nil || live == nil { + return nil, nil, fmt.Errorf("live instance not found") + } + detail, err := s.store.GetInstanceByID(ctx, live.ID) + if err != nil { + return nil, nil, err + } + version, err := s.resolvePublishedVersion(ctx, live.ConfigSnapshot) + if err != nil { + return nil, nil, err + } + return detail, version, nil +} + func (s *Service) BuildDefaultPlan(ctx context.Context, workflowKey string, versionNum int) (*WorkflowPlan, error) { if s.wfStore == nil { return nil, ErrNotAvailable } + if !workflowdef.IsOfficialWorkflowKey(workflowKey) { + return nil, ErrWorkflowVersionNotFound + } def, err := s.wfStore.GetDefinitionByKey(ctx, workflowKey) if err != nil { return nil, ErrWorkflowVersionNotFound diff --git a/internal/projectinstance/service_test.go b/internal/projectinstance/service_test.go index 355c6c46..c7ba5170 100644 --- a/internal/projectinstance/service_test.go +++ b/internal/projectinstance/service_test.go @@ -8,72 +8,6 @@ import ( "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() @@ -86,7 +20,7 @@ func TestService_PlanPreviewActivate(t *testing.T) { if err != nil { t.Fatalf("SaveWorkflowPlan: %v", err) } - if settings.Plan == nil || settings.Plan.WorkflowDefinitionKey != "medical_device_development" { + if settings.Plan == nil || settings.Plan.WorkflowDefinitionKey != workflowdef.OfficialKeyMain { t.Fatalf("expected saved plan, got %+v", settings.Plan) } @@ -99,14 +33,14 @@ func TestService_PlanPreviewActivate(t *testing.T) { } result, err := svc.Activate(ctx, 42, 1, true, ActivateInput{ - ConfirmationToken: preview.ConfirmationToken, + 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" { + if result.LifecycleStatus != LifecycleActive || result.ActiveWorkflowKey != workflowdef.OfficialKeyMain { t.Fatalf("unexpected activate result: %+v", result) } @@ -133,8 +67,28 @@ func TestService_BlockPlanEditWhenActive(t *testing.T) { func TestService_SeparationOfDutyValidation(t *testing.T) { bad := validPlan() bad.Stages[0].DocTasks[0].Duties.ReviewerUserID = 1 - res := validatePlan(bad, testResolver().versions["medical_device_development"]) + res := validatePlan(bad, testResolver().versions[workflowdef.OfficialKeyMain]) if res.Valid { t.Fatal("expected separation of duty violation") } } + +func TestService_SavePlanReturnsValidationIssues(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 8, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, nil) + bad := validPlan() + bad.Stages[0].DocTasks[0].Duties = DocDuty{} + + _, err := svc.SaveWorkflowPlan(ctx, 8, 1, true, SavePlanInput{ + Plan: bad, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + var validationErr *ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected ValidationError, got %v", err) + } + if len(validationErr.Issues) != 3 { + t.Fatalf("expected three missing-duty issues, got %+v", validationErr.Issues) + } +} diff --git a/internal/projectinstance/stage_access.go b/internal/projectinstance/stage_access.go index d0944100..b3d4b4e7 100644 --- a/internal/projectinstance/stage_access.go +++ b/internal/projectinstance/stage_access.go @@ -24,13 +24,18 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI } live, err := s.store.GetLiveInstance(ctx, projectID) - if err != nil || live == nil || live.Status == InstanceCompleted { + if err != nil || live == nil { + out.Restricted = true + return out, nil + } + if live.Status == InstanceCompleted || live.Status == "terminated" { + out.Restricted = true return out, nil } detail, err := s.store.GetInstanceByID(ctx, live.ID) if err != nil { - return out, err + return nil, err } out.Restricted = true @@ -61,7 +66,10 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI } out.AllowedStageKeys[stPlan.StageKey] = true for _, dt := range stPlan.DocTasks { - if dt.Enabled { + if !dt.Enabled { + continue + } + if isCreator || docTaskDutyForUser(dt, userID) { out.AllowedTaskKeys[dt.TaskKey] = true } } @@ -71,16 +79,19 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI 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 { + if dt.Enabled && docTaskDutyForUser(dt, userID) { return true } } return false } +func docTaskDutyForUser(dt DocTaskPlan, userID int64) bool { + return dt.Duties.AuthorUserID == userID || + dt.Duties.ReviewerUserID == userID || + dt.Duties.ApproverUserID == userID +} + // TaskKeyAllowed 判断 task_key 是否在允许范围内。 func (a *StageAccess) TaskKeyAllowed(taskKey string) bool { if a == nil || !a.Restricted { diff --git a/internal/projectinstance/stage_access_test.go b/internal/projectinstance/stage_access_test.go index 8b625179..1a740a46 100644 --- a/internal/projectinstance/stage_access_test.go +++ b/internal/projectinstance/stage_access_test.go @@ -48,12 +48,12 @@ func TestResolveStageAccess_ActiveStageDutyOnly(t *testing.T) { } creatorAccess, _ := svc.ResolveStageAccess(ctx, 42, 1, true) - if !creatorAccess.StageKeyAllowed("biz_intake") { + if !creatorAccess.StageKeyAllowed("V0.1_triage") { t.Fatal("creator should access active stage") } outsiderAccess, _ := svc.ResolveStageAccess(ctx, 42, 9, false) - if outsiderAccess.StageKeyAllowed("biz_intake") { + if outsiderAccess.StageKeyAllowed("V0.1_triage") { t.Fatal("user without duty should not access active stage chat") } } diff --git a/internal/projectinstance/stage_remediation_test.go b/internal/projectinstance/stage_remediation_test.go new file mode 100644 index 00000000..c1a5d53b --- /dev/null +++ b/internal/projectinstance/stage_remediation_test.go @@ -0,0 +1,209 @@ +package projectinstance + +import ( + "context" + "testing" + + "chat-agent-system/internal/workflowdef" +) + +func TestAdvanceStage_CompletesProjectAfterLastStage(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 100, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, &stubDocChecker{states: map[string]string{"intake": "approved"}}) + + _, err := svc.SaveWorkflowPlan(ctx, 100, 1, true, SavePlanInput{ + Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + if err != nil { + t.Fatalf("SaveWorkflowPlan: %v", err) + } + preview, err := svc.ActivationPreview(ctx, 100, 1, true) + if err != nil { + t.Fatalf("ActivationPreview: %v", err) + } + result, err := svc.Activate(ctx, 100, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if err != nil { + t.Fatalf("Activate: %v", err) + } + + advanced, err := svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{ + StageKey: "V0.1_triage", ExpectedConfigRevision: result.Instance.ConfigRevision, + }) + if err != nil { + t.Fatalf("AdvanceStage: %v", err) + } + if !advanced.InstanceDone { + t.Fatal("expected instance done after last stage") + } + if advanced.Instance.Status != InstanceCompleted { + t.Fatalf("expected completed instance, got %q", advanced.Instance.Status) + } + + lifecycle, activeKey, _, _, err := store.GetProjectMeta(ctx, 100) + if err != nil { + t.Fatalf("GetProjectMeta: %v", err) + } + if lifecycle != LifecyclePlanning { + t.Fatalf("expected lifecycle planning, got %q", lifecycle) + } + if activeKey != "" { + t.Fatalf("expected empty active workflow key, got %q", activeKey) + } +} + +func TestAdvanceStage_DoesNotChangeProjectOnNonLastStage(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 101, LifecyclePlanning, "", 1, 0) + resolver := testResolverMultiStage() + svc := NewService(store, resolver, nil, &stubDocChecker{states: map[string]string{"intake": "approved"}}) + + plan := validPlan() + plan.Stages = append(plan.Stages, StagePlan{ + StageKey: "V0.5_feasibility", Enabled: true, + DocTasks: []DocTaskPlan{{ + TaskKey: "feasibility_reg", Enabled: true, + Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}, + }}, + }) + _, err := svc.SaveWorkflowPlan(ctx, 101, 1, true, SavePlanInput{ + Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + if err != nil { + t.Fatalf("SaveWorkflowPlan: %v", err) + } + preview, _ := svc.ActivationPreview(ctx, 101, 1, true) + result, _ := svc.Activate(ctx, 101, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + + _, err = svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{ + StageKey: "V0.1_triage", ExpectedConfigRevision: result.Instance.ConfigRevision, + }) + if err != nil { + t.Fatalf("AdvanceStage: %v", err) + } + + lifecycle, _, _, _, err := store.GetProjectMeta(ctx, 101) + if err != nil { + t.Fatalf("GetProjectMeta: %v", err) + } + if lifecycle != LifecycleActive { + t.Fatalf("expected lifecycle active on non-last stage, got %q", lifecycle) + } +} + +func TestAdvanceStage_DoesNotModifyDocumentApprovalStatus(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 102, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, &stubDocChecker{states: map[string]string{"intake": "approved"}}) + + _, _ = svc.SaveWorkflowPlan(ctx, 102, 1, true, SavePlanInput{ + Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + preview, _ := svc.ActivationPreview(ctx, 102, 1, true) + result, _ := svc.Activate(ctx, 102, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + + before, err := store.GetInstanceByID(ctx, result.Instance.ID) + if err != nil { + t.Fatalf("GetInstanceByID: %v", err) + } + beforeStatus := before.Stages[0].DocTasks[0].Status + + _, err = svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{ + StageKey: "V0.1_triage", ExpectedConfigRevision: result.Instance.ConfigRevision, + }) + if err != nil { + t.Fatalf("AdvanceStage: %v", err) + } + + after, err := store.GetInstanceByID(ctx, result.Instance.ID) + if err != nil { + t.Fatalf("GetInstanceByID after: %v", err) + } + if after.Stages[0].DocTasks[0].Status != beforeStatus { + t.Fatalf("doc status changed from %q to %q after advance", beforeStatus, after.Stages[0].DocTasks[0].Status) + } +} + +func TestDisabledDocumentPersistence(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 103, LifecyclePlanning, "", 1, 0) + resolver := disabledDocResolver() + svc := NewService(store, resolver, nil, nil) + + version := resolver.versions[workflowdef.OfficialKeyMain] + plan := buildDefaultPlan(version) + for i := range plan.Stages[0].DocTasks { + if plan.Stages[0].DocTasks[i].TaskKey == "research" { + plan.Stages[0].DocTasks[i].Enabled = false + continue + } + plan.Stages[0].DocTasks[i].Duties = DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3} + } + _, err := svc.SaveWorkflowPlan(ctx, 103, 1, true, SavePlanInput{ + Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + if err != nil { + t.Fatalf("SaveWorkflowPlan: %v", err) + } + preview, _ := svc.ActivationPreview(ctx, 103, 1, true) + result, err := svc.Activate(ctx, 103, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if err != nil { + t.Fatalf("Activate: %v", err) + } + + detail, err := store.GetInstanceByID(ctx, result.Instance.ID) + if err != nil { + t.Fatalf("GetInstanceByID: %v", err) + } + foundDisabled := false + for _, st := range detail.Stages { + for _, dt := range st.DocTasks { + if dt.TaskKey == "research" { + if dt.Status != "skipped_disabled" { + t.Fatalf("expected skipped_disabled, got %q", dt.Status) + } + foundDisabled = true + } + } + } + if !foundDisabled { + t.Fatal("disabled doc task instance not persisted") + } + + decision := svc.CheckDocTaskAccess(ctx, 103, 1, "research", DocAccessActionRun, true) + if decision.Allowed || decision.Code != AccessCodeDocTaskDisabled { + t.Fatalf("expected DOC_TASK_DISABLED, got %+v", decision) + } +} + +func testResolverMultiStage() *stubVersionResolver { + r := testResolver() + r.versions[workflowdef.OfficialKeyMain].Definition.Stages = append( + r.versions[workflowdef.OfficialKeyMain].Definition.Stages, + workflowdef.StageDef{ + Key: "V0.5_feasibility", Name: "V0.5 可行性", Optional: false, Position: 20, + DocTasks: []workflowdef.DocTaskDef{{ + Key: "feasibility_reg", Title: "法规路径评估", CreatorConfigurable: true, BlockingWhenEnabled: true, DefaultEnabled: true, + OutputDocType: "feasibility", OutputDocTitle: "法规路径评估", + }}, + }, + ) + return r +} diff --git a/internal/projectinstance/stage_service_test.go b/internal/projectinstance/stage_service_test.go index 5418f775..75114df1 100644 --- a/internal/projectinstance/stage_service_test.go +++ b/internal/projectinstance/stage_service_test.go @@ -39,7 +39,7 @@ func TestService_AdvanceStageGate(t *testing.T) { t.Fatalf("Activate: %v", err) } - readiness, err := svc.GetStageReadiness(ctx, result.Instance.ID, "biz_intake", 1, true) + readiness, err := svc.GetStageReadiness(ctx, result.Instance.ID, "V0.1_triage", 1, true) if err != nil { t.Fatalf("GetStageReadiness: %v", err) } @@ -49,15 +49,15 @@ func TestService_AdvanceStageGate(t *testing.T) { _ = settings advanced, err := svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{ - StageKey: "biz_intake", ExpectedConfigRevision: result.Instance.ConfigRevision, + StageKey: "V0.1_triage", 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.CompletedStage != "V0.1_triage" { + t.Fatalf("expected completed V0.1_triage, got %s", advanced.CompletedStage) } - if advanced.NextStageKey == "biz_intake" { + if advanced.NextStageKey == "V0.1_triage" { t.Fatal("next stage should not be same as completed") } } @@ -76,7 +76,7 @@ func TestService_AdvanceStageBlockedWithoutApproval(t *testing.T) { }) _, err := svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{ - StageKey: "biz_intake", ExpectedConfigRevision: result.Instance.ConfigRevision, + StageKey: "V0.1_triage", 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 index 0d1af835..1942bdbe 100644 --- a/internal/projectinstance/stage_store.go +++ b/internal/projectinstance/stage_store.go @@ -117,16 +117,11 @@ func (s *pgStore) AdvanceStage(ctx context.Context, instanceID int64, stageKey s 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 + var projectID int64 + _ = tx.QueryRow(ctx, `SELECT project_id FROM project_workflow_instances WHERE id = $1`, instanceID).Scan(&projectID) + err = tx.QueryRow(ctx, ` SELECT id, stage_key FROM project_stage_instances WHERE workflow_instance_id = $1 AND enabled = true AND status = 'pending' @@ -134,12 +129,24 @@ func (s *pgStore) AdvanceStage(ctx context.Context, instanceID int64, stageKey s `, 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 + UPDATE project_workflow_instances SET status = 'completed', config_revision = config_revision + 1, + completed_at = $2, completed_by = ( + SELECT activated_by FROM project_workflow_instances WHERE id = $1 + ), updated_at = $2 WHERE id = $1 `, instanceID, now) if err != nil { return nil, err } + if projectID > 0 { + _, err = tx.Exec(ctx, ` + UPDATE projects SET lifecycle_status = 'planning', active_workflow_key = '', updated_at = $2 + WHERE id = $1 + `, projectID, now) + if err != nil { + return nil, err + } + } } else if err != nil { return nil, err } else { diff --git a/internal/projectinstance/store.go b/internal/projectinstance/store.go index 5e416fc6..c9fa246a 100644 --- a/internal/projectinstance/store.go +++ b/internal/projectinstance/store.go @@ -2,6 +2,8 @@ package projectinstance import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -10,6 +12,8 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + + "chat-agent-system/internal/workflowdef" ) // TablesReady 检查 Phase 3 表是否已迁移。 @@ -37,7 +41,10 @@ type Store interface { 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) + ActivateInstance(ctx context.Context, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64) (*WorkflowInstance, error) + ActivateInstanceWithToken(ctx context.Context, token string, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64, planHash string) (*WorkflowInstance, error) + CountActiveTaskRuns(ctx context.Context, projectID int64) (int, error) + TerminateInstance(ctx context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*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) @@ -241,21 +248,26 @@ func (s *pgStore) replaceInstanceChildren(ctx context.Context, tx pgx.Tx, inst * return err } - for _, st := range inst.ConfigSnapshot.Stages { + for i, st := range inst.ConfigSnapshot.Stages { + position := (i + 1) * 10 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) + VALUES ($1, $2, $3, $4, 'pending') RETURNING id + `, inst.ID, st.StageKey, position, st.Enabled).Scan(&stageID) if err != nil { return err } for _, dt := range st.DocTasks { + status := "pending" + if !dt.Enabled { + status = "skipped_disabled" + } 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) + VALUES ($1, $2, $3, $4) RETURNING id + `, stageID, dt.TaskKey, dt.Enabled, status).Scan(&docTaskID) if err != nil { return err } @@ -281,13 +293,31 @@ func (s *pgStore) replaceInstanceChildren(ctx context.Context, tx pgx.Tx, inst * 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) +func (s *pgStore) ActivateInstance(ctx context.Context, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64) (*WorkflowInstance, error) { + return s.activateInstanceTx(ctx, s.pool, "", projectID, inst, version, expectedVersion, activatedBy, "") +} + +func (s *pgStore) ActivateInstanceWithToken(ctx context.Context, token string, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64, planHash string) (*WorkflowInstance, error) { + return s.activateInstanceTx(ctx, s.pool, token, projectID, inst, version, expectedVersion, activatedBy, planHash) +} + +func (s *pgStore) activateInstanceTx(ctx context.Context, db pgxDB, token string, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64, planHash string) (*WorkflowInstance, error) { + workflowKey := "" + if version != nil { + workflowKey = version.WorkflowKey + } + tx, err := db.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) + if token != "" { + if err := consumeActivationTokenTx(ctx, tx, token, projectID, planHash); err != nil { + return nil, err + } + } + tag, err := tx.Exec(ctx, ` UPDATE projects SET lifecycle_status = 'active', active_workflow_key = $2, workflow_selection_version = workflow_selection_version + 1, @@ -333,22 +363,148 @@ func (s *pgStore) ActivateInstance(ctx context.Context, projectID int64, inst *W return nil, err } + if version != nil { + if err := s.applyRuntimeMetadata(ctx, tx, inst.ID, version.Definition); err != nil { + return nil, err + } + } + if err := tx.Commit(ctx); err != nil { return nil, err } return activated, nil } +type pgxDB interface { + Begin(ctx context.Context) (pgx.Tx, error) +} + +func consumeActivationTokenTx(ctx context.Context, tx pgx.Tx, token string, projectID int64, planHash string) error { + tokenHash := hashActivationToken(token) + var storedHash string + var usedAt *time.Time + var expiresAt time.Time + err := tx.QueryRow(ctx, ` + SELECT plan_hash, used_at, expires_at + FROM workflow_activation_tokens WHERE token = $1 AND project_id = $2 + `, tokenHash, projectID).Scan(&storedHash, &usedAt, &expiresAt) + if errors.Is(err, pgx.ErrNoRows) { + return ErrTokenInvalid + } + if err != nil { + return err + } + if usedAt != nil || time.Now().After(expiresAt) || storedHash != planHash { + return ErrTokenInvalid + } + tag, err := tx.Exec(ctx, ` + UPDATE workflow_activation_tokens SET used_at = now() WHERE token = $1 AND used_at IS NULL + `, tokenHash) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrTokenInvalid + } + return nil +} + +func (s *pgStore) CountActiveTaskRuns(ctx context.Context, projectID int64) (int, error) { + if projectID <= 0 { + return 0, nil + } + var count int + err := s.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM task_runs + WHERE project_id = $1 AND status IN ('pending', 'running') + `, projectID).Scan(&count) + if err != nil { + return 0, nil + } + return count, nil +} + +func (s *pgStore) applyRuntimeMetadata(ctx context.Context, tx pgx.Tx, instanceID int64, def workflowdef.VersionDefinition) error { + taskDefs := map[string]workflowdef.DocTaskDef{} + for _, st := range def.Stages { + for _, dt := range st.DocTasks { + taskDefs[dt.Key] = dt + } + } + // 先完整读取并关闭结果集,再执行 UPDATE: + // pgx 在同一事务连接上存在未读完的 rows 时执行 Exec 会报 "conn busy"。 + rows, err := tx.Query(ctx, ` + SELECT dti.id, dti.task_key + FROM project_doc_task_instances dti + JOIN project_stage_instances si ON si.id = dti.stage_instance_id + WHERE si.workflow_instance_id = $1 + `, instanceID) + if err != nil { + return err + } + type taskInstanceRow struct { + id int64 + taskKey string + } + taskRows := make([]taskInstanceRow, 0) + for rows.Next() { + var r taskInstanceRow + if err := rows.Scan(&r.id, &r.taskKey); err != nil { + rows.Close() + return err + } + taskRows = append(taskRows, r) + } + iterErr := rows.Err() + rows.Close() + if iterErr != nil { + return iterErr + } + for _, r := range taskRows { + td, ok := taskDefs[r.taskKey] + if !ok { + continue + } + tplKey := td.TemplateAssetKey + tplVer := td.TemplateVersion + if td.Template != nil { + if td.Template.AssetKey != "" { + tplKey = td.Template.AssetKey + } + if td.Template.Version > 0 { + tplVer = td.Template.Version + } + } + if _, err := tx.Exec(ctx, ` + UPDATE project_doc_task_instances SET + executor_key = $2, quality_gate_key = $3, + template_asset_key = $4, template_version = $5, + output_doc_type = $6, output_doc_title = $7, updated_at = now() + WHERE id = $1 + `, r.id, td.ExecutorKey, td.QualityGateKey, tplKey, tplVer, td.OutputDocType, td.OutputDocTitle); err != nil { + return err + } + } + return nil +} + +func hashActivationToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + 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) + tokenHash := hashActivationToken(token) _, 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) + `, tokenHash, projectID, planHash, expectedVersion, raw, createdBy, expiresAt) return err } func (s *pgStore) ConsumeActivationToken(ctx context.Context, token string, projectID int64, planHash string) (*WorkflowPlan, error) { + tokenHash := hashActivationToken(token) var snapRaw []byte var storedHash string var usedAt *time.Time @@ -356,7 +512,7 @@ func (s *pgStore) ConsumeActivationToken(ctx context.Context, token string, proj 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) + `, tokenHash, projectID).Scan(&storedHash, &snapRaw, &usedAt, &expiresAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrTokenInvalid } @@ -368,7 +524,7 @@ func (s *pgStore) ConsumeActivationToken(ctx context.Context, token string, proj } tag, err := s.pool.Exec(ctx, ` UPDATE workflow_activation_tokens SET used_at = now() WHERE token = $1 AND used_at IS NULL - `, token) + `, tokenHash) if err != nil { return nil, err } @@ -380,6 +536,48 @@ func (s *pgStore) ConsumeActivationToken(ctx context.Context, token string, proj return &plan, nil } +func (s *pgStore) TerminateInstance(ctx context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + now := time.Now() + row := tx.QueryRow(ctx, ` + UPDATE project_workflow_instances SET status = 'terminated', terminated_at = $3, terminated_by = $4, + termination_reason = $5, config_revision = config_revision + 1, updated_at = $3 + WHERE id = $1 AND project_id = $2 AND status IN ('active', 'awaiting_stage_confirmation', 'termination_requested') + 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 + `, instanceID, projectID, now, terminatedBy, reason) + inst, err := scanInstance(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrTerminationNotAllowed + } + if err != nil { + return nil, err + } + + _, err = tx.Exec(ctx, ` + UPDATE projects SET lifecycle_status = 'planning', active_workflow_key = '', updated_at = $2 + WHERE id = $1 + `, projectID, now) + if err != nil { + return nil, err + } + + _, _ = tx.Exec(ctx, ` + UPDATE task_runs SET status = 'cancelled', updated_at = $2, error_message = 'workflow terminated' + WHERE project_id = $1 AND status IN ('pending', 'running') + `, projectID, now) + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return inst, nil +} + // ============================================================================= // 内存 Store(单元测试 / 无 PG 迁移) // ============================================================================= @@ -459,7 +657,7 @@ func (m *memStore) rebuildStagesLocked(inst *WorkflowInstance, activate bool) { task := memDocTaskRecord{id: m.nextTaskID, taskKey: dt.TaskKey, enabled: dt.Enabled, status: "pending"} m.nextTaskID++ if !dt.Enabled { - task.status = "skipped" + task.status = "skipped_disabled" } rec.tasks = append(rec.tasks, task) } @@ -578,7 +776,22 @@ func (m *memStore) UpsertPlanningInstance(_ context.Context, inst *WorkflowInsta return ©Inst, nil } -func (m *memStore) ActivateInstance(_ context.Context, projectID int64, inst *WorkflowInstance, workflowKey string, expectedVersion int, activatedBy int64) (*WorkflowInstance, error) { +func (m *memStore) ActivateInstanceWithToken(ctx context.Context, token string, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64, planHash string) (*WorkflowInstance, error) { + if _, err := m.ConsumeActivationToken(ctx, token, projectID, planHash); err != nil { + return nil, err + } + return m.ActivateInstance(ctx, projectID, inst, version, expectedVersion, activatedBy) +} + +func (m *memStore) CountActiveTaskRuns(_ context.Context, _ int64) (int, error) { + return 0, nil +} + +func (m *memStore) ActivateInstance(_ context.Context, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64) (*WorkflowInstance, error) { + workflowKey := "" + if version != nil { + workflowKey = version.WorkflowKey + } m.mu.Lock() defer m.mu.Unlock() pm := m.ensureMeta(projectID) @@ -648,11 +861,6 @@ func (m *memStore) AdvanceStage(_ context.Context, instanceID int64, stageKey st 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 { @@ -664,6 +872,11 @@ func (m *memStore) AdvanceStage(_ context.Context, instanceID int64, stageKey st inst.UpdatedAt = time.Now() if nextIdx < 0 { inst.Status = InstanceCompleted + if pm := m.meta[inst.ProjectID]; pm.lifecycle != "" { + pm.lifecycle = LifecyclePlanning + pm.activeKey = "" + m.meta[inst.ProjectID] = pm + } } else { records[nextIdx].status = StageActive } @@ -692,10 +905,31 @@ func (m *memStore) MarkDocTaskApproved(_ context.Context, projectID int64, taskK return nil } +func (m *memStore) TerminateInstance(_ context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error) { + m.mu.Lock() + defer m.mu.Unlock() + inst := m.live[projectID] + if inst == nil || inst.ID != instanceID { + return nil, ErrInstanceNotFound + } + now := time.Now() + inst.Status = "terminated" + copyInst := *inst + delete(m.live, projectID) + pm := m.ensureMeta(projectID) + pm.lifecycle = LifecyclePlanning + pm.activeKey = "" + m.meta[projectID] = pm + _ = terminatedBy + _ = reason + _ = now + return ©Inst, 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{ + m.tokens[hashActivationToken(token)] = activationTokenRec{ projectID: projectID, planHash: planHash, expectedVer: expectedVersion, snapshot: snapshot, expiresAt: expiresAt, } @@ -705,12 +939,13 @@ func (m *memStore) SaveActivationToken(_ context.Context, token string, projectI 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] + tokenHash := hashActivationToken(token) + rec, ok := m.tokens[tokenHash] 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 + m.tokens[tokenHash] = rec plan := rec.snapshot return &plan, nil } diff --git a/internal/projectinstance/terminate.go b/internal/projectinstance/terminate.go new file mode 100644 index 00000000..80db0d18 --- /dev/null +++ b/internal/projectinstance/terminate.go @@ -0,0 +1,113 @@ +package projectinstance + +import ( + "context" + "fmt" +) + +// TerminateInput 受控终止输入。 +type TerminateInput struct { + Reason string `json:"reason"` + Confirm bool `json:"confirm"` + ExpectedConfigRevision int `json:"expected_config_revision"` +} + +// TerminateResult 终止结果。 +type TerminateResult struct { + Instance WorkflowInstance `json:"instance"` + Reason string `json:"reason"` +} + +// PendingDocSummary 终止预览中的待处理文档摘要。 +type PendingDocSummary struct { + TaskKey string `json:"task_key"` + Status string `json:"status"` + StageKey string `json:"stage_key,omitempty"` +} + +// TerminationPreview 受控终止影响预览。 +type TerminationPreview struct { + InstanceID int64 `json:"instance_id"` + PendingDocuments []PendingDocSummary `json:"pending_documents"` + InProgressTaskRuns int `json:"in_progress_task_runs"` + CanTerminate bool `json:"can_terminate"` + Impact string `json:"impact,omitempty"` +} + +func (s *Service) GetTerminationPreview(ctx context.Context, instanceID int64, projectID int64, userID int64, isCreator bool) (*TerminationPreview, error) { + if !isCreator { + return nil, ErrForbiddenNotCreator + } + if !s.Available() { + return nil, ErrNotAvailable + } + live, err := s.store.GetLiveInstance(ctx, projectID) + if err != nil || live == nil || live.ID != instanceID { + return nil, ErrInstanceNotFound + } + if live.Status == InstanceCompleted || live.Status == "terminated" { + return nil, ErrTerminationNotAllowed + } + + detail, err := s.store.GetInstanceByID(ctx, instanceID) + if err != nil { + return nil, err + } + + pending := make([]PendingDocSummary, 0) + for _, st := range detail.Stages { + if st.StageKey != detail.ActiveStageKey { + continue + } + for _, dt := range st.DocTasks { + if !dt.Enabled || dt.Status == "skipped_disabled" { + continue + } + if dt.Status != "approved" { + pending = append(pending, PendingDocSummary{ + TaskKey: dt.TaskKey, Status: dt.Status, StageKey: st.StageKey, + }) + } + } + } + + taskRuns, _ := s.store.CountActiveTaskRuns(ctx, projectID) + preview := &TerminationPreview{ + InstanceID: instanceID, + PendingDocuments: pending, + InProgressTaskRuns: taskRuns, + CanTerminate: true, + Impact: fmt.Sprintf( + "%d pending documents, %d in-progress task runs will be cancelled", + len(pending), taskRuns, + ), + } + return preview, nil +} + +func (s *Service) TerminateInstance(ctx context.Context, instanceID int64, projectID int64, userID int64, isCreator bool, in TerminateInput) (*TerminateResult, error) { + if !isCreator { + return nil, ErrForbiddenNotCreator + } + if !in.Confirm { + return nil, ErrTerminationNotAllowed + } + if in.Reason == "" { + return nil, ErrValidationFailed + } + if !s.Available() { + return nil, ErrNotAvailable + } + live, err := s.store.GetLiveInstance(ctx, projectID) + if err != nil || live == nil || live.ID != instanceID { + return nil, ErrInstanceNotFound + } + if in.ExpectedConfigRevision > 0 && live.ConfigRevision != in.ExpectedConfigRevision { + return nil, ErrInstanceRevisionConflict + } + inst, err := s.store.TerminateInstance(ctx, instanceID, projectID, userID, in.Reason) + if err != nil { + return nil, err + } + return &TerminateResult{Instance: *inst, Reason: in.Reason}, nil +} diff --git a/internal/projectinstance/terminate_test.go b/internal/projectinstance/terminate_test.go new file mode 100644 index 00000000..0b68f4fd --- /dev/null +++ b/internal/projectinstance/terminate_test.go @@ -0,0 +1,48 @@ +package projectinstance + +import ( + "context" + "testing" +) + +func TestTerminateInstance_UpdatesProjectLifecycle(t *testing.T) { + ctx := context.Background() + store := NewMemoryStore() + _ = SeedProjectMeta(store, 300, LifecyclePlanning, "", 1, 0) + svc := NewService(store, TestResolver(), nil, &StubDocChecker{States: map[string]string{"intake": "approved"}}) + + _, _ = svc.SaveWorkflowPlan(ctx, 300, 1, true, SavePlanInput{ + Plan: ValidTestPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + preview, _ := svc.ActivationPreview(ctx, 300, 1, true) + activated, _ := svc.Activate(ctx, 300, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + + pv, err := svc.GetTerminationPreview(ctx, activated.Instance.ID, 300, 1, true) + if err != nil { + t.Fatalf("GetTerminationPreview: %v", err) + } + if !pv.CanTerminate { + t.Fatal("expected can terminate") + } + + res, err := svc.TerminateInstance(ctx, activated.Instance.ID, 300, 1, true, TerminateInput{ + Reason: "测试终止", Confirm: true, ExpectedConfigRevision: activated.Instance.ConfigRevision, + }) + if err != nil { + t.Fatalf("TerminateInstance: %v", err) + } + if res.Instance.Status != "terminated" { + t.Fatalf("expected terminated, got %q", res.Instance.Status) + } + + lifecycle, activeKey, _, _, err := store.GetProjectMeta(ctx, 300) + if err != nil { + t.Fatalf("GetProjectMeta: %v", err) + } + if lifecycle != LifecyclePlanning || activeKey != "" { + t.Fatalf("expected planning with empty key, got lifecycle=%q key=%q", lifecycle, activeKey) + } +} diff --git a/internal/projectinstance/testutil.go b/internal/projectinstance/testutil.go new file mode 100644 index 00000000..9e6519d8 --- /dev/null +++ b/internal/projectinstance/testutil.go @@ -0,0 +1,116 @@ +package projectinstance + +import ( + "context" + + "chat-agent-system/internal/workflowdef" +) + +// NewMemoryStore 返回内存 Store,供跨包集成测试使用。 +func NewMemoryStore() Store { + return newMemStore() +} + +// TestResolver 返回最小正式商务工作流解析器。 +func TestResolver() VersionResolver { + return testResolver() +} + +// ValidTestPlan 返回单阶段 intake 测试规划。 +func ValidTestPlan() WorkflowPlan { + return validPlan() +} + +// StubDocChecker 内存文档批准状态探针。 +type StubDocChecker struct { + States map[string]string +} + +func (s *StubDocChecker) TaskApprovalStates(_ context.Context, _ int64) (map[string]string, error) { + if s == nil { + return map[string]string{}, nil + } + return s.States, nil +} + +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 disabledDocResolver() *stubVersionResolver { + r := testResolver() + r.versions[workflowdef.OfficialKeyMain].Definition.Stages[0].DocTasks = append( + r.versions[workflowdef.OfficialKeyMain].Definition.Stages[0].DocTasks, + workflowdef.DocTaskDef{ + Key: "research", Title: "市场调研", CreatorConfigurable: true, BlockingWhenEnabled: false, DefaultEnabled: false, + OutputDocType: "research", OutputDocTitle: "市场调研", + }, + ) + return r +} + +func testResolver() *stubVersionResolver { + key := workflowdef.OfficialKeyMain + ver := &workflowdef.Version{ + ID: 100, WorkflowID: 10, Version: 1, Status: workflowdef.StatusPublished, + WorkflowKey: key, WorkflowName: workflowdef.OfficialWorkflowName(key), + Definition: workflowdef.VersionDefinition{ + SchemaVersion: 2, + WorkflowKey: key, + Stages: []workflowdef.StageDef{{ + Key: "V0.1_triage", Name: "V0.1 需求摸底", Optional: false, Position: 10, + DocTasks: []workflowdef.DocTaskDef{{ + Key: "intake", Title: "用户初始需求收集表", CreatorConfigurable: true, BlockingWhenEnabled: true, DefaultEnabled: true, + OutputDocType: "intake", OutputDocTitle: "用户初始需求收集表", + }}, + }}, + }, + } + return &stubVersionResolver{ + defs: map[string]*workflowdef.Definition{ + key: {ID: 10, Key: key, Name: workflowdef.OfficialWorkflowName(key), LatestPublishedVersion: 1}, + }, + versions: map[string]*workflowdef.Version{key: ver}, + } +} + +func validPlan() WorkflowPlan { + return WorkflowPlan{ + WorkflowDefinitionKey: workflowdef.OfficialKeyMain, + WorkflowVersion: 1, + Stages: []StagePlan{{ + StageKey: "V0.1_triage", Enabled: true, + DocTasks: []DocTaskPlan{{ + TaskKey: "intake", Enabled: true, + Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}, + }}, + }}, + } +} diff --git a/internal/projectinstance/types.go b/internal/projectinstance/types.go index c317c027..595f1736 100644 --- a/internal/projectinstance/types.go +++ b/internal/projectinstance/types.go @@ -27,16 +27,18 @@ type DocDuty struct { // DocTaskPlan 文档任务裁剪与职责。 type DocTaskPlan struct { - TaskKey string `json:"task_key"` - Enabled bool `json:"enabled"` - Duties DocDuty `json:"duties"` + TaskKey string `json:"task_key"` + TaskTitle string `json:"task_title,omitempty"` + 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"` + StageKey string `json:"stage_key"` + StageName string `json:"stage_name,omitempty"` + Enabled bool `json:"enabled"` + DocTasks []DocTaskPlan `json:"doc_tasks"` } // WorkflowPlan 项目工作流规划快照。 @@ -99,31 +101,31 @@ type SavePlanInput struct { // 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"` + 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"` + 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"` + ConfirmationToken string `json:"confirmation_token"` + ExpectedProjectVersion int `json:"expected_project_version"` + ExpectedPlanRevision int `json:"expected_plan_revision"` } // ActivateResult 激活结果。 @@ -166,8 +168,8 @@ type StageInstance struct { // InstanceDetail 工作流实例详情(含阶段树)。 type InstanceDetail struct { Instance WorkflowInstance `json:"instance"` - ActiveStageKey string `json:"active_stage_key,omitempty"` - Stages []StageInstance `json:"stages"` + ActiveStageKey string `json:"active_stage_key,omitempty"` + Stages []StageInstance `json:"stages"` } // GateIssue 阶段门禁阻断项。 @@ -188,14 +190,14 @@ type DocTaskReadiness struct { // 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"` + 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 创建者确认推进。 @@ -206,10 +208,10 @@ type AdvanceStageInput struct { // 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"` + 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 index 24ebabc3..8a02c720 100644 --- a/internal/projectinstance/validator.go +++ b/internal/projectinstance/validator.go @@ -21,6 +21,12 @@ func validatePlan(plan WorkflowPlan, version *workflowdef.Version) workflowdef.V Code: "WORKFLOW_NOT_PUBLISHED", Message: "只能绑定已发布的工作流版本", }) } + if !workflowdef.IsOfficialWorkflowKey(plan.WorkflowDefinitionKey) { + issues = append(issues, workflowdef.ValidationIssue{ + Code: "WORKFLOW_NOT_SELECTABLE", Field: "workflow_definition_key", + Message: "仅允许选择正式三工作流", + }) + } if plan.WorkflowDefinitionKey != version.WorkflowKey { issues = append(issues, workflowdef.ValidationIssue{ Code: "WORKFLOW_KEY_MISMATCH", Field: "workflow_definition_key", @@ -79,9 +85,9 @@ func validatePlan(plan WorkflowPlan, version *workflowdef.Version) workflowdef.V }) continue } - if (td.Required || td.Blocking) && !dt.Enabled { + if !dt.Enabled && !td.IsCreatorConfigurable() { issues = append(issues, workflowdef.ValidationIssue{ - Code: "REQUIRED_DOC_DISABLED", Field: dt.TaskKey, Message: "必需/阻断文档任务不能关闭", + Code: "REQUIRED_DOC_DISABLED", Field: dt.TaskKey, Message: "该文档任务不可由创建者关闭", }) } if dt.Enabled { @@ -105,6 +111,35 @@ func validatePlan(plan WorkflowPlan, version *workflowdef.Version) workflowdef.V return workflowdef.ValidationResult{Valid: len(issues) == 0, Issues: issues} } +func validateActivationPlan(plan WorkflowPlan, version *workflowdef.Version) workflowdef.ValidationResult { + base := validatePlan(plan, version) + if !base.Valid { + return base + } + if countEnabledDocTasks(plan) == 0 { + base.Valid = false + base.Issues = append(base.Issues, workflowdef.ValidationIssue{ + Code: "EMPTY_WORKFLOW", Message: "至少启用一个文档任务才能激活工作流", + }) + } + return base +} + +func countEnabledDocTasks(plan WorkflowPlan) int { + n := 0 + for _, st := range plan.Stages { + if !st.Enabled { + continue + } + for _, dt := range st.DocTasks { + if dt.Enabled { + n++ + } + } + } + return n +} + func validateDuties(d DocDuty, taskKey string) []workflowdef.ValidationIssue { issues := make([]workflowdef.ValidationIssue, 0) if d.AuthorUserID <= 0 { @@ -152,14 +187,16 @@ func buildDefaultPlan(version *workflowdef.Version) WorkflowPlan { } for _, st := range version.Definition.Stages { sp := StagePlan{ - StageKey: st.Key, - Enabled: !st.Optional, - DocTasks: make([]DocTaskPlan, 0, len(st.DocTasks)), + StageKey: st.Key, + StageName: st.Name, + 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, + TaskKey: dt.Key, + TaskTitle: dt.Title, + Enabled: dt.DefaultEnabled, }) } plan.Stages = append(plan.Stages, sp) @@ -167,6 +204,35 @@ func buildDefaultPlan(version *workflowdef.Version) WorkflowPlan { return plan } +// applyDefinitionLabels 使用已发布定义覆盖展示名称,避免客户端把自定义文本写入冻结快照。 +func applyDefinitionLabels(plan *WorkflowPlan, version *workflowdef.Version) { + if plan == nil || version == nil { + return + } + stageDefs := make(map[string]workflowdef.StageDef, len(version.Definition.Stages)) + for _, stage := range version.Definition.Stages { + stageDefs[stage.Key] = stage + } + for stageIdx := range plan.Stages { + stage := &plan.Stages[stageIdx] + stageDef, ok := stageDefs[stage.StageKey] + if !ok { + continue + } + stage.StageName = stageDef.Name + taskDefs := make(map[string]workflowdef.DocTaskDef, len(stageDef.DocTasks)) + for _, task := range stageDef.DocTasks { + taskDefs[task.Key] = task + } + for taskIdx := range stage.DocTasks { + task := &stage.DocTasks[taskIdx] + if taskDef, exists := taskDefs[task.TaskKey]; exists { + task.TaskTitle = taskDef.Title + } + } + } +} + func previewSummary(plan WorkflowPlan, version *workflowdef.Version) PreviewSummary { sum := PreviewSummary{ WorkflowVersion: plan.WorkflowVersion, diff --git a/internal/projectinstance/validator_test.go b/internal/projectinstance/validator_test.go new file mode 100644 index 00000000..ebe34be4 --- /dev/null +++ b/internal/projectinstance/validator_test.go @@ -0,0 +1,67 @@ +package projectinstance + +import ( + "testing" + + "chat-agent-system/internal/workflowdef" +) + +func TestValidateActivationPlan_EmptyWorkflowBlocked(t *testing.T) { + plan := WorkflowPlan{ + WorkflowDefinitionKey: workflowdef.OfficialKeyProductManager, + WorkflowVersion: 2, + Stages: []StagePlan{{ + StageKey: "requirements_baseline", + Enabled: true, + DocTasks: []DocTaskPlan{{ + TaskKey: "system_reqs", + Enabled: false, + }}, + }}, + } + version := &workflowdef.Version{ + Status: workflowdef.StatusPublished, + WorkflowKey: workflowdef.OfficialKeyProductManager, + Version: 2, + Definition: workflowdef.VersionDefinition{ + SchemaVersion: 2, + Stages: []workflowdef.StageDef{{ + Key: "requirements_baseline", Position: 10, + DocTasks: []workflowdef.DocTaskDef{{ + Key: "system_reqs", CreatorConfigurable: true, DefaultEnabled: true, + OutputDocType: "system_reqs", + }}, + }}, + }, + } + res := validatePlan(plan, version) + if !res.Valid { + t.Fatalf("save plan should allow all docs disabled: %+v", res.Issues) + } + act := validateActivationPlan(plan, version) + if act.Valid { + t.Fatal("activation should reject empty enabled doc set") + } +} + +func TestBuildDefaultPlan_IncludesDisplayNames(t *testing.T) { + version := testResolver().versions[workflowdef.OfficialKeyMain] + plan := buildDefaultPlan(version) + if len(plan.Stages) != 1 || plan.Stages[0].StageName != "V0.1 需求摸底" { + t.Fatalf("expected stage display name, got %+v", plan.Stages) + } + if len(plan.Stages[0].DocTasks) != 1 || plan.Stages[0].DocTasks[0].TaskTitle != "用户初始需求收集表" { + t.Fatalf("expected task display title, got %+v", plan.Stages[0].DocTasks) + } +} + +func TestApplyDefinitionLabels_OverridesClientText(t *testing.T) { + version := testResolver().versions[workflowdef.OfficialKeyMain] + plan := validPlan() + plan.Stages[0].StageName = "客户端伪造阶段" + plan.Stages[0].DocTasks[0].TaskTitle = "客户端伪造文档" + applyDefinitionLabels(&plan, version) + if plan.Stages[0].StageName != "V0.1 需求摸底" || plan.Stages[0].DocTasks[0].TaskTitle != "用户初始需求收集表" { + t.Fatalf("definition labels must win, got %+v", plan.Stages[0]) + } +} diff --git a/internal/projectworkflow/service.go b/internal/projectworkflow/service.go index 35e65fb8..47db21ff 100644 --- a/internal/projectworkflow/service.go +++ b/internal/projectworkflow/service.go @@ -121,6 +121,11 @@ func (s *Service) ListProjects(ctx context.Context, userID int64) ([]*Project, e return s.store.ListProjectsForUser(ctx, userID) } +// ListAllProjects 查询全部项目,仅供已完成系统管理员鉴权的调用方使用。 +func (s *Service) ListAllProjects(ctx context.Context) ([]*Project, error) { + return s.store.ListAllProjects(ctx) +} + // IsProjectCreator 判断用户是否为指定项目的权威创建人 func (s *Service) IsProjectCreator(ctx context.Context, projectID int64, userID int64) (bool, error) { p, err := s.store.GetProjectByID(ctx, projectID) diff --git a/internal/projectworkflow/service_test.go b/internal/projectworkflow/service_test.go index 9f821bf2..7016e764 100644 --- a/internal/projectworkflow/service_test.go +++ b/internal/projectworkflow/service_test.go @@ -125,6 +125,39 @@ func TestProjectWorkflowService_CreateAndCreatorACL(t *testing.T) { } } +func TestProjectWorkflowService_ListAllProjects(t *testing.T) { + ctx := context.Background() + svc := NewService(NewStore(nil), NewDefaultCatalog()) + creator := &auth.UserAccount{ID: 102, Username: "lisi", Name: "李主管"} + + created, err := svc.CreateProject(ctx, creator, CreateProjectInput{Name: "管理员跨项目查看测试"}) + if err != nil { + t.Fatalf("CreateProject failed: %v", err) + } + visible, err := svc.ListProjects(ctx, 999) + if err != nil { + t.Fatalf("ListProjects failed: %v", err) + } + if len(visible) != 0 { + t.Fatalf("unrelated user should not see projects, got %d", len(visible)) + } + + all, err := svc.ListAllProjects(ctx) + if err != nil { + t.Fatalf("ListAllProjects failed: %v", err) + } + found := false + for _, project := range all { + if project.ID == created.ID { + found = true + break + } + } + if !found { + t.Fatalf("ListAllProjects should include project %d", created.ID) + } +} + func TestProjectWorkflowService_LineageAndStale(t *testing.T) { ctx := context.Background() store := NewStore(nil) diff --git a/internal/projectworkflow/store.go b/internal/projectworkflow/store.go index 59bc2278..41aee3a2 100644 --- a/internal/projectworkflow/store.go +++ b/internal/projectworkflow/store.go @@ -17,6 +17,7 @@ type Store interface { CreateProject(ctx context.Context, p *Project) (*Project, error) GetProjectByID(ctx context.Context, id int64) (*Project, error) GetProjectByCode(ctx context.Context, code string) (*Project, error) + ListAllProjects(ctx context.Context) ([]*Project, error) ListProjectsForUser(ctx context.Context, userID int64) ([]*Project, error) ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error) EnsureProjectWorkflows(ctx context.Context, projectID int64, defaultKeys []string) error @@ -145,16 +146,16 @@ func (s *pgStore) GetProjectByCode(ctx context.Context, code string) (*Project, } func (s *pgStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Project, error) { - // 允许查看项目(自己创建,或公开项目,或已有会话参与) + // 仅创建人或 active 项目成员可见(移除 creator_id=1/NULL 公开规则) 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), + p.current_stage, p.environment, p.status, COALESCE(p.creator_user_id, 0), 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 - WHERE p.creator_user_id = $1 OR cs.id IS NOT NULL OR p.creator_user_id IS NULL OR p.creator_user_id = 1 + LEFT JOIN project_members pm ON pm.project_id = p.id AND pm.user_id = $1 AND pm.status = 'active' + WHERE p.creator_user_id = $1 OR pm.user_id IS NOT NULL ORDER BY p.id DESC ` rows, err := s.pool.Query(ctx, query, userID) @@ -179,6 +180,38 @@ func (s *pgStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Pro return list, nil } +func (s *pgStore) ListAllProjects(ctx context.Context) ([]*Project, error) { + query := ` + SELECT 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, 0), + 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 + ORDER BY p.id DESC + ` + rows, err := s.pool.Query(ctx, query) + if err != nil { + return nil, fmt.Errorf("list all projects failed: %w", err) + } + defer rows.Close() + + var list []*Project + for rows.Next() { + p := &Project{} + 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.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision, + &p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt, + ); err != nil { + return nil, err + } + list = append(list, p) + } + return list, rows.Err() +} + func (s *pgStore) ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error) { query := ` SELECT id, project_id, workflow_key, status, enabled, created_at, updated_at @@ -652,7 +685,7 @@ func (m *memStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Pr defer m.mu.RUnlock() var list []*Project for _, p := range m.projects { - if p.CreatorUserID == userID || p.CreatorUserID == 0 || p.CreatorUserID == 1 { + if p.CreatorUserID == userID { cpy := *p list = append(list, &cpy) } @@ -660,6 +693,17 @@ func (m *memStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Pr return list, nil } +func (m *memStore) ListAllProjects(ctx context.Context) ([]*Project, error) { + m.mu.RLock() + defer m.mu.RUnlock() + list := make([]*Project, 0, len(m.projects)) + for _, p := range m.projects { + cpy := *p + list = append(list, &cpy) + } + return list, nil +} + func (m *memStore) ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error) { m.mu.RLock() defer m.mu.RUnlock() diff --git a/internal/server/admin_permissions_handlers.go b/internal/server/admin_permissions_handlers.go new file mode 100644 index 00000000..94bce5bc --- /dev/null +++ b/internal/server/admin_permissions_handlers.go @@ -0,0 +1,20 @@ +package server + +import ( + "net/http" + + "chat-agent-system/internal/authz" +) + +// handleAdminPermissions GET /api/v1/admin/permissions — 权限目录(服务端权威) +func (s *Server) handleAdminPermissions(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requirePrincipal(w, r); !ok { + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + catalog := authz.PermissionCatalog() + writeJSONResponse(w, http.StatusOK, map[string]any{"permissions": catalog}) +} diff --git a/internal/server/asset_handlers_test.go b/internal/server/asset_handlers_test.go index bd9355e4..69eb7bad 100644 --- a/internal/server/asset_handlers_test.go +++ b/internal/server/asset_handlers_test.go @@ -87,7 +87,10 @@ func TestUploadProjectAsset_RejectsOversizedFilename(t *testing.T) { 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) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } 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_gate_a_test.go b/internal/server/auth_gate_a_test.go new file mode 100644 index 00000000..edab29b9 --- /dev/null +++ b/internal/server/auth_gate_a_test.go @@ -0,0 +1,46 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/config" +) + +func TestGateA_LineageAndRunsRequireProjectAccess(t *testing.T) { + cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: true, Env: "main", ContextDbHost: "127.0.0.1"} + srv := NewServer(cfg, bizdev.MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"})) + srv.cfg = cfg + + cases := []struct { + method string + path string + }{ + {http.MethodGet, "/api/v1/projects/999/lineage?doc_id=doc_x"}, + {http.MethodGet, "/api/v1/projects/999/workflows/main/runs"}, + } + for _, tc := range cases { + req := httptest.NewRequest(tc.method, tc.path, nil) + rec := httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("%s %s expected 401 without auth, got %d", tc.method, tc.path, rec.Code) + } + } +} + +func TestGateA_CreateProjectRequiresPermission(t *testing.T) { + cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: true, Env: "main", ContextDbHost: "127.0.0.1"} + srv := NewServer(cfg, bizdev.MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"})) + srv.cfg = cfg + + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects", nil) + req.Header.Set("Authorization", "Bearer invalid") + rec := httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } +} diff --git a/internal/server/auth_handlers.go b/internal/server/auth_handlers.go index 08146671..a75862d4 100644 --- a/internal/server/auth_handlers.go +++ b/internal/server/auth_handlers.go @@ -307,6 +307,21 @@ func (s *Server) handleAdminUpdateStatus(w http.ResponseWriter, r *http.Request) _ = json.NewEncoder(w).Encode(updated) } +// handleAuthPublicConfig 返回登录/注册页所需的公开准入配置(无需鉴权) +func (s *Server) handleAuthPublicConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "不支持的方法", http.StatusMethodNotAllowed) + return + } + cfg, err := s.authSvc.GetConfig(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(cfg) +} + // handleAdminConfig 获取或更新系统准入配置 func (s *Server) handleAdminConfig(w http.ResponseWriter, r *http.Request) { if _, ok := s.requireSystemAdmin(w, r); !ok { diff --git a/internal/server/auth_handlers_test.go b/internal/server/auth_handlers_test.go index c7918a51..632548ae 100644 --- a/internal/server/auth_handlers_test.go +++ b/internal/server/auth_handlers_test.go @@ -25,6 +25,42 @@ func setupTestServer() (*httptest.Server, *auth.Service) { return ts, srv.AuthService() } +func TestAuthHandlers_PublicConfigUnauthenticated(t *testing.T) { + ts, authSvc := setupTestServer() + defer ts.Close() + + _ = authSvc.UpdateConfig(t.Context(), auth.SystemAuthConfig{ + AllowRegistration: true, + RequireApproval: true, + }) + + resp, err := http.Get(ts.URL + "/api/v1/auth/public-config") + if err != nil { + t.Fatalf("public-config request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("Expected 200 for unauthenticated /auth/public-config, got %d", resp.StatusCode) + } + + var cfg auth.SystemAuthConfig + if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil { + t.Fatalf("decode public-config: %v", err) + } + if !cfg.AllowRegistration || !cfg.RequireApproval { + t.Fatalf("unexpected public-config: %+v", cfg) + } + + adminResp, err := http.Get(ts.URL + "/api/v1/admin/config") + if err != nil { + t.Fatalf("admin config request failed: %v", err) + } + defer adminResp.Body.Close() + if adminResp.StatusCode != http.StatusUnauthorized { + t.Fatalf("Expected 401 for unauthenticated /admin/config, got %d", adminResp.StatusCode) + } +} + func TestAuthHandlers_UnauthenticatedMeReturns401(t *testing.T) { ts, _ := setupTestServer() defer ts.Close() diff --git a/internal/server/auth_helpers.go b/internal/server/auth_helpers.go index 3e334f37..a95ba552 100644 --- a/internal/server/auth_helpers.go +++ b/internal/server/auth_helpers.go @@ -1,6 +1,7 @@ package server import ( + "context" "errors" "net/http" @@ -9,6 +10,40 @@ import ( "chat-agent-system/internal/ctxmgr" ) +// failClosedAuthz 生产/DB 模式在授权依赖异常时默认拒绝。 +func (s *Server) failClosedAuthz() bool { + return s == nil || s.cfg == nil || !s.cfg.AllowInMemory +} + +func (s *Server) userHasSystemPermission(ctx context.Context, user *auth.UserAccount, permKey string) (bool, error) { + if user == nil || permKey == "" { + return false, nil + } + if s.isSystemAdminUser(ctx, user) { + return true, nil + } + caps, err := s.authzSvc.ListCapabilities(ctx, user.ID) + if err != nil { + return false, err + } + if hasPermInCapabilityList(caps, permKey) { + return true, nil + } + if user.RoleID != "" && hasPermInCapabilityList(authz.FallbackCapabilitiesByRoleID(user.RoleID), permKey) { + return true, nil + } + return false, nil +} + +func hasPermInCapabilityList(caps []string, permKey string) bool { + for _, k := range caps { + if k == permKey || k == authz.PermManage { + return true + } + } + return false +} + func writeAuthzError(w http.ResponseWriter, err error) { switch { case errors.Is(err, authz.ErrForbidden): diff --git a/internal/server/auth_middleware.go b/internal/server/auth_middleware.go index 1eb44384..0ba7961b 100644 --- a/internal/server/auth_middleware.go +++ b/internal/server/auth_middleware.go @@ -27,6 +27,9 @@ func isPublicRoute(method, path string) bool { if method == http.MethodPost && path == "/api/v1/auth/register" { return true } + if method == http.MethodGet && path == "/api/v1/auth/public-config" { + return true + } if !strings.HasPrefix(path, "/api/") { return true } diff --git a/internal/server/bizdev_stage_gate.go b/internal/server/bizdev_stage_gate.go index f22bcf7a..729f00d1 100644 --- a/internal/server/bizdev_stage_gate.go +++ b/internal/server/bizdev_stage_gate.go @@ -48,9 +48,6 @@ func (g *bizdevStageGate) AllowTaskExecution(ctx context.Context, projectID, use if taskKey != "" && access.TaskKeyAllowed(taskKey) { return nil } - if access.StageKeyAllowed(access.ActiveStageKey) { - return nil - } return fmt.Errorf("%w: task %q", bizdev.ErrStageAccessDenied, taskKey) } @@ -73,7 +70,10 @@ func (g *bizdevStageGate) FilterProjectContext(ctx context.Context, projectID, u return pCtx } access, err := g.access(ctx, projectID, userID) - if err != nil || access == nil || !access.Restricted { + if err != nil { + return &ctxmgr.ProjectCollectiveContext{} + } + if access == nil || !access.Restricted { return pCtx } out := *pCtx @@ -106,7 +106,10 @@ func (g *bizdevStageGate) FilterPriorDocs(ctx context.Context, projectID, userID return docs } access, err := g.access(ctx, projectID, userID) - if err != nil || access == nil || !access.Restricted { + if err != nil { + return nil + } + if access == nil || !access.Restricted { return docs } out := make([]squad.PriorDocItem, 0, len(docs)) diff --git a/internal/server/e2e_workflow_governance_test.go b/internal/server/e2e_workflow_governance_test.go new file mode 100644 index 00000000..5ec70d35 --- /dev/null +++ b/internal/server/e2e_workflow_governance_test.go @@ -0,0 +1,146 @@ +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/workflowdef" +) + +// TestE2E_WorkflowGovernance_CompleteAndReplan 验证末阶段完成后项目回到 planning 并可再次获取目录。 +func TestE2E_WorkflowGovernance_CompleteAndReplan(t *testing.T) { + dsn := db.SkipIfNoTestDatabaseURL(t) + _ = dsn + pool := openPGPoolWithMigrations(t) + if pool == nil { + return + } + defer pool.Close() + + ctx := context.Background() + handler, srv := setupPGGovernanceServer(t, pool) + + creatorToken := loginTestUser(t, handler, "zhanggong", "123456") + creatorID := fetchAuthUserID(t, handler, creatorToken) + + wfKey := workflowdef.OfficialKeyTestManager + seedOfficialWorkflowManifest(t, pool, wfKey) + + var projectID int64 + code := "e2e-gov-" + 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, 'E2E Governance', 'active', $2, 'planning', '', 1, 0) RETURNING id + `, code, creatorID).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) + }) + + reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=0", projectID, wfKey), nil) + withBearerAuth(reqDefault, creatorToken) + 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) + 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()) + } + + 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) + + docID := fmt.Sprintf("doc_proj%d_test_manager", projectID) + docEngine := srv.BizdevService().DocGov() + _, err = docEngine.CreateOrGetDocument(ctx, docID, projectID, "test_manager", "系统验证方案及计划", "system_verification_planning", "", + creatorID, "Author", reviewerID, "Reviewer", approverID, "Approver") + if err != nil { + t.Fatalf("create doc: %v", err) + } + _ = docEngine.MarkDocumentContentUpdated(ctx, docID, "hash_e2e_te", false) + _, _ = docEngine.SubmitForReview(ctx, docID, creatorID, "author", "Author") + _, _ = docEngine.ReviewApprove(ctx, docID, reviewerID, "reviewer", "Reviewer", "ok") + _, err = docEngine.ApproverApprove(ctx, docID, approverID, "approver", "Approver", "ok") + if err != nil { + t.Fatalf("approve doc: %v", err) + } + + advanceBody, _ := json.Marshal(projectinstance.AdvanceStageInput{ + StageKey: "system_verification_planning", ExpectedConfigRevision: activated.Instance.ConfigRevision, + }) + reqAdvance := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/workflow-instances/%d/stages/system_verification_planning/advance", activated.Instance.ID), bytes.NewReader(advanceBody)) + withBearerAuth(reqAdvance, creatorToken) + recAdvance := httptest.NewRecorder() + handler.ServeHTTP(recAdvance, reqAdvance) + if recAdvance.Code != http.StatusOK { + t.Fatalf("advance: %d %s", recAdvance.Code, recAdvance.Body.String()) + } + + reqSettings := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/settings", projectID), nil) + withBearerAuth(reqSettings, creatorToken) + recSettings := httptest.NewRecorder() + handler.ServeHTTP(recSettings, reqSettings) + if recSettings.Code != http.StatusOK { + t.Fatalf("settings: %d %s", recSettings.Code, recSettings.Body.String()) + } + var settings projectinstance.ProjectSettings + if err := json.Unmarshal(recSettings.Body.Bytes(), &settings); err != nil { + t.Fatalf("decode settings: %v", err) + } + if settings.LifecycleStatus != projectinstance.LifecyclePlanning { + t.Fatalf("expected planning after completion, got %q", settings.LifecycleStatus) + } + + reqCatalog := httptest.NewRequest(http.MethodGet, "/api/v1/workflows/catalog", nil) + withBearerAuth(reqCatalog, creatorToken) + recCatalog := httptest.NewRecorder() + handler.ServeHTTP(recCatalog, reqCatalog) + if recCatalog.Code != http.StatusOK { + t.Fatalf("catalog: %d %s", recCatalog.Code, recCatalog.Body.String()) + } +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index e0db5232..1efa7edc 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -91,7 +91,11 @@ 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() + if dbReady { + schemaVersion = s.svc.LiveSchemaVersion(context.Background()) + } else { + schemaVersion = s.svc.SchemaVersion() + } } schemaReady := schemaVersion > 0 || (s.cfg != nil && s.cfg.AllowInMemory) taskStoreReady := s.svc != nil && taskrun.TablesReady(context.Background(), s.svc.Pool()) @@ -197,6 +201,18 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { } req.UserID = user.ID + if req.ProjectID > 0 { + if !s.isSystemAdminUser(r.Context(), user) { + if err := s.authorizer.RequireProjectAccess(r.Context(), req.ProjectID, user.ID); err != nil { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "无权访问该项目", + }) + return + } + } + } + // 0. 如果关联了项目,在 projectworkflow 服务中记录运行实例 var runRecord *projectworkflow.WorkflowRun if s.pwSvc != nil && req.ProjectID > 0 { diff --git a/internal/server/me_settings_handlers.go b/internal/server/me_settings_handlers.go new file mode 100644 index 00000000..4d962ed0 --- /dev/null +++ b/internal/server/me_settings_handlers.go @@ -0,0 +1,87 @@ +package server + +import ( + "encoding/json" + "net/http" +) + +type userProfilePatch struct { + Name *string `json:"name,omitempty"` + Email *string `json:"email,omitempty"` + Locale *string `json:"locale,omitempty"` + Timezone *string `json:"timezone,omitempty"` +} + +// handleMeProfile GET/PATCH /api/v1/me/profile +func (s *Server) handleMeProfile(w http.ResponseWriter, r *http.Request) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + switch r.Method { + case http.MethodGet: + writeJSONResponse(w, http.StatusOK, map[string]any{ + "id": user.ID, "username": user.Username, "name": user.Name, + "email": user.Email, "locale": user.Locale, "timezone": user.Timezone, + "department": user.Department, "employee_id": user.EmployeeID, "position": user.Position, + }) + case http.MethodPatch: + var patch userProfilePatch + if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + updated := *user + if patch.Name != nil { + updated.Name = *patch.Name + } + if patch.Email != nil { + updated.Email = *patch.Email + } + if patch.Locale != nil { + updated.Locale = *patch.Locale + } + if patch.Timezone != nil { + updated.Timezone = *patch.Timezone + } + saved, err := s.authSvc.UpdateUserProfile(r.Context(), &updated) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSONResponse(w, http.StatusOK, saved) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// handleMePreferences GET/PUT /api/v1/me/preferences +func (s *Server) handleMePreferences(w http.ResponseWriter, r *http.Request) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + switch r.Method { + case http.MethodGet: + prefs, err := s.authSvc.GetUserPreferences(r.Context(), user.ID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSONResponse(w, http.StatusOK, prefs) + case http.MethodPut: + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + prefs, err := s.authSvc.SaveUserPreferences(r.Context(), user.ID, body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSONResponse(w, http.StatusOK, prefs) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} diff --git a/internal/server/project_handlers.go b/internal/server/project_handlers.go index 810dc34c..f6bcab14 100644 --- a/internal/server/project_handlers.go +++ b/internal/server/project_handlers.go @@ -20,6 +20,16 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`, http.StatusUnauthorized) return } + if ok, permErr := s.userHasSystemPermission(r.Context(), user, "project:create"); permErr != nil { + http.Error(w, `{"error":"ERR_INTERNAL","message":"`+permErr.Error()+`"}`, http.StatusInternalServerError) + return + } else if !ok { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "无权创建项目", + }) + return + } var req projectworkflow.CreateProjectInput if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -60,7 +70,21 @@ func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) { return } - list, err := s.pwSvc.ListProjects(r.Context(), user.ID) + isAdmin, err := s.authzSvc.IsSystemAdmin(r.Context(), user.ID, user.RoleID) + if err != nil { + writeJSONResponse(w, http.StatusInternalServerError, map[string]string{ + "error_code": "ERR_AUTHZ", + "message": "权限判定失败", + }) + return + } + + var list []*projectworkflow.Project + if isAdmin { + list, err = s.pwSvc.ListAllProjects(r.Context()) + } else { + list, err = s.pwSvc.ListProjects(r.Context(), user.ID) + } if err != nil { http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError) return @@ -122,7 +146,7 @@ func (s *Server) handleListProjectWorkflows(w http.ResponseWriter, r *http.Reque writeJSONResponse(w, http.StatusOK, wfs) } -// handleActivateWorkflow 切换项目当前激活的工作流焦点(创建人权限受控) +// handleActivateWorkflow 旧式工作流切换 API(已退役)。 func (s *Server) handleActivateWorkflow(w http.ResponseWriter, r *http.Request) { idStr := r.PathValue("id") id, err := strconv.ParseInt(idStr, 10, 64) @@ -137,6 +161,11 @@ func (s *Server) handleActivateWorkflow(w http.ResponseWriter, r *http.Request) return } + if s.instSvc != nil && s.instSvc.Available() { + http.Error(w, `{"error":"WORKFLOW_SWITCH_API_RETIRED","message":"请通过项目设置完成工作流规划与激活"}`, http.StatusGone) + return + } + if s.blockLegacyWorkflowSwitch(w, r, id) { return } @@ -206,12 +235,18 @@ func (s *Server) handleRunProjectWorkflow(w http.ResponseWriter, r *http.Request if r.Body != nil { _ = json.NewDecoder(r.Body).Decode(&req) } + if req.TaskKey != "" && req.TaskKey != def.TaskKey && s.failClosedAuthz() { + http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"客户端不可覆盖 task_key"}`, http.StatusBadRequest) + return + } + if !s.allowWorkflowDocTaskAccess(r.Context(), id, user.ID, def.TaskKey) { + s.writeWorkflowAccessDenied(w) + return + } req.ProjectID = id req.WorkflowKey = wfKey req.UserID = user.ID - if req.TaskKey == "" { - req.TaskKey = def.TaskKey - } + req.TaskKey = def.TaskKey bodyBytes, _ := json.Marshal(req) r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) @@ -226,6 +261,9 @@ func (s *Server) handleListProjectWorkflowRuns(w http.ResponseWriter, r *http.Re http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest) return } + if _, ok := s.requireProjectAccess(w, r, id); !ok { + return + } wfKey := r.PathValue("workflow_key") runs, err := s.pwSvc.ListWorkflowRuns(r.Context(), id, wfKey, 20) if err != nil { @@ -240,6 +278,16 @@ func (s *Server) handleListProjectWorkflowRuns(w http.ResponseWriter, r *http.Re // handleGetProjectLineage 查询项目文档血缘关系及失效状态 func (s *Server) handleGetProjectLineage(w http.ResponseWriter, r *http.Request) { + idStr := r.PathValue("id") + projectID, _ := strconv.ParseInt(idStr, 10, 64) + if 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 + } + docID := strings.TrimSpace(r.URL.Query().Get("doc_id")) if docID == "" { idStr := r.PathValue("id") @@ -273,4 +321,3 @@ func (s *Server) handleGetProjectLineage(w http.ResponseWriter, r *http.Request) } writeJSONResponse(w, http.StatusOK, lineages) } - diff --git a/internal/server/project_handlers_test.go b/internal/server/project_handlers_test.go index 02d34039..408c659c 100644 --- a/internal/server/project_handlers_test.go +++ b/internal/server/project_handlers_test.go @@ -9,8 +9,49 @@ import ( "chat-agent-system/internal/bizdev" "chat-agent-system/internal/config" + "chat-agent-system/internal/projectworkflow" ) +func TestHandleListProjects_AdminCanSeeProjectsOwnedByOtherUsers(t *testing.T) { + cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: true} + svc := bizdev.MustNewService(t, cfg) + srv := NewServer(cfg, svc) + ctx := t.Context() + + creator, err := srv.AuthService().GetUserByID(ctx, 2) + if err != nil { + t.Fatalf("load creator: %v", err) + } + created, err := srv.pwSvc.CreateProject(ctx, creator, projectworkflow.CreateProjectInput{ + Name: "非管理员创建的项目", + }) + if err != nil { + t.Fatalf("create project: %v", err) + } + + admin, err := srv.AuthService().GetUserByID(ctx, 1) + if err != nil { + t.Fatalf("load admin: %v", err) + } + req := httptest.NewRequest(http.MethodGet, "/api/v1/projects", nil) + req = req.WithContext(withPrincipal(req.Context(), admin)) + rec := httptest.NewRecorder() + srv.handleListProjects(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + var projects []*projectworkflow.Project + if err := json.NewDecoder(rec.Body).Decode(&projects); err != nil { + t.Fatalf("decode projects: %v", err) + } + for _, project := range projects { + if project.ID == created.ID { + return + } + } + t.Fatalf("admin response should include project %d", created.ID) +} + func TestHandleRunProjectWorkflow_DecodesBodyWithoutContentLength(t *testing.T) { cfg := &config.Config{ DataDir: t.TempDir(), diff --git a/internal/server/project_members_handlers.go b/internal/server/project_members_handlers.go index c683ad22..8825291b 100644 --- a/internal/server/project_members_handlers.go +++ b/internal/server/project_members_handlers.go @@ -29,7 +29,7 @@ func (s *Server) handleProjectMembers(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - s.handleListProjectMembers(w, r, user, projectID) + s.handleListProjectMembers(w, r, projectID) case http.MethodPost: s.handleAddProjectMember(w, r, user, projectID) case http.MethodPut: @@ -41,16 +41,8 @@ func (s *Server) handleProjectMembers(w http.ResponseWriter, r *http.Request) { } } -func (s *Server) handleListProjectMembers(w http.ResponseWriter, r *http.Request, user *auth.UserAccount, projectID int64) { - if err := s.authorizer.RequireProjectAccess(r.Context(), projectID, user.ID); err != nil { - if err == authz.ErrForbidden { - writeJSONResponse(w, http.StatusForbidden, map[string]string{ - "error_code": "ERR_FORBIDDEN", - "message": "无权访问该项目", - }) - return - } - http.Error(w, err.Error(), http.StatusInternalServerError) +func (s *Server) handleListProjectMembers(w http.ResponseWriter, r *http.Request, projectID int64) { + if _, ok := s.requireProjectAccess(w, r, projectID); !ok { return } members, err := s.authorizer.ListProjectMembers(r.Context(), projectID) diff --git a/internal/server/project_settings_handlers.go b/internal/server/project_settings_handlers.go index 59913737..de370805 100644 --- a/internal/server/project_settings_handlers.go +++ b/internal/server/project_settings_handlers.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "strconv" + "time" "chat-agent-system/internal/projectinstance" ) @@ -52,7 +53,29 @@ func (s *Server) handleGetProjectSettings(w http.ResponseWriter, r *http.Request writeProjectInstanceError(w, err) return } - writeJSONResponse(w, http.StatusOK, settings) + type settingsResponse struct { + *projectinstance.ProjectSettings + RecentWorkflowEvents []workflowEventSummary `json:"recent_workflow_events,omitempty"` + } + out := settingsResponse{ProjectSettings: settings} + if s.notifySvc != nil && s.notifySvc.Available() { + items, listErr := s.notifySvc.ListProjectNotifications(r.Context(), user.ID, projectID, 0, 20) + if listErr == nil { + for _, n := range items { + out.RecentWorkflowEvents = append(out.RecentWorkflowEvents, workflowEventSummary{ + ID: n.ID, Type: n.Type, Title: n.Title, CreatedAt: n.CreatedAt.Format(time.RFC3339), + }) + } + } + } + writeJSONResponse(w, http.StatusOK, out) +} + +type workflowEventSummary struct { + ID int64 `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + CreatedAt string `json:"created_at"` } func (s *Server) handleSaveWorkflowPlan(w http.ResponseWriter, r *http.Request) { @@ -145,7 +168,16 @@ func writeProjectInstanceError(w http.ResponseWriter, err error) { 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) + var validationErr *projectinstance.ValidationError + if errors.As(err, &validationErr) { + writeJSONResponse(w, http.StatusBadRequest, map[string]any{ + "error": "VALIDATION_FAILED", + "message": "工作流配置校验未通过", + "issues": validationErr.Issues, + }) + return + } + http.Error(w, `{"error":"VALIDATION_FAILED","message":"工作流配置校验未通过"}`, http.StatusBadRequest) case errors.Is(err, projectinstance.ErrTokenInvalid): http.Error(w, `{"error":"TOKEN_INVALID","message":"激活令牌无效或已过期"}`, http.StatusBadRequest) case errors.Is(err, projectinstance.ErrConcurrentConflict): diff --git a/internal/server/project_workflow_api_test.go b/internal/server/project_workflow_api_test.go index 353fc87d..edbe2d58 100644 --- a/internal/server/project_workflow_api_test.go +++ b/internal/server/project_workflow_api_test.go @@ -207,18 +207,18 @@ func TestProjectWorkflow_TestManagerAndLineageStale(t *testing.T) { require.Positive(t, proj.ID) projectID := proj.ID - // 2. 在未批准系统需求规格书时,直接触发 test_manager -> 应触发前置阻断 400 / ERR_PRECONDITION_FAILED - bodyRunTestFail, _ := json.Marshal(map[string]any{ + // 2. 无已批准 SRS 时,test_manager 仍可独立启动(PM/TE 解耦) + bodyRunTestNoSRS, _ := json.Marshal(map[string]any{ "task_key": "test_manager", "project_name": proj.Name, "backend": "stub", }) - reqRunTestFail := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/workflows/test_manager/run", projectID), bytes.NewReader(bodyRunTestFail)) - withUserIDAuth(reqRunTestFail, loginTestUser(t, handler, "zhanggong", "123456")) - recRunTestFail := httptest.NewRecorder() - handler.ServeHTTP(recRunTestFail, reqRunTestFail) - assert.Equal(t, http.StatusBadRequest, recRunTestFail.Code) - assert.Contains(t, recRunTestFail.Body.String(), "前置条件未满足") + reqRunTestNoSRS := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/workflows/test_manager/run", projectID), bytes.NewReader(bodyRunTestNoSRS)) + withUserIDAuth(reqRunTestNoSRS, loginTestUser(t, handler, "zhanggong", "123456")) + recRunTestNoSRS := httptest.NewRecorder() + handler.ServeHTTP(recRunTestNoSRS, reqRunTestNoSRS) + assert.Equal(t, http.StatusOK, recRunTestNoSRS.Code) + assert.NotContains(t, recRunTestNoSRS.Body.String(), "前置条件未满足") // 3. 在受控治理中创建并批准系统需求规格书 srDocID := fmt.Sprintf("doc_proj%d_system_reqs", projectID) diff --git a/internal/server/server.go b/internal/server/server.go index d73001d4..8a29c905 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -109,6 +109,7 @@ func NewServer(cfg *config.Config, svc *bizdev.Service) *Server { } if instSvc != nil && instSvc.Available() { svc.SetStageAccessGate(newBizdevStageGate(instSvc, pwSvc)) + coordEngine.SetInstanceService(instSvc) } s.registerWorkflowApprovalHook() s.startNotificationReminderLoop() @@ -164,7 +165,10 @@ func (s *Server) Routes() http.Handler { 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/workflow-instances/{id}/termination-preview", s.handleTerminationPreview) + mux.HandleFunc("POST /api/v1/workflow-instances/{id}/terminate", s.handleTerminateWorkflowInstance) mux.HandleFunc("GET /api/v1/workflows", s.handleListWorkflowDefinitions) + mux.HandleFunc("GET /api/v1/workflows/catalog", s.handleWorkflowCatalog) 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) @@ -248,10 +252,16 @@ func (s *Server) Routes() http.Handler { // 9. 用户认证、安全改密与超级管理员后台接口 mux.HandleFunc("POST /api/v1/auth/login", s.handleAuthLogin) mux.HandleFunc("POST /api/v1/auth/register", s.handleAuthRegister) + mux.HandleFunc("GET /api/v1/auth/public-config", s.handleAuthPublicConfig) mux.HandleFunc("POST /api/v1/auth/logout", s.handleAuthLogout) mux.HandleFunc("POST /api/v1/auth/change-password", s.handleAuthChangePassword) mux.HandleFunc("GET /api/v1/auth/me", s.handleAuthMe) + mux.HandleFunc("GET /api/v1/me/profile", s.handleMeProfile) + mux.HandleFunc("PATCH /api/v1/me/profile", s.handleMeProfile) + mux.HandleFunc("GET /api/v1/me/preferences", s.handleMePreferences) + mux.HandleFunc("PUT /api/v1/me/preferences", s.handleMePreferences) mux.HandleFunc("GET /api/v1/auth/sessions", s.handleAuthSessions) + mux.HandleFunc("GET /api/v1/admin/permissions", s.handleAdminPermissions) mux.HandleFunc("DELETE /api/v1/auth/sessions/{id}", s.handleAuthSessionByID) mux.HandleFunc("GET /api/v1/admin/roles", s.handleAdminRoles) mux.HandleFunc("POST /api/v1/admin/roles", s.handleAdminRoles) @@ -332,6 +342,9 @@ func (s *Server) Start() error { Addr: addr, Handler: s.Routes(), ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 60 * time.Second, + WriteTimeout: 120 * time.Second, + IdleTimeout: 120 * time.Second, } return s.httpServer.ListenAndServe() diff --git a/internal/server/sse.go b/internal/server/sse.go index 4bb43ddf..4ad7f2ad 100644 --- a/internal/server/sse.go +++ b/internal/server/sse.go @@ -307,11 +307,30 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) { req.Backend = q.Get("backend") req.WorkDir = q.Get("work_dir") req.AutoRepair, _ = strconv.ParseBool(q.Get("auto_repair")) + if pid := q.Get("project_id"); pid != "" { + req.ProjectID, _ = strconv.ParseInt(pid, 10, 64) + } if inc := q.Get("include_details"); inc != "" { includeDetails, _ = strconv.ParseBool(inc) } } + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + if req.ProjectID > 0 { + if !s.isSystemAdminUser(r.Context(), user) { + if err := s.authorizer.RequireProjectAccess(r.Context(), req.ProjectID, user.ID); err != nil { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "无权访问该项目", + }) + return + } + } + } + // 请求追踪 ID:与 chat stream 对齐,注入 ctx 并回写响应头 requestID := fmt.Sprintf("req_%d", time.Now().UnixNano()) w.Header().Set("X-Request-Id", requestID) @@ -322,11 +341,6 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Accel-Buffering", "no") // 禁用 Nginx 等反代缓冲 flusher.Flush() - user, ok := s.requirePrincipal(w, r) - if !ok { - return - } - if req.TaskKey == "" && req.Stage == "" { req.TaskKey = "intake" } diff --git a/internal/server/stage_access_helpers.go b/internal/server/stage_access_helpers.go index c69f3d26..5cf45916 100644 --- a/internal/server/stage_access_helpers.go +++ b/internal/server/stage_access_helpers.go @@ -20,20 +20,64 @@ func (s *Server) resolveStageAccess(ctx context.Context, projectID, userID int64 return s.instSvc.ResolveStageAccess(ctx, projectID, userID, isCreator) } -func (s *Server) allowWorkflowDocTaskAccess(ctx context.Context, projectID, userID int64, taskKey string) bool { +func (s *Server) checkWorkflowDocTaskAccess(ctx context.Context, projectID, userID int64, taskKey string) projectinstance.DocAccessDecision { if taskKey == "" { - return true + return projectinstance.DocAccessDecision{Allowed: true} + } + if s.instSvc != nil && s.instSvc.Available() && projectID > 0 && userID > 0 { + live, liveErr := s.instSvc.HasLiveInstance(ctx, projectID) + if liveErr == nil && live { + isCreator, _ := s.pwSvc.IsProjectCreator(ctx, projectID, userID) + return s.instSvc.CheckDocTaskAccess(ctx, projectID, userID, taskKey, projectinstance.DocAccessActionRun, isCreator) + } } access, err := s.resolveStageAccess(ctx, projectID, userID) - if err != nil || access == nil || !access.Restricted { + if err != nil { + if s.failClosedAuthz() { + return projectinstance.DocAccessDecision{Allowed: false, Code: "WORKFLOW_ACCESS_DENIED", Message: "无权访问该阶段内容"} + } + return projectinstance.DocAccessDecision{Allowed: true} + } + if access == nil || !access.Restricted || access.TaskKeyAllowed(taskKey) { + return projectinstance.DocAccessDecision{Allowed: true} + } + return projectinstance.DocAccessDecision{Allowed: false, Code: "WORKFLOW_ACCESS_DENIED", Message: "无权访问该阶段内容"} +} + +func (s *Server) allowWorkflowDocTaskAccess(ctx context.Context, projectID, userID int64, taskKey string) bool { + return s.checkWorkflowDocTaskAccess(ctx, projectID, userID, taskKey).Allowed +} + +func (s *Server) enforceWorkflowDocTaskAccess(w http.ResponseWriter, ctx context.Context, projectID, userID int64, taskKey string) bool { + decision := s.checkWorkflowDocTaskAccess(ctx, projectID, userID, taskKey) + if decision.Allowed { return true } - return access.TaskKeyAllowed(taskKey) + s.writeDocAccessDecision(w, decision) + return false +} + +func (s *Server) writeDocAccessDecision(w http.ResponseWriter, decision projectinstance.DocAccessDecision) { + status := http.StatusForbidden + if decision.Code == projectinstance.AccessCodeDocTaskDisabled { + status = http.StatusConflict + } + msg := decision.Message + if msg == "" { + msg = "无权访问该文档任务" + } + payload, _ := json.Marshal(map[string]string{"error": decision.Code, "message": msg}) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(payload) } 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 { + if err != nil { + return !s.failClosedAuthz() + } + if access == nil || !access.Restricted { return true } return len(access.AllowedStageKeys) > 0 @@ -41,7 +85,10 @@ func (s *Server) allowAnyStageAccess(ctx context.Context, projectID, userID int6 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 { + if err != nil { + return !s.failClosedAuthz() + } + if access == nil || !access.Restricted { return true } return access.StageKeyAllowed(access.ActiveStageKey) @@ -67,7 +114,13 @@ func (s *Server) filterWorkflowDocsByStageAccess( return docs } access, err := s.resolveStageAccess(ctx, projectID, userID) - if err != nil || access == nil || !access.Restricted { + if err != nil { + if s.failClosedAuthz() { + return nil + } + return docs + } + if access == nil || !access.Restricted { return docs } out := make([]docgov.WorkflowDocumentSummary, 0, len(docs)) @@ -88,7 +141,13 @@ func (s *Server) filterChatMessagesByStageAccess( return msgs } access, err := s.resolveStageAccess(ctx, projectID, userID) - if err != nil || access == nil || !access.Restricted { + if err != nil { + if s.failClosedAuthz() { + return nil + } + return msgs + } + if access == nil || !access.Restricted { return msgs } out := make([]ctxmgr.ChatMessageRecord, 0, len(msgs)) @@ -113,7 +172,13 @@ func (s *Server) filterChatSearchHitsByStageAccess( return hits } access, err := s.resolveStageAccess(ctx, projectID, userID) - if err != nil || access == nil || !access.Restricted { + if err != nil { + if s.failClosedAuthz() { + return nil + } + return hits + } + if access == nil || !access.Restricted { return hits } out := make([]ctxmgr.ChatSearchHit, 0, len(hits)) diff --git a/internal/server/task_run_auth.go b/internal/server/task_run_auth.go index a849ee13..92ad50c0 100644 --- a/internal/server/task_run_auth.go +++ b/internal/server/task_run_auth.go @@ -7,7 +7,15 @@ import ( "chat-agent-system/internal/auth" ) -func (s *Server) assertTaskRunOwnedByUser(w http.ResponseWriter, ctx context.Context, runID string, user *auth.UserAccount) bool { +type taskRunAction string + +const ( + taskRunView taskRunAction = "view" + taskRunCancel taskRunAction = "cancel" + taskRunResume taskRunAction = "resume" +) + +func (s *Server) assertTaskRunAccess(w http.ResponseWriter, ctx context.Context, runID string, user *auth.UserAccount, action taskRunAction) bool { if user == nil { writeJSONResponse(w, http.StatusUnauthorized, map[string]string{ "error_code": "ERR_UNAUTHENTICATED", @@ -23,11 +31,18 @@ func (s *Server) assertTaskRunOwnedByUser(w http.ResponseWriter, ctx context.Con if s.isSystemAdminUser(ctx, user) { return true } - if rec.UserID > 0 && rec.UserID == user.ID { - return true - } + isOwner := rec.UserID > 0 && rec.UserID == user.ID + isProjectOwner := false if rec.ProjectID > 0 && s.authorizer != nil { - if err := s.authorizer.RequireProjectAccess(ctx, rec.ProjectID, user.ID); err == nil { + isProjectOwner, _ = s.authorizer.IsProjectOwner(ctx, rec.ProjectID, user.ID) + } + switch action { + case taskRunView: + if isOwner || isProjectOwner { + return true + } + case taskRunCancel, taskRunResume: + if isOwner || isProjectOwner { return true } } @@ -38,6 +53,10 @@ func (s *Server) assertTaskRunOwnedByUser(w http.ResponseWriter, ctx context.Con return false } +func (s *Server) assertTaskRunOwnedByUser(w http.ResponseWriter, ctx context.Context, runID string, user *auth.UserAccount) bool { + return s.assertTaskRunAccess(w, ctx, runID, user, taskRunView) +} + func (s *Server) isSystemAdminUser(ctx context.Context, user *auth.UserAccount) bool { if user == nil { return false diff --git a/internal/server/task_run_authz_pg_test.go b/internal/server/task_run_authz_pg_test.go new file mode 100644 index 00000000..df4bf484 --- /dev/null +++ b/internal/server/task_run_authz_pg_test.go @@ -0,0 +1,123 @@ +package server_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "chat-agent-system/internal/projectworkflow" +) + +func TestTaskRunAuthz_OutsiderForbidden_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") + outsiderToken := loginTestUser(t, handler, "lizhuguan", "123456") + + createBody, _ := json.Marshal(projectworkflow.CreateProjectInput{Name: "Task Run Authz 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 %s", recCreate.Code, recCreate.Body.String()) + } + var proj projectworkflow.Project + _ = json.Unmarshal(recCreate.Body.Bytes(), &proj) + t.Cleanup(func() { cleanupProject(t, pool, proj.ID) }) + + var wfRunsBefore, taskRunsBefore int + _ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM workflow_runs WHERE project_id = $1`, proj.ID).Scan(&wfRunsBefore) + _ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM task_runs WHERE project_id = $1`, proj.ID).Scan(&taskRunsBefore) + + syncBody, _ := json.Marshal(map[string]interface{}{ + "task_key": "intake", + "project_id": proj.ID, + "backend": "stub", + }) + reqSync := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/run", bytes.NewReader(syncBody)) + withBearerAuth(reqSync, outsiderToken) + recSync := httptest.NewRecorder() + handler.ServeHTTP(recSync, reqSync) + if recSync.Code != http.StatusForbidden { + t.Fatalf("outsider sync tasks/run expected 403, got %d: %s", recSync.Code, recSync.Body.String()) + } + + streamBody, _ := json.Marshal(map[string]interface{}{ + "task_key": "intake", + "project_id": proj.ID, + "backend": "stub", + }) + reqStream := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/run/stream", bytes.NewReader(streamBody)) + withBearerAuth(reqStream, outsiderToken) + recStream := httptest.NewRecorder() + handler.ServeHTTP(recStream, reqStream) + if recStream.Code != http.StatusForbidden { + t.Fatalf("outsider stream tasks/run/stream expected 403, got %d: %s", recStream.Code, recStream.Body.String()) + } + + var wfRunsAfter, taskRunsAfter int + _ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM workflow_runs WHERE project_id = $1`, proj.ID).Scan(&wfRunsAfter) + _ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM task_runs WHERE project_id = $1`, proj.ID).Scan(&taskRunsAfter) + if wfRunsAfter != wfRunsBefore { + t.Fatalf("workflow_runs changed for forbidden sync: before=%d after=%d", wfRunsBefore, wfRunsAfter) + } + if taskRunsAfter != taskRunsBefore { + t.Fatalf("task_runs changed for forbidden stream: before=%d after=%d", taskRunsBefore, taskRunsAfter) + } + + // 匿名请求 + reqAnon := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/run", bytes.NewReader(syncBody)) + recAnon := httptest.NewRecorder() + handler.ServeHTTP(recAnon, reqAnon) + if recAnon.Code != http.StatusUnauthorized { + t.Fatalf("anonymous tasks/run expected 401, got %d", recAnon.Code) + } +} + +func TestTaskRunAuthz_MemberAllowed_PG(t *testing.T) { + pool := openPGPoolWithMigrations(t) + if pool == nil { + return + } + defer pool.Close() + + handler, _ := setupPGGovernanceServer(t, pool) + creatorToken := loginTestUser(t, handler, "zhanggong", "123456") + + createBody, _ := json.Marshal(projectworkflow.CreateProjectInput{Name: fmt.Sprintf("Task Run Member %d", time.Now().UnixNano())}) + 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) }) + + syncBody, _ := json.Marshal(map[string]interface{}{ + "task_key": "intake", + "project_id": proj.ID, + "backend": "stub", + }) + reqSync := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/run", bytes.NewReader(syncBody)) + withBearerAuth(reqSync, creatorToken) + recSync := httptest.NewRecorder() + handler.ServeHTTP(recSync, reqSync) + if recSync.Code != http.StatusOK { + t.Fatalf("member sync tasks/run expected 200, got %d: %s", recSync.Code, recSync.Body.String()) + } +} diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index f1de1a9d..e4c5ad1d 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -68,6 +68,9 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { if req.TaskKey == "" && req.Stage == "" { req.TaskKey = "intake" } + if req.ProjectID > 0 && req.TaskKey != "" && !s.enforceWorkflowDocTaskAccess(w, r.Context(), req.ProjectID, user.ID, req.TaskKey) { + return + } req.WorkDir = "" ctxMgr := s.svc.ContextManager() @@ -196,7 +199,7 @@ func (s *Server) handleCancelTaskRun(w http.ResponseWriter, r *http.Request) { http.Error(w, "run_id required", http.StatusBadRequest) return } - if !s.assertTaskRunOwnedByUser(w, r.Context(), runID, user) { + if !s.assertTaskRunAccess(w, r.Context(), runID, user, taskRunCancel) { return } if err := s.taskRuns.Cancel(runID); err != nil { @@ -256,7 +259,7 @@ func (s *Server) handleResumeTaskRun(w http.ResponseWriter, r *http.Request) { http.Error(w, "run_id required", http.StatusBadRequest) return } - if !s.assertTaskRunOwnedByUser(w, r.Context(), runID, user) { + if !s.assertTaskRunAccess(w, r.Context(), runID, user, taskRunResume) { return } diff --git a/internal/server/test_helpers_test.go b/internal/server/test_helpers_test.go index f38a5c34..690f98fc 100644 --- a/internal/server/test_helpers_test.go +++ b/internal/server/test_helpers_test.go @@ -2,13 +2,25 @@ package server_test import ( "bytes" + "context" "encoding/json" + "net" "net/http" "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" "chat-agent-system/internal/auth" "chat-agent-system/internal/config" + "chat-agent-system/internal/db" + "chat-agent-system/internal/workflowdef" ) func loginTestAdmin(t *testing.T, handler http.Handler, cfg *config.Config) string { @@ -67,3 +79,86 @@ func loginTestUser(t *testing.T, handler http.Handler, username, password string func withUserIDAuth(req *http.Request, token string) { req.Header.Set("Authorization", "Bearer "+token) } + +func configFromTestDatabaseURL(t *testing.T) *config.Config { + t.Helper() + dsn := db.SkipIfNoTestDatabaseURL(t) + parsed, err := url.Parse(dsn) + if err != nil { + t.Fatalf("parse TEST_DATABASE_URL: %v", err) + } + host, portStr, splitErr := net.SplitHostPort(parsed.Host) + if splitErr != nil { + host = parsed.Host + portStr = "5432" + } + port, _ := strconv.Atoi(portStr) + user := parsed.User.Username() + password, _ := parsed.User.Password() + dbName := strings.TrimPrefix(parsed.Path, "/") + return &config.Config{ + Env: "test", + DataDir: t.TempDir(), + AgentBackend: "stub", + AllowInMemory: false, + ContextDbHost: host, + ContextDbPort: port, + ContextDbUser: user, + ContextDbPassword: password, + ContextDbName: dbName, + AdminUsername: envOr("ADMIN_USERNAME", "admin"), + AdminPassword: envOr("ADMIN_PASSWORD", "admin-secret-password-123"), + } +} + +func envOr(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} + +func piAgentSharedDataDir(t *testing.T) string { + t.Helper() + if d := strings.TrimSpace(os.Getenv("DATA_DIR")); d != "" { + return d + } + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + for _, rel := range []string{".", "..", "../..", "../../.."} { + base := filepath.Clean(filepath.Join(cwd, rel)) + for _, envName := range []string{"dev", "test"} { + candidate := filepath.Join(base, "data", envName) + ws := filepath.Join(candidate, "workspaces") + if st, statErr := os.Stat(ws); statErr == nil && st.IsDir() { + return candidate + } + } + } + t.Skip("shared pi-agent DATA_DIR not found (mount data/test/workspaces or data/dev/workspaces)") + return "" +} + +func seedOfficialWorkflowManifest(t *testing.T, pool *pgxpool.Pool, workflowKey string) { + t.Helper() + filename, ok := workflowdef.V2ManifestFiles[workflowKey] + if !ok { + t.Fatalf("unknown official workflow key %q", workflowKey) + } + def, err := workflowdef.LoadManifestDefinition(filename) + if err != nil { + t.Fatalf("load manifest %s: %v", filename, err) + } + wfStore := workflowdef.NewStore(pool) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := wfStore.UpsertPublishedSeed(ctx, workflowdef.CreateDefinitionInput{ + Key: workflowKey, + Name: workflowdef.OfficialWorkflowName(workflowKey), + Definition: def, + }, 1); err != nil { + t.Fatalf("seed official workflow %s: %v", workflowKey, err) + } +} diff --git a/internal/server/workflow_catalog_handlers.go b/internal/server/workflow_catalog_handlers.go new file mode 100644 index 00000000..ad33845e --- /dev/null +++ b/internal/server/workflow_catalog_handlers.go @@ -0,0 +1,54 @@ +package server + +import ( + "net/http" + + "chat-agent-system/internal/workflowdef" +) + +type workflowCatalogItem struct { + Key string `json:"key"` + Name string `json:"name"` + LatestVersion int `json:"latest_version"` + ProductDomain string `json:"product_domain,omitempty"` +} + +func (s *Server) handleWorkflowCatalog(w http.ResponseWriter, r *http.Request) { + if s.wfDefSvc == nil || !s.wfDefSvc.Available() { + http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"workflow catalog unavailable"}`, http.StatusServiceUnavailable) + return + } + defs, err := s.wfDefSvc.Store().ListProjectSelectableDefinitions(r.Context()) + if err != nil { + http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError) + return + } + items := make([]workflowCatalogItem, 0, len(workflowdef.OfficialWorkflowKeys())) + seen := map[string]bool{} + for _, d := range defs { + if !workflowdef.IsOfficialWorkflowKey(d.Key) || seen[d.Key] { + continue + } + seen[d.Key] = true + name := d.Name + if name == "" { + name = workflowdef.OfficialWorkflowName(d.Key) + } + items = append(items, workflowCatalogItem{ + Key: d.Key, + Name: name, + LatestVersion: d.LatestPublishedVersion, + ProductDomain: d.ProductDomain, + }) + } + for _, key := range workflowdef.OfficialWorkflowKeys() { + if seen[key] { + continue + } + items = append(items, workflowCatalogItem{ + Key: key, + Name: workflowdef.OfficialWorkflowName(key), + }) + } + writeJSONResponse(w, http.StatusOK, map[string]any{"workflows": items}) +} diff --git a/internal/server/workflow_governance_pg_test.go b/internal/server/workflow_governance_pg_test.go index cdcc2793..bad064d4 100644 --- a/internal/server/workflow_governance_pg_test.go +++ b/internal/server/workflow_governance_pg_test.go @@ -7,7 +7,6 @@ import ( "fmt" "net/http" "net/http/httptest" - "os" "testing" "time" @@ -15,7 +14,6 @@ import ( "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" @@ -25,10 +23,7 @@ import ( 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" - } + dsn := db.SkipIfNoTestDatabaseURL(t) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() pool, err := pgxpool.New(ctx, dsn) @@ -53,9 +48,10 @@ func openPGPoolWithMigrations(t *testing.T) *pgxpool.Pool { t.Skip("workflow/project instance tables not ready") } if auth.IAMTablesReady(ctx, pool) { - authStore, err := auth.NewPgStore(pool, &config.Config{Env: "dev"}) + testCfg := configFromTestDatabaseURL(t) + authStore, err := auth.NewPgStore(pool, testCfg) if err == nil { - _ = auth.ReactivateDevSeedUsers(ctx, authStore, &config.Config{Env: "dev"}) + _ = auth.ReactivateDevSeedUsers(ctx, authStore, testCfg) } } return pool @@ -86,19 +82,7 @@ func fetchAuthUserID(t *testing.T, handler http.Handler, token string) int64 { func setupPGGovernanceServer(t *testing.T, pool *pgxpool.Pool) (http.Handler, *server.Server) { 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", - } + cfg := configFromTestDatabaseURL(t) svc := bizdev.MustNewService(t, cfg) if svc.Pool() == nil { t.Skip("bizdev service not connected to postgres") @@ -117,36 +101,8 @@ func TestWorkflowGovernance_PG_ActivateApproveAdvanceChain(t *testing.T) { 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) - } + wfKey := workflowdef.OfficialKeyMain + seedOfficialWorkflowManifest(t, pool, wfKey) creatorToken := loginTestUser(t, handler, "zhanggong", "123456") guestToken := loginTestUser(t, handler, "guest", "123456") @@ -155,6 +111,7 @@ func TestWorkflowGovernance_PG_ActivateApproveAdvanceChain(t *testing.T) { // 1. 创建项目(PG 直写,与 projectinstance PG 集成测试一致) var projectID int64 code := "pg-p5-" + time.Now().Format("150405.000") + var err error 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 @@ -174,7 +131,7 @@ func TestWorkflowGovernance_PG_ActivateApproveAdvanceChain(t *testing.T) { _, _ = 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) + reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=0", projectID, wfKey), nil) withBearerAuth(reqDefault, creatorToken) recDefault := httptest.NewRecorder() handler.ServeHTTP(recDefault, reqDefault) @@ -286,10 +243,10 @@ func TestWorkflowGovernance_PG_ActivateApproveAdvanceChain(t *testing.T) { // 7. 创建者推进阶段 advanceBody, _ := json.Marshal(projectinstance.AdvanceStageInput{ - StageKey: "biz_intake", + StageKey: "V0.1_triage", ExpectedConfigRevision: activated.Instance.ConfigRevision, }) - reqAdvance := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/workflow-instances/%d/stages/biz_intake/advance", instanceID), bytes.NewReader(advanceBody)) + reqAdvance := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/workflow-instances/%d/stages/V0.1_triage/advance", instanceID), bytes.NewReader(advanceBody)) withBearerAuth(reqAdvance, creatorToken) recAdvance := httptest.NewRecorder() handler.ServeHTTP(recAdvance, reqAdvance) @@ -300,7 +257,7 @@ func TestWorkflowGovernance_PG_ActivateApproveAdvanceChain(t *testing.T) { if err := json.Unmarshal(recAdvance.Body.Bytes(), &advanceResult); err != nil { t.Fatalf("decode advance: %v", err) } - if advanceResult.NextStageKey != "biz_feasibility" && !advanceResult.InstanceDone { + if advanceResult.NextStageKey != "V0.5_feasibility" && !advanceResult.InstanceDone { t.Fatalf("unexpected advance result: %+v", advanceResult) } diff --git a/internal/server/workflow_governance_piagent_pg_test.go b/internal/server/workflow_governance_piagent_pg_test.go index 279b7d5e..d60f4653 100644 --- a/internal/server/workflow_governance_piagent_pg_test.go +++ b/internal/server/workflow_governance_piagent_pg_test.go @@ -13,12 +13,10 @@ import ( "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" ) @@ -34,22 +32,18 @@ func piAgentRunnerHealthy() bool { 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", + cfg := configFromTestDatabaseURL(t) + cfg.DataDir = piAgentSharedDataDir(t) + cfg.AgentBackend = "pi-agent" + cfg.AgentTimeout = 60 * time.Minute + cfg.PiAgentRunnerURL = envOr("PI_AGENT_RUNNER_URL", "http://127.0.0.1:18865") + cfg.PiAgentRunnerAuthToken = envOr("PI_AGENT_RUNNER_AUTH_TOKEN", envOr("RUNNER_AUTH_TOKEN", "")) + cfg.PiAgentModel = "MiniMax-M3" + cfg.PiAgentProvider = "minimax" + if cfg.PiAgentRunnerAuthToken == "" { + t.Skip("RUNNER_AUTH_TOKEN not set; skipping pi-agent PG E2E") } + mergePiAgentE2EKbConfig(cfg) svc := bizdev.MustNewService(t, cfg) if svc.Pool() == nil { t.Skip("bizdev service not connected to postgres") @@ -58,11 +52,17 @@ func setupPGPiAgentServer(t *testing.T, pool *pgxpool.Pool) (http.Handler, *serv 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) { +type piAgentGovernanceCase struct { + WfKey string + TaskKey string + StageKey string + ProjectName string + ExpectPlanningDone bool + Materials []bizdev.MaterialInput +} + +func runPiAgentGovernanceE2E(t *testing.T, spec piAgentGovernanceCase) { + t.Helper() if testing.Short() { t.Skip("skipping slow pi-agent PG E2E in short mode") } @@ -82,33 +82,7 @@ func TestWorkflowGovernance_PiAgent_PG_FullChain(t *testing.T) { 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) - } + seedOfficialWorkflowManifest(t, pool, spec.WfKey) creatorToken := loginTestUser(t, handler, "zhanggong", "123456") reviewerToken := loginTestUser(t, handler, "lizhuguan", "123456") @@ -117,9 +91,8 @@ func TestWorkflowGovernance_PiAgent_PG_FullChain(t *testing.T) { reviewerID := fetchAuthUserID(t, handler, reviewerToken) approverID := fetchAuthUserID(t, handler, approverToken) - // HTTP 创建中文名项目(验证 UTF-8 修复) createBody, _ := json.Marshal(projectworkflow.CreateProjectInput{ - Name: "PG PiAgent 治理链中文项目", + Name: spec.ProjectName, DeviceType: "Ultrasonic Device", IntendedUse: "Tissue resection", TargetMarket: "NMPA Class III", @@ -137,10 +110,9 @@ func TestWorkflowGovernance_PiAgent_PG_FullChain(t *testing.T) { } t.Cleanup(func() { cleanupProject(t, pool, proj.ID) }) projectID := proj.ID - t.Logf("✓ [1. 项目创建] id=%d code=%s", projectID, proj.Code) + t.Logf("✓ [1. 项目创建] id=%d code=%s wf=%s", projectID, proj.Code, spec.WfKey) - // 规划 + 激活 - reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=1", projectID, wfKey), nil) + reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=0", projectID, spec.WfKey), nil) withBearerAuth(reqDefault, creatorToken) recDefault := httptest.NewRecorder() handler.ServeHTTP(recDefault, reqDefault) @@ -188,10 +160,9 @@ func TestWorkflowGovernance_PiAgent_PG_FullChain(t *testing.T) { 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", + TaskKey: spec.TaskKey, ProjectName: proj.Name, DeviceType: proj.DeviceType, IntendedUse: "Tissue resection", @@ -201,11 +172,13 @@ func TestWorkflowGovernance_PiAgent_PG_FullChain(t *testing.T) { 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"}, - }}, + Fields: map[string]string{ + "customer_name": "PG PiAgent 治理链客户", + "device_type": proj.DeviceType, + "intended_use": "Tissue resection", + "target_market": "NMPA Class III", + }, + Materials: spec.Materials, }) reqTask, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/tasks/run", bytes.NewReader(taskBody)) withBearerAuth(reqTask, creatorToken) @@ -220,26 +193,20 @@ func TestWorkflowGovernance_PiAgent_PG_FullChain(t *testing.T) { 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"` + 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") + t.Fatalf("pi-agent %s task failed", spec.TaskKey) } - docID := "" - if taskRes.DocRecord != nil { + docID := fmt.Sprintf("doc_proj%d_%s", projectID, spec.TaskKey) + if taskRes.DocRecord != nil && taskRes.DocRecord.DocID != "" { 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)) + t.Logf("✓ [3. pi-agent %s] doc=%s title=%s elapsed=%s", spec.TaskKey, 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") @@ -250,7 +217,6 @@ func TestWorkflowGovernance_PiAgent_PG_FullChain(t *testing.T) { } 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() @@ -271,14 +237,54 @@ func TestWorkflowGovernance_PiAgent_PG_FullChain(t *testing.T) { } advanceBody, _ := json.Marshal(projectinstance.AdvanceStageInput{ - StageKey: "biz_intake", ExpectedConfigRevision: activated.Instance.ConfigRevision, + StageKey: spec.StageKey, ExpectedConfigRevision: activated.Instance.ConfigRevision, }) - reqAdvance := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/workflow-instances/%d/stages/biz_intake/advance", instanceID), bytes.NewReader(advanceBody)) + reqAdvance := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/workflow-instances/%d/stages/%s/advance", instanceID, spec.StageKey), 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 治理全链路完成") + if spec.ExpectPlanningDone { + reqSettings := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/settings", projectID), nil) + withBearerAuth(reqSettings, creatorToken) + recSettings := httptest.NewRecorder() + handler.ServeHTTP(recSettings, reqSettings) + var settings projectinstance.ProjectSettings + _ = json.Unmarshal(recSettings.Body.Bytes(), &settings) + if settings.LifecycleStatus != projectinstance.LifecyclePlanning { + t.Fatalf("expected planning after completion, got %q", settings.LifecycleStatus) + } + } + t.Logf("✓ [5. 阶段推进] PG + pi-agent 治理全链路完成 wf=%s", spec.WfKey) +} + +// TestWorkflowGovernance_PiAgent_PG_FullChain — 正式 main + pi-agent intake +func TestWorkflowGovernance_PiAgent_PG_FullChain(t *testing.T) { + runPiAgentGovernanceE2E(t, piAgentGovernanceCase{ + WfKey: workflowdef.OfficialKeyMain, + TaskKey: "intake", + StageKey: "V0.1_triage", + ProjectName: "PG PiAgent 治理链中文项目", + Materials: []bizdev.MaterialInput{{ + Filename: "project_brief.md", + Content: []byte("# 项目简报\n\n- 产品:超声软组织手术系统\n- 目标:NMPA III 类注册路径摸底\n- 关键需求:双频刀头、灌注与负压吸引\n"), + }}, + }) +} + +// TestWorkflowGovernance_PiAgent_PG_ProductManager_FullChain — 正式 product_manager + pi-agent system_reqs +func TestWorkflowGovernance_PiAgent_PG_ProductManager_FullChain(t *testing.T) { + runPiAgentGovernanceE2E(t, piAgentGovernanceCase{ + WfKey: workflowdef.OfficialKeyProductManager, + TaskKey: "system_reqs", + StageKey: "requirements_baseline", + ProjectName: "PG PiAgent 产品经理治理链", + ExpectPlanningDone: true, + Materials: []bizdev.MaterialInput{{ + Filename: "intake_summary.md", + Content: []byte("# 立项输入\n\n| REQ-01 | 双频超声刀头 24/35kHz | 高 |\n| REQ-02 | 灌注流量 10-50 mL/min | 高 |\n| REQ-03 | NMPA III 类注册路径 | 高 |\n"), + }}, + }) } diff --git a/internal/server/workflow_terminate_handlers.go b/internal/server/workflow_terminate_handlers.go new file mode 100644 index 00000000..e03bba1c --- /dev/null +++ b/internal/server/workflow_terminate_handlers.go @@ -0,0 +1,80 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "chat-agent-system/internal/projectinstance" +) + +func (s *Server) handleTerminationPreview(w http.ResponseWriter, r *http.Request) { + if s.instSvc == nil || !s.instSvc.Available() { + http.Error(w, `{"error":"ERR_NOT_AVAILABLE"}`, http.StatusServiceUnavailable) + return + } + instanceID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || instanceID <= 0 { + http.Error(w, `{"error":"ERR_INVALID_PARAM"}`, http.StatusBadRequest) + return + } + user, err := s.authenticateUser(r) + if err != nil || user == nil { + http.Error(w, `{"error":"ERR_UNAUTHENTICATED"}`, http.StatusUnauthorized) + return + } + detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID) + if err != nil { + http.Error(w, `{"error":"ERR_NOT_FOUND"}`, http.StatusNotFound) + return + } + isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), detail.Instance.ProjectID, user.ID) + preview, err := s.instSvc.GetTerminationPreview(r.Context(), instanceID, detail.Instance.ProjectID, user.ID, isCreator) + if err != nil { + writeProjectInstanceError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, preview) +} + +func (s *Server) handleTerminateWorkflowInstance(w http.ResponseWriter, r *http.Request) { + if s.instSvc == nil || !s.instSvc.Available() { + http.Error(w, `{"error":"ERR_NOT_AVAILABLE"}`, http.StatusServiceUnavailable) + return + } + instanceID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || instanceID <= 0 { + http.Error(w, `{"error":"ERR_INVALID_PARAM"}`, http.StatusBadRequest) + return + } + user, err := s.authenticateUser(r) + if err != nil || user == nil { + http.Error(w, `{"error":"ERR_UNAUTHENTICATED"}`, http.StatusUnauthorized) + return + } + detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID) + if err != nil { + http.Error(w, `{"error":"ERR_NOT_FOUND"}`, http.StatusNotFound) + return + } + var in projectinstance.TerminateInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + http.Error(w, `{"error":"ERR_INVALID_PARAM"}`, http.StatusBadRequest) + return + } + isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), detail.Instance.ProjectID, user.ID) + res, err := s.instSvc.TerminateInstance(r.Context(), instanceID, detail.Instance.ProjectID, user.ID, isCreator, in) + if err != nil { + switch { + case errors.Is(err, projectinstance.ErrForbiddenNotCreator): + http.Error(w, `{"error":"ERR_FORBIDDEN"}`, http.StatusForbidden) + case errors.Is(err, projectinstance.ErrTerminationNotAllowed): + http.Error(w, `{"error":"ERR_TERMINATION_NOT_ALLOWED"}`, http.StatusConflict) + default: + http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError) + } + return + } + writeJSONResponse(w, http.StatusOK, res) +} diff --git a/internal/squad/quality_gate_v2.go b/internal/squad/quality_gate_v2.go new file mode 100644 index 00000000..057362cd --- /dev/null +++ b/internal/squad/quality_gate_v2.go @@ -0,0 +1,84 @@ +package squad + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" +) + +// ValidateQualityGateV2 按 quality_gate_key 执行结构化门禁。 +func ValidateQualityGateV2(gateKey, wsDir string) []string { + switch gateKey { + case "quality.system_reqs.v2": + return validateRequirementsJSON(wsDir) + case "quality.test_manager.v2": + return validateTestCasesJSON(wsDir) + default: + return nil + } +} + +func validateRequirementsJSON(wsDir string) []string { + path := filepath.Join(wsDir, "artifacts", "requirements.json") + raw, err := os.ReadFile(path) + if err != nil { + return []string{"requirements.json 为必需产物"} + } + var reqs []map[string]any + if err := json.Unmarshal(raw, &reqs); err != nil { + return []string{"requirements.json 格式无效"} + } + ids := map[string]bool{} + issues := make([]string, 0) + for _, r := range reqs { + id, _ := r["id"].(string) + if id == "" { + issues = append(issues, "需求缺少 id") + continue + } + if ids[id] { + issues = append(issues, "重复需求 id: "+id) + } + ids[id] = true + for _, field := range []string{"verification_method", "acceptance_criteria"} { + if v, _ := r[field].(string); strings.TrimSpace(v) == "" { + issues = append(issues, id+": 缺少 "+field) + } + } + } + return issues +} + +func validateTestCasesJSON(wsDir string) []string { + path := filepath.Join(wsDir, "artifacts", "test_cases.json") + raw, err := os.ReadFile(path) + if err != nil { + return []string{"test_cases.json 为必需产物"} + } + var cases []map[string]any + if err := json.Unmarshal(raw, &cases); err != nil { + return []string{"test_cases.json 格式无效"} + } + reqPath := filepath.Join(wsDir, "artifacts", "requirements.json") + reqRaw, err := os.ReadFile(reqPath) + if err != nil { + return []string{"缺少上游 requirements.json 无法验证覆盖率"} + } + var reqs []map[string]any + _ = json.Unmarshal(reqRaw, &reqs) + covered := map[string]bool{} + for _, tc := range cases { + if rid, _ := tc["requirement_id"].(string); rid != "" { + covered[rid] = true + } + } + issues := make([]string, 0) + for _, r := range reqs { + id, _ := r["id"].(string) + if id != "" && !covered[id] { + issues = append(issues, "需求未覆盖: "+id) + } + } + return issues +} diff --git a/internal/workflow/registry.go b/internal/workflow/registry.go new file mode 100644 index 00000000..9481a38b --- /dev/null +++ b/internal/workflow/registry.go @@ -0,0 +1,46 @@ +package workflow + +import "fmt" + +// ExecutorRegistry 执行器注册表(schema v2 发布校验与运行时解析)。 +var ExecutorRegistry = map[string]struct{}{ + "bizdev.stage.v1": {}, + "squad.system_reqs.v1": {}, + "squad.test_manager.v1": {}, + "bizdev.intake.v1": {}, + "bizdev.research.v1": {}, + "bizdev.feasibility.v1": {}, + "bizdev.feasibility_dev.v1": {}, + "bizdev.user_requirements.v1": {}, + "bizdev.prelim_design.v1": {}, + "bizdev.cdo_quote.v1": {}, +} + +// QualityGateRegistry 确定性质量门禁注册表。 +var QualityGateRegistry = map[string]struct{}{ + "quality.system_reqs.v2": {}, + "quality.test_manager.v2": {}, + "quality.generic.v1": {}, +} + +// ValidateExecutorKey 校验执行器已注册。 +func ValidateExecutorKey(key string) error { + if key == "" { + return fmt.Errorf("executor_key required") + } + if _, ok := ExecutorRegistry[key]; !ok { + return fmt.Errorf("unknown executor_key %q", key) + } + return nil +} + +// ValidateQualityGateKey 校验质量门禁已注册。 +func ValidateQualityGateKey(key string) error { + if key == "" { + return nil + } + if _, ok := QualityGateRegistry[key]; !ok { + return fmt.Errorf("unknown quality_gate_key %q", key) + } + return nil +} diff --git a/internal/workflow/runtime/orchestrator.go b/internal/workflow/runtime/orchestrator.go new file mode 100644 index 00000000..7431c17a --- /dev/null +++ b/internal/workflow/runtime/orchestrator.go @@ -0,0 +1,103 @@ +package runtime + +import ( + "context" + "fmt" + + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/workflowdef" +) + +// TaskResolution 从冻结实例解析的运行时任务绑定。 +type TaskResolution struct { + ProjectID int64 + WorkflowInstID int64 + StageKey string + TaskKey string + DocTaskInstID int64 + ExecutorKey string + QualityGateKey string + TemplateAsset string + TemplateVersion int + AuthorUserID int64 + ReviewerUserID int64 + ApproverUserID int64 +} + +// Orchestrator 只读冻结快照编排(禁止 flatten 旁路)。 +type Orchestrator struct { + instSvc *projectinstance.Service +} + +func NewOrchestrator(instSvc *projectinstance.Service) *Orchestrator { + return &Orchestrator{instSvc: instSvc} +} + +// ResolveDocTask 由 doc_task_instance_id 解析 executor/模板/三权。 +func (o *Orchestrator) ResolveDocTask(ctx context.Context, projectID, docTaskInstID int64) (*TaskResolution, error) { + if o == nil || o.instSvc == nil || !o.instSvc.Available() { + return nil, fmt.Errorf("orchestrator unavailable") + } + detail, version, err := o.instSvc.GetLiveInstanceDetail(ctx, projectID) + if err != nil { + return nil, err + } + plan := detail.Instance.ConfigSnapshot + for _, st := range detail.Stages { + for _, dt := range st.DocTasks { + if dt.ID != docTaskInstID { + continue + } + res := &TaskResolution{ + ProjectID: projectID, + WorkflowInstID: detail.Instance.ID, + StageKey: st.StageKey, + TaskKey: dt.TaskKey, + DocTaskInstID: dt.ID, + } + if def := lookupTaskDef(&version.Definition, st.StageKey, dt.TaskKey); def != nil { + res.ExecutorKey = def.ExecutorKey + res.QualityGateKey = def.QualityGateKey + if def.Template != nil { + res.TemplateAsset = def.Template.AssetKey + res.TemplateVersion = def.Template.Version + } else { + res.TemplateAsset = def.TemplateAssetKey + res.TemplateVersion = def.TemplateVersion + } + } + for _, stPlan := range plan.Stages { + if stPlan.StageKey != st.StageKey { + continue + } + for _, dtPlan := range stPlan.DocTasks { + if dtPlan.TaskKey != dt.TaskKey { + continue + } + res.AuthorUserID = dtPlan.Duties.AuthorUserID + res.ReviewerUserID = dtPlan.Duties.ReviewerUserID + res.ApproverUserID = dtPlan.Duties.ApproverUserID + } + } + return res, nil + } + } + return nil, fmt.Errorf("doc task instance not in live snapshot") +} + +func lookupTaskDef(def *workflowdef.VersionDefinition, stageKey, taskKey string) *workflowdef.DocTaskDef { + if def == nil { + return nil + } + for _, st := range def.Stages { + if st.Key != stageKey { + continue + } + for i := range st.DocTasks { + if st.DocTasks[i].Key == taskKey { + return &st.DocTasks[i] + } + } + } + return nil +} diff --git a/internal/workflowdef/bootstrap.go b/internal/workflowdef/bootstrap.go index aa34e495..f4ab5fbc 100644 --- a/internal/workflowdef/bootstrap.go +++ b/internal/workflowdef/bootstrap.go @@ -8,32 +8,32 @@ import ( "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, - }}, - }}, - }, - } +var officialSeedMeta = map[string]struct { + Name string + Description string + Domain string +}{ + OfficialKeyMain: { + Name: "商务对接流程", + Description: "V0.1 需求摸底 → V0.5 方案与可行性 → V1.0 商务报价(7 个受控文档)", + Domain: "medical_device", + }, + OfficialKeyProductManager: { + Name: "智能产品经理流程", + Description: "基于项目上下文独立生成系统需求规格书", + Domain: "medical_device", + }, + OfficialKeyTestManager: { + Name: "智能测试工程师流程", + Description: "基于项目上下文独立生成系统验证方案及计划", + Domain: "medical_device", + }, } -// BootstrapSeeds 幂等导入静态 catalog、首批行业工作流与 templates/docs。 +// BootstrapSeeds 幂等导入模板与三正式 v2 工作流;非正式定义标记为不可选。 func BootstrapSeeds(ctx context.Context, pool *pgxpool.Pool, cas *storage.CAS, templatesDir string, publishedBy int64) error { if pool == nil || !TablesReady(ctx, pool) { return nil @@ -60,80 +60,44 @@ func BootstrapSeeds(ctx context.Context, pool *pgxpool.Pool, cas *storage.CAS, t _, _ = 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 { + for _, key := range OfficialWorkflowKeys() { + meta := officialSeedMeta[key] + filename := V2ManifestFiles[key] + def, loadErr := LoadManifestDefinition(filename) + if loadErr != nil { + return loadErr + } + normalizeManifestDefinition(&def) + if err := store.UpsertPublishedSeed(ctx, CreateDefinitionInput{ + Key: key, + Name: meta.Name, + Description: meta.Description, + ProductDomain: meta.Domain, + Definition: def, + }, publishedBy); err != nil { return err } } - return nil + + return store.RetireNonOfficialDefinitions(ctx) } -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 normalizeManifestDefinition(def *VersionDefinition) { + if def == nil { + return + } + def.RequiredUpstreamWorkflow = "" + for i := range def.Stages { + for j := range def.Stages[i].DocTasks { + dt := &def.Stages[i].DocTasks[j] + if dt.CreatorConfigurable || dt.BlockingWhenEnabled || dt.DefaultEnabled { + continue + } + if dt.Required || dt.Blocking { + dt.CreatorConfigurable = true + dt.BlockingWhenEnabled = true + dt.DefaultEnabled = true + } + } } } - -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_policy.go b/internal/workflowdef/catalog_policy.go new file mode 100644 index 00000000..13f2edf0 --- /dev/null +++ b/internal/workflowdef/catalog_policy.go @@ -0,0 +1,53 @@ +package workflowdef + +// 正式项目可选工作流(唯一对外目录)。 +const ( + OfficialKeyMain = "main" + OfficialKeyProductManager = "product_manager" + OfficialKeyTestManager = "test_manager" +) + +var officialWorkflowKeys = map[string]struct{}{ + OfficialKeyMain: {}, + OfficialKeyProductManager: {}, + OfficialKeyTestManager: {}, +} + +var officialWorkflowNames = map[string]string{ + OfficialKeyMain: "商务对接流程", + OfficialKeyProductManager: "智能产品经理流程", + OfficialKeyTestManager: "智能测试工程师流程", +} + +var officialCatalogOrder = map[string]int{ + OfficialKeyMain: 10, + OfficialKeyProductManager: 20, + OfficialKeyTestManager: 30, +} + +// IsOfficialWorkflowKey 是否为三正式工作流 key。 +func IsOfficialWorkflowKey(key string) bool { + _, ok := officialWorkflowKeys[key] + return ok +} + +// OfficialWorkflowName 返回正式对外名称。 +func OfficialWorkflowName(key string) string { + if name, ok := officialWorkflowNames[key]; ok { + return name + } + return key +} + +// OfficialCatalogOrder 返回目录排序权重。 +func OfficialCatalogOrder(key string) int { + if order, ok := officialCatalogOrder[key]; ok { + return order + } + return 9999 +} + +// OfficialWorkflowKeys 返回三正式 key 列表(稳定顺序)。 +func OfficialWorkflowKeys() []string { + return []string{OfficialKeyMain, OfficialKeyProductManager, OfficialKeyTestManager} +} diff --git a/internal/workflowdef/catalog_policy_test.go b/internal/workflowdef/catalog_policy_test.go new file mode 100644 index 00000000..29d95504 --- /dev/null +++ b/internal/workflowdef/catalog_policy_test.go @@ -0,0 +1,43 @@ +package workflowdef + +import "testing" + +func TestOfficialWorkflowKeys(t *testing.T) { + keys := OfficialWorkflowKeys() + if len(keys) != 3 { + t.Fatalf("expected 3 official keys, got %d", len(keys)) + } + for _, k := range keys { + if !IsOfficialWorkflowKey(k) { + t.Fatalf("key %q not official", k) + } + } +} + +func TestValidateDefinitionV2_OfficialDocCounts(t *testing.T) { + def, err := LoadManifestDefinition("main.v2.json") + if err != nil { + t.Fatal(err) + } + normalizeManifestDefinition(&def) + res := ValidateDefinitionV2(def) + if !res.Valid { + t.Fatalf("main v2 invalid: %+v", res.Issues) + } +} + +func TestValidateDefinitionV2_RejectsUpstream(t *testing.T) { + def := VersionDefinition{ + SchemaVersion: 2, + WorkflowKey: OfficialKeyTestManager, + RequiredUpstreamWorkflow: "product_manager", + Stages: []StageDef{{ + Key: "s", Position: 10, + DocTasks: []DocTaskDef{{Key: "test_manager", OutputDocType: "test_manager", ExecutorKey: "squad.test_manager.v1"}}, + }}, + } + res := ValidateDefinitionV2(def) + if res.Valid { + t.Fatal("expected upstream rejection") + } +} diff --git a/internal/workflowdef/catalog_store.go b/internal/workflowdef/catalog_store.go new file mode 100644 index 00000000..eb9bf1a9 --- /dev/null +++ b/internal/workflowdef/catalog_store.go @@ -0,0 +1,68 @@ +package workflowdef + +import "context" + +// ListProjectSelectableDefinitions 返回项目可选的已发布正式工作流(严格三项白名单)。 +func (s *Store) ListProjectSelectableDefinitions(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, + COALESCE(project_selectable, false), COALESCE(catalog_order, 0), created_at, updated_at + FROM workflow_definitions + WHERE def_key = ANY($1) AND latest_published_version > 0 + ORDER BY COALESCE(catalog_order, 0) ASC, def_key ASC + `, OfficialWorkflowKeys()) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]Definition, 0, 3) + 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.ProjectSelectable, &d.CatalogOrder, &d.CreatedAt, &d.UpdatedAt); err != nil { + return nil, err + } + if !IsOfficialWorkflowKey(d.Key) { + continue + } + if !d.ProjectSelectable { + d.ProjectSelectable = true + } + if d.Name == "" || d.Name == d.Key { + d.Name = OfficialWorkflowName(d.Key) + } + out = append(out, d) + } + return out, rows.Err() +} + +// RetireNonOfficialDefinitions 将非三正式定义标记为不可选。 +func (s *Store) RetireNonOfficialDefinitions(ctx context.Context) error { + if !s.enabled() { + return ErrNotAvailable + } + _, err := s.pool.Exec(ctx, ` + UPDATE workflow_definitions + SET project_selectable = false, status = 'retired', updated_at = now() + WHERE def_key <> ALL($1) + `, OfficialWorkflowKeys()) + if err != nil { + return err + } + _, err = s.pool.Exec(ctx, ` + UPDATE workflow_definitions + SET project_selectable = true, + catalog_order = CASE def_key + WHEN 'main' THEN 10 + WHEN 'product_manager' THEN 20 + WHEN 'test_manager' THEN 30 + ELSE catalog_order + END, + updated_at = now() + WHERE def_key = ANY($1) + `, OfficialWorkflowKeys()) + return err +} diff --git a/internal/workflowdef/manifest_loader.go b/internal/workflowdef/manifest_loader.go new file mode 100644 index 00000000..49f4dbc4 --- /dev/null +++ b/internal/workflowdef/manifest_loader.go @@ -0,0 +1,33 @@ +package workflowdef + +import ( + "embed" + "encoding/json" + "fmt" +) + +//go:embed manifests/*.json +var manifestFS embed.FS + +// LoadManifestDefinition 读取内嵌 v2 manifest(Phase 4 发布种子)。 +func LoadManifestDefinition(filename string) (VersionDefinition, error) { + raw, err := manifestFS.ReadFile("manifests/" + filename) + if err != nil { + return VersionDefinition{}, fmt.Errorf("read manifest %s: %w", filename, err) + } + var def VersionDefinition + if err := json.Unmarshal(raw, &def); err != nil { + return VersionDefinition{}, fmt.Errorf("parse manifest %s: %w", filename, err) + } + if def.SchemaVersion <= 0 { + def.SchemaVersion = 2 + } + return def, nil +} + +// V2ManifestFiles 三个正式工作流 v2 manifest 文件名。 +var V2ManifestFiles = map[string]string{ + "main": "main.v2.json", + "product_manager": "product_manager.v2.json", + "test_manager": "test_manager.v2.json", +} diff --git a/internal/workflowdef/manifests/main.v2.json b/internal/workflowdef/manifests/main.v2.json new file mode 100644 index 00000000..d6b78a76 --- /dev/null +++ b/internal/workflowdef/manifests/main.v2.json @@ -0,0 +1,54 @@ +{ + "schema_version": 2, + "workflow_key": "main", + "product_domains": ["medical_device", "non_medical_software"], + "stages": [ + { + "key": "V0.1_triage", + "name": "V0.1 需求摸底", + "position": 10, + "optional": false, + "doc_tasks": [{ + "key": "intake", + "title": "用户初始需求收集表", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "bizdev.intake.v1", + "quality_gate_key": "quality.generic.v1", + "output_doc_type": "intake", + "output_doc_title": "用户初始需求收集表" + }] + }, + { + "key": "V0.5_feasibility", + "name": "V0.5 方案与可行性", + "position": 20, + "optional": false, + "doc_tasks": [ + {"key": "research", "title": "调研报告", "default_enabled": true, "creator_configurable": true, "blocking_when_enabled": true, "executor_key": "bizdev.research.v1", "quality_gate_key": "quality.generic.v1", "output_doc_type": "research", "output_doc_title": "调研报告"}, + {"key": "feasibility_dev", "title": "设计开发可行性评估报告", "default_enabled": true, "creator_configurable": true, "blocking_when_enabled": true, "executor_key": "bizdev.feasibility_dev.v1", "quality_gate_key": "quality.generic.v1", "output_doc_type": "feasibility_dev", "output_doc_title": "设计开发可行性评估报告"}, + {"key": "feasibility_reg", "title": "注册路径可行性评估报告", "default_enabled": true, "creator_configurable": true, "blocking_when_enabled": true, "executor_key": "bizdev.feasibility.v1", "quality_gate_key": "quality.generic.v1", "output_doc_type": "feasibility_reg", "output_doc_title": "注册路径可行性评估报告"}, + {"key": "user_requirements", "title": "用户需求表", "default_enabled": true, "creator_configurable": true, "blocking_when_enabled": true, "executor_key": "bizdev.user_requirements.v1", "quality_gate_key": "quality.generic.v1", "output_doc_type": "user_requirements", "output_doc_title": "用户需求表"}, + {"key": "prelim_design", "title": "系统初步设计方案", "default_enabled": true, "creator_configurable": true, "blocking_when_enabled": true, "executor_key": "bizdev.prelim_design.v1", "quality_gate_key": "quality.generic.v1", "output_doc_type": "prelim_design", "output_doc_title": "系统初步设计方案"} + ] + }, + { + "key": "V1.0_quoting", + "name": "V1.0 商务报价", + "position": 30, + "optional": false, + "doc_tasks": [{ + "key": "cdo_quote", + "title": "CDO 报价单", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "bizdev.cdo_quote.v1", + "quality_gate_key": "quality.generic.v1", + "output_doc_type": "cdo_quote", + "output_doc_title": "CDO 报价单" + }] + } + ] +} diff --git a/internal/workflowdef/manifests/product_manager.v2.json b/internal/workflowdef/manifests/product_manager.v2.json new file mode 100644 index 00000000..ad87d2d4 --- /dev/null +++ b/internal/workflowdef/manifests/product_manager.v2.json @@ -0,0 +1,22 @@ +{ + "schema_version": 2, + "workflow_key": "product_manager", + "product_domains": ["medical_device", "non_medical_software"], + "stages": [{ + "key": "requirements_baseline", + "name": "系统需求基线", + "position": 10, + "optional": false, + "doc_tasks": [{ + "key": "system_reqs", + "title": "系统需求规格书", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.system_reqs.v1", + "quality_gate_key": "quality.system_reqs.v2", + "output_doc_type": "system_reqs", + "output_doc_title": "系统需求规格书" + }] + }] +} diff --git a/internal/workflowdef/manifests/test_manager.v2.json b/internal/workflowdef/manifests/test_manager.v2.json new file mode 100644 index 00000000..a55e49f1 --- /dev/null +++ b/internal/workflowdef/manifests/test_manager.v2.json @@ -0,0 +1,22 @@ +{ + "schema_version": 2, + "workflow_key": "test_manager", + "product_domains": ["medical_device", "non_medical_software"], + "stages": [{ + "key": "system_verification_planning", + "name": "系统验证策划", + "position": 10, + "optional": false, + "doc_tasks": [{ + "key": "test_manager", + "title": "系统验证方案及计划", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "squad.test_manager.v1", + "quality_gate_key": "quality.test_manager.v2", + "output_doc_type": "test_manager", + "output_doc_title": "系统验证方案及计划" + }] + }] +} diff --git a/internal/workflowdef/publish_test.go b/internal/workflowdef/publish_test.go index e3c239f1..1e7ef287 100644 --- a/internal/workflowdef/publish_test.go +++ b/internal/workflowdef/publish_test.go @@ -2,21 +2,18 @@ package workflowdef_test import ( "context" - "os" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" + "chat-agent-system/internal/db" "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" - } + dsn := db.SkipIfNoTestDatabaseURL(t) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() pool, err := pgxpool.New(ctx, dsn) @@ -43,6 +40,10 @@ func TestPublishVersion_ImmutableAfterPublish(t *testing.T) { ctx := context.Background() svc := workflowdef.NewService(pool, nil) key := "test_wf_" + time.Now().Format("150405") + defer func() { + _, _ = pool.Exec(ctx, `DELETE FROM workflow_versions WHERE workflow_id IN (SELECT id FROM workflow_definitions WHERE def_key = $1)`, key) + _, _ = pool.Exec(ctx, `DELETE FROM workflow_definitions WHERE def_key = $1`, key) + }() def, ver, err := svc.CreateDefinition(ctx, workflowdef.CreateDefinitionInput{ Key: key, Name: "测试工作流", Definition: workflowdef.VersionDefinition{ diff --git a/internal/workflowdef/store.go b/internal/workflowdef/store.go index 22adf693..7743defd 100644 --- a/internal/workflowdef/store.go +++ b/internal/workflowdef/store.go @@ -361,17 +361,14 @@ func (s *Store) ListPublishedCatalogEntries(ctx context.Context) ([]Version, err return out, rows.Err() } -// UpsertPublishedSeed 幂等写入已发布种子(bootstrap)。 +// UpsertPublishedSeed 按内容 hash 幂等发布种子;已有版本但 hash 变化时递增新版本。 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 + validation := ValidateVersionBySchema(in.Definition) + if !validation.Valid { + return fmt.Errorf("seed validation failed: %d issues", len(validation.Issues)) } defHash, err := hashDefinition(in.Definition) @@ -380,6 +377,11 @@ func (s *Store) UpsertPublishedSeed(ctx context.Context, in CreateDefinitionInpu } defRaw, _ := json.Marshal(in.Definition) + existing, err := s.GetDefinitionByKey(ctx, in.Key) + if err != nil && !errors.Is(err, ErrNotFound) { + return err + } + tx, err := s.pool.Begin(ctx) if err != nil { return err @@ -387,13 +389,38 @@ func (s *Store) UpsertPublishedSeed(ctx context.Context, in CreateDefinitionInpu defer tx.Rollback(ctx) var defID int64 + nextVersion := 1 if existing != nil { defID = existing.ID + if existing.LatestPublishedVersion > 0 { + var latestHash string + _ = tx.QueryRow(ctx, ` + SELECT definition_hash FROM workflow_versions + WHERE workflow_id = $1 AND version = $2 AND status = 'published' + `, defID, existing.LatestPublishedVersion).Scan(&latestHash) + if latestHash == defHash { + _, _ = tx.Exec(ctx, ` + UPDATE workflow_definitions SET name = $2, description = $3, project_selectable = true, + catalog_order = $4, updated_at = now() + WHERE id = $1 + `, defID, in.Name, in.Description, OfficialCatalogOrder(in.Key)) + return tx.Commit(ctx) + } + nextVersion = existing.LatestPublishedVersion + 1 + } + _, err = tx.Exec(ctx, ` + UPDATE workflow_definitions SET name = $2, description = $3, product_domain = $4, + project_selectable = true, catalog_order = $5, updated_at = now() + WHERE id = $1 + `, defID, in.Name, in.Description, in.ProductDomain, OfficialCatalogOrder(in.Key)) + if err != nil { + return err + } } 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) + INSERT INTO workflow_definitions (def_key, name, description, product_domain, latest_published_version, project_selectable, catalog_order) + VALUES ($1, $2, $3, $4, 0, true, $5) RETURNING id + `, in.Key, in.Name, in.Description, in.ProductDomain, OfficialCatalogOrder(in.Key)).Scan(&defID) if err != nil { return err } @@ -401,16 +428,16 @@ func (s *Store) UpsertPublishedSeed(ctx context.Context, in CreateDefinitionInpu _, 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) + VALUES ($1, $2, 'published', $3, $4, $5, $6, now(), $6) ON CONFLICT (workflow_id, version) DO NOTHING - `, defID, in.Definition.SchemaVersion, defRaw, defHash, publishedBy) + `, defID, nextVersion, 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) + UPDATE workflow_definitions SET latest_published_version = $2, updated_at = now() WHERE id = $1 + `, defID, nextVersion) if err != nil { return err } diff --git a/internal/workflowdef/types.go b/internal/workflowdef/types.go index f5882f48..8ca20247 100644 --- a/internal/workflowdef/types.go +++ b/internal/workflowdef/types.go @@ -12,17 +12,28 @@ const ( ScanRejected = "rejected" ) +// TemplateRef 模板版本引用(schema v2)。 +type TemplateRef struct { + AssetKey string `json:"asset_key"` + Version int `json:"version"` +} + // 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"` + Key string `json:"key"` + Title string `json:"title"` + Required bool `json:"required"` + Blocking bool `json:"blocking"` + DefaultEnabled bool `json:"default_enabled"` + CreatorConfigurable bool `json:"creator_configurable"` + BlockingWhenEnabled bool `json:"blocking_when_enabled"` + TemplateAssetKey string `json:"template_asset_key,omitempty"` + TemplateVersion int `json:"template_version,omitempty"` + Template *TemplateRef `json:"template,omitempty"` + ExecutorKey string `json:"executor_key,omitempty"` + QualityGateKey string `json:"quality_gate_key,omitempty"` + OutputDocType string `json:"output_doc_type"` + OutputDocTitle string `json:"output_doc_title"` } // StageDef 线性阶段定义。 @@ -37,9 +48,13 @@ type StageDef struct { // VersionDefinition 工作流版本完整快照(JSONB)。 type VersionDefinition struct { - SchemaVersion int `json:"schema_version"` - RequiredUpstreamWorkflow string `json:"required_upstream_workflow,omitempty"` - Stages []StageDef `json:"stages"` + SchemaVersion int `json:"schema_version"` + WorkflowKey string `json:"workflow_key,omitempty"` + ProductDomains []string `json:"product_domains,omitempty"` + RequiredUpstreamWorkflow string `json:"required_upstream_workflow,omitempty"` + Stages []StageDef `json:"stages"` + ParticipationPolicy map[string]any `json:"participation_policy,omitempty"` + CompletionPolicy map[string]any `json:"completion_policy,omitempty"` } // Definition 工作流逻辑标识。 @@ -50,11 +65,29 @@ type Definition struct { Description string `json:"description"` Status string `json:"status"` ProductDomain string `json:"product_domain,omitempty"` + ProjectSelectable bool `json:"project_selectable"` + CatalogOrder int `json:"catalog_order"` LatestPublishedVersion int `json:"latest_published_version"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } +// IsCreatorConfigurable 文档是否可由创建者关闭。 +func (dt DocTaskDef) IsCreatorConfigurable() bool { + if dt.CreatorConfigurable { + return true + } + return !dt.Required && !dt.Blocking +} + +// BlocksWhenEnabled 启用后是否阻断阶段推进。 +func (dt DocTaskDef) BlocksWhenEnabled() bool { + if dt.BlockingWhenEnabled { + return true + } + return dt.Required || dt.Blocking +} + // Version 工作流版本记录。 type Version struct { ID int64 `json:"id"` diff --git a/internal/workflowdef/validator.go b/internal/workflowdef/validator.go index f6fb6638..46659c97 100644 --- a/internal/workflowdef/validator.go +++ b/internal/workflowdef/validator.go @@ -11,6 +11,9 @@ type upstreamExistsChecker func(workflowKey string) bool // ValidateDefinition 对工作流版本定义做确定性校验。 func ValidateDefinition(def VersionDefinition, tplReady templateReadyChecker, upstreamExists upstreamExistsChecker) ValidationResult { + if def.SchemaVersion >= 2 { + return ValidateDefinitionV2(def) + } issues := make([]ValidationIssue, 0) if def.SchemaVersion <= 0 { def.SchemaVersion = 1 diff --git a/internal/workflowdef/validator_v2.go b/internal/workflowdef/validator_v2.go new file mode 100644 index 00000000..a5240f69 --- /dev/null +++ b/internal/workflowdef/validator_v2.go @@ -0,0 +1,105 @@ +package workflowdef + +import ( + "fmt" + + "chat-agent-system/internal/workflow" +) + +var officialDocCounts = map[string]int{ + OfficialKeyMain: 7, + OfficialKeyProductManager: 1, + OfficialKeyTestManager: 1, +} + +// ValidateDefinitionV2 schema v2 发布校验。 +func ValidateDefinitionV2(def VersionDefinition) ValidationResult { + issues := make([]ValidationIssue, 0) + if def.SchemaVersion < 2 { + issues = append(issues, ValidationIssue{Code: "SCHEMA_VERSION", Message: "schema_version must be >= 2"}) + } + if def.RequiredUpstreamWorkflow != "" { + issues = append(issues, ValidationIssue{ + Code: "UPSTREAM_NOT_ALLOWED", Message: "正式工作流不得声明 required_upstream_workflow", + }) + } + seenPos := map[int]bool{} + seenTask := map[string]bool{} + docCount := 0 + for _, st := range def.Stages { + if st.Key == "" { + issues = append(issues, ValidationIssue{Code: "STAGE_KEY_REQUIRED", Message: "stage key required"}) + continue + } + if st.Position <= 0 { + issues = append(issues, ValidationIssue{Code: "STAGE_POSITION", Field: st.Key, Message: "position must be > 0"}) + } + if seenPos[st.Position] { + issues = append(issues, ValidationIssue{Code: "DUPLICATE_POSITION", Field: st.Key, Message: "duplicate stage position"}) + } + seenPos[st.Position] = true + if len(st.DocTasks) == 0 { + issues = append(issues, ValidationIssue{Code: "STAGE_NO_DOC_TASKS", Field: st.Key, Message: "each stage must have at least one doc task"}) + } + for _, dt := range st.DocTasks { + docCount++ + if dt.Key == "" { + issues = append(issues, ValidationIssue{Code: "DOC_TASK_KEY_REQUIRED", Message: "doc task key required"}) + continue + } + if seenTask[dt.Key] { + issues = append(issues, ValidationIssue{Code: "DUPLICATE_DOC_TASK", Field: dt.Key, Message: "duplicate doc task key"}) + } + seenTask[dt.Key] = true + if dt.ExecutorKey != "" { + if err := workflow.ValidateExecutorKey(dt.ExecutorKey); err != nil { + issues = append(issues, ValidationIssue{Code: "UNKNOWN_EXECUTOR", Field: dt.Key, Message: err.Error()}) + } + } + if dt.QualityGateKey != "" { + if err := workflow.ValidateQualityGateKey(dt.QualityGateKey); err != nil { + issues = append(issues, ValidationIssue{Code: "UNKNOWN_QUALITY_GATE", Field: dt.Key, Message: err.Error()}) + } + } + if dt.OutputDocType == "" { + issues = append(issues, ValidationIssue{Code: "OUTPUT_DOC_TYPE", Field: dt.Key, Message: "output_doc_type required"}) + } + } + } + if len(def.Stages) == 0 { + issues = append(issues, ValidationIssue{Code: "NO_STAGES", Message: "at least one stage required"}) + } + if def.WorkflowKey != "" && IsOfficialWorkflowKey(def.WorkflowKey) { + if want, ok := officialDocCounts[def.WorkflowKey]; ok && docCount != want { + issues = append(issues, ValidationIssue{ + Code: "OFFICIAL_DOC_COUNT", Field: def.WorkflowKey, + Message: fmt.Sprintf("official workflow %s requires exactly %d doc tasks, got %d", def.WorkflowKey, want, docCount), + }) + } + if name := OfficialWorkflowName(def.WorkflowKey); name == def.WorkflowKey { + // name check happens at publish layer via Definition.Name + } + } + return ValidationResult{Valid: len(issues) == 0, Issues: issues} +} + +// ValidateVersionBySchema 按 schema_version 路由校验器。 +func ValidateVersionBySchema(def VersionDefinition) ValidationResult { + if def.SchemaVersion >= 2 { + return ValidateDefinitionV2(def) + } + return validateDefinitionV1(def) +} + +func validateDefinitionV1(def VersionDefinition) ValidationResult { + issues := make([]ValidationIssue, 0) + for i, st := range def.Stages { + if st.Position != i { + issues = append(issues, ValidationIssue{ + Code: "STAGE_POSITION", Field: st.Key, + Message: fmt.Sprintf("position must equal index %d", i), + }) + } + } + return ValidationResult{Valid: len(issues) == 0, Issues: issues} +} diff --git a/scripts/env-service.sh b/scripts/env-service.sh index 9b7dd660..0c5a0eeb 100755 --- a/scripts/env-service.sh +++ b/scripts/env-service.sh @@ -85,7 +85,10 @@ run_compose() { exit 1 fi - prepare_env_file "${target}" + # status/ps 为只读查询,不隐式创建配置或数据目录 + if [[ "${act}" != "status" && "${act}" != "ps" ]]; then + prepare_env_file "${target}" + fi local env_arg="" if [[ -f "${env_file}" ]]; then diff --git a/scripts/fix-completed-instance-lifecycle.sql b/scripts/fix-completed-instance-lifecycle.sql new file mode 100644 index 00000000..0d521952 --- /dev/null +++ b/scripts/fix-completed-instance-lifecycle.sql @@ -0,0 +1,18 @@ +-- 修复「实例 completed 但项目仍为 active」的历史不一致数据。 +-- 用法: +-- psql "$DATABASE_URL" -f scripts/fix-completed-instance-lifecycle.sql +-- Dry-run(仅统计): +-- psql "$DATABASE_URL" -c "SELECT COUNT(*) FROM projects p JOIN project_workflow_instances i ON i.project_id = p.id AND i.status = 'completed' WHERE p.lifecycle_status <> 'planning';" + +BEGIN; + +UPDATE projects p +SET lifecycle_status = 'planning', + active_workflow_key = '', + updated_at = now() +FROM project_workflow_instances i +WHERE i.project_id = p.id + AND i.status = 'completed' + AND p.lifecycle_status <> 'planning'; + +COMMIT; diff --git a/scripts/retire-nonofficial-workflow-definitions.sql b/scripts/retire-nonofficial-workflow-definitions.sql new file mode 100644 index 00000000..9ed66658 --- /dev/null +++ b/scripts/retire-nonofficial-workflow-definitions.sql @@ -0,0 +1,16 @@ +-- 退休非正式 active 工作流定义(保留 main / product_manager / test_manager) +-- Dry-run: +-- SELECT def_key, status FROM workflow_definitions +-- WHERE status = 'active' AND def_key NOT IN ('main','product_manager','test_manager'); +-- 用法: +-- psql "$DATABASE_URL" -f scripts/retire-nonofficial-workflow-definitions.sql + +BEGIN; + +UPDATE workflow_definitions +SET status = 'retired', + updated_at = now() +WHERE status = 'active' + AND def_key NOT IN ('main', 'product_manager', 'test_manager'); + +COMMIT; diff --git a/web/src/App.tsx b/web/src/App.tsx index 21c9aedc..70340610 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -40,7 +40,6 @@ import { shouldLoadControlledContent, } from './lib/controlledDocHydrate'; import { DocumentLineage } from './types/projectWorkflow'; -import { WorkflowPrerequisiteBanner } from './components/workflow/WorkflowPrerequisiteBanner'; import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice'; import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner'; import { deriveReplyTitle } from './features/replyExport/title'; @@ -672,6 +671,9 @@ export default function App() { // 认证与会话状态 const [session, setSession] = useState(() => authClient.loadSavedSession()); + const [sessionValidated, setSessionValidated] = useState(() => + authClient.loadSavedSession() ? null : false, + ); const [systemEnv, setSystemEnv] = useState(''); const [systemAuthConfig, setSystemAuthConfig] = useState({ allowRegistration: true, @@ -696,15 +698,26 @@ export default function App() { setCapabilities(saved.capabilities ?? []); const match = allUsers.find((u) => u.id === saved.user.id); setCurrentUser(mergeSessionUser(saved.user, match)); - void authClient.fetchMe().then((me) => { - setCapabilities(me.capabilities ?? []); - setCurrentUser(mergeSessionUser(me.user, match)); - }).catch(() => {}); + void authClient + .fetchMe() + .then((me) => { + setCapabilities(me.capabilities ?? []); + setCurrentUser(mergeSessionUser(me.user, match)); + setSessionValidated(true); + }) + .catch(() => { + authClient.clearLocalSession(); + setSession(null); + setCapabilities([]); + setSessionValidated(false); + }); + } else { + setSessionValidated(false); } }, []); useEffect(() => { - if (!session) { + if (!session || sessionValidated !== true) { setProjectSyncNotice(null); return; } @@ -715,7 +728,7 @@ export default function App() { const serverProjects = await fetchProjects(); if (cancelled) return; setProjects((prev) => { - const mapped = serverProjects.map((project) => + const mapped = (serverProjects ?? []).map((project) => mapServerProjectToProjectInfo( project, allUsers, @@ -740,15 +753,15 @@ export default function App() { return () => { cancelled = true; }; - }, [session, allUsers, syncProjectMembersFromServer]); + }, [session, sessionValidated, allUsers, syncProjectMembersFromServer]); useEffect(() => { - if (!projectMembersModalOpen || !currentProject || !session) return; + if (!projectMembersModalOpen || !currentProject || !session || sessionValidated !== true) return; void syncProjectMembersFromServer(currentProject.id, currentProject.creatorId); }, [projectMembersModalOpen, currentProject, session, syncProjectMembersFromServer]); useEffect(() => { - if (!session || !currentUser.id) { + if (!session || sessionValidated !== true || !currentUser.id) { setSessionSyncNotice(null); return; } @@ -759,7 +772,7 @@ export default function App() { const serverSessions = await fetchChatSessions(currentUser.id); if (cancelled) return; setSessions((prev) => { - const mapped = serverSessions.map((item) => + const mapped = (serverSessions ?? []).map((item) => mapServerSessionToProjectSession( item, projects, @@ -781,11 +794,14 @@ export default function App() { return () => { cancelled = true; }; - }, [session, currentUser.id, projects]); + }, [session, sessionValidated, currentUser.id, projects]); const handleLogout = () => { - authClient.logout(); - setSession(null); + void authClient.logout().finally(() => { + setSession(null); + setCapabilities([]); + setSessionValidated(false); + }); }; // 核心功能权限计算 @@ -828,6 +844,7 @@ export default function App() { }; useEffect(() => { + if (!session || sessionValidated !== true || !currentProjectId) return; if (currentProjectId) { getProject(currentProjectId) .then((p) => { @@ -848,22 +865,16 @@ export default function App() { .then(() => setWorkflowGovernanceEnabled(true)) .catch(() => setWorkflowGovernanceEnabled(false)); } - }, [currentProjectId]); + }, [session, sessionValidated, currentProjectId]); useEffect(() => { + if (!session || sessionValidated !== true || !currentProjectId) return; if (currentProjectId) { fetchProjectLineage(currentProjectId) .then((lins) => setProjectLineages(lins)) .catch(() => {}); } - }, [currentProjectId, currentWfState.activeKey]); - - const hasApprovedSystemReqs = deliverables.some( - (d) => - d.projectId === currentProjectId && - (d.taskKey === 'system_reqs' || d.docId.includes('system_reqs')) && - d.lifecycleState === 'approved' - ); + }, [session, sessionValidated, currentProjectId, currentWfState.activeKey]); const workflowBanner = ( <> @@ -888,16 +899,6 @@ export default function App() { isCreator={currentWfState.creatorId === currentUser.id} /> )} - { - setProjectWorkflowsState((prev) => ({ - ...prev, - [currentProjectId]: { ...currentWfState, activeKey: 'product_manager' }, - })); - }} - /> { @@ -1093,6 +1094,7 @@ export default function App() { // 受控非模板文档:挂载/切换/同步后自动拉取服务端正文 useEffect(() => { + if (!session || sessionValidated !== true) return; const target = deliverables.find((d) => d.docId === activeDocId); if (!target || !shouldLoadControlledContent(target)) return; @@ -1122,7 +1124,7 @@ export default function App() { return () => { cancelled = true; }; - }, [activeDocId, deliverables]); + }, [session, sessionValidated, activeDocId, deliverables]); const retryControlledDocLoad = useCallback(() => { const target = deliverables.find((d) => d.docId === activeDocId); @@ -1520,6 +1522,7 @@ export default function App() { // 挂载或项目切换时,拉取服务端的自由文档(我的私密与项目共享)并同步至 deliverables useEffect(() => { + if (!session || sessionValidated !== true || !currentProjectId) return; let active = true; const fetchDocs = async () => { try { @@ -1569,11 +1572,11 @@ export default function App() { return () => { active = false; }; - }, [currentProjectId, currentUser.id]); + }, [session, sessionValidated, currentProjectId, currentUser.id]); // 挂载或项目切换时,拉取服务端工作流受控文档并覆盖本地模板状态 useEffect(() => { - if (!currentProjectId || currentProjectId <= 0) return; + if (!session || sessionValidated !== true || !currentProjectId || currentProjectId <= 0) return; let active = true; const syncWorkflowDocs = async () => { try { @@ -1615,7 +1618,7 @@ export default function App() { return () => { active = false; }; - }, [currentProjectId, currentUser.id, currentUser.name]); + }, [session, sessionValidated, currentProjectId, currentUser.id, currentUser.name]); // 模态弹窗与侧栏 const [editingBlock, setEditingBlock] = useState(null); @@ -1641,12 +1644,14 @@ export default function App() { }, [messages, activeSessionId]); // P0: 会话按日窗口状态机 (处理首屏拉取、瞬时落底、触顶加载补偿) - const turnRuntime = useTurnRuntime(activeSessionId); - const taskRunStream = useTaskRunStream(activeSessionId); + const authenticatedSessionId = + session && sessionValidated === true ? activeSessionId : ''; + const turnRuntime = useTurnRuntime(authenticatedSessionId); + const taskRunStream = useTaskRunStream(authenticatedSessionId); const isThinking = turnRuntime.isThinking || taskRunStream.isThinking; const chatWindow = useChatWindow({ - sessionId: activeSessionId, + sessionId: authenticatedSessionId, liveMessages: currentSessionMessages, }); @@ -1666,11 +1671,11 @@ export default function App() { unreadMessageIds, isAllReadCompleted, consumeNextUnread, - } = useUnreadMarks(activeSessionId, currentUser?.id); + } = useUnreadMarks(authenticatedSessionId, currentUser?.id); // 会话切换时加载分类与日期统计 useEffect(() => { - if (!activeSessionId) return; + if (!activeSessionId || sessionValidated !== true) return; fetchChatCategories(activeSessionId) .then((cats) => { setCategoryStats(cats || []); @@ -1686,7 +1691,7 @@ export default function App() { .catch((err) => { console.warn('Failed to load date stats:', err); }); - }, [activeSessionId]); + }, [activeSessionId, sessionValidated]); // 监听 URL 哈希 (#/s/{sessionId}/m/{messageId}) 实现深链接直接定位与脉冲动效 const activeSessionIdRef = useRef(activeSessionId); @@ -1699,8 +1704,14 @@ export default function App() { loadDayForAnchorRef.current = chatWindow.loadDayForAnchor; }, [chatWindow.loadDayForAnchor]); + const sessionValidatedRef = useRef(sessionValidated); + useEffect(() => { + sessionValidatedRef.current = sessionValidated; + }, [sessionValidated]); + useEffect(() => { const handleHashChange = () => { + if (sessionValidatedRef.current !== true) return; const parsed = parseAnchorHash(window.location.hash); if (!parsed) return; const currentActive = activeSessionIdRef.current; @@ -3103,6 +3114,7 @@ export default function App() { { setSession(newSession); + setSessionValidated(true); setCapabilities(newSession.capabilities ?? []); if (newSession.env) setSystemEnv(newSession.env); const match = allUsers.find((u) => u.id === newSession.user.id); @@ -3839,10 +3851,12 @@ export default function App() { open={projectSettingsOpen} projectId={currentProjectId} projectName={currentActiveSession?.title || '当前项目'} - members={(currentProject?.members || []).map((m) => ({ - userId: m.userId, - name: allUsers.find((u) => u.id === m.userId)?.name || `用户 ${m.userId}`, - }))} + members={(currentProject?.members || []) + .filter((m) => m.status === 'active') + .map((m) => ({ + userId: m.userId, + name: allUsers.find((u) => u.id === m.userId)?.name || `用户 ${m.userId}`, + }))} onClose={() => setProjectSettingsOpen(false)} onActivated={(activeKey) => { setProjectWorkflowsState((prev) => ({ diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index dc482803..9dd31383 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -36,6 +36,33 @@ async function apiFetch(path: string, init?: RequestInit): Promise { export class AuthClient { constructor(_options?: AuthClientOptions) {} + async fetchProfile(): Promise> { + const token = this.getSessionToken(); + const resp = await apiFetch('/api/v1/me/profile', { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!resp.ok) { + throw new Error('无法加载个人资料'); + } + return resp.json(); + } + + async savePreferences(prefs: Record): Promise> { + const token = this.getSessionToken(); + const resp = await apiFetch('/api/v1/me/preferences', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(prefs), + }); + if (!resp.ok) { + throw new Error('保存偏好失败'); + } + return resp.json(); + } + async fetchMe(): Promise { const token = this.getSessionToken(); const resp = await apiFetch('/api/v1/auth/me', { @@ -191,14 +218,18 @@ export class AuthClient { } async getSystemAuthConfig(): Promise { - const token = this.getSessionToken(); - const resp = await apiFetch('/api/v1/admin/config', { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); + const resp = await apiFetch('/api/v1/auth/public-config'); if (!resp.ok) { return { allowRegistration: false, requireApproval: true }; } - return resp.json(); + const data = (await resp.json()) as { + allow_registration?: boolean; + require_approval?: boolean; + }; + return { + allowRegistration: data.allow_registration ?? false, + requireApproval: data.require_approval ?? true, + }; } async listRoles(): Promise { @@ -303,7 +334,20 @@ export class AuthClient { return resp.json(); } - logout() { + async logout(): Promise { + const token = this.getSessionToken(); + try { + await apiFetch('/api/v1/auth/logout', { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + } catch { + // 网络失败仍清除本地态 + } + this.clearLocalSession(); + } + + clearLocalSession() { if (typeof window !== 'undefined' && window.localStorage) { window.localStorage.removeItem(AUTH_STORAGE_KEY_SESSION); } diff --git a/web/src/api/authHeaders.ts b/web/src/api/authHeaders.ts index 69a2fe72..3e9128f3 100644 --- a/web/src/api/authHeaders.ts +++ b/web/src/api/authHeaders.ts @@ -23,3 +23,23 @@ export function getBearerAuthHeaders(contentType?: string): Record, + contentType?: string, +): Record { + return { ...getBearerAuthHeaders(contentType), ...extra }; +} + +/** Authenticated fetch wrapper; merges Bearer token into every request. */ +export async function authorizedFetch(input: RequestInfo, init?: RequestInit): Promise { + const extra = + init?.headers instanceof Headers + ? Object.fromEntries(init.headers.entries()) + : ((init?.headers as Record | undefined) ?? {}); + const contentType = + typeof extra['Content-Type'] === 'string' ? extra['Content-Type'] : undefined; + const headers = mergeAuthHeaders(extra, contentType); + return fetch(input, { ...init, headers }); +} diff --git a/web/src/api/chatDigest.ts b/web/src/api/chatDigest.ts index f69d6afa..7215d85f 100644 --- a/web/src/api/chatDigest.ts +++ b/web/src/api/chatDigest.ts @@ -1,4 +1,5 @@ import { DailyDigest } from '../types/workbench'; +import { mergeAuthHeaders, getBearerAuthHeaders } from './authHeaders'; export async function fetchDailyDigests( sessionId: string, @@ -12,14 +13,15 @@ export async function fetchDailyDigests( const res = await fetch(url.toString(), { method: 'GET', - headers: { Accept: 'application/json' }, + headers: mergeAuthHeaders({ Accept: 'application/json' }), }); if (!res.ok) { throw new Error(`fetchDailyDigests failed: HTTP ${res.status}`); } - return res.json(); + const data = await res.json(); + return Array.isArray(data) ? data : []; } export async function generateDailyDigest( @@ -28,7 +30,7 @@ export async function generateDailyDigest( ): Promise<{ queued: boolean; digest: DailyDigest }> { const res = await fetch('/api/v1/chat/digests/generate', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getBearerAuthHeaders('application/json'), body: JSON.stringify({ session_id: sessionId, day_key: dayKey, diff --git a/web/src/api/chatSearch.ts b/web/src/api/chatSearch.ts index ef93776e..c5faf138 100644 --- a/web/src/api/chatSearch.ts +++ b/web/src/api/chatSearch.ts @@ -1,4 +1,5 @@ import { ChatSearchHit, ChatSearchMode } from '../types/workbench'; +import { mergeAuthHeaders } from './authHeaders'; export interface SearchChatMessagesParams { sessionId: string; @@ -33,12 +34,13 @@ export async function searchChatMessages(params: SearchChatMessagesParams): Prom const res = await fetch(url.toString(), { method: 'GET', - headers: { Accept: 'application/json' }, + headers: mergeAuthHeaders({ Accept: 'application/json' }), }); if (!res.ok) { throw new Error(`searchChatMessages failed: HTTP ${res.status}`); } - return res.json(); + const data = await res.json(); + return Array.isArray(data) ? data : []; } diff --git a/web/src/api/chatSessions.ts b/web/src/api/chatSessions.ts index 05bd7b46..7bead403 100644 --- a/web/src/api/chatSessions.ts +++ b/web/src/api/chatSessions.ts @@ -38,7 +38,8 @@ export async function fetchChatSessions( if (!res.ok) { throw new Error(`获取会话列表失败: ${res.status}`); } - return res.json(); + const data = await res.json(); + return Array.isArray(data) ? data : []; } export async function upsertChatSession(input: UpsertChatSessionInput): Promise { diff --git a/web/src/api/chatStream.ts b/web/src/api/chatStream.ts index 165debad..52c5140c 100644 --- a/web/src/api/chatStream.ts +++ b/web/src/api/chatStream.ts @@ -1,6 +1,7 @@ import type { ChatErrorInfo } from '../types/workbench'; import type { MessageExportCapability } from '../types/replyExport'; import { fallbackErrorClass } from './chatRetry'; +import { mergeAuthHeaders } from './authHeaders'; export type StreamEvent = { id?: string; @@ -139,13 +140,13 @@ export async function streamChat( signal?: AbortSignal, options?: { idempotencyKey?: string }, ): Promise { - const headers: Record = { - 'Content-Type': 'application/json', - Accept: 'text/event-stream', - }; - if (options?.idempotencyKey) { - headers['Idempotency-Key'] = options.idempotencyKey; - } + const headers = mergeAuthHeaders( + { + Accept: 'text/event-stream', + ...(options?.idempotencyKey ? { 'Idempotency-Key': options.idempotencyKey } : {}), + }, + 'application/json', + ); const resp = await fetch('/api/v1/chat/stream', { method: 'POST', headers, diff --git a/web/src/api/chatTurn.ts b/web/src/api/chatTurn.ts index 03f46cf9..be018b8a 100644 --- a/web/src/api/chatTurn.ts +++ b/web/src/api/chatTurn.ts @@ -1,3 +1,5 @@ +import { getBearerAuthHeaders } from './authHeaders'; + export type ChatTurnStatus = { sessionId: string; clientTurnId: string; @@ -13,7 +15,7 @@ export async function fetchChatTurnStatus( signal?: AbortSignal, ): Promise { const url = `/api/v1/chat/turns/${encodeURIComponent(clientTurnId)}?session_id=${encodeURIComponent(sessionId)}`; - const res = await fetch(url, { signal }); + const res = await fetch(url, { signal, headers: getBearerAuthHeaders() }); if (res.status === 404) return null; if (!res.ok) { throw new Error(`fetchChatTurnStatus failed: HTTP ${res.status}`); diff --git a/web/src/api/chatWindow.ts b/web/src/api/chatWindow.ts index 9be24226..ce8e2cc7 100644 --- a/web/src/api/chatWindow.ts +++ b/web/src/api/chatWindow.ts @@ -6,6 +6,7 @@ import { ChatCategoryStat, } from '../types/workbench'; import { MessageContentKind, MessageExportCapability } from '../types/replyExport'; +import { getBearerAuthHeaders } from './authHeaders'; export interface RawChatMessageRecord { id: number; @@ -123,7 +124,7 @@ export async function fetchChatWindow( url += `&before_day=${encodeURIComponent(beforeDay)}`; } - const res = await fetch(url, { signal }); + const res = await fetch(url, { signal, headers: getBearerAuthHeaders() }); if (!res.ok) { throw new Error(`fetchChatWindow failed: HTTP ${res.status}`); } @@ -156,7 +157,9 @@ export async function fetchDayMessages( if (options?.afterId) params.set('after_id', String(options.afterId)); if (options?.limit) params.set('limit', String(options.limit)); - const res = await fetch(`/api/v1/chat/messages/day?${params.toString()}`); + const res = await fetch(`/api/v1/chat/messages/day?${params.toString()}`, { + headers: getBearerAuthHeaders(), + }); if (!res.ok) { throw new Error(`fetchDayMessages failed: HTTP ${res.status}`); } @@ -174,12 +177,15 @@ export async function fetchDayMessages( } export async function fetchChatDates(sessionId: string): Promise { - const res = await fetch(`/api/v1/chat/dates?session_id=${encodeURIComponent(sessionId)}`); + const res = await fetch(`/api/v1/chat/dates?session_id=${encodeURIComponent(sessionId)}`, { + headers: getBearerAuthHeaders(), + }); if (!res.ok) { throw new Error(`fetchChatDates failed: HTTP ${res.status}`); } const raw = await res.json(); - return (raw || []).map((item: any) => ({ + const items = Array.isArray(raw) ? raw : []; + return items.map((item: any) => ({ dayKey: item.day_key, count: item.count || 0, categories: item.categories || {}, @@ -192,7 +198,7 @@ export async function importChatMessages( ): Promise<{ imported_count: number; total: number }> { const res = await fetch('/api/v1/chat/messages/import', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getBearerAuthHeaders('application/json'), body: JSON.stringify(messages), }); if (!res.ok) { @@ -202,12 +208,15 @@ export async function importChatMessages( } export async function fetchChatCategories(sessionId: string): Promise { - const res = await fetch(`/api/v1/chat/categories?session_id=${encodeURIComponent(sessionId)}`); + const res = await fetch(`/api/v1/chat/categories?session_id=${encodeURIComponent(sessionId)}`, { + headers: getBearerAuthHeaders(), + }); if (!res.ok) { throw new Error(`fetchChatCategories failed: HTTP ${res.status}`); } const raw = await res.json(); - return (raw || []).map((item: any) => ({ + const items = Array.isArray(raw) ? raw : []; + return items.map((item: any) => ({ category: item.category, label: item.label, count: item.count || 0, diff --git a/web/src/api/docgov.ts b/web/src/api/docgov.ts index c26c23eb..e85a3fef 100644 --- a/web/src/api/docgov.ts +++ b/web/src/api/docgov.ts @@ -1,5 +1,7 @@ /** 受控文档正文保存与编辑租约 API */ +import { getBearerAuthHeaders } from './authHeaders'; + const API_BASE = ''; export interface SaveDocumentContentPayload { @@ -23,7 +25,7 @@ export async function saveDocumentContent(payload: SaveDocumentContentPayload): }> { const resp = await fetch(`${API_BASE}/api/v1/docgov/content`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getBearerAuthHeaders('application/json'), body: JSON.stringify(payload), }); if (!resp.ok) { diff --git a/web/src/api/projectDocuments.ts b/web/src/api/projectDocuments.ts index af0070f0..be3234ad 100644 --- a/web/src/api/projectDocuments.ts +++ b/web/src/api/projectDocuments.ts @@ -1,5 +1,7 @@ /** 项目工作流受控文档 API */ +import { getBearerAuthHeaders } from './authHeaders'; + export type WorkflowDocumentSummary = { doc_id: string; project_id: number; @@ -26,17 +28,21 @@ export async function listProjectWorkflowDocuments( ): Promise { const resp = await fetch( `/api/v1/projects/${projectId}/documents?category=workflow`, + { headers: getBearerAuthHeaders() }, ); if (!resp.ok) { throw new Error(`list project workflow documents failed: ${resp.status}`); } - return resp.json(); + const data = await resp.json(); + return Array.isArray(data) ? data : []; } export async function fetchControlledDocumentContent( docId: string, ): Promise { - const resp = await fetch(`/api/v1/docgov/documents/${encodeURIComponent(docId)}/content`); + const resp = await fetch(`/api/v1/docgov/documents/${encodeURIComponent(docId)}/content`, { + headers: getBearerAuthHeaders(), + }); if (!resp.ok) { throw new Error(`fetch controlled document content failed: ${resp.status}`); } diff --git a/web/src/api/projectSettings.ts b/web/src/api/projectSettings.ts index 28f6161a..0e7f2de9 100644 --- a/web/src/api/projectSettings.ts +++ b/web/src/api/projectSettings.ts @@ -6,6 +6,8 @@ import { StageReadiness, AdvanceStageResult, ProjectNotification, + WorkflowCatalogItem, + TerminationPreview, } from '../types/projectSettings'; import { getBearerAuthHeaders } from './authHeaders'; @@ -130,6 +132,44 @@ export async function fetchProjectNotifications( return Array.isArray(data.items) ? data.items : []; } +export async function fetchWorkflowCatalog(): Promise { + const res = await fetch(`${API_BASE}/api/v1/workflows/catalog`, { + headers: getBearerAuthHeaders(), + }); + if (!res.ok) { + return []; + } + const data = await res.json(); + return Array.isArray(data.workflows) ? data.workflows : []; +} + +export async function fetchTerminationPreview(instanceId: number): Promise { + const res = await fetch(`${API_BASE}/api/v1/workflow-instances/${instanceId}/termination-preview`, { + headers: getBearerAuthHeaders(), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(parseError(text) || `终止预览失败: ${res.status}`); + } + return res.json(); +} + +export async function terminateWorkflowInstance( + instanceId: number, + input: { reason: string; confirm: boolean; expected_config_revision: number } +): Promise<{ instance: { id: number; status: string }; reason: string }> { + const res = await fetch(`${API_BASE}/api/v1/workflow-instances/${instanceId}/terminate`, { + method: 'POST', + headers: getBearerAuthHeaders('application/json'), + body: JSON.stringify(input), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(parseError(text) || `终止工作流失败: ${res.status}`); + } + return res.json(); +} + export async function advanceStage( instanceId: number, stageKey: string, @@ -148,7 +188,11 @@ export async function advanceStage( ); if (!res.ok) { const text = await res.text(); - throw new Error(parseError(text) || `阶段推进失败: ${res.status}`); + const msg = parseError(text) || `阶段推进失败: ${res.status}`; + if (res.status === 409 || /revision|conflict|版本/i.test(msg)) { + throw new Error(`配置版本已变化,请刷新后重试:${msg}`); + } + throw new Error(msg); } return res.json(); } @@ -156,6 +200,13 @@ export async function advanceStage( function parseError(text: string): string | null { try { const parsed = JSON.parse(text); + if (Array.isArray(parsed.issues) && parsed.issues.length > 0) { + const details = parsed.issues + .map((issue: { message?: string }) => issue.message) + .filter(Boolean) + .join(';'); + if (details) return details; + } return parsed.message || parsed.error || null; } catch { return text || null; diff --git a/web/src/api/projectWorkflow.ts b/web/src/api/projectWorkflow.ts index 5264b682..b091d865 100644 --- a/web/src/api/projectWorkflow.ts +++ b/web/src/api/projectWorkflow.ts @@ -18,7 +18,8 @@ export async function fetchProjects(): Promise { if (!res.ok) { throw new Error(`获取项目列表失败: ${res.status}`); } - return res.json(); + const data = await res.json(); + return Array.isArray(data) ? data : []; } export async function createProject(data: CreateProjectInput): Promise { diff --git a/web/src/api/taskRuns.ts b/web/src/api/taskRuns.ts index 5327dec8..7896ea89 100644 --- a/web/src/api/taskRuns.ts +++ b/web/src/api/taskRuns.ts @@ -1,4 +1,5 @@ import { parseSSEChunk, type StreamEvent } from './chatStream'; +import { mergeAuthHeaders, getBearerAuthHeaders } from './authHeaders'; export type CreateTaskRunBody = Record & { session_id: string; @@ -17,7 +18,7 @@ export type CreateTaskRunResponse = { export async function createTaskRun(body: CreateTaskRunBody): Promise { const res = await fetch('/api/v1/tasks/runs', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getBearerAuthHeaders('application/json'), body: JSON.stringify(body), }); if (!res.ok) { @@ -35,7 +36,7 @@ export async function subscribeTaskRunEvents( const url = `/api/v1/tasks/runs/${encodeURIComponent(runId)}/events?after_seq=${after}`; const resp = await fetch(url, { method: 'GET', - headers: { Accept: 'text/event-stream' }, + headers: mergeAuthHeaders({ Accept: 'text/event-stream' }), signal, }); if (!resp.ok || !resp.body) { @@ -61,6 +62,7 @@ export async function subscribeTaskRunEvents( export async function cancelTaskRun(runId: string): Promise { const res = await fetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}/cancel`, { method: 'POST', + headers: getBearerAuthHeaders(), }); if (!res.ok) { throw new Error(`cancel task run failed: HTTP ${res.status}`); @@ -68,7 +70,9 @@ export async function cancelTaskRun(runId: string): Promise { } export async function getTaskRun(runId: string) { - const res = await fetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}`); + const res = await fetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}`, { + headers: getBearerAuthHeaders(), + }); if (!res.ok) { throw new Error(`get task run failed: HTTP ${res.status}`); } diff --git a/web/src/api/taskStream.ts b/web/src/api/taskStream.ts index ae3e3ce5..53f7ac76 100644 --- a/web/src/api/taskStream.ts +++ b/web/src/api/taskStream.ts @@ -1,6 +1,7 @@ import { parseSSEChunk, type StreamEvent } from './chatStream'; import { canonicalDocIdForTask } from '../lib/controlledDocHydrate'; import type { TaskRunContext } from '../types/workbench'; +import { mergeAuthHeaders } from './authHeaders'; export type { TaskRunContext }; @@ -18,7 +19,7 @@ export async function streamTask( ): Promise { const resp = await fetch('/api/v1/tasks/run/stream', { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' }, + headers: mergeAuthHeaders({ Accept: 'text/event-stream' }, 'application/json'), body: JSON.stringify(body), signal, }); diff --git a/web/src/components/admin/AdminSettingsModal.tsx b/web/src/components/admin/AdminSettingsModal.tsx index 51f6f68e..4c7fd744 100644 --- a/web/src/components/admin/AdminSettingsModal.tsx +++ b/web/src/components/admin/AdminSettingsModal.tsx @@ -1473,7 +1473,7 @@ export const AdminSettingsModal: React.FC = ({ Bcrypt (加盐哈希)
- 符合 21 CFR Part 11 电子签名不可逆哈希标准 + 电子签名与审计事件由服务端记录并防篡改校验
diff --git a/web/src/components/project/ProjectSettingsModal.tsx b/web/src/components/project/ProjectSettingsModal.tsx index 8f345161..4d5eefc0 100644 --- a/web/src/components/project/ProjectSettingsModal.tsx +++ b/web/src/components/project/ProjectSettingsModal.tsx @@ -6,6 +6,8 @@ import { saveWorkflowPlan, fetchActivationPreview, activateWorkflowInstance, + fetchTerminationPreview, + terminateWorkflowInstance, } from '../../api/projectSettings'; import { ProjectSettings, @@ -45,6 +47,8 @@ export const ProjectSettingsModal: React.FC = ({ const [plan, setPlan] = useState(null); const [preview, setPreview] = useState(null); const [selectedKey, setSelectedKey] = useState(''); + const [terminateReason, setTerminateReason] = useState(''); + const [terminatePreview, setTerminatePreview] = useState<{ impact?: string } | null>(null); const loadSettings = useCallback(async () => { setLoading(true); @@ -52,6 +56,16 @@ export const ProjectSettingsModal: React.FC = ({ try { const s = await fetchProjectSettings(projectId); setSettings(s); + if (s.live_instance && s.live_instance.status !== 'completed' && s.live_instance.status !== 'terminated') { + try { + const preview = await fetchTerminationPreview(s.live_instance.id); + setTerminatePreview(preview); + } catch { + setTerminatePreview(null); + } + } else { + setTerminatePreview(null); + } if (s.plan) { setPlan(s.plan); setSelectedKey(s.plan.workflow_definition_key); @@ -105,6 +119,31 @@ export const ProjectSettingsModal: React.FC = ({ const handleSavePlan = async () => { if (!plan || !settings) return; + + const enabledTasks = plan.stages + .filter((stage) => stage.enabled) + .flatMap((stage) => stage.doc_tasks.filter((task) => task.enabled)); + if (enabledTasks.length === 0) { + setError('至少启用一个文档后才能激活工作流。'); + return; + } + if (members.length < 3) { + setError(`当前项目只有 ${members.length} 名有效成员。启用文档需要分别指定编制、审核、批准负责人,请先在“项目成员”中补充至至少 3 人。`); + return; + } + for (const task of enabledTasks) { + const dutyIds = [task.duties.author_user_id, task.duties.reviewer_user_id, task.duties.approver_user_id]; + const label = task.task_title || task.task_key; + if (dutyIds.some((id) => !id)) { + setError(`请为“${label}”完整指定编制、审核、批准负责人。`); + return; + } + if (new Set(dutyIds).size !== dutyIds.length) { + setError(`“${label}”的编制、审核、批准负责人必须由不同成员担任。`); + return; + } + } + setLoading(true); setError(null); try { @@ -126,6 +165,24 @@ export const ProjectSettingsModal: React.FC = ({ } }; + const handleTerminate = async () => { + if (!settings?.live_instance || !terminateReason.trim()) return; + setLoading(true); + setError(null); + try { + await terminateWorkflowInstance(settings.live_instance.id, { + reason: terminateReason.trim(), + confirm: true, + expected_config_revision: settings.live_instance.config_revision, + }); + onClose(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : '终止工作流失败'); + } finally { + setLoading(false); + } + }; + const handleActivate = async () => { if (!preview || !settings) return; setLoading(true); @@ -235,6 +292,57 @@ export const ProjectSettingsModal: React.FC = ({ )} + {settings?.live_instance && settings.live_instance.status !== 'completed' && ( +
+
受控终止当前工作流
+ {terminatePreview?.impact && ( +
{terminatePreview.impact}
+ )} +