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);
|
submissionService.saveDraft(id, req);
|
||||||
return ApiResult.ok();
|
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.context.LoginUser;
|
||||||
import cn.tianbaotong.fill.common.exception.BizException;
|
import cn.tianbaotong.fill.common.exception.BizException;
|
||||||
import cn.tianbaotong.fill.module.fill.dto.DraftDtos;
|
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.entity.CollectSubmission;
|
||||||
import cn.tianbaotong.fill.module.fill.mapper.SubmissionMapper;
|
import cn.tianbaotong.fill.module.fill.mapper.SubmissionMapper;
|
||||||
import cn.tianbaotong.fill.module.form.dto.FormFieldVO;
|
import cn.tianbaotong.fill.module.form.dto.FormFieldVO;
|
||||||
@@ -24,6 +25,7 @@ import java.time.LocalDate;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -53,6 +55,7 @@ public class SubmissionService {
|
|||||||
private final TaskMapper taskMapper;
|
private final TaskMapper taskMapper;
|
||||||
private final FormSchemaService formSchemaService;
|
private final FormSchemaService formSchemaService;
|
||||||
private final MpRosterMapper rosterMapper;
|
private final MpRosterMapper rosterMapper;
|
||||||
|
private final SubmissionValidator validator;
|
||||||
|
|
||||||
// ── §9.3 取草稿(幂等)────────────────────────
|
// ── §9.3 取草稿(幂等)────────────────────────
|
||||||
|
|
||||||
@@ -225,6 +228,73 @@ public class SubmissionService {
|
|||||||
.set(CollectSubmission::getChangedFields, sub.getChangedFields()));
|
.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) {
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,10 +108,50 @@
|
|||||||
|
|
||||||
<view class="fill__bar">
|
<view class="fill__bar">
|
||||||
<text class="fill__save-hint">{{ saveHint }}</text>
|
<text class="fill__save-hint">{{ saveHint }}</text>
|
||||||
<button class="fill__btn" @click="doSubmit">提交审核</button>
|
<button v-if="canEdit" class="fill__btn" :disabled="submitting" @click="doSubmit">
|
||||||
|
{{ submitting ? '正在校验…' : '提交审核' }}
|
||||||
|
</button>
|
||||||
|
<view v-else class="fill__done">{{ statusText }}</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 校验问题清单(红黄绿)。
|
||||||
|
用页面内浮层而不是 uni.showModal:showModal 只能放一段纯文本,
|
||||||
|
而问题清单要分级上色、每条要带规则编号 —— 这些正是学生能看懂"哪里不对"的关键。
|
||||||
|
全屏纯文本会让人只看到"提交失败",然后放弃 -->
|
||||||
|
<view v-if="report" class="fill__mask" @click="report = null">
|
||||||
|
<view class="fill__sheet" @click.stop>
|
||||||
|
<view class="fill__sheet-head">
|
||||||
|
<text class="fill__sheet-title">{{ reportTitle }}</text>
|
||||||
|
<text class="fill__sheet-close" @click="report = null">关闭</text>
|
||||||
|
</view>
|
||||||
|
<view class="fill__sheet-sum">
|
||||||
|
<text v-if="report.redCount" class="fill__pill fill__pill--red">
|
||||||
|
必须处理 {{ report.redCount }}
|
||||||
|
</text>
|
||||||
|
<text v-if="report.yellowCount" class="fill__pill fill__pill--yellow">
|
||||||
|
提醒 {{ report.yellowCount }}
|
||||||
|
</text>
|
||||||
|
<text v-if="report.infoCount" class="fill__pill fill__pill--info">
|
||||||
|
提示 {{ report.infoCount }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<scroll-view scroll-y class="fill__sheet-body">
|
||||||
|
<view v-for="(it, i) in report.issues" :key="i"
|
||||||
|
class="fill__issue" :class="'fill__issue--s' + it.severity">
|
||||||
|
<view class="fill__issue-top">
|
||||||
|
<text class="fill__issue-code">{{ it.ruleCode }}</text>
|
||||||
|
<text class="fill__issue-title">{{ it.title }}</text>
|
||||||
|
</view>
|
||||||
|
<text v-if="it.detail" class="fill__issue-detail">{{ it.detail }}</text>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
<view class="fill__sheet-foot">
|
||||||
|
<button class="fill__btn" @click="onReportAction">{{ reportAction }}</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -299,7 +339,7 @@ function token(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async // ── 材料上传 ─────────────────────────────────
|
// ── 材料上传 ─────────────────────────────────
|
||||||
|
|
||||||
interface Material {
|
interface Material {
|
||||||
materialCode: string
|
materialCode: string
|
||||||
@@ -321,12 +361,61 @@ interface UploadedFile {
|
|||||||
height?: number
|
height?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SubmitIssue {
|
||||||
|
ruleCode: string
|
||||||
|
severity: number
|
||||||
|
title: string
|
||||||
|
detail?: string | null
|
||||||
|
}
|
||||||
|
interface SubmitReport {
|
||||||
|
submitted: boolean
|
||||||
|
status: number
|
||||||
|
statusText: string
|
||||||
|
redCount: number
|
||||||
|
yellowCount: number
|
||||||
|
infoCount: number
|
||||||
|
issues: SubmitIssue[]
|
||||||
|
}
|
||||||
|
|
||||||
const materials = ref<Material[]>([])
|
const materials = ref<Material[]>([])
|
||||||
const filesByMaterial = ref<Record<string, UploadedFile[]>>({})
|
const filesByMaterial = ref<Record<string, UploadedFile[]>>({})
|
||||||
/** 已提交/已截止时不给增删材料(与后端同口径)。 */
|
/** 填报单状态:0 草稿 / 1 待初审 / 2 待终审 / 3 已通过 / 4 已驳回 / 5 已归档。 */
|
||||||
const filesDeletable = computed(() =>
|
|
||||||
submissionId.value !== null && (submissionStatus.value ?? 0) === 0)
|
|
||||||
const submissionStatus = ref<number>(0)
|
const submissionStatus = ref<number>(0)
|
||||||
|
const submitting = ref(false)
|
||||||
|
/** 提交结果报告。null = 没有要展示的清单。 */
|
||||||
|
const report = ref<SubmitReport | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可编辑 = 草稿 或 被驳回。
|
||||||
|
*
|
||||||
|
* <p>已提交/已通过/已归档都不给改,与后端同口径(后端也会拒)。
|
||||||
|
* 前端隐藏按钮不是为了"安全",而是**别让学生白改一遍再被拒** ——
|
||||||
|
* 他改了半小时、点了提交,才被告知"要改先撤回",那个体验很伤人。
|
||||||
|
*/
|
||||||
|
const canEdit = computed(() => {
|
||||||
|
const st = submissionStatus.value
|
||||||
|
return submissionId.value !== null && (st === 0 || st === 4)
|
||||||
|
})
|
||||||
|
const filesDeletable = canEdit
|
||||||
|
|
||||||
|
const statusText = computed(() => {
|
||||||
|
const t: Record<number, string> = {
|
||||||
|
1: '已提交,等待班主任初审',
|
||||||
|
2: '初审通过,等待管理员终审',
|
||||||
|
3: '已通过',
|
||||||
|
4: '已驳回,请修改后重新提交',
|
||||||
|
5: '已归档'
|
||||||
|
}
|
||||||
|
return t[submissionStatus.value] ?? '草稿'
|
||||||
|
})
|
||||||
|
|
||||||
|
const reportBlocked = computed(() => (report.value?.redCount ?? 0) > 0)
|
||||||
|
const reportTitle = computed(() =>
|
||||||
|
reportBlocked.value ? '还不能提交' : '提交前请确认'
|
||||||
|
)
|
||||||
|
const reportAction = computed(() =>
|
||||||
|
reportBlocked.value ? '知道了,去修改' : '仍然提交'
|
||||||
|
)
|
||||||
|
|
||||||
function kb(n?: number): string {
|
function kb(n?: number): string {
|
||||||
if (!n) return '0KB'
|
if (!n) return '0KB'
|
||||||
@@ -428,13 +517,71 @@ function removeFile(f: UploadedFile) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交按钮的入口。
|
||||||
|
*
|
||||||
|
* <p>⚠️ **不要给这个函数加参数**。模板里写的是 `@click="doSubmit"`,
|
||||||
|
* 而 uni-app 会把事件对象作为第一个参数传进来 —— 如果签名是
|
||||||
|
* `doSubmit(confirmWarnings = false)`,那个事件对象会被当成"已确认警告"(真值),
|
||||||
|
* 于是有警告时会被静默强制提交。这类 bug 不会有任何报错。
|
||||||
|
*/
|
||||||
function doSubmit() {
|
function doSubmit() {
|
||||||
// 提交(含校验、红黄绿问题)是下一段。这里明确说清,而不是静默无反应
|
void submitNow(false)
|
||||||
uni.showModal({
|
}
|
||||||
title: '提交功能开发中',
|
|
||||||
content: '你填的内容已经自动保存为草稿。提交与校验属下一阶段开发。',
|
/**
|
||||||
showCancel: false
|
* 真正提交。
|
||||||
})
|
*
|
||||||
|
* <p>**两次调用是刻意的**:第一次不带 {@code confirmWarnings},
|
||||||
|
* 服务端若发现只有黄色警告就返回 {@code submitted=false} 让我们弹确认;
|
||||||
|
* 学生点「仍然提交」才带 {@code confirmWarnings=true} 再调一次。
|
||||||
|
*
|
||||||
|
* <p>为什么不在前端自己判断"只有警告就直接放行":
|
||||||
|
* 因为只有服务端的校验结果算数 —— 两次请求之间学生可能又改坏了什么。
|
||||||
|
* 前端手上那份清单只用来渲染,不能用来做决定。
|
||||||
|
*/
|
||||||
|
async function submitNow(confirmWarnings: boolean) {
|
||||||
|
if (!submissionId.value || submitting.value) return
|
||||||
|
submitting.value = true
|
||||||
|
uni.showLoading({ title: '正在校验…' })
|
||||||
|
try {
|
||||||
|
const r = await request<SubmitReport>({
|
||||||
|
url: `/api/v1/mp/submissions/${submissionId.value}/submit`,
|
||||||
|
method: 'POST',
|
||||||
|
token: token(),
|
||||||
|
data: { confirmWarnings }
|
||||||
|
})
|
||||||
|
if (r.submitted) {
|
||||||
|
report.value = null
|
||||||
|
submissionStatus.value = r.status ?? 1
|
||||||
|
uni.showModal({
|
||||||
|
title: '提交成功',
|
||||||
|
content: r.statusText || '已提交,等待班主任初审',
|
||||||
|
showCancel: false
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
report.value = r
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '提交失败',
|
||||||
|
content: e instanceof Error ? e.message : '请稍后重试',
|
||||||
|
showCancel: false
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
uni.hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onReportAction() {
|
||||||
|
if (reportBlocked.value) {
|
||||||
|
// 有红色项:关掉清单让他去改。**不要**顺手滚到第一个错误处 ——
|
||||||
|
// 那会在学生正在读清单时把页面拽走
|
||||||
|
report.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void submitNow(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoad(async (options) => {
|
onLoad(async (options) => {
|
||||||
@@ -458,6 +605,7 @@ onLoad(async (options) => {
|
|||||||
})
|
})
|
||||||
sections.value = form.sections || []
|
sections.value = form.sections || []
|
||||||
submissionId.value = form.submissionId
|
submissionId.value = form.submissionId
|
||||||
|
submissionStatus.value = form.submissionStatus ?? 0
|
||||||
taskName.value = form.taskName
|
taskName.value = form.taskName
|
||||||
deadlineTip.value = form.submitPolicy?.deadlineTip || ''
|
deadlineTip.value = form.submitPolicy?.deadlineTip || ''
|
||||||
const v: Record<string, unknown> = {}
|
const v: Record<string, unknown> = {}
|
||||||
@@ -466,6 +614,8 @@ onLoad(async (options) => {
|
|||||||
}
|
}
|
||||||
values.value = v
|
values.value = v
|
||||||
materials.value = (form.materials || []) as Material[]
|
materials.value = (form.materials || []) as Material[]
|
||||||
|
// 载入已传的材料:有草稿时学生上次传的还在,不拉一次他会以为白传了
|
||||||
|
await loadFiles()
|
||||||
|
|
||||||
// 没有草稿就先建一份 —— 否则第一次失焦保存会因为 submissionId 为空而静默不存
|
// 没有草稿就先建一份 —— 否则第一次失焦保存会因为 submissionId 为空而静默不存
|
||||||
if (!submissionId.value) {
|
if (!submissionId.value) {
|
||||||
@@ -553,5 +703,74 @@ page { background-color: $page-bg; }
|
|||||||
font-size: 32rpx; color: #fff; background: $primary; border-radius: 12rpx;
|
font-size: 32rpx; color: #fff; background: $primary; border-radius: 12rpx;
|
||||||
&::after { border: none; }
|
&::after { border: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 材料上传 ──
|
||||||
|
&__section-note { margin-bottom: 20rpx; font-size: 24rpx; color: #00000099; line-height: 1.5; }
|
||||||
|
|
||||||
|
&__mat {
|
||||||
|
padding: 24rpx 0;
|
||||||
|
& + & { border-top: 1rpx solid #f0f0f0; }
|
||||||
|
}
|
||||||
|
&__mat-head { display: flex; align-items: center; flex-wrap: wrap; gap: 12rpx; }
|
||||||
|
&__mat-name { font-size: 30rpx; font-weight: 600; color: #000000e6; }
|
||||||
|
&__mat-req { width: 100%; font-size: 24rpx; color: #0000004d; }
|
||||||
|
&__mat-tip { margin-top: 10rpx; font-size: 24rpx; color: #00000099; line-height: 1.5; }
|
||||||
|
&__mat-btn {
|
||||||
|
display: inline-block; margin: 20rpx 0 0; padding: 0 28rpx; font-size: 26rpx;
|
||||||
|
line-height: 2.3; color: $primary; background: #ecf2fe; border-radius: 8rpx;
|
||||||
|
&::after { border: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已传的材料:要让学生一眼看清「原名 → 规范名」「压前 → 压后」——
|
||||||
|
// 这正是他花钱买的那个能力,藏起来等于没做
|
||||||
|
&__file { margin-top: 16rpx; padding: 16rpx 20rpx; background: #f7f8fa; border-radius: 10rpx; }
|
||||||
|
&__file-line { display: flex; align-items: baseline; justify-content: space-between; gap: 20rpx; }
|
||||||
|
&__file-name { flex: 1; font-size: 26rpx; color: #000000e6; word-break: break-all; }
|
||||||
|
&__file-del { flex-shrink: 0; font-size: 24rpx; color: $danger; }
|
||||||
|
&__file-meta { margin-top: 8rpx; font-size: 22rpx; color: #0000004d; }
|
||||||
|
&__file-ok { color: #00a870; }
|
||||||
|
&__file-pending { color: #ed7b2f; }
|
||||||
|
|
||||||
|
&__done {
|
||||||
|
padding: 22rpx 0; font-size: 28rpx; text-align: center; color: #00000073;
|
||||||
|
background: #f0f0f0; border-radius: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 校验问题清单(红黄绿)──
|
||||||
|
&__mask {
|
||||||
|
position: fixed; left: 0; right: 0; top: 0; bottom: 0; z-index: 90;
|
||||||
|
display: flex; align-items: flex-end; background: rgba(0, 0, 0, .45);
|
||||||
|
}
|
||||||
|
&__sheet {
|
||||||
|
box-sizing: border-box; width: 100%; max-height: 82vh; padding: 28rpx 28rpx 40rpx;
|
||||||
|
background: #fff; border-radius: 24rpx 24rpx 0 0;
|
||||||
|
}
|
||||||
|
&__sheet-head { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
&__sheet-title { font-size: 34rpx; font-weight: 600; color: #000000e6; }
|
||||||
|
&__sheet-close { font-size: 26rpx; color: #00000073; }
|
||||||
|
&__sheet-sum { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 20rpx; }
|
||||||
|
&__pill { padding: 6rpx 18rpx; font-size: 22rpx; border-radius: 999rpx; }
|
||||||
|
&__pill--red { color: $danger; background: #fdecee; }
|
||||||
|
&__pill--yellow { color: #ed7b2f; background: #fef3e8; }
|
||||||
|
&__pill--info { color: #0052d9; background: #ecf2fe; }
|
||||||
|
&__sheet-body { max-height: 52vh; margin-top: 20rpx; }
|
||||||
|
|
||||||
|
&__issue {
|
||||||
|
margin-bottom: 16rpx; padding: 20rpx; border-left: 8rpx solid #dcdcdc;
|
||||||
|
background: #f7f8fa; border-radius: 8rpx;
|
||||||
|
&--s3 { border-left-color: $danger; background: #fdf3f4; }
|
||||||
|
&--s2 { border-left-color: #ed7b2f; background: #fef8f2; }
|
||||||
|
&--s1 { border-left-color: #0052d9; background: #f4f7fe; }
|
||||||
|
}
|
||||||
|
&__issue-top { display: flex; align-items: baseline; gap: 12rpx; }
|
||||||
|
&__issue-code {
|
||||||
|
flex-shrink: 0; padding: 2rpx 10rpx; font-size: 20rpx; color: #00000073;
|
||||||
|
background: #ffffff; border-radius: 6rpx;
|
||||||
|
}
|
||||||
|
&__issue-title { flex: 1; font-size: 28rpx; font-weight: 600; color: #000000e6; }
|
||||||
|
&__issue-detail {
|
||||||
|
display: block; margin-top: 10rpx; font-size: 26rpx; line-height: 1.6; color: #00000099;
|
||||||
|
}
|
||||||
|
&__sheet-foot { margin-top: 24rpx; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
提交与校验 验收测试(PRD §5.7 的 VAL 系列 + §9.8 提交)。
|
||||||
|
|
||||||
|
[x] 缺材料 / 缺必填字段 → **阻断**,且**一次给全**(不是改一条再拒一次)
|
||||||
|
[x] VAL-16 在校生三项:缺一项就拦
|
||||||
|
[x] VAL-15 补考科目数按等级限制(四级最多 1 科)
|
||||||
|
[x] 只有黄色警告时:先返回 submitted=false 让学生确认,confirmWarnings=true 才真的提交
|
||||||
|
[x] 提交成功后状态 → 1(待初审),再提交被拒
|
||||||
|
[x] 红黄计数落库(班主任端要按它排序)
|
||||||
|
|
||||||
|
⚠️ 这个 E2E 的重点不是"能不能提交",而是**拦得对不对**。
|
||||||
|
一个校验器最危险的状态是"永远通过" —— 它看起来一切正常,
|
||||||
|
直到机构把整批材料退回来。所以下面每条规则都断言了"该拦的确实拦了"和"修好后确实放过"。
|
||||||
|
|
||||||
|
跑法:python scripts/e2e-m3-submit.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
SRC = os.path.join(ROOT, "backend", "xt-server", "src", "main", "resources")
|
||||||
|
PG = os.environ.get("XT_PG_CONTAINER", "xt-pg")
|
||||||
|
UID = 992002
|
||||||
|
|
||||||
|
PASSED: list[str] = []
|
||||||
|
FAILED: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
text = "\n".join(open(os.path.join(SRC, f), encoding="utf-8").read()
|
||||||
|
for f in ("application-dev.yml", "application.yml")
|
||||||
|
if os.path.exists(os.path.join(SRC, 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) -> 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": ["STUDENT"], "userType": 4,
|
||||||
|
"iat": now, "exp": now + 3600}, separators=(",", ":")).encode()))
|
||||||
|
return signing + "." + b64u(hmac.new(key, signing.encode(), hashlib.sha512).digest())
|
||||||
|
|
||||||
|
|
||||||
|
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=60) 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 upload(token: str, sid: int, code: str, filename: str, data: bytes) -> dict:
|
||||||
|
boundary = "----xte2e" + uuid.uuid4().hex[:12]
|
||||||
|
parts = []
|
||||||
|
for k, v in [("submissionId", str(sid)), ("materialCode", code)]:
|
||||||
|
parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode())
|
||||||
|
parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{filename}"\r\n'
|
||||||
|
f"Content-Type: image/jpeg\r\n\r\n".encode())
|
||||||
|
parts.append(data)
|
||||||
|
parts.append(f"\r\n--{boundary}--\r\n".encode())
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"http://localhost:8080/api/v1/mp/files/upload", data=b"".join(parts), method="POST",
|
||||||
|
headers={"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||||
|
"Authorization": f"Bearer {token}"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=90) 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 small_jpeg() -> bytes:
|
||||||
|
from PIL import Image
|
||||||
|
buf = io.BytesIO()
|
||||||
|
Image.new("RGB", (900, 1200), (200, 210, 220)).save(buf, "JPEG", quality=90)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def rules(r: dict) -> set[str]:
|
||||||
|
return {i.get("ruleCode") for i in ((r.get("data") or {}).get("issues") or [])}
|
||||||
|
|
||||||
|
|
||||||
|
def sub(token: str, sid: int, confirm: bool = False) -> dict:
|
||||||
|
return call("POST", f"/api/v1/mp/submissions/{sid}/submit", token, {"confirmWarnings": confirm})
|
||||||
|
|
||||||
|
|
||||||
|
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, tenant = args.task, args.tenant
|
||||||
|
base = "/api/v1/mp"
|
||||||
|
|
||||||
|
print("=" * 72)
|
||||||
|
print(f"提交与校验 E2E task={task_id}")
|
||||||
|
print("=" * 72)
|
||||||
|
|
||||||
|
cfg = read_cfg()
|
||||||
|
tok = mint(cfg["jwt"], cfg["issuer"], UID, tenant)
|
||||||
|
|
||||||
|
rid = sql(f"SELECT id FROM collect_roster WHERE task_id={task_id} LIMIT 1")
|
||||||
|
if not rid.isdigit():
|
||||||
|
fail("取名册行", "任务下没有名册")
|
||||||
|
return 1
|
||||||
|
sql(f"UPDATE collect_roster SET bind_user_id={UID}, bind_type=1 WHERE id={rid}")
|
||||||
|
|
||||||
|
r = call("POST", f"{base}/submissions", tok, {"taskId": task_id})
|
||||||
|
if r.get("code") != 0:
|
||||||
|
fail("建草稿", f"{r}")
|
||||||
|
return 1
|
||||||
|
sid = r["data"]["id"]
|
||||||
|
print(f" submission={sid} roster={rid}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# ── 1. 什么都没填就提交:必须被拦,且一次给全 ──
|
||||||
|
print("\n[1] 空着提交 → 必须拦住")
|
||||||
|
r = sub(tok, sid)
|
||||||
|
check("HTTP 200 + code=0(校验不通过是业务结果,不是异常)",
|
||||||
|
r.get("code") == 0, f"code={r.get('code')} msg={r.get('msg')}")
|
||||||
|
d = r.get("data") or {}
|
||||||
|
check("submitted=false", d.get("submitted") is False, f"{d.get('submitted')}")
|
||||||
|
check("redCount > 0", (d.get("redCount") or 0) > 0, f"red={d.get('redCount')}")
|
||||||
|
check("**一次给全**:问题数 ≥ 3(不是只报第一条)",
|
||||||
|
len(d.get("issues") or []) >= 3, f"{len(d.get('issues') or [])} 条")
|
||||||
|
rs = rules(r)
|
||||||
|
check("VAL-05 缺必传材料(身份证)", "VAL-05" in rs, f"实际规则: {sorted(rs)}")
|
||||||
|
check("VAL-22 必填字段缺失", "VAL-22" in rs, f"实际规则: {sorted(rs)}")
|
||||||
|
check("VAL-01 **不该误报**(id_card_no 存的是 AES 密文,不能当明文算校验位 —— 实测错过一次,表现是 100% 阻断)",
|
||||||
|
"VAL-01" not in rs, f"实际规则: {sorted(rs)}")
|
||||||
|
check("状态没被改动(仍是草稿)", d.get("status") == 0, f"status={d.get('status')}")
|
||||||
|
db = sql(f"SELECT status || '|' || coalesce(submit_time::text,'-') FROM collect_submission WHERE id={sid}")
|
||||||
|
check("库里也没被改成已提交", db.split("|")[0] == "0", f"{db}")
|
||||||
|
|
||||||
|
# ── 2. VAL-16 在校生三项 ──
|
||||||
|
print("\n[2] 在校生三项(VAL-16)")
|
||||||
|
fill = {"work_unit": "无", "is_retake": False, "is_student": True,
|
||||||
|
"work_years": "3", "cert_occupation": "", "cert_level": "", "cert_no": ""}
|
||||||
|
call("PUT", f"{base}/submissions/{sid}", tok, {"formData": fill})
|
||||||
|
r = sub(tok, sid)
|
||||||
|
rs = rules(r)
|
||||||
|
check("isStudent=是 但三项都空 → VAL-16", "VAL-16" in rs, f"实际: {sorted(rs)}")
|
||||||
|
# 只补一项,还应该拦
|
||||||
|
call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {"school": "示范职业学校"}})
|
||||||
|
r = sub(tok, sid)
|
||||||
|
check("只补了 1 项仍然拦(缺项要列全)", "VAL-16" in rules(r), f"实际: {sorted(rules(r))}")
|
||||||
|
call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {
|
||||||
|
"school": "示范职业学校", "major": "数控技术", "enroll_date": "2023-09-01"}})
|
||||||
|
r = sub(tok, sid)
|
||||||
|
check("三项补齐 → VAL-16 消失", "VAL-16" not in rules(r), f"实际: {sorted(rules(r))}")
|
||||||
|
|
||||||
|
# ── 3. VAL-15 补考科目数 ──
|
||||||
|
print("\n[3] 补考科目数按等级(VAL-15)")
|
||||||
|
call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {"is_retake": True}})
|
||||||
|
r = sub(tok, sid)
|
||||||
|
check("勾了补考但一科没选 → VAL-15(警告,可提交)",
|
||||||
|
"VAL-15" in rules(r), f"实际: {sorted(rules(r))}")
|
||||||
|
check("这一条是警告不是错误(severity=2)",
|
||||||
|
any(i.get("ruleCode") == "VAL-15" and i.get("severity") == 2
|
||||||
|
for i in (r.get("data") or {}).get("issues") or []),
|
||||||
|
"severity 不对")
|
||||||
|
# 四级最多 1 科:勾 2 科必须拦
|
||||||
|
call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {
|
||||||
|
"retake_theory": "理论", "retake_practice": "实操"}})
|
||||||
|
r = sub(tok, sid)
|
||||||
|
check("四级勾 2 科 → VAL-15 阻断", "VAL-15" in rules(r), f"实际: {sorted(rules(r))}")
|
||||||
|
check("升级为错误(severity=3)",
|
||||||
|
any(i.get("ruleCode") == "VAL-15" and i.get("severity") == 3
|
||||||
|
for i in (r.get("data") or {}).get("issues") or []),
|
||||||
|
"severity 不对")
|
||||||
|
# ⚠️ 补考三科都是**必填且可见**(is_retake=1),所以不能留空 ——
|
||||||
|
# 学生必须每项都明确选,「无」是合法值(PRD §2.6 的三条铁律)。
|
||||||
|
# 用「无」而不是 "" 也正是为了把这条铁律跑一遍
|
||||||
|
call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {
|
||||||
|
"retake_practice": "无", "retake_overall": "无"}})
|
||||||
|
r = sub(tok, sid)
|
||||||
|
check("只留 1 科 → 不再有 VAL-15 错误",
|
||||||
|
"VAL-15" not in rules(r), f"实际: {sorted(rules(r))}")
|
||||||
|
|
||||||
|
# ── 4. 传材料 → VAL-05 消失 ──
|
||||||
|
print("\n[4] 传上必传材料")
|
||||||
|
img = small_jpeg()
|
||||||
|
up = upload(tok, sid, "IDCARD", "身份证正面.jpg", img)
|
||||||
|
if not check("上传成功", up.get("code") == 0, f"{up}"):
|
||||||
|
return 1
|
||||||
|
call("POST", f"/api/v1/mp/files/{up['data']['id']}/process", tok)
|
||||||
|
# 同一张图再传一份(IDCARD 允许 2 份)→ 制造一条**可预期的黄色警告**,
|
||||||
|
# 否则"无红无黄 → 直接提交成功",下一步那条"先确认再提交"的路径就成了没被测过的代码
|
||||||
|
up2 = upload(tok, sid, "IDCARD", "身份证反面.jpg", img)
|
||||||
|
if up2.get("code") == 0:
|
||||||
|
call("POST", f"/api/v1/mp/files/{up2['data']['id']}/process", tok)
|
||||||
|
r = sub(tok, sid)
|
||||||
|
rs = rules(r)
|
||||||
|
check("VAL-05 消失(材料齐了)", "VAL-05" not in rs, f"实际: {sorted(rs)}")
|
||||||
|
check("VAL-09 同一张图传两份 → 黄色警告", "VAL-09" in rs, f"实际: {sorted(rs)}")
|
||||||
|
check("有警告但无红色 → 未确认时**不提交**",
|
||||||
|
(r.get("data") or {}).get("submitted") is False, f"{r.get('data')}")
|
||||||
|
|
||||||
|
# ── 5. 只剩警告 → 两次提交 ──
|
||||||
|
print("\n[5] 只有警告时:先确认,再提交")
|
||||||
|
r = sub(tok, sid, confirm=False)
|
||||||
|
d = r.get("data") or {}
|
||||||
|
if not d:
|
||||||
|
print(f" ⚠ 响应没有 data:{json.dumps(r, ensure_ascii=False)[:300]}")
|
||||||
|
rs = rules(r)
|
||||||
|
red = [i for i in (d.get("issues") or []) if i.get("severity") == 3]
|
||||||
|
check("已无红色项", not red, f"仍有红: {[i.get('ruleCode') for i in red]}")
|
||||||
|
if (d.get("yellowCount") or 0) > 0:
|
||||||
|
check("有警告且未确认 → submitted=false(等学生确认)",
|
||||||
|
d.get("submitted") is False, f"{d.get('submitted')}")
|
||||||
|
r2 = sub(tok, sid, confirm=True)
|
||||||
|
check("带 confirmWarnings=true → 提交成功",
|
||||||
|
(r2.get("data") or {}).get("submitted") is True, f"{r2.get('data')}")
|
||||||
|
else:
|
||||||
|
check("没有警告 → 直接提交成功", d.get("submitted") is True, f"{d}")
|
||||||
|
|
||||||
|
st = sql(f"SELECT status || '|' || (submit_time IS NOT NULL)::text || '|' || "
|
||||||
|
f"red_count || '|' || yellow_count FROM collect_submission WHERE id={sid}")
|
||||||
|
parts = st.split("|")
|
||||||
|
check("库里状态 → 1(待初审)", parts[0] == "1", f"status={parts[0]}")
|
||||||
|
check("submit_time 已写入", parts[1] == "true", f"{parts[1]}")
|
||||||
|
check("红黄计数落库(班主任端要用它排序)", parts[2] == "0", f"red={parts[2]} yellow={parts[3]}")
|
||||||
|
|
||||||
|
# ── 6. 防重复提交 ──
|
||||||
|
print("\n[6] 防重复提交")
|
||||||
|
r = sub(tok, sid)
|
||||||
|
check("再提交被拒", r.get("code") not in (0, None), f"code={r.get('code')}")
|
||||||
|
check("提示里说明了当前状态(学生最怕不知道提交成功没有)",
|
||||||
|
"已提交" in (r.get("msg") or "") or "初审" in (r.get("msg") or ""),
|
||||||
|
f"msg={r.get('msg')}")
|
||||||
|
finally:
|
||||||
|
print("\n[7] 清理")
|
||||||
|
sql(f"DELETE FROM collect_file WHERE submission_id={sid}")
|
||||||
|
sql(f"DELETE FROM collect_submission WHERE id={sid}")
|
||||||
|
sql(f"UPDATE collect_roster SET bind_user_id=NULL, bind_time=NULL, bind_type=NULL WHERE id={rid}")
|
||||||
|
import shutil
|
||||||
|
for sub2 in ("raw", "std"):
|
||||||
|
d2 = os.path.join(ROOT, "backend", "data", "uploads", sub2, "1", str(task_id))
|
||||||
|
if os.path.exists(d2):
|
||||||
|
shutil.rmtree(d2, ignore_errors=True)
|
||||||
|
ok("已清理")
|
||||||
|
|
||||||
|
print("\n" + "=" * 72)
|
||||||
|
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
|
||||||
|
for n, dd in FAILED:
|
||||||
|
print(f" · {n}\n {dd}")
|
||||||
|
print("=" * 72)
|
||||||
|
return 1 if FAILED else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user