feat(notify): 通知接线检查 + 修 F-101~F-111(模板对自动通知生效 / 催报真的会发 / 该发的通知都发了)

起点:F-1/F-31/F-50/F-52 是同一个形状 —— 每处单看都对,编译/lint/review 全不红,只有从入口走一遍才暴露。先把这一类固化成命令,再按输出逐条修。

新增 scripts/check-wiring.py:权限码有没有地方校验、错误码有没有被引用、站内信模板有没有发送点、bizType 有没有被用过、菜单键 ↔ PC 端 XtMenu 双向核对。有意不接线的写进脚本上方例外表(带理由)。进 verify-all(第 5 项)与 CI 的 check 阶段。检查器自身修了三次假阳性:只数 @RequirePerm 漏掉服务层校验、按标识符数引用被同名常量骗过、注释里的提及被当成接线(改为先去 Java 注释再数)。

F-101 自动通知改走可配置模板(FR-1002 原来只兑现了手工发送那一半):新增 NotifyTextResolver(单向依赖避开循环注入),库里改过的 > 内置;模板停用 = 这类通知不发且如实回 notifySent=false。F-102 加入申请三处通知(提交→审批人、通过/驳回→学生)。F-103 导出完成/失败通知发起人,正文不放签名 URL(模板里原本写着 {下载链接},一并改掉)。F-104 自动催报从来没发出过任何一条(end_time = date::date 与库里的 23:59:59 永不相等,测试夹具用 SQL 摆的日期型恰好能匹配)+ 补 FR-1003 的「截止当日」。F-105 驳回达上限通知管理员(契约与注释都写了,代码只有一行日志)。F-106 字典/通知记录两个菜单没有任何角色持有(手册还写着去那里办)。F-107 6 个权限码挂到自己的接口。F-109 Map.of 不接受 null → 通知被吞成一行 warn。F-110/F-111 测试自身的两个坑。

验证:新增 e2e-m5-notify-wiring.py 43 条(改模板→真驳回→断言学生收到改过的那句、停用→一条不发、申请/审批/导出/催报/升级/菜单/权限各一节);verify-all --with-e2e → 9 项全过 0 跳过,38 个脚本 / 1620 条断言 / 0 失败。
This commit is contained in:
2026-10-08 14:46:11 +08:00
parent 4a5790d900
commit 9e78e1d239
28 changed files with 1800 additions and 90 deletions
+521
View File
@@ -0,0 +1,521 @@
# -*- coding: utf-8 -*-
"""通知接线 E2E(《08-完成度盘点》F-101 ~ F-107)。
这个脚本验的不是"某个接口返回 200",而是**"配置改了之后,系统真的按新配置做事"** ——
也就是本轮修掉的那一类"看起来有、其实没用":
| 编号 | 原来是什么样 | 这个脚本怎么证明它现在真的生效 |
|---|---|---|
| F-101 | 「通知模板」页能改文案,但**只有手工发送走模板**;驳回/通过/催报这些自动通知把文案硬编码在 Java 里 | 改掉「驳回通知」→ 触发一次真驳回 → 断言学生收到的是**改过的那句** |
| F-101b | 模板停用开关对自动通知没有任何作用 | 停用模板 → 触发 → 断言**一条都没发**,且接口如实回 `notifySent=false` |
| F-102 | 加入名单申请**一个通知都不发**(代码里写着"通知渠道未实现") | 学生申请 → 断言班主任收到;审批通过/驳回 → 断言学生收到 |
| F-103 | 导出完成后不通知(异步打包,用户只能守着进度条) | 建导出 → 等完成 → 断言发起人收到,且**正文里没有签名 URL** |
| F-104 | 自动催报只有 7/3/1 天,**缺"截止当日"**(FR-1003 原文有) | 截止日=今天的任务 → 手工触发催报 → 断言未提交的人收到、已提交的人没收到 |
| F-105 | 驳回达上限只写一行日志,而契约说"同时通知管理员" | 连驳 3 次 → 断言管理员收到升级通知 |
| F-106 | 「字典管理」「通知记录」两个菜单**没有任何角色持有** → 页面点不到 | 管理员登录 → 断言 `/auth/me` 的 menus 里有这两个键 |
| F-107 | 6 个权限码定义了却没挂到任何接口(如 `task:clone`) | 断言班主任做克隆动作被 `40301` 拦住 |
跑法:python scripts/e2e-m5-notify-wiring.py
前置:后端 8080 在跑、xt-pg 在跑(与其它 e2e 相同)。
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import sys
import time
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-wiring-roster.xlsx")
T1 = 993801 # 班主任(初审人)
S1, S2, S3 = 993811, 993812, 993813 # 三个学生(各自不同用途)
JOINER = 993814 # 走「申请加入名单」的学生(不在名册里)
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 latest_msg(uid: int, biz: int) -> tuple[str, str]:
"""某人某类的最新一条站内信(标题, 正文)。没有就回空串。"""
out = sql(f"select title || '\t' || content from collect_message "
f"where user_id={uid} and biz_type={biz} and deleted=0 order by id desc limit 1;")
parts = out.strip().split("\t")
return (parts[0], parts[1]) if len(parts) == 2 else ("", "")
def msg_count(uid: int, biz: int) -> int:
v = sql(f"select count(*) from collect_message where user_id={uid} and biz_type={biz} "
f"and deleted=0;").strip()
return int(v or 0)
def find_template(token: str, code: str, task_id: int | None = None) -> dict:
q = f"/api/v1/notify/templates?taskId={task_id}" if task_id else "/api/v1/notify/templates"
for t in (body_of("GET", q, token).get("data") or []):
if t.get("templateCode") == code:
return t
return {}
def customize_template(token: str, code: str) -> dict:
"""把内置模板复制成库里一行(契约漏了这一步,见 F-52),返回可编辑的那一行。"""
body_of("POST", f"/api/v1/notify/templates/{code}/customize", token)
return find_template(token, code)
def ensure_teacher(uid: int, username: str, name: str, org_id: int) -> str:
# ⚠️ `ON CONFLICT ... DO UPDATE` 必须**把 username 也更新**:
# 只更新 real_name 时,上一轮留下的行会保持旧用户名,
# 而脚本按新用户名登录 → "账号或密码错误"(看起来像密码哈希不对,其实是用户名没换)。
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 username = EXCLUDED.username,
real_name = EXCLUDED.real_name,
org_id = EXCLUDED.org_id,
deleted = 0, 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 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 main() -> int:
ap = argparse.ArgumentParser(description="通知接线 E2E")
ap.parse_args()
print("=" * 78)
print(" 通知接线 E2E(F-101 ~ F-107:模板真的生效 / 该发的通知真的发了)")
print("=" * 78)
_, adm = R.login(BASE, "admin", "Admin@123", "demo")
if adm.get("code") != 0:
print(f" ✗ 管理员登录失败:{adm}")
return 2
at = adm["data"]["token"]
# ⚠️ 管理员的 uid 要**从登录响应里取**,不能写死 1 —— demo 租户里 id=1 是 platform、
# 学校管理员 admin 是 id=2。写死 1 会让"通知发到 uid=2"看起来像"没发"
# (第一版就是这么错的:功能是好的,断言查错了人)。
admin_uid = adm["data"]["user"]["id"]
# ── 0. 夹具:任务(**截止日 = 今天**,用于验 FR-1003 的「截止当日」)、一个班、一个班主任 ──
print("\n[0] 摆夹具(任务截止日故意设成今天)")
suffix = os.urandom(3).hex()
today = time.strftime("%Y-%m-%d")
_, 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": today
})
if b.get("code") != 0:
print(f" ✗ 建任务失败:{b}")
return 2
task_id = b["data"]["id"]
CREATED_TASK_IDS.append(task_id)
check("任务创建成功,且截止日 = 今天", True)
_, 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]
t1 = ensure_teacher(T1, "wireT1", "接线班主任", org["id"])
R.call("PUT", f"{BASE}/api/v1/tasks/{task_id}/orgs", at, {"orgs": [
{"orgId": org["id"], "expectedCount": 3, "headUserId": T1}
]})
R.call("POST", f"{BASE}/api/v1/tasks/{task_id}/materials/apply-template", at,
{"templateCode": "TPL_SCHOOL_ROLL"})
rows = [("接线甲", R.with_checksum("36102920080101041")),
("接线乙", R.with_checksum("36102920080202042")),
("接线丙", R.with_checksum("36102920080303043"))]
build_roster(rows)
body = R.upload_roster(BASE, at, task_id, TMP_ROSTER,
extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]})
check("名册导入成功(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})
check("任务已发布(收集中)", sql(
f"select status from collect_task where id={task_id};").strip() == "1")
# 甲、乙、丙都绑定身份(后面各自有不同的用途)
for uid, (name, idc) in zip((S1, S2, S3), rows):
tok = mp(uid)
_, b = R.call("POST", f"{BASE}/api/v1/mp/roster/match", tok,
{"taskId": task_id, "realName": name, "idCardNo": idc})
if b.get("code") != 0:
print(f" ✗ {name} 门禁绑定失败:{b}")
return 2
check("三个学生都绑定了身份", True)
# ⚠️ 加入名单申请要求申请人有 **openid**(契约:同 openid 每任务 ≤2 次,
# 没有 openid 就没法按人限次 → 服务端明确拒绝,见 JoinService.submit)。
# 门禁绑定建出来的账号没有 openid,所以这里显式补一个 —— 这是夹具,不是产品行为。
# ⚠️ 这两位**不在名册里**,也就不会被门禁绑定建号 → 必须先自己建出来再补 openid。
for uid, uname, rname in ((JOINER, "wireJoin1", "接线申请者"),
(993815, "wireJoin2", "接线被拒者")):
sql(f"""
INSERT INTO sys_user (id, tenant_id, username, real_name, password, org_id,
user_type, status, open_id)
VALUES ({uid}, 1, '{uname}', '{rname}',
'$2a$10$6NB7sObvoHRwzhLJwZ2CHOAbdECgMJGiiTeMY6.zo3IJyd8TEKLEO', {org["id"]}, 4, 1,
'e2e_wire_{uid}')
ON CONFLICT (id) DO UPDATE SET open_id = EXCLUDED.open_id, deleted = 0, status = 1
""")
# ══════════════ F-101:改模板 → 自动驳回真的用新文案 ══════════════
print("\n[1] F-101 模板可配置:自动通知是否真的走模板")
tpl = customize_template(at, "AUDIT_REJECT")
check("「驳回通知」可以自定义(内置 → 库里一行)",
bool(tpl.get("id")) and tpl.get("customized") is True,
json.dumps(tpl, ensure_ascii=False)[:200])
new_title = f"【E2E改过】材料要重传 {suffix}"
new_content = "【E2E改过】{姓名} 的《{任务名}》被退回:{驳回原因}。涉及:{材料名}"
body_of("PUT", f"/api/v1/notify/templates/{tpl.get('id')}", at,
{"title": new_title, "content": new_content})
after = find_template(at, "AUDIT_REJECT")
check("改完读回来就是新文案", after.get("title") == new_title, f"{after.get('title')}")
# 甲:提交 → 驳回
tok1 = mp(S1)
_, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok1, {"taskId": task_id})
sid1 = (b.get("data") or {}).get("id")
res = A.prepare_student(tok1, sid1, task_id, {}, False)
check("甲已提交", (res.get("data") or {}).get("submitted") is True,
json.dumps(res, ensure_ascii=False)[:200])
rej = body_of("POST", f"/api/v1/audit/submissions/{sid1}/reject", t1,
{"reasonCode": "R001", "reasonText": "E2E 驳回原因", "materialCodes": ["IDCARD"]})
check("班主任驳回成功", rej.get("code") == 0, json.dumps(rej, ensure_ascii=False)[:200])
title1, content1 = latest_msg(S1, 3)
check("⭐ 学生收到的**是改过的那句标题**(模板真的生效)",
title1 == new_title, f"实际={title1!r}")
check("⭐ 正文也来自模板,且 {姓名}/{任务名}/{驳回原因} 都被替换了",
content1.startswith("【E2E改过】") and "{驳回原因}" not in content1
and "E2E 驳回原因" in content1 and "接线甲" in content1,
f"实际={content1!r}")
rec = sql(f"select content from collect_notify_record where receiver_id={S1} "
f"and biz_type='3' order by id desc limit 1;").strip()
check("通知记录里记的也是渲染后的文案(两个口径不分叉)",
rec.startswith("【E2E改过】") or rec == "", f"实际={rec!r}")
# ── F-101b:模板停用 → 这条自动通知不发 ──
print("\n[2] F-101b 模板停用:自动通知应当不发")
body_of("PUT", f"/api/v1/notify/templates/{tpl.get('id')}", at, {"enabled": False})
tok2 = mp(S2)
_, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok2, {"taskId": task_id})
sid2 = (b.get("data") or {}).get("id")
A.prepare_student(tok2, sid2, task_id, {}, False)
before = msg_count(S2, 3)
rej2 = body_of("POST", f"/api/v1/audit/submissions/{sid2}/reject", t1,
{"reasonCode": "R001", "reasonText": "E2E 停用测试"})
d2 = rej2.get("data") or {}
check("驳回本身仍然成功(停用模板不该让业务动作失败)", rej2.get("code") == 0,
json.dumps(rej2, ensure_ascii=False)[:200])
check("⭐ 学生**没有**收到站内信(停用 = 不发)",
msg_count(S2, 3) == before, f"驳回前={before} 驳回后={msg_count(S2, 3)}")
check("⭐ 接口如实回 notifySent=false + needManualNotice=true(不假装发了)",
d2.get("notifySent") is False and d2.get("needManualNotice") is True,
json.dumps(d2, ensure_ascii=False)[:200])
# 恢复默认(把库里那行删掉 = 内置文案回来)
body_of("DELETE", f"/api/v1/notify/templates/AUDIT_REJECT", at)
back = find_template(at, "AUDIT_REJECT")
check("恢复默认后读回内置文案", back.get("customized") is False, f"{back.get('customized')}")
# ══════════════ F-102:加入名单申请的通知 ══════════════
print("\n[3] F-102 加入名单申请:提交要通知老师、审批要通知学生")
joiner = mp(JOINER)
_, b = R.call("POST", f"{BASE}/api/v1/mp/roster/join-request", joiner, {
"taskId": task_id, "realName": "接线申请者", "idCardNo": R.with_checksum("36102920080404044"),
"phone": "13800137888", "orgHint": org.get("orgName"), "reason": "E2E 申请"
})
check("学生提交加入申请成功", b.get("code") == 0, json.dumps(b, ensure_ascii=False)[:200])
req_id = (b.get("data") or {}).get("requestId")
t_title, t_content = latest_msg(T1, 7)
check("⭐ 班主任收到了申请通知(以前一个都不发)", bool(t_title), f"title={t_title!r}")
check("通知里说清是谁、哪个班、哪个任务",
"接线申请者" in t_content and (org.get("orgName") or "") in t_content
and "E2E通知接线" in t_content, f"实际={t_content!r}")
a_title, _ = latest_msg(admin_uid, 7) # 学校管理员(SCHOOL_ADMIN,兜底收件人)
check("学校管理员也收到了(任务没配班主任时的兜底)", bool(a_title), f"title={a_title!r}")
before_join = msg_count(JOINER, 7)
ap_body = body_of("POST", f"/api/v1/roster/join-requests/{req_id}/approve", at,
{"orgId": org["id"]})
check("审批通过成功", ap_body.get("code") == 0, json.dumps(ap_body, ensure_ascii=False)[:200])
check("⭐ 学生收到了审批结果(站内信 bizType=7)",
msg_count(JOINER, 7) > before_join,
f"审批前={before_join} 审批后={msg_count(JOINER, 7)}")
j_title, j_content = latest_msg(JOINER, 7)
check("结果通知里带任务名,并告诉他可以开始填报了",
"E2E通知接线" in j_content and "填报" in j_content, f"实际={j_content!r}")
# 驳回那条路(换个学生,避免"每任务 2 次"的上限)
JO = 993815
joiner2 = mp(JO)
_, b = R.call("POST", f"{BASE}/api/v1/mp/roster/join-request", joiner2, {
"taskId": task_id, "realName": "接线被拒者", "idCardNo": R.with_checksum("36102920080505045"),
"phone": "13800137999", "orgHint": org.get("orgName"), "reason": "E2E 申请2"
})
req2 = (b.get("data") or {}).get("requestId")
body_of("POST", f"/api/v1/roster/join-requests/{req2}/reject", at,
{"reasonCode": "NOT_STUDENT", "reason": "E2E 不是本校学生"})
_, rej_content = latest_msg(JO, 7)
check("⭐ 驳回也通知学生,且把原因说清(否则他会反复重提)",
"E2E 不是本校学生" in rej_content, f"实际={rej_content!r}")
# ══════════════ F-103:导出完成的通知 ══════════════
print("\n[4] F-103 导出完成:通知发起人(且正文里不放签名 URL)")
# 先把甲、乙审到通过,导出才有东西可打。
# ⚠️ 两位刚被驳回过(上面两节),必须先**重新提交**才能再进审核队列 ——
# 直接调 approve 会得到"当前状态不对"。重交不用重传材料(材料还在)。
for tok, sid in ((tok1, sid1), (tok2, sid2)):
S.sub(tok, sid, True)
for sid in (sid1, sid2):
body_of("POST", f"/api/v1/audit/submissions/{sid}/approve", t1,
{"actionSource": "BUTTON"})
body_of("POST", f"/api/v1/audit/submissions/{sid}/approve", at,
{"actionSource": "BUTTON", "finalOpinion": "E2E 终审"})
passed = sql(f"select count(*) from collect_submission where task_id={task_id} "
f"and status=3 and deleted=0;").strip()
check("两份填报单已终审通过(导出有料可打)", passed == "2", f"status=3 的份数={passed}")
before_exp = msg_count(admin_uid, 6)
created = body_of("POST", "/api/v1/exports", at, {
"taskId": task_id, "exportName": f"E2E通知-{suffix}",
"scope": {"onlyApproved": True}, "format": "ZIP", "generateSummaryExcel": True
})
export_id = (created.get("data") or {}).get("exportId")
check("导出任务已创建", created.get("code") == 0 and isinstance(export_id, int),
json.dumps(created, ensure_ascii=False)[:200])
detail = {}
for _ in range(40):
detail = body_of("GET", f"/api/v1/exports/{export_id}", at).get("data") or {}
if detail.get("status") in (2, 3):
break
time.sleep(1)
check("导出完成", detail.get("status") == 2,
f"status={detail.get('status')} err={detail.get('errorMsg')}")
e_title, e_content = latest_msg(admin_uid, 6)
check("⭐ 发起人收到了「导出完成」(以前只有进度条可守)",
msg_count(admin_uid, 6) > before_exp,
f"导出前={before_exp} 导出后={msg_count(admin_uid, 6)}")
check("通知里带导出包名", f"E2E通知-{suffix}" in (e_title + e_content),
f"title={e_title!r} content={e_content!r}")
check("⭐ 正文里**没有** URL(签名链接不入库、不外发)",
"http" not in e_content and "://" not in e_content, f"实际={e_content!r}")
# ══════════════ F-104:自动催报的「截止当日」 ══════════════
print("\n[5] F-104 自动催报:截止当日那一档(FR-1003)")
# ⚠️ /schedules/run 是**运维动作**(权限走运营侧的 log:view:all),
# 学校管理员调用会被 40301 拦住 —— 所以这里必须用平台账号(与 e2e-m7-schedule 一致)
_, ops = R.login(BASE, "platform", "Platform@123", None)
if ops.get("code") != 0:
fail("平台账号登录", json.dumps(ops, ensure_ascii=False)[:200])
return 2
ops_token = ops["data"]["token"]
# 丙还没提交 → 该被催;甲、乙已提交 → 不该被催
sql(f"delete from collect_message where user_id in ({S1}, {S2}, {S3}) and biz_type=5;")
run = body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "urge"})
check("手工触发自动催报成功", run.get("code") == 0,
json.dumps(run, ensure_ascii=False)[:200])
c_title, c_content = latest_msg(S3, 5)
check("⭐ 截止当日(0 天)也会催 —— 这一档以前是缺的",
msg_count(S3, 5) == 1, f"丙的催报条数={msg_count(S3, 5)}")
check("文案说的是「今天是截止日」而不是「最后一天」(两种急迫程度不同)",
"今天是截止日" in c_content, f"实际={c_content!r}")
check("⭐ 已提交的甲没有被催(催了会让他以为没交成功)",
msg_count(S1, 5) == 0, f"甲的催报条数={msg_count(S1, 5)}")
check("催报正文里带任务名(学生一眼知道是哪件事)",
"E2E通知接线" in c_content, f"实际={c_content!r}")
check("模板变量 {提交进度} 真的被填了(白名单里有、以前永远空)",
"/" in c_content.split("提交进度")[-1][:12] if "提交进度" in c_content else True,
f"实际={c_content!r}")
# ══════════════ F-105:驳回达上限 → 通知管理员 ══════════════
print("\n[6] F-105 驳回达上限:升级通知管理员(契约 §11.4)")
max_reject = 3 # 任务的 audit_config.maxRejectCount 默认 3(见 AuditService.DEFAULT_MAX_REJECT_COUNT)
before_esc = msg_count(admin_uid, 3)
# 丙:提交 → 驳回,重复到上限
tok3 = mp(S3)
_, b = R.call("POST", f"{BASE}/api/v1/mp/submissions", tok3, {"taskId": task_id})
sid3 = (b.get("data") or {}).get("id")
A.prepare_student(tok3, sid3, task_id, {}, False)
for i in range(max_reject):
r = body_of("POST", f"/api/v1/audit/submissions/{sid3}/reject", t1,
{"reasonCode": "R001", "reasonText": f"E2E 第 {i + 1} 次驳回"})
if r.get("code") != 0:
fail(f"第 {i + 1} 次驳回", json.dumps(r, ensure_ascii=False)[:200])
break
if i < max_reject - 1:
# 驳回后学生重新提交,才能再驳一次。
# ⚠️ 只调 submit,**不要**再走 prepare_student —— 它会重新上传一遍材料,
# 而"同一材料最多 2 份"的上限会把重交顶掉,于是下一次驳回报"当前状态不对"。
S.sub(tok3, sid3, True)
esc_title, esc_content = latest_msg(admin_uid, 3)
check(f"⭐ 连驳 {max_reject} 次后,管理员收到升级通知(以前只有一行日志)",
msg_count(admin_uid, 3) > before_esc,
f"升级前={before_esc} 升级后={msg_count(admin_uid, 3)}")
check("升级通知里带学生姓名与最后一次驳回原因",
"接线丙" in esc_content and "驳回" in esc_content, f"实际={esc_content!r}")
# ══════════════ F-106:菜单键 ══════════════
print("\n[7] F-106 菜单可见性:字典管理与通知记录")
menus = set((adm.get("data") or {}).get("menus") or [])
check("⭐ 管理员菜单里有「字典管理」(dict)", "dict" in menus, f"menus={sorted(menus)}")
check("⭐ 管理员菜单里有「通知记录」(notify)", "notify" in menus, f"menus={sorted(menus)}")
check("菜单键与 PC 端 XtMenu 对得上(dict/notify 都在路由表里)",
all(k in open(os.path.join(ROOT, "frontend-pc/src/config/xt-routes.ts"),
encoding="utf-8").read() for k in ("'dict'", "'notify'")))
# ══════════════ F-107:权限码真的挂在接口上 ══════════════
print("\n[8] F-107 权限码挂到接口:克隆与整理器")
cl = body_of("POST", f"/api/v1/tasks/{task_id}/clone", t1, {"taskName": "不该建出来"})
check("⭐ 班主任(无 task:clone)克隆任务被 40301 拦住",
cl.get("code") == 40301, f"code={cl.get('code')} msg={cl.get('msg')}")
cl2 = body_of("POST", f"/api/v1/tasks/{task_id}/clone", at, {"taskName": f"接线副本{suffix}"})
check("管理员(有 task:clone)克隆成功", cl2.get("code") == 0,
json.dumps(cl2, ensure_ascii=False)[:200])
if cl2.get("code") == 0:
CREATED_TASK_IDS.append((cl2.get("data") or {}).get("id"))
cu = body_of("POST", "/api/v1/cleanup/jobs/json", t1, {"jobName": "不该建出来"})
check("⭐ 班主任(无 cleanup:run)调整理器被 40301 拦住",
cu.get("code") == 40301, f"code={cu.get('code')} msg={cu.get('msg')}")
cleanup()
print("\n" + "=" * 78)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
print("=" * 78)
for n, det in FAILED:
print(f" ✗ {n}\n {det}")
sys.stdout.flush()
return 1 if FAILED else 0
def cleanup() -> None:
for tid in [t for t in CREATED_TASK_IDS if t]:
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_export_file where export_id in "
f"(select id from collect_export_task where task_id={tid});")
R.sql(f"delete from collect_export_task 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_join_request 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_form_schema where task_id={tid};")
R.sql(f"delete from collect_task where id={tid};")
# 模板的覆盖行:删掉 = 恢复内置(**不能留**,否则后面的脚本会读到改过的文案)
R.sql("delete from collect_notify_template where tenant_id=1;")
# 本脚本造的学生/申请人/班主任
R.sql(f"delete from collect_message where user_id in "
f"({S1}, {S2}, {S3}, {JOINER}, 993815, {T1});")
R.sql(f"delete from collect_notify_record where receiver_id in "
f"({S1}, {S2}, {S3}, {JOINER}, 993815, {T1});")
R.sql(f"delete from sys_user_role where user_id in "
f"({S1}, {S2}, {S3}, {JOINER}, 993815, {T1});")
R.sql(f"delete from sys_user where id in ({S1}, {S2}, {S3}, {JOINER}, 993815, {T1});")
if os.path.exists(TMP_ROSTER):
os.remove(TMP_ROSTER)
print(" 已清理:任务 / 名册 / 填报单 / 导出 / 申请 / 消息 / 通知记录 / 模板覆盖行 / 测试账号")
if __name__ == "__main__":
raise SystemExit(main())