1. 前言
在多租户或多部门系统中,我们希望开发者在写 SQL(如 SELECT * FROM sys_user)时,不需要手动拼接 WHERE tenant_id = 1 或 WHERE dept_id = 100。
本文将基于 MyBatis-Plus 的拦截器机制,结合 JSqlParser 和 Spring AOP,实现一套灵活的、支持注解控制的数据权限框架。
2. 核心架构设计
整个方案可以分为三个层级:
- 策略层 (Rule):定义具体的过滤规则(如部门规则、租户规则)。
- 执行层 (Handler & Factory):负责在 SQL 执行时,将规则拼接进 SQL。
- 控制层 (Annotation & AOP):负责通过注解动态开启、关闭或过滤规则。
核心交互时序图:
DataPermissionRule (具体规则) RuleFactory (决策工厂) RuleHandler (执行工头) ContextHolder (上下文) AnnotationInterceptor (AOP 切面) 业务调用 (Service) DataPermissionRule (具体规则) RuleFactory (决策工厂) RuleHandler (执行工头) ContextHolder (上下文) AnnotationInterceptor (AOP 切面) 业务调用 (Service) 阶段一:环境准备 (AOP) 阶段二:SQL 拦截与改写 (MyBatis-Plus) alt [匹配成功] [不匹配] loop [遍历每一个规则] 阶段三:清理现场 (Finally) 1. 调用业务方法 (e.g., selectUser) 1 解析 @DataPermission 注解 2 2. 入栈配置 (add) (Context 存入 ThreadLocal) 3 3. 执行 SQL (触发 MP 拦截器 getSqlSegment) 4 4.以此 SQL ID 获取生效规则 5 5. 获取当前注解配置 6 返回 Annotation (或 null) 7 核心决策逻辑 (判空/Enable/Include/Exclude) 8 6. 返回规则列表 [Rule A, Rule B...] 9 7. 表名匹配吗? (getTableNames) 10 8. 生成 SQL 片段 (getExpression) 11 返回 "dept_id = 100" 12 9. 拼接 AND 条件 13 跳过 14 10. 返回最终 WHERE 条件 15 业务执行结束 16 11. 出栈清理 (remove) 17 清理完成 18
3. 核心代码实现
第一步:定义规则接口 (Strategy Pattern)
我们需要一个接口来统一所有的数据权限规则。
java
public interface DataPermissionRule {
/**
* 获取该规则生效的表名集合
* 作用:快速过滤,只有匹配的表才需要处理,提升性能
*/
Set<String> getTableNames();
/**
* 根据表名和别名,生成 SQL 条件表达式
* @param tableName 表名 (e.g., sys_user)
* @param tableAlias 别名 (e.g., u)
* @return JSqlParser 表达式 (e.g., u.dept_id = 1)
*/
Expression getExpression(String tableName, Alias tableAlias);
}
第二步:实现具体的业务规则 (Rule Implementation)
以"部门数据权限"为例,限制用户只能查询本部门的数据。
java
@AllArgsConstructor
public class DeptDataPermissionRule implements DataPermissionRule {
private final PermissionApi permissionApi; // 业务 Service,用于获取当前用户的部门权限
@Override
public Set<String> getTableNames() {
// 配置哪些表需要进行部门隔离
return Set.of("sys_user", "sys_dept", "t_order");
}
@Override
public Expression getExpression(String tableName, Alias tableAlias) {
// 1. 获取当前登录用户
Long userId = SecurityUtils.getLoginUserId();
if (userId == null) return null; // 未登录不处理
// 2. 获取用户的部门数据权限 (通常建议做一层 RequestScope 缓存)
Set<Long> deptIds = permissionApi.getUserDeptDataPermission(userId);
if (CollUtil.isEmpty(deptIds)) {
// 如果没有部门权限,直接返回 null = null,保证查不到数据
return new EqualsTo(new NullValue(), new NullValue());
}
// 3. 构造 SQL:dept_id IN (1, 2, 3)
// 注意:这里需要根据别名处理列名,如 u.dept_id
String columnName = MyBatisUtils.getAliasColumn(tableAlias, "dept_id");
return new InExpression(
new Column(columnName),
new ParenthesedExpressionList(new ExpressionList(
deptIds.stream().map(LongValue::new).collect(Collectors.toList())
))
);
}
}
第三步:上下文与 AOP 控制 (Context Holder)
为了让业务层能通过注解灵活控制权限(如 @DataPermission(enable=false)),我们需要 ThreadLocal 上下文。
3.1 上下文持有者
java
public class DataPermissionContextHolder {
// 使用 LinkedList 支持方法的嵌套调用(入栈/出栈)
private static final ThreadLocal<LinkedList<DataPermission>> CONTEXT =
ThreadLocal.withInitial(LinkedList::new);
public static void add(DataPermission annotation) {
CONTEXT.get().addLast(annotation);
}
public static DataPermission remove() {
LinkedList<DataPermission> list = CONTEXT.get();
DataPermission last = list.removeLast();
if (list.isEmpty()) CONTEXT.remove(); // 清理 ThreadLocal,防内存泄露
return last;
}
public static DataPermission get() {
return CONTEXT.get().peekLast(); // 获取栈顶(当前生效)的配置
}
}
3.2 AOP 拦截器
java
@DataPermission // 该注解,用于 {@link DATA_PERMISSION_NULL} 的空对象
public class DataPermissionAnnotationInterceptor implements MethodInterceptor {
/**
* DataPermission 空对象,用于方法无 {@link DataPermission} 注解时,使用 DATA_PERMISSION_NULL 进行占位
*/
static final DataPermission DATA_PERMISSION_NULL = DataPermissionAnnotationInterceptor.class.getAnnotation(DataPermission.class);
@Getter
private final Map<MethodClassKey, DataPermission> dataPermissionCache = new ConcurrentHashMap<>();
@Override
public Object invoke(MethodInvocation methodInvocation) throws Throwable {
// 入栈
DataPermission dataPermission = this.findAnnotation(methodInvocation);
if (dataPermission != null) {
DataPermissionContextHolder.add(dataPermission);
}
try {
// 执行逻辑
return methodInvocation.proceed();
} finally {
// 出栈
if (dataPermission != null) {
DataPermissionContextHolder.remove();
}
}
}
private DataPermission findAnnotation(MethodInvocation methodInvocation) {
// 1. 从缓存中获取
Method method = methodInvocation.getMethod();
Object targetObject = methodInvocation.getThis();
Class<?> clazz = targetObject != null ? targetObject.getClass() : method.getDeclaringClass();
MethodClassKey methodClassKey = new MethodClassKey(method, clazz);
DataPermission dataPermission = dataPermissionCache.get(methodClassKey);
if (dataPermission != null) {
return dataPermission != DATA_PERMISSION_NULL ? dataPermission : null;
}
// 2.1 从方法中获取
dataPermission = AnnotationUtils.findAnnotation(method, DataPermission.class);
// 2.2 从类上获取
if (dataPermission == null) {
dataPermission = AnnotationUtils.findAnnotation(clazz, DataPermission.class);
}
// 2.3 添加到缓存中
dataPermissionCache.put(methodClassKey, dataPermission != null ? dataPermission : DATA_PERMISSION_NULL);
return dataPermission;
}
}
3.3 切面 Advisor
java
/**
* DataPermission 注解的 Advisor
* 职责:将拦截器 (Interceptor) 与切点 (Pointcut) 绑定
*/
@Getter
@EqualsAndHashCode(callSuper = true)
public class DataPermissionAnnotationAdvisor extends AbstractPointcutAdvisor {
private final Advice advice;
private final Pointcut pointcut;
/**
* 构造函数
* @param interceptor 我们在 3.2 节定义的拦截器
*/
public DataPermissionAnnotationAdvisor(DataPermissionAnnotationInterceptor interceptor) {
this.advice = interceptor;
this.pointcut = this.buildPointcut();
}
/**
* 构建切点逻辑 (核心匹配规则)
*/
protected Pointcut buildPointcut() {
// 1. 类级别的匹配器:检查类头上是否有 @DataPermission
// 第二个参数 true 表示 checkInherited,支持继承的注解
Pointcut classPointcut = new AnnotationMatchingPointcut(DataPermission.class, true);
// 2. 方法级别的匹配器:检查方法头上是否有 @DataPermission
// 第一个参数 null 表示不关心类注解,只关心方法注解
Pointcut methodPointcut = new AnnotationMatchingPointcut(null, DataPermission.class, true);
// 3. 组合逻辑 (Union)
// 含义:(类上有注解) OR (方法上有注解) -> 都算命中
// 使用 Spring 的 ComposablePointcut 进行组合
return new ComposablePointcut(classPointcut).union(methodPointcut);
}
}
第四步:规则决策工厂 (Factory)
工厂负责根据当前的 AOP 上下文,筛选出本次 SQL 执行需要哪些规则。
java
@RequiredArgsConstructor
public class DataPermissionRuleFactoryImpl implements DataPermissionRuleFactory {
private final List<DataPermissionRule> rules; // Spring 注入所有 Rule Bean
@Override
public List<DataPermissionRule> getDataPermissionRules() {
// 1. 获取上下文中的注解配置
DataPermission activeAnnotation = DataPermissionContextHolder.get();
// 2. 如果没注解,默认返回所有规则(安全兜底)
if (activeAnnotation == null) {
return rules;
}
// 3. 如果显式禁用,返回空
if (!activeAnnotation.enable()) {
return Collections.emptyList();
}
// 4. 处理 Include(白名单)和 Exclude(黑名单)
// 这里使用 Stream 过滤,逻辑:Include 优先于 Exclude
return rules.stream().filter(rule -> {
Class<?> ruleClass = rule.getClass();
if (ArrayUtil.isNotEmpty(activeAnnotation.includeRules())) {
return ArrayUtil.contains(activeAnnotation.includeRules(), ruleClass);
}
if (ArrayUtil.isNotEmpty(activeAnnotation.excludeRules())) {
return !ArrayUtil.contains(activeAnnotation.excludeRules(), ruleClass);
}
return true;
}).collect(Collectors.toList());
}
}
第五步:MyBatis-Plus 处理器 (Handler)
这是连接业务逻辑与 MyBatis 底层的桥梁。它实现 MP 的 MultiDataPermissionHandler 接口。
java
@RequiredArgsConstructor
public class DataPermissionRuleHandler implements MultiDataPermissionHandler {
private final DataPermissionRuleFactory ruleFactory;
@Override
public Expression getSqlSegment(Table table, Expression where, String mappedStatementId) {
// 1. 从工厂拿到当前生效的规则列表
List<DataPermissionRule> rules = ruleFactory.getDataPermissionRules();
if (CollUtil.isEmpty(rules)) return null;
Expression allExpression = null;
// 2. 遍历所有规则,拼装 AND 条件
for (DataPermissionRule rule : rules) {
// 2.1 只要管辖范围内的表
if (!rule.getTableNames().contains(table.getName())) {
continue;
}
// 2.2 生成单条条件 (e.g., dept_id = 1)
Expression oneExpress = rule.getExpression(table.getName(), table.getAlias());
if (oneExpress == null) continue;
// 2.3 拼接到总条件中:WHERE (old) AND (rule1) AND (rule2)
allExpression = allExpression == null ? oneExpress
: new AndExpression(allExpression, oneExpress);
}
return allExpression;
}
}
第六步:最终组装与配置 (Configuration)
将上述组件组装起来,并注入到 MyBatis-Plus 的拦截器链中。
java
@Configuration
public class DataPermissionConfiguration {
@Bean
public DataPermissionRuleFactory dataPermissionRuleFactory(List<DataPermissionRule> rules) {
return new DataPermissionRuleFactoryImpl(rules);
}
@Bean
public DataPermissionRuleHandler dataPermissionRuleHandler(DataPermissionRuleFactory ruleFactory) {
return new DataPermissionRuleHandler(ruleFactory);
}
/**
* 配置 MP 的拦截器
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor(DataPermissionRuleHandler handler) {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 1. 创建数据权限拦截器
DataPermissionInterceptor dataPermissionInterceptor = new DataPermissionInterceptor();
// 2. 【关键】dataPermissionInterceptor没有使用自动注入,需要手动注入 Handler
dataPermissionInterceptor.setDataPermissionHandler(handler);
// 3. 添加到拦截器链
interceptor.addInnerInterceptor(dataPermissionInterceptor);
// (可选) 添加分页拦截器等...
return interceptor;
}
}
4. 实战指南:如何在业务开发中使用?
框架搭建完毕后,在日常业务开发中,数据权限的使用主要分为三种场景:默认自动生效 、注解灵活控制 、编程式临时控制。
4.1 场景一:默认无感知过滤 (The Magic)
这是最常见的场景。开发者在编写 Service 代码时,完全不需要关心权限逻辑,就像写普通的 CRUD 一样。
业务代码:
java
@Service
public class UserServiceImpl implements UserService {
@Resource
private UserMapper userMapper;
public List<UserDO> getUserList() {
// 开发者只写了查询所有,没加任何 WHERE 条件
return userMapper.selectList(null);
}
}
实际执行的 SQL:
框架会自动识别当前登录人(如租户 ID=1,部门 ID=100),并改写 SQL:
sql
SELECT * FROM sys_user
WHERE tenant_id = 1 -- 自动拼接租户规则
AND dept_id = 100 -- 自动拼接部门规则
注意 :前提是
sys_user表必须包含在DeptDataPermissionRule的getTableNames()返回集合中。
4.2 场景二:基于注解的策略控制 (Annotation)
当我们需要打破默认规则时(例如:管理员查询、跨部门统计、后台定时任务),使用 @DataPermission 注解是最优雅的方式。
2.1 彻底关闭权限(上帝视角)
适用于后台管理统计、数据迁移、定时任务等场景。
java
@DataPermission(enable = false) // 关掉所有规则
public List<UserDO> getAllUsersForAdmin() {
// 执行 SQL: SELECT * FROM sys_user
// 结果:查出全库所有租户、所有部门的数据
return userMapper.selectList(null);
}
2.2 排除特定规则(黑名单)
适用于"虽然我是 A 租户的人,但我要查询所有租户的信息,不过我只能看我自己部门的"这种复杂场景。
java
// 排除租户规则,但保留部门规则(以及其他规则)
@DataPermission(excludeRules = TenantDataPermissionRule.class)
public List<UserDO> getMultiTenantUsers() {
// 执行 SQL: SELECT * FROM sys_user WHERE dept_id = 100
// 结果:租户限制消失了,但部门限制还在
return userMapper.selectList(null);
}
2.3 只启用特定规则(白名单)
适用于非常严格的场景,确保只有核心规则生效,防止其他规则(如刚开发的实验性规则)干扰。
java
// 只启用部门规则,忽略其他一切
@DataPermission(includeRules = DeptDataPermissionRule.class)
public void onlyDeptLogic() { ... }
4.3 场景三:代码块级临时控制 (Utils)
如果你不能修改 Service 方法的签名(比如是继承的父类方法),或者你只想在方法内部的某一行 查询暂时忽略权限,注解就不好用了。这时我们需要一个工具类 DataPermissionUtils。
工具类实现 (Util Implementation):
java
public class DataPermissionUtils {
public static <T> T executeIgnore(Supplier<T> supplier) {
// 1. 压入一个"禁用"配置
DataPermissionContextHolder.add(new DataPermission() {
public boolean enable() { return false; }
// ... 其他默认实现
});
try {
// 2. 执行业务逻辑
return supplier.get();
} finally {
// 3. 恢复现场
DataPermissionContextHolder.remove();
}
}
}
业务使用 (Usage):
java
public void complexBusiness() {
// 1. 这里的查询受权限控制
userMapper.selectList(null);
// 2. 这里的查询临时忽略权限
List<UserDO> allData = DataPermissionUtils.executeIgnore(() -> {
return userMapper.selectList(null);
});
// 3. 这里的查询恢复受权限控制
userMapper.selectById(1);
}
4.4 前置条件:上下文的填充 (Important!)
这就回到了我们定义的 Rule 实现类。所有的过滤逻辑都依赖于 "当前登录人是谁"。
在 Web 环境中,通常通过 Filter 或 Interceptor 在请求开始时,将用户信息放入 ThreadLocal(如 Spring Security 的 SecurityContextHolder)。
如果是在单元测试 或非 Web 环境 中,使用 Service 前必须先模拟登录,否则 Rule 获取不到 UserID 可能会抛出异常或返回空结果。
单元测试示例:
java
@Test
public void testDataPermission() {
// 1. Mock 登录用户 (放入 Security 上下文)
SecurityUtils.mockLoginUser(new LoginUser().setId(1L).setDeptId(100L));
// 2. 执行 Service,此时 Rule 才能拿到 dept_id = 100
List<UserDO> users = userService.getUserList();
// 3. 断言 SQL 是否拼接正确
Assert.assertTrue(users.size() > 0);
}
5. 总结表
| 需求 | 解决方案 | 原理 |
|---|---|---|
| 日常 CRUD | 直接写 Mapper 查询 | Handler 自动拦截并拼接 SQL |
| 管理员/定时任务 | 方法加 @DataPermission(enable=false) |
Factory 返回空 Rule 列表 |
| 跨租户查询 | 方法加 @DataPermission(excludeRules=TenantRule.class) |
Factory 过滤掉 TenantRule |
| 局部代码放行 | DataPermissionUtils.executeIgnore(...) |
ContextHolder 临时入栈配置 |
Q&A
Advisor的代码不是说,如果类和方法上没有写@DataPermission就不会被DataPermissionAnnotationInterceptor拦截么,没有被DataPermissionAnnotationInterceptor拦截就不会有dataPermission上下文,那么就不会加where
这是一个基于默认安全 策略的设计:注解的作用是"申请特权",而不是"开启防护"。
DataPermissionAnnotationAdvisor 确实只拦截带 @DataPermission 的方法,因此对于普通的业务方法,拦截器不会执行,DataPermissionContextHolder.get() 拿到的结果就是 null。但这恰恰触发了 DataPermissionRuleFactoryImpl 的默认兜底逻辑:它一旦检测到上下文为 null,就会判定为"当前无特殊指示",从而强制返回所有 配置的规则(return rules)。这就确保了开发者在写代码时,什么都不用做就是最安全的 (自动拼接所有 WHERE 条件);只有当需要打破规则 (如管理员查询 enable=false)时,才需要加注解来生成上下文,指示工厂去"放行"。
核心代码证据如下:
java
// DataPermissionRuleFactoryImpl.java
@Override
public List<DataPermissionRule> getDataPermissionRule(String mappedStatementId) {
// 1. 对于没加 @DataPermission 的方法,拦截器不执行,这里拿到 null
DataPermission dataPermission = DataPermissionContextHolder.get();
// 2. 【关键逻辑】Context 为 null 并非"不处理",而是"全量处理"
if (dataPermission == null) {
return rules; // 返回容器中所有的规则(如租户规则、部门规则等)
}
// 3. 只有当 dataPermission != null (有注解) 时,才根据注解去排除或禁用规则
if (!dataPermission.enable()) {
return Collections.emptyList();
}
// ... Include/Exclude 处理
}
DataPermissionRuleHandler的getSqlSegment方法又是被谁调用的呢?为什么只需要重写这个方法就能实现无感添加Where的功能。
getSqlSegment 方法是由 MyBatis-Plus 的内部拦截器 DataPermissionInterceptor**(继承自 BaseMultiTableInnerInterceptor)在 SQL 执行前的拦截阶段调用的。之所以只需重写该方法就能实现无感添加 WHERE 条件,是因为 MyBatis-Plus 利用 JSqlParser 将原始 SQL 解析为抽象语法树(AST),拦截器会遍历这棵树中的所有表,调用你的 getSqlSegment 获取额外的权限条件片段(如 dept_id = 1),然后通过 动态改写 AST**,将这些条件以 AND 的方式强制追加到原 SQL 的 WHERE 子句中,最终执行的是改写后的 SQL,从而实现了业务代码无感知的底层数据隔离。