Files
xiaotutongxue/docs/交接摘要.md
T
lovife edb47e8557 verify(h5): 提交页问题清单浮层真浏览器验证通过
两条分支都跑通了(穿透单次要 30 秒左右,耐心等):

  红色分支(阻断)缺身份证材料 + 必填未填
    → 标题「还不能提交」、[必须处理 3]、
      三张红卡(--s3,左边框 #E34D59 / 底 #FDF3F4):
      VAL-16 / VAL-22 / VAL-05
    → 「知道了,去修改」点击后浮层关闭

  黄色分支(可强提)只差一个 VAL-09 重复文件
    → 标题「提交前请确认」、[提醒 1]、
      一张橙卡(--s2,左边框 #ED7B2F)
    → 「仍然提交」→ 弹出「提交成功 / 已提交,等待班主任初审」

提交成功后的状态切换也验证了:
  · 上传按钮与「删除」**全部消失**(canEdit=false)
  · 提交按钮被替换为只读文案「已提交,等待班主任初审」
  · 材料列表显示规范名 + 压缩后体积:
    张同学-361029XXXXXX0015-身份证.jpg  17KB · 900×1200
  · 库:status=1 red=0 yellow=1 submit_time 已写
    日志:提交未通过 ×2 → 提交成功
  · 顺带验证 loadFiles()(重进页面能看到上次传的材料与体积并能删)

⚠️ git add -A 差点把 backend/data/uploads/ 下的**运行时上传文件**提交进来。
   那是数据不是代码 —— 已加进 .gitignore(backend/data/)并从暂存区撤回。

新增 scripts/dev-seed-warn-state.py:把某份草稿快速摆到"只剩黄色警告"的状态,
用来验浮层/审核端的"只有警告"分支,省掉"手填 7 个字段 + 等穿透"。
它**不**替代 E2E —— 逻辑仍由 e2e-m3-submit.py 断言,这个脚本只负责摆状态。

演示态:collect_submission.id=21 现在是「已提交」(本次验证留下)。
它是很好的演示态,但要重跑填报流程必须先删掉它(见交接摘要 §一·十三)。
名册(task 17, id=38)绑定 sys_user.id=151,保持不动。
2026-09-29 13:21:07 +08:00

951 lines
74 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 交接摘要
> 用途:**压缩后的唯一状态源**。新会话读这一份即可接续,不必回看历史对话。
> 更新:2026-09-28
---
## 一、项目一句话
职业技能等级认定**报名材料收集 SaaS**。学生在微信小程序填报、班主任在手机初审、管理员在 PC 终审与导出。核心价值:**动态表单 + 规则引擎(命名/压缩/识别)+ 门禁核验 + 一键规范导出**。
工作目录 `D:\IDEA_Project\xiaotutongxue`(git 仓库,**已有 3 次提交**:`63aec55` 文档 / `9d7a8ca` M0 后端 / `87ece5a` 前端换框架;**其后又有一批未提交改动**,见 §一·四),交付物在 `docs/`。
---
## 一·二、一期范围(2026-09-28 客户确认收窄)
> **一期只做「学校收集 + 自主认定」一条流程。** 详见 PRD「一期范围声明」。
| 维度 | 字段 | 一期 | 二期后置 |
|---|---|---|---|
| 收集模式 | `collect_mode` | `1` 学校收集 | `2` 社会收集 |
| 认定类型 | `cert_type` | `1` 自主认定 | `2` 社会评价 / `3` 专项职业能力 |
**一期材料只有 4 类,全部有实测样本**:一寸照片 / 身份证 / 学籍表 / 工作证明
**二期才做的 2 类材料零样本**:`XJW_BA` 学信网备案表 / `OTHER_PROOF` 其它佐证材料
**数据结构保留不启用**(二期直接开,不需 DB 迁移)——因为这三项本来就是**配置驱动**的
(`needClassAudit` 是开关、字段隐藏走 `visibleWhen`、材料差异是材料项配置),
所以**省下的主要是测试成本与一个外部依赖,而不是代码量**。
**工期**:392 → **387 人日**(−5);**关键路径不变,仍 15 周**。
> ⚠️ **二期启动前必做**:索取社会评价样本(D4)→ 标定 `XJW_BA`/`OTHER_PROOF` → 补社会收集审核流与专项职业能力字段隐藏。
---
## 一·三、开发进展(2026-09-28 起)
> **代码位置**:`backend/`(Java 21 + Spring Boot 4.1.1)、`deploy/`(基础设施)、`scripts/`(工具)
| 项 | 状态 |
|---|---|
| 技术栈定版 | ✅ Java 21 + **Spring Boot 4.1.1** + MyBatis-Plus 3.5.17(boot4) + PG16 + Redis7<br>PC: Vue3 + **Gi Admin Pro(Arco Design Vue 2.58)**;小程序: uni-app |
| M0 后端骨架 | ✅ 统一响应/异常/58 错误码/多租户拦截/软删除/字段填充/AES-GCM/HMAC/RSA/JWT/权限模型 |
| M0 数据库 | ✅ Flyway 2 个迁移;26 张表(20 业务 + 5 标准 + 1 迁移记录) |
| M0 后端接口 | ✅ 租户开通与列表、组织 CRUD 与树、PC 登录/登出/当前用户、**账号管理全套(含 Excel 批量导入)** |
| M0 验收 | ✅ **`e2e-m0.py` → 36/36**;**`e2e-m0-user.py` → 55/55** |
| **M1-01 任务 CRUD 与状态机** | ✅ 建/改/删/发布/暂停/恢复/截止/归档 + 可见范围(班主任只看自己带的班);**`e2e-m1-task.py` → 71/71** |
| **M1-02 覆盖班级与班主任** | ✅ `GET/PUT /tasks/{id}/orgs`(整体替换 + 软移除 + 历史行复用)+ 前端「配置班级」抽屉(勾选班级、主/副班主任、应填人数);**`e2e-m1-orgs.py` → 48/48** |
| **M1-04 材料项定义** | ✅ 材料项 CRUD(§7.10)+ 三个内置模板(`TPL_SCHOOL_ROLL`/`TPL_SOCIAL`/`TPL_ZHUANXIANG`)+ 材料目录接口;**`e2e-m1-materials.py` → 68/68** |
| **M1-03 表单 schema 版本化** | ✅ `GET/PUT /tasks/{id}/form-schema`(版本只增不改 + `baseVersion` 乐观锁 40025 + 内置字段不可删 40026)+ 官方 21 项内置字段目录;**`e2e-m1-form.py` → 48/48** |
| **M1-06 填报二维码** | ✅ `GET /tasks/{id}/qrcode`(真调微信生成小程序码)+ 匿名图片接口 `GET /qrcodes/{scene}`;**`e2e-m1-qrcode.py` → 32/32(唯一打外网的 E2E)** |
| **小程序端骨架 + 扫码落地页** | ✅ `frontend-mp/`(uni-app,appid `wxf01d3fa60f87f0b7`)+ 匿名 `GET /mp/scene-context`;**已在微信开发者工具里跑通**;`e2e-m1-scene.py` → 31/31 |
| PC 前端 | ✅ 已换 Gi Admin Pro + 腾讯云主题;已实现:登录 / 工作台 / 组织架构 / 租户管理 / 账号管理 / **收集任务(含配置班级)** |
| 待做 | M1 剩余(M1-05 规则模板 + **M1-07~M1-10 前端**);**M3 小程序填报主流程**(`/mp/login` 门禁核验 表单);CI/CD;**C1 小程序类目资质(阻塞学生真正开始填报)** |
---
## 一·四、账号管理竖切(2026-09-28 晚,未提交)
> 范围:`GET/POST/PUT/DELETE /users`、`PUT /users/{id}/status`、`POST /users/{id}/reset-password`、
> `POST /users/import`、`PUT /auth/password`;前端「组织与账号 → 账号管理」一屏。
**验收**:`python scripts/e2e-m0-user.py` → **55/55**;`python scripts/e2e-m0.py` 回归 **36/36**。
前端 `pnpm typecheck` **0 错**、`pnpm build` 通过、新增文件 eslint/stylelint 干净。
账号管理页**已在浏览器肉眼确认**(首屏与切页均可见、8→7 列、数据与分页正确)。
### 本轮修掉的 5 个真问题(都不是"功能没做完",是"做完了但错的")
| # | 问题 | 后果 | 修法 |
|:--:|---|---|---|
| 1 | **学校租户也发放 `PLATFORM_ADMIN` 角色行** | 学校管理员在新增账号时传 `roleCodes:["PLATFORM_ADMIN"]` 即可造出带 `tenant:manage` 的账号;而 `/tenants` 只校验权限码、不校验"是否真在平台租户下" → **任何学校管理员都能开通租户、拉全平台租户列表**(租户级越权到平台级) | ① `RoleProvisioner` 不再给非平台租户发该角色行;② `UserService.resolveAssignableRoles` 按租户白名单校验,越界返回 `40301`。两道都留 |
| 2 | `UserUpsertReq.username` 上的 `@NotBlank` 对**修改**也生效 | `PUT /users/{id}` 永远返回 `40001`,而错误明细只说"存在阻断性校验问题",看不出是登录名 —— 改账号功能整体不可用 | 去掉该注解(`@Pattern` 对 null 是通过的),"新增时必填"改由 `UserService.create` 判定 |
| 3 | `Main.vue` 的 `<transition appear>` | 首次渲染被当成一次过渡,元素被加上 `zoom-fade-enter-from/-active`(`opacity:0`)后**永不清理** → **DOM 内容齐全、接口数据正常、文字可选中,但整块内容区肉眼不可见**。极易误判成"页面没渲染"而往路由/接口方向排查 | 去掉 `appear`(首次加载本就不需要入场动画)。**已浏览器复验通过** |
| 4 | 我写的 `assertAnotherAdminExists` **在停用/删除路径上不可达** | 能调这两个接口的人自己必然是活跃管理员,所以"除目标外还有管理员"恒成立。而真正会锁死租户的路径——**把管理员改成普通角色**(目标可以是操作者自己)——当时没有拦 | 新增 `losingAdminRole()` + `UserMapper.countAdminRoles()`,在改角色时判定;方法注释里写明"哪条路径真生效、哪条是防御性的" |
| 5 | ⭐ **`sys_user.user_type` 有两套互相冲突的枚举** | DDL/实体注释写「1教师/管理员 2填报人(学生) 3社会人员」,而**权威契约**(《03-接口设计》§2.7)与 `BuiltinRole.UserType` 是「1平台运营 2学校管理员 3班主任 4学生」。两套的 **2 和 3 完全错位**。我照错的注释写了新代码,结果是:**`userType=3`(班主任,最高频的账号)被当成"社会人员(二期)"直接拒掉** —— 建班主任账号会失败;前端「类型」列还把学校管理员显示成"填报人(学生)" | ① 后端改成**从角色反推用户类型**再比对(`ROLE_TO_USER_TYPE`),不再逐条 `if`;② 新增 `V3__fix_user_type_comment.sql` 更正库注释(**不能改 V1,Flyway 会校验 checksum**);③ 更正 PRD 与 `schema-postgresql.sql` 的同一行注释;④ 前端「用户类型」从手选改为**由角色推导**,从根上消除矛盾组合;⑤ E2E 加 3 条回归断言锁住这个语义 |
> ⚠️ **第 5 条是最值得记住的一条**:不是"漏了个校验",而是**同一份资料里有两套定义,且都对不上**。
> 教训:看到"枚举注释"时,要去**权威契约**(这里是《03-接口设计》)核对,不能就近抄实体或 DDL 的注释。
> 这条也解释了为什么 `V1` 迁移里的错误注释只能靠 `V3` 覆盖 —— 已应用的迁移**一个字都不能改**。
### 已知缺口(有意留着,别当成漏做)
| 项 | 说明 |
|---|---|
| **改密后旧 token 仍有效** | 直到自然过期。要立刻踢人需要 JWT 带签发时间 + 与"最近改密时间"比较,涉及 `JwtService`/`AuthInterceptor`,不适合混在这一刀里。已在 `AuthService.changePassword` 注释里写明 |
| `hasActiveTask()` 恒为 `false` | `collect_task` 是 M1 的表且当前无"任务负责人"字段,`40014` 这条约束**目前不生效**。方法注释已标红,M1 必须补 |
| 手机号**模糊**搜不了 | AES-GCM 每次密文不同,`LIKE` 在原理上不可行。完整号码走 `phone_hash` 等值匹配;代价是"只记得中间几位"搜不到,属加密的必然取舍。E2E 里有一条专门断言这个行为(避免后人当 bug 改掉) |
| **浏览器端验证** | ✅ 已完成。账号管理页首屏与切页均 `opacity: 1`(`appear` 修复生效)、7 列、数据与分页正确、组织树正常;demo 租户已清理干净(1 个账号 `admin`、6 个组织节点)。⚠️ 下一个新页面上线时容易忽略的一点:**新增 `views/` 目录后必须重启 dev server**,见下方环境坑 3 |
### 本轮踩到的三个环境坑
1. **Maven 的本地仓库不在 `C:\Users\<u>\.m2\repository`**,而在 `D:\maven_repositiory`。在那里查依赖才准。
2. **`easyexcel-4.0.3.jar` 只有 3KB** —— 它是聚合包,真类在 `easyexcel-core`(528KB)+ `poi 5.2.5`。只看聚合包的大小会误判成"依赖是空的"。这正是本项目"依赖在、功能不在"那条教训的又一例。
3. **`import.meta.glob('@/views/**/*.vue')` 在 dev server 里对"新增目录"不总失效**:`views/user/` 建好并 HMR 重载后,路由仍拿不到组件(component 为 `undefined` → 内容区空白)。**新增页面目录后要重启 dev server**,不能只靠 HMR。
---
## 一·五、M1-01 任务 CRUD 与状态机(2026-09-28 夜,未提交)
> 范围:`GET/POST /tasks`、`GET/PUT/DELETE /tasks/{id}`、
> `POST /tasks/{id}/{publish|pause|resume|close|archive}`;前端「收集管理 → 收集任务」一屏。
**验收**:`python scripts/e2e-m1-task.py` → **71/71**;
`e2e-m0.py` 36/36、`e2e-m0-user.py` 55/55 回归通过;
前端 `pnpm typecheck` 0 错、`pnpm build` 通过。
### 三条硬约束(改动时当成规则,不是"可以商量的校验")
| # | 约束 | 为什么 |
|:--:|---|---|
| 1 | **「暂停」与「截止」是两件事** | 暂停:学生不能交,**已提交的照常审**,且**可恢复**(名单要改、规则要调时用)。截止:不可交、仍可审,**不可逆**。做成一个就等于把"临时冻一下"变成不可逆操作,现场没人敢点 |
| 2 | **发布前置 5 项全是阻断级**,`force` 绕不过 | 名册非空(40016)、名册含姓名与证件号(40017)、≥1 项必传材料(40018)、已设置覆盖班级(40019)、命名模板可用(40020)。可绕过就会出现"学生填了却没人审"或"文件名根本生成不出来"。只有 P1 级提醒(某班未指定初审人)可用 `force` 跳过 |
| 3 | **`collectMode` / `certType` 发布后不可改** | 它们决定审核流与材料清单;改了会让已经填过的学生面对另一份表单。归档后仍可改名称,只锁这两个 |
### 本轮修掉的 3 个真问题
| # | 问题 | 后果 | 修法 |
|:--:|---|---|---|
| 1 | **`collect_task_org` 没有 `deleted` 列** | 我起初让它继承 `TenantEntity`(基类带 `@TableLogic deleted`),MyBatis-Plus 会给**每条**查询自动加 `deleted = 0` → 运行时报「列不存在」。而这**编译期完全看不出来** | 该实体改成不继承基类、显式声明字段;"移除"语义走 `status = 0`(DDL 注释即如此),**不要对它调 `deleteById`**(会变成物理删除) |
| 2 | **任务编码查重被逻辑删除遮蔽** | 用 MP 的 `selectCount` 查重会自动过滤 `deleted = 0`,但**数据库的唯一约束不看软删标记** —— 软删掉的任务仍占着那个编码。表现为 `duplicate key value violates unique constraint "uk_task_tenant_code"`(500)。另外原用 `COUNT+1` 也错:删掉中间一个任务后计数会退回去,算出已占用的号 | 改为 `MAX(序号) + 1`,且**查重语句刻意不加 `deleted = 0`**(与唯一约束同口径);编码一次用过就不复用(它同时是**文件目录名**,复用会把两批任务的文件混进同一目录)。并发撞号由唯一约束兜底并返回可读提示,不再 500 |
| 3 | **Boot 4 的 Jackson 自动配置在 `spring-boot-starter-jackson`,项目没引** | 两个症状:① 容器里**没有 `ObjectMapper` bean**(`@Autowired` 直接启动失败);② `application.yml` 里 `spring.jackson.*` **静默失效** —— 其中 `time-zone: Asia/Shanghai` 不生效最危险:本机时区恰好是上海时看不出来,部署到 UTC 服务器后所有时间会整体偏 8 小时,且只表现为"时间看着不对"。**注意 `spring-boot-starter-json` 在 Boot 4 已无 4.x 版本**(仓库里最高 3.5.6),照老习惯找它是找不到的 | 在 `xt-common` 补 `spring-boot-starter-jackson`。但**任务配置的 JSON 读写仍不用 Jackson** —— Boot 4 用 Jackson 3(`tools.jackson`)而 `jjwt-jackson` 带来 Jackson 2,打包后 jar 里两个 `jackson-databind` 并存(2.21.5 + 3.1.5),import 哪个都是赌;改用项目已直接声明的 `hutool-all` |
### 已知缺口(有意留着)
| 项 | 说明 |
|---|---|
| **发布在前置数据齐备前不可达** | 发布要求名册(M2)、材料项(M1-04)、覆盖班级(M1-02)。所以 E2E 用 **SQL 夹具**造这三样,才能走到"已发布→暂停⇄恢复→截止→归档"并验证状态机。这是测试夹具,不是产品捷径 —— **M1-02/M1-04/M2 落地后要回来把夹具换成真实接口** |
| 命名规则试跑目前是**静态校验** | 只查"变量名是否合法"与"是否含姓名/证件号/材料名"。完整形态(拿真实名册前若干行跑一遍,检查生成的名字非空且两两不重复)需要名册数据,属 M1-09 / M3-01。当前这条已能拦住绝大多数错误(变量名写错、漏必备变量),且名册为空时发布本就会被 `40016` 先拦 |
| `stat` 恒为 `null` | 依赖名册(M2)与提交单(M3)。**故意不返回全 0 的对象** —— 那会让界面显示"完成率 0%",而真相是"还没有分母"。界面显式显示「待导入名册」 |
| 任务编码并发撞号 | 两个请求同时到达可能算出同一个号。唯一约束兜底 + 可读提示(不再 500)。一学期几十个任务,撞号概率极低,为此引独立序列表或分布式锁不划算 —— 但**别当成已解决** |
| `taskOrgMapper` 尚未用到 | M1-01 只用到 `TaskReadinessMapper` 里的计数;覆盖班级的增删改属 M1-02 |
### 前端踩到的坑
- **不要在校验/修复工具会碰的模板里拼花括号**。我写 `{{ '{' + v + '}' }}` 渲染命名变量按钮,`eslint --fix` 把它"帮忙"改成模板字符串 `` `{${v}}` `` —— 而里面的 `}}` 会截断 Vue 的插值解析器,报
`Error parsing JavaScript expression: Unexpected token, expected "}"`。
**这个错只在 `pnpm build` / 编译时出现,编辑器里看不出来。**
正确做法:在 `<script>` 里算好 `{ value, label }`,模板只做纯取值。
- **列宽要先量可用宽度,不能按像素凑总宽**(实测数据):
| 页面 | 可用宽度 | 原因 |
|---|---|---|
| 收集任务 | 约 **824px** | 只有 200 左侧栏 |
| 账号管理 | 约 **574px** | 200 左侧栏 + 约 250 左侧组织树卡 |
Arco 会按容器**重新分配**列宽:声明 120 的列被拉到 188,而 `fixed="right"` 的操作列被压到 105 —— 105px 装不下「编辑 / 重置密码 / 停用」,**竖排成了「编 辑 / 重 置 密 码 / 停 用」**。
更关键的是:列宽合计超过可用宽度时必然横向滚动,而**钉住的操作列会盖住紧邻它的那一列**(实测把「状态」整个遮住了,表头挤成「进度操作」)—— 这是固定列的固有行为,不是配置错误。
**踩过两次的教训:去掉 `fixed` 看似不压盖了,实际是把操作列滚出视野,用户根本看不到操作入口 —— 比压盖更糟。**
真正的解法只有一条:把列数减到能在可用宽度内放下(账号管理目前 5 列 800px,还需要再减一列)。
- ⚠️ **改中文内容一律用 Python 脚本文件,不要用 PowerShell 内联命令**(本项目已写进规则,我又犯了一次):
用 `python -c @"..."@` 内联改这两个 vue 文件时,PowerShell 把 `fixed="right"` 的 `f` 吃成了一个**不可见字符**(变成 `ixed="right"`),同时引入"不规则空白"。
肉眼完全看不出来,只有 eslint 的 `no-irregular-whitespace` 能发现。
---
## 一·六、M1-02 覆盖班级与班主任(2026-09-29 上午,未提交)
> 范围:`GET/PUT /api/v1/tasks/{id}/orgs`(《03-接口设计》§7.6);
> 前端「收集任务 → 操作 → 配置班级」抽屉(勾选班级 + 主/副班主任 + 应填人数)。
**验收**:`python scripts/e2e-m1-orgs.py` → **48/48**;
`e2e-m1-task.py` 71/71、`e2e-m0.py` 36/36、`e2e-m0-user.py` 55/55 回归通过;
前端 `typecheck` / `eslint` / `lint:style` 0 错、`build` 通过。
### 三条守住的规则(改动时当成规则)
| # | 规则 | 为什么 |
|:--:|---|---|
| 1 | **班级与初审人都必须在本租户内** | 跨租户的 id 一律当作"不存在或已删除",不透露"它存在但不属于你"。漏了的后果是 A 校可以把 B 校的班挂到自己的任务上,B 校的班主任就出现在 A 校的审批流里 |
| 2 | **初审人必须持有班主任/副班主任角色** | 「指定初审人」本质上是一次**授权** —— 那个人将能看到该班学生的身份证号与学籍材料。不做角色校验就等于把"谁能看学生隐私"变成任意授权。报错要给出下一步(去账号管理分配角色),不能只说"校验失败" |
| 3 | **移除是软移除**(`status = 0`),且**"移除再加回"必须复用原行** | 名册与提交单挂在覆盖关系下,物理删会让历史数据失去归属。而复用是**必须**的(不是优化):`uk_task_org UNIQUE (task_id, org_id)` 不带 status 条件,一个班在同一任务下只能有一行,直接 insert 会违反唯一约束 |
### 本轮修掉的 3 个真问题
| # | 问题 | 后果与修法 |
|:--:|---|---|
| 1 | **我误判 `uk_task_org` 不存在** | 先前的 grep 只匹配含 `collect_task_org` 字样的行,而 `CONSTRAINT uk_task_org UNIQUE (task_id, org_id)` 那行**没有表名** —— 于是漏掉了,还基于"没有唯一约束"的错误前提写了一个多余的部分唯一索引 `uk_task_org_active`。已撤掉(DROP INDEX + 删 flyway 历史行 + 删文件),并更正了注释:**"复用已移除的行"不是优化而是必须**。教训:**查约束要查 `CREATE TABLE` 整块,不能只 grep 表名** |
| 2 | **`collect_task_org.expected_count` 是 `NOT NULL DEFAULT 0`,与接口契约矛盾** | DDL 注释写的是"冗余,由名册汇总"(系统算的),但《03-接口设计》§7.6 把它放在 **PUT 请求体**里,且说"用于**与名册实际人数比对**,不一致时告警" —— 只有"管理员填的期望值 vs 名册实际值"才需要比对。而 `0` 是有意义的业务值("预期 0 人"),拿它兼职表示"没填"会让**每个没填的班**都触发"应填 0 人、实际 31 人"的假告警;告警变噪音就没人看了,真正"应填 28、名册却有 31"(名册导错)反而被漏掉 —— 而这条告警的全部价值就在那一次。已由 `V4__task_org_expected_count_nullable.sql` 改为**可空 + 去掉默认值**,并同步更正了 `docs/db/schema-postgresql.sql` 与 PRD 里的内联 DDL。**NULL ≠ 0 这条规则适用于所有"可选数字"字段** |
| 3 | **删掉迁移文件后必须 `mvn clean`,否则它会"复活"** | 我删掉 `V4__task_org_active_unique.sql` 后只跑了 `mvn package`(没有 clean):`target/classes/db/migration/` 里**还留着旧副本**,被打进 jar,Flyway 发现历史里没有 v4 就**又执行了一遍** —— 索引回来了、历史行填回去了。之后 I 又建了同名版本号的新 V4,启动就报 `Found more than one migration with version 4`;清了旧副本后又变成 `Migration checksum mismatch for migration version 4`。**排查绕了好几圈,根源就是一行 `mvn package` 少了 `clean`。** 规则:**动过 `db/migration/` 里的文件(新增、删除、改名),一律 `mvn clean package`** |
顺带修掉:`views/task/index.vue` 的操作列从 3 个链接涨到 4 个(编辑/配置班级/发布/删除),
160px 会挤成竖排 —— 把「进度」并进「状态」列腾出 40px,操作列给到 200px,合计 820px
(可用约 824px)。发布被拦时现在会**告诉用户去哪里补**(`BLOCKING_HINTS`),
而不是只说"未设置覆盖班级"。
### E2E 脚本必须"崩了也清理"
`e2e-m1-orgs.py` 第一次运行时在准备阶段抛了 `TypeError`(我自己写错了参数),
**脚本直接退出、清理段没执行**,把两个测试账号留在了库里 —— 直到我在浏览器里
看到「班主任」下拉里有个 `E2E班主任688ed2` 才发现。
已改成:清理逻辑抽成模块级 `cleanup_all()`(幂等),既在 `main()` 末尾调用,
也在 `__main__` 的 `try/except BaseException` 里兜一次。
**其余三条 E2E 脚本(`e2e-m0.py` / `e2e-m0-user.py` / `e2e-m1-task.py`)还是老样子,
崩了会漏数据,下一轮一并按这个模式改。**
> 另外记一笔:清理残留账号时我写的用户名前缀过滤(`adm`)**把 `admin` 也匹配进去了**,
> 是服务端的「不能停用当前登录的账号,请换一个管理员账号操作」把它挡下来的。
> 那条护栏当初加的时候是为了防自锁,这次意外挡住了一次误删 —— 护栏的价值往往
> 不是在被设计的场景里体现的。
### ⚠️ 新增两条前端硬规则(来自 `frontend-pc/AGENTS.md` 与 `.agents/skills/`)
1. **未经用户明确要求,不要 `git commit` / `push`**(AGENTS.md 常驻规则)。
本轮改动**未提交**,工作区里留着。
2. **`defineEmits` 必须用函数调用签名**,禁止 tuple/对象形式:
```ts
// ✅
const emit = defineEmits<{ (e: 'save-success'): void }>()
// ❌ const emit = defineEmits<{ 'save-success': [] }>()
```
框架自带组件(GiForm / GiButton / GiTable)用的都是前者 —— 这是本项目已确立的写法。
我此前写的 5 个组件(`OrgFormModal` / `TaskFormModal` / `TaskOrgDrawer` /
`UserFormModal` / `UserImportModal`)都是被禁的 tuple 形式,**本轮已全部改正**。
改文件前先按 `frontend-pc/AGENTS.md` 的表格加载对应 skill(`.vue` → `code-vue`,样式 → `code-css`,
`.ts` → `code-ts`,CRUD → `project-crud`)。
---
## 一·七、M1-04 材料项定义(2026-09-29 上午,未提交)
> 范围:`GET/POST /tasks/{id}/materials`、`PUT/DELETE /materials/{id}`(《03-接口设计》§7.10)、
> 三个内置材料模板、材料目录接口 `GET /material-catalog`。
> **按计划表,材料规则的界面属 M1-09(6 人日,前端),本轮只做后端。**
**验收**:`python scripts/e2e-m1-materials.py` → **68/68**;
其余四条回归通过(`e2e-m0.py` 36 + `e2e-m0-user.py` 55 + `e2e-m1-task.py` 71 + `e2e-m1-orgs.py` 48),
合计 **278 项断言**。
### 落在代码里的三件事
| # | 事 | 说明 |
|:--:|---|---|
| 1 | **默认值按两个维度分别取** | 见 `MaterialCatalog`:**按材料码**定名字/别名/默认压缩档/**份数与是否合并**;**按压缩档**定固定尺寸(P1)/长边下限(P2)/目标 DPI(P3)/输出格式。把份数塞进档位默认里是很容易犯的错 —— 那样所有 P2 材料都会变成"要传 2 份",而"身份证要正反面"是**这件材料的事实**,不是档位的规则 |
| 2 | **`WORK_PROOF` 的名字是「工作证明」** | 机构模板里「职业技能等级个人申报表」的规范文件名就是「工作证明」。这是机构既有习惯,**不要"修正"** —— 改成"个人申报表"会让导出包与该机构一直以来的归档口径对不上 |
| 3 | **关联键是 `(task_id, material_code)`** | `collect_file` 表里**没有** `material_def_id`。所以:①「已有文件不可删」(`40027`)必须按码查,用 id 查会永远返回 0、这道校验形同虚设;②修改**不允许改码**(改了等于把已有文件迁到别的材料名下) |
### 本轮修掉的 2 个真问题
| # | 问题 | 说明 |
|:--:|---|---|
| 1 | **`NOT NULL DEFAULT` 与"档位不适用就置空"打架(同一类 bug 第二次)** | `min_long_side`(`NOT NULL DEFAULT 1400`)与 `output_format`(`NOT NULL DEFAULT 'jpg'`)在切档位时被写成 null → PostgreSQL 报 `null value in column "min_long_side" violates not-null constraint`,接口 500。而更根本的是:**契约 §3.4 明确写着 `outputFormat` 的 `null` = 保持原格式** —— 这一列本就不该是 NOT NULL。已由 `V5__material_profile_params_nullable.sql` 把四个档位参数统一成"**不适用时为空**"(另外两个 `target_dpi`/`fixed_size` 本来就可空),并同步更正两份 DDL 文档。<br>⚠️ **这与 `expected_count`(§一·六)是同一类问题**:`NOT NULL DEFAULT x` 看起来无害,但它让"未设置/不适用"无法表达,而契约往往又要求能表达。**动"可选数字/字符串"字段前,先查 `information_schema.columns` 的 `is_nullable`,不要只读 DDL** |
| 2 | **我的两条 E2E 断言是坏的** | ①把 `lower_case` 断言成"应被拒"——实际上服务端把它规范化成 `LOWER_CASE` 后接受了,这是**对输入友好**的正确行为,错的是断言;②"改档位后参数跟着变"用了 `P3`,而 `OTHER_PROOF` 的默认档本来就是 `P3`,于是**无论改没改都通过**(假通过)。教训:**断言要能真的失败** —— 换档位要换到与原值不同的档位,才测得出东西 |
### 顺带做的两处整理
- **抽出 `support/NamingTemplate`**:任务级 `naming_template` 与材料级 `name_pattern` 用**同一套**校验规则。
两处各写一套会出现"任务级认的变量材料级不认",而这类不一致的表现是
**发布时通过、导出时文件名叫错**,最难在测试里撞上。`TaskService` 已改为委托。
- **`materialTemplateId`(契约里是 int64)不再空转**:§7.10 的三个模板在文档与代码里都是
**具名常量**,库里也没有"材料模板表",拿 id 指代常量就得先造一张表放它们。
所以新增 `materialTemplateCode`(字符串)承接这三个内置模板,
`materialTemplateId` 留给 M1-05 的 `collect_rule_template`(那是真正的一行一条,id 才有意义)。
### 已知缺口(有意留着)
| 项 | 说明 |
|---|---|
| **`minSizeKb` 恒为 `null`** | 契约 §3.4 给了这个字段但"对应列"写的是 `—`(没有列),而《04-核心模块设计说明》又确实用到了 `materialDef.minSizeKb` —— 两侧口径没对齐。而且**具体阈值属于待办 **D5「一寸照片阈值标定」**,还没有实测数据**。编一个数(比如 30KB)会让它看起来"已实现",但那个数没有依据,卡住真材料时没人说得清为什么。等 D5 出结果再接 |
| **材料规则的界面未做** | 按计划表属 **M1-09(材料规则配置,6 人日,前端)**。所以**目前材料清单只能通过接口配置**,界面上还没有入口 —— 也就是说**发布仍无法在界面上走通**(还差材料清单与名册两块) |
| 条件显示的求值引擎 | 本轮只做规则的存取与形状校验(`field` 必填);真正的求值在填报引擎 M3-10 |
| `40030`(审核中不可删材料) | 属 M5,当前删除只查 `40027`(已有文件) |
---
## 一·八、M1-03 表单 schema 版本化与冲突检测(2026-09-29 上午,未提交)
> 范围:`GET/PUT /api/v1/tasks/{id}/form-schema`(《03-接口设计》§7.9 / §3.5)、
> 官方 21 项内置字段目录(`BuiltinFields`)。
> **按计划表这一项是后端**;表单设计器界面是 M1-07(8 人日,前端)。
**验收**:`python scripts/e2e-m1-form.py` → **48/48**;
六条 E2E 合计 **326 项断言**全通过(36 + 55 + 71 + 48 + 68 + 48);
Flyway 迁移 1~6 全部 success。
### 四条守住的规则
| # | 规则 | 为什么 |
|:--:|---|---|
| 1 | **版本只增不改**:每次保存插一行新版本,旧的保留(`is_current = 0`) | 学生是按某一版填的,事后必须能回答"他当时看到的是什么"。覆盖式保存会让这个问题永远答不上来。表上没有 `deleted` 列也是这个道理 —— 表单版本不需要软删,它本来就不该删 |
| 2 | **`baseVersion` 是乐观锁**,不符则 `40025` **并把最新定义一起回传** | 设计器是"打开 → 改很多处 → 保存",中间可能好几分钟。直接覆盖会**静默丢掉别人刚加的字段**,而且丢失发生在"另一个人"那里。只说"版本冲突"而不给最新内容,等于把合并推给一个信息不全的调用方 —— 所以 `40025` 的 `data` 里带着最新 schema |
| 3 | **内置字段不可删(`40026`)的判断依据是「内置目录」而不是「上一版定义」** | 拿上一版当依据的话,客户端只要让上一版先缺一个(如果它是这样存的)就绕过去了。实际测试也验了:**把 `builtin` 传成 `false` 无法绕过** —— `builtin` 与 `source` 一律以内置目录为准,不采信客户端 |
| 4 | **扩展字段键必须带 `extra_` 前缀、不可与内置重名** | 扩展字段的值落 `extras_json`,与内置列同处一份 form_data。一个叫 `education` 的扩展字段会让"这个值到底是内置的还是扩展的"永远说不清 |
### 契约之外必须补的一个字段:`source`
§3.5 说"内置字段与 `collect_submission` 列名一致",但**「姓名」「身份证号」「联系电话」「序号」根本不在 submission 里,在名册里**(`collect_roster`)。所以加了 `source`(`ROSTER` / `SUBMISSION`)。
- 不标来源,填报引擎就得靠一张硬编码名单去猜"这个 key 去哪取值",猜错的表现是**"学生端这两栏永远是空的"** —— 一个不报错、只让人困惑的失败。
- ⚠️ **`source` 与 `locked` 互相独立**,这个区别很关键:
「联系电话」`source = ROSTER`(值来自名册)但 **`locked = false`**(预填可改 —— 名册里的号码常是家长的老号码);
「报考职业」`source = SUBMISSION` 且 `locked = true`(由计划定,学生不懂也不该改)。
### 21 项内置字段的口径(照抄,不得擅改)
- 选项集**逐字照抄官方**:学历 11 项、等级 5 项、补考理论 4 项、补考实操 6 项、补考综合 2 项。
官方原文写着"若手动输入则只能填写下拉选项中包含的值" —— **多一个少一个都算改了机构的规则**。
- 级联:补考三科仅 `is_retake = true` 时显示;在校生三项仅 `is_student = true` 时显示;
入学时间带 `^\d{4}-\d{2}-\d{2}$` 正则(官方原文「格式必须 YYYY-MM-DD」)。
- 工作单位的提示语保留官方原文「没有工作单位请填「**无**」」。
⚠️ **「无」是合法业务值,不是空值** —— 校验时不能把"无"当成"没填"给拦掉(这条在交接摘要正文里已列为铁律)。
- `collect_form_schema` **没有 `deleted` 列**,实体刻意不继承基类(`collect_task_org` 踩过同一个坑)。
### 本轮踩的坑
| 坑 | 说明 |
|---|---|
| **我的 E2E 又写错一条** | 测「已截止不可改表单」时我先调 `close`,但**草稿是不能直接截止的**(`close` 只允许 收集中/已暂停)。已改成先用 SQL 夹具把发布前置备齐 → 发布 → 再截止 → 再断言。**这反而验证了一件对的事**:没配齐就发布,学生填了也没人审,所以 `close` 把草稿挡住是合理的 |
| 写 Java 时把 `Map` 声明成了 `String` | `final String whenRetake = visible(...)` —— 编译器直接拦住,没有代价 |
### 已知缺口
| 项 | 说明 |
|---|---|
| **表单设计器界面未做** | 按计划表属 **M1-07(8 人日,前端)**。目前表单定义只能通过接口读写 |
| **报考职业/工种/等级的选项为空** | 它们来自"评价计划"(由名册聚合,M2),不是固定选项。当前 `options` 留空,界面上会显示成没有可选项 —— 属预期,等 M2 接上计划级联 |
| 没有字典表,`dictCode` 暂未启用 | 库里没有 `sys_dict`,所以官方选项直接内联在 `options` 里。将来接字典模块时可以把它们移过去 |
| 条件显示的求值引擎 | 本轮只做存取与形状校验;求值在填报引擎 M3-10 |
---
## 一·九、M1-06 填报二维码(2026-09-29 上午,未提交)
> 范围:`GET /tasks/{id}/qrcode`(§7.12)+ 匿名图片 `GET /qrcodes/{scene}`、
> 微信小程序凭据接入、access_token 与二维码的 Redis 缓存。
**验收**:`python scripts/e2e-m1-qrcode.py` → **32/32**;
**这是本仓库唯一一条打外网的 E2E**(真实调微信生成小程序码)。
七条 E2E 合计 **358 项断言**全通过(36 + 55 + 71 + 48 + 68 + 48 + 32)。
### 微信账号与凭据
| 项 | 值 / 位置 |
|---|---|
| 小程序 appid | `wxf01d3fa60f87f0b7`(**已实测确认是小程序**:小程序码可生成、小程序订阅消息可用、公众号的用户管理接口返回 48001) |
| 凭据位置 | `application-dev.yml` 的 `fill.mini-program`,用 `${WX_MP_APP_SECRET:...}` 形式,**test/prod 必须用环境变量覆盖** |
| ⚠️ 安全 | appsecret 与上述几把自研密钥**性质不同**:jwt/crypto/rsa 泄露只影响本系统,appsecret 泄露等于别人能以这个小程序身份调用所有接口。**生产环境务必单独申请凭据 + 走环境变量** |
| 另一个号 | `wx5d3ae5c1c21fef24` 是**公众号测试号**(实测确认),做不了小程序码,但**能做网页授权 + JS-SDK** → 正好是「H5 保底」需要的能力。两条路都有了 |
**开发期 `env-version` 设为 `develop`**(配置项 `WX_MP_ENV_VERSION`)。原因写在配置注释里:
这个小程序若尚未发布,用 `release` 生成的码扫开会提示「小程序未发布」,而**接口不会报错** ——
只会让人以为"码生成得不对",白查半天。
### 三处刻意的设计
| # | 设计 | 为什么 |
|:--:|---|---|
| 1 | **access_token 走 Redis 缓存(TTL 7000 秒)** | token 7200 秒有效,而**获取接口有日调用限额**(约 2000 次)。每次生成码都取一次 token,几十个班一起用就会撞限额,表现是"早上还能用,下午全班的码都生成不出来"。7000 而非 7200 是为了避开"刚取到就过期"的边界 |
| 2 | **同一个 scene 的图缓存 30 天** | 图是一样的,而生成接口本身也有日限额;列表页每渲染一次就可能请求一次 |
| 3 | **微信 errcode 翻译成人话** | 原始返回只有一个数字,而 `40164`(IP 不在白名单)与 `40001`(appsecret 错)是完全不同的排查方向。不翻译的话运维看到的是"生成二维码失败" |
**匿名图片接口只读缓存、绝不现场调微信**:否则任何人都能用构造出来的 scene 刷爆当天的
调用额度,而额度用完是**全租户的码都生成不出来** —— 等于整个填报入口瘫痪。
### 本轮踩的 3 个坑(都是"假设错了但不报错")
| # | 坑 | 说明 |
|:--:|---|---|
| 1 | **微信返回的是 JPEG,不是 PNG** | 探测时我按 PNG 魔数判断,把一次成功报成了失败(接口 200、91KB 数据、看着一切正常)。**这个坑在服务代码里也留了注释**:按 Content-Type 或"是不是图片"来判断,别按某一种格式的魔数 |
| 2 | **业务错误返回 HTTP 200** | 项目约定是"只有 401/403/429/5xxxx 才占用真 HTTP 状态码",业务错误码在响应体的 `code` 里(见 `ErrorCode.httpStatus()`)。我的 E2E 按"HTTP 状态码 != 200"判断业务失败,于是把"业务失败"当成了"成功" |
| 3 | **我写的断言又一次恒为真** | `check("...", "max-age" in (...) or True, "")` —— `or True` 让它永远通过。**这与上一轮记进摘要的"断言要能真的失败"是同一类错**,说明那条教训我只是写下来了、没有真的在写断言时自查。已修,并把"必带 Cache-Control"改成真的读响应头来判 |
> 另外还修了一处自己给自己挖的坑:`fetch_*` 辅助函数成功时返回 `err=None`,
> 而我的断言在**成功路径**上写 `err[:120]`,于是"本该通过的用例"把脚本崩掉了。
> 已改成成功时返回空串。
### 已知缺口
| 项 | 说明 |
|---|---|
| **`shortUrl` 恒为 `null`** | 短链需要一个短链服务(发号 + 跳转表 + 域名),而「H5 保底」这条路线本身还没定。**不返回一个"其实很长"的链接冒充短链** —— 字段叫 `shortUrl` 却给 80 个字符,会让调用方以为可以直接打印 |
| 二维码图存在 Redis,没走对象存储 | 后端**还没接 MinIO**(容器在跑,代码没接)。当前由本服务的匿名接口提供图片。接入对象存储后可以把这里换成返回 OSS 地址 |
| 小程序正式使用还需两步**人工操作** | ① 服务器域名白名单加我们的 API 域名(否则小程序里请求不通);② 确认小程序已发布(扫 `release` 的码验证),开发期用 `develop` |
| 前端没有二维码入口 | 属 M1-07~M1-10 的前端范围。当前可通过接口取码 |
---
## 一·十、小程序端骨架 + 扫码落地页(2026-09-29 上午,未提交)
> 范围:`frontend-mp/`(uni-app 项目,从官方 `uni-preset-vue#vite-ts` 模板建)、
> `GET /api/v1/mp/scene-context`(匿名,登录前的落地页查询)。
**验收**:`python scripts/e2e-m1-scene.py` → **31/31**;
八条 E2E 合计 **389 项断言**全通过。
**已在微信开发者工具里真机模拟跑通**:`scene=t1-k17` → 显示「示范职业学校 /
2026年9月机电专业认定材料收集 / 尚未发布」。
### ⚠️ 微信版本与「谁能扫」(这是上线计划的关键约束)
| 版本 | 谁能扫开 | 有效性与成本 |
|---|---|---|
| `develop` 开发版 | **只有开发者本人** | 上传/预览后短期有效,**会过期**(实测遇到过「开发版小程序已过期」) |
| `trial` 体验版 | **只有体验成员**(后台「成员管理」加人) | 相对稳定,被新版本顶替前一直有效 |
| `release` 正式版 | **所有人** | 需**提交审核 + 发布**,涉及小程序类目与资质 |
> ⚠️ **开发版与体验版,学生都扫不开。** 只有正式发布后学生才能扫 ——
> 所以 **C1「小程序资质法务确认」不是"以后再说"的事**,它直接决定学生能不能开始填报。
> 建议尽早就"教育类目需要什么资质"问清楚。
### 两个工具链的产物路径(容易搞混)
| 工具 | 命令/操作 | 产物目录 |
|---|---|---|
| CLI(pnpm) | `pnpm dev:mp-weixin` / `build:mp-weixin` | `dist/dev/mp-weixin`、`dist/build/mp-weixin` |
| HBuilderX | 「运行到小程序模拟器」 | **`dist/dev/mp-weixin`**(它把这个项目当 CLI 型项目,**不是** `unpackage/`) |
### 两个必踩的环境坑
| 坑 | 现象与解法 |
|---|---|
| **微信开发者工具的「服务端口」默认关闭** | HBuilderX 运行时报 `IDE service port disabled` / `工具的服务端口已关闭`,然后 `[info] 退出` —— **调不起开发者工具**。解法:打开开发者工具 → **设置 → 安全设置 → 服务端口 → 开启**。或绕过:直接「导入项目」`dist/dev/mp-weixin` |
| **模拟扫码要手动配编译模式** | 默认进入时 `onLoad` 参数为空(页面会提示"请扫描二维码")。要模拟:工具栏「编译模式」→ 添加 → **进入场景=扫描二维码** → **启动参数 `scene=t1-k17`** |
另:`<style lang="scss">` 需要装 `sass`(模板里没带),已 `pnpm add -D sass`。
### 匿名接口 + 多租户拦截器:一个必踩的坑
`FillTenantLineHandler.getTenantId()` 在**匿名请求下直接抛 `40101`**,
所以落地页这种匿名接口**不能**直接用 `taskMapper.selectById()`。
正确写法(项目注释称此为一次**代码审查点**):建一个专用 Mapper,
每条查询 `@InterceptorIgnore(tenantLine = "true")` **并显式带 `tenant_id`**。
本项目的做法见 `module/mp/mapper/MpSceneMapper.java` ——
把这几条**集中在一个文件**里,正是为了让"关掉框架保护后由人保证隔离"这件事有地方审。
### scene 的三段式与限流
```
t{tenantId} 租户级(M0 开通租户时约定的形态)
t{tenantId}-k{taskId} 任务级
t{tenantId}-k{taskId}-o{orgId} 带班级
```
⚠️ **scene 是可穷举的**(`t1-k1`、`t1-k2`…),所以这个匿名接口:
① 只返回落地页必须的最少信息(**不要往里加"顺带能查出来"的东西**);
② 按 IP 限流(20 次/分钟,用 Redis INCR,复用项目的 `GATE_RATE_LIMITED` 40071)。
### 已知缺口
| 项 | 说明 |
|---|---|
| **填报主流程未做** | `/mp/login`(code+scene 换 openid/session)、门禁核验(`/mp/roster/match`)、表单填写 —— 属 **M3**。现在点「开始填报」给的是明确的"开发中"提示,不是静默无反应 |
| 落地页的"开始填报"按钮 | 同上,等 M3 接上 |
| 小程序正式发布需人工操作 | ① 服务器域名白名单加 API 域名;② 提交审核(类目/资质);③ 上传并设为体验版 |
| 小程序端无 lint/typecheck 配置 | 用的是官方模板自带的(只有 `type-check` 脚本)。项目既有规范(ESLint/Stylelint)还没套到 `frontend-mp/` |
---
## 一·十一、M3-01 门禁核验(2026-09-29 上午,未提交)
> 范围:`POST /api/v1/mp/roster/match`(《03-接口设计》§5.4)+
> `common/util/IdCardUtil`(校验位三态裁决)。**M3 的第一块。**
**验收**:`python scripts/e2e-m3-gate.py` → **29/29**;
九条 E2E 合计 **418 项断言**全通过。
### 四条守住的规则
| # | 规则 | 为什么 |
|:--:|---|---|
| 1 | **失败提示必须统一(`40007`),不区分"姓名错"还是"号码错"** | 分开提示等于交付了一个**身份证号枚举器**:用任意姓名 + 目标号码试,若提示"姓名不对"就说明号码在名单里,一个一个试就能把全校身份证号试出来。这条不是体验取舍,是安全底线。E2E 专门断言"两种失败的 `msg` 字符串完全一致" |
| 2 | **绑定并发安全**:`UPDATE ... WHERE bind_user_id IS NULL` + 看影响行数 | 不要改成"先 SELECT 判空再 UPDATE":中间有窗口,两人会同时通过检查,**后绑的人静默覆盖先绑的人**,而先绑的学生之后再也进不去。E2E 用两个线程同时冲同一行,断言"恰好一个成功、一个 40072" |
| 3 | **证件号明文只在 `match()` 里出现一次**,失败日志**只记 HMAC 与 uid** | 日志被翻出来同样是泄露。响应里永远只有 `idCardMask`,E2E 断言"整个响应 JSON 里不含明文" |
| 4 | **已绑定自己的人重复核验是幂等的** | 学生刷新页面、返回再进来,不该看到"该信息已被登记"这种吓人的提示 |
### `IdCardUtil` 的价值在**三态**,不在对错
真实数据集实测过:名册 28 人校验位全通过(可信基准);某两个学生的**文件名**证件号校验位失败(确定是号码错,**可自动改正**);另一个学生的两个号码**都通过**(是两个真人 → **串档实锤,必须人工介入**)。
后两种**看起来都是"号码对不上",处理策略完全相反** —— 一个能放心自动修,另一个自动修就是把两个真人的材料合并。区分它们的唯一办法就是校验位。所以对外暴露 `Verdict{VALID, CHECKSUM_FAILED, MALFORMED}` 而不是 `boolean`。
⚠️ **1900 年下界是业务判断,不是国标要求**:网上流传的"示例有效身份证号"里有一批出生年份在 1800 年代(如常被引用的 `440524188001010014` → 1880 年),**它们校验位合法**,但不可能是认定考生。只算校验位会把它们判成 VALID。这个坑在写 E2E 时真踩到了(两个线程都拿到 40007,因为测试数据用了那个号)。
### E2E 怎么拿 token(值得记下的手法)
门禁要求"已登录的学生",而登录(`/mp/login`)需要真实的 `wx.login` code —— 脚本拿不到。所以 `e2e-m3-gate.py` **用 dev 配置里的 JWT 密钥自己签一个 MP token**(HS512,claims 与 `JwtService.issue` 同构)。这与 e2e-m0 用 dev RSA 公钥加密登录密码是同一类做法:用开发配置里的凭据手搓测试夹具,**不是绕过认证逻辑**。prod 的密钥不在仓库里,这个手法自然失效。
顺带:读配置要读**两个**文件 —— `jwt.secret`/`hmac-key` 在 `application-dev.yml`,而 `issuer` 在主 `application.yml`。只读一个会漏掉 issuer,而症状是 401,完全指不到"配置没读到"。
### 回归抓到我一条脆断言
`e2e-m1-qrcode.py` 里"token TTL 是 7000 秒"这条,在连续第二次运行时失败了(实测 TTL=4633 ≈ 两次运行的时间差)—— 它假设 token 是刚取的,而实际上沿用了缓存。**已改成"先清 token 缓存强制重取,再断言 TTL∈[6900,7000]"**,现在连跑两次都稳定通过。
> 教训与前面几次同类:断言要**确定性地**反映被测行为,而不是碰巧在某种执行顺序下通过。
### 已知缺口
| 项 | 说明 |
|---|---|
| **`/mp/login` 未做** | 它需要真实的 `wx.login` code,**脚本测不了**,只能在小程序里测。下一轮连同小程序核验页一起做 |
| 形近字候选(FR-504c)未实现 | 姓名不一致时统一返回 40007。`needConfirmName` 恒为 false |
| 名册导入未做(M2) | E2E 用 SQL 直接造名册行 |
| 小程序端还没有核验页 | 落地页点「开始填报」目前是明确的"开发中"提示 |
---
## 一·十二、M3-02 H5 微信网页授权(2026-09-29,未提交)
> 方向决定:**H5 优先**(资质门槛低得多、且前端可自动化测试)。小程序端代码不浪费 ——
> uni-app 一套代码多端,`build:h5` 直接出 H5。
**验收**:`python scripts/e2e-m3-h5-auth.py` → **22/22**;
(受影响的四条回归全绿:落地页 31 / 门禁 29 / M0 账号 55 / 二维码 32)
### 关键事实:H5 必须用**公众号**的 appid
网页授权与小程序码是**两套凭据、两套接口**,不能混用。所以配置分成了两段:
`fill.mini-program`(小程序,生成小程序码用)与 `fill.mp-h5`(公众号,网页授权用)。
### 授权流程与三处设计
```
H5 页面 → GET /mp/h5/authorize?scene=X → 拿微信授权地址 → 跳微信
→ 用户点"允许" → 微信 302 回 /mp/h5/callback?code&state
→ 换 openid → 找/建学生 → 下发会话 → 302 回 H5
```
| # | 设计 | 为什么 |
|:--:|---|---|
| 1 | **token 放在 URL 的 fragment(`#`)里,不放 query** | fragment 不发给服务器 —— 不会进 Nginx access log、不会进 Referer、不会进浏览器历史。放 query 等于把 token 写进四五处日志 |
| 2 | **`scene` 不裸放在 `state` 里**,而是存 Redis 用一次性随机串指代 | state 会进 URL、浏览器历史与日志;放业务参数容易被人拿来构造请求 |
| 3 | **回调必须"永远重定向回应用"**,绝不抛业务错误 | 那是浏览器直接跳转的地址,抛错用户看到的是一屏 `{"code":40001,...}` JSON —— 一个学生完全无法理解、也不知道该怎么办的画面 |
### 打桩(为了自动化)与四道约束
微信授权那一步**必须真人在微信里点**,脚本永远做不了。所以要有打桩,而打桩是**危险动作**:
1. 配置开关 `fill.mp-h5.stub-enabled` 默认 **false**,只有 dev 打开;
2. 代码里**再判一次** profile 不含 prod(配置写错也进不来,双保险);
3. **接口路径独立**(`/mp/h5/stub-login`),不是"真实回调里加个 if" —— 那种写法一旦判断写反就是灾难;
4. 每次调用都 **WARN 日志**,不留静默通道。
### 本轮修掉的 2 个真问题
| # | 问题 | 说明 |
|:--:|---|---|
| 1 | **`RoleProvisioner.bindRole` 在匿名请求下不可用** | 它内部走 MP 的 BaseMapper,插入时租户拦截器拿不到 tenantId、直接抛「未登录」。症状是 **500 且角色没建上**(用户建了却没角色,后面每个接口都会以"无权限"拒绝他,排查毫无头绪)。已改为自己写:`@InterceptorIgnore` + 显式 tenant_id |
| 2 | **授权回调抛业务错误 → 用户看到一屏 JSON** | 见上表第 3 条。已改成 try/catch 后重定向回应用,把 `e.getMessage()` 作为 `authError` 带上(那句话本来就是写给人看的,如「code 无效或已使用过(code 只能用一次,且 5 分钟过期)」) |
### ⚠️ 一个上线前必须解决的问题:H5 与小程序是**两个 openid**
同一个人的小程序 openid 与公众号 openid **是不同的**(不同 appid 各自签发),
只有 `union_id` 一致 —— 而 union_id 需要两个账号都绑到**同一个微信开放平台账号**才有。
后果:学生先用 H5 填、后来又用小程序填,系统会当成**两个用户**,第二个渠道撞 `40072(已被登记)`。
一期只走 H5,所以现在不会碰到;**但两条腿都上线前必须解决** ——
办法是把两个账号绑到同一个开放平台账号拿 union_id,然后按 union_id 认人。
### 已知缺口
| 项 | 说明 |
|---|---|
| **H5 核验页未做** | 落地页点「开始填报」目前仍是"开发中"提示。下一轮:核验页(姓名+证件号→门禁)→ 认领会话 token |
| H5 桌面全宽铺开 | 该加 `max-width: 480px` 居中(主战场是手机,但自动化验证在桌面浏览器里跑) |
| 真实微信授权未走通 | 需要:公众号后台填「授权回调页面域名」+ 域名解析到能跑服务的机器(或内网穿透)。**打桩跳过的是微信那一步,后面全走真逻辑** |
| `/mp/login`(小程序登录)仍未做 | 与 H5 授权是同一件事的两个入口,可共用 `resolveUser` |
---
**关键实现决策(不要在后继开发中改回去)**
| 决策 | 理由 |
|---|---|
| **权限码定义在代码里**(`PermissionDef` / `BuiltinRole`),不建 `sys_permission` 表 | 权限码是前后端契约,放 DB 必然与代码漂移;一期无"权限配置界面"需求 |
| **租户拦截器默认拒绝**:匿名请求触及租户表直接抛 40101 | 漏加租户条件 = 越租户泄露全校身份证号,不可接受的失败模式;宁可让开发者立刻撞墙 |
| 匿名接口只靠 `@PublicApi` 放行,**不做路径白名单** | 两套机制会让"哪些接口公开"出现两个来源,改一处漏一处 |
| **登录时账号不存在也跑一次 BCrypt**(假哈希) | 否则响应时间差会泄露"账号是否存在",直接把密码喷洒成功率提高几个数量级 |
| 密码 RSA 加密传输 | 不是替代 HTTPS,而是防止网关/APM/链路追踪把明文密码记进日志 |
| 登出黑名单**存 token 的 SHA-256**,TTL 到自然过期 | token 本身是凭证,明文写进 Redis 等于复制了一份到别处 |
**四个必须实测的坑**(详见 `07-开发实施计划.md` §9,10 条实录)
Boot 4 / Flyway 10 / MP 3.5.9 这一批版本都在"把单体拆成模块",
导致**「依赖在、功能不在」的静默失败变多**。典型:只加 `flyway-core` 时迁移**不跑也不报错**。
**规则:每加一个中间件,必须实测它的核心动作真的发生了,不能只看依赖解析成功。**
---
## 一·五、客户回执(2026-09-28 · 需求已冻结)
> **客户全部 23 项按建议方案确认。** 唯一功能增量是 **B1:名单加入申请通道**。
| 组 | 需开发 | 说明 |
|---|:--:|---|
| A 认定机构 6 项 | **0** | 与现有设计完全一致(`min_long_side=1400`、`min_quality=70`、PNG 保留等都是当初按实测设计的) |
| B 校方业务 9 项 | **1** | **B1** 已落地:FR-509 系列 + `collect_join_request` 表 + 5 接口 + 2 屏原型 |
| C 商务法务 5 项 | 0 | C1 小程序资质推进中,H5 保底已列入 M0-13 预研 |
| D 内部技术 7 项 | 0 | **D4 已后置为二期**(一期不涉及零样本材料);**D7 已降级**(文件名识别准确率不影响任何交付项,残留只是上传页文案校准,班主任群里问两句即可)。**均已清除,无外部依赖** |
**规模变化**:FR 115 → **125** / 接口 117 → **121** / 库表 19 → **20** / VAL 20 → **21** / 错误码 50 → **57** / Golden 断言 29 → **36** / 功能验收 85 → **111** / 原型 29 → **31** 屏 / 任务 94 → **100** / 人日 380 → **392** → **387**(一期收窄 −5)/ 工期 14 → **15 周**。
**B1 的设计要点(三条边界,不要在后继开发中放水)**
| # | 边界 | 理由 |
|:--:|---|---|
| 1 | **只解决"进名单",不绕过门禁** | 申请期间仍不能填报;审批通过后才自动绑定放行 |
| 2 | **证件号与手机号仅存 HMAC + 脱敏值** | 与门禁口径一致 |
| 3 | **驳回必须逐条填原因;批量通过不提供"全选"** | 身份认定环节误操作代价极高 |
---
## 二、已锁定的铁律(不要重开讨论)
| # | 决策 | 关键理由 |
|---|---|---|
| 1 | **扫描件强制压到 ≤1M,无例外** | 认定机构硬要求 |
| 2 | 默认参数 **300dpi / q85** | 实测 28/28 达标,平均 806KB,清晰度零损失 |
| 3 | 材料来源只有 **聊天记录 / 相册 / 手机文件**,「直接拍照」默认关闭 | 实测 4 类材料无一来自现场拍照 |
| 4 | **必须先导名单**,姓名+身份证匹配不通过只提示、不放行(学校与社会收集皆然) | 门禁是产品根基 |
| 5 | 报考范围**从名单聚合**,不存在独立"计划清单"文件 | 真实名单验证 |
| 6 | 动态表单 = **内置字段(固定列,可统计)+ 扩展字段(extras_json,仅展示导出)** | 兼顾统计能力与灵活性 |
| 7 | 数据库 **PostgreSQL**(纯 PG);软删除字段名为 **`deleted`** | 客户已确认 |
| 8 | 班主任端**只做右滑通过**,驳回必须点按钮 | 误触后果不对称 |
| 9 | 批量通过**只带无问题项**,红黄一律排除 | 防"手快全选" |
| 10 | 有不合格文件时**直接禁用导出按钮**,不允许带问题打包 | 铁律 |
| 11 | 识别策略 **规则为主 + VLM 兜底**(触发率 2%~10%) | 成本可控 |
| 12 | 识别失败**一律放行**,不得阻断提交 | 可用性优先 |
| 13 | 原型**不用 emoji**(`→` `↔` `·` 等排版字符保留) | 设计约束 |
---
## 三、已完成交付物
| 文件 | 版本/大小 | 状态 |
|---|---|---|
| `docs/数据信息填报系统PRD.md` | V2.4 / 189KB / 3019 行 | ✅ 含 **125 条 FR**、**21 条 VAL**、**121 个接口**、**20 表 DDL**、实测附录 |
| `docs/数据信息填报系统PRD-功能逻辑卷.md` | V1.9 / 88KB | ✅ 纯功能逻辑,**不含库表/接口/代码** |
| `docs/db/schema-postgresql.sql` | 37KB | ✅ **PG 16.15 实测零错误**(20 表 / 177 列注释 / 65 索引 / 20 表注释) |
| `docs/prototype/student-app.html` | **11 屏** / 57KB | ✅ 含 STEP 03B「申请加入名单」 |
| `docs/prototype/teacher-app.html` | 9 屏 / 63KB | ✅ |
| `docs/prototype/admin-web.html` | **11 屏** / 114KB | ✅ 含表单设计器、材料规则配置、历史整理器、**申请审批** |
| `docs/prototype/index.html` | 7KB | ✅ 三端入口 |
| `docs/00-交付计划.md` | — | ✅ 7 项计划与进度记录 |
| `docs/01-客户与机构确认函.md` | 23KB | ✅ **P1** 23 项**已全部确认**(客户按建议执行,需求冻结) |
| `docs/02-需求追溯矩阵.md` | 10KB | ✅ **P2** 审计报告,含 10 个接口缺口 |
| `docs/03-接口设计.md` | **115KB / 2883 行** | ✅ **P3 121 个接口**字段级定义 + 枚举全集 + 12 个 VO + **58 个错误码**;§4 逐节声称数与表行数一致 |
| `docs/06-验收测试清单.md` | **33KB** | ✅ **P6 36 条 Golden 断言** + **111 条功能验收** + **27 条异常边界** |
| `../README.md` | **10KB** | ✅ **P7 文档导航**,按角色读法 + 快速上手 |
| `docs/04-核心模块设计说明.md` | **69KB / 1453 行** | ✅ **P4 六个模块**伪代码级说明 + 依赖图 + Golden 基线 |
| `docs/05-开发任务拆解.md` | **31KB / 506 行** | ✅ **P5 100 个任务 / 387 人日 / 15 周**,含关键路径、一期范围与核算口径 |
---
## 四、待办计划
| # | 任务 | 产出 | 状态 |
|---|---|---|---|
| ~~P1~~ | 客户与认定机构确认函 | `01-客户与机构确认函.md` | ✅ |
| ~~P2~~ | 需求追溯矩阵 | `02-需求追溯矩阵.md` | ✅ |
| ~~P2.5~~ | 管理端补 3 屏原型 | 并入 `admin-web.html` | ✅ |
| **P3** | **接口设计(OpenAPI)** | `03-接口设计.md` | ✅ **已完成**(117 个接口) |
| P4 | 核心模块设计说明(伪代码级) | `04-核心模块设计说明.md` | ✅ **已完成**(6 个模块) |
| P5 | 开发任务拆解 WBS(M0–M7) | `05-开发任务拆解.md` | ✅ **已完成**(94 任务 / 380 人日 / 14 周) |
| P6 | 验收测试清单(Golden Test Set) | `06-验收测试清单.md` | ✅ **已完成**(29 条 Golden 断言 + 85 条功能验收) |
| P7 | 文档导航 README | `../README.md` | ✅ **已完成** |
### P3 已补齐的缺口(21 个接口,原审计发现 10 项)
```
✅ I1 报考范围读取/确认 GET/PUT /api/v1/tasks/{id}/plan-scope
✅ I2 历史数据整理器 POST /api/v1/cleanup/jobs(共 5 个)
✅ I3 字典管理 /api/v1/dicts(4 个)
✅ I4 操作日志 GET /api/v1/logs/operations
✅ I5 登录日志 GET /api/v1/logs/logins
✅ I6 消息中心 /mp/messages、/mp/messages/read、/mp/teacher/messages
✅ I7 订阅额度登记 POST /api/v1/mp/subscribe
✅ I8 审核撤回 POST /api/v1/audit/submissions/{id}/revoke
✅ I9 任务暂停/恢复 POST /api/v1/tasks/{id}/pause、/resume
✅ I10 材料识别结果读取 已并入 GET /api/v1/audit/submissions/{id} 响应
➕ 文件规范化 process 原仅在 PRD §8.9 详述、未登记进清单,已补登
```
**接口总数:96(PRD 原有)+ 21 = 117**
---
## 五、关键事实速查
### 真实基准数据集(Golden Test Set)
`D:\项目\202609机电填报系统\24中技数控1班\` —— **28 人 × 4 类材料 = 112 文件**
| 实测项 | 结果 |
|---|---|
| 原始总量 | 475 MB |
| 命名违规 | 59 / 112(52.7%) |
| 双扩展名 | 56 |
| 体积超标(>1M) | 31 |
| 证件号异常 | 5 例(3 可自动、2 待人工) |
| 疑似串档 | 1(**唯一必须人工判断的**) |
| 压缩后 | 300dpi/q85 → 平均 806KB、最大 831KB、**0 张超标** |
| 材料识别 | 文件名层 112/112;形态层 99.1%;OCR 内容层 32/32 |
### 官方模板要点
- 21 列(A–U):**红=必填 11 项、绿=级联 6 项、黑=选填 4 项**
- **「报考职业」「报考工种」在 Excel 里没有下拉,是自由填写** ← 我方产品的核心增量
- 「职业技能等级个人申报表」的规范文件名却是 **「工作证明」**(机构既有习惯)
- **表头行与工作表名都不固定**(实测一份在第 1 行、一份在第 6 行)
### 三个易错实现陷阱
1. **「无」是合法业务值,不是空值**
2. **专项职业能力:无工种 / 无等级 / 无补考科目(0 科)**
3. 补考约束:三/四/五级最多 1 科,一/二级最多 2 科,专项 0 科
### 压缩五档(PRD §6.3)
A4 扫描件 300dpi(2480×3508)/q85 · 一寸照片固定 295×413 · 学籍表为 16:9 截图**保持 PNG 不缩放**
### 教师端数字口径(原型必须自洽)
28 人 = 已通过 15 + 待审 4 + 待核对 3 + 有问题 1 + 未提交 5;**完成率 82% 是已提交率(23/28)**
---
## 六、环境与工具
| 项 | 内容 |
|---|---|
| PG 验证容器 | `docker exec -it pg-verify psql -U postgres -d filldb`;清理 `docker rm -f pg-verify` |
| 原型校验脚本 | `python C:\Users\93292\AppData\Local\Temp\opencode\check2.py <html路径>` |
| DDL 同步方式 | 从主 PRD 的 ` ```sql ` 代码块自动抽取生成,改文档后重跑抽取脚本 |
| 原型约束 | 单文件自包含、零外部依赖、内联 SVG(`<symbol>`+`<use>`,1.7px 描边 currentColor)、无 emoji |
| 原型校验基线 | 四份全通过:标签配对 ✓ / CSS 变量 ✓ / SVG 无冗余 ✓ / emoji 0 ✓ |
---
## 七、重要教训
### 教训一:脚本审计前必须先验证脚本本身
P2 审计时脚本因未匹配加粗编号 ``| **FR-614** |``,误报 3 个"需求条目问题"(实为假警报);若直接采信并"修复",会引入 3 处新错误。同时 FR 总数由误算的 102 修正为 **115**。
> **⚠️ 这条教训在 2026-09-28 写开发实施计划时又复现了一次。**
> 我为交接摘要重算交付物体量,用了三个正则去数"接口数",分别得到 **74 / 95 / 121**——
> 三个都"看起来对",但没有一个经过验证。
>
> 定位过程:**逐节对比「标题声称数」与「实际表行数」**,发现
> ① 我漏了 `| ⭐ GET | ... |` 这种 ⭐ 在方法**前面**的行;
> ② §4.9 / §4.11 是**索引节**(交叉引用别处已计数的接口),本就不该计入。
> 修正后逐节声称数与表行数**全部为 0 差**,合计 **121**。
>
> **加固**:`scripts/verify-docs.py` 现在会做「**逐节声称数 vs 实际行数**」的一致性检查,
> 而不是只看总数对不对——总数相等也可能是个别节多算、个别节少算恰好抵消。
>
> **可复用的规则**:
> 1. 数字类断言必须**交叉核对至少两种独立方法**;
> 2. 核对要**分块**(逐节/逐表),不能只看总和;
> 3. 相互矛盾的中间值**一个都不要先写进文档**——写错再改,比一开始就空着更容易误导人。
### 教训二:写新文档必须回查已有编号体系,不能凭记忆
P4 写 M5 校验引擎时,我凭记忆用了一套 VAL 编号,**与 PRD 权威清单整体错位**:
| P4 原写法 | 我以为的含义 | PRD 实际定义 |
|---|---|---|
| VAL-01 | 姓名与名册一致 | **证件号格式与校验位** |
| VAL-03 | 证件号格式 | **姓名与名册一致** |
| VAL-06 | 双扩展名 | **文件格式白名单** |
| VAL-07 | 多扩展名冲突 | **文件体积上限** |
**后果**:若不修正,开发按 P4 实现会把规则完全接错,且两份文档互相矛盾。
**修正**:已全部重映射到 PRD 体系(单次扫描替换避免 01↔03 碰撞),并给 PRD 补了 P4 需要的三条新规则 **VAL-18 文件名规范化 / VAL-19 疑似串档 / VAL-20 体积下限保护**。
**规则**:新增文档引用任何已有编号(FR/VAL/错误码/接口)前,**先脚本抽取权威清单核对**,不凭记忆。
### 教训三:系统里的数据不会错,错的一定是「归属」
客户指出:**"系统应该不存在名称错误的情况。是先录入了有这个姓名和身份证,然后才能填写的。"**
这指出一个真实的设计缺陷:
| 位置 | 会不会错 | 说明 |
|---|---|---|
| **填报单里的姓名/证件号** | **永远不会错** | 门禁已核验 + 字段锁定。客户说得对 |
| 文件名里的姓名/证件号 | 可能不同 | **但这不是"名称错误",而是"这份文件可能不属于这个人"** |
**原设计的错误**:把"文件名姓证件号与名册不符"处理为「自动按名册修正」(信息级),
这会把"A 传了 B 的材料"**静默洗白**成 A 的材料——审核时完全看不出来,事后无从追溯。
```
学生 A 在微信里误选了同学 B 的材料
↓ 文件名:李四-3610...(B 的号码,校验位通过)
错误做法:判"与名册不符" → 改成 张三-3610... → 信息级,A 看不到
↓ 审核:班主任看到一份名字正确的材料 → 通过
↓ 最终:B 的工作证明被认定成 A 的 ← 灾难
```
**修正(客户已确认)**:
1. **判定优先级重排**为 `证件号反查名册 > OCR 内容 > 姓名(仅辅助)`
2. 新增 **VAL-21 文件归属校验**:归属存疑一律**阻断提交**,且**不提供"仍然继续"**
3. **姓名不再作为独立规则**——只在证件号佐证时按名册取值,仅留痕不打扰学生
4. 核心判据:**文件名证件号能否在名册里反查到"另一个人"**(一次查询,走唯一索引,< 1ms)
**精髓在于两条的区分**:
- **校验位失败** = 不是一个真实号码 → 只是写错了 → **可以自动改**
- **校验位通过** = 一个真实存在的自然人 → 改了就是改了身份 → **必须阻断**
### 教训五:联系方式不进系统
客户指出:**"学生本来就有班主任联系方式,不需要系统里面体现。"**
原设计在门禁失败页放了一个「联系老师」按钮(脱敏手机号 + 一键拨打),并为此专门开了一个接口 `GET /mp/roster/contact`。
已移除:
| 移除项 | 原因 |
|---|---|
| `GET /mp/roster/contact` 接口 | 不必要(用户已知道怎么找人)+ 有维护成本(换人/换号要同步)+ 多一处隐私暴露面 |
| 门禁失败页「联系老师」按钮 | 同上。改为只保留两条**系统内动作**:重新填写 / 申请加入名单 |
| 功能逻辑卷中的联系方式展示描述 | 同步 |
**保留**:纯文案提示(如"如有疑问请联系老师")——**不涉及任何联系方式数据**。
**接口总数**:122 → **121**。
### 教训四:「社会评价」与「社会收集」只差一个字
`cert_type`(认定类型:自主认定 / **社会评价** / 专项职业能力)决定**填什么材料**;
`collect_mode`(收集模式:学校收集 / **社会收集**)决定**谁审核**。二者可任意组合,已在 PRD §5.3 与接口 §2.3 分别写明。
另外补全了 `cert_type` 的第三个取值(专项职业能力)在 DDL 注释与附录中的遗漏。
### 附带修正
- **体积口径**:原型导出中心曾写"475MB → 22.4MB(95%)",与 PRD 及历史整理器屏(37MB/92%)矛盾。实为混淆——**444→22MB 是"工作证明"单项,475→37MB 是全班 112 文件合计**。已统一为 37MB / 92%。
---
## 八、外部依赖(未回)
`01-客户与机构确认函.md` 已发出待回,其中**最高优先级 3 项**:
- **A1** 认定机构是否书面接受 300dpi 扫描件
- **B1** 名单外的人怎么处理(建议加"申请加入名单→管理员审批"通道)
- **B3** 是否允许学生修改老师预填字段(建议:姓名/证件号/报考三项锁定,其余可改留痕)
未回复则按文档内建议方案推进。
---
## §一·十三 材料上传 + 提交校验(最新一轮,提交 `9911ff0`)
### 已完成的提交
| 提交 | 内容 |
|---|---|
| `80a4050` | 材料上传与规范化(压缩到 1M 内 + 按规则改名) |
| `8ef0973` | 材料上传前端界面 + 列出/删除接口 |
| `9911ff0` | 提交 + 红黄绿校验(VAL 系列)+ 静态资源缓存头 |
### 学生端主流程已打通
```
扫码 → 微信授权 → 落地页 → 核验身份 → 填报页(三态+级联)
→ 存草稿 → 传材料(自动压缩改名)→ 提交(红黄绿校验)
```
### 实测结论(可直接引用的数字)
- **压缩**:11.6MB 的 2400×3200 原图 → **712KB**,1020×1360(P2 档长边限 1600),q70
- **改名**:`身份证正面.jpg` → `张同学-361029XXXXXX0015-身份证.jpg`
- **校验**:`e2e-m3-submit.py` **30/30**
### ⚠️ 本轮五个坑(都已写进代码注释,不要再犯)
1. **校验器不能用列名当字段 key**。字段 key 是 **snake_case**
(`work_unit`/`job_level`/`enroll_date`),权威来源是 `BuiltinFields`。
且**报考三项存在 `ext_json`,不是独立列**(契约 §2.4)。
症状:"明明填了却说没填"。
**更严重的是**:同一条数据被两条规则用两种方式读 → 两条规则互相矛盾
(VAL-16 说缺、VAL-22 说不缺)。**统一用 `FillService.getForm` 装配值做唯一口径。**
2. **`collect_roster.id_card_no` 是 AES 密文**(列注释写明)。
`id_card_hash` 是 HMAC、`id_card_mask` 是脱敏展示。
明文只在**导入**与**门禁**时短暂存在 → 提交时**做不到**校验位校验。
曾拿它当明文算校验位 → **100% 阻断**(这种"规则很对但读错列"的错,
反而会被当成"校验太严"而不是 bug)。
3. **`VAL-22` 必须判可见性**,求值口径与前端 `fill.vue` 的 `visible()` **逐字一致**
(`norm` 归一 `true/false`↔`1/0`;未知 `op` 一律**可见**=fail-open)。
不判可见会要求在校生填「补考理论」—— 而那个字段在他页面上根本不存在。
4. **静态资源必须有 `Cache-Control`**(新增 `StaticCacheHeaderFilter`)。
没有它浏览器走**启发式缓存**:旧 `index.html` → 旧 chunk →
**学生发版后看到的是上个月的页面**,而开发机硬刷新一次就没了,
极易被当成"偶发"。现在:`/assets/**` 一年 `immutable`(文件名带内容哈希)、
`*.html` `no-cache`、`/api/**` `no-store`。
> 上一轮"浏览器加载旧 chunk"的悬案就是它 —— 已用
> 「从 8080 下载 chunk 与磁盘文件比 SHA-256」证实不是构建问题。
5. **`edit` 工具的 `oldString` 是子串匹配**。上一轮我把 `function doSubmit() {`
当锚点,而原文是 **`async function doSubmit() {`** —— 于是 `async` 被留在
注释前面,变成 `async // ── …`,编译**通过**、构建**通过**,
但运行时 `ReferenceError: async is not defined` → **整页空白**。
**教训:构建成功完全不能代替"跑一遍"。** 锚点要带足够上下文。
### 环境/数据注意事项
- 演示名册(task 17)的 `id_card_no` 是 `placeholder`(某次手工 SQL 留下的,
代码里搜不到这个串)。**这是正常的开发态**:门禁走 `id_card_hash`、
填报页走 `id_card_mask`,都不需要明文。**不要去"修"它。**
- `collect_form_schema` **是空的** → 演示任务走 `BuiltinFields` 的官方 21 项模板。
- 字段 key 权威列表见 `BuiltinFields.java`(21 项,全 snake_case)。
### 契约层面的两处有意偏差(必须让客户知道)
| 项 | 偏差 | 原因 |
|---|---|---|
| 上传 | 契约是 `POST /mp/files/upload-token`(OSS 直传凭证),实际**后端代理接收**(multipart) | 后端还没接对象存储。硬做一个"返回假凭证"的接口只会让人以为能直传。接入后可并存:小文件走代理、大文件走直传 |
| 文件存储 | **本地磁盘**(`fill.upload.dir`) | 多实例部署时各存各的会丢文件 → **接入对象存储前不能上多副本** |
### 新增规则(需客户确认)
`VAL-22 = 表单必填字段缺失`。PRD 的 VAL-01~21 是**具体业务规则**,
没有覆盖「动态表单的必填字段」(因为表单字段是配置出来的)。
按系列顺延补的 —— **这是新增,不是 PRD 原有条目**,需要登记。
### ✅ 浏览器验证已完成(同日补做)
`fill.vue` 的问题清单浮层在**真浏览器**里跑通了两条分支(穿透慢,单次要 30 秒左右,耐心等):
| 分支 | 触发条件 | 实测表现 |
|---|---|---|
| **红色**(阻断) | 缺身份证材料 + 必填未填 | 标题「**还不能提交**」、`[必须处理 3]`、三张红卡(`--s3`,左边框 `#E34D59`/底 `#FDF3F4`):`VAL-16` / `VAL-22` / `VAL-05`;按钮「**知道了,去修改**」→ 点击后浮层关闭 |
| **黄色**(可强提) | 只差一个 `VAL-09` 重复文件 | 标题「**提交前请确认**」、`[提醒 1]`、一张橙卡(`--s2`,左边框 `#ED7B2F`);按钮「**仍然提交**」→ 点后弹出「提交成功 / 已提交,等待班主任初审」 |
**提交成功后的状态切换也验证了**:
- 上传按钮与「删除」**全部消失**(`canEdit=false`,已提交不再可改)
- 提交按钮被替换为只读文案「已提交,等待班主任初审」
- 材料列表显示规范名 + 压缩后体积:`张同学-361029XXXXXX0015-身份证.jpg 17KB · 900×1200`
- 库:`status=1 red=0 yellow=1 submit_time` 已写;日志 `提交未通过 ×2 → 提交成功`
**顺带验证**:`loadFiles()` 正常(重进页面能看到上次传的材料与体积、并能删)。
### 演示态的现状(下一个会话请注意)
- **`collect_submission.id = 21` 现在是「已提交」状态**(上面这次验证留下的)。
它是个很好的演示态,但**要重跑填报流程必须先删掉**:
```sql
DELETE FROM collect_file WHERE submission_id = 21;
DELETE FROM collect_submission WHERE id = 21;
```
- 名册(task 17,id=38)绑定 `sys_user.id=151`(学生微信账号)——**保持不动**。
### 新增的开发工具
`scripts/dev-seed-warn-state.py`:把某份草稿快速摆到"只剩黄色警告"的状态
(填必填 + 传两张同内容的图 → `VAL-09`)。
**用途**:验证浮层/审核端的"只有警告"分支时,不必在页面上手填 7 个字段再等穿透。
它**不**替代 E2E —— 逻辑仍由 `e2e-m3-submit.py` 断言,这个脚本只负责摆状态。
### 下一步
1. **提交页在浏览器里的肉眼验证还没做**(问题清单浮层的渲染与交互)。
代码与产物都对,但**未经浏览器验证** —— 不要当成已验证。
2. `M1-05` 规则模板、`M1-07~M1-10` 前端(表单设计器、材料规则界面、任务向导)
3. 材料与表单的条件联动(`visible_when` 后端已存,材料侧前端还没求值)
4. 接入对象存储(**阻塞多副本部署**)
5. 待办(非阻塞):C1 小程序资质法务确认(**已明确会阻塞学生真正开始填报**)、
C5 隐私声明法务文案、D1 OCR 引擎选型、D5 一寸照片阈值标定、D6 微信三通道体积上限真机实测