
我住过一家民宿老板把每间房门都配了不同的房卡。客人手里那张卡能开自己房间、餐厅和公共区域的门保洁大姐那张能开所有客房但不能进财务室而老板自己那张万能卡哪儿都能去。你看这就是一个活生生的权限管理系统——你手里有几把房卡决定了你在这个楼里能走到哪一步。把这个场景搬到系统里就是权限管理业界俗称RBACRole-Based Access Control。不管你是写后台管理系统、SaaS平台还是给公司做内部OA权限这块躲不掉而且做不好是真会出事的——用户点进了不该点的页面、看到了不该看的数据、甚至把不该删的记录删了每一件都是「丢钱」级别的灾难。这篇文章我就从房卡这个比喻切入把权限管理的设计思路、数据库表结构、Java端的核心实现再到实际项目里最容易踩的坑一条龙讲清楚。适合刚接触后台开发的初级工程师也适合那些权限模块写着写着把自己绕晕的同学。1. 权限管理的核心思路与设计拆解1.1 从生活场景理解权限的本质先把这个比喻拉远了看。房卡解决的本质问题是谁、能进哪扇门、进去之后能干什么。拆一下就是三个关键词主体谁、资源门、操作能干什么。系统里也一样主体是用户资源是菜单、页面、按钮、接口、数据操作是增删改查。权限管理做的就是把这三种东西之间的关系定义清楚、控制起来。很多人一上来就画表、写代码其实不对。权限设计的成败往往在动手写SQL之前就已经决定了。你定义清楚「用户-角色-权限」这三层关系后面所有代码都是在为这个模型做落地。模型设计得烂后面写再多代码都是补窟窿。1.2 为什么一定要引入「角色」这层中间层最朴素的权限方案是给每个用户直接绑权限。这个方案在小玩具项目里没问题但稍微上点规模就崩了。假设系统有500个用户、50个权限点。如果你直接给用户绑权限那数据量就是用户数乘以权限数可能要上万条关系记录。更难受的是如果公司调整了业务需要给所有销售岗员工新增一个「导出客户列表」的权限你要挨个给每个销售改配置改到怀疑人生。引入角色这层中间层之后情况完全变了。你只需要创建一个「销售」角色把「导出客户列表」这个权限挂到角色上然后把用户都归到这个角色下。以后调整权限只动角色一个角色的变动自动同步给所有关联用户。这就是RBAC模型的精髓用户不再直接持有权限而是通过角色间接获得权限。1.3 权限模型的核心要素拆解一个标准的RBAC模型核心就四张实体表加两张关联表。我用房卡的例子帮你记用户表住店的人角色表房卡的等级类型房客卡、保洁卡、万能卡菜单/权限表每扇门以及门上贴的标签用户角色关联表谁领了哪种卡角色菜单关联表每种卡能开哪些门这里很多人容易犯一个错误觉得菜单表就是权限表。其实严格来说菜单是权限的载体权限点远不止菜单。一个页面上可能有「新增按钮」「删除按钮」「导出按钮」这些按钮权限比菜单权限更细属于按钮级权限。再加上有些页面里还要控制数据范围——比如销售只能看自己名下的客户经理能看整个团队的老板能看全公司的——这就引出了数据权限的概念。所以完整看下来权限管理其实是三层菜单权限能不能看到入口、操作权限能不能按这个按钮、数据权限能看到多少数据。这三层各有各的实现手段接下来我挨个拆。1.4 权限管理解决的三大问题把需求归类之后你会发现权限系统其实就在解决三个问题身份认证你是谁常见手段是登录、Token、Session对应到房卡就是「刷卡验卡」这一步。访问控制你能进哪些门这是权限管理的核心对应到系统里就是菜单显示、路由拦截、接口鉴权。操作审计你进去之后干了什么对应到系统里就是操作日志、行为追踪。这一层很多小团队会忽略但对涉及资金、核心数据的系统来说审计比权限本身还重要——因为权限做不到100%完善审计能帮你事后发现问题、追责定位。我在项目里做过一次统计线上权限相关的故障超过一半都不是「权限没配」而是「权限配错了没人发现」。所以一个合格的权限系统必须把审计日志这块一起做进去。2. 权限表设计一把房卡背后的数据结构2.1 五张基础表的字段设计要点先说用户表、角色表、用户角色表、角色菜单表这四张字段设计其实比较固定我这里直接给出一份可以落地用的表结构。用户表sys_userid主键username登录名password加密后的密码绝对不允许存明文status账号状态正常/禁用dept_id所属部门预留给数据权限用角色表sys_roleid主键role_name角色名称role_code角色编码实际判断权限时用编码不要用名称status角色状态用户角色表sys_user_roleuser_idrole_id角色菜单表sys_role_menurole_idmenu_id这四张表是铁打的底盘所有RBAC项目基本都是这个骨架。真正容易出问题的是第五张表——菜单/权限表sys_menu它的设计直接决定了权限树好不好维护、权限标识够不够清晰、以及后面做n叉树遍历时顺不顺手。2.2 权限表设计n叉树结构在这里扮演什么角色菜单/权限表之所以特殊是因为菜单天然是树形结构一级菜单下面挂二级菜单二级菜单下面再挂按钮。这个树不是二叉树每个节点可以有任意多个子节点所以是一棵标准的n叉树。这张表的字段设计我建议这样id主键parent_id父节点ID顶级菜单就是0menu_name菜单名称menu_type节点类型目录/菜单/按钮perms权限标识比如 system:user:addpath前端路由路径component前端组件地址icon图标sort排序号visible是否显示status状态这里有个设计的关键点parent_id就是n叉树的指针。整个菜单树靠这个字段把零散的节点串成树而前端加载菜单、后端做权限校验、管理端做菜单配置本质都是对这棵n叉树的遍历和筛选。我之前见过一个项目把菜单和按钮分开建了两张表理由是「菜单是菜单按钮是按钮混在一起不好维护」。结果前端要同时拉两张表的数据再自己组装成树后端判断权限也要查两次维护成本翻倍。相信我用type字段区分类型、放在同一张表里用parent_id串成树是经过大量项目验证的最省心方案。2.3 权限标识perms是整棵树的「门锁编号」光有树形结构还不够每个节点还得有一个全局唯一的「门锁编号」这就是perms字段。比如一个用户管理页面通常会有四个按钮权限点system:user:list查询用户列表system:user:add新增用户system:user:edit编辑用户system:user:delete删除用户这个命名规则看起来简单但非常重要。我见过有人随便起名比如「新增」「delUser」「adduser」大小写混乱、没有模块前缀结果权限标识撞车、判断逻辑写得像坨浆糊。社区里比较通用的规范是「模块:子模块:操作」三段式全程小写冒号分隔。这样看代码的时候一眼就知道这个权限点是干什么的。2.4 数据权限设计从「能进房间」到「能看多少东西」菜单权限和按钮权限控制的是「能不能」数据权限控制的是「能看多少」。同一个「订单列表」页面普通销售只能看自己的订单销售经理能看整个团队的财务总监能看全公司的。这就是数据权限的经典场景。数据权限的实现方式五花八门常见的有根据用户角色写死SQL条件通过数据权限规则表动态拼接SQL通过部门层级dept_id来实现我在项目里用的比较多的是基于部门的方案用户表存dept_id部门表用parent_id构成一棵部门树又是n叉树数据权限规则定义成「本人」「本部门」「本部门及以下」「全部」四种级别。查询数据时根据规则自动拼接过滤条件比如「本部门及以下」就需要递归查部门树取出所有下级部门ID。这块是权限管理里最灵活、也最容易出bug的地方。我的建议是第一版先做「本人」和「全部」两种把框架搭起来后面再按需扩展「部门及以下」。一上来就做全往往会把自己绕晕。3. Java端权限管理的核心实现从登录到方法级拦截3.1 登录认证给用户发一张「动态房卡」权限管理的第一步是认证。用户输入用户名密码登录成功之后后端要生成一个凭证——这就是那张「房卡」。常见方案有两种Session和TokenJWT。Session方案是传统的登录成功后把用户信息存到服务端Session给浏览器发一个Session ID。Token方案则是无状态的登录成功后把用户ID、角色、过期时间等信息签名进一个Token字符串里发给前端前端每次请求都带上它后端验签即可。从权限管理的角度来看我更推荐JWT方案。原因有三点第一无状态意味着不需要Session复制分布式部署更轻松第二JWT里可以携带用户的角色和权限信息减少查库次数第三前后端分离的项目里Token天然比Session好处理跨域问题。不过要注意JWT有个经典的坑它一旦签发在过期之前是没法主动作废的。用户改了密码、被管理员禁用已签发的Token可能仍然有效。解决办法通常是自己维护一个Token黑名单或者缩短Token有效期配合刷新Token机制使用。这块细节多后文问题排查部分我会单独说。3.2 登录后构建权限集合一张房卡上印了哪些门号用户登录成功之后不能只验证一下身份就完事要把这个用户所有能访问的权限点收集起来存到一个权限集合里。这个集合就是用户那张房卡上印的「允许进入的门号列表」。在Java里常见的做法是这样// 假设user是当前登录用户 ListString permissionList new ArrayList(); // 1. 先查用户拥有的所有角色 ListRole roleList roleMapper.selectRolesByUserId(user.getId()); // 2. 再通过角色查所有菜单权限标识 for (Role role : roleList) { ListMenu menuList menuMapper.selectMenusByRoleId(role.getId()); for (Menu menu : menuList) { if (StringUtils.hasText(menu.getPerms())) { permissionList.add(menu.getPerms()); } } }这里有个性能优化点不要用循环去查数据库一次把SQL写好。用MySQL的JOIN或者嵌套子查询一条SQL就把用户的所有perms查出来SELECT DISTINCT m.perms FROM sys_user_role ur JOIN sys_role_menu rm ON ur.role_id rm.role_id JOIN sys_menu m ON rm.menu_id m.id WHERE ur.user_id #{userId} AND m.perms IS NOT NULL AND m.perms ! 实测下来这个SQL在数据量大一点的时候性能也扛得住。查出来的这个集合在后面做菜单树裁剪和方法级拦截时都会用到。3.3 动态菜单树构建n叉树的两次遍历法登录之后前端需要根据用户权限渲染菜单。这里的核心问题就是你手里有一棵完整的菜单树所有菜单但用户只买了其中一部分门有权限的节点你要把这棵n叉树裁剪成「用户能看到的子树」。最经典的方案是两次遍历法我先给代码再讲原理public ListMenuVO buildMenuTree(ListMenu allMenus, SetString userPerms) { // 第一步过滤出用户有权限的菜单 ListMenu visibleMenus allMenus.stream() .filter(menu - userPerms.contains(menu.getPerms()) || menu.getMenuType().equals(目录)) .collect(Collectors.toList()); // 第二步建立 id - 节点映射 MapLong, MenuVO map new HashMap(); for (Menu menu : visibleMenus) { MenuVO vo new MenuVO(menu); vo.setChildren(new ArrayList()); map.put(menu.getId(), vo); } // 第三步两次遍历法组装树 ListMenuVO tree new ArrayList(); for (Menu menu : visibleMenus) { MenuVO vo map.get(menu.getId()); if (menu.getParentId() 0) { tree.add(vo); } else { MenuVO parent map.get(menu.getParentId()); if (parent ! null) { parent.getChildren().add(vo); } } } return tree; }为什么叫两次遍历法因为完整菜单数据只扫了两遍第一遍建立id到节点的映射第二遍通过parent_id找到父节点并挂载。相比递归法这个思路避免了递归带来的栈溢出风险和反复查库的性能问题。这里有个细节值得注意目录型节点menu_type为目录即使没有按钮权限也要保留在菜单树上否则用户看到的目录就是断的。但是用户不能访问的页面节点必须过滤掉——如果不过滤前端不显示但用户手动敲URL还是能访问所以后端接口必须也做拦截。3.4 方法级权限控制用注解和AOP守住每一扇门菜单隐藏只是用户体验层面的事真正的安全防线在后端接口上。你不能因为前端不显示「删除用户」按钮就认为用户无法删除用户——他抓包直接调接口照样能删。后端接口鉴权的通用手段是自定义一个权限注解配合Spring AOP实现方法级拦截。先定义注解Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface RequiresPermission { String value(); }再写AOP切面Aspect Component public class PermissionAspect { Around(annotation(requiresPermission)) public Object checkPermission(ProceedingJoinPoint pjp, RequiresPermission requiresPermission) throws Throwable { // 从当前登录用户上下文中获取权限集合 SetString perms SecurityUtils.getCurrentUserPermissions(); if (perms.contains(requiresPermission.value())) { return pjp.proceed(); } throw new BusinessException(无权限访问); } }然后在Controller上直接标注RequiresPermission(system:user:delete) DeleteMapping(/{id}) public ResultVoid deleteUser(PathVariable Long id) { userService.deleteById(id); return Result.ok(); }这样每次调用这个接口AOP切面会先校验当前用户有没有对应的权限标识没有就抛异常有才放行。AOP方案的优点是不侵入业务代码权限逻辑统一收敛而且粒度能精确到方法级别。实际项目中这种注解往往和Spring Security或者Shiro配合使用。如果你用的是Shiro它自带RequiresPermissions注解底层就是类似的AOP原理。如果你用的是Spring Security则需要自己扩展一个权限评估器。原理都是相通的在方法执行前拿当前用户的权限集合和注解声明的权限点做匹配。3.5 部门树递归查子部门n叉树应用的经典场景只要涉及数据权限几乎躲不开递归查部门子节点这个需求。比如数据权限是「本部门及以下」那就得拿到当前部门所有下级部门的ID集合。递归写法很直观public ListLong getChildDeptIds(Long deptId) { ListLong result new ArrayList(); ListDept allDepts deptMapper.selectAll(); // 查出全部部门 buildChildDeptIds(deptId, allDepts, result); return result; } private void buildChildDeptIds(Long parentId, ListDept allDepts, ListLong result) { for (Dept dept : allDepts) { if (parentId.equals(dept.getParentId())) { result.add(dept.getId()); buildChildDeptIds(dept.getId(), allDepts, result); } } }但要注意如果部门层级特别深超过500层递归可能会有栈溢出风险。生产环境一般不会出现这么深的组织架构所以这个方案是够用的。如果想做得更优雅一点可以用栈转成迭代版本也可以用两次遍历法先建好整棵部门树再按需裁剪。核心思路和菜单树完全一致——因为部门和菜单在结构上都是n叉树处理方式天然通用。理解了这一点你会发现n叉树在Java权限管理里就像一把万能钥匙菜单树、部门树、组织架构树本质上都是同一套玩法。4. 常见问题与排查技巧那些让你半夜被叫醒的坑4.1 超级管理员权限被绕过一查全是「设计如此」最常见的权限事故是这样的开发期为了方便搞了一个超级管理员admin后台代码里写了「如果是admin就直接放行」的逻辑。上线时忘了清理结果所有接口对admin都无条件放行——因为写判断的人可能只拦了部分接口部分接口压根没拦admin登录进去想干嘛就干嘛。我的建议是超级管理员不能成为跳过权限校验的借口。admin账号也应该走完整的权限链路只是给他分配一个包含所有权限点的角色而已。这样所有代码路径都是一套逻辑没有任何特例后续审计也清晰。如果实在想保留超管概念就把「是否超管」的判断收敛到一个统一的地方比如在构建权限集合时直接把所有perms塞给超管不要在业务代码里到处写if (isAdmin)。这个「到处写」的问题比超管本身更可怕——它在代码里埋了一堆隐性后门。4.2 权限树递归导致的内存爆炸和死循环菜单树构建时最典型的问题有两个。第一个是死循环数据库里有脏数据某个节点的parent_id指向了自己或者两个节点互相指向对方递归就永远走不完直接把内存堆满。排查方式是在递归方法里加一个visited集合或者限制递归深度超过一定层数直接报错。第二个是数据量大时性能差。很多人的第一版实现是在循环里查库每个节点查一次parent500个菜单查501次数据库。正确的做法是先一次性查出所有菜单到内存用两次遍历法或纯内存递归组装数据库只碰一次。还有一个容易被忽视的点父节点ID为0的顶级节点如果数据库里没有匹配的parent_id节点会丢失。组装完成后务必检查tree.size()和过滤后的列表大小是否一致不一致基本就是父节点丢失了。4.3 权限变更不生效缓存与实时性的博弈权限数据是高频读取、低频变更的。为了性能大家都会把用户的权限集合放缓存Redis或本地缓存。但缓存带来的问题就是管理员改了某用户的角色用户那边权限没变用户就找你报怨「我怎么还能看到那个按钮」。这个问题的解法通常有三种方案一权限变更时主动清缓存推荐。比如修改角色权限后删除所有关联用户的权限缓存。数据量小的时候最靠谱。方案二给权限集合加版本号用户每次请求时比较版本号不一致就刷新。方案三不做权限缓存每次请求实时查库。性能牺牲较大不推荐。我实际项目里用得最多的是方案一配合方案二每次查询时带一个权限版本号管理员改权限时全局递增版本号发现版本不一致就自动刷新缓存。这样既保证实时性又不会因为清缓存的那一下把数据库打崩。4.4 越权漏洞水平越权和垂直越权权限管理做得再完善也防不住业务代码写出来的越权漏洞。这是所有权限问题里最值钱、也最隐蔽的一种。垂直越权普通用户通过修改URL或构造请求访问了管理员才能访问的接口。比如普通用户直接调/admin/deleteUser接口。方法级注解接口鉴权能防住大部分这种情况。水平越权用户A通过修改参数访问了用户B的数据。比如用户A查自己的订单把请求参数里的userId123改成userId456结果订单接口没校验归属直接把别人的订单返回了。水平越权的防御核心原则是数据归属校验不能只依赖前端传参必须结合当前登录用户的身份。查详情接口时除了接收id参数还要通过当前登录用户判断这条数据是否属于他。有人把这个叫「数据所有权校验」写起来非常枯燥但漏一个都是事故。我见过不止一次线上事故是订单管理系统的详情接口没做归属校验被人遍历id把全站订单扒光了。这个教训写出来希望你能记住。4.5 JWT无法主动失效改了密码老Token还能用这是个很隐蔽的问题。JWT无状态是优点也是缺点——服务端不保存Token状态意味着你没法主动让一个Token失效。用户改了密码、管理员禁用了账号但用户手里的旧Token在过期前依然有效该访问还是能访问。几种缓解方案密码修改时更新一个password_version字段JWT里带上这个版本号校验时比对版本号不一致就拒绝。用户被禁用时把账号状态从Redis缓存里刷新每次请求检查状态。缩短Token有效期到15-30分钟配合Refresh Token机制让「踢人下线」的延迟控制在可接受范围内。权限管理这块我个人的体会是它不像缓存、消息队列那样有那么多让人兴奋的技术点它更像建筑里的消防通道——平时存在感很低但一旦出事就是大事。你手里的房卡有几把每把能开哪些门这个问题设计阶段多花一天想清楚后面能少踩无数个不眠夜。最后再分享一个小技巧无论项目多急权限相关的表结构变更一定要留SQL迁移脚本权限标识的命名规范要写进团队的开发规范里。这两件事坚持做下来你会发现维护权限系统的心态会稳很多。