Files
xiaotutongxue/scripts/e2e-m5-notify-wiring.py
T
lovife 1f3c485ceb feat(role,scope)!: 角色与授权可配 + 需求收敛(4 角色 / 10 菜单 / 删两模块)
角色与授权(交接摘要 §一·三十五):
- 权限判定合并两个来源:内置角色按代码(BuiltinRole)、自定义角色按库
  (sys_role_permission)。实现放 module 层的 DbRoleGrantSource,security 只依赖
  接口 RoleGrantSource —— 否则角色服务要调 permissionChecker,两个包迟早成环
- 权限判定必须加缓存:此前是零 IO,而压测基线是"SQL 条数恒定"。缓存放 Redis,
  空结果也缓存(否则已删角色的 token 会把库打穿)、Redis 挂了退化成直读库、TTL 只兜底
- V18 建 sys_role.user_type(账号的 user_type 一直从角色反推,自定义角色不在代码映射里)
  与 sys_role_menu;8 个接口 + 错误码 40045;契约 119 -> 129
- 内置角色只读(权限码是前后端契约,做成可改 = 一次误操作就能把管理员锁在系统外);
  平台级能力两道防线(界面禁用 + 服务端 40301);防自锁;改完立即生效(写授权清缓存)
- RoleProvisioner 从"已存在就 continue"改成幂等回填 + 每日 03:20 定时任务 ——
  修的是"代码改了权限、已开通租户永远停在发放那一刻",这种漂移不报错也没测试会红
- scripts/e2e-m10-role.py 77/77

需求收敛(交接摘要 §一·三十六,本节的数字为准):
- 删副班主任 / 审核员两个内置角色(6 -> 4)、历史整理器(module/cleanup)、
  加入申请(module/join + 学生端 pages/join)、collect_task_org.deputy_user_ids 整列;
  审核范围三级回退 -> 两级,连带 6 个 mapper 的 LIKE '%,id,%' 分支
- V19 迁移处理存量:先解 sys_user_role 绑定再软删角色;账号只在"除这两个角色外没有
  别的角色"时才软删。必须迁而不能只删代码 —— 角色码删掉后 mergePermissions 返回空集,
  存量账号会"能登录、处处 40301",不抛异常不打日志
- 契约 129 -> 119、错误码 59 -> 54、权限码 30 -> 28、PC 菜单 12 -> 10、
  小程序页 10 -> 9、原型 32 -> 29 屏、E2E 39 -> 37 个(断言 1695 条)
- UserService.list 对不认识的角色码由"静默丢掉过滤条件"改成失败关闭
  (之前传个不存在的 roleCode 会返回全校账号,且看起来完全正常)
- 品牌元信息同步改名「深学在线」:pom 名称、/doc.html 标题、配置与启动类注释、
  存储自检探针内容;学生端 app 名与全局导航栏标题
2026-10-10 16:48:54 +08:00

517 lines
28 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(《08-完成度盘点》F-101 ~ F-107)。
这个脚本验的不是"某个接口返回 200",而是**"配置改了之后,系统真的按新配置做事"** ——
也就是本轮修掉的那一类"看起来有、其实没用":
| 编号 | 原来是什么样 | 这个脚本怎么证明它现在真的生效 |
|---|---|---|
| F-101 | 「通知模板」页能改文案,但**只有手工发送走模板**;驳回/通过/催报这些自动通知把文案硬编码在 Java 里 | 改掉「驳回通知」→ 触发一次真驳回 → 断言学生收到的是**改过的那句** |
| F-101b | 模板停用开关对自动通知没有任何作用 | 停用模板 → 触发 → 断言**一条都没发**,且接口如实回 `notifySent=false` |
| F-102 | 加入名单申请**一个通知都不发**(代码里写着"通知渠道未实现") | **通道已删除**(2026-10 需求收敛):断言两张表与两个角色都不在了 |
| 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] = []
CREATED_TPL_IDS: list[int] = [] # 本脚本自定义过的通知模板行(清理时**只删自己建的**)
TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-wiring-roster.xlsx")
T1 = 993801 # 班主任(初审人)
S1, S2, S3 = 993811, 993812, 993813 # 三个学生(各自不同用途)
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, task_id: int | None = None) -> tuple[str, str]:
"""某人某类的最新一条站内信(标题, 正文)。没有就回空串。
⚠️ 传 `task_id` 时按**这条消息指向的任务**收窄(`link_type='TASK'` + `link_id=taskId`,
见 `UrgeService`)。不传就是"这个人的全部该类消息"。
"""
where = f"user_id={uid} and biz_type={biz} and deleted=0"
if task_id is not None:
where += f" and link_type='TASK' and link_id={task_id}"
out = sql(f"select title || '\t' || content from collect_message "
f"where {where} 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, task_id: int | None = None) -> int:
"""某人某类站内信条数。
⚠️ **催报这类断言必须传 `task_id`**(踩过一次):催报任务是按"截止日在今天+N 天"扫
**全库所有任务**的,而每次跑 E2E 都会新建一个"今天截止"的任务 ——
只要上一次跑留下过一个没清干净的同截止日任务(脚本提前 return 时会跳过清理),
下一次的催报就会同时命中两个任务,此人收到 2 条,断言"== 1"随机变红。
按 `link_id`(催报消息指向的就是 taskId)收窄之后,这个断言与库里有多少残留任务无关。
"""
where = f"user_id={uid} and biz_type={biz} and deleted=0"
if task_id is not None:
where += f" and link_type='TASK' and link_id={task_id}"
v = sql(f"select count(*) from collect_message where {where};").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)
# ══════════════ 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])
if tpl.get("id"):
CREATED_TPL_IDS.append(tpl["id"])
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')}")
# ══════════════ 需求收敛:删掉的东西必须真的不在(2026-10)══════════════
print("\n[3] 需求收敛:两张表、两个内置角色、两个权限码都已下线")
for tbl in ("collect_join_request", "collect_cleanup_job"):
out = sql(f"SELECT count(*) FROM information_schema.tables WHERE table_name = '{tbl}'")
check(f"⚠️ 表 {tbl} 已被 V19 删除", out.strip() == "0", f"查询结果={out!r}")
out = sql("SELECT count(*) FROM information_schema.columns "
"WHERE table_name = 'collect_task_org' AND column_name = 'deputy_user_ids'")
check("⚠️ collect_task_org.deputy_user_ids 列已删除(协办人机制下线)",
out.strip() == "0", f"查询结果={out!r}")
# ⚠️ 不要用 `string_agg` 拼起来比字符串:它**没有 ORDER BY**,
# 行的物理顺序会随 vacuum/重建而变,断言就会"偶尔红一次"(跑全量时才复现)。
# 这里改成"数个数 + 点名单",与顺序无关。
out = sql("SELECT count(*) FROM sys_role WHERE deleted = 0 AND role_type = 1 AND tenant_id = 1")
check("⚠️ 内置角色只剩 4 个", out.strip() == "4", f"实际={out.strip()!r}")
out = sql("SELECT count(*) FROM sys_role WHERE deleted = 0 AND role_type = 1 AND tenant_id = 1 "
"AND role_code IN ('DEPUTY_TEACHER','AUDITOR')")
check("⚠️ 内置角色里没有副班主任 / 审核员", out.strip() == "0", f"查询结果={out!r}")
out = sql("SELECT count(*) FROM sys_user_role ur JOIN sys_role r ON r.id = ur.role_id "
"WHERE ur.deleted = 0 AND r.role_code IN ('DEPUTY_TEACHER','AUDITOR')")
check("⚠️ 没有任何账号还挂着这两个角色", out.strip() == "0", f"查询结果={out!r}")
# 权限码目录:join:audit 与 cleanup:run 必须已经消失(否则「角色与授权」页还能勾到它们)
perms = body_of("GET", "/api/v1/permissions", at)
codes = set()
for grp in (perms.get("data") or []):
for it in (grp.get("items") or []):
codes.add(it.get("code"))
check("⭐ 权限码目录里不再有 join:audit / cleanup:run",
"join:audit" not in codes and "cleanup:run" not in codes,
f"实际命中={sorted(c for c in codes if c in ('join:audit', 'cleanup:run'))}")
check("⭐ 权限码总数收敛为 28", len(codes) == 28, f"实际 {len(codes)} 个")
# ══════════════ 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"]
# 丙还没提交 → 该被催;甲、乙已提交 → 不该被催
# ⚠️ 计数用 `task_id` 收窄:催报任务扫的是"截止日=今天+N 天"的**全库**任务,
# 残留的同截止日任务会让计数变成 2(详见 msg_count 的注释)。
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, task_id)
check("⭐ 截止当日(0 天)也会催 —— 这一档以前是缺的",
msg_count(S3, 5, task_id) == 1, f"丙本任务催报条数={msg_count(S3, 5, task_id)}")
check("文案说的是「今天是截止日」而不是「最后一天」(两种急迫程度不同)",
"今天是截止日" in c_content, f"实际={c_content!r}")
check("⭐ 已提交的甲没有被催(催了会让他以为没交成功)",
msg_count(S1, 5, task_id) == 0, f"甲本任务催报条数={msg_count(S1, 5, task_id)}")
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 权限码挂到接口:克隆任务走 task:clone")
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"))
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_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};")
# 模板的覆盖行:**只删本脚本建的那几行**(删掉 = 恢复内置)。
# ⚠️ 不能写成 `delete from collect_notify_template where tenant_id=1` ——
# 那会把演示态里别人改过的模板一起删掉(而"演示态不能被脚本弄坏"是这个仓库的硬约束)。
for tid in CREATED_TPL_IDS:
R.sql(f"delete from collect_notify_template where id={tid};")
# 本脚本造的学生/班主任
R.sql(f"delete from collect_message where user_id in ({S1}, {S2}, {S3}, {T1});")
R.sql(f"delete from collect_notify_record where receiver_id in ({S1}, {S2}, {S3}, {T1});")
R.sql(f"delete from sys_user_role where user_id in ({S1}, {S2}, {S3}, {T1});")
R.sql(f"delete from sys_user where id in ({S1}, {S2}, {S3}, {T1});")
if os.path.exists(TMP_ROSTER):
os.remove(TMP_ROSTER)
print(" 已清理:任务 / 名册 / 填报单 / 导出 / 消息 / 通知记录 / 模板覆盖行 / 测试账号")
if __name__ == "__main__":
raise SystemExit(main())