extra) {
+ return new Issue(code, severity, title, detail, extra);
+ }
+ }
+
+ /**
+ * 提交结果报告。
+ *
+ * 不论成败都返回完整清单(而不是"失败就只给一个错误码"):
+ * 学生一次可能同时缺 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 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 的"可强制通过")。
+ * 只对黄色有效;红色是阻断级,这个参数绕不过去
+ */
+ public record SubmitReq(Boolean confirmWarnings) {
+
+ public boolean confirmed() {
+ return Boolean.TRUE.equals(confirmWarnings);
+ }
+ }
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/SubmissionService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/SubmissionService.java
index a6f1e39..01983e0 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/SubmissionService.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/SubmissionService.java
@@ -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 系列)。
+ *
+ * 校验不通过不是"异常",是正常的业务结果。
+ * 所以它在 HTTP 层永远是 200,具体看返回的 {@code Report}:
+ * 红 > 0 → 必须改({@code submitted=false});红 == 0 且黄 > 0 且未确认 → 请学生确认警告
+ * ({@code submitted=false},前端弹"这些是提醒,仍要提交吗");再调一次带
+ * {@code confirmWarnings=true} 才真正提交。
+ *
+ *
为什么要"再调一次"而不是前端自己判断:
+ * 因为只有服务端的校验结果算数 —— 学生在两次请求之间可能又改坏了什么。
+ * 前端拿到的那个清单只是用来渲染,不是用来做决定的。
+ *
+ *
⚠️ 一期不做并发保护(同一份填报单被两个端同时提交)。
+ * 学生的真实场景是一人一机;真要做的话应当是 `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 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.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.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) {
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/SubmissionValidator.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/SubmissionValidator.java
new file mode 100644
index 0000000..d98ca75
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/SubmissionValidator.java
@@ -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 系列)。
+ *
+ * 唯一的取值口径:{@link FillService#getForm} 装配出来的那一份
+ *
+ * 不要自己去读列。这条是本类最容易写错、且后果最难查的地方 ——
+ * 实测踩过三次:
+ *
+ * - 字段 key 是 snake_case({@code work_unit}、{@code job_level}、{@code enroll_date}),
+ * 不是列名。写 {@code jobLevel} 会读到一个不存在的字段,表现是"明明填了却说没填"。
+ * - 报考三项({@code occupation}/{@code worktype}/{@code job_level})
+ * 存在 {@code ext_json} 里,不是独立列(契约 §2.4)。读列永远是空的。
+ * - 同一份数据被两条规则用两种方式读 → 两条规则互相矛盾
+ * (VAL-16 说"缺所学专业"、VAL-22 同时说"不缺")。这种自相矛盾比单纯漏判更糟:
+ * 学生不知道该信哪一条。
+ *
+ *
+ * 另外两条贯穿全篇的原则
+ *
+ * ① 一次给全,不要"改一条再拒一次"。
+ * 学生一次可能同时缺 3 项材料、2 个字段没填。逐条拒绝会让他改 5 次、提交 5 次。
+ *
+ *
② 「用户选择了无」与「用户没填」必须能区分(PRD §2.6 的三条铁律)。
+ * 学历填「无」是合法业务值;补考科目填「无」表示"不补这一科",0 科是合法值。
+ * 把它们当成"没填"是这套系统最容易犯、且最难被测试发现的错 ——
+ * 因为开发自己测试时永远填真实值,不会填「无」。
+ *
+ *
③ 不可见的字段不参与必填判定。
+ * 「补考理论」只在「是否补考=是」时可见;在校生不该被要求交工作证明。
+ * 求值口径必须与前端 {@code fill.vue} 的 {@code visible()} **逐字一致**,
+ * 否则会出现"学生看得见、后端说你没填"(或反过来)这种最让人火大的问题。
+ *
+ *
⚠️ 关于 {@code VAL-22}:PRD 的 VAL-01…VAL-21 是**具体业务规则**,没有覆盖
+ * 「动态表单的必填字段」。因为表单字段是配置出来的,缺这一条会有漏洞,
+ * 所以按系列顺延补了 VAL-22 = 表单必填字段缺失 —— 这是**新增**,不是 PRD 原有条目。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class SubmissionValidator {
+
+ // ── 字段 key(唯一来源:BuiltinFields / 表单 schema,全部 snake_case)──
+
+ /** 报考三项(VAL-13)。锁定字段,值来自名册的 ext_json。 */
+ private static final List PLAN_KEYS = List.of(
+ new String[]{"occupation", "报考职业"},
+ new String[]{"worktype", "报考工种"},
+ new String[]{"job_level", "报考等级"});
+
+ /** 在校生三项(VAL-16)。 */
+ private static final List 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 NONE_WORDS = Set.of("无", "没有", "none", "无补考");
+
+ private final MaterialDefService materialDefService;
+ private final FileMapper fileMapper;
+ private final FillService fillService;
+
+ public List validate(CollectSubmission s, CollectTask task, CollectRoster roster) {
+ List out = new ArrayList<>();
+
+ // 唯一取值口径:学生看到的那一份装配结果(含条件可见性所需的一切)
+ StudentFormVO form = fillService.getForm(s.getTaskId());
+ Map 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 证件号 ────────────────────────────────
+
+ /**
+ * ⚠️ 这里不能算校验位。
+ *
+ * {@code collect_roster.id_card_no} 是 AES 密文(列注释写明了),
+ * 明文只在两个时刻短暂存在:导入时(M2 名册导入会逐行校验并出报告)
+ * 和门禁时(学生手输,当场三态判定并比对 {@code id_card_hash})。
+ * 到了提交这一步,库里已经没有明文可算了。
+ *
+ *
实测踩过:一开始直接读了 {@code id_card_no} 去算校验位,
+ * 于是每一份填报单都被判"身份证号未通过校验" —— 而那个值其实是密文。
+ * 这种"规则本身很对、但读错了列"的错,表现是100% 阻断,
+ * 反而容易被当成"校验太严"而不是 bug。
+ *
+ *
所以这里只做**能做的**:确认名册里确实有证件号(用脱敏列判断)。
+ * 校验位交给导入与门禁 —— 那两个地方有明文,也有明确的失败出口。
+ */
+ private void checkIdentity(List 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 out, Map values) {
+ List 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 科。
+ *
+ * 这条规则来自评价计划的硬约束 —— 报错了科目,考点按科目排考场,改不了。
+ */
+ private void checkRetake(List out, Map values, CollectSubmission s) {
+ if (!isTrue(values.get("is_retake"))) {
+ return;
+ }
+ List 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 out, Map values) {
+ if (!isTrue(values.get("is_student"))) {
+ return;
+ }
+ List 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 本职业工作年限 ────────────────────────
+
+ /**
+ * 年限只能是阿拉伯数字。
+ *
+ * 输入阶段已经拦过一次(schema 里配了 {@code min:0 max:60}),这里是**兜底**:
+ * 值可能来自历史数据或人工改数。上界 60 是业务判断 ——
+ * 超过它几乎一定是填错了单位(把月当成年)或把工号填进来了。
+ */
+ private void checkWorkYears(List out, Map 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 表单必填字段 ──────────────────────────
+
+ /**
+ * 动态表单里标了必填、且**当前可见**的字段不能为空。
+ *
+ * 三处细节:
+ *
+ * - 要求可见性。实测踩过:不判可见就提交,会要求一个在校生
+ * 填「补考理论」——而那个字段在他的页面上根本不存在。他会以为系统坏了。
+ * - 锁定字段跳过。它们由老师填、学生改不了,报给他也没用;
+ * 那类问题由 VAL-13 单独管(并告诉他去找班主任)。
+ * - 「无」是合法值(PRD §2.6)。填了「无」= 已填,不是缺失。
+ *
+ */
+ private void checkRequiredFields(List out, StudentFormVO form, Map values) {
+ Set already = new LinkedHashSet<>();
+ for (String[] k : PLAN_KEYS) {
+ already.add(k[0]);
+ }
+ for (String[] k : STUDENT_KEYS) {
+ already.add(k[0]);
+ }
+
+ List 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 out, CollectSubmission s, Map values) {
+ List defs = materialDefService.list(s.getTaskId());
+ List files = fileMapper.listMine(s.getTenantId(), s.getId(), s.getSubmitterId());
+ Map> 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 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> byHash = new LinkedHashMap<>();
+ for (CollectFile f : files) {
+ if (f.getFileHash() != null) {
+ byHash.computeIfAbsent(f.getFileHash(), k -> new ArrayList<>()).add(f);
+ }
+ }
+ for (Map.Entry> 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 + ")。如果其中一份传错了位置,请删掉重传"));
+ }
+ }
+ }
+
+ // ── 与前端逐字一致的求值 ─────────────────────────
+
+ /**
+ * 条件可见求值。必须与前端 {@code fill.vue} 的 {@code visible()} 逐字一致。
+ *
+ * 不一致的后果不是报错,而是两类都无法解释的现象:
+ * 学生在页面上看得见某个字段、后端却说"你没填"(或反过来)。
+ * 这种问题极难排查 —— 因为两边各自看都"没错"。
+ */
+ static boolean visible(Map when, Map 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;
+ }
+ }
+}
diff --git a/frontend-mp/src/pages/fill/fill.vue b/frontend-mp/src/pages/fill/fill.vue
index 9a0dff1..7b0d268 100644
--- a/frontend-mp/src/pages/fill/fill.vue
+++ b/frontend-mp/src/pages/fill/fill.vue
@@ -108,10 +108,50 @@
{{ saveHint }}
-
+
+ {{ statusText }}
+
+
+
+
+
+ {{ reportTitle }}
+ 关闭
+
+
+
+ 必须处理 {{ report.redCount }}
+
+
+ 提醒 {{ report.yellowCount }}
+
+
+ 提示 {{ report.infoCount }}
+
+
+
+
+
+ {{ it.ruleCode }}
+ {{ it.title }}
+
+ {{ it.detail }}
+
+
+
+
+
@@ -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([])
const filesByMaterial = ref>({})
-/** 已提交/已截止时不给增删材料(与后端同口径)。 */
-const filesDeletable = computed(() =>
- submissionId.value !== null && (submissionStatus.value ?? 0) === 0)
+/** 填报单状态:0 草稿 / 1 待初审 / 2 待终审 / 3 已通过 / 4 已驳回 / 5 已归档。 */
const submissionStatus = ref(0)
+const submitting = ref(false)
+/** 提交结果报告。null = 没有要展示的清单。 */
+const report = ref(null)
+
+/**
+ * 可编辑 = 草稿 或 被驳回。
+ *
+ * 已提交/已通过/已归档都不给改,与后端同口径(后端也会拒)。
+ * 前端隐藏按钮不是为了"安全",而是**别让学生白改一遍再被拒** ——
+ * 他改了半小时、点了提交,才被告知"要改先撤回",那个体验很伤人。
+ */
+const canEdit = computed(() => {
+ const st = submissionStatus.value
+ return submissionId.value !== null && (st === 0 || st === 4)
+})
+const filesDeletable = canEdit
+
+const statusText = computed(() => {
+ const t: Record = {
+ 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) {
})
}
+/**
+ * 提交按钮的入口。
+ *
+ * ⚠️ **不要给这个函数加参数**。模板里写的是 `@click="doSubmit"`,
+ * 而 uni-app 会把事件对象作为第一个参数传进来 —— 如果签名是
+ * `doSubmit(confirmWarnings = false)`,那个事件对象会被当成"已确认警告"(真值),
+ * 于是有警告时会被静默强制提交。这类 bug 不会有任何报错。
+ */
function doSubmit() {
- // 提交(含校验、红黄绿问题)是下一段。这里明确说清,而不是静默无反应
- uni.showModal({
- title: '提交功能开发中',
- content: '你填的内容已经自动保存为草稿。提交与校验属下一阶段开发。',
- showCancel: false
- })
+ void submitNow(false)
+}
+
+/**
+ * 真正提交。
+ *
+ *
**两次调用是刻意的**:第一次不带 {@code confirmWarnings},
+ * 服务端若发现只有黄色警告就返回 {@code submitted=false} 让我们弹确认;
+ * 学生点「仍然提交」才带 {@code confirmWarnings=true} 再调一次。
+ *
+ *
为什么不在前端自己判断"只有警告就直接放行":
+ * 因为只有服务端的校验结果算数 —— 两次请求之间学生可能又改坏了什么。
+ * 前端手上那份清单只用来渲染,不能用来做决定。
+ */
+async function submitNow(confirmWarnings: boolean) {
+ if (!submissionId.value || submitting.value) return
+ submitting.value = true
+ uni.showLoading({ title: '正在校验…' })
+ try {
+ const r = await request({
+ 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) => {
@@ -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 = {}
@@ -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; }
}
diff --git a/scripts/e2e-m3-submit.py b/scripts/e2e-m3-submit.py
new file mode 100644
index 0000000..d7f4b7a
--- /dev/null
+++ b/scripts/e2e-m3-submit.py
@@ -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())