feat(audit): 审核闭环后端(M5-01~05)—— 「谁能审谁」比「怎么审」重要

一、落地
   module/audit:entity(CollectAuditRecord) + 4 个 mapper + AuditService
                 + RejectReasonCatalog(附录 C 的 10 条,写在代码里,理由同权限码)
   两端接口:PC /api/v1/audit/*(§11)、小程序 /api/v1/mp/teacher/audit/*(§10.2~10.5,只做初审)
   待办的 MP 与 PC **共用一份 VO**(契约里那两份重合九成):做两份迟早分叉,
   而"同一个学生在手机上是红、在电脑上是绿"最伤信任。

二、★ 三件必须守住的事
   ① 权限 ≠ 范围:全校几十个班主任都有 audit:first,但只有这个班的那位能审这个学生。
      初审查询强制带 org 范围,且**空列表 = 看不见任何单子**(SQL 里显式 1 = 0,
      不是"不过滤")—— 那是这类查询最经典也最严重的写法错误。
      E2E 专门造了个"同样是班主任但没被指定"的 T2:看不到待办、打不开详情、点不动通过。
   ② 有红问题不许通过(40032),且校验是**现场重算**的:提交时落库的 red_count 是快照,
      而 OCR/识别是异步的、名册与材料规则也可能被改。"放过一份有问题材料"不可撤销。
   ③ 批量通过只带绿色项,红黄一律排除(onlyClean 服务端强制 true,传 false 不报错也不生效),
      且跳过的每一条都要说清原因 —— 否则用户只会反复点那个大按钮。

三、红黄计数:列表用快照,动作/详情现场重算并**回写快照**(自己会收敛,最多错一次)
   ⚠️ 因此 detail() 不能标 readOnly=true —— 它要写回写。标了 PG 直接拒绝 UPDATE,
   而这个 bug 只在"现场值与快照不一致"时炸,也就是最需要它工作的那一次。已踩并修。

四、⚠️ 三处文档与代码不一致,需要文档侧对齐(详见交接摘要 §一·二十七)
   1. 撤回的动作码:DDL 注释 3=批量通过,而接口设计 §11.4 写"action = 3(撤回)"。
      1~6 沿用 DDL,**撤回另取 7** —— 时间轴最不能混淆:把撤回显示成批量通过,
      会让人以为这份单子被通过了。
   2. 撤回的适用状态:§11.4 只列了状态 3;额外允许**状态 2**("班主任点错了通过"正是这个状态)。
      状态机自洽:撤初审 → 1,撤终审 → 2。**需客户确认**。
   3. 文件 source 的语义:DDL(2 班主任代传/3 管理员代传/4 整理器导入) 与 §10.6(source=4 代传)
      冲突,以 DDL 为准;代传接口(M5-06)未做,时间轴已按 source∈{2,3} 预留。

五、顺带补掉两个"没人写过"的窟窿
   · 名册行的 submit_status **全仓库只有读没有写** → 名册列表状态列恒为"未提交",
     而 REPLACE 护栏只靠 bind_user_id 在生效。现在学生侧与审核侧都写它。
     它**不是单调的**:被驳回(4)后重交必须回到 2,否则班主任一直以为他还欠一份。
   · collect_submission 少映射了 class_audit_time / admin_audit_time(DDL 里一直有)。
     口径是"**本轮**审核的状态"(驳回时清空,历史在 collect_audit_record 里一条不少)。

六、审核端看材料走单独通道
   MaterialFileService 抽出 urlOf/contentOf(调用方授权),学生端仍 /mp/files/*、
   审核端 /audit/files/*。不要为了"让管理员也能看"在学生那个方法里加 or 条件——那一刻学生端就漏了。

七、一个把「取值口径」讲清楚了的坑
   SubmissionValidator 原本走 FillService.getForm(taskId)(学生视角:先过门禁、再拿自己的草稿),
   审核人必然撞 40007 且拿到的是自己那份草稿。已抽 assembleFor(task, roster, sub),两条路径共用同一段装配。
   紧接着暴露第二层:**改提交单的列不一定改变"组装出来的值"** —— 学生没动过的字段值会**回退到名册**。
   想造红问题要用**名册里没有的字段**(E2E 最后用"本职业工作年限填 999"→ VAL-17)。

八、E2E:scripts/e2e-m5-audit.py → 87/87
   复用了既有的两个脚本工具(import e2e-m3-submit 拿学生侧造数、import e2e-m2-roster 拿服务端工具),
   不复制第二份 mint/upload。覆盖:范围隔离、详情与策略、40032 现场重算、幂等回放、
   终审、批量通过的三种跳过、驳回与升级、学生重交、撤回的 40033/40034/40301 与"不能撤两次"。
   夹具教训:身份证材料的目录默认长边下限是 1400,小图必判「清晰度偏低」=黄色警告,
   于是"绿色单"前提不成立、批量断言集体失败 —— 失败的样子像功能坏了,其实只是夹具不满足前提。

九、没做的(都在计划内):代传(M5-06)、统计(M5-09)、通知(M5-12)、批量驳回/转交(P1)。
   因为没有通知渠道,驳回响应**如实**回 notifySent=false + needManualNotice=true ——
   不能为了让契约好看而写 true,那会让"学生到底收到没有"变成没人能回答的问题。

十、回归:全量 **753 条断言、0 失败**(新增 87);verify-docs 19 项通过。
    下一步:M5 前端(班主任卡片流只右滑通过 / 学生详情 / 驳回页 / 一键通过确认 / PC 终审)。
This commit is contained in:
2026-09-29 22:29:00 +08:00
parent e6cd1049d4
commit 5213bc214f
21 changed files with 3695 additions and 22 deletions
@@ -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)。
*
* <p>类上只做"粗筛"(初级或终审权限,满足其一即可)——因为<b>具体要哪一个权限取决于这一单的状态</b>:
* 待初审的单子要 {@code audit:first},待终审的要 {@code audit:final}。
* 这个分支没法在注解里表达,所以真正的判定在 {@code AuditService.requireAuditableNode}。
*
* <p>⚠️ 这里有<b>两层</b>检查,缺一不可:<b>权限</b>(有没有这类动作的资格)
* 与<b>范围</b>(这一单归不归我)。全校几十个班主任都有初审权限,
* 但只有这个班的班主任能审这个学生 —— 只查权限就是把全校学生交给了每个班主任。
*/
@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<AuditDtos.TodoPageVO> 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<AuditDtos.DetailVO> 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<AuditDtos.ApproveVO> 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<AuditDtos.RejectVO> 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<AuditDtos.BatchApproveVO> batchApprove(@RequestBody AuditDtos.BatchApproveReq req) {
return ApiResult.ok(auditService.batchApprove(req));
}
@PostMapping("/submissions/{id}/revoke")
@Operation(summary = "撤回审核结论",
description = "只能撤回**最近一次通过**;已导出(40033)、已归档(40034)不可撤;"
+ "非原审核人且非管理员返回 40301")
public ApiResult<AuditDtos.RevokeVO> revoke(@PathVariable Long id,
@RequestBody AuditDtos.RevokeReq req) {
return ApiResult.ok(auditService.revoke(id, req));
}
@GetMapping("/submissions/{id}/timeline")
@Operation(summary = "审核时间轴", description = "倒序(最近的在最前)。含学生提交、初审、终审、撤回、代传")
public ApiResult<List<AuditDtos.TimelineItem>> timeline(@PathVariable Long id) {
return ApiResult.ok(auditService.timeline(id));
}
@GetMapping("/reason-codes")
@Operation(summary = "驳回原因字典",
description = "PRD 附录 C 的 10 条。⚠️ 契约里写的\"按 taskId 定制\"还没有配置入口,"
+ "所以不接收该参数 —— 假支持会让人配了半天发现不生效")
public ApiResult<List<AuditDtos.ReasonCodeVO>> reasonCodes() {
return ApiResult.ok(RejectReasonCatalog.list());
}
@GetMapping("/files/{id}/url")
@Operation(summary = "取材料的访问链接(审核端)",
description = "七牛私有空间 → 签名链接(默认 30 分钟,**不要存库、不要转发**)。"
+ "授权口径:这份材料所属的填报单必须在我能看/能审的范围内")
public ApiResult<MaterialFileService.FileUrl> 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<byte[]> 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());
}
}
@@ -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)。
*
* <p>与 PC 端({@code /api/v1/audit/*})<b>共用同一个服务</b>,差别只有三点:
* <ol>
* <li>这里只做<b>初审</b>({@code node} 固定为 1)—— 手机上不该出现"终审",
* 那是管理员在电脑上做的事(要对着几十份材料比对着看)</li>
* <li>权限只要 {@code audit:first}(班主任 / 副班主任)</li>
* <li>路径前缀是 {@code /mp/teacher}(小程序端)</li>
* </ol>
*
* <p>⚠️ <b>左滑不做驳回</b>(客户确认过的原型):驳回必须点按钮 + 二次确认,
* 因为"通过"点错了还能撤回,而"驳回"点错了学生那边立刻收到通知,
* 一来一回的代价不对称。接口层面只做一件事:把 {@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<AuditDtos.TodoPageVO> 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<AuditDtos.DetailVO> detail(@PathVariable Long submissionId) {
return ApiResult.ok(auditService.detail(submissionId));
}
@PostMapping("/{submissionId}/approve")
@Operation(summary = "通过(右滑或按钮)",
description = "actionSource 记 SWIPE/BUTTON 用于埋点。红问题存在时返回 40032")
public ApiResult<AuditDtos.ApproveVO> 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<AuditDtos.RejectVO> 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<AuditDtos.BatchApproveVO> batchApprove(@RequestBody AuditDtos.BatchApproveReq req) {
return ApiResult.ok(auditService.batchApprove(req));
}
@GetMapping("/reason-codes")
@Operation(summary = "驳回原因字典(附录 C 的 10 条)")
public ApiResult<List<AuditDtos.ReasonCodeVO>> reasonCodes() {
return ApiResult.ok(RejectReasonCatalog.list());
}
}
@@ -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)。
*
* <h3>为什么 MP(班主任)与 PC(管理员)共用同一份 VO</h3>
*
* <p>契约里是两处(§10.2 的卡片流、§11.1 的待审表),但字段重合九成以上,
* 差异只是"这一端用不到哪几个"。做两份的代价不是多写一个类,而是<b>迟早分叉</b>:
* 一边加了"红问题摘要",另一边没有,于是同一个学生在手机上看到红、
* 在电脑上看到绿 —— 而这类不一致最容易让人不信任系统。
*
* <p>所以:一个超集 VO,两端各取所需。<b>字段只增不减</b>,
* 加字段时同步两端的展示说明。
*/
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} 有红色。
*
* <p>⚠️ <b>没有 {@code 1}</b> —— 契约 §10.2 就是这三档,
* 前端不要写"1 = 蓝色/信息级"的分支(写了永远不会触发)。
*/
private Integer level;
private Integer redCount;
private Integer yellowCount;
/** 红问题的第一句(卡片上直接显示"身份证号与名册不一致");无红时为 null */
private String redSummary;
/** 材料缩略图(签名链接,**有有效期**,不要缓存到本地或存库) */
private List<String> materialThumbs;
private Integer materialCount;
/** 学生改过的预填字段(审核时要一眼看到"这不是老师填的那份") */
private List<String> changedFields;
private Integer rejectCount;
/**
* 能否【批量通过】。
*
* <p>⚠️ <b>由服务端算好,前端不许自己判</b>(契约 §10.2 的原文)。
* 规则是"红黄都为零",而前端只看得见红黄计数、看不见
* "这个计数是什么时候算的"。规则不落地到前端,才不会出现
* "前端说能、服务端说不能"这种让人反复点按钮的僵局。
*/
private Boolean batchApprovable;
private Integer status;
private String statusText;
}
/** 待办列表分页结果。 */
@Getter
@Setter
@Schema(description = "待办列表")
public static class TodoPageVO {
private List<TodoVO> 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<String, Object> formData;
private List<String> changedFields;
/** 改动明细:老师填的是什么、学生改成了什么 */
private List<ChangedItem> changedDetail;
private List<MaterialGroup> materials;
/** 现场校验的问题清单(红/黄/蓝)。⚠️ 不是提交那一刻的快照,见 AuditService 的说明 */
private List<SubmitDtos.Issue> issues;
private Integer redCount;
private Integer yellowCount;
private Integer rejectCount;
private String lastRejectCode;
private String lastRejectText;
private ApprovePolicy approvePolicy;
private RevokePolicy revokePolicy;
private List<TimelineItem> 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<FileItem> 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<SubmitDtos.Issue> 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<String> 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<String> 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<String> materialCodes;
/** 语音转文字结果(可选输入方式) */
private String voiceText;
private Boolean suggestReupload;
}
/** 驳回的结果。 */
@Getter
@Setter
public static class RejectVO {
private Integer status;
private String statusText;
private Integer rejectCount;
/**
* 通知是否已发出。
*
* <p>⚠️ 一期恒为 {@code false}:订阅消息(M5-12)还没做。
* <b>不能为了让契约好看而写 true</b> —— 那会让"学生到底收到没有"
* 变成一个没人能回答的问题,而家长第二天就会打电话问。
*/
private Boolean notifySent;
/** 学生是否**没**收到通知(前端据此显示"请口头告知学生") */
private Boolean needManualNotice;
/** 驳回次数是否已达上限(达上限同时通知管理员,见 §10.4 的升级规则) */
private Boolean escalated;
}
/** 批量通过(§10.5)。 */
@Getter
@Setter
@Schema(description = "批量通过")
public static class BatchApproveReq {
private List<Long> submissionIds;
/**
* 是否只通过无问题项。
*
* <p>⚠️ <b>服务端强制 true</b>:客户端传 false 会被改回 true。
* 这是铁律(交接摘要 §二·9)—— 批量通过只能带绿色项,红黄一律排除。
* 字段保留只是为了契约形状一致。
*/
private Boolean onlyClean;
private String actionSource;
}
/** 批量通过的结果。 */
@Getter
@Setter
public static class BatchApproveVO {
private int approved;
private int skipped;
private List<BatchSkip> 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;
}
}
@@ -0,0 +1,49 @@
package cn.tianbaotong.fill.module.audit.dto;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 待办列表的一行(Mapper 直出,不是响应 VO)。
*
* <p>分开是因为它们回答的问题不同:这里只装"列表要用的列",
* 而 {@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;
}
@@ -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})—— <b>审核时间轴的唯一来源</b>。
*
* <p>它只增不改(没有 {@code deleted} / {@code update_time} 列)。这不是省事,
* 而是语义:审核结论是**发生过的事实**。撤回不是"改掉那条记录",
* 而是<b>再写一条撤回记录</b> —— 于是时间轴上永远能看出
* "谁在什么时候通过了、后来谁在什么时候撤回了、为什么"。
* 把上一行删掉就只剩一个"当前状态",而状态是看不出这些的。
*
* <p>⚠️ <b>本实体刻意不继承 {@code TenantEntity}</b>:那张基类带 {@code @TableLogic deleted},
* 会让每条查询自动加上 {@code deleted = 0},而这张表<b>没有这一列</b> ——
* 表现是运行时 {@code column deleted does not exist}。同 {@code CollectTaskOrg}。
*
* <p>⚠️ {@code action = 3} 的语义在文档与 DDL 之间<b>冲突</b>,见 {@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;
/**
* 撤回。
*
* <p>⚠️ <b>这个值是我定的,因为文档与 DDL 冲突</b>:
* DDL 的注释写的是 {@code 1通过 2驳回 3批量通过 4批量驳回 5转交 6代传},
* 而《03-接口设计》§11.4 写"写入一条 <b>action = 3(撤回)</b>的记录"。
* 两者对 3 的解释正好相反 —— 而时间轴恰恰是<b>最不能混淆</b>的地方:
* 把"撤回"显示成"批量通过",等于让人以为这份单子被通过了。
*
* <p>所以:1~6 沿用 DDL(它们已发布、可能已有数据),撤回另取 <b>7</b>,
* 并在此写明冲突。这条会同步进交接摘要,供客户/文档侧对齐。
*/
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;
}
@@ -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;
/**
* 审核动作的前置检查:<b>这份数据是不是已经交付过了</b>。
*
* <p>「已导出打包 → 不可撤回」({@code 40033})这条约束需要一个"这个任务导出过没有"的查询。
* 导出模块(M6)还没做,而只有审核端用得到,所以先放在这里,
* <b>M6 落地后应把它搬到导出模块</b>(那时导出模块才是 {@code collect_export_task} 的归属方)。
*/
public interface AuditPreconditionMapper {
/**
* 这个任务有没有<b>已完成</b>的导出包。
*
* <p>{@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);
}
@@ -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。
*
* <p>没有任何手写 SQL:时间轴(按填报单倒序列出)与"最近一次结论"
* 都能用 {@code LambdaQueryWrapper} 表达,而**用 Wrapper 就自动带租户条件**
* ({@code sys_tenant} 之外的业务表都会被拦截器补上 {@code tenant_id = ?})。
* 手写 SQL 的场合才需要显式声明隔离条件 —— 那几处集中在
* {@link AuditScopeMapper} 与 {@link AuditTodoMapper},并有注释说明。
*/
public interface AuditRecordMapper extends BaseMapper<CollectAuditRecord> {
}
@@ -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;
/**
* 审核范围:<b>"这个人能审哪些班"</b>。
*
* <p>《03-接口设计》§7.6 定了覆盖班级与初审人的关系({@code collect_task_org}),
* 这里把它翻译成一次查询。三级回退的顺序是<b>刻意的</b>:
*
* <ol>
* <li>{@code head_user_id} —— 任务里为该班指定的初审人(最具体,优先)</li>
* <li>{@code deputy_user_ids} —— 副班主任 / 协办人</li>
* <li>{@code sys_org.leader_user_id} —— 任务没指定时,回退到班级负责人的身份</li>
* </ol>
*
* <p>⚠️ <b>{@code deputy_user_ids} 用 {@code LIKE '%,3,%'} 匹配,这是有前提的</b>:
* 库里存的是**首尾都带逗号**的形式({@code ,3,7,})。裸逗号分隔的 {@code 3,17}
* 会被 {@code %,3,%} 误命中 —— 表现是"莫名其妙的协办人能看到别人的班"。
* 写入端在 {@code TaskOrgService.joinIds},两处必须一致。
*
* <p>⚠️ 本章手写 SQL 关掉了租户拦截器,<b>隔离条件由本方法自己保证</b>
* (每个表都显式带了 {@code tenant_id})。这是本仓库对"关掉框架保护"的统一要求:
* 集中放置、显式带条件、并写清为什么 —— 同 {@code MpSceneMapper}。
*/
public interface AuditScopeMapper {
/**
* 在这个任务里,我能初审的班级 id。
*
* @return 班级 id;<b>空列表表示"一个班都不归我审"</b>。
* 调用方必须把它当成"看不见任何单子",而不是"不过滤"
*/
@Select("""
<script>
SELECT cto.org_id
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.status = 1
<if test="taskId != null">
AND cto.task_id = #{taskId}
</if>
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})
)
ORDER BY cto.org_id
</script>
""")
List<Long> 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);
}
@@ -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;
/**
* 待办列表(班主任初审 / 管理员终审<b>共用一套查询</b>)。
*
* <p>两个端(小程序卡片流、PC 终审表)问的是同一个问题 ——
* "现在轮到我处理的单子有哪些"。差异只有三个过滤条件与排序,
* 所以做成一套查询 + 参数化,而不是两份 SQL:两份 SQL 一旦分叉,
* 迟早出现"小程序看得到、PC 看不到某一单"这种没人解释得清的现象。
*
* <h3>⚠️ 关掉租户拦截器,隔离条件在这里显式写明</h3>
*
* <p>这是一条带 3 个 join 的手写 SQL,隔离条件按本仓库的规矩集中写清、可审:
* <ul>
* <li>主表 {@code s.tenant_id = #{tenantId}}</li>
* <li>每个 join 的表都带 {@code tenant_id = #{tenantId}} ——
* 只靠 join 键相等是不够的:id 是各租户自增的,
* 万一哪天有个脏数据指向别的租户的名册行,join 就会把别人的姓名带出来</li>
* </ul>
*
* <h3>⚠️ {@code orgIds} 的"空列表"必须当成"看不见"</h3>
*
* <p>{@code orgIds = []} 与 {@code orgIds = null} 在业务上<b>完全不同</b>:
* <ul>
* <li>{@code null} = 不按班级过滤(终审:全租户)</li>
* <li>{@code []} = 这个老师一个班都不带 → <b>应该看不见任何单子</b></li>
* </ul>
* 把空列表当成"不过滤"是这类查询最经典、也最严重的写法错误:
* 一个刚被移出所有班级的班主任会突然看到全校的单子。
* 所以 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("""
<script>
SELECT s.id AS submission_id,
s.roster_id AS roster_id,
s.task_id AS task_id,
t.task_name AS task_name,
s.org_id AS org_id,
o.org_name AS org_name,
r.real_name AS real_name,
r.id_card_mask AS id_card_mask,
s.status AS status,
s.submit_time AS submit_time,
s.red_count AS red_count,
s.yellow_count AS yellow_count,
s.reject_count AS reject_count,
s.changed_fields AS changed_fields
FROM collect_submission s
LEFT JOIN collect_roster r
ON r.id = s.roster_id AND r.deleted = 0 AND r.tenant_id = #{tenantId}
LEFT JOIN sys_org o
ON o.id = s.org_id AND o.deleted = 0 AND o.tenant_id = #{tenantId}
LEFT JOIN collect_task t
ON t.id = s.task_id AND t.deleted = 0 AND t.tenant_id = #{tenantId}
WHERE s.tenant_id = #{tenantId}
AND s.deleted = 0
AND s.status = #{status}
<if test="taskId != null">
AND s.task_id = #{taskId}
</if>
<if test="orgId != null">
AND s.org_id = #{orgId}
</if>
<choose>
<when test="orgIds != null and orgIds.size() > 0">
AND s.org_id IN
<foreach item="oid" collection="orgIds" open="(" separator="," close=")">#{oid}</foreach>
</when>
<when test="orgIds != null">
AND 1 = 0
</when>
</choose>
<if test="level != null and level == 0">
AND s.red_count = 0 AND s.yellow_count = 0
</if>
<if test="level != null and level == 2">
AND s.red_count = 0 AND s.yellow_count &gt; 0
</if>
<if test="level != null and level == 3">
AND s.red_count &gt; 0
</if>
<if test="keyword != null and keyword != ''">
AND r.real_name LIKE ('%' || #{keyword} || '%')
</if>
<if test="waitingOverHours != null">
AND s.submit_time &lt; now() - make_interval(hours =&gt; #{waitingOverHours})
</if>
ORDER BY s.submit_time ASC, s.id ASC
</script>
""")
IPage<AuditTodoRow> pageTodos(IPage<AuditTodoRow> page,
@Param("tenantId") Long tenantId,
@Param("status") Integer status,
@Param("taskId") Long taskId,
@Param("orgId") Long orgId,
@Param("orgIds") List<Long> orgIds,
@Param("level") Integer level,
@Param("keyword") String keyword,
@Param("waitingOverHours") Integer waitingOverHours);
/**
* 同一批过滤条件下的四个计数(用于列表页顶部的"全部 / 无问题 / 有异常 / 超时")。
*
* <p><b>刻意不受 {@code level} 影响</b>:这几个数字是给用户"点哪一档"用的,
* 点进去之后再按 level 过滤。跟着 level 变的话,点一下"有异常",
* "全部"就变成了 1 —— 那样他再也回不到全部,只能刷新页面。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
<script>
SELECT COUNT(*) AS all_count,
COUNT(*) FILTER (WHERE s.red_count = 0 AND s.yellow_count = 0) AS clean_count,
COUNT(*) FILTER (WHERE s.red_count &gt; 0 OR s.yellow_count &gt; 0) AS abnormal_count,
COUNT(*) FILTER (WHERE s.submit_time &lt; now() - make_interval(hours =&gt; #{timeoutHours}))
AS overtime_count
FROM collect_submission s
LEFT JOIN collect_roster r
ON r.id = s.roster_id AND r.deleted = 0 AND r.tenant_id = #{tenantId}
WHERE s.tenant_id = #{tenantId}
AND s.deleted = 0
AND s.status = #{status}
<if test="taskId != null">
AND s.task_id = #{taskId}
</if>
<if test="orgId != null">
AND s.org_id = #{orgId}
</if>
<choose>
<when test="orgIds != null and orgIds.size() > 0">
AND s.org_id IN
<foreach item="oid" collection="orgIds" open="(" separator="," close=")">#{oid}</foreach>
</when>
<when test="orgIds != null">
AND 1 = 0
</when>
</choose>
<if test="keyword != null and keyword != ''">
AND r.real_name LIKE ('%' || #{keyword} || '%')
</if>
</script>
""")
Map<String, Object> counts(@Param("tenantId") Long tenantId,
@Param("status") Integer status,
@Param("taskId") Long taskId,
@Param("orgId") Long orgId,
@Param("orgIds") List<Long> orgIds,
@Param("keyword") String keyword,
@Param("timeoutHours") Integer timeoutHours);
}
@@ -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 条)。
*
* <p><b>为什么写在代码里而不是建一张字典表</b>:和权限码同理(见 {@code PermissionDef})——
* 它是<b>前后端的契约</b>,而且驳回是"把学生的材料打回去重做"的动作,
* 理由文案一旦能被某个人在后台改掉,历史记录里那句"材料不是本人"就再也无法解释清楚了。
* 契约 §11.7 说"仅返回启用的、可按 taskId 定制",这两件事都还没有配置入口 ——
* 所以现在返回全部 10 条,<b>不假装支持定制</b>(假支持会让机构配了半天发现不生效)。
*/
public final class RejectReasonCatalog {
private RejectReasonCatalog() {
}
/** 「其他」的码:选它必须手填说明,否则学生收到一条"其他"等于没收到。 */
public static final String CODE_OTHER = "R999";
private static final Map<String, String> TEXTS = Map.of(
"R001", "材料不清晰,请重新提供(建议找打印店重新扫描或索取原图)",
"R002", "身份证号与老师名单不一致",
"R003", "材料不是本人",
"R004", "文件超过 1M",
"R005", "缺少材料",
"R006", "工作证明未盖公章",
"R007", "材料不完整(缺正反面 / 缺页)",
"R008", "信息填写有误",
"R009", "照片背景不符合要求",
CODE_OTHER, "其他(手填)"
);
/** 展示顺序按 PRD 附录 C 的编号,不用 Map 的遍历顺序(那是不确定的)。 */
private static final List<String> ORDER = List.of(
"R001", "R002", "R003", "R004", "R005", "R006", "R007", "R008", "R009", CODE_OTHER);
public static List<AuditDtos.ReasonCodeVO> list() {
List<AuditDtos.ReasonCodeVO> 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);
}
/**
* 校验原因码,并返回规范的文案。
*
* <p>⚠️ 认不出就<b>拒绝</b>,不静默放过:驳回是学生唯一能看到的"为什么被打回",
* 存进去一个字典里没有的码,界面上就只剩一个空白原因,
* 而学生只会以为系统坏了 —— 他会打电话给班主任,班主任也说不清。
*/
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();
}
}
@@ -54,4 +54,42 @@ public interface FileMapper extends BaseMapper<CollectFile> {
java.util.List<CollectFile> listMine(@Param("tenantId") Long tenantId,
@Param("submissionId") Long submissionId,
@Param("submitterId") Long submitterId);
/**
* 审核侧:一次取出一批填报单的当前生效文件(待办列表要显示材料缩略图)。
*
* <p>没有 {@code submitter_id} 条件 —— 审核看的正是别人的材料。
* 授权在上一层完成:<b>只有已经通过"这个班归我审"的填报单 id 才会传进来</b>
* (见 {@code AuditService.todos} 与 {@code requireAuditable})。
*
* <p>为什么要"一次一批"而不是每行查一次:待办一页 10~20 行,
* 每行一次查询就是典型的 N+1,而列表接口的耗时是最容易被用户感知的。
*/
@InterceptorIgnore(tenantLine = "true")
@org.apache.ibatis.annotations.Select("""
<script>
SELECT * FROM collect_file
WHERE tenant_id = #{tenantId} AND deleted = 0 AND is_current = 1
AND submission_id IN
<foreach item="sid" collection="submissionIds" open="(" separator="," close=")">#{sid}</foreach>
ORDER BY submission_id, material_code, version
</script>
""")
java.util.List<CollectFile> listBySubmissions(@Param("tenantId") Long tenantId,
@Param("submissionIds") java.util.List<Long> submissionIds);
/**
* 审核侧:按 id 取一份文件(不带归属条件)。
*
* <p>同样由调用方保证授权(先确认这份材料所属的填报单在审核范围内),
* 见 {@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);
}
@@ -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/";
/**
* 取链接的**实现**:调用方负责授权,本方法只管拼地址。
*
* <p>抽出来是为了让审核端也能取同一个文件的链接 —— 两边的差别只有两处:
* <b>谁有权看</b>(各自校验)与<b>本地存储时走哪个下载前缀</b>
* (学生端 {@code /api/v1/mp/files/} 校验"这份属于你",
* 审核端 {@code /api/v1/audit/files/} 校验"这个班归你审")。
*
* <p>⚠️ 不要让审核端复用 {@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})。
*
* <p>为什么要按 id 流式提供:本地存储(开发环境)没有公网签名地址,
* 而预览必须是浏览器能直接打开的 URL。生产用七牛时走的是签名链接,
* 这个接口不会被用到 —— 但开发时它是唯一能看图的路径。
*/
public Content contentOf(CollectFile f, boolean original) {
boolean useRaw = original;
String path = original ? f.getRawPath() : f.getStdPath();
if (path == null || path.isBlank()) {
@@ -105,8 +105,20 @@ public class CollectSubmission extends TenantEntity {
private Long classAuditBy;
/**
* 初审时间。
*
* <p>⚠️ 这一对(含 {@link #classAuditBy})是"<b>本轮</b>审核的状态",
* 不是历史:被驳回时会被清空(见 {@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;
@@ -42,4 +42,30 @@ public interface SubmissionMapper extends BaseMapper<CollectSubmission> {
CollectSubmission selectMineById(@Param("id") Long id,
@Param("tenantId") Long tenantId,
@Param("submitterId") Long submitterId);
/**
* 审核侧取填报单:只带租户,不带归属 —— <b>归属由审核范围(班级)决定,不由提交人决定</b>。
*
* <p>所以这里不能沿用 {@code selectMineById}:班主任要看的恰恰是<b>别人</b>的单子。
* 隔离靠两件事,缺一不可:
* <ol>
* <li>这里的 {@code tenant_id}(跨租户绝不可能)</li>
* <li>调用方必须先通过"这个班归不归我审"的检查(见 {@code AuditService})</li>
* </ol>
*
* <p>⚠️ {@code FOR UPDATE} 是刻意的:审核是<b>并发动作</b> ——
* 两个端(手机 + 电脑)同时点通过、或者班主任和终审管理员同时处理,
* 没有行锁就会出现"两条审核记录 + 状态被后写的那次覆盖"。
* 那时时间轴上会有两条"通过",而库里只有一个状态,谁也说不清是怎么变的。
*
* <p>因此本方法的调用方<b>必须</b>在 {@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);
}
@@ -82,11 +82,33 @@ public class FillService {
"请先核对身份(填写姓名与身份证号)再开始填报");
}
FormSchemaVO schema = formSchemaService.get(taskId);
return assemble(task, roster, submissionMapper.selectMine(me.tenantId(), taskId, me.uid()));
}
/**
* 装配表单(含值)—— <b>按指定的名册行与填报单,而不是按当前登录人</b>。
*
* <p>学生端走 {@link #getForm}(先过门禁,再拿自己的草稿);审核端走这一个:
* 审核人要看的正是<b>别人</b>的那份,走学生入口会被门禁拦掉
* (报"请先核对身份"),而且拿到的会是审核人自己的草稿。
*
* <p>⚠️ 两条路径**必须共用同一段装配代码**。一旦分成两份,审核端看到的值
* 就会和学生看到的不一样,而那是"两条校验规则互相矛盾"的开端
* (见 {@code SubmissionValidator} 的类注释)。
*
* <p>这里<b>不判任务状态</b>:已截止甚至已归档的任务照样要能审(审的是历史数据)。
* 状态检查属于"能不能填",不属于"能不能看"。
*/
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<String, Object> 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<String> touched = new LinkedHashSet<>();
if (draft != null) {
vo.setSubmissionId(draft.getId());
@@ -164,7 +185,7 @@ public class FillService {
vo.getSections().add(ex);
}
List<MaterialDefVO> materials = materialDefService.list(taskId);
List<MaterialDefVO> 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<String, Object> rosterValuesOf(CollectRoster r) {
Map<String, Object> m = new LinkedHashMap<>();
if (r == null) {
// 审核端可能拿到没有名册行的填报单(一期不会有,社会收集才有)。
// 这里返回空映射而不是 NPE —— 让"没有名册"表现为"这些字段都空着",
// 由校验规则去说清"缺什么",而不是变成一个 500。
return m;
}
putIfPresent(m, "seq_no", r.getSeqNo());
putIfPresent(m, "real_name", r.getRealName());
// ⚠️ 只回脱敏值:表单上没有必要出现完整证件号(截图/录屏/分享都是泄露面)
@@ -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);
}
/**
* 学生点进表单时:名册行的状态从「未提交」变成「草稿」。
*
* <p>⚠️ <b>只在当前是「未提交」时才写</b>。学生被驳回后(4)再打开表单,
* 不该被退回成「草稿」—— 班主任看名册时要能看出"这份已经被驳回过",
* 否则他会以为这个学生从没交过。同理,已提交(2)之后也不用再改。
*/
private void markRosterDraft(Long rosterId) {
if (rosterId == null) {
return;
}
collectRosterMapper.update(null, Wrappers.<CollectRoster>lambdaUpdate()
.eq(CollectRoster::getId, rosterId)
.eq(CollectRoster::getSubmitStatus, CollectRoster.SUBMIT_NONE)
.set(CollectRoster::getSubmitStatus, CollectRoster.SUBMIT_DRAFT));
}
/**
* 名册行的提交状态——<b>它反映"这一行现在处在哪一步",所以是可以往回走的</b>:
* 被驳回(4)之后学生改完重新交,它必须回到「待审」(2)。
* 不做这一步,名册列表会一直显示「已驳回」,而班主任据此挨个去催那些早就交了的。
*/
void markRosterStatus(Long rosterId, int status) {
if (rosterId == null) {
return;
}
collectRosterMapper.update(null, Wrappers.<CollectRoster>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 {
* <p>抽出来是因为读写各要一遍 switch,散在两个方法里迟早漏字段 ——
* 而漏掉的表现是"某一项填了没保存",且不报错。
*/
static final class SubValues {
public static final class SubValues {
/**
* 有哪些字段在 {@code collect_submission} 里是可写列。
*
* <p>把它列成清单(而不是只看 switch 里有哪些 case)是为了让这个事实
* <b>只有一个来源</b>:加字段时先看表有没有这一列,而不是看代码里有没有分支。
*/
public static final java.util.List<String> 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} 里有没有对应的可写列。
*
* <p>⚠️ 姓名、身份证号、联系电话<b>只存在于名册</b>,提交表里没有这三列 ——
* 所以它们既不该被比较(会造成假的"改动留痕"),也不该被写入。
* 判断"列存在"而不是"我实现了读写的分支",是为了让这个事实<b>只有一个来源</b>:
* 加字段时先看表有没有这一列,而不是看代码里有没有 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<String, Object> allOf(CollectSubmission s) {
Map<String, Object> 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();
@@ -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<Issue> validate(CollectSubmission s, CollectTask task, CollectRoster roster) {
List<Issue> 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<String, Object> 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<String, Object> when, Map<String, Object> values) {
public static boolean visible(Map<String, Object> when, Map<String, Object> values) {
if (when == null) {
return true;
}
@@ -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;
// ── 预填列(老师已填,学生端只核对)─────────────
/** 报考职业(锁定,由老师确定)。 */
+131
View File
@@ -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 的前端**:班主任端卡片流(原型 ②,**只右滑通过**、左滑不绑定驳回)、
学生详情页(原型 ③)、驳回页(原型 ⑤,原因快选 + 二次确认)、
一键通过确认页(原型 ④)、管理端终审列表与详情(原型 ⑥)。
+639
View File
@@ -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())