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
+542
View File
@@ -0,0 +1,542 @@
# -*- coding: utf-8 -*-
"""压测(M7-09):门禁并发 / 上传并发 / 读接口 / 导出大任务
## 为什么值得真跑一次,而不是"看代码觉得没问题"
这个项目的三个并发热点,每一个都有"看代码看不出来"的地方:
| 场景 | 直觉 | 实际可能卡在 |
|---|---|---|
| 门禁核验(60 人同时扫码) | "就是查一下名册" | 每个请求都要 **AES 解密全表证件号** 逐个比对(密文没法走索引) |
| 上传(30 人同时传) | "就是写个文件" | 图片压缩是 **CPU 密集**,而 Tomcat 线程数有限 |
| 读接口(列表/统计) | "加个索引就行" | 连接池只有 20 条,长事务(导出打包)会把池子占死 |
| 导出大任务 | "异步的,不影响" | 导出**同步**做前置检查 + 打包,而它在一个大事务里 |
所以这份脚本不做"泛泛压一下",而是**针对这四个场景各设计一组**,
并把 p50/p95/p99 与错误码分布打出来 —— **错误码分布比成功率更有信息量**
(40071 是限流、50000 是内部错、40301 是权限,三种要修的地方完全不同)。
## 用法
```bash
python scripts/load-test.py # 全套(约 2~3 分钟)
python scripts/load-test.py --only gate # 只跑门禁
python scripts/load-test.py --conc 40 # 改并发
```
⚠️ 它**会往库里写东西**(名册、填报单、材料),结尾会清理;
中途 Ctrl-C 会留下残留 —— 用 `--clean` 单独清理一次即可。
"""
import argparse
import base64
import concurrent.futures as cf
import importlib.util
import json
import os
import statistics
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASE = "http://localhost:8080"
SUFFIX = uuid.uuid4().hex[:6]
TMP_ROSTER = os.path.join(ROOT, "scripts", f"_lt-roster-{SUFFIX}.xlsx")
TMP_IMG = os.path.join(ROOT, "scripts", f"_lt-img-{SUFFIX}.jpg")
# 压测规模(可按机器调整;--rows 会覆盖 GATE_USERS)
GATE_USERS = 60 # 名册人数(同时也是门禁的并发用户数)
READ_CONC = 20 # 读接口并发
READ_ROUNDS = 8 # 每个并发线程的轮数
UPLOAD_USERS = 20 # 上传:20 个学生同时传
UPLOAD_PER_USER = 2 # 每人传几张
TASK_IDS = []
ROSTER_IDS = []
UIDS = []
def _load(name, fn):
spec = importlib.util.spec_from_file_location(name, os.path.join(ROOT, "scripts", fn))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
R = _load("e2e_m2_roster", "e2e-m2-roster.py")
def sql(s):
return R.sql(s)[1]
def one(s):
v = sql(s)
return v.split("\n")[0].strip() if v else ""
def call(method, path, token=None, body=None, timeout=60):
return R.call(method, BASE + path, token, body, timeout=timeout)
# ══════════════ 计时与统计 ══════════════
class Stat:
"""一个场景的耗时与错误码统计。"""
def __init__(self, name):
self.name = name
self.lat = []
self.codes = {}
self.fail_detail = []
self.t0 = time.perf_counter()
self.t1 = None
def done(self):
"""场景结束(算吞吐要墙钟时间,不是"平均耗时的倒数")。"""
self.t1 = time.perf_counter()
def add(self, ms, code):
self.lat.append(ms)
self.codes[code] = self.codes.get(code, 0) + 1
def report(self):
if not self.lat:
print(" %-22s 没有样本" % self.name)
return
s = sorted(self.lat)
n = len(s)
ok = self.codes.get(0, 0)
# ⚠️ 吞吐 = 请求数 / **墙钟耗时**。
# 原来的式子化简后是 1/平均耗时 —— 那不是吞吐,
# 而"一行看起来很像结论的数字"最容易被抄进汇报里
elapsed = (self.t1 or time.perf_counter()) - self.t0
print(" %-24s 请求 %-5d 成功 %-5d 失败 %-4d "
"p50 %6.1fms p95 %7.1fms p99 %7.1fms max %7.1fms 吞吐 %5.0f/s"
% (self.name, n, ok, n - ok,
s[int(n * 0.50)], s[min(n - 1, int(n * 0.95))],
s[min(n - 1, int(n * 0.99))], s[-1],
n / max(0.001, elapsed)))
# ⚠️ 错误码分布比成功率更有信息量:40071 限流 / 50000 内部错 / 40301 权限
bad = {k: v for k, v in self.codes.items() if k != 0}
if bad:
print(" 失败分布:%s" % json.dumps(bad, ensure_ascii=False))
if self.fail_detail:
print(" 首个失败样本:%s" % self.fail_detail[0][:200])
def timed(fn):
t0 = time.perf_counter()
try:
code, body = fn()
except Exception as e: # noqa: BLE001
return (time.perf_counter() - t0) * 1000, -1, str(e)
return (time.perf_counter() - t0) * 1000, code, body
# ══════════════ 夹具 ══════════════
def build_fixture(admin):
"""造一个任务 + N 个名册人(每人一个已知证件号)+ 对应 uid 的 MP token。"""
_, orgs = call("GET", "/api/v1/orgs/tree", admin)
classes = []
def walk(ns):
for n in ns or []:
if n.get("orgType") == 3:
classes.append(n)
walk(n.get("children"))
walk(orgs.get("data") or [])
if not classes:
raise SystemExit("没有班级节点")
org = classes[0]
_, b = call("POST", "/api/v1/tasks", admin, {
"taskName": f"压测任务 {SUFFIX}", "batchNo": "202609", "majorName": "数控加工",
"collectMode": 1, "certType": 1,
"startTime": "2026-01-01", "endTime": "2099-12-31",
})
tid = b["data"]["id"]
TASK_IDS.append(tid)
call("POST", f"/api/v1/tasks/{tid}/materials", admin, {
"materialCode": "IDCARD", "materialName": "身份证", "required": True,
"fileTypes": ["jpg", "jpeg", "png"], "maxSizeKb": 2048, "maxCount": 3,
"minSizeKb": 1, "minLongSide": 100,
})
call("PUT", f"/api/v1/tasks/{tid}/orgs", admin,
{"orgs": [{"orgId": org["id"], "expectedCount": GATE_USERS}]})
# N 个证件号(前 17 位递增,校验位由脚本补)
rows = []
for i in range(GATE_USERS):
# ⚠️ 身份证号是**定长**的:6 位地区 + 8 位出生日期 + 3 位顺序 + 1 位校验。
# 所以任何一段"从序号推出来的值"都必须**取模保证位数** ——
# 我原来写 `"%03d" % (100 + i)`,i >= 900 时变成 4 位,
# 号码成了 19 位,2000 人的夹具只导进去 900 个。
#
# 这里把序号拆成"地区码分块 + 3 位顺序号":每 1000 人换一个地区码。
# 出生日期固定用 20080101(必须是**真实存在的日期** —— 我曾经拼出
# `20081000`,被导入校验以"出生日期要真实存在"拒掉,而那条校验抓的
# 不是格式,是"这个号码不可能属于一个人")。
area = "%06d" % (361029 + i // 1000)
seq = "%03d" % (i % 1000)
idc = R.with_checksum(area + "20080101" + seq)
rows.append((f"压测{i:04d}", idc))
build_roster(rows)
body = R.upload_roster(BASE, admin, tid, TMP_ROSTER,
extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]})
# ⚠️ **判据是 data.created,不是 code**:导入了 0 行也是 code=0
# (体检报告在 data 里)。判错的话压测会"没有样本",
# 而那个现象看起来像压测脚本坏了 —— 与 F-84 是同一个坑
rep = body.get("data") or {}
if body.get("code") != 0 or rep.get("created") != GATE_USERS:
raise SystemExit("夹具名册导入没达标:code=%s created=%s errors=%s"
% (body.get("code"), rep.get("created"),
json.dumps(rep.get("errors"), ensure_ascii=False)[:300]))
ids = sql(f"select id, id_card_no from collect_roster where task_id={tid} "
f"and deleted=0 order by id;")
# ⚠️ 证件号在库里是密文,压测要的是**明文**(门禁接口收明文),所以直接用生成的
roster_rows = []
for idx, line in enumerate([x for x in ids.split("\n") if x.strip()]):
rid = int(line.split("|")[0])
ROSTER_IDS.append(rid)
roster_rows.append({"rosterId": rid, "name": rows[idx][0], "idCardNo": rows[idx][1]})
if len(roster_rows) != GATE_USERS:
raise SystemExit("夹具名册行数不对:期望 %d,实际 %d"
% (GATE_USERS, len(roster_rows)))
call("POST", f"/api/v1/tasks/{tid}/publish", admin, {"force": True})
cfg = R.read_dev_config()
tokens = []
for i, r in enumerate(roster_rows):
uid = 998000 + i
UIDS.append(uid)
tokens.append({"uid": uid, "token": R.mint_mp_token(
cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4),
"name": r["name"], "idCardNo": r["idCardNo"], "rosterId": r["rosterId"]})
return tid, tokens
def build_roster(rows):
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, "中专", "138001399%02d" % (i % 100), "无",
"车工", "数控车工", "四级", "否", "", "", "", "是",
"示范职业学校", "数控加工", "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 make_img(size_kb=400):
"""造一张尽量接近真实照片的图(纯色会被压得很小,失真)。"""
import random
random.seed(7)
# 最小合法 JPEG 头 + 随机填充(不是真 JPEG,但上传接口只看扩展名与大小)
with open(TMP_IMG, "wb") as f:
f.write(b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00")
f.write(bytes(random.getrandbits(8) for _ in range(size_kb * 1024)))
f.write(b"\xff\xd9")
def reset_gate():
"""清门禁限流(每用户 10 次/小时)。压测每个用户只核验 1 次,理论上不触发;
但重复跑会累积,所以开跑前清一次。"""
for uid in UIDS:
os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning '
'DEL xt:mp:gate:user:%d > NUL 2>&1' % uid)
# ══════════════ 场景 ══════════════
def scenario_gate(tid, tokens, conc):
"""门禁核验:N 个学生同时扫码。"""
st = Stat("门禁核验")
reset_gate()
def one_req(t):
def go():
c, b = call("POST", "/api/v1/mp/roster/match", t["token"],
{"taskId": tid, "realName": t["name"], "idCardNo": t["idCardNo"]},
timeout=60)
return c if c == 200 else c, b
ms, code, body = timed(go)
# 业务码在 body 里(HTTP 200 + code != 0),要分开统计
biz = body.get("code") if isinstance(body, dict) else None
return ms, int(biz if biz is not None else code), body
with cf.ThreadPoolExecutor(max_workers=conc) as ex:
for ms, code, body in ex.map(one_req, tokens):
st.add(ms, code)
if code != 0 and len(st.fail_detail) < 3:
st.fail_detail.append(json.dumps(body, ensure_ascii=False))
st.done()
st.report()
return st
def scenario_read(tid, admin, conc, rounds):
"""
读接口:任务列表 / 名册列表 / 任务统计 / 待办。
⚠️ **按端点分开统计**,不要只报一个"混合 p95":
压测的目的就是回答"该优化哪个",而混在一起的数字回答不了 ——
四个接口里可能有一个占掉 90% 的耗时,而混合 p95 会把它的形状抹平。
"""
paths = [
("GET", f"/api/v1/tasks?pageNum=1&pageSize=20", "任务列表"),
("GET", f"/api/v1/tasks/{tid}/roster?pageNum=1&pageSize=50", "名册列表(50/页)"),
("GET", f"/api/v1/tasks/{tid}/roster?pageNum=1&pageSize=200", "名册列表(200/页)"),
("GET", f"/api/v1/tasks/{tid}/statistics", "任务统计"),
("GET", f"/api/v1/audit/todos?pageNum=1&pageSize=20", "审核待办"),
]
stats = {label: Stat(label) for _m, _p, label in paths}
def worker(_):
out = []
for _r in range(rounds):
for m, p, label in paths:
def go():
c, b = call(m, p, admin)
return c, b
ms, code, body = timed(go)
biz = body.get("code") if isinstance(body, dict) else None
out.append((label, ms, int(biz if biz is not None else code), body))
return out
with cf.ThreadPoolExecutor(max_workers=conc) as ex:
for results in ex.map(worker, range(conc)):
for label, ms, code, body in results:
st = stats[label]
st.add(ms, code)
if code != 0 and len(st.fail_detail) < 3:
st.fail_detail.append(json.dumps(body, ensure_ascii=False))
for st in stats.values():
st.done()
for label in [x[2] for x in paths]:
stats[label].report()
return stats
def scenario_upload(tid, tokens, conc):
"""上传:并发上传图片(压缩是 CPU 密集)。"""
st = Stat("材料上传")
make_img()
sub_ids = {}
# 每个用户先建草稿
for t in tokens[:conc]:
c, b = call("POST", "/api/v1/mp/submissions", t["token"], {"taskId": tid})
if c == 200 and isinstance(b, dict) and b.get("code") == 0:
sub_ids[t["uid"]] = b["data"]["id"]
def one_req(t):
sid = sub_ids.get(t["uid"])
if sid is None:
return 0.0, -2, {"msg": "没有草稿"}
def go():
return upload_file("/api/v1/mp/files/upload", t["token"], sid, TMP_IMG)
ms, code, body = timed(go)
biz = body.get("code") if isinstance(body, dict) else None
return ms, int(biz if biz is not None else code), body
jobs = []
for t in tokens[:conc]:
for _ in range(UPLOAD_PER_USER):
jobs.append(t)
with cf.ThreadPoolExecutor(max_workers=conc) as ex:
for ms, code, body in ex.map(one_req, jobs):
st.add(ms, code)
if code != 0 and len(st.fail_detail) < 3:
st.fail_detail.append(json.dumps(body, ensure_ascii=False))
st.done()
st.report()
return st
def upload_file(path, token, submission_id, file_path, timeout=180):
boundary = "----lt" + uuid.uuid4().hex[:12]
name = os.path.basename(file_path)
with open(file_path, "rb") as f:
content = f.read()
def part(field, value):
return (f"--{boundary}\r\nContent-Disposition: form-data; name=\"{field}\"\r\n\r\n"
f"{value}\r\n").encode("utf-8")
body = b""
body += part("submissionId", submission_id)
body += part("materialCode", "IDCARD")
body += (f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; "
f"filename=\"{name}\"\r\nContent-Type: image/jpeg\r\n\r\n").encode("utf-8")
body += content + b"\r\n" + f"--{boundary}--\r\n".encode("utf-8")
req = urllib.request.Request(BASE + path, data=body, method="POST", headers={
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Authorization": f"Bearer {token}",
})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status, json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
return e.code, {"msg": e.read().decode("utf-8", "replace")[:200]}
except Exception as e: # noqa: BLE001
return -1, {"msg": str(e)}
def scenario_export(tid, admin):
"""导出:前置检查(只读)+ 创建(同步打包)。"""
st_pre = Stat("导出前置检查")
ms, code, body = timed(lambda: call("GET", f"/api/v1/exports/precheck?taskId={tid}", admin))
st_pre.add(ms, int((body or {}).get("code", code)))
st_pre.done()
st_pre.report()
if code != 200 or (body or {}).get("code") != 0:
print(" 前置检查没通过,跳过创建:%s" % json.dumps(body, ensure_ascii=False)[:200])
return
pre = (body or {}).get("data") or {}
can = pre.get("canExport")
ms, code, body = timed(lambda: call("POST", "/api/v1/exports", admin, {
"taskId": tid, "exportName": f"压测导出 {SUFFIX}", "onlyApproved": False,
}, timeout=600))
biz = int((body or {}).get("code", code))
msg = (body or {}).get("msg") or ""
if can is False:
# ⚠️ 前置检查不通过时,创建接口**必须也拒绝** —— 这是契约里那条铁律
# (`canExport=false` 时禁用按钮、不弹警告放行)的服务端一半。
# 把它报成"压测失败"是误导:它会让人以为导出坏了。
blocked = biz == 40035
print(" 前置检查:canExport=false(%d 项 blocking)→ 创建接口%s"
% (len(pre.get("blocking") or []),
"**正确拒绝**(40035)✓ 铁律在服务端也生效" if blocked
else "竟然放行了 ✗(msg=%s)" % msg[:80]))
if not blocked:
print(" ⚠️ 这是**缺陷**:界面靠 canExport 禁用按钮,"
"而这里是最后一道服务端防线 —— 它必须自己拦一次")
else:
st_new = Stat("导出创建(同步打包)")
st_new.add(ms, biz)
st_new.done()
st_new.report()
if biz == 0:
eid = (body.get("data") or {}).get("id")
print(" 导出任务 id=%s(异步打包,这里只量「创建+排队」的耗时)" % eid)
# ══════════════ 清理 ══════════════
def cleanup():
for tid in TASK_IDS:
for tbl in ("collect_validation_issue", "collect_audit_record", "collect_notify_record",
"collect_file", "collect_submission", "collect_roster",
"collect_task_org", "collect_material_def"):
sql("delete from %s where task_id=%s;" % (tbl, tid))
sql("delete from collect_task where id=%s;" % tid)
for uid in UIDS:
sql("delete from collect_message where user_id=%d;" % uid)
for p in (TMP_ROSTER, TMP_IMG):
if os.path.exists(p):
os.remove(p)
print(" 已清理:压测任务 / 名册 / 材料 / 消息 / 临时文件")
def main():
# ⚠️ `global` 必须出现在**同一函数里第一次使用该名字之前** ——
# 放晚了会报 "name 'X' is used prior to global declaration"
global GATE_USERS
ap = argparse.ArgumentParser(description="压测(门禁/上传/读/导出)")
ap.add_argument("--only", default="", help="只跑一个场景:gate / read / upload / export")
ap.add_argument("--conc", type=int, default=0, help="覆盖并发数")
ap.add_argument("--clean", action="store_true", help="只清理上次残留")
ap.add_argument("--rows", type=int, default=0,
help="名册人数(默认 %d)。⚠️ 谈响应时间必须带上规模" % GATE_USERS)
ap.add_argument("--with-upload", action="store_true",
help="跑上传压测(⚠️ 会往真实对象存储写文件,默认关闭)")
args = ap.parse_args()
if args.rows > 0:
GATE_USERS = args.rows
if args.clean:
# 残留任务按名字前缀找
for line in sql("select id from collect_task where task_name like '压测任务 %';").split("\n"):
if line.strip().isdigit():
TASK_IDS.append(int(line.strip()))
cleanup()
return 0
print("=" * 100)
print(" 压测(M7-09) 后端:%s" % BASE)
print(" 规模:名册/门禁 %d 人 读 %d 并发 × %d 轮 上传 %d×%d(需 --with-upload)"
% (GATE_USERS, READ_CONC, READ_ROUNDS, UPLOAD_USERS, UPLOAD_PER_USER))
print(" ⚠️ 读接口的结果**必须带规模看**:同一个接口在 60 行与 2000 行上是两件事")
print("=" * 100)
for acct in ("admin",):
os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning '
'DEL xt:login:fail:%s > NUL 2>&1' % acct)
_, adm = R.login(BASE, "admin", "Admin@123", "demo")
if adm.get("code") != 0:
print("管理员登录失败:" + str(adm))
return 2
admin = adm["data"]["token"]
print("\n── 造夹具 ──")
t0 = time.perf_counter()
tid, tokens = build_fixture(admin)
print(" 任务 %d,名册 %d 人,夹具耗时 %.1fs"
% (tid, len(tokens), time.perf_counter() - t0))
stats = {}
conc = args.conc
try:
if args.only in ("", "gate"):
print("\n── ① 门禁核验(%d 人同时扫码,每个都要 AES 解密全表比对)──" % GATE_USERS)
stats["gate"] = scenario_gate(tid, tokens, conc or GATE_USERS)
if args.only in ("", "read"):
print("\n── ② 读接口(任务/名册/统计/待办 混合,%d 并发 × %d 轮)──"
% (READ_CONC, READ_ROUNDS))
stats["read"] = scenario_read(tid, admin, conc or READ_CONC, READ_ROUNDS)
if args.only == "upload" or (args.with_upload and args.only == ""):
print("\n── ③ 材料上传(%d 人同时传,压缩是 CPU 密集)──" % UPLOAD_USERS)
stats["upload"] = scenario_upload(tid, tokens, conc or UPLOAD_USERS)
if args.only == "" and not args.with_upload:
print("\n── ③ 材料上传(**默认跳过**)──")
print(" ⚠️ 当前 dev 的存储是七牛**真实空间**(见 backend/application-local.yml),")
print(" 压测上传会打到外部服务并把测试文件灌进真实桶。要跑就加 --with-upload,")
print(" 且最好先把存储切成本地:XT_STORAGE_PROVIDER=local 重启后端。")
if args.only in ("", "export"):
print("\n── ④ 导出(前置检查 + 创建)──")
scenario_export(tid, admin)
finally:
print("\n── 清理 ──")
cleanup()
print("\n" + "=" * 100)
print(" 结论:看 p95 与**失败分布**。失败码的含义:")
print(" 40071 门禁限流(每用户 10 次/小时)—— 说明压测把同一个用户重复用了")
print(" 42901 接口限流 50000 内部错(去看 backend/_dev-log.txt) 40301 权限")
print("=" * 100)
sys.stdout.flush()
return 0
if __name__ == "__main__":
raise SystemExit(main())