实战:基于 MyBatis-Plus 实现无感知的“数据权限”自动过滤

1. 前言

在多租户或多部门系统中,我们希望开发者在写 SQL(如 SELECT * FROM sys_user)时,不需要手动拼接 WHERE tenant_id = 1WHERE dept_id = 100

本文将基于 MyBatis-Plus 的拦截器机制,结合 JSqlParserSpring AOP,实现一套灵活的、支持注解控制的数据权限框架。

2. 核心架构设计

整个方案可以分为三个层级:

  1. 策略层 (Rule):定义具体的过滤规则(如部门规则、租户规则)。
  2. 执行层 (Handler & Factory):负责在 SQL 执行时,将规则拼接进 SQL。
  3. 控制层 (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 表必须包含在 DeptDataPermissionRulegetTableNames() 返回集合中。


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,从而实现了业务代码无感知的底层数据隔离。

相关推荐
星空寻流年3 小时前
c3p0连接池isClosed()异常事故分析:MyBatis版本兼容问题排查与解决
mybatis
中年程序员一枚3 小时前
Springboot使用maven编译报juh-3.2.1.jar缺失
spring boot·maven·jar
韩立学长3 小时前
基于Springboot建筑物保护可视化系统rk6tni53(程序、源码、数据库、调试部署方案及开发环境)系统界面展示及获取方式置于文档末尾,可供参考。
数据库·spring boot·后端
kkkkkkkkl243 小时前
从 ACK 到事务裁决:Spring Boot 中 RocketMQ 事务消息的完整工作机制解析
spring boot·rocketmq·java-rocketmq
程序帝国4 小时前
配合上一个文章
spring boot
雨中飘荡的记忆4 小时前
MyBatis结果映射模块详解
java·mybatis