多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

已知菜单表怎么配置接口权限?RBAC、拦截器与注解鉴权实战

已知菜单表怎么配置接口权限?RBAC、拦截器与注解鉴权实战 1. 需求判断菜单权限与接口权限到底差在哪一层先说我遇到这个标题时的第一反应这又是一个“看起来简单做起来一堆坑”的需求。菜单表通常指的是前端路由菜单它解决的是“用户登录后能看到哪些页面、哪些按钮”而接口权限解决的是“请求到达后端时服务端如何判断这个用户能不能调这个接口”。这两者中间的落差就是越权漏洞生长的温床。我在实际项目里见过太多次这样的场景前端根据角色把菜单隐藏了但有人直接抓包调用后端接口数据一样能返回来——菜单权限是纯前端的显示控制它本质上只是“用户体验层面的隐藏”不构成任何安全边界。所以当你面对“已知菜单表如何配置接口权限”这个问题时首先要做的不是急着写代码而是先想清楚三件事你要控制的接口属于哪些角色能访问菜单表里的路由路径与后端接口URL是“一对一”还是“一对多”关系权限校验的锚点是角色、用户还是更细粒度的权限标识permission code这三件事没想明白后面怎么配都是乱的。我从一开始就建议团队把权限模型定成“用户-角色-权限标识”这套经典RBAC变体。菜单表里面不要只存菜单名称和前端路由必须为每个菜单项分配一个唯一的权限标识比如system:user:list然后让后端接口绑定同样的权限标识。这样一来菜单显示和接口校验共用同一套“权限语言”前端控制“看得到看不到”后端控制“调得动调不动”才能真正闭环。具体到技术选型上我见过几种做法最原始的是在接口里手写if (当前用户不是管理员) { 拒绝 }这种代码散落各处维护成本极高好一点的是Spring Security或Shiro这类框架按URL规则做角色拦截再往前一步就是基于注解或权限标识的细粒度校验。我个人推荐的做法是“拦截器注解”的双层结构所有请求先走全局拦截器做身份认证再通过接口上声明的权限标识做授权校验既照顾了统一性又保留了接口级别的灵活性。2. 权限模型落地菜单表结构改造与权限标识命名2.1 菜单表最少需要哪几个字段既然题目说“已知菜单表”说明表已经有了。我这里拿最常见的sys_menu表举例不管你是从若依、Ruoyi-Vue还是自己从零写的后台核心字段基本跑不出这几列字段名作用备注id菜单唯一标识自增或雪花ID均可parent_id父级菜单ID顶级菜单为0menu_name菜单名称前端展示用path前端路由路径如/system/usercomponent前端组件路径决定渲染哪个页面perms权限标识如system:user:listmenu_type菜单类型M目录、C菜单、F按钮visible是否显示控制前端是否渲染status状态启用/停用这里最关键的是perms字段。很多项目在设计初期没有这个字段或者有但大家不重视随便填甚至留空结果就是前端菜单只能按角色整棵树的显示或隐藏根本做不到按钮级控制后端接口也无法和菜单表关联。你要配置接口权限perms就是那座连接前后端的桥。关于命名规范我踩过几年坑之后固定下来一套规则三段式模块:功能:操作。比如用户管理列表是system:user:list新增用户是system:user:add编辑是system:user:edit删除是system:user:remove重置密码是system:user:resetPwd。这样命名有几个实际好处一看标识就知道它在哪个模块、对应什么操作在菜单表和接口注解上直接复制使用不会出现语义歧义后续做批量授权时也能用前缀system:user:*做通配匹配。还有一种情况需要留意接口并不总是和菜单一一对应。比如菜单“用户管理”页面它对应的接口可能就有七八个列表、详情、新增、编辑、删除、导出、导入、分配角色等。这时候menu_type为 C 的那条记录perms可以填system:user:list而页面里的新增按钮、删除按钮应该在菜单表里以menu_type F按钮的子节点形式存在各占一条记录各配一个perms。别小看这个设计它直接决定了你能不能做到“张三看不到删除按钮也无法调用删除接口”这个理想状态。2.2 为什么必须区分菜单权限与按钮权限菜单权限解决了“看见哪棵树”的问题按钮权限解决的是“页面里能操作哪些控件”的问题。但很多项目会把两者混在一起处理我在代码评审时没少看到这种写法前端拿到角色后后端返回一个menus数组前端根据菜单数组去渲染左侧导航至于页面里某个按钮是否灰置靠的是角色名判断——比如if (role admin)。这种写法刚开始觉得爽等到角色超过五个、权限规则开始交叉时就是一场灾难。正确的做法是后端一次查询返回两个维度的数据一是菜单树用于渲染左侧导航二是权限标识集合perms前端拿perms去控制按钮显隐。判断条件不要再用角色名而是v-hasPermi[system:user:add]这类指令或hasPermi工具函数。后端接口再用相同的权限标识做校验形成“前端按钮失效 后端接口拒绝”的双保险。2.3 菜单表数据初始化与权限标识同步菜单表的初始数据通常由开发或实施人员通过SQL脚本或后台管理页面录入。我强烈建议把菜单数据的维护界面做进后台里而不要一直靠手写SQL。理由很简单菜单表一旦和接口权限绑定任何一次perms改动都意味着前端所有依赖该标识的控制逻辑都要跟着变这比改个菜单名称严重得多。权限标识同步有一个很现实的痛点新增了一个菜单比如“订单导出”按钮开发人员在接口上加了RequiresPermission(order:export)但忘了在菜单表里插入对应的按钮记录结果就是所有人都没有这个权限标识功能永远报“无权限”。为了避免这种问题我采用的办法是在项目启动时做一次权限标识自检扫描所有接口注解上的权限标识和菜单表里的perms集合做对比把缺失的标识打印成警告日志提醒开发者“接口已声明但菜单表未配置”。原理就是启动时用Spring的ApplicationRunner或PostConstruct读一遍所有HandlerMethod上的权限注解再查一次数据库差的列出来。这个小工具很值得写后面我会放思路。3. 接口权限校验实现从拦截器到注解的完整链路3.1 总体流程设计先理清一次带权限的请求在后端完整的流转路径请求进入后端被全局过滤器或拦截器捕获解析Token得到用户ID。根据用户ID查出角色集合再根据角色查出权限标识集合缓存到内存后续步骤直接用缓存。Spring MVC处理请求时如果目标接口方法上标注了权限注解则通过AOP或拦截器对当前用户的权限集合做比对。比对通过则放行不通过则抛出无权限异常由统一异常处理器转为HTTP 403返回。前端收到403后可以全局提示“无权限操作”。这套链路里第2步的缓存非常关键。我见过有人每次请求都实时查一遍用户角色和权限接口一多就出现明显的性能抖动。实际项目中我用Caffeine做本地缓存Key是用户IDValue是权限标识集合超时时间设为5分钟再结合“权限变更时主动删除该用户缓存”的失效策略基本能做到实时性与性能兼顾。如果系统是分布式多实例缓存失效消息可以通过Redis通道广播做法就是在发布订阅频道里发一条用户ID各实例收到后删除对应的本地缓存。3.2 权限注解的设计与参数选择注解我用了两个维度做组合一个是角色一个是权限标识。光用角色做限制太粗光用权限标识又没法处理“管理员绕过一切”的场景所以两者配合最顺手。示例定义Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface RequiresPermission { String[] value() default {}; Logical logical() default Logical.OR; }value是权限标识数组logical表示多个权限标识之间是AND还是OR关系。比如“订单退款”这个接口你可能要求用户同时具备order:refund:apply和order:refund:approve两个权限那就用AND而“数据导出”接口只要具备order:export或report:export任一即可就用OR。设计这个参数很有必要不要偷懒只做一个字符串。实际使用场景是这样的在Controller方法上标注RequiresPermission({system:user:add}) PostMapping(/system/user) public AjaxResult add(RequestBody SysUser user) { return userService.insertUser(user); }如果权限标识和菜单表里的perms不一致比如菜单表写的是system:user:insert接口注解写的是system:user:add那就会出现一种很割裂的现象前端按钮显示了但后端一调就403或者前端按钮隐藏了但接口却放行了。这样的问题用肉眼非常难排查所以权限标识自检工具就显得尤其重要。3.3 核心校验逻辑实现思路校验逻辑我用一个简单的拦截器实现不走Spring Security那种复杂框架。核心步骤是这样Component public class PermissionInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod (HandlerMethod) handler; RequiresPermission requiresPermission handlerMethod.getMethodAnnotation(RequiresPermission.class); if (requiresPermission null) { requiresPermission handlerMethod.getBeanType().getAnnotation(RequiresPermission.class); } if (requiresPermission null) { return true; } LoginUser loginUser SecurityUtils.getLoginUser(); if (loginUser.isAdmin()) { return true; } SetString permsSet permissionService.getMenuPermission(loginUser); String[] requiredPerms requiresPermission.value(); boolean hasPermission false; for (String perm : requiredPerms) { if (permsSet.contains(perm)) { hasPermission true; break; } } if (!hasPermission) { throw new AccessDeniedException(没有权限操作); } return true; } }我自己在实际项目中把这段代码抽成了一个单独的PermissionService不直接写在拦截器里方便在其他地方复用。判断逻辑有一个细节getMenuPermission不只是查当前用户的权限标识集合还需要把管理员通常是用户ID为1的超管单独处理——直接给他返回一个*:*:*的通配标识让他天然匹配所有接口。这是业务惯例但要注意不要把管理员判断散落在业务代码里统一收敛到权限服务这一层。开发者容易忽略的是类级别注解与方法级别注解的叠加处理。上面的代码先查方法注解再查类注解但类与方法同时存在时会以方法注解覆盖类注解这是符合预期的。更灵活的写法是方法上没标注时继承类上标注同时方法上有标注时合并两个集合再校验我在项目里是直接让方法覆盖类避免产生“类上要求A权限方法上要求B权限用户要同时具备AB才放行”这种费解的组合。3.4 权限比对时通配符与精确匹配的取舍权限标识的集合比对我用的是精确匹配加前缀通配。菜单表里的perms可能是system:user:*而接口注解上是system:user:list这时候要把*当通配符处理。实现方式是把用户的权限集合循环一遍每个元素按冒号分段遇到*就停止精确匹配改用AntPath风格匹配。不要在这里图省事用简单的字符串startsWith否则system:user2:list这种相似前缀的标识会被误放行。Spring框架本身带了AntPathMatcher直接用它来做通配匹配是最省事的。比对逻辑放在PermissionService里每次请求动态判断一次命中后缓存匹配结果整体开销几乎可以忽略。另外一个优化点是如果菜单表里已经定义了大量perms: *:*:*这类全量标识建议在查询时就过滤掉不要在运行时匹配因为那会让所有用户等同管理员完全失去鉴权意义。4. 权限数据流角色授权、菜单树接口与动态刷新4.1 角色和权限标识的关联关系设计有了菜单表和权限标识接下来就要做“角色授权”的页面和表设计。核心表结构是经典的5张表用户表、角色表、菜单表、用户角色关联表、角色菜单关联表。角色菜单关联表里存的是菜单ID而回查权限标识集合时通过关联表从菜单表里把perms字段捞出来。这套设计的精妙之处在于菜单表既是前端菜单的数据源又是权限标识的数据源两者天然不会脱节。授权页面上也只需要做一件事——给角色勾选菜单树然后保存角色和菜单的关联关系。展示层面不用单独维护权限标识点击某个菜单的checkbox时实际上就已经把该菜单对应的perms授权给了角色。实际开发中这里有个细节角色菜单关联表关联的到底是哪些菜单ID。如果勾选了目录M类型菜单它的perms往往是空的没什么实际授权意义勾选了按钮F类型菜单对应的perms才是真正的接口权限标识。这会导致一个困惑授权页面上用户勾了一个目录感觉没起作用实际上是这个目录本身不绑定任何接口。我建议授权页面做成树形结构后保存时把选中的节点ID直接全部存进角色菜单关联表不需要前端区分菜单类型。后端的优势是数据齐整、可逆前端要的只是树的展示和勾选状态。权限校验时查询语句天然会过滤掉perms为空的菜单记录逻辑上不会出问题。4.2 获取菜单树与权限标识集合的接口设计后台管理系统里通常会提供两个核心接口GET /getRouters返回当前用户的菜单树用于前端动态路由GET /getInfo返回用户信息和权限标识集合。这两个接口必须复用同一套查询逻辑否则可能出现“菜单能渲染出来但按钮权限标识缺失”的bug。菜单树的SQL逻辑大致是先查出用户的角色ID集合再通过角色查菜单最后按menu_type过滤出目录和菜单类型按parent_id组装树。这里要注意排序同一层级的菜单要按order_num排序不然前端渲染出来菜单顺序总是乱跳。权限标识集合的查询则更简单直接按角色关联菜单拿perms过滤掉空值和非按钮类型转成Set返回。我遇到过一个真实案例菜单树接口返回正常但perms集合里漏了某个按钮权限排查后发现是角色菜单关联表里存的菜单ID是父级目录ID按钮记录没有挂到该角色上。这种情况在手动执行SQL调试时最容易踩到因为人眼看数据链路通常只核对目录和一级菜单按钮节点太深容易忽略。4.3 权限变更后的缓存刷新策略权限配置不是静态的运营人员随时可能调整角色菜单关联。用户登录后权限集合已经写入缓存如果后台改了角色授权用户不重新登录可能一直持旧权限。我用的方案是“后台操作角色授权时主动失效涉及的用户缓存”。简单做法是维护一张角色-用户映射表或者直接查用户角色关联表编辑角色授权后取出该角色下的所有用户ID逐个删除缓存Key。分布式场景下再发一条Redis消息让所有实例都删一遍。这个方案复杂度可控效果显著。这里有几个容易踩的细节超级管理员在权限校验中绕过一切不要在缓存里缓存他的权限集合每次直接返回*:*:*避免改角色导致超管缓存失效。如果用户直接修改了自己的角色比如被移出某角色最安全的做法是删除该用户所有缓存同时强制前端重新拉取用户信息否则页面上可能残留旧按钮。权限缓存建议用“用户ID 权限标识集合”的格式而不是“角色ID 权限标识”。因为一个用户多角色时按角色缓存还要做合并去重复杂度更高。5. 权限初始化流程与常见问题排查5.1 新用户入职的权限初始化实操配置接口权限这件事落到日常运维里高频场景就是“新来一个运营要开后台账号”。我按常用顺序理了一遍基本是五步走新建用户初始密码设置后强制首次登录修改为该用户分配角色如果已有匹配的角色直接绑定如果没有匹配角色先创建角色再给角色分配菜单树角色授权时把需要用到的菜单和按钮全部勾选用新账号登录打开菜单、点击按钮验证接口是否放行。第4步要多说一句勾选菜单时不要只勾目录和一级菜单要一路勾到最末级的按钮。我见过有人只勾了“用户管理”这个页面结果页面上全是按钮但因为按钮权限没勾所有操作都报403新员工还以为系统坏了。因此我在权限管理页面上专门加了一个“展开/折叠全部”和“父子联动勾选”的交互默认父节点勾选时子节点全部跟着勾上省去大量手工操作。5.2 常见问题速查表做权限配置最容易翻车的几个点我整理成了速查表问题现象可能原因排查步骤菜单显示正常但按钮全部报无权限角色授权时未勾选按钮类型菜单查看角色的菜单分配确认是否包含F类型菜单接口直接返回403接口注解权限标识与菜单表perms不一致扫描接口注解与菜单表比对差异新增菜单后所有人都看不到菜单未启用或未分配给任何角色检查菜单状态检查角色菜单关联用户权限变更后不生效缓存未刷新手动清除该用户的权限缓存超管操作某些接口也403代码没有走超管判断分支检查权限服务里是否对超管ID做了绕过菜单树排序错乱查询结果未按order_num排序SQL加order by排序字段5.3 开发阶段的权限自检工具思路前面提过启动扫描权限标识差异的检查工具具体思路我简要说一下项目里用起来很值启动时用Spring的RequestMappingHandlerMapping拿到所有HandlerMethod。遍历每个方法上的RequiresPermission注解取出所有权限标识。查询菜单表里的perms集合作差集。把只存在于接口声明、但菜单表里没有的标识打印成错误日志。有一个特例有些内部接口或不需要权限控制的接口不加RequiresPermission注解这一步会自动跳过。加上这个自检工具后团队几乎不再出现“前端菜单未配置但接口已加权限”这类低级问题算是我这些年最推荐的脚手架代码之一。6. 实操体验与一点忠告权限系统本质上要解决的是“数据边界”问题它不像写CRUD那样有标准答案每家公司甚至每个项目的菜单结构、角色模型都不同所以网上能找到的成熟代码只能借鉴不能照搬。我自己最初给一个项目做接口权限时也偷懒想过“直接copy若依的权限模块”后来发现菜单表结构和业务需求差太多花在适配上的时间比重写一遍还多。从那次之后我倾向于把这个模块做成一套通用模型字段按业务裁剪核心逻辑保持一致往新项目上铺的时候反而更快。真正开始编码之前建议你先花一晚上把菜单表跑一遍列清楚所有菜单节点和接口URL的对应关系会省掉后面80%的沟通成本。另外我强烈建议把菜单表里的perms字段从设计之初就设成非空约束配合启动自检工具不要让这个字段“有机会”为空。毕竟权限系统一旦上线漏配一个标识可能就意味着某条核心数据可以被越权访问这个风险不值得冒。如果后续你有更高阶的需求比如数据权限部门、用户范围、接口限流、操作日志审计可以在同一套拦截器链上继续扩展但前提是基础的角色-菜单-接口权限链路是干净的。基础不牢后面都是夹生饭。我在实际项目中已经把这套链路跑得很顺了希望这篇文章能让你少走几条弯路。
返回列表