#!/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 norm(v) -> str: s = '' if v is None else str(v).strip() if s.lower() == 'true': return '1' if s.lower() == 'false': return '0' return s def visible(when, values: dict) -> bool: """ 条件可见求值。**必须与服务端 SubmissionValidator.visible 同口径** —— 不一致会出现"学生看得见、后端说你没填"这类两边各自看都没错的问题。 """ if not when: return True f = when.get('field') if not f: return True op = when.get('op') if op is not None and str(op) != 'eq': return True return norm(values.get(str(f))) == norm(when.get('value')) def form_values(token: str, task_id: int) -> tuple[dict, list]: """读表单装配结果:字段当前值 + 材料项(含条件)。""" r = call("GET", f"/api/v1/mp/tasks/{task_id}/form", token) d = r.get("data") or {} values = {} for sec in d.get("sections") or []: for f in sec.get("fields") or []: values[f.get("key")] = f.get("value") return values, d.get("materials") or [] 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] 传上必传材料") # ⚠️ **不要写死材料码**。任务的材料要求是可配置的(管理员能在「材料规则」里 # 加成一寸照片/学籍表/工作证明…),而"条件材料"还要看学生是不是在校生。 # 写死 IDCARD 的脚本,在任务配置一变就全红 —— 而那看起来像代码坏了。 # 这里从表单接口读"当前学生看得见、且必传"的材料,按它上传。 img = small_jpeg() values, mats = form_values(tok, task_id) need = [m for m in mats if m.get("required") and visible(m.get("visibleWhen"), values)] skipped = [m.get("materialCode") for m in mats if m not in need] print(f" 当前必传且有条件成立的:{ [m.get('materialCode') for m in need] }") if skipped: print(f" 跳过(选传或条件不成立):{skipped}") check("读到了必传材料清单(不是写死的一项)", len(need) >= 1, f"{need}") check("条件材料被正确排除(在校生不该被要求交工作证明)", all((m.get("visibleWhen") or {}).get("value") is not False for m in need), f"必传里混进了非在校生的材料: {[m.get('materialCode') for m in need]}") for i, m in enumerate(need): code = m.get("materialCode") up = upload(tok, sid, code, f"{m.get('materialName') or code}.jpg", img) if up.get("code") != 0: fail(f"上传 {code}", f"{up}") return 1 call("POST", f"/api/v1/mp/files/{up['data']['id']}/process", tok) # 第一项再传一份同一张图 → 制造一条**可预期的黄色警告**(VAL-09), # 否则"无红无黄 → 直接提交成功",下面那条"先确认再提交"的路径就成了没被测过的代码 if i == 0: up2 = upload(tok, sid, code, f"{m.get('materialName') or code}-重复.jpg", img) if up2.get("code") == 0: call("POST", f"/api/v1/mp/files/{up2['data']['id']}/process", tok) else: print(f" ({code} 不支持第二份,跳过重复件造数:{up2.get('msg')})") 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())