Files
xiaotutongxue/scripts/e2e-m3-h5-auth.py
T
lovife 5de9ccc100 feat(fill): 草稿保存(§9.3 / §9.4)
POST /mp/submissions 取草稿(幂等)+ PUT /mp/submissions/{id} 保存草稿。
学生填了一半能存下来,再打开还在。

四条守住的规则:
1) **填报单必须属于调用者** —— 这个条件写进 SQL 的 WHERE(selectMineById),
   不是"查出来再判断"。后者一旦判断漏写就是越权。
2) **锁定字段服务端再拒一次**:前端已经渲染成只读,但请求可以伪造,
   而「报考工种」被改错的后果是机构直接不受理。两层防护里只有服务端这层是真的。
3) **改动留痕**(changed_fields):老师填的值被学生改过要记下来 ——
   审核时看到的是一个数字,无法判断该找谁(老师填错找老师,学生改错直接驳回)。
4) 已提交的填报单不能直接改,要先撤回。

本轮修掉的 4 个真问题(全是"不报错但结果错"的类型):

1) **changed_fields 是 jsonb,我当 varchar 用了**。存了逗号分隔字符串,
   PostgreSQL 报 `invalid input syntax for type json`,而错误信息里的
   "Token xxx is invalid" 完全指不到是这一列。已改为存 JSON 数组。
   ⚠️ 这已经是第三次被"列的真实类型与我的假设不符"咬到
   (前两次:expected_count NOT NULL、min_long_side NOT NULL)。
   教训:**写列之前查 information_schema 的实际类型,不要只读 DDL 文档。**

2) **changed_fields 把没动过的字段也标成"改过"**。原因是我拿"提交表的列值"
   和"名册值"比,而锁定字段的列**永远不写初值**、姓名/证件号/电话**根本没有列** ——
   全都读成 null,于是全被判成"改过"。留痕一旦充满噪音,真正的改动就没人看了。
   已改为:排除锁定字段(按定义不可能被改)与没有列的字段。

3) **学生自己填的"非预填字段"根本没被读回来**。我的合并只遍历了"名册有的键",
   于是在读学校填了、保存成功、再打开却没了。已改为按**表单字段全集**遍历,
   并明确三种情形:动过的预填字段用草稿值(空也算,那是清空)、
   自己填的用草稿值、其余用名册值。

4) **建草稿时没做初值,导致三种状态无法区分**(从没动过 / 改成了别的 / 清空了)——
   列值都是 null。表现是改动判不出、清空会被名册值顶回来。
   已在建草稿时把名册值写入"可改的预填字段"做初值,状态就自明了。

顺带修正一处**字段目录与 DDL 的真不一致**:
   表单定义把「联系电话」标为可改(PRD 把它列在「预填可改」里),
   但 collect_submission **没有 phone 列** —— 学生改了没地方存。
   一期先设为**只读**,并在代码里写明要真正开放修改需要加一列。
   不用"前端允许改、后端悄悄丢掉"糊过去 —— 那会让学生以为改成功了,
   实际提交的还是老号码。

另一处实现选择修正:锁定字段(报考职业/工种/等级)**要写进提交单**。
   它们恰恰是最重要的统计维度(按工种统计报名人数),不落列就统计不出来 ——
   而架构约束正是"内置字段落列才可统计"。写进去还带来一个好处:
   它是**提交时的快照**,老师之后改了名册,这份单子仍记得"他当时报的是四级"。

顺带修 E2E 的两条过时期望(回归抓到的):
   · 写死了 127.0.0.1,而 H5 应用地址已改成穿透域名 → 改为从配置读期望值;
   · fragment 改成 hash 路由格式后,token 解析没跟上。

验收:e2e-m3-draft 27/27、e2e-m3-gate 29/29、e2e-m3-h5-auth 23/23。
2026-09-29 11:41:40 +08:00

237 lines
11 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 -*-
"""
H5 微信网页授权 验收测试(M3-02)。
覆盖:
[x] /mp/h5/authorize 生成的微信授权地址参数正确(appid/redirect_uri/scope/state)
[x] state 每次不同(防重放),且带回 scene
[x] /mp/h5/stub-login(开发用)→ 302 回 H5,**token 在 fragment 里、不在 query 里**
[x] 打桩登录是幂等的:同一个 openid 第二次登录拿同一个用户
[x] 拿到的 token 真的能用(过得了认证)
[x] 没有 scene 时不猜租户,而是提示扫码
[x] 无效 code 的微信错误被翻译成人话(真调微信)
⚠️ 关于打桩:stub-login 是**开发/测试专用**路径,生产禁用(配置开关 + profile 双保险)。
这里测它,是为了让"H5 登录之后的全部流程"能被自动化 —— 微信授权那一步
必须真人在微信里点,脚本永远做不了。
用法:python scripts/e2e-m3-h5-auth.py
"""
from __future__ import annotations
import argparse
import json
import os
import re
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")
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")
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
CREATED_USER_IDS: list[int] = []
def ok(n: str) -> None:
PASSED.append(n)
print(f" [OK] {n}")
def fail(n: str, d: str) -> None:
FAILED.append((n, d))
print(f" [FAIL] {n}\n {d}")
def check(n: str, cond: bool, d: str = "") -> bool:
if cond:
ok(n)
else:
fail(n, d 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 json_call(url: str, token: str | None = None) -> dict:
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
try:
return json.loads(e.read().decode("utf-8"))
except Exception:
return {"_http": e.code}
def redirect_of(url: str) -> tuple[int, dict]:
"""不跟随重定向,拿 Location 头。"""
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *a, **k):
return None
op = urllib.request.build_opener(NoRedirect)
try:
with op.open(urllib.request.Request(url), timeout=30) as resp:
return resp.status, dict(resp.headers)
except urllib.error.HTTPError as e:
return e.code, dict(e.headers)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080"))
args = ap.parse_args()
base = args.base.rstrip("/")
print("=" * 72)
print(f"H5 微信网页授权 E2E base={base}")
print("=" * 72)
openid = "e2e_openid_" + uuid.uuid4().hex[:10]
scene = "t1-k17"
# ── 1. 授权地址 ─────────────────────────────────────
print("\n[1] 生成微信授权地址")
r1 = json_call(f"{base}/api/v1/mp/h5/authorize?scene={scene}")
check("接口可用", r1.get("code") == 0, f"{r1}")
u1 = (r1.get("data") or {}).get("authorizeUrl") or ""
print(f" {u1[:150]}…")
check("指向微信授权端点", u1.startswith("https://open.weixin.qq.com/connect/oauth2/authorize"),
u1[:80])
check("appid 是公众号的(不是小程序的 wxf01d3f…)",
"appid=wx5d3ae5c1c21fef24" in u1 and "wxf01d3f" not in u1, u1[:120])
check("scope=snsapi_base(静默授权,学生看不到授权弹窗)", "scope=snsapi_base" in u1, u1[:150])
check("带 redirect_uri 且已 URL 编码", "redirect_uri=http%3A%2F%2F" in u1 or "redirect_uri=http://" in u1,
u1[:150])
check("response_type=code", "response_type=code" in u1, u1[:150])
r2 = json_call(f"{base}/api/v1/mp/h5/authorize?scene={scene}")
u2 = (r2.get("data") or {}).get("authorizeUrl") or ""
check("两次的 state 不同(防重放)", ("state=" in u1 and "state=" in u2 and u1 != u2),
"两次返回相同,state 可能是固定的")
# ── 2. 打桩登录(开发用)─────────────────────────────
print("\n[2] 打桩登录(跳过微信授权,供自动化测试)")
code, headers = redirect_of(
f"{base}/api/v1/mp/h5/stub-login?openid={openid}&scene={scene}")
check("返回 302 重定向", code == 302, f"HTTP {code} headers={list(headers.keys())}")
loc = headers.get("Location", "")
print(f" Location: {loc[:120]}…")
# H5 应用地址是可配置的(本地开发用穿透域名),所以不能写死 127.0.0.1 ——
# 从配置里读期望值,改配置不会让这条断言假失败
# H5 应用地址是**可配置的**(本地开发用内网穿透域名),所以从配置里读期望值,
# 别写死 127.0.0.1 —— 改了配置这条断言就会假失败(实测踩过)
yml = open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "backend", "xt-server", "src", "main",
"resources", "application-dev.yml"), encoding="utf-8").read()
mm = re.search(r"^\s*h5-base-url:\s*\$\{[^:}]+:([^}]+)\}", yml, re.M)
expect_base = mm.group(1).strip() if mm else ""
check("回到配置的 H5 应用地址", bool(expect_base) and loc.startswith(expect_base),
f"Location={loc[:80]} 期望前缀={expect_base or '(配置里没读到)'}")
check("**token 在 fragment(#)里,不在 query 里** —— query 会进 Nginx 日志/Referer",
"#" in loc and "token=" in loc.split("#", 1)[1],
f"Location={loc[:140]}")
check("query 部分没有 token", "token=" not in loc.split("#", 1)[0], loc[:140])
# ⚠️ fragment 是 **hash 路由格式**(/pages/index/index?token=X&scene=Y),
# 不是裸 hash。uni-app H5 的 onLoad 只解析路由的 query ——
# 写成裸 hash 时两边都不认,参数会全丢(踩过)。
frag = loc.split("#", 1)[1] if "#" in loc else ""
check("fragment 是 hash 路由格式(uni-app 能解析)",
frag.startswith("/pages/index/index?"), f"fragment={frag[:60]}")
qs = frag.split("?", 1)[1] if "?" in frag else ""
params = dict(p.split("=", 1) for p in qs.split("&") if "=" in p)
token = urllib.parse.unquote(params.get("token", ""))
check("fragment 里带回了 scene(授权回来还要继续用)",
urllib.parse.unquote(params.get("scene", "")) == scene, frag[:100])
check("token 非空且像 JWT", token.count(".") == 2 and len(token) > 60, f"len={len(token)}")
# ── 3. token 真的能用 ───────────────────────────────
print("\n[3] 拿到的 token 真的能过认证")
probe = json_call(f"{base}/api/v1/mp/roster/match", token) # GET 会 405,但认证先过
check("token 有效(不是 40101 未登录)", probe.get("code") != 40101,
f"code={probe.get('code')} msg={probe.get('msg')}")
# ── 4. 幂等 ─────────────────────────────────────────
print("\n[4] 同一个 openid 第二次登录")
okk, uid1 = sql("SELECT id FROM sys_user WHERE open_id = '" + openid + "' AND deleted = 0")
code2, headers2 = redirect_of(
f"{base}/api/v1/mp/h5/stub-login?openid={openid}&scene={scene}")
okk2, uid2 = sql("SELECT id FROM sys_user WHERE open_id = '" + openid + "' AND deleted = 0")
check("第二次登录不重复建号(同一个 user id)",
okk and okk2 and uid1.strip() == uid2.strip() and uid1.strip() != "",
f"{uid1.strip()} vs {uid2.strip()}")
okk3, cnt = sql("SELECT count(*) FROM sys_user WHERE open_id = '" + openid + "' AND deleted = 0")
check("库里这个 openid 只有一行", okk3 and cnt.strip() == "1", f"{cnt}")
if uid1.strip().isdigit():
CREATED_USER_IDS.append(int(uid1.strip()))
okk4, role = sql("SELECT count(*) FROM sys_user_role ur JOIN sys_role r ON r.id = ur.role_id "
f"WHERE ur.user_id = {uid1.strip()} AND r.role_code = 'STUDENT'")
check("已授予学生角色(用户类型由角色推导)", okk4 and role.strip() == "1", f"{role}")
# ── 5. 没有 scene 时不猜租户 ─────────────────────────
print("\n[5] 没带 scene 时")
code3, headers3 = redirect_of(f"{base}/api/v1/mp/h5/stub-login?openid={openid}")
loc3 = headers3.get("Location", "")
# 打桩路径与真实回调的提示语不同(它明确说"需要带合法的 scene"),
# 所以这里只断言"有错误信息且没有 token",不去比对具体文案
check("不猜默认租户,而是给出错误提示", "authError" in loc3 and len(loc3) > 20,
f"Location={loc3[:140]}")
check("提示里能看懂是 scene 的问题(不是一句笼统的失败)",
"scene" in urllib.parse.unquote(loc3).lower(), urllib.parse.unquote(loc3)[:120])
check("这种重定向里没有 token", "token=" not in loc3, loc3[:120])
# ── 6. 无效 code 的微信错误要能看懂 ───────────────────
print("\n[6] 无效 code(真调微信,验证错误翻译)")
code4, headers4 = redirect_of(
f"{base}/api/v1/mp/h5/callback?code=THIS_CODE_IS_FAKE&state=nonexistent")
loc4 = headers4.get("Location", "")
decoded = urllib.parse.unquote(loc4)
print(f" {decoded[:170]}…")
# state 不存在 → scene 为空 → 提示扫码(在换 code 之前就该拦)
check("state 不存在时不往下走(先拦在 scene 缺失)",
"authError" in loc4, f"Location={decoded[:140]}")
# ── 7. 清理 ─────────────────────────────────────────
print("\n[7] 清理测试数据")
for uid in CREATED_USER_IDS:
sql(f"DELETE FROM sys_user_role WHERE user_id = {uid}")
sql(f"DELETE FROM sys_user WHERE id = {uid}")
ok(f"已清理 {len(CREATED_USER_IDS)} 个测试账号")
print("\n" + "=" * 72)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
if FAILED:
print("\n失败项:")
for n, d in FAILED:
print(f" · {n}\n {d}")
print("=" * 72)
return 1 if FAILED else 0
if __name__ == "__main__":
raise SystemExit(main())