Files
xiaotutongxue/docs/08-完成度盘点与问题清单.md
T
lovife 7df078e0ea docs: 同步交接摘要 / PRD / 原型,并记录品牌改名「深学在线」
- 交接摘要补 §一·三十五~§一·三十八:角色与授权、需求收敛(4 角色 / 10 菜单 / 取消二期)、
  PC 端重搭后的 UI 修复与卡片式基线、品牌改名与新标识
- PRD 与功能逻辑卷只改标题行与产品代号线;**文件名不动** —— verify-docs.py 按路径读,
  全库还有 20+ 处按文件名引用,改名会连带炸脚本
- 原型 index / admin-web / teacher-app / student-app 的品牌区改「深学在线」,
  admin-web 的 .mark(10 处)换成内嵌的新标 data URI,保持原型"单文件自包含"的约定
- 根 AGENTS.md 与 frontend-pc 的规范同步:接线检查补第 ⑨ 项(SVG 资产是不是合法 XML ——
  SVG 注释里出现连续两个减号就 not well-formed,图标裂开而控制台一句话都不报)、
  「最容易写错的约定」6 -> 7 条(新增品牌标)
- deploy/docker-compose.yml 只改头注释里的项目名(compose 的 name 与七牛对象前缀都没动)
2026-10-10 16:49:04 +08:00

598 lines
151 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 完成度盘点与问题清单(施工计划)
> 目的:**给"还剩什么、先做哪个、每个问题怎么改"一份可直接照着做的清单。**
> 口径:以**代码与实测**为准(文档是需求源,但与代码冲突处以代码为准,冲突项在 §五 单列)。
> 盘点时间: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 → **1595 条**,0 失败 0 跳过) |
>
> **删除的三块**:① 内置角色「副班主任 `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**、本地存储兜底、导出产物落盘 + 流式下载 + 分卷与过期清理、**37 个 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](frontend-pc/src/apis/xt/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` 的注释 |
---
## 七、建议的执行顺序(按依赖,不按重要性)
```
① 审核前端(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 的待办底稿)
> **这份清单是跑出来的,不是抄出来的:**
>
> ```bash
> 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 实现)、未完成清单(P0~P2)、问题清单 F-1~F-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-11~F-15 与决定 D1~D9 |
| 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-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 不更新用户名 → 看起来像密码错) |