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 项断言全通过。
This commit is contained in:
2026-09-29 10:46:21 +08:00
parent 0bc7ee02dc
commit 15b819746b
13 changed files with 1300 additions and 4 deletions
+11 -1
View File
@@ -249,6 +249,14 @@ def main() -> int:
# ── 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 ""
@@ -297,7 +305,9 @@ def main() -> int:
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()}")
6900 <= int(out.strip()) <= 7000,
f"TTL={out.strip()}(若远小于 7000,说明这条断言读到的是复用来的 token —— "
f"它必须在本脚本开头被清掉)")
# ── 4. 匿名接口只读缓存 ─────────────────────────────
print("\n[4] 匿名图片接口只读缓存(防刷微信额度)")
+466
View File
@@ -0,0 +1,466 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
门禁核验(POST /api/v1/mp/roster/match)验收测试 —— M3 的第一块。
覆盖《03-接口设计》§5.4:
[x] 姓名 + 证件号完全匹配 → 放行并绑定
[x] 返回里**只有脱敏证件号**,绝无明文
[x] 幂等:已绑自己的人再核验仍成功(学生刷新页面不该看到吓人的提示)
[x] **失败提示统一**:姓名错与号码错给**完全相同**的一句话(防身份证号枚举)
[x] 40072:同一名册项被另一个账号核验 → 已被登记
[x] **并发**:两个账号同时对同一行核验 → 恰好一个成功
[x] 限流:同账号 10 次/小时 → 40071
[x] 跨租户:拿别租户的 taskId 不泄露
[x] 预填:老师已填的报考职业/工种/等级随核验结果返回
怎么拿 token(本脚本的关键手法):
门禁要求"已登录的学生",而登录(/mp/login)需要真实的 wx.login code —— 脚本拿不到。
所以这里**用 dev 配置里的 JWT 密钥自己签一个 MP token**(HS512,claims 与
JwtService.issue 一致)。这与 e2e-m0 用 dev RSA 公钥加密登录密码是同一类做法:
都是"用开发配置里的凭据手搓一个测试夹具",不是绕过认证逻辑。
⚠️ 它只对 dev 有效;prod 的密钥不在仓库里,这个手法自然失效。
用法
python scripts/e2e-m3-gate.py
依赖:cryptography;且需要能 `docker exec xt-pg` / `xt-redis`
退出码:0 = 全部通过
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import hmac
import json
import os
import re
import subprocess
import sys
import time
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")
DEV_YML = os.path.join(ROOT, "backend", "xt-server", "src", "main", "resources", "application-dev.yml")
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]] = []
CREATED_TASK_IDS: list[int] = []
CREATED_USER_IDS: list[int] = []
# 名册里的两条测试数据(证件号都校验位正确)
ID_A = "361029200912100015"
NAME_A = "曾伊豪"
ID_B = "440524199001010018"
NAME_B = "杨晨"
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 clear_rate() -> None:
"""清掉门禁限流键 —— 否则本脚本自己会被限流(它要发十几次请求)。"""
okk, out = redis(["--scan", "--pattern", "xt:mp:gate:*"])
if okk and out.strip():
for k in out.strip().splitlines():
redis(["DEL", k])
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_roster WHERE task_id IN ({ids})",
f"DELETE FROM collect_task WHERE id IN ({ids})",
]:
sql(stmt)
base = CTX.get("base")
token = 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
clear_rate()
CTX: dict[str, str | None] = {"base": None, "token": None}
# ── 配置读取与 token 手搓 ────────────────────────────
def read_dev_config() -> dict:
"""从 application-dev.yml(+ application.yml 兜底)里读 jwt.secret / hmac-key / issuer。
⚠️ 要读<b>两个</b>文件:`secret` 与 `hmac-key` 在 dev 配置里,
而 `issuer` 在主配置 application.yml 里(因为它不是密钥,各环境相同)。
只读一个文件的话会漏掉 issuer —— 而缺了它签出来的 token 验签直接失败,
报错是 401,完全指不到"配置没读到"。
这个文件是 YAML,但结构很浅,用行匹配比引 yaml 库更省事。找不到就明确报错,
不要静默用默认值。
"""
files = [DEV_YML, os.path.join(os.path.dirname(DEV_YML), "application.yml")]
text = "\n".join(open(f, encoding="utf-8").read() for f in files if os.path.exists(f))
out: dict[str, str] = {}
for key, pattern in [
("jwt_secret", r"^\s*secret:\s*(\S+)\s*$"),
("hmac_key", r"^\s*hmac-key:\s*(\S+)\s*$"),
("issuer", r"^\s*issuer:\s*(\S+)\s*$"),
]:
m = re.search(pattern, text, re.M)
if not m:
raise SystemExit(f" ✗ 在 application*.yml 里找不到 {key}({pattern})")
out[key] = m.group(1)
return out
def b64u(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def mint_mp_token(secret_b64: str, issuer: str, uid: int, tenant_id: int,
roles: list[str], user_type: int) -> str:
"""手搓一个与 JwtService.issue 同构的 MP token(HS512)。"""
key = base64.b64decode(secret_b64)
now = int(time.time())
header = {"alg": "HS512"}
payload = {
"iss": issuer,
"sub": str(uid),
"tenantId": tenant_id,
"orgIds": [],
"roles": roles,
"userType": user_type,
"iat": now,
"exp": now + 3600,
}
signing = (b64u(json.dumps(header, separators=(",", ":")).encode()) + "."
+ b64u(json.dumps(payload, separators=(",", ":")).encode()))
sig = hmac.new(key, signing.encode(), hashlib.sha512).digest()
return signing + "." + b64u(sig)
def hmac_hex(key_b64: str, raw: str) -> str:
"""与 HmacIndex.hash 完全一致:trim + 转大写 → HmacSHA256 → hex。"""
key = base64.b64decode(key_b64)
return hmac.new(key, raw.strip().upper().encode("utf-8"), hashlib.sha256).hexdigest()
# ── HTTP ─────────────────────────────────────────────
def call(method: str, url: str, token: str | None = None,
body: dict | None = None, timeout: int = 30) -> 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 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 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 match(base: str, token: str, name: str, id_card: str, task_id: int) -> dict:
_, body = call("POST", f"{base}/api/v1/mp/roster/match", token,
{"realName": name, "idCardNo": id_card, "taskId": task_id})
return body
def main() -> int:
ap = argparse.ArgumentParser(description="门禁核验 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("/")
CTX["base"] = base
print("=" * 72)
print(f"门禁核验 E2E base={base}")
print(" (token 是用 dev 配置里的 JWT 密钥手搓的,见脚本头部说明)")
print("=" * 72)
suffix = uuid.uuid4().hex[:6]
cfg = read_dev_config()
# ── 0. 准备 ────────────────────────────────────────
print("\n[0] 准备")
status, body = login(base, "admin", args.password, args.tenant)
if body.get("code") != 0:
fail("管理员登录", f"code={body.get('code')} msg={body.get('msg')}")
return 1
admin_token = body["data"]["token"]
CTX["token"] = admin_token
tenant_id = (body["data"].get("tenant") or {}).get("id")
admin_uid = (body["data"].get("user") or {}).get("id") or 2
ok(f"管理员登录(租户 {tenant_id},uid={admin_uid})")
# 手搓的学生 token
student_uid = 990001
stu_token = mint_mp_token(cfg["jwt_secret"], cfg["issuer"], student_uid, tenant_id,
["STUDENT"], 4)
other_uid = 990002
other_token = mint_mp_token(cfg["jwt_secret"], cfg["issuer"], other_uid, tenant_id,
["STUDENT"], 4)
admin_mp = mint_mp_token(cfg["jwt_secret"], cfg["issuer"], admin_uid, tenant_id,
["SCHOOL_ADMIN"], 2)
_, probe = call("POST", f"{base}/api/v1/mp/roster/match", stu_token,
{"realName": "探针", "idCardNo": ID_A, "taskId": 999999})
check("手搓的 token 能通过认证(不是 401)", probe.get("code") != 40101,
f"code={probe.get('code')} msg={probe.get('msg')}")
# 建任务
status, body = call("POST", f"{base}/api/v1/tasks", admin_token, {
"taskName": f"E2E门禁 {suffix}", "collectMode": 1, "batchNo": "2006",
"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"]
CREATED_TASK_IDS.append(task_id)
ok(f"建任务(id={task_id})")
# 取一个班级
status, body = call("GET", f"{base}/api/v1/orgs/tree", admin_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"))
org_id = classes[0]["id"] if classes else None
org_name = classes[0]["orgName"] if classes else None
# 造名册(直接用 SQL:名册导入属 M2,这里只需要两行能匹配的数据)
for name, idc, occ in [(NAME_A, ID_A, "车工"), (NAME_B, ID_B, "铣工")]:
okk, out = sql(
f"INSERT INTO collect_roster (tenant_id, task_id, org_id, real_name, "
f"id_card_no, id_card_hash, id_card_mask, occupation, worktype, job_level, "
f"education, is_student, phone) VALUES ("
f"{tenant_id}, {task_id}, {org_id or 'NULL'}, '{name}', "
f"'encrypted-placeholder', '{hmac_hex(cfg['hmac_key'], idc)}', "
f"'{idc[:6]}******{idc[-4:]}', '{occ}', '数控{occ}', '四级', "
f"'中等专科', 1, '138****8888')")
if not okk:
fail("造名册", out)
return 1
ok(f"造了 2 行名册({NAME_A} / {NAME_B})")
clear_rate()
# ── 1. 正常核验 ─────────────────────────────────────
print("\n[1] 姓名 + 证件号完全匹配")
r = match(base, stu_token, NAME_A, ID_A, task_id)
check("核验通过", r.get("code") == 0, f"code={r.get('code')} msg={r.get('msg')}")
d = r.get("data") or {}
check("matched=true", d.get("matched") is True, f"{d.get('matched')}")
check("返回 rosterId", isinstance(d.get("rosterId"), int), f"{d.get('rosterId')}")
check("返回姓名", d.get("realName") == NAME_A, f"{d.get('realName')}")
check("**证件号只回脱敏值**", d.get("idCardMask") and "******" in d["idCardMask"],
f"idCardMask={d.get('idCardMask')}")
check("返回里**没有任何字段含完整证件号**(防泄露)",
ID_A not in json.dumps(d, ensure_ascii=False),
f"响应里出现了明文证件号!{json.dumps(d, ensure_ascii=False)[:200]}")
check("返回班级名", d.get("orgName") == org_name, f"{d.get('orgName')} 期望={org_name}")
pf = d.get("prefill") or {}
check("预填了报考职业(老师已填,学生只核对)", pf.get("occupation") == "车工",
f"prefill={pf}")
check("预填了报考工种与等级(锁定项)",
pf.get("worktype") == "数控车工" and pf.get("jobLevel") == "四级", f"{pf}")
check("needConfirmName 为 false(形近字确认属 FR-504c,本轮未实现)",
d.get("needConfirmName") is False, f"{d.get('needConfirmName')}")
# ── 2. 幂等 ─────────────────────────────────────────
print("\n[2] 幂等:已绑自己的人再核验")
r2 = match(base, stu_token, NAME_A, ID_A, task_id)
check("重复核验仍成功(学生刷新页面不该看到「已被登记」)", r2.get("code") == 0,
f"code={r2.get('code')} msg={r2.get('msg')}")
# ── 3. 统一失败提示(防枚举的核心)────────────────────
print("\n[3] 失败提示必须统一 —— 这是防身份证号枚举的底线")
r_wrong_name = match(base, stu_token, "张三", ID_A, task_id)
r_wrong_id = match(base, stu_token, NAME_A, "11010519491231002X", task_id)
r_no_such = match(base, stu_token, NAME_A, "361029200912100015", task_id + 777)
check("姓名不对 → 40007", r_wrong_name.get("code") == 40007,
f"code={r_wrong_name.get('code')}")
check("号码不在名册 → 40007", r_wrong_id.get("code") == 40007,
f"code={r_wrong_id.get('code')}")
check("**两者的提示文字完全一致**(分开就会变成枚举器:先用假姓名+目标号码试,"
"若提示「姓名不对」就说明号码在名单里)",
r_wrong_name.get("msg") == r_wrong_id.get("msg"),
f"姓名错:{r_wrong_name.get('msg')} / 号码错:{r_wrong_id.get('msg')}")
check("不存在的任务给的是另一类提示(那不属于身份信息不匹配)",
r_no_such.get("code") == 40001, f"code={r_no_such.get('code')}")
# ── 4. 已被他人绑定 ─────────────────────────────────
print("\n[4] 同一名册项被别人核验")
clear_rate()
r_other = match(base, other_token, NAME_A, ID_A, task_id)
check("另一账号核验同一行 → 40072 已被登记", r_other.get("code") == 40072,
f"code={r_other.get('code')} msg={r_other.get('msg')}")
# ── 5. 并发 ─────────────────────────────────────────
print("\n[5] 并发:两个账号同时对同一行核验")
clear_rate()
# 用 B 那一行,让两个不同的 uid 同时冲
import threading
results: list[int] = []
lock = threading.Lock()
barrier = threading.Barrier(2)
def hit(tok):
barrier.wait()
_, b = call("POST", f"{base}/api/v1/mp/roster/match", tok,
{"realName": NAME_B, "idCardNo": ID_B, "taskId": task_id})
with lock:
results.append(b.get("code"))
t1 = threading.Thread(target=hit, args=(stu_token,))
t2 = threading.Thread(target=hit, args=(other_token,))
t1.start()
t2.start()
t1.join()
t2.join()
success_n = sum(1 for c in results if c == 0)
bound_n = sum(1 for c in results if c == 40072)
check(f"恰好一个成功、一个 40072(实际 {results})", success_n == 1 and bound_n == 1,
f"两个都成功会静默覆盖先绑的人,而先绑的学生之后再也进不去。结果={results}")
okk, out = sql(f"SELECT count(*) FROM collect_roster WHERE task_id = {task_id} "
f"AND id_card_hash = '{hmac_hex(cfg['hmac_key'], ID_B)}' "
f"AND bind_user_id IS NOT NULL")
check("库里那一行确实只被绑了一次", okk and out.strip() == "1", f"{out}")
# ── 6. 格式非法的号码 ───────────────────────────────
print("\n[6] 格式非法的号码")
clear_rate()
r_malformed = match(base, stu_token, NAME_A, "abcd", task_id)
check("乱码号码 → 40007", r_malformed.get("code") == 40007, f"code={r_malformed.get('code')}")
check("提示与「号码不在名册」**同样一致**(校验位的快速路径不泄露信息)",
r_malformed.get("msg") == r_wrong_id.get("msg"),
f"{r_malformed.get('msg')} / {r_wrong_id.get('msg')}")
# ── 7. 限流 ─────────────────────────────────────────
print("\n[7] 限流:同账号 10 次/小时")
clear_rate()
codes = []
for _ in range(11):
_, b = call("POST", f"{base}/api/v1/mp/roster/match", stu_token,
{"realName": "张三", "idCardNo": ID_A, "taskId": task_id})
codes.append(b.get("code"))
check("前 10 次是业务结果(40007)", codes[:10].count(40007) == 10, f"{codes}")
check("第 11 次被限流(40071)", codes[10] == 40071, f"{codes}")
clear_rate()
_, after = call("POST", f"{base}/api/v1/mp/roster/match", stu_token,
{"realName": NAME_A, "idCardNo": ID_A, "taskId": task_id})
check("清掉计数器后立刻恢复", after.get("code") == 0, f"code={after.get('code')}")
# ── 8. 管理员也能核验(代学生核验是一期就要支持的能力)──
print("\n[8] 管理员(代核验)")
r_admin = match(base, admin_mp, "张三", ID_A, task_id)
check("管理员也能调门禁(allowProxyUpload 默认开,代学生核验是支持的)",
r_admin.get("code") in (0, 40007, 40072),
f"code={r_admin.get('code')} msg={r_admin.get('msg')}")
# ── 9. 清理 ─────────────────────────────────────────
print("\n[9] 清理测试数据")
cleanup_all()
ok(f"已清理 {len(CREATED_TASK_IDS)} 个任务、{len(CREATED_USER_IDS)} 个账号")
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)