Files
xiaotutongxue/scripts/e2e-m9-thumb.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

267 lines
11 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 -*-
"""验证缩略图真的更小(用完即删)。
## 为什么要打到真实七牛
这条改动的**全部价值**就是"图变小了"。而"参数拼在签名之前"这件事
只有在**私有空间的真实签名**下才暴露:拼错顺序 → 401 →
现象是"图片加载不出来",而单测/本地存储(回退原图)永远测不出来。
所以这里:
① 造一个任务 + 上传一张**较大的真实图片**(用 Pillow 生成 2000×1500 的噪声图,
纯色会被压到很小,测不出差别);
② 分别取 `thumb=false` 与 `thumb=true` 的链接;
③ 用 HTTP GET 下载两者,**比较字节数与像素尺寸** —— 缩略图必须真的更小。
"""
import importlib.util
import io as _io
import json
import os
import subprocess
import sys
import urllib.request
import uuid
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
spec = importlib.util.spec_from_file_location("r", os.path.join(ROOT, "scripts", "e2e-m2-roster.py"))
R = importlib.util.module_from_spec(spec)
spec.loader.exec_module(R)
BASE = "http://localhost:8080"
SUFFIX = uuid.uuid4().hex[:6]
TMP_IMG = os.path.join(ROOT, "scripts", f"_thumb-{SUFFIX}.jpg")
TASK_IDS = []
UID = 997601
PASSED, FAILED = [], []
def ok(n):
PASSED.append(n)
print(" [OK] " + n)
def fail(n, d):
FAILED.append((n, d))
print(" [FAIL] " + n + "\n " + d)
def check(n, cond, d=""):
ok(n) if cond else fail(n, d)
def sql(s):
return R.sql(s)[1].strip()
def make_big_jpeg(path, w=2000, h=1500):
"""造一张**接近真实照片**的大图:随机噪声几乎不可压缩,正适合测体积。"""
import random
from PIL import Image
random.seed(3)
img = Image.new("RGB", (w, h))
px = img.load()
for y in range(0, h, 2):
for x in range(0, w, 2):
c = (random.getrandbits(8), random.getrandbits(8), random.getrandbits(8))
px[x, y] = c
if x + 1 < w:
px[x + 1, y] = c
if y + 1 < h:
px[x, y + 1] = c
if x + 1 < w:
px[x + 1, y + 1] = c
img.save(path, "JPEG", quality=92)
return os.path.getsize(path)
def upload(path, token, *fields):
args = ["curl", "-s", "-X", "POST", BASE + path, "-H", "Authorization: Bearer " + token]
for f in fields:
args += ["-F", f]
r = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", timeout=180)
try:
return json.loads(r.stdout)
except Exception:
return {"code": -1, "msg": (r.stdout or r.stderr or "")[:300]}
print("=" * 78)
print(" 缩略图验证(打到真实七牛:参数必须在签名之前)")
print("=" * 78)
_, adm = R.login(BASE, "admin", "Admin@123", "demo")
at = adm["data"]["token"]
# 存储 provider
_, log = R.sql("select 1")
print("\n[0] 当前存储 provider")
prov = subprocess.run(["docker", "exec", "xt-pg", "psql", "-U", "fill", "-d", "filldb",
"-t", "-c", "select 1"], capture_output=True, text=True)
import glob
logtxt = ""
try:
logtxt = _io.open(os.path.join(ROOT, "backend", "_dev-log.txt"), encoding="utf-8",
errors="replace").read()
except Exception:
pass
is_qiniu = "七牛云" in logtxt
print(" 后端日志里最后一条存储声明是七牛:%s" % is_qiniu)
if not is_qiniu:
print(" ⚠️ 当前不是七牛存储,缩略图会回退成原图 —— 这个验证需要 provider=qiniu")
# 造夹具
print("\n[1] 造夹具(任务 + 名册 + 大图)")
size = make_big_jpeg(TMP_IMG)
print(" 生成的测试图:%d×%d,%.0f KB" % (2000, 1500, size / 1024))
_, orgs = R.call("GET", BASE + "/api/v1/orgs/tree", at)
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 [])
org = classes[0]
_, b = R.call("POST", BASE + "/api/v1/tasks", at, {
"taskName": f"缩略图验证 {SUFFIX}", "batchNo": "202609", "majorName": "数控加工",
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2099-12-31"})
if b.get("code") != 0 or not (b.get("data") or {}).get("id"):
raise SystemExit("夹具:建任务失败 —— " + json.dumps(b, ensure_ascii=False)[:300])
tid = b["data"]["id"]
TASK_IDS.append(tid)
R.call("POST", BASE + "/api/v1/tasks/%d/materials" % tid, at, {
"materialCode": "IDCARD", "materialName": "身份证", "required": True,
"fileTypes": ["jpg", "jpeg", "png"], "maxSizeKb": 4096, "maxCount": 2,
"minSizeKb": 1, "minLongSide": 100})
R.call("PUT", BASE + "/api/v1/tasks/%d/orgs" % tid, at,
{"orgs": [{"orgId": org["id"], "expectedCount": 1}]})
from openpyxl import Workbook
xlsx = os.path.join(ROOT, "scripts", f"_thumb-{SUFFIX}.xlsx")
wb = Workbook()
ws = wb.active
header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位", "报考职业", "报考工种",
"报考等级", "是否补考", "补考理论", "补考实操", "补考综合", "是否在校生",
"在读学校", "所学专业", "入学时间"]
for c, h in enumerate(header, 1):
ws.cell(row=1, column=c, value=h)
idc = R.with_checksum("36102920080101900")
vals = [1, "缩略甲", idc, "中专", "13800138801", "无", "车工", "数控车工", "四级", "否",
"", "", "", "是", "示范职业学校", "数控加工", "2024-09-01"]
for c, v in enumerate(vals, 1):
if v != "":
ws.cell(row=2, column=c, value=v)
wb.save(xlsx)
rep = R.upload_roster(BASE, at, tid, xlsx,
extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]})
rd = rep.get("data") or {}
check("夹具:名册导入 1 人", rep.get("code") == 0 and rd.get("created") == 1,
json.dumps(rep, ensure_ascii=False)[:250])
# ⚠️ **必须发布**:草稿态的任务不能填报,学生会拿到 40008。
# 发布的前置条件是"名册非空 + 有必传材料 + 配了班级",上面三步已经满足。
_, pub = R.call("POST", BASE + "/api/v1/tasks/%d/publish" % tid, at, {"force": True})
if pub.get("code") != 0:
raise SystemExit("夹具:发布任务失败 —— " + json.dumps(pub, ensure_ascii=False)[:300])
check("夹具:任务已发布(收集中)",
sql("select status from collect_task where id=%d;" % tid) == "1",
sql("select status from collect_task where id=%d;" % tid))
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)
cfg = R.read_dev_config()
tok = R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], UID, 1, ["STUDENT"], 4)
_, mb = R.call("POST", BASE + "/api/v1/mp/roster/match", tok,
{"taskId": tid, "realName": "缩略甲", "idCardNo": idc})
check("夹具:门禁核验通过", mb.get("code") == 0, json.dumps(mb, ensure_ascii=False)[:200])
_, cb = R.call("POST", BASE + "/api/v1/mp/submissions", tok, {"taskId": tid})
# ⚠️ 必须查 code:建草稿失败时 data 是 null,不查就会变成一个
# `TypeError: 'NoneType' object is not subscriptable`,而真正的原因一个字都不打
if cb.get("code") != 0 or not (cb.get("data") or {}).get("id"):
raise SystemExit("夹具:建草稿失败 —— " + json.dumps(cb, ensure_ascii=False)[:300])
sid = cb["data"]["id"]
up = upload("/api/v1/mp/files/upload", tok, "submissionId=%d" % sid, "materialCode=IDCARD",
"file=@%s;filename=大图.jpg;type=image/jpeg" % TMP_IMG)
fid = (up.get("data") or {}).get("id") or (up.get("data") or {}).get("fileId")
check("夹具:材料上传成功", up.get("code") == 0 and fid, json.dumps(up, ensure_ascii=False)[:250])
# ── 取两种链接 ──
print("\n[2] 取链接并下载比较")
_, r1 = R.call("GET", BASE + "/api/v1/audit/files/%s/url" % fid, at)
_, r2 = R.call("GET", BASE + "/api/v1/audit/files/%s/url?thumb=true" % fid, at)
d1 = r1.get("data") or {}
d2 = r2.get("data") or {}
print(" 字段:", sorted(d1.keys()))
print(" url 尾部:…%s" % str(d1.get("url"))[-80:])
print(" thumbUrl 尾部:…%s" % str(d2.get("thumbUrl"))[-80:])
# ⚠️ 缩略图在 **thumbUrl** 字段里;`url` 永远是原图链接。
# 我第一版查的是 `url`,于是"看起来像缩略图没生效",其实是查错了字段。
check("⭐ `url` 是原图(**不带**图片处理参数)",
"imageView2" not in str(d2.get("url")), str(d2.get("url"))[:160])
check("⭐ `thumbUrl` 带上了图片处理参数(且拼在**签名之前**)",
"imageView2" in str(d2.get("thumbUrl")), str(d2.get("thumbUrl"))[:200])
check("⭐ `thumbMaxLongSide` 回显了我们请求的规格(400)",
d2.get("thumbMaxLongSide") == 400, str(d2.get("thumbMaxLongSide")))
check("原图请求(不带 thumb)时 thumbUrl 与 url 相同",
d1.get("thumbUrl") == d1.get("url"), "thumbUrl=%s" % str(d1.get("thumbUrl"))[:120])
def fetch(url):
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=60) as r:
return r.status, r.read()
try:
s1, b1 = fetch(d1["url"])
s2, b2 = fetch(d2["thumbUrl"]) # ★ 缩略图在 thumbUrl 字段
print(" 原图:HTTP %s,%.0f KB" % (s1, len(b1) / 1024))
print(" 缩略:HTTP %s,%.0f KB" % (s2, len(b2) / 1024))
check("⭐ 两个链接都能下载(**签名是对的** —— 参数若拼在签名之后会 401)",
s1 == 200 and s2 == 200, "HTTP %s / %s" % (s1, s2))
check("⭐ 缩略图**真的更小**", len(b2) < len(b1),
"原图 %d 字节,缩略 %d 字节" % (len(b1), len(b2)))
from PIL import Image
im1 = Image.open(_io.BytesIO(b1))
im2 = Image.open(_io.BytesIO(b2))
print(" 原图尺寸:%s×%s 缩略尺寸:%s×%s" % (im1.width, im1.height, im2.width, im2.height))
check("⭐ 缩略图长边 ≤ 400(我们请求的规格)",
max(im2.width, im2.height) <= 400,
"缩略图 %d×%d" % (im2.width, im2.height))
check("⭐ 比例没变形(等比缩放)",
abs(im1.width / im1.height - im2.width / im2.height) < 0.02,
"原图比 %.3f,缩略比 %.3f" % (im1.width / im1.height, im2.width / im2.height))
print("\n 体积变化:%.0f KB → %.0f KB(降为 %.1f%%)"
% (len(b1) / 1024, len(b2) / 1024, 100.0 * len(b2) / max(1, len(b1))))
except Exception as e: # noqa: BLE001
fail("下载/解码失败", repr(e))
# ── 清理 ──
print("\n[3] 清理")
for t_ 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"):
R.sql("delete from %s where task_id=%s;" % (tbl, t_))
R.sql("delete from collect_task where id=%s;" % t_)
for p in (TMP_IMG, xlsx):
if os.path.exists(p):
os.remove(p)
R.sql("delete from collect_message where user_id=%d;" % UID)
print(" 已清理(⚠️ 七牛桶里的对象仍在 —— 删库不删桶,见 F-93)")
print("\n" + "=" * 78)
print("通过 %d 失败 %d 共 %d" % (len(PASSED), len(FAILED), len(PASSED) + len(FAILED)))
print("=" * 78)
for n, d in FAILED:
print(" x " + n + "\n " + d)
sys.stdout.flush()
raise SystemExit(1 if FAILED else 0)