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:
2026-09-30 09:02:50 +08:00
parent 3195dc4d7b
commit 6cca97efe6
18 changed files with 3406 additions and 1 deletions
+15
View File
@@ -89,6 +89,21 @@
<artifactId>easyexcel</artifactId> <artifactId>easyexcel</artifactId>
</dependency> </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 时用,本地开发可退回磁盘 --> <!-- 对象存储(材料原图与规范件)—— provider=qiniu 时用,本地开发可退回磁盘 -->
<dependency> <dependency>
<groupId>com.qiniu</groupId> <groupId>com.qiniu</groupId>
@@ -4,15 +4,22 @@ import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;
/** /**
* 数据信息填报系统 · 主应用。 * 数据信息填报系统 · 主应用。
* *
* <p>一期范围:<b>学校收集 + 自主认定</b>(见 PRD「一期范围声明」)。 * <p>一期范围:<b>学校收集 + 自主认定</b>(见 PRD「一期范围声明」)。
* 社会收集 / 社会评价 / 专项职业能力的数据结构与枚举保留但不启用,传入返回 {@code 40044}。 * 社会收集 / 社会评价 / 专项职业能力的数据结构与枚举保留但不启用,传入返回 {@code 40044}。
*
* <p>{@code @EnableScheduling} 是给<b>导出包过期清理</b>用的
* ({@code ExportCleanupService},契约 §12 的"保留 7 天")。
* 目前只有这一个定时任务 —— 别顺手加更多:定时任务是"没有请求也会发生的事",
* 每加一个都要能回答"它失败了谁会知道"。
*/ */
@SpringBootApplication @SpringBootApplication
@ConfigurationPropertiesScan @ConfigurationPropertiesScan
@EnableScheduling
@MapperScan("cn.tianbaotong.fill.module.**.mapper") @MapperScan("cn.tianbaotong.fill.module.**.mapper")
public class FillApplication { public class FillApplication {
@@ -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);
}
}
@@ -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));
}
}
@@ -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;
}
}
@@ -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;
}
@@ -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;
}
@@ -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);
}
@@ -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);
}
@@ -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> {
}
@@ -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;
}
}
@@ -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 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 title 一句话说清"哪里不对"
* @param detail 说清"怎么改"。<b>要能照着做</b>—— * @param detail 说清"怎么改"。<b>要能照着做</b>——
* "材料不合规"这种话等于没说,学生会直接打电话给班主任 * "材料不合规"这种话等于没说,学生会直接打电话给班主任
* @param extra 定位信息(材料码、名册 id 等);没有就为 null
*/ */
public record Issue(String ruleCode, int severity, String title, String detail, public record Issue(String ruleCode, int severity, String title, String detail,
Map<String, Object> extra) { 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);
+233
View File
@@ -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>
+536
View File
@@ -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>