Java案例怎么实现菜单权限?

wen java案例 17

Java案例:如何实现精细化的菜单权限控制?从零到实战全解析

📖 目录导读

  1. 为什么菜单权限是系统安全的第一道防线?
  2. 菜单权限的底层模型设计(RBAC核心)
  3. 数据库表结构设计与SQL案例
  4. 前端动态路由与菜单渲染实现
  5. 后端接口权限拦截实战(Spring Security + 自定义注解)
  6. 常见问题与面试问答精华(含代码示例)
  7. 总结与建议:从入门到生产级的最佳实践

为什么菜单权限是系统安全的第一道防线?

问答: 问:菜单权限和后端接口权限有什么区别?只控制菜单不控制接口会有什么风险?
答:菜单权限控制的是“前端可见性”,接口权限控制的是“后端可访问性”,如果只控制菜单而不校验接口,用户可以通过直接调用API绕过前端限制,导致数据泄露。菜单权限 + 接口权限是标配组合。

Java案例怎么实现菜单权限?

在企业级Java应用中,菜单权限决定了用户“能看到什么”,而接口权限决定了用户“能操作什么”,典型的案例场景包括:

  • 管理员拥有全部菜单和操作权限
  • 普通员工只能看到“我的工单”“个人中心”等少量菜单
  • 外部访客只能看到“公告”“帮助中心”

菜单权限的底层模型设计(RBAC核心)

1 核心概念

菜单权限本质上是 RBAC(基于角色的访问控制) 的延伸,我们通过以下三层关系实现:

用户(User)  →  角色(Role)  →  权限(Permission)
                                        ↓
                                  菜单资源(Menu)
  • 用户:系统的实际操作者
  • 角色:权限的集合,如“管理员”“运营人员”“审核员”
  • 权限:最小操作单元,通常对应一个菜单项或按钮(如“新增用户”“删除订单”)
  • 菜单资源:前端渲染的树形层级,拥有parentId和order字段

2 模型扩展:按钮级权限

除了菜单,实际项目中还需要控制“新增”“删除”“导出”等按钮,统一的做法是:

  • 将按钮视为 “叶子权限” ,绑定在父级菜单之下
  • 前端通过 v-if="hasPerm('sys:user:add')" 控制显示隐藏

数据库表结构设计与SQL案例

1 表结构设计

表名 字段 说明
sys_user id, username, role_id 用户表,与角色关联
sys_role id, role_name, role_key 角色表
sys_menu id, parent_id, name, path, perm, type, order_num 菜单及权限表
sys_role_menu role_id, menu_id 角色-菜单关联表

核心SQL示例(建表语句):

CREATE TABLE `sys_menu` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `parent_id` bigint(20) DEFAULT 0 COMMENT '父菜单ID',
  `name` varchar(50) NOT NULL COMMENT '菜单名称',
  `path` varchar(200) DEFAULT NULL COMMENT '路由路径',
  `perm` varchar(100) DEFAULT NULL COMMENT '权限标识,如 sys:user:list',
  `type` tinyint(4) DEFAULT NULL COMMENT '类型:0目录 1菜单 2按钮',
  `order_num` int(11) DEFAULT 0 COMMENT '排序',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

2 获取用户菜单的SQL

SELECT DISTINCT m.*
FROM sys_user u
JOIN sys_role r ON u.role_id = r.id
JOIN sys_role_menu rm ON r.id = rm.role_id
JOIN sys_menu m ON rm.menu_id = m.id
WHERE u.id = #{userId} AND m.type IN (0, 1)
ORDER BY m.parent_id, m.order_num;

注意:type=0(目录,不可点击)、type=1(菜单,可跳转)、type=2(按钮权限,仅用于后端校验)


前端动态路由与菜单渲染实现

1 动态路由构建

前端(Vue/Router)在用户登录后,根据后端返回的菜单列表动态生成路由,伪代码示例:

// 后端返回的菜单数组(已排序)
const menuList = [
  { id: 1, parentId: 0, name: '系统管理', path: '/system', component: 'Layout' },
  { id: 2, parentId: 1, name: '用户管理', path: '/system/user', component: 'user/index' },
  { id: 3, parentId: 1, name: '角色管理', path: '/system/role', component: 'role/index' }
];
// 递归构建成树
function buildTree(menuList, parentId = 0) {
  return menuList.filter(item => item.parentId === parentId)
    .map(item => ({
      ...item,
      children: buildTree(menuList, item.id)
    }));
}
// 动态添加到router.addRoute()
menuTree.forEach(route => {
  router.addRoute('layout', {
    path: route.path,
    name: route.name,
    component: () => import(`@/views/${route.component}`)
  });
});

2 按钮级权限指令

// 自定义指令 v-perm
Vue.directive('perm', {
  inserted(el, binding) {
    const perms = store.state.user.perms; // 当前用户所有perm列表
    if (!perms.includes(binding.value)) {
      el.parentNode && el.parentNode.removeChild(el);
    }
  }
});
// 使用方式:
<el-button v-perm="'sys:user:add'">新增用户</el-button>

后端接口权限拦截实战(Spring Security + 自定义注解)

1 自定义权限注解

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface PreAuthorize {
    String value(); // 如 "sys:user:list"
}

2 Spring Security 拦截器实现

@Component
public class PermissionInterceptor extends HandlerInterceptorAdapter {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        if (handler instanceof HandlerMethod) {
            HandlerMethod hm = (HandlerMethod) handler;
            PreAuthorize annotation = hm.getMethodAnnotation(PreAuthorize.class);
            if (annotation != null) {
                String needPerm = annotation.value();
                // 从当前登录用户缓存中获取权限列表
                List<String> userPerms = SecurityUtils.getCurrentUser().getPerms();
                if (!userPerms.contains(needPerm)) {
                    throw new ForbiddenException("无权限访问");
                }
            }
        }
        return true;
    }
}

3 与前端菜单协同

  • 前端渲染菜单时,后端同时返回 perm 列表存入Vuex
  • 按钮级别:后端校验 + 前端 v-perm 指令
  • 菜单级别:后端返回的菜单 perm 为null表示无需校验

常见问题与面试问答精华

问题 答案
Q1: 如何防止普通用户通过F12篡改JS直接调用菜单? 后端接口必须校验权限,前端控制仅作UI展示。
Q2: 菜单权限数据量大时,每次刷新都要查数据库吗? 建议将用户-菜单关系缓存到Redis,权限变更时清除缓存。
Q3: 子菜单包含父菜单的权限怎么办? 设计时保持父菜单权限为“纯目录权限”,叶子菜单或按钮才设置具体perm。
Q4: 如何实现“同一个用户在不同部门有不同菜单”? 扩展角色为“部门角色”,在 sys_role_menu 中增加 dept_id 字段。
Q5: 按钮权限和菜单权限冲突吗? 不冲突,按钮是菜单的下级资源,通过 type=2 区分并绑定父菜单id。

面试高频coding题:
请用Java写一个方法,将扁平的菜单列表(包含id, parentId)转换为树形结构。

public List<MenuVO> buildMenuTree(List<Menu> menList) {
    Map<Long, MenuVO> map = new HashMap<>();
    List<MenuVO> roots = new ArrayList<>();
    for (Menu m : menList) {
        MenuVO vo = new MenuVO(m);
        map.put(m.getId(), vo);
    }
    for (Menu m : menList) {
        if (m.getParentId() == 0) {
            roots.add(map.get(m.getId()));
        } else {
            MenuVO parent = map.get(m.getParentId());
            if (parent != null) {
                parent.getChildren().add(map.get(m.getId()));
            }
        }
    }
    // 排序
    roots.sort(Comparator.comparing(MenuVO::getOrderNum));
    return roots;
}

总结与建议:从入门到生产级的最佳实践

  1. 最小权限原则:只给用户需要的最小菜单集合,避免“全功能但看不到”的情况。
  2. 权限缓存:使用Redis缓存用户权限列表,设置TTL为30分钟或登录态失效时清除。
  3. 前后端双重校验:前端防误操作,后端防绕过。
  4. 日志审计:每次权限变更(新增/删除菜单)记录操作日志,便于追溯。
  5. 动态加载:不要在前端写死路由表,通过后端接口生成动态路由。

真实案例:某电商后台系统通过上述方案,将权限管理从单表硬编码升级为RBAC+菜单树,权限配置效率提升80%,线上权限漏洞降为0,欢迎你在自己的项目中尝试以上实现,结合Spring Boot 3.x + Vue 3 + MyBatis-Plus,优雅地解决菜单权限问题。

抱歉,评论功能暂时关闭!