后端一人搞定菜单级权限控制:RBAC模型与动态路由实战
1. 菜单级权限控制到底在控什么
先把概念理清楚。很多人一听到“权限控制到菜单级”,脑子里第一反应是“不就是给不同角色显示不同的侧边栏吗”。这个理解只对了一半。菜单级权限控制,控的是用户登录之后能看到哪些导航入口、能进入哪些页面、以及在页面里能触发哪些操作。它是一条从“登录”到“看到界面”再到“点击按钮”的完整链路,菜单只是这条链路上最直观的一环。
我见过不少项目,权限做得稀里糊涂:后端接口裸奔,前端靠 v-if 藏按钮,菜单写死在路由表里。这种做法的后果是,只要有人手动敲 URL,或者直接调接口,该拦的拦不住。所以菜单级权限的本质,不是“藏起来”,而是“进不去、调不通”。前端负责体验层面的可见性,后端负责数据层面的安全性,两者缺一不可。
那“后端一个人能不能配出来”这个问题,答案是可以,但有前提。前提是你得把权限模型设计对,把数据表结构定好,把动态路由和后端鉴权的联动逻辑跑通。一个人做,难点不在写代码的量,而在于思路要清晰、边界要划清。下面我按实际落地的顺序,把整套东西拆开讲。
1.1 权限模型选型:RBAC 是绕不开的起点
做菜单级权限,绕不开 RBAC(基于角色的访问控制)。它的核心就三张表:用户表、角色表、权限表,外加两张关联表把用户和角色、角色和权限连起来。为什么是角色而不是直接给用户配权限?因为实际项目里,用户数量远大于角色数量,给角色配权限一次配好,新用户挂上角色就完事,维护成本低得多。
具体到菜单级,权限表里存的其实是“菜单/按钮”这类资源。一条权限记录通常包含这几个关键字段:权限名称、权限标识(比如 system:user:list)、资源类型(目录/菜单/按钮)、父级 ID、路由地址、组件路径、图标、排序。这样一张表既能撑起前端的动态菜单渲染,又能作为后端接口鉴权的依据。
我个人的经验是,权限标识的命名一定要有规范,推荐 模块:子模块:操作 三段式。比如 system:user:add、system:role:edit。这个标识后面会同时出现在前端按钮的权限判断和后端接口的注解里,命名乱了,后期排查能要人命。
1.2 菜单级和按钮级的边界在哪
菜单级管的是“能不能进这个页面”,按钮级管的是“进了页面能不能点这个按钮”。两者在数据表里其实是同一套权限数据,只是资源类型不同。目录和菜单决定路由和侧边栏,按钮决定页面内的操作点。
为什么要区分?因为有些场景下,用户能进订单列表页,但不一定能点“删除订单”。如果只做到菜单级,那进了页面所有按钮都能点,这显然不够。所以真正完整的方案,是菜单级和按钮级一起做,共用一套权限标识体系。前端拿到权限标识集合后,菜单用它筛路由,按钮用它控制显隐。
2. 数据表怎么设计才不返工
表结构设计是这套东西的地基,地基歪了后面全是坑。我见过最离谱的设计是把菜单和权限分成两套完全独立的表,结果配权限要配两遍,改一处忘一处。正确的做法是一张权限表统管目录、菜单、按钮,用类型字段区分。
2.1 核心表结构拆解
用户表不用多说,存账号密码基本信息。角色表存角色名和角色标识。权限表是重点,我一般会这么设计:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| parent_id | bigint | 父级 ID,顶级为 0 |
| name | varchar | 菜单/权限名称 |
| type | tinyint | 1 目录 2 菜单 3 按钮 |
| path | varchar | 路由地址 |
| component | varchar | 前端组件路径 |
| perms | varchar | 权限标识 |
| icon | varchar | 图标 |
| sort | int | 排序 |
| visible | tinyint | 是否显示 |
用户角色关联表和角色权限关联表就是两个中间表,各存两个外键。这里有个细节:角色权限关联表在删除角色时要级联清理,否则会留下脏数据,导致某些权限标识莫名其妙还在生效。
2.2 为什么权限标识要单独存一列
有人会问,菜单的路由地址不就能当权限标识用吗?不行。路由地址是给前端路由用的,权限标识是给后端鉴权用的,两者职责不同。一个菜单可能对应多个后端接口,比如用户列表页有查询、新增、修改、删除四个接口,它们共用同一个菜单,但权限标识各不相同。所以权限标识必须独立存储,且要能精确到每个操作。
提示:权限标识一旦上线就尽量不要改,因为前端按钮和后端注解都引用了它。要改就得全局搜索替换,漏一处就是权限漏洞。
3. 后端鉴权链路怎么串起来
后端这块,核心就两件事:登录时把用户的权限标识集合查出来,请求时校验当前接口需要的权限标识在不在集合里。听起来简单,但中间有几个容易翻车的点。
3.1 登录后权限集合的加载
用户登录成功后,后端要根据用户 ID 找到他所有角色,再根据角色找到所有权限标识,去重后组成一个集合。这个集合有两个用途:一是返回给前端渲染菜单,二是存到服务端会话或缓存里供接口鉴权用。
这里有个性能考量。如果每次请求都去数据库查一遍权限,数据库压力会很大。常见做法是登录时把权限集合缓存起来,比如放到 Redis 里,key 用用户 ID 或 token,设置合理的过期时间。用户权限变更时主动清缓存,这样既快又不会读到旧数据。
我踩过的一个坑是:缓存了权限集合,但管理员改了角色权限后忘了清缓存,导致用户还是用旧权限在操作。后来我在角色权限变更的接口里统一加了清缓存逻辑,才彻底解决。
3.2 接口鉴权的实现方式
接口鉴权一般用拦截器或过滤器实现。请求进来后,先解析 token 拿到用户身份,再拿到该接口需要的权限标识,最后判断用户权限集合里有没有这个标识。没有就返回无权限,有就放行。
权限标识怎么和接口绑定?两种方式。一种是在接口方法上加自定义注解,比如 @RequiresPermissions("system:user:add"),拦截器读取注解拿到标识。另一种是在数据库里配接口和权限的映射关系。前者更直观,后者更灵活。我一般用注解方式,因为改起来方便,代码里一眼能看到这个接口需要什么权限。
拦截器里拿到注解上的权限标识后,和当前用户的权限集合做比对。这里要注意,超级管理员通常要放行所有权限,可以在拦截器里加个判断,如果用户是超管就直接放行,不走比对逻辑。
3.3 动态路由的数据从哪来
前端动态路由的数据,就是后端根据用户权限查出来的菜单树。后端把菜单树转成前端路由需要的格式返回,前端拿到后动态注册路由。这里的关键是,后端返回的菜单树必须和权限标识对应上,前端才能既渲染菜单又控制按钮。
菜单树的构建一般用递归。从顶级菜单开始,逐层找子菜单,组装成树形结构。返回给前端的字段通常包括:路由地址、组件路径、菜单名称、图标、子菜单列表。前端拿到后,把组件路径映射成实际组件,注册到路由表里。
4. 前端动态路由和按钮权限怎么落地
前端这块,Vue 和 React 思路类似,我以 Vue 为例讲。核心是登录后拿到菜单数据,动态生成路由,同时把权限标识集合存起来供按钮判断用。
4.1 动态路由的注册时机
动态路由不能写死在路由表里,得在登录后、进入主界面之前注册。通常的做法是:登录成功后,把菜单数据存到状态管理里,然后在路由守卫里判断,如果路由还没注册,就根据菜单数据生成路由并 addRoute。
这里有个顺序问题。如果路由守卫里先判断了目标路由存不存在,再注册路由,可能会因为注册时机太晚导致白屏。我的做法是在守卫里先检查是否已加载菜单,没加载就先加载菜单并注册路由,然后再放行。这样保证进入任何页面前路由都已就绪。
注意 next({ ...to, replace: true }) 这行,它的作用是重新触发一次导航,确保新注册的路由能被匹配到。不加这行,第一次进入动态路由页面可能会 404。
4.2 按钮权限的判断指令
按钮权限一般封装成一个指令或组件。指令方式用起来最顺手,比如 v-permission="'system:user:add'",指令内部判断当前用户权限集合里有没有这个标识,没有就把元素移除。
用指令的好处是写法简洁,坏处是元素已经被渲染到 DOM 了再移除,有轻微的性能损耗。如果在意这个,可以用函数式组件或计算属性提前判断。不过实际项目里,按钮数量有限,指令方式完全够用。
注意:前端按钮权限只是体验优化,不能当安全手段。真正的安全靠后端接口鉴权。前端藏了按钮,后端该拦还得拦。
4.3 菜单渲染和路由的对应关系
侧边栏菜单的渲染,直接遍历后端返回的菜单树就行。但要注意,菜单的显示和路由的注册是两回事。有些菜单可能只用于分组(目录),不对应实际页面,这种在渲染时要特殊处理,不能生成路由。
我一般会在菜单数据里用类型字段区分:目录只渲染分组标题,菜单渲染可点击项并注册路由,按钮不参与菜单渲染。这样前端处理逻辑清晰,不会把按钮误渲染成菜单项。
5. 一个人配这套东西的实操顺序
如果你是一个人从零开始配,我建议按这个顺序来,能少走很多弯路。
5.1 第一步:把表结构和初始数据准备好
先把用户、角色、权限、关联表建好,然后手动插入一条超管数据、一个超管角色、以及最基础的菜单权限数据。超管角色先关联所有权限,保证自己能登录进去配后面的东西。这一步别偷懒,初始数据不全,后面登录进去一片空白,连配权限的入口都找不到。
5.2 第二步:后端登录和权限查询接口
先实现登录接口,登录成功后返回 token 和用户基本信息。然后实现查询用户权限标识集合和菜单树的接口。这两个接口是前端动态路由的基础,必须先跑通。可以用 Postman 先测,确认返回的数据结构符合预期。
5.3 第三步:后端接口鉴权拦截器
拦截器加上,注解加上,先拿一两个接口试。确认无权限时返回正确的错误码,有权限时正常放行。这一步做完,后端的安全底座就有了。
5.4 第四步:前端动态路由和菜单渲染
前端先做登录页,登录后调菜单接口,生成路由,渲染侧边栏。这一步做完,不同角色登录能看到不同菜单的效果就出来了。然后再加按钮权限指令,把页面内的操作点控制住。
5.5 第五步:联调和边界测试
最后一步是测试各种边界情况:无权限用户直接敲 URL 能不能进、按钮藏了但接口能不能调通、超管是不是所有都能访问、权限变更后缓存有没有及时更新。这些测完,整套权限控制才算真正可用。
6. 常见问题和排查思路
这套东西做下来,坑不少。我整理了几个高频问题和排查方法。
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 登录后菜单空白 | 菜单接口没返回数据或前端没注册路由 | 看接口返回、看路由注册逻辑 |
| 动态路由页面 404 | 路由注册时机晚于导航 | 检查守卫里注册和放行的顺序 |
| 按钮权限不生效 | 权限标识不匹配或指令没注册 | 对比前后端权限标识、检查指令注册 |
| 接口鉴权失效 | 拦截器没生效或注解没读到 | 看拦截器配置、看注解是否被代理 |
| 权限变更不生效 | 缓存没清 | 检查权限变更后是否清缓存 |
6.1 动态路由刷新后丢失的问题
动态路由是运行时注册的,刷新页面后路由表重置,如果没重新加载菜单,就会 404。解决办法是在路由守卫里每次都检查菜单是否已加载,没加载就重新拉取并注册。配合把菜单数据存到本地存储,可以减少一次接口请求,但要注意本地存储的数据可能过期,最好还是以接口为准。
6.2 权限标识前后端对不上的问题
这个问题太常见了。前端写 system:user:add,后端注解写 system:user:create,一字之差,权限就失效。我的做法是权限标识统一由后端定义,前端从菜单接口里拿,不手写。按钮上直接用后端返回的标识,这样就不会对不上。
6.3 超管权限的处理
超管通常要能访问所有菜单和接口。有两种做法:一种是把所有权限都关联给超管角色,另一种是在鉴权逻辑里判断是超管就放行。前者数据量大,新增权限还得记得给超管加;后者代码里加个判断就行,更省事。我倾向后者,但要注意判断逻辑要可靠,别把普通用户误判成超管。
7. 一些实操心得
做这套东西,代码量其实不大,难的是细节。我分享几个自己踩坑总结出来的经验。
权限标识的命名规范一定要在项目初期就定好,并且写进文档。后期加功能的人多了,命名五花八门,维护成本会指数级上升。我一般会在项目里放一个权限标识常量类,所有标识从这里取,避免手写字符串。
菜单树的构建尽量放在后端做,前端只负责渲染。后端对数据结构更可控,前端拿到的就是最终形态,减少前端处理逻辑。有些团队喜欢后端返回扁平列表,前端自己组装树,这样前端逻辑会变重,而且不同页面可能重复实现组装逻辑。
缓存权限集合时,过期时间别设太长。权限变更虽然会主动清缓存,但万一清缓存逻辑有 bug,短过期时间能兜底。我一般设 30 分钟,既能减少数据库压力,又不至于权限变更后长时间不生效。
测试权限一定要用不同角色的账号真实登录去测,不能只靠改代码模拟。我见过有人测试时直接改前端权限集合,测出来没问题,一上线真实登录就露馅。真实登录能暴露 token 解析、缓存读取、菜单查询整条链路上的问题。
最后说一句,一个人配这套东西完全可行,但前提是思路清晰、按顺序来、每步都验证。别想着一次性全写完再测,那样出了问题很难定位是哪一环的毛病。分步做、分步测,稳扎稳打,两三天就能跑通整套流程。