本文基于一个真实的物联网 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 排查过程
第一步:怀疑数据权限拦截器污染
最初怀疑是 DeviceDataScopeInterceptor 给 selectById 追加了数据权限条件。但分析代码发现,selectById 在 SqlEnhanceContext.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 的分页参数,导致:
selectById被当作分页查询 :PageHelper 对其生成了 COUNT 查询,返回计数结果而非实体对象,selectById得到null,查不到租户信息。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 架构设计建议
如果重新设计数据权限方案,建议考虑以下改进:
-
权限上下文前置收集:在网关或过滤器层统一收集权限数据,避免在 AOP 切面中执行数据库查询,从根本上避免与 PageHelper 的冲突。
-
使用 MyBatis-Plus 的数据权限插件 :MyBatis-Plus 3.5.2+ 提供了
DataPermissionInterceptor,原生支持数据权限,减少自定义拦截器的复杂度。 -
避免在切面中查询数据库:租户信息、角色权限等可以缓存在用户会话(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 调用点 |