Skip to content

诊断与排错 ​

适用端:后端 + Web | 类型:排错

定制功能

本页讲的五个诊断入口——权限诊断页、/system/permission-diag/check、SystemDiagnosticApi、启动期漂移检测、Agent 工具 diagnose_not_working——全部是本项目自研。上游 yudao 的权限模型是「角色 ↔ 菜单」两张表,配错的后果就是「菜单不显示」,没有可诊断的中间态,因此上游也没有任何诊断设施。

这一页讲**「配了但不生效」怎么查**。

它不是一篇接口说明。这个子系统最贵的缺陷形态不是报错,而是「配了、保存成功了、发布成功了、界面上一切正常,但就是没生效,且没有任何提示」——所有能看的地方看起来都是对的:界面上配置在、数据库里记录在、接口返回 200。线索只存在于几个特定位置的取值对不对得上,而那些位置分散在 system、infra、bpm、agent 四个模块里。

所以本页的组织方式是:先说清五个入口各自能回答什么、不能回答什么,再把全域已知的静默失效汇成一张「症状 → 根因 → 去哪查」的表(本页最后一节,也是唯一需要记住的一节)。

前置阅读:权限体系总览、授权与决策引擎、行过滤规则。诊断页的前端实现在前端授权页开发。

它解决什么问题 ​

五个入口,每个回答一个不同的问题:

入口谁触发回答什么读 / 写权限串
权限诊断页 /system/permission-diag/check人在浏览器里点这个人对这个权限点,判决是什么、依据是哪几行只读system:permission-diag:query
SystemDiagnosticApiJava 调用方(当前只有 Agent 工具)这条配置为什么没生效(结构事实,不是判决)只读无(进程内 API)
Agent 工具 diagnose_not_workingAI 助手同上,但把已知清单一次性跑完并给修法只读agent:assistant:use
PermissionDriftDetector启动期 @Order(3)代码里的权限点与库里的对不对得上写库无
PermissionBenchmarkRunner启动期 @Order(100),需开关七项判定耗时是否超门槛只读(但反复调用真实判定)无

五个入口里有一个会写库

PermissionDriftDetector 在启动期创建资源行(约定式推导 syncTree)并修改 parentId(repairParents,走 updateResource 会重算物化路径并级联更新全部后代)。其余四个都是只读。

「我只是想查一下」和「我一重启它就改了库」是两件事。排查前先知道自己站在哪一边——尤其是塌缩开关(collapse-enabled)切换前后,推导器写进去的东西完全不同(见下文「承载串」一节)。

代码在哪 ​

enlev-module-system/src/main/java/cn/enlev/ainb/module/system/
├── api/permission/
│   ├── SystemDiagnosticApi.java            53 行  进程内诊断 API(3 个方法)
│   ├── SystemDiagnosticApiImpl.java       152 行  实现:查资源、数授权、比租户、挑非法 fieldMode
│   └── dto/ResourceDiagnosticRespDTO.java  67 行  7 个字段,每个对应一类静默失效
├── controller/admin/permission/
│   └── PermissionDiagController.java      175 行  唯一 1 个端点 GET /system/permission-diag/check
└── framework/permission/
    ├── core/scan/
    │   ├── PermissionAnnotationScanner.java 71 行  从容器里正则抽 @ss.hasPermission 的字面量
    │   └── PermissionDriftDetector.java    451 行  漂移 / 陈旧 / 约定式推导 / repairParents
    ├── core/dataset/NativeDatasetScanner.java 168 行  classpath 扫 @PermissionDataset
    ├── core/decision/GrantDecisionEngine.java       Decision + DecisionReason(8 个理由)
    └── runner/
        ├── PermissionScanRunner.java        57 行  @Order(3),扫描 + 检测的唯一触发点
        └── NativeDatasetRegisterRunner.java 72 行  @Order(6),登记原生数据集

enlev-module-system/src/main/java/.../framework/permission/perf/
└── PermissionBenchmarkRunner.java          309 行  @Order(100),⚠️ 在 perf/ 不在 runner/

enlev-module-agent/src/main/java/cn/enlev/ainb/module/agent/tool/impl/diagnostic/
└── DiagnoseNotWorkingTool.java             416 行  4 种 targetType,输出 Finding 列表 + 免责句

ainb-ui-admin-vue3/src/
├── views/system/permissionDiag/index.vue   270 行  诊断页(下拉 + 三张结果表)
└── api/system/grant/index.ts:545-573              DiagGrantRow / PermissionDiagVO / diagPermission

两处目录不一致,都是有意为之

  1. PermissionBenchmarkRunner 在 framework/permission/perf/,其余六个 Runner 在 framework/permission/runner/。找它别只翻 runner/。
  2. 前端没有 src/api/system/permissionDiag/,diagPermission 挂在 src/api/system/grant/index.ts 里。这符合「诊断读的是授权行」的语义,但打破了本仓 api/<module>/<feature>/index.ts ↔ views/<module>/<feature>/index.vue 的配对约定——views/system/permissionDiag/ 有目录、api/system/ 下没有对应目录。同一件事在授权域还有一处:api/system/grant/ 有、views/system/grant/ 没有(页面在 views/system/role/grant/)。

核心机制 ​

启动链:七个 Runner 的顺序决定了日志该按什么顺序读 ​

@OrderRunner做什么写库失败后果
1ResourceMigrateRunnersystem_menu → sys_resource是后面全部无数据
2GrantMigrateRunnersystem_role_menu → sys_grant(依赖 1 已完成)是授权全丢
3PermissionScanRunner扫注解 → 漂移检测 → 约定式推导 → repairParents是见下
4BuiltInRuleSeedRunner播内置行级规则种子是规则下拉空
5DatasetTableRegisterRunner读 sys_resource 里已有的 DATASET 行,注册表名到内存否(改内存态)行级规则不被调用
6NativeDatasetRegisterRunner扫 @PermissionDataset → 登记原生数据集是单表失败不阻断,ERROR 留痕
100PermissionBenchmarkRunner七项耗时压测否默认不装配

PermissionScanRunner 的类注释写明了它为什么排 3:必须在 1、2 之后,sys_resource 里有迁移数据,漂移对比才有意义。NativeDatasetRegisterRunner 排 6 的理由也写在注释里——它自己就会调 registerTable,排在 5 后面是为了让「迁移 → 种子 → 重建内存态」这条既有链先完整跑完,本 Runner 只做增量。

顺序读错会得到假结论

「表名集合是内存态」这一点在行过滤规则里展开过:它由 5、6 两个 Runner 在启动时重建,不落库。所以**「我明明配了规则,怎么没生效」的第一件事是确认后端重启过**,而不是去查规则内容。诊断工具给的建议里也带着这句:「标完要重启(登记发生在启动时的 Runner 里)」。

漂移检测:三个数字,只有一个值得读 ​

PermissionDriftDetector#detect 返回 DriftReport(missing, stale, datasetDrivenStale, created),打出四条日志:

级别日志数字含义值得读吗
WARN[漂移:代码里的权限点在 sys_resource 里找不到承载行…]这些权限的鉴权永远返回 false🔴 必须读
INFO[陈旧·数据集驱动 N 个…]运行时生成的数据集资源,注解扫描器结构上扫不到属预期
INFO[陈旧·待查 N 个…]库里有、代码里没有,且不是运行时生成的未启用模块必然落在这里
INFO[约定式推导完成:新建 N 个节点]本次写库建了几行非 0 时要留意

报 stale 等于报一个常量

DriftReport#unexplainedStale() 的注释把这件事说得很直白:

调用方要报数字时报这个,不要报 stale —— 后者永久非零(塌缩落库后实测 137,其中 128 个是数据集驱动),报它等于报一个常量

PermissionScanRunner:47-53 就是照这条办的,注释写着「一个永远不变的告警数字,很快就没有人会再读」。这个后果比「多一条日志」严重:看日志的人一旦学会跳过它,真正值得查的那几条也一起被跳过了。

同理,[陈旧·待查] 那条只打数量与模块分布,不打全量权限串。原因是根 pom.xml 里大部分 module 是注释掉的(当前只启用 system/infra/bpm),而 system_menu 里有全部模块的菜单——那些模块的源码在磁盘上但不参与编译,扫描器自然扫不到,实测 1533 个(mes 316 / cms 188 / hrm 146 / erp 112 …),全是预期。早期实现把这 1533 个串整个拼进一行日志,除了淹没上面那条 WARN 之外没有任何作用。

🔴 塌缩开启后必须先换算成「承载串」 ​

这是整个检测器里最贵的一段逻辑,detect 里的注释直接写着「这一条是本次改造里最贵的那个坑」。

塌缩开启后,代码里的权限串与库里的权限串不再同名:代码写 system:user:query,而库里承载它的那一行 permission 是 system:user。直接拿两个集合相减会得到两件错事:

  1. 一条「几乎所有权限点都漂移了」的假 WARN,而它们其实一个都不漂移;
  2. syncTree 照着代码里的原始串推导,把塌缩退役掉的 457 个成员行原样建回来——而成员行一出现,判定侧的 R11 镜像就改按原始串查,授权却早已搬到身份行上,结果是每次重启后权限静默消失,且不报错、不回滚。

所以 detect 先调 collapsedLocator.identityOfAll(codePermissions) 换算成承载串再比。换算口径与判定侧 GrantDecisionService#lookupOf 同源,都走 PermissionStringResolver#identityOf。开关关闭时承载串就是原始串,行为与塌缩前逐字节一致。

datasetDrivenStale:判据是父行类型,不是写死前缀 ​

从「陈旧」里挑出注解扫描器结构上永远扫不到的那批。在线表单发布与原生数据集登记会在运行时按描述符建出列/字段/动作资源,它们的权限串是拼出来的(如 infra:form-design-run:device_manage:create),代码里从来没有对应的 @PreAuthorize——FormDesignRuntimeController 是拿 String.format 现场拼串再交给 checkPermission 的。

判据取「父行的类型 ∈ {DATASET, COLUMN, FIELD}」而不是写死 infra:form-design-run: 前缀,理由写在注释里:写死前缀会把一个跨模块的字符串常量埋进 system 模块,将来任何新的运行时资源族都得回来改这里,而漏改的表现正是「又变回永久噪声」。父行类型是数据本身的性质。实测这个判据把 238 行存活 ACTION 干净地切成 139(父行 MENU,代码侧)与 99(父行 DATASET,运行时侧),没有交叉。

java
// PermissionDriftDetector#datasetDrivenStale
String parentType = r.getParentId() == null ? null : typeOfId.get(r.getParentId());
if (parentType != null && datasetTypes.contains(parentType)) {
    out.add(r.getPermission());
}

那个 null 判必须显式写

别把它折回一行 datasetTypes.contains(typeOfId.get(..)):Set.of 造出来的是 ImmutableCollections.SetN,它对 contains(null) 抛 NPE 而不是返回 false(HashMap#get 那种宽容在这里不存在)。而「父行查不到」不是异常分支,是常态——根级行的 parentId 就是 0,库里没有 id=0 的行。

源码注释记着这次事故:「真炸过一次:detect 在启动期扫描里被调,NPE 会直接掀掉整个权限扫描」。

另一个方向也要留意:父行查不到时不归入「数据集驱动」,而是留在「待查」那一堆里。归错方向的代价不对称——把一条真该查的塞进「属预期」等于把它永久藏起来。

repairParents:每次都修 0 行,但不能删 ​

syncTree 的创建循环只新建缺失的节点,对已存在的一律 continue,所以它自己修不了任何旧行。而早期版本的 migrateFromMenu / syncFromMenu 建出来的资源全部是 parentId = ID_ROOT。当时的实测后果:840 个资源里 712 个(83%)挂在根下,比如 system:role:query 的路径是 /root/system:role:query/,而它应该是 /root/system/system:role/system:role:query/。

直接的业务影响是授权继承失效:给 system:role 授权本该覆盖它下面全部 system:role:*,但那些权限点挂在根下、不在它的资源链里,一个都继承不到。

两条创建路径后来都补上了 ResourceServiceImpl#attachToConventionParent,repairParents 也把存量修完了。塌缩落库后复测:存活 682 行里挂在根下的是 124 行、且全部是单段的模块根(system / infra / …),本来就该在根下;「直接约定父存在却不是实际父」= 0,「要靠逐层收窄才找得到祖先」= 0。

那个 0 是幂等性的测量,不是「死代码」的证明

《权限资源统一方案》阶段 3 第 12 条写着「删掉 repairParents,约定树成为唯一形态」,那个前提没有成立:

  • 新建时的 attachToConventionParent 只试直接约定父(a:b:c 只找 a:b),找不到就退回根下;
  • repairParents 还会逐层收窄(再试 a),并且能在「子先于父被建出来」之后补挂。

两者是同一件事的两半。而「子先于父被建出来」在菜单管理里随时可能发生——管理员按什么顺序建菜单,与权限串的层级没有任何关系。真发生时,能把它补挂回去的只有本方法。删掉它的后果是:那一行永远挂在根下,而挂在根下等于祖先链断了,给 a:b 授权覆盖不到它,且全程不报错。

修正走 updateResource 而不是直接改 parentId——它会重算物化路径并级联更新所有后代;只改 parentId 的话 path 还是旧的,而祖先链是沿 path 上溯算的,等于没修。

遍历的是库里全部资源,不是推导出的节点

推导器只扫得到代码注解里的权限串(实测 221 个),而库里有几百个资源——未启用模块、表单发布、流程部署产生的资源都不在扫描范围内。只遍历推导结果的话,那几百个资源永远修不到(第一版就是这么写的,只修了 101 个)。

注解扫描器的四个结构盲区 ​

「漂移」这个数字能信到什么程度,取决于 PermissionAnnotationScanner 扫得到什么。它只有 71 行,四个限制都写在代码里:

限制出处后果
只扫 @RestController / @RequestMapping 的 BeangetBeansWithAnnotation挂在 Service 方法上的 @PreAuthorize 扫不到
clazz.getDeclaredMethods()不含继承来的方法基类里的 @PreAuthorize 扫不到
正则 @ss\.hasPermission\(['\"]([^'\"]+)['\"]\)只认字面量单串形式hasAnyPermissions、常量拼接、SpEL 变量一律不可见
getName().contains("$$") → getSuperclass()剥 CGLIB 代理多层代理只剥一层

实测:盲区 1 当前没有受害者

grep -rln "@PreAuthorize" 在 enlev-module-* 的 main 源码里命中 13 个非 Controller 文件,但逐个核对后全部是注释里的文字提及(AgentSecurityGateway、FormDataPermissionChecker、DataInterfaceServiceImpl 等正是在解释「为什么这里不用 @PreAuthorize」)。也就是说:编程式鉴权的那些权限串确实扫描器看不见,但它们本来就不靠注解表达,所以当前不会造成假漂移。

这是「潜在盲区」而不是「现役缺陷」——记下来是为了将来有人在 Service 上加注解时知道它不会被检测。

/system/permission-diag/check 的四步 ​

四步里只有第 4 步是权威:conclusion 来自 grantDecisionService.hasPermission(...),走的是引擎那条完整优先级链。前三步(主体闭包、资源链、授权行)都是给人看的中间事实,而 reason 是从第 3 步另行推导的,不与第 4 步同源。

这个「结论与理由不同源」是本页最需要记住的一件事,下面三条分歧都由它产生。

🔴 分歧一:contextId 硬编码 null,上下文行却照常显示为「生效」 ​

PermissionDiagController:123 调的是:

java
boolean finalResult = grantDecisionService.hasPermission(targetUserId, permission, null, action);

第三个参数 contextId 写死 null。而引擎那边:

java
// GrantDecisionEngine:172
.filter(g -> g.isGlobal() || (contextId != null && contextId.equals(g.getContextResourceId())))
// GrantDecisionEngine:196
if (contextId != null) { /* CONTEXT_DENY / CONTEXT_ALLOW 两支 */ }

contextId == null 时,所有上下文授权行都被滤掉,两个上下文分支也都不进。

但诊断页拉数据用的 GrantMapper#selectListByResourceIds 只按 resourceId 过滤(注释写着「同时拉 context=NULL(全局)和 context=指定值的行」),而控制器算 effective 时也没有 isGlobal() 判断:

java
row.setEffective(subjectHit && actionHit && g.isActive());

于是一条上下文 DENY 会被标成「生效(拒)」、行底色刷红,而同一屏的 conclusion 是 ALLOWED。更糟的是 DiagRespVO.GrantRow 的 12 个字段里没有 contextResourceId,前端 VO 也没有——用户在页面上根本看不出这一行是上下文行。

排查流程节点字段权限时(那正是 contextId 唯一非空的场景),诊断页给不出有效信息。

🔴 分歧二:超管在诊断页上会看到假的「无权限」 ​

GrantDecisionService#isSuperAdmin 是 private,只被菜单与权限串列表那两条路径调用(:793、:944)。它的注释写得很清楚:

它的调用方 PermissionServiceImpl#hasAnyPermissions 已经先判过超管了。

也就是说超管短路发生在 PermissionServiceImpl(「情况一:超管直接放行」),不在 GrantDecisionService.hasPermission 里。而诊断页直接调后者。

后果:超管查一个自己没有任何授权、且 default_effect=DENY 的权限点,页面显示 🚫 无权限,而实际访问是放行的。这正好发生在最有可能做排查的那个账号上。

(isSuperAdmin 还有一条 fail-closed:判定抛异常时按非超管处理并 WARN——「宁可少给权限,也不能因为一次异常把全部权限放出去」。这条与诊断页无关,但同一段代码。)

🔴 分歧三:reason 的三种错法 ​

reason 的推导只有两个分支:

java
if (effective.isEmpty()) {
    resp.setReason("没有命中任何有效授权,回落到资源的 default_effect=" + chain.get(0).getDefaultEffect());
} else {
    resp.setReason(hasDeny ? "存在有效的 DENY 授权(id=…),DENY 优先级高于所有 ALLOW"
                           : "存在有效的 ALLOW 授权(id=…)");
}
  1. effect 是脏值时给出空 id 的句子。 引擎有一条 DecisionReason.NO_MATCHING_EFFECT:「命中了行,但没有任何一行的 effect 是 ALLOW 或 DENY(null / 脏值)」→ 静默拒绝。此时 effective 非空、hasDeny 为假,页面输出「存在有效的 ALLOW 授权(id=)」——一个空的 id 列表。而引擎源码对这个理由的评语正是「脏数据不该放大成放行,但也值得在诊断里看得见」,恰恰是看不见的。
  2. 引擎已经算好的 DecisionReason 被丢掉了。 GrantDecisionEngine#explain 返回 Decision(allowed, reason, decidingGrant, sourceResource, fromDefaultEffect)——「判决 + 判决理由 + 是谁投出的这一票」。诊断页调的是 hasPermission(薄封装,只返回 boolean),把这四个字段全部扔掉,再用中文重新推一遍。
  3. default_effect 那句读的是列,与引擎同源(引擎注释特意强调「读的是 sys_resource.default_effect 列,不是 ResourceTypeEnum#defaultEffect()」),这一条是对的——但它是四步里唯一同源的一处。

「判定必须由后端算」这条纪律,诊断页自己破了一半

GrantDecisionService:1085-1125 有一段很长的 🔴 注释,说明为什么判定不能在前端再抄一份,并点名了已经漂过的两次:

buildResourceChain(沿 path 上溯、不过滤 status)与 buildChainFromMap(沿 parentId 上溯、只含启用资源)在「停用祖先」场景分叉;GrantAdminController#listByResource 与 #toRespVOs 两处 expired 口径也不一致。

Decision record 的类注释也复述了这两次,并给出结论:「把『判决』和『理由』焊死在同一段代码里:isAllowed 退化成 explain(...).allowed(),两者在构造上不可能不一致」(见授权与决策引擎)。

本轮读码又找到两处同形的新漂移:诊断页的 effective 判据(分歧一、三),以及下文的 grantCount 同名不同义。它们都没有违反「不在前端抄判定」,因为漂移发生在后端内部——一个 Controller 用简化判据复述引擎的结论。焊死 isAllowed/explain 只保证了那一对不分叉,管不住第三个调用方自己再算一遍。

SystemDiagnosticApi:七个字段,每个对应一类静默失效 ​

这个接口刻意与 PermissionApi 分开,类注释给的理由是语义不同:

PermissionApi 回答「这个人能不能做这件事」,是业务判定;这里回答「这条配置为什么没生效」,是排查。前者的调用方是业务代码,后者的调用方是人(或替人排查的 AI)。

以及一条更实际的:诊断方法返回的是内部结构事实(有几行资源、租户对不对得上),业务代码不该看见这些,更不该有人拿它们去做判定。

字段查什么🔴 口径陷阱
permission回显入参—
registeredsys_resource 里有没有这个串走 selectListByPermission,过滤 status = 0:只有停用行的串会报 registered=false,工具据此建议「确认来源动作真的执行过」,而真相是行在、被停用了
resourceCount该串对应几行资源同上,停用行不计数。>1 时要小心「前端按钮显示了、点下去 403」
datasetTable数据集资源对应的物理表取第一条非空的;同串多行指向不同表是配置错误,但这里只如实报能查到的那一个
grantCount该资源上几条授权走 selectListByResourceIds,SQL 已滤掉过期行。与资源详情面板那个同名字段不同义(见下)
tenantMismatchGrantCounttenant_id 写错几条只判 ROLE 主体(用户、部门的租户来源不同,不能一并判);角色查不到时不算错配(那是「角色被删了」,另一个问题)
illegalFieldModes出现过哪些非法 fieldMode只看未过期行。合法值由 FieldModeEnum.values() 现算,不写死

另有两个方法:getNativeDatasetTables()(行级规则只对这些表生效)与 getLegalFieldModes()(供调用方校验,避免自己抄一份枚举)。

grantCount 在两个界面上同名不同义

  • 资源详情面板:ResourceAdminController:313 用 grantMapper.selectCountByResourceId(id),Mapper 上的注释是「⚠️ 不过滤 expireTime:已过期的行也算「有人配过」」。
  • 诊断工具:SystemDiagnosticApiImpl:79-81 用 selectListByResourceIds(...) 再 .size(),过期行已被 SQL 滤掉。

于是「资源详情显示授权数 3」与「diagnose_not_working 报『这个资源上没有任何授权』」可以同时为真。后者的建议是「到『权限管理』里给对应角色授权」——而真正该做的是给那 3 条续期。

这不是假想:GrantMapper 里同时存在两套计数方法,注释各自说明了自己为什么过滤 / 不过滤,诊断路径只是选错了那一套。

「资源上没有任何授权」还漏了另一种情况

appendGrantFindings 在 grantCount == 0 时直接 return,不再往下查租户与 fieldMode。逻辑上没错(没有行就没有行级问题),但它把「一条授权都没有」和「只有过期授权」压成了同一句话——而这两件事的修法完全相反:前者要新建,后者要续期。

Agent 工具 diagnose_not_working ​

java
@AgentTool(
        name = "diagnose_not_working",
        displayName = "诊断配了不生效问题",
        groupName = "诊断排错",
        permission = "agent:assistant:use",
        risk = RiskLevel.READ_ONLY)

四种 targetType,paramsSchema() 里逐字列出:

targetTypetarget 传什么查什么
process流程标识 key 或流程名称是否部署过 / 流程资源是否登记 / 🔴 审批节点 approveType
permission权限串,如 bpm:process:oa_leave资源是否登记 / 授权租户是否写错 / 字段模式是否合法
table物理表名,如 biz_purchase_apply这张表有没有登记为数据集
subField不需要子表字段分隔符的两份副本是否一致

返回 { targetType, target, problemCount, findings[], disclaimer },每条 Finding 是 { level, title, detail, advice? },level ∈ {PROBLEM, WARN, OK, INFO},只有 PROBLEM 计入 problemCount。

🔴 免责句每次必带,且有单测钉着 ​

java
/** 这句话每次都要出现在输出里,不是客套 */
private static final String DISCLAIMER =
        "⚠️ 以上只是静态比对的结果,不能替代在浏览器里真跑一遍。"
                + "这个子系统里「静态检查全过、实际仍然不生效」是常态 —— "
                + "请务必用一个真实账号走一遍完整流程来确认。";

类注释解释了为什么这不是客套:「『工具说没问题』很容易被当成『问题解决了』」。DiagnoseNotWorkingToolTest 的第一个用例 @DisplayName("🔴 输出里必须带「不能替代在浏览器里真跑一遍」") 就是钉这句的。

approveType:整套流程配置里唯一会静默失效的一条 ​

checkApproveType 递归遍历仿钉钉设计器的节点树(含 conditionNodes 分支),只看 type 为 11(审批人)或 13(办理人)的节点,比它们的 approveType 与 bpmModelApi.getProcessConstraints().getRequiredApproveType()。不等时报 PROBLEM,理由逐字是:

approveType 不等于 N 时,后端会把该节点的候选人、审批方式、字段权限、按钮、超时设置全部丢弃,而且部署照样成功、不报任何错。表现就是流程跑到这个节点没有任何人能审批、或者配的字段权限完全不起作用。

一条配置错,五项一起失效,且部署成功——这是全域静默失效里破坏面最大的一条。

若流程没有节点树(getModelSimpleJson 返回空),报 INFO「它可能是用 BPMN 设计器画的,本工具的节点检查不适用」,然后 return,不硬猜。

appendGrantFindings 的 begin 起点技巧 ​

java
// ⚠️ 记下起点:末尾的「看起来正常」只能基于【本方法新增的】结论来判断。
// 扫全量的话,流程诊断里一个无关的问题(比如「没部署过」)
// 就会把授权这块的正常结论吃掉,让人以为授权也有问题
int begin = findings.size();
…
if (findings.subList(begin, findings.size()).stream().noneMatch(Finding::problem)) {
    findings.add(Finding.ok("授权配置看起来正常", …));
}

这个方法被 diagnoseProcess 与 diagnosePermission 共用,findings 是同一个列表。不记起点就会串味。

subField:只比两处,刻意不比第三处 ​

比对「契约 JSON 里的值」与「bpm 字节码里内联的值」,不比 infra 的 Java 常量——那一侧由 SubFieldSeparatorSingleSourceTest 在构建期钉住,运行期再看没有新信息。

它保留的理由是 JLS 15.28:编译期常量会被内联,只重装 infra 的 jar 而没重装 bpm 就会真漂移,而这种漂移单测结构上看不见,运行期比对是唯一的出口。现象是「子表字段配了权限却不生效」,或反过来把有权限的字段裁掉——「从现象几乎不可能反推到分隔符」。

getNativeDatasetTables():行级规则生效范围的真值表 ​

生产代码里只有 2 处 @PermissionDataset

AdminUserDO(system:user → system_users)与 DeptDO(system:dept → system_dept)。也就是说 getNativeDatasetTables() 当前返回 2 张表,其余任何原生表上配的行级规则一次都不会被调用。

NativeDatasetRegisterRunner 的类注释正是为此而写:在此之前,进得了 RuleDataPermissionRule 表名集合的只有在线表单,「system_users、infra_file 这些原生模块的表一张都没有——在授权页上给它们配的行级规则配了不会被调用,且不报错」。

扫描用 classpath 而不是 Spring 容器,理由写在注释里:注解标在 DO 上,而 DO 不是 Bean,容器里根本没有它。要么把注解改标在 Service 上(那样表名与列名就离开了它们的定义处,迟早和 DO 漂移),要么扫 classpath。所以:

java
// false = 不带 Spring 默认的「必须是 @Component」过滤器,否则 DO 一个都扫不出来
ClassPathScanningCandidateComponentProvider provider =
        new ClassPathScanningCandidateComponentProvider(false);
provider.addIncludeFilter(new AnnotationTypeFilter(PermissionDataset.class));

扫描范围 BASE_PACKAGE = "cn.enlev.ainb.module",只扫业务模块不扫框架层(框架层没有业务表)。

五种「登记不上」的情形,每一种都 ERROR 留痕,注释给的理由都是同一句:静默跳过的表现是「这张表的行级权限从来不生效」,而那是查不出来的。

情形处理日志
两个类声明同一个数据集只采用前者[scan][数据集({})被重复声明:{} 与 {},只采用前者]
单个类加载失败跳过该类,不拖垮整轮[scan][加载类({})失败,其数据集声明本次未登记]
没写权限标识跳过[parse][类({})的 @PermissionDataset 没写权限标识,跳过]
既没写 table、类上也没 @TableName跳过[parse][数据集({})既没写 table、类({})上也没有 @TableName,跳过 —— 没有表名就注册不进行级规则,配了规则也不会被调用]
labels 条目格式非法(应为 字段名=中文名)忽略该条[parse][数据集({})的 labels 条目({})格式非法,正确格式为 字段名=中文名,已忽略]

table 留空时回落到 @TableName,为的是「让声明尽量贴着定义处,不必抄一遍表名」。表名与槽位列名一律 toLowerCase()——所以 getNativeDatasetTables() 返回的是小写集合,diagnoseTable 比对前也 toLowerCase(Locale.ROOT)。

DeptDO 上有一条值得单独抄下来的 ⚠️:

dept 槽位指向自己的 id,不是 parent_id。判据与旧的 DeptDataPermissionRule 逐字对齐——那边写的就是 rule.addDeptColumn(DeptDO.class, "id"):对部门表来说,「这条记录属于哪个部门」的答案是它自己。指到 parent_id 会变成「按上级部门过滤」,语义完全不同。

「配了但不生效」总表 ​

这是本页唯一需要记住的一节。全部条目都来自源码注释或本轮读码,没有一条是推测。「报错吗」那一列是这个子系统的关键属性:绝大多数是「否」。

症状根因去哪查报错吗
某功能谁都用不了,日志无异常代码里有 @PreAuthorize,sys_resource 里没有承载行启动日志 WARN [漂移:…];diagnose_not_working permission否(只有启动 WARN)
授权配了等于没配资源未登记 → 引擎 chains.isEmpty() 直接判无权,而授权记录看着好好的registered 字段否
授权配了等于没配(资源明明在)资源行被停用 → selectListByPermission 过滤 status=0,判定侧只认启用资源资源管理页看 status否
前端按钮显示了、点下去 403同一权限串挂在多行资源上,授权只配在其中一行,两侧口径不一致resourceCount > 1否
授权在任何租户下都匹配不上sys_grant.tenant_id 写成了默认 0,正确来源是角色的租户tenantMismatchGrantCount否
整行字段配置被丢弃fieldMode 传了非法值(最常见是 VISIBLE)illegalFieldModes否
一行授权完全是死的subjectType 写了枚举外的值(ROLES 复数、role 小写)→ parse 返回 null,闭包永远算不出它。写成 DENY 时更糟:配的人以为封住了,其实什么都没封直接查库;写入侧现已有 @InEnum 拦(GrantSaveReqVO:45),存量脏数据仍在否
行级规则一次都不被调用表没登记为数据集(没标 @PermissionDataset,或标了没重启)diagnose_not_working table否(登记失败才 ERROR)
行级规则突然全部失效表名集合是内存态,由 @Order(5)/@Order(6) 两个 Runner 重建;没重启就没有启动日志 [NativeDatasetRegisterRunner][扫描到 N 个…]否
内置规则不保护任何表BuiltInRuleSeedRunner 播的种子 built_in=0查 sys_rule.built_in否
规则停用了却仍然生效sys_rule.status 在判定时从不被读读码:判定链里没有 status 分支否
子表字段配了权限不生效子表分隔符两份副本漂移(只重装了 infra 的 jar,bpm 里是内联的旧值)diagnose_not_working subField否
流程节点没人能审批🔴 approveType 不等于 required → 候选人、审批方式、字段权限、按钮、超时全部丢弃,部署照样成功diagnose_not_working process否
节点级字段权限完全不起作用流程资源未登记 → contextId 解析为 null,退回全局判定diagnose_not_working process否
流程发起不了、也不在发起列表里流程从未部署model.deployed否
登录后导航栏什么都没有,接口全 200completeMenuAncestors 缺位 → buildMenuTree 整体丢弃。实测修复前 permissions 有 733 条而 menus 是 []读 GrantDecisionService:870-900否
给父资源授权,子资源一个都没继承到子资源挂在根下、不在父的资源链里(repairParents 修的就是这个)查 sys_resource.path否
每次重启后权限静默消失塌缩开启但比对/推导用了原始串 → 457 个退役成员行被建回来,判定侧改按原始串查而授权在身份行上detect 里的 identityOfAll 换算否,且不回滚
诊断页说「无权限」但实际能访问被诊断的是超管:短路在 PermissionServiceImpl,hasPermission 里没有见上文「分歧二」否
诊断页红底标「生效(拒)」但结论是 ALLOWED上下文授权行:引擎按 contextId=null 滤掉了它,页面没有见上文「分歧一」否

数据模型 ​

诊断没有自己的表——它读的全是别人的。四个输出结构:

DriftReport(record,PermissionDriftDetector 内嵌)

字段类型含义
missingSet<String>代码里有、库里找不到承载行。塌缩开启时是承载串(system:user)而不是原始串(system:user:query)
staleSet<String>库里有、代码里没有的 ACTION 行。含 datasetDrivenStale
datasetDrivenStaleSet<String>stale 的子集:运行时生成的数据集资源,属预期
createdint本次约定式推导新建了几个节点

hasDrift() 只看 missing;unexplainedStale() = stale - datasetDrivenStale。

ResourceDiagnosticRespDTO(7 字段)——见上文表格。

DiagRespVO(PermissionDiagController 内嵌)

字段类型备注
userId / permission / action—回显入参;userId 不填时取当前登录用户
subjectsList<String>主体闭包,格式 类型:refId 或 code,已排序
resourceChainList<ResourceRespVO>从最具体到根
allGrantsList<GrantRow>资源链上的全部授权行
conclusionStringALLOWED / DENIED,来自引擎
reasonString⚠️ 来自简化判据,与 conclusion 不同源

GrantRow 12 字段:id、subjectType、subjectRefId、subjectCode、resourceId、action、effect、expireTime、active、subjectHit、actionHit、effective。没有 contextResourceId——这是「分歧一」在数据层的根因。

Finding(record,DiagnoseNotWorkingTool 内嵌,private):(title, detail, advice, level, problem),四个静态工厂 problem / warn / ok / info。toMap() 里 advice 为空时不放进 map(OK 类结论通常没有建议)。

NativeDatasetDescriptor(record,9 字段):permission、name、table(小写)、slots(槽位名 → 列名,小写)、fields、columns、labels、sensitive、sourceClass(仅用于日志与排障)。

fields 与 columns 列同样的名字,生成互不影响的两套资源

AdminUserDO 上的注释解释了为什么:「这样才能配出『列表里不显示手机号,但点进详情能看』」。fields 对应 :fld:(详情字段),columns 对应 :col:(列表列)。

同一段注释还有一个容易反着理解的点:那里列的是「允许被字段级权限控制的字段」,不是「会被隐藏的字段」。没登记的字段配也配不出来——授权页的资源树里根本没有那一项。

接口清单 ​

HTTP:1 个端点 ​

方法路径权限串说明
GET/system/permission-diag/checksystem:permission-diag:queryuserId 可选(不填=当前用户)、permission 必填、action 默认 VIEW

这个权限串是全 Controller 里唯一的例外

GrantAdminController:238-240 的注释专门点了名:

🔴 沿用本 Controller 全部读接口共用的权限串,角色授权页已有的权限点直接够用——不需要新增菜单/权限串、不需要动数据库。(PermissionDiagController 用独立的 system:permission-diag:query,那是唯一例外,不要抄)

诊断页自己不挂复杂权限点,类注释给的理由是「看诊断结果的往往是超管或运维,挂太细反而自己把自己堵住」。

前端 API:1 个函数 ​

diagPermission(permission, action?, userId?) 在 src/api/system/grant/index.ts:563-573,返回 PermissionDiagVO。DiagGrantRow 与后端 GrantRow 逐字段对齐(同样没有 contextResourceId)。

进程内 API:SystemDiagnosticApi ​

方法返回用途
diagnoseResource(String permission)ResourceDiagnosticRespDTO一个权限点的结构事实
getNativeDatasetTables()Set<String>(小写)行级规则只对这些表生效
getLegalFieldModes()List<String>合法 fieldMode,供调用方校验,避免自己抄一份枚举

Agent 工具:diagnose_not_working ​

risk = RiskLevel.READ_ONLY、permission = "agent:assistant:use"、groupName = "诊断排错"。resultArrayKeys() 返回 ["findings"](见 AgentToolFunction#resultArrayKeys:本工具全部返回分支里数组值的键的并集)。

日志锚点 ​

排查时按这些串 grep 启动日志:

级别锚点含义
INFO[PermissionScanRunner][扫描到 N 个权限串]扫描器的可见范围
WARN[PermissionDriftDetector][漂移:🔴 唯一必须处理的
WARN[PermissionScanRunner][⚠️ 发现 N 个漂移权限点同上的汇总
INFO[陈旧·数据集驱动 N 个属预期
INFO[陈旧·待查 N 个看模块分布,不看明细
INFO[约定式推导完成:新建 N 个节点]本次写库量
INFO[修正 N 个挂错父节点的资源]只在 N>0 时打
WARN[创建资源节点失败 / [repairParents][修正失败单点失败,不阻断
WARN[repairParents][资源({}) 挂到 {} 会成环,跳过]防成环生效了
ERROR[scan][数据集({})被重复声明只采用前者,被忽略那份可能写着不同槽位列
ERROR[parse][…跳过]这张表的行级权限本次不生效
INFO[NativeDatasetRegisterRunner][扫描到 N 个原生数据集声明,成功登记 M 个]N≠M 时看上面的 ERROR
WARN[isSuperAdmin][用户({})的超管判定失败,按非超管处理]fail-closed 生效了
ERROR[benchmark][找不到压测数据…]见下文坑 12

怎么扩展 ​

往「配了不生效」清单里加一条检查 ​

  1. 如果新检查需要读 system 模块的结构事实,在 ResourceDiagnosticRespDTO 上加字段,并在 SystemDiagnosticApiImpl#diagnoseResource 里填。不要在 Agent 工具里直接查库——工具在 agent 模块,跨过去只能走 API。
  2. 在 DiagnoseNotWorkingTool 对应的 diagnoseXxx 里加 Finding。选 level 的口径:problem 计入 problemCount 且意味着「必须修」,warn 是「要留意但不一定错」(例:一个串对应多行资源),ok 用于显式确认某项正常,info 用于「本工具不适用」。
  3. 若新检查是共用的(多个 targetType 都要跑),放进 appendGrantFindings,并注意 begin 起点那段——不要改成扫全量。
  4. advice 要写「怎么修」,不只写「哪里错」。现有条目的 advice 都带着具体动作(「把这些节点的 approveType 改成 N 后重新部署」「全量 mvn install 再重启后端」)。
  5. 在 DiagnoseNotWorkingToolTest 加用例。该测试是 BaseMockitoUnitTest,三个 API 全靠 mock,不需要 DB。

给一张原生表开行级权限 ​

  1. 在 DO 上标 @PermissionDataset(value = "模块:实体", name = "中文名", …)。table 可以留空,会回落到 @TableName。
  2. 按需要填槽位:owner / dept / status / bizKey(默认 id,主键不叫 id 的表要显式覆盖)。列名一律会被 toLowerCase()。
  3. fields(详情可控字段,:fld:)与 columns(列表可控列,:col:)按需填——没填的字段在授权页的资源树里根本不出现。
  4. labels 用 字段名=中文名 格式,格式非法会被忽略并 ERROR。
  5. 重启后端(登记发生在 @Order(6) 的 Runner 里)。
  6. 用 diagnose_not_working 的 targetType=table 验一遍——它会告诉你这张表在不在已登记集合里,以及当前集合一共几张表。

加一个诊断端点 ​

抄 PermissionDiagController 的形状,但注意两件事:结论必须调 GrantDecisionService(不要在 Controller 里复述判定),以及权限串的取舍——独立的 system:permission-diag:query 是被明确标注为「唯一例外,不要抄」的,新端点默认应沿用所在 Controller 已有的权限串。

坑与注意 ​

  1. 🔴 诊断页的 contextId 硬编码 null(PermissionDiagController:123)。上下文授权行会被引擎滤掉,但页面照常显示为「生效」并刷红/绿底色,GrantRow 里没有 contextResourceId 可辨。排查流程节点字段权限时,这个页面给不出有效信息。
  2. 🔴 超管在诊断页上会看到假的「无权限」。超管短路在 PermissionServiceImpl#hasAnyPermissions(情况一),GrantDecisionService#isSuperAdmin 是 private 且只服务菜单/权限串列表两条路径。诊断页直接调 hasPermission,绕过了短路。
  3. 🔴 reason 与 conclusion 不同源。conclusion 来自引擎完整优先级链,reason 来自 effective = subjectHit && actionHit && isActive() 这个简化判据。effect 是脏值时会输出「存在有效的 ALLOW 授权(id=)」这种空 id 句子。
  4. 🔴 引擎已经算好的 DecisionReason 被丢掉了。explain 返回「判决 + 理由 + 是谁投出的这一票 + 是否来自 default_effect」,诊断页调 hasPermission 只拿 boolean,再用中文重推一遍。NO_MATCHING_EFFECT 的源码评语是「值得在诊断里看得见」——恰恰看不见。
  5. 🔴 grantCount 在两个界面同名不同义。资源详情面板含过期行(selectCountByResourceId),诊断工具不含(selectListByResourceIds)。两边同时为真时,诊断给的建议(新建授权)与真正该做的事(续期)相反。
  6. 🔴 registered=false 不等于「没登记」。selectListByPermission 过滤 status=0,只有停用行的串也报未登记,而工具的建议指向「确认来源动作执行过」。ResourceMapper 里另有一个 selectByPermissionIncludeDisabled 正是为「停用的行同样占位」而存在,诊断路径没用它。
  7. 🔴 PermissionDriftDetector 会写库,是五个入口里唯一的一个。约定式推导创建资源行,repairParents 改 parentId 并级联重算后代的物化路径。
  8. 🔴 塌缩开启后比对与推导必须用承载串,否则把 457 个退役成员行原样建回来,判定侧改按原始串查而授权在身份行上——每次重启后权限静默消失,不报错、不回滚。源码注释称其为「本次改造里最贵的那个坑」。
  9. 🔴 repairParents 每次修 0 行,但不能删。那是幂等,不是死代码:创建侧只试直接约定父、找不到就退回根下,而「子先于父被建出来」在菜单管理里随时可能发生。方案文档里「删掉它」的前提没有成立。
  10. 🔴 Set.of(...).contains(null) 抛 NPE 而不是返回 false。「父行查不到」是常态(根级行 parentId=0,库里没有 id=0 的行),而 detect 在启动期被调,NPE 会掀掉整个权限扫描。真炸过一次。
  11. 🔴 approveType 错一条,五项配置一起失效(候选人、审批方式、字段权限、按钮、超时),且部署照样成功。这是流程侧唯一会静默失效的一条。
  12. ⚠️ PermissionBenchmarkRunner 按注释跑不起来。类注释与 log.error 都指向 python script/perf/gen_perf_data.py --yes,但仓库里 script/ 下只有 docker/idea/jenkins/livekit-poc/shell,没有 perf/ 目录(find 全库无 gen_perf_data*)。开着 benchmark-enabled 启动只会得到一条 ERROR 然后返回。同一段注释还说「五项门槛」,而 measure(...) 实际调了 7 次(主体闭包冷/热、资源祖先链、单次鉴权、条件树→SQL、参与表派生可见性、参与表待办分页)。与本仓「注释里引用的 docs/*.md 已不存在」是同一类问题。
  13. ⚠️ 诊断页那个「已过期」标签几乎不可能正确渲染。后端 SQL 已滤掉过期行,所以到得了前端的行 expireTime 要么为 null 要么在未来。三分支的实际后果:过期行根本不出现;!active && expireTime != null 只可能是「subjectType 脏 + 到期时间在未来」→ 标着「已过期」而其实没过期;!active && expireTime == null → 落到 v-else 显示「永不过期」,而它 active=false 不参与决策,页面上看不出为什么。
  14. ⚠️ isActive() 不只是「未过期」,它还要求 SubjectTypeEnum.parse(subjectType) != null。所以 active=false 有两种成因,页面上无法区分。
  15. ⚠️ expired 字段在 GrantAdminController 里两处口径不一致::191 是 setExpired(!g.isActive())(脏 subjectType 会被报成「已过期」),:382 是 setExpired(expireTime != null && now.isAfter(expireTime))(看不到脏 subjectType)。这是引擎注释点名的两次历史漂移之一,至今仍在。
  16. ⚠️ 诊断页有两处死代码:diagMaxHeight / recalcDiagHeight(:219-220,全库无其它引用,却注册着 resize 监听),以及上一条那个「已过期」标签。
  17. ⚠️ 注解扫描器有四个结构盲区(只扫 web Bean、getDeclaredMethods 不含继承、正则只认字面量单串、CGLIB 只剥一层)。实测当前没有受害者——非 Controller 文件里的 @PreAuthorize 全是注释提及——但编程式鉴权的权限串(Agent 工具的 permission 属性、FormDataPermissionChecker 等)确实不在检测范围内。
  18. ⚠️ resolveResourceChain 只返回第一条链。一个 permission 可能对应多行资源(本库当前 2 个串如此),判定侧 hasPermission 是逐条都算的。排查「授权配了为什么不生效」时要留意:你配的那行未必是这里显示的这条链。
  19. ⚠️ 塌缩开启后 resolveResourceChain 返回身份行的链,不是代码里那个原始串的行。这是刻意的:「返回一条库里已经软删的行的链,只会把人引到错误的地方去查」。
  20. ⚠️ resolveResourceChain 的 action 位填 VIEW 只为让 Lookup 两位成对,与诊断请求里的 action 参数无关。别把它当成「按 VIEW 查的链」。
  21. ⚠️ datasetTable 取第一条非空的。同一权限串的多行资源指向不同表是配置错误,但诊断只如实报能查到的那一个,不会告警。
  22. ⚠️ countTenantMismatch 只判 ROLE 主体,且角色查不到时不算错配(那是「角色被删了」,另一个问题)。用户、部门主体的租户来源不同,不能一并判。
  23. ⚠️ appendGrantFindings 在 grantCount == 0 时直接 return,不再查租户与 fieldMode。它把「一条授权都没有」和「只有过期授权」压成了同一句话。
  24. ⚠️ Finding 是 private record,level 是字符串不是枚举。加工具侧的调用方只能读 toMap() 的输出。
  25. ⚠️ subField 诊断刻意不比 infra 的 Java 常量。那一侧由 SubFieldSeparatorSingleSourceTest 在构建期钉住。它保留的唯一理由是 JLS 15.28 常量内联——只重装 infra 的 jar 而没重装 bpm 会真漂移,且单测结构上看不见。
  26. ⚠️ diagnoseProcess 在没有节点树时直接 return(报 INFO 后不再检查 approveType)。用 BPMN 设计器画的流程,节点检查完全不适用。
  27. ⚠️ @Order 数字之间留了空档(1-6 连续,然后跳到 100)。插新 Runner 时别顺手把 100 改成 7——PermissionBenchmarkRunner 的注释要求它「排在所有登记/迁移 Runner 之后,量的才是稳定状态」。
  28. ⚠️ 漂移检测不阻止启动。类注释写明「告警就够了,防止『偶发漂移导致整机无法启动』」。所以「后端起来了」不代表没有漂移。
  29. ⚠️ NativeDatasetScanner 用 ClassPathScanningCandidateComponentProvider(false)。那个 false 是关掉 Spring 默认的「必须是 @Component」过滤器——改成 true 的话 DO 一个都扫不出来,且不报错,表现是全部原生表的行级权限静默失效。
  30. ⚠️ 诊断页的动作下拉给全量词表、不分「生效/预留」组。这是刻意的:预留动作也要能选,才能实测出「配了确实不生效」。别按授权页的分组口径去「统一」它。
  31. ⚠️ 诊断页保留 allow-create。权限串有 600+ 个且格式严格,纯手打错一个字符就诊断出「无此资源」,而那和「确实没权限」在结果页上长得一模一样。保留 allow-create 是因为诊断本来就可能要查一个还没建资源行的串。

→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。

测试 ​

测试类基类@Test覆盖
PermissionDriftDetectorTestBaseDbUnitTest(H2)9无漂移 / 拼错串告警 / 部分漂移 / 自动建目录 / 幂等 / 陈旧仅告警不删除 / 陈旧分类三分支
DiagnoseNotWorkingToolTestBaseMockitoUnitTest13免责句必带、分隔符两侧一致与不一致、表登记与未登记、资源未登记、租户写错、非法 fieldMode、多行资源、approveType 错、流程资源未登记、未部署、流程不存在
NativeDatasetScannerTestBaseMockitoUnitTest5完整声明、@TableName 回落、🔴 无表名必须跳过、bizKey 默认与覆盖、⚠️ 真实接入样例(AdminUserDO)也要扫得到
ResourceServiceImplCollapseTestBaseDbUnitTest11(含漂移段)塌缩与 ResourceServiceImpl / PermissionDriftDetector 的交互

两个测试类的第一个用例都是「最该被钉住的那条」:DiagnoseNotWorkingToolTest 是 @DisplayName("🔴 输出里必须带「不能替代在浏览器里真跑一遍」"),NativeDatasetScannerTest 里那条 🔴 是「没有表名的声明必须被跳过——登记出一个没有表的数据集,行级规则永远匹配不上它」。

PermissionDriftDetectorTest 用 @Import({PermissionDriftDetector.class, ResourceServiceImpl.class, …}) 真跑 H2,所以「约定式推导_幂等」这个用例是真幂等(跑两遍 detect,第二遍 created 必须是 0),不是 mock 出来的。

零测试的四处

  1. PermissionDiagController——本页「三条分歧」全部在这个类里,一个测试都没有。
  2. SystemDiagnosticApiImpl——countTenantMismatch 与 collectIllegalFieldModes 的边界(角色查不到、fieldMode 为空、多行资源取第一条非空表名)无测试。
  3. PermissionScanRunner——「报 unexplainedStale 而不是 stale」这条纪律只写在注释里,没有测试钉住;改回 stale 不会有任何测试变红。
  4. PermissionBenchmarkRunner——309 行,零测试,且按注释跑不起来(坑 12)。

第 3 条最值得补:它是「一个永远不变的告警数字很快就没有人会再读」这条判断的唯一执行点,而它没有任何机械保障。

未证实 ​

  • 本页所有「实测 N」的数字——457 个退役成员行、840/712(83%)、682/124、221 个扫描到的权限串、101 个第一版修的行数、238 行 ACTION 切成 139/99、1533 个陈旧(mes 316 / cms 188 / hrm 146 / erp 112)、137/128、733 条 permissions 对空 menus、库里有 2 个串对应多行资源——全部来自源码注释或历史快照,本轮没有重新查库。GrantDecisionService:482-484 对这一类数字的态度可以直接搬过来:别把任何一个数字当常量用。
  • PermissionBenchmarkRunner 只读了类注释、@Order/@ConditionalOnProperty、SAMPLES/WARMUP 常量与 7 个 measure(...) 调用点,没有读 measure、report、participatedKeys、todoPage 的实现。七项门槛的具体数值(5.0 / 0.5 / 2.0 / 10.0 / 3.0 / 100.0 / 100.0 ms)是从调用点抄的,「中位数与 P95、不取平均」的理由来自常量注释。
  • 七项门槛是否真的能过没有验证——因为造数脚本不存在(坑 12),本轮没有也不可能跑起来。
  • ResourceMigrateRunner、GrantMigrateRunner、BuiltInRuleSeedRunner、DatasetTableRegisterRunner 四个 Runner 只从 @Order 与其它类的注释里得知其职责,没有读过源码。启动链表格里的「做什么」与「失败后果」两列对这四个是转述。
  • 「非 Controller 文件里的 @PreAuthorize 全是注释提及」这一结论来自对 6 个 grep 命中文件的逐行核对,核对的是 grep -n "@PreAuthorize" 的输出上下文,没有通读那 6 个文件。
  • SubjectTypeEnum.parse 返回 null 的行为读了枚举类注释(:19-25)与 GrantSaveReqVO:31-46,没有读 parse 的方法体。
  • 「诊断页那个『已过期』标签几乎不可能正确渲染」是从 SQL 过滤条件 + isActive() 定义 + 模板三分支推导出来的,没有在浏览器里构造脏数据实测。同理,「超管看到假的无权限」与「上下文行显示为生效」两条也是读码推导,未实测。
  • completeMenuAncestors 的 733/空 menus 那一节引自 GrantDecisionService:870-900 的注释,本轮只核了注释本身,没有复现。
  • 前端 permissionDiag/index.vue 只读了模板与 rowClass,runDiag、loadOptions、formatResourceType 等函数体是上一轮读的,本轮未复核。

相关页面 ​