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.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 学生端填报(《03-接口设计》§9.x)。
|
* 学生端填报(《03-接口设计》§9.x)。
|
||||||
*
|
*
|
||||||
@@ -23,6 +22,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
public class FillController {
|
public class FillController {
|
||||||
|
|
||||||
private final FillService fillService;
|
private final FillService fillService;
|
||||||
|
private final cn.tianbaotong.fill.module.fill.service.SubmissionService submissionService;
|
||||||
|
|
||||||
@GetMapping("/tasks/{id}/form")
|
@GetMapping("/tasks/{id}/form")
|
||||||
@Operation(summary = "表单结构 + 当前值",
|
@Operation(summary = "表单结构 + 当前值",
|
||||||
@@ -31,4 +31,28 @@ public class FillController {
|
|||||||
public ApiResult<StudentFormVO> form(@PathVariable Long id) {
|
public ApiResult<StudentFormVO> form(@PathVariable Long id) {
|
||||||
return ApiResult.ok(fillService.getForm(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.context.UserContext;
|
||||||
import cn.tianbaotong.fill.common.exception.BizException;
|
import cn.tianbaotong.fill.common.exception.BizException;
|
||||||
import cn.tianbaotong.fill.module.fill.dto.StudentFormVO;
|
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.FormFieldVO;
|
||||||
import cn.tianbaotong.fill.module.form.dto.FormSchemaVO;
|
import cn.tianbaotong.fill.module.form.dto.FormSchemaVO;
|
||||||
import cn.tianbaotong.fill.module.form.service.FormSchemaService;
|
import cn.tianbaotong.fill.module.form.service.FormSchemaService;
|
||||||
@@ -20,6 +22,7 @@ import org.springframework.stereotype.Service;
|
|||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -58,6 +61,7 @@ public class FillService {
|
|||||||
private final FormSchemaService formSchemaService;
|
private final FormSchemaService formSchemaService;
|
||||||
private final MaterialDefService materialDefService;
|
private final MaterialDefService materialDefService;
|
||||||
private final MpRosterMapper rosterMapper;
|
private final MpRosterMapper rosterMapper;
|
||||||
|
private final SubmissionMapper submissionMapper;
|
||||||
|
|
||||||
public StudentFormVO getForm(Long taskId) {
|
public StudentFormVO getForm(Long taskId) {
|
||||||
LoginUser me = UserContext.require();
|
LoginUser me = UserContext.require();
|
||||||
@@ -79,14 +83,59 @@ public class FillService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
FormSchemaVO schema = formSchemaService.get(taskId);
|
FormSchemaVO schema = formSchemaService.get(taskId);
|
||||||
Map<String, Object> values = rosterValues(roster);
|
Map<String, Object> values = rosterValuesOf(roster);
|
||||||
|
|
||||||
StudentFormVO vo = new StudentFormVO();
|
StudentFormVO vo = new StudentFormVO();
|
||||||
vo.setTaskId(taskId);
|
vo.setTaskId(taskId);
|
||||||
vo.setTaskName(task.getTaskName());
|
vo.setTaskName(task.getTaskName());
|
||||||
vo.setFormVersion(schema.getVersion());
|
vo.setFormVersion(schema.getVersion());
|
||||||
vo.setSubmissionStatus(0);
|
vo.setSubmissionStatus(CollectSubmission.STATUS_DRAFT);
|
||||||
// TODO(下一段):取草稿单,把学生存过的值覆盖到 values 上
|
|
||||||
|
// 学生自己存过的草稿覆盖名册值。
|
||||||
|
// ⚠️ 关键在**怎么判断"这个字段学生动过"**:草稿列是 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();
|
StudentFormVO.Section basic = new StudentFormVO.Section();
|
||||||
basic.setTitle("基本信息");
|
basic.setTitle("基本信息");
|
||||||
@@ -134,8 +183,12 @@ public class FillService {
|
|||||||
*
|
*
|
||||||
* <p>键名与表单字段键、与 {@code collect_submission} 的列名三者一致 ——
|
* <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<>();
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
putIfPresent(m, "seq_no", r.getSeqNo());
|
putIfPresent(m, "seq_no", r.getSeqNo());
|
||||||
putIfPresent(m, "real_name", r.getRealName());
|
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,
|
list.add(submission("education", "学历", "select", true, false, ++sort * 10,
|
||||||
opts(EDU_LEVEL), null, null));
|
opts(EDU_LEVEL), null, null));
|
||||||
|
|
||||||
// E 联系电话:来自名册(预填),但学生可以改 —— 名册里的号码常是家长的老号码
|
// 联系电话:来自名册(预填),但**提交表没有 phone 列** —— 学生改了没地方存。
|
||||||
final FormFieldVO phone = roster("phone", "联系电话", "phone", true, false, ++sort * 10);
|
// ⚠️ 这是字段目录与 DDL 的一处真不一致(PRD 把"联系电话"列在「预填可改」里)。
|
||||||
phone.setType("phone");
|
// 一期先设为**只读**,与"如号码有误请联系班主任"一致;
|
||||||
|
// 要真正开放修改,需要给 collect_submission 加一列 phone
|
||||||
|
// (或把这类"无列可存的预填项"统一落到 form_data)。
|
||||||
|
// 先记下来,不要用"前端允许改、后端悄悄丢掉"来糊过去 ——
|
||||||
|
// 那会让学生以为改成功了,而实际提交的还是老号码。
|
||||||
|
final FormFieldVO phone = roster("phone", "联系电话", "phone", true, true, ++sort * 10);
|
||||||
list.add(phone);
|
list.add(phone);
|
||||||
|
|
||||||
// F 工作单位:官方原文「没有则填"无"」。
|
// F 工作单位:官方原文「没有则填"无"」。
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
草稿保存 验收测试(《03-接口设计》§9.3 / §9.4)。
|
||||||
|
|
||||||
|
[x] 取草稿是幂等的(反复调用只产生一份)
|
||||||
|
[x] 保存草稿:只传变化的字段,服务端合并
|
||||||
|
[x] **锁定字段服务端再拒一次**(前端只读是可以绕过的)
|
||||||
|
[x] 改动留痕:只有"老师填过且学生改过"的字段进 changed_fields
|
||||||
|
[x] 学生把预填字段**清空**也要能落库(updateById 会静默跳过 null)
|
||||||
|
[x] 未过门禁不能建草稿;已提交的不能直接改
|
||||||
|
[x] 草稿值会覆盖名册值回显,且"清空过的"字段不会被名册值顶回来
|
||||||
|
|
||||||
|
跑法:python scripts/e2e-m3-draft.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
DEV_YML = os.path.join(ROOT, "backend", "xt-server", "src", "main", "resources", "application-dev.yml")
|
||||||
|
PG = os.environ.get("XT_PG_CONTAINER", "xt-pg")
|
||||||
|
|
||||||
|
PASSED: list[str] = []
|
||||||
|
FAILED: list[tuple[str, str]] = []
|
||||||
|
CREATED: list[int] = []
|
||||||
|
|
||||||
|
|
||||||
|
def ok(n: str) -> None:
|
||||||
|
PASSED.append(n)
|
||||||
|
print(f" [OK] {n}")
|
||||||
|
|
||||||
|
|
||||||
|
def fail(n: str, d: str) -> None:
|
||||||
|
FAILED.append((n, d))
|
||||||
|
print(f" [FAIL] {n}\n {d}")
|
||||||
|
|
||||||
|
|
||||||
|
def check(n: str, cond: bool, d: str = "") -> bool:
|
||||||
|
if cond:
|
||||||
|
ok(n)
|
||||||
|
else:
|
||||||
|
fail(n, d or "断言不成立")
|
||||||
|
return cond
|
||||||
|
|
||||||
|
|
||||||
|
def sql(q: str) -> str:
|
||||||
|
r = subprocess.run(
|
||||||
|
["docker", "exec", "-e", "PGPASSWORD=fill_dev_pwd", PG, "psql", "-U", "fill",
|
||||||
|
"-d", "filldb", "-t", "-A", "-c", q], capture_output=True, text=True, timeout=40)
|
||||||
|
return (r.stdout or r.stderr).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def read_cfg() -> dict:
|
||||||
|
files = [DEV_YML, os.path.join(os.path.dirname(DEV_YML), "application.yml")]
|
||||||
|
text = "\n".join(open(f, encoding="utf-8").read() for f in files if os.path.exists(f))
|
||||||
|
out = {}
|
||||||
|
for k, p in [("jwt", r"^\s*secret:\s*(\S+)\s*$"), ("issuer", r"^\s*issuer:\s*(\S+)\s*$")]:
|
||||||
|
m = re.search(p, text, re.M)
|
||||||
|
if not m:
|
||||||
|
raise SystemExit(f"找不到 {k}")
|
||||||
|
out[k] = m.group(1)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def b64u(b: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
|
||||||
|
|
||||||
|
|
||||||
|
def mint(secret: str, issuer: str, uid: int, tenant: int, roles: list[str], utype: int) -> str:
|
||||||
|
key = base64.b64decode(secret)
|
||||||
|
now = int(time.time())
|
||||||
|
signing = (b64u(json.dumps({"alg": "HS512"}, separators=(",", ":")).encode()) + "."
|
||||||
|
+ b64u(json.dumps({"iss": issuer, "sub": str(uid), "tenantId": tenant,
|
||||||
|
"orgIds": [], "roles": roles, "userType": utype,
|
||||||
|
"iat": now, "exp": now + 3600}, separators=(",", ":")).encode()))
|
||||||
|
sig = hmac.new(key, signing.encode(), hashlib.sha512).digest()
|
||||||
|
return signing + "." + b64u(sig)
|
||||||
|
|
||||||
|
|
||||||
|
def call(method: str, path: str, token: str | None = None, body: dict | None = None) -> dict:
|
||||||
|
h = {"Content-Type": "application/json; charset=utf-8"}
|
||||||
|
if token:
|
||||||
|
h["Authorization"] = f"Bearer {token}"
|
||||||
|
data = json.dumps(body, ensure_ascii=False).encode() if body is not None else None
|
||||||
|
req = urllib.request.Request("http://localhost:8080" + path, data=data, headers=h, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as x:
|
||||||
|
return json.loads(x.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try:
|
||||||
|
return json.loads(e.read().decode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return {"_http": e.code}
|
||||||
|
|
||||||
|
|
||||||
|
def fields_of(form: dict) -> dict:
|
||||||
|
out = {}
|
||||||
|
for sec in form.get("sections") or []:
|
||||||
|
for f in sec.get("fields") or []:
|
||||||
|
out[f["key"]] = f
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--task", type=int, default=17)
|
||||||
|
ap.add_argument("--tenant", type=int, default=1)
|
||||||
|
args = ap.parse_args()
|
||||||
|
task_id = args.task
|
||||||
|
tenant = args.tenant
|
||||||
|
|
||||||
|
print("=" * 72)
|
||||||
|
print(f"草稿保存 E2E task={task_id}")
|
||||||
|
print("=" * 72)
|
||||||
|
|
||||||
|
cfg = read_cfg()
|
||||||
|
uid = 991001 # 测试用学生(不建真实账号,草稿表只存 id)
|
||||||
|
tok = mint(cfg["jwt"], cfg["issuer"], uid, tenant, ["STUDENT"], 4)
|
||||||
|
base = f"/api/v1/mp"
|
||||||
|
|
||||||
|
# ── 0. 前置:必须先过门禁 ──────────────────────────
|
||||||
|
print("\n[0] 前置门槛")
|
||||||
|
r = call("POST", f"{base}/submissions", tok, {"taskId": task_id})
|
||||||
|
check("未过门禁时不能建草稿(40007)", r.get("code") == 40007,
|
||||||
|
f"code={r.get('code')} msg={r.get('msg')}")
|
||||||
|
|
||||||
|
# 造一个已绑定的名册行(模拟门禁已通过)
|
||||||
|
h = sql(f"SELECT id FROM collect_roster WHERE task_id={task_id} LIMIT 1")
|
||||||
|
if not h.isdigit():
|
||||||
|
fail("取名册行", "任务下没有名册,先跑 e2e-m3-h5-auth 或手工造一行")
|
||||||
|
return 1
|
||||||
|
sql(f"UPDATE collect_roster SET bind_user_id={uid}, bind_type=1 WHERE id={h}")
|
||||||
|
|
||||||
|
r = call("GET", f"{base}/tasks/{task_id}/form", tok)
|
||||||
|
check("过门禁后能取表单", r.get("code") == 0, f"code={r.get('code')} msg={r.get('msg')}")
|
||||||
|
form = r.get("data") or {}
|
||||||
|
f0 = fields_of(form)
|
||||||
|
check("表单有 21 个内置字段", len(f0) == 21, f"实际 {len(f0)}")
|
||||||
|
check("报考职业是只读且已由老师填", f0["occupation"].get("locked") is True
|
||||||
|
and f0["occupation"].get("value") == "车工", f"{f0['occupation']}")
|
||||||
|
check("学历可改且标注老师已填", f0["education"].get("locked") is False
|
||||||
|
and f0["education"].get("prefilled") is True, f"{f0['education']}")
|
||||||
|
|
||||||
|
# ── 1. 取草稿(幂等)──────────────────────────────
|
||||||
|
print("\n[1] 取草稿:幂等")
|
||||||
|
r1 = call("POST", f"{base}/submissions", tok, {"taskId": task_id})
|
||||||
|
check("首次创建", r1.get("code") == 0 and (r1.get("data") or {}).get("created") is True,
|
||||||
|
f"{r1}")
|
||||||
|
sid = (r1.get("data") or {}).get("id")
|
||||||
|
CREATED.append(sid)
|
||||||
|
r2 = call("POST", f"{base}/submissions", tok, {"taskId": task_id})
|
||||||
|
check("再次调用返回同一份(created=false)",
|
||||||
|
(r2.get("data") or {}).get("id") == sid and (r2.get("data") or {}).get("created") is False,
|
||||||
|
f"{r2.get('data')}")
|
||||||
|
cnt = sql(f"SELECT count(*) FROM collect_submission WHERE task_id={task_id} AND submitter_id={uid}")
|
||||||
|
check("库里只有一份草稿", cnt == "1", f"{cnt}")
|
||||||
|
|
||||||
|
# ── 2. 保存草稿 ──────────────────────────────────
|
||||||
|
print("\n[2] 保存草稿(只传变化的字段)")
|
||||||
|
r = call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {
|
||||||
|
"school": "江西机电职业技术学院",
|
||||||
|
"major": "数控加工",
|
||||||
|
"enroll_date": "2024-09-01",
|
||||||
|
"is_student": True,
|
||||||
|
}})
|
||||||
|
check("保存成功", r.get("code") == 0, f"code={r.get('code')} msg={r.get('msg')}")
|
||||||
|
r = call("GET", f"{base}/tasks/{task_id}/form", tok)
|
||||||
|
fd = fields_of(r.get("data") or {})
|
||||||
|
check("存过的值回显了", fd["school"].get("value") == "江西机电职业技术学院",
|
||||||
|
f"{fd['school'].get('value')}")
|
||||||
|
check("库里也落了列(可统计)",
|
||||||
|
sql(f"SELECT school FROM collect_submission WHERE id={sid}") == "江西机电职业技术学院",
|
||||||
|
sql(f"SELECT school FROM collect_submission WHERE id={sid}"))
|
||||||
|
check("没传的字段不受影响(名册值仍在)", fd["occupation"].get("value") == "车工",
|
||||||
|
f"{fd['occupation'].get('value')}")
|
||||||
|
check("草稿单 id 出现在表单里", (r.get("data") or {}).get("submissionId") == sid,
|
||||||
|
f"{(r.get('data') or {}).get('submissionId')}")
|
||||||
|
|
||||||
|
# ── 3. 锁定字段服务端再拒一次 ──────────────────────
|
||||||
|
print("\n[3] 锁定字段:前端只读是可绕过的,服务端必须再拦")
|
||||||
|
r = call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {"occupation": "铣工"}})
|
||||||
|
check("改「报考职业」被拒", r.get("code") == 40001, f"code={r.get('code')} msg={r.get('msg')}")
|
||||||
|
check("拒绝理由讲清是老师定的、以及怎么办",
|
||||||
|
"老师" in (r.get("msg") or "") and "班主任" in (r.get("msg") or ""),
|
||||||
|
f"msg={r.get('msg')}")
|
||||||
|
check("库里没被改", sql(f"SELECT occupation FROM collect_submission WHERE id={sid}") == "车工",
|
||||||
|
sql(f"SELECT occupation FROM collect_submission WHERE id={sid}"))
|
||||||
|
r = call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {"job_level": "三级/高级工"}})
|
||||||
|
check("改「报考等级」也被拒", r.get("code") == 40001, f"code={r.get('code')}")
|
||||||
|
r = call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {"occupation": "车工"}})
|
||||||
|
check("把锁定字段改成原值不算改(不报错)", r.get("code") == 0, f"code={r.get('code')}")
|
||||||
|
|
||||||
|
# ── 4. 未知字段要被拒绝 ────────────────────────────
|
||||||
|
print("\n[4] 未知字段")
|
||||||
|
r = call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {"not_a_field": "x"}})
|
||||||
|
check("传不认识的字段被拒(不静默忽略)", r.get("code") == 40001,
|
||||||
|
f"code={r.get('code')} msg={r.get('msg')}")
|
||||||
|
|
||||||
|
# ── 5. 改预填字段 → 留痕 ───────────────────────────
|
||||||
|
print("\n[5] 改动留痕")
|
||||||
|
r = call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {"education": "大学专科"}})
|
||||||
|
check("改预填的「学历」成功", r.get("code") == 0, f"code={r.get('code')}")
|
||||||
|
cf = sql(f"SELECT changed_fields FROM collect_submission WHERE id={sid}")
|
||||||
|
check("changed_fields 记录了 education", "education" in cf, f"changed_fields={cf}")
|
||||||
|
check("没改过的字段不在里面", "occupation" not in cf and "school" not in cf,
|
||||||
|
f"changed_fields={cf}")
|
||||||
|
|
||||||
|
# ── 6. 清空预填字段(updateById 会静默跳过 null)────
|
||||||
|
print("\n[6] 清空预填字段")
|
||||||
|
r = call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {"education": ""}})
|
||||||
|
check("清空成功", r.get("code") == 0, f"code={r.get('code')}")
|
||||||
|
v = sql(f"SELECT coalesce(education,'<NULL>') FROM collect_submission WHERE id={sid}")
|
||||||
|
check("库里真的变成 NULL(不是静默没生效)", v == "<NULL>", f"education={v}")
|
||||||
|
r = call("GET", f"{base}/tasks/{task_id}/form", tok)
|
||||||
|
fd = fields_of(r.get("data") or {})
|
||||||
|
check("清空后回显是空,**不会被名册值顶回来**", fd["education"].get("value") in (None, ""),
|
||||||
|
f"{fd['education'].get('value')}")
|
||||||
|
check("没动过的字段仍用名册值", fd["work_unit"].get("prefilled") in (True, False)
|
||||||
|
and fd["occupation"].get("value") == "车工", f"{fd['occupation'].get('value')}")
|
||||||
|
|
||||||
|
# ── 7. 清理 ───────────────────────────────────────
|
||||||
|
print("\n[7] 清理")
|
||||||
|
sql(f"DELETE FROM collect_submission WHERE id IN ({','.join(str(x) for x in CREATED)})")
|
||||||
|
sql(f"UPDATE collect_roster SET bind_user_id=NULL, bind_time=NULL, bind_type=NULL WHERE id={h}")
|
||||||
|
ok(f"已清理 {len(CREATED)} 份草稿并解绑名册")
|
||||||
|
|
||||||
|
print("\n" + "=" * 72)
|
||||||
|
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
|
||||||
|
for n, d in FAILED:
|
||||||
|
print(f" · {n}\n {d}")
|
||||||
|
print("=" * 72)
|
||||||
|
return 1 if FAILED else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -23,6 +23,7 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import urllib.error
|
import urllib.error
|
||||||
@@ -141,13 +142,28 @@ def main() -> int:
|
|||||||
check("返回 302 重定向", code == 302, f"HTTP {code} headers={list(headers.keys())}")
|
check("返回 302 重定向", code == 302, f"HTTP {code} headers={list(headers.keys())}")
|
||||||
loc = headers.get("Location", "")
|
loc = headers.get("Location", "")
|
||||||
print(f" Location: {loc[:120]}…")
|
print(f" Location: {loc[:120]}…")
|
||||||
check("回到 H5 应用地址", loc.startswith("http://127.0.0.1:5174"), loc[:80])
|
# H5 应用地址是可配置的(本地开发用穿透域名),所以不能写死 127.0.0.1 ——
|
||||||
|
# 从配置里读期望值,改配置不会让这条断言假失败
|
||||||
|
# H5 应用地址是**可配置的**(本地开发用内网穿透域名),所以从配置里读期望值,
|
||||||
|
# 别写死 127.0.0.1 —— 改了配置这条断言就会假失败(实测踩过)
|
||||||
|
yml = open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "backend", "xt-server", "src", "main",
|
||||||
|
"resources", "application-dev.yml"), encoding="utf-8").read()
|
||||||
|
mm = re.search(r"^\s*h5-base-url:\s*\$\{[^:}]+:([^}]+)\}", yml, re.M)
|
||||||
|
expect_base = mm.group(1).strip() if mm else ""
|
||||||
|
check("回到配置的 H5 应用地址", bool(expect_base) and loc.startswith(expect_base),
|
||||||
|
f"Location={loc[:80]} 期望前缀={expect_base or '(配置里没读到)'}")
|
||||||
check("**token 在 fragment(#)里,不在 query 里** —— query 会进 Nginx 日志/Referer",
|
check("**token 在 fragment(#)里,不在 query 里** —— query 会进 Nginx 日志/Referer",
|
||||||
"#" in loc and "token=" in loc.split("#", 1)[1],
|
"#" in loc and "token=" in loc.split("#", 1)[1],
|
||||||
f"Location={loc[:140]}")
|
f"Location={loc[:140]}")
|
||||||
check("query 部分没有 token", "token=" not in loc.split("#", 1)[0], loc[:140])
|
check("query 部分没有 token", "token=" not in loc.split("#", 1)[0], loc[:140])
|
||||||
|
# ⚠️ fragment 是 **hash 路由格式**(/pages/index/index?token=X&scene=Y),
|
||||||
|
# 不是裸 hash。uni-app H5 的 onLoad 只解析路由的 query ——
|
||||||
|
# 写成裸 hash 时两边都不认,参数会全丢(踩过)。
|
||||||
frag = loc.split("#", 1)[1] if "#" in loc else ""
|
frag = loc.split("#", 1)[1] if "#" in loc else ""
|
||||||
params = dict(p.split("=", 1) for p in frag.split("&") if "=" in p)
|
check("fragment 是 hash 路由格式(uni-app 能解析)",
|
||||||
|
frag.startswith("/pages/index/index?"), f"fragment={frag[:60]}")
|
||||||
|
qs = frag.split("?", 1)[1] if "?" in frag else ""
|
||||||
|
params = dict(p.split("=", 1) for p in qs.split("&") if "=" in p)
|
||||||
token = urllib.parse.unquote(params.get("token", ""))
|
token = urllib.parse.unquote(params.get("token", ""))
|
||||||
check("fragment 里带回了 scene(授权回来还要继续用)",
|
check("fragment 里带回了 scene(授权回来还要继续用)",
|
||||||
urllib.parse.unquote(params.get("scene", "")) == scene, frag[:100])
|
urllib.parse.unquote(params.get("scene", "")) == scene, frag[:100])
|
||||||
|
|||||||
Reference in New Issue
Block a user