# -*- 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())