Files
xiaotutongxue/scripts/e2e-m1-qrcode.py
T
lovife 15b819746b feat(mp): M3-01 门禁核验(名单是闸门)
M3 的第一块:学生必须先过名单核验,才能开始填报任何东西。

交付:
· POST /api/v1/mp/roster/match(§5.4)—— 姓名+证件号与名册匹配,命中才放行并绑定
· common/util/IdCardUtil —— 身份证校验位(三态裁决)
· support/ClientIp —— 取真实调用方 IP(限流用,X-Forwarded-For 第一段)
· module/roster/entity/CollectRoster —— 名册实体(M2 会继续用)

四条守住的规则:
1) 失败提示必须统一(40007),不区分"姓名错"还是"号码错"。
   分开提示等于交付了一个身份证号枚举器:用任意姓名+目标号码试,
   若提示"姓名不对"就说明号码在名单里,一个个试就能把全校号码试出来。
   这不是体验取舍,是安全底线。E2E 专门断言两种失败的 msg 字符串完全一致。
2) 绑定并发安全:UPDATE ... WHERE bind_user_id IS NULL + 看影响行数。
   不要改成"先 SELECT 判空再 UPDATE"——中间有窗口,两人会同时通过检查,
   后绑的人静默覆盖先绑的人,而先绑的学生之后再也进不去。
   E2E 用两个线程同时冲同一行,断言"恰好一个成功、一个 40072"(实测 [0, 40072])。
3) 证件号明文只在 match() 里出现一次,失败日志只记 HMAC 与 uid(日志被翻出来
   同样是泄露);响应里永远只有 idCardMask,E2E 断言整个响应 JSON 不含明文。
4) 已绑定自己的人重复核验是幂等的 —— 学生刷新页面不该看到"该信息已被登记"。

IdCardUtil 的价值在三态而不在对错:
   真实数据集实测:名册 28 人校验位全通过(可信基准);某两个学生的文件名证件号
   校验位失败(确定是号码错,可自动改正);另一学生的两个号码都通过(是两个真人,
   串档实锤,必须人工介入)。后两种看起来都是"号码对不上",处理策略却完全相反——
   一个能放心自动修,另一个自动修就是把两个真人的材料合并。区分它们的唯一办法
   就是校验位。所以对外暴露 Verdict{VALID,CHECKSUM_FAILED,MALFORMED} 而非 boolean。

   1900 年下界是业务判断,不是国标要求:网上流传的"示例有效身份证号"里有一批
   出生年份在 1800 年代(如常被引用的 440524188001010014 → 1880 年),它们校验位
   合法但不可能是认定考生。只算校验位会把它们判成 VALID。这个坑写 E2E 时真踩到了。

E2E 怎么拿 token:门禁要求已登录的学生,而登录(/mp/login)需要真实的 wx.login
code,脚本拿不到。所以用 dev 配置里的 JWT 密钥自己签一个 MP token(HS512,
claims 与 JwtService.issue 同构)—— 与 e2e-m0 用 dev RSA 公钥加密登录密码是同一类
做法:用开发配置里的凭据手搓测试夹具,不是绕过认证逻辑。prod 密钥不在仓库里,
这个手法自然失效。顺带:读配置要读两个文件,issuer 在主 application.yml 里,
只读 dev 会漏掉它,而症状是 401,完全指不到"配置没读到"。

回归抓到我一条脆断言:
   e2e-m1-qrcode.py 的"token TTL 是 7000 秒"在连续第二次运行时失败(TTL=4633,
   正好是两次运行的时间差)——它假设 token 是刚取的,实际沿用了缓存。
   已改成"先清缓存强制重取,再断言 TTL∈[6900,7000]",连跑两次都稳定通过。
   教训与前面几次同类:断言要确定性地反映被测行为,而不是碰巧在某种顺序下通过。

已知缺口(写进交接摘要):
· /mp/login 未做:它需要真实 wx.login code,脚本测不了,只能在小程序里测,
  下一轮连同小程序核验页一起做。
· 形近字候选(FR-504c)未实现,needConfirmName 恒为 false。
· 名册导入未做(M2),E2E 用 SQL 直接造名册行。
· 小程序端还没有核验页(落地页的「开始填报」目前是明确的"开发中"提示)。

验收:e2e-m3-gate.py 29/29(含并发绑定与限流);
九条 E2E 合计 418 项断言全通过。
2026-09-29 10:46:21 +08:00

415 lines
19 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
M1-06 填报二维码 验收测试。
覆盖《03-接口设计》§7.12:
[x] GET /tasks/{id}/qrcode?type=MINI → scene + qrcodeUrl(真实调微信生成)
[x] GET /qrcodes/{scene} 匿名取图,返回真实图片二进制
[x] 同一个 scene 的码会被缓存,不重复消耗微信日额度
[x] 匿名图片接口**只读缓存**,不现场调微信(防刷额度)
[x] orgId 必须在本任务的覆盖范围内
[x] type=URL(H5 短链)明确不支持,且不返回假的「短链」
[x] 权限
⚠️ 这个 E2E 会**真实调用微信接口**(生成小程序码)。
它是本仓库唯一一条打外网的测试 —— 需要本机能出网、且凭据有效。
如果失败于 40164(IP 不在白名单),那是环境问题不是代码问题,脚本会明确指出。
为什么值得这样测:
小程序码是学生端的唯一入口。它失败的表现不是报错,而是"码扫不开 / 扫开是空白页",
而那时码已经发到几百人的班级群里了 —— 必须在上线前真的生成一次、真的取回图片。
用法
python scripts/e2e-m1-qrcode.py
依赖:cryptography;且需要能 `docker exec xt-pg` / `xt-redis`
退出码:0 = 全部通过
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import subprocess
import sys
import urllib.error
import urllib.request
import uuid
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem")
PG_CONTAINER = os.environ.get("XT_PG_CONTAINER", "xt-pg")
PG_USER = os.environ.get("PG_USER", "fill")
PG_DB = os.environ.get("PG_DB", "filldb")
PG_PWD = os.environ.get("PG_PASSWORD", "fill_dev_pwd")
REDIS_CONTAINER = os.environ.get("XT_REDIS_CONTAINER", "xt-redis")
REDIS_PWD = os.environ.get("REDIS_PASSWORD", "redis_dev_pwd")
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
CTX: dict[str, str | None] = {"base": None, "token": None}
CREATED_TASK_IDS: list[int] = []
CREATED_USER_IDS: list[int] = []
CREATED_SCENES: list[str] = []
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) -> tuple[bool, str]:
try:
r = subprocess.run(
["docker", "exec", "-e", f"PGPASSWORD={PG_PWD}", PG_CONTAINER,
"psql", "-U", PG_USER, "-d", PG_DB, "-t", "-A", "-c", stmt],
capture_output=True, text=True, timeout=40)
return r.returncode == 0, (r.stdout or r.stderr).strip()
except Exception as e:
return False, str(e)
def redis(args: list[str]) -> tuple[bool, str]:
try:
r = subprocess.run(
["docker", "exec", "-e", f"REDISCLI_AUTH={REDIS_PWD}", REDIS_CONTAINER,
"redis-cli"] + args,
capture_output=True, text=True, timeout=30)
return r.returncode == 0, (r.stdout or r.stderr).strip()
except Exception as e:
return False, str(e)
def cleanup_all() -> None:
if CREATED_TASK_IDS:
ids = ",".join(str(i) for i in CREATED_TASK_IDS)
for stmt in [
f"DELETE FROM collect_task_org WHERE task_id IN ({ids})",
f"DELETE FROM collect_material_def WHERE task_id IN ({ids})",
f"DELETE FROM collect_form_schema WHERE task_id IN ({ids})",
f"DELETE FROM collect_roster WHERE task_id IN ({ids})",
f"DELETE FROM collect_task WHERE id IN ({ids})",
]:
sql(stmt)
for scene in CREATED_SCENES:
redis(["DEL", f"xt:wxmp:code:develop:{scene}"])
redis(["DEL", f"xt:wxmp:code:trial:{scene}"])
redis(["DEL", f"xt:wxmp:code:release:{scene}"])
base, token = CTX.get("base"), CTX.get("token")
if base and token:
for uid in CREATED_USER_IDS:
try:
call("DELETE", f"{base}/api/v1/users/{uid}", token)
except Exception:
pass
def rsa_encrypt(plain: str) -> str:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
with open(PUB_KEY, "rb") as f:
pub = serialization.load_pem_public_key(f.read())
return base64.b64encode(pub.encrypt(plain.encode(), padding.PKCS1v15())).decode()
def call(method: str, url: str, token: str | None = None,
body: dict | None = None, timeout: int = 40) -> tuple[int, dict]:
headers = {"Content-Type": "application/json; charset=utf-8"}
if token:
headers["Authorization"] = f"Bearer {token}"
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
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:
raw = e.read().decode("utf-8")
try:
return e.code, json.loads(raw)
except json.JSONDecodeError:
return e.code, {"raw": raw}
def fetch_full(url: str, token: str | None = None, timeout: int = 40):
"""
取二进制并连响应头一起返回。
⚠️ 成功时第 4 个返回值是**空串**而不是 None —— 这样调用方写
`f"HTTP {st} {err[:120]}"` 在成功路径上也不会炸。
之前返回 None 时,恰恰是"本该通过的断言"把脚本崩掉了(成功时 err 为 None),
属于自己给自己挖坑。
"""
headers = {}
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, headers=headers, method="GET")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status, dict(resp.headers), resp.read(), ""
except urllib.error.HTTPError as e:
return e.code, dict(e.headers), b"", e.read().decode("utf-8", "replace")[:300]
def fetch_image(url: str, token: str | None = None, timeout: int = 40):
"""取二进制(图片),不走 JSON。"""
st, headers, body, err = fetch_full(url, token, timeout)
return st, headers.get("Content-Type", ""), body, err
def login(base: str, username: str, password: str, tenant: str | None = None):
payload = {"username": username, "password": rsa_encrypt(password)}
if tenant:
payload["tenantCode"] = tenant
return call("POST", f"{base}/api/v1/auth/login", body=payload)
def is_image(b: bytes) -> str:
if len(b) > 12 and b[0] == 0xFF and b[1] == 0xD8:
return "JPEG"
if b[:4] == b"\x89PNG":
return "PNG"
if b[:3] == b"GIF":
return "GIF"
return ""
def main() -> int:
ap = argparse.ArgumentParser(description="M1-06 填报二维码 E2E")
ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080"))
ap.add_argument("--tenant", default=os.environ.get("XT_TENANT", "demo"))
ap.add_argument("--password", default="Admin@123")
args = ap.parse_args()
base = args.base.rstrip("/")
print("=" * 72)
print(f"M1-06 填报二维码 E2E base={base} 租户={args.tenant}")
print(" ⚠️ 这条会真实调用微信接口生成小程序码")
print("=" * 72)
suffix = uuid.uuid4().hex[:6]
# ── 0. 准备 ────────────────────────────────────────
print("\n[0] 准备")
status, body = login(base, "admin", args.password, args.tenant)
if body.get("code") != 0:
fail("管理员登录", f"HTTP {status} code={body.get('code')} msg={body.get('msg')}")
return 1
token = body["data"]["token"]
CTX["base"], CTX["token"] = base, token
ok("管理员登录")
status, body = call("POST", f"{base}/api/v1/tasks", token, {
"taskName": f"E2E二维码 {suffix}", "collectMode": 1, "batchNo": "2004",
"majorName": "数控加工", "certType": 1,
"startTime": "2026-09-28", "endTime": "2026-12-31",
})
if body.get("code") != 0:
fail("建任务", f"code={body.get('code')} msg={body.get('msg')}")
return 1
task_id = body["data"]["id"]
task_code = body["data"]["taskCode"]
CREATED_TASK_IDS.append(task_id)
ok(f"建任务(id={task_id} code={task_code})")
# 取一个班级,用于 orgId 用例
status, body = call("GET", f"{base}/api/v1/orgs/tree", token)
classes: list[dict] = []
def walk(nodes):
for n in nodes or []:
if n.get("orgType") == 3:
classes.append(n)
walk(n.get("children"))
walk(body.get("data"))
if not classes:
fail("取班级", "demo 租户里没有班级,无法测 orgId 用例")
return 1
ok(f"取到班级:{classes[0]['orgName']}(id={classes[0]['id']})")
# ── 1. 生成小程序码 ─────────────────────────────────
print("\n[1] 生成小程序码(真实调微信)")
# 先清掉 token 缓存,强制这一次真的去微信取 —— 否则下面"TTL=7000"那条断言
# 会因为"沿用了上次运行留下的 token"而失败(实测踩过:TTL 是 4633,
# 正好等于两次运行的时间差)。断言要能确定地反映"刚取到的 token 有多长有效期"。
okk, keys = redis(["--scan", "--pattern", "xt:wxmp:token:*"])
if okk and keys.strip():
for k in keys.strip().splitlines():
redis(["DEL", k])
print(" 已清 token 缓存,强制重新获取")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/qrcode?type=MINI", token)
if not check("生成成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"):
msg = body.get("msg") or ""
if "40164" in msg:
print(" ↑ 这是环境问题:调用方 IP 不在小程序的 IP 白名单里。")
print(" 请在微信小程序后台「开发管理 → 开发设置 → IP 白名单」加上本机出口 IP。")
return 1
d = body.get("data") or {}
scene = d.get("scene") or ""
CREATED_SCENES.append(scene)
print(f" scene={scene} qrcodeUrl={d.get('qrcodeUrl')}")
check("返回 scene", scene.startswith(f"t1-k{task_id}"), f"scene={scene}")
check("scene 不超过微信 32 字符上限", len(scene) <= 32, f"长度 {len(scene)}")
check("qrcodeUrl 指向本服务的图片接口",
(d.get("qrcodeUrl") or "").endswith("/api/v1/qrcodes/" + scene),
f"{d.get('qrcodeUrl')}")
check("shortUrl 为 null(没有短链服务,不拿长链接冒充)",
d.get("shortUrl") is None, f"{d.get('shortUrl')}")
check("expireTime 为 null(二维码永久有效,截止由小程序端提示)",
d.get("expireTime") is None, f"{d.get('expireTime')}")
# ── 2. 匿名取图 ─────────────────────────────────────
print("\n[2] 匿名取二维码图片")
st, ct, data, err = fetch_image(f"{base}/api/v1/qrcodes/{scene}")
if not check("匿名可访问(<img src> 带不了 token,必须匿名)", st == 200,
f"HTTP {st} {err}"):
return 1
fmt = is_image(data)
check(f"返回的是真实图片({len(data)} 字节,{fmt})", fmt != "",
f"前 16 字节:{data[:16].hex()}")
check("Content-Type 是图片", ct.startswith("image/"), f"Content-Type={ct}")
check("图不小于 1KB(防止拿到一个空图或错误页)", len(data) > 1024, f"{len(data)} 字节")
st2, hdrs, _, _ = fetch_full(f"{base}/api/v1/qrcodes/{scene}")
check("带 Cache-Control(同一个 scene 的图不会变,让浏览器缓存,免得列表页每次拉 90KB)",
"max-age" in (hdrs.get("Cache-Control") or ""),
f"Cache-Control={hdrs.get('Cache-Control')}")
# ── 3. 缓存 ─────────────────────────────────────────
print("\n[3] 缓存:不重复消耗微信额度")
okk, out = redis(["--scan", "--pattern", f"xt:wxmp:code:*:{scene}"])
check("小程序码已进 Redis 缓存", okk and scene in out, f"scan → {out}")
okk, out = redis(["--scan", "--pattern", "xt:wxmp:token:*"])
check("access_token 也缓存了(日调用限额,不能每次都取)", okk and out.strip() != "",
f"scan → {out}")
okk, out = redis(["TTL", f"xt:wxmp:token:{out.strip().splitlines()[0].split(':')[-1]}"]) \
if out.strip() else (False, "")
if okk and out.strip().isdigit():
check("token 的 TTL 是 7000 秒(比官方 7200 少 200,避开边界)",
6900 <= int(out.strip()) <= 7000,
f"TTL={out.strip()}(若远小于 7000,说明这条断言读到的是复用来的 token —— "
f"它必须在本脚本开头被清掉)")
# ── 4. 匿名接口只读缓存 ─────────────────────────────
print("\n[4] 匿名图片接口只读缓存(防刷微信额度)")
fake_scene = "t99999-k99999"
st, headers, data, err = fetch_full(f"{base}/api/v1/qrcodes/{fake_scene}")
# ⚠️ 项目约定:业务错误返回 **HTTP 200**,真正的错误码在响应体的 code 里
# (见 ErrorCode.httpStatus():只有 401/403/429 与 5xxxx 才占用真 HTTP 状态码)。
# 所以这里必须解析 body —— 只看 HTTP 状态码会把"业务失败"当成"成功"。
parsed = None
try:
parsed = json.loads(data.decode("utf-8"))
except Exception:
pass
check("没生成过的 scene 返回失败而不是现生成",
parsed is not None and parsed.get("code") == 40001
and "还没有生成过" in (parsed.get("msg") or ""),
f"HTTP {st} body={data[:180]!r}")
okk, out = redis(["--scan", "--pattern", f"*{fake_scene}*"])
check("确实没有为它生成任何东西(额度没被消耗)", okk and out.strip() == "", f"scan → {out}")
okk, out = redis(["EXISTS", f"xt:wxmp:code:develop:{fake_scene}"])
check("Redis 里也没有为它留下 key", okk and out.strip() == "0", f"EXISTS → {out}")
# ── 5. orgId ────────────────────────────────────────
print("\n[5] orgId:必须在本任务的覆盖范围里")
status, body = call("GET",
f"{base}/api/v1/tasks/{task_id}/qrcode?type=MINI&orgId={classes[0]['id']}",
token)
check("班级不在覆盖范围时被拒", body.get("code") == 40001,
f"code={body.get('code')} msg={body.get('msg')}")
check("拒绝理由指路到「配置班级」",
"配置班级" in (body.get("msg") or ""), f"msg={body.get('msg')}")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
"orgs": [{"orgId": classes[0]["id"]}],
})
if check("把该班加进覆盖范围", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"):
status, body = call("GET",
f"{base}/api/v1/tasks/{task_id}/qrcode?type=MINI&orgId={classes[0]['id']}",
token)
check("加进覆盖范围后可以生成带班级的码", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
d2 = body.get("data") or {}
org_scene = d2.get("scene") or ""
CREATED_SCENES.append(org_scene)
check("scene 带上班级后缀 -o{orgId}", org_scene.endswith(f"-o{classes[0]['id']}"),
f"scene={org_scene}")
st, ct, data, err = fetch_image(f"{base}/api/v1/qrcodes/{org_scene}")
check("带班级的码也能取到图片", st == 200 and is_image(data) != "",
f"HTTP {st} {err[:120]}")
# ── 6. type=URL ─────────────────────────────────────
print("\n[6] H5 短链:明确不支持")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/qrcode?type=URL", token)
check("type=URL 被明确拒绝", body.get("code") == 40001,
f"code={body.get('code')} msg={body.get('msg')}")
check("拒绝理由说清原因(短链服务 + H5 方案未定),不是笼统的「不支持」",
"短链" in (body.get("msg") or "") and "H5" in (body.get("msg") or ""),
f"msg={body.get('msg')}")
# ── 7. 权限 ─────────────────────────────────────────
print("\n[7] 权限")
status, body = call("GET", f"{base}/api/v1/tasks/99999999/qrcode", token)
check("任务不存在时给出明确提示", body.get("code") == 40001,
f"code={body.get('code')} msg={body.get('msg')}")
ht_user = f"htq{suffix}"
status, body = call("POST", f"{base}/api/v1/users", token, {
"username": ht_user, "realName": f"E2E二维码班主任{suffix}",
"phone": "13900139501", "roleCodes": ["HEAD_TEACHER"], "userType": 3,
})
if check("造班主任账号", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"):
uid, pwd = body["data"]["id"], body["data"]["initialPassword"]
CREATED_USER_IDS.append(uid)
status, body = login(base, ht_user, pwd, args.tenant)
ht_token = body["data"]["token"]
# 班主任不该看到"别人的任务"的码;本任务没把他设为初审人,所以列表里没有它,
# 但 qrcode 接口用的是 task:view + 租户内可见 —— 这里验证的是他能生成(他有 task:view)
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/qrcode?type=MINI", ht_token)
check("班主任可生成自己班上任务的码(task:view)", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
# ── 8. 清理 ────────────────────────────────────────
print("\n[8] 清理测试数据")
cleanup_all()
ok(f"已清理 {len(CREATED_TASK_IDS)} 个任务、{len(CREATED_USER_IDS)} 个账号、{len(CREATED_SCENES)} 个二维码缓存")
print("\n" + "=" * 72)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
if FAILED:
print("\n失败项:")
for name, detail in FAILED:
print(f" · {name}\n {detail}")
print("=" * 72)
return 1 if FAILED else 0
if __name__ == "__main__":
try:
code = main()
except BaseException:
print("\n ⚠ 脚本异常退出,正在清理已造出的测试数据…")
cleanup_all()
raise
raise SystemExit(code)