Files
xiaotutongxue/scripts/e2e-m1-form.py
T
lovife ef903a55c3 feat(task): 表单设计器(M1-08)—— 填报表单在界面上也能配了
"能动态设置填报表单"是本产品的核心承诺之一,而在这之前表单定义只能用 SQL 改。
更要紧的是它有一个**不可逆的方向**:内置字段删不掉(40026),正确用法是
"用条件显示隐藏"—— 而只有界面上给这条路留了位置,用户才会照着做。

新增:
  · apis/xt/form-schema.ts —— 表单定义接口 + 字段类型 + 条件选项 + 扩展字段 key 生成
  · views/task/TaskFormSchemaDrawer.vue —— 21 项内置字段(必填/条件/排序/提示语就地改)
    + 扩展字段增删改 + 保存为新版本 + 版本冲突处理
  · task/index.vue 操作列加「表单设计」,240→300px

浏览器实测:
  · 版本标签「未保存过(当前为官方默认表单)」——服务端对没存过的任务返回内置默认定义,
    让设计器从一份完整的官方表单开始,而不是从空白开始
  · **21 项全在,且「条件显示」反查正确**:补考三科→仅补考时、
    在读学校/专业/入学时间→仅在校生、本职业工作年限必填开关是关的。
    把服务端任意 visibleWhen 对象映射回人话标签,是本界面最容易做错的一处
  · 保存→库里 v1 current=1 fields=21;三次保存得到 v1/v2/v3,
    旧的 is_current=0 全部保留(**版本只增不改**实测)
  · 「不收集」= {field:"_never_",...}(内置字段删不掉,用一条永远不成立的条件隐藏);
    保存后原有条件(is_retake/is_student)都保住了

⭐ 端到端:学生端 字段 21→17(已获证书编号消失、补考三科消失、在校生三项保留)、
   材料 4→3(工作证明消失)

⚠️ 顺带修掉一个真 bug:**材料侧的条件没求值**
   上面这次验证暴露的:表单字段的条件前端有求值(21→17),材料的没有 ——
   「工作证明(仅非在校生)」照样显示给在校生;而后端校验是排除它的,
   于是表现成"系统让我传,我不传也不报错",学生只能自己猜。
   修法:把 visible() 抽成 whenVisible(),**字段与材料共用同一份**。
   教训:同一套规则出现在两处时必须共用同一个函数 ——
   抄一份的代价不是多几行,而是两边行为不一致,而两边各自看都没错。

修掉一条过期的 E2E 断言:e2e-m1-form.py 断言「联系电话不锁定」,
   与实现刻意的只读相反(BuiltinFields L88-93:collect_submission 没有 phone 列,
   所以一期只读;反过来做"前端允许改、后端悄悄丢掉"最糟 ——
   学生以为改成功,实际提交的还是老号码)。改断言并写明理由,48/48 恢复。

frontend-mp 类型检查修干净:原先 fill.vue 还有 3 条(输入框事件、日期选择、
   FormData 缺 submissionStatus),现在只剩 config/index.ts 的 2 条 ——
   那是 uni-app 的 #ifdef H5/#ifndef H5 声明同一变量,vue-tsc 看不懂条件编译,设计如此。

验收:e2e-m1-form 48/48、materials 68/68、task 71/71、orgs 48/48、
   m3-submit 31/31、file 29/29、draft 27/27、gate 29/29(合计 351 条断言全绿)
2026-09-29 13:48:11 +08:00

415 lines
22 KiB
Python
Raw 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-03 表单 schema 版本化与冲突检测 验收测试。
覆盖《03-接口设计》§7.9 / §3.5:
[x] 未保存过时返回内置字段目录的默认定义(官方 21 项,version=0)
[x] 22 项内置字段的锁定/必填/来源/选项与官方模板逐条对齐
[x] 保存为**新版本**(旧版本保留),is_current 只有一个
[x] baseVersion 不符 → 40025 **并回传最新定义**(前端据此合并)
[x] 缺少内置字段 → 40026(内置字段不可删)
[x] 扩展字段:必须 extra_ 前缀、不可与内置重名、不参与统计
[x] 客户端传 builtin=false 不能绕过「不可删除」(内建标志以目录为准)
[x] 状态限制与权限
为什么这些值得逐条断言:
表单定义是学生端看到的东西。版本搞错的表现是"学生按旧版填、老师按新版审";
内置字段被删的表现是"这个人的姓名取不到值"—— 都是不报错、只让人困惑的失败。
用法
python scripts/e2e-m1-form.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:
if CREATED_TASK_IDS:
ids = ",".join(str(i) for i in CREATED_TASK_IDS)
for stmt in [
f"DELETE FROM collect_form_schema 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
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-03 表单 schema 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-03 表单 schema 版本化 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("管理员登录")
status, body = call("POST", f"{base}/api/v1/tasks", token, {
"taskName": f"E2E表单 {suffix}", "collectMode": 1, "batchNo": "2003",
"majorName": "数控加工", "certType": 1,
"startTime": "2026-09-28", "endTime": "2026-12-31",
})
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"建任务(id={task_id})")
# ── 1. 默认定义 = 官方 21 项 ─────────────────────────
print("\n[1] 未保存过时的默认定义")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/form-schema", token)
d = body.get("data") or {}
fields = d.get("fields") or []
by_key = {f["key"]: f for f in fields}
check("返回内置默认定义(version=0,未落库)",
d.get("version") == 0 and d.get("latestVersion") == 0,
f"version={d.get('version')} latest={d.get('latestVersion')}")
check("恰好 21 个内置字段(官方模板 A~U 列)", len(fields) == 21, f"实际 {len(fields)}")
check("顺序即 A~U 列顺序",
[f["key"] for f in fields] == [
"seq_no", "real_name", "id_card_no", "education", "phone", "work_unit",
"occupation", "worktype", "job_level", "is_retake", "retake_theory",
"retake_practice", "retake_overall", "is_student", "school", "major",
"enroll_date", "work_years", "cert_occupation", "cert_level", "cert_no"],
f"{[f['key'] for f in fields]}")
check("全部标记为 builtin(内置不可删)", all(f.get("builtin") is True for f in fields),
f"{[(f['key'], f.get('builtin')) for f in fields if not f.get('builtin')]}")
# 锁定项:PRD §「锁定」= 姓名、身份证号、报考职业、报考工种、报考等级
locked_expected = {"real_name", "id_card_no", "occupation", "worktype", "job_level"}
locked_actual = {f["key"] for f in fields if f.get("locked")}
check("锁定项与 PRD 一致(姓名/身份证号/报考职业/工种/等级)",
locked_expected.issubset(locked_actual),
f"应为 {sorted(locked_expected)},实际 {sorted(locked_actual)}")
# ⚠️ 这一条按**实现的刻意决定**断言,而不是按 PRD 原文的"可核对修改"。
# 原因:collect_submission **没有 phone 列**(见 BuiltinFields L88-93),
# 所以「联系电话」一期设为只读,与"如号码有误请联系班主任"一致。
# 反过来做(前端允许改、后端悄悄丢掉)最糟:学生会以为改成功了,
# 而实际提交的还是老号码。
# 要真正开放修改,得先给 collect_submission 加一列(或让它落到 form_data)。
check("联系电话来自名册,且一期**刻意锁定**(没有 phone 列可存,宁可只读也不静默丢弃)",
by_key["phone"].get("source") == "ROSTER" and by_key["phone"].get("locked") is True,
f"source={by_key['phone'].get('source')} locked={by_key['phone'].get('locked')}")
check("姓名/身份证号的来源是名册(不在 collect_submission 里)",
by_key["real_name"].get("source") == "ROSTER"
and by_key["id_card_no"].get("source") == "ROSTER",
f"{by_key['real_name'].get('source')} / {by_key['id_card_no'].get('source')}")
check("学历的来源是 submission 且带 11 个官方选项",
by_key["education"].get("source") == "SUBMISSION"
and len(by_key["education"].get("options") or []) == 11,
f"source={by_key['education'].get('source')} options={len(by_key['education'].get('options') or [])}")
check("报考等级的 5 个官方选项照抄",
(by_key["job_level"].get("options") or [])[:2] == ["五级/初级工", "四级/中级工"],
f"{by_key['job_level'].get('options')}")
check("补考三科只在「是否补考=是」时显示(官方规则 1)",
all((by_key[k].get("visibleWhen") or {}).get("field") == "is_retake"
and (by_key[k].get("visibleWhen") or {}).get("value") is True
for k in ("retake_theory", "retake_practice", "retake_overall")),
f"{[(k, by_key[k].get('visibleWhen')) for k in ('retake_theory', 'retake_practice')]}")
check("在校生三项只在「是否在校生=是」时显示(官方规则 3/4)",
all((by_key[k].get("visibleWhen") or {}).get("field") == "is_student"
for k in ("school", "major", "enroll_date")),
f"{[(k, by_key[k].get('visibleWhen')) for k in ('school', 'major', 'enroll_date')]}")
check("入学时间带 YYYY-MM-DD 正则(官方原文「格式必须 YYYY-MM-DD」)",
(by_key["enroll_date"].get("validate") or {}).get("regex") == "^\\d{4}-\\d{2}-\\d{2}$",
f"{by_key['enroll_date'].get('validate')}")
check("工作单位的提示语写着「没有则填无」", "无" in (by_key["work_unit"].get("tip") or ""),
f"{by_key['work_unit'].get('tip')}")
# ── 2. 保存为版本 1 ─────────────────────────────────
print("\n[2] 保存为新版本")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
"baseVersion": 0,
"fields": fields,
"extras": [{"key": "extra_skill_cert", "label": "其它技能证书编号",
"type": "text", "required": False, "sortNo": 100}],
})
check("从 version=0 保存成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
saved = body.get("data") or {}
check("新版本是 1", saved.get("version") == 1, f"{saved.get('version')}")
check("isCurrent=true", saved.get("isCurrent") is True, f"{saved.get('isCurrent')}")
check("扩展字段存下来了", len(saved.get("extras") or []) == 1,
f"{saved.get('extras')}")
check("latestVersion 同步为 1", saved.get("latestVersion") == 1, f"{saved.get('latestVersion')}")
okk, out = sql(f"SELECT count(*) FROM collect_form_schema WHERE task_id={task_id}")
check("库里落了一行版本", okk and out.strip() == "1", f"{out}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/form-schema", token)
check("读回的是 version=1", (body.get("data") or {}).get("version") == 1,
f"{(body.get('data') or {}).get('version')}")
# ── 3. 改一处 → 版本 2 ──────────────────────────────
print("\n[3] 再次保存 → 版本递增、旧版保留")
fields2 = json.loads(json.dumps(fields))
for f in fields2:
if f["key"] == "work_unit":
f["required"] = False
f["tip"] = "改成选填了"
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
"baseVersion": 1, "fields": fields2, "extras": [],
})
check("从 version=1 保存成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
check("新版本是 2", (body.get("data") or {}).get("version") == 2, f"{(body.get('data') or {}).get('version')}")
okk, out = sql(f"SELECT count(*) FROM collect_form_schema WHERE task_id={task_id}")
check("库里两行版本(旧版保留,不覆盖)", okk and out.strip() == "2", f"{out}")
okk, out = sql(f"SELECT count(*) FROM collect_form_schema WHERE task_id={task_id} AND is_current=1")
check("当前版本只有一个", okk and out.strip() == "1", f"{out}")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/form-schema", token)
d2 = {f["key"]: f for f in (body.get("data") or {}).get("fields") or []}
check("改动已生效(工作单位变选填)", d2["work_unit"].get("required") is False,
f"{d2['work_unit'].get('required')}")
check("扩展字段可以整体清空", (body.get("data") or {}).get("extras") == [],
f"{(body.get('data') or {}).get('extras')}")
# ── 4. 版本冲突 ─────────────────────────────────────
print("\n[4] 版本冲突(40025)")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
"baseVersion": 1, "fields": fields2, "extras": [],
})
check("用过期 baseVersion 保存被拒 40025", body.get("code") == 40025,
f"code={body.get('code')} msg={body.get('msg')}")
latest = body.get("data") or {}
check("40025 的响应里**带上了最新定义**(前端据此做差异合并)",
latest.get("version") == 2 and len(latest.get("fields") or []) == 21,
f"version={latest.get('version')} fields={len(latest.get('fields') or [])}")
check("冲突提示里说清了基准版本与最新版本",
"基准版本 1" in (body.get("msg") or "") and "最新 2" in (body.get("msg") or ""),
f"msg={body.get('msg')}")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
"baseVersion": 99, "fields": fields2, "extras": [],
})
check("baseVersion 比最新大也被拒(不是只防「小于」)", body.get("code") == 40025,
f"code={body.get('code')} msg={body.get('msg')}")
# ── 5. 内置字段不可删 ───────────────────────────────
print("\n[5] 内置字段不可删(40026)")
missing_one = [f for f in fields2 if f["key"] != "id_card_no"]
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
"baseVersion": 2, "fields": missing_one, "extras": [],
})
check("删掉「身份证号」被拒 40026", body.get("code") == 40026,
f"code={body.get('code')} msg={body.get('msg')}")
check("拒绝理由点出了缺哪个字段,并给出替代做法(隐藏而不是删)",
"id_card_no" in (body.get("msg") or "") and "隐藏" in (body.get("msg") or ""),
f"msg={body.get('msg')}")
spoofed = json.loads(json.dumps(fields2))
spoofed = [f for f in spoofed if f["key"] != "id_card_no"]
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
"baseVersion": 2, "fields": spoofed, "extras": [],
})
check("把 builtin 传成 false 也无法绕过不可删除", body.get("code") == 40026,
f"code={body.get('code')} msg={body.get('msg')}")
# ── 6. 扩展字段 ─────────────────────────────────────
print("\n[6] 扩展字段")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
"baseVersion": 2, "fields": fields2,
"extras": [{"key": "education", "label": "想冒充内置列", "type": "text"}],
})
check("扩展字段不能与内置字段重名", body.get("code") == 40001,
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
"baseVersion": 2, "fields": fields2,
"extras": [{"key": "skillCert", "label": "没有 extra_ 前缀", "type": "text"}],
})
check("扩展字段 key 必须带 extra_ 前缀", body.get("code") == 40001,
f"code={body.get('code')} msg={body.get('msg')}")
check("以上失败都没有产生新版本",
(call("GET", f"{base}/api/v1/tasks/{task_id}/form-schema", token)[1].get("data") or {}).get("version") == 2,
"版本被推进了")
# ── 7. 状态限制与权限 ───────────────────────────────
print("\n[7] 状态限制与权限")
# ⚠️ 要测「已截止不可改表单」,必须先把任务推到「收集中」:
# 草稿是**不能直接截止的**(close 只允许 收集中 / 已暂停)——
# 这正是发布前置校验存在的意义:没配齐就发布,学生填了也没人审。
# 所以这里用 SQL 夹具把发布前置的三样备齐。
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')")
sql(f"INSERT INTO collect_material_def (tenant_id, task_id, material_code, material_name, required) "
f"VALUES ({tenant_id}, {task_id}, 'IDCARD', '身份证', 1)")
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("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
"baseVersion": 2, "fields": fields2, "extras": [],
})
check("收集中允许改表单(§3.5 说「已发布任务改表单必须递增」,说明是允许改的)",
body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/close", token, {})
check("把任务截止", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", token, {
"baseVersion": 3, "fields": fields2, "extras": [],
})
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}/form-schema", token)
check("已截止仍可读表单(只读不等于看不见)", body.get("code") == 0, f"code={body.get('code')}")
ht_user = f"htf{suffix}"
status, body = call("POST", f"{base}/api/v1/users", token, {
"username": ht_user, "realName": f"E2E表单班主任{suffix}",
"phone": "13900139401", "roleCodes": ["HEAD_TEACHER"], "userType": 3,
})
if check("造班主任账号", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"):
uid, pwd = body["data"]["id"], body["data"]["initialPassword"]
CREATED_USER_IDS.append(uid)
status, body = login(base, ht_user, pwd, args.tenant)
ht_token = body["data"]["token"]
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/form-schema", ht_token)
check("班主任能读表单定义", body.get("code") == 0, f"code={body.get('code')}")
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/form-schema", ht_token, {
"baseVersion": 2, "fields": fields2, "extras": [],
})
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)