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 new file mode 100644 index 0000000..6abb02c --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/controller/CleanupController.java @@ -0,0 +1,155 @@ +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.TASK_MANAGE) + @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.TASK_MANAGE) + @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.TASK_MANAGE) + @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.TASK_MANAGE) + @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 new file mode 100644 index 0000000..ad945a5 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/dto/CleanupDtos.java @@ -0,0 +1,282 @@ +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 new file mode 100644 index 0000000..0838013 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/entity/CollectCleanupJob.java @@ -0,0 +1,89 @@ +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 new file mode 100644 index 0000000..ac05b37 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/mapper/CleanupJobMapper.java @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000..e9160e2 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/service/CleanupImportService.java @@ -0,0 +1,284 @@ +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`)。 + * + *

这个动作的分量:它把"一堆历史文件"变成"一份可审核的交付"

+ * + *

导入之后,客户拿到的是一个真实的任务:名册、材料、填报单、状态齐备, + * 可以走审核、可以导出。所以这里做的是"按解析结果重建一套数据", + * 而重建最容易出的两类错必须在这里挡住: + * + *

    + *
  1. 重复导入:客户手滑点两次是常事。多出来的任务很难被发现 + * (它会安静地躺在任务列表里),而"同一批人出现两次"会让导出重复计人。 + * 所以导入是**幂等**的(看 {@code target_task_id})。
  2. + *
  3. 证件号缺失:解析不出证件号的人**不能建名册行** —— + * `collect_roster.id_card_no` 是 NOT NULL 的密文列(F-19 那个坑), + * 而且没有证件号的人永远过不了门禁、也过不了名册校验。 + * 所以这类人**跳过并计入报告**,而不是塞一条空号的行进去。
  4. + *
+ * + *

一期能做到哪一步(说清边界,不假装完整)

+ * + *

能建:任务、名册(仅"解析出姓名 **且** 证件号"的人)、材料定义(按解析出的材料码)。 + * 不能建:填报单与挂文件 —— 那需要把整理结果里的每个文件重新走一遍 + * "压缩 + 按模板改名 + 挂到某个填报单",而一期还没有"把外部文件挂到指定填报单" + * 的服务端入口(代传接口要 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; + } + + /** + * 新任务的"专业名称"从哪来。 + * + *

三级回退: + *

    + *
  1. 请求里显式给了就用它;
  2. + *
  3. 否则**继承关联名册任务的专业** —— 历史材料与那份名册本来就属于同一个专业, + * 而这是最可信的来源(比让用户再填一遍更不容易填错);
  4. + *
  5. 都没有就明确报错,让用户填。
  6. + *
+ * + *

为什么不猜一个默认值(比如"未分类"):专业名会进任务名、进导出汇总、 + * 也会被用来筛人。猜错的话错误会一路带到交付物里,而"让你现在填一下"只是一秒的事。 + */ + 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 new file mode 100644 index 0000000..473e5a0 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/service/CleanupService.java @@ -0,0 +1,765 @@ +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 的历史材料,形态是"人长出来的": + * 一个班一个文件夹、里面一个人一个文件夹、文件名五花八门。 + * 要收进系统得先规范化,而**人工改名几百份是不可能的**。 + * + *

这一版做什么 / 不做什么(必须说清,否则会被当成"功能不完整")

+ * + *

做: + *

+ * + *

不做(并且理由都不是"来不及"): + *

+ * + *

为什么"有名单 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; + } + + /** + * 体检报告。 + * + *

两个关键数字必须算对: + *

+ * 契约里写明「报告里最重要的一句话是 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> dryRunParse(byte[] zip) { + List> out = new ArrayList<>(); + for (Entry e : readZip(zip)) { + HistoryNameParser.Hint h = HistoryNameParser.parse(e.path()); + Map m = new LinkedHashMap<>(); + m.put("path", e.path()); + m.put("studentName", h.studentName()); + m.put("idCard", h.idCard() == null ? null : maskOf(h.idCard())); + m.put("idCardValid", h.idCard() != null && IdCardUtil.isValid(h.idCard())); + m.put("materialCode", h.materialCode()); + m.put("orgHint", h.orgHint()); + out.add(m); + } + return out; + } + + /** 保留:给"文件夹来源"分支留的口子(一期走不到)。 */ + static String normalizePath(String p) { + return IdentityNormalizer.toHalfWidth(p).replace('\\', '/'); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/service/HistoryNameParser.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/service/HistoryNameParser.java new file mode 100644 index 0000000..343edac --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/cleanup/service/HistoryNameParser.java @@ -0,0 +1,296 @@ +package cn.tianbaotong.fill.module.cleanup.service; + +import cn.tianbaotong.fill.support.IdentityNormalizer; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 从**历史文件名与目录名**里猜出"这是谁、哪项材料"。 + * + *

这个类的全部难点:客户的历史文件夹是"人长出来的",不是机器生成的

+ * + *

实测(PRD §5.9 的背景)见到的形态: + *

+ *   24中技数控1班/张三/身份证.jpg.jpg
+ *   24中技数控1班/张三-361029200907295110/一寸照片.png
+ *   24数控/张三 身份证/IMG_2034.jpg
+ *   .\李四\学籍表\学籍表(1).JPG
+ * 
+ * 所以解析必须**从右往左找最有信息量的那段**,而不是"按固定层级取"—— + * 后者在换一个班之后一定失效。 + * + *

两条不准做的事

+ * + *
    + *
  1. 不猜身份证号:只认**完整 18 位且校验位正确**的。文件名里出现 + * 一串 18 位数字很可能是别的编号(学号、订单号),猜错会把材料挂到别人身上 —— + * 而"疑似串档"恰恰是这份报告最有价值的一条,不能自己先制造出来。
  2. + *
  3. 不把"猜不出"当成失败:猜不出就归到"待人工归位", + * 由报告里那一条 {@code needManual} 说清。硬塞一个分类会让报告撒谎。
  4. + *
+ */ +public final class HistoryNameParser { + + private HistoryNameParser() { + } + + /** 18 位身份证号(末位可为 X)。 */ + private static final Pattern ID_CARD = Pattern.compile("(\\d{17}[\\dXx])"); + + /** 材料关键词 → 材料名(客户的历史命名习惯,与内置材料的别名有重叠但不完全相同)。 */ + private static final String[][] MATERIAL_HINTS = { + {"身份证", "IDCARD"}, {"正面", "IDCARD"}, {"反面", "IDCARD"}, {"人像面", "IDCARD"}, + {"国徽面", "IDCARD"}, + {"一寸", "PHOTO1"}, {"照片", "PHOTO1"}, {"证件照", "PHOTO1"}, {"免冠", "PHOTO1"}, + {"学籍", "SCHOOL_ROLL"}, {"学籍表", "SCHOOL_ROLL"}, + {"工作证明", "WORK_PROOF"}, {"在职", "WORK_PROOF"}, {"证明", "WORK_PROOF"}, + {"备案", "XJW_BA"}, + {"其他", "OTHER_PROOF"}, {"其它", "OTHER_PROOF"}, {"佐证", "OTHER_PROOF"}, + }; + + /** 一个文件路径解析出来的线索。 */ + public record Hint(String studentName, String idCard, String materialCode, + String materialName, String orgHint, String fileName) { + } + + /** + * 解析一条相对路径。 + * + * @param relativePath 形如 {@code 24中技数控1班/张三/身份证.jpg} + */ + public static Hint parse(String relativePath) { + String p = relativePath.replace('\\', '/'); + String[] segs = p.split("/"); + // 去掉空段(形如 `./李四/...` 会切出一个空串) + List parts = new ArrayList<>(); + for (String s : segs) { + if (s != null && !s.isBlank() && !".".equals(s.trim())) { + parts.add(s.trim()); + } + } + if (parts.isEmpty()) { + return new Hint(null, null, null, null, null, null); + } + String fileName = parts.get(parts.size() - 1); + // 目录段(不含文件名) + List dirs = parts.size() > 1 ? parts.subList(0, parts.size() - 1) : List.of(); + + String idCard = null; + String name = null; + String orgHint = null; + + // ① 身份证号:从**文件名与所有目录段**里找第一个合法的 18 位 + // + // 为什么目录段也要找:常见的形态是"张三-361029200907295110"当文件夹名, + // 此时文件名只是 `身份证.jpg`。 + Set pool = new LinkedHashSet<>(); + pool.add(fileName); + pool.addAll(dirs); + for (String s : pool) { + Matcher m = ID_CARD.matcher(s); + while (m.find()) { + String cand = m.group(1); + if (cn.tianbaotong.fill.common.util.IdCardUtil.isValid(cand)) { + idCard = cand.toUpperCase(); + break; + } + } + if (idCard != null) { + break; + } + } + + // ② 姓名:优先"带身份证号的那一段"里、号码前面的中文 + // 其次取最靠近文件的目录名(班级 → 姓名 → 文件 是最常见的结构) + for (String s : pool) { + if (idCard != null && s.contains(idCard)) { + String before = s.substring(0, s.indexOf(idCard)); + name = chineseName(before); + if (name != null) { + break; + } + } + } + if (name == null && looksLikePersonSegment(stripExt(fileName))) { + // 从文件名里找(`张三-身份证.jpg`) + name = chineseName(stripExt(fileName)); + } + if (name == null && !dirs.isEmpty()) { + // 从最深的目录往上找第一个像姓名的(2~4 个汉字) + for (int i = dirs.size() - 1; i >= 0; i--) { + if (!looksLikePersonSegment(dirs.get(i))) { + continue; + } + String cand = chineseName(dirs.get(i)); + if (cand != null) { + name = cand; + break; + } + } + } + + // ③ 班级:最浅的那一层目录(通常就是班级),但**排除**看起来是姓名的那一层 + for (String s : dirs) { + if (name != null && s.contains(name)) { + continue; + } + if (s.contains("班") || s.contains("级") || s.contains("数控") + || s.contains("机电") || s.contains("专业")) { + orgHint = s; + break; + } + } + + // ④ 材料:在"文件名 + 各级目录名"里找关键词(**文件名优先**,它更具体) + String materialCode = null; + String materialName = null; + for (String s : pool) { + for (String[] kv : MATERIAL_HINTS) { + if (s.contains(kv[0])) { + materialCode = kv[1]; + materialName = kv[0]; + break; + } + } + if (materialCode != null) { + break; + } + } + + return new Hint(name, idCard, materialCode, materialName, orgHint, fileName); + } + + /** 这段文本是不是"材料文件夹名"(里面出现了材料关键词)。 */ + private static boolean looksLikeMaterial(String s) { + if (s == null) { + return false; + } + for (String[] kv : MATERIAL_HINTS) { + if (s.contains(kv[0])) { + return true; + } + } + return false; + } + + /** + /** + * 这个片段**整体**看起来像"一个人的名字"吗。 + * + *

与 {@link #chineseName} 的分工:这里判断"这段文本是不是人名所在的片段", + * 那里从片段里抠出候选字。两条排除规则: + *

    + *
  1. **含数字 → 不是人名**:"24中技数控1班" 会被抠出 "中技数控", + * 而那是班级不是人(真实人名里不会出现阿拉伯数字);
  2. + *
  3. **含班级/学校词 → 不是人名**("班"、"级"、"专业"、"学校"、"学院")。
  4. + *
+ */ + static boolean looksLikePersonSegment(String s) { + if (s == null || s.isBlank()) { + return false; + } + if (s.matches(".*\\d.*")) { + return false; + } + for (String marker : new String[]{"班", "级", "专业", "学校", "学院", "系", "组"}) { + if (s.contains(marker)) { + return false; + } + } + return true; + } + + /** + * 从一段文本里取"看起来是人名"的中文片段。 + * + *

规则保守:**2~4 个连续汉字**,且不包含材料/班级关键词。 + * 放宽到 1 个汉字会把"张"这种姓当成名字; + * 放宽到 5 个会把"数控加工中心"(专业名)当成名字。 + */ + static String chineseName(String s) { + if (s == null || s.isBlank()) { + return null; + } + // 去掉常见的分隔符与噪声 + String t = s.replaceAll("[-_()()【】\\[\\]\\s]+", ""); + // ⚠️ 取**完整的**中文串,不要 {2,4} 截断 —— + // "新建文件夹"(5 字)被截成 "新建文件" 之后,噪声词表里查不到它, + // 于是那个文件被归到"新建文件"名下,而报告上看起来还挺像回事。 + Matcher m = Pattern.compile("[\\u4e00-\\u9fa5]+").matcher(t); + while (m.find()) { + String cand = m.group(); + // 人名是 2~4 个汉字;连续中文超过 4 个字说明这是个标签("新建文件夹"),不是人 + if (cand.length() < 2 || cand.length() > 4) { + continue; + } + if (isNoiseWord(cand)) { + continue; + } + // 材料词("身份证"、"一寸照片"、"学籍表")出现在目录名里时, + // 它指的是"这是哪项材料",不是"这是谁" —— 由材料词表负责判断 + if (looksLikeMaterial(s)) { + continue; + } + return cand; + } + return null; + } + + /** + * 明显不是姓名的词(材料名 / 班级词 / 目录名)。 + * + *

⚠️ 判据必须收得很紧,因为中文人名与业务词高度重叠。 + * 我第一版写的是 {@code w.contains(n)},于是「整理甲」这种名字里 + * 只要含"整理"就被当噪声滤掉 —— 表现是**所有文件都归不了位**, + * 报告里冒出一堆 orphan,而串档检测也因此失去基准(全都对不上名册)。 + * + *

⚠️ 只做精确匹配,不做包含。我中间写过一版"长度 ≤2 的词被包含就算噪声", + * 而"整理"正好是 2 个字 —— 于是「整理甲」又被滤掉了。 + * 教训是:中文人名与业务短词无法用"包含"区分, + * 这条规则只要存在,早晚会吃掉一个真名,而失败是静默的(文件变成"无法归位")。 + * + *

"是不是材料/班级词"由 {@link #MATERIAL_HINTS} 与下面这份词表**各自**判断: + * 前者管材料目录名("身份证"、"一寸照片"),后者管纯噪声词。 + * 职责分开之后,这边只需要回答"这个字面量本身是不是一个业务词"。 + */ + private static boolean isNoiseWord(String w) { + String[] noise = {"身份证", "一寸", "照片", "学籍", "工作", "证明", "备案", "其他", "其它", + "佐证", "材料", "扫描", "复印", "副本", "正本", "原图", "最终", "新版", "旧版", + "中技", "数控", "机电", "专业", "班级", "学院", "学校", "高级", "中级", "初级", + "认定", "报名", "收集", "整理", "待整理", "已整理", "未整理", "补充", "补交", + // 目录名噪声:这些是"文件夹"而不是人,混进候选会让归位结果看起来可信但实际是错的 + "新建", "文件夹", "新建文件夹", "未命名", "待定", "临时", "归档", "历史", + "材料汇总", "待整理材料", "杂项", "其它材料", "其他材料"}; + for (String n : noise) { + if (w.equals(n)) { + return true; + } + } + return false; + } + + private static String stripExt(String s) { + int dot = s.lastIndexOf('.'); + return dot > 0 ? s.substring(0, dot) : s; + } + + /** 材料码 → 中文名(写进规范文件名用)。 */ + public static String materialNameOf(String code) { + for (String[] kv : MATERIAL_HINTS) { + if (kv[1].equals(code)) { + return kv[0]; + } + } + return code; + } + + /** 把解析到的证件号规范化(全角转半角 + 去空白),与其它入口同口径。 */ + public static String normalizeIdCard(String raw) { + return IdentityNormalizer.idCard(raw); + } +} diff --git a/backend/xt-server/src/main/resources/db/migration/V13__cleanup_job_audit.sql b/backend/xt-server/src/main/resources/db/migration/V13__cleanup_job_audit.sql new file mode 100644 index 0000000..fd003ee --- /dev/null +++ b/backend/xt-server/src/main/resources/db/migration/V13__cleanup_job_audit.sql @@ -0,0 +1,38 @@ +-- ============================================================================ +-- V13:历史整理任务补审计列 +-- +-- V1 建 `collect_cleanup_job` 时漏了 `update_time` / `deleted`,而实体要继承 +-- `BaseEntity`(那个基类带 @TableLogic 与自动填充)—— 少了这两列的表现是 +-- **运行时**才炸:查询补 `deleted = 0`、更新补 `update_time`,都会报 column does not exist。 +-- +-- 这是同一类错的第 N 次(见 F-24/F-35)。这次是 `scripts/check-entity-columns.py` +-- 在写代码**之前**就报出来的 —— 那个脚本的价值第一次体现在"还没踩坑就先拦住"。 +-- +-- 另外补两列业务字段: +-- · `roster_task_id` —— 契约 §13.2 的 `rosterTaskId`。**这本是关键参数** +-- (有名单才能做证件号校验与串档检测),而 V1 的表里没有它, +-- 于是"整理结果关联到哪个名册"这件事无处可存。 +-- · `source_url` —— `sourceType=3`(云盘链接)时的来源地址。 +-- 一期不做云盘(返回 40044),但列先留着:加列容易,事后补一次迁移要动生产库。 +-- ============================================================================ + +ALTER TABLE collect_cleanup_job ADD COLUMN IF NOT EXISTS create_by BIGINT; +ALTER TABLE collect_cleanup_job ADD COLUMN IF NOT EXISTS create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; +ALTER TABLE collect_cleanup_job ADD COLUMN IF NOT EXISTS update_by BIGINT; +ALTER TABLE collect_cleanup_job ADD COLUMN IF NOT EXISTS update_time TIMESTAMP; +ALTER TABLE collect_cleanup_job ADD COLUMN IF NOT EXISTS deleted SMALLINT NOT NULL DEFAULT 0; +ALTER TABLE collect_cleanup_job ADD COLUMN IF NOT EXISTS roster_task_id BIGINT; +ALTER TABLE collect_cleanup_job ADD COLUMN IF NOT EXISTS source_url VARCHAR(512); +ALTER TABLE collect_cleanup_job ADD COLUMN IF NOT EXISTS processed_files INTEGER; +ALTER TABLE collect_cleanup_job ADD COLUMN IF NOT EXISTS parsed_students INTEGER; +ALTER TABLE collect_cleanup_job ADD COLUMN IF NOT EXISTS parsed_materials INTEGER; +ALTER TABLE collect_cleanup_job ADD COLUMN IF NOT EXISTS raw_size_bytes BIGINT; +ALTER TABLE collect_cleanup_job ADD COLUMN IF NOT EXISTS final_size_bytes BIGINT; + +COMMENT ON COLUMN collect_cleanup_job.roster_task_id IS '关联的已有任务名册(契约 §13.2 的 rosterTaskId):**有名单才能做证件号校验与串档检测**'; +COMMENT ON COLUMN collect_cleanup_job.source_url IS 'sourceType=3(云盘链接)的来源地址;一期不启用'; +COMMENT ON COLUMN collect_cleanup_job.update_time IS '审计列(V1 漏了):实体继承 BaseEntity 就必须有它'; +COMMENT ON COLUMN collect_cleanup_job.deleted IS '审计列(V1 漏了):@TableLogic 会让查询自动补 deleted = 0'; + +-- 默认值兜底:老行(如果有)不会因为 NOT NULL 而报错 +UPDATE collect_cleanup_job SET update_time = create_time WHERE update_time IS NULL; diff --git a/frontend-pc/src/apis/xt/cleanup.ts b/frontend-pc/src/apis/xt/cleanup.ts new file mode 100644 index 0000000..c99be84 --- /dev/null +++ b/frontend-pc/src/apis/xt/cleanup.ts @@ -0,0 +1,264 @@ +/** + * 历史数据整理器(《03-接口设计》§13.2)。 + * + * ## 这个功能的"卖点"就是报告里的一句话 + * + * 契约里写明:**报告里最重要的一句话是 `autoHandled / needManual`** —— + * 「112 个文件里 90 个系统全自动处理,只有 1 个必须你亲自判断」。 + * 所以这一屏的核心不是"进度条",而是**那两个数字与重点清单**。 + * 进度只是等待时的安抚,报告才是客户付钱的理由。 + * + * ## 几个界面必须区分清楚的状态 + * + * | status | 界面该显示什么 | + * |---|---| + * | `0` 排队 / `1` 解析中 / `2` 处理中 | 进度条 + 已处理的文件数;**不给"下载"按钮**(还没有产物) | + * | `3` 已完成 | 报告 + 下载结果包 + 导入为正式任务 | + * | `4` 失败 | **`errorMsg` 必须显示出来** | + * + * ⚠️ 失败时 `errorMsg` 是唯一能让用户知道"为什么"的东西。常见原因是 + * "传了不是 zip 的文件"与"包里单个文件超过 50MB" —— 这两条都在服务端有明确文案, + * 界面要做的是**把它原样显示**,而不是笼统说"整理失败"。 + * + * ## 为什么 `rosterTaskId` 这个参数要在界面上强调 + * + * 契约 §13.2 的原话:**没有名册就只能做文件名规范化;有名单才能做证件号校验、 + * 疑似串档检测** —— 而"疑似串档 1 例"正是报告里最有价值的一条。 + * 也就是说:关联名册与不关联,"体检报告"是两份完全不同的东西。 + * 界面必须把这件事说在**上传之前**,而不是等报告出来发现那两项全是 0。 + */ + +import { getToken } from '@/utils/auth' +import http from '@/utils/http' + +/** 整理任务状态。 */ +export type CleanupStatus = 0 | 1 | 2 | 3 | 4 + +export interface CleanupJobVO { + id: number + jobName: string + /** `1` 压缩包 / `2` 文件夹 / `3` 云盘链接(一期只支持 `1`)。 */ + sourceType: number + /** 关联的已有名册任务。**有关联才有证件号校验与串档检测**。 */ + rosterTaskId?: number | null + status: CleanupStatus + statusText: string + progress: number + totalFiles?: number | null + processedFiles?: number | null + parsedStudents?: number | null + parsedMaterials?: number | null + rawSizeBytes?: number | null + finalSizeBytes?: number | null + /** 形如 `91.5%`;没算出来时是 `—`(**不是 0%** —— 0% 看起来像"没压缩")。 */ + compressRatio?: string + resultPath?: string | null + /** 导入成正式任务后的任务 id(有值就说明已导入,导入是幂等的)。 */ + targetTaskId?: number | null + errorMsg?: string | null + createTime?: string + finishTime?: string | null +} + +export interface CleanupJobPage { + list: CleanupJobVO[] + total: number +} + +/** 报告汇总。 */ +export interface ReportSummary { + totalFiles: number + nameInvalid: number + sizeExceeded: number + formatConverted: number + idcardMismatch: number + idcardAutoFixed: number + idcardNeedConfirm: number + /** 疑似串档:某人的**多项**材料证件号与名册均不同、且彼此一致 → 整个文件夹可能被换过。 */ + crossFileSuspected: number + lowResolution: number + orphanImages: number +} + +export interface Highlight { + type: string + /** `1` 提示 / `2` 警告 / `3` 必须人工。 */ + level: number + rosterId?: number | null + realName?: string | null + title: string + materialCode?: string | null + detail?: string | null + suggestion?: string | null +} + +export interface NameFixSample { + original: string + fixed: string + rule?: string +} + +export interface IdcardConflict { + rosterId?: number | null + realName?: string | null + rosterValue?: string | null + fileNameValue?: string | null + verdict: string + verdictText: string +} + +export interface CleanupReportVO { + jobId: number + summary: ReportSummary + /** 系统全自动处理了多少(**服务端算的**,不让前端去减)。 */ + autoHandled: number + /** 必须人工判断的(串档 + 无法归位 + 待确认的证件号冲突)。 */ + needManual: number + highlights: Highlight[] + nameFixedSamples: NameFixSample[] + idcardConflicts: IdcardConflict[] +} + +export interface CleanupImportVO { + taskId: number + rosterCount: number + fileCount: number +} + +export function getCleanupJobs(params: { + status?: number + keyword?: string + pageNum?: number + pageSize?: number +}) { + return http.get('/cleanup/jobs', params) +} + +export function getCleanupJob(id: number) { + return http.get(`/cleanup/jobs/${id}`) +} + +export function getCleanupReport(id: number) { + return http.get(`/cleanup/jobs/${id}/report`) +} + +/** + * 创建整理任务(multipart)。 + * + * ⚠️ 超时给到 600s:大包的解压 + 解析 + 重新打包都在服务端同步做, + * 而默认 30s 在几百 MB 的包上必然超时 —— 超时的表现是"点了没反应", + * 用户会重复点,于是同一个包被整理好几次。 + */ +export function createCleanupJob(file: File, opts: { + jobName: string + rosterTaskId?: number + namingTemplate?: string +}) { + const fd = new FormData() + fd.append('file', file) + fd.append('jobName', opts.jobName) + fd.append('sourceType', '1') + if (opts.rosterTaskId !== undefined) { + fd.append('rosterTaskId', String(opts.rosterTaskId)) + } + if (opts.namingTemplate) { + fd.append('namingTemplate', opts.namingTemplate) + } + // 不要手动设 Content-Type:axios 会带着 boundary 自己设 + return http.request<{ jobId: number, status: number, estimatedSeconds: number }>({ + method: 'post', + url: '/cleanup/jobs', + data: fd, + timeout: 600 * 1000 + }) +} + +/** + * 导入为正式任务。 + * + * ⚠️ 这条路**不能带 `importFiles`**:一期还做不到把整理结果里的文件自动挂到填报单, + * 而服务端会**如实拒绝**(`40044`)而不是安静忽略。界面因此不要给这个勾选项 —— + * 给了一个必然报错的选项,用户只会以为系统坏了。 + */ +export function importCleanupJob(id: number, data: { + taskName: string + collectMode?: number + batchNo: string + importRoster?: boolean + majorName?: string +}) { + return http.post(`/cleanup/jobs/${id}/import`, { + collectMode: 1, + importRoster: true, + ...data + }) +} + +/** + * 真正下载整理结果包。 + * + * ⚠️ 必须带 token 取 blob 再本地触发下载:`/cleanup/jobs/{id}/result` 是要登录的, + * 直接 `window.open` / `` 都不会带 `Authorization` 头, + * 用户会看到一个 401 页面(或"下载没反应")。 + * + * 放在 api 层而不是页面里,与导出中心的 `downloadExportFile` 保持同一写法 —— + * 同一件事在一个项目里有两处实现,改的时候一定会漏一处。 + */ +export async function downloadCleanupResult(id: number, jobName?: string): Promise { + const token = getToken() + const base = import.meta.env.VITE_API_PREFIX || '/api/v1' + const res = await fetch(`${base}/cleanup/jobs/${id}/result`, { + headers: token ? { Authorization: `Bearer ${token}` } : {} + }) + if (!res.ok) { + throw new Error(`下载失败(HTTP ${res.status})`) + } + const blob = await res.blob() + const href = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = href + a.download = `${jobName || '整理结果'}-${id}.zip` + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(href) +} + +/** 状态 → Tag 颜色(Arco 的 status 只认 normal/warning/success/danger)。 */ +export function statusColor(status: CleanupStatus): 'normal' | 'warning' | 'success' | 'danger' { + switch (status) { + case 3: + return 'success' + case 4: + return 'danger' + case 1: + case 2: + return 'warning' + default: + return 'normal' + } +} + +/** 重点条目的级别 → Tag 颜色。`3` 是"必须人工",用红色。 */ +export function levelColor(level: number): 'normal' | 'warning' | 'danger' { + if (level >= 3) return 'danger' + if (level === 2) return 'warning' + return 'normal' +} + +/** + * 报告里"最有说服力的一句话"。 + * + * 契约的原话就是这个格式:**「112 个文件里 90 个系统全自动处理, + * 只有 1 个必须你亲自判断」**。所以这句话由前端按同一口径拼 —— + * 但它拼的是**服务端算好的两个数**,不是自己减出来的 + * (前端减出来的数字早晚会与服务端分叉)。 + */ +export function headlineOf(report: CleanupReportVO): string { + const total = report.summary.totalFiles + const need = report.needManual + return `${total} 个文件里,${report.autoHandled} 个系统全自动处理,` + // ⚠️ 不要在这里写 Markdown 的 `**粗体**`:它落在**文本节点**里, + // 页面上会原样显示两个星号(要粗体得让调用方用富文本渲染) + + `${need > 0 ? `只有 ${need} 个必须你亲自判断` : '没有需要你判断的'}` +} diff --git a/frontend-pc/src/views/cleanup/index.vue b/frontend-pc/src/views/cleanup/index.vue new file mode 100644 index 0000000..4f1f37a --- /dev/null +++ b/frontend-pc/src/views/cleanup/index.vue @@ -0,0 +1,693 @@ + + + + +