diff --git a/.doc/audit/2026-09-13-document-revision-reviewer-duty-sync.md b/.doc/audit/2026-09-13-document-revision-reviewer-duty-sync.md new file mode 100644 index 00000000..9ac648e1 --- /dev/null +++ b/.doc/audit/2026-09-13-document-revision-reviewer-duty-sync.md @@ -0,0 +1,82 @@ +--- +title: "项目 reviewer 未同步到受控文档职责" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +severity: critical +--- + +# 项目 reviewer 未同步到受控文档职责 + +## 现象 + +第17轮真实 UI(schema 30,`dev@639969f`):UI-00 至 UI-12 PASS。UI-13a 精确职责断言首次暴露产品缺陷: + +- UI-01 页面建项目把 USER_B 的职责下拉设为 `reviewer` +- B 登录打开 `doc_proj6145_intake` 权限矩阵时,select `value` / badge `data-doc-role` 实际为 `viewer` +- 失败:`reviewer B intake duty select must be reviewer: expected_duty:reviewer;got:viewer` + +现场文档头仍写「编制人: 编制工程师 / 审核人: 审核负责人 / 批准人: 批准负责人」。 + +## 根因分析 + +只读查本轮 `project 6145`(不暴露账号密码/token): + +| 层 | 证据 | 后果 | +|---|---|---| +| 立项 UI | `CreateProjectModal` 把 B 标成 `reviewer`;默认还勾选 seed `id=3` 李主管 / `id=4` 王总监 | 用户意图是 B=审核人 | +| 成员持久化 | `toApiProjectRole` **禁止** author/reviewer/approver,一律映射 `member`;payload 无 document duty | B=`171` 落成 `project_role=member` | +| `project_members` | `170` owner(A)、`171` member(B)、`3`/`4` member | 库表无法区分 B 是 reviewer | +| 任务生成 | `bizdev.service`:`ReviewerID==0` → **写死 2** +「审核负责人」;`ApproverID==0` → **写死 3** | 与真实成员无关 | +| `document_records` | intake / prelim_design 均为 `author_id=170`(恰为创建者 UserID)、`reviewer_id=2`、`approver_id=3` | 第二份文档继承了同一套假职责 | +| 列表 API | 原样投影 `reviewer_id=2` | 前端拿到权威但错误的 id | +| `resolveCurrentDocRole` | 受控文档按 id 匹配,fail-closed 为 viewer | B=`171` ≠ `2` → viewer;**不是前端提权,是服务端职责丢失** | + +`document_duty_assignments` 只挂官方工作流实例任务,聊天 `TRIGGER_TASK` 生成账本不走该表。 + +## 严重度 + +`critical` + +项目创建时明确指定的审核人在后续每份受控文档上变成查阅者;写路径虽因 fail-closed 未把 B 提成编制人,但审核职责与权限矩阵验收全部失真。 + +## 关联整改 + +- Remediation: [../remediation/2026-09-13-document-revision-reviewer-duty-sync.md](../remediation/2026-09-13-document-revision-reviewer-duty-sync.md) +- Short-term: [../ops/short-term/2026-09-13-document-revision-reviewer-duty-sync.md](../ops/short-term/2026-09-13-document-revision-reviewer-duty-sync.md) +- 分支: `fix/document-revision-reviewer-duty-sync` + +## 证据 + +- 失败归档(只读,不改不删):`.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui13a-reviewer-duty-sync-failure/` +- `phase-state.json`:`controlledDocId=doc_proj6145_intake`,项目 `UI验收-超声微创系统-mu0bvnp8` +- Playwright:`expected_duty:reviewer;got:viewer` + +## 独立审查收紧(同一分支,暂不合入) + +独立审查指出首轮 Heal「已是任意在任成员则保留」会让批准人串位:6145 类场景 `approver=3`(李主管也是成员)而 assigned=`4` 时不应保留 3。项目 `document_duty` 才是权威。 + +立项后成员增改/恢复若只发 `project_role`,`document_duty` 会静默失效。更新成员时若省略 `project_role` 且带 `document_duty`,默认 `member` 会把 owner 降级。 + +生成/列表路径若吞掉自愈错误,会带着 `reviewer_id=2` 假成功。 + +## 最后一轮缺口(已在同分支修复) + +- 移出成员只改 `exited` 不同步文档;前端移出不刷新矩阵 +- 空 assigned 若保留非成员占位,退出审核人会留在文档上 +- reviewer→viewer 时人仍 active,generic heal 会把槽位留下,必须按更新前 duty 显式撤销 +- `CreateOrGetDocument` 错误被 `_` 吞掉会假成功 + +## 原子清槽约束(已在同分支修复) + +职责撤销/转移不得在成员 upsert 已提交后再用服务层前后快照做非原子清理。权威路径是同一 DB 事务:`pg_advisory_xact_lock` → 读旧 duty / 冲突 holder → 成员更新 → 精确清 `reviewer_*` / `approver_*`。随后 heal 填新 holder。 + +## Heal 与成员写竞态(已在同分支修复) + +`HealProjectDocuments` 若无项目锁、用 pool 先读后写,会出现:heal 读到 reviewer A → 成员事务持锁把 A 清槽并提交为 B → 旧 heal 把 A 写回。现已与成员写共用 `pg_advisory_xact_lock(project_id)`,解析与 `document_records` 更新在同一事务。 + +## 开放问题 + +- 本轮不重跑真实 UI-13a;不得标 E2E 通过或任务 `done` +- **6145 剩余风险**:迁移后 owner 会补 `author`;B(171)当时只落成 `member` 且 `document_duty=''`。服务端不能臆造 reviewer。须再次经成员 API 写入 `document_duty=reviewer` 后,自愈才会把文档从占位 `2` 对齐到 B。 +- 隔离 PG 集成须用临时库 + `TEST_DATABASE_URL`,见 `scripts/run-isolated-duty-sync-pg-tests.sh`;禁止默认连 `agent_system_dev` diff --git a/.doc/audit/INDEX.md b/.doc/audit/INDEX.md index eb114735..a6831c15 100644 --- a/.doc/audit/INDEX.md +++ b/.doc/audit/INDEX.md @@ -31,6 +31,14 @@ ### 最新专项审计(2026-09-13) +**文件**: [2026-09-13-document-revision-reviewer-duty-sync.md](./2026-09-13-document-revision-reviewer-duty-sync.md) + +**结论**: 第17轮 UI-00–12 PASS;精确职责断言暴露真实缺陷——UI-01 已把 B 设为 reviewer,`doc_proj6145_*` 的 `reviewer_id` 却是硬编码 `2`,矩阵为 viewer。 + +**整改**: [项目 reviewer 同步整改](../remediation/2026-09-13-document-revision-reviewer-duty-sync.md) + +### UI-13a 职责单元格误判(2026-09-13) + **文件**: [2026-09-13-document-revision-duty-cell-assertion.md](./2026-09-13-document-revision-duty-cell-assertion.md) **结论**: 第16轮 UI-13a 已打开 reviewer B 权限矩阵;整行 `innerText` 含全部 select option,误命中「编制负责人」。须读 select value / badge role。 diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 135124c8..0759aa31 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -1,11 +1,11 @@ # 当前焦点 **更新**: 2026-09-13 -**production_ready**: no(DOCREV:schema **30**;第16轮 UI-13a 职责单元格断言;不执行 env-service 重建 / 不声称 E2E 通过) +**production_ready**: no(DOCREV:schema **30**;第17轮 UI-13a reviewer 职责未同步;不执行 env-service 重建 / 不声称 E2E 通过) ## 焦点 -1. **DOCREV-RT**: [职责单元格精确断言](short-term/2026-09-13-document-revision-duty-cell-assertion.md)(`fix/document-revision-duty-cell-assertion`;第16轮整行 option 文案误判编制负责人;不声称 E2E 通过) +1. **DOCREV-RT**: [项目 reviewer 同步到受控文档职责](short-term/2026-09-13-document-revision-reviewer-duty-sync.md)(`fix/document-revision-reviewer-duty-sync`;heal 同事务锁已提交,暂不合入;不声称 E2E 通过) 2. **DOCREV-RT**: [质量修复子工作区契约](short-term/2026-09-13-document-revision-quality-repair-workspace-contract.md)(已合入 `dev@339e871`;仍 `in_progress`,待占槽重跑 UI-03) 3. **DOCREV-RT**: [编辑弹窗 hydration 竞态](short-term/2026-09-13-document-revision-editor-hydration-race.md)(已合入 `dev@8b1a531`;仍 `in_progress`,待占槽重跑 UI-06) @@ -33,6 +33,7 @@ | Cursor-Auto | document-revision-fast-completion-observation | fix/document-revision-fast-completion-observation | - | document-revision-fast-completion-observation | in_progress | | Cursor-Auto | document-revision-workflow-category-navigation | fix/document-revision-workflow-category-navigation | - | document-revision-workflow-category-navigation | in_progress | | Cursor-Auto | document-revision-duty-cell-assertion | fix/document-revision-duty-cell-assertion | - | document-revision-duty-cell-assertion | in_progress | +| Cursor-Auto | document-revision-reviewer-duty-sync | fix/document-revision-reviewer-duty-sync | - | document-revision-reviewer-duty-sync | in_progress | > 新任务开始前登记。`branch` 必须为 `feature/` 或 `fix/`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。 @@ -83,3 +84,4 @@ | UI-11 快速完成观测误判审计 | [../audit/2026-09-13-document-revision-fast-completion-observation.md](../audit/2026-09-13-document-revision-fast-completion-observation.md) | | UI-13a 私密分类导航失败审计 | [../audit/2026-09-13-document-revision-workflow-category-navigation.md](../audit/2026-09-13-document-revision-workflow-category-navigation.md) | | UI-13a 职责单元格误判审计 | [../audit/2026-09-13-document-revision-duty-cell-assertion.md](../audit/2026-09-13-document-revision-duty-cell-assertion.md) | +| UI-13a reviewer 职责未同步审计 | [../audit/2026-09-13-document-revision-reviewer-duty-sync.md](../audit/2026-09-13-document-revision-reviewer-duty-sync.md) | diff --git a/.doc/ops/checklists/2026-09-13-document-revision-reviewer-duty-sync.md b/.doc/ops/checklists/2026-09-13-document-revision-reviewer-duty-sync.md new file mode 100644 index 00000000..5ccaebf5 --- /dev/null +++ b/.doc/ops/checklists/2026-09-13-document-revision-reviewer-duty-sync.md @@ -0,0 +1,25 @@ +--- +title: "项目 reviewer 职责同步收紧" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +related_task: "2026-09-13-document-revision-reviewer-duty-sync.md" +--- + +# 项目 reviewer 职责同步收紧 + +## 最后一轮严格修复 + +- [x] 移出成员后端 reconcile + `ERR_DUTY_SYNC`;前端 `afterProjectMemberPersisted` 刷新文档 +- [x] Heal 空 assigned 仅清退出/非成员;在任成员保留;author 不清空 +- [x] 显式 revoke:reviewer→viewer、职责转移、退出、幂等;author 不进撤销集 +- [x] `CreateOrGetDocument` 失败任务不成功;fault test +- [x] 成员 upsert/退出:advisory lock、读旧/冲突 holder、成员更新、`document_records` 精确清槽同一事务;提交后 heal;失败 `ERR_DUTY_SYNC` +- [x] `HealProjectDocuments` 与成员写同锁键/顺序;解析与文档更新同事务;转移后旧 holder 不复活;第二次 heal 幂等 +- [x] `gofmt` / `go vet` / `go test -short` / 相关 `-race` / `npm test` / `build` / 隔离 PG +- [ ] 真实 UI-13a(本轮不做) + +## 完成定义 + +- [x] 上述实现项有测试或隔离 PG 证据 +- [ ] 现场 UI-13a 仍开放,不得标 done diff --git a/.doc/ops/handoffs/2026-09-13-document-revision-reviewer-duty-sync.md b/.doc/ops/handoffs/2026-09-13-document-revision-reviewer-duty-sync.md new file mode 100644 index 00000000..b2af5cbe --- /dev/null +++ b/.doc/ops/handoffs/2026-09-13-document-revision-reviewer-duty-sync.md @@ -0,0 +1,71 @@ +--- +title: "reviewer 职责同步:heal 同事务锁后交接" +created: 2026-09-13 +from: Cursor-Auto +to: next-agent +status: open +--- + +# 交接:reviewer 职责同步(暂不合入) + +## 目标(下一位 Agent 应完成什么) + +- 不要在本分支继续改职责语义,除非新审查点 +- 合入前占 CURRENT 集成槽位,再 `--no-ff` 合 `dev`;本轮禁止 merge / 重建容器 / 声称 E2E +- 6145 的 B 仍是空 `document_duty`,须再走成员 API 写成 reviewer 后自愈才会对齐文档 + +## 已完成 + +- Heal 以 assigned 为权威;空 assigned 只清失效审核/批准 holder +- 成员增改/移出:同一事务内 advisory lock + 读旧/冲突 holder + 成员写 + 精确清 reviewer/approver 槽;提交后服务层 heal 新 holder +- `HealProjectDocuments` 与成员写共用 `pg_advisory_xact_lock(project_id)`;解析与文档读写同事务;错误回滚 +- 确定性并发 PG:成员写持锁未提交时 heal 进入 `pg_locks` 等待;提交后旧 reviewer 不复活;第二次 heal 幂等 +- 同步失败仍显式 `ERR_DUTY_SYNC`;author 不因普通成员变更清空 +- 账本创建失败任务失败 + +## 未完成 + +- 真实 UI-13a +- 合入 `dev` 与共享容器 schema 31 晋升 + +## 认领 / 槽位状态 + +| 项 | 值 | +|----|-----| +| mutex 已释放 | no(`document-revision-reviewer-duty-sync` 仍 `in_progress`) | +| 集成槽位 holder | 他人 e2e;本任务未占槽 | +| branch | `fix/document-revision-reviewer-duty-sync` | +| worktree | - | + +> 若 mutex 未释放,下一位须先在 CURRENT 认领或写 blocker。 + +## 关键文件 + +| 路径 | 说明 | +|------|------| +| `internal/docduty/revoke.go` | `AcquireProjectDutyLock`(与成员写同键) | +| `internal/docduty/store.go` | heal lock + 解析 + 回填同事务 | +| `internal/authz/authorizer.go` | upsert / setStatus 走同一 lock helper | +| `scripts/run-isolated-duty-sync-pg-tests.sh` | 隔离 PG 入口 | + +## 如何验证 + +```bash +go test -short ./... +go test -short -race ./internal/docduty ./internal/authz ./internal/bizdev ./internal/server +cd web && npm test && npm run build +# 隔离库,禁止 agent_system_dev: +./scripts/run-isolated-duty-sync-pg-tests.sh +``` + +## 风险 / Blocker + +- 旧项目未写 `document_duty` 时,在任成员仍留在文档槽位(有意兼容) +- 显式降级/退出才会撤 reviewer/approver;author 永不被 revoke +- 6145 B 不会被臆造为 reviewer + +## 相关链接 + +- CURRENT: [../CURRENT.md](../CURRENT.md) +- Task: [../short-term/2026-09-13-document-revision-reviewer-duty-sync.md](../short-term/2026-09-13-document-revision-reviewer-duty-sync.md) +- Checklist: [../checklists/2026-09-13-document-revision-reviewer-duty-sync.md](../checklists/2026-09-13-document-revision-reviewer-duty-sync.md) diff --git a/.doc/ops/short-term/2026-09-13-document-revision-reviewer-duty-sync.md b/.doc/ops/short-term/2026-09-13-document-revision-reviewer-duty-sync.md new file mode 100644 index 00000000..a67eb18b --- /dev/null +++ b/.doc/ops/short-term/2026-09-13-document-revision-reviewer-duty-sync.md @@ -0,0 +1,68 @@ +--- +title: "项目 reviewer 同步到受控文档职责" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +owner: Cursor-Auto +branch: fix/document-revision-reviewer-duty-sync +worktree: "-" +mutex: document-revision-reviewer-duty-sync +--- + +# 项目 reviewer 同步到受控文档职责 + +## 目标 + +堵住第17轮 UI-13a:UI-01 已把 USER_B 设为 reviewer,但 `doc_proj6145_intake` 矩阵为 viewer。项目创建时的 reviewer 必须成为后续每份受控文档的权威 `reviewerId`。 + +## 范围 + +- `project_members.document_duty` 迁移与成员 upsert +- 任务生成/账本创建解析项目职责;禁止硬编码 2/3 +- 已有文档缺失/非成员占位的服务端自愈 +- 前端立项 persist `document_duty` + 职责互斥 +- audit / remediation / CURRENT + +## 非目标 + +- 不 merge、不启停/重建容器、不跑/声称真实 UI E2E、不 push +- 不删不改证据目录 +- 不向用户索要 token;不提交账号密码 + +## 步骤 + +1. [x] 从 `dev@639969f` 开分支并登记 mutex +2. [x] RED/GREEN:解析、自愈、成员 duty、前端映射 +3. [x] `gofmt` / `go vet` / `go test -short` / 相关 `-race` / `npm test` / `npm run build` +4. [x] 原子提交 +5. [x] 独立审查收紧(暂不合入):Heal 以 assigned 为权威;成员变更带 duty 并同步文档;缺 role 不降 owner;写权精确断言;迁移多 owner 确定回填;并发 upsert PG 用例;自愈失败不假成功 +6. [x] 最后一轮:移出成员 sync + 前端刷新;空 assigned 只清失效 holder;显式 revoke 再 heal;账本创建失败不成功 +7. [x] 补充审查:lock + 读旧/冲突 holder + 成员写 + 精确清槽同事务;提交后 heal;失败仍 `ERR_DUTY_SYNC` +8. [x] Heal 与成员写共用 `pg_advisory_xact_lock(project_id)`;解析与文档读写同事务;确定性并发 PG 证明旧 reviewer 不复活 + +## 验收标准 + +- [x] A=author、B=reviewer、C=none;第二文档继承;重复执行幂等;旧文档缺失回填 +- [x] 服务端写操作仍按权限拒绝 +- [x] 前端不按名字提升 +- [ ] 现场 UI-13a(未跑) + +## 相关链接 + +- Audit: [../../audit/2026-09-13-document-revision-reviewer-duty-sync.md](../../audit/2026-09-13-document-revision-reviewer-duty-sync.md) +- Remediation: [../../remediation/2026-09-13-document-revision-reviewer-duty-sync.md](../../remediation/2026-09-13-document-revision-reviewer-duty-sync.md) +- Checklist: [../checklists/2026-09-13-document-revision-reviewer-duty-sync.md](../checklists/2026-09-13-document-revision-reviewer-duty-sync.md) +- Handoff: [../handoffs/2026-09-13-document-revision-reviewer-duty-sync.md](../handoffs/2026-09-13-document-revision-reviewer-duty-sync.md) + +## 证据 / 备注 + +- mutex:`document-revision-reviewer-duty-sync`(新 mutex,不占用已有 in_progress) +- 基线:`dev@639969f` +- 证据(只读):`.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui13a-reviewer-duty-sync-failure/` +- 验证(原子清槽轮):`gofmt`/`go vet` PASS;`go test -short ./...` PASS;相关 `-race` PASS;`npm test`/`build` PASS;隔离 PG PASS +- 原子性:成员写与 `HealProjectDocuments` 均先 `pg_advisory_xact_lock(project_id)`,再在同一事务内读成员/文档并更新;旧 heal 不能在转移提交后把 reviewer A 写回 +- PG 集成已写 heal/空 assigned/多 owner 回填/并发 upsert;`-short` 或无 `TEST_DATABASE_URL` 时跳过 +- 隔离跑法:`scripts/run-isolated-duty-sync-pg-tests.sh`(`TEST_DATABASE_URL` 或 `PG_ADMIN_URL` 建临时库)。拒绝 `agent_system_dev` +- 本轮已在已运行的 context Postgres 上 `CREATE DATABASE agent_duty_sync_itest_*`,串行跑通后 `DROP`;未 `env-service`、未动共享库数据 +- **兼容**:旧二进制不读 `document_duty` 仍可运行;新代码在 assigned 为空时不臆造 reviewer。6145 的 B 仍是空 duty,须再走成员 API +- **未跑真实 UI-13a,不得标 done**;**不得 merge / 启停容器 / E2E / push** diff --git a/.doc/remediation/2026-09-13-document-revision-reviewer-duty-sync.md b/.doc/remediation/2026-09-13-document-revision-reviewer-duty-sync.md new file mode 100644 index 00000000..4918af77 --- /dev/null +++ b/.doc/remediation/2026-09-13-document-revision-reviewer-duty-sync.md @@ -0,0 +1,55 @@ +--- +title: "项目 reviewer 同步到受控文档职责" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +--- + +# 项目 reviewer 同步到受控文档职责 + +## 背景 + +链到 [../audit/2026-09-13-document-revision-reviewer-duty-sync.md](../audit/2026-09-13-document-revision-reviewer-duty-sync.md)。 + +## 目标 + +- 立项时指定的 reviewer 成员,在后续**每份新生成受控文档**上成为 `reviewerId` / `reviewer` +- 服务端职责为权威;前端不得靠名字或本地宽松回退提升 +- 非成员仍无权;作者/批准人不串位 +- 已有文档职责缺失或为非成员占位(如硬编码 `2`/`3`)时,服务端可重复自愈,而不是只改测试数据 + +## 分波次计划 + +| 波次 | 内容 | 验收 | +|------|------|------| +| Wave 0 | `project_members.document_duty` + 解析/自愈 + 任务生成去硬编码 + 前端持久化 duty | A author、B reviewer、C none;第二文档继承;幂等;写操作按权拒绝;`go test -short` / npm | +| Wave 1 | 占槽重跑真实 UI-13a(本轮不做) | 矩阵 `role=reviewer` | + +## 设计 + +1. 访问角色仍是 `owner/member/viewer`;文档职责另存 `document_duty`(`author`/`reviewer`/`approver`/空),同一项目同一职责仅一名在任成员。 +2. `Resolve`:author=指定编制人或创建者;reviewer/approver=指定职责,否则仅当请求 id 是在任成员才采用;**禁止**默认 2/3。 +3. `Heal`:项目 `document_duty` 为权威。assigned 非零则对齐(错 ID——即使对方也是在任成员——或同 ID 旧名称);assigned 为空才保留当前值,避免臆造/清空。禁止「任意 active member 就放过」。 +4. 列表/生成走自愈;**失败必须返回可诊断错误**,生成路径不得假成功;`CreateOrGetDocument` 失败不得报告成功。成员增改/移出:advisory lock、读取旧/冲突 holder、成员更新、`document_records` 精确清 reviewer/approver 槽必须在同一事务;提交后服务层 heal 新 holder;失败仍 `ERR_DUTY_SYNC`。author 不因普通成员变更清空。`HealProjectDocuments` 必须与成员写共用同一 `pg_advisory_xact_lock(project_id)`,并在该事务内完成解析与文档更新,避免旧 heal 把已转移的 reviewer 写回。 +5. 前端立项与立项后增改/恢复都发 `document_dutyFromMemberRole`。更新成员省略 `project_role` 时保留现有访问角色,禁止默认 `member` 把 owner 降级。 +6. 迁移 owner 回填:创建者优先,否则最低 `user_id`,单行 UPDATE,避免部分唯一索引冲突。 + +## 验收标准 + +- [x] 服务端解析与自愈单测(含错 ID 恰为成员、同 ID 旧名称、assigned 空、第二文档、幂等) +- [x] 前端立项与成员增改/恢复 payload 带 `document_duty`;不按名字提权 +- [x] 更新成员缺 `project_role` 保留 owner;写权测试 author `err==nil`,reviewer/outsider 精确 forbidden +- [x] `go test -short ./...`、相关 `-race`、`npm test`/`build` +- [x] 隔离 PG(临时库 `agent_duty_sync_itest_*`,测完删除;未连 `agent_system_dev`、未启停容器) +- [ ] 真实 UI-13a(本轮不做) + +## 兼容与剩余风险 + +- **兼容**:未应用 00031 的环境不读 `document_duty`;应用后旧成员空 duty 不会被臆造为 reviewer。assigned 为空时自愈不清空已有在任成员。 +- **6145**:B 仍是 `member` + 空 duty。新 heal 会把错误成员 approver 对齐到 assigned(若有);B 的文档 reviewer 仍是占位 `2`,直到成员 API 写入 `document_duty=reviewer`。 +- **隔离 PG**:`TEST_DATABASE_URL` 指向临时库,或 `PG_ADMIN_URL` + `scripts/run-isolated-duty-sync-pg-tests.sh`。禁止默认连 `agent_system_dev`。本轮不启停共享容器。 + +## 相关链接 + +- Audit: [../audit/2026-09-13-document-revision-reviewer-duty-sync.md](../audit/2026-09-13-document-revision-reviewer-duty-sync.md) +- Short-term: [../ops/short-term/2026-09-13-document-revision-reviewer-duty-sync.md](../ops/short-term/2026-09-13-document-revision-reviewer-duty-sync.md) diff --git a/.doc/remediation/INDEX.md b/.doc/remediation/INDEX.md index 15cf191d..ece77867 100644 --- a/.doc/remediation/INDEX.md +++ b/.doc/remediation/INDEX.md @@ -3,6 +3,7 @@ | 文档 | 说明 | |------|------| | [gap-matrix.md](./gap-matrix.md) | 发布缺口 SSOT | +| [2026-09-13-document-revision-reviewer-duty-sync.md](./2026-09-13-document-revision-reviewer-duty-sync.md) | UI-01 已分配 B=reviewer,受控文档 reviewer_id 硬编码 2,矩阵降为 viewer | | [2026-09-13-document-revision-duty-cell-assertion.md](./2026-09-13-document-revision-duty-cell-assertion.md) | UI-13a 整行 option 文案误判编制负责人,须读 select value/badge | | [2026-09-13-document-revision-workflow-category-navigation.md](./2026-09-13-document-revision-workflow-category-navigation.md) | UI-13a 停在私密分类,打开受控文档前必须先切流程受控 | | [2026-09-13-document-revision-fast-completion-observation.md](./2026-09-13-document-revision-fast-completion-observation.md) | UI-11 快完成被硬等 inflight 误杀,只改验收观测契约 | diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index 5fcf0b83..7746b482 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -25,7 +25,7 @@ | 前端工作台 | 三栏 + 块文档 + SSE | partial | [test workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[现场专项审计](../audit/2026-09-13-conversation-intent-runtime-state.md) | 错误卡片/任务进度/文档 busy 状态刷新恢复与人工抽检待完成 | `web` | | PI Runner / 配置链 | pi-agent + MiniMax 真实链路 | done | [test piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md) | — | `pi-agent` | | 三环境运维 | dev/test/main + 共享 KB | done | [PROJECT.md](../../PROJECT.md) §4;四环境 healthy | — | `infra` | -| CI / E2E | PG 集成 + 真实 PI E2E | partial | [gap-matrix BUG-08](../remediation/gap-matrix.md);[test pg-ci](../test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md);[UI-13a 职责单元格](../audit/2026-09-13-document-revision-duty-cell-assertion.md) | CI `TEST_DATABASE_URL` + 真实 PI E2E 实录;职责断言不得用整行 option 文案 | `ci` | +| CI / E2E | PG 集成 + 真实 PI E2E | partial | [gap-matrix BUG-08](../remediation/gap-matrix.md);[test pg-ci](../test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md);[UI-13a reviewer 职责同步](../audit/2026-09-13-document-revision-reviewer-duty-sync.md) | CI `TEST_DATABASE_URL` 隔离库;立项/改职责后 reviewer 必须成为受控文档 `reviewer_id`;Heal 以 assigned 为权威;成员写与 heal 同项目锁 | `ci` | --- diff --git a/deploy/postgres/goose/00031_2026-09-13-project-member-document-duty.sql b/deploy/postgres/goose/00031_2026-09-13-project-member-document-duty.sql new file mode 100644 index 00000000..ca2e605d --- /dev/null +++ b/deploy/postgres/goose/00031_2026-09-13-project-member-document-duty.sql @@ -0,0 +1,54 @@ +-- +goose Up +-- 项目成员访问角色(owner/member/viewer)与文档职责(author/reviewer/approver)分离。 +-- 立项时指定的 reviewer 必须能回填到后续受控文档 reviewer_id。 + +ALTER TABLE project_members + ADD COLUMN IF NOT EXISTS document_duty TEXT NOT NULL DEFAULT ''; + +ALTER TABLE project_members + DROP CONSTRAINT IF EXISTS chk_pm_document_duty; + +ALTER TABLE project_members + ADD CONSTRAINT chk_pm_document_duty + CHECK (document_duty IN ('', 'author', 'reviewer', 'approver')); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_project_members_active_document_duty + ON project_members (project_id, document_duty) + WHERE status = 'active' AND document_duty <> ''; + +-- 创建者默认编制职责;已有非空职责不覆盖。 +-- 多 owner/异常历史只回填一行:创建者优先,否则最低 user_id。 +-- 必须与 internal/docduty.OwnerAuthorBackfillSQL 保持同步。 +WITH candidates AS ( + SELECT pm.project_id, pm.user_id, + CASE WHEN p.creator_user_id = pm.user_id THEN 0 ELSE 1 END AS creator_rank, + pm.user_id AS id_rank + FROM project_members pm + JOIN projects p ON p.id = pm.project_id + WHERE pm.status = 'active' + AND pm.document_duty = '' + AND ( + pm.project_role = 'owner' + OR p.creator_user_id = pm.user_id + ) + AND NOT EXISTS ( + SELECT 1 FROM project_members other + WHERE other.project_id = pm.project_id + AND other.status = 'active' + AND other.document_duty = 'author' + ) +), +picked AS ( + SELECT DISTINCT ON (project_id) project_id, user_id + FROM candidates + ORDER BY project_id, creator_rank ASC, id_rank ASC +) +UPDATE project_members pm +SET document_duty = 'author', updated_at = now() +FROM picked +WHERE pm.project_id = picked.project_id AND pm.user_id = picked.user_id; + +-- +goose Down +DROP INDEX IF EXISTS uq_project_members_active_document_duty; +ALTER TABLE project_members DROP CONSTRAINT IF EXISTS chk_pm_document_duty; +ALTER TABLE project_members DROP COLUMN IF EXISTS document_duty; diff --git a/internal/authz/authorizer.go b/internal/authz/authorizer.go index b9b99c6c..db9a9583 100644 --- a/internal/authz/authorizer.go +++ b/internal/authz/authorizer.go @@ -7,13 +7,16 @@ import ( "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" + + "chat-agent-system/internal/docduty" ) var ( - ErrForbidden = errors.New("authz: forbidden") - ErrProjectNotFound = errors.New("authz: project not found") - ErrUnauthenticated = errors.New("authz: unauthenticated") + ErrForbidden = errors.New("authz: forbidden") + ErrProjectNotFound = errors.New("authz: project not found") + ErrUnauthenticated = errors.New("authz: unauthenticated") ) const ( @@ -32,14 +35,21 @@ var allowedProjectRoles = map[string]struct{}{ // ProjectMemberRecord 项目成员 API 视图。 type ProjectMemberRecord struct { - ID int64 `json:"id,omitempty"` - ProjectID int64 `json:"project_id,omitempty"` - UserID int64 `json:"user_id"` - Username string `json:"username,omitempty"` - Name string `json:"name,omitempty"` - ProjectRole string `json:"project_role"` - Status string `json:"status"` - JoinedAt string `json:"joined_at,omitempty"` + ID int64 `json:"id,omitempty"` + ProjectID int64 `json:"project_id,omitempty"` + UserID int64 `json:"user_id"` + Username string `json:"username,omitempty"` + Name string `json:"name,omitempty"` + ProjectRole string `json:"project_role"` + DocumentDuty string `json:"document_duty,omitempty"` + Status string `json:"status"` + JoinedAt string `json:"joined_at,omitempty"` +} + +// MemberUpsert 项目访问角色 + 可选文档职责。 +type MemberUpsert struct { + ProjectRole string + DocumentDuty *string } // Authorizer 项目级对象授权。 @@ -55,20 +65,13 @@ func (a *Authorizer) enabled() bool { return a != nil && a.pool != nil } -// AddOwnerOnCreate 项目创建时写入 owner 成员关系。 +// AddOwnerOnCreate 项目创建时写入 owner 成员关系,并默认编制职责。 func (a *Authorizer) AddOwnerOnCreate(ctx context.Context, projectID, userID int64) error { - if !a.enabled() || projectID <= 0 || userID <= 0 { - return nil - } - _, err := a.pool.Exec(ctx, ` - INSERT INTO project_members (project_id, user_id, project_role, status, joined_at, updated_at) - VALUES ($1, $2, $3, $4, now(), now()) - ON CONFLICT (project_id, user_id) DO UPDATE SET - project_role = EXCLUDED.project_role, - status = EXCLUDED.status, - updated_at = now() - `, projectID, userID, RoleOwner, StatusActive) - return err + duty := docduty.DutyAuthor + return a.UpsertProjectMemberSpec(ctx, projectID, userID, MemberUpsert{ + ProjectRole: RoleOwner, + DocumentDuty: &duty, + }) } // IsProjectMember 判断用户是否为项目 active 成员或创建人。 @@ -198,7 +201,7 @@ func (a *Authorizer) ListProjectMembers(ctx context.Context, projectID int64) ([ SELECT pm.id, pm.project_id, pm.user_id, COALESCE(NULLIF(u.username, ''), u.account_id, '') AS username, COALESCE(u.name, '') AS name, - pm.project_role, pm.status, pm.joined_at + pm.project_role, COALESCE(pm.document_duty, ''), pm.status, pm.joined_at FROM project_members pm JOIN users u ON u.id = pm.user_id WHERE pm.project_id = $1 @@ -215,7 +218,7 @@ func (a *Authorizer) ListProjectMembers(ctx context.Context, projectID int64) ([ if err := rows.Scan( &rec.ID, &rec.ProjectID, &rec.UserID, &rec.Username, &rec.Name, - &rec.ProjectRole, &rec.Status, &joined, + &rec.ProjectRole, &rec.DocumentDuty, &rec.Status, &joined, ); err != nil { return nil, err } @@ -225,29 +228,122 @@ func (a *Authorizer) ListProjectMembers(ctx context.Context, projectID int64) ([ return out, rows.Err() } -// UpsertProjectMember 新增或更新成员角色(禁止 docgov 专用角色名)。 +// GetProjectMember 读取单个成员(含已退出)。无记录或禁用时返回 nil, nil。 +func (a *Authorizer) GetProjectMember(ctx context.Context, projectID, userID int64) (*ProjectMemberRecord, error) { + if a == nil || !a.enabled() || projectID <= 0 || userID <= 0 { + return nil, nil + } + var rec ProjectMemberRecord + var joined time.Time + err := a.pool.QueryRow(ctx, ` + SELECT pm.id, pm.project_id, pm.user_id, + COALESCE(NULLIF(u.username, ''), u.account_id, '') AS username, + COALESCE(u.name, '') AS name, + pm.project_role, COALESCE(pm.document_duty, ''), pm.status, pm.joined_at + FROM project_members pm + JOIN users u ON u.id = pm.user_id + WHERE pm.project_id = $1 AND pm.user_id = $2 + `, projectID, userID).Scan( + &rec.ID, &rec.ProjectID, &rec.UserID, + &rec.Username, &rec.Name, + &rec.ProjectRole, &rec.DocumentDuty, &rec.Status, &joined, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get project member: %w", err) + } + rec.JoinedAt = joined.Format("2006-01-02 15:04:05") + return &rec, nil +} + +// UpsertProjectMember 新增或更新成员访问角色(禁止把 author/reviewer/approver 写入 project_role)。 func (a *Authorizer) UpsertProjectMember(ctx context.Context, projectID, userID int64, projectRole string) error { + return a.UpsertProjectMemberSpec(ctx, projectID, userID, MemberUpsert{ProjectRole: projectRole}) +} + +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23505" +} + +func isRetryableDutyConflict(err error) bool { + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) { + return false + } + switch pgErr.Code { + case "23505", "40P01", "40001": + return true + default: + return false + } +} + +// UpsertProjectMemberSpec 写入访问角色,并可选独占 document_duty。 +// 写入 document_duty 时,同一事务内:项目 advisory lock、读取旧/冲突 holder、 +// 成员更新、精确清空 document_records 的 reviewer/approver 槽;不改 author。 +// 并发抢同一职责时重试,收敛为 last-writer,并依赖部分唯一索引保证无双 reviewer。 +func (a *Authorizer) UpsertProjectMemberSpec(ctx context.Context, projectID, userID int64, spec MemberUpsert) error { if projectID <= 0 || userID <= 0 { return fmt.Errorf("authz: invalid project or user id") } - if err := validateProjectRole(projectRole); err != nil { + if err := validateProjectRole(spec.ProjectRole); err != nil { return err } + var duty string + var err error + if spec.DocumentDuty != nil { + duty, err = docduty.NormalizeDocumentDuty(*spec.DocumentDuty) + if err != nil { + return err + } + } if !a.enabled() { return nil } - _, err := a.pool.Exec(ctx, ` + const maxAttempts = 4 + var last error + for attempt := 0; attempt < maxAttempts; attempt++ { + last = a.upsertProjectMemberSpecOnce(ctx, projectID, userID, spec, duty) + if last == nil || !isRetryableDutyConflict(last) { + return last + } + } + return last +} + +func (a *Authorizer) upsertProjectMemberSpecOnce(ctx context.Context, projectID, userID int64, spec MemberUpsert, duty string) error { + tx, err := a.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + + if spec.DocumentDuty != nil { + if err := applyDutyWriteInTx(ctx, tx, projectID, userID, spec.ProjectRole, duty); err != nil { + return err + } + return tx.Commit(ctx) + } + + _, err = tx.Exec(ctx, ` INSERT INTO project_members (project_id, user_id, project_role, status, joined_at, updated_at) VALUES ($1, $2, $3, $4, now(), now()) ON CONFLICT (project_id, user_id) DO UPDATE SET project_role = EXCLUDED.project_role, status = EXCLUDED.status, updated_at = now() - `, projectID, userID, projectRole, StatusActive) - return err + `, projectID, userID, spec.ProjectRole, StatusActive) + if err != nil { + return err + } + return tx.Commit(ctx) } // SetProjectMemberStatus 更新成员状态(如 exited)。 +// 退出时与 upsert 相同:advisory lock、读取旧 duty、状态更新、精确清槽在同一事务。 func (a *Authorizer) SetProjectMemberStatus(ctx context.Context, projectID, userID int64, status string) error { if projectID <= 0 || userID <= 0 { return fmt.Errorf("authz: invalid project or user id") @@ -258,7 +354,32 @@ func (a *Authorizer) SetProjectMemberStatus(ctx context.Context, projectID, user if !a.enabled() { return nil } - tag, err := a.pool.Exec(ctx, ` + const maxAttempts = 4 + var last error + for attempt := 0; attempt < maxAttempts; attempt++ { + last = a.setProjectMemberStatusOnce(ctx, projectID, userID, status) + if last == nil || !isRetryableDutyConflict(last) { + return last + } + } + return last +} + +func (a *Authorizer) setProjectMemberStatusOnce(ctx context.Context, projectID, userID int64, status string) error { + tx, err := a.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + + if err := docduty.AcquireProjectDutyLock(ctx, tx, projectID); err != nil { + return err + } + prevDuty, err := docduty.LoadMemberDutyTx(ctx, tx, projectID, userID) + if err != nil { + return err + } + tag, err := tx.Exec(ctx, ` UPDATE project_members SET status = $3, updated_at = now() WHERE project_id = $1 AND user_id = $2 @@ -269,7 +390,58 @@ func (a *Authorizer) SetProjectMemberStatus(ctx context.Context, projectID, user if tag.RowsAffected() == 0 { return fmt.Errorf("authz: project member not found") } - return nil + if status == StatusExited { + if err := docduty.ApplySlotRevocations(ctx, tx, projectID, docduty.PlanSlotRevocations(docduty.MemberDutyChange{ + UserID: userID, PreviousDuty: prevDuty, NextDuty: prevDuty, Exited: true, + })); err != nil { + return err + } + } + return tx.Commit(ctx) +} + +// applyDutyWriteInTx 假定调用方已 Begin:lock → 读旧/冲突 holder → 独占写成员 → 清槽。 +func applyDutyWriteInTx(ctx context.Context, tx pgx.Tx, projectID, userID int64, projectRole, duty string) error { + if err := docduty.AcquireProjectDutyLock(ctx, tx, projectID); err != nil { + return err + } + prevDuty, err := docduty.LoadMemberDutyTx(ctx, tx, projectID, userID) + if err != nil { + return err + } + holders, err := docduty.LoadActiveSlotHoldersTx(ctx, tx, projectID) + if err != nil { + return err + } + + if duty != docduty.DutyNone { + if _, err := tx.Exec(ctx, ` + UPDATE project_members + SET document_duty = '', updated_at = now() + WHERE project_id = $1 AND document_duty = $2 AND user_id <> $3 AND status = $4 + `, projectID, duty, userID, StatusActive); err != nil { + return err + } + } + + if _, err := tx.Exec(ctx, ` + INSERT INTO project_members (project_id, user_id, project_role, document_duty, status, joined_at, updated_at) + VALUES ($1, $2, $3, $4, $5, now(), now()) + ON CONFLICT (project_id, user_id) DO UPDATE SET + project_role = EXCLUDED.project_role, + document_duty = EXCLUDED.document_duty, + status = EXCLUDED.status, + updated_at = now() + `, projectID, userID, projectRole, duty, StatusActive); err != nil { + return err + } + + return docduty.ApplySlotRevocations(ctx, tx, projectID, docduty.PlanSlotRevocations(docduty.MemberDutyChange{ + UserID: userID, + PreviousDuty: prevDuty, + NextDuty: duty, + PriorHolderIDs: holders, + })) } // TableExists 检查 project_members 表是否存在。 diff --git a/internal/authz/authorizer_pg_test.go b/internal/authz/authorizer_pg_test.go new file mode 100644 index 00000000..1901ec7f --- /dev/null +++ b/internal/authz/authorizer_pg_test.go @@ -0,0 +1,285 @@ +package authz + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "chat-agent-system/internal/db" + "chat-agent-system/internal/docduty" +) + +func TestUpsertProjectMemberDuty_ConcurrentLastWriter_PG(t *testing.T) { + if testing.Short() { + t.Skip("skipping PostgreSQL duty concurrency in -short") + } + dsn := db.SkipIfNoTestDatabaseURL(t) + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("postgres unavailable: %v", err) + } + defer pool.Close() + if err := pool.Ping(ctx); err != nil { + t.Fatalf("postgres ping failed: %v", err) + } + if err := db.RunMigrationsOnceForTest(ctx, pool, dsn); err != nil { + t.Fatalf("migration failed: %v", err) + } + + suffix := time.Now().UnixNano() + ownerID := insertAuthzUser(t, ctx, pool, suffix, "owner") + reviewerA := insertAuthzUser(t, ctx, pool, suffix, "ra") + reviewerB := insertAuthzUser(t, ctx, pool, suffix, "rb") + + var projectID int64 + if err := pool.QueryRow(ctx, ` + INSERT INTO projects (code, name, status, creator_user_id) + VALUES ($1, $2, 'active', $3) + RETURNING id + `, fmt.Sprintf("duty-race-%d", suffix), "concurrent reviewer", ownerID).Scan(&projectID); err != nil { + t.Fatalf("insert project: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM project_members WHERE project_id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id IN ($1,$2,$3)`, ownerID, reviewerA, reviewerB) + }) + + a := NewAuthorizer(pool) + dutyAuthor := docduty.DutyAuthor + if err := a.UpsertProjectMemberSpec(ctx, projectID, ownerID, MemberUpsert{ + ProjectRole: RoleOwner, DocumentDuty: &dutyAuthor, + }); err != nil { + t.Fatal(err) + } + if err := a.UpsertProjectMember(ctx, projectID, reviewerA, RoleMember); err != nil { + t.Fatal(err) + } + if err := a.UpsertProjectMember(ctx, projectID, reviewerB, RoleMember); err != nil { + t.Fatal(err) + } + + duty := docduty.DutyReviewer + var wg sync.WaitGroup + errCh := make(chan error, 16) + targets := []int64{reviewerA, reviewerB} + for i := 0; i < 12; i++ { + target := targets[i%2] + wg.Add(1) + go func(uid int64) { + defer wg.Done() + if err := a.UpsertProjectMemberSpec(ctx, projectID, uid, MemberUpsert{ + ProjectRole: RoleMember, DocumentDuty: &duty, + }); err != nil { + errCh <- err + } + }(target) + } + wg.Wait() + close(errCh) + for upsertErr := range errCh { + t.Fatalf("concurrent upsert failed after retry: %v", upsertErr) + } + + rows, err := pool.Query(ctx, ` + SELECT user_id FROM project_members + WHERE project_id = $1 AND status = 'active' AND document_duty = 'reviewer' + ORDER BY user_id + `, projectID) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var holders []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + t.Fatal(err) + } + holders = append(holders, id) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if len(holders) != 1 { + t.Fatalf("want exactly one active reviewer, got %v", holders) + } + if holders[0] != reviewerA && holders[0] != reviewerB { + t.Fatalf("unexpected reviewer %d", holders[0]) + } +} + +func TestUpsertProjectMember_AtomicDutyRevoke_PG(t *testing.T) { + if testing.Short() { + t.Skip("skipping PostgreSQL duty revoke atomicity in -short") + } + dsn := db.SkipIfNoTestDatabaseURL(t) + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("postgres unavailable: %v", err) + } + defer pool.Close() + if err := pool.Ping(ctx); err != nil { + t.Fatalf("postgres ping failed: %v", err) + } + if err := db.RunMigrationsOnceForTest(ctx, pool, dsn); err != nil { + t.Fatalf("migration failed: %v", err) + } + + suffix := time.Now().UnixNano() + authorID := insertAuthzUser(t, ctx, pool, suffix, "author") + reviewerA := insertAuthzUser(t, ctx, pool, suffix, "ra") + reviewerB := insertAuthzUser(t, ctx, pool, suffix, "rb") + + var projectID int64 + if err := pool.QueryRow(ctx, ` + INSERT INTO projects (code, name, status, creator_user_id) + VALUES ($1, $2, 'active', $3) + RETURNING id + `, fmt.Sprintf("duty-atomic-%d", suffix), "atomic upsert revoke", authorID).Scan(&projectID); err != nil { + t.Fatalf("insert project: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM document_records WHERE project_id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM project_members WHERE project_id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id IN ($1,$2,$3)`, authorID, reviewerA, reviewerB) + }) + + a := NewAuthorizer(pool) + dutyAuthor := docduty.DutyAuthor + dutyReviewer := docduty.DutyReviewer + dutyNone := docduty.DutyNone + if err := a.UpsertProjectMemberSpec(ctx, projectID, authorID, MemberUpsert{ + ProjectRole: RoleOwner, DocumentDuty: &dutyAuthor, + }); err != nil { + t.Fatal(err) + } + if err := a.UpsertProjectMemberSpec(ctx, projectID, reviewerA, MemberUpsert{ + ProjectRole: RoleMember, DocumentDuty: &dutyReviewer, + }); err != nil { + t.Fatal(err) + } + if err := a.UpsertProjectMemberSpec(ctx, projectID, reviewerB, MemberUpsert{ + ProjectRole: RoleMember, DocumentDuty: &dutyNone, + }); err != nil { + t.Fatal(err) + } + + docID := fmt.Sprintf("doc_proj%d_intake", projectID) + if _, err := pool.Exec(ctx, ` + INSERT INTO document_records ( + doc_id, project_id, task_key, title, stage, version, + current_state, is_locked, is_template_only, + author_id, author_name, reviewer_id, reviewer_name, approver_id, approver_name, + created_at, updated_at + ) VALUES ( + $1, $2, 'intake', 'intake doc', 'V0.5', 1, + 'drafting', false, false, + $3, '编制工程师', $4, '审核负责人', NULL, '', + now(), now() + ) + `, docID, projectID, authorID, reviewerA); err != nil { + t.Fatalf("insert doc: %v", err) + } + + if err := a.UpsertProjectMemberSpec(ctx, projectID, reviewerA, MemberUpsert{ + ProjectRole: RoleMember, DocumentDuty: &dutyNone, + }); err != nil { + t.Fatal(err) + } + assertAuthzDocSlot(t, ctx, pool, docID, "reviewer_id", 0) + assertAuthzDocSlot(t, ctx, pool, docID, "author_id", authorID) + assertAuthzMemberDuty(t, ctx, pool, projectID, reviewerA, docduty.DutyNone) + + if err := a.UpsertProjectMemberSpec(ctx, projectID, reviewerA, MemberUpsert{ + ProjectRole: RoleMember, DocumentDuty: &dutyReviewer, + }); err != nil { + t.Fatal(err) + } + if _, _, err := docduty.HealProjectDocuments(ctx, pool, projectID); err != nil { + t.Fatal(err) + } + assertAuthzDocSlot(t, ctx, pool, docID, "reviewer_id", reviewerA) + + if err := a.UpsertProjectMemberSpec(ctx, projectID, reviewerB, MemberUpsert{ + ProjectRole: RoleMember, DocumentDuty: &dutyReviewer, + }); err != nil { + t.Fatal(err) + } + assertAuthzDocSlot(t, ctx, pool, docID, "reviewer_id", 0) + assertAuthzDocSlot(t, ctx, pool, docID, "author_id", authorID) + assertAuthzMemberDuty(t, ctx, pool, projectID, reviewerA, docduty.DutyNone) + assertAuthzMemberDuty(t, ctx, pool, projectID, reviewerB, docduty.DutyReviewer) + + if _, _, err := docduty.HealProjectDocuments(ctx, pool, projectID); err != nil { + t.Fatal(err) + } + assertAuthzDocSlot(t, ctx, pool, docID, "reviewer_id", reviewerB) + assertAuthzDocSlot(t, ctx, pool, docID, "author_id", authorID) + + if err := a.UpsertProjectMemberSpec(ctx, projectID, reviewerB, MemberUpsert{ + ProjectRole: RoleMember, DocumentDuty: &dutyReviewer, + }); err != nil { + t.Fatal(err) + } + assertAuthzDocSlot(t, ctx, pool, docID, "reviewer_id", reviewerB) + + if err := a.SetProjectMemberStatus(ctx, projectID, reviewerB, StatusExited); err != nil { + t.Fatal(err) + } + assertAuthzDocSlot(t, ctx, pool, docID, "reviewer_id", 0) + assertAuthzDocSlot(t, ctx, pool, docID, "author_id", authorID) + + if err := a.SetProjectMemberStatus(ctx, projectID, reviewerB, StatusExited); err != nil { + t.Fatal(err) + } + assertAuthzDocSlot(t, ctx, pool, docID, "reviewer_id", 0) + assertAuthzDocSlot(t, ctx, pool, docID, "author_id", authorID) +} + +func assertAuthzDocSlot(t *testing.T, ctx context.Context, pool *pgxpool.Pool, docID, column string, want int64) { + t.Helper() + var got int64 + q := fmt.Sprintf(`SELECT COALESCE(%s, 0) FROM document_records WHERE doc_id = $1`, column) + if err := pool.QueryRow(ctx, q, docID).Scan(&got); err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("%s %s=%d want %d", docID, column, got, want) + } +} + +func assertAuthzMemberDuty(t *testing.T, ctx context.Context, pool *pgxpool.Pool, projectID, userID int64, want string) { + t.Helper() + var got string + if err := pool.QueryRow(ctx, ` + SELECT COALESCE(document_duty, '') FROM project_members WHERE project_id = $1 AND user_id = $2 + `, projectID, userID).Scan(&got); err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("user %d document_duty=%q want %q", userID, got, want) + } +} + +func insertAuthzUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool, suffix int64, kind string) int64 { + t.Helper() + var id int64 + if err := pool.QueryRow(ctx, ` + INSERT INTO users (account_id, name, role) + VALUES ($1, $2, 'engineer') + RETURNING id + `, fmt.Sprintf("authz_%s_%d", kind, suffix), "用户"+kind).Scan(&id); err != nil { + t.Fatalf("insert user %s: %v", kind, err) + } + return id +} diff --git a/internal/authz/service_test.go b/internal/authz/service_test.go index 476238ae..3f753d37 100644 --- a/internal/authz/service_test.go +++ b/internal/authz/service_test.go @@ -34,4 +34,19 @@ func TestUpsertProjectMember_RejectsDocRolesWithoutDB(t *testing.T) { if err := a.UpsertProjectMember(context.Background(), 1, 2, authz.RoleOwner); err != nil { t.Fatalf("owner should be allowed: %v", err) } + bad := "viewer" + if err := a.UpsertProjectMemberSpec(context.Background(), 1, 2, authz.MemberUpsert{ + ProjectRole: authz.RoleMember, + DocumentDuty: &bad, + }); err == nil { + t.Fatal("document_duty=viewer must be rejected") + } +} + +func TestGetProjectMember_DisabledWithoutPool(t *testing.T) { + a := authz.NewAuthorizer(nil) + rec, err := a.GetProjectMember(context.Background(), 1, 2) + if err != nil || rec != nil { + t.Fatalf("disabled authorizer must not invent a member, got (%v, %v)", rec, err) + } } diff --git a/internal/bizdev/doc_id_test.go b/internal/bizdev/doc_id_test.go index 68296628..f6e53f2e 100644 --- a/internal/bizdev/doc_id_test.go +++ b/internal/bizdev/doc_id_test.go @@ -2,11 +2,13 @@ package bizdev import ( "context" + "errors" "strings" "testing" "time" "chat-agent-system/internal/config" + "chat-agent-system/internal/docgov" ) func TestNormalizeDocID_LegacyPatterns(t *testing.T) { @@ -209,6 +211,34 @@ func TestExecuteTask_LegacyDocIDDoesNotFailValidation(t *testing.T) { } } +func TestExecuteTask_CreateDocumentFailureIsNotSuccess(t *testing.T) { + svc := MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"}) + svc.createDocumentHook = func(context.Context, string, int64, string, string, string, string, int64, string, int64, string, int64, string) (*docgov.DocumentRecord, error) { + return nil, errors.New("ledger unavailable") + } + res, err := svc.ExecuteTask(context.Background(), TaskRunRequest{ + TaskKey: "research", + ProjectID: 56, + ProjectName: "智能体演示项目", + CustomerName: "北京水木东方", + DeviceType: "医疗器械", + Backend: "stub", + Timeout: 10 * time.Second, + }) + if err == nil || !strings.Contains(err.Error(), "ledger unavailable") { + t.Fatalf("want ledger error, got %v", err) + } + if res == nil || res.Success { + t.Fatalf("no ledger must not report success: %+v", res) + } + if !strings.Contains(res.ErrorReason, "create document ledger failed") { + t.Fatalf("ErrorReason=%q", res.ErrorReason) + } + if res.DocRecord != nil { + t.Fatalf("failed create must not return a ledger, got %#v", res.DocRecord) + } +} + func TestExecuteTask_EmptyDocIDSkipsDocRevision(t *testing.T) { svc := MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"}) ctx := context.Background() diff --git a/internal/bizdev/document_duty_resolve_test.go b/internal/bizdev/document_duty_resolve_test.go new file mode 100644 index 00000000..e519fc78 --- /dev/null +++ b/internal/bizdev/document_duty_resolve_test.go @@ -0,0 +1,40 @@ +package bizdev + +import ( + "context" + "testing" + + "chat-agent-system/internal/docduty" +) + +func TestResolveTaskDocumentDuties_UsesCreatorNotHardcodedReviewer(t *testing.T) { + req := TaskRunRequest{ + UserID: 170, ProjectID: 6145, + AuthorName: "编制人A", + } + got := resolveTaskDocumentDuties(context.Background(), nil, req) + if got.Author.UserID != 170 { + t.Fatalf("author want 170 got %+v", got.Author) + } + if got.Reviewer.UserID == 2 || got.Approver.UserID == 3 { + t.Fatalf("must not invent hardcoded 2/3: %+v", got) + } + if got.Reviewer.UserID != 0 { + t.Fatalf("no project members → reviewer empty, got %+v", got.Reviewer) + } +} + +func TestResolveTaskDocumentDuties_IgnoresNonMemberRequestReviewer(t *testing.T) { + req := TaskRunRequest{ + UserID: 170, ReviewerID: 2, ReviewerName: "审核负责人", + } + got := resolveTaskDocumentDuties(context.Background(), nil, req) + if got.Reviewer.UserID != 0 { + t.Fatalf("request reviewer 2 is not a member: %+v", got.Reviewer) + } +} + +func TestBindingFromDocRoundTrip(t *testing.T) { + // compile-time guard that helper stays aligned with Resolve slots + _ = docduty.Binding{} +} diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index b2e5c60c..944ceafe 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "os" "path/filepath" "strings" @@ -16,6 +17,7 @@ import ( "chat-agent-system/internal/config" "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/db" + "chat-agent-system/internal/docduty" "chat-agent-system/internal/docgov" "chat-agent-system/internal/docrevision" "chat-agent-system/internal/freedoc" @@ -45,6 +47,8 @@ type Service struct { stageGate StageAccessGate // stageTaskHook 仅测试注入:覆盖 ExecuteStage 子任务执行,验证链中止边界。 stageTaskHook func(context.Context, TaskRunRequest) (*TaskRunResult, error) + // createDocumentHook 仅测试注入:覆盖账本创建失败路径。 + createDocumentHook func(ctx context.Context, docID string, projectID int64, taskKey, title, stage, workDir string, authorID int64, authorName string, reviewerID int64, reviewerName string, approverID int64, approverName string) (*docgov.DocumentRecord, error) } // NewService 构造业务服务;生产/test 环境数据库不可用且未显式 ALLOW_IN_MEMORY 时返回 error。 @@ -453,28 +457,17 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun } } - authorID := req.AuthorID - if authorID == 0 { - authorID = req.UserID - } - authorName := req.AuthorName + duties := resolveTaskDocumentDuties(ctx, s.pool, req) + authorID, authorName := duties.Author.UserID, duties.Author.Name + reviewerID, reviewerName := duties.Reviewer.UserID, duties.Reviewer.Name + approverID, approverName := duties.Approver.UserID, duties.Approver.Name if authorName == "" { authorName = "编制工程师" } - reviewerID := req.ReviewerID - if reviewerID == 0 { - reviewerID = 2 - } - reviewerName := req.ReviewerName - if reviewerName == "" { + if reviewerName == "" && reviewerID > 0 { reviewerName = "审核负责人" } - approverID := req.ApproverID - if approverID == 0 { - approverID = 3 - } - approverName := req.ApproverName - if approverName == "" { + if approverName == "" && approverID > 0 { approverName = "批准负责人" } @@ -484,11 +477,60 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun var appliedProposalID string if s.docGov != nil && strings.TrimSpace(docID) != "" { - docRec, _ = s.docGov.CreateOrGetDocument( - ctx, docID, req.ProjectID, req.TaskKey, artifactTitle, - "V0.5_feasibility", req.WorkDir, - authorID, authorName, reviewerID, reviewerName, approverID, approverName, - ) + var createErr error + if s.createDocumentHook != nil { + docRec, createErr = s.createDocumentHook( + ctx, docID, req.ProjectID, req.TaskKey, artifactTitle, + "V0.5_feasibility", req.WorkDir, + authorID, authorName, reviewerID, reviewerName, approverID, approverName, + ) + } else { + docRec, createErr = s.docGov.CreateOrGetDocument( + ctx, docID, req.ProjectID, req.TaskKey, artifactTitle, + "V0.5_feasibility", req.WorkDir, + authorID, authorName, reviewerID, reviewerName, approverID, approverName, + ) + } + if createErr != nil { + return &TaskRunResult{ + TaskKey: req.TaskKey, + ArtifactTitle: artifactTitle, + WorkDir: req.WorkDir, + Success: false, + ErrorReason: "create document ledger failed: " + createErr.Error(), + DocRecord: docRec, + }, createErr + } + if s.pool != nil && req.ProjectID > 0 { + assigned, _, healErr := docduty.HealProjectDocuments(ctx, s.pool, req.ProjectID) + if healErr != nil { + log.Printf("[docduty] heal project %d after generate failed: %v", req.ProjectID, healErr) + return &TaskRunResult{ + TaskKey: req.TaskKey, + ArtifactTitle: artifactTitle, + WorkDir: req.WorkDir, + Success: false, + ErrorReason: "document duty heal failed: " + healErr.Error(), + DocRecord: docRec, + }, healErr + } + if docRec != nil { + members, creator, loadErr := docduty.LoadProjectMembers(ctx, s.pool, req.ProjectID) + if loadErr != nil { + log.Printf("[docduty] load members project %d after generate failed: %v", req.ProjectID, loadErr) + return &TaskRunResult{ + TaskKey: req.TaskKey, + ArtifactTitle: artifactTitle, + WorkDir: req.WorkDir, + Success: false, + ErrorReason: "document duty load failed: " + loadErr.Error(), + DocRecord: docRec, + }, loadErr + } + next, _ := docduty.HealBinding(bindingFromDoc(docRec), assigned, docduty.ActiveMemberIDs(members, creator)) + writeBindingToDoc(docRec, next) + } + } if req.ForceDirectEdit { if docRec != nil && docRec.IsLocked { @@ -1297,3 +1339,44 @@ func (s *Service) InspectWorkspace(ctx context.Context, workDir string) (*squad. func (s *Service) ResumeOrModifyWorkspace(ctx context.Context, req squad.ModifyWorkflowRequest) (*squad.SquadRunResult, error) { return s.squad.ResumeOrModifyWorkspace(ctx, req) } + +func resolveTaskDocumentDuties(ctx context.Context, pool *pgxpool.Pool, req TaskRunRequest) docduty.Binding { + reqDuties := docduty.RequestDuties{ + AuthorID: req.AuthorID, AuthorName: req.AuthorName, + ReviewerID: req.ReviewerID, ReviewerName: req.ReviewerName, + ApproverID: req.ApproverID, ApproverName: req.ApproverName, + } + if reqDuties.AuthorID == 0 { + reqDuties.AuthorID = req.UserID + } + if pool != nil && req.ProjectID > 0 { + if bound, err := docduty.ResolveProject(ctx, pool, req.ProjectID, reqDuties); err == nil { + if bound.Author.UserID == 0 && req.UserID > 0 { + bound.Author = docduty.Holder{UserID: req.UserID, Name: strings.TrimSpace(req.AuthorName)} + } + return bound + } + } + creator := docduty.Holder{UserID: req.UserID, Name: strings.TrimSpace(req.AuthorName)} + return docduty.Resolve(nil, creator, reqDuties) +} + +func bindingFromDoc(doc *docgov.DocumentRecord) docduty.Binding { + if doc == nil { + return docduty.Binding{} + } + return docduty.Binding{ + Author: docduty.Holder{UserID: doc.AuthorID, Name: doc.AuthorName}, + Reviewer: docduty.Holder{UserID: doc.ReviewerID, Name: doc.ReviewerName}, + Approver: docduty.Holder{UserID: doc.ApproverID, Name: doc.ApproverName}, + } +} + +func writeBindingToDoc(doc *docgov.DocumentRecord, bound docduty.Binding) { + if doc == nil { + return + } + doc.AuthorID, doc.AuthorName = bound.Author.UserID, bound.Author.Name + doc.ReviewerID, doc.ReviewerName = bound.Reviewer.UserID, bound.Reviewer.Name + doc.ApproverID, doc.ApproverName = bound.Approver.UserID, bound.Approver.Name +} diff --git a/internal/db/testdb.go b/internal/db/testdb.go index b71f192f..4abbb032 100644 --- a/internal/db/testdb.go +++ b/internal/db/testdb.go @@ -1,11 +1,33 @@ package db import ( + "context" "os" "strings" + "sync" "testing" + + "github.com/jackc/pgx/v5/pgxpool" ) +var ( + migrateOnceMu sync.Mutex + migratedDSNs = map[string]error{} +) + +// RunMigrationsOnceForTest 同一 DSN 只迁一次,避免并行包抢同一隔离库。 +func RunMigrationsOnceForTest(ctx context.Context, pool *pgxpool.Pool, dsn string) error { + key := strings.TrimSpace(dsn) + migrateOnceMu.Lock() + defer migrateOnceMu.Unlock() + if err, ok := migratedDSNs[key]; ok { + return err + } + err := RunMigrations(ctx, pool, ResolveMigrationsDir()) + migratedDSNs[key] = err + return err +} + const testDatabaseEnv = "TEST_DATABASE_URL" // RequireTestDatabaseURL 集成测试必须显式提供隔离测试库,禁止默认连开发库。 diff --git a/internal/docduty/owner_backfill.go b/internal/docduty/owner_backfill.go new file mode 100644 index 00000000..2e5bf1de --- /dev/null +++ b/internal/docduty/owner_backfill.go @@ -0,0 +1,90 @@ +package docduty + +import "strings" + +// OwnerAuthorBackfillSQL 与 goose 00031 保持同步:每个项目只回填一行编制人。 +// 创建者优先,否则最低 user_id,避免单条 UPDATE 同时改多行撞部分唯一索引。 +const OwnerAuthorBackfillSQL = ` +WITH candidates AS ( + SELECT pm.project_id, pm.user_id, + CASE WHEN p.creator_user_id = pm.user_id THEN 0 ELSE 1 END AS creator_rank, + pm.user_id AS id_rank + FROM project_members pm + JOIN projects p ON p.id = pm.project_id + WHERE pm.status = 'active' + AND pm.document_duty = '' + AND ( + pm.project_role = 'owner' + OR p.creator_user_id = pm.user_id + ) + AND NOT EXISTS ( + SELECT 1 FROM project_members other + WHERE other.project_id = pm.project_id + AND other.status = 'active' + AND other.document_duty = 'author' + ) +), +picked AS ( + SELECT DISTINCT ON (project_id) project_id, user_id + FROM candidates + ORDER BY project_id, creator_rank ASC, id_rank ASC +) +UPDATE project_members pm +SET document_duty = 'author', updated_at = now() +FROM picked +WHERE pm.project_id = picked.project_id AND pm.user_id = picked.user_id +` + +// OwnerAuthorBackfillOneProjectSQL 与上式相同,仅限单个项目,供隔离测试使用。 +const OwnerAuthorBackfillOneProjectSQL = ` +WITH candidates AS ( + SELECT pm.project_id, pm.user_id, + CASE WHEN p.creator_user_id = pm.user_id THEN 0 ELSE 1 END AS creator_rank, + pm.user_id AS id_rank + FROM project_members pm + JOIN projects p ON p.id = pm.project_id + WHERE pm.project_id = $1 + AND pm.status = 'active' + AND pm.document_duty = '' + AND ( + pm.project_role = 'owner' + OR p.creator_user_id = pm.user_id + ) + AND NOT EXISTS ( + SELECT 1 FROM project_members other + WHERE other.project_id = pm.project_id + AND other.status = 'active' + AND other.document_duty = 'author' + ) +), +picked AS ( + SELECT DISTINCT ON (project_id) project_id, user_id + FROM candidates + ORDER BY project_id, creator_rank ASC, id_rank ASC +) +UPDATE project_members pm +SET document_duty = 'author', updated_at = now() +FROM picked +WHERE pm.project_id = picked.project_id AND pm.user_id = picked.user_id +` + +// PickDefaultAuthorUserID 多 owner/异常历史时确定性选择编制回填对象。 +// 仅考虑在任且 document_duty 为空的 owner 或创建者:创建者优先,否则最低 user_id。 +func PickDefaultAuthorUserID(members []Member, creatorID int64) int64 { + var lowestOwner int64 + for _, m := range members { + if !isActiveMember(m) || strings.TrimSpace(m.DocumentDuty) != DutyNone { + continue + } + if creatorID > 0 && m.UserID == creatorID { + return creatorID + } + if m.ProjectRole != "owner" { + continue + } + if lowestOwner == 0 || m.UserID < lowestOwner { + lowestOwner = m.UserID + } + } + return lowestOwner +} diff --git a/internal/docduty/owner_backfill_test.go b/internal/docduty/owner_backfill_test.go new file mode 100644 index 00000000..4cb06c9d --- /dev/null +++ b/internal/docduty/owner_backfill_test.go @@ -0,0 +1,35 @@ +package docduty + +import "testing" + +func TestPickDefaultAuthorUserID_CreatorPreferredOverLowestOwner(t *testing.T) { + members := []Member{ + {UserID: 3, Status: StatusActive, ProjectRole: "owner", DocumentDuty: DutyNone}, + {UserID: 10, Status: StatusActive, ProjectRole: "owner", DocumentDuty: DutyNone}, + } + if got := PickDefaultAuthorUserID(members, 10); got != 10 { + t.Fatalf("creator should win over lowest owner, got %d", got) + } +} + +func TestPickDefaultAuthorUserID_LowestIDWhenCreatorMissing(t *testing.T) { + members := []Member{ + {UserID: 8, Status: StatusActive, ProjectRole: "owner", DocumentDuty: DutyNone}, + {UserID: 3, Status: StatusActive, ProjectRole: "owner", DocumentDuty: DutyNone}, + {UserID: 1, Status: StatusActive, ProjectRole: "member", DocumentDuty: DutyNone}, + } + if got := PickDefaultAuthorUserID(members, 99); got != 3 { + t.Fatalf("lowest owner id, got %d", got) + } +} + +func TestPickDefaultAuthorUserID_SkipsExistingDutyAndExited(t *testing.T) { + members := []Member{ + {UserID: 1, Status: StatusActive, ProjectRole: "owner", DocumentDuty: DutyAuthor}, + {UserID: 2, Status: "exited", ProjectRole: "owner", DocumentDuty: DutyNone}, + {UserID: 4, Status: StatusActive, ProjectRole: "owner", DocumentDuty: DutyNone}, + } + if got := PickDefaultAuthorUserID(members, 1); got != 4 { + t.Fatalf("existing author/exited must be skipped, got %d", got) + } +} diff --git a/internal/docduty/resolve.go b/internal/docduty/resolve.go new file mode 100644 index 00000000..bdb02151 --- /dev/null +++ b/internal/docduty/resolve.go @@ -0,0 +1,182 @@ +package docduty + +import ( + "fmt" + "strings" +) + +const ( + DutyNone = "" + DutyAuthor = "author" + DutyReviewer = "reviewer" + DutyApprover = "approver" + StatusActive = "active" +) + +// Holder 某一文档职责的用户。 +type Holder struct { + UserID int64 + Name string +} + +// Binding 一份受控文档应采用的编制/审核/批准职责。 +type Binding struct { + Author Holder + Reviewer Holder + Approver Holder +} + +// Member 项目成员快照(访问角色与文档职责分离)。 +type Member struct { + UserID int64 + Name string + Status string + ProjectRole string + DocumentDuty string +} + +// RequestDuties 任务请求里客户端声称的职责;仅当该用户是在任成员时才可采用。 +type RequestDuties struct { + AuthorID int64 + AuthorName string + ReviewerID int64 + ReviewerName string + ApproverID int64 + ApproverName string +} + +// NormalizeDocumentDuty 校验 document_duty。空串表示无文档职责。 +func NormalizeDocumentDuty(raw string) (string, error) { + duty := strings.TrimSpace(strings.ToLower(raw)) + switch duty { + case DutyNone, DutyAuthor, DutyReviewer, DutyApprover: + return duty, nil + default: + return "", fmt.Errorf("docduty: invalid document_duty %q", raw) + } +} + +// ActiveMemberIDs 在任成员(含创建者)。 +func ActiveMemberIDs(members []Member, creator Holder) map[int64]struct{} { + ids := make(map[int64]struct{}, len(members)+1) + if creator.UserID > 0 { + ids[creator.UserID] = struct{}{} + } + for _, m := range members { + if m.UserID > 0 && (m.Status == "" || m.Status == StatusActive) { + ids[m.UserID] = struct{}{} + } + } + return ids +} + +func isActiveMember(m Member) bool { + return m.UserID > 0 && (m.Status == "" || m.Status == StatusActive) +} + +func dutyHolder(members []Member, duty string) Holder { + var found Holder + for _, m := range members { + if !isActiveMember(m) || m.DocumentDuty != duty { + continue + } + found = Holder{UserID: m.UserID, Name: strings.TrimSpace(m.Name)} + } + return found +} + +func pickMember(reqID int64, reqName string, memberIDs map[int64]struct{}, members []Member) Holder { + if reqID <= 0 { + return Holder{} + } + if _, ok := memberIDs[reqID]; !ok { + return Holder{} + } + name := strings.TrimSpace(reqName) + for _, m := range members { + if m.UserID == reqID && isActiveMember(m) && strings.TrimSpace(m.Name) != "" { + name = strings.TrimSpace(m.Name) + break + } + } + return Holder{UserID: reqID, Name: name} +} + +// Resolve 以项目 document_duty 为权威。禁止回落到硬编码 2/3。 +// author:指定编制人 → 创建者 → 请求 id(须为成员)。 +// reviewer/approver:指定职责 → 请求 id(须为成员)→ 空。 +func Resolve(members []Member, creator Holder, req RequestDuties) Binding { + memberIDs := ActiveMemberIDs(members, creator) + out := Binding{} + + if h := dutyHolder(members, DutyAuthor); h.UserID > 0 { + out.Author = h + } else if creator.UserID > 0 { + out.Author = creator + } else { + out.Author = pickMember(req.AuthorID, req.AuthorName, memberIDs, members) + } + + if h := dutyHolder(members, DutyReviewer); h.UserID > 0 { + out.Reviewer = h + } else { + out.Reviewer = pickMember(req.ReviewerID, req.ReviewerName, memberIDs, members) + } + + if h := dutyHolder(members, DutyApprover); h.UserID > 0 { + out.Approver = h + } else { + out.Approver = pickMember(req.ApproverID, req.ApproverName, memberIDs, members) + } + + return out +} + +// DutyMissing 当前职责缺失或指向非成员(含历史硬编码占位 id)。 +func DutyMissing(currentID int64, memberIDs map[int64]struct{}) bool { + if currentID <= 0 { + return true + } + _, ok := memberIDs[currentID] + return !ok +} + +func healSlot(current, assigned Holder, memberIDs map[int64]struct{}, clearInactive bool) (Holder, bool) { + if assigned.UserID > 0 { + if current.UserID == assigned.UserID && current.Name == assigned.Name { + return current, false + } + return assigned, true + } + if current.UserID <= 0 { + return current, false + } + if _, ok := memberIDs[current.UserID]; ok { + return current, false + } + if !clearInactive { + return current, false + } + return Holder{}, true +} + +// HealBinding 以项目 document_duty 为权威。assigned 非零则对齐(错 ID 或同 ID 旧名称)。 +// assigned 为空时:审核/批准仅清空已退出或非成员的失效 holder;在任成员保留以兼容未初始化 duty 的旧项目。 +// 编制槽位即使 assigned 为空也不清空,避免无意去掉 owner/author。 +func HealBinding(current, assigned Binding, memberIDs map[int64]struct{}) (Binding, bool) { + out := current + changed := false + if next, ok := healSlot(current.Author, assigned.Author, memberIDs, false); ok { + out.Author = next + changed = true + } + if next, ok := healSlot(current.Reviewer, assigned.Reviewer, memberIDs, true); ok { + out.Reviewer = next + changed = true + } + if next, ok := healSlot(current.Approver, assigned.Approver, memberIDs, true); ok { + out.Approver = next + changed = true + } + return out, changed +} diff --git a/internal/docduty/resolve_test.go b/internal/docduty/resolve_test.go new file mode 100644 index 00000000..8341d0eb --- /dev/null +++ b/internal/docduty/resolve_test.go @@ -0,0 +1,246 @@ +package docduty + +import ( + "sync" + "testing" +) + +func sampleMembers() ([]Member, Holder) { + creator := Holder{UserID: 170, Name: "编制人A"} + members := []Member{ + {UserID: 170, Name: "编制人A", Status: StatusActive, ProjectRole: "owner", DocumentDuty: DutyAuthor}, + {UserID: 171, Name: "审核人B", Status: StatusActive, ProjectRole: "member", DocumentDuty: DutyReviewer}, + {UserID: 3, Name: "李主管", Status: StatusActive, ProjectRole: "member", DocumentDuty: DutyNone}, + {UserID: 4, Name: "王总监", Status: StatusActive, ProjectRole: "member", DocumentDuty: DutyApprover}, + } + return members, creator +} + +func TestResolve_AAuthorBReviewerCNone(t *testing.T) { + members, creator := sampleMembers() + got := Resolve(members, creator, RequestDuties{ + AuthorID: 2, ReviewerID: 2, ApproverID: 3, + AuthorName: "编制工程师", ReviewerName: "审核负责人", ApproverName: "批准负责人", + }) + if got.Author.UserID != 170 || got.Author.Name != "编制人A" { + t.Fatalf("author: %+v", got.Author) + } + if got.Reviewer.UserID != 171 || got.Reviewer.Name != "审核人B" { + t.Fatalf("reviewer: %+v", got.Reviewer) + } + if got.Approver.UserID != 4 || got.Approver.Name != "王总监" { + t.Fatalf("approver: %+v", got.Approver) + } + if got.Author.UserID == got.Reviewer.UserID || got.Reviewer.UserID == got.Approver.UserID { + t.Fatalf("duties swapped: %+v", got) + } + if got.Reviewer.UserID == 99 || got.Author.UserID == 99 { + t.Fatal("non-member C must not receive a duty") + } +} + +func TestResolve_NeverDefaultsToHardcodedTwoOrThree(t *testing.T) { + creator := Holder{UserID: 170, Name: "编制人A"} + members := []Member{ + {UserID: 170, Name: "编制人A", Status: StatusActive, ProjectRole: "owner", DocumentDuty: DutyAuthor}, + } + got := Resolve(members, creator, RequestDuties{}) + if got.Reviewer.UserID != 0 || got.Approver.UserID != 0 { + t.Fatalf("expected empty reviewer/approver, got %+v", got) + } + if got.Reviewer.UserID == 2 || got.Approver.UserID == 3 { + t.Fatal("hardcoded 2/3 must not be invented") + } +} + +func TestResolve_RequestIDsOnlyIfMembers(t *testing.T) { + creator := Holder{UserID: 170, Name: "编制人A"} + members := []Member{ + {UserID: 170, Name: "编制人A", Status: StatusActive, ProjectRole: "owner"}, + {UserID: 171, Name: "审核人B", Status: StatusActive, ProjectRole: "member"}, + } + got := Resolve(members, creator, RequestDuties{ReviewerID: 2, ReviewerName: "审核负责人"}) + if got.Reviewer.UserID != 0 { + t.Fatalf("non-member request reviewer 2 must be ignored, got %+v", got.Reviewer) + } + got = Resolve(members, creator, RequestDuties{ReviewerID: 171, ReviewerName: "审核人B"}) + if got.Reviewer.UserID != 171 { + t.Fatalf("member request reviewer must apply: %+v", got.Reviewer) + } +} + +func TestHealBinding_PlaceholderNonMemberBackfillIdempotent(t *testing.T) { + members, creator := sampleMembers() + assigned := Resolve(members, creator, RequestDuties{}) + memberIDs := ActiveMemberIDs(members, creator) + + current := Binding{ + Author: Holder{UserID: 170, Name: "编制工程师"}, + Reviewer: Holder{UserID: 2, Name: "审核负责人"}, + Approver: Holder{UserID: 3, Name: "批准负责人"}, + } + first, changed := HealBinding(current, assigned, memberIDs) + if !changed { + t.Fatal("placeholder reviewer 2 and stale/wrong assigned slots must be healed") + } + if first.Reviewer.UserID != 171 { + t.Fatalf("reviewer want 171 got %+v", first.Reviewer) + } + if first.Author.UserID != 170 || first.Author.Name != "编制人A" { + t.Fatalf("author must stay A with assigned name: %+v", first.Author) + } + if first.Approver.UserID != 4 || first.Approver.Name != "王总监" { + t.Fatalf("assigned approver 4 must replace member 3: %+v", first.Approver) + } + + second, changedAgain := HealBinding(first, assigned, memberIDs) + if changedAgain { + t.Fatalf("heal must be idempotent, got %+v", second) + } + if second != first { + t.Fatalf("second pass changed binding: %+v vs %+v", first, second) + } +} + +func TestHealBinding_WrongActiveMemberIDAlignsToAssigned(t *testing.T) { + members, creator := sampleMembers() + assigned := Resolve(members, creator, RequestDuties{}) + memberIDs := ActiveMemberIDs(members, creator) + current := Binding{ + Author: Holder{UserID: 170, Name: "编制人A"}, + Reviewer: Holder{UserID: 3, Name: "李主管"}, + Approver: Holder{UserID: 3, Name: "李主管"}, + } + got, changed := HealBinding(current, assigned, memberIDs) + if !changed { + t.Fatal("wrong active-member IDs must align to assigned duties") + } + if got.Reviewer.UserID != 171 || got.Approver.UserID != 4 { + t.Fatalf("want reviewer=171 approver=4, got %+v", got) + } + if got.Author.UserID != 170 { + t.Fatalf("author must stay assigned A: %+v", got.Author) + } +} + +func TestHealBinding_SameIDStaleNameAligns(t *testing.T) { + members, creator := sampleMembers() + assigned := Resolve(members, creator, RequestDuties{}) + memberIDs := ActiveMemberIDs(members, creator) + current := Binding{ + Author: Holder{UserID: 170, Name: "编制工程师"}, + Reviewer: Holder{UserID: 171, Name: "审核负责人"}, + Approver: Holder{UserID: 4, Name: "批准负责人"}, + } + got, changed := HealBinding(current, assigned, memberIDs) + if !changed { + t.Fatal("same IDs with stale names must be healed") + } + if got.Author.Name != "编制人A" || got.Reviewer.Name != "审核人B" || got.Approver.Name != "王总监" { + t.Fatalf("stale names not aligned: %+v", got) + } + if got.Author.UserID != 170 || got.Reviewer.UserID != 171 || got.Approver.UserID != 4 { + t.Fatalf("IDs must stay assigned: %+v", got) + } +} + +func TestHealBinding_EmptyAssignedKeepsActiveMember(t *testing.T) { + memberIDs := map[int64]struct{}{170: {}, 3: {}} + current := Binding{ + Author: Holder{UserID: 170, Name: "编制人A"}, + Reviewer: Holder{UserID: 3, Name: "李主管"}, + Approver: Holder{UserID: 3, Name: "李主管"}, + } + got, changed := HealBinding(current, Binding{}, memberIDs) + if changed { + t.Fatalf("empty assigned must not invent or clear active members: %+v", got) + } + if got != current { + t.Fatalf("empty assigned must keep current: %+v", got) + } +} + +func TestHealBinding_EmptyAssignedClearsInactiveHolder(t *testing.T) { + memberIDs := map[int64]struct{}{170: {}} + current := Binding{ + Author: Holder{UserID: 170, Name: "编制人A"}, + Reviewer: Holder{UserID: 171, Name: "已退出审核人"}, + Approver: Holder{UserID: 2, Name: "审核负责人"}, + } + got, changed := HealBinding(current, Binding{}, memberIDs) + if !changed { + t.Fatal("inactive reviewer/approver must be cleared when assigned is empty") + } + if got.Reviewer.UserID != 0 || got.Approver.UserID != 0 { + t.Fatalf("want cleared review/approve slots, got %+v", got) + } + if got.Author.UserID != 170 { + t.Fatalf("author must stay: %+v", got.Author) + } +} + +func TestHealBinding_EmptyAssignedDoesNotClearAuthor(t *testing.T) { + memberIDs := map[int64]struct{}{} + current := Binding{Author: Holder{UserID: 170, Name: "编制人A"}} + got, changed := HealBinding(current, Binding{}, memberIDs) + if changed || got.Author.UserID != 170 { + t.Fatalf("author must not be cleared when assigned is empty: changed=%v got=%+v", changed, got) + } +} + +func TestHealBinding_ZeroDutyBackfill(t *testing.T) { + members, creator := sampleMembers() + assigned := Resolve(members, creator, RequestDuties{}) + memberIDs := ActiveMemberIDs(members, creator) + got, changed := HealBinding(Binding{}, assigned, memberIDs) + if !changed || got.Reviewer.UserID != 171 || got.Author.UserID != 170 { + t.Fatalf("zero duties: changed=%v got=%+v", changed, got) + } +} + +func TestResolveAndHeal_SecondDocumentInherits(t *testing.T) { + members, creator := sampleMembers() + first := Resolve(members, creator, RequestDuties{}) + second := Resolve(members, creator, RequestDuties{}) + if first.Reviewer.UserID != 171 || second.Reviewer.UserID != 171 { + t.Fatalf("second document must inherit reviewer B: first=%+v second=%+v", first, second) + } + if first.Author.UserID != second.Author.UserID || first.Approver.UserID != second.Approver.UserID { + t.Fatalf("second document duties diverged: %+v vs %+v", first, second) + } +} + +func TestResolve_ConcurrentIdempotent(t *testing.T) { + members, creator := sampleMembers() + var wg sync.WaitGroup + errCh := make(chan string, 32) + for i := 0; i < 32; i++ { + wg.Add(1) + go func() { + defer wg.Done() + got := Resolve(members, creator, RequestDuties{ReviewerID: 2}) + if got.Reviewer.UserID != 171 || got.Author.UserID != 170 { + errCh <- "resolve mismatch" + } + memberIDs := ActiveMemberIDs(members, creator) + healed, _ := HealBinding(Binding{Reviewer: Holder{UserID: 2, Name: "审核负责人"}}, got, memberIDs) + if healed.Reviewer.UserID != 171 { + errCh <- "heal mismatch" + } + }() + } + wg.Wait() + close(errCh) + for msg := range errCh { + t.Fatal(msg) + } +} + +func TestNormalizeDocumentDuty(t *testing.T) { + if _, err := NormalizeDocumentDuty("reviewer"); err != nil { + t.Fatal(err) + } + if _, err := NormalizeDocumentDuty("viewer"); err == nil { + t.Fatal("viewer is not a document duty") + } +} diff --git a/internal/docduty/revoke.go b/internal/docduty/revoke.go new file mode 100644 index 00000000..641cf084 --- /dev/null +++ b/internal/docduty/revoke.go @@ -0,0 +1,167 @@ +package docduty + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" +) + +// MemberDutyChange 成员职责/在任状态变更,供显式撤销文档槽位。 +type MemberDutyChange struct { + UserID int64 + PreviousDuty string + NextDuty string + Exited bool + PriorHolderIDs map[string]int64 +} + +// SlotRevocation 从受控文档上撤销某用户的审核/批准槽位;永不针对 author。 +type SlotRevocation struct { + UserID int64 + Duty string +} + +// PriorDutyHolders 更新前在任成员的职责持有者。 +func PriorDutyHolders(members []Member) map[string]int64 { + out := make(map[string]int64, 3) + for _, m := range members { + if !isActiveMember(m) { + continue + } + switch m.DocumentDuty { + case DutyAuthor, DutyReviewer, DutyApprover: + out[m.DocumentDuty] = m.UserID + } + } + return out +} + +// PlanSlotRevocations 按更新前 duty 显式计算要撤销的审核/批准槽位。 +// reviewer/approver → viewer/其他职责、职责转移、退出成员都会产生撤销;author 永不进入结果。 +func PlanSlotRevocations(change MemberDutyChange) []SlotRevocation { + seen := make(map[string]struct{}, 4) + out := make([]SlotRevocation, 0, 2) + add := func(userID int64, duty string) { + if userID <= 0 || (duty != DutyReviewer && duty != DutyApprover) { + return + } + key := fmt.Sprintf("%d:%s", userID, duty) + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + out = append(out, SlotRevocation{UserID: userID, Duty: duty}) + } + if change.Exited || change.PreviousDuty != change.NextDuty { + add(change.UserID, change.PreviousDuty) + } + if change.NextDuty == DutyReviewer || change.NextDuty == DutyApprover { + if prior := change.PriorHolderIDs[change.NextDuty]; prior > 0 && prior != change.UserID { + add(prior, change.NextDuty) + } + } + return out +} + +// LoadMemberDutyTx 在调用方事务内读取该成员当前 document_duty;无行则空串。 +func LoadMemberDutyTx(ctx context.Context, tx pgx.Tx, projectID, userID int64) (string, error) { + var duty string + err := tx.QueryRow(ctx, ` + SELECT COALESCE(document_duty, '') + FROM project_members + WHERE project_id = $1 AND user_id = $2 + `, projectID, userID).Scan(&duty) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("docduty: load member duty: %w", err) + } + return duty, nil +} + +// LoadActiveSlotHoldersTx 在调用方事务内读取在任 reviewer/approver(不含 author)。 +func LoadActiveSlotHoldersTx(ctx context.Context, tx pgx.Tx, projectID int64) (map[string]int64, error) { + rows, err := tx.Query(ctx, ` + SELECT user_id, document_duty + FROM project_members + WHERE project_id = $1 AND status = $2 AND document_duty IN ($3, $4) + `, projectID, StatusActive, DutyReviewer, DutyApprover) + if err != nil { + return nil, fmt.Errorf("docduty: load slot holders: %w", err) + } + defer rows.Close() + out := make(map[string]int64, 2) + for rows.Next() { + var userID int64 + var duty string + if err := rows.Scan(&userID, &duty); err != nil { + return nil, fmt.Errorf("docduty: scan slot holder: %w", err) + } + out[duty] = userID + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("docduty: slot holders: %w", err) + } + return out, nil +} + +// projectDutyLockSQL 成员写与 HealProjectDocuments 共用同一键:pg_advisory_xact_lock(project_id)。 +// 必须先于成员/文档读写;禁止改成两段 int 锁或其它键。 +const projectDutyLockSQL = `SELECT pg_advisory_xact_lock($1)` + +// projectDutyLockHook 仅测试使用:在申请锁之前同步,便于证明 heal 会阻塞在成员写事务之后。 +var projectDutyLockHook func() + +// AcquireProjectDutyLock 获取项目职责互斥锁;调用方须已 Begin,锁随事务结束释放。 +func AcquireProjectDutyLock(ctx context.Context, tx pgx.Tx, projectID int64) error { + if tx == nil || projectID <= 0 { + return fmt.Errorf("docduty: invalid project lock") + } + if hook := projectDutyLockHook; hook != nil { + hook() + } + if _, err := tx.Exec(ctx, projectDutyLockSQL, projectID); err != nil { + return fmt.Errorf("docduty: lock project %d: %w", projectID, err) + } + return nil +} + +// ApplySlotRevocations 在调用方已持有的事务内精确清空 reviewer/approver 槽。 +// 不开启新事务、不加锁、不 heal;author 列永不写入。 +func ApplySlotRevocations(ctx context.Context, tx pgx.Tx, projectID int64, revs []SlotRevocation) error { + if tx == nil || projectID <= 0 { + return nil + } + for _, rev := range revs { + q, ok := clearDutySlotSQL(rev.Duty) + if !ok { + continue + } + if _, err := tx.Exec(ctx, q, projectID, rev.UserID); err != nil { + return fmt.Errorf("docduty: clear %s for user %d: %w", rev.Duty, rev.UserID, err) + } + } + return nil +} + +func clearDutySlotSQL(duty string) (string, bool) { + switch duty { + case DutyReviewer: + return ` + UPDATE document_records + SET reviewer_id = NULL, reviewer_name = '', updated_at = now() + WHERE project_id = $1 AND COALESCE(task_key, '') <> '' AND reviewer_id = $2 + `, true + case DutyApprover: + return ` + UPDATE document_records + SET approver_id = NULL, approver_name = '', updated_at = now() + WHERE project_id = $1 AND COALESCE(task_key, '') <> '' AND approver_id = $2 + `, true + default: + return "", false + } +} diff --git a/internal/docduty/revoke_test.go b/internal/docduty/revoke_test.go new file mode 100644 index 00000000..916fffeb --- /dev/null +++ b/internal/docduty/revoke_test.go @@ -0,0 +1,62 @@ +package docduty + +import "testing" + +func TestPlanSlotRevocations_ReviewerToViewer(t *testing.T) { + got := PlanSlotRevocations(MemberDutyChange{ + UserID: 171, PreviousDuty: DutyReviewer, NextDuty: DutyNone, + }) + if len(got) != 1 || got[0].UserID != 171 || got[0].Duty != DutyReviewer { + t.Fatalf("reviewer→viewer: %+v", got) + } +} + +func TestPlanSlotRevocations_DutyTransfer(t *testing.T) { + got := PlanSlotRevocations(MemberDutyChange{ + UserID: 99, PreviousDuty: DutyNone, NextDuty: DutyReviewer, + PriorHolderIDs: map[string]int64{DutyReviewer: 171}, + }) + if len(got) != 1 || got[0].UserID != 171 || got[0].Duty != DutyReviewer { + t.Fatalf("transfer must revoke prior reviewer: %+v", got) + } +} + +func TestPlanSlotRevocations_ExitedMember(t *testing.T) { + got := PlanSlotRevocations(MemberDutyChange{ + UserID: 171, PreviousDuty: DutyReviewer, NextDuty: DutyReviewer, Exited: true, + }) + if len(got) != 1 || got[0].Duty != DutyReviewer { + t.Fatalf("exited reviewer must revoke: %+v", got) + } +} + +func TestPlanSlotRevocations_IdempotentEmpty(t *testing.T) { + got := PlanSlotRevocations(MemberDutyChange{UserID: 171, PreviousDuty: DutyNone, NextDuty: DutyNone}) + if len(got) != 0 { + t.Fatalf("repeat empty change must be no-op: %+v", got) + } +} + +func TestPlanSlotRevocations_NeverRevokesAuthor(t *testing.T) { + got := PlanSlotRevocations(MemberDutyChange{ + UserID: 170, PreviousDuty: DutyAuthor, NextDuty: DutyNone, + PriorHolderIDs: map[string]int64{DutyAuthor: 170}, + }) + if len(got) != 0 { + t.Fatalf("author must not be revoked: %+v", got) + } +} + +func TestPriorDutyHolders_IgnoresExited(t *testing.T) { + holders := PriorDutyHolders([]Member{ + {UserID: 170, Status: StatusActive, DocumentDuty: DutyAuthor}, + {UserID: 171, Status: "exited", DocumentDuty: DutyReviewer}, + {UserID: 4, Status: StatusActive, DocumentDuty: DutyApprover}, + }) + if holders[DutyAuthor] != 170 || holders[DutyApprover] != 4 { + t.Fatalf("holders=%v", holders) + } + if _, ok := holders[DutyReviewer]; ok { + t.Fatal("exited reviewer must not be a prior holder") + } +} diff --git a/internal/docduty/store.go b/internal/docduty/store.go new file mode 100644 index 00000000..ec45112d --- /dev/null +++ b/internal/docduty/store.go @@ -0,0 +1,206 @@ +package docduty + +import ( + "context" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +// dutyDB 覆盖 pool 与 tx,供同一套解析/回填 SQL 复用。 +type dutyDB interface { + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row + Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error) +} + +// LoadProjectMembers 读取项目在任/历史成员与创建者,供职责解析。 +func LoadProjectMembers(ctx context.Context, pool *pgxpool.Pool, projectID int64) ([]Member, Holder, error) { + if pool == nil || projectID <= 0 { + return nil, Holder{}, nil + } + return loadProjectMembers(ctx, pool, projectID) +} + +func loadProjectMembers(ctx context.Context, db dutyDB, projectID int64) ([]Member, Holder, error) { + var creator Holder + err := db.QueryRow(ctx, ` + SELECT COALESCE(p.creator_user_id, 0), COALESCE(u.name, '') + FROM projects p + LEFT JOIN users u ON u.id = p.creator_user_id + WHERE p.id = $1 + `, projectID).Scan(&creator.UserID, &creator.Name) + if err != nil && err != pgx.ErrNoRows { + return nil, Holder{}, fmt.Errorf("docduty: load creator: %w", err) + } + creator.Name = strings.TrimSpace(creator.Name) + + rows, err := db.Query(ctx, ` + SELECT pm.user_id, COALESCE(u.name, ''), COALESCE(pm.status, ''), + COALESCE(pm.project_role, ''), COALESCE(pm.document_duty, '') + FROM project_members pm + JOIN users u ON u.id = pm.user_id + WHERE pm.project_id = $1 + ORDER BY pm.user_id ASC + `, projectID) + if err != nil { + return nil, creator, fmt.Errorf("docduty: load members: %w", err) + } + defer rows.Close() + + members := make([]Member, 0) + for rows.Next() { + var m Member + if err := rows.Scan(&m.UserID, &m.Name, &m.Status, &m.ProjectRole, &m.DocumentDuty); err != nil { + return nil, creator, err + } + m.Name = strings.TrimSpace(m.Name) + members = append(members, m) + } + return members, creator, rows.Err() +} + +// ResolveProject 从库表解析项目默认文档职责。 +func ResolveProject(ctx context.Context, pool *pgxpool.Pool, projectID int64, req RequestDuties) (Binding, error) { + members, creator, err := LoadProjectMembers(ctx, pool, projectID) + if err != nil { + return Binding{}, err + } + return Resolve(members, creator, req), nil +} + +func resolveProjectTx(ctx context.Context, tx pgx.Tx, projectID int64, req RequestDuties) (Binding, error) { + members, creator, err := loadProjectMembers(ctx, tx, projectID) + if err != nil { + return Binding{}, err + } + return Resolve(members, creator, req), nil +} + +type storedDocDuties struct { + DocID string + Author Holder + Reviewer Holder + Approver Holder +} + +// BackfillMissingDocumentDuties 按项目 assigned 职责对齐受控文档;assigned 为空的槽位不臆造、不清空。 +func BackfillMissingDocumentDuties(ctx context.Context, pool *pgxpool.Pool, projectID int64, assigned Binding) (int, error) { + if pool == nil || projectID <= 0 { + return 0, nil + } + return backfillMissingDocumentDuties(ctx, pool, projectID, assigned) +} + +func backfillMissingDocumentDuties(ctx context.Context, db dutyDB, projectID int64, assigned Binding) (int, error) { + members, creator, err := loadProjectMembers(ctx, db, projectID) + if err != nil { + return 0, err + } + memberIDs := ActiveMemberIDs(members, creator) + + rows, err := db.Query(ctx, ` + SELECT doc_id, + COALESCE(author_id, 0), COALESCE(author_name, ''), + COALESCE(reviewer_id, 0), COALESCE(reviewer_name, ''), + COALESCE(approver_id, 0), COALESCE(approver_name, '') + FROM document_records + WHERE project_id = $1 AND COALESCE(task_key, '') <> '' + `, projectID) + if err != nil { + return 0, fmt.Errorf("docduty: list documents: %w", err) + } + defer rows.Close() + + pending := make([]storedDocDuties, 0) + for rows.Next() { + var rec storedDocDuties + if err := rows.Scan( + &rec.DocID, + &rec.Author.UserID, &rec.Author.Name, + &rec.Reviewer.UserID, &rec.Reviewer.Name, + &rec.Approver.UserID, &rec.Approver.Name, + ); err != nil { + return 0, err + } + current := Binding{Author: rec.Author, Reviewer: rec.Reviewer, Approver: rec.Approver} + next, changed := HealBinding(current, assigned, memberIDs) + if !changed { + continue + } + rec.Author, rec.Reviewer, rec.Approver = next.Author, next.Reviewer, next.Approver + pending = append(pending, rec) + } + if err := rows.Err(); err != nil { + return 0, err + } + + updated := 0 + for _, rec := range pending { + tag, err := db.Exec(ctx, ` + UPDATE document_records + SET author_id = $2, author_name = $3, + reviewer_id = $4, reviewer_name = $5, + approver_id = $6, approver_name = $7, + updated_at = now() + WHERE doc_id = $1 AND project_id = $8 + `, rec.DocID, nullIfZero(rec.Author.UserID), rec.Author.Name, + nullIfZero(rec.Reviewer.UserID), rec.Reviewer.Name, + nullIfZero(rec.Approver.UserID), rec.Approver.Name, + projectID) + if err != nil { + return updated, fmt.Errorf("docduty: backfill %s: %w", rec.DocID, err) + } + if tag.RowsAffected() > 0 { + updated++ + } + } + return updated, nil +} + +func nullIfZero(id int64) any { + if id <= 0 { + return nil + } + return id +} + +// HealProjectDocuments 在同一事务内:项目 advisory lock、解析成员职责、读写 document_records。 +// 锁键/顺序与成员写事务一致。第二次调用应为 0 行。错误回滚,不得被调用方静默吞掉。 +func HealProjectDocuments(ctx context.Context, pool *pgxpool.Pool, projectID int64) (Binding, int, error) { + if pool == nil || projectID <= 0 { + return Binding{}, 0, nil + } + tx, err := pool.Begin(ctx) + if err != nil { + return Binding{}, 0, fmt.Errorf("docduty: begin heal: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + if err := AcquireProjectDutyLock(ctx, tx, projectID); err != nil { + return Binding{}, 0, err + } + assigned, n, err := healProjectDocumentsTx(ctx, tx, projectID) + if err != nil { + return Binding{}, 0, err + } + if err := tx.Commit(ctx); err != nil { + return Binding{}, 0, fmt.Errorf("docduty: commit heal: %w", err) + } + return assigned, n, nil +} + +func healProjectDocumentsTx(ctx context.Context, tx pgx.Tx, projectID int64) (Binding, int, error) { + assigned, err := resolveProjectTx(ctx, tx, projectID, RequestDuties{}) + if err != nil { + return Binding{}, 0, err + } + n, err := backfillMissingDocumentDuties(ctx, tx, projectID, assigned) + if err != nil { + return Binding{}, 0, err + } + return assigned, n, nil +} diff --git a/internal/docduty/store_pg_test.go b/internal/docduty/store_pg_test.go new file mode 100644 index 00000000..8396ad27 --- /dev/null +++ b/internal/docduty/store_pg_test.go @@ -0,0 +1,569 @@ +package docduty + +import ( + "context" + "fmt" + "runtime" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "chat-agent-system/internal/db" +) + +func openDutyTestPool(t *testing.T) (context.Context, context.CancelFunc, *pgxpool.Pool) { + t.Helper() + if testing.Short() { + t.Skip("skipping PostgreSQL duty sync integration in -short") + } + dsn := db.SkipIfNoTestDatabaseURL(t) + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + cancel() + t.Fatalf("postgres unavailable: %v", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + cancel() + t.Fatalf("postgres ping failed: %v", err) + } + if err := db.RunMigrationsOnceForTest(ctx, pool, dsn); err != nil { + pool.Close() + cancel() + t.Fatalf("migration failed: %v", err) + } + t.Cleanup(func() { + pool.Close() + cancel() + }) + return ctx, cancel, pool +} + +func TestProjectReviewerDutySync_PG(t *testing.T) { + ctx, _, pool := openDutyTestPool(t) + + suffix := time.Now().UnixNano() + authorID := insertDutyUser(t, ctx, pool, suffix, "author") + reviewerID := insertDutyUser(t, ctx, pool, suffix, "reviewer") + outsiderID := insertDutyUser(t, ctx, pool, suffix, "none") + placeholderID := insertDutyUser(t, ctx, pool, suffix, "placeholder") + wrongApproverID := insertDutyUser(t, ctx, pool, suffix, "wrong_appr") + assignedApproverID := insertDutyUser(t, ctx, pool, suffix, "assigned_appr") + + var projectID int64 + if err := pool.QueryRow(ctx, ` + INSERT INTO projects (code, name, status, creator_user_id) + VALUES ($1, $2, 'active', $3) + RETURNING id + `, fmt.Sprintf("duty-sync-%d", suffix), "reviewer duty sync", authorID).Scan(&projectID); err != nil { + t.Fatalf("insert project: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM document_records WHERE project_id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM project_members WHERE project_id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id IN ($1,$2,$3,$4,$5,$6)`, authorID, reviewerID, outsiderID, placeholderID, wrongApproverID, assignedApproverID) + }) + + if _, err := pool.Exec(ctx, ` + INSERT INTO project_members (project_id, user_id, project_role, document_duty, status, joined_at, updated_at) + VALUES ($1, $2, 'owner', 'author', 'active', now(), now()), + ($1, $3, 'member', 'reviewer', 'active', now(), now()), + ($1, $4, 'member', '', 'active', now(), now()), + ($1, $5, 'member', 'approver', 'active', now(), now()) + `, projectID, authorID, reviewerID, wrongApproverID, assignedApproverID); err != nil { + t.Fatalf("insert members: %v", err) + } + + bound, err := ResolveProject(ctx, pool, projectID, RequestDuties{ReviewerID: 2, ReviewerName: "审核负责人"}) + if err != nil { + t.Fatal(err) + } + if bound.Author.UserID != authorID || bound.Reviewer.UserID != reviewerID { + t.Fatalf("resolve A/B: %+v", bound) + } + if bound.Reviewer.UserID == outsiderID || bound.Author.UserID == outsiderID { + t.Fatal("C none must not receive duty") + } + + intakeID := fmt.Sprintf("doc_proj%d_intake", projectID) + secondID := fmt.Sprintf("doc_proj%d_prelim_design", projectID) + insertDutyDoc(t, ctx, pool, projectID, intakeID, "intake", authorID, placeholderID, wrongApproverID) + insertDutyDoc(t, ctx, pool, projectID, secondID, "prelim_design", authorID, placeholderID, wrongApproverID) + + assigned, n, err := HealProjectDocuments(ctx, pool, projectID) + if err != nil { + t.Fatal(err) + } + if n == 0 { + t.Fatal("expected non-member placeholder reviewer to be backfilled") + } + if assigned.Reviewer.UserID != reviewerID { + t.Fatalf("assigned reviewer=%d", assigned.Reviewer.UserID) + } + assertDocReviewer(t, ctx, pool, intakeID, reviewerID) + assertDocReviewer(t, ctx, pool, secondID, reviewerID) + assertDocApprover(t, ctx, pool, intakeID, assignedApproverID) + assertDocApprover(t, ctx, pool, secondID, assignedApproverID) + assertDocAuthorName(t, ctx, pool, intakeID, "用户author") + + _, n2, err := HealProjectDocuments(ctx, pool, projectID) + if err != nil { + t.Fatal(err) + } + if n2 != 0 { + t.Fatalf("heal must be idempotent, second pass updated %d rows", n2) + } + assertDocReviewer(t, ctx, pool, secondID, reviewerID) + assertDocApprover(t, ctx, pool, secondID, assignedApproverID) +} + +func TestHealBinding_EmptyAssignedKeepsMember_PG(t *testing.T) { + ctx, _, pool := openDutyTestPool(t) + suffix := time.Now().UnixNano() + authorID := insertDutyUser(t, ctx, pool, suffix, "keep_author") + memberID := insertDutyUser(t, ctx, pool, suffix, "keep_member") + + var projectID int64 + if err := pool.QueryRow(ctx, ` + INSERT INTO projects (code, name, status, creator_user_id) + VALUES ($1, $2, 'active', $3) + RETURNING id + `, fmt.Sprintf("duty-keep-%d", suffix), "empty assigned keep", authorID).Scan(&projectID); err != nil { + t.Fatalf("insert project: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM document_records WHERE project_id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM project_members WHERE project_id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id IN ($1,$2)`, authorID, memberID) + }) + if _, err := pool.Exec(ctx, ` + INSERT INTO project_members (project_id, user_id, project_role, document_duty, status, joined_at, updated_at) + VALUES ($1, $2, 'owner', 'author', 'active', now(), now()), + ($1, $3, 'member', '', 'active', now(), now()) + `, projectID, authorID, memberID); err != nil { + t.Fatalf("insert members: %v", err) + } + docID := fmt.Sprintf("doc_proj%d_intake", projectID) + insertDutyDoc(t, ctx, pool, projectID, docID, "intake", authorID, memberID, memberID) + if _, err := pool.Exec(ctx, `UPDATE document_records SET author_name = $2 WHERE doc_id = $1`, docID, "用户keep_author"); err != nil { + t.Fatal(err) + } + + _, n, err := HealProjectDocuments(ctx, pool, projectID) + if err != nil { + t.Fatal(err) + } + if n != 0 { + t.Fatalf("empty assigned reviewer/approver must keep existing member, updated %d", n) + } + assertDocReviewer(t, ctx, pool, docID, memberID) + assertDocApprover(t, ctx, pool, docID, memberID) +} + +func TestHealBinding_EmptyAssignedClearsExited_PG(t *testing.T) { + ctx, _, pool := openDutyTestPool(t) + suffix := time.Now().UnixNano() + authorID := insertDutyUser(t, ctx, pool, suffix, "ex_author") + exitedID := insertDutyUser(t, ctx, pool, suffix, "ex_reviewer") + + var projectID int64 + if err := pool.QueryRow(ctx, ` + INSERT INTO projects (code, name, status, creator_user_id) + VALUES ($1, $2, 'active', $3) + RETURNING id + `, fmt.Sprintf("duty-exited-%d", suffix), "clear exited", authorID).Scan(&projectID); err != nil { + t.Fatalf("insert project: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM document_records WHERE project_id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM project_members WHERE project_id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id IN ($1,$2)`, authorID, exitedID) + }) + if _, err := pool.Exec(ctx, ` + INSERT INTO project_members (project_id, user_id, project_role, document_duty, status, joined_at, updated_at) + VALUES ($1, $2, 'owner', 'author', 'active', now(), now()), + ($1, $3, 'member', '', 'exited', now(), now()) + `, projectID, authorID, exitedID); err != nil { + t.Fatalf("insert members: %v", err) + } + docID := fmt.Sprintf("doc_proj%d_intake", projectID) + insertDutyDoc(t, ctx, pool, projectID, docID, "intake", authorID, exitedID, 0) + if _, err := pool.Exec(ctx, `UPDATE document_records SET author_name = $2 WHERE doc_id = $1`, docID, "用户ex_author"); err != nil { + t.Fatal(err) + } + + _, n, err := HealProjectDocuments(ctx, pool, projectID) + if err != nil { + t.Fatal(err) + } + if n == 0 { + t.Fatal("exited reviewer must be cleared when assigned is empty") + } + assertDocReviewer(t, ctx, pool, docID, 0) + assertDocAuthorName(t, ctx, pool, docID, "用户ex_author") +} + +func TestApplySlotRevocations_SameTxnCommitAndRollback_PG(t *testing.T) { + ctx, _, pool := openDutyTestPool(t) + suffix := time.Now().UnixNano() + authorID := insertDutyUser(t, ctx, pool, suffix, "txn_author") + reviewerA := insertDutyUser(t, ctx, pool, suffix, "txn_ra") + + var projectID int64 + if err := pool.QueryRow(ctx, ` + INSERT INTO projects (code, name, status, creator_user_id) + VALUES ($1, $2, 'active', $3) + RETURNING id + `, fmt.Sprintf("duty-txn-%d", suffix), "atomic revoke txn", authorID).Scan(&projectID); err != nil { + t.Fatalf("insert project: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM document_records WHERE project_id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM project_members WHERE project_id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id IN ($1,$2)`, authorID, reviewerA) + }) + if _, err := pool.Exec(ctx, ` + INSERT INTO project_members (project_id, user_id, project_role, document_duty, status, joined_at, updated_at) + VALUES ($1, $2, 'owner', 'author', 'active', now(), now()), + ($1, $3, 'member', 'reviewer', 'active', now(), now()) + `, projectID, authorID, reviewerA); err != nil { + t.Fatalf("insert members: %v", err) + } + docID := fmt.Sprintf("doc_proj%d_intake", projectID) + insertDutyDoc(t, ctx, pool, projectID, docID, "intake", authorID, reviewerA, 0) + + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + if _, err := tx.Exec(ctx, ` + UPDATE project_members SET document_duty = '' WHERE project_id = $1 AND user_id = $2 + `, projectID, reviewerA); err != nil { + _ = tx.Rollback(ctx) + t.Fatal(err) + } + if err := ApplySlotRevocations(ctx, tx, projectID, PlanSlotRevocations(MemberDutyChange{ + UserID: reviewerA, PreviousDuty: DutyReviewer, NextDuty: DutyNone, + })); err != nil { + _ = tx.Rollback(ctx) + t.Fatal(err) + } + if err := tx.Rollback(ctx); err != nil { + t.Fatal(err) + } + assertDocReviewer(t, ctx, pool, docID, reviewerA) + assertMemberDuty(t, ctx, pool, projectID, reviewerA, DutyReviewer) + assertDocAuthorID(t, ctx, pool, docID, authorID) + + tx, err = pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + if err := AcquireProjectDutyLock(ctx, tx, projectID); err != nil { + _ = tx.Rollback(ctx) + t.Fatal(err) + } + prev, err := LoadMemberDutyTx(ctx, tx, projectID, reviewerA) + if err != nil { + _ = tx.Rollback(ctx) + t.Fatal(err) + } + if prev != DutyReviewer { + _ = tx.Rollback(ctx) + t.Fatalf("locked previous duty=%q", prev) + } + if _, err := tx.Exec(ctx, ` + UPDATE project_members SET document_duty = '' WHERE project_id = $1 AND user_id = $2 + `, projectID, reviewerA); err != nil { + _ = tx.Rollback(ctx) + t.Fatal(err) + } + if err := ApplySlotRevocations(ctx, tx, projectID, PlanSlotRevocations(MemberDutyChange{ + UserID: reviewerA, PreviousDuty: prev, NextDuty: DutyNone, + })); err != nil { + _ = tx.Rollback(ctx) + t.Fatal(err) + } + if err := tx.Commit(ctx); err != nil { + t.Fatal(err) + } + assertDocReviewer(t, ctx, pool, docID, 0) + assertMemberDuty(t, ctx, pool, projectID, reviewerA, DutyNone) + assertDocAuthorID(t, ctx, pool, docID, authorID) +} + +func TestOwnerAuthorBackfill_MultiOwnerDeterministic_PG(t *testing.T) { + ctx, _, pool := openDutyTestPool(t) + suffix := time.Now().UnixNano() + lowOwner := insertDutyUser(t, ctx, pool, suffix, "low_owner") + creatorOwner := insertDutyUser(t, ctx, pool, suffix, "creator_owner") + if lowOwner >= creatorOwner { + t.Fatalf("expected inserted low owner id < creator, got %d >= %d", lowOwner, creatorOwner) + } + + var projectID int64 + if err := pool.QueryRow(ctx, ` + INSERT INTO projects (code, name, status, creator_user_id) + VALUES ($1, $2, 'active', $3) + RETURNING id + `, fmt.Sprintf("duty-owners-%d", suffix), "multi owner backfill", creatorOwner).Scan(&projectID); err != nil { + t.Fatalf("insert project: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM project_members WHERE project_id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id IN ($1,$2)`, lowOwner, creatorOwner) + }) + if _, err := pool.Exec(ctx, ` + INSERT INTO project_members (project_id, user_id, project_role, document_duty, status, joined_at, updated_at) + VALUES ($1, $2, 'owner', '', 'active', now(), now()), + ($1, $3, 'owner', '', 'active', now(), now()) + `, projectID, lowOwner, creatorOwner); err != nil { + t.Fatalf("insert owners: %v", err) + } + + if _, err := pool.Exec(ctx, OwnerAuthorBackfillOneProjectSQL, projectID); err != nil { + t.Fatalf("owner author backfill: %v", err) + } + assertMemberDuty(t, ctx, pool, projectID, creatorOwner, DutyAuthor) + assertMemberDuty(t, ctx, pool, projectID, lowOwner, DutyNone) + + if _, err := pool.Exec(ctx, OwnerAuthorBackfillOneProjectSQL, projectID); err != nil { + t.Fatalf("second backfill: %v", err) + } + assertMemberDuty(t, ctx, pool, projectID, creatorOwner, DutyAuthor) + assertMemberDuty(t, ctx, pool, projectID, lowOwner, DutyNone) +} + +func TestHealProjectDocuments_TransferDoesNotResurrectOldReviewer_PG(t *testing.T) { + ctx, _, pool := openDutyTestPool(t) + suffix := time.Now().UnixNano() + authorID := insertDutyUser(t, ctx, pool, suffix, "heal_author") + reviewerA := insertDutyUser(t, ctx, pool, suffix, "heal_ra") + reviewerB := insertDutyUser(t, ctx, pool, suffix, "heal_rb") + + var projectID int64 + if err := pool.QueryRow(ctx, ` + INSERT INTO projects (code, name, status, creator_user_id) + VALUES ($1, $2, 'active', $3) + RETURNING id + `, fmt.Sprintf("duty-heal-race-%d", suffix), "heal vs transfer", authorID).Scan(&projectID); err != nil { + t.Fatalf("insert project: %v", err) + } + t.Cleanup(func() { + projectDutyLockHook = nil + _, _ = pool.Exec(context.Background(), `DELETE FROM document_records WHERE project_id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM project_members WHERE project_id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID) + _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id IN ($1,$2,$3)`, authorID, reviewerA, reviewerB) + }) + if _, err := pool.Exec(ctx, ` + INSERT INTO project_members (project_id, user_id, project_role, document_duty, status, joined_at, updated_at) + VALUES ($1, $2, 'owner', 'author', 'active', now(), now()), + ($1, $3, 'member', 'reviewer', 'active', now(), now()), + ($1, $4, 'member', '', 'active', now(), now()) + `, projectID, authorID, reviewerA, reviewerB); err != nil { + t.Fatalf("insert members: %v", err) + } + docID := fmt.Sprintf("doc_proj%d_intake", projectID) + insertDutyDoc(t, ctx, pool, projectID, docID, "intake", authorID, reviewerA, 0) + + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback(ctx) }() + if err := AcquireProjectDutyLock(ctx, tx, projectID); err != nil { + t.Fatal(err) + } + if _, err := tx.Exec(ctx, ` + UPDATE project_members SET document_duty = '', updated_at = now() + WHERE project_id = $1 AND document_duty = $2 AND user_id <> $3 AND status = $4 + `, projectID, DutyReviewer, reviewerB, StatusActive); err != nil { + t.Fatal(err) + } + if _, err := tx.Exec(ctx, ` + UPDATE project_members SET document_duty = $3, updated_at = now() + WHERE project_id = $1 AND user_id = $2 + `, projectID, reviewerB, DutyReviewer); err != nil { + t.Fatal(err) + } + if err := ApplySlotRevocations(ctx, tx, projectID, PlanSlotRevocations(MemberDutyChange{ + UserID: reviewerB, PreviousDuty: DutyNone, NextDuty: DutyReviewer, + PriorHolderIDs: map[string]int64{DutyReviewer: reviewerA}, + })); err != nil { + t.Fatal(err) + } + + aboutToLock := make(chan struct{}) + projectDutyLockHook = func() { + close(aboutToLock) + projectDutyLockHook = nil + } + + type healResult struct { + assigned Binding + n int + err error + } + done := make(chan healResult, 1) + go func() { + assigned, n, err := HealProjectDocuments(ctx, pool, projectID) + done <- healResult{assigned: assigned, n: n, err: err} + }() + + select { + case <-aboutToLock: + case <-ctx.Done(): + t.Fatal("heal did not reach project advisory lock") + } + if err := waitForProjectAdvisoryLockWaiter(ctx, pool, projectID); err != nil { + t.Fatal(err) + } + if err := tx.Commit(ctx); err != nil { + t.Fatal(err) + } + + res := <-done + if res.err != nil { + t.Fatalf("heal after transfer: %v", res.err) + } + if res.assigned.Reviewer.UserID != reviewerB { + t.Fatalf("heal assigned reviewer=%d want %d", res.assigned.Reviewer.UserID, reviewerB) + } + assertDocReviewer(t, ctx, pool, docID, reviewerB) + assertDocAuthorID(t, ctx, pool, docID, authorID) + if res.assigned.Reviewer.UserID == reviewerA { + t.Fatal("old reviewer A must not be assigned after transfer") + } + + _, n2, err := HealProjectDocuments(ctx, pool, projectID) + if err != nil { + t.Fatal(err) + } + if n2 != 0 { + t.Fatalf("second heal must be idempotent, updated %d", n2) + } + assertDocReviewer(t, ctx, pool, docID, reviewerB) + assertDocAuthorID(t, ctx, pool, docID, authorID) +} + +func waitForProjectAdvisoryLockWaiter(ctx context.Context, pool *pgxpool.Pool, projectID int64) error { + for { + var waiting bool + err := pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 + FROM pg_locks + WHERE locktype = 'advisory' + AND NOT granted + AND classid = (($1::bigint) >> 32)::integer + AND objid = (($1::bigint) & 4294967295)::oid + ) + `, projectID).Scan(&waiting) + if err != nil { + return fmt.Errorf("query advisory lock waiters: %w", err) + } + if waiting { + return nil + } + runtime.Gosched() + select { + case <-ctx.Done(): + return fmt.Errorf("heal did not block on project advisory lock: %w", ctx.Err()) + default: + } + } +} + +func insertDutyUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool, suffix int64, kind string) int64 { + t.Helper() + var id int64 + account := fmt.Sprintf("duty_%s_%d", kind, suffix) + err := pool.QueryRow(ctx, ` + INSERT INTO users (account_id, name, role) + VALUES ($1, $2, 'engineer') + RETURNING id + `, account, "用户"+kind).Scan(&id) + if err != nil { + t.Fatalf("insert user %s: %v", kind, err) + } + return id +} + +func insertDutyDoc(t *testing.T, ctx context.Context, pool *pgxpool.Pool, projectID int64, docID, taskKey string, authorID, reviewerID, approverID int64) { + t.Helper() + _, err := pool.Exec(ctx, ` + INSERT INTO document_records ( + doc_id, project_id, task_key, title, stage, version, + current_state, is_locked, is_template_only, + author_id, author_name, reviewer_id, reviewer_name, approver_id, approver_name, + created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, 'V0.5', 1, + 'drafting', false, false, + $5, '编制工程师', $6, '审核负责人', $7, '批准负责人', + now(), now() + ) + `, docID, projectID, taskKey, taskKey+" doc", authorID, reviewerID, nullIfZero(approverID)) + if err != nil { + t.Fatalf("insert doc %s: %v", docID, err) + } +} + +func assertDocReviewer(t *testing.T, ctx context.Context, pool *pgxpool.Pool, docID string, want int64) { + t.Helper() + assertDocSlot(t, ctx, pool, docID, "reviewer_id", want) +} + +func assertDocApprover(t *testing.T, ctx context.Context, pool *pgxpool.Pool, docID string, want int64) { + t.Helper() + assertDocSlot(t, ctx, pool, docID, "approver_id", want) +} + +func assertDocSlot(t *testing.T, ctx context.Context, pool *pgxpool.Pool, docID, column string, want int64) { + t.Helper() + var got int64 + q := fmt.Sprintf(`SELECT COALESCE(%s, 0) FROM document_records WHERE doc_id = $1`, column) + if err := pool.QueryRow(ctx, q, docID).Scan(&got); err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("%s %s=%d want %d", docID, column, got, want) + } +} + +func assertDocAuthorID(t *testing.T, ctx context.Context, pool *pgxpool.Pool, docID string, want int64) { + t.Helper() + assertDocSlot(t, ctx, pool, docID, "author_id", want) +} + +func assertDocAuthorName(t *testing.T, ctx context.Context, pool *pgxpool.Pool, docID, want string) { + t.Helper() + var got string + if err := pool.QueryRow(ctx, `SELECT COALESCE(author_name, '') FROM document_records WHERE doc_id = $1`, docID).Scan(&got); err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("%s author_name=%q want %q", docID, got, want) + } +} + +func assertMemberDuty(t *testing.T, ctx context.Context, pool *pgxpool.Pool, projectID, userID int64, want string) { + t.Helper() + var got string + if err := pool.QueryRow(ctx, ` + SELECT COALESCE(document_duty, '') FROM project_members WHERE project_id = $1 AND user_id = $2 + `, projectID, userID).Scan(&got); err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("user %d document_duty=%q want %q", userID, got, want) + } +} diff --git a/internal/docgov/engine.go b/internal/docgov/engine.go index 0f54f28d..364618a5 100644 --- a/internal/docgov/engine.go +++ b/internal/docgov/engine.go @@ -13,11 +13,11 @@ import ( // ModificationCheckResult 智能体或人员发起修改前的认领与授权判定结果 type ModificationCheckResult struct { - AllowedDirectEdit bool `json:"allowed_direct_edit"` // 是否允许直接执行修改 - RequiresProposal bool `json:"requires_proposal"` // 是否需要生成变更提案待编制人拍板 - AuthorClaimed bool `json:"author_claimed"` // 编制人是否已认领 - PendingProposal *ChangeProposal `json:"pending_proposal,omitempty"` - Reason string `json:"reason"` + AllowedDirectEdit bool `json:"allowed_direct_edit"` // 是否允许直接执行修改 + RequiresProposal bool `json:"requires_proposal"` // 是否需要生成变更提案待编制人拍板 + AuthorClaimed bool `json:"author_claimed"` // 编制人是否已认领 + PendingProposal *ChangeProposal `json:"pending_proposal,omitempty"` + Reason string `json:"reason"` } // GovernanceEngine 受控文档编制/审核/批准与认领治理引擎 @@ -25,11 +25,11 @@ type GovernanceEngine struct { pool *pgxpool.Pool sm *StateMachine - mu sync.RWMutex - memDocs map[string]*DocumentRecord - memComms map[string][]*DocumentComment - memProps map[string]*ChangeProposal - memAudits map[string][]*AuditLog + mu sync.RWMutex + memDocs map[string]*DocumentRecord + memComms map[string][]*DocumentComment + memProps map[string]*ChangeProposal + memAudits map[string][]*AuditLog onApprovedHook func(ctx context.Context, doc *DocumentRecord) onApprovedHooks []func(ctx context.Context, doc *DocumentRecord) } @@ -178,18 +178,16 @@ func (e *GovernanceEngine) CreateOrGetDocument( doc.AuthorID, doc.AuthorName, doc.ReviewerID, doc.ReviewerName, doc.ApproverID, doc.ApproverName, cfgJSON, doc.CreatedAt, doc.UpdatedAt, ).Scan(&id, &currState, &isLocked, &isTmpl, &authClaim, &revClaim, &appClaim) - if err == nil { - doc.ID = id - doc.CurrentState = DocumentState(currState) - doc.IsLocked = isLocked - doc.IsTemplateOnly = isTmpl - doc.AuthorClaimed = authClaim - doc.ReviewerClaimed = revClaim - doc.ApproverClaimed = appClaim - } else { - // 仅做诊断性日志输出 - // fmt.Printf("[docgov] DB persist error: %v\n", err) + if err != nil { + return nil, fmt.Errorf("persist document ledger: %w", err) } + doc.ID = id + doc.CurrentState = DocumentState(currState) + doc.IsLocked = isLocked + doc.IsTemplateOnly = isTmpl + doc.AuthorClaimed = authClaim + doc.ReviewerClaimed = revClaim + doc.ApproverClaimed = appClaim } e.memDocs[docID] = doc diff --git a/internal/docgov/project_docs.go b/internal/docgov/project_docs.go index b27810ce..15a7a12c 100644 --- a/internal/docgov/project_docs.go +++ b/internal/docgov/project_docs.go @@ -9,6 +9,8 @@ import ( "path/filepath" "strings" "time" + + "chat-agent-system/internal/docduty" ) // WorkflowDocumentSummary 项目工作流受控文档列表项。 @@ -51,6 +53,9 @@ func (e *GovernanceEngine) ListWorkflowDocumentsByProject(ctx context.Context, p } if e.pool != nil { + if _, _, err := docduty.HealProjectDocuments(ctx, e.pool, projectID); err != nil { + return nil, fmt.Errorf("heal project document duties: %w", err) + } rows, err := e.pool.Query(ctx, ` SELECT doc_id, project_id, task_key, title, current_state, is_template_only, COALESCE(content_sha256, ''), COALESCE(work_dir, ''), version, updated_at, diff --git a/internal/server/document_revision_duty_write_test.go b/internal/server/document_revision_duty_write_test.go new file mode 100644 index 00000000..c678ebc2 --- /dev/null +++ b/internal/server/document_revision_duty_write_test.go @@ -0,0 +1,49 @@ +package server + +import ( + "context" + "errors" + "testing" + + "chat-agent-system/internal/auth" + "chat-agent-system/internal/authz" + "chat-agent-system/internal/docrevision" +) + +func TestAuthorizeDocRevision_ReviewerAndNonMemberCannotWrite(t *testing.T) { + srv := newTestServer(t) + // 隔离写权门闩:跳过项目成员库检查,只验证 AuthorID。禁止依赖共享库或默认拒绝。 + srv.cfg.AllowInMemory = true + srv.authorizer = authz.NewAuthorizer(nil) + ctx := context.Background() + const ( + authorID int64 = 170 + reviewerID int64 = 171 + strangerID int64 = 99 + docID = "doc_proj_duty_write" + ) + if _, err := srv.svc.DocGov().CreateOrGetDocument( + ctx, docID, 6145, "intake", "摸底", "V0.1", "", + authorID, "编制人A", reviewerID, "审核人B", 0, "", + ); err != nil { + t.Fatal(err) + } + ref := &docRevisionRef{ + Kind: "controlled", ProjectID: 6145, LifecycleState: "drafting", AuthorID: authorID, + } + + author := &auth.UserAccount{ID: authorID, Name: "编制人A"} + if err := srv.authorizeDocRevision(ctx, author, ref, true); err != nil { + t.Fatalf("author write must succeed, got %v", err) + } + + reviewer := &auth.UserAccount{ID: reviewerID, Name: "审核人B"} + if err := srv.authorizeDocRevision(ctx, reviewer, ref, true); !errors.Is(err, docrevision.ErrForbidden) { + t.Fatalf("reviewer write want docrevision.ErrForbidden, got %v", err) + } + + stranger := &auth.UserAccount{ID: strangerID, Name: "局外人C"} + if err := srv.authorizeDocRevision(ctx, stranger, ref, true); !errors.Is(err, docrevision.ErrForbidden) { + t.Fatalf("outsider write want docrevision.ErrForbidden, got %v", err) + } +} diff --git a/internal/server/project_members_handlers.go b/internal/server/project_members_handlers.go index 8825291b..d2f5e9ea 100644 --- a/internal/server/project_members_handlers.go +++ b/internal/server/project_members_handlers.go @@ -1,18 +1,54 @@ package server import ( + "context" "encoding/json" + "errors" + "log" "net/http" "strconv" "chat-agent-system/internal/auth" "chat-agent-system/internal/authz" + "chat-agent-system/internal/docduty" ) +var errProjectRoleRequired = errors.New("project_role required when member does not exist") + +func resolveUpdateProjectRole(requested string, existing *authz.ProjectMemberRecord) (string, error) { + if requested != "" { + return requested, nil + } + if existing != nil && existing.ProjectRole != "" { + return existing.ProjectRole, nil + } + return "", errProjectRoleRequired +} + +func writeDutySyncError(w http.ResponseWriter) { + http.Error(w, `{"error":"ERR_DUTY_SYNC","message":"成员已保存,但受控文档职责同步失败"}`, http.StatusInternalServerError) +} + +func (s *Server) healProjectDuties(ctx context.Context, projectID int64) error { + if s == nil || s.svc == nil || projectID <= 0 { + return nil + } + pool := s.svc.Pool() + if pool == nil { + return nil + } + _, _, err := docduty.HealProjectDocuments(ctx, pool, projectID) + if err != nil { + log.Printf("[docduty] heal project %d after member change failed: %v", projectID, err) + } + return err +} + type projectMemberRequest struct { - UserID int64 `json:"user_id"` - ProjectRole string `json:"project_role"` - Status string `json:"status,omitempty"` + UserID int64 `json:"user_id"` + ProjectRole string `json:"project_role"` + DocumentDuty *string `json:"document_duty,omitempty"` + Status string `json:"status,omitempty"` } func (s *Server) handleProjectMembers(w http.ResponseWriter, r *http.Request) { @@ -81,10 +117,17 @@ func (s *Server) handleAddProjectMember(w http.ResponseWriter, r *http.Request, if req.ProjectRole == "" { req.ProjectRole = authz.RoleMember } - if err := s.authorizer.UpsertProjectMember(r.Context(), projectID, req.UserID, req.ProjectRole); err != nil { + if err := s.authorizer.UpsertProjectMemberSpec(r.Context(), projectID, req.UserID, authz.MemberUpsert{ + ProjectRole: req.ProjectRole, + DocumentDuty: req.DocumentDuty, + }); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } + if err := s.healProjectDuties(r.Context(), projectID); err != nil { + writeDutySyncError(w) + return + } writeJSONResponse(w, http.StatusCreated, map[string]bool{"success": true}) } @@ -97,17 +140,46 @@ func (s *Server) handleUpdateProjectMember(w http.ResponseWriter, r *http.Reques http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"user_id required"}`, http.StatusBadRequest) return } - if req.ProjectRole != "" { - if err := s.authorizer.UpsertProjectMember(r.Context(), projectID, req.UserID, req.ProjectRole); err != nil { + var existing *authz.ProjectMemberRecord + if s.authorizer != nil { + rec, err := s.authorizer.GetProjectMember(r.Context(), projectID, req.UserID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + existing = rec + } else if req.ProjectRole == "" && req.DocumentDuty != nil { + http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"project_role required with document_duty"}`, http.StatusBadRequest) + return + } + wrote := false + if req.ProjectRole != "" || req.DocumentDuty != nil { + role, err := resolveUpdateProjectRole(req.ProjectRole, existing) + if err != nil { + http.Error(w, `{"error":"ERR_INVALID_PARAM","message":"project_role required when member does not exist"}`, http.StatusBadRequest) + return + } + if err := s.authorizer.UpsertProjectMemberSpec(r.Context(), projectID, req.UserID, authz.MemberUpsert{ + ProjectRole: role, + DocumentDuty: req.DocumentDuty, + }); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } + wrote = true } if req.Status != "" { if err := s.authorizer.SetProjectMemberStatus(r.Context(), projectID, req.UserID, req.Status); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } + wrote = true + } + if wrote { + if err := s.healProjectDuties(r.Context(), projectID); err != nil { + writeDutySyncError(w) + return + } } writeJSONResponse(w, http.StatusOK, map[string]bool{"success": true}) } @@ -126,5 +198,9 @@ func (s *Server) handleRemoveProjectMember(w http.ResponseWriter, r *http.Reques http.Error(w, err.Error(), http.StatusInternalServerError) return } + if err := s.healProjectDuties(r.Context(), projectID); err != nil { + writeDutySyncError(w) + return + } writeJSONResponse(w, http.StatusOK, map[string]bool{"success": true}) } diff --git a/internal/server/project_members_role_test.go b/internal/server/project_members_role_test.go new file mode 100644 index 00000000..a7dbb8d2 --- /dev/null +++ b/internal/server/project_members_role_test.go @@ -0,0 +1,51 @@ +package server + +import ( + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "chat-agent-system/internal/authz" +) + +func TestResolveUpdateProjectRole_KeepsExistingOwner(t *testing.T) { + role, err := resolveUpdateProjectRole("", &authz.ProjectMemberRecord{ProjectRole: authz.RoleOwner}) + if err != nil { + t.Fatal(err) + } + if role != authz.RoleOwner { + t.Fatalf("omitted project_role must keep owner, got %q", role) + } +} + +func TestResolveUpdateProjectRole_RejectsMissingMember(t *testing.T) { + if _, err := resolveUpdateProjectRole("", nil); !errors.Is(err, errProjectRoleRequired) { + t.Fatalf("want errProjectRoleRequired, got %v", err) + } + if _, err := resolveUpdateProjectRole("", &authz.ProjectMemberRecord{}); !errors.Is(err, errProjectRoleRequired) { + t.Fatalf("empty existing role must be rejected, got %v", err) + } +} + +func TestWriteDutySyncError_UsesExplicitCode(t *testing.T) { + rec := httptest.NewRecorder() + writeDutySyncError(rec) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status=%d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "ERR_DUTY_SYNC") { + t.Fatalf("body=%s", rec.Body.String()) + } +} + +func TestResolveUpdateProjectRole_ExplicitRoleWins(t *testing.T) { + role, err := resolveUpdateProjectRole(authz.RoleViewer, &authz.ProjectMemberRecord{ProjectRole: authz.RoleOwner}) + if err != nil { + t.Fatal(err) + } + if role != authz.RoleViewer { + t.Fatalf("explicit role must win, got %q", role) + } +} diff --git a/scripts/run-isolated-duty-sync-pg-tests.sh b/scripts/run-isolated-duty-sync-pg-tests.sh new file mode 100755 index 00000000..963c653b --- /dev/null +++ b/scripts/run-isolated-duty-sync-pg-tests.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# 隔离库跑 document_duty 相关 PostgreSQL 集成测试。 +# 不启停/重建 compose,不连 agent_system_dev / 共享对话库。 +# +# 用法 A(已有隔离库): +# TEST_DATABASE_URL='postgres://app:pass@127.0.0.1:55432/agent_duty_sync_itest?sslmode=disable' \ +# ./scripts/run-isolated-duty-sync-pg-tests.sh +# +# 用法 B(Codex/本机用管理连接建临时库,测完删除): +# PG_ADMIN_URL='postgres://postgres:pass@127.0.0.1:55432/postgres?sslmode=disable' \ +# ./scripts/run-isolated-duty-sync-pg-tests.sh +# +# 禁止:把 TEST_DATABASE_URL 指到 agent_system_dev、5433 默认开发库。 +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT_DIR}" + +refuse_shared_dsn() { + local dsn="$1" + if [[ "${dsn}" == *"agent_system_dev"* || "${dsn}" == *"/agent_system_test"* || "${dsn}" == *"/agent_system_main"* ]]; then + echo "[错误] 拒绝共享环境库名(agent_system_dev/test/main)。请 CREATE DATABASE 临时库。" >&2 + exit 2 + fi +} + +rewrite_db_name() { + local src="$1" + local dbname="$2" + local prefix query + if [[ "${src}" == *"?"* ]]; then + prefix="${src%%\?*}" + query="${src#*\?}" + echo "${prefix%/*}/${dbname}?${query}" + else + echo "${src%/*}/${dbname}" + fi +} + +CREATED_DB="" +ADMIN_URL="${PG_ADMIN_URL:-}" +TEST_URL="${TEST_DATABASE_URL:-}" + +if [[ -n "${ADMIN_URL}" ]]; then + refuse_shared_dsn "${ADMIN_URL}" + if ! command -v psql >/dev/null 2>&1; then + echo "[错误] 需要 psql 才能用 PG_ADMIN_URL 建临时库" >&2 + exit 1 + fi + CREATED_DB="agent_duty_sync_itest_$$" + echo "[info] creating isolated database ${CREATED_DB}" + psql "${ADMIN_URL}" -v ON_ERROR_STOP=1 -c "CREATE DATABASE ${CREATED_DB};" + cleanup() { + if [[ -n "${CREATED_DB}" ]]; then + echo "[info] dropping isolated database ${CREATED_DB}" + psql "${ADMIN_URL}" -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS ${CREATED_DB};" || true + fi + } + trap cleanup EXIT + TEST_URL="$(rewrite_db_name "${ADMIN_URL}" "${CREATED_DB}")" +elif [[ -n "${TEST_URL}" ]]; then + refuse_shared_dsn "${TEST_URL}" +else + echo "[错误] 请设置 TEST_DATABASE_URL(隔离库)或 PG_ADMIN_URL(自动建临时库)" >&2 + echo "示例:" >&2 + echo " createdb agent_duty_sync_itest" >&2 + echo " TEST_DATABASE_URL='postgres://app:pass@127.0.0.1:55432/agent_duty_sync_itest?sslmode=disable' \\" >&2 + echo " go test ./internal/docduty ./internal/authz ./internal/server -count=1 -run 'Duty|ReviewerDuty|DocumentDuty|ConcurrentLastWriter|OwnerAuthor'" >&2 + exit 1 +fi + +export TEST_DATABASE_URL="${TEST_URL}" +echo "[info] running isolated duty-sync PostgreSQL tests" + +# 包串行,避免并行 goose 抢同一临时库。 +go test ./internal/docduty -count=1 -run 'Duty|ReviewerDuty|DocumentDuty|OwnerAuthor|HealBinding|ApplySlotRevocations|HealProjectDocuments|TransferDoesNotResurrect' +go test ./internal/authz -count=1 -run 'ConcurrentLastWriter|DocumentDuty|AtomicDutyRevoke|UpsertProjectMember' +go test ./internal/server -count=1 -run 'AuthorizeDocRevision|ResolveUpdateProjectRole|WriteDutySyncError' diff --git a/web/src/App.tsx b/web/src/App.tsx index bfab2c0d..a6669702 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -34,17 +34,18 @@ import { removeProjectMember, updateProjectMember, } from './api/projectMembers'; -import { ensureCreatedProjectWithMembers } from './lib/createProjectMembersPersist'; +import { + buildManagedMemberUpsertPayload, + ensureCreatedProjectWithMembers, +} from './lib/createProjectMembersPersist'; +import { afterProjectMemberPersisted } from './lib/afterProjectMemberPersisted'; import { beginCreateProjectSubmit, endCreateProjectSubmit, resolveCreateProjectSubmitPhase, type CreateProjectSubmitPhase, } from './lib/createProjectSubmitGuard'; -import { - mapApiMembersToProjectMembers, - toApiProjectRole, -} from './lib/projectMemberAdapter'; +import { mapApiMembersToProjectMembers } from './lib/projectMemberAdapter'; import { mergeWorkflowDocumentsFromServer } from './lib/deliverableSync'; import { resolveCurrentDocRole } from './lib/currentDocRole'; import { canAddBlockComment } from './lib/commentPolicy'; @@ -645,6 +646,7 @@ export default function App() { const [sessionSyncNotice, setSessionSyncNotice] = useState(null); const [projectMembersModalOpen, setProjectMembersModalOpen] = useState(false); const [projectMembersError, setProjectMembersError] = useState(null); + const [workflowDocsEpoch, setWorkflowDocsEpoch] = useState(0); const [projectSettingsOpen, setProjectSettingsOpen] = useState(false); const [workflowGovernanceEnabled, setWorkflowGovernanceEnabled] = useState(false); @@ -1736,7 +1738,7 @@ export default function App() { return () => { active = false; }; - }, [session, sessionValidated, currentProjectId, currentUser.id, currentUser.name]); + }, [session, sessionValidated, currentProjectId, currentUser.id, currentUser.name, workflowDocsEpoch]); // 文档占用是多人共享运行态:定期从服务端同步,避免其他标签页/成员开始编辑后本页无感知。 useEffect(() => { @@ -3772,11 +3774,17 @@ export default function App() { const handleAddProjectMember = async (newMember: Omit) => { if (!currentProject) return; try { - await addProjectMember(currentProject.id, { - user_id: newMember.userId, - project_role: toApiProjectRole(newMember.role, newMember.userId, currentProject.creatorId), + await addProjectMember( + currentProject.id, + buildManagedMemberUpsertPayload( + { userId: newMember.userId, role: newMember.role }, + currentProject.creatorId, + ), + ); + await afterProjectMemberPersisted({ + syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId), + refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1), }); - await syncProjectMembersFromServer(currentProject.id, currentProject.creatorId); const newSessId = buildPrivateSessionId(`p${currentProject.id}`, newMember.userId); setSessions((prev) => { @@ -3819,11 +3827,14 @@ export default function App() { const handleUpdateProjectMemberRole = async (userId: number, newRole: ProjectMemberRole) => { if (!currentProject) return; try { - await updateProjectMember(currentProject.id, { - user_id: userId, - project_role: toApiProjectRole(newRole, userId, currentProject.creatorId), + await updateProjectMember( + currentProject.id, + buildManagedMemberUpsertPayload({ userId, role: newRole }, currentProject.creatorId), + ); + await afterProjectMemberPersisted({ + syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId), + refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1), }); - await syncProjectMembersFromServer(currentProject.id, currentProject.creatorId); } catch (err) { setProjectMembersError(err instanceof Error ? err.message : '更新成员角色失败'); } @@ -3833,7 +3844,10 @@ export default function App() { if (!currentProject) return; try { await removeProjectMember(currentProject.id, userId); - await syncProjectMembersFromServer(currentProject.id, currentProject.creatorId); + await afterProjectMemberPersisted({ + syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId), + refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1), + }); } catch (err) { setProjectMembersError(err instanceof Error ? err.message : '移出成员失败'); } @@ -3843,16 +3857,17 @@ export default function App() { if (!currentProject) return; try { const member = currentProject.members.find((m) => m.userId === userId); - await updateProjectMember(currentProject.id, { - user_id: userId, - project_role: toApiProjectRole( - member?.role ?? 'member', - userId, - currentProject.creatorId + await updateProjectMember( + currentProject.id, + buildManagedMemberUpsertPayload( + { userId, role: member?.role ?? 'member', status: 'active' }, + currentProject.creatorId, ), - status: 'active', + ); + await afterProjectMemberPersisted({ + syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId), + refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1), }); - await syncProjectMembersFromServer(currentProject.id, currentProject.creatorId); } catch (err) { setProjectMembersError(err instanceof Error ? err.message : '恢复成员失败'); } diff --git a/web/src/api/projectMembers.ts b/web/src/api/projectMembers.ts index ff98daf1..d7fc98c4 100644 --- a/web/src/api/projectMembers.ts +++ b/web/src/api/projectMembers.ts @@ -2,6 +2,7 @@ import { getBearerAuthHeaders } from './authHeaders'; export type ApiProjectMemberRole = 'owner' | 'member' | 'viewer'; export type ApiProjectMemberStatus = 'active' | 'exited'; +export type ApiDocumentDuty = 'author' | 'reviewer' | 'approver' | ''; export interface ApiProjectMemberRecord { id?: number; @@ -10,6 +11,7 @@ export interface ApiProjectMemberRecord { username?: string; name?: string; project_role: ApiProjectMemberRole; + document_duty?: ApiDocumentDuty; status: ApiProjectMemberStatus; joined_at?: string; } @@ -21,6 +23,7 @@ export interface ListProjectMembersResponse { export interface UpsertProjectMemberPayload { user_id: number; project_role: ApiProjectMemberRole; + document_duty?: ApiDocumentDuty; status?: ApiProjectMemberStatus; } diff --git a/web/src/components/chat/deliverableSync.test.ts b/web/src/components/chat/deliverableSync.test.ts index ba40a4c2..e983bdc9 100644 --- a/web/src/components/chat/deliverableSync.test.ts +++ b/web/src/components/chat/deliverableSync.test.ts @@ -102,6 +102,35 @@ describe('mergeWorkflowDocumentsFromServer', () => { expect(asA[0].dutiesAuthoritative).toBe(true); }); + it('maps server reviewer_id for USER_B and does not invent reviewer from the login name', () => { + const userB = { id: 171, name: '审核人B-mu0bvnp8' }; + const merged = mergeWorkflowDocumentsFromServer( + [], + [ + { + doc_id: 'doc_proj6145_intake', + project_id: 6145, + task_key: 'intake', + title: '用户初始需求收集表', + lifecycle_state: 'drafting', + is_template_only: false, + author_id: 170, + author_name: '编制人A', + reviewer_id: 171, + reviewer_name: '审核人B-mu0bvnp8', + approver_id: 0, + approver_name: '', + }, + ], + 6145, + userB, + ); + expect(merged[0].reviewerId).toBe(171); + expect(merged[0].authorId).toBe(170); + expect(merged[0].authorId).not.toBe(userB.id); + expect(merged[0].reviewer).toBe('审核人B-mu0bvnp8'); + }); + it('marks server controlled docs authoritative even when duty ids are 0', () => { const merged = mergeWorkflowDocumentsFromServer( [], diff --git a/web/src/components/project/CreateProjectModal.tsx b/web/src/components/project/CreateProjectModal.tsx index d1934bcc..f04c1428 100644 --- a/web/src/components/project/CreateProjectModal.tsx +++ b/web/src/components/project/CreateProjectModal.tsx @@ -7,6 +7,7 @@ import { shouldBlockCreateProjectClose, type CreateProjectSubmitPhase, } from '../../lib/createProjectSubmitGuard'; +import { exclusiveMemberRoleChange } from '../../lib/projectDocumentDuty'; interface CreateProjectModalProps { isOpen: boolean; @@ -101,13 +102,7 @@ export const CreateProjectModal: React.FC = ({ const handleChangeRole = (userId: number, role: ProjectMemberRole) => { if (submitting) return; - setSelectedMembers((prev) => ({ - ...prev, - [userId]: { - ...prev[userId], - role, - }, - })); + setSelectedMembers((prev) => exclusiveMemberRoleChange(prev, userId, role)); }; const handleConfirm = (e: React.FormEvent) => { diff --git a/web/src/lib/afterProjectMemberPersisted.test.ts b/web/src/lib/afterProjectMemberPersisted.test.ts new file mode 100644 index 00000000..c25f508a --- /dev/null +++ b/web/src/lib/afterProjectMemberPersisted.test.ts @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { afterProjectMemberPersisted } from './afterProjectMemberPersisted'; + +test('afterProjectMemberPersisted syncs members then refreshes workflow docs', async () => { + const calls: string[] = []; + await afterProjectMemberPersisted({ + syncMembers: async () => { + calls.push('sync'); + }, + refreshWorkflowDocs: () => { + calls.push('refresh'); + }, + }); + assert.deepEqual(calls, ['sync', 'refresh']); +}); + +test('afterProjectMemberPersisted does not refresh when member sync fails', async () => { + let refreshed = false; + await assert.rejects( + () => + afterProjectMemberPersisted({ + syncMembers: async () => { + throw new Error('sync failed'); + }, + refreshWorkflowDocs: () => { + refreshed = true; + }, + }), + /sync failed/, + ); + assert.equal(refreshed, false); +}); diff --git a/web/src/lib/afterProjectMemberPersisted.ts b/web/src/lib/afterProjectMemberPersisted.ts new file mode 100644 index 00000000..f3efaa5d --- /dev/null +++ b/web/src/lib/afterProjectMemberPersisted.ts @@ -0,0 +1,9 @@ +/** 成员增改/恢复/移出成功后同步成员并刷新受控文档矩阵。 */ + +export async function afterProjectMemberPersisted(opts: { + syncMembers: () => Promise; + refreshWorkflowDocs: () => void; +}): Promise { + await opts.syncMembers(); + opts.refreshWorkflowDocs(); +} diff --git a/web/src/lib/createProjectMembersPersist.test.ts b/web/src/lib/createProjectMembersPersist.test.ts index 4c345e16..16aaedb5 100644 --- a/web/src/lib/createProjectMembersPersist.test.ts +++ b/web/src/lib/createProjectMembersPersist.test.ts @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { buildInitialMemberUpsertPayload, + buildManagedMemberUpsertPayload, ensureCreatedProjectWithMembers, persistInitialProjectMembers, selectNonCreatorInitialMembers, @@ -21,14 +22,43 @@ test('selectNonCreatorInitialMembers skips creator only', () => { ); }); +test('buildManagedMemberUpsertPayload sends document_duty on add/update/restore', () => { + assert.deepEqual(buildManagedMemberUpsertPayload({ userId: 171, role: 'reviewer' }, 170), { + user_id: 171, + project_role: 'member', + document_duty: 'reviewer', + }); + assert.deepEqual(buildManagedMemberUpsertPayload({ userId: 4, role: 'approver' }, 170), { + user_id: 4, + project_role: 'member', + document_duty: 'approver', + }); + assert.deepEqual( + buildManagedMemberUpsertPayload({ userId: 171, role: 'reviewer', status: 'active' }, 170), + { + user_id: 171, + project_role: 'member', + document_duty: 'reviewer', + status: 'active', + }, + ); + assert.deepEqual(buildManagedMemberUpsertPayload({ userId: 170, role: 'owner' }, 170), { + user_id: 170, + project_role: 'owner', + document_duty: 'author', + }); +}); + test('buildInitialMemberUpsertPayload maps reviewer UI role to member API role', () => { assert.deepEqual(buildInitialMemberUpsertPayload({ userId: 99, userName: 'B', role: 'reviewer' }, 86), { user_id: 99, project_role: 'member', + document_duty: 'reviewer', }); assert.deepEqual(buildInitialMemberUpsertPayload({ userId: 99, userName: 'B', role: 'viewer' }, 86), { user_id: 99, project_role: 'viewer', + document_duty: '', }); }); diff --git a/web/src/lib/createProjectMembersPersist.ts b/web/src/lib/createProjectMembersPersist.ts index 594b027e..9ff79c7a 100644 --- a/web/src/lib/createProjectMembersPersist.ts +++ b/web/src/lib/createProjectMembersPersist.ts @@ -5,6 +5,7 @@ import type { UpsertProjectMemberPayload } from '../api/projectMembers'; import { toApiProjectRole } from './projectMemberAdapter'; +import { documentDutyFromMemberRole } from './projectDocumentDuty'; import type { ProjectMemberRole } from '../types/workbench'; export type InitialProjectMemberInput = { @@ -26,10 +27,25 @@ export function buildInitialMemberUpsertPayload( member: InitialProjectMemberInput, creatorUserId: number, ): UpsertProjectMemberPayload { - return { - user_id: member.userId, - project_role: toApiProjectRole(member.role, member.userId, creatorUserId), + return buildManagedMemberUpsertPayload( + { userId: member.userId, role: member.role }, + creatorUserId, + ); +} + +export function buildManagedMemberUpsertPayload( + input: { userId: number; role: ProjectMemberRole; status?: 'active' | 'exited' }, + creatorUserId: number, +): UpsertProjectMemberPayload { + const payload: UpsertProjectMemberPayload = { + user_id: input.userId, + project_role: toApiProjectRole(input.role, input.userId, creatorUserId), + document_duty: documentDutyFromMemberRole(input.role), }; + if (input.status) { + payload.status = input.status; + } + return payload; } export type AddProjectMemberFn = ( diff --git a/web/src/lib/currentDocRole.test.ts b/web/src/lib/currentDocRole.test.ts index 7b53e81e..c8b18cc6 100644 --- a/web/src/lib/currentDocRole.test.ts +++ b/web/src/lib/currentDocRole.test.ts @@ -25,6 +25,30 @@ describe('resolveCurrentDocRole', () => { assert.equal(resolveCurrentDocRole({ currentUser: stranger, ...duties }), 'viewer'); }); + it('resolves real USER_B id as reviewer and never name-promotes placeholder 2', () => { + const userB = { id: 171, name: '审核人B' }; + assert.equal( + resolveCurrentDocRole({ + currentUser: userB, + authorId: 170, + reviewerId: 171, + approverId: 4, + governanceMode: 'controlled', + }), + 'reviewer', + ); + assert.equal( + resolveCurrentDocRole({ + currentUser: userB, + authorId: 170, + reviewerId: 2, + reviewer: '审核人B', + governanceMode: 'controlled', + }), + 'viewer', + ); + }); + it('fail-closes unknown identity to viewer even if projectPermission says author', () => { assert.equal( resolveCurrentDocRole({ diff --git a/web/src/lib/projectDocumentDuty.test.ts b/web/src/lib/projectDocumentDuty.test.ts new file mode 100644 index 00000000..298d1f6d --- /dev/null +++ b/web/src/lib/projectDocumentDuty.test.ts @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + documentDutyFromMemberRole, + exclusiveMemberRoleChange, + memberRoleFromDocumentDuty, +} from './projectDocumentDuty'; + +test('documentDutyFromMemberRole maps UI reviewer and creator', () => { + assert.equal(documentDutyFromMemberRole('reviewer'), 'reviewer'); + assert.equal(documentDutyFromMemberRole('creator'), 'author'); + assert.equal(documentDutyFromMemberRole('viewer'), ''); + assert.equal(documentDutyFromMemberRole('member'), ''); +}); + +test('exclusiveMemberRoleChange keeps a single reviewer', () => { + const next = exclusiveMemberRoleChange( + { + 3: { selected: true, role: 'reviewer' }, + 171: { selected: true, role: 'viewer' }, + }, + 171, + 'reviewer', + ); + assert.equal(next[171].role, 'reviewer'); + assert.equal(next[3].role, 'viewer'); +}); + +test('memberRoleFromDocumentDuty does not invent reviewer from access role', () => { + assert.equal(memberRoleFromDocumentDuty('reviewer', 'member'), 'reviewer'); + assert.equal(memberRoleFromDocumentDuty('', 'member'), 'member'); + assert.equal(memberRoleFromDocumentDuty(undefined, 'viewer'), 'viewer'); +}); diff --git a/web/src/lib/projectDocumentDuty.ts b/web/src/lib/projectDocumentDuty.ts new file mode 100644 index 00000000..4a52013d --- /dev/null +++ b/web/src/lib/projectDocumentDuty.ts @@ -0,0 +1,45 @@ +import type { ProjectMemberRole } from '../types/workbench'; + +export type DocumentDuty = 'author' | 'reviewer' | 'approver' | ''; + +const DUTY_ROLES: DocumentDuty[] = ['author', 'reviewer', 'approver']; + +export function documentDutyFromMemberRole(role: ProjectMemberRole): DocumentDuty { + if (role === 'author' || role === 'reviewer' || role === 'approver') { + return role; + } + if (role === 'creator' || role === 'owner') { + return 'author'; + } + return ''; +} + +export function memberRoleFromDocumentDuty( + documentDuty: string | undefined, + fallback: ProjectMemberRole, +): ProjectMemberRole { + if (documentDuty === 'author' || documentDuty === 'reviewer' || documentDuty === 'approver') { + return documentDuty; + } + return fallback; +} + +export function exclusiveMemberRoleChange( + selected: Record, + userId: number, + role: ProjectMemberRole, +): Record { + const next: Record = { ...selected, [userId]: { ...selected[userId], role } }; + const duty = documentDutyFromMemberRole(role); + if (!DUTY_ROLES.includes(duty)) { + return next; + } + for (const key of Object.keys(next)) { + const id = Number(key); + if (id === userId) continue; + if (documentDutyFromMemberRole(next[id].role) === duty) { + next[id] = { ...next[id], role: 'viewer' }; + } + } + return next; +} diff --git a/web/src/lib/projectMemberAdapter.test.ts b/web/src/lib/projectMemberAdapter.test.ts index d4e95357..6195e273 100644 --- a/web/src/lib/projectMemberAdapter.test.ts +++ b/web/src/lib/projectMemberAdapter.test.ts @@ -27,6 +27,7 @@ test('mapApiMemberToProjectMember builds UI record', () => { name: '张工', username: 'zhang', project_role: 'member', + document_duty: 'reviewer', status: 'active', joined_at: '2026-09-12 10:00:00', }, @@ -34,6 +35,6 @@ test('mapApiMemberToProjectMember builds UI record', () => { ); assert.equal(mapped.userId, 2); assert.equal(mapped.userName, '张工'); - assert.equal(mapped.role, 'member'); + assert.equal(mapped.role, 'reviewer'); assert.equal(mapped.status, 'active'); }); diff --git a/web/src/lib/projectMemberAdapter.ts b/web/src/lib/projectMemberAdapter.ts index 4944807a..7e5fd1ec 100644 --- a/web/src/lib/projectMemberAdapter.ts +++ b/web/src/lib/projectMemberAdapter.ts @@ -1,5 +1,6 @@ import { ApiProjectMemberRecord, ApiProjectMemberRole } from '../api/projectMembers'; import { ProjectMember, ProjectMemberRole } from '../types/workbench'; +import { memberRoleFromDocumentDuty } from './projectDocumentDuty'; /** UI 角色 → 服务端 project_role(禁止 author/reviewer/approver)。 */ export function toApiProjectRole( @@ -39,7 +40,10 @@ export function mapApiMemberToProjectMember( return { userId: rec.user_id, userName: rec.name?.trim() || rec.username?.trim() || `用户 #${rec.user_id}`, - role: fromApiProjectRole(rec.project_role, rec.user_id, creatorId), + role: memberRoleFromDocumentDuty( + rec.document_duty, + fromApiProjectRole(rec.project_role, rec.user_id, creatorId), + ), status: rec.status === 'exited' ? 'exited' : 'active', joinedAt: rec.joined_at || new Date().toLocaleString(), exitedAt: rec.status === 'exited' ? rec.joined_at : undefined,