MyBatis-Plus 拦截器实现行级数据权限:JSqlParser 注入原理与 4 种数据范围方案
MyBatis-Plus拦截器实现数据权限原理
引言
在企业级应用中,数据权限控制是比功能权限更细粒度的安全需求。功能权限控制"能不能做",数据权限控制"能看到什么数据"。例如:销售经理只能查看本部门的客户数据,区域总监只能查看所属区域的数据。
MyBatis-Plus提供了强大的拦截器机制,允许在SQL执行前动态注入条件,从而实现透明的行级数据权限控制。本文将深入分析这一机制的实现原理。
一、数据权限的核心问题
1.1 什么是行级数据权限
行级数据权限是指在查询数据时,根据当前用户的权限范围,自动在SQL中添加过滤条件,限制返回的数据行。
-- 原始查询
SELECT * FROM customer WHERE status = 1
-- 注入数据权限后(当前用户只能看本部门数据)
SELECT * FROM customer WHERE status = 1 AND dept_id IN (1, 2, 3)
1.2 实现方案对比
graph TB
A[数据权限实现方案] --> B[代码层过滤]
A --> C[视图层过滤]
A --> D[SQL拦截注入]
B --> B1[每处查询手动加条件<br/>❌ 遗漏风险高]
C --> C2[创建数据库视图<br/>❌ 维护成本高]
D --> D3[拦截器自动注入<br/>✅ 透明无侵入]
style D3 fill:#d5f9d5
style B1 fill:#f9d5d5
style C2 fill:#f9d5d5
二、MyBatis-Plus拦截器机制
2.1 拦截器执行流程
MyBatis-Plus基于MyBatis的Interceptor机制,在SQL执行前对MappedStatement进行拦截和改写。
sequenceDiagram
participant App as 应用代码
participant MP as MyBatis-Plus
participant Interceptor as 数据权限拦截器
participant JSqlParser as SQL解析器
participant DB as 数据库
App->>MP: 执行查询
MP->>Interceptor: intercept()拦截
Interceptor->>JSqlParser: 解析原始SQL
JSqlParser-->>Interceptor: 返回AST语法树
Interceptor->>Interceptor: 获取当前用户权限规则
Interceptor->>Interceptor: 构建权限条件表达式
Interceptor->>JSqlParser: 注入条件到AST
JSqlParser-->>Interceptor: 返回改写后的SQL
Interceptor-->>MP: 继续执行
MP->>DB: 执行改写后的SQL
DB-->>App: 返回过滤后的数据
2.2 核心接口与类
MyBatis-Plus的数据权限拦截器核心类为DataPermissionInterceptor,它继承自JsqlParserSupport,利用JSqlParser库对SQL进行解析和改写。
/**
* 自定义数据权限拦截器
* 在SQL执行前自动注入数据权限条件
*/
public class CustomDataPermissionInterceptor extends JsqlParserSupport
implements InnerInterceptor {
private DataPermissionHandler dataPermissionHandler;
@Override
public void beforeQuery(Executor executor, MappedStatement ms,
Object parameter, RowBounds rowBounds,
ResultHandler resultHandler, BoundSql boundSql) {
// 如果是系统内部调用,跳过数据权限
if (InterceptorIgnoreHelper.willIgnoreDataPermission(ms.getId())) {
return;
}
// 解析并改写SQL
PluginUtils.MPBoundSql mpBs = PluginUtils.mpBoundSql(boundSql);
mpBs.sql(parserSingle(mpBs.sql(), ms.getId()));
}
@Override
public void processSelect(Select select, int index, String sql,
Object obj) {
SelectBody selectBody = select.getSelectBody();
if (selectBody instanceof PlainSelect) {
// 处理普通SELECT语句
this.processPlainSelect((PlainSelect) selectBody);
} else if (selectBody instanceof SetOperationList) {
// 处理UNION等集合操作
SetOperationList setOpList = (SetOperationList) selectBody;
List<SelectBody> selectBodies = setOpList.getSelects();
selectBodies.forEach(sb -> {
if (sb instanceof PlainSelect) {
this.processPlainSelect((PlainSelect) sb);
}
});
}
}
/**
* 处理普通SELECT,注入WHERE条件
*/
protected void processPlainSelect(PlainSelect plainSelect) {
Expression sqlSegment = dataPermissionHandler.getSqlSegment(
plainSelect.getWhere(), plainSelect);
if (sqlSegment != null) {
plainSelect.setWhere(sqlSegment);
}
}
}
三、SQL条件注入原理
3.1 JSqlParser解析SQL
JSqlParser将SQL语句解析为抽象语法树(AST),每个SQL元素对应一个Java对象:
graph TB
A["SELECT * FROM user WHERE status = 1"] --> B[Select对象]
B --> C[SelectBody: PlainSelect]
C --> D[SelectItems: *]
C --> E[FromItem: user表]
C --> F[Where: status = 1]
F --> G[注入权限条件后]
G --> H["WHERE status = 1 AND dept_id IN (1,2,3)"]
style G fill:#d5f9d5
style H fill:#d5f9d5
3.2 条件注入实现
/**
* 数据权限处理器
* 根据当前用户权限生成SQL条件
*/
@Component
public class CustomDataPermissionHandler implements DataPermissionHandler {
@Autowired
private SecurityUtils securityUtils;
@Autowired
private DataScopeService dataScopeService;
/**
* 获取数据权限SQL片段
* @param where 原始WHERE条件
* @param mappedStatementId Mapper方法的完整路径
* @return 注入权限条件后的WHERE表达式
*/
@Override
public Expression getSqlSegment(Expression where, String mappedStatementId) {
// 获取当前用户
LoginUser currentUser = securityUtils.getCurrentUser();
if (currentUser == null || currentUser.isAdmin()) {
return where; // 管理员不受数据权限限制
}
// 获取用户的数据权限规则
DataScope dataScope = dataScopeService.getDataScope(currentUser.getUserId());
// 构建权限条件
Expression scopeExpression = buildScopeExpression(dataScope);
if (scopeExpression == null) {
return where;
}
// 将权限条件与原始WHERE合并
if (where == null) {
return scopeExpression;
}
return new AndExpression(where, scopeExpression);
}
/**
* 根据数据范围构建SQL条件
*/
private Expression buildScopeExpression(DataScope scope) {
switch (scope.getScopeType()) {
case ALL:
// 全部数据,不添加条件
return null;
case DEPT_ONLY:
// 仅本部门
return buildInExpression("dept_id",
Collections.singletonList(scope.getDeptId()));
case DEPT_AND_SUB:
// 本部门及子部门
return buildInExpression("dept_id", scope.getDeptIds());
case CUSTOM:
// 自定义数据范围
return buildCustomExpression(scope.getCustomRules());
default:
return null;
}
}
/**
* 构建IN条件表达式
* 生成: column_name IN (value1, value2, ...)
*/
private Expression buildInExpression(String columnName, List<Long> values) {
Column column = new Column(columnName);
// 使用JSqlParser构建IN表达式
ItemsList itemsList = new ExpressionList(
values.stream()
.map(v -> new LongValue(v))
.collect(Collectors.toList())
);
return new InExpression(column, itemsList);
}
}
3.3 多表关联时的条件注入
当SQL涉及多表JOIN时,需要精确定位权限条件应该加在哪个表上:
/**
* 处理多表关联的数据权限注入
*/
protected void processJoinSelect(PlainSelect plainSelect) {
FromItem fromItem = plainSelect.getFromItem();
List<Join> joins = plainSelect.getJoins();
// 处理主表
if (fromItem instanceof Table) {
Table table = (Table) fromItem;
String tableName = table.getName();
Expression tableCondition = getTableCondition(tableName);
if (tableCondition != null) {
plainSelect.setWhere(mergeCondition(
plainSelect.getWhere(), tableCondition));
}
}
// 处理JOIN表
if (joins != null) {
for (Join join : joins) {
FromItem rightItem = join.getRightItem();
if (rightItem instanceof Table) {
Table table = (Table) rightItem;
String tableName = table.getName();
Expression tableCondition = getTableCondition(tableName);
if (tableCondition != null) {
// JOIN表的权限条件添加到ON子句
join.setOnExpression(mergeCondition(
join.getOnExpressions().iterator().next(),
tableCondition));
}
}
}
}
}
四、拦截器注册与配置
4.1 注册拦截器到MyBatis-Plus
/**
* MyBatis-Plus配置类
* 注册数据权限拦截器
*/
@Configuration
public class MybatisPlusConfig {
@Autowired
private CustomDataPermissionHandler dataPermissionHandler;
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 数据权限拦截器(需放在分页拦截器之前)
CustomDataPermissionInterceptor dataPermissionInterceptor =
new CustomDataPermissionInterceptor();
dataPermissionInterceptor.setDataPermissionHandler(dataPermissionHandler);
interceptor.addInnerInterceptor(dataPermissionInterceptor);
// 分页拦截器
interceptor.addInnerInterceptor(
new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
4.2 忽略数据权限的场景
某些场景需要绕过数据权限(如系统内部调用、管理员操作),可通过注解实现:
/**
* 数据权限忽略注解
* 标注在Mapper方法上,表示该方法不受数据权限控制
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface DataPermissionIgnore {
}
// 在拦截器中检查注解
@Override
public void beforeQuery(Executor executor, MappedStatement ms, ...) {
// 检查Mapper方法是否标注了忽略注解
String mapperId = ms.getId();
if (isDataPermissionIgnored(mapperId)) {
return;
}
// ... 正常数据权限处理
}
五、完整执行流程
flowchart TB
A[Mapper方法调用] --> B{是否有忽略注解?}
B -->|是| C[直接执行原始SQL]
B -->|否| D[拦截器拦截]
D --> E[JSqlParser解析SQL]
E --> F{获取当前用户}
F -->|未登录| G[抛出未认证异常]
F -->|管理员| C
F -->|普通用户| H[查询用户数据权限规则]
H --> I{权限范围类型}
I -->|全部数据| C
I -->|本部门| J[注入 dept_id = 当前部门ID]
I -->|本部门及子部门| K[注入 dept_id IN 部门ID列表]
I -->|自定义| L[注入自定义SQL条件]
J --> M[合并条件到原始SQL]
K --> M
L --> M
M --> N[执行改写后的SQL]
N --> O[返回过滤后的数据]
style C fill:#d5f9d5
style G fill:#f9d5d5
style N fill:#d5f9d5
结论与建议
核心优势
- 透明无侵入:业务代码无需关心数据权限,拦截器自动处理
- 集中管控:权限规则统一管理,避免遗漏
- 灵活可扩展:支持多种数据范围类型,可自定义规则
实践建议
- 拦截器顺序:数据权限拦截器必须放在分页拦截器之前,否则分页查询的COUNT语句不会被注入权限条件
- 性能优化:权限规则应缓存到Redis,避免每次查询都访问数据库获取权限配置
- 注解控制:提供忽略注解,让系统内部调用可以绕过数据权限
- SQL兼容性:JSqlParser对复杂SQL(如嵌套子查询、存储过程调用)的解析可能存在局限,需充分测试
- 日志审计:记录数据权限条件的注入日志,便于排查权限问题