528 lines
29 KiB
Python
528 lines
29 KiB
Python
# -*- 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] = []
|
||
CREATED_TPL_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])
|
||
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')}")
|
||
|
||
# ══════════════ 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};")
|
||
# 模板的覆盖行:**只删本脚本建的那几行**(删掉 = 恢复内置)。
|
||
# ⚠️ 不能写成 `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 "
|
||
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())
|