诊断与排错
适用端:后端 + Web | 类型:排错
定制功能
本页讲的五个诊断入口——权限诊断页、/system/permission-diag/check、SystemDiagnosticApi、启动期漂移检测、Agent 工具 diagnose_not_working——全部是本项目自研。上游 yudao 的权限模型是「角色 ↔ 菜单」两张表,配错的后果就是「菜单不显示」,没有可诊断的中间态,因此上游也没有任何诊断设施。
这一页讲**「配了但不生效」怎么查**。
它不是一篇接口说明。这个子系统最贵的缺陷形态不是报错,而是「配了、保存成功了、发布成功了、界面上一切正常,但就是没生效,且没有任何提示」——所有能看的地方看起来都是对的:界面上配置在、数据库里记录在、接口返回 200。线索只存在于几个特定位置的取值对不对得上,而那些位置分散在 system、infra、bpm、agent 四个模块里。
所以本页的组织方式是:先说清五个入口各自能回答什么、不能回答什么,再把全域已知的静默失效汇成一张「症状 → 根因 → 去哪查」的表(本页最后一节,也是唯一需要记住的一节)。
前置阅读:权限体系总览、授权与决策引擎、行过滤规则。诊断页的前端实现在前端授权页开发。
它解决什么问题
五个入口,每个回答一个不同的问题:
| 入口 | 谁触发 | 回答什么 | 读 / 写 | 权限串 |
|---|---|---|---|---|
权限诊断页 /system/permission-diag/check | 人在浏览器里点 | 这个人对这个权限点,判决是什么、依据是哪几行 | 只读 | system:permission-diag:query |
SystemDiagnosticApi | Java 调用方(当前只有 Agent 工具) | 这条配置为什么没生效(结构事实,不是判决) | 只读 | 无(进程内 API) |
Agent 工具 diagnose_not_working | AI 助手 | 同上,但把已知清单一次性跑完并给修法 | 只读 | 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两处目录不一致,都是有意为之
PermissionBenchmarkRunner在framework/permission/perf/,其余六个 Runner 在framework/permission/runner/。找它别只翻runner/。- 前端没有
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 的顺序决定了日志该按什么顺序读
@Order | Runner | 做什么 | 写库 | 失败后果 |
|---|---|---|---|---|
| 1 | ResourceMigrateRunner | system_menu → sys_resource | 是 | 后面全部无数据 |
| 2 | GrantMigrateRunner | system_role_menu → sys_grant(依赖 1 已完成) | 是 | 授权全丢 |
| 3 | PermissionScanRunner | 扫注解 → 漂移检测 → 约定式推导 → repairParents | 是 | 见下 |
| 4 | BuiltInRuleSeedRunner | 播内置行级规则种子 | 是 | 规则下拉空 |
| 5 | DatasetTableRegisterRunner | 读 sys_resource 里已有的 DATASET 行,注册表名到内存 | 否(改内存态) | 行级规则不被调用 |
| 6 | NativeDatasetRegisterRunner | 扫 @PermissionDataset → 登记原生数据集 | 是 | 单表失败不阻断,ERROR 留痕 |
| 100 | PermissionBenchmarkRunner | 七项耗时压测 | 否 | 默认不装配 |
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。直接拿两个集合相减会得到两件错事:
- 一条「几乎所有权限点都漂移了」的假 WARN,而它们其实一个都不漂移;
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,运行时侧),没有交叉。
// 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 的 Bean | getBeansWithAnnotation | 挂在 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 调的是:
boolean finalResult = grantDecisionService.hasPermission(targetUserId, permission, null, action);第三个参数 contextId 写死 null。而引擎那边:
// 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() 判断:
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 的推导只有两个分支:
if (effective.isEmpty()) {
resp.setReason("没有命中任何有效授权,回落到资源的 default_effect=" + chain.get(0).getDefaultEffect());
} else {
resp.setReason(hasDeny ? "存在有效的 DENY 授权(id=…),DENY 优先级高于所有 ALLOW"
: "存在有效的 ALLOW 授权(id=…)");
}effect是脏值时给出空 id 的句子。 引擎有一条DecisionReason.NO_MATCHING_EFFECT:「命中了行,但没有任何一行的effect是 ALLOW 或 DENY(null / 脏值)」→ 静默拒绝。此时effective非空、hasDeny为假,页面输出「存在有效的 ALLOW 授权(id=)」——一个空的 id 列表。而引擎源码对这个理由的评语正是「脏数据不该放大成放行,但也值得在诊断里看得见」,恰恰是看不见的。- 引擎已经算好的
DecisionReason被丢掉了。GrantDecisionEngine#explain返回Decision(allowed, reason, decidingGrant, sourceResource, fromDefaultEffect)——「判决 + 判决理由 + 是谁投出的这一票」。诊断页调的是hasPermission(薄封装,只返回 boolean),把这四个字段全部扔掉,再用中文重新推一遍。 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 | 回显入参 | — |
registered | sys_resource 里有没有这个串 | 走 selectListByPermission,过滤 status = 0:只有停用行的串会报 registered=false,工具据此建议「确认来源动作真的执行过」,而真相是行在、被停用了 |
resourceCount | 该串对应几行资源 | 同上,停用行不计数。>1 时要小心「前端按钮显示了、点下去 403」 |
datasetTable | 数据集资源对应的物理表 | 取第一条非空的;同串多行指向不同表是配置错误,但这里只如实报能查到的那一个 |
grantCount | 该资源上几条授权 | 走 selectListByResourceIds,SQL 已滤掉过期行。与资源详情面板那个同名字段不同义(见下) |
tenantMismatchGrantCount | tenant_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
@AgentTool(
name = "diagnose_not_working",
displayName = "诊断配了不生效问题",
groupName = "诊断排错",
permission = "agent:assistant:use",
risk = RiskLevel.READ_ONLY)四种 targetType,paramsSchema() 里逐字列出:
targetType | target 传什么 | 查什么 |
|---|---|---|
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。
🔴 免责句每次必带,且有单测钉着
/** 这句话每次都要出现在输出里,不是客套 */
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 起点技巧
// ⚠️ 记下起点:末尾的「看起来正常」只能基于【本方法新增的】结论来判断。
// 扫全量的话,流程诊断里一个无关的问题(比如「没部署过」)
// 就会把授权这块的正常结论吃掉,让人以为授权也有问题
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。所以:
// 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 | 否 |
| 登录后导航栏什么都没有,接口全 200 | completeMenuAncestors 缺位 → buildMenuTree 整体丢弃。实测修复前 permissions 有 733 条而 menus 是 [] | 读 GrantDecisionService:870-900 | 否 |
| 给父资源授权,子资源一个都没继承到 | 子资源挂在根下、不在父的资源链里(repairParents 修的就是这个) | 查 sys_resource.path | 否 |
| 每次重启后权限静默消失 | 塌缩开启但比对/推导用了原始串 → 457 个退役成员行被建回来,判定侧改按原始串查而授权在身份行上 | detect 里的 identityOfAll 换算 | 否,且不回滚 |
| 诊断页说「无权限」但实际能访问 | 被诊断的是超管:短路在 PermissionServiceImpl,hasPermission 里没有 | 见上文「分歧二」 | 否 |
| 诊断页红底标「生效(拒)」但结论是 ALLOWED | 上下文授权行:引擎按 contextId=null 滤掉了它,页面没有 | 见上文「分歧一」 | 否 |
数据模型
诊断没有自己的表——它读的全是别人的。四个输出结构:
DriftReport(record,PermissionDriftDetector 内嵌)
| 字段 | 类型 | 含义 |
|---|---|---|
missing | Set<String> | 代码里有、库里找不到承载行。塌缩开启时是承载串(system:user)而不是原始串(system:user:query) |
stale | Set<String> | 库里有、代码里没有的 ACTION 行。含 datasetDrivenStale |
datasetDrivenStale | Set<String> | stale 的子集:运行时生成的数据集资源,属预期 |
created | int | 本次约定式推导新建了几个节点 |
hasDrift() 只看 missing;unexplainedStale() = stale - datasetDrivenStale。
ResourceDiagnosticRespDTO(7 字段)——见上文表格。
DiagRespVO(PermissionDiagController 内嵌)
| 字段 | 类型 | 备注 |
|---|---|---|
userId / permission / action | — | 回显入参;userId 不填时取当前登录用户 |
subjects | List<String> | 主体闭包,格式 类型:refId 或 code,已排序 |
resourceChain | List<ResourceRespVO> | 从最具体到根 |
allGrants | List<GrantRow> | 资源链上的全部授权行 |
conclusion | String | ALLOWED / DENIED,来自引擎 |
reason | String | ⚠️ 来自简化判据,与 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/check | system:permission-diag:query | userId 可选(不填=当前用户)、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 |
怎么扩展
往「配了不生效」清单里加一条检查
- 如果新检查需要读
system模块的结构事实,在ResourceDiagnosticRespDTO上加字段,并在SystemDiagnosticApiImpl#diagnoseResource里填。不要在 Agent 工具里直接查库——工具在agent模块,跨过去只能走 API。 - 在
DiagnoseNotWorkingTool对应的diagnoseXxx里加Finding。选 level 的口径:problem计入problemCount且意味着「必须修」,warn是「要留意但不一定错」(例:一个串对应多行资源),ok用于显式确认某项正常,info用于「本工具不适用」。 - 若新检查是共用的(多个
targetType都要跑),放进appendGrantFindings,并注意begin起点那段——不要改成扫全量。 advice要写「怎么修」,不只写「哪里错」。现有条目的advice都带着具体动作(「把这些节点的approveType改成 N 后重新部署」「全量mvn install再重启后端」)。- 在
DiagnoseNotWorkingToolTest加用例。该测试是BaseMockitoUnitTest,三个 API 全靠 mock,不需要 DB。
给一张原生表开行级权限
- 在 DO 上标
@PermissionDataset(value = "模块:实体", name = "中文名", …)。table可以留空,会回落到@TableName。 - 按需要填槽位:
owner/dept/status/bizKey(默认id,主键不叫id的表要显式覆盖)。列名一律会被toLowerCase()。 fields(详情可控字段,:fld:)与columns(列表可控列,:col:)按需填——没填的字段在授权页的资源树里根本不出现。labels用字段名=中文名格式,格式非法会被忽略并 ERROR。- 重启后端(登记发生在
@Order(6)的 Runner 里)。 - 用
diagnose_not_working的targetType=table验一遍——它会告诉你这张表在不在已登记集合里,以及当前集合一共几张表。
加一个诊断端点
抄 PermissionDiagController 的形状,但注意两件事:结论必须调 GrantDecisionService(不要在 Controller 里复述判定),以及权限串的取舍——独立的 system:permission-diag:query 是被明确标注为「唯一例外,不要抄」的,新端点默认应沿用所在 Controller 已有的权限串。
坑与注意
- 🔴 诊断页的
contextId硬编码null(PermissionDiagController:123)。上下文授权行会被引擎滤掉,但页面照常显示为「生效」并刷红/绿底色,GrantRow里没有contextResourceId可辨。排查流程节点字段权限时,这个页面给不出有效信息。 - 🔴 超管在诊断页上会看到假的「无权限」。超管短路在
PermissionServiceImpl#hasAnyPermissions(情况一),GrantDecisionService#isSuperAdmin是 private 且只服务菜单/权限串列表两条路径。诊断页直接调hasPermission,绕过了短路。 - 🔴
reason与conclusion不同源。conclusion来自引擎完整优先级链,reason来自effective = subjectHit && actionHit && isActive()这个简化判据。effect是脏值时会输出「存在有效的 ALLOW 授权(id=)」这种空 id 句子。 - 🔴 引擎已经算好的
DecisionReason被丢掉了。explain返回「判决 + 理由 + 是谁投出的这一票 + 是否来自 default_effect」,诊断页调hasPermission只拿 boolean,再用中文重推一遍。NO_MATCHING_EFFECT的源码评语是「值得在诊断里看得见」——恰恰看不见。 - 🔴
grantCount在两个界面同名不同义。资源详情面板含过期行(selectCountByResourceId),诊断工具不含(selectListByResourceIds)。两边同时为真时,诊断给的建议(新建授权)与真正该做的事(续期)相反。 - 🔴
registered=false不等于「没登记」。selectListByPermission过滤status=0,只有停用行的串也报未登记,而工具的建议指向「确认来源动作执行过」。ResourceMapper里另有一个selectByPermissionIncludeDisabled正是为「停用的行同样占位」而存在,诊断路径没用它。 - 🔴
PermissionDriftDetector会写库,是五个入口里唯一的一个。约定式推导创建资源行,repairParents改parentId并级联重算后代的物化路径。 - 🔴 塌缩开启后比对与推导必须用承载串,否则把 457 个退役成员行原样建回来,判定侧改按原始串查而授权在身份行上——每次重启后权限静默消失,不报错、不回滚。源码注释称其为「本次改造里最贵的那个坑」。
- 🔴
repairParents每次修 0 行,但不能删。那是幂等,不是死代码:创建侧只试直接约定父、找不到就退回根下,而「子先于父被建出来」在菜单管理里随时可能发生。方案文档里「删掉它」的前提没有成立。 - 🔴
Set.of(...).contains(null)抛 NPE 而不是返回 false。「父行查不到」是常态(根级行parentId=0,库里没有id=0的行),而detect在启动期被调,NPE 会掀掉整个权限扫描。真炸过一次。 - 🔴
approveType错一条,五项配置一起失效(候选人、审批方式、字段权限、按钮、超时),且部署照样成功。这是流程侧唯一会静默失效的一条。 - ⚠️
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已不存在」是同一类问题。 - ⚠️ 诊断页那个「已过期」标签几乎不可能正确渲染。后端 SQL 已滤掉过期行,所以到得了前端的行
expireTime要么为 null 要么在未来。三分支的实际后果:过期行根本不出现;!active && expireTime != null只可能是「subjectType 脏 + 到期时间在未来」→ 标着「已过期」而其实没过期;!active && expireTime == null→ 落到v-else显示「永不过期」,而它active=false不参与决策,页面上看不出为什么。 - ⚠️
isActive()不只是「未过期」,它还要求SubjectTypeEnum.parse(subjectType) != null。所以active=false有两种成因,页面上无法区分。 - ⚠️
expired字段在GrantAdminController里两处口径不一致::191是setExpired(!g.isActive())(脏 subjectType 会被报成「已过期」),:382是setExpired(expireTime != null && now.isAfter(expireTime))(看不到脏 subjectType)。这是引擎注释点名的两次历史漂移之一,至今仍在。 - ⚠️ 诊断页有两处死代码:
diagMaxHeight/recalcDiagHeight(:219-220,全库无其它引用,却注册着 resize 监听),以及上一条那个「已过期」标签。 - ⚠️ 注解扫描器有四个结构盲区(只扫 web Bean、
getDeclaredMethods不含继承、正则只认字面量单串、CGLIB 只剥一层)。实测当前没有受害者——非 Controller 文件里的@PreAuthorize全是注释提及——但编程式鉴权的权限串(Agent 工具的permission属性、FormDataPermissionChecker等)确实不在检测范围内。 - ⚠️
resolveResourceChain只返回第一条链。一个 permission 可能对应多行资源(本库当前 2 个串如此),判定侧hasPermission是逐条都算的。排查「授权配了为什么不生效」时要留意:你配的那行未必是这里显示的这条链。 - ⚠️ 塌缩开启后
resolveResourceChain返回身份行的链,不是代码里那个原始串的行。这是刻意的:「返回一条库里已经软删的行的链,只会把人引到错误的地方去查」。 - ⚠️
resolveResourceChain的 action 位填VIEW只为让 Lookup 两位成对,与诊断请求里的action参数无关。别把它当成「按 VIEW 查的链」。 - ⚠️
datasetTable取第一条非空的。同一权限串的多行资源指向不同表是配置错误,但诊断只如实报能查到的那一个,不会告警。 - ⚠️
countTenantMismatch只判 ROLE 主体,且角色查不到时不算错配(那是「角色被删了」,另一个问题)。用户、部门主体的租户来源不同,不能一并判。 - ⚠️
appendGrantFindings在grantCount == 0时直接 return,不再查租户与 fieldMode。它把「一条授权都没有」和「只有过期授权」压成了同一句话。 - ⚠️
Finding是 private record,level是字符串不是枚举。加工具侧的调用方只能读toMap()的输出。 - ⚠️
subField诊断刻意不比 infra 的 Java 常量。那一侧由SubFieldSeparatorSingleSourceTest在构建期钉住。它保留的唯一理由是 JLS 15.28 常量内联——只重装 infra 的 jar 而没重装 bpm 会真漂移,且单测结构上看不见。 - ⚠️
diagnoseProcess在没有节点树时直接 return(报 INFO 后不再检查approveType)。用 BPMN 设计器画的流程,节点检查完全不适用。 - ⚠️
@Order数字之间留了空档(1-6 连续,然后跳到 100)。插新 Runner 时别顺手把 100 改成 7——PermissionBenchmarkRunner的注释要求它「排在所有登记/迁移 Runner 之后,量的才是稳定状态」。 - ⚠️ 漂移检测不阻止启动。类注释写明「告警就够了,防止『偶发漂移导致整机无法启动』」。所以「后端起来了」不代表没有漂移。
- ⚠️
NativeDatasetScanner用ClassPathScanningCandidateComponentProvider(false)。那个false是关掉 Spring 默认的「必须是@Component」过滤器——改成true的话 DO 一个都扫不出来,且不报错,表现是全部原生表的行级权限静默失效。 - ⚠️ 诊断页的动作下拉给全量词表、不分「生效/预留」组。这是刻意的:预留动作也要能选,才能实测出「配了确实不生效」。别按授权页的分组口径去「统一」它。
- ⚠️ 诊断页保留
allow-create。权限串有 600+ 个且格式严格,纯手打错一个字符就诊断出「无此资源」,而那和「确实没权限」在结果页上长得一模一样。保留allow-create是因为诊断本来就可能要查一个还没建资源行的串。
→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。
测试
| 测试类 | 基类 | @Test | 覆盖 |
|---|---|---|---|
PermissionDriftDetectorTest | BaseDbUnitTest(H2) | 9 | 无漂移 / 拼错串告警 / 部分漂移 / 自动建目录 / 幂等 / 陈旧仅告警不删除 / 陈旧分类三分支 |
DiagnoseNotWorkingToolTest | BaseMockitoUnitTest | 13 | 免责句必带、分隔符两侧一致与不一致、表登记与未登记、资源未登记、租户写错、非法 fieldMode、多行资源、approveType 错、流程资源未登记、未部署、流程不存在 |
NativeDatasetScannerTest | BaseMockitoUnitTest | 5 | 完整声明、@TableName 回落、🔴 无表名必须跳过、bizKey 默认与覆盖、⚠️ 真实接入样例(AdminUserDO)也要扫得到 |
ResourceServiceImplCollapseTest | BaseDbUnitTest | 11(含漂移段) | 塌缩与 ResourceServiceImpl / PermissionDriftDetector 的交互 |
两个测试类的第一个用例都是「最该被钉住的那条」:DiagnoseNotWorkingToolTest 是 @DisplayName("🔴 输出里必须带「不能替代在浏览器里真跑一遍」"),NativeDatasetScannerTest 里那条 🔴 是「没有表名的声明必须被跳过——登记出一个没有表的数据集,行级规则永远匹配不上它」。
PermissionDriftDetectorTest 用 @Import({PermissionDriftDetector.class, ResourceServiceImpl.class, …}) 真跑 H2,所以「约定式推导_幂等」这个用例是真幂等(跑两遍 detect,第二遍 created 必须是 0),不是 mock 出来的。
零测试的四处
PermissionDiagController——本页「三条分歧」全部在这个类里,一个测试都没有。SystemDiagnosticApiImpl——countTenantMismatch与collectIllegalFieldModes的边界(角色查不到、fieldMode 为空、多行资源取第一条非空表名)无测试。PermissionScanRunner——「报unexplainedStale而不是stale」这条纪律只写在注释里,没有测试钉住;改回stale不会有任何测试变红。PermissionBenchmarkRunner——309 行,零测试,且按注释跑不起来(坑 12)。
第 3 条最值得补:它是「一个永远不变的告警数字很快就没有人会再读」这条判断的唯一执行点,而它没有任何机械保障。
未证实
- 本页所有「实测 N」的数字——457 个退役成员行、840/712(83%)、682/124、221 个扫描到的权限串、101 个第一版修的行数、238 行 ACTION 切成 139/99、1533 个陈旧(
mes316 /cms188 /hrm146 /erp112)、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等函数体是上一轮读的,本轮未复核。
