merge: fix/document-revision-reviewer-duty-sync
This commit is contained in:
@@ -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`
|
||||
@@ -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
@@ -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)
|
||||
@@ -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 误杀,只改验收观测契约 |
|
||||
|
||||
@@ -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
@@ -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 表是否存在。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 集成测试必须显式提供隔离测试库,禁止默认连开发库。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Executable
+78
@@ -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
@@ -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 : '恢复成员失败');
|
||||
}
|
||||
|
||||
@@ -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: '',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user