From 9911ff01f1feba99e7a50a2689d69e8e32688b8e Mon Sep 17 00:00:00 2001 From: lovife <474239041@qq.com> Date: Tue, 29 Sep 2026 12:39:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(submit):=20=E6=8F=90=E4=BA=A4=20+=20?= =?UTF-8?q?=E7=BA=A2=E9=BB=84=E7=BB=BF=E6=A0=A1=E9=AA=8C=EF=BC=88PRD=20?= =?UTF-8?q?=C2=A75.7=20=E7=9A=84=20VAL=20=E7=B3=BB=E5=88=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一次给全,不"改一条再拒一次":学生一次可能同时缺 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 全绿。 --- .../fill/support/StaticCacheHeaderFilter.java | 65 +++ .../fill/controller/FillController.java | 13 + .../fill/module/fill/dto/SubmitDtos.java | 102 ++++ .../fill/service/SubmissionService.java | 70 +++ .../fill/service/SubmissionValidator.java | 446 ++++++++++++++++++ frontend-mp/src/pages/fill/fill.vue | 241 +++++++++- scripts/e2e-m3-submit.py | 318 +++++++++++++ 7 files changed, 1244 insertions(+), 11 deletions(-) create mode 100644 backend/xt-common/src/main/java/cn/tianbaotong/fill/support/StaticCacheHeaderFilter.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/dto/SubmitDtos.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/SubmissionValidator.java create mode 100644 scripts/e2e-m3-submit.py diff --git a/backend/xt-common/src/main/java/cn/tianbaotong/fill/support/StaticCacheHeaderFilter.java b/backend/xt-common/src/main/java/cn/tianbaotong/fill/support/StaticCacheHeaderFilter.java new file mode 100644 index 0000000..4d0544f --- /dev/null +++ b/backend/xt-common/src/main/java/cn/tianbaotong/fill/support/StaticCacheHeaderFilter.java @@ -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; + +/** + * 静态资源的缓存策略 + 接口响应禁止缓存。 + * + *

为什么必须有这个类

+ * 没有它的时候,静态资源只有 {@code Last-Modified}、没有 {@code Cache-Control}、没有 {@code ETag}。 + * 这会触发浏览器的 启发式缓存(RFC 9111 §4.2.2:没有显式过期时间时, + * 浏览器按 {@code Last-Modified} 年龄的 10% 自行推断一个保鲜期)。 + * + *

后果不是"慢一点",而是发版后学生看到旧页面: + * 旧 {@code index.html} 被缓存 → 它里面写死了旧 {@code /assets/index-<旧哈希>.js} → + * 学生打开的是我们上个月的那一版,而我们在后台怎么改都没用。 + * 排查这个现象极其费时 —— 因为它只在"已经打开过页面的设备"上复现, + * 开发机上硬刷新一次就没了,于是很容易被当成"偶发"。 + * + *

实测踩过:材料上传的前端界面明明已构建、chunk 也已正确下发 + * (从 8080 下载 9606 字节,与磁盘文件 SHA-256 一致、里面确实有那段代码), + * 但浏览器执行的是旧 chunk —— 页面里就是没有材料区。 + * + *

策略

+ * + */ +@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); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/controller/FillController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/controller/FillController.java index a48d3bf..423f604 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/controller/FillController.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/controller/FillController.java @@ -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 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())); + } } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/dto/SubmitDtos.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/dto/SubmitDtos.java new file mode 100644 index 0000000..c705d59 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/dto/SubmitDtos.java @@ -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 系列)。 + * + *

为什么问题项是这个形状({@code {ruleCode, severity, title, detail}})

+ * 因为它在三个地方要用同一份数据: + * + * 所以 {@code ruleCode} 用的是 PRD 里那套 **VAL-xx** 编号,而不是内部枚举名 —— + * 客服、班主任、开发三方说的是同一个词,会议里才不会鸡同鸭讲。 + */ +public final class SubmitDtos { + + private SubmitDtos() { + } + + /** + * 一条校验问题。 + * + * @param ruleCode PRD §5.7 的规则编号(VAL-01…)。不要写内部枚举名 + * @param severity {@code 1} 信息(蓝)/ {@code 2} 警告(黄)/ {@code 3} 错误(红,阻断提交) + * @param title 一句话说清"哪里不对" + * @param detail 说清"怎么改"。要能照着做—— + * "材料不合规"这种话等于没说,学生会直接打电话给班主任 + */ + public record Issue(String ruleCode, int severity, String title, String detail, + Map 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 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} 装配出来的那一份

+ * + *

不要自己去读列。这条是本类最容易写错、且后果最难查的地方 —— + * 实测踩过三次: + *

    + *
  1. 字段 key 是 snake_case({@code work_unit}、{@code job_level}、{@code enroll_date}), + * 不是列名。写 {@code jobLevel} 会读到一个不存在的字段,表现是"明明填了却说没填"。
  2. + *
  3. 报考三项({@code occupation}/{@code worktype}/{@code job_level}) + * 存在 {@code ext_json} 里,不是独立列(契约 §2.4)。读列永远是空的。
  4. + *
  5. 同一份数据被两条规则用两种方式读 → 两条规则互相矛盾 + * (VAL-16 说"缺所学专业"、VAL-22 同时说"不缺")。这种自相矛盾比单纯漏判更糟: + * 学生不知道该信哪一条。
  6. + *
+ * + *

另外两条贯穿全篇的原则

+ * + *

① 一次给全,不要"改一条再拒一次"。 + * 学生一次可能同时缺 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())