写在前面
上一篇聊了项目初始化和工程规范,这篇来聊一个让所有后台系统都绕不开的话题------权限控制。
为什么要做权限?因为我的项目不是一个单用户的 demo,而是一个有完整用户体系的平台。系统管理模块里有用户管理、角色管理、菜单权限管理,股票基金模块有监控阈值配置,AI 模块有 Token 配额管理。这些功能不是所有用户都应该看到的------不同的用户看到的内容是可以动态设置的。
所以权限控制不是"锦上添花",而是必须做的事。
一个人做权限,和团队做有什么区别? 团队做权限,通常有现成的权限框架(如 Sa-Token、Shiro),配置一下就能用。但我想从零实现,能根据自己的需求定制。
最后我实现了一套前后端分离的完整权限链路------后端管数据,前端管展示,两层互不依赖但紧密配合。
访问地址 :http://124.222.194.201/
前端代码 :github.com/seapack-hub...
后端代码 :github.com/seapack-hub...
一、权限模型:五张表撑起整个体系
1.1 经典 RBAC 模型
我采用了经典的 RBAC(Role-Based Access Control) 模型------基于角色的访问控制。核心思想很简单:
用户 → 角色 → 权限
用户不直接拥有权限,而是通过角色间接获得。一个用户可以有多个角色,一个角色可以包含多个权限。这样管理起来很灵活:给一个角色加了权限,这个角色下的所有用户自动生效。
1.2 数据库设计:五张表
plain
┌──────────┐ ┌──────────────┐ ┌──────────┐ ┌──────────────────┐ ┌──────────────┐
│ sys_user │────▶│ sys_user_role│◀────│ sys_role │────▶│sys_role_permission│◀────│sys_permission│
└──────────┘ └──────────────┘ └──────────┘ └──────────────────┘ └──────────────┘
用户表 用户-角色中间表 角色表 角色-权限中间表 权限/菜单表
用户表
plain
CREATE TABLE `sys_user` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`user_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '用户名',
`mobile` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '手机号',
`nick_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '昵称',
`gender` tinyint NULL DEFAULT NULL COMMENT '性别 (1男 0女)',
`email` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '邮箱',
`status` tinyint NULL DEFAULT NULL COMMENT '状态 (1正常 0停用)',
`dept_id` bigint NULL DEFAULT NULL COMMENT '部门ID',
`dept_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '部门名称',
`create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
`password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '密码',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 6 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户表' ROW_FORMAT = Dynamic;
用户角色关联表
plain
CREATE TABLE `sys_user_role` (
`user_id` bigint NOT NULL COMMENT '用户ID',
`role_id` bigint NOT NULL COMMENT '角色ID',
PRIMARY KEY (`user_id`, `role_id`) USING BTREE,
KEY `idx_role_id` (`role_id`) USING BTREE,
-- 【重点】直接在建表时声明外键并设置级联删除
CONSTRAINT `fk_user_role_user_id` FOREIGN KEY (`user_id`) REFERENCES `sys_user` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_user_role_role_id` FOREIGN KEY (`role_id`) REFERENCES `sys_role` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户与角色关联表';
角色表
plain
CREATE TABLE `sys_role` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '角色ID',
`role_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色名称',
`role_code` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色编码',
`description` varchar(255) DEFAULT NULL COMMENT '角色描述',
`status` tinyint DEFAULT 1 COMMENT '状态 (1正常 0停用)',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uk_role_code` (`role_code`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统角色表';
角色权限关联表
plain
CREATE TABLE `sys_role_permission` (
`role_id` bigint NOT NULL COMMENT '角色ID',
`permission_id` bigint NOT NULL COMMENT '权限ID',
PRIMARY KEY (`role_id`, `permission_id`) USING BTREE,
KEY `idx_permission_id` (`permission_id`) USING BTREE,
CONSTRAINT `fk_role_perm_role_id` FOREIGN KEY (`role_id`) REFERENCES `sys_role` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_role_perm_perm_id` FOREIGN KEY (`permission_id`) REFERENCES `sys_permission` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色与权限关联表';
权限/菜单表
plain
CREATE TABLE sys_permission (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '权限ID',
parent_id BIGINT DEFAULT 0 COMMENT '父级权限ID (0表示顶级)',
name VARCHAR(100) COMMENT '权限/菜单名称',
perm_key VARCHAR(100) COMMENT '权限标识符',
type INT COMMENT '资源类型 (1-目录, 2-菜单, 3-按钮)',
path VARCHAR(200) COMMENT '前端路由路径',
component VARCHAR(200) COMMENT '前端组件路径',
sort_order INT DEFAULT 0 COMMENT '显示排序',
status INT DEFAULT 1 COMMENT '状态 (1正常 0停用)'
);
五张表的关系:
| 表名 | 作用 | 关键字段 |
|---|---|---|
sys_user |
存储用户基本信息 | id, user_name, password, nick_name |
sys_role |
存储角色定义 | id, role_name, role_code, description |
sys_permission |
核心表------存储目录、菜单、按钮三种资源 | id, parent_id, name, perm_key, type, path, component |
sys_user_role |
用户-角色多对多关联 | user_id, role_id(联合主键) |
sys_role_permission |
角色-权限多对多关联 | role_id, permission_id(联合主键) |
重点说说 sys_permission 表 ------它是整个权限体系的核心。这张表用 parent_id 构建树形结构,通过 type 字段区分三种资源类型:
java
// SysPermission.java
@Entity
@Table(name = "sys_permission")
public class SysPermission {
@Id
private Long id;
private Long parentId; // 父级权限ID (0表示顶级)
private String name; // 权限/菜单名称
private String permKey; // 权限标识符(如 systemManagement、user、add)
private Integer type; // 资源类型 (1-目录, 2-菜单, 3-按钮)
private String path; // 前端路由路径
private String component; // 前端组件路径
private Integer sortOrder; // 显示排序
private Integer status; // 状态 (1正常 0停用)
}
type 的三种取值对应了权限的三个层级:
plain
type=1 目录(如"系统管理")
└── type=2 菜单(如"用户管理")
└── type=3 按钮(如"新增用户")
举个例子,数据库里可能长这样:
plain
[目录] systemManagement → type=1, perm_key=systemManagement
[菜单] user → type=2, perm_key=user
[按钮] add → type=3, perm_key=add
[按钮] edit → type=3, perm_key=edit
[按钮] delete → type=3, perm_key=delete
[菜单] role → type=2, perm_key=role
[按钮] add → type=3, perm_key=add
注意按钮的 permKey 只存了 add、edit 这样的短名,不是 sys:user:add 这样的完整路径。完整路径是在后端查询时动态拼接出来的,后面会详细讲。
1.3 权限标识符的设计:短名 + 动态拼接
为什么要这样设计?直接在按钮上存 sys:user:add 不是更简单?
确实更简单,但有两个问题:
- 冗余 :如果
add这个按钮被多个菜单复用(比如用户管理和角色管理都有"新增"按钮),存完整路径会导致重复 - 维护成本:如果目录的 permKey 改了,所有子节点的完整路径都要改
所以我选择了只存短名,查询时拼接 的策略。后端 AuthService 中有一个递归方法负责这件事:
java
// AuthService.java
private String buildPermKeyPath(SysPermission p, List<SysPermission> allPerms) {
// 递归向上查找父节点,拼接完整路径
// 例如:按钮 permKey="add",父菜单 permKey="user",祖父目录 permKey="system"
// 返回 "system:user:add"
if (p.getParentId() == null || p.getParentId() == 0) {
return p.getPermKey();
}
SysPermission parent = allPerms.stream()
.filter(a -> a.getId().equals(p.getParentId()))
.findFirst().orElse(null);
String parentPath = buildPermKeyPath(parent, allPerms);
return parentPath + ":" + p.getPermKey();
}
效果 :前端拿到的按钮权限标识符是 ["system:user:add", "system:role:edit"] 这样的完整路径,一眼就能看出这个按钮属于哪个模块的哪个菜单。
二、后端:三个接口撑起权限数据
后端权限相关的接口只有三个,设计原则是每个接口职责单一,返回的数据前端直接能用:
2.1 接口一:GET /auth/user-info ------ 用户信息 + 菜单权限
java
@GetMapping("/user-info")
public ResponseEntity<UserInfoVO> userInfo() {
Long userId = SecurityUtils.getCurrentUserId(); // 从 JWT Token 自动解析
return ResponseEntity.ok(authService.getUserInfo(userId));
}
返回的 UserInfoVO 包含:
java
public class UserInfoVO {
private Long userId;
private String userName;
private List<String> roles; // 角色编码列表,如 ["admin", "user"]
private List<String> permissions; // 菜单权限标识符,如 ["systemManagement", "user"]
}
关键细节 :permissions 只包含 type=1(目录)和 type=2(菜单)的 permKey,不包含按钮权限 。为什么分开?因为菜单权限和按钮权限的使用场景不同------菜单权限用于路由守卫和侧边栏渲染 ,按钮权限用于页面内按钮显隐。混在一起反而增加复杂度。
java
// AuthService.java ------ getUserInfo
List<SysPermission> menuPerms = permSet.stream()
.filter(p -> (p.getType() == 1 || p.getType() == 2)) // 只要目录和菜单
.collect(Collectors.toList());
return UserInfoVO.of(userId, user.getUserName(), roles, menuPerms);
2.2 接口二:GET /auth/menus ------ 动态菜单树
java
@GetMapping("/menus")
public ResponseEntity<List<PermissionTreeNode>> menus() {
Long userId = SecurityUtils.getCurrentUserId();
return ResponseEntity.ok(authService.getUserMenus(userId));
}
返回的是一个树形结构,前端拿到后用于渲染侧边栏和提取 permKey:
java
// PermissionTreeNode.java
public class PermissionTreeNode {
private Long id;
private Long parentId;
private String name;
private String permKey;
private Integer type;
private String path;
private String component;
private Integer sortOrder;
private List<PermissionTreeNode> children = new ArrayList<>();
}
树形构建的核心逻辑:
- 从
sys_role_permission查出用户所有角色拥有的权限 ID - 从
sys_permission查出所有权限记录(扁平列表) - 过滤出用户有权限的
type=1/2节点 - 补全父节点链------如果用户只有"用户管理"菜单的权限,但没有"基础信息"目录的权限,也要把"基础信息"目录加进来,否则树形结构会断
- 递归构建树形结构
java
// 补全父节点链,确保树形结构完整
private void addAncestors(SysPermission p, List<SysPermission> allPerms, Set<Long> visibleIds) {
if (p.getParentId() == null || p.getParentId() == 0) return;
visibleIds.add(p.getParentId());
allPerms.stream()
.filter(a -> a.getId().equals(p.getParentId()))
.findFirst()
.ifPresent(parent -> addAncestors(parent, allPerms, visibleIds));
}
为什么要补全父节点? 想象一下:用户只有"用户管理"(type=2)的权限,没有"基础信息"(type=1)的权限。如果不补全父节点,侧边栏会渲染出一个没有父级的孤立菜单项,样式和层级都会出问题。补全之后,侧边栏能正确渲染出"基础信息 > 用户管理"的层级结构。
2.3 接口三:GET /auth/buttons ------ 按钮权限
java
@GetMapping("/buttons")
public ResponseEntity<List<String>> buttons() {
Long userId = SecurityUtils.getCurrentUserId();
return ResponseEntity.ok(authService.getUserButtonPerms(userId));
}
返回的是完整路径格式的按钮权限列表:
json
["system:user:add", "system:user:edit", "system:role:add", "system:role:delete"]
核心逻辑:
- 查出用户所有角色的权限 ID
- 从所有权限记录中筛选
type=3(按钮)的节点 - 对每个按钮调用
buildPermKeyPath()拼接完整路径
java
// AuthService.java ------ getUserButtonPerms
for (SysPermission p : allPerms) {
if (permIds.contains(p.getId()) && p.getType() == 3 && p.getPermKey() != null) {
String fullPath = buildPermKeyPath(p, allPerms); // 递归拼接路径
if (fullPath != null && !fullPath.isEmpty()) {
buttonPerms.add(fullPath);
}
}
}
2.4 安全层:Spring Security + JWT
所有权限接口都需要携带有效的 JWT Token。后端通过 Spring Security 的过滤器链实现:
java
// SecurityConfig.java
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/login", "/auth/captcha/**", "/auth/rsa/**").permitAll()
.anyRequest().authenticated() // 其他接口都要认证
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
JwtAuthenticationFilter 在每个请求到达 Controller 之前执行,从 Authorization 头提取 Token,解析出 userId,写入 SecurityContext。后续 Controller 可以直接通过 SecurityUtils.getCurrentUserId() 获取当前用户 ID:
java
// SecurityUtils.java
public static Long getCurrentUserId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof Long) {
return (Long) auth.getPrincipal();
}
return null;
}
这里有一个重要的设计决策 :JwtAuthenticationFilter 只做 Token 校验和用户身份注入 ,不做权限校验。权限校验交给前端的路由守卫和按钮指令。为什么?因为前后端分离架构下,后端只负责提供数据,前端负责展示控制。后端做权限校验(如 URL 级别的访问控制)是"防君子不防小人"------真正要保护数据安全,应该在业务逻辑层校验,而不是在网关层。
三、前端:路由守卫的完整链路
3.1 登录后的第一次请求:权限数据加载
用户登录成功后,前端会拿到 Token 存入 localStorage。但权限数据不在登录时加载,而是在用户第一次访问需要权限的页面时,由路由守卫触发加载。
这个设计的原因是:登录接口只返回 Token 和基本信息,不返回权限数据。权限数据的获取需要有效的 Token,所以必须先登录、拿到 Token、再请求权限接口。
路由守卫 permissionPlugin 的核心逻辑:
typescript
// permissionPlugin.ts
async beforeEach({ to }) {
const userStore = useUserStore()
// 1. 白名单直接放行
if (WHITE_LIST.includes(to.path)) return undefined
// 2. Token 校验与恢复
if (!userStore.token) {
const isRestored = userStore.restoreLoginState() // 从 localStorage 恢复
if (!isRestored) {
return `/login?redirect=${to.path}` // 未登录 → 跳登录页
}
// 2a. 权限数据已加载则跳过
if (!userStore.authLoaded) {
// 2b. 优先从 sessionStorage 恢复(页面刷新后避免重复 API 调用)
const cached = userStore.restoreAuthFromCache()
if (!cached) {
// 2c. 缓存未命中 → 从后端拉取
try {
await userStore.fetchAuthPerms(String(userStore.userId))
} catch {
userStore.clearAuth()
userStore.clearToken()
return `/login?redirect=${to.path}` // Token 过期 → 跳登录页
}
} else {
// 从缓存恢复后需要重建路由表
usePermissionStore().collectRoutes()
}
}
}
// 3. 权限标识检查
const permKey = getValidPermKey(to.meta)
if (permKey) {
if (!userStore.menuPermKeys.includes(permKey)) {
// 无权限 → 在同模块内找第一个有权限的子路由
const moduleName = to.matched.find(
r => r.name && MODULE_ROUTE_NAMES.includes(r.name as string)
)?.name
if (moduleName) {
const modDef = MODULE_DEFS.find(m => m.key === moduleName)
const fallback = modDef?.entryRoutes?.find(
name => userStore.menuPermKeys.includes(name)
)
if (fallback) return { name: fallback }
}
return '/errorPage/403' // 找不到 → 跳 403
}
}
}
三层缓存策略,减少不必要的 API 请求:
plain
第 1 层:内存(userStore.authLoaded)
↓ 未命中
第 2 层:sessionStorage(页面刷新后恢复)
↓ 未命中
第 3 层:后端 API(fetchAuthPerms)
为什么要这样设计?因为权限数据很少变化(只有管理员修改权限时才会变),如果每次都请求后端接口,既浪费带宽又增加延迟。sessionStorage 在标签页关闭时自动清除,保证了权限数据的时效性。
3.2 权限数据的三层结构
fetchAuthPerms 一次请求三个接口,拿到三类数据:
typescript
// userStore.fetchAuthPerms
async fetchAuthPerms(userId: string) {
// 1. 用户信息 + 菜单权限标识
const authInfo = await AuthAPI.getUserInfo(userId)
this.roles = authInfo.roles
this.perms = authInfo.perms
// 2. 按钮权限标识(完整路径)
const buttonPerms = await AuthAPI.getButtons()
this.buttonPerms = buttonPerms
// 3. 动态菜单树
const menu = await AuthAPI.getMenus(userId)
this.menuTree = menu
// 缓存到 sessionStorage
this.authLoaded = true
this.saveAuthToCache()
// 重建路由表,刷新侧边栏
usePermissionStore().collectRoutes()
}
三个数据各司其职:
| 数据 | 来源接口 | 用途 | 存储位置 |
|---|---|---|---|
perms |
/auth/user-info | usePermission 组合式函数的权限/角色判断 |
userStore |
buttonPerms |
/auth/buttons | v-permission 指令和 useButtonPermission 的按钮显隐 |
userStore |
menuTree |
/auth/menus | 提取menuPermKeys 供路由守卫校验,渲染侧边栏 |
userStore |
menuPermKeys** 是一个计算属性**,从 menuTree 中递归提取所有非空的 permKey:
typescript
// userStore.menuPermKeys
menuPermKeys(state): string[] {
const keys = new Set<string>()
function walk(nodes: MenuTree[]) {
for (const node of nodes) {
const k = node.permKey?.trim()
if (k) keys.add(k)
if (node.children?.length) walk(node.children)
}
}
walk(state.menuTree)
return Array.from(keys)
}
这个属性是路由守卫判断用户是否有权访问某个页面的唯一数据源 。为什么不用 perms?因为 perms 只包含 permKey 列表,而 menuPermKeys 是从实际的菜单树中提取的------如果后端菜单树里没有某个节点,即使 perms 里有对应的 permKey,路由守卫也不会放行。这样保证了权限数据的一致性。
3.3 路由定义中的 permKey
每个路由都可以在 meta.permKey 中声明自己的权限标识:
typescript
// systemManagement.ts
{
path: '/systemManagement',
name: 'systemManagement',
meta: {
title: 'systemManagement',
description: '系统管理',
icon: 'system',
permKey: 'systemManagement', // 目录级权限标识
},
children: [
{
path: 'user',
name: 'user',
meta: {
title: 'user',
description: '用户管理',
icon: 'user',
permKey: 'user', // 菜单级权限标识
},
},
{
path: 'role',
name: 'role',
meta: {
title: 'role',
description: '角色管理',
permKey: 'role',
},
},
]
}
路由守卫的校验流程:
plain
用户访问 /systemManagement/user
↓
permissionPlugin 检查 meta.permKey = 'user'
↓
检查 userStore.menuPermKeys 是否包含 'user'
↓ 包含 → 放行
↓ 不包含
↓ 在 systemManagement 模块内找 entryRoutes 中第一个有权限的
↓ 找到 → 重定向
↓ 找不到 → 跳 403
兜底重定向 是一个很实用的设计。假设用户没有"用户管理"的权限,但有"角色管理"的权限。当用户访问 /systemManagement/user 时,路由守卫不会直接跳 403,而是先在 systemManagement 模块的 entryRoutes 中找第一个用户有权限的路由:
typescript
// config/modules.ts
{
key: 'systemManagement',
entryRoutes: [
'Dashboard', 'baseInfo', 'dept', 'user', 'industryManagement',
'industryClassification', 'dictSetting', 'permission', 'role', 'menu',
],
}
如果用户有 role 的权限,就重定向到角色管理页面。只有所有 entryRoutes 都没权限,才跳 403。这样用户体验好很多------不会因为点错一个菜单就看到冷冰冰的 403 页面。
3.4 权限数据的缓存与恢复
页面刷新是权限系统最容易出 bug 的场景。用户登录后刷新页面,Pinia 状态会丢失,需要重新加载权限数据。但如果每次都请求后端接口,体验会很差(刷新后要等几秒才能看到页面)。
我的解决方案是三层缓存:
typescript
// 缓存结构
interface AuthCache {
roles: string[]
perms: string[]
buttonPerms: string[]
menuTree: MenuTree[]
}
// 保存到 sessionStorage
saveAuthToCache() {
const cache: AuthCache = {
roles: this.roles,
perms: this.perms,
buttonPerms: this.buttonPerms,
menuTree: this.menuTree,
}
sessionStorage.setItem(CacheKey.AUTH_CACHE, JSON.stringify(cache))
}
// 从 sessionStorage 恢复
restoreAuthFromCache(): boolean {
const raw = sessionStorage.getItem(CacheKey.AUTH_CACHE)
if (!raw) return false
const cache: AuthCache = JSON.parse(raw)
this.roles = cache.roles
this.perms = cache.perms
this.buttonPerms = cache.buttonPerms
this.menuTree = cache.menuTree
this.authLoaded = true
return true
}
路由守卫中的恢复逻辑:
typescript
// permissionPlugin.ts
if (!userStore.authLoaded) {
const cached = userStore.restoreAuthFromCache()
if (cached) {
// 从缓存恢复 → 重建路由表(路由注册状态也需要恢复)
usePermissionStore().collectRoutes()
} else {
// 缓存未命中 → 请求后端
await userStore.fetchAuthPerms(String(userStore.userId))
}
}
为什么用 sessionStorage 而不是 localStorage? 因为**权限数据应该跟随标签页的生命周期**。用户打开多个标签页时,每个标签页有独立的 sessionStorage,互不干扰。标签页关闭后自动清除,保证下次打开时重新获取最新权限数据。
四、前端:按钮级权限的三种实现方式
菜单权限控制的是"用户能看到哪些页面",按钮权限控制的是"用户在页面里能做哪些操作"。 我提供了三种方式来控制按钮显隐,覆盖不同的使用场景。
4.1 方式一:v-permission 指令(模板声明式)
最简单直接的方式,在模板中用自定义指令控制按钮显隐:
vue
<!-- 单个权限标识 -->
<el-button v-permission="'system:user:add'">新增用户</el-button>
<!-- 多个权限标识(OR 逻辑) -->
<el-button v-permission="['system:user:add', 'system:user:edit']">批量操作</el-button>
<!-- 无参数:始终显示 -->
<el-button>公开功能</el-button>
实现原理:
typescript
// directives/permission.ts
function checkPermission(perm?: string | string[]): boolean {
const userStore = useUserStore()
const perms = userStore.buttonPerms ?? []
if (!perm) return true
if (perms.includes('*:*:*') || perms.includes('*')) return true // 通配符
if (typeof perm === 'string') return perms.includes(perm)
if (Array.isArray(perm)) return perm.some(p => perms.includes(p))
return false
}
app.directive('permission', {
mounted(el, binding) {
if (!checkPermission(binding.value)) {
el.parentNode?.removeChild(el) // 无权限 → 从 DOM 移除(不是隐藏)
}
},
updated(el, binding) {
if (!checkPermission(binding.value)) {
el.parentNode?.removeChild(el)
}
},
})
两个关键设计:
- 移除而非隐藏 :无权限时直接从 DOM 移除元素,而不是
display: none。这样用户即使打开开发者工具也看不到被隐藏的按钮,安全性更高 - 支持通配符 :管理员角色通常需要所有权限,
*:*:*通配符避免了给管理员配置几百个权限的麻烦
4.2 方式二:useButtonPermission Hook(脚本式)
当按钮需要在 JavaScript 逻辑中判断权限时(比如操作列的按钮组),用组合式函数更合适:
typescript
// hooks/useButtonPermission.ts
export default () => {
const userStore = useUserStore()
const buttonHasPermission = (buttonPermission: any) => {
const permKey = getPermKey(buttonPermission)
if (!permKey) return true
// 新系统:从 userStore.buttonPerms 查找
const { buttonPerms } = userStore
if (buttonPerms.length > 0) {
if (buttonPerms.includes('*:*:*') || buttonPerms.includes('*')) return true
return buttonPerms.includes(permKey)
}
// 旧系统:从 route.meta.buttonList 查找(兼容)
// ...
}
const buttonsHasPermission = (buttons: any[]) => {
return buttons.filter(item => {
if (item.buttonPermission) return buttonHasPermission(item.buttonPermission)
return true
})
}
return { buttonHasPermission, buttonsHasPermission }
}
使用示例:
vue
<script setup>
const { buttonsHasPermission } = useButtonPermission()
const operationButtons = computed(() => {
const buttons = [
{ label: '编辑', type: 'primary', buttonPermission: 'system:user:edit' },
{ label: '删除', type: 'danger', buttonPermission: 'system:user:delete' },
]
return buttonsHasPermission(buttons) // 过滤掉无权限的按钮
})
</script>
<template>
<el-button v-for="btn in operationButtons" :key="btn.label" :type="btn.type">
{{ btn.label }}
</el-button>
</template>
4.3 方式三:usePermission Hook(通用权限/角色判断)
当需要在 JavaScript 中同时判断权限和角色时,用这个 hook:
typescript
// hooks/usePermission.ts
export function usePermission() {
const userStore = useUserStore()
// 校验权限标识(OR 逻辑)
function hasPermission(perm?: string | string[]): boolean {
const perms = userStore.perms ?? []
if (!perm) return true
if (typeof perm === 'string') return perms.includes(perm)
if (Array.isArray(perm)) return perm.some(p => perms.includes(p))
return false
}
// 校验角色编码
function hasRole(role: string | string[]): boolean {
const roles = userStore.roles ?? []
if (typeof role === 'string') return roles.includes(role)
if (Array.isArray(role)) return role.some(r => roles.includes(r))
return false
}
// 校验所有权限(AND 逻辑)
function hasAllPerms(perms: string[]): boolean {
const userPerms = userStore.perms ?? []
return perms.every(p => userPerms.includes(p))
}
return { hasPermission, hasRole, hasAllPerms }
}
三种 hook 的适用场景:
| Hook | 数据源 | 适用场景 |
|---|---|---|
v-permission |
userStore.buttonPerms |
模板中控制按钮显隐 |
useButtonPermission |
userStore.buttonPerms |
操作列按钮组过滤 |
usePermission |
userStore.perms + userStore.roles |
逻辑分支判断、角色级别功能开关 |
4.4 页面级权限校验
除了路由守卫的拦截,我还在页面组件内部加了一层兜底校验:
typescript
// hooks/usePagePermission.ts
export function usePagePermission(permKey: string, pageName: string) {
const router = useRouter()
const userStore = useUserStore()
onMounted(() => {
if (!userStore.menuPermKeys.includes(permKey)) {
ElMessage.warning(`暂无「${pageName}」的访问权限,请联系管理员开通`)
router.replace({ path: '/stockFund/workbench' })
}
})
}
为什么路由守卫已经拦截了,还要在页面里再校验一次? 因为路由守卫的拦截有"时间差"------用户直接通过 URL 访问页面时,路由守卫会拦截。但如果用户是通过侧边栏点击进入的,路由守卫的 permKey 检查可能不会触发(因为有些路由的 meta.permKey 是空的)。页面级校验是一个安全兜底,确保即使路由守卫漏掉了,页面自己也能拦截。
五、工作台:按权限过滤模块入口
工作台是用户登录后看到的第一个页面,展示了所有可用的模块卡片。这里的权限过滤逻辑很直观------用户没有权限的模块不显示:
vue
<!-- workbench/index.vue -->
<script setup>
const accessibleModules = computed(() => {
return MODULE_DEFS.filter(m => !m.permKey || userStore.menuPermKeys.includes(m.permKey))
})
</script>
点击模块卡片时,通过 useRoutePermission hook 做带权限校验的跳转:
typescript
const { navigateWithPermission, hasRoutePermission } = useRoutePermission()
function enterModule(mod) {
// 有 entryRoutes 时,找第一个用户有权限的路由跳转
if (mod.entryRoutes?.length) {
const target = mod.entryRoutes.find(name => hasRoutePermission(name))
if (target) {
navigateWithPermission(target)
return
}
router.push('/errorPage/403')
return
}
router.push({ path: mod.path })
}
模块入口路由的优先级 :entryRoutes 数组中的路由按优先级排列。比如 systemManagement 模块的 entryRoutes 是 ['Dashboard', 'baseInfo', 'dept', 'user', ...],用户点击"系统管理"卡片时,会依次检查这些路由的权限,跳转到第一个有权限的页面。
六、数据流全景图
把前后端串起来看,整个权限体系的数据流是这样的:
plain
┌────────────────────────────────────────────────────────────------─┐
│ 后端 (Spring Boot) │
│ │
│ sys_user ──▶ sys_user_role ──▶ sys_role │
│ │ │
│ sys_role_permission │
│ │ │
│ sys_permission │
│ (type=1/2/3) │
│ │ │
│ ┌───────────────────────┼───────────────────┐ │
│ ▼ ▼ ▼ │
│ /auth/user-info /auth/menus /auth/buttons│
│ (roles + perms) (菜单树) (按钮权限) │
└─────────────────────────────────────────────────────────────---------┘
│
│ HTTP (Bearer Token)
▼
┌─────────────────────────────────────────────────────────────┐
│ 前端 (Vue 3) │
│ │
│ fetchAuthPerms() ──▶ userStore │
│ ├── roles: ['admin', 'user'] │
│ ├── perms: ['systemManagement', 'user'] │
│ ├── buttonPerms: ['system:user:add', 'system:user:edit'] │
│ └── menuTree: [...] │
│ │ │
│ ├──▶ menuPermKeys (计算属性) │
│ │ │ │
│ │ ├──▶ permissionPlugin (路由守卫) │
│ │ │ 检查 meta.permKey 是否在 menuPermKeys 中│
│ │ │ │
│ │ └──▶ workbench (工作台) │
│ │ 过滤 accessibleModules │
│ │ │
│ ├──▶ buttonPerms │
│ │ │ │
│ │ ├──▶ v-permission 指令 (模板显隐) │
│ │ └──▶ useButtonPermission (操作列按钮) │
│ │ │
│ └──▶ roles + perms │
│ │ │
│ └──▶ usePermission (角色/权限逻辑判断) │
└─────────────────────────────────────────────────────────────┘
七、踩过的坑和设计取舍
7.1 为什么菜单权限和按钮权限分开获取?
最开始我把菜单权限和按钮权限放在一个接口里返回,后来发现有问题:
- 菜单权限用于路由守卫和侧边栏渲染,在页面加载时就需要
- 按钮权限用于页面内按钮显隐,在具体页面组件里才需要
如果混在一起,每次刷新页面都要加载全部权限数据(包括几百个按钮权限),浪费带宽。分开之后,按钮权限可以延迟加载(虽然目前是和菜单权限一起加载的,但架构上已经解耦了)。
7.2 为什么不用动态路由注册?
很多权限系统会根据后端返回的菜单树动态注册路由(router.addRoute()),只注册用户有权限的路由。但我选择了全量注册 + 守卫拦截的方案。
原因:
- 简单:全量注册不需要处理路由的动态添加/移除逻辑,减少 bug
- 侧边栏渲染 :全量注册后,侧边栏的数据来源是
permissionStore.dynamicRoutes,不需要额外处理 - 权限校验 :路由守卫通过
meta.permKey和menuPermKeys做权限判断,效果和动态路由一样------无权限的页面进不去 - 开发体验好 :在工作的开发中,公司使用的是动态注册路由,每次开发新功能界面,需要先注册路由然后才能显示导航栏菜单,本地开发调试时很不方便,所以我想让菜单路由由前后端一起控制,后端控制有权限的界面显示与隐藏,前端可以配置这个界面是否有权限。
当然这个方案也有缺点:用户在浏览器的地址栏手动输入无权限的 URL,能看到路由存在(虽然进不去)。对于个人项目来说,这个缺点可以接受。
7.3 sessionStorage vs localStorage
权限数据用 sessionStorage 而不是 localStorage,Token 用 localStorage。这个区别很重要:
- Token 用 localStorage :因为 Token 是长期有效的(24 小时),用户关闭浏览器再打开应该还是登录状态
- 权限数据用 sessionStorage :因为权限数据应该跟随标签页生命周期。如果用 localStorage,用户在 A 标签页登出后,B 标签页的权限数据还在,可能导致权限不一致
7.4 permKey 的命名规范
权限标识符的命名没有强制规范(比如必须用冒号分隔),但我在实践中形成了一套约定:
- 目录级:
systemManagement、stockFund、aiModule - 菜单级:
user、role、stockQuote、rag - 按钮级:后端自动拼接为
system:user:add、system:role:edit
前端路由定义中的 permKey 和后端数据库中的 permKey 必须一一对应 。这是最容易出错的地方------如果前端写了 permKey: 'user' 但数据库里存的是 sys_user,权限校验就会失败。
八、总结
这套权限体系的复杂度比我预期的要高。它涉及五张数据库表、三个后端接口、一个路由守卫插件、三种前端权限判断方式、三层缓存策略。但拆开来看,每一部分的逻辑都不复杂------复杂的是它们之间的协作关系。
| 维度 | 实现方式 | 说明 |
|---|---|---|
| 数据模型 | RBAC 五张表 | --- |
| 权限标识 | 短名存储 + 动态拼接 | 冗余低,维护方便 |
| 后端接口 | /user-info + /menus + /buttons | 三个接口各司其职,职责清晰 |
| 路由守卫 | permissionPlugin 插件 | 和进度条等守卫解耦,好维护 |
| 菜单权限 | meta.permKey + menuPermKeys | 数据源统一,不会出现"权限和菜单不一致" |
| 按钮权限 | v-permission + useButtonPermission | 声明式 + 脚本式,覆盖所有场景 |
| 缓存策略 | 内存 → sessionStorage → 后端 API | 刷新页面不卡顿,标签页隔离 |
| 兜底重定向 | 同模块 entryRoutes 优先 | 用户体验好,不会直接看到 403 |
权限控制的本质是"最小权限原则"------用户只能看到和操作他被授权的资源。实现这个原则不难,难的是在保证安全的同时不影响用户体验。路由守卫的兜底重定向、sessionStorage 的缓存恢复、通配符的支持,这些都是在"安全"和"体验"之间找平衡。