From 870c38828790187238ae2f994077b5b9276d3357 Mon Sep 17 00:00:00 2001
From: lovife <474239041@qq.com>
Date: Tue, 29 Sep 2026 09:47:09 +0800
Subject: [PATCH] =?UTF-8?q?feat(form):=20M1-03=20=E8=A1=A8=E5=8D=95=20sche?=
=?UTF-8?q?ma=20=E7=89=88=E6=9C=AC=E5=8C=96=E4=B8=8E=E5=86=B2=E7=AA=81?=
=?UTF-8?q?=E6=A3=80=E6=B5=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
学生端看到的表单终于可以按任务配置了。按计划表这一项是后端
(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。
---
.../form/controller/FormSchemaController.java | 54 +++
.../fill/module/form/dto/FormFieldVO.java | 79 ++++
.../module/form/dto/FormSchemaSaveReq.java | 46 ++
.../fill/module/form/dto/FormSchemaVO.java | 50 +++
.../module/form/entity/CollectFormSchema.java | 62 +++
.../module/form/mapper/FormSchemaMapper.java | 13 +
.../module/form/service/BuiltinFields.java | 242 +++++++++++
.../form/service/FormSchemaService.java | 336 +++++++++++++++
.../V6__form_schema_json_comment.sql | 21 +
docs/交接摘要.md | 59 ++-
scripts/e2e-m1-form.py | 408 ++++++++++++++++++
11 files changed, 1369 insertions(+), 1 deletion(-)
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/controller/FormSchemaController.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/dto/FormFieldVO.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/dto/FormSchemaSaveReq.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/dto/FormSchemaVO.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/entity/CollectFormSchema.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/mapper/FormSchemaMapper.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/service/BuiltinFields.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/service/FormSchemaService.java
create mode 100644 backend/xt-server/src/main/resources/db/migration/V6__form_schema_json_comment.sql
create mode 100644 scripts/e2e-m1-form.py
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/controller/FormSchemaController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/controller/FormSchemaController.java
new file mode 100644
index 0000000..1857e74
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/controller/FormSchemaController.java
@@ -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)。
+ *
+ *
只有两个动作:读当前定义、保存为新版本。没有删除接口是刻意的 ——
+ * 表单版本是只增不改的历史快照,删掉它就无法回答
+ * "某个学生当时看到的是哪一版表单"。
+ */
+@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 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 save(@PathVariable Long id,
+ @Valid @RequestBody FormSchemaSaveReq req) {
+ return ApiResult.ok(formSchemaService.save(id, req));
+ }
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/dto/FormFieldVO.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/dto/FormFieldVO.java
new file mode 100644
index 0000000..bcfb102
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/dto/FormFieldVO.java
@@ -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)。
+ *
+ * 字段键({@code key})与 {@code collect_submission} 的列名一致 ——
+ * 这样"可统计"这件事不用额外机制:字段值就是表里的列,能直接 GROUP BY。
+ * 扩展字段没有列,值落在 {@code collect_submission.extras_json},所以
+ * 不参与统计(这是架构约束,不是取舍)。
+ *
+ *
⚠️ {@code source} 字段是契约之外的补充:§3.5 说"内置字段与
+ * {@code collect_submission} 列名一致",但「姓名」「身份证号」「联系电话」「序号」
+ * 这四项不在 submission 里,在名册里({@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;
+
+ /** 是否内置字段。内置不可删除({@code 40026})。 */
+ private Boolean builtin;
+
+ /**
+ * 是否锁定(学生不可改)。
+ *
+ *
「姓名 / 身份证号 / 报考职业 / 报考工种 / 报考等级」是锁定的 ——
+ * 这几项由老师根据机构要求与报考策略定,学生不懂也不该改。
+ * 让它们出现在学生端可编辑,只会制造错误(而且是最难挽回的那类错误:
+ * 报错了工种,机构那边直接不受理)。
+ */
+ private Boolean locked;
+
+ private Boolean required;
+
+ /** 选项来源字典码。当前没有字典表,内置选项走 {@code options}。 */
+ private String dictCode;
+
+ /** 手填选项。 */
+ private List options = new ArrayList<>();
+
+ /** 校验规则:{@code {regex, min, max, message}}。 */
+ private Map validate;
+
+ /** 条件显示规则:{@code {field, op, value}}。 */
+ private Map visibleWhen;
+
+ private Integer sortNo;
+
+ /** 提示语,如「老师已填,请核对」。 */
+ private String tip;
+
+ /**
+ * 取值来源:{@code SUBMISSION}(落 {@code collect_submission} 的列)或
+ * {@code ROSTER}(来自名册,如姓名/身份证号/联系电话/序号)。
+ * 见类注释:契约没写,但不写就得靠硬编码名单猜。
+ */
+ private String source;
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/dto/FormSchemaSaveReq.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/dto/FormSchemaSaveReq.java
new file mode 100644
index 0000000..d4e355e
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/dto/FormSchemaSaveReq.java
@@ -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 {
+
+ /**
+ * 编辑开始时的版本号,用于乐观锁。
+ *
+ * 与当前版本不符就拒绝({@code 40025})并回传最新定义,由前端做差异合并。
+ *
+ *
为什么必须有这个字段:表单设计器是一个"打开 → 改很多处 → 保存"的过程,
+ * 中间可能好几分钟。这期间别人(或另一个标签页)保存过一次,那么后保存的人
+ * 是基于旧定义改的 —— 直接覆盖会静默丢掉对方刚加的字段,
+ * 而且丢失发生在"另一个人"那里,当事人完全不知道。
+ * 加上 {@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 fields = new ArrayList<>();
+
+ /** 扩展字段。值落 {@code extras_json},不参与统计。 */
+ @Valid
+ @Schema(description = "扩展字段列表(不参与统计)")
+ private List extras = new ArrayList<>();
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/dto/FormSchemaVO.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/dto/FormSchemaVO.java
new file mode 100644
index 0000000..9c4c729
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/dto/FormSchemaVO.java
@@ -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)。
+ *
+ * {@code fields} 与 {@code extras} 分开是有意义的:
+ *
+ * - {@code fields}:内置字段,键与 {@code collect_submission} 的列名一致
+ * → 可查询、可索引、可统计、可校验
+ * - {@code extras}:扩展字段,值落 {@code extras_json} → 仅展示与导出,不参与统计
+ *
+ * 界面上必须把这条差异明示出来("扩展字段不参与核心统计"),
+ * 否则校长会发现"我的报表里怎么没有这个字段",而那时已经填了几百份了。
+ */
+@Getter
+@Setter
+@Schema(description = "任务表单定义")
+public class FormSchemaVO {
+
+ /** 版本号。已发布任务每次改动 +1(§3.5)。 */
+ private Integer version;
+
+ /** 是否当前生效版本。 */
+ private Boolean isCurrent;
+
+ private List fields = new ArrayList<>();
+
+ private List extras = new ArrayList<>();
+
+ private LocalDateTime createTime;
+
+ /**
+ * 当前最新版本号。
+ *
+ * 编辑时要用它当 {@code baseVersion}:前端打开表单设计器时记下它,
+ * 保存时带上;若期间被别人改过,服务端会以 {@code 40025} 拒绝并回传最新定义,
+ * 由前端做差异合并。没有这个值,两个人同时改就必然有人白改一遍。
+ */
+ private Integer latestVersion;
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/entity/CollectFormSchema.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/entity/CollectFormSchema.java
new file mode 100644
index 0000000..c90d9ed
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/entity/CollectFormSchema.java
@@ -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}),按版本存储。
+ *
+ *
⚠️ 本实体刻意不继承 {@code TenantEntity} / {@code BaseEntity}:
+ * 这张表没有 {@code deleted} 列,也没有 {@code update_by}/{@code update_time}。
+ * 基类上的 {@code @TableLogic} 会让 MyBatis-Plus 给每条查询自动加上 {@code deleted = 0},
+ * 直接报"列不存在"({@code collect_task_org} 踩过同一个坑)。
+ *
+ *
没有 {@code deleted} 是有道理的:表单版本是只增不改的历史快照 ——
+ * 已发布任务的每一版都要留着(学生是按某一版填的,事后要能回答"他当时看到的是什么")。
+ * 所以这里不需要软删,只有"新增版本"与"切换 is_current"。
+ *
+ *
{@code schema_json} 存的是一个对象,形如
+ * {@code {"fields":[...],"extras":[...]}},不是裸的字段数组
+ * (DDL 注释写的是"字段定义数组",与实现不一致,已由 V6 更正)。
+ * 为什么合成一个文档:一版表单就是一份快照。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;
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/mapper/FormSchemaMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/mapper/FormSchemaMapper.java
new file mode 100644
index 0000000..31f7385
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/mapper/FormSchemaMapper.java
@@ -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})。
+ *
+ *
M1-03 只需要基础 CRUD:取当前版本、查历史、插入新版本、切换 is_current。
+ * 都在 Service 里用条件构造器完成,不需要自定义 SQL。
+ */
+public interface FormSchemaMapper extends BaseMapper {
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/service/BuiltinFields.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/service/BuiltinFields.java
new file mode 100644
index 0000000..32d2655
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/service/BuiltinFields.java
@@ -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;
+
+/**
+ * 内置字段目录:官方《考生线下报名信息导入模板》的 21 个字段(A~U 列)。
+ *
+ * 这份清单是本产品的地基,不是可有可无的默认值:官方模板 21 项里,
+ * 「报考职业 / 报考工种 / 报考等级」在 Excel 里是自由填写的文本 ——
+ * 这是现场最容易出错的地方(写错工种,机构直接不受理,而学生到那时才知道)。
+ * 我们把它做成从计划级联的下拉,从源头不可能填错。
+ *
+ *
三条口径必须与 PRD 逐字对齐,改之前先读 PRD 的字段全集表:
+ *
+ * - 锁定项({@code locked}):姓名、身份证号、报考职业、报考工种、报考等级。
+ * 这几项由老师按机构要求与报考策略定,学生不懂也不该改。序号也来自名册,
+ * 一并锁定 —— 让学生去编辑"自己是第几号"没有任何意义。
+ * - 选项({@code options})照抄不许擅改:官方说明写着
+ * "下拉选项无需手填……若手动输入则只能填写下拉选项中包含的值"。
+ * 我们多一个少一个都算改了机构的规则。
+ * - {@code source} 区分取值来源:{@code ROSTER} 的字段值来自名册
+ * ({@code collect_roster}),{@code SUBMISSION} 的落
+ * {@code collect_submission} 的列。注意 {@code source} 与 {@code locked}
+ * 互相独立:「联系电话」来自名册但学生可以改(预填可改),
+ * 「报考职业」来自计划且锁定。
+ *
+ */
+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 EDU_LEVEL = List.of(
+ "博士研究生", "硕士研究生", "大学本科", "大学专科", "中等专科",
+ "职业高中", "技工学校", "普通中学", "初级中学", "小学", "其他");
+
+ /** 报考等级 / 已获证书等级(5 项)。 */
+ private static final List JOB_LEVEL = List.of(
+ "五级/初级工", "四级/中级工", "三级/高级工", "二级/技师", "一级/高级技师");
+
+ /** 补考理论(4 项)。 */
+ private static final List RETAKE_THEORY = List.of(
+ "理论-纸笔作答", "理论-机考", "理论-直接认定", "纸笔作答");
+
+ /** 补考实操(6 项)。 */
+ private static final List RETAKE_PRACTICE = List.of(
+ "实操-现场操作", "实操-模拟操作", "实操-纸笔作答", "实操-机考", "实操-直接认定", "技能操作");
+
+ /** 补考综合(2 项)。 */
+ private static final List RETAKE_OVERALL = List.of(
+ "综合评审", "综合评审-直接认定");
+
+ /**
+ * 21 个内置字段,顺序即 A~U 列的顺序({@code sortNo} 从 10 起,留出插入空间)。
+ */
+ private static final List ALL = build();
+
+ private static List build() {
+ List 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 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 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 options,
+ Map 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 options, Map 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 opts(List values) {
+ return new ArrayList<>(values);
+ }
+
+ private static Map visible(String field, Object value) {
+ Map m = new LinkedHashMap<>();
+ m.put("field", field);
+ m.put("op", "eq");
+ m.put("value", value);
+ return m;
+ }
+
+ private static Map regex(String pattern, String message) {
+ Map m = new LinkedHashMap<>();
+ m.put("regex", pattern);
+ m.put("message", message);
+ return m;
+ }
+
+ // ── 对外 ──────────────────────────────────────
+
+ /** 内置字段的默认定义(深拷贝:调用方可能要改,不能污染常量)。 */
+ public static List defaults() {
+ List 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 keys() {
+ return ALL.stream().map(FormFieldVO::getKey).collect(Collectors.toSet());
+ }
+
+ /** 某个键是否内置。 */
+ public static boolean isBuiltin(String key) {
+ return keys().contains(key);
+ }
+
+ /** 字段全集,供错误信息里提示"有哪些内置字段"。 */
+ public static List keysOrdered() {
+ return ALL.stream().map(FormFieldVO::getKey).toList();
+ }
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/service/FormSchemaService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/service/FormSchemaService.java
new file mode 100644
index 0000000..69853d9
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/service/FormSchemaService.java
@@ -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),按版本存储。
+ *
+ * 三条规则:
+ *
+ * - 版本只增不改:每次保存都插一行新版本,旧的保留({@code is_current = 0})。
+ * 学生是按某一版填的,事后必须能回答"他当时看到的是什么" ——
+ * 覆盖式保存会让这个问题永远答不上来。
+ * - {@code baseVersion} 是乐观锁:与最新版本不符就拒绝({@code 40025})
+ * 并把最新定义一起回传,由前端做差异合并。表单设计器是"打开 → 改很多处 → 保存",
+ * 中间可能好几分钟,直接覆盖会静默丢掉别人刚加的字段。
+ * - 内置字段不可删({@code 40026})。判断依据是内置目录而不是上一版定义 ——
+ * 拿"上一版"当依据的话,客户端只要先删一次(如果上一版就缺)就绕过去了。
+ * 不想收的字段应该用 {@code visibleWhen} 隐藏,而不是删掉。
+ *
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class FormSchemaService {
+
+ /** 可编辑表单的任务状态:草稿 / 收集中 / 已暂停。 */
+ private static final Set 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;
+
+ // ── 查询 ──────────────────────────────────────
+
+ /**
+ * 任务的表单定义。
+ *
+ * 还没保存过时返回内置字段目录的默认定义(未落库),{@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 fields = normalizeFields(req.getFields());
+ List extras = normalizeExtras(req.getExtras());
+
+ int nextVersion = latest + 1;
+
+ // 先把所有旧版本摘掉 is_current,再插新的 —— 两步比"插入后回填旧行"少一次 id 传递,
+ // 而且中间任何一步失败都在同一事务里回滚,不会留下"两个当前版本"
+ formSchemaMapper.update(null, Wrappers.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);
+ }
+
+ // ── 校验与归一化 ───────────────────────────────
+
+ /**
+ * 内置字段校验。
+ *
+ * {@code builtin} 与 {@code source} 一律以内置目录为准,不采信客户端传的值 ——
+ * 否则客户端把 {@code builtin} 传成 false 就绕过了「不可删除」,
+ * 传错 {@code source} 会让填报引擎去 submission 里找名册的字段。
+ */
+ private List normalizeFields(List input) {
+ List list = input == null ? List.of() : input;
+
+ Set 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 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 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 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;
+ }
+
+ /**
+ * 扩展字段校验。
+ *
+ * 键必须带 {@code extra_} 前缀:扩展字段的值落 {@code extras_json},
+ * 与内置列同处一份 form_data 里。前缀是防重名的护栏 ——
+ * 一个叫 {@code education} 的扩展字段会让"这个值到底是内置的还是扩展的"永远说不清。
+ */
+ private List normalizeExtras(List input) {
+ List list = input == null ? List.of() : input;
+ Set builtinKeys = BuiltinFields.keys();
+ Set seen = new HashSet<>();
+ List 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.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.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 fields, List extras) {
+ Map 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 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 readFields(Object raw) {
+ List 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 -> "未知状态";
+ };
+ }
+}
diff --git a/backend/xt-server/src/main/resources/db/migration/V6__form_schema_json_comment.sql b/backend/xt-server/src/main/resources/db/migration/V6__form_schema_json_comment.sql
new file mode 100644
index 0000000..ebe27bc
--- /dev/null
+++ b/backend/xt-server/src/main/resources/db/migration/V6__form_schema_json_comment.sql
@@ -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":[扩展字段]}。一版即一份原子快照,故不拆列';
diff --git a/docs/交接摘要.md b/docs/交接摘要.md
index f729be1..382eb86 100644
--- a/docs/交接摘要.md
+++ b/docs/交接摘要.md
@@ -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 |
+
+---
+
**关键实现决策(不要在后继开发中改回去)**
| 决策 | 理由 |
diff --git a/scripts/e2e-m1-form.py b/scripts/e2e-m1-form.py
new file mode 100644
index 0000000..b3722f4
--- /dev/null
+++ b/scripts/e2e-m1-form.py
@@ -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)