Files
xiaotutongxue/scripts/e2e-m6-export-split.py
lovife 30c3714d35 test(scripts): 36 个 E2E 与 6 个机械校验工具(1548 条断言)+ 一键交付自检
新增:check-api-parity(契约 121 接口 ↔ 控制器,自带「必须恰好 121」自检)、
check-entity-columns(基类要求的列 vs 迁移建表列,F-35 那个坑踩了四次之后上的工具)、
check-manual(手册引用的错误码与提示文案对得上代码)、check-test-profile(test profile 真起一次)、
e2e-prodguard、load-test(四场景、按端点分别统计)、verify-all(一条命令跑完交付自检)、
dev-seed-audit(界面验收用的演示数据),以及 M1~M9 各轮 E2E。

压测抓到一个真缺陷并修掉(F-92):门禁 IP 限流原来计「所有请求」,于是一个班连同一个
WiFi 时 60 个不同学生同时核验只过了 20 个 → 改为只计失败 + 单独的成功上限,20/60 → 60/60。

度量工具的偏差会伪装成产品缺陷(F-60/F-77/F-97),所以每个脚本都断言「动作自己的结论字段」
(submitted / created / canExport),而不是 HTTP 200 + code 0。

验证:verify-all.py --quick 5/5;--with-e2e 见 docs/09 §8。
2026-09-30 09:02:57 +08:00

326 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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())