diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/controller/AuditController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/controller/AuditController.java
new file mode 100644
index 0000000..632025f
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/controller/AuditController.java
@@ -0,0 +1,145 @@
+package cn.tianbaotong.fill.module.audit.controller;
+
+import cn.tianbaotong.fill.common.api.ApiResult;
+import cn.tianbaotong.fill.module.audit.dto.AuditDtos;
+import cn.tianbaotong.fill.module.audit.service.AuditService;
+import cn.tianbaotong.fill.module.audit.service.RejectReasonCatalog;
+import cn.tianbaotong.fill.module.file.service.MaterialFileService;
+import cn.tianbaotong.fill.security.PermissionDef;
+import cn.tianbaotong.fill.security.RequirePerm;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.MediaType;
+import org.springframework.http.MediaTypeFactory;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 管理端审核(《03-接口设计》§11)。
+ *
+ *
类上只做"粗筛"(初级或终审权限,满足其一即可)——因为具体要哪一个权限取决于这一单的状态:
+ * 待初审的单子要 {@code audit:first},待终审的要 {@code audit:final}。
+ * 这个分支没法在注解里表达,所以真正的判定在 {@code AuditService.requireAuditableNode}。
+ *
+ *
⚠️ 这里有两层检查,缺一不可:权限(有没有这类动作的资格)
+ * 与范围(这一单归不归我)。全校几十个班主任都有初审权限,
+ * 但只有这个班的班主任能审这个学生 —— 只查权限就是把全校学生交给了每个班主任。
+ */
+@Tag(name = "管理端 · 审核")
+@RestController
+@RequestMapping("/api/v1/audit")
+@RequirePerm({PermissionDef.AUDIT_FIRST, PermissionDef.AUDIT_FINAL})
+@RequiredArgsConstructor
+public class AuditController {
+
+ private final AuditService auditService;
+
+ @GetMapping("/todos")
+ @Operation(summary = "待办列表",
+ description = "node=1 初审(只看到自己带的班)/ node=2 终审(全租户)。"
+ + "counts 里的四个数字**不受 level 过滤影响**,否则点一下「有异常」就再也回不到「全部」。"
+ + "batchApprovable 由服务端算好,前端不许自己判")
+ public ApiResult todos(
+ @RequestParam(defaultValue = "1") int node,
+ @RequestParam(required = false) Long taskId,
+ @RequestParam(required = false) Long orgId,
+ @RequestParam(required = false) Integer level,
+ @RequestParam(required = false) Integer waitingOverHours,
+ @RequestParam(required = false) String keyword,
+ @RequestParam(required = false) Long pageNum,
+ @RequestParam(required = false) Long pageSize) {
+ return ApiResult.ok(auditService.todos(node, taskId, orgId, level,
+ waitingOverHours, keyword, pageNum, pageSize));
+ }
+
+ @GetMapping("/submissions/{id}")
+ @Operation(summary = "审核详情",
+ description = "完整填报单 + 材料(含签名预览链接)+ 现场校验的问题清单 + 改动明细 + 时间轴。"
+ + "**识别结果(detect)随本接口返回**(追溯矩阵 I10 的落点),M4 未做时该字段为 null")
+ public ApiResult detail(@PathVariable Long id) {
+ return ApiResult.ok(auditService.detail(id));
+ }
+
+ @PostMapping("/submissions/{id}/approve")
+ @Operation(summary = "通过(初审 1→2 / 终审 2→3)",
+ description = "红问题存在时返回 40032(校验是**现场重算**的,不是提交时的快照)。"
+ + "重复点通过是幂等的:返回 replayed=true 与首次相同的结果")
+ public ApiResult approve(@PathVariable Long id,
+ @RequestBody(required = false) AuditDtos.ApproveReq req) {
+ return ApiResult.ok(auditService.approve(id, req));
+ }
+
+ @PostMapping("/submissions/{id}/reject")
+ @Operation(summary = "驳回(→4)",
+ description = "reasonCode 取附录 C 的 10 条;选 R999 必须填 reasonText。"
+ + "达驳回上限时 escalated=true(一期没有通知渠道,响应里如实地说明)")
+ public ApiResult reject(@PathVariable Long id,
+ @RequestBody AuditDtos.RejectReq req) {
+ return ApiResult.ok(auditService.reject(id, req));
+ }
+
+ @PostMapping("/submissions/batch-approve")
+ @Operation(summary = "批量通过",
+ description = "**铁律:只带无问题的单,红黄一律排除**(服务端强制,前端传了红单也会被跳过并回报原因)。"
+ + "单次 ≤200 条(超出返回 40011)")
+ public ApiResult batchApprove(@RequestBody AuditDtos.BatchApproveReq req) {
+ return ApiResult.ok(auditService.batchApprove(req));
+ }
+
+ @PostMapping("/submissions/{id}/revoke")
+ @Operation(summary = "撤回审核结论",
+ description = "只能撤回**最近一次通过**;已导出(40033)、已归档(40034)不可撤;"
+ + "非原审核人且非管理员返回 40301")
+ public ApiResult revoke(@PathVariable Long id,
+ @RequestBody AuditDtos.RevokeReq req) {
+ return ApiResult.ok(auditService.revoke(id, req));
+ }
+
+ @GetMapping("/submissions/{id}/timeline")
+ @Operation(summary = "审核时间轴", description = "倒序(最近的在最前)。含学生提交、初审、终审、撤回、代传")
+ public ApiResult> timeline(@PathVariable Long id) {
+ return ApiResult.ok(auditService.timeline(id));
+ }
+
+ @GetMapping("/reason-codes")
+ @Operation(summary = "驳回原因字典",
+ description = "PRD 附录 C 的 10 条。⚠️ 契约里写的\"按 taskId 定制\"还没有配置入口,"
+ + "所以不接收该参数 —— 假支持会让人配了半天发现不生效")
+ public ApiResult> reasonCodes() {
+ return ApiResult.ok(RejectReasonCatalog.list());
+ }
+
+ @GetMapping("/files/{id}/url")
+ @Operation(summary = "取材料的访问链接(审核端)",
+ description = "七牛私有空间 → 签名链接(默认 30 分钟,**不要存库、不要转发**)。"
+ + "授权口径:这份材料所属的填报单必须在我能看/能审的范围内")
+ public ApiResult fileUrl(
+ @PathVariable Long id,
+ @RequestParam(defaultValue = "false") boolean original,
+ @RequestParam(required = false) Long expireSeconds) {
+ return ApiResult.ok(auditService.fileUrl(id, original, expireSeconds));
+ }
+
+ @GetMapping("/files/{id}/content")
+ @Operation(summary = "取材料内容(审核端,本地存储预览用)",
+ description = "provider=qiniu 时走签名链接即可;本地开发用这个。两种实现都要求有审核/查看权限")
+ public ResponseEntity fileContent(@PathVariable Long id,
+ @RequestParam(defaultValue = "false") boolean original) {
+ MaterialFileService.Content c = auditService.fileContent(id, original);
+ MediaType type = MediaTypeFactory.getMediaType(c.name())
+ .orElse(MediaType.APPLICATION_OCTET_STREAM);
+ return ResponseEntity.ok()
+ .contentType(type)
+ .header("Content-Disposition", "inline")
+ .body(c.bytes());
+ }
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/controller/MpTeacherAuditController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/controller/MpTeacherAuditController.java
new file mode 100644
index 0000000..2523fef
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/controller/MpTeacherAuditController.java
@@ -0,0 +1,99 @@
+package cn.tianbaotong.fill.module.audit.controller;
+
+import cn.tianbaotong.fill.common.api.ApiResult;
+import cn.tianbaotong.fill.module.audit.dto.AuditDtos;
+import cn.tianbaotong.fill.module.audit.service.AuditService;
+import cn.tianbaotong.fill.module.audit.service.RejectReasonCatalog;
+import cn.tianbaotong.fill.security.PermissionDef;
+import cn.tianbaotong.fill.security.RequirePerm;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 班主任端审核(《03-接口设计》§10.2 ~ §10.5)。
+ *
+ * 与 PC 端({@code /api/v1/audit/*})共用同一个服务,差别只有三点:
+ *
+ * - 这里只做初审({@code node} 固定为 1)—— 手机上不该出现"终审",
+ * 那是管理员在电脑上做的事(要对着几十份材料比对着看)
+ * - 权限只要 {@code audit:first}(班主任 / 副班主任)
+ * - 路径前缀是 {@code /mp/teacher}(小程序端)
+ *
+ *
+ * ⚠️ 左滑不做驳回(客户确认过的原型):驳回必须点按钮 + 二次确认,
+ * 因为"通过"点错了还能撤回,而"驳回"点错了学生那边立刻收到通知,
+ * 一来一回的代价不对称。接口层面只做一件事:把 {@code actionSource}
+ * 记下来({@code SWIPE} / {@code BUTTON}),用来分析误触率。
+ */
+@Tag(name = "小程序 · 班主任审核")
+@RestController
+@RequestMapping("/api/v1/mp/teacher/audit")
+@RequirePerm(PermissionDef.AUDIT_FIRST)
+@RequiredArgsConstructor
+public class MpTeacherAuditController {
+
+ private final AuditService auditService;
+
+ @GetMapping("/todos")
+ @Operation(summary = "待审卡片流",
+ description = "只返回**归我审的班**的待初审单子(权限不等于范围:全校几十个班主任都有初审权限,"
+ + "但只有这个班的那位能审这个学生)。默认每页 10 张卡片")
+ public ApiResult todos(
+ @RequestParam(required = false) Long taskId,
+ @RequestParam(required = false) Long orgId,
+ @RequestParam(required = false) Integer level,
+ @RequestParam(required = false) Integer waitingOverHours,
+ @RequestParam(required = false) String keyword,
+ @RequestParam(required = false) Long pageNum,
+ @RequestParam(required = false, defaultValue = "10") Long pageSize) {
+ return ApiResult.ok(auditService.todos(AuditService.NODE_CLASS, taskId, orgId, level,
+ waitingOverHours, keyword, pageNum, pageSize));
+ }
+
+ @GetMapping("/{submissionId}")
+ @Operation(summary = "学生详情(材料预览 + 问题清单 + 变更高亮)",
+ description = "与 PC 端同一个 VO。预览链接是**签名 URL**(30 分钟),不要缓存到本地")
+ public ApiResult detail(@PathVariable Long submissionId) {
+ return ApiResult.ok(auditService.detail(submissionId));
+ }
+
+ @PostMapping("/{submissionId}/approve")
+ @Operation(summary = "通过(右滑或按钮)",
+ description = "actionSource 记 SWIPE/BUTTON 用于埋点。红问题存在时返回 40032")
+ public ApiResult approve(@PathVariable Long submissionId,
+ @RequestBody(required = false) AuditDtos.ApproveReq req) {
+ return ApiResult.ok(auditService.approve(submissionId, req));
+ }
+
+ @PostMapping("/{submissionId}/reject")
+ @Operation(summary = "驳回(必须点按钮 + 二次确认)",
+ description = "reasonCode 必填(附录 C);R999 必须填 reasonText")
+ public ApiResult reject(@PathVariable Long submissionId,
+ @RequestBody AuditDtos.RejectReq req) {
+ return ApiResult.ok(auditService.reject(submissionId, req));
+ }
+
+ @PostMapping("/batch-approve")
+ @Operation(summary = "一键通过(只带绿色项)",
+ description = "**铁律:红黄一律排除**。返回里逐条说明哪几个被跳过、为什么 —— "
+ + "只回\"成功 3 条、跳过 1 条\"的话,用户只会反复点")
+ public ApiResult batchApprove(@RequestBody AuditDtos.BatchApproveReq req) {
+ return ApiResult.ok(auditService.batchApprove(req));
+ }
+
+ @GetMapping("/reason-codes")
+ @Operation(summary = "驳回原因字典(附录 C 的 10 条)")
+ public ApiResult> reasonCodes() {
+ return ApiResult.ok(RejectReasonCatalog.list());
+ }
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/dto/AuditDtos.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/dto/AuditDtos.java
new file mode 100644
index 0000000..efd93d2
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/dto/AuditDtos.java
@@ -0,0 +1,523 @@
+package cn.tianbaotong.fill.module.audit.dto;
+
+import cn.tianbaotong.fill.module.fill.dto.SubmitDtos;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 审核相关的请求 / 响应(《03-接口设计》§10.2 ~ §10.6、§11.1 ~ §11.7)。
+ *
+ * 为什么 MP(班主任)与 PC(管理员)共用同一份 VO
+ *
+ * 契约里是两处(§10.2 的卡片流、§11.1 的待审表),但字段重合九成以上,
+ * 差异只是"这一端用不到哪几个"。做两份的代价不是多写一个类,而是迟早分叉:
+ * 一边加了"红问题摘要",另一边没有,于是同一个学生在手机上看到红、
+ * 在电脑上看到绿 —— 而这类不一致最容易让人不信任系统。
+ *
+ *
所以:一个超集 VO,两端各取所需。字段只增不减,
+ * 加字段时同步两端的展示说明。
+ */
+public final class AuditDtos {
+
+ private AuditDtos() {
+ }
+
+ // ── 待办列表 ──────────────────────────────────
+
+ /** 待办卡片。 */
+ @Getter
+ @Setter
+ @Schema(description = "待办卡片")
+ public static class TodoVO {
+
+ private Long submissionId;
+
+ private Long rosterId;
+
+ private Long taskId;
+
+ private String taskName;
+
+ private String realName;
+
+ /** 头像。一期没有头像(学生不填),恒为 null,保留字段供二期 */
+ private String avatarUrl;
+
+ private Long orgId;
+
+ private String orgName;
+
+ private LocalDateTime submitTime;
+
+ /** 已等待小时数(保留 1 位小数)—— 列表按它正序,等得最久的排最前 */
+ private Double waitHours;
+
+ /**
+ * 红黄绿分级:{@code 0} 无问题 / {@code 2} 仅警告 / {@code 3} 有红色。
+ *
+ *
⚠️ 没有 {@code 1} —— 契约 §10.2 就是这三档,
+ * 前端不要写"1 = 蓝色/信息级"的分支(写了永远不会触发)。
+ */
+ private Integer level;
+
+ private Integer redCount;
+
+ private Integer yellowCount;
+
+ /** 红问题的第一句(卡片上直接显示"身份证号与名册不一致");无红时为 null */
+ private String redSummary;
+
+ /** 材料缩略图(签名链接,**有有效期**,不要缓存到本地或存库) */
+ private List materialThumbs;
+
+ private Integer materialCount;
+
+ /** 学生改过的预填字段(审核时要一眼看到"这不是老师填的那份") */
+ private List changedFields;
+
+ private Integer rejectCount;
+
+ /**
+ * 能否【批量通过】。
+ *
+ * ⚠️ 由服务端算好,前端不许自己判(契约 §10.2 的原文)。
+ * 规则是"红黄都为零",而前端只看得见红黄计数、看不见
+ * "这个计数是什么时候算的"。规则不落地到前端,才不会出现
+ * "前端说能、服务端说不能"这种让人反复点按钮的僵局。
+ */
+ private Boolean batchApprovable;
+
+ private Integer status;
+
+ private String statusText;
+ }
+
+ /** 待办列表分页结果。 */
+ @Getter
+ @Setter
+ @Schema(description = "待办列表")
+ public static class TodoPageVO {
+
+ private List list;
+
+ private long total;
+
+ private Counts counts;
+ }
+
+ /** 顶部四个计数。 */
+ @Getter
+ @Setter
+ @Schema(description = "待办计数")
+ public static class Counts {
+
+ private long all;
+
+ /** 无问题(可直接批量通过的那批) */
+ private long clean;
+
+ /** 有红或有黄(需要人看的那批) */
+ private long abnormal;
+
+ /** 已超过审核时限还没处理(时限取审计配置里的初审/终审小时数) */
+ private long overTime;
+ }
+
+ // ── 详情 ──────────────────────────────────────
+
+ /** 审核详情(§11.2)。 */
+ @Getter
+ @Setter
+ @Schema(description = "审核详情")
+ public static class DetailVO {
+
+ private Long id;
+
+ private Integer status;
+
+ private String statusText;
+
+ private Long taskId;
+
+ private String taskName;
+
+ private Long orgId;
+
+ private String orgName;
+
+ private String realName;
+
+ /** 脱敏证件号。完整号要走单独接口并留痕(§3 的安全约定),审核详情里不给 */
+ private String idCardMask;
+
+ private LocalDateTime submitTime;
+
+ private String occupation;
+
+ private String worktype;
+
+ private String jobLevel;
+
+ /** 表单值(内置 + 扩展),键是 snake_case */
+ private Map formData;
+
+ private List changedFields;
+
+ /** 改动明细:老师填的是什么、学生改成了什么 */
+ private List changedDetail;
+
+ private List materials;
+
+ /** 现场校验的问题清单(红/黄/蓝)。⚠️ 不是提交那一刻的快照,见 AuditService 的说明 */
+ private List issues;
+
+ private Integer redCount;
+
+ private Integer yellowCount;
+
+ private Integer rejectCount;
+
+ private String lastRejectCode;
+
+ private String lastRejectText;
+
+ private ApprovePolicy approvePolicy;
+
+ private RevokePolicy revokePolicy;
+
+ private List timeline;
+ }
+
+ /** 一处改动。 */
+ @Getter
+ @Setter
+ public static class ChangedItem {
+
+ private String field;
+
+ private String label;
+
+ /** 名册里老师填的值(脱敏后的展示值) */
+ private String rosterValue;
+
+ /** 学生提交的值 */
+ private String studentValue;
+
+ /** ⚠️ 一期恒为 null:{@code changed_fields} 存的是字段名数组,没有逐字段的时间 */
+ private LocalDateTime changedAt;
+ }
+
+ /** 一个材料项及其文件。 */
+ @Getter
+ @Setter
+ public static class MaterialGroup {
+
+ private String materialCode;
+
+ private String materialName;
+
+ private Boolean required;
+
+ /** 是否已交(有当前生效的文件) */
+ private Boolean done;
+
+ /** 条件材料是否对学生可见(不可见的材料不该出现在审核页上要求他交) */
+ private Boolean visible;
+
+ private List files;
+ }
+
+ /** 一个文件。 */
+ @Getter
+ @Setter
+ public static class FileItem {
+
+ private Long id;
+
+ private String originalName;
+
+ private String standardName;
+
+ /** 预览链接(签名,默认 30 分钟;七牛私有空间**不能长期保存**) */
+ private String previewUrl;
+
+ private String thumbUrl;
+
+ private Long finalSizeKb;
+
+ private Long maxSizeKb;
+
+ private Integer width;
+
+ private Integer height;
+
+ private Integer dpi;
+
+ /** {@code 0} 未校验 / {@code 1} 通过 / {@code 2} 警告 / {@code 3} 错误 */
+ private Integer validateStatus;
+
+ /** 上传来源:{@code 1} 本人 / {@code 2} 班主任代传 / {@code 3} 管理员代传 / {@code 4} 整理器导入 */
+ private Integer source;
+
+ /** 材料识别结果(M4 未做时字段存在但为空,前端要能接受 null) */
+ private Detect detect;
+
+ /** 文件级问题 */
+ private List issues;
+ }
+
+ /** 材料识别结果(追溯矩阵 I10 的落点,随详情一起返回)。 */
+ @Getter
+ @Setter
+ public static class Detect {
+
+ private String materialCode;
+
+ private Double confidence;
+
+ private String source;
+
+ private String sourceText;
+
+ private Boolean conflict;
+ }
+
+ /** 这个单子现在能不能通过。 */
+ @Getter
+ @Setter
+ public static class ApprovePolicy {
+
+ private Boolean canApprove;
+
+ private Boolean canBatch;
+
+ /** 不能通过时的一句话原因(红问题 / 不是我的班 / 状态不对) */
+ private String reason;
+
+ /** 阻断级别的红问题标题,前端直接列出来 */
+ private List redTitles;
+ }
+
+ /** 这个单子现在能不能撤回。 */
+ @Getter
+ @Setter
+ public static class RevokePolicy {
+
+ private Boolean canRevoke;
+
+ private String reason;
+
+ /** 能撤回的话,撤哪条记录(前端原样回传即可) */
+ private Long auditRecordId;
+ }
+
+ /** 时间轴一条。 */
+ @Getter
+ @Setter
+ public static class TimelineItem {
+
+ /** 记录 id;{@code 0} 节点(学生提交)没有记录 id */
+ private Long id;
+
+ /** {@code 0} 学生提交 / {@code 1} 初审 / {@code 2} 终审 / {@code 3} 材料 */
+ private Integer node;
+
+ private String nodeText;
+
+ /** {@link cn.tianbaotong.fill.module.audit.entity.CollectAuditRecord} 的 {@code ACTION_*} */
+ private Integer action;
+
+ private String actionText;
+
+ private Long auditorId;
+
+ private String auditorName;
+
+ private String reasonCode;
+
+ private String reasonText;
+
+ private List materialCodes;
+
+ private LocalDateTime time;
+
+ /** 处理耗时(秒) */
+ private Integer durationSec;
+ }
+
+ // ── 请求 / 动作结果 ───────────────────────────
+
+ /** 通过(§10.3 / §11.3)。 */
+ @Getter
+ @Setter
+ @Schema(description = "审核通过")
+ public static class ApproveReq {
+
+ private String remark;
+
+ /** {@code SWIPE} 右滑 / {@code BUTTON} 按钮 —— 只用于埋点分析误触率 */
+ private String actionSource;
+
+ /** 终审意见(§11.3 的终审额外字段) */
+ private String finalOpinion;
+
+ /** 终审额外字段,本轮未使用(保留契约形状) */
+ private Boolean needArchive;
+ }
+
+ /** 通过的结果。 */
+ @Getter
+ @Setter
+ public static class ApproveVO {
+
+ private Integer status;
+
+ private String statusText;
+
+ private String nextNode;
+
+ private String nextNodeText;
+
+ /** true = 这次是重复请求的幂等回放(结果与第一次相同),不是真的又审了一次 */
+ private Boolean replayed;
+ }
+
+ /** 驳回(§10.4)。 */
+ @Getter
+ @Setter
+ @Schema(description = "审核驳回")
+ public static class RejectReq {
+
+ /** PRD 附录 C 的原因码,如 {@code R001} */
+ private String reasonCode;
+
+ /** 补充说明;{@code R999} 时必填 */
+ private String reasonText;
+
+ /** 是哪几项材料的问题 */
+ private List materialCodes;
+
+ /** 语音转文字结果(可选输入方式) */
+ private String voiceText;
+
+ private Boolean suggestReupload;
+ }
+
+ /** 驳回的结果。 */
+ @Getter
+ @Setter
+ public static class RejectVO {
+
+ private Integer status;
+
+ private String statusText;
+
+ private Integer rejectCount;
+
+ /**
+ * 通知是否已发出。
+ *
+ * ⚠️ 一期恒为 {@code false}:订阅消息(M5-12)还没做。
+ * 不能为了让契约好看而写 true —— 那会让"学生到底收到没有"
+ * 变成一个没人能回答的问题,而家长第二天就会打电话问。
+ */
+ private Boolean notifySent;
+
+ /** 学生是否**没**收到通知(前端据此显示"请口头告知学生") */
+ private Boolean needManualNotice;
+
+ /** 驳回次数是否已达上限(达上限同时通知管理员,见 §10.4 的升级规则) */
+ private Boolean escalated;
+ }
+
+ /** 批量通过(§10.5)。 */
+ @Getter
+ @Setter
+ @Schema(description = "批量通过")
+ public static class BatchApproveReq {
+
+ private List submissionIds;
+
+ /**
+ * 是否只通过无问题项。
+ *
+ * ⚠️ 服务端强制 true:客户端传 false 会被改回 true。
+ * 这是铁律(交接摘要 §二·9)—— 批量通过只能带绿色项,红黄一律排除。
+ * 字段保留只是为了契约形状一致。
+ */
+ private Boolean onlyClean;
+
+ private String actionSource;
+ }
+
+ /** 批量通过的结果。 */
+ @Getter
+ @Setter
+ public static class BatchApproveVO {
+
+ private int approved;
+
+ private int skipped;
+
+ private List details;
+ }
+
+ /** 被跳过的原因(**必须逐条说清**,否则用户只会看到"选了 5 个,过了 3 个")。 */
+ @Getter
+ @Setter
+ public static class BatchSkip {
+
+ private Long submissionId;
+
+ private String realName;
+
+ private boolean skipped;
+
+ private String reason;
+
+ private Integer redCount;
+
+ private Integer yellowCount;
+ }
+
+ /** 撤回(§11.4)。 */
+ @Getter
+ @Setter
+ @Schema(description = "撤回审核结论")
+ public static class RevokeReq {
+
+ private Long auditRecordId;
+
+ private String reason;
+ }
+
+ /** 撤回的结果。 */
+ @Getter
+ @Setter
+ public static class RevokeVO {
+
+ private Integer status;
+
+ private String statusText;
+
+ private Long revokedRecordId;
+ }
+
+ /** 驳回原因字典一项(§11.7)。 */
+ @Getter
+ @Setter
+ public static class ReasonCodeVO {
+
+ private String code;
+
+ private String text;
+
+ /** true = 选中它必须填补充说明(只有 {@code R999}) */
+ private boolean requiresText;
+ }
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/dto/AuditTodoRow.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/dto/AuditTodoRow.java
new file mode 100644
index 0000000..a0bf22d
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/dto/AuditTodoRow.java
@@ -0,0 +1,49 @@
+package cn.tianbaotong.fill.module.audit.dto;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+/**
+ * 待办列表的一行(Mapper 直出,不是响应 VO)。
+ *
+ * 分开是因为它们回答的问题不同:这里只装"列表要用的列",
+ * 而 {@link AuditDtos.TodoVO} 还要装"算出来的东西"
+ * (等待小时数、红黄绿分级、材料缩略图、能否批量通过)。
+ * 把两者合成一个类,读代码的人就分不清哪些字段来自数据库、哪些是推导的。
+ */
+@Getter
+@Setter
+public class AuditTodoRow {
+
+ private Long submissionId;
+
+ private Long rosterId;
+
+ private Long taskId;
+
+ private String taskName;
+
+ private Long orgId;
+
+ private String orgName;
+
+ private String realName;
+
+ private String idCardMask;
+
+ /** {@code collect_submission.status}。 */
+ private Integer status;
+
+ private LocalDateTime submitTime;
+
+ private Integer redCount;
+
+ private Integer yellowCount;
+
+ private Integer rejectCount;
+
+ /** 学生改过的预填字段(JSON 数组字符串,可能为 null)。 */
+ private String changedFields;
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/entity/CollectAuditRecord.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/entity/CollectAuditRecord.java
new file mode 100644
index 0000000..a780ec9
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/entity/CollectAuditRecord.java
@@ -0,0 +1,104 @@
+package cn.tianbaotong.fill.module.audit.entity;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 审核记录(对应 {@code collect_audit_record})—— 审核时间轴的唯一来源。
+ *
+ *
它只增不改(没有 {@code deleted} / {@code update_time} 列)。这不是省事,
+ * 而是语义:审核结论是**发生过的事实**。撤回不是"改掉那条记录",
+ * 而是再写一条撤回记录 —— 于是时间轴上永远能看出
+ * "谁在什么时候通过了、后来谁在什么时候撤回了、为什么"。
+ * 把上一行删掉就只剩一个"当前状态",而状态是看不出这些的。
+ *
+ *
⚠️ 本实体刻意不继承 {@code TenantEntity}:那张基类带 {@code @TableLogic deleted},
+ * 会让每条查询自动加上 {@code deleted = 0},而这张表没有这一列 ——
+ * 表现是运行时 {@code column deleted does not exist}。同 {@code CollectTaskOrg}。
+ *
+ *
⚠️ {@code action = 3} 的语义在文档与 DDL 之间冲突,见 {@link #ACTION_REVOKE}。
+ */
+@Getter
+@Setter
+@TableName("collect_audit_record")
+public class CollectAuditRecord implements Serializable {
+
+ /** 初审(班主任)。 */
+ public static final String NODE_CLASS = "CLASS_AUDIT";
+
+ /** 终审(管理员)。 */
+ public static final String NODE_ADMIN = "ADMIN_AUDIT";
+
+ /** 通过。 */
+ public static final int ACTION_APPROVE = 1;
+
+ /** 驳回。 */
+ public static final int ACTION_REJECT = 2;
+
+ /** 批量通过。 */
+ public static final int ACTION_BATCH_APPROVE = 3;
+
+ /** 批量驳回。 */
+ public static final int ACTION_BATCH_REJECT = 4;
+
+ /** 转交(P1,未实现)。 */
+ public static final int ACTION_TRANSFER = 5;
+
+ /** 代传材料(P1,未实现)。 */
+ public static final int ACTION_PROXY_UPLOAD = 6;
+
+ /**
+ * 撤回。
+ *
+ *
⚠️ 这个值是我定的,因为文档与 DDL 冲突:
+ * DDL 的注释写的是 {@code 1通过 2驳回 3批量通过 4批量驳回 5转交 6代传},
+ * 而《03-接口设计》§11.4 写"写入一条 action = 3(撤回)的记录"。
+ * 两者对 3 的解释正好相反 —— 而时间轴恰恰是最不能混淆的地方:
+ * 把"撤回"显示成"批量通过",等于让人以为这份单子被通过了。
+ *
+ *
所以:1~6 沿用 DDL(它们已发布、可能已有数据),撤回另取 7,
+ * 并在此写明冲突。这条会同步进交接摘要,供客户/文档侧对齐。
+ */
+ public static final int ACTION_REVOKE = 7;
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ @TableField(value = "tenant_id", fill = FieldFill.INSERT)
+ private Long tenantId;
+
+ private Long taskId;
+
+ private Long submissionId;
+
+ /** {@link #NODE_CLASS} / {@link #NODE_ADMIN}。 */
+ private String auditNode;
+
+ private Long auditorId;
+
+ /** 见上面各 {@code ACTION_*} 常量。 */
+ private Integer action;
+
+ /** 驳回原因编码(PRD 附录 C),只有驳回有。 */
+ private String reasonCode;
+
+ /** 驳回补充说明 / 撤回原因。 */
+ private String reasonText;
+
+ /** 被驳回的材料项,逗号分隔。 */
+ private String materialCodes;
+
+ /** 处理耗时(秒)—— 从学生提交到本次审核。 */
+ private Integer durationSec;
+
+ @TableField(value = "create_time", fill = FieldFill.INSERT)
+ private LocalDateTime createTime;
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditPreconditionMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditPreconditionMapper.java
new file mode 100644
index 0000000..df8469c
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditPreconditionMapper.java
@@ -0,0 +1,29 @@
+package cn.tianbaotong.fill.module.audit.mapper;
+
+import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+/**
+ * 审核动作的前置检查:这份数据是不是已经交付过了。
+ *
+ *
「已导出打包 → 不可撤回」({@code 40033})这条约束需要一个"这个任务导出过没有"的查询。
+ * 导出模块(M6)还没做,而只有审核端用得到,所以先放在这里,
+ * M6 落地后应把它搬到导出模块(那时导出模块才是 {@code collect_export_task} 的归属方)。
+ */
+public interface AuditPreconditionMapper {
+
+ /**
+ * 这个任务有没有已完成的导出包。
+ *
+ *
{@code status} 的取值见 DDL:{@code 0} 排队 / {@code 1} 处理中 / {@code 2} 完成 / {@code 3} 失败。
+ * 只把"完成"算数 —— 排队中或失败的那次没有产生任何交付物,
+ * 拿它去阻止撤回会让人莫名其妙("我明明没导出成功")。
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @Select("""
+ SELECT COUNT(*) FROM collect_export_task
+ WHERE tenant_id = #{tenantId} AND task_id = #{taskId} AND status = 2
+ """)
+ long countFinishedExports(@Param("tenantId") Long tenantId, @Param("taskId") Long taskId);
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditRecordMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditRecordMapper.java
new file mode 100644
index 0000000..48b9a5b
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditRecordMapper.java
@@ -0,0 +1,16 @@
+package cn.tianbaotong.fill.module.audit.mapper;
+
+import cn.tianbaotong.fill.module.audit.entity.CollectAuditRecord;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+/**
+ * 审核记录 Mapper。
+ *
+ *
没有任何手写 SQL:时间轴(按填报单倒序列出)与"最近一次结论"
+ * 都能用 {@code LambdaQueryWrapper} 表达,而**用 Wrapper 就自动带租户条件**
+ * ({@code sys_tenant} 之外的业务表都会被拦截器补上 {@code tenant_id = ?})。
+ * 手写 SQL 的场合才需要显式声明隔离条件 —— 那几处集中在
+ * {@link AuditScopeMapper} 与 {@link AuditTodoMapper},并有注释说明。
+ */
+public interface AuditRecordMapper extends BaseMapper {
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditScopeMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditScopeMapper.java
new file mode 100644
index 0000000..b4165aa
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditScopeMapper.java
@@ -0,0 +1,83 @@
+package cn.tianbaotong.fill.module.audit.mapper;
+
+import cn.tianbaotong.fill.module.audit.entity.CollectAuditRecord;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+/**
+ * 审核范围:"这个人能审哪些班"。
+ *
+ * 《03-接口设计》§7.6 定了覆盖班级与初审人的关系({@code collect_task_org}),
+ * 这里把它翻译成一次查询。三级回退的顺序是刻意的:
+ *
+ *
+ * - {@code head_user_id} —— 任务里为该班指定的初审人(最具体,优先)
+ * - {@code deputy_user_ids} —— 副班主任 / 协办人
+ * - {@code sys_org.leader_user_id} —— 任务没指定时,回退到班级负责人的身份
+ *
+ *
+ * ⚠️ {@code deputy_user_ids} 用 {@code LIKE '%,3,%'} 匹配,这是有前提的:
+ * 库里存的是**首尾都带逗号**的形式({@code ,3,7,})。裸逗号分隔的 {@code 3,17}
+ * 会被 {@code %,3,%} 误命中 —— 表现是"莫名其妙的协办人能看到别人的班"。
+ * 写入端在 {@code TaskOrgService.joinIds},两处必须一致。
+ *
+ *
⚠️ 本章手写 SQL 关掉了租户拦截器,隔离条件由本方法自己保证
+ * (每个表都显式带了 {@code tenant_id})。这是本仓库对"关掉框架保护"的统一要求:
+ * 集中放置、显式带条件、并写清为什么 —— 同 {@code MpSceneMapper}。
+ */
+public interface AuditScopeMapper {
+
+ /**
+ * 在这个任务里,我能初审的班级 id。
+ *
+ * @return 班级 id;空列表表示"一个班都不归我审"。
+ * 调用方必须把它当成"看不见任何单子",而不是"不过滤"
+ */
+ @Select("""
+
+ """)
+ List classAuditOrgIds(@Param("tenantId") Long tenantId,
+ @Param("taskId") Long taskId,
+ @Param("userId") Long userId);
+
+ /**
+ * 在我能初审的班里,是否有这一个班(单点判断,避免为一次校验拉全列表)。
+ */
+ @Select("""
+ SELECT COUNT(*)
+ FROM collect_task_org cto
+ LEFT JOIN sys_org o
+ ON o.id = cto.org_id AND o.deleted = 0 AND o.tenant_id = cto.tenant_id
+ WHERE cto.tenant_id = #{tenantId}
+ AND cto.task_id = #{taskId}
+ AND cto.org_id = #{orgId}
+ AND cto.status = 1
+ AND (
+ cto.head_user_id = #{userId}
+ OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
+ OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
+ )
+ """)
+ long countMyOrg(@Param("tenantId") Long tenantId,
+ @Param("taskId") Long taskId,
+ @Param("orgId") Long orgId,
+ @Param("userId") Long userId);
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditTodoMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditTodoMapper.java
new file mode 100644
index 0000000..3b4a0a7
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditTodoMapper.java
@@ -0,0 +1,169 @@
+package cn.tianbaotong.fill.module.audit.mapper;
+
+import cn.tianbaotong.fill.module.audit.dto.AuditTodoRow;
+import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 待办列表(班主任初审 / 管理员终审共用一套查询)。
+ *
+ * 两个端(小程序卡片流、PC 终审表)问的是同一个问题 ——
+ * "现在轮到我处理的单子有哪些"。差异只有三个过滤条件与排序,
+ * 所以做成一套查询 + 参数化,而不是两份 SQL:两份 SQL 一旦分叉,
+ * 迟早出现"小程序看得到、PC 看不到某一单"这种没人解释得清的现象。
+ *
+ *
⚠️ 关掉租户拦截器,隔离条件在这里显式写明
+ *
+ * 这是一条带 3 个 join 的手写 SQL,隔离条件按本仓库的规矩集中写清、可审:
+ *
+ * - 主表 {@code s.tenant_id = #{tenantId}}
+ * - 每个 join 的表都带 {@code tenant_id = #{tenantId}} ——
+ * 只靠 join 键相等是不够的:id 是各租户自增的,
+ * 万一哪天有个脏数据指向别的租户的名册行,join 就会把别人的姓名带出来
+ *
+ *
+ * ⚠️ {@code orgIds} 的"空列表"必须当成"看不见"
+ *
+ * {@code orgIds = []} 与 {@code orgIds = null} 在业务上完全不同:
+ *
+ * - {@code null} = 不按班级过滤(终审:全租户)
+ * - {@code []} = 这个老师一个班都不带 → 应该看不见任何单子
+ *
+ * 把空列表当成"不过滤"是这类查询最经典、也最严重的写法错误:
+ * 一个刚被移出所有班级的班主任会突然看到全校的单子。
+ * 所以 SQL 里为它单独写了 {@code 1 = 0}(失败关闭),而不是静默放过。
+ */
+public interface AuditTodoMapper {
+
+ /**
+ * 分页查询待办。
+ *
+ * @param status 要查的填报单状态({@code 1} 待初审 / {@code 2} 待终审)
+ * @param orgIds {@code null} = 全租户(终审);非 null = 只在这些班里(初审)。空列表 = 看不见
+ * @param level {@code 0} 无问题 / {@code 2} 仅警告 / {@code 3} 有红色;{@code null} = 全部
+ * @param keyword 姓名模糊搜索
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @Select("""
+
+ """)
+ IPage pageTodos(IPage page,
+ @Param("tenantId") Long tenantId,
+ @Param("status") Integer status,
+ @Param("taskId") Long taskId,
+ @Param("orgId") Long orgId,
+ @Param("orgIds") List orgIds,
+ @Param("level") Integer level,
+ @Param("keyword") String keyword,
+ @Param("waitingOverHours") Integer waitingOverHours);
+
+ /**
+ * 同一批过滤条件下的四个计数(用于列表页顶部的"全部 / 无问题 / 有异常 / 超时")。
+ *
+ * 刻意不受 {@code level} 影响:这几个数字是给用户"点哪一档"用的,
+ * 点进去之后再按 level 过滤。跟着 level 变的话,点一下"有异常",
+ * "全部"就变成了 1 —— 那样他再也回不到全部,只能刷新页面。
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @Select("""
+
+ """)
+ Map counts(@Param("tenantId") Long tenantId,
+ @Param("status") Integer status,
+ @Param("taskId") Long taskId,
+ @Param("orgId") Long orgId,
+ @Param("orgIds") List orgIds,
+ @Param("keyword") String keyword,
+ @Param("timeoutHours") Integer timeoutHours);
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/AuditService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/AuditService.java
new file mode 100644
index 0000000..5e0667d
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/AuditService.java
@@ -0,0 +1,1377 @@
+package cn.tianbaotong.fill.module.audit.service;
+
+import cn.hutool.json.JSONUtil;
+import cn.tianbaotong.fill.common.api.ErrorCode;
+import cn.tianbaotong.fill.common.context.LoginUser;
+import cn.tianbaotong.fill.common.context.UserContext;
+import cn.tianbaotong.fill.common.exception.BizException;
+import cn.tianbaotong.fill.module.audit.dto.AuditDtos;
+import cn.tianbaotong.fill.module.audit.dto.AuditTodoRow;
+import cn.tianbaotong.fill.module.audit.entity.CollectAuditRecord;
+import cn.tianbaotong.fill.module.audit.mapper.AuditPreconditionMapper;
+import cn.tianbaotong.fill.module.audit.mapper.AuditRecordMapper;
+import cn.tianbaotong.fill.module.audit.mapper.AuditScopeMapper;
+import cn.tianbaotong.fill.module.audit.mapper.AuditTodoMapper;
+import cn.tianbaotong.fill.module.file.entity.CollectFile;
+import cn.tianbaotong.fill.module.file.mapper.FileMapper;
+import cn.tianbaotong.fill.module.file.service.MaterialFileService;
+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.fill.service.FillService;
+import cn.tianbaotong.fill.module.fill.service.SubmissionService;
+import cn.tianbaotong.fill.module.fill.service.SubmissionValidator;
+import cn.tianbaotong.fill.module.form.dto.FormFieldVO;
+import cn.tianbaotong.fill.module.form.dto.FormSchemaVO;
+import cn.tianbaotong.fill.module.form.service.FormSchemaService;
+import cn.tianbaotong.fill.module.material.dto.MaterialDefVO;
+import cn.tianbaotong.fill.module.material.service.MaterialDefService;
+import cn.tianbaotong.fill.module.org.entity.SysOrg;
+import cn.tianbaotong.fill.module.org.mapper.OrgMapper;
+import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
+import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
+import cn.tianbaotong.fill.module.task.entity.CollectTask;
+import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
+import cn.tianbaotong.fill.module.user.entity.SysUser;
+import cn.tianbaotong.fill.module.user.mapper.UserMapper;
+import cn.tianbaotong.fill.security.PermissionChecker;
+import cn.tianbaotong.fill.security.PermissionDef;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigInteger;
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+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;
+import java.util.stream.Collectors;
+
+/**
+ * 审核闭环(《03-接口设计》§10.2~§10.5、§11.1~§11.5)。
+ *
+ * 这个类的三件事,按重要性排序
+ *
+ *
+ * - 谁能审谁({@link #requireAuditableNode}):初审必须落在"我带的班"里。
+ * 这一条写错等于把全校学生的身份证号与学籍材料交给任何一个班主任。
+ * - 有红问题不许通过({@code 40032}):校验在审核这一刻现场重算,
+ * 不是拿提交时的快照 —— 快照会过期,而"放过一份有问题的材料"是不可撤销的。
+ * - 每个动作都留一条记录:审核记录只增不改,撤回是"再写一条",
+ * 所以时间轴能回答"谁在什么时候改的、为什么"。
+ *
+ *
+ * 红黄计数:列表用快照,动作时现场重算并回写
+ *
+ * 待办列表按 {@code collect_submission.red_count / yellow_count} 分级 ——
+ * 那是在学生提交那一刻算出来落库的。它可能过期:OCR / 识别是异步的,
+ * 名册或材料规则也可能被改。所以:
+ *
+ *
+ * - 列表用快照(要快,且它本来就是概览)
+ * - 详情与通过 / 批量通过现场重算,并把结果回写快照 ——
+ * 于是快照自己会收敛到现场值,最多错一次
+ *
+ *
+ * 不做成"列表也现场重算",是因为那要对每一行跑一次完整校验
+ * (装配表单 + 查材料 + 查文件);20 行一页就是 20 遍,
+ * 而列表是所有页面里最常被刷的。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AuditService {
+
+ /** 初审节点({@code collect_submission.status = 1})。 */
+ public static final int NODE_CLASS = 1;
+
+ /** 终审节点({@code collect_submission.status = 2})。 */
+ public static final int NODE_ADMIN = 2;
+
+ /** 待办列表默认页大小(卡片流 10 条正好一屏多一点)。 */
+ private static final int DEFAULT_PAGE_SIZE = 10;
+
+ /** 列表最多一次给多少行 —— 要缩略图就要签 URL,一次几百条会把存储层的签名打爆。 */
+ private static final int MAX_PAGE_SIZE = 100;
+
+ /** 卡片上最多给几张缩略图(原型 ② 是 4 张一排)。 */
+ private static final int MAX_THUMBS = 4;
+
+ /** 批量通过的条数上限(契约 §10.5:≤200,错误码 40011 用的就是它)。 */
+ private static final int MAX_BATCH = 200;
+
+ /** 审核配置读不出来时的兜底(与 {@code AuditConfigVO} 的默认值一致)。 */
+ private static final int DEFAULT_CLASS_TIMEOUT_HOURS = 48;
+ private static final int DEFAULT_ADMIN_TIMEOUT_HOURS = 72;
+ private static final int DEFAULT_MAX_REJECT_COUNT = 3;
+
+ /** 预览链接有效期(30 分钟,与学生端同口径)。 */
+ private static final long URL_EXPIRE_SECONDS = 1800L;
+
+ /**
+ * 本地存储时,审核端的下载前缀。
+ *
+ *
与学生端({@code /api/v1/mp/files/})刻意分开:两边的授权检查完全不同
+ * (那边查"这份文件是不是你传的",这边查"这个班归不归你审")。
+ * 共用一个前缀迟早会有人把两套检查混在一起,而混错了就是越权。
+ */
+ static final String AUDIT_DOWNLOAD_PREFIX = "/api/v1/audit/files/";
+
+ private final SubmissionMapper submissionMapper;
+ private final AuditRecordMapper auditRecordMapper;
+ private final AuditScopeMapper auditScopeMapper;
+ private final AuditTodoMapper todoMapper;
+ private final AuditPreconditionMapper preconditionMapper;
+ private final TaskMapper taskMapper;
+ private final OrgMapper orgMapper;
+ private final UserMapper userMapper;
+ private final CollectRosterMapper rosterMapper;
+ private final FileMapper fileMapper;
+ private final MaterialDefService materialDefService;
+ private final MaterialFileService fileService;
+ private final FormSchemaService formSchemaService;
+ private final SubmissionValidator validator;
+ private final PermissionChecker permissionChecker;
+
+ // ══════════════════ 待办列表 ══════════════════
+
+ /**
+ * 待办列表(班主任初审 / 管理员终审共用)。
+ *
+ * @param node {@link #NODE_CLASS} / {@link #NODE_ADMIN}
+ */
+ @Transactional(readOnly = true)
+ public AuditDtos.TodoPageVO todos(int node, Long taskId, Long orgId, Integer level,
+ Integer waitingOverHours, String keyword,
+ Long pageNum, Long pageSize) {
+ LoginUser me = UserContext.require();
+ int status = statusOfNode(node);
+ requireNodePermission(node);
+
+ // 初审:只能看"归我审的班";终审:全租户(null = 不按班级过滤)。
+ // ⚠️ 空列表 ≠ null:空列表意味着"一个班都不归我审",SQL 里会变成 1 = 0
+ List orgScope = node == NODE_CLASS
+ ? auditScopeMapper.classAuditOrgIds(me.tenantId(), taskId, me.uid())
+ : null;
+ String kw = blank(keyword) ? null : keyword.trim();
+
+ long pn = pageNum == null || pageNum < 1 ? 1 : pageNum;
+ long ps = pageSize == null || pageSize < 1 ? DEFAULT_PAGE_SIZE : Math.min(pageSize, MAX_PAGE_SIZE);
+ IPage page = todoMapper.pageTodos(new Page<>(pn, ps),
+ me.tenantId(), status, taskId, orgId, orgScope, level, kw, waitingOverHours);
+
+ AuditDtos.TodoPageVO out = new AuditDtos.TodoPageVO();
+ out.setList(assembleTodos(page.getRecords(), me.tenantId()));
+ out.setTotal(page.getTotal());
+ out.setCounts(countsOf(todoMapper.counts(me.tenantId(), status, taskId, orgId, orgScope,
+ kw, timeoutHours(node, taskId))));
+ return out;
+ }
+
+ /** 待办的状态与节点必须对得上:初审看 1,终审看 2。 */
+ private static int statusOfNode(int node) {
+ return switch (node) {
+ case NODE_CLASS -> CollectSubmission.STATUS_PENDING_CLASS;
+ case NODE_ADMIN -> CollectSubmission.STATUS_PENDING_ADMIN;
+ default -> throw new BizException(ErrorCode.VALIDATION_BLOCKED,
+ "审核节点只能是 1(初审)或 2(终审),收到:" + node);
+ };
+ }
+
+ private void requireNodePermission(int node) {
+ // 终审要 AUDIT_FINAL;初审要 AUDIT_FIRST。注意**光有权限还不够** ——
+ // 初审还要落在自己带的班里,那一条在 SQL 的 orgScope 里(见 AuditTodoMapper)
+ permissionChecker.require(node == NODE_CLASS
+ ? PermissionDef.AUDIT_FIRST
+ : PermissionDef.AUDIT_FINAL);
+ }
+
+ /**
+ * 列表行的装配。
+ *
+ * 材料缩略图**一次批量取**(见 {@code FileMapper.listBySubmissions})——
+ * 一行查一次是典型的 N+1,而列表是最常刷的页面。
+ */
+ private List assembleTodos(List rows, Long tenantId) {
+ if (rows == null || rows.isEmpty()) {
+ return List.of();
+ }
+ List ids = rows.stream().map(AuditTodoRow::getSubmissionId).toList();
+ Map> bySubmission = fileMapper
+ .listBySubmissions(tenantId, ids).stream()
+ .collect(Collectors.groupingBy(CollectFile::getSubmissionId,
+ LinkedHashMap::new, Collectors.toList()));
+
+ LocalDateTime now = LocalDateTime.now();
+ List out = new ArrayList<>(rows.size());
+ for (AuditTodoRow r : rows) {
+ AuditDtos.TodoVO vo = new AuditDtos.TodoVO();
+ vo.setSubmissionId(r.getSubmissionId());
+ vo.setRosterId(r.getRosterId());
+ vo.setTaskId(r.getTaskId());
+ vo.setTaskName(r.getTaskName());
+ vo.setRealName(r.getRealName());
+ vo.setOrgId(r.getOrgId());
+ vo.setOrgName(r.getOrgName());
+ vo.setSubmitTime(r.getSubmitTime());
+ vo.setWaitHours(waitHours(r.getSubmitTime(), now));
+ int red = nz(r.getRedCount());
+ int yellow = nz(r.getYellowCount());
+ vo.setRedCount(red);
+ vo.setYellowCount(yellow);
+ vo.setLevel(levelOf(red, yellow));
+ vo.setRejectCount(nz(r.getRejectCount()));
+ vo.setStatus(r.getStatus());
+ vo.setStatusText(SubmitDtos.Report.statusText(r.getStatus()));
+ vo.setChangedFields(parseStringArray(r.getChangedFields()));
+ // ⚠️ batchApprovable 由服务端算:红黄都为零才允许进"一键通过"
+ vo.setBatchApprovable(red == 0 && yellow == 0);
+ vo.setRedSummary(red == 0 ? null : firstRedTitle(r.getSubmissionId(), tenantId));
+
+ List fs = bySubmission.getOrDefault(r.getSubmissionId(), List.of());
+ vo.setMaterialCount(fs.size());
+ vo.setMaterialThumbs(fs.stream().limit(MAX_THUMBS)
+ .map(f -> fileService.urlOf(f, false, URL_EXPIRE_SECONDS,
+ AUDIT_DOWNLOAD_PREFIX).url())
+ .toList());
+ out.add(vo);
+ }
+ return out;
+ }
+
+ /**
+ * 卡片上那句红问题("身份证号与名册不一致")。
+ *
+ * 库里没有存"红问题摘要",只有计数。所以这里对红色单现场跑一次校验 ——
+ * 一期红色单几乎不存在(提交时红色会拦住学生),所以这点开销可以接受;
+ * 换成给每一行都跑,列表就变成"每行一次全量校验"了。
+ */
+ private String firstRedTitle(Long submissionId, Long tenantId) {
+ CollectSubmission sub = submissionMapper.selectById(submissionId);
+ if (sub == null) {
+ return null;
+ }
+ try {
+ CollectTask task = taskMapper.selectById(sub.getTaskId());
+ List issues = validator.validate(sub, task, rosterOf(sub));
+ return issues.stream()
+ .filter(i -> i.severity() == SubmitDtos.Issue.ERROR)
+ .map(SubmitDtos.Issue::title)
+ .findFirst()
+ .orElse(null);
+ } catch (RuntimeException e) {
+ // 摘要失败不该让整个列表 500 —— 它只是一句提示
+ log.warn("取红问题摘要失败:submission={}", submissionId, e);
+ return null;
+ }
+ }
+
+ /**
+ * 顶部计数。
+ *
+ * {@code overTime} 的口径:提交时间早于"现在 − 时限"。
+ * 时限取该任务的审核配置(初审 48h / 终审 72h 是 PRD 默认)。
+ */
+ private AuditDtos.Counts countsOf(Map raw) {
+ AuditDtos.Counts c = new AuditDtos.Counts();
+ c.setAll(asLong(raw == null ? null : raw.get("all_count")));
+ c.setClean(asLong(raw == null ? null : raw.get("clean_count")));
+ c.setAbnormal(asLong(raw == null ? null : raw.get("abnormal_count")));
+ c.setOverTime(asLong(raw == null ? null : raw.get("overtime_count")));
+ return c;
+ }
+
+ /** 审核时限(小时):优先任务配置,读不到就用 PRD 默认值。 */
+ private int timeoutHours(int node, Long taskId) {
+ boolean isClass = node == NODE_CLASS;
+ if (taskId != null) {
+ CollectTask task = taskMapper.selectById(taskId);
+ Map cfg = parseObject(task == null ? null : task.getAuditConfig());
+ Integer v = asInt(cfg.get(isClass ? "classAuditTimeoutHours" : "adminAuditTimeoutHours"));
+ if (v != null && v > 0) {
+ return v;
+ }
+ }
+ return isClass ? DEFAULT_CLASS_TIMEOUT_HOURS : DEFAULT_ADMIN_TIMEOUT_HOURS;
+ }
+
+ // ══════════════════ 详情 ══════════════════
+
+ /**
+ * 审核详情(§11.2)。
+ *
+ * ⚠️ 这里刻意不标 {@code readOnly = true}:它会回写红黄快照
+ * (见 {@link #writeBackCounts} 的说明)。标了 readOnly 之后,
+ * PG 会直接拒绝那条 UPDATE({@code cannot execute UPDATE in a read-only transaction}),
+ * 而表现是"计数没变时一切正常、一旦现场值与快照不一致就 500" ——
+ * 也就是这条路径只在**最需要它工作**的那一次崩掉。
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public AuditDtos.DetailVO detail(Long submissionId) {
+ LoginUser me = UserContext.require();
+ CollectSubmission sub = requireSubmission(submissionId, me.tenantId());
+ CollectTask task = taskMapper.selectById(sub.getTaskId());
+ CollectRoster roster = rosterOf(sub);
+ SysOrg org = sub.getOrgId() == null ? null : orgMapper.selectById(sub.getOrgId());
+
+ boolean canAudit = canAuditNow(sub, me);
+ if (!canAudit && !permissionChecker.has(PermissionDef.PROGRESS_VIEW_ALL)) {
+ // 既不能审、又没有"看全部进度"的权限 —— 那就不该看到学生的材料
+ throw new BizException(ErrorCode.FORBIDDEN, "你没有查看这份填报单的权限");
+ }
+
+ // 现场校验(并顺带回写快照,见类注释)
+ List issues = validator.validate(sub, task, roster);
+ int red = countSeverity(issues, SubmitDtos.Issue.ERROR);
+ int yellow = countSeverity(issues, SubmitDtos.Issue.WARN);
+ writeBackCounts(sub, red, yellow);
+
+ AuditDtos.DetailVO vo = new AuditDtos.DetailVO();
+ vo.setId(sub.getId());
+ vo.setStatus(sub.getStatus());
+ vo.setStatusText(SubmitDtos.Report.statusText(sub.getStatus()));
+ vo.setTaskId(sub.getTaskId());
+ vo.setTaskName(task == null ? null : task.getTaskName());
+ vo.setOrgId(sub.getOrgId());
+ vo.setOrgName(org == null ? null : org.getOrgName());
+ vo.setRealName(roster == null ? null : roster.getRealName());
+ vo.setIdCardMask(roster == null ? null : roster.getIdCardMask());
+ vo.setSubmitTime(sub.getSubmitTime());
+ vo.setOccupation(sub.getOccupation());
+ vo.setWorktype(sub.getWorktype());
+ vo.setJobLevel(sub.getJobLevel());
+ vo.setRedCount(red);
+ vo.setYellowCount(yellow);
+ vo.setRejectCount(nz(sub.getRejectCount()));
+ applyLastReject(vo, sub.getLastRejectReason());
+
+ vo.setFormData(formValuesOf(sub));
+ vo.setChangedFields(parseStringArray(sub.getChangedFields()));
+ vo.setChangedDetail(changedDetailOf(sub, roster, task));
+ vo.setMaterials(materialsOf(sub, task));
+ vo.setIssues(issues);
+ vo.setApprovePolicy(approvePolicyOf(sub, me, red, yellow, issues));
+ vo.setRevokePolicy(revokePolicyOf(sub, me));
+ vo.setTimeline(timelineOf(sub, task));
+ return vo;
+ }
+
+ /** 学生填的全部值(内置列 + 扩展字段)。 */
+ private Map formValuesOf(CollectSubmission sub) {
+ Map out = new LinkedHashMap<>(SubmissionService.SubValues.allOf(sub));
+ out.putAll(parseObject(sub.getExtrasJson()));
+ return out;
+ }
+
+ /**
+ * 改动明细:老师填的是什么、学生改成了什么。
+ *
+ * ⚠️ {@code changedAt} 恒为 null:{@code changed_fields} 存的是字段名数组
+ * (见 {@code SubmissionService.saveDraft}),没有逐字段的时间。
+ * 契约里这个字段的形状是 {@code {old,new,time}},但落库时选了更简单的数组 ——
+ * 以代码为准(同 RosterVO 那次)。要时间的话得改存储形状,那是另一件事。
+ */
+ private List changedDetailOf(CollectSubmission sub, CollectRoster roster,
+ CollectTask task) {
+ List keys = parseStringArray(sub.getChangedFields());
+ if (keys.isEmpty() || roster == null) {
+ return List.of();
+ }
+ Map rosterVals = FillService.rosterValuesOf(roster);
+ Map labels = labelsOf(task);
+ List out = new ArrayList<>(keys.size());
+ for (String key : keys) {
+ AuditDtos.ChangedItem item = new AuditDtos.ChangedItem();
+ item.setField(key);
+ item.setLabel(labels.getOrDefault(key, key));
+ item.setRosterValue(str(rosterVals.get(key)));
+ item.setStudentValue(str(SubmissionService.SubValues.read(sub, key)));
+ if (item.getStudentValue() == null) {
+ Map extras = parseObject(sub.getExtrasJson());
+ item.setStudentValue(str(extras.get(key)));
+ }
+ item.setChangedAt(null);
+ out.add(item);
+ }
+ return out;
+ }
+
+ /** key → 中文标签(表单里配置的 label;扩展字段也在里面)。 */
+ private Map labelsOf(CollectTask task) {
+ Map out = new HashMap<>();
+ if (task == null) {
+ return out;
+ }
+ FormSchemaVO schema = formSchemaService.get(task.getId());
+ for (FormFieldVO f : schema.getFields()) {
+ out.put(f.getKey(), f.getLabel());
+ }
+ for (FormFieldVO f : schema.getExtras()) {
+ out.put(f.getKey(), f.getLabel());
+ }
+ return out;
+ }
+
+ /**
+ * 材料分组(按任务的材料定义走,**不是**按学生传了什么走)。
+ *
+ * 差别很关键:只看"传了什么"的话,缺的那一项根本不会出现在审核页上,
+ * 而"缺件"恰恰是最常见的驳回原因。所以以定义为骨架,文件挂上去。
+ *
+ *
条件材料(如在校生不交工作证明)按可见性排除 —— 判可见性与学生端
+ * 用同一个 {@code SubmissionValidator.visible},避免"学生看不见、审核却要求有"。
+ */
+ private List materialsOf(CollectSubmission sub, CollectTask task) {
+ if (task == null) {
+ return List.of();
+ }
+ Map values = formValuesOf(sub);
+ List files = fileMapper.listBySubmissions(
+ sub.getTenantId(), List.of(sub.getId()));
+ Map> byCode = files.stream()
+ .collect(Collectors.groupingBy(CollectFile::getMaterialCode,
+ LinkedHashMap::new, Collectors.toList()));
+
+ List defs = materialDefService.list(sub.getTaskId());
+ List out = new ArrayList<>(defs.size());
+ for (MaterialDefVO def : defs) {
+ boolean visible = SubmissionValidator.visible(def.getVisibleWhen(), values);
+ List fs = byCode.getOrDefault(def.getMaterialCode(), List.of());
+ AuditDtos.MaterialGroup g = new AuditDtos.MaterialGroup();
+ g.setMaterialCode(def.getMaterialCode());
+ g.setMaterialName(def.getMaterialName());
+ g.setRequired(def.getRequired());
+ g.setVisible(visible);
+ g.setDone(!fs.isEmpty());
+ g.setFiles(fs.stream().map(f -> fileItemOf(f, def)).toList());
+ out.add(g);
+ }
+ return out;
+ }
+
+ private AuditDtos.FileItem fileItemOf(CollectFile f, MaterialDefVO def) {
+ AuditDtos.FileItem item = new AuditDtos.FileItem();
+ item.setId(f.getId());
+ item.setOriginalName(f.getOriginalName());
+ item.setStandardName(f.getStandardName());
+ MaterialFileService.FileUrl u = fileService.urlOf(f, false, URL_EXPIRE_SECONDS,
+ AUDIT_DOWNLOAD_PREFIX);
+ item.setPreviewUrl(u.url());
+ item.setThumbUrl(u.url());
+ item.setFinalSizeKb(f.getFinalSize() == null ? null : f.getFinalSize() / 1024);
+ item.setMaxSizeKb(def.getMaxSizeKb() == null ? null : def.getMaxSizeKb().longValue());
+ item.setWidth(f.getWidth());
+ item.setHeight(f.getHeight());
+ item.setDpi(f.getDpi());
+ item.setValidateStatus(f.getValidateStatus());
+ item.setSource(f.getSource());
+ // 识别结果(M4)与文件级问题都还没有落库:前者要 OCR/VLM,后者要规则能定位到文件。
+ // 明确留 null / 空数组,而不是编一个"看起来实现了"的值。
+ item.setDetect(null);
+ item.setIssues(List.of());
+ return item;
+ }
+
+ private AuditDtos.ApprovePolicy approvePolicyOf(CollectSubmission sub, LoginUser me,
+ int red, int yellow,
+ List issues) {
+ AuditDtos.ApprovePolicy p = new AuditDtos.ApprovePolicy();
+ int node = nodeOfStatus(sub.getStatus());
+ if (node == 0) {
+ p.setCanApprove(false);
+ p.setCanBatch(false);
+ p.setReason("当前状态是「" + SubmitDtos.Report.statusText(sub.getStatus()) + "」,没有待处理的审核动作");
+ return p;
+ }
+ String scopeReason = auditDenyReason(sub, me, node);
+ if (scopeReason != null) {
+ p.setCanApprove(false);
+ p.setCanBatch(false);
+ p.setReason(scopeReason);
+ return p;
+ }
+ p.setRedTitles(issues.stream()
+ .filter(i -> i.severity() == SubmitDtos.Issue.ERROR)
+ .map(SubmitDtos.Issue::title)
+ .toList());
+ if (red > 0) {
+ p.setCanApprove(false);
+ p.setCanBatch(false);
+ p.setReason("存在 " + red + " 个红色问题,必须先驳回让学生改");
+ return p;
+ }
+ p.setCanApprove(true);
+ // 批量通过只带绿色项 —— 黄色也要单独看(铁律)
+ p.setCanBatch(yellow == 0);
+ if (yellow > 0) {
+ p.setReason("有 " + yellow + " 个黄色提醒,需要你单独确认后再通过");
+ }
+ return p;
+ }
+
+ private AuditDtos.RevokePolicy revokePolicyOf(CollectSubmission sub, LoginUser me) {
+ AuditDtos.RevokePolicy p = new AuditDtos.RevokePolicy();
+ p.setCanRevoke(false);
+ if (!permissionChecker.has(PermissionDef.AUDIT_WITHDRAW)) {
+ p.setReason("没有撤回权限");
+ return p;
+ }
+ if (Objects.equals(sub.getStatus(), CollectSubmission.STATUS_ARCHIVED)) {
+ p.setReason("已归档,不可撤回");
+ return p;
+ }
+ CollectAuditRecord latest = latestConclusion(sub.getId());
+ if (latest == null) {
+ p.setReason("还没有可撤回的审核结论");
+ return p;
+ }
+ if (!Objects.equals(latest.getAuditorId(), me.uid())
+ && !permissionChecker.has(PermissionDef.AUDIT_FINAL)) {
+ p.setReason("只有原审核人或管理员可以撤回");
+ return p;
+ }
+ if (preconditionMapper.countFinishedExports(sub.getTenantId(), sub.getTaskId()) > 0) {
+ p.setReason("已导出打包,不可撤回(已交付的数据不支持追溯修改)");
+ return p;
+ }
+ p.setCanRevoke(true);
+ p.setAuditRecordId(latest.getId());
+ return p;
+ }
+
+ /** 时间轴(§11.5,**倒序**:最近发生的在最上面)。 */
+ @Transactional(readOnly = true)
+ public List timeline(Long submissionId) {
+ LoginUser me = UserContext.require();
+ CollectSubmission sub = requireSubmission(submissionId, me.tenantId());
+ if (!canAuditNow(sub, me) && !permissionChecker.has(PermissionDef.PROGRESS_VIEW_ALL)) {
+ throw new BizException(ErrorCode.FORBIDDEN, "你没有查看这份填报单的权限");
+ }
+ return timelineOf(sub, taskMapper.selectById(sub.getTaskId()));
+ }
+
+ private List timelineOf(CollectSubmission sub, CollectTask task) {
+ List out = new ArrayList<>();
+
+ // ① 学生提交(合成条目:它不是一条审核记录,但时间轴上必须有它——
+ // 否则"班主任 3 小时处理完"这件事没有起点)
+ if (sub.getSubmitTime() != null) {
+ AuditDtos.TimelineItem t = new AuditDtos.TimelineItem();
+ t.setId(0L);
+ t.setNode(0);
+ t.setNodeText("学生提交");
+ t.setActionText("提交材料");
+ t.setAuditorId(sub.getSubmitterId());
+ t.setTime(sub.getSubmitTime());
+ out.add(t);
+ }
+
+ // ② 审核记录
+ List records = auditRecordMapper.selectList(
+ Wrappers.lambdaQuery()
+ .eq(CollectAuditRecord::getSubmissionId, sub.getId())
+ .orderByDesc(CollectAuditRecord::getId));
+ Map names = userNames(records.stream()
+ .map(CollectAuditRecord::getAuditorId).filter(Objects::nonNull).collect(Collectors.toSet()));
+ for (CollectAuditRecord r : records) {
+ AuditDtos.TimelineItem t = new AuditDtos.TimelineItem();
+ t.setId(r.getId());
+ t.setNode(CollectAuditRecord.NODE_ADMIN.equals(r.getAuditNode()) ? 2 : 1);
+ t.setNodeText(CollectAuditRecord.NODE_ADMIN.equals(r.getAuditNode()) ? "管理员终审" : "班主任初审");
+ t.setAction(r.getAction());
+ t.setActionText(actionText(r.getAction()));
+ t.setAuditorId(r.getAuditorId());
+ t.setAuditorName(names.get(r.getAuditorId()));
+ t.setReasonCode(r.getReasonCode());
+ t.setReasonText(r.getReasonText());
+ t.setMaterialCodes(splitCsv(r.getMaterialCodes()));
+ t.setTime(r.getCreateTime());
+ t.setDurationSec(r.getDurationSec());
+ out.add(t);
+ }
+
+ // ③ 教师代传的材料(source = 2 班主任 / 3 管理员)
+ // ⚠️ 代传接口(M5-06)还没做,所以现在不会有这类文件;但时间轴的口径先立住:
+ // 代传必须留痕、且学生端可见(§10.6)。
+ List proxied = fileMapper.listBySubmissions(sub.getTenantId(), List.of(sub.getId()))
+ .stream().filter(f -> f.getSource() != null && (f.getSource() == 2 || f.getSource() == 3))
+ .toList();
+ if (!proxied.isEmpty()) {
+ Map byId = userNames(proxied.stream()
+ .map(CollectFile::getCreateBy).filter(Objects::nonNull).collect(Collectors.toSet()));
+ Set seen = new LinkedHashSet<>();
+ for (CollectFile f : proxied) {
+ String who = byId.getOrDefault(f.getCreateBy(), "老师");
+ if (!seen.add(who + f.getMaterialCode())) {
+ continue; // 同一人代传同一个材料多次,只在时间轴上留一条
+ }
+ AuditDtos.TimelineItem t = new AuditDtos.TimelineItem();
+ t.setId(0L);
+ t.setNode(3);
+ t.setNodeText("代传材料");
+ t.setAction(CollectAuditRecord.ACTION_PROXY_UPLOAD);
+ t.setActionText("由" + who + "代传");
+ t.setAuditorId(f.getCreateBy());
+ t.setAuditorName(who);
+ t.setMaterialCodes(List.of(f.getMaterialCode()));
+ t.setTime(f.getCreateTime());
+ out.add(t);
+ }
+ }
+
+ // 倒序:时间轴上最近发生的最上面。节点的合成条目的时间为准 ——
+ // 审核记录的 create_time 可能与其 duration 起点不同,不混用同一把尺子
+ out.sort((a, b) -> {
+ if (a.getTime() == null || b.getTime() == null) {
+ return 0;
+ }
+ return b.getTime().compareTo(a.getTime());
+ });
+ return out;
+ }
+
+ private static String actionText(Integer action) {
+ if (action == null) {
+ return "未知动作";
+ }
+ return switch (action) {
+ case CollectAuditRecord.ACTION_APPROVE -> "通过";
+ case CollectAuditRecord.ACTION_REJECT -> "驳回";
+ case CollectAuditRecord.ACTION_BATCH_APPROVE -> "批量通过";
+ case CollectAuditRecord.ACTION_BATCH_REJECT -> "批量驳回";
+ case CollectAuditRecord.ACTION_TRANSFER -> "转交";
+ case CollectAuditRecord.ACTION_PROXY_UPLOAD -> "代传材料";
+ case CollectAuditRecord.ACTION_REVOKE -> "撤回结论";
+ default -> "未知动作(" + action + ")";
+ };
+ }
+
+ // ══════════════════ 通过 ══════════════════
+
+ /** 通过(§10.3 / §11.3)。初审 {@code 1→2},终审 {@code 2→3}。 */
+ @Transactional(rollbackFor = Exception.class)
+ public AuditDtos.ApproveVO approve(Long submissionId, AuditDtos.ApproveReq req) {
+ LoginUser me = UserContext.require();
+ CollectSubmission sub = submissionMapper.selectForAudit(submissionId, me.tenantId());
+ if (sub == null) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED, "填报单不存在或已删除");
+ }
+
+ // 幂等(契约 §10.3 写了"幂等:是"):同一个人对同一个节点重复点通过,
+ // 不该变成"通过两次"或"报错说状态不对"——学生端只关心结果。
+ AuditDtos.ApproveVO replay = replayOf(sub, me);
+ if (replay != null) {
+ log.info("审核通过为幂等回放:submission={} status={}", submissionId, sub.getStatus());
+ return replay;
+ }
+
+ int node = requireAuditableNode(sub, me);
+ CollectTask task = taskMapper.selectById(sub.getTaskId());
+ List issues = validator.validate(sub, task, rosterOf(sub));
+ int red = countSeverity(issues, SubmitDtos.Issue.ERROR);
+ int yellow = countSeverity(issues, SubmitDtos.Issue.WARN);
+ writeBackCounts(sub, red, yellow);
+ if (red > 0) {
+ // ⚠️ 铁律:有红问题不允许通过。校验是**现场重算**的 ——
+ // 提交时的快照可能已经过期(异步识别、名册被改),
+ // 而"放过一份有问题材料"的代价不可撤销。
+ String first = issues.stream()
+ .filter(i -> i.severity() == SubmitDtos.Issue.ERROR)
+ .map(SubmitDtos.Issue::title).findFirst().orElse("");
+ throw new BizException(ErrorCode.RED_ISSUE_BLOCKS_APPROVE,
+ "存在 " + red + " 个红色问题,不允许通过:" + first);
+ }
+
+ applyApproval(sub, node, me, CollectAuditRecord.ACTION_APPROVE, remarkOf(req));
+ int newStatus = node == NODE_CLASS
+ ? CollectSubmission.STATUS_PENDING_ADMIN
+ : CollectSubmission.STATUS_APPROVED;
+ log.info("审核通过:submission={} node={} → status={} 审计人={}",
+ submissionId, node, newStatus, me.uid());
+ return approveResult(newStatus, false);
+ }
+
+ /**
+ * 幂等回放:如果"我上一次的通过"仍然有效,直接返回当前状态。
+ *
+ * 判定条件(三个都要满足):
+ *
+ * - 最近一条结论是我做的通过
+ * - 那条结论的节点已经生效(初审记录 + 状态已到终审或更后)
+ * - 没有再被驳回/撤回(最近一条结论仍是它)
+ *
+ * 只认得"我自己的"——别人的重复结论说明状态已经往前走了,
+ * 那时应该报"当前状态不对",而不是假装成功。
+ */
+ private AuditDtos.ApproveVO replayOf(CollectSubmission sub, LoginUser me) {
+ CollectAuditRecord latest = latestConclusion(sub.getId());
+ if (latest == null || !Objects.equals(latest.getAuditorId(), me.uid())) {
+ return null;
+ }
+ boolean approved = latest.getAction() != null
+ && (latest.getAction() == CollectAuditRecord.ACTION_APPROVE
+ || latest.getAction() == CollectAuditRecord.ACTION_BATCH_APPROVE);
+ if (!approved) {
+ return null;
+ }
+ boolean moved = CollectAuditRecord.NODE_CLASS.equals(latest.getAuditNode())
+ ? (sub.getStatus() != null && sub.getStatus() >= CollectSubmission.STATUS_PENDING_ADMIN)
+ : Objects.equals(sub.getStatus(), CollectSubmission.STATUS_APPROVED);
+ return moved ? approveResult(sub.getStatus(), true) : null;
+ }
+
+ private static AuditDtos.ApproveVO approveResult(int status, boolean replayed) {
+ AuditDtos.ApproveVO vo = new AuditDtos.ApproveVO();
+ vo.setStatus(status);
+ vo.setStatusText(SubmitDtos.Report.statusText(status));
+ vo.setReplayed(replayed);
+ if (status == CollectSubmission.STATUS_PENDING_ADMIN) {
+ vo.setNextNode("ADMIN_AUDIT");
+ vo.setNextNodeText("等待管理员终审");
+ } else if (status == CollectSubmission.STATUS_APPROVED) {
+ vo.setNextNode(null);
+ vo.setNextNodeText("审核完成");
+ }
+ return vo;
+ }
+
+ private static String remarkOf(AuditDtos.ApproveReq req) {
+ if (req == null) {
+ return null;
+ }
+ if (req.getFinalOpinion() != null && !req.getFinalOpinion().isBlank()) {
+ return req.getFinalOpinion().trim();
+ }
+ return req.getRemark() == null ? null : req.getRemark().trim();
+ }
+
+ /** 落库:状态 + 审核人列 + 一条记录 + 名册行状态。 */
+ private void applyApproval(CollectSubmission sub, int node, LoginUser me, int action, String remark) {
+ LocalDateTime now = LocalDateTime.now();
+ var update = Wrappers.lambdaUpdate()
+ .eq(CollectSubmission::getId, sub.getId())
+ .set(CollectSubmission::getStatus,
+ node == NODE_CLASS
+ ? CollectSubmission.STATUS_PENDING_ADMIN
+ : CollectSubmission.STATUS_APPROVED);
+ if (node == NODE_CLASS) {
+ update.set(CollectSubmission::getClassAuditBy, me.uid())
+ .set(CollectSubmission::getClassAuditTime, now);
+ } else {
+ update.set(CollectSubmission::getAdminAuditBy, me.uid())
+ .set(CollectSubmission::getAdminAuditTime, now);
+ }
+ submissionMapper.update(null, update);
+ insertRecord(sub, node, me, action, null, remark, null);
+ // 名册行:初审通过仍是"待审"(等终审),终审通过才是"已通过"
+ markRoster(sub.getRosterId(), node == NODE_CLASS
+ ? CollectRoster.SUBMIT_PENDING
+ : CollectRoster.SUBMIT_APPROVED);
+ }
+
+ private void insertRecord(CollectSubmission sub, int node, LoginUser me, int action,
+ String reasonCode, String reasonText, List materialCodes) {
+ CollectAuditRecord r = new CollectAuditRecord();
+ r.setTaskId(sub.getTaskId());
+ r.setSubmissionId(sub.getId());
+ r.setAuditNode(node == NODE_CLASS ? CollectAuditRecord.NODE_CLASS : CollectAuditRecord.NODE_ADMIN);
+ r.setAuditorId(me.uid());
+ r.setAction(action);
+ r.setReasonCode(reasonCode);
+ r.setReasonText(clip(reasonText, 512));
+ r.setMaterialCodes(clip(materialCodes == null || materialCodes.isEmpty()
+ ? null : String.join(",", materialCodes), 256));
+ r.setDurationSec(durationSec(sub.getSubmitTime()));
+ auditRecordMapper.insert(r);
+ }
+
+ // ══════════════════ 驳回 ══════════════════
+
+ /** 驳回(§10.4)。初审、终审都直接生效(终审可越过初审)。 */
+ @Transactional(rollbackFor = Exception.class)
+ public AuditDtos.RejectVO reject(Long submissionId, AuditDtos.RejectReq req) {
+ LoginUser me = UserContext.require();
+ if (req == null) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写驳回原因");
+ }
+ CollectSubmission sub = submissionMapper.selectForAudit(submissionId, me.tenantId());
+ if (sub == null) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED, "填报单不存在或已删除");
+ }
+ int node = requireAuditableNode(sub, me);
+
+ String code = RejectReasonCatalog.requireValid(req.getReasonCode(), req.getReasonText());
+ String fullText = RejectReasonCatalog.fullText(code, req.getReasonText(), req.getVoiceText());
+
+ int rejectCount = nz(sub.getRejectCount()) + 1;
+ int max = maxRejectCount(taskMapper.selectById(sub.getTaskId()));
+ boolean escalated = rejectCount >= max;
+
+ // last_reject_reason 只有 varchar(512):超了会直接 500(PG 报 value too long),
+ // 所以先截断再拼 JSON
+ String json = JSONUtil.createObj()
+ .set("reasonCode", code)
+ .set("reasonText", clip(fullText, 400))
+ .toString();
+ submissionMapper.update(null, Wrappers.lambdaUpdate()
+ .eq(CollectSubmission::getId, sub.getId())
+ .set(CollectSubmission::getStatus, CollectSubmission.STATUS_REJECTED)
+ .set(CollectSubmission::getRejectCount, rejectCount)
+ .set(CollectSubmission::getLastRejectReason, clip(json, 512))
+ // 这一轮审核到此结束:审核人列一起清掉。
+ // 留着它们会出现"状态是已驳回、却带着初审通过时间"这种自相矛盾的行,
+ // 而历史在 collect_audit_record 里一条都不会少。
+ .set(CollectSubmission::getClassAuditBy, null)
+ .set(CollectSubmission::getClassAuditTime, null)
+ .set(CollectSubmission::getAdminAuditBy, null)
+ .set(CollectSubmission::getAdminAuditTime, null));
+
+ insertRecord(sub, node, me, CollectAuditRecord.ACTION_REJECT, code, fullText,
+ req.getMaterialCodes());
+ markRoster(sub.getRosterId(), CollectRoster.SUBMIT_REJECTED);
+
+ if (escalated) {
+ // ⚠️ 通知(订阅消息 / 站内信,M5-12)还没做,所以这里只有日志。
+ // 响应里如实地把 notifySent=false + needManualNotice=true 告诉前端,
+ // 让班主任知道"得自己跟学生说一声"——比假装通知过了强得多。
+ log.warn("驳回次数已达上限,需要管理员介入:submission={} 学生={} 第 {} 次驳回 原因={}",
+ submissionId, sub.getRosterId(), rejectCount, code);
+ }
+ log.info("审核驳回:submission={} node={} 第 {} 次 原因={}", submissionId, node, rejectCount, code);
+
+ AuditDtos.RejectVO vo = new AuditDtos.RejectVO();
+ vo.setStatus(CollectSubmission.STATUS_REJECTED);
+ vo.setStatusText(SubmitDtos.Report.statusText(CollectSubmission.STATUS_REJECTED));
+ vo.setRejectCount(rejectCount);
+ vo.setNotifySent(false);
+ vo.setNeedManualNotice(true);
+ vo.setEscalated(escalated);
+ return vo;
+ }
+
+ // ══════════════════ 批量通过 ══════════════════
+
+ /**
+ * 批量通过(§10.5)。
+ *
+ * 铁律:只带无问题的单,红黄一律排除(即使前端传了红单,服务端也跳过并回报原因)。
+ * 这是防"手快全选"的最后一道闸 —— 前端多选控件天生容易全选,
+ * 而一次全选放行 30 份有问题的材料,返工成本由学生和机构一起承担。
+ *
+ *
跳过的每一条都要说明白:只回"成功 3 条、跳过 2 条",用户只会反复点。
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public AuditDtos.BatchApproveVO batchApprove(AuditDtos.BatchApproveReq req) {
+ LoginUser me = UserContext.require();
+ List ids = req == null || req.getSubmissionIds() == null
+ ? List.of()
+ : req.getSubmissionIds().stream().filter(Objects::nonNull).distinct().toList();
+ if (ids.isEmpty()) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请选择要通过的填报单");
+ }
+ if (ids.size() > MAX_BATCH) {
+ throw new BizException(ErrorCode.BATCH_LIMIT_EXCEEDED,
+ "一次最多批量通过 " + MAX_BATCH + " 份,当前选了 " + ids.size() + " 份");
+ }
+ // 契约里有 onlyClean 字段,但服务端强制 true(铁律不许关)。
+ // 客户端传 false 不报错、也不生效 —— 报错会让旧客户端直接不可用,
+ // 而这件事没有"客户端说了算"的版本。
+ if (req.getOnlyClean() != null && !req.getOnlyClean()) {
+ log.info("批量通过收到 onlyClean=false,已按铁律强制为 true(用户={})", me.uid());
+ }
+
+ List details = new ArrayList<>();
+ int approved = 0;
+ for (Long id : ids) {
+ AuditDtos.BatchSkip skip = new AuditDtos.BatchSkip();
+ skip.setSubmissionId(id);
+ try {
+ CollectSubmission sub = submissionMapper.selectForAudit(id, me.tenantId());
+ if (sub == null) {
+ skip.setSkipped(true);
+ skip.setReason("填报单不存在或不属于本单位");
+ details.add(skip);
+ continue;
+ }
+ skip.setRealName(rosterName(sub));
+ String deny = auditDenyReason(sub, me, nodeOfStatus(sub.getStatus()));
+ if (deny != null) {
+ skip.setSkipped(true);
+ skip.setReason(deny);
+ details.add(skip);
+ continue;
+ }
+ int node = nodeOfStatus(sub.getStatus());
+ CollectTask task = taskMapper.selectById(sub.getTaskId());
+ List issues = validator.validate(sub, task, rosterOf(sub));
+ int red = countSeverity(issues, SubmitDtos.Issue.ERROR);
+ int yellow = countSeverity(issues, SubmitDtos.Issue.WARN);
+ writeBackCounts(sub, red, yellow);
+ if (red > 0 || yellow > 0) {
+ skip.setSkipped(true);
+ skip.setRedCount(red);
+ skip.setYellowCount(yellow);
+ skip.setReason(red > 0
+ ? "存在 " + red + " 个红色问题,需单独审核"
+ : "有 " + yellow + " 个黄色提醒,需单独确认后再通过");
+ details.add(skip);
+ continue;
+ }
+ applyApproval(sub, node, me, CollectAuditRecord.ACTION_BATCH_APPROVE, "一键通过");
+ approved++;
+ } catch (BizException e) {
+ // 单条失败不拖垮整批:把原因回报给用户,他才知道要单独处理哪一条
+ skip.setSkipped(true);
+ skip.setReason(e.getMessage());
+ details.add(skip);
+ }
+ }
+ log.info("批量通过:选中 {} 份,通过 {} 份,跳过 {} 份(用户={})",
+ ids.size(), approved, ids.size() - approved, me.uid());
+
+ AuditDtos.BatchApproveVO vo = new AuditDtos.BatchApproveVO();
+ vo.setApproved(approved);
+ vo.setSkipped(ids.size() - approved);
+ vo.setDetails(details);
+ return vo;
+ }
+
+ // ══════════════════ 撤回 ══════════════════
+
+ /**
+ * 撤回已作出的审核结论(§11.4)。
+ *
+ * 三条约束,各自的理由不同:
+ *
+ * - 已导出打包 → {@code 40033}:数据已经交给机构了,
+ * 在系统里改掉只会让"我们看到的"和"机构手上的"不一致。
+ * - 已归档 → {@code 40034}:归档是任务的终点,往回走没有意义。
+ * - 非原审核人且非管理员 → {@code 40301}:撤回是对别人结论的推翻,
+ * 只有当事人和管理员有资格。
+ *
+ *
+ * ⚠️ 只能撤回「最近一次结论」:撤回是"把这件事退回到那一刻之前",
+ * 越过中间那条撤回会让状态无法自洽(比如终审通过之后,去撤回更早的初审通过)。
+ * 要撤更早的那条,先把后面的撤掉。
+ *
+ *
⚠️ 契约 §11.4 的表格只列了"状态 3(已通过)"。这里也允许状态 2 ——
+ * 因为最常见的真实场景是班主任点错了通过(那一步之后状态是 2,不是 3)。
+ * 状态机本身是自洽的:撤初审记录 → 回到待初审(1);撤终审记录 → 回到待终审(2)。
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public AuditDtos.RevokeVO revoke(Long submissionId, AuditDtos.RevokeReq req) {
+ LoginUser me = UserContext.require();
+ permissionChecker.require(PermissionDef.AUDIT_WITHDRAW);
+ if (req == null || req.getAuditRecordId() == null) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED, "缺少要撤回的审核记录 id");
+ }
+ if (req.getReason() == null || req.getReason().isBlank()) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写撤回原因");
+ }
+ CollectSubmission sub = submissionMapper.selectForAudit(submissionId, me.tenantId());
+ if (sub == null) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED, "填报单不存在或已删除");
+ }
+ CollectTask task = taskMapper.selectById(sub.getTaskId());
+ if (Objects.equals(sub.getStatus(), CollectSubmission.STATUS_ARCHIVED)
+ || (task != null && Objects.equals(task.getStatus(), CollectTask.STATUS_ARCHIVED))) {
+ throw new BizException(ErrorCode.ARCHIVED_NO_WITHDRAW);
+ }
+ if (preconditionMapper.countFinishedExports(sub.getTenantId(), sub.getTaskId()) > 0) {
+ throw new BizException(ErrorCode.EXPORTED_NO_WITHDRAW);
+ }
+ CollectAuditRecord record = auditRecordMapper.selectById(req.getAuditRecordId());
+ // ⚠️ 租户由拦截器保证:别的租户的记录这里查不到,于是统一报"记录不存在",
+ // 不透露"它存在但不属于你"(同 TaskOrgService 的写法)
+ if (record == null || !Objects.equals(record.getSubmissionId(), submissionId)) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED, "审核记录不存在或不属于这份填报单");
+ }
+ if (!Objects.equals(record.getAuditorId(), me.uid())
+ && !permissionChecker.has(PermissionDef.AUDIT_FINAL)) {
+ throw new BizException(ErrorCode.FORBIDDEN, "只有原审核人或管理员可以撤回");
+ }
+ Integer action = record.getAction();
+ if (action == null || (action != CollectAuditRecord.ACTION_APPROVE
+ && action != CollectAuditRecord.ACTION_BATCH_APPROVE)) {
+ // 驳回为什么不能撤回:学生已经收到"要改什么",把他从"待改"拉回"待审"
+ // 只会让他更困惑;真的驳回错了,让他重新交一次即可(材料都还在)。
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED,
+ "只能撤回「通过」结论;驳回后学生重新提交即可,不需要撤回");
+ }
+ CollectAuditRecord latest = latestConclusion(submissionId);
+ if (latest == null) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED,
+ "这条结论已经被撤回过了(现在没有站得住的审核结论)");
+ }
+ if (!Objects.equals(latest.getId(), record.getId())) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED,
+ "只能撤回最近一次结论(要撤更早的那条,请先撤回它后面的)");
+ }
+
+ boolean classNode = CollectAuditRecord.NODE_CLASS.equals(record.getAuditNode());
+ int newStatus = classNode
+ ? CollectSubmission.STATUS_PENDING_CLASS
+ : CollectSubmission.STATUS_PENDING_ADMIN;
+ var update = Wrappers.lambdaUpdate()
+ .eq(CollectSubmission::getId, sub.getId())
+ .set(CollectSubmission::getStatus, newStatus);
+ if (classNode) {
+ update.set(CollectSubmission::getClassAuditBy, null)
+ .set(CollectSubmission::getClassAuditTime, null);
+ } else {
+ update.set(CollectSubmission::getAdminAuditBy, null)
+ .set(CollectSubmission::getAdminAuditTime, null);
+ }
+ submissionMapper.update(null, update);
+
+ // 撤回也是"发生过的事实":再写一条,而不是删掉原来那条(见 CollectAuditRecord 的类注释)
+ insertRecord(sub, classNode ? NODE_CLASS : NODE_ADMIN, me,
+ CollectAuditRecord.ACTION_REVOKE, null,
+ "撤回记录 #" + record.getId() + ":" + req.getReason().trim(), null);
+ markRoster(sub.getRosterId(), CollectRoster.SUBMIT_PENDING);
+
+ log.info("撤回审核结论:submission={} 撤回记录={} 新状态={} 原因={}",
+ submissionId, record.getId(), newStatus, req.getReason());
+
+ AuditDtos.RevokeVO vo = new AuditDtos.RevokeVO();
+ vo.setStatus(newStatus);
+ vo.setStatusText(SubmitDtos.Report.statusText(newStatus));
+ vo.setRevokedRecordId(record.getId());
+ return vo;
+ }
+
+ // ══════════════════ 文件(审核端预览)══════════════════
+
+ /** 审核端的预览链接(七牛私有空间 → 签名 URL,有有效期)。 */
+ @Transactional(readOnly = true)
+ public MaterialFileService.FileUrl fileUrl(Long fileId, boolean original, Long expireSeconds) {
+ CollectFile f = requireFileReadable(fileId);
+ return fileService.urlOf(f, original, expireSeconds, AUDIT_DOWNLOAD_PREFIX);
+ }
+
+ /** 审核端的内容流(本地存储时的预览通道)。 */
+ @Transactional(readOnly = true)
+ public MaterialFileService.Content fileContent(Long fileId, boolean original) {
+ return fileService.contentOf(requireFileReadable(fileId), original);
+ }
+
+ /**
+ * 取一份文件,并确认"这个人有权看它"。
+ *
+ * 授权不是"这个文件存在"就够 —— 而是它所属的填报单我能看:
+ * 能审这一单的节点,或者有全租户进度权限(管理员)。
+ * 少这一步,任何人只要猜到 fileId 就能下载别人的身份证照片。
+ */
+ private CollectFile requireFileReadable(Long fileId) {
+ LoginUser me = UserContext.require();
+ CollectFile f = fileMapper.selectForAudit(fileId, me.tenantId());
+ if (f == null) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件不存在或已删除");
+ }
+ CollectSubmission sub = submissionMapper.selectById(f.getSubmissionId());
+ if (sub == null) {
+ throw new BizException(ErrorCode.FORBIDDEN, "你没有查看这份材料的权限");
+ }
+ if (!canAuditNow(sub, me) && !permissionChecker.has(PermissionDef.PROGRESS_VIEW_ALL)) {
+ throw new BizException(ErrorCode.FORBIDDEN, "你没有查看这份材料的权限");
+ }
+ return f;
+ }
+
+ // ══════════════════ 内部:范围与权限 ══════════════════
+
+ /**
+ * 我能审这一单吗。
+ *
+ *
判定的两个维度缺一不可:
+ *
+ * - 状态 → 节点:待初审的单子归班主任,待终审的归管理员。
+ * 一个只有终审权限的人不该去点待初审的单子(那会跳过班主任那一层)。
+ * - 班级:初审必须落在"我带的班"。权限不等于范围 ——
+ * 全校几十个班主任都有 {@code audit:first},但只有这一个班的那位能审这个学生。
+ *
+ *
+ * @return 节点;不能审时抛异常(消息说清是哪一条不满足)
+ */
+ private int requireAuditableNode(CollectSubmission sub, LoginUser me) {
+ int node = nodeOfStatus(sub.getStatus());
+ if (node == 0) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED,
+ "这份填报单当前是「" + SubmitDtos.Report.statusText(sub.getStatus())
+ + "」,没有待你处理的审核动作");
+ }
+ String deny = auditDenyReason(sub, me, node);
+ if (deny != null) {
+ throw new BizException(ErrorCode.FORBIDDEN, deny);
+ }
+ return node;
+ }
+
+ /**
+ * 不能审的原因(能审时返回 {@code null})。
+ *
+ * 抽成"返回原因"而不是"直接抛异常",是因为待办列表与批量通过要的是
+ * 一句能显示给人看的话(跳过原因),而不是一个异常。
+ */
+ private String auditDenyReason(CollectSubmission sub, LoginUser me, int node) {
+ if (node == NODE_CLASS) {
+ if (!permissionChecker.has(PermissionDef.AUDIT_FIRST)) {
+ return "你没有初审权限";
+ }
+ if (sub.getOrgId() == null) {
+ return "这份填报单没有班级信息,无法确认审核范围";
+ }
+ long mine = auditScopeMapper.countMyOrg(me.tenantId(), sub.getTaskId(),
+ sub.getOrgId(), me.uid());
+ if (mine <= 0) {
+ return "这份填报单不在你负责的班级里";
+ }
+ return null;
+ }
+ if (node == NODE_ADMIN) {
+ if (!permissionChecker.has(PermissionDef.AUDIT_FINAL)) {
+ return "你没有终审权限";
+ }
+ return null;
+ }
+ return "当前状态没有待处理的审核动作";
+ }
+
+ /** 现在能不能审(用于详情/文件的可读判定)。 */
+ private boolean canAuditNow(CollectSubmission sub, LoginUser me) {
+ int node = nodeOfStatus(sub.getStatus());
+ return node != 0 && auditDenyReason(sub, me, node) == null;
+ }
+
+ private static int nodeOfStatus(Integer status) {
+ if (status == null) {
+ return 0;
+ }
+ return switch (status) {
+ case CollectSubmission.STATUS_PENDING_CLASS -> NODE_CLASS;
+ case CollectSubmission.STATUS_PENDING_ADMIN -> NODE_ADMIN;
+ default -> 0;
+ };
+ }
+
+ /**
+ * 最近一次仍然有效的结论(通过/驳回/批量),撤回记录不算。
+ *
+ *
⚠️ "仍然有效"这四个字是关键:被撤回过的结论不再有效 ——
+ * 所以这里先看最新的一条记录,如果它是撤回,就说明此刻没有站得住的结论。
+ * 少了这一条判断,同一条结论可以被反复撤回(每次都会再写一条撤回记录、
+ * 把状态再设回去),而界面上"撤回"按钮会一直亮着 —— 用户只会反复点。
+ */
+ private CollectAuditRecord latestConclusion(Long submissionId) {
+ List rows = auditRecordMapper.selectList(
+ Wrappers.lambdaQuery()
+ .eq(CollectAuditRecord::getSubmissionId, submissionId)
+ .orderByDesc(CollectAuditRecord::getId)
+ .last("LIMIT 1"));
+ if (rows.isEmpty()) {
+ return null;
+ }
+ CollectAuditRecord newest = rows.get(0);
+ Integer action = newest.getAction();
+ boolean conclusion = action != null
+ && (action == CollectAuditRecord.ACTION_APPROVE
+ || action == CollectAuditRecord.ACTION_REJECT
+ || action == CollectAuditRecord.ACTION_BATCH_APPROVE
+ || action == CollectAuditRecord.ACTION_BATCH_REJECT);
+ return conclusion ? newest : null;
+ }
+
+ // ══════════════════ 内部:装配与工具 ══════════════════
+
+ private CollectSubmission requireSubmission(Long id, Long tenantId) {
+ CollectSubmission sub = submissionMapper.selectById(id);
+ // ⚠️ selectById 走的是租户拦截器(自动带 tenant_id),跨租户查不到 ——
+ // 所以这里统一报"不存在",不透露"它存在但不属于你"
+ if (sub == null || !Objects.equals(sub.getTenantId(), tenantId)) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED, "填报单不存在或已删除");
+ }
+ return sub;
+ }
+
+ private CollectRoster rosterOf(CollectSubmission sub) {
+ return sub.getRosterId() == null ? null : rosterMapper.selectById(sub.getRosterId());
+ }
+
+ private String rosterName(CollectSubmission sub) {
+ CollectRoster r = rosterOf(sub);
+ return r == null ? null : r.getRealName();
+ }
+
+ private Map userNames(Set ids) {
+ Map out = new HashMap<>();
+ if (ids == null || ids.isEmpty()) {
+ return out;
+ }
+ for (SysUser u : userMapper.selectByIds(ids)) {
+ out.put(u.getId(), u.getRealName() == null ? u.getUsername() : u.getRealName());
+ }
+ return out;
+ }
+
+ /**
+ * 把现场算出来的红黄数写回快照。
+ *
+ * 为什么值得写:待办列表按快照分级,而快照是提交那一刻的。
+ * 写完这一次,列表上的数字就与现场一致了 —— 这叫"读取时顺手自愈",
+ * 比"列表每行都重算"便宜得多,也比"永远用旧值"准确得多。
+ */
+ private void writeBackCounts(CollectSubmission sub, int red, int yellow) {
+ if (Objects.equals(nz(sub.getRedCount()), red) && Objects.equals(nz(sub.getYellowCount()), yellow)) {
+ return; // 没变就不写:审核页刷新一次不该产生一次 update
+ }
+ submissionMapper.update(null, Wrappers.lambdaUpdate()
+ .eq(CollectSubmission::getId, sub.getId())
+ .set(CollectSubmission::getRedCount, red)
+ .set(CollectSubmission::getYellowCount, yellow));
+ }
+
+ private void markRoster(Long rosterId, int status) {
+ if (rosterId == null) {
+ return;
+ }
+ rosterMapper.update(null, Wrappers.lambdaUpdate()
+ .eq(CollectRoster::getId, rosterId)
+ .set(CollectRoster::getSubmitStatus, status));
+ }
+
+ private static int countSeverity(List issues, int severity) {
+ return (int) issues.stream().filter(i -> i.severity() == severity).count();
+ }
+
+ private static int levelOf(int red, int yellow) {
+ if (red > 0) {
+ return 3;
+ }
+ return yellow > 0 ? 2 : 0;
+ }
+
+ private static Double waitHours(LocalDateTime submitTime, LocalDateTime now) {
+ if (submitTime == null) {
+ return null;
+ }
+ long minutes = Duration.between(submitTime, now).toMinutes();
+ return Math.round(minutes / 6.0) / 10.0; // 保留 1 位小数
+ }
+
+ private static int durationSec(LocalDateTime submitTime) {
+ if (submitTime == null) {
+ return 0;
+ }
+ long s = Duration.between(submitTime, LocalDateTime.now()).getSeconds();
+ return s < 0 ? 0 : (int) Math.min(s, Integer.MAX_VALUE);
+ }
+
+ private int maxRejectCount(CollectTask task) {
+ Map cfg = parseObject(task == null ? null : task.getAuditConfig());
+ Integer v = asInt(cfg.get("maxRejectCount"));
+ return v == null || v <= 0 ? DEFAULT_MAX_REJECT_COUNT : v;
+ }
+
+ private void applyLastReject(AuditDtos.DetailVO vo, String json) {
+ Map m = parseObject(json);
+ vo.setLastRejectCode(str(m.get("reasonCode")));
+ vo.setLastRejectText(str(m.get("reasonText")));
+ }
+
+ private static List parseStringArray(String json) {
+ if (json == null || json.isBlank()) {
+ return List.of();
+ }
+ try {
+ return JSONUtil.parseArray(json).toList(String.class);
+ } catch (RuntimeException e) {
+ // 存量数据可能是别的形状(早期版本存过逗号分隔)—— 退化成 CSV 再试一次,
+ // 还不行就空数组。留痕字段坏掉不该让审核页打不开
+ return splitCsv(json);
+ }
+ }
+
+ private static List splitCsv(String csv) {
+ if (csv == null || csv.isBlank()) {
+ return List.of();
+ }
+ List out = new ArrayList<>();
+ for (String s : csv.split(",")) {
+ if (!s.isBlank()) {
+ out.add(s.trim());
+ }
+ }
+ return out;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map parseObject(String json) {
+ if (json == null || json.isBlank()) {
+ return new LinkedHashMap<>();
+ }
+ try {
+ Object o = JSONUtil.parse(json);
+ if (o instanceof Map, ?> m) {
+ Map out = new LinkedHashMap<>();
+ m.forEach((k, v) -> out.put(String.valueOf(k), v));
+ return out;
+ }
+ return new LinkedHashMap<>();
+ } catch (RuntimeException e) {
+ return new LinkedHashMap<>();
+ }
+ }
+
+ private static String clip(String s, int max) {
+ if (s == null) {
+ return null;
+ }
+ return s.length() <= max ? s : s.substring(0, max);
+ }
+
+ private static String str(Object v) {
+ return v == null ? null : String.valueOf(v);
+ }
+
+ private static boolean blank(String s) {
+ return s == null || s.isBlank();
+ }
+
+ private static int nz(Integer v) {
+ return v == null ? 0 : v;
+ }
+
+ private static long asLong(Object v) {
+ if (v instanceof Number n) {
+ return n.longValue();
+ }
+ if (v instanceof BigInteger b) {
+ return b.longValue();
+ }
+ return v == null ? 0L : Long.parseLong(String.valueOf(v));
+ }
+
+ private static Integer asInt(Object v) {
+ if (v instanceof Number n) {
+ return n.intValue();
+ }
+ if (v == null) {
+ return null;
+ }
+ try {
+ return Integer.valueOf(String.valueOf(v).trim());
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/RejectReasonCatalog.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/RejectReasonCatalog.java
new file mode 100644
index 0000000..7db156d
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/service/RejectReasonCatalog.java
@@ -0,0 +1,93 @@
+package cn.tianbaotong.fill.module.audit.service;
+
+import cn.tianbaotong.fill.common.api.ErrorCode;
+import cn.tianbaotong.fill.common.exception.BizException;
+import cn.tianbaotong.fill.module.audit.dto.AuditDtos;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 驳回事由字典(PRD 附录 C 的 10 条)。
+ *
+ * 为什么写在代码里而不是建一张字典表:和权限码同理(见 {@code PermissionDef})——
+ * 它是前后端的契约,而且驳回是"把学生的材料打回去重做"的动作,
+ * 理由文案一旦能被某个人在后台改掉,历史记录里那句"材料不是本人"就再也无法解释清楚了。
+ * 契约 §11.7 说"仅返回启用的、可按 taskId 定制",这两件事都还没有配置入口 ——
+ * 所以现在返回全部 10 条,不假装支持定制(假支持会让机构配了半天发现不生效)。
+ */
+public final class RejectReasonCatalog {
+
+ private RejectReasonCatalog() {
+ }
+
+ /** 「其他」的码:选它必须手填说明,否则学生收到一条"其他"等于没收到。 */
+ public static final String CODE_OTHER = "R999";
+
+ private static final Map TEXTS = Map.of(
+ "R001", "材料不清晰,请重新提供(建议找打印店重新扫描或索取原图)",
+ "R002", "身份证号与老师名单不一致",
+ "R003", "材料不是本人",
+ "R004", "文件超过 1M",
+ "R005", "缺少材料",
+ "R006", "工作证明未盖公章",
+ "R007", "材料不完整(缺正反面 / 缺页)",
+ "R008", "信息填写有误",
+ "R009", "照片背景不符合要求",
+ CODE_OTHER, "其他(手填)"
+ );
+
+ /** 展示顺序按 PRD 附录 C 的编号,不用 Map 的遍历顺序(那是不确定的)。 */
+ private static final List ORDER = List.of(
+ "R001", "R002", "R003", "R004", "R005", "R006", "R007", "R008", "R009", CODE_OTHER);
+
+ public static List list() {
+ List out = new ArrayList<>(ORDER.size());
+ for (String code : ORDER) {
+ AuditDtos.ReasonCodeVO vo = new AuditDtos.ReasonCodeVO();
+ vo.setCode(code);
+ vo.setText(TEXTS.get(code));
+ vo.setRequiresText(CODE_OTHER.equals(code));
+ out.add(vo);
+ }
+ return out;
+ }
+
+ public static String textOf(String code) {
+ return code == null ? null : TEXTS.get(code);
+ }
+
+ /**
+ * 校验原因码,并返回规范的文案。
+ *
+ * ⚠️ 认不出就拒绝,不静默放过:驳回是学生唯一能看到的"为什么被打回",
+ * 存进去一个字典里没有的码,界面上就只剩一个空白原因,
+ * 而学生只会以为系统坏了 —— 他会打电话给班主任,班主任也说不清。
+ */
+ public static String requireValid(String code, String reasonText) {
+ String c = code == null ? null : code.trim().toUpperCase();
+ if (c == null || c.isEmpty() || !TEXTS.containsKey(c)) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED,
+ "请选择驳回原因(原因码不在字典里:" + code + ")");
+ }
+ if (CODE_OTHER.equals(c) && (reasonText == null || reasonText.isBlank())) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED,
+ "选了「其他」就必须写清具体原因,否则学生不知道要改什么");
+ }
+ return c;
+ }
+
+ /** 存库的展示文案:字典文案 + (有补充说明时)学生的补充,两段都要留痕。 */
+ public static String fullText(String code, String reasonText, String voiceText) {
+ String base = textOf(code);
+ StringBuilder sb = new StringBuilder(base == null ? "" : base);
+ if (reasonText != null && !reasonText.isBlank()) {
+ sb.append(";补充:").append(reasonText.trim());
+ }
+ if (voiceText != null && !voiceText.isBlank()) {
+ sb.append(";语音转写:").append(voiceText.trim());
+ }
+ return sb.toString();
+ }
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/mapper/FileMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/mapper/FileMapper.java
index 5f891e0..f396ca8 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/mapper/FileMapper.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/mapper/FileMapper.java
@@ -54,4 +54,42 @@ public interface FileMapper extends BaseMapper {
java.util.List listMine(@Param("tenantId") Long tenantId,
@Param("submissionId") Long submissionId,
@Param("submitterId") Long submitterId);
+
+ /**
+ * 审核侧:一次取出一批填报单的当前生效文件(待办列表要显示材料缩略图)。
+ *
+ * 没有 {@code submitter_id} 条件 —— 审核看的正是别人的材料。
+ * 授权在上一层完成:只有已经通过"这个班归我审"的填报单 id 才会传进来
+ * (见 {@code AuditService.todos} 与 {@code requireAuditable})。
+ *
+ *
为什么要"一次一批"而不是每行查一次:待办一页 10~20 行,
+ * 每行一次查询就是典型的 N+1,而列表接口的耗时是最容易被用户感知的。
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @org.apache.ibatis.annotations.Select("""
+
+ """)
+ java.util.List listBySubmissions(@Param("tenantId") Long tenantId,
+ @Param("submissionIds") java.util.List submissionIds);
+
+ /**
+ * 审核侧:按 id 取一份文件(不带归属条件)。
+ *
+ * 同样由调用方保证授权(先确认这份材料所属的填报单在审核范围内),
+ * 见 {@code AuditService.fileUrl}。为什么不让审核端走 {@code selectMine}:
+ * 那个方法的名字与语义都是"我的文件",让审核复用它,
+ * 早晚会有人为了"让管理员也能看"而在里面加一个 or 条件 —— 那时学生端就漏了。
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @org.apache.ibatis.annotations.Select("""
+ SELECT * FROM collect_file
+ WHERE id = #{id} AND tenant_id = #{tenantId} AND deleted = 0
+ """)
+ CollectFile selectForAudit(@Param("id") Long id, @Param("tenantId") Long tenantId);
}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/MaterialFileService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/MaterialFileService.java
index 030ebd3..ca9e419 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/MaterialFileService.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/MaterialFileService.java
@@ -316,6 +316,24 @@ public class MaterialFileService {
if (f == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件不存在或不属于你");
}
+ return urlOf(f, original, expireSeconds, LOCAL_DOWNLOAD_PREFIX);
+ }
+
+ /** 本地存储时,下载/预览走哪个前缀。学生端与审核端各有一份(权限检查在各自的服务里)。 */
+ public static final String LOCAL_DOWNLOAD_PREFIX = "/api/v1/mp/files/";
+
+ /**
+ * 取链接的**实现**:调用方负责授权,本方法只管拼地址。
+ *
+ *
抽出来是为了让审核端也能取同一个文件的链接 —— 两边的差别只有两处:
+ * 谁有权看(各自校验)与本地存储时走哪个下载前缀
+ * (学生端 {@code /api/v1/mp/files/} 校验"这份属于你",
+ * 审核端 {@code /api/v1/audit/files/} 校验"这个班归你审")。
+ *
+ *
⚠️ 不要让审核端复用 {@link #url}:那个方法一开头就查 {@code submitter_id},
+ * 为了"让管理员也能看"而在里面加 or 条件,学生端就漏了。
+ */
+ public FileUrl urlOf(CollectFile f, boolean original, Long expireSeconds, String localDownloadPrefix) {
// 取哪一份:优先按参数,**取不到就退到另一份**。
// 「原始件默认不保留」(客户决定),所以 original=true 时很可能根本没有原始件;
// 这时退回到规范件并如实标注,比报"文件不存在"有用得多 ——
@@ -336,7 +354,7 @@ public class MaterialFileService {
// 本地只能由本服务流式提供,所以这里换成**按文件 id** 的下载接口
// (而不是让存储层返回一个 path 参数 —— 存储层不知道 fileId,
// 而按 id 取能复用"这份文件属于你"的归属校验)
- url = "/api/v1/mp/files/" + f.getId() + "/content?original=" + useRaw;
+ url = localDownloadPrefix + f.getId() + "/content?original=" + useRaw;
}
String name = useRaw
? f.getOriginalName()
@@ -361,6 +379,17 @@ public class MaterialFileService {
if (f == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件不存在或不属于你");
}
+ return contentOf(f, original);
+ }
+
+ /**
+ * 取内容的**实现**:调用方负责授权(同 {@link #urlOf})。
+ *
+ *
为什么要按 id 流式提供:本地存储(开发环境)没有公网签名地址,
+ * 而预览必须是浏览器能直接打开的 URL。生产用七牛时走的是签名链接,
+ * 这个接口不会被用到 —— 但开发时它是唯一能看图的路径。
+ */
+ public Content contentOf(CollectFile f, boolean original) {
boolean useRaw = original;
String path = original ? f.getRawPath() : f.getStdPath();
if (path == null || path.isBlank()) {
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/entity/CollectSubmission.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/entity/CollectSubmission.java
index d6b6459..0e182f3 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/entity/CollectSubmission.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/entity/CollectSubmission.java
@@ -105,8 +105,20 @@ public class CollectSubmission extends TenantEntity {
private Long classAuditBy;
+ /**
+ * 初审时间。
+ *
+ *
⚠️ 这一对(含 {@link #classAuditBy})是"本轮审核的状态",
+ * 不是历史:被驳回时会被清空(见 {@code AuditService.reject})。
+ * 历史请查 {@code collect_audit_record} —— 那里一条都不会少。
+ */
+ private LocalDateTime classAuditTime;
+
private Long adminAuditBy;
+ /** 终审时间。语义同 {@link #classAuditTime}。 */
+ private LocalDateTime adminAuditTime;
+
public static final int STATUS_DRAFT = 0;
public static final int STATUS_PENDING_CLASS = 1;
public static final int STATUS_PENDING_ADMIN = 2;
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/mapper/SubmissionMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/mapper/SubmissionMapper.java
index 1c86226..4b99359 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/mapper/SubmissionMapper.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/mapper/SubmissionMapper.java
@@ -42,4 +42,30 @@ public interface SubmissionMapper extends BaseMapper {
CollectSubmission selectMineById(@Param("id") Long id,
@Param("tenantId") Long tenantId,
@Param("submitterId") Long submitterId);
+
+ /**
+ * 审核侧取填报单:只带租户,不带归属 —— 归属由审核范围(班级)决定,不由提交人决定。
+ *
+ * 所以这里不能沿用 {@code selectMineById}:班主任要看的恰恰是别人的单子。
+ * 隔离靠两件事,缺一不可:
+ *
+ * - 这里的 {@code tenant_id}(跨租户绝不可能)
+ * - 调用方必须先通过"这个班归不归我审"的检查(见 {@code AuditService})
+ *
+ *
+ * ⚠️ {@code FOR UPDATE} 是刻意的:审核是并发动作 ——
+ * 两个端(手机 + 电脑)同时点通过、或者班主任和终审管理员同时处理,
+ * 没有行锁就会出现"两条审核记录 + 状态被后写的那次覆盖"。
+ * 那时时间轴上会有两条"通过",而库里只有一个状态,谁也说不清是怎么变的。
+ *
+ *
因此本方法的调用方必须在 {@code @Transactional} 里,锁才有意义。
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ @Select("""
+ SELECT * FROM collect_submission
+ WHERE id = #{id} AND tenant_id = #{tenantId} AND deleted = 0
+ FOR UPDATE
+ """)
+ CollectSubmission selectForAudit(@Param("id") Long id,
+ @Param("tenantId") Long tenantId);
}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/FillService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/FillService.java
index 71080dd..0605199 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/FillService.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/FillService.java
@@ -82,11 +82,33 @@ public class FillService {
"请先核对身份(填写姓名与身份证号)再开始填报");
}
- FormSchemaVO schema = formSchemaService.get(taskId);
+ return assemble(task, roster, submissionMapper.selectMine(me.tenantId(), taskId, me.uid()));
+ }
+
+ /**
+ * 装配表单(含值)—— 按指定的名册行与填报单,而不是按当前登录人。
+ *
+ *
学生端走 {@link #getForm}(先过门禁,再拿自己的草稿);审核端走这一个:
+ * 审核人要看的正是别人的那份,走学生入口会被门禁拦掉
+ * (报"请先核对身份"),而且拿到的会是审核人自己的草稿。
+ *
+ *
⚠️ 两条路径**必须共用同一段装配代码**。一旦分成两份,审核端看到的值
+ * 就会和学生看到的不一样,而那是"两条校验规则互相矛盾"的开端
+ * (见 {@code SubmissionValidator} 的类注释)。
+ *
+ *
这里不判任务状态:已截止甚至已归档的任务照样要能审(审的是历史数据)。
+ * 状态检查属于"能不能填",不属于"能不能看"。
+ */
+ public StudentFormVO assembleFor(CollectTask task, CollectRoster roster, CollectSubmission sub) {
+ return assemble(task, roster, sub);
+ }
+
+ private StudentFormVO assemble(CollectTask task, CollectRoster roster, CollectSubmission draft) {
+ FormSchemaVO schema = formSchemaService.get(task.getId());
Map values = rosterValuesOf(roster);
StudentFormVO vo = new StudentFormVO();
- vo.setTaskId(taskId);
+ vo.setTaskId(task.getId());
vo.setTaskName(task.getTaskName());
vo.setFormVersion(schema.getVersion());
vo.setSubmissionStatus(CollectSubmission.STATUS_DRAFT);
@@ -96,7 +118,6 @@ public class FillService {
// "从没填过"和"填了又清空"。所以用 changed_fields —— 它记录的正是
// "学生动过的预填字段"。在里面的字段以草稿为准(哪怕是空),
// 不在里面的字段以名册为准。这也是 changed_fields 的第二个用途。
- CollectSubmission draft = submissionMapper.selectMine(me.tenantId(), taskId, me.uid());
Set touched = new LinkedHashSet<>();
if (draft != null) {
vo.setSubmissionId(draft.getId());
@@ -164,7 +185,7 @@ public class FillService {
vo.getSections().add(ex);
}
- List materials = materialDefService.list(taskId);
+ List materials = materialDefService.list(task.getId());
vo.setMaterials(new ArrayList<>(materials));
StudentFormVO.SubmitPolicy policy = new StudentFormVO.SubmitPolicy();
@@ -190,6 +211,12 @@ public class FillService {
*/
public static Map rosterValuesOf(CollectRoster r) {
Map m = new LinkedHashMap<>();
+ if (r == null) {
+ // 审核端可能拿到没有名册行的填报单(一期不会有,社会收集才有)。
+ // 这里返回空映射而不是 NPE —— 让"没有名册"表现为"这些字段都空着",
+ // 由校验规则去说清"缺什么",而不是变成一个 500。
+ return m;
+ }
putIfPresent(m, "seq_no", r.getSeqNo());
putIfPresent(m, "real_name", r.getRealName());
// ⚠️ 只回脱敏值:表单上没有必要出现完整证件号(截图/录屏/分享都是泄露面)
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 01983e0..8117eef 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
@@ -55,6 +55,7 @@ public class SubmissionService {
private final TaskMapper taskMapper;
private final FormSchemaService formSchemaService;
private final MpRosterMapper rosterMapper;
+ private final cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper collectRosterMapper;
private final SubmissionValidator validator;
// ── §9.3 取草稿(幂等)────────────────────────
@@ -107,10 +108,43 @@ public class SubmissionService {
}
submissionMapper.insert(s);
+ // 名册那一行的状态跟着走到「草稿」:老师看名册时才知道"这个学生已经点进来了"
+ markRosterDraft(roster.getId());
log.info("建草稿:task={} roster={} user={} id={}", taskId, roster.getId(), me.uid(), s.getId());
return new DraftDtos.CreateVO(s.getId(), s.getFormVersion(), true);
}
+ /**
+ * 学生点进表单时:名册行的状态从「未提交」变成「草稿」。
+ *
+ * ⚠️ 只在当前是「未提交」时才写。学生被驳回后(4)再打开表单,
+ * 不该被退回成「草稿」—— 班主任看名册时要能看出"这份已经被驳回过",
+ * 否则他会以为这个学生从没交过。同理,已提交(2)之后也不用再改。
+ */
+ private void markRosterDraft(Long rosterId) {
+ if (rosterId == null) {
+ return;
+ }
+ collectRosterMapper.update(null, Wrappers.lambdaUpdate()
+ .eq(CollectRoster::getId, rosterId)
+ .eq(CollectRoster::getSubmitStatus, CollectRoster.SUBMIT_NONE)
+ .set(CollectRoster::getSubmitStatus, CollectRoster.SUBMIT_DRAFT));
+ }
+
+ /**
+ * 名册行的提交状态——它反映"这一行现在处在哪一步",所以是可以往回走的:
+ * 被驳回(4)之后学生改完重新交,它必须回到「待审」(2)。
+ * 不做这一步,名册列表会一直显示「已驳回」,而班主任据此挨个去催那些早就交了的。
+ */
+ void markRosterStatus(Long rosterId, int status) {
+ if (rosterId == null) {
+ return;
+ }
+ collectRosterMapper.update(null, Wrappers.lambdaUpdate()
+ .eq(CollectRoster::getId, rosterId)
+ .set(CollectRoster::getSubmitStatus, status));
+ }
+
// ── §9.4 保存草稿 ────────────────────────────
@Transactional(rollbackFor = Exception.class)
@@ -288,7 +322,12 @@ public class SubmissionService {
.eq(CollectSubmission::getId, id)
.set(CollectSubmission::getStatus, CollectSubmission.STATUS_PENDING_CLASS)
.set(CollectSubmission::getSubmitTime, java.time.LocalDateTime.now()));
- log.info("提交成功:submission={} 红={} 黄={} 已确认警告={}",
+ // 名册上那一行也要跟着走(0未提交/1草稿/2待审/3已通过/4已驳回)。
+ // ⚠️ 以前这里没写,于是名册列表的"状态"列永远是"未提交",
+ // 而 REPLACE 导入的护栏(bind_user_id IS NULL AND submit_status = 0)
+ // 实际只靠 bind_user_id 生效 —— 一旦哪天绑定被清掉,
+ // 已经提交过的行就会被当成"没动过"而遭整表替换。
+ markRosterStatus(s.getRosterId(), CollectRoster.SUBMIT_PENDING); log.info("提交成功:submission={} 红={} 黄={} 已确认警告={}",
id, red, yellow, confirmWarnings);
return new SubmitDtos.Report(true, CollectSubmission.STATUS_PENDING_CLASS,
SubmitDtos.Report.statusText(CollectSubmission.STATUS_PENDING_CLASS),
@@ -353,27 +392,40 @@ public class SubmissionService {
* 抽出来是因为读写各要一遍 switch,散在两个方法里迟早漏字段 ——
* 而漏掉的表现是"某一项填了没保存",且不报错。
*/
- static final class SubValues {
+ public static final class SubValues {
+
+ /**
+ * 有哪些字段在 {@code collect_submission} 里是可写列。
+ *
+ *
把它列成清单(而不是只看 switch 里有哪些 case)是为了让这个事实
+ * 只有一个来源:加字段时先看表有没有这一列,而不是看代码里有没有分支。
+ */
+ public static final java.util.List KEYS = java.util.List.of(
+ "occupation", "worktype", "job_level", "education", "is_student",
+ "school", "major", "enroll_date", "work_unit", "is_retake",
+ "retake_theory", "retake_practice", "retake_overall", "work_years",
+ "cert_occupation", "cert_level", "cert_no");
/**
* 这个字段在 {@code collect_submission} 里有没有对应的可写列。
*
* ⚠️ 姓名、身份证号、联系电话只存在于名册,提交表里没有这三列 ——
* 所以它们既不该被比较(会造成假的"改动留痕"),也不该被写入。
- * 判断"列存在"而不是"我实现了读写的分支",是为了让这个事实只有一个来源:
- * 加字段时先看表有没有这一列,而不是看代码里有没有 case。
*/
- static boolean hasColumn(String key) {
- return switch (key) {
- case "occupation", "worktype", "job_level", "education", "is_student",
- "school", "major", "enroll_date", "work_unit", "is_retake",
- "retake_theory", "retake_practice", "retake_overall", "work_years",
- "cert_occupation", "cert_level", "cert_no" -> true;
- default -> false;
- };
+ public static boolean hasColumn(String key) {
+ return KEYS.contains(key);
}
- static void apply(CollectSubmission s, String key, Object v) {
+ /** 把这一行的全部业务列读成一个 map(审核详情要展示学生填了什么)。 */
+ public static Map allOf(CollectSubmission s) {
+ Map out = new LinkedHashMap<>();
+ for (String key : KEYS) {
+ out.put(key, read(s, key));
+ }
+ return out;
+ }
+
+ public static void apply(CollectSubmission s, String key, Object v) {
String str = v == null ? null : String.valueOf(v);
switch (key) {
case "occupation" -> s.setOccupation(blankToNull(str));
@@ -400,7 +452,7 @@ public class SubmissionService {
}
}
- static Object read(CollectSubmission s, String key) {
+ public static Object read(CollectSubmission s, String key) {
return switch (key) {
case "occupation" -> s.getOccupation();
case "worktype" -> s.getWorktype();
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
index d98ca75..78b2d48 100644
--- 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
@@ -1,5 +1,7 @@
package cn.tianbaotong.fill.module.fill.service;
+import cn.tianbaotong.fill.common.api.ErrorCode;
+import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.file.entity.CollectFile;
import cn.tianbaotong.fill.module.file.mapper.FileMapper;
import cn.tianbaotong.fill.module.fill.dto.StudentFormVO;
@@ -89,8 +91,16 @@ public class SubmissionValidator {
public List validate(CollectSubmission s, CollectTask task, CollectRoster roster) {
List out = new ArrayList<>();
- // 唯一取值口径:学生看到的那一份装配结果(含条件可见性所需的一切)
- StudentFormVO form = fillService.getForm(s.getTaskId());
+ if (task == null) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除");
+ }
+
+ // 唯一取值口径:学生看到的那一份装配结果(含条件可见性所需的一切)。
+ //
+ // ⚠️ 这里用 assembleFor(按**指定的**填报单装配),而不是学生入口 getForm ——
+ // 因为审核时"当前登录人"是班主任/管理员,他们既过不了门禁,
+ // 也拿不到别人那份草稿。两条路径共用同一段装配代码,见 FillService.assembleFor。
+ StudentFormVO form = fillService.assembleFor(task, roster, s);
Map values = new LinkedHashMap<>();
for (StudentFormVO.Section sec : form.getSections()) {
for (FormFieldVO f : sec.getFields()) {
@@ -386,7 +396,7 @@ public class SubmissionValidator {
* 学生在页面上看得见某个字段、后端却说"你没填"(或反过来)。
* 这种问题极难排查 —— 因为两边各自看都"没错"。
*/
- static boolean visible(Map when, Map values) {
+ public static boolean visible(Map when, Map values) {
if (when == null) {
return true;
}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/entity/CollectRoster.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/entity/CollectRoster.java
index 4096657..e9d379d 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/entity/CollectRoster.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/entity/CollectRoster.java
@@ -76,6 +76,28 @@ public class CollectRoster extends TenantEntity {
/** {@code 0} 未提交 / {@code 1} 草稿 / {@code 2} 待审 / {@code 3} 已通过 / {@code 4} 已驳回。 */
private Integer submitStatus;
+ // ── submit_status 的取值(唯一来源)────────────────
+ //
+ // 这几个值以前只写在列注释里,于是"谁来改它"没有归属:
+ // 结果是从名册导入到审核通过,没有一处写过它 —— 名册列表的状态列恒为「未提交」。
+ // 现在写入集中在两处:学生侧(提交/存草稿)与审核侧(通过/驳回),
+ // 都用这里的常量,避免各处写 0/1/2/3/4 的字面量。
+
+ /** 学生还没开始填(也与"没有草稿"同义)。 */
+ public static final int SUBMIT_NONE = 0;
+
+ /** 有草稿但还没提交。 */
+ public static final int SUBMIT_DRAFT = 1;
+
+ /** 已提交,等待初审。 */
+ public static final int SUBMIT_PENDING = 2;
+
+ /** 终审通过。 */
+ public static final int SUBMIT_APPROVED = 3;
+
+ /** 被驳回,等学生改。 */
+ public static final int SUBMIT_REJECTED = 4;
+
// ── 预填列(老师已填,学生端只核对)─────────────
/** 报考职业(锁定,由老师确定)。 */
diff --git a/docs/交接摘要.md b/docs/交接摘要.md
index 97835fa..14885e7 100644
--- a/docs/交接摘要.md
+++ b/docs/交接摘要.md
@@ -2123,3 +2123,134 @@ t1 域名免租户码登录 / 与手填租户码矛盾时明确报错 / **跨租
回到 §一·二十二 的顺序:**第 4 步 = 审核闭环(后端)** ——
初审 / 终审 / 撤回 / 批量通过(只带无问题项)/ 驳回(逐条填原因)。
+
+---
+
+## §一·二十七 审核闭环(M5-01~05 后端)—— 「谁能审谁」比「怎么审」重要
+
+### 落地
+
+| 位置 | 作用 |
+|---|---|
+| `module/audit/entity/CollectAuditRecord` | 审核记录(只增不改。撤回是**再写一条**,不是删掉上一条) |
+| `module/audit/mapper/AuditScopeMapper` | **谁能审哪些班**(三级回退:指定初审人 → 副班主任 → 班级负责人) |
+| `module/audit/mapper/AuditTodoMapper` | 待办列表(初审/终审共用一套查询 + 四个计数) |
+| `module/audit/mapper/AuditPreconditionMapper` | 「已导出打包」判定(M6 落地后应搬到导出模块) |
+| `module/audit/service/AuditService` | 待办 / 详情 / 通过 / 驳回 / 批量通过 / 撤回 / 时间轴 / 审核端文件访问 |
+| `module/audit/service/RejectReasonCatalog` | 附录 C 的 10 条原因码(**写在代码里**,理由同权限码) |
+| `controller/AuditController` | PC:`/api/v1/audit/*`(§11) |
+| `controller/MpTeacherAuditController` | 小程序:`/api/v1/mp/teacher/audit/*`(§10.2~10.5,只做初审) |
+
+待办的 MP 端与 PC 端**共用一份 VO**(契约里那两份重合九成)——
+两份迟早分叉,而"同一个学生在手机上是红、在电脑上是绿"最伤信任。
+
+### ★ 三件必须守住的事
+
+**① 权限 ≠ 范围。** 全校几十个班主任都有 `audit:first`,但只有**这个班的**班主任能审这个学生。
+所以初审查询强制带 org 范围,且**空列表 = 看不见任何单子**(SQL 里显式写 `1 = 0`,
+而不是"不过滤")——那是这类查询最经典也最严重的写法错误:一个刚被移出所有班级的老师
+会突然看到全校的单子。E2E 专门造了一个"同样是班主任但没被指定"的 T2:
+他看不到待办、打不开详情、点不动通过(三条断言分别守着)。
+
+**② 有红问题不许通过(`40032`),而且校验是「现场重算」的。**
+提交时落库的 `red_count` 是**快照**:OCR / 识别是异步的,名册和材料规则也可能被改。
+审核这一刻必须重算——"放过一份有问题的材料"不可撤销。
+E2E 的做法是提交成功后把必填项改坏,再点通过:**若读快照就会通过**。
+
+**③ 批量通过只带绿色项,红黄一律排除。** 契约里的 `onlyClean` 服务端**强制 true**
+(传 false 不报错也不生效——报错会让旧客户端不可用,而这件事没有"客户端说了算"的版本)。
+跳过的每一条都要**说清原因**(红/黄/不在我的班/状态不对),否则用户只会反复点那个大按钮。
+
+### 红黄计数的口径(列表用快照,动作时重算并回写)
+
+- **列表**用 `collect_submission.red_count/yellow_count`(要快,它本来就是概览)
+- **详情**与**通过 / 批量通过**现场重算,**并回写快照** → 快照自己会收敛,最多错一次
+
+⚠️ 因此 `detail()` **不能标 `readOnly = true`**:它要写那条回写。
+标了之后 PG 直接拒绝 UPDATE(`cannot execute update in a read-only transaction`),
+而这个 bug 的表现极其阴险:**计数没变时一切正常,一出现不一致就 500** ——
+也就是它只在最需要工作的那一次崩掉。(已踩并修,注释写在方法上。)
+
+### ⚠️ 两处「文档与代码不一致」,需要文档侧对齐
+
+| # | 冲突 | 我的处理 |
+|---|---|---|
+| 1 | **撤回的动作码**:DDL 注释写 `3=批量通过`,而接口设计 §11.4 写"写入一条 `action = 3`(撤回)的记录" | 1~6 沿用 DDL(已发布、可能已有数据),**撤回另取 7**。理由是时间轴最不能混淆:把"撤回"显示成"批量通过",会让人以为这份单子被通过了 |
+| 2 | **撤回的适用状态**:§11.4 的表格只列了"状态 3(已通过)" | 额外允许**状态 2** —— 最常见的真实场景是**班主任点错了通过**(那一步之后状态正是 2,不是 3)。状态机自洽:撤初审记录 → 回到 1;撤终审记录 → 回到 2。**这条要请客户确认**(若不允许,去掉一个分支即可) |
+| 3 | 材料文件 `source` 的语义:DDL 写 `2 班主任代传 / 3 管理员代传 / 4 整理器导入`,§10.6 写"文件记录 `source = 4`(代传)" | 以 DDL 为准(更细,且已建表)。代传接口(M5-06)还没做,时间轴已按 `source ∈ {2,3}` 预留 |
+
+### 顺带修掉的两个「没人写过」的窟窿
+
+1. **名册行的 `submit_status` 以前从来没人写**(全仓库只有读,没有写)→
+ 名册列表的「状态」列恒显示"未提交";而 REPLACE 导入的护栏
+ (`bind_user_id IS NULL AND submit_status = 0`)实际上只靠 `bind_user_id` 在生效 ——
+ 一旦绑定被清掉,已经提交过的行会被当成"没动过"而遭整表替换。
+ 现在写入点有两个:学生侧(草稿 / 提交)与审核侧(初审通过 / 终审通过 / 驳回 / 撤回)。
+ ⚠️ 它**不是单调的**:被驳回(4)后学生重交必须回到 2,否则班主任会一直以为他还欠一份。
+2. **`collect_submission` 少了两个实体字段**(`class_audit_time` / `admin_audit_time`)——
+ DDL 里一直有这两列,实体只映射了 `*_by`。审核要写时间才补上。
+ 这两列的口径是「**本轮**审核的状态」(驳回时清空,历史在 `collect_audit_record` 里一条不少)。
+
+### 审核端看材料:单独一条通道,不复用学生那个
+
+`MaterialFileService` 抽出 `urlOf / contentOf`(调用方负责授权),学生端仍走
+`/api/v1/mp/files/*`(查"这份是不是你传的"),审核端走 `/api/v1/audit/files/*`
+(查"这个班归不归你审")。**不要**为了"让管理员也能看"而在学生那个方法里加 or 条件 ——
+那一刻学生端就漏了。
+
+### 一个把「取值口径」讲清楚了的坑(E2E 抓到的)
+
+`SubmissionValidator` 原本通过 `FillService.getForm(taskId)` 取表单值 ——
+那是**学生视角**:它先过门禁、再拿"当前登录人"的草稿。审核人当然过不了门禁(`40007`),
+而且拿到的会是审核人自己那份草稿。已抽出 `FillService.assembleFor(task, roster, sub)`,
+两条路径**共用同一段装配代码**(分成两份就是"两条校验规则互相矛盾"的开端)。
+
+紧接着暴露出第二层:**改提交单里的列不一定改变"组装出来的值"** ——
+学生**没动过**的字段,值会**回退到名册**。E2E 里想用"清空在读学校"造红问题,
+对甲有效(甲改过该字段 → 在 `changed_fields` 里 → 以草稿为准,空也算),
+对乙**完全无效**(乙没改过 → 值仍来自名册)。
+最后改用名册里没有的字段(本职业工作年限填 999 → VAL-17)才稳定造出红问题。
+👉 造红问题的正确姿势:**挑一个名册里没有的字段**。
+
+### E2E:`scripts/e2e-m5-audit.py` → **87/87**
+
+夹具**刻意做出一个"同样是班主任但没被指定"的人**,并复用了既有的两个脚本工具
+(`import e2e-m3-submit` 拿学生侧的造数能力、`import e2e-m2-roster` 拿服务端工具)——
+不复制第二份 `mint`/`upload`,否则两边迟早分叉。
+
+```
+[1] 两个学生提交:甲干净 / 乙有黄色(同一张图传两份 → VAL-09)
+[2] 待办:T1 看到 2 份 + 四个计数 + batchApprovable;**T2 一份都看不到**;T1 查终审 → 403
+[3] 详情:脱敏证件号 / 材料按定义分组 / 预览链接 / 改动明细 / 两份策略 / 时间轴倒序;**T2 打不开**
+[4] 红问题:改坏必填项后通过 → **40032**;详情现场值 red=1 且**回写**了快照
+[5] 初审通过 → 2 + 留痕 + 名册状态;**重复点通过 → replayed=true 且不重复写记录**
+[6] 终审通过 → 3 + 名册 3
+[7] 批量通过:黄的单跳过(说清原因)→ 清掉重复件后过 → 红的单跳过
+[8] 驳回:原因必填 / 字典校验 / R999 必填说明;第 3 次 → escalated;**学生重交 → 状态与名册都回到待审**
+[9] 撤回:**已导出 40033 / 已归档 40034**;非原审核人非管理员 40301;成功回退 + action=7 留痕;同一条不能撤两次
+[10] 字典 10 条 + 批量上限 40011 + 学生调审核接口 40301
+```
+
+**全量 753 条断言、0 失败**(新增 87)。
+
+### 另一个夹具教训
+
+E2E 一开始给材料传了张 900×1200 的小图 —— 而**身份证材料项的目录默认长边下限是 1400**
+(`MaterialCatalog` 的档位默认,接口传 `minLongSide` 也压不过它),
+于是每份单子都被判「清晰度偏低」= **黄色警告**,接着"绿色单/批量通过"的断言集体失败。
+**失败的样子像功能坏了,其实只是夹具不满足业务前提。** 换成 1600×1400 的图即可。
+
+### 这一步没做的(都在计划内)
+
+- M5-06 代传材料(`source=2/3` 的时间轴口径已预留)
+- M5-09 审核统计(`/audit/statistics`:人均时长、驳回原因 TOP)
+- M5-12 通知(订阅消息 / 站内信)→ 因此驳回响应**如实**回
+ `notifySent=false + needManualNotice=true`,并在日志里标 escalated。
+ **不能为了让契约好看而写 true** —— 那会让"学生到底收到没有"变成没人能回答的问题
+- FR-806 批量驳回(P1)、FR-808 转交(P1)
+
+### 下一步
+
+**M5 的前端**:班主任端卡片流(原型 ②,**只右滑通过**、左滑不绑定驳回)、
+学生详情页(原型 ③)、驳回页(原型 ⑤,原因快选 + 二次确认)、
+一键通过确认页(原型 ④)、管理端终审列表与详情(原型 ⑥)。
diff --git a/scripts/e2e-m5-audit.py b/scripts/e2e-m5-audit.py
new file mode 100644
index 0000000..6529c1c
--- /dev/null
+++ b/scripts/e2e-m5-audit.py
@@ -0,0 +1,639 @@
+# -*- coding: utf-8 -*-
+"""
+审核闭环 E2E(M5-01 ~ M5-05):待办列表 / 详情 / 通过 / 驳回 / 批量通过 / 撤回。
+
+**这个脚本要守住的三件事**(按重要性):
+
+ ① **谁都不能审别人班的学生**。全校几十个班主任都有 `audit:first`,
+ 但只有这个班的班主任能审这个学生 —— "权限"与"范围"是两回事,
+ 只查权限等于把全校学生的身份证照片交给每一个班主任。
+ 所以这里专门造一个**没被指定**的班主任 T2:他既看不到待办、也点不动通过。
+
+ ② **有红问题不许通过**(40032),而且必须**现场重算**、不是拿提交时的快照。
+ 做法:提交成功后用 SQL 把「在读学校」清空,造出一个 VAL-16 红问题
+ ——如果服务端读的是快照(red_count=0),这次通过就会成功,而这正是最危险的 bug。
+
+ ③ **批量通过只带绿色项**:红黄一律排除,且**黄的单也要被跳过并说明原因**
+ (原型上"一键通过"是个大按钮,最容易被手快全选)。
+
+跑法:python scripts/e2e-m5-audit.py
+前提:后端 8080 在跑、xt-pg 在跑。脚本自己造任务/老师/学生,跑完自己清理。
+"""
+from __future__ import annotations
+
+import argparse
+import importlib.util
+import json
+import os
+import subprocess
+import sys
+import uuid
+
+sys.stdout.reconfigure(encoding="utf-8")
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+PG_CONTAINER = os.environ.get("XT_PG_CONTAINER", "xt-pg")
+
+PASSED: list[str] = []
+FAILED: list[tuple[str, str]] = []
+
+# 两个"学生"(MP 端只认 token 里的 sub,不要求 sys_user 里真有这个人 —— 与 e2e-m3-* 同口径)
+UID_A = 992201
+UID_B = 992202
+
+
+def _load(module_name: str, filename: str):
+ """
+ 直接 import 既有的 E2E 脚本,复用它们的工具与夹具。
+
+ 自己再写一份 `mint`/`upload`/`form_values` 就等着两边分叉:
+ 一边改了签名一边没改,表现是"这个脚本单独跑得过、一起跑就红"。
+ """
+ path = os.path.join(ROOT, "scripts", filename)
+ spec = importlib.util.spec_from_file_location(module_name, path)
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod
+
+
+S = _load("e2e_m3_submit", "e2e-m3-submit.py") # 学生侧:造草稿 / 传材料 / 提交
+R = _load("e2e_m2_roster", "e2e-m2-roster.py") # 服务端工具:登录 / 导名册 / 校验位
+
+CREATED_TASK_IDS: list[int] = []
+CREATED_USER_IDS: list[int] = []
+TMP_FILES: list[str] = []
+
+
+def ok(name: str) -> None:
+ PASSED.append(name)
+ print(f" [OK] {name}")
+
+
+def fail(name: str, detail: str) -> None:
+ FAILED.append((name, detail))
+ print(f" [FAIL] {name}\n {detail}")
+
+
+def check(name: str, cond: bool, detail: str = "") -> bool:
+ if cond:
+ ok(name)
+ else:
+ fail(name, detail or "断言不成立")
+ return cond
+
+
+def sql(stmt: str) -> str:
+ r = subprocess.run(
+ ["docker", "exec", "-e", "PGPASSWORD=fill_dev_pwd", PG_CONTAINER,
+ "psql", "-U", "fill", "-d", "filldb", "-t", "-A", "-c", stmt],
+ capture_output=True, text=True, timeout=40)
+ return (r.stdout or r.stderr).strip()
+
+
+def call(base: str, method: str, path: str, token: str | None = None,
+ body: dict | None = None) -> dict:
+ """PC 侧调用:R.call 返回 (status, body),这里只要 body。"""
+ _, b = R.call(method, f"{base}{path}", token, body)
+ return b
+
+
+def build_roster(path: str, rows: list[tuple[str, str]]) -> None:
+ """两行名册(姓名 + 身份证号),报考三项按任务声明填好。"""
+ from openpyxl import Workbook
+ wb = Workbook()
+ ws = wb.active
+ ws.title = "24中技数控1班"
+ header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位",
+ "报考职业", "报考工种", "报考等级", "是否补考",
+ "补考理论", "补考实操", "补考综合", "是否在校生",
+ "在读学校", "所学专业", "入学时间"]
+ for c, t in enumerate(header, start=1):
+ ws.cell(row=1, column=c, value=t)
+ for i, (name, idc) in enumerate(rows):
+ vals = [i + 1, name, idc, "中专", f"138001388{i}", "无",
+ "车工", "数控车工", "四级", "否", "", "", "", "是",
+ "示范职业学校", "数控加工", "2024-09-01"]
+ for c, v in enumerate(vals, start=1):
+ if v != "":
+ ws.cell(row=i + 2, column=c, value=v)
+ wb.save(path)
+
+
+def big_jpeg() -> bytes:
+ """
+ 一张**长边够长**的测试图。
+
+ ⚠️ 不能用 e2e-m3-submit 里那张 900×1200 的小图:身份证材料项的目录默认
+ 长边下限是 **1400**(`MaterialCatalog` 的档位默认,接口传 minLongSide 也压不过它),
+ 低于它会被判「清晰度偏低」——那是**黄色警告**,
+ 于是"干净单"这个前提不成立,批量通过的断言会集体失败,
+ 而失败的样子像功能坏了。夹具不满足业务前提,是这类误判最常见的来源。
+ """
+ from PIL import Image
+ import io
+ buf = io.BytesIO()
+ Image.new("RGB", (1600, 1400), (200, 210, 220)).save(buf, "JPEG", quality=90)
+ return buf.getvalue()
+
+
+def prepare_student(tok: str, sid: int, task_id: int, fill: dict, dup_first_file: bool) -> dict:
+ """
+ 把一个学生推到"已提交"。
+
+ - 填表(只填可改字段;锁定字段的值来自名册)
+ - 按**表单里当前必传且条件成立**的材料逐项上传(不写死材料码)
+ - dup_first_file:第一项材料再传一份同一张图 → 制造一条可预期的黄色警告(VAL-09)
+ """
+ S.call("PUT", f"/api/v1/mp/submissions/{sid}", tok, {"formData": fill})
+ img = big_jpeg()
+ values, mats = S.form_values(tok, task_id)
+ need = [m for m in mats if m.get("required") and S.visible(m.get("visibleWhen"), values)]
+ for i, m in enumerate(need):
+ code = m.get("materialCode")
+ up = S.upload(tok, sid, code, f"{m.get('materialName') or code}.jpg", img)
+ if up.get("code") != 0:
+ return {"_upload_error": up}
+ S.call("POST", f"/api/v1/mp/files/{up['data']['id']}/process", tok)
+ if dup_first_file and i == 0:
+ up2 = S.upload(tok, sid, code, f"{m.get('materialName') or code}-重复.jpg", img)
+ if up2.get("code") == 0:
+ S.call("POST", f"/api/v1/mp/files/{up2['data']['id']}/process", tok)
+ return S.sub(tok, sid, True)
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description="审核闭环 E2E")
+ ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080"))
+ ap.add_argument("--tenant", default=os.environ.get("XT_TENANT", "demo"))
+ ap.add_argument("--password", default="Admin@123")
+ args = ap.parse_args()
+ base = args.base.rstrip("/")
+ suffix = uuid.uuid4().hex[:6]
+
+ print("=" * 76)
+ print(f"审核闭环 E2E(M5-01~05) base={base}")
+ print("=" * 76)
+
+ # ── 0. 夹具 ────────────────────────────────────────
+ print("\n[0] 准备:任务 + 名册 + 两个班主任(一个被指定、一个没被指定)")
+ _, b = R.login(base, "admin", args.password, args.tenant)
+ if b.get("code") != 0:
+ fail("管理员登录", f"{b}")
+ return 1
+ admin = b["data"]["token"]
+ ok("管理员登录")
+
+ _, tree = R.call("GET", f"{base}/api/v1/orgs/tree", admin)
+ classes = []
+
+ def walk(nodes):
+ for n in nodes or []:
+ if n.get("orgType") == 3:
+ classes.append(n)
+ walk(n.get("children"))
+
+ walk((tree.get("data") or []))
+ if not classes:
+ fail("取一个班级", "demo 租户里没有 orgType=3 的组织")
+ return 1
+ org_id, org_name = classes[0]["id"], classes[0]["orgName"]
+ ok(f"取到班级:{org_name}(id={org_id})")
+
+ teachers = {}
+ for i, tag in enumerate(("t1", "t2")):
+ uname = f"e2e{tag}{suffix}"
+ body = call(base, "POST", "/api/v1/users", admin, {
+ "username": uname, "realName": f"E2E{suffix}{tag}",
+ # 手机号要过唯一约束:用 suffix 算出来,别用 hash()(它是进程随机的)
+ "phone": "13" + str(int(suffix, 16) % 10 ** 8 + i).zfill(9),
+ "roleCodes": ["HEAD_TEACHER"], "userType": 3,
+ })
+ if body.get("code") != 0:
+ fail(f"建班主任 {tag}", f"{body}")
+ return 1
+ uid = body["data"]["id"]
+ pwd = body["data"]["initialPassword"]
+ CREATED_USER_IDS.append(uid)
+ _, lb = R.login(base, uname, pwd, args.tenant)
+ if lb.get("code") != 0:
+ fail(f"班主任 {tag} 登录", f"{lb}")
+ return 1
+ teachers[tag] = {"id": uid, "token": lb["data"]["token"], "name": f"E2E{suffix}{tag}"}
+ ok(f"两个班主任账号已建(T1 id={teachers['t1']['id']} 被指定为初审人,T2 id={teachers['t2']['id']} 不指定)")
+
+ body = call(base, "POST", "/api/v1/tasks", admin, {
+ "taskName": f"E2E审核 {suffix}", "collectMode": 1, "batchNo": "2007",
+ "majorName": "数控加工", "jobName": "车工", "certType": 1,
+ "startTime": "2026-09-28", "endTime": "2026-12-31",
+ })
+ if body.get("code") != 0:
+ fail("建任务", f"{body}")
+ return 1
+ task_id = body["data"]["id"]
+ CREATED_TASK_IDS.append(task_id)
+
+ fix = os.path.join(ROOT, "scripts", f"_tmp_audit_{suffix}.xlsx")
+ TMP_FILES.append(fix)
+ rows = [("审核甲", R.with_checksum("361029" + "20080101" + "011")),
+ ("审核乙", R.with_checksum("361029" + "20080202" + "022"))]
+ build_roster(fix, rows)
+ # ⚠️ 必须显式带 orgId:名册导入的 orgId 是**整批归到哪个班**(为空则名册行不留 org_id)。
+ # 留空的后果不是"少个字段",而是提交单没有班级 → 审核范围判不出来(40001/40301)。
+ body = R.upload_roster(base, admin, task_id, fix,
+ {"dryRun": "false", "orgId": str(org_id)})
+ check("名册导入 2 人", (body.get("code") == 0 and (body.get("data") or {}).get("created") == 2),
+ f"{body}")
+ check("名册行带上了班级(审核范围要靠它)",
+ sql(f"SELECT count(*) FROM collect_roster WHERE task_id={task_id} "
+ f"AND org_id={org_id} AND deleted=0") == "2",
+ sql(f"SELECT org_id FROM collect_roster WHERE task_id={task_id} AND deleted=0"))
+
+ body = call(base, "PUT", f"/api/v1/tasks/{task_id}/orgs", admin, {
+ "orgs": [{"orgId": org_id, "headUserId": teachers["t1"]["id"], "expectedCount": 2}],
+ })
+ check("覆盖班级 + 指定初审人 = T1", body.get("code") == 0, f"{body}")
+ body = call(base, "POST", f"/api/v1/tasks/{task_id}/materials", admin, {
+ "materialCode": "IDCARD", "materialName": "身份证", "required": True,
+ "fileTypes": ["jpg", "jpeg", "png"], "maxSizeKb": 1024, "maxCount": 2,
+ # ⚠️ 把下限调到 1KB / 长边 100:E2E 用的小图(17KB、900×1200)会被
+ # 材料目录的默认下限判成「体积偏小 / 清晰度偏低」——那是**黄色警告**,
+ # 于是"绿色单"这个前提不成立,批量通过的断言全部跟着错。
+ # 这坑很值得记:断言失败的样子像功能坏了,其实只是夹具不满足前提。
+ "minSizeKb": 1, "minLongSide": 100,
+ })
+ check("配必传材料(身份证;下限调到 1KB 免得小图被判「体积偏小」)",
+ body.get("code") == 0, f"{body}")
+ body = call(base, "POST", f"/api/v1/tasks/{task_id}/publish", admin, {"force": True})
+ check("发布任务", body.get("code") == 0, f"{body}")
+
+ ids = sql(f"SELECT id, real_name FROM collect_roster WHERE task_id={task_id} AND deleted=0 "
+ f"ORDER BY id")
+ pairs = [ln.split("|") for ln in ids.splitlines() if ln]
+ if len(pairs) != 2:
+ fail("取名册行", ids)
+ return 1
+ rid_a, rid_b = int(pairs[0][0]), int(pairs[1][0])
+
+ cfg = S.read_cfg()
+ tok_a = S.mint(cfg["jwt"], cfg["issuer"], UID_A, 1)
+ tok_b = S.mint(cfg["jwt"], cfg["issuer"], UID_B, 1)
+
+ try:
+ # ── 1. 两个学生提交 ────────────────────────────
+ print("\n[1] 学生提交:甲(干净)/ 乙(有一处黄色警告)")
+ sql(f"UPDATE collect_roster SET bind_user_id={UID_A}, bind_type=1 WHERE id={rid_a}")
+ r = S.call("POST", "/api/v1/mp/submissions", tok_a, {"taskId": task_id})
+ sid_a = r["data"]["id"]
+ # 刻意改一个老师填过的字段(在读学校)→ 审核端要能看出"这不是老师填的那份"
+ rep_a = prepare_student(tok_a, sid_a, task_id, {
+ "work_years": "3", "school": "我校(学生改过)",
+ }, dup_first_file=False)
+ check("甲:提交成功", (rep_a.get("data") or {}).get("submitted") is True, f"{rep_a}")
+
+ sql(f"UPDATE collect_roster SET bind_user_id={UID_B}, bind_type=1 WHERE id={rid_b}")
+ r = S.call("POST", "/api/v1/mp/submissions", tok_b, {"taskId": task_id})
+ sid_b = r["data"]["id"]
+ rep_b = prepare_student(tok_b, sid_b, task_id, {"work_years": "5"}, dup_first_file=True)
+ check("乙:带着黄色警告提交成功(学生确认过)",
+ (rep_b.get("data") or {}).get("submitted") is True, f"{rep_b}")
+ check("乙:确实是「有黄无红」——这才轮得到批量通过的排除逻辑",
+ ((rep_b.get("data") or {}).get("yellowCount") or 0) > 0
+ and ((rep_b.get("data") or {}).get("redCount") or 0) == 0, f"{rep_b.get('data')}")
+
+ st = sql(f"SELECT status || '|' || red_count || '|' || yellow_count || '|' || "
+ f"submit_status FROM collect_submission s "
+ f"JOIN collect_roster r ON r.id = s.roster_id "
+ f"WHERE s.id={sid_a} AND s.deleted=0").split("|")
+ check("甲:状态 1(待初审)、红黄都是 0", st[0] == "1" and st[1] == "0" and st[2] == "0", f"{st}")
+ check("**名册行的提交状态跟着走到 2(待审)**——以前没人写过它,名册列表恒显示「未提交」",
+ st[3] == "2", f"submit_status={st[3]}")
+
+ # ── 2. 待办列表与范围隔离 ──────────────────────
+ print("\n[2] 待办列表:权限 ≠ 范围")
+ t1, t2 = teachers["t1"]["token"], teachers["t2"]["token"]
+ b1 = call(base, "GET", f"/api/v1/audit/todos?node=1&taskId={task_id}", t1)
+ d1 = b1.get("data") or {}
+ check("T1(被指定的初审人)看得到 2 份", d1.get("total") == 2, f"{b1}")
+ check("counts.all = 2", (d1.get("counts") or {}).get("all") == 2, f"{d1.get('counts')}")
+ check("counts.clean = 1(只有甲是绿色)",
+ (d1.get("counts") or {}).get("clean") == 1, f"{d1.get('counts')}")
+ check("counts.abnormal = 1(乙有黄色)",
+ (d1.get("counts") or {}).get("abnormal") == 1, f"{d1.get('counts')}")
+ rows1 = {x.get("submissionId"): x for x in (d1.get("list") or [])}
+ ca, cb = rows1.get(sid_a) or {}, rows1.get(sid_b) or {}
+ check("甲 batchApprovable=true(服务端判定,前端不许自己算)",
+ ca.get("batchApprovable") is True, f"{ca}")
+ check("乙 batchApprovable=false", cb.get("batchApprovable") is False, f"{cb}")
+ check("卡片带姓名/班级/材料数/缩略图",
+ ca.get("realName") and ca.get("orgName") and (ca.get("materialCount") or 0) >= 1
+ and len(ca.get("materialThumbs") or []) >= 1, f"{ca}")
+ check("甲带「学生改过」的字段(changedFields 非空)",
+ "school" in (ca.get("changedFields") or []), f"{ca.get('changedFields')}")
+ check("等待小时数是算出来的(0.x 小时)",
+ isinstance(ca.get("waitHours"), (int, float)) and ca.get("waitHours") < 1, f"{ca.get('waitHours')}")
+
+ b2 = call(base, "GET", f"/api/v1/audit/todos?node=1&taskId={task_id}", t2)
+ check("**T2(同样是班主任、但没被指定)→ 一份都看不到**",
+ (b2.get("data") or {}).get("total") == 0, f"{b2}")
+
+ b3 = call(base, "GET", f"/api/v1/audit/todos?node=2&taskId={task_id}", t1)
+ check("T1 没有终审权限 → 403(不是空列表)", b3.get("code") == 40301, f"{b3}")
+
+ # ── 3. 详情 ────────────────────────────────────
+ print("\n[3] 审核详情:材料预览 + 改动明细 + 时间轴 + 能不能通过")
+ dt = call(base, "GET", f"/api/v1/audit/submissions/{sid_a}", t1)
+ d = dt.get("data") or {}
+ check("T1 打得开", dt.get("code") == 0, f"{dt}")
+ dt2 = call(base, "GET", f"/api/v1/audit/submissions/{sid_a}", t2)
+ check("**T2 打不开同一份(403)**", dt2.get("code") == 40301, f"{dt2}")
+ check("姓名与脱敏证件号(不给完整号)",
+ d.get("realName") == "审核甲" and "****" in (d.get("idCardMask") or ""), f"{d.get('realName')}/{d.get('idCardMask')}")
+ mats = d.get("materials") or []
+ idcard = next((m for m in mats if m.get("materialCode") == "IDCARD"), None)
+ check("材料按定义分组(缺的那项也会出现,不是只看传了什么)",
+ idcard is not None and idcard.get("done") is True, f"{mats}")
+ files = (idcard or {}).get("files") or []
+ check("文件带**预览链接**(七牛签名 URL 或本地走审核端下载通道,都行)",
+ bool(files) and ("token=" in (files[0].get("previewUrl") or "")
+ or "/api/v1/audit/files/" in (files[0].get("previewUrl") or "")),
+ f"{files}")
+ cd = d.get("changedDetail") or []
+ check("改动明细说得出「老师填的 vs 学生改的」",
+ len(cd) == 1 and cd[0].get("field") == "school"
+ and cd[0].get("rosterValue") == "示范职业学校"
+ and cd[0].get("studentValue") == "我校(学生改过)", f"{cd}")
+ check("approvePolicy.canApprove = true(无红)",
+ (d.get("approvePolicy") or {}).get("canApprove") is True, f"{d.get('approvePolicy')}")
+ check("approvePolicy.canBatch = true(无红无黄)",
+ (d.get("approvePolicy") or {}).get("canBatch") is True, f"{d.get('approvePolicy')}")
+ check("revokePolicy:还没有可撤回的结论",
+ (d.get("revokePolicy") or {}).get("canRevoke") is False, f"{d.get('revokePolicy')}")
+ tl = d.get("timeline") or []
+ check("时间轴是**倒序**:最近的在最前,最早的一格是「学生提交」",
+ tl and tl[-1].get("node") == 0 and tl[-1].get("nodeText") == "学生提交", f"{tl}")
+
+ db = call(base, "GET", f"/api/v1/audit/submissions/{sid_b}", t1)
+ check("乙的 canBatch = false(有黄色)",
+ ((db.get("data") or {}).get("approvePolicy") or {}).get("canBatch") is False,
+ f"{(db.get('data') or {}).get('approvePolicy')}")
+
+ # ── 4. 红问题不许通过(现场重算)───────────────
+ print("\n[4] 有红问题不许通过 —— 而且必须**现场重算**,不是提交时的快照")
+ sql(f"UPDATE collect_submission SET school=NULL WHERE id={sid_a}") # VAL-16:在校生缺在读学校
+ ra = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/approve", t1,
+ {"actionSource": "BUTTON"})
+ check("**清空必填项后通过 → 40032**(若读快照会误判为可过)",
+ ra.get("code") == 40032, f"{ra}")
+ check("提示里说了是哪一类问题", "在读学校" in (ra.get("msg") or ""), f"{ra.get('msg')}")
+ d4b = call(base, "GET", f"/api/v1/audit/submissions/{sid_a}", t1)
+ d4 = d4b.get("data") or {}
+ check("详情里的红计数也变成 1(现场值与快照已对齐)",
+ d4.get("redCount") == 1, f"{d4b}")
+ check("canApprove 变成 false,并给出原因",
+ (d4.get("approvePolicy") or {}).get("canApprove") is False
+ and "红色" in ((d4.get("approvePolicy") or {}).get("reason") or ""),
+ f"{d4.get('approvePolicy')}")
+ check("**详情这一读顺手把快照回写了**(列表下次读到的是现场值)",
+ sql(f"SELECT red_count FROM collect_submission WHERE id={sid_a}") == "1",
+ sql(f"SELECT red_count FROM collect_submission WHERE id={sid_a}"))
+ sql(f"UPDATE collect_submission SET school='示范职业学校' WHERE id={sid_a}")
+
+ # ── 5. 初审通过 ────────────────────────────────
+ print("\n[5] 初审通过:1 → 2,并留痕")
+ ra = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/approve", t1,
+ {"actionSource": "SWIPE", "remark": "材料齐全"})
+ da = ra.get("data") or {}
+ check("通过成功", ra.get("code") == 0, f"{ra}")
+ check("状态 → 2 且说明下一步是「等待管理员终审」",
+ da.get("status") == 2 and da.get("nextNode") == "ADMIN_AUDIT", f"{da}")
+ check("replayed=false(这是真审了一次)", da.get("replayed") is False, f"{da}")
+ sql_status = sql(f"SELECT status || '|' || (class_audit_by IS NOT NULL)::text || '|' || "
+ f"(class_audit_time IS NOT NULL)::text FROM collect_submission WHERE id={sid_a}")
+ check("库里:状态 2 + 初审人/时间都写了", sql_status == "2|true|true", f"{sql_status}")
+ check("审核记录写了一条(audit_node=CLASS_AUDIT, action=1)",
+ sql(f"SELECT audit_node || '|' || action || '|' || auditor_id FROM collect_audit_record "
+ f"WHERE submission_id={sid_a} ORDER BY id DESC LIMIT 1")
+ == f"CLASS_AUDIT|1|{teachers['t1']['id']}",
+ sql(f"SELECT audit_node || '|' || action FROM collect_audit_record WHERE submission_id={sid_a}"))
+
+ ra2 = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/approve", t1, {})
+ check("**重复点通过是幂等的**(返回 replayed=true,不报错、不重复写记录)",
+ ra2.get("code") == 0 and (ra2.get("data") or {}).get("replayed") is True, f"{ra2}")
+ check("幂等回放没有多写一条记录",
+ sql(f"SELECT count(*) FROM collect_audit_record WHERE submission_id={sid_a}") == "1",
+ sql(f"SELECT count(*) FROM collect_audit_record WHERE submission_id={sid_a}"))
+
+ # ── 6. 终审 ────────────────────────────────────
+ print("\n[6] 终审:2 → 3")
+ bt = call(base, "GET", f"/api/v1/audit/todos?node=2&taskId={task_id}", admin)
+ check("管理员终审列表看到 1 份", (bt.get("data") or {}).get("total") == 1, f"{bt}")
+ rfin = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/approve", admin,
+ {"finalOpinion": "同意认定", "needArchive": False})
+ check("终审通过 → 3", (rfin.get("data") or {}).get("status") == 3, f"{rfin}")
+ st = sql(f"SELECT status || '|' || (admin_audit_by IS NOT NULL)::text FROM collect_submission "
+ f"WHERE id={sid_a}")
+ check("库里状态 3 + 终审人已写", st.startswith("3|"), f"{st}")
+ check("名册行 → 3(已通过)",
+ sql(f"SELECT submit_status FROM collect_roster WHERE id={rid_a}") == "3",
+ sql(f"SELECT submit_status FROM collect_roster WHERE id={rid_a}"))
+ check("库里有 2 条审核记录(初审 + 终审);时间轴上的「学生提交」是合成条目",
+ sql(f"SELECT count(*) FROM collect_audit_record WHERE submission_id={sid_a}") == "2",
+ sql(f"SELECT count(*) FROM collect_audit_record WHERE submission_id={sid_a}"))
+
+ # ── 7. 批量通过:只带绿色项 ────────────────────
+ print("\n[7] 批量通过:红黄一律排除(铁律)")
+ # ① 乙此刻是「有黄无红」→ 必须被跳过,且要说清是黄色
+ rb = call(base, "POST", "/api/v1/audit/submissions/batch-approve", t1, {
+ "submissionIds": [sid_b], "onlyClean": False,
+ })
+ db_ = rb.get("data") or {}
+ check("onlyClean 传 false 也不生效(服务端强制为 true)", rb.get("code") == 0, f"{rb}")
+ details = {x.get("submissionId"): x for x in (db_.get("details") or [])}
+ check("**有黄的单被跳过**:approved=0",
+ db_.get("approved") == 0 and db_.get("skipped") == 1, f"{db_}")
+ check("跳过原因说清了「黄色提醒需单独确认」(不是一句「跳过」)",
+ "黄色" in (details.get(sid_b, {}).get("reason") or ""), f"{details.get(sid_b)}")
+
+ # ② 把那份重复件停用 → 乙变成绿色 → 这次应该真的通过
+ sql(f"UPDATE collect_file SET is_current=0 WHERE submission_id={sid_b} AND id IN "
+ f"(SELECT id FROM collect_file WHERE submission_id={sid_b} ORDER BY id DESC LIMIT 1)")
+ rb2 = call(base, "POST", "/api/v1/audit/submissions/batch-approve", t1, {
+ "submissionIds": [sid_b], "onlyClean": True,
+ })
+ db2 = rb2.get("data") or {}
+ check("清掉重复件后:approved=1(乙过初审)", db2.get("approved") == 1, f"{rb2}")
+ check("批量通过记的是 action=3(批量通过),不是 1",
+ sql(f"SELECT action FROM collect_audit_record WHERE submission_id={sid_b} "
+ f"ORDER BY id DESC LIMIT 1") == "3",
+ sql(f"SELECT action FROM collect_audit_record WHERE submission_id={sid_b}"))
+ check("批量通过后状态 → 2",
+ sql(f"SELECT status FROM collect_submission WHERE id={sid_b}") == "2",
+ sql(f"SELECT status FROM collect_submission WHERE id={sid_b}"))
+
+ # ③ 把乙弄成红色 → 红色单同样必须被跳过
+ # ⚠️ 两个坑,都值得记:
+ # ① 先把状态放回 1:批量通过之后乙已经在"待终审"了,那时初审人再点它,
+ # 跳过原因会是「你没有终审权限」——正确但**不对题**,会掩盖这条用例要验的东西;
+ # ② 红问题要用**名册里没有的字段**来造(这里用「本职业工作年限」填 999 → VAL-17)。
+ # 一开始我用「在读学校」清空列,结果**根本不会触发**——
+ # 因为组装表单值时"学生没动过"的字段会**回退到名册值**(见 FillService.assemble),
+ # 提交单里那列是不是 null 并不重要。这正是"校验收敛到同一份取值口径"的体现。
+ sql(f"UPDATE collect_submission SET status=1, work_years=999 WHERE id={sid_b}")
+ rb3 = call(base, "POST", "/api/v1/audit/submissions/batch-approve", t1,
+ {"submissionIds": [sid_b]})
+ details3 = {x.get("submissionId"): x for x in ((rb3.get("data") or {}).get("details") or [])}
+ check("**有红的单被跳过**:approved=0",
+ (rb3.get("data") or {}).get("approved") == 0, f"{rb3}")
+ check("跳过原因说清了「存在红色问题,需单独审核」",
+ "红色" in (details3.get(sid_b, {}).get("reason") or ""), f"{details3.get(sid_b)}")
+ sql(f"UPDATE collect_submission SET work_years=5, status=2 WHERE id={sid_b}")
+
+ # ── 8. 驳回 ────────────────────────────────────
+ print("\n[8] 驳回:原因必填 + 升级 + 学生重交")
+ # 让乙回到待初审:管理员先驳回(终审驳回直接生效,可越过初审)
+ sql(f"UPDATE collect_submission SET status=2 WHERE id={sid_b}")
+ r_no = call(base, "POST", f"/api/v1/audit/submissions/{sid_b}/reject", admin, {})
+ check("不填原因 → 拒绝", r_no.get("code") != 0, f"{r_no}")
+ r_bad = call(base, "POST", f"/api/v1/audit/submissions/{sid_b}/reject", admin,
+ {"reasonCode": "R404"})
+ check("原因码不在字典里 → 拒绝", r_bad.get("code") != 0, f"{r_bad}")
+ r_999 = call(base, "POST", f"/api/v1/audit/submissions/{sid_b}/reject", admin,
+ {"reasonCode": "R999"})
+ check("R999 不填说明 → 拒绝(学生收到一条「其他」等于没收到)",
+ r_999.get("code") != 0, f"{r_999}")
+
+ rej = call(base, "POST", f"/api/v1/audit/submissions/{sid_b}/reject", admin,
+ {"reasonCode": "R002", "reasonText": "名册上是 2008 年", "materialCodes": ["IDCARD"]})
+ dj = rej.get("data") or {}
+ check("驳回成功 → 状态 4", dj.get("status") == 4, f"{rej}")
+ check("notifySent=false 且 needManualNotice=true(一期没有通知渠道,**如实说**)",
+ dj.get("notifySent") is False and dj.get("needManualNotice") is True, f"{dj}")
+ check("第 1 次驳回,escalated=false", dj.get("escalated") is False, f"{dj}")
+ st = sql(f"SELECT status || '|' || reject_count || '|' || last_reject_reason || '|' || "
+ f"(SELECT submit_status FROM collect_roster WHERE id={rid_b}) "
+ f"FROM collect_submission WHERE id={sid_b}")
+ check("库里:状态 4 + 次数 1 + 原因 JSON(含码与文案)",
+ st.startswith("4|1|") and "R002" in st and "名册上是 2008 年" in st, f"{st}")
+ check("名册行 → 4(已驳回)", st.rstrip().endswith("|4"), f"{st}")
+ check("驳回记录写了一条(action=2 + 原因码 + 材料项)",
+ sql(f"SELECT action || '|' || reason_code || '|' || material_codes "
+ f"FROM collect_audit_record WHERE submission_id={sid_b} ORDER BY id DESC LIMIT 1")
+ == "2|R002|IDCARD",
+ sql(f"SELECT action || '|' || reason_code FROM collect_audit_record WHERE submission_id={sid_b}"))
+
+ # 升级:把次数改成 2,再驳一次就是第 3 次
+ sql(f"UPDATE collect_submission SET status=2, reject_count=2 WHERE id={sid_b}")
+ rej2 = call(base, "POST", f"/api/v1/audit/submissions/{sid_b}/reject", admin,
+ {"reasonCode": "R005"})
+ check("第 3 次驳回 → escalated=true(达上限要升级给管理员)",
+ (rej2.get("data") or {}).get("escalated") is True, f"{rej2}")
+
+ # 学生重新提交 → 回到待初审,名册状态也要回来
+ sql(f"UPDATE collect_submission SET reject_count=3 WHERE id={sid_b}")
+ r_re = S.sub(tok_b, sid_b, True)
+ check("学生被驳回后可以重新提交", (r_re.get("data") or {}).get("submitted") is True, f"{r_re}")
+ st = sql(f"SELECT status || '|' || (SELECT submit_status FROM collect_roster WHERE id={rid_b}) "
+ f"FROM collect_submission WHERE id={sid_b}")
+ check("**状态回到 1,名册行回到 2(待审)**——不回退的话班主任会一直以为他还欠一份",
+ st == "1|2", f"{st}")
+
+ # ── 9. 撤回 ────────────────────────────────────
+ print("\n[9] 撤回:只能撤最近一次通过;已导出/已归档不可撤")
+ d9 = call(base, "GET", f"/api/v1/audit/submissions/{sid_a}", admin).get("data") or {}
+ rp = d9.get("revokePolicy") or {}
+ check("管理员看得到可撤回的结论(最近一条 = 终审那条)",
+ rp.get("canRevoke") is True and rp.get("auditRecordId"), f"{rp}")
+ admin_rec = rp.get("auditRecordId")
+
+ t2_rev = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/revoke", t2,
+ {"auditRecordId": admin_rec, "reason": "越权试试"})
+ check("T2(无撤回权限)→ 403", t2_rev.get("code") == 40301, f"{t2_rev}")
+
+ # 导出过 → 40033
+ sql(f"INSERT INTO collect_export_task (tenant_id, task_id, export_name, status, "
+ f"create_time) VALUES (1, {task_id}, 'E2E假导出', 2, now())")
+ r_exp = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/revoke", admin,
+ {"auditRecordId": admin_rec, "reason": "导出后还想撤"})
+ check("**已导出打包 → 40033**(已交付的数据不可追溯修改)",
+ r_exp.get("code") == 40033, f"{r_exp}")
+ sql(f"DELETE FROM collect_export_task WHERE task_id={task_id} AND export_name='E2E假导出'")
+
+ # 归档 → 40034
+ sql(f"UPDATE collect_task SET status=3 WHERE id={task_id}")
+ r_arc = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/revoke", admin,
+ {"auditRecordId": admin_rec, "reason": "归档后还想撤"})
+ check("已归档 → 40034", r_arc.get("code") == 40034, f"{r_arc}")
+ sql(f"UPDATE collect_task SET status=1 WHERE id={task_id}")
+
+ r_ok = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/revoke", admin,
+ {"auditRecordId": admin_rec, "reason": "发错了人,撤回重审"})
+ check("撤回终审结论 → 回到 2(待终审)",
+ (r_ok.get("data") or {}).get("status") == 2, f"{r_ok}")
+ check("撤回也留痕:多了一条 action=7(撤回)的记录",
+ sql(f"SELECT count(*) FROM collect_audit_record WHERE submission_id={sid_a} "
+ f"AND action=7") == "1",
+ sql(f"SELECT action FROM collect_audit_record WHERE submission_id={sid_a} ORDER BY id"))
+ check("时间轴里能看出「谁撤回了哪一条」",
+ "撤回记录 #" in sql(f"SELECT reason_text FROM collect_audit_record WHERE "
+ f"submission_id={sid_a} AND action=7"),
+ sql(f"SELECT reason_text FROM collect_audit_record WHERE submission_id={sid_a} AND action=7"))
+
+ # 撤回后再撤回同一条 → 已经是最新的那条被撤了,不能重复撤
+ r_twice = call(base, "POST", f"/api/v1/audit/submissions/{sid_a}/revoke", admin,
+ {"auditRecordId": admin_rec, "reason": "再撤一次"})
+ check("同一条不能撤两次(它已经不是「最近一次结论」了)",
+ r_twice.get("code") != 0, f"{r_twice}")
+
+ # ── 10. 字典与边界 ─────────────────────────────
+ print("\n[10] 驳回原因字典与边界")
+ rc = call(base, "GET", "/api/v1/audit/reason-codes", t1)
+ items = (rc.get("data") or [])
+ check("10 条原因码(PRD 附录 C)", len(items) == 10, f"{len(items)}")
+ check("R999 标了 requiresText=true",
+ any(x.get("code") == "R999" and x.get("requiresText") is True for x in items), f"{items[:2]}")
+ check("文案都是人话(不是 R001 这种码,也不是空)",
+ all(x.get("text") and x.get("text") != x.get("code") for x in items), f"{items}")
+
+ r_over = call(base, "POST", "/api/v1/audit/submissions/batch-approve", t1,
+ {"submissionIds": list(range(1, 300))})
+ check("批量超过 200 条 → 40011", r_over.get("code") == 40011, f"{r_over}")
+ r_none = call(base, "POST", "/api/v1/audit/submissions/batch-approve", t1,
+ {"submissionIds": []})
+ check("空列表 → 明确报错(不是静默成功)", r_none.get("code") != 0, f"{r_none}")
+
+ # 学生不该能碰审核接口
+ r_stu = S.call("GET", "/api/v1/audit/todos?node=1", tok_a)
+ check("学生拿自己的 token 调审核接口 → 40301", r_stu.get("code") == 40301, f"{r_stu}")
+
+ finally:
+ print("\n[11] 清理")
+ for tid in CREATED_TASK_IDS:
+ sql(f"DELETE FROM collect_audit_record WHERE task_id={tid}")
+ sql(f"DELETE FROM collect_file WHERE task_id={tid}")
+ sql(f"DELETE FROM collect_submission WHERE task_id={tid}")
+ sql(f"DELETE FROM collect_roster WHERE task_id={tid}")
+ sql(f"DELETE FROM collect_material_def WHERE task_id={tid}")
+ sql(f"DELETE FROM collect_task_org WHERE task_id={tid}")
+ sql(f"DELETE FROM collect_export_task WHERE task_id={tid}")
+ sql(f"DELETE FROM collect_task WHERE id={tid}")
+ for uid in CREATED_USER_IDS:
+ sql(f"DELETE FROM sys_user_role WHERE user_id={uid}")
+ sql(f"DELETE FROM sys_user WHERE id={uid}")
+ for p in TMP_FILES:
+ try:
+ os.remove(p)
+ except OSError:
+ pass
+ left = sql(f"SELECT count(*) FROM collect_task WHERE task_name LIKE 'E2E审核%'")
+ ok(f"已清理(残留任务 {left} 个)")
+
+ print("\n" + "=" * 76)
+ print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
+ for n, dd in FAILED:
+ print(f" · {n}\n {dd}")
+ print("=" * 76)
+ return 1 if FAILED else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())