欢迎光临
我们一直在努力

【Java后端技术亮点】动态路由权限(按钮级权限),细粒度控制到按钮级别

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

在这里插入图片描述

文章目录

    • 一、为什么需要按钮级权限?
      • 1.1 业务场景
      • 1.2 只在前端控制的痛点
    • 二、动态路由权限的核心思路
      • 2.1 整体架构
      • 2.2 权限标识设计
    • 三、后端实现
      • 3.1 数据库设计
      • 3.2 登录时返回权限列表
      • 3.3 权限校验注解
      • 3.4 AOP切面实现权限校验
      • 3.5 接口使用示例
    • 四、前端实现
      • 4.1 Vue权限指令
      • 4.2 页面中使用
      • 4.3 权限函数(用于JS逻辑判断)
    • 五、预判问题与解答
      • Q1:前后端权限不一致怎么办?
      • Q2:权限变更后怎么实时生效?
      • Q3:超级管理员怎么设计?
      • Q4:接口权限和菜单权限怎么统一管理?
      • Q5:权限粒度多细合适?
    • 六、与Spring Security的对比
    • 七、面试高频考点
      • 面试官问:请介绍一下按钮级权限的实现方案
      • 面试官问:权限列表存在哪里?Redis还是Session?
      • 面试官问:权限注解放在Controller还是Service?
    • 八、总结
    • 参考资料

一、为什么需要按钮级权限?

1.1 业务场景

实际场景:你做了一个用户管理页面,有查看、编辑、删除、导出四个按钮。管理员四个按钮都能点,部门经理只能查看和编辑,普通员工只能查看。这种需求在企业管理系统里非常常见。

常见按钮权限需求:

页面按钮管理员经理员工
用户管理 新增
用户管理 编辑
用户管理 删除
用户管理 导出
订单管理 审核
订单管理 退款

1.2 只在前端控制的痛点

踩坑提醒:我见过太多项目只在前端用v-if控制按钮显示,后端接口不做校验。懂点技术的用户直接调接口,权限控制形同虚设!

<!– ❌ 只在前端控制,不安全 –>
<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 数据库设计

    — 菜单表(包含按钮)
    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 登录时返回权限列表

    @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 权限校验注解

    @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切面实现权限校验

    @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 接口使用示例

    @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权限指令

    // 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 页面中使用

    <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逻辑判断)

    // 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续期时刷新权限

    // Token刷新时重新查询权限
    @PostMapping("/refresh")
    public Result refresh() {
    // …
    // 重新查询权限列表
    Set<String> permissions = menuService.selectPermissionsByUserId(userId);
    redisTemplate.opsForSet().add("permissions:" + userId, permissions.toArray());
    // …
    }

    方案二:WebSocket推送

    // 管理员修改角色权限后,推送给在线用户
    @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+定时刷新

    // 前端定时刷新权限(5分钟一次)
    setInterval(() => {
    store.dispatch('refreshPermissions')
    }, 5 * 60 * 1000)

    Q3:超级管理员怎么设计?

    解答:

    方案一:特殊角色标识

    @Before("@annotation(requiresPermissions)")
    public void checkPermission(JoinPoint point, RequiresPermissions requiresPermissions) {
    User user = SecurityUtil.getCurrentUser();

    // 超级管理员跳过校验
    if ("admin".equals(user.getRoleKey())) {
    return;
    }

    // … 正常校验
    }

    方案二:拥有所有权限

    — 超级管理员角色关联所有菜单
    INSERT INTO sys_role_menu (role_id, menu_id)
    SELECT 1, menu_id FROM sys_menu;

    推荐方案一:代码层面特殊处理,数据库不需要存大量关联数据。

    Q4:接口权限和菜单权限怎么统一管理?

    解答:

    — 菜单表统一存储
    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提供了更完善的权限控制方案:

    // 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判断资源所属部门,再校验权限

    @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;
    }
    }


    八、总结

    动态路由权限(按钮级权限)核心价值:

  • 细粒度控制:精确到按钮级别
  • 前后端双重校验:前端提升体验,后端保证安全
  • 统一权限标识:前后端使用同一套权限体系
  • 易于维护:权限配置化,变更灵活
  • 一句话总结:通过统一的权限标识,前端控制按钮显示,后端控制接口访问,实现细粒度的按钮级权限控制。


    参考资料

  • RuoYi官方文档 – 权限控制
  • Spring Security官方文档 – Method Security

  • 互动话题:你在项目中是怎么实现按钮级权限的?是用Spring Security还是自定义注解?有没有遇到过前后端权限不一致的问题?欢迎在评论区分享你的实践经验!

    如果这篇文章对你有帮助,欢迎点赞、收藏!关注我,后续会继续分享更多Java后端技术亮点 👇


    本文为【Java后端技术亮点】系列第3篇,持续更新中…

    赞(0)
    未经允许不得转载:171主机测评 » 【Java后端技术亮点】动态路由权限(按钮级权限),细粒度控制到按钮级别
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址