feat(fill): 草稿保存(§9.3 / §9.4)

POST /mp/submissions 取草稿(幂等)+ PUT /mp/submissions/{id} 保存草稿。
学生填了一半能存下来,再打开还在。

四条守住的规则:
1) **填报单必须属于调用者** —— 这个条件写进 SQL 的 WHERE(selectMineById),
   不是"查出来再判断"。后者一旦判断漏写就是越权。
2) **锁定字段服务端再拒一次**:前端已经渲染成只读,但请求可以伪造,
   而「报考工种」被改错的后果是机构直接不受理。两层防护里只有服务端这层是真的。
3) **改动留痕**(changed_fields):老师填的值被学生改过要记下来 ——
   审核时看到的是一个数字,无法判断该找谁(老师填错找老师,学生改错直接驳回)。
4) 已提交的填报单不能直接改,要先撤回。

本轮修掉的 4 个真问题(全是"不报错但结果错"的类型):

1) **changed_fields 是 jsonb,我当 varchar 用了**。存了逗号分隔字符串,
   PostgreSQL 报 `invalid input syntax for type json`,而错误信息里的
   "Token xxx is invalid" 完全指不到是这一列。已改为存 JSON 数组。
   ⚠️ 这已经是第三次被"列的真实类型与我的假设不符"咬到
   (前两次:expected_count NOT NULL、min_long_side NOT NULL)。
   教训:**写列之前查 information_schema 的实际类型,不要只读 DDL 文档。**

2) **changed_fields 把没动过的字段也标成"改过"**。原因是我拿"提交表的列值"
   和"名册值"比,而锁定字段的列**永远不写初值**、姓名/证件号/电话**根本没有列** ——
   全都读成 null,于是全被判成"改过"。留痕一旦充满噪音,真正的改动就没人看了。
   已改为:排除锁定字段(按定义不可能被改)与没有列的字段。

3) **学生自己填的"非预填字段"根本没被读回来**。我的合并只遍历了"名册有的键",
   于是在读学校填了、保存成功、再打开却没了。已改为按**表单字段全集**遍历,
   并明确三种情形:动过的预填字段用草稿值(空也算,那是清空)、
   自己填的用草稿值、其余用名册值。

4) **建草稿时没做初值,导致三种状态无法区分**(从没动过 / 改成了别的 / 清空了)——
   列值都是 null。表现是改动判不出、清空会被名册值顶回来。
   已在建草稿时把名册值写入"可改的预填字段"做初值,状态就自明了。

顺带修正一处**字段目录与 DDL 的真不一致**:
   表单定义把「联系电话」标为可改(PRD 把它列在「预填可改」里),
   但 collect_submission **没有 phone 列** —— 学生改了没地方存。
   一期先设为**只读**,并在代码里写明要真正开放修改需要加一列。
   不用"前端允许改、后端悄悄丢掉"糊过去 —— 那会让学生以为改成功了,
   实际提交的还是老号码。

另一处实现选择修正:锁定字段(报考职业/工种/等级)**要写进提交单**。
   它们恰恰是最重要的统计维度(按工种统计报名人数),不落列就统计不出来 ——
   而架构约束正是"内置字段落列才可统计"。写进去还带来一个好处:
   它是**提交时的快照**,老师之后改了名册,这份单子仍记得"他当时报的是四级"。

顺带修 E2E 的两条过时期望(回归抓到的):
   · 写死了 127.0.0.1,而 H5 应用地址已改成穿透域名 → 改为从配置读期望值;
   · fragment 改成 hash 路由格式后,token 解析没跟上。

验收:e2e-m3-draft 27/27、e2e-m3-gate 29/29、e2e-m3-h5-auth 23/23。
This commit is contained in:
2026-09-29 11:41:40 +08:00
parent eff5a761bf
commit 5de9ccc100
9 changed files with 969 additions and 10 deletions
@@ -10,7 +10,6 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 学生端填报(《03-接口设计》§9.x)。
*
@@ -23,6 +22,7 @@ import org.springframework.web.bind.annotation.RestController;
public class FillController {
private final FillService fillService;
private final cn.tianbaotong.fill.module.fill.service.SubmissionService submissionService;
@GetMapping("/tasks/{id}/form")
@Operation(summary = "表单结构 + 当前值",
@@ -31,4 +31,28 @@ public class FillController {
public ApiResult<StudentFormVO> form(@PathVariable Long id) {
return ApiResult.ok(fillService.getForm(id));
}
@org.springframework.web.bind.annotation.PostMapping("/submissions")
@Operation(summary = "取草稿(幂等)",
description = "存在则返回既有草稿、不存在则创建。学生反复点「开始填写」不该产生多份")
public ApiResult<cn.tianbaotong.fill.module.fill.dto.DraftDtos.CreateVO> createDraft(
@org.springframework.web.bind.annotation.RequestBody java.util.Map<String, Long> body) {
Long taskId = body == null ? null : body.get("taskId");
if (taskId == null) {
throw new cn.tianbaotong.fill.common.exception.BizException(
cn.tianbaotong.fill.common.api.ErrorCode.VALIDATION_BLOCKED, "缺少 taskId");
}
return ApiResult.ok(submissionService.createOrGet(taskId));
}
@org.springframework.web.bind.annotation.PutMapping("/submissions/{id}")
@Operation(summary = "保存草稿",
description = "只传变化的字段,服务端合并。锁定字段被改动会拒绝(服务端再拦一次,"
+ "因为前端只读是可以绕过的)。已提交的填报单不能直接改,要先撤回")
public ApiResult<Void> saveDraft(@PathVariable Long id,
@org.springframework.web.bind.annotation.RequestBody
cn.tianbaotong.fill.module.fill.dto.DraftDtos.SaveReq req) {
submissionService.saveDraft(id, req);
return ApiResult.ok();
}
}
@@ -0,0 +1,49 @@
package cn.tianbaotong.fill.module.fill.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.Map;
/** 草稿相关请求/响应(《03-接口设计》§9.3 / §9.4)。 */
public final class DraftDtos {
private DraftDtos() {
}
/** §9.3 取草稿的响应。 */
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "填报单")
public static class CreateVO {
private Long id;
private Integer formVersion;
/** true = 本次新建;false = 返回了既有草稿(幂等)。 */
private Boolean created;
}
/** §9.4 保存草稿。 */
@Getter
@Setter
@Schema(description = "保存草稿")
public static class SaveReq {
/**
* 表单值,<b>只传变化的字段</b>,服务端做合并。
*
* <p>键必须是表单定义里存在的字段 —— 传了不认识的键会拒绝而不是静默忽略:
* 静默忽略会让"我明明填了却没保存上"这种问题极难定位。
*/
private Map<String, Object> formData;
/** 扩展字段的值(只接受表单定义里声明过的扩展键)。 */
private Map<String, Object> extrasJson;
/** 客户端时间,用于冲突提示(本轮未使用,保留契约字段)。 */
private String clientTime;
}
}
@@ -0,0 +1,116 @@
package cn.tianbaotong.fill.module.fill.entity;
import cn.tianbaotong.fill.support.TenantEntity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 填报单(对应 {@code collect_submission})—— 一个学生在一个任务下的那一份材料。
*
* <p><b>身份由名册决定,不由本人声明</b>:{@code rosterId} 指向门禁核验时绑定上的
* 名册行,姓名与证件号都是从那里来的。学生填的只是"这个人的其余信息"。
* 所以这张表里<b>没有</b> {@code real_name}/{@code id_card_no} —— 那两项始终以名册为准,
* 存两份就会有两份不一致的可能。
*
* <p>⚠️ <b>内置字段落列、扩展字段落 {@code extras_json}</b>,这是架构约束不是取舍:
* 只有落列的字段才可查询、可索引、可统计。界面上一直明示"扩展字段不参与核心统计",
* 否则校长会发现报表里少了一列,而那时已经填了几百份。
*/
@Getter
@Setter
@TableName("collect_submission")
public class CollectSubmission extends TenantEntity {
private Long taskId;
/** 绑定的名册行 —— 身份的唯一来源。 */
private Long rosterId;
private Long orgId;
/** 填报人(微信账号)。 */
private Long submitterId;
/** 填写时用的表单版本,留痕用。 */
private Integer formVersion;
/** 表单值快照(jsonb)。列上已有值的字段以列为准,这里用于导出与留痕。 */
private String formData;
// ── 内置字段(可统计)───────────────────────
private String occupation;
private String worktype;
private String jobLevel;
private String education;
/** 是否在校生:{@code 0} 否 / {@code 1} 是。 */
private Integer isStudent;
private String school;
private String major;
private LocalDate enrollDate;
private String workUnit;
private Integer isRetake;
private String retakeTheory;
private String retakePractice;
private String retakeOverall;
private Integer workYears;
private String certOccupation;
private String certLevel;
private String certNo;
/** 扩展字段的值(不参与统计)。 */
private String extrasJson;
/**
* 学生改过的预填字段,逗号分隔。
*
* <p>「老师已填、学生改过」这件事必须留痕 —— 否则审核时看到的是一个数字,
* 无法判断它是老师填的还是学生改的。而这两种情况的处理完全不同:
* 老师填错要找老师,学生改错直接驳回就行。
*/
private String changedFields;
/** {@code 0} 草稿 / {@code 1} 待初审 / {@code 2} 待终审 / {@code 3} 已通过 / {@code 4} 已驳回 / {@code 5} 已归档。 */
private Integer status;
private Integer rejectCount;
private String lastRejectReason;
private Integer redCount;
private Integer yellowCount;
private LocalDateTime submitTime;
private Long classAuditBy;
private Long adminAuditBy;
public static final int STATUS_DRAFT = 0;
public static final int STATUS_PENDING_CLASS = 1;
public static final int STATUS_PENDING_ADMIN = 2;
public static final int STATUS_APPROVED = 3;
public static final int STATUS_REJECTED = 4;
public static final int STATUS_ARCHIVED = 5;
}
@@ -0,0 +1,45 @@
package cn.tianbaotong.fill.module.fill.mapper;
import cn.tianbaotong.fill.module.fill.entity.CollectSubmission;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
public interface SubmissionMapper extends BaseMapper<CollectSubmission> {
/**
* 取某人在某任务下的填报单(一个学生在一个任务下只有一份)。
*
* <p>用显式 SQL 而不是条件构造器,原因与门禁一致:{@code tenant_id} 与
* {@code submitter_id} 都是"必须带上"的过滤条件,写成显式参数让它们
* 在一个地方可审查 —— 少一个条件就是"能看到别人的填报单"。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT * FROM collect_submission
WHERE tenant_id = #{tenantId} AND task_id = #{taskId}
AND submitter_id = #{submitterId} AND deleted = 0
ORDER BY id DESC
LIMIT 1
""")
CollectSubmission selectMine(@Param("tenantId") Long tenantId,
@Param("taskId") Long taskId,
@Param("submitterId") Long submitterId);
/**
* 按 id 取,且<b>必须属于我</b>。
*
* <p>不要写成"先按 id 查、再判断是不是我的" —— 那样一旦判断漏写就是越权。
* 把 {@code submitter_id} 放进 WHERE 里,越权在 SQL 层就不可能发生。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT * FROM collect_submission
WHERE id = #{id} AND tenant_id = #{tenantId}
AND submitter_id = #{submitterId} AND deleted = 0
""")
CollectSubmission selectMineById(@Param("id") Long id,
@Param("tenantId") Long tenantId,
@Param("submitterId") Long submitterId);
}
@@ -5,6 +5,8 @@ import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.fill.dto.StudentFormVO;
import cn.tianbaotong.fill.module.fill.entity.CollectSubmission;
import cn.tianbaotong.fill.module.fill.mapper.SubmissionMapper;
import cn.tianbaotong.fill.module.form.dto.FormFieldVO;
import cn.tianbaotong.fill.module.form.dto.FormSchemaVO;
import cn.tianbaotong.fill.module.form.service.FormSchemaService;
@@ -20,6 +22,7 @@ import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -58,6 +61,7 @@ public class FillService {
private final FormSchemaService formSchemaService;
private final MaterialDefService materialDefService;
private final MpRosterMapper rosterMapper;
private final SubmissionMapper submissionMapper;
public StudentFormVO getForm(Long taskId) {
LoginUser me = UserContext.require();
@@ -79,14 +83,59 @@ public class FillService {
}
FormSchemaVO schema = formSchemaService.get(taskId);
Map<String, Object> values = rosterValues(roster);
Map<String, Object> values = rosterValuesOf(roster);
StudentFormVO vo = new StudentFormVO();
vo.setTaskId(taskId);
vo.setTaskName(task.getTaskName());
vo.setFormVersion(schema.getVersion());
vo.setSubmissionStatus(0);
// TODO(下一段):取草稿单,把学生存过的值覆盖到 values 上
vo.setSubmissionStatus(CollectSubmission.STATUS_DRAFT);
// 学生自己存过的草稿覆盖名册值。
// ⚠️ 关键在**怎么判断"这个字段学生动过"**:草稿列是 null 时无法区分
// "从没填过"和"填了又清空"。所以用 changed_fields —— 它记录的正是
// "学生动过的预填字段"。在里面的字段以草稿为准(哪怕是空),
// 不在里面的字段以名册为准。这也是 changed_fields 的第二个用途。
CollectSubmission draft = submissionMapper.selectMine(me.tenantId(), taskId, me.uid());
Set<String> touched = new LinkedHashSet<>();
if (draft != null) {
vo.setSubmissionId(draft.getId());
vo.setSubmissionStatus(draft.getStatus());
// changed_fields 是 jsonb(JSON 数组),不是逗号分隔字符串
if (draft.getChangedFields() != null && !draft.getChangedFields().isBlank()) {
try {
List<String> keys = cn.hutool.json.JSONUtil.toList(draft.getChangedFields(), String.class);
for (String k : keys) {
if (k != null && !k.isBlank()) {
touched.add(k.trim());
}
}
} catch (Exception e) {
log.warn("changed_fields 不是合法 JSON 数组,忽略:{}", draft.getChangedFields());
}
}
// 草稿值怎么覆盖名册值 —— 三种情形(配合建草稿时的"预填初值"):
// ① 学生动过的预填字段(在 changed_fields 里)→ 用草稿值,**空也算**(那是清空)
// ② 学生自己填的(名册没有的,如在读学校)→ 草稿值非空就用它
// ③ 其余 → 名册值
// ⚠️ 只遍历"名册有的键"会漏掉情形 ② —— 实测表现是"学生在读学校填了,
// 保存成功,再打开却没了"。所以按**表单字段全集**遍历。
for (String key : schema.getFields().stream().map(FormFieldVO::getKey).toList()) {
if (!SubmissionService.SubValues.hasColumn(key)) {
continue;
}
Object v = SubmissionService.SubValues.read(draft, key);
if (touched.contains(key)) {
if (v == null) {
values.remove(key);
} else {
values.put(key, v);
}
} else if (v != null) {
values.put(key, v);
}
}
}
StudentFormVO.Section basic = new StudentFormVO.Section();
basic.setTitle("基本信息");
@@ -134,8 +183,12 @@ public class FillService {
*
* <p>键名与表单字段键、与 {@code collect_submission} 的列名三者一致 ——
* 这是「内置字段可统计」的实现基础,任何一处不一致都会让统计口径出现缺口。
*
* <p>public 是给 {@code SubmissionService} 复用的:保存草稿时要用<b>同一份</b>
* 映射去判断"锁定字段有没有被改"以及"哪些预填字段被改过"。
* 两处各写一份的话,改了名册映射就会漏掉一处,而漏掉的表现是留痕不准。
*/
private static Map<String, Object> rosterValues(CollectRoster r) {
public static Map<String, Object> rosterValuesOf(CollectRoster r) {
Map<String, Object> m = new LinkedHashMap<>();
putIfPresent(m, "seq_no", r.getSeqNo());
putIfPresent(m, "real_name", r.getRealName());
@@ -0,0 +1,400 @@
package cn.tianbaotong.fill.module.fill.service;
import cn.hutool.json.JSONUtil;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.fill.dto.DraftDtos;
import cn.tianbaotong.fill.module.fill.entity.CollectSubmission;
import cn.tianbaotong.fill.module.fill.mapper.SubmissionMapper;
import cn.tianbaotong.fill.module.form.dto.FormFieldVO;
import cn.tianbaotong.fill.module.form.dto.FormSchemaVO;
import cn.tianbaotong.fill.module.form.service.FormSchemaService;
import cn.tianbaotong.fill.module.mp.mapper.MpRosterMapper;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
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.time.LocalDate;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* 草稿的建立与保存(《03-接口设计》§9.3 / §9.4)。
*
* <p><b>三处不能省的判断</b>:
* <ol>
* <li><b>填报单必须属于调用者</b> —— 这个条件写在 SQL 的 WHERE 里
* (见 {@code SubmissionMapper.selectMineById}),不是"查出来再判断"。
* 后者一旦判断漏写就是越权。</li>
* <li><b>锁定字段服务端再拒一次</b>。前端已经渲染成只读了,但服务端不能因此放行:
* 请求是可以伪造的,而「报考工种」被改错的后果是机构直接不受理。
* 两层防护里,只有服务端这层是真的。</li>
* <li><b>改动要留痕</b>({@code changed_fields}):老师填的值被学生改过,
* 审核时看到的是一个数字,无法判断该找谁 —— 老师填错要找老师,
* 学生改错直接驳回就行。</li>
* </ol>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SubmissionService {
private final SubmissionMapper submissionMapper;
private final TaskMapper taskMapper;
private final FormSchemaService formSchemaService;
private final MpRosterMapper rosterMapper;
// ── §9.3 取草稿(幂等)────────────────────────
@Transactional(rollbackFor = Exception.class)
public DraftDtos.CreateVO createOrGet(Long taskId) {
LoginUser me = UContext.require();
requireFillableTask(taskId);
CollectRoster roster = requireGate(me.tenantId(), taskId, me.uid());
CollectSubmission exist = submissionMapper.selectMine(me.tenantId(), taskId, me.uid());
if (exist != null) {
// 幂等:学生反复点"开始填写"不该产生多份草稿
return new DraftDtos.CreateVO(exist.getId(), exist.getFormVersion(), false);
}
FormSchemaVO schema = formSchemaService.get(taskId);
CollectSubmission s = new CollectSubmission();
s.setTaskId(taskId);
s.setRosterId(roster.getId());
s.setOrgId(roster.getOrgId());
s.setSubmitterId(me.uid());
s.setFormVersion(schema.getVersion());
s.setStatus(CollectSubmission.STATUS_DRAFT);
s.setRejectCount(0);
// ⚠️ 把"可改的预填字段"用名册值做**初值**。
//
// 不做这一步,草稿列全是 null,于是**三种状态无法区分**:
// "从没动过" / "改成了别的" / "清空了" —— 列值都是 null(或非 null 但无从对比)。
// 表现是两种 bug 同时出现:改过的判不出、清空的会被名册值顶回来。
//
// 做了初值之后状态是自明的:
// null(不可改的字段,本来就不写) / 等于名册值(没动过) / 不等于(改过或清空)
Map<String, FormFieldVO> defs = new LinkedHashMap<>();
for (FormFieldVO f : schema.getFields()) {
defs.put(f.getKey(), f);
}
Map<String, Object> rv = FillService.rosterValuesOf(roster);
for (Map.Entry<String, Object> e : rv.entrySet()) {
if (!SubmissionService.SubValues.hasColumn(e.getKey())) {
continue; // 提交表没有这一列(如联系电话)
}
// ⚠️ 锁定字段**也要**写进提交单。
// 一开始我只写了"可改的预填字段",但漏了关键一点:
// 「报考职业/工种/等级」恰恰是**最重要的统计维度**(按工种统计报名人数),
// 不落列就统计不出来 —— 而架构约束正是"内置字段落列才可统计"。
// 写进去还有一个好处:它是**提交时的快照**。
// 老师之后改了名册,这份单子仍然记得"他当时报的是四级"。
SubmissionService.SubValues.apply(s, e.getKey(), e.getValue());
}
submissionMapper.insert(s);
log.info("建草稿:task={} roster={} user={} id={}", taskId, roster.getId(), me.uid(), s.getId());
return new DraftDtos.CreateVO(s.getId(), s.getFormVersion(), true);
}
// ── §9.4 保存草稿 ────────────────────────────
@Transactional(rollbackFor = Exception.class)
public void saveDraft(Long id, DraftDtos.SaveReq req) {
LoginUser me = UContext.require();
CollectSubmission sub = submissionMapper.selectMineById(id, me.tenantId(), me.uid());
if (sub == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "填报单不存在或不属于你");
}
requireFillableTask(sub.getTaskId());
if (sub.getStatus() != null && sub.getStatus() != CollectSubmission.STATUS_DRAFT) {
// 已提交的单子不能再改 —— 要改必须先撤回(§9.11)
throw new BizException(ErrorCode.AUDIT_IN_PROGRESS, "已提交的填报单不能直接修改,请先撤回");
}
CollectRoster roster = requireGate(me.tenantId(), sub.getTaskId(), me.uid());
FormSchemaVO schema = formSchemaService.get(sub.getTaskId());
Map<String, FormFieldVO> defs = new LinkedHashMap<>();
for (FormFieldVO f : schema.getFields()) {
defs.put(f.getKey(), f);
}
for (FormFieldVO f : schema.getExtras()) {
defs.put(f.getKey(), f);
}
Map<String, Object> rosterVals = FillService.rosterValuesOf(roster);
Map<String, Object> incoming = req.getFormData() == null ? Map.of() : req.getFormData();
for (Map.Entry<String, Object> e : incoming.entrySet()) {
String key = e.getKey();
FormFieldVO def = defs.get(key);
if (def == null) {
// 不静默忽略:静默忽略会让"我明明填了却没保存上"极难定位
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"表单里没有这个字段:" + key);
}
if (Boolean.TRUE.equals(def.getLocked())) {
if (!sameValue(e.getValue(), rosterVals.get(key))) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"「" + def.getLabel() + "」由老师确定,不能修改;如有误请联系班主任");
}
continue; // 与名册一致,不必写
}
SubValues.apply(sub, key, e.getValue());
}
// 扩展字段:只接受表单定义里声明过的键(否则就是往库里塞任意 JSON)
if (req.getExtrasJson() != null && !req.getExtrasJson().isEmpty()) {
Map<String, Object> extras = new LinkedHashMap<>();
for (Map.Entry<String, Object> e : req.getExtrasJson().entrySet()) {
FormFieldVO def = defs.get(e.getKey());
if (def == null || Boolean.TRUE.equals(def.getBuiltin())) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"不是本表单的扩展字段:" + e.getKey());
}
extras.put(e.getKey(), e.getValue());
}
sub.setExtrasJson(JSONUtil.toJsonStr(extras));
}
// 留痕:名册里老师填过、而现在值不一样的字段。
// ⚠️ changed_fields 是 **jsonb**(不是逗号分隔字符串)—— 存错类型会直接报
// `invalid input syntax for type json`,而错误信息里的 "Token xxx is invalid"
// 完全指不到是这一列的问题。所以这里用 JSON 数组。
Set<String> changed = new LinkedHashSet<>();
for (Map.Entry<String, FormFieldVO> e : defs.entrySet()) {
FormFieldVO def = e.getValue();
if (!Boolean.TRUE.equals(def.getBuiltin())) {
continue;
}
// ⚠️ 锁定字段按定义就不可能被改(上面已经拒掉了改动),而且它们的提交表列
// **永远不写初值** —— 拿"读不到(null)"和"名册有值"比,会把它们
// 全部误判成"学生改过"。实测就是这样:changed_fields 里出现了
// occupation/worktype/job_level,而学生从没碰过。
// 留痕一旦充满噪音,真正的改动就没人看了 —— 那它的价值就没了。
if (Boolean.TRUE.equals(def.getLocked())) {
continue;
}
// 提交表没有这一列的(如联系电话)也不比,理由同上
if (!SubValues.hasColumn(e.getKey())) {
continue;
}
Object rosterVal = rosterVals.get(e.getKey());
if (rosterVal == null) {
continue; // 名册本来就没填 → 不算"改了老师填的"
}
if (!sameValue(SubValues.read(sub, e.getKey()), rosterVal)) {
changed.add(e.getKey());
}
}
sub.setChangedFields(changed.isEmpty() ? null : JSONUtil.toJsonStr(changed));
// ⚠️ 用 UpdateWrapper 显式写每个值列:updateById 会跳过 null,
// 而"学生把已填的学历清空"正是一次把字段置 null 的操作 ——
// 用 updateById 会静默不生效(接口成功、库里没变),最难查的一类 bug
submissionMapper.update(null, Wrappers.<CollectSubmission>lambdaUpdate()
.eq(CollectSubmission::getId, sub.getId())
.set(CollectSubmission::getOccupation, sub.getOccupation())
.set(CollectSubmission::getWorktype, sub.getWorktype())
.set(CollectSubmission::getJobLevel, sub.getJobLevel())
.set(CollectSubmission::getEducation, sub.getEducation())
.set(CollectSubmission::getIsStudent, sub.getIsStudent())
.set(CollectSubmission::getSchool, sub.getSchool())
.set(CollectSubmission::getMajor, sub.getMajor())
.set(CollectSubmission::getEnrollDate, sub.getEnrollDate())
.set(CollectSubmission::getWorkUnit, sub.getWorkUnit())
.set(CollectSubmission::getIsRetake, sub.getIsRetake())
.set(CollectSubmission::getRetakeTheory, sub.getRetakeTheory())
.set(CollectSubmission::getRetakePractice, sub.getRetakePractice())
.set(CollectSubmission::getRetakeOverall, sub.getRetakeOverall())
.set(CollectSubmission::getWorkYears, sub.getWorkYears())
.set(CollectSubmission::getCertOccupation, sub.getCertOccupation())
.set(CollectSubmission::getCertLevel, sub.getCertLevel())
.set(CollectSubmission::getCertNo, sub.getCertNo())
.set(CollectSubmission::getExtrasJson, sub.getExtrasJson())
.set(CollectSubmission::getChangedFields, sub.getChangedFields()));
}
// ── 内部 ──────────────────────────────────────
private void requireFillableTask(Long taskId) {
CollectTask task = taskMapper.selectById(taskId);
if (task == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除");
}
if (task.getStatus() == null || task.getStatus() != CollectTask.STATUS_COLLECTING) {
throw new BizException(ErrorCode.TASK_NOT_OPEN);
}
}
private CollectRoster requireGate(Long tenantId, Long taskId, Long userId) {
CollectRoster roster = rosterMapper.selectBoundByUser(tenantId, taskId, userId);
if (roster == null) {
throw new BizException(ErrorCode.GATE_NOT_MATCHED,
"请先核对身份(填写姓名与身份证号)再填写材料");
}
return roster;
}
/**
* 值比较:把 {@code null}、空串、纯空白视作同一个"没填"。
*
* <p>不这么做的话,学生把输入框里老师填的内容删空再原样打一遍(或者前后多一个空格)
* 就会被判成"改过" — 留痕会充满噪音,而留痕的价值就在于让人一眼看到真正的改动。
*/
static boolean sameValue(Object a, Object b) {
String x = a == null ? "" : String.valueOf(a).trim();
String y = b == null ? "" : String.valueOf(b).trim();
if (x.equals(y)) {
return true;
}
// 布尔的两端表示:库里是 0/1,前端传 true/false
return normalizeBool(x).equals(normalizeBool(y));
}
private static String normalizeBool(String s) {
return switch (s.toLowerCase()) {
case "true" -> "1";
case "false" -> "0";
default -> s;
};
}
/** 只为让依赖注入的 {@code UserContext} 不被静态导入污染。 */
private static final class UContext {
static LoginUser require() {
return cn.tianbaotong.fill.common.context.UserContext.require();
}
}
/**
* 内置字段的"按 key 读/写"。
*
* <p>抽出来是因为读写各要一遍 switch,散在两个方法里迟早漏字段 ——
* 而漏掉的表现是"某一项填了没保存",且不报错。
*/
static final class SubValues {
/**
* 这个字段在 {@code collect_submission} 里有没有对应的可写列。
*
* <p>⚠️ 姓名、身份证号、联系电话<b>只存在于名册</b>,提交表里没有这三列 ——
* 所以它们既不该被比较(会造成假的"改动留痕"),也不该被写入。
* 判断"列存在"而不是"我实现了读写的分支",是为了让这个事实<b>只有一个来源</b>:
* 加字段时先看表有没有这一列,而不是看代码里有没有 case。
*/
static boolean hasColumn(String key) {
return switch (key) {
case "occupation", "worktype", "job_level", "education", "is_student",
"school", "major", "enroll_date", "work_unit", "is_retake",
"retake_theory", "retake_practice", "retake_overall", "work_years",
"cert_occupation", "cert_level", "cert_no" -> true;
default -> false;
};
}
static void apply(CollectSubmission s, String key, Object v) {
String str = v == null ? null : String.valueOf(v);
switch (key) {
case "occupation" -> s.setOccupation(blankToNull(str));
case "worktype" -> s.setWorktype(blankToNull(str));
case "job_level" -> s.setJobLevel(blankToNull(str));
case "education" -> s.setEducation(blankToNull(str));
case "is_student" -> s.setIsStudent(boolToInt(v));
case "school" -> s.setSchool(blankToNull(str));
case "major" -> s.setMajor(blankToNull(str));
case "enroll_date" -> s.setEnrollDate(parseDate(str));
case "work_unit" -> s.setWorkUnit(blankToNull(str));
case "is_retake" -> s.setIsRetake(boolToInt(v));
case "retake_theory" -> s.setRetakeTheory(blankToNull(str));
case "retake_practice" -> s.setRetakePractice(blankToNull(str));
case "retake_overall" -> s.setRetakeOverall(blankToNull(str));
case "work_years" -> s.setWorkYears(parseInt(str));
case "cert_occupation" -> s.setCertOccupation(blankToNull(str));
case "cert_level" -> s.setCertLevel(blankToNull(str));
case "cert_no" -> s.setCertNo(blankToNull(str));
default -> {
// 姓名/证件号/序号等来自名册,学生端不该写;真传了也应被上游的 locked 拦掉。
// 走到这里说明 schema 没标 locked —— 那是配置问题,静默丢弃比写入更安全
}
}
}
static Object read(CollectSubmission s, String key) {
return switch (key) {
case "occupation" -> s.getOccupation();
case "worktype" -> s.getWorktype();
case "job_level" -> s.getJobLevel();
case "education" -> s.getEducation();
case "is_student" -> s.getIsStudent();
case "school" -> s.getSchool();
case "major" -> s.getMajor();
case "enroll_date" -> s.getEnrollDate() == null ? null : s.getEnrollDate().toString();
case "work_unit" -> s.getWorkUnit();
case "is_retake" -> s.getIsRetake();
case "retake_theory" -> s.getRetakeTheory();
case "retake_practice" -> s.getRetakePractice();
case "retake_overall" -> s.getRetakeOverall();
case "work_years" -> s.getWorkYears();
case "cert_occupation" -> s.getCertOccupation();
case "cert_level" -> s.getCertLevel();
case "cert_no" -> s.getCertNo();
default -> null;
};
}
private static String blankToNull(String s) {
if (s == null) {
return null;
}
String t = s.trim();
return t.isEmpty() ? null : t;
}
private static Integer boolToInt(Object v) {
if (v == null) {
return null;
}
if (v instanceof Boolean b) {
return b ? 1 : 0;
}
String s = String.valueOf(v).trim();
if (s.isEmpty()) {
return null;
}
return ("1".equals(s) || "true".equalsIgnoreCase(s)) ? 1 : 0;
}
private static Integer parseInt(String s) {
if (s == null || s.isBlank()) {
return null;
}
try {
return Integer.valueOf(s.trim());
} catch (NumberFormatException e) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "「本职业工作年限」只能填阿拉伯数字");
}
}
private static LocalDate parseDate(String s) {
if (s == null || s.isBlank()) {
return null;
}
try {
return LocalDate.parse(s.trim());
} catch (Exception e) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "「入学时间」格式应为 YYYY-MM-DD");
}
}
}
}
@@ -83,9 +83,14 @@ public final class BuiltinFields {
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");
// 联系电话:来自名册(预填),但**提交表没有 phone 列** —— 学生改了没地方存。
// ⚠️ 这是字段目录与 DDL 的一处真不一致(PRD 把"联系电话"列在「预填可改」里)。
// 一期先设为**只读**,与"如号码有误请联系班主任"一致;
// 要真正开放修改,需要给 collect_submission 加一列 phone
// (或把这类"无列可存的预填项"统一落到 form_data)。
// 先记下来,不要用"前端允许改、后端悄悄丢掉"来糊过去 ——
// 那会让学生以为改成功了,而实际提交的还是老号码。
final FormFieldVO phone = roster("phone", "联系电话", "phone", true, true, ++sort * 10);
list.add(phone);
// F 工作单位:官方原文「没有则填"无"」。