Files
xiaotutongxue/scripts/e2e-m1-qrcode.py
T
lovife eb352e3537 feat(qrcode): M1-06 填报二维码(接入真实微信小程序)
学生端的入口终于通了:班主任把码发到班级群,学生扫码进填报页。
这条 E2E 是本仓库唯一打外网的测试 —— 真实调微信生成小程序码并取回图片。

交付:
· GET /tasks/{id}/qrcode?type=MINI[&orgId]  (§7.12)
· GET /qrcodes/{scene}  匿名图片接口(<img src> 带不了 Authorization 头,
  必须匿名。图里只有一串场景值,不含学生信息)
· 微信小程序凭据接入 + access_token 与二维码的 Redis 缓存

微信账号(已实测确认,不是靠猜):
· wxf01d3fa60f87f0b7 是【小程序】:小程序码可生成、小程序订阅消息可用、
  公众号的用户管理接口返回 48001(交叉验证)
· wx5d3ae5c1c21fef24 是【公众号测试号】:做不了小程序码,但能做网页授权
  + JS-SDK —— 正好是「H5 保底」需要的能力。两条路都有号了

三处刻意的设计:
1) access_token 走 Redis 缓存(TTL 7000 秒,比官方 7200 少 200 避开边界)。
   token 获取接口有日调用限额(约 2000 次),每次生成码都取一次,
   几十个班一起用就会撞限额 —— 表现是"早上还能用,下午全班的码都生成不出来"。
2) 同一个 scene 的图缓存 30 天:图是一样的,而生成接口本身也有日限额,
   列表页每渲染一次就可能请求一次。
3) 微信 errcode 翻译成人话:40164(IP 不在白名单)与 40001(appsecret 错)
   是完全不同的排查方向,只给一个数字等于让运维从错的方向查起。

匿名图片接口**只读缓存、绝不现场调微信**:否则任何人都能用构造出来的 scene
刷爆当天额度,而额度用完是全租户的码都生成不出来 —— 等于整个填报入口瘫痪。

开发期 env-version 设成 develop(配置项 WX_MP_ENV_VERSION)。这个小程序若尚未
发布,用 release 生成的码扫开提示「小程序未发布」,而接口不会报错 ——
只会让人以为"码生成得不对"。

本轮踩的 3 个坑,都是"假设错了但不报错":
1) 微信返回的是 JPEG 不是 PNG。探测脚本按 PNG 魔数判断,把一次成功报成了失败
   (接口 200、91KB、看着一切正常)。服务代码里按 Content-Type/是不是图片判断。
2) 业务错误返回 HTTP 200 —— 项目约定只有 401/403/429/5xxxx 才占用真 HTTP 状态码,
   业务码在响应体的 code 里(ErrorCode.httpStatus())。我的 E2E 按 HTTP 状态码判
   业务失败,于是把"业务失败"当成了"成功"。
3) 我写的断言又一次恒为真:check(..., "max-age" in (...) or True, "")。
   这与上一轮刚记进摘要的"断言要能真的失败"是同一类错 —— 说明那条教训我只是
   写下来了,没有真的在写断言时自查。已改成真的读响应头来判。
   另外 fetch_* 辅助函数成功时返回 err=None,而断言在成功路径上写 err[:120],
   把"本该通过的用例"崩掉了;已改成成功时返回空串。

⚠️ 凭据位置与安全:
app-secret 放在 application-dev.yml(会入库),用 ${WX_MP_APP_SECRET:...} 形式,
test/prod 必须用环境变量覆盖。它与 jwt/crypto/rsa 那几把自研密钥性质不同:
那几个泄露只影响本系统,appsecret 泄露等于别人能以这个小程序身份调用所有接口。
生产环境务必单独申请凭据 + 走环境变量。

已知缺口:
· shortUrl 恒为 null:短链需要短链服务,而「H5 保底」路线还没定。
  不返回一个"其实很长"的链接冒充短链 —— 字段叫 shortUrl 却给 80 个字符,
  会让调用方以为可以直接打印。
· 图存在 Redis,没走对象存储(后端还没接 MinIO,容器在跑代码没接)。
· 小程序正式使用还需两步人工操作:服务器域名白名单加 API 域名、确认小程序已发布。
· 前端还没有二维码入口(属 M1-07~M1-10)。

验收:e2e-m1-qrcode.py 32/32(真实调微信);七条 E2E 合计 358 项断言全通过。
2026-09-29 10:08:02 +08:00

405 lines
18 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] 生成小程序码(真实调微信)")
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()}")
# ── 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)