#!/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)