RBAC 权限模型 4 种数据范围实现:MyBatis-Plus 拦截器自动注入 SQL
RBAC 权限模型四种数据范围实现
引言
在企业级应用中,权限控制不仅仅是"能不能访问"的问题,更核心的是"能看到哪些数据"。传统的 RBAC(Role-Based Access Control)模型解决了功能权限的问题,但在数据权限层面往往力不从心。
例如:销售总监能看到所有订单,区域经理只能看到本区域订单,普通销售只能看到自己的订单。这种数据范围控制是权限系统的进阶需求。
本文将分享 RBAC 权限模型中四种数据范围的实现方案,以及如何通过 MyBatis-Plus 拦截器自动注入 SQL 条件,实现无侵入的数据权限控制。
一、RBAC 模型与数据范围
1.1 RBAC 基础模型
erDiagram
USER ||--o{ USER_ROLE : "拥有"
ROLE ||--o{ USER_ROLE : "分配"
ROLE ||--o{ ROLE_MENU : "关联"
MENU ||--o{ ROLE_MENU : "被关联"
ROLE ||--o{ ROLE_DEPT : "关联"
DEPT ||--o{ ROLE_DEPT : "被关联"
USER {
bigint id PK
varchar username
bigint dept_id FK
}
ROLE {
bigint id PK
varchar role_name
varchar data_scope
}
DEPT {
bigint id PK
varchar dept_name
bigint parent_id
}
MENU {
bigint id PK
varchar menu_name
varchar permission
}
1.2 四种数据范围定义
| 数据范围 | data_scope 值 | 说明 | SQL 效果 |
|---|---|---|---|
| 全部数据 | 1 | 不做任何过滤 | 无额外条件 |
| 本部门数据 | 2 | 仅查看本部门 | dept_id = 当前用户部门ID |
| 本部门及下级 | 3 | 查看本部门及子部门 | dept_id IN (本部门及子部门ID列表) |
| 仅本人 | 4 | 仅查看自己创建的 | create_by = 当前用户ID |
二、数据范围实现方案
2.1 角色数据范围配置
在角色表中增加 data_scope 字段,标识该角色的数据范围类型:
/**
* 角色实体
*/
public class Role {
private Long id;
private String roleName;
private String roleKey;
/** 数据范围:1-全部 2-本部门 3-本部门及下级 4-仅本人 */
private Integer dataScope;
}
2.2 数据范围上下文
在请求处理过程中,需要获取当前用户的数据范围信息,通过 ThreadLocal 传递:
/**
* 数据权限上下文
*/
public class DataScopeContext {
private static final ThreadLocal<DataScope> CONTEXT = new ThreadLocal<>();
/**
* 设置当前用户数据范围
*/
public static void set(DataScope scope) {
CONTEXT.set(scope);
}
/**
* 获取当前用户数据范围
*/
public static DataScope get() {
return CONTEXT.get();
}
/**
* 清除上下文
*/
public static void clear() {
CONTEXT.remove();
}
}
/**
* 数据范围信息
*/
public record DataScope(
Long userId, // 当前用户ID
Long deptId, // 当前用户部门ID
Integer dataScope, // 数据范围类型
List<Long> deptIds // 部门ID列表(本部门及下级时使用)
) {}
2.3 登录时注入数据范围
用户登录后,根据其角色的数据范围设置上下文:
/**
* 登录后设置数据权限上下文
*/
public void setupDataScope(Long userId) {
// 获取用户角色(取最大权限的角色)
Role role = roleMapper.selectMaxDataScopeRole(userId);
DataScope scope;
switch (role.getDataScope()) {
case 1 -> scope = new DataScope(userId, null, 1, null);
case 2 -> {
Long deptId = userMapper.selectDeptIdByUserId(userId);
scope = new DataScope(userId, deptId, 2, List.of(deptId));
}
case 3 -> {
Long deptId = userMapper.selectDeptIdByUserId(userId);
// 递归查询本部门及所有子部门ID
List<Long> deptIds = deptMapper.selectDeptAndChildrenIds(deptId);
scope = new DataScope(userId, deptId, 3, deptIds);
}
case 4 -> scope = new DataScope(userId, null, 4, null);
default -> scope = new DataScope(userId, null, 4, null);
}
DataScopeContext.set(scope);
}
三、MyBatis-Plus 拦截器自动注入
3.1 核心思路
通过 MyBatis-Plus 的拦截器机制,在 SQL 执行前自动追加数据权限条件,业务代码无需关心数据过滤逻辑。
sequenceDiagram
participant C as Controller
participant S as Service
participant MP as MyBatis-Plus拦截器
participant DB as 数据库
C->>S: 查询列表
S->>MP: 执行SQL: SELECT * FROM order
MP->>MP: 获取DataScopeContext
MP->>MP: 根据dataScope追加WHERE条件
MP->>DB: SELECT * FROM order WHERE dept_id IN (...)
DB-->>C: 返回过滤后的数据
3.2 自定义数据权限拦截器
/**
* 数据权限拦截器
* 自动为SQL追加数据范围条件
*/
@Intercepts({
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class,
RowBounds.class, ResultHandler.class})
})
public class DataScopeInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
DataScope scope = DataScopeContext.get();
// 无数据范围信息或全部数据权限,直接放行
if (scope == null || scope.dataScope() == 1) {
return invocation.proceed();
}
// 获取原始SQL
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
Object parameter = args[1];
BoundSql boundSql = ms.getBoundSql(parameter);
String originalSql = boundSql.getSql();
// 追加数据权限条件
String newSql = applyDataScope(originalSql, scope);
// 替换SQL
resetSql(ms, boundSql, newSql, parameter);
return invocation.proceed();
}
/**
* 根据数据范围追加SQL条件
*/
private String applyDataScope(String sql, DataScope scope) {
String condition = switch (scope.dataScope()) {
case 2 -> "dept_id = " + scope.deptId();
case 3 -> "dept_id IN (" +
scope.deptIds().stream()
.map(String::valueOf)
.collect(Collectors.joining(",")) + ")";
case 4 -> "create_by = " + scope.userId();
default -> "1 = 0"; // 未知范围,拒绝所有
};
// 简单的SQL拼接(生产环境建议使用JSqlParser)
if (sql.toUpperCase().contains("WHERE")) {
return sql + " AND " + condition;
} else {
return sql + " WHERE " + condition;
}
}
}
3.3 使用 JSqlParser 精确解析(推荐)
生产环境推荐使用 JSqlParser 库精确解析和修改 SQL,避免字符串拼接带来的问题:
/**
* 使用JSqlParser解析并追加数据权限条件
*/
private String applyDataScopeWithParser(String sql, DataScope scope) {
try {
Statement statement = CCJSqlParserUtil.parse(sql);
Select select = (Select) statement;
PlainSelect plainSelect = (PlainSelect) select.getSelectBody();
// 构建数据权限条件表达式
Expression scopeCondition = buildScopeExpression(scope);
// 获取已有WHERE条件
Expression where = plainSelect.getWhere();
if (where != null) {
// AND连接
plainSelect.setWhere(new AndExpression(
new Parenthesis(where), scopeCondition));
} else {
plainSelect.setWhere(scopeCondition);
}
return select.toString();
} catch (JSQLParserException e) {
throw new RuntimeException("SQL解析失败", e);
}
}
/**
* 构建数据范围表达式
*/
private Expression buildScopeExpression(DataScope scope) {
return switch (scope.dataScope()) {
case 2 -> new EqualsTo(
new Column("dept_id"),
new LongValue(scope.deptId()));
case 3 -> new InExpression(
new Column("dept_id"),
new ExpressionList<>(scope.deptIds().stream()
.map(LongValue::new).collect(Collectors.toList())));
case 4 -> new EqualsTo(
new Column("create_by"),
new LongValue(scope.userId()));
default -> new LongValue(0).equals(new LongValue(1))
? null : null; // 1=0 拒绝
};
}
3.4 注册拦截器
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor(
DataScopeInterceptor dataScopeInterceptor) {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 数据权限拦截器(需在分页插件之前)
interceptor.addInnerInterceptor(new DataScopeInnerInterceptor());
// 分页插件
interceptor.addInnerInterceptor(
new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
四、部门树递归查询优化
4.1 部门表设计
CREATE TABLE sys_dept (
id BIGINT PRIMARY KEY,
dept_name VARCHAR(100) NOT NULL,
parent_id BIGINT DEFAULT 0,
sort_order INT DEFAULT 0,
INDEX idx_parent_id (parent_id)
);
4.2 递归查询子部门(MySQL 8.0 CTE)
-- 使用CTE递归查询本部门及所有子部门
WITH RECURSIVE dept_tree AS (
-- 锚点:本部门
SELECT id FROM sys_dept WHERE id = #{deptId}
UNION ALL
-- 递归:子部门
SELECT d.id FROM sys_dept d
INNER JOIN dept_tree dt ON d.parent_id = dt.id
)
SELECT id FROM dept_tree;
对应的 MyBatis-Plus 实现:
/**
* 递归查询部门及子部门ID列表
*/
default List<Long> selectDeptAndChildrenIds(Long deptId) {
return selectList(new LambdaQueryWrapper<SysDept>()
.apply("id IN (WITH RECURSIVE dept_tree AS " +
"(SELECT id FROM sys_dept WHERE id = {0} " +
"UNION ALL SELECT d.id FROM sys_dept d " +
"INNER JOIN dept_tree dt ON d.parent_id = dt.id) " +
"SELECT id FROM dept_tree)", deptId))
.stream()
.map(SysDept::getId)
.collect(Collectors.toList());
}
4.3 部门树缓存优化
频繁递归查询数据库性能较差,建议将部门树缓存到 Redis:
/**
* 获取部门及子部门ID(带缓存)
*/
public List<Long> getDeptAndChildrenIds(Long deptId) {
String cacheKey = "dept:tree:" + deptId;
// 先查缓存
List<Long> cached = redisTemplate.opsForList().range(cacheKey, 0, -1);
if (cached != null && !cached.isEmpty()) {
return cached;
}
// 查数据库
List<Long> deptIds = deptMapper.selectDeptAndChildrenIds(deptId);
// 写入缓存,过期时间1小时
redisTemplate.opsForList().rightPushAll(cacheKey, deptIds);
redisTemplate.expire(cacheKey, 1, TimeUnit.HOURS);
return deptIds;
}
五、数据范围处理流程总览
flowchart TD
A[用户请求] --> B[获取当前用户角色]
B --> C{角色dataScope?}
C -->|1-全部数据| D[不追加条件]
C -->|2-本部门| E[追加: dept_id = 用户部门ID]
C -->|3-本部门及下级| F[递归查询子部门ID]
F --> G[追加: dept_id IN 部门ID列表]
C -->|4-仅本人| H[追加: create_by = 用户ID]
D --> I[执行SQL返回结果]
E --> I
G --> I
H --> I
style D fill:#c8e6c9,stroke:#2e7d32
style E fill:#fff9c4,stroke:#f9a825
style G fill:#ffe0b2,stroke:#ef6c00
style H fill:#ffcdd2,stroke:#c62828
结论与建议
核心要点
- 数据范围是 RBAC 的进阶能力:功能权限解决"能不能做",数据权限解决"能看到什么"
- 拦截器方式零侵入:业务代码无需关心数据过滤,由拦截器统一处理
- JSqlParser 优于字符串拼接:生产环境务必使用 SQL 解析库,避免注入风险和语法错误
- 部门树缓存必不可少:递归查询是性能瓶颈,Redis 缓存可大幅提升响应速度
扩展建议
- 多角色取最大权限:用户拥有多个角色时,取数据范围最大的角色
- 自定义数据范围:部分场景需要指定部门列表,可扩展
data_scope = 5(自定义)并关联role_dept表 - 拦截器白名单:部分查询(如导出、报表)可能需要绕过数据权限,可通过注解标记