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。
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user