1f3c485ceb
角色与授权(交接摘要 §一·三十五): - 权限判定合并两个来源:内置角色按代码(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 名与全局导航栏标题
517 lines
28 KiB
Python
517 lines
28 KiB
Python
# -*- 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())
|