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
@@ -0,0 +1,54 @@
package cn.tianbaotong.fill.module.form.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.module.form.dto.FormSchemaSaveReq;
import cn.tianbaotong.fill.module.form.dto.FormSchemaVO;
import cn.tianbaotong.fill.module.form.service.FormSchemaService;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.security.RequirePerm;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 任务表单定义(《03-接口设计》§7.9)。
*
* <p>只有两个动作:读当前定义、保存为新版本。没有删除接口是刻意的 ——
* 表单版本是只增不改的历史快照,删掉它就无法回答
* "某个学生当时看到的是哪一版表单"。
*/
@Tag(name = "表单定义")
@RestController
@RequestMapping("/api/v1/tasks/{id}/form-schema")
@RequiredArgsConstructor
public class FormSchemaController {
private final FormSchemaService formSchemaService;
@GetMapping
@RequirePerm(PermissionDef.TASK_VIEW)
@Operation(summary = "读取任务表单定义",
description = "从未保存过时返回内置字段目录的默认定义(version=0),"
+ "让设计器从一份完整的官方表单开始,而不是从空白开始")
public ApiResult<FormSchemaVO> get(@PathVariable Long id) {
return ApiResult.ok(formSchemaService.get(id));
}
@PutMapping
@RequirePerm(PermissionDef.TASK_MANAGE)
@Operation(summary = "保存任务表单定义(版本化)",
description = "每次保存插入一个新版本,旧版本保留。baseVersion 与当前最新不符时返回 40025,"
+ "并在 data 里带上最新定义供前端做差异合并(响应格式:{code:40025, data:最新schema})。"
+ "缺少任一内置字段返回 40026(内置字段不可删,不想收就用条件显示隐藏)")
public ApiResult<FormSchemaVO> save(@PathVariable Long id,
@Valid @RequestBody FormSchemaSaveReq req) {
return ApiResult.ok(formSchemaService.save(id, req));
}
}
@@ -0,0 +1,79 @@
package cn.tianbaotong.fill.module.form.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 表单里的一个字段(《03-接口设计》§3.5)。
*
* <p>字段键({@code key})与 {@code collect_submission} 的列名一致 ——
* 这样"可统计"这件事不用额外机制:字段值就是表里的列,能直接 GROUP BY。
* 扩展字段没有列,值落在 {@code collect_submission.extras_json},所以
* <b>不参与统计</b>(这是架构约束,不是取舍)。
*
* <p>⚠️ <b>{@code source} 字段是契约之外的补充</b>:§3.5 说"内置字段与
* {@code collect_submission} 列名一致",但「姓名」「身份证号」「联系电话」「序号」
* 这四项<b>不在 submission 里,在名册里</b>({@code collect_roster})。
* 不标明来源,填报引擎就得靠一张硬编码名单去猜"这个 key 去哪取值",
* 而猜错的表现是"学生端这两栏永远是空的"——一个不会报错、只会让人困惑的失败。
*/
@Getter
@Setter
@Schema(description = "表单字段")
public class FormFieldVO {
/**
* 字段键。内置字段与列名一致,如 {@code education} / {@code is_retake};
* 扩展字段约定以 {@code extra_} 开头。
*/
private String key;
private String label;
/** {@code text} / {@code number} / {@code select} / {@code cascade} / {@code date} / {@code idcard} / {@code phone} / {@code bool}。 */
private String type;
/** 是否内置字段。<b>内置不可删除</b>({@code 40026})。 */
private Boolean builtin;
/**
* 是否锁定(学生不可改)。
*
* <p>「姓名 / 身份证号 / 报考职业 / 报考工种 / 报考等级」是锁定的 ——
* 这几项由老师根据机构要求与报考策略定,学生不懂也不该改。
* 让它们出现在学生端可编辑,只会制造错误(而且是最难挽回的那类错误:
* 报错了工种,机构那边直接不受理)。
*/
private Boolean locked;
private Boolean required;
/** 选项来源字典码。当前没有字典表,内置选项走 {@code options}。 */
private String dictCode;
/** 手填选项。 */
private List<String> options = new ArrayList<>();
/** 校验规则:{@code {regex, min, max, message}}。 */
private Map<String, Object> validate;
/** 条件显示规则:{@code {field, op, value}}。 */
private Map<String, Object> visibleWhen;
private Integer sortNo;
/** 提示语,如「老师已填,请核对」。 */
private String tip;
/**
* 取值来源:{@code SUBMISSION}(落 {@code collect_submission} 的列)或
* {@code ROSTER}(来自名册,如姓名/身份证号/联系电话/序号)。
* 见类注释:契约没写,但不写就得靠硬编码名单猜。
*/
private String source;
}
@@ -0,0 +1,46 @@
package cn.tianbaotong.fill.module.form.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
/**
* 保存任务表单定义(《03-接口设计》§7.9 的 PUT 请求)。
*/
@Getter
@Setter
@Schema(description = "保存表单定义")
public class FormSchemaSaveReq {
/**
* 编辑开始时的版本号,用于乐观锁。
*
* <p>与当前版本不符就拒绝({@code 40025})并回传最新定义,由前端做差异合并。
*
* <p><b>为什么必须有这个字段</b>:表单设计器是一个"打开 → 改很多处 → 保存"的过程,
* 中间可能好几分钟。这期间别人(或另一个标签页)保存过一次,那么后保存的人
* 是基于旧定义改的 —— 直接覆盖会<b>静默丢掉对方刚加的字段</b>,
* 而且丢失发生在"另一个人"那里,当事人完全不知道。
* 加上 {@code baseVersion} 之后,这种冲突变成一次明确的提示。
*/
@NotNull(message = "缺少 baseVersion(请带上打开时的版本号)")
@Schema(description = "编辑开始时的版本号", example = "2",
requiredMode = Schema.RequiredMode.REQUIRED)
private Integer baseVersion;
/** 内置字段。删除任一 {@code builtin=true} 的字段会被拒({@code 40026})。 */
@NotNull(message = "缺少 fields")
@Valid
@Schema(description = "内置字段列表", requiredMode = Schema.RequiredMode.REQUIRED)
private List<FormFieldVO> fields = new ArrayList<>();
/** 扩展字段。值落 {@code extras_json},不参与统计。 */
@Valid
@Schema(description = "扩展字段列表(不参与统计)")
private List<FormFieldVO> extras = new ArrayList<>();
}
@@ -0,0 +1,50 @@
package cn.tianbaotong.fill.module.form.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 任务表单定义(《03-接口设计》§3.5)。
*
* <p>{@code fields} 与 {@code extras} 分开是有意义的:
* <ul>
* <li>{@code fields}:内置字段,键与 {@code collect_submission} 的列名一致
* → <b>可查询、可索引、可统计、可校验</b></li>
* <li>{@code extras}:扩展字段,值落 {@code extras_json} → <b>仅展示与导出,不参与统计</b></li>
* </ul>
* 界面上必须把这条差异明示出来("扩展字段不参与核心统计"),
* 否则校长会发现"我的报表里怎么没有这个字段",而那时已经填了几百份了。
*/
@Getter
@Setter
@Schema(description = "任务表单定义")
public class FormSchemaVO {
/** 版本号。已发布任务每次改动 +1(§3.5)。 */
private Integer version;
/** 是否当前生效版本。 */
private Boolean isCurrent;
private List<FormFieldVO> fields = new ArrayList<>();
private List<FormFieldVO> extras = new ArrayList<>();
private LocalDateTime createTime;
/**
* 当前最新版本号。
*
* <p>编辑时要用它当 {@code baseVersion}:前端打开表单设计器时记下它,
* 保存时带上;若期间被别人改过,服务端会以 {@code 40025} 拒绝并回传最新定义,
* 由前端做差异合并。<b>没有这个值,两个人同时改就必然有人白改一遍。</b>
*/
private Integer latestVersion;
}
@@ -0,0 +1,62 @@
package cn.tianbaotong.fill.module.form.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 表单定义(对应 {@code collect_form_schema}),按版本存储。
*
* <p>⚠️ <b>本实体刻意不继承 {@code TenantEntity} / {@code BaseEntity}</b>:
* 这张表<b>没有 {@code deleted} 列</b>,也没有 {@code update_by}/{@code update_time}。
* 基类上的 {@code @TableLogic} 会让 MyBatis-Plus 给每条查询自动加上 {@code deleted = 0},
* 直接报"列不存在"({@code collect_task_org} 踩过同一个坑)。
*
* <p><b>没有 {@code deleted} 是有道理的</b>:表单版本是<b>只增不改</b>的历史快照 ——
* 已发布任务的每一版都要留着(学生是按某一版填的,事后要能回答"他当时看到的是什么")。
* 所以这里不需要软删,只有"新增版本"与"切换 is_current"。
*
* <p><b>{@code schema_json} 存的是一个对象</b>,形如
* {@code {"fields":[...],"extras":[...]}},不是裸的字段数组
* (DDL 注释写的是"字段定义数组",与实现不一致,已由 V6 更正)。
* 为什么合成一个文档:一版表单就是<b>一份快照</b>。fields 与 extras 分两列存,
* 就存在"一半写成功"的可能,而"学生看到的表单"必须是原子的那一版。
*/
@Getter
@Setter
@TableName("collect_form_schema")
public class CollectFormSchema implements Serializable {
@TableId(type = IdType.AUTO)
private Long id;
@TableField(value = "tenant_id", fill = FieldFill.INSERT)
private Long tenantId;
private Long taskId;
/** 版本号。已发布任务每次改动 +1(§3.5)。 */
private Integer version;
/** 表单定义快照,JSON 原文:{@code {"fields":[...],"extras":[...]}}。 */
private String schemaJson;
/** {@code 1} 当前生效 / {@code 0} 历史版本。同一任务下最多一条为 1。 */
private Integer isCurrent;
@TableField(value = "create_by", fill = FieldFill.INSERT)
private Long createBy;
@TableField(value = "create_time", fill = FieldFill.INSERT)
private LocalDateTime createTime;
public static final int CURRENT = 1;
public static final int HISTORY = 0;
}
@@ -0,0 +1,13 @@
package cn.tianbaotong.fill.module.form.mapper;
import cn.tianbaotong.fill.module.form.entity.CollectFormSchema;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/**
* 表单定义({@code collect_form_schema})。
*
* <p>M1-03 只需要基础 CRUD:取当前版本、查历史、插入新版本、切换 is_current。
* 都在 Service 里用条件构造器完成,不需要自定义 SQL。
*/
public interface FormSchemaMapper extends BaseMapper<CollectFormSchema> {
}
@@ -0,0 +1,242 @@
package cn.tianbaotong.fill.module.form.service;
import cn.tianbaotong.fill.module.form.dto.FormFieldVO;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 内置字段目录:官方《考生线下报名信息导入模板》的 <b>21 个字段</b>(A~U 列)。
*
* <p>这份清单是本产品的<b>地基</b>,不是可有可无的默认值:官方模板 21 项里,
* 「报考职业 / 报考工种 / 报考等级」在 Excel 里是自由填写的文本 ——
* 这是现场最容易出错的地方(写错工种,机构直接不受理,而学生到那时才知道)。
* 我们把它做成从计划级联的下拉,<b>从源头不可能填错</b>。
*
* <p>三条口径必须与 PRD 逐字对齐,改之前先读 PRD 的字段全集表:
* <ol>
* <li><b>锁定项({@code locked})</b>:姓名、身份证号、报考职业、报考工种、报考等级。
* 这几项由老师按机构要求与报考策略定,学生不懂也不该改。序号也来自名册,
* 一并锁定 —— 让学生去编辑"自己是第几号"没有任何意义。</li>
* <li><b>选项({@code options})照抄不许擅改</b>:官方说明写着
* "下拉选项无需手填……若手动输入则只能填写下拉选项中包含的值"。
* 我们多一个少一个都算改了机构的规则。</li>
* <li><b>{@code source} 区分取值来源</b>:{@code ROSTER} 的字段值来自名册
* ({@code collect_roster}),{@code SUBMISSION} 的落
* {@code collect_submission} 的列。注意 {@code source} 与 {@code locked}
* <b>互相独立</b>:「联系电话」来自名册但学生可以改(预填可改),
* 「报考职业」来自计划且锁定。</li>
* </ol>
*/
public final class BuiltinFields {
private BuiltinFields() {
}
public static final String SOURCE_ROSTER = "ROSTER";
public static final String SOURCE_SUBMISSION = "SUBMISSION";
// ── 官方下拉选项(PRD「官方下拉选项全集」,照抄不得擅改)──
/** 学历(11 项)。 */
private static final List<String> EDU_LEVEL = List.of(
"博士研究生", "硕士研究生", "大学本科", "大学专科", "中等专科",
"职业高中", "技工学校", "普通中学", "初级中学", "小学", "其他");
/** 报考等级 / 已获证书等级(5 项)。 */
private static final List<String> JOB_LEVEL = List.of(
"五级/初级工", "四级/中级工", "三级/高级工", "二级/技师", "一级/高级技师");
/** 补考理论(4 项)。 */
private static final List<String> RETAKE_THEORY = List.of(
"理论-纸笔作答", "理论-机考", "理论-直接认定", "纸笔作答");
/** 补考实操(6 项)。 */
private static final List<String> RETAKE_PRACTICE = List.of(
"实操-现场操作", "实操-模拟操作", "实操-纸笔作答", "实操-机考", "实操-直接认定", "技能操作");
/** 补考综合(2 项)。 */
private static final List<String> RETAKE_OVERALL = List.of(
"综合评审", "综合评审-直接认定");
/**
* 21 个内置字段,顺序即 A~U 列的顺序({@code sortNo} 从 10 起,留出插入空间)。
*/
private static final List<FormFieldVO> ALL = build();
private static List<FormFieldVO> build() {
List<FormFieldVO> list = new ArrayList<>();
int sort = 0;
// A 序号:来自名册
list.add(roster("seq_no", "序号", "number", true, true, ++sort * 10));
// B 姓名 / C 身份证号:来自名册,锁定(学生不能改自己的名字和证件号)
list.add(roster("real_name", "姓名", "text", true, true, ++sort * 10));
list.add(roster("id_card_no", "身份证号", "idcard", true, true, ++sort * 10));
// D 学历:预填可改
list.add(submission("education", "学历", "select", true, false, ++sort * 10,
opts(EDU_LEVEL), null, null));
// E 联系电话:来自名册(预填),但学生可以改 —— 名册里的号码常是家长的老号码
final FormFieldVO phone = roster("phone", "联系电话", "phone", true, false, ++sort * 10);
phone.setType("phone");
list.add(phone);
// F 工作单位:官方原文「没有则填"无"」。
// ⚠️ 「无」是合法业务值,不是空值 —— 校验时不能把"无"当成"没填"给拦掉
list.add(submission("work_unit", "工作单位", "text", true, false, ++sort * 10,
null, null, "没有工作单位请填「无」"));
// G/H/I 报考职业、工种、等级:来自评价计划,锁定。
// options 留空 —— 它们由计划级联(M2 之后接),不是固定选项
list.add(submission("occupation", "报考职业", "select", true, true, ++sort * 10,
null, null, "由老师按报考计划确定,不可修改"));
list.add(submission("worktype", "报考工种", "cascade", true, true, ++sort * 10,
null, null, "只能填该职业包含的工种;无工种填「无」"));
list.add(submission("job_level", "报考等级", "select", true, true, ++sort * 10,
opts(JOB_LEVEL), null, null));
// J 是否补考
list.add(submission("is_retake", "是否补考", "bool", true, false, ++sort * 10,
null, null, null));
// K/L/M 补考三科:仅"是否补考=是"时出现(官方规则 1/2)
final Map<String, Object> whenRetake = visible("is_retake", true);
list.add(submission("retake_theory", "补考理论", "select", true, false, ++sort * 10,
opts(RETAKE_THEORY), whenRetake, null));
list.add(submission("retake_practice", "补考实操", "select", true, false, ++sort * 10,
opts(RETAKE_PRACTICE), whenRetake, null));
list.add(submission("retake_overall", "补考综合", "select", true, false, ++sort * 10,
opts(RETAKE_OVERALL), whenRetake, null));
// N 是否在校生
list.add(submission("is_student", "是否在校生", "bool", true, false, ++sort * 10,
null, null, null));
// O/P/Q 在校生三项:仅"是否在校生=是"时出现且必填(官方规则 3/4)。
// 注意 Q 的格式要求:官方原文「格式必须 YYYY-MM-DD」,所以给正则
final Map<String, Object> whenStudent = visible("is_student", true);
list.add(submission("school", "在读学校", "text", true, false, ++sort * 10,
null, whenStudent, null));
list.add(submission("major", "所学专业", "text", true, false, ++sort * 10,
null, whenStudent, null));
final FormFieldVO enroll = submission("enroll_date", "入学时间", "date", true, false,
++sort * 10, null, whenStudent, "格式:YYYY-MM-DD");
enroll.setValidate(regex("^\\d{4}-\\d{2}-\\d{2}$", "入学时间格式应为 YYYY-MM-DD"));
list.add(enroll);
// R 本职业工作年限:官方原文「只能填阿拉伯数字」
final FormFieldVO years = submission("work_years", "本职业工作年限", "number", false, false,
++sort * 10, null, null, "只能填阿拉伯数字");
years.setValidate(Map.of("min", 0, "max", 60, "message", "请填写 0~60 之间的数字"));
list.add(years);
// S/T/U 已获证书:选填
list.add(submission("cert_occupation", "已获证书职业工种", "text", false, false,
++sort * 10, null, null, null));
list.add(submission("cert_level", "已获证书等级", "select", false, false,
++sort * 10, opts(JOB_LEVEL), null, null));
list.add(submission("cert_no", "已获证书编号", "text", false, false,
++sort * 10, null, null, null));
return list;
}
// ── 构造小工具 ────────────────────────────────
private static FormFieldVO roster(String key, String label, String type, boolean required,
boolean locked, int sortNo) {
return field(key, label, type, required, locked, sortNo, SOURCE_ROSTER, null, null, null);
}
private static FormFieldVO submission(String key, String label, String type, boolean required,
boolean locked, int sortNo, List<String> options,
Map<String, Object> visibleWhen, String tip) {
return field(key, label, type, required, locked, sortNo, SOURCE_SUBMISSION,
options, visibleWhen, tip);
}
private static FormFieldVO field(String key, String label, String type, boolean required,
boolean locked, int sortNo, String source,
List<String> options, Map<String, Object> visibleWhen,
String tip) {
FormFieldVO f = new FormFieldVO();
f.setKey(key);
f.setLabel(label);
f.setType(type);
f.setBuiltin(true);
f.setLocked(locked);
f.setRequired(required);
f.setSortNo(sortNo);
f.setSource(source);
f.setOptions(options == null ? new ArrayList<>() : new ArrayList<>(options));
f.setVisibleWhen(visibleWhen);
f.setTip(tip);
return f;
}
private static List<String> opts(List<String> values) {
return new ArrayList<>(values);
}
private static Map<String, Object> visible(String field, Object value) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("field", field);
m.put("op", "eq");
m.put("value", value);
return m;
}
private static Map<String, Object> regex(String pattern, String message) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("regex", pattern);
m.put("message", message);
return m;
}
// ── 对外 ──────────────────────────────────────
/** 内置字段的默认定义(深拷贝:调用方可能要改,不能污染常量)。 */
public static List<FormFieldVO> defaults() {
List<FormFieldVO> copy = new ArrayList<>();
for (FormFieldVO f : ALL) {
FormFieldVO n = new FormFieldVO();
n.setKey(f.getKey());
n.setLabel(f.getLabel());
n.setType(f.getType());
n.setBuiltin(true);
n.setLocked(f.getLocked());
n.setRequired(f.getRequired());
n.setSortNo(f.getSortNo());
n.setSource(f.getSource());
n.setDictCode(f.getDictCode());
n.setOptions(new ArrayList<>(f.getOptions()));
n.setVisibleWhen(f.getVisibleWhen() == null ? null : new LinkedHashMap<>(f.getVisibleWhen()));
n.setValidate(f.getValidate() == null ? null : new LinkedHashMap<>(f.getValidate()));
n.setTip(f.getTip());
copy.add(n);
}
return copy;
}
/** 内置字段的键集合,用于「不可删除」与「不可改来源」的判断。 */
public static Set<String> keys() {
return ALL.stream().map(FormFieldVO::getKey).collect(Collectors.toSet());
}
/** 某个键是否内置。 */
public static boolean isBuiltin(String key) {
return keys().contains(key);
}
/** 字段全集,供错误信息里提示"有哪些内置字段"。 */
public static List<String> keysOrdered() {
return ALL.stream().map(FormFieldVO::getKey).toList();
}
}
@@ -0,0 +1,336 @@
package cn.tianbaotong.fill.module.form.service;
import cn.hutool.json.JSONUtil;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.form.dto.FormFieldVO;
import cn.tianbaotong.fill.module.form.dto.FormSchemaSaveReq;
import cn.tianbaotong.fill.module.form.dto.FormSchemaVO;
import cn.tianbaotong.fill.module.form.entity.CollectFormSchema;
import cn.tianbaotong.fill.module.form.mapper.FormSchemaMapper;
import cn.tianbaotong.fill.module.task.entity.CollectTask;
import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 任务表单定义(《03-接口设计》§7.9),按版本存储。
*
* <p><b>三条规则</b>:
* <ol>
* <li><b>版本只增不改</b>:每次保存都插一行新版本,旧的保留({@code is_current = 0})。
* 学生是按某一版填的,事后必须能回答"他当时看到的是什么" ——
* 覆盖式保存会让这个问题永远答不上来。</li>
* <li><b>{@code baseVersion} 是乐观锁</b>:与最新版本不符就拒绝({@code 40025})
* 并把<b>最新定义一起回传</b>,由前端做差异合并。表单设计器是"打开 → 改很多处 → 保存",
* 中间可能好几分钟,直接覆盖会静默丢掉别人刚加的字段。</li>
* <li><b>内置字段不可删</b>({@code 40026})。判断依据是<b>内置目录</b>而不是上一版定义 ——
* 拿"上一版"当依据的话,客户端只要先删一次(如果上一版就缺)就绕过去了。
* 不想收的字段应该用 {@code visibleWhen} 隐藏,而不是删掉。</li>
* </ol>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FormSchemaService {
/** 可编辑表单的任务状态:草稿 / 收集中 / 已暂停。 */
private static final Set<Integer> EDITABLE_TASK_STATUS = Set.of(
CollectTask.STATUS_DRAFT,
CollectTask.STATUS_COLLECTING,
CollectTask.STATUS_PAUSED);
/** 扩展字段的键约定:必须带 {@code extra_} 前缀,避免与内置列重名。 */
private static final Pattern EXTRA_KEY = Pattern.compile("^extra_[a-z0-9_]{1,40}$");
private static final String KEY_FIELDS = "fields";
private static final String KEY_EXTRAS = "extras";
private final FormSchemaMapper formSchemaMapper;
private final TaskMapper taskMapper;
// ── 查询 ──────────────────────────────────────
/**
* 任务的表单定义。
*
* <p>还没保存过时返回<b>内置字段目录的默认定义</b>(未落库),{@code version = 0}。
* 这样前端第一次打开设计器看到的不是空白,而是官方 21 项 ——
* 让用户从"一份完整的表单"开始改,而不是从"零"开始配,
* 是这个产品能落地的前提(否则每个班主任都要自己搭一遍 21 个字段)。
*/
public FormSchemaVO get(Long taskId) {
requireTask(taskId);
CollectFormSchema current = selectCurrent(taskId);
int latest = latestVersion(taskId);
if (current == null) {
FormSchemaVO vo = new FormSchemaVO();
vo.setVersion(0);
vo.setLatestVersion(latest);
vo.setIsCurrent(true);
vo.setFields(BuiltinFields.defaults());
vo.setExtras(new ArrayList<>());
return vo;
}
return toVo(current, latest);
}
// ── 保存 ──────────────────────────────────────
@Transactional(rollbackFor = Exception.class)
public FormSchemaVO save(Long taskId, FormSchemaSaveReq req) {
CollectTask task = requireTask(taskId);
int status = task.getStatus() == null ? -1 : task.getStatus();
if (!EDITABLE_TASK_STATUS.contains(status)) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"任务已" + statusText(status) + ",表单不可再改");
}
int latest = latestVersion(taskId);
if (req.getBaseVersion() == null || req.getBaseVersion() != latest) {
// 冲突:把最新定义一起回传,前端据此做差异合并。
// 只说"版本冲突"而不给最新内容,等于把合并这件事推给一个信息不全的调用方。
throw new BizException(ErrorCode.FORM_VERSION_CONFLICT,
"表单已被他人修改(你的基准版本 " + req.getBaseVersion()
+ ",当前最新 " + latest + ")。最新定义已随本响应返回,请合并后重试",
get(taskId));
}
List<FormFieldVO> fields = normalizeFields(req.getFields());
List<FormFieldVO> extras = normalizeExtras(req.getExtras());
int nextVersion = latest + 1;
// 先把所有旧版本摘掉 is_current,再插新的 —— 两步比"插入后回填旧行"少一次 id 传递,
// 而且中间任何一步失败都在同一事务里回滚,不会留下"两个当前版本"
formSchemaMapper.update(null, Wrappers.<CollectFormSchema>lambdaUpdate()
.eq(CollectFormSchema::getTaskId, taskId)
.set(CollectFormSchema::getIsCurrent, CollectFormSchema.HISTORY));
CollectFormSchema entity = new CollectFormSchema();
entity.setTaskId(taskId);
entity.setVersion(nextVersion);
entity.setSchemaJson(writeSchema(fields, extras));
entity.setIsCurrent(CollectFormSchema.CURRENT);
formSchemaMapper.insert(entity);
log.info("任务 {}({})表单保存为新版本 v{}:{} 个内置字段 + {} 个扩展字段",
taskId, task.getTaskCode(), nextVersion, fields.size(), extras.size());
return toVo(entity, nextVersion);
}
// ── 校验与归一化 ───────────────────────────────
/**
* 内置字段校验。
*
* <p>{@code builtin} 与 {@code source} <b>一律以内置目录为准</b>,不采信客户端传的值 ——
* 否则客户端把 {@code builtin} 传成 false 就绕过了「不可删除」,
* 传错 {@code source} 会让填报引擎去 submission 里找名册的字段。
*/
private List<FormFieldVO> normalizeFields(List<FormFieldVO> input) {
List<FormFieldVO> list = input == null ? List.of() : input;
Set<String> provided = new LinkedHashSet<>();
for (FormFieldVO f : list) {
if (f.getKey() == null || f.getKey().isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "字段缺少 key");
}
if (!provided.add(f.getKey().trim())) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "字段 key 重复:" + f.getKey());
}
}
List<String> missing = new ArrayList<>();
for (String key : BuiltinFields.keysOrdered()) {
if (!provided.contains(key)) {
missing.add(key);
}
}
if (!missing.isEmpty()) {
throw new BizException(ErrorCode.BUILTIN_FIELD_NOT_DELETABLE,
"内置字段不可删除:缺少 " + String.join("、", missing)
+ "。如果是不想收这些信息,请用条件显示规则把它们隐藏,而不是删掉");
}
// 按 sortNo 排序,保证存进去的顺序稳定(前端画布顺序 = 存储顺序)
List<FormFieldVO> out = new ArrayList<>(list);
out.sort((a, b) -> Integer.compare(
a.getSortNo() == null ? 0 : a.getSortNo(),
b.getSortNo() == null ? 0 : b.getSortNo()));
// 目录只取一次:21 个字段逐个 deepcopy 一遍目录是白白的 441 次拷贝
Map<String, FormFieldVO> defByKey = new LinkedHashMap<>();
for (FormFieldVO d : BuiltinFields.defaults()) {
defByKey.put(d.getKey(), d);
}
for (FormFieldVO f : out) {
String key = f.getKey().trim();
f.setKey(key);
f.setBuiltin(true);
FormFieldVO def = defByKey.get(key);
if (def != null) {
// 来源以目录为准:它决定"这个值去哪张表取",传错的表现是
// "学生端这两栏永远是空的"——一个不报错、只让人困惑的失败
f.setSource(def.getSource());
}
if (f.getLabel() == null || f.getLabel().isBlank()) {
f.setLabel(def == null ? key : def.getLabel());
}
if (f.getType() == null || f.getType().isBlank()) {
f.setType(def == null ? "text" : def.getType());
}
if (f.getRequired() == null) {
f.setRequired(def != null && Boolean.TRUE.equals(def.getRequired()));
}
if (f.getLocked() == null) {
f.setLocked(def != null && Boolean.TRUE.equals(def.getLocked()));
}
if (f.getOptions() == null) {
f.setOptions(new ArrayList<>());
}
}
return out;
}
/**
* 扩展字段校验。
*
* <p>键必须带 {@code extra_} 前缀:扩展字段的值落 {@code extras_json},
* 与内置列同处一份 form_data 里。前缀是防重名的护栏 ——
* 一个叫 {@code education} 的扩展字段会让"这个值到底是内置的还是扩展的"永远说不清。
*/
private List<FormFieldVO> normalizeExtras(List<FormFieldVO> input) {
List<FormFieldVO> list = input == null ? List.of() : input;
Set<String> builtinKeys = BuiltinFields.keys();
Set<String> seen = new HashSet<>();
List<FormFieldVO> out = new ArrayList<>();
for (FormFieldVO f : list) {
if (f.getKey() == null || f.getKey().isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "扩展字段缺少 key");
}
String key = f.getKey().trim();
if (builtinKeys.contains(key)) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"扩展字段的 key 不能与内置字段重名:" + key);
}
if (!EXTRA_KEY.matcher(key).matches()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"扩展字段的 key 必须以 extra_ 开头、只含小写字母/数字/下划线:" + key);
}
if (!seen.add(key)) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "扩展字段 key 重复:" + key);
}
f.setKey(key);
f.setBuiltin(false);
f.setSource(null);
if (f.getOptions() == null) {
f.setOptions(new ArrayList<>());
}
out.add(f);
}
out.sort((a, b) -> Integer.compare(
a.getSortNo() == null ? 0 : a.getSortNo(),
b.getSortNo() == null ? 0 : b.getSortNo()));
return out;
}
// ── 内部 ──────────────────────────────────────
private CollectTask requireTask(Long taskId) {
CollectTask task = taskMapper.selectById(taskId);
if (task == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除");
}
return task;
}
private CollectFormSchema selectCurrent(Long taskId) {
return formSchemaMapper.selectOne(Wrappers.<CollectFormSchema>lambdaQuery()
.eq(CollectFormSchema::getTaskId, taskId)
.eq(CollectFormSchema::getIsCurrent, CollectFormSchema.CURRENT)
.orderByDesc(CollectFormSchema::getVersion)
.last("limit 1"));
}
/** 最新版本号;没有任何版本时返回 0(= 还是内置默认)。 */
private int latestVersion(Long taskId) {
CollectFormSchema newest = formSchemaMapper.selectOne(
Wrappers.<CollectFormSchema>lambdaQuery()
.eq(CollectFormSchema::getTaskId, taskId)
.orderByDesc(CollectFormSchema::getVersion)
.last("limit 1"));
return newest == null || newest.getVersion() == null ? 0 : newest.getVersion();
}
// ── JSON 与 VO 装配 ────────────────────────────
private static String writeSchema(List<FormFieldVO> fields, List<FormFieldVO> extras) {
Map<String, Object> doc = new LinkedHashMap<>();
doc.put(KEY_FIELDS, fields);
doc.put(KEY_EXTRAS, extras);
return JSONUtil.toJsonStr(doc);
}
private static FormSchemaVO toVo(CollectFormSchema entity, int latestVersion) {
FormSchemaVO vo = new FormSchemaVO();
vo.setVersion(entity.getVersion());
vo.setLatestVersion(latestVersion);
vo.setIsCurrent(entity.getIsCurrent() != null && entity.getIsCurrent() == CollectFormSchema.CURRENT);
vo.setCreateTime(entity.getCreateTime());
vo.setFields(new ArrayList<>());
vo.setExtras(new ArrayList<>());
String json = entity.getSchemaJson();
if (json == null || json.isBlank()) {
return vo;
}
try {
Map<String, Object> doc = JSONUtil.toBean(json, Map.class);
vo.setFields(readFields(doc.get(KEY_FIELDS)));
vo.setExtras(readFields(doc.get(KEY_EXTRAS)));
} catch (Exception e) {
// 库里的 JSON 坏了不该让整个页面 500;退回内置默认并留日志
log.warn("表单定义解析失败(task 的版本 {}):{}", entity.getVersion(), e.getMessage());
vo.setFields(BuiltinFields.defaults());
}
return vo;
}
private static List<FormFieldVO> readFields(Object raw) {
List<FormFieldVO> out = new ArrayList<>();
if (!(raw instanceof List<?> list)) {
return out;
}
for (Object item : list) {
out.add(JSONUtil.toBean(JSONUtil.toJsonStr(item), FormFieldVO.class));
}
return out;
}
private static String statusText(int status) {
return switch (status) {
case CollectTask.STATUS_DRAFT -> "草稿";
case CollectTask.STATUS_COLLECTING -> "收集中";
case CollectTask.STATUS_CLOSED -> "截止";
case CollectTask.STATUS_ARCHIVED -> "归档";
case CollectTask.STATUS_PAUSED -> "暂停";
default -> "未知状态";
};
}
}
@@ -0,0 +1,21 @@
-- ============================================================================
-- 更正 collect_form_schema.schema_json 的列注释
-- ============================================================================
--
-- 背景:V1 把这一列的注释写成 '字段定义数组',即"一个裸的字段数组"。
-- 但一版表单同时包含两类定义:
-- fields 内置字段(键与 collect_submission 的列名一致 → 可统计)
-- extras 扩展字段(值落 extras_json → 不参与统计)
-- 所以实际存的是 {"fields":[...],"extras":[...]}。
--
-- 为什么合成一个文档、而不是拆成两列(fields_json + extras_json):
-- 一版表单就是**一份快照**。拆成两列就存在"只写成功一半"的可能
-- (fields 更新了、extras 没更新),而"学生看到的那版表单"必须是原子的。
-- 版本化的东西尤其如此 —— 事后要能回答"他当时看到的是哪一版",
-- 答案必须是完整的一份,不能是拼起来的半份。
--
-- 这只是注释更正,不动数据(与 V3 更正 user_type 注释同一做法)。
-- ============================================================================
COMMENT ON COLUMN collect_form_schema.schema_json IS
'表单定义快照,JSON 对象:{"fields":[内置字段],"extras":[扩展字段]}。一版即一份原子快照,故不拆列';
+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 |
---
**关键实现决策(不要在后继开发中改回去)**
| 决策 | 理由 |
+408
View File
@@ -0,0 +1,408 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
M1-03 表单 schema 版本化与冲突检测 验收测试。
覆盖《03-接口设计》§7.9 / §3.5:
[x] 未保存过时返回内置字段目录的默认定义(官方 21 项,version=0)
[x] 22 项内置字段的锁定/必填/来源/选项与官方模板逐条对齐
[x] 保存为**新版本**(旧版本保留),is_current 只有一个
[x] baseVersion 不符 → 40025 **并回传最新定义**(前端据此合并)
[x] 缺少内置字段 → 40026(内置字段不可删)
[x] 扩展字段:必须 extra_ 前缀、不可与内置重名、不参与统计
[x] 客户端传 builtin=false 不能绕过「不可删除」(内建标志以目录为准)
[x] 状态限制与权限
为什么这些值得逐条断言:
表单定义是学生端看到的东西。版本搞错的表现是"学生按旧版填、老师按新版审";
内置字段被删的表现是"这个人的姓名取不到值"—— 都是不报错、只让人困惑的失败。
用法
python scripts/e2e-m1-form.py
依赖:cryptography;且需要能 `docker exec xt-pg`(清理)
退出码:0 = 全部通过
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import subprocess
import sys
import urllib.error
import urllib.request
import uuid
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem")
PG_CONTAINER = os.environ.get("XT_PG_CONTAINER", "xt-pg")
PG_USER = os.environ.get("PG_USER", "fill")
PG_DB = os.environ.get("PG_DB", "filldb")
PG_PWD = os.environ.get("PG_PASSWORD", "fill_dev_pwd")
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
CTX: dict[str, str | None] = {"base": None, "token": None}
CREATED_TASK_IDS: list[int] = []
CREATED_USER_IDS: list[int] = []
def ok(name: str) -> None:
PASSED.append(name)
print(f" [OK] {name}")
def fail(name: str, detail: str) -> None:
FAILED.append((name, detail))
print(f" [FAIL] {name}\n {detail}")
def check(name: str, cond: bool, detail: str = "") -> bool:
if cond:
ok(name)
else:
fail(name, detail or "断言不成立")
return cond
def sql(stmt: str) -> tuple[bool, str]:
try:
r = subprocess.run(
["docker", "exec", "-e", f"PGPASSWORD={PG_PWD}", PG_CONTAINER,
"psql", "-U", PG_USER, "-d", PG_DB, "-t", "-A", "-c", stmt],
capture_output=True, text=True, timeout=40)
return r.returncode == 0, (r.stdout or r.stderr).strip()
except Exception as e:
return False, str(e)
def cleanup_all() -> None:
if CREATED_TASK_IDS:
ids = ",".join(str(i) for i in CREATED_TASK_IDS)
for stmt in [
f"DELETE FROM collect_form_schema WHERE task_id IN ({ids})",
f"DELETE FROM collect_material_def WHERE task_id IN ({ids})",
f"DELETE FROM collect_task_org WHERE task_id IN ({ids})",
f"DELETE FROM collect_roster WHERE task_id IN ({ids})",
f"DELETE FROM collect_task WHERE id IN ({ids})",
]:
sql(stmt)
base, token = CTX.get("base"), CTX.get("token")
if base and token:
for uid in CREATED_USER_IDS:
try:
call("DELETE", f"{base}/api/v1/users/{uid}", token)
except Exception:
pass
def rsa_encrypt(plain: str) -> str:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
with open(PUB_KEY, "rb") as f:
pub = serialization.load_pem_public_key(f.read())
return base64.b64encode(pub.encrypt(plain.encode(), padding.PKCS1v15())).decode()
def call(method: str, url: str, token: str | None = None,
body: dict | None = None, timeout: int = 30) -> tuple[int, dict]:
headers = {"Content-Type": "application/json; charset=utf-8"}
if token:
headers["Authorization"] = f"Bearer {token}"
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status, json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8")
try:
return e.code, json.loads(raw)
except json.JSONDecodeError:
return e.code, {"raw": raw}
def login(base: str, username: str, password: str, tenant: str | None = None):
payload = {"username": username, "password": rsa_encrypt(password)}
if tenant:
payload["tenantCode"] = tenant
return call("POST", f"{base}/api/v1/auth/login", body=payload)
def main() -> int:
ap = argparse.ArgumentParser(description="M1-03 表单 schema E2E")
ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080"))
ap.add_argument("--tenant", default=os.environ.get("XT_TENANT", "demo"))
ap.add_argument("--password", default="Admin@123")
args = ap.parse_args()
base = args.base.rstrip("/")
print("=" * 72)
print(f"M1-03 表单 schema 版本化 E2E base={base} 租户={args.tenant}")
print("=" * 72)
suffix = uuid.uuid4().hex[:6]
# ── 0. 准备 ────────────────────────────────────────
print("\n[0] 准备")
status, body = login(base, "admin", args.password, args.tenant)
if body.get("code") != 0:
fail("管理员登录", f"HTTP {status} code={body.get('code')} msg={body.get('msg')}")
return 1
token = body["data"]["token"]
tenant_id = (body["data"].get("tenant") or {}).get("id")
CTX["base"], CTX["token"] = base, token
ok("管理员登录")
status, body = call("POST", f"{base}/api/v1/tasks", token, {
"taskName": f"E2E表单 {suffix}", "collectMode": 1, "batchNo": "2003",
"majorName": "数控加工", "certType": 1,
"startTime": "2026-09-28", "endTime": "2026-12-31",
})
if body.get("code") != 0:
fail("建任务", f"code={body.get('code')} msg={body.get('msg')}")
return 1
task_id = body["data"]["id"]
CREATED_TASK_IDS.append(task_id)
ok(f"建任务(id={task_id})")
# ── 1. 默认定义 = 官方 21 项 ─────────────────────────
print("\n[1] 未保存过时的默认定义")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/form-schema", token)
d = body.get("data") or {}
fields = d.get("fields") or []
by_key = {f["key"]: f for f in fields}
check("返回内置默认定义(version=0,未落库)",
d.get("version") == 0 and d.get("latestVersion") == 0,
f"version={d.get('version')} latest={d.get('latestVersion')}")
check("恰好 21 个内置字段(官方模板 A~U 列)", len(fields) == 21, f"实际 {len(fields)}")
check("顺序即 A~U 列顺序",
[f["key"] for f in fields] == [
"seq_no", "real_name", "id_card_no", "education", "phone", "work_unit",
"occupation", "worktype", "job_level", "is_retake", "retake_theory",
"retake_practice", "retake_overall", "is_student", "school", "major",
"enroll_date", "work_years", "cert_occupation", "cert_level", "cert_no"],
f"{[f['key'] for f in fields]}")
check("全部标记为 builtin(内置不可删)", all(f.get("builtin") is True for f in fields),
f"{[(f['key'], f.get('builtin')) for f in fields if not f.get('builtin')]}")
# 锁定项:PRD §「锁定」= 姓名、身份证号、报考职业、报考工种、报考等级
locked_expected = {"real_name", "id_card_no", "occupation", "worktype", "job_level"}
locked_actual = {f["key"] for f in fields if f.get("locked")}
check("锁定项与 PRD 一致(姓名/身份证号/报考职业/工种/等级)",
locked_expected.issubset(locked_actual),
f"应为 {sorted(locked_expected)},实际 {sorted(locked_actual)}")
check("联系电话来自名册但**不锁定**(预填可改:名册里常是家长的老号码)",
by_key["phone"].get("source") == "ROSTER" and by_key["phone"].get("locked") is False,
f"source={by_key['phone'].get('source')} locked={by_key['phone'].get('locked')}")
check("姓名/身份证号的来源是名册(不在 collect_submission 里)",
by_key["real_name"].get("source") == "ROSTER"
and by_key["id_card_no"].get("source") == "ROSTER",
f"{by_key['real_name'].get('source')} / {by_key['id_card_no'].get('source')}")
check("学历的来源是 submission 且带 11 个官方选项",
by_key["education"].get("source") == "SUBMISSION"
and len(by_key["education"].get("options") or []) == 11,
f"source={by_key['education'].get('source')} options={len(by_key['education'].get('options') or [])}")
check("报考等级的 5 个官方选项照抄",
(by_key["job_level"].get("options") or [])[:2] == ["五级/初级工", "四级/中级工"],
f"{by_key['job_level'].get('options')}")
check("补考三科只在「是否补考=是」时显示(官方规则 1)",
all((by_key[k].get("visibleWhen") or {}).get("field") == "is_retake"
and (by_key[k].get("visibleWhen") or {}).get("value") is True
for k in ("retake_theory", "retake_practice", "retake_overall")),
f"{[(k, by_key[k].get('visibleWhen')) for k in ('retake_theory', 'retake_practice')]}")
check("在校生三项只在「是否在校生=是」时显示(官方规则 3/4)",
all((by_key[k].get("visibleWhen") or {}).get("field") == "is_student"
for k in ("school", "major", "enroll_date")),
f"{[(k, by_key[k].get('visibleWhen')) for k in ('school', 'major', 'enroll_date')]}")
check("入学时间带 YYYY-MM-DD 正则(官方原文「格式必须 YYYY-MM-DD」)",
(by_key["enroll_date"].get("validate") or {}).get("regex") == "^\\d{4}-\\d{2}-\\d{2}$",
f"{by_key['enroll_date'].get('validate')}")
check("工作单位的提示语写着「没有则填无」", "无" in (by_key["work_unit"].get("tip") or ""),
f"{by_key['work_unit'].get('tip')}")
# ── 2. 保存为版本 1 ─────────────────────────────────
print("\n[2] 保存为新版本")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
"baseVersion": 0,
"fields": fields,
"extras": [{"key": "extra_skill_cert", "label": "其它技能证书编号",
"type": "text", "required": False, "sortNo": 100}],
})
check("从 version=0 保存成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
saved = body.get("data") or {}
check("新版本是 1", saved.get("version") == 1, f"{saved.get('version')}")
check("isCurrent=true", saved.get("isCurrent") is True, f"{saved.get('isCurrent')}")
check("扩展字段存下来了", len(saved.get("extras") or []) == 1,
f"{saved.get('extras')}")
check("latestVersion 同步为 1", saved.get("latestVersion") == 1, f"{saved.get('latestVersion')}")
okk, out = sql(f"SELECT count(*) FROM collect_form_schema WHERE task_id={task_id}")
check("库里落了一行版本", okk and out.strip() == "1", f"{out}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/form-schema", token)
check("读回的是 version=1", (body.get("data") or {}).get("version") == 1,
f"{(body.get('data') or {}).get('version')}")
# ── 3. 改一处 → 版本 2 ──────────────────────────────
print("\n[3] 再次保存 → 版本递增、旧版保留")
fields2 = json.loads(json.dumps(fields))
for f in fields2:
if f["key"] == "work_unit":
f["required"] = False
f["tip"] = "改成选填了"
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
"baseVersion": 1, "fields": fields2, "extras": [],
})
check("从 version=1 保存成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
check("新版本是 2", (body.get("data") or {}).get("version") == 2, f"{(body.get('data') or {}).get('version')}")
okk, out = sql(f"SELECT count(*) FROM collect_form_schema WHERE task_id={task_id}")
check("库里两行版本(旧版保留,不覆盖)", okk and out.strip() == "2", f"{out}")
okk, out = sql(f"SELECT count(*) FROM collect_form_schema WHERE task_id={task_id} AND is_current=1")
check("当前版本只有一个", okk and out.strip() == "1", f"{out}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/form-schema", token)
d2 = {f["key"]: f for f in (body.get("data") or {}).get("fields") or []}
check("改动已生效(工作单位变选填)", d2["work_unit"].get("required") is False,
f"{d2['work_unit'].get('required')}")
check("扩展字段可以整体清空", (body.get("data") or {}).get("extras") == [],
f"{(body.get('data') or {}).get('extras')}")
# ── 4. 版本冲突 ─────────────────────────────────────
print("\n[4] 版本冲突(40025)")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
"baseVersion": 1, "fields": fields2, "extras": [],
})
check("用过期 baseVersion 保存被拒 40025", body.get("code") == 40025,
f"code={body.get('code')} msg={body.get('msg')}")
latest = body.get("data") or {}
check("40025 的响应里**带上了最新定义**(前端据此做差异合并)",
latest.get("version") == 2 and len(latest.get("fields") or []) == 21,
f"version={latest.get('version')} fields={len(latest.get('fields') or [])}")
check("冲突提示里说清了基准版本与最新版本",
"基准版本 1" in (body.get("msg") or "") and "最新 2" in (body.get("msg") or ""),
f"msg={body.get('msg')}")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
"baseVersion": 99, "fields": fields2, "extras": [],
})
check("baseVersion 比最新大也被拒(不是只防「小于」)", body.get("code") == 40025,
f"code={body.get('code')} msg={body.get('msg')}")
# ── 5. 内置字段不可删 ───────────────────────────────
print("\n[5] 内置字段不可删(40026)")
missing_one = [f for f in fields2 if f["key"] != "id_card_no"]
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
"baseVersion": 2, "fields": missing_one, "extras": [],
})
check("删掉「身份证号」被拒 40026", body.get("code") == 40026,
f"code={body.get('code')} msg={body.get('msg')}")
check("拒绝理由点出了缺哪个字段,并给出替代做法(隐藏而不是删)",
"id_card_no" in (body.get("msg") or "") and "隐藏" in (body.get("msg") or ""),
f"msg={body.get('msg')}")
spoofed = json.loads(json.dumps(fields2))
spoofed = [f for f in spoofed if f["key"] != "id_card_no"]
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
"baseVersion": 2, "fields": spoofed, "extras": [],
})
check("把 builtin 传成 false 也无法绕过不可删除", body.get("code") == 40026,
f"code={body.get('code')} msg={body.get('msg')}")
# ── 6. 扩展字段 ─────────────────────────────────────
print("\n[6] 扩展字段")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
"baseVersion": 2, "fields": fields2,
"extras": [{"key": "education", "label": "想冒充内置列", "type": "text"}],
})
check("扩展字段不能与内置字段重名", body.get("code") == 40001,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
"baseVersion": 2, "fields": fields2,
"extras": [{"key": "skillCert", "label": "没有 extra_ 前缀", "type": "text"}],
})
check("扩展字段 key 必须带 extra_ 前缀", body.get("code") == 40001,
f"code={body.get('code')} msg={body.get('msg')}")
check("以上失败都没有产生新版本",
(call("GET", f"{base}/api/v1/tasks/{task_id}/form-schema", token)[1].get("data") or {}).get("version") == 2,
"版本被推进了")
# ── 7. 状态限制与权限 ───────────────────────────────
print("\n[7] 状态限制与权限")
# ⚠️ 要测「已截止不可改表单」,必须先把任务推到「收集中」:
# 草稿是**不能直接截止的**(close 只允许 收集中 / 已暂停)——
# 这正是发布前置校验存在的意义:没配齐就发布,学生填了也没人审。
# 所以这里用 SQL 夹具把发布前置的三样备齐。
sql(f"INSERT INTO collect_roster (tenant_id, task_id, real_name, id_card_no, id_card_hash) "
f"VALUES ({tenant_id}, {task_id}, 'E2E学生A', 'seed', 'seed')")
sql(f"INSERT INTO collect_material_def (tenant_id, task_id, material_code, material_name, required) "
f"VALUES ({tenant_id}, {task_id}, 'IDCARD', '身份证', 1)")
okk, out = sql(f"INSERT INTO collect_task_org (tenant_id, task_id, org_id, status) "
f"VALUES ({tenant_id}, {task_id}, "
f"(SELECT id FROM sys_org WHERE tenant_id={tenant_id} AND org_type=3 LIMIT 1), 1)")
check("夹具:名册 / 材料 / 覆盖班级就位", okk, out)
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/publish", token, {"force": True})
check("发布任务(进入收集中)", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
"baseVersion": 2, "fields": fields2, "extras": [],
})
check("收集中允许改表单(§3.5 说「已发布任务改表单必须递增」,说明是允许改的)",
body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/close", token, {})
check("把任务截止", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
"baseVersion": 3, "fields": fields2, "extras": [],
})
check("已截止不可再改表单", body.get("code") == 40001, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/form-schema", token)
check("已截止仍可读表单(只读不等于看不见)", body.get("code") == 0, f"code={body.get('code')}")
ht_user = f"htf{suffix}"
status, body = call("POST", f"{base}/api/v1/users", token, {
"username": ht_user, "realName": f"E2E表单班主任{suffix}",
"phone": "13900139401", "roleCodes": ["HEAD_TEACHER"], "userType": 3,
})
if check("造班主任账号", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"):
uid, pwd = body["data"]["id"], body["data"]["initialPassword"]
CREATED_USER_IDS.append(uid)
status, body = login(base, ht_user, pwd, args.tenant)
ht_token = body["data"]["token"]
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/form-schema", ht_token)
check("班主任能读表单定义", body.get("code") == 0, f"code={body.get('code')}")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", ht_token, {
"baseVersion": 2, "fields": fields2, "extras": [],
})
check("班主任不能改表单(无 task:manage)", body.get("code") == 40301,
f"code={body.get('code')}")
# ── 8. 清理 ────────────────────────────────────────
print("\n[8] 清理测试数据")
cleanup_all()
ok(f"已清理 {len(CREATED_TASK_IDS)} 个任务、{len(CREATED_USER_IDS)} 个账号")
print("\n" + "=" * 72)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
if FAILED:
print("\n失败项:")
for name, detail in FAILED:
print(f" · {name}\n {detail}")
print("=" * 72)
return 1 if FAILED else 0
if __name__ == "__main__":
try:
code = main()
except BaseException:
print("\n ⚠ 脚本异常退出,正在清理已造出的测试数据…")
cleanup_all()
raise
raise SystemExit(code)