Files
xiaotutongxue/docs/08-完成度盘点与问题清单.md
lovife a9b2dd2a16 docs: 登记 F-125~F-130 并同步脚本数 37→38
三个 P0 本轮只测不改、先记账:F-125 学生被驳回后改不了任何东西(改字段/传材料/
删材料全 40030、撤回 40001,而重新提交是允许的,四处信号都说他能改)、
F-128 扩展字段存得进读不出、必填扩展字段会让整个任务永久无法提交(内置表单一个
扩展字段都没有,所以从来没有数据走过这条路)、F-130 小程序端登录断成两截
(前端从没调过 /mp/login,后端 scene 带 taskId 必然 500)。

README 补 docs/11 的入口,AGENTS.md 补小程序端现状与 38 个脚本的新口径,
问题清单补 F-126/F-127/F-129 三条与 F-125/F-128/F-130 的修法方向。
2026-10-14 15:40:18 +08:00

169 KiB
Raw Permalink Blame History

完成度盘点与问题清单(施工计划)

目的:给"还剩什么、先做哪个、每个问题怎么改"一份可直接照着做的清单。 口径:以代码与实测为准(文档是需求源,但与代码冲突处以代码为准,冲突项在 §五 单列)。 盘点时间:2026-09-29 · 基线提交 5213bc2

⚠️ 2026-10 需求收敛(本文件以下数字与条目的最新口径)

客户要求收敛范围后,代码 / 库 / E2E / 原型 / 文档已同步,本文件的盘点结论随之变化:

项 本文件写的(当时) 现在
契约接口 129 119
错误码 59 54
权限码 30 28
内置角色 6 4
PC 业务菜单 12 10
小程序页面 10 9
原型屏 32 29
E2E 脚本 39 37(断言 1698 → 1617 条,0 失败 0 跳过);2026-10 新增 e2e-m3-flow.py → 38 个 / 1712 条

删除的三块:① 内置角色「副班主任 DEPUTY_TEACHER」「审核员 AUDITOR」; ② 历史整理器(module/cleanup,5 接口 + cleanup:run + collect_cleanup_job 表 + PC 页 + 原型屏); ③ 加入申请(module/join,5 接口 + join:audit + collect_join_request 表 + PC 页 + 学生端 pages/join

  • 错误码 40037/40038/40039/40040/40042 + 两个站内信模板)。 另有 collect_task_org.deputy_user_ids 整列删除(审核范围三级回退 → 两级), 以及 二期整体取消(社会收集 / 社会评价 / 专项职业能力 / 数据范围可配 …一律不做)。

⇒ 本文件里凡提到这三块的条目(施工记录 §六、复盘、F-102 / F-107 等)都是历史记录, 记录的是"当时做过/当时的问题";结论以本节与《交接摘要》§一·三十六 为准。 相关条目已在原位加注(未删行,便于对账)。


一、盘点方式(复现命令)

# 盘点项 命令 / 依据 本次结果
1 后端回归 依次跑 scripts/e2e-*.py(18 个) 753 条断言、0 失败
2 接口实现度 python scripts/check-api-parity.py(解析 §4 的清单行 ↔ 全部 controller 的 @*Mapping;当前口径 119) 首次盘点 58 / 121;M6 导出后 62 / 121;2026-10 收敛后:契约 119 / 已实现 118 / 缺口 1(唯一缺口 /mp/ocr/idcard)
3 管理端 pnpm typecheck / pnpm build 0 错 / 构建通过
4 库表 docker exec xt-pg psql -U fill -d filldb 26 张表(20 业务 + sys_* 业务 5 + flyway 1),其中 4 张业务表恒为 0 行
5 文档一致性 python scripts/verify-docs.py 20 项全通过(含本文件)
6 工作区 git status --short 干净;xt-server-1.0.0-SNAPSHOT.jar 已是最新提交的构建

⚠️ 计数口径的教训:第一版盘点用「@GetMapping」之类的短名匹配,漏掉了 FillController 里用全限定注解(@org.springframework.web.bind.annotation.PostMapping) 写的 3 个接口,于是 /mp/submissions 系列被误报成"未实现"。 结论:数接口这类事必须按契约清单逐条比对,不能按写法猜 —— 与《交接摘要》§七 「脚本审计前先验证脚本本身」是同一类错误。

现在这件事固化成 scripts/check-api-parity.py(可复现): 它按 §4 总览逐条比对代码,结束时自检"契约必须恰好 119 个"(2026-10 由 129 改准) 这样"文档改了表格格式导致解析漏项"也会当场暴露,而不是给出一份看着对的错报告。


二、总体完成度

层 已完成 未完成 完成度
后端接口 118 个(M0/M1/M2/M3 主链路 + M5 审核闭环与班主任看板 + M6 导出、分卷与过期清理 + 学生端我的任务/撤回/进度 + 通知消息与催报 + 名册改删 + 操作/登录日志 + 字典管理 + 代传与教师端文件访问 + 校验问题清单 / 通知模板与手工发送 / 微信回调 + 任务克隆 / 任务统计 / 规则模板 / 名册导入模板 / 审核统计 / PC 端代传 + 开放接口(认定机构对接) + 小程序登录与绑手机号 / 组织导入 / 租户用量 / 文件视图·直传凭证·批量处理);已删除:B1 加入申请、历史数据整理器(2026-10) 1 个 99%
后端里程碑 契约 §4 的 119 个接口里 118 个已实现(2026-10 收敛后的口径;原为 129 里 128) 只剩 M4 的 /mp/ocr/idcard(等 OCR 供应商选型)+ 前端页面与 CI/CD 约 7.5/8
管理端(PC) 登录/工作台/组织/账号/租户/收集任务 + 3 个配置抽屉 + 名册与报考范围 + 审核台 + 导出中心 / 通知记录 / 操作日志 / 字典管理 / 角色与授权(纯 Vue 3 + Naive UI,2026-10 重搭);已删除:加入申请页、历史整理器页(2026-10) 表单设计器已上线 约 100%
学生端(H5/小程序) 落地页、门禁核验、填报、我的任务 / 撤回 / 进度 / 消息中心(加入申请页已删除,门禁失败只提示联系老师) 临期提醒页、催报 约 90%
班主任端(小程序) 看板 / 审核卡片流 / 详情与驳回(右滑通过、左滑不绑驳回)+ 代传材料 / 催报 / 学生材料预览(后端与 MP 页面均已就绪) 临期提醒入口 约 95%
基础设施 PG 16 + Redis 7 + 七牛(provider=qiniu 已验)、Flyway V1~V19、本地存储兜底、导出产物落盘 + 流式下载 + 分卷与过期清理、38 个 E2E 脚本(断言数以 verify-all 为准)+ 4 个机械校验脚本 CI/CD、test/prod 环境、压测、灰度 约 58%

契约接口实现度(按 §4 分类)

分类 契约 已实现 缺口
4.1 认证与账号 13 13 0
4.2 组织与租户 15 15 0
4.3 任务与配置 30 27 3
4.4 名册 12 12 0
4.5 填报端 · 小程序 16 15 1
4.6 班主任移动端 10 10 0
4.7 审核 9 9 0
4.8 校验 / 导出 / 通知 12 12 0
4.10 开放接口 4 0 4
合计 121 120 1

三、已完成(可用、有证据)

里程碑 内容 证据
M0 骨架、58 错误码、多租户默认拒绝、软删除、AES-GCM/HMAC/RSA、JWT、权限码在代码里;租户开通、组织树、账号全套(含 Excel 导入)、PC 登录/验证码/改密 e2e-m0 36、e2e-m0-user 55
M1 任务 CRUD 与状态机(发布 5 项阻断校验,force 绕不过)、覆盖班级与班主任(软移除+历史行复用)、材料项定义(3 内置模板)、表单 schema 版本化(40025/40026)、填报二维码 71 / 48 / 68 / 48 / 32
M1 前端 收集任务列表、配置班级抽屉、材料规则界面、表单设计器 上述 E2E + 浏览器实测
M2 名册导入(表头探测/体检报告/APPEND‑REPLACE/dryRun)、名册分页、报考范围聚合与 4 类体检、FR‑311 追加告警、门禁匹配引擎(原子绑定)、H5 微信授权、租户域名(默认关闭、能力保留) 67 / 38 / 29 / 23 / 6
M3 命名规范化引擎、压缩引擎(五档 + EXIF 转正 + 二分降质)、上传即规范(默认不留原始件)、草稿自动存、提交与 VAL 红黄绿校验 39 / 27 / 31
存储 七牛私有空间签名链接、本地 provider=local 兜底 17
M5 后端 待办/详情/通过/驳回/批量通过(服务端强制只带绿色)/撤回/时间轴/审核端文件通道;「权限≠范围」用 1 = 0 守死;红黄计数现场重算并回写 87
学生端 落地页、门禁核验、填报页(条件级联 + 材料上传 + 前端压缩 + 提交清单 + 材料预览) H5 浏览器实测 + e2e-m3-*

四、未完成(按"能不能上线"排序)

⚠️ 截至 2026-09-30,剩下的全部是外部依赖或(当时的)二期范围 —— 没有一条是"我们还没写"。 2026-10 更新:其中的"二期范围"已整体取消(不再排期),只剩外部依赖(OCR 供应商 / 小程序类目 / union_id / 真机验证)。 所以每条都指向 docs/01-客户与机构确认函.md 的具体编号:卡在谁那里、要谁做什么。

剩项 卡点 找谁 编号
P0-1 小程序类目资质 教育类目可能要办学许可 法务 / 商务 docs/01 C1(第一轮已问,等回执)
P0-4 H5 与小程序两个 openid 需微信开放平台账号并绑定两个 appid 才有 union_id 商务推动 + 我方 0.5 人日 docs/01 E1(第二轮新增)
P1-1 M4 识别与校验(7 个接口) 需先定 OCR 供应商(含数据出境合规) 商务比价 + 我方技术评估 docs/01 D1
P2-8 小程序端真机验证 需要真机与体验版 客户提供设备 docs/01 D6
P2-6 二期功能 一期范围外(已约定) 客户定启动时点 docs/01 E3

📌 union_id 那条最容易漏:字段(sys_user.union_id)与方案都已就位、 注释也写了,但全仓库零读写(683 个用户全为 null)—— 它不是"没设计",而是"没有它做不了"。两条填报腿不能同时上线,就卡在这一步。

📌 不要在没有这些输入的情况下去"顺手实现":M4 按某家供应商写死,换供应商要重写。

4.1 P0 · 阻断学生开始填报 / 阻断交付

# 未完成项 对应任务/契约 为什么是 P0
P0-1 小程序类目资质(C1) M7-10 前置 开发版/体验版学生都扫不开,只有正式版能扫 —— 它决定"能不能开始收"(→ docs/01 C1)
P0-2 导出打包引擎与交付物 + 导出中心 UI → ✅ 已完成:views/export/index.vue 有前置检查清单(canExport=false 时禁用按钮、不弹警告放行)、进度、产物下载(带 token 取 blob);scripts/e2e-m6-export.py 60 条、e2e-m6-export-split.py 35 条 M6-05、M6-06 —
P0-3 审核前端(管理端终审台 + 班主任端卡片流) → ✅ 已完成:管理端审核台 + frontend-mp/src/pages/audit/audit.vue 实现了原型铁律(右滑通过 / 驳回必须点按钮,左滑什么都不做),另有 audit-detail / dashboard / messages / tasks / progress 五个小程序页面 M5-07~11 —
P0-4 H5 与小程序两个 openid 架构前置 两条腿都上线时同一学生会被当成两个人(撞 40072),必须先绑开放平台拿 union_id 并按它认人(→ docs/01 E1,字段与方案已就位、零读写)

4.2 P1 · 影响验收与可用性

# 未完成项 对应任务/契约
P1-1 M4 识别与校验(形态 / OCR / VLM / 串档)—— 现在唯一的大块(§4.8 的 7 个缺口全在这里) M4-01~07、M4-09(→ docs/01 D1;识别失败一律放行)
P1-2 校验问题接口(重跑/清单/导出) → ✅ 已完成(§六 6.1 第 6i 项)。剩余:M4 的识别类校验本身(等 D1 选型) §12.1~12.3
P1-3 通知与消息中心、订阅额度、催报 ✅ 已完成(§13.5 三个 + §13.6 一个 + §8.6/§10.7 催报四个 + §13.3 通知记录一个)。剩余:§4.6 的教师端文件访问(/mp/teacher/files/{fileId})—— 见 F-28:钉住降级链后两级的不是代码量,而是"短信/公众号通道还没有商务结论"
P1-4 B1 加入申请(客户唯一功能增量) → 曾完成,2026-10 需求回退已整体删除 —
P1-5 学生端「我的任务列表 / 撤回 / 进度与问题清单」 → ✅ 已完成:pages/tasks(含「撤回修改」,canWithdraw 由服务端算)+ pages/progress;scripts/e2e-m3-mytask.py 42 条 §9.1、§9.11、§9.12
P1-6 班主任端看板与文件访问 → ✅ 已完成(§六 6.1 第 6g 项:看板 / §10.6 代传 / §10.7 催报 / §4.6 学生材料预览) §10.1、§10.6、§10.7
P1-7 历史数据整理器(差异化获客功能) → 曾完成,2026-10 已整体删除 —
P1-8 操作日志与登录日志查询、字典管理 → ✅ 已完成:操作日志真的在写(sys_operation_log 实测 523 条,最近一条是几分钟前;F-1 已在前面几轮修掉),写入口统一走 OperationLogService;views/log 与 views/dict 均已可用 M7-03/04/05、§13.3、§13.4
P1-9 任务统计与数据看板 → ✅ 已完成(§六 6.1 第 6j 项 + 本轮 6o 的工作台看板页) §7.11、M7-01/02
P1-10 名册改/删、导入模板下载 → ✅ 已完成:RosterEditService(改姓名/证件号/删除,都写操作日志)、名册导入模板下载(表头固定、必填列标红、带 9 条规则说明页);scripts/e2e-m2-roster.py 67 条 §8.3、§8.4、§8.5
P1-11 M1-05 规则模板;任务克隆 → ✅ 已完成(§六 6.1 第 6j 项;另补了契约漏掉的「保存为模板」,见 F-62)。剩余:模板的 FORM 部分(F-64) §7.13、§4.3 的 /tasks/{id}/clone
P1-12 代传材料(教师端/审核端) → ✅ 已完成(§六 6.1 第 6g 项) M5-06、§10.6、§11.7
P1-13 开放接口(认定机构对接) → ✅ 已完成(§六 6.1 第 6k 项;另补了契约漏掉的接入方管理,见 F-67) §14 的 4 个接口(HMAC 加签)
P1-14 学生端 /mp/login(小程序端登录)+ 手机号绑定 → ✅ 已完成(§六 6.1 第 6l 项) §5.3、§4.1

4.3 P2 · 上线收尾(原"与二期"已取消)

# 未完成项 说明
P2-1 CI/CD + test/prod 环境 + 部署 + 数据迁移预案 → ✅ 本轮完成:.gitlab-ci.yml(6 个 job,三阶段;e2e 阶段跑全套 1516 条断言,每次随机生成密钥)+ application-test.yml / application-prod.yml(零密钥)+ ProdEnvCheck(启动自检,6 条"配错了也能启动"的配置)+ docs/09-部署与运维.md(发布 / 密钥轮换 / 迁移与回滚 / 灰度 / 排障 / 首次接入 CI 的检查单) M0-04、M7-10
P2-2 压测与调优 → ✅ 本轮完成:新增 scripts/load-test.py(四个场景、按端点分别统计、可调规模);压测发现并修掉一个会让整间教室被挡住的限流缺陷(F-92)。结论:后端没有 N+1、DB 在 2ms 以内,并发下的延迟是排队效应。尚未测:导出大包、上传并发、5000 人名册、长稳(见 docs/09 §9.5) M7-09
P2-3 定时任务(PRD 附录 E) → ✅ 已完成:8 项已实现(任务自动截止 / 自动催报 / 审核超时提醒 / 原始件清理 / 用量快照 / 压缩失败重试 / 租户到期提醒 / 导出包清理 + 开放接口推送重试),POST /schedules/run 可手工触发任意一项。⚠️ 第 9 项「待认领名册清理」不做 —— 它对应的"自助认领"机制已在 PRD V1.5 废除(见 F-91)。二期另加第 9 个任务「内置角色授权同步」(每日 03:20,runNow 名 role,见 6v) M7-12
P2-4 用户手册 / 培训材料 / 客服话术 → ✅ 本轮完成:docs/10-用户手册与客服话术.md(三角色操作手册 + 高频问题标准回答 + 30 分钟培训提纲 + 上线首日检查单);并新增 scripts/check-manual.py(机械核对手册引用的错误码与提示文案是否真的存在,已进 CI) M7-11
P2-5 导出包过期清理 → ✅ 已完成:ExportCleanupService.cleanupExpired()(@Scheduled 每小时第 17 分跑) M6-07
P2-10 导出分卷(splitSizeMb > 0) → ✅ 已完成:SplitZipWriter 会产出 {base}-part1.zip、{base}-part2.zip…;scripts/e2e-m6-export-split.py 35 条 M6-07
P2-6 二期:社会收集 / 社会评价 / 专项职业能力 已取消(2026-10 不再设计二期;传 collectMode=2、certType=2,3 返回 40044)
P2-7 七牛实时样式缩略图 → ✅ 本轮完成:StorageService.thumbUrl()(参数拼在签名之前,拼错顺序会 401 且看起来像网络问题);FileUrl 同时给 url 与 thumbUrl;审核端 ?thumb=true。实测 1454KB → 36KB(2.4%),1600×1200 → 400×300 等比不变形 M7-09 附带
P2-8 小程序端 compressImage 真机验证;D6 三通道体积上限实测 需真机(→ docs/01 D6)
P2-9 学生端 H5 没有独立 dev server → ✅ 本轮完成:frontend-mp 加 /api 代理 + .env.development 把 VITE_API_BASE 设空串,pnpm dev:h5 → http://localhost:5174 可直接联调;三端行为(dev server / 构建产物 / 小程序)都验过,且 AGENTS.md 里那句过期的说明已同步 开发体验

五、发现的问题与修改建议

F-1【P1 · 真问题】操作日志从没人写

  • 现象:sys_operation_log 0 行;module/log/mapper/OperationLogMapper 全仓库只有它自己 import(LoginLogMapper 则在 AuthService 里真写,sys_login_log 704 行)。
  • 影响:M7-04 的「敏感操作留痕」实际不存在(查看明文证件号、下载、导出、代传、删除都无痕);而 §13.4 的两个查询接口一旦做出来,界面上会永远是空的。
  • 建议:
    1. 加 module/log/service/OperationLogService + @OperationLog(module, action) 注解 + aspect/OperationLogAspect,在注解里声明要记的动作;
    2. 优先挂 6 处:VIEW_IDCARD_FULL(审核详情看完整证件号)、导出创建与下载、代传、名册 REPLACE、账号删除、租户开通;
    3. 入库前脱敏(SysOperationLog 类注释已写明:去掉 password、证件号明文);
    4. 验收:E2E 新增一节,断言"看完整证件号后 sys_operation_log 多了一行且不含明文"。

F-2【P2 · 环境遗留】xt-minio 容器还在跑

  • 现象:docker ps 有 xt-minio(9000/9001,已跑 30 小时),而 deploy/docker-compose.yml 里只有 pg 与 redis,代码里 S3/MinIO 已删。
  • 影响:占用端口 + 误导(新人会以为本地有对象存储可用)。
  • 建议:docker rm -f xt-minio;若还有旧数据要留,先 docker run --rm -v 导出再删。同时在 AGENTS.md 的"环境与流程坑"里保留"MinIO 已删,别去找"这句(已有,够用)。

F-3【P1 · 文档与代码不一致】/mp/files/upload-token 与 /mp/ocr/idcard —— 已闭环

  • 现象:契约(PRD §8 与 03 §9.5/§9.9)规定上传是「OS 直传 + upload-token」,OCR 走 /mp/ocr/idcard;实际实现是 POST /mp/files/upload(服务端压缩 + 按模板改名,默认不留原始件),OCR 完全没做。

  • 影响:前端按契约写会调不存在的接口;upload-token 是"防重复建文件记录"的关键设计(重复提交幂等),现在靠 POST /mp/files/upload 每次新建记录来承担。

  • 建议(推荐改文档 + 保留实现,别改实现):

    1. 在 03 文档 §9.5 标注「已改为服务端上传:POST /mp/files/upload(multipart,收上来即压缩+改名)」,§9.6 process 语义按 AGENTS.md 的口径改成"有原始件时的重新规范化";
    2. §9.9 OCR 标注「一期缓做(等 D1 选型)」,不要留着像已实现的样子;
    3. 若后续要恢复直传(大文件/弱网),再单独立任务,不要在这轮混做。
  • ✅ 已闭环(2026-09-30,客户确认后按建议改文档):§9.5 重写为「上传前置检查(预检/秒传/压缩建议)」、§9.6 改为「有原始件时的重新规范化」、§9.9 标注一期缓做 —— 口径见契约 §17.2 D-3/D-4/D-12。

F-4【P1 · 文档与代码不一致】契约里没有的接口(首次盘点 19 个,现在 41 个) —— 已闭环

  • 现象(代码有、03 文档 §4 清单没有):最典型的是 GET /mp/scene-context、 GET /auth/captcha、GET /mp/h5/authorize|callback|stub-login(代替 /mp/login)、 POST /mp/files/upload(代替 upload-token + 直传)、GET /material-catalog…… 完整清单现跑:python scripts/check-api-parity.py --extra(当前 41 个)。

  • 影响:契约不是"当前系统长什么样",导致每次核对都要重新考古;前端/测试无法以文档为准。 ⚠️ 更要紧的是:这个数会继续长(每加一处界面必需接口就 +1), 所以它必须以命令的形式存在,不能以表格的形式存在 —— 见 §九。

  • 建议(已落地):在 03 文档新增「§17 实现补充与偏差记录」,逐条列出这些接口,并在 §4 的合计口径里说明"119 是设计口径,另有 38 个实现补充"(2026-10 复核数)。

  • ✅ 已闭环(2026-09-30):契约新增 §17 实现补充与偏差记录 —— 41 个接口按六类分组(清单由 check-api-parity.py --extra 生成,写明「手抄件会过期,以命令输出为准」),外加 12 条已确认偏离 D-1~D-12(含租户域名一行、biz_type 落库字符串、§14 路径前缀)。

F-5【P2 · 数据卫生】E2E 租户残留到"每个请求多一次解析" —— 清理工具已补齐(2026-09-30)

  • 现象:sys_tenant 17 行、sys_user 300 行,而脚本每次跑都会建测试租户,清理只删自己建的那一个。 ⚠️ 现在回头看,这个数字被低估了:跑了几十轮之后是 48 个 e2e* 租户 / 709 个用户 / 144 个挂在 demo 租户下的测试组织 / 82 个【演示·旧】任务。
  • 影响:本地演示/截图时列表里全是 e2e-* 名字;租户域名解析与登录"跨单位重名"分支被无意义数据搅扰。
  • 真正的窟窿(这一轮才看清):删库不删盘。backend/data/uploads 里 281 个文件 / 276 MB, 而库里只剩 16 行文件 —— 压测与 E2E 都只删自己的库行。 文件的归属只有库知道,所以这件事必须由"拿库做对照"的工具来收,不能靠 rm -rf。
  • 已做(scripts/dev-clean-e2e.py,默认 dry-run): ① --only-orphan-files --apply:删没有任何库行指向的磁盘文件 —— 已执行,255 个 / 275.4 MB, uploads 从 276 MB 降到 0.3 MB; ② --apply:清 e2e 租户 + 测试组织 + 它们的业务行(业务表按 information_schema 动态枚举, 以后新增业务表不会漏);③ --demo-tasks:清【演示·旧】任务及其子表与文件。
  • ⚠️ 后两项我没有执行,原因具体:当前演示态正挂在其中一个"E2E 24数控1班 645451"组织上 (就是导出目录里那个班级名),删了组织等于把演示态弄坏。所以: 要清就先确认演示态迁到正式组织名下,再跑 --apply。 我也因此把 --demo-tasks 的匹配收窄到只带「旧」的那批 —— 【演示】审核台 是当前在用的,原来那句 LIKE '【演示】%' 会把它一起删掉, 而这种错在预览里只表现为"83 个"(比 82 多一个),极容易被放过。
  • 建议:把 --only-orphan-files --apply 写进"跑完压测/E2E 的收尾动作"(见 AGENTS.md 第三节)。
  • ✅ 已执行(2026-09-30,含「在用组织保护」):--apply --demo-tasks --orphan-files 清掉 49 个 e2e 租户 + 147 个测试组织 + 84 个【演示·旧】任务,共 3714 行、27 个文件;清理前先算「仍被存活任务引用的组织」并保留(3 个,其中就有当前演示态挂着的 E2E 24数控1班 —— 演示态完好:任务 1449 / 名册 3 人 / 组织 43 都在)。另外 xt-minio 容器已删(F-2)、七牛桶已清(F-93)。

F-6【P2 · 文档误导】frontend-pc/AGENTS.md 里的 Mock 说明已过时 —— 已修(2026-09-30)

  • 现象:该文件写「开发默认 Mock(/mock 前缀)」,而 AGENTS.md 已注明"已过时,没有 Mock,dev 走 vite proxy 到 8080"。
  • 影响:AI/新人按旧说明写 Mock 代码或排查"为什么没有 /mock"。
  • 已修(按下面的建议做的,但多修了一处,见"连带"): frontend-pc/AGENTS.md 的 pnpm dev 注释与"开发默认 Mock"那句, 改为"/api 代理到 http://localhost:8080,没有 Mock → 后端不起着就没有数据", 并写明我们的接口模块在 src/apis/xt/。
  • 连带(这才是根子):frontend-pc/.agents/skills/project-api/SKILL.md 整篇仍以 vite-plugin-mock / mock/** / defineMock 为主线 —— 而该项目里 mock/ 目录与插件都已删除, 照它做会去建一个没人加载的 mock/ 目录。只改 AGENTS.md 那句等于"把指路牌改了、 但路的尽头还是错的",所以在那份 skill 开头加了 ⚠️ 说明(保留原文,供 Gi 模板升级时做差异比对)。 核实方式:frontend-pc/ 下无 mock/,package.json 与 vite.config.ts 里都没有 mock 关键字。

F-7【P2 · 一致性】撤回的动作码与适用状态 —— 已闭环

  • 现象:DDL 注释 3=批量通过 与 03 §11.4「写入 action = 3(撤回)」冲突,代码取 7(已按"时间轴不能混淆"处理);§11.4 的状态表只列状态 3,代码额外允许状态 2。

  • 建议:客户确认后同步两处文档;若客户不同意状态 2 的撤回,删一个分支即可(代码里有注释标明)。

  • ✅ 已闭环(2026-09-30,客户确认按代码口径):§2.7 auditAction 补全 7 个动作(撤回=7),§11.4 约束表补「状态 2 可撤(撤的是初审)」、留痕改 action=7。DDL 注释少列 7(迁移已发布不改,以 §2.7 为准)。

F-8【P2 · 安全/上线】打桩登录与存储密钥

  • 现象:application-dev.yml 里 stub-enabled: ${WX_H5_STUB:true}(dev 默认开着),而代码侧默认 false;keep-raw: ${XT_STORAGE_KEEP_RAW:false} 默认不留原始件;七牛 AK/SK 只在本地 application-local.yml。
  • 建议:上线检查清单里固化三条 —— ① 生产不设 WX_H5_STUB(代码里 fill.mp-h5.stub-enabled 默认 false + profile 二次判定,两道都留着);② provider=qiniu 且 bucket 私有;③ 导出/下载一律走签名链接、URL 不入库不外发。

F-9【P3 · 工程】后端无单元测试,纯函数没被单测覆盖

  • 现象:src/test 为空,验证全靠 E2E(很硬,但每次要起服务 + 连库)。
  • 建议:先给三个纯函数补最小单测(不引框架之外的东西):IdentityNormalizer、命名规范化引擎、压缩档位推导。理由是这三个"输入输出确定、没有 IO",单测成本最低、回归收益最高;其余继续靠 E2E。

F-10【P3 · 一致】租户域名相关文档暂缓 —— 已闭环

  • 现象:§一·二十五 的能力已实现且默认关闭,PRD 与 03 文档未写(当时有意暂缓)。
  • 建议:保持暂缓,但在 03 文档 §17 留一行"租户域名能力:Host 定位 + token 授权,默认关闭,启用条件 4 步",避免将来重开时找不到入口。

  • ✅ 已闭环(留一行入口,按建议):契约 §17.2 D-10 写明「能力已实现(Host 定位 + token 授权),默认关闭,启用四步见《交接摘要》§一·二十六」。

六、施工记录(本轮自主完成的事)

客户要求:"完成所有的开发计划,中途发现问题自己决定,记录出来让我知道即可。" 本节的每条都带验证方式,不写"已完成"这种没有证据的话。

6.1 已完成并验证

# 事项 交付物 验证
1 P0-3 审核前端(PC) frontend-pc/src/apis/xt/audit.ts、views/audit/index.vue(待办台:四个计数、分级过滤、批量通过、快捷通过/驳回)、AuditDetailDrawer.vue(材料/问题清单/改动明细/时间轴/通过·驳回·撤回)、AuditRejectModal.vue(附录 C 原因码 + R999 必填说明)、FilePreviewImage.vue(签名链接现取);xt-routes.ts 的 audit 由"待建"改为已实现 vue-tsc 0 错、eslint 0 错;dev server 逐模块编译 200(含 4 个新组件);接口层用真数据核过(见下)
2 P0-2 导出与交付(M6 后端 + 导出中心 UI) 后端 module/export/:实体 + 两个 Mapper + DTO + ExportService(前置检查 / 目录树 / 异步打包 / 进度 / 下载)+ ExportController(7 个接口)+ 独立导出线程池;前端 apis/xt/export-task.ts、views/export/index.vue(范围/包名/阻断清单/目录树预览/按钮禁用/记录表)、ExportDetailDrawer.vue(进度轮询 + 产物 + 下载) scripts/e2e-m6-export.py → 60/60;vue-tsc 0 错、eslint 0 错、dev server 模块编译 200
3 P1-5 学生端「我的任务 / 撤回 / 进度」 后端 StudentTaskMapper + StudentTaskService + StudentTaskController(§9.1 /mp/tasks、§9.11 /mp/submissions/{id}/withdraw、§9.12 /mp/submissions/{id}/progress);学生端新页 pages/tasks/tasks.vue(我的填报:待办数/状态/驳回原因/截止提醒/撤回)与 pages/progress/progress.vue(材料清单 + 问题 + 时间轴);落地页在"有登录态但没 scene"时改为进「我的填报」 scripts/e2e-m3-mytask.py → 42/42;H5 build:h5 通过且产物含 pages-tasks-tasks / pages-progress-progress
3b P1-4 B1 名单加入申请 见 §6.1 上一轮记录(后端 + 学生 H5 + 管理端审批 + V7 迁移) scripts/e2e-m2-join.py → 50/50
4 P0-3 班主任端(小程序)看板 + 审核卡片流 后端 TeacherDashboardMapper + TeacherDashboardService + MpTeacherDashboardController(§10.1 看板);小程序新页 pages/dashboard/dashboard.vue(已交率与已通过分开呈现、四个待办角标、班级卡片、截止提醒)、pages/audit/audit.vue(卡片流:右滑通过 / 左滑不绑驳回、一键通过只带无问题项)、pages/audit-detail/audit-detail.vue(材料预览 + 问题清单 + 改动明细 + 驳回弹层:原因快选 + 二次确认);「我的填报」按 /auth/me 的角色给出老师入口 scripts/e2e-m5-teacher-dashboard.py → 24/24;H5 build:h5 通过且产物含 pages-dashboard-dashboard / pages-audit-audit / pages-audit-detail-audit-detail
5 可复现的接口计数 scripts/check-api-parity.py(契约 §4 的 129 个接口 ↔ 全部 controller,自带"必须恰好 129"的自检) python scripts/check-api-parity.py 退出码 0
6 界面验收用的演示数据 scripts/dev-seed-audit.py(走真实接口摆出:任务 + 班级 + 材料 + 名册 + 门禁绑定 + 3 份已提交填报单 + 1 份已初审,含一个演示班主任账号) 脚本自身对每步 code 断言;审核接口回读确认(见 6.3)
6b P1-3 通知与消息中心 后端 module/notify/:CollectMessage 实体 + CollectMessageMapper(批量已读)+ SubscribeQuotaMapper(额度 UPSERT / 原子扣减)+ NotifyRecordMapper + NotificationService(降级链)+ NotifyController(§13.5 消息中心 3 个 + §13.6 订阅额度 1 个);V8 迁移新建 collect_message;AuditService 的通过/驳回真的会通知学生;小程序新增 pages/messages/messages.vue + utils/subscribe.ts(提交后索取授权) scripts/e2e-m5-notify.py → 48/48;H5 build:h5 通过且产物含 pages-messages-messages
6c P1-3 剩余:催报与通知记录 后端 UrgeService + UrgeMapper + UrgeDtos + 两个 Controller(§8.6 定向/一键催报、§10.7 教师端催报与历史、§13.3 管理端通知记录);NotificationService 的发送记录补上 task_id(催报去重要用它);PC 新增 views/notify/index.vue(通知记录:状态 3 与 2 分开显示)+ notify.ts scripts/e2e-m5-urge.py → 40/40;vue-tsc 0 错、eslint 0 错
6d P1-8 操作日志留痕(§13.4,PRD FR-506/FR-1202 的 P0 项) V9 迁移给 sys_operation_log 补 4 列结构化留痕(target_type/target_id/before_val/after_val);OperationLogService(显式留痕 + 统一强制脱敏)+ LogController(§13.4 操作日志与登录日志两个查询);已接入的敏感动作:名册改证件号 / 改名 / 删名册项(§8.3/§8.4 一并实现,含「标记不参与」的可逆替代)、任务发布/暂停/恢复/截止/归档;PC 新增 views/log/index.vue scripts/e2e-m5-logs.py → 44/44;vue-tsc 0 错、eslint 0 错
6e P1-8 剩余:字典管理(§13.3) V10 迁移新建 sys_dict_item(客户自定义项)与 sys_dict_disabled(内置项停用开关,两表分开才能"一键恢复默认");DictCatalog(内置字典写在代码里,照抄官方表格)+ DictService + DictItemMapper + DictController(§13.3 四个 + 内置项 toggle);顺带给 BuiltinFields / RejectReasonCatalog 加了访问器 —— 表单下拉与字典是同一份口径,不是复制两份;PC 新增 views/dict/index.vue(内置项只有"停用/恢复",自定义项才可改名删) scripts/e2e-m1-dict.py → 53/53;vue-tsc 0 错、eslint 0 错
6f 导出分卷 + 过期清理(§12.4 的 splitSizeMb / 保留 7 天) V11 迁移新建 collect_export_file(一票一卷一行,替代原来那段 result_files JSON);SplitZipWriter(边写边轮转,每一卷都是完整可解的 ZIP,不是 .z01);ExportCleanupService(定时任务 + 手工入口:删文件、留记录);ExportService 的下载改走产物表并按行判可下载(过期后回「已被清理、请重新导出」而不是「文件不存在」);新增 scripts/check-entity-columns.py(机械校验"实体基类要求的列 vs 迁移建表列") scripts/e2e-m6-export-split.py → 33/33;e2e-m6-export.py 60/60 未回归
6g P1-12 班主任代传材料 + 教师端文件访问 V12 迁移给 collect_file 补 proxy_by / proxy_reason;TeacherProxyService + MpTeacherProxyController(§10.6 代传、§4.6 教师端看学生材料);一步 multipart(不是契约写的"先传文件再挂 fileId");三道闸门:只给自己负责的班 / 只给草稿状态的单 / 必须留痕且学生可见 scripts/e2e-m5-proxy.py → 33/33

| 6h | P2-04 历史数据整理器(§13.2,5 个接口) | V13 迁移给 collect_cleanup_job 补审计列(update_by/update_time/deleted —— V1 漏了,写代码之前就被 check-entity-columns.py 拦住)+ roster_task_id;HistoryNameParser(从"人长出来的"历史路径里解析姓名/证件号/材料/班级)+ CleanupService(解压 → 解析 → 与名册比对 → 按模板重打包 → 体检报告)+ CleanupImportService(导入为正式任务,幂等);报告算出 autoHandled / needManual(契约里最重要的一句话)与疑似串档检测 | scripts/e2e-m7-cleanup.py → 54/54(含"坏包要明确失败"与"无名单时串档恒为 0") |

| 6i | P1-2 校验问题清单(§12.1~12.3)+ §12.7 通知模板与手工发送 + 微信回调 | V14 迁移给 collect_validation_issue 补审计列(该表 V1 就建好了、列与契约 §3.9 逐列一致,只是一直没有代码用它,见 F-50)并加"同一问题只一行"的表达式唯一索引;新建 collect_notify_template、给 collect_notify_record 补 msg_id;ValidationService(对齐语义:已有→只更新内容不碰 resolved / 新增→插入 / 消失→标已解决)+ NotifyTemplateService(内置模板写在代码里、库里只存改过的;自定义 / 恢复默认)+ NotifyCallbackService(验签 + 凭发出时记的 id 反查租户);上传时把"系统改过文件名"记成 VAL-06 自动修复,让 byRule.autoFixed 有真实数据 | scripts/e2e-m7-validation.py → 70/70 |

| 6j | P1-11 / P1-9 任务克隆、任务统计、规则模板、名册导入模板、审核统计、PC 端代传 | V15 迁移给 collect_rule_template 补审计列(该表 V1 就建好、列与契约 §7.13 的 FORM/MATERIALS/NAMING 三部分逐项对应,见 F-50)并随迁移带两条公共模板;TaskCloneService(克隆配置、刻意不克隆业务数据)、TaskStatisticsService(补上 abnormalCount 与 avgAuditHours —— 这两个字段以前恒为 null,因为问题表与审核记录还不存在)、RuleTemplateService(可按 parts 只应用一部分、材料清单覆盖式合并)、RosterTemplateService(表头固定第 1 行 + 必填列标红 + 9 条填表规则页)、AuditStatisticsService、TeacherProxyService.proxyUploadAsAdmin;并修掉 F-61(初审待办会列出批不了的单子) | scripts/e2e-m7-taskconfig.py → 69/69(含待办范围判据的回归防线) | | 7 | 全量回归 | — | 33 个脚本 / 1489 条断言 / 0 失败(753 + 导出 60 + 加入申请 50 + 学生端 42 + 看板 24 + 通知 48 + 催报 40 + 日志 44 + 字典 53 + 分卷 35 + 代传 33 + 整理器 54 + 校验 70 + 任务配置 69 + 开放接口 60 + 组织与文件 54) | | 6v | 二期「角色与授权」(FR-205) | V18 迁移:sys_role 加 user_type(账号的 user_type 一直是从角色反推的,自定义角色不在代码映射里 → 没有这列就"角色建得出来、发不出去")、新建 sys_role_menu(菜单不做权限的附属,与 sys_role_permission 同构)。权限判定:PermissionChecker 合并两个来源 —— 内置角色按代码、自定义角色按库(DbRoleGrantSource,Redis 缓存 + 写后失效;空结果也缓存,否则已删角色的 token 会把库打穿;Redis 挂了退化成直读库,两个方向都安全)。内置角色授权同步:RoleProvisioner 从"已存在就跳过"改成幂等回填 + ScheduleJobs 每日 03:20 同步(修掉"代码改了权限、老租户永远停在旧的那套"这种不会报错的漂移)。8 个接口(docs/03 §4.12/§5.10)+ 40045;学校租户授予平台级权限码 / 配置平台专用菜单 / 建平台运营类角色 → 40301;防自锁(不许去掉自己最后的 role:manage 权限或 role 菜单、不许停用/删除它、改账号角色时同理保护 user:manage)。PC 页 views/role/(内置只读 + 权限矩阵 + 菜单勾选 + 保存即生效)+ 原型 ⑪;账号页的可选角色 / 用户类型 / 角色名全部改读库(删掉前端三份写死的镜像表)。⚠️ dataScope 本期只展示:范围仍由"权限码 + 任务里被指派了哪些班"决定,收口成 resolver 是下一批 | 新增 scripts/e2e-m10-role.py → 77/77(含"改授权后同一个 token 立刻生效""停用角色立刻失效""内置角色只读""越权与自锁""删除守卫 40045""内置角色权限以代码为准 + 同步回填幂等");浏览器实测(新建自定义角色 → 账号页出现该角色 → 改权限落库 → 删除);pnpm typecheck 0 错、eslint 0 错;verify-docs / check-wiring / check-api-parity(契约 129)全绿 | | 6u | P2-7 缩略图 + P2-9 H5 独立 dev server | 缩略图:StorageService.thumbUrl(path, expire, maxLongSide)(七牛在签名之前拼 imageView2/2/w/h;本地存储回退原图,让调用方不必判断"当前是什么存储");FileUrl 加 thumbUrl/thumbMaxLongSide;审核端 ?thumb=true(默认 false —— 详情页要看防伪纹)。H5 dev server:vite.config.ts 加 /api 代理 + .env.development 把 VITE_API_BASE 设空串(uni.request 不能传相对路径,所以空串时仍补 location.origin,由代理兜住) | 缩略图实测 1454KB → 36KB(2.4%)、400×300 等比;新增 scripts/e2e-m9-thumb.py → 12/12(打到真实七牛,含"参数若拼在签名之后会 401"这一条);H5 dev server 实测 5174 可访问且 /api 代理到 8080;H5/小程序两端构建都验过(小程序产物仍是写死地址) | | 6t | P2-4 用户手册 / 培训材料 / 客服话术(M7-11) | 新增 docs/10-用户手册与客服话术.md:学生/班主任/管理员三份操作手册 + 「高频问题 → 标准回答」+ 30 分钟培训提纲 + 上线首日检查单。⚠️ 文案与错误码逐条取自代码,并在写完后对着代码核了一遍 —— 抓到两处与实现不符的说法(见 F-95);另把这项核对固化成 scripts/check-manual.py 并进 CI | 9 个错误码 + 14 句提示文案全部与代码一致 | | 6s | P2-2 压测(M7-09) | 新增 scripts/load-test.py(门禁 / 读接口 / 上传 / 导出四场景;按端点分别统计;--rows 可调规模)。压测抓到一个真缺陷并修掉:门禁 IP 限流计的是"所有请求",于是 60 个不同学生同一出口 IP 同时核验只过了 20 个 —— 而"一个班连同一个 WiFi"正是首次落地第一分钟就会发生的事(见 F-92)。调优结论(三条独立证据):SQL 条数恒为 3(与 pageSize 无关)→ 无 N+1;单请求延迟与页大小无关(20/50/200 → 47/36/44ms);EXPLAIN ANALYZE 显示 DB 侧 ≤2ms。所以并发下的 p50 156~375ms 是排队效应,要提吞吐得加机器而不是改 SQL | 修前 20/60 → 修后 60/60;门禁 E2E 仍 29/29 绿 | | 6r | P2-3 定时任务收尾(PRD 附录 E 的最后三项) | V17 迁移:collect_file 加 process_retry_count / process_error / last_retry_time(+ 只索引"还没处理出规范件"的部分索引)、新建 sys_tenant_usage_snapshot(一个租户一个周期一行,唯一索引保证幂等);ScheduleJobs 加三个任务:用量快照(每日 01:00,⚠️ tenant_id 必须显式写—— 平台上下文不会自动填,忘了就会全挤到 0 上,而那看起来像「没有数据」)、压缩失败重试(每 10 分钟,候选 = 没有规范件 + 有原始件 + 计数 < 3;必须封顶,否则原图损坏的文件会被每 10 分钟重试一次而永远不会成功)、租户到期提醒(每日 08:00,30/7/1 天,发给该租户自己的管理员);runNow 支持 7 个任务名 | e2e-m7-schedule.py 扩到 38/38(新增快照幂等与一致性、重试累加与封顶、到期提醒的日期窗口与收件人) | | 6q | P2-1 CI/CD + test/prod 环境(M0-04 / M7-10) | 新增 .gitlab-ci.yml(三阶段 6 个 job:机械校验 / 构建(含 H5)/ 全套 E2E 打真库;密钥每次随机生成,顺带证明"应用没有偷偷依赖 dev 默认值");application-test.yml(零密钥、允许 clean 以便每次从空库跑全量迁移)、application-prod.yml(零密钥、禁 clean、关 Swagger、日志滚动);ProdEnvCheck(EnvironmentPostProcessor,跑在任何 bean 之前);docs/09-部署与运维.md;删掉模板残留的 GitHub Pages 工作流(远端是 GitLab、用 pnpm、且它发布的是没有 API 的静态页) | 新增 scripts/e2e-prodguard.py → 9/9(三种误配形态都拒绝启动)+ scripts/check-test-profile.py(test profile 真起一次,可重复跑);本地照跑 CI 的 check/build 各步骤全通过 | | 6p | P2-3 定时任务(PRD 附录 E 的四项) | 新建 module/schedule/:ScheduleMapper(跨租户的候选查询集中在这一个文件,全部 @InterceptorIgnore + 只读;别名一律带双引号,见 F-83)+ ScheduleJobs(四个 @Scheduled,每个都是"先以平台身份扫候选 → 再逐个租户摆上下文干活"的两段式)+ ScheduleController(POST /schedules/run 手工触发,权限用运营侧的 log:view:all)。四项:任务自动截止(end_time < CURRENT_DATE,动作名 AUTO_CLOSE 以区分「系统关的」与「管理员关的」)、自动催报(7/3/1 天,end_time = 那天 而非 <=,只催未提交)、审核超时提醒(时效与 §11.6 同口径,发给"真的要动手的那个人",找不到班主任时升级给学校管理员)、原始件清理(先清引用再删文件)。另给 UrgeService 加 autoUrge、TaskService 加 autoClose | 新增 scripts/e2e-m7-schedule.py → 27/27 | | 6o | P1-9 工作台数据看板(PC) | 工作台底部原来是一张「开发进度」卡,写着「M0 后端已完成,界面建设中」—— 而项目已是 120/121 个接口可用、10 个 PC 页面可点。首页上说一件不真的事,比空着更糟,所以换成真实看板:apis/xt/task.ts 加 getTaskStatistics + TASK_STATUS_TEXT(状态文案放一处)、apis/xt/audit.ts 加 getAuditStatistics;views/dashboard/index.vue 加"选任务 → 进度四格(应填/已提交/已通过/待审)+ 分班进度 + 审核情况 + 驳回原因分布"。提交率与通过率分两列(已提交 ≠ 已通过);平均审核时长按量级换单位显示(null=还没有样本 / 0=不足 1 分钟 / 否则分钟或小时) | 浏览器实测:应填 3 / 已提交 3 / 提交率 100.0% / 已通过 3 / 通过率 100.0% / 待审 0,分班表与审核表都出数;pnpm typecheck 通过、eslint 0 错 | | 6n | 「待建」页不再说谎(3 个"功能已就绪、入口在别处"的菜单项) | 名单管理 / 材料规则 / 表单设计 三个菜单项对应的功能早就做好了(TaskRosterDrawer / TaskMaterialDrawer / TaskFormSchemaDrawer,入口在「收集任务」每一行的链接里),但点进菜单看到的一直是「这个页面还没做」+ 一句早已过期的"已实现的页面:工作台、组织架构、租户管理"。说"还没做"而功能其实在隔壁,比找不到更糟 —— 它让人得出错误结论。 给 XtMenu 加可选的 entryAt,待建页据此分两种说法:有 entryAt → 「功能已就绪」+ 真实入口指路 + 跳转按钮;没有 → 原来的「还没做」(角色与授权 就是这个:权限码由代码决定、自定义角色属二期,确实没做)。并删掉那句会静默过期的清单 —— 任何写死在代码里的"现状"都会过期,而没人会记得回来改它 | 浏览器实测四个菜单项:三个显示「功能已就绪」并可一键跳到「收集任务」,角色与授权 显示「二期」;pnpm typecheck 通过、eslint 0 错 → ✅ 已闭环(2026-10-09,二期):「角色与授权」已实现(见 6v),ready:false 的菜单归零,「待建」页只作为兜底保留 | | 6m | P2-04 历史整理器页面(PC) | 新增 frontend-pc/src/apis/xt/cleanup.ts(含报告口径:headlineOf 拼"112 个文件里 90 个全自动处理,只有 1 个必须你亲自判断",但它拼的是服务端算好的两个数,不是前端减出来的 —— 前端减出来的早晚会与后端分叉;下载走 api 层,与导出中心同一写法)+ views/cleanup/index.vue(列表 / 上传 / 进度轮询 / 报告 / 难点清单 / 改名样例 / 证件号冲突 / 下载 / 导入);路由 ready: true。实机验证(浏览器里真的点了一遍):列表出数据、点详情出报告、压缩率显示 —、重点条目带建议、导入按钮可见 | pnpm typecheck 通过;eslint 0 错;截图留证 | | 6l | P1-14 / P1-8 / P1-10 小程序登录、组织导入、租户用量、文件视图·直传凭证·批量处理 | MpLoginService(jscode2session + 复用 H5 的 resolveUser,不复制一份建账号逻辑)+ 手机号绑定(手机号从客户端到不了服务端,所以能作凭据;一个号只能绑一个账号);OrgImportService(父子关系用编码 + 上级编码、两遍扫描支持"子行在父行之前"、补 ancestor_path);TenantUsageService(周期支持月/季;ocrCount/smsCount 恒为 0 并带 notes 说明);MaterialFileViewService(§9.5 预检+秒传+压缩提示、§9.7 批量处理、§9.8 预览带 actualKind);并修掉 F-73(秒传排在份数检查之后 → 网络重传得到"你超了"而不是"你已经传过了") | scripts/e2e-m8-orgfile.py → 54/54 | | 6k | P2-05 开放接口(§14,供认定机构对接) | V16 迁移新建 collect_open_app(密钥存 AES 密文 —— HMAC 要能还原,不能只存哈希)与 collect_open_push_log(next_retry_at 落库,内存定时器在重启后就没了);OpenSignService(HMAC-SHA256 + 5 分钟时间窗 + nonce 防重放,先验签再占 nonce)、OpenDataService(§14.2/§14.3,一律不返回明文证件号、签名 URL 30 分钟)、OpenPushService(§14.4 推送 + 定时重试 + 人工重推,推送挂在 afterCommit 上)、OpenAppAdminController(契约漏掉的接入方管理,见 F-67);并把规则模板的两个接口修回契约路径 /api/v1/rule-templates(见 F-68) | scripts/e2e-m8-open.py → 60/60 |

6.2 导出模块到底做了什么(客户最关心的那条链)

  • 铁律 10 落地:前置检查不通过 → 40035 + 完整清单(谁的哪一项、该怎么办)+ quickActions; 没有"警告后放行"这个分支(代码里就没有 force 参数)。界面上可以直接拿 GET /exports/precheck 决定按钮是否可点。
  • 只装该装的:草稿与未绑定的人不进包(但会在 warnings 里说清有几个人); 文件只取 is_current = 1(历史版本绝不进交付物 —— 否则等于把两份矛盾的身份证一起交给机构)。
  • 目录结构按 PRD §5.9:根目录 {批次号}-{任务名称} → 班级 → 材料类型 → 文件, 汇总表(文件名用机构的叫法:考生线下报名信息导入模板(汇总).xlsx)放根目录。 ⚠️ 第一版我按直觉做成了"班级/姓名/文件",与本规范不符 —— 已改回并按规范加了断言(见 F-16)。
  • 目录树预览:GET /exports/preview 与真正打包共用同一份构建代码, 所以"预览"不会说谎;E2E 会把预览的节点名与产物路径逐项比对。
  • 汇总 Excel:严格 21 列(对齐官方模板 A~U),证件号用脱敏值。
  • 中文文件名:用 commons-compress 显式设 UTF-8 编码, 并在 E2E 里断言 ZIP 的 UTF-8 标志位(general purpose bit 11) —— 这一条只在 Windows 解压时才出错,服务器上怎么看都对,所以必须验在产物里。
  • 校验值:产物带 sha256:,下载接口回它,便于客户核对传输完整性。

6.3 本轮自主决定的事(客户只需知道,不需要回复)

# 决定 理由
D1 审核台不做前端侧的范围过滤,只显示服务端返回的待办 "权限 ≠ 范围"由服务端用 1 = 0 守死;前端再过滤一次只会在口径不一致时制造假象
D2 批量通过被跳过的条目逐条列原因(弹窗里列出姓名 + 原因) 只说"过了 3 个"会让用户反复点那个大按钮
D3 审核台的"通过"按钮在 approvePolicy.canApprove=false 时禁用并显示原因,而不是等他点了再弹 40032 点完才被拒会让人以为系统故障
D4 新增 GET /exports/precheck(契约里没有) 界面必须先知道"能不能导"才能禁用按钮;契约只在 POST 失败时回 40035,那样用户要先点一次才知道
D5 导出包落在本地磁盘(data/uploads/exports/),下载走自己的流式接口 打包本来就要写盘;七牛私有空间放临时交付物会引入"签名过期"这类与业务无关的失败。将来要上云存储,只需换 download 里的 URL 来源
D6 collect_export_task 的 currentStep / stepText / stats.rawSizeMb 不落库,由 status + progress 推导 / 按需聚合 那张表的 DDL 已发布,没有 current_step / step_text / raw_size 三列;为三个展示值新建迁移不划算
D7 导出前置检查复用 SubmissionValidator(与提交时、审核时同一套规则) 另写一份必然分叉,分叉的表现是"提交时说没问题、导出时说有问题"
D8 E2E 每次跑前重摆演示数据(dev-seed-audit.py --reset) 第一版脚本第二轮就红:数据已被上一轮审完,canExport 自然变 true。夹具状态不对造成的红,看起来像功能坏了
D9 前端 ISSUE_SEVERITY 的口径改为 1 提示 / 2 警告 / 3 阻断 见 F-11:三处注释互相矛盾,以服务端 Issue.INFO/WARN/ERROR 常量(数红黄用的那三个)为准

6.4 本轮新发现的问题(已修 / 待办)

# 问题 处理
F-11 SubmissionValidator 的 severity 口径有三处互相矛盾的注释:Issue 的 JavaDoc 写"1信息 2警告 3错误",而 Severity 常量注释写"1=红 2=黄 3=蓝",实际代码是 INFO=1 / WARN=2 / ERROR=3(SubmissionService 数红黄就用它们)。我第一版导出前置检查按"1=红"写,会把"提示"当成阻断 → 正常数据也导不出来 已改:导出按 Issue.ERROR 判定;前端 ISSUE_SEVERITY 与详情页配色改为 1 蓝 / 2 黄 / 3 红;Issue 的 JavaDoc 补上"不要按直觉改常量"。建议:把 Severity 常量注释也一起改掉(本轮未动,属纯注释)
F-12 collect_export_task 表与契约字段不一致:契约 §12.5 有 currentStep / stepText / stats.rawSizeMb,表里三列都没有 不新建迁移,改为推导(D6)。已在实体注释里写明"不要再按契约建字段"
F-13 RevokePolicy 在"已导出"之后不返回 auditRecordId(只把 canRevoke 置 false) 这不是 bug,但意味着 40033 在界面上不可达(只会出现 40001「缺少要撤回的记录 id」)。已在 E2E 里按"用户真正看得见的东西"断言,并写清 40033 只在绕过前端时可达
F-14 已发布的任务既删不掉也改不了名(40001),演示/测试数据无法清理 开发种子脚本改为"SQL 清空 + 改名挪开"(夹具性质,已注明)。建议:给管理员留一个"归档后可删"的口径,否则本地库会一直堆任务 ✅ 已闭环(客户确认「归档后可删」):DELETE /tasks/{id} 支持 status=3 —— 级联物理删 14 张表(刻意不软删:本条的动机就是库会一直堆)+ 尽力删物理文件 + sys_operation_log 记 DELETE_TASK;收集中/已截止仍拒删(归档是最后的后悔药)。验证:新增 scripts/e2e-m1-task-delete.py → 19/19。
F-15 我第一次汇总时把 753 读成了 771(我的算术错,不是脚本的问题)。逐脚本累加可复现:原 18 个脚本合计恰好 753,加上新增的 e2e-m6-export.py(60)为 813 已更正为 813,并在正文给出"753 + 60"的算式。建议:这类数字一律附上算式,别只写结论 —— 否则下一次还是会算错
F-16 ⚠️ 我第一版把导出目录结构做错了:做成"班级/姓名/文件"(更符合"给人翻"的直觉),而 PRD §5.9 明确是「{批次号}-{任务名称} / 班级 / 材料类型 / 文件」,根目录也不是用户填的导出名 已改回规范,并把"根目录名、第二层、第三层、汇总表位置"四条都写成 E2E 断言(e2e-m6-export.py),另加"预览节点与产物逐项一致"。教训:目录结构是交付物的形状,属于需求,不能按直觉设计 —— 这类错只在客户解压时暴露
F-17 汇总表文件名:契约 §12.4 只说"生成汇总 Excel",PRD §5.9 给的是机构叫法 考生线下报名信息导入模板(汇总).xlsx 按 PRD 命名(机构按这个名字找它)。已写进 E2E
F-18 eslint --fix 会把"靠全角空格对齐"的目录树标记重排,并在模板里报 no-irregular-whitespace 目录树改为「半角制表符 + CSS padding」,不再依赖空白对齐。教训:凡是要过 lint 的模板,不要用不可见字符表达版式
F-19 ⚠️ B1 的"审批通过"在实现层走不通:collect_roster.id_card_no 是 NOT NULL 的 AES 密文列,而按契约"服务端转 HMAC、不落明文"设计的申请表里只有 hash 与 mask → 审批写名册时必然 null value in column "id_card_no" violates not-null constraint(500)。三条替代路都被排除:让学生先过门禁(先有鸡还是先有蛋)、让管理员重输(把学生写错一位的风险复制给管理员)、不写这一列(DDL 不允许,且审核看完整号正是解这一列) 新增 V7 迁移给 collect_join_request 补一列 id_card_cipher(AES-GCM 密文,与名册同一把密钥/算法):审批时原样搬进名册,明文全程只在提交请求的内存里存在过。V7 之前提交的老申请会明确提示"请重新提交一次",不静默建一条没有证件号的名册行(那条行以后永远过不了名册校验)。建议:契约 §8.7.4 的"服务端转 HMAC,不落明文"应改成"存 HMAC + 密文",否则下一个实现的人会再踩一次
F-20 自述班级被姓名规范化弄坏:我第一版用 IdentityNormalizer.name() 处理 orgHint,而那个方法会去掉空格(姓名要"张 三"→"张三"),班级名里的空格是有意义的("24中技数控1班 645451"去掉空格就再也匹配不上组织)→ 审批时"自述班级自动匹配"永远失败 改为只做全角转半角 + 去首尾空白。教训:姓名与名称的规范化规则不能互相借用 —— 一个要去空格,一个不能去
F-21 ⚠️ 进度条永远到不了 100%:SubmissionValidator 的"缺必传材料"那条问题没有在 extra 里带 materialCode(材料名只写在文案里);而 MaterialDefVO.required=1 的「工作证明」对在校生本次不适用(visibleWhen: is_student=false)。我第一版按"所有 required 材料都算分母"算进度 → 学生把 3 项都交了也只显示 75%,而且那一项上什么问题都不显示 ① 进度页用与学生看到/校验用的同一份装配结果求条件可见性,不适用的材料不进清单也不进分母;② 问题的归项改为"先看 extra.materialCode,没有再按文案里的材料名反查";③ 未交的必传材料直接标 issueLevel=3。E2E 断言已改为"在校生只列 3 项"。教训:进度条的分母必须与"这个人本次真的要交什么"一致 —— 差一项就是一条永远填不满的进度条
F-22 collect_roster 里 @TableField(exist = false) 被加在 BIND_TYPE_GATE / BIND_TYPE_ADMIN 两个 static final 常量上(本意大概是"这不是列",但静态常量本来就不是实体字段,注解是多余的) 本轮未改(纯冗余、无行为影响)。但新增 bind_type = 3(来自加入申请)时不能照抄这个写法,已在 JoinService 里直接用字面量 3 并注明契约依据
F-23 ⚠️ 后端启动直接崩:TeacherDashboardMapper 的 <script> 动态 SQL 里写了裸 <>(SQL 的"不等于"),而 <script> 块要按 XML 解析 —— < 必须转义成 &lt;。错误是 SAXParseException: 元素内容必须由格式正确的字符数据或标记组成,指不到具体那一行 SQL,而我第一反应是去查 SQL 逻辑(走了一段弯路)。更危险的是:mvn package 照样成功,只有启动时才炸 改成 &lt;&gt;(SQL 标准的"不等于"),并顺手扫了 module/mp/mapper 下所有动态 SQL,确认没有别的裸 <。教训:带 <script> 的 Mapper 编译期不校验 XML,改完必须真启动一次 —— 这类错的表现是"服务起不来",不是"接口报错"
F-24 ⚠️ collect_message 少了两列审计字段(create_by / update_by):实体继承 TenantEntity,而 FillMetaObjectHandler 会填这两列 → 插入报 column "create_by" of relation "collect_message" does not exist,站内信一条都写不进去。这个坑在 collect_task_org / collect_export_task 上都踩过,是第三次 V8 还没发布(本轮才加、且只在开发库跑过一次),所以直接在迁移里补上两列——而不是像前两次那样"去掉实体字段"。判断依据:这张表以后要按 create_by 查"谁发的消息",少这列以后要再加就麻烦了。教训:新建业务表先问一句"它是 TenantEntity 还是普通实体",然后照着列清单核对一遍
F-25 ⚠️ @Update 里的 &lt; 不会被还原成 <:SubscribeQuotaMapper.consume 写的是 AND used &lt; total,而没有 <script> 包裹的注解 SQL 不经过 XML 解析 → 字面量 &lt; 原样发给 PG → syntax error at or near "&"。表现更绕:这个异常在 NotificationService 里被捕获降级成"仅站内信",所以用户侧完全正常,只有 collect_notify_record 里一行都没有 外面套一层 <script>(本意不是为了动态 SQL,就是为了转义),并写进注释。连带教训:这类"被吞掉的异常"最难查 —— 我一开始以为是"没被调用",查了三轮日志才看到真正的堆栈。排查手法:先把被吞的异常用 System.err 打出来,别信日志配置
F-26 ⚠️ collect_notify_record.biz_type 是 varchar(32),不是数字:契约 §6 的字段表把 bizType 定义成数字码(1~7),而 V1 的 DDL 建的是 varchar + 注释只写"业务类型"。传 Integer 进去 PG 直接拒绝(column "biz_type" is of type character varying but expression is of type integer)—— 同样被降级逻辑吞掉,表现还是"表里一行都没有" 按 DDL 走:biz_type 存数字码的字符串形式("3"),接口层仍然是数字(契约不变)。collect_message.biz_type 我用的是 SMALLINT(新表,我自己定的),两者"A 通过 bizType 关联"依然成立。建议:契约 §6 补一句"collect_notify_record.biz_type 落库为字符串" ✅ 已闭环:契约 §17.2 D-8 写明「落库为数字码的字符串形式(varchar 列),接口层仍是数字」。
F-27 notifySent 的语义被收窄了:以前它表示"有没有通知渠道"(恒 false),现在表示"微信推送有没有真的发出去"。站内信一定写得进去,所以 needManualNotice 仍然可能为 true(学生不一定会进小程序) 已在代码里写清,并且没有顺手把 needManualNotice 改成恒 false。E2E 断言"降级时如实告知:notifySent=false / needManualNotice=true",防止后人把它"优化"成乐观值。这是对前端契约的语义变更,已在本文档记录
F-28 降级链只做了两级(订阅消息 → 站内信),中间两级没做:契约 §13.6 写的是「订阅消息 → 公众号模板 → 短信 → 仅站内信」。后两级的缺不是代码问题 —— 公众号模板消息要已认证服务号 + 用户已关注(一期没有公众号),短信要买通道与签名(按条计费,是商务动作)。我选择只实现能真正走通的两级,而不是写两级"看起来有、其实永远失败"的通道 在 NotificationService 的类注释里写清了为什么少做,并用 channel 字段如实标出这条消息最终怎么到的。理由:永远失败的通道会往日志里刷一片失败,把真正的原因(没配通道)淹掉,而运营看到"通知失败率 90%"会去查代码,实际上该去找商务。 ✅ 已决策(2026-09-30 客户确认:一期维持两级),备案进契约 §17.2 D-11;接公众号模板/短信是商务动作(认证服务号、短信通道采购),不是代码欠账
F-29 催报的分钟级限流还没做:契约 §3 的表里写了「催报 10 次/分钟/任务」,本轮只做了 Service 层的去重(excludeRecentHours,默认 24 小时),没有做分钟级限流。两者的作用不同:去重防的是"同一个学生被重复催",限流防的是"老师连点把系统打爆"。现在连点 100 次不会造成伤害(第二次起全被去重跳过),但会白跑 100 次全表查询 记为 P1 剩余项(与 §3 的其它限流一起做)。不做的理由:现在没有真实并发压力,而限流放哪个维度(租户/用户/任务)需要与 §3 的其它限流统一,单独加一个会分叉 ✅ 已闭环:补上分钟级限流(10 次/分钟/任务,超限 42901 且话说清等多久;Redis 不可用时放行不瘫痪);e2e-m5-urge.py → 42/42(新增「第 11 次被限流」断言)。

| F-30 | ⚠️ 契约里 notifyBizType 的 5/6 是对调的:§259 那行写「5 导出完成 / 6 催报」,而 §2655 的消息中心示例是「bizType: 2 临期提醒、bizType: 3 驳回」—— 后者能与代码对上,所以不是我看错行。后果很具体:我第一版按契约写 biz_type = '6' 做催报去重,而实际写入的是消息中心的 5,于是去重静默失效(老师点两下学生收到两条),排查时完全看不出是"码对不上" | 统一按消息中心的码(5 催报 / 6 导出完成,见 CollectMessage 的常量与 UrgeService.BIZ_URGE 的注释),并把不一致记在这里。建议:契约 §6 的 notifyBizType 与 §5.9/§13.5 对齐成同一套码 —— 改哪一边要客户确认 ✅ 已闭环:客户确认以 §13.5 为准(5 催报 / 6 导出完成),§2.7 表已改,全文档统一。 | | F-31 | 登录日志只有 PC 账号密码登录(login_type 恒为 1,库里 1101 行全是 PASSWORD):学生从小程序/H5 进来走的是微信授权(/mp/h5/*),那条链路不写 sys_login_log。所以按 §13.4 查"学生登录记录"会是空的 | 本轮没有补(补它要动 MP 授权链路,属于另一处改动),而是在接口与页面的说明里如实写清这是已知边界 —— 否则运维会以为日志功能坏了。记为 P1 剩余项。风险:合规上"谁在什么时候登录过"对学生侧是缺口 ✅ 已闭环:新增 WechatLoginLogger,小程序登录与 H5 授权回调/打桩登录都写 sys_login_log(login_type=2 与 DDL 注释对齐;H5/小程序共用 2,靠 userAgent 区分);e2e-m3-h5-auth.py → 24/24。 |

| F-32 | ⚠️ "发布"与"恢复"都是 →收集中:我第一版让 patchStatus 从 (原状态, 新状态) 反推动作名,于是 resume 被记成了 PUBLISH_TASK —— 而合规要区分的恰恰是"这个任务是第一次发布,还是被暂停后又恢复的"(恢复意味着它曾经被暂停,那段时间学生的填报是被关掉的) | 改成由调用方显式给动作名(patchStatus(id, status, ACTION_*)),并补了契约表里没有但必须有的两个动作:RESUME_TASK / CLOSE_TASK。教训:凡是从结果反推原因的地方,遇到"多对一"就一定会错 —— 而这类错通常表现为"日志看起来有、但答案是错的",比缺日志更难发现 |

| F-33 | publish 原来没走 patchStatus(自己 updateById 设状态 + published_time):把留痕集中到 patchStatus 之后,才发现"发布"这条最该留痕的动作被漏掉了 —— 而它正是任务开始对外收集的时点 | 已改为走 patchStatus,并把 published_time 的写入一起收进去(否则会出现"状态是收集中但没有发布时间"的行,而没人说得清是怎么来的) | | F-34 | sys_occupation_ref(国标职业库)一期是空的(0 行):契约 §13.3 把 OCCUPATION 字典的来源指定为这张表,而它是国标数据 —— 要客户提供或采购后导入,不是我们能生成的。所以现在字典页里「职业(国标)」显示 0 项 | 在字典页与接口说明里如实写清这是已知情况(否则用户会以为功能坏了),并把 COUNT(*) 直接当作 itemCount(有数据时自动就对了,不需要改代码)。建议:正式实施前确认国标职业数据的来源与授权 | | F-35 | ⚠️ F-24 第三次复发:collect_export_file(V11 新建)又漏了 create_by / update_by —— 实体继承 TenantEntity,FillMetaObjectHandler 会填这两列,于是插入报 column "create_by" ... does not exist。这个坑已经踩了四次(collect_task_org / collect_export_task / collect_message / collect_export_file),每次的现象都长得像"SQL 写错了",而实际是建表漏列 | 修了 V11 之后,把这件事变成机械检查:新增 scripts/check-entity-columns.py,从 Java 实体读 @TableName 与基类、从迁移 SQL 读建表列,比对"基类要求的列在不在",并额外查"普通实体带 @TableLogic 但表里没有 deleted"(那是同一类错的另一种形态)。教训:一个错犯第二次就该上工具,第三次说明"记住"这条路是走不通的 |

| F-36 | 写机器检查本身也会假阳性:check-entity-columns.py 第一版直接搜 @TableLogic 字符串,结果把 4 个实体的类注释也算进去了 —— 而那四段注释恰恰在解释"为什么不继承基类"(里面当然会出现 @TableLogic 这个词)。表现是"4 个实体报错",而我差点据此去改那四个本来就是对的实体 | 改成先去注释再找注解。教训:静态检查一定要能区分"代码"与"解释代码的文字" —— 否则它会指向正好写着"这里不该这么做"的地方 |

| F-37 | @Scheduled 的定时任务在测试里等不起:导出包过期清理每小时跑一次,而验证"过期之后下载会怎样"总不能把过期时间改成 7 天后再等一小时 | 加了 POST /exports/cleanup-run(手工触发,权限同导出管理员)。清理本身可重复执行(已清理的卷会跳过),所以手工触发没有副作用。这是本项目第一个定时任务,所以顺手在 FillApplication 上写了"别顺手加更多:定时任务是「没有请求也会发生的事」,每加一个都要能回答「它失败了谁会知道」" | | F-38 | ⚠️ collect_file.source 的口径在两份文档里冲突:PRD 的建表注释(第 1894 行)是「1 本人上传 / 2 班主任代传 / 3 管理员代传 / 4 整理器导入」,而接口设计 §3.6 的字段表(第 464 行)是「1 聊天记录 / 2 相册 / 3 手机文件 / 4 代传」。两者都是 4 个值但含义完全不同 | 以 DDL 为准(它是"这一列表示什么"的定义;已有数据全为 1 与它一致;PC 端 FILE_SOURCE 常量已按它写),并在列注释里写明冲突 —— 见 F-39 的同类处理。建议:契约 §3.6 的字段表要改成 DDL 口径,否则下一个人会按字段表写 ✅ 已闭环:客户确认以建表注释为准(谁传的),§3.6/§9.5 字段表已改。 |

| F-39 | ⚠️ collect_file.source 从来没有被代码显式写过:库里那 10 行全是列默认值 1,也就是「学生自己传的」与「老师代传的」在数据上完全区分不出来。而契约 §10.6 明确要求代传必须留痕(source = 4/我按 DDL 用 2) | V12 起写入侧显式赋值:学生上传写 1、代传在插入后改成 2 并记 proxy_by + proxy_reason。为什么还加 proxy_by:source=2 只说"是班主任代传的",说不出是哪个班主任、为什么替他传 —— 而契约要求时间轴显示「由李老师代传」,名字只能存在这里 |

| F-40 | ⚠️ precheck.canExport 在"没有任何候选"时仍然为 true:canExport = blocking.isEmpty(),而一个"没人提交过"的任务 blocking 当然是空的(没有文件就没有不合格文件)→ 按钮被点亮,点下去却报"没有可导出的数据"。这直接违反契约 §12.4 的铁律(canExport=false 时前端禁用按钮)——反过来说,按钮亮着就该能导出 | 修成"没有阻断项 且 有东西可导",并把「还没有人提交填报单」也放进 blocking(前端只读它渲染"卡在哪",不给理由会让人以为系统坏了)。E2E 加了回归防线 |

| F-41 | ⚠️ onlyApproved 在前置检查里根本没生效,而阻断项给用户的建议正是「也可以改选『只要已通过的』」—— 照着提示做仍然导不出来,用户会以为系统坏了。而打包那一步(pack)是看 onlyApproved 的,所以两处口径原本就不一致 | 让 precheck 也认 onlyApproved:勾了之后未审完的人不进包、也不再阻断,但必须留一条 warning 说清"有几个人因此没进包"(静默少人才是真危险:客户拿到包会以为人齐了)。E2E 加了回归防线 |

| F-42 | ⚠️ E2E 的 cleanup 静默失败:R.sql(...) 的返回值一直没人看,而"已发布的任务删不掉"这件事没被处理 → 残留的 roster 行让后面的脚本(e2e-m5-teacher-dashboard.py)算出错误人数(应填 3 变 4),三条断言失败。排查时完全看不出是"上一个脚本没清干净" —— 更难的是:单跑那个脚本是通过的,只有全量回归才红 | 把 cleanup 改成可审计:按依赖顺序删 → 逐条记结果到 scripts/_e2e-clean-report.txt → 末尾校验一行都没剩 → 有问题就打印出来。教训:清理是测试的一部分,"清理失败"必须和"断言失败"一样可见 |

| F-43 | 门禁核验按用户 1 小时 10 次限流,而演示脚本每次都用同一批演示 uid ——一个小时内跑到第 4 次就撞 40071,整脚本 SystemExit。表现极具误导性:调用它的 E2E 报"演示数据没摆成" | 加 dev-seed-audit.py --reset-gate-limit(显式开关,不默认清 ——限流是生产行为,不该被脚本顺手改掉)。连带踩到的:xt-redis 开了 requirepass,而 subprocess.run(capture_output=True) 不看返回码 → 表现为"清了但没清掉" | | F-44 | ⚠️ TaskService.create 对缺必填项抛 NPE(对外 50000)而不是校验错误:req.getMajorName().trim() 在 majorName 为 null 时抛 NullPointerException,而 GlobalExceptionHandler 把它变成「系统繁忙,请稍后再试」—— 真正的原因是"你没填专业名称"。这类坑每个字段都会踩一次 | 在 create 开头集中判 taskName/batchNo/majorName 三个必填项并给出字段名。(发现路径:历史整理器导入时没传 majorName → 500,翻日志才看到 NPE) |

| F-45 | ⚠️ collect_task.task_code 是 NOT NULL 且由 TaskService 生成 —— 绕过它直接 taskMapper.insert 会报 null value in column "task_code",对外同样是 50000 | 历史整理器的导入改成走 TaskService.create(复用编码生成与校验),而不是自己再写一份编码逻辑 —— 两处各生成一次编码迟早会撞号 |

| F-46 | ⚠️ FileProcessService.renderName 的 ext 参数必须自带点号 (既有调用点是 "." + ext)。传裸扩展名不报错,只会产出「身份证jpg」这种文件 —— 文件系统按无扩展名处理,客户双击打不开。少一个字符的错误不会报错,只会让交付物不可用 | 调用点改成 "." + ext,并在注释里写明这个约定 |

| F-47 | ⚠️ 异步线程里回查"刚插入的那一行"必然查不到:create() 带 @Transactional,插入 job 后立刻投递异步任务,而异步线程与请求线程是两个事务 —— 请求线程还没提交,异步线程查不到 → 「整理任务不存在」,整次整理白跑。这类时序问题不能用 sleep 解决(只是把概率变小) | 让异步方法直接收 job 对象(调用方手里就有),去掉"回查"这个动作本身 |

| F-48 | ⚠️ "候选名判定"用 contains 会吃掉真名:解析历史文件名时,噪声词表里只要有「整理」这种 2 字词,contains 判定就会把「整理甲」当噪声滤掉 —— 表现是所有文件都归不了位(报告里一堆 orphan,串档检测也失去基准)。我先后写了两版都栽在同一个地方(第二版留了"≤2 字词可被包含"的例外,而"整理"正好 2 个字) | 判据彻底改成:噪声词只做精确匹配;"是不是材料/班级词"分别由材料词表与「完整中文串长度 2~4 + 不含数字 + 不含班级词」判断({2,4} 截断也会把「新建文件夹」切成「新建文件」而当成人名)。教训:中文人名与业务短词无法用"包含"区分 |

| F-49 | ⚠️ 参数校验排在幂等判断之后就等于没校验:整理结果导入的 importFiles=true(一期做不到,本该 40044)在第二次导入时被安静接受 —— 因为请求先命中了"已经导过了,直接返回原任务"。一次不合法的请求不该因为"恰好之前导过一次"而变成成功 | 参数校验提到幂等判断之前 | | F-50 | ⚠️ collect_validation_issue 与 collect_rule_template 两张表 V1 就建好了,却一直没有代码用它们(连 expected_val/actual_val/auto_fixed/resolved_by 都有,与契约 §3.9 逐列一致)。也就是"当初就设计好了",只是 §12.1~12.3 三个接口没做 —— 而盘点工具只比"契约 vs 控制器",看不到"表建了但没人用" | 本轮把问题清单做完;collect_rule_template(§4.3 的规则模板 2 个接口)仍待做。教训:盘点时要连"迁移里建好的表有没有代码在用"一起算,否则会重复设计 |

| F-51 | ⚠️ 契约的 POST /validation/run 声明 async 默认 true 并返回 jobId,但整个契约没有"查作业状态"的接口 —— 那个 jobId 查不到任何东西 | 一期同步跑完(实测 1 份填报单 <100ms),jobId 只表示"本次运行",并加返回 checkedSubmissions / issuesFound / unresolvedLeft / elapsedMs,让界面能立刻显示结果 —— 比返回一个查不到状态的作业号有用(加字段记在 F-52) ✅ 已闭环:客户确认改同步并返回统计字段(checkedSubmissions/issuesFound/unresolvedLeft/elapsedMs),§12.1 已改。 |

| F-52 | ⚠️ 契约 §12.7 的模板接口链断在第一步:GET /notify/templates 返回的内置模板没有 id(它不在库里),而 PUT /notify/templates/{id} 要 id —— 用户点"编辑"时前端根本拿不出一个 id。文档上看起来是通的 | 补 POST /notify/templates/{code}/customize(把内置文案复制成库里一行,幂等)与 DELETE /notify/templates/{code}(恢复默认);另补 GET /notify/templates/variables(变量白名单必须服务端给,前端抄一份迟早不一致)与 POST /notify/send 的rosterIds / 整任务发法 |

| F-53 | ⚠️ 微信回调必须偏离两条全局约定:①它是匿名入口(微信不带我们的登录态);②响应体是微信格式 {"errcode":0} 而不是本项目的 {code,msg,data} | 两条都写进注释,并说明权限从哪来:只写推送状态、不读业务数据 + 必须验签(缺 fill.mini-program.callback-token 时明确拒绝,而不是跳过验签)+ 凭发出时记的 recordId/MsgID 反查租户(查不到就报错,绝不猜一个租户去写 —— 写错租户等于把数据丢进别人家) |

| F-54 | ⚠️ PostgreSQL 把未加引号的别名折叠成小写:rule_code AS ruleCode 返回的键其实是 rulecode,Java 侧 m.get("ruleCode") 拿到 null —— 对外表现是"按规则分组"整块全 null,而 SQL 完全正确、日志里查询成功、行数也对 | 别名写成 AS "ruleCode"。这条对任何用 Map 接的查询都适用 |

| F-55 | ⚠️ PostgreSQL 推断不出 ? IS NULL 里参数的类型:WHERE (#{taskId} IS NULL AND task_id IS NULL OR task_id = #{taskId}) 报 could not determine data type of parameter(对外 50000)| 改用 <script> + <choose>,让传 null 时那个参数干脆不出现在 SQL 里。教训:注解 SQL 里的"可选条件"不能用"参数与 NULL 比较"的写法 |

| F-56 | ⚠️ 软删除与业务唯一索引打架:@TableLogic 让 delete() 变成 UPDATE deleted=1,而 uk_notify_tpl 唯一索引不看 deleted → "恢复默认 → 再自定义"撞唯一约束(500)。这类冲突在"软删除 + 业务唯一键"的组合下必然出现,只是要到第二次操作才暴露 | 模板覆盖行改为物理删除。判断依据:这张表的行是"配置"还是"业务数据" —— 前者的历史价值低、而唯一键冲突的代价高 |

| F-57 | ⚠️ PUT /tasks/{id}/form-schema 是有的(FormSchemaService.save 实现了),但规则模板的 FORM 部分没有落地的应用路径 —— RuleTemplateService 只能"跳过并说明原因"。所以模板的 FORM 部分现在等于装饰 | 如实 skipped + 原因(不假装套用了)。建议:下一步把「应用表单定义」接上,或把模板的 FORM 部分从契约里去掉 ✅ 已闭环:FormSchemaService.saveRawVersion(版本化保存只有一份),RuleTemplateService 的 FORM 分支真正应用;e2e-m7-taskconfig.py → 75/75。 |

| F-58 | ⚠️ 契约 §7.13 只给了「列表」与「应用」,没有「保存为模板」 —— 而没有它,"用户自建模板"永远是一句空话:模板只能靠改库或写迁移产生,而 §7.13 明确说列表要"含公共模板与租户自建" | 补 POST /tasks/{id}/save-as-template,并加边界:只有平台管理员能把模板设为公共(公共模板对所有租户可见,否则任何一个学校都能把自己的配置推给所有人 —— 那不是权限问题,是产品问题) |

| F-59 | ⚠️ 公共模板会被租户拦截器挡掉:规则模板的公共模板是 tenant_id = 0,而拦截器会给查询补 tenant_id = <当前租户> → 公共模板一条都查不出来,而表现是"列表空着但不报错"(功能看起来没做) | RuleTemplateMapper 用 @InterceptorIgnore(tenantLine="true") + 手写 (tenant_id = 本租户 OR (is_public = 1 AND tenant_id = 0)),并把"关掉框架保护之后由这一行保证隔离"写进注释(与 MpSceneMapper 同一做法)。这是"默认拒绝"体系里必须主动开的口子,而不是顺手关掉 |

| F-60 | ⚠️ Excel 索引调色板里同一个红色有两个下标:FF0000 既是 2 也是 10,而 POI 的 IndexedColors.RED 用 10、openpyxl 的 COLOR_INDEX 第一次出现是 2 —— 写死任何一个都会在另一边红。这条本身只是测试断言的问题,但它暴露了更普遍的一类:跨库的"同一个常量"要按值查、不要按位置查 | E2E 改成按索引反查颜色值再比较(COLOR_INDEX[index]) | | F-61 | ⚠️ 初审待办会列出"我批不了"的单子 —— 待办与审批用了两套范围判据:待办按 s.org_id IN orgIds,而 orgIds 在 taskId 为空时是「我在任意任务里负责过的班级」;审批按「这份单子所属任务里这个班归不归我」。于是「我在任务 A 是班级 43 的初审人、在任务 B 不是」时,待办照样列出任务 B 的单子,点通过回 40301。真实场景里"同一个班在不同任务的初审人不同"很常见(换班主任、分批指定),所以这不是脏数据才有的问题 —— 而表现是"我权限没配好",用户只能一条条点、一条条被拒(最糟糕的那种交互) | 待办改成与审批同一套判据:EXISTS (select 1 from collect_task_org cto where cto.task_id = s.task_id and cto.org_id = s.org_id and 我是它的初审人)。改动覆盖 pageTodos 与 counts(含四个档位计数),并在 E2E 里加了性质断言(待办里每一条都必须通过同一套 SQL 判据),而不是"待办有几条"这种随夹具变化的断言 | | F-62 | ⚠️ 契约 §7.13 只给了「列表」与「应用」,没有「保存为模板」 —— 而没有它,"用户自建模板"永远是一句空话:模板只能靠改库或写迁移产生,而 §7.13 明确说列表要"含公共模板与租户自建" | 补 POST /tasks/{id}/save-as-template,并加边界:只有平台管理员能把模板设为公共(公共模板对所有租户可见,否则任何一个学校都能把自己的配置推给所有人 —— 那不是权限问题,是产品问题) | | F-63 | ⚠️ 公共模板会被租户拦截器挡掉:规则模板的公共模板是 tenant_id = 0,而拦截器会给查询补 tenant_id = <当前租户> → 公共模板一条都查不出来,而表现是"列表空着但不报错"(功能看起来没做) | RuleTemplateMapper 用 @InterceptorIgnore(tenantLine="true") + 手写 (tenant_id = 本租户 OR (is_public = 1 AND tenant_id = 0)),并把"关掉框架保护之后由这一行保证隔离"写进注释(与 MpSceneMapper 同一做法)。这是"默认拒绝"体系里必须主动开的口子,而不是顺手关掉 | | F-64 | ⚠️ PUT /tasks/{id}/form-schema 声称实现,但规则模板的 FORM 部分没有落地的应用路径:RuleTemplateService 只能"跳过并说明原因"。所以模板的 FORM 部分现在等于装饰 | 如实 skipped + 原因(不假装套用了)。建议:下一步把"应用表单定义"接上,或把模板的 FORM 部分从契约里去掉 ✅ 已闭环:同 F-57;顺带补上「存为模板也存表单」(此前存模板不存表单、套模板用不上,两头各缺一半)。 | | F-65 | ⚠️ 契约 §14 的路径与 §1 的路径约定冲突:§14 的详情节写 POST /open/v1/auth/token(不带 /api/v1),而 §1 声明全部接口在生产是 https://api.tianbaotong.cn/api/v1/** | 按 §1 走,实现为 /api/v1/open/v1/...(与其它 110 多个接口同一前缀,网关只需一条规则)。check-api-parity.py 的 norm() 会去掉 /api/v1 前缀,两种写法都能对上,所以工具不会替我们发现这个冲突 —— 它只能靠人读文档发现 ✅ 已闭环:§17.2 D-7 写明统一 /api/v1/open/v1/... 前缀(网关一条规则)。 |

| F-66 | ⚠️ 契约 §14.4 把一个"我方 → 机构"的报文写成了我方的一个路径:正文说"系统在终审通过后主动推送",请求示例也标注"我方 → 机构",但接口清单里它是 POST /open/v1/callback/push(一个我方路径) | 实现为人工重推入口({submissionId}),并重置重试次数 —— 它是"人判断过该再试一次",不该被"已经失败 5 次"挡住(那正是需要人工的场景)。自动推送与自动重试分别发生在终审通过的 afterCommit 与定时任务里 |

| F-67 | ⚠️ 契约 §14 定义了"机构怎么调我们",却没定义 appKey / appSecret 从哪来 —— 没有管理接口,§14 的四个接口在真实流程里跑不起来:只能靠改库产生接入方,而 appSecret 是密文列(有人得手工加密一个字符串) | 补 POST/GET /open-apps、/{id}/reset-secret、/{id}/toggle、/push-logs,并守住两条:appSecret 只在创建/重置时返回一次;重置是显式动作(它会让对方现有签名立刻失效,要在界面上交代清楚) |

| F-68 | ⚠️ 规则模板被我挂错了路径:契约 §4.3 的清单写 /rule-templates(即 /api/v1/rule-templates),而我挂在了 TaskController(base /api/v1/tasks)下 → /api/v1/tasks/rule-templates。是契约对、我错:模板是独立资源(有 id、可被多个任务套用),不是任务的下级 | 新建 RuleTemplateController(base /api/v1/rule-templates)。这个错是 check-api-parity.py 报出来的 —— 它按完整路径比对,所以"路径挂错"和"没实现"在它眼里是同一个现象(都算缺口)。这也是它作为验收工具的价值:只要路径与契约不符就会红 |

| F-69 | ⚠️ 开放接口的令牌路径必然 500:appOfToken 里用 selectById 回查接入方,而开放接口是匿名的 —— 请求进来时还没有租户上下文,租户拦截器直接拒绝(未登录或登录已失效),对外是 50000「系统繁忙」。HMAC 路径不出现(它走 findByAppKey,已忽略租户),所以只有令牌路径 500,看起来像"令牌功能没做" | 凡在"还没有租户上下文"时查归属都用忽略租户的专用方法(findByAppKey / findByIdIgnoreTenant / findBySubmissionIgnoreTenant),拿到归属后立刻 TenantScope.callAs 摆上下文。"先找归属、再摆上下文"这个模式里,找归属那一步必须能无租户执行 —— 范围就是那几个方法,逐个写清理由 |

| F-70 | ⚠️ nonce 的占用顺序反了会变成拒绝服务入口:我原来的顺序是"先占 nonce 再验签",注释还写了一句"反过来的话错误签名能消耗 nonce"—— 这句话恰好说反了。先占的话,任何人发一个签名错误的请求就能把那个 nonce 消耗掉;先验签则签名错直接拒、nonce 不被碰 | 改成先验签、后占 nonce。E2E 有一条专门守它("错误签名不该消耗 nonce")—— 这条断言的价值在于:它测的是一个副作用,而不是返回值 | | F-71 | ⚠️ §9.5 的"直传凭证"给的是 S3 风格的签名 PUT URL,而本项目存储走七牛 —— 七牛直传是"POST 到 upload.qiniup.com + form 带 token",而且必须配回调(callbackUrl)才能让服务端知道"传完了、文件在哪"。没有回调,文件会安静地躺在存储里,而库里没有任何记录指向它 | 一期如实返回 direct: false + uploadUrl = 本项目的接收地址(学生端 wx.uploadFile 直接 POST 它,与 H5 路径完全一样)。凭证这一层仍保留它真正的价值:预检 + 秒传 + 压缩提示 —— 这三件事都能在"不传一个字节"的情况下完成,而那正是它要解决的问题(传了 8MB 才发现格式不对/重复了) ✅ 已闭环:§9.5 重写为 direct:false + 服务端接收地址;恢复直传的前置条件(含七牛回调)写进 §17.2 D-3。 | | F-72 | ⚠️ uploadToken 签发之后没有"消费"它的地方:我按契约实现了凭证的签发与预检,但上传接口没有接它(/mp/files/upload 仍然只认 submissionId + materialCode)。于是凭证目前只用于"预检与秒传判断",不构成一道校验 —— 客户端完全可以跳过凭证直接上传 | 如实记录(不假装它是安全机制)。建议:要么让 /mp/files/upload 接受并校验凭证(一次性、15 分钟、绑定到人),要么把它定位成"纯性能优化"并在契约里写明 —— 现在两种都不是 ✅ 已闭环(客户确认定位为预检/秒传/压缩建议,非安全边界):契约 §9.5 明写「客户端可跳过;服务端上传本身做全部校验」。 | | F-73 | ⚠️ 秒传检查排在份数检查之后 —— 而"同一个文件重传"恰恰是秒传要解决的场景(学生传完网络断了、客户端重试)。顺序反了会得到「身份证最多 2 份,你已经传了 2 份。要换请先删掉上一份」,让用户在"我明明刚传的"这件事上原地打转 | 改成先秒传、后份数。顺序体现的是"哪个答案对用户更有用":「你已经传过了」永远比「你超了」更有用,因为前者不需要他做任何事 | | F-74 | ⚠️ 平台运营账号(platform)登录会撞图形验证码:LoginGuard 在连续失败后要求验证码(xt:login:fail:{account}),而验证码是图片、脚本给不出来。这不是缺陷(那是防爆破的设计),但它让"验收运营类接口"卡在第一步 —— 而 §6.5/§6.7 的权限正是"运营" | E2E 先清 xt:login:fail:platform 再登录,并把这个 key 写进注释(真实验收时运营账号被锁,运维也要知道清哪个)。⚠️ 顺带确认:平台账号的密码是 Platform@123(在 docs/07-开发实施计划.md),不是学校管理员的 Admin@123 —— 我先按 Admin@123 试,得到"账号或密码错误",很容易误判成"平台账号没建" | | F-75 | ⚠️ 压缩率会出现负数:一个 3 个小文件(各 40 字节)的包,raw=120B、final=802B → 页面显示 -568.3%。原因是"压缩"在小包上本来就不适用 —— 里面的文件已经是压过的(jpg),或者重新打 zip 的包头开销比省下来的还多。问题不在"不好看",而在客户会以为系统算错了(而它没算错) | 服务端对 ratio < 0 回 —(与"还没算"同一个符号:都是"这个数说明不了什么")。⚠️ 顺带发现 E2E 的断言太弱:"%" in compressRatio 对 -568.3% 也成立 —— 它确实"是个百分比",只是没有意义。已改成"要么形如 12.3%(非负),要么是 —" | | F-76 | ⚠️ GiButton 没有 text prop:它的文案来自 type 的预设(BUTTON_MAP)或默认插槽(<slot>{{ buttonConfig?.text }}</slot>)。我写成 <GiButton type="add" text="上传压缩包整理" />,typecheck 过了、控制台没报错、按钮也正常渲染 —— 只是显示的是预设的「新增」 | 改用默认插槽传文案。教训:「传了没报错」不等于「生效了」 —— 这类错只有真正打开页面看才会发现。所以前端的验证底线是在浏览器里看一眼,typecheck/lint 过不代表对(未知属性在 Vue 模板里完全合法) | | F-77 | ⚠️ 我自己在验证时用错了选择器,差点去"修"一个不存在的 bug:我用 .arco-table-tbody .arco-table-tr 数行数,得到 0 行、判定"表格没渲染";真相是 Arco 在无 scroll.y 时 <tbody> 上不带 arco-table-tbody 这个 class,行一直都在(document.querySelector(".arco-table tbody").innerText 立刻能看到) | 记下来是因为它值一条纪律:"测出来是 0"与"确实是 0"是两件事 —— 断言要打在真实的 DOM/响应上,并且先确认这个度量本身是有效的(换一个选择器、或直接读文字,交叉验证一次)。这与之前 F-60(POI 与 openpyxl 对"红色"的索引不同)是同一类:度量工具的偏差会伪装成产品的缺陷 | | F-78 | ⚠️ 「待建」页在说谎,而且说了很久:名单管理 / 材料规则 / 表单设计 的功能早在前面几轮就做完了(作为「收集任务」行内的抽屉),但这一页对它们一律说「这个页面还没做」。原因不是"忘了改文案",而是这一页从来没读到过菜单定义 —— 它按 route.meta.menu 找,而 formatAsyncRoutes 构造的 meta 里根本没有 menu 这个键(只有 hidden/keepAlive/title/icon/affix/…)→ findMenu() 恒为 undefined → 每个菜单项自己的 note 从来没显示出来过 | 给 XtMenu 加可选的 entryAt(真实入口在哪),并按路径查找(findMenuByPath(route.path),不动 Gi 模板的共享代码)。待建页据此分两种说法:有 entryAt → 「功能已就绪」+ 指路 + 跳转按钮;没有 → 原来的「还没做」。教训:一个"取值永远失败"的表达式,表现是"文案不对",而根因跟文案毫无关系 —— 与 F-77 一样,先确认取值本身有效,再改下游 | | F-79 | ⚠️ 又一处把 Markdown 写进纯文本节点:菜单 note 里的 **按任务** 在页面上原样显示成两个星号。这与 F-76(headlineOf 里的 **)完全同类 —— 同一天里犯了两次 | 清掉全部 3 处(并扫了一遍所有 note: 而不是只修看到的那条)。记这条是因为"同一类错犯两次"说明它不是我手滑,而是缺一条检查:写文案时先问一句"这段文字最终落在哪里、那里支不支持富文本" | | F-80 | ⚠️ 脚本改代码时把"跨行拼接的属性"腰斩了:roster 的 note 本来是note: '第一段' + 换行 + + '第二段' 两行拼的,我的补丁按"整行替换"处理,只换掉了第一行,于是留下一个孤立的 + '导入、体检报告…',整个文件语法错误(typecheck 报在几百行之外,看着完全无关) | 按多行结构重写这一段。教训:补丁用"整行替换"时,必须先确认这个属性是不是多行的 —— find() 命中一行不等于这个属性只有一行。(这与之前 F-57 那次"find() 返回 -1 却继续往下写"是同一族:基于文本的补丁要有"结构假设",而假设必须被验证) | | F-81 | ⚠️ 平均审核时长返回 17 位浮点数:AVG(duration_sec)/3600.0 的 PostgreSQL 双精度结果直接透出,界面上是 0.00038888888888888887 小时。那不是"精确",是"没处理" —— 既不可读又让人怀疑系统算错了。而且这个字段开放接口(§14)也看得到,对外报文里出现这种东西很显眼 | 服务端 round2() 四舍五入到 2 位(avgAuditHours 与 byAuditor.avgHours 两处);null 仍保持 null("没有样本"与"0 小时"是两件事)。前端再按量级换单位:null→—、0→不到 1 分钟、<1→X 分钟、否则 X 小时. 这不是美化,是别让人读出一个不存在的结论(0.0 小时 会被读成"零小时") | | F-82 | ⚠️ 首页在说一件不真的事,而且没人会发现:工作台底部那张卡写着「M0 后端已完成,界面建设中」+ "界面按里程碑推进:M1→M2→M3→M5→M6"。而实际上契约 121 个接口里 120 个已实现、10 个 PC 页面可点。它不报错、不影响任何功能、也没有测试会红 —— 所以它能一直错下去,而每个新用户打开首页第一眼看到的就是它 | 整块换成真实看板。教训:说明性文案比功能代码更容易过期,因为它没有测试守着。写"进度/现状"类文案时,要么让它从数据里来(这次的做法),要么就别写具体数字(写成"详见交接摘要"也行)。本轮同时核销了 §四 里 8 条"已完成但仍挂未完成"的条目 —— 那份清单过期同样属于这一类 | | F-83 | ⚠️ Map<String, Object> 结果的别名会被 PostgreSQL 折成小写 —— SELECT tenant_id AS tenantId 在结果里是 tenantid,于是 row.get("tenantId") 拿到 null。同一个写法,两种结果:返回 POJO 的 mapper(UrgeMapper)不受影响(MyBatis 按属性名忽略大小写匹配),所以这个坑只在 Map 结果上出现 —— 照着别的 mapper 抄一遍就会中招。它的表现极其安静:定时任务照跑、TenantScope.runAs(null, ...) 抛了被 catch 吞掉、日志里只有一行 warn,看起来跑过了,其实什么都没做 | 别名一律 AS "camelCase"(与 F-54 同一条规矩,这是同一个坑的第二次)。另外在四个任务里都加了候选行自检:缺 tenantId 就把"候选行的实际键"打进日志,下次一眼能看到是 tenantid 不是 tenantId | | F-84 | ⚠️ 「HTTP 200 + code 0」不等于业务动作成功:我在 E2E 里断言提交接口 code == 0 就算"学生已提交",而契约 §9.5 明写「校验不通过也是 HTTP 200:它是正常业务结果而不是异常」。于是乙(没传必传材料)的 code=0 而 data.submitted=false、填报单还是草稿、名册还是「草稿」 | 断言改成判据字段 data.submitted,并把 redCount 与 issues 打进失败信息里。凡是"校验可能不通过"的接口(提交、发布、导入、批量审核…),判据都在 data 里 —— 夹具断言"动作成功"时必须断言那个动作自己的结论字段。这个错的表现极具欺骗性:夹具"通过了",后面所有断言全红,看起来像被验的功能坏了 | | F-85 | ⚠️ 清理只在"正常结束"时跑,而失败路径才是残留的主要来源:E2E 里 20 多处 raise SystemExit(夹具失败即中止)都会跳过结尾的 cleanup(),于是上一轮的消息留在库里,把这一轮的"不该发"断言全部污染成失败(看起来像催报去重坏了) | 两头都清:开跑前先 preclean()(保证起点干净),结尾再清一遍。顺带修了一个观测问题:Python 的 stdout 在管道里是块缓冲、stderr 不缓冲,所以 raise SystemExit 的消息会出现在日志最前面,看起来像"前面某一节就死了" —— 结尾加 sys.stdout.flush() | | F-86 | ⚠️ "检查配置"的代码自己先崩在"配置有问题"上:生产自检原本要在启动时列出"缺哪些环境变量",而我用 env.getProperty(key) 去读 —— 它会解析占位符,而 prod 配置里写的是 secret: ${XT_JWT_SECRET},环境变量没设时它抛 PlaceholderResolutionException。于是精心写的那张清单一个字都打不出来,用户看到的还是一段 56 行栈 | 改成安全解析(解析失败才算没配)。⚠️ 中间我还修错了一版:改成只读 PropertySource.getProperty(完全不解析)之后,读到的永远是字面量 ${XT_JWT_SECRET},于是 env 明明设了值也报"未设置" —— 这个错更隐蔽("真没设"的场景看起来完全正确)。一般化:做配置检查的代码必须能区分"没配"与"配了",而且要比被检查的配置更皮实 | | F-87 | ⚠️ 自检写成 ApplicationRunner 就轮不到它:ApplicationRunner 跑在容器就绪之后,而缺 XT_JWT_SECRET 时 JwtService 在构造 bean 时就炸了 —— 自检根本没机会说话 | 挪到 EnvironmentPostProcessor(在 META-INF/spring.factories 里注册),它跑在任何 bean 之前,只需要 Environment。⚠️ 注册错的表现是"检查静默不生效"(应用照常启动、毫无报错),所以 scripts/e2e-prodguard.py 是真的起一次 jar 来验证,而不是看代码在不在 | | F-88 | ⚠️ 我定义了 CI 的 e2e 模板却没写 extends 它的 job:.e2e-base 里写好了"起 PG/Redis 容器、装依赖、生成随机密钥、起后端、循环跑全套 E2E",而 YAML 里没有任何 job 引用它 —— 流水线解析完全通过、阶段名也都在,唯独这个项目唯一的主验证手段根本没跑 | 补上 接口断言(全套 E2E) job,并在文档里记下这次是怎么发现的:用 yaml.safe_load 把 job 名列出来数一遍。"看起来有 e2e 阶段"和"真的跑了 e2e"是两件事 —— 配置类文件也需要断言(光"能解析"不够,还要断言"该有的都在") | | F-89 | ⚠️ "造一个合法密钥"这件事我连错两次,而且错法一样:第一次 AES 假密钥解出来是 30 字节(要 32),第二次 HMAC 那串根本不是合法 base64。两次都不是配置的问题,是我在"手写字面量当密钥"上想当然 —— 而它们的表现都是启动失败 + 一个栈,看起来像"配置写错了" | 改成 base64(os.urandom(n)) 生成。顺带验了 CI 里那三条 openssl rand -base64 N 的长度是对的(64→88 字符、32→44、48→64,解回都是原字节数)—— 否则流水线会因为密钥长度红掉。⚠️ 同时把"密钥长度"收进 ProdEnvCheck:不改的话运维看到的是一个 85 层栈里的 AES-256 需要 32 字节密钥,实际 30 字节,而那个信息本该出现在那张清单里 | | F-90 | ⚠️ "起来了"被误报成"起不来":check-test-profile.py 在 Tomcat 打印 Tomcat started 的同一瞬间就发健康检查,那时端口还没开始接受连接 → connection refused → 脚本报"起不来",而日志里明明是 Tomcat started on port 18098。误报的方向恰好最危险:它会让人去查一个不存在的问题 | 健康检查加 20 次重试。⚠️ 同一个脚本还有第二个缺陷:写死端口且 kill() 后不等退出,第二次跑就撞端口,报的错(Port xxx already in use)与"配置对不对"无关 —— 一个只能跑一次的检查等于没有检查,改成每次 socket 要一个空闲端口 + wait(timeout=15)。并把它加进 CI(在 e2e 起服务之前单独跑一步:失败了就是"配置起不来",而不是"E2E 脚本有问题") | | F-91 | ⚠️ PRD 附录 E 里有一条定时任务,对应的机制在 V1.5 就被废除了:附录 E 第 6 条写着「待认领名册清理|每日|超时未认领的人员移出应填名单并通知管理员」,而 PRD 正文 §"容错与纠正对照表"明确写着:「📌 已废除:原矩阵中"不在名册 → 转待认领队列"—— "自助认领"机制已在 V1.5 废除(会绕过门禁)。证件号不在名册时的正确出路是引导走「申请加入名单」通道(FR-509),由管理员逐条审批后入册」 | 不做这一项,并把 9 项核销为 8 项。⚠️ 为什么这条值得单独记:附录 E 是一张看起来最像清单的清单(9 行、有频率、有说明),而它漏跟了正文的一次机制废除 —— 照着它做,会把一个已被产品明确废除的门禁旁路重新实现出来。清单类附录最容易过期,因为它没有"会被读到"的必然性(正文条款在讨论时一定会被翻) | | F-92 | ⚠️⚠️ 门禁限流会把整间教室挡住(压测抓到的真缺陷):IP 限流 20 次/分钟计的是所有请求(成功也算),于是 60 个不同学生同一出口 IP 同时核验时,只有 20 个能进,其余 40 个收到 40071 操作过于频繁。而"一个班的学生坐在同一间教室、连同一个 WiFi"正是首次落地第一分钟就会发生的事 —— 这不是性能问题,是上线即事故 | 拆成两个计数器,因为目的不同:失败(按人 10/小时、按 IP 20/分钟)用于防猜与防枚举;成功(按 IP 200/分钟)只用于防"拿这个接口批量取数据"。关键洞见:枚举攻击产生的是失败,而教室产生的是成功 —— 把成功也算进防枚举额度里,等于用防攻击的代价挡住正常用户,而挡住的量级恰好是"一个班"。⚠️ 另有一条刻意不计的失败:GATE_ALREADY_BOUND(两个人抢同一个名册项)——开学第一天必然出现几例,算进额度会让整间教室在几秒内被限流,而没有人做错了什么 | | F-93 | ⚠️ 压测会打到真实对象存储:本机 application-local.yml 里 provider: qiniu,所以上传压测落的是线上那个空间(bucket=tools-matrix)—— 40 张图打过去,七牛回了 status:614 | 上传压测改成默认不跑(--with-upload 才跑),并在脚本里打印"要跑请先 XT_STORAGE_PROVIDER=local 重启后端"。两件事都不对:压测不该依赖外部服务(网络抖动会让"性能结论"变成对七牛的评价),也不该往真实桶里灌测试文件(删库不删桶) ✅ 已闭环:压测改为打本地存储实例(docs/09 §9.4 推荐跑法);真实桶里 2036 个测试对象 / 116 MB 已用 scripts/dev-clean-qiniu.py 清空(只动 xiaotutongxue/ 前缀,删前核实库中零引用,删后复验 0 个)。 | | F-94 | ⚠️ 我自己在压测里犯了三个度量错误:① 吞吐算成了 n / (avg × n),化简就是 1/平均耗时 —— 那不是吞吐,而"一行看起来很像结论的数字"最容易被抄进汇报里;② 只报"混合 p95",看不出哪个接口慢(而"该优化哪个"才是压测的目的);③ 用两个并发数字推断出"每行约 2.8ms",而单请求复测(20/50/200 页 → 47/36/44ms)直接否掉了它 —— 并发下的差异是排队噪声,不是按行成本 | ①改成"请求数 / 墙钟耗时";②按端点分别统计;③把"单请求中位"也纳入报告,并在 §9 里写明"并发数字不能用来推断每行成本"。教训:并发压测的数字只能回答"系统在并发下的表现",不能反推单请求的成本结构 —— 要问"哪里慢"就得用"数 SQL 条数 + 单请求计时 + EXPLAIN"这三样 | | F-95 | ⚠️ 手册初稿里有两处"按设想的系统写",而不是"按真的系统写":① 我写了「同一张图重复传会被自动识别(秒传)」—— 而 /mp/files/upload 只计算 file_hash、没有去重查询(秒传逻辑在 /mp/files/upload-token 里,而 F-72 已记过:上传接口并不消费它)。手册这么写会让学生以为重复传不占份数,然后撞上份数上限;② 我写了「代传要填原因(这是审计要求)」—— 而 proxyReason 是选填,手册说"必须填"会让老师以为界面坏了(因为不填也能提交) | 两处都改成与实现一致的说法。并把这件事故化:新增 scripts/check-manual.py,把"手册引用的错误码与提示文案是否真的存在"变成机械检查并进 CI —— 因为手册是文档、文案在代码里,改一次文案手册就静默失准,而没有任何东西会红(与 F-82「首页在说一件不真的事」同一类)。⚠️ 但要说清它查不到什么:它拦的是"手册写了代码里没有的东西",拦不住"手册漏了新增的东西" —— 那仍然要靠人 | | F-96 | ⚠️ 本轮我连续犯了三个"没断言就往下走"的错,形态不同、根因相同:① 文本补丁静默没生效:str.replace 没命中时不报错、不返回失败,脚本照样打印"成功",而文件一个字没改 —— 随后的编译错("方法不存在")看起来像另一个问题;② 夹具构造失败却继续往下走:with_checksum 传错位数后,失败发生在 300 行之外的名册导入报告里("当前 19 位");③ R.call 的返回值没查 code:建草稿失败时 data 是 null,报的是 TypeError: 'NoneType' object is not subscriptable,真正原因一个字都没打 | ①改成"每处替换都断言命中"(must_replace),并回读校验;②把 helper 改成参数不合法就抛(治本,前两次我只改调用点);③每个 R.call 先查 code 并把 msg 带进断言信息。根因是同一条:把"我以为发生了"当成"确实发生了" —— 而这三处的共同代价是把排查引向错误的方向(比直接报错贵得多) | | F-97 | ⚠️ 我断言错了字段名,于是"缩略图功能"看起来没生效:FileUrl 有 url(原图)与 thumbUrl(缩略图)两个字段,而我的验证脚本去检查 d2["url"] 里有没有 imageView2 —— 按设计它永远是原图链接,所以那个断言必然为 false;更糟的是它连着把后面两条断言一起带错了(下载的是原图,于是"没变小") | 改成检查 thumbUrl,并加一条反向断言:url 不该带处理参数、thumbUrl 该带。⚠️ 这与 F-77(用错 CSS 选择器,差点修一个不存在的 bug)是同一类:断言之前先确认"你在量的那个东西是对的"。这次的隐蔽之处是字段名很像(url vs thumbUrl),而且接口确实返回 200、数据确实存在 —— 一切看起来都正常,只有断言是错的 | | F-98 | ⚠️ "随机端口"这个防撞设计带来一个反直觉的副作用:check-test-profile.py 每次取随机空闲端口,于是上一次失败留下的 JVM 永远撞不到这个端口、也就永远不会被发现 —— 它会一直占着内存与 CPU,直到某一次"怎么都起不来"(实测:连跑几轮后本机攒了 5~7 个 java,启动从 10 秒变成 180 秒超时;而下一次跑又好了,这正是"时好时坏"的来源,很容易被归因成"配置有问题")。同时那次失败没法排查:子进程输出走 PIPE 而没人读,事后才读又已被 kill 截断,最后只剩一句"180 秒内没探活成功" | ①开跑前按 jar + profile 清残留(只杀本项目的 jar,且保留 dev 后端 —— E2E 正打着它,顺手杀掉等于把后面的检查全废掉);②子进程输出落盘(backend/_test-profile.txt,已被 .gitignore 的 backend/_*.txt 覆盖),失败时打印"停在哪一行 + 关键字命中 + 尾部 20 行",成功时打印启动用时(基线 ~10 秒 / 72 行 / 9.5KB,一旦变成几十秒先怀疑本机负载);③等待期间每 15 秒报一次进度,卡住时不留沉默。教训:给"防撞"加一层机制时,要问一句"那它把什么东西藏起来了" | | 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 的注释 | | F-112 | 🔴 GET /tenants/{id}/usage 在平台账号下算的是「全平台」的数(做租户管理时发现,是本轮最严重的一条)。平台运营的 tenantId 是 0,而 MybatisPlusConfig.FillTenantLineHandler.ignoreTable() 对平台身份直接返回 true —— 整张表的租户过滤被关掉。于是 TenantUsageService.distinctStudents()(查 collect_roster)与存储量统计(查 collect_file)都不带租户条件,返回的是全平台的学生数与存储占用,却标着某一所学校的名字 —— 运营会拿着它去谈续费。⚠️ 而这个接口的调用者一定是平台运营(tenant:view 在 PermissionDef.platformOnlyCodes() 里),所以它从来只在"过滤被关掉"的上下文里跑过。⚠️ 更麻烦的是它在本地完全看不出来:实测库里 collect_roster 34 行、collect_file 22 行全在 tenant_id=1,只有一所学校有数据时,错误值与正确值完全相等(ActingTenantResolver 的类注释描述的正是这个陷阱) | 已修:统计段包进 TenantScope.callAs(tenantId, 0L, Set.of(), ...) —— 切走之后 isPlatformAdmin() 变 false,租户过滤自动恢复(本仓既有机制,OpenDataService / OpenPushService 在用)。没有给查询手写 tenant_id 条件(那等于放弃拦截器的保护)。E2E 新增夹具(往另一个租户直插一行 collect_roster)+ 断言"接口值 == 库里 where tenant_id=1 的去重数",e2e-m8-orgfile.py 74/74 | | F-113 | 🔴 PUT /tenants/{id} 把手机号明文写进加密列 → 详情接口必然 500:create 走 codec.encrypt(...),而 update 是 upd.setContactPhone(req.getContactPhone());DB 列注释写着「联系电话(AES加密存储)」。AesGcmCipher.decrypt 对非 Base64 输入直接抛(该类没有 dev 旁路),于是存过一次电话,这所学校的详情页就永远打不开。实测 sys_tenant.id=1 存的就是明文 13800138000(来源不可考:全仓库没有任何脚本 PUT 过租户电话,3 处 contactPhone 都是 POST /tenants) | 已修:非空才 codec.encrypt(...) 写入(空 = 不修改,不是清空);详情返回脱敏值(不给明文:这是全系统唯一一处加密存储的联系方式,可读且不留痕会与「看证件号明文必须留痕」自相矛盾)。读路径必须容错:解密失败按明文处理 + log.warn(历史行不做数据迁移 —— 迁移里拿不到 AES 密钥),否则详情页是"永久 500"这种看起来像系统坏了的故障 | | F-114 | ⚠️ 租户的两个枚举值"能建不能改":tenantType 在 update() 里只放行 1~3,而 DDL / 实体注释 / 契约 / 前端下拉都是4 值(1 学校 2 认定机构 3 报名点 4 企业);status 只放行 0/1,而实体与 DDL 是 0 停用 / 1 正常 / 2 试用 —— 前端的状态筛选按钮组里就有「试用」,选了永远是空列表 | 已修:tenantType 14、status 0/1/2,报错文案与实体注释逐字对齐;E2E 断言 status=2 与 tenantType=4 都能改 | | F-115 | ⚠️ 租户的开通 / 修改 / 停用原先一条日志都不写:update() 只有 log.warn / log.info(那是给运维看系统日志的,不是合规留痕)。F-1 的修改建议里明确点了「租户开通」是优先留痕点 —— 停用一所学校是这套系统里影响面最大的单个动作 | 已修:新增模块 TENANT、动作 CREATE_TENANT / UPDATE_TENANT / DISABLE_TENANT / ENABLE_TENANT(停用与启用不并进 UPDATE_TENANT:出争议时第一个要看的就是这个区别);before/after 是 k=v 摘要(手机号只记"有没有设置"—— 密文写进日志既没用又难看,所以"换了一个号"改用请求里带没带它来补一条)。⚠️ 这些行的 tenant_id 是 0(平台身份),查"这所学校被谁停过"要用 target_type=TENANT + target_id,不能用 tenantId 筛 —— 已写进 MODULE_TENANT 常量注释与契约 §6.8 | | F-116 | ⚠️ expireBefore 差一天:LocalDate.parse(...).atStartOfDay() 与存进去的当天 23:59:59 比 → 「今天到期」的租户查不出来。同型的坑在 LogController.parseTo 上已经修过一次(那次是"查 9 月 30 日查不到当天")。另外格式不对时原先直接抛 50000 | 已修:atTime(23,59,59)(含当天);格式不对返回 40001 并说明格式 —— 刻意不学 LogController.parseTo 的"当没传":那两个是界面上的自由文本输入,静默忽略是为了"输错了也别让人看不到日志";而这个值只由界面按当前日期算出来,静默忽略筛选会让人以为"我按到期日筛过了" | | F-117 | ⚠️ AUTO_CLOSE(定时任务自动截止)是字符串字面量,不在常量表里:TaskService.autoClose() 直接传 "AUTO_CLOSE"。所以"后端 ACTION_* 常量 ↔ 前端文案"这种检查本来就扫不到它 —— 而它正是量最大的那类任务日志(库里 TASK 1548 行里的大头),界面上会原样回显这个英文码。(STATUS_CHANGE 兜底名同样是字面量) | 已修:提成 OperationLogService.ACTION_AUTO_CLOSE / ACTION_STATUS_CHANGE 并在 TaskService 用常量;新增检查 check-wiring.py 第 ⑩ 项(27 个动作码两端逐字核对),已反向验证(删掉前端一条文案 → 报红并 exit 1)。教训:这一族检查的价值取决于被检查的那一侧有没有被规范化 | | F-118 | ⚠️ 日志页的「模块」下拉里有两个永远筛不到的项:moduleOptions 只有 4 项(ROSTER/TASK/AUDIT/EXPORT),其中 AUDIT / EXPORT 后端一个写入点都没有,而真正会写的 FILE(代传材料)与 ROLE(6 个角色动作)不在下拉里 —— 选中死项只会得到空表,让人以为"这段时间没有审核动作" | 已修:apis/xt/log.ts 导出 MODULE_OPTIONS(TASK/ROSTER/FILE/ROLE/TENANT)与 TARGET_TYPE_OPTIONS,口径写明"只列后端真有写入点的模块"。⚠️ 顺带登记:MODULE_AUDIT / MODULE_EXPORT / MODULE_USER / MODULE_JOIN 四个常量当前零调用点,其中 MODULE_JOIN 是需求收敛后遗留的死常量 | | F-119 | ⚠️ LogController 的 /logins 描述与事实相反:@Operation 里写着「一期的登录日志只有 PC 账号密码登录…目前不写这张表(见 F-31)」,而 F-31 已闭环(WechatLoginLogger 会写 login_type=2)。这是"注释从正确的当时留到了不正确之后"那类问题 | 已修:描述改为按 loginType(PASSWORD / WECHAT)说明,并写明 location 恒为 null(没接 IP 库) | | F-120 | 📌 request_param / duration_ms 有列、无写入点:全库 1758 行里两列都是 0 行有值(max(length(extra_json))=45、max(length(user_agent))=125,而这两列从来没被 setXxx 过)。它们也不在 OperationLogMapper.listOperations 的 SELECT 里 | 不做(已写进契约 §13.4):要采集得引入 Servlet 过滤器或 AOP 抓请求体并脱敏,而 OperationLogService 的类注释已论证过"合规留痕不用 AOP";界面上不展示、也不做筛选。本轮只把轻字段 request_method / request_uri / extra_json 补进列表 SELECT(实测一页 20 行约 2 KB),因此不需要为详情新开接口 | | F-121 | 📌 docs/db/schema-postgresql.sql 缺三张表:只含 sys_tenant,没有 sys_operation_log / sys_login_log / sys_tenant_usage_snapshot(这三张只在 Flyway 迁移 V2 / V9 / V17 里)。按它建库会缺日志与用量快照 | 本轮不动(登记待办):Flyway 才是权威,那份 DDL 是给 DBA 看的快照,补齐它属独立一件事 | | F-122 | 📌 只在平台身份下可达、且查业务表的接口 = 租户越界的风险面:F-112 暴露的是一个模式,不是一处笔误 —— 凡是"平台专属权限码 + 查带 tenant_id 的表",都会因为 ignoreTable() 对平台返回 true 而静默跨租户 | 本轮把 tenant 模块逐接口过了一遍(list / detail / create / update / usage),只有 usage 统计业务表,已修;logs 模块自己显式处理了范围(logScopeTenantId)。留下的排查线索:以后新增平台专属接口时,先问一句"它查的是不是带 tenant_id 的表" | | F-123 | ⚠️ 登录日志没有租户列 → 平台视角下这些行"不属于任何学校":LoginLogVO 11 个字段里没有 tenantId,而平台运营看到的日志是跨单位的(log:viewAll)。于是"某个账号什么时候登进来的"这个问题答不完整 —— 筛得了单位,却看不出每行属于哪个单位。sys_login_log.tenant_id 从 V2 建表起就有(NOT NULL DEFAULT 0),只是 VO 没带出来 | 已修:LoginLogVO 加 tenantId、LoginLogMapper.listLogins 的 SELECT 加 l.tenant_id AS tenantId、契约 §13.4 同步;界面上登录日志表在「时间」后加「单位」列(0 → 平台),仅平台身份显示(学校管理员自己就是那个单位,加了是噪音) | | F-124 | ⚠️ LoginLogVO.userType 的 Javadoc 与真实枚举错位:写着「1 超管 / 2 管理员 / 3 教师 / 4 学生」,而权威口径(BuiltinRole.UserType、UserService.USER_TYPE_NAMES、库列注释、《03》§2.7)是 1 平台运营 / 2 学校管理员 / 3 班主任 / 4 学生。实测库里 user_type=1 的行就是 platform 账号。这是同一个坑的第三次出现(该错位枚举曾导致"把班主任当成社会人员拒掉") | 已修:Javadoc 改为正确枚举 + 写明"权威来源是 BuiltinRole.UserType,别按模板那套映射"。前端本来就用对了(复用 USER_TYPE_NAMES,没按评审意见里那句错的口径写),所以这处只影响读代码的人 | | F-125 | ⚠️ 学生被驳回后「改不了任何东西」(P0 · 阻断闭环):status=4 时改字段 → 40030(SubmissionService.java:158-161)、传材料 → 40030(MaterialFileService.java:103-105)、删材料 → 40030(:338-341)、撤回 → 40001(只放行 1/2,StudentTaskService.java:190-196),而重新提交是允许的(SubmissionService.java:293-297)—— 于是学生能看到"被驳回,请修改后重新提交"却什么都没法改。四处信号同时说他能改:statusText(StudentTaskService.java:142)、canFillNow=true(:105-106,underReview 只算 1/2/3)、progress.canEdit=true(:311)、progress.canSubmit=true(:313)。契约站学生一边:《03》§2.4(docs/03-接口设计.md:239-248)写 4 已驳回 → 可流转到 1(学生重提交) | 本轮只测不改,已登记 docs/11 F-125 + scripts/e2e-m3-flow.py 三条 XFAIL(EP-E09/EP-F10/EP-F10b/EP-I09)。为什么 1617 条断言没抓到:scripts/e2e-m5-audit.py:531-538 重交前只改了库里的 reject_count,没改任何业务字段 —— 它证明的是"原样重交能过",而问题在"改了反而过不去"。建议最小改法(改业务口径,须确认):三处写守卫从 != STATUS_DRAFT 放宽为 ∉ {STATUS_DRAFT, STATUS_REJECTED},与 submit、progress.canEdit 对齐 | | F-126 | 📌 scripts/e2e-m3-draft.py 的 docstring 自述虚高::11 写着 [x] …已提交的不能直接改,但该文件全文没有这条断言(全仓唯一涉及 40030 的是 scripts/e2e-m5-proxy.py:277,测的是代传路径)。docstring 的 [x] 清单在本仓库被当作覆盖率的唯一账本(verify-all 的汇总与多份文档的"37 个脚本 / N 条断言"都建立在它上面),所以虚高比 F-84 更隐蔽:断言缺失不会让任何东西变红,反而让覆盖率看起来更好 | 本轮已补断言:EP-E08(已提交后 PUT → 40030)与 EP-F09(已提交后上传 → 40030),均通过。教训固化成规程:docs/11 §3.1 要求用 grep 'check(\|ok(' 核对既有脚本的实际断言而不是读自述 —— 这条规程一上来就抓到了本项 | | F-127 | 📌 契约 §9.10 的口径与实现不一致,且未登记在「有意偏离」清单里:《03》§9.10(docs/03-接口设计.md:1986-1996)写"校验失败返回 40001 + data.blocking[]/warnings[]/canForceSubmit",实现是 HTTP 200 + code:0 + data{submitted:false,status,statusText,redCount,yellowCount,infoCount,issues[]}(FillController.java:59-70,代码注释给了理由:校验不通过是正常业务结果、要一次给全清单)。实现口径与项目约定一致、37 个脚本也都按它写,**但 §17.2 的 D-1D-12(docs/03-接口设计.md:2972-2988)里没有它** | 本轮只提议:在 §17.2 补一行 D-13(表格仍 5 列,不影响 verify-docs.py)。后果:文档读者会写出 code != 0 的判据,而那是恒假的 —— 测试静默变绿 | | F-128 | ⚠️ 扩展字段「存得进、读不出」,必填扩展字段会让整个任务永久无法提交(P0 · 阻断功能):PUT /mp/submissions/{id} {"extrasJson":{...}} → code=0 且库里 extras_json 确实写进去了,但 GET /mp/tasks/{id}/form 该字段 value 恒为 null,提交时 VAL-22 恒报"还有必填项没填"。根因:FillService.assemble(FillService.java:106-159)构造取值表只有两个来源 —— 名册列、以及 schema.getFields() 中 SubValues.hasColumn(key) 成立的列,从不把 draft.extrasJson 合进 values;而扩展字段的值只存在 extras_json 里。于是 SubmissionValidator.validate(SubmissionValidator.java:104-109)重建的 values 里也没有它,checkRequiredFields 的 blank() 恒为真 | 本轮只测不改,已登记 docs/11 F-128 + 两条 XFAIL(EP-D09 回显、EP-J02e 提交)。为什么至今没暴露:内置表单(BuiltinFields.java)一个扩展字段都没有,既有 37 个脚本用的全是内置表单 → 这条路径从来没有数据走过。建议最小改法(改读取口径,须确认):在 FillService.java:144-158 那段循环之后,把 schema.getExtras() 的每个键从 draft.getExtrasJson() 解析后 values.put(key, v);⚠️ 不要顺手把扩展字段塞进 changed_fields(那是给内置列用的) | | F-129 | 📌 把已声明的扩展字段放进 formData 不会报错,值被静默丢弃:{"formData":{"extra_upgrade_note":"..."}} → code=0,extras_json 仍是 {}。对照:未声明的键放进 formData 会明确 40001(SubmissionService.java:178-182,注释写着"不静默忽略");而已声明的扩展键在 defs 里查得到(:169-171 把 extras 也放进了 defs),于是躲过那道检查,接着 SubValues.apply(sub, key, value) 对它没有对应列、静默什么都不做 | 契约要求扩展字段走 extrasJson(DraftDtos.java:44),属"用错参数";但同一个方法里"不静默忽略"的原则对两种塞错口袋的待遇不一致,前端写错一次就是学生白填一遍。已登记 docs/11 F-129 + EP-E16(钉住现状)。建议:SubValues.apply 遇到"既不是列、又确实是声明过的扩展键"时抛 40001,提示"扩展字段请放在 extrasJson" | | F-130 | ⚠️ 微信小程序端的学生完全进不来,登录链路断成两截(P0 · 功能不可用):开发者工具模拟器里实测,扫任务码进小程序 → 落地页正常 → 点「开始填报」→ 填姓名 + 身份证 → 点「核对并继续」→ 屏幕只有**「未登录或登录已失效」。断点 A(前端):小程序端从来没有调用过 /mp/login** —— 全仓 frontend-mp/src 里唯一写 token 的地方是 pages/index/index.vue:222(从 H5 授权回调的 options.token 取),而 9 个页面的 token() 都读 uni.getStorageSync('token');搜不到 wx.login/uni.login//mp/login 的调用(只有 utils/request.ts:22 一句注释「登录流程见契约 §5.x 的 /mp/login,属 M3」),也没有 web-view 兜底。断点 B(后端):/mp/login 只要 scene 带 taskId 就必然 500 —— MpLoginService.java:127 用普通 BaseMapper.selectOne 查 collect_roster,而该接口是 @PublicApi(匿名)→ 租户拦截器默认拒绝 → BizException(40101) → 被 MyBatis 包成 PersistenceException → GlobalExceptionHandler → HTTP 500 / code 50000「系统繁忙,请稍后再试」 | 本轮只测不改,已登记 docs/11 F-130 与 §5.4。对照实验(同秒同端点,只改 scene):t1 → 200 + token;t1-k2313 → 500 —— 即"只在永远用不到的场景下能用"(学生扫的码必然带 taskId)。日志铁证:已建学生账号:uid=1389 之后紧跟 Cause: BizException: 未登录或登录已失效,栈顶 MpLoginService.login(MpLoginService.java:127)。副作用已核实:login() 是 @Transactional,账号随异常回滚(库里只有 scene=t1 成功建出的 uid 1390),所以是"每次点都 500、什么也没留下",不是"攒下孤儿账号"。为什么至今没暴露:① /mp/login 没有任何 E2E 覆盖(scripts/e2e-m3-gate.py:18 写着"脚本拿不到 wx.login code" —— 本轮证明它拿得到,开发者工具自动化里可在页面上下文调 wx.login() 换到真 code);② 文档互相矛盾(docs/08:147 记「学生端 /mp/login + 手机号绑定 ✅ 已完成」指的是后端,docs/交接摘要.md:563 还留着「/mp/login(小程序登录)仍未做」),没有一处说"前端没接"。修法方向(未实施):该分支改走专用 Mapper + @InterceptorIgnore(tenantLine=\"true\") + 显式带 tenant_id(AGENTS.md 那条铁律,MpH5Mapper.selectByOpenId(tenantId, openId) 就是这么做的,这里漏了);另外 40101 不该被包成 50000 对外 |


七、建议的执行顺序(按依赖,不按重要性)

① 审核前端(P0-3)── 打通"学生提交 → 班主任审 → 终审"这条能看见的路
        ↓ 审核结论是导出的前置条件
② 导出与交付(P0-2)── 打包引擎 + 汇总 Excel + 目录树 + 禁用导出铁律
        ↓ 交付物能产出后,才谈得上"验收"
③ B1 加入申请(P1-4)── 客户唯一功能增量,表已建
        ↓ 与 ② 互不依赖,可并行
④ 学生端剩余页(P1-5)+ 班主任端看板(P1-6)
        ↓
⑤ 识别与校验(P1-1/P1-2,等 D1 选型)── 失败一律放行,不阻断主流程
        ↓
⑥ 收尾(P2-1~4):CI/CD + 环境 + 压测 + 手册;小程序资质全程并行推进(P0-1)

工时参照(沿用 05-开发任务拆解.md 的口径,未含缓冲):M6 导出 32 人日、M4 识别校验 34 人日、 M7 收尾 52 人日、M5 剩余前端约 31 人日。关键路径仍是"审核前端 → 导出打包", 打包引擎 10 人日不可拆(拆开要重写),所以这一段加人无效。


八、每步的验收口径(做完就得能跑出这些)

步骤 验收
审核前端 e2e-m5-audit.py 87 条不回归 + 浏览器实测:T2(同是班主任但没被指定)看不到待办;右滑通过后列表少一张;驳回必须点按钮并选原因
导出与交付 新增 scripts/e2e-m6-export.py:有红问题 → canExport=false;导出后解压校验目录树与 21 列汇总 Excel;Windows 解压中文文件名不乱码;URL 不入库
B1 加入申请 新增 scripts/e2e-m2-join.py:门禁失败 → 提交申请 → 管理端审批 → 通过后该生下次扫码能进门禁
学生端剩余页 e2e-m3-* 不回归 + H5 真机(硬刷新)走完"我的任务 → 填报 → 提交 → 撤回 → 重交"
识别与校验 识别失败一律放行(断言"识别不可用时提交仍成功");置信度 <0.8 不展示
收尾 CI 里跑 mvn -DskipTests package + 全量 E2E + vue-tsc + verify-docs.py;上线检查清单(F-8)逐项打勾

九、附录 A · 代码有、契约 §4 清单没有的接口(F-4 的待办底稿)

这份清单是跑出来的,不是抄出来的:

python scripts/check-api-parity.py --extra

当前口径:代码共注册 161 个接口,契约 §4 声称 121 个(其中 120 个已在代码里), 差集 41 个。

⚠️ 上一版这里是"人工做一次差集、手抄成表"(写着 83 / 62 / 21)—— 此后每加一个接口它就悄悄过期一次,最后差了近一倍,而且没人会记得回来改它。 手抄件会过期,命令不会。所以下面只留"为什么它们不在契约里"的分类结论; 要逐条清单时现跑那条命令。

9.1 这 41 个按其「为什么不在契约里」分成六类

类 个数 为什么契约里没有 该怎么办
A 材料通道:一步 multipart / 审核端独立一条 7 契约写的是"先传文件拿 fileId 再挂载"与 upload-token + OSS 直传;实现是一步 multipart,且审核端与学生端各一条通道(审核端要看防伪纹,学生端只看自己的) §17 注明"实现按此写法"。不要改实现去迁就文档:直传要把密钥交给前端,反而更不安全
B 微信授权链路代替 POST /mp/login 4 H5 走网页授权跳转(authorize → callback,token 放 URL fragment)、匿名落地页要 scene-context、dev 打桩登录只用于联调 §17 注明;stub-login 要写明仅非生产可用(代码里另有一道 profile 判断)
C 签名 URL:契约只定义了"取下载信息" 1 GET /exports/{id}/file 才是真正下文件那条;§12.6 只定义了取下载信息 补进 §17,并写明 URL 有有效期 —— 不能入库、不能发出去让人明天再打开
D 界面与接口必需、清单漏列 17 按钮要先知道"能不能点"(exports/precheck、exports/preview、material-catalog)、匿名出图、批量通过、教师端原因字典、名册项"标记不参与"这个可逆替代(roster/{rid}/deactivate)…… 直接补进 §17 清单,这类没有口径争议
E 契约链条本身断在第一步(要客户确认) 8 §12.7 模板:GET /notify/templates 返回的内置模板没有 id,而 PUT …/{id} 要 id → 用户点"编辑"时根本拿不出 id(F-52);§14 的接入方管理整块没写(F-67) 这 8 条不是补文档能解决的,要客户定口径(见 F-51 / F-52 / F-67)
F 运维与调试动作 4 定时任务在测试里"等不起",所以留了手工入口(F-37):schedules/run、exports/cleanup-run、cleanup/jobs/json、cleanup/jobs/parse-preview 补进 §17,标注"运维/调试用"(权限走运营侧的 log:view:all)

合计 7 + 4 + 1 + 17 + 8 + 4 = 41。 分类是按"谁去改什么"分的 —— 需要客户回答的只有 E 那一栏,其余三类是文档侧的补记。


十、变更记录

时间 变更
2026-09-29 首次成文:完成度矩阵(121 接口 / 58 实现)、未完成清单(P0P2)、问题清单 F-1F-10 与执行顺序;新增 scripts/check-api-parity.py 把接口计数固化成可复现检查
2026-09-29 附录 A 逐条核对:19 个"代码有、契约清单没有"的接口(用 tool 做差集确认,不靠人工数)
2026-09-29 §六 施工记录:完成 P0-3 审核前端(PC)与 P0-2 导出与交付(M6 后端),新增 scripts/e2e-m6-export.py(54 条)与 scripts/dev-seed-audit.py;全量回归 19 脚本 / 771 断言 / 0 失败;新增问题 F-11F-15 与决定 D1D9
2026-09-29 导出中心 UI + 目录树预览;修正 F-16(导出目录结构按 PRD §5.9 改为「批次号-任务名/班级/材料类型/文件」);新增 F-16~F-18;断言数更正为 813(753+60)
2026-09-29 B1 名单加入申请(后端 + 学生端 H5 + 管理端审批,V7 迁移);新增 scripts/e2e-m2-join.py(50 条);新增 F-19(名册 NOT NULL 密文列导致审批走不通)/F-20(自述班级被姓名规范化去掉空格);全量回归 20 脚本 / 863 断言 / 0 失败;接口实现度 67/121(55%)
2026-09-29 学生端「我的任务 / 撤回 / 进度」(§9.1/§9.11/§9.12 后端 + H5 两页);新增 scripts/e2e-m3-mytask.py(42 条);新增 F-21(进度条因"不适用材料算进分母"永远到不了 100%)/F-22(静态常量上的多余 @TableField);全量回归 21 脚本 / 905 断言 / 0 失败;接口实现度 70/121(58%)
2026-09-30 班主任端(小程序)看板 + 审核卡片流(§10.1 看板后端 + 3 个新页:看板 / 卡片流 / 详情与驳回);新增 scripts/e2e-m5-teacher-dashboard.py(24 条,验证「已交率 ≠ 通过率」与「范围只算我负责的班」);新增 F-23(<script> 动态 SQL 里裸 < 导致 MyBatis XML 解析失败、启动即崩);全量回归 22 脚本 / 929 断言 / 0 失败;接口实现度 71/121(59%)
2026-09-30 通知与消息中心(§13.5 站内信 + §13.6 订阅额度;V8 新表 collect_message;驳回/终审通过现在真的会通知学生,notifySent 语义收窄为「微信推送发出去没有」);小程序新增消息中心页 + 提交后索取订阅授权;新增 scripts/e2e-m5-notify.py(48 条);新增 F-24(表缺审计列)/F-25(@Update 里 &lt; 不转义)/F-26(biz_type 是 varchar 不是数字);全量回归 23 脚本 / 977 断言 / 0 失败;接口实现度 94/121(78%)
2026-09-30 催报(§8.6 定向/一键催报 + §10.7 教师端催报与历史 + §13.3 通知记录;/notify/records 也一并做了);PC 新增通知记录页;新增 scripts/e2e-m5-urge.py(40 条);新增 F-29(催报分钟级限流未做)/F-30(契约 notifyBizType 5/6 对调,导致去重静默失效);全量回归 24 脚本 / 1017 断言 / 0 失败;接口实现度 94/121(78%)
2026-09-30 操作日志留痕(§13.4 操作日志与登录日志;V9 迁移补结构化留痕列)+ 名册改/删(§8.3/§8.4,证件号变更强制留痕、已绑定不可删并给出可逆替代);PC 新增操作日志页;新增 scripts/e2e-m5-logs.py(44 条);新增 F-31(学生微信登录不写登录日志)/F-32(发布与恢复都是→收集中,反推动作名会记错)/F-33(publish 原来漏了留痕);全量回归 25 脚本 / 1061 断言 / 0 失败;接口实现度 94/121(78%)
2026-09-30 字典管理(§13.3 四个 + 内置项停用/恢复;V10 迁移新建 sys_dict_item 与 sys_dict_disabled,两表分开才能一键恢复默认);内置字典写在代码里、表单与字典共用同一份口径;PC 新增字典管理页;新增 scripts/e2e-m1-dict.py(53 条);新增 F-34(国标职业库一期为空,属外部数据依赖);全量回归 26 脚本 / 1114 断言 / 0 失败;接口实现度 94/121(78%)
2026-09-30 导出分卷与过期清理(§12.4 的 splitSizeMb;V11 迁移新建 collect_export_file);每卷都是完整可解的 ZIP、各带 sha256、各带汇总 Excel;过期后删文件留记录(状态 4 已过期,不是 3 失败);新增 scripts/check-entity-columns.py 与 scripts/e2e-m6-export-split.py(33 条);新增 F-35(F-24 第三次复发 → 上机械检查)/F-36(机器检查自身的假阳性)/F-37(定时任务在测试里等不起 → 加手工入口);全量回归 27 脚本 / 1147 断言 / 0 失败
2026-09-30 班主任代传材料 + 教师端文件访问(§10.6/§4.6;V12 迁移补 proxy_by/proxy_reason;一步 multipart,三道闸门);新增 scripts/e2e-m5-proxy.py(33 条);新增 F-38(source 两处文档口径冲突)/F-39(source 从来没被写过)/F-40(canExport 在无候选时仍为 true)/F-41(onlyApproved 在前置检查里没生效)/F-42(E2E cleanup 静默失败污染后续脚本)/F-43(门禁限流打断演示脚本);全量回归 28 脚本 / 1183 断言 / 0 失败;接口实现度 94/121(78%);§4.6 班主任移动端缺口清零
2026-09-30 历史数据整理器(§13.2,差异化获客功能;V13 补审计列与 roster_task_id;HistoryNameParser + 体检报告 + 幂等导入);新增 scripts/e2e-m7-cleanup.py(54 条);新增 F-44(TaskService.create 缺必填抛 NPE)/F-45(task_code 必须由 TaskService 生成)/F-46(renderName 的 ext 要带点号)/F-47(异步回查跨事务必然查不到)/F-48(候选名判定用 contains 会吃掉真名)/F-49(参数校验被幂等短路);接口实现度 94/121(78%);§13.2 的 5 个缺口清零
2026-09-30 校验问题清单(§12.1~12.3)+ 通知模板与手工发送(§12.7)+ 微信回调;V14 迁移;新增 scripts/e2e-m7-validation.py(70 条);新增 F-50(表建了没人用)/F-51(契约要 async 却没状态接口)/F-52(模板接口链断在第一步)/F-53(微信回调要偏离两条全局约定)/F-54(PG 折叠别名大小写)/F-55(PG 推断不出 ? IS NULL)/F-56(软删除与唯一索引打架);接口实现度 101/121(83%);§4.8 缺口清零;check-api-parity.py 加 --missing
2026-09-30 任务克隆 / 任务统计(§7.11)/ 规则模板(§7.13)/ 名册导入模板(§8.5)/ 审核统计(§11.6)/ PC 端代传(§11.7);V15 迁移(collect_rule_template 补审计列 + 两条公共模板);新增 scripts/e2e-m7-taskconfig.py(69 条);新增 F-61(初审待办会列出批不了的单子 —— 待办与审批两套判据)/F-62(契约漏了「保存为模板」)/F-63(公共模板被租户拦截器挡掉)/F-64(模板的 FORM 部分没有落地路径);接口实现度 106/121(88%);§4.4 与 §4.7 缺口清零
2026-09-30 开放接口(§14,供认定机构对接);V16 迁移(接入方 + 推送记录);新增 scripts/e2e-m8-open.py(60 条);修 rule-templates 的路径(F-68);新增 F-65(§14 路径与 §1 冲突)/F-66(§14.4 的报文方向与路径不一致)/F-67(契约漏了接入方管理)/F-68(规则模板挂错路径)/F-69(匿名入口回查接入方必然 500)/F-70(nonce 占用顺序反了 = 拒绝服务入口);接口实现度 112/121(93%);§4.10 缺口清零
2026-09-30 小程序登录与手机号绑定(§5.3)/ 组织导入(§6.4)/ 租户修改与用量(§6.5、§6.7)/ 文件视图·直传凭证·批量处理(§9.5、§9.7、§9.8);新增 scripts/e2e-m8-orgfile.py(54 条);新增 F-71(七牛直传缺"上传完成回调")/F-72(uploadToken 签发后无人消费)/F-73(秒传排在份数检查之后)/F-74(运营账号的验证码与密码);接口实现度 120/121(99%) —— 只剩 /mp/ocr/idcard(等 OCR 选型);§4.1 与 §4.2 缺口清零
2026-09-30 历史整理器页面(PC):apis/xt/cleanup.ts + views/cleanup/index.vue,路由 ready: true;修 F-75(压缩率负数 → 服务端回 —,并强化 E2E 断言)/F-76(GiButton 没有 text prop,"传了没报错≠生效")/F-77(我自己验证时用错选择器,差点修一个不存在的 bug);PC 端 ready:false 的路由从 5 个减到 4 个(还剩 名单管理 / 表单设计器 / 材料规则 / 角色与授权)
2026-09-30 「待建」页不再说谎:XtMenu 加 entryAt,名单管理/材料规则/表单设计 三个菜单项改为「功能已就绪 + 真实入口指路 + 跳转按钮」,删掉会静默过期的"已实现页面"清单;修 F-78(待建页按 meta.menu 查找恒为 undefined,所以每个菜单项的 note 从来没显示过)/F-79(Markdown 星号写进纯文本,与 F-76 同类)/F-80(文本补丁腰斩跨行拼接的属性 → 文件语法错误);PC 端 ready:false 的路由仍是 4 个,但其中 3 个现在明确指向真实入口
2026-09-30 工作台数据看板(P1-9):apis/xt/{task,audit}.ts 加统计封装与TASK_STATUS_TEXT;views/dashboard/index.vue 换掉过期的「开发进度」卡,改为"选任务 → 进度四格 + 分班进度 + 审核情况 + 驳回原因";修 F-81(平均审核时长 17 位浮点数 → 服务端 2 位 + 前端按量级换单位)/F-82(首页在说一件不真的事:写着"M0 后端已完成、界面建设中");并核销 §四 里 8 条已完成却仍挂"未完成"的条目(P0-2/P0-3/P1-5/P1-8/P1-9/P1-10/P2-5/P2-10,每条都附了证据)
2026-09-30 定时任务(P2-3,PRD 附录 E 的四项):新建 module/schedule/(ScheduleMapper + ScheduleJobs + ScheduleController),补上任务自动截止 / 自动催报 / 审核超时提醒 / 原始件清理;新增 scripts/e2e-m7-schedule.py(27 条);新增 F-83(Map 结果的别名被折成小写 → 定时任务静默什么都不做,与 F-54 同一个坑的第二次)/F-84(「HTTP 200 + code 0」不等于动作成功)/F-85(清理只在正常结束时跑,失败路径才是残留来源)
2026-09-30 CI/CD + test/prod 环境(P2-1):.gitlab-ci.yml(三阶段 6 job,e2e 阶段跑全套断言且每次随机生成密钥)、application-test.yml / application-prod.yml(零密钥)、ProdEnvCheck(启动前自检 6 条)、docs/09-部署与运维.md;删掉模板残留的 GitHub Pages 工作流;新增 scripts/e2e-prodguard.py(9 条);新增 F-86(检查配置的代码自己先崩在配置上)/F-87(自检写成 ApplicationRunner 就轮不到它)/F-88(CI 的 e2e 模板没写 extends 它的 job)
2026-09-30 定时任务收尾(P2-3 完成):V17 迁移(重试三列 + 用量快照表);ScheduleJobs 补用量快照 / 压缩失败重试 / 租户到期提醒;e2e-m7-schedule.py 扩到 38 条;新增 F-91(PRD 附录 E 的「待认领名册清理」对应机制已在 V1.5 废除,故 9 项核销为 8 项);实体/表计数更新为 27 实体 / 33 表
2026-09-30 压测(P2-2):新增 scripts/load-test.py(四场景、按端点统计、可调规模);修掉 F-92 —— 门禁 IP 限流曾把整间教室挡住(60 人同 IP 只过 20 个),改成"只对失败计数";新增 F-93(压测会打到真实七牛空间)/F-94(我自己的三个度量错误:吞吐公式、混合 p95、用并发数字推断每行成本);docs/09-部署与运维.md 加 §9 压测记录
2026-09-30 用户手册与客服话术(P2-4):新增 docs/10-用户手册与客服话术.md(三角色手册 + 标准回答 + 培训提纲 + 上线检查单);新增 scripts/check-manual.py(手册引用的错误码/文案 ↔ 代码,已进 CI);新增 F-95(手册初稿两处"按设想的系统写":秒传不存在、代传原因其实选填);docs 一致性检查从 21 项增至 22 项
2026-09-30 缩略图(P2-7)+ H5 独立 dev server(P2-9):StorageService.thumbUrl / FileUrl.thumbUrl / 审核端 ?thumb=true;frontend-mp 加 /api 代理与 VITE_API_BASE;新增 scripts/e2e-m9-thumb.py(12 条,打到真实七牛);同步 AGENTS.md 里"H5 没有独立 dev server"那句过期说明;新增 F-96(三个"没断言就往下走":补丁静默无效 / 夹具静默失败 /不查 code)/F-97(断言错了字段名,与 F-77 同类)
2026-09-30 对外待决事项归拢 + 一键交付自检:docs/01 新增 E 组·第二轮待确认(E1 union_id 是"H5 与小程序不能同时上线"的阻断项,E2 D3 限流口径更新、E3 二期时点,D3 那行划掉);§四 的每条卡点补上"找谁 / 编号";交接摘要新增 §一·二十八(坐标刷新 + 这 20 多轮最该记住的 7 条);新增 scripts/verify-all.py(一条命令跑完交付自检,跳过项如实列出、不算失败)。全量回归 36 脚本 / 1548 断言 / 0 失败
2026-09-30 文档小账清理 + check-test-profile 加固:① 附录 A 改为"可再生成" —— check-api-parity.py 加 --extra 现算"代码有、契约 §4 没有"的接口(41 个;旧手抄表写 83/62/21,已差近一倍),并按"谁去改什么"分六类(只有 E 类要客户定口径);② F-6 已修:frontend-pc/AGENTS.md 的 Mock 说明改正,并连带修 .agents/skills/project-api/SKILL.md(整篇仍以 vite-plugin-mock 为主线,而 mock/ 目录与插件都已删除 —— 只改指路牌不改路的尽头等于没改);③ docs/03 给 §2 notifyBizType 的 5/6 与 §3.6/§9.5 的 source 加 ⚠️ 注(不改口径,只把文档内部冲突摆明 + 指出实现以哪边为准,待客户确认);④ check-test-profile.py 改为日志落盘 + 按 jar/profile 清残留 JVM —— 随机端口让残留永远撞不到端口,于是静静攒着,直到某次"怎么都起不来"(新增 F-98)
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-1D-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-1F-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 不更新用户名 → 看起来像密码错)