Java案例:如何实现精细化的菜单权限控制?从零到实战全解析
📖 目录导读
- 为什么菜单权限是系统安全的第一道防线?
- 菜单权限的底层模型设计(RBAC核心)
- 数据库表结构设计与SQL案例
- 前端动态路由与菜单渲染实现
- 后端接口权限拦截实战(Spring Security + 自定义注解)
- 常见问题与面试问答精华(含代码示例)
- 总结与建议:从入门到生产级的最佳实践
为什么菜单权限是系统安全的第一道防线?
问答: 问:菜单权限和后端接口权限有什么区别?只控制菜单不控制接口会有什么风险?
答:菜单权限控制的是“前端可见性”,接口权限控制的是“后端可访问性”,如果只控制菜单而不校验接口,用户可以通过直接调用API绕过前端限制,导致数据泄露。菜单权限 + 接口权限是标配组合。
在企业级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;
}
总结与建议:从入门到生产级的最佳实践
- 最小权限原则:只给用户需要的最小菜单集合,避免“全功能但看不到”的情况。
- 权限缓存:使用Redis缓存用户权限列表,设置TTL为30分钟或登录态失效时清除。
- 前后端双重校验:前端防误操作,后端防绕过。
- 日志审计:每次权限变更(新增/删除菜单)记录操作日志,便于追溯。
- 动态加载:不要在前端写死路由表,通过后端接口生成动态路由。
真实案例:某电商后台系统通过上述方案,将权限管理从单表硬编码升级为RBAC+菜单树,权限配置效率提升80%,线上权限漏洞降为0,欢迎你在自己的项目中尝试以上实现,结合Spring Boot 3.x + Vue 3 + MyBatis-Plus,优雅地解决菜单权限问题。
