# 权限评估层(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](./page-routing.md),本文只讲权限判定本身。
403 跳转与请求层提示的去重口径见 [error-handling.md](./error-handling.md),后端地址来源见 [environment.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` 里就有这种行:
```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):
```vue
```
数据级判定(同一功能在不同群组/目录下动作不同):
```ts
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> = {
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,项目根目录):
```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|Y`) | 放行 | ✅ 登录跳转与冷启动深链都正常进 `/dashboard`,0 error / 0 warn;顺带在页面运行时直呼线上内核复现了数组 OR 语义:`canAny(view, ["TEMP_NO_SUCH","PublishPhoto"]) -> true`、`canAny(view, ["TEMP_NO_SUCH"]) -> false` |
三轮都只用临时值,`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 上那个是吞输出的垫片):
```powershell
$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=`。此时看两处实锤就够了:
```powershell
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 字段名(压缩后属性名不变):
```powershell
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](./error-handling.md) 在浏览器里跑一次探针:静态四步全绿也证明不了钩子会被触发。
**两处刻意绕开外部格式化器的写法**(`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` 为准,否则会照抄到这些历史残留。