30c3714d35
新增: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。
326 lines
16 KiB
Python
326 lines
16 KiB
Python
# -*- 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())
|