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>
</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 {
@@ -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 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);