写在前面:做过管理后台的同学都知道,菜单权限好控制,但按钮权限难搞。比如同一个页面,管理员能看到"删除"按钮,普通员工看不到。很多人的做法是在前端写v-if判断,但这样不安全,后端也得校验。今天聊聊EL-ADMIN、RuoYi都在用的方案:动态路由权限,前后端双重校验,细粒度控制到按钮级别。

文章目录
-
- 一、为什么需要按钮级权限?
-
- [1.1 业务场景](#1.1 业务场景)
- [1.2 只在前端控制的痛点](#1.2 只在前端控制的痛点)
- 二、动态路由权限的核心思路
-
- [2.1 整体架构](#2.1 整体架构)
- [2.2 权限标识设计](#2.2 权限标识设计)
- 三、后端实现
-
- [3.1 数据库设计](#3.1 数据库设计)
- [3.2 登录时返回权限列表](#3.2 登录时返回权限列表)
- [3.3 权限校验注解](#3.3 权限校验注解)
- [3.4 AOP切面实现权限校验](#3.4 AOP切面实现权限校验)
- [3.5 接口使用示例](#3.5 接口使用示例)
- 四、前端实现
-
- [4.1 Vue权限指令](#4.1 Vue权限指令)
- [4.2 页面中使用](#4.2 页面中使用)
- [4.3 权限函数(用于JS逻辑判断)](#4.3 权限函数(用于JS逻辑判断))
- 五、预判问题与解答
- [六、与Spring Security的对比](#六、与Spring Security的对比)
- 七、面试高频考点
- 八、总结
- 参考资料
一、为什么需要按钮级权限?
1.1 业务场景
实际场景:你做了一个用户管理页面,有查看、编辑、删除、导出四个按钮。管理员四个按钮都能点,部门经理只能查看和编辑,普通员工只能查看。这种需求在企业管理系统里非常常见。
常见按钮权限需求:
| 页面 | 按钮 | 管理员 | 经理 | 员工 |
|---|---|---|---|---|
| 用户管理 | 新增 | ✅ | ❌ | ❌ |
| 用户管理 | 编辑 | ✅ | ✅ | ❌ |
| 用户管理 | 删除 | ✅ | ❌ | ❌ |
| 用户管理 | 导出 | ✅ | ✅ | ❌ |
| 订单管理 | 审核 | ✅ | ✅ | ❌ |
| 订单管理 | 退款 | ✅ | ❌ | ❌ |
1.2 只在前端控制的痛点
踩坑提醒:我见过太多项目只在前端用v-if控制按钮显示,后端接口不做校验。懂点技术的用户直接调接口,权限控制形同虚设!
vue
<!-- ❌ 只在前端控制,不安全 -->
<template>
<div>
<button v-if="isAdmin">删除</button>
<button v-if="isAdmin || isManager">编辑</button>
</div>
</template>
安全问题:
- 前端代码可被篡改(浏览器F12直接改)
- 用户直接调API绕过前端
- 权限逻辑分散,难以维护
正确做法:前后端双重校验
- 前端:控制按钮显示(用户体验好)
- 后端:控制接口访问(安全兜底)
二、动态路由权限的核心思路
2.1 整体架构
用户登录
↓
后端根据用户角色查询权限列表
↓
返回权限标识集合(如['user:add','user:edit','user:delete'])
↓
前端根据权限标识渲染按钮
↓
用户点击按钮
↓
后端接口再次校验权限(AOP拦截)
核心组件:
| 组件 | 作用 | 位置 |
|---|---|---|
| 权限标识 | 唯一标识一个操作,如user:add |
数据库 |
| 前端权限指令 | v-permission="['user:add']" |
前端 |
| 后端权限注解 | @PreAuthorize("hasAuthority('user:add')") |
后端 |
| 权限校验AOP | 拦截请求,校验权限 | 后端 |
2.2 权限标识设计
格式:模块:操作
user:list 用户列表查询
user:add 用户新增
user:edit 用户编辑
user:delete 用户删除
user:export 用户导出
order:list 订单列表
order:audit 订单审核
order:refund 订单退款
经验之谈 :权限标识要统一规范,建议用
模块:操作的格式。模块名和前端路由保持一致,方便对应。
三、后端实现
3.1 数据库设计
sql
-- 菜单表(包含按钮)
CREATE TABLE sys_menu (
menu_id BIGINT PRIMARY KEY,
menu_name VARCHAR(50), -- 菜单名称
parent_id BIGINT, -- 父菜单ID
menu_type CHAR(1), -- 类型:M目录 C菜单 F按钮
permission VARCHAR(50), -- 权限标识
path VARCHAR(200), -- 路由地址
component VARCHAR(255), -- 组件路径
icon VARCHAR(100), -- 图标
sort_order INT, -- 排序
status CHAR(1) -- 状态
);
-- 角色表
CREATE TABLE sys_role (
role_id BIGINT PRIMARY KEY,
role_name VARCHAR(50),
role_key VARCHAR(50), -- 角色标识
status CHAR(1)
);
-- 角色菜单关联表
CREATE TABLE sys_role_menu (
role_id BIGINT,
menu_id BIGINT,
PRIMARY KEY (role_id, menu_id)
);
-- 插入按钮数据
INSERT INTO sys_menu VALUES
(100, '用户查询', 1, 'F', 'user:list', NULL, NULL, NULL, 1, '0'),
(101, '用户新增', 1, 'F', 'user:add', NULL, NULL, NULL, 2, '0'),
(102, '用户编辑', 1, 'F', 'user:edit', NULL, NULL, NULL, 3, '0'),
(103, '用户删除', 1, 'F', 'user:delete', NULL, NULL, NULL, 4, '0');
3.2 登录时返回权限列表
java
@PostMapping("/login")
public Result login(@RequestBody LoginDTO dto) {
// 1. 校验用户名密码
User user = userService.checkLogin(dto);
// 2. 查询用户权限列表
Set<String> permissions = menuService.selectPermissionsByUserId(user.getId());
// 结果:["user:list", "user:add", "user:edit"]
// 3. 生成Token
String token = JwtUtil.createToken(user.getId(), user.getUsername());
// 4. 权限列表存入Redis(方便后续校验)
redisTemplate.opsForSet().add("permissions:" + user.getId(), permissions.toArray());
redisTemplate.expire("permissions:" + user.getId(), 30, TimeUnit.MINUTES);
return Result.success(Map.of(
"token", token,
"permissions", permissions
));
}
3.3 权限校验注解
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresPermissions {
String[] value(); // 权限标识数组
Logical logical() default Logical.AND; // AND:全部满足 OR:满足其一
}
public enum Logical {
AND, OR
}
3.4 AOP切面实现权限校验
java
@Aspect
@Component
public class PermissionAspect {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Before("@annotation(requiresPermissions)")
public void checkPermission(JoinPoint point, RequiresPermissions requiresPermissions) {
// 1. 获取当前用户
Long userId = SecurityUtil.getUserId();
// 2. 从Redis获取用户权限列表
Set<String> userPermissions = redisTemplate.opsForSet()
.members("permissions:" + userId);
// 3. 获取方法要求的权限
String[] requiredPermissions = requiresPermissions.value();
Logical logical = requiresPermissions.logical();
// 4. 校验权限
boolean hasPermission;
if (logical == Logical.AND) {
// 必须全部满足
hasPermission = Arrays.stream(requiredPermissions)
.allMatch(p -> userPermissions.contains(p));
} else {
// 满足其一即可
hasPermission = Arrays.stream(requiredPermissions)
.anyMatch(p -> userPermissions.contains(p));
}
// 5. 无权限抛出异常
if (!hasPermission) {
throw new NoPermissionException("无权限操作");
}
}
}
3.5 接口使用示例
java
@RestController
@RequestMapping("/user")
public class UserController {
@GetMapping("/list")
@RequiresPermissions("user:list")
public Result list() {
return Result.success(userService.list());
}
@PostMapping("/add")
@RequiresPermissions("user:add")
public Result add(@RequestBody User user) {
userService.add(user);
return Result.success("新增成功");
}
@PutMapping("/edit")
@RequiresPermissions("user:edit")
public Result edit(@RequestBody User user) {
userService.update(user);
return Result.success("编辑成功");
}
@DeleteMapping("/delete/{id}")
@RequiresPermissions("user:delete")
public Result delete(@PathVariable Long id) {
userService.delete(id);
return Result.success("删除成功");
}
// 需要多个权限(AND)
@PostMapping("/audit")
@RequiresPermissions(value = {"user:audit", "user:edit"}, logical = Logical.AND)
public Result audit(@RequestBody AuditDTO dto) {
// 需要同时拥有audit和edit权限
return Result.success();
}
// 满足其一即可(OR)
@PostMapping("/export")
@RequiresPermissions(value = {"user:export", "user:admin"}, logical = Logical.OR)
public Result export() {
// 有export或admin权限即可
return Result.success();
}
}
踩坑提醒:注解一定要放在Controller方法上,不要放在Service上。因为AOP默认拦截Spring管理的Bean,Controller是最外层入口。
四、前端实现
4.1 Vue权限指令
javascript
// permission.js
import Vue from 'vue'
// 注册v-permission指令
Vue.directive('permission', {
inserted(el, binding, vnode) {
const { value } = binding
const permissions = store.getters.permissions // 从Vuex获取权限列表
if (value && value instanceof Array && value.length > 0) {
const hasPermission = permissions.some(permission => {
return value.includes(permission)
})
if (!hasPermission) {
el.parentNode && el.parentNode.removeChild(el) // 无权限,移除按钮
}
} else {
throw new Error(`权限标识格式错误,如:v-permission="['user:add']"`)
}
}
})
4.2 页面中使用
vue
<template>
<div class="user-management">
<!-- 新增按钮 -->
<el-button
v-permission="['user:add']"
type="primary"
@click="handleAdd"
>
新增用户
</el-button>
<!-- 导出按钮 -->
<el-button
v-permission="['user:export']"
@click="handleExport"
>
导出
</el-button>
<!-- 用户列表 -->
<el-table :data="userList">
<el-table-column prop="username" label="用户名" />
<el-table-column prop="email" label="邮箱" />
<el-table-column label="操作">
<template slot-scope="scope">
<!-- 编辑按钮 -->
<el-button
v-permission="['user:edit']"
size="mini"
@click="handleEdit(scope.row)"
>
编辑
</el-button>
<!-- 删除按钮 -->
<el-button
v-permission="['user:delete']"
size="mini"
type="danger"
@click="handleDelete(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
4.3 权限函数(用于JS逻辑判断)
javascript
// utils/permission.js
/**
* 判断是否拥有权限
* @param {string|array} value 权限标识
* @param {string} logical AND/OR
*/
export function hasPermission(value, logical = 'AND') {
const permissions = store.getters.permissions
if (typeof value === 'string') {
return permissions.includes(value)
}
if (value instanceof Array) {
if (logical === 'AND') {
return value.every(item => permissions.includes(item))
} else {
return value.some(item => permissions.includes(item))
}
}
return false
}
// 使用示例
import { hasPermission } from '@/utils/permission'
export default {
methods: {
handleClick() {
// JS逻辑中判断权限
if (hasPermission('user:edit')) {
this.doSomething()
} else {
this.$message.error('无权限')
}
},
handleComplex() {
// 需要多个权限
if (hasPermission(['user:audit', 'user:edit'], 'AND')) {
this.doAudit()
}
}
}
}
五、预判问题与解答
Q1:前后端权限不一致怎么办?
问题场景:前端显示按钮,但后端报无权限;或前端不显示,后端有权限。
解答:
- 以前端为准:前端显示但后端无权限 → 后端抛异常,前端提示
- 以后端为准:前端不显示但后端有权限 → 用户无法触发(看不到按钮)
推荐做法:
- 前端权限列表从后端获取,保证一致性
- 后端作为安全兜底,必须校验
- 定期同步前后端权限配置
Q2:权限变更后怎么实时生效?
解答:
方案一:Token续期时刷新权限
java
// Token刷新时重新查询权限
@PostMapping("/refresh")
public Result refresh() {
// ...
// 重新查询权限列表
Set<String> permissions = menuService.selectPermissionsByUserId(userId);
redisTemplate.opsForSet().add("permissions:" + userId, permissions.toArray());
// ...
}
方案二:WebSocket推送
java
// 管理员修改角色权限后,推送给在线用户
@PostMapping("/role/update")
public Result updateRole(@RequestBody Role role) {
roleService.update(role);
// 推送权限变更消息
List<Long> userIds = userService.selectUserIdsByRoleId(role.getId());
for (Long userId : userIds) {
websocketService.sendMessage(userId, "PERMISSION_CHANGED");
}
return Result.success();
}
方案三:短TTL+定时刷新
javascript
// 前端定时刷新权限(5分钟一次)
setInterval(() => {
store.dispatch('refreshPermissions')
}, 5 * 60 * 1000)
Q3:超级管理员怎么设计?
解答:
方案一:特殊角色标识
java
@Before("@annotation(requiresPermissions)")
public void checkPermission(JoinPoint point, RequiresPermissions requiresPermissions) {
User user = SecurityUtil.getCurrentUser();
// 超级管理员跳过校验
if ("admin".equals(user.getRoleKey())) {
return;
}
// ... 正常校验
}
方案二:拥有所有权限
sql
-- 超级管理员角色关联所有菜单
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT 1, menu_id FROM sys_menu;
推荐方案一:代码层面特殊处理,数据库不需要存大量关联数据。
Q4:接口权限和菜单权限怎么统一管理?
解答:
sql
-- 菜单表统一存储
CREATE TABLE sys_menu (
menu_id BIGINT PRIMARY KEY,
menu_name VARCHAR(50),
menu_type CHAR(1), -- M目录 C菜单 F按钮 A接口
permission VARCHAR(50), -- 权限标识
path VARCHAR(200), -- 路由地址(菜单)或接口URL(接口)
method VARCHAR(10) -- GET/POST/PUT/DELETE(接口类型)
);
-- 插入菜单
INSERT INTO sys_menu VALUES (1, '用户管理', 'C', 'user:view', '/user', NULL);
-- 插入按钮
INSERT INTO sys_menu VALUES (2, '新增用户', 'F', 'user:add', NULL, NULL);
-- 插入接口
INSERT INTO sys_menu VALUES (3, '新增用户接口', 'A', 'user:add', '/api/user', 'POST');
前端 :只显示menu_type为C和F的数据
后端:只校验menu_type为F和A的数据
Q5:权限粒度多细合适?
解答:
| 粒度 | 示例 | 适用场景 |
|---|---|---|
| 粗粒度 | user:manage |
简单系统,只有管理员和普通用户 |
| 中粒度 | user:add, user:edit |
大多数企业系统 |
| 细粒度 | user:add:own, user:add:all |
复杂业务,需要区分操作范围 |
推荐中粒度:
- 按钮级别控制(add/edit/delete)
- 不过度设计(避免user:add:own:department这种多级权限)
- 数据权限用另一套机制(见上一篇文章)
六、与Spring Security的对比
Spring Security提供了更完善的权限控制方案:
java
// Spring Security 方式
@RestController
@RequestMapping("/user")
public class UserController {
@GetMapping("/list")
@PreAuthorize("hasAuthority('user:list')")
public Result list() {
return Result.success(userService.list());
}
@PostMapping("/add")
@PreAuthorize("hasAuthority('user:add')")
public Result add(@RequestBody User user) {
return Result.success();
}
// 表达式方式
@DeleteMapping("/delete/{id}")
@PreAuthorize("hasAuthority('user:delete') or hasRole('admin')")
public Result delete(@PathVariable Long id) {
return Result.success();
}
// 方法参数校验
@PutMapping("/edit")
@PreAuthorize("hasAuthority('user:edit') and #user.id == authentication.principal.id")
public Result edit(@RequestBody User user) {
// 只能编辑自己的数据
return Result.success();
}
}
对比:
| 特性 | 自定义注解 | Spring Security |
|---|---|---|
| 学习成本 | 低 | 高 |
| 功能丰富度 | 基本够用 | 非常丰富(表达式、方法参数等) |
| 灵活性 | 高(可完全自定义) | 中(受框架约束) |
| 生态集成 | 需自己实现 | 与Spring生态完美集成 |
建议:
- 简单项目:自定义注解足够
- 复杂项目:用Spring Security
七、面试高频考点
面试官问:请介绍一下按钮级权限的实现方案
参考答案:
按钮级权限需要前后端双重校验:
后端:
- 定义权限标识(如
user:add、user:delete) - 使用
@RequiresPermissions注解标记接口 - AOP切面拦截,校验用户是否拥有该权限
- 权限列表存入Redis,减少数据库查询
前端:
- 登录时获取用户权限列表
- 自定义
v-permission指令,根据权限控制按钮显示 - JS逻辑中使用
hasPermission函数判断
关键点:
- 后端必须校验(安全兜底)
- 前端辅助展示(用户体验)
- 权限变更后需要刷新或推送通知
面试官问:权限列表存在哪里?Redis还是Session?
参考答案:
推荐Redis:
- 分布式环境下Session不共享
- Redis支持设置过期时间,自动清理
- 权限变更时只需删Redis,用户重新登录获取新权限
存储结构:
Key: permissions:userId
Value: Set<String> ["user:list", "user:add", ...]
TTL: 30分钟(和Token一致)
为什么不存JWT:
- JWT签发后内容固定,权限变更无法实时生效
- JWT payload大小有限制
面试官问:权限注解放在Controller还是Service?
参考答案:
放在Controller:
- 权限校验是最外层入口控制
- Service可能被多个Controller调用,权限要求可能不同
- 符合"尽早失败"原则
特殊情况放在Service:
- 同一个接口,根据参数不同需要不同权限
- 如:
/api/resource/{id},根据id判断资源所属部门,再校验权限
java
@Service
public class ResourceService {
@Autowired
private PermissionChecker permissionChecker;
public Resource getById(Long id) {
Resource resource = resourceMapper.selectById(id);
// 动态权限校验
if (!permissionChecker.hasDataPermission(resource.getDeptId())) {
throw new NoPermissionException();
}
return resource;
}
}
八、总结
动态路由权限(按钮级权限)核心价值:
- 细粒度控制:精确到按钮级别
- 前后端双重校验:前端提升体验,后端保证安全
- 统一权限标识:前后端使用同一套权限体系
- 易于维护:权限配置化,变更灵活
一句话总结:通过统一的权限标识,前端控制按钮显示,后端控制接口访问,实现细粒度的按钮级权限控制。
参考资料
互动话题:你在项目中是怎么实现按钮级权限的?是用Spring Security还是自定义注解?有没有遇到过前后端权限不一致的问题?欢迎在评论区分享你的实践经验!
如果这篇文章对你有帮助,欢迎点赞、收藏!关注我,后续会继续分享更多Java后端技术亮点 👇
本文为【Java后端技术亮点】系列第3篇,持续更新中...