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

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

文章目录


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

1.1 业务场景

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

常见按钮权限需求:

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

1.2 只在前端控制的痛点

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

vue 复制代码
<!-- ❌ 只在前端控制,不安全 -->
<template>
  <div>
    <button v-if="isAdmin">删除</button>
    <button v-if="isAdmin || isManager">编辑</button>
  </div>
</template>

安全问题

  1. 前端代码可被篡改(浏览器F12直接改)
  2. 用户直接调API绕过前端
  3. 权限逻辑分散,难以维护

正确做法:前后端双重校验

  • 前端:控制按钮显示(用户体验好)
  • 后端:控制接口访问(安全兜底)

二、动态路由权限的核心思路

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:前后端权限不一致怎么办?

问题场景:前端显示按钮,但后端报无权限;或前端不显示,后端有权限。

解答

  1. 以前端为准:前端显示但后端无权限 → 后端抛异常,前端提示
  2. 以后端为准:前端不显示但后端有权限 → 用户无法触发(看不到按钮)

推荐做法

  • 前端权限列表从后端获取,保证一致性
  • 后端作为安全兜底,必须校验
  • 定期同步前后端权限配置

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

七、面试高频考点

面试官问:请介绍一下按钮级权限的实现方案

参考答案

按钮级权限需要前后端双重校验:

后端

  1. 定义权限标识(如user:adduser:delete
  2. 使用@RequiresPermissions注解标记接口
  3. AOP切面拦截,校验用户是否拥有该权限
  4. 权限列表存入Redis,减少数据库查询

前端

  1. 登录时获取用户权限列表
  2. 自定义v-permission指令,根据权限控制按钮显示
  3. 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;
    }
}

八、总结

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

  1. 细粒度控制:精确到按钮级别
  2. 前后端双重校验:前端提升体验,后端保证安全
  3. 统一权限标识:前后端使用同一套权限体系
  4. 易于维护:权限配置化,变更灵活

一句话总结:通过统一的权限标识,前端控制按钮显示,后端控制接口访问,实现细粒度的按钮级权限控制。


参考资料

  1. RuoYi官方文档 - 权限控制
  2. Spring Security官方文档 - Method Security

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

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


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

相关推荐
Fanfanaas1 小时前
C++ 继承
java·开发语言·jvm·c++·学习·算法
蚰蜒螟1 小时前
走进 Linux 内核:从 touch 命令到磁盘 inode 的完整旅程
java·linux·前端
zzqssliu1 小时前
taocarts 跨境独立站 SEO 优化实践(多语言 + 反向海淘场景)
java·javascript·php
前端Hardy1 小时前
CSS 动画真的比 JS 快?Josh Comeau 做了组实验,结果跟直觉不一样
前端·javascript·后端
Front思1 小时前
调取支付宝支付正式环境不可以唤起来,但是沙箱可以
后端
foggyprojects1 小时前
AI 生成 SQL 模板以后,为什么还需要固定 helper 规则
后端
在繁华处1 小时前
Java从零到熟练(十一):Spring框架入门
java·开发语言·spring
明天一点1 小时前
Cloudflare 通知转发钉钉机器人
前端·后端
小锋java12341 小时前
【技术专题】LangChain4j 开发Java Agent智能体 - 整合SpringBoot4
java·人工智能