@@ -154,7 +154,7 @@
- **影响**:占用端口 + 误导(新人会以为本地有对象存储可用)。
- **影响**:占用端口 + 误导(新人会以为本地有对象存储可用)。
- **建议**: `docker rm -f xt-minio` ;若还有旧数据要留,先 `docker run --rm -v` 导出再删。同时在 `AGENTS.md` 的"环境与流程坑"里保留"MinIO 已删,别去找"这句(已有,够用)。
- **建议**: `docker rm -f xt-minio` ;若还有旧数据要留,先 `docker run --rm -v` 导出再删。同时在 `AGENTS.md` 的"环境与流程坑"里保留"MinIO 已删,别去找"这句(已有,够用)。
### F-3【P1 · 文档与代码不一致】`/mp/files/upload-token` 与 `/mp/ocr/idcard`
### 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 完全没做。
- **现象**:契约(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` 每次新建记录来承担。
- **影响**:前端按契约写会调不存在的接口;`upload-token` 是"防重复建文件记录"的关键设计(重复提交幂等),现在靠 `POST /mp/files/upload` 每次新建记录来承担。
@@ -163,7 +163,9 @@
2. §9.9 OCR 标注「一期缓做(等 D1 选型)」,不要留着像已实现的样子;
2. §9.9 OCR 标注「一期缓做(等 D1 选型)」,不要留着像已实现的样子;
3. 若后续要恢复直传(大文件/弱网),再单独立任务,不要在这轮混做。
3. 若后续要恢复直传(大文件/弱网),再单独立任务,不要在这轮混做。
### F-4【P1 · 文档与代码不一致】契约里没有的接口(首次盘点 19 个,现在 41 个)
- ✅ **已闭环(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` 、
- **现象**(代码有、03 文档 §4 清单没有):最典型的是 `GET /mp/scene-context` 、
`GET /auth/captcha` 、`GET /mp/h5/authorize|callback|stub-login` (代替 `/mp/login` )、
`GET /auth/captcha` 、`GET /mp/h5/authorize|callback|stub-login` (代替 `/mp/login` )、
@@ -174,6 +176,8 @@
所以它必须以命令的形式存在,不能以表格的形式存在 —— 见 §九。
所以它必须以命令的形式存在,不能以表格的形式存在 —— 见 §九。
- **建议**:在 03 文档**新增一节「§17 实现补充与偏差记录」**,逐条列出这些接口(路径 / 权限 / 是否要补进 §4 清单),并在 §4 的合计口径里说明"121 是设计口径,另有 N 个实现补充"。改动后必须重跑 `python scripts/verify-docs.py` (它逐节核对接口数,加节时注意别改动 §4 的声称数)。
- **建议**:在 03 文档**新增一节「§17 实现补充与偏差记录」**,逐条列出这些接口(路径 / 权限 / 是否要补进 §4 清单),并在 §4 的合计口径里说明"121 是设计口径,另有 N 个实现补充"。改动后必须重跑 `python scripts/verify-docs.py` (它逐节核对接口数,加节时注意别改动 §4 的声称数)。
- ✅ **已闭环(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) **
### F-5【P2 · 数据卫生】E2E 租户残留到"每个请求多一次解析" —— **清理工具已补齐(2026-09-30) **
- **现象**: `sys_tenant` 17 行、`sys_user` 300 行,而脚本每次跑都会建测试租户,清理只删自己建的那一个。
- **现象**: `sys_tenant` 17 行、`sys_user` 300 行,而脚本每次跑都会建测试租户,清理只删自己建的那一个。
@@ -195,6 +199,7 @@
`【演示】审核台` 是当前在用的,原来那句 `LIKE '【演示】%'` 会把它一起删掉,
`【演示】审核台` 是当前在用的,原来那句 `LIKE '【演示】%'` 会把它一起删掉,
而这种错在预览里只表现为"83 个"(比 82 多一个),**极容易被放过**。
而这种错在预览里只表现为"83 个"(比 82 多一个),**极容易被放过**。
- **建议**:把 `--only-orphan-files --apply` 写进"跑完压测/E2E 的收尾动作"(见 `AGENTS.md` 第三节)。
- **建议**:把 `--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) **
### F-6【P2 · 文档误导】`frontend-pc/AGENTS.md` 里的 Mock 说明已过时 —— **已修(2026-09-30) **
@@ -210,11 +215,13 @@
但路的尽头还是错的",所以在那份 skill 开头加了 ⚠️ 说明(保留原文,供 Gi 模板升级时做差异比对)。
但路的尽头还是错的",所以在那份 skill 开头加了 ⚠️ 说明(保留原文,供 Gi 模板升级时做差异比对)。
核实方式:`frontend-pc/` 下无 `mock/` , `package.json` 与 `vite.config.ts` 里都没有 mock 关键字。
核实方式:`frontend-pc/` 下无 `mock/` , `package.json` 与 `vite.config.ts` 里都没有 mock 关键字。
### F-7【P2 · 一致性】撤回的动作码与适用状态
### F-7【P2 · 一致性】撤回的动作码与适用状态 —— **已闭环**
- **现象**: DDL 注释 `3=批量通过` 与 03 §11.4「写入 `action = 3` (撤回)」冲突,代码取 `7` (已按"时间轴不能混淆"处理);§11.4 的状态表只列状态 3,代码额外允许状态 2。
- **现象**: DDL 注释 `3=批量通过` 与 03 §11.4「写入 `action = 3` (撤回)」冲突,代码取 `7` (已按"时间轴不能混淆"处理);§11.4 的状态表只列状态 3,代码额外允许状态 2。
- **建议**:客户确认后同步两处文档;若客户不同意状态 2 的撤回,删一个分支即可(代码里有注释标明)。
- **建议**:客户确认后同步两处文档;若客户不同意状态 2 的撤回,删一个分支即可(代码里有注释标明)。
- ✅ **已闭环(2026-09-30,客户确认按代码口径) ** :§2.7 `auditAction` 补全 7 个动作(撤回=7),§11.4 约束表补「状态 2 可撤(撤的是初审)」、留痕改 `action=7` 。DDL 注释少列 7(迁移已发布不改,以 §2.7 为准)。
### F-8【P2 · 安全/上线】打桩登录与存储密钥
### 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` 。
- **现象**: `application-dev.yml` 里 `stub-enabled: ${WX_H5_STUB:true}` (**dev 默认开着**),而代码侧默认 `false` ; `keep-raw: ${XT_STORAGE_KEEP_RAW:false}` 默认不留原始件;七牛 AK/SK 只在本地 `application-local.yml` 。
@@ -225,13 +232,15 @@
- **现象**: `src/test` 为空,验证全靠 E2E(很硬,但每次要起服务 + 连库)。
- **现象**: `src/test` 为空,验证全靠 E2E(很硬,但每次要起服务 + 连库)。
- **建议**:先给三个**纯函数**补最小单测(不引框架之外的东西):`IdentityNormalizer` 、命名规范化引擎、压缩档位推导。理由是这三个"输入输出确定、没有 IO",单测成本最低、回归收益最高;其余继续靠 E2E。
- **建议**:先给三个**纯函数**补最小单测(不引框架之外的东西):`IdentityNormalizer` 、命名规范化引擎、压缩档位推导。理由是这三个"输入输出确定、没有 IO",单测成本最低、回归收益最高;其余继续靠 E2E。
### F-10【P3 · 一致】租户域名相关文档暂缓
### F-10【P3 · 一致】租户域名相关文档暂缓 —— **已闭环**
- **现象**:§一·二十五 的能力已实现且默认关闭,PRD 与 03 文档未写(当时有意暂缓)。
- **现象**:§一·二十五 的能力已实现且默认关闭,PRD 与 03 文档未写(当时有意暂缓)。
- **建议**:保持暂缓,但**在 03 文档 §17 留一行**"租户域名能力:`Host 定位 + token 授权` ,默认关闭,启用条件 4 步",避免将来重开时找不到入口。
- **建议**:保持暂缓,但**在 03 文档 §17 留一行**"租户域名能力:`Host 定位 + token 授权` ,默认关闭,启用条件 4 步",避免将来重开时找不到入口。
---
---
- ✅ **已闭环(留一行入口,按建议) ** :契约 §17.2 **D-10 ** 写明「能力已实现(Host 定位 + token 授权),默认关闭,启用四步见《交接摘要》§一·二十六」。
## 六、施工记录(本轮自主完成的事)
## 六、施工记录(本轮自主完成的事)
> 客户要求:**"完成所有的开发计划,中途发现问题自己决定,记录出来让我知道即可。"**
> 客户要求:**"完成所有的开发计划,中途发现问题自己决定,记录出来让我知道即可。"**
@@ -312,7 +321,7 @@
| 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-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-12 | * * `collect_export_task` 表与契约字段不一致**:契约 §12.5 有 `currentStep / stepText / stats.rawSizeMb` ,表里三列都没有 | 不新建迁移,改为推导(D6)。已在实体注释里写明"不要再按契约建字段" |
| F-13 | `RevokePolicy` 在"已导出"之后**不返回** `auditRecordId` (只把 `canRevoke` 置 false) | 这不是 bug,但意味着 * * `40033` 在界面上不可达**(只会出现 40001「缺少要撤回的记录 id」)。已在 E2E 里按"用户真正看得见的东西"断言,并写清 40033 只在绕过前端时可达 |
| F-13 | `RevokePolicy` 在"已导出"之后**不返回** `auditRecordId` (只把 `canRevoke` 置 false) | 这不是 bug,但意味着 * * `40033` 在界面上不可达**(只会出现 40001「缺少要撤回的记录 id」)。已在 E2E 里按"用户真正看得见的东西"断言,并写清 40033 只在绕过前端时可达 |
| F-14 | 已发布的任务**既删不掉也改不了名**(`40001` ),演示/测试数据无法清理 | 开发种子脚本改为"SQL 清空 + 改名挪开"(夹具性质,已注明)。**建议**:给管理员留一个"归档后可删"的口径,否则本地库会一直堆任务 |
| 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-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-16 | ⚠️ **我第一版把导出目录结构做错了 ** :做成"班级/姓名/文件"(更符合"给人翻"的直觉),而 PRD §5.9 明确是「`{批次号}-{任务名称}` / 班级 / **材料类型 ** / 文件」,根目录也不是用户填的导出名 | 已改回规范,并把"根目录名、第二层、第三层、汇总表位置"四条都写成 E2E 断言(`e2e-m6-export.py` ),另加"预览节点与产物逐项一致"。**教训**:目录结构是**交付物的形状**,属于需求,不能按直觉设计 —— 这类错只在客户解压时暴露 |
| F-17 | 汇总表文件名:契约 §12.4 只说"生成汇总 Excel", PRD §5.9 给的是机构叫法 `考生线下报名信息导入模板(汇总).xlsx` | 按 PRD 命名(机构按这个名字找它)。已写进 E2E |
| F-17 | 汇总表文件名:契约 §12.4 只说"生成汇总 Excel", PRD §5.9 给的是机构叫法 `考生线下报名信息导入模板(汇总).xlsx` | 按 PRD 命名(机构按这个名字找它)。已写进 E2E |
@@ -324,13 +333,13 @@
| F-23 | ⚠️ **后端启动直接崩 ** : `TeacherDashboardMapper` 的 `<script>` 动态 SQL 里写了裸 `<>` ( SQL 的"不等于"),而 `<script>` 块要按 **XML ** 解析 —— `<` 必须转义成 `<` 。错误是 `SAXParseException: 元素内容必须由格式正确的字符数据或标记组成` ,**指不到具体那一行 SQL**,而我第一反应是去查 SQL 逻辑(走了一段弯路)。更危险的是:`mvn package` **照样成功 ** ,只有启动时才炸 | 改成 `<>` ( SQL 标准的"不等于"),并顺手扫了 `module/mp/mapper` 下所有动态 SQL,确认没有别的裸 `<` 。**教训**:带 `<script>` 的 Mapper **编译期不校验 XML ** ,改完必须**真启动一次** —— 这类错的表现是"服务起不来",不是"接口报错" |
| 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-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-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-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-27 | `notifySent` 的语义**被收窄了**:以前它表示"有没有通知渠道"(恒 `false` ),现在表示"**微信推送**有没有真的发出去"。站内信一定写得进去,所以 `needManualNotice` 仍然可能为 `true` (学生不一定会进小程序) | 已在代码里写清,并且**没有**顺手把 `needManualNotice` 改成恒 `false` 。E2E 断言"降级时如实告知:notifySent=false / needManualNotice=true",防止后人把它"优化"成乐观值。**这是对前端契约的语义变更,已在本文档记录** |
| F-28 | **降级链只做了两级(订阅消息 → 站内信),中间两级没做 ** :契约 §13.6 写的是「订阅消息 → 公众号模板 → 短信 → 仅站内信」。后两级的缺不是代码问题 —— 公众号模板消息要**已认证服务号 + 用户已关注**(一期没有公众号),短信要买通道与签名(按条计费,是商务动作)。我选择**只实现能真正走通的两级**,而不是写两级"看起来有、其实永远失败"的通道 | 在 `NotificationService` 的类注释里写清了为什么少做,并用 `channel` 字段如实标出这条消息最终怎么到的。**理由**:永远失败的通道会往日志里刷一片失败,把真正的原因(没配通道)淹掉,而运营看到"通知失败率 90%"会去查代码,实际上该去找商务 |
| 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 的其它限流统一,单独加一个会分叉 |
| 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 对齐成同一套码 —— 改哪一边要客户确认 |
| 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 剩余项。**风险**:合规上"谁在什么时候登录过"对学生侧是缺口 |
| 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-32 | ⚠️ * * "发布"与"恢复"都是 `→收集中` **:我第一版让 `patchStatus` 从 `(原状态, 新状态)` **反推**动作名,于是 `resume` 被记成了 `PUBLISH_TASK` —— 而合规要区分的恰恰是"这个任务是第一次发布,还是被暂停后又恢复的"(恢复意味着它曾经被暂停,那段时间学生的填报是被关掉的) | 改成**由调用方显式给动作名 ** ( `patchStatus(id, status, ACTION_*)` ),并补了契约表里没有但必须有的两个动作:`RESUME_TASK` / `CLOSE_TASK` 。**教训**:凡是从结果反推原因的地方,遇到"多对一"就一定会错 —— 而这类错通常表现为"日志看起来有、但答案是错的",比缺日志更难发现 |
@@ -341,7 +350,7 @@
| F-36 | **写机器检查本身也会假阳性 ** : `check-entity-columns.py` 第一版直接搜 `@TableLogic` 字符串,结果把 4 个实体的**类注释**也算进去了 —— 而那四段注释恰恰在解释"为什么不继承基类"(里面当然会出现 `@TableLogic` 这个词)。表现是"4 个实体报错",而我差点据此去改那四个**本来就是对的**实体 | 改成**先去注释再找注解**。**教训**:静态检查一定要能区分"代码"与"解释代码的文字" —— 否则它会指向正好写着"这里不该这么做"的地方 |
| F-36 | **写机器检查本身也会假阳性 ** : `check-entity-columns.py` 第一版直接搜 `@TableLogic` 字符串,结果把 4 个实体的**类注释**也算进去了 —— 而那四段注释恰恰在解释"为什么不继承基类"(里面当然会出现 `@TableLogic` 这个词)。表现是"4 个实体报错",而我差点据此去改那四个**本来就是对的**实体 | 改成**先去注释再找注解**。**教训**:静态检查一定要能区分"代码"与"解释代码的文字" —— 否则它会指向正好写着"这里不该这么做"的地方 |
| F-37 | * * `@Scheduled` 的定时任务在测试里等不起**:导出包过期清理每小时跑一次,而验证"过期之后下载会怎样"总不能把过期时间改成 7 天后再等一小时 | 加了 `POST /exports/cleanup-run` (手工触发,权限同导出管理员)。清理本身**可重复执行**(已清理的卷会跳过),所以手工触发没有副作用。**这是本项目第一个定时任务**,所以顺手在 `FillApplication` 上写了"别顺手加更多:定时任务是「没有请求也会发生的事」,每加一个都要能回答「它失败了谁会知道」" |
| 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-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-39 | ⚠️ * * `collect_file.source` 从来没有被代码显式写过**:库里那 10 行全是**列默认值 1**,也就是「学生自己传的」与「老师代传的」在数据上**完全区分不出来**。而契约 §10.6 明确要求代传必须留痕(`source = 4` /我按 DDL 用 2) | V12 起写入侧显式赋值:学生上传写 1、代传在插入后改成 2 并记 `proxy_by` + `proxy_reason` 。**为什么还加 `proxy_by` **: `source=2` 只说"是班主任代传的",说不出**是哪个**班主任、**为什么**替他传 —— 而契约要求时间轴显示「由李老师代传」,名字只能存在这里 |
@@ -365,7 +374,7 @@
| F-49 | ⚠️ **参数校验排在幂等判断之后就等于没校验 ** :整理结果导入的 `importFiles=true` (一期做不到,本该 40044)在**第二次**导入时被安静接受 —— 因为请求先命中了"已经导过了,直接返回原任务"。**一次不合法的请求不该因为"恰好之前导过一次"而变成成功** | 参数校验提到幂等判断之前 |
| 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-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-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-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` / 整任务发法 |
@@ -377,7 +386,7 @@
| F-56 | ⚠️ **软删除与业务唯一索引打架 ** : `@TableLogic` 让 `delete()` 变成 `UPDATE deleted=1` ,而 `uk_notify_tpl` 唯一索引**不看 deleted** → "恢复默认 → 再自定义"撞唯一约束(500)。**这类冲突在"软删除 + 业务唯一键"的组合下必然出现**,只是要到第二次操作才暴露 | 模板覆盖行改为**物理删除**。**判断依据**:这张表的行是"配置"还是"业务数据" —— 前者的历史价值低、而唯一键冲突的代价高 |
| 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-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-58 | ⚠️ **契约 §7.13 只给了「列表」与「应用」,没有「保存为模板」 ** —— 而没有它,"用户自建模板"永远是一句空话:模板只能靠改库或写迁移产生,而 §7.13 明确说列表要"含公共模板与**租户自建**" | 补 `POST /tasks/{id}/save-as-template` ,并加边界:**只有平台管理员能把模板设为公共**(公共模板对所有租户可见,否则任何一个学校都能把自己的配置推给所有人 —— 那不是权限问题,是产品问题) |
@@ -387,8 +396,8 @@
| 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-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-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-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-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` 前缀,两种写法都能对上,**所以工具不会替我们发现这个冲突** —— 它只能靠人读文档发现 |
| 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-66 | ⚠️ **契约 §14.4 把一个"我方 → 机构"的报文写成了我方的一个路径 ** :正文说"系统在终审通过后主动推送",请求示例也标注"我方 → 机构",但接口清单里它是 `POST /open/v1/callback/push` (一个我方路径) | 实现为**人工重推入口**(`{submissionId}` ),并重置重试次数 —— 它是"人判断过该再试一次",不该被"已经失败 5 次"挡住(那正是需要人工的场景)。自动推送与自动重试分别发生在终审通过的 `afterCommit` 与定时任务里 |
@@ -399,8 +408,8 @@
| F-69 | ⚠️ **开放接口的令牌路径必然 500 ** : `appOfToken` 里用 `selectById` 回查接入方,而开放接口是匿名的 —— **请求进来时还没有租户上下文 ** ,租户拦截器直接拒绝(`未登录或登录已失效` ),对外是 50000「系统繁忙」。HMAC 路径不出现(它走 `findByAppKey` ,已忽略租户),所以**只有令牌路径 500**,看起来像"令牌功能没做" | 凡在"还没有租户上下文"时查归属都用忽略租户的专用方法(`findByAppKey` / `findByIdIgnoreTenant` / `findBySubmissionIgnoreTenant` ),拿到归属后立刻 `TenantScope.callAs` 摆上下文。**"先找归属、再摆上下文"这个模式里,找归属那一步必须能无租户执行** —— 范围就是那几个方法,逐个写清理由 |
| 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-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-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 分钟、绑定到人),要么把它定位成"纯性能优化"并在契约里写明 —— 现在两种都不是 |
| F-72 | ⚠️ * * `uploadToken` 签发之后没有"消费"它的地方**:我按契约实现了凭证的签发与预检,但**上传接口没有接它**(`/mp/files/upload` 仍然只认 `submissionId + materialCode` )。于是凭证目前只用于"预检与秒传判断",**不构成一道校验** —— 客户端完全可以跳过凭证直接上传 | 如实记录(不假装它是安全机制)。**建议**:要么让 `/mp/files/upload` 接受并校验凭证(一次性、15 分钟、绑定到人),要么把它定位成"纯性能优化"并在契约里写明 —— 现在两种都不是 ✅ 已闭环(客户确认定位为**预检/秒传/压缩建议**,非安全边界):契约 §9.5 明写「客户端可跳过;服务端上传本身做全部校验」。 |
| F-73 | ⚠️ **秒传检查排在份数检查之后 ** —— 而"同一个文件重传"恰恰是秒传要解决的场景(学生传完网络断了、客户端重试)。顺序反了会得到「身份证最多 2 份,你已经传了 2 份。要换请先删掉上一份」,让用户在"我明明刚传的"这件事上原地打转 | 改成**先秒传、后份数**。**顺序体现的是"哪个答案对用户更有用"**:「你已经传过了」永远比「你超了」更有用,因为前者不需要他做任何事 |
| 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-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-75 | ⚠️ **压缩率会出现负数 ** :一个 3 个小文件(各 40 字节)的包,`raw=120B` 、`final=802B` → 页面显示 `-568.3%` 。原因是"压缩"在小包上本来就不适用 —— 里面的文件已经是压过的(jpg),或者重新打 zip 的**包头开销**比省下来的还多。问题不在"不好看",而在**客户会以为系统算错了**(而它没算错) | 服务端对 `ratio < 0` 回 `—` (与"还没算"同一个符号:**都是"这个数说明不了什么"**)。⚠️ 顺带发现 **E2E 的断言太弱 ** : `"%" in compressRatio` 对 `-568.3%` 也成立 —— 它确实"是个百分比",只是没有意义。已改成"要么形如 `12.3%` (非负),要么是 `—` " |
@@ -421,7 +430,7 @@
| 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-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-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-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-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-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-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-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` 带进断言信息。**根因是同一条:把"我以为发生了"当成"确实发生了"** —— 而这三处的共同代价是**把排查引向错误的方向**(比直接报错贵得多) |
@@ -533,6 +542,7 @@ M7 收尾 52 人日、M5 剩余前端约 31 人日。**关键路径仍是"审核
| 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 | **对外待决事项归拢 + 一键交付自检 ** : `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 | **文档小账清理 + `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 | **补测 `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-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 ** |