Files
lovife 1f3c485ceb feat(role,scope)!: 角色与授权可配 + 需求收敛(4 角色 / 10 菜单 / 删两模块)
角色与授权(交接摘要 §一·三十五):
- 权限判定合并两个来源:内置角色按代码(BuiltinRole)、自定义角色按库
  (sys_role_permission)。实现放 module 层的 DbRoleGrantSource,security 只依赖
  接口 RoleGrantSource —— 否则角色服务要调 permissionChecker,两个包迟早成环
- 权限判定必须加缓存:此前是零 IO,而压测基线是"SQL 条数恒定"。缓存放 Redis,
  空结果也缓存(否则已删角色的 token 会把库打穿)、Redis 挂了退化成直读库、TTL 只兜底
- V18 建 sys_role.user_type(账号的 user_type 一直从角色反推,自定义角色不在代码映射里)
  与 sys_role_menu;8 个接口 + 错误码 40045;契约 119 -> 129
- 内置角色只读(权限码是前后端契约,做成可改 = 一次误操作就能把管理员锁在系统外);
  平台级能力两道防线(界面禁用 + 服务端 40301);防自锁;改完立即生效(写授权清缓存)
- RoleProvisioner 从"已存在就 continue"改成幂等回填 + 每日 03:20 定时任务 ——
  修的是"代码改了权限、已开通租户永远停在发放那一刻",这种漂移不报错也没测试会红
- scripts/e2e-m10-role.py 77/77

需求收敛(交接摘要 §一·三十六,本节的数字为准):
- 删副班主任 / 审核员两个内置角色(6 -> 4)、历史整理器(module/cleanup)、
  加入申请(module/join + 学生端 pages/join)、collect_task_org.deputy_user_ids 整列;
  审核范围三级回退 -> 两级,连带 6 个 mapper 的 LIKE '%,id,%' 分支
- V19 迁移处理存量:先解 sys_user_role 绑定再软删角色;账号只在"除这两个角色外没有
  别的角色"时才软删。必须迁而不能只删代码 —— 角色码删掉后 mergePermissions 返回空集,
  存量账号会"能登录、处处 40301",不抛异常不打日志
- 契约 129 -> 119、错误码 59 -> 54、权限码 30 -> 28、PC 菜单 12 -> 10、
  小程序页 10 -> 9、原型 32 -> 29 屏、E2E 39 -> 37 个(断言 1695 条)
- UserService.list 对不认识的角色码由"静默丢掉过滤条件"改成失败关闭
  (之前传个不存在的 roleCode 会返回全校账号,且看起来完全正常)
- 品牌元信息同步改名「深学在线」:pom 名称、/doc.html 标题、配置与启动类注释、
  存储自检探针内容;学生端 app 名与全局导航栏标题
2026-10-10 16:48:54 +08:00

501 lines
27 KiB
Python
Raw Permalink 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 -*-
"""
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 {}
# ⚠️ 2026-10 需求收敛:可选材料码 6 → 4(`XJW_BA`/`OTHER_PROOF` 属已取消的二期,
# 它们**读得到、选不到** —— 仍留在目录里只为让历史任务的材料项显示得出名字)。
check("目录只返回 4 种**可选**材料码(学信网备案表/其它佐证材料不再可选)",
len(cat.get("codes") or []) == 4,
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')}")
# 内置模板 3 → 1:社会收集与专项职业能力属已取消的二期,只剩学校收集一种。
check("目录只返回 1 个内置模板(学校收集)",
len(cat.get("templates") or []) == 1
and (cat.get("templates") or [{}])[0].get("templateCode") == "TPL_SCHOOL_ROLL",
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)