Files
lovife 0bc7ee02dc feat(mp): 小程序端骨架 + 扫码落地页(scene 识别租户)
小程序端从零建起来了,并在微信开发者工具里真机模拟跑通:
扫 scene=t1-k17 → 显示「示范职业学校 / 2026年9月机电专业认定材料收集 / 尚未发布」。

交付:
· frontend-mp/(uni-app,官方 uni-preset-vue#vite-ts 模板),appid 已填
· src/pages/index/index.vue 扫码落地页:读 scene → 调后端 → 显示
  「哪个学校 / 填什么 / 现在能不能填」
· GET /api/v1/mp/scene-context(匿名):登录前的落地页查询
· 主题色与 PC 管理端同一套(腾讯云 TDesign 色板),两端视觉一致

为什么落地页必然是匿名接口:
学生扫开的第一屏要回答"这是哪个学校、要填什么、能不能填",
而这发生在绑定身份之前。它与契约里的 POST /mp/login(code+scene 换 session)
不是一回事 —— 那个要换 openid,属 M3。

⚠️ 一个必踩的坑(撞上并绕开了):
FillTenantLineHandler.getTenantId() 在**匿名请求下直接抛 40101**,
所以不能用 taskMapper.selectById()。正确写法是专用 Mapper + 每条查询
@InterceptorIgnore(tenantLine="true") 并显式带 tenant_id(项目注释称此为
一次代码审查点)。见 module/mp/mapper/MpSceneMapper.java —— 把这几条集中在
一个文件里,正是为了让"关掉框架保护后由人保证隔离"这件事有地方审。

⚠️ scene 可穷举(t1-k1、t1-k2…),所以匿名接口:
① 只返回落地页必须的最少信息,不往里加"顺带能查出来"的东西;
② 按 IP 限流(20 次/分钟,Redis INCR,复用项目的 GATE_RATE_LIMITED 40071)。
E2E 专门验了"前 20 次通过、第 21 次起被限、清掉计数器立刻恢复"。

状态文案分五种而不是合并成"当前不可填报":学生的下一步动作完全不同 ——
「已截止」要找班主任、「已暂停」要等通知、「尚未发布」是根本不该现在扫。

顺带记两个环境坑(写进交接摘要):
· 微信开发者工具的「服务端口」默认关闭,HBuilderX 运行时报
  `IDE service port disabled` 然后退出,**调不起开发者工具**。
  解法:开发者工具 → 设置 → 安全设置 → 服务端口 → 开启;
  或直接「导入项目」dist/dev/mp-weixin 绕过。
· HBuilderX 把这个项目当 CLI 型项目,产物在 dist/dev/mp-weixin,
  **不是** unpackage/。模拟扫码要在「编译模式」里设 进入场景=扫描二维码、
  启动参数 scene=t1-k17。
· <style lang="scss"> 需要装 sass(模板没带),已 pnpm add -D sass。

⚠️ 对上线计划最重要的一条结论(已写进交接摘要):
开发版(develop)只有开发者能扫、且会过期;体验版(trial)只有体验成员能扫;
**只有正式版(release)学生才能扫**,而它需要提交审核,涉及小程序类目与资质。
所以 C1「小程序资质法务确认」不是"以后再说",它直接决定学生能不能开始填报。

已知缺口:
· 填报主流程(/mp/login 门禁核验 表单填写)属 M3;
  现在点「开始填报」给的是明确的"开发中"提示,不是静默无反应。
· frontend-mp 还没套项目既有的 ESLint/Stylelint 规范。

验收:e2e-m1-scene.py 31/31;八条 E2E 合计 389 项断言全通过;
微信开发者工具里人工确认页面渲染与 scene 传递正确。
2026-09-29 10:29:14 +08:00

386 lines
18 KiB
Python
Raw Permalink 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 -*-
"""
扫码落地页(GET /api/v1/mp/scene-context)验收测试。
覆盖 FR-105(统一小程序 + 扫码带参 scene 识别租户):
[x] 正常 scene:返回租户名、任务名、状态、能否填报、给学生的话
[x] 非法 scene / 不存在的租户 / 不存在的任务:各自给出**不同的**提示
[x] 租户级 scene(不带任务):提示去扫任务码
[x] 带班级:班级名只在"该班确实在任务覆盖范围里"时才返回
[x] 跨租户:A 租户的 scene 拼上 B 租户的任务 id → 必须当作"不存在"
[x] 状态文案随任务状态变化(收集中/已暂停/已截止)
[x] **限流**:scene 可穷举,必须能挡住批量扫描
[x] 这个接口**匿名**就能访问(学生登录前就要用)
为什么这些值得逐条断言:
这是学生扫码后的第一屏,而且它是**匿名**接口、scene 又可穷举(t1-k1、t1-k2…)。
一旦哪个分支写错,后果不是"报错",而是"把不该给的信息给出去了",
或者"扫对了码却告诉他这个码不是本系统的" —— 后者会让学生直接放弃。
用法
python scripts/e2e-m1-scene.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.parse
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]] = []
CREATED_TASK_IDS: list[int] = []
CREATED_TENANT_IDS: list[int] = []
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_roster WHERE task_id IN ({ids})",
f"DELETE FROM collect_task WHERE id IN ({ids})",
]:
sql(stmt)
for tid in CREATED_TENANT_IDS:
for stmt in [
f"DELETE FROM sys_user_role WHERE user_id IN (SELECT id FROM sys_user WHERE tenant_id = {tid})",
f"DELETE FROM sys_user WHERE tenant_id = {tid}",
f"DELETE FROM sys_org WHERE tenant_id = {tid}",
f"DELETE FROM sys_role WHERE tenant_id = {tid}",
f"DELETE FROM collect_task WHERE tenant_id = {tid}",
f"DELETE FROM sys_tenant WHERE id = {tid}",
]:
sql(stmt)
redis(["DEL", "xt:mp:scene:rate:127.0.0.1"])
redis(["DEL", "xt:mp:scene:rate:localhost"])
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 = 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 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 scene(base: str, value: str, token: str | None = None) -> dict:
"""调 scene-context,返回 data。"""
url = f"{base}/api/v1/mp/scene-context?scene={urllib.parse.quote(value)}"
_, body = call("GET", url, token)
return body.get("data") or {}
def scene_raw(base: str, value: str) -> tuple[int, dict]:
url = f"{base}/api/v1/mp/scene-context?scene={urllib.parse.quote(value)}"
return call("GET", url)
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("/")
print("=" * 72)
print(f"扫码落地页 E2E base={base}")
print(" (本接口是匿名的 —— 学生登录前就要用;测试也刻意不带 token)")
print("=" * 72)
suffix = uuid.uuid4().hex[:6]
redis(["DEL", "xt:mp:scene:rate:127.0.0.1"])
# ── 0. 准备(用管理员造数据,但 scene 查询都不带 token)────
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
token = body["data"]["token"]
tenant_id = (body["data"].get("tenant") or {}).get("id")
ok(f"管理员登录(租户 id={tenant_id})")
# 本次要用的任务
status, body = call("POST", f"{base}/api/v1/tasks", token, {
"taskName": f"E2E落地页 {suffix}", "collectMode": 1, "batchNo": "2005",
"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", 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 租户里没有班级")
return 1
# ── 1. 正常 scene(匿名)─────────────────────────────
print("\n[1] 正常 scene(匿名,不带 token)")
d = scene(base, f"t{tenant_id}-k{task_id}")
check("返回租户名(学生要能确认扫对了学校)", bool(d.get("tenantName")),
f"tenantName={d.get('tenantName')}")
check("返回任务名", d.get("taskName") == f"E2E落地页 {suffix}", f"taskName={d.get('taskName')}")
check("草稿状态 → 尚未发布", d.get("taskStatusText") == "尚未发布", f"{d.get('taskStatusText')}")
check("草稿不可填报", d.get("canSubmit") is False, f"canSubmit={d.get('canSubmit')}")
check("提示语是「尚未开始」(不是笼统的不可填报)",
"尚未开始" in (d.get("message") or ""), f"message={d.get('message')}")
check("valid 为 true", d.get("valid") is True, f"valid={d.get('valid')}")
# ── 2. 各种非法输入要给不同的提示 ─────────────────────
print("\n[2] 非法输入:提示要各不相同(否则学生不知道该找谁)")
d_garbage = scene(base, "garbage")
d_no_tenant = scene(base, "t99999999-k1")
d_no_task = scene(base, f"t{tenant_id}-k99999999")
check("乱码 → 不是本系统的二维码", "不是本系统生成" in (d_garbage.get("message") or ""),
f"{d_garbage.get('message')}")
check("不存在的租户 → 单位不存在", "单位不存在" in (d_no_tenant.get("message") or ""),
f"{d_no_tenant.get('message')}")
check("不存在的任务 → 任务不存在", "任务不存在" in (d_no_task.get("message") or ""),
f"{d_no_task.get('message')}")
msgs = {d_garbage.get("message"), d_no_tenant.get("message"), d_no_task.get("message")}
check("三种失败的提示互不相同(不是同一句话敷衍)", len(msgs) == 3, f"{msgs}")
# ── 3. 租户级 scene ──────────────────────────────────
print("\n[3] 租户级 scene(M0 开通租户时约定的 t{tenantId})")
d_tenant = scene(base, f"t{tenant_id}")
check("租户级码有效但提示去扫任务码",
d_tenant.get("valid") is True and "填报二维码" in (d_tenant.get("message") or ""),
f"valid={d_tenant.get('valid')} message={d_tenant.get('message')}")
check("租户级码没有任务信息", d_tenant.get("taskId") is None, f"{d_tenant.get('taskId')}")
# ── 4. 带班级 ────────────────────────────────────────
print("\n[4] 带班级的 scene")
d_not_covered = scene(base, f"t{tenant_id}-k{task_id}-o{classes[0]['id']}")
check("班级不在覆盖范围时不返回 orgName(不能凭空显示一个班)",
d_not_covered.get("orgName") is None, f"orgName={d_not_covered.get('orgName')}")
call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token,
{"orgs": [{"orgId": classes[0]["id"]}]})
d_covered = scene(base, f"t{tenant_id}-k{task_id}-o{classes[0]['id']}")
check("班级在覆盖范围时返回班级名",
d_covered.get("orgName") == classes[0]["orgName"],
f"orgName={d_covered.get('orgName')} 期望={classes[0]['orgName']}")
check("带班不影响任务信息", d_covered.get("taskId") == task_id, f"{d_covered.get('taskId')}")
# ── 5. 跨租户 ────────────────────────────────────────
print("\n[5] 跨租户:A 租户的 scene 拼 B 租户的任务 id")
status, plat = login(base, "platform", "Platform@123")
other_task_id = None
if plat.get("code") == 0:
plat_token = plat["data"]["token"]
other_code = f"scn{suffix}"
status, body = call("POST", f"{base}/api/v1/tenants", plat_token, {
"tenantName": f"E2E扫码隔离 {suffix}", "tenantCode": other_code, "tenantType": 1,
"contactName": "测试", "contactPhone": "13900139601", "expireTime": "2027-12-31",
"adminUsername": "admin", "adminRealName": "隔离管理员", "adminPhone": "13900139602",
})
if body.get("code") == 0:
other_tenant_id = body["data"]["id"]
other_pwd = body["data"]["adminInitialPassword"]
CREATED_TENANT_IDS.append(other_tenant_id)
login(base, "admin", other_pwd, other_code)
_, ob = login(base, "admin", other_pwd, other_code)
if ob.get("code") == 0:
ot = ob["data"]["token"]
_, tb = call("POST", f"{base}/api/v1/tasks", ot, {
"taskName": f"别校的任务 {suffix}", "collectMode": 1, "batchNo": "2011",
"majorName": "x", "certType": 1,
"startTime": "2026-09-28", "endTime": "2026-12-31",
})
if tb.get("code") == 0:
other_task_id = tb["data"]["id"]
sql(f"DELETE FROM collect_task WHERE id = {other_task_id}") # 用 SQL 收尾更省事
if other_task_id:
d_cross = scene(base, f"t{tenant_id}-k{other_task_id}")
check("用 A 租户的租户号 + B 租户的任务 id → 任务不存在(不跨租户泄露)",
"任务不存在" in (d_cross.get("message") or ""), f"message={d_cross.get('message')}")
d_cross2 = scene(base, f"t{CREATED_TENANT_IDS[0]}-k{task_id}")
check("反过来(B 的租户号 + A 的任务 id)也不泄露",
"任务不存在" in (d_cross2.get("message") or ""), f"message={d_cross2.get('message')}")
else:
fail("跨租户用例", "第二个租户没造出来,这条隔离断言没执行")
# ── 6. 状态文案随状态变化 ────────────────────────────
print("\n[6] 状态文案:五种状态给五句不同的话")
sql(f"INSERT INTO collect_roster (tenant_id, task_id, real_name, id_card_no, id_card_hash) "
f"VALUES ({tenant_id}, {task_id}, 'E2E学生A', 'seed', 'seed')")
sql(f"INSERT INTO collect_material_def (tenant_id, task_id, material_code, material_name, required) "
f"VALUES ({tenant_id}, {task_id}, 'IDCARD', '身份证', 1)")
call("POST", f"{base}/api/v1/tasks/{task_id}/publish", token, {"force": True})
d_collecting = scene(base, f"t{tenant_id}-k{task_id}")
check("收集中 → 可填报", d_collecting.get("canSubmit") is True,
f"canSubmit={d_collecting.get('canSubmit')} status={d_collecting.get('taskStatusText')}")
check("收集中 → 提示「开始填报」",
"开始填报" in (d_collecting.get("message") or ""), f"{d_collecting.get('message')}")
call("POST", f"{base}/api/v1/tasks/{task_id}/pause", token, {})
d_paused = scene(base, f"t{tenant_id}-k{task_id}")
check("已暂停 → 不可填报", d_paused.get("canSubmit") is False, f"{d_paused.get('canSubmit')}")
check("已暂停 → 提示等恢复通知(不是「已截止」,学生的下一步动作完全不同)",
"暂停" in (d_paused.get("message") or ""), f"{d_paused.get('message')}")
call("POST", f"{base}/api/v1/tasks/{task_id}/resume", token)
call("POST", f"{base}/api/v1/tasks/{task_id}/close", token, {})
d_closed = scene(base, f"t{tenant_id}-k{task_id}")
check("已截止 → 不可填报", d_closed.get("canSubmit") is False, f"{d_closed.get('canSubmit')}")
check("已截止 → 提示联系班主任补交(§7.12:码不做失效,靠这句话说明状态)",
"截止" in (d_closed.get("message") or "") and "班主任" in (d_closed.get("message") or ""),
f"{d_closed.get('message')}")
check("截止后仍能查到任务(码永久有效)", d_closed.get("taskName") == f"E2E落地页 {suffix}",
f"{d_closed.get('taskName')}")
# ── 7. 限流 ──────────────────────────────────────────
print("\n[7] 限流:scene 可穷举,必须挡得住批量扫描")
rate_key = None
okk, out = redis(["--scan", "--pattern", "xt:mp:scene:rate:*"])
if okk and out.strip():
rate_key = out.strip().splitlines()[0]
if rate_key:
redis(["DEL", rate_key])
codes = []
for i in range(25):
st, b = scene_raw(base, f"t{tenant_id}-k{task_id}")
codes.append((st, b.get("code")))
# 前 20 次应成功(code=0),第 21 次起应被限流
passed_n = sum(1 for st, c in codes if c == 0)
limited_n = sum(1 for st, c in codes if c == 40071)
check("前 20 次正常通过", passed_n == 20, f"通过 {passed_n} 次")
check("第 21 次起被限流(40071)", limited_n == 5, f"被限 {limited_n} 次")
check("限流用的是项目的 GATE_RATE_LIMITED(40071),不是自制错误码",
any(c == 40071 for _, c in codes), f"{set(c for _, c in codes)}")
redis(["DEL", rate_key])
_, after = scene_raw(base, f"t{tenant_id}-k{task_id}")
check("清掉计数器后立刻恢复(说明是限流不是封禁)", after.get("code") == 0,
f"code={after.get('code')}")
else:
fail("限流用例", "没找到限流键,说明限流可能没生效")
# ── 8. 清理 ──────────────────────────────────────────
print("\n[8] 清理测试数据")
cleanup_all()
ok(f"已清理 {len(CREATED_TASK_IDS)} 个任务、{len(CREATED_TENANT_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)