feat(export): 导出与交付(M6)—— 前置检查、目录树预览、分卷打包与 7 天过期清理
客户最关心的那条链: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 都有断言。
This commit is contained in:
@@ -89,6 +89,21 @@
|
||||
<artifactId>easyexcel</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!--
|
||||
ZIP 打包(导出交付物)。
|
||||
⚠️ 用它而不是 JDK 的 `ZipOutputStream`,唯一原因是**中文文件名的编码**:
|
||||
JDK 的 ZipOutputStream 永远写 UTF-8 并置 EFS 标志位,而 Windows 自带的
|
||||
「解压缩文件夹」对没有 EFS 标志的历史做法是按本地代码页解 —— 于是中文名
|
||||
变成乱码,而**这个 bug 只在客户机器上出现**(服务器上怎么看都是对的)。
|
||||
commons-compress 允许显式指定 `entryNameEncoding`,把这件事变成可控的。
|
||||
版本由 POI 传递引入,这里只把它抬成直接依赖,避免"哪天 POI 换版本就编不过"。
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-compress</artifactId>
|
||||
<version>1.27.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 对象存储(材料原图与规范件)—— provider=qiniu 时用,本地开发可退回磁盘 -->
|
||||
<dependency>
|
||||
<groupId>com.qiniu</groupId>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 数据信息填报系统 · 主应用。
|
||||
*
|
||||
* <p>一期范围:<b>学校收集 + 自主认定</b>(见 PRD「一期范围声明」)。
|
||||
* 社会收集 / 社会评价 / 专项职业能力的数据结构与枚举保留但不启用,传入返回 {@code 40044}。
|
||||
*
|
||||
* <p>{@code @EnableScheduling} 是给<b>导出包过期清理</b>用的
|
||||
* ({@code ExportCleanupService},契约 §12 的"保留 7 天")。
|
||||
* 目前只有这一个定时任务 —— 别顺手加更多:定时任务是"没有请求也会发生的事",
|
||||
* 每加一个都要能回答"它失败了谁会知道"。
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@ConfigurationPropertiesScan
|
||||
@EnableScheduling
|
||||
@MapperScan("cn.tianbaotong.fill.module.**.mapper")
|
||||
public class FillApplication {
|
||||
|
||||
|
||||
+39
@@ -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;
|
||||
|
||||
/**
|
||||
* 导出打包的线程池。
|
||||
*
|
||||
* <h3>为什么单独一个池,而不用 {@code @Async} 的默认池</h3>
|
||||
* <p>打包是<b>长任务 + CPU/IO 混合</b>(压缩几百张图、写几十 MB 的 zip),
|
||||
* 而 Spring 的默认异步池是给短任务用的(队列无界 + 线程数少)。
|
||||
* 混在一起的话,一次导出能把所有短任务(比如"通知")排到后面去 ——
|
||||
* 而这类故障的表现是"系统变慢",没人会想到是导出引起的。
|
||||
*
|
||||
* <p>线程数刻意只有 2:导出是<b>串行的大活</b>,
|
||||
* 同时跑 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);
|
||||
}
|
||||
}
|
||||
+145
@@ -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)。
|
||||
*
|
||||
* <p>权限只有两个:{@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<ExportDtos.PrecheckVO> precheck(@RequestParam Long taskId,
|
||||
@RequestParam(required = false) java.util.List<Long> 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<ExportDtos.TreeVO> preview(@RequestParam Long taskId,
|
||||
@RequestParam(required = false) java.util.List<Long> 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<ExportDtos.CreateVO> create(@RequestBody ExportDtos.CreateReq req) {
|
||||
return ApiResult.ok(exportService.create(req));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@RequirePerm(PermissionDef.EXPORT_DOWNLOAD)
|
||||
@Operation(summary = "导出记录列表")
|
||||
public ApiResult<Map<String, Object>> 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<ExportDtos.ExportTaskVO> detail(@PathVariable Long id) {
|
||||
return ApiResult.ok(exportService.detail(id));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/download")
|
||||
@RequirePerm(PermissionDef.EXPORT_DOWNLOAD)
|
||||
@Operation(summary = "取下载信息",
|
||||
description = "返回本服务的下载地址(而不是对象存储签名链接)—— "
|
||||
+ "包体落在打包机的磁盘上,走自己的流式接口最直接")
|
||||
public ApiResult<ExportDtos.DownloadVO> 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<byte[]> 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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 手工触发过期清理。
|
||||
*
|
||||
* <p>⚠️ <b>为什么要把这个动作暴露成接口</b>:定时任务(每小时一次)在
|
||||
* <b>测试与现场排障时都等不起</b> —— 要验证"过期之后下载会怎样",
|
||||
* 总不能把过期时间改到 7 天后再等一小时。而"清理"本身是可重复执行的
|
||||
* (已清理的卷会被跳过),所以手工触发没有副作用。
|
||||
*
|
||||
* <p>权限用 {@code export:download}(导出管理员)而不是 {@code log:viewTenant}:
|
||||
* 它删的是导出产物文件,属于导出这条线的运维动作。
|
||||
*/
|
||||
@PostMapping("/cleanup-run")
|
||||
@RequirePerm(PermissionDef.EXPORT_DOWNLOAD)
|
||||
@Operation(summary = "手工触发导出包过期清理",
|
||||
description = "定时任务每小时自动跑一次;这个入口是给**测试与排障**用的"
|
||||
+ "(不然要验证过期行为得等一小时)。重复执行安全:已清理的卷会跳过")
|
||||
public ApiResult<Map<String, Object>> cleanupRun() {
|
||||
int cleaned = exportCleanupService.doCleanup();
|
||||
return ApiResult.ok(Map.of("cleanedFiles", cleaned));
|
||||
}
|
||||
}
|
||||
+293
@@ -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)。
|
||||
*
|
||||
* <p>分成两个内部类放一个文件,与 {@code AuditDtos} 同一风格:
|
||||
* 这些 DTO **只在这一条链路上用**,拆成十几个文件反而看不出它们之间的关系。
|
||||
*/
|
||||
public final class ExportDtos {
|
||||
|
||||
private ExportDtos() {
|
||||
}
|
||||
|
||||
// ── 请求 ──────────────────────────────────────
|
||||
|
||||
/** 范围条件。 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class ScopeReq {
|
||||
|
||||
/** 只要这些班;空表示全部 */
|
||||
private List<Long> 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> blocking = new ArrayList<>();
|
||||
|
||||
private BlockingSummary summary = new BlockingSummary();
|
||||
|
||||
/** 可一键做的补救动作 */
|
||||
private List<String> quickActions = new ArrayList<>();
|
||||
|
||||
/** 不阻断但要在界面上说明的提醒(如"有 3 人未提交,不会进包") */
|
||||
private List<String> 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<ResultFile> 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 右侧那一栏)──────
|
||||
|
||||
/**
|
||||
* 一个树节点。
|
||||
*
|
||||
* <p>叶子节点(文件)用 {@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<TreeNode> children = new ArrayList<>();
|
||||
|
||||
/** 仅文件节点:如何从存储里取到它(预览时不下发,打包时内部使用) */
|
||||
private transient String sourcePath;
|
||||
}
|
||||
|
||||
/** 目录树预览(§12.4 的"目录树预览")。 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class TreeVO {
|
||||
|
||||
/** 根目录名:{@code {批次号}-{任务名称}}(PRD §5.9) */
|
||||
private String root;
|
||||
|
||||
private List<TreeNode> children = new ArrayList<>();
|
||||
|
||||
/** 会进包的人数 */
|
||||
private int personCount;
|
||||
|
||||
/** 会进包的文件数(不含汇总表) */
|
||||
private int fileCount;
|
||||
|
||||
private long totalSizeBytes;
|
||||
|
||||
/** 与原始体积相比的压缩率(只是估算口径说明,实际以打包结果为准) */
|
||||
private String note;
|
||||
}
|
||||
}
|
||||
+42
@@ -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})—— 一个分卷一行。
|
||||
*
|
||||
* <p>为什么独立成表(而不是复用 {@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;
|
||||
}
|
||||
+98
@@ -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})。
|
||||
*
|
||||
* <p>⚠️ <b>本实体刻意不继承 {@code BaseEntity} / {@code TenantEntity}</b>:
|
||||
* 这张表<b>没有 {@code deleted} 列</b>(导出记录是审计痕迹,只增不删),
|
||||
* 而基类带 {@code @TableLogic deleted} —— 继承它会让 MyBatis-Plus 给**每条**查询
|
||||
* 自动追加 {@code deleted = 0},运行时报「列不存在」,而<b>编译期完全看不出来</b>。
|
||||
* (同一个坑在 {@code collect_task_org} 上踩过,见交接摘要 §一·五。)
|
||||
*
|
||||
* <p>同理这张表没有 {@code update_by / update_time},也**没有**
|
||||
* {@code current_step / step_text / raw_size} 三列 —— 契约(§12.5)里要的
|
||||
* {@code currentStep / stepText / stats.rawSizeMb} 分别是
|
||||
* <b>由 {@code status + progress} 推导</b>与<b>按需聚合</b>出来的
|
||||
* (见 {@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} 已过期)。
|
||||
*
|
||||
* <p>⚠️ 过期**不是失败**:这个包当时是成功产出并被下载过的,
|
||||
* 只是保留期(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;
|
||||
}
|
||||
+188
@@ -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} 一次性取回。
|
||||
*
|
||||
* <p>为什么不复用各模块的 Service:打包要对**几百份**提交单逐个取材料与名册,
|
||||
* 走 Service 就是 N+1 次查询(真实批次 28 人 × 4 类材料已经明显慢,
|
||||
* 几百人时会把整个导出卡住)。所以这里按"一次查询取一列"的方式写,
|
||||
* 与 {@code AuditScopeMapper} 同一取舍。
|
||||
*
|
||||
* <p>⚠️ 三条必须守住的:
|
||||
* <ol>
|
||||
* <li><b>只取 {@code is_current = 1}</b> 的文件。历史版本(学生重传前的旧件)
|
||||
* 绝不能进交付包 —— 那等于把两份矛盾的身份证一起交给认定机构。</li>
|
||||
* <li><b>不取证件号明文</b>(它是 AES 密文,取了也没用),导出用脱敏值
|
||||
* ({@code id_card_mask})。真要明文只有"审核场景单独取 + 留痕"那一跳。</li>
|
||||
* <li><b>不带 tenant_id 条件由拦截器补</b>,但这里是自定义 SQL ——
|
||||
* 拦截器同样会注入,所以**不要**再手写一遍(写错一个值就是跨租户数据泄露)。</li>
|
||||
* </ol>
|
||||
*/
|
||||
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; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 取**全量**名册行 + 它的填报单状态。
|
||||
*
|
||||
* <p>刻意不按 {@code onlyApproved} 过滤:前置检查要能说出"谁还没审完"
|
||||
* (那是导出被拦住的真正原因),所以这一条查询必须把未审的人一并带回来,
|
||||
* 由上层决定"拦"还是"只装通过的那些"。
|
||||
* 分两个查询(一个过滤、一个不过滤)会让两边的口径迟早分叉。
|
||||
*
|
||||
* @param taskId 任务
|
||||
* @param orgIds 只要这些班;空表示不限
|
||||
*/
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT r.id AS rosterId,
|
||||
r.real_name AS realName,
|
||||
r.id_card_mask AS idCardMask,
|
||||
r.org_id AS orgId,
|
||||
o.org_name AS orgName,
|
||||
COALESCE(s.occupation, r.occupation) AS occupation,
|
||||
COALESCE(s.worktype, r.worktype) AS worktype,
|
||||
COALESCE(s.job_level, r.job_level) AS jobLevel,
|
||||
s.id AS submissionId,
|
||||
s.status AS status,
|
||||
s.submitter_id AS submitterId,
|
||||
s.submit_time AS submitTime,
|
||||
r.bind_user_id AS bindUserId
|
||||
FROM collect_roster r
|
||||
LEFT JOIN sys_org o ON o.id = r.org_id AND o.deleted = 0
|
||||
LEFT JOIN collect_submission s ON s.roster_id = r.id AND s.deleted = 0
|
||||
WHERE r.task_id = #{taskId} AND r.deleted = 0
|
||||
<if test="orgIds != null and orgIds.size() > 0">
|
||||
AND r.org_id IN
|
||||
<foreach collection="orgIds" item="oid" open="(" separator="," close=")">#{oid}</foreach>
|
||||
</if>
|
||||
ORDER BY o.org_name NULLS LAST, r.id
|
||||
</script>
|
||||
""")
|
||||
List<Candidate> listCandidates(@Param("taskId") Long taskId,
|
||||
@Param("orgIds") List<Long> orgIds);
|
||||
|
||||
/** 当前生效的文件({@code is_current = 1}),按提交单批量取。 */ @Select("""
|
||||
<script>
|
||||
SELECT f.id AS id,
|
||||
f.submission_id AS submissionId,
|
||||
f.roster_id AS rosterId,
|
||||
f.material_code AS materialCode,
|
||||
f.original_name AS originalName,
|
||||
f.standard_name AS standardName,
|
||||
f.std_path AS stdPath,
|
||||
f.final_size AS finalSize,
|
||||
f.width AS width,
|
||||
f.height AS height,
|
||||
f.dpi AS dpi
|
||||
FROM collect_file f
|
||||
WHERE f.is_current = 1 AND f.deleted = 0
|
||||
AND f.submission_id IN
|
||||
<foreach collection="submissionIds" item="sid" open="(" separator="," close=")">#{sid}</foreach>
|
||||
ORDER BY f.id
|
||||
</script>
|
||||
""")
|
||||
List<PackFile> listFiles(@Param("submissionIds") List<Long> submissionIds);
|
||||
|
||||
/**
|
||||
* 某个任务下所有「当前生效」文件的原始体积合计。
|
||||
*
|
||||
* <p>用途只有一个:算导出包的**压缩率**(契约 §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);
|
||||
}
|
||||
+62
@@ -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;
|
||||
|
||||
/**
|
||||
* 导出产物(一个分卷一行)。
|
||||
*
|
||||
* <p>比 {@code collect_export_task.result_files} 那个 JSON 字段强的地方:
|
||||
* 分卷能按主键下载、能按行清理,而不是每次解析整段 JSON
|
||||
* (那会在"下载时别人正在清理"的窗口里读到半新半旧的内容)。
|
||||
*/
|
||||
public interface ExportFileMapper extends BaseMapper<CollectExportFile> {
|
||||
|
||||
/**
|
||||
* 一次导出的全部分卷(按序号)。
|
||||
*
|
||||
* <p>⚠️ **不筛 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<CollectExportFile> listByExport(@Param("tenantId") Long tenantId,
|
||||
@Param("exportId") Long exportId);
|
||||
|
||||
/**
|
||||
* 按序号取一卷。
|
||||
*
|
||||
* <p>带 {@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);
|
||||
}
|
||||
+13
@@ -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;
|
||||
|
||||
/**
|
||||
* 导出任务表。
|
||||
*
|
||||
* <p>租户条件由多租户拦截器自动注入,**不要在 SQL 里手写 tenant_id** ——
|
||||
* 手写一份就多一个漏掉的机会,而漏掉的后果是跨租户读到别人的导出记录。
|
||||
*/
|
||||
public interface ExportTaskMapper extends BaseMapper<CollectExportTask> {
|
||||
}
|
||||
+126
@@ -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})。
|
||||
*
|
||||
* <h3>清理的语义:删文件、**留记录**</h3>
|
||||
*
|
||||
* <p>导出包保留 7 天(契约写明的口径),到期后:
|
||||
* <ul>
|
||||
* <li><b>磁盘文件删掉</b> —— 这是清理的目的(导出包是几百 MB 到几 GB 的临时产物);</li>
|
||||
* <li><b>数据库行留着</b>,把 {@code collect_export_file.downloadable} 标成 0、
|
||||
* 任务标成"已过期"。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>⚠️ 为什么不连行一起删:<b>"当时确实导出过这个包"是审计要保留的事实</b>。
|
||||
* 删了行之后,"这个任务什么时候导出过、导出了几卷、给谁下载过"就查不出来了,
|
||||
* 而这几件事恰恰是交付纠纷时最需要回答的。
|
||||
* 这与"名册删除用软删"是同一个道理。
|
||||
*
|
||||
* <h3>为什么不用 @Transactional 包住整个清理</h3>
|
||||
*
|
||||
* <p>清理是"删文件 + 改行"两件事,而**文件删除不可回滚**。
|
||||
* 用一个大事务包住,只会让"文件删了、事务回滚了"变成一种可能的状态 ——
|
||||
* 那时行还显示可下载,但文件已经没了,用户点下载得到"文件不在磁盘上"。
|
||||
*
|
||||
* <p>所以顺序是刻意的:**先改行(置为不可下载)→ 再删文件**。
|
||||
* 中间失败的最坏结果是"文件还在但显示已清理"(浪费一点磁盘),
|
||||
* 而不是"显示可下载但文件没了"(用户报障)。
|
||||
*
|
||||
* <h3>定时任务的租户上下文</h3>
|
||||
*
|
||||
* <p>{@code TenantScope.runAs} 必须包住每一轮 —— 多租户拦截器从 ThreadLocal 取
|
||||
* tenantId,定时线程里没有它,一句 SQL 都跑不了(表现为 40101 或"查不到数据")。
|
||||
* 这里按**平台租户**跑:清理是运维动作,要看所有租户的过期包。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ExportCleanupService {
|
||||
|
||||
private final ExportTaskMapper exportTaskMapper;
|
||||
private final ExportFileMapper exportFileMapper;
|
||||
|
||||
/**
|
||||
* 每小时扫一次过期导出包。
|
||||
*
|
||||
* <p>每小时而不是每天:过期是"到点就不可下载"的承诺,
|
||||
* 每天跑一次会让实际保留期在 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<CollectExportTask> expired = exportTaskMapper.selectList(
|
||||
Wrappers.<CollectExportTask>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;
|
||||
}
|
||||
}
|
||||
+1127
File diff suppressed because it is too large
Load Diff
+182
@@ -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})。
|
||||
*
|
||||
* <h3>为什么"分卷"必须在**写的时候**做,不能打包完再切</h3>
|
||||
*
|
||||
* <p>把一个大 ZIP 按字节切成 {@code .z01/.z02} 是另一种格式
|
||||
* (分卷压缩:每卷都不能单独解开,必须全部到齐)。而客户拿到的是
|
||||
* 一个含中文文件名的交付包,对方往往只会双击其中一个 ——
|
||||
* 所以这里做的是**每个分卷都是一个完整可解的 ZIP**,
|
||||
* 只是文件名带 {@code -part1}。这一条决定了实现方式:必须边写边判断,
|
||||
* 到阈值就收尾开下一卷。
|
||||
*
|
||||
* <h3>为什么按**未压缩字节数**估算,而不是写完后看文件大小</h3>
|
||||
*
|
||||
* <p>看真实文件大小最准,但每写一个 entry 就去 {@code Files.size()}
|
||||
* 是一次系统调用;几百个文件就是几百次。而这里的目的只是"别让某一卷过大",
|
||||
* 不需要精确 —— 所以累计**原始字节数**,到阈值就换卷。
|
||||
* 代价是压缩率高的材料(截图 PNG)会让实际分卷偏小,那没有坏处。
|
||||
*
|
||||
* <h3>一个必须守住的行为:**空卷不出**</h3>
|
||||
*
|
||||
* <p>刚好在换卷后没有更多文件时,不能留下一个只有目录条目的空 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<CollectExportFile> 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收尾。
|
||||
*
|
||||
* <p>⚠️ 没有任何条目时**不产出空卷** —— 一个只含目录条目的 zip
|
||||
* 会被客户当成"坏包"。这时返回空列表,由调用方报"没有任何可导出的文件"。
|
||||
*/
|
||||
public List<CollectExportFile> 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;
|
||||
}
|
||||
}
|
||||
@@ -24,11 +24,18 @@ public final class SubmitDtos {
|
||||
/**
|
||||
* 一条校验问题。
|
||||
*
|
||||
* <p>⚠️ <b>severity 的口径是 {@code 1} 提示(蓝)/ {@code 2} 警告(黄)/
|
||||
* {@code 3} 错误(红,阻断提交)</b> —— 以本类下面的常量({@code INFO} / {@code WARN} /
|
||||
* {@code ERROR})为准,它们同时被 {@code SubmissionService} 用来数红黄。
|
||||
* 这三行注释曾经写成另一种顺序,导致前端把"提示"染成红色、
|
||||
* 导出前置检查把"提示"当成阻断(正常数据也导不出来)。<b>不要按直觉改常量。</b>
|
||||
*
|
||||
* @param ruleCode PRD §5.7 的规则编号(VAL-01…)。<b>不要写内部枚举名</b>
|
||||
* @param severity {@code 1} 信息(蓝)/ {@code 2} 警告(黄)/ {@code 3} 错误(红,阻断提交)
|
||||
* @param severity {@code 1} 提示(蓝)/ {@code 2} 警告(黄)/ {@code 3} 错误(红,阻断提交)
|
||||
* @param title 一句话说清"哪里不对"
|
||||
* @param detail 说清"怎么改"。<b>要能照着做</b>——
|
||||
* "材料不合规"这种话等于没说,学生会直接打电话给班主任
|
||||
* @param extra 定位信息(材料码、名册 id 等);没有就为 null
|
||||
*/
|
||||
public record Issue(String ruleCode, int severity, String title, String detail,
|
||||
Map<String, Object> extra) {
|
||||
|
||||
@@ -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);
|
||||
@@ -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<ExportPrecheck>('/exports/precheck', {
|
||||
taskId,
|
||||
orgIds: scope.orgIds?.length ? scope.orgIds.join(',') : undefined,
|
||||
onlyApproved: scope.onlyApproved
|
||||
})
|
||||
}
|
||||
|
||||
/** 目录树预览(最终交付的样子)。 */
|
||||
export function previewExportTree(taskId: number, scope: ExportScope = {}) {
|
||||
return http.get<ExportTree>('/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<ExportTaskVO>(`/exports/${id}`)
|
||||
}
|
||||
|
||||
/** 取下载信息(文件名/体积/校验值/有效期)。 */
|
||||
export function getExportDownload(id: number, fileIndex = 0) {
|
||||
return http.get<ExportDownloadInfo>(`/exports/${id}/download`, { fileIndex })
|
||||
}
|
||||
|
||||
/**
|
||||
* 真正下载文件。
|
||||
*
|
||||
* ⚠️ 必须带 token 取 blob 再本地触发下载:`/exports/{id}/file` 是要登录的,
|
||||
* 直接 `window.open` 不会带 `Authorization` 头,用户会看到一个 401 页面。
|
||||
*/
|
||||
export async function downloadExportFile(id: number, fileIndex = 0): Promise<void> {
|
||||
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<number, { text: string, color: string }> = {
|
||||
0: { text: '排队中', color: 'gray' },
|
||||
1: { text: '处理中', color: 'arcoblue' },
|
||||
2: { text: '已完成', color: 'green' },
|
||||
3: { text: '失败', color: 'red' }
|
||||
}
|
||||
|
||||
/** 阻断类型 → 中文标签。 */
|
||||
export const BLOCKING_TYPE: Record<string, { text: string, color: string }> = {
|
||||
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<string, string> = {
|
||||
APPROVE_PENDING: '还有没审完的单子,去「审核」把它们审掉',
|
||||
RETRY_COMPRESS: '有材料体积超标,让学生(或班主任代传)重新上传,系统会自动重压',
|
||||
REPROCESS_NAMES: '有文件没走完规范化,删掉重传即可自动改名',
|
||||
NOTIFY_UNSUBMITTED: '有人还没提交,可以先催报再导出'
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<a-drawer v-model:visible="visible" :width="900" :mask-closable="false" :footer="false"
|
||||
unmount-on-close @cancel="close">
|
||||
<template #title>
|
||||
<span>导出详情</span>
|
||||
<a-tag v-if="detail" class="ed__head-tag" :color="EXPORT_STATUS[detail.status]?.color ?? 'gray'">
|
||||
{{ detail.statusText || EXPORT_STATUS[detail.status]?.text }}
|
||||
</a-tag>
|
||||
<span v-if="detail" class="ed__head-sub">{{ detail.exportName }}</span>
|
||||
</template>
|
||||
|
||||
<a-spin :loading="loading" class="ed__spin">
|
||||
<template v-if="detail">
|
||||
<a-alert v-if="detail.status === 3" type="error" class="ed__alert">
|
||||
<template #title>打包失败:{{ detail.errorMsg || '未知原因' }}</template>
|
||||
<template #default>
|
||||
失败不会产生任何交付物,重新导出即可(**不会**因此产生重复计费或脏数据)。
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<!-- 进度:处理中每 2 秒自动刷新一次,完成后自动停 -->
|
||||
<div class="ed__bar">
|
||||
<a-progress :percent="(detail.progress ?? 0) / 100" :status="progressStatus"
|
||||
:animation="detail.status === 1" />
|
||||
<div class="ed__muted">
|
||||
{{ detail.stepText || EXPORT_STATUS[detail.status]?.text }}
|
||||
<template v-if="detail.fileCount"> · 共 {{ detail.fileCount }} 人</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-descriptions :column="2" size="small" bordered class="ed__desc">
|
||||
<a-descriptions-item label="任务">{{ detail.taskName || '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">{{ fmt(detail.createTime) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="完成时间">{{ fmt(detail.finishTime) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="保留到">{{ fmt(detail.expireTime) }}(到期后需重新导出)</a-descriptions-item>
|
||||
<a-descriptions-item label="原始体积">
|
||||
{{ detail.stats?.rawSizeMb ?? '—' }} MB
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="包体 / 压缩率">
|
||||
{{ detail.stats?.finalSizeMb ?? '—' }} MB
|
||||
<span class="ed__muted">({{ detail.stats?.compressRatio || '—' }})</span>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<!-- 产物 -->
|
||||
<div class="ed__section">产物</div>
|
||||
<a-empty v-if="!(detail.resultFiles ?? []).length" description="还没有产物(未完成或失败)" />
|
||||
<a-table v-else :data="detail.resultFiles ?? []" :pagination="false" size="small"
|
||||
row-key="name" :bordered="{ cell: true }">
|
||||
<template #columns>
|
||||
<a-table-column title="文件名" data-index="name" />
|
||||
<a-table-column title="体积" :width="100" align="center">
|
||||
<template #cell="{ record }">{{ record.sizeMb }} MB</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="校验值(sha256)" :width="240">
|
||||
<template #cell="{ record }">
|
||||
<span class="ed__code" :title="record.checksum">{{ shortHash(record.checksum) }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="操作" :width="110" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-link :loading="downloading" @click="onDownload(record.part ?? 0)">下载</a-link>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<div class="ed__muted ed__tip">
|
||||
下载的是 ZIP 包,解压后目录结构为「{批次号}-{任务名称}/班级/材料类型/文件」+ 根目录的汇总表。
|
||||
校验值用于核对传输是否完整(本机核对:<span class="ed__code">certutil -hashfile 包名 SHA256</span>)。
|
||||
</div>
|
||||
</template>
|
||||
</a-spin>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ExportTaskVO } from '@/apis/xt/export-task'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { downloadExportFile, EXPORT_STATUS, getExportDetail } from '@/apis/xt/export-task'
|
||||
|
||||
/**
|
||||
* 导出详情(进度 + 产物 + 下载)。
|
||||
*
|
||||
* ## 为什么要轮询、以及什么时候停
|
||||
*
|
||||
* 打包是后台任务,进度只能轮询。**完成后必须停**:不停的话用户开着这个抽屉
|
||||
* 就等于一直在打接口;而"忘了停"在演示时看不出来,只在监控里变成莫名其妙的 QPS。
|
||||
*
|
||||
* ## 下载为什么要走 axios/fetch 而不是直接给个链接
|
||||
*
|
||||
* `/exports/{id}/file` 是要登录的(带 `Authorization` 头),
|
||||
* 直接 `window.open` 会 401。所以走 `downloadExportFile`(取 blob 再本地触发下载)。
|
||||
*/
|
||||
defineOptions({ name: 'ExportDetailDrawer' })
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'changed'): void
|
||||
}>()
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const downloading = ref(false)
|
||||
const detail = ref<ExportTaskVO | null>(null)
|
||||
const exportId = ref<number>(0)
|
||||
let timer: number | null = null
|
||||
|
||||
const progressStatus = computed(() => {
|
||||
const s = detail.value?.status
|
||||
if (s === 3) {
|
||||
// Arco 的 Progress 状态里"失败"叫 danger(不是 error)
|
||||
return 'danger'
|
||||
}
|
||||
if (s === 2) {
|
||||
return 'success'
|
||||
}
|
||||
return 'normal'
|
||||
})
|
||||
|
||||
function fmt(v: string | null) {
|
||||
return v ? v.replace('T', ' ').slice(0, 16) : '—'
|
||||
}
|
||||
|
||||
function shortHash(v: string | null) {
|
||||
if (!v) {
|
||||
return '—'
|
||||
}
|
||||
return v.length > 24 ? `${v.slice(0, 18)}…` : v
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const res = await getExportDetail(exportId.value)
|
||||
detail.value = res.data ?? null
|
||||
// 只在"还在跑"的时候继续轮询;完成/失败各刷一次就停
|
||||
if (detail.value && (detail.value.status === 0 || detail.value.status === 1)) {
|
||||
startTimer()
|
||||
} else {
|
||||
stopTimer()
|
||||
}
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
stopTimer()
|
||||
timer = window.setInterval(() => {
|
||||
load().catch(() => stopTimer())
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (timer !== null) {
|
||||
window.clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function open(id: number) {
|
||||
exportId.value = id
|
||||
visible.value = true
|
||||
loading.value = true
|
||||
try {
|
||||
await load()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
stopTimer()
|
||||
visible.value = false
|
||||
emit('changed')
|
||||
}
|
||||
|
||||
async function onDownload(fileIndex: number) {
|
||||
downloading.value = true
|
||||
try {
|
||||
await downloadExportFile(exportId.value, fileIndex)
|
||||
Message.success('已开始下载')
|
||||
} catch (e) {
|
||||
Message.error((e as Error).message || '下载失败')
|
||||
} finally {
|
||||
downloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(stopTimer)
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ed {
|
||||
&__head-tag {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
&__head-sub {
|
||||
margin-left: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__spin {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__bar {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
&__section {
|
||||
margin: 18px 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__muted {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__code {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&__tip {
|
||||
margin-top: 10px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,536 @@
|
||||
<template>
|
||||
<GiPageLayout>
|
||||
<a-card :bordered="false">
|
||||
<template #title>导出中心</template>
|
||||
<template #extra>
|
||||
<span class="exp__hint">交付物:ZIP 包 + 根目录汇总表,目录结构按机构规范生成</span>
|
||||
</template>
|
||||
|
||||
<div class="g-row-tool">
|
||||
<a-space wrap>
|
||||
<a-select v-model="taskId" placeholder="选择要导出的任务" size="small" class="exp__task"
|
||||
:loading="taskLoading" @change="onTaskChange">
|
||||
<a-option v-for="t in taskOptions" :key="t.id" :value="t.id">
|
||||
{{ t.taskName }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
<a-radio-group v-model="onlyApproved" type="button" size="small" :disabled="!taskId"
|
||||
@change="reload">
|
||||
<a-radio :value="false">全部已提交</a-radio>
|
||||
<a-radio :value="true">仅终审通过</a-radio>
|
||||
</a-radio-group>
|
||||
<a-button size="small" :disabled="!taskId" @click="reload">
|
||||
<template #icon><IconRefresh /></template>
|
||||
重新检查
|
||||
</a-button>
|
||||
</a-space>
|
||||
<a-space>
|
||||
<span class="exp__hint">{{ checkedAt ? `上次检查:${checkedAt}` : '选择任务后自动检查' }}</span>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-empty v-if="!taskId" description="先选一个任务 —— 导出是按任务进行的" />
|
||||
|
||||
<a-spin v-else :loading="checking">
|
||||
<!--
|
||||
⚠️ 这里是**铁律**的落点:canExport=false 时按钮直接禁用,**不弹警告放行**。
|
||||
清单要具体到"谁 + 哪一项 + 怎么办",否则管理员只能去几百份材料里逐个翻。
|
||||
-->
|
||||
<a-alert v-if="precheck && !precheck.canExport" type="error" class="exp__alert">
|
||||
<template #title>
|
||||
前置检查未通过:{{ precheck.blocking.length }} 项不合格,<strong>无法导出</strong>。
|
||||
这是铁律 —— 不合格的文件不允许进入汇总包。
|
||||
</template>
|
||||
<template #default>
|
||||
<div v-for="(a, i) in quickActions" :key="i" class="exp__action">
|
||||
· {{ a }}
|
||||
</div>
|
||||
</template>
|
||||
</a-alert>
|
||||
<a-alert v-else-if="precheck" type="success" :show-icon="false" class="exp__alert">
|
||||
<template #title>
|
||||
检查通过:<strong>{{ precheck.candidateCount }}</strong> 人、
|
||||
<strong>{{ precheck.fileCount }}</strong> 个文件(原始
|
||||
{{ mb(precheck.rawSizeBytes) }} MB)可以打包。
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<a-alert v-for="(w, i) in precheck?.warnings ?? []" :key="`w${i}`" type="warning"
|
||||
:show-icon="false" class="exp__alert">
|
||||
{{ w }}
|
||||
</a-alert>
|
||||
|
||||
<a-row :gutter="12">
|
||||
<a-col :span="11">
|
||||
<div class="exp__card">
|
||||
<div class="exp__card-head">导出设置</div>
|
||||
<!-- 不引 a-form:这些是"输入即生效"的设置项,没有校验,加一层 form
|
||||
只会多要一个 model 绑定(Arco 的 a-form 要求 model 必填) -->
|
||||
<div class="exp__field">
|
||||
<div class="exp__label">包名(会写进 ZIP 文件名)</div>
|
||||
<a-input v-model="exportName" placeholder="如 202609机电认定-24中技数控1班"
|
||||
:max-length="60" show-word-limit />
|
||||
</div>
|
||||
<div class="exp__field">
|
||||
<div class="exp__label">分卷大小</div>
|
||||
<a-select v-model="splitSizeMb">
|
||||
<a-option :value="0">不分卷(一期只支持这一项)</a-option>
|
||||
</a-select>
|
||||
<div class="exp__hint">分卷还没做:几百人的批次超过 1GB 时会需要它</div>
|
||||
</div>
|
||||
<div class="exp__field">
|
||||
<a-checkbox v-model="desensitize">证件号脱敏导出</a-checkbox>
|
||||
<div class="exp__hint">
|
||||
名册里存的本来就是脱敏值,这个开关留给二期(明文导出需二次确认)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 阻断清单:逐条说清"谁 + 哪一项 + 怎么办" -->
|
||||
<div v-if="precheck && precheck.blocking.length" class="exp__card">
|
||||
<div class="exp__card-head">
|
||||
卡住的地方({{ precheck.blocking.length }})
|
||||
<span class="exp__hint">
|
||||
还没审完 {{ precheck.summary.unapproved }} · 缺材料 {{ precheck.summary.missing }} ·
|
||||
体积超标 {{ precheck.summary.sizeExceeded }} · 名字未规范 {{ precheck.summary.nameInvalid }}
|
||||
</span>
|
||||
</div>
|
||||
<a-table :data="precheck.blocking" :pagination="{ pageSize: 8, size: 'mini' }"
|
||||
size="mini" row-key="title" :bordered="{ cell: true }">
|
||||
<template #columns>
|
||||
<a-table-column title="类型" :width="100" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="BLOCKING_TYPE[record.type]?.color ?? 'gray'" size="small">
|
||||
{{ BLOCKING_TYPE[record.type]?.text ?? record.type }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="问题">
|
||||
<template #cell="{ record }">
|
||||
<div>{{ record.title }}</div>
|
||||
<div class="exp__hint">{{ record.action }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="13">
|
||||
<div class="exp__card">
|
||||
<div class="exp__card-head">
|
||||
目录结构预览
|
||||
<span class="exp__hint">
|
||||
{{ tree ? `${tree.personCount} 人 × 各类材料 = ${tree.fileCount} 个文件` : '' }}
|
||||
</span>
|
||||
</div>
|
||||
<!--
|
||||
目录树用「半角制表符 + CSS 缩进」,**不用全角空格**:
|
||||
全角空格(U+3000)会被 eslint 的 no-irregular-whitespace 拦下,
|
||||
而靠空白对齐的写法在 `--fix` 之后必然被重排 —— 那样每次改这个文件
|
||||
都要打一次仗。缩进交给 `.exp__tree-*` 的 padding-left。
|
||||
-->
|
||||
<div v-if="tree" class="exp__tree">
|
||||
<div class="exp__tree-root">{{ tree.root }}/</div>
|
||||
<div v-for="c in tree.children" :key="c.path" class="exp__tree-org">
|
||||
<div class="exp__tree-dir">
|
||||
<span class="exp__br">|-- </span>{{ c.name }}/
|
||||
<span class="exp__hint">{{ c.studentCount }} 人</span>
|
||||
</div>
|
||||
<div v-for="m in c.children" :key="m.path" class="exp__tree-mat">
|
||||
<div class="exp__tree-dir2">
|
||||
<span class="exp__br">|-- </span>{{ m.name }}/
|
||||
<span class="exp__hint">{{ m.children.length }} 个</span>
|
||||
</div>
|
||||
<div v-for="f in m.children.slice(0, 2)" :key="f.path" class="exp__tree-file">
|
||||
<span class="exp__br">`-- </span>{{ f.name }}
|
||||
<span class="exp__hint">{{ f.sizeKb }}KB</span>
|
||||
</div>
|
||||
<div v-if="m.children.length > 2" class="exp__tree-file exp__hint">
|
||||
<span class="exp__br">`-- </span>…(其余 {{ m.children.length - 2 }} 个)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="exp__tree-file">
|
||||
<span class="exp__br">`-- </span>考生线下报名信息导入模板(汇总).xlsx
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-else description="没有可预览的内容" />
|
||||
<div class="exp__hint exp__tree-note">
|
||||
预览与真正打包**共用同一份构建代码**,所以这里看到的就是解压后的样子
|
||||
(每个材料目录最多列 2 个文件,避免几百人时刷屏)。
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 底部动作条:按钮的可用性就是铁律的落点 -->
|
||||
<div class="exp__footer">
|
||||
<span class="exp__hint">
|
||||
导出是后台任务,完成后在这里下载;产物保留 7 天
|
||||
</span>
|
||||
<a-space>
|
||||
<a-tooltip :disabled="canExport" :content="blockReason">
|
||||
<a-button type="primary" size="small" :disabled="!canExport" :loading="creating"
|
||||
@click="onCreate">
|
||||
<template #icon><IconDownload /></template>
|
||||
开始导出
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div class="exp__card-head">导出记录</div>
|
||||
<a-table :data="records" :loading="listLoading" row-key="id" size="small"
|
||||
:pagination="pagination" :scroll="{ x: 900 }" @page-change="onPageChange">
|
||||
<template #columns>
|
||||
<a-table-column title="包名 / 任务" :width="240" ellipsis tooltip>
|
||||
<template #cell="{ record }">
|
||||
<div class="exp__name">{{ record.exportName }}</div>
|
||||
<div class="exp__hint">{{ record.taskName || '—' }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="状态" :width="100" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="EXPORT_STATUS[record.status]?.color ?? 'gray'" size="small">
|
||||
{{ record.statusText || EXPORT_STATUS[record.status]?.text }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="进度" :width="150">
|
||||
<template #cell="{ record }">
|
||||
<a-progress :percent="(record.progress ?? 0) / 100" size="small"
|
||||
:status="record.status === 3 ? 'danger' : (record.status === 2 ? 'success' : 'normal')" />
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="人数 / 包体" :width="120">
|
||||
<template #cell="{ record }">
|
||||
<div>{{ record.fileCount ?? '—' }} 人</div>
|
||||
<div class="exp__hint">{{ record.stats?.finalSizeMb ?? '—' }} MB</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="创建时间" :width="150">
|
||||
<template #cell="{ record }">{{ fmt(record.createTime) }}</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="操作" :width="160" align="center" fixed="right">
|
||||
<template #cell="{ record }">
|
||||
<a-space size="mini">
|
||||
<a-link @click="onOpen(record)">详情</a-link>
|
||||
<a-link v-if="record.status === 2 && record.resultFiles?.length"
|
||||
@click="onQuickDownload(record)">
|
||||
下载
|
||||
</a-link>
|
||||
<a-link v-if="record.status === 3" status="warning" @click="onOpen(record)">看原因</a-link>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<ExportDetailDrawer ref="detailRef" @changed="loadRecords" />
|
||||
</GiPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ExportPrecheck, ExportTaskVO, ExportTree } from '@/apis/xt/export-task'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { IconDownload, IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
import {
|
||||
BLOCKING_TYPE,
|
||||
createExport,
|
||||
downloadExportFile,
|
||||
EXPORT_STATUS,
|
||||
getExportList,
|
||||
precheckExport,
|
||||
previewExportTree,
|
||||
QUICK_ACTION_TEXT
|
||||
} from '@/apis/xt/export-task'
|
||||
import { getTaskList } from '@/apis/xt/task'
|
||||
import ExportDetailDrawer from './ExportDetailDrawer.vue'
|
||||
|
||||
/**
|
||||
* 导出中心(原型 STEP 07,契约 §12.4~§12.6)。
|
||||
*
|
||||
* ## 一屏只做一件事:让"不允许带问题打包"变成按钮禁用
|
||||
*
|
||||
* 所以这一页的**主逻辑就是前置检查**:进页面先检查、改范围再检查、
|
||||
* `canExport=false` 就把「开始导出」禁用并把清单列出来。
|
||||
* 没有"点一下试试看"的分支 —— 也不该有。
|
||||
*
|
||||
* ## 目录树预览不是装饰
|
||||
*
|
||||
* 客户拿到包才会发现问题(名字不对、少一个人、体积没压下去),
|
||||
* 而那时已经交出去了。预览把这些提前到"点导出之前",
|
||||
* 并且它与打包**共用同一份构建代码**,所以不会说谎。
|
||||
*/
|
||||
defineOptions({ name: 'Export' })
|
||||
|
||||
const taskLoading = ref(false)
|
||||
const checking = ref(false)
|
||||
const listLoading = ref(false)
|
||||
const creating = ref(false)
|
||||
const taskOptions = ref<Array<{ id: number, taskName: string }>>([])
|
||||
const taskId = ref<number | undefined>(undefined)
|
||||
const onlyApproved = ref(false)
|
||||
const exportName = ref('')
|
||||
const splitSizeMb = ref(0)
|
||||
const desensitize = ref(false)
|
||||
const precheck = ref<ExportPrecheck | null>(null)
|
||||
const tree = ref<ExportTree | null>(null)
|
||||
const checkedAt = ref('')
|
||||
const records = ref<ExportTaskVO[]>([])
|
||||
const total = ref(0)
|
||||
const query = reactive({ pageNum: 1, pageSize: 10 })
|
||||
|
||||
const detailRef = useTemplateRef('detailRef')
|
||||
|
||||
const canExport = computed(() => precheck.value?.canExport === true)
|
||||
const blockReason = computed(() => {
|
||||
const n = precheck.value?.blocking.length ?? 0
|
||||
return n ? `有 ${n} 项不合格,先按左侧清单处理` : '前置检查未通过'
|
||||
})
|
||||
const quickActions = computed(
|
||||
() => (precheck.value?.quickActions ?? []).map((k) => QUICK_ACTION_TEXT[k] ?? k)
|
||||
)
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
total: total.value,
|
||||
showTotal: true,
|
||||
showPageSize: true
|
||||
}))
|
||||
|
||||
function fmt(v: string | null) {
|
||||
return v ? v.replace('T', ' ').slice(0, 16) : '—'
|
||||
}
|
||||
|
||||
function mb(bytes: number | null) {
|
||||
return bytes ? Math.round(bytes / 1024 / 1024 * 10) / 10 : 0
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
taskLoading.value = true
|
||||
try {
|
||||
const res = await getTaskList({ pageNum: 1, pageSize: 200 })
|
||||
taskOptions.value = (res.data?.list ?? []).map((t) => ({ id: t.id, taskName: t.taskName }))
|
||||
} finally {
|
||||
taskLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 前置检查 + 目录树预览一起拉(两者口径必须一致,所以永远是同一次点击触发) */
|
||||
async function reload() {
|
||||
if (!taskId.value) {
|
||||
return
|
||||
}
|
||||
checking.value = true
|
||||
try {
|
||||
const scope = { onlyApproved: onlyApproved.value }
|
||||
const [pre, tr] = await Promise.all([
|
||||
precheckExport(taskId.value, scope),
|
||||
previewExportTree(taskId.value, scope)
|
||||
])
|
||||
precheck.value = pre.data ?? null
|
||||
tree.value = tr.data ?? null
|
||||
checkedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||
if (!exportName.value) {
|
||||
const t = taskOptions.value.find((x) => x.id === taskId.value)
|
||||
if (t) {
|
||||
exportName.value = t.taskName.slice(0, 60)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onTaskChange() {
|
||||
exportName.value = ''
|
||||
precheck.value = null
|
||||
tree.value = null
|
||||
query.pageNum = 1
|
||||
reload()
|
||||
loadRecords()
|
||||
}
|
||||
|
||||
async function loadRecords() {
|
||||
listLoading.value = true
|
||||
try {
|
||||
const res = await getExportList({
|
||||
taskId: taskId.value,
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize
|
||||
})
|
||||
records.value = res.data?.list ?? []
|
||||
total.value = res.data?.total ?? 0
|
||||
} finally {
|
||||
listLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPageChange(page: number) {
|
||||
query.pageNum = page
|
||||
loadRecords()
|
||||
}
|
||||
|
||||
async function onCreate() {
|
||||
if (!taskId.value || !exportName.value.trim()) {
|
||||
Message.warning('请填写包名')
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
const res = await createExport({
|
||||
taskId: taskId.value,
|
||||
exportName: exportName.value.trim(),
|
||||
scope: { onlyApproved: onlyApproved.value },
|
||||
splitSizeMb: splitSizeMb.value,
|
||||
desensitize: desensitize.value,
|
||||
format: 'ZIP',
|
||||
generateSummaryExcel: true
|
||||
})
|
||||
Message.success('已开始打包,完成后在这里下载')
|
||||
await loadRecords()
|
||||
if (res.data?.exportId) {
|
||||
detailRef.value?.open(res.data.exportId)
|
||||
}
|
||||
} catch (e) {
|
||||
// 40035:前置检查不过。**不带 force**,所以这里只能把清单摊开
|
||||
const err = e as Error & { bizCode?: number, bizData?: unknown }
|
||||
if (err.bizCode === 40035 && err.bizData) {
|
||||
precheck.value = err.bizData as ExportPrecheck
|
||||
Modal.warning({
|
||||
title: '还不能导出',
|
||||
content: `${err.message}\n\n左侧清单列出了卡住的人与项,处理完再点「重新检查」。`,
|
||||
hideCancel: true
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onOpen(row: ExportTaskVO) {
|
||||
detailRef.value?.open(row.id)
|
||||
}
|
||||
|
||||
async function onQuickDownload(row: ExportTaskVO) {
|
||||
try {
|
||||
await downloadExportFile(row.id, 0)
|
||||
Message.success('已开始下载')
|
||||
} catch (e) {
|
||||
Message.error((e as Error).message || '下载失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadTasks()
|
||||
await loadRecords()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.exp {
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__task {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
&__alert {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
&__action {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&__card {
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__card-head {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__tree {
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
&__tree-root {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
&__tree-dir {
|
||||
padding-left: 0;
|
||||
color: rgb(var(--arcoblue-6));
|
||||
}
|
||||
|
||||
&__tree-dir2 {
|
||||
padding-left: 20px;
|
||||
color: rgb(var(--arcoblue-5));
|
||||
}
|
||||
|
||||
&__tree-file {
|
||||
padding-left: 40px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
/** 制表符(├─ └─):缩进靠父级的 padding,这里只管样式 */
|
||||
&__br {
|
||||
color: var(--color-text-3);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&__tree-note {
|
||||
margin-top: 8px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 12px;
|
||||
margin-top: 6px;
|
||||
border-top: 1px solid var(--color-border-1);
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
&__field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
margin-bottom: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user