Files
Docker/uniapp/youjiala/src/md/permission.md
T
2026-10-13 14:23:10 +08:00

26 KiB
Raw Blame History

权限评估层(PermissionContext 移植与优化)

适用范围:src/permission/*、src/router/routes.ts 的 PAGE_PERMISSIONS、src/router/nav-guard.ts 的第三步判定、src/stores/loginProfile.ts 的 permissionInfo。 本文记录"语义为什么这样定""相对旧 WebVue 改了哪几处、为什么",代码注释只解释局部行为,跨文件与跨端的约束以本文为准。 路由/短地址/401·403·404 的守卫链路见 page-routing.md,本文只讲权限判定本身。 403 跳转与请求层提示的去重口径见 error-handling.md,后端地址来源见 environment.md。


1. 数据来源:只有一个,且是登录时的快照

POST /Auth/Login
  -> LoginProfile.PermissionInfo : SecurityUserPermissionInfo
       ├─ UserGroups               string[]   当前用户所属组名
       ├─ Permissions              []         功能级授权行(含四个 Indc)
       ├─ PermissionDependents     []         数据级/操作级授权行(多 DependentDetailName)
       └─ PermissionFunctionMaps   []         权限 -> 功能 -> 页面名 映射
  -> stores/loginProfile.updateLoginProfile()
  -> persist.paths 含 "loginProfile.PermissionInfo"(本地缓存水合)
  -> permission/permission-context.ts 建索引并判定

三条硬约束:

  • 不另开权限缓存。判定层只读 useLoginProfileStore().permissionInfo,避免出现第二个数据源。
  • 权限随令牌一起下发,所以后端改了授权,前端必须重新登录才会变。403 页的主按钮是"重新登录"而不是"刷新",就是这个原因。
  • 档案是从本地缓存水合的,可能半截或为空——判定层对此的容错见 §5。

生成实体(src/data/auto/securityGroupPermissionViewEntity.ts 等)的字段与内核的 PermissionRowLike / DependentRowLike 结构兼容,内核不 import 生成类:生成器改字段名不该牵连判定逻辑,只要后端 JSON 形状不变就照样工作。


2. 判定语义:以服务端为唯一权威

对齐目标:CloudBuilder.Security.Service/Authorization/PermissionContext.cs。

动作 后端判定条件 前端
none Permissions 里存在该 PermissionName(不看任何 Indc) can(index, "none", p)
view PermissionName == p && ViewIndc == 'Y' canView
create 同上,CreateIndc == 'Y' canCreate
amend 同上,AmendIndc == 'Y' canAmend
delete 同上,DeleteIndc == 'Y' canDelete
带 dependent 只查 PermissionDependents,条件加 DependentDetailName == dependent 传第二个参数

四条刻意保持"严格"的规则,都是为了让前端不比后端宽:

  1. Y 精确比较(YesNoPolicy.YES),小写 y、空串、undefined 都不算通过。
  2. 权限名精确字符串比较,不做大小写宽松、不做 trim、不做 '*' 通配。后端是 x.PermissionName == permission,前端放宽只会造成"前端放行、后端 403"。
  3. 传了 dependent 就不回退功能级。回退等于比后端宽,同样制造错觉。
  4. 无管理员短路。后端没有"system_group 全放行"的分支,前端也不许加——一旦加了,本地就再也复现不出后端的判定结果。

none 存在的意义:后端 CanNone 就长这样(只要求挂过权限名),用于"有授权行就允许进入,具体动作交给接口按 Indc 判"。前端保留同款,是为了能逐字复现后端行为,不是为了省事。

2.1 操作型判定(Submit / Approval)

后端 [PermissionOperate(name, Operate.X)] 的 switch(PermissionHandler.HandleRequirementAsync):

Operate 后端调用 前端等价
None CanNone(name) canOperate(index, "None", p)
Find / Print CanView(name) "Find" / "Print"
Insert CanCreate(name) "Insert"
Update CanAmend(name) "Update"
Delete CanDelete(name) "Delete"
其余(Submit / Approval) CanNone(name, operate) —— 把 operate 字符串当作 DependentDetailName 去数据级授权里查 "Submit" / "Approval"

这张表在代码里只写一处:permission-kernel.ts 的 canOperate()。它让"提交/审核按钮该不该亮"与"接口会不会被 403"共用同一份映射。种子数据里能直接看到 Submit/Approval/Cancel/Valid/Update 这类 dependent_detail_name(security_permission_dependent 表),所以这不是推测出来的形态。

注意:CanView(p, dep) 这类带 dependent 的四动作后端 overload 目前只有 SecurityUserService 等个别地方用到(CanView(SecurityPermissionPolicy.CanSeeAllUser) 是单参形式),页面级数据权限在实际业务里尚未铺开。前端把口子留着,代价只是几个可选参数。


3. 相对旧 WebVue(D:\Docker\WebVue\src\hooks\PermissionContext.ts)的差异

# 旧实现 新实现 理由
1 canView 只判权限名存不存在,漏检 ViewIndc 检 ViewIndc == 'Y' 见 §3.1,这是唯一的行为修复,其余都是工程优化
2 每次判定 Permissions?.some(...) 线性扫;一个页面调 4 次扫 4 遍 构造/共享时建 Map 索引,判定 O(1) 相册类页面一屏十几个按钮各判各的,重建成本不该落在渲染路径上
3 每次 usePermissionContext() 都 new PermissionContext(...) 全站共享一个 computed 索引,档案变才重建 同上;且 computed 天然把"重新登录→权限变→UI 重算"接进依赖追踪
4 权限名来自菜单树注入的 route.meta.permission(layout/insideLayout.ts) router/routes.ts 的 PAGE_PERMISSIONS 集中登记 uni-app 没有 route meta,也没有 beforeEach;集中登记还能被 TS 校验 key
5 PERMISSION_FREE_ROUTE_NAMES = ["Preview","PreviewAudio","PreviewVideo"] 免检名单 删掉,未登记即不校验 新架构下"不登记"就是免检,名单是冗余的第二套开关
6 页面里 4 行命令式赋值:canView.value = pContext.canView(route.meta.permission as string) 等 usePagePermission() 直接给 5 个 computed 少 4 行、免 as string、且权限变了会自动重算(旧写法是一次性快照)
7 无批量入口 canAny(index, action, string | string[]):任一命中即通过 一个页面挂在多个功能权限下(相册浏览同时属于 PublishPhoto 与 FamilyGroup)
8 无"一次取全"入口 actionsOf() / flags() / perm.flags 整页按钮渲染一次读完,不重复查
9 无排查手段 granted(action) 返回已授权权限名清单 写错权限名是最难自查的故障,需要反查工具
10 无操作型判定 canOperate() 见 §2.1

兼容方面保留同名同 API:PermissionContext 类(canNone/canView/canCreate/canAmend/canDelete)与 usePermissionContext() 都在,旧页面代码可以原样搬过来再逐步换成 composable。

3.1 为什么必须补检 ViewIndc(种子数据 + 实测双重佐证)

种子数据 Data/Init-builder-202609240910.sql 里就有这种行:

-- security_group_permission:挂了权限名,但四个标全 N
('vip_group','Welcome','N','N','N','N','')
('system_group','LoginAgent','N','N','N','N','')
-- 只读型授权:能看不能改
('vip_group','SecurityUser','Y','N','N','N','SecurityUser')

账号 001 登录后档案里的实际取值(PermissionInfo.Permissions 共 22 行,取自 localStorage.loginProfile):

Welcome|N|N|N|N      <-- 权限名挂着,后端 CanView = false
LoginAgent|N|N|N|N
PublishPhoto|Y|Y|Y|Y
SecurityUser|Y|Y|Y|Y 与 SecurityUser|Y|N|N|N   (两组各授一份,取 OR)

旧实现下,Welcome、LoginAgent 这类"挂了权限名但一个动作都没给"的行会让 canView 返回 true:用户进得来页面,页面一调列表接口就是 Find -> CanView -> 403,表现成"打开就一片红"。补检之后,进不来就直接跳 403 页,原因写在页面上,而不是让用户对着一堆失败的请求猜。

这条修复带来的直接后果:dashboard 不能绑 Welcome。 库里 Welcome 的 view_indc='N',旧前端靠漏检歪打正着地放行了欢迎页;新前端如果照抄"每页都绑权限"的形式,会把所有人(包括管理员组)挡在欢迎页外面。所以现在 PAGE_PERMISSIONS 里没有 dashboard 条目——不登记即不校验,这正是 §3 第 5 条免检机制的用意(端到端实测见 §7.2)。

同理,divider 是菜单分隔线(security_permission 里就叫 divider,security_function 里对应 security_line_1 等),管理员组对它的授权甚至是 Y|Y|Y|Y——授权标位对它是无意义的填值,所以绝不能用"Indc 有 Y"来反推"这是个真功能",它也不是页面权限,任何情况下都不该登记到 PAGE_PERMISSIONS。


4. 文件与 API

文件 职责 依赖
permission/permission-kernel.ts 纯函数 + 索引,判定语义全在这 只依赖 data/custom/yesNoPolicy
permission/permission-context.ts 接 pinia、响应式、页面级锁、路由级判定 kernel + router/routes + stores
permission/permission-kernel.spec.ts 可执行校验(不引测试框架) kernel
router/routes.ts PAGE_PERMISSIONS 页面 -> 权限名登记表 只 import kernel 的 PermissionNames 类型
router/nav-guard.ts 404 → 401 → 403 三段判定 canVisitPage()

内核导出:buildPermissionIndex / can / canAny / canOperate / actionsOf / grantedPermissions / dependentKey,类型 PermissionAction、PermissionOperate、PermissionNames、PermissionIndex、*Like。

context 导出:

API 用途
usePermission() 页面/组件里最常用的入口:canView/canCreate/canAmend/canDelete/canNone/can/canOperate/granted(函数形式,读共享索引)
usePagePermission(name?) 页面级五把 computed 锁 + flags;省略 name 时按页面栈推断,但 onLoad 早期栈可能还没压上当前页,能显式传就显式传
usePermissionContext() / PermissionContext 旧 API,一次性快照;移植旧代码时用
canVisitPage(name) 路由级判定,只由 nav-guard 调用
pagePermission(name) / currentPageName() 登记表读取与栈顶反查

模板用法(小程序/App 只能这样,见 §6.1):

<script setup lang="ts">
import { usePagePermission } from "@/permission/permission-context";

// 页面绑定的权限名由 routes.ts 的 PAGE_PERMISSIONS 登记,页面只问"有没有"
const perm = usePagePermission("albumList");
</script>

<template>
  <u-button v-if="perm.canCreate" text="新建相册" />
  <u-button v-if="perm.canDelete" text="删除" type="error" />
  <!-- 提交按钮:与后端 [PermissionOperate(name, Operate.Submit)] 同口径 -->
  <u-button v-if="canOperate('Submit', 'PublishPhoto')" text="提交" />
</template>

数据级判定(同一功能在不同群组/目录下动作不同):

const { can, canOperate } = usePermission();
can("amend", "FamilyGroup", familyId); // 只查 PermissionDependents
canOperate("Approval", "PublishPhoto"); // 走 operate -> dependent 那条线

5. 两套失败策略:路由 fail-open,按钮 fail-closed

这不是不一致,是故意的。

层 判定入口 档案缺失/权限清单为空时 理由
路由 canVisitPage() 放行(fail-open) 档案来自本地缓存,一旦被写坏或尚未水合,严格模式会把所有人挡在 403 外面——那时用户连"能不能重新登录"都点不到。真正的安全边界始终在后端(每个接口都有 [PermissionOperate])
按钮 usePagePermission() / usePermission() 不显示(fail-closed) 多挡一个按钮只是少个入口;误放一个按钮,点了只会收到 403

permissionInfoUnusable() 只认一个信号:Permissions 数组缺失或长度为 0。它不区分"这人真没权限"和"档案坏了"——前端分不出来,所以把这个歧义交给后端裁决,而不是自己猜。

由此推出两条排查纪律:

  • 页面进不去,先看 PAGE_PERMISSIONS 有没有登记错权限名(登记错 = 该页对所有人 403,除非档案是空的)。
  • 按钮不见了,先 usePermission().granted() 打印实际授权,再对照后端组授权。

6. 新增页面时怎么登记权限

1. 拿到权限名:后端 security_permission.permission_name(权威清单)
   或 security_permission_function_map(function_name <-> permission_name <-> page_name)
2. 写进 src/router/routes.ts 的 PAGE_PERMISSIONS:
     export const PAGE_PERMISSIONS: Partial<Record<PageName, PermissionNames>> = {
       albumList: "PublishPhoto",              // 单个
       preview: ["PublishPhoto", "FamilyGroup"], // 数组 = 任一命中即放行
     };
3. 页面里用 usePagePermission("albumList") 控制按钮,不要自己写权限名

三条纪律:

  • 权限名必须是后端库里的原文,大小写、拼写逐字一致。写错不会编译报错,只会让该页对所有人 403(§5 已述)。
  • 能不登记就不登记。欢迎页、错误页、纯展示页都不该绑权限;绑一个库里全 N 的权限(如 Welcome)等于自断门路。
  • 权限名不散落在页面里。页面只引用 PageName,名字集中在 PAGE_PERMISSIONS,这样"哪些页面受权限管"是一张可审阅的表,而不是 grep 全项目。

6.1 跨端注意

  • 小程序不支持自定义指令,所以不要写 v-perm/v-auth 之类的指令(App 端同样受编译期限制)。本项目统一用 composable + v-if,三端一致。
  • 判定不碰 uni.* API,也不碰请求层,所以 permission-kernel.ts 可以在 node 下直接跑(§7)。
  • 无 router.beforeEach:路由级判定只有 nav-guard 的两条链路(拦截器 + 页面 onShow 复核),权限层挂在它的第三步,顺序固定在 401 之后——未登录时档案通常也是空的,先给 401 才谈得上"去登录"。

6.2 为什么不用已经在手里的 PermissionFunctionMaps 生成登记表

登录档案已经带了 PermissionFunctionMaps[]({PermissionName, FunctionName, PageName}),看起来可以直接推导出 PAGE_PERMISSIONS。不能:后端构造它时 join info.Permissions(PermissionContext.cs:68-73),只包含当前用户已授权的权限。用它登记,"该页需要但当前用户没有"的权限名会直接缺席 → 该页变成不校验 → 谁都能进,恰好把权限体系掏空。

它的正确用途在别处:登记与排查时的"页面名 <-> 权限名"反查表(security_permission_function_map 视图,字段只有 PermissionName/FunctionName/PageName)。它画不出菜单——没有 FunctionLocalName/FunctionEngName、没有 Icon、没有 Orderby、没有父子层级,做菜单要用下面 §8 说的 FindMenuByUsername。


7. 校验:内核是可执行的

项目还没接测试运行器,所以 permission-kernel.spec.ts 写成"能被 esbuild 打包、node 直接跑"的形态。跑法(PowerShell,项目根目录):

& "C:\Program Files\nodejs\node.exe" ".\node_modules\esbuild\bin\esbuild" `
  src\permission\permission-kernel.spec.ts --bundle --platform=node --format=cjs `
  --alias:@=./src --outfile="$env:TEMP\permission-kernel.spec.cjs" --log-level=warning
& "C:\Program Files\nodejs\node.exe" "$env:TEMP\permission-kernel.spec.cjs" 2>&1 `
  | Out-File -Encoding utf8 "$env:TEMP\perm-run.log"
"RUN_EXIT=$LASTEXITCODE"

RUN_EXIT=0 且日志尾部为 permission-kernel: 59 assertions passed 即通过(断言失败会打印 FAIL <用例名> 并以非零退出,不会"跑了但没人看输出")。

覆盖的 10 组语义:view 必须检 ViewIndc、四动作互相独立、多用户组授权 OR 合并、数据级不回退、严格比较(小写 y/大小写/不 trim)、档案缺失或损坏不抛异常、canAny 数组与空串、未授予权限返回全 false、grantedPermissions 清单、canOperate 全操作映射(含 Submit 命中与 Approval 未挂)。

将来引入 vitest,只需把 check() 换成 expect(),用例本身不动。判定语义改动必须先加用例再改代码——canView 那条修复就是靠用例钉住的,光读代码很容易自我说服"应该没问题"。

7.2 端到端实测(H5 dev + 真实后端,账号 001)

PAGE_PERMISSIONS 是空表,路由级 403 在日常代码里走不到,所以验证时临时往表里塞值、跑完再回滚。方法:npm run dev:h5(5173)+ 后端 5235,浏览器真机登录,观察地址栏与 console。三轮结论:

临时绑定 预期 实测
dashboard: "TEMP_E2E_NOT_EXIST" 登录后与冷启动深链都进不去 ✅ 两条链路都落 /error/page403?from=/dashboard,0 console error;401→403 的 ?from= 传递与"重新登录带 redirect"一并复验
dashboard: "Welcome"(库里真实存在,但 view_indc='N') 仍拦 ✅ 登录后 2.5s 落到 403。这就是 §3.1 那条修复的端到端实证——旧前端在此处会误放行,然后满屏接口 403
dashboard: ["PublishVideo", "PublishPhoto"](均 `Y Y Y

三轮都只用临时值,PAGE_PERMISSIONS 现已回滚为 {}。验证权限层时不要只看"能不能进",必须同时看 console——拦截逻辑本身没问题时,残余故障通常表现为多打了几个接口或重复跳转。

顺带发现并修掉一个既有缺陷(与权限无关,但正是这轮端到端才暴露的):登录页缺 ?redirect= 时,resolvePageName("") 会经 normalizePath("") -> "/" -> "login" 返回真值,于是 login.vue 的 onLoad 把默认落地地址覆盖成空串,uni.reLaunch({url: ""}) 直接 fail: Missing required args "url",用户登录成功后卡在登录页。修复分两处:routes.ts 的 resolvePageName 对空地址早退返回 null(源头),login.vue 追加 !isAuthFree(name)(不让手敲的 ?redirect=/ 把自己跳回登录页)。

7.3 改完权限层必跑的四步(本机实测耗时约 2 分钟)

项目根目录,PowerShell。node 必须用绝对路径(PATH 上那个是吞输出的垫片):

$n = 'C:\Program Files\nodejs\node.exe'
# 1) 内核语义(§7)        期望 SPEC_EXIT=0 且日志含 "59 assertions passed"
& $n ".\node_modules\esbuild\bin\esbuild" src\permission\permission-kernel.spec.ts --bundle --platform=node --format=cjs --alias:@=./src --outfile="$env:TEMP\permission-kernel.spec.cjs" --log-level=warning
& $n "$env:TEMP\permission-kernel.spec.cjs" 2>&1 | Out-File -Encoding utf8 "$env:TEMP\perm-run.log"; "SPEC_EXIT=$LASTEXITCODE"
# 2) 类型(含 IDE 误报的裁决)  期望 VTSC_EXIT=0 且 VTSC_BYTES=0
& $n ".\node_modules\vue-tsc\bin\vue-tsc.js" --noEmit 2>&1 | Out-File -Encoding utf8 "$env:TEMP\vt.log"; "VTSC_EXIT=$LASTEXITCODE VTSC_BYTES=" + (Get-Item "$env:TEMP\vt.log").Length
# 3) Lint(含 md 里的 ts 代码块)  期望 ESLINT_EXIT=0
& $n ".\node_modules\eslint\bin\eslint.js" src -f stylish -o "$env:TEMP\lint.txt"; "ESLINT_EXIT=$LASTEXITCODE"
# 4) 三端构建              期望三份日志都是 DONE Build complete.
.\node_modules\.bin\uni.cmd build          *> "$env:TEMP\b-h5.log"
.\node_modules\.bin\uni.cmd build -p mp-weixin *> "$env:TEMP\b-mp.log"
.\node_modules\.bin\uni.cmd build -p app      *> "$env:TEMP\b-app.log"

别把"没回显"当成"卡死":uni.cmd 的输出经 PowerShell 缓冲,三条链式命令跑完才一次性刷出来,中途取消会让你只看到 ExitCode 130 而看不到 H5_EXIT=。此时看两处实锤就够了:

Get-Item "$env:TEMP\b-h5.log","$env:TEMP\b-mp.log","$env:TEMP\b-app.log" | Select-Object Length, LastWriteTime
Select-String -Path "$env:TEMP\b-*.log" -Pattern 'Build complete'

三端单次构建各约 10 秒,整条链 35 秒上下;日志里有 DONE Build complete. 且 dist\build\{h5,mp-weixin,app} 的 LastWriteTime 是刚刚,就是成功。想确认权限层真的进了产物,搜后端 JSON 字段名(压缩后属性名不变):

Get-ChildItem 'dist\build\mp-weixin' -Recurse -File -Include *.js | Select-String -Pattern 'PermissionDependents' -List

别用 Select-String -Path 'dist\build\mp-weixin\**\*.js':PowerShell 的 ** 通配在这里不递归,会给出假的"0 命中"(本轮就这样误判过一次"小程序产物没吃到 .env",实际是搜索方式错了)。

改完请求层或全局错误边界,还要按 error-handling.md §7.2 在浏览器里跑一次探针:静态四步全绿也证明不了钩子会被触发。

两处刻意绕开外部格式化器的写法(permission-kernel.ts):IDE 的 prettier 与项目 ESLint 在两个构造上各执一词,--fix 之后一保存又被改回去,来回拉锯且每次都以 ESLINT_EXIT=1 收场,所以直接换成两边都稳定的形态,而不是每次收尾都跑一遍 --fix:

构造 冲突 现在的写法
canAny 的逐项判定 prettier 给单参数箭头加括号,style/arrow-parens(项目规范:单参数必须省括号)又要去掉 for (const name of list),语义与 some() 的短路完全一致
PermissionOperate 联合类型 多行字面量联合的 = 位置:prettier 放行尾,style/operator-linebreak 放行首 PERMISSION_OPERATES 用 as const 数组 + (typeof ...)[number] 派生,单行放得下;顺带得到可运行的枚举清单

自查方法:连跑两遍 eslint src,中间 Start-Sleep 几秒等格式化器动手——两遍都 EXIT=0 才算真的稳定,只跑一遍容易把"刚被 --fix 修好、马上又会被格式化回去"误判成通过。


8. 已知缺口

  • 入口可见性(菜单/首页九宫格)已实现——这一层不再是缺口。权限可见性分两层,两层现在都落地了:

    层 管什么 数据源 现状
    ① 入口可见性 首页列不列得出这个入口 POST /SecurityUser/FindMenuByUsername(后端按用户权限过滤) 已实现:dashboard.vue + useAdminMenu + admin/menu-map.ts 白名单,详见 src/md/admin-pages.md §5
    ② 页面可进入性 手输地址/深链跳过去能不能进 PAGE_PERMISSIONS + canVisitPage() 判定层就绪,security 域三页已登记(见下条)

    ①的后端链路(SecurityUserService.cs:232-248)是 用户名 -> security_user_actor -> security_actor_group -> security_group_permission -> security_permission_function_map -> security_function,只返回本人被授权的功能,UserId 为空直接返回 null。securityUserService.FindMenuByUsername() 返回 SecurityFunctionResultData(含 SecurityFunctionDisplayData[] 的 FunctionLocalName/FunctionEngName/Icon/Orderby/PermissionName),正是旧 layout/index.vue 的 initMenu() 消费的形态;useAdminMenu 拿它渲染,再过 menu-map 白名单与 canVisitPage 两道本地过滤。securityFunctionService.FindMenus()([AllowAnonymous]、不区分用户的全量表)仍只用来全量核对 permission_name,别拿它做菜单兜底。 遗留:stores/tabbar.ts 的默认列表仍是空的(tabbar 与本批九宫格是两套东西,暂未统一数据源)。

  • 数据级(dependent)判定尚无真实页面在用:旧项目所有调用点都是单参形式,口子为 Submit/Approval 与将来的目录级授权保留。

  • PAGE_PERMISSIONS 登记仍很稀疏:security 域已登记 userEnquiry=UserEnquiry、userOperation=UserOperation、actorEnquiry=SecurityActor 三条(命名不统一、别"顺手归一"的坑见 src/md/admin-pages.md §4);其余旧 WebVue 域(Group/Function/Dms/Publish…)尚未迁进来。已登记页面会真正触发路由级 403,未登记页面仍只靠请求层 HTTP 403 兜底。

  • 403 页的"返回首页"在首页本身被拦时会原地弹回一次:page403.vue 的返回按钮走 reLaunch(pageUrl("dashboard")),而 reLaunch 同样过 nav-guard;若 dashboard 恰好被绑了拿不到的权限,这次跳转会被再拦一次、弹回 403(实测是一次性弹回,不是死循环,因为 403 页自身在 AUTH_FREE_PAGES 里)。dashboard 目前有意不绑(§3.1),所以这条还触发不到;等哪天给 dashboard 也登记了权限,这里就该改成"按 PAGE_PERMISSIONS 找第一个可达页"或直接隐藏该按钮。

  • 前端判定滞后于后端:后端每次请求实时查库,前端读登录时的快照。管理员改了授权后,用户重新登录前看到的仍是旧按钮状态(可能点了才 403)。这是"权限随令牌下发"这个既有模型的代价,不是本层引入的。

  • SecurityUserPermissionInfo.UserGroups 未参与判定:后端也不用它做短路(只用于 join 出授权行),前端同样只用 Permissions / PermissionDependents。

  • 孤儿授权数据:种子库里 security_group_permission 有 abc 组以及 acc/PhotoPublish/AudioPublish/VideoPublish 等权限名,而 security_group / security_permission 里查不到它们。判定层对此的表现是"多几行永远匹配不上的索引项",无害;但登记权限名时不能以授权表的并集为准,要以 security_permission 为准,否则会照抄到这些历史残留。