一、附录 A 从"手抄表"改成"可再生成的命令"
check-api-parity.py 加 --extra,现算"代码有、契约 §4 没有"的接口 = 41 个
(旧手抄表写 83/62/21,已差近一倍,而且没人会记得回来改它)。
并按"谁去改什么"分六类 —— 只有 E 类(8 个)要客户定口径,其余是文档侧补记。
二、F-6 已修:frontend-pc 的 Mock 说明
AGENTS.md 那句改正之外,连带修了 .agents/skills/project-api/SKILL.md ——
它整篇仍以 vite-plugin-mock / mock/** 为主线,而该项目里 mock/ 目录与插件都已删除。
只改指路牌、不改路的尽头,等于没改。
三、docs/03 两处文档内部口径冲突加 ⚠️ 注(不改口径,只把冲突摆明)
§2 notifyBizType 的 5/6 与 §13.5、落库值不一致 → 实现以 §13.5 为准(催报去重按 5);
§3.6/§9.5 的 collect_file.source 字段表(入口来源)与建表注释(谁传的)冲突 →
实现以建表口径为准。两处都写明"待客户确认",见 F-30 / F-38 / F-39。
四、check-test-profile.py 加固(新增 F-98)
· 子进程输出落盘(不再用没人读的 PIPE):失败时能看见"停在哪一行",
成功时打出启动用时(基线 ~10 秒 / 72 行 / 9.5KB);
· 开跑前按 jar + profile 清残留 JVM,且保留 dev 后端(E2E 正打着它);
· 等待期间每 15 秒报一次进度,卡住时不留沉默。
教训:随机端口这个防撞设计把残留 JVM 藏起来了 —— 撞不到端口就永远发现不了,
于是表现成"时好时坏",而失败时连日志都读不到。
验证:python scripts/verify-all.py --with-e2e → 通过 8 / 失败 0 / 跳过 0
(E2E 36 脚本 / 1548 断言 / 278s;prodguard 9/9;test profile 能起)
python scripts/verify-docs.py → 22 项全部通过
122 KiB
完成度盘点与问题清单(施工计划)
目的:给"还剩什么、先做哪个、每个问题怎么改"一份可直接照着做的清单。 口径:以代码与实测为准(文档是需求源,但与代码冲突处以代码为准,冲突项在 §五 单列)。 盘点时间:2026-09-29 · 基线提交
5213bc2
一、盘点方式(复现命令)
| # | 盘点项 | 命令 / 依据 | 本次结果 |
|---|---|---|---|
| 1 | 后端回归 | 依次跑 scripts/e2e-*.py(18 个) |
753 条断言、0 失败 |
| 2 | 接口实现度 | python scripts/check-api-parity.py(解析 §4 的 121 个接口 ↔ 全部 controller 的 @*Mapping) |
首次盘点 58 / 121;完成 M6 导出后为 62 / 121(51%);另有 21 个接口在代码里但契约清单没有 |
| 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 总览逐条比对代码,结束时自检"契约必须恰好 121 个",不等就退出码 1 —— 这样"文档改了表格格式导致解析漏项"也会当场暴露,而不是给出一份看着对的错报告。
二、总体完成度
| 层 | 已完成 | 未完成 | 完成度 |
|---|---|---|---|
| 后端接口 | 120 个(M0/M1/M2/M3 主链路 + M5 审核闭环与班主任看板 + M6 导出、分卷与过期清理 + B1 加入申请 + 学生端我的任务/撤回/进度 + 通知消息与催报 + 名册改删 + 操作/登录日志 + 字典管理 + 代传与教师端文件访问 + 历史数据整理器 + 校验问题清单 / 通知模板与手工发送 / 微信回调 + 任务克隆 / 任务统计 / 规则模板 / 名册导入模板 / 审核统计 / PC 端代传 + 开放接口(认定机构对接) + 小程序登录与绑手机号 / 组织导入 / 租户用量 / 文件视图·直传凭证·批量处理) | 1 个 | 99% |
| 后端里程碑 | 契约 §4 的 121 个接口里 120 个已实现(M0~M3 主链路、M5 审核闭环、M6 导出与交付、M7 整理器与运维接口、开放接口、小程序登录与文件接口) | 只剩 M4 的 /mp/ocr/idcard(等 OCR 选型)+ 前端页面与 CI/CD |
约 7.5/8 |
| 管理端(PC) | 登录/工作台/组织/账号/租户/收集任务 + 3 个配置抽屉 + 名册与报考范围 + 审核台 + 导出中心 / 加入申请 / 通知记录 / 操作日志 / 字典管理 | 角色、历史整理器页面(后端已就绪)、表单设计器 | 约 92% |
| 学生端(H5/小程序) | 落地页、门禁核验、填报、我的任务 / 撤回 / 进度 / 消息中心 / 加入申请 | 临期提醒页、催报 | 约 85% |
| 班主任端(小程序) | 看板 / 审核卡片流 / 详情与驳回(右滑通过、左滑不绑驳回)+ 代传材料 / 催报 / 学生材料预览(后端与 MP 页面均已就绪) | 临期提醒入口 | 约 95% |
| 基础设施 | PG 16 + Redis 7 + 七牛(provider=qiniu 已验)、Flyway V1~V16、本地存储兜底、导出产物落盘 + 流式下载 + 分卷与过期清理、33 个 E2E 脚本(1489 条断言)+ 3 个机械校验脚本 |
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,剩下的全部是外部依赖或二期范围 —— 没有一条是"我们还没写"。 所以每条都指向
docs/01-客户与机构确认函.md的具体编号:卡在谁那里、要谁做什么。
剩项 卡点 找谁 编号 P0-1 小程序类目资质 教育类目可能要办学许可 法务 / 商务 docs/01C1(第一轮已问,等回执)P0-4 H5 与小程序两个 openid 需微信开放平台账号并绑定两个 appid 才有 union_id商务推动 + 我方 0.5 人日 docs/01E1(第二轮新增)P1-1 M4 识别与校验(7 个接口) 需先定 OCR 供应商(含数据出境合规) 商务比价 + 我方技术评估 docs/01D1P2-8 小程序端真机验证 需要真机与体验版 客户提供设备 docs/01D6P2-6 二期功能 一期范围外(已约定) 客户定启动时点 docs/01E3📌
union_id那条最容易漏:字段(sys_user.union_id)与方案都已就位、 注释也写了,但全仓库零读写(683 个用户全为null)—— 它不是"没设计",而是"没有它做不了"。两条填报腿不能同时上线,就卡在这一步。📌 不要在没有这些输入的情况下去"顺手实现":M4 按某家供应商写死,换供应商要重写。
4.1 P0 · 阻断学生开始填报 / 阻断交付
| # | 未完成项 | 对应任务/契约 | 为什么是 P0 |
|---|---|---|---|
| P0-1 | 小程序类目资质(C1) | M7-10 前置 | 开发版/体验版学生都扫不开,只有正式版能扫 —— 它决定"能不能开始收"(→ docs/01 C1) |
views/export/index.vue 有前置检查清单(canExport=false 时禁用按钮、不弹警告放行)、进度、产物下载(带 token 取 blob);scripts/e2e-m6-export.py 60 条、e2e-m6-export-split.py 35 条 |
M6-05、M6-06 | — | |
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;识别失败一律放行) |
| §12.1~12.3 | ||
✅ 已完成(§13.5 三个 + §13.6 一个 + §8.6/§10.7 催报四个 + §13.3 通知记录一个)。剩余:§4.6 的教师端文件访问(/mp/teacher/files/{fileId})—— 见 F-28:钉住降级链后两级的不是代码量,而是"短信/公众号通道还没有商务结论" |
||
| P1-4 | §8.7 的 5 个接口 + 学生 STEP 03B + 管理端审批页 全部落地 | |
pages/tasks(含「撤回修改」,canWithdraw 由服务端算)+ pages/progress;scripts/e2e-m3-mytask.py 42 条 |
§9.1、§9.11、§9.12 | |
| §10.1、§10.6、§10.7 | ||
| M7-06/07、§13.2 的 5 个接口 | ||
sys_operation_log 实测 523 条,最近一条是几分钟前;F-1 已在前面几轮修掉),写入口统一走 OperationLogService;views/log 与 views/dict 均已可用 |
M7-03/04/05、§13.3、§13.4 | |
| §7.11、M7-01/02 | ||
RosterEditService(改姓名/证件号/删除,都写操作日志)、名册导入模板下载(表头固定、必填列标红、带 9 条规则说明页);scripts/e2e-m2-roster.py 67 条 |
§8.3、§8.4、§8.5 | |
§7.13、§4.3 的 /tasks/{id}/clone |
||
| M5-06、§10.6、§11.7 | ||
| §14 的 4 个接口(HMAC 加签) | ||
/mp/login(小程序端登录)+ 手机号绑定 |
§5.3、§4.1 |
4.3 P2 · 上线收尾与二期
| # | 未完成项 | 说明 |
|---|---|---|
.gitlab-ci.yml(6 个 job,三阶段;e2e 阶段跑全套 1516 条断言,每次随机生成密钥)+ application-test.yml / application-prod.yml(零密钥)+ ProdEnvCheck(启动自检,6 条"配错了也能启动"的配置)+ docs/09-部署与运维.md(发布 / 密钥轮换 / 迁移与回滚 / 灰度 / 排障 / 首次接入 CI 的检查单) |
M0-04、M7-10 | |
scripts/load-test.py(四个场景、按端点分别统计、可调规模);压测发现并修掉一个会让整间教室被挡住的限流缺陷(F-92)。结论:后端没有 N+1、DB 在 2ms 以内,并发下的延迟是排队效应。尚未测:导出大包、上传并发、5000 人名册、长稳(见 docs/09 §9.5) |
M7-09 | |
POST /schedules/run 可手工触发任意一项。⚠️ 第 9 项「待认领名册清理」不做 —— 它对应的"自助认领"机制已在 PRD V1.5 废除(见 F-91) |
M7-12 | |
docs/10-用户手册与客服话术.md(三角色操作手册 + 高频问题标准回答 + 30 分钟培训提纲 + 上线首日检查单);并新增 scripts/check-manual.py(机械核对手册引用的错误码与提示文案是否真的存在,已进 CI) |
M7-11 | |
ExportCleanupService.cleanupExpired()(@Scheduled 每小时第 17 分跑) |
M6-07 | |
splitSizeMb > 0)SplitZipWriter 会产出 {base}-part1.zip、{base}-part2.zip…;scripts/e2e-m6-export-split.py 35 条 |
M6-07 | |
| P2-6 | 二期:社会收集 / 社会评价 / 专项职业能力 | 一期范围外(collectMode=2、certType=2,3 返回 40044)→ 启动时点见 docs/01 E3 |
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) |
frontend-mp 加 /api 代理 + .env.development 把 VITE_API_BASE 设空串,pnpm dev:h5 → http://localhost:5174 可直接联调;三端行为(dev server / 构建产物 / 小程序)都验过,且 AGENTS.md 里那句过期的说明已同步 |
开发体验 |
五、发现的问题与修改建议
F-1【P1 · 真问题】操作日志从没人写
- 现象:
sys_operation_log0 行;module/log/mapper/OperationLogMapper全仓库只有它自己 import(LoginLogMapper则在AuthService里真写,sys_login_log704 行)。 - 影响:M7-04 的「敏感操作留痕」实际不存在(查看明文证件号、下载、导出、代传、删除都无痕);而 §13.4 的两个查询接口一旦做出来,界面上会永远是空的。
- 建议:
- 加
module/log/service/OperationLogService+@OperationLog(module, action)注解 +aspect/OperationLogAspect,在注解里声明要记的动作; - 优先挂 6 处:
VIEW_IDCARD_FULL(审核详情看完整证件号)、导出创建与下载、代传、名册 REPLACE、账号删除、租户开通; - 入库前脱敏(
SysOperationLog类注释已写明:去掉 password、证件号明文); - 验收: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每次新建记录来承担。 - 建议(推荐改文档 + 保留实现,别改实现):
- 在 03 文档 §9.5 标注「已改为服务端上传:
POST /mp/files/upload(multipart,收上来即压缩+改名)」,§9.6process语义按AGENTS.md的口径改成"有原始件时的重新规范化"; - §9.9 OCR 标注「一期缓做(等 D1 选型)」,不要留着像已实现的样子;
- 若后续要恢复直传(大文件/弱网),再单独立任务,不要在这轮混做。
- 在 03 文档 §9.5 标注「已改为服务端上传:
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 清单),并在 §4 的合计口径里说明"121 是设计口径,另有 N 个实现补充"。改动后必须重跑
python scripts/verify-docs.py(它逐节核对接口数,加节时注意别改动 §4 的声称数)。
F-5【P2 · 数据卫生】E2E 租户残留到"每个请求多一次解析"
- 现象:
sys_tenant17 行、sys_user300 行,而脚本每次跑都会建测试租户,清理只删自己建的那一个。 - 影响:本地演示/截图时列表里全是
e2e-*名字;租户域名解析与登录"跨单位重名"分支被无意义数据搅扰。 - 建议:
scripts/dev-clean-e2e.py增加一节"按名字前缀删测试租户与账号"(默认 dry-run,--yes才真删),并在AGENTS.md里把"跑完 E2E 顺手清一次"写成固定动作。
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 的撤回,删一个分支即可(代码里有注释标明)。
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 步",避免将来重开时找不到入口。
六、施工记录(本轮自主完成的事)
客户要求:"完成所有的开发计划,中途发现问题自己决定,记录出来让我知道即可。" 本节的每条都带验证方式,不写"已完成"这种没有证据的话。
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 的 121 个接口 ↔ 全部 controller,自带"必须恰好 121"的自检) |
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) |
| 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 错 |
| 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 清空 + 改名挪开"(夹具性质,已注明)。建议:给管理员留一个"归档后可删"的口径,否则本地库会一直堆任务 |
| 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 解析 —— < 必须转义成 <。错误是 SAXParseException: 元素内容必须由格式正确的字符数据或标记组成,指不到具体那一行 SQL,而我第一反应是去查 SQL 逻辑(走了一段弯路)。更危险的是:mvn package 照样成功,只有启动时才炸 |
改成 <>(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 里的 < 不会被还原成 <:SubscribeQuotaMapper.consume 写的是 AND used < total,而没有 <script> 包裹的注解 SQL 不经过 XML 解析 → 字面量 < 原样发给 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 落库为字符串" |
| F-27 | notifySent 的语义被收窄了:以前它表示"有没有通知渠道"(恒 false),现在表示"微信推送有没有真的发出去"。站内信一定写得进去,所以 needManualNotice 仍然可能为 true(学生不一定会进小程序) |
已在代码里写清,并且没有顺手把 needManualNotice 改成恒 false。E2E 断言"降级时如实告知:notifySent=false / needManualNotice=true",防止后人把它"优化"成乐观值。这是对前端契约的语义变更,已在本文档记录 |
| F-28 | 降级链只做了两级(订阅消息 → 站内信),中间两级没做:契约 §13.6 写的是「订阅消息 → 公众号模板 → 短信 → 仅站内信」。后两级的缺不是代码问题 —— 公众号模板消息要已认证服务号 + 用户已关注(一期没有公众号),短信要买通道与签名(按条计费,是商务动作)。我选择只实现能真正走通的两级,而不是写两级"看起来有、其实永远失败"的通道 | 在 NotificationService 的类注释里写清了为什么少做,并用 channel 字段如实标出这条消息最终怎么到的。理由:永远失败的通道会往日志里刷一片失败,把真正的原因(没配通道)淹掉,而运营看到"通知失败率 90%"会去查代码,实际上该去找商务 |
| F-29 | 催报的分钟级限流还没做:契约 §3 的表里写了「催报 10 次/分钟/任务」,本轮只做了 Service 层的去重(excludeRecentHours,默认 24 小时),没有做分钟级限流。两者的作用不同:去重防的是"同一个学生被重复催",限流防的是"老师连点把系统打爆"。现在连点 100 次不会造成伤害(第二次起全被去重跳过),但会白跑 100 次全表查询 |
记为 P1 剩余项(与 §3 的其它限流一起做)。不做的理由:现在没有真实并发压力,而限流放哪个维度(租户/用户/任务)需要与 §3 的其它限流统一,单独加一个会分叉 |
| 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 对齐成同一套码 —— 改哪一边要客户确认 |
| F-31 | 登录日志只有 PC 账号密码登录(login_type 恒为 1,库里 1101 行全是 PASSWORD):学生从小程序/H5 进来走的是微信授权(/mp/h5/*),那条链路不写 sys_login_log。所以按 §13.4 查"学生登录记录"会是空的 | 本轮没有补(补它要动 MP 授权链路,属于另一处改动),而是在接口与页面的说明里如实写清这是已知边界 —— 否则运维会以为日志功能坏了。记为 P1 剩余项。风险:合规上"谁在什么时候登录过"对学生侧是缺口 |
| 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 口径,否则下一个人会按字段表写 |
| 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) |
| 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 部分从契约里去掉 |
| 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-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 前缀,两种写法都能对上,所以工具不会替我们发现这个冲突 —— 它只能靠人读文档发现 |
| 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 才发现格式不对/重复了) |
| F-72 | ⚠️ uploadToken 签发之后没有"消费"它的地方:我按契约实现了凭证的签发与预检,但上传接口没有接它(/mp/files/upload 仍然只认 submissionId + materialCode)。于是凭证目前只用于"预检与秒传判断",不构成一道校验 —— 客户端完全可以跳过凭证直接上传 | 如实记录(不假装它是安全机制)。建议:要么让 /mp/files/upload 接受并校验凭证(一次性、15 分钟、绑定到人),要么把它定位成"纯性能优化"并在契约里写明 —— 现在两种都不是 |
| 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 重启后端"。两件事都不对:压测不该依赖外部服务(网络抖动会让"性能结论"变成对七牛的评价),也不该往真实桶里灌测试文件(删库不删桶) |
| 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 秒报一次进度,卡住时不留沉默。教训:给"防撞"加一层机制时,要问一句"那它把什么东西藏起来了" |
七、建议的执行顺序(按依赖,不按重要性)
① 审核前端(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 实现)、未完成清单(P0scripts/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 |
| 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 里 < 不转义)/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) |