feat(notify): 通知接线检查 + 修 F-101~F-111(模板对自动通知生效 / 催报真的会发 / 该发的通知都发了)

起点:F-1/F-31/F-50/F-52 是同一个形状 —— 每处单看都对,编译/lint/review 全不红,只有从入口走一遍才暴露。先把这一类固化成命令,再按输出逐条修。

新增 scripts/check-wiring.py:权限码有没有地方校验、错误码有没有被引用、站内信模板有没有发送点、bizType 有没有被用过、菜单键 ↔ PC 端 XtMenu 双向核对。有意不接线的写进脚本上方例外表(带理由)。进 verify-all(第 5 项)与 CI 的 check 阶段。检查器自身修了三次假阳性:只数 @RequirePerm 漏掉服务层校验、按标识符数引用被同名常量骗过、注释里的提及被当成接线(改为先去 Java 注释再数)。

F-101 自动通知改走可配置模板(FR-1002 原来只兑现了手工发送那一半):新增 NotifyTextResolver(单向依赖避开循环注入),库里改过的 > 内置;模板停用 = 这类通知不发且如实回 notifySent=false。F-102 加入申请三处通知(提交→审批人、通过/驳回→学生)。F-103 导出完成/失败通知发起人,正文不放签名 URL(模板里原本写着 {下载链接},一并改掉)。F-104 自动催报从来没发出过任何一条(end_time = date::date 与库里的 23:59:59 永不相等,测试夹具用 SQL 摆的日期型恰好能匹配)+ 补 FR-1003 的「截止当日」。F-105 驳回达上限通知管理员(契约与注释都写了,代码只有一行日志)。F-106 字典/通知记录两个菜单没有任何角色持有(手册还写着去那里办)。F-107 6 个权限码挂到自己的接口。F-109 Map.of 不接受 null → 通知被吞成一行 warn。F-110/F-111 测试自身的两个坑。

验证:新增 e2e-m5-notify-wiring.py 43 条(改模板→真驳回→断言学生收到改过的那句、停用→一条不发、申请/审批/导出/催报/升级/菜单/权限各一节);verify-all --with-e2e → 9 项全过 0 跳过,38 个脚本 / 1620 条断言 / 0 失败。
This commit is contained in:
2026-10-08 14:46:11 +08:00
parent 4a5790d900
commit 9e78e1d239
28 changed files with 1800 additions and 90 deletions
+63
View File
@@ -2464,3 +2464,66 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
- `verify-all --with-e2e` → 见提交信息里的最终数字(37 个 E2E 脚本全绿)。
- E2E 总数 36 → **37**(新增 `e2e-m1-task-delete.py`),断言数相应增加。
---
## §一·三十二 接线检查:把"定义了但没接上"变成命令(2026-10-08)
> F-1~F-100 全部核销之后,这一轮只做一件事:**问"还有没有同一族的错"**。
> F-1(日志没人写)、F-31(微信登录不写日志)、F-50(表建好没人用)、
> F-52/F-67(契约定义了但链路没接上)是**同一个形状** ——
> 每一处单看都对(类在、表在、常量在、注释写得很清楚),
> 所以编译 / typecheck / lint / code review **全都不红**,只有"从入口走一遍"才暴露。
> 结论:**先把它变成命令**(`scripts/check-wiring.py`),再按输出逐条修。
> 本轮共修 **11 条(F-101~F-111)**,新增 E2E **43 条**,终验 `verify-all --with-e2e`
> → **9 项全过 / 0 跳过**(38 个 E2E 脚本 / **1620 条断言** / 0 失败)。
### 一、`check-wiring.py` 查五件事(进 `verify-all.py` 与 CI 的 check 阶段)
| # | 查什么 | 没接上的后果 |
|:--:|---|---|
| 1 | 权限码有没有地方校验(`@RequirePerm` **或**服务层 `permissionChecker`) | 权限码是死的 |
| 2 | 权限码有没有被任何内置角色分配 | **这个接口谁都调不了** |
| 3 | `ErrorCode` 的码有没有被引用 | 文档里有、代码里没有对应分支 |
| 4 | 站内信模板有没有发送点 | 模板页能改,但那条通知根本不发 |
| 5 | 菜单键 ↔ PC 端 `XtMenu` 双向核对 | 菜单永远不显示 / 前端有菜单没人看得见 |
**例外表写在脚本里**(连同理由):有意不接线的(二期功能、范围类权限、M4 等选型)
逐条登记并打印出来 —— **不靠下次再想一遍**,也不静默跳过。
⚠️ **检查器自己修了三次假阳性**(这段比结论更值得记):
① 只数 `@RequirePerm` → 把服务层守着的 `AUDIT_WITHDRAW` 报成"没人用";
② 按标识符数引用 → `JOIN_APPROVE` 撞上操作日志的动作码,**零发送点的模板被算成有引用**;
③ **注释里的提及被当成接线**(`ExportController` 类注释写着"权限只有两个:
`export:download` 与 `export:unmasked`",而代码从没校验过它)→ 改成**先去 Java 注释再数**。
> 静态检查的通用教训(F-36 的老话):**要能区分"代码"与"解释代码的文字"**,
> 而且写完要**自己验一遍会不会漏**(本轮拿一个故意写错的模板码试了一次,确认它真的会红)。
### 二、修掉的 11 条(每条都有 E2E 断言)
| # | 问题一句话 | 修法 |
|:--:|---|---|
| **F-101** | **FR-1002「通知模板可配置」只兑现了手工发送那一半** —— 驳回/通过/催报这些**自动**通知把文案硬编码在 Java 里,而 FR-1002 点名的 5 类**全是自动类** | 新增 `NotifyTextResolver`(单向依赖,避开循环注入):**库里改过的 > 内置**,与模板列表页同一份口径;`NotifyRequest` 加 `subjectCode/subjectVars/extraText`;**模板停用 = 这类通知不发** |
| **F-102** | 加入名单申请**一个通知都不发**(代码里写着"通知渠道未实现",而渠道早做好了) | 提交 → 通知该审批的人(班主任/副班主任 + 学校管理员兜底);通过/驳回 → 通知学生(含原因) |
| **F-103** | 导出完成/失败不通知(异步打包,用户只能守进度条) | 通知发起人;⚠️ **正文不放签名 URL**(站内信落库,链接几十分钟就失效)—— 内置模板里原本写着 `{下载链接}`,一并改掉 |
| **F-104** | ⚠️⚠️ **自动催报从来没发出过任何一条**:`end_time = date::date` 与库里的 `23:59:59` **永不相等** | 改 `end_time::date = #{date}::date`;并补 FR-1003 原文的**「+ 截止当日」**(0 天,文案与"最后一天"分开) |
| **F-105** | 驳回达上限只写一行日志,而契约说"**同时通知管理员**" | 新增 `AUDIT_ESCALATE` 模板 + 发给本租户学校管理员(按角色找,不写死账号) |
| **F-106** | 字典管理 / 通知记录**没有任何角色持有菜单键** → 页面做得好也点不到(**手册还写着"去那里办"**) | 给 `SCHOOL_ADMIN` 补 `dict` / `notify` |
| **F-107** | 10 个权限码没挂到任何接口(克隆/表单/材料/报考范围/整理器/租户…) | 6 个改挂自己的码;4 个范围类/二期开关进例外表 |
| **F-108** | `TASK_PUBLISH` / `TASK_DEADLINE` 零发送点 | **不做**(发布时刻没有收件人:学生要先扫码才绑账号,而发布前没人会扫)→ 登记例外 + 理由 |
| **F-109** | `Map.of(...)` 不接受 null → 拼模板变量抛 NPE → 被"通知失败不影响业务"吞成一行 warn(**驳回通知全丢而驳回照样成功**) | 新增 `NotifyRequest.vars(...)`(null 跳过);姓名优先取**名册**上的名字 |
| **F-110** | schedule 的断言数的是**全库**「审核超时」消息 → 几天后因别的老数据自己变红(代码一行没改) | 断言**限定到本次夹具**(跨租户扫描类功能的断言必须这么做) |
| **F-111** | 夹具 `ON CONFLICT (id) DO UPDATE` 不更新用户名 → 登录报"账号或密码错误"(看起来像哈希不对) | 固定用户名 + `username = EXCLUDED.username` |
### 三、这一轮最值得记住的三件事
1. **F-104 是被"夹具不像真东西"盖住的**:测试用 `update collect_task set end_time = CURRENT_DATE + 3`
摆截止日(存进去是 00:00:00 的日期型),恰好能匹配那个写错的 SQL;
而真实接口存的是 `23:59:59` → **生产上一条都不会发**。
与 F-99「假 JPEG」是同一族:**夹具不像真东西,绿灯就不算数**。
现在这条断言**走真实接口建任务**(`e2e-m5-notify-wiring.py`)。
2. **"改了就生效"必须验到"发出去的那句话"上**:F-101 的关键断言不是"接口返回 0",
而是"**改掉模板 → 触发真驳回 → 查库看学生收到的那条标题**"。
3. **降级逻辑越完善,接线类的错越安静**:F-109 的 NPE 被 try/catch 吞掉、
F-104 的沉默被 `if (!tasks.isEmpty())` 挡住、F-106 的错只在"点菜单"时才出现 ——
所以"从入口走一遍"这件事只能靠 E2E,不能靠 review。