# -*- coding: utf-8 -*- """导出分卷与过期清理 E2E(《03-接口设计》§12.4 的 `splitSizeMb` / 保留 7 天)。 单独一个脚本(而不是塞进 `e2e-m6-export.py`)的理由:那两个脚本跑的前置不同 —— `e2e-m6-export.py` 要先摆演示数据并**导出后不可撤回**的连带断言; 这个脚本只关心"分卷对不对"与"过期之后会怎样",不需要那一整套前置。 ## 分卷的验收点 ① **每一卷都是完整可解的 ZIP**(不是 `.z01` 那种必须全部到齐的分卷格式)—— 客户拿到包往往只会双击其中一个,切成 `.z01` 会让对方以为文件坏了; ② 卷名带 `-partN`,每一卷都有独立的 sha256; ③ 每一卷根目录都带汇总 Excel(只看一卷时也要能拿到名单)。 ## 过期清理的验收点 ① 到期后**文件删掉、行留下** —— "当时确实导出过"是审计要保留的事实, 删行之后"这个任务导出过几卷、给谁下载过"就查不出来了; ② 下载接口明确回「已被清理、请重新导出」,而不是「文件不存在」(那像 bug); ③ 任务状态是 **4 已过期**,不是 3 失败 —— **过期不是失败**, 把正常交付过的包算成失败,会让交付统计这个要给人看的数字失准。 跑法:python scripts/e2e-m6-export-split.py """ from __future__ import annotations import argparse import importlib.util import json import os import sys import zipfile sys.stdout.reconfigure(encoding="utf-8") ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PASSED: list[str] = [] FAILED: list[tuple[str, str]] = [] CREATED_EXPORT_IDS: list[int] = [] def _load(module_name: str, filename: str): path = os.path.join(ROOT, "scripts", filename) spec = importlib.util.spec_from_file_location(module_name, path) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod R = _load("e2e_m2_roster", "e2e-m2-roster.py") BASE = "http://localhost:8080" SUMMARY_NAME = "考生线下报名信息导入模板(汇总).xlsx" def ok(name: str) -> None: PASSED.append(name) print(f" [OK] {name}") def fail(name: str, detail: str) -> None: FAILED.append((name, detail)) print(f" [FAIL] {name}\n {detail}") def check(name: str, cond: bool, detail: str = "") -> bool: if cond: ok(name) else: fail(name, detail or "断言不成立") return cond def sql(stmt: str) -> str: _, out = R.sql(stmt) return out def body_of(method: str, path: str, token: str | None = None, payload: dict | None = None) -> dict: _, b = R.call(method, f"{BASE}{path}", token, payload) return b def wait_export(export_id: int, token: str, tries: int = 40) -> dict: """轮询到终态(2 完成 / 3 失败 / 4 过期)。""" import time d = {} for _ in range(tries): d = body_of("GET", f"/api/v1/exports/{export_id}", token).get("data") or {} if d.get("status") in (2, 3, 4): return d time.sleep(1) return d def main() -> int: ap = argparse.ArgumentParser(description="导出分卷与过期清理 E2E") ap.parse_args() print("=" * 74) print(" 导出分卷与过期清理 E2E(§12.4)") print("=" * 74) _, adm = R.login(BASE, "admin", "Admin@123", "demo") if adm.get("code") != 0: print(f" ✗ 管理员登录失败:{adm}") return 2 at = adm["data"]["token"] # ── 0. 自己摆演示数据 ── # # ⚠️ 原来这里靠"找任意一个可导出的任务",而那种任务只有 # `e2e-m6-export.py` 跑过才有 —— 也就是**隐式依赖另一个脚本的副作用**。 # 全量回归时它排在前面(或那个脚本中途失败)就会以 # "没有可导出的任务"退出,看起来像脚本自己坏了。 # 自己摆夹具(与 e2e-m6-export.py 同一做法)就没有这个顺序依赖。 print("\n[0] 自己摆演示数据(不依赖别的脚本跑过)") import subprocess seed = subprocess.run([sys.executable, os.path.join(ROOT, "scripts", "dev-seed-audit.py"), "--reset", "--approve-one", "--reset-gate-limit"], capture_output=True, text=True, encoding="utf-8", timeout=600) if seed.returncode != 0: print(" ✗ dev-seed-audit.py 失败:") print((seed.stdout or "")[-1200:]) print((seed.stderr or "")[-600:]) return 2 ok("演示数据就绪(dev-seed-audit.py --reset)") _, b = R.call("GET", f"{BASE}/api/v1/tasks?pageSize=50", at) tasks = (b.get("data") or {}).get("list") or [] task_id = None for t in tasks: # 用 **候选数** 而不是 canExport 判断:演示种子里没人终审通过, # 所以 onlyApproved=true 的候选本来就是 0 —— 那是正确的,不是缺陷 pre = body_of("GET", f"/api/v1/exports/precheck?taskId={t['id']}&onlyApproved=true", at) if pre.get("code") == 0 and ((pre.get("data") or {}).get("candidateCount") or 0) > 0: task_id = t["id"] break if task_id is None: print(" ✗ 演示数据里没有可导出的任务(dev-seed-audit.py 的预期没达成)") return 2 check("找到可导出的任务(有已提交的填报单)", task_id is not None, f"{task_id}") # ★ 这一条是刚修掉的那个缺陷的回归防线:候选为 0 时 canExport 必须是 false, # 否则界面会把「开始导出」点亮,点下去却报"没有可导出的数据" pre_empty = body_of("GET", f"/api/v1/exports/precheck?taskId={task_id}&onlyApproved=true", at) pe = pre_empty.get("data") or {} check("⭐ 候选为 0 时 canExport 必须是 false(按钮不能亮着却导不出)", pe.get("candidateCount") != 0 or pe.get("canExport") is False, f"cand={pe.get('candidateCount')} canExport={pe.get('canExport')}") # ── 1. 单卷(不传 splitSizeMb)行为不变 ── print("\n[1] 单卷:不传 splitSizeMb 时行为与以前一致") single = body_of("POST", "/api/v1/exports", at, { "taskId": task_id, "exportName": "E2E分卷-单卷", "scope": {"onlyApproved": True} }) check("创建成功", single.get("code") == 0, json.dumps(single, ensure_ascii=False)[:200]) sid = (single.get("data") or {}).get("exportId") CREATED_EXPORT_IDS.append(sid) sd = wait_export(sid, at) check("完成", sd.get("status") == 2, f"status={sd.get('status')} err={sd.get('errorMsg')}") check("⭐ 单卷只有 1 个产物", len(sd.get("resultFiles") or []) == 1, f"{len(sd.get('resultFiles') or [])}") check("单卷名里**没有** -part(不分卷时不该多出后缀)", "-part" not in ((sd.get("resultFiles") or [{}])[0].get("name") or ""), f"{(sd.get('resultFiles') or [{}])[0].get('name')}") # ── 2. 分卷:1MB 阈值必然分出多卷 ── print("\n[2] 分卷:splitSizeMb=1 必然分出多卷") sp = body_of("POST", "/api/v1/exports", at, { "taskId": task_id, "exportName": "E2E分卷-多卷", "scope": {"onlyApproved": True}, "splitSizeMb": 1 }) check("创建成功", sp.get("code") == 0, json.dumps(sp, ensure_ascii=False)[:200]) spid = (sp.get("data") or {}).get("exportId") CREATED_EXPORT_IDS.append(spid) spd = wait_export(spid, at) check("完成", spd.get("status") == 2, f"status={spd.get('status')} err={spd.get('errorMsg')}") vols = spd.get("resultFiles") or [] check("产物非空", len(vols) >= 1, json.dumps(vols, ensure_ascii=False)[:250]) check("⭐ 卷名带 -partN(客户按顺序解压)", all(f"-part{i + 1}.zip" in (v.get("name") or "") for i, v in enumerate(vols)), f"{[v.get('name') for v in vols]}") check("⭐ 每一卷都有独立的 sha256(交付完整性可逐卷核对)", all(str(v.get("checksum", "")).startswith("sha256:") for v in vols), f"{[v.get('checksum') for v in vols]}") check("卷号从 0 开始且连续", sorted(v.get("part") for v in vols) == list(range(len(vols))), f"{[v.get('part') for v in vols]}") check("数据库中一票一卷一行", sql(f"select count(*) from collect_export_file where export_id={spid} " f"and deleted=0;").strip() == str(len(vols)), sql(f"select part_no, file_name from collect_export_file where export_id={spid};")) total_mb = sum(float(v.get("sizeMb") or 0) for v in vols) check("⭐ 每卷体积都不超过阈值(1MB,允许压缩后偏小)", all(float(v.get("sizeMb") or 0) <= 1.05 for v in vols), f"{[v.get('sizeMb') for v in vols]}") # ── 3. 逐卷解开 —— 这是"每一卷都是完整 ZIP"的唯一可靠验证 ── print("\n[3] 逐卷独立解开(每一卷都必须是完整 ZIP)") all_entries: list[str] = [] for i, v in enumerate(vols): dl = body_of("GET", f"/api/v1/exports/{spid}/download?fileIndex={i}", at).get("data") or {} check(f"第 {i + 1} 卷有下载地址", bool(dl.get("url")), json.dumps(dl, ensure_ascii=False)[:200]) p = os.path.join(ROOT, "scripts", f"_e2e-split-{i}.zip") os.system(f'curl -s -o "{p}" -H "Authorization: Bearer {at}" "{BASE}{dl.get("url")}"') try: with zipfile.ZipFile(p) as z: bad = z.testzip() check(f"⭐ 第 {i + 1} 卷能**独立**解开(CRC 全过)", bad is None, f"坏条目={bad}") inside = z.namelist() all_entries.extend(inside) check(f"第 {i + 1} 卷根目录带汇总 Excel(只看一卷也能拿到名单)", any(n.endswith(SUMMARY_NAME) for n in inside), f"{inside[:3]}") check(f"第 {i + 1} 卷不是空卷", len(inside) > 0, f"{len(inside)} 个条目") except zipfile.BadZipFile as e: fail(f"第 {i + 1} 卷不是合法 ZIP", str(e)) finally: if os.path.exists(p): os.remove(p) # 各卷条目合起来 = 单卷的完整交付(不漏不重) # # ⚠️ 在这里**重新取一次下载地址**并校验落地:第 1 节拿到的地址可能在 # dev-seed 重置之后已经失效,而复用旧地址的表现是 # `FileNotFoundError`(看起来像"下载坏了",实际是"用了过期地址")。 ssingle = body_of("GET", f"/api/v1/exports/{sid}/download?fileIndex=0", at).get("data") or {} p1 = os.path.join(ROOT, "scripts", "_e2e-single.zip") if os.path.exists(p1): os.remove(p1) # -f 让 curl 在 HTTP 错误时返回非零,而不是安静地不生成文件 rc = os.system(f'curl -sf -o "{p1}" -H "Authorization: Bearer {at}" ' f'"{BASE}{ssingle.get("url")}"') if not os.path.exists(p1): fail("单卷产物下载失败(无法做合并对比)", f"url={ssingle.get('url')} rc={rc} 单卷记录状态={sd.get('status')}") single_entries = set() else: with zipfile.ZipFile(p1) as z: single_entries = set(z.namelist()) os.remove(p1) check("⭐ 分卷条目合起来 = 单卷的完整交付(不漏不重)", set(all_entries) == single_entries, f"分卷独有={sorted(set(all_entries) - single_entries)[:3]} " f"单卷独有={sorted(single_entries - set(all_entries))[:3]}") # ── 4. 过期清理 ── print("\n[4] 过期清理:删文件、留记录") n_before = sql(f"select count(*) from collect_export_file where export_id={spid} " f"and downloadable=1;").strip() check("清理前各卷都是可下载的", n_before == str(len(vols)), f"{n_before}") paths = [x.strip() for x in sql(f"select file_path from collect_export_file " f"where export_id={spid} order by part_no;").split("\n") if x.strip()] check("库里记了各卷的磁盘路径", len(paths) == len(vols), f"{paths}") # 把过期时间改到 1 小时前 —— 不然要等 7 天 sql(f"update collect_export_task set expire_time = now() - interval '1 hour' where id={spid};") run = body_of("POST", "/api/v1/exports/cleanup-run", at, {}) check("手工清理入口可用(不然验证过期要等一小时)", run.get("code") == 0, json.dumps(run, ensure_ascii=False)[:200]) check("清掉了至少一个文件", ((run.get("data") or {}).get("cleanedFiles") or 0) >= 1, json.dumps(run.get("data"), ensure_ascii=False)) check("⭐ 产物行**还在**(审计要保留「当时导出过」这个事实)", sql(f"select count(*) from collect_export_file where export_id={spid} " f"and deleted=0;").strip() == str(len(vols)), sql(f"select count(*) from collect_export_file where export_id={spid};")) check("⭐ 全部标成 downloadable=0", sql(f"select count(*) from collect_export_file where export_id={spid} " f"and downloadable=0;").strip() == str(len(vols)), sql(f"select part_no, downloadable from collect_export_file where export_id={spid};")) check("⭐ 任务状态 = 4 已过期(不是 3 失败 —— 过期不是失败)", sql(f"select status from collect_export_task where id={spid};").strip() == "4", sql(f"select status, error_msg from collect_export_task where id={spid};")) check("清完之后磁盘文件真的没了", all(not os.path.exists(p) for p in paths), f"{[p for p in paths if os.path.exists(p)]}") dl_after = body_of("GET", f"/api/v1/exports/{spid}/download?fileIndex=0", at) check("⭐ 过期后下载提示「已过期/请重新导出」,而不是像 bug 的「文件不存在」", "过期" in (dl_after.get("msg") or "") or "重新导出" in (dl_after.get("msg") or ""), f"code={dl_after.get('code')} msg={dl_after.get('msg')}") fl_after = body_of("GET", f"/api/v1/exports/{spid}/file?fileIndex=0", at) check("直接取文件流也被拦(不是 500、不是空白文件)", fl_after.get("code") != 0, f"code={fl_after.get('code')}") # ── 5. 重复清理安全 ── print("\n[5] 重复清理安全") run2 = body_of("POST", "/api/v1/exports/cleanup-run", at, {}) check("再清一次不报错(已清理的卷会跳过)", run2.get("code") == 0, json.dumps(run2, ensure_ascii=False)[:200]) check("不会把行删掉或改成别的状态", sql(f"select count(*) from collect_export_file where export_id={spid};").strip() == str(len(vols))) check("单卷那票**没有**被误清理(它是好的,没到过期时间)", sql(f"select count(*) from collect_export_file where export_id={sid} " f"and downloadable=1;").strip() == "1", sql(f"select downloadable from collect_export_file where export_id={sid};")) # ── 6. 边界 ── print("\n[6] 边界") b = body_of("GET", f"/api/v1/exports/{spid}/download?fileIndex=99", at) check("分卷号越界 → 明确报错", b.get("code") != 0, f"code={b.get('code')} msg={b.get('msg')}") b = body_of("GET", "/api/v1/exports/999999999/download", at) check("不存在的导出 id → 明确报错而不是 500", b.get("code") != 0, f"code={b.get('code')}") cleanup() print("\n" + "=" * 74) print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}") print("=" * 74) for n, det in FAILED: print(f" ✗ {n}\n {det}") return 1 if FAILED else 0 def cleanup() -> None: for eid in CREATED_EXPORT_IDS: R.sql(f"delete from collect_export_file where export_id={eid};") R.sql(f"delete from collect_export_task where id={eid};") print(" 已清理:测试导出记录与产物行") if __name__ == "__main__": raise SystemExit(main())