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:
@@ -0,0 +1,445 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""接线检查:找「定义好了、代码里也写了,但**从来没有被接上**」的东西。
|
||||
|
||||
## 为什么需要它(这一族错在本仓库反复出现)
|
||||
|
||||
盘点这个项目时抓到的 P0/P1 问题里,有一大类长着同一个样子:
|
||||
|
||||
| 例子 | 现象 |
|
||||
|---|---|
|
||||
| F-1 | `OperationLogMapper` 存在,但**全仓库只有它自己 import** → 敏感操作一个字都没留痕 |
|
||||
| F-31 | `LoginLogMapper` 在写,但**微信登录那条链路不写** → 合规上查不到学生登录 |
|
||||
| F-50 | `collect_validation_issue` 表 V1 就建好了,**一直没有代码用它** |
|
||||
| F-24/35 | 实体要的审计列,建表时漏了 → 插入报错(四次) |
|
||||
| F-52/67 | 契约里定义了接口,但没有"把链路接上"的那一步,功能等于装饰 |
|
||||
|
||||
它们的共同点:**每一处单看都是对的**(类在、表在、常量在、注释写得很清楚),
|
||||
所以 code review、typecheck、编译、单元测试**全都不会红** —— 只有"从入口走一遍"
|
||||
才会暴露。而人不会每次都走一遍全链路。
|
||||
|
||||
所以把它固化成命令:**列出"声明了但没有任何地方引用"的那些声明**,
|
||||
逐条由人判断是"忘记接线"还是"有意留的"(有意留的要写进下面的已知例外表,
|
||||
而不是靠下次再想一遍)。
|
||||
|
||||
## 查五件事
|
||||
|
||||
| # | 查什么 | 没接上的后果 |
|
||||
|:--:|---|---|
|
||||
| 1 | `PermissionDef` 的权限码没有任何 `@RequirePerm` 用它 | 权限码是死的(或接口忘了标权限) |
|
||||
| 2 | `PermissionDef` 的权限码没有被任何内置角色分配 | **这个接口谁都调不了**(只有平台租户能绕过) |
|
||||
| 3 | `ErrorCode` 的码零引用 | 文档里有、代码里没有对应分支 |
|
||||
| 4 | `NotifyTemplates` 的站内信模板零发送点 | 模板页能改,但那条通知根本不发 / 不走模板 |
|
||||
| 5 | `CollectMessage.BIZ_*` 零使用 | 消息中心那个分类永远是空的 |
|
||||
| 6 | 内置角色声明的菜单键 ↔ PC 端 `XtMenu` 的 key | 菜单永远不显示(键拼错),或前端有菜单谁都看不到 |
|
||||
|
||||
## 它**不查**什么(免得被当成"全绿就没事")
|
||||
|
||||
- 不查"逻辑对不对":它只回答"有没有被引用过",一个被引用了但用错地方的常量
|
||||
它照样算通过;
|
||||
- 不查前端有没有把权限码用上(`v-permission` 之类)—— 那要跨语言解析模板;
|
||||
- 不查服务层"备好了没人调"的公开方法(如本轮发现的
|
||||
`NotifyTemplateService.templateOf`)—— 这类误报太多,靠人去读;
|
||||
所以第 4 项查的是"**模板码有没有发送点**",那是它能可靠判定的那一层。
|
||||
|
||||
跑法:`python scripts/check-wiring.py`(`--quiet` 只打摘要,给 `verify-all.py` 用)
|
||||
退出码:0 = 没有未接线的声明;1 = 有(每条都打出来)。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
os.chdir(ROOT)
|
||||
|
||||
PERM_DEF = 'backend/xt-server/src/main/java/cn/tianbaotong/fill/security/PermissionDef.java'
|
||||
BUILTIN_ROLE = 'backend/xt-server/src/main/java/cn/tianbaotong/fill/security/BuiltinRole.java'
|
||||
ERROR_CODE = 'backend/xt-common/src/main/java/cn/tianbaotong/fill/common/api/ErrorCode.java'
|
||||
NOTIFY_TPL = 'backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/service/NotifyTemplates.java'
|
||||
MESSAGE_ENTITY = ('backend/xt-server/src/main/java/cn/tianbaotong/fill/module/notify/'
|
||||
'entity/CollectMessage.java')
|
||||
XT_ROUTES = 'frontend-pc/src/config/xt-routes.ts'
|
||||
|
||||
# ── 已知例外:**有意不接线的**,写在这里(连同理由),不要靠下次再想一遍 ──
|
||||
# 权限码:没有任何地方校验它(既没有 @RequirePerm,也没有服务层的 permissionChecker)
|
||||
PERM_NO_CHECK = {
|
||||
'ROLE_MANAGE': '「角色与授权」页是二期功能(权限码先定下来,见 08 文档 6n):'
|
||||
'一期不开放自定义角色,所以没有任何接口需要它',
|
||||
'ROSTER_MANAGE_OWN': '「本班名册」不是独立接口,而是 roster 接口里的**数据范围**分支'
|
||||
'(服务层按角色判 orgIds),接口级权限用 ROSTER_MANAGE / ROSTER_VIEW',
|
||||
'PROGRESS_VIEW_OWN': '同上:进度看板的"本班/本人"是同一接口的范围分支(见 ProgressService)',
|
||||
'SUBMISSION_OWN': '学生端接口靠**登录态 + 只能操作本人那一份**保证,'
|
||||
'不走权限码(学生是 MP 端唯一角色,加一道权限码只会多一个出错点)',
|
||||
'EXPORT_UNMASKED': '脱敏导出开关 `desensitize` 是**显式保留到二期**的(名册里本来就是'
|
||||
'脱敏值,见 ExportDtos 的注释),所以这个码暂时没有对应接口',
|
||||
}
|
||||
# 权限码:被校验了,但没有被任何内置角色分配(= 谁都调不了)
|
||||
PERM_NO_ROLE = {}
|
||||
ERROR_CODE_UNUSED = {
|
||||
'ID_CARD_CHECKSUM_FAILED': 'M4 识别与校验(等 OCR 选型,见 docs/01 D1)',
|
||||
'ID_CARD_MISMATCH_ROSTER': '同上(M4)',
|
||||
'SUSPECTED_WRONG_OWNER': '同上(M4,串档检测)',
|
||||
'FILE_OWNERSHIP_DOUBT': '同上(M4,VAL-21 归属校验)',
|
||||
'OCR_FAILED': '同上(M4,OCR 服务异常)',
|
||||
'IMAGE_TOO_LARGE_CIRCUIT_BREAK': '同上(M4,图片熔断)',
|
||||
'VLM_FAILED': '同上(M4,VLM 识别)',
|
||||
'FILE_PROCESS_FAILED': '内部错误:统一由 GlobalExceptionHandler 兜成 50000 返回,'
|
||||
'细分码留在文档里给排查用(客户端拿到的仍是"系统繁忙")',
|
||||
'NOTIFY_FAILED': '同上(通知失败不影响主流程,只记日志)',
|
||||
'STORAGE_FAILED': '同上(存储失败由 FileProcessService 转成 50001 口径的业务提示)',
|
||||
'SIGNED_URL_EXPIRED': '签名链接过期由**存储层**返回(七牛 401 → StorageService 转成'
|
||||
'可读提示),没有单独抛这个码;契约 §12.6 的语义已由提示覆盖',
|
||||
'IDEMPOTENT_REPLAY': '契约 §9.4 的幂等键回放:一期用"重复提交返回既有结果"实现,'
|
||||
'没有单独回这个码(要回它得先有幂等键,属二期)',
|
||||
'REJECT_LIMIT_EXCEEDED': '驳回达上限**不是错误**:驳回本身成功了,'
|
||||
'只是把 escalated 标出来(契约 §11.4「超限升级管理员」),'
|
||||
'所以走的是响应字段而不是错误码 —— 见 docs/08 F-105',
|
||||
}
|
||||
NOTIFY_TPL_UNUSED = {
|
||||
'TASK_DEADLINE': '临期提醒的自动发送走「自动催报」(BIZ 5 + URGE / URGE_DRAFT 模板),'
|
||||
'所以这个码只在模板页里可编辑;要单独发"临期提醒"得先有"未绑定学生的账号",'
|
||||
'见 docs/08 F-108 的记录',
|
||||
'TASK_PUBLISH': '「发布即通知学生」在本系统的流程里**没有收件人**:'
|
||||
'学生要先扫码核验才会绑定账号,而发布之前没有人会去扫 —— '
|
||||
'所以这条通知发出去只会是 0 条(见 docs/08 F-108)',
|
||||
}
|
||||
BIZ_UNUSED = {
|
||||
'BIZ_TASK_PUBLISH': '同上:bizType 1(任务发布)与 TASK_PUBLISH 模板同因,'
|
||||
'在"发布时刻没有可发的收件人"这件事解决之前不发',
|
||||
}
|
||||
|
||||
|
||||
def read(path):
|
||||
return io.open(path, encoding='utf-8').read()
|
||||
|
||||
|
||||
def strip_java_comments(src):
|
||||
"""把注释替换成空白(**保留换行**,行号才不会错位)。
|
||||
|
||||
⚠️ 必须先去注释再数引用:本仓库的注释极其啰嗦,而且**专门在解释"为什么不这么做"** ——
|
||||
例如 `ExportController` 的类注释里写着"权限只有两个:export:download 与 export:unmasked",
|
||||
而代码里从来没校验过 `EXPORT_UNMASKED`。不剥注释的话,这个检查会被自己的文档满足
|
||||
(假阴性),而那正是《08》F-36 踩过的坑:**静态检查要能区分"代码"与"解释代码的文字"**。
|
||||
"""
|
||||
out = []
|
||||
i, n = 0, len(src)
|
||||
state = None # None / 'line' / 'block' / 'str' / 'char'
|
||||
while i < n:
|
||||
c = src[i]
|
||||
nxt = src[i + 1] if i + 1 < n else ''
|
||||
if state is None:
|
||||
if c == '/' and nxt == '/':
|
||||
state = 'line'
|
||||
out.append(' ')
|
||||
i += 2
|
||||
continue
|
||||
if c == '/' and nxt == '*':
|
||||
state = 'block'
|
||||
out.append(' ')
|
||||
i += 2
|
||||
continue
|
||||
if c == '"':
|
||||
state = 'str'
|
||||
elif c == "'":
|
||||
state = 'char'
|
||||
out.append(c)
|
||||
i += 1
|
||||
continue
|
||||
if state == 'line':
|
||||
if c == '\n':
|
||||
state = None
|
||||
out.append('\n')
|
||||
else:
|
||||
out.append(' ')
|
||||
i += 1
|
||||
continue
|
||||
if state == 'block':
|
||||
if c == '*' and nxt == '/':
|
||||
state = None
|
||||
out.append(' ')
|
||||
i += 2
|
||||
continue
|
||||
out.append('\n' if c == '\n' else ' ')
|
||||
i += 1
|
||||
continue
|
||||
# 字符串 / 字符字面量里:原样保留(模板码就是字符串),只处理转义与结束符
|
||||
if c == '\\':
|
||||
out.append(src[i:i + 2])
|
||||
i += 2
|
||||
continue
|
||||
if (state == 'str' and c == '"') or (state == 'char' and c == "'"):
|
||||
state = None
|
||||
out.append(c)
|
||||
i += 1
|
||||
return ''.join(out)
|
||||
|
||||
|
||||
def java_sources():
|
||||
out = {}
|
||||
for p in glob.glob('backend/**/*.java', recursive=True):
|
||||
try:
|
||||
out[p] = strip_java_comments(read(p))
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def refs(sources, name, skip_files):
|
||||
"""名字作为**整个标识符**(不是子串)在其它文件里出现过几次。"""
|
||||
pat = re.compile(r'\b' + re.escape(name) + r'\b')
|
||||
hits = []
|
||||
for p, src in sources.items():
|
||||
if os.path.abspath(p) in skip_files:
|
||||
continue
|
||||
for i, line in enumerate(src.split('\n'), 1):
|
||||
if pat.search(line):
|
||||
hits.append((p, i, line.strip()))
|
||||
return hits
|
||||
|
||||
|
||||
def set_of_blocks(src):
|
||||
"""把每个 `Set.of( ... )` 的参数原文取出来(括号配对,能吃下注释与换行)。
|
||||
|
||||
⚠️ 第一版用正则 `Set\\.of\\(\\s*((?:"[^"]*"\\s*,?\\s*)+)\\)` 匹配 ——
|
||||
往菜单集合里加一行 `//` 注释之后它就**静默不匹配了**,于是 8 个菜单键
|
||||
全被报成"没有任何角色声明"(假阳性)。静态检查里凡是"匹配不到就当没有"的写法,
|
||||
都要先问一句"匹配不到时会发生什么"。
|
||||
"""
|
||||
out = []
|
||||
for m in re.finditer(r'Set\.of\(', src):
|
||||
i = m.end()
|
||||
depth = 1
|
||||
while i < len(src) and depth > 0:
|
||||
if src[i] == '(':
|
||||
depth += 1
|
||||
elif src[i] == ')':
|
||||
depth -= 1
|
||||
i += 1
|
||||
out.append(src[m.end():i - 1])
|
||||
return out
|
||||
|
||||
|
||||
def strip_line_comments(block):
|
||||
return '\n'.join(re.sub(r'//.*$', '', ln) for ln in block.split('\n'))
|
||||
|
||||
|
||||
def template_send_points(sources, code, tpl_file):
|
||||
"""站内信模板的"发送点" = 代码里有人把这个码当成模板码用。
|
||||
|
||||
⚠️ 判据不能用"标识符出现次数":`JOIN_APPROVE` 同时是
|
||||
`OperationLogService.ACTION_JOIN_APPROVE`(操作日志动作码)与
|
||||
`NotificationService.TPL_JOIN_APPROVE`(微信订阅消息模板码)的值 ——
|
||||
按标识符数会得出"3 处引用",而它当时**一个发送点都没有**。
|
||||
|
||||
所以认两件事:
|
||||
① 字面量 `"CODE"` 出现在非"常量声明"的行上;
|
||||
② 以 `"CODE"` 为值的常量被**在通知调用的上下文里**引用
|
||||
(±6 行窗口里出现 `ofTemplate` / `NotifyRequest` / `notify(` / `subjectCode`)——
|
||||
因为发送点常常写成 `SUBJECT_URGE` 这种具名常量,字面量根本不出现。
|
||||
|
||||
②是**启发式**:它可能被别的写法骗过(窗口里恰好有这些词)。它是烟雾报警器,不是证明。
|
||||
"""
|
||||
hits = []
|
||||
aliases = {}
|
||||
for p, s in sources.items():
|
||||
for line in s.split('\n'):
|
||||
m = re.search(r'static final String\s+(\w+)\s*=\s*"' + re.escape(code) + r'"', line)
|
||||
if m:
|
||||
aliases[m.group(1)] = p
|
||||
lit = re.compile(r'"' + re.escape(code) + r'"')
|
||||
for p, s in sources.items():
|
||||
if os.path.abspath(p) == os.path.abspath(tpl_file):
|
||||
continue
|
||||
for i, line in enumerate(s.split('\n'), 1):
|
||||
if lit.search(line) and not re.search(r'static final String\s+\w+\s*=', line):
|
||||
hits.append((p, i, line.strip()))
|
||||
ctx = re.compile(r'ofTemplate|NotifyRequest|notify\(|subjectCode')
|
||||
for name in aliases:
|
||||
pat = re.compile(r'\b' + name + r'\b')
|
||||
for p, s in sources.items():
|
||||
lines = s.split('\n')
|
||||
for i, line in enumerate(lines, 1):
|
||||
if not pat.search(line) or re.search(r'static final String\s+' + name + r'\b', line):
|
||||
continue
|
||||
window = '\n'.join(lines[max(0, i - 7):i + 5])
|
||||
if ctx.search(window):
|
||||
hits.append((p, i, line.strip()))
|
||||
return hits
|
||||
|
||||
|
||||
def menu_exception(key):
|
||||
"""菜单键的已知例外(不是 PC 端菜单,或由基础路由提供)。"""
|
||||
if key == 'dashboard':
|
||||
return 'PC 工作台是**基础路由**(不在 XtMenu 里,所有登录用户都能看到)'
|
||||
if key.startswith('mp-'):
|
||||
return '这是**小程序**的 tabBar 键(学生端菜单),PC 端当然没有'
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description='接线检查')
|
||||
ap.add_argument('--quiet', action='store_true')
|
||||
args = ap.parse_args()
|
||||
|
||||
def say(*a):
|
||||
if not args.quiet:
|
||||
print(*a)
|
||||
|
||||
src = java_sources()
|
||||
problems = [] # (类别, 名字, 说明)
|
||||
notes = [] # 已知例外,照样打出来(不能静默)
|
||||
|
||||
say('=' * 88)
|
||||
say(' 接线检查 「声明了、但没有任何地方引用它」')
|
||||
say('=' * 88)
|
||||
|
||||
# ── ① 权限码:有没有地方校验它 ────────────────────────────
|
||||
perm_src = read(PERM_DEF)
|
||||
perms = re.findall(r'^\s{4}([A-Z][A-Z0-9_]*)\(', perm_src, re.M)
|
||||
# @RequirePerm 的用法(可能跨行、可能 `{A, B}` 多条)
|
||||
require_perm = set()
|
||||
for s in src.values():
|
||||
for block in re.findall(r'@RequirePerm\s*\(([^)]*)\)', s, re.S):
|
||||
require_perm.update(re.findall(r'PermissionDef\.([A-Z][A-Z0-9_]*)', block))
|
||||
role_perm = set(re.findall(r'PermissionDef\.([A-Z][A-Z0-9_]*)', read(BUILTIN_ROLE)))
|
||||
# 服务层校验(permissionChecker.has / require):**同样是校验点** ——
|
||||
# ⚠️ 第一版只数 @RequirePerm,于是把 AUDIT_WITHDRAW 报成"没有接口用它",
|
||||
# 而它明明在 AuditService 里用 permissionChecker.require 守了两处(F-36 的教训:
|
||||
# 静态检查必须先确认"它数的那件事"就是"要判定的那件事")。
|
||||
service_perm = set()
|
||||
skip_perm_files = {os.path.abspath(PERM_DEF), os.path.abspath(BUILTIN_ROLE)}
|
||||
for p, s in src.items():
|
||||
if os.path.abspath(p) in skip_perm_files:
|
||||
continue
|
||||
service_perm.update(re.findall(r'PermissionDef\.([A-Z][A-Z0-9_]*)', s))
|
||||
|
||||
say('\n【①】权限码 %d 个(PermissionDef)' % len(perms))
|
||||
for name in perms:
|
||||
checked = name in service_perm or name in require_perm
|
||||
in_role = name in role_perm
|
||||
if checked and in_role:
|
||||
where = '接口注解' if name in require_perm else '服务层'
|
||||
say(' ✓ %-22s %s' % (name, where))
|
||||
continue
|
||||
# 有例外登记的照实打出来(**不能打 ✓**:那会让"未接线"看起来像已接线)
|
||||
why = []
|
||||
if not checked:
|
||||
why.append(PERM_NO_CHECK.get(name) or '没有任何地方校验它(@RequirePerm 与服务层都没有)')
|
||||
if not in_role:
|
||||
why.append(PERM_NO_ROLE.get(name) or '没有被任何内置角色分配(这个接口谁都调不了)')
|
||||
if (not checked and name in PERM_NO_CHECK) or (not in_role and name in PERM_NO_ROLE):
|
||||
notes.append('权限码 %s —— 已知例外:%s' % (name, ';'.join(why)))
|
||||
say(' — %-22s 已知例外:%s' % (name, ';'.join(why)[:60]))
|
||||
else:
|
||||
problems.append(('权限码', name, ';'.join(why)))
|
||||
say(' ✗ %-22s %s' % (name, ';'.join(why)))
|
||||
|
||||
# ── ② 错误码:有没有被引用 ────────────────────────────────
|
||||
ec_src = read(ERROR_CODE)
|
||||
codes = re.findall(r'^\s{4}([A-Z][A-Z0-9_]*)\((\d+),', ec_src, re.M)
|
||||
skip = {os.path.abspath(ERROR_CODE)}
|
||||
say('\n【②】错误码 %d 个(ErrorCode)' % len(codes))
|
||||
unused_codes = []
|
||||
for name, num in codes:
|
||||
if name == 'SUCCESS':
|
||||
continue
|
||||
if not refs(src, 'ErrorCode.' + name, skip):
|
||||
unused_codes.append((name, num))
|
||||
for name, num in unused_codes:
|
||||
if name in ERROR_CODE_UNUSED:
|
||||
notes.append('错误码 %s(%s) 零引用 —— 已知例外:%s' % (name, num, ERROR_CODE_UNUSED[name]))
|
||||
else:
|
||||
problems.append(('错误码', '%s(%s)' % (name, num), '全仓库零引用(除定义处)'))
|
||||
say(' 零引用的码:%s' % ('、'.join('%s(%s)' % (n, c) for n, c in unused_codes) or '无'))
|
||||
|
||||
# ── ③ 站内信模板:有没有发送点 ────────────────────────────
|
||||
tpl_src = read(NOTIFY_TPL)
|
||||
tpls = re.findall(r'new Def\("([A-Z_]+)",\s*(\d+)', tpl_src)
|
||||
say('\n【③】站内信模板 %d 个(NotifyTemplates)' % len(tpls))
|
||||
for code, biz in tpls:
|
||||
hits = template_send_points(src, code, NOTIFY_TPL)
|
||||
if hits:
|
||||
say(' ✓ %-14s biz=%s %d 处引用(%s)'
|
||||
% (code, biz, len(hits), os.path.basename(hits[0][0])))
|
||||
elif code in NOTIFY_TPL_UNUSED:
|
||||
notes.append('站内信模板 %s 零发送点 —— 已知例外:%s' % (code, NOTIFY_TPL_UNUSED[code]))
|
||||
say(' — %-14s biz=%s 零发送点(已知例外)' % (code, biz))
|
||||
else:
|
||||
problems.append(('站内信模板', code, '没有任何发送点(模板页能改,但这条通知不发)'))
|
||||
say(' ✗ %-14s biz=%s **零发送点**' % (code, biz))
|
||||
|
||||
# 撞名:同一个字面量被两个命名空间当成"自己的码"
|
||||
say('\n【③b】模板码撞名(不同命名空间、同一个字符串)')
|
||||
for code, biz in tpls:
|
||||
owners = []
|
||||
for p, s in src.items():
|
||||
for m in re.finditer(r'static final String\s+([A-Z_]+)\s*=\s*"%s"' % re.escape(code), s):
|
||||
owners.append(m.group(1))
|
||||
if len(owners) > 1:
|
||||
say(' ! %-14s 同时是 %s 的值 —— 不是 bug,但下一个改代码的人会当成同一个东西'
|
||||
% (code, '、'.join(sorted(set(owners)))))
|
||||
|
||||
# ── ④ 消息中心 bizType:有没有被用过 ──────────────────────
|
||||
msg_src = read(MESSAGE_ENTITY)
|
||||
bizs = re.findall(r'public static final int (BIZ_[A-Z_]+) = (\d+);', msg_src)
|
||||
skip_msg = {os.path.abspath(MESSAGE_ENTITY)}
|
||||
say('\n【④】消息中心 bizType %d 个(CollectMessage)' % len(bizs))
|
||||
for name, num in bizs:
|
||||
hits = refs(src, name, skip_msg)
|
||||
if hits:
|
||||
say(' ✓ %-18s = %s %d 处引用' % (name, num, len(hits)))
|
||||
elif name in BIZ_UNUSED:
|
||||
notes.append('bizType %s(%s) 零使用 —— 已知例外:%s' % (name, num, BIZ_UNUSED[name]))
|
||||
else:
|
||||
problems.append(('bizType', '%s(%s)' % (name, num),
|
||||
'零使用(消息中心这个分类永远是空的)'))
|
||||
say(' ✗ %-18s = %s **零使用**' % (name, num))
|
||||
|
||||
# ── ⑤ 内置角色菜单键 ↔ PC 端菜单 ──────────────────────────
|
||||
role_src = read(BUILTIN_ROLE)
|
||||
menu_keys = set()
|
||||
for block in set_of_blocks(role_src):
|
||||
cleaned = strip_line_comments(block)
|
||||
# 权限集合里是 `PermissionDef.X`(没有引号)→ 只有"全是字符串字面量"的才是菜单
|
||||
if re.search(r'PermissionDef\.', cleaned):
|
||||
continue
|
||||
menu_keys.update(re.findall(r'"([^"]+)"', cleaned))
|
||||
pc_keys = set(re.findall(r"key:\s*'([a-zA-Z-]+)'", read(XT_ROUTES)))
|
||||
say('\n【⑤】菜单键:内置角色声明 %d 个 / PC 端 XtMenu %d 个'
|
||||
% (len(menu_keys), len(pc_keys)))
|
||||
for k in sorted(menu_keys - pc_keys):
|
||||
ex = menu_exception(k)
|
||||
if ex:
|
||||
notes.append('菜单键 %s —— 已知例外:%s' % (k, ex))
|
||||
say(' — %-14s %s' % (k, ex))
|
||||
else:
|
||||
problems.append(('菜单键', k, '内置角色声明了它,但 PC 端 XtMenu 里没有这个 key'))
|
||||
say(' ✗ %-14s 角色声明了,PC 端没有这个 key' % k)
|
||||
for k in sorted(pc_keys - menu_keys):
|
||||
problems.append(('菜单键', k, 'PC 端有菜单,但没有任何内置角色声明它(谁都看不到)'))
|
||||
say(' ✗ %-14s PC 端有,但没有任何角色能看到' % k)
|
||||
for k in sorted(menu_keys & pc_keys):
|
||||
say(' ✓ %s' % k)
|
||||
|
||||
# ── 汇总 ────────────────────────────────────────────────
|
||||
print('\n' + '=' * 88)
|
||||
if notes:
|
||||
print(' 已知例外 %d 条(有意不接线,写在本脚本上方的表里):' % len(notes))
|
||||
for n in notes:
|
||||
print(' — %s' % n)
|
||||
print(' 未接线的声明 %d 条:' % len(problems))
|
||||
for kind, name, why in problems:
|
||||
print(' ✗ [%s] %s —— %s' % (kind, name, why))
|
||||
if not problems:
|
||||
print(' (无)')
|
||||
print('=' * 88)
|
||||
sys.stdout.flush()
|
||||
return 1 if problems else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -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())
|
||||
+21
-10
@@ -292,11 +292,21 @@ def main():
|
||||
# ── 4. 审核超时提醒 ──
|
||||
print("\n[4] 审核超时提醒(时效与 §11.6 同口径)")
|
||||
# 乙的填报单刚提交 → 还没超时,先跑一次证明不发
|
||||
#
|
||||
# ⚠️ 断言必须**限定到本次夹具的收件人**,不能数全库的"审核超时"消息:
|
||||
# 超时扫描是跨租户的,库里只要存在**别的**老填报单(几天前别的脚本留下的、
|
||||
# 或者上一轮跑到一半留下的),它就会照样提醒 —— 于是这条断言会在
|
||||
# **几天之后突然变红**(那些单子的 submit_time 终于超过 48 小时了),
|
||||
# 而代码一行都没改。第一版就是这么写的,它在本轮真的红了。
|
||||
# 判据改成"这次涉及的两个人有没有收到",就与库里的历史数据无关了。
|
||||
def timeout_msgs_to(uid: int) -> str:
|
||||
return sql("select count(*) from collect_message where user_id=%d and biz_type=2 "
|
||||
"and title like '%%审核超时%%';" % uid)
|
||||
|
||||
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "timeout"})
|
||||
check("⭐ 刚提交的**不该**被提醒(否则每小时骚扰一次)",
|
||||
sql("select count(*) from collect_message where biz_type=2 "
|
||||
"and title like '%%审核超时%%';") == "0",
|
||||
sql("select count(*) from collect_message where biz_type=2;"))
|
||||
timeout_msgs_to(STU2) == "0",
|
||||
"乙收到的审核超时提醒 = " + timeout_msgs_to(STU2))
|
||||
# 把提交时间改到 5 天前(> 48 小时)→ 应该提醒
|
||||
st = one("select status from collect_submission where task_id=%d and submitter_id=%d;"
|
||||
% (urge_task, STU2))
|
||||
@@ -304,20 +314,21 @@ def main():
|
||||
"submission.status = " + st)
|
||||
R.sql("update collect_submission set submit_time = NOW() - INTERVAL '5 days' "
|
||||
"where task_id=%d and submitter_id=%d;" % (urge_task, STU2))
|
||||
R.sql("delete from collect_message where biz_type=2 and title like '%%审核超时%%';")
|
||||
body_of("POST", "/api/v1/schedules/run", ops_token, {"job": "timeout"})
|
||||
n = sql("select count(*) from collect_message where biz_type=2 "
|
||||
"and title like '%%审核超时%%';")
|
||||
check("⭐ 超时 5 天的被提醒了", n != "0", "提醒条数 = " + n)
|
||||
"and title like '%%审核超时%%' and content like '%%E2E 自动催报%%';")
|
||||
check("⭐ 超时 5 天的被提醒了", n != "0", "本次夹具的提醒条数 = " + n)
|
||||
check("⭐ 文案里带**等了多久**与**时效是多少**(审核员才知道超了多少)",
|
||||
"48" in sql("select content from collect_message where biz_type=2 "
|
||||
"and title like '%%审核超时%%' order by id desc limit 1;"),
|
||||
sql("select content from collect_message where biz_type=2 order by id desc limit 1;"))
|
||||
"and title like '%%审核超时%%' and content like '%%E2E 自动催报%%' "
|
||||
"order by id desc limit 1;"),
|
||||
sql("select content from collect_message where biz_type=2 "
|
||||
"and content like '%%E2E 自动催报%%' order by id desc limit 1;"))
|
||||
check("⭐ 提醒只发给**该动手的人**(班里没配负责人时不该发给所有人)",
|
||||
sql("select count(distinct user_id) from collect_message where biz_type=2 "
|
||||
"and title like '%%审核超时%%';") != "0",
|
||||
"and title like '%%审核超时%%' and content like '%%E2E 自动催报%%';") != "0",
|
||||
sql("select user_id, count(*) from collect_message where biz_type=2 "
|
||||
"group by user_id;").replace("\n", " | "))
|
||||
"and content like '%%E2E 自动催报%%' group by user_id;").replace("\n", " | "))
|
||||
|
||||
# ── 5. 原始件清理 ──
|
||||
print("\n[5] 原始件清理(raw_expire_time 到期)")
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
| 实体 ↔ 建表 | `check-entity-columns.py` | 无 |
|
||||
| 文档自身一致 | `verify-docs.py` | 无 |
|
||||
| 手册 ↔ 代码文案 | `check-manual.py` | 无 |
|
||||
| 接口行为(1548 条断言) | `scripts/e2e-*.py` | **后端在跑 + PG/Redis** |
|
||||
| 接线(定义 ↔ 使用) | `check-wiring.py` | 无 |
|
||||
| 接口行为(1577+ 条断言) | `scripts/e2e-*.py` | **后端在跑 + PG/Redis** |
|
||||
| 生产配置拦截 | `e2e-prodguard.py` | **能起 jar** |
|
||||
| CI 那个 profile 能不能起 | `check-test-profile.py` | 后端 jar + PG/Redis |
|
||||
|
||||
@@ -87,6 +88,8 @@ def main():
|
||||
r"(\d+) 个实体全部与迁移一致((\d+) 张表)"),
|
||||
("文档自身一致", "python scripts/verify-docs.py", r"结果: (\d+) 项全部通过"),
|
||||
("手册 ↔ 代码文案", "python scripts/check-manual.py", r"结果:.*"),
|
||||
("接线(定义↔使用)", "python scripts/check-wiring.py --quiet",
|
||||
r"未接线的声明 (\d+) 条"),
|
||||
]
|
||||
for name, cmd, pat in checks:
|
||||
code, out = run(cmd, timeout=300)
|
||||
|
||||
Reference in New Issue
Block a user