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
+377
View File
@@ -0,0 +1,377 @@
# -*- coding: utf-8 -*-
"""M6 导出与交付 E2E(《03-接口设计》§12.4 ~ §12.6)。
它验的不是"能不能打个包",而是**这条链路上四条硬规则**:
1. **铁律 10**:有不合格文件 → `40035` 且 `canExport=false`,
并且清单要**指名道姓**(谁的哪一项)—— 只说"存在不合格文件"等于没说。
2. **只装已通过终审的**:待审/被驳回的单子不许进包(交付物不能有半成品)。
3. **目录结构与内容**:班级/姓名/规范件名 + 汇总表;解压后能直接交给认定机构。
4. **中文文件名在 Windows 解压不乱码** —— 靠 ZIP 的 UTF-8 + EFS 标志位。
这一条在服务器上"看着都对",只在客户机器上出事,所以**必须在产物里断言**:
用 commons-compress 读回 entry 名,并检查 general purpose bit 11 被置位。
跑法(前提:后端 8080 在跑、`python scripts/dev-seed-audit.py` 已摆好演示数据):
python scripts/e2e-m6-export.py
"""
from __future__ import annotations
import argparse
import importlib.util
import io
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]] = []
def _load(module_name: str, filename: str):
"""复用既有 E2E 的工具(登录 / SQL / 打印口径),不复制第二份。"""
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"
# 演示任务(`scripts/dev-seed-audit.py` 建的)——按名字找,不写死 id
DEMO_TASK_NAME = "【演示】审核台"
TEACHER_USER = "demot1"
TEACHER_PWD = "Demo1234"
def reset_demo_state() -> int:
"""
把演示数据摆回"刚提交完、还没审"的初始态。
为什么必须重摆而不是"接着上一轮跑":
本脚本要验的第一件事是**还有没审完的人时必须拦住导出**(铁律 10)。
而上一轮跑完,那三份已经全审过了 → 这条断言必然失败,
于是"第二次跑就红"——那种红看着像功能坏了,其实只是夹具状态不对。
(本脚本第一次跑就踩了这个:第二轮 canExport 直接是 true。)
直接复用开发用的种子脚本,避免在这里再写一份造数逻辑(两份必然分叉)。
"""
seed = _load("dev_seed_audit", "dev-seed-audit.py")
argv = sys.argv
try:
sys.argv = ["dev-seed-audit.py", "--reset"]
return seed.main()
finally:
sys.argv = argv
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:
okk, out = R.sql(stmt)
return out
def body_of(method: str, path: str, token: str | None = None, payload: dict | None = None):
_, b = R.call(method, f"{BASE}{path}", token, payload)
return b
def main() -> int:
ap = argparse.ArgumentParser(description="M6 导出与交付 E2E")
args = ap.parse_args()
print("=" * 72)
print(" M6 导出与交付 E2E")
print("=" * 72)
print("\n[准备] 把演示数据重摆到「刚提交、还没审」的初始态")
if reset_demo_state() != 0:
print(" ✗ 摆演示数据失败,先看 dev-seed-audit.py 的输出")
return 2
_, adm = R.login(BASE, "admin", "Admin@123", "demo")
if adm.get("code") != 0:
print(f" ✗ 管理员登录失败:{adm}")
return 2
at = adm["data"]["token"]
_, tea = R.login(BASE, TEACHER_USER, TEACHER_PWD, "demo")
if tea.get("code") != 0:
print(f" ✗ 班主任登录失败:{tea}(先跑 scripts/dev-seed-audit.py)")
return 2
tt = tea["data"]["token"]
# 找演示任务
tasks = body_of("GET", "/api/v1/tasks?pageNum=1&pageSize=200", at)
task = next((t for t in (tasks.get("data") or {}).get("list") or []
if t.get("taskName") == DEMO_TASK_NAME), None)
if not task:
print(f" ✗ 找不到演示任务「{DEMO_TASK_NAME}」,先跑 python scripts/dev-seed-audit.py")
return 2
task_id = task["id"]
print(f"\n[0] 演示任务 id={task_id}")
# ── 1. 前置检查:有人没审完 → 必须拦住,并说清是谁 ──
print("\n[1] 前置检查(还有没审完的人)")
pre = body_of("GET", f"/api/v1/exports/precheck?taskId={task_id}", at)
check("precheck 接口可用", pre.get("code") == 0, json.dumps(pre, ensure_ascii=False)[:200])
d = pre.get("data") or {}
check("canExport=false(还有没审完的)", d.get("canExport") is False, f"{d.get('canExport')}")
check("阻断里指出了 UNAPPROVED 这一类",
any(b.get("type") == "UNAPPROVED" for b in (d.get("blocking") or [])),
json.dumps((d.get("blocking") or [])[:3], ensure_ascii=False))
check("阻断项带姓名(能指名道姓)",
all(b.get("realName") for b in (d.get("blocking") or [])))
check("summary.unapproved 与阻断条数一致",
(d.get("summary") or {}).get("unapproved") == len([b for b in (d.get("blocking") or [])
if b.get("type") == "UNAPPROVED"]),
json.dumps(d.get("summary"), ensure_ascii=False))
check("quickActions 给出下一步(APPROVE_PENDING)",
"APPROVE_PENDING" in (d.get("quickActions") or []),
json.dumps(d.get("quickActions"), ensure_ascii=False))
create = body_of("POST", "/api/v1/exports", at, {
"taskId": task_id, "exportName": "不该成功", "scope": {"onlyApproved": False}
})
check("这是前置检查不过时的 40035(不是别的错)", create.get("code") == 40035,
f"code={create.get('code')} msg={create.get('msg')}")
check("40035 的 data 里带着完整清单(前端能直接列出来)",
isinstance(create.get("data"), dict)
and "blocking" in (create.get("data") or {}),
json.dumps(create.get("data"), ensure_ascii=False)[:200])
check("被拦时**没有**创建导出记录",
sql(f"select count(*) from collect_export_task where task_id={task_id} "
f"and status <> 3;").strip() == "0")
# ── 2. 把三个学生审完(班主任初审 + 管理员终审)──
print("\n[2] 把演示数据审到全部通过")
todos = body_of("GET", "/api/v1/audit/todos?node=1&pageNum=1&pageSize=50", tt).get("data") or {}
first_ids = [x["submissionId"] for x in (todos.get("list") or [])]
check("班主任看到初审待办", len(first_ids) >= 2, f"todos={first_ids}")
for sid in first_ids:
b = body_of("POST", f"/api/v1/audit/submissions/{sid}/approve", tt,
{"actionSource": "BUTTON"})
if b.get("code") != 0:
fail(f"初审通过 submission={sid}", json.dumps(b, ensure_ascii=False)[:200])
check("初审全部通过", True)
final = body_of("GET", "/api/v1/audit/todos?node=2&pageNum=1&pageSize=50", at).get("data") or {}
passes = [x["submissionId"] for x in (final.get("list") or [])]
for sid in passes:
b = body_of("POST", f"/api/v1/audit/submissions/{sid}/approve", at,
{"actionSource": "BUTTON", "finalOpinion": "E2E 终审通过"})
if b.get("code") != 0:
fail(f"终审通过 submission={sid}", json.dumps(b, ensure_ascii=False)[:200])
passed_cnt = sql(f"select count(*) from collect_submission where task_id={task_id} "
f"and status=3 and deleted=0;").strip()
check("三份填报单都是「已通过」", passed_cnt == "3", f"status=3 的份数={passed_cnt}")
# ⚠️ 已通过的单子**不会再出现在待办里**(待办只列待处理的),
# 所以后面要用到的 id 必须从库里取 —— 用 todos 的返回值会让脚本
# "第一次跑得过、第二次跑就空指针"(本脚本已经踩过一次)。
final_ids = [int(x) for x in sql(
f"select id from collect_submission where task_id={task_id} and status=3 "
f"and deleted=0 order by id limit 3;").split()]
check("能从库里取到已通过的填报单 id", len(final_ids) == 3, f"{final_ids}")
# ── 3. 前置检查应当通过 ──
print("\n[3] 审完之后:前置检查应当放行")
pre2 = body_of("GET", f"/api/v1/exports/precheck?taskId={task_id}", at).get("data") or {}
check("canExport=true", pre2.get("canExport") is True,
json.dumps(pre2.get("blocking"), ensure_ascii=False)[:300])
check("候选人数 = 3", pre2.get("candidateCount") == 3, f"{pre2.get('candidateCount')}")
check("文件数 = 9(3 人 × 3 类材料)", pre2.get("fileCount") == 9, f"{pre2.get('fileCount')}")
check("原始体积 > 0", (pre2.get("rawSizeBytes") or 0) > 0, f"{pre2.get('rawSizeBytes')}")
# ── 4. 创建导出并等完成 ──
print("\n[4] 创建导出并轮询到完成")
created = body_of("POST", "/api/v1/exports", at, {
"taskId": task_id,
"exportName": "E2E导出-验收",
"scope": {"onlyApproved": True},
"format": "ZIP",
"generateSummaryExcel": True
})
check("创建成功", created.get("code") == 0, json.dumps(created, ensure_ascii=False)[:250])
export_id = (created.get("data") or {}).get("exportId")
check("返回 exportId", isinstance(export_id, int), f"{export_id}")
detail = {}
for _ in range(40):
detail = body_of("GET", f"/api/v1/exports/{export_id}", at).get("data") or {}
if detail.get("status") in (2, 3):
break
import time
time.sleep(1)
check("导出完成(status=2)", detail.get("status") == 2,
f"status={detail.get('status')} step={detail.get('stepText')} err={detail.get('errorMsg')}")
check("进度 100", detail.get("progress") == 100, f"{detail.get('progress')}")
check("产物清单非空", bool(detail.get("resultFiles")), json.dumps(detail.get("resultFiles"))[:200])
rf = (detail.get("resultFiles") or [{}])[0]
check("产物名带导出名", str(rf.get("name", "")).startswith("E2E导出-验收"), f"{rf.get('name')}")
check("产物有体积", (rf.get("size") or 0) > 0, f"{rf.get('size')}")
check("产物有 sha256 校验值", str(rf.get("checksum", "")).startswith("sha256:"),
f"{rf.get('checksum')}")
check("stats 里有压缩率", bool((detail.get("stats") or {}).get("compressRatio")),
json.dumps(detail.get("stats"), ensure_ascii=False))
# ── 5. 下载并验内容 ──
print("\n[5] 下载并检查包内容")
dl = body_of("GET", f"/api/v1/exports/{export_id}/download", at).get("data") or {}
check("下载信息给出地址与文件名", bool(dl.get("url")) and bool(dl.get("fileName")),
json.dumps(dl, ensure_ascii=False)[:200])
check("下载有效期 > 0 秒", (dl.get("expireSeconds") or 0) > 0, f"{dl.get('expireSeconds')}")
zip_path = os.path.join(ROOT, "scripts", "_e2e-export.zip")
code = os.system(f'curl -s -o "{zip_path}" -H "Authorization: Bearer {at}" '
f'"{BASE}{dl.get("url")}"')
check("下载接口返回 200 且落地成文件",
os.path.exists(zip_path) and os.path.getsize(zip_path) > 1000,
f"exit={code} size={os.path.getsize(zip_path) if os.path.exists(zip_path) else 'N/A'}")
names: list[str] = []
with zipfile.ZipFile(zip_path) as z:
for info in z.infolist():
names.append(info.filename)
# ★ UTF-8 标志位(general purpose bit 11)—— Windows 解压看的就是它
if info.filename.endswith("身份证.jpg") or info.filename.endswith("汇总表.xlsx"):
check(f"「{info.filename}」置了 UTF-8 标志位(bit 11)",
bool(info.flag_bits & 0x800), f"flag_bits={info.flag_bits:#x}")
# 汇总表内容。⚠️ 文件名按 PRD §5.9 的官方叫法,不是"汇总表.xlsx"
xlsx = [n for n in names if n.endswith("(汇总).xlsx")]
check("包里有汇总表(文件名用机构叫法)", bool(xlsx), f"{names[:5]}")
if xlsx:
from openpyxl import load_workbook
with z.open(xlsx[0]) as fh:
wb = load_workbook(io.BytesIO(fh.read()))
ws = wb.active
headers = [c.value for c in ws[1]]
check("汇总表 21 列(对齐官方模板 A~U)", len(headers) == 21, f"{len(headers)}:{headers}")
check("汇总表表头关键列正确",
headers[:4] == ["序号", "姓名", "身份证号", "性别"] and headers[-1] == "备注",
f"{headers[:4]} … {headers[-1]}")
check("汇总表数据行 = 3", ws.max_row == 4, f"max_row={ws.max_row}")
names_col = [ws.cell(row=r, column=2).value for r in range(2, ws.max_row + 1)]
check("汇总表里三个学生都在", sorted(n for n in names_col if n) ==
sorted(["演示甲", "演示乙", "演示丙"]), f"{names_col}")
idcard = str(ws.cell(row=2, column=3).value or "")
check("汇总表里的证件号是脱敏的(不含 18 位明文)",
"*" in idcard and len(idcard) <= 18, f"{idcard}")
# ── 目录结构必须与 PRD §5.9 的规范一致(不是"看着合理"就行)──
check("根目录是「{批次号}-{任务名称}」",
any(n.startswith("202609-【演示】审核台/") for n in names),
f"{sorted({n.split('/')[0] for n in names})}")
check("第二层是班级", any(n.count("/") >= 2 and n.split("/")[1].startswith("E2E 24数控1班")
for n in names),
f"{sorted({n.split('/')[1] for n in names if n.count('/') >= 2})}")
mats = sorted({n.split("/")[2] for n in names if n.count("/") >= 3})
# ⚠️ 用 set 比较,不用"排序后相等":中文的排序结果取决于码点,
# 写死一个顺序会让断言在换一台机器/换一个 Python 版本时莫名其妙地红。
check("第三层是**材料类型**(一寸照片/身份证/学籍表)",
set(mats) == {"一寸照片", "身份证", "学籍表"}, f"{mats}")
check("第四层是文件(班级/材料/文件 三层目录 + 文件名)",
all(n.count("/") == 3 for n in names if n.endswith(".jpg")),
f"{[n for n in names if n.endswith('.jpg')][:3]}")
check("汇总表在根目录(不进子目录)",
all(n.count("/") == 1 for n in names if n.endswith("(汇总).xlsx")),
f"{xlsx}")
check("文件名是规范件名(带姓名与证件号)",
all("演示" in n for n in names if n.endswith(".jpg")),
f"{[n for n in names if n.endswith('.jpg')][:3]}")
check("没有把草稿/原始件混进去(无 raw/ 前缀)",
not any(n.startswith("raw/") or "_raw" in n for n in names), f"{names[:5]}")
# 目录树预览接口:必须与产物一致(否则"预览"就是谎言)
tree = body_of("GET", f"/api/v1/exports/preview?taskId={task_id}&onlyApproved=true",
at).get("data") or {}
check("目录树预览接口可用", tree.get("root") == "202609-【演示】审核台",
f"root={tree.get('root')}")
check("预览的班级节点与产物一致",
{x.get("name") for x in (tree.get("children") or [])}
== {n.split("/")[1] for n in names if n.count("/") >= 2},
f"{[x.get('name') for x in (tree.get('children') or [])]}")
preview_mats = {m.get("name") for c in (tree.get("children") or [])
for m in (c.get("children") or [])}
check("预览的材料目录与产物一致", preview_mats == set(mats), f"{preview_mats}")
check("预览标注了人数与文件数",
tree.get("personCount") == 3 and tree.get("fileCount") == 9,
f"person={tree.get('personCount')} file={tree.get('fileCount')}")
os.remove(zip_path)
# ── 6. 导出的连带效果 ──
print("\n[6] 导出之后的连带约束")
#
# ⚠️ 这里**不能**断言"撤回返回 40033":`RevokePolicy` 在导出之后直接把
# `canRevoke` 置 false(理由写明"已导出打包,不可撤回"),
# 前端因此拿不到 `auditRecordId` → 真去调接口会先撞"缺少要撤回的记录 id"(40001)。
# 也就是说 **40033 在界面上不会出现**,它只在"绕过前端直接调接口"时才是可达的。
# 所以这里断言的是**用户与前端真正看得见的东西**:policy 说不许撤,且理由点名"已导出"。
fdetail = body_of("GET", f"/api/v1/audit/submissions/{final_ids[0]}", at).get("data") or {}
rp = fdetail.get("revokePolicy") or {}
check("已导出之后 canRevoke=false", rp.get("canRevoke") is False, json.dumps(rp, ensure_ascii=False))
check("不能撤的理由点名了「已导出」", "导出" in str(rp.get("reason")),
f"{rp.get('reason')}")
check("前端拿不到可撤回的记录 id(所以不会误发请求)", rp.get("auditRecordId") is None,
f"{rp.get('auditRecordId')}")
revoke = body_of("POST", f"/api/v1/audit/submissions/{final_ids[0]}/revoke", at,
{"reason": "E2E:导出后不该能撤"})
check("绕过前端直接调撤回:被拒(40033 或缺记录 id 都算拒)",
revoke.get("code") in (40033, 40001),
f"code={revoke.get('code')} msg={revoke.get('msg')}")
still = sql(f"select status from collect_submission where id={final_ids[0]};").strip()
check("那一单仍然是「已通过」(没被撤掉)", still == "3", f"status={still}")
lst = body_of("GET", f"/api/v1/exports?taskId={task_id}&pageNum=1&pageSize=10", at).get("data") or {}
check("导出列表里有这次记录", any(x.get("id") == export_id for x in (lst.get("list") or [])),
json.dumps(lst, ensure_ascii=False)[:200])
check("列表里带任务名", all(x.get("taskName") for x in (lst.get("list") or []) if x.get("taskId")),
json.dumps((lst.get("list") or [{}])[0], ensure_ascii=False)[:200])
# ── 7. 边界 ──
print("\n[7] 边界与权限")
b = body_of("POST", "/api/v1/exports", at, {"taskId": task_id, "exportName": "空数据",
"scope": {"orgIds": [999999]}})
check("指定不存在的班 → 没有候选数据被拦住", b.get("code") != 0,
f"code={b.get('code')} msg={b.get('msg')}")
b = body_of("POST", "/api/v1/exports", at, {"exportName": "没任务"})
check("缺 taskId → 40001", b.get("code") == 40001, f"code={b.get('code')}")
b = body_of("GET", f"/api/v1/exports/{export_id}", tt)
check("班主任(无 export:download)看导出记录 → 40301 或 403",
b.get("code") in (40301, 403) or b.get("_http") == 403,
f"code={b.get('code')} http={b.get('_http')}")
b = body_of("GET", "/api/v1/exports/99999999", at)
check("不存在的导出 id → 明确报错而不是 500", b.get("code") == 40001, f"code={b.get('code')}")
print("\n" + "=" * 72)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
print("=" * 72)
for n, det in FAILED:
print(f" ✗ {n}\n {det}")
return 1 if FAILED else 0
if __name__ == "__main__":
raise SystemExit(main())