ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

通用RBAC权限系统设计实战:从表结构到按钮级权限

通用RBAC权限系统设计实战:从表结构到按钮级权限 1. 为什么我要自己造 RBAC 的轮子三个让我抓狂的真实场景权限系统大概是所有后台管理系统里最“看着简单、做起来抓狂”的东西。RBAC 这个词很多后端都挂在嘴边无非是用户绑角色、角色绑权限、再拿权限去校验接口。但真实项目里一旦松一口气权限逻辑就会以各种姿势出问题。我这里总结开发风汐通用管理系统之前遇到的三个真实场景每一个都让人想把电脑合上。第一个场景是权限全写在前端。菜单和按钮倒是会按角色显隐但后端接口谁都能调只要懂一点网络请求的知识就能绕过页面直接操作数据。第二个场景是权限判断散落在每个 Controller 里每个接口开头都有一段“拿当前用户、判断角色、不通过就抛异常”的重复代码。前期还能接受后期接口一多谁漏写一段谁顺手删掉一行权限体系就出现缺口而且很难发现。第三个场景是权限写死。角色名直接出现在v-ifrole admin里新增一个理想要接管的角色时必须把所有写死的地方翻出来改一遍改漏一处就出事故。我做风汐通用管理系统就是想把这些痛点一次性收拢。这不是一套全新的理论框架而是基于 RBAC 模型做的通用权限底座覆盖目录、菜单、按钮、接口和行级数据权限项目引入之后能直接使用也方便按业务二次开发。如果你准备自己搭建权限系统或者正在被权限代码纠缠得头疼下面这些设计思路和踩坑记录应该能帮你少走不少弯路。1.1 权限写进 if/else改一次权限改一次代码最典型的反面教材是把权限判断直接写在业务逻辑里。比如订单审核接口一开始写的是“只有管理员可以审核”后来产品说运营专员也可以审核那就得打开代码改判断条件重新打包发布。如果需求再进一步变成“华东区的运营专员只能审核华东区的订单”这就已经不是简单追加一个条件能解决的了因为它已经混进了数据维度——同一个角色在不同数据范围内要有不同可见性。这个需求用 if/else 写出来大概会是三层嵌套加一堆临时集合谁写谁想骂人。风汐的设计从一开始就把这套逻辑从业务代码里抽离。判断“谁能访问这个接口”用注解加 AOP 完成判断“他能看到哪些数据”用 SQL 拦截器完成业务代码里只写业务完全不关心调用方是谁。带来的直接效果是权限变了只改角色配置线上服务不用动开发也不用陪着做版本发布。我自己在最开始写这套东西的时候就反复提醒自己一个问题权限系统不应该侵入业务系统它应该像一个水闸立在所有请求和数据的上游而不是散落在每条河道里。1.2 “通用”两个字背后的真实需求市面上现成的权限框架不少功能也比我这一套全但实际用起来经常会有几个尴尬点学习成本太高文档绕来绕去想扩展一个业务字段要翻半天扩展机制功能过于复杂很多项目根本用不到那么庞大的组织机构模型。风汐的“通用”指的不是功能多寡而是拿到任何新项目里都能接得上、不碍事、用得顺手。开发者开发权限系统时经常陷入一个误区把权限系统和业务系统绑得太紧。比如在权限管理后台里放订单管理、商品管理看起来功能丰富但实际上把权限底座和业务模块耦合在了一起。风汐的做法是只管账号、角色、权限、数据规则、操作日志这几件事业务系统要接入时只需要引入依赖、按约定声明权限码前端接好指令和组件即可。这个定位让我在开发过程中少走了很多弯路因为一旦想明白了“自己是底座而不是业务系统”就不会纠结业务功能要不要内置这类问题。1.3 风汐的定位不是框架是一套可以直接落地的底座我把它拆成了两个部分底层是权限核心服务负责用户、角色、权限、数据规则的管理和校验上层是管理界面提供一套可以独立部署的后台页面包含用户管理、角色管理、菜单管理、操作日志。对于需要快速上线的项目把管理后台部署起来配好角色和权限就能跑对于要做深度定制的项目直接把核心模块依赖进来按需求扩展自己的业务表即可。这套系统实际上解决了我自己反复遇到的四个问题第一权限点能不能覆盖到按钮和接口而不是只控制菜单显隐第二行级数据权限能不能按角色配置而不是每个表格都单独写一段权限 SQL第三权限变更之后能不能立刻生效而不是让用户等半天甚至重新登录第四权限记录能不能追溯权限被谁改了、改了什么能不能查得清楚。这四个问题也是后面几个章节要展开的核心内容。2. 数据模型设计五张表和一个类型字段撑起整套权限RBAC 系统的根基在数据模型。风汐的核心表总共五张用户表、角色表、权限表、用户-角色关联表、角色-权限关联表。很多教程还会加一张用户-权限直连表我自己建议不要加原因在后面细说。2.1 基础表结构与角色中转的必要性用户表和角色表是常规设计ID、名称、状态、创建时间没有太多可说的。真正的关键在于中间表。用户通过角色拿权限而不是直接和权限挂钩这个“中转”设计是 RBAC 的核心价值。假设系统里有 100 个用户、50 个权限点如果用户直连权限需要维护的关系是 100×50而且权限一变要改几十个人有了角色之后同一类用户归成一个角色改角色就全部生效管理成本大大降低。以下是风汐最简版的核心建表结构字段做了精简实际项目里可以根据需要再补充部门ID、岗位ID、邮箱、手机号等信息CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, status TINYINT DEFAULT 1 COMMENT 1启用 0禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE sys_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_code VARCHAR(50) NOT NULL UNIQUE, role_name VARCHAR(50) NOT NULL, status TINYINT DEFAULT 1 COMMENT 1启用 0禁用, remark VARCHAR(255) ); CREATE TABLE sys_permission ( id BIGINT PRIMARY KEY AUTO_INCREMENT, perm_code VARCHAR(100) NOT NULL COMMENT 权限码如 system:user:add, perm_name VARCHAR(50) NOT NULL, type TINYINT NOT NULL COMMENT 1目录 2菜单 3按钮 4数据权限, parent_id BIGINT DEFAULT 0 COMMENT 父级ID顶级为0, sort_order INT DEFAULT 0 ); CREATE TABLE sys_user_role ( user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, PRIMARY KEY (user_id, role_id) ); CREATE TABLE sys_role_permission ( role_id BIGINT NOT NULL, permission_id BIGINT NOT NULL, PRIMARY KEY (role_id, permission_id) );这里有一个容易被忽略的设计细节用户-角色、角色-权限这两张关联表主键用的是联合主键而不是额外的自增 ID。联合主键虽然写起来多一点但有一个非常实际的好处——数据库层面就能挡住重复绑定。如果用一个自增主键一个用户可能被重复绑上同一个角色业务层还要多做一次去重判断。2.2 permission 表的树形设计与权限码规范权限表里我加了一个 type 字段这是很多初学者容易漏掉的部分。同一张权限表里既存目录、菜单又存按钮和数据权限靠 type 区分层级parent_id 维护树形关系。目录下面挂菜单菜单下面挂按钮数据权限则单独标记为 type4。这样前端生成菜单树的时候可以一次性查出所有权限数据递归拼装即可不需要为不同层级建多张表。权限码的规范比表结构更重要。我强烈推荐三段式模块:功能:操作例如system:user:add、order:audit:pass、report:view:list。三段式的好处是层级清晰通配匹配也方便——用户持有order:*就代表订单模块的所有权限。实际执行权限判断时把用户拥有的权限码集合和接口需要的权限码做完全匹配或逐级前缀匹配性能稳定也容易读。权限码本身在系统里可以起到字典的作用开发看到system:user:add就明白这是用户管理模块的新增操作不用去查数据库文档。2.3 行级数据权限的模型选择角色上挂规则还是单独一张规则表接口权限解决的是“能不能访问”数据权限解决的是“访问后能看哪些行”。比如销售订单表A 业务员只能看自己的单子销售主管可以看本部门的单子总监可以看全部。如果把“看全部”做成一个普通按钮权限点还行但“看本部门”就需要动态参数——部门是随用户职位变化而变化的。风汐的方案是把数据权限做成独立配置规则表达式以 JSON 形式挂到角色上。系统内置三种基础规则本人、本部门、全部另外预留自定义 SQL 条件的扩展点。模型层面有一条铁律数据权限规则绝对不能散落在业务代码里。凡是散落在业务代码里的数据范围判断最后都会变成权限漏洞的温床因为每个开发写出来的过滤条件都不一样有的用user_id ?有的用dept_id IN (?)有的只在查询结果里用 Java 内存过滤性能和安全性都很糟糕。2.4 审计字段与扩展字段的取舍五张表我都加了 create_time、update_time、create_by 这些审计字段。权限管理本身属于敏感操作谁在什么时候改了谁的权限必须可追溯。我单独建了一张操作日志表日志记录的不是简单 HTTP 请求而是业务语义例如“管理员 A 把角色‘运营专员’的‘订单审核’权限移除了”这样的完整描述。这样即使某天线上权限出了问题也能通过日志快速定位到具体操作者和变更时间。另外给角色表留了 remark 字段给权限表留了 status 字段。别小看权限表的 status状态字段可以控制权限点的启停上线前可以先把新权限点禁用掉灰度验证通过之后再启用全程不影响历史数据和线上业务。3. 后端权限校验链路注解、拦截器与缓存怎么配合表结构设计完成之后就进入权限校验链路的实现。我把它拆成三段先确定当前请求是谁发起的再判断这个用户有没有目标权限最后看他在数据层面能看到多少行。这三个问题分别对应登录态解析、注解校验和数据权限拼接。3.1 权限码三段式的语义设计接口权限的校验方式最直观的是在 Controller 方法上加自定义注解。开发人员只需要声明这个接口需要什么权限完全不用管当前用户是谁、角色又是什么RequiresPermission(system:user:add) PostMapping(/user) public Result addUser(RequestBody UserVO vo) { // 业务逻辑完全不关心调用方身份 return userService.add(vo); }校验逻辑用 Spring AOP 实现。切面里先拿到当前登录用户的信息从 Token 解析出来放在 ThreadLocal 里再取用户拥有的权限码集合最后判断这个集合是否满足接口要求的权限码。满足就放行不满足就抛出权限不足的异常。这份“权限码集合”是整条链路里反复读的数据非常值得做缓存我在后面小节专门说。3.2 注解AOP的实现逻辑风汐里的校验切面核心逻辑大概是这样的Aspect Component public class PermissionAspect { Around(annotation(requiresPermission)) public Object checkPermission(ProceedingJoinPoint joinPoint, RequiresPermission requiresPermission) throws Throwable { String required requiresPermission.value(); SetString perms PermissionHolder.getCurrentUserPerms(); if (!PermMatcher.hasPermission(perms, required)) { throw new PermissionDeniedException(缺少权限: required); } return joinPoint.proceed(); } }PermMatcher 内部要处理通配符匹配用户持有*、order:*、order:audit:*这些权限码时如何判断。实现思路是把用户拥有的权限码按冒号分段每一段都和要求的权限码逐级对比遇到通配符就返回匹配成功。有一点要特别注意AOP 切面只负责处理标注了RequiresPermission的接口。那些没加注解的接口默认应该按“不校验”还是“全部拒绝”处理必须在一开始就想清楚。我的建议是默认全部拒绝只放行显式声明了权限码的接口防止新增接口时忘记加注解变成裸奔接口。3.3 行级权限在 SQL 层的位置拦截器动态拼接条件接口权限搞定之后行级权限是真正费心的地方。风汐的做法是在 MyBatis 拦截器里拦截 Executor.query 方法解析 SQL 之后根据当前用户的数据权限规则自动在语句中追加 WHERE 条件。业务层写的查询不带任何权限过滤拦截器帮忙把“只能看自己数据”这类条件拼进去。内置三种基础规则常见的实现方式如下规则代码语义拼接后的SQL条件示例SCOPE_SELF仅本人user_id 当前用户IDSCOPE_DEPT本部门及下属部门dept_id IN (当前部门及子部门ID列表)SCOPE_ALL全部数据不追加条件这里有一个特别值得提醒的坑对于分页 SQLcount 语句和 limit 语句都必须追加同一套权限条件否则会出现“总条数是 100列表却只有 20 条”或者反过来“总条数是 10下一页还能翻出数据”的问题。风汐在处理分页插件适配时专门验证过把条件拼接逻辑抽成公共方法count 和 page 两条路径共用一份代码这样就不会出现两边条件不一致的情况。3.4 缓存与强一致权限变更后如何立刻生效权限校验链路里每个请求都去数据库查用户角色、角色权限的话数据量上来之后性能马上会出问题。风汐用 Redis 做权限缓存key 设计为user:perms:{userId}value 是权限码集合。用户登录成功后把权限码批量加载进缓存后续请求直接读缓存判断。缓存带来的最大风险是权限变更后的滞后。如果管理员刚刚把某个用户的某个权限撤销了但 Redis 里缓存还留着旧权限用户依然能访问对应接口这是权限系统里不能接受的问题。风汐的解决办法是只要发生了权限绑定关系变化——给角色绑定权限、解绑权限、给用户分配角色、移除角色、删除角色——都会调用权限刷新 Service主动删除相关用户的user:perms:{userId}缓存 key。删除缓存比更新缓存更可靠因为下次请求发现 key 不存在会自动重新计算并加载最新权限省去了复杂的一致性维护逻辑。4. 前端按钮级权限与动态菜单的落地细节后端校验再严格前端体验也不能拖后腿。很多人以为前端权限只是“菜单别显示不该看的”实际上按钮级权限的细节更多。这一块的坑我踩得比后端多得多。4.1 动态菜单登录后拉取权限树再注册路由后台管理系统的菜单必须根据当前用户的权限动态生成。用户登录之后后端返回权限列表前端拿这份列表渲染菜单树。这里要区分“动态菜单”和“动态路由”动态菜单是用户看到的导航动态路由是实际可访问的页面路径。风汐的做法是路由表拆成公共路由和动态路由两部分动态部分在登录后根据权限码匹配页面组件再通过路由注册接口挂载。用户没有权限的页面即使手动改 URL 路径也会因为路由不存在而跳转回首页。刷新页面的问题是这一块最容易踩的坑。前端状态管理比如 Vuex 或 Pinia在刷新后会清空如果刷新时没有重新拉取权限数据动态路由和菜单就会消失用户会直接掉回登录页。解决办法是在路由守卫的每次跳转里判断状态中是否存在权限数据没有就调用一次获取权限接口等拿到数据再放行。这个判断逻辑虽然简单但能避免掉绝大多数“刷新丢菜单”的线上问题。4.2 按钮级控制的两种常见写法按钮权限是后台管理系统里最通用的需求风汐提供了两种主流写法。第一种是自定义指令适合“没有权限就移除 DOM”的场景// main.js app.directive(permission, { mounted(el, binding) { const required binding.value; if (!hasPermission(required)) { el.parentNode?.removeChild(el); } } });在模板里使用时非常简洁el-button v-permissionorder:audit:pass审核通过/el-button第二种是权限判断函数适合“禁用但保留按钮”的场景el-button :disabled!hasPermission(order:audit:pass)审核通过/el-button两者的使用场景有讲究。我推荐非关键操作查询、导出用禁用加 Tooltip 提示让用户感知到功能存在但当前账号不可用危险操作删除、审核、转账直接隐藏避免反编译或误触。如果一刀切全部隐藏用户会以为系统坏了如果全部禁用鼠标滑过一堆灰色按钮也给体验添堵。4.3 刷新页面按钮消失的根因与解决“第二天刷新页面按钮不显示了”这个问题在社区里几乎成了日经贴原因是权限数据没有被持久化。如果hasPermission函数读的是页面内存变量刷新之后内存清空按钮自然就消失。解决方式有两个一是把用户权限码存放在 localStorage/sessionStorage刷新时直接读二是每次刷新都重新请求接口拉取权限。两种都试过之后我更推荐第二种。权限码是敏感信息存本地有被读取和篡改的风险而且会出现“账号权限已经被后端撤销但浏览器里还留着按钮”这种前后端不同步的问题。5. 踩坑实录权限系统里最容易翻车的六个场景最后这部分全是我开发、上线和运维过程中遇到过的真问题。每个问题都对应了一个具体的修复方案希望能帮你避开相同的坑。5.1 超级管理员的通配权限是“*”别把全部权限手动挂上去很多新手在设计超管角色时会手动把所有权限点都绑定到“超级管理员”这个角色上。这是典型的治标不治本。权限点是持续增加的每加一个新页面、新按钮就要回头维护超管的角色绑定关系漏掉一次超管自己都进不了新页面。正确做法是在匹配逻辑里使用通配符*表示全部权限超管在角色权限绑定表里甚至可以没有记录PermMatcher 遇到超管直接放行。匹配顺序上先判断通配符再判断精确权限码防止出现权限码本身带有通配语义造成歧义。5.2 删除角色和删除权限之后的连锁反应删除角色时不能物理删除。如果用户-角色关联表里已经有几十条记录指向这个角色物理删除后这些记录就成了孤儿数据用户的权限状态完全不可控。风汐给角色表加了一个 deleted 标记删除角色只是把状态置为禁用历史关联数据保留。权限表同理物理删除权限会有更大的连锁反应所有引用这个权限的角色在数据库中都会少一条关系如果一个角色本来只有这一个权限删完它的权限集合就空了。因此权限的删除操作我做了逻辑禁用不走物理删除权限码保留在表里保证历史数据完整可查。5.3 在线用户权限过期踢下线还是等缓存失效权限变更后缓存 key 被删除了但用户当前页面上已经渲染出来的按钮还是旧的。这个问题前端解决不了后端再严格也只能保证“下一次请求一定是新的权限”。我的经验是分场景普通业务权限下次请求刷新即可安全等级高的权限比如管理员被移出超管角色需要强制该用户重新登录。实现方式是引入 Token 版本号机制权限变更时版本号加一旧版本 Token 发起的请求会被拒绝用户被迫重新登录获取新状态。这个机制看起来简单但能在权限注销场景里起到关键作用。5.4 部门调整后行级数据权限的收敛问题行级权限最容易出问题的点是部门调整。一个用户原本在 A 部门能看到 A 部门的数据调到 B 部门之后如果数据权限规则是“按当前用户的部门实时查询”那不需要改任何东西权限范围自动变成 B 部门。但如果是“在用户表里冗余一个部门ID字段权限判断时读这个字段”这种设计就会出现用户被调到 B 部门后仍然能看到 A 部门数据的尴尬情况。风汐在数据权限规则里特别强调部门 ID 必须在每次请求时实时解析不能缓存。一次性的权限缓存可以提升性能但把“当前属于哪个部门”也缓存进去就会导致职位调整后权限范围不跟着变。5.5 权限码改名的代价以ID还是以权限码作为关联键权限表的主键是自增 ID但业务里真正使用的标识是权限码。如果系统里到处用“权限 ID”做关联权限码一旦改名所有引用这个权限的代码、配置、日志都要跟着调整牵一发而动全身。最佳实践是权限码作为业务唯一键ID 只作为物理主键。角色-权限表的关联可以关联权限 ID但权限码的修改不能直接改库要提供一个“重命名权限”的接口由系统自动同步所有引用到该权限 ID 的角色配置同时保留旧的权限码映射关系一段时间避免线上服务还在使用旧权限码时直接找不到权限。5.6 性能隐患递归查权限树与角色多权限点的缓存策略权限表是树形结构前端渲染菜单时如果每条菜单都单独查一次数据库很容易出现 N 次查询的性能问题。风汐的做法是一次性查出当前用户可见的整棵权限树在前端用 JS 递归拼装成菜单结构数据库只查询一次。后端加载用户权限时也一样先把用户所有角色的权限 ID 集合合并去重再批量查询权限码避免在循环里逐条查询数据库。用户很多、权限点也很多时合并后的权限码集合可能非常庞大。比如一个大部门里每个用户平均挂 300 个权限码几千个用户的场景Redis 存储和网络传输的开销都不小。我的处理策略是对权限码集合做序列化压缩后再存入缓存读取时解压。实测在 3 万用户、平均每人 300 个权限码的场景下压缩后的 Redis 读取耗时基本在 1 毫秒以内完全不需要做复杂的多维索引方案。个人经历告诉我权限系统的复杂度不在 RBAC 模型本身而在于边界情况。模型层少想一步代码层就要多填几个坑。这套系统之后我还计划往两个方向扩展一是把数据权限规则做成可视化表达式编辑器让运营人员可以在后台直接配置“订单金额大于 X 且状态为已完成”这类条件二是把权限审计记录细化到权限点级别不只是在日志里记操作类型。等有新实践了我再回来补充。
返回列表