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:
+54
@@ -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;
|
||||
}
|
||||
+46
@@ -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;
|
||||
}
|
||||
+62
@@ -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;
|
||||
}
|
||||
+13
@@ -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> {
|
||||
}
|
||||
+242
@@ -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();
|
||||
}
|
||||
}
|
||||
+336
@@ -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":[扩展字段]}。一版即一份原子快照,故不拆列';
|
||||
Reference in New Issue
Block a user