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。
This commit is contained in:
2026-09-30 09:02:57 +08:00
parent 6945f194b5
commit 30c3714d35
26 changed files with 9190 additions and 1 deletions
+325
View File
@@ -0,0 +1,325 @@
# -*- 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())