This commit is contained in:
owenchen committed 2026-10-13 10:47:39 +08:00
1 parent 6a8258ac4b
commit a7b25d49c4
29 files changed
+1960 -649

No files matched your search

+118
View File
@@ -0,0 +1,118 @@
# 环境变量分层(.env 与 WebSitePolicy 的分工)
适用范围:项目根 `.env` / `.env.development` / `.env.production`、`src/types/env.d.ts`、`src/policy/custom/webSitePolicy.ts`、`src/common/http.interceptor.ts` 的地址取值。
本文记录"为什么这样分层""三端读取为什么必须写字面量",以及新增一个变量的固定步骤。错误处理口径见 [error-handling.md](./error-handling.md),路由与权限见 [page-routing.md](./page-routing.md)、[permission.md](./permission.md)。
---
## 1. 分层模型:谁覆盖谁
`vite.config.ts` 没有设置 `envDir` / `envPrefix`,所以走 Vite 默认:
- **加载目录 = 项目根**(`uniapp/youjiala/`,即 `index.html` 所在层),**不是 `src/`**;
- **只有 `VITE_` 前缀的键**会注入 `import.meta.env`,其它键不会(也不该)出现在前端产物里。
| 文件 | 何时生效 | 是否提交 |
| --- | --- | --- |
| `.env` | 所有 mode 的基底 | 提交 |
| `.env.development` | `uni` / `uni -p xxx`(mode=development) | 提交 |
| `.env.production` | `uni build` / `uni build -p xxx`(mode=production) | 提交 |
| `.env.local`、`.env.development.local`、`.env.production.local` | 同名 mode 下优先级最高 | **不提交**(`.gitignore` 里 `*.local` 已覆盖) |
mode 由 uni 命令决定(要指定别的 mode 得显式加 `--mode`):
```
uni / uni -p mp-weixin / uni -p app => development => .env + .env.development(+.local)
uni build / uni build -p xxx => production => .env + .env.production(+.local)
```
同名项后者覆盖前者;`.env.development` / `.env.production` 里**只列与 `.env` 不同的项**,重复抄一遍只会让三处值互相打架。
## 2. 只放"换环境要动"的东西
当前只有三个键,全部是后端根地址:
| 键 | 生效场景 | 缺省回落值 |
| --- | --- | --- |
| `VITE_WEB_API_ROOT` | 发布构建(三端共用) | `https://uat.youjiala.com` |
| `VITE_WEB_API_ROOT_DEV` | 开发构建,H5 | `http://localhost:5235` |
| `VITE_WEB_API_ROOT_DEV_APP` | 开发构建,App 真机/模拟器 | `https://uat.youjiala.com` |
分工纪律:
- **品牌与文案**(`Title` / `Copy` / `Beian`)、**路由常量**(`Dashboard` / `Web401~404`)、**时间格式**留在 `webSitePolicy.ts` 的常量里,不进 `.env`:它们跟着版本走,不跟着环境走。
- **密钥、token、数据库口令一律禁止进 `.env`**。前端产物人人可读,`.env` 又被提交,任何写进来的凭据等同于公开发布。
- 本机差异(自己的后端端口、局域网 IP)写 `.env.*.local`,不要提交本机地址。
## 3. fallback:删掉 .env 也必须能构建
`webSitePolicy.ts` 里每个地址都写成"env 优先、常量兜底":
```ts
/** 取环境变量:非字符串、空串、纯空白都算未配置,回落 fallback */
function env(value: string | undefined, fallback: string): string {
const text = String(value ?? "").trim();
return text.length > 0 ? text : fallback;
}
export class WebSitePolicy {
static readonly WebApiRootDev: string = env(
import.meta.env.VITE_WEB_API_ROOT_DEV,
"http://localhost:5235",
);
}
```
所以:
- `.env` 缺失、键漏写、值写成空串或只有空格 —— 都落到文件里的常量,构建不报错、产物可运行;
- 文件里的常量是**最后一道防线**,不是摆设,改动它等于改默认环境,需要同期更新本文与 README 的表格。
`http.interceptor.ts` 按端挑选常量(`#ifdef APP-PLUS` 是 uni 的条件编译,只有 App 构建会把那行编进去):
```ts
let devBaseUrl = WebSitePolicy.WebApiRootDev;
// #ifdef APP-PLUS
devBaseUrl = WebSitePolicy.WebApiRootDevApp;
// #endif
export const baseUrl = import.meta.env.DEV ? devBaseUrl : WebSitePolicy.WebApiRoot;
```
## 4. 硬规矩:只能用字面量读
```text
import.meta.env.VITE_WEB_API_ROOT; // ✅ 三端都正常
import.meta.env[key]; // ❌ 小程序 / App 真机上拿到 undefined
```
H5 走浏览器 ESM,`import.meta.env` 是个真实对象,动态取键也能 work;小程序与 App 端由 uni 编译器在构建期把 `import.meta.env.VITE_XXX` **静态替换**成字符串字面量,动态键没人替换,运行时对象根本不存在。
`src/types/env.d.ts` 显式声明这三个键(`vite/client` 自带的 `ImportMetaEnv` 有 `[key: string]: any` 索引签名,不声明时拼错键名是 `any`、编译器一声不响)。**新增变量必须同时补这份声明**,否则失去拼写保护。
## 5. 新增一个环境变量的固定四步
1. 项目根 `.env` 加 `VITE_XXX=默认值`,只在需要按 mode 区分的文件里写覆盖;
2. `src/types/env.d.ts` 的 `ImportMetaEnv` 补一行 `readonly VITE_XXX: string;`;
3. 消费点写成字面量 `import.meta.env.VITE_XXX`;若是地址/配置类,走 `webSitePolicy` 的 `env()` 包裹以保留 fallback,业务代码继续只读 `WebSitePolicy.*`;
4. 更新本文 §2 的表格与 README 的"后端地址"一节。
## 6. 实测结论(哨兵法)
改动 `.env` 后不能只看代码,要验证值真的进了产物。做法:把 `.env.production` 的 `VITE_WEB_API_ROOT` 临时改成一个必然不可能出现在源码里的域名(如 `https://env-probe.invalid`),跑 `npm run build` 与 `npm run build:mp-weixin`,然后在产物里搜这个域名。
本轮结果(2026-10-13):
| 端 | 产物命中哨兵 | 残留 `import.meta` | 结论 |
| --- | --- | --- | --- |
| H5 | 是 | 否 | env 生效 |
| mp-weixin | 是(`dist/build/mp-weixin/policy/custom/webSitePolicy.js`) | 否 | 编译器已静态替换,小程序同样吃 `.env` |
验证完必须把 `.env.production` 恢复原值。搜索小程序产物要用递归方式:`Get-ChildItem -Recurse | Select-String`,`Select-String -Path 'dist\**\*.js'` 这种通配在 PowerShell 里不可靠,会给出假的"0 命中"。
## 7. 常见坑
- **改了 `.env` 不生效**:Vite 不会热更新 env 文件,必须重启 `uni` / `uni -p` 进程。
- **小程序请求发不出去**:平台侧要求 `https` 且域名需在小程序后台"request 合法域名"里登记;本地联调用 `urlCheck: false`(`manifest.json` 的 `mp-weixin.setting`)只是让开发者工具不拦,正式包仍受平台约束。
- **H5 连本地后端报 CORS**:是后端未放行开发端口,不是 `.env` 的问题;改 `VITE_WEB_API_ROOT_DEV` 换到已放行的地址即可。
- **App 真机连不上**:真机/模拟器上没有宿主机的 `localhost`,走 `VITE_WEB_API_ROOT_DEV_APP`(默认已指向 UAT),需要连本机后端时把它改成局域网 IP。
- **别在 `.env` 里写注释续行**:dotenv 只认整行 `#` 注释,一行末尾换行再接裸文本会被当成键值行解析。
+134
View File
@@ -0,0 +1,134 @@
# 全局错误边界(未捕获异常与 Promise 拒绝的兜底)
适用范围:`src/common/global-error.ts`、`src/App.vue` 的 `setupGlobalErrorBoundary()` 调用、`src/common/http.interceptor.ts` 的"已提示"标记。
本文规定"什么错误由谁提示用户",避免同一个故障弹两遍、或者干脆一声不响。请求层本身的链路见 [page-routing.md](./page-routing.md),地址取值见 [environment.md](./environment.md)。
---
## 1. 为什么必须有
uni-app 没有 vue-router 那种 `router.onError`,也没有全局 `app.config.errorHandler` 的成熟落点:
- 页面里一个没 `catch` 的 promise,只会在控制台留一行,用户看到的是"点了没反应";
- 运行时抛错若发生在异步回调里,可能连错误页都进不去。
所以本项目的兜底是 **App 级两个钩子 + 一个上报模块**:保证"控制台一定有记录,用户按需得到一次轻提示"。
## 2. 数据流与职责
```text
页面 / service 漏 catch
-> 错误来源(按端不同,见 §2.1)
uni App 钩子:onError(err) / onUnhandledRejection({ reason, promise })
H5 额外兜底:window "error" / "unhandledrejection" 监听
-> setupGlobalErrorBoundary()(src/common/global-error.ts,在 App.vue setup 顶层调用一次)
-> reportGlobalError(error, source)
1) console.error 记全量原始错误(永不省略)
2) 带"已提示"标记 -> 到此为止,只留日志
3) 文案在 3s 内已提示过 -> 静默(去重)
4) DEV -> 排队弹 uni.showModal,显示来源与详情前 300 字
PROD -> uni.showToast 一句通用文案
```
| 位置 | 负责什么 |
| --- | --- |
| `App.vue` | 只在 setup 顶层调一次 `setupGlobalErrorBoundary()`,不写判断逻辑 |
| `common/global-error.ts` | 钩子注册、H5 window 兜底、文案提取、去重、弹窗排队 |
| `common/http.interceptor.ts` | 决定"这一处是否已经打扰过用户",并给错误打标记 |
| 页面 / 业务代码 | 能处理就自己 `catch` 并处理;处理不了就别 `catch`,交给边界 |
钩子注册必须放在 **App.vue 的 setup 顶层**:`onError` / `onUnhandledRejection` 要把回调绑到 App 实例上,放进 `onLaunch` 的回调里就离开了 setup 上下文。
`onUnhandledRejection` 只把 `event.reason` 传下去(不是整个 event):类型里 `reason` 声明为 `string`,真机常常是 `Error` 对象,传原物才能让 `describe()` 拿到 `message`;`describe()` 也支持再往里挖一层(认 `reason` 字段),所以哪怕误传整个事件对象也不会掉进 `[object Object]`。
### 2.1 平台派发差异(H5 实测,不是抄文档)
| 端 | 谁派发 uni 钩子 | 够不着的错误 | 本项目的处理 |
| --- | --- | --- | --- |
| H5 | `onError` **只由 Vue 的 `app.config.errorHandler` 触发**(组件渲染 / 生命周期里的同步错误);`onUnhandledRejection` 在 `uni-h5` 全文没有派发点(搜 `unhandledrejection` 命中 0) | `setTimeout` / 事件回调里抛出的异常、所有 Promise 拒绝 | 额外挂 `window` 的 `error` / `unhandledrejection` 监听 |
| 微信小程序 | 由小程序运行时派发 `App.onError` / `App.onUnhandledRejection`(**本轮未在真机/开发者工具实测**) | 需 capture 阶段才收得到的资源加载错误(未接) | uni 钩子覆盖主链路,§8 列了待办 |
| App | 由 uni-app 运行时派发(**同样未实测**) | 同左 | uni 钩子覆盖主链路 |
第一轮实测的教训就在这里:只在 `App.vue` 里注册 uni 钩子时,浏览器控制台能看到裸的 `边界探针-error`,但 `[global:*]` 一条都没出现、弹窗一次没弹 —— **钩子注册成功≠会被触发**。所以 H5 的 window 兜底是必需项,`setupGlobalErrorBoundary()` 用 `typeof window` 判断只在浏览器里挂,小程序 / App 端自动跳过,`windowFallbackInstalled` 保证 HMR 重复执行也不会叠监听。
## 3. 三条纪律
1. **已提示过的不再打扰。** 请求层已经弹过 toast/modal、或已经跳到登录页/403 页的错误,用 `markErrorNotified(err)` 打标记,边界见到标记只写日志。
2. **同一文案 3 秒内只提示一次。** 去重按文案分桶(`lastShownAt: Map<string, number>`),记录表上限 64 条,超限时清掉超过 30 秒的旧项,长时间运行不会无限增长。
3. **只记录与提示,绝不吞错误。** 该 `throw` 的照旧 `throw`,边界不负责"让程序继续跑"。否则故障会被藏进日志,排查时连"到底报没报"都区分不出来。
`markErrorNotified` 原样返回入参,所以能内联使用:
```ts
import { markErrorNotified } from "@/common/global-error";
throw markErrorNotified(new Error(bizMsg));
reject(markErrorNotified(err));
```
## 4. 请求层哪些站点打了标记
`describe()` 依次尝试 `message` / `errMsg` / `errorMessage` / `msg` / `Message` 取值,取不到就 `String(error)` 兜底,因此字符串型错误也能出文案。
| 站点 | 是否标记 | 原因 |
| --- | --- | --- |
| `Failed to connect` / `request:fail` | 仅当 `meta.toast` 打开 | 提示由 `meta.toast` 决定;关掉提示时把兜底交还给边界 |
| 401 无登录态 → 跳登录页 | 标记 | 页面已经跳走,等于已告知 |
| 刷新令牌失败 → `goLogin` | 标记原错误对象 | 保留 `status` 供调用方分支,只补标记 |
| 403 → 跳 403 页 | 标记 | 403 页会列出缺哪条权限,不需要再弹一次 |
| 非 2xx 通用分支 | 仅当 `meta.toast` 打开 | `err.status` / `err.data` 保持不变 |
| 业务异常(`ValidatedException` / `ServiceErrorException`) | 标记 | `detectBusinessException` 内部已经 modal/toast |
| `uploadFile` 状态码异常 / 网络失败 / 业务异常 | 标记 | 三处都先 `notifyError` 再 reject |
| `downloadFile` JSON 错误体 / 非 2xx | 标记 | 已提示 |
| `downloadFile` 的 `writeFile` / `openDocument` 失败 | **不标记** | 这两处原本静默失败,正是要靠边界补提示 |
| `downloadFile` 内部 `uni.request` 网络失败 | **不标记** | 没有任何提示动作 |
## 5. 业务代码怎么写
- **想让用户看到失败**:直接 `await`,别 `catch`。边界会记日志并在生产给一句轻提示。
- **自己有能力恢复**(重试、占位、表单标红):自己 `catch`,处理完就别再抛;此时边界看不到这个错误,符合预期。
- **既要处理又要留痕**:`catch` 里 `console.error` 后按需 `markErrorNotified(err)`,防止上层再抛时重复提示。
- **禁止** `try { ... } catch { }` 空吞。确实要忽略的错误,写成 `catch { /* 说明为什么可以忽略 */ }` 并在注释里给出理由。
## 6. 两端表现差异
| 场景 | DEV(`import.meta.env.DEV`) | 生产构建 |
| --- | --- | --- |
| 未捕获错误 | `showModal`:标题带来源(`error` / `rejection`),正文是错误文案前 300 字 | `showToast`:"操作未成功,请稍后重试" |
| 已提示过的错误 | 只有 `console.error` | 只有 `console.error` |
| 同文案连续报错 | 首条弹窗,其余 3 秒内静默 | 首条 toast,其余 3 秒内静默 |
开发期弹窗是刻意的:"看到了但没提示"和"确实没报错"必须能一眼区分。生产提示不带内部术语与堆栈,详情只在控制台。
**弹窗必须排队**:H5 的 `<uni-modal>` 是单例节点,连续两次 `showModal` 会原地改写同一个框,用户只看得见最后一条(实测过)。`showDevModal()` 因此按时间槽排队——每条占用最多 `MODAL_MAX_HOLD_MS = 5000ms`,到点没点"确定"也放行下一条,既不丢展示也不会把后续错误堵死。
## 7. 验证记录与配方
### 7.1 已完成的 H5 实测(2026-10-13,登录页临时探针)
探针:`onLoad` 里 600ms 抛 `new Error("边界探针-error")`、900ms 产生 `Promise.reject(new Error("边界探针-rejection"))`。验证完已删除。
| 检查项 | 修复前 | 修复后实测 |
| --- | --- | --- |
| `[global:error]` 日志 | 无 | 1 条(window `error` 监听) |
| `[global:rejection]` 日志 | 无 | 1 条(window `unhandledrejection` 监听) |
| 弹窗标题 | 一次都没弹 | `未捕获错误(error)` → `未捕获错误(rejection)` |
| 排队时刻 | — | 第二条在 t≈5.6s 出现(第一条 t≈0.6s + 5s 时间槽),实测与 `MODAL_MAX_HOLD_MS` 一致 |
| 重复提示 | — | 无:uni 钩子与 window 监听没有叠加打两遍,`uni-modal` 节点数恒为 1,无多余 toast |
### 7.2 复现配方
1. 任意页面的 `onLoad` 里临时加一句 `setTimeout(() => { throw new Error("边界探针"); }, 600);` 与一句 `setTimeout(() => { Promise.reject(new Error("边界探针-rejection")); }, 900);`;
2. H5 跑 `npm run dev`,**强制整页刷新**(env / App.vue 改动不走热更):应看到两条 `[global:*]` 日志与两个先后出现的弹窗;
3. 换成发一个必然失败的请求:应只有请求层那一次提示,边界只留日志(说明"已提示"标记生效);
4. 小程序 / App 端:用微信开发者工具与 HBuilderX 各跑一次同样两个探针,确认 `[global:*]` 日志是否出现 —— 这一步还没做过,结果请回填 §8。
`npm run type-check` 与 `npm run lint` 只能证明代码可编译,**不能证明钩子被触发**;改完这块至少跑一遍第 1、2 步。
## 8. 已知缺口
- **小程序 / App 端运行时未实测**:H5 已钉死(§7.1),另两端只做到"构建产物里含边界代码"。§7.2 第 4 步跑完前,不能宣称三端都兜住了。
- **资源加载错误没接**:`img` / `script` 404 的 `error` 事件不冒泡,需要 `window.addEventListener("error", handler, true)`(capture 阶段)才收得到,目前未挂。
- **页面级错误未接管**:`onError` 是 App 级的,某个页面自己用 `errorCaptured` 拦截后就不会冒到这里(目前项目里没人用 `errorCaptured`)。
- **提示文案未国际化**:`"操作未成功,请稍后重试"` 与弹窗标题都是硬编码中文,接多语言时应改走 `vue-i18n`,与 `src/locale/lang` 一起维护。
- **生产 toast 文案过于笼统**:刻意不带细节,但如果要做错误上报(按 `source` + 文案指纹聚合),`reportGlobalError` 是唯一收口点,加上报只改这一处。
+36 -3
View File
@@ -2,6 +2,7 @@
适用范围:`src/pages.json`、`src/manifest.json`、`src/router/routes.ts`、`src/router/h5-clean-url.ts`、`src/main.ts`。
本文记录"为什么这么设计"和"新增页面要动哪几处",代码里的注释只解释局部行为,跨文件的约束以本文为准。
后端地址的 `.env` 分层见 [environment.md](./environment.md),跳转失败与未捕获错误的兜底口径见 [error-handling.md](./error-handling.md),权限判定见 [permission.md](./permission.md)。
---
@@ -74,6 +75,16 @@ src/pages/dashboard/dashboard.vue pages.json: pages/dashboard/dashboard URL
`history` 开关的读取路径:编译层特征开关 `__UNI_FEATURE_ROUTER_MODE__`,取自 `manifest.web || manifest.h5` 的 `router.mode`。
### 3.3 权限评估层(第三轮,见 permission.md)
| 文件 | 改动 |
| --- | --- |
| `permission/permission-kernel.ts` | 新增。纯函数判定内核 + 预建索引(`buildPermissionIndex` / `can` / `canAny` / `canOperate` / `actionsOf` / `grantedPermissions`) |
| `permission/permission-context.ts` | 新增。接 pinia 档案:`PermissionContext` / `usePermission` / `usePagePermission` / `canVisitPage` |
| `permission/permission-kernel.spec.ts` | 新增。59 条断言的可执行校验(esbuild + node 跑,未引测试框架) |
| `router/routes.ts` | `PAGE_PERMISSIONS` 值类型放宽为 `PermissionNames`(`string \| string[]`),注释指向 permission.md |
| `router/nav-guard.ts` | `evaluate()` 补第三步 `canVisitPage(name)` → `error403` |
---
## 4. 短地址机制:追加克隆记录,而不是挂 alias
@@ -165,6 +176,7 @@ export const PAGE_PATHS = {
2. src/pages.json 追加 { "path": "pages/album/album" }
3. src/router/routes.ts 的 PAGE_PATHS 追加 album: "pages/album/album"
4. src/pages/album/album.vue 受登录保护的页面调一次 useAuthGuard()
5. src/router/routes.ts 的 PAGE_PERMISSIONS 受功能权限控制的页面才登记权限名,见 permission.md §6
```
第 2 步是**绝对必须**:`pages.json` 是编译期唯一注册入口,不写就没有这个页面。
@@ -199,7 +211,7 @@ export const PAGE_PATHS = {
## 8. 当前没有做的事(已知缺口)
- **权限守卫只做了一半**:登录守卫与 401/404 已落地(§11),但 `PAGE_PERMISSIONS` 还是空表——旧 WebVue 的 `setupPermissionGuard` 依赖 `PermissionContext.canView`(菜单/功能权限清单),那套体系尚未移植,所以页面级 403 目前只由请求层的 HTTP 403 触发。
- **权限守卫已落地但登记表是空的**:`PAGE_PERMISSIONS` 尚无条目,路由级 403 因此还没被任何页面真正触发(现在能跳 403 的只有请求层的 HTTP 403)。判定层本身已完成并可用,语义、登记流程与已知缺口见 [permission.md](./permission.md)。
- **history 模式的服务端兜底**:`/login`、`/dashboard` 这类深链**直接刷新依赖服务端把请求 rewrite 回 `index.html`**。dev server(Vite)已实测返回 SPA 壳,但生产环境(如 `uat.youjiala.com` 的 Nginx)需要加 `try_files $uri $uri/ /index.html;` 一条,否则刷新会 404。这一条尚未在生产环境验证。
---
@@ -269,6 +281,7 @@ queue: res === false 时返回 { then(){}, catch(){} } 这样的空 thenable
- `AUTH_FREE_PAGES` = `login` + 三个错误页。**错误页必须免登录**:401 页就是给"没登录/登录失效"的人看的,拦掉它只会让用户看到一个登录框而不知道刚才为什么被踢。
- 登录态只认请求层的 `getJwtInfo()?.AccessToken`(令牌唯一来源,与 `stores/loginProfile` 口径一致)。
- 刻意**不**把已登录用户从 `/login` 弹回首页:403 页的"重新登录"按钮需要先能进入登录页。
- 第三步(403)走 **fail-open**:本地档案缺失或权限清单为空时放行,只有"档案里有清单但目标页的权限名不在其中"才拦。理由与风险边界见 permission.md §5——真正的安全边界在后端每个接口的 `[PermissionOperate]`。
- 跳错误页用 `redirectTo`(保住返回栈,"返回上一页"才有内容),失败再退化成 `reLaunch`。
### 11.3 错误页与外壳组件
@@ -290,12 +303,32 @@ queue: res === false 时返回 { then(){}, catch(){} } 这样的空 thenable
### 11.5 403 的两个触发点
- 请求层:`common/http.interceptor.ts` 的非 2xx 分支里 `statusCode === 403` → `goForbidden()` 跳 403 页并带 `?from=`;已经在 403 页就不再跳,避免"错误页自己发的请求又 403"来回刷。
- 导航层:等 `PAGE_PERMISSIONS` 与 `PermissionContext` 移植后接在 `evaluate()` 的第三步。
- 导航层:`evaluate()` 的第三步 `if (!canVisitPage(name)) return "error403"`,数据源是 `PAGE_PERMISSIONS` + `PermissionContext`(判定语义、双失败策略、登记流程全部见 [permission.md](./permission.md))。该步排在 401 之后:未登录时档案通常也是空的,先给 401 才谈得上"去登录"。
`goForbidden()` 在请求层自己实现而没有复用 `nav-guard` 的 `jumpToError`:`nav-guard` 依赖请求层读登录态,反向 import 会形成循环依赖。
`goForbidden()` 在请求层自己实现而没有复用 `nav-guard` 的 `jumpToError`:`nav-guard` 依赖请求层读登录态,反向 import 会形成循环依赖。`canVisitPage()` 由 `nav-guard` import `permission/permission-context` 单向调用,不构成环——`permission-context` 只 import `routes.ts` 的常量与 `resolvePageName`,不 import `nav-guard`。
### 11.6 词条与验证
- 文案全部走语言包:`locale/lang/{zh-CN,en-US}.json` 的 `ErrorPage` 组 30 个键;组件内固定的"可能的原因""出错的地址:"也在里面(`reasonsTitle` / `fromLabel`)。
- 实测(H5 dev + 自动化浏览器):未登录深链 `/dashboard` → 401 页且 `from` 显示正确;401 的"去登录"→ `/login?redirect=/dashboard`;`001/123` 登录后回跳 `/dashboard`;`/no/such/page` → 404 页;404 的"返回首页"放行;清态后直访 `/error/page403` 不被再拦、无死循环;`window.__uniRoutes` 共 10 条(5 真 + 5 短地址克隆),全程零 console error/warn。
- 三端构建 `EXIT=0`;`location.pathname` 仅出现在 H5 产物(`setupUnknownPathFallback` 的 `#ifdef H5` 生效),`page401` 三端产物均有。
### 11.7 `resolvePageName("")` 曾把登录成功后置地址清成空串(已修)
第三轮接权限层做端到端时暴露的既有缺陷,与权限无关,但正是"空地址到底算不算一个页面"这条边界没定死:
```text
无 ?redirect= 的普通登录
-> login.vue onLoad 里 decodeQueryPath(undefined) -> ""
-> resolvePageName("") -> normalizePath("") -> "/" -> 命中入口页分支 -> "login"(真值)
-> if (name) 成立 -> redirectUrl.value = ""(把默认的 /dashboard 覆盖成空串)
-> uni.reLaunch({ url: "" }) -> fail: Missing required args "url"
-> 用户点了登录、接口 200、令牌已写入,却仍然卡在登录页
```
`"/"` 作为 H5 入口页的路由 path 必须能被 `resolvePageName` 认出来(冷启动落在入口页时靠它反查页面名),所以不能动那一条;问题在于 `normalizePath` 把空串也归一成 `"/"`,于是"我压根没打算跳"被误读成"我要跳入口页"。修复分两层:
- `routes.ts` `resolvePageName`:开头对 `!String(url || "").trim()` 直接返回 `null`(源头掐断,空地址永远不算页面)。
- `login.vue` `onLoad`:判定条件从 `if (name)` 收紧为 `if (name && !isAuthFree(name))`——手敲 `?redirect=/` 或 `?redirect=/error/page401` 时不再把落地页设成登录页/错误页自己。
教训:**"`""` 和 `"/"` 等价"这种归一化只该出现在真正需要它的地方**。凡是把字符串地址解析成结构化结果的函数,都要先明确"空输入是错误还是默认值",否则默认值会顺着真值判断悄悄覆盖掉上游的初始化。
+319
View File
@@ -0,0 +1,319 @@
# 权限评估层(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
<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>
```
数据级判定(同一功能在不同群组/目录下动作不同):
```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<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,项目根目录):
```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` 是空白占位页 |
| ② 页面可进入性 | 手输地址/深链跳过去能不能进 | `PAGE_PERMISSIONS` + `canVisitPage()` | 判定层就绪,登记表为空 |
旧 WebVue 主要靠 ①:看不见的入口自然不会点,②只是补"手敲 URL"的漏洞。①的后端链路(`SecurityUserService.cs:232-248`)是 `用户名 -> security_user_actor -> security_actor_group -> security_group_permission -> security_permission_function_map -> security_function`,只返回本人被授权的功能,`UserId` 为空直接返回 null。
两个接口都已随 service 层移植进来且**零调用者**:`securityUserService.FindMenuByUsername()`(返回 `SecurityFunctionResultData`,含 `SecurityFunctionDisplayData[]` 的 `FunctionLocalName/FunctionEngName/Icon/Orderby/PageUrl/PermissionName` 与 `SecurityFunctionMaps` 层级,正是旧 `layout/index.vue` 的 `initMenu()` 消费的形态)和 `securityFunctionService.FindMenus()`(`[AllowAnonymous]`、**不区分用户的全量表**,旧项目只用来全量注册路由,别拿它做过滤)。
所以这一条缺口的真实含义是:等做首页入口时调 `FindMenuByUsername` 渲染,并用返回的 `PermissionName` 顺手校对 `PAGE_PERMISSIONS` 的登记;`stores/tabbar.ts` 的默认列表目前也是空的,等同一个数据源。
- **数据级(dependent)判定尚无真实页面在用**:旧项目所有调用点都是单参形式,口子为 `Submit`/`Approval` 与将来的目录级授权保留。
- **`PAGE_PERMISSIONS` 目前是空表**:现在只有 login / dashboard / 三个错误页,除 dashboard 有意不绑(§3.1)之外,其余页面还没迁进来。空表意味着路由级 403 实际上还没被任何页面触发,只能靠请求层的 HTTP 403。
- **403 页的"返回首页"在首页本身被拦时会原地弹回一次**:`page403.vue` 的返回按钮走 `reLaunch(pageUrl("dashboard"))`,而 reLaunch 同样过 `nav-guard`;若 dashboard 恰好被绑了拿不到的权限,这次跳转会被再拦一次、弹回 403(实测是一次性弹回,不是死循环,因为 403 页自身在 `AUTH_FREE_PAGES` 里)。等真有页面登记权限时,这里应该改成"按 `PAGE_PERMISSIONS` 找第一个可达页"或直接隐藏该按钮。
- **前端判定滞后于后端**:后端每次请求实时查库,前端读登录时的快照。管理员改了授权后,用户重新登录前看到的仍是旧按钮状态(可能点了才 403)。这是"权限随令牌下发"这个既有模型的代价,不是本层引入的。
- **`SecurityUserPermissionInfo.UserGroups` 未参与判定**:后端也不用它做短路(只用于 join 出授权行),前端同样只用 `Permissions` / `PermissionDependents`。
- **孤儿授权数据**:种子库里 `security_group_permission` 有 `abc` 组以及 `acc`/`PhotoPublish`/`AudioPublish`/`VideoPublish` 等权限名,而 `security_group` / `security_permission` 里查不到它们。判定层对此的表现是"多几行永远匹配不上的索引项",无害;但登记权限名时**不能以授权表的并集为准**,要以 `security_permission` 为准,否则会照抄到这些历史残留。