Files
xiaotutongxue/scripts/e2e-m3-draft.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

252 lines
12 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 -*-
"""
草稿保存 验收测试(《03-接口设计》§9.3 / §9.4)。
[x] 取草稿是幂等的(反复调用只产生一份)
[x] 保存草稿:只传变化的字段,服务端合并
[x] **锁定字段服务端再拒一次**(前端只读是可以绕过的)
[x] 改动留痕:只有"老师填过且学生改过"的字段进 changed_fields
[x] 学生把预填字段**清空**也要能落库(updateById 会静默跳过 null)
[x] 未过门禁不能建草稿;已提交的不能直接改
[x] 草稿值会覆盖名册值回显,且"清空过的"字段不会被名册值顶回来
跑法:python scripts/e2e-m3-draft.py
"""
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.parse
import urllib.request
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEV_YML = os.path.join(ROOT, "backend", "xt-server", "src", "main", "resources", "application-dev.yml")
PG = os.environ.get("XT_PG_CONTAINER", "xt-pg")
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
CREATED: 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(q: str) -> str:
r = subprocess.run(
["docker", "exec", "-e", "PGPASSWORD=fill_dev_pwd", PG, "psql", "-U", "fill",
"-d", "filldb", "-t", "-A", "-c", q], capture_output=True, text=True, timeout=40)
return (r.stdout or r.stderr).strip()
def read_cfg() -> dict:
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 = {}
for k, p in [("jwt", r"^\s*secret:\s*(\S+)\s*$"), ("issuer", r"^\s*issuer:\s*(\S+)\s*$")]:
m = re.search(p, text, re.M)
if not m:
raise SystemExit(f"找不到 {k}")
out[k] = m.group(1)
return out
def b64u(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def mint(secret: str, issuer: str, uid: int, tenant: int, roles: list[str], utype: int) -> str:
key = base64.b64decode(secret)
now = int(time.time())
signing = (b64u(json.dumps({"alg": "HS512"}, separators=(",", ":")).encode()) + "."
+ b64u(json.dumps({"iss": issuer, "sub": str(uid), "tenantId": tenant,
"orgIds": [], "roles": roles, "userType": utype,
"iat": now, "exp": now + 3600}, separators=(",", ":")).encode()))
sig = hmac.new(key, signing.encode(), hashlib.sha512).digest()
return signing + "." + b64u(sig)
def call(method: str, path: str, token: str | None = None, body: dict | None = None) -> dict:
h = {"Content-Type": "application/json; charset=utf-8"}
if token:
h["Authorization"] = f"Bearer {token}"
data = json.dumps(body, ensure_ascii=False).encode() if body is not None else None
req = urllib.request.Request("http://localhost:8080" + path, data=data, headers=h, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as x:
return json.loads(x.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 fields_of(form: dict) -> dict:
out = {}
for sec in form.get("sections") or []:
for f in sec.get("fields") or []:
out[f["key"]] = f
return out
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--task", type=int, default=17)
ap.add_argument("--tenant", type=int, default=1)
args = ap.parse_args()
task_id = args.task
tenant = args.tenant
print("=" * 72)
print(f"草稿保存 E2E task={task_id}")
print("=" * 72)
cfg = read_cfg()
uid = 991001 # 测试用学生(不建真实账号,草稿表只存 id)
tok = mint(cfg["jwt"], cfg["issuer"], uid, tenant, ["STUDENT"], 4)
base = f"/api/v1/mp"
# ── 0. 前置:必须先过门禁 ──────────────────────────
print("\n[0] 前置门槛")
r = call("POST", f"{base}/submissions", tok, {"taskId": task_id})
check("未过门禁时不能建草稿(40007)", r.get("code") == 40007,
f"code={r.get('code')} msg={r.get('msg')}")
# 造一个已绑定的名册行(模拟门禁已通过)
h = sql(f"SELECT id FROM collect_roster WHERE task_id={task_id} LIMIT 1")
if not h.isdigit():
fail("取名册行", "任务下没有名册,先跑 e2e-m3-h5-auth 或手工造一行")
return 1
sql(f"UPDATE collect_roster SET bind_user_id={uid}, bind_type=1 WHERE id={h}")
r = call("GET", f"{base}/tasks/{task_id}/form", tok)
check("过门禁后能取表单", r.get("code") == 0, f"code={r.get('code')} msg={r.get('msg')}")
form = r.get("data") or {}
f0 = fields_of(form)
check("表单有 21 个内置字段", len(f0) == 21, f"实际 {len(f0)}")
check("报考职业是只读且已由老师填", f0["occupation"].get("locked") is True
and f0["occupation"].get("value") == "车工", f"{f0['occupation']}")
check("学历可改且标注老师已填", f0["education"].get("locked") is False
and f0["education"].get("prefilled") is True, f"{f0['education']}")
# ── 1. 取草稿(幂等)──────────────────────────────
print("\n[1] 取草稿:幂等")
r1 = call("POST", f"{base}/submissions", tok, {"taskId": task_id})
check("首次创建", r1.get("code") == 0 and (r1.get("data") or {}).get("created") is True,
f"{r1}")
sid = (r1.get("data") or {}).get("id")
CREATED.append(sid)
r2 = call("POST", f"{base}/submissions", tok, {"taskId": task_id})
check("再次调用返回同一份(created=false)",
(r2.get("data") or {}).get("id") == sid and (r2.get("data") or {}).get("created") is False,
f"{r2.get('data')}")
cnt = sql(f"SELECT count(*) FROM collect_submission WHERE task_id={task_id} AND submitter_id={uid}")
check("库里只有一份草稿", cnt == "1", f"{cnt}")
# ── 2. 保存草稿 ──────────────────────────────────
print("\n[2] 保存草稿(只传变化的字段)")
r = call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {
"school": "江西机电职业技术学院",
"major": "数控加工",
"enroll_date": "2024-09-01",
"is_student": True,
}})
check("保存成功", r.get("code") == 0, f"code={r.get('code')} msg={r.get('msg')}")
r = call("GET", f"{base}/tasks/{task_id}/form", tok)
fd = fields_of(r.get("data") or {})
check("存过的值回显了", fd["school"].get("value") == "江西机电职业技术学院",
f"{fd['school'].get('value')}")
check("库里也落了列(可统计)",
sql(f"SELECT school FROM collect_submission WHERE id={sid}") == "江西机电职业技术学院",
sql(f"SELECT school FROM collect_submission WHERE id={sid}"))
check("没传的字段不受影响(名册值仍在)", fd["occupation"].get("value") == "车工",
f"{fd['occupation'].get('value')}")
check("草稿单 id 出现在表单里", (r.get("data") or {}).get("submissionId") == sid,
f"{(r.get('data') or {}).get('submissionId')}")
# ── 3. 锁定字段服务端再拒一次 ──────────────────────
print("\n[3] 锁定字段:前端只读是可绕过的,服务端必须再拦")
r = call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {"occupation": "铣工"}})
check("改「报考职业」被拒", r.get("code") == 40001, f"code={r.get('code')} msg={r.get('msg')}")
check("拒绝理由讲清是老师定的、以及怎么办",
"老师" in (r.get("msg") or "") and "班主任" in (r.get("msg") or ""),
f"msg={r.get('msg')}")
check("库里没被改", sql(f"SELECT occupation FROM collect_submission WHERE id={sid}") == "车工",
sql(f"SELECT occupation FROM collect_submission WHERE id={sid}"))
r = call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {"job_level": "三级/高级工"}})
check("改「报考等级」也被拒", r.get("code") == 40001, f"code={r.get('code')}")
r = call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {"occupation": "车工"}})
check("把锁定字段改成原值不算改(不报错)", r.get("code") == 0, f"code={r.get('code')}")
# ── 4. 未知字段要被拒绝 ────────────────────────────
print("\n[4] 未知字段")
r = call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {"not_a_field": "x"}})
check("传不认识的字段被拒(不静默忽略)", r.get("code") == 40001,
f"code={r.get('code')} msg={r.get('msg')}")
# ── 5. 改预填字段 → 留痕 ───────────────────────────
print("\n[5] 改动留痕")
r = call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {"education": "大学专科"}})
check("改预填的「学历」成功", r.get("code") == 0, f"code={r.get('code')}")
cf = sql(f"SELECT changed_fields FROM collect_submission WHERE id={sid}")
check("changed_fields 记录了 education", "education" in cf, f"changed_fields={cf}")
check("没改过的字段不在里面", "occupation" not in cf and "school" not in cf,
f"changed_fields={cf}")
# ── 6. 清空预填字段(updateById 会静默跳过 null)────
print("\n[6] 清空预填字段")
r = call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {"education": ""}})
check("清空成功", r.get("code") == 0, f"code={r.get('code')}")
v = sql(f"SELECT coalesce(education,'<NULL>') FROM collect_submission WHERE id={sid}")
check("库里真的变成 NULL(不是静默没生效)", v == "<NULL>", f"education={v}")
r = call("GET", f"{base}/tasks/{task_id}/form", tok)
fd = fields_of(r.get("data") or {})
check("清空后回显是空,**不会被名册值顶回来**", fd["education"].get("value") in (None, ""),
f"{fd['education'].get('value')}")
check("没动过的字段仍用名册值", fd["work_unit"].get("prefilled") in (True, False)
and fd["occupation"].get("value") == "车工", f"{fd['occupation'].get('value')}")
# ── 7. 清理 ───────────────────────────────────────
print("\n[7] 清理")
sql(f"DELETE FROM collect_submission WHERE id IN ({','.join(str(x) for x in CREATED)})")
sql(f"UPDATE collect_roster SET bind_user_id=NULL, bind_time=NULL, bind_type=NULL WHERE id={h}")
ok(f"已清理 {len(CREATED)} 份草稿并解绑名册")
print("\n" + "=" * 72)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
for n, d in FAILED:
print(f" · {n}\n {d}")
print("=" * 72)
return 1 if FAILED else 0
if __name__ == "__main__":
raise SystemExit(main())