Files
lovife b516a7c47b fix(tenant,log)!: 平台身份下用量统计算成全平台 + 5 处租户/日志缺陷
做「平台运营的租户管理」时挖出来的,其中第 1 条与那个功能本身无关、但严重得多。

1) 🔴 GET /tenants/{id}/usage 一直算的是**全平台**的数。平台运营的 tenantId 是 0,而
   FillTenantLineHandler.ignoreTable() 对平台身份直接返回 true —— 整张表的租户过滤被关掉,
   于是 distinctStudents()(查 collect_roster)与存储量(查 collect_file)都不带租户条件,
   返回全平台数字却标着某一所学校的名字,运营会拿它去谈续费。而这个接口的调用者**一定是**
   平台运营(tenant:view 在 platformOnlyCodes 里),所以它从来只在"过滤被关掉"的上下文里跑过。
   ⚠️ 本地完全看不出来:实测 34 行 collect_roster、22 行 collect_file 全在 tenant_id=1,
   只有一所学校有数据时错误值与正确值**完全相等**。修法:统计段包进既有的 TenantScope.callAs,
   切走后 isPlatformAdmin() 变 false、过滤自动恢复;**没有**手写 tenant_id 条件 —— 那等于
   放弃拦截器的保护。

2) PUT /tenants/{id} 把手机号**明文**写进加密列(create 走 codec.encrypt,update 是裸赋值)。
   AesGcmCipher.decrypt 对非 Base64 直接抛(该类没有 dev 旁路)→ 存过一次电话,这所学校的
   详情页就永远打不开。库里 id=1 存的就是明文 13800138000。改为非空才加密写入(空 = 不修改),
   详情返回脱敏值;**读路径必须容错**(解密失败按明文处理 + WARN),否则故障形态是"永久 500",
   看起来像系统坏了、而不是"库里有一行旧数据"。历史明文行不做迁移(迁移里拿不到密钥)。

3) 租户两个枚举"能建不能改":tenantType 只放行 1~3(实体/DDL/契约/前端都是 4 值)、
   status 只放行 0/1(实体有 2 试用,而前端的状态筛选按钮里就有「试用」,选了永远是空列表)。
   已按真实枚举放开,报错文案与实体注释逐字对齐。

4) 开通/修改/停用租户原先一条日志都不写(只有给运维看的 log.warn/log.info)。新增
   MODULE_TENANT 与 CREATE_TENANT/UPDATE_TENANT/DISABLE_TENANT/ENABLE_TENANT —— 停用与启用
   不并进 UPDATE_TENANT:停用是这套系统里影响面最大的单个动作,出争议时第一个要看的就是这个
   区别。⚠️ 这些行的 tenant_id 是 0(写日志用的是**操作者**的上下文),查"这所学校被谁停过"
   要用 target_type=TENANT + target_id,**不能**用 tenantId 筛(反直觉,已写进常量注释)。

5) expireBefore 用 atStartOfDay() 与存进去的当天 23:59:59 比 → **差一天**,「今天到期」的租户
   查不出来(同型的坑在 LogController.parseTo 上修过一次,那次是"查 9 月 30 日查不到当天")。
   改为补到当天末尾。格式不对返回 40001 并说明格式 —— **刻意不学** parseTo 的"当没传":
   那侧是界面上的自由文本输入,静默忽略是为了"输错了也别让人看不到日志";这侧的值只由界面按
   当前日期算出来,静默忽略筛选会让人以为"我按到期日筛过了"。

6) 顺带:
   - AUTO_CLOSE(定时任务自动截止)在 TaskService 里是**字符串字面量**、不在常量表里 ——
     也就是说"后端常量 ↔ 前端文案"这类检查**本来就扫不到它**,而它正是量最大的那类任务日志,
     界面上会原样回显英文码。提成常量(连带 STATUS_CHANGE 兜底名)。
   - 日志列表 SELECT 补 request_method/request_uri/extra_json 三个**轻字段**(实测一页 20 行
     约 2 KB),日志详情因此不需要新开接口;真正重的 request_param/duration_ms 全库 1758 行里
     **0 行有值**(从来没有 setXxx 过),不采集。
   - LoginLogVO 补 tenantId:平台运营看到的登录日志是**跨单位**的,没有这一列就分不清一行是
     哪所学校的,而"某个账号什么时候登进来的"一定得连着"哪个单位"一起回答。
   - 修正 LoginLogVO.userType 的 Javadoc:原来写着「1 超管 / 2 管理员 / 3 教师 / 4 学生」,
     与权威口径(1 平台运营 / 2 学校管理员 / 3 班主任 / 4 学生)错位 —— **同一个坑第三次出现**,
     该错位枚举曾导致"把班主任当成社会人员拒掉"。

新增检查 scripts/check-wiring.py 第 ⑩ 项:后端会写的动作码 ↔ PC 端 OPERATION_ACTIONS 的文案,
逐字核对(缺一个文案,日志页就会原样回显英文码)。**已反向验证**:删掉一条文案 → 报红 + exit 1。
⚠️ 写这个检查时踩到的坑:同文件里 MODULE_OPTIONS / TARGET_TYPE_OPTIONS 也是 { value: 'TASK' }
的形状,整文件扫会把模块名当成动作码收进来 —— 所以只从 OPERATION_ACTIONS 那个数组里取。

验证:python scripts/verify-all.py --with-e2e → **9 项全过 / 0 失败 / 0 跳过**(37 脚本 /
1617 断言)。e2e-m8-orgfile.py **74/74** —— 其中"用量只算这一所学校"这条**必须造夹具**才
证伪得了:它往新建的 E2E 租户**直插一行 collect_roster**(该表 NOT NULL 且无默认值的列只有 5 个,
不需要走导入夹具),再断言"接口值 == 库里 where tenant_id=1 的去重数",并**额外断言
"全平台去重数 > 一号学校去重数"**,否则这条断言是空转的。e2e-m5-logs.py 44/44(回归)。
重打包重启后再验一次:/logs/logins 返回 tenantId、/tenants/1 返回 138****8000(而不是 500)——
判据是**看返回体里有没有新字段**,而不是"接口通了就算"。
2026-10-13 14:04:48 +08:00

733 lines
38 KiB
Python
Raw Permalink 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:整块界面静默消失(见下) |
| 8 | PC 端模板里的 `<n-*>` 组件有没有对应 `import` | 被当未知元素渲染:不报错,样式整块丢(见下) |
| 9 | **`.svg` 资产是不是合法 XML** | 图标裂开,控制台一句话都不报(见下) |
| 10 | **后端会写的日志动作码 ↔ PC 端 `OPERATION_ACTIONS` 的文案** | 日志页原样回显英文码(见下) |
### 第 7 项是 2026-10-09 补的(自己踩了一次)
在工作台按权限决定"发不发请求"时,我把 `progress:viewAll` 写成了 `progress:view:all`
(多一个冒号)。后果是那个判断**恒为 false**:「审核情况」整块对**所有人**都不显示 ——
而编译、typecheck、lint、后端日志、甚至 E2E **全都不红**。
这一族错的特征与上面六项完全一样:**声明对不上,谁都不报错**;
而"权限不足就少显示一块"看起来又很像设计(不是崩溃,是安静地少一块)。
### 第 8 / 9 项是 2026-10 补的(重搭 PC 端之后)
第 8 项:仓库**没有 auto-import**,漏 import 的 `<n-button>` 被当成未知自定义元素,
只丢样式不报错(实测 4 个文件 6 处)。
第 9 项:SVG 是 XML,**注释里出现连续两个减号**(比如顺手写下 CSS 自定义属性的
写法)就会让整个文件 not well-formed。浏览器对 `<img src="x.svg">` 的失败完全静默 ——
控制台不打、`vue-tsc` 不打、`eslint` 不打,页面上只是那个图标裂开。
换品牌标时连踩两次:`logo.svg` 的注释里写了变量名,登录页与侧栏的标直接裂图,
而 typecheck / lint / 本脚本全绿。
后两项严格说不是"声明没接线",而是**同一族:坏了但没有任何工具会红**。
放这里是因为它想要的就是"一条命令把这一族扫一遍"。
### 第 10 项是 2026-10 补的(做租户管理与日志详情时)
日志页的 `actionText()` **查不到就原样回显那个码**:界面上出现 `AUTO_CLOSE`
这种没翻译的英文,编译 / lint / typecheck / review 全都不红。
加这一项时顺带发现了一件更值得记的事:`AUTO_CLOSE` **根本不在常量表里** ——
它在 `TaskService.autoClose()` 里是用字符串字面量写的。也就是说,
"后端常量 ↔ 前端清单"这种检查**本来就扫不到它**;写这个检查的同时
得先把字面量提成常量(`ACTION_AUTO_CLOSE`),检查才真的有覆盖。
**这一族检查的价值,取决于被检查的那一侧有没有被规范化。**
⚠️ 与第 7 项同一个教训:**加完必须反向验证**(删掉前端一条文案,它得红),
否则又是一个"扫不到却判为通过"。
## 它**不查**什么(免得被当成"全绿就没事")
- 不查"逻辑对不对":它只回答"有没有被引用过",一个被引用了但用错地方的常量
它照样算通过;
- 不查前端有没有把权限码用上(`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 xml.dom.minidom
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 = {
'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 = {}
# 第 ⑩ 项:后端会写的日志动作码,却**有意**不在 PC 端给中文文案的。
# 现在没有例外 —— 少一个文案就会在日志页上原样回显英文码,没有"故意不翻译"的理由。
ACTION_TEXT_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):
"""站内信模板的"发送点" = 代码里有人把这个码当成模板码用。
⚠️ 判据不能用"标识符出现次数":模板码常常**同时是别的常量的值** ——
例如 `URGE` 既是站内信的模板码,又是催报业务类型(`SUBJECT_URGE`)的名字;
早先的 `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']"`(重搭前的指令参数,保留兼容)
# · `user.has('task:manage')` / `user.hasAny('a','b')`
# —— **2026-10 重搭为纯 Vue 3 + Naive 后的唯一写法**(已无 v-hasPerm 指令)。
# ⚠️ 少了这一条,本项会永远报"PC 端引用的权限码 0 个":
# 看起来是"通过",实际是**检查器失效**(安全网静默消失,正是本脚本要防的那类错)。
#
# ⚠️ 这个检查器自己也要能被验:**故意写错一个码,它必须红**。
# 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))
# `user.has('码')` / `user.hasAny('码', ...)`。
# ⚠️ 这里加了 `域:动作` 的形状约束:`.has(` 在别处也用于 Set/Map
# (`changed.has(key)`、`set.has(r.orgId)`),不约束形状会收进一堆假阳性。
# 本项目的权限码一律是 `域:动作` 形状(见 PermissionDef)。
for m in re.finditer(r"\.has(?:Any)?\(([^)]*)\)", line):
for c in re.findall(r"'([a-zA-Z]+:[a-zA-Z]+)'", m.group(1)):
fe_codes.setdefault(c, []).append('%s:%d' % (p, i))
# `v-hasPerm` 是**模板**里的写法:只在 .vue 里找,别去 .ts 的示例/报错文案里找
# (指令本身已随 2026-10 重搭移除,这里保留是为了旧分支/文档示例仍能被查出来)
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))
# ── ⑧ PC 端 `<n-*>` 组件 ↔ import ────────────────────────────
#
# ⚠️ 为什么它属于「声明了但没接上」:本仓库**没有 auto-import**(见 frontend-pc/AGENTS.md
# 第 1 条),漏 import 的后果是 Vue 把它当成**未知自定义元素**渲染 ——
# 不报错、不崩、lint 与 vue-tsc 也全绿,只是样式整块丢掉。
# 2026-10 重搭成纯 Vue 3 + Naive 后实测踩到 4 个文件 6 处:
# `<n-button>` 变成无边框透明块、`<n-avatar>` 变成 18×29 的裸元素、
# `<n-form>` 的表单排版全丢 —— 表现就是"这个页面样式坏了",但控制台只给一句 warn。
#
# 判定方式:在 `<template>` 段里出现的 `<n-xxx>`,其 PascalCase 名字必须在本文件
# 的某个 `import { ... } from '...'` 里出现过。
naive_missing = []
for p in sorted(glob.glob('frontend-pc/src/**/*.vue', recursive=True)):
try:
raw = read(p)
except Exception:
continue
m = re.search(r'<template>(.*)</template>', raw, re.S)
if not m:
continue
used = set(re.findall(r'<(n-[a-z0-9-]+)[\s/>]', m.group(1)))
if not used:
continue
imported = set()
for im in re.finditer(r'import\s+([^;]+?)\s+from\s+[\'"][^\'"]+[\'"]', raw, re.S):
clause = im.group(1)
brace = re.search(r'\{([^}]*)\}', clause, re.S)
if brace:
for item in brace.group(1).split(','):
item = item.split(' as ')[-1].strip()
if item:
imported.add(item)
head = clause.split('{')[0].strip().rstrip(',')
if head and not head.startswith('*'):
imported.add(head)
for tag in sorted(used):
comp = ''.join(part.capitalize() for part in tag.split('-'))
if comp not in imported:
naive_missing.append((p, tag, comp))
say('\n【⑧】PC 端 `<n-*>` 组件未 import 的 %d 处' % len(naive_missing))
seen_files = set()
for p, tag, comp in naive_missing:
problems.append(('未 import 的组件', '<%s>' % tag,
'%s 里没 import %s(会被当成未知元素渲染,样式整块丢失)'
% (p, comp)))
if p not in seen_files:
seen_files.add(p)
say(' %s' % p)
say(' ✗ <%-16s 需要 import { %s } from \'naive-ui\'' % (tag + '>', comp))
if not naive_missing:
say(' ✓ 模板里用到的 <n-*> 组件都有对应 import')
# ── ⑨ SVG 资产是不是合法 XML ──────────────────────────────
#
# ⚠️ 为什么它属于这一族:SVG 是 XML,**注释里出现连续两个减号**
# (比如顺手写下 CSS 自定义属性的写法)就会让整个文件 not well-formed。
# 浏览器对 `<img src="x.svg">` 的失败**完全静默** ——
# 控制台不打、vue-tsc 不打、eslint 不打,页面上只是那个图标裂开。
# 2026-10 换品牌标时连踩两次:logo.svg 注释里写了变量名,
# 登录页(34px)与侧栏(24px)的标一起变成裂图,而三样检查全绿。
#
# 判定方式:拿标准库把每个 .svg 当 XML 解析一遍 —— 不需要浏览器,
# 也不依赖某个具体 SVG 特性。
svg_paths = []
for pattern in ('frontend-pc/src/**/*.svg', 'frontend-pc/public/**/*.svg',
'frontend-mp/src/**/*.svg', 'backend/**/*.svg'):
svg_paths.extend(glob.glob(pattern, recursive=True))
svg_paths = sorted(p for p in set(svg_paths)
if '/node_modules/' not in p and '\\node_modules\\' not in p
and '/dist/' not in p and '\\dist\\' not in p
and '/target/' not in p and '\\target\\' not in p)
svg_bad = []
for p in svg_paths:
try:
xml.dom.minidom.parse(p)
except Exception as e:
svg_bad.append((p, str(e)))
say('\n【⑨】SVG 资产 %d 个 / XML 非法的 %d 个' % (len(svg_paths), len(svg_bad)))
for p, why in svg_bad:
problems.append(('SVG 资产', p,
'%s(浏览器静默不渲染:图标裂开,任何检查都不报)' % why))
say(' ✗ %s\n %s' % (p, why))
if not svg_bad:
say(' ✓ 全部能被解析为合法 XML')
# ── ⑩ 后端会写的日志动作码 ↔ PC 端的中文文案 ──────────────
#
# ⚠️ 为什么属于这一族:PC 端的 `actionText()` **查不到就原样回显那个码**
# (不是空白,也不报错)。于是日志页上会出现一个没翻译的英文码,
# 而编译 / lint / typecheck / review **全都不红** —— 只有人盯着那一列才看得出来。
#
# ⚠️ 加这一项时顺带发现的:`AUTO_CLOSE`(定时任务自动截止)**根本不在常量表里**,
# 是字符串字面量写的 —— 所以"常量 ↔ 前端清单"这种检查本来就扫不到它。
# 已提成 `OperationLogService.ACTION_AUTO_CLOSE`,这条检查才真的有覆盖。
#
# ⚠️ 刻意**只从 `OPERATION_ACTIONS` 这个数组里取**前端清单:同文件里
# `MODULE_OPTIONS` / `TARGET_TYPE_OPTIONS` 也是 `{ value: 'TASK' }` 的形状,
# 整文件扫会把 `FILE` / `TENANT` 这些模块名当成动作码收进来(假阳性)。
LOG_SERVICE = ('backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/'
'service/OperationLogService.java')
PC_LOG_API = 'frontend-pc/src/apis/xt/log.ts'
be_actions = set(re.findall(r'public static final String ACTION_\w+ = "([A-Z_]+)";',
strip_java_comments(read(LOG_SERVICE))))
blk = re.search(r'export const OPERATION_ACTIONS[^=]*=\s*\[(.*?)\n\]',
strip_js_comments(read(PC_LOG_API)), re.S)
fe_actions = set(re.findall(r"value:\s*'([A-Z_]+)'", blk.group(1) if blk else ''))
say('\n【⑩】日志动作码:后端常量 %d 个 / PC 端文案 %d 个'
% (len(be_actions), len(fe_actions)))
if not be_actions or not fe_actions:
# 解析失败必须**报红**,不能静默通过 —— 第 ⑦ 项就是栽在这里
# (重搭后写法变了,它"一个码都扫不到却判为通过")。
problems.append(('日志动作码', '解析失败',
'后端解析出 %d 个 / PC 端解析出 %d 个 —— 0 就是检查器自己失效了,'
'不是"没问题"' % (len(be_actions), len(fe_actions))))
say(' ✗ 解析失败(后端 %d / PC %d):0 表示检查器失效,先修检查器'
% (len(be_actions), len(fe_actions)))
else:
for code in sorted(be_actions - fe_actions):
if code in ACTION_TEXT_EXCEPTION:
notes.append('日志动作码 %s —— 已知例外:%s' % (code, ACTION_TEXT_EXCEPTION[code]))
say(' — %-18s %s' % (code, ACTION_TEXT_EXCEPTION[code]))
else:
problems.append(('日志动作码', code,
'后端会写它,但 PC 端 OPERATION_ACTIONS 没有文案'
'(日志页会原样显示这个英文码)'))
say(' ✗ %-18s 后端会写,PC 端没文案 → 界面回显英文码' % code)
for code in sorted(fe_actions - be_actions):
problems.append(('日志动作码', code,
'PC 端有文案,但后端没有任何 ACTION_* 常量等于它'
'(选中它永远筛不到东西)'))
say(' ✗ %-18s PC 端有文案,后端没这个码 → 选了永远筛不到' % code)
if be_actions == fe_actions:
say(' ✓ %d 个动作码两端逐字一致:%s'
% (len(be_actions), '、'.join(sorted(be_actions))))
# ── 汇总 ────────────────────────────────────────────────
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())