merge: fix/governance-v2-codex — three-workflow governance and Codex P0

This commit is contained in:
weichen
2026-09-13 02:50:23 +00:00
163 changed files with 10928 additions and 865 deletions
@@ -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`
+24
View File
@@ -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 <branch>`
## 禁止
- 向用户索要或要求粘贴 token
- 在聊天/文档/commit 中展示 secret
- 写入 git config 或提交到仓库
## 缺失时
在 `.doc/ops` 记 blocker(路径 + 缺字段),提示用户本机补齐后重试;**仍不索要内容**。
+312
View File
@@ -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*
+440
View File
@@ -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*
@@ -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
**下一步**: 待产品&技术评审确认,进入实施阶段
@@ -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)。
+461
View File
@@ -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 快速参考
**适用对象**: 项目全体
**更新频率**: 同步更新主审计文档
+71
View File
@@ -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 与前端人工抽检**。
File diff suppressed because it is too large Load Diff
@@ -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` 执行只读聚合查询。该快照仅用于诊断当前开发环境,不代表生产环境。
@@ -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。
+35
View File
@@ -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
```
+23
View File
@@ -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`。
+30
View File
@@ -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) |
+24
View File
@@ -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)。
@@ -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)
@@ -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 顺序执行。
@@ -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)
@@ -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`
+20
View File
@@ -0,0 +1,20 @@
---
title: "<Checklist 标题>"
status: in_progress
created: YYYY-MM-DD
updated: YYYY-MM-DD
related_task: ""
---
# <Checklist 标题>
## Wave / 阶段
### 阶段名
- [ ] 项 1 — 证据:
- [ ] 项 2 — 证据:
## 完成定义
- [ ] 全部勾选且有证据链接
+41
View File
@@ -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:
+20
View File
@@ -0,0 +1,20 @@
---
title: "<长期任务标题>"
status: planned
created: YYYY-MM-DD
updated: YYYY-MM-DD
---
# <长期任务标题>
## 动机
## 成功标准
## 依赖
## 暂不启动原因
(若 status 为 planned)
## 相关链接
+26
View File
@@ -0,0 +1,26 @@
---
title: "<调研标题>"
created: YYYY-MM-DD
status: done
confidence: high
---
# 调研:<标题>
## 结论
(1–3 句)
## 证据
| 项 | 路径/命令 | 结果 |
|----|-----------|------|
| | | |
## 开放问题
-
## 建议动作
-
+40
View File
@@ -0,0 +1,40 @@
---
title: "<任务标题>"
status: planned
created: YYYY-MM-DD
updated: YYYY-MM-DD
owner: agent
---
# <任务标题>
## 目标
(一句话可验证目标)
## 范围
-
## 非目标
-
## 步骤
1. [ ]
2. [ ]
## 验收标准
- [ ]
## 相关链接
- Checklist:
- Handoff:
- Test report:
## 证据 / 备注
(命令输出、commit、文件路径)
+37
View File
@@ -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`
@@ -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。
@@ -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 门禁前须重跑并归档新报告。
@@ -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 容器就绪后补跑并更新本报告第五节真实控制台输出
@@ -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 检查历史不一致。
@@ -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`)。
+2 -2
View File
@@ -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
+199
View File
@@ -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-<slug>.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=<token>
- URL_PREFIX=<host-without-scheme>
```
### 执行方式
- 优先调用本仓封装脚本(若存在)
- 或自行读取 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)。**
+9
View File
@@ -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/` 路径读取。
+130
View File
@@ -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=<token>
- URL_PREFIX=<host>
```
- **用法**:`./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` 上渐进式分批提交
+3 -1
View File
@@ -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=
+4
View File
@@ -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
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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';
+3 -1
View File
@@ -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
+2
View File
@@ -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:
+1 -1
View File
@@ -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"},
+2
View File
@@ -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"`
+44
View File
@@ -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
}
+1 -1
View File
@@ -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"},
+1 -1
View File
@@ -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)
}
}
+25
View File
@@ -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"},
}
}
+9
View File
@@ -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)
+8 -5
View File
@@ -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"
+39
View File
@@ -0,0 +1,39 @@
package bizdev
import (
"context"
"fmt"
"chat-agent-system/internal/ctxmgr"
)
type snapshotDialogueInputs struct {
Dialogue []string
Decisions []string
ExcludedPrivate int
ExcludedNoAccess int
}
func (s *Service) collectSnapshotDialogueInputs(ctx context.Context, projectID, userID int64) snapshotDialogueInputs {
out := snapshotDialogueInputs{}
if s == nil || projectID <= 0 || s.ctxMgr == nil {
return out
}
if coll, err := s.getProjectContextForUser(ctx, projectID, userID); err == nil && coll != nil {
for _, d := range coll.MilestoneDecisions {
out.Decisions = append(out.Decisions, fmt.Sprintf("[%s] %s (%s)", d.ActivityType, d.Title, d.UserName))
}
}
dialogue, err := s.ctxMgr.CollectProjectDialogue(ctx, projectID, ctxmgr.CollectProjectDialogueOptions{
Limit: 120,
})
if err != nil || dialogue == nil {
return out
}
out.Dialogue = dialogue.Lines
out.ExcludedPrivate = dialogue.ExcludedPrivate
out.ExcludedNoAccess = dialogue.ExcludedNoAccess
return out
}
@@ -0,0 +1,48 @@
package bizdev
import (
"context"
"testing"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/projectcontext"
"chat-agent-system/internal/replyexport"
)
func TestCollectSnapshotDialogueInputs_FeedsBuildSnapshot(t *testing.T) {
ctx := context.Background()
mgr := ctxmgr.NewContextManager(nil, nil, ctxmgr.DefaultTimeDecayConfig())
projectID := int64(601)
meta := map[string]any{"project_id": projectID}
_, _ = mgr.RecordUserMessageWithID(ctx, "sess-601", 1, "user", "测试经理需要覆盖注册分类路径", "", "", meta)
_, _ = mgr.RecordUserMessageWithID(ctx, "sess-601", 1, "assistant", "我会整理测试验证范围与风险点。", "", "", map[string]any{
"project_id": projectID,
"export_policy": string(replyexport.PolicyFull),
"content_kind": string(replyexport.KindDocumentText),
})
_, _ = mgr.RecordActivity(ctx, projectID, 1, "张工", "decision", "确认先做注册分类调研", map[string]any{}, 1, true)
svc := &Service{ctxMgr: mgr}
inputs := svc.collectSnapshotDialogueInputs(ctx, projectID, 1)
if len(inputs.Dialogue) == 0 {
t.Fatalf("expected dialogue lines, got none")
}
if len(inputs.Decisions) != 1 {
t.Fatalf("expected 1 decision, got %d", len(inputs.Decisions))
}
ctxSvc := projectcontext.NewService(nil)
snap, err := ctxSvc.BuildSnapshot(ctx, projectcontext.BuildInput{
ProjectID: projectID,
TaskKey: "test_manager",
Dialogue: inputs.Dialogue,
Decisions: inputs.Decisions,
})
if err != nil {
t.Fatalf("BuildSnapshot: %v", err)
}
if snap.SourceCount < 2 {
t.Fatalf("expected dialogue + decision sources, got %d", snap.SourceCount)
}
}
+58 -27
View File
@@ -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...)
}
}
}
+3 -3
View File
@@ -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),透传全部前序累积
+25 -18
View File
@@ -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")
}
+2 -2
View File
@@ -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",
+13
View File
@@ -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
}
@@ -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) {
+63
View File
@@ -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 == "" {
+53
View File
@@ -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
}
@@ -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)
}
}
+7
View File
@@ -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()
+169
View File
@@ -0,0 +1,169 @@
package ctxmgr
import (
"context"
"encoding/json"
"fmt"
"strings"
"chat-agent-system/internal/replyexport"
)
const defaultProjectDialogueLimit = 120
// ProjectDialogueCollectResult 项目级对话采集结果(供上下文快照使用)。
type ProjectDialogueCollectResult struct {
Lines []string
ExcludedPrivate int
ExcludedNoAccess int
AllowedStageKey string
StageFilteredCount int
}
// CollectProjectDialogueOptions 采集选项。
type CollectProjectDialogueOptions struct {
Limit int
AllowedStageKey string // 非空时仅保留无 stage_key 或匹配的对话
}
// CollectProjectDialogue 收集进入项目知识边界的共享对话行。
func (m *ContextManager) CollectProjectDialogue(ctx context.Context, projectID int64, opts CollectProjectDialogueOptions) (*ProjectDialogueCollectResult, error) {
out := &ProjectDialogueCollectResult{AllowedStageKey: opts.AllowedStageKey}
if m == nil || projectID <= 0 {
return out, nil
}
limit := opts.Limit
if limit <= 0 {
limit = defaultProjectDialogueLimit
}
var msgs []ChatMessageRecord
if m.pool != nil {
rows, err := m.pool.Query(ctx, `
SELECT cm.id, cm.session_id, COALESCE(cm.user_id, 0), cm.role, cm.content,
cm.intent, cm.branch, cm.metadata, COALESCE(cm.day_key::text, ''),
COALESCE(cm.category, ''), COALESCE(cm.content_kind, ''),
COALESCE(cm.export_policy, 'none'), cm.created_at
FROM chat_messages cm
JOIN chat_sessions cs ON cs.session_id = cm.session_id
WHERE cs.project_id = $1 AND cm.deleted_at IS NULL
ORDER BY cm.created_at ASC
LIMIT $2
`, projectID, limit)
if err != nil {
return nil, fmt.Errorf("collect project dialogue: %w", err)
}
defer rows.Close()
for rows.Next() {
var rec ChatMessageRecord
var metaJSON []byte
if err := rows.Scan(
&rec.ID, &rec.SessionID, &rec.UserID, &rec.Role, &rec.Content,
&rec.Intent, &rec.Branch, &metaJSON, &rec.DayKey, &rec.Category,
&rec.ContentKind, &rec.ExportPolicy, &rec.CreatedAt,
); err != nil {
return nil, err
}
if len(metaJSON) > 0 {
_ = json.Unmarshal(metaJSON, &rec.Metadata)
}
msgs = append(msgs, rec)
}
} else {
m.mu.RLock()
for sessionID, sessionMsgs := range m.messages {
sess := m.sessions[sessionID]
if sess != nil && sess.ProjectID != projectID {
continue
}
for _, rec := range sessionMsgs {
if sess == nil || sess.ProjectID == 0 {
if pid := messageProjectID(rec); pid != projectID {
continue
}
}
msgs = append(msgs, rec)
}
}
m.mu.RUnlock()
}
for _, rec := range msgs {
line, include, excludedPrivate, excludedNoAccess := classifyDialogueLine(rec, opts.AllowedStageKey)
if excludedPrivate {
out.ExcludedPrivate++
}
if excludedNoAccess {
out.ExcludedNoAccess++
out.StageFilteredCount++
}
if !include || line == "" {
continue
}
out.Lines = append(out.Lines, line)
}
return out, nil
}
func messageProjectID(rec ChatMessageRecord) int64 {
if rec.Metadata == nil {
return 0
}
if pVal, ok := rec.Metadata["project_id"].(int64); ok {
return pVal
}
if pVal, ok := rec.Metadata["project_id"].(float64); ok {
return int64(pVal)
}
return 0
}
func classifyDialogueLine(rec ChatMessageRecord, allowedStageKey string) (line string, include bool, excludedPrivate bool, excludedNoAccess bool) {
content := strings.TrimSpace(rec.Content)
if content == "" {
return "", false, true, false
}
if strings.HasPrefix(content, "[private]") || strings.HasPrefix(content, "[draft]") {
return "", false, true, false
}
if vis, _ := rec.Metadata["visibility"].(string); vis == "private" {
return "", false, true, false
}
stageKey := MessageStageKey(rec.Metadata)
if allowedStageKey != "" && stageKey != "" && stageKey != allowedStageKey {
return "", false, false, true
}
switch rec.Role {
case "user":
line = fmt.Sprintf("用户: %s", content)
return line, true, false, false
case "assistant":
switch rec.ContentKind {
case string(replyexport.KindActionCard), string(replyexport.KindStatusUpdate),
string(replyexport.KindErrorNotice), string(replyexport.KindSystemNotice):
return "", false, true, false
}
switch rec.ExportPolicy {
case string(replyexport.PolicyFull), string(replyexport.PolicyCopyOnly):
line = fmt.Sprintf("助手: %s", content)
return line, true, false, false
default:
return "", false, true, false
}
default:
return "", false, true, false
}
}
// MessageStageKey 从消息 metadata 提取阶段键。
func MessageStageKey(metadata map[string]any) string {
if metadata == nil {
return ""
}
if sk, ok := metadata["stage_key"].(string); ok {
return sk
}
return ""
}
+60
View File
@@ -0,0 +1,60 @@
package ctxmgr
import (
"context"
"testing"
"chat-agent-system/internal/replyexport"
)
func TestCollectProjectDialogue_ExcludesPrivateAndIncludesShared(t *testing.T) {
ctx := context.Background()
mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig())
projectID := int64(501)
meta := map[string]any{"project_id": projectID}
_, _ = mgr.RecordUserMessageWithID(ctx, "sess-501", 1, "user", "[private] 不应纳入", "", "", meta)
_, _ = mgr.RecordUserMessageWithID(ctx, "sess-501", 1, "user", "项目需要支持多用户协作", "", "", meta)
_, _ = mgr.RecordUserMessageWithID(ctx, "sess-501", 1, "assistant", "好的,我会整理需求要点。", "", "", map[string]any{
"project_id": projectID,
"export_policy": string(replyexport.PolicyFull),
"content_kind": string(replyexport.KindDocumentText),
})
res, err := mgr.CollectProjectDialogue(ctx, projectID, CollectProjectDialogueOptions{})
if err != nil {
t.Fatalf("CollectProjectDialogue: %v", err)
}
if len(res.Lines) != 2 {
t.Fatalf("expected 2 shared lines, got %d: %v", len(res.Lines), res.Lines)
}
if res.ExcludedPrivate < 1 {
t.Fatalf("expected excluded private >= 1, got %d", res.ExcludedPrivate)
}
}
func TestCollectProjectDialogue_StageFilter(t *testing.T) {
ctx := context.Background()
mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig())
projectID := int64(502)
_, _ = mgr.RecordUserMessageWithID(ctx, "sess-502", 1, "user", "立项阶段讨论", "", "", map[string]any{
"project_id": projectID, "stage_key": "biz_intake",
})
_, _ = mgr.RecordUserMessageWithID(ctx, "sess-502", 1, "user", "研发阶段讨论", "", "", map[string]any{
"project_id": projectID, "stage_key": "biz_feasibility",
})
res, err := mgr.CollectProjectDialogue(ctx, projectID, CollectProjectDialogueOptions{
AllowedStageKey: "biz_intake",
})
if err != nil {
t.Fatalf("CollectProjectDialogue: %v", err)
}
if len(res.Lines) != 1 {
t.Fatalf("expected 1 stage-filtered line, got %d: %v", len(res.Lines), res.Lines)
}
if res.ExcludedNoAccess != 1 {
t.Fatalf("expected 1 excluded by stage, got %d", res.ExcludedNoAccess)
}
}
+29
View File
@@ -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
}
+7
View File
@@ -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)
+292
View File
@@ -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)"
}
+62
View File
@@ -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))
}
}
@@ -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)
}
}
@@ -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)
}
}
+8 -5
View File
@@ -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)
}
}
+132
View File
@@ -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 = &copyDoc
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
}
@@ -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)
}
}
+41 -18
View File
@@ -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}
}
+1 -1
View File
@@ -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"
@@ -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)
+35 -19
View File
@@ -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
+24 -70
View File
@@ -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 &copyV, 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)
}
}
+18 -7
View File
@@ -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 {
@@ -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")
}
}
@@ -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
}
@@ -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)
+16 -9
View File
@@ -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 {
+256 -21
View File
@@ -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 &copyInst, 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 &copyInst, 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
}
+113
View File
@@ -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
}
@@ -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)
}
}
+116
View File
@@ -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 &copyV, 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},
}},
}},
}
}
+41 -39
View File
@@ -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"`
}
+73 -7
View File
@@ -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,
@@ -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])
}
}
+5
View File
@@ -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)
+33
View File
@@ -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)
+49 -5
View File
@@ -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()
@@ -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})
}
+4 -1
View File
@@ -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)
+46
View File
@@ -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)
}
}
+15
View File
@@ -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 {
+36
View File
@@ -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()

Some files were not shown because too many files have changed in this diff Show More