feat(form): M1-03 表单 schema 版本化与冲突检测

学生端看到的表单终于可以按任务配置了。按计划表这一项是后端
(4 人日),表单设计器界面属 M1-07。

交付:
· GET/PUT /tasks/{id}/form-schema(§7.9 / §3.5)
· BuiltinFields:官方《考生线下报名信息导入模板》的 21 个内置字段(A~U 列)
  —— 选项集逐字照抄,级联规则照官方原文

四条守住的规则:
1) 版本只增不改:每次保存插一行新版本,旧的保留(is_current=0)。
   学生是按某一版填的,事后必须能回答"他当时看到的是什么"。表上没有 deleted
   列也是这个道理 —— 表单版本不需要软删,它本来就不该删。
   顺带:collect_form_schema 没有 deleted 列,实体刻意不继承基类
   (collect_task_org 踩过同一个坑)。
2) baseVersion 是乐观锁,不符则 40025 并把最新定义一起回传。
   设计器是"打开 → 改很多处 → 保存",中间可能好几分钟;直接覆盖会静默丢掉
   别人刚加的字段,而且丢失发生在"另一个人"那里。只说"版本冲突"而不给最新内容,
   等于把合并推给一个信息不全的调用方 —— 所以 40025 的 data 里带着最新 schema。
3) 内置字段不可删(40026)的判断依据是「内置目录」而不是「上一版定义」:
   拿上一版当依据,客户端只要让上一版先缺一个就绕过去了。E2E 也验了
   把 builtin 传成 false 无法绕过 —— builtin 与 source 一律以内置目录为准。
4) 扩展字段键必须带 extra_ 前缀、不可与内置重名:扩展字段的值落 extras_json,
   与内置列同处一份 form_data,一个叫 education 的扩展字段会让"这个值到底是
   内置的还是扩展的"永远说不清。

契约之外必须补的一个字段 source:
   §3.5 说"内置字段与 collect_submission 列名一致",但「姓名」「身份证号」
   「联系电话」「序号」根本不在 submission 里,在名册里(collect_roster)。
   不标来源,填报引擎就得靠硬编码名单去猜"这个 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」)。
· 工作单位保留官方原文提示「没有工作单位请填「无」」。
  ⚠️「无」是合法业务值,不是空值,校验时不能把它当成"没填"拦掉。
· 锁定项(姓名/身份证号/报考职业/工种/等级)与 PRD 一致,另把「序号」一并锁定
  (来自名册,让学生编辑"自己是第几号"没有意义)。

顺带:V6 更正 collect_form_schema.schema_json 的列注释(V1 写的是"字段定义数组",
实际存 {"fields":[...],"extras":[...]})。合成一个文档而不是拆两列,是因为
一版表单就是一份快照 —— 拆开就存在"只写成功一半"的可能,而"学生看到的那版"
必须是原子的。

本轮踩的坑(记进交接摘要):
· 我的 E2E 又写错一条:测「已截止不可改表单」时先调 close,但**草稿是不能直接
  截止的**(close 只允许 收集中/已暂停)。改成先用 SQL 夹具把发布前置备齐 →
  发布 → 再截止。这反而验证了一件对的事:没配齐就发布,学生填了也没人审,
  所以 close 把草稿挡住是合理的。
· 写 Java 时把 Map 声明成了 String,编译器直接拦住,没有代价。

已知缺口:
· 表单设计器界面未做(M1-07)。
· 报考职业/工种/等级的 options 为空:它们来自"评价计划"(由名册聚合,M2),
  不是固定选项。属预期,等 M2 接上计划级联。
· 库里没有字典表,所以官方选项内联在 options 里,dictCode 暂未启用。
· 条件显示的求值引擎属 M3-10,本轮只做存取与形状校验。

验收:e2e-m1-form.py 48/48;六条 E2E 合计 326 项断言全通过;Flyway 迁移 1~6 success。
This commit is contained in:
2026-09-29 09:47:09 +08:00
parent d32969d766
commit 870c388287
11 changed files with 1369 additions and 1 deletions
+58 -1
View File
@@ -50,8 +50,9 @@
| **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** |
| PC 前端 | ✅ 已换 Gi Admin Pro + 腾讯云主题;已实现:登录 / 工作台 / 组织架构 / 租户管理 / 账号管理 / **收集任务(含配置班级)** |
| 待做 | M1 剩余(M1-03 表单 schema、M1-05 规则模板、M1-06 二维码、**M1-07~M1-10 前端**——材料规则的界面按计划属 **M1-09**);小程序(uni-app)未创建;CI/CD;H5 保底预研 |
| 待做 | M1 剩余(M1-05 规则模板、M1-06 二维码、**M1-07~M1-10 前端**——表单设计器 M1-07、材料规则界面 M1-09);小程序(uni-app)未创建;CI/CD;H5 保底预研 |
---
@@ -266,6 +267,62 @@
---
## 一·八、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 |
---
**关键实现决策(不要在后继开发中改回去)**
| 决策 | 理由 |