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:
+25
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
+116
@@ -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;
|
||||
}
|
||||
+45
@@ -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);
|
||||
}
|
||||
+57
-4
@@ -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());
|
||||
|
||||
+400
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -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 工作单位:官方原文「没有则填"无"」。
|
||||
|
||||
Reference in New Issue
Block a user