feat(audit): 审核闭环后端(M5-01~05)—— 「谁能审谁」比「怎么审」重要
一、落地
module/audit:entity(CollectAuditRecord) + 4 个 mapper + AuditService
+ RejectReasonCatalog(附录 C 的 10 条,写在代码里,理由同权限码)
两端接口:PC /api/v1/audit/*(§11)、小程序 /api/v1/mp/teacher/audit/*(§10.2~10.5,只做初审)
待办的 MP 与 PC **共用一份 VO**(契约里那两份重合九成):做两份迟早分叉,
而"同一个学生在手机上是红、在电脑上是绿"最伤信任。
二、★ 三件必须守住的事
① 权限 ≠ 范围:全校几十个班主任都有 audit:first,但只有这个班的那位能审这个学生。
初审查询强制带 org 范围,且**空列表 = 看不见任何单子**(SQL 里显式 1 = 0,
不是"不过滤")—— 那是这类查询最经典也最严重的写法错误。
E2E 专门造了个"同样是班主任但没被指定"的 T2:看不到待办、打不开详情、点不动通过。
② 有红问题不许通过(40032),且校验是**现场重算**的:提交时落库的 red_count 是快照,
而 OCR/识别是异步的、名册与材料规则也可能被改。"放过一份有问题材料"不可撤销。
③ 批量通过只带绿色项,红黄一律排除(onlyClean 服务端强制 true,传 false 不报错也不生效),
且跳过的每一条都要说清原因 —— 否则用户只会反复点那个大按钮。
三、红黄计数:列表用快照,动作/详情现场重算并**回写快照**(自己会收敛,最多错一次)
⚠️ 因此 detail() 不能标 readOnly=true —— 它要写回写。标了 PG 直接拒绝 UPDATE,
而这个 bug 只在"现场值与快照不一致"时炸,也就是最需要它工作的那一次。已踩并修。
四、⚠️ 三处文档与代码不一致,需要文档侧对齐(详见交接摘要 §一·二十七)
1. 撤回的动作码:DDL 注释 3=批量通过,而接口设计 §11.4 写"action = 3(撤回)"。
1~6 沿用 DDL,**撤回另取 7** —— 时间轴最不能混淆:把撤回显示成批量通过,
会让人以为这份单子被通过了。
2. 撤回的适用状态:§11.4 只列了状态 3;额外允许**状态 2**("班主任点错了通过"正是这个状态)。
状态机自洽:撤初审 → 1,撤终审 → 2。**需客户确认**。
3. 文件 source 的语义:DDL(2 班主任代传/3 管理员代传/4 整理器导入) 与 §10.6(source=4 代传)
冲突,以 DDL 为准;代传接口(M5-06)未做,时间轴已按 source∈{2,3} 预留。
五、顺带补掉两个"没人写过"的窟窿
· 名册行的 submit_status **全仓库只有读没有写** → 名册列表状态列恒为"未提交",
而 REPLACE 护栏只靠 bind_user_id 在生效。现在学生侧与审核侧都写它。
它**不是单调的**:被驳回(4)后重交必须回到 2,否则班主任一直以为他还欠一份。
· collect_submission 少映射了 class_audit_time / admin_audit_time(DDL 里一直有)。
口径是"**本轮**审核的状态"(驳回时清空,历史在 collect_audit_record 里一条不少)。
六、审核端看材料走单独通道
MaterialFileService 抽出 urlOf/contentOf(调用方授权),学生端仍 /mp/files/*、
审核端 /audit/files/*。不要为了"让管理员也能看"在学生那个方法里加 or 条件——那一刻学生端就漏了。
七、一个把「取值口径」讲清楚了的坑
SubmissionValidator 原本走 FillService.getForm(taskId)(学生视角:先过门禁、再拿自己的草稿),
审核人必然撞 40007 且拿到的是自己那份草稿。已抽 assembleFor(task, roster, sub),两条路径共用同一段装配。
紧接着暴露第二层:**改提交单的列不一定改变"组装出来的值"** —— 学生没动过的字段值会**回退到名册**。
想造红问题要用**名册里没有的字段**(E2E 最后用"本职业工作年限填 999"→ VAL-17)。
八、E2E:scripts/e2e-m5-audit.py → 87/87
复用了既有的两个脚本工具(import e2e-m3-submit 拿学生侧造数、import e2e-m2-roster 拿服务端工具),
不复制第二份 mint/upload。覆盖:范围隔离、详情与策略、40032 现场重算、幂等回放、
终审、批量通过的三种跳过、驳回与升级、学生重交、撤回的 40033/40034/40301 与"不能撤两次"。
夹具教训:身份证材料的目录默认长边下限是 1400,小图必判「清晰度偏低」=黄色警告,
于是"绿色单"前提不成立、批量断言集体失败 —— 失败的样子像功能坏了,其实只是夹具不满足前提。
九、没做的(都在计划内):代传(M5-06)、统计(M5-09)、通知(M5-12)、批量驳回/转交(P1)。
因为没有通知渠道,驳回响应**如实**回 notifySent=false + needManualNotice=true ——
不能为了让契约好看而写 true,那会让"学生到底收到没有"变成没人能回答的问题。
十、回归:全量 **753 条断言、0 失败**(新增 87);verify-docs 19 项通过。
下一步:M5 前端(班主任卡片流只右滑通过 / 学生详情 / 驳回页 / 一键通过确认 / PC 终审)。
This commit is contained in:
+131
@@ -2123,3 +2123,134 @@ t1 域名免租户码登录 / 与手填租户码矛盾时明确报错 / **跨租
|
||||
|
||||
回到 §一·二十二 的顺序:**第 4 步 = 审核闭环(后端)** ——
|
||||
初审 / 终审 / 撤回 / 批量通过(只带无问题项)/ 驳回(逐条填原因)。
|
||||
|
||||
---
|
||||
|
||||
## §一·二十七 审核闭环(M5-01~05 后端)—— 「谁能审谁」比「怎么审」重要
|
||||
|
||||
### 落地
|
||||
|
||||
| 位置 | 作用 |
|
||||
|---|---|
|
||||
| `module/audit/entity/CollectAuditRecord` | 审核记录(只增不改。撤回是**再写一条**,不是删掉上一条) |
|
||||
| `module/audit/mapper/AuditScopeMapper` | **谁能审哪些班**(三级回退:指定初审人 → 副班主任 → 班级负责人) |
|
||||
| `module/audit/mapper/AuditTodoMapper` | 待办列表(初审/终审共用一套查询 + 四个计数) |
|
||||
| `module/audit/mapper/AuditPreconditionMapper` | 「已导出打包」判定(M6 落地后应搬到导出模块) |
|
||||
| `module/audit/service/AuditService` | 待办 / 详情 / 通过 / 驳回 / 批量通过 / 撤回 / 时间轴 / 审核端文件访问 |
|
||||
| `module/audit/service/RejectReasonCatalog` | 附录 C 的 10 条原因码(**写在代码里**,理由同权限码) |
|
||||
| `controller/AuditController` | PC:`/api/v1/audit/*`(§11) |
|
||||
| `controller/MpTeacherAuditController` | 小程序:`/api/v1/mp/teacher/audit/*`(§10.2~10.5,只做初审) |
|
||||
|
||||
待办的 MP 端与 PC 端**共用一份 VO**(契约里那两份重合九成)——
|
||||
两份迟早分叉,而"同一个学生在手机上是红、在电脑上是绿"最伤信任。
|
||||
|
||||
### ★ 三件必须守住的事
|
||||
|
||||
**① 权限 ≠ 范围。** 全校几十个班主任都有 `audit:first`,但只有**这个班的**班主任能审这个学生。
|
||||
所以初审查询强制带 org 范围,且**空列表 = 看不见任何单子**(SQL 里显式写 `1 = 0`,
|
||||
而不是"不过滤")——那是这类查询最经典也最严重的写法错误:一个刚被移出所有班级的老师
|
||||
会突然看到全校的单子。E2E 专门造了一个"同样是班主任但没被指定"的 T2:
|
||||
他看不到待办、打不开详情、点不动通过(三条断言分别守着)。
|
||||
|
||||
**② 有红问题不许通过(`40032`),而且校验是「现场重算」的。**
|
||||
提交时落库的 `red_count` 是**快照**:OCR / 识别是异步的,名册和材料规则也可能被改。
|
||||
审核这一刻必须重算——"放过一份有问题的材料"不可撤销。
|
||||
E2E 的做法是提交成功后把必填项改坏,再点通过:**若读快照就会通过**。
|
||||
|
||||
**③ 批量通过只带绿色项,红黄一律排除。** 契约里的 `onlyClean` 服务端**强制 true**
|
||||
(传 false 不报错也不生效——报错会让旧客户端不可用,而这件事没有"客户端说了算"的版本)。
|
||||
跳过的每一条都要**说清原因**(红/黄/不在我的班/状态不对),否则用户只会反复点那个大按钮。
|
||||
|
||||
### 红黄计数的口径(列表用快照,动作时重算并回写)
|
||||
|
||||
- **列表**用 `collect_submission.red_count/yellow_count`(要快,它本来就是概览)
|
||||
- **详情**与**通过 / 批量通过**现场重算,**并回写快照** → 快照自己会收敛,最多错一次
|
||||
|
||||
⚠️ 因此 `detail()` **不能标 `readOnly = true`**:它要写那条回写。
|
||||
标了之后 PG 直接拒绝 UPDATE(`cannot execute update in a read-only transaction`),
|
||||
而这个 bug 的表现极其阴险:**计数没变时一切正常,一出现不一致就 500** ——
|
||||
也就是它只在最需要工作的那一次崩掉。(已踩并修,注释写在方法上。)
|
||||
|
||||
### ⚠️ 两处「文档与代码不一致」,需要文档侧对齐
|
||||
|
||||
| # | 冲突 | 我的处理 |
|
||||
|---|---|---|
|
||||
| 1 | **撤回的动作码**:DDL 注释写 `3=批量通过`,而接口设计 §11.4 写"写入一条 `action = 3`(撤回)的记录" | 1~6 沿用 DDL(已发布、可能已有数据),**撤回另取 7**。理由是时间轴最不能混淆:把"撤回"显示成"批量通过",会让人以为这份单子被通过了 |
|
||||
| 2 | **撤回的适用状态**:§11.4 的表格只列了"状态 3(已通过)" | 额外允许**状态 2** —— 最常见的真实场景是**班主任点错了通过**(那一步之后状态正是 2,不是 3)。状态机自洽:撤初审记录 → 回到 1;撤终审记录 → 回到 2。**这条要请客户确认**(若不允许,去掉一个分支即可) |
|
||||
| 3 | 材料文件 `source` 的语义:DDL 写 `2 班主任代传 / 3 管理员代传 / 4 整理器导入`,§10.6 写"文件记录 `source = 4`(代传)" | 以 DDL 为准(更细,且已建表)。代传接口(M5-06)还没做,时间轴已按 `source ∈ {2,3}` 预留 |
|
||||
|
||||
### 顺带修掉的两个「没人写过」的窟窿
|
||||
|
||||
1. **名册行的 `submit_status` 以前从来没人写**(全仓库只有读,没有写)→
|
||||
名册列表的「状态」列恒显示"未提交";而 REPLACE 导入的护栏
|
||||
(`bind_user_id IS NULL AND submit_status = 0`)实际上只靠 `bind_user_id` 在生效 ——
|
||||
一旦绑定被清掉,已经提交过的行会被当成"没动过"而遭整表替换。
|
||||
现在写入点有两个:学生侧(草稿 / 提交)与审核侧(初审通过 / 终审通过 / 驳回 / 撤回)。
|
||||
⚠️ 它**不是单调的**:被驳回(4)后学生重交必须回到 2,否则班主任会一直以为他还欠一份。
|
||||
2. **`collect_submission` 少了两个实体字段**(`class_audit_time` / `admin_audit_time`)——
|
||||
DDL 里一直有这两列,实体只映射了 `*_by`。审核要写时间才补上。
|
||||
这两列的口径是「**本轮**审核的状态」(驳回时清空,历史在 `collect_audit_record` 里一条不少)。
|
||||
|
||||
### 审核端看材料:单独一条通道,不复用学生那个
|
||||
|
||||
`MaterialFileService` 抽出 `urlOf / contentOf`(调用方负责授权),学生端仍走
|
||||
`/api/v1/mp/files/*`(查"这份是不是你传的"),审核端走 `/api/v1/audit/files/*`
|
||||
(查"这个班归不归你审")。**不要**为了"让管理员也能看"而在学生那个方法里加 or 条件 ——
|
||||
那一刻学生端就漏了。
|
||||
|
||||
### 一个把「取值口径」讲清楚了的坑(E2E 抓到的)
|
||||
|
||||
`SubmissionValidator` 原本通过 `FillService.getForm(taskId)` 取表单值 ——
|
||||
那是**学生视角**:它先过门禁、再拿"当前登录人"的草稿。审核人当然过不了门禁(`40007`),
|
||||
而且拿到的会是审核人自己那份草稿。已抽出 `FillService.assembleFor(task, roster, sub)`,
|
||||
两条路径**共用同一段装配代码**(分成两份就是"两条校验规则互相矛盾"的开端)。
|
||||
|
||||
紧接着暴露出第二层:**改提交单里的列不一定改变"组装出来的值"** ——
|
||||
学生**没动过**的字段,值会**回退到名册**。E2E 里想用"清空在读学校"造红问题,
|
||||
对甲有效(甲改过该字段 → 在 `changed_fields` 里 → 以草稿为准,空也算),
|
||||
对乙**完全无效**(乙没改过 → 值仍来自名册)。
|
||||
最后改用名册里没有的字段(本职业工作年限填 999 → VAL-17)才稳定造出红问题。
|
||||
👉 造红问题的正确姿势:**挑一个名册里没有的字段**。
|
||||
|
||||
### E2E:`scripts/e2e-m5-audit.py` → **87/87**
|
||||
|
||||
夹具**刻意做出一个"同样是班主任但没被指定"的人**,并复用了既有的两个脚本工具
|
||||
(`import e2e-m3-submit` 拿学生侧的造数能力、`import e2e-m2-roster` 拿服务端工具)——
|
||||
不复制第二份 `mint`/`upload`,否则两边迟早分叉。
|
||||
|
||||
```
|
||||
[1] 两个学生提交:甲干净 / 乙有黄色(同一张图传两份 → VAL-09)
|
||||
[2] 待办:T1 看到 2 份 + 四个计数 + batchApprovable;**T2 一份都看不到**;T1 查终审 → 403
|
||||
[3] 详情:脱敏证件号 / 材料按定义分组 / 预览链接 / 改动明细 / 两份策略 / 时间轴倒序;**T2 打不开**
|
||||
[4] 红问题:改坏必填项后通过 → **40032**;详情现场值 red=1 且**回写**了快照
|
||||
[5] 初审通过 → 2 + 留痕 + 名册状态;**重复点通过 → replayed=true 且不重复写记录**
|
||||
[6] 终审通过 → 3 + 名册 3
|
||||
[7] 批量通过:黄的单跳过(说清原因)→ 清掉重复件后过 → 红的单跳过
|
||||
[8] 驳回:原因必填 / 字典校验 / R999 必填说明;第 3 次 → escalated;**学生重交 → 状态与名册都回到待审**
|
||||
[9] 撤回:**已导出 40033 / 已归档 40034**;非原审核人非管理员 40301;成功回退 + action=7 留痕;同一条不能撤两次
|
||||
[10] 字典 10 条 + 批量上限 40011 + 学生调审核接口 40301
|
||||
```
|
||||
|
||||
**全量 753 条断言、0 失败**(新增 87)。
|
||||
|
||||
### 另一个夹具教训
|
||||
|
||||
E2E 一开始给材料传了张 900×1200 的小图 —— 而**身份证材料项的目录默认长边下限是 1400**
|
||||
(`MaterialCatalog` 的档位默认,接口传 `minLongSide` 也压不过它),
|
||||
于是每份单子都被判「清晰度偏低」= **黄色警告**,接着"绿色单/批量通过"的断言集体失败。
|
||||
**失败的样子像功能坏了,其实只是夹具不满足业务前提。** 换成 1600×1400 的图即可。
|
||||
|
||||
### 这一步没做的(都在计划内)
|
||||
|
||||
- M5-06 代传材料(`source=2/3` 的时间轴口径已预留)
|
||||
- M5-09 审核统计(`/audit/statistics`:人均时长、驳回原因 TOP)
|
||||
- M5-12 通知(订阅消息 / 站内信)→ 因此驳回响应**如实**回
|
||||
`notifySent=false + needManualNotice=true`,并在日志里标 escalated。
|
||||
**不能为了让契约好看而写 true** —— 那会让"学生到底收到没有"变成没人能回答的问题
|
||||
- FR-806 批量驳回(P1)、FR-808 转交(P1)
|
||||
|
||||
### 下一步
|
||||
|
||||
**M5 的前端**:班主任端卡片流(原型 ②,**只右滑通过**、左滑不绑定驳回)、
|
||||
学生详情页(原型 ③)、驳回页(原型 ⑤,原因快选 + 二次确认)、
|
||||
一键通过确认页(原型 ④)、管理端终审列表与详情(原型 ⑥)。
|
||||
|
||||
Reference in New Issue
Block a user