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:
@@ -439,6 +439,25 @@
|
||||
| F-99 | ⚠️ **补测"没测过的场景"时,压测工具自己连续撒了 8 次谎** —— 它们的共同点是"**度量/夹具出问题时,现象长得像产品缺陷**"。① **夹具前置没断言**:单独跑 `--only upload` 时 40 张图**全部**报「没有草稿」—— 因为上传需要先经门禁绑定,而以前它总是跟在门禁场景后面跑、"恰好"能跑;② **假 JPEG**:夹具图是"JPEG 头 + 随机字节",能过扩展名与大小校验,但服务端**解码即失败**,走的根本不是压缩路径 —— 换上真图后立刻量到 2850KB → 1329KB(**47%**);③ **猜列名**:`collect_export_file` 的列是 `part_no / file_name / file_path`,我按直觉写成 `part / name / path`,SQL 直接报错;④ **判据取错返回值**:`upload_file` 返回的是 `(HTTP 码, body)`,我拿 HTTP 码当业务判据,把 `code: 0` 的成功报成"串行基线失败"(与 F-84 / F-96 同一类,**第三次**);⑤ **跨租户同名账号**:`select id from sys_user where username='admin'` 命中的是**别人家**的 admin(库里有 50 多个同名),于是"初审人"被设成跨租户用户 id —— 正确做法是**从 token 的 `sub` 取 uid**;⑥ **不查接口返回码**:`PUT /tasks/{id}/orgs` 被服务端拒(`headUserId` 必须是**真持班主任角色**的账号)而我没查,"发布"失败也没查,最后学生端整齐地报 `40008 任务已截止、已归档或已暂停`,排查方向整个错了;⑦ **PowerShell `Select-Object -First N` 会提前停止读取上游**:被截断的进程**仍然活着**,看起来就是"卡住",我为此白等了十几分钟 —— 而外层工具又把输出缓冲到命令结束,于是"慢"与"被堵"根本分不开;⑧ **清理只删库行不删磁盘文件**:压测跑几轮就在 `backend/data/uploads` 攒下几百 MB | ①在上传/导出场景里**显式绑定 + 断言前置**(不成立就报"前置不成立",而不是"上传失败");②`make_img` 改用 Pillow 生成**真 JPEG**(噪声图,纯色量不出 CPU 成本);③按 DDL 写列名;④改判 `body["code"]`;⑤新增 `uid_of(token)`(解 JWT 的 `sub`);⑥新增 `call_ok()`:夹具里每个接口调用**都断言业务码为 0**;⑦长跑/压测/起服务**一律把日志落盘**,不用管道截断(与 F-98 同一个手段);⑧`cleanup()` 先取出文件路径再删行,**连磁盘文件一起删**(存储是七牛时那些路径本地不存在,是安全的 no-op)。**教训:补测的价值一半在数字,另一半在"原来夹具一直不成立"** —— 而后者只有在**断言前置**之后才会暴露 |
|
||||
| F-100 | ⚠️ **门禁"成功侧上限"卡在年级量级,会挡住全校集中填报**(F-92 的第二次复发,量级更大):契约 §1.6 只规定**失败侧**(同 openid 10 次/小时、同 IP 20 次/分钟),成功侧 200 次/分钟是本项目自己加的(防"拿匹配接口批量取数据"),而当初注释里写的本意是"一个 200 人的年级同时扫码也不该被挡"。实测 **250 个不同学生同时核验(全部正确)→ 213 成功、37 被挡**,提示「当前网络访问过于集中,请稍等 1 分钟再试」。"全校在同一个出口 IP 集中扫码"(开学第一课集体填报)正是会发生的场景,而**这些人没有一个做错了什么** —— 与 F-92「教室被挡住」是同一类,只是从"一个班"放大到"一所学校" | 上调 `IP_OK_LIMIT_PER_MINUTE` **200 → 600**(依据:一所学校一次集中填报的峰值;它仍然是限速,防枚举靠的是失败侧那两个计数器,**原样未动**)。**复测**:250 人 **250/250**、500 人 **500/500**(500 并发 p50 1.4s / p95 2.5s,是排队不是限流),`e2e-m3-gate.py` 仍 **29/29**。已在 `MpRosterService` 常量注释里写明依据与"改之前先量"的命令,并同步 `docs/01` E2、`docs/09` §9.3。**教训:自己加的"防御性限额"也要过一遍真实场景的量级 —— 否则它与它要防的东西一样,都是事故** |
|
||||
|
||||
> **以下 F-101 ~ F-111 是"把 F-1~F-100 全部核销之后"新起的一轮**。
|
||||
> 起点是一个问题:**还有没有同一族的错?** —— F-1(日志没人写)、F-31(微信登录不写日志)、
|
||||
> F-50(表建好没人用)、F-52/F-67(契约定义了但链路没接上)都是**同一个形状**:
|
||||
> **每一处单看都是对的**(类在、表在、常量在、注释写得很清楚),
|
||||
> 所以编译、typecheck、code review **全都不会红**,只有"从入口走一遍"才暴露。
|
||||
> 于是先把这一类固化成命令(`scripts/check-wiring.py`),再按它的输出逐条修。
|
||||
|
||||
| F-101 | ⚠️⚠️ **FR-1002「通知模板可配置」只兑现了一半,而且是不点名的那一半**:模板页能改文案、`POST /notify/send`(**手工**发送)也确实按模板渲染 —— 但驳回、审核通过、催报、临期这些**自动**通知把文案硬编码在各自的 Service 里(`AuditService` 拼 `"你的材料被退回了"`、`UrgeService` 拼 `"请尽快完成填报"`…)。而 FR-1002 点名的 5 类通知(**任务发布 / 临期提醒 / 驳回通知 / 审核通过 / 导出完成**)**全是自动类**。后果很具体:管理员把「驳回通知」改成自己学校的说法,学生收到的还是原来那句,而**界面上显示的是改过的**(模板列表页读的正是被改过的那行)—— 这种"显示一套、发出去另一套"没有任何测试会红 | 新增 `NotifyTextResolver`(单向依赖,避免 `NotificationService` ↔ `NotifyTemplateService` 循环注入):**库里改过的 > 内置**,与模板列表页**同一份口径**;`NotifyRequest` 增加 `subjectCode` / `subjectVars` / `extraText`(模板之外的"这一次"的补充:班级、老师附言、最后一天提醒);驳回/通过/催报/升级/加入申请/导出完成**全部改走模板**。**模板被停用 = 这类通知不发**(那正是开关的意义),并如实回 `notifySent=false` + `needManualNotice=true`。验证:新增 `scripts/e2e-m5-notify-wiring.py`(**43 条**,含"改掉模板 → 触发真驳回 → 断言学生收到的是改过的那句"与"停用 → 一条都不发") |
|
||||
| F-102 | ⚠️ **加入名单申请一个通知都不发**:`JoinService` 里三处写着 `log.warn("…没有发出通知(通知渠道未实现)")`,而通知渠道**早在 M5 就做好了**(`NotificationService`),只是没人接上;`CollectMessage.BIZ_JOIN_AUDIT=7` 零使用、`JOIN_SUBMIT` / `JOIN_APPROVE` 两个内置模板零发送点。后果:学生提交申请后**老师不知道**(只能自己想到去翻审批页),审批通过/驳回后**学生不知道**(他会以为申请丢了而反复重提,而每人每任务只能提 2 次) | 提交后通知**该审批的人**(`collect_task_org.head_user_id` + `deputy_user_ids` + 本租户学校管理员兜底 —— 不是"所有有 `join:audit` 权限的人",一个学校 50 个班主任等于推给没人);通过/驳回后通知学生(含驳回原因)。验证:`e2e-m5-notify-wiring.py` 第 [3] 节 |
|
||||
| F-103 | ⚠️ **导出完成后不通知**(`BIZ_EXPORT=6` 零使用、`EXPORT_DONE` 模板零发送点):打包是**异步**的(几百份材料十几秒到几分钟),用户只能守着进度条;失败也不通知,于是"包永远不会好"这件事没人知道 | `pack()` 成功与 `markFailed()` 各发一条给**发起人**。⚠️ **正文里不放下载链接**:七牛是私有空间、链接是**签名 URL**,而站内信是**落库**的 —— 把签名链接写进正文就等于"URL 入库",与铁律直接冲突(那个链接几十分钟后就失效)。内置模板里原本写着 `{下载链接}`,**一并改掉**。验证:`e2e-m5-notify-wiring.py` 第 [4] 节(含"正文里没有 http"断言) |
|
||||
| F-104 | ⚠️⚠️ **自动催报从来没有发出过任何一条通知**(FR-1003 的 P0 功能实际是"零"):`tasksEndingOn` 写的是 `end_time = #{date}::date`,而 `collect_task.end_time` 存的是**当天 23:59:59**,`'2026-10-08'::date` 是 `00:00:00` —— **两者永不相等**。它活了这么久是因为**测试夹具用 SQL 摆截止日**(`update collect_task set end_time = CURRENT_DATE + 3` 存进去的是 00:00:00 的日期型),那种形状恰好能匹配;而日志里只有 `if (!tasks.isEmpty())` 的沉默 | SQL 改为 `end_time::date = #{date}::date`;同时补上 **FR-1003 原文里的「+ 截止当日」那一档**(`URGE_DAYS_BEFORE` 由 `{7,3,1}` 改 `{7,3,1,0}`)—— 缺的恰恰是"再不做就来不及"的那天,且 0 天与 1 天的文案必须分开("今天是截止日,24 点前提交" vs "今天是最后一天")。验证:`e2e-m5-notify-wiring.py` 第 [5] 节(**走真实接口建任务**,end_time 自然是 23:59:59) |
|
||||
| F-105 | ⚠️ **驳回次数达上限时,"需要管理员介入"没有任何人知道**:契约 §11.4 写「`escalated: true`,**同时通知管理员**」,代码注释也写着"站内信也发给管理员",而实际只有一行 `log.warn` —— **日志不是通知**:学生看到被驳回、班主任看到"已达上限",管理员什么都没看到 | 新增 `AUDIT_ESCALATE` 模板 + `notifyEscalated()`:发给**本租户的学校管理员**(`UserMapper.userIdsOfRole`,按角色找而不是固定账号);`40010 REJECT_LIMIT_EXCEEDED` **不回错误码**(驳回本身成功了,这是响应字段而不是错误)—— 已在例外表里写明理由。验证:`e2e-m5-notify-wiring.py` 第 [6] 节(连驳 3 次) |
|
||||
| F-106 | ⚠️ **两个做好的页面**(字典管理、通知记录)**从菜单点不到**:PC 端菜单可见性是拿 `/auth/me` 返回的 `menus[]` 过滤 `XtMenu` 的(`buildXtRoutes`),而 `BuiltinRole` 里**没有任何角色声明 `dict` / `notify`** → 页面、路由、接口都在,菜单里永远不出现。这是 F-78「待建页在说谎」的镜像:**那次是"说没做其实做了",这次是"做了但没人看得见"**。⚠️ **手册早就把它们写进去了**(`docs/10` 的"遇到这事去哪办"表里写着「加一个下拉选项 → 配置 → 字典管理」「查通知到底发出去没有 → 收集管理 → 通知记录」)—— 也就是说**用户按手册找不到那个菜单**,而客服会收到"我这里没有这一项" | 给 `SCHOOL_ADMIN` 补这两个菜单键;`check-wiring.py` 增加"菜单键 ↔ XtMenu"的双向核对(角色声明了但前端没有 / 前端有但没角色声明,两种都报)。验证:`e2e-m5-notify-wiring.py` 第 [7] 节读真实 `/auth/me` |
|
||||
| F-107 | ⚠️ **10 个权限码定义了却没有任何地方校验**(`task:clone` / `form:manage` / `material:manage` / `plan:import` / `cleanup:run` / `tenant:view` / …):接口挂的是更宽的 `task:manage`,于是"克隆任务"和"改任务名"是同一个权限;`PUT /tenants/{id}` 与"租户用量"甚至挂着 `log:view:all`(复制粘贴留下的)。**权限码写宽了不会报错,只会让本该分开的能力分不开** | 6 个能明确对应的接口改挂它自己的码(克隆 → `task:clone`、表单 → `form:manage`、材料 → `material:manage`、报考范围 → `plan:import`、整理器 → `cleanup:run`、租户读 → `tenant:view`、租户改 → `tenant:manage`);其余 4 个(`roster:manageOwn` / `progress:viewOwn` / `submission:own` / `export:unmasked`)是**范围类或二期开关**,写进 `check-wiring.py` 的例外表并说明理由。验证:`e2e-m5-notify-wiring.py` 第 [8] 节(班主任克隆/整理器 → `40301`,管理员 → 成功) |
|
||||
| F-108 | 📌 **两个内置模板"零发送点"是刻意的**(`TASK_PUBLISH` / `TASK_DEADLINE`,连带 `BIZ_TASK_PUBLISH=1`):"发布即通知学生"在本系统的流程里**没有收件人** —— 学生要先扫码核验才会绑定账号,而发布之前没有人会去扫(扫码落地页对草稿任务显示"本次收集尚未开始")。发出去只会是 0 条。**所以不做**,并在 `check-wiring.py` 的例外表里写明原因(连同"什么时候该重新考虑它") | 不做,登记例外。**理由要留痕**:否则下一个人看到模板里有它就以为该发,而实现出来是个永远 0 条的功能 |
|
||||
| F-109 | ⚠️ **`Map.of(...)` 拼模板变量 → 一条通知都没发**:`Map.of` **不接受 null 值**,而"这个学生查不到姓名"完全正常(学生账号是扫码核验时建的,`real_name` 可能还没填)→ 抛 NPE → 被外层"通知失败不影响业务"的 try/catch 吞成**一行 warn**,于是**驳回通知全丢,而驳回本身照样成功**。这是 F-25「被吞掉的异常」的第二次:**降级逻辑越完善,这类错越安静** | 新增 `NotifyRequest.vars(...)`(null 值直接跳过 —— 模板渲染对"没有值"的处理本来就是渲染成空);姓名改为**优先取名册上的名字**(那才是"他在这个任务里叫什么")。发现方式:`e2e-m5-notify-wiring.py` 第一次跑就红了(标题对、正文里姓名是空的) |
|
||||
| F-110 | ⚠️ **一条断言写成了"全库计数",于是它会在几天后突然变红**:`e2e-m7-schedule.py` 的"刚提交的不该被提醒"数的是**全库**的「审核超时」消息,而超时扫描是**跨租户**的 —— 库里只要存在别的老填报单(几天前别的脚本留下的),它就会照样提醒。本轮真的红了,而**代码一行都没改**(那些单子的 `submit_time` 终于超过 48 小时了) | 断言**限定到本次夹具的收件人与任务**(`user_id` + 正文里的任务名)。**教训:跨租户扫描类功能的断言必须"限定到自己的夹具"** —— 否则它测的是"库里干不干净",而那是另一件事(与 F-42「cleanup 静默失败污染后续脚本」同一族) |
|
||||
| F-111 | ⚠️ **`ON CONFLICT (id) DO UPDATE` 不更新用户名 → 看起来像密码错**:E2E 夹具按 id upsert 测试账号,只更新了 `real_name`,于是上一轮残留的行保留着**旧用户名**,而脚本按新用户名登录 → `40012 账号或密码错误`。第一反应会去查密码哈希(而它是对的) | 夹具改为**固定用户名 + `username = EXCLUDED.username`**(幂等);已写进 `e2e-m5-notify-wiring.py` 的注释 |
|
||||
|
||||
---
|
||||
|
||||
## 七、建议的执行顺序(按依赖,不按重要性)
|
||||
@@ -544,6 +563,7 @@ M7 收尾 52 人日、M5 剩余前端约 31 人日。**关键路径仍是"审核
|
||||
| 2026-09-30 | **补测 `docs/09` §9.5 的"没测过"项(上传并发 / 名册导入规模 / 导出大包 / 长跑稳定性)**,`scripts/load-test.py` 加 `--base` / `--fixture-only` / `--files-per-user` / `--split-mb` / `--soak` / `--rps`,上传夹具换成**真 JPEG**,并新增 `export-big` 场景(造 N 份已通过的单 → 打包 → 逐卷验证 ZIP)。结果:**上传 20 并发 40/40**(串行 321ms / 并发 p50 620ms、p95 801ms;2850KB → 1329KB = **47%**)、**5000 行名册导入 20.2s**(RSS 782MB)、**导出 150 人 194.6MB 打包 13.2s**、**分卷 4 卷每卷可单独解开**、**长跑 60 分钟**(RSS / PG 连接数趋势见 `docs/09` §9.6)。**同一轮里压测工具自己撒了 8 次谎**(前置未断言 / 假 JPEG / 猜列名 / 判据取错返回值 / 跨租户同名账号 / 不查返回码 / 管道截断 / 清理不删文件)→ 新增 **F-99**,并把结论写进 `docs/09` §9.1 / §9.4 / §9.5 / §9.6 |
|
||||
| 2026-09-30 | **17 项决策全部执行(A 组改契约 / B 组补代码 / C 组备案 / D 组清环境)**:① **契约 V2.6**:§2.7 `notifyBizType` 5/6 修正(F-30)、`auditAction` 补全 7 动作(F-7)、§3.6/§9.5 `source` 改建表口径(F-38/39)、§9.5 重写为「上传前置检查」+ §9.6 `process` 改语义 + §9.9 标注缓做(F-3/71/72)、§11.4 撤回约束补状态 2、§12.1 `validation/run` 改同步(F-51)、**新增 §17 实现补充与偏差记录**(41 接口六类 + D-1~D-12,F-4/10/26/65);② **代码**:规则模板 FORM 真正可套用 + 存模板也存表单(F-57/64,`e2e-m7-taskconfig` 75/75)、已归档任务可删——级联**物理删** 14 表 + 留痕(F-14,新增 `e2e-m1-task-delete` 19/19)、催报分钟级限流 10 次/分/任务(F-29,`e2e-m5-urge` 42/42)、学生微信登录写 `sys_login_log`(F-31,`WechatLoginLogger`,`e2e-m3-h5-auth` 24/24)、三个纯函数最小单测 12 条(F-9);③ **备案**:通知降级链一期维持两级(F-28→D-11)、国标职业库待客户提供(`docs/01` 新增 **E4**,F-34);④ **环境**:`xt-minio` 已删(F-2)、本地清 49 e2e 租户/147 测试组织/84 演示旧任务(3714 行 + 27 文件,**在用组织保护**保住演示态,F-5)、七牛桶 `xiaotutongxue/` 前缀 **2036 个对象/116MB 清空**(删前核实库中零引用,F-93,新增 `dev-clean-qiniu.py`)——至此 **F-1~F-100 无未决项,剩余全部等外部输入** |
|
||||
| 2026-09-30 | **长跑稳定性测完 + 门禁成功上限 200→600(F-100)+ 磁盘清理(F-5 落地)**:① 长跑 51 个样本 / 15300 请求 / **0 失败**,RSS 前 1/3 均值 603.9MB → 后 1/3 582.9MB(**-3.5% 平稳**)、PG 连接 17.0 → 6.2(**定时任务高峰后正常回落**)、p95 62ms 纹丝不动 → 写进 `docs/09` §9.5.4;② **门禁成功侧上限实测卡在年级量级**(250 人 → 37 被挡)→ 上调 600,复测 250/250、500/500,`e2e-m3-gate` 29/29(**F-100**),同步 `docs/01` E2、`docs/09` §9.3;③ `dev-clean-e2e.py` 扩展(`--demo-tasks` / `--orphan-files` / `--only-orphan-files`,业务表按 `information_schema` 动态枚举),**已清磁盘孤儿文件 255 个 / 275.4MB**(uploads 276MB → 0.3MB);e2e 租户与"E2E 24数控1班"组织**刻意不删**(当前演示态挂在其中一个班下面,已记录原因)——问题清单累计 **F-1~F-100** |
|
||||
| 2026-10-08 | **新增接线检查 `scripts/check-wiring.py` + 修 F-101~F-111(11 条,全部带 E2E)**。起点:F-1/F-31/F-50/F-52 是**同一个形状**(每一处单看都对 → 编译/lint/review 全不红 → 只有从入口走一遍才暴露),所以先把它变成命令:查**权限码有没有地方校验、错误码有没有被引用、站内信模板有没有发送点、`bizType` 有没有被用过、菜单键 ↔ PC 端 `XtMenu` 是否对得上**,查不到的逐条由人判断"忘记接线"还是"有意留的"(有意的写进脚本上方的例外表 + 理由,**不靠下次再想一遍**)。⚠️ 检查器自己也修了三次假阳性:① 只数 `@RequirePerm` → 把服务层 `permissionChecker.require` 守着的 `AUDIT_WITHDRAW` 报成"没人用";② 按标识符数引用 → `JOIN_APPROVE` 撞上操作日志的动作码,**零发送点的模板被算成有 3 处引用**;③ **注释里的提及被当成接线**(`ExportController` 的类注释写着"权限只有两个:export:download 与 export:unmasked",而代码从没校验过它)→ 改为**先去 Java 注释再数**(F-36 的老教训:静态检查要能区分"代码"与"解释代码的文字")。**修掉的真问题**:F-101 自动通知改走可配置模板(FR-1002 原来只兑现了手工发送那一半)、F-102 加入申请三处通知(代码里还写着"通知渠道未实现",而渠道早做好了)、F-103 导出完成/失败通知(正文**不放签名 URL**,模板里原本写着 `{下载链接}`)、**F-104 自动催报从来没发出过任何一条**(`end_time = date::date` 与库里的 `23:59:59` 永不相等,而测试夹具用 SQL 摆的日期型恰好能匹配)+ 补 FR-1003 的「截止当日」那一档、F-105 驳回达上限通知管理员(契约与注释都写了,代码只有一行日志)、F-106 字典/通知记录两个菜单**没有任何角色持有**(页面做得再好也点不到)、F-107 6 个权限码挂到自己的接口(克隆/表单/材料/报考范围/整理器/租户)。**工具链**:`check-wiring.py` 进 `verify-all.py`(第 5 项机械校验)与 CI 的 check 阶段。**验证**:新增 `scripts/e2e-m5-notify-wiring.py`(**43 条断言**,全部打真实接口:改模板→真驳回→断言学生收到改过的那句、停用→一条不发、申请/审批/导出/催报/升级/菜单/权限各一节),并修 F-110(schedule 断言全库计数 → 限定到夹具,它在几天后会自己变红)/F-111(夹具 upsert 不更新用户名 → 看起来像密码错) |
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user