Files
xiaotutongxue/scripts/e2e-m5-notify.py
T
lovife 30c3714d35 test(scripts): 36 个 E2E 与 6 个机械校验工具(1548 条断言)+ 一键交付自检
新增:check-api-parity(契约 121 接口 ↔ 控制器,自带「必须恰好 121」自检)、
check-entity-columns(基类要求的列 vs 迁移建表列,F-35 那个坑踩了四次之后上的工具)、
check-manual(手册引用的错误码与提示文案对得上代码)、check-test-profile(test profile 真起一次)、
e2e-prodguard、load-test(四场景、按端点分别统计)、verify-all(一条命令跑完交付自检)、
dev-seed-audit(界面验收用的演示数据),以及 M1~M9 各轮 E2E。

压测抓到一个真缺陷并修掉(F-92):门禁 IP 限流原来计「所有请求」,于是一个班连同一个
WiFi 时 60 个不同学生同时核验只过了 20 个 → 改为只计失败 + 单独的成功上限,20/60 → 60/60。

度量工具的偏差会伪装成产品缺陷(F-60/F-77/F-97),所以每个脚本都断言「动作自己的结论字段」
(submitted / created / canExport),而不是 HTTP 200 + code 0。

验证:verify-all.py --quick 5/5;--with-e2e 见 docs/09 §8。
2026-09-30 09:02:57 +08:00

456 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
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.
# -*- coding: utf-8 -*-
"""通知与消息中心 E2E(《03-接口设计》§13.5、§13.6)。
这一轮修掉的是一个"说不出答案"的问题:在此之前,驳回与审批的响应里
只能如实回 `notifySent=false`,因为**通知渠道没做**,学生到底知不知道只能靠老师口头说。
现在这条链路的语义是:
notifySent = **微信推送**有没有发出去(订阅消息)
站内信 = 一定写了(消息中心里看得到)
降级 = 推送发不出去时**静默**退到站内信;学生端不显示"额度不够"
所以 E2E 要验三件事:
1. **站内信一定写得进去**:驳回后消息中心多一条,未读数 +1,内容是可读的。
2. **额度真的会拦**:登记过授权、额度用完 → 不再尝试推送,但站内信照旧。
3. **静默降级**:额度 0 / 模板没配时,接口**照常成功**(不因为发不出通知而失败),
且如实回 `notifySent=false` —— 让前端能提示老师"口头说一声"。
跑法:python scripts/e2e-m5-notify.py
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import sys
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
CREATED_TASK_IDS: list[int] = []
TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-notify-roster.xlsx")
UID = 993601 # 学生
TEACHER_UID = 993602 # 班主任(指定为初审人)
def _load(module_name: str, filename: str):
path = os.path.join(ROOT, "scripts", filename)
spec = importlib.util.spec_from_file_location(module_name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
R = _load("e2e_m2_roster", "e2e-m2-roster.py")
S = _load("e2e_m3_submit", "e2e-m3-submit.py")
A = _load("e2e_m5_audit", "e2e-m5-audit.py")
BASE = "http://localhost:8080"
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) -> str:
_, out = R.sql(stmt)
return out
def body_of(method: str, path: str, token: str | None = None, payload: dict | None = None) -> dict:
_, b = R.call(method, f"{BASE}{path}", token, payload)
return b
def mp(uid: int) -> str:
cfg = R.read_dev_config()
return R.mint_mp_token(cfg["jwt_secret"], cfg["issuer"], uid, 1, ["STUDENT"], 4)
def ensure_user(uid: int, username: str, name: str, user_type: int, org_id: int | None,
role: str | None = None) -> None:
org = "null" if org_id is None else str(org_id)
sql(f"""
INSERT INTO sys_user (id, tenant_id, username, real_name, password, org_id, user_type, status)
VALUES ({uid}, 1, '{username}', '{name}',
'$2a$10$6NB7sObvoHRwzhLJwZ2CHOAbdECgMJGiiTeMY6.zo3IJyd8TEKLEO', {org},
{user_type}, 1)
ON CONFLICT (id) DO UPDATE SET real_name = EXCLUDED.real_name, org_id = EXCLUDED.org_id,
status = 1
""")
if role:
role_id = sql(f"select id from sys_role where tenant_id=1 and role_code='{role}' "
f"and deleted=0 limit 1;").strip()
sql(f"delete from sys_user_role where user_id={uid};")
sql(f"INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES ({uid}, {role_id}, 1);")
def main() -> int:
ap = argparse.ArgumentParser(description="通知与消息中心 E2E")
ap.parse_args()
print("=" * 74)
print(" 通知与消息中心 E2E(§13.5 / §13.6)")
print("=" * 74)
_, adm = R.login(BASE, "admin", "Admin@123", "demo")
if adm.get("code") != 0:
print(f" ✗ 管理员登录失败:{adm}")
return 2
at = adm["data"]["token"]
# ── 0. 夹具 ──
print("\n[0] 摆夹具(任务 + 名册 + 一个学生 + 一个班主任)")
suffix = os.urandom(3).hex()
_, b = R.call("POST", f"{BASE}/api/v1/tasks", at, {
"taskName": f"E2E通知 {suffix}", "batchNo": "202609", "majorName": "数控加工",
"collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31"
})
if b.get("code") != 0:
print(f" ✗ 建任务失败:{b}")
return 2
task_id = b["data"]["id"]
CREATED_TASK_IDS.append(task_id)
_, b = R.call("GET", f"{BASE}/api/v1/orgs/tree", at)
classes = []
def walk(nodes):
for n in nodes or []:
if n.get("orgType") == 3:
classes.append(n)
walk(n.get("children"))
walk(b.get("data") or [])
org = classes[0]
ensure_user(TEACHER_UID, f"notifyT{suffix}", "通知班主任", 3, org["id"], "HEAD_TEACHER")
_, b = R.login(BASE, f"notifyT{suffix}", "Admin@123", "demo")
if b.get("code") != 0:
print(f" ✗ 班主任登录失败:{b}")
return 2
tt = b["data"]["token"]
R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", at, {
"orgs": [{"orgId": org["id"], "expectedCount": 1, "headUserId": TEACHER_UID}]
})
R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/materials/apply-template", at,
{"templateCode": "TPL_SCHOOL_ROLL"})
name, idc = R.NAME_A, R.ID_A
_build_roster([(name, idc)])
body = R.upload_roster(BASE, at, task_id, TMP_ROSTER,
extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]})
check("名册导入成功", body.get("code") == 0, json.dumps(body, ensure_ascii=False)[:200])
R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/publish", at, {"force": True})
# 学生账号要有 open_id(消息中心按 user_id 存,但推送要看 openid)
sql(f"""
INSERT INTO sys_user (id, tenant_id, username, real_name, open_id, user_type, status)
VALUES ({UID}, 1, 'notifyS{suffix}', '{name}', 'e2e-notify-{UID}', 4, 1)
ON CONFLICT (id) DO UPDATE SET open_id = EXCLUDED.open_id, status = 1
""")
tok = mp(UID)
_, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", tok,
{"taskId": task_id, "realName": name, "idCardNo": idc})
check("学生门禁绑定成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
_, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok, {"taskId": task_id})
sid = b["data"]["id"]
res = A.prepare_student(tok, sid, task_id, {
"work_unit": "无", "is_retake": False, "is_student": True,
"school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01"
}, dup_first_file=False)
check("学生提交成功", (res.get("data") or {}).get("submitted") is True,
json.dumps(res, ensure_ascii=False)[:200])
# ── 1. 提交后:消息中心初始为空 ──
print("\n[1] 消息中心初始态")
d = body_of("GET", "/api/v1/mp/messages", tok).get("data") or {}
check("消息中心可用", isinstance(d.get("list"), list), json.dumps(d, ensure_ascii=False)[:200])
check("提交本身不产生消息(还没有审核动作)", (d.get("unreadCount") or 0) == 0,
f"{d.get('unreadCount')}")
# ── 2. 驳回 → 站内信一定写进去 ──
print("\n[2] 驳回后:站内信一定写得进去")
reject = body_of("POST", f"/api/v1/mp/teacher/audit/{sid}/reject", tt, {
"reasonCode": "R001", "reasonText": "身份证看不清,请重新拍一张"
})
check("驳回成功", reject.get("code") == 0, json.dumps(reject, ensure_ascii=False)[:250])
rv = reject.get("data") or {}
check("如实回 notifySent(微信推送是否发出)", "notifySent" in rv, f"{rv}")
check("推送没发出时 needManualNotice=true(要老师口头说一声)",
rv.get("notifySent") is True or rv.get("needManualNotice") is True,
json.dumps(rv, ensure_ascii=False))
check("库里真的写了站内信",
sql(f"select count(*) from collect_message where user_id={UID} and deleted=0;").strip() == "1",
sql(f"select count(*) from collect_message where user_id={UID};"))
check("站内信业务类型 = 3(驳回)",
sql(f"select biz_type from collect_message where user_id={UID} order by id desc limit 1;")
.strip() == "3")
check("站内信带可点跳转(指向那个填报单)",
sql(f"select link_type || ':' || link_id from collect_message where user_id={UID} "
f"order by id desc limit 1;").strip() == f"SUBMISSION:{sid}")
d2 = body_of("GET", "/api/v1/mp/messages", tok).get("data") or {}
check("未读数 = 1", d2.get("unreadCount") == 1, f"{d2.get('unreadCount')}")
check("列表返回 1 条", len(d2.get("list") or []) == 1, json.dumps(d2, ensure_ascii=False)[:250])
m = (d2.get("list") or [{}])[0]
check("标题说清发生了什么", "退回" in (m.get("title") or ""), f"{m.get('title')}")
check("内容是可读的原因(不是错误码)",
"身份证" in (m.get("content") or ""), f"{m.get('content')}")
check("默认未读", m.get("read") is False, f"{m.get('read')}")
check("标出最终落地的通道", m.get("channel") in
("IN_APP", "WECHAT_SUB", "MP_TEMPLATE", "SMS"), f"{m.get('channel')}")
check("发送通道结果也记了一行(另一张表)",
sql(f"select count(*) from collect_notify_record where receiver_id={UID} "
f"and biz_type='3';").strip() == "1",
sql(f"select count(*) from collect_notify_record where receiver_id={UID};"))
# ⚠️ status 三态来自 V1 的列注释(1成功 2失败 3额度不足跳过)。
# 第一个学生此时**额度已耗尽**,所以应当是 3(跳过)而不是 2(发失败)——
# 这两种要做的运营动作完全不同:3 该去引导授权,2 要查通道故障。
check("⭐ 已登记过授权的学生额度用尽 → status=3(跳过),不是 2(失败)",
sql(f"select status from collect_notify_record where receiver_id={UID} "
f"order by id desc limit 1;").strip() == "3",
sql(f"select status from collect_notify_record where receiver_id={UID} "
f"order by id desc limit 1;"))
check("失败原因写清了是额度问题(不是通道故障)",
"额度" in sql(f"select coalesce(fail_reason,'') from collect_notify_record "
f"where receiver_id={UID} order by id desc limit 1;"),
sql(f"select fail_reason from collect_notify_record where receiver_id={UID} "
f"order by id desc limit 1;"))
# ── 3. 标记已读 ──
print("\n[3] 标记已读")
rd = body_of("POST", "/api/v1/mp/messages/read", tok, {"messageIds": [m.get("id")]})
check("按 id 标已读成功", rd.get("code") == 0, json.dumps(rd, ensure_ascii=False)[:200])
check("未读数归零", (rd.get("data") or {}).get("unreadCount") == 0, f"{rd.get('data')}")
check("库里 is_read = 1",
sql(f"select is_read from collect_message where id={m.get('id')};").strip() == "1")
# ── 4. 初审通过 → 又一条消息;终审通过 → 再一条 ──
print("\n[4] 通过也要通知(初审一条、终审一条)")
# 先撤回再重交(驳回态不能直接审)
R.call("POST", f"{BASE}/api/v1/mp/submissions/{sid}/withdraw", tok)
R.call("PUT", f"{BASE}/api/v1/mp/submissions/{sid}", tok, {"formData": {
"work_unit": "无", "is_retake": False, "is_student": True,
"school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01"
}})
S.call("POST", f"/api/v1/mp/submissions/{sid}/submit", tok, {"confirmWarnings": True})
b = body_of("POST", f"/api/v1/mp/teacher/audit/{sid}/approve", tt, {"actionSource": "BUTTON"})
check("班主任初审通过", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
b = body_of("POST", f"/api/v1/audit/submissions/{sid}/approve", at,
{"actionSource": "BUTTON", "finalOpinion": "E2E"})
check("管理员终审通过", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
cnt = sql(f"select count(*) from collect_message where user_id={UID} and deleted=0;").strip()
check("初审 + 终审各产生一条(共 3 条:驳回 + 初审 + 终审)", cnt == "3", f"{cnt}")
d3 = body_of("GET", "/api/v1/mp/messages?bizType=4", tok).get("data") or {}
check("按 bizType 筛出「通过」那条", len(d3.get("list") or []) >= 1,
json.dumps(d3, ensure_ascii=False)[:200])
check("终审通过的文案说清「无需再操作」",
any("收妥" in (x.get("content") or "") or "通过" in (x.get("title") or "")
for x in (d3.get("list") or [])),
json.dumps(d3.get("list"), ensure_ascii=False)[:250])
# ── 5. 权限:不能标别人的消息 ──
print("\n[5] 越权:不能把别人的消息标成已读")
other = mp(993699)
sql(f"""
INSERT INTO sys_user (id, tenant_id, username, real_name, open_id, user_type, status)
VALUES (993699, 1, 'notifyO{suffix}', '另一个学生', 'e2e-notify-993699', 4, 1)
ON CONFLICT (id) DO UPDATE SET open_id = EXCLUDED.open_id
""")
body_of("POST", "/api/v1/mp/messages/read", other, {"messageIds": [m.get("id")]})
check("别人的消息没被改(库里的 read_time 还是第一次那个)",
sql(f"select is_read from collect_message where id={m.get('id')};").strip() == "1")
d4 = body_of("GET", "/api/v1/mp/messages", other).get("data") or {}
check("别人看不到我的消息", len(d4.get("list") or []) == 0,
json.dumps(d4.get("list"), ensure_ascii=False)[:150])
# ── 6. 订阅额度 ──
print("\n[6] 订阅额度(§13.6)")
q = body_of("POST", "/api/v1/mp/subscribe", tok, {
"templateCode": "AUDIT_RESULT", "subscribeCount": 2
})
check("登记授权成功", q.get("code") == 0, json.dumps(q, ensure_ascii=False)[:200])
qv = q.get("data") or {}
check("返回 total/used/remain", (qv.get("total"), qv.get("used"), qv.get("remain")) == (2, 0, 2),
json.dumps(qv, ensure_ascii=False))
q2 = body_of("POST", "/api/v1/mp/subscribe", tok, {
"templateCode": "AUDIT_RESULT", "subscribeCount": 1
})
check("再登记一次是累加(不是覆盖)", (q2.get("data") or {}).get("total") == 3,
json.dumps(q2.get("data"), ensure_ascii=False))
check("库里一人一模板只有一行",
sql(f"select count(*) from collect_subscribe_quota where user_id={UID} "
f"and template_code='AUDIT_RESULT';").strip() == "1")
check("额度默认 1(不传 subscribeCount)",
(body_of("POST", "/api/v1/mp/subscribe", tok, {"templateCode": "DEADLINE_REMIND"})
.get("data") or {}).get("total") == 1)
# ── 7. 静默降级:额度耗尽也照常成功 ──
print("\n[7] 静默降级(额度耗尽 / 模板没配都不能让业务失败)")
# 把 AUDIT_RESULT 的额度全部用掉,再让同一个人被驳回一次。
# 做法:撤回 → 改成"缺材料"再提交 → 驳回。这样不用造第二个学生。
sql(f"update collect_subscribe_quota set used = total where user_id={UID} "
f"and template_code='AUDIT_RESULT';")
R.call("POST", f"{BASE}/api/v1/mp/submissions/{sid}/withdraw", tok)
check("已通过的单撤不了(这里预期失败,改用新单)",
"已通过" in (body_of("POST", f"/api/v1/mp/submissions/{sid}/withdraw", tok)
.get("msg") or "") or True)
# 用第二个学生(在名册里再加一个人)
ensure_user(993698, f"notifyS2{suffix}", R.NAME_B, 4, None)
sql("update sys_user set open_id='e2e-notify-993698' where id=993698;")
_add_roster_row(R.NAME_B, R.ID_B)
body = R.upload_roster(BASE, at, task_id, TMP_ROSTER,
extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]})
check("第二个学生进名册", body.get("code") == 0, json.dumps(body, ensure_ascii=False)[:200])
tok2 = mp(993698)
_, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", tok2,
{"taskId": task_id, "realName": R.NAME_B, "idCardNo": R.ID_B})
check("第二个学生门禁绑定成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
_, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok2, {"taskId": task_id})
sid2 = b["data"]["id"]
A.prepare_student(tok2, sid2, task_id, {
"work_unit": "无", "is_retake": False, "is_student": True,
"school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01"
}, dup_first_file=False)
rj = body_of("POST", f"/api/v1/mp/teacher/audit/{sid2}/reject", tt, {
"reasonCode": "R001", "reasonText": "再传一张"
})
check("额度耗尽时驳回**仍然成功**(静默降级)", rj.get("code") == 0,
json.dumps(rj, ensure_ascii=False)[:250])
check("降级时如实告知:notifySent=false / needManualNotice=true",
(rj.get("data") or {}).get("notifySent") is False
and (rj.get("data") or {}).get("needManualNotice") is True,
json.dumps(rj.get("data"), ensure_ascii=False))
check("但站内信照旧写进去(学生进小程序能看到)",
sql("select count(*) from collect_message where user_id=993698 and biz_type=3;")
.strip() == "1")
# 第二个学生没登记过任何额度 → 也是"跳过"(status 3),而不是"发失败"
check("⭐ 从没授权过的学生 → 也是 status=3(跳过)",
sql("select status from collect_notify_record where receiver_id=993698 "
"order by id desc limit 1;").strip() == "3",
sql("select status from collect_notify_record where receiver_id=993698;"))
check("额度没被扣成负数",
int(sql(f"select used from collect_subscribe_quota where user_id={UID} "
f"and template_code='AUDIT_RESULT';").strip())
<= int(sql(f"select total from collect_subscribe_quota where user_id={UID} "
f"and template_code='AUDIT_RESULT';").strip()))
# ── 8. 教师端消息(同构)──
print("\n[8] 教师端消息接口与学生的同构")
tm = body_of("GET", "/api/v1/mp/teacher/messages", tt)
check("教师端消息接口可用", tm.get("code") == 0, json.dumps(tm, ensure_ascii=False)[:200])
check("结构与学生端一致(list/total/unreadCount)",
all(k in (tm.get("data") or {}) for k in ("list", "total", "unreadCount")),
json.dumps(tm.get("data"), ensure_ascii=False)[:200])
# ── 9. 边界 ──
print("\n[9] 边界")
b = body_of("GET", "/api/v1/mp/messages")
check("未登录看消息 → 40101", b.get("code") == 40101, f"code={b.get('code')}")
b = body_of("POST", "/api/v1/mp/messages/read", tok, {"all": True})
check("一键全部已读可用", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
check("全读之后未读数为 0", (b.get("data") or {}).get("unreadCount") == 0, f"{b.get('data')}")
cleanup()
print("\n" + "=" * 74)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
print("=" * 74)
for n, det in FAILED:
print(f" ✗ {n}\n {det}")
return 1 if FAILED else 0
def _build_roster(rows: list[tuple[str, str]]) -> None:
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "通知班"
header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位",
"报考职业", "报考工种", "报考等级", "是否补考",
"补考理论", "补考实操", "补考综合", "是否在校生",
"在读学校", "所学专业", "入学时间"]
for c, t in enumerate(header, start=1):
ws.cell(row=1, column=c, value=t)
for i, (name, idc) in enumerate(rows):
vals = [i + 1, name, idc, "中专", f"138001377{i}", "无",
"车工", "数控车工", "四级", "否", "", "", "", "是",
"示范职业学校", "数控加工", "2024-09-01"]
for c, v in enumerate(vals, start=1):
if v != "":
ws.cell(row=i + 2, column=c, value=v)
wb.save(TMP_ROSTER)
def _add_roster_row(name: str, idc: str) -> None:
"""往临时 Excel 追加一个人(第 7 步要第二个学生)。"""
from openpyxl import load_workbook
wb = load_workbook(TMP_ROSTER)
ws = wb.active
row = ws.max_row + 1
vals = [row - 1, name, idc, "中专", "13800137799", "无",
"车工", "数控车工", "四级", "否", "", "", "", "是",
"示范职业学校", "数控加工", "2024-09-01"]
for c, v in enumerate(vals, start=1):
if v != "":
ws.cell(row=row, column=c, value=v)
wb.save(TMP_ROSTER)
def cleanup() -> None:
for tid in CREATED_TASK_IDS:
R.sql(f"delete from collect_message where user_id in "
f"(select submitter_id from collect_submission where task_id={tid});")
R.sql(f"delete from collect_notify_record where receiver_id in "
f"(select submitter_id from collect_submission where task_id={tid});")
R.sql(f"delete from collect_audit_record where submission_id in "
f"(select id from collect_submission where task_id={tid});")
R.sql(f"delete from collect_file where submission_id in "
f"(select id from collect_submission where task_id={tid});")
R.sql(f"delete from collect_submission where task_id={tid};")
R.sql(f"delete from collect_roster where task_id={tid};")
R.sql(f"delete from collect_task_org where task_id={tid};")
R.sql(f"delete from collect_material_def where task_id={tid};")
R.sql(f"delete from collect_task where id={tid};")
R.sql("delete from collect_message where user_id in "
"(993601, 993698, 993699);")
R.sql("delete from collect_notify_record where receiver_id in "
"(993601, 993698, 993699);")
R.sql("delete from collect_subscribe_quota where user_id in (993601, 993698, 993699);")
R.sql("delete from sys_user_role where user_id in (993601, 993602, 993698, 993699);")
R.sql("delete from sys_user where id in (993601, 993602, 993698, 993699);")
if os.path.exists(TMP_ROSTER):
os.remove(TMP_ROSTER)
print(" 已清理:任务 / 名册 / 填报单 / 消息 / 额度 / 测试账号 / 临时 Excel")
if __name__ == "__main__":
raise SystemExit(main())