摘要:在企业级中后台应用中,前端权限控制往往容易陷入"过度设计"的误区。本文复盘了我们如何将一个原本计划投入 30 人天的"全栈级前端鉴权方案",通过架构思维的转变,重构为仅需 5 人天的"体验导向型方案"。我们放弃了在浏览器端构建虚假的"马其诺防线",转而利用验证中心(Verification Center)模式和 TypeScript 类型系统,打造了极致的用户体验。
一、 背景:一场关于"安全感"的博弈
在最近的 IBS Web 内测迭代中,我们面临一个经典的安全审计问题:"用户可以通过直接修改 URL 访问无权限的页面。"
面对这个问题,技术团队的第一反应是构建一套严密的"前端防线":
- 路由层 :在
beforeEach中拦截所有未授权访问。 - 视图层 :封装
v-permission指令移除 DOM 元素。 - 数据层:在 Store 中维护一份庞大的权限映射表,甚至试图在前端过滤列表数据。
然而,在深入评估后,我们发现这种"重前端、轻后端"的策略存在巨大的 ROI(投入产出比)陷阱。
1.1 误区分析
- 重复建设:后端 API 已经实现了完善的数据级权限控制(Data Scope),前端再做一遍数据过滤是纯粹的冗余。
- 维护噩梦 :前后端权限逻辑必须时刻保持 1:1 同步,一旦后端调整粒度(如新增一个"导出"权限),前端必须发版,否则就会出现"后端允许但前端拦截"的 False Positive。
- 伪安全 :前端的所有代码对用户都是透明的。熟练的攻击者可以直接通过 Postman 绕过前端路由调用 API。前端永远不是安全防线,后端才是。
二、 架构重构:Verification Center 模式
基于"前端负责体验,后端负责安全"的原则,我们重新设计了权限架构。核心组件是 验证中心(Verification Center)。
2.1 架构设计图
2.2 核心代码实现:可插拔的验证规则
为了解决"不同场景需要触发不同验证"的问题(例如:F5 刷新时需要重新校验,但路由跳转时可以复用缓存),我们设计了 VerificationRule 接口,并引入了 noCache 机制。
typescript
// src/services/verification/index.ts (精简版)
export type When = 'login' | 'appReady' | 'routeChange' | 'manual'
export interface VerificationRule {
id: string
when: When[]
// 核心特性:控制是否跳过会话级缓存
// F5 刷新或强制重校验时,此标志决定是否再次弹出认证窗口
noCache?: boolean
shouldRun: (ctx: VerificationContext, when?: When) => boolean | Promise<boolean>
run: (ctx: VerificationContext, when?: When) => void | Promise<void>
}
// 验证执行引擎
async function run(when: When) {
const list = rules.filter(r => r.when.includes(when))
for (const rule of list) {
// 智能缓存策略:除非规则明确要求 noCache,否则同一会话仅执行一次
if (!rule.noCache && sessionSeen[rule.id])
continue
if (await rule.shouldRun(ctx, when)) {
if (!rule.noCache) sessionSeen[rule.id] = true
await rule.run(ctx, when)
}
}
}
设计亮点:
- 解耦 :路由守卫不再关心具体的业务逻辑(如"密码是否过期"),只负责触发
VerificationCenter.run('routeChange')。 - 性能 :通过
sessionSeen缓存机制,避免了每次路由切换都重复执行昂贵的校验逻辑。 - 灵活 :针对关键操作(如"用户类型变更"),通过配置
noCache: true即可强制每次刷新页面时重新校验,完美解决了"F5 刷新后弹窗不复现"的顽疾。
三、 TypeScript 与 Pinia 的类型体操
在重构 Permission Store 时,我们遇到了 Pinia 在复杂类型推断下的一个经典问题:ts(2742)。
3.1 问题复现
当我们尝试在 setup 语法中使用复杂的嵌套类型(如递归的菜单树)并隐式推断返回类型时,TypeScript 编译器抛出了错误:
The inferred type of 'usePermissionStore' cannot be named without a reference to '.pnpm/.../node_modules/@intlify/core-base'. This is likely not portable. A type annotation is necessary.
这是因为推断出的类型包含了一些不仅不可见、而且路径极深的第三方内部类型。
3.2 解决方案:显式接口定义
为了解决这个问题,并遵循"高内聚"的设计原则,我们放弃了隐式推断,转而定义明确的 Store 接口。
typescript
// src/store/core/permission.ts
// 1. 明确定义路由类型(解决递归类型推断问题)
export type AppRouteRecordRaw = RouteRecordRaw & {
hidden?: boolean
children?: AppRouteRecordRaw[]
}
// 2. 定义 Store 公开接口(Contract)
export interface PermissionStoreAPI {
routes: Ref<AppRouteRecordRaw[]>
generateRoutesFromMenu: (menuList: MenuItem[]) => MenuItem[]
restoreRoutes: () => boolean
}
// 3. 在 defineStore 中显式应用接口
export const usePermissionStore = defineStore('permission', (): PermissionStoreAPI => {
const routes = ref<AppRouteRecordRaw[]>([])
function generateRoutesFromMenu(menuList: MenuItem[]) {
// ... 具体的业务逻辑
return []
}
function restoreRoutes() {
// ... 恢复逻辑
return true
}
return {
routes,
generateRoutesFromMenu,
restoreRoutes,
}
})
这种做法虽然多写了几行代码,但带来了显著的收益:
- 类型稳定:切断了对第三方私有类型的依赖。
- 文档化 :
PermissionStoreAPI接口本身就是最好的文档,开发者一眼就能看出这个 Store 提供了哪些能力。
四、 路由层的"软拦截"策略
在路由层面,我们放弃了传统的"硬拦截"(即检测到无权限直接 next(false) 或重定向),转而采用"软拦截"策略。
4.1 为什么要软拦截?
在内测阶段,如果用户通过 URL 访问了一个尚未在菜单配置的页面,硬拦截会直接导致 404 或死循环。而软拦截允许页面加载,但通过后端 API 的 403 响应来驱动 UI 展示。
4.2 实现方式
typescript
// src/router/index.ts
router.beforeEach(async (to, from, next) => {
// 1. 启动进度条,提升感知
nprogressManager.start()
// 2. 核心:不在此处做复杂的权限比对
// 我们信任后端数据安全,这里只做基础的登录态检查
// 如果用户已登录但无权限,让他进入页面,看到"无数据"或"无权限"的空状态组件
// 3. 触发验证中心(异步,不阻塞路由跳转)
VerificationCenter.run('routeChange')
next()
})
这种策略将"权限不足"的处理权交还给了页面组件(配合 <el-empty description="无权访问" />),既保证了系统的鲁棒性,又提升了用户体验。
五、 总结与思考
这次重构不仅仅是代码层面的修改,更是技术价值观的校准。
- 分层治理:后端守住安全底线,前端负责交互上限。
- 体验优先:权限控制的目的是"引导用户",而不是"防御用户"。
- 极简主义:用 20% 的代码解决 80% 的核心体验问题,剩下的 20% 极端场景交给后端兜底。
通过这套架构,我们将原本需要 30 人天的庞大工程,精简为 5 人天的高效迭代,同时彻底解决了 F5 刷新、类型推断错误等技术债。这或许才是架构设计的魅力所在:在约束中寻找最优解。