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