30c3714d35
新增: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。
456 lines
22 KiB
Python
456 lines
22 KiB
Python
# -*- 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())
|