feat(submit): 提交 + 红黄绿校验(PRD §5.7 的 VAL 系列)
一次给全,不"改一条再拒一次":学生一次可能同时缺 3 项材料、2 个字段没填,
逐条拒绝会让他改 5 次提交 5 次。所以 validate 收全部问题后一次性返回
{ruleCode, severity, title, detail},ruleCode 用 PRD 的 VAL-xx 编号。
两段式提交:只有黄色警告时先返回 submitted=false 让学生确认,
再带 confirmWarnings=true 才真的提交。为什么不前端自己判断 ——
只有服务端的校验结果算数,两次请求之间学生可能又改坏了什么。
实现的规则:VAL-01 证件号 / VAL-05 材料完整性 / VAL-06 格式 /
VAL-07 体积 / VAL-08 清晰度 / VAL-09 重复文件 / VAL-13 报考三项 /
VAL-15 补考科目数按等级 / VAL-16 在校生三项 / VAL-17 工作年限 /
VAL-18 未规范化 / VAL-20 体积下限 / VAL-22 表单必填(**新增**:
PRD 的 VAL-01~21 是具体业务规则,没覆盖动态表单的必填字段,按系列顺延补的)。
═══ 本轮抓到的四个真问题(都记在代码注释里了)═══
① **校验器不能用列名当字段 key**。字段 key 是 snake_case
(work_unit / job_level / enroll_date),不是列名;而且报考三项
存在 ext_json 里、**不是独立列**(契约 §2.4)。写 camelCase 的表现是
"明明填了却说没填"。更糟的是 VAL-16 读列、VAL-22 读表单装配值 ——
**两条规则对同一份数据给出相反结论**,学生不知道该信哪一条。
现在统一用 FillService.getForm 装配出来的那一份作为唯一取值口径。
② **VAL-22 没判可见性**。不判可见就会要求一个在校生填「补考理论」——
而那个字段在他页面上根本不存在。求值口径已与前端 fill.vue 的 visible()
逐字对齐(norm 归一 true/false↔1/0、未知 op 一律可见)。
③ **VAL-01 读错了列**:collect_roster.id_card_no 是 **AES 密文**
(列注释写明的),明文只在导入与门禁时短暂存在。一开始拿它算校验位,
于是每一份填报单都被判"身份证号未通过校验" —— 100% 阻断。
这种"规则很对但读错列"的错,反而容易被当成"校验太严"而不是 bug。
现在只确认名册里有证件号(用脱敏列),校验位交给导入与门禁。
④ **静态资源没有 Cache-Control/ETag**(新增 StaticCacheHeaderFilter)。
没有它浏览器会走启发式缓存:旧 index.html → 旧 chunk →
**学生发版后看到的是上个月的页面**,而开发机硬刷新一次就没了,
极容易被当成"偶发"。现在 /assets/** 一年 immutable(文件名带内容哈希)、
*.html no-cache(每次校验)、/api/** no-store。
排查过程里还确认了一件事:材料上传那轮的"浏览器加载旧 chunk"
不是构建问题 —— 从 8080 下载的 chunk 与磁盘文件 SHA-256 一致、里面确实有新代码,
是缓存头缺失导致的浏览器行为。
验收:e2e-m3-submit.py **30/30**(含"该拦的确实拦了"和"修好后确实放过")。
回归:草稿 27 / 门禁 29 / 材料上传 29 / 材料目录 68 全绿。
This commit is contained in:
+65
@@ -0,0 +1,65 @@
|
||||
package cn.tianbaotong.fill.support;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 静态资源的缓存策略 + 接口响应禁止缓存。
|
||||
*
|
||||
* <h3>为什么必须有这个类</h3>
|
||||
* 没有它的时候,静态资源只有 {@code Last-Modified}、<b>没有 {@code Cache-Control}、没有 {@code ETag}</b>。
|
||||
* 这会触发浏览器的 <b>启发式缓存</b>(RFC 9111 §4.2.2:没有显式过期时间时,
|
||||
* 浏览器按 {@code Last-Modified} 年龄的 10% 自行推断一个保鲜期)。
|
||||
*
|
||||
* <p>后果不是"慢一点",而是<b>发版后学生看到旧页面</b>:
|
||||
* 旧 {@code index.html} 被缓存 → 它里面写死了旧 {@code /assets/index-<旧哈希>.js} →
|
||||
* 学生打开的是我们上个月的那一版,而我们在后台怎么改都没用。
|
||||
* 排查这个现象极其费时 —— 因为它<b>只在"已经打开过页面的设备"上复现</b>,
|
||||
* 开发机上硬刷新一次就没了,于是很容易被当成"偶发"。
|
||||
*
|
||||
* <p>实测踩过:材料上传的前端界面明明已构建、chunk 也已正确下发
|
||||
* (从 8080 下载 9606 字节,与磁盘文件 SHA-256 一致、里面确实有那段代码),
|
||||
* 但浏览器执行的是旧 chunk —— 页面里就是没有材料区。
|
||||
*
|
||||
* <h3>策略</h3>
|
||||
* <ul>
|
||||
* <li>{@code /assets/**}:文件名带内容哈希,内容变了名字就变,
|
||||
* 所以可以<b>永久缓存</b>({@code immutable})。这是唯一的性能红利所在:
|
||||
* 这些包加起来好几 MB,学生不该每次发版都重下。</li>
|
||||
* <li>{@code *.html} 与 {@code /}:<b>每次都必须回源校验</b>({@code no-cache})。
|
||||
* 注意 {@code no-cache} ≠ 不缓存,而是"用之前先问一下"——
|
||||
* 配合 {@code Last-Modified} 会走 304,只花一个往返、不重传正文,这是对的。</li>
|
||||
* <li>{@code /api/**}:{@code no-store}。接口响应<b>绝不能</b>被缓存 ——
|
||||
* 微信 webview 里"刚提交完再进去看还是旧状态"这种问题,
|
||||
* 十有八九是某个中间层把 GET 响应缓存了。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class StaticCacheHeaderFilter extends OncePerRequestFilter {
|
||||
|
||||
/** 一年。带内容哈希的资源可以按"永久"对待。 */
|
||||
private static final String IMMUTABLE = "public, max-age=31536000, immutable";
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain chain) throws ServletException, IOException {
|
||||
String uri = request.getRequestURI();
|
||||
if (uri.startsWith("/assets/")) {
|
||||
response.setHeader("Cache-Control", IMMUTABLE);
|
||||
} else if (uri.startsWith("/api/")) {
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
} else if (uri.equals("/") || uri.endsWith(".html")) {
|
||||
response.setHeader("Cache-Control", "no-cache");
|
||||
}
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
+13
@@ -55,4 +55,17 @@ public class FillController {
|
||||
submissionService.saveDraft(id, req);
|
||||
return ApiResult.ok();
|
||||
}
|
||||
|
||||
@org.springframework.web.bind.annotation.PostMapping("/submissions/{id}/submit")
|
||||
@Operation(summary = "提交(含校验)",
|
||||
description = "校验结果在 data 里:{submitted, status, redCount, yellowCount, issues[]}。"
|
||||
+ "红 > 0 → 必须改;红 = 0 且黄 > 0 → 先弹给学生确认,再带 confirmWarnings=true 提交一次。"
|
||||
+ "**校验不通过也是 HTTP 200**:它是正常业务结果而不是异常,"
|
||||
+ "前端必须拿到完整清单才能渲染红黄绿 —— 一次只给一条会让学生改 5 次提交 5 次")
|
||||
public ApiResult<cn.tianbaotong.fill.module.fill.dto.SubmitDtos.Report> submit(
|
||||
@PathVariable Long id,
|
||||
@org.springframework.web.bind.annotation.RequestBody(required = false)
|
||||
cn.tianbaotong.fill.module.fill.dto.SubmitDtos.SubmitReq req) {
|
||||
return ApiResult.ok(submissionService.submit(id, req != null && req.confirmed()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package cn.tianbaotong.fill.module.fill.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 提交与问题清单(《03-接口设计》§9.8;问题项见《04-核心模块设计说明》§2.3 与 PRD §5.7 的 VAL 系列)。
|
||||
*
|
||||
* <h3>为什么问题项是这个形状({@code {ruleCode, severity, title, detail}})</h3>
|
||||
* 因为它在三个地方要用同一份数据:
|
||||
* <ul>
|
||||
* <li><b>学生端</b>:红黄绿三色清单,每条要能指到"哪里不对、怎么改";</li>
|
||||
* <li><b>班主任端</b>:待办列表的红黄绿分级、{@code batchApprovable} 的服务端判定;</li>
|
||||
* <li><b>问题清单页</b>({@code GET /validation/issues}):管理端按规则码聚合看"这批人主要错在哪"。</li>
|
||||
* </ul>
|
||||
* 所以 {@code ruleCode} 用的是 PRD 里那套 **VAL-xx** 编号,而不是内部枚举名 ——
|
||||
* 客服、班主任、开发三方说的是同一个词,会议里才不会鸡同鸭讲。
|
||||
*/
|
||||
public final class SubmitDtos {
|
||||
|
||||
private SubmitDtos() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条校验问题。
|
||||
*
|
||||
* @param ruleCode PRD §5.7 的规则编号(VAL-01…)。<b>不要写内部枚举名</b>
|
||||
* @param severity {@code 1} 信息(蓝)/ {@code 2} 警告(黄)/ {@code 3} 错误(红,阻断提交)
|
||||
* @param title 一句话说清"哪里不对"
|
||||
* @param detail 说清"怎么改"。<b>要能照着做</b>——
|
||||
* "材料不合规"这种话等于没说,学生会直接打电话给班主任
|
||||
*/
|
||||
public record Issue(String ruleCode, int severity, String title, String detail,
|
||||
Map<String, Object> extra) {
|
||||
|
||||
public static final int INFO = 1;
|
||||
public static final int WARN = 2;
|
||||
public static final int ERROR = 3;
|
||||
|
||||
public static Issue info(String code, String title, String detail) {
|
||||
return new Issue(code, INFO, title, detail, null);
|
||||
}
|
||||
|
||||
public static Issue warn(String code, String title, String detail) {
|
||||
return new Issue(code, WARN, title, detail, null);
|
||||
}
|
||||
|
||||
public static Issue error(String code, String title, String detail) {
|
||||
return new Issue(code, ERROR, title, detail, null);
|
||||
}
|
||||
|
||||
/** 带定位信息(材料码、名册 id 等),前端据此高亮到具体那一项。 */
|
||||
public static Issue with(String code, int severity, String title, String detail,
|
||||
Map<String, Object> extra) {
|
||||
return new Issue(code, severity, title, detail, extra);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交结果报告。
|
||||
*
|
||||
* <p><b>不论成败都返回完整清单</b>(而不是"失败就只给一个错误码"):
|
||||
* 学生一次可能同时缺 3 项材料、2 个字段没填 —— 他要知道**全部**,
|
||||
* 而不是改一条、提交一次、再被拒一次。那种体验会让人直接放弃。
|
||||
*
|
||||
* @param submitted 是否真的提交成功了
|
||||
* @param status 提交后的填报单状态({@code 1} 待初审);未提交成功时为原状态
|
||||
* @param issues 全部问题(含蓝/黄 —— 它们是提示,不是错误)
|
||||
*/
|
||||
public record Report(boolean submitted, Integer status, String statusText,
|
||||
int redCount, int yellowCount, int infoCount,
|
||||
List<Issue> issues) {
|
||||
|
||||
public static String statusText(Integer status) {
|
||||
if (status == null) {
|
||||
return "草稿";
|
||||
}
|
||||
return switch (status) {
|
||||
case 0 -> "草稿";
|
||||
case 1 -> "已提交,等待班主任初审";
|
||||
case 2 -> "初审通过,等待管理员终审";
|
||||
case 3 -> "已通过";
|
||||
case 4 -> "已驳回,请修改后重新提交";
|
||||
case 5 -> "已归档";
|
||||
default -> "未知";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交请求体。
|
||||
*
|
||||
* @param confirmWarnings 已确认全部黄色警告、坚持提交(对应 PRD 的"可强制通过")。
|
||||
* <b>只对黄色有效</b>;红色是阻断级,这个参数绕不过去
|
||||
*/
|
||||
public record SubmitReq(Boolean confirmWarnings) {
|
||||
|
||||
public boolean confirmed() {
|
||||
return Boolean.TRUE.equals(confirmWarnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
@@ -5,6 +5,7 @@ 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.dto.SubmitDtos;
|
||||
import cn.tianbaotong.fill.module.fill.entity.CollectSubmission;
|
||||
import cn.tianbaotong.fill.module.fill.mapper.SubmissionMapper;
|
||||
import cn.tianbaotong.fill.module.form.dto.FormFieldVO;
|
||||
@@ -24,6 +25,7 @@ import java.time.LocalDate;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
@@ -53,6 +55,7 @@ public class SubmissionService {
|
||||
private final TaskMapper taskMapper;
|
||||
private final FormSchemaService formSchemaService;
|
||||
private final MpRosterMapper rosterMapper;
|
||||
private final SubmissionValidator validator;
|
||||
|
||||
// ── §9.3 取草稿(幂等)────────────────────────
|
||||
|
||||
@@ -225,6 +228,73 @@ public class SubmissionService {
|
||||
.set(CollectSubmission::getChangedFields, sub.getChangedFields()));
|
||||
}
|
||||
|
||||
// ── §9.8 提交 ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* 提交(含校验,PRD §5.7 的 VAL 系列)。
|
||||
*
|
||||
* <p><b>校验不通过不是"异常",是正常的业务结果。</b>
|
||||
* 所以它在 HTTP 层永远是 200,具体看返回的 {@code Report}:
|
||||
* 红 > 0 → 必须改({@code submitted=false});红 == 0 且黄 > 0 且未确认 → 请学生确认警告
|
||||
* ({@code submitted=false},前端弹"这些是提醒,仍要提交吗");再调一次带
|
||||
* {@code confirmWarnings=true} 才真正提交。
|
||||
*
|
||||
* <p><b>为什么要"再调一次"而不是前端自己判断:</b>
|
||||
* 因为只有服务端的校验结果算数 —— 学生在两次请求之间可能又改坏了什么。
|
||||
* 前端拿到的那个清单只是用来渲染,不是用来做决定的。
|
||||
*
|
||||
* <p>⚠️ 一期不做并发保护(同一份填报单被两个端同时提交)。
|
||||
* 学生的真实场景是一人一机;真要做的话应当是 `UPDATE ... WHERE status = 0` 并检查影响行数,
|
||||
* 而不是在这里加锁。
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public SubmitDtos.Report submit(Long id, boolean confirmWarnings) {
|
||||
LoginUser me = UContext.require();
|
||||
CollectSubmission s = submissionMapper.selectMineById(id, me.tenantId(), me.uid());
|
||||
if (s == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "填报单不存在或不属于你");
|
||||
}
|
||||
// 防重复提交。已提交的**不是**报错后清空页面,而是明确告诉他在哪一步 ——
|
||||
// 学生最怕的是"我到底提交成功没有"
|
||||
if (s.getStatus() != null && s.getStatus() != CollectSubmission.STATUS_DRAFT
|
||||
&& s.getStatus() != CollectSubmission.STATUS_REJECTED) {
|
||||
throw new BizException(ErrorCode.AUDIT_IN_PROGRESS,
|
||||
"这份填报单已经提交过了,当前是「" + SubmitDtos.Report.statusText(s.getStatus()) + "」");
|
||||
}
|
||||
requireFillableTask(s.getTaskId());
|
||||
CollectRoster roster = requireGate(me.tenantId(), s.getTaskId(), me.uid());
|
||||
CollectTask task = taskMapper.selectById(s.getTaskId());
|
||||
|
||||
List<SubmitDtos.Issue> issues = validator.validate(s, task, roster);
|
||||
int red = (int) issues.stream().filter(i -> i.severity() == SubmitDtos.Issue.ERROR).count();
|
||||
int yellow = (int) issues.stream().filter(i -> i.severity() == SubmitDtos.Issue.WARN).count();
|
||||
int info = (int) issues.stream().filter(i -> i.severity() == SubmitDtos.Issue.INFO).count();
|
||||
|
||||
// 红黄计数落库:班主任端要靠它排序、以及判定"是否可批量通过"。
|
||||
// 不能等审核时现算 —— 那样每打开一个人都要重跑一遍全量校验
|
||||
submissionMapper.update(null, Wrappers.<CollectSubmission>lambdaUpdate()
|
||||
.eq(CollectSubmission::getId, id)
|
||||
.set(CollectSubmission::getRedCount, red)
|
||||
.set(CollectSubmission::getYellowCount, yellow));
|
||||
|
||||
boolean needConfirm = red == 0 && yellow > 0 && !confirmWarnings;
|
||||
if (red > 0 || needConfirm) {
|
||||
log.info("提交未通过:submission={} 红={} 黄={} 蓝={}", id, red, yellow, info);
|
||||
return new SubmitDtos.Report(false, s.getStatus(),
|
||||
SubmitDtos.Report.statusText(s.getStatus()), red, yellow, info, issues);
|
||||
}
|
||||
|
||||
submissionMapper.update(null, Wrappers.<CollectSubmission>lambdaUpdate()
|
||||
.eq(CollectSubmission::getId, id)
|
||||
.set(CollectSubmission::getStatus, CollectSubmission.STATUS_PENDING_CLASS)
|
||||
.set(CollectSubmission::getSubmitTime, java.time.LocalDateTime.now()));
|
||||
log.info("提交成功:submission={} 红={} 黄={} 已确认警告={}",
|
||||
id, red, yellow, confirmWarnings);
|
||||
return new SubmitDtos.Report(true, CollectSubmission.STATUS_PENDING_CLASS,
|
||||
SubmitDtos.Report.statusText(CollectSubmission.STATUS_PENDING_CLASS),
|
||||
red, yellow, info, issues);
|
||||
}
|
||||
|
||||
// ── 内部 ──────────────────────────────────────
|
||||
|
||||
private void requireFillableTask(Long taskId) {
|
||||
|
||||
+446
@@ -0,0 +1,446 @@
|
||||
package cn.tianbaotong.fill.module.fill.service;
|
||||
|
||||
import cn.tianbaotong.fill.module.file.entity.CollectFile;
|
||||
import cn.tianbaotong.fill.module.file.mapper.FileMapper;
|
||||
import cn.tianbaotong.fill.module.fill.dto.StudentFormVO;
|
||||
import cn.tianbaotong.fill.module.fill.dto.SubmitDtos.Issue;
|
||||
import cn.tianbaotong.fill.module.fill.entity.CollectSubmission;
|
||||
import cn.tianbaotong.fill.module.form.dto.FormFieldVO;
|
||||
import cn.tianbaotong.fill.module.material.dto.MaterialDefVO;
|
||||
import cn.tianbaotong.fill.module.material.service.MaterialDefService;
|
||||
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
|
||||
import cn.tianbaotong.fill.module.task.entity.CollectTask;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 提交前的校验(PRD §5.7 的 VAL 系列)。
|
||||
*
|
||||
* <h3>唯一的取值口径:{@link FillService#getForm} 装配出来的那一份</h3>
|
||||
*
|
||||
* <p><b>不要自己去读列。</b>这条是本类最容易写错、且后果最难查的地方 ——
|
||||
* 实测踩过三次:
|
||||
* <ol>
|
||||
* <li>字段 key 是 <b>snake_case</b>({@code work_unit}、{@code job_level}、{@code enroll_date}),
|
||||
* 不是列名。写 {@code jobLevel} 会读到一个不存在的字段,表现是"明明填了却说没填"。</li>
|
||||
* <li>报考三项({@code occupation}/{@code worktype}/{@code job_level})
|
||||
* <b>存在 {@code ext_json} 里,不是独立列</b>(契约 §2.4)。读列永远是空的。</li>
|
||||
* <li>同一份数据被两条规则用两种方式读 → 两条规则<b>互相矛盾</b>
|
||||
* (VAL-16 说"缺所学专业"、VAL-22 同时说"不缺")。这种自相矛盾比单纯漏判更糟:
|
||||
* 学生不知道该信哪一条。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <h3>另外两条贯穿全篇的原则</h3>
|
||||
*
|
||||
* <p><b>① 一次给全,不要"改一条再拒一次"。</b>
|
||||
* 学生一次可能同时缺 3 项材料、2 个字段没填。逐条拒绝会让他改 5 次、提交 5 次。
|
||||
*
|
||||
* <p><b>② 「用户选择了无」与「用户没填」必须能区分</b>(PRD §2.6 的三条铁律)。
|
||||
* 学历填「无」是合法业务值;补考科目填「无」表示"不补这一科",0 科是合法值。
|
||||
* 把它们当成"没填"是这套系统最容易犯、且最难被测试发现的错 ——
|
||||
* 因为开发自己测试时永远填真实值,不会填「无」。
|
||||
*
|
||||
* <p><b>③ 不可见的字段不参与必填判定。</b>
|
||||
* 「补考理论」只在「是否补考=是」时可见;在校生不该被要求交工作证明。
|
||||
* 求值口径必须与前端 {@code fill.vue} 的 {@code visible()} **逐字一致**,
|
||||
* 否则会出现"学生看得见、后端说你没填"(或反过来)这种最让人火大的问题。
|
||||
*
|
||||
* <p>⚠️ 关于 {@code VAL-22}:PRD 的 VAL-01…VAL-21 是**具体业务规则**,没有覆盖
|
||||
* 「动态表单的必填字段」。因为表单字段是配置出来的,缺这一条会有漏洞,
|
||||
* 所以按系列顺延补了 <b>VAL-22 = 表单必填字段缺失</b> —— 这是**新增**,不是 PRD 原有条目。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SubmissionValidator {
|
||||
|
||||
// ── 字段 key(唯一来源:BuiltinFields / 表单 schema,全部 snake_case)──
|
||||
|
||||
/** 报考三项(VAL-13)。锁定字段,值来自名册的 ext_json。 */
|
||||
private static final List<String[]> PLAN_KEYS = List.of(
|
||||
new String[]{"occupation", "报考职业"},
|
||||
new String[]{"worktype", "报考工种"},
|
||||
new String[]{"job_level", "报考等级"});
|
||||
|
||||
/** 在校生三项(VAL-16)。 */
|
||||
private static final List<String[]> STUDENT_KEYS = List.of(
|
||||
new String[]{"school", "在读学校"},
|
||||
new String[]{"major", "所学专业"},
|
||||
new String[]{"enroll_date", "入学时间"});
|
||||
|
||||
/** 补考三科(VAL-15)。 */
|
||||
private static final String[] RETAKE_KEYS = {"retake_theory", "retake_practice", "retake_overall"};
|
||||
|
||||
/** 表示"这一项没有"的合法值 —— 它**不是**缺失(PRD §2.6)。 */
|
||||
private static final Set<String> NONE_WORDS = Set.of("无", "没有", "none", "无补考");
|
||||
|
||||
private final MaterialDefService materialDefService;
|
||||
private final FileMapper fileMapper;
|
||||
private final FillService fillService;
|
||||
|
||||
public List<Issue> validate(CollectSubmission s, CollectTask task, CollectRoster roster) {
|
||||
List<Issue> out = new ArrayList<>();
|
||||
|
||||
// 唯一取值口径:学生看到的那一份装配结果(含条件可见性所需的一切)
|
||||
StudentFormVO form = fillService.getForm(s.getTaskId());
|
||||
Map<String, Object> values = new LinkedHashMap<>();
|
||||
for (StudentFormVO.Section sec : form.getSections()) {
|
||||
for (FormFieldVO f : sec.getFields()) {
|
||||
values.put(f.getKey(), f.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
checkIdentity(out, roster);
|
||||
checkPlan(out, values);
|
||||
checkRetake(out, values, s);
|
||||
checkStudent(out, values);
|
||||
checkWorkYears(out, values);
|
||||
checkRequiredFields(out, form, values);
|
||||
checkMaterials(out, s, values);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── VAL-01 证件号 ────────────────────────────────
|
||||
|
||||
/**
|
||||
* ⚠️ <b>这里不能算校验位。</b>
|
||||
*
|
||||
* <p>{@code collect_roster.id_card_no} 是 <b>AES 密文</b>(列注释写明了),
|
||||
* 明文只在两个时刻短暂存在:<b>导入时</b>(M2 名册导入会逐行校验并出报告)
|
||||
* 和<b>门禁时</b>(学生手输,当场三态判定并比对 {@code id_card_hash})。
|
||||
* 到了提交这一步,库里已经没有明文可算了。
|
||||
*
|
||||
* <p>实测踩过:一开始直接读了 {@code id_card_no} 去算校验位,
|
||||
* 于是每一份填报单都被判"身份证号未通过校验" —— 而那个值其实是密文。
|
||||
* 这种"规则本身很对、但读错了列"的错,表现是<b>100% 阻断</b>,
|
||||
* 反而容易被当成"校验太严"而不是 bug。
|
||||
*
|
||||
* <p>所以这里只做**能做的**:确认名册里确实有证件号(用脱敏列判断)。
|
||||
* 校验位交给导入与门禁 —— 那两个地方有明文,也有明确的失败出口。
|
||||
*/
|
||||
private void checkIdentity(List<Issue> out, CollectRoster roster) {
|
||||
if (roster == null) {
|
||||
out.add(Issue.error("VAL-01", "找不到你的名册信息",
|
||||
"请联系班主任确认你在本次认定的名册中"));
|
||||
return;
|
||||
}
|
||||
String mask = roster.getIdCardMask();
|
||||
if (mask == null || mask.isBlank() || mask.length() < 15) {
|
||||
out.add(Issue.error("VAL-01", "名册里没有你的身份证号",
|
||||
"请联系班主任在名册中补上身份证号,否则材料无法上报"));
|
||||
}
|
||||
}
|
||||
|
||||
// ── VAL-13 报考三项 ──────────────────────────────
|
||||
|
||||
private void checkPlan(List<Issue> out, Map<String, Object> values) {
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (String[] k : PLAN_KEYS) {
|
||||
if (blank(values.get(k[0]))) {
|
||||
missing.add(k[1]);
|
||||
}
|
||||
}
|
||||
if (!missing.isEmpty()) {
|
||||
out.add(Issue.error("VAL-13", String.join("、", missing) + " 还没有确定",
|
||||
"这三项由老师按报考计划设定,学生不能改。请联系班主任在你的名册行里补上"));
|
||||
}
|
||||
}
|
||||
|
||||
// ── VAL-15 补考科目数量 ──────────────────────────
|
||||
|
||||
/**
|
||||
* 补考科目数与等级匹配:三/四/五级最多 1 科;一/二级最多 2 科。
|
||||
*
|
||||
* <p>这条规则来自评价计划的硬约束 —— 报错了科目,考点按科目排考场,改不了。
|
||||
*/
|
||||
private void checkRetake(List<Issue> out, Map<String, Object> values, CollectSubmission s) {
|
||||
if (!isTrue(values.get("is_retake"))) {
|
||||
return;
|
||||
}
|
||||
List<String> picked = new ArrayList<>();
|
||||
for (String k : RETAKE_KEYS) {
|
||||
if (!blank(values.get(k)) && !isNone(values.get(k))) {
|
||||
picked.add(labelOf(k));
|
||||
}
|
||||
}
|
||||
int max = maxRetakeCount(str(values.get("job_level")));
|
||||
if (picked.isEmpty()) {
|
||||
// 勾了「是补考」却一科没选 —— 0 科本身是合法值(PRD §2.6),
|
||||
// 但与"是补考"自相矛盾。所以是**警告**而不是错误:
|
||||
// 学生多半是想取消补考,把「是否补考」改成「否」就行
|
||||
out.add(Issue.warn("VAL-15", "勾了「是补考」但没有选补考科目",
|
||||
"如果这次不补考,把「是否补考」改成「否」;如果要补考,请勾上要补的科目"));
|
||||
} else if (picked.size() > max) {
|
||||
out.add(Issue.error("VAL-15", "补考科目超出本等级上限",
|
||||
"「" + str(values.get("job_level")) + "」最多补考 " + max + " 科,你选了 "
|
||||
+ picked.size() + " 科(" + String.join("、", picked) + ")。请去掉多余的科目"));
|
||||
}
|
||||
}
|
||||
|
||||
private static String labelOf(String key) {
|
||||
return switch (key) {
|
||||
case "retake_theory" -> "理论";
|
||||
case "retake_practice" -> "实操";
|
||||
default -> "综合";
|
||||
};
|
||||
}
|
||||
|
||||
private static int maxRetakeCount(String level) {
|
||||
if (level == null || level.isBlank()) {
|
||||
return 1;
|
||||
}
|
||||
// 一/二级可补 2 科;三/四/五级 1 科;专项职业能力不设补考
|
||||
return level.contains("一级") || level.contains("二级") || level.contains("一級") ? 2 : 1;
|
||||
}
|
||||
|
||||
// ── VAL-16 在校生三项 ────────────────────────────
|
||||
|
||||
private void checkStudent(List<Issue> out, Map<String, Object> values) {
|
||||
if (!isTrue(values.get("is_student"))) {
|
||||
return;
|
||||
}
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (String[] k : STUDENT_KEYS) {
|
||||
if (blank(values.get(k[0]))) {
|
||||
missing.add(k[1]);
|
||||
}
|
||||
}
|
||||
if (!missing.isEmpty()) {
|
||||
out.add(Issue.error("VAL-16", "在校生必须填写" + String.join("、", missing),
|
||||
"你选了「在校生」,这三项认定机构要用来核对学籍,缺一项都会被退回。"
|
||||
+ "入学时间格式为 YYYY-MM-DD"));
|
||||
}
|
||||
}
|
||||
|
||||
// ── VAL-17 本职业工作年限 ────────────────────────
|
||||
|
||||
/**
|
||||
* 年限只能是阿拉伯数字。
|
||||
*
|
||||
* <p>输入阶段已经拦过一次(schema 里配了 {@code min:0 max:60}),这里是**兜底**:
|
||||
* 值可能来自历史数据或人工改数。上界 60 是业务判断 ——
|
||||
* 超过它几乎一定是填错了单位(把月当成年)或把工号填进来了。
|
||||
*/
|
||||
private void checkWorkYears(List<Issue> out, Map<String, Object> values) {
|
||||
Object v = values.get("work_years");
|
||||
if (blank(v)) {
|
||||
return;
|
||||
}
|
||||
String raw = str(v).trim();
|
||||
Integer y = parseInt(raw);
|
||||
if (y == null) {
|
||||
out.add(Issue.error("VAL-17", "本职业工作年限必须是阿拉伯数字",
|
||||
"你填的是「" + raw + "」。请只填数字(单位是年),不要写「三年」或「3年」"));
|
||||
} else if (y < 0 || y > 60) {
|
||||
out.add(Issue.error("VAL-17", "本职业工作年限不在合理范围",
|
||||
"你填的是 " + y + " 年。请填 0~60 之间的数字(单位是年,不是月)"));
|
||||
}
|
||||
}
|
||||
|
||||
// ── VAL-22 表单必填字段 ──────────────────────────
|
||||
|
||||
/**
|
||||
* 动态表单里标了必填、且**当前可见**的字段不能为空。
|
||||
*
|
||||
* <p>三处细节:
|
||||
* <ul>
|
||||
* <li><b>要求可见性</b>。实测踩过:不判可见就提交,会要求一个在校生
|
||||
* 填「补考理论」——而那个字段在他的页面上根本不存在。他会以为系统坏了。</li>
|
||||
* <li><b>锁定字段跳过</b>。它们由老师填、学生改不了,报给他也没用;
|
||||
* 那类问题由 VAL-13 单独管(并告诉他去找班主任)。</li>
|
||||
* <li><b>「无」是合法值</b>(PRD §2.6)。填了「无」= 已填,不是缺失。</li>
|
||||
* </ul>
|
||||
*/
|
||||
private void checkRequiredFields(List<Issue> out, StudentFormVO form, Map<String, Object> values) {
|
||||
Set<String> already = new LinkedHashSet<>();
|
||||
for (String[] k : PLAN_KEYS) {
|
||||
already.add(k[0]);
|
||||
}
|
||||
for (String[] k : STUDENT_KEYS) {
|
||||
already.add(k[0]);
|
||||
}
|
||||
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (StudentFormVO.Section sec : form.getSections()) {
|
||||
for (FormFieldVO f : sec.getFields()) {
|
||||
if (already.contains(f.getKey())
|
||||
|| !Boolean.TRUE.equals(f.getRequired())
|
||||
|| Boolean.TRUE.equals(f.getLocked())) {
|
||||
continue;
|
||||
}
|
||||
if (!visible(f.getVisibleWhen(), values)) {
|
||||
continue;
|
||||
}
|
||||
if (blank(values.get(f.getKey()))) {
|
||||
missing.add(f.getLabel());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!missing.isEmpty()) {
|
||||
out.add(Issue.error("VAL-22", "还有必填项没填:" + String.join("、", missing),
|
||||
"这几项认定机构要用,空着会被整份退回。填「无」也可以 —— 但要明确填上"));
|
||||
}
|
||||
}
|
||||
|
||||
// ── VAL-05/06/07/08/09/18/20 材料 ───────────────
|
||||
|
||||
private void checkMaterials(List<Issue> out, CollectSubmission s, Map<String, Object> values) {
|
||||
List<MaterialDefVO> defs = materialDefService.list(s.getTaskId());
|
||||
List<CollectFile> files = fileMapper.listMine(s.getTenantId(), s.getId(), s.getSubmitterId());
|
||||
Map<String, List<CollectFile>> byCode = new LinkedHashMap<>();
|
||||
for (CollectFile f : files) {
|
||||
byCode.computeIfAbsent(f.getMaterialCode(), k -> new ArrayList<>()).add(f);
|
||||
}
|
||||
|
||||
for (MaterialDefVO def : defs) {
|
||||
// 条件材料:在校生不该被要求交工作证明
|
||||
if (def.getVisibleWhen() != null && !visible(def.getVisibleWhen(), values)) {
|
||||
continue;
|
||||
}
|
||||
List<CollectFile> fs = byCode.getOrDefault(def.getMaterialCode(), List.of());
|
||||
|
||||
// VAL-05 材料完整性 —— 缺件是最高频的退回原因
|
||||
if (Boolean.TRUE.equals(def.getRequired()) && fs.isEmpty()) {
|
||||
out.add(Issue.error("VAL-05", "缺少必传材料:" + def.getMaterialName(),
|
||||
"「" + def.getMaterialName() + "」是必传项,请拍照上传。"
|
||||
+ (def.getTipText() == null ? "" : def.getTipText())));
|
||||
continue;
|
||||
}
|
||||
|
||||
int maxSizeKb = def.getMaxSizeKb() == null ? 1024 : def.getMaxSizeKb();
|
||||
for (CollectFile f : fs) {
|
||||
// VAL-06 格式白名单
|
||||
if (def.getFileTypes() != null && !def.getFileTypes().isEmpty()
|
||||
&& !def.getFileTypes().contains(f.getFileExt())) {
|
||||
out.add(Issue.error("VAL-06", "「" + def.getMaterialName() + "」的文件格式不对",
|
||||
"必须是 " + String.join("/", def.getFileTypes()) + ",这份是 ."
|
||||
+ f.getFileExt() + "。请重新上传"));
|
||||
}
|
||||
// VAL-07 体积上限 —— 认定机构只看这一个数字
|
||||
long size = f.getFinalSize() == null ? 0L : f.getFinalSize();
|
||||
if (size > maxSizeKb * 1024L) {
|
||||
out.add(Issue.error("VAL-07", "「" + def.getMaterialName() + "」超过 " + maxSizeKb + "KB",
|
||||
"现在 " + (size / 1024) + "KB。认定机构对体积有硬要求,超了会直接退回。"
|
||||
+ "请删掉重新上传,系统会自动帮你压"));
|
||||
}
|
||||
// VAL-20 体积下限(警告)—— 异常小多半不是原图
|
||||
if (def.getMinSizeKb() != null && size > 0 && size < def.getMinSizeKb() * 1024L) {
|
||||
out.add(Issue.warn("VAL-20", "「" + def.getMaterialName() + "」体积偏小",
|
||||
"只有 " + (size / 1024) + "KB,可能不是原图(比如被微信压过)。"
|
||||
+ "建议用手机原相机重拍,审核时更容易通过"));
|
||||
}
|
||||
// VAL-08 可读性(警告)
|
||||
int w = f.getWidth() == null ? 0 : f.getWidth();
|
||||
int h = f.getHeight() == null ? 0 : f.getHeight();
|
||||
int minLong = def.getMinLongSide() == null ? 0 : def.getMinLongSide();
|
||||
if (w > 0 && h > 0 && minLong > 0 && Math.max(w, h) < minLong) {
|
||||
out.add(Issue.warn("VAL-08", "「" + def.getMaterialName() + "」清晰度偏低",
|
||||
"这张图 " + w + "×" + h + ",建议长边不低于 " + minLong
|
||||
+ " 像素。证件类材料模糊会被机构退回"));
|
||||
}
|
||||
// VAL-18 还没做过规范化(信息)—— 提醒但不阻断
|
||||
if (f.getStandardName() == null) {
|
||||
out.add(Issue.info("VAL-18", "「" + def.getMaterialName() + "」还没压缩改名",
|
||||
"系统会在提交前完成压缩与改名。如果一直是这个状态,请删掉重新上传"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// VAL-09 重复文件(警告)—— 通常是同一张图传到了两个材料项
|
||||
Map<String, List<CollectFile>> byHash = new LinkedHashMap<>();
|
||||
for (CollectFile f : files) {
|
||||
if (f.getFileHash() != null) {
|
||||
byHash.computeIfAbsent(f.getFileHash(), k -> new ArrayList<>()).add(f);
|
||||
}
|
||||
}
|
||||
for (Map.Entry<String, List<CollectFile>> e : byHash.entrySet()) {
|
||||
if (e.getValue().size() > 1) {
|
||||
StringBuilder names = new StringBuilder();
|
||||
for (CollectFile f : e.getValue()) {
|
||||
String n = f.getStandardName() == null ? f.getOriginalName() : f.getStandardName();
|
||||
if (names.length() > 0) {
|
||||
names.append("、");
|
||||
}
|
||||
names.append(n);
|
||||
}
|
||||
out.add(Issue.warn("VAL-09", "有内容完全相同的材料",
|
||||
"这几份是同一张图(" + names + ")。如果其中一份传错了位置,请删掉重传"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 与前端逐字一致的求值 ─────────────────────────
|
||||
|
||||
/**
|
||||
* 条件可见求值。<b>必须与前端 {@code fill.vue} 的 {@code visible()} 逐字一致</b>。
|
||||
*
|
||||
* <p>不一致的后果不是报错,而是两类都无法解释的现象:
|
||||
* 学生在页面上看得见某个字段、后端却说"你没填"(或反过来)。
|
||||
* 这种问题极难排查 —— 因为两边各自看都"没错"。
|
||||
*/
|
||||
static boolean visible(Map<String, Object> when, Map<String, Object> values) {
|
||||
if (when == null) {
|
||||
return true;
|
||||
}
|
||||
Object field = when.get("field");
|
||||
if (field == null || String.valueOf(field).isBlank()) {
|
||||
return true;
|
||||
}
|
||||
Object op = when.get("op");
|
||||
if (op != null && !"eq".equals(String.valueOf(op))) {
|
||||
// 未知运算符一律**可见**(fail-open):宁可多让一个字段参与校验并被学生看见,
|
||||
// 也不要因为求值不了而把本该有的字段藏起来
|
||||
return true;
|
||||
}
|
||||
return norm(values.get(String.valueOf(field))).equals(norm(when.get("value")));
|
||||
}
|
||||
|
||||
/** 布尔的两端表示({@code true/false} 与 {@code 1/0})要归一 —— 与前端同口径。 */
|
||||
private static String norm(Object v) {
|
||||
if (v == null) {
|
||||
return "";
|
||||
}
|
||||
String s = String.valueOf(v).trim();
|
||||
if ("true".equalsIgnoreCase(s)) {
|
||||
return "1";
|
||||
}
|
||||
if ("false".equalsIgnoreCase(s)) {
|
||||
return "0";
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
private static boolean isTrue(Object v) {
|
||||
String s = str(v).trim();
|
||||
return "1".equals(s) || "true".equalsIgnoreCase(s) || "是".equals(s);
|
||||
}
|
||||
|
||||
/** 空判定:{@code null} / 空串算空,但 **{@code "无"} 是合法值,不算空**(PRD §2.6)。 */
|
||||
private static boolean blank(Object v) {
|
||||
return v == null || String.valueOf(v).trim().isEmpty();
|
||||
}
|
||||
|
||||
private static boolean isNone(Object v) {
|
||||
return NONE_WORDS.contains(str(v).trim());
|
||||
}
|
||||
|
||||
private static String str(Object v) {
|
||||
return v == null ? "" : String.valueOf(v);
|
||||
}
|
||||
|
||||
private static Integer parseInt(String s) {
|
||||
try {
|
||||
return Integer.valueOf(s);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user