feat(material): M1-04 材料项定义与内置模板

发布前置校验里最后缺的一项(40018「未配置必传材料项」)终于能配了。
按计划表这一项是后端(4 人日),材料规则的界面属 M1-09。

交付:
· 材料项 CRUD(《03-接口设计》§7.10):GET/POST /tasks/{id}/materials、
  PUT/DELETE /materials/{id}
· 三个内置模板(TPL_SCHOOL_ROLL / TPL_SOCIAL / TPL_ZHUANXIANG),幂等应用
· GET /material-catalog:材料码/档位/格式白名单/模板的静态清单,
  供界面下拉使用(接口是契约外的,理由见 Controller 注释:把这份常量在前端
  再抄一份会造成两份真相,而别名正是识别历史文件名用的)

落在代码里的三件事:
1) 默认值按两个维度分别取(MaterialCatalog):
   · 按材料码定 名字/别名/默认压缩档/份数/是否合并
   · 按压缩档定 固定尺寸(P1)/长边下限(P2)/目标DPI(P3)/输出格式
   把份数塞进档位默认里是很容易犯的错 —— 那样所有 P2 材料都会变成"要传 2 份",
   而"身份证要正反面"是这件材料的事实,不是档位的规则。
2) WORK_PROOF 的名字就是「工作证明」。机构模板里「职业技能等级个人申报表」
   的规范文件名就是「工作证明」,这是机构既有习惯,不要"修正" —— 改了会让
   导出包与该机构一直以来的归档口径对不上,带教老师只认这个名字。
3) 关联键是 (task_id, material_code),不是 material_def_id(表里没有那个外键)。
   所以 40027「已有文件不可删」必须按码查 —— 用 id 查会永远返回 0,
   这道校验形同虚设,后果是"删了材料定义,文件成了孤儿"。

本轮修掉的 2 个真问题:
1) NOT NULL DEFAULT 与"档位不适用就置空"打架(同一类 bug 的第二次):
   min_long_side(NOT NULL DEFAULT 1400)与 output_format(NOT NULL DEFAULT 'jpg')
   在切档位时被写成 null,PostgreSQL 直接 500。而更根本的是:契约 §3.4 明确写着
   outputFormat 的 null = 保持原格式 —— 这一列本就不该是 NOT NULL。
   已由 V5__material_profile_params_nullable.sql 把四个档位参数统一成
   "不适用时为空"(另两个本来就可空),并同步更正两份 DDL 文档。
   ⚠️ 这与上一轮的 expected_count 是同一类问题:NOT NULL DEFAULT x 看起来无害,
   但它让"未设置/不适用"无法表达,而契约往往又要求能表达。
   动"可选数字/字符串"字段前,先查 information_schema.columns 的 is_nullable。
2) 我的两条 E2E 断言本身是坏的:
   · 把 lower_case 断言成"应被拒" —— 实际服务端把它规范化成 LOWER_CASE 后
     接受了,这是对输入友好的正确行为,错的是断言;
   · "改档位后参数跟着变"用了 P3,而 OTHER_PROOF 的默认档本来就是 P3,
     于是无论改没改都会通过(假通过)。
   教训:断言要能真的失败。

顺带整理:
· 抽出 support/NamingTemplate:任务级 naming_template 与材料级 name_pattern
  必须共用同一套校验规则。两处各写一套会出现"任务级认的变量材料级不认",
  而这类不一致的表现是"发布时通过、导出时文件名叫错",最难在测试里撞上。
  TaskService 已改为委托。
· materialTemplateId(契约里是 int64)不再空转:§7.10 的三个模板在文档与代码里
  都是具名常量,库里也没有"材料模板表",拿 id 指代常量就得先造一张表放它们。
  新增 materialTemplateCode(字符串)承接内置模板,materialTemplateId 留给
  M1-05 的 collect_rule_template(那是真正的一行一条,id 才有意义)。

已知缺口(写进了代码注释与交接摘要):
· minSizeKb 恒为 null:契约 §3.4 给了字段但"对应列"是 —,而《04》又用到
  materialDef.minSizeKb,两侧口径没对齐;且具体阈值属待办 D5「一寸照片阈值标定」,
  没有实测数据。编一个数会让它看起来"已实现",卡住真材料时没人说得清为什么。
· 条件显示只做存取与形状校验,求值在填报引擎 M3-10。
· 40030(审核中不可删材料)属 M5,当前删除只查 40027。

验收:e2e-m1-materials.py 68/68;五条 E2E 合计 278 项断言全通过;
Flyway 迁移 1~5 全部 success。
This commit is contained in:
2026-09-29 09:42:08 +08:00
parent 7523ebe789
commit d32969d766
15 changed files with 1860 additions and 55 deletions
+493
View File
@@ -0,0 +1,493 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
M1-04 材料项定义 验收测试。
覆盖《03-接口设计》§7.10:
[x] 按内置模板初始化(TPL_SCHOOL_ROLL / TPL_SOCIAL / TPL_ZHUANXIANG),幂等
[x] 材料项 CRUD;材料码任务内唯一;材料码不可修改
[x] 默认值按「材料码」与「压缩档位」两个维度分别取(见 MaterialCatalog)
[x] 条件显示(FR-402):在校生→学籍表,非在校生→工作证明 / 学信网备案表
[x] 已有文件的材料项不可删(40027)—— 关联键是 (task_id, material_code)
[x] 校验:档位 / 格式白名单 / 体积范围 / 材料码格式 / 命名模板
[x] 状态限制与权限
为什么这些值得逐条断言:
材料清单直接决定学生要交什么、交多大、压到什么程度。默认值算错的表现是
"身份证被要求只传 1 份"(正反面传不上)或"证件照没被压到 295x413"
(机构退件),而这两种都不会报错,只会让材料收不成。
用法
python scripts/e2e-m1-materials.py
依赖:cryptography;且需要能 `docker exec xt-pg`(造"已有文件"与清理)
退出码:0 = 全部通过
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import subprocess
import sys
import urllib.error
import urllib.request
import uuid
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem")
PG_CONTAINER = os.environ.get("XT_PG_CONTAINER", "xt-pg")
PG_USER = os.environ.get("PG_USER", "fill")
PG_DB = os.environ.get("PG_DB", "filldb")
PG_PWD = os.environ.get("PG_PASSWORD", "fill_dev_pwd")
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
CTX: dict[str, str | None] = {"base": None, "token": None}
CREATED_TASK_IDS: list[int] = []
CREATED_USER_IDS: list[int] = []
# ── 断言 ──────────────────────────────────────────────
def ok(name: str) -> None:
PASSED.append(name)
print(f" [OK] {name}")
def fail(name: str, detail: str) -> None:
FAILED.append((name, detail))
print(f" [FAIL] {name}\n {detail}")
def check(name: str, cond: bool, detail: str = "") -> bool:
if cond:
ok(name)
else:
fail(name, detail or "断言不成立")
return cond
def sql(stmt: str) -> tuple[bool, str]:
try:
r = subprocess.run(
["docker", "exec", "-e", f"PGPASSWORD={PG_PWD}", PG_CONTAINER,
"psql", "-U", PG_USER, "-d", PG_DB, "-t", "-A", "-c", stmt],
capture_output=True, text=True, timeout=40)
return r.returncode == 0, (r.stdout or r.stderr).strip()
except Exception as e:
return False, str(e)
def cleanup_all() -> None:
"""幂等清理;崩溃时也会被 __main__ 的 finally 调一次。"""
if CREATED_TASK_IDS:
ids = ",".join(str(i) for i in CREATED_TASK_IDS)
for stmt in [
f"DELETE FROM collect_file WHERE task_id IN ({ids})",
f"DELETE FROM collect_material_def WHERE task_id IN ({ids})",
f"DELETE FROM collect_task_org WHERE task_id IN ({ids})",
f"DELETE FROM collect_roster WHERE task_id IN ({ids})",
f"DELETE FROM collect_task WHERE id IN ({ids})",
]:
sql(stmt)
base, token = CTX.get("base"), CTX.get("token")
if base and token:
for uid in CREATED_USER_IDS:
try:
call("DELETE", f"{base}/api/v1/users/{uid}", token)
except Exception:
pass
# ── HTTP ────────────────────────────────────────────
def rsa_encrypt(plain: str) -> str:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
with open(PUB_KEY, "rb") as f:
pub = serialization.load_pem_public_key(f.read())
return base64.b64encode(pub.encrypt(plain.encode(), padding.PKCS1v15())).decode()
def call(method: str, url: str, token: str | None = None,
body: dict | None = None, timeout: int = 30) -> tuple[int, dict]:
headers = {"Content-Type": "application/json; charset=utf-8"}
if token:
headers["Authorization"] = f"Bearer {token}"
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status, json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8")
try:
return e.code, json.loads(raw)
except json.JSONDecodeError:
return e.code, {"raw": raw}
def login(base: str, username: str, password: str, tenant: str | None = None):
payload = {"username": username, "password": rsa_encrypt(password)}
if tenant:
payload["tenantCode"] = tenant
return call("POST", f"{base}/api/v1/auth/login", body=payload)
def main() -> int:
ap = argparse.ArgumentParser(description="M1-04 材料项定义 E2E")
ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080"))
ap.add_argument("--tenant", default=os.environ.get("XT_TENANT", "demo"))
ap.add_argument("--password", default="Admin@123")
args = ap.parse_args()
base = args.base.rstrip("/")
print("=" * 72)
print(f"M1-04 材料项定义 E2E base={base} 租户={args.tenant}")
print("=" * 72)
suffix = uuid.uuid4().hex[:6]
# ── 0. 准备 ────────────────────────────────────────
print("\n[0] 准备")
status, body = login(base, "admin", args.password, args.tenant)
if body.get("code") != 0:
fail("管理员登录", f"HTTP {status} code={body.get('code')} msg={body.get('msg')}")
return 1
token = body["data"]["token"]
tenant_id = (body["data"].get("tenant") or {}).get("id")
CTX["base"], CTX["token"] = base, token
ok(f"管理员登录(租户 id={tenant_id})")
# 建任务时直接带内置模板
batch = "2002"
status, body = call("POST", f"{base}/api/v1/tasks", token, {
"taskName": f"E2E材料清单 {suffix}", "collectMode": 1, "batchNo": batch,
"majorName": "数控加工", "certType": 1,
"startTime": "2026-09-28", "endTime": "2026-12-31",
"materialTemplateCode": "TPL_SCHOOL_ROLL",
})
if body.get("code") != 0:
fail("建任务(带材料模板)", f"code={body.get('code')} msg={body.get('msg')}")
return 1
task_id = body["data"]["id"]
CREATED_TASK_IDS.append(task_id)
ok(f"建任务并带 TPL_SCHOOL_ROLL 模板(id={task_id})")
# ── 1. 模板初始化 ───────────────────────────────────
print("\n[1] 内置模板初始化")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
rows = body.get("data") or []
check("模板铺出 4 个材料项", len(rows) == 4, f"实际 {len(rows)}:{[r.get('materialCode') for r in rows]}")
if len(rows) != 4:
return 1
by_code = {r["materialCode"]: r for r in rows}
check("四个材料码正确(照片/身份证/学籍表/工作证明)",
set(by_code) == {"PHOTO1", "IDCARD", "SCHOOL_ROLL", "WORK_PROOF"}, f"{sorted(by_code)}")
p1 = by_code.get("PHOTO1", {})
check("一寸照片:名称来自 §2.1 材料目录", p1.get("materialName") == "一寸照片",
f"{p1.get('materialName')}")
check("一寸照片:别名自动填了(识别历史文件名要用)",
"证件照" in (p1.get("aliases") or []) and "1寸照片" in (p1.get("aliases") or []),
f"{p1.get('aliases')}")
check("一寸照片:默认档位 P1", p1.get("compressProfile") == "P1", f"{p1.get('compressProfile')}")
check("一寸照片:P1 档补上了固定尺寸 295x413",
(p1.get("fixedSize") or {}).get("width") == 295
and (p1.get("fixedSize") or {}).get("height") == 413,
f"{p1.get('fixedSize')}")
check("一寸照片:输出格式 jpg", p1.get("outputFormat") == "jpg", f"{p1.get('outputFormat')}")
idc = by_code.get("IDCARD", {})
check("身份证:默认档位 P2", idc.get("compressProfile") == "P2", f"{idc.get('compressProfile')}")
check("身份证:份数 2 且允许合并(正反面)",
idc.get("maxCount") == 2 and idc.get("mergeEnabled") is True,
f"maxCount={idc.get('maxCount')} merge={idc.get('mergeEnabled')}")
check("身份证:P2 档补上了长边下限 1400", idc.get("minLongSide") == 1400,
f"{idc.get('minLongSide')}")
check("身份证:P2 档下不该有 P1 的固定尺寸",
idc.get("fixedSize") is None, f"{idc.get('fixedSize')}")
wp = by_code.get("WORK_PROOF", {})
check("工作证明:名称是「工作证明」而不是「个人申报表」(机构既有习惯,不要修正)",
wp.get("materialName") == "工作证明", f"{wp.get('materialName')}")
check("工作证明:默认档位 P3", wp.get("compressProfile") == "P3", f"{wp.get('compressProfile')}")
check("工作证明:P3 档补上了目标 DPI 300", wp.get("targetDpi") == 300, f"{wp.get('targetDpi')}")
sr = by_code.get("SCHOOL_ROLL", {})
check("学籍表:默认档位 P4,输出保 PNG(截图转 JPG 会糊掉表格线)",
sr.get("compressProfile") == "P4" and sr.get("outputFormat") == "png",
f"{sr.get('compressProfile')} / {sr.get('outputFormat')}")
check("FR-402 级联:学籍表仅在校生显示(is_student=true)",
(sr.get("visibleWhen") or {}).get("field") == "is_student"
and (sr.get("visibleWhen") or {}).get("value") is True,
f"{sr.get('visibleWhen')}")
check("FR-402 级联:工作证明仅非在校生显示(is_student=false)",
(wp.get("visibleWhen") or {}).get("field") == "is_student"
and (wp.get("visibleWhen") or {}).get("value") is False,
f"{wp.get('visibleWhen')}")
check("模板里的材料都是必传(互斥由条件显示解决,在校生不会被要求交工作证明)",
all(r.get("required") is True for r in rows), f"{[(r['materialCode'], r.get('required')) for r in rows]}")
# 幂等
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials/apply-template", token,
{"templateCode": "TPL_SCHOOL_ROLL"})
check("重复应用同一模板是幂等的(不报错、不重复插)",
body.get("code") == 0 and (body.get("data") or {}).get("createdCount") == 0,
f"code={body.get('code')} data={body.get('data')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
check("重复应用后仍是 4 项", len(body.get("data") or []) == 4, f"{len(body.get('data') or [])}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials/apply-template", token,
{"templateCode": "TPL_NOPE"})
check("不存在的模板码被拒且提示可用模板", body.get("code") == 40001 and "TPL_" in (body.get("msg") or ""),
f"code={body.get('code')} msg={body.get('msg')}")
# ── 2. 材料目录 ─────────────────────────────────────
print("\n[2] 材料目录接口")
status, body = call("GET", f"{base}/api/v1/material-catalog", token)
cat = body.get("data") or {}
check("目录返回 6 种材料码", len(cat.get("codes") or []) == 6,
f"{[c.get('code') for c in (cat.get('codes') or [])]}")
check("目录返回 6 个压缩档位", len(cat.get("profiles") or []) == 6, f"{cat.get('profiles')}")
check("目录返回 5 种允许格式", len(cat.get("fileTypes") or []) == 5, f"{cat.get('fileTypes')}")
check("目录返回 3 个内置模板", len(cat.get("templates") or []) == 3, f"{cat.get('templates')}")
check("目录返回体积可配范围 307~5120",
cat.get("maxSizeRange") == [307, 5120], f"{cat.get('maxSizeRange')}")
# ── 3. 新增与默认值 ─────────────────────────────────
print("\n[3] 新增材料项")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "OTHER_PROOF", "materialName": "其它佐证材料",
"tipText": "如有获奖证书、技能证书请一并上传",
})
check("新增选传材料成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
other_id = body.get("data")
check("返回新增的 id", isinstance(other_id, int), f"{other_id}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "OTHER_PROOF", "materialName": "重复的",
})
check("同一个材料码不能配两次(任务内唯一)", body.get("code") == 40001,
f"code={body.get('code')} msg={body.get('msg')}")
# 小写码会被规范化为大写 —— 这是对输入友好,也保证"码"只有一个口径
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "lower_case", "materialName": "小写码材料",
})
check("材料码小写会被规范化为大写(不是拒绝)", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
lower_id = body.get("data")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
codes = [r["materialCode"] for r in (body.get("data") or [])]
check("规范化后的码是大写 LOWER_CASE", "LOWER_CASE" in codes, f"{codes}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "1INVALID", "materialName": "数字开头",
})
check("材料码以数字开头被拒", body.get("code") == 40001, f"code={body.get('code')} msg={body.get('msg')}")
# 未知码允许(学校可能有《护士执业证》这类目录外的材料),但名称必须自己填
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "CUSTOM_CERT", "materialName": "护士执业证",
})
check("目录外的材料码允许使用(学校会有目录外的材料)", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
custom_id = body.get("data")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
refetched = {r["materialCode"]: r for r in (body.get("data") or [])}
check("目录外的码默认档位 AUTO、份数 1、不合并、无固定尺寸",
refetched.get("CUSTOM_CERT", {}).get("compressProfile") == "AUTO"
and refetched.get("CUSTOM_CERT", {}).get("maxCount") == 1
and refetched.get("CUSTOM_CERT", {}).get("mergeEnabled") is False
and refetched.get("CUSTOM_CERT", {}).get("fixedSize") is None,
f"{refetched.get('CUSTOM_CERT')}")
# ── 4. 校验 ─────────────────────────────────────────
print("\n[4] 校验:不该放行的都要拦住")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "BAD_PROFILE", "materialName": "档位非法", "compressProfile": "P9",
})
check("非法压缩档位被拒", body.get("code") == 40001, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "BAD_TYPE", "materialName": "格式非法", "fileTypes": ["jpg", "exe"],
})
check("格式白名单外的类型被拒且列出可选值",
body.get("code") == 40001 and "exe" in (body.get("msg") or ""),
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "TOO_BIG", "materialName": "体积超范围", "maxSizeKb": 9000,
})
check("体积上限超出 5M 被拒", body.get("code") == 40001, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "TOO_SMALL", "materialName": "体积过小", "maxSizeKb": 100,
})
check("体积上限低于 0.3M 被拒(压得太狠等于把材料压废)", body.get("code") == 40001,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "BAD_NAMING", "materialName": "命名错", "namePattern": "{姓名}-{材料名}",
})
check("材料级命名模板缺 {证件号} 被拒 40020", body.get("code") == 40020,
f"code={body.get('code')} msg={body.get('msg')}")
check("命名模板的错误信息里点明了是哪个材料的问题",
"命名错" in (body.get("msg") or ""), f"msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "BAD_VISIBLE", "materialName": "条件缺字段",
"visibleWhen": {"op": "eq", "value": True},
})
check("条件显示规则缺 field 被拒", body.get("code") == 40001 and "field" in (body.get("msg") or ""),
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
check("以上失败都没有污染数据(7 项:4 模板 + OTHER_PROOF + CUSTOM_CERT + LOWER_CASE)",
len(body.get("data") or []) == 7, f"实际 {len(body.get('data') or [])}")
# ── 5. 修改 ─────────────────────────────────────────
print("\n[5] 修改材料项")
status, body = call("PUT", f"{base}/api/v1/materials/{other_id}", token, {
"materialCode": "OTHER_PROOF", "materialName": "其它佐证材料(改名)",
"required": False, "maxSizeKb": 2048,
"compressProfile": "P1",
})
check("修改成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
m = {r["materialCode"]: r for r in (body.get("data") or [])}.get("OTHER_PROOF", {})
check("名称已改", m.get("materialName") == "其它佐证材料(改名)", f"{m.get('materialName')}")
check("改为选传已生效", m.get("required") is False, f"{m.get('required')}")
check("体积上限已改 2048", m.get("maxSizeKb") == 2048, f"{m.get('maxSizeKb')}")
# 注意:这里必须换到与原档位(P3)**不同**的档位,否则断言会因"原值恰好相同"而假通过。
# 之前我用 P3 就踩了这个坑:OTHER_PROOF 的默认档本来就是 P3,测不出任何东西。
check("从 P3 改到 P1 后:P1 的固定尺寸出现、P3 的目标 DPI 消失",
m.get("compressProfile") == "P1"
and (m.get("fixedSize") or {}).get("width") == 295
and (m.get("fixedSize") or {}).get("height") == 413
and m.get("targetDpi") is None,
f"profile={m.get('compressProfile')} fixed={m.get('fixedSize')} dpi={m.get('targetDpi')}")
check("切档位后不适用的长边下限被清空(这正是 500 的那条路径)",
m.get("minLongSide") is None, f"{m.get('minLongSide')}")
status, body = call("PUT", f"{base}/api/v1/materials/{custom_id}", token, {
"materialCode": "RENAMED_CODE", "materialName": "护士执业证",
})
check("材料码不可修改(它是文件的关联键)", body.get("code") == 40001,
f"code={body.get('code')} msg={body.get('msg')}")
# 清空命名模板:这一条正是"updateById 会跳过 null"会静默失效的地方
status, body = call("PUT", f"{base}/api/v1/materials/{custom_id}", token, {
"materialCode": "CUSTOM_CERT", "materialName": "护士执业证",
"namePattern": "{姓名}-{证件号}-{材料名}",
})
check("设置材料级命名模板成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("PUT", f"{base}/api/v1/materials/{custom_id}", token, {
"materialCode": "CUSTOM_CERT", "materialName": "护士执业证", "namePattern": None,
})
check("清空材料级命名模板成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
m = {r["materialCode"]: r for r in (body.get("data") or [])}.get("CUSTOM_CERT", {})
check("清空后读回是 null(清空真的落库,不是被静默忽略)",
m.get("namePattern") is None, f"{m.get('namePattern')}")
# ── 6. 删除 ─────────────────────────────────────────
print("\n[6] 删除与 40027")
status, body = call("DELETE", f"{base}/api/v1/materials/{custom_id}", token)
check("没有文件的材料项可以删除", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
check("删除后列表少了一项", len(body.get("data") or []) == 6, f"{len(body.get('data') or [])}")
# 造一个"已上传的文件"——关联键是 (task_id, material_code),没有 material_def_id
okk, out = sql(
f"INSERT INTO collect_file (tenant_id, task_id, submission_id, material_code, "
f"original_name, file_ext, raw_size, final_size, file_hash) "
f"VALUES ({tenant_id}, {task_id}, 0, 'OTHER_PROOF', '身份证.jpg', 'jpg', 100, 90, 'seedhash')")
check("夹具:写入 1 个文件(关联 material_code=OTHER_PROOF)", okk, out)
status, body = call("DELETE", f"{base}/api/v1/materials/{other_id}", token)
check("已有文件的材料项不可删除 40027", body.get("code") == 40027,
f"code={body.get('code')} msg={body.get('msg')}")
check("拒绝理由给出了替代做法(设选传或加条件隐藏)",
"选传" in (body.get("msg") or "") or "隐藏" in (body.get("msg") or ""),
f"msg={body.get('msg')}")
# ── 7. 状态限制与权限 ───────────────────────────────
print("\n[7] 状态限制与权限")
okk, out = sql(f"INSERT INTO collect_roster (tenant_id, task_id, real_name, id_card_no, id_card_hash) "
f"VALUES ({tenant_id}, {task_id}, 'E2E学生A', 'seed', 'seed')")
okk, out = sql(f"INSERT INTO collect_task_org (tenant_id, task_id, org_id, status) "
f"VALUES ({tenant_id}, {task_id}, "
f"(SELECT id FROM sys_org WHERE tenant_id={tenant_id} AND org_type=3 LIMIT 1), 1)")
check("夹具:名册与覆盖班级就位", okk, out)
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/publish", token, {"force": True})
check("任务已发布(说明材料清单不再是发布的阻断项)", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "AFTER_PUBLISH", "materialName": "发布后补的材料",
})
check("收集中仍可补材料(「漏交了,加一项」是真实需求)", body.get("code") == 0,
f"code={body.get('code')} msg={body.get('msg')}")
call("POST", f"{base}/api/v1/tasks/{task_id}/close", token, {})
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", token, {
"materialCode": "AFTER_CLOSE", "materialName": "截止后加的",
})
check("已截止不可再加材料", body.get("code") == 40001, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", token)
check("已截止仍可读材料清单(只读不等于看不见)", body.get("code") == 0,
f"code={body.get('code')}")
# 班主任只读
ht_user = f"htm{suffix}"
status, body = call("POST", f"{base}/api/v1/users", token, {
"username": ht_user, "realName": f"E2E材料班主任{suffix}",
"phone": "13900139301", "roleCodes": ["HEAD_TEACHER"], "userType": 3,
})
if check("造班主任账号", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"):
ht_id = body["data"]["id"]
ht_pwd = body["data"]["initialPassword"]
CREATED_USER_IDS.append(ht_id)
status, body = login(base, ht_user, ht_pwd, args.tenant)
ht_token = body["data"]["token"]
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/materials", ht_token)
check("班主任能读材料清单(他要照着检查学生交得对不对)", body.get("code") == 0,
f"code={body.get('code')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", ht_token,
{"materialCode": "BY_TEACHER", "materialName": "班主任加的"})
check("班主任不能加材料(无 task:manage)", body.get("code") == 40301,
f"code={body.get('code')}")
# ── 8. 清理 ────────────────────────────────────────
print("\n[8] 清理测试数据")
cleanup_all()
ok(f"已清理 {len(CREATED_TASK_IDS)} 个任务、{len(CREATED_USER_IDS)} 个账号及其夹具数据")
print("\n" + "=" * 72)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
if FAILED:
print("\n失败项:")
for name, detail in FAILED:
print(f" · {name}\n {detail}")
print("=" * 72)
return 1 if FAILED else 0
if __name__ == "__main__":
try:
code = main()
except BaseException:
print("\n ⚠ 脚本异常退出,正在清理已造出的测试数据…")
cleanup_all()
raise
raise SystemExit(code)