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} 分开是有意义的: + *

+ * 界面上必须把这条差异明示出来("扩展字段不参与核心统计"), + * 否则校长会发现"我的报表里怎么没有这个字段",而那时已经填了几百份了。 + */ +@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 的字段全集表: + *

    + *
  1. 锁定项({@code locked}):姓名、身份证号、报考职业、报考工种、报考等级。 + * 这几项由老师按机构要求与报考策略定,学生不懂也不该改。序号也来自名册, + * 一并锁定 —— 让学生去编辑"自己是第几号"没有任何意义。
  2. + *
  3. 选项({@code options})照抄不许擅改:官方说明写着 + * "下拉选项无需手填……若手动输入则只能填写下拉选项中包含的值"。 + * 我们多一个少一个都算改了机构的规则。
  4. + *
  5. {@code source} 区分取值来源:{@code ROSTER} 的字段值来自名册 + * ({@code collect_roster}),{@code SUBMISSION} 的落 + * {@code collect_submission} 的列。注意 {@code source} 与 {@code locked} + * 互相独立:「联系电话」来自名册但学生可以改(预填可改), + * 「报考职业」来自计划且锁定。
  6. + *
+ */ +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),按版本存储。 + * + *

三条规则: + *

    + *
  1. 版本只增不改:每次保存都插一行新版本,旧的保留({@code is_current = 0})。 + * 学生是按某一版填的,事后必须能回答"他当时看到的是什么" —— + * 覆盖式保存会让这个问题永远答不上来。
  2. + *
  3. {@code baseVersion} 是乐观锁:与最新版本不符就拒绝({@code 40025}) + * 并把最新定义一起回传,由前端做差异合并。表单设计器是"打开 → 改很多处 → 保存", + * 中间可能好几分钟,直接覆盖会静默丢掉别人刚加的字段。
  4. + *
  5. 内置字段不可删({@code 40026})。判断依据是内置目录而不是上一版定义 —— + * 拿"上一版"当依据的话,客户端只要先删一次(如果上一版就缺)就绕过去了。 + * 不想收的字段应该用 {@code visibleWhen} 隐藏,而不是删掉。
  6. + *
+ */ +@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)