Files
xiaotutongxue/scripts/e2e-m5-proxy.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

451 lines
21 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 -*-
"""班主任代传材料 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())