MyBatis-Plus与AOP实现动态数据权限过滤的实践与踩坑记录

本文基于一个真实的物联网 SaaS 平台项目,记录了在 MyBatis-Plus + PageHelper 分页插件的基础组件体系下,使用 AOP 切面 + 自定义注解 + ThreadLocal + SQL 拦截器实现动态数据权限过滤的完整方案,以及在分页场景下踩到的"查不到租户信息"的深坑与排查过程。


一、背景与需求

在多租户 SaaS 系统中,不同角色登录后看到的数据范围不同。例如:

  • 租户管理员:能看到本租户下所有设备
  • 普通用户:只能看到其角色绑定的区域/机构下的设备

数据权限控制需要在 业务代码无感知 的情况下,自动给 SQL 追加过滤条件(如 WHERE district_id IN ('A', 'B')),避免每个查询手动拼接。

技术栈

组件 版本 作用
MyBatis-Plus 3.x ORM 框架,提供 InnerInterceptor 扩展机制
PageHelper 6.1.1 分页插件,基于 ThreadLocal 实现自动分页
Spring AOP - 切面拦截,在方法执行前后织入权限逻辑
JSqlParser - SQL 解析,动态改写 WHERE 条件

二、整体架构设计

数据权限过滤的核心思路是:AOP 切面负责收集权限上下文 → ThreadLocal 传递 → SQL 拦截器负责改写 SQL

2.1 组件职责划分

sql 复制代码
┌─────────────────────────────────────────────────────────────┐
│  业务层 Service                                              │
│  PageHelper.startPage(...) + mapper.getEquipmentList(dto)   │
└──────────────────────────┬──────────────────────────────────┘
                           │ @DataScope 注解触发
                           ▼
┌─────────────────────────────────────────────────────────────┐
│  AOP 切面 DeviceDataScopeAspect                              │
│  1. 获取当前登录用户                                          │
│  2. 查询租户信息,判断是否管理员                               │
│  3. 非管理员:查询角色绑定的区域/机构权限                       │
│  4. 将权限上下文写入 ThreadLocal (SqlEnhanceContext)          │
│  5. point.proceed() 执行目标方法                             │
│  6. finally 清理 ThreadLocal                                 │
└──────────────────────────┬──────────────────────────────────┘
                           │ MyBatis 执行 SQL
                           ▼
┌─────────────────────────────────────────────────────────────┐
│  SQL 拦截器 DeviceDataScopeInterceptor (InnerInterceptor)    │
│  1. 从 ThreadLocal 读取权限上下文                             │
│  2. 管理员:跳过                                              │
│  3. 非管理员:用 JSqlParser 改写 SQL,追加 WHERE 条件         │
│     如:district_id IN ('A', 'B')                            │
└─────────────────────────────────────────────────────────────┘

2.2 核心组件代码

(1)自定义注解 @DataScope

标注在 Mapper 方法上,声明该查询需要数据权限过滤,并指定过滤字段:

java 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DataScope {
    // 数据资源类型:设备/区域/机构/人员
    DataResourceEnum dataResource() default DataResourceEnum.DEVICE;
    // 数据库表中的过滤字段别名,如 "e.district_id"
    String filterFieldAlias() default "device_id";
    // 是否走子查询模式(跨表过滤)
    boolean subQuery() default false;
    String subQueryField() default "";
    String subQueryTable() default "";
    String subQueryFilterFieldAlias() default "";
}

使用示例:

java 复制代码
@DataScope(filterFieldAlias = "e.district_id")
List<EquipmentListPO> getEquipmentList(@Param("dto") EquipmentListQuery dto);

(2)ThreadLocal 上下文 SqlEnhanceContext

在 AOP 切面和 SQL 拦截器之间传递权限数据:

java 复制代码
public class SqlEnhanceContext {
    private static final ThreadLocal<Map<String, Object>> CONTEXT = new ThreadLocal<>();

    public static void set(String key, Object value) {
        Map<String, Object> map = CONTEXT.get();
        if (map == null) {
            map = new HashMap<>();
            CONTEXT.set(map);
        }
        map.put(key, value);
    }

    public static Object get(String key) {
        Map<String, Object> map = CONTEXT.get();
        return map != null ? map.get(key) : null;
    }

    public static void clear() {
        CONTEXT.remove();
    }
}

存储的上下文键值:

Key 含义
DATA_FILTER_FIELD_NAME 过滤字段名,如 e.district_id
DATA_FILTER_FIELD_VALUE 过滤值列表,如 ["A", "B"]
IS_TENANT_ADMIN 是否租户管理员(管理员跳过过滤)
DATA_RESOURCE_TYPE 资源类型枚举
IS_SUB_QUERY 是否子查询模式

(3)AOP 切面 DeviceDataScopeAspect

拦截 @DataScope 注解的方法,在执行前收集权限上下文:

java 复制代码
@Aspect
@Component
@Slf4j
public class DeviceDataScopeAspect {

    @Autowired
    BaseTenantMapper baseTenantMapper;
    @Autowired
    BaseDistrictRoleRelationService baseDistrictRoleRelationService;

    @Around("@annotation(dataScope)")
    public Object around(ProceedingJoinPoint point, DataScope dataScope) throws Throwable {
        try {
            WebSessionUserInfo userInfo = RequestHolderUtil.getWebSessionUserAndValid();

            // 查询租户,判断是否管理员
            BaseTenant tenant = baseTenantMapper.selectById(userInfo.getTenantId());
            boolean isTenantAdmin = (tenant != null
                    && tenant.getAdminAccount().equals(userInfo.getIdentity()));

            // 非管理员:查询角色绑定的数据权限
            List<String> currentDistricts = new ArrayList<>();
            if (!isTenantAdmin) {
                if (DataResourceEnum.DEVICE.equals(dataScope.dataResource())) {
                    List<BaseDistrictRoleRelation> relations =
                            baseDistrictRoleRelationService.findByRoleId(
                                    userInfo.getTenantId(), userInfo.getRoleId());
                    if (Objects.nonNull(relations)) {
                        relations.forEach(item -> currentDistricts.add(item.getDistrictIds()));
                    } else {
                        currentDistricts.add("None"); // 无权限则查不到数据
                    }
                }
                // ... 其他资源类型处理
            }

            // 写入 ThreadLocal
            SqlEnhanceContext.set(SystemConstant.DATA_FILTER_FIELD_NAME, dataScope.filterFieldAlias());
            SqlEnhanceContext.set(SystemConstant.DATA_FILTER_FIELD_VALUE, currentDistricts);
            SqlEnhanceContext.set(SystemConstant.IS_TENANT_ADMIN, isTenantAdmin);
            // ...

            // 执行目标方法
            return point.proceed();
        } catch (Exception e) {
            log.error("DeviceDataScopeAspect error", e);
            return point.proceed();
        } finally {
            SqlEnhanceContext.clear();
        }
    }
}

(4)SQL 拦截器 DeviceDataScopeInterceptor

实现 MyBatis-Plus 的 InnerInterceptor,在 SQL 执行前动态改写:

java 复制代码
@Component
@Intercepts({
    @Signature(type = Executor.class, method = "query",
               args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class DeviceDataScopeInterceptor implements InnerInterceptor {

    @Override
    public void beforeQuery(Executor executor, MappedStatement ms, Object parameter,
                            RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
        // 无上下文则跳过
        String filterFieldName = (String) SqlEnhanceContext.get(SystemConstant.DATA_FILTER_FIELD_NAME);
        if (Objects.isNull(filterFieldName)) {
            return;
        }
        // 管理员跳过
        if (Boolean.TRUE.equals(SqlEnhanceContext.get(SystemConstant.IS_TENANT_ADMIN))) {
            return;
        }

        // 用 JSqlParser 解析并改写 SQL
        List<Object> filterValue = (List<Object>) SqlEnhanceContext.get(SystemConstant.DATA_FILTER_FIELD_VALUE);
        String originalSql = boundSql.getSql();

        try {
            Statement stmt = CCJSqlParserUtil.parse(originalSql);
            if (stmt instanceof Select) {
                PlainSelect plainSelect = (PlainSelect) ((Select) stmt).getSelectBody();

                // 构建 IN 表达式:district_id IN ('A', 'B')
                List<Expression> expressions = new ArrayList<>();
                for (Object value : filterValue) {
                    expressions.add(new StringValue(value.toString()));
                }
                ParenthesedExpressionList expressionList = new ParenthesedExpressionList(expressions);
                InExpression inExpression = new InExpression();
                inExpression.setLeftExpression(new Column(filterFieldName));
                inExpression.setRightExpression(expressionList);

                // 追加到 WHERE 子句
                if (plainSelect.getWhere() == null) {
                    plainSelect.setWhere(inExpression);
                } else {
                    plainSelect.setWhere(new AndExpression(plainSelect.getWhere(), inExpression));
                }
            }
            // 替换回 BoundSql
            String newSql = stmt.toString();
            PluginUtils.mpBoundSql(boundSql).sql(newSql);
        } catch (Exception e) {
            log.error("SQL 解析或追加过滤条件失败,将执行原始SQL", e);
        }
    }
}

(5)注册拦截器

java 复制代码
@Configuration
public class MybatisPlusConfig {
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
        interceptor.addInnerInterceptor(new DeviceDataScopeInterceptor()); // 数据权限
        return interceptor;
    }
}

三、踩坑:分页时查不到租户信息

3.1 问题现象

设备列表分页查询,首次请求正常 ,但点击第二页及以后baseTenantMapper.selectById 查不到租户信息,返回 null,导致 isTenantAdmin 恒为 false,所有用户都被当作非管理员处理,数据权限逻辑混乱。

日志中 selectById 实际执行的 SQL 是:

sql 复制代码
SELECT count(0) FROM base_tenant WHERE id = ? AND logic_delete = 0

这是一条 COUNT 查询 ,而 selectById 本应执行 SELECT * FROM base_tenant WHERE id = ?

3.2 排查过程

第一步:怀疑数据权限拦截器污染

最初怀疑是 DeviceDataScopeInterceptorselectById 追加了数据权限条件。但分析代码发现,selectByIdSqlEnhanceContext.set(...) 之前执行,此时上下文为空,拦截器会直接 return,不会污染。

结论:数据权限拦截器不是根因。

第二步:从日志 SQL 找到突破口

日志显示 selectById 执行的是 SELECT count(0) FROM base_tenant,这是 PageHelper 分页插件的 COUNT 查询 特征。selectById 本不该有 COUNT,这说明 PageHelper 把 selectById 当成了分页查询

第三步:梳理执行时序,定位根因

业务代码的调用链:

java 复制代码
// EquipmentBasicInfoServiceImpl.getEquipmentList()
PageHelper.startPage(request.getPageIndex(), request.getPageSize());  // ①
List<EquipmentListPO> equipmentList = equipmentBasicInfoMapper.getEquipmentList(dto);  // ②

getEquipmentList 标注了 @DataScope,会被 DeviceDataScopeAspect 拦截。完整时序:

sql 复制代码
① PageHelper.startPage(pageIndex, pageSize)
   → 分页参数存入 PageHelper 的 ThreadLocal(只对紧随其后的第一条 SQL 生效)

② equipmentBasicInfoMapper.getEquipmentList(dto)
   → 触发 @DataScope 切面
      └─ DeviceDataScopeAspect.around()
           ③ baseTenantMapper.selectById(tenantId)   ← ⚠️ 消费了分页参数!
              → PageHelper 检测到 ThreadLocal 中的分页参数
              → 把 selectById 当作分页查询
              → 执行 SELECT count(0) FROM base_tenant WHERE id = ?
              → 返回 count=1,但 selectById 期望返回 BaseTenant 实体
              → 实际得到 null(查不到租户)

           ④ SqlEnhanceContext.set(...)              ← 设置数据权限上下文

           ⑤ point.proceed()                         ← 执行 getEquipmentList
              → 分页参数已被 ③ 消费
              → getEquipmentList 失去分页能力

3.3 根因总结

PageHelper 的分页参数只对紧随其后的第一条 SQL 生效 。AOP 切面在执行目标方法 getEquipmentList 之前,先执行了 baseTenantMapper.selectById 查询租户信息,selectById 提前消费了 PageHelper 的分页参数,导致:

  1. selectById 被当作分页查询 :PageHelper 对其生成了 COUNT 查询,返回计数结果而非实体对象,selectById 得到 null查不到租户信息
  2. getEquipmentList 失去分页能力 :分页参数已被 selectById 消费,真正的目标查询不再分页。

3.4 为什么首次正常、第二次才出问题

PageHelper 的分页 ThreadLocal 与 Tomcat 线程复用、请求时序有关:

  • 首次请求 :可能由于线程初始化、时序差异,selectById 执行时分页参数尚未稳定生效,查询正常。
  • 第二次请求(线程复用) :Tomcat 复用线程池中的线程,PageHelper 的 ThreadLocal 时序稳定后,selectById 稳定地消费了分页参数,COUNT 查询替代了实体查询,返回 null

本质上该缺陷一直存在,只是由于 ThreadLocal 的时序敏感性,表现不稳定,增加了排查难度。


四、解决方案

4.1 核心思路

在切面中查询租户前,暂存 PageHelper 的分页 ThreadLocal 并清除 ,确保 selectById 不被 PageHelper 拦截;查询完租户后恢复分页参数 ,保证后续 point.proceed() 中的 getEquipmentList 仍能正常分页。

4.2 修复代码

java 复制代码
@Around("@annotation(dataScope)")
public Object around(ProceedingJoinPoint point, DataScope dataScope) throws Throwable {
    try {
        WebSessionUserInfo userInfo = RequestHolderUtil.getWebSessionUserAndValid();

        // ====== 修复:隔离 PageHelper 分页参数 ======
        // PageHelper.startPage 的分页参数只对紧随其后的第一条 SQL 生效。
        // 切面在 getEquipmentList 之前先执行 selectById,会导致:
        // 1) selectById 被当作分页查询执行 COUNT 语句,返回 null(查不到租户)
        // 2) 真正的 getEquipmentList 失去分页能力
        // 修复:查询租户前暂存并清除分页参数,查询完再恢复。
        com.github.pagehelper.Page<?> savedPage = com.github.pagehelper.PageHelper.getLocalPage();
        com.github.pagehelper.PageHelper.clearPage();
        BaseTenant tenant;
        try {
            tenant = baseTenantMapper.selectById(userInfo.getTenantId());
        } finally {
            if (savedPage != null) {
                com.github.pagehelper.PageHelper.setLocalPage(savedPage);
            }
        }
        // ====== 修复结束 ======

        boolean isTenantAdmin = (tenant != null
                && tenant.getAdminAccount().equals(userInfo.getIdentity()));

        // ... 后续权限上下文收集与 point.proceed() 不变
    } catch (Exception e) {
        log.error("DeviceDataScopeAspect error", e);
        return point.proceed();
    } finally {
        SqlEnhanceContext.clear();
    }
}

4.3 修复效果

环节 修复前 修复后
selectById 被 PageHelper 拦截,执行 COUNT 查询,返回 null 正常执行实体查询,返回租户对象
getEquipmentList 分页参数已被消费,失去分页能力 分页参数已恢复,正常分页
租户管理员判断 tenant 为 null,isTenantAdmin 恒为 false 正常判断管理员身份
数据权限过滤 非管理员走过滤,但权限上下文异常 数据权限过滤逻辑正常

五、经验总结与最佳实践

5.1 ThreadLocal 的时序敏感性

本项目同时使用了三个基于 ThreadLocal 的组件,它们之间会产生意想不到的相互影响:

组件 ThreadLocal 作用 生命周期
PageHelper 分页参数 只对紧随其后的第一条 SQL 生效
SqlEnhanceContext 数据权限上下文 AOP 切面内,finally 清理
RequestHolderUtil 登录用户信息 请求级别,过滤器清理

最佳实践 :在 AOP 切面中执行任何数据库查询前,必须评估是否会受到其他 ThreadLocal 组件的影响。如果会,需要做暂存-清除-恢复的隔离处理。

5.2 切面中的内部查询需隔离上下文

切面在 point.proceed() 前执行的任何数据库查询(如查询租户、查询角色权限),都可能受到以下干扰:

  • PageHelper:分页参数被提前消费
  • 数据权限拦截器:上下文残留导致 SQL 被错误改写
  • MyBatis-Plus 租户插件:租户字段被自动追加

通用隔离模式

java 复制代码
@Around("@annotation(dataScope)")
public Object around(ProceedingJoinPoint point, DataScope dataScope) throws Throwable {
    // 1. 暂存所有可能干扰的 ThreadLocal
    Page<?> savedPage = PageHelper.getLocalPage();
    Map<String, Object> savedScope = SqlEnhanceContext.snapshot();

    try {
        // 2. 清除 ThreadLocal,确保内部查询不受干扰
        PageHelper.clearPage();
        SqlEnhanceContext.clear();

        // 3. 执行内部查询(安全环境)
        BaseTenant tenant = baseTenantMapper.selectById(tenantId);
        List<String> districts = queryDistrictPermission(roleId);

        // 4. 恢复 ThreadLocal,供目标方法使用
        if (savedPage != null) PageHelper.setLocalPage(savedPage);
        SqlEnhanceContext.set(...);  // 设置数据权限上下文

        // 5. 执行目标方法
        return point.proceed();
    } finally {
        // 6. 清理所有 ThreadLocal
        SqlEnhanceContext.clear();
        PageHelper.clearPage();
    }
}

5.3 日志是定位 ThreadLocal 问题的关键线索

本问题的核心线索来自日志中的异常 SQL:

sql 复制代码
SELECT count(0) FROM base_tenant WHERE id = ? AND logic_delete = 0
  • SELECT count(0) → PageHelper 的 COUNT 查询特征
  • FROM base_tenant → 本应是 selectById 的实体查询

排查建议:遇到"查询返回 null 但数据存在"的问题,优先开启 MyBatis SQL 日志,检查实际执行的 SQL 是否被插件改写。

5.4 架构设计建议

如果重新设计数据权限方案,建议考虑以下改进:

  1. 权限上下文前置收集:在网关或过滤器层统一收集权限数据,避免在 AOP 切面中执行数据库查询,从根本上避免与 PageHelper 的冲突。

  2. 使用 MyBatis-Plus 的数据权限插件 :MyBatis-Plus 3.5.2+ 提供了 DataPermissionInterceptor,原生支持数据权限,减少自定义拦截器的复杂度。

  3. 避免在切面中查询数据库:租户信息、角色权限等可以缓存在用户会话(Session/Token)中,切面直接从会话读取,无需查库。


六、附录:涉及文件清单

文件 路径 说明
DataScope common/annotation/DataScope.java 数据权限注解
SqlEnhanceContext common/util/SqlEnhanceContext.java ThreadLocal 上下文工具
DeviceDataScopeAspect service/web/aspect/DeviceDataScopeAspect.java AOP 切面,权限收集
DeviceDataScopeInterceptor common/interceptor/DeviceDataScopeInterceptor.java SQL 拦截器,SQL 改写
MybatisPlusConfig common/config/MybatisPlusConfig.java 拦截器注册
EquipmentBasicInfoMapper mybatis/mapper/EquipmentBasicInfoMapper.java 使用 @DataScope 的 Mapper
EquipmentBasicInfoServiceImpl service/.../EquipmentBasicInfoServiceImpl.java 业务层,PageHelper.startPage 调用点
相关推荐
电气研究所1 小时前
Java + Redis 实现工业设备告警去抖,避免变频器故障重复推送
java·前端
無限進步D1 小时前
Java 代码块
java·开发语言
用户3126874877201 小时前
@Async 加了没生效?Spring Boot 异步编程全链路拆解:从线程池到自调用陷阱
java·spring boot
zfoo-framework1 小时前
安装pi agent 支持duojie和deepseek
java·服务器·前端
君顾12 小时前
代驾跑腿业务系统实战开发指南
java·开发语言·代驾跑腿
NGINX开源社区3 小时前
NGINX Ingress Controller 5.5:安全性与性能提升,迁移更轻松
java·服务器·nginx
云烟成雨TD3 小时前
Micrometer 系列【25】Spring Boot Actuator | Spring MVC、Spring WebFlux 指标
java·spring boot·micrometer
liudashuang20173 小时前
Kafka Producer 隐藏深坑:Sender 线程自阻塞(自死锁)导致 BufferExhaustedException 完整复盘
java·分布式·kafka·linq
风筱3 小时前
Idea的CC GUI插件安装Claude Code SDK失败
java·ide·ai编程