merge: fix/document-revision-reviewer-duty-sync

This commit is contained in:
weichen
2026-09-13 22:48:05 +00:00
44 changed files with 3184 additions and 120 deletions
@@ -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`
+8
View File
@@ -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。
+4 -2
View File
@@ -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/<slug>` 或 `fix/<slug>`,**禁止** `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) |
@@ -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
@@ -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)
@@ -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**
@@ -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)
+1
View File
@@ -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 误杀,只改验收观测契约 |
+1 -1
View File
@@ -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` |
---
@@ -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;
+205 -33
View File
@@ -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 表是否存在。
+285
View File
@@ -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
}
+15
View File
@@ -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)
}
}
+30
View File
@@ -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()
@@ -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{}
}
+105 -22
View File
@@ -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
}
+22
View File
@@ -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 集成测试必须显式提供隔离测试库,禁止默认连开发库。
+90
View File
@@ -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
}
+35
View File
@@ -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)
}
}
+182
View File
@@ -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
}
+246
View File
@@ -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")
}
}
+167
View File
@@ -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
}
}
+62
View File
@@ -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")
}
}
+206
View File
@@ -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
}
+569
View File
@@ -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)
}
}
+19 -21
View File
@@ -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
+5
View File
@@ -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,
@@ -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)
}
}
+82 -6
View File
@@ -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})
}
@@ -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)
}
}
+78
View File
@@ -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'
+38 -23
View File
@@ -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<string | null>(null);
const [projectMembersModalOpen, setProjectMembersModalOpen] = useState(false);
const [projectMembersError, setProjectMembersError] = useState<string | null>(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<ProjectMember, 'joinedAt' | 'status'>) => {
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 : '恢复成员失败');
}
+3
View File
@@ -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;
}
@@ -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(
[],
@@ -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<CreateProjectModalProps> = ({
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) => {
@@ -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);
});
@@ -0,0 +1,9 @@
/** 成员增改/恢复/移出成功后同步成员并刷新受控文档矩阵。 */
export async function afterProjectMemberPersisted(opts: {
syncMembers: () => Promise<unknown>;
refreshWorkflowDocs: () => void;
}): Promise<void> {
await opts.syncMembers();
opts.refreshWorkflowDocs();
}
@@ -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: '',
});
});
+19 -3
View File
@@ -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 = (
+24
View File
@@ -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({
+33
View File
@@ -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');
});
+45
View File
@@ -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<T extends { role: ProjectMemberRole }>(
selected: Record<number, T>,
userId: number,
role: ProjectMemberRole,
): Record<number, T> {
const next: Record<number, T> = { ...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;
}
+2 -1
View File
@@ -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');
});
+5 -1
View File
@@ -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,