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
+450
View File
@@ -0,0 +1,450 @@
# -*- coding: utf-8 -*-
"""班主任代传材料 E2E(《03-接口设计》§10.6 + §4.6 教师端文件访问)。
真实场景:学生不会弄(照片在家长手机里 / 不会压缩)、或者人不在。
没有这条通道时,老师只能"让学生把照片发给自己,再让学生登录传上来" ——
而学生之所以要老师帮忙,往往就是因为他弄不了。
## 三道必须守住的闸门
① **范围**:只能给**自己负责的班**的学生代传。不校验的话,
任何老师能给任何学生塞材料 —— 那等于伪造认定材料;
② **状态**:只能给**草稿**状态的填报单代传。已提交的单子塞材料会让
"审到一半的材料清单"变了,而审核人看的是他点开那一刻的内容;
③ **留痕**:`source=2` + 代传人 + 原因 + 操作日志,且**学生端可见** ——
学生必须能知道"这份不是我传的,是李老师帮我传的",
否则他在被驳回时会一头雾水("我什么时候传过这张照片?")。
## 还要验的一件事:`source` 以前从来没被写过
V12 之前 `collect_file.source` 全靠列默认值(库里 10 行全是 1),
所以"自己传的"与"代传的"在数据上**完全区分不出来**。
本脚本会断言代传之后 source 真的变成了 2。
跑法:python scripts/e2e-m5-proxy.py
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import sys
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_TASK_IDS: list[int] = []
TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-proxy-roster.xlsx")
# 一张足够小的真 JPEG(1x1 白点)—— 上传要过格式幻数检查,不能是随便的字节
TINY_JPEG = os.path.join(ROOT, "scripts", "_e2e-proxy.jpg")
T1 = 993501 # 班主任甲(A 班初审人)
T2 = 993502 # 班主任乙(不负责 A 班)
S1 = 993511 # A 班学生
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")
S = _load("e2e_m3_submit", "e2e-m3-submit.py")
A = _load("e2e_m5_audit", "e2e-m5-audit.py")
BASE = "http://localhost:8080"
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 mp(uid: int) -> str:
cfg = R.read_dev_config()
return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4)
def ensure_teacher(uid: int, username: str, name: str, org_id: int) -> str:
sql(f"""
INSERT INTO sys_user (id, tenant_id, username, real_name, password, org_id, user_type, status)
VALUES ({uid}, 1, '{username}', '{name}',
'$2a$10$6NB7sObvoHRwzhLJwZ2CHOAbdECgMJGiiTeMY6.zo3IJyd8TEKLEO', {org_id}, 3, 1)
ON CONFLICT (id) DO UPDATE SET real_name = EXCLUDED.real_name,
org_id = EXCLUDED.org_id,
username = EXCLUDED.username,
password = EXCLUDED.password,
user_type = EXCLUDED.user_type,
status = 1
""")
role_id = sql("select id from sys_role where tenant_id=1 and role_code='HEAD_TEACHER' "
"and deleted=0 limit 1;").strip()
sql(f"delete from sys_user_role where user_id={uid};")
sql(f"INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES ({uid}, {role_id}, 1);")
_, b = R.login(BASE, username, "Admin@123", "demo")
if b.get("code") != 0:
raise SystemExit(f" 班主任 {username} 登录失败:{b}")
return b["data"]["token"]
def upload_proxy(token: str, sid: int, material: str, reason: str | None,
fname: str = "代传照片.jpg") -> dict:
"""用 curl 发 multipart(urllib 手搓 multipart 容易出错)。"""
import subprocess
args = ["curl", "-s", "-X", "POST",
f"{BASE}/api/v1/mp/teacher/proxy-upload",
"-H", f"Authorization: Bearer {token}",
"-F", f"submissionId={sid}",
"-F", f"materialCode={material}",
"-F", f"file=@{TINY_JPEG};filename={fname};type=image/jpeg"]
if reason is not None:
args += ["-F", f"proxyReason={reason}"]
r = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", timeout=60)
try:
return json.loads(r.stdout)
except Exception:
return {"code": -1, "msg": (r.stdout or r.stderr or "")[:300]}
def main() -> int:
ap = argparse.ArgumentParser(description="班主任代传 E2E")
ap.parse_args()
print("=" * 74)
print(" 班主任代传材料 E2E(§10.6 / §4.6)")
print("=" * 74)
_, adm = R.login(BASE, "admin", "Admin@123", "demo")
if adm.get("code") != 0:
print(f" ✗ 管理员登录失败:{adm}")
return 2
at = adm["data"]["token"]
_make_jpeg()
# ── 0. 夹具 ──
print("\n[0] 摆夹具(一个任务、两个班、两个班主任)")
suffix = os.urandom(3).hex()
_, b = R.call("POST", f"{BASE}/api/v1/tasks", at, {
"taskName": f"E2E代传 {suffix}", "batchNo": "202609", "majorName": "数控加工",
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31"
})
if b.get("code") != 0:
print(f" ✗ 建任务失败:{b}")
return 2
task_id = b["data"]["id"]
CREATED_TASK_IDS.append(task_id)
_, b = R.call("GET", f"{BASE}/api/v1/orgs/tree", at)
classes = []
def walk(nodes):
for n in nodes or []:
if n.get("orgType") == 3:
classes.append(n)
walk(n.get("children"))
walk(b.get("data") or [])
org_a, org_b = classes[0], classes[1]
t1 = ensure_teacher(T1, f"proxyT1{suffix}", "代传班主任甲", org_a["id"])
t2 = ensure_teacher(T2, f"proxyT2{suffix}", "代传班主任乙", org_b["id"])
ok("两个班主任账号就绪")
R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", at, {"orgs": [
{"orgId": org_a["id"], "expectedCount": 1, "headUserId": T1},
{"orgId": org_b["id"], "expectedCount": 0, "headUserId": T2}
]})
R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/materials/apply-template", at,
{"templateCode": "TPL_SCHOOL_ROLL"})
rows = [("代传甲", R.with_checksum("36102920080101061"))]
_build_roster(rows)
body = R.upload_roster(BASE, at, task_id, TMP_ROSTER,
extra={"dryRun": "false", "mode": "APPEND", "orgId": org_a["id"]})
check("名册导入成功", body.get("code") == 0, json.dumps(body, ensure_ascii=False)[:200])
R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/publish", at, {"force": True})
tok = mp(S1)
_, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", tok,
{"taskId": task_id, "realName": rows[0][0], "idCardNo": rows[0][1]})
check("学生门禁绑定成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
_, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok, {"taskId": task_id})
sid = b["data"]["id"]
check("学生建了草稿", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:150])
# ── 1. 代传上下文(界面要一直显示"替谁传")──
print("\n[1] 代传前:我到底在替谁传")
ctx = body_of("GET", f"/api/v1/mp/teacher/submissions/{sid}/proxy-context", t1).get("data") or {}
check("上下文接口可用", ctx.get("submissionId") == sid,
json.dumps(ctx, ensure_ascii=False)[:200])
check("带学生姓名(防止传给上一个人)", ctx.get("realName") == rows[0][0],
f"{ctx.get('realName')}")
check("带脱敏证件号(老师要能确认是这个人)", bool(ctx.get("idCardMask")),
f"{ctx.get('idCardMask')}")
check("标出可否代传", ctx.get("canProxy") is True, f"{ctx.get('canProxy')}")
ctx2 = body_of("GET", f"/api/v1/mp/teacher/submissions/{sid}/proxy-context", t2)
check("⭐ 别的班的老师看不到这个上下文(40301)", ctx2.get("code") == 40301,
f"code={ctx2.get('code')} msg={ctx2.get('msg')}")
# ── 2. 代传:一次 multipart 搞定 ──
print("\n[2] 代传一份材料")
# ⚠️ 代传用 IDCARD 而不是 PHOTO1:PHOTO1 只有 1 份上限,
# 如果代传占了它,后面"学生补齐并提交"就会撞 40001(只能传 1 份)——
# 那不是被测代码的问题,是夹具自己占掉了名额。
up = upload_proxy(t1, sid, "IDCARD", "学生说照片在家长手机里,我帮他传")
check("代传成功", up.get("code") == 0, json.dumps(up, ensure_ascii=False)[:300])
fv = up.get("data") or {}
file_id = fv.get("fileId")
check("返回 fileId", isinstance(file_id, int), f"{file_id}")
check("返回代传人 id", fv.get("proxyBy") == T1, f"{fv.get('proxyBy')}")
check("⭐ 返回代传人姓名(审核时间轴要显示「由李老师代传」)",
"代传班主任甲" in (fv.get("proxyByName") or ""), f"{fv.get('proxyByName')}")
check("返回可显示的来源文案", "代传" in (fv.get("sourceText") or ""),
f"{fv.get('sourceText')}")
# ── 3. 留痕:source / proxy_by / proxy_reason / 操作日志 ──
print("\n[3] 留痕(契约 §10.6:代传必须留痕)")
check("⭐ source = 2(班主任代传)—— V12 之前这一列从来没被写过",
sql(f"select source from collect_file where id={file_id};").strip() == "2",
sql(f"select source from collect_file where id={file_id};"))
check("⭐ 记了代传人", sql(f"select proxy_by from collect_file where id={file_id};").strip()
== str(T1), sql(f"select proxy_by from collect_file where id={file_id};"))
check("⭐ 记了代传原因",
"家长手机" in sql(f"select coalesce(proxy_reason,'') from collect_file where id={file_id};"),
sql(f"select proxy_reason from collect_file where id={file_id};"))
check("⭐ 操作日志里有 PROXY_UPLOAD",
sql("select count(*) from sys_operation_log where action='PROXY_UPLOAD' "
f"and target_id={file_id};").strip() == "1",
sql("select action, target_type, extra_json from sys_operation_log "
"where action='PROXY_UPLOAD' order by id desc limit 1;"))
check("日志的 target 指向文件(可按对象查)",
sql(f"select target_type from sys_operation_log where action='PROXY_UPLOAD' "
f"and target_id={file_id} order by id desc limit 1;").strip() == "FILE")
check("文件名也按规范件模板改过(代传不能绕过规范化)",
sql(f"select standard_name from collect_file where id={file_id};").strip() != "",
sql(f"select original_name, standard_name from collect_file where id={file_id};"))
# ── 4. 学生端可见 ──
print("\n[4] 学生端可见(不然他被驳回时会一头雾水)")
mine = body_of("GET", f"/api/v1/mp/submissions/{sid}/progress", tok).get("data") or {}
mats = mine.get("materials") or []
photo = next((m for m in mats if m.get("materialCode") == "IDCARD"), None)
check("学生能在进度页看到这份材料", (photo or {}).get("done") is True,
json.dumps(photo, ensure_ascii=False))
check("进度条不计入「缺材料」(代传的也算已交)",
all(m.get("issueLevel", 0) < 3 for m in mats if m.get("done")),
json.dumps([(m.get("materialCode"), m.get("issueLevel")) for m in mats],
ensure_ascii=False))
# ── 5. 闸门二:已提交不能代传 ──
print("\n[5] 闸门:已提交的填报单不能代传")
res = A.prepare_student(tok, sid, task_id, {
"work_unit": "无", "is_retake": False, "is_student": True,
"school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01"
}, dup_first_file=False)
check("学生补齐并提交", (res.get("data") or {}).get("submitted") is True,
json.dumps(res, ensure_ascii=False)[:250])
up2 = upload_proxy(t1, sid, "IDCARD", None)
check("⭐ 已提交后代传被拒(40030 已进入审核)", up2.get("code") == 40030,
f"code={up2.get('code')} msg={up2.get('msg')}")
check("提示说清要先撤回",
"撤回" in (up2.get("msg") or ""), f"{up2.get('msg')}")
# ── 6. 闸门一:范围 ──
print("\n[6] 闸门:只能给自己负责的班代传")
# 用乙的身份代传甲班学生的材料
up3 = upload_proxy(t2, sid, "IDCARD", None)
check("⭐ 别的班的老师代传 → 40301", up3.get("code") == 40301,
f"code={up3.get('code')} msg={up3.get('msg')}")
up4 = upload_proxy(t1, 999999999, "IDCARD", None)
check("不存在的填报单 → 明确报错", up4.get("code") != 0,
f"code={up4.get('code')} msg={up4.get('msg')}")
up5 = upload_proxy(t1, sid, "NOT_EXIST_MATERIAL", None)
check("任务里没有的材料码 → 明确报错", up5.get("code") != 0,
f"code={up5.get('code')} msg={up5.get('msg')}")
# ── 7. 教师端文件访问(§4.6)──
print("\n[7] 教师端看学生材料(按班级范围授权)")
pv = os.path.join(ROOT, "scripts", "_e2e-proxy-dl.jpg")
code = os.system(f'curl -s -o "{pv}" -H "Authorization: Bearer {t1}" '
f'"{BASE}/api/v1/mp/teacher/files/{file_id}/preview"')
size = os.path.getsize(pv) if os.path.exists(pv) else 0
check("⭐ 班主任能看自己班学生的材料", size > 100, f"exit={code} size={size}")
# Content-Type 必须是图片:给 octet-stream 会让"点开身份证"变成"下载一个文件"。
# ⚠️ 不能用 R.call 读这个响应 —— 它是二进制,json 解析会 UnicodeDecodeError,
# 所以用 curl 的 -D 把响应头写到临时文件再看。
hdr = os.path.join(ROOT, "scripts", "_e2e-proxy-head.txt")
os.system(f'curl -s -o NUL -D "{hdr}" -H "Authorization: Bearer {t1}" '
f'"{BASE}/api/v1/mp/teacher/files/{file_id}/preview"')
ctype = ""
if os.path.exists(hdr):
with open(hdr, encoding="utf-8", errors="replace") as f:
for line in f:
if line.lower().startswith("content-type"):
ctype = line.strip()
os.remove(hdr)
check("⭐ 返回的是图片 Content-Type(否则「点开身份证」会变成「下载文件」)",
"image/" in ctype.lower(), f"{ctype}")
code2 = os.system(f'curl -s -o "{pv}2" -H "Authorization: Bearer {t2}" '
f'"{BASE}/api/v1/mp/teacher/files/{file_id}/preview"')
body2 = ""
if os.path.exists(pv + "2"):
with open(pv + "2", encoding="utf-8", errors="replace") as f:
body2 = f.read()[:200]
os.remove(pv + "2")
check("⭐ 别的班的老师看同一份材料 → 被拒(40301)", "40301" in body2,
f"exit={code2} body={body2[:150]}")
stok = mp(S1)
_, b = R.call("GET", f"{BASE}/api/v1/mp/teacher/files/{file_id}/preview", stok)
check("学生调教师端文件接口 → 被拒", b.get("code") in (40101, 40301, 403),
f"code={b.get('code')}")
if os.path.exists(pv):
os.remove(pv)
# ── 8. 权限 ──
print("\n[8] 权限")
# ⚠️ 必须发**真正的** multipart:这个接口声明了 consumes=multipart/form-data,
# 用 JSON body 会在进 Controller 之前就 500(媒体类型不匹配),
# 那样测的是"请求形态不对"而不是"未登录"。
import subprocess
r = subprocess.run(["curl", "-s", "-X", "POST",
f"{BASE}/api/v1/mp/teacher/proxy-upload",
"-F", f"submissionId={sid}",
"-F", "materialCode=IDCARD",
"-F", f"file=@{TINY_JPEG};filename=x.jpg;type=image/jpeg"],
capture_output=True, text=True, encoding="utf-8", timeout=60)
try:
nb = json.loads(r.stdout)
except Exception:
nb = {"code": -1, "msg": (r.stdout or "")[:200]}
check("未登录代传 → 40101", nb.get("code") == 40101, f"code={nb.get('code')} msg={nb.get('msg')}")
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 _make_jpeg() -> None:
"""造一张真 JPEG:上传要过格式幻数检查,随便的字节会被 40004 拒掉。"""
import base64
# 1x1 白点 JPEG
b64 = ("/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a"
"HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA"
"AAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==")
with open(TINY_JPEG, "wb") as f:
f.write(base64.b64decode(b64))
def _build_roster(rows: list[tuple[str, str]]) -> None:
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "代传班"
header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位",
"报考职业", "报考工种", "报考等级", "是否补考",
"补考理论", "补考实操", "补考综合", "是否在校生",
"在读学校", "所学专业", "入学时间"]
for c, t in enumerate(header, start=1):
ws.cell(row=1, column=c, value=t)
for i, (name, idc) in enumerate(rows):
vals = [i + 1, name, idc, "中专", f"138001344{i}", "无",
"车工", "数控车工", "四级", "否", "", "", "", "是",
"示范职业学校", "数控加工", "2024-09-01"]
for c, v in enumerate(vals, start=1):
if v != "":
ws.cell(row=i + 2, column=c, value=v)
wb.save(TMP_ROSTER)
def cleanup() -> None:
"""清理测试数据,并**把失败暴露出来**。
⚠️ 这个函数原来是静默失败的:`R.sql(...)` 的返回值没人看,
而"已发布的任务删不掉"这件事没有被处理 —— 残留的 roster 行会让
后面的脚本(班主任看板)算出错误的人数,而排查时完全看不出原因。
所以现在:按依赖顺序删 → **逐条记结果** → 末尾校验一行都没剩。
"""
report: list[str] = []
for tid in CREATED_TASK_IDS:
# 顺序不能反:先删引用它们的子表,否则会撞外键/留下孤儿
steps = [
("操作日志", f"delete from sys_operation_log where target_id in "
f"(select id from collect_file where task_id={tid});"),
("文件", f"delete from collect_file where task_id={tid};"),
("填报单", f"delete from collect_submission where task_id={tid};"),
("名册", f"delete from collect_roster where task_id={tid};"),
("班级配置", f"delete from collect_task_org where task_id={tid};"),
("材料定义", f"delete from collect_material_def where task_id={tid};"),
("任务", f"delete from collect_task where id={tid};"),
]
for label, stmt in steps:
okk, out = R.sql(stmt)
report.append(f"{label} task={tid}: {'OK' if okk else 'FAIL ' + out[:120]}")
for label, stmt in [
("代理上传日志", "delete from sys_operation_log where request_uri like '%proxy-upload%';"),
("角色绑定", "delete from sys_user_role where user_id in (993501, 993502, 993511);"),
("测试账号", "delete from sys_user where id in (993501, 993502, 993511);"),
]:
okk, out = R.sql(stmt)
report.append(f"{label}: {'OK' if okk else 'FAIL ' + out[:120]}")
# ★ 校验:一行都不该剩(这一步才是"清理真的成功了吗"的答案)
leftovers = 0
for tid in CREATED_TASK_IDS:
for tbl in ("collect_roster", "collect_submission", "collect_file"):
_, n = R.sql(f"select count(*) from {tbl} where task_id={tid};")
if n.strip() not in ("", "0"):
leftovers += int(n.strip())
report.append(f"⭐ 残留 {tbl} task={tid}: {n.strip()} 行")
report_path = os.path.join(ROOT, "scripts", "_e2e-clean-report.txt")
with open(report_path, "w", encoding="utf-8") as f:
f.write("\n".join(report))
for p in (TMP_ROSTER, TINY_JPEG):
if os.path.exists(p):
os.remove(p)
fails = [x for x in report if "FAIL" in x or "残留" in x]
if fails:
print(f" ⚠️ 清理有 {len(fails)} 处问题(详见 scripts/_e2e-clean-report.txt):")
for x in fails[:6]:
print(f" {x}")
else:
print(" 已清理(并校验无残留):任务 / 名册 / 填报单 / 材料 / 操作日志 / 测试账号")
if __name__ == "__main__":
raise SystemExit(main())