第 66 章:RBAC 权限模型
学习目标
- 深入理解 RBAC0/1/2/3 四种模型
- 设计完整的用户-角色-权限数据库结构
- 实现按钮级、菜单级、数据级三种粒度的权限控制
一、什么是 RBAC?
RBAC = Role-Based Access Control,基于角色的访问控制。
核心思想:不直接给用户赋权限,而是给用户分配角色,给角色赋权限。 改一次角色权限,所有该角色的用户都生效。
二、RBAC 四层模型
| 模型 | 含义 | 实战价值 |
|---|---|---|
| RBAC0 | 用户-角色-权限基本关系 | 90% 系统的核心 |
| RBAC1 | 角色继承(主管继承员工) | 大型组织 |
| RBAC2 | 角色互斥(财务不能兼出纳) | 合规要求 |
| RBAC3 | RBAC1 + RBAC2 | 复杂权限系统 |
三、数据库设计
基础五张表
sql
-- ① 用户表
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
nickname VARCHAR(50),
password VARCHAR(100) NOT NULL,
status TINYINT DEFAULT 1 COMMENT '0=禁用 1=正常',
dept_id BIGINT COMMENT '部门 ID',
tenant_id BIGINT NOT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
is_deleted TINYINT DEFAULT 0,
KEY idx_dept (dept_id)
);
-- ② 角色表
CREATE TABLE sys_role (
id BIGINT PRIMARY KEY,
name VARCHAR(50) NOT NULL COMMENT '角色名称',
code VARCHAR(50) UNIQUE NOT NULL COMMENT '角色编码:ROLE_ADMIN',
sort INT DEFAULT 0,
status TINYINT DEFAULT 1,
data_scope TINYINT DEFAULT 4 COMMENT '数据权限范围',
tenant_id BIGINT NOT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
is_deleted TINYINT DEFAULT 0
);
-- ③ 菜单/权限表(合一)
CREATE TABLE sys_menu (
id BIGINT PRIMARY KEY,
parent_id BIGINT DEFAULT 0,
name VARCHAR(50) NOT NULL COMMENT '菜单名',
type TINYINT NOT NULL COMMENT '1=目录 2=菜单 3=按钮',
permission VARCHAR(100) COMMENT '权限标识:user:create',
path VARCHAR(200) COMMENT '前端路径',
component VARCHAR(200) COMMENT '前端组件',
icon VARCHAR(50),
sort INT DEFAULT 0,
status TINYINT DEFAULT 1,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
is_deleted TINYINT DEFAULT 0
);
-- ④ 用户-角色关系
CREATE TABLE sys_user_role (
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
PRIMARY KEY (user_id, role_id),
KEY idx_role (role_id)
);
-- ⑤ 角色-菜单/权限关系
CREATE TABLE sys_role_menu (
role_id BIGINT NOT NULL,
menu_id BIGINT NOT NULL,
PRIMARY KEY (role_id, menu_id)
);角色继承(RBAC1)
sql
-- 父角色继承子角色
CREATE TABLE sys_role_relation (
parent_role_id BIGINT NOT NULL,
child_role_id BIGINT NOT NULL,
PRIMARY KEY (parent_role_id, child_role_id)
);
-- 主管继承组员的所有权限角色互斥(RBAC2)
sql
-- 不能同时拥有的角色组合
CREATE TABLE sys_role_conflict (
role_id_1 BIGINT NOT NULL,
role_id_2 BIGINT NOT NULL,
PRIMARY KEY (role_id_1, role_id_2)
);
-- 财务和出纳不能同时分配四、菜单权限设计
sql
INSERT INTO sys_menu (id, parent_id, name, type, permission, path) VALUES
-- 一级目录
(1, 0, '系统管理', 1, NULL, '/system'),
-- 二级菜单
(10, 1, '用户管理', 2, NULL, '/system/user'),
(11, 1, '角色管理', 2, NULL, '/system/role'),
(12, 1, '菜单管理', 2, NULL, '/system/menu'),
-- 三级按钮
(100, 10, '用户新增', 3, 'user:create', NULL),
(101, 10, '用户编辑', 3, 'user:update', NULL),
(102, 10, '用户删除', 3, 'user:delete', NULL),
(103, 10, '用户查询', 3, 'user:query', NULL);type 字段是精髓:
1= 目录(左侧菜单分组)2= 菜单(左侧菜单项 + 路由)3= 按钮(页面内的按钮)
五、查询当前用户的权限
java
@Service
@RequiredArgsConstructor
public class PermissionService {
private final RedisTemplate<String, Object> redis;
private final UserMapper userMapper;
private final RoleMapper roleMapper;
private final MenuMapper menuMapper;
// ① 获取用户的所有权限(从 Redis 缓存)
public Set<String> getUserPermissions(Long userId) {
Set<String> cached = (Set<String>) redis.opsForValue()
.get("user:perms:" + userId);
if (cached != null) return cached;
// ② 从 DB 查询
Set<String> perms = new HashSet<>();
List<Role> roles = roleMapper.selectByUserId(userId);
for (Role role : roles) {
List<Menu> menus = menuMapper.selectByRoleId(role.getId());
for (Menu menu : menus) {
if (menu.getType() == 3 && menu.getPermission() != null) {
perms.add(menu.getPermission()); // user:create 等
}
}
}
// ③ 写缓存(30 分钟)
redis.opsForValue().set(
"user:perms:" + userId, perms, 30, TimeUnit.MINUTES);
return perms;
}
// ④ 获取用户的菜单树(前端用)
public List<MenuTreeVO> getUserMenuTree(Long userId) {
List<Menu> all = menuMapper.selectByUserId(userId);
return buildTree(all, 0L);
}
private List<MenuTreeVO> buildTree(List<Menu> all, Long parentId) {
return all.stream()
.filter(m -> m.getParentId().equals(parentId))
.map(m -> {
MenuTreeVO vo = new MenuTreeVO();
BeanUtils.copyProperties(m, vo);
vo.setChildren(buildTree(all, m.getId()));
return vo;
})
.sorted(Comparator.comparing(MenuTreeVO::getSort))
.toList();
}
// ⑤ 权限变更时清理缓存
public void clearCache(Long userId) {
redis.delete("user:perms:" + userId);
}
}六、权限注解
java
// ① 按钮级权限(细粒度)
@Service
public class UserService {
@PreAuthorize("hasAuthority('user:create')")
public void create(UserDTO dto) { ... }
@PreAuthorize("hasAuthority('user:update')")
public void update(UserDTO dto) { ... }
@PreAuthorize("hasAuthority('user:delete')")
public void delete(Long id) { ... }
}
// ② 自定义注解(更可读)
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasAuthority('{value}')")
public @interface RequiresPermission {
String value(); // "user:create"
}
// 用法
@RequiresPermission("user:create")
public void create(UserDTO dto) { ... }java
// ③ 自定义注解(角色)
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('{value}')")
public @interface RequiresRole {
String value();
}
@RequiresRole("ADMIN")
@DeleteMapping("/{id}")
public void delete(@PathVariable Long id) { ... }七、数据权限
场景:销售只能看自己部门的订单,主管能看整个大区,总裁看全部。
sql
-- 数据权限范围
sys_role.data_scope:
1 -- 全部
2 -- 本部门及下级
3 -- 本部门
4 -- 仅本人
5 -- 自定义(指定部门)java
@Service
@RequiredArgsConstructor
public class DataScopeService {
private final SecurityUtils securityUtils;
private final UserMapper userMapper;
// ① 数据权限过滤
public void applyDataScope(Wrapper<Order> wrapper) {
User currentUser = securityUtils.getCurrentUser();
Role currentRole = currentUser.getRoles().get(0); // 简化:取第一个角色
Integer scope = currentRole.getDataScope();
switch (scope) {
case 1: // 全部:不加条件
break;
case 2: // 本部门及下级
List<Long> deptIds = getDeptAndChildren(currentUser.getDeptId());
wrapper.in("dept_id", deptIds);
break;
case 3: // 本部门
wrapper.eq("dept_id", currentUser.getDeptId());
break;
case 4: // 仅本人
wrapper.eq("create_by", currentUser.getId());
break;
case 5: // 自定义
List<Long> customDeptIds = customDeptMapper
.selectByRoleId(currentRole.getId());
wrapper.in("dept_id", customDeptIds);
break;
}
}
}用 MyBatis-Plus 拦截器实现(更优雅)
java
public class DataScopeInnerInterceptor implements InnerInterceptor {
@Override
public void beforeQuery(Executor executor, MappedStatement ms,
Object parameter, RowBounds rowBounds,
ResultHandler resultHandler, BoundSql boundSql) {
// ① 拿到当前 SQL
String sql = boundSql.getSql();
// ② 拿到当前用户的数据权限范围
DataScope dataScope = SecurityUtils.getCurrentDataScope();
// ③ 拼接条件
String newSql = appendCondition(sql, dataScope);
// ④ 用反射改写 BoundSql 的 SQL(略)
// 实际项目建议重写 MetaObject
}
}八、菜单权限的 Vue 集成
js
// src/store/modules/permission.js
export const usePermissionStore = defineStore('permission', {
state: () => ({
menus: [], // 当前用户的菜单树
permissions: [], // 当前用户的按钮权限
}),
actions: {
async loadPermissions() {
// ① 调接口获取
const { data } = await api.getUserMenus()
this.menus = data.menus
this.permissions = data.permissions
},
hasPermission(code) {
return this.permissions.includes(code)
},
},
})
// ② 在按钮上用 v-permission 指令
<el-button v-permission="['user:create']">新增用户</el-button>
<el-button v-permission="['user:delete']">删除</el-button>
// ③ 自定义指令
app.directive('permission', {
mounted(el, binding) {
const allowed = binding.value
const has = usePermissionStore().hasPermission(allowed)
if (!has) el.parentNode?.removeChild(el)
},
})九、权限设计的反模式
反模式 1:权限颗粒度过细
sql
-- ❌ 每个按钮都建一个权限
'user:create', 'user:update', 'user:delete', 'user:export',
'user:import', 'user:reset-password', 'user:enable', 'user:disable',
'user:avatar-upload', 'user:profile-update', ...
-- 几十个按钮 → 维护噩梦
-- ✅ 按业务场景分组
'user:manage' -- 用户管理(增删改查)
'user:export' -- 导出(如果业务需要单独控制)反模式 2:权限耦合到 URL
sql
-- ❌ 用 URL 当权限标识
'GET /api/user/list'
'POST /api/user'
-- ✅ 用业务标识
'user:query'
'user:create'反模式 3:权限检查散落在代码各处
java
// ❌ 每个方法都写 if 判断
if (currentUser.getId() != order.getUserId()) {
throw new BusinessException("无权查看");
}
// ✅ 集中在拦截器 / 注解
@PreAuthorize("hasAuthority('order:view') or #order.userId == authentication.principal.id")
public Order getOrder(Long id) { ... }十、本章小结
| 要点 | 关键 |
|---|---|
| 模型 | RBAC0 用户-角色-权限(核心) |
| 表设计 | 5 张基础表(user / role / menu / user_role / role_menu) |
| 菜单 type | 1=目录 2=菜单 3=按钮 |
| 按钮权限 | @PreAuthorize("hasAuthority('user:create')") |
| 菜单权限 | 返回菜单树 + 前端动态生成路由 |
| 数据权限 | data_scope:全部 / 本部门 / 仅本人 / 自定义 |
| 性能 | 权限列表 Redis 缓存(30 分钟)+ 权限变更主动清理 |
| 颗粒度 | 按业务场景分组,不要太细 |
动手练习
练习 1:基础题
设计并实现完整 RBAC:5 张表 + 用户管理 CRUD 接口 + 给角色分配菜单/权限的接口。
练习 2:进阶题
实现"数据权限":销售部员工只能看自己部门的订单,主管能看整个销售部,总裁看所有部门。用 MyBatis-Plus 拦截器自动拼接条件。
练习 3:思考题
你的 SaaS 系统支持多租户,多个租户共用一套代码。如何让每个租户独立管理自己的角色和权限?
下一章:第 67 章:接口文档 OpenAPI →