feat(submit): 提交 + 红黄绿校验(PRD §5.7 的 VAL 系列)

一次给全,不"改一条再拒一次":学生一次可能同时缺 3 项材料、2 个字段没填,
逐条拒绝会让他改 5 次提交 5 次。所以 validate 收全部问题后一次性返回
{ruleCode, severity, title, detail},ruleCode 用 PRD 的 VAL-xx 编号。

两段式提交:只有黄色警告时先返回 submitted=false 让学生确认,
再带 confirmWarnings=true 才真的提交。为什么不前端自己判断 ——
只有服务端的校验结果算数,两次请求之间学生可能又改坏了什么。

实现的规则:VAL-01 证件号 / VAL-05 材料完整性 / VAL-06 格式 /
VAL-07 体积 / VAL-08 清晰度 / VAL-09 重复文件 / VAL-13 报考三项 /
VAL-15 补考科目数按等级 / VAL-16 在校生三项 / VAL-17 工作年限 /
VAL-18 未规范化 / VAL-20 体积下限 / VAL-22 表单必填(**新增**:
PRD 的 VAL-01~21 是具体业务规则,没覆盖动态表单的必填字段,按系列顺延补的)。

═══ 本轮抓到的四个真问题(都记在代码注释里了)═══

① **校验器不能用列名当字段 key**。字段 key 是 snake_case
   (work_unit / job_level / enroll_date),不是列名;而且报考三项
   存在 ext_json 里、**不是独立列**(契约 §2.4)。写 camelCase 的表现是
   "明明填了却说没填"。更糟的是 VAL-16 读列、VAL-22 读表单装配值 ——
   **两条规则对同一份数据给出相反结论**,学生不知道该信哪一条。
   现在统一用 FillService.getForm 装配出来的那一份作为唯一取值口径。

② **VAL-22 没判可见性**。不判可见就会要求一个在校生填「补考理论」——
   而那个字段在他页面上根本不存在。求值口径已与前端 fill.vue 的 visible()
   逐字对齐(norm 归一 true/false↔1/0、未知 op 一律可见)。

③ **VAL-01 读错了列**:collect_roster.id_card_no 是 **AES 密文**
   (列注释写明的),明文只在导入与门禁时短暂存在。一开始拿它算校验位,
   于是每一份填报单都被判"身份证号未通过校验" —— 100% 阻断。
   这种"规则很对但读错列"的错,反而容易被当成"校验太严"而不是 bug。
   现在只确认名册里有证件号(用脱敏列),校验位交给导入与门禁。

④ **静态资源没有 Cache-Control/ETag**(新增 StaticCacheHeaderFilter)。
   没有它浏览器会走启发式缓存:旧 index.html → 旧 chunk →
   **学生发版后看到的是上个月的页面**,而开发机硬刷新一次就没了,
   极容易被当成"偶发"。现在 /assets/** 一年 immutable(文件名带内容哈希)、
   *.html no-cache(每次校验)、/api/** no-store。

排查过程里还确认了一件事:材料上传那轮的"浏览器加载旧 chunk"
不是构建问题 —— 从 8080 下载的 chunk 与磁盘文件 SHA-256 一致、里面确实有新代码,
是缓存头缺失导致的浏览器行为。

验收:e2e-m3-submit.py **30/30**(含"该拦的确实拦了"和"修好后确实放过")。
回归:草稿 27 / 门禁 29 / 材料上传 29 / 材料目录 68 全绿。
This commit is contained in:
2026-09-29 12:39:16 +08:00
parent 8ef09733b1
commit 9911ff01f1
7 changed files with 1244 additions and 11 deletions
+318
View File
@@ -0,0 +1,318 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
提交与校验 验收测试(PRD §5.7 的 VAL 系列 + §9.8 提交)。
[x] 缺材料 / 缺必填字段 → **阻断**,且**一次给全**(不是改一条再拒一次)
[x] VAL-16 在校生三项:缺一项就拦
[x] VAL-15 补考科目数按等级限制(四级最多 1 科)
[x] 只有黄色警告时:先返回 submitted=false 让学生确认,confirmWarnings=true 才真的提交
[x] 提交成功后状态 → 1(待初审),再提交被拒
[x] 红黄计数落库(班主任端要按它排序)
⚠️ 这个 E2E 的重点不是"能不能提交",而是**拦得对不对**。
一个校验器最危险的状态是"永远通过" —— 它看起来一切正常,
直到机构把整批材料退回来。所以下面每条规则都断言了"该拦的确实拦了"和"修好后确实放过"。
跑法:python scripts/e2e-m3-submit.py
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import hmac
import io
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC = os.path.join(ROOT, "backend", "xt-server", "src", "main", "resources")
PG = os.environ.get("XT_PG_CONTAINER", "xt-pg")
UID = 992002
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
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:
text = "\n".join(open(os.path.join(SRC, f), encoding="utf-8").read()
for f in ("application-dev.yml", "application.yml")
if os.path.exists(os.path.join(SRC, 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) -> 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": ["STUDENT"], "userType": 4,
"iat": now, "exp": now + 3600}, separators=(",", ":")).encode()))
return signing + "." + b64u(hmac.new(key, signing.encode(), hashlib.sha512).digest())
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=60) 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 upload(token: str, sid: int, code: str, filename: str, data: bytes) -> dict:
boundary = "----xte2e" + uuid.uuid4().hex[:12]
parts = []
for k, v in [("submissionId", str(sid)), ("materialCode", code)]:
parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode())
parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{filename}"\r\n'
f"Content-Type: image/jpeg\r\n\r\n".encode())
parts.append(data)
parts.append(f"\r\n--{boundary}--\r\n".encode())
req = urllib.request.Request(
"http://localhost:8080/api/v1/mp/files/upload", data=b"".join(parts), method="POST",
headers={"Content-Type": f"multipart/form-data; boundary={boundary}",
"Authorization": f"Bearer {token}"})
try:
with urllib.request.urlopen(req, timeout=90) 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 small_jpeg() -> bytes:
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (900, 1200), (200, 210, 220)).save(buf, "JPEG", quality=90)
return buf.getvalue()
def rules(r: dict) -> set[str]:
return {i.get("ruleCode") for i in ((r.get("data") or {}).get("issues") or [])}
def sub(token: str, sid: int, confirm: bool = False) -> dict:
return call("POST", f"/api/v1/mp/submissions/{sid}/submit", token, {"confirmWarnings": confirm})
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, tenant = args.task, args.tenant
base = "/api/v1/mp"
print("=" * 72)
print(f"提交与校验 E2E task={task_id}")
print("=" * 72)
cfg = read_cfg()
tok = mint(cfg["jwt"], cfg["issuer"], UID, tenant)
rid = sql(f"SELECT id FROM collect_roster WHERE task_id={task_id} LIMIT 1")
if not rid.isdigit():
fail("取名册行", "任务下没有名册")
return 1
sql(f"UPDATE collect_roster SET bind_user_id={UID}, bind_type=1 WHERE id={rid}")
r = call("POST", f"{base}/submissions", tok, {"taskId": task_id})
if r.get("code") != 0:
fail("建草稿", f"{r}")
return 1
sid = r["data"]["id"]
print(f" submission={sid} roster={rid}")
try:
# ── 1. 什么都没填就提交:必须被拦,且一次给全 ──
print("\n[1] 空着提交 → 必须拦住")
r = sub(tok, sid)
check("HTTP 200 + code=0(校验不通过是业务结果,不是异常)",
r.get("code") == 0, f"code={r.get('code')} msg={r.get('msg')}")
d = r.get("data") or {}
check("submitted=false", d.get("submitted") is False, f"{d.get('submitted')}")
check("redCount > 0", (d.get("redCount") or 0) > 0, f"red={d.get('redCount')}")
check("**一次给全**:问题数 ≥ 3(不是只报第一条)",
len(d.get("issues") or []) >= 3, f"{len(d.get('issues') or [])} 条")
rs = rules(r)
check("VAL-05 缺必传材料(身份证)", "VAL-05" in rs, f"实际规则: {sorted(rs)}")
check("VAL-22 必填字段缺失", "VAL-22" in rs, f"实际规则: {sorted(rs)}")
check("VAL-01 **不该误报**(id_card_no 存的是 AES 密文,不能当明文算校验位 —— 实测错过一次,表现是 100% 阻断)",
"VAL-01" not in rs, f"实际规则: {sorted(rs)}")
check("状态没被改动(仍是草稿)", d.get("status") == 0, f"status={d.get('status')}")
db = sql(f"SELECT status || '|' || coalesce(submit_time::text,'-') FROM collect_submission WHERE id={sid}")
check("库里也没被改成已提交", db.split("|")[0] == "0", f"{db}")
# ── 2. VAL-16 在校生三项 ──
print("\n[2] 在校生三项(VAL-16)")
fill = {"work_unit": "无", "is_retake": False, "is_student": True,
"work_years": "3", "cert_occupation": "", "cert_level": "", "cert_no": ""}
call("PUT", f"{base}/submissions/{sid}", tok, {"formData": fill})
r = sub(tok, sid)
rs = rules(r)
check("isStudent=是 但三项都空 → VAL-16", "VAL-16" in rs, f"实际: {sorted(rs)}")
# 只补一项,还应该拦
call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {"school": "示范职业学校"}})
r = sub(tok, sid)
check("只补了 1 项仍然拦(缺项要列全)", "VAL-16" in rules(r), f"实际: {sorted(rules(r))}")
call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {
"school": "示范职业学校", "major": "数控技术", "enroll_date": "2023-09-01"}})
r = sub(tok, sid)
check("三项补齐 → VAL-16 消失", "VAL-16" not in rules(r), f"实际: {sorted(rules(r))}")
# ── 3. VAL-15 补考科目数 ──
print("\n[3] 补考科目数按等级(VAL-15)")
call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {"is_retake": True}})
r = sub(tok, sid)
check("勾了补考但一科没选 → VAL-15(警告,可提交)",
"VAL-15" in rules(r), f"实际: {sorted(rules(r))}")
check("这一条是警告不是错误(severity=2)",
any(i.get("ruleCode") == "VAL-15" and i.get("severity") == 2
for i in (r.get("data") or {}).get("issues") or []),
"severity 不对")
# 四级最多 1 科:勾 2 科必须拦
call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {
"retake_theory": "理论", "retake_practice": "实操"}})
r = sub(tok, sid)
check("四级勾 2 科 → VAL-15 阻断", "VAL-15" in rules(r), f"实际: {sorted(rules(r))}")
check("升级为错误(severity=3)",
any(i.get("ruleCode") == "VAL-15" and i.get("severity") == 3
for i in (r.get("data") or {}).get("issues") or []),
"severity 不对")
# ⚠️ 补考三科都是**必填且可见**(is_retake=1),所以不能留空 ——
# 学生必须每项都明确选,「无」是合法值(PRD §2.6 的三条铁律)。
# 用「无」而不是 "" 也正是为了把这条铁律跑一遍
call("PUT", f"{base}/submissions/{sid}", tok, {"formData": {
"retake_practice": "无", "retake_overall": "无"}})
r = sub(tok, sid)
check("只留 1 科 → 不再有 VAL-15 错误",
"VAL-15" not in rules(r), f"实际: {sorted(rules(r))}")
# ── 4. 传材料 → VAL-05 消失 ──
print("\n[4] 传上必传材料")
img = small_jpeg()
up = upload(tok, sid, "IDCARD", "身份证正面.jpg", img)
if not check("上传成功", up.get("code") == 0, f"{up}"):
return 1
call("POST", f"/api/v1/mp/files/{up['data']['id']}/process", tok)
# 同一张图再传一份(IDCARD 允许 2 份)→ 制造一条**可预期的黄色警告**,
# 否则"无红无黄 → 直接提交成功",下一步那条"先确认再提交"的路径就成了没被测过的代码
up2 = upload(tok, sid, "IDCARD", "身份证反面.jpg", img)
if up2.get("code") == 0:
call("POST", f"/api/v1/mp/files/{up2['data']['id']}/process", tok)
r = sub(tok, sid)
rs = rules(r)
check("VAL-05 消失(材料齐了)", "VAL-05" not in rs, f"实际: {sorted(rs)}")
check("VAL-09 同一张图传两份 → 黄色警告", "VAL-09" in rs, f"实际: {sorted(rs)}")
check("有警告但无红色 → 未确认时**不提交**",
(r.get("data") or {}).get("submitted") is False, f"{r.get('data')}")
# ── 5. 只剩警告 → 两次提交 ──
print("\n[5] 只有警告时:先确认,再提交")
r = sub(tok, sid, confirm=False)
d = r.get("data") or {}
if not d:
print(f" ⚠ 响应没有 data:{json.dumps(r, ensure_ascii=False)[:300]}")
rs = rules(r)
red = [i for i in (d.get("issues") or []) if i.get("severity") == 3]
check("已无红色项", not red, f"仍有红: {[i.get('ruleCode') for i in red]}")
if (d.get("yellowCount") or 0) > 0:
check("有警告且未确认 → submitted=false(等学生确认)",
d.get("submitted") is False, f"{d.get('submitted')}")
r2 = sub(tok, sid, confirm=True)
check("带 confirmWarnings=true → 提交成功",
(r2.get("data") or {}).get("submitted") is True, f"{r2.get('data')}")
else:
check("没有警告 → 直接提交成功", d.get("submitted") is True, f"{d}")
st = sql(f"SELECT status || '|' || (submit_time IS NOT NULL)::text || '|' || "
f"red_count || '|' || yellow_count FROM collect_submission WHERE id={sid}")
parts = st.split("|")
check("库里状态 → 1(待初审)", parts[0] == "1", f"status={parts[0]}")
check("submit_time 已写入", parts[1] == "true", f"{parts[1]}")
check("红黄计数落库(班主任端要用它排序)", parts[2] == "0", f"red={parts[2]} yellow={parts[3]}")
# ── 6. 防重复提交 ──
print("\n[6] 防重复提交")
r = sub(tok, sid)
check("再提交被拒", r.get("code") not in (0, None), f"code={r.get('code')}")
check("提示里说明了当前状态(学生最怕不知道提交成功没有)",
"已提交" in (r.get("msg") or "") or "初审" in (r.get("msg") or ""),
f"msg={r.get('msg')}")
finally:
print("\n[7] 清理")
sql(f"DELETE FROM collect_file WHERE submission_id={sid}")
sql(f"DELETE FROM collect_submission WHERE id={sid}")
sql(f"UPDATE collect_roster SET bind_user_id=NULL, bind_time=NULL, bind_type=NULL WHERE id={rid}")
import shutil
for sub2 in ("raw", "std"):
d2 = os.path.join(ROOT, "backend", "data", "uploads", sub2, "1", str(task_id))
if os.path.exists(d2):
shutil.rmtree(d2, ignore_errors=True)
ok("已清理")
print("\n" + "=" * 72)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
for n, dd in FAILED:
print(f" · {n}\n {dd}")
print("=" * 72)
return 1 if FAILED else 0
if __name__ == "__main__":
raise SystemExit(main())