Files
xiaotutongxue/scripts/check-wiring.py
T
lovife 0bbac7d228 feat(pc,platform): 验收走查修复(平台按学校代管 + 菜单/403/日志范围/分页)
客户在验收里点出来的问题,一次改完。两段改动:

一、平台运营与租户范围(后端为主)
- 新增请求头 X-Tenant-Id:平台运营是唯一跨租户身份,租户拦截器对它整体关掉了过滤,
  于是「组织架构 / 账号管理 / 工作台」拿到的是所有租户混在一起的数据 —— 而只有一所
  学校有数据时,它和学校管理员看到的完全一样,看不出错(这是最危险的一点)。
  现在这些页面必须先选定学校,服务端再把本次请求的租户上下文换成目标租户:
  只认平台身份(其它身份带头一律忽略)、目标租户必须存在且启用。
- OrgService.tree / UserService.list 补显式 tenant_id 条件(原来只靠拦截器,而拦截器对
  平台不生效);TaskService.seesWholeTenant 改用新的 LoginUser.isPlatformStaff() ——
  isPlatformAdmin 回答"当前租户是不是平台租户",代管时它为 false,会让平台退化成普通
  用户(任务列表掉进"班主任只看自己带的班"分支,得到空列表);PLATFORM_ADMIN 补
  TASK_VIEW(PRD §3.2「查看填报进度看板:平台运营 ✅」)。
- 操作日志对平台永远是空的:SQL 写死 tenant_id = 当前租户,而平台的 tenantId 是 0。
  按契约 §13.4 补 tenantId 参数与 log:viewAll:平台默认看全量、可指定某所学校;
  学校管理员恒为本租户,传别家返回 40301(不静默忽略,否则会让人以为"筛过了")。
- 开放接口"推送重试"从未生效(F-112):定时扫描缺 @InterceptorIgnore,异常被 log.warn
  吞掉;同一方法里"接入方已停用"那条 update 也没有租户上下文,会每 10 秒重试到永远。
  E2E 补了"真的重试了"的断言(原来只断言"安排了下次重试")。

二、PC 端走查体验
- 去掉 3 条无用菜单(名单管理 / 表单设计器 / 材料规则):功能都在「收集任务」行内入口,
  菜单里那一条点进去只有指路;后端菜单键同步删除,entryAt 那套机制一并删掉。
- 班主任点工作台必弹 403:工作台无条件调了要 org:view / progress:viewAll 的两个接口。
  403 走 HTTP 状态码,try/catch 拦不住它的弹窗 —— 改成按权限决定发不发请求。
- 操作日志:分页器被裁在可视区外(新增 .g-list-card 让表体自己滚、表头钉住、分页器留在
  卡片底部)+ 去掉页面上那段免责声明;收集任务的状态切换改成单独一行 tab;去掉顶栏假数据
  铃铛;配色按客户要求还原。

顺带修两处"检查器自己不严":check-wiring 新增第 ⑦ 项(PC 端引用的权限码必须存在于
PermissionDef —— 本轮我自己把 progress:viewAll 写成 progress:view:all,判断恒为 false,
而编译 / typecheck / lint / E2E 全都不红);verify-docs 的表格列数检查不再把 GFM 转义
竖线当成列分隔符。两个检查器都拿"故意写错的样例"试过,确认真的会红。

验证:typecheck / mvn package / check-wiring(未接线声明 0 条)/ verify-docs(22 项)全过;
e2e-m0 36、e2e-m1-orgs 48、e2e-m0-user 55、e2e-m5-logs 44、e2e-m5-notify-wiring 43、
e2e-m1-tenant-domain 6、e2e-m8-open 61 全绿;浏览器按角色逐个真点复验。
详见 docs/交接摘要.md §一·三十三 与 §一·三十四。
2026-10-09 17:29:20 +08:00

547 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""接线检查:找「定义好了、代码里也写了,但**从来没有被接上**」的东西。
## 为什么需要它(这一族错在本仓库反复出现)
盘点这个项目时抓到的 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 | 菜单永远不显示(键拼错),或前端有菜单谁都看不到 |
| 7 | **PC 端引用的权限码 ↔ `PermissionDef`** | 判断恒为 false/true:整块界面静默消失(见下) |
### 第 7 项是 2026-10-09 补的(自己踩了一次)
在工作台按权限决定"发不发请求"时,我把 `progress:viewAll` 写成了 `progress:view:all`
(多一个冒号)。后果是那个判断**恒为 false**:「审核情况」整块对**所有人**都不显示 ——
而编译、typecheck、lint、后端日志、甚至 E2E **全都不红**。
这一族错的特征与上面六项完全一样:**声明对不上,谁都不报错**;
而"权限不足就少显示一块"看起来又很像设计(不是崩溃,是安静地少一块)。
## 它**不查**什么(免得被当成"全绿就没事")
- 不查"逻辑对不对":它只回答"有没有被引用过",一个被引用了但用错地方的常量
它照样算通过;
- 不查前端有没有把权限码用上(`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 = {}
# PC 端引用的权限码:PermissionDef 里没有的(**应当保持为空** ——
# 写错一个码的后果是"那个判断恒为 false",整块界面静默消失,见文件头第 7 项)
FE_PERM_EXCEPTION = {}
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 strip_js_comments(src):
"""去掉 JS/TS 的注释,**但要认引号**(不能见到 `//` 就砍)。
⚠️ 为什么需要它:第 ⑦ 项第一版把 `directives/hasPerm.ts` 里的
**注释与示例字符串**(`<button v-hasPerm="['home:btn:add']">`、报错文案里的示例)
当成了真实用法,一口气报了 5 条假阳性 —— 模板作者的示例码当然不在 PermissionDef 里。
这与本脚本 2026-10-08 修过的第 ③ 条是同一个教训(当时是 Java 注释里的提及
被当成接线):**检查器必须能区分"代码"与"解释代码的文字"**,
否则它要么天天误报、要么被人加一堆例外表绕过去。
"""
out = []
i, n = 0, len(src)
quote = None # 当前是否在字符串里(' 或 ")
while i < n:
c = src[i]
if quote:
out.append(c)
if c == '\\' and i + 1 < n: # 转义:连同下一个字符一起吞掉
out.append(src[i + 1])
i += 2
continue
if c == quote:
quote = None
i += 1
continue
if c in '\'"`':
quote = c
out.append(c)
i += 1
continue
if c == '/' and i + 1 < n and src[i + 1] == '/':
while i < n and src[i] != '\n':
i += 1
continue
if c == '/' and i + 1 < n and src[i + 1] == '*':
i += 2
while i + 1 < n and not (src[i] == '*' and src[i + 1] == '/'):
i += 1
i += 2
out.append(' ')
continue
out.append(c)
i += 1
return ''.join(out)
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)
# ── ⑦ PC 端引用的权限码 ↔ PermissionDef ─────────────────────
#
# 两个写法都认:`userStore.permissions.includes('org:view')` 与
# `v-hasPerm="['audit:final']"`(后者是模板里写的指令参数)。
#
# ⚠️ 这个检查器自己也要能被验:**故意写错一个码,它必须红**。
# 2026-10-09 加它的时候正是拿一个写错的码试过的(那时是真的写错了)。
perm_codes = set(re.findall(r'\(\s*"([^"]+)"\s*,\s*"', strip_java_comments(read(PERM_DEF))))
fe_codes = {} # code -> 出现位置(文件:行)
for p in sorted(glob.glob('frontend-pc/src/**/*.vue', recursive=True)
+ glob.glob('frontend-pc/src/**/*.ts', recursive=True)):
try:
raw = read(p)
except Exception:
continue
# 先去掉注释,再找用法 —— 否则注释/示例文案里的码会被当成真用法(假阳性)
src = strip_js_comments(raw)
is_vue = p.endswith('.vue')
for i, line in enumerate(src.split('\n'), 1):
for m in re.finditer(r"permissions\.includes\(\s*'([^']+)'", line):
fe_codes.setdefault(m.group(1), []).append('%s:%d' % (p, i))
# `v-hasPerm` 是**模板**里的写法:只在 .vue 里找,别去 .ts 的示例/报错文案里找
if is_vue:
for m in re.finditer(r"v-hasPerm\s*=\s*\"\[([^\]]*)\]\"", line):
for c in re.findall(r"'([^']+)'", m.group(1)):
fe_codes.setdefault(c, []).append('%s:%d' % (p, i))
say('\n【⑦】PC 端引用的权限码 %d 个 / PermissionDef 声明 %d 个'
% (len(fe_codes), len(perm_codes)))
for code in sorted(fe_codes):
where = fe_codes[code][0]
if code in perm_codes:
say(' ✓ %-22s %s' % (code, where))
elif code in FE_PERM_EXCEPTION:
notes.append('PC 端权限码 %s —— 已知例外:%s' % (code, FE_PERM_EXCEPTION[code]))
say(' — %-22s %s' % (code, FE_PERM_EXCEPTION[code]))
else:
problems.append(('前端权限码', code,
'PermissionDef 里没有这个码(判断会恒为 false:%s)' % where))
say(' ✗ %-22s PermissionDef 里没有它 → 这个判断永远是 false %s' % (code, where))
# ── 汇总 ────────────────────────────────────────────────
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())