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:
+145
@@ -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());
|
||||
}
|
||||
}
|
||||
+99
@@ -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;
|
||||
}
|
||||
}
|
||||
+49
@@ -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;
|
||||
}
|
||||
+104
@@ -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;
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
+16
@@ -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> {
|
||||
}
|
||||
+83
@@ -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);
|
||||
}
|
||||
+169
@@ -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 > 0
|
||||
</if>
|
||||
<if test="level != null and level == 3">
|
||||
AND s.red_count > 0
|
||||
</if>
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND r.real_name LIKE ('%' || #{keyword} || '%')
|
||||
</if>
|
||||
<if test="waitingOverHours != null">
|
||||
AND s.submit_time < now() - make_interval(hours => #{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 > 0 OR s.yellow_count > 0) AS abnormal_count,
|
||||
COUNT(*) FILTER (WHERE s.submit_time < now() - make_interval(hours => #{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);
|
||||
}
|
||||
+1377
File diff suppressed because it is too large
Load Diff
+93
@@ -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();
|
||||
}
|
||||
}
|
||||
+38
@@ -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);
|
||||
}
|
||||
|
||||
+30
-1
@@ -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()) {
|
||||
|
||||
+12
@@ -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;
|
||||
|
||||
+26
@@ -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);
|
||||
}
|
||||
|
||||
+31
-4
@@ -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());
|
||||
// ⚠️ 只回脱敏值:表单上没有必要出现完整证件号(截图/录屏/分享都是泄露面)
|
||||
|
||||
+66
-14
@@ -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();
|
||||
|
||||
+13
-3
@@ -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;
|
||||
}
|
||||
|
||||
+22
@@ -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;
|
||||
|
||||
// ── 预填列(老师已填,学生端只核对)─────────────
|
||||
|
||||
/** 报考职业(锁定,由老师确定)。 */
|
||||
|
||||
Reference in New Issue
Block a user