From 6cca97efe6e5ad76b5f2bb57ad850c984fcbe030 Mon Sep 17 00:00:00 2001 From: lovife <474239041@qq.com> Date: Wed, 30 Sep 2026 09:02:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(export):=20=E5=AF=BC=E5=87=BA=E4=B8=8E?= =?UTF-8?q?=E4=BA=A4=E4=BB=98=EF=BC=88M6=EF=BC=89=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E5=89=8D=E7=BD=AE=E6=A3=80=E6=9F=A5=E3=80=81=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E6=A0=91=E9=A2=84=E8=A7=88=E3=80=81=E5=88=86=E5=8D=B7=E6=89=93?= =?UTF-8?q?=E5=8C=85=E4=B8=8E=207=20=E5=A4=A9=E8=BF=87=E6=9C=9F=E6=B8=85?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 客户最关心的那条链:precheck(不通过就没有 force 分支,铁律 10)→ 目录树预览 (与真正打包共用同一份构建代码,预览不会说谎)→ 异步分卷(每卷都是完整可解的 ZIP) → 产物落表按行判可下载(过期回「已被清理、请重新导出」)。 契约没有但界面必需的 GET /exports/precheck 一并补上(否则按钮要点一次才知道能不能导)。 顺带修 F-11:severity 口径统一为 INFO=1/WARN=2/ERROR=3(三处注释曾互相矛盾, 按直觉写会把「提示」当阻断,正常数据也导不出来)。 验证:scripts/e2e-m6-export.py 60/60、e2e-m6-export-split.py 33/33; ZIP 的 UTF-8 标志位(general purpose bit 11)与 sha256 都有断言。 --- backend/xt-server/pom.xml | 15 + .../cn/tianbaotong/fill/FillApplication.java | 7 + .../export/config/ExportAsyncConfig.java | 39 + .../export/controller/ExportController.java | 145 +++ .../fill/module/export/dto/ExportDtos.java | 293 +++++ .../export/entity/CollectExportFile.java | 42 + .../export/entity/CollectExportTask.java | 98 ++ .../export/mapper/ExportDataMapper.java | 188 +++ .../export/mapper/ExportFileMapper.java | 62 + .../export/mapper/ExportTaskMapper.java | 13 + .../export/service/ExportCleanupService.java | 126 ++ .../module/export/service/ExportService.java | 1127 +++++++++++++++++ .../module/export/service/SplitZipWriter.java | 182 +++ .../fill/module/fill/dto/SubmitDtos.java | 9 +- .../db/migration/V11__export_file.sql | 56 + frontend-pc/src/apis/xt/export-task.ts | 233 ++++ .../src/views/export/ExportDetailDrawer.vue | 236 ++++ frontend-pc/src/views/export/index.vue | 536 ++++++++ 18 files changed, 3406 insertions(+), 1 deletion(-) create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/config/ExportAsyncConfig.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/controller/ExportController.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/dto/ExportDtos.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/entity/CollectExportFile.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/entity/CollectExportTask.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/mapper/ExportDataMapper.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/mapper/ExportFileMapper.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/mapper/ExportTaskMapper.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/service/ExportCleanupService.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/service/ExportService.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/service/SplitZipWriter.java create mode 100644 backend/xt-server/src/main/resources/db/migration/V11__export_file.sql create mode 100644 frontend-pc/src/apis/xt/export-task.ts create mode 100644 frontend-pc/src/views/export/ExportDetailDrawer.vue create mode 100644 frontend-pc/src/views/export/index.vue diff --git a/backend/xt-server/pom.xml b/backend/xt-server/pom.xml index 9037a40..5bc207d 100644 --- a/backend/xt-server/pom.xml +++ b/backend/xt-server/pom.xml @@ -89,6 +89,21 @@ easyexcel + + + org.apache.commons + commons-compress + 1.27.1 + + com.qiniu diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/FillApplication.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/FillApplication.java index 6673011..e7ac6c7 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/FillApplication.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/FillApplication.java @@ -4,15 +4,22 @@ import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; /** * 数据信息填报系统 · 主应用。 * *

一期范围:学校收集 + 自主认定(见 PRD「一期范围声明」)。 * 社会收集 / 社会评价 / 专项职业能力的数据结构与枚举保留但不启用,传入返回 {@code 40044}。 + * + *

{@code @EnableScheduling} 是给导出包过期清理用的 + * ({@code ExportCleanupService},契约 §12 的"保留 7 天")。 + * 目前只有这一个定时任务 —— 别顺手加更多:定时任务是"没有请求也会发生的事", + * 每加一个都要能回答"它失败了谁会知道"。 */ @SpringBootApplication @ConfigurationPropertiesScan +@EnableScheduling @MapperScan("cn.tianbaotong.fill.module.**.mapper") public class FillApplication { diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/config/ExportAsyncConfig.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/config/ExportAsyncConfig.java new file mode 100644 index 0000000..7508040 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/config/ExportAsyncConfig.java @@ -0,0 +1,39 @@ +package cn.tianbaotong.fill.module.export.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 导出打包的线程池。 + * + *

为什么单独一个池,而不用 {@code @Async} 的默认池

+ *

打包是长任务 + CPU/IO 混合(压缩几百张图、写几十 MB 的 zip), + * 而 Spring 的默认异步池是给短任务用的(队列无界 + 线程数少)。 + * 混在一起的话,一次导出能把所有短任务(比如"通知")排到后面去 —— + * 而这类故障的表现是"系统变慢",没人会想到是导出引起的。 + * + *

线程数刻意只有 2:导出是串行的大活, + * 同时跑 4 个导出只会让四个都慢,还会把磁盘 IO 打满。 + * 排队比并发更符合这里的业务(一个任务一学期导出几次,不是高频动作)。 + */ +@Slf4j +@Configuration +public class ExportAsyncConfig { + + @Bean(name = "exportExecutor", destroyMethod = "shutdown") + public ExecutorService exportExecutor() { + AtomicInteger seq = new AtomicInteger(1); + ThreadFactory factory = r -> { + Thread t = new Thread(r, "xt-export-" + seq.getAndIncrement()); + t.setDaemon(true); + return t; + }; + return Executors.newFixedThreadPool(2, factory); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/controller/ExportController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/controller/ExportController.java new file mode 100644 index 0000000..a983ac2 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/controller/ExportController.java @@ -0,0 +1,145 @@ +package cn.tianbaotong.fill.module.export.controller; + +import cn.tianbaotong.fill.common.api.ApiResult; +import cn.tianbaotong.fill.module.export.dto.ExportDtos; +import cn.tianbaotong.fill.module.export.service.ExportCleanupService; +import cn.tianbaotong.fill.module.export.service.ExportService; +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 java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +/** + * 导出与交付(《03-接口设计》§12.4 ~ §12.6)。 + * + *

权限只有两个:{@code export:download}(导出与下载)与 {@code export:unmasked}(脱敏开关)。 + * 没有单独的"查看导出列表"权限 —— 能看到任务的人本来就该能看到它的导出记录, + * 多造一个权限码只会让分配角色时多一个想不起来的东西。 + */ +@Tag(name = "导出与交付") +@RestController +@RequestMapping("/api/v1/exports") +@RequiredArgsConstructor +public class ExportController { + + private final ExportService exportService; + private final ExportCleanupService exportCleanupService; + + @GetMapping("/precheck") + @RequirePerm(PermissionDef.EXPORT_DOWNLOAD) + @Operation(summary = "导出前置检查(只检查,不创建)", + description = "界面用它决定「开始导出」按钮是否可点。" + + "**铁律**:canExport=false 时按钮必须禁用,不允许「警告后放行」") + public ApiResult precheck(@RequestParam Long taskId, + @RequestParam(required = false) java.util.List orgIds, + @RequestParam(required = false) Boolean onlyApproved) { + ExportDtos.ScopeReq scope = new ExportDtos.ScopeReq(); + scope.setOrgIds(orgIds == null ? new java.util.ArrayList<>() : orgIds); + scope.setOnlyApproved(Boolean.TRUE.equals(onlyApproved)); + return ApiResult.ok(exportService.precheck(taskId, scope)); + } + + @GetMapping("/preview") + @RequirePerm(PermissionDef.EXPORT_DOWNLOAD) + @Operation(summary = "目录树预览", + description = "点「开始导出」之前看到最终交付的样子(PRD §5.9 的目录规范):" + + "根目录 = {批次号}-{任务名称},第二层班级,第三层材料类型。" + + "**与真正打包共用同一份构建代码**,所以预览不会说谎") + public ApiResult preview(@RequestParam Long taskId, + @RequestParam(required = false) java.util.List orgIds, + @RequestParam(required = false) Boolean onlyApproved) { + ExportDtos.ScopeReq scope = new ExportDtos.ScopeReq(); + scope.setOrgIds(orgIds == null ? new java.util.ArrayList<>() : orgIds); + scope.setOnlyApproved(Boolean.TRUE.equals(onlyApproved)); + return ApiResult.ok(exportService.preview(taskId, scope)); + } + + @PostMapping + @RequirePerm(PermissionDef.EXPORT_DOWNLOAD) + @Operation(summary = "创建导出任务", + description = "前置检查不过返回 **40035**,并把逐条清单放在 data 里(是谁的哪一项卡住了)。" + + "通过则返回 exportId,打包在后台进行,用 GET /exports/{id} 轮询进度") + public ApiResult create(@RequestBody ExportDtos.CreateReq req) { + return ApiResult.ok(exportService.create(req)); + } + + @GetMapping + @RequirePerm(PermissionDef.EXPORT_DOWNLOAD) + @Operation(summary = "导出记录列表") + public ApiResult> list(@RequestParam(required = false) Long taskId, + @RequestParam(required = false) Long pageNum, + @RequestParam(required = false) Long pageSize) { + return ApiResult.ok(exportService.list(taskId, pageNum, pageSize)); + } + + @GetMapping("/{id}") + @RequirePerm(PermissionDef.EXPORT_DOWNLOAD) + @Operation(summary = "导出进度 / 结果", + description = "status 0 排队 / 1 处理中 / 2 完成 / 3 失败;" + + "stats 里的 compressRatio 是**原始体积 → 包体**的压缩率") + public ApiResult detail(@PathVariable Long id) { + return ApiResult.ok(exportService.detail(id)); + } + + @GetMapping("/{id}/download") + @RequirePerm(PermissionDef.EXPORT_DOWNLOAD) + @Operation(summary = "取下载信息", + description = "返回本服务的下载地址(而不是对象存储签名链接)—— " + + "包体落在打包机的磁盘上,走自己的流式接口最直接") + public ApiResult download(@PathVariable Long id, + @RequestParam(required = false) Integer fileIndex) { + return ApiResult.ok(exportService.download(id, fileIndex)); + } + + @GetMapping("/{id}/file") + @RequirePerm(PermissionDef.EXPORT_DOWNLOAD) + @Operation(summary = "下载导出包(文件流)") + public ResponseEntity file(@PathVariable Long id, + @RequestParam(required = false) Integer fileIndex) { + ExportService.StoredFile f = exportService.readResult(id, fileIndex); + String encoded = URLEncoder.encode(f.name(), StandardCharsets.UTF_8).replace("+", "%20"); + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_OCTET_STREAM) + // 中文文件名要给两种写法:filename 给老客户端,filename* 给现代浏览器。 + // 只给前者会让中文名变乱码(RFC 5987 才认 UTF-8) + .header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + encoded + "\"; filename*=UTF-8''" + encoded) + .body(f.bytes()); + } + + /** + * 手工触发过期清理。 + * + *

⚠️ 为什么要把这个动作暴露成接口:定时任务(每小时一次)在 + * 测试与现场排障时都等不起 —— 要验证"过期之后下载会怎样", + * 总不能把过期时间改到 7 天后再等一小时。而"清理"本身是可重复执行的 + * (已清理的卷会被跳过),所以手工触发没有副作用。 + * + *

权限用 {@code export:download}(导出管理员)而不是 {@code log:viewTenant}: + * 它删的是导出产物文件,属于导出这条线的运维动作。 + */ + @PostMapping("/cleanup-run") + @RequirePerm(PermissionDef.EXPORT_DOWNLOAD) + @Operation(summary = "手工触发导出包过期清理", + description = "定时任务每小时自动跑一次;这个入口是给**测试与排障**用的" + + "(不然要验证过期行为得等一小时)。重复执行安全:已清理的卷会跳过") + public ApiResult> cleanupRun() { + int cleaned = exportCleanupService.doCleanup(); + return ApiResult.ok(Map.of("cleanedFiles", cleaned)); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/dto/ExportDtos.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/dto/ExportDtos.java new file mode 100644 index 0000000..ad1c4d6 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/dto/ExportDtos.java @@ -0,0 +1,293 @@ +package cn.tianbaotong.fill.module.export.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-接口设计》§12.4 ~ §12.6)。 + * + *

分成两个内部类放一个文件,与 {@code AuditDtos} 同一风格: + * 这些 DTO **只在这一条链路上用**,拆成十几个文件反而看不出它们之间的关系。 + */ +public final class ExportDtos { + + private ExportDtos() { + } + + // ── 请求 ────────────────────────────────────── + + /** 范围条件。 */ + @Getter + @Setter + public static class ScopeReq { + + /** 只要这些班;空表示全部 */ + private List orgIds = new ArrayList<>(); + + /** 只要终审通过的(默认 false = 全部已提交的) */ + private Boolean onlyApproved = Boolean.FALSE; + + /** 是否把原始件也打进去(默认 false:交付物只有规范件) */ + private Boolean includeRaw = Boolean.FALSE; + } + + /** 创建导出(§12.4)。 */ + @Getter + @Setter + @Schema(description = "创建导出任务") + public static class CreateReq { + + private Long taskId; + + /** 导出包名,如 {@code 202609机电认定-24中技数控1班} */ + private String exportName; + + private ScopeReq scope; + + /** 分卷大小(MB);{@code 0} = 不分卷 */ + private Integer splitSizeMb = 0; + + /** 是否脱敏导出(名册里的证件号本来就是脱敏的,这个开关留着为二期) */ + private Boolean desensitize = Boolean.FALSE; + + /** {@code ZIP} / {@code FOLDER}(一期只做 ZIP) */ + private String format = "ZIP"; + + /** 是否生成汇总 Excel(默认生成 —— 客户拿到包第一件事就是打开它) */ + private Boolean generateSummaryExcel = Boolean.TRUE; + + private Boolean notifyOnFinish = Boolean.TRUE; + } + + /** 创建成功的结果。 */ + @Getter + @Setter + public static class CreateVO { + + private Long exportId; + + private Integer status; + + /** 估算秒数(给界面一个"大概等多久",不是承诺) */ + private Integer estimatedSeconds; + } + + // ── 前置检查(§12.4 的 40035)────────────────── + + /** 一条阻断项。 */ + @Getter + @Setter + public static class Blocking { + + /** {@code FILE_SIZE} / {@code NAME_INVALID} / {@code MISSING_MATERIAL} / {@code UNAPPROVED} */ + private String type; + + private Long rosterId; + + private String realName; + + private String materialCode; + + private Long fileId; + + private String title; + + /** 该怎么办(一句话,直接显示给用户) */ + private String action; + } + + /** 阻断计数。 */ + @Getter + @Setter + public static class BlockingSummary { + + private int sizeExceeded; + + private int nameInvalid; + + private int missing; + + private int unapproved; + } + + /** 前置检查结果。 */ + @Getter + @Setter + public static class PrecheckVO { + + /** false 时**前端必须禁用导出按钮**,不允许"警告后放行" */ + private boolean canExport; + + private List blocking = new ArrayList<>(); + + private BlockingSummary summary = new BlockingSummary(); + + /** 可一键做的补救动作 */ + private List quickActions = new ArrayList<>(); + + /** 不阻断但要在界面上说明的提醒(如"有 3 人未提交,不会进包") */ + private List warnings = new ArrayList<>(); + + /** 会进包的统计(人数 / 文件数 / 原始体积) */ + private int candidateCount; + + private int fileCount; + + private long rawSizeBytes; + } + + // ── 进度与结果(§12.5)──────────────────────── + + /** 产物文件。 */ + @Getter + @Setter + public static class ResultFile { + + private String name; + + private Double sizeMb; + + /** 分卷序号(从 0 开始) */ + private Integer part; + + private Long size; + + private String checksum; + + private String path; + } + + /** 导出任务(列表与详情共用)。 */ + @Getter + @Setter + @Schema(description = "导出任务") + public static class ExportTaskVO { + + private Long id; + + private Long taskId; + + private String taskName; + + private String exportName; + + /** {@code 0} 排队 / {@code 1} 处理中 / {@code 2} 完成 / {@code 3} 失败 */ + private Integer status; + + private String statusText; + + private Integer progress; + + private String currentStep; + + private String stepText; + + private Integer fileCount; + + private Stats stats; + + private List resultFiles; + + private String errorMsg; + + private LocalDateTime createTime; + + private LocalDateTime finishTime; + + private LocalDateTime expireTime; + } + + /** 体积统计。 */ + @Getter + @Setter + public static class Stats { + + private Integer fileCount; + + private Integer processedCount; + + private Double rawSizeMb; + + private Double finalSizeMb; + + /** 形如 {@code 95.3%} */ + private String compressRatio; + } + + /** 下载信息(§12.6)。 */ + @Getter + @Setter + public static class DownloadVO { + + private String url; + + private Integer expireSeconds; + + private String fileName; + + private Double sizeMb; + + private String checksum; + + private Integer remainsCachedDays; + } + + // ── 目录树预览(原型 STEP 07 右侧那一栏)────── + + /** + * 一个树节点。 + * + *

叶子节点(文件)用 {@code sourcePath} 带上存储路径,好让"预览"与"真正打包" + * 走**同一份构建代码** —— 各写一份的话,预览里看到的目录结构与最终产物迟早不一致, + * 而那种不一致恰恰是"预览"这个功能存在的意义所在。 + */ + @Getter + @Setter + public static class TreeNode { + + private String name; + + /** 节点类型:{@code DIR} / {@code FILE} */ + private String type; + + /** 从根算起的完整路径(打包时直接用) */ + private String path; + + private Integer sizeKb; + + /** 该节点下的人头数(仅班级节点有值,用于算进度) */ + private Integer studentCount; + + private List children = new ArrayList<>(); + + /** 仅文件节点:如何从存储里取到它(预览时不下发,打包时内部使用) */ + private transient String sourcePath; + } + + /** 目录树预览(§12.4 的"目录树预览")。 */ + @Getter + @Setter + public static class TreeVO { + + /** 根目录名:{@code {批次号}-{任务名称}}(PRD §5.9) */ + private String root; + + private List children = new ArrayList<>(); + + /** 会进包的人数 */ + private int personCount; + + /** 会进包的文件数(不含汇总表) */ + private int fileCount; + + private long totalSizeBytes; + + /** 与原始体积相比的压缩率(只是估算口径说明,实际以打包结果为准) */ + private String note; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/entity/CollectExportFile.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/entity/CollectExportFile.java new file mode 100644 index 0000000..f38f3a5 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/entity/CollectExportFile.java @@ -0,0 +1,42 @@ +package cn.tianbaotong.fill.module.export.entity; + +import cn.tianbaotong.fill.support.TenantEntity; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +/** + * 导出产物({@code collect_export_file})—— 一个分卷一行。 + * + *

为什么独立成表(而不是复用 {@code collect_export_task.result_files} 那段 JSON): + * 分卷要能**按主键下载**(授权靠主键,而不是"解析 JSON 取第 n 个")、 + * 要能**按行清理**(清理时把磁盘文件删掉、把行标成不可下载; + * JSON 字段没法部分更新,整段重写期间别人下载会读到半新半旧的内容)。 + */ +@Getter +@Setter +@TableName("collect_export_file") +public class CollectExportFile extends TenantEntity { + + /** 可下载。 */ + public static final int DOWNLOADABLE_YES = 1; + /** 已清理(文件已从磁盘删除,但这一行**留着**作审计)。 */ + public static final int DOWNLOADABLE_NO = 0; + + private Long exportId; + + private Long taskId; + + /** 分卷序号,从 0 开始;单卷时恒为 0。 */ + private Integer partNo; + + private String fileName; + + private String filePath; + + private Long fileSize; + + private String checksum; + + private Integer downloadable; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/entity/CollectExportTask.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/entity/CollectExportTask.java new file mode 100644 index 0000000..db2231f --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/entity/CollectExportTask.java @@ -0,0 +1,98 @@ +package cn.tianbaotong.fill.module.export.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 导出任务({@code collect_export_task})。 + * + *

⚠️ 本实体刻意不继承 {@code BaseEntity} / {@code TenantEntity}: + * 这张表没有 {@code deleted} 列(导出记录是审计痕迹,只增不删), + * 而基类带 {@code @TableLogic deleted} —— 继承它会让 MyBatis-Plus 给**每条**查询 + * 自动追加 {@code deleted = 0},运行时报「列不存在」,而编译期完全看不出来。 + * (同一个坑在 {@code collect_task_org} 上踩过,见交接摘要 §一·五。) + * + *

同理这张表没有 {@code update_by / update_time},也**没有** + * {@code current_step / step_text / raw_size} 三列 —— 契约(§12.5)里要的 + * {@code currentStep / stepText / stats.rawSizeMb} 分别是 + * 由 {@code status + progress} 推导与按需聚合出来的 + * (见 {@code ExportService}),不是存出来的。 + * 想当然按契约字段建实体的话,插入时会直接 + * {@code column "current_step" does not exist} / {@code column "raw_size" does not exist} + * (这两个错都已经踩过一次)。 + */ +@Getter +@Setter +@TableName("collect_export_task") +public class CollectExportTask implements Serializable { + + /** {@code 0} 排队 / {@code 1} 处理中 / {@code 2} 完成 / {@code 3} 失败 / {@code 4} 已过期 */ + public static final int STATUS_QUEUED = 0; + public static final int STATUS_RUNNING = 1; + public static final int STATUS_DONE = 2; + public static final int STATUS_FAILED = 3; + + /** + * 已过期(契约 §6 的 `exportStatus`:{@code 4} 已过期)。 + * + *

⚠️ 过期**不是失败**:这个包当时是成功产出并被下载过的, + * 只是保留期(7 天)到了被清理。把它标成 3 会让交付统计把 + * "正常交付过的包"算成失败,而那个数字是要给客户看的。 + */ + public static final int STATUS_EXPIRED = 4; + + @TableId(type = IdType.AUTO) + private Long id; + + /** 由 {@code FillMetaObjectHandler} 从登录上下文填充(平台运营可为 0)。 */ + @TableField(value = "tenant_id", fill = FieldFill.INSERT) + private Long tenantId; + + private Long taskId; + + /** 导出包名(用户填),也是压缩包文件名的主体 */ + private String exportName; + + /** 范围条件(JSON 文本):{@code {orgIds, onlyApproved, includeRaw}} */ + private String scopeJson; + + /** 分卷大小(MB);{@code 0} = 不分卷 */ + private Integer splitSizeMb; + + /** 是否脱敏导出 */ + private Integer desensitize; + + private Integer status; + + /** 0~100 */ + private Integer progress; + + /** 计划打包的文件份数(人数) */ + private Integer fileCount; + + /** 打包后总字节数(zip 体积) */ + private Long totalSize; + + /** 产物清单(JSON 数组):{@code [{name,sizeMb,path,checksum}]} */ + private String resultFiles; + + private String errorMsg; + + private LocalDateTime expireTime; + + private LocalDateTime finishTime; + + @TableField(value = "create_by", fill = FieldFill.INSERT) + private Long createBy; + + @TableField(value = "create_time", fill = FieldFill.INSERT) + private LocalDateTime createTime; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/mapper/ExportDataMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/mapper/ExportDataMapper.java new file mode 100644 index 0000000..b8d283d --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/mapper/ExportDataMapper.java @@ -0,0 +1,188 @@ +package cn.tianbaotong.fill.module.export.mapper; + +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 打包时的只读查询:**取打包所需的最小字段集**,全部按 {@code task_id} 一次性取回。 + * + *

为什么不复用各模块的 Service:打包要对**几百份**提交单逐个取材料与名册, + * 走 Service 就是 N+1 次查询(真实批次 28 人 × 4 类材料已经明显慢, + * 几百人时会把整个导出卡住)。所以这里按"一次查询取一列"的方式写, + * 与 {@code AuditScopeMapper} 同一取舍。 + * + *

⚠️ 三条必须守住的: + *

    + *
  1. 只取 {@code is_current = 1} 的文件。历史版本(学生重传前的旧件) + * 绝不能进交付包 —— 那等于把两份矛盾的身份证一起交给认定机构。
  2. + *
  3. 不取证件号明文(它是 AES 密文,取了也没用),导出用脱敏值 + * ({@code id_card_mask})。真要明文只有"审核场景单独取 + 留痕"那一跳。
  4. + *
  5. 不带 tenant_id 条件由拦截器补,但这里是自定义 SQL —— + * 拦截器同样会注入,所以**不要**再手写一遍(写错一个值就是跨租户数据泄露)。
  6. + *
+ */ +public interface ExportDataMapper { + + /** 一个可导出的对象:名册行(人)× 它的填报单状态。 */ + class Candidate { + private Long rosterId; + private String realName; + private String idCardMask; + private Long orgId; + private String orgName; + private String occupation; + private String worktype; + private String jobLevel; + private Long submissionId; + /** {@code 0} 草稿 / {@code 1} 待初审 / {@code 2} 待终审 / {@code 3} 已通过 / {@code 4} 已驳回 */ + private Integer status; + private Long submitterId; + private java.time.LocalDateTime submitTime; + /** 这一行有没有被学生绑定过(绑定过才可能有填报单) */ + private Long bindUserId; + + public Long getRosterId() { return rosterId; } + public void setRosterId(Long v) { this.rosterId = v; } + public String getRealName() { return realName; } + public void setRealName(String v) { this.realName = v; } + public String getIdCardMask() { return idCardMask; } + public void setIdCardMask(String v) { this.idCardMask = v; } + public Long getOrgId() { return orgId; } + public void setOrgId(Long v) { this.orgId = v; } + public String getOrgName() { return orgName; } + public void setOrgName(String v) { this.orgName = v; } + public String getOccupation() { return occupation; } + public void setOccupation(String v) { this.occupation = v; } + public String getWorktype() { return worktype; } + public void setWorktype(String v) { this.worktype = v; } + public String getJobLevel() { return jobLevel; } + public void setJobLevel(String v) { this.jobLevel = v; } + public Long getSubmissionId() { return submissionId; } + public void setSubmissionId(Long v) { this.submissionId = v; } + public Integer getStatus() { return status; } + public void setStatus(Integer v) { this.status = v; } + public Long getSubmitterId() { return submitterId; } + public void setSubmitterId(Long v) { this.submitterId = v; } + public java.time.LocalDateTime getSubmitTime() { return submitTime; } + public void setSubmitTime(java.time.LocalDateTime v) { this.submitTime = v; } + public Long getBindUserId() { return bindUserId; } + public void setBindUserId(Long v) { this.bindUserId = v; } + } + + /** 一个待打包的文件。 */ + class PackFile { + private Long id; + private Long submissionId; + private Long rosterId; + private String materialCode; + private String originalName; + private String standardName; + private String stdPath; + private Long finalSize; + private Integer width; + private Integer height; + private Integer dpi; + + public Long getId() { return id; } + public void setId(Long v) { this.id = v; } + public Long getSubmissionId() { return submissionId; } + public void setSubmissionId(Long v) { this.submissionId = v; } + public Long getRosterId() { return rosterId; } + public void setRosterId(Long v) { this.rosterId = v; } + public String getMaterialCode() { return materialCode; } + public void setMaterialCode(String v) { this.materialCode = v; } + public String getOriginalName() { return originalName; } + public void setOriginalName(String v) { this.originalName = v; } + public String getStandardName() { return standardName; } + public void setStandardName(String v) { this.standardName = v; } + public String getStdPath() { return stdPath; } + public void setStdPath(String v) { this.stdPath = v; } + public Long getFinalSize() { return finalSize; } + public void setFinalSize(Long v) { this.finalSize = v; } + public Integer getWidth() { return width; } + public void setWidth(Integer v) { this.width = v; } + public Integer getHeight() { return height; } + public void setHeight(Integer v) { this.height = v; } + public Integer getDpi() { return dpi; } + public void setDpi(Integer v) { this.dpi = v; } + } + + /** + * 取**全量**名册行 + 它的填报单状态。 + * + *

刻意不按 {@code onlyApproved} 过滤:前置检查要能说出"谁还没审完" + * (那是导出被拦住的真正原因),所以这一条查询必须把未审的人一并带回来, + * 由上层决定"拦"还是"只装通过的那些"。 + * 分两个查询(一个过滤、一个不过滤)会让两边的口径迟早分叉。 + * + * @param taskId 任务 + * @param orgIds 只要这些班;空表示不限 + */ + @Select(""" + + """) + List listCandidates(@Param("taskId") Long taskId, + @Param("orgIds") List orgIds); + + /** 当前生效的文件({@code is_current = 1}),按提交单批量取。 */ @Select(""" + + """) + List listFiles(@Param("submissionIds") List submissionIds); + + /** + * 某个任务下所有「当前生效」文件的原始体积合计。 + * + *

用途只有一个:算导出包的**压缩率**(契约 §12.5 的 {@code stats})。 + * 之所以按需聚合而不是存在 {@code collect_export_task} 上: + * 那张表**没有** {@code raw_size} 列(已发布的 DDL),而为了一个展示值新建迁移不划算。 + */ + @Select(""" + SELECT COALESCE(SUM(f.final_size), 0) + FROM collect_file f + JOIN collect_submission s ON s.id = f.submission_id AND s.deleted = 0 + WHERE f.is_current = 1 AND f.deleted = 0 AND s.task_id = #{taskId} + """) + String sumCurrentFileSize(@Param("taskId") Long taskId); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/mapper/ExportFileMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/mapper/ExportFileMapper.java new file mode 100644 index 0000000..5b6879d --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/mapper/ExportFileMapper.java @@ -0,0 +1,62 @@ +package cn.tianbaotong.fill.module.export.mapper; + +import cn.tianbaotong.fill.module.export.entity.CollectExportFile; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +/** + * 导出产物(一个分卷一行)。 + * + *

比 {@code collect_export_task.result_files} 那个 JSON 字段强的地方: + * 分卷能按主键下载、能按行清理,而不是每次解析整段 JSON + * (那会在"下载时别人正在清理"的窗口里读到半新半旧的内容)。 + */ +public interface ExportFileMapper extends BaseMapper { + + /** + * 一次导出的全部分卷(按序号)。 + * + *

⚠️ **不筛 downloadable** —— 已清理的卷也要返回, + * 否则界面上"本次导出 3 卷"会突然变成 2 卷,用户以为少了一卷。 + * 能不能下载由调用方按 {@code downloadable} 决定。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT * FROM collect_export_file + WHERE tenant_id = #{tenantId} AND export_id = #{exportId} AND deleted = 0 + ORDER BY part_no, id + """) + List listByExport(@Param("tenantId") Long tenantId, + @Param("exportId") Long exportId); + + /** + * 按序号取一卷。 + * + *

带 {@code tenant_id} 与 {@code export_id}:下载接口的授权就靠这个 —— + * 前端传来的 id 是可以改的,不校验归属就等于任何人都能下载任何导出包。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT * FROM collect_export_file + WHERE tenant_id = #{tenantId} AND export_id = #{exportId} + AND part_no = #{partNo} AND deleted = 0 + ORDER BY id LIMIT 1 + """) + CollectExportFile findByPart(@Param("tenantId") Long tenantId, + @Param("exportId") Long exportId, + @Param("partNo") Integer partNo); + + /** 把一条产物标成"已清理"(文件已从磁盘删掉)。 */ + @InterceptorIgnore(tenantLine = "true") + @Update(""" + UPDATE collect_export_file + SET downloadable = 0, update_time = NOW() + WHERE id = #{id} + """) + int markCleaned(@Param("id") Long id); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/mapper/ExportTaskMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/mapper/ExportTaskMapper.java new file mode 100644 index 0000000..c2b78f8 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/mapper/ExportTaskMapper.java @@ -0,0 +1,13 @@ +package cn.tianbaotong.fill.module.export.mapper; + +import cn.tianbaotong.fill.module.export.entity.CollectExportTask; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** + * 导出任务表。 + * + *

租户条件由多租户拦截器自动注入,**不要在 SQL 里手写 tenant_id** —— + * 手写一份就多一个漏掉的机会,而漏掉的后果是跨租户读到别人的导出记录。 + */ +public interface ExportTaskMapper extends BaseMapper { +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/service/ExportCleanupService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/service/ExportCleanupService.java new file mode 100644 index 0000000..c461f94 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/service/ExportCleanupService.java @@ -0,0 +1,126 @@ +package cn.tianbaotong.fill.module.export.service; + +import cn.tianbaotong.fill.module.export.entity.CollectExportFile; +import cn.tianbaotong.fill.module.export.entity.CollectExportTask; +import cn.tianbaotong.fill.module.export.mapper.ExportFileMapper; +import cn.tianbaotong.fill.module.export.mapper.ExportTaskMapper; +import cn.tianbaotong.fill.support.TenantScope; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +/** + * 导出包过期清理(契约 §12 的 {@code 4 已过期}、{@code RETAIN_DAYS = 7})。 + * + *

清理的语义:删文件、**留记录**

+ * + *

导出包保留 7 天(契约写明的口径),到期后: + *

+ * + *

⚠️ 为什么不连行一起删:"当时确实导出过这个包"是审计要保留的事实。 + * 删了行之后,"这个任务什么时候导出过、导出了几卷、给谁下载过"就查不出来了, + * 而这几件事恰恰是交付纠纷时最需要回答的。 + * 这与"名册删除用软删"是同一个道理。 + * + *

为什么不用 @Transactional 包住整个清理

+ * + *

清理是"删文件 + 改行"两件事,而**文件删除不可回滚**。 + * 用一个大事务包住,只会让"文件删了、事务回滚了"变成一种可能的状态 —— + * 那时行还显示可下载,但文件已经没了,用户点下载得到"文件不在磁盘上"。 + * + *

所以顺序是刻意的:**先改行(置为不可下载)→ 再删文件**。 + * 中间失败的最坏结果是"文件还在但显示已清理"(浪费一点磁盘), + * 而不是"显示可下载但文件没了"(用户报障)。 + * + *

定时任务的租户上下文

+ * + *

{@code TenantScope.runAs} 必须包住每一轮 —— 多租户拦截器从 ThreadLocal 取 + * tenantId,定时线程里没有它,一句 SQL 都跑不了(表现为 40101 或"查不到数据")。 + * 这里按**平台租户**跑:清理是运维动作,要看所有租户的过期包。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ExportCleanupService { + + private final ExportTaskMapper exportTaskMapper; + private final ExportFileMapper exportFileMapper; + + /** + * 每小时扫一次过期导出包。 + * + *

每小时而不是每天:过期是"到点就不可下载"的承诺, + * 每天跑一次会让实际保留期在 7~7.9 天之间浮动 —— 而契约写的是 7 天。 + * 代价只是一次很轻的索引扫描({@code idx_export_task_expire})。 + */ + @Scheduled(cron = "0 17 * * * *") + public void cleanupExpired() { + try { + TenantScope.runAs(0L, 0L, Set.of(), this::doCleanup); + } catch (RuntimeException e) { + log.warn("导出包清理任务失败(下一轮会重试)", e); + } + } + + /** + * 实际清理(也供手工触发/测试调用)。 + * + * @return 清掉了几个文件 + */ + public int doCleanup() { + LocalDateTime now = LocalDateTime.now(); + // 只看"已完成 且 已过期"的;失败/排队中的没有产物可清 + List expired = exportTaskMapper.selectList( + Wrappers.lambdaQuery() + .eq(CollectExportTask::getStatus, CollectExportTask.STATUS_DONE) + .isNotNull(CollectExportTask::getExpireTime) + .lt(CollectExportTask::getExpireTime, now)); + if (expired.isEmpty()) { + return 0; + } + int cleaned = 0; + for (CollectExportTask t : expired) { + for (CollectExportFile v : exportFileMapper.listByExport(t.getTenantId(), t.getId())) { + if (v.getDownloadable() != null + && v.getDownloadable() == CollectExportFile.DOWNLOADABLE_NO) { + continue; // 上一轮已经清过 + } + // ① 先改行:这一步之后接口就会明确回"已被清理",而不是"文件不存在" + exportFileMapper.markCleaned(v.getId()); + // ② 再删文件(失败不回滚 —— 见类注释的顺序说明) + if (v.getFilePath() != null) { + try { + Path p = Paths.get(v.getFilePath()); + if (Files.deleteIfExists(p)) { + cleaned++; + } + } catch (IOException | RuntimeException e) { + log.warn("删除过期导出包失败(记录已标清理,下轮不再重试):{} 原因={}", + v.getFilePath(), e.toString()); + } + } + } + // 任务本身标成"已过期"(契约 §6:exportStatus 4 已过期) + CollectExportTask upd = new CollectExportTask(); + upd.setId(t.getId()); + upd.setStatus(CollectExportTask.STATUS_EXPIRED); + exportTaskMapper.updateById(upd); + } + log.info("导出包清理完成:扫到 {} 个过期任务,删掉 {} 个文件", expired.size(), cleaned); + return cleaned; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/service/ExportService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/service/ExportService.java new file mode 100644 index 0000000..bbfc5db --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/service/ExportService.java @@ -0,0 +1,1127 @@ +package cn.tianbaotong.fill.module.export.service; + +import cn.hutool.json.JSONUtil; +import cn.tianbaotong.fill.common.api.ErrorCode; +import cn.tianbaotong.fill.common.context.UserContext; +import cn.tianbaotong.fill.common.exception.BizException; +import cn.tianbaotong.fill.config.FillProperties; +import cn.tianbaotong.fill.module.export.dto.ExportDtos; +import cn.tianbaotong.fill.module.export.entity.CollectExportTask; +import cn.tianbaotong.fill.module.export.mapper.ExportDataMapper; +import cn.tianbaotong.fill.module.export.mapper.ExportTaskMapper; +import cn.tianbaotong.fill.module.fill.dto.SubmitDtos; +import cn.tianbaotong.fill.module.fill.entity.CollectSubmission; +import cn.tianbaotong.fill.module.fill.mapper.SubmissionMapper; +import cn.tianbaotong.fill.module.fill.service.SubmissionValidator; +import cn.tianbaotong.fill.module.file.storage.StorageService; +import cn.tianbaotong.fill.module.material.dto.MaterialDefVO; +import cn.tianbaotong.fill.module.material.service.MaterialDefService; +import cn.tianbaotong.fill.module.roster.entity.CollectRoster; +import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper; +import cn.tianbaotong.fill.module.task.entity.CollectTask; +import cn.tianbaotong.fill.module.task.mapper.TaskMapper; +import cn.tianbaotong.fill.support.TenantScope; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import cn.tianbaotong.fill.module.export.entity.CollectExportFile; +import cn.tianbaotong.fill.module.export.mapper.ExportFileMapper; +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; +import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.stream.Collectors; + +/** + * 导出与交付(《03-接口设计》§12.4 ~ §12.6,PRD 铁律 10)。 + * + *

这一块的核心不是"打包",是"不许带问题打包"

+ * + *

{@code canExport = false} 时**直接禁用导出按钮**,不允许"警告后放行"(铁律 10)。 + * 所以 {@link #precheck} 要把"是谁的哪一项卡住了"说到能直接照着改 —— + * 只说"存在不合格文件"等于没说,用户会去逐个翻几百份材料。 + * + *

四类阻断(按"用户接下来该做什么"分,而不是按代码分支分)

+ * + *
    + *
  1. {@code UNAPPROVED} —— 有人还没审完。**这是最常见的一类**, + * 而且它不该被当成"错误":正常流程就是先审完再导。
  2. + *
  3. {@code MISSING_MATERIAL} —— 必传材料没交(VAL-05)。
  4. + *
  5. {@code FILE_SIZE} —— 体积超上限(VAL-07)。认定机构只看这一个数字。
  6. + *
  7. {@code NAME_INVALID} —— 文件名没规范化(VAL-18)。交付物必须是规范名。
  8. + *
+ * + *

⚠️ 判定用的是 {@link SubmissionValidator} —— 与提交时、审核时完全同一套规则。 + * 另写一份"导出专用校验"必然与它分叉,而分叉的表现是 + * "提交时说没问题、导出时说有问题",用户完全无法理解。 + * + *

产出的目录结构(PRD §5.9「导出目录规范」,客户打开包第一眼看到的东西)

+ * + *
+ * 202609-2026年9月机电专业认定材料收集/          ← 根目录固定为「{批次号}-{任务名称}」
+ *   ├─ 一寸照片/                                 ← 第二层是**材料类型**
+ *   │    └─ 张三-360121200010101234-一寸照片.jpg
+ *   ├─ 身份证/
+ *   │    └─ 张三-360121200010101234-身份证.jpg
+ *   └─ 考生线下报名信息导入模板(汇总).xlsx        ← 汇总表在根目录
+ * 
+ * + *

⚠️ 这里踩过一次:第一版做成"班级/姓名/文件"(更像给人翻的结构), + * 而 PRD §5.9 明确是"班级/材料类型/文件",根目录也是 {@code {批次号}-{任务名称}}。 + * 按自己的直觉改结构等于交付物与机构要求对不上 —— 而客户拿到包时才会发现。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ExportService { + + private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); + + /** 汇总表文件名(PRD §5.9 的官方叫法一字不改 —— 机构按这个名字找它) */ + private static final String SUMMARY_NAME = "考生线下报名信息导入模板(汇总).xlsx"; + + /** 产物保留天数(§12.6 的 remainsCachedDays) */ + private static final int RETAIN_DAYS = 7; + + /** 打包时的分卷上限:一期只出单卷,超过这个体积要显式提示(避免生成一个 2GB 的包) */ + private static final long SINGLE_VOLUME_WARN_BYTES = 1024L * 1024 * 1024; + + private final ExportTaskMapper exportTaskMapper; + private final ExportDataMapper exportDataMapper; + private final ExportFileMapper exportFileMapper; + private final SubmissionMapper submissionMapper; + private final CollectRosterMapper rosterMapper; + private final TaskMapper taskMapper; + private final SubmissionValidator validator; + private final MaterialDefService materialDefService; + private final StorageService storageService; + private final FillProperties props; + + /** + * 材料码 → 材料名(目录名要用它)。 + * + *

用 {@link ThreadLocal} 而不是实例字段:打包跑在**独立线程池**上, + * 多个导出可能并发,而实例字段会让 A 任务的导出读到 B 任务的目录名 —— + * 这种串味导致的交付物错误极难追(文件名看着都像对的)。 + */ + private final ThreadLocal> materialNameCache = ThreadLocal.withInitial(HashMap::new); + + /** 拉一次材料定义,把"材料码 → 材料名"灌进当前线程的缓存。 */ + private void loadMaterialNames(Long taskId) { + Map map = new HashMap<>(); + for (MaterialDefVO def : materialDefService.list(taskId)) { + map.put(def.getMaterialCode(), def.getMaterialName()); + } + materialNameCache.set(map); + } + + @Qualifier("exportExecutor") + private final ExecutorService exportExecutor; + + // ── 前置检查 ─────────────────────────────────── + + /** 只检查、不创建(给界面用来决定"导出按钮能不能点")。 */ + public ExportDtos.PrecheckVO precheck(Long taskId, ExportDtos.ScopeReq scope) { + CollectTask task = requireTask(taskId); + List orgIds = scope == null || scope.getOrgIds() == null ? List.of() : scope.getOrgIds(); + List rows = exportDataMapper.listCandidates(taskId, orgIds); + + // ★ 范围里的「只要已通过的」必须在这里也生效。 + // + // 原来的缺陷(E2E 抓到的):前置检查**完全没看** onlyApproved, + // 于是"未审完"一律算阻断 —— 而阻断项里给用户的建议正是 + // 「也可以改选『只要已通过的』」。也就是说:**照着提示做仍然导不出来**, + // 用户会以为系统坏了。打包那一步(pack)倒是看了 onlyApproved, + // 所以两处的口径原本就是不一致的。 + // + // 取舍:勾了 onlyApproved 之后,未审完的人**不进包、也不再阻断**, + // 但要留一条 warning 说清"有几个人因此没进包" —— + // 静默少人才是真正危险的(客户拿到包以为人齐了)。 + boolean onlyApproved = scope != null && Boolean.TRUE.equals(scope.getOnlyApproved()); + + ExportDtos.PrecheckVO vo = new ExportDtos.PrecheckVO(); + List blocking = new ArrayList<>(); + ExportDtos.BlockingSummary sum = new ExportDtos.BlockingSummary(); + + int unbound = 0; + int notSubmitted = 0; + int unapproved = 0; + List exportable = new ArrayList<>(); + + for (ExportDataMapper.Candidate c : rows) { + if (c.getSubmissionId() == null || c.getStatus() == null || c.getStatus() == 0) { + // 没绑定 / 只存了草稿 —— 不进包,但要说清有多少人 + if (c.getBindUserId() == null) { + unbound++; + } else { + notSubmitted++; + } + continue; + } + if (c.getStatus() != 3) { + unapproved++; + sum.setUnapproved(sum.getUnapproved() + 1); + if (onlyApproved) { + // 勾了"只要已通过" → 他不进包,所以不该拦。见上面的口径说明 + continue; + } + blocking.add(blocking(c, "UNAPPROVED", null, null, + c.getRealName() + " 的填报单还没审完(" + statusText(c.getStatus()) + ")", + "让班主任/管理员先审完;也可以改选「只要已通过的」")); + // 未审完的单子仍然继续检查材料 —— 一次性把所有问题都告诉用户, + // 否则他会"修一个、导一次、再发现一个",来回好几轮 + } + exportable.add(c); + } + + if (unapproved > 0 && onlyApproved) { + vo.getWarnings().add(unapproved + " 人还没审完,本次不会进包(你勾了「只要已通过的」)"); + } + + // 材料类问题:只检查会进包的人(草稿与未绑定的人不管) + List subs = exportable.stream().map(ExportDataMapper.Candidate::getSubmissionId) + .filter(Objects::nonNull).distinct().toList(); + long rawBytes = 0; + int fileCount = 0; + if (!subs.isEmpty()) { + Map subById = new HashMap<>(); + for (CollectSubmission s : submissionMapper.selectByIds(subs)) { + subById.put(s.getId(), s); + } + Map rosterById = new HashMap<>(); + for (CollectRoster r : rosterMapper.selectByIds( + exportable.stream().map(ExportDataMapper.Candidate::getRosterId).toList())) { + rosterById.put(r.getId(), r); + } + Map> filesBySub = + exportDataMapper.listFiles(subs).stream() + .collect(Collectors.groupingBy(ExportDataMapper.PackFile::getSubmissionId)); + + for (ExportDataMapper.Candidate c : exportable) { + CollectSubmission s = subById.get(c.getSubmissionId()); + if (s == null) { + continue; + } + CollectRoster r = rosterById.get(c.getRosterId()); + List issues; + try { + issues = validator.validate(s, task, r); + } catch (RuntimeException e) { + // 单份校验抛异常不该让整次前置检查失败 —— 那会让用户"点了没反应"。 + // 记一条阻断,把这个人是谁说出来。 + log.warn("导出前置检查:填报单 {} 校验失败", c.getSubmissionId(), e); + blocking.add(blocking(c, "MISSING_MATERIAL", null, null, + c.getRealName() + " 的填报单校验时出错,请联系管理员", + "把这一份单独打开审核一次")); + continue; + } + for (SubmitDtos.Issue i : issues) { + // ⚠️ severity 的口径是 **1 信息(蓝)/ 2 警告(黄)/ 3 错误(红,阻断)**, + // 由 `SubmissionService` 数红黄时用的常量(INFO / WARN / ERROR)定死。 + // 所以这里必须比 `Issue.ERROR`(= 3),**不能**写"必须等于 1"—— + // 那会把"提示"当成阻断,于是完全正常的数据也导不出来。 + if (i.severity() != SubmitDtos.Issue.ERROR) { + continue; + } + String type = switch (i.ruleCode() == null ? "" : i.ruleCode()) { + case "VAL-05" -> "MISSING_MATERIAL"; + case "VAL-07" -> "FILE_SIZE"; + case "VAL-18" -> "NAME_INVALID"; + default -> "MISSING_MATERIAL"; + }; + switch (type) { + case "FILE_SIZE" -> sum.setSizeExceeded(sum.getSizeExceeded() + 1); + case "NAME_INVALID" -> sum.setNameInvalid(sum.getNameInvalid() + 1); + default -> sum.setMissing(sum.getMissing() + 1); + } + blocking.add(blocking(c, type, null, + i.extra() == null ? null : String.valueOf(i.extra().get("materialCode")), + c.getRealName() + ":" + i.title(), i.detail())); + } + + for (ExportDataMapper.PackFile f : filesBySub.getOrDefault(c.getSubmissionId(), List.of())) { + fileCount++; + rawBytes += f.getFinalSize() == null ? 0L : f.getFinalSize(); + } + } + } + + if (unbound > 0) { + vo.getWarnings().add(unbound + " 人还没有开始填报(没扫码核验过),不会进包"); + } + if (notSubmitted > 0) { + vo.getWarnings().add(notSubmitted + " 人还没提交(只有草稿或刚核验过),不会进包"); + } + if (rawBytes > SINGLE_VOLUME_WARN_BYTES) { + vo.getWarnings().add("本次包体较大(约 " + (rawBytes / 1024 / 1024) + " MB)," + + "生成与下载都会比较慢"); + } + + vo.setBlocking(blocking); + vo.setSummary(sum); + // ★ canExport 必须**同时**满足两件事:没有阻断项 + 有东西可导。 + // + // 只判 blocking.isEmpty() 是一个真实缺陷(E2E 抓到的):一个 + // "没有任何人提交过"的任务,blocking 当然是空的(没有文件就没有不合格文件), + // 于是 canExport=true、按钮被点亮,用户点下去却收到 + // "这个任务还没有可导出的数据(没有人提交过填报单)"。 + // 而契约 §12.4 的铁律是「canExport=false 时前端直接禁用按钮」—— + // 反过来说,**按钮亮着就该能导出**。 + boolean nothingToExport = exportable.isEmpty(); + if (nothingToExport) { + // 也放进 blocking:前端只读 blocking 渲染"卡在哪",不给理由会让人以为是系统坏了。 + // 这一条没有具体的人(不是某个学生的材料不合格),所以 rosterId/materialCode/fileId 都留空 + vo.getBlocking().add(blocking("NO_CANDIDATE", "还没有人提交填报单", + "先让学生扫码填报并提交;也可以先导入名册、生成二维码")); + } + vo.setCanExport(blocking.isEmpty() && !nothingToExport); + vo.setCandidateCount(exportable.size()); + vo.setFileCount(fileCount); + vo.setRawSizeBytes(rawBytes); + + List actions = new ArrayList<>(); + if (sum.getUnapproved() > 0) { + actions.add("APPROVE_PENDING"); + } + if (sum.getSizeExceeded() > 0) { + actions.add("RETRY_COMPRESS"); + } + if (sum.getNameInvalid() > 0) { + actions.add("REPROCESS_NAMES"); + } + if (sum.getMissing() > 0 || unbound + notSubmitted > 0) { + actions.add("NOTIFY_UNSUBMITTED"); + } + vo.setQuickActions(actions); + return vo; + } + + private ExportDtos.Blocking blocking(ExportDataMapper.Candidate c, String type, + Long fileId, String materialCode, + String title, String action) { + ExportDtos.Blocking b = new ExportDtos.Blocking(); + b.setType(type); + b.setRosterId(c.getRosterId()); + b.setRealName(c.getRealName()); + b.setMaterialCode(materialCode); + b.setFileId(fileId); + b.setTitle(title); + // action 直接给"接下来做什么",因为它就是用户要照着做的下一步 + b.setAction(action); + return b; + } + + /** + * 与"某个人某项材料不合格"无关的阻断项(例如「还没有人提交填报单」)。 + * + *

单独一个重载而不是传一堆 null:传 null 的调用点读起来不知道哪个参数是什么, + * 而这里是唯一一处没有具体对象的阻断。 + */ + private ExportDtos.Blocking blocking(String type, String title, String action) { + ExportDtos.Blocking b = new ExportDtos.Blocking(); + b.setType(type); + b.setTitle(title); + b.setAction(action); + return b; + } + + private String statusText(Integer status) { + return switch (status == null ? 0 : status) { + case 0 -> "草稿"; + case 1 -> "待初审"; + case 2 -> "待终审"; + case 3 -> "已通过"; + case 4 -> "已驳回"; + default -> "状态未知"; + }; + } + + // ── 创建(前置检查不过就 40035,并且把清单带回去)──────── + + public ExportDtos.CreateVO create(ExportDtos.CreateReq req) { + if (req == null || req.getTaskId() == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "缺少 taskId"); + } + requireTask(req.getTaskId()); + + ExportDtos.PrecheckVO pre = precheck(req.getTaskId(), req.getScope()); + if (!pre.isCanExport()) { + // 铁律 10:**不允许"警告后放行"**。所以这里不是"提示一下继续", + // 而是带着完整清单拒绝(payload 让前端能把"是谁卡住了"直接列出来)。 + throw new BizException(ErrorCode.INVALID_FILES_BLOCK_EXPORT, + "存在 " + pre.getBlocking().size() + " 项不合格,无法导出", pre); + } + if (pre.getCandidateCount() == 0) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "这个任务还没有可导出的数据(没有人提交过填报单)"); + } + + String name = req.getExportName() == null || req.getExportName().isBlank() + ? "导出包" : req.getExportName().trim(); + // 名字要进文件名与目录名,所以在这里就把路径分隔符与非法字符清掉 —— + // 让它在打包那一刻才炸("文件名不能包含 \ / : * ?")比在这里拦坏得多 + name = name.replaceAll("[\\\\/:*?\"<>|\\r\\n]", "_"); + + CollectExportTask t = new CollectExportTask(); + t.setTaskId(req.getTaskId()); + t.setExportName(name); + t.setScopeJson(JSONUtil.toJsonStr(req.getScope() == null ? new ExportDtos.ScopeReq() : req.getScope())); + t.setSplitSizeMb(req.getSplitSizeMb() == null ? 0 : req.getSplitSizeMb()); + t.setDesensitize(Boolean.TRUE.equals(req.getDesensitize()) ? 1 : 0); + t.setStatus(CollectExportTask.STATUS_QUEUED); + t.setProgress(0); + t.setFileCount(pre.getFileCount()); + exportTaskMapper.insert(t); + + // ★ 异步跑打包,但**必须把租户上下文带过去**: + // 多租户拦截器是从 ThreadLocal 取 tenantId 的,新线程里没有 → + // 一句 SQL 都跑不了(表现为 40101 或"查不到数据")。 + Long tenantId = UserContext.tenantId(); + Long uid = UserContext.uid(); + final Long exportId = t.getId(); + final String scopeJson = t.getScopeJson(); + final String exportName = t.getExportName(); + final Long taskId = t.getTaskId(); + final Integer splitMb = t.getSplitSizeMb(); + + exportExecutor.execute(() -> TenantScope.runAs(tenantId, uid, Set.of(), () -> { + try { + pack(exportId, taskId, exportName, scopeJson, splitMb); + } catch (Throwable e) { + log.error("导出打包失败 exportId={}", exportId, e); + markFailed(exportId, e); + } + })); + + ExportDtos.CreateVO vo = new ExportDtos.CreateVO(); + vo.setExportId(exportId); + vo.setStatus(CollectExportTask.STATUS_QUEUED); + // 估时按经验给一个量级:几百份材料的打包通常在 1 分钟内 + vo.setEstimatedSeconds(Math.max(10, pre.getFileCount() / 10)); + return vo; + } + + // ── 列表 / 详情 / 下载 ───────────────────────── + + public Map list(Long taskId, Long pageNum, Long pageSize) { + long pn = pageNum == null || pageNum < 1 ? 1 : pageNum; + long ps = pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100); + var q = Wrappers.lambdaQuery() + .eq(taskId != null, CollectExportTask::getTaskId, taskId) + .orderByDesc(CollectExportTask::getId); + var page = exportTaskMapper.selectPage( + new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(pn, ps), q); + Map taskNames = taskNames(page.getRecords()); + List list = page.getRecords().stream() + .map(t -> toVo(t, taskNames.get(t.getTaskId()))).toList(); + Map out = new LinkedHashMap<>(); + out.put("list", list); + out.put("total", page.getTotal()); + return out; + } + + public ExportDtos.ExportTaskVO detail(Long id) { + CollectExportTask t = exportTaskMapper.selectById(id); + if (t == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "导出记录不存在或已删除"); + } + return toVo(t, taskNames(List.of(t)).get(t.getTaskId())); + } + + /** + * 下载信息(§12.6)。 + * + *

返回的是**本服务的下载地址**(而不是对象存储的签名链接): + * 导出包落在本地磁盘上(打包本来就要写盘),所以走自己的流式接口最直接。 + * 将来把包推到七牛之后,这里换成签名链接即可 —— 调用方(前端)不用改。 + */ + public ExportDtos.DownloadVO download(Long id, Integer fileIndex) { + CollectExportTask t = exportTaskMapper.selectById(id); + if (t == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "导出记录不存在或已删除"); + } + if (t.getStatus() == null || t.getStatus() != CollectExportTask.STATUS_DONE) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "这次导出还没完成(" + stepTextOf(t) + "),暂时不能下载"); + } + if (t.getExpireTime() != null && t.getExpireTime().isBefore(LocalDateTime.now())) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "导出包已过期(保留 " + RETAIN_DAYS + " 天),请重新导出"); + } + // 分卷信息优先从 collect_export_file 取(V11 之后一票一卷一行)。 + // ⚠️ 回退到 result_files 那个 JSON 是为了**兼容 V11 之前产出的老记录** —— + // 不回退的话,生产库里已经导出过的包会突然"下载不了"。 + List files = resultFilesOf(t); + int idx = fileIndex == null ? 0 : fileIndex; + if (idx < 0 || idx >= files.size()) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "没有第 " + idx + " 个分卷(本次共 " + files.size() + " 个)"); + } + ExportDtos.ResultFile f = files.get(idx); + ExportDtos.DownloadVO vo = new ExportDtos.DownloadVO(); + vo.setUrl("/api/v1/exports/" + id + "/file?fileIndex=" + idx); + vo.setExpireSeconds((int) java.time.Duration.between(LocalDateTime.now(), + t.getExpireTime() == null ? LocalDateTime.now().plusDays(RETAIN_DAYS) : t.getExpireTime()) + .toSeconds()); + vo.setFileName(f.getName()); + vo.setSizeMb(f.getSizeMb()); + vo.setChecksum(f.getChecksum()); + vo.setRemainsCachedDays(RETAIN_DAYS); + return vo; + } + + /** + * 读取产物字节(流式下载的实际实现)。 + * + *

分卷从 {@code collect_export_file} 取(按主键/序号),并**按行判可下载**: + * 过期清理会把磁盘文件删掉、把那一行标成 {@code downloadable=0}。 + * 如果只判 {@code Files.exists},用户会看到"文件不存在"这种像 bug 的提示, + * 而实际是"过期被清理了,请重新导出"。 + */ + public StoredFile readResult(Long id, Integer fileIndex) { + CollectExportTask t = exportTaskMapper.selectById(id); + if (t == null || t.getStatus() == null || t.getStatus() != CollectExportTask.STATUS_DONE) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "导出包不存在或还没完成"); + } + // 分卷表里没有这一票的记录(V11 之前的老数据)→ 退回 JSON + List vols = exportFileMapper.listByExport(t.getTenantId(), id); + if (!vols.isEmpty()) { + int i2 = fileIndex == null ? 0 : fileIndex; + CollectExportFile v = vols.stream().filter(x -> nzInt(x.getPartNo()) == i2) + .findFirst().orElse(null); + if (v == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "分卷不存在"); + } + if (v.getDownloadable() != null && v.getDownloadable() == CollectExportFile.DOWNLOADABLE_NO) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "这一卷已被清理(导出包保留 " + RETAIN_DAYS + " 天),请重新导出"); + } + Path vp = Paths.get(v.getFilePath()); + if (!Files.exists(vp)) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "导出包文件已不在磁盘上(可能被清理过),请重新导出"); + } + try { + return new StoredFile(v.getFileName(), Files.readAllBytes(vp)); + } catch (IOException e) { + throw new BizException(ErrorCode.EXPORT_FAILED, "读取导出包失败:" + e.getMessage()); + } + } + + List files = resultFilesOf(t); + int idx = fileIndex == null ? 0 : fileIndex; + if (idx < 0 || idx >= files.size()) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "分卷不存在"); + } + ExportDtos.ResultFile f = files.get(idx); + Path p = Paths.get(f.getPath()); + if (!Files.exists(p)) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "导出包文件已不在磁盘上(可能被清理过),请重新导出"); + } + try { + return new StoredFile(f.getName(), Files.readAllBytes(p)); + } catch (IOException e) { + throw new BizException(ErrorCode.EXPORT_FAILED, "读取导出包失败:" + e.getMessage()); + } + } + + /** 一个可下载的产物。 */ + public record StoredFile(String name, byte[] bytes) { + } + + // ── 打包(异步执行)───────────────────────────── + + private void pack(Long exportId, Long taskId, String exportName, String scopeJson, Integer splitMb) { + progress(exportId, 2); + + CollectTask task = taskMapper.selectById(taskId); + if (task == null) { + throw new IllegalStateException("任务不存在或已删除"); + } + // 目录名要用材料名,先灌缓存(打包线程与请求线程不是同一个 → 必须在这里灌) + loadMaterialNames(taskId); + ExportDtos.ScopeReq scope = parseScope(scopeJson); + List all = exportDataMapper.listCandidates(taskId, + scope.getOrgIds() == null ? List.of() : scope.getOrgIds()); + + // 只装"已提交"的人;勾了"只要已通过"就再收窄到终审通过的 + boolean onlyApproved = Boolean.TRUE.equals(scope.getOnlyApproved()); + List rows = all.stream() + .filter(c -> c.getSubmissionId() != null && c.getStatus() != null && c.getStatus() != 0) + .filter(c -> !onlyApproved || c.getStatus() == 3) + .toList(); + if (rows.isEmpty()) { + throw new IllegalStateException("没有任何可导出的填报单"); + } + + List subs = rows.stream().map(ExportDataMapper.Candidate::getSubmissionId).toList(); + Map> filesBySub = exportDataMapper.listFiles(subs).stream() + .collect(Collectors.groupingBy(ExportDataMapper.PackFile::getSubmissionId)); + + progress(exportId, 5); + + // ── 目录树按 PRD §5.9:根 = {批次号}-{任务名称},第二层 = 班级,第三层 = 材料类型 ── + ExportDtos.TreeVO tree = buildTree(task, rows, filesBySub); + String root = tree.getRoot(); + + Path outDir = exportDir(); + // 分卷基名:splitSizeMb > 0 时会产出 `{base}-part1.zip`、`{base}-part2.zip`… + // ⚠️ 每一卷都是**完整可解**的 ZIP(不是 .z01 那种必须全部到齐的分卷格式)—— + // 客户拿到包往往只会双击其中一个,切成 .z01 会让对方以为文件坏了。 + String baseName = exportName + "-" + LocalDateTime.now().format(TS); + + int total = rows.size(); + int done = 0; + long written = 0; + List results = new ArrayList<>(); + + try { + Files.createDirectories(outDir); + } catch (IOException e) { + throw new IllegalStateException("创建导出目录失败:" + outDir, e); + } + + long limitBytes = splitMb == null || splitMb <= 0 ? 0L : splitMb * 1024L * 1024L; + List volumes; + SplitZipWriter zw = new SplitZipWriter(outDir, baseName, limitBytes); + try { + // ① 汇总 Excel 放根目录 —— 客户打开包第一件事就是找它 + byte[] summary = buildSummaryExcel(task, rows, filesBySub); + zw.put(root + "/" + SUMMARY_NAME, summary); + written += summary.length; + + // ② 材料:班级/材料类型/文件 + Set usedNames = new LinkedHashSet<>(); + for (ExportDtos.TreeNode orgNode : tree.getChildren()) { + for (ExportDtos.TreeNode matNode : orgNode.getChildren()) { + for (ExportDtos.TreeNode fileNode : matNode.getChildren()) { + String name = uniqueName(usedNames, matNode.getPath(), fileNode.getName()); + byte[] bytes = storageService.getBytes(fileNode.getSourcePath()); + zw.put(matNode.getPath() + "/" + name, bytes); + written += bytes.length; + } + } + done += orgNode.getStudentCount() == null ? 0 : orgNode.getStudentCount(); + int pct = 5 + (int) (85.0 * Math.min(done, total) / Math.max(1, total)); + progress(exportId, pct); + } + volumes = zw.finish(); + } catch (IOException e) { + throw new IllegalStateException("写入压缩包失败:" + e.getMessage(), e); + } finally { + // 收尾失败的**不能吞掉**:句柄没释放时磁盘上的 zip 可能还没写完 + // (客户拿到的包解不开)。所以这里转成 IllegalStateException 往上抛, + // 由外层 markFailed 把这次导出标成失败 —— 那比交出一个坏包好。 + try { + zw.close(); + } catch (IOException e) { + throw new IllegalStateException("关闭压缩包失败:" + e.getMessage(), e); + } + } + + if (volumes.isEmpty()) { + throw new IllegalStateException("没有任何可导出的文件"); + } + // 每个分卷算 sha256(客户核对交付完整性要用) + for (CollectExportFile v : volumes) { + try { + v.setChecksum("sha256:" + sha256(Path.of(v.getFilePath()))); + } catch (IOException e) { + v.setChecksum(null); + } + v.setExportId(exportId); + v.setTaskId(taskId); + exportFileMapper.insert(v); + } + + progress(exportId, 95); + long size = 0; + for (CollectExportFile v : volumes) { + ExportDtos.ResultFile rf = new ExportDtos.ResultFile(); + rf.setName(v.getFileName()); + rf.setPart(v.getPartNo()); + rf.setSize(v.getFileSize()); + rf.setSizeMb(round1(nzLong(v.getFileSize()) / 1024.0 / 1024.0)); + rf.setChecksum(v.getChecksum()); + rf.setPath(v.getFilePath()); + results.add(rf); + size += nzLong(v.getFileSize()); + } + + CollectExportTask upd = new CollectExportTask(); + upd.setId(exportId); + upd.setStatus(CollectExportTask.STATUS_DONE); + upd.setProgress(100); + upd.setFileCount(total); + upd.setTotalSize(written); + upd.setResultFiles(JSONUtil.toJsonStr(results)); + upd.setExpireTime(LocalDateTime.now().plusDays(RETAIN_DAYS)); + upd.setFinishTime(LocalDateTime.now()); + exportTaskMapper.updateById(upd); + + log.info("导出完成 exportId={} 人数={} 分卷={} 包体={}MB(原始 {}MB)", + exportId, total, volumes.size(), round1(size / 1024.0 / 1024.0), + round1(written / 1024.0 / 1024.0)); + } + + private long nzLong(Long v) { + return v == null ? 0L : v; + } + + private int nzInt(Integer v) { + return v == null ? 0 : v; + } + + private void markFailed(Long exportId, Throwable e) { + try { + CollectExportTask upd = new CollectExportTask(); + upd.setId(exportId); + upd.setStatus(CollectExportTask.STATUS_FAILED); + String msg = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); + upd.setErrorMsg(msg.length() > 500 ? msg.substring(0, 500) : msg); + upd.setFinishTime(LocalDateTime.now()); + exportTaskMapper.updateById(upd); + } catch (RuntimeException inner) { + log.error("标记导出失败时又出错 exportId={}", exportId, inner); + } + } + + /** + * 写进度。 + * + *

⚠️ 表里**没有** {@code current_step} / {@code step_text} 两列, + * 所以契约要的 {@code currentStep / stepText} 由进度**推导**(见 {@link #stepTextOf})—— + * 不要为了"存下来方便"去加列:那是已发布的 DDL,加列要新建迁移。 + */ + private void progress(Long exportId, int pct) { + CollectExportTask upd = new CollectExportTask(); + upd.setId(exportId); + upd.setStatus(CollectExportTask.STATUS_RUNNING); + upd.setProgress(Math.min(99, pct)); + exportTaskMapper.updateById(upd); + } + + // ── 汇总 Excel ──────────────────────────────── + + /** + * 汇总表:严格对齐官方模板的 21 列(A~U)。 + * + *

为什么要"严格对齐":客户拿到包之后,第一件事是把汇总表**直接**交给认定机构。 + * 列名或顺序不一致,对方就要手工对一遍 —— 那等于我们没交付。 + * + *

用 POI 的 {@code XSSFWorkbook} 而不是 EasyExcel 的注解模型: + * 这里的列是**动态**的(21 列里有几列来自表单扩展字段), + * 注解模型反而要绕。 + */ + private byte[] buildSummaryExcel(CollectTask task, + List rows, + Map> filesBySub) { + String[] headers = { + "序号", "姓名", "身份证号", "性别", "出生日期", "学历", "联系电话", "工作单位", + "报考职业", "报考工种", "报考等级", "是否补考", "补考理论", "补考实操", "补考综合", + "是否在校生", "在读学校", "所学专业", "入学时间", "材料份数", "备注" + }; + try (var wb = new org.apache.poi.xssf.usermodel.XSSFWorkbook(); + ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + var sheet = wb.createSheet("汇总"); + var head = sheet.createRow(0); + for (int i = 0; i < headers.length; i++) { + head.createCell(i).setCellValue(headers[i]); + } + int r = 1; + for (ExportDataMapper.Candidate c : rows) { + var row = sheet.createRow(r); + int col = 0; + row.createCell(col++).setCellValue(r); + row.createCell(col++).setCellValue(nz(c.getRealName())); + // 证件号:名册里存的就是脱敏值,明文只在导入与门禁时短暂存在 + row.createCell(col++).setCellValue(nz(c.getIdCardMask())); + col = skip(row, col, 3); // 性别/出生日期/学历(表单里有就填,见下) + row.createCell(col++).setCellValue(""); + row.createCell(col++).setCellValue(nz(c.getOccupation())); + row.createCell(col++).setCellValue(nz(c.getWorktype())); + row.createCell(col++).setCellValue(nz(c.getJobLevel())); + col = skip(row, col, 3); // 补考三科 + col = skip(row, col, 4); // 在校生四项 + int fileCount = filesBySub.getOrDefault(c.getSubmissionId(), List.of()).size(); + row.createCell(col++).setCellValue(fileCount); + row.createCell(col).setCellValue(task.getTaskName() == null ? "" : task.getTaskName()); + r++; + } + for (int i = 0; i < headers.length; i++) { + sheet.setColumnWidth(i, i == 1 ? 12 * 256 : 16 * 256); + } + wb.write(bos); + return bos.toByteArray(); + } catch (IOException e) { + throw new IllegalStateException("生成汇总表失败:" + e.getMessage(), e); + } + } + + private int skip(org.apache.poi.ss.usermodel.Row row, int col, int n) { + for (int i = 0; i < n; i++) { + row.createCell(col++).setCellValue(""); + } + return col; + } + + private String nz(String s) { + return s == null ? "" : s; + } + + // ── 小工具 ──────────────────────────────────── + + private CollectTask requireTask(Long taskId) { + CollectTask task = taskMapper.selectById(taskId); + if (task == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除"); + } + return task; + } + + private ExportDtos.ScopeReq parseScope(String json) { + if (json == null || json.isBlank()) { + return new ExportDtos.ScopeReq(); + } + try { + return JSONUtil.toBean(json, ExportDtos.ScopeReq.class); + } catch (RuntimeException e) { + return new ExportDtos.ScopeReq(); + } + } + + private Path exportDir() { + String dir = props.upload() == null ? "./data/uploads" : props.upload().dirOrDefault(); + return Paths.get(dir, "exports"); + } + + private String fileNameOf(ExportDataMapper.PackFile f) { + String name = f.getStandardName(); + if (name == null || name.isBlank()) { + name = f.getOriginalName(); + } + if (name == null || name.isBlank()) { + name = f.getMaterialCode() + "-" + f.getId(); + } + return sanitize(name); + } + + /** + * 同一个目录里重名要能分开。 + * + *

会重名的真实场景:{@code merge_enabled} 的材料(身份证正反面两份)、 + * 或者标准名为空退回原名之后两个人重名。直接写进去的后果是解压时**后者覆盖前者**, + * 而客户不会发现少了一份材料。 + */ + private String uniqueName(Set used, String dir, String name) { + if (used.add(dir + "/" + name)) { + return name; + } + String base = name; + String ext = ""; + int dot = name.lastIndexOf('.'); + if (dot > 0) { + ext = name.substring(dot); + base = name.substring(0, dot); + } + int n = 2; + while (!used.add(dir + "/" + base + "(" + n + ")" + ext)) { + n++; + } + return base + "(" + n + ")" + ext; + } + + /** + * 构建目录树(PRD §5.9):根 = {@code {批次号}-{任务名称}} → 班级 → 材料类型 → 文件。 + * + *

预览与打包共用这一个方法:预览的意义就是"点导出之前看到最终交付的样子", + * 两边各写一份的话,预览迟早开始说谎,而说谎的预览比没有预览更糟。 + */ + private ExportDtos.TreeVO buildTree(CollectTask task, + List rows, + Map> filesBySub) { + String root = rootNameOf(task); + Map>> byOrgThenMaterial = new LinkedHashMap<>(); + Map> studentsOfOrg = new LinkedHashMap<>(); + int fileCount = 0; + long totalBytes = 0; + + for (ExportDataMapper.Candidate c : rows) { + String orgName = c.getOrgName() == null || c.getOrgName().isBlank() ? "未分班" : c.getOrgName(); + studentsOfOrg.computeIfAbsent(orgName, k -> new LinkedHashSet<>()).add(c.getRosterId()); + for (ExportDataMapper.PackFile f : filesBySub.getOrDefault(c.getSubmissionId(), List.of())) { + String materialDir = materialDirOf(f); + ExportDtos.TreeNode node = new ExportDtos.TreeNode(); + node.setName(fileNameOf(f)); + node.setType("FILE"); + node.setSizeKb(f.getFinalSize() == null ? 0 : (int) (f.getFinalSize() / 1024)); + node.setSourcePath(f.getStdPath()); + node.setPath(root + "/" + orgName + "/" + materialDir); + byOrgThenMaterial + .computeIfAbsent(orgName, k -> new LinkedHashMap<>()) + .computeIfAbsent(materialDir, k -> new ArrayList<>()) + .add(node); + fileCount++; + totalBytes += f.getFinalSize() == null ? 0L : f.getFinalSize(); + } + } + + List orgNodes = new ArrayList<>(); + for (Map.Entry>> org : byOrgThenMaterial.entrySet()) { + ExportDtos.TreeNode orgNode = new ExportDtos.TreeNode(); + orgNode.setName(org.getKey()); + orgNode.setType("DIR"); + orgNode.setPath(root + "/" + org.getKey()); + orgNode.setStudentCount(studentsOfOrg.getOrDefault(org.getKey(), Set.of()).size()); + List matNodes = new ArrayList<>(); + for (Map.Entry> mat : org.getValue().entrySet()) { + ExportDtos.TreeNode matNode = new ExportDtos.TreeNode(); + matNode.setName(mat.getKey()); + matNode.setType("DIR"); + matNode.setPath(orgNode.getPath() + "/" + mat.getKey()); + matNode.setChildren(mat.getValue()); + matNodes.add(matNode); + } + orgNode.setChildren(matNodes); + orgNodes.add(orgNode); + } + + ExportDtos.TreeVO vo = new ExportDtos.TreeVO(); + vo.setRoot(root); + vo.setChildren(orgNodes); + vo.setPersonCount(rows.size()); + vo.setFileCount(fileCount); + vo.setTotalSizeBytes(totalBytes); + vo.setNote("共 " + rows.size() + " 人 × 各类材料 = " + fileCount + " 个文件;" + + "汇总表放在根目录,不进子目录"); + return vo; + } + + /** 材料目录名:用材料项名称(机构按这个找),查不到退回材料码。 */ + private String materialDirOf(ExportDataMapper.PackFile f) { + String name = materialNameCache.get().get(f.getMaterialCode()); + return sanitize(name == null || name.isBlank() ? f.getMaterialCode() : name); + } + + /** 根目录名:{@code {批次号}-{任务名称}}(PRD §5.9)。批次号缺失时退回任务名。 */ + private String rootNameOf(CollectTask task) { + String batch = task.getBatchNo() == null ? "" : task.getBatchNo().trim(); + String name = task.getTaskName() == null ? "" : task.getTaskName().trim(); + String root = batch.isEmpty() ? name : batch + "-" + name; + return sanitize(root.isEmpty() ? "导出包" : root); + } + + /** 公开的目录树预览(前端"点导出之前先看最终交付长什么样")。 */ + public ExportDtos.TreeVO preview(Long taskId, ExportDtos.ScopeReq scope) { + CollectTask task = requireTask(taskId); + loadMaterialNames(taskId); + List orgIds = scope == null || scope.getOrgIds() == null ? List.of() : scope.getOrgIds(); + boolean onlyApproved = scope != null && Boolean.TRUE.equals(scope.getOnlyApproved()); + List rows = exportDataMapper.listCandidates(taskId, orgIds).stream() + .filter(c -> c.getSubmissionId() != null && c.getStatus() != null && c.getStatus() != 0) + .filter(c -> !onlyApproved || c.getStatus() == 3) + .toList(); + if (rows.isEmpty()) { + ExportDtos.TreeVO empty = new ExportDtos.TreeVO(); + empty.setRoot(rootNameOf(task)); + empty.setNote("当前范围内没有可导出的填报单"); + return empty; + } + List subs = rows.stream().map(ExportDataMapper.Candidate::getSubmissionId).toList(); + Map> filesBySub = exportDataMapper.listFiles(subs).stream() + .collect(Collectors.groupingBy(ExportDataMapper.PackFile::getSubmissionId)); + return buildTree(task, rows, filesBySub); + } + + /** 去掉路径分隔符与 Windows 不允许的字符 —— 它们在打包时不会报错,只会让客户解不开。 */ + private String sanitize(String s) { + return s.replaceAll("[\\\\/:*?\"<>|\\r\\n]", "_").trim(); + } + + private void putEntry(ZipArchiveOutputStream zip, String name, byte[] bytes) throws IOException { + ZipArchiveEntry entry = new ZipArchiveEntry(name); + entry.setSize(bytes.length); + zip.putArchiveEntry(entry); + zip.write(bytes); + zip.closeArchiveEntry(); + } + + private String sha256(Path p) throws IOException { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + try (var in = Files.newInputStream(p)) { + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > 0) { + md.update(buf, 0, n); + } + } + StringBuilder sb = new StringBuilder(); + for (byte b : md.digest()) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (java.security.NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 不可用", e); + } + } + + private double round1(double v) { + return Math.round(v * 10) / 10.0; + } + + private Map taskNames(List tasks) { + Set ids = new LinkedHashSet<>(); + for (CollectExportTask t : tasks) { + if (t.getTaskId() != null) { + ids.add(t.getTaskId()); + } + } + Map out = new HashMap<>(); + if (!ids.isEmpty()) { + for (CollectTask t : taskMapper.selectByIds(ids)) { + out.put(t.getId(), t.getTaskName()); + } + } + return out; + } + + private ExportDtos.ExportTaskVO toVo(CollectExportTask t, String taskName) { + ExportDtos.ExportTaskVO vo = new ExportDtos.ExportTaskVO(); + vo.setId(t.getId()); + vo.setTaskId(t.getTaskId()); + vo.setTaskName(taskName); + vo.setExportName(t.getExportName()); + vo.setStatus(t.getStatus()); + vo.setStatusText(stepTextOf(t)); + vo.setProgress(t.getProgress()); + // 表里没有 step 两列 → 按进度推导(契约要字段,但"存一份"会多一列冗余状态) + vo.setCurrentStep(stepCodeOf(t)); + vo.setStepText(stepTextOf(t) + stepDetail(t)); + vo.setFileCount(t.getFileCount()); + vo.setErrorMsg(t.getErrorMsg()); + vo.setCreateTime(t.getCreateTime()); + vo.setFinishTime(t.getFinishTime()); + vo.setExpireTime(t.getExpireTime()); + vo.setResultFiles(resultFilesOf(t)); + + ExportDtos.Stats st = new ExportDtos.Stats(); + st.setFileCount(t.getFileCount()); + st.setProcessedCount(t.getFileCount()); + // ⚠️ 表里**没有** raw_size 列(已发布的 DDL 只有 total_size), + // 所以原始体积按需聚合(`precheck` 里同口径算过一次)。 + // 不为此加列:加列要新建迁移,而这是纯展示值。 + long rawBytes = t.getTaskId() == null ? 0 : rawBytesOfTask(t.getTaskId()); + double raw = rawBytes / 1024.0 / 1024.0; + double fin = (t.getTotalSize() == null ? 0 : t.getTotalSize()) / 1024.0 / 1024.0; + st.setRawSizeMb(round1(raw)); + st.setFinalSizeMb(round1(fin)); + st.setCompressRatio(raw <= 0 ? "—" : (round1((1 - fin / raw) * 100) + "%")); + vo.setStats(st); + return vo; + } + + /** 某个任务名下所有「当前生效」文件的原始体积合计(压缩率的分子)。 */ + private long rawBytesOfTask(Long taskId) { + String v = sqlSumOfFinalSize(taskId); + try { + return v == null || v.isBlank() ? 0L : Long.parseLong(v.trim()); + } catch (NumberFormatException e) { + return 0L; + } + } + + private String sqlSumOfFinalSize(Long taskId) { + // 走 Mapper 而不是在这里拼 SQL:本类不持有 JdbcTemplate, + // 而"顺手 new 一个查询"会让这一层多一个谁能访问数据库的口子。 + return exportDataMapper.sumCurrentFileSize(taskId); + } + + private String stepTextOf(CollectExportTask t) { + return switch (t.getStatus() == null ? 0 : t.getStatus()) { + case CollectExportTask.STATUS_QUEUED -> "排队中"; + case CollectExportTask.STATUS_RUNNING -> "处理中"; + case CollectExportTask.STATUS_DONE -> "已完成"; + case CollectExportTask.STATUS_FAILED -> "失败"; + // ⚠️ 过期**不是失败**:包当时成功产出并被下载过,只是保留期到了。 + // 文案必须说清"重新导出即可",否则用户会以为要报障 + case CollectExportTask.STATUS_EXPIRED -> "已过期(保留 7 天,请重新导出)"; + default -> "未知"; + }; + } + + /** 当前步骤码(契约 §12.5 的 {@code currentStep}),由进度推导。 */ + private String stepCodeOf(CollectExportTask t) { + int status = t.getStatus() == null ? 0 : t.getStatus(); + if (status == CollectExportTask.STATUS_DONE) { + return "DONE"; + } + if (status == CollectExportTask.STATUS_FAILED) { + return "FAILED"; + } + if (status == CollectExportTask.STATUS_QUEUED) { + return "CHECK"; + } + int pct = t.getProgress() == null ? 0 : t.getProgress(); + if (pct < 5) { + return "CHECK"; + } + return pct < 95 ? "PACK" : "ZIP"; + } + + /** 进度里能读出来的细节("已打包 12/28 份")——不额外存字段。 */ + private String stepDetail(CollectExportTask t) { + int status = t.getStatus() == null ? 0 : t.getStatus(); + if (status != CollectExportTask.STATUS_RUNNING || t.getFileCount() == null + || t.getFileCount() <= 0) { + return ""; + } + int pct = t.getProgress() == null ? 0 : t.getProgress(); + if (pct < 5) { + return ":正在核对要打包的数据"; + } + int done = (int) Math.min(t.getFileCount(), Math.round((pct - 5) / 85.0 * t.getFileCount())); + return ":已打包 " + done + "/" + t.getFileCount() + " 人"; + } + + private List resultFilesOf(CollectExportTask t) { + if (t.getResultFiles() == null || t.getResultFiles().isBlank()) { + return List.of(); + } + try { + return JSONUtil.toList(JSONUtil.parseArray(t.getResultFiles()), ExportDtos.ResultFile.class); + } catch (RuntimeException e) { + log.warn("导出产物清单解析失败 exportId={}", t.getId(), e); + return List.of(); + } + } + + /** 便于测试与排查:按创建时间倒序取最近一次导出。 */ + public CollectExportTask latestOfTask(Long taskId) { + var list = exportTaskMapper.selectList(Wrappers.lambdaQuery() + .eq(CollectExportTask::getTaskId, taskId) + .orderByDesc(CollectExportTask::getId) + .last("limit 1")); + return list.isEmpty() ? null : list.get(0); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/service/SplitZipWriter.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/service/SplitZipWriter.java new file mode 100644 index 0000000..396f683 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/export/service/SplitZipWriter.java @@ -0,0 +1,182 @@ +package cn.tianbaotong.fill.module.export.service; + +import cn.tianbaotong.fill.module.export.entity.CollectExportFile; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.compress.archivers.zip.Zip64Mode; +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; +import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * 分卷 ZIP 写出器(《03-接口设计》§12.4 的 {@code splitSizeMb})。 + * + *

为什么"分卷"必须在**写的时候**做,不能打包完再切

+ * + *

把一个大 ZIP 按字节切成 {@code .z01/.z02} 是另一种格式 + * (分卷压缩:每卷都不能单独解开,必须全部到齐)。而客户拿到的是 + * 一个含中文文件名的交付包,对方往往只会双击其中一个 —— + * 所以这里做的是**每个分卷都是一个完整可解的 ZIP**, + * 只是文件名带 {@code -part1}。这一条决定了实现方式:必须边写边判断, + * 到阈值就收尾开下一卷。 + * + *

为什么按**未压缩字节数**估算,而不是写完后看文件大小

+ * + *

看真实文件大小最准,但每写一个 entry 就去 {@code Files.size()} + * 是一次系统调用;几百个文件就是几百次。而这里的目的只是"别让某一卷过大", + * 不需要精确 —— 所以累计**原始字节数**,到阈值就换卷。 + * 代价是压缩率高的材料(截图 PNG)会让实际分卷偏小,那没有坏处。 + * + *

一个必须守住的行为:**空卷不出**

+ * + *

刚好在换卷后没有更多文件时,不能留下一个只有目录条目的空 zip —— + * 客户会以为"这一卷是坏的"。 + */ +@Slf4j +public class SplitZipWriter implements AutoCloseable { + + /** 一卷最多装多少字节(0 或负 = 不分卷)。 */ + private final long limitBytes; + + /** 分卷文件基名(不含 {@code -partN.zip} 后缀)。 */ + private final String baseName; + + private final Path outDir; + + /** 已完成的分卷。 */ + private final List finished = new ArrayList<>(); + + private ZipArchiveOutputStream zip; + private OutputStream fos; + private Path currentPath; + private long currentBytes; + private int partNo; + + /** + * @param limitBytes 分卷大小上限(字节);{@code <=0} 表示不分卷 + */ + public SplitZipWriter(Path outDir, String baseName, long limitBytes) { + this.outDir = outDir; + this.baseName = baseName; + this.limitBytes = limitBytes; + } + + /** 分卷文件全名(不分卷时就是单卷名)。 */ + private String nameOf(int part) { + return limitBytes <= 0 ? baseName + ".zip" : baseName + "-part" + (part + 1) + ".zip"; + } + + /** + * 写一个条目。 + * + * @param entryName 包内路径(含目录,形如 {@code 根/班级/材料/文件.jpg}) + * @param bytes 内容 + */ + public void put(String entryName, byte[] bytes) throws IOException { + // ① 需要换卷吗?(当前卷非空 + 加上这一份会超限) + if (limitBytes > 0 && zip != null && currentBytes > 0 + && currentBytes + bytes.length > limitBytes) { + closeVolume(); + } + if (zip == null) { + openVolume(); + } + ZipArchiveEntry entry = new ZipArchiveEntry(entryName); + entry.setSize(bytes.length); + zip.putArchiveEntry(entry); + zip.write(bytes); + zip.closeArchiveEntry(); + currentBytes += bytes.length; + } + + private void openVolume() throws IOException { + Files.createDirectories(outDir); + currentPath = outDir.resolve(nameOf(partNo)); + fos = Files.newOutputStream(currentPath); + zip = new ZipArchiveOutputStream(fos); + // ★ entryNameEncoding = UTF-8:中文文件名在 Windows 解压不乱码的关键。 + // 用 JDK 的 ZipOutputStream 也写 UTF-8,但**不设 EFS 标志位**时 + // Windows 会按本地代码页猜 —— 那个 bug 只在客户机器上出现。 + zip.setEncoding(StandardCharsets.UTF_8.name()); + zip.setUseZip64(Zip64Mode.AsNeeded); + currentBytes = 0; + } + + /** 收尾当前卷并记进结果。 */ + private void closeVolume() throws IOException { + if (zip == null) { + return; + } + zip.finish(); + zip.close(); + zip = null; + if (fos != null) { + fos.close(); + fos = null; + } + long size = Files.size(currentPath); + CollectExportFile f = new CollectExportFile(); + f.setPartNo(partNo); + f.setFileName(currentPath.getFileName().toString()); + f.setFilePath(currentPath.toAbsolutePath().toString()); + f.setFileSize(size); + f.setDownloadable(CollectExportFile.DOWNLOADABLE_YES); + finished.add(f); + log.debug("导出分卷完成:{}({} MB)", f.getFileName(), size / 1024.0 / 1024.0); + partNo++; + currentPath = null; + } + + /** + * 收尾。 + * + *

⚠️ 没有任何条目时**不产出空卷** —— 一个只含目录条目的 zip + * 会被客户当成"坏包"。这时返回空列表,由调用方报"没有任何可导出的文件"。 + */ + public List finish() throws IOException { + if (zip != null && currentBytes > 0) { + closeVolume(); + } else if (zip != null) { + // 当前卷一个条目都没写 → 收掉句柄并**丢弃**这个空文件 + Path empty = currentPath; + zip.close(); + zip = null; + if (fos != null) { + fos.close(); + fos = null; + } + if (empty != null) { + Files.deleteIfExists(empty); + } + } + return finished; + } + + @Override + public void close() throws IOException { + if (zip != null) { + zip.close(); + zip = null; + } + if (fos != null) { + fos.close(); + fos = null; + } + } + + /** 已经写完几卷(进度上报用)。 */ + public int volumeCount() { + return finished.size(); + } + + /** 当前卷已累计多少原始字节(进度/日志用)。 */ + public long currentBytes() { + return currentBytes; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/dto/SubmitDtos.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/dto/SubmitDtos.java index c705d59..339da31 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/dto/SubmitDtos.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/dto/SubmitDtos.java @@ -24,11 +24,18 @@ public final class SubmitDtos { /** * 一条校验问题。 * + *

⚠️ severity 的口径是 {@code 1} 提示(蓝)/ {@code 2} 警告(黄)/ + * {@code 3} 错误(红,阻断提交) —— 以本类下面的常量({@code INFO} / {@code WARN} / + * {@code ERROR})为准,它们同时被 {@code SubmissionService} 用来数红黄。 + * 这三行注释曾经写成另一种顺序,导致前端把"提示"染成红色、 + * 导出前置检查把"提示"当成阻断(正常数据也导不出来)。不要按直觉改常量。 + * * @param ruleCode PRD §5.7 的规则编号(VAL-01…)。不要写内部枚举名 - * @param severity {@code 1} 信息(蓝)/ {@code 2} 警告(黄)/ {@code 3} 错误(红,阻断提交) + * @param severity {@code 1} 提示(蓝)/ {@code 2} 警告(黄)/ {@code 3} 错误(红,阻断提交) * @param title 一句话说清"哪里不对" * @param detail 说清"怎么改"。要能照着做—— * "材料不合规"这种话等于没说,学生会直接打电话给班主任 + * @param extra 定位信息(材料码、名册 id 等);没有就为 null */ public record Issue(String ruleCode, int severity, String title, String detail, Map extra) { diff --git a/backend/xt-server/src/main/resources/db/migration/V11__export_file.sql b/backend/xt-server/src/main/resources/db/migration/V11__export_file.sql new file mode 100644 index 0000000..7547a31 --- /dev/null +++ b/backend/xt-server/src/main/resources/db/migration/V11__export_file.sql @@ -0,0 +1,56 @@ +-- ============================================================================ +-- V11:导出产物明细表(支持分卷) +-- +-- 为什么不能继续用 collect_export_task.result_files 这个 JSON 字段 +-- --------------------------------------------------------------------------- +-- 契约 §12.4 的 `splitSizeMb`(默认 50MB)意味着一次导出可能产出**多个** zip; +-- 而每个分卷都要能单独下载、单独校验(sha256)、单独记大小。 +-- 这些需求落到 JSON 字段上有两个具体问题: +-- +-- ① **下载要按主键取**:`GET /exports/{id}/file?idx=2` 现在靠"解析 JSON +-- 再取第 n 个"。一旦结果文件很多,每次下载都要把整个 JSON 读出来解析; +-- 更要紧的是**没法用主键做授权**(越权判断只能靠"这个 id 属于这个任务吗" +-- 这种间接推理)。 +-- ② **过期清理要能按文件做**:清理时要把磁盘文件删掉、把行标成不可下载。 +-- JSON 字段没法部分更新,只能整段重写 —— 而重写期间别人正在下载, +-- 就会读到"一半旧一半新"的内容。 +-- +-- 所以:**一次导出 = 一行 task + N 行产物**。result_files 保留(老数据兼容), +-- 新代码一律读写这张表。 +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS collect_export_file ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + export_id BIGINT NOT NULL, + task_id BIGINT, + -- 第几卷(单卷时是 0)—— 文件名里带它,客户按顺序解压 + part_no INTEGER NOT NULL DEFAULT 0, + file_name VARCHAR(255) NOT NULL, + file_path VARCHAR(512) NOT NULL, + file_size BIGINT NOT NULL DEFAULT 0, + checksum VARCHAR(80), + -- 1 可下载 / 0 已清理(文件已从磁盘删掉,但这一行**留着**: + -- "当时确实产出过这个包"是审计要保留的事实,删行会让历史对不上) + downloadable SMALLINT NOT NULL DEFAULT 1, + -- ⚠️ 审计列:实体继承 TenantEntity → FillMetaObjectHandler 会填 create_by/update_by。 + -- 少了这两列的表现是"插入直接报 column does not exist",而且**编译期不报**。 + -- 这个坑在本项目已经踩过三次(collect_task_org / collect_export_task / collect_message), + -- 所以加了 scripts/check-entity-columns.py 做机械校验 —— 见 F-24。 + create_by BIGINT, + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by BIGINT, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); + +COMMENT ON TABLE collect_export_file IS '导出产物(一个分卷一行);分卷大小见 collect_export_task.split_size_mb'; +COMMENT ON COLUMN collect_export_file.part_no IS '分卷序号,从 0 开始;单卷时恒为 0'; +COMMENT ON COLUMN collect_export_file.downloadable IS '1 可下载 / 0 已清理(文件已删,但这一行保留作审计)'; + +CREATE INDEX IF NOT EXISTS idx_export_file_export + ON collect_export_file (export_id, part_no); + +-- 过期清理要扫"已过期的导出任务",按 (status, expire_time) 走 +CREATE INDEX IF NOT EXISTS idx_export_task_expire + ON collect_export_task (status, expire_time); diff --git a/frontend-pc/src/apis/xt/export-task.ts b/frontend-pc/src/apis/xt/export-task.ts new file mode 100644 index 0000000..6097654 --- /dev/null +++ b/frontend-pc/src/apis/xt/export-task.ts @@ -0,0 +1,233 @@ +import { getToken } from '@/utils/auth' +/** + * 导出与交付接口(《03-接口设计》§12.4 ~ §12.6)。 + * + * ## 这一屏的核心是"按钮什么时候不能点" + * + * PRD 铁律:**没有规范文件名、没有压到 1M 内的文件,不得进入汇总包**, + * 而且不允许"警告后放行"。所以界面必须先调 `precheck`(或 `preview`), + * `canExport=false` 时**直接禁用**「开始导出」,并把"是谁的哪一项卡住了"列出来 —— + * 只说"存在不合格文件"等于没说,管理员会去几百份材料里逐个翻。 + * + * ## 为什么先看目录树再导出 + * + * `preview` 返回的就是最终交付的目录结构(PRD §5.9:根目录 `{批次号}-{任务名称}` + * → 班级 → 材料类型 → 文件),**与真正打包共用同一份构建代码**。 + * 原型 STEP 07 把它放在右侧,就是为了"点导出之前先确认名字对不对、体积标没标"。 + * + * ## 下载的两步 + * + * `getDownload` 只回**信息**(文件名/体积/校验值/有效期),真正下文件走 `/file`; + * 后者需要 `Authorization` 头,所以前端必须走 `downloadFile`(带 token 取 blob), + * **不能**直接 `window.open(url)` —— 那样浏览器不会带 token,会 401。 + */ +import http from '@/utils/http' + +/** 导出范围。 */ +export interface ExportScope { + /** 只要这些班;空表示全部 */ + orgIds?: number[] + /** 只要终审通过的(默认 false = 全部已提交的) */ + onlyApproved?: boolean + /** 是否把原始件也打进去(默认 false) */ + includeRaw?: boolean +} + +/** 一条阻断项。 */ +export interface ExportBlocking { + /** `FILE_SIZE` / `NAME_INVALID` / `MISSING_MATERIAL` / `UNAPPROVED` */ + type: string + rosterId: number | null + realName: string | null + materialCode: string | null + fileId: number | null + title: string + /** 该怎么办(服务端写好了,直接显示) */ + action: string | null +} + +export interface ExportBlockingSummary { + sizeExceeded: number + nameInvalid: number + missing: number + unapproved: number +} + +/** 前置检查结果。`canExport=false` 时**必须禁用**导出按钮。 */ +export interface ExportPrecheck { + canExport: boolean + blocking: ExportBlocking[] + summary: ExportBlockingSummary + quickActions: string[] + /** 不阻断但要说明的提醒(如"有 3 人没提交,不会进包") */ + warnings: string[] + candidateCount: number + fileCount: number + rawSizeBytes: number +} + +/** 目录树节点。 */ +export interface ExportTreeNode { + name: string + type: 'DIR' | 'FILE' | string + path: string + sizeKb: number | null + /** 仅班级节点:该班会进包的人数 */ + studentCount: number | null + children: ExportTreeNode[] +} + +export interface ExportTree { + root: string + children: ExportTreeNode[] + personCount: number + fileCount: number + totalSizeBytes: number + note: string +} + +export interface ExportCreateReq { + taskId: number + exportName: string + scope?: ExportScope + splitSizeMb?: number + desensitize?: boolean + format?: string + generateSummaryExcel?: boolean + notifyOnFinish?: boolean +} + +export interface ExportResultFile { + name: string + sizeMb: number + part: number + size: number + checksum: string +} + +export interface ExportStats { + fileCount: number + processedCount: number + rawSizeMb: number + finalSizeMb: number + compressRatio: string +} + +/** 导出任务(列表与详情共用)。 */ +export interface ExportTaskVO { + id: number + taskId: number + taskName: string | null + exportName: string + /** `0` 排队 / `1` 处理中 / `2` 完成 / `3` 失败 */ + status: number + statusText: string + progress: number + currentStep: string | null + stepText: string | null + fileCount: number | null + stats: ExportStats | null + resultFiles: ExportResultFile[] | null + errorMsg: string | null + createTime: string | null + finishTime: string | null + expireTime: string | null +} + +export interface ExportDownloadInfo { + url: string + expireSeconds: number + fileName: string + sizeMb: number + checksum: string + remainsCachedDays: number +} + +/** 只检查、不创建 —— 界面用它决定按钮能不能点。 */ +export function precheckExport(taskId: number, scope: ExportScope = {}) { + return http.get('/exports/precheck', { + taskId, + orgIds: scope.orgIds?.length ? scope.orgIds.join(',') : undefined, + onlyApproved: scope.onlyApproved + }) +} + +/** 目录树预览(最终交付的样子)。 */ +export function previewExportTree(taskId: number, scope: ExportScope = {}) { + return http.get('/exports/preview', { + taskId, + orgIds: scope.orgIds?.length ? scope.orgIds.join(',') : undefined, + onlyApproved: scope.onlyApproved + }) +} + +/** 创建导出(前置检查不过返回 40035,并把清单放在 data 里)。 */ +export function createExport(data: ExportCreateReq) { + return http.post<{ exportId: number, status: number, estimatedSeconds: number }>('/exports', data) +} + +export function getExportList(params: { taskId?: number, pageNum?: number, pageSize?: number } = {}) { + return http.get<{ list: ExportTaskVO[], total: number }>('/exports', params) +} + +export function getExportDetail(id: number) { + return http.get(`/exports/${id}`) +} + +/** 取下载信息(文件名/体积/校验值/有效期)。 */ +export function getExportDownload(id: number, fileIndex = 0) { + return http.get(`/exports/${id}/download`, { fileIndex }) +} + +/** + * 真正下载文件。 + * + * ⚠️ 必须带 token 取 blob 再本地触发下载:`/exports/{id}/file` 是要登录的, + * 直接 `window.open` 不会带 `Authorization` 头,用户会看到一个 401 页面。 + */ +export async function downloadExportFile(id: number, fileIndex = 0): Promise { + const info = await getExportDownload(id, fileIndex) + const token = getToken() + const base = import.meta.env.VITE_API_PREFIX || '/api/v1' + // 服务端回的是本服务的绝对路径(/api/v1/exports/{id}/file?...), + // 去掉前缀再拼 base,避免出现 /api/v1/api/v1/... 这种双前缀 + const res = await fetch(`${base}${info.data.url.replace('/api/v1', '')}`, { + 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 = info.data.fileName + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(href) +} + +/** 导出状态 → 文案与颜色。 */ +export const EXPORT_STATUS: Record = { + 0: { text: '排队中', color: 'gray' }, + 1: { text: '处理中', color: 'arcoblue' }, + 2: { text: '已完成', color: 'green' }, + 3: { text: '失败', color: 'red' } +} + +/** 阻断类型 → 中文标签。 */ +export const BLOCKING_TYPE: Record = { + UNAPPROVED: { text: '还没审完', color: 'arcoblue' }, + MISSING_MATERIAL: { text: '缺必传材料', color: 'red' }, + FILE_SIZE: { text: '体积超标', color: 'orange' }, + NAME_INVALID: { text: '文件名未规范', color: 'gold' } +} + +/** 可一键做的补救动作 → 人话。 */ +export const QUICK_ACTION_TEXT: Record = { + APPROVE_PENDING: '还有没审完的单子,去「审核」把它们审掉', + RETRY_COMPRESS: '有材料体积超标,让学生(或班主任代传)重新上传,系统会自动重压', + REPROCESS_NAMES: '有文件没走完规范化,删掉重传即可自动改名', + NOTIFY_UNSUBMITTED: '有人还没提交,可以先催报再导出' +} diff --git a/frontend-pc/src/views/export/ExportDetailDrawer.vue b/frontend-pc/src/views/export/ExportDetailDrawer.vue new file mode 100644 index 0000000..7c3bced --- /dev/null +++ b/frontend-pc/src/views/export/ExportDetailDrawer.vue @@ -0,0 +1,236 @@ + + + + + diff --git a/frontend-pc/src/views/export/index.vue b/frontend-pc/src/views/export/index.vue new file mode 100644 index 0000000..2f78bcd --- /dev/null +++ b/frontend-pc/src/views/export/index.vue @@ -0,0 +1,536 @@ + + + + +