Files
xiaotutongxue/scripts/e2e-m6-export.py
T
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

378 lines
20 KiB
Python
Raw 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 -*-
"""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())