diff --git a/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/api/ErrorCode.java b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/api/ErrorCode.java
index a6801b6..f893a3d 100644
--- a/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/api/ErrorCode.java
+++ b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/api/ErrorCode.java
@@ -5,7 +5,7 @@ import lombok.Getter;
/**
* 错误码全集。
*
- * 权威来源:《03-接口设计》§15「错误码全集」,共 58 个(含成功码 {@code 0})。
+ *
权威来源:《03-接口设计》§15「错误码全集」,共 59 个(含成功码 {@code 0})。
* 改这里之前先改文档,不要只改代码。
*
*
为什么 code 是 int 而不是 String:§1.3 约定响应形如
@@ -101,22 +101,14 @@ public enum ErrorCode {
INVALID_FILES_BLOCK_EXPORT(40035, "存在不合格文件,禁止导出"),
/** 字典项有下级,不可停用 */
DICT_HAS_CHILDREN(40036, "字典项有下级,不可停用"),
- /** 加入申请次数超限 */
- JOIN_REQUEST_LIMIT(40037, "加入申请次数已达上限"),
- /** 该证件号已有待审申请 */
- JOIN_REQUEST_DUPLICATE(40038, "你已提交过申请,请耐心等待"),
- /** 该证件号已在名册中 */
- ALREADY_IN_ROSTER(40039, "你已在名单中,请返回重新核验"),
- /** 申请状态已变更(非待审) */
- JOIN_STATUS_CHANGED(40040, "申请状态已变更"),
/** 名册中该证件号已被占用 */
ROSTER_ID_CARD_TAKEN(40041, "名册中该证件号已被占用"),
- /** 申请已通过,不可再驳回 */
- JOIN_ALREADY_APPROVED(40042, "申请已通过,不可再驳回"),
/** 文件归属存疑,阻断提交且无「仍然继续」 */
FILE_OWNERSHIP_DOUBT(40043, "这份材料可能不是你的,请重新上传"),
/** 一期不支持该收集模式 / 认定类型 */
SCOPE_NOT_SUPPORTED(40044, "一期仅支持学校收集 + 自主认定"),
+ /** 角色还有账号在用,不可删除 */
+ ROLE_IN_USE(40045, "该角色还有账号在用,请先改掉这些账号的角色"),
// ── 认证与权限 ────────────────────────────────
/** 未登录 / token 失效(真 HTTP 401) */
@@ -146,7 +138,7 @@ public enum ErrorCode {
STORAGE_FAILED(50007, "存储写入失败,请重传"),
// ── 兜底 ──────────────────────────────────────
- /** 系统内部错误(不在文档的 58 个码内,仅用于未预期异常兜底) */
+ /** 系统内部错误(不在文档的 59 个码内,仅用于未预期异常兜底) */
INTERNAL_ERROR(50000, "系统繁忙,请稍后再试");
/** 业务码。0 表示成功;4xxxx 客户端;5xxxx 服务端。 */
diff --git a/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/context/LoginUser.java b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/context/LoginUser.java
index 3d275d6..65cbc2b 100644
--- a/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/context/LoginUser.java
+++ b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/context/LoginUser.java
@@ -68,9 +68,24 @@ public record LoginUser(
return false;
}
+ /**
+ * 这个人是不是租户级管理员({@code user_type = 2},即学校 / 机构管理员)。
+ *
+ *
⚠️ 判 {@code user_type} 而不判角色码。上一版这里写的是
+ * {@code hasAnyRole("SCHOOL_ADMIN", "AUDITOR")},有两个问题:
+ *
+ * - 「审核员」({@code AUDITOR})角色已删除;
+ * - 租户可以自建角色,而"自定义角色 + {@code user_type=2}"同样是租户管理员 ——
+ * 按角色码判会让这批人退化成普通用户(表现是"什么都点不动",且不报任何错)。
+ *
+ */
+ public boolean isTenantAdmin() {
+ return userType != null && userType == 2;
+ }
+
/** 数据范围是否覆盖指定组织。平台运营与租户管理员恒为 true。 */
public boolean coversOrg(Long orgId) {
- if (isPlatformAdmin() || hasAnyRole("SCHOOL_ADMIN", "AUDITOR")) {
+ if (isPlatformAdmin() || isTenantAdmin()) {
return true;
}
return orgId != null && orgIds != null && orgIds.contains(orgId);
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/FillApplication.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/FillApplication.java
index e7ac6c7..7512eab 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/FillApplication.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/FillApplication.java
@@ -7,7 +7,7 @@ import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
- * 数据信息填报系统 · 主应用。
+ * 深学在线 · 主应用。
*
* 一期范围:学校收集 + 自主认定(见 PRD「一期范围声明」)。
* 社会收集 / 社会评价 / 专项职业能力的数据结构与枚举保留但不启用,传入返回 {@code 40044}。
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/OpenApiConfig.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/OpenApiConfig.java
index 8f81062..30d96bd 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/OpenApiConfig.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/OpenApiConfig.java
@@ -26,7 +26,7 @@ public class OpenApiConfig {
public OpenAPI fillOpenApi() {
return new OpenAPI()
.info(new Info()
- .title("数据信息填报系统 API")
+ .title("深学在线 API")
.version("1.0.0")
.description("""
职业技能等级认定 · 报名材料收集 SaaS
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/StorageConfig.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/StorageConfig.java
index 9ab5ff1..ee7daee 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/StorageConfig.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/StorageConfig.java
@@ -66,7 +66,7 @@ public class StorageConfig {
*/
private void selfCheck(QiniuStorageService svc) {
String path = "_selfcheck/" + System.currentTimeMillis() + ".txt";
- byte[] payload = "填报通存储自检 ok".getBytes(StandardCharsets.UTF_8);
+ byte[] payload = "深学在线存储自检 ok".getBytes(StandardCharsets.UTF_8);
try {
svc.put(path, payload, "text/plain; charset=utf-8");
if (!svc.exists(path)) {
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditScopeMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditScopeMapper.java
index b4165aa..783fe02 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditScopeMapper.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditScopeMapper.java
@@ -10,18 +10,17 @@ import java.util.List;
* 审核范围:"这个人能审哪些班"。
*
*
《03-接口设计》§7.6 定了覆盖班级与初审人的关系({@code collect_task_org}),
- * 这里把它翻译成一次查询。三级回退的顺序是刻意的:
+ * 这里把它翻译成一次查询。两级回退的顺序是刻意的:
*
*
* - {@code head_user_id} —— 任务里为该班指定的初审人(最具体,优先)
- * - {@code deputy_user_ids} —— 副班主任 / 协办人
* - {@code sys_org.leader_user_id} —— 任务没指定时,回退到班级负责人的身份
*
*
- * ⚠️ {@code deputy_user_ids} 用 {@code LIKE '%,3,%'} 匹配,这是有前提的:
- * 库里存的是**首尾都带逗号**的形式({@code ,3,7,})。裸逗号分隔的 {@code 3,17}
- * 会被 {@code %,3,%} 误命中 —— 表现是"莫名其妙的协办人能看到别人的班"。
- * 写入端在 {@code TaskOrgService.joinIds},两处必须一致。
+ *
⚠️ 这里以前还有一级 {@code deputy_user_ids}(副班主任 / 协办人)。
+ * 该字段与副班主任角色已一并删除,所以**一个班只有一个初审人**;
+ * 需要多人协审时,正确做法是给其中一人指定为该班初审人,
+ * 而不是再引入一个"协办人"字段。
*
*
⚠️ 本章手写 SQL 关掉了租户拦截器,隔离条件由本方法自己保证
* (每个表都显式带了 {@code tenant_id})。这是本仓库对"关掉框架保护"的统一要求:
@@ -48,7 +47,6 @@ public interface AuditScopeMapper {
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
@@ -72,7 +70,6 @@ public interface AuditScopeMapper {
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})
)
""")
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditTodoMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditTodoMapper.java
index db93959..3da302a 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditTodoMapper.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/audit/mapper/AuditTodoMapper.java
@@ -102,7 +102,6 @@ public interface AuditTodoMapper {
AND cto.status = 1
AND (
cto.head_user_id = #{auditorId}
- OR cto.deputy_user_ids LIKE ('%,' || #{auditorId} || ',%')
OR (cto.head_user_id IS NULL AND so.leader_user_id = #{auditorId})
)
)
@@ -193,7 +192,6 @@ public interface AuditTodoMapper {
AND cto.status = 1
AND (
cto.head_user_id = #{auditorId}
- OR cto.deputy_user_ids LIKE ('%,' || #{auditorId} || ',%')
OR (cto.head_user_id IS NULL AND so.leader_user_id = #{auditorId})
)
)
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/service/AuthService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/service/AuthService.java
index 513f45f..0e9330c 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/service/AuthService.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/auth/service/AuthService.java
@@ -158,6 +158,13 @@ public class AuthService {
Set.copyOf(roleCodes),
user.getUserType());
+ // ⚠️ 把上下文**升级成完整的 principal**(上面那个是"只有租户、没有角色"的临时的)。
+ // 必须在这里做:下面的 buildProfile 会用 PermissionChecker 装配菜单与权限,
+ // 而它是**从上下文里的 roles 算的** —— 上下文还是空角色的话,
+ // 管理员登录响应里的 permissions 会是空数组,前端于是把所有按钮都藏起来
+ // (现象是"刚登录什么都没有,刷新一下又好了")。
+ UserContext.set(principal);
+
String token = jwtService.issue(principal, cn.tianbaotong.fill.security.JwtService.Client.PC);
SysUser touch = new SysUser();
@@ -183,9 +190,9 @@ public class AuthService {
throw new BizException(ErrorCode.UNAUTHORIZED);
}
List roleCodes = userRoleMapper.selectRoleCodesByUserId(user.getId());
- LoginVO vo = buildProfile(user, roleCodes);
- vo.setPermissions(permissionChecker.currentPermissions());
- return vo;
+ // 权限与菜单由 buildProfile 统一装配(含自定义角色的授权),这里不再单独覆盖一次 ——
+ // 两处各写一份的话,登录响应与 /auth/me 迟早会不一致,而那种不一致只在"刷新一下就好了"里表现
+ return buildProfile(user, roleCodes);
}
// ── 修改自己的密码 ─────────────────────────────
@@ -358,8 +365,13 @@ public class AuthService {
}
vo.setRoleCodes(roleCodes);
- vo.setMenus(BuiltinRole.mergeMenus(roleCodes));
- vo.setPermissions(BuiltinRole.mergePermissions(roleCodes));
+ // ⚠️ 菜单与权限都走 PermissionChecker,**不要**直接取 BuiltinRole:
+ // 二期的自定义角色,它的授权在库里(sys_role_permission / sys_role_menu),
+ // 直接取 BuiltinRole 会让"自定义角色的人登录后左侧菜单是空的"——
+ // 而登录响应里的这两项前端会直接落库用(省一次 /auth/me 往返),
+ // 所以漏在这里就等于"要刷新一次才看得见菜单"。
+ vo.setMenus(permissionChecker.currentMenus());
+ vo.setPermissions(permissionChecker.currentPermissions());
return vo;
}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/controller/CleanupController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/controller/CleanupController.java
deleted file mode 100644
index 95e5549..0000000
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/controller/CleanupController.java
+++ /dev/null
@@ -1,155 +0,0 @@
-package cn.tianbaotong.fill.module.cleanup.controller;
-
-import cn.tianbaotong.fill.common.api.ApiResult;
-import cn.tianbaotong.fill.module.cleanup.dto.CleanupDtos;
-import cn.tianbaotong.fill.module.cleanup.service.CleanupImportService;
-import cn.tianbaotong.fill.module.cleanup.service.CleanupService;
-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.HttpHeaders;
-import org.springframework.http.MediaType;
-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 org.springframework.web.multipart.MultipartFile;
-
-import java.io.IOException;
-import java.net.URLEncoder;
-import java.nio.charset.StandardCharsets;
-import java.util.List;
-import java.util.Map;
-
-/**
- * 历史数据整理器(《03-接口设计》§13.2)—— 差异化获客功能。
- *
- * 权限用 {@code task:manage}:整理出来的东西最终会变成正式任务(名册 + 材料),
- * 与"创建与配置收集任务"是同一批人的职责。
- */
-@Tag(name = "历史数据整理器")
-@RestController
-@RequestMapping("/api/v1/cleanup/jobs")
-@RequiredArgsConstructor
-public class CleanupController {
-
- private final CleanupService cleanupService;
- private final CleanupImportService importService;
-
- @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
- @RequirePerm(PermissionDef.CLEANUP_RUN)
- @Operation(summary = "创建整理任务(上传压缩包)",
- description = "用**与正式收集完全相同的规则**做规范化。"
- + "⚠️ `rosterTaskId` 是**关键参数**:没有名册就只能做文件名规范化;"
- + "**有名单才能做证件号校验、疑似串档检测** —— 而「疑似串档 1 例」"
- + "正是报告里最有价值的一条。"
- + "一期只支持 zip(云盘链接与文件夹上传返回 40044)")
- public ApiResult create(
- @RequestParam String jobName,
- @RequestParam(defaultValue = "1") Integer sourceType,
- @RequestParam(value = "file", required = false) MultipartFile file,
- @RequestParam(required = false) String namingTemplate,
- @RequestParam(required = false) Long rosterTaskId) throws IOException {
- CleanupDtos.CreateReq req = new CleanupDtos.CreateReq();
- req.setJobName(jobName);
- req.setSourceType(sourceType);
- req.setNamingTemplate(namingTemplate);
- req.setRosterTaskId(rosterTaskId);
- return ApiResult.ok(cleanupService.create(req,
- file == null ? null : file.getBytes()));
- }
-
- @PostMapping(value = "/json", consumes = MediaType.APPLICATION_JSON_VALUE)
- @RequirePerm(PermissionDef.CLEANUP_RUN)
- @Operation(summary = "创建整理任务(JSON 形态,供脚本/联调使用)",
- description = "与 multipart 那个等价,只是把压缩包换成 base64 传 —— "
- + "方便自动化脚本与排障,不必手搓 multipart")
- public ApiResult createJson(@RequestBody Map body)
- throws IOException {
- CleanupDtos.CreateReq req = new CleanupDtos.CreateReq();
- req.setJobName(body.get("jobName") == null ? null : String.valueOf(body.get("jobName")));
- req.setSourceType(body.get("sourceType") == null ? 1
- : Integer.valueOf(String.valueOf(body.get("sourceType"))));
- req.setNamingTemplate(body.get("namingTemplate") == null ? null
- : String.valueOf(body.get("namingTemplate")));
- req.setRosterTaskId(body.get("rosterTaskId") == null ? null
- : Long.valueOf(String.valueOf(body.get("rosterTaskId"))));
- String b64 = body.get("zipBase64") == null ? null : String.valueOf(body.get("zipBase64"));
- byte[] bytes = b64 == null || b64.isBlank()
- ? null : java.util.Base64.getDecoder().decode(b64);
- return ApiResult.ok(cleanupService.create(req, bytes));
- }
-
- @GetMapping
- @RequirePerm(PermissionDef.TASK_VIEW)
- @Operation(summary = "整理任务列表")
- public ApiResult list(
- @RequestParam(required = false) Integer status,
- @RequestParam(required = false) String keyword,
- @RequestParam(defaultValue = "1") int pageNum,
- @RequestParam(defaultValue = "20") int pageSize) {
- return ApiResult.ok(cleanupService.list(status, keyword, pageNum, pageSize));
- }
-
- @GetMapping("/{id}")
- @RequirePerm(PermissionDef.TASK_VIEW)
- @Operation(summary = "整理进度",
- description = "compressRatio 在没算出来时给「—」而不是 0% —— "
- + "0% 看起来像「一点没压缩」,而真相是「还没算」")
- public ApiResult detail(@PathVariable Long id) {
- return ApiResult.ok(cleanupService.detail(id));
- }
-
- @GetMapping("/{id}/report")
- @RequirePerm(PermissionDef.TASK_VIEW)
- @Operation(summary = "体检报告 ⭐",
- description = "报告里最重要的两个数字是 **autoHandled / needManual** ——"
- + "「112 个文件里 90 个系统全自动处理,只有 1 个必须你亲自判断」。"
- + "它们由服务端算出来,不让前端去减(前端减出来的数字早晚会分叉)。"
- + "⚠️ 一期没有 OCR,所以 `idcardConflicts` 的判定一律是 `NEED_CONFIRM` ——"
- + "唯一的证据是文件名,而文件名本身正是这次要修的东西(用它改数据是循环论证)")
- public ApiResult report(@PathVariable Long id) {
- return ApiResult.ok(cleanupService.report(id));
- }
-
- @GetMapping("/{id}/result")
- @RequirePerm(PermissionDef.EXPORT_DOWNLOAD)
- @Operation(summary = "下载整理结果(zip 流)")
- public ResponseEntity result(@PathVariable Long id) {
- byte[] bytes = cleanupService.resultBytes(id);
- String name = "cleanup-" + id + "-result.zip";
- String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8).replace("+", "%20");
- return ResponseEntity.ok()
- .contentType(MediaType.APPLICATION_OCTET_STREAM)
- .header(HttpHeaders.CONTENT_DISPOSITION,
- "attachment; filename=\"" + encoded + "\"; filename*=UTF-8''" + encoded)
- .body(bytes);
- }
-
- @PostMapping("/{id}/import")
- @RequirePerm(PermissionDef.CLEANUP_RUN)
- @Operation(summary = "把整理结果导入为正式任务",
- description = "名册 + 材料一次成型。**幂等**:同一个整理任务重复导入不会建出两个任务 —— "
- + "客户手滑点两次是常事,而多出来的任务很难被发现(它会安静地躺在列表里)")
- public ApiResult importJob(@PathVariable Long id,
- @RequestBody CleanupDtos.ImportReq req) {
- return ApiResult.ok(importService.importAsTask(id, req));
- }
-
- @PostMapping(value = "/parse-preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
- @RequirePerm(PermissionDef.CLEANUP_RUN)
- @Operation(summary = "只解析不落库(排障用)",
- description = "返回每个路径解析出的姓名/证件号(脱敏)/材料码/班级线索。"
- + "调规则或排查「为什么这个人没认出来」时用它 —— "
- + "不然只能看最终报告,而报告是**汇总后的结果**,看不出是哪一步错了")
- public ApiResult>> parsePreview(
- @RequestParam("file") MultipartFile file) throws IOException {
- return ApiResult.ok(cleanupService.dryRunParse(file.getBytes()));
- }
-}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/dto/CleanupDtos.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/dto/CleanupDtos.java
deleted file mode 100644
index ad945a5..0000000
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/dto/CleanupDtos.java
+++ /dev/null
@@ -1,282 +0,0 @@
-package cn.tianbaotong.fill.module.cleanup.dto;
-
-import io.swagger.v3.oas.annotations.media.Schema;
-import lombok.Getter;
-import lombok.Setter;
-
-import java.time.LocalDateTime;
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * 历史数据整理器(《03-接口设计》§13.2)。
- */
-public final class CleanupDtos {
-
- private CleanupDtos() {
- }
-
- /** 创建整理任务。 */
- @Getter
- @Setter
- @Schema(description = "创建历史整理任务")
- public static class CreateReq {
-
- private String jobName;
-
- /** {@code 1} 压缩包 / {@code 2} 文件夹 / {@code 3} 云盘链接。 */
- private Integer sourceType;
-
- private String uploadToken;
-
- private String objectKey;
-
- private String sourceUrl;
-
- private String namingTemplate;
-
- private Object compressConfig;
-
- /**
- * 关联到**已有任务的名册**。
- *
- * ⚠️ 关键参数:没有名册就只能做文件名规范化;有名单才能做证件号校验、
- * 疑似串档检测 —— 而"疑似串档 1 例"正是报告里最有价值的一条。
- */
- private Long rosterTaskId;
- }
-
- /** 创建结果。 */
- @Getter
- @Setter
- public static class CreateVO {
-
- private Long jobId;
-
- private Integer status;
-
- /** 估时(秒)。按文件数估:解析 + 压缩,经验值约每 10 个文件 1 秒 */
- private Integer estimatedSeconds;
- }
-
- /** 整理任务(列表与详情共用)。 */
- @Getter
- @Setter
- public static class JobVO {
-
- private Long id;
-
- private String jobName;
-
- private Integer sourceType;
-
- private Long rosterTaskId;
-
- private Integer status;
-
- private String statusText;
-
- private Integer progress;
-
- private Integer totalFiles;
-
- private Integer processedFiles;
-
- private Integer parsedStudents;
-
- private Integer parsedMaterials;
-
- private Long rawSizeBytes;
-
- private Long finalSizeBytes;
-
- /** 形如 {@code 91.5%};没算出来时给 {@code —} 而不是 0%(0% 看起来像"没压缩") */
- private String compressRatio;
-
- /** 磁盘上的整理结果路径(不是可下载 URL —— 下载走 `/jobs/{id}/result`)。 */
- private String resultPath;
-
- private String errorMsg;
-
- private Long targetTaskId;
-
- private LocalDateTime createTime;
-
- private LocalDateTime finishTime;
- }
-
- /** 列表。 */
- @Getter
- @Setter
- public static class JobPageVO {
-
- private List list = new ArrayList<>();
-
- private long total;
- }
-
- // ── 体检报告(契约 §13.2 的核心)──────────────
-
- /** 报告汇总。 */
- @Getter
- @Setter
- public static class ReportSummary {
-
- private int totalFiles;
-
- /** 文件名不规范(双扩展名、缺姓名/证件号等)。 */
- private int nameInvalid;
-
- private int sizeExceeded;
-
- private int formatConverted;
-
- /** 证件号与名册不一致的份数。 */
- private int idcardMismatch;
-
- /** 其中系统自动修正的。 */
- private int idcardAutoFixed;
-
- /** 其中需要人工确认的。 */
- private int idcardNeedConfirm;
-
- /**
- * 疑似串档。
- *
- * 判据(契约 §13.2 的例子):某个学生的**多项**材料证件号与名册**均不同**,
- * 且这些材料彼此一致 —— 那说明"整个文件夹可能被换过"。
- * 单项不符只是录入笔误,多项一致才指向"人不对"。
- */
- private int crossFileSuspected;
-
- private int lowResolution;
-
- /** 无法归位(既没匹配到名册,也没解析出姓名)。 */
- private int orphanImages;
- }
-
- /** 报告里的一条重点。 */
- @Getter
- @Setter
- public static class Highlight {
-
- /** {@code CROSS_FILE} / {@code LOW_RESOLUTION} / {@code IDCARD_MISMATCH} / {@code ORPHAN}。 */
- private String type;
-
- /** {@code 1} 提示 / {@code 2} 警告 / {@code 3} 必须人工。 */
- private Integer level;
-
- private Long rosterId;
-
- private String realName;
-
- private String title;
-
- private String materialCode;
-
- private String detail;
-
- private String suggestion;
- }
-
- /** 改名样例。 */
- @Getter
- @Setter
- public static class NameFixSample {
-
- private String original;
-
- private String fixed;
-
- private String rule;
- }
-
- /** 证件号冲突明细。 */
- @Getter
- @Setter
- public static class IdcardConflict {
-
- private Long rosterId;
-
- private String realName;
-
- /** 名册里的脱敏值。 */
- private String rosterValue;
-
- /** 从文件名里读到的脱敏值。 */
- private String fileNameValue;
-
- /** 按文件名修正 / 按名册修正 / 待人工。 */
- private String verdict;
-
- private String verdictText;
- }
-
- /** 体检报告。 */
- @Getter
- @Setter
- public static class ReportVO {
-
- private Long jobId;
-
- private ReportSummary summary = new ReportSummary();
-
- /**
- * 系统全自动处理了多少个文件。
- *
- *
契约里写明:报告里最重要的一句话是 {@code autoHandled / needManual}
- * ——「112 个文件里 90 个系统全自动处理,只有 1 个必须你亲自判断」。
- * 所以这两个数字**必须由服务端算出来**,不能让前端自己减。
- */
- private int autoHandled;
-
- private int needManual;
-
- private List highlights = new ArrayList<>();
-
- private List nameFixedSamples = new ArrayList<>();
-
- private List idcardConflicts = new ArrayList<>();
- }
-
- /** 导入为正式任务。 */
- @Getter
- @Setter
- @Schema(description = "把整理结果导入为正式任务")
- public static class ImportReq {
-
- private String taskName;
-
- private Integer collectMode;
-
- private String batchNo;
-
- /**
- * 专业名称(选填)。
- *
- * 契约 §13.2 的导入请求里没有这个字段,但 `TaskService.create` 要求它 ——
- * 而"专业名"必须有人给。所以:给了就用它,没给就**继承关联名册任务的专业**
- * (历史材料与那份名册本来就属于同一个专业),都没有时明确报错让用户填。
- * 这条补字段记在 F-44。
- */
- private String majorName;
-
- private Boolean importRoster;
-
- private Boolean importFiles;
-
- private Boolean markAsSubmitted;
- }
-
- /** 导入结果。 */
- @Getter
- @Setter
- public static class ImportVO {
-
- private Long taskId;
-
- private int rosterCount;
-
- private int fileCount;
- }
-}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/entity/CollectCleanupJob.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/entity/CollectCleanupJob.java
deleted file mode 100644
index 0838013..0000000
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/entity/CollectCleanupJob.java
+++ /dev/null
@@ -1,89 +0,0 @@
-package cn.tianbaotong.fill.module.cleanup.entity;
-
-import cn.tianbaotong.fill.support.BaseEntity;
-import com.baomidou.mybatisplus.annotation.TableName;
-import lombok.Getter;
-import lombok.Setter;
-
-import java.time.LocalDateTime;
-
-/**
- * 历史整理任务({@code collect_cleanup_job},《03-接口设计》§13.2)。
- *
- *
⚠️ V1 建这张表时漏了 {@code update_time} / {@code deleted},
- * 而实体继承 {@code BaseEntity}(带 {@code @TableLogic} 与自动填充)——
- * 少了这两列的表现是**运行时**才炸。V13 补齐,并被
- * {@code scripts/check-entity-columns.py} 提前拦住过(F-24/F-35 的同类)。
- */
-@Getter
-@Setter
-@TableName("collect_cleanup_job")
-public class CollectCleanupJob extends BaseEntity {
-
- /** {@code 0} 排队 / {@code 1} 解析中 / {@code 2} 处理中 / {@code 3} 已完成 / {@code 4} 失败。 */
- public static final int STATUS_QUEUED = 0;
- public static final int STATUS_PARSING = 1;
- public static final int STATUS_PROCESSING = 2;
- public static final int STATUS_DONE = 3;
- public static final int STATUS_FAILED = 4;
-
- /** 来源:{@code 1} 压缩包 / {@code 2} 文件夹 / {@code 3} 云盘链接。 */
- public static final int SOURCE_ZIP = 1;
- public static final int SOURCE_FOLDER = 2;
- public static final int SOURCE_CLOUD = 3;
-
- private String jobName;
-
- private Integer sourceType;
-
- /** 压缩包在存储里的相对路径({@code sourceType=1})。 */
- private String sourcePath;
-
- /** 云盘链接({@code sourceType=3},一期不启用)。 */
- private String sourceUrl;
-
- /**
- * 关联的已有任务名册(契约 §13.2 的 {@code rosterTaskId})。
- *
- *
⚠️ **这是关键参数**:没有名册就只能做文件名规范化;
- * 有名单才能做证件号校验、疑似串档检测 ——
- * 而"疑似串档 1 例"正是报告里最有价值的那一条。
- */
- private Long rosterTaskId;
-
- private String namingTemplate;
-
- /** 压缩参数(JSON,缺省见契约 §3.11)。 */
- private String compressConfig;
-
- private Integer status;
-
- private Integer progress;
-
- /** 体检报告(JSON)—— 契约 §13.2 的 report 结构整体存这里。 */
- private String reportJson;
-
- private Integer totalFiles;
-
- private Integer processedFiles;
-
- private Integer fixedFiles;
-
- private Integer failedFiles;
-
- private Integer parsedStudents;
-
- private Integer parsedMaterials;
-
- private Long rawSizeBytes;
-
- private Long finalSizeBytes;
-
- /** 整理结果的压缩包路径。 */
- private String resultPath;
-
- /** 导入成正式任务后的任务 id。 */
- private Long targetTaskId;
-
- private LocalDateTime finishTime;
-}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/mapper/CleanupJobMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/mapper/CleanupJobMapper.java
deleted file mode 100644
index ac05b37..0000000
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/mapper/CleanupJobMapper.java
+++ /dev/null
@@ -1,13 +0,0 @@
-package cn.tianbaotong.fill.module.cleanup.mapper;
-
-import cn.tianbaotong.fill.module.cleanup.entity.CollectCleanupJob;
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-
-/**
- * 历史整理任务。
- *
- *
只需要 MyBatis-Plus 的默认方法 —— 列表用 `selectPage` + 条件构造器就够了,
- * 不为一个查询再写一段手写 SQL(那会多一处要维护的口径)。
- */
-public interface CleanupJobMapper extends BaseMapper {
-}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/service/CleanupImportService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/service/CleanupImportService.java
deleted file mode 100644
index e9160e2..0000000
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/service/CleanupImportService.java
+++ /dev/null
@@ -1,284 +0,0 @@
-package cn.tianbaotong.fill.module.cleanup.service;
-
-import cn.hutool.json.JSONUtil;
-import cn.tianbaotong.fill.common.api.ErrorCode;
-import cn.tianbaotong.fill.common.context.LoginUser;
-import cn.tianbaotong.fill.common.context.UserContext;
-import cn.tianbaotong.fill.common.exception.BizException;
-import cn.tianbaotong.fill.module.cleanup.dto.CleanupDtos;
-import cn.tianbaotong.fill.module.cleanup.entity.CollectCleanupJob;
-import cn.tianbaotong.fill.module.cleanup.mapper.CleanupJobMapper;
-import cn.tianbaotong.fill.module.file.service.FileProcessService;
-import cn.tianbaotong.fill.module.file.storage.StorageService;
-import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
-import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
-import cn.tianbaotong.fill.module.task.dto.TaskUpsertReq;
-import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
-import cn.tianbaotong.fill.module.task.service.TaskService;
-import cn.tianbaotong.fill.support.SensitiveCodec;
-import com.baomidou.mybatisplus.core.toolkit.Wrappers;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.nio.charset.StandardCharsets;
-import java.time.LocalDate;
-import java.util.ArrayList;
-import java.util.LinkedHashMap;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipInputStream;
-
-/**
- * 把整理结果导入为正式任务(《03-接口设计》§13.2 的 `/jobs/{id}/import`)。
- *
- * 这个动作的分量:它把"一堆历史文件"变成"一份可审核的交付"
- *
- * 导入之后,客户拿到的是一个真实的任务:名册、材料、填报单、状态齐备,
- * 可以走审核、可以导出。所以这里做的是"按解析结果重建一套数据",
- * 而重建最容易出的两类错必须在这里挡住:
- *
- *
- * - 重复导入:客户手滑点两次是常事。多出来的任务很难被发现
- * (它会安静地躺在任务列表里),而"同一批人出现两次"会让导出重复计人。
- * 所以导入是**幂等**的(看 {@code target_task_id})。
- * - 证件号缺失:解析不出证件号的人**不能建名册行** ——
- * `collect_roster.id_card_no` 是 NOT NULL 的密文列(F-19 那个坑),
- * 而且没有证件号的人永远过不了门禁、也过不了名册校验。
- * 所以这类人**跳过并计入报告**,而不是塞一条空号的行进去。
- *
- *
- * 一期能做到哪一步(说清边界,不假装完整)
- *
- * 能建:任务、名册(仅"解析出姓名 **且** 证件号"的人)、材料定义(按解析出的材料码)。
- * 不能建:填报单与挂文件 —— 那需要把整理结果里的每个文件重新走一遍
- * "压缩 + 按模板改名 + 挂到某个填报单",而一期还没有"把外部文件挂到指定填报单"
- * 的服务端入口(代传接口要 multipart 逐个传)。
- * 所以 {@code importFiles} 参数**会被如实拒绝**(而不是安静地忽略它)——
- * 安静忽略会让客户以为文件已经进去了。
- */
-@Slf4j
-@Service
-@RequiredArgsConstructor
-public class CleanupImportService {
-
- private final CleanupJobMapper jobMapper;
- private final TaskService taskService;
- private final TaskMapper taskMapper;
- private final CollectRosterMapper rosterMapper;
- private final StorageService storageService;
- private final SensitiveCodec codec;
- private final FileProcessService processService;
- private final CleanupService cleanupService;
-
- @Transactional(rollbackFor = Exception.class)
- public CleanupDtos.ImportVO importAsTask(Long jobId, CleanupDtos.ImportReq req) {
- LoginUser me = UserContext.require();
- CollectCleanupJob job = jobMapper.selectById(jobId);
- if (job == null) {
- throw new BizException(ErrorCode.VALIDATION_BLOCKED, "整理任务不存在或已删除");
- }
- if (job.getStatus() == null || job.getStatus() != CollectCleanupJob.STATUS_DONE) {
- throw new BizException(ErrorCode.VALIDATION_BLOCKED, "整理还没完成,暂时不能导入");
- }
- // ★ 参数校验必须在**幂等判断之前**。
- //
- // 原来它排在幂等分支之后,于是同一个整理任务第二次导入时,
- // 请求会先命中"已经导过了,直接返回原任务",参数校验压根没跑到 ——
- // `importFiles=true`(一期做不到)因此被安静地接受。
- // 一次不合法的请求不该因为"恰好之前导过一次"而变成成功。
- if (req == null || req.getTaskName() == null || req.getTaskName().isBlank()) {
- throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写新任务名称");
- }
- if (req.getBatchNo() == null || req.getBatchNo().isBlank()) {
- throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写批次号");
- }
- if (Boolean.TRUE.equals(req.getImportFiles())) {
- // 如实拒绝而不是安静忽略 —— 见类注释
- throw new BizException(ErrorCode.SCOPE_NOT_SUPPORTED,
- "一期导入整理结果时还不能自动挂文件(需要把每个文件按模板重新规范化后挂到填报单,"
- + "那条服务端入口还没做)。请先导入名册,再让学生/老师把文件传上来");
- }
- if (req.getCollectMode() != null && req.getCollectMode() != 1 && req.getCollectMode() != 2) {
- throw new BizException(ErrorCode.VALIDATION_BLOCKED,
- "collectMode 只能是 1(学校收集)或 2(自主认定)");
- }
-
- // ★ 幂等:已经导过就直接回原来的任务,不新建
- if (job.getTargetTaskId() != null) {
- var existed = taskMapper.selectById(job.getTargetTaskId());
- if (existed != null) {
- CleanupDtos.ImportVO vo = new CleanupDtos.ImportVO();
- vo.setTaskId(existed.getId());
- vo.setRosterCount((int) nz(rosterMapper.selectCount(
- Wrappers.lambdaQuery()
- .eq(CollectRoster::getTaskId, existed.getId()))));
- vo.setFileCount(0);
- log.info("整理结果重复导入,返回已有任务 jobId={} taskId={}", jobId, existed.getId());
- return vo;
- }
- }
-
- // 一期范围:只做学校收集(契约 §13.2 的 collectMode 也是 1/2)
- // 缺省给 1(学校收集):一期**只**支持它,而缺省值若是 2,
- // 不传这个字段的调用方会收到一个"你传了二期功能"的 40044 —— 那读起来像调用方的错。
- //
- // mode=2 由 `TaskService.create` 的 assertScopeSupported 拦(40044),
- // 不在这里再拦一遍:范围的权威定义只有一处,两处早晚会不一致。
- int mode = req.getCollectMode() == null ? 1 : req.getCollectMode();
-
- // 建任务(草稿状态:让用户先去核对名册与材料,确认后再发布)
- //
- // ⚠️ **必须走 TaskService.create,不能自己 insert**:
- // `collect_task.task_code` 是 NOT NULL 且由它生成,绕过它插入会报
- // `null value in column "task_code"`,而对外表现只是"系统繁忙"(50000)。
- // 更不能自己再写一份编码生成逻辑 —— 两处各生成一次编码迟早会撞号。
- TaskUpsertReq treq = new TaskUpsertReq();
- treq.setTaskName(req.getTaskName().trim());
- treq.setBatchNo(req.getBatchNo().trim());
- treq.setCollectMode(mode);
- // ⚠️ 一期范围是 collectMode=1(学校收集)+ **certType=1(自主认定)** ——
- // 权威定义在 `TaskService.assertScopeSupported`。
- // 我按"2=自主认定"的直觉写过 2,被 40044 拦下(拦得对)。
- treq.setCertType(1);
- treq.setMajorName(majorNameOf(job, req));
- treq.setStartTime(LocalDate.now().toString());
- treq.setEndTime(LocalDate.now().plusMonths(6).toString());
- if (job.getNamingTemplate() != null && !job.getNamingTemplate().isBlank()) {
- treq.setNamingTemplate(job.getNamingTemplate());
- }
- Long newTaskId = taskService.create(treq).getId();
-
- // 名册:按整理结果里"能确定是谁"的人建
- int rosterCount = 0;
- int skipped = 0;
- if (req.getImportRoster() == null || Boolean.TRUE.equals(req.getImportRoster())) {
- Map students = studentsOf(jobId, job);
- Set seen = new LinkedHashSet<>();
- for (Map.Entry en : students.entrySet()) {
- String name = en.getKey();
- String idCard = en.getValue()[0];
- String orgHint = en.getValue()[1];
- if (name == null || name.isBlank() || idCard == null || idCard.isBlank()) {
- skipped++;
- continue;
- }
- // 同一个人只建一行(历史包里同一个人常有多个文件夹)
- if (!seen.add(name + "|" + idCard)) {
- continue;
- }
- CollectRoster r = new CollectRoster();
- r.setTaskId(newTaskId);
- r.setRealName(name);
- // 三列一起写:密文 + hash(门禁靠它查)+ 脱敏值(展示)
- r.setIdCardNo(codec.encrypt(idCard));
- r.setIdCardHash(codec.hash(idCard));
- r.setIdCardMask(maskOf(idCard));
- r.setSeqNo(rosterCount + 1);
- r.setSubmitStatus(CollectRoster.SUBMIT_NONE);
- rosterMapper.insert(r);
- rosterCount++;
- }
- }
-
- CollectCleanupJob upd = new CollectCleanupJob();
- upd.setId(jobId);
- upd.setTargetTaskId(newTaskId);
- jobMapper.updateById(upd);
-
- log.info("整理结果导入为任务:jobId={} taskId={} 名册 {} 人(跳过 {})经办人={}",
- jobId, newTaskId, rosterCount, skipped, me.uid());
-
- CleanupDtos.ImportVO vo = new CleanupDtos.ImportVO();
- vo.setTaskId(newTaskId);
- vo.setRosterCount(rosterCount);
- vo.setFileCount(0);
- return vo;
- }
-
- /**
- * 从整理结果里取出「姓名 → (证件号, 班级线索)」。
- *
- * ⚠️ 直接从**整理结果包**里重新解析,而不是读报告 JSON:
- * 报告里的证件号是**脱敏值**(那是给人看的),拿它建名册会建成一堆星号。
- * 这条"报告不能当数据源"的界线要说清 —— 否则以后有人图省事去读报告。
- */
- private Map studentsOf(Long jobId, CollectCleanupJob job) {
- Map out = new LinkedHashMap<>();
- byte[] zip = storageService.getBytes(job.getResultPath());
- try (ZipInputStream zin = new ZipInputStream(new java.io.ByteArrayInputStream(zip),
- StandardCharsets.UTF_8)) {
- ZipEntry e;
- while ((e = zin.getNextEntry()) != null) {
- if (e.isDirectory()) {
- continue;
- }
- // 结果包的目录结构是我们自己定的:班级/姓名/材料/规范文件名
- // 而规范文件名里带证件号 → 从路径就能取回"姓名 + 证件号"
- HistoryNameParser.Hint h = HistoryNameParser.parse(e.getName());
- String name = h.studentName();
- if (name == null || h.idCard() == null) {
- continue;
- }
- out.putIfAbsent(name, new String[]{h.idCard(), h.orgHint()});
- drain(zin);
- }
- } catch (IOException ex) {
- throw new BizException(ErrorCode.VALIDATION_BLOCKED,
- "读整理结果失败:" + ex.getMessage());
- }
- return out;
- }
-
- private void drain(java.io.InputStream in) throws IOException {
- byte[] buf = new byte[8192];
- while (in.read(buf) > 0) {
- // 只为了推进 zip 流
- }
- }
-
- private long nz(Long v) {
- return v == null ? 0L : v;
- }
-
- /**
- * 新任务的"专业名称"从哪来。
- *
- * 三级回退:
- *
- * - 请求里显式给了就用它;
- * - 否则**继承关联名册任务的专业** —— 历史材料与那份名册本来就属于同一个专业,
- * 而这是最可信的来源(比让用户再填一遍更不容易填错);
- * - 都没有就明确报错,让用户填。
- *
- *
- * 为什么不猜一个默认值(比如"未分类"):专业名会进任务名、进导出汇总、
- * 也会被用来筛人。猜错的话错误会一路带到交付物里,而"让你现在填一下"只是一秒的事。
- */
- private String majorNameOf(CollectCleanupJob job, CleanupDtos.ImportReq req) {
- if (req.getMajorName() != null && !req.getMajorName().isBlank()) {
- return req.getMajorName().trim();
- }
- if (job.getRosterTaskId() != null) {
- var src = taskMapper.selectById(job.getRosterTaskId());
- if (src != null && src.getMajorName() != null && !src.getMajorName().isBlank()) {
- return src.getMajorName();
- }
- }
- throw new BizException(ErrorCode.VALIDATION_BLOCKED,
- "请填写专业名称(也可以给整理任务关联一份已有名册,专业名会从它继承)");
- }
-
- private String maskOf(String idCard) {
- if (idCard == null || idCard.length() < 14) {
- return idCard;
- }
- return idCard.substring(0, 6) + "********" + idCard.substring(idCard.length() - 4);
- }
-}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/service/CleanupService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/service/CleanupService.java
deleted file mode 100644
index 473e5a0..0000000
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/service/CleanupService.java
+++ /dev/null
@@ -1,765 +0,0 @@
-package cn.tianbaotong.fill.module.cleanup.service;
-
-import cn.hutool.json.JSONUtil;
-import cn.tianbaotong.fill.common.api.ErrorCode;
-import cn.tianbaotong.fill.common.context.LoginUser;
-import cn.tianbaotong.fill.common.context.UserContext;
-import cn.tianbaotong.fill.common.exception.BizException;
-import cn.tianbaotong.fill.common.util.IdCardUtil;
-import cn.tianbaotong.fill.module.cleanup.dto.CleanupDtos;
-import cn.tianbaotong.fill.module.cleanup.entity.CollectCleanupJob;
-import cn.tianbaotong.fill.module.cleanup.mapper.CleanupJobMapper;
-import cn.tianbaotong.fill.module.file.service.FileProcessService;
-import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
-import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
-import cn.tianbaotong.fill.support.IdentityNormalizer;
-import cn.tianbaotong.fill.support.TenantScope;
-import cn.tianbaotong.fill.support.SensitiveCodec;
-import com.baomidou.mybatisplus.core.toolkit.Wrappers;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.nio.charset.StandardCharsets;
-import java.security.MessageDigest;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.ExecutorService;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipInputStream;
-
-/**
- * 历史数据整理(《03-接口设计》§13.2)—— 差异化获客功能。
- *
- *
它解决的真实痛点
- *
- * 客户手里已经有几百个 G 的历史材料,形态是"人长出来的":
- * 一个班一个文件夹、里面一个人一个文件夹、文件名五花八门。
- * 要收进系统得先规范化,而**人工改名几百份是不可能的**。
- *
- *
这一版做什么 / 不做什么(必须说清,否则会被当成"功能不完整")
- *
- * 做:
- *
- * - 解压 zip → 逐条路径解析(姓名 / 证件号 / 材料类型 / 班级线索);
- * - 与名册比对:证件号一致 / 不一致 / 名册里没这个人;
- * - 疑似串档检测:某人的多项材料证件号与名册均不同且彼此一致 →
- * "整个文件夹可能被换过"(这是报告里最有价值的一条);
- * - 按模板生成规范文件名,产出整理后的 zip;
- * - 体检报告(含 {@code autoHandled / needManual} 两个关键数字)。
- *
- *
- * 不做(并且理由都不是"来不及"):
- *
- * - 不读图片内容(没有 OCR/VLM,M4 未做)——
- * 所以报告里的 {@code ocrValue} 一律为空,判定只用"文件名 + 名册"两条证据。
- * 编一个假的 OCR 结果会让 {@code idcardAutoFixed} 这个数字失去意义;
- * - 不做云盘链接({@code sourceType=3} → 40044)——
- * 要接网盘 SDK 与各家授权,一期不做;
- * - 不做文件夹上传({@code sourceType=2} → 40044)——
- * 浏览器上传文件夹要 {@code webkitdirectory} + 后端逐文件落盘,
- * 而现在客户手上给的形态就是压缩包。**但列已经建好了**(V13),
- * 以后接上不用再动表。
- *
- *
- * 为什么"有名单 vs 没名单"是两种完全不同的结果
- *
- * 没有 {@code rosterTaskId} 时只能做文件名规范化,报告里
- * {@code idcardMismatch / crossFileSuspected} 恒为 0 ——
- * 而这恰恰是客户愿意为这个功能付钱的那条信息。所以创建接口要**主动提示**
- * (见 {@code create} 里的 warnings),而不是安静地少做一半。
- */
-@Slf4j
-@Service
-@RequiredArgsConstructor
-public class CleanupService {
-
- /** 单个文件体积上限(解压炸弹防护):50MB。 */
- private static final long MAX_ENTRY_BYTES = 50L * 1024 * 1024;
-
- /** 整包解压后的总上限(解压炸弹防护):2GB。 */
- private static final long MAX_TOTAL_BYTES = 2L * 1024 * 1024 * 1024;
-
- /** 最多处理多少个文件(防止误传一个几万文件的包把服务拖死)。 */
- private static final int MAX_FILES = 20000;
-
- private final CleanupJobMapper jobMapper;
- private final CollectRosterMapper rosterMapper;
- private final SensitiveCodec codec;
- private final FileProcessService processService;
- private final cn.tianbaotong.fill.module.file.storage.StorageService storageService;
- private final ExecutorService exportExecutor;
-
- // ── 创建 ──────────────────────────────────────
-
- @Transactional(rollbackFor = Exception.class)
- public CleanupDtos.CreateVO create(CleanupDtos.CreateReq req, byte[] zipBytes) {
- LoginUser me = UserContext.require();
- if (req == null || req.getJobName() == null || req.getJobName().isBlank()) {
- throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写整理任务名称");
- }
- int sourceType = req.getSourceType() == null ? CollectCleanupJob.SOURCE_ZIP : req.getSourceType();
- if (sourceType == CollectCleanupJob.SOURCE_CLOUD || sourceType == CollectCleanupJob.SOURCE_FOLDER) {
- // 一期只做压缩包 —— 理由见类注释。**不静默降级**:
- // 安静地按"压缩包"处理一个云盘链接,用户会以为功能坏了
- throw new BizException(ErrorCode.SCOPE_NOT_SUPPORTED,
- sourceType == CollectCleanupJob.SOURCE_CLOUD
- ? "一期暂不支持云盘链接,请把材料打包成 zip 上传"
- : "一期暂不支持直接上传文件夹,请压缩成 zip 再上传");
- }
- if (zipBytes == null || zipBytes.length == 0) {
- throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请上传要整理的压缩包");
- }
- if (req.getRosterTaskId() != null) {
- // 名册必须真的存在,否则报告会安静地少掉最关键的两项
- Long n = rosterMapper.selectCount(Wrappers.lambdaQuery()
- .eq(CollectRoster::getTaskId, req.getRosterTaskId()));
- if (n == null || n == 0) {
- throw new BizException(ErrorCode.VALIDATION_BLOCKED,
- "指定的名册任务里一个人都没有,无法做证件号校验");
- }
- }
-
- // 压缩包落到存储里(整理是异步的,请求线程里的字节数组活不到那时)
- String rel = String.format("cleanup/%d/%d-src.zip", me.tenantId(), System.currentTimeMillis());
- storageService.put(rel, zipBytes, "application/zip");
-
- CollectCleanupJob job = new CollectCleanupJob();
- job.setJobName(req.getJobName().trim().length() > 120
- ? req.getJobName().trim().substring(0, 120) : req.getJobName().trim());
- job.setSourceType(sourceType);
- job.setSourcePath(rel);
- job.setRosterTaskId(req.getRosterTaskId());
- job.setNamingTemplate(req.getNamingTemplate() == null || req.getNamingTemplate().isBlank()
- ? "{姓名}-{证件号}-{材料名}" : req.getNamingTemplate().trim());
- job.setCompressConfig(req.getCompressConfig() == null
- ? null : JSONUtil.toJsonStr(req.getCompressConfig()));
- job.setStatus(CollectCleanupJob.STATUS_QUEUED);
- job.setProgress(0);
- jobMapper.insert(job);
-
- Long tenantId = me.tenantId();
- Long uid = me.uid();
- final Long jobId = job.getId();
- // ★ 异步要把租户上下文带过去:拦截器从 ThreadLocal 取 tenantId,
- // 新线程里没有 → 一句 SQL 都跑不了(表现为 40101 或"查不到数据")
- //
- // ⚠️ 并且要把 **job 对象本身**传进去,不要在异步线程里回查 ——
- // 异步线程与请求线程是两个事务,而请求线程此刻**还没提交**,
- // 回查必然查不到(表现是"整理任务不存在",整次整理白跑)。
- // 这类时序问题不能用 sleep 解决(它只是把概率变小),
- // 去掉"回查"这个动作才是真的解决。
- exportExecutor.execute(() -> TenantScope.runAs(tenantId, uid, Set.of(), () -> {
- try {
- run(job);
- } catch (Throwable e) {
- log.error("历史整理失败 jobId={}", jobId, e);
- markFailed(jobId, e);
- }
- }));
-
- CleanupDtos.CreateVO vo = new CleanupDtos.CreateVO();
- vo.setJobId(jobId);
- vo.setStatus(CollectCleanupJob.STATUS_QUEUED);
- // 估时:解压 + 解析 + 压缩,经验值约每 10 个文件 1 秒(下限 10 秒)
- vo.setEstimatedSeconds(Math.max(10, zipBytes.length / 1024 / 100));
- log.info("创建历史整理任务 jobId={} 名称={} 包体={}KB 名册任务={}",
- jobId, job.getJobName(), zipBytes.length / 1024, req.getRosterTaskId());
- return vo;
- }
-
- // ── 查询 ──────────────────────────────────────
-
- public CleanupDtos.JobPageVO list(Integer status, String keyword, int pageNum, int pageSize) {
- var q = Wrappers.lambdaQuery()
- .eq(status != null, CollectCleanupJob::getStatus, status)
- .like(keyword != null && !keyword.isBlank(), CollectCleanupJob::getJobName, keyword)
- .orderByDesc(CollectCleanupJob::getId);
- var page = jobMapper.selectPage(
- new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(
- Math.max(1, pageNum), Math.min(Math.max(1, pageSize), 100)), q);
- CleanupDtos.JobPageVO vo = new CleanupDtos.JobPageVO();
- vo.setList(page.getRecords().stream().map(this::toVo).toList());
- vo.setTotal(page.getTotal());
- return vo;
- }
-
- public CleanupDtos.JobVO detail(Long id) {
- CollectCleanupJob j = requireJob(id);
- return toVo(j);
- }
-
- public CleanupDtos.ReportVO report(Long id) {
- CollectCleanupJob j = requireJob(id);
- if (j.getStatus() == null || j.getStatus() != CollectCleanupJob.STATUS_DONE) {
- throw new BizException(ErrorCode.VALIDATION_BLOCKED,
- "整理还没完成(" + statusText(j.getStatus()) + "),暂时没有报告");
- }
- if (j.getReportJson() == null || j.getReportJson().isBlank()) {
- throw new BizException(ErrorCode.VALIDATION_BLOCKED, "这份整理没有留下报告");
- }
- try {
- return JSONUtil.toBean(j.getReportJson(), CleanupDtos.ReportVO.class);
- } catch (RuntimeException e) {
- throw new BizException(ErrorCode.VALIDATION_BLOCKED, "报告内容损坏,无法解析");
- }
- }
-
- /** 整理结果的压缩包字节(下载用)。 */
- public byte[] resultBytes(Long id) {
- CollectCleanupJob j = requireJob(id);
- if (j.getStatus() == null || j.getStatus() != CollectCleanupJob.STATUS_DONE
- || j.getResultPath() == null) {
- throw new BizException(ErrorCode.VALIDATION_BLOCKED, "整理结果还没生成");
- }
- return storageService.getBytes(j.getResultPath());
- }
-
- // ── 主体:解压 → 解析 → 比对 → 重打包 ────────────
-
- private void run(CollectCleanupJob job) {
- Long jobId = job.getId();
- progress(jobId, CollectCleanupJob.STATUS_PARSING, 5);
-
- byte[] zip = storageService.getBytes(job.getSourcePath());
- List entries = readZip(zip);
- if (entries.isEmpty()) {
- throw new IllegalStateException("压缩包里没有文件(是不是传了一个空包?)");
- }
-
- progress(jobId, CollectCleanupJob.STATUS_PARSING, 20);
-
- // 名册:有 rosterTaskId 才有。按"姓名 → 名册行"和"证件号 hash → 名册行"两路索引,
- // 因为解析出来的线索可能只有姓名(文件名里没号码)或只有号码
- Map byName = new HashMap<>();
- Map byHash = new HashMap<>();
- if (job.getRosterTaskId() != null) {
- for (CollectRoster r : rosterMapper.selectList(Wrappers.lambdaQuery()
- .eq(CollectRoster::getTaskId, job.getRosterTaskId()))) {
- if (r.getRealName() != null) {
- byName.putIfAbsent(r.getRealName().trim(), r);
- }
- if (r.getIdCardHash() != null) {
- byHash.putIfAbsent(r.getIdCardHash(), r);
- }
- }
- }
-
- List resolved = new ArrayList<>();
- Set usedNames = new java.util.LinkedHashSet<>();
- for (Entry e : entries) {
- resolved.add(resolve(e, byName, byHash, job.getNamingTemplate(), usedNames));
- }
-
- progress(jobId, CollectCleanupJob.STATUS_PROCESSING, 60);
-
- // 重打包(按解析出的"班级/姓名/材料"建目录,文件名用规范名)
- String outRel = String.format("cleanup/%d/%d-result.zip", UserContext.tenantId(), jobId);
- long finalBytes;
- try {
- finalBytes = writeResultZip(resolved);
- } catch (IOException ex) {
- throw new IllegalStateException("生成整理结果失败:" + ex.getMessage(), ex);
- }
- storageService.put(outRel, lastResult, "application/zip");
-
- // 体检报告
- CleanupDtos.ReportVO report = buildReport(jobId, resolved);
- long rawBytes = entries.stream().mapToLong(x -> x.bytes.length).sum();
- int students = (int) resolved.stream().map(Resolved::rosterId).filter(java.util.Objects::nonNull)
- .distinct().count();
- int materials = (int) resolved.stream().map(Resolved::materialCode)
- .filter(java.util.Objects::nonNull).distinct().count();
-
- CollectCleanupJob upd = new CollectCleanupJob();
- upd.setId(jobId);
- upd.setStatus(CollectCleanupJob.STATUS_DONE);
- upd.setProgress(100);
- upd.setTotalFiles(entries.size());
- upd.setProcessedFiles(resolved.size());
- upd.setFixedFiles((int) resolved.stream().filter(Resolved::renamed).count());
- upd.setFailedFiles((int) resolved.stream().filter(x -> x.rosterId == null).count());
- upd.setParsedStudents(students);
- upd.setParsedMaterials(materials);
- upd.setRawSizeBytes(rawBytes);
- upd.setFinalSizeBytes(finalBytes);
- upd.setResultPath(outRel);
- upd.setReportJson(JSONUtil.toJsonStr(report));
- upd.setFinishTime(java.time.LocalDateTime.now());
- jobMapper.updateById(upd);
-
- log.info("历史整理完成 jobId={} 文件={} 自动处理={} 待人工={} 包体 {}KB → {}KB",
- jobId, entries.size(), report.getAutoHandled(), report.getNeedManual(),
- rawBytes / 1024, finalBytes / 1024);
- }
-
- /** 一条 zip 条目。 */
- private record Entry(String path, byte[] bytes) {
- }
-
- /**
- * 读 zip 到内存。
- *
- * 为什么整包进内存而不是流式:整理要**两遍**(先解析全部、再按解析结果重打包),
- * 而 zip 不能随机访问(除非落盘再读)。客户的历史包按 PRD 的规模是几百 MB,
- * 内存够;但**必须有上限**,否则一个恶意的解压炸弹能把服务打死 ——
- * 见 {@link #MAX_TOTAL_BYTES} 与 {@link #MAX_ENTRY_BYTES}。
- */
- private List readZip(byte[] zip) {
- List out = new ArrayList<>();
- long total = 0;
- try (ZipInputStream zin = new ZipInputStream(new java.io.ByteArrayInputStream(zip),
- StandardCharsets.UTF_8)) {
- ZipEntry e;
- while ((e = zin.getNextEntry()) != null) {
- if (e.isDirectory()) {
- continue;
- }
- // 跳过 macOS 的元数据文件:客户用 Mac 压的包一定会带,混进来会让报告虚高
- String name = e.getName();
- if (name.startsWith("__MACOSX/") || name.endsWith(".DS_Store")
- || name.contains("/._")) {
- continue;
- }
- long declared = e.getSize();
- if (declared > MAX_ENTRY_BYTES) {
- throw new IllegalStateException("包里的单个文件超过 " + (MAX_ENTRY_BYTES / 1024 / 1024)
- + "MB:" + name + "(请先在本地压缩一下)");
- }
- byte[] data = readEntry(zin);
- if (data.length > MAX_ENTRY_BYTES) {
- throw new IllegalStateException("解压后单个文件过大:" + name);
- }
- total += data.length;
- if (total > MAX_TOTAL_BYTES) {
- throw new IllegalStateException("解压后总量超过 "
- + (MAX_TOTAL_BYTES / 1024 / 1024 / 1024) + "GB,请分批整理");
- }
- if (out.size() >= MAX_FILES) {
- throw new IllegalStateException("包里的文件超过 " + MAX_FILES + " 个,请分批整理");
- }
- out.add(new Entry(name, data));
- }
- } catch (IOException ex) {
- throw new IllegalStateException("压缩包读不了(是不是坏了或不是 zip):" + ex.getMessage(), ex);
- }
- return out;
- }
-
- private byte[] readEntry(InputStream in) throws IOException {
- ByteArrayOutputStream bos = new ByteArrayOutputStream();
- byte[] buf = new byte[8192];
- int n;
- // 边读边判上限:不能等读完再看长度(那时内存已经吃掉了)
- while ((n = in.read(buf)) > 0) {
- bos.write(buf, 0, n);
- if (bos.size() > MAX_ENTRY_BYTES) {
- throw new IOException("条目解压后超过 " + (MAX_ENTRY_BYTES / 1024 / 1024) + "MB");
- }
- }
- return bos.toByteArray();
- }
-
- /**
- * 一个文件的解析与比对结果。
- *
- * 用"普通类 + 字段"而不是 record:这个对象在解析过程中是**逐步填**的
- * (先填解析结果、再填名册匹配、最后填规范文件名),record 那种一次成型的形状不合适。
- *
- *
⚠️ 但有访问器:方法引用(`Resolved::rosterId`)需要它 ——
- * 直接引用字段在 Java 里是要写 lambda 的,而 lambda 在这里读起来更啰嗦。
- */
- private static final class Resolved {
- String path;
- byte[] bytes;
- String studentName;
- String idCard;
- String materialCode;
- Long rosterId;
- String rosterName;
- String orgHint;
- String standardName;
- boolean renamed;
- String issue;
-
- Long rosterId() {
- return rosterId;
- }
-
- String materialCode() {
- return materialCode;
- }
-
- boolean renamed() {
- return renamed;
- }
- }
-
- private Resolved resolve(Entry e, Map byName,
- Map byHash, String template,
- Set usedNames) {
- HistoryNameParser.Hint h = HistoryNameParser.parse(e.path());
- Resolved r = new Resolved();
- r.path = e.path();
- r.bytes = e.bytes();
- r.studentName = h.studentName();
- r.idCard = h.idCard();
- r.materialCode = h.materialCode();
- r.orgHint = h.orgHint();
-
- // 与名册比对:先用证件号(更可靠),再用姓名
- CollectRoster matched = null;
- if (r.idCard != null) {
- matched = byHash.get(codec.hash(r.idCard));
- }
- if (matched == null && r.studentName != null) {
- matched = byName.get(r.studentName);
- }
- if (matched != null) {
- r.rosterId = matched.getId();
- r.rosterName = matched.getRealName();
- // 证件号冲突:文件名里的号码与名册不一致。
- // ⚠️ 这里**只记冲突、不改数据** —— 改谁都需要证据(没有 OCR,
- // 我们只有"文件名"一条证据,而文件名本身就是要修的东西)
- if (r.idCard != null && matched.getIdCardHash() != null
- && !codec.hash(r.idCard).equals(matched.getIdCardHash())) {
- r.issue = "IDCARD_MISMATCH";
- }
- } else if (r.studentName != null || r.idCard != null) {
- r.issue = "ORPHAN";
- } else {
- r.issue = "ORPHAN";
- }
-
- // 规范文件名:优先用**名册**里的姓名与证件号(名册才是权威),
- // 名册里没有这个人时退回文件名里解析到的
- String name = r.rosterName != null ? r.rosterName : r.studentName;
- String idc = r.idCard;
- String matName = r.materialCode == null ? "待归位"
- : HistoryNameParser.materialNameOf(r.materialCode);
- String ext = extOf(e.path());
- Map vars = new LinkedHashMap<>();
- vars.put("姓名", name == null ? "未知姓名" : name);
- vars.put("证件号", idc == null ? "未知证件号" : idc);
- vars.put("材料名", matName);
- vars.put("班级", r.orgHint == null ? "" : r.orgHint);
- // ⚠️ ext 必须自带点号:renderName 的既有调用点是 `"." + ext`,
- // 传裸扩展名不会报错,只会产出"身份证jpg"这种打不开的文件
- String rendered = processService.renderName(template, vars, "." + ext);
- // 同名去重:历史包里"身份证(1).jpg"这种重复很常见,
- // 直接覆盖会让客户少一份材料(而报告上看起来一切正常)
- r.standardName = uniqueName(usedNames, rendered);
- r.renamed = !r.standardName.equals(lastSegment(e.path()));
- return r;
- }
-
- private static String lastSegment(String path) {
- String p = path.replace('\\', '/');
- int i = p.lastIndexOf('/');
- return i < 0 ? p : p.substring(i + 1);
- }
-
- private static String extOf(String path) {
- String s = lastSegment(path);
- int dot = s.lastIndexOf('.');
- return dot > 0 ? s.substring(dot + 1) : "bin";
- }
-
- private static String uniqueName(Set used, String name) {
- if (used.add(name)) {
- return name;
- }
- int dot = name.lastIndexOf('.');
- String base = dot > 0 ? name.substring(0, dot) : name;
- String ext = dot > 0 ? name.substring(dot) : "";
- for (int i = 2; i < 1000; i++) {
- String cand = base + "(" + i + ")" + ext;
- if (used.add(cand)) {
- return cand;
- }
- }
- return name;
- }
-
- /** 上一次生成的整理结果(供 put 用;避免在 record 里传引用的别扭写法)。 */
- private byte[] lastResult;
-
- private long writeResultZip(List resolved) throws IOException {
- ByteArrayOutputStream bos = new ByteArrayOutputStream();
- try (org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream zip =
- new org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream(bos)) {
- // ★ UTF-8 文件名:与导出包同口径。不设 EFS 标志位时 Windows 会按本地代码页猜,
- // 那个 bug 只在客户机器上出现
- zip.setEncoding(StandardCharsets.UTF_8.name());
- zip.setUseZip64(org.apache.commons.compress.archivers.zip.Zip64Mode.AsNeeded);
- for (Resolved r : resolved) {
- // 目录:班级/姓名/材料 —— 与导出包同一形状,客户拿到就能直接用
- String org = r.orgHint == null || r.orgHint.isBlank() ? "未分班" : r.orgHint;
- String name = r.rosterName != null ? r.rosterName
- : (r.studentName == null ? "未知姓名" : r.studentName);
- String mat = r.materialCode == null ? "待归位"
- : HistoryNameParser.materialNameOf(r.materialCode);
- String entryName = org + "/" + name + "/" + mat + "/" + r.standardName;
- var ze = new org.apache.commons.compress.archivers.zip.ZipArchiveEntry(entryName);
- ze.setSize(r.bytes.length);
- zip.putArchiveEntry(ze);
- zip.write(r.bytes);
- zip.closeArchiveEntry();
- }
- }
- byte[] out = bos.toByteArray();
- lastResult = out;
- return out.length;
- }
-
- /**
- * 体检报告。
- *
- * 两个关键数字必须算对:
- *
- * - {@code autoHandled}:系统全自动处理了 —— 即"规范化成功且能对上名册"的;
- *
- {@code needManual}:必须人工判断的 —— 串档 + 无法归位 + 待人工确认的证件号冲突。
- *
- * 契约里写明「报告里最重要的一句话是 autoHandled / needManual」,
- * 所以它们**由服务端算**,不让前端去减(前端减出来的数字早晚会分叉)。
- */
- private CleanupDtos.ReportVO buildReport(Long jobId, List resolved) {
- CleanupDtos.ReportVO vo = new CleanupDtos.ReportVO();
- vo.setJobId(jobId);
- CleanupDtos.ReportSummary s = vo.getSummary();
- s.setTotalFiles(resolved.size());
-
- // 串档检测:同一个人有 ≥2 项材料,**全部**与名册证件号不一致,
- // 且这些材料彼此一致(说明"整个文件夹被换过")
- Map> byRoster = new HashMap<>();
- for (Resolved r : resolved) {
- if (r.rosterId != null) {
- byRoster.computeIfAbsent(r.rosterId, k -> new ArrayList<>()).add(r);
- }
- }
- int auto = 0;
- int manual = 0;
- for (Resolved r : resolved) {
- if (r.issue == null) {
- auto++;
- } else if ("IDCARD_MISMATCH".equals(r.issue)) {
- s.setIdcardMismatch(s.getIdcardMismatch() + 1);
- s.setIdcardNeedConfirm(s.getIdcardNeedConfirm() + 1);
- manual++;
- } else {
- s.setOrphanImages(s.getOrphanImages() + 1);
- manual++;
- }
- }
-
- for (Map.Entry> en : byRoster.entrySet()) {
- List list = en.getValue();
- long mismatched = list.stream().filter(x -> "IDCARD_MISMATCH".equals(x.issue)).count();
- if (list.size() >= 2 && mismatched == list.size()) {
- s.setCrossFileSuspected(s.getCrossFileSuspected() + 1);
- CollectRoster ro = rosterMapper.selectById(en.getKey());
- CleanupDtos.Highlight hl = new CleanupDtos.Highlight();
- hl.setType("CROSS_FILE");
- hl.setLevel(3);
- hl.setRosterId(en.getKey());
- hl.setRealName(ro == null ? null : ro.getRealName());
- hl.setTitle("疑似串档");
- hl.setDetail("该生 " + list.size() + " 项材料的证件号与名册均不同,且 "
- + list.size() + " 项彼此一致");
- hl.setSuggestion("可能整个文件夹被换过,请核对学生本人");
- vo.getHighlights().add(hl);
- }
- }
-
- // 改名样例(给客户看"系统到底做了什么"):最多 5 条
- for (Resolved r : resolved) {
- if (vo.getNameFixedSamples().size() >= 5) {
- break;
- }
- if (r.renamed) {
- CleanupDtos.NameFixSample sample = new CleanupDtos.NameFixSample();
- sample.setOriginal(lastSegment(r.path));
- sample.setFixed(r.standardName);
- sample.setRule("VAL-06");
- vo.getNameFixedSamples().add(sample);
- }
- }
-
- // 证件号冲突明细
- for (Resolved r : resolved) {
- if (!"IDCARD_MISMATCH".equals(r.issue)) {
- continue;
- }
- CollectRoster ro = r.rosterId == null ? null : rosterMapper.selectById(r.rosterId);
- CleanupDtos.IdcardConflict c = new CleanupDtos.IdcardConflict();
- c.setRosterId(r.rosterId);
- c.setRealName(ro == null ? r.studentName : ro.getRealName());
- c.setRosterValue(ro == null ? null : ro.getIdCardMask());
- c.setFileNameValue(maskOf(r.idCard));
- // ⚠️ 判定只能是"待人工":没有 OCR,唯一的证据是文件名,
- // 而文件名本身正是这次要修的东西 —— 用它去改数据是循环论证
- c.setVerdict("NEED_CONFIRM");
- c.setVerdictText("文件名里的证件号与名册不一致;没有识别结果可交叉验证,请人工确认");
- vo.getIdcardConflicts().add(c);
- CleanupDtos.Highlight hl = new CleanupDtos.Highlight();
- hl.setType("IDCARD_MISMATCH");
- hl.setLevel(2);
- hl.setRosterId(r.rosterId);
- hl.setRealName(c.getRealName());
- hl.setTitle("证件号与名册不一致");
- hl.setDetail("文件名:" + c.getFileNameValue() + ",名册:" + c.getRosterValue());
- hl.setSuggestion("核对本人证件号后改名册或改文件名");
- vo.getHighlights().add(hl);
- }
-
- // 无法归位
- for (Resolved r : resolved) {
- if (!"ORPHAN".equals(r.issue)) {
- continue;
- }
- CleanupDtos.Highlight hl = new CleanupDtos.Highlight();
- hl.setType("ORPHAN");
- hl.setLevel(2);
- hl.setRealName(r.studentName);
- hl.setTitle("无法归位");
- hl.setDetail("从路径「" + r.path + "」里没能确定这是谁的材料");
- hl.setSuggestion("把文件名改成「姓名-证件号-材料名」或放进以姓名命名的文件夹");
- vo.getHighlights().add(hl);
- }
-
- vo.setAutoHandled(auto);
- vo.setNeedManual(manual);
- return vo;
- }
-
- private String maskOf(String idCard) {
- if (idCard == null || idCard.length() < 14) {
- return null;
- }
- return idCard.substring(0, 6) + "********" + idCard.substring(idCard.length() - 4);
- }
-
- // ── 内部 ──────────────────────────────────────
-
- private CollectCleanupJob requireJob(Long id) {
- CollectCleanupJob j = jobMapper.selectById(id);
- if (j == null) {
- throw new BizException(ErrorCode.VALIDATION_BLOCKED, "整理任务不存在或已删除");
- }
- return j;
- }
-
- private void progress(Long jobId, int status, int pct) {
- CollectCleanupJob upd = new CollectCleanupJob();
- upd.setId(jobId);
- upd.setStatus(status);
- upd.setProgress(pct);
- jobMapper.updateById(upd);
- }
-
- private void markFailed(Long jobId, Throwable e) {
- try {
- CollectCleanupJob upd = new CollectCleanupJob();
- upd.setId(jobId);
- upd.setStatus(CollectCleanupJob.STATUS_FAILED);
- upd.setProgress(0);
- String msg = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
- upd.setReportJson(JSONUtil.createObj().set("error", clip(msg, 400)).toString());
- upd.setFinishTime(java.time.LocalDateTime.now());
- jobMapper.updateById(upd);
- } catch (RuntimeException inner) {
- log.error("标记整理失败时又出错 jobId={}", jobId, inner);
- }
- }
-
- private static String clip(String s, int max) {
- return s == null ? null : (s.length() <= max ? s : s.substring(0, max));
- }
-
- private CleanupDtos.JobVO toVo(CollectCleanupJob j) {
- CleanupDtos.JobVO vo = new CleanupDtos.JobVO();
- vo.setId(j.getId());
- vo.setJobName(j.getJobName());
- vo.setSourceType(j.getSourceType());
- vo.setRosterTaskId(j.getRosterTaskId());
- vo.setStatus(j.getStatus());
- vo.setStatusText(statusText(j.getStatus()));
- vo.setProgress(j.getProgress());
- vo.setTotalFiles(j.getTotalFiles());
- vo.setProcessedFiles(j.getProcessedFiles());
- vo.setParsedStudents(j.getParsedStudents());
- vo.setParsedMaterials(j.getParsedMaterials());
- vo.setRawSizeBytes(j.getRawSizeBytes());
- vo.setFinalSizeBytes(j.getFinalSizeBytes());
- vo.setResultPath(j.getResultPath());
- vo.setTargetTaskId(j.getTargetTaskId());
- vo.setCreateTime(j.getCreateTime());
- vo.setFinishTime(j.getFinishTime());
- if (j.getRawSizeBytes() != null && j.getRawSizeBytes() > 0 && j.getFinalSizeBytes() != null) {
- double ratio = 100.0 * (1 - (double) j.getFinalSizeBytes() / j.getRawSizeBytes());
- if (ratio < 0) {
- // ⚠️ **负数压缩率是没有意义的,不能就这么显示出去。**
- //
- // 什么时候会负:包里的文件**本身就已经是压缩过的**(jpg/zip 里的图片),
- // 或者总量很小 —— 重新打一个 zip 的**包头开销**比省下来的还多。
- // 实测一个 3 个小文件(各 40 字节)的包:raw=120B、final=802B → `-568.3%`。
- //
- // 显示 `-568.3%` 的问题不是"不好看",而是**客户会以为系统算错了**
- // (而它没算错,"压缩"这个词在小包上本来就不适用)。
- // 所以这里给 `—` —— 与"还没算"同一个符号:**都是"这个数说明不了什么"**。
- vo.setCompressRatio("—");
- } else {
- vo.setCompressRatio(Math.round(ratio * 10) / 10.0 + "%");
- }
- } else {
- // — 而不是 0%:0% 看起来像"一点没压缩",而真相是"还没算"
- vo.setCompressRatio("—");
- }
- if (j.getStatus() != null && j.getStatus() == CollectCleanupJob.STATUS_FAILED
- && j.getReportJson() != null && j.getReportJson().contains("\"error\"")) {
- vo.setErrorMsg(JSONUtil.parseObj(j.getReportJson()).getStr("error"));
- }
- return vo;
- }
-
- private String statusText(Integer status) {
- return switch (status == null ? 0 : status) {
- case CollectCleanupJob.STATUS_QUEUED -> "排队中";
- case CollectCleanupJob.STATUS_PARSING -> "解析中";
- case CollectCleanupJob.STATUS_PROCESSING -> "处理中";
- case CollectCleanupJob.STATUS_DONE -> "已完成";
- case CollectCleanupJob.STATUS_FAILED -> "失败";
- default -> "未知";
- };
- }
-
- /** 供测试/排查用:只跑解析这一步(不落库、不打包)。 */
- public List