a4406f545a
发布任务的 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 全绿。
377 lines
17 KiB
Python
377 lines
17 KiB
Python
#!/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())
|