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:
2026-09-29 12:39:16 +08:00
parent 8ef09733b1
commit 9911ff01f1
7 changed files with 1244 additions and 11 deletions
@@ -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);
}
}
@@ -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);
}
}
}
@@ -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) {
@@ -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;
}
}
}
+227 -8
View File
@@ -108,10 +108,50 @@
<view class="fill__bar">
<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>
</template>
</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>
</template>
@@ -299,7 +339,7 @@ function token(): string {
}
}
async // ── 材料上传 ─────────────────────────────────
// ── 材料上传 ─────────────────────────────────
interface Material {
materialCode: string
@@ -321,12 +361,61 @@ interface UploadedFile {
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 filesByMaterial = ref<Record<string, UploadedFile[]>>({})
/** 已提交/已截止时不给增删材料(与后端同口径)。 */
const filesDeletable = computed(() =>
submissionId.value !== null && (submissionStatus.value ?? 0) === 0)
/** 填报单状态:0 草稿 / 1 待初审 / 2 待终审 / 3 已通过 / 4 已驳回 / 5 已归档。 */
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 {
if (!n) return '0KB'
@@ -428,13 +517,71 @@ function removeFile(f: UploadedFile) {
})
}
/**
* 提交按钮的入口。
*
* <p>⚠️ **不要给这个函数加参数**。模板里写的是 `@click="doSubmit"`,
* 而 uni-app 会把事件对象作为第一个参数传进来 —— 如果签名是
* `doSubmit(confirmWarnings = false)`,那个事件对象会被当成"已确认警告"(真值),
* 于是有警告时会被静默强制提交。这类 bug 不会有任何报错。
*/
function doSubmit() {
// 提交(含校验、红黄绿问题)是下一段。这里明确说清,而不是静默无反应
void submitNow(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: '你填的内容已经自动保存为草稿。提交与校验属下一阶段开发。',
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) => {
@@ -458,6 +605,7 @@ onLoad(async (options) => {
})
sections.value = form.sections || []
submissionId.value = form.submissionId
submissionStatus.value = form.submissionStatus ?? 0
taskName.value = form.taskName
deadlineTip.value = form.submitPolicy?.deadlineTip || ''
const v: Record<string, unknown> = {}
@@ -466,6 +614,8 @@ onLoad(async (options) => {
}
values.value = v
materials.value = (form.materials || []) as Material[]
// 载入已传的材料:有草稿时学生上次传的还在,不拉一次他会以为白传了
await loadFiles()
// 没有草稿就先建一份 —— 否则第一次失焦保存会因为 submissionId 为空而静默不存
if (!submissionId.value) {
@@ -553,5 +703,74 @@ page { background-color: $page-bg; }
font-size: 32rpx; color: #fff; background: $primary; border-radius: 12rpx;
&::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>
+318
View File
@@ -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())