feat(cleanup): 历史数据整理器(§13.2,V13)—— 解压解析、体检报告、导入为正式任务
从「人长出来的」历史路径里解析姓名/证件号/材料/班级 → 与名册比对 → 按模板重打包 → 体检报告(autoHandled / needManual 由服务端算,前端只展示,不自己减)→ 幂等导入。 F-48 的教训:中文人名与业务短词无法用「包含」区分——噪声词只做精确匹配, 候选名按「完整中文串 2~4 字 + 不含数字 + 不含班级词」判定。 F-49:参数校验必须排在幂等判断之前(否则不合法的请求会因「之前导过一次」变成成功)。 验证:e2e-m7-cleanup 54/54(含「坏包要明确失败」与「无名单时串档恒为 0」)。
This commit is contained in:
+155
@@ -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)—— 差异化获客功能。
|
||||
*
|
||||
* <p>权限用 {@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<CleanupDtos.CreateVO> 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<CleanupDtos.CreateVO> createJson(@RequestBody Map<String, Object> 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<CleanupDtos.JobPageVO> 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<CleanupDtos.JobVO> 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<CleanupDtos.ReportVO> report(@PathVariable Long id) {
|
||||
return ApiResult.ok(cleanupService.report(id));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/result")
|
||||
@RequirePerm(PermissionDef.EXPORT_DOWNLOAD)
|
||||
@Operation(summary = "下载整理结果(zip 流)")
|
||||
public ResponseEntity<byte[]> 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<CleanupDtos.ImportVO> 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<List<Map<String, Object>>> parsePreview(
|
||||
@RequestParam("file") MultipartFile file) throws IOException {
|
||||
return ApiResult.ok(cleanupService.dryRunParse(file.getBytes()));
|
||||
}
|
||||
}
|
||||
+282
@@ -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;
|
||||
|
||||
/**
|
||||
* 关联到**已有任务的名册**。
|
||||
*
|
||||
* <p>⚠️ 关键参数:没有名册就只能做文件名规范化;<b>有名单才能做证件号校验、
|
||||
* 疑似串档检测</b> —— 而"疑似串档 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<JobVO> 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;
|
||||
|
||||
/**
|
||||
* 疑似串档。
|
||||
*
|
||||
* <p>判据(契约 §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();
|
||||
|
||||
/**
|
||||
* 系统全自动处理了多少个文件。
|
||||
*
|
||||
* <p>契约里写明:<b>报告里最重要的一句话是 {@code autoHandled / needManual}</b>
|
||||
* ——「112 个文件里 90 个系统全自动处理,只有 1 个必须你亲自判断」。
|
||||
* 所以这两个数字**必须由服务端算出来**,不能让前端自己减。
|
||||
*/
|
||||
private int autoHandled;
|
||||
|
||||
private int needManual;
|
||||
|
||||
private List<Highlight> highlights = new ArrayList<>();
|
||||
|
||||
private List<NameFixSample> nameFixedSamples = new ArrayList<>();
|
||||
|
||||
private List<IdcardConflict> idcardConflicts = new ArrayList<>();
|
||||
}
|
||||
|
||||
/** 导入为正式任务。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "把整理结果导入为正式任务")
|
||||
public static class ImportReq {
|
||||
|
||||
private String taskName;
|
||||
|
||||
private Integer collectMode;
|
||||
|
||||
private String batchNo;
|
||||
|
||||
/**
|
||||
* 专业名称(选填)。
|
||||
*
|
||||
* <p>契约 §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;
|
||||
}
|
||||
}
|
||||
+89
@@ -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)。
|
||||
*
|
||||
* <p>⚠️ 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})。
|
||||
*
|
||||
* <p>⚠️ **这是关键参数**:没有名册就只能做文件名规范化;
|
||||
* <b>有名单才能做证件号校验、疑似串档检测</b> ——
|
||||
* 而"疑似串档 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;
|
||||
}
|
||||
+13
@@ -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;
|
||||
|
||||
/**
|
||||
* 历史整理任务。
|
||||
*
|
||||
* <p>只需要 MyBatis-Plus 的默认方法 —— 列表用 `selectPage` + 条件构造器就够了,
|
||||
* 不为一个查询再写一段手写 SQL(那会多一处要维护的口径)。
|
||||
*/
|
||||
public interface CleanupJobMapper extends BaseMapper<CollectCleanupJob> {
|
||||
}
|
||||
+284
@@ -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`)。
|
||||
*
|
||||
* <h3>这个动作的分量:它把"一堆历史文件"变成"一份可审核的交付"</h3>
|
||||
*
|
||||
* <p>导入之后,客户拿到的是一个真实的任务:名册、材料、填报单、状态齐备,
|
||||
* 可以走审核、可以导出。所以这里做的是"按解析结果重建一套数据",
|
||||
* 而重建最容易出的两类错必须在这里挡住:
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>重复导入</b>:客户手滑点两次是常事。多出来的任务很难被发现
|
||||
* (它会安静地躺在任务列表里),而"同一批人出现两次"会让导出重复计人。
|
||||
* 所以导入是**幂等**的(看 {@code target_task_id})。</li>
|
||||
* <li><b>证件号缺失</b>:解析不出证件号的人**不能建名册行** ——
|
||||
* `collect_roster.id_card_no` 是 NOT NULL 的密文列(F-19 那个坑),
|
||||
* 而且没有证件号的人永远过不了门禁、也过不了名册校验。
|
||||
* 所以这类人**跳过并计入报告**,而不是塞一条空号的行进去。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <h3>一期能做到哪一步(说清边界,不假装完整)</h3>
|
||||
*
|
||||
* <p>能建:任务、名册(仅"解析出姓名 **且** 证件号"的人)、材料定义(按解析出的材料码)。
|
||||
* <b>不能建</b>:填报单与挂文件 —— 那需要把整理结果里的每个文件重新走一遍
|
||||
* "压缩 + 按模板改名 + 挂到某个填报单",而一期还没有"把外部文件挂到指定填报单"
|
||||
* 的服务端入口(代传接口要 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.<CollectRoster>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<String, String[]> students = studentsOf(jobId, job);
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
for (Map.Entry<String, String[]> 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从整理结果里取出「姓名 → (证件号, 班级线索)」。
|
||||
*
|
||||
* <p>⚠️ 直接从**整理结果包**里重新解析,而不是读报告 JSON:
|
||||
* 报告里的证件号是**脱敏值**(那是给人看的),拿它建名册会建成一堆星号。
|
||||
* 这条"报告不能当数据源"的界线要说清 —— 否则以后有人图省事去读报告。
|
||||
*/
|
||||
private Map<String, String[]> studentsOf(Long jobId, CollectCleanupJob job) {
|
||||
Map<String, String[]> 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 新任务的"专业名称"从哪来。
|
||||
*
|
||||
* <p>三级回退:
|
||||
* <ol>
|
||||
* <li>请求里显式给了就用它;</li>
|
||||
* <li>否则**继承关联名册任务的专业** —— 历史材料与那份名册本来就属于同一个专业,
|
||||
* 而这是最可信的来源(比让用户再填一遍更不容易填错);</li>
|
||||
* <li>都没有就明确报错,让用户填。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>为什么不猜一个默认值(比如"未分类"):专业名会进任务名、进导出汇总、
|
||||
* 也会被用来筛人。猜错的话错误会一路带到交付物里,而"让你现在填一下"只是一秒的事。
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
+765
@@ -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)—— 差异化获客功能。
|
||||
*
|
||||
* <h3>它解决的真实痛点</h3>
|
||||
*
|
||||
* <p>客户手里已经有几百个 G 的历史材料,形态是"人长出来的":
|
||||
* 一个班一个文件夹、里面一个人一个文件夹、文件名五花八门。
|
||||
* 要收进系统得先规范化,而**人工改名几百份是不可能的**。
|
||||
*
|
||||
* <h3>这一版做什么 / 不做什么(必须说清,否则会被当成"功能不完整")</h3>
|
||||
*
|
||||
* <p><b>做</b>:
|
||||
* <ul>
|
||||
* <li>解压 zip → 逐条路径解析(姓名 / 证件号 / 材料类型 / 班级线索);</li>
|
||||
* <li>与名册比对:证件号一致 / 不一致 / 名册里没这个人;</li>
|
||||
* <li><b>疑似串档检测</b>:某人的多项材料证件号与名册<b>均不同</b>且彼此一致 →
|
||||
* "整个文件夹可能被换过"(这是报告里最有价值的一条);</li>
|
||||
* <li>按模板生成规范文件名,产出整理后的 zip;</li>
|
||||
* <li>体检报告(含 {@code autoHandled / needManual} 两个关键数字)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>不做</b>(并且理由都不是"来不及"):
|
||||
* <ul>
|
||||
* <li><b>不读图片内容</b>(没有 OCR/VLM,M4 未做)——
|
||||
* 所以报告里的 {@code ocrValue} 一律为空,判定只用"文件名 + 名册"两条证据。
|
||||
* 编一个假的 OCR 结果会让 {@code idcardAutoFixed} 这个数字失去意义;</li>
|
||||
* <li><b>不做云盘链接</b>({@code sourceType=3} → 40044)——
|
||||
* 要接网盘 SDK 与各家授权,一期不做;</li>
|
||||
* <li><b>不做文件夹上传</b>({@code sourceType=2} → 40044)——
|
||||
* 浏览器上传文件夹要 {@code webkitdirectory} + 后端逐文件落盘,
|
||||
* 而现在客户手上给的形态就是压缩包。**但列已经建好了**(V13),
|
||||
* 以后接上不用再动表。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>为什么"有名单 vs 没名单"是两种完全不同的结果</h3>
|
||||
*
|
||||
* <p>没有 {@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.<CollectRoster>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.<CollectCleanupJob>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<Entry> entries = readZip(zip);
|
||||
if (entries.isEmpty()) {
|
||||
throw new IllegalStateException("压缩包里没有文件(是不是传了一个空包?)");
|
||||
}
|
||||
|
||||
progress(jobId, CollectCleanupJob.STATUS_PARSING, 20);
|
||||
|
||||
// 名册:有 rosterTaskId 才有。按"姓名 → 名册行"和"证件号 hash → 名册行"两路索引,
|
||||
// 因为解析出来的线索可能只有姓名(文件名里没号码)或只有号码
|
||||
Map<String, CollectRoster> byName = new HashMap<>();
|
||||
Map<String, CollectRoster> byHash = new HashMap<>();
|
||||
if (job.getRosterTaskId() != null) {
|
||||
for (CollectRoster r : rosterMapper.selectList(Wrappers.<CollectRoster>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> resolved = new ArrayList<>();
|
||||
Set<String> 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 到内存。
|
||||
*
|
||||
* <p>为什么整包进内存而不是流式:整理要**两遍**(先解析全部、再按解析结果重打包),
|
||||
* 而 zip 不能随机访问(除非落盘再读)。客户的历史包按 PRD 的规模是几百 MB,
|
||||
* 内存够;但**必须有上限**,否则一个恶意的解压炸弹能把服务打死 ——
|
||||
* 见 {@link #MAX_TOTAL_BYTES} 与 {@link #MAX_ENTRY_BYTES}。
|
||||
*/
|
||||
private List<Entry> readZip(byte[] zip) {
|
||||
List<Entry> 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个文件的解析与比对结果。
|
||||
*
|
||||
* <p>用"普通类 + 字段"而不是 record:这个对象在解析过程中是**逐步填**的
|
||||
* (先填解析结果、再填名册匹配、最后填规范文件名),record 那种一次成型的形状不合适。
|
||||
*
|
||||
* <p>⚠️ 但有访问器:方法引用(`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<String, CollectRoster> byName,
|
||||
Map<String, CollectRoster> byHash, String template,
|
||||
Set<String> 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<String, String> 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<String> 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> 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 体检报告。
|
||||
*
|
||||
* <p>两个关键数字必须算对:
|
||||
* <ul>
|
||||
* <li>{@code autoHandled}:系统全自动处理了 —— 即"规范化成功且能对上名册"的;
|
||||
* <li>{@code needManual}:必须人工判断的 —— 串档 + 无法归位 + 待人工确认的证件号冲突。
|
||||
* </ul>
|
||||
* 契约里写明「报告里最重要的一句话是 autoHandled / needManual」,
|
||||
* 所以它们**由服务端算**,不让前端去减(前端减出来的数字早晚会分叉)。
|
||||
*/
|
||||
private CleanupDtos.ReportVO buildReport(Long jobId, List<Resolved> resolved) {
|
||||
CleanupDtos.ReportVO vo = new CleanupDtos.ReportVO();
|
||||
vo.setJobId(jobId);
|
||||
CleanupDtos.ReportSummary s = vo.getSummary();
|
||||
s.setTotalFiles(resolved.size());
|
||||
|
||||
// 串档检测:同一个人有 ≥2 项材料,**全部**与名册证件号不一致,
|
||||
// 且这些材料彼此一致(说明"整个文件夹被换过")
|
||||
Map<Long, List<Resolved>> 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<Long, List<Resolved>> en : byRoster.entrySet()) {
|
||||
List<Resolved> 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<Map<String, Object>> dryRunParse(byte[] zip) {
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (Entry e : readZip(zip)) {
|
||||
HistoryNameParser.Hint h = HistoryNameParser.parse(e.path());
|
||||
Map<String, Object> 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('\\', '/');
|
||||
}
|
||||
}
|
||||
+296
@@ -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;
|
||||
|
||||
/**
|
||||
* 从**历史文件名与目录名**里猜出"这是谁、哪项材料"。
|
||||
*
|
||||
* <h3>这个类的全部难点:客户的历史文件夹是"人长出来的",不是机器生成的</h3>
|
||||
*
|
||||
* <p>实测(PRD §5.9 的背景)见到的形态:
|
||||
* <pre>
|
||||
* 24中技数控1班/张三/身份证.jpg.jpg
|
||||
* 24中技数控1班/张三-361029200907295110/一寸照片.png
|
||||
* 24数控/张三 身份证/IMG_2034.jpg
|
||||
* .\李四\学籍表\学籍表(1).JPG
|
||||
* </pre>
|
||||
* 所以解析必须**从右往左找最有信息量的那段**,而不是"按固定层级取"——
|
||||
* 后者在换一个班之后一定失效。
|
||||
*
|
||||
* <h3>两条不准做的事</h3>
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>不猜身份证号</b>:只认**完整 18 位且校验位正确**的。文件名里出现
|
||||
* 一串 18 位数字很可能是别的编号(学号、订单号),猜错会把材料挂到别人身上 ——
|
||||
* 而"疑似串档"恰恰是这份报告最有价值的一条,不能自己先制造出来。</li>
|
||||
* <li><b>不把"猜不出"当成失败</b>:猜不出就归到"待人工归位",
|
||||
* 由报告里那一条 {@code needManual} 说清。硬塞一个分类会让报告撒谎。</li>
|
||||
* </ol>
|
||||
*/
|
||||
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<String> 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<String> 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<String> 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;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 这个片段**整体**看起来像"一个人的名字"吗。
|
||||
*
|
||||
* <p>与 {@link #chineseName} 的分工:这里判断"这段文本是不是人名所在的片段",
|
||||
* 那里从片段里抠出候选字。两条排除规则:
|
||||
* <ol>
|
||||
* <li>**含数字 → 不是人名**:"24中技数控1班" 会被抠出 "中技数控",
|
||||
* 而那是班级不是人(真实人名里不会出现阿拉伯数字);</li>
|
||||
* <li>**含班级/学校词 → 不是人名**("班"、"级"、"专业"、"学校"、"学院")。</li>
|
||||
* </ol>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从一段文本里取"看起来是人名"的中文片段。
|
||||
*
|
||||
* <p>规则保守:**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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 明显不是姓名的词(材料名 / 班级词 / 目录名)。
|
||||
*
|
||||
* <p>⚠️ <b>判据必须收得很紧,因为中文人名与业务词高度重叠</b>。
|
||||
* 我第一版写的是 {@code w.contains(n)},于是「<b>整理</b>甲」这种名字里
|
||||
* 只要含"整理"就被当噪声滤掉 —— 表现是**所有文件都归不了位**,
|
||||
* 报告里冒出一堆 orphan,而串档检测也因此失去基准(全都对不上名册)。
|
||||
*
|
||||
* <p>⚠️ <b>只做精确匹配,不做包含</b>。我中间写过一版"长度 ≤2 的词被包含就算噪声",
|
||||
* 而"整理"正好是 2 个字 —— 于是「<b>整理</b>甲」又被滤掉了。
|
||||
* 教训是:<b>中文人名与业务短词无法用"包含"区分</b>,
|
||||
* 这条规则只要存在,早晚会吃掉一个真名,而失败是静默的(文件变成"无法归位")。
|
||||
*
|
||||
* <p>"是不是材料/班级词"由 {@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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<CleanupJobPage>('/cleanup/jobs', params)
|
||||
}
|
||||
|
||||
export function getCleanupJob(id: number) {
|
||||
return http.get<CleanupJobVO>(`/cleanup/jobs/${id}`)
|
||||
}
|
||||
|
||||
export function getCleanupReport(id: number) {
|
||||
return http.get<CleanupReportVO>(`/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<CleanupImportVO>(`/cleanup/jobs/${id}/import`, {
|
||||
collectMode: 1,
|
||||
importRoster: true,
|
||||
...data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 真正下载整理结果包。
|
||||
*
|
||||
* ⚠️ 必须带 token 取 blob 再本地触发下载:`/cleanup/jobs/{id}/result` 是要登录的,
|
||||
* 直接 `window.open` / `<a href>` 都不会带 `Authorization` 头,
|
||||
* 用户会看到一个 401 页面(或"下载没反应")。
|
||||
*
|
||||
* 放在 api 层而不是页面里,与导出中心的 `downloadExportFile` 保持同一写法 ——
|
||||
* 同一件事在一个项目里有两处实现,改的时候一定会漏一处。
|
||||
*/
|
||||
export async function downloadCleanupResult(id: number, jobName?: string): Promise<void> {
|
||||
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} 个必须你亲自判断` : '没有需要你判断的'}`
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
<template>
|
||||
<GiPageLayout margin collapsed>
|
||||
<a-row justify="space-between" class="g-row-tool">
|
||||
<a-space wrap>
|
||||
<!-- ⚠️ GiButton **没有 `text` prop**:文案要么用 type 的预设(新增/编辑/…),
|
||||
要么用**默认插槽**。写成 `text="..."` 不会报错,但页面上显示的是预设文案 -->
|
||||
<GiButton type="upload" @click="openCreate">上传压缩包整理</GiButton>
|
||||
<a-button @click="loadRecords">刷新</a-button>
|
||||
</a-space>
|
||||
<a-space wrap>
|
||||
<a-input-search
|
||||
v-model="query.keyword"
|
||||
placeholder="按整理任务名称搜索"
|
||||
allow-clear
|
||||
style="width: 240px"
|
||||
@search="onSearch"
|
||||
/>
|
||||
<a-select
|
||||
v-model="query.status"
|
||||
placeholder="全部状态"
|
||||
allow-clear
|
||||
style="width: 140px"
|
||||
:options="STATUS_OPTIONS"
|
||||
@change="onSearch"
|
||||
/>
|
||||
<a-button @click="onReset">重置</a-button>
|
||||
</a-space>
|
||||
</a-row>
|
||||
|
||||
<a-table
|
||||
:data="records"
|
||||
:loading="listLoading"
|
||||
row-key="id"
|
||||
:pagination="pagination"
|
||||
:bordered="{ cell: true }"
|
||||
:scroll="{ x: 1100 }"
|
||||
@page-change="onPageChange"
|
||||
>
|
||||
<template #columns>
|
||||
<a-table-column title="序号" :width="68" align="center">
|
||||
<template #cell="{ rowIndex }">{{ rowIndex + 1 }}</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="整理任务" data-index="jobName" :width="240" ellipsis tooltip />
|
||||
<a-table-column title="状态" :width="110" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="statusColor(record.status)">{{ record.statusText }}</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="进度" :width="150">
|
||||
<template #cell="{ record }">
|
||||
<span v-if="record.status === 3">{{ record.totalFiles ?? 0 }} 个文件</span>
|
||||
<span v-else-if="record.status === 4" class="cl-dim">—</span>
|
||||
<a-progress v-else :percent="(record.progress || 0) / 100" size="small" />
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="压缩率" :width="100" align="center">
|
||||
<!--
|
||||
⚠️ `—` 有**两个**含义,都不要替换成 0%:
|
||||
① 还没算出来(任务在跑);
|
||||
② 这个包**本来就不适用压缩** —— 里面的文件已经是压过的(jpg),
|
||||
或总量太小,重新打 zip 的包头开销比省下来的还多。
|
||||
实测 3 个 40 字节的小文件:raw=120B、final=802B,
|
||||
按公式算是 `-568.3%` —— **负数压缩率是没有意义的**,
|
||||
所以服务端也回 `—`(同一个符号,都是"这个数说明不了什么")。
|
||||
-->
|
||||
<template #cell="{ record }">{{ record.compressRatio || '—' }}</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="结果" :width="110" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag v-if="record.status === 3 && record.targetTaskId" color="success">已导入</a-tag>
|
||||
<a-tag v-else-if="record.status === 3" color="arcoblue">待导入</a-tag>
|
||||
<a-tag v-else-if="record.status === 4" color="danger">失败</a-tag>
|
||||
<span v-else class="cl-dim">—</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="创建时间" data-index="createTime" :width="170" />
|
||||
<a-table-column title="操作" :width="90" align="center" fixed="right">
|
||||
<template #cell="{ record }">
|
||||
<a-button type="text" size="mini" @click="openDetail(record)">详情</a-button>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!--
|
||||
上传弹窗。**刻意先选文件、再填名字**:用户的动作顺序是"我有个包要整理",
|
||||
而"整理任务名叫什么"是他看到包之后才想的事。
|
||||
-->
|
||||
<a-modal
|
||||
v-model:visible="createVisible"
|
||||
title="上传压缩包整理"
|
||||
:mask-closable="false"
|
||||
:ok-loading="creating"
|
||||
ok-text="开始整理"
|
||||
@before-ok="submitCreate"
|
||||
@close="resetCreate"
|
||||
>
|
||||
<a-form :model="createForm" layout="vertical">
|
||||
<a-form-item field="file" label="压缩包(zip)" required>
|
||||
<a-upload :auto-upload="false" :limit="1" accept=".zip" @change="onFileChange" />
|
||||
<template #extra>
|
||||
<div class="cl-tip">
|
||||
包里单个文件不超过 50MB、总量不超过 2GB;macOS 的
|
||||
<code>__MACOSX</code> 会被自动忽略(否则报告里的文件数会虚高)。
|
||||
</div>
|
||||
</template>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="jobName" label="整理任务名称" required>
|
||||
<a-input v-model="createForm.jobName" placeholder="如 24中技数控1班-历史整理" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="rosterTaskId" label="关联到已有任务的名册">
|
||||
<a-select
|
||||
v-model="createForm.rosterTaskId"
|
||||
placeholder="不关联也能跑,但会少两项关键检查"
|
||||
allow-clear
|
||||
allow-search
|
||||
:loading="taskLoading"
|
||||
:options="taskOptions"
|
||||
/>
|
||||
<!--
|
||||
⚠️ 这句是这一屏最该说清的话(契约 §13.2 的原话):
|
||||
没有名册就只能做文件名规范化;**有名单才能做证件号校验、疑似串档检测** ——
|
||||
而"疑似串档 1 例"正是报告里最有价值的一条。
|
||||
不说清的话,用户会在报告里看到那两项是 0,然后以为功能没做。
|
||||
-->
|
||||
<template #extra>
|
||||
<div class="cl-tip">
|
||||
<strong>有关联才有证件号校验与串档检测</strong> ——
|
||||
「疑似串档 1 例」是体检报告里最有价值的一条,而没有名册就无从比对。
|
||||
</div>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 详情:进度 / 报告 / 导入 -->
|
||||
<a-drawer
|
||||
v-model:visible="detailVisible"
|
||||
:width="760"
|
||||
:title="detail?.jobName || '整理详情'"
|
||||
:footer="false"
|
||||
unmount-on-close
|
||||
@close="stopPolling"
|
||||
>
|
||||
<a-spin :loading="detailLoading" style="display: block">
|
||||
<template v-if="detail">
|
||||
<a-descriptions :column="2" bordered size="medium">
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="statusColor(detail.status)">{{ detail.statusText }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="进度">{{ detail.progress }}%</a-descriptions-item>
|
||||
<a-descriptions-item label="文件数">{{ detail.totalFiles ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="压缩率">
|
||||
{{ detail.compressRatio || '—' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="解析出学员">
|
||||
{{ detail.parsedStudents ?? '—' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="材料种类">
|
||||
{{ detail.parsedMaterials ?? '—' }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<!--
|
||||
失败时**必须把 errorMsg 原样显示**:它是唯一能让用户知道"为什么"的东西。
|
||||
常见原因(不是 zip、单文件超 50MB)服务端都有明确文案,
|
||||
换成笼统的"整理失败"等于把已经写好的解释扔掉。
|
||||
-->
|
||||
<a-alert v-if="detail.status === 4" type="error" class="cl-alert" title="整理失败">
|
||||
{{ detail.errorMsg || '服务端没有返回原因,请联系管理员查日志' }}
|
||||
</a-alert>
|
||||
|
||||
<a-progress
|
||||
v-else-if="detail.status !== 3"
|
||||
:percent="(detail.progress || 0) / 100"
|
||||
class="cl-alert"
|
||||
animation
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="report" class="cl-headline">
|
||||
<div class="cl-headline__main">{{ headlineOf(report) }}</div>
|
||||
<div class="cl-headline__sub">
|
||||
这一句是报告的全部意义:<strong>系统做了什么</strong>,以及
|
||||
<strong>还剩多少要人判断</strong>。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-row v-if="report" :gutter="12" class="cl-stats">
|
||||
<a-col v-for="s in reportStats" :key="s.label" :span="6">
|
||||
<div class="cl-stat" :class="{ 'cl-stat--warn': s.warn }">
|
||||
<div class="cl-stat__num">{{ s.value }}</div>
|
||||
<div class="cl-stat__label">{{ s.label }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-divider>重点({{ (report?.highlights || []).length }})</a-divider>
|
||||
<a-empty v-if="!(report?.highlights || []).length" description="没有需要人工处理的" />
|
||||
<div v-for="(h, i) in report?.highlights || []" :key="i" class="cl-hl">
|
||||
<a-space>
|
||||
<a-tag :color="levelColor(h.level)">
|
||||
{{ h.level >= 3 ? '必须人工' : h.level === 2 ? '警告' : '提示' }}
|
||||
</a-tag>
|
||||
<strong>{{ h.title }}</strong>
|
||||
<span v-if="h.realName" class="cl-hl__who">{{ h.realName }}</span>
|
||||
</a-space>
|
||||
<div class="cl-hl__detail">{{ h.detail }}</div>
|
||||
<div v-if="h.suggestion" class="cl-hl__suggest">建议:{{ h.suggestion }}</div>
|
||||
</div>
|
||||
|
||||
<a-divider>系统改过的文件名(样例)</a-divider>
|
||||
<a-empty v-if="!(report?.nameFixedSamples || []).length" description="没有需要改名的" />
|
||||
<a-table
|
||||
v-else
|
||||
:data="report?.nameFixedSamples || []"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
:bordered="{ cell: true }"
|
||||
>
|
||||
<template #columns>
|
||||
<a-table-column title="原名" data-index="original" ellipsis tooltip />
|
||||
<a-table-column title="改成" data-index="fixed" ellipsis tooltip />
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<a-divider>证件号冲突</a-divider>
|
||||
<a-empty v-if="!(report?.idcardConflicts || []).length" description="没有证件号冲突" />
|
||||
<a-table
|
||||
v-else
|
||||
:data="report?.idcardConflicts || []"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
:bordered="{ cell: true }"
|
||||
>
|
||||
<template #columns>
|
||||
<a-table-column title="姓名" data-index="realName" :width="100" />
|
||||
<a-table-column title="名册上的" data-index="rosterValue" :width="160" />
|
||||
<a-table-column title="文件名里的" data-index="fileNameValue" :width="160" />
|
||||
<a-table-column title="判定" data-index="verdictText" ellipsis tooltip />
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
</template>
|
||||
</a-spin>
|
||||
|
||||
<div v-if="detail" class="cl-footer">
|
||||
<a-space>
|
||||
<a-button v-if="detail.status === 3" type="primary" @click="openImport">
|
||||
导入为正式任务
|
||||
</a-button>
|
||||
<a-button v-if="detail.status === 3" :loading="downloading" @click="downloadResult">
|
||||
下载整理结果
|
||||
</a-button>
|
||||
<a-button @click="refresh">刷新</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-drawer>
|
||||
|
||||
<!-- 导入为正式任务 -->
|
||||
<a-modal
|
||||
v-model:visible="importVisible"
|
||||
title="导入为正式任务"
|
||||
:mask-closable="false"
|
||||
:ok-loading="importing"
|
||||
ok-text="导入"
|
||||
@before-ok="submitImport"
|
||||
>
|
||||
<a-alert type="normal" class="cl-alert">
|
||||
导入会建一个<strong>草稿</strong>任务,并把解析出的学员建成名册。
|
||||
<br />
|
||||
<strong>不会导入文件</strong>:整理结果里的材料要让学生/老师重新传上来
|
||||
—— 一期还没有「把外部文件挂到指定填报单」的入口。
|
||||
</a-alert>
|
||||
<a-form :model="importForm" layout="vertical">
|
||||
<a-form-item field="taskName" label="新任务名称" required>
|
||||
<a-input v-model="importForm.taskName" />
|
||||
</a-form-item>
|
||||
<a-form-item field="batchNo" label="批次号" required>
|
||||
<a-input v-model="importForm.batchNo" placeholder="如 202609" />
|
||||
</a-form-item>
|
||||
<a-form-item field="majorName" label="专业名称">
|
||||
<a-input v-model="importForm.majorName" placeholder="留空则从关联名册继承" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</GiPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CleanupJobVO, CleanupReportVO } from '@/apis/xt/cleanup'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { computed, onUnmounted, reactive, ref } from 'vue'
|
||||
import {
|
||||
|
||||
createCleanupJob,
|
||||
downloadCleanupResult,
|
||||
getCleanupJob,
|
||||
getCleanupJobs,
|
||||
getCleanupReport,
|
||||
headlineOf,
|
||||
importCleanupJob,
|
||||
levelColor,
|
||||
statusColor
|
||||
} from '@/apis/xt/cleanup'
|
||||
import { getTaskList } from '@/apis/xt/task'
|
||||
import { GiButton, GiPageLayout } from '@/components/index'
|
||||
|
||||
defineOptions({ name: 'SettingCleanup' })
|
||||
|
||||
// ⚠️ 这里用 `a-table + records + 手写分页`,**不用 Gi 模板的 `useTable`**:
|
||||
// `useTable` 认的是 Gi 的 `{ records, total }`,而我们的契约为 `{ list, total }`。
|
||||
// 项目里其它 XT 页面(导出中心、审核台…)都是这个写法 —— 保持一致,
|
||||
// 免得同一个项目里出现两套列表加载方式。
|
||||
//
|
||||
// ⚠️ 分页对象**自己 computed**,不用 Gi 的 `usePagination()`:那个 hook 的第一个参数是
|
||||
// "翻页时重新加载"的回调,而我们已经用 `@page-change` 在重新加载 ——
|
||||
// 两处都触发会让一次翻页打两次接口(同一个页码打两次)。
|
||||
// 这个 computed 的写法与 `views/export/index.vue` 一致。
|
||||
|
||||
const records = ref<CleanupJobVO[]>([])
|
||||
const total = ref(0)
|
||||
const listLoading = ref(false)
|
||||
const query = reactive<{ pageNum: number, pageSize: number, keyword: string, status?: number }>({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
keyword: '',
|
||||
status: undefined
|
||||
})
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ label: '排队中', value: 0 },
|
||||
{ label: '解析中', value: 1 },
|
||||
{ label: '处理中', value: 2 },
|
||||
{ label: '已完成', value: 3 },
|
||||
{ label: '失败', value: 4 }
|
||||
]
|
||||
|
||||
async function loadRecords() {
|
||||
listLoading.value = true
|
||||
try {
|
||||
const res = await getCleanupJobs({
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
keyword: query.keyword || undefined,
|
||||
status: query.status
|
||||
})
|
||||
records.value = res.data?.list ?? []
|
||||
total.value = res.data?.total ?? 0
|
||||
} finally {
|
||||
listLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
total: total.value,
|
||||
showTotal: true,
|
||||
showPageSize: true
|
||||
}))
|
||||
|
||||
function onSearch() {
|
||||
query.pageNum = 1
|
||||
loadRecords()
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
query.keyword = ''
|
||||
query.status = undefined
|
||||
onSearch()
|
||||
}
|
||||
|
||||
function onPageChange(page: number) {
|
||||
query.pageNum = page
|
||||
loadRecords()
|
||||
}
|
||||
|
||||
loadRecords()
|
||||
|
||||
// ── 上传 ────────────────────────────────────────
|
||||
|
||||
const createVisible = ref(false)
|
||||
const creating = ref(false)
|
||||
const createForm = reactive<{
|
||||
file: File | null
|
||||
jobName: string
|
||||
rosterTaskId?: number
|
||||
}>({ file: null, jobName: '', rosterTaskId: undefined })
|
||||
|
||||
const taskLoading = ref(false)
|
||||
const taskOptions = ref<{ label: string, value: number }[]>([])
|
||||
|
||||
function openCreate() {
|
||||
createVisible.value = true
|
||||
if (!taskOptions.value.length) {
|
||||
taskLoading.value = true
|
||||
getTaskList({ pageNum: 1, pageSize: 200 })
|
||||
.then((res) => {
|
||||
taskOptions.value = (res.data?.list || []).map((t) => ({
|
||||
label: t.taskName,
|
||||
value: t.id
|
||||
}))
|
||||
})
|
||||
.finally(() => {
|
||||
taskLoading.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function resetCreate() {
|
||||
createForm.file = null
|
||||
createForm.jobName = ''
|
||||
createForm.rosterTaskId = undefined
|
||||
}
|
||||
|
||||
/** `auto-upload=false` 时 a-upload 给的是 fileList,里面 `file` 才是真正的 File。 */
|
||||
function onFileChange(_: unknown, currentFile: { file?: File }) {
|
||||
createForm.file = currentFile?.file || null
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (!createForm.file) {
|
||||
Message.warning('请先选择一个 zip 压缩包')
|
||||
return false
|
||||
}
|
||||
if (!createForm.jobName.trim()) {
|
||||
Message.warning('请填写整理任务名称')
|
||||
return false
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
const res = await createCleanupJob(createForm.file, {
|
||||
jobName: createForm.jobName.trim(),
|
||||
rosterTaskId: createForm.rosterTaskId
|
||||
})
|
||||
Message.success(`已开始整理(任务 ${res.data.jobId}),可以关掉这个窗口等它跑完`)
|
||||
createVisible.value = false
|
||||
resetCreate()
|
||||
onSearch()
|
||||
return true
|
||||
} catch {
|
||||
// http 封装已经提示过了;这里只保证弹窗不关掉
|
||||
return false
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 详情与轮询 ──────────────────────────────────
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<CleanupJobVO | null>(null)
|
||||
const report = ref<CleanupReportVO | null>(null)
|
||||
let timer: number | undefined
|
||||
|
||||
async function openDetail(row: CleanupJobVO) {
|
||||
detailVisible.value = true
|
||||
detail.value = row
|
||||
report.value = null
|
||||
await loadDetail(row.id)
|
||||
// 还在跑就轮询:整理是异步的,而用户会盯着这一屏等结果
|
||||
if (!detail.value || detail.value.status === 0 || detail.value.status === 1
|
||||
|| detail.value.status === 2) {
|
||||
startPolling(row.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetail(id: number) {
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const res = await getCleanupJob(id)
|
||||
detail.value = res.data
|
||||
if (res.data?.status === 3) {
|
||||
stopPolling()
|
||||
await loadReport(id)
|
||||
} else if (res.data?.status === 4) {
|
||||
stopPolling()
|
||||
}
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadReport(id: number) {
|
||||
try {
|
||||
const res = await getCleanupReport(id)
|
||||
report.value = res.data
|
||||
} catch {
|
||||
// 报告拿不到不影响下载结果包,所以不打断;用户会看到"重点"那块是空的
|
||||
report.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(id: number) {
|
||||
stopPolling()
|
||||
timer = window.setInterval(() => {
|
||||
loadDetail(id)
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
/** 抽屉关掉就停 —— 否则关了还在后台每 3 秒打一次接口。 */
|
||||
function stopPolling() {
|
||||
if (timer) {
|
||||
window.clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(stopPolling)
|
||||
|
||||
async function refresh() {
|
||||
if (detail.value) {
|
||||
await loadDetail(detail.value.id)
|
||||
loadRecords()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 报告里的数字 ────────────────────────────────
|
||||
|
||||
const reportStats = computed(() => {
|
||||
const s = report.value?.summary
|
||||
if (!s) return []
|
||||
return [
|
||||
{ label: '不规范文件名', value: s.nameInvalid, warn: false },
|
||||
{ label: '已自动修复证件号', value: s.idcardAutoFixed, warn: false },
|
||||
{ label: '疑似串档', value: s.crossFileSuspected, warn: s.crossFileSuspected > 0 },
|
||||
{ label: '无法归位', value: s.orphanImages, warn: s.orphanImages > 0 }
|
||||
]
|
||||
})
|
||||
|
||||
// ── 下载结果包 ──────────────────────────────────
|
||||
|
||||
const downloading = ref(false)
|
||||
|
||||
/**
|
||||
* ⚠️ 走 `fetch` + Blob 而不是 `<a href>`:结果接口要带 Authorization 头,
|
||||
* 而 `<a href>` 带不了 —— 直接导航过去会 401,用户看到的是"下载没反应"。
|
||||
*/
|
||||
async function downloadResult() {
|
||||
if (!detail.value) return
|
||||
downloading.value = true
|
||||
try {
|
||||
// 下载放 api 层(与导出中心的 downloadExportFile 同一写法):它要带
|
||||
// Authorization 头取 blob,`<a href>` 带不了 —— 直接导航过去会 401
|
||||
await downloadCleanupResult(detail.value.id, detail.value.jobName)
|
||||
} catch (e) {
|
||||
Message.error((e as Error).message)
|
||||
} finally {
|
||||
downloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 导入为正式任务 ──────────────────────────────
|
||||
|
||||
const importVisible = ref(false)
|
||||
const importing = ref(false)
|
||||
const importForm = reactive({ taskName: '', batchNo: '', majorName: '' })
|
||||
|
||||
function openImport() {
|
||||
if (!detail.value) return
|
||||
if (detail.value.targetTaskId) {
|
||||
// 服务端是幂等的(再导一次返回原来那个任务),但先说清更好:
|
||||
// 否则用户以为点了会建第二个任务
|
||||
Message.info(
|
||||
`这份整理已经导入过了(任务 ${detail.value.targetTaskId}),再点会返回同一个任务`
|
||||
)
|
||||
}
|
||||
importForm.taskName = `${detail.value.jobName}-导入`
|
||||
importForm.batchNo = new Date().toISOString().slice(0, 7).replace('-', '')
|
||||
importForm.majorName = ''
|
||||
importVisible.value = true
|
||||
}
|
||||
|
||||
async function submitImport() {
|
||||
if (!detail.value) return false
|
||||
if (!importForm.taskName.trim() || !importForm.batchNo.trim()) {
|
||||
Message.warning('任务名称与批次号都要填')
|
||||
return false
|
||||
}
|
||||
importing.value = true
|
||||
try {
|
||||
const res = await importCleanupJob(detail.value.id, {
|
||||
taskName: importForm.taskName.trim(),
|
||||
batchNo: importForm.batchNo.trim(),
|
||||
majorName: importForm.majorName.trim() || undefined
|
||||
})
|
||||
Message.success(`已建任务 ${res.data.taskId},名册 ${res.data.rosterCount} 人`)
|
||||
importVisible.value = false
|
||||
await refresh()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.cl-tip {
|
||||
margin-top: 4px;
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.cl-dim {
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.cl-alert {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.cl-footer {
|
||||
margin-top: 20px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--color-border-1);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.cl-headline {
|
||||
margin: 16px 0;
|
||||
padding: 16px;
|
||||
background: var(--color-fill-1);
|
||||
border-left: 3px solid rgb(var(--arcoblue-6));
|
||||
border-radius: 4px;
|
||||
|
||||
&__main {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
margin-top: 6px;
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.cl-stats {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.cl-stat {
|
||||
padding: 12px;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
|
||||
&--warn .cl-stat__num {
|
||||
color: rgb(var(--red-6));
|
||||
}
|
||||
|
||||
&__num {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&__label {
|
||||
margin-top: 4px;
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.cl-hl {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--color-border-1);
|
||||
|
||||
&__who {
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
&__detail {
|
||||
margin-top: 4px;
|
||||
color: var(--color-text-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&__suggest {
|
||||
margin-top: 2px;
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user