1f3c485ceb
角色与授权(交接摘要 §一·三十五): - 权限判定合并两个来源:内置角色按代码(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 名与全局导航栏标题
501 lines
27 KiB
Python
501 lines
27 KiB
Python
#!/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)
|