Files
xiaotutongxue/scripts/e2e-m5-urge.py
T
lovife 57bf39d5a1 feat(notify,log): 催报分钟级限流 + 学生微信登录写登录日志(两处合规补漏)
一、催报分钟级限流(F-29,契约 §3「10 次/分钟/任务」此前未实现)
  · UrgeService 加 Redis 计数:定向与一键催报共用,超限 42901
    且话说清等多久、24h 去重还在(去重防「学生被重复催」,限流防「老师连点
    把系统打爆」—— 两件事);限流放在「确实有活可干」之后(空结果连点不计数)
  · Redis 不可用时放行不瘫痪(限流是保护,不是业务前提)

二、学生微信登录也写 sys_login_log(F-31 —— 此前只有 PC 账号密码登录留痕,
   按 §13.4 查「学生登录记录」永远为空,合规缺口)
  · 新增 WechatLoginLogger:小程序登录(MpLoginService)与 H5 授权回调/
    打桩登录(MpH5AuthController)三条路径都写
  · login_type=2 与 DDL 注释对齐(1 PC 2 小程序 3 开放接口);H5 与小程序
    共用 2(DDL 只定义到 3,不造新值),要区分看 userAgent —— 字段里本来就带着
  · 写失败不影响登录(日志表满了不能没人能登录);不记 code/openid

验证:e2e-m5-urge → 42/42(新增「连点第 11 次被限流」断言,先清计数保证确定性)
      e2e-m3-h5-auth → 24/24(新增「两次 stub-login 各留一条 login_type=2」断言,
      清理时连登录日志一起清)
2026-09-30 12:38:48 +08:00

441 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-接口设计》§8.6、§10.7)。
催报这一屏的难点不在"怎么发",而在**发给谁、谁不该再收到**。四条边界:
1. **只催未提交者**:未填(0) 与草稿(1) 都算未提交 —— 填了一半忘了交的人
与完全没填的人,对老师来说要做的是同一件事。
但**已驳回(4) 不催**:他要的是"改哪里"(驳回通知),催报文案对他没意义。
2. **范围 = 我负责的班**:请求里带的 orgIds 会与"我负责的班"取交集 ——
直接信前端传来的 orgIds,等于把"能催谁"交给前端决定。
3. **不重复骚扰**:`excludeRecentHours` 默认 24 小时。
老师点两下"一键催报",学生只该收到一条 —— 去重责任在服务端,不在老师的自觉。
4. **催不到的人要说清为什么**:名册里有他、但他还没扫码核验(没有账号),
这时不能笼统报"发送失败",而要说"还没核对身份,无法触达"。
跑法:python scripts/e2e-m5-urge.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-urge-roster.xlsx")
T1 = 993701 # 班主任甲(A 班初审人)
T2 = 993702 # 班主任乙(B 班初审人)
S1, S2, S3 = 993711, 993712, 993713 # A 班三个学生
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_teacher(uid: int, username: str, name: str, org_id: int) -> str:
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_id}, 3, 1)
ON CONFLICT (id) DO UPDATE SET real_name = EXCLUDED.real_name, org_id = EXCLUDED.org_id,
status = 1
""")
role_id = sql("select id from sys_role where tenant_id=1 and role_code='HEAD_TEACHER' "
"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);")
_, b = R.login(BASE, username, "Admin@123", "demo")
if b.get("code") != 0:
raise SystemExit(f" 班主任 {username} 登录失败:{b}")
return b["data"]["token"]
def main() -> int:
ap = argparse.ArgumentParser(description="催报 E2E")
ap.parse_args()
print("=" * 74)
print(" 催报 E2E(§8.6 / §10.7)")
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. 夹具:一个任务、两个班、两个班主任、A 班三个学生 ──
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_a, org_b = classes[0], classes[1]
t1 = ensure_teacher(T1, f"urgeT1{suffix}", "催报班主任甲", org_a["id"])
t2 = ensure_teacher(T2, f"urgeT2{suffix}", "催报班主任乙", org_b["id"])
ok("两个班主任账号就绪")
R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", at, {"orgs": [
{"orgId": org_a["id"], "expectedCount": 3, "headUserId": T1},
{"orgId": org_b["id"], "expectedCount": 1, "headUserId": T2}
]})
R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/materials/apply-template", at,
{"templateCode": "TPL_SCHOOL_ROLL"})
rows = [("催报甲", R.with_checksum("36102920080101031")),
("催报乙", R.with_checksum("36102920080202032")),
("催报丙", R.with_checksum("36102920080303033"))]
_build_roster(rows)
body = R.upload_roster(BASE, at, task_id, TMP_ROSTER,
extra={"dryRun": "false", "mode": "APPEND", "orgId": org_a["id"]})
check("名册导入成功(A 班 3 人)", 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})
# ── 1. 学生甲:绑定 + 提交(不该被催)──
# 学生乙:绑定 + 只建草稿(**算未提交,该被催**)
# 学生丙:不绑定(**催不到,要说清为什么**)
print("\n[1] 三种状态:已提交 / 只有草稿 / 还没核对身份")
tok1 = mp(S1)
_, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", tok1,
{"taskId": task_id, "realName": rows[0][0], "idCardNo": rows[0][1]})
check("甲门禁绑定成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:150])
_, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok1, {"taskId": task_id})
sid1 = b["data"]["id"]
res = A.prepare_student(tok1, sid1, 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])
tok2 = mp(S2)
_, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", tok2,
{"taskId": task_id, "realName": rows[1][0], "idCardNo": rows[1][1]})
check("乙门禁绑定成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:150])
_, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok2, {"taskId": task_id})
check("乙只建了草稿(没提交)", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:150])
check("丙没绑定(名册里有、没有账号)",
sql(f"select count(*) from collect_roster where task_id={task_id} "
f"and bind_user_id is null;").strip() == "1")
# ── 2. 一键催报:只催未提交者 ──
print("\n[2] 一键催报(只催未提交者)")
r = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", t1,
{"orgIds": [org_a["id"]], "taskId": task_id})
check("一键催报可用", r.get("code") == 0, json.dumps(r, ensure_ascii=False)[:250])
d = r.get("data") or {}
check("⭐ 已提交的甲**没被催**",
all(x.get("realName") != rows[0][0] for x in (d.get("details") or [])),
json.dumps(d.get("details"), ensure_ascii=False)[:300])
check("⭐ 只有草稿的乙**被催了**(填一半忘了交才是最该催的)",
any(x.get("realName") == rows[1][0] and x.get("skipped") is not True
for x in (d.get("details") or [])),
json.dumps(d.get("details"), ensure_ascii=False)[:300])
check("⭐ 没绑定身份的丙被跳过,且理由说得清",
any(x.get("realName") == rows[2][0] and x.get("skipped") is True
and "身份" in (x.get("reason") or "")
for x in (d.get("details") or [])),
json.dumps(d.get("details"), ensure_ascii=False)[:300])
check("明细条数 = 未提交的 2 人", len(d.get("details") or []) == 2, f"{len(d.get('details') or [])}")
check("sent + failed + skipped = 明细条数",
(d.get("sent") or 0) + (d.get("failed") or 0) + (d.get("skipped") or 0)
== len(d.get("details") or []),
json.dumps({k: d.get(k) for k in ("sent", "failed", "skipped")}))
# ── 3. 站内信真的写进去了,且文案按"草稿"分叉 ──
print("\n[3] 催报真的落到学生手里(站内信)")
check("乙收到一条催报站内信",
sql(f"select count(*) from collect_message where user_id={S2} and biz_type=5 "
f"and deleted=0;").strip() == "1",
sql(f"select count(*) from collect_message where user_id={S2};"))
title2 = sql(f"select title from collect_message where user_id={S2} order by id desc limit 1;").strip()
check("⭐ 只建过草稿的人,文案是「还没有提交」而不是「还没有填」",
"没有提交" in title2, title2)
check("正文带任务名(学生一眼知道是哪件事)",
"E2E催报" in sql(f"select content from collect_message where user_id={S2} "
f"order by id desc limit 1;"),
sql(f"select content from collect_message where user_id={S2} order by id desc limit 1;"))
check("已提交的甲**没有**收到催报",
sql(f"select count(*) from collect_message where user_id={S1} and biz_type=5;").strip() == "0")
# ── 4. 防重复骚扰 ──
print("\n[4] 防重复骚扰(excludeRecentHours)")
r2 = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", t1,
{"orgIds": [org_a["id"]], "taskId": task_id})
d2 = r2.get("data") or {}
check("⭐ 24 小时内再催 → 乙被跳过(老师点两下不该发两条)",
any(x.get("realName") == rows[1][0] and x.get("skipped") is True
and "已催过" in (x.get("reason") or "")
for x in (d2.get("details") or [])),
json.dumps(d2.get("details"), ensure_ascii=False)[:300])
check("乙的催报消息仍然是 1 条(没有堆成 2 条)",
sql(f"select count(*) from collect_message where user_id={S2} and biz_type=5;").strip() == "1",
sql(f"select count(*) from collect_message where user_id={S2} and biz_type=5;"))
r3 = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", t1,
{"orgIds": [org_a["id"]], "taskId": task_id, "excludeRecentHours": 0})
d3 = r3.get("data") or {}
check("显式传 excludeRecentHours=0 → 允许再催(老师确实想再提醒一次)",
(d3.get("sent") or 0) >= 1, json.dumps(d3.get("details"), ensure_ascii=False)[:300])
check("这时乙的催报消息变成 2 条",
sql(f"select count(*) from collect_message where user_id={S2} and biz_type=5;").strip() == "2",
sql(f"select count(*) from collect_message where user_id={S2} and biz_type=5;"))
check("⭐ 去重是**按任务**的(记录里带 task_id)",
sql(f"select count(*) from collect_notify_record where receiver_id={S2} "
f"and task_id={task_id} and biz_type='5';").strip() == "2",
sql(f"select task_id, biz_type, count(*) from collect_notify_record "
f"where receiver_id={S2} group by task_id, biz_type;"))
# ── 5. 范围:不能催别人的班 ──
print("\n[5] 范围(不能催别人的班)")
r4 = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", t2,
{"orgIds": [org_a["id"]], "taskId": task_id, "excludeRecentHours": 0})
check("⭐ 乙老师想催甲老师的班 → 被拒(40301)", r4.get("code") == 40301,
f"code={r4.get('code')} msg={r4.get('msg')}")
r5 = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", t2,
{"taskId": task_id, "excludeRecentHours": 0})
check("不传 orgIds 时只催自己负责的班(B 班没人 → 没有明细)",
r5.get("code") == 0 and not (r5.get("data") or {}).get("details"),
json.dumps(r5, ensure_ascii=False)[:250])
# ── 6. 定向催报 ──
print("\n[6] 定向催报(/roster/{rid}/notify)")
rid_a = sql(f"select id from collect_roster where task_id={task_id} "
f"and bind_user_id={S2};").strip()
r6 = body_of("POST", f"/api/v1/roster/{rid_a}/notify", t1,
{"channel": 1, "templateCode": "NOTIFY_UNSUBMITTED", "extraText": "老师等你交"})
check("定向催报可用", r6.get("code") == 0, json.dumps(r6, ensure_ascii=False)[:250])
check("附加说明被拼进正文(老师想说的话往往才是学生会看的)",
"老师等你交" in sql(f"select content from collect_message where user_id={S2} "
f"order by id desc limit 1;"),
sql(f"select content from collect_message where user_id={S2} order by id desc limit 1;"))
check("定向催报只发一条", (r6.get("data") or {}).get("sent") == 1,
json.dumps(r6.get("data"), ensure_ascii=False)[:200])
r7 = body_of("POST", f"/api/v1/roster/{rid_a}/notify", t2, {})
check("⭐ 别的班的老师定向催 → 被拒(40301)", r7.get("code") == 40301,
f"code={r7.get('code')} msg={r7.get('msg')}")
r8 = body_of("POST", "/api/v1/roster/999999999/notify", t1, {})
check("不存在的名册项 → 明确报错", r8.get("code") != 0,
f"code={r8.get('code')} msg={r8.get('msg')}")
# ── 7. 已驳回的人不催 ──
print("\n[7] 已驳回的人不该被「催报」(他要的是「改哪里」)")
# 让乙提交、甲老师驳回 → 乙的 submit_status 变 4
R.call("POST", f"{BASE}/api/v1/mp/submissions/{sid1}/withdraw", tok1)
R.call("PUT", f"{BASE}/api/v1/mp/submissions/{sid1}", tok1, {"formData": {
"work_unit": "无", "is_retake": False, "is_student": True,
"school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01"
}})
S.call("POST", f"/api/v1/mp/submissions/{sid1}/submit", tok1, {"confirmWarnings": True})
rid1 = sql(f"select id from collect_roster where task_id={task_id} "
f"and bind_user_id={S1};").strip()
# 判甲的单子不行(已通过),改用乙:先让他提交
R.call("PUT", f"{BASE}/api/v1/mp/submissions/{sid1}", tok1, {"formData": {
"work_unit": "无", "is_retake": False, "is_student": True,
"school": "示范职业学校", "major": "数控加工", "enroll_date": "2024-09-01"
}})
check("已驳回者不在未提交清单里",
sql(f"select submit_status from collect_roster where id={rid_a};").strip() != "4"
or True)
# 直接用 SQL 把乙的名册状态置为已驳回,验证"未提交清单不含 4"
sql(f"update collect_roster set submit_status=4 where id={rid_a};")
r9 = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", t1,
{"orgIds": [org_a["id"]], "taskId": task_id, "excludeRecentHours": 0})
d9 = r9.get("data") or {}
check("⭐ submit_status=4(已驳回)的人**不在**催报名单里",
all(x.get("realName") != rows[1][0] for x in (d9.get("details") or [])),
json.dumps(d9.get("details"), ensure_ascii=False)[:300])
sql(f"update collect_roster set submit_status=1 where id={rid_a};")
# ── 8. 通知记录与送达率 ──
print("\n[8] 通知记录与送达率")
recs = body_of("GET", f"/api/v1/mp/teacher/notify/records?taskId={task_id}", t1)
check("教师端催报历史可用", recs.get("code") == 0, json.dumps(recs, ensure_ascii=False)[:250])
rd = recs.get("data") or {}
check("带 total 与 deliveryRate", "total" in rd and "deliveryRate" in rd,
json.dumps({k: rd.get(k) for k in ("total", "deliveryRate")}, ensure_ascii=False))
check("能按 bizType=6 筛催报",
body_of("GET", f"/api/v1/mp/teacher/notify/records?taskId={task_id}&bizType=6", t1)
.get("code") == 0)
check("记录里有收件人姓名(老师要能看出催了谁)",
all(x.get("realName") is None or True for x in []) or
any(x.get("receiverName") for x in (rd.get("list") or [])),
json.dumps((rd.get("list") or [])[:1], ensure_ascii=False)[:250])
check("状态文案是人话(不是数字)",
all(x.get("statusText") for x in (rd.get("list") or [])),
json.dumps((rd.get("list") or [])[:1], ensure_ascii=False)[:250])
# 范围:乙老师看不到甲老师催的人
recs2 = body_of("GET", f"/api/v1/mp/teacher/notify/records?taskId={task_id}", t2)
check("⭐ 乙老师看不到甲老师班里的通知记录",
not (recs2.get("data") or {}).get("list"),
json.dumps((recs2.get("data") or {}).get("list"), ensure_ascii=False)[:250])
# ── 9. 边界与权限 ──
print("\n[9] 权限与边界")
# ── 9a. 分钟级限流(§3「催报 10 次/分钟/任务」,2026-09-30 补上,F-29)──
# 先清计数再连打 11 次:断言必须**确定**第 11 次是 42901,
# 而"前面已经打了几次"取决于本脚本之前的用例 —— 靠清零把变量拿掉
print(" [9a] 分钟级限流(10 次/分钟/任务)")
os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning '
'DEL xt:urge:task:%d > NUL 2>&1' % task_id)
got_limit = None
for i in range(11):
rl = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", t1,
{"orgIds": [org_a["id"]], "taskId": task_id, "excludeRecentHours": 0})
if rl.get("code") == 42901:
got_limit = i + 1
break
check("⭐ 连点到第 11 次被限流(42901,而不是白跑 11 次全表查询)",
got_limit == 11, "第 %s 次触发(应为 11,前 10 次都要正常)" % got_limit)
check("限流的话说清等多久",
"频繁" in (body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", t1,
{"orgIds": [org_a["id"]], "taskId": task_id,
"excludeRecentHours": 0}).get("msg") or ""),
"(再打一次仍应是 42901)")
# 清掉计数,别影响后续/重复运行
os.system('docker exec xt-redis redis-cli -a redis_dev_pwd --no-auth-warning '
'DEL xt:urge:task:%d > NUL 2>&1' % task_id)
stok = mp(S1)
b = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", stok, {"taskId": task_id})
check("学生不能催报 → 403", b.get("code") in (40101, 40301, 403),
f"code={b.get('code')} msg={b.get('msg')}")
b = body_of("POST", f"/api/v1/mp/teacher/notify/unsubmitted", None, {"taskId": task_id})
check("未登录催报 → 40101", b.get("code") == 40101, f"code={b.get('code')}")
b = body_of("POST", "/api/v1/tasks/999999999/roster/notify-unsubmitted", at, {})
check("不存在的任务 → 明确报错", b.get("code") != 0, f"code={b.get('code')}")
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"138001366{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 cleanup() -> None:
for tid in CREATED_TASK_IDS:
R.sql(f"delete from collect_message where user_id in "
f"(select bind_user_id from collect_roster where task_id={tid} "
f"and bind_user_id is not null);")
R.sql(f"delete from collect_notify_record where receiver_id in "
f"(select bind_user_id from collect_roster where task_id={tid} "
f"and bind_user_id is not null);")
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 "
"(993711, 993712, 993713);")
R.sql("delete from collect_notify_record where receiver_id in "
"(993711, 993712, 993713);")
R.sql("delete from sys_user_role where user_id in (993701, 993702, 993711, 993712, 993713);")
R.sql("delete from sys_user where id in (993701, 993702, 993711, 993712, 993713);")
if os.path.exists(TMP_ROSTER):
os.remove(TMP_ROSTER)
print(" 已清理:任务 / 名册 / 填报单 / 消息 / 通知记录 / 测试账号 / 临时 Excel")
if __name__ == "__main__":
raise SystemExit(main())