Files
xiaotutongxue/scripts/e2e-m3-submit.py
T
lovife a4406f545a feat(task): 材料规则界面(M1-04 前端)—— 动态收集要求在界面上真的能配了
发布任务的 5 项前置检查里有一项是「至少配一项必传材料」(40018)。
在这之前,管理员配材料要求**只能直接改数据库** —— 而"能动态设置收集要求"
正是本产品区别于"发个 Excel 让学生填"的核心。界面上没有它,这个能力等于不存在。
(task/index.vue 里那句「请到『材料规则』里配置…」也一直指着一个不存在的界面。)

新增:
  · apis/xt/material.ts —— 材料项接口 + 材料目录 + 枚举镜像
  · views/task/TaskMaterialDrawer.vue —— 列表 + 新增/编辑/删除 + 按模板初始化
  · task/index.vue 操作列加「材料规则」(草稿/收集中/已暂停三态),操作列 200→240px

四处刻意的设计:
  1. 选材料类型**自动带出默认值**(规范名、别名、默认档位与份数)。
     让人手填这些,一是慢,二是必然填不一致 —— 而"档位选错"的后果是
     证件翻拍件被压糊、机构退回。实测:选「学信网备案表」自动带出
     别名(学信网/学历备案表/电子注册备案表)+ P4 + 1 份。
  2. **条件显示只给三选一**(所有学生/仅在校生/仅非在校生),不做通用规则构造器。
     实际业务只有一种需求:学籍表与工作证明按是否在校生互斥。给一个能拼任意条件的
     东西,用户拼出个永远为假的条件,表现是"学生那边根本不显示这一项",
     而原因藏在一条没人在意的规则里。
  3. **编辑时锁死材料码**:它是 collect_file 的关联键(两者之间没有外键),
     改了码已上传的文件就成了孤儿 —— 静默丢材料。
  4. **收集中改动显式警告**:服务端允许改,但学生会立刻看到新要求,
     已传的材料可能因此出现新的问题项。

浏览器真点过:抽屉打开、模板初始化生成标准 4 项且**条件标签正确**
(学籍表=仅在校生、工作证明=仅非在校生)、新增的自动带默认值、保存成功。
typecheck 通过,改动文件 lint 0 错 0 警。

⭐ 顺带验证了一条完整链路:e2e-m3-submit.py 原先**写死了 IDCARD**,
   配上标准 4 项之后全红 —— 那说明脚本不够健壮(材料要求本来就是可配置的)。
   改成从表单接口读"当前学生看得见、且必传"的材料后:
     当前必传且有条件成立的:['IDCARD', 'PHOTO1', 'SCHOOL_ROLL']
     跳过(选传或条件不成立):['WORK_PROOF']
   即:管理员配的「学籍表=仅在校生」→ 学生端正确隐藏工作证明 → 校验不误报缺失。
   **教训:E2E 不要写死业务配置。写死的那一刻,测试就从"验证行为"变成了
   "验证数据",配置一变全红 —— 看起来像代码坏了。**

又修一处文档缺陷:《03-接口设计》§2.7 的 taskStatus 把 2 与 4 写反了
   (文档:2已暂停/3已截止/4已归档;代码与 DDL:2已截止/3已归档/4暂停)。
   按文档实现会把「暂停」和「截止」做反,而这两者语义差别很大
   (暂停可恢复、已提交照常审;截止不可逆)。

40018 的提示改为「请**点本行的**「材料规则」…」—— 顶层菜单里也有一个「材料规则」
   (里程碑占位页),不点明"本行"会把人引到那个占位页去。

验收:e2e-m3-submit.py 31/31;
回归 材料目录 68 / 材料上传 29 / 草稿 27 / 门禁 29 全绿。
2026-09-29 13:33:09 +08:00

377 lines
17 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 -*-
"""
提交与校验 验收测试(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())