30c3714d35
新增: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。
267 lines
11 KiB
Python
267 lines
11 KiB
Python
# -*- 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)
|