b516a7c47b
做「平台运营的租户管理」时挖出来的,其中第 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)—— 判据是**看返回体里有没有新字段**,而不是"接口通了就算"。
733 lines
38 KiB
Python
733 lines
38 KiB
Python
# -*- 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())
|