# -*- 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 端模板里的 `` 组件有没有对应 `import` | 被当未知元素渲染:不报错,样式整块丢(见下) | | 9 | **`.svg` 资产是不是合法 XML** | 图标裂开,控制台一句话都不报(见下) | ### 第 7 项是 2026-10-09 补的(自己踩了一次) 在工作台按权限决定"发不发请求"时,我把 `progress:viewAll` 写成了 `progress:view:all` (多一个冒号)。后果是那个判断**恒为 false**:「审核情况」整块对**所有人**都不显示 —— 而编译、typecheck、lint、后端日志、甚至 E2E **全都不红**。 这一族错的特征与上面六项完全一样:**声明对不上,谁都不报错**; 而"权限不足就少显示一块"看起来又很像设计(不是崩溃,是安静地少一块)。 ### 第 8 / 9 项是 2026-10 补的(重搭 PC 端之后) 第 8 项:仓库**没有 auto-import**,漏 import 的 `` 被当成未知自定义元素, 只丢样式不报错(实测 4 个文件 6 处)。 第 9 项:SVG 是 XML,**注释里出现连续两个减号**(比如顺手写下 CSS 自定义属性的 写法)就会让整个文件 not well-formed。浏览器对 `` 的失败完全静默 —— 控制台不打、`vue-tsc` 不打、`eslint` 不打,页面上只是那个图标裂开。 换品牌标时连踩两次:`logo.svg` 的注释里写了变量名,登录页与侧栏的标直接裂图, 而 typecheck / lint / 本脚本全绿。 后两项严格说不是"声明没接线",而是**同一族:坏了但没有任何工具会红**。 放这里是因为它想要的就是"一条命令把这一族扫一遍"。 ## 它**不查**什么(免得被当成"全绿就没事") - 不查"逻辑对不对":它只回答"有没有被引用过",一个被引用了但用错地方的常量 它照样算通过; - 不查前端有没有把权限码用上(`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 = {} 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` 里的 **注释与示例字符串**(`