26 KiB
权限评估层(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 |
传第二个参数 |
四条刻意保持"严格"的规则,都是为了让前端不比后端宽:
Y精确比较(YesNoPolicy.YES),小写y、空串、undefined都不算通过。- 权限名精确字符串比较,不做大小写宽松、不做 trim、不做
'*'通配。后端是x.PermissionName == permission,前端放宽只会造成"前端放行、后端 403"。 - 传了 dependent 就不回退功能级。回退等于比后端宽,同样制造错觉。
- 无管理员短路。后端没有"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为准,否则会照抄到这些历史残留。