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":[扩展字段]}。一版即一份原子快照,故不拆列';