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