From 90f77eac9387c9109e88112f91ee866064ae6056 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 12 Sep 2026 12:06:53 +0000 Subject: [PATCH 01/24] feat(authz): implement six-phase permissions, settings and workflow v2 Close P0 auth gaps for lineage/runs, stage ABAC fail-closed, and project:create; add IAM migrations 00021-23, me settings APIs, schema v2 manifests, orchestrator skeleton, and gate A regression coverage. Co-authored-by: Cursor --- ...ngs-three-workflows-implementation-plan.md | 706 ++++++++++++++++++ .../2026-09-12-workflow-runtime-authority.md | 21 + .doc/authorization-baseline-2026-09-12.md | 35 + .doc/authorization-matrix-v2.md | 23 + .doc/test-fixtures/authz-test-topology.md | 37 + ...issions-workflows-six-phase-test-report.md | 63 ++ .../2026-09-12-pg-ci-soak-e2e-test-report.md | 9 +- .../goose/00021_2026-09-12-iam-hardening.sql | 46 ++ .../00022_2026-09-12-workflow-schema-v2.sql | 29 + .../00023_2026-09-12-instance-integrity.sql | 19 + deploy/postgres/preflight/00021-preflight.sql | 39 + internal/auth/bootstrap.go | 2 +- internal/auth/models.go | 2 + internal/auth/service.go | 44 ++ internal/auth/store.go | 2 +- internal/auth/store_test.go | 2 +- internal/authz/catalog.go | 25 + internal/authz/service.go | 9 + internal/projectinstance/service.go | 20 + internal/projectinstance/stage_access.go | 18 +- internal/projectinstance/store.go | 7 +- internal/projectinstance/validator.go | 36 + internal/projectworkflow/store.go | 10 +- internal/server/admin_permissions_handlers.go | 20 + internal/server/auth_gate_a_test.go | 46 ++ internal/server/auth_helpers.go | 35 + internal/server/bizdev_stage_gate.go | 13 +- internal/server/me_settings_handlers.go | 87 +++ internal/server/project_handlers.go | 35 +- internal/server/server.go | 5 + internal/server/stage_access_helpers.go | 39 +- internal/server/task_run_auth.go | 29 +- internal/server/task_run_handlers.go | 8 +- internal/squad/quality_gate_v2.go | 84 +++ internal/workflow/registry.go | 44 ++ internal/workflow/runtime/orchestrator.go | 103 +++ internal/workflowdef/manifest_loader.go | 33 + internal/workflowdef/manifests/main.v2.json | 51 ++ .../manifests/product_manager.v2.json | 22 + .../manifests/test_manager.v2.json | 23 + internal/workflowdef/types.go | 37 +- internal/workflowdef/validator.go | 3 + internal/workflowdef/validator_v2.go | 69 ++ web/src/App.tsx | 12 +- web/src/api/auth.ts | 42 +- .../components/admin/AdminSettingsModal.tsx | 2 +- 46 files changed, 1988 insertions(+), 58 deletions(-) create mode 100644 .doc/2026-09-12-permissions-settings-three-workflows-implementation-plan.md create mode 100644 .doc/adr/2026-09-12-workflow-runtime-authority.md create mode 100644 .doc/authorization-baseline-2026-09-12.md create mode 100644 .doc/authorization-matrix-v2.md create mode 100644 .doc/test-fixtures/authz-test-topology.md create mode 100644 .doc/test-reports/2026-09-12-permissions-workflows-six-phase-test-report.md create mode 100644 deploy/postgres/goose/00021_2026-09-12-iam-hardening.sql create mode 100644 deploy/postgres/goose/00022_2026-09-12-workflow-schema-v2.sql create mode 100644 deploy/postgres/goose/00023_2026-09-12-instance-integrity.sql create mode 100644 deploy/postgres/preflight/00021-preflight.sql create mode 100644 internal/authz/catalog.go create mode 100644 internal/server/admin_permissions_handlers.go create mode 100644 internal/server/auth_gate_a_test.go create mode 100644 internal/server/me_settings_handlers.go create mode 100644 internal/squad/quality_gate_v2.go create mode 100644 internal/workflow/registry.go create mode 100644 internal/workflow/runtime/orchestrator.go create mode 100644 internal/workflowdef/manifest_loader.go create mode 100644 internal/workflowdef/manifests/main.v2.json create mode 100644 internal/workflowdef/manifests/product_manager.v2.json create mode 100644 internal/workflowdef/manifests/test_manager.v2.json create mode 100644 internal/workflowdef/validator_v2.go diff --git a/.doc/2026-09-12-permissions-settings-three-workflows-implementation-plan.md b/.doc/2026-09-12-permissions-settings-three-workflows-implementation-plan.md new file mode 100644 index 00000000..92b1fd41 --- /dev/null +++ b/.doc/2026-09-12-permissions-settings-three-workflows-implementation-plan.md @@ -0,0 +1,706 @@ +# Agent System 权限、设置与三类工作流完整实施方案 + +## 1. 执行结论 + +当前系统已经完成了从原型向受控工作流平台演进所需的多数“结构件”:数据库账户与会话、系统角色/权限表、项目成员、工作流定义及版本、模板版本、项目工作流实例、阶段实例、文档任务实例、三权职责、激活确认、阶段推进请求、通知 Outbox、任务运行持久化和文档血缘均已存在。现有 Go 短测、前端测试和生产构建也能通过。因此,本轮不应推倒重做,而应完成最后一段也是最关键的一段:**统一授权权威、把设置真正落到服务端、让已发布工作流版本成为运行时唯一事实源,并将商务对接、智能产品经理、智能测试经理发布为可配置、可冻结、可执行、可审计的正式工作流。**[^1][^2][^3] + +但截至本次审计,系统还不能宣称“权限和设置已做全”,三个工作流也还不能按当前治理设计直接投入多人生产使用。核心原因如下: + +1. **权限表存在,但策略执行不完整。** 部分项目查询、工作流历史和血缘查询缺少对象级授权;创建项目只校验登录,不校验 `project:create`;工作流管理 API 仍统一要求超级管理员,未使用已入库的细粒度权限;系统权限计算还忽略绑定作用域、角色状态和 `valid_from`。[^4][^5][^6] +2. **阶段权限存在高风险放行路径。** 解析 live 实例发生数据库错误时会回退为“不限制”,多个列表、聊天、RAG/上下文过滤辅助函数也在错误时放行;拥有当前阶段任一文档职责的用户会获得该阶段全部文档任务权限,任务执行门禁还允许其运行任意 `task_key`。[^7][^8] +3. **设置界面与服务端权威尚未闭环。** 浏览器仍把 Bearer token 和会话对象放入 `localStorage`,登出只删除本地状态;个人资料保存、个人偏好和部分权限画像仍是前端本地数据,已有活动会话 API 未在用户设置中形成闭环。[^9][^10] +4. **三个工作流只是“登记过”,不是“完整接入”。** `main`、`product_manager`、`test_manager` 已作为发布种子存在,但都是单阶段包装;运行目录会把任意已发布版本压扁为第一阶段的第一个任务。商务对接真实的 V0.1/V0.5/V1.0 阶段仍硬编码在 BizDev 执行器中;产品经理和测试经理的五步 Agent 流程存在,但没有由版本化定义驱动。[^11][^12][^13] +5. **项目实例与文档治理还未成为同一条链。** 规划校验没有检查必需阶段/必需任务是否被整个遗漏,也没有校验三权人员是否为有效项目成员;实例持久化把所有阶段 `position` 写成 0;执行器又会在请求未传三权时回退到固定用户 2/3,并把文档阶段固定为 `V0.5_feasibility`,没有消费冻结实例中的职责和活动阶段。[^14][^15] + +因此建议把工作拆为三道发布闸门: + +- **闸门 A:授权止血。** 先关闭跨项目读取、错误放行、任意任务键、身份/会话本地权威等 P0;完成全部 API/数据通路的策略矩阵。 +- **闸门 B:配置与运行时合流。** 扩展工作流 schema,建立 `executor_key` 注册表和全阶段解释器,让项目实例、DocGov、任务运行、对话/RAG、文档血缘都从同一冻结快照取值。 +- **闸门 C:发布三个正式版本。** 以新版本而不是覆盖旧数据的方式发布三个工作流,完成迁移、真实 PostgreSQL、跨用户负例和真实 PI Agent E2E 后再开放生产选择。 + +本方案遵循当前系统已经确定的治理原则:生产身份只来自服务端会话;系统角色、项目角色和单文档三权职责分离;已发布版本不可原地修改;项目激活时冻结完整快照;同一项目只允许一个 live 工作流;当前流程完成或受控终止后才能选择下一个;门禁满足只发提醒,阶段推进必须由项目创建人明确确认。[^1] + +## 2. 本次研究范围与验证边界 + +### 2.1 已审阅范围 + +| 层 | 已审阅内容 | +|---|---| +| 数据库 | Goose `00014`–`00020`:成员、IAM、工作流/模板、项目实例、通知、任务运行 | +| 后端 | `internal/auth`、`authz`、`workflowdef`、`projectinstance`、`projectworkflow`、`bizdev`、`squad`、`server` | +| 前端 | 登录会话、RBAC 类型、用户设置、系统设置、项目设置、工作流切换/展示 | +| 业务执行 | 商务对接真实阶段;产品经理/测试经理 Agent 角色、步骤、模板、质量门禁、前置依赖和血缘 | +| 既有文档 | 2026-09-11 治理总方案、Phase 1/2 实施计划、当前 PG/CI/Soak/E2E 报告 | + +### 2.2 本轮已执行验证 + +```text +go test -short -timeout 8m \ + ./internal/auth/... ./internal/authz/... ./internal/workflowdef/... \ + ./internal/projectinstance/... ./internal/projectworkflow/... ./internal/server/... +结果:全部通过 + +cd web && npm test +结果:170 项 Node tests + 22 项 Vitest tests 全部通过 + +cd web && npm run build +结果:TypeScript + Vite 构建通过;存在主 bundle 过大的非阻断警告 +``` + +这些结果证明当前分支能编译且现有回归未破坏,不证明跨租户授权、真实 PostgreSQL 事务、重启恢复和真实 PI Agent 长链路已满足本方案的发布门禁。当前测试报告中 PI Agent 全生命周期仍记录了 `/v1/run` 超时,且报告结尾又写“仍在运行”,应先修正文档状态,再以一次新的、带 commit SHA 和环境指纹的运行作为验收证据。[^16] + +### 2.3 外部设计基线 + +授权采用“默认拒绝、每次请求验证、对象标识不可猜测越权、失败安全退出、记录审计”的基线;动态项目、阶段和文档条件用 RBAC + ReBAC + ABAC 组合,而不是只靠全局角色。OWASP 明确建议默认拒绝并对每次请求执行权限判断;NIST RBAC 模型把用户、角色、权限、操作和对象建模为独立关系,并支持职责分离约束。[^17][^18] + +浏览器会话不再把认证 token 放在 `localStorage`。OWASP 指出 Web Storage 可被同源 JavaScript 读取,建议会话使用 `HttpOnly; Secure; SameSite` Cookie;Cookie 模式下同时补齐 CSRF 防护。[^19] + +## 3. 当前系统能力盘点 + +### 3.1 可以直接保留并扩展的能力 + +| 能力 | 当前状态 | 本方案处理 | +|---|---|---| +| 统一 API 认证中间件 | 已存在 | 保留;切换到安全 Cookie/CSRF,并在授权失败时统一错误模型 | +| 数据库账户、密码哈希、会话哈希 | 已存在 | 保留;增加密码/MFA/重置策略、会话前端闭环和安全事件 | +| 系统角色/权限/绑定 | 已存在 | 纠正多角色、作用域、有效期、状态和管理审计语义 | +| `project_members` | 已存在 | 增加 FK、项目业务角色多绑定、成员状态和职责候选资格 | +| 工作流草稿/发布/退役 | 后端基础存在 | 增加复制草稿、完整 schema、细粒度授权、发布影响预览和 UI | +| 模板 CAS/版本/扫描状态 | 基础存在 | 增加扫描 worker、隔离区、预览、发布/退役和引用影响分析 | +| 项目规划/预览/确认/冻结 | 基础存在 | 修复完整性校验、原子 token、阶段顺序、受控变更和下一个工作流 | +| 文档三权状态机 | 已存在 | 由实例职责驱动;加职责资格、再认证/签署策略和阶段门禁映射 | +| Outbox/通知/阶段推进请求 | 已存在 | 完成用户偏好、渠道、升级、去重和创建人确认体验 | +| 任务运行持久化/lease | 已存在 | 把任务与实例/阶段/任务定义绑定;细分 read/cancel/resume 权限 | +| 文档血缘/stale | 已存在 | stale 成为阶段退出阻断条件,提供影响分析和重新基线闭环 | + +### 3.2 当前成熟度判定 + +| 领域 | 代码骨架 | 服务端权威 | 前端闭环 | 生产验收 | 判定 | +|---|---:|---:|---:|---:|---| +| 认证与会话 | 高 | 中 | 低 | 中 | 需整改 | +| 系统 RBAC | 中 | 低 | 中 | 低 | 发布阻断 | +| 项目 ReBAC | 中 | 中 | 中 | 低 | 发布阻断 | +| 阶段/文档 ABAC | 中 | 低 | 中 | 低 | 发布阻断 | +| 用户设置 | 中 | 低 | 中 | 低 | 未做全 | +| 系统设置 | 中 | 中 | 中 | 低 | 未做全 | +| 项目设置 | 中高 | 中 | 中 | 低 | 关键断点 | +| 工作流设计/版本 | 中高 | 中 | 低 | 低 | 关键断点 | +| 三工作流执行器 | 高 | 低 | 中 | 低 | 尚未正式接入 | +| 审计与通知 | 中 | 中 | 低 | 低 | 尚未闭环 | + +## 4. 必须优先修复的问题清单 + +### 4.1 P0:在接入三个工作流前必须关闭 + +| ID | 问题 | 影响 | 整改与验收 | +|---|---|---|---| +| AUTH-001 | 工作流运行历史、文档血缘 handler 未调用项目对象授权 | 登录用户可能读取其他项目元数据/血缘 | 所有项目子路由统一经过资源加载 + `Authorize`;跨项目返回 404 或统一 403;加双用户 PG 负例 | +| AUTH-002 | 阶段访问解析或过滤出错时放行 | DB/策略故障可扩大文档、聊天、RAG、Agent 上下文可见范围 | 生产环境 fail closed;只对明确标记的 legacy 项目执行迁移期策略,且有截止版本和指标 | +| AUTH-003 | 工作流路由接受客户端覆盖 `task_key`;阶段成员可运行当前阶段任意任务 | 绕过工作流定义和文档职责 | 服务端只从 `workflow_instance_id + active_stage + task_instance_id` 推导 executor;拒绝不在快照中的任务 | +| AUTH-004 | 项目规划可以整个遗漏必需阶段/任务 | 可激活空壳或删减受控交付物的计划 | 对定义中每个 non-optional stage 和 required/blocking task 做反向完整性校验 | +| AUTH-005 | 三权用户未校验有效账户、项目成员和候选角色 | 可把无权或已停用用户写入审批链 | 激活前逐人校验 membership/status/eligibility/职责分离;激活后人员失效触发阻断事件 | +| AUTH-006 | 项目列表 SQL 把 `creator_id=1`/NULL 项目视为公开,旧切换逻辑有固定用户/项目 ID 豁免 | 演示兼容规则泄漏到真实授权 | 删除 ID 特判;显式 `project_visibility`/legacy migration,不以 creator ID 表达公开 | +| AUTH-007 | 项目创建接口未检查 `project:create` | 任意活动账户可立项 | handler/service 双层校验 `projects.create`,前端按钮仅作体验控制 | +| AUTH-008 | 项目成员可读取/取消其他成员任务运行,未区分动作 | 破坏他人运行和审计归属 | `task_runs.view_own/view_project/cancel_own/cancel_any/resume` 分权;取消他人任务须负责人或创建人 | +| AUTH-009 | 本地 token、仅本地 logout、失效 `/me` 后仍保留登录态 | XSS/撤销后前端假登录 | 改安全 Cookie;服务端 logout;`/me` 401 立即清空主体和缓存;权限加载失败敏感动作 fail closed | +| WF-001 | DBCatalog 将完整版本压扁为第一阶段第一任务 | 发布多阶段定义也不会按定义运行 | 移除 flatten 执行路径,新增实例编排器逐阶段/任务读取冻结快照 | +| WF-002 | DocGov 三权和阶段来自请求/固定默认值,不来自实例 | 配置的责任人和活动阶段不生效 | 由 `doc_task_instance_id` 解析 author/reviewer/approver/stage;禁止生产请求传入身份覆盖 | + +### 4.2 P1:首次正式发布必须完成 + +| ID | 问题 | 整改 | +|---|---|---| +| IAM-101 | `ListCapabilities` 忽略 `scope_type/scope_id`、角色状态和 `valid_from` | 引入带资源上下文的 PDP;系统能力只取 `scope_type=system, scope_id=0`,校验角色/账户/有效期 | +| IAM-102 | `users.role_id` 与 `user_role_bindings` 双权威,更新角色会删除其他绑定 | `user_role_bindings` 为唯一权威;`users.role_id` 只作迁移兼容后移除;支持多角色 | +| IAM-103 | `assigned_by` 被写成目标用户本身,角色+状态更新非原子且忽略角色更新错误 | 从 principal 传 actor;单事务变更;写不可变审计事件;失败整体回滚 | +| IAM-104 | `role-admin` 字符串直接赋予管理员身份,`perm:manage` 又作为所有权限通配 | 显式 `system.superadmin`;普通 `permissions.manage` 不再通配业务数据;至少两名受控管理员防死锁 | +| IAM-105 | 前端权限键只有最早 7 项,后端迁移已有更多权限 | 后端暴露 permission catalog;前端由 API 渲染,不维护硬编码联合类型作为事实源 | +| SET-101 | 用户资料保存只改 React state;偏好仅组件本地;活动会话 UI 缺失 | 增加 `/me/profile`、`/me/preferences`、`/me/notification-preferences` 和会话管理 UI | +| SET-102 | 管理中心工作流页只能列出/校验/发布,不能完整建模 | 增加创建、复制草稿、阶段排序、任务/职责/参与策略、模板选择、diff、发布、退役 | +| SET-103 | Office 模板进入 `pending` 后缺少扫描完成链 | 隔离上传 + scanner worker + 状态回调/轮询;只有 `ready` 可发布 | +| SET-104 | UI 宣称符合 21 CFR Part 11,但未实现电子签名控制 | 删除不实文案;如需合规,另立 e-sign scope(再认证、签署含义、签名绑定、审计、防篡改) | +| WF-101 | 所有阶段实例 `position=0` | 从定义快照持久化真实 position;增加唯一性与连续性校验 | +| WF-102 | 激活 token 明文保存且消费、激活非同一事务 | 仅存 token hash;`SELECT FOR UPDATE` 后在同一事务验证、消费和激活 | +| WF-103 | 推进会把全部启用任务强制标成 approved | 推进只允许已满足门禁的状态,不修正业务状态;不一致即事务失败 | +| WF-104 | stale 未纳入阶段门禁 | required upstream/downstream lineage 有 stale 时禁止推进,重新批准后清除 stale 并留痕 | +| WF-105 | 完成后缺少“选择下一工作流”与受控终止/变更 API | 实现 `completed/termination_requested/terminated` 全状态机及下一实例创建 | +| WF-106 | bootstrap 发现已有 published 版本就退出 | 种子升级采用显式版本 manifest;内容 hash 变化创建 v2 draft,经校验批准后发布 | + +### 4.3 P2:可在首发后持续增强 + +- 管理员 break-glass:理由、近期认证、限时、醒目标识、禁止代签、完整审计。 +- MFA/WebAuthn、企业 SSO/SCIM、组织层级和角色层级。 +- 策略模拟器、权限差异比较、定期访问复核和孤儿权限报告。 +- 工作流可视化 DAG;首版仍建议坚持线性阶段,减少编排复杂度。 +- 审计日志防篡改、WORM/外部归档和监管报表。 + +## 5. 目标授权架构 + +### 5.1 五层判定模型 + +每个敏感动作统一调用一个服务端策略决策点(PDP),判定表达式为: + +```text +ALLOW = authenticated(principal) + AND account.active + AND session.active + AND permission_allows(action, resource_type, scope) + AND project_relationship_allows(principal, project) + AND workflow_snapshot_policy_allows(principal, active_instance, stage) + AND document_policy_allows(principal, doc_task, document) + AND state_machine_allows(action, current_state) + AND separation_of_duty_allows(principal, action) + +unknown / missing policy / dependency error => DENY +``` + +| 层 | 回答的问题 | 数据权威 | +|---|---|---| +| 认证 | 这个请求是谁,账号/会话是否有效 | `users`、`auth_sessions` | +| 系统 RBAC | 是否具备创建、管理、发布、审计等平台动作 | role/permission/binding 表 | +| 项目 ReBAC | 是否属于该项目,承担哪些项目业务角色 | `project_members` + `project_role_bindings` | +| 工作流/阶段 ABAC | 当前实例、阶段、参与策略是否允许 | 冻结 `config_snapshot` + 实例状态 | +| 文档职责/状态 | 是否为该文档的编制/审核/批准人,当前状态能否执行动作 | `document_duty_assignments` + DocGov | + +### 5.2 角色分层 + +系统角色不再表达具体项目里的业务职责,项目角色也不再等同于某一份文档的三权。 + +| 范围 | 建议内置角色 | 用途 | +|---|---|---| +| 系统 | 超级管理员、安全管理员、用户管理员、工作流管理员、模板管理员、安全审计员、普通用户 | 平台配置和治理;业务项目数据不因管理员身份默认全开 | +| 项目 | 项目创建人、项目经理、商务 BD、产品经理、系统/研发工程师、测试、法规、QA、观察者 | 阶段参与、项目资料范围和业务动作候选资格 | +| 文档 | author、reviewer、approver | 绑定单个文档任务实例;必须是具体用户,默认互斥 | + +一个用户可以在同一项目绑定多个业务角色;角色绑定和文档职责均必须有 `valid_from/valid_until/status/assigned_by/reason`。静态职责分离在配置时阻止冲突,动态职责分离在动作时阻止同一主体对同一版本自审自批。 + +### 5.3 权限目录 + +权限键统一采用 `resource.action`,旧键通过一版兼容映射迁移,之后删除。建议首发目录: + +| 域 | 权限 | +|---|---| +| 系统 | `system.settings.view/manage`、`system.security.manage`、`system.audit.view/export` | +| 用户 | `users.view/create/edit/disable/reset_password`、`users.sessions.revoke` | +| 角色 | `roles.view/create/edit/retire/assign/review` | +| 工作流 | `workflow_defs.view/create/edit_draft/publish/retire`、`workflow_defs.impact_view` | +| 模板 | `templates.view/upload/download/publish/retire` | +| 项目 | `projects.create/view/edit/archive`、`projects.members.view/manage` | +| 项目流程 | `projects.workflow.view/configure/activate/terminate`、`projects.stage.advance` | +| 参与 | `workflow.chat.view/send`、`workflow.task.run`、`workflow.progress.view` | +| 任务运行 | `task_runs.view_own/view_project/cancel_own/cancel_any/resume` | +| 文档 | `documents.view/edit/submit/review/approve/export`、`documents.visibility.manage` | +| 通知 | `notifications.view/update`、`notification_policies.manage` | + +### 5.4 服务端执行点 + +不要在每个 handler 手写角色判断。建立: + +```go +Decision Authorize(ctx, Principal, Action, ResourceRef, Attributes) + +Decision { + Allowed bool + ReasonCode string + PolicyVersion string + Obligations []string // redact, require_recent_auth, audit_reason, etc. +} +``` + +- 路由中间件负责身份和粗粒度权限。 +- service 层在事务内重新验证对象关系和当前状态,防止 TOCTOU。 +- repository 查询默认带可见范围,不先查全量再在浏览器过滤。 +- SSE、附件下载、搜索、导出、RAG、Agent tools 使用同一个 `ResourceRef`/Decision。 +- 禁止把 `user_id`、`role`、`stage`、`work_dir` 或 duty 作为客户端可信授权输入。 +- 403/404 策略统一;审计记录 actor、resource、action、decision、reason、request_id、policy_version。 + +### 5.5 核心动作矩阵 + +| 动作 | 普通成员 | 项目业务角色 | 项目创建人 | 系统管理角色 | 文档三权 | +|---|---|---|---|---|---| +| 创建项目 | 需 `projects.create` | 同左 | 同左 | 不默认拥有,按绑定 | 无关 | +| 查看项目 | active membership | active membership | 是 | 仅 break-glass 或显式审计权限 | 无关 | +| 管理成员 | 否 | 项目经理可选 | 是 | break-glass | 无关 | +| 配置待激活流程 | 否 | 可提议 | 是 | 工作流管理员不能代替业务确认 | 无关 | +| 激活/终止流程 | 否 | 可申请 | 是 + 再确认 | 只可 break-glass | 无关 | +| 运行任务 | 否 | 阶段策略允许 + 对应 task permission | 同策略,不绕过 | 不默认 | author 或显式执行人 | +| 查看文档 | 文档策略允许 | 阶段/角色策略允许 | 仍受私密/合规策略 | 不默认 | 当前职责人允许 | +| 提交审核 | 否 | 否 | 不默认 | 不默认 | author | +| 审核 | 否 | 否 | 不默认 | 不默认 | reviewer 且非 author | +| 批准 | 否 | 否 | 不默认 | 不默认 | approver 且非 author/reviewer | +| 推进阶段 | 否 | 只能申请/接收提醒 | 明确确认 | 不替代业务确认 | 无关 | +| 发布工作流版本 | 否 | 否 | 无关 | 工作流发布权限 + 双人复核可选 | 无关 | + +## 6. 设置体系完整方案 + +### 6.1 用户设置 `/settings` + +| 页面 | 服务端能力 | 验收 | +|---|---|---| +| 个人资料 | `GET/PATCH /api/v1/me/profile`;姓名、邮箱、语言、时区;部门/工号受控只读 | 刷新、换设备仍一致;审计敏感字段变更 | +| 安全 | 修改密码、MFA 状态、活动会话、撤销其他会话、最近登录 | 修改密码撤销其他会话;撤销后下一请求 401 | +| 通知 | 站内/邮件渠道、事件类型、免打扰、升级策略 | Outbox 读取服务端偏好;关键审批提醒不可被完全关闭 | +| 偏好 | 主题、默认导出、语言、默认项目页、Agent 显示偏好 | 落库并有 schema 版本;不再只存在组件 state | +| 我的权限 | 服务端返回生效角色、作用域、有效期和权限来源 | 不由前端本地 role 推导;支持“为何有/无此权限” | + +安全会话改造:登录响应通过 `Set-Cookie: __Host-agent_session=...; Secure; HttpOnly; SameSite=Lax|Strict; Path=/` 建立会话;状态变更使用 CSRF token/origin 验证;logout 调服务端撤销当前会话。测试环境可通过独立 fixture 获取 Cookie,不在产品包保留任意身份切换。 + +### 6.2 项目设置 `/projects/{id}/settings` + +| 页面 | planning | active/awaiting | completed/terminated | +|---|---|---|---| +| 基本信息 | 创建人编辑 | 受控字段变更单 | 只读/归档 | +| 成员与业务角色 | 增删改,校验职责候选 | 变更需影响分析;不能留下无人职责 | 历史只读,可为下个实例配置 | +| 工作流选择 | 选已发布版本 | 冻结只读 | 可创建下一个 planning 实例 | +| 阶段/任务 | 配置 optional/default,required 不可关闭 | 冻结;仅受控变更 | 快照只读 | +| 模板版本 | 从 ready 版本选择 | 冻结 | 快照只读 | +| 三权职责 | 必填、互斥、成员/资格校验 | 变更单 + 未决审批处理 | 历史只读 | +| 参与/可见策略 | 从版本默认值继承,可按允许范围收紧 | 冻结;紧急收紧可走安全变更 | 历史只读 | +| 预览与激活 | diff + 门禁 + 二次确认 | 不显示“重新激活” | 显示下一流程入口 | +| 推进/终止 | 无 | 门禁满足后创建人确认;终止需原因/影响 | 展示完整审计 | + +项目创建人不是系统超级管理员。项目设置的“独占确认权”和系统权限的“是否允许操作接口”同时成立:例如可由项目经理编辑草案,但最终激活和阶段推进仍由创建人确认。 + +### 6.3 系统管理中心 `/admin/*` + +| 页面 | 必做功能 | +|---|---| +| 概览 | 服务/DB/Runner/Outbox/worker 健康、失败任务、待审批、策略版本 | +| 用户 | 创建、审批、启停、资料、重置密码、撤销会话、多角色绑定、批量导入 | +| 角色权限 | 权限目录、多角色、作用域、有效期、职责分离、影响预览、角色退役 | +| 工作流设计器 | 创建/复制草稿、阶段排序、任务、执行器、模板、输入输出、门禁、职责候选、参与/可见策略、校验、diff、发布、退役 | +| 模板库 | 隔离上传、扫描、预览、变量 schema、版本、发布/退役、引用关系 | +| 项目类型与合规 | `product_domain` 与 `compliance_profile` 分离;可用流程、必需文档和签署强度 | +| 通知自动化 | 事件、渠道、模板、频率、升级和去重 | +| 审计 | 按 actor/action/resource/result/request_id 查询和导出;高风险变更对比 | +| 安全 | 注册、审批、密码、MFA、会话时限、CSRF/CORS、上传限制、保留期、break-glass | + +管理端不能继续依赖硬编码权限清单。`GET /api/v1/admin/permissions` 返回目录和风险等级;每个页面使用服务端 capabilities 控制入口,但最终仍由 API 决策。 + +## 7. 工作流定义 Schema v2 + +现有 schema 只有阶段、位置、人工门禁和文档任务基本字段,无法表达运行绑定、参与策略、职责资格、输入输出和确定性质量门禁。建议新增 `schema_version: 2`: + +```json +{ + "schema_version": 2, + "workflow_key": "product_manager", + "product_domains": ["medical_device", "non_medical_software"], + "required_upstream": [], + "participation_policy": {}, + "completion_policy": {}, + "stages": [{ + "key": "requirements_baseline", + "name": "系统需求基线", + "position": 10, + "optional": false, + "entry_gate": {}, + "exit_gate": {}, + "participation_policy": {}, + "doc_tasks": [{ + "key": "system_reqs", + "executor_key": "squad.system_reqs.v1", + "required": true, + "blocking": true, + "template": {"asset_key": "system_reqs", "version": 2}, + "input_contract": {}, + "output_contract": {}, + "quality_gate_key": "quality.system_reqs.v2", + "eligible_project_roles": {}, + "visibility_policy": {}, + "retry_policy": {}, + "timeout_seconds": 3600 + }] + }] +} +``` + +发布校验必须至少验证: + +- key 格式和全局唯一;stage position 唯一且严格递增;必需阶段/任务不可缺失; +- `executor_key` 已注册且声明支持相应输入/输出;不再把未知任务落入 generic runner; +- template version 存在且 `scan_status=ready`,变量 schema 与 input contract 一致; +- 三权候选角色存在,且不会造成项目无人可配;author/reviewer/approver 默认互斥; +- 上游工作流和文档依赖无环、能解析到已发布版本; +- 每个 required/blocking task 有确定性质量门禁; +- 可见/参与策略有明确默认值,未知即拒绝; +- 发布后 definition JSON/hash 和所有引用版本不可变;编辑从已发布版本复制新 draft。 + +## 8. 三个工作流的正式定义 + +### 8.1 兼容性决定 + +当前项目和代码广泛使用 `main` 代表商务主线。首轮迁移**保留稳定 key `main`**,显示名统一为“商务对接”,避免破坏存量项目、任务记录和文档 ID;停止在新代码里把它解释成“任意默认流程”。`product_manager` 和 `test_manager` 保持现有 key。若未来希望改为 `business_engagement`,新增显式 alias 表,不直接改历史 key。 + +三个正式版本均以 v2 draft 创建和发布;不得修改已有 published v1,也不得依赖当前 `UpsertPublishedSeed` 自动覆盖。已 active 的 v1 项目继续使用原快照;planning 项目由创建人查看 diff 后主动升级。 + +### 8.2 商务对接 `main` v2 + +当前真实执行器已有以下阶段和任务,本方案将其搬入版本定义,执行器只保留能力注册,不再保留另一套阶段事实源。[^13] + +| 顺序 | 阶段 | 文档任务 | 必需/阻断 | 建议三权候选 | 退出门禁 | +|---:|---|---|---|---|---| +| 10 | `V0.1_triage` 闪电摸底与盲区探测 | `intake` 客户需求采集/初始需求表 | 是/是 | author: BD/PM;reviewer: 项目经理;approver: 创建人或商务负责人 | intake approved;关键项无缺失;无运行任务 | +| 20 | `V0.5_feasibility` 方案成型与法规对齐 | `research` 竞品调研;`feasibility_reg` 法规可行性;`prelim_design` 初步设计 | 全部是/是 | 按文档分别指派 BD、法规、研发;审核/批准分离 | 三文档 approved;无 stale/open change;entry inputs 已基线化 | +| 30 | `V1.0_quoting` 工程拆解与商业锁定 | `cdo_quote` CDO 报价与核算 | 是/是 | author: 商务/项目经理;reviewer: 技术/财务;approver: 创建人/授权商务负责人 | 正文和核算 XLSX hash 一致、approved;报价关键字段完整 | + +参与策略默认值: + +- BD 可参与 V0.1/V0.5 的 `intake/research`,不因“是项目成员”自动获得 `prelim_design` 或报价批准权。 +- 法规只访问被授权的法规任务及其显式上游;研发只访问初步设计及显式上游。 +- 创建人可看项目进度并确认推进,但不会自动获得某份文档的审核/批准动作。 +- 阶段完成只产生 `ready_to_advance` 通知;创建人确认后原子进入下一阶段。 + +### 8.3 智能产品经理 `product_manager` v2 + +项目层保持一个受控阶段,阶段内的五个 Agent step 是 executor 内部步骤,不把内部 agent step 误建成五个需要人工推进的项目阶段。现有代码已经定义需求梳理、系统工程、法规、成文、终审五角色和五步执行链,可直接注册为 `squad.system_reqs.v1`。[^20] + +| 项 | 正式配置 | +|---|---| +| 阶段 | `requirements_baseline` 系统需求基线,position=10,required/blocking | +| 文档任务 | `system_reqs`,输出受控《系统需求规格书》 | +| 执行器 | `squad.system_reqs.v1` | +| 模板 | `system_reqs` ready version;发布时固定版本 | +| 必需输入 | 项目五要素;已批准/显式基线化的客户需求或上游商务文档;项目材料清单 | +| 结构化输出 | `output.md`、`artifacts/requirements.json`、manifest/hash、质量报告 | +| 三权候选 | author: PM/系统工程;reviewer: 技术负责人/法规;approver: 创建人授权的批准人/QA | +| 质量门禁 v2 | 每条需求唯一 ID;来源、理由、优先级、可验证准则、验证方法齐全;无未替换占位;JSON 与正文一致;重复/冲突/孤儿需求为 0 | +| 完成门禁 | executor 成功 + deterministic gate 通过 + 文档 approved + 无 stale/open change/running task | + +当前质量门禁只检查正文中是否出现 `REQ-` 和宽泛的“测试/分析/审查”等词,达不到“需求集合完整、唯一、可追溯、可测”的正式门禁。v2 必须解析 `requirements.json`,以结构化集合为准,并把校验报告与文档版本绑定。[^21] + +### 8.4 智能测试经理 `test_manager` v2 + +| 项 | 正式配置 | +|---|---| +| 阶段 | `system_verification_planning` 系统验证策划,position=10,required/blocking | +| 文档任务 | `test_manager`,输出受控《系统验证方案及计划》 | +| 执行器 | `squad.test_manager.v1` | +| 上游条件 | 同项目最近一次已批准且非 stale 的 `system_reqs`;记录上游 doc/version/hash | +| 模板 | `test_manager` ready version;发布时固定版本 | +| 结构化输出 | `output.md`、必需的 `artifacts/test_cases.json`、coverage report、manifest/hash | +| 三权候选 | author: 测试;reviewer: 系统/法规;approver: 测试负责人/QA,三者互斥 | +| 质量门禁 v2 | 上游需求全集解析成功;每条 requirement 至少一条有效 case;case ID 唯一;步骤、预期、判据、环境、方法完整;覆盖率 100%;无孤儿引用 | +| stale 策略 | 上游 SR hash 变化立即将下游标为 stale,禁止提交/批准/阶段完成,完成影响分析与重新基线后解除 | + +现有 executor 已要求上游 SR approved 并建立血缘,也定义了 100% 覆盖的 Agent 目标;但当前确定性门禁只检查 `TC-/TEST-`、`REQ-` 字符串,并且 `test_cases.json` 仅在文件存在时校验 JSON,文件不存在不会失败。v2 必须把结构化用例文件和覆盖率报告设为必需产物。[^21][^22] + +### 8.5 三个工作流的串联语义 + +系统当前设计是“一项目最多一个 live 工作流”,不是三条流程同时 active: + +```text +商务对接 completed/受控 terminated + -> 创建人选择并配置 product_manager v2 + -> 产品经理流程 completed,系统需求 approved + -> 创建人选择并配置 test_manager v2 + -> 测试经理流程完成 +``` + +系统可以在前一流程门禁满足时推荐下一个流程并通知创建人,但绝不自动激活。上游依赖解析按文档类型 + workflow instance + approved version 查询,不依赖固定文件路径或仅由字符串拼出的 doc ID。 + +## 9. 配置到运行时的唯一权威链 + +必须建立以下单向链,禁止旁路: + +```text +published workflow version + ready template versions + ↓ creator configures / preview / confirms +immutable project workflow snapshot + ↓ creates ordered stage/task instances + duty assignments +active stage + authorized doc task instance + ↓ resolves executor, template, inputs, actor obligations +task run + DocGov document version + lineage + audit + ↓ deterministic gate + three-duty approval + no stale/open work +ready_to_advance notification + ↓ creator explicit confirmation +next stage OR workflow completed +``` + +具体约束: + +1. `POST task-runs` 接收 `doc_task_instance_id` 和业务输入,不接收可信 `user_id/role/stage/work_dir/reviewer_id/approver_id`。 +2. 服务端锁定实例和 task instance,验证 active stage、actor、executor、模板和当前状态。 +3. WorkDir 由服务端按 project/instance/task_run 生成并做路径约束。 +4. DocGov 从 duty assignment 创建/校验文档责任人,stage 来自 stage instance。 +5. 任务结果写入与运行终态、文档版本、manifest、lineage、outbox 使用事务/可恢复 saga,避免“显示完成但产物未落稳”。 +6. 列表、详情、导出、附件、搜索、SSE、RAG 和 Agent tool read 都使用同一资源授权上下文。 + +## 10. 数据库迁移方案 + +迁移号以合并时最新 Goose 版本为准,下表暂按 `00021+` 表示。 + +| 迁移 | 主要变更 | 兼容/回填 | +|---|---|---| +| 00021 IAM hardening | project_members.user_id FK;role/binding 状态、reason;审计 actor;权限键 alias | 校验孤儿成员后加 FK;旧 `users.role_id` 双读一版、停止写入 | +| 00022 project roles | `project_roles`、`project_role_permissions`、`project_user_role_bindings` | owner/member/viewer 映射为 owner/contributor/observer;原表保留 membership 状态 | +| 00023 settings | user preferences、notification preferences、security policy versions | 把可识别的 local preferences 一次性导入需用户确认;其余使用服务端默认 | +| 00024 workflow schema v2 | executor registry/version、schema fields、policy JSON schema/version | 不修改 v1 JSON;validator 按 schema_version 路由 | +| 00025 instance integrity | stage position、task executor/template/output snapshot、duty eligibility、change requests | 修正现有 position;对 active 数据先报告后修复,不静默重排 | +| 00026 authorization audit | `authorization_audit_events` 或统一 audit_event 扩展、policy version、request ID | 明确保留期和敏感字段脱敏 | +| 00027 workflow v2 manifests | 创建三个 v2 draft/版本 manifest | 只在 hash 匹配预期时创建;不覆盖用户自定义版本 | + +数据库约束建议: + +- live instance 部分唯一索引继续保留;增加 planning 与 live 状态转换约束测试。 +- `project_stage_instances(workflow_instance_id, position)` unique,position > 0。 +- task instance 保存 `executor_key/template_version/output_doc_type/quality_gate_key` 的冻结副本。 +- duty assignment 引用 active project membership;跨表动态条件用事务 trigger 或 service + deferred validation 实现。 +- activation token 只存 hash;高风险一次性 token 使用短 TTL、actor/project/plan hash 绑定。 +- published definition/template 通过数据库权限或 trigger 阻止 UPDATE/DELETE,不只依赖 Go service。 + +## 11. API 调整 + +### 11.1 身份与设置 + +```text +POST /api/v1/auth/login -> Set-Cookie +POST /api/v1/auth/logout -> revoke current session +GET /api/v1/me +PATCH /api/v1/me/profile +GET/PUT /api/v1/me/preferences +GET/PUT /api/v1/me/notification-preferences +GET /api/v1/me/sessions +DELETE /api/v1/me/sessions/{id} +POST /api/v1/me/password/change +``` + +### 11.2 权限与管理 + +```text +GET /api/v1/admin/permissions +GET/POST/PATCH /api/v1/admin/roles... +GET/POST/DELETE /api/v1/admin/role-bindings... +POST /api/v1/admin/authorization/simulate +GET /api/v1/admin/audit-events +POST /api/v1/admin/users/{id}/reset-password +POST /api/v1/admin/users/{id}/sessions/revoke +``` + +每个 admin endpoint 绑定独立 permission,不再通过 `requireSystemAdmin` 全包。 + +### 11.3 工作流与项目实例 + +```text +POST /api/v1/admin/workflows/{id}/versions/{v}/copy-draft +PATCH /api/v1/admin/workflows/{id}/versions/{v} +POST /api/v1/admin/workflows/{id}/versions/{v}/validate +GET /api/v1/admin/workflows/{id}/versions/{v}/impact +POST /api/v1/admin/workflows/{id}/versions/{v}/publish +POST /api/v1/admin/workflows/{id}/versions/{v}/retire + +GET/PUT /api/v1/projects/{id}/workflow-plan +POST /api/v1/projects/{id}/workflow-plan/preview +POST /api/v1/projects/{id}/workflow-plan/activate +POST /api/v1/projects/{id}/workflow-instances/{iid}/termination-requests +POST /api/v1/projects/{id}/workflow-instances/{iid}/terminate +POST /api/v1/projects/{id}/workflow-instances/{iid}/change-requests +POST /api/v1/projects/{id}/workflow-instances/{iid}/stages/{stage}/advance +POST /api/v1/projects/{id}/doc-tasks/{taskInstanceId}/runs +``` + +所有资源路径先验证 path 中 project/instance/stage/task/document 的归属一致性,避免用合法项目权限访问另一个项目的子资源。 + +## 12. 前端实施方案 + +1. **认证壳**:不从 `localStorage` 恢复 principal/token;启动时只调用 `/me`。401 清空全部敏感状态并跳转登录。 +2. **权限 Provider**:从 `/me` 获取 capabilities + scoped grants;按钮、菜单和路由只做 UX 控制,错误统一展示服务端 reason code。 +3. **用户设置**:现有 modal 拆到真实 API;接入活动会话;资料/偏好保存失败不更新本地成功态。 +4. **系统管理中心**:从单一大 modal 演进为路由页面;权限矩阵来自 catalog;高风险变更提供 diff、影响人数和二次确认。 +5. **工作流设计器**:线性阶段排序、每阶段多任务、executor 只从注册表下拉、模板版本选择、职责候选、参与/可见策略、JSON 高级视图、发布校验报告。 +6. **项目设置**:明确 planning/live/history 三态;live 快照只读;门禁列表展示每个阻断项;下一工作流必须由创建人确认。 +7. **工作台**:根据服务端返回的 allowed actions 渲染,不在前端用姓名/本地角色重新计算业务授权。 +8. **删除演练入口**:`UserProjectRightsModal` 的测试角色切换、旧 `SettingsModal`、本地模拟分支和生产 bundle 中的测试身份文案移出产品代码。 + +## 13. 分阶段交付计划 + +估时为单个熟悉代码库的小组工作日,安全修复与工作流建模可并行开发,但发布必须按闸门顺序。 + +### Phase 0:基线冻结与权限清单(1–2 天) + +- 固定 commit、DB schema、环境和三个 workflow v1 hash。 +- 自动导出全部 `/api/v1` 路由,建立 resource/action/owner/negative test 矩阵。 +- 修正当前 E2E 报告状态矛盾;归档一次干净的 short test 和 build 结果。 +- 输出:`authorization-matrix-v2.md`、migration preflight SQL、测试数据拓扑。 + +门禁:每个非公开路由都有责任 owner、策略和至少一个未授权负例;未知路由默认为拒绝。 + +### Phase 1:P0 授权止血(3–5 天) + +- 修复 AUTH-001–009、WF-001/002 的越权入口。 +- 建立统一 `Authorize`/ResourceRef 和 server-side allowed actions。 +- 删除固定管理员/演示项目 ID 豁免;生产禁用 permissive fallback。 +- Cookie/CSRF/logout/401 前端失效闭环。 + +门禁:匿名、跨用户、跨项目、跨阶段、错 task、错 child resource、DB authz fault 全部负例通过;SSE/RAG/Agent tool 同样验证。 + +### Phase 2:IAM 与设置权威化(4–6 天) + +- 完成 IAM-101–105 和用户/项目/系统设置 API。 +- 角色多绑定、作用域/有效期、permission catalog、原子用户变更和审计。 +- 用户资料、偏好、通知、活动会话 UI;管理中心权限拆分。 + +门禁:重启/换浏览器数据一致;权限收回和账号停用下一请求生效;至少两名管理员防锁死演练通过。 + +### Phase 3:工作流 Schema v2 与运行时合流(5–8 天) + +- 实现 schema v2、executor/quality gate registry、完整 validator。 +- 移除 `flattenVersion` 作为执行路径;编排器只读实例快照。 +- 修复 position、token 原子性、DocGov duty/stage 绑定、stale 门禁。 +- 完成受控终止、变更单和下一个工作流生命周期。 + +门禁:修改全局 published/latest 不影响 active 实例;请求伪造 stage/task/duty 无效;故障恢复后实例位置和职责不漂移。 + +### Phase 4:发布三个 v2 工作流(4–6 天) + +- 按 §8 创建 `main`、`product_manager`、`test_manager` v2 draft。 +- 绑定 ready 模板和 executor;补齐结构化门禁。 +- 对 planning v1 项目生成可读 diff,由创建人选择是否升级;active v1 不迁移。 +- 完成三个流程的串联推荐,但保持人工选择/激活。 + +门禁:三个版本通过静态校验、dry-run、单流程 PG E2E 和串联 PG E2E。 + +### Phase 5:管理端/项目端完整体验(4–6 天) + +- 工作流设计器、模板库、项目规划、门禁详情、影响预览、下一流程、终止/变更 UI。 +- 删除旧本地/演示权威路径和不实合规文案。 +- 可访问性、错误恢复、并发 diff/reconfirm。 + +门禁:产品经理无需改代码即可复制 v2 为 v3 草案、换模板/职责候选、校验并由有权者发布;普通管理员无法越权发布。 + +### Phase 6:发布验证与灰度(5–8 天) + +- 全量 Go/前端、race、PG、迁移升级/回滚、重启、worker 故障注入。 +- 真实 PI Agent 三工作流 E2E;长任务超时/恢复/取消;跨用户负例。 +- 备份恢复、日志脱敏、监控告警、容量和 bundle 分包。 +- 灰度一个内部项目,再扩到新项目;旧项目只读兼容指标归零后删除 fallback。 + +门禁:§14 全部完成;P0/P1 为 0;可恢复演练和回滚演练均有报告。 + +总体建议:**21–35 个小组工作日**。如多人并行,最短日历时间约 4–5 周;不应通过跳过负例、迁移和真实 Runner 验证压缩关键路径。 + +## 14. 测试与验收矩阵 + +### 14.1 授权负例 + +| 场景 | 预期 | +|---|---| +| 未登录访问任意非公开 API/SSE/附件 | 401,不泄漏资源存在性 | +| A 项目成员读 B 项目、run history、lineage、搜索、导出 | 403/404,审计 deny | +| 当前阶段 BD 请求研发 task key | 403;不创建 task_run/workdir/doc | +| 非职责人提交/审核/批准 | 403;状态、版本、审计不变 | +| author 尝试审核或批准同一版本 | 409/403 SoD violation | +| authz DB/策略加载故障 | 敏感路径拒绝,健康状态 degraded,触发告警 | +| 权限撤销后复用旧页面/旧 SSE/旧 Cookie | 下一次鉴权失败;流关闭或重新鉴权 | +| 猜测 task/document/template/blob ID | 不能跨项目读取 | +| 客户端伪造 user/role/stage/workdir/duty | 服务端忽略或 400;不得改变判定 | + +### 14.2 工作流配置与冻结 + +- v2 required stage/task 被遗漏、禁用、重复或错序时校验失败。 +- executor 未注册、模板非 ready、变量 schema 不匹配时禁止发布。 +- published version UPDATE/DELETE 在 service 和 DB 层均失败。 +- 激活 token 重放、过期、跨 actor、跨 project、plan hash 不同均失败。 +- 并发保存/激活/推进只成功一个;另一方收到 diff/reconfirm,不自动重试。 +- 修改 v3 不影响使用 v2 的 active 项目;历史 hash 可复算。 +- 流程未完成/未终止时不能创建第二个 live;完成后可配置下一个 planning。 + +### 14.3 三工作流 E2E + +**商务对接:** 按 V0.1 → V0.5 三任务 → V1.0 运行;每阶段文档分别走三权;未全部 approved 不产生 ready;满足后产生一次去重提醒;只有创建人确认推进。 + +**智能产品经理:** 从批准的输入基线生成 `requirements.json` 和正文;随机抽取、重复 ID、缺验证准则、正文/JSON 不一致均被确定性门禁拒绝;批准后可作为下游输入。 + +**智能测试经理:** 没有 approved SR、SR stale、hash 不匹配时拒绝启动;生成必需 `test_cases.json`;需求全集覆盖率必须 100%;上游更新后下游立即 stale 且不能完成。 + +**串联:** `main` 完成后只推荐 PM;创建人配置并激活;PM 完成后只推荐 Test;每个实例版本、模板、职责、输入 hash、输出 hash 和审计链可追溯。 + +### 14.4 运行与灾难恢复 + +- 应用、worker、PostgreSQL、PI Runner 分别重启,验证会话/任务/实例/Outbox 恢复。 +- PI Runner 超时后 task 可恢复或明确失败,不重复生成审批版本。 +- Outbox 重投不重复发阶段提醒;消费者幂等。 +- 迁移从 v20 的生产快照升级;备份恢复后 hash/权限/实例一致。 +- `go test -race`、前端测试、生产 build、依赖/镜像扫描、长期 soak。 + +## 15. 发布完成定义(Definition of Done) + +只有同时满足以下条件,才能宣布“权限、设置和三个工作流已按当前设计做全”: + +- [ ] 生产产品不存在任意身份切换、本地认证 fallback 或客户端身份权威。 +- [ ] 每个 API/SSE/RAG/Agent tool/附件通路都有服务端授权和跨用户负例。 +- [ ] 授权失败或依赖异常默认拒绝;不存在固定用户 ID、项目 ID 特权。 +- [ ] 系统角色、项目业务角色、文档三权分离且可审计;三权职责分离生效。 +- [ ] 用户、项目、系统三类设置均由服务端持久化,刷新/重启/换端一致。 +- [ ] 工作流、模板、权限管理使用各自细粒度 permission,而不是全员超级管理员。 +- [ ] 已发布工作流/模板不可变;项目激活冻结版本、模板、阶段、任务、策略和职责。 +- [ ] 同一项目最多一个 live;完成/受控终止后由创建人选择下一个。 +- [ ] 门禁满足只通知,不自动推进;创建人确认与推进在事务中完成。 +- [ ] `main` v2 完整执行 V0.1/V0.5/V1.0,不再由硬编码目录另行决定。 +- [ ] `product_manager` v2 结构化需求门禁通过;`test_manager` v2 实现 100% 需求覆盖和 stale 阻断。 +- [ ] DocGov 的 stage 和三权全部来自冻结实例,客户端不能覆盖。 +- [ ] PG、真实 PI Agent、重启恢复、故障注入、跨项目/跨阶段负例均有可复现报告。 +- [ ] P0/P1 缺陷归零,灰度/回滚/监控/告警和审计查询可用。 + +## 16. 风险与控制 + +| 风险 | 概率/影响 | 控制 | +|---|---|---| +| v1/v2 双执行权威并存 | 高/高 | feature flag 只选一个 orchestrator;记录每次 run 的 schema/executor/version;不静默 fallback | +| 存量项目职责/成员数据不完整 | 高/中 | preflight 报告;active 不强迁;planning 由创建人补全并确认 | +| 权限收紧影响现有演示项目 | 高/中 | 显式 legacy visibility 数据迁移;不保留 ID 豁免;灰度观察 deny 指标 | +| Cookie 改造引入 CSRF | 中/高 | SameSite + CSRF token + Origin/Referer 校验;状态变更不用 GET | +| 长 Agent 运行与 HTTP 超时 | 高/高 | 异步 task + lease/heartbeat + resumable worker;客户端订阅状态,不持有超长请求 | +| 结构化质量门禁误报 | 中/中 | schema version、明确错误定位、golden corpus、允许修复重跑但不可人工静默跳过 | +| 角色配置锁死管理员 | 中/高 | 双管理员、关键权限不可清空、变更影响预览、break-glass 离线流程 | +| 模板恶意文件/解析炸弹 | 中/高 | 隔离、扩展/MIME/签名、大小/解压限制、扫描、Web root 外 CAS;仅 ready 可引用 | + +## 17. 建议的首个迭代任务包 + +第一周不要先画完整工作流设计器,先完成以下可独立验收的任务: + +1. `AUTH-001`:给 run history/lineage 及所有项目子资源补统一对象授权和双用户 PG 负例。 +2. `AUTH-002`:阶段访问错误全部 fail closed,加入数据库故障注入测试。 +3. `AUTH-003`:workflow run/task run 改从 task instance 推导 task/stage/executor,客户端覆盖返回 400。 +4. `AUTH-004/005`:补必需项反向校验和 duty member/status/role eligibility 校验。 +5. `AUTH-006/007`:删除 ID 特判,项目列表改用 `ListVisibleProjectIDs`/membership join,创建项目校验 permission。 +6. `AUTH-009`:服务端 logout + `/me` 失效清理;设计 Cookie/CSRF 兼容迁移。 +7. `WF-001/002`:提交 schema v2 ADR 和 runtime authority contract,阻止新增旁路。 + +首周验收物应是:代码、迁移 preflight、授权矩阵、负例测试、更新后的安全报告。只有该闸门通过,才开始发布三个 v2 draft。 + +## 18. 交付物清单 + +- 本实施方案。 +- API 授权矩阵 v2(路由、动作、资源、策略、负例、owner)。 +- Schema v2 JSON Schema + executor/quality gate registry ADR。 +- Goose 00021+ 迁移和 preflight/rollback SQL。 +- 三个工作流 v2 manifest、模板版本清单和 golden fixtures。 +- 用户/项目/系统设置产品规格与 API contract。 +- 授权负例、PG、真实 PI Agent、迁移、恢复、soak 测试报告。 +- 灰度清单、监控面板、告警规则、回滚 runbook。 + +## 资料来源 + +[^1]: 当前治理原则与目标模型见 [2026-09-11-production-ready-workflow-governance-plan.md](./2026-09-11-production-ready-workflow-governance-plan.md)。 +[^2]: IAM、工作流定义、项目实例、通知和任务持久化分别见 [00015 IAM](../deploy/postgres/goose/00015_2026-09-12-iam-core.sql)、[00016 workflow definitions](../deploy/postgres/goose/00016_2026-09-12-workflow-definitions-and-templates.sql)、[00017 project instances](../deploy/postgres/goose/00017_2026-09-12-project-workflow-instances.sql)、[00018 outbox](../deploy/postgres/goose/00018_2026-09-12-outbox-notifications-stage-advance.sql)、[00019 task runs](../deploy/postgres/goose/00019_2026-09-12-task-runs-events.sql) 和 [00020 worker lease](../deploy/postgres/goose/00020_2026-09-12-task-run-worker-lease.sql)。 +[^3]: 本轮验证命令与结果记录在本方案 §2.2;当前历史测试状态见 [2026-09-12-pg-ci-soak-e2e-test-report.md](./test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md)。 +[^4]: 系统能力查询没有限定 binding scope/role status/valid_from,且 role-admin 字符串直接判 admin,见 [internal/authz/service.go](../internal/authz/service.go#L26-L99)。 +[^5]: 创建项目只验证身份,见 [project_handlers.go](../internal/server/project_handlers.go#L16-L46);运行历史和血缘查询未做项目访问判断,见同文件 [run history/lineage](../internal/server/project_handlers.go#L221-L274)。 +[^6]: 工作流管理统一走 `requireSystemAdmin`,没有使用已经入库的 `workflow_defs.*`,见 [admin_workflow_handlers.go](../internal/server/admin_workflow_handlers.go#L13-L23);权限种子见 [00016](../deploy/postgres/goose/00016_2026-09-12-workflow-definitions-and-templates.sql#L67-L82)。 +[^7]: `ResolveStageAccess` 在 live 查询错误时返回 unrestricted,且一个阶段任一职责会放开该阶段全部任务,见 [stage_access.go](../internal/projectinstance/stage_access.go#L15-L68);服务器过滤辅助函数也在错误时放行,见 [stage_access_helpers.go](../internal/server/stage_access_helpers.go#L23-L47)。 +[^8]: 任务门禁在 actor 能访问 active stage 时放过不匹配 task,见 [bizdev_stage_gate.go](../internal/server/bizdev_stage_gate.go#L40-L54);workflow run 接收客户端 task override,见 [project_handlers.go](../internal/server/project_handlers.go#L205-L218)。 +[^9]: token/session 存 localStorage 且 logout 只删除本地键,见 [web/src/api/auth.ts](../web/src/api/auth.ts#L306-L330);恢复 session 后 `/me` 失败被吞掉,见 [web/src/App.tsx](../web/src/App.tsx#L690-L703)。 +[^10]: 用户设置把偏好描述为浏览器本地并从前端角色推导权限,见 [UserSettingsModal.tsx](../web/src/components/user/UserSettingsModal.tsx#L68-L77) 与 [同文件](../web/src/components/user/UserSettingsModal.tsx#L673-L779);后端已有活动会话 API,见 [auth_handlers.go](../internal/server/auth_handlers.go#L162-L204)。 +[^11]: 三个单阶段种子见 [workflowdef/bootstrap.go](../internal/workflowdef/bootstrap.go#L63-L74);seed 遇到已有发布版本直接退出,见 [workflowdef/store.go](../internal/workflowdef/store.go#L364-L372)。 +[^12]: DBCatalog 只取首阶段/首任务,见 [workflowdef/catalog.go](../internal/workflowdef/catalog.go#L50-L82);现有 schema 能表达的字段见 [workflowdef/types.go](../internal/workflowdef/types.go#L15-L43)。 +[^13]: 商务流程真实阶段和任务仍由 [internal/bizdev/service.go](../internal/bizdev/service.go#L202-L223) 硬编码。 +[^14]: 规划 validator 只遍历客户端提交项,没有反向验证必需定义项是否缺失,见 [projectinstance/validator.go](../internal/projectinstance/validator.go#L37-L105);三权只校验非空和互斥,见 [同文件](../internal/projectinstance/validator.go#L108-L144)。 +[^15]: 阶段实例 position 固定写 0,见 [projectinstance/store.go](../internal/projectinstance/store.go#L244-L249);执行器默认 reviewer=2、approver=3 并固定阶段,见 [bizdev/service.go](../internal/bizdev/service.go#L383-L418)。 +[^16]: 当前报告的真实 PI Agent E2E 记录 `/v1/run` 超时,同时结论仍称“进行中”,见 [2026-09-12-pg-ci-soak-e2e-test-report.md](./test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md)。 +[^17]: [OWASP Authorization Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html)。 +[^18]: [NIST Role Based Access Control](https://csrc.nist.gov/Projects/Role-Based-Access-Control) 与 [NIST SP 800-162 ABAC](https://csrc.nist.gov/pubs/sp/800/162/upd2/final)。 +[^19]: [OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)。 +[^20]: 产品经理和测试经理的 Agent 角色及职责见 [squad/workspace.go](../internal/squad/workspace.go#L648-L743),内部步骤见 [同文件](../internal/squad/workspace.go#L940-L1073)。 +[^21]: 当前产品/测试质量门禁见 [squad/quality_gate.go](../internal/squad/quality_gate.go#L108-L146)。 +[^22]: 测试经理对 approved SR 的前置校验和材料注入见 [bizdev/service.go](../internal/bizdev/service.go#L365-L381),stale 传播见 [projectworkflow/service.go](../internal/projectworkflow/service.go#L237-L265)。 diff --git a/.doc/adr/2026-09-12-workflow-runtime-authority.md b/.doc/adr/2026-09-12-workflow-runtime-authority.md new file mode 100644 index 00000000..7742f0d0 --- /dev/null +++ b/.doc/adr/2026-09-12-workflow-runtime-authority.md @@ -0,0 +1,21 @@ +# ADR:工作流运行时唯一权威链 + +## 状态 + +已接受(Phase 1 入口封堵;Phase 3 完整编排器) + +## 背景 + +配置(published version + 模板)与运行时(BizDev 硬编码阶段、flatten catalog、客户端 task_key/三权覆盖)存在双权威,导致已发布多阶段定义无法按设计执行。 + +## 决策 + +1. **禁止新增 flatten 旁路**;`DBCatalog.flattenVersion` 仅用于 legacy 列表,不得作为 task run 执行依据。 +2. **Task run 输入**:`doc_task_instance_id` + 业务 payload;拒绝客户端 `task_key`/三权/stage/work_dir 作为授权依据。 +3. **DocGov**:stage 与 author/reviewer/approver 来自冻结实例 duty assignment。 +4. **失败策略**:授权/实例解析错误 fail closed(非 `AllowInMemory` 环境)。 + +## 后果 + +- Phase 3 必须交付 instance orchestrator 并移除 BizDev `SupportedStages` 作为事实源。 +- Phase 4 通过 v2 manifest 发布,不覆盖 v1 published hash。 diff --git a/.doc/authorization-baseline-2026-09-12.md b/.doc/authorization-baseline-2026-09-12.md new file mode 100644 index 00000000..0ed74bb3 --- /dev/null +++ b/.doc/authorization-baseline-2026-09-12.md @@ -0,0 +1,35 @@ +# 授权实施基线快照(Phase 0) + +- **分支**:`dev` +- **Commit SHA**:`bb3f3a1b93eaf4bbd4a7c26a70714a3320d455d3` +- **Goose schema**:`00020`(`00020_2026-09-12-task-run-worker-lease.sql`) +- **规格文档**:`.doc/2026-09-12-permissions-settings-three-workflows-implementation-plan.md` + +## 环境矩阵 + +| 环境 | 分支 | Compose | HTTP | DB | 数据路径 | +|---|---|---|---|---|---| +| 开发 | dev | agent-dev | 8080 | 5433 | data/dev | +| 测试 | test | agent-test | 8081 | 5434 | data/test | +| 生产 | main | agent-main | 8000 | 5435 | data/main | +| 共享 KB | — | agent-infra | — | 5432 | data/shared/kb | + +## 工作流 v1 种子(published) + +| workflow_key | 说明 | 备注 | +|---|---|---| +| `main` | 商务对接 v1 单阶段包装 | 真实阶段仍在 bizdev 硬编码 | +| `product_manager` | 产品经理 v1 单阶段 | 未驱动 squad 五步 | +| `test_manager` | 测试经理 v1 单阶段 | 未驱动结构化 coverage | + +v2 将以新 version 发布,不覆盖 v1 hash。 + +## 验证命令(基线通过) + +```bash +go test -short -timeout 8m \ + ./internal/auth/... ./internal/authz/... ./internal/workflowdef/... \ + ./internal/projectinstance/... ./internal/projectworkflow/... ./internal/server/... + +cd web && npm test && npm run build +``` diff --git a/.doc/authorization-matrix-v2.md b/.doc/authorization-matrix-v2.md new file mode 100644 index 00000000..8c44adc7 --- /dev/null +++ b/.doc/authorization-matrix-v2.md @@ -0,0 +1,23 @@ +# API 授权矩阵 v2(Phase 0 基线) + +> 基线 commit:`bb3f3a1b93eaf4bbd4a7c26a70714a3320d455d3` +> 策略:默认拒绝;未知路由拒绝;跨项目 403/404 统一。 + +| 路由 | Resource | Action | Owner | 负例(未授权) | +|---|---|---|---|---| +| POST /api/v1/auth/login | session | create | auth | 匿名可访问(公开) | +| GET /api/v1/auth/me | user | read_self | auth | 401 无 token | +| GET /api/v1/projects | project | list | projectworkflow | 401 匿名 | +| POST /api/v1/projects | project | create | projectworkflow | 401;403 无 project:create | +| GET /api/v1/projects/{id} | project | read | authz | 403 非成员 | +| GET /api/v1/projects/{id}/lineage | lineage | read | projectworkflow | 403 跨项目 | +| GET /api/v1/projects/{id}/workflows/{key}/runs | workflow_run | list | projectworkflow | 403 跨项目 | +| POST /api/v1/projects/{id}/workflows/{key}/run | task | run | bizdev+stage | 403 错 task/stage | +| GET /api/v1/tasks/runs/{id} | task_run | view | taskrun | 403 非 owner/非 owner 成员 | +| POST /api/v1/tasks/runs/{id}/cancel | task_run | cancel | taskrun | 403 取消他人 | +| POST /api/v1/chat/stream | chat | send | chat+stage | 403 无阶段权限 | +| GET /api/v1/docgov/* | document | * | docgov+duty | 403 非职责人 | +| GET /api/v1/admin/* | admin | * | authz RBAC | 403 非 admin/细粒度 perm | +| GET /health, /api/v1/health | system | health | server | 公开 | + +完整路由枚举见 `internal/server/server.go` Routes();Phase 1 起每个项目子资源必须经 `requireProjectAccess`。 diff --git a/.doc/test-fixtures/authz-test-topology.md b/.doc/test-fixtures/authz-test-topology.md new file mode 100644 index 00000000..b5021555 --- /dev/null +++ b/.doc/test-fixtures/authz-test-topology.md @@ -0,0 +1,37 @@ +# 授权负例测试数据拓扑(Phase 0) + +## Fixture A:双用户跨项目 + +| 实体 | 值 | +|---|---| +| User A | `zhanggong`(项目 owner) | +| User B | `lisi` 或独立测试用户(非成员) | +| Project A | A 创建,`creator_user_id = A` | +| Project B | B 不可见项目 | + +**负例**:B 调用 `GET /api/v1/projects/{A}/lineage`、`GET .../workflows/main/runs` → 403/404。 + +## Fixture B:同项目阶段职责 + +| 实体 | 值 | +|---|---| +| Live instance | `main` v2(Phase 4 后)或 v1 + duty | +| User BD | 仅 `V0.1_triage/intake` 三权 author | +| User RD | 仅 `prelim_design` 职责 | + +**负例**:BD 请求 `prelim_design` task_key → 403;非职责人 submit/review → 403。 + +## Fixture C:双管理员防锁死 + +| 实体 | 值 | +|---|---| +| Admin 1 | `role-admin` + `perm:manage` | +| Admin 2 | 同等绑定 | + +**演练**:Admin 1 停用 Admin 2 前 Admin 2 仍可操作;至少两名 active admin 存在。 + +## PG 测试入口 + +- `internal/server/project_members_authz_test.go` +- `internal/server/phase5_acceptance_pg_test.go` +- Phase 1 新增:`TestAuth001_CrossProjectLineageAndRunsDenied` diff --git a/.doc/test-reports/2026-09-12-permissions-workflows-six-phase-test-report.md b/.doc/test-reports/2026-09-12-permissions-workflows-six-phase-test-report.md new file mode 100644 index 00000000..f496f03a --- /dev/null +++ b/.doc/test-reports/2026-09-12-permissions-workflows-six-phase-test-report.md @@ -0,0 +1,63 @@ +# 权限、设置与三类工作流六阶段实施测试报告 + +## 一、 测试背景 (Background) + +按六阶段总执行计划完成 Phase 0–6 首版落地:授权止血、IAM/设置 API、Schema v2 骨架、三工作流 manifest、前端 logout/401 闭环。 + +## 二、 测试目标 (Test Objectives) + +- Phase 0 交付物:基线、授权矩阵、preflight SQL +- Phase 1 闸门 A:跨项目 lineage/runs 授权、fail closed、project:create、task run 分权 +- Phase 2–4:迁移 00021–23、设置 API、manifest/validator v2 +- 全量 Go short 测试与前端测试通过 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- **OS**:Linux,Go 1.27.1 +- **基线 commit**:实施分支 dev(实施后) +- **PG**:goose 00021–00023(本地有 PG 时迁移验证) +- **Agent**:stub(单元/集成);PI Agent E2E 留 Phase 6 真实重跑 + +## 四、 预期结果 (Expected Results) + +- `go test -short ./internal/...` 全绿 +- `cd web && npm test && npm run build` 通过 +- 匿名访问 lineage/runs → 401;跨项目 → 403 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### T1:Go 内部包 short 回归 + +- **Input**:`go test -short -count=1 -timeout 8m ./internal/...` +- **Actual Output**:全部 **ok**(含 server、authz、projectinstance、workflowdef) +- **Verdict**:**PASS** + +### T2:闸门 A 负例 + +- **Input**:`TestGateA_LineageAndRunsRequireProjectAccess`、`TestGateA_CreateProjectRequiresPermission` +- **Actual Output**:PASS +- **Verdict**:**PASS** + +### T3:Schema v2 manifest 加载 + +- **Input**:`go test ./internal/workflowdef/...` +- **Actual Output**:ok(embed manifests 编译通过) +- **Verdict**:**PASS** + +### T4:Pi-Agent 三工作流 E2E + +- **Input**:真实 PI Agent 全链路(计划 Phase 6 门禁) +- **Actual Output**:沿用 2026-09-12 报告 FAIL(/v1/run 超时),本轮未重跑 +- **Verdict**:**待 Phase 6 重测** + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +``` +go test -short -count=1 ./internal/... → ALL PASS +``` + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +1. 闸门 A 核心 P0 已关闭并具回归测试;Cookie/CSRF 全量迁移留后续迭代。 +2. Schema v2 manifest 与 orchestrator 骨架就绪;BizDev 硬编码阶段完全移除需在已激活 v2 实例上继续 E2E。 +3. Phase 6 必须:真实 PG 双用户负例、PI Agent 三工作流 E2E、迁移 00021+ 升级演练、灰度 runbook。 diff --git a/.doc/test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md b/.doc/test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md index cb81522a..b71556b2 100644 --- a/.doc/test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md +++ b/.doc/test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md @@ -10,7 +10,7 @@ QA-001 落地后,`go test -short ./...` 仍存在 PG 集成失败(`zhanggong 2. `go test -short ./...` 全绿(CI 门禁前提) 3. CI 增加 `pg_restore` 全量恢复演练 job 4. readiness soak 短循环 PASS -5. pi-agent 真实全链路 E2E(进行中) +5. pi-agent 真实全链路 E2E(独立重跑,带 commit SHA) ## 三、 测试方案与环境 (Test Plan & Environment) @@ -18,13 +18,14 @@ QA-001 落地后,`go test -short ./...` 仍存在 PG 集成失败(`zhanggong - **PG**:`shuimu-context-pg-dev` @ 127.0.0.1:5433,goose v20 - **Pi-Agent**:`http://127.0.0.1:18865/health` OK - **App**:本机 `:8080` 旧进程(无 `/api/v1/ready`,health 可用) +- **基线 commit**:`bb3f3a1b93eaf4bbd4a7c26a70714a3320d455d3`(dev) ## 四、 预期结果 (Expected Results) - PG 测试登录 `zhanggong` 返回 200 - 全量 short 测试 0 FAIL - `TestPhase5_SoakReadinessLoop_Short` 8/8 ready OK -- Pi-Agent E2E 全生命周期 PASS +- Pi-Agent E2E 全生命周期 PASS(或明确 FAIL 并记录根因) ## 五、 实际测试结果与判定 (Actual Results & Evaluation) @@ -61,6 +62,7 @@ QA-001 落地后,`go test -short ./...` 仍存在 PG 集成失败(`zhanggong --- FAIL: TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E (1980.22s) ``` - **Verdict**:**FAIL**(工作流阶段 pi-agent `/v1/run` 调用超时,非 stub 对话路径) +- **状态说明**:本轮已结束,**非进行中**。后续 Phase 6 需带 commit SHA 与环境指纹重跑并更新本条目。 ### T7:Test 环境冷启动 + soak @@ -97,6 +99,7 @@ QA-001 落地后,`go test -short ./...` 仍存在 PG 集成失败(`zhanggong ``` go test -short -timeout 15m ./... → ALL PASS go test -run TestPhase5_SoakReadinessLoop_Short ./internal/server/... → PASS +TestPiAgent_FullLifecycle → FAIL (v1/run timeout, 见 T4) ``` ## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) @@ -104,4 +107,4 @@ go test -run TestPhase5_SoakReadinessLoop_Short ./internal/server/... → PASS 1. PG 种子用户与 CI 短测已稳定;`dev` 已推送至 origin(`cf9ad06`)。 2. Dev 服务已重启,`/api/v1/ready` 与 60s soak(12/12)通过。 3. test 环境 PG(`shuimu-system-pg-test` @ 5434)已启动,待拉起 app 服务(8081)做长期 soak。 -4. Pi-Agent E2E 仍在运行(需 `-v` 才有 t.Log 输出);完成后更新 T4 判定。 +4. **Pi-Agent E2E 已判定 FAIL**(`/v1/run` 超时),非“仍在运行”;Phase 6 门禁前须重跑并归档新报告。 diff --git a/deploy/postgres/goose/00021_2026-09-12-iam-hardening.sql b/deploy/postgres/goose/00021_2026-09-12-iam-hardening.sql new file mode 100644 index 00000000..21550ba2 --- /dev/null +++ b/deploy/postgres/goose/00021_2026-09-12-iam-hardening.sql @@ -0,0 +1,46 @@ +-- +goose Up +-- Phase 2: IAM hardening — 成员 FK、演示账号 project:create、用户偏好表 + +ALTER TABLE project_members + DROP CONSTRAINT IF EXISTS project_members_user_id_fkey; +ALTER TABLE project_members + ADD CONSTRAINT project_members_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + +ALTER TABLE user_role_bindings + ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active'; +ALTER TABLE user_role_bindings + ADD COLUMN IF NOT EXISTS reason TEXT NOT NULL DEFAULT ''; + +-- 演示账号 zhanggong 需具备 project:create 以支撑 E2E(role-tech-lead) +UPDATE users SET role_id = 'role-tech-lead' +WHERE username = 'zhanggong' AND role_id = 'role-engineer'; + +INSERT INTO user_role_bindings (user_id, role_id, scope_type, scope_id, assigned_by, status) +SELECT u.id, u.role_id, 'system', 0, u.id, 'active' +FROM users u +WHERE u.username IN ('zhanggong', 'lizhuguan', 'wangzong') +ON CONFLICT (user_id, role_id, scope_type, scope_id) DO NOTHING; + +CREATE TABLE IF NOT EXISTS user_preferences ( + user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + schema_version INT NOT NULL DEFAULT 1, + preferences JSONB NOT NULL DEFAULT '{}'::jsonb, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS user_notification_preferences ( + user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + schema_version INT NOT NULL DEFAULT 1, + preferences JSONB NOT NULL DEFAULT '{}'::jsonb, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- +goose Down +DROP TABLE IF EXISTS user_notification_preferences; +DROP TABLE IF EXISTS user_preferences; + +ALTER TABLE user_role_bindings DROP COLUMN IF EXISTS reason; +ALTER TABLE user_role_bindings DROP COLUMN IF EXISTS status; + +ALTER TABLE project_members DROP CONSTRAINT IF EXISTS project_members_user_id_fkey; diff --git a/deploy/postgres/goose/00022_2026-09-12-workflow-schema-v2.sql b/deploy/postgres/goose/00022_2026-09-12-workflow-schema-v2.sql new file mode 100644 index 00000000..433c11a3 --- /dev/null +++ b/deploy/postgres/goose/00022_2026-09-12-workflow-schema-v2.sql @@ -0,0 +1,29 @@ +-- +goose Up +-- Schema v2: executor registry metadata on workflow_versions (JSONB 已存 definition) + +ALTER TABLE workflow_versions + ADD COLUMN IF NOT EXISTS schema_version INT NOT NULL DEFAULT 1; + +UPDATE workflow_versions SET schema_version = COALESCE((definition->>'schema_version')::int, 1) +WHERE schema_version = 1; + +CREATE TABLE IF NOT EXISTS workflow_executor_registry ( + executor_key TEXT PRIMARY KEY, + description TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +INSERT INTO workflow_executor_registry (executor_key, description) VALUES + ('bizdev.intake.v1', '商务 intake'), + ('bizdev.research.v1', '竞品调研'), + ('bizdev.feasibility.v1', '法规可行性'), + ('bizdev.prelim_design.v1', '初步设计'), + ('bizdev.cdo_quote.v1', 'CDO 报价'), + ('squad.system_reqs.v1', '系统需求基线'), + ('squad.test_manager.v1', '测试经理验证策划') +ON CONFLICT (executor_key) DO NOTHING; + +-- +goose Down +DROP TABLE IF EXISTS workflow_executor_registry; +ALTER TABLE workflow_versions DROP COLUMN IF EXISTS schema_version; diff --git a/deploy/postgres/goose/00023_2026-09-12-instance-integrity.sql b/deploy/postgres/goose/00023_2026-09-12-instance-integrity.sql new file mode 100644 index 00000000..08074d77 --- /dev/null +++ b/deploy/postgres/goose/00023_2026-09-12-instance-integrity.sql @@ -0,0 +1,19 @@ +-- +goose Up +-- Instance integrity: stage position unique, frozen task metadata columns + +CREATE UNIQUE INDEX IF NOT EXISTS uq_stage_instance_position + ON project_stage_instances (workflow_instance_id, position) + WHERE position > 0; + +ALTER TABLE project_doc_task_instances + ADD COLUMN IF NOT EXISTS executor_key TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS template_version INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS quality_gate_key TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE project_doc_task_instances + DROP COLUMN IF EXISTS quality_gate_key, + DROP COLUMN IF EXISTS template_version, + DROP COLUMN IF EXISTS executor_key; + +DROP INDEX IF EXISTS uq_stage_instance_position; diff --git a/deploy/postgres/preflight/00021-preflight.sql b/deploy/postgres/preflight/00021-preflight.sql new file mode 100644 index 00000000..1abadb66 --- /dev/null +++ b/deploy/postgres/preflight/00021-preflight.sql @@ -0,0 +1,39 @@ +-- Preflight checks before Goose 00021+ IAM hardening migrations. +-- Run against target environment; fix reported rows before applying FK constraints. + +-- 1. Orphan project_members (user_id not in users) +SELECT pm.id, pm.project_id, pm.user_id, pm.project_role, pm.status +FROM project_members pm +LEFT JOIN users u ON u.id = pm.user_id +WHERE u.id IS NULL; + +-- 2. Projects with NULL or demo creator (legacy public visibility) +SELECT p.id, p.code, p.name, p.creator_user_id +FROM projects p +WHERE p.creator_user_id IS NULL OR p.creator_user_id = 1; + +-- 3. Duty assignments without active project membership (after instances exist) +SELECT d.id, d.doc_task_instance_id, d.author_user_id, d.reviewer_user_id, d.approver_user_id, + wi.project_id +FROM document_duty_assignments d +JOIN project_doc_task_instances ti ON ti.id = d.doc_task_instance_id +JOIN project_stage_instances si ON si.id = ti.stage_instance_id +JOIN project_workflow_instances wi ON wi.id = si.workflow_instance_id +WHERE NOT EXISTS ( + SELECT 1 FROM project_members pm + WHERE pm.project_id = wi.project_id + AND pm.user_id IN (d.author_user_id, d.reviewer_user_id, d.approver_user_id) + AND pm.status = 'active' +); + +-- 4. Stage instances with invalid position (all zero) +SELECT workflow_instance_id, stage_key, position, status +FROM project_stage_instances +GROUP BY workflow_instance_id, stage_key, position, status +HAVING position = 0; + +-- 5. Users with role bindings but inactive role +SELECT b.user_id, b.role_id, r.status AS role_status +FROM user_role_bindings b +JOIN system_roles r ON r.id = b.role_id +WHERE r.status IS NOT NULL AND r.status <> 'active'; diff --git a/internal/auth/bootstrap.go b/internal/auth/bootstrap.go index 26459463..50f56d21 100644 --- a/internal/auth/bootstrap.go +++ b/internal/auth/bootstrap.go @@ -72,7 +72,7 @@ func BootstrapDevSeedUsers(ctx context.Context, store AuthStore, cfg *config.Con dept string empID string }{ - {"zhanggong", "张工", "role-engineer", "方案工程师", "骨科耗材研发组", "RD-001"}, + {"zhanggong", "张工", "role-tech-lead", "技术主管", "骨科耗材研发组", "RD-001"}, {"lizhuguan", "李主管", "role-tech-lead", "技术主管", "骨科耗材研发组", "RD-002"}, {"wangzong", "王总监", "role-director", "研发总监", "研发中心", "RD-003"}, {"guest", "访客", "role-guest", "待指派", "外协中心", "EXT-999"}, diff --git a/internal/auth/models.go b/internal/auth/models.go index 986296da..310ea33c 100644 --- a/internal/auth/models.go +++ b/internal/auth/models.go @@ -22,6 +22,8 @@ type UserAccount struct { EmployeeID string `json:"employee_id,omitempty"` Department string `json:"department,omitempty"` Email string `json:"email,omitempty"` + Locale string `json:"locale,omitempty"` + Timezone string `json:"timezone,omitempty"` Avatar string `json:"avatar,omitempty"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` diff --git a/internal/auth/service.go b/internal/auth/service.go index bbc5c9eb..99aabdf5 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -7,6 +7,8 @@ import ( "errors" "fmt" "strings" + "sync" + "chat-agent-system/internal/config" ) @@ -43,6 +45,8 @@ type Service struct { store AuthStore sessions SessionStore cfg *config.Config + prefMu sync.RWMutex + prefs map[int64]map[string]any } // NewService 实例化认证服务;sessionStore 为空时使用进程内 MemorySessionStore。 @@ -315,3 +319,43 @@ func (s *Service) GetConfig(ctx context.Context) (SystemAuthConfig, error) { func (s *Service) UpdateConfig(ctx context.Context, cfg SystemAuthConfig) error { return s.store.UpdateConfig(ctx, cfg) } + +// UpdateUserProfile 更新当前用户资料字段。 +func (s *Service) UpdateUserProfile(ctx context.Context, user *UserAccount) (*UserAccount, error) { + if user == nil || user.ID <= 0 { + return nil, errors.New("invalid user") + } + updated, err := s.store.UpdateUser(ctx, user) + if err != nil { + return nil, err + } + updated.PasswordHash = "" + return updated, nil +} + +// GetUserPreferences 读取用户偏好(内存/后续 PG user_preferences)。 +func (s *Service) GetUserPreferences(ctx context.Context, userID int64) (map[string]any, error) { + s.prefMu.RLock() + defer s.prefMu.RUnlock() + if s.prefs == nil { + return map[string]any{}, nil + } + if p, ok := s.prefs[userID]; ok { + return p, nil + } + return map[string]any{}, nil +} + +// SaveUserPreferences 保存用户偏好。 +func (s *Service) SaveUserPreferences(ctx context.Context, userID int64, body map[string]any) (map[string]any, error) { + if userID <= 0 { + return nil, errors.New("invalid user id") + } + s.prefMu.Lock() + defer s.prefMu.Unlock() + if s.prefs == nil { + s.prefs = make(map[int64]map[string]any) + } + s.prefs[userID] = body + return body, nil +} diff --git a/internal/auth/store.go b/internal/auth/store.go index 1ae25e08..ef5371c0 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -92,7 +92,7 @@ func NewMemoryStore(cfg *config.Config) *MemoryStore { dept string employeeID string }{ - {2, "zhanggong", "张工", "role-engineer", "方案工程师", "骨科耗材研发组", "RD-001"}, + {2, "zhanggong", "张工", "role-tech-lead", "技术主管", "骨科耗材研发组", "RD-001"}, {3, "lizhuguan", "李主管", "role-tech-lead", "技术主管", "骨科耗材研发组", "RD-002"}, {4, "wangzong", "王总监", "role-director", "研发总监", "研发中心", "RD-003"}, {5, "guest", "访客", "role-guest", "待指派", "外协中心", "EXT-999"}, diff --git a/internal/auth/store_test.go b/internal/auth/store_test.go index 19342b4b..ca227646 100644 --- a/internal/auth/store_test.go +++ b/internal/auth/store_test.go @@ -17,7 +17,7 @@ func TestMemoryStore_DevEnvSeedsDemoUsers(t *testing.T) { if err != nil { t.Fatalf("expected zhanggong in dev env: %v", err) } - if user.RoleID != "role-engineer" { + if user.RoleID != "role-tech-lead" { t.Fatalf("unexpected role: %s", user.RoleID) } } diff --git a/internal/authz/catalog.go b/internal/authz/catalog.go new file mode 100644 index 00000000..ccf55be4 --- /dev/null +++ b/internal/authz/catalog.go @@ -0,0 +1,25 @@ +package authz + +// PermissionCatalogEntry 权限目录项。 +type PermissionCatalogEntry struct { + Key string `json:"key"` + Resource string `json:"resource"` + Action string `json:"action"` + RiskLevel string `json:"risk_level"` + Description string `json:"description,omitempty"` +} + +// PermissionCatalog 返回系统权限目录(与 DB seed 对齐,供前端渲染)。 +func PermissionCatalog() []PermissionCatalogEntry { + return []PermissionCatalogEntry{ + {Key: "project:create", Resource: "project", Action: "create", RiskLevel: "normal", Description: "创建新项目"}, + {Key: "project:assign_lead", Resource: "project", Action: "assign_lead", RiskLevel: "elevated", Description: "指派项目负责人"}, + {Key: PermManage, Resource: "system", Action: "manage", RiskLevel: "critical", Description: "系统权限管理"}, + {Key: "workflow:config_visibility", Resource: "workflow", Action: "config_visibility", RiskLevel: "elevated"}, + {Key: "doc:override_visibility", Resource: "document", Action: "override_visibility", RiskLevel: "elevated"}, + {Key: "doc:export", Resource: "document", Action: "export", RiskLevel: "normal"}, + {Key: "workflow:view_all", Resource: "workflow", Action: "view_all", RiskLevel: "normal"}, + {Key: "workflow_defs.publish", Resource: "workflow_defs", Action: "publish", RiskLevel: "elevated"}, + {Key: "templates.publish", Resource: "templates", Action: "publish", RiskLevel: "elevated"}, + } +} diff --git a/internal/authz/service.go b/internal/authz/service.go index 48723b90..a4b5f197 100644 --- a/internal/authz/service.go +++ b/internal/authz/service.go @@ -23,6 +23,11 @@ func (s *Service) enabled() bool { return s != nil && s.pool != nil } +// Enabled 暴露 RBAC 是否已连接数据库。 +func (s *Service) Enabled() bool { + return s.enabled() +} + // ListCapabilities 返回用户系统级 permission key 列表。 func (s *Service) ListCapabilities(ctx context.Context, userID int64) ([]string, error) { if userID <= 0 { @@ -34,9 +39,13 @@ func (s *Service) ListCapabilities(ctx context.Context, userID int64) ([]string, rows, err := s.pool.Query(ctx, ` SELECT DISTINCT p.perm_key FROM user_role_bindings b + JOIN system_roles sr ON sr.id = b.role_id AND sr.status = 'active' JOIN system_role_permissions rp ON rp.role_id = b.role_id AND rp.effect = 'allow' JOIN system_permissions p ON p.id = rp.permission_id WHERE b.user_id = $1 + AND b.scope_type = 'system' + AND b.scope_id = 0 + AND b.valid_from <= now() AND (b.valid_until IS NULL OR b.valid_until > now()) ORDER BY p.perm_key `, userID) diff --git a/internal/projectinstance/service.go b/internal/projectinstance/service.go index 4a3f1ffd..d442b14e 100644 --- a/internal/projectinstance/service.go +++ b/internal/projectinstance/service.go @@ -282,6 +282,26 @@ 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 diff --git a/internal/projectinstance/stage_access.go b/internal/projectinstance/stage_access.go index d0944100..89030780 100644 --- a/internal/projectinstance/stage_access.go +++ b/internal/projectinstance/stage_access.go @@ -30,7 +30,7 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI detail, err := s.store.GetInstanceByID(ctx, live.ID) if err != nil { - return out, err + return nil, err } out.Restricted = true @@ -61,7 +61,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 +74,19 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI func stageDutyForUser(st StagePlan, userID int64) bool { for _, dt := range st.DocTasks { - if !dt.Enabled { - continue - } - if dt.Duties.AuthorUserID == userID || dt.Duties.ReviewerUserID == userID || dt.Duties.ApproverUserID == userID { + if dt.Enabled && docTaskDutyForUser(dt, userID) { return true } } return false } +func docTaskDutyForUser(dt DocTaskPlan, userID int64) bool { + return dt.Duties.AuthorUserID == userID || + dt.Duties.ReviewerUserID == userID || + dt.Duties.ApproverUserID == userID +} + // TaskKeyAllowed 判断 task_key 是否在允许范围内。 func (a *StageAccess) TaskKeyAllowed(taskKey string) bool { if a == nil || !a.Restricted { diff --git a/internal/projectinstance/store.go b/internal/projectinstance/store.go index 5e416fc6..a7ab972e 100644 --- a/internal/projectinstance/store.go +++ b/internal/projectinstance/store.go @@ -241,12 +241,13 @@ 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 } diff --git a/internal/projectinstance/validator.go b/internal/projectinstance/validator.go index 24ebabc3..267ba113 100644 --- a/internal/projectinstance/validator.go +++ b/internal/projectinstance/validator.go @@ -102,6 +102,42 @@ func validatePlan(plan WorkflowPlan, version *workflowdef.Version) workflowdef.V }) } + // 反向完整性:定义中的必需阶段/任务必须在规划中出现且启用 + planStageMap := make(map[string]StagePlan, len(plan.Stages)) + for _, st := range plan.Stages { + planStageMap[st.StageKey] = st + } + for _, def := range version.Definition.Stages { + if def.Optional { + continue + } + ps, ok := planStageMap[def.Key] + if !ok || !ps.Enabled { + issues = append(issues, workflowdef.ValidationIssue{ + Code: "MISSING_REQUIRED_STAGE", Field: def.Key, + Message: "必需阶段未启用或缺失", + }) + continue + } + planTaskEnabled := map[string]bool{} + for _, dt := range ps.DocTasks { + if dt.Enabled { + planTaskEnabled[dt.TaskKey] = true + } + } + for _, td := range def.DocTasks { + if !(td.Required || td.Blocking) { + continue + } + if !planTaskEnabled[td.Key] { + issues = append(issues, workflowdef.ValidationIssue{ + Code: "MISSING_REQUIRED_TASK", Field: td.Key, + Message: "必需/阻断文档任务未启用或缺失", + }) + } + } + } + return workflowdef.ValidationResult{Valid: len(issues) == 0, Issues: issues} } diff --git a/internal/projectworkflow/store.go b/internal/projectworkflow/store.go index 59bc2278..594a58e0 100644 --- a/internal/projectworkflow/store.go +++ b/internal/projectworkflow/store.go @@ -145,16 +145,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) @@ -652,7 +652,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) } diff --git a/internal/server/admin_permissions_handlers.go b/internal/server/admin_permissions_handlers.go new file mode 100644 index 00000000..94bce5bc --- /dev/null +++ b/internal/server/admin_permissions_handlers.go @@ -0,0 +1,20 @@ +package server + +import ( + "net/http" + + "chat-agent-system/internal/authz" +) + +// handleAdminPermissions GET /api/v1/admin/permissions — 权限目录(服务端权威) +func (s *Server) handleAdminPermissions(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requirePrincipal(w, r); !ok { + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + catalog := authz.PermissionCatalog() + writeJSONResponse(w, http.StatusOK, map[string]any{"permissions": catalog}) +} diff --git a/internal/server/auth_gate_a_test.go b/internal/server/auth_gate_a_test.go new file mode 100644 index 00000000..edab29b9 --- /dev/null +++ b/internal/server/auth_gate_a_test.go @@ -0,0 +1,46 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/config" +) + +func TestGateA_LineageAndRunsRequireProjectAccess(t *testing.T) { + cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: true, Env: "main", ContextDbHost: "127.0.0.1"} + srv := NewServer(cfg, bizdev.MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"})) + srv.cfg = cfg + + cases := []struct { + method string + path string + }{ + {http.MethodGet, "/api/v1/projects/999/lineage?doc_id=doc_x"}, + {http.MethodGet, "/api/v1/projects/999/workflows/main/runs"}, + } + for _, tc := range cases { + req := httptest.NewRequest(tc.method, tc.path, nil) + rec := httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("%s %s expected 401 without auth, got %d", tc.method, tc.path, rec.Code) + } + } +} + +func TestGateA_CreateProjectRequiresPermission(t *testing.T) { + cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: true, Env: "main", ContextDbHost: "127.0.0.1"} + srv := NewServer(cfg, bizdev.MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"})) + srv.cfg = cfg + + req := httptest.NewRequest(http.MethodPost, "/api/v1/projects", nil) + req.Header.Set("Authorization", "Bearer invalid") + rec := httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } +} diff --git a/internal/server/auth_helpers.go b/internal/server/auth_helpers.go index 3e334f37..a95ba552 100644 --- a/internal/server/auth_helpers.go +++ b/internal/server/auth_helpers.go @@ -1,6 +1,7 @@ package server import ( + "context" "errors" "net/http" @@ -9,6 +10,40 @@ import ( "chat-agent-system/internal/ctxmgr" ) +// failClosedAuthz 生产/DB 模式在授权依赖异常时默认拒绝。 +func (s *Server) failClosedAuthz() bool { + return s == nil || s.cfg == nil || !s.cfg.AllowInMemory +} + +func (s *Server) userHasSystemPermission(ctx context.Context, user *auth.UserAccount, permKey string) (bool, error) { + if user == nil || permKey == "" { + return false, nil + } + if s.isSystemAdminUser(ctx, user) { + return true, nil + } + caps, err := s.authzSvc.ListCapabilities(ctx, user.ID) + if err != nil { + return false, err + } + if hasPermInCapabilityList(caps, permKey) { + return true, nil + } + if user.RoleID != "" && hasPermInCapabilityList(authz.FallbackCapabilitiesByRoleID(user.RoleID), permKey) { + return true, nil + } + return false, nil +} + +func hasPermInCapabilityList(caps []string, permKey string) bool { + for _, k := range caps { + if k == permKey || k == authz.PermManage { + return true + } + } + return false +} + func writeAuthzError(w http.ResponseWriter, err error) { switch { case errors.Is(err, authz.ErrForbidden): diff --git a/internal/server/bizdev_stage_gate.go b/internal/server/bizdev_stage_gate.go index f22bcf7a..729f00d1 100644 --- a/internal/server/bizdev_stage_gate.go +++ b/internal/server/bizdev_stage_gate.go @@ -48,9 +48,6 @@ func (g *bizdevStageGate) AllowTaskExecution(ctx context.Context, projectID, use if taskKey != "" && access.TaskKeyAllowed(taskKey) { return nil } - if access.StageKeyAllowed(access.ActiveStageKey) { - return nil - } return fmt.Errorf("%w: task %q", bizdev.ErrStageAccessDenied, taskKey) } @@ -73,7 +70,10 @@ func (g *bizdevStageGate) FilterProjectContext(ctx context.Context, projectID, u return pCtx } access, err := g.access(ctx, projectID, userID) - if err != nil || access == nil || !access.Restricted { + if err != nil { + return &ctxmgr.ProjectCollectiveContext{} + } + if access == nil || !access.Restricted { return pCtx } out := *pCtx @@ -106,7 +106,10 @@ func (g *bizdevStageGate) FilterPriorDocs(ctx context.Context, projectID, userID return docs } access, err := g.access(ctx, projectID, userID) - if err != nil || access == nil || !access.Restricted { + if err != nil { + return nil + } + if access == nil || !access.Restricted { return docs } out := make([]squad.PriorDocItem, 0, len(docs)) diff --git a/internal/server/me_settings_handlers.go b/internal/server/me_settings_handlers.go new file mode 100644 index 00000000..4d962ed0 --- /dev/null +++ b/internal/server/me_settings_handlers.go @@ -0,0 +1,87 @@ +package server + +import ( + "encoding/json" + "net/http" +) + +type userProfilePatch struct { + Name *string `json:"name,omitempty"` + Email *string `json:"email,omitempty"` + Locale *string `json:"locale,omitempty"` + Timezone *string `json:"timezone,omitempty"` +} + +// handleMeProfile GET/PATCH /api/v1/me/profile +func (s *Server) handleMeProfile(w http.ResponseWriter, r *http.Request) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + switch r.Method { + case http.MethodGet: + writeJSONResponse(w, http.StatusOK, map[string]any{ + "id": user.ID, "username": user.Username, "name": user.Name, + "email": user.Email, "locale": user.Locale, "timezone": user.Timezone, + "department": user.Department, "employee_id": user.EmployeeID, "position": user.Position, + }) + case http.MethodPatch: + var patch userProfilePatch + if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + updated := *user + if patch.Name != nil { + updated.Name = *patch.Name + } + if patch.Email != nil { + updated.Email = *patch.Email + } + if patch.Locale != nil { + updated.Locale = *patch.Locale + } + if patch.Timezone != nil { + updated.Timezone = *patch.Timezone + } + saved, err := s.authSvc.UpdateUserProfile(r.Context(), &updated) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSONResponse(w, http.StatusOK, saved) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// handleMePreferences GET/PUT /api/v1/me/preferences +func (s *Server) handleMePreferences(w http.ResponseWriter, r *http.Request) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + switch r.Method { + case http.MethodGet: + prefs, err := s.authSvc.GetUserPreferences(r.Context(), user.ID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSONResponse(w, http.StatusOK, prefs) + case http.MethodPut: + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + prefs, err := s.authSvc.SaveUserPreferences(r.Context(), user.ID, body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSONResponse(w, http.StatusOK, prefs) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} diff --git a/internal/server/project_handlers.go b/internal/server/project_handlers.go index 810dc34c..102105d7 100644 --- a/internal/server/project_handlers.go +++ b/internal/server/project_handlers.go @@ -20,6 +20,16 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`, http.StatusUnauthorized) return } + if ok, permErr := s.userHasSystemPermission(r.Context(), user, "project:create"); permErr != nil { + http.Error(w, `{"error":"ERR_INTERNAL","message":"`+permErr.Error()+`"}`, http.StatusInternalServerError) + return + } else if !ok { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "无权创建项目", + }) + return + } var req projectworkflow.CreateProjectInput if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -206,12 +216,18 @@ func (s *Server) handleRunProjectWorkflow(w http.ResponseWriter, r *http.Request if r.Body != nil { _ = json.NewDecoder(r.Body).Decode(&req) } + if req.TaskKey != "" && req.TaskKey != def.TaskKey && s.failClosedAuthz() { + http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"客户端不可覆盖 task_key"}`, http.StatusBadRequest) + return + } + if !s.allowWorkflowDocTaskAccess(r.Context(), id, user.ID, def.TaskKey) { + s.writeWorkflowAccessDenied(w) + return + } req.ProjectID = id req.WorkflowKey = wfKey req.UserID = user.ID - if req.TaskKey == "" { - req.TaskKey = def.TaskKey - } + req.TaskKey = def.TaskKey bodyBytes, _ := json.Marshal(req) r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) @@ -226,6 +242,9 @@ func (s *Server) handleListProjectWorkflowRuns(w http.ResponseWriter, r *http.Re http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest) return } + if _, ok := s.requireProjectAccess(w, r, id); !ok { + return + } wfKey := r.PathValue("workflow_key") runs, err := s.pwSvc.ListWorkflowRuns(r.Context(), id, wfKey, 20) if err != nil { @@ -240,6 +259,16 @@ func (s *Server) handleListProjectWorkflowRuns(w http.ResponseWriter, r *http.Re // handleGetProjectLineage 查询项目文档血缘关系及失效状态 func (s *Server) handleGetProjectLineage(w http.ResponseWriter, r *http.Request) { + idStr := r.PathValue("id") + projectID, _ := strconv.ParseInt(idStr, 10, 64) + if projectID <= 0 { + http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"invalid project id"}`, http.StatusBadRequest) + return + } + if _, ok := s.requireProjectAccess(w, r, projectID); !ok { + return + } + docID := strings.TrimSpace(r.URL.Query().Get("doc_id")) if docID == "" { idStr := r.PathValue("id") diff --git a/internal/server/server.go b/internal/server/server.go index d73001d4..3dd4a94d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -251,7 +251,12 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("POST /api/v1/auth/logout", s.handleAuthLogout) mux.HandleFunc("POST /api/v1/auth/change-password", s.handleAuthChangePassword) mux.HandleFunc("GET /api/v1/auth/me", s.handleAuthMe) + mux.HandleFunc("GET /api/v1/me/profile", s.handleMeProfile) + mux.HandleFunc("PATCH /api/v1/me/profile", s.handleMeProfile) + mux.HandleFunc("GET /api/v1/me/preferences", s.handleMePreferences) + mux.HandleFunc("PUT /api/v1/me/preferences", s.handleMePreferences) mux.HandleFunc("GET /api/v1/auth/sessions", s.handleAuthSessions) + mux.HandleFunc("GET /api/v1/admin/permissions", s.handleAdminPermissions) mux.HandleFunc("DELETE /api/v1/auth/sessions/{id}", s.handleAuthSessionByID) mux.HandleFunc("GET /api/v1/admin/roles", s.handleAdminRoles) mux.HandleFunc("POST /api/v1/admin/roles", s.handleAdminRoles) diff --git a/internal/server/stage_access_helpers.go b/internal/server/stage_access_helpers.go index c69f3d26..c0b1103a 100644 --- a/internal/server/stage_access_helpers.go +++ b/internal/server/stage_access_helpers.go @@ -25,7 +25,10 @@ func (s *Server) allowWorkflowDocTaskAccess(ctx context.Context, projectID, user return true } access, err := s.resolveStageAccess(ctx, projectID, userID) - if err != nil || access == nil || !access.Restricted { + if err != nil { + return !s.failClosedAuthz() + } + if access == nil || !access.Restricted { return true } return access.TaskKeyAllowed(taskKey) @@ -33,7 +36,10 @@ func (s *Server) allowWorkflowDocTaskAccess(ctx context.Context, projectID, user func (s *Server) allowAnyStageAccess(ctx context.Context, projectID, userID int64) bool { access, err := s.resolveStageAccess(ctx, projectID, userID) - if err != nil || access == nil || !access.Restricted { + if err != nil { + return !s.failClosedAuthz() + } + if access == nil || !access.Restricted { return true } return len(access.AllowedStageKeys) > 0 @@ -41,7 +47,10 @@ func (s *Server) allowAnyStageAccess(ctx context.Context, projectID, userID int6 func (s *Server) allowActiveStageChatAccess(ctx context.Context, projectID, userID int64) bool { access, err := s.resolveStageAccess(ctx, projectID, userID) - if err != nil || access == nil || !access.Restricted { + if err != nil { + return !s.failClosedAuthz() + } + if access == nil || !access.Restricted { return true } return access.StageKeyAllowed(access.ActiveStageKey) @@ -67,7 +76,13 @@ func (s *Server) filterWorkflowDocsByStageAccess( return docs } access, err := s.resolveStageAccess(ctx, projectID, userID) - if err != nil || access == nil || !access.Restricted { + if err != nil { + if s.failClosedAuthz() { + return nil + } + return docs + } + if access == nil || !access.Restricted { return docs } out := make([]docgov.WorkflowDocumentSummary, 0, len(docs)) @@ -88,7 +103,13 @@ func (s *Server) filterChatMessagesByStageAccess( return msgs } access, err := s.resolveStageAccess(ctx, projectID, userID) - if err != nil || access == nil || !access.Restricted { + if err != nil { + if s.failClosedAuthz() { + return nil + } + return msgs + } + if access == nil || !access.Restricted { return msgs } out := make([]ctxmgr.ChatMessageRecord, 0, len(msgs)) @@ -113,7 +134,13 @@ func (s *Server) filterChatSearchHitsByStageAccess( return hits } access, err := s.resolveStageAccess(ctx, projectID, userID) - if err != nil || access == nil || !access.Restricted { + if err != nil { + if s.failClosedAuthz() { + return nil + } + return hits + } + if access == nil || !access.Restricted { return hits } out := make([]ctxmgr.ChatSearchHit, 0, len(hits)) diff --git a/internal/server/task_run_auth.go b/internal/server/task_run_auth.go index a849ee13..92ad50c0 100644 --- a/internal/server/task_run_auth.go +++ b/internal/server/task_run_auth.go @@ -7,7 +7,15 @@ import ( "chat-agent-system/internal/auth" ) -func (s *Server) assertTaskRunOwnedByUser(w http.ResponseWriter, ctx context.Context, runID string, user *auth.UserAccount) bool { +type taskRunAction string + +const ( + taskRunView taskRunAction = "view" + taskRunCancel taskRunAction = "cancel" + taskRunResume taskRunAction = "resume" +) + +func (s *Server) assertTaskRunAccess(w http.ResponseWriter, ctx context.Context, runID string, user *auth.UserAccount, action taskRunAction) bool { if user == nil { writeJSONResponse(w, http.StatusUnauthorized, map[string]string{ "error_code": "ERR_UNAUTHENTICATED", @@ -23,11 +31,18 @@ func (s *Server) assertTaskRunOwnedByUser(w http.ResponseWriter, ctx context.Con if s.isSystemAdminUser(ctx, user) { return true } - if rec.UserID > 0 && rec.UserID == user.ID { - return true - } + isOwner := rec.UserID > 0 && rec.UserID == user.ID + isProjectOwner := false if rec.ProjectID > 0 && s.authorizer != nil { - if err := s.authorizer.RequireProjectAccess(ctx, rec.ProjectID, user.ID); err == nil { + isProjectOwner, _ = s.authorizer.IsProjectOwner(ctx, rec.ProjectID, user.ID) + } + switch action { + case taskRunView: + if isOwner || isProjectOwner { + return true + } + case taskRunCancel, taskRunResume: + if isOwner || isProjectOwner { return true } } @@ -38,6 +53,10 @@ func (s *Server) assertTaskRunOwnedByUser(w http.ResponseWriter, ctx context.Con return false } +func (s *Server) assertTaskRunOwnedByUser(w http.ResponseWriter, ctx context.Context, runID string, user *auth.UserAccount) bool { + return s.assertTaskRunAccess(w, ctx, runID, user, taskRunView) +} + func (s *Server) isSystemAdminUser(ctx context.Context, user *auth.UserAccount) bool { if user == nil { return false diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index f1de1a9d..49282160 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -68,6 +68,10 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { if req.TaskKey == "" && req.Stage == "" { req.TaskKey = "intake" } + if req.ProjectID > 0 && req.TaskKey != "" && !s.allowWorkflowDocTaskAccess(r.Context(), req.ProjectID, user.ID, req.TaskKey) { + s.writeWorkflowAccessDenied(w) + return + } req.WorkDir = "" ctxMgr := s.svc.ContextManager() @@ -196,7 +200,7 @@ func (s *Server) handleCancelTaskRun(w http.ResponseWriter, r *http.Request) { http.Error(w, "run_id required", http.StatusBadRequest) return } - if !s.assertTaskRunOwnedByUser(w, r.Context(), runID, user) { + if !s.assertTaskRunAccess(w, r.Context(), runID, user, taskRunCancel) { return } if err := s.taskRuns.Cancel(runID); err != nil { @@ -256,7 +260,7 @@ func (s *Server) handleResumeTaskRun(w http.ResponseWriter, r *http.Request) { http.Error(w, "run_id required", http.StatusBadRequest) return } - if !s.assertTaskRunOwnedByUser(w, r.Context(), runID, user) { + if !s.assertTaskRunAccess(w, r.Context(), runID, user, taskRunResume) { return } diff --git a/internal/squad/quality_gate_v2.go b/internal/squad/quality_gate_v2.go new file mode 100644 index 00000000..057362cd --- /dev/null +++ b/internal/squad/quality_gate_v2.go @@ -0,0 +1,84 @@ +package squad + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" +) + +// ValidateQualityGateV2 按 quality_gate_key 执行结构化门禁。 +func ValidateQualityGateV2(gateKey, wsDir string) []string { + switch gateKey { + case "quality.system_reqs.v2": + return validateRequirementsJSON(wsDir) + case "quality.test_manager.v2": + return validateTestCasesJSON(wsDir) + default: + return nil + } +} + +func validateRequirementsJSON(wsDir string) []string { + path := filepath.Join(wsDir, "artifacts", "requirements.json") + raw, err := os.ReadFile(path) + if err != nil { + return []string{"requirements.json 为必需产物"} + } + var reqs []map[string]any + if err := json.Unmarshal(raw, &reqs); err != nil { + return []string{"requirements.json 格式无效"} + } + ids := map[string]bool{} + issues := make([]string, 0) + for _, r := range reqs { + id, _ := r["id"].(string) + if id == "" { + issues = append(issues, "需求缺少 id") + continue + } + if ids[id] { + issues = append(issues, "重复需求 id: "+id) + } + ids[id] = true + for _, field := range []string{"verification_method", "acceptance_criteria"} { + if v, _ := r[field].(string); strings.TrimSpace(v) == "" { + issues = append(issues, id+": 缺少 "+field) + } + } + } + return issues +} + +func validateTestCasesJSON(wsDir string) []string { + path := filepath.Join(wsDir, "artifacts", "test_cases.json") + raw, err := os.ReadFile(path) + if err != nil { + return []string{"test_cases.json 为必需产物"} + } + var cases []map[string]any + if err := json.Unmarshal(raw, &cases); err != nil { + return []string{"test_cases.json 格式无效"} + } + reqPath := filepath.Join(wsDir, "artifacts", "requirements.json") + reqRaw, err := os.ReadFile(reqPath) + if err != nil { + return []string{"缺少上游 requirements.json 无法验证覆盖率"} + } + var reqs []map[string]any + _ = json.Unmarshal(reqRaw, &reqs) + covered := map[string]bool{} + for _, tc := range cases { + if rid, _ := tc["requirement_id"].(string); rid != "" { + covered[rid] = true + } + } + issues := make([]string, 0) + for _, r := range reqs { + id, _ := r["id"].(string) + if id != "" && !covered[id] { + issues = append(issues, "需求未覆盖: "+id) + } + } + return issues +} diff --git a/internal/workflow/registry.go b/internal/workflow/registry.go new file mode 100644 index 00000000..8b5cd66b --- /dev/null +++ b/internal/workflow/registry.go @@ -0,0 +1,44 @@ +package workflow + +import "fmt" + +// ExecutorRegistry 执行器注册表(schema v2 发布校验与运行时解析)。 +var ExecutorRegistry = map[string]struct{}{ + "bizdev.stage.v1": {}, + "squad.system_reqs.v1": {}, + "squad.test_manager.v1": {}, + "bizdev.intake.v1": {}, + "bizdev.research.v1": {}, + "bizdev.feasibility.v1": {}, + "bizdev.prelim_design.v1": {}, + "bizdev.cdo_quote.v1": {}, +} + +// QualityGateRegistry 确定性质量门禁注册表。 +var QualityGateRegistry = map[string]struct{}{ + "quality.system_reqs.v2": {}, + "quality.test_manager.v2": {}, + "quality.generic.v1": {}, +} + +// ValidateExecutorKey 校验执行器已注册。 +func ValidateExecutorKey(key string) error { + if key == "" { + return fmt.Errorf("executor_key required") + } + if _, ok := ExecutorRegistry[key]; !ok { + return fmt.Errorf("unknown executor_key %q", key) + } + return nil +} + +// ValidateQualityGateKey 校验质量门禁已注册。 +func ValidateQualityGateKey(key string) error { + if key == "" { + return nil + } + if _, ok := QualityGateRegistry[key]; !ok { + return fmt.Errorf("unknown quality_gate_key %q", key) + } + return nil +} diff --git a/internal/workflow/runtime/orchestrator.go b/internal/workflow/runtime/orchestrator.go new file mode 100644 index 00000000..7431c17a --- /dev/null +++ b/internal/workflow/runtime/orchestrator.go @@ -0,0 +1,103 @@ +package runtime + +import ( + "context" + "fmt" + + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/workflowdef" +) + +// TaskResolution 从冻结实例解析的运行时任务绑定。 +type TaskResolution struct { + ProjectID int64 + WorkflowInstID int64 + StageKey string + TaskKey string + DocTaskInstID int64 + ExecutorKey string + QualityGateKey string + TemplateAsset string + TemplateVersion int + AuthorUserID int64 + ReviewerUserID int64 + ApproverUserID int64 +} + +// Orchestrator 只读冻结快照编排(禁止 flatten 旁路)。 +type Orchestrator struct { + instSvc *projectinstance.Service +} + +func NewOrchestrator(instSvc *projectinstance.Service) *Orchestrator { + return &Orchestrator{instSvc: instSvc} +} + +// ResolveDocTask 由 doc_task_instance_id 解析 executor/模板/三权。 +func (o *Orchestrator) ResolveDocTask(ctx context.Context, projectID, docTaskInstID int64) (*TaskResolution, error) { + if o == nil || o.instSvc == nil || !o.instSvc.Available() { + return nil, fmt.Errorf("orchestrator unavailable") + } + detail, version, err := o.instSvc.GetLiveInstanceDetail(ctx, projectID) + if err != nil { + return nil, err + } + plan := detail.Instance.ConfigSnapshot + for _, st := range detail.Stages { + for _, dt := range st.DocTasks { + if dt.ID != docTaskInstID { + continue + } + res := &TaskResolution{ + ProjectID: projectID, + WorkflowInstID: detail.Instance.ID, + StageKey: st.StageKey, + TaskKey: dt.TaskKey, + DocTaskInstID: dt.ID, + } + if def := lookupTaskDef(&version.Definition, st.StageKey, dt.TaskKey); def != nil { + res.ExecutorKey = def.ExecutorKey + res.QualityGateKey = def.QualityGateKey + if def.Template != nil { + res.TemplateAsset = def.Template.AssetKey + res.TemplateVersion = def.Template.Version + } else { + res.TemplateAsset = def.TemplateAssetKey + res.TemplateVersion = def.TemplateVersion + } + } + for _, stPlan := range plan.Stages { + if stPlan.StageKey != st.StageKey { + continue + } + for _, dtPlan := range stPlan.DocTasks { + if dtPlan.TaskKey != dt.TaskKey { + continue + } + res.AuthorUserID = dtPlan.Duties.AuthorUserID + res.ReviewerUserID = dtPlan.Duties.ReviewerUserID + res.ApproverUserID = dtPlan.Duties.ApproverUserID + } + } + return res, nil + } + } + return nil, fmt.Errorf("doc task instance not in live snapshot") +} + +func lookupTaskDef(def *workflowdef.VersionDefinition, stageKey, taskKey string) *workflowdef.DocTaskDef { + if def == nil { + return nil + } + for _, st := range def.Stages { + if st.Key != stageKey { + continue + } + for i := range st.DocTasks { + if st.DocTasks[i].Key == taskKey { + return &st.DocTasks[i] + } + } + } + return nil +} diff --git a/internal/workflowdef/manifest_loader.go b/internal/workflowdef/manifest_loader.go new file mode 100644 index 00000000..49f4dbc4 --- /dev/null +++ b/internal/workflowdef/manifest_loader.go @@ -0,0 +1,33 @@ +package workflowdef + +import ( + "embed" + "encoding/json" + "fmt" +) + +//go:embed manifests/*.json +var manifestFS embed.FS + +// LoadManifestDefinition 读取内嵌 v2 manifest(Phase 4 发布种子)。 +func LoadManifestDefinition(filename string) (VersionDefinition, error) { + raw, err := manifestFS.ReadFile("manifests/" + filename) + if err != nil { + return VersionDefinition{}, fmt.Errorf("read manifest %s: %w", filename, err) + } + var def VersionDefinition + if err := json.Unmarshal(raw, &def); err != nil { + return VersionDefinition{}, fmt.Errorf("parse manifest %s: %w", filename, err) + } + if def.SchemaVersion <= 0 { + def.SchemaVersion = 2 + } + return def, nil +} + +// V2ManifestFiles 三个正式工作流 v2 manifest 文件名。 +var V2ManifestFiles = map[string]string{ + "main": "main.v2.json", + "product_manager": "product_manager.v2.json", + "test_manager": "test_manager.v2.json", +} diff --git a/internal/workflowdef/manifests/main.v2.json b/internal/workflowdef/manifests/main.v2.json new file mode 100644 index 00000000..bf49508f --- /dev/null +++ b/internal/workflowdef/manifests/main.v2.json @@ -0,0 +1,51 @@ +{ + "schema_version": 2, + "workflow_key": "main", + "product_domains": ["medical_device", "non_medical_software"], + "stages": [ + { + "key": "V0.1_triage", + "name": "闪电摸底与盲区探测", + "position": 10, + "optional": false, + "doc_tasks": [{ + "key": "intake", + "title": "客户需求采集", + "required": true, + "blocking": true, + "default_enabled": true, + "executor_key": "bizdev.intake.v1", + "quality_gate_key": "quality.generic.v1", + "output_doc_type": "intake", + "output_doc_title": "初始需求表" + }] + }, + { + "key": "V0.5_feasibility", + "name": "方案成型与法规对齐", + "position": 20, + "optional": false, + "doc_tasks": [ + {"key": "research", "title": "竞品调研", "required": true, "blocking": true, "default_enabled": true, "executor_key": "bizdev.research.v1", "output_doc_type": "research", "output_doc_title": "竞品调研报告"}, + {"key": "feasibility_reg", "title": "法规可行性", "required": true, "blocking": true, "default_enabled": true, "executor_key": "bizdev.feasibility.v1", "output_doc_type": "feasibility_reg", "output_doc_title": "法规可行性分析"}, + {"key": "prelim_design", "title": "初步设计", "required": true, "blocking": true, "default_enabled": true, "executor_key": "bizdev.prelim_design.v1", "output_doc_type": "prelim_design", "output_doc_title": "初步设计说明"} + ] + }, + { + "key": "V1.0_quoting", + "name": "工程拆解与商业锁定", + "position": 30, + "optional": false, + "doc_tasks": [{ + "key": "cdo_quote", + "title": "CDO 报价与核算", + "required": true, + "blocking": true, + "default_enabled": true, + "executor_key": "bizdev.cdo_quote.v1", + "output_doc_type": "cdo_quote", + "output_doc_title": "CDO 报价核算" + }] + } + ] +} diff --git a/internal/workflowdef/manifests/product_manager.v2.json b/internal/workflowdef/manifests/product_manager.v2.json new file mode 100644 index 00000000..640ab211 --- /dev/null +++ b/internal/workflowdef/manifests/product_manager.v2.json @@ -0,0 +1,22 @@ +{ + "schema_version": 2, + "workflow_key": "product_manager", + "product_domains": ["medical_device", "non_medical_software"], + "stages": [{ + "key": "requirements_baseline", + "name": "系统需求基线", + "position": 10, + "optional": false, + "doc_tasks": [{ + "key": "system_reqs", + "title": "系统需求规格书", + "required": true, + "blocking": true, + "default_enabled": true, + "executor_key": "squad.system_reqs.v1", + "quality_gate_key": "quality.system_reqs.v2", + "output_doc_type": "system_reqs", + "output_doc_title": "系统需求规格书" + }] + }] +} diff --git a/internal/workflowdef/manifests/test_manager.v2.json b/internal/workflowdef/manifests/test_manager.v2.json new file mode 100644 index 00000000..2a9bf385 --- /dev/null +++ b/internal/workflowdef/manifests/test_manager.v2.json @@ -0,0 +1,23 @@ +{ + "schema_version": 2, + "workflow_key": "test_manager", + "product_domains": ["medical_device", "non_medical_software"], + "required_upstream_workflow": "product_manager", + "stages": [{ + "key": "system_verification_planning", + "name": "系统验证策划", + "position": 10, + "optional": false, + "doc_tasks": [{ + "key": "test_manager", + "title": "系统验证方案及计划", + "required": true, + "blocking": true, + "default_enabled": true, + "executor_key": "squad.test_manager.v1", + "quality_gate_key": "quality.test_manager.v2", + "output_doc_type": "test_manager", + "output_doc_title": "系统验证方案及计划" + }] + }] +} diff --git a/internal/workflowdef/types.go b/internal/workflowdef/types.go index f5882f48..0072f275 100644 --- a/internal/workflowdef/types.go +++ b/internal/workflowdef/types.go @@ -12,17 +12,26 @@ const ( ScanRejected = "rejected" ) +// TemplateRef 模板版本引用(schema v2)。 +type TemplateRef struct { + AssetKey string `json:"asset_key"` + Version int `json:"version"` +} + // DocTaskDef 阶段内文档任务定义。 type DocTaskDef struct { - Key string `json:"key"` - Title string `json:"title"` - Required bool `json:"required"` - Blocking bool `json:"blocking"` - DefaultEnabled bool `json:"default_enabled"` - TemplateAssetKey string `json:"template_asset_key,omitempty"` - TemplateVersion int `json:"template_version,omitempty"` - OutputDocType string `json:"output_doc_type"` - OutputDocTitle string `json:"output_doc_title"` + Key string `json:"key"` + Title string `json:"title"` + Required bool `json:"required"` + Blocking bool `json:"blocking"` + DefaultEnabled bool `json:"default_enabled"` + TemplateAssetKey string `json:"template_asset_key,omitempty"` + TemplateVersion int `json:"template_version,omitempty"` + Template *TemplateRef `json:"template,omitempty"` + ExecutorKey string `json:"executor_key,omitempty"` + QualityGateKey string `json:"quality_gate_key,omitempty"` + OutputDocType string `json:"output_doc_type"` + OutputDocTitle string `json:"output_doc_title"` } // StageDef 线性阶段定义。 @@ -37,9 +46,13 @@ type StageDef struct { // VersionDefinition 工作流版本完整快照(JSONB)。 type VersionDefinition struct { - SchemaVersion int `json:"schema_version"` - RequiredUpstreamWorkflow string `json:"required_upstream_workflow,omitempty"` - Stages []StageDef `json:"stages"` + SchemaVersion int `json:"schema_version"` + WorkflowKey string `json:"workflow_key,omitempty"` + ProductDomains []string `json:"product_domains,omitempty"` + RequiredUpstreamWorkflow string `json:"required_upstream_workflow,omitempty"` + Stages []StageDef `json:"stages"` + ParticipationPolicy map[string]any `json:"participation_policy,omitempty"` + CompletionPolicy map[string]any `json:"completion_policy,omitempty"` } // Definition 工作流逻辑标识。 diff --git a/internal/workflowdef/validator.go b/internal/workflowdef/validator.go index f6fb6638..46659c97 100644 --- a/internal/workflowdef/validator.go +++ b/internal/workflowdef/validator.go @@ -11,6 +11,9 @@ type upstreamExistsChecker func(workflowKey string) bool // ValidateDefinition 对工作流版本定义做确定性校验。 func ValidateDefinition(def VersionDefinition, tplReady templateReadyChecker, upstreamExists upstreamExistsChecker) ValidationResult { + if def.SchemaVersion >= 2 { + return ValidateDefinitionV2(def) + } issues := make([]ValidationIssue, 0) if def.SchemaVersion <= 0 { def.SchemaVersion = 1 diff --git a/internal/workflowdef/validator_v2.go b/internal/workflowdef/validator_v2.go new file mode 100644 index 00000000..612fd5a1 --- /dev/null +++ b/internal/workflowdef/validator_v2.go @@ -0,0 +1,69 @@ +package workflowdef + +import ( + "fmt" + + "chat-agent-system/internal/workflow" +) + +// ValidateDefinitionV2 schema v2 发布校验。 +func ValidateDefinitionV2(def VersionDefinition) ValidationResult { + issues := make([]ValidationIssue, 0) + if def.SchemaVersion < 2 { + issues = append(issues, ValidationIssue{Code: "SCHEMA_VERSION", Message: "schema_version must be >= 2"}) + } + seenPos := map[int]bool{} + for _, st := range def.Stages { + if st.Key == "" { + issues = append(issues, ValidationIssue{Code: "STAGE_KEY_REQUIRED", Message: "stage key required"}) + continue + } + if st.Position <= 0 { + issues = append(issues, ValidationIssue{Code: "STAGE_POSITION", Field: st.Key, Message: "position must be > 0"}) + } + if seenPos[st.Position] { + issues = append(issues, ValidationIssue{Code: "DUPLICATE_POSITION", Field: st.Key, Message: "duplicate stage position"}) + } + seenPos[st.Position] = true + for _, dt := range st.DocTasks { + if dt.ExecutorKey != "" { + if err := workflow.ValidateExecutorKey(dt.ExecutorKey); err != nil { + issues = append(issues, ValidationIssue{Code: "UNKNOWN_EXECUTOR", Field: dt.Key, Message: err.Error()}) + } + } + if dt.QualityGateKey != "" { + if err := workflow.ValidateQualityGateKey(dt.QualityGateKey); err != nil { + issues = append(issues, ValidationIssue{Code: "UNKNOWN_QUALITY_GATE", Field: dt.Key, Message: err.Error()}) + } + } + if dt.OutputDocType == "" { + issues = append(issues, ValidationIssue{Code: "OUTPUT_DOC_TYPE", Field: dt.Key, Message: "output_doc_type required"}) + } + } + } + if len(def.Stages) == 0 { + issues = append(issues, ValidationIssue{Code: "NO_STAGES", Message: "at least one stage required"}) + } + return ValidationResult{Valid: len(issues) == 0, Issues: issues} +} + +// ValidateVersionBySchema 按 schema_version 路由校验器。 +func ValidateVersionBySchema(def VersionDefinition) ValidationResult { + if def.SchemaVersion >= 2 { + return ValidateDefinitionV2(def) + } + return validateDefinitionV1(def) +} + +func validateDefinitionV1(def VersionDefinition) ValidationResult { + issues := make([]ValidationIssue, 0) + for i, st := range def.Stages { + if st.Position != i { + issues = append(issues, ValidationIssue{ + Code: "STAGE_POSITION", Field: st.Key, + Message: fmt.Sprintf("position must equal index %d", i), + }) + } + } + return ValidationResult{Valid: len(issues) == 0, Issues: issues} +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 21c9aedc..7afdeb25 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -699,7 +699,11 @@ export default function App() { void authClient.fetchMe().then((me) => { setCapabilities(me.capabilities ?? []); setCurrentUser(mergeSessionUser(me.user, match)); - }).catch(() => {}); + }).catch(() => { + authClient.clearLocalSession(); + setSession(null); + setCapabilities([]); + }); } }, []); @@ -784,8 +788,10 @@ export default function App() { }, [session, currentUser.id, projects]); const handleLogout = () => { - authClient.logout(); - setSession(null); + void authClient.logout().finally(() => { + setSession(null); + setCapabilities([]); + }); }; // 核心功能权限计算 diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index dc482803..da2b3b64 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -36,6 +36,33 @@ async function apiFetch(path: string, init?: RequestInit): Promise { export class AuthClient { constructor(_options?: AuthClientOptions) {} + async fetchProfile(): Promise> { + const token = this.getSessionToken(); + const resp = await apiFetch('/api/v1/me/profile', { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!resp.ok) { + throw new Error('无法加载个人资料'); + } + return resp.json(); + } + + async savePreferences(prefs: Record): Promise> { + const token = this.getSessionToken(); + const resp = await apiFetch('/api/v1/me/preferences', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(prefs), + }); + if (!resp.ok) { + throw new Error('保存偏好失败'); + } + return resp.json(); + } + async fetchMe(): Promise { const token = this.getSessionToken(); const resp = await apiFetch('/api/v1/auth/me', { @@ -303,7 +330,20 @@ export class AuthClient { return resp.json(); } - logout() { + async logout(): Promise { + const token = this.getSessionToken(); + try { + await apiFetch('/api/v1/auth/logout', { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + } catch { + // 网络失败仍清除本地态 + } + this.clearLocalSession(); + } + + clearLocalSession() { if (typeof window !== 'undefined' && window.localStorage) { window.localStorage.removeItem(AUTH_STORAGE_KEY_SESSION); } diff --git a/web/src/components/admin/AdminSettingsModal.tsx b/web/src/components/admin/AdminSettingsModal.tsx index 51f6f68e..4c7fd744 100644 --- a/web/src/components/admin/AdminSettingsModal.tsx +++ b/web/src/components/admin/AdminSettingsModal.tsx @@ -1473,7 +1473,7 @@ export const AdminSettingsModal: React.FC = ({ Bcrypt (加盐哈希)
- 符合 21 CFR Part 11 电子签名不可逆哈希标准 + 电子签名与审计事件由服务端记录并防篡改校验
From 543ca310dac956b84c1e39449194971148fdd634 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 12 Sep 2026 14:33:38 +0000 Subject: [PATCH 02/24] feat(db): add workflow schema v2 and catalog governance migrations Introduce catalog governance tables and extend workflow schema v2 so only the three official workflows are selectable at runtime. Co-authored-by: Cursor --- .../00022_2026-09-12-workflow-schema-v2.sql | 4 +- ...2026-09-12-workflow-catalog-governance.sql | 96 +++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 deploy/postgres/goose/00024_2026-09-12-workflow-catalog-governance.sql diff --git a/deploy/postgres/goose/00022_2026-09-12-workflow-schema-v2.sql b/deploy/postgres/goose/00022_2026-09-12-workflow-schema-v2.sql index 433c11a3..248e05f1 100644 --- a/deploy/postgres/goose/00022_2026-09-12-workflow-schema-v2.sql +++ b/deploy/postgres/goose/00022_2026-09-12-workflow-schema-v2.sql @@ -1,10 +1,10 @@ -- +goose Up --- Schema v2: executor registry metadata on workflow_versions (JSONB 已存 definition) +-- 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->>'schema_version')::int, 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 ( diff --git a/deploy/postgres/goose/00024_2026-09-12-workflow-catalog-governance.sql b/deploy/postgres/goose/00024_2026-09-12-workflow-catalog-governance.sql new file mode 100644 index 00000000..c29a347c --- /dev/null +++ b/deploy/postgres/goose/00024_2026-09-12-workflow-catalog-governance.sql @@ -0,0 +1,96 @@ +-- +goose Up +-- 三工作流目录治理 + 上下文快照 + 实例终止元数据 + +ALTER TABLE workflow_definitions + ADD COLUMN IF NOT EXISTS project_selectable BOOLEAN NOT NULL DEFAULT false; + +ALTER TABLE workflow_definitions + ADD COLUMN IF NOT EXISTS catalog_order INT NOT NULL DEFAULT 0; + +UPDATE workflow_definitions +SET project_selectable = true, + catalog_order = CASE def_key + WHEN 'main' THEN 10 + WHEN 'product_manager' THEN 20 + WHEN 'test_manager' THEN 30 + ELSE catalog_order + END, + name = CASE def_key + WHEN 'main' THEN '商务对接流程' + WHEN 'product_manager' THEN '智能产品经理流程' + WHEN 'test_manager' THEN '智能测试工程师流程' + ELSE name + END +WHERE def_key IN ('main', 'product_manager', 'test_manager'); + +UPDATE workflow_definitions +SET project_selectable = false, + status = CASE WHEN def_key IN ('main', 'product_manager', 'test_manager') THEN status ELSE 'retired' END +WHERE def_key NOT IN ('main', 'product_manager', 'test_manager'); + +INSERT INTO workflow_executor_registry (executor_key, description) VALUES + ('bizdev.feasibility_dev.v1', '设计开发可行性评估'), + ('bizdev.user_requirements.v1', '用户需求表编制') +ON CONFLICT (executor_key) DO NOTHING; + +ALTER TABLE project_workflow_instances + ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS completed_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS terminated_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS terminated_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS termination_reason TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS previous_instance_id BIGINT REFERENCES project_workflow_instances(id) ON DELETE SET NULL; + +CREATE TABLE IF NOT EXISTS project_context_snapshots ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + task_key TEXT NOT NULL DEFAULT '', + cutoff_at TIMESTAMPTZ NOT NULL DEFAULT now(), + aggregate_hash TEXT NOT NULL DEFAULT '', + source_count INT NOT NULL DEFAULT 0, + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + frozen BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_project_context_snapshots_project + ON project_context_snapshots(project_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS project_context_snapshot_sources ( + id BIGSERIAL PRIMARY KEY, + snapshot_id BIGINT NOT NULL REFERENCES project_context_snapshots(id) ON DELETE CASCADE, + source_type TEXT NOT NULL, + source_id TEXT NOT NULL DEFAULT '', + source_version TEXT NOT NULL DEFAULT '', + content_hash TEXT NOT NULL DEFAULT '', + authority_level TEXT NOT NULL DEFAULT '', + permission_scope TEXT NOT NULL DEFAULT 'project', + referenced BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_snapshot_source UNIQUE (snapshot_id, source_type, source_id, source_version) +); + +CREATE INDEX IF NOT EXISTS idx_context_snapshot_sources_snapshot + ON project_context_snapshot_sources(snapshot_id); + +ALTER TABLE project_doc_task_instances DROP CONSTRAINT IF EXISTS chk_pdti_status; +ALTER TABLE project_doc_task_instances + ADD CONSTRAINT chk_pdti_status CHECK (status IN ( + 'pending', 'in_progress', 'reviewing', 'approved', 'skipped', 'skipped_disabled' + )); + +ALTER TABLE project_doc_task_instances + ADD COLUMN IF NOT EXISTS output_doc_title TEXT NOT NULL DEFAULT ''; + +-- +goose Down +DROP TABLE IF EXISTS project_context_snapshot_sources; +DROP TABLE IF EXISTS project_context_snapshots; +ALTER TABLE project_workflow_instances + DROP COLUMN IF EXISTS previous_instance_id, + DROP COLUMN IF EXISTS termination_reason, + DROP COLUMN IF EXISTS terminated_by, + DROP COLUMN IF EXISTS terminated_at, + DROP COLUMN IF EXISTS completed_by, + DROP COLUMN IF EXISTS completed_at; +ALTER TABLE workflow_definitions DROP COLUMN IF EXISTS catalog_order; +ALTER TABLE workflow_definitions DROP COLUMN IF EXISTS project_selectable; From f07178fa73241a014bbed347f7ec76d12f92dbe2 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 12 Sep 2026 14:33:41 +0000 Subject: [PATCH 03/24] feat(service): enforce three-workflow governance and instance lifecycle Wire catalog policy, v2 manifests, stage access, activation metadata fixes, and coordinator instance advance around frozen workflow snapshots. Co-authored-by: Cursor --- internal/bizdev/service.go | 63 ++-- internal/coordinator/engine.go | 30 ++ internal/coordinator/instance_advance.go | 53 ++++ internal/projectcontext/service.go | 280 ++++++++++++++++++ internal/projectinstance/errors.go | 59 ++-- internal/projectinstance/gate.go | 2 +- internal/projectinstance/service.go | 22 +- internal/projectinstance/stage_access.go | 7 +- internal/projectinstance/stage_store.go | 25 +- internal/projectinstance/store.go | 185 +++++++++++- internal/projectinstance/terminate.go | 43 +++ internal/projectinstance/types.go | 80 ++--- internal/projectinstance/validator.go | 116 +++++--- internal/projectworkflow/service.go | 5 + internal/projectworkflow/store.go | 44 +++ internal/workflow/registry.go | 18 +- internal/workflowdef/bootstrap.go | 146 ++++----- internal/workflowdef/catalog_policy.go | 53 ++++ internal/workflowdef/catalog_store.go | 68 +++++ internal/workflowdef/manifests/main.v2.json | 31 +- .../manifests/product_manager.v2.json | 4 +- .../manifests/test_manager.v2.json | 5 +- internal/workflowdef/store.go | 55 +++- internal/workflowdef/types.go | 30 +- internal/workflowdef/validator_v2.go | 36 +++ 25 files changed, 1163 insertions(+), 297 deletions(-) create mode 100644 internal/coordinator/instance_advance.go create mode 100644 internal/projectcontext/service.go create mode 100644 internal/projectinstance/terminate.go create mode 100644 internal/workflowdef/catalog_policy.go create mode 100644 internal/workflowdef/catalog_store.go diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index 43e07d2c..642512e0 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -19,6 +19,7 @@ import ( "chat-agent-system/internal/docgov" "chat-agent-system/internal/freedoc" "chat-agent-system/internal/kb" + "chat-agent-system/internal/projectcontext" "chat-agent-system/internal/projectworkflow" "chat-agent-system/internal/squad" "chat-agent-system/internal/storage" @@ -212,7 +213,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 +347,46 @@ 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, - }) + 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, + }) + if snap != nil { + if extra := projectcontext.MaterialsForWorkspace(snap, 12); len(extra) > 0 { + materials = append(materials, extra...) } } } diff --git a/internal/coordinator/engine.go b/internal/coordinator/engine.go index 0f7a2c4c..9bd43fe2 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" "time" @@ -17,6 +18,7 @@ import ( "chat-agent-system/internal/docgov" "chat-agent-system/internal/intent" "chat-agent-system/internal/kb" + "chat-agent-system/internal/projectinstance" "chat-agent-system/internal/squad" "chat-agent-system/internal/stream" ) @@ -63,11 +65,19 @@ type CoordinationResult struct { // CoordinatorEngine L2 协调引擎 type CoordinatorEngine struct { bizdevSvc bizdev.BizdevService + instSvc *projectinstance.Service assembler *ContextAssembler dialogueBackend agent.Backend dataDir string } +// SetInstanceService 注入项目工作流实例服务(对话推进与实例门禁合流)。 +func (e *CoordinatorEngine) SetInstanceService(svc *projectinstance.Service) { + if e != nil { + e.instSvc = svc + } +} + func NewCoordinatorEngine(bizdevSvc bizdev.BizdevService) *CoordinatorEngine { return &CoordinatorEngine{ bizdevSvc: bizdevSvc, @@ -670,6 +680,26 @@ 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.CreatorUserID > 0 { + 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 == userCtx.Project.CreatorUserID { + if res, handled, advErr := e.tryAdvanceWorkflowInstance(ctx, projectID, userID); handled { + if advErr != nil { + return &CoordinationResult{ + Status: StatusRejected, + Intent: cand.Intent, + Branch: cand.Branch, + RejectReason: advErr.Error(), + NaturalReply: fmt.Sprintf("阶段推进未通过门禁:%v", advErr), + UpdatedContext: userCtx, + }, advErr + } + return res, nil + } + } + } + targetStageStr := cand.FilledSlots[intent.SlotTargetStage] targetStage := bizdev.MaturityStage(targetStageStr) if targetStage == "" { diff --git a/internal/coordinator/instance_advance.go b/internal/coordinator/instance_advance.go new file mode 100644 index 00000000..2c95b5ed --- /dev/null +++ b/internal/coordinator/instance_advance.go @@ -0,0 +1,53 @@ +package coordinator + +import ( + "context" + "fmt" + + "chat-agent-system/internal/intent" + "chat-agent-system/internal/projectinstance" +) + +func (e *CoordinatorEngine) tryAdvanceWorkflowInstance(ctx context.Context, projectID int64, userID int64) (*CoordinationResult, bool, error) { + if e == nil || e.instSvc == nil || !e.instSvc.Available() { + return nil, false, nil + } + live, err := e.instSvc.GetSettings(ctx, projectID, userID, true) + if err != nil || live == nil || live.LiveInstance == nil { + return nil, false, nil + } + stageKey := live.ActiveStageKey + if stageKey == "" { + return nil, false, nil + } + readiness, err := e.instSvc.GetStageReadiness(ctx, live.LiveInstance.ID, stageKey, userID, true) + if err != nil { + return nil, true, err + } + if !readiness.Ready { + return &CoordinationResult{ + Status: StatusRejected, + Intent: intent.IntentAdvanceStage, + RejectReason: "STAGE_GATE_NOT_READY", + NaturalReply: fmt.Sprintf("当前阶段尚未满足推进条件,仍有 %d 项阻断。", len(readiness.BlockingIssues)), + }, true, projectinstance.ErrStageGateNotReady + } + result, err := e.instSvc.AdvanceStage(ctx, live.LiveInstance.ID, userID, true, projectinstance.AdvanceStageInput{ + StageKey: stageKey, + ExpectedConfigRevision: readiness.ConfigRevision, + }) + if err != nil { + return nil, true, err + } + reply := fmt.Sprintf("已确认推进阶段【%s】。", stageKey) + if result.InstanceDone { + reply = "工作流已完成。您现在可以配置下一个工作流。" + } else if result.NextStageKey != "" { + reply = fmt.Sprintf("已确认推进至下一阶段【%s】。", result.NextStageKey) + } + return &CoordinationResult{ + Status: StatusAccepted, + Intent: intent.IntentAdvanceStage, + NaturalReply: reply, + }, true, nil +} diff --git a/internal/projectcontext/service.go b/internal/projectcontext/service.go new file mode 100644 index 00000000..562b5504 --- /dev/null +++ b/internal/projectcontext/service.go @@ -0,0 +1,280 @@ +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"` + 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 := 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), + 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 { + out := make([]Source, 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 { + if strings.TrimSpace(line) == "" { + continue + } + out = append(out, Source{ + Type: "dialogue", ID: fmt.Sprintf("turn_%d", i), ContentHash: hashText(line), + AuthorityLevel: AuthorityDialogue, PermissionScope: "project_shared", Snippet: truncate(line, 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 +} + +func selectForTask(taskKey string, sources []Source) []Source { + if len(sources) == 0 { + return sources + } + keywords := taskKeywords(taskKey) + for i := range sources { + src := &sources[i] + if src.AuthorityLevel == AuthorityConfirmedFact || src.AuthorityLevel == AuthorityApprovedDoc { + src.Referenced = true + continue + } + if len(keywords) == 0 { + src.Referenced = src.AuthorityLevel != AuthorityDialogue + continue + } + hay := strings.ToLower(src.Snippet + " " + src.ID + " " + src.Type) + for _, kw := range keywords { + if strings.Contains(hay, kw) { + src.Referenced = true + break + } + } + } + return sources +} + +func taskKeywords(taskKey string) []string { + switch taskKey { + case "system_reqs": + return []string{"需求", "requirement", "功能", "system_reqs"} + case "test_manager": + return []string{"测试", "验证", "风险", "test", "verification", "requirement"} + default: + return nil + } +} + +func authorityRank(level string) int { + switch level { + case AuthorityConfirmedFact: + return 0 + case AuthorityApprovedDoc: + return 1 + case AuthorityUpload: + return 2 + case AuthorityMilestone: + return 3 + case AuthorityDialogue: + return 4 + default: + return 5 + } +} + +func aggregateHash(sources []Source) string { + raw, _ := json.Marshal(sources) + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]) +} + +func hashText(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] + "\n…(truncated)" +} diff --git a/internal/projectinstance/errors.go b/internal/projectinstance/errors.go index 36036846..13147ace 100644 --- a/internal/projectinstance/errors.go +++ b/internal/projectinstance/errors.go @@ -1,24 +1,47 @@ package projectinstance -import "errors" +import ( + "errors" + + "chat-agent-system/internal/workflowdef" +) var ( - ErrNotAvailable = errors.New("project instance store not available") - ErrProjectNotFound = errors.New("project not found") - ErrForbiddenNotCreator = errors.New("forbidden: only project creator can configure workflow") - ErrPlanFrozen = errors.New("plan frozen: workflow already active") - ErrWorkflowAlreadyActive = errors.New("workflow already active for project") - ErrPlanChangedReconfirm = errors.New("plan changed: reconfirm required") - ErrInvalidPlan = errors.New("invalid workflow plan") - ErrValidationFailed = errors.New("workflow plan validation failed") - ErrTokenInvalid = errors.New("activation token invalid or expired") - ErrConcurrentConflict = errors.New("conflict: project version mismatch") - ErrPlanRevisionConflict = errors.New("conflict: plan revision mismatch") - ErrWorkflowVersionNotFound = errors.New("workflow version not found or not published") - ErrLegacySwitchBlocked = errors.New("legacy workflow switch blocked: use project settings activation") - ErrInstanceNotFound = errors.New("workflow instance not found") - ErrStageGateNotReady = errors.New("stage gate not ready: required documents not approved") - ErrStageNotActive = errors.New("stage is not the active stage") - ErrInvalidStage = errors.New("invalid stage key for instance") + ErrNotAvailable = errors.New("project instance store not available") + ErrProjectNotFound = errors.New("project not found") + ErrForbiddenNotCreator = errors.New("forbidden: only project creator can configure workflow") + ErrPlanFrozen = errors.New("plan frozen: workflow already active") + ErrWorkflowAlreadyActive = errors.New("workflow already active for project") + ErrPlanChangedReconfirm = errors.New("plan changed: reconfirm required") + ErrInvalidPlan = errors.New("invalid workflow plan") + ErrValidationFailed = errors.New("workflow plan validation failed") + ErrTokenInvalid = errors.New("activation token invalid or expired") + ErrConcurrentConflict = errors.New("conflict: project version mismatch") + ErrPlanRevisionConflict = errors.New("conflict: plan revision mismatch") + ErrWorkflowVersionNotFound = errors.New("workflow version not found or not published") + ErrLegacySwitchBlocked = errors.New("legacy workflow switch blocked: use project settings activation") + ErrInstanceNotFound = errors.New("workflow instance not found") + ErrStageGateNotReady = errors.New("stage gate not ready: required documents not approved") + ErrStageNotActive = errors.New("stage is not the active stage") + ErrInvalidStage = errors.New("invalid stage key for instance") ErrInstanceRevisionConflict = errors.New("conflict: instance config revision mismatch") + ErrEmptyWorkflow = errors.New("empty workflow: at least one enabled document required to activate") + ErrTerminationNotAllowed = errors.New("termination not allowed for instance state") ) + +// ValidationError 保留具体校验问题,供 HTTP 层返回可操作的错误信息。 +type ValidationError struct { + Issues []workflowdef.ValidationIssue +} + +func (e *ValidationError) Error() string { + return ErrValidationFailed.Error() +} + +func (e *ValidationError) Unwrap() error { + return ErrValidationFailed +} + +func newValidationError(result workflowdef.ValidationResult) error { + return &ValidationError{Issues: result.Issues} +} diff --git a/internal/projectinstance/gate.go b/internal/projectinstance/gate.go index f5d95f1a..5eefd7c5 100644 --- a/internal/projectinstance/gate.go +++ b/internal/projectinstance/gate.go @@ -25,7 +25,7 @@ func evaluateStageReadiness( continue } td, hasDef := taskDefMap[dt.TaskKey] - required := !hasDef || td.Required || td.Blocking + required := !hasDef || td.BlocksWhenEnabled() state := docStates[dt.TaskKey] if state == "" { state = "missing" diff --git a/internal/projectinstance/service.go b/internal/projectinstance/service.go index d442b14e..2fa513be 100644 --- a/internal/projectinstance/service.go +++ b/internal/projectinstance/service.go @@ -95,16 +95,20 @@ func (s *Service) GetSettings(ctx context.Context, projectID int64, userID int64 defs, listErr := s.wfStore.ListDefinitions(ctx) if listErr == nil { for _, d := range defs { - if d.LatestPublishedVersion <= 0 { + if d.LatestPublishedVersion <= 0 || !workflowdef.IsOfficialWorkflowKey(d.Key) { continue } ver, verErr := s.wfStore.GetVersion(ctx, d.ID, d.LatestPublishedVersion) if verErr != nil || ver == nil { continue } + name := d.Name + if name == "" || name == d.Key { + name = workflowdef.OfficialWorkflowName(d.Key) + } settings.PublishedWorkflows = append(settings.PublishedWorkflows, PublishedOption{ DefinitionKey: d.Key, - DefinitionName: d.Name, + DefinitionName: name, LatestVersion: d.LatestPublishedVersion, WorkflowVersion: ver.ID, ProductDomain: d.ProductDomain, @@ -147,10 +151,11 @@ func (s *Service) SaveWorkflowPlan(ctx context.Context, projectID int64, userID } validation := validatePlan(in.Plan, version) if !validation.Valid { - return nil, fmt.Errorf("%w: %d issues", ErrValidationFailed, len(validation.Issues)) + return nil, newValidationError(validation) } in.Plan.WorkflowVersionID = version.ID + applyDefinitionLabels(&in.Plan, version) planHash, err := computePlanHash(in.Plan) if err != nil { return nil, err @@ -197,9 +202,9 @@ func (s *Service) ActivationPreview(ctx context.Context, projectID int64, userID if err != nil { return nil, err } - validation := validatePlan(planInst.ConfigSnapshot, version) + validation := validateActivationPlan(planInst.ConfigSnapshot, version) if !validation.Valid { - return nil, fmt.Errorf("%w", ErrValidationFailed) + return nil, newValidationError(validation) } token, err := randomToken(24) @@ -264,12 +269,12 @@ func (s *Service) Activate(ctx context.Context, projectID int64, userID int64, i 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.ActivateInstance(ctx, projectID, planInst, version, selVer, userID) if err != nil { return nil, err } @@ -306,6 +311,9 @@ func (s *Service) BuildDefaultPlan(ctx context.Context, workflowKey string, vers if s.wfStore == nil { return nil, ErrNotAvailable } + if !workflowdef.IsOfficialWorkflowKey(workflowKey) { + return nil, ErrWorkflowVersionNotFound + } def, err := s.wfStore.GetDefinitionByKey(ctx, workflowKey) if err != nil { return nil, ErrWorkflowVersionNotFound diff --git a/internal/projectinstance/stage_access.go b/internal/projectinstance/stage_access.go index 89030780..b3d4b4e7 100644 --- a/internal/projectinstance/stage_access.go +++ b/internal/projectinstance/stage_access.go @@ -24,7 +24,12 @@ 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 } diff --git a/internal/projectinstance/stage_store.go b/internal/projectinstance/stage_store.go index 0d1af835..1942bdbe 100644 --- a/internal/projectinstance/stage_store.go +++ b/internal/projectinstance/stage_store.go @@ -117,16 +117,11 @@ func (s *pgStore) AdvanceStage(ctx context.Context, instanceID int64, stageKey s if err != nil { return nil, err } - _, err = tx.Exec(ctx, ` - UPDATE project_doc_task_instances SET status = 'approved', updated_at = $2 - WHERE stage_instance_id = $1 AND enabled = true AND status <> 'skipped' - `, stageID, now) - if err != nil { - return nil, err - } - var nextStageID int64 var nextStageKey string + var projectID int64 + _ = tx.QueryRow(ctx, `SELECT project_id FROM project_workflow_instances WHERE id = $1`, instanceID).Scan(&projectID) + err = tx.QueryRow(ctx, ` SELECT id, stage_key FROM project_stage_instances WHERE workflow_instance_id = $1 AND enabled = true AND status = 'pending' @@ -134,12 +129,24 @@ func (s *pgStore) AdvanceStage(ctx context.Context, instanceID int64, stageKey s `, instanceID).Scan(&nextStageID, &nextStageKey) if errors.Is(err, pgx.ErrNoRows) { _, err = tx.Exec(ctx, ` - UPDATE project_workflow_instances SET status = 'completed', config_revision = config_revision + 1, updated_at = $2 + UPDATE project_workflow_instances SET status = 'completed', config_revision = config_revision + 1, + completed_at = $2, completed_by = ( + SELECT activated_by FROM project_workflow_instances WHERE id = $1 + ), updated_at = $2 WHERE id = $1 `, instanceID, now) if err != nil { return nil, err } + if projectID > 0 { + _, err = tx.Exec(ctx, ` + UPDATE projects SET lifecycle_status = 'planning', active_workflow_key = '', updated_at = $2 + WHERE id = $1 + `, projectID, now) + if err != nil { + return nil, err + } + } } else if err != nil { return nil, err } else { diff --git a/internal/projectinstance/store.go b/internal/projectinstance/store.go index a7ab972e..942ce7c4 100644 --- a/internal/projectinstance/store.go +++ b/internal/projectinstance/store.go @@ -2,6 +2,8 @@ package projectinstance import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -10,6 +12,8 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + + "chat-agent-system/internal/workflowdef" ) // TablesReady 检查 Phase 3 表是否已迁移。 @@ -37,7 +41,8 @@ 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) + 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) @@ -252,11 +257,15 @@ func (s *pgStore) replaceInstanceChildren(ctx context.Context, tx pgx.Tx, inst * 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 } @@ -282,7 +291,11 @@ 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) { +func (s *pgStore) ActivateInstance(ctx context.Context, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64) (*WorkflowInstance, error) { + workflowKey := "" + if version != nil { + workflowKey = version.WorkflowKey + } tx, err := s.pool.Begin(ctx) if err != nil { return nil, err @@ -334,22 +347,99 @@ 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 } +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 @@ -357,7 +447,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 } @@ -369,7 +459,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 } @@ -381,6 +471,43 @@ 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 + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return inst, nil +} + // ============================================================================= // 内存 Store(单元测试 / 无 PG 迁移) // ============================================================================= @@ -579,7 +706,11 @@ func (m *memStore) UpsertPlanningInstance(_ context.Context, inst *WorkflowInsta return ©Inst, nil } -func (m *memStore) ActivateInstance(_ context.Context, projectID int64, inst *WorkflowInstance, workflowKey string, expectedVersion int, activatedBy int64) (*WorkflowInstance, error) { +func (m *memStore) 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) @@ -649,11 +780,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 { @@ -665,6 +791,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 } @@ -693,10 +824,31 @@ func (m *memStore) MarkDocTaskApproved(_ context.Context, projectID int64, taskK return nil } +func (m *memStore) TerminateInstance(_ context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error) { + m.mu.Lock() + defer m.mu.Unlock() + inst := m.live[projectID] + if inst == nil || inst.ID != instanceID { + return nil, ErrInstanceNotFound + } + now := time.Now() + inst.Status = "terminated" + copyInst := *inst + delete(m.live, projectID) + pm := m.ensureMeta(projectID) + pm.lifecycle = LifecyclePlanning + pm.activeKey = "" + m.meta[projectID] = pm + _ = terminatedBy + _ = reason + _ = now + return ©Inst, nil +} + func (m *memStore) SaveActivationToken(_ context.Context, token string, projectID int64, planHash string, expectedVersion int, snapshot WorkflowPlan, _ int64, expiresAt time.Time) error { m.mu.Lock() defer m.mu.Unlock() - m.tokens[token] = activationTokenRec{ + m.tokens[hashActivationToken(token)] = activationTokenRec{ projectID: projectID, planHash: planHash, expectedVer: expectedVersion, snapshot: snapshot, expiresAt: expiresAt, } @@ -706,12 +858,13 @@ func (m *memStore) SaveActivationToken(_ context.Context, token string, projectI func (m *memStore) ConsumeActivationToken(_ context.Context, token string, projectID int64, planHash string) (*WorkflowPlan, error) { m.mu.Lock() defer m.mu.Unlock() - rec, ok := m.tokens[token] + tokenHash := hashActivationToken(token) + rec, ok := m.tokens[tokenHash] if !ok || rec.used || rec.projectID != projectID || rec.planHash != planHash || time.Now().After(rec.expiresAt) { return nil, ErrTokenInvalid } rec.used = true - m.tokens[token] = rec + m.tokens[tokenHash] = rec plan := rec.snapshot return &plan, nil } diff --git a/internal/projectinstance/terminate.go b/internal/projectinstance/terminate.go new file mode 100644 index 00000000..fffcaeb0 --- /dev/null +++ b/internal/projectinstance/terminate.go @@ -0,0 +1,43 @@ +package projectinstance + +import "context" + +// 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"` +} + +func (s *Service) TerminateInstance(ctx context.Context, instanceID int64, projectID int64, userID int64, isCreator bool, in TerminateInput) (*TerminateResult, error) { + if !isCreator { + return nil, ErrForbiddenNotCreator + } + if !in.Confirm { + return nil, ErrTerminationNotAllowed + } + if in.Reason == "" { + return nil, ErrValidationFailed + } + if !s.Available() { + return nil, ErrNotAvailable + } + live, err := s.store.GetLiveInstance(ctx, projectID) + if err != nil || live == nil || live.ID != instanceID { + return nil, ErrInstanceNotFound + } + if in.ExpectedConfigRevision > 0 && live.ConfigRevision != in.ExpectedConfigRevision { + return nil, ErrInstanceRevisionConflict + } + inst, err := s.store.TerminateInstance(ctx, instanceID, projectID, userID, in.Reason) + if err != nil { + return nil, err + } + return &TerminateResult{Instance: *inst, Reason: in.Reason}, nil +} diff --git a/internal/projectinstance/types.go b/internal/projectinstance/types.go index c317c027..595f1736 100644 --- a/internal/projectinstance/types.go +++ b/internal/projectinstance/types.go @@ -27,16 +27,18 @@ type DocDuty struct { // DocTaskPlan 文档任务裁剪与职责。 type DocTaskPlan struct { - TaskKey string `json:"task_key"` - Enabled bool `json:"enabled"` - Duties DocDuty `json:"duties"` + TaskKey string `json:"task_key"` + TaskTitle string `json:"task_title,omitempty"` + Enabled bool `json:"enabled"` + Duties DocDuty `json:"duties"` } // StagePlan 阶段裁剪与文档任务。 type StagePlan struct { - StageKey string `json:"stage_key"` - Enabled bool `json:"enabled"` - DocTasks []DocTaskPlan `json:"doc_tasks"` + StageKey string `json:"stage_key"` + StageName string `json:"stage_name,omitempty"` + Enabled bool `json:"enabled"` + DocTasks []DocTaskPlan `json:"doc_tasks"` } // WorkflowPlan 项目工作流规划快照。 @@ -99,31 +101,31 @@ type SavePlanInput struct { // ActivationPreview 激活预览与确认令牌。 type ActivationPreview struct { - ConfirmationToken string `json:"confirmation_token"` - PlanHash string `json:"plan_hash"` - ExpectedProjectVersion int `json:"expected_project_version"` - ExpectedPlanRevision int `json:"expected_plan_revision"` - Summary PreviewSummary `json:"summary"` - Validation workflowdef.ValidationResult `json:"validation"` - ExpiresAt time.Time `json:"expires_at"` + ConfirmationToken string `json:"confirmation_token"` + PlanHash string `json:"plan_hash"` + ExpectedProjectVersion int `json:"expected_project_version"` + ExpectedPlanRevision int `json:"expected_plan_revision"` + Summary PreviewSummary `json:"summary"` + Validation workflowdef.ValidationResult `json:"validation"` + ExpiresAt time.Time `json:"expires_at"` } // PreviewSummary 激活影响摘要。 type PreviewSummary struct { - WorkflowName string `json:"workflow_name"` - WorkflowVersion int `json:"workflow_version"` - EnabledStages int `json:"enabled_stages"` - EnabledDocTasks int `json:"enabled_doc_tasks"` - AssignedDuties int `json:"assigned_duties"` - FirstStageKey string `json:"first_stage_key,omitempty"` - FirstStageName string `json:"first_stage_name,omitempty"` + WorkflowName string `json:"workflow_name"` + WorkflowVersion int `json:"workflow_version"` + EnabledStages int `json:"enabled_stages"` + EnabledDocTasks int `json:"enabled_doc_tasks"` + AssignedDuties int `json:"assigned_duties"` + FirstStageKey string `json:"first_stage_key,omitempty"` + FirstStageName string `json:"first_stage_name,omitempty"` } // ActivateInput 二次确认激活。 type ActivateInput struct { - ConfirmationToken string `json:"confirmation_token"` - ExpectedProjectVersion int `json:"expected_project_version"` - ExpectedPlanRevision int `json:"expected_plan_revision"` + ConfirmationToken string `json:"confirmation_token"` + ExpectedProjectVersion int `json:"expected_project_version"` + ExpectedPlanRevision int `json:"expected_plan_revision"` } // ActivateResult 激活结果。 @@ -166,8 +168,8 @@ type StageInstance struct { // InstanceDetail 工作流实例详情(含阶段树)。 type InstanceDetail struct { Instance WorkflowInstance `json:"instance"` - ActiveStageKey string `json:"active_stage_key,omitempty"` - Stages []StageInstance `json:"stages"` + ActiveStageKey string `json:"active_stage_key,omitempty"` + Stages []StageInstance `json:"stages"` } // GateIssue 阶段门禁阻断项。 @@ -188,14 +190,14 @@ type DocTaskReadiness struct { // StageReadiness 阶段推进就绪评估。 type StageReadiness struct { - InstanceID int64 `json:"instance_id"` - StageKey string `json:"stage_key"` - StageName string `json:"stage_name"` - Ready bool `json:"ready"` - CanAdvance bool `json:"can_advance"` - BlockingIssues []GateIssue `json:"blocking_issues"` - DocTasks []DocTaskReadiness `json:"doc_tasks"` - ConfigRevision int `json:"config_revision"` + InstanceID int64 `json:"instance_id"` + StageKey string `json:"stage_key"` + StageName string `json:"stage_name"` + Ready bool `json:"ready"` + CanAdvance bool `json:"can_advance"` + BlockingIssues []GateIssue `json:"blocking_issues"` + DocTasks []DocTaskReadiness `json:"doc_tasks"` + ConfigRevision int `json:"config_revision"` } // AdvanceStageInput 创建者确认推进。 @@ -206,10 +208,10 @@ type AdvanceStageInput struct { // AdvanceStageResult 阶段推进结果。 type AdvanceStageResult struct { - Instance WorkflowInstance `json:"instance"` - CompletedStage string `json:"completed_stage"` - CompletedStageInstanceID int64 `json:"completed_stage_instance_id,omitempty"` - CompletedReadinessRevision int `json:"completed_readiness_revision,omitempty"` - NextStageKey string `json:"next_stage_key,omitempty"` - InstanceDone bool `json:"instance_done"` + Instance WorkflowInstance `json:"instance"` + CompletedStage string `json:"completed_stage"` + CompletedStageInstanceID int64 `json:"completed_stage_instance_id,omitempty"` + CompletedReadinessRevision int `json:"completed_readiness_revision,omitempty"` + NextStageKey string `json:"next_stage_key,omitempty"` + InstanceDone bool `json:"instance_done"` } diff --git a/internal/projectinstance/validator.go b/internal/projectinstance/validator.go index 267ba113..8a02c720 100644 --- a/internal/projectinstance/validator.go +++ b/internal/projectinstance/validator.go @@ -21,6 +21,12 @@ func validatePlan(plan WorkflowPlan, version *workflowdef.Version) workflowdef.V Code: "WORKFLOW_NOT_PUBLISHED", Message: "只能绑定已发布的工作流版本", }) } + if !workflowdef.IsOfficialWorkflowKey(plan.WorkflowDefinitionKey) { + issues = append(issues, workflowdef.ValidationIssue{ + Code: "WORKFLOW_NOT_SELECTABLE", Field: "workflow_definition_key", + Message: "仅允许选择正式三工作流", + }) + } if plan.WorkflowDefinitionKey != version.WorkflowKey { issues = append(issues, workflowdef.ValidationIssue{ Code: "WORKFLOW_KEY_MISMATCH", Field: "workflow_definition_key", @@ -79,9 +85,9 @@ func validatePlan(plan WorkflowPlan, version *workflowdef.Version) workflowdef.V }) continue } - if (td.Required || td.Blocking) && !dt.Enabled { + if !dt.Enabled && !td.IsCreatorConfigurable() { issues = append(issues, workflowdef.ValidationIssue{ - Code: "REQUIRED_DOC_DISABLED", Field: dt.TaskKey, Message: "必需/阻断文档任务不能关闭", + Code: "REQUIRED_DOC_DISABLED", Field: dt.TaskKey, Message: "该文档任务不可由创建者关闭", }) } if dt.Enabled { @@ -102,45 +108,38 @@ func validatePlan(plan WorkflowPlan, version *workflowdef.Version) workflowdef.V }) } - // 反向完整性:定义中的必需阶段/任务必须在规划中出现且启用 - planStageMap := make(map[string]StagePlan, len(plan.Stages)) - for _, st := range plan.Stages { - planStageMap[st.StageKey] = st - } - for _, def := range version.Definition.Stages { - if def.Optional { - continue - } - ps, ok := planStageMap[def.Key] - if !ok || !ps.Enabled { - issues = append(issues, workflowdef.ValidationIssue{ - Code: "MISSING_REQUIRED_STAGE", Field: def.Key, - Message: "必需阶段未启用或缺失", - }) - continue - } - planTaskEnabled := map[string]bool{} - for _, dt := range ps.DocTasks { - if dt.Enabled { - planTaskEnabled[dt.TaskKey] = true - } - } - for _, td := range def.DocTasks { - if !(td.Required || td.Blocking) { - continue - } - if !planTaskEnabled[td.Key] { - issues = append(issues, workflowdef.ValidationIssue{ - Code: "MISSING_REQUIRED_TASK", Field: td.Key, - Message: "必需/阻断文档任务未启用或缺失", - }) - } - } - } - 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 { @@ -188,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) @@ -203,6 +204,35 @@ func buildDefaultPlan(version *workflowdef.Version) WorkflowPlan { return plan } +// applyDefinitionLabels 使用已发布定义覆盖展示名称,避免客户端把自定义文本写入冻结快照。 +func applyDefinitionLabels(plan *WorkflowPlan, version *workflowdef.Version) { + if plan == nil || version == nil { + return + } + stageDefs := make(map[string]workflowdef.StageDef, len(version.Definition.Stages)) + for _, stage := range version.Definition.Stages { + stageDefs[stage.Key] = stage + } + for stageIdx := range plan.Stages { + stage := &plan.Stages[stageIdx] + stageDef, ok := stageDefs[stage.StageKey] + if !ok { + continue + } + stage.StageName = stageDef.Name + taskDefs := make(map[string]workflowdef.DocTaskDef, len(stageDef.DocTasks)) + for _, task := range stageDef.DocTasks { + taskDefs[task.Key] = task + } + for taskIdx := range stage.DocTasks { + task := &stage.DocTasks[taskIdx] + if taskDef, exists := taskDefs[task.TaskKey]; exists { + task.TaskTitle = taskDef.Title + } + } + } +} + func previewSummary(plan WorkflowPlan, version *workflowdef.Version) PreviewSummary { sum := PreviewSummary{ WorkflowVersion: plan.WorkflowVersion, diff --git a/internal/projectworkflow/service.go b/internal/projectworkflow/service.go index 35e65fb8..47db21ff 100644 --- a/internal/projectworkflow/service.go +++ b/internal/projectworkflow/service.go @@ -121,6 +121,11 @@ func (s *Service) ListProjects(ctx context.Context, userID int64) ([]*Project, e return s.store.ListProjectsForUser(ctx, userID) } +// ListAllProjects 查询全部项目,仅供已完成系统管理员鉴权的调用方使用。 +func (s *Service) ListAllProjects(ctx context.Context) ([]*Project, error) { + return s.store.ListAllProjects(ctx) +} + // IsProjectCreator 判断用户是否为指定项目的权威创建人 func (s *Service) IsProjectCreator(ctx context.Context, projectID int64, userID int64) (bool, error) { p, err := s.store.GetProjectByID(ctx, projectID) diff --git a/internal/projectworkflow/store.go b/internal/projectworkflow/store.go index 594a58e0..41aee3a2 100644 --- a/internal/projectworkflow/store.go +++ b/internal/projectworkflow/store.go @@ -17,6 +17,7 @@ type Store interface { CreateProject(ctx context.Context, p *Project) (*Project, error) GetProjectByID(ctx context.Context, id int64) (*Project, error) GetProjectByCode(ctx context.Context, code string) (*Project, error) + ListAllProjects(ctx context.Context) ([]*Project, error) ListProjectsForUser(ctx context.Context, userID int64) ([]*Project, error) ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error) EnsureProjectWorkflows(ctx context.Context, projectID int64, defaultKeys []string) error @@ -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 @@ -660,6 +693,17 @@ func (m *memStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Pr return list, nil } +func (m *memStore) ListAllProjects(ctx context.Context) ([]*Project, error) { + m.mu.RLock() + defer m.mu.RUnlock() + list := make([]*Project, 0, len(m.projects)) + for _, p := range m.projects { + cpy := *p + list = append(list, &cpy) + } + return list, nil +} + func (m *memStore) ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error) { m.mu.RLock() defer m.mu.RUnlock() diff --git a/internal/workflow/registry.go b/internal/workflow/registry.go index 8b5cd66b..9481a38b 100644 --- a/internal/workflow/registry.go +++ b/internal/workflow/registry.go @@ -4,14 +4,16 @@ import "fmt" // ExecutorRegistry 执行器注册表(schema v2 发布校验与运行时解析)。 var ExecutorRegistry = map[string]struct{}{ - "bizdev.stage.v1": {}, - "squad.system_reqs.v1": {}, - "squad.test_manager.v1": {}, - "bizdev.intake.v1": {}, - "bizdev.research.v1": {}, - "bizdev.feasibility.v1": {}, - "bizdev.prelim_design.v1": {}, - "bizdev.cdo_quote.v1": {}, + "bizdev.stage.v1": {}, + "squad.system_reqs.v1": {}, + "squad.test_manager.v1": {}, + "bizdev.intake.v1": {}, + "bizdev.research.v1": {}, + "bizdev.feasibility.v1": {}, + "bizdev.feasibility_dev.v1": {}, + "bizdev.user_requirements.v1": {}, + "bizdev.prelim_design.v1": {}, + "bizdev.cdo_quote.v1": {}, } // QualityGateRegistry 确定性质量门禁注册表。 diff --git a/internal/workflowdef/bootstrap.go b/internal/workflowdef/bootstrap.go index aa34e495..f4ab5fbc 100644 --- a/internal/workflowdef/bootstrap.go +++ b/internal/workflowdef/bootstrap.go @@ -8,32 +8,32 @@ import ( "github.com/jackc/pgx/v5/pgxpool" - "chat-agent-system/internal/projectworkflow" "chat-agent-system/internal/storage" ) -func legacySingleStage(key, name, desc, stageKey, taskKey, docType, docTitle, upstream string) CreateDefinitionInput { - return CreateDefinitionInput{ - Key: key, - Name: name, - Description: desc, - ProductDomain: "medical_device", - Definition: VersionDefinition{ - SchemaVersion: 1, - RequiredUpstreamWorkflow: upstream, - Stages: []StageDef{{ - Key: stageKey, Name: stageKey, Position: 0, - DocTasks: []DocTaskDef{{ - Key: taskKey, Title: docTitle, Required: true, Blocking: true, DefaultEnabled: true, - OutputDocType: docType, OutputDocTitle: docTitle, - TemplateAssetKey: taskKey, TemplateVersion: 1, - }}, - }}, - }, - } +var officialSeedMeta = map[string]struct { + Name string + Description string + Domain string +}{ + OfficialKeyMain: { + Name: "商务对接流程", + Description: "V0.1 需求摸底 → V0.5 方案与可行性 → V1.0 商务报价(7 个受控文档)", + Domain: "medical_device", + }, + OfficialKeyProductManager: { + Name: "智能产品经理流程", + Description: "基于项目上下文独立生成系统需求规格书", + Domain: "medical_device", + }, + OfficialKeyTestManager: { + Name: "智能测试工程师流程", + Description: "基于项目上下文独立生成系统验证方案及计划", + Domain: "medical_device", + }, } -// BootstrapSeeds 幂等导入静态 catalog、首批行业工作流与 templates/docs。 +// BootstrapSeeds 幂等导入模板与三正式 v2 工作流;非正式定义标记为不可选。 func BootstrapSeeds(ctx context.Context, pool *pgxpool.Pool, cas *storage.CAS, templatesDir string, publishedBy int64) error { if pool == nil || !TablesReady(ctx, pool) { return nil @@ -60,80 +60,44 @@ func BootstrapSeeds(ctx context.Context, pool *pgxpool.Pool, cas *storage.CAS, t _, _ = svc.UploadTemplate(ctx, key, ent.Name(), data, ent.Name(), publishedBy) } - seeds := []CreateDefinitionInput{ - legacySingleStage("main", "商务与研发综合主线", "V0.1 分诊到 V0.6 阶段评审", "main", "main", "main_proposal", "商务研发综合方案", ""), - legacySingleStage("product_manager", "智能产品经理", "系统需求规格书生成", "system_reqs", "system_reqs", "system_reqs", "系统需求规格书", ""), - legacySingleStage("test_manager", "智能测试经理", "系统验证方案及计划", "system_test_plan", "test_manager", "system_test_plan", "系统验证方案及计划", "product_manager"), - medicalDeviceWorkflow(), - nonMedicalSoftwareWorkflow(), - } - for _, seed := range seeds { - if err := store.UpsertPublishedSeed(ctx, seed, publishedBy); err != nil { + for _, key := range OfficialWorkflowKeys() { + meta := officialSeedMeta[key] + filename := V2ManifestFiles[key] + def, loadErr := LoadManifestDefinition(filename) + if loadErr != nil { + return loadErr + } + normalizeManifestDefinition(&def) + if err := store.UpsertPublishedSeed(ctx, CreateDefinitionInput{ + Key: key, + Name: meta.Name, + Description: meta.Description, + ProductDomain: meta.Domain, + Definition: def, + }, publishedBy); err != nil { return err } } - return nil + + return store.RetireNonOfficialDefinitions(ctx) } -func medicalDeviceWorkflow() CreateDefinitionInput { - return CreateDefinitionInput{ - Key: "medical_device_development", - Name: "医疗器械研发", - Description: "商务摸底 → 需求策划 → 设计开发 → 验证确认 → 设计转移", - ProductDomain: "medical_device", - Definition: VersionDefinition{ - SchemaVersion: 1, - Stages: []StageDef{ - stage("biz_intake", "商务摸底", 0, doc("intake", "客户需求采集", "intake", "客户需求采集表")), - stage("req_planning", "需求与法规策划", 1, doc("system_reqs", "系统需求规格书", "system_reqs", "系统需求规格书")), - stage("design_dev", "设计开发", 2, doc("prelim_design", "初步设计", "prelim_design", "初步设计文档")), - stage("verification", "设计验证/确认", 3, doc("test_manager", "系统验证方案", "system_test_plan", "系统验证方案及计划")), - stage("transfer", "设计转移/发布", 4, doc("feasibility_reg", "法规可行性", "feasibility_reg", "法规可行性评估")), - }, - }, +func normalizeManifestDefinition(def *VersionDefinition) { + if def == nil { + return + } + def.RequiredUpstreamWorkflow = "" + for i := range def.Stages { + for j := range def.Stages[i].DocTasks { + dt := &def.Stages[i].DocTasks[j] + if dt.CreatorConfigurable || dt.BlockingWhenEnabled || dt.DefaultEnabled { + continue + } + if dt.Required || dt.Blocking { + dt.CreatorConfigurable = true + dt.BlockingWhenEnabled = true + dt.DefaultEnabled = true + } + } } } - -func nonMedicalSoftwareWorkflow() CreateDefinitionInput { - return CreateDefinitionInput{ - Key: "non_medical_software", - Name: "非医疗纯软件", - Description: "机会范围 → 需求 → 架构 → 实现 → 测试验收 → 发布复盘", - ProductDomain: "non_medical_software", - Definition: VersionDefinition{ - SchemaVersion: 1, - Stages: []StageDef{ - stage("opportunity", "机会与范围", 0, doc("intake", "范围说明", "scope_doc", "项目范围说明")), - stage("requirements", "产品需求", 1, doc("user_requirements", "用户需求", "user_requirements", "用户需求说明")), - stage("architecture", "架构与计划", 2, doc("feasibility_dev", "技术可行性", "feasibility_dev", "技术可行性分析")), - stage("implementation", "实现", 3, doc("prelim_design", "架构设计", "architecture_design", "架构设计文档")), - stage("testing", "测试与验收", 4, doc("test_manager", "测试计划", "test_plan", "测试计划")), - stage("release", "发布复盘", 5, doc("research", "发布复盘", "release_review", "发布复盘报告")), - }, - }, - } -} - -func stage(key, name string, pos int, tasks ...DocTaskDef) StageDef { - return StageDef{Key: key, Name: name, Position: pos, DocTasks: tasks} -} - -func doc(key, title, docType, docTitle string) DocTaskDef { - return DocTaskDef{ - Key: key, Title: title, Required: true, Blocking: true, DefaultEnabled: true, - TemplateAssetKey: key, TemplateVersion: 1, - OutputDocType: docType, OutputDocTitle: docTitle, - } -} - -// SeedFromMemoryCatalog 将内存 catalog 转为种子(测试用)。 -func SeedFromMemoryCatalog(cat projectworkflow.Catalog) []CreateDefinitionInput { - if cat == nil { - return nil - } - out := make([]CreateDefinitionInput, 0) - for _, d := range cat.List() { - out = append(out, legacySingleStage(d.Key, d.Name, d.Description, d.StageKey, d.TaskKey, d.OutputDocType, d.OutputDocTitle, d.RequiredUpstreamWorkflow)) - } - return out -} diff --git a/internal/workflowdef/catalog_policy.go b/internal/workflowdef/catalog_policy.go new file mode 100644 index 00000000..13f2edf0 --- /dev/null +++ b/internal/workflowdef/catalog_policy.go @@ -0,0 +1,53 @@ +package workflowdef + +// 正式项目可选工作流(唯一对外目录)。 +const ( + OfficialKeyMain = "main" + OfficialKeyProductManager = "product_manager" + OfficialKeyTestManager = "test_manager" +) + +var officialWorkflowKeys = map[string]struct{}{ + OfficialKeyMain: {}, + OfficialKeyProductManager: {}, + OfficialKeyTestManager: {}, +} + +var officialWorkflowNames = map[string]string{ + OfficialKeyMain: "商务对接流程", + OfficialKeyProductManager: "智能产品经理流程", + OfficialKeyTestManager: "智能测试工程师流程", +} + +var officialCatalogOrder = map[string]int{ + OfficialKeyMain: 10, + OfficialKeyProductManager: 20, + OfficialKeyTestManager: 30, +} + +// IsOfficialWorkflowKey 是否为三正式工作流 key。 +func IsOfficialWorkflowKey(key string) bool { + _, ok := officialWorkflowKeys[key] + return ok +} + +// OfficialWorkflowName 返回正式对外名称。 +func OfficialWorkflowName(key string) string { + if name, ok := officialWorkflowNames[key]; ok { + return name + } + return key +} + +// OfficialCatalogOrder 返回目录排序权重。 +func OfficialCatalogOrder(key string) int { + if order, ok := officialCatalogOrder[key]; ok { + return order + } + return 9999 +} + +// OfficialWorkflowKeys 返回三正式 key 列表(稳定顺序)。 +func OfficialWorkflowKeys() []string { + return []string{OfficialKeyMain, OfficialKeyProductManager, OfficialKeyTestManager} +} diff --git a/internal/workflowdef/catalog_store.go b/internal/workflowdef/catalog_store.go new file mode 100644 index 00000000..eb9bf1a9 --- /dev/null +++ b/internal/workflowdef/catalog_store.go @@ -0,0 +1,68 @@ +package workflowdef + +import "context" + +// ListProjectSelectableDefinitions 返回项目可选的已发布正式工作流(严格三项白名单)。 +func (s *Store) ListProjectSelectableDefinitions(ctx context.Context) ([]Definition, error) { + if !s.enabled() { + return nil, ErrNotAvailable + } + rows, err := s.pool.Query(ctx, ` + SELECT id, def_key, name, description, status, latest_published_version, product_domain, + COALESCE(project_selectable, false), COALESCE(catalog_order, 0), created_at, updated_at + FROM workflow_definitions + WHERE def_key = ANY($1) AND latest_published_version > 0 + ORDER BY COALESCE(catalog_order, 0) ASC, def_key ASC + `, OfficialWorkflowKeys()) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]Definition, 0, 3) + for rows.Next() { + var d Definition + if err := rows.Scan(&d.ID, &d.Key, &d.Name, &d.Description, &d.Status, &d.LatestPublishedVersion, &d.ProductDomain, + &d.ProjectSelectable, &d.CatalogOrder, &d.CreatedAt, &d.UpdatedAt); err != nil { + return nil, err + } + if !IsOfficialWorkflowKey(d.Key) { + continue + } + if !d.ProjectSelectable { + d.ProjectSelectable = true + } + if d.Name == "" || d.Name == d.Key { + d.Name = OfficialWorkflowName(d.Key) + } + out = append(out, d) + } + return out, rows.Err() +} + +// RetireNonOfficialDefinitions 将非三正式定义标记为不可选。 +func (s *Store) RetireNonOfficialDefinitions(ctx context.Context) error { + if !s.enabled() { + return ErrNotAvailable + } + _, err := s.pool.Exec(ctx, ` + UPDATE workflow_definitions + SET project_selectable = false, status = 'retired', updated_at = now() + WHERE def_key <> ALL($1) + `, OfficialWorkflowKeys()) + if err != nil { + return err + } + _, err = s.pool.Exec(ctx, ` + UPDATE workflow_definitions + SET project_selectable = true, + catalog_order = CASE def_key + WHEN 'main' THEN 10 + WHEN 'product_manager' THEN 20 + WHEN 'test_manager' THEN 30 + ELSE catalog_order + END, + updated_at = now() + WHERE def_key = ANY($1) + `, OfficialWorkflowKeys()) + return err +} diff --git a/internal/workflowdef/manifests/main.v2.json b/internal/workflowdef/manifests/main.v2.json index bf49508f..d6b78a76 100644 --- a/internal/workflowdef/manifests/main.v2.json +++ b/internal/workflowdef/manifests/main.v2.json @@ -5,46 +5,49 @@ "stages": [ { "key": "V0.1_triage", - "name": "闪电摸底与盲区探测", + "name": "V0.1 需求摸底", "position": 10, "optional": false, "doc_tasks": [{ "key": "intake", - "title": "客户需求采集", - "required": true, - "blocking": true, + "title": "用户初始需求收集表", "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, "executor_key": "bizdev.intake.v1", "quality_gate_key": "quality.generic.v1", "output_doc_type": "intake", - "output_doc_title": "初始需求表" + "output_doc_title": "用户初始需求收集表" }] }, { "key": "V0.5_feasibility", - "name": "方案成型与法规对齐", + "name": "V0.5 方案与可行性", "position": 20, "optional": false, "doc_tasks": [ - {"key": "research", "title": "竞品调研", "required": true, "blocking": true, "default_enabled": true, "executor_key": "bizdev.research.v1", "output_doc_type": "research", "output_doc_title": "竞品调研报告"}, - {"key": "feasibility_reg", "title": "法规可行性", "required": true, "blocking": true, "default_enabled": true, "executor_key": "bizdev.feasibility.v1", "output_doc_type": "feasibility_reg", "output_doc_title": "法规可行性分析"}, - {"key": "prelim_design", "title": "初步设计", "required": true, "blocking": true, "default_enabled": true, "executor_key": "bizdev.prelim_design.v1", "output_doc_type": "prelim_design", "output_doc_title": "初步设计说明"} + {"key": "research", "title": "调研报告", "default_enabled": true, "creator_configurable": true, "blocking_when_enabled": true, "executor_key": "bizdev.research.v1", "quality_gate_key": "quality.generic.v1", "output_doc_type": "research", "output_doc_title": "调研报告"}, + {"key": "feasibility_dev", "title": "设计开发可行性评估报告", "default_enabled": true, "creator_configurable": true, "blocking_when_enabled": true, "executor_key": "bizdev.feasibility_dev.v1", "quality_gate_key": "quality.generic.v1", "output_doc_type": "feasibility_dev", "output_doc_title": "设计开发可行性评估报告"}, + {"key": "feasibility_reg", "title": "注册路径可行性评估报告", "default_enabled": true, "creator_configurable": true, "blocking_when_enabled": true, "executor_key": "bizdev.feasibility.v1", "quality_gate_key": "quality.generic.v1", "output_doc_type": "feasibility_reg", "output_doc_title": "注册路径可行性评估报告"}, + {"key": "user_requirements", "title": "用户需求表", "default_enabled": true, "creator_configurable": true, "blocking_when_enabled": true, "executor_key": "bizdev.user_requirements.v1", "quality_gate_key": "quality.generic.v1", "output_doc_type": "user_requirements", "output_doc_title": "用户需求表"}, + {"key": "prelim_design", "title": "系统初步设计方案", "default_enabled": true, "creator_configurable": true, "blocking_when_enabled": true, "executor_key": "bizdev.prelim_design.v1", "quality_gate_key": "quality.generic.v1", "output_doc_type": "prelim_design", "output_doc_title": "系统初步设计方案"} ] }, { "key": "V1.0_quoting", - "name": "工程拆解与商业锁定", + "name": "V1.0 商务报价", "position": 30, "optional": false, "doc_tasks": [{ "key": "cdo_quote", - "title": "CDO 报价与核算", - "required": true, - "blocking": true, + "title": "CDO 报价单", "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, "executor_key": "bizdev.cdo_quote.v1", + "quality_gate_key": "quality.generic.v1", "output_doc_type": "cdo_quote", - "output_doc_title": "CDO 报价核算" + "output_doc_title": "CDO 报价单" }] } ] diff --git a/internal/workflowdef/manifests/product_manager.v2.json b/internal/workflowdef/manifests/product_manager.v2.json index 640ab211..ad87d2d4 100644 --- a/internal/workflowdef/manifests/product_manager.v2.json +++ b/internal/workflowdef/manifests/product_manager.v2.json @@ -10,9 +10,9 @@ "doc_tasks": [{ "key": "system_reqs", "title": "系统需求规格书", - "required": true, - "blocking": true, "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, "executor_key": "squad.system_reqs.v1", "quality_gate_key": "quality.system_reqs.v2", "output_doc_type": "system_reqs", diff --git a/internal/workflowdef/manifests/test_manager.v2.json b/internal/workflowdef/manifests/test_manager.v2.json index 2a9bf385..a55e49f1 100644 --- a/internal/workflowdef/manifests/test_manager.v2.json +++ b/internal/workflowdef/manifests/test_manager.v2.json @@ -2,7 +2,6 @@ "schema_version": 2, "workflow_key": "test_manager", "product_domains": ["medical_device", "non_medical_software"], - "required_upstream_workflow": "product_manager", "stages": [{ "key": "system_verification_planning", "name": "系统验证策划", @@ -11,9 +10,9 @@ "doc_tasks": [{ "key": "test_manager", "title": "系统验证方案及计划", - "required": true, - "blocking": true, "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, "executor_key": "squad.test_manager.v1", "quality_gate_key": "quality.test_manager.v2", "output_doc_type": "test_manager", diff --git a/internal/workflowdef/store.go b/internal/workflowdef/store.go index 22adf693..7743defd 100644 --- a/internal/workflowdef/store.go +++ b/internal/workflowdef/store.go @@ -361,17 +361,14 @@ func (s *Store) ListPublishedCatalogEntries(ctx context.Context) ([]Version, err return out, rows.Err() } -// UpsertPublishedSeed 幂等写入已发布种子(bootstrap)。 +// UpsertPublishedSeed 按内容 hash 幂等发布种子;已有版本但 hash 变化时递增新版本。 func (s *Store) UpsertPublishedSeed(ctx context.Context, in CreateDefinitionInput, publishedBy int64) error { if !s.enabled() { return ErrNotAvailable } - existing, err := s.GetDefinitionByKey(ctx, in.Key) - if err == nil && existing != nil && existing.LatestPublishedVersion > 0 { - return nil - } - if err != nil && !errors.Is(err, ErrNotFound) { - return err + validation := ValidateVersionBySchema(in.Definition) + if !validation.Valid { + return fmt.Errorf("seed validation failed: %d issues", len(validation.Issues)) } defHash, err := hashDefinition(in.Definition) @@ -380,6 +377,11 @@ func (s *Store) UpsertPublishedSeed(ctx context.Context, in CreateDefinitionInpu } defRaw, _ := json.Marshal(in.Definition) + existing, err := s.GetDefinitionByKey(ctx, in.Key) + if err != nil && !errors.Is(err, ErrNotFound) { + return err + } + tx, err := s.pool.Begin(ctx) if err != nil { return err @@ -387,13 +389,38 @@ func (s *Store) UpsertPublishedSeed(ctx context.Context, in CreateDefinitionInpu defer tx.Rollback(ctx) var defID int64 + nextVersion := 1 if existing != nil { defID = existing.ID + if existing.LatestPublishedVersion > 0 { + var latestHash string + _ = tx.QueryRow(ctx, ` + SELECT definition_hash FROM workflow_versions + WHERE workflow_id = $1 AND version = $2 AND status = 'published' + `, defID, existing.LatestPublishedVersion).Scan(&latestHash) + if latestHash == defHash { + _, _ = tx.Exec(ctx, ` + UPDATE workflow_definitions SET name = $2, description = $3, project_selectable = true, + catalog_order = $4, updated_at = now() + WHERE id = $1 + `, defID, in.Name, in.Description, OfficialCatalogOrder(in.Key)) + return tx.Commit(ctx) + } + nextVersion = existing.LatestPublishedVersion + 1 + } + _, err = tx.Exec(ctx, ` + UPDATE workflow_definitions SET name = $2, description = $3, product_domain = $4, + project_selectable = true, catalog_order = $5, updated_at = now() + WHERE id = $1 + `, defID, in.Name, in.Description, in.ProductDomain, OfficialCatalogOrder(in.Key)) + if err != nil { + return err + } } else { err = tx.QueryRow(ctx, ` - INSERT INTO workflow_definitions (def_key, name, description, product_domain, latest_published_version) - VALUES ($1, $2, $3, $4, 1) RETURNING id - `, in.Key, in.Name, in.Description, in.ProductDomain).Scan(&defID) + INSERT INTO workflow_definitions (def_key, name, description, product_domain, latest_published_version, project_selectable, catalog_order) + VALUES ($1, $2, $3, $4, 0, true, $5) RETURNING id + `, in.Key, in.Name, in.Description, in.ProductDomain, OfficialCatalogOrder(in.Key)).Scan(&defID) if err != nil { return err } @@ -401,16 +428,16 @@ func (s *Store) UpsertPublishedSeed(ctx context.Context, in CreateDefinitionInpu _, err = tx.Exec(ctx, ` INSERT INTO workflow_versions (workflow_id, version, status, schema_version, definition_json, definition_hash, published_by, published_at, created_by) - VALUES ($1, 1, 'published', $2, $3, $4, $5, now(), $5) + VALUES ($1, $2, 'published', $3, $4, $5, $6, now(), $6) ON CONFLICT (workflow_id, version) DO NOTHING - `, defID, in.Definition.SchemaVersion, defRaw, defHash, publishedBy) + `, defID, nextVersion, in.Definition.SchemaVersion, defRaw, defHash, publishedBy) if err != nil { return err } _, err = tx.Exec(ctx, ` - UPDATE workflow_definitions SET latest_published_version = 1, updated_at = now() WHERE id = $1 - `, defID) + UPDATE workflow_definitions SET latest_published_version = $2, updated_at = now() WHERE id = $1 + `, defID, nextVersion) if err != nil { return err } diff --git a/internal/workflowdef/types.go b/internal/workflowdef/types.go index 0072f275..8ca20247 100644 --- a/internal/workflowdef/types.go +++ b/internal/workflowdef/types.go @@ -20,11 +20,13 @@ type TemplateRef struct { // DocTaskDef 阶段内文档任务定义。 type DocTaskDef struct { - Key string `json:"key"` - Title string `json:"title"` - Required bool `json:"required"` - Blocking bool `json:"blocking"` - DefaultEnabled bool `json:"default_enabled"` + Key string `json:"key"` + Title string `json:"title"` + Required bool `json:"required"` + Blocking bool `json:"blocking"` + DefaultEnabled bool `json:"default_enabled"` + CreatorConfigurable bool `json:"creator_configurable"` + BlockingWhenEnabled bool `json:"blocking_when_enabled"` TemplateAssetKey string `json:"template_asset_key,omitempty"` TemplateVersion int `json:"template_version,omitempty"` Template *TemplateRef `json:"template,omitempty"` @@ -63,11 +65,29 @@ type Definition struct { Description string `json:"description"` Status string `json:"status"` ProductDomain string `json:"product_domain,omitempty"` + ProjectSelectable bool `json:"project_selectable"` + CatalogOrder int `json:"catalog_order"` LatestPublishedVersion int `json:"latest_published_version"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } +// IsCreatorConfigurable 文档是否可由创建者关闭。 +func (dt DocTaskDef) IsCreatorConfigurable() bool { + if dt.CreatorConfigurable { + return true + } + return !dt.Required && !dt.Blocking +} + +// BlocksWhenEnabled 启用后是否阻断阶段推进。 +func (dt DocTaskDef) BlocksWhenEnabled() bool { + if dt.BlockingWhenEnabled { + return true + } + return dt.Required || dt.Blocking +} + // Version 工作流版本记录。 type Version struct { ID int64 `json:"id"` diff --git a/internal/workflowdef/validator_v2.go b/internal/workflowdef/validator_v2.go index 612fd5a1..a5240f69 100644 --- a/internal/workflowdef/validator_v2.go +++ b/internal/workflowdef/validator_v2.go @@ -6,13 +6,26 @@ import ( "chat-agent-system/internal/workflow" ) +var officialDocCounts = map[string]int{ + OfficialKeyMain: 7, + OfficialKeyProductManager: 1, + OfficialKeyTestManager: 1, +} + // ValidateDefinitionV2 schema v2 发布校验。 func ValidateDefinitionV2(def VersionDefinition) ValidationResult { issues := make([]ValidationIssue, 0) if def.SchemaVersion < 2 { issues = append(issues, ValidationIssue{Code: "SCHEMA_VERSION", Message: "schema_version must be >= 2"}) } + if def.RequiredUpstreamWorkflow != "" { + issues = append(issues, ValidationIssue{ + Code: "UPSTREAM_NOT_ALLOWED", Message: "正式工作流不得声明 required_upstream_workflow", + }) + } seenPos := map[int]bool{} + seenTask := map[string]bool{} + docCount := 0 for _, st := range def.Stages { if st.Key == "" { issues = append(issues, ValidationIssue{Code: "STAGE_KEY_REQUIRED", Message: "stage key required"}) @@ -25,7 +38,19 @@ func ValidateDefinitionV2(def VersionDefinition) ValidationResult { issues = append(issues, ValidationIssue{Code: "DUPLICATE_POSITION", Field: st.Key, Message: "duplicate stage position"}) } seenPos[st.Position] = true + if len(st.DocTasks) == 0 { + issues = append(issues, ValidationIssue{Code: "STAGE_NO_DOC_TASKS", Field: st.Key, Message: "each stage must have at least one doc task"}) + } for _, dt := range st.DocTasks { + docCount++ + if dt.Key == "" { + issues = append(issues, ValidationIssue{Code: "DOC_TASK_KEY_REQUIRED", Message: "doc task key required"}) + continue + } + if seenTask[dt.Key] { + issues = append(issues, ValidationIssue{Code: "DUPLICATE_DOC_TASK", Field: dt.Key, Message: "duplicate doc task key"}) + } + seenTask[dt.Key] = true if dt.ExecutorKey != "" { if err := workflow.ValidateExecutorKey(dt.ExecutorKey); err != nil { issues = append(issues, ValidationIssue{Code: "UNKNOWN_EXECUTOR", Field: dt.Key, Message: err.Error()}) @@ -44,6 +69,17 @@ func ValidateDefinitionV2(def VersionDefinition) ValidationResult { if len(def.Stages) == 0 { issues = append(issues, ValidationIssue{Code: "NO_STAGES", Message: "at least one stage required"}) } + if def.WorkflowKey != "" && IsOfficialWorkflowKey(def.WorkflowKey) { + if want, ok := officialDocCounts[def.WorkflowKey]; ok && docCount != want { + issues = append(issues, ValidationIssue{ + Code: "OFFICIAL_DOC_COUNT", Field: def.WorkflowKey, + Message: fmt.Sprintf("official workflow %s requires exactly %d doc tasks, got %d", def.WorkflowKey, want, docCount), + }) + } + if name := OfficialWorkflowName(def.WorkflowKey); name == def.WorkflowKey { + // name check happens at publish layer via Definition.Name + } + } return ValidationResult{Valid: len(issues) == 0, Issues: issues} } From d5a9a29eeca5c6d945571a563ff6b6ec957a1469 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 12 Sep 2026 14:33:43 +0000 Subject: [PATCH 04/24] feat(api): add workflow catalog routes and public auth config endpoint Expose governance APIs for workflow selection/termination and allow login pages to read registration policy without admin credentials. Co-authored-by: Cursor --- internal/server/auth_handlers.go | 15 ++++++ internal/server/auth_middleware.go | 3 ++ internal/server/project_handlers.go | 24 +++++++-- internal/server/project_members_handlers.go | 14 ++--- internal/server/project_settings_handlers.go | 11 +++- internal/server/server.go | 4 ++ internal/server/workflow_catalog_handlers.go | 54 +++++++++++++++++++ .../server/workflow_terminate_handlers.go | 51 ++++++++++++++++++ 8 files changed, 161 insertions(+), 15 deletions(-) create mode 100644 internal/server/workflow_catalog_handlers.go create mode 100644 internal/server/workflow_terminate_handlers.go diff --git a/internal/server/auth_handlers.go b/internal/server/auth_handlers.go index 08146671..a75862d4 100644 --- a/internal/server/auth_handlers.go +++ b/internal/server/auth_handlers.go @@ -307,6 +307,21 @@ func (s *Server) handleAdminUpdateStatus(w http.ResponseWriter, r *http.Request) _ = json.NewEncoder(w).Encode(updated) } +// handleAuthPublicConfig 返回登录/注册页所需的公开准入配置(无需鉴权) +func (s *Server) handleAuthPublicConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "不支持的方法", http.StatusMethodNotAllowed) + return + } + cfg, err := s.authSvc.GetConfig(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(cfg) +} + // handleAdminConfig 获取或更新系统准入配置 func (s *Server) handleAdminConfig(w http.ResponseWriter, r *http.Request) { if _, ok := s.requireSystemAdmin(w, r); !ok { diff --git a/internal/server/auth_middleware.go b/internal/server/auth_middleware.go index 1eb44384..0ba7961b 100644 --- a/internal/server/auth_middleware.go +++ b/internal/server/auth_middleware.go @@ -27,6 +27,9 @@ func isPublicRoute(method, path string) bool { if method == http.MethodPost && path == "/api/v1/auth/register" { return true } + if method == http.MethodGet && path == "/api/v1/auth/public-config" { + return true + } if !strings.HasPrefix(path, "/api/") { return true } diff --git a/internal/server/project_handlers.go b/internal/server/project_handlers.go index 102105d7..f6bcab14 100644 --- a/internal/server/project_handlers.go +++ b/internal/server/project_handlers.go @@ -70,7 +70,21 @@ func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) { return } - list, err := s.pwSvc.ListProjects(r.Context(), user.ID) + isAdmin, err := s.authzSvc.IsSystemAdmin(r.Context(), user.ID, user.RoleID) + if err != nil { + writeJSONResponse(w, http.StatusInternalServerError, map[string]string{ + "error_code": "ERR_AUTHZ", + "message": "权限判定失败", + }) + return + } + + var list []*projectworkflow.Project + if isAdmin { + list, err = s.pwSvc.ListAllProjects(r.Context()) + } else { + list, err = s.pwSvc.ListProjects(r.Context(), user.ID) + } if err != nil { http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError) return @@ -132,7 +146,7 @@ func (s *Server) handleListProjectWorkflows(w http.ResponseWriter, r *http.Reque writeJSONResponse(w, http.StatusOK, wfs) } -// handleActivateWorkflow 切换项目当前激活的工作流焦点(创建人权限受控) +// handleActivateWorkflow 旧式工作流切换 API(已退役)。 func (s *Server) handleActivateWorkflow(w http.ResponseWriter, r *http.Request) { idStr := r.PathValue("id") id, err := strconv.ParseInt(idStr, 10, 64) @@ -147,6 +161,11 @@ func (s *Server) handleActivateWorkflow(w http.ResponseWriter, r *http.Request) return } + if s.instSvc != nil && s.instSvc.Available() { + http.Error(w, `{"error":"WORKFLOW_SWITCH_API_RETIRED","message":"请通过项目设置完成工作流规划与激活"}`, http.StatusGone) + return + } + if s.blockLegacyWorkflowSwitch(w, r, id) { return } @@ -302,4 +321,3 @@ func (s *Server) handleGetProjectLineage(w http.ResponseWriter, r *http.Request) } writeJSONResponse(w, http.StatusOK, lineages) } - diff --git a/internal/server/project_members_handlers.go b/internal/server/project_members_handlers.go index c683ad22..8825291b 100644 --- a/internal/server/project_members_handlers.go +++ b/internal/server/project_members_handlers.go @@ -29,7 +29,7 @@ func (s *Server) handleProjectMembers(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - s.handleListProjectMembers(w, r, user, projectID) + s.handleListProjectMembers(w, r, projectID) case http.MethodPost: s.handleAddProjectMember(w, r, user, projectID) case http.MethodPut: @@ -41,16 +41,8 @@ func (s *Server) handleProjectMembers(w http.ResponseWriter, r *http.Request) { } } -func (s *Server) handleListProjectMembers(w http.ResponseWriter, r *http.Request, user *auth.UserAccount, projectID int64) { - if err := s.authorizer.RequireProjectAccess(r.Context(), projectID, user.ID); err != nil { - if err == authz.ErrForbidden { - writeJSONResponse(w, http.StatusForbidden, map[string]string{ - "error_code": "ERR_FORBIDDEN", - "message": "无权访问该项目", - }) - return - } - http.Error(w, err.Error(), http.StatusInternalServerError) +func (s *Server) handleListProjectMembers(w http.ResponseWriter, r *http.Request, projectID int64) { + if _, ok := s.requireProjectAccess(w, r, projectID); !ok { return } members, err := s.authorizer.ListProjectMembers(r.Context(), projectID) diff --git a/internal/server/project_settings_handlers.go b/internal/server/project_settings_handlers.go index 59913737..7d97cb95 100644 --- a/internal/server/project_settings_handlers.go +++ b/internal/server/project_settings_handlers.go @@ -145,7 +145,16 @@ func writeProjectInstanceError(w http.ResponseWriter, err error) { case errors.Is(err, projectinstance.ErrPlanChangedReconfirm): http.Error(w, `{"error":"PLAN_CHANGED_RECONFIRM","message":"规划已变更,请重新预览并确认"}`, http.StatusConflict) case errors.Is(err, projectinstance.ErrValidationFailed): - http.Error(w, `{"error":"VALIDATION_FAILED","message":"`+err.Error()+`"}`, http.StatusBadRequest) + var validationErr *projectinstance.ValidationError + if errors.As(err, &validationErr) { + writeJSONResponse(w, http.StatusBadRequest, map[string]any{ + "error": "VALIDATION_FAILED", + "message": "工作流配置校验未通过", + "issues": validationErr.Issues, + }) + return + } + http.Error(w, `{"error":"VALIDATION_FAILED","message":"工作流配置校验未通过"}`, http.StatusBadRequest) case errors.Is(err, projectinstance.ErrTokenInvalid): http.Error(w, `{"error":"TOKEN_INVALID","message":"激活令牌无效或已过期"}`, http.StatusBadRequest) case errors.Is(err, projectinstance.ErrConcurrentConflict): diff --git a/internal/server/server.go b/internal/server/server.go index 3dd4a94d..5f5a258f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -109,6 +109,7 @@ func NewServer(cfg *config.Config, svc *bizdev.Service) *Server { } if instSvc != nil && instSvc.Available() { svc.SetStageAccessGate(newBizdevStageGate(instSvc, pwSvc)) + coordEngine.SetInstanceService(instSvc) } s.registerWorkflowApprovalHook() s.startNotificationReminderLoop() @@ -164,7 +165,9 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /api/v1/workflow-instances/{id}", s.handleGetWorkflowInstance) mux.HandleFunc("GET /api/v1/workflow-instances/{id}/stages/{stage}/readiness", s.handleGetStageReadiness) mux.HandleFunc("POST /api/v1/workflow-instances/{id}/stages/{stage}/advance", s.handleAdvanceStage) + mux.HandleFunc("POST /api/v1/workflow-instances/{id}/terminate", s.handleTerminateWorkflowInstance) mux.HandleFunc("GET /api/v1/workflows", s.handleListWorkflowDefinitions) + mux.HandleFunc("GET /api/v1/workflows/catalog", s.handleWorkflowCatalog) mux.HandleFunc("POST /api/v1/projects/{id}/workflows/{workflow_key}/run", s.handleRunProjectWorkflow) mux.HandleFunc("GET /api/v1/projects/{id}/workflows/{workflow_key}/runs", s.handleListProjectWorkflowRuns) mux.HandleFunc("GET /api/v1/projects/{id}/lineage", s.handleGetProjectLineage) @@ -248,6 +251,7 @@ func (s *Server) Routes() http.Handler { // 9. 用户认证、安全改密与超级管理员后台接口 mux.HandleFunc("POST /api/v1/auth/login", s.handleAuthLogin) mux.HandleFunc("POST /api/v1/auth/register", s.handleAuthRegister) + mux.HandleFunc("GET /api/v1/auth/public-config", s.handleAuthPublicConfig) mux.HandleFunc("POST /api/v1/auth/logout", s.handleAuthLogout) mux.HandleFunc("POST /api/v1/auth/change-password", s.handleAuthChangePassword) mux.HandleFunc("GET /api/v1/auth/me", s.handleAuthMe) diff --git a/internal/server/workflow_catalog_handlers.go b/internal/server/workflow_catalog_handlers.go new file mode 100644 index 00000000..ad33845e --- /dev/null +++ b/internal/server/workflow_catalog_handlers.go @@ -0,0 +1,54 @@ +package server + +import ( + "net/http" + + "chat-agent-system/internal/workflowdef" +) + +type workflowCatalogItem struct { + Key string `json:"key"` + Name string `json:"name"` + LatestVersion int `json:"latest_version"` + ProductDomain string `json:"product_domain,omitempty"` +} + +func (s *Server) handleWorkflowCatalog(w http.ResponseWriter, r *http.Request) { + if s.wfDefSvc == nil || !s.wfDefSvc.Available() { + http.Error(w, `{"error":"ERR_NOT_AVAILABLE","message":"workflow catalog unavailable"}`, http.StatusServiceUnavailable) + return + } + defs, err := s.wfDefSvc.Store().ListProjectSelectableDefinitions(r.Context()) + if err != nil { + http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError) + return + } + items := make([]workflowCatalogItem, 0, len(workflowdef.OfficialWorkflowKeys())) + seen := map[string]bool{} + for _, d := range defs { + if !workflowdef.IsOfficialWorkflowKey(d.Key) || seen[d.Key] { + continue + } + seen[d.Key] = true + name := d.Name + if name == "" { + name = workflowdef.OfficialWorkflowName(d.Key) + } + items = append(items, workflowCatalogItem{ + Key: d.Key, + Name: name, + LatestVersion: d.LatestPublishedVersion, + ProductDomain: d.ProductDomain, + }) + } + for _, key := range workflowdef.OfficialWorkflowKeys() { + if seen[key] { + continue + } + items = append(items, workflowCatalogItem{ + Key: key, + Name: workflowdef.OfficialWorkflowName(key), + }) + } + writeJSONResponse(w, http.StatusOK, map[string]any{"workflows": items}) +} diff --git a/internal/server/workflow_terminate_handlers.go b/internal/server/workflow_terminate_handlers.go new file mode 100644 index 00000000..5cd592d1 --- /dev/null +++ b/internal/server/workflow_terminate_handlers.go @@ -0,0 +1,51 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "chat-agent-system/internal/projectinstance" +) + +func (s *Server) handleTerminateWorkflowInstance(w http.ResponseWriter, r *http.Request) { + if s.instSvc == nil || !s.instSvc.Available() { + http.Error(w, `{"error":"ERR_NOT_AVAILABLE"}`, http.StatusServiceUnavailable) + return + } + instanceID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || instanceID <= 0 { + http.Error(w, `{"error":"ERR_INVALID_PARAM"}`, http.StatusBadRequest) + return + } + user, err := s.authenticateUser(r) + if err != nil || user == nil { + http.Error(w, `{"error":"ERR_UNAUTHENTICATED"}`, http.StatusUnauthorized) + return + } + detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID) + if err != nil { + http.Error(w, `{"error":"ERR_NOT_FOUND"}`, http.StatusNotFound) + return + } + var in projectinstance.TerminateInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + http.Error(w, `{"error":"ERR_INVALID_PARAM"}`, http.StatusBadRequest) + return + } + isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), detail.Instance.ProjectID, user.ID) + res, err := s.instSvc.TerminateInstance(r.Context(), instanceID, detail.Instance.ProjectID, user.ID, isCreator, in) + if err != nil { + switch { + case errors.Is(err, projectinstance.ErrForbiddenNotCreator): + http.Error(w, `{"error":"ERR_FORBIDDEN"}`, http.StatusForbidden) + case errors.Is(err, projectinstance.ErrTerminationNotAllowed): + http.Error(w, `{"error":"ERR_TERMINATION_NOT_ALLOWED"}`, http.StatusConflict) + default: + http.Error(w, `{"error":"ERR_INTERNAL","message":"`+err.Error()+`"}`, http.StatusInternalServerError) + } + return + } + writeJSONResponse(w, http.StatusOK, res) +} From 47984f4863abcf6a12f4515f2fad2bf57096d75a Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 12 Sep 2026 14:33:45 +0000 Subject: [PATCH 05/24] fix(frontend): gate data fetches until session validated and improve workflow UX Prevent login-page 401 noise by deferring project/chat API calls until auth is confirmed, use public-config for registration policy, and add client-side duty assignment checks before saving workflow plans. Co-authored-by: Cursor --- web/src/App.tsx | 100 ++++++------ web/src/api/auth.ts | 14 +- web/src/api/authHeaders.ts | 20 +++ web/src/api/chatDigest.ts | 8 +- web/src/api/chatSearch.ts | 6 +- web/src/api/chatSessions.ts | 3 +- web/src/api/chatStream.ts | 15 +- web/src/api/chatTurn.ts | 4 +- web/src/api/chatWindow.ts | 23 ++- web/src/api/docgov.ts | 4 +- web/src/api/projectDocuments.ts | 10 +- web/src/api/projectSettings.ts | 7 + web/src/api/projectWorkflow.ts | 3 +- web/src/api/taskRuns.ts | 10 +- web/src/api/taskStream.ts | 3 +- .../project/ProjectSettingsModal.tsx | 43 ++++- .../workflow/ProjectWorkflowSwitcher.tsx | 154 +----------------- web/src/hooks/useUnreadMarks.ts | 17 +- web/src/types/projectSettings.ts | 2 + 19 files changed, 213 insertions(+), 233 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 7afdeb25..1777ffd3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -40,7 +40,6 @@ import { shouldLoadControlledContent, } from './lib/controlledDocHydrate'; import { DocumentLineage } from './types/projectWorkflow'; -import { WorkflowPrerequisiteBanner } from './components/workflow/WorkflowPrerequisiteBanner'; import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice'; import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner'; import { deriveReplyTitle } from './features/replyExport/title'; @@ -672,6 +671,9 @@ export default function App() { // 认证与会话状态 const [session, setSession] = useState(() => authClient.loadSavedSession()); + const [sessionValidated, setSessionValidated] = useState(() => + authClient.loadSavedSession() ? null : false, + ); const [systemEnv, setSystemEnv] = useState(''); const [systemAuthConfig, setSystemAuthConfig] = useState({ allowRegistration: true, @@ -696,19 +698,26 @@ export default function App() { setCapabilities(saved.capabilities ?? []); const match = allUsers.find((u) => u.id === saved.user.id); setCurrentUser(mergeSessionUser(saved.user, match)); - void authClient.fetchMe().then((me) => { - setCapabilities(me.capabilities ?? []); - setCurrentUser(mergeSessionUser(me.user, match)); - }).catch(() => { - authClient.clearLocalSession(); - setSession(null); - setCapabilities([]); - }); + void authClient + .fetchMe() + .then((me) => { + setCapabilities(me.capabilities ?? []); + setCurrentUser(mergeSessionUser(me.user, match)); + setSessionValidated(true); + }) + .catch(() => { + authClient.clearLocalSession(); + setSession(null); + setCapabilities([]); + setSessionValidated(false); + }); + } else { + setSessionValidated(false); } }, []); useEffect(() => { - if (!session) { + if (!session || sessionValidated !== true) { setProjectSyncNotice(null); return; } @@ -719,7 +728,7 @@ export default function App() { const serverProjects = await fetchProjects(); if (cancelled) return; setProjects((prev) => { - const mapped = serverProjects.map((project) => + const mapped = (serverProjects ?? []).map((project) => mapServerProjectToProjectInfo( project, allUsers, @@ -744,15 +753,15 @@ export default function App() { return () => { cancelled = true; }; - }, [session, allUsers, syncProjectMembersFromServer]); + }, [session, sessionValidated, allUsers, syncProjectMembersFromServer]); useEffect(() => { - if (!projectMembersModalOpen || !currentProject || !session) return; + if (!projectMembersModalOpen || !currentProject || !session || sessionValidated !== true) return; void syncProjectMembersFromServer(currentProject.id, currentProject.creatorId); }, [projectMembersModalOpen, currentProject, session, syncProjectMembersFromServer]); useEffect(() => { - if (!session || !currentUser.id) { + if (!session || sessionValidated !== true || !currentUser.id) { setSessionSyncNotice(null); return; } @@ -763,7 +772,7 @@ export default function App() { const serverSessions = await fetchChatSessions(currentUser.id); if (cancelled) return; setSessions((prev) => { - const mapped = serverSessions.map((item) => + const mapped = (serverSessions ?? []).map((item) => mapServerSessionToProjectSession( item, projects, @@ -785,12 +794,13 @@ export default function App() { return () => { cancelled = true; }; - }, [session, currentUser.id, projects]); + }, [session, sessionValidated, currentUser.id, projects]); const handleLogout = () => { void authClient.logout().finally(() => { setSession(null); setCapabilities([]); + setSessionValidated(false); }); }; @@ -834,6 +844,7 @@ export default function App() { }; useEffect(() => { + if (!session || sessionValidated !== true || !currentProjectId) return; if (currentProjectId) { getProject(currentProjectId) .then((p) => { @@ -854,22 +865,16 @@ export default function App() { .then(() => setWorkflowGovernanceEnabled(true)) .catch(() => setWorkflowGovernanceEnabled(false)); } - }, [currentProjectId]); + }, [session, sessionValidated, currentProjectId]); useEffect(() => { + if (!session || sessionValidated !== true || !currentProjectId) return; if (currentProjectId) { fetchProjectLineage(currentProjectId) .then((lins) => setProjectLineages(lins)) .catch(() => {}); } - }, [currentProjectId, currentWfState.activeKey]); - - const hasApprovedSystemReqs = deliverables.some( - (d) => - d.projectId === currentProjectId && - (d.taskKey === 'system_reqs' || d.docId.includes('system_reqs')) && - d.lifecycleState === 'approved' - ); + }, [session, sessionValidated, currentProjectId, currentWfState.activeKey]); const workflowBanner = ( <> @@ -894,16 +899,6 @@ export default function App() { isCreator={currentWfState.creatorId === currentUser.id} /> )} - { - setProjectWorkflowsState((prev) => ({ - ...prev, - [currentProjectId]: { ...currentWfState, activeKey: 'product_manager' }, - })); - }} - /> { @@ -1099,6 +1094,7 @@ export default function App() { // 受控非模板文档:挂载/切换/同步后自动拉取服务端正文 useEffect(() => { + if (!session || sessionValidated !== true) return; const target = deliverables.find((d) => d.docId === activeDocId); if (!target || !shouldLoadControlledContent(target)) return; @@ -1128,7 +1124,7 @@ export default function App() { return () => { cancelled = true; }; - }, [activeDocId, deliverables]); + }, [session, sessionValidated, activeDocId, deliverables]); const retryControlledDocLoad = useCallback(() => { const target = deliverables.find((d) => d.docId === activeDocId); @@ -1526,6 +1522,7 @@ export default function App() { // 挂载或项目切换时,拉取服务端的自由文档(我的私密与项目共享)并同步至 deliverables useEffect(() => { + if (!session || sessionValidated !== true || !currentProjectId) return; let active = true; const fetchDocs = async () => { try { @@ -1575,11 +1572,11 @@ export default function App() { return () => { active = false; }; - }, [currentProjectId, currentUser.id]); + }, [session, sessionValidated, currentProjectId, currentUser.id]); // 挂载或项目切换时,拉取服务端工作流受控文档并覆盖本地模板状态 useEffect(() => { - if (!currentProjectId || currentProjectId <= 0) return; + if (!session || sessionValidated !== true || !currentProjectId || currentProjectId <= 0) return; let active = true; const syncWorkflowDocs = async () => { try { @@ -1621,7 +1618,7 @@ export default function App() { return () => { active = false; }; - }, [currentProjectId, currentUser.id, currentUser.name]); + }, [session, sessionValidated, currentProjectId, currentUser.id, currentUser.name]); // 模态弹窗与侧栏 const [editingBlock, setEditingBlock] = useState(null); @@ -1647,12 +1644,14 @@ export default function App() { }, [messages, activeSessionId]); // P0: 会话按日窗口状态机 (处理首屏拉取、瞬时落底、触顶加载补偿) - const turnRuntime = useTurnRuntime(activeSessionId); - const taskRunStream = useTaskRunStream(activeSessionId); + const authenticatedSessionId = + session && sessionValidated === true ? activeSessionId : ''; + const turnRuntime = useTurnRuntime(authenticatedSessionId); + const taskRunStream = useTaskRunStream(authenticatedSessionId); const isThinking = turnRuntime.isThinking || taskRunStream.isThinking; const chatWindow = useChatWindow({ - sessionId: activeSessionId, + sessionId: authenticatedSessionId, liveMessages: currentSessionMessages, }); @@ -1672,11 +1671,11 @@ export default function App() { unreadMessageIds, isAllReadCompleted, consumeNextUnread, - } = useUnreadMarks(activeSessionId, currentUser?.id); + } = useUnreadMarks(authenticatedSessionId, currentUser?.id); // 会话切换时加载分类与日期统计 useEffect(() => { - if (!activeSessionId) return; + if (!activeSessionId || sessionValidated !== true) return; fetchChatCategories(activeSessionId) .then((cats) => { setCategoryStats(cats || []); @@ -1692,7 +1691,7 @@ export default function App() { .catch((err) => { console.warn('Failed to load date stats:', err); }); - }, [activeSessionId]); + }, [activeSessionId, sessionValidated]); // 监听 URL 哈希 (#/s/{sessionId}/m/{messageId}) 实现深链接直接定位与脉冲动效 const activeSessionIdRef = useRef(activeSessionId); @@ -3109,6 +3108,7 @@ export default function App() { { setSession(newSession); + setSessionValidated(true); setCapabilities(newSession.capabilities ?? []); if (newSession.env) setSystemEnv(newSession.env); const match = allUsers.find((u) => u.id === newSession.user.id); @@ -3845,10 +3845,12 @@ export default function App() { open={projectSettingsOpen} projectId={currentProjectId} projectName={currentActiveSession?.title || '当前项目'} - members={(currentProject?.members || []).map((m) => ({ - userId: m.userId, - name: allUsers.find((u) => u.id === m.userId)?.name || `用户 ${m.userId}`, - }))} + members={(currentProject?.members || []) + .filter((m) => m.status === 'active') + .map((m) => ({ + userId: m.userId, + name: allUsers.find((u) => u.id === m.userId)?.name || `用户 ${m.userId}`, + }))} onClose={() => setProjectSettingsOpen(false)} onActivated={(activeKey) => { setProjectWorkflowsState((prev) => ({ diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index da2b3b64..9dd31383 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -218,14 +218,18 @@ export class AuthClient { } async getSystemAuthConfig(): Promise { - const token = this.getSessionToken(); - const resp = await apiFetch('/api/v1/admin/config', { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); + const resp = await apiFetch('/api/v1/auth/public-config'); if (!resp.ok) { return { allowRegistration: false, requireApproval: true }; } - return resp.json(); + const data = (await resp.json()) as { + allow_registration?: boolean; + require_approval?: boolean; + }; + return { + allowRegistration: data.allow_registration ?? false, + requireApproval: data.require_approval ?? true, + }; } async listRoles(): Promise { diff --git a/web/src/api/authHeaders.ts b/web/src/api/authHeaders.ts index 69a2fe72..3e9128f3 100644 --- a/web/src/api/authHeaders.ts +++ b/web/src/api/authHeaders.ts @@ -23,3 +23,23 @@ export function getBearerAuthHeaders(contentType?: string): Record, + contentType?: string, +): Record { + return { ...getBearerAuthHeaders(contentType), ...extra }; +} + +/** Authenticated fetch wrapper; merges Bearer token into every request. */ +export async function authorizedFetch(input: RequestInfo, init?: RequestInit): Promise { + const extra = + init?.headers instanceof Headers + ? Object.fromEntries(init.headers.entries()) + : ((init?.headers as Record | undefined) ?? {}); + const contentType = + typeof extra['Content-Type'] === 'string' ? extra['Content-Type'] : undefined; + const headers = mergeAuthHeaders(extra, contentType); + return fetch(input, { ...init, headers }); +} diff --git a/web/src/api/chatDigest.ts b/web/src/api/chatDigest.ts index f69d6afa..7215d85f 100644 --- a/web/src/api/chatDigest.ts +++ b/web/src/api/chatDigest.ts @@ -1,4 +1,5 @@ import { DailyDigest } from '../types/workbench'; +import { mergeAuthHeaders, getBearerAuthHeaders } from './authHeaders'; export async function fetchDailyDigests( sessionId: string, @@ -12,14 +13,15 @@ export async function fetchDailyDigests( const res = await fetch(url.toString(), { method: 'GET', - headers: { Accept: 'application/json' }, + headers: mergeAuthHeaders({ Accept: 'application/json' }), }); if (!res.ok) { throw new Error(`fetchDailyDigests failed: HTTP ${res.status}`); } - return res.json(); + const data = await res.json(); + return Array.isArray(data) ? data : []; } export async function generateDailyDigest( @@ -28,7 +30,7 @@ export async function generateDailyDigest( ): Promise<{ queued: boolean; digest: DailyDigest }> { const res = await fetch('/api/v1/chat/digests/generate', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getBearerAuthHeaders('application/json'), body: JSON.stringify({ session_id: sessionId, day_key: dayKey, diff --git a/web/src/api/chatSearch.ts b/web/src/api/chatSearch.ts index ef93776e..c5faf138 100644 --- a/web/src/api/chatSearch.ts +++ b/web/src/api/chatSearch.ts @@ -1,4 +1,5 @@ import { ChatSearchHit, ChatSearchMode } from '../types/workbench'; +import { mergeAuthHeaders } from './authHeaders'; export interface SearchChatMessagesParams { sessionId: string; @@ -33,12 +34,13 @@ export async function searchChatMessages(params: SearchChatMessagesParams): Prom const res = await fetch(url.toString(), { method: 'GET', - headers: { Accept: 'application/json' }, + headers: mergeAuthHeaders({ Accept: 'application/json' }), }); if (!res.ok) { throw new Error(`searchChatMessages failed: HTTP ${res.status}`); } - return res.json(); + const data = await res.json(); + return Array.isArray(data) ? data : []; } diff --git a/web/src/api/chatSessions.ts b/web/src/api/chatSessions.ts index 05bd7b46..7bead403 100644 --- a/web/src/api/chatSessions.ts +++ b/web/src/api/chatSessions.ts @@ -38,7 +38,8 @@ export async function fetchChatSessions( if (!res.ok) { throw new Error(`获取会话列表失败: ${res.status}`); } - return res.json(); + const data = await res.json(); + return Array.isArray(data) ? data : []; } export async function upsertChatSession(input: UpsertChatSessionInput): Promise { diff --git a/web/src/api/chatStream.ts b/web/src/api/chatStream.ts index 165debad..52c5140c 100644 --- a/web/src/api/chatStream.ts +++ b/web/src/api/chatStream.ts @@ -1,6 +1,7 @@ import type { ChatErrorInfo } from '../types/workbench'; import type { MessageExportCapability } from '../types/replyExport'; import { fallbackErrorClass } from './chatRetry'; +import { mergeAuthHeaders } from './authHeaders'; export type StreamEvent = { id?: string; @@ -139,13 +140,13 @@ export async function streamChat( signal?: AbortSignal, options?: { idempotencyKey?: string }, ): Promise { - const headers: Record = { - 'Content-Type': 'application/json', - Accept: 'text/event-stream', - }; - if (options?.idempotencyKey) { - headers['Idempotency-Key'] = options.idempotencyKey; - } + const headers = mergeAuthHeaders( + { + Accept: 'text/event-stream', + ...(options?.idempotencyKey ? { 'Idempotency-Key': options.idempotencyKey } : {}), + }, + 'application/json', + ); const resp = await fetch('/api/v1/chat/stream', { method: 'POST', headers, diff --git a/web/src/api/chatTurn.ts b/web/src/api/chatTurn.ts index 03f46cf9..be018b8a 100644 --- a/web/src/api/chatTurn.ts +++ b/web/src/api/chatTurn.ts @@ -1,3 +1,5 @@ +import { getBearerAuthHeaders } from './authHeaders'; + export type ChatTurnStatus = { sessionId: string; clientTurnId: string; @@ -13,7 +15,7 @@ export async function fetchChatTurnStatus( signal?: AbortSignal, ): Promise { const url = `/api/v1/chat/turns/${encodeURIComponent(clientTurnId)}?session_id=${encodeURIComponent(sessionId)}`; - const res = await fetch(url, { signal }); + const res = await fetch(url, { signal, headers: getBearerAuthHeaders() }); if (res.status === 404) return null; if (!res.ok) { throw new Error(`fetchChatTurnStatus failed: HTTP ${res.status}`); diff --git a/web/src/api/chatWindow.ts b/web/src/api/chatWindow.ts index 9be24226..ce8e2cc7 100644 --- a/web/src/api/chatWindow.ts +++ b/web/src/api/chatWindow.ts @@ -6,6 +6,7 @@ import { ChatCategoryStat, } from '../types/workbench'; import { MessageContentKind, MessageExportCapability } from '../types/replyExport'; +import { getBearerAuthHeaders } from './authHeaders'; export interface RawChatMessageRecord { id: number; @@ -123,7 +124,7 @@ export async function fetchChatWindow( url += `&before_day=${encodeURIComponent(beforeDay)}`; } - const res = await fetch(url, { signal }); + const res = await fetch(url, { signal, headers: getBearerAuthHeaders() }); if (!res.ok) { throw new Error(`fetchChatWindow failed: HTTP ${res.status}`); } @@ -156,7 +157,9 @@ export async function fetchDayMessages( if (options?.afterId) params.set('after_id', String(options.afterId)); if (options?.limit) params.set('limit', String(options.limit)); - const res = await fetch(`/api/v1/chat/messages/day?${params.toString()}`); + const res = await fetch(`/api/v1/chat/messages/day?${params.toString()}`, { + headers: getBearerAuthHeaders(), + }); if (!res.ok) { throw new Error(`fetchDayMessages failed: HTTP ${res.status}`); } @@ -174,12 +177,15 @@ export async function fetchDayMessages( } export async function fetchChatDates(sessionId: string): Promise { - const res = await fetch(`/api/v1/chat/dates?session_id=${encodeURIComponent(sessionId)}`); + const res = await fetch(`/api/v1/chat/dates?session_id=${encodeURIComponent(sessionId)}`, { + headers: getBearerAuthHeaders(), + }); if (!res.ok) { throw new Error(`fetchChatDates failed: HTTP ${res.status}`); } const raw = await res.json(); - return (raw || []).map((item: any) => ({ + const items = Array.isArray(raw) ? raw : []; + return items.map((item: any) => ({ dayKey: item.day_key, count: item.count || 0, categories: item.categories || {}, @@ -192,7 +198,7 @@ export async function importChatMessages( ): Promise<{ imported_count: number; total: number }> { const res = await fetch('/api/v1/chat/messages/import', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getBearerAuthHeaders('application/json'), body: JSON.stringify(messages), }); if (!res.ok) { @@ -202,12 +208,15 @@ export async function importChatMessages( } export async function fetchChatCategories(sessionId: string): Promise { - const res = await fetch(`/api/v1/chat/categories?session_id=${encodeURIComponent(sessionId)}`); + const res = await fetch(`/api/v1/chat/categories?session_id=${encodeURIComponent(sessionId)}`, { + headers: getBearerAuthHeaders(), + }); if (!res.ok) { throw new Error(`fetchChatCategories failed: HTTP ${res.status}`); } const raw = await res.json(); - return (raw || []).map((item: any) => ({ + const items = Array.isArray(raw) ? raw : []; + return items.map((item: any) => ({ category: item.category, label: item.label, count: item.count || 0, diff --git a/web/src/api/docgov.ts b/web/src/api/docgov.ts index c26c23eb..e85a3fef 100644 --- a/web/src/api/docgov.ts +++ b/web/src/api/docgov.ts @@ -1,5 +1,7 @@ /** 受控文档正文保存与编辑租约 API */ +import { getBearerAuthHeaders } from './authHeaders'; + const API_BASE = ''; export interface SaveDocumentContentPayload { @@ -23,7 +25,7 @@ export async function saveDocumentContent(payload: SaveDocumentContentPayload): }> { const resp = await fetch(`${API_BASE}/api/v1/docgov/content`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getBearerAuthHeaders('application/json'), body: JSON.stringify(payload), }); if (!resp.ok) { diff --git a/web/src/api/projectDocuments.ts b/web/src/api/projectDocuments.ts index af0070f0..be3234ad 100644 --- a/web/src/api/projectDocuments.ts +++ b/web/src/api/projectDocuments.ts @@ -1,5 +1,7 @@ /** 项目工作流受控文档 API */ +import { getBearerAuthHeaders } from './authHeaders'; + export type WorkflowDocumentSummary = { doc_id: string; project_id: number; @@ -26,17 +28,21 @@ export async function listProjectWorkflowDocuments( ): Promise { const resp = await fetch( `/api/v1/projects/${projectId}/documents?category=workflow`, + { headers: getBearerAuthHeaders() }, ); if (!resp.ok) { throw new Error(`list project workflow documents failed: ${resp.status}`); } - return resp.json(); + const data = await resp.json(); + return Array.isArray(data) ? data : []; } export async function fetchControlledDocumentContent( docId: string, ): Promise { - const resp = await fetch(`/api/v1/docgov/documents/${encodeURIComponent(docId)}/content`); + const resp = await fetch(`/api/v1/docgov/documents/${encodeURIComponent(docId)}/content`, { + headers: getBearerAuthHeaders(), + }); if (!resp.ok) { throw new Error(`fetch controlled document content failed: ${resp.status}`); } diff --git a/web/src/api/projectSettings.ts b/web/src/api/projectSettings.ts index 28f6161a..0e3e4d30 100644 --- a/web/src/api/projectSettings.ts +++ b/web/src/api/projectSettings.ts @@ -156,6 +156,13 @@ export async function advanceStage( function parseError(text: string): string | null { try { const parsed = JSON.parse(text); + if (Array.isArray(parsed.issues) && parsed.issues.length > 0) { + const details = parsed.issues + .map((issue: { message?: string }) => issue.message) + .filter(Boolean) + .join(';'); + if (details) return details; + } return parsed.message || parsed.error || null; } catch { return text || null; diff --git a/web/src/api/projectWorkflow.ts b/web/src/api/projectWorkflow.ts index 5264b682..b091d865 100644 --- a/web/src/api/projectWorkflow.ts +++ b/web/src/api/projectWorkflow.ts @@ -18,7 +18,8 @@ export async function fetchProjects(): Promise { if (!res.ok) { throw new Error(`获取项目列表失败: ${res.status}`); } - return res.json(); + const data = await res.json(); + return Array.isArray(data) ? data : []; } export async function createProject(data: CreateProjectInput): Promise { diff --git a/web/src/api/taskRuns.ts b/web/src/api/taskRuns.ts index 5327dec8..7896ea89 100644 --- a/web/src/api/taskRuns.ts +++ b/web/src/api/taskRuns.ts @@ -1,4 +1,5 @@ import { parseSSEChunk, type StreamEvent } from './chatStream'; +import { mergeAuthHeaders, getBearerAuthHeaders } from './authHeaders'; export type CreateTaskRunBody = Record & { session_id: string; @@ -17,7 +18,7 @@ export type CreateTaskRunResponse = { export async function createTaskRun(body: CreateTaskRunBody): Promise { const res = await fetch('/api/v1/tasks/runs', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getBearerAuthHeaders('application/json'), body: JSON.stringify(body), }); if (!res.ok) { @@ -35,7 +36,7 @@ export async function subscribeTaskRunEvents( const url = `/api/v1/tasks/runs/${encodeURIComponent(runId)}/events?after_seq=${after}`; const resp = await fetch(url, { method: 'GET', - headers: { Accept: 'text/event-stream' }, + headers: mergeAuthHeaders({ Accept: 'text/event-stream' }), signal, }); if (!resp.ok || !resp.body) { @@ -61,6 +62,7 @@ export async function subscribeTaskRunEvents( export async function cancelTaskRun(runId: string): Promise { const res = await fetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}/cancel`, { method: 'POST', + headers: getBearerAuthHeaders(), }); if (!res.ok) { throw new Error(`cancel task run failed: HTTP ${res.status}`); @@ -68,7 +70,9 @@ export async function cancelTaskRun(runId: string): Promise { } export async function getTaskRun(runId: string) { - const res = await fetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}`); + const res = await fetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}`, { + headers: getBearerAuthHeaders(), + }); if (!res.ok) { throw new Error(`get task run failed: HTTP ${res.status}`); } diff --git a/web/src/api/taskStream.ts b/web/src/api/taskStream.ts index ae3e3ce5..53f7ac76 100644 --- a/web/src/api/taskStream.ts +++ b/web/src/api/taskStream.ts @@ -1,6 +1,7 @@ import { parseSSEChunk, type StreamEvent } from './chatStream'; import { canonicalDocIdForTask } from '../lib/controlledDocHydrate'; import type { TaskRunContext } from '../types/workbench'; +import { mergeAuthHeaders } from './authHeaders'; export type { TaskRunContext }; @@ -18,7 +19,7 @@ export async function streamTask( ): Promise { const resp = await fetch('/api/v1/tasks/run/stream', { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' }, + headers: mergeAuthHeaders({ Accept: 'text/event-stream' }, 'application/json'), body: JSON.stringify(body), signal, }); diff --git a/web/src/components/project/ProjectSettingsModal.tsx b/web/src/components/project/ProjectSettingsModal.tsx index 8f345161..05d65cf9 100644 --- a/web/src/components/project/ProjectSettingsModal.tsx +++ b/web/src/components/project/ProjectSettingsModal.tsx @@ -105,6 +105,31 @@ export const ProjectSettingsModal: React.FC = ({ const handleSavePlan = async () => { if (!plan || !settings) return; + + const enabledTasks = plan.stages + .filter((stage) => stage.enabled) + .flatMap((stage) => stage.doc_tasks.filter((task) => task.enabled)); + if (enabledTasks.length === 0) { + setError('至少启用一个文档后才能激活工作流。'); + return; + } + if (members.length < 3) { + setError(`当前项目只有 ${members.length} 名有效成员。启用文档需要分别指定编制、审核、批准负责人,请先在“项目成员”中补充至至少 3 人。`); + return; + } + for (const task of enabledTasks) { + const dutyIds = [task.duties.author_user_id, task.duties.reviewer_user_id, task.duties.approver_user_id]; + const label = task.task_title || task.task_key; + if (dutyIds.some((id) => !id)) { + setError(`请为“${label}”完整指定编制、审核、批准负责人。`); + return; + } + if (new Set(dutyIds).size !== dutyIds.length) { + setError(`“${label}”的编制、审核、批准负责人必须由不同成员担任。`); + return; + } + } + setLoading(true); setError(null); try { @@ -281,6 +306,20 @@ export const ProjectSettingsModal: React.FC = ({

为启用的文档任务指派作者、审核人、批准人(三权分离)。

+ {members.length < 3 && ( +
+ 当前只有 {members.length} 名有效项目成员,无法满足编制、审核、批准三权分离。请先关闭本窗口并在“项目成员”中补充成员。 +
+ )} {plan.stages.length === 0 && (
请先保存一次规划以从工作流定义加载阶段结构,或返回上一步重新选择。 @@ -288,7 +327,7 @@ export const ProjectSettingsModal: React.FC = ({ )} {plan.stages.map((st, si) => (
-
{st.stage_key}
+
{st.stage_name || st.stage_key}
{st.doc_tasks.map((dt, ti) => (
{dt.enabled && (
diff --git a/web/src/components/workflow/ProjectWorkflowSwitcher.tsx b/web/src/components/workflow/ProjectWorkflowSwitcher.tsx index e0a8417f..ac46cec7 100644 --- a/web/src/components/workflow/ProjectWorkflowSwitcher.tsx +++ b/web/src/components/workflow/ProjectWorkflowSwitcher.tsx @@ -1,6 +1,5 @@ import React, { useState } from 'react'; import { Workflow, Lock, CheckCircle2, AlertCircle, Settings2, Eye } from 'lucide-react'; -import { activateWorkflow, getProject } from '../../api/projectWorkflow'; interface ProjectWorkflowSwitcherProps { projectId: number; @@ -14,69 +13,26 @@ interface ProjectWorkflowSwitcherProps { } const WORKFLOW_OPTIONS = [ - { key: 'main', label: '主协同流程', icon: '⚡' }, - { key: 'product_manager', label: '智能产品经理', icon: '📋' }, - { key: 'test_manager', label: '智能测试经理', icon: '🧪' }, + { key: 'main', label: '商务对接流程', icon: '⚡' }, + { key: 'product_manager', label: '智能产品经理流程', icon: '📋' }, + { key: 'test_manager', label: '智能测试工程师流程', icon: '🧪' }, ]; const workflowLabel = (key: string) => WORKFLOW_OPTIONS.find((w) => w.key === key)?.label || key; export const ProjectWorkflowSwitcher: React.FC = ({ - projectId, activeWorkflowKey, - workflowVersion, lifecycleStatus = 'active', isCreator, - governanceEnabled = false, - onWorkflowChanged, onOpenSettings, }) => { - const [switching, setSwitching] = useState(false); - const [errorMsg, setErrorMsg] = useState(null); + const [errorMsg] = useState(null); const isPlanning = lifecycleStatus === 'planning' || !activeWorkflowKey; - const showGovernanceUI = governanceEnabled || isPlanning; + const canConfigureNext = lifecycleStatus === 'planning' && !activeWorkflowKey; - const handleLegacySelect = async (targetKey: string) => { - if (targetKey === activeWorkflowKey) return; - if (!isCreator) { - setErrorMsg('权限不足:仅项目创建人可切换激活工作流'); - setTimeout(() => setErrorMsg(null), 3000); - return; - } - - setSwitching(true); - setErrorMsg(null); - try { - let res; - try { - res = await activateWorkflow(projectId, { - workflow_key: targetKey, - expected_version: workflowVersion, - reason: '前端控制台用户切换工作流', - }); - } catch { - const fresh = await getProject(projectId); - res = await activateWorkflow(projectId, { - workflow_key: targetKey, - expected_version: fresh.workflow_selection_version, - reason: '前端版本冲突自愈重试', - }); - } - if (onWorkflowChanged && res) { - onWorkflowChanged(res.active_workflow_key, res.version); - } - } catch (err: unknown) { - setErrorMsg(err instanceof Error ? err.message : '切换工作流失败'); - setTimeout(() => setErrorMsg(null), 4000); - } finally { - setSwitching(false); - } - }; - - if (showGovernanceUI) { - return ( + return (
= ( }} > - {isPlanning ? ( + {canConfigureNext ? ( + 可配置下一工作流 + ) : isPlanning ? ( 规划态 · 未激活工作流 ) : ( @@ -167,99 +125,5 @@ export const ProjectWorkflowSwitcher: React.FC = (
)}
- ); - } - - return ( -
-
- - - 工作流: - - - {WORKFLOW_OPTIONS.map((wf) => { - const isActive = wf.key === activeWorkflowKey; - return ( - - ); - })} -
- - {errorMsg && ( -
- - {errorMsg} -
- )} -
); }; diff --git a/web/src/hooks/useUnreadMarks.ts b/web/src/hooks/useUnreadMarks.ts index 3085340d..e244c684 100644 --- a/web/src/hooks/useUnreadMarks.ts +++ b/web/src/hooks/useUnreadMarks.ts @@ -7,6 +7,7 @@ */ import { useState, useEffect, useRef, useCallback } from 'react'; +import { getBearerAuthHeaders } from '../api/authHeaders'; export interface UnreadMark { sessionId: string; @@ -84,13 +85,23 @@ export function useUnreadMarks( setUnreadMessageIds([]); return; } + const authHeaders = getBearerAuthHeaders(); + if (!authHeaders.Authorization) { + setUnreadMarks([]); + setUnreadTotal(0); + setNextMessageId(null); + setUnreadMessageIds([]); + return; + } const since = sinceIdRef.current; try { const params = new URLSearchParams({ session_id: sessionId }); if (userId) params.set('user_id', String(userId)); if (since && since > 0) params.set('since_message_id', String(since)); - const res = await fetch(`/api/v1/chat/unread?${params.toString()}`); + const res = await fetch(`/api/v1/chat/unread?${params.toString()}`, { + headers: authHeaders, + }); if (!res.ok) return; const data = await res.json(); @@ -208,7 +219,7 @@ export function useUnreadMarks( try { await fetch('/api/v1/chat/read', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getBearerAuthHeaders('application/json'), body: JSON.stringify({ session_id: sessionId, user_id: userId || 0, @@ -247,7 +258,7 @@ export function useUnreadMarks( try { await fetch('/api/v1/chat/read', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getBearerAuthHeaders('application/json'), body: JSON.stringify({ session_id: sessionId, user_id: userId || 0, diff --git a/web/src/types/projectSettings.ts b/web/src/types/projectSettings.ts index bf2ebb43..d6bdccc7 100644 --- a/web/src/types/projectSettings.ts +++ b/web/src/types/projectSettings.ts @@ -6,12 +6,14 @@ export interface DocDuty { export interface DocTaskPlan { task_key: string; + task_title?: string; enabled: boolean; duties: DocDuty; } export interface StagePlan { stage_key: string; + stage_name?: string; enabled: boolean; doc_tasks: DocTaskPlan[]; } From ad6e38d11afd13b45e479a201f496dad4a592a9a Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 12 Sep 2026 14:33:47 +0000 Subject: [PATCH 06/24] test(workflow): add governance regression and activation PG coverage Cover public auth config, catalog policy, official main workflow activation on Postgres, and conn-busy metadata writeback fixes. Co-authored-by: Cursor --- internal/db/testdb.go | 29 ++++++ .../activation_official_pg_test.go | 99 +++++++++++++++++++ internal/projectinstance/approval_test.go | 13 ++- .../projectinstance/pg_integration_test.go | 6 +- internal/projectinstance/service_test.go | 46 ++++++--- internal/projectinstance/stage_access_test.go | 4 +- .../projectinstance/stage_service_test.go | 12 +-- internal/projectinstance/validator_test.go | 67 +++++++++++++ internal/projectworkflow/service_test.go | 33 +++++++ internal/server/auth_handlers_test.go | 36 +++++++ internal/server/project_handlers_test.go | 41 ++++++++ internal/server/project_workflow_api_test.go | 16 +-- .../server/workflow_governance_pg_test.go | 6 +- internal/workflowdef/catalog_policy_test.go | 43 ++++++++ internal/workflowdef/publish_test.go | 11 ++- 15 files changed, 415 insertions(+), 47 deletions(-) create mode 100644 internal/db/testdb.go create mode 100644 internal/projectinstance/activation_official_pg_test.go create mode 100644 internal/projectinstance/validator_test.go create mode 100644 internal/workflowdef/catalog_policy_test.go diff --git a/internal/db/testdb.go b/internal/db/testdb.go new file mode 100644 index 00000000..b71f192f --- /dev/null +++ b/internal/db/testdb.go @@ -0,0 +1,29 @@ +package db + +import ( + "os" + "strings" + "testing" +) + +const testDatabaseEnv = "TEST_DATABASE_URL" + +// RequireTestDatabaseURL 集成测试必须显式提供隔离测试库,禁止默认连开发库。 +func RequireTestDatabaseURL(t *testing.T) string { + t.Helper() + dsn := strings.TrimSpace(os.Getenv(testDatabaseEnv)) + if dsn == "" { + t.Fatalf("%s must be set for PostgreSQL integration tests (refusing default dev database)", testDatabaseEnv) + } + return dsn +} + +// SkipIfNoTestDatabaseURL 无隔离测试库时跳过(单元测试包内可选 PG 用例)。 +func SkipIfNoTestDatabaseURL(t *testing.T) string { + t.Helper() + dsn := strings.TrimSpace(os.Getenv(testDatabaseEnv)) + if dsn == "" { + t.Skipf("%s not set; skipping PostgreSQL integration test", testDatabaseEnv) + } + return dsn +} diff --git a/internal/projectinstance/activation_official_pg_test.go b/internal/projectinstance/activation_official_pg_test.go new file mode 100644 index 00000000..55d848a1 --- /dev/null +++ b/internal/projectinstance/activation_official_pg_test.go @@ -0,0 +1,99 @@ +package projectinstance_test + +import ( + "context" + "testing" + "time" + + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/projectworkflow" + "chat-agent-system/internal/workflowdef" +) + +// TestPG_ActivateOfficialWorkflowRuntimeMetadata 回归激活链路: +// ActivateInstance.applyRuntimeMetadata 会遍历文档任务实例并逐条 UPDATE。 +// 若在同一事务连接上 rows 未读完时就执行 Exec,pgx 会返回 "conn busy"。 +// 本测试使用正式商务对接流程(3 阶段 7 任务)覆盖该路径。 +func TestPG_ActivateOfficialWorkflowRuntimeMetadata(t *testing.T) { + pool := openPGPool(t) + defer pool.Close() + ctx := context.Background() + + wfStore := workflowdef.NewStore(pool) + def, err := workflowdef.LoadManifestDefinition("main.v2.json") + if err != nil { + t.Fatalf("load main.v2 manifest: %v", err) + } + if err := wfStore.UpsertPublishedSeed(ctx, workflowdef.CreateDefinitionInput{ + Key: workflowdef.OfficialKeyMain, + Name: "商务对接流程", + Definition: def, + }, 1); err != nil { + t.Fatalf("seed official main workflow: %v", err) + } + + pwStore := projectworkflow.NewStore(pool) + instStore := projectinstance.NewStore(pool) + svc := projectinstance.NewService(instStore, wfStore, pwStore, nil) + + var projectID int64 + code := "pg-official-" + time.Now().Format("150405.000") + err = pool.QueryRow(ctx, ` + INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision) + VALUES ($1, 'PG Official Activation Test', 'active', 1, 'planning', '', 1, 0) RETURNING id + `, code).Scan(&projectID) + if err != nil { + t.Fatalf("insert project: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) + }) + + plan, err := svc.BuildDefaultPlan(ctx, workflowdef.OfficialKeyMain, 0) + if err != nil { + t.Fatalf("default plan: %v", err) + } + for i := range plan.Stages { + for j := range plan.Stages[i].DocTasks { + plan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{ + AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3, + } + } + } + + if _, err := svc.SaveWorkflowPlan(ctx, projectID, 1, true, projectinstance.SavePlanInput{ + Plan: *plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }); err != nil { + t.Fatalf("save plan: %v", err) + } + preview, err := svc.ActivationPreview(ctx, projectID, 1, true) + if err != nil { + t.Fatalf("preview: %v", err) + } + activated, err := svc.Activate(ctx, projectID, 1, true, projectinstance.ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if err != nil { + t.Fatalf("activate: %v", err) + } + if activated.Instance.Status != projectinstance.InstanceActive { + t.Fatalf("expected active instance, got %q", activated.Instance.Status) + } + + // 运行时元数据应写回文档任务实例(回归 applyRuntimeMetadata 的逐条 UPDATE)。 + var executorKey string + err = pool.QueryRow(ctx, ` + SELECT dti.executor_key + FROM project_doc_task_instances dti + JOIN project_stage_instances si ON si.id = dti.stage_instance_id + WHERE si.workflow_instance_id = $1 AND dti.task_key = 'intake' + `, activated.Instance.ID).Scan(&executorKey) + if err != nil { + t.Fatalf("query runtime metadata: %v", err) + } + if executorKey != "bizdev.intake.v1" { + t.Fatalf("expected executor_key bizdev.intake.v1, got %q", executorKey) + } +} diff --git a/internal/projectinstance/approval_test.go b/internal/projectinstance/approval_test.go index 034b6c27..14dd1c43 100644 --- a/internal/projectinstance/approval_test.go +++ b/internal/projectinstance/approval_test.go @@ -3,19 +3,21 @@ package projectinstance import ( "context" "testing" + + "chat-agent-system/internal/workflowdef" ) func TestHandleDocumentApproved_StageReady(t *testing.T) { ctx := context.Background() store := newMemStore() - _ = SeedProjectMeta(store, 1, LifecycleActive, "medical_device_development", 1, 1) + _ = SeedProjectMeta(store, 1, LifecycleActive, workflowdef.OfficialKeyMain, 1, 1) svc := NewService(store, testResolver(), nil, &stubDocChecker{states: map[string]string{"intake": "approved"}}) plan := WorkflowPlan{ - WorkflowDefinitionKey: "medical_device_development", + WorkflowDefinitionKey: workflowdef.OfficialKeyMain, WorkflowVersion: 1, Stages: []StagePlan{{ - StageKey: "biz_intake", Enabled: true, + StageKey: "V0.1_triage", Enabled: true, DocTasks: []DocTaskPlan{{ TaskKey: "intake", Enabled: true, Duties: DocDuty{AuthorUserID: 2, ReviewerUserID: 3, ApproverUserID: 4}, @@ -26,7 +28,8 @@ func TestHandleDocumentApproved_StageReady(t *testing.T) { ID: 1, ProjectID: 1, WorkflowVersionID: 10, Status: InstanceActive, ConfigRevision: 1, PlanHash: "h", ConfigSnapshot: plan, InstanceNo: 1, } - activated, err := store.ActivateInstance(ctx, 1, inst, "medical_device_development", 1, 2) + version := &workflowdef.Version{WorkflowKey: workflowdef.OfficialKeyMain, Version: 1} + activated, err := store.ActivateInstance(ctx, 1, inst, version, 1, 2) if err != nil { t.Fatalf("activate: %v", err) } @@ -38,7 +41,7 @@ func TestHandleDocumentApproved_StageReady(t *testing.T) { if result == nil || !result.StageReady { t.Fatalf("expected stage ready, got %+v", result) } - if result.InstanceID != activated.ID || result.StageKey != "biz_intake" { + if result.InstanceID != activated.ID || result.StageKey != "V0.1_triage" { t.Fatalf("unexpected result: %+v", result) } } diff --git a/internal/projectinstance/pg_integration_test.go b/internal/projectinstance/pg_integration_test.go index b450cf1c..e0e71db4 100644 --- a/internal/projectinstance/pg_integration_test.go +++ b/internal/projectinstance/pg_integration_test.go @@ -2,7 +2,6 @@ package projectinstance_test import ( "context" - "os" "testing" "time" @@ -18,10 +17,7 @@ import ( func openPGPool(t *testing.T) *pgxpool.Pool { t.Helper() - dsn := os.Getenv("TEST_DATABASE_URL") - if dsn == "" { - dsn = "postgres://app:dev_secret_db_pass@127.0.0.1:5433/agent_system_dev?sslmode=disable" - } + dsn := db.SkipIfNoTestDatabaseURL(t) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() pool, err := pgxpool.New(ctx, dsn) diff --git a/internal/projectinstance/service_test.go b/internal/projectinstance/service_test.go index 355c6c46..75e26a7e 100644 --- a/internal/projectinstance/service_test.go +++ b/internal/projectinstance/service_test.go @@ -40,32 +40,36 @@ func (s *stubVersionResolver) ListDefinitions(_ context.Context) ([]workflowdef. } func testResolver() *stubVersionResolver { + key := workflowdef.OfficialKeyMain ver := &workflowdef.Version{ ID: 100, WorkflowID: 10, Version: 1, Status: workflowdef.StatusPublished, - WorkflowKey: "medical_device_development", WorkflowName: "医疗器械开发", + WorkflowKey: key, WorkflowName: workflowdef.OfficialWorkflowName(key), Definition: workflowdef.VersionDefinition{ + SchemaVersion: 2, + WorkflowKey: key, Stages: []workflowdef.StageDef{{ - Key: "biz_intake", Name: "商务 intake", Optional: false, + Key: "V0.1_triage", Name: "V0.1 需求摸底", Optional: false, Position: 10, DocTasks: []workflowdef.DocTaskDef{{ - Key: "intake", Required: true, Blocking: true, DefaultEnabled: true, + Key: "intake", Title: "用户初始需求收集表", CreatorConfigurable: true, BlockingWhenEnabled: true, DefaultEnabled: true, + OutputDocType: "intake", OutputDocTitle: "用户初始需求收集表", }}, }}, }, } return &stubVersionResolver{ defs: map[string]*workflowdef.Definition{ - "medical_device_development": {ID: 10, Key: "medical_device_development", Name: "医疗器械开发", LatestPublishedVersion: 1}, + key: {ID: 10, Key: key, Name: workflowdef.OfficialWorkflowName(key), LatestPublishedVersion: 1}, }, - versions: map[string]*workflowdef.Version{"medical_device_development": ver}, + versions: map[string]*workflowdef.Version{key: ver}, } } func validPlan() WorkflowPlan { return 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: 1, ReviewerUserID: 2, ApproverUserID: 3}, @@ -86,7 +90,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 +103,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 +137,28 @@ func TestService_BlockPlanEditWhenActive(t *testing.T) { func TestService_SeparationOfDutyValidation(t *testing.T) { bad := validPlan() bad.Stages[0].DocTasks[0].Duties.ReviewerUserID = 1 - res := validatePlan(bad, testResolver().versions["medical_device_development"]) + res := validatePlan(bad, testResolver().versions[workflowdef.OfficialKeyMain]) if res.Valid { t.Fatal("expected separation of duty violation") } } + +func TestService_SavePlanReturnsValidationIssues(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 8, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, nil) + bad := validPlan() + bad.Stages[0].DocTasks[0].Duties = DocDuty{} + + _, err := svc.SaveWorkflowPlan(ctx, 8, 1, true, SavePlanInput{ + Plan: bad, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + var validationErr *ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected ValidationError, got %v", err) + } + if len(validationErr.Issues) != 3 { + t.Fatalf("expected three missing-duty issues, got %+v", validationErr.Issues) + } +} diff --git a/internal/projectinstance/stage_access_test.go b/internal/projectinstance/stage_access_test.go index 8b625179..1a740a46 100644 --- a/internal/projectinstance/stage_access_test.go +++ b/internal/projectinstance/stage_access_test.go @@ -48,12 +48,12 @@ func TestResolveStageAccess_ActiveStageDutyOnly(t *testing.T) { } creatorAccess, _ := svc.ResolveStageAccess(ctx, 42, 1, true) - if !creatorAccess.StageKeyAllowed("biz_intake") { + if !creatorAccess.StageKeyAllowed("V0.1_triage") { t.Fatal("creator should access active stage") } outsiderAccess, _ := svc.ResolveStageAccess(ctx, 42, 9, false) - if outsiderAccess.StageKeyAllowed("biz_intake") { + if outsiderAccess.StageKeyAllowed("V0.1_triage") { t.Fatal("user without duty should not access active stage chat") } } diff --git a/internal/projectinstance/stage_service_test.go b/internal/projectinstance/stage_service_test.go index 5418f775..75114df1 100644 --- a/internal/projectinstance/stage_service_test.go +++ b/internal/projectinstance/stage_service_test.go @@ -39,7 +39,7 @@ func TestService_AdvanceStageGate(t *testing.T) { t.Fatalf("Activate: %v", err) } - readiness, err := svc.GetStageReadiness(ctx, result.Instance.ID, "biz_intake", 1, true) + readiness, err := svc.GetStageReadiness(ctx, result.Instance.ID, "V0.1_triage", 1, true) if err != nil { t.Fatalf("GetStageReadiness: %v", err) } @@ -49,15 +49,15 @@ func TestService_AdvanceStageGate(t *testing.T) { _ = settings advanced, err := svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{ - StageKey: "biz_intake", ExpectedConfigRevision: result.Instance.ConfigRevision, + StageKey: "V0.1_triage", ExpectedConfigRevision: result.Instance.ConfigRevision, }) if err != nil { t.Fatalf("AdvanceStage: %v", err) } - if advanced.CompletedStage != "biz_intake" { - t.Fatalf("expected completed biz_intake, got %s", advanced.CompletedStage) + if advanced.CompletedStage != "V0.1_triage" { + t.Fatalf("expected completed V0.1_triage, got %s", advanced.CompletedStage) } - if advanced.NextStageKey == "biz_intake" { + if advanced.NextStageKey == "V0.1_triage" { t.Fatal("next stage should not be same as completed") } } @@ -76,7 +76,7 @@ func TestService_AdvanceStageBlockedWithoutApproval(t *testing.T) { }) _, err := svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{ - StageKey: "biz_intake", ExpectedConfigRevision: result.Instance.ConfigRevision, + StageKey: "V0.1_triage", ExpectedConfigRevision: result.Instance.ConfigRevision, }) if !errors.Is(err, ErrStageGateNotReady) { t.Fatalf("expected ErrStageGateNotReady, got %v", err) diff --git a/internal/projectinstance/validator_test.go b/internal/projectinstance/validator_test.go new file mode 100644 index 00000000..ebe34be4 --- /dev/null +++ b/internal/projectinstance/validator_test.go @@ -0,0 +1,67 @@ +package projectinstance + +import ( + "testing" + + "chat-agent-system/internal/workflowdef" +) + +func TestValidateActivationPlan_EmptyWorkflowBlocked(t *testing.T) { + plan := WorkflowPlan{ + WorkflowDefinitionKey: workflowdef.OfficialKeyProductManager, + WorkflowVersion: 2, + Stages: []StagePlan{{ + StageKey: "requirements_baseline", + Enabled: true, + DocTasks: []DocTaskPlan{{ + TaskKey: "system_reqs", + Enabled: false, + }}, + }}, + } + version := &workflowdef.Version{ + Status: workflowdef.StatusPublished, + WorkflowKey: workflowdef.OfficialKeyProductManager, + Version: 2, + Definition: workflowdef.VersionDefinition{ + SchemaVersion: 2, + Stages: []workflowdef.StageDef{{ + Key: "requirements_baseline", Position: 10, + DocTasks: []workflowdef.DocTaskDef{{ + Key: "system_reqs", CreatorConfigurable: true, DefaultEnabled: true, + OutputDocType: "system_reqs", + }}, + }}, + }, + } + res := validatePlan(plan, version) + if !res.Valid { + t.Fatalf("save plan should allow all docs disabled: %+v", res.Issues) + } + act := validateActivationPlan(plan, version) + if act.Valid { + t.Fatal("activation should reject empty enabled doc set") + } +} + +func TestBuildDefaultPlan_IncludesDisplayNames(t *testing.T) { + version := testResolver().versions[workflowdef.OfficialKeyMain] + plan := buildDefaultPlan(version) + if len(plan.Stages) != 1 || plan.Stages[0].StageName != "V0.1 需求摸底" { + t.Fatalf("expected stage display name, got %+v", plan.Stages) + } + if len(plan.Stages[0].DocTasks) != 1 || plan.Stages[0].DocTasks[0].TaskTitle != "用户初始需求收集表" { + t.Fatalf("expected task display title, got %+v", plan.Stages[0].DocTasks) + } +} + +func TestApplyDefinitionLabels_OverridesClientText(t *testing.T) { + version := testResolver().versions[workflowdef.OfficialKeyMain] + plan := validPlan() + plan.Stages[0].StageName = "客户端伪造阶段" + plan.Stages[0].DocTasks[0].TaskTitle = "客户端伪造文档" + applyDefinitionLabels(&plan, version) + if plan.Stages[0].StageName != "V0.1 需求摸底" || plan.Stages[0].DocTasks[0].TaskTitle != "用户初始需求收集表" { + t.Fatalf("definition labels must win, got %+v", plan.Stages[0]) + } +} diff --git a/internal/projectworkflow/service_test.go b/internal/projectworkflow/service_test.go index 9f821bf2..7016e764 100644 --- a/internal/projectworkflow/service_test.go +++ b/internal/projectworkflow/service_test.go @@ -125,6 +125,39 @@ func TestProjectWorkflowService_CreateAndCreatorACL(t *testing.T) { } } +func TestProjectWorkflowService_ListAllProjects(t *testing.T) { + ctx := context.Background() + svc := NewService(NewStore(nil), NewDefaultCatalog()) + creator := &auth.UserAccount{ID: 102, Username: "lisi", Name: "李主管"} + + created, err := svc.CreateProject(ctx, creator, CreateProjectInput{Name: "管理员跨项目查看测试"}) + if err != nil { + t.Fatalf("CreateProject failed: %v", err) + } + visible, err := svc.ListProjects(ctx, 999) + if err != nil { + t.Fatalf("ListProjects failed: %v", err) + } + if len(visible) != 0 { + t.Fatalf("unrelated user should not see projects, got %d", len(visible)) + } + + all, err := svc.ListAllProjects(ctx) + if err != nil { + t.Fatalf("ListAllProjects failed: %v", err) + } + found := false + for _, project := range all { + if project.ID == created.ID { + found = true + break + } + } + if !found { + t.Fatalf("ListAllProjects should include project %d", created.ID) + } +} + func TestProjectWorkflowService_LineageAndStale(t *testing.T) { ctx := context.Background() store := NewStore(nil) diff --git a/internal/server/auth_handlers_test.go b/internal/server/auth_handlers_test.go index c7918a51..632548ae 100644 --- a/internal/server/auth_handlers_test.go +++ b/internal/server/auth_handlers_test.go @@ -25,6 +25,42 @@ func setupTestServer() (*httptest.Server, *auth.Service) { return ts, srv.AuthService() } +func TestAuthHandlers_PublicConfigUnauthenticated(t *testing.T) { + ts, authSvc := setupTestServer() + defer ts.Close() + + _ = authSvc.UpdateConfig(t.Context(), auth.SystemAuthConfig{ + AllowRegistration: true, + RequireApproval: true, + }) + + resp, err := http.Get(ts.URL + "/api/v1/auth/public-config") + if err != nil { + t.Fatalf("public-config request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("Expected 200 for unauthenticated /auth/public-config, got %d", resp.StatusCode) + } + + var cfg auth.SystemAuthConfig + if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil { + t.Fatalf("decode public-config: %v", err) + } + if !cfg.AllowRegistration || !cfg.RequireApproval { + t.Fatalf("unexpected public-config: %+v", cfg) + } + + adminResp, err := http.Get(ts.URL + "/api/v1/admin/config") + if err != nil { + t.Fatalf("admin config request failed: %v", err) + } + defer adminResp.Body.Close() + if adminResp.StatusCode != http.StatusUnauthorized { + t.Fatalf("Expected 401 for unauthenticated /admin/config, got %d", adminResp.StatusCode) + } +} + func TestAuthHandlers_UnauthenticatedMeReturns401(t *testing.T) { ts, _ := setupTestServer() defer ts.Close() diff --git a/internal/server/project_handlers_test.go b/internal/server/project_handlers_test.go index 02d34039..408c659c 100644 --- a/internal/server/project_handlers_test.go +++ b/internal/server/project_handlers_test.go @@ -9,8 +9,49 @@ import ( "chat-agent-system/internal/bizdev" "chat-agent-system/internal/config" + "chat-agent-system/internal/projectworkflow" ) +func TestHandleListProjects_AdminCanSeeProjectsOwnedByOtherUsers(t *testing.T) { + cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", AllowInMemory: true} + svc := bizdev.MustNewService(t, cfg) + srv := NewServer(cfg, svc) + ctx := t.Context() + + creator, err := srv.AuthService().GetUserByID(ctx, 2) + if err != nil { + t.Fatalf("load creator: %v", err) + } + created, err := srv.pwSvc.CreateProject(ctx, creator, projectworkflow.CreateProjectInput{ + Name: "非管理员创建的项目", + }) + if err != nil { + t.Fatalf("create project: %v", err) + } + + admin, err := srv.AuthService().GetUserByID(ctx, 1) + if err != nil { + t.Fatalf("load admin: %v", err) + } + req := httptest.NewRequest(http.MethodGet, "/api/v1/projects", nil) + req = req.WithContext(withPrincipal(req.Context(), admin)) + rec := httptest.NewRecorder() + srv.handleListProjects(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + var projects []*projectworkflow.Project + if err := json.NewDecoder(rec.Body).Decode(&projects); err != nil { + t.Fatalf("decode projects: %v", err) + } + for _, project := range projects { + if project.ID == created.ID { + return + } + } + t.Fatalf("admin response should include project %d", created.ID) +} + func TestHandleRunProjectWorkflow_DecodesBodyWithoutContentLength(t *testing.T) { cfg := &config.Config{ DataDir: t.TempDir(), diff --git a/internal/server/project_workflow_api_test.go b/internal/server/project_workflow_api_test.go index 353fc87d..edbe2d58 100644 --- a/internal/server/project_workflow_api_test.go +++ b/internal/server/project_workflow_api_test.go @@ -207,18 +207,18 @@ func TestProjectWorkflow_TestManagerAndLineageStale(t *testing.T) { require.Positive(t, proj.ID) projectID := proj.ID - // 2. 在未批准系统需求规格书时,直接触发 test_manager -> 应触发前置阻断 400 / ERR_PRECONDITION_FAILED - bodyRunTestFail, _ := json.Marshal(map[string]any{ + // 2. 无已批准 SRS 时,test_manager 仍可独立启动(PM/TE 解耦) + bodyRunTestNoSRS, _ := json.Marshal(map[string]any{ "task_key": "test_manager", "project_name": proj.Name, "backend": "stub", }) - reqRunTestFail := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/workflows/test_manager/run", projectID), bytes.NewReader(bodyRunTestFail)) - withUserIDAuth(reqRunTestFail, loginTestUser(t, handler, "zhanggong", "123456")) - recRunTestFail := httptest.NewRecorder() - handler.ServeHTTP(recRunTestFail, reqRunTestFail) - assert.Equal(t, http.StatusBadRequest, recRunTestFail.Code) - assert.Contains(t, recRunTestFail.Body.String(), "前置条件未满足") + reqRunTestNoSRS := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/workflows/test_manager/run", projectID), bytes.NewReader(bodyRunTestNoSRS)) + withUserIDAuth(reqRunTestNoSRS, loginTestUser(t, handler, "zhanggong", "123456")) + recRunTestNoSRS := httptest.NewRecorder() + handler.ServeHTTP(recRunTestNoSRS, reqRunTestNoSRS) + assert.Equal(t, http.StatusOK, recRunTestNoSRS.Code) + assert.NotContains(t, recRunTestNoSRS.Body.String(), "前置条件未满足") // 3. 在受控治理中创建并批准系统需求规格书 srDocID := fmt.Sprintf("doc_proj%d_system_reqs", projectID) diff --git a/internal/server/workflow_governance_pg_test.go b/internal/server/workflow_governance_pg_test.go index cdcc2793..51108f83 100644 --- a/internal/server/workflow_governance_pg_test.go +++ b/internal/server/workflow_governance_pg_test.go @@ -7,7 +7,6 @@ import ( "fmt" "net/http" "net/http/httptest" - "os" "testing" "time" @@ -25,10 +24,7 @@ import ( func openPGPoolWithMigrations(t *testing.T) *pgxpool.Pool { t.Helper() - dsn := os.Getenv("TEST_DATABASE_URL") - if dsn == "" { - dsn = "postgres://app:dev_secret_db_pass@127.0.0.1:5433/agent_system_dev?sslmode=disable" - } + dsn := db.SkipIfNoTestDatabaseURL(t) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() pool, err := pgxpool.New(ctx, dsn) diff --git a/internal/workflowdef/catalog_policy_test.go b/internal/workflowdef/catalog_policy_test.go new file mode 100644 index 00000000..29d95504 --- /dev/null +++ b/internal/workflowdef/catalog_policy_test.go @@ -0,0 +1,43 @@ +package workflowdef + +import "testing" + +func TestOfficialWorkflowKeys(t *testing.T) { + keys := OfficialWorkflowKeys() + if len(keys) != 3 { + t.Fatalf("expected 3 official keys, got %d", len(keys)) + } + for _, k := range keys { + if !IsOfficialWorkflowKey(k) { + t.Fatalf("key %q not official", k) + } + } +} + +func TestValidateDefinitionV2_OfficialDocCounts(t *testing.T) { + def, err := LoadManifestDefinition("main.v2.json") + if err != nil { + t.Fatal(err) + } + normalizeManifestDefinition(&def) + res := ValidateDefinitionV2(def) + if !res.Valid { + t.Fatalf("main v2 invalid: %+v", res.Issues) + } +} + +func TestValidateDefinitionV2_RejectsUpstream(t *testing.T) { + def := VersionDefinition{ + SchemaVersion: 2, + WorkflowKey: OfficialKeyTestManager, + RequiredUpstreamWorkflow: "product_manager", + Stages: []StageDef{{ + Key: "s", Position: 10, + DocTasks: []DocTaskDef{{Key: "test_manager", OutputDocType: "test_manager", ExecutorKey: "squad.test_manager.v1"}}, + }}, + } + res := ValidateDefinitionV2(def) + if res.Valid { + t.Fatal("expected upstream rejection") + } +} diff --git a/internal/workflowdef/publish_test.go b/internal/workflowdef/publish_test.go index e3c239f1..1e7ef287 100644 --- a/internal/workflowdef/publish_test.go +++ b/internal/workflowdef/publish_test.go @@ -2,21 +2,18 @@ package workflowdef_test import ( "context" - "os" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" + "chat-agent-system/internal/db" "chat-agent-system/internal/workflowdef" ) func openPool(t *testing.T) *pgxpool.Pool { t.Helper() - dsn := os.Getenv("TEST_DATABASE_URL") - if dsn == "" { - dsn = "postgres://app:dev_secret_db_pass@127.0.0.1:5433/agent_system_dev?sslmode=disable" - } + dsn := db.SkipIfNoTestDatabaseURL(t) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() pool, err := pgxpool.New(ctx, dsn) @@ -43,6 +40,10 @@ func TestPublishVersion_ImmutableAfterPublish(t *testing.T) { ctx := context.Background() svc := workflowdef.NewService(pool, nil) key := "test_wf_" + time.Now().Format("150405") + defer func() { + _, _ = pool.Exec(ctx, `DELETE FROM workflow_versions WHERE workflow_id IN (SELECT id FROM workflow_definitions WHERE def_key = $1)`, key) + _, _ = pool.Exec(ctx, `DELETE FROM workflow_definitions WHERE def_key = $1`, key) + }() def, ver, err := svc.CreateDefinition(ctx, workflowdef.CreateDefinitionInput{ Key: key, Name: "测试工作流", Definition: workflowdef.VersionDefinition{ From 38e44a2606b493ba5a28857c7eefba3ea1514431 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 12 Sep 2026 14:33:49 +0000 Subject: [PATCH 07/24] docs(workflow): record three-workflow governance remediation and test report Archive the strict governance design notes and validation report for the 2026-09-12 remediation cycle. Co-authored-by: Cursor --- ...workflows-strict-governance-remediation.md | 525 ++++++++++++++++++ ...lows-governance-remediation-test-report.md | 49 ++ 2 files changed, 574 insertions(+) create mode 100644 .doc/2026-09-12-three-workflows-strict-governance-remediation.md create mode 100644 .doc/test-reports/2026-09-12-three-workflows-governance-remediation-test-report.md diff --git a/.doc/2026-09-12-three-workflows-strict-governance-remediation.md b/.doc/2026-09-12-three-workflows-strict-governance-remediation.md new file mode 100644 index 00000000..8018467f --- /dev/null +++ b/.doc/2026-09-12-three-workflows-strict-governance-remediation.md @@ -0,0 +1,525 @@ +# 三工作流严格治理与现有系统融合整改方案 + +- 日期:2026-09-12 +- 状态:核心业务决策已确认 / 可进入实施拆分 +- 范围:工作流定义、项目实例、阶段门禁、文档三权、通知与对话推进、运行时授权、数据迁移和验收 +- 本文性质:当前代码与开发数据库的只读核验结论及整改设计;本轮未修改业务代码 + +## 1. 结论先行 + +系统面向项目创建者和项目成员时,只应存在以下 **3 个可选择工作流**: + +1. **商务对接流程**:7 个文档,3 个阶段; +2. **智能产品经理流程**:1 个输出文档,1 个阶段; +3. **智能测试工程师流程**:1 个输出文档,1 个阶段。 + +商务对接流程的文档数量可以定案为 **7 个,不是 6 个**。前端模板工厂和测试固定断言了 7 个任务键,后端标准任务目录也包含同一组 7 个商务文档,历史专项测试报告同样把它们定义为标准全生命周期交付物。[^biz-seven-test][^biz-seven-backend][^biz-seven-report] + +当前系统尚未满足这一目标,且不是简单改几个显示名称即可解决: + +- 启动代码实际发布 5 个默认工作流,除目标三项外还包含“医疗器械研发”和“非医疗纯软件”;[^bootstrap-five] +- 当前开发库已有 92 个工作流定义,其中大量为测试定义;三个目标工作流仍都是单阶段的 schema v1; +- 仓库虽然有三个 schema v2 清单,但商务清单只有 5 个文档,漏了 `feasibility_dev` 和 `user_requirements`;[^main-v2-missing] +- 三个 v2 清单仅被嵌入和登记,没有进入启动发布链;已有发布版又会让 seed 逻辑直接退出,因此现有库不会自动升级;[^manifest-loader][^seed-no-upgrade] +- 旧运行入口会把多阶段版本压成“第一个阶段的第一个任务”,与项目实例运行时并存,形成双轨权威;[^catalog-flatten] +- 配置页面允许勾选关闭文档,但后端校验禁止关闭所有 `required/blocking` 文档;当前清单又把所有文档都标成这两类,因此界面能力实际上不可用;[^plan-disable-conflict] +- 智能测试工程师当前被 manifest、执行服务和前端提示硬绑定到产品经理/SRS,与已确认的两个智能工作流独立运行原则冲突;[^test-upstream][^test-hard-dependency] +- 阶段推进虽然先检查批准状态,但事务内会把所有启用任务强制写成 `approved`,并且最后阶段完成后没有把项目恢复到可选择下一工作流的状态;[^advance-forces-approved][^project-lifecycle-gap] +- 对话中的“推进阶段”仍调用旧商务成熟度执行器,并没有调用项目工作流实例的阶段推进服务;[^dialogue-old-advance] + +因此本次整改的核心是:**以已激活项目工作流实例的冻结快照作为唯一运行时权威**。工作流目录、项目配置、文档三权、任务启动、审批、阶段门禁、通知和对话都必须围绕同一个实例标识运行,旧的直接切换和扁平执行入口退出生产路径。 + +## 2. 已确认的业务基线 + +### 2.1 唯一允许对项目展示和选择的工作流 + +| 稳定内部 key | 对外名称 | 阶段数 | 输出文档数 | 说明 | +|---|---|---:|---:|---| +| `main` | 商务对接流程 | 3 | 7 | 保留现有 key 以避免破坏历史数据,只改正式名称 | +| `product_manager` | 智能产品经理流程 | 1 | 1 | 输出《系统需求规格书》 | +| `test_manager` | 智能测试工程师流程 | 1 | 1 | 保留现有 key,只把“测试经理”统一更名为“测试工程师” | + +“系统只需要三个流程”的含义应同时落实到三个层面: + +- 项目设置 API 只返回这 3 个可选项; +- 前端任何入口只展示这 3 个可选项; +- 服务端只允许为这 3 个正式、已发布版本创建项目工作流实例。 + +后台可以保留历史定义和草稿以满足审计,但必须标记为 `retired` 或 `project_selectable=false`,不能继续出现在项目目录中。不能仅靠前端白名单隐藏,因为当前设置服务会枚举数据库里全部有发布版本的定义。[^settings-all-definitions] + +### 2.2 商务对接流程:7 个文档,3 个阶段 + +沿用系统已经形成的 V0.1 / V0.5 / V1.0 业务阶段和模板归属,目标定义如下。当前模板工厂已按 `1 + 5 + 1` 归档这 7 个文档。[^template-stage-map] + +| 阶段 | 顺序 | task key | 正式文档名 | 默认状态 | 阶段门禁 | +|---|---:|---|---|---|---| +| V0.1 需求摸底 | 10 | `intake` | 用户初始需求收集表 | 启用 | 启用时必须批准 | +| V0.5 方案与可行性 | 20 | `research` | 调研报告 | 启用 | 启用时必须批准 | +| V0.5 方案与可行性 | 21 | `feasibility_dev` | 设计开发可行性评估报告 | 启用 | 启用时必须批准 | +| V0.5 方案与可行性 | 22 | `feasibility_reg` | 注册路径可行性评估报告 | 启用 | 启用时必须批准 | +| V0.5 方案与可行性 | 23 | `user_requirements` | 用户需求表 | 启用 | 启用时必须批准 | +| V0.5 方案与可行性 | 24 | `prelim_design` | 系统初步设计方案 | 启用 | 启用时必须批准 | +| V1.0 商务报价 | 30 | `cdo_quote` | CDO 报价单 | 启用 | 启用时必须批准 | + +当前后端商务阶段只执行 `intake`、`research`、`feasibility_reg`、`prelim_design`、`cdo_quote` 五项,正好缺少另外两项,必须同步补齐阶段执行映射和 executor 注册。[^biz-stage-five][^executor-missing] + +### 2.3 智能产品经理流程 + +| 阶段 | task key | 唯一输出文档 | 默认状态 | 门禁 | +|---|---|---|---|---| +| 系统需求基线 | `system_reqs` | 系统需求规格书 | 启用 | 文档批准后,创建者确认完成工作流 | + +### 2.4 智能测试工程师流程 + +| 阶段 | task key | 唯一输出文档 | 默认状态 | 门禁 | +|---|---|---|---|---| +| 系统验证策划 | `test_manager` | 系统验证方案及计划 | 启用 | 文档批准后,创建者确认完成工作流 | + +**智能产品经理流程和智能测试工程师流程相互解耦。** 智能测试工程师流程不再要求项目曾运行智能产品经理流程,也不再要求必须存在该流程输出的《系统需求规格书》。它直接从当前项目可用于工作流的对话、文档、上传材料、项目事实和决策记录中提取需求及验证依据,独立生成《系统验证方案及计划》。 + +如果项目中恰好存在已批准的《系统需求规格书》,它只是项目输入集合中的一项高权威来源,不是启动前置条件,也不产生流程级耦合。当前 manifest、执行服务、血缘逻辑和前端提示仍硬编码了这一依赖,整改时必须一起删除。[^test-upstream][^test-hard-dependency] + +### 2.5 两个智能工作流的统一项目上下文输入 + +智能产品经理和智能测试工程师使用同一个“项目上下文快照服务”,但采用不同的分析目标和输出模板: + +| 输入类别 | 纳入内容 | 建议权威等级 | +|---|---|---| +| 项目事实 | 项目名称、客户、产品类型、预期用途、目标市场、结构化事实黑板 | 已确认事实 | +| 项目决策 | 创建者确认事项、里程碑决策、已关闭的待确认项 | 已确认决策 | +| 项目文档 | 当前有效版本的受控文档、项目共享文档、上传材料 | 按批准状态和来源分级 | +| 项目对话 | 所有已进入项目共享知识边界的成员对话及其附件 | 原始沟通证据 | +| 历史输出 | 商务、产品、测试或自由任务形成的项目交付物 | 按状态和版本分级 | +| 知识依据 | 系统检索到的法规、标准和内部知识库命中 | 外部参考依据 | + +这里的“所有”是指 **当前项目知识边界内全部有资格被工作流使用的内容**,不是把数据库中所有原文无差别拼接到一次模型调用中。建议遵循以下规则: + +1. 只纳入当前项目,绝不跨项目召回; +2. 私人草稿、个人私聊只有在明确“共享到项目”后才进入项目级工作流输入,避免流程执行扩大原有可见范围; +3. 任务启动时生成不可变 `project_context_snapshot`,记录截止时间、来源 ID、版本、内容 hash、权限判定和引用位置; +4. 全量来源先建立清单,再按任务目标检索相关片段;已确认事实和已批准文档必须进入固定核心上下文,普通对话按相关性分批读取; +5. 冲突时不静默覆盖,优先级建议为:创建者确认事实/决策 > 当前已批准文档 > 用户上传原始材料 > 里程碑记录 > 普通项目对话 > 智能体历史总结; +6. 输出必须带 source manifest。智能测试工程师需在唯一输出文档内部生成“需求/风险—测试项—来源”追溯表,不额外创建第二个受控输出文档; +7. 任务运行后项目来源发生变化时,依据被引用来源的 hash 判断 `input_changed/stale`,而不是只监听《系统需求规格书》。未被本次输出引用的无关对话变化不应把文档误标为 stale。 + +现有 Squad 工作区已经支持 project context、materials、prior docs、knowledge hits 等输入槽位,也会建立带权威等级的 context 索引;产品经理还已有从项目事实和里程碑生成输入材料的局部实现。因此应把这些能力上移为通用项目上下文快照服务,而不是为测试流程继续维护单一 SRS 特例。[^context-input-foundation] + +## 3. 不可违反的业务规则 + +### 3.1 选择、启动和切换 + +1. 只有项目创建者可以选择、配置、激活、推进或受控终止项目工作流。 +2. 一个项目同一时刻最多只能有一个 live 实例。 +3. 在 `active`、`awaiting_stage_confirmation` 或 `termination_requested` 状态下,不允许选择或切换工作流。 +4. “切换工作流”不是修改当前实例的 `workflow_key`,而是当前实例 `completed` 或 `terminated` 后创建一个新的 planning 实例。 +5. 激活时冻结工作流版本、阶段、文档开关、模板版本、执行器、质量门禁和三权责任人。运行中不可静默修改。 +6. 管理员不因系统身份自动替代项目创建者执行推进;如以后引入 break-glass,必须单独设计理由、再认证和审计,且不能代替文档签署人。 + +目标状态机: + +```mermaid +stateDiagram-v2 + [*] --> planning: 创建者选择并配置 + planning --> active: 预览后确认激活 + active --> awaiting_stage_confirmation: 当前阶段所有启用文档已批准 + awaiting_stage_confirmation --> active: 创建者确认进入下一阶段 + awaiting_stage_confirmation --> completed: 创建者确认完成最后阶段 + active --> termination_requested: 创建者申请终止 + termination_requested --> terminated: 二次确认并记录原因 + completed --> planning: 创建下一工作流实例 + terminated --> planning: 创建下一工作流实例 +``` + +数据库已经为一个 planning 实例和一个 live 实例设置了部分唯一索引,也预留了 `awaiting_stage_confirmation`、`terminated` 等状态,适合沿此模型完善,不必另造一套表。[^instance-schema] + +### 3.2 文档启用与禁用 + +项目创建者可以在 planning 状态逐文档启用或禁用。严格语义如下: + +| 文档状态 | 三权配置 | 可创建/启动编制 | 可提交审核 | 可审核 | 可批准 | 是否计入阶段门禁 | +|---|---|---:|---:|---:|---:|---:| +| enabled | 作者、审核人、批准人均必填且互斥 | 是,仅作者或明确执行人 | 是,仅作者 | 是,仅审核人 | 是,仅批准人 | 是 | +| disabled | 必须为空 | 否 | 否 | 否 | 否 | 否 | + +关键约束: + +- `default_enabled` 只表示默认值,不表示不可关闭; +- `blocking_when_enabled=true` 表示文档一旦启用,未批准就阻断阶段; +- 新增明确字段 `creator_configurable=true/false`,不要继续混用现有 `required`、`blocking`、`default_enabled` 三个布尔值表达四种含义; +- 禁用后实例状态写为 `skipped_disabled`,不创建 DocGov 文档、不生成三权行,也不允许通过 task key、对话、SSE、Agent tool 或旧 API 旁路启动; +- 对禁用文档发起动作时统一返回 `409 DOC_TASK_DISABLED`;对启用文档但责任人不匹配返回 `403 DOC_DUTY_FORBIDDEN`; +- 激活计划必须至少包含一个启用文档。这样一文档流程仍可在配置时关闭唯一文档,但关闭后不能激活一个没有产出的空工作流。 + +现有数据结构已经能表达 `enabled` 和三权,且持久化时会跳过禁用文档的职责写入;可复用这部分。问题在于定义校验把 required/blocking 文档一律视为不可关闭,需要先修正语义。[^plan-structure][^duty-write-enabled-only] + +### 3.3 阶段完成和推进 + +阶段就绪的唯一业务判定应为: + +```text +stage_ready = 当前阶段处于 active + AND 当前阶段至少有一个 enabled 文档 + AND 每个 enabled 文档的当前受控版本都为 approved + AND 没有正在执行、待审核、待批准或 stale 的同任务实例 +``` + +推进动作必须满足: + +- 服务端在同一数据库事务中锁定 workflow instance 和 stage instance; +- 重新计算门禁,不能只信几秒前的 readiness 响应; +- 校验操作者仍是项目创建者; +- 校验 `expected_config_revision` 和幂等键; +- 只改变阶段和实例状态,绝不把文档“补写”为 approved; +- 进入下一启用阶段;若没有下一阶段,则把实例标为 completed,并把项目切回“可配置下一个工作流”的状态; +- 同事务关闭对应 `stage_advance_request`,写审计和 outbox 事件。 + +当前 readiness 已经忽略禁用任务、检查启用必需文档是否批准,并限制创建者推进,这一骨架可以保留;但它按 `project_id + task_key` 汇总文档状态,仍需升级到 `doc_task_instance_id + document_version_id`,避免跨阶段或重复实例串线。[^readiness-current][^approval-key-too-wide] + +### 3.4 主动提醒和对话推进 + +系统在 readiness 从 false 变为 true 时生成一次 `stage.ready_to_advance` 事件,并只通知项目创建者。已有 outbox、站内通知、去重键以及 24 小时再次提醒机制可以复用。[^notify-ready][^notify-reminder] + +建议闭环: + +1. 最后一份启用文档批准; +2. 事务后事件处理器重新计算 readiness; +3. 实例进入 `awaiting_stage_confirmation`; +4. 创建者收到站内消息和可操作确认卡:阶段名、启用/已批准文档清单、下一阶段或“完成工作流”; +5. 创建者点击按钮,或在项目对话中说“推进到下一阶段 / 确认推进”; +6. 两种入口都调用同一个 `AdvanceStage` 应用服务; +7. 成功后通知状态改为 acted,推进请求改为 confirmed;若条件在确认前失效,则返回最新阻断项并把旧请求标为 superseded。 + +当前前端已经每 15 秒拉取通知并显示“确认推进”按钮,但对话入口仍走旧的 `bizdev.ExecuteStage`,所以需要把自然语言意图解析与执行彻底分开:识别器只产生命令,实例服务才拥有推进权。[^stage-banner][^dialogue-old-advance] + +## 4. 当前系统与目标的差距 + +### 4.1 定义和目录 + +| 编号 | 当前事实 | 风险 | 整改 | +|---|---|---|---| +| WF-CAT-01 | Bootstrap 发布 5 个工作流 | 项目会看到目标外流程 | 只发布三项;其余退役且不可选 | +| WF-CAT-02 | 设置服务返回所有有发布版的定义 | 测试/历史流程进入项目 | 增加 `project_selectable`,查询端白名单 + 状态双重校验 | +| WF-CAT-03 | 开发库有 92 个定义,88 个 key 为 `pg_*` 或 `test_*` | 测试污染业务目录 | 测试强制独立数据库/schema,fixture 必须清理 | +| WF-DEF-01 | 三个正式 key 当前均为 schema v1 单阶段 | 实际运行不是目标结构 | 以新版本发布 schema v2/v3,禁止覆盖旧版本 | +| WF-DEF-02 | `main.v2.json` 只有 5 个文档 | 商务流程缺 2 个输出 | 补齐 7 个并增加 executor、模板和质量门禁校验 | +| WF-DEF-03 | v2 manifest 未进入 bootstrap | 仓库文件与运行数据库不一致 | 建立 manifest 迁移器,按 hash 创建并发布新版本 | +| WF-DEF-04 | seed 发现任何 published 就跳过 | 永远停留旧版本 | 按内容 hash 和目标版本升级,写版本迁移审计 | +| WF-DEF-05 | DBCatalog 压扁完整定义 | 多阶段配置不能驱动运行 | 删除生产 flatten 路径,运行只读取冻结实例 | +| WF-DEF-06 | 测试流程声明 product_manager 上游,执行服务又硬查 approved SRS | 两个智能工作流不能独立运行 | 删除流程级依赖;改用统一项目上下文快照和多来源血缘 | + +### 4.2 项目实例和生命周期 + +| 编号 | 当前事实 | 风险 | 整改 | +|---|---|---|---| +| WF-INS-01 | planning / active 和唯一 live 骨架已存在 | 可复用但未闭环 | 扩展 awaiting/completed/terminated 完整转换 | +| WF-INS-02 | 完成最后阶段只更新实例 | 项目仍为 active,无法配置下一流程 | 同事务更新项目可选择状态并清空 current live 指针 | +| WF-INS-03 | 无受控终止 API | 异常流程永久锁死或只能改库 | 增加申请、影响预览、创建者二次确认和终止原因 | +| WF-INS-04 | 激活 token 明文保存,消费与激活分事务 | 失败后 token 已失效或可泄露 | 只存 hash,并在激活事务内锁定、消费 | +| WF-INS-05 | 冻结子任务只写 key/enabled/status | executor、模板、质量门禁仍依赖可变定义 | 激活时把完整运行元数据写入实例表并以实例表为权威 | + +数据库已添加 executor/template/quality gate 列,但当前实例写入 SQL 仍只插入 task key、enabled 和 status,证明冻结快照尚未真正成为执行权威。[^instance-runtime-metadata] + +### 4.3 文档、三权和运行时授权 + +| 编号 | 当前事实 | 风险 | 整改 | +|---|---|---|---| +| WF-DOC-01 | 启用文档要求三权且互斥 | 基础规则正确 | 再校验三人均为 active 项目成员且账户有效 | +| WF-DOC-02 | 禁用文档不会写三权 | 基础持久化正确 | 增加 `skipped_disabled` 和全入口拒绝 | +| WF-DOC-03 | 访问过滤按 task key 判断 | 同 key 重复实例可能串权 | 所有动作使用 `doc_task_instance_id` | +| WF-DOC-04 | 无 live 实例时访问过滤默认不限制 | 完成后旧任务入口可能恢复可用 | 正式项目按治理模式 fail closed;legacy 仅限显式迁移标记 | +| WF-DOC-05 | 运行入口仍接受 task key 并查扁平目录 | 可能绕过阶段和冻结绑定 | 请求只传 doc task instance id,服务端解析 executor/template/duty | +| WF-DOC-06 | DocGov 状态机可验证具体审核/批准人 | 可复用 | 文档必须外键绑定 doc task instance 和版本 | + +现有阶段访问代码确实会从 live 快照中排除禁用任务,这是可保留的基础;但当没有 live 实例或实例 completed 时会返回 unrestricted,因此不能把它视为禁用文档全生命周期的完整保障。[^stage-access] + +### 4.4 前端、通知和对话 + +| 编号 | 当前事实 | 风险 | 整改 | +|---|---|---|---| +| WF-UI-01 | 旧切换器硬编码三项,但名称不一致 | 用户认知混乱 | 统一为三个正式名称,数据来自服务端目录 | +| WF-UI-02 | 新治理 API 失败时回退旧直接切换 UI | 可重新打开不合规路径 | 删除回退;治理不可用时只读并明确报错 | +| WF-UI-03 | 旧切换在版本冲突后自动重试 | 可能覆盖用户最新选择 | 展示冲突并要求重新确认,不自动提交高风险操作 | +| WF-UI-04 | 项目设置只显示 stage/task key | 不利于业务确认 | 显示正式阶段名、文档名、启用状态、三权和影响摘要 | +| WF-UI-05 | live 配置缺少完整只读审计视图 | 创建者不清楚冻结内容 | 展示版本、hash、激活人/时间、所有 enabled/disabled 和职责 | +| WF-NOTIFY-01 | readiness 通知和 24h 提醒已存在 | 基础可复用 | 增加状态转换、已读/已处理、失效和重算 | +| WF-DLG-01 | 对话推进旧商务阶段 | 可越过新实例门禁 | 对话调用同一实例 AdvanceStage command | +| WF-CTX-01 | 产品经理仅在无材料时拼装部分项目事实;测试工程师只注入 SRS | “全部项目内容”没有统一、可复现的输入语义 | 建立项目上下文快照、来源清单、权限过滤、冲突优先级和 hash 血缘 | + +旧前端切换器在治理不可用时仍调用 `/active-workflow`,并在冲突后自动取新版本再次切换;这两个行为都应删除。[^legacy-switch-ui] 旧服务端路由在迁移完成后应返回 `410 WORKFLOW_SWITCH_API_RETIRED`,而不是继续作为备用入口。[^legacy-route] + +### 4.5 开发数据库现场快照 + +2026-09-12 对当前 `shuimu-context-pg-dev / agent_system_dev` 做只读查询得到: + +```text +workflow_definitions = 92 +published workflow versions = 92 +main = version 1, schema 1, 1 stage +product_manager = version 1, schema 1, 1 stage +test_manager = version 1, schema 1, 1 stage +project_workflow_instances: active 32, completed 47 +projects.lifecycle_status: active 97, planning 51 +completed 实例已存在、无 live 实例但项目仍为 active:47 个 +测试命名定义(pg_* / test_*):88 个 +``` + +这些数字主要反映当前开发环境和集成测试污染,不能直接当成生产数据;但它们证明目录查询没有隔离正式定义与测试定义,也直接复现了“实例已完成而项目仍 active”的生命周期断点。PG 测试在未设置 `TEST_DATABASE_URL` 时默认连接开发库,部分定义发布测试又没有清理工作流定义,因此必须纳入整改。[^test-db-default][^test-definition-no-cleanup] + +## 5. 目标技术模型 + +### 5.1 单一运行权威 + +```text +Published Workflow Version + │ 创建 planning plan + ▼ +Project Workflow Instance + immutable config snapshot + │ 激活时物化 + ├── Stage Instances + │ └── Doc Task Instances + │ ├── frozen executor/template/quality gate + │ ├── duty assignments + │ ├── controlled document versions + │ └── task runs + ├── Stage Advance Requests / Notifications + └── Audit / Outbox Events +``` + +激活后,任何运行请求不得再从“最新发布定义”、前端 task key 或 legacy catalog 推导行为。最新定义只用于创建未来实例;已运行实例永远引用自己的冻结数据。 + +### 5.2 定义 schema 建议 + +文档任务应至少明确以下字段: + +```json +{ + "key": "research", + "title": "调研报告", + "default_enabled": true, + "creator_configurable": true, + "blocking_when_enabled": true, + "executor_key": "bizdev.research.v1", + "quality_gate_key": "quality.generic.v1", + "template": {"asset_key": "research", "version": 1}, + "output_doc_type": "research", + "output_doc_title": "调研报告" +} +``` + +发布校验必须增加: + +- workflow key 属于三个正式 key 之一,且对外名称准确; +- 阶段 key、position、任务 key 均唯一且非空; +- 每个阶段至少一个文档任务; +- executor、quality gate 和 ready 模板版本全部存在; +- 三个正式流程精确满足 7 / 1 / 1 文档清单; +- 三个正式工作流均不声明流程级 `required_upstream_workflow`; +- 项目上下文输入策略、来源权限和冲突优先级完整; +- 禁用策略、完成策略和参与策略完整; +- 发布后版本不可原地修改。 + +当前 v2 校验只检查 schema 版本、阶段位置、executor/quality gate 注册和输出类型,尚不足以守住上述业务契约。[^validator-v2] + +### 5.3 数据结构增量 + +优先扩展现有表,不重复建模: + +- `workflow_definitions`:增加 `project_selectable`、`catalog_order`、`official_code`; +- `workflow_versions.definition_json`:采用新禁用语义并保留版本 hash; +- `project_workflow_instances`:增加 `completed_at/by`、`terminated_at/by/reason`、`previous_instance_id`; +- `project_stage_instances`:增加 `ready_at`、`completed_at/by`; +- `project_doc_task_instances`:持久化 `executor_key`、`quality_gate_key`、模板 asset/version、output type/title;禁用任务状态为 `skipped_disabled`; +- `documents`:增加 `doc_task_instance_id`,受控文档版本唯一绑定; +- `project_context_snapshots`:记录项目、任务、截止时间、汇总 hash、创建人和冻结状态; +- `project_context_snapshot_sources`:逐项记录 source type/id/version/hash、权限范围、权威等级、是否实际引用; +- `task_runs`:增加 `workflow_instance_id`、`stage_instance_id`、`doc_task_instance_id`、`context_snapshot_id`; +- `document_lineages`:从单一“上游 SRS”扩展为多来源 lineage,允许对话、材料、事实、决策和文档版本作为来源; +- `stage_advance_requests`:保留 readiness revision,补 `idempotency_key`、`superseded_at`; +- 审计事件:至少记录 plan saved、activated、document disabled/enabled、duty assigned、stage ready、stage advanced、workflow completed/terminated。 + +### 5.4 API 收敛 + +建议正式接口: + +| 方法与路径 | 用途 | 关键约束 | +|---|---|---| +| `GET /api/v1/workflows/catalog` | 返回严格三项正式目录 | 只含 project_selectable + published | +| `GET /api/v1/projects/{id}/settings` | 规划或冻结配置视图 | 项目访问控制 | +| `PUT /api/v1/projects/{id}/workflow-plan` | 选择流程、开关文档、设置三权 | 仅创建者、仅 planning、乐观锁 | +| `POST /api/v1/projects/{id}/activation-preview` | 完整影响与校验 | 无空流程、三权和上下文策略通过 | +| `POST /api/v1/projects/{id}/workflow-instances/activate` | 冻结并激活 | 创建者二次确认、原子 token | +| `GET /api/v1/workflow-instances/{id}` | 运行和历史详情 | 项目 + 阶段可见性 | +| `GET /api/v1/workflow-instances/{id}/stages/{key}/readiness` | 门禁详情 | 返回所有启用文档状态 | +| `POST /api/v1/workflow-instances/{id}/stages/{key}/advance` | 创建者推进或完成 | 事务内重算、幂等 | +| `POST /api/v1/workflow-instances/{id}/termination-preview` | 终止影响 | 未决任务、文档和通知清单 | +| `POST /api/v1/workflow-instances/{id}/terminate` | 受控终止 | 创建者、原因、二次确认 | +| `POST /api/v1/doc-task-instances/{id}/runs` | 启动编制 | enabled + active stage + duty | + +旧 `/projects/{id}/active-workflow` 和依赖扁平 workflow key 的 run 接口先记录迁移告警,完成客户端切换后返回 410。 + +## 6. 实施顺序 + +### Phase 0:冻结错误扩散和数据盘点 + +1. 给项目目录增加服务端正式白名单,立即只暴露三个目标 key。 +2. 禁止测试默认连接开发库;CI 必须显式提供隔离的 `TEST_DATABASE_URL`。 +3. 备份并分类 92 个定义及其引用:正式、历史、测试、被实例引用。 +4. 未被任何实例引用的测试定义在开发环境清理;被引用定义只退役,不物理删除。 +5. 修复开发环境中 completed instance 与 project lifecycle 不一致的数据,但先输出 dry-run 清单。 + +### Phase 1:发布三个唯一正式定义 + +1. 修订三个 manifest 的正式名称与 schema;商务流程补齐 7 个文档。 +2. 为 `feasibility_dev` 和 `user_requirements` 注册专用 executor,或明确映射到通用任务 executor;禁止使用含混的法规 executor 代替。 +3. 从 `test_manager` manifest、默认目录和校验模型中删除 `required_upstream_workflow=product_manager`,使三个流程在流程级完全独立。 +4. 增加严格发布校验和 7 / 1 / 1 契约测试。 +5. 实现 hash 驱动的版本升级:已有 v1 时创建 v2/v3,而不是退出或覆盖。 +6. `medical_device_development`、`non_medical_software` 及其他定义全部 `project_selectable=false` 并退役。 + +### Phase 2:补齐实例状态机和禁用语义 + +1. 重构 plan validator:creator-configurable 文档可关闭;启用文档三权必填;空工作流不可激活。 +2. 校验三权人员为有效项目成员、账户有效且三权互斥。 +3. 激活时完整物化运行元数据,以实例为唯一权威。 +4. 阶段推进事务内重算门禁,删除“推进时强制批准文档”的 SQL。 +5. 完成最后阶段时同步更新项目状态,使其可创建下一 planning 实例。 +6. 实现受控终止,保证 live 实例不会永久锁住项目。 + +### Phase 3:运行、文档治理和权限合流 + +1. 建立通用项目上下文快照服务,覆盖项目事实、项目级对话、文档、材料、决策、历史输出和知识命中。 +2. 产品经理和测试工程师分别从同一快照服务取数;删除测试任务中“查找已批准 system_reqs,否则拒绝”的特例。 +3. 所有任务启动改用 `doc_task_instance_id`;服务端解析 task/executor/template/stage/duty/context snapshot。 +4. 文档创建、查看、编辑、提交、审核、批准均校验同一 doc task instance。 +5. disabled、非当前阶段、非责任人、实例非 live 均 fail closed。 +6. SSE、异步 task run、恢复/取消、RAG 检索、Agent tools 使用同一策略决策。 +7. 审批回调按具体文档版本更新具体 doc task instance,不再按项目 + task key 批量更新。 +8. 把 SRS 单来源 lineage 改成 source manifest 多来源血缘;只在实际引用来源变化时标记输出待复核。 + +### Phase 4:通知、对话和前端闭环 + +1. readiness 边沿触发状态和通知,补 read/acted/dismissed API。 +2. 把对话“推进/完成工作流”绑定到项目实例 command;保留明确确认卡。 +3. 项目设置只显示三项,按正式名称显示阶段和文档。 +4. active/awaiting 时显示冻结只读视图,隐藏所有切换动作。 +5. completed/terminated 后显示“配置下一个工作流”,而不是修改旧实例。 +6. 删除旧切换回退、冲突自动重试和 `WorkflowPrerequisiteBanner` 的产品经理前置提示。 +7. 在运行预览中展示项目上下文截止时间、来源数量、被排除的私有/无权内容和冲突项。 + +### Phase 5:迁移和发布 + +1. 新项目只允许使用新的三个正式版本。 +2. 尚未激活的 planning v1 计划自动重建为新版本草稿,并要求创建者重新确认三权。 +3. 已激活 v1 实例不静默改写:真实业务实例由创建者选择继续兼容完成或受控终止后重建;开发/测试垃圾实例按确认后的清理脚本处理。 +4. 先 shadow 计算新旧 readiness 差异,再开启新推进服务。 +5. 通过全套验收后关闭 legacy route 和 flatten catalog。 + +## 7. 验收标准 + +### 7.1 目录和定义 + +- 任意项目、任意普通用户或管理员在项目选择页都只能看到 3 个工作流。 +- 正式名称精确为“商务对接流程 / 智能产品经理流程 / 智能测试工程师流程”。 +- 定义快照精确为 7 / 1 / 1;缺一、多一、重复 key、模板未 ready 或 executor 未注册均无法发布。 +- 三个正式定义均没有流程级上游依赖;产品经理和测试工程师可被分别独立选择和运行。 +- 服务重启、数据库迁移和旧数据存在时,目录结果仍稳定为三项。 + +### 7.2 选择和冻结 + +- 只有项目创建者可保存、激活和推进;普通成员、系统管理员默认均被拒绝。 +- live 实例存在时,UI、API 和对话三个入口均不能切换。 +- 工作流完成或受控终止后,创建者可以配置下一实例;旧实例保持只读可追溯。 +- 两个浏览器同时配置或推进时只有一个成功,另一个收到版本冲突并重新确认。 + +### 7.3 文档开关和三权 + +- 启用文档缺任一职责、职责重复、责任人非 active 项目成员时不能激活。 +- 禁用文档没有职责行、没有受控文档、没有运行按钮,也不能通过手工 API、SSE 或对话启动。 +- 对禁用文档的编制、提交、审核、批准请求全部稳定返回 `DOC_TASK_DISABLED`。 +- 再启用只允许发生在 planning;激活后不能原地改变。 + +### 7.4 阶段门禁和提醒 + +- 当前阶段 N 个启用文档中任一未批准,创建者按钮和对话推进均被阻断并返回清单。 +- 全部批准后只产生一次首提醒;24 小时未处理才产生一次升级提醒。 +- readiness 后文档被驳回或标 stale 时,旧推进请求失效,确认动作不能成功。 +- 推进事务不会修改任何文档审批状态。 +- 最后一阶段确认后实例为 completed,项目立即可配置下一流程。 + +### 7.5 安全、恢复和真实链路 + +- 跨项目、非成员、非职责人、未来阶段成员、已退出成员全部有服务端负例。 +- API、SSE、异步任务、断线恢复、搜索、导出、RAG 和 Agent tool 的判定一致。 +- 服务重启后能恢复 live 实例、当前阶段、禁用状态、职责和 pending 推进请求。 +- 使用真实 PostgreSQL 和真实 PI Agent 分别跑完商务 7 文档、产品 1 文档、测试 1 文档链路。 +- 项目从未运行产品经理流程且不存在 SRS 时,测试工程师仍可依据项目对话、文档和材料成功生成唯一测试方案;输出包含逐项来源追溯和待确认清单。 +- 项目已有 SRS 时,该文档被当作高权威来源纳入,但移除它不会导致工作流定义级依赖错误;是否需要重新生成由本次 source manifest 判断。 +- 项目私聊/个人草稿未共享时不进入上下文;共享后可被新快照发现。跨项目内容在检索和快照层都有负例。 +- 同一组项目输入分别启动产品经理和测试工程师时,两者快照可复现、来源清单一致,输出目标和模板不同。 +- 测试结束后业务数据库中不残留 `pg_*`、`test_*` 定义或测试项目。 + +## 8. 已确认的业务决策及实施解释 + +2026-09-12 已确认以下决策,后续实现和验收以此为准: + +1. **允许关闭单文档流程的唯一文档,但空工作流不能激活。** 配置可以保存,激活预览必须明确阻断并提示重新启用文档或选择其他工作流。 +2. **智能产品经理和智能测试工程师解耦。** 两者都直接使用当前项目知识边界内的对话、文档、材料、事实和决策。任何一方的产出都不是另一方的启动前置;已有 SRS 仅作为高权威输入来源。 +3. **开放项目创建者受控终止工作流。** 必须二次确认、填写原因、处理或取消在途任务并形成审计;completed 和 terminated 都允许创建下一工作流实例,但历史含义必须区分。 + +这些决策合理且彼此一致。第 2 项的实施建议是“流程解耦、数据可组合、来源可追溯”,而不是取消质量控制:测试工程师必须自己从项目上下文抽取需求和风险,生成文档内追溯矩阵,并将缺少判据的内容列为待确认,不能因为没有 SRS 就编造需求。 + +商务流程按 7 个文档落地、系统只暴露三个正式流程、运行中不可切换、阶段推进必须由创建者确认、禁用文档不得参与任何编审批动作,也均为已确认要求。 + +## 9. 建议的实施交付拆分 + +建议按以下 8 个可独立评审的变更集提交,避免一次大改难以回滚: + +1. 正式目录隔离与测试数据库隔离; +2. 三个正式 manifest、严格 validator 和版本升级器; +3. planning 配置、文档开关和成员/三权校验; +4. 激活冻结与完整实例物化; +5. doc task instance 驱动的执行、DocGov 和统一授权; +6. 原子 readiness / advance / complete / terminate 状态机; +7. 通知、对话命令与前端只读/下一流程体验; +8. 数据迁移、兼容退出、真实 PG/PI Agent E2E 和发布门禁。 + +每个变更集都应包含正向测试、越权负例、并发负例和重启恢复验证。只有第 8 项完成后,才应删除旧兼容路径并宣布三工作流治理正式生效。 + +## 10. 来源与证据 + +[^biz-seven-test]: `web/src/engine/templateFactory.test.ts:8-33`,测试精确断言 7 个任务键;读取于 2026-09-12。 +[^biz-seven-backend]: `internal/squad/service.go:128-138`,后端标准任务目录包含 7 个商务任务及产品、测试任务;读取于 2026-09-12。 +[^biz-seven-report]: `.doc/test-reports/2026-09-08-project-init-template-cloning-test-report.md:12-25,51-60`,历史专项测试把商务全生命周期固定为 7 个受控交付物;读取于 2026-09-12。 +[^bootstrap-five]: `internal/workflowdef/bootstrap.go:63-74,78-115`,启动种子包含三个旧流程及两个额外行业流程;读取于 2026-09-12。 +[^main-v2-missing]: `internal/workflowdef/manifests/main.v2.json:1-50`,清单只定义 intake、research、feasibility_reg、prelim_design、cdo_quote;读取于 2026-09-12。 +[^manifest-loader]: `internal/workflowdef/manifest_loader.go:9-32` 仅提供嵌入读取和文件名映射;仓库检索未发现 `V2ManifestFiles` 的运行时调用;读取于 2026-09-12。 +[^seed-no-upgrade]: `internal/workflowdef/store.go:364-372`,已有任意 latest published version 时直接返回;读取于 2026-09-12。 +[^catalog-flatten]: `internal/workflowdef/catalog.go:27-41,50-82`,数据库目录将版本压为首阶段首任务;读取于 2026-09-12。 +[^plan-disable-conflict]: `internal/projectinstance/validator.go:37-90,105-139`,required 或 blocking 任务关闭时校验失败;读取于 2026-09-12。 +[^advance-forces-approved]: `internal/projectinstance/stage_store.go:71-158`,推进事务把启用任务状态强制更新为 approved;读取于 2026-09-12。 +[^project-lifecycle-gap]: `internal/projectinstance/stage_store.go:128-158` 只完成实例,`internal/projectinstance/service.go:119-135` 又只允许 planning 项目保存下一计划;读取于 2026-09-12。 +[^dialogue-old-advance]: `internal/coordinator/engine.go:672-811`,对话推进调用 `bizdevSvc.ExecuteStage` 并直接改变旧成熟度上下文;读取于 2026-09-12。 +[^settings-all-definitions]: `internal/projectinstance/service.go:94-113`,项目设置枚举全部有 latest published version 的定义;读取于 2026-09-12。 +[^template-stage-map]: `web/src/engine/templateFactory.ts:37-39,87-89,158-160,228-230,286-288,331-333,392-394`,7 个商务模板当前归属为 V0.1 一项、V0.5 五项、V1.0 一项;读取于 2026-09-12。 +[^biz-stage-five]: `internal/bizdev/service.go:204-223`,旧商务阶段仅映射 1 + 3 + 1 个任务;读取于 2026-09-12。 +[^executor-missing]: `internal/workflow/registry.go:5-15`,商务 executor 缺少 feasibility_dev 和 user_requirements;读取于 2026-09-12。 +[^test-upstream]: `internal/workflowdef/manifests/test_manager.v2.json:1-23`,测试流程声明 product_manager 前置依赖;读取于 2026-09-12。 +[^test-hard-dependency]: `internal/bizdev/service.go:349-380,673-687`,测试任务硬查同项目 approved SRS、自动注入并记录单一 SRS 血缘;读取于 2026-09-12。 +[^context-input-foundation]: `internal/squad/service.go:35-55`、`internal/squad/workspace.go:262-408` 和 `internal/bizdev/service.go:250-294`,现有运行参数及工作区已支持项目、材料、前序文档、知识和部分项目事实输入;读取于 2026-09-12。 +[^instance-schema]: `deploy/postgres/goose/00017_2026-09-12-project-workflow-instances.sql:13-52`,实例状态和 one planning/one live 唯一索引;读取于 2026-09-12。 +[^plan-structure]: `internal/projectinstance/types.go:21-48`,计划已包含文档 enabled 和三权;读取于 2026-09-12。 +[^duty-write-enabled-only]: `internal/projectinstance/store.go:244-280`,禁用文档不会写职责行;读取于 2026-09-12。 +[^readiness-current]: `internal/projectinstance/gate.go:5-53` 和 `internal/projectinstance/stage_service.go:15-63`,现有门禁忽略禁用任务并限制创建者;读取于 2026-09-12。 +[^approval-key-too-wide]: `internal/projectinstance/stage_store.go:176-191`,批准同步按 project_id + task_key 更新;读取于 2026-09-12。 +[^notify-ready]: `internal/server/workflow_approval_hook.go:17-44` 和 `internal/notification/service.go:38-107`,批准回调、推进请求、outbox 与创建者通知;读取于 2026-09-12。 +[^notify-reminder]: `internal/notification/reminder.go:35-91,94-171`,24 小时提醒和 confirmed 状态;读取于 2026-09-12。 +[^stage-banner]: `web/src/components/workflow/StageAdvanceBanner.tsx:29-95,111-151`,前端轮询 readiness/通知并提供确认按钮;读取于 2026-09-12。 +[^instance-runtime-metadata]: `deploy/postgres/goose/00023_2026-09-12-instance-integrity.sql:8-11` 已添加运行元数据列,而 `internal/projectinstance/store.go:254-259` 插入时未写这些列;读取于 2026-09-12。 +[^stage-access]: `internal/projectinstance/stage_access.go:15-71,90-104`,live 实例中排除禁用任务,但无 live/completed 时不限制;读取于 2026-09-12。 +[^legacy-switch-ui]: `web/src/components/workflow/ProjectWorkflowSwitcher.tsx:16-23,38-76,173-236`,旧名称、旧直切换和冲突自动重试仍存在;读取于 2026-09-12。 +[^legacy-route]: `internal/server/server.go:157-169`,新项目实例 API 与旧 active-workflow/run API 同时暴露;读取于 2026-09-12。 +[^validator-v2]: `internal/workflowdef/validator_v2.go:9-47`,现有 v2 校验范围有限;读取于 2026-09-12。 +[^test-db-default]: `internal/projectinstance/pg_integration_test.go:19-49` 和 `internal/server/workflow_governance_pg_test.go:26-61`,未设置 TEST_DATABASE_URL 时连接本地开发库;读取于 2026-09-12。 +[^test-definition-no-cleanup]: `internal/workflowdef/publish_test.go:37-72`,发布测试创建带时间戳定义但未注册清理;读取于 2026-09-12。 + +开发数据库数字来源:2026-09-12 在本机容器内对 `workflow_definitions`、`workflow_versions`、`project_workflow_instances` 和 `projects` 执行只读聚合查询。该快照仅用于诊断当前开发环境,不代表生产环境。 diff --git a/.doc/test-reports/2026-09-12-three-workflows-governance-remediation-test-report.md b/.doc/test-reports/2026-09-12-three-workflows-governance-remediation-test-report.md new file mode 100644 index 00000000..76abc5d0 --- /dev/null +++ b/.doc/test-reports/2026-09-12-three-workflows-governance-remediation-test-report.md @@ -0,0 +1,49 @@ +# 三工作流严格治理整改 测试报告 + +## 一、 测试背景 (Background) + +依据 `.doc/2026-09-12-three-workflows-strict-governance-remediation.md`,系统需收敛为 3 个正式工作流、实例唯一运行权威、PM/TE 解耦及项目上下文快照。 + +## 二、 测试目标 (Test Objectives) + +- 目录 API 仅暴露 3 个正式工作流 +- 单文档流程可保存但不可激活空工作流 +- TE 无 SRS 硬门禁,可选 SRS 作为高权威来源 +- 阶段推进不强制改写文档 approved 状态 +- 集成测试禁止默认连接开发库 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS: Linux +- 单元测试: `go test ./internal/workflowdef/... ./internal/projectinstance/... ./internal/coordinator/...` +- PG 集成: 需 `TEST_DATABASE_URL` 指向隔离库 + +## 四、 预期结果 (Expected Results) + +- `ValidateDefinitionV2` 拒绝流程级 upstream 与错误文档数 +- `validateActivationPlan` 拒绝零启用文档激活 +- `GET /api/v1/workflows/catalog` 返回三项 +- `PUT active-workflow` 在实例治理可用时返回 410 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1: 正式 manifest 契约 + +- **Input**: `go test ./internal/workflowdef -run Official -v` +- **判定**: 见 CI/本地输出 + +### 用例 2: 空工作流激活阻断 + +- **Input**: `go test ./internal/projectinstance -run EmptyWorkflow -v` +- **判定**: PASS(save 允许 / activate 拒绝) + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +```bash +go test ./internal/workflowdef/... ./internal/projectinstance/... ./internal/coordinator/... ./internal/projectcontext/... +``` + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- 代码变更已落地 8 个变更集核心路径 +- 真实 PG + PI Agent 全链路 E2E 需在 `TEST_DATABASE_URL` 与 PI 容器就绪后补跑并更新本报告第五节真实控制台输出 From 574dd16546b15519bd1880cbfa5a7bcbb105df26 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 12 Sep 2026 14:39:01 +0000 Subject: [PATCH 08/24] fix(frontend): skip hash anchor navigation until session is validated Avoid chat day fetches with empty session_id on the login page when a stale URL hash is present. Co-authored-by: Cursor --- web/src/App.tsx | 6 ++++++ web/src/hooks/useChatWindow.ts | 1 + 2 files changed, 7 insertions(+) diff --git a/web/src/App.tsx b/web/src/App.tsx index 1777ffd3..70340610 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1704,8 +1704,14 @@ export default function App() { loadDayForAnchorRef.current = chatWindow.loadDayForAnchor; }, [chatWindow.loadDayForAnchor]); + const sessionValidatedRef = useRef(sessionValidated); + useEffect(() => { + sessionValidatedRef.current = sessionValidated; + }, [sessionValidated]); + useEffect(() => { const handleHashChange = () => { + if (sessionValidatedRef.current !== true) return; const parsed = parseAnchorHash(window.location.hash); if (!parsed) return; const currentActive = activeSessionIdRef.current; diff --git a/web/src/hooks/useChatWindow.ts b/web/src/hooks/useChatWindow.ts index 9ccc19ee..a6188042 100644 --- a/web/src/hooks/useChatWindow.ts +++ b/web/src/hooks/useChatWindow.ts @@ -301,6 +301,7 @@ export function useChatWindow({ // 跨日锚点拉取: 根据 messageId 拉取所属日分片并合并进窗口 const loadDayForAnchor = useCallback( async (messageId: number): Promise => { + if (!sessionId) return false; for (const b of dayStack) { if (b.messages.some((m) => m.serverId === messageId)) { return true; From 77b8d07cd79ab4796449c81100809efbd96f3fc6 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 12 Sep 2026 15:10:23 +0000 Subject: [PATCH 09/24] =?UTF-8?q?fix(governance):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E8=AE=BF=E9=97=AE=E5=88=A4=E5=AE=9A=E4=B8=8E?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=E5=BF=AB=E7=85=A7=E6=8E=92=E9=99=A4?= =?UTF-8?q?=E8=AE=A1=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐 DocumentAccessDecider HTTP 409、projectcontext 私有对话排除统计,并更新整改 gap matrix 与测试报告。 --- .doc/2026-09-12-remediation-gap-matrix.md | 35 +++ ...flow-governance-remediation-test-report.md | 62 ++++++ internal/coordinator/engine.go | 39 +++- .../instance_advance_governance_test.go | 83 +++++++ internal/projectcontext/service.go | 50 +++-- internal/projectcontext/service_test.go | 62 ++++++ .../projectinstance/activation_token_test.go | 80 +++++++ internal/projectinstance/document_access.go | 132 +++++++++++ .../projectinstance/document_access_test.go | 58 +++++ internal/projectinstance/service.go | 14 +- internal/projectinstance/service_test.go | 70 ------ .../projectinstance/stage_remediation_test.go | 209 ++++++++++++++++++ internal/projectinstance/store.go | 85 ++++++- internal/projectinstance/terminate.go | 72 +++++- internal/projectinstance/terminate_test.go | 48 ++++ internal/projectinstance/testutil.go | 116 ++++++++++ .../server/e2e_workflow_governance_test.go | 163 ++++++++++++++ internal/server/project_settings_handlers.go | 25 ++- internal/server/server.go | 1 + internal/server/stage_access_helpers.go | 48 +++- internal/server/task_run_handlers.go | 3 +- .../server/workflow_terminate_handlers.go | 29 +++ scripts/fix-completed-instance-lifecycle.sql | 18 ++ web/src/api/projectSettings.ts | 46 +++- .../project/ProjectSettingsModal.tsx | 83 +++++++ .../workflow/ProjectWorkflowSwitcher.tsx | 87 ++++---- .../workflow/StageAdvanceBanner.tsx | 6 +- web/src/types/projectSettings.ts | 23 ++ 28 files changed, 1587 insertions(+), 160 deletions(-) create mode 100644 .doc/2026-09-12-remediation-gap-matrix.md create mode 100644 .doc/test-reports/2026-09-12-workflow-governance-remediation-test-report.md create mode 100644 internal/coordinator/instance_advance_governance_test.go create mode 100644 internal/projectcontext/service_test.go create mode 100644 internal/projectinstance/activation_token_test.go create mode 100644 internal/projectinstance/document_access.go create mode 100644 internal/projectinstance/document_access_test.go create mode 100644 internal/projectinstance/stage_remediation_test.go create mode 100644 internal/projectinstance/terminate_test.go create mode 100644 internal/projectinstance/testutil.go create mode 100644 internal/server/e2e_workflow_governance_test.go create mode 100644 scripts/fix-completed-instance-lifecycle.sql diff --git a/.doc/2026-09-12-remediation-gap-matrix.md b/.doc/2026-09-12-remediation-gap-matrix.md new file mode 100644 index 00000000..6bab68ae --- /dev/null +++ b/.doc/2026-09-12-remediation-gap-matrix.md @@ -0,0 +1,35 @@ +# 整改缺口复核矩阵 + +**日期**: 2026-09-12(更新) +**分支**: dev +**用途**: 审计文档 vs 代码现状;后续 Task 仅引用本矩阵 + +| ID | 审计描述 | 状态 | 证据 | 计划动作 | +|---|---|---|---|---| +| BUG-01 | 完成后 lifecycle 未更新 | PASS | `stage_remediation_test.go`;`scripts/fix-completed-instance-lifecycle.sql` | 已完成 | +| BUG-02 | 推进强制批文档 | PASS | `AdvanceStage` 无 doc UPDATE;`TestAdvanceStage_DoesNotModifyDocumentApprovalStatus` | 已完成 | +| BUG-03 | 无 skipped_disabled | PASS | memStore `skipped_disabled`;`TestDisabledDocumentPersistence` | 已完成 | +| BUG-04 | 对话走旧 bizdev | PASS | `engine.go` live 门控;`instance_advance_governance_test.go` | 已完成 | +| BUG-05 | 拒绝逻辑分散 | PASS | `document_access.go` + `stage_access_helpers.go`;HTTP 409 `DOC_TASK_DISABLED` | 已完成 | +| BUG-06 | token 明文/重放 | PASS | `ActivateInstanceWithToken` 同事务;`activation_token_test.go` | 已完成 | +| BUG-07 | 完成后下一流程 | PASS | 同 BUG-01 + settings `lifecycle_status=planning` E2E | 已完成 | +| BUG-08 | 测试库隔离 | PARTIAL | `db/testdb.go` Require/Skip;治理 PG 测试仍 Skip 无库时 | CI 必须设 `TEST_DATABASE_URL` | +| BUG-09 | 版本冲突自动重试 | PASS | `StageAdvanceBanner` reload;`advanceStage` 409 提示 | 已完成 | +| BUG-10 | 无审计视图 | PASS | `recent_workflow_events` in settings + UI | 已完成 | +| BUG-11 | 旧 API 未下线 | PARTIAL | 治理开启时 410 | 保持 + 文档标注 | +| BUG-12 | 前端名称硬编码 | PASS | `ProjectWorkflowSwitcher` catalog API | 已完成 | +| MISSING-01 | 上下文快照 0% | PARTIAL | `projectcontext/service.go` 五层源 + 私有对话排除计数 | PM/TE bizdev 传入 Dialogue 待加深 | +| MISSING-02 | 终止 0% | PASS | `GetTerminationPreview` + 取消在途 + 前端 | 已完成 | +| MISSING-04 | E2E 0% | PARTIAL | `e2e_workflow_governance_test.go` 单工作流完成→planning | 三正式工作流 + 真实 PI Agent 待 test 分支补跑 | + +**结论**: Phase A 全部 PASS;Phase B/C 核心项 PASS,E2E 与上下文快照仍有 PARTIAL。test 分支合并前需 `TEST_DATABASE_URL` + 真实 PI Agent 全链路验收。 + +## 发布门禁(test → main) + +``` +□ go test ./internal/projectinstance/... ./internal/coordinator/... ./internal/projectcontext/... 全绿 +□ TEST_DATABASE_URL 下 go test ./internal/server/... -run 'TestE2E_WorkflowGovernance|TestWorkflowGovernance' 全绿 +□ 真实 PI Agent(MiniMax-M3)三工作流治理 E2E 实录写入 test-reports +□ scripts/fix-completed-instance-lifecycle.sql 在目标库 dry-run 无意外 +□ 前端:版本冲突刷新、终止 preview、catalog 名称、审计事件列表人工抽检 +``` diff --git a/.doc/test-reports/2026-09-12-workflow-governance-remediation-test-report.md b/.doc/test-reports/2026-09-12-workflow-governance-remediation-test-report.md new file mode 100644 index 00000000..a4d39836 --- /dev/null +++ b/.doc/test-reports/2026-09-12-workflow-governance-remediation-test-report.md @@ -0,0 +1,62 @@ +# 三工作流治理整改 测试报告 + +## 一、 测试背景 (Background) + +2026-09-12 全面审计发现治理链路存在文档拒绝分散、对话双轨推进、终止 preview 缺失等问题。本报告记录 Phase A–C 整改后的单元/集成验证。 + +## 二、 测试目标 (Test Objectives) + +- Phase A:生命周期补测、推进不改文档、禁用文档、对话不切 bizdev、token 一次性、DocumentAccessDecider + HTTP 409 +- Phase B:终止 preview、E2E 完成→planning、上下文快照可复现(含私有对话排除计数) +- 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 上下文快照 + +**命令**: `go test ./internal/projectcontext/... -count=1` + +**判定**: PASS — 同输入 hash 可复现;`[private]`/`[draft]` 对话计入 `ExcludedPrivateCount` 且不进入来源清单 + +### 5.4 PG E2E(需 TEST_DATABASE_URL) + +**命令**: `TEST_DATABASE_URL=... go test ./internal/server/... -run TestE2E_WorkflowGovernance_CompleteAndReplan -count=1 -v` + +**判定**: SKIP 或 PASS(视 CI 是否配置隔离库) + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +``` +go test ./internal/projectinstance/... ./internal/coordinator/... ./internal/projectcontext/... -count=1 +``` + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- 整改代码路径已用单元测试锁定;PG 全链路需在 `test` 分支部署前用真实 PI Agent 补跑并更新本报告第五节 5.4 为 PASS 实录。 +- 建议每周运行 `scripts/fix-completed-instance-lifecycle.sql` dry-run 检查历史不一致。 diff --git a/internal/coordinator/engine.go b/internal/coordinator/engine.go index 9bd43fe2..7442034e 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -680,11 +680,28 @@ 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.CreatorUserID > 0 { + 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 == userCtx.Project.CreatorUserID { - if res, handled, advErr := e.tryAdvanceWorkflowInstance(ctx, projectID, userID); handled { + 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, @@ -697,6 +714,22 @@ func (e *CoordinatorEngine) handleAdvanceStage(ctx context.Context, cand *intent } 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 + } + } } } diff --git a/internal/coordinator/instance_advance_governance_test.go b/internal/coordinator/instance_advance_governance_test.go new file mode 100644 index 00000000..b2bd74d2 --- /dev/null +++ b/internal/coordinator/instance_advance_governance_test.go @@ -0,0 +1,83 @@ +package coordinator + +import ( + "context" + "testing" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/ctxmgr" + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/intent" + "chat-agent-system/internal/kb" + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/squad" + "chat-agent-system/internal/storage" +) + +type countingBizdev struct { + stageCalls int +} + +func (c *countingBizdev) ListTasks() []bizdev.TaskMeta { return nil } +func (c *countingBizdev) ListStages() []bizdev.StageMeta { return nil } +func (c *countingBizdev) ExecuteTask(_ context.Context, _ bizdev.TaskRunRequest) (*bizdev.TaskRunResult, error) { + return &bizdev.TaskRunResult{}, nil +} +func (c *countingBizdev) ExecuteStage(_ context.Context, _ bizdev.StageRunRequest) (*bizdev.StageRunResult, error) { + c.stageCalls++ + return &bizdev.StageRunResult{StageTitle: "legacy"}, nil +} +func (c *countingBizdev) InspectWorkspace(_ context.Context, _ string) (*squad.WorkspaceState, error) { + return nil, nil +} +func (c *countingBizdev) ResumeOrModifyWorkspace(_ context.Context, _ squad.ModifyWorkflowRequest) (*squad.SquadRunResult, error) { + return nil, nil +} +func (c *countingBizdev) ContextManager() *ctxmgr.ContextManager { return nil } +func (c *countingBizdev) CAS() *storage.CAS { return nil } +func (c *countingBizdev) DocGov() *docgov.GovernanceEngine { return nil } +func (c *countingBizdev) KB() *kb.Client { return nil } + +func TestDialogueAdvanceStage_LiveInstanceDoesNotUseBizdev(t *testing.T) { + store := projectinstance.NewMemoryStore() + _ = projectinstance.SeedProjectMeta(store, 55, projectinstance.LifecyclePlanning, "", 1, 0) + instSvc := projectinstance.NewService(store, projectinstance.TestResolver(), nil, &projectinstance.StubDocChecker{States: map[string]string{"intake": "approved"}}) + + ctx := context.Background() + _, _ = instSvc.SaveWorkflowPlan(ctx, 55, 1, true, projectinstance.SavePlanInput{ + Plan: projectinstance.ValidTestPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + preview, _ := instSvc.ActivationPreview(ctx, 55, 1, true) + _, _ = instSvc.Activate(ctx, 55, 1, true, projectinstance.ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + + counter := &countingBizdev{} + engine := NewCoordinatorEngine(counter) + engine.SetInstanceService(instSvc) + + userCtx := &UserProjectContext{ + Project: ProjectInfo{ID: "55", CreatorUserID: 1, CurrentStage: bizdev.StageV01_Triage}, + User: UserInfo{ID: "1", ProjectRole: "owner"}, + } + recRes := &intent.IntentRecognitionResult{ + TopIntent: &intent.CandidateIntent{ + Intent: intent.IntentAdvanceStage, + FilledSlots: map[string]string{ + intent.SlotTargetStage: string(bizdev.StageV05_Feasibility), + }, + }, + } + + res, err := engine.ProcessIntent(ctx, recRes, userCtx) + if err != nil { + t.Fatalf("ProcessIntent: %v", err) + } + if res.Status != StatusAccepted { + t.Fatalf("expected accepted via instance service, got %s (%s)", res.Status, res.RejectReason) + } + if counter.stageCalls != 0 { + t.Fatalf("expected zero bizdev ExecuteStage calls with live instance, got %d", counter.stageCalls) + } +} diff --git a/internal/projectcontext/service.go b/internal/projectcontext/service.go index 562b5504..197c75a8 100644 --- a/internal/projectcontext/service.go +++ b/internal/projectcontext/service.go @@ -38,13 +38,15 @@ type Source struct { // 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"` - Sources []Source `json:"sources"` + 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 构建快照所需输入。 @@ -77,17 +79,19 @@ func (s *Service) Available() bool { // BuildSnapshot 建立来源清单并按任务检索相关片段(不 dump 全量原文)。 func (s *Service) BuildSnapshot(ctx context.Context, in BuildInput) (*Snapshot, error) { cutoff := time.Now() - sources := enumerateSources(in) + 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), - Sources: sources, + 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 @@ -137,8 +141,10 @@ func MaterialsForWorkspace(snap *Snapshot, maxItems int) []squad.MaterialItem { return out } -func enumerateSources(in BuildInput) []Source { +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 @@ -177,12 +183,18 @@ func enumerateSources(in BuildInput) []Source { }) } for i, line := range in.Dialogue { - if strings.TrimSpace(line) == "" { + 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(line), - AuthorityLevel: AuthorityDialogue, PermissionScope: "project_shared", Snippet: truncate(line, 2000), + 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 { @@ -204,7 +216,7 @@ func enumerateSources(in BuildInput) []Source { sort.Slice(out, func(i, j int) bool { return authorityRank(out[i].AuthorityLevel) < authorityRank(out[j].AuthorityLevel) }) - return out + return out, excludedPrivate, excludedNoAccess } func selectForTask(taskKey string, sources []Source) []Source { diff --git a/internal/projectcontext/service_test.go b/internal/projectcontext/service_test.go new file mode 100644 index 00000000..37a409aa --- /dev/null +++ b/internal/projectcontext/service_test.go @@ -0,0 +1,62 @@ +package projectcontext + +import ( + "testing" + + "chat-agent-system/internal/squad" +) + +func TestBuildSnapshot_ConsistentForSameInput(t *testing.T) { + svc := NewService(nil) + in := BuildInput{ + ProjectID: 1, + TaskKey: "system_reqs", + UserID: 1, + Facts: map[string]string{"product_type": "超声设备"}, + Materials: []squad.MaterialItem{{Filename: "note.md", Content: []byte("材料说明")}}, + Dialogue: []string{"项目需要 CE 认证"}, + Decisions: []string{"目标市场:欧盟"}, + } + s1, err := svc.BuildSnapshot(t.Context(), in) + if err != nil { + t.Fatalf("BuildSnapshot: %v", err) + } + s2, err := svc.BuildSnapshot(t.Context(), in) + if err != nil { + t.Fatalf("BuildSnapshot second: %v", err) + } + if s1.AggregateHash != s2.AggregateHash { + t.Fatalf("expected reproducible hash, got %s vs %s", s1.AggregateHash, s2.AggregateHash) + } +} + +func TestBuildSnapshot_ExcludesEmptyPrivateDialogue(t *testing.T) { + svc := NewService(nil) + snap, err := svc.BuildSnapshot(t.Context(), BuildInput{ + ProjectID: 2, + TaskKey: "test_manager", + Dialogue: []string{"", " ", "[private] 不应纳入", "可共享的项目讨论"}, + }) + if err != nil { + t.Fatalf("BuildSnapshot: %v", err) + } + if snap.SourceCount != 1 { + t.Fatalf("expected 1 shared dialogue source, got %d", snap.SourceCount) + } + if snap.ExcludedPrivateCount < 3 { + t.Fatalf("expected excluded private count >= 3, got %d", snap.ExcludedPrivateCount) + } +} + +func TestMaterialsForWorkspace_RespectsAuthority(t *testing.T) { + snap := &Snapshot{ + Sources: []Source{ + {Type: "dialogue", ID: "turn_1", Referenced: false, Snippet: "ignored"}, + {Type: AuthorityApprovedDoc, ID: "srs", Referenced: true, Snippet: "SRS body"}, + }, + } + items := MaterialsForWorkspace(snap, 5) + if len(items) != 1 { + t.Fatalf("expected 1 referenced material, got %d", len(items)) + } +} diff --git a/internal/projectinstance/activation_token_test.go b/internal/projectinstance/activation_token_test.go new file mode 100644 index 00000000..151aeace --- /dev/null +++ b/internal/projectinstance/activation_token_test.go @@ -0,0 +1,80 @@ +package projectinstance + +import ( + "context" + "errors" + "sync" + "testing" +) + +func TestActivationToken_OneTimeUse(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 200, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, nil) + + _, _ = svc.SaveWorkflowPlan(ctx, 200, 1, true, SavePlanInput{ + Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + preview, err := svc.ActivationPreview(ctx, 200, 1, true) + if err != nil { + t.Fatalf("ActivationPreview: %v", err) + } + + _, err = svc.Activate(ctx, 200, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if err != nil { + t.Fatalf("first activate: %v", err) + } + + _, err = svc.Activate(ctx, 200, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if !errors.Is(err, ErrTokenInvalid) && !errors.Is(err, ErrPlanFrozen) { + t.Fatalf("expected ErrTokenInvalid or ErrPlanFrozen on reuse, got %v", err) + } +} + +func TestActivationToken_ConcurrentActivateOnlyOneSucceeds(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 201, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, nil) + + _, _ = svc.SaveWorkflowPlan(ctx, 201, 1, true, SavePlanInput{ + Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + preview, err := svc.ActivationPreview(ctx, 201, 1, true) + if err != nil { + t.Fatalf("ActivationPreview: %v", err) + } + + var wg sync.WaitGroup + successes := 0 + var mu sync.Mutex + for i := 0; i < 3; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, actErr := svc.Activate(ctx, 201, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if actErr == nil { + mu.Lock() + successes++ + mu.Unlock() + } + }() + } + wg.Wait() + if successes != 1 { + t.Fatalf("expected exactly 1 successful activation, got %d", successes) + } +} diff --git a/internal/projectinstance/document_access.go b/internal/projectinstance/document_access.go new file mode 100644 index 00000000..91d20782 --- /dev/null +++ b/internal/projectinstance/document_access.go @@ -0,0 +1,132 @@ +package projectinstance + +import ( + "context" +) + +const ( + DocAccessActionView = "view" + DocAccessActionEdit = "edit" + DocAccessActionSubmit = "submit" + DocAccessActionReview = "review" + DocAccessActionApprove = "approve" + DocAccessActionRun = "run" + + AccessCodeDocTaskDisabled = "DOC_TASK_DISABLED" + AccessCodeNotCurrentStage = "DOC_TASK_NOT_IN_ACTIVE_STAGE" + AccessCodeForbiddenDuty = "DOC_DUTY_FORBIDDEN" + AccessCodeInstanceClosed = "WORKFLOW_INSTANCE_CLOSED" +) + +// DocAccessDecision 统一文档任务访问判定结果。 +type DocAccessDecision struct { + Allowed bool `json:"allowed"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +// CheckDocTaskAccess 对 live 治理实例下的文档任务做 fail-closed 判定。 +// 无 live 实例时不限制(legacy 兼容)。 +func (s *Service) CheckDocTaskAccess( + ctx context.Context, + projectID int64, + userID int64, + taskKey string, + action string, + isCreator bool, +) DocAccessDecision { + allow := DocAccessDecision{Allowed: true} + if s == nil || s.store == nil || projectID <= 0 || userID <= 0 || taskKey == "" { + return allow + } + + live, err := s.store.GetLiveInstance(ctx, projectID) + if err != nil || live == nil { + return allow + } + if live.Status == InstanceCompleted || live.Status == "terminated" { + return DocAccessDecision{ + Allowed: false, + Code: AccessCodeInstanceClosed, + Message: "工作流实例已结束", + } + } + + detail, err := s.store.GetInstanceByID(ctx, live.ID) + if err != nil || detail == nil { + return DocAccessDecision{Allowed: false, Code: AccessCodeInstanceClosed, Message: "工作流实例不可用"} + } + + var docInst *DocTaskInstance + var stageKey string + for _, st := range detail.Stages { + for i := range st.DocTasks { + if st.DocTasks[i].TaskKey == taskKey { + copyDoc := st.DocTasks[i] + docInst = ©Doc + stageKey = st.StageKey + break + } + } + if docInst != nil { + break + } + } + if docInst == nil { + return allow + } + + if docInst.Status == "skipped_disabled" { + return DocAccessDecision{ + Allowed: false, + Code: AccessCodeDocTaskDisabled, + Message: "该文档已被创建者禁用", + } + } + + if stageKey != "" && stageKey != detail.ActiveStageKey { + if action != DocAccessActionView { + return DocAccessDecision{ + Allowed: false, + Code: AccessCodeNotCurrentStage, + Message: "文档不在当前活动阶段", + } + } + } + + if action == DocAccessActionView { + return allow + } + + planTask := findDocTaskPlan(live.ConfigSnapshot, taskKey) + if planTask == nil || !planTask.Enabled { + return DocAccessDecision{ + Allowed: false, + Code: AccessCodeDocTaskDisabled, + Message: "该文档已被创建者禁用", + } + } + + if isCreator { + return allow + } + if docTaskDutyForUser(*planTask, userID) { + return allow + } + return DocAccessDecision{ + Allowed: false, + Code: AccessCodeForbiddenDuty, + Message: "当前用户不是该文档职责人", + } +} + +func findDocTaskPlan(plan WorkflowPlan, taskKey string) *DocTaskPlan { + for _, st := range plan.Stages { + for i := range st.DocTasks { + if st.DocTasks[i].TaskKey == taskKey { + return &st.DocTasks[i] + } + } + } + return nil +} diff --git a/internal/projectinstance/document_access_test.go b/internal/projectinstance/document_access_test.go new file mode 100644 index 00000000..b7a259bf --- /dev/null +++ b/internal/projectinstance/document_access_test.go @@ -0,0 +1,58 @@ +package projectinstance + +import ( + "context" + "testing" + + "chat-agent-system/internal/workflowdef" +) + +func TestCheckDocTaskAccess_DisabledDocumentRejected(t *testing.T) { + ctx := context.Background() + store := NewMemoryStore() + _ = SeedProjectMeta(store, 400, LifecyclePlanning, "", 1, 0) + resolver := disabledDocResolver() + svc := NewService(store, resolver, nil, nil) + + version := resolver.versions[workflowdef.OfficialKeyMain] + plan := buildDefaultPlan(version) + for i := range plan.Stages[0].DocTasks { + if plan.Stages[0].DocTasks[i].TaskKey == "research" { + plan.Stages[0].DocTasks[i].Enabled = false + continue + } + plan.Stages[0].DocTasks[i].Duties = DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3} + } + _, _ = svc.SaveWorkflowPlan(ctx, 400, 1, true, SavePlanInput{Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1}) + preview, _ := svc.ActivationPreview(ctx, 400, 1, true) + _, _ = svc.Activate(ctx, 400, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + + decision := svc.CheckDocTaskAccess(ctx, 400, 1, "research", DocAccessActionRun, true) + if decision.Allowed || decision.Code != AccessCodeDocTaskDisabled { + t.Fatalf("expected DOC_TASK_DISABLED, got %+v", decision) + } +} + +func TestCheckDocTaskAccess_NonDutyUserRejected(t *testing.T) { + ctx := context.Background() + store := NewMemoryStore() + _ = SeedProjectMeta(store, 401, LifecyclePlanning, "", 1, 0) + svc := NewService(store, TestResolver(), nil, nil) + + _, _ = svc.SaveWorkflowPlan(ctx, 401, 1, true, SavePlanInput{ + Plan: ValidTestPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + preview, _ := svc.ActivationPreview(ctx, 401, 1, true) + _, _ = svc.Activate(ctx, 401, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + + decision := svc.CheckDocTaskAccess(ctx, 401, 99, "intake", DocAccessActionRun, false) + if decision.Allowed || decision.Code != AccessCodeForbiddenDuty { + t.Fatalf("expected DOC_DUTY_FORBIDDEN, got %+v", decision) + } +} diff --git a/internal/projectinstance/service.go b/internal/projectinstance/service.go index 2fa513be..9d14ad6a 100644 --- a/internal/projectinstance/service.go +++ b/internal/projectinstance/service.go @@ -253,18 +253,6 @@ 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 @@ -274,7 +262,7 @@ func (s *Service) Activate(ctx context.Context, projectID int64, userID int64, i return nil, ErrValidationFailed } - activated, err := s.store.ActivateInstance(ctx, projectID, planInst, version, selVer, userID) + activated, err := s.store.ActivateInstanceWithToken(ctx, in.ConfirmationToken, projectID, planInst, version, selVer, userID, planInst.PlanHash) if err != nil { return nil, err } diff --git a/internal/projectinstance/service_test.go b/internal/projectinstance/service_test.go index 75e26a7e..c7ba5170 100644 --- a/internal/projectinstance/service_test.go +++ b/internal/projectinstance/service_test.go @@ -8,76 +8,6 @@ import ( "chat-agent-system/internal/workflowdef" ) -type stubVersionResolver struct { - versions map[string]*workflowdef.Version - defs map[string]*workflowdef.Definition -} - -func (s *stubVersionResolver) GetDefinitionByKey(_ context.Context, key string) (*workflowdef.Definition, error) { - d, ok := s.defs[key] - if !ok { - return nil, workflowdef.ErrNotFound - } - return d, nil -} - -func (s *stubVersionResolver) GetVersion(_ context.Context, workflowID int64, version int) (*workflowdef.Version, error) { - for _, v := range s.versions { - if v.WorkflowID == workflowID && v.Version == version { - copyV := *v - return ©V, nil - } - } - return nil, workflowdef.ErrNotFound -} - -func (s *stubVersionResolver) ListDefinitions(_ context.Context) ([]workflowdef.Definition, error) { - out := make([]workflowdef.Definition, 0, len(s.defs)) - for _, d := range s.defs { - out = append(out, *d) - } - return out, nil -} - -func testResolver() *stubVersionResolver { - 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}, - }}, - }}, - } -} - func TestService_PlanPreviewActivate(t *testing.T) { ctx := context.Background() store := newMemStore() diff --git a/internal/projectinstance/stage_remediation_test.go b/internal/projectinstance/stage_remediation_test.go new file mode 100644 index 00000000..c1a5d53b --- /dev/null +++ b/internal/projectinstance/stage_remediation_test.go @@ -0,0 +1,209 @@ +package projectinstance + +import ( + "context" + "testing" + + "chat-agent-system/internal/workflowdef" +) + +func TestAdvanceStage_CompletesProjectAfterLastStage(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 100, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, &stubDocChecker{states: map[string]string{"intake": "approved"}}) + + _, err := svc.SaveWorkflowPlan(ctx, 100, 1, true, SavePlanInput{ + Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + if err != nil { + t.Fatalf("SaveWorkflowPlan: %v", err) + } + preview, err := svc.ActivationPreview(ctx, 100, 1, true) + if err != nil { + t.Fatalf("ActivationPreview: %v", err) + } + result, err := svc.Activate(ctx, 100, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, + ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if err != nil { + t.Fatalf("Activate: %v", err) + } + + advanced, err := svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{ + StageKey: "V0.1_triage", ExpectedConfigRevision: result.Instance.ConfigRevision, + }) + if err != nil { + t.Fatalf("AdvanceStage: %v", err) + } + if !advanced.InstanceDone { + t.Fatal("expected instance done after last stage") + } + if advanced.Instance.Status != InstanceCompleted { + t.Fatalf("expected completed instance, got %q", advanced.Instance.Status) + } + + lifecycle, activeKey, _, _, err := store.GetProjectMeta(ctx, 100) + if err != nil { + t.Fatalf("GetProjectMeta: %v", err) + } + if lifecycle != LifecyclePlanning { + t.Fatalf("expected lifecycle planning, got %q", lifecycle) + } + if activeKey != "" { + t.Fatalf("expected empty active workflow key, got %q", activeKey) + } +} + +func TestAdvanceStage_DoesNotChangeProjectOnNonLastStage(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 101, LifecyclePlanning, "", 1, 0) + resolver := testResolverMultiStage() + svc := NewService(store, resolver, nil, &stubDocChecker{states: map[string]string{"intake": "approved"}}) + + plan := validPlan() + plan.Stages = append(plan.Stages, StagePlan{ + StageKey: "V0.5_feasibility", Enabled: true, + DocTasks: []DocTaskPlan{{ + TaskKey: "feasibility_reg", Enabled: true, + Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}, + }}, + }) + _, err := svc.SaveWorkflowPlan(ctx, 101, 1, true, SavePlanInput{ + Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + if err != nil { + t.Fatalf("SaveWorkflowPlan: %v", err) + } + preview, _ := svc.ActivationPreview(ctx, 101, 1, true) + result, _ := svc.Activate(ctx, 101, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + + _, err = svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{ + StageKey: "V0.1_triage", ExpectedConfigRevision: result.Instance.ConfigRevision, + }) + if err != nil { + t.Fatalf("AdvanceStage: %v", err) + } + + lifecycle, _, _, _, err := store.GetProjectMeta(ctx, 101) + if err != nil { + t.Fatalf("GetProjectMeta: %v", err) + } + if lifecycle != LifecycleActive { + t.Fatalf("expected lifecycle active on non-last stage, got %q", lifecycle) + } +} + +func TestAdvanceStage_DoesNotModifyDocumentApprovalStatus(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 102, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, &stubDocChecker{states: map[string]string{"intake": "approved"}}) + + _, _ = svc.SaveWorkflowPlan(ctx, 102, 1, true, SavePlanInput{ + Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + preview, _ := svc.ActivationPreview(ctx, 102, 1, true) + result, _ := svc.Activate(ctx, 102, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + + before, err := store.GetInstanceByID(ctx, result.Instance.ID) + if err != nil { + t.Fatalf("GetInstanceByID: %v", err) + } + beforeStatus := before.Stages[0].DocTasks[0].Status + + _, err = svc.AdvanceStage(ctx, result.Instance.ID, 1, true, AdvanceStageInput{ + StageKey: "V0.1_triage", ExpectedConfigRevision: result.Instance.ConfigRevision, + }) + if err != nil { + t.Fatalf("AdvanceStage: %v", err) + } + + after, err := store.GetInstanceByID(ctx, result.Instance.ID) + if err != nil { + t.Fatalf("GetInstanceByID after: %v", err) + } + if after.Stages[0].DocTasks[0].Status != beforeStatus { + t.Fatalf("doc status changed from %q to %q after advance", beforeStatus, after.Stages[0].DocTasks[0].Status) + } +} + +func TestDisabledDocumentPersistence(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 103, LifecyclePlanning, "", 1, 0) + resolver := disabledDocResolver() + svc := NewService(store, resolver, nil, nil) + + version := resolver.versions[workflowdef.OfficialKeyMain] + plan := buildDefaultPlan(version) + for i := range plan.Stages[0].DocTasks { + if plan.Stages[0].DocTasks[i].TaskKey == "research" { + plan.Stages[0].DocTasks[i].Enabled = false + continue + } + plan.Stages[0].DocTasks[i].Duties = DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3} + } + _, err := svc.SaveWorkflowPlan(ctx, 103, 1, true, SavePlanInput{ + Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + if err != nil { + t.Fatalf("SaveWorkflowPlan: %v", err) + } + preview, _ := svc.ActivationPreview(ctx, 103, 1, true) + result, err := svc.Activate(ctx, 103, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + if err != nil { + t.Fatalf("Activate: %v", err) + } + + detail, err := store.GetInstanceByID(ctx, result.Instance.ID) + if err != nil { + t.Fatalf("GetInstanceByID: %v", err) + } + foundDisabled := false + for _, st := range detail.Stages { + for _, dt := range st.DocTasks { + if dt.TaskKey == "research" { + if dt.Status != "skipped_disabled" { + t.Fatalf("expected skipped_disabled, got %q", dt.Status) + } + foundDisabled = true + } + } + } + if !foundDisabled { + t.Fatal("disabled doc task instance not persisted") + } + + decision := svc.CheckDocTaskAccess(ctx, 103, 1, "research", DocAccessActionRun, true) + if decision.Allowed || decision.Code != AccessCodeDocTaskDisabled { + t.Fatalf("expected DOC_TASK_DISABLED, got %+v", decision) + } +} + +func testResolverMultiStage() *stubVersionResolver { + r := testResolver() + r.versions[workflowdef.OfficialKeyMain].Definition.Stages = append( + r.versions[workflowdef.OfficialKeyMain].Definition.Stages, + workflowdef.StageDef{ + Key: "V0.5_feasibility", Name: "V0.5 可行性", Optional: false, Position: 20, + DocTasks: []workflowdef.DocTaskDef{{ + Key: "feasibility_reg", Title: "法规路径评估", CreatorConfigurable: true, BlockingWhenEnabled: true, DefaultEnabled: true, + OutputDocType: "feasibility", OutputDocTitle: "法规路径评估", + }}, + }, + ) + return r +} diff --git a/internal/projectinstance/store.go b/internal/projectinstance/store.go index 942ce7c4..c9fa246a 100644 --- a/internal/projectinstance/store.go +++ b/internal/projectinstance/store.go @@ -42,6 +42,8 @@ type Store interface { HasLiveInstance(ctx context.Context, projectID int64) (bool, error) UpsertPlanningInstance(ctx context.Context, inst *WorkflowInstance) (*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 @@ -292,16 +294,30 @@ func (s *pgStore) replaceInstanceChildren(ctx context.Context, tx pgx.Tx, inst * } 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 := s.pool.Begin(ctx) + 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, @@ -359,6 +375,55 @@ func (s *pgStore) ActivateInstance(ctx context.Context, projectID int64, inst *W 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 { @@ -502,6 +567,11 @@ func (s *pgStore) TerminateInstance(ctx context.Context, instanceID int64, proje 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 } @@ -587,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) } @@ -706,6 +776,17 @@ func (m *memStore) UpsertPlanningInstance(_ context.Context, inst *WorkflowInsta return ©Inst, nil } +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 { diff --git a/internal/projectinstance/terminate.go b/internal/projectinstance/terminate.go index fffcaeb0..80db0d18 100644 --- a/internal/projectinstance/terminate.go +++ b/internal/projectinstance/terminate.go @@ -1,6 +1,9 @@ package projectinstance -import "context" +import ( + "context" + "fmt" +) // TerminateInput 受控终止输入。 type TerminateInput struct { @@ -15,6 +18,73 @@ type TerminateResult struct { 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 diff --git a/internal/projectinstance/terminate_test.go b/internal/projectinstance/terminate_test.go new file mode 100644 index 00000000..0b68f4fd --- /dev/null +++ b/internal/projectinstance/terminate_test.go @@ -0,0 +1,48 @@ +package projectinstance + +import ( + "context" + "testing" +) + +func TestTerminateInstance_UpdatesProjectLifecycle(t *testing.T) { + ctx := context.Background() + store := NewMemoryStore() + _ = SeedProjectMeta(store, 300, LifecyclePlanning, "", 1, 0) + svc := NewService(store, TestResolver(), nil, &StubDocChecker{States: map[string]string{"intake": "approved"}}) + + _, _ = svc.SaveWorkflowPlan(ctx, 300, 1, true, SavePlanInput{ + Plan: ValidTestPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1, + }) + preview, _ := svc.ActivationPreview(ctx, 300, 1, true) + activated, _ := svc.Activate(ctx, 300, 1, true, ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + + pv, err := svc.GetTerminationPreview(ctx, activated.Instance.ID, 300, 1, true) + if err != nil { + t.Fatalf("GetTerminationPreview: %v", err) + } + if !pv.CanTerminate { + t.Fatal("expected can terminate") + } + + res, err := svc.TerminateInstance(ctx, activated.Instance.ID, 300, 1, true, TerminateInput{ + Reason: "测试终止", Confirm: true, ExpectedConfigRevision: activated.Instance.ConfigRevision, + }) + if err != nil { + t.Fatalf("TerminateInstance: %v", err) + } + if res.Instance.Status != "terminated" { + t.Fatalf("expected terminated, got %q", res.Instance.Status) + } + + lifecycle, activeKey, _, _, err := store.GetProjectMeta(ctx, 300) + if err != nil { + t.Fatalf("GetProjectMeta: %v", err) + } + if lifecycle != LifecyclePlanning || activeKey != "" { + t.Fatalf("expected planning with empty key, got lifecycle=%q key=%q", lifecycle, activeKey) + } +} diff --git a/internal/projectinstance/testutil.go b/internal/projectinstance/testutil.go new file mode 100644 index 00000000..9e6519d8 --- /dev/null +++ b/internal/projectinstance/testutil.go @@ -0,0 +1,116 @@ +package projectinstance + +import ( + "context" + + "chat-agent-system/internal/workflowdef" +) + +// NewMemoryStore 返回内存 Store,供跨包集成测试使用。 +func NewMemoryStore() Store { + return newMemStore() +} + +// TestResolver 返回最小正式商务工作流解析器。 +func TestResolver() VersionResolver { + return testResolver() +} + +// ValidTestPlan 返回单阶段 intake 测试规划。 +func ValidTestPlan() WorkflowPlan { + return validPlan() +} + +// StubDocChecker 内存文档批准状态探针。 +type StubDocChecker struct { + States map[string]string +} + +func (s *StubDocChecker) TaskApprovalStates(_ context.Context, _ int64) (map[string]string, error) { + if s == nil { + return map[string]string{}, nil + } + return s.States, nil +} + +type stubVersionResolver struct { + versions map[string]*workflowdef.Version + defs map[string]*workflowdef.Definition +} + +func (s *stubVersionResolver) GetDefinitionByKey(_ context.Context, key string) (*workflowdef.Definition, error) { + d, ok := s.defs[key] + if !ok { + return nil, workflowdef.ErrNotFound + } + return d, nil +} + +func (s *stubVersionResolver) GetVersion(_ context.Context, workflowID int64, version int) (*workflowdef.Version, error) { + for _, v := range s.versions { + if v.WorkflowID == workflowID && v.Version == version { + copyV := *v + return ©V, nil + } + } + return nil, workflowdef.ErrNotFound +} + +func (s *stubVersionResolver) ListDefinitions(_ context.Context) ([]workflowdef.Definition, error) { + out := make([]workflowdef.Definition, 0, len(s.defs)) + for _, d := range s.defs { + out = append(out, *d) + } + return out, nil +} + +func disabledDocResolver() *stubVersionResolver { + r := testResolver() + r.versions[workflowdef.OfficialKeyMain].Definition.Stages[0].DocTasks = append( + r.versions[workflowdef.OfficialKeyMain].Definition.Stages[0].DocTasks, + workflowdef.DocTaskDef{ + Key: "research", Title: "市场调研", CreatorConfigurable: true, BlockingWhenEnabled: false, DefaultEnabled: false, + OutputDocType: "research", OutputDocTitle: "市场调研", + }, + ) + return r +} + +func testResolver() *stubVersionResolver { + key := workflowdef.OfficialKeyMain + ver := &workflowdef.Version{ + ID: 100, WorkflowID: 10, Version: 1, Status: workflowdef.StatusPublished, + WorkflowKey: key, WorkflowName: workflowdef.OfficialWorkflowName(key), + Definition: workflowdef.VersionDefinition{ + SchemaVersion: 2, + WorkflowKey: key, + Stages: []workflowdef.StageDef{{ + Key: "V0.1_triage", Name: "V0.1 需求摸底", Optional: false, Position: 10, + DocTasks: []workflowdef.DocTaskDef{{ + Key: "intake", Title: "用户初始需求收集表", CreatorConfigurable: true, BlockingWhenEnabled: true, DefaultEnabled: true, + OutputDocType: "intake", OutputDocTitle: "用户初始需求收集表", + }}, + }}, + }, + } + return &stubVersionResolver{ + defs: map[string]*workflowdef.Definition{ + key: {ID: 10, Key: key, Name: workflowdef.OfficialWorkflowName(key), LatestPublishedVersion: 1}, + }, + versions: map[string]*workflowdef.Version{key: ver}, + } +} + +func validPlan() WorkflowPlan { + return WorkflowPlan{ + WorkflowDefinitionKey: workflowdef.OfficialKeyMain, + WorkflowVersion: 1, + Stages: []StagePlan{{ + StageKey: "V0.1_triage", Enabled: true, + DocTasks: []DocTaskPlan{{ + TaskKey: "intake", Enabled: true, + Duties: DocDuty{AuthorUserID: 1, ReviewerUserID: 2, ApproverUserID: 3}, + }}, + }}, + } +} diff --git a/internal/server/e2e_workflow_governance_test.go b/internal/server/e2e_workflow_governance_test.go new file mode 100644 index 00000000..115100ef --- /dev/null +++ b/internal/server/e2e_workflow_governance_test.go @@ -0,0 +1,163 @@ +package server_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "chat-agent-system/internal/db" + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/workflowdef" +) + +// TestE2E_WorkflowGovernance_CompleteAndReplan 验证末阶段完成后项目回到 planning 并可再次获取目录。 +func TestE2E_WorkflowGovernance_CompleteAndReplan(t *testing.T) { + dsn := db.SkipIfNoTestDatabaseURL(t) + _ = dsn + pool := openPGPoolWithMigrations(t) + if pool == nil { + return + } + defer pool.Close() + + ctx := context.Background() + handler, _ := setupPGGovernanceServer(t, pool) + + creatorToken := loginTestUser(t, handler, "zhanggong", "123456") + creatorID := fetchAuthUserID(t, handler, creatorToken) + + wfSvc := workflowdef.NewService(pool, nil) + wfKey := "e2e_gov_" + time.Now().Format("150405.000") + _, ver, err := wfSvc.CreateDefinition(ctx, workflowdef.CreateDefinitionInput{ + Key: wfKey, Name: "E2E Governance WF", + Definition: workflowdef.VersionDefinition{ + SchemaVersion: 1, + Stages: []workflowdef.StageDef{{ + Key: "only_stage", Name: "唯一阶段", Position: 0, + DocTasks: []workflowdef.DocTaskDef{{ + Key: "intake", Required: true, Blocking: true, DefaultEnabled: true, + OutputDocType: "intake", OutputDocTitle: "Intake", + }}, + }}, + }, + }, 1) + if err != nil { + t.Fatalf("create workflow: %v", err) + } + if _, err := wfSvc.PublishVersion(ctx, ver.WorkflowID, ver.Version, 1); err != nil { + t.Fatalf("publish workflow: %v", err) + } + + var projectID int64 + code := "e2e-gov-" + time.Now().Format("150405.000") + err = pool.QueryRow(ctx, ` + INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision) + VALUES ($1, 'E2E Governance', 'active', $2, 'planning', '', 1, 0) RETURNING id + `, code, creatorID).Scan(&projectID) + if err != nil { + t.Fatalf("insert project: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) + }) + + reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=1", projectID, wfKey), nil) + withBearerAuth(reqDefault, creatorToken) + recDefault := httptest.NewRecorder() + handler.ServeHTTP(recDefault, reqDefault) + if recDefault.Code != http.StatusOK { + t.Fatalf("default plan: %d %s", recDefault.Code, recDefault.Body.String()) + } + var plan projectinstance.WorkflowPlan + _ = json.Unmarshal(recDefault.Body.Bytes(), &plan) + reviewerID := fetchAuthUserID(t, handler, loginTestUser(t, handler, "lizhuguan", "123456")) + approverID := fetchAuthUserID(t, handler, loginTestUser(t, handler, "wangzong", "123456")) + for i := range plan.Stages { + for j := range plan.Stages[i].DocTasks { + plan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{ + AuthorUserID: creatorID, ReviewerUserID: reviewerID, ApproverUserID: approverID, + } + } + } + saveBody, _ := json.Marshal(projectinstance.SavePlanInput{Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1}) + reqSave := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/workflow-plan", projectID), bytes.NewReader(saveBody)) + withBearerAuth(reqSave, creatorToken) + recSave := httptest.NewRecorder() + handler.ServeHTTP(recSave, reqSave) + if recSave.Code != http.StatusOK { + t.Fatalf("save plan: %d %s", recSave.Code, recSave.Body.String()) + } + + reqPreview := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/activation-preview", projectID), nil) + withBearerAuth(reqPreview, creatorToken) + recPreview := httptest.NewRecorder() + handler.ServeHTTP(recPreview, reqPreview) + var preview projectinstance.ActivationPreview + _ = json.Unmarshal(recPreview.Body.Bytes(), &preview) + + activateBody, _ := json.Marshal(projectinstance.ActivateInput{ + ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion, + ExpectedPlanRevision: preview.ExpectedPlanRevision, + }) + reqActivate := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/workflow-instances/activate", projectID), bytes.NewReader(activateBody)) + withBearerAuth(reqActivate, creatorToken) + recActivate := httptest.NewRecorder() + handler.ServeHTTP(recActivate, reqActivate) + if recActivate.Code != http.StatusOK { + t.Fatalf("activate: %d %s", recActivate.Code, recActivate.Body.String()) + } + var activated projectinstance.ActivateResult + _ = json.Unmarshal(recActivate.Body.Bytes(), &activated) + + // 批准并推进至完成(复用 governance 测试中的 docgov 路径) + docID := fmt.Sprintf("doc_e2e_%d", projectID) + // 文档批准链在 workflow_governance_pg_test 已覆盖;此处直接标记实例文档 approved 并推进 + _, err = pool.Exec(ctx, ` + UPDATE project_doc_task_instances dti SET status = 'approved', updated_at = now() + FROM project_stage_instances psi + WHERE dti.stage_instance_id = psi.id AND psi.workflow_instance_id = $1 AND dti.task_key = 'intake' + `, activated.Instance.ID) + if err != nil { + t.Fatalf("mark approved: %v", err) + } + _ = docID + + advanceBody, _ := json.Marshal(projectinstance.AdvanceStageInput{ + StageKey: "only_stage", ExpectedConfigRevision: activated.Instance.ConfigRevision, + }) + reqAdvance := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/workflow-instances/%d/stages/only_stage/advance", activated.Instance.ID), bytes.NewReader(advanceBody)) + withBearerAuth(reqAdvance, creatorToken) + recAdvance := httptest.NewRecorder() + handler.ServeHTTP(recAdvance, reqAdvance) + if recAdvance.Code != http.StatusOK { + t.Fatalf("advance: %d %s", recAdvance.Code, recAdvance.Body.String()) + } + + reqSettings := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/settings", projectID), nil) + withBearerAuth(reqSettings, creatorToken) + recSettings := httptest.NewRecorder() + handler.ServeHTTP(recSettings, reqSettings) + if recSettings.Code != http.StatusOK { + t.Fatalf("settings: %d %s", recSettings.Code, recSettings.Body.String()) + } + var settings projectinstance.ProjectSettings + if err := json.Unmarshal(recSettings.Body.Bytes(), &settings); err != nil { + t.Fatalf("decode settings: %v", err) + } + if settings.LifecycleStatus != projectinstance.LifecyclePlanning { + t.Fatalf("expected planning after completion, got %q", settings.LifecycleStatus) + } + + reqCatalog := httptest.NewRequest(http.MethodGet, "/api/v1/workflows/catalog", nil) + withBearerAuth(reqCatalog, creatorToken) + recCatalog := httptest.NewRecorder() + handler.ServeHTTP(recCatalog, reqCatalog) + if recCatalog.Code != http.StatusOK { + t.Fatalf("catalog: %d %s", recCatalog.Code, recCatalog.Body.String()) + } +} diff --git a/internal/server/project_settings_handlers.go b/internal/server/project_settings_handlers.go index 7d97cb95..de370805 100644 --- a/internal/server/project_settings_handlers.go +++ b/internal/server/project_settings_handlers.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "strconv" + "time" "chat-agent-system/internal/projectinstance" ) @@ -52,7 +53,29 @@ func (s *Server) handleGetProjectSettings(w http.ResponseWriter, r *http.Request writeProjectInstanceError(w, err) return } - writeJSONResponse(w, http.StatusOK, settings) + type settingsResponse struct { + *projectinstance.ProjectSettings + RecentWorkflowEvents []workflowEventSummary `json:"recent_workflow_events,omitempty"` + } + out := settingsResponse{ProjectSettings: settings} + if s.notifySvc != nil && s.notifySvc.Available() { + items, listErr := s.notifySvc.ListProjectNotifications(r.Context(), user.ID, projectID, 0, 20) + if listErr == nil { + for _, n := range items { + out.RecentWorkflowEvents = append(out.RecentWorkflowEvents, workflowEventSummary{ + ID: n.ID, Type: n.Type, Title: n.Title, CreatedAt: n.CreatedAt.Format(time.RFC3339), + }) + } + } + } + writeJSONResponse(w, http.StatusOK, out) +} + +type workflowEventSummary struct { + ID int64 `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + CreatedAt string `json:"created_at"` } func (s *Server) handleSaveWorkflowPlan(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/server.go b/internal/server/server.go index 5f5a258f..32fcff49 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -165,6 +165,7 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /api/v1/workflow-instances/{id}", s.handleGetWorkflowInstance) mux.HandleFunc("GET /api/v1/workflow-instances/{id}/stages/{stage}/readiness", s.handleGetStageReadiness) mux.HandleFunc("POST /api/v1/workflow-instances/{id}/stages/{stage}/advance", s.handleAdvanceStage) + mux.HandleFunc("GET /api/v1/workflow-instances/{id}/termination-preview", s.handleTerminationPreview) mux.HandleFunc("POST /api/v1/workflow-instances/{id}/terminate", s.handleTerminateWorkflowInstance) mux.HandleFunc("GET /api/v1/workflows", s.handleListWorkflowDefinitions) mux.HandleFunc("GET /api/v1/workflows/catalog", s.handleWorkflowCatalog) diff --git a/internal/server/stage_access_helpers.go b/internal/server/stage_access_helpers.go index c0b1103a..5cf45916 100644 --- a/internal/server/stage_access_helpers.go +++ b/internal/server/stage_access_helpers.go @@ -20,18 +20,56 @@ func (s *Server) resolveStageAccess(ctx context.Context, projectID, userID int64 return s.instSvc.ResolveStageAccess(ctx, projectID, userID, isCreator) } -func (s *Server) allowWorkflowDocTaskAccess(ctx context.Context, projectID, userID int64, taskKey string) bool { +func (s *Server) checkWorkflowDocTaskAccess(ctx context.Context, projectID, userID int64, taskKey string) projectinstance.DocAccessDecision { if taskKey == "" { - return true + return projectinstance.DocAccessDecision{Allowed: true} + } + if s.instSvc != nil && s.instSvc.Available() && projectID > 0 && userID > 0 { + live, liveErr := s.instSvc.HasLiveInstance(ctx, projectID) + if liveErr == nil && live { + isCreator, _ := s.pwSvc.IsProjectCreator(ctx, projectID, userID) + return s.instSvc.CheckDocTaskAccess(ctx, projectID, userID, taskKey, projectinstance.DocAccessActionRun, isCreator) + } } access, err := s.resolveStageAccess(ctx, projectID, userID) if err != nil { - return !s.failClosedAuthz() + if s.failClosedAuthz() { + return projectinstance.DocAccessDecision{Allowed: false, Code: "WORKFLOW_ACCESS_DENIED", Message: "无权访问该阶段内容"} + } + return projectinstance.DocAccessDecision{Allowed: true} } - if access == nil || !access.Restricted { + if access == nil || !access.Restricted || access.TaskKeyAllowed(taskKey) { + return projectinstance.DocAccessDecision{Allowed: true} + } + return projectinstance.DocAccessDecision{Allowed: false, Code: "WORKFLOW_ACCESS_DENIED", Message: "无权访问该阶段内容"} +} + +func (s *Server) allowWorkflowDocTaskAccess(ctx context.Context, projectID, userID int64, taskKey string) bool { + return s.checkWorkflowDocTaskAccess(ctx, projectID, userID, taskKey).Allowed +} + +func (s *Server) enforceWorkflowDocTaskAccess(w http.ResponseWriter, ctx context.Context, projectID, userID int64, taskKey string) bool { + decision := s.checkWorkflowDocTaskAccess(ctx, projectID, userID, taskKey) + if decision.Allowed { return true } - return access.TaskKeyAllowed(taskKey) + s.writeDocAccessDecision(w, decision) + return false +} + +func (s *Server) writeDocAccessDecision(w http.ResponseWriter, decision projectinstance.DocAccessDecision) { + status := http.StatusForbidden + if decision.Code == projectinstance.AccessCodeDocTaskDisabled { + status = http.StatusConflict + } + msg := decision.Message + if msg == "" { + msg = "无权访问该文档任务" + } + payload, _ := json.Marshal(map[string]string{"error": decision.Code, "message": msg}) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(payload) } func (s *Server) allowAnyStageAccess(ctx context.Context, projectID, userID int64) bool { diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index 49282160..e4c5ad1d 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -68,8 +68,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { if req.TaskKey == "" && req.Stage == "" { req.TaskKey = "intake" } - if req.ProjectID > 0 && req.TaskKey != "" && !s.allowWorkflowDocTaskAccess(r.Context(), req.ProjectID, user.ID, req.TaskKey) { - s.writeWorkflowAccessDenied(w) + if req.ProjectID > 0 && req.TaskKey != "" && !s.enforceWorkflowDocTaskAccess(w, r.Context(), req.ProjectID, user.ID, req.TaskKey) { return } req.WorkDir = "" diff --git a/internal/server/workflow_terminate_handlers.go b/internal/server/workflow_terminate_handlers.go index 5cd592d1..e03bba1c 100644 --- a/internal/server/workflow_terminate_handlers.go +++ b/internal/server/workflow_terminate_handlers.go @@ -9,6 +9,35 @@ import ( "chat-agent-system/internal/projectinstance" ) +func (s *Server) handleTerminationPreview(w http.ResponseWriter, r *http.Request) { + if s.instSvc == nil || !s.instSvc.Available() { + http.Error(w, `{"error":"ERR_NOT_AVAILABLE"}`, http.StatusServiceUnavailable) + return + } + instanceID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || instanceID <= 0 { + http.Error(w, `{"error":"ERR_INVALID_PARAM"}`, http.StatusBadRequest) + return + } + user, err := s.authenticateUser(r) + if err != nil || user == nil { + http.Error(w, `{"error":"ERR_UNAUTHENTICATED"}`, http.StatusUnauthorized) + return + } + detail, err := s.instSvc.GetInstanceDetail(r.Context(), instanceID) + if err != nil { + http.Error(w, `{"error":"ERR_NOT_FOUND"}`, http.StatusNotFound) + return + } + isCreator, _ := s.pwSvc.IsProjectCreator(r.Context(), detail.Instance.ProjectID, user.ID) + preview, err := s.instSvc.GetTerminationPreview(r.Context(), instanceID, detail.Instance.ProjectID, user.ID, isCreator) + if err != nil { + writeProjectInstanceError(w, err) + return + } + writeJSONResponse(w, http.StatusOK, preview) +} + func (s *Server) handleTerminateWorkflowInstance(w http.ResponseWriter, r *http.Request) { if s.instSvc == nil || !s.instSvc.Available() { http.Error(w, `{"error":"ERR_NOT_AVAILABLE"}`, http.StatusServiceUnavailable) diff --git a/scripts/fix-completed-instance-lifecycle.sql b/scripts/fix-completed-instance-lifecycle.sql new file mode 100644 index 00000000..0d521952 --- /dev/null +++ b/scripts/fix-completed-instance-lifecycle.sql @@ -0,0 +1,18 @@ +-- 修复「实例 completed 但项目仍为 active」的历史不一致数据。 +-- 用法: +-- psql "$DATABASE_URL" -f scripts/fix-completed-instance-lifecycle.sql +-- Dry-run(仅统计): +-- psql "$DATABASE_URL" -c "SELECT COUNT(*) FROM projects p JOIN project_workflow_instances i ON i.project_id = p.id AND i.status = 'completed' WHERE p.lifecycle_status <> 'planning';" + +BEGIN; + +UPDATE projects p +SET lifecycle_status = 'planning', + active_workflow_key = '', + updated_at = now() +FROM project_workflow_instances i +WHERE i.project_id = p.id + AND i.status = 'completed' + AND p.lifecycle_status <> 'planning'; + +COMMIT; diff --git a/web/src/api/projectSettings.ts b/web/src/api/projectSettings.ts index 0e3e4d30..0e7f2de9 100644 --- a/web/src/api/projectSettings.ts +++ b/web/src/api/projectSettings.ts @@ -6,6 +6,8 @@ import { StageReadiness, AdvanceStageResult, ProjectNotification, + WorkflowCatalogItem, + TerminationPreview, } from '../types/projectSettings'; import { getBearerAuthHeaders } from './authHeaders'; @@ -130,6 +132,44 @@ export async function fetchProjectNotifications( return Array.isArray(data.items) ? data.items : []; } +export async function fetchWorkflowCatalog(): Promise { + const res = await fetch(`${API_BASE}/api/v1/workflows/catalog`, { + headers: getBearerAuthHeaders(), + }); + if (!res.ok) { + return []; + } + const data = await res.json(); + return Array.isArray(data.workflows) ? data.workflows : []; +} + +export async function fetchTerminationPreview(instanceId: number): Promise { + const res = await fetch(`${API_BASE}/api/v1/workflow-instances/${instanceId}/termination-preview`, { + headers: getBearerAuthHeaders(), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(parseError(text) || `终止预览失败: ${res.status}`); + } + return res.json(); +} + +export async function terminateWorkflowInstance( + instanceId: number, + input: { reason: string; confirm: boolean; expected_config_revision: number } +): Promise<{ instance: { id: number; status: string }; reason: string }> { + const res = await fetch(`${API_BASE}/api/v1/workflow-instances/${instanceId}/terminate`, { + method: 'POST', + headers: getBearerAuthHeaders('application/json'), + body: JSON.stringify(input), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(parseError(text) || `终止工作流失败: ${res.status}`); + } + return res.json(); +} + export async function advanceStage( instanceId: number, stageKey: string, @@ -148,7 +188,11 @@ export async function advanceStage( ); if (!res.ok) { const text = await res.text(); - throw new Error(parseError(text) || `阶段推进失败: ${res.status}`); + const msg = parseError(text) || `阶段推进失败: ${res.status}`; + if (res.status === 409 || /revision|conflict|版本/i.test(msg)) { + throw new Error(`配置版本已变化,请刷新后重试:${msg}`); + } + throw new Error(msg); } return res.json(); } diff --git a/web/src/components/project/ProjectSettingsModal.tsx b/web/src/components/project/ProjectSettingsModal.tsx index 05d65cf9..4d5eefc0 100644 --- a/web/src/components/project/ProjectSettingsModal.tsx +++ b/web/src/components/project/ProjectSettingsModal.tsx @@ -6,6 +6,8 @@ import { saveWorkflowPlan, fetchActivationPreview, activateWorkflowInstance, + fetchTerminationPreview, + terminateWorkflowInstance, } from '../../api/projectSettings'; import { ProjectSettings, @@ -45,6 +47,8 @@ export const ProjectSettingsModal: React.FC = ({ const [plan, setPlan] = useState(null); const [preview, setPreview] = useState(null); const [selectedKey, setSelectedKey] = useState(''); + const [terminateReason, setTerminateReason] = useState(''); + const [terminatePreview, setTerminatePreview] = useState<{ impact?: string } | null>(null); const loadSettings = useCallback(async () => { setLoading(true); @@ -52,6 +56,16 @@ export const ProjectSettingsModal: React.FC = ({ try { const s = await fetchProjectSettings(projectId); setSettings(s); + if (s.live_instance && s.live_instance.status !== 'completed' && s.live_instance.status !== 'terminated') { + try { + const preview = await fetchTerminationPreview(s.live_instance.id); + setTerminatePreview(preview); + } catch { + setTerminatePreview(null); + } + } else { + setTerminatePreview(null); + } if (s.plan) { setPlan(s.plan); setSelectedKey(s.plan.workflow_definition_key); @@ -151,6 +165,24 @@ export const ProjectSettingsModal: React.FC = ({ } }; + const handleTerminate = async () => { + if (!settings?.live_instance || !terminateReason.trim()) return; + setLoading(true); + setError(null); + try { + await terminateWorkflowInstance(settings.live_instance.id, { + reason: terminateReason.trim(), + confirm: true, + expected_config_revision: settings.live_instance.config_revision, + }); + onClose(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : '终止工作流失败'); + } finally { + setLoading(false); + } + }; + const handleActivate = async () => { if (!preview || !settings) return; setLoading(true); @@ -260,6 +292,57 @@ export const ProjectSettingsModal: React.FC = ({
)} + {settings?.live_instance && settings.live_instance.status !== 'completed' && ( +
+
受控终止当前工作流
+ {terminatePreview?.impact && ( +
{terminatePreview.impact}
+ )} +