当前位置: 首页 > news >正文

MyBatis拦截器实现数据权限控制:原理、实现与PageHelper兼容方案

1. 项目概述与核心痛点

在开发企业级后台管理系统时,数据权限控制是一个绕不开的经典难题。前端菜单和按钮的权限,我们通常可以通过配置角色与资源的关系来实现,相对直观。但到了后端,特别是数据库查询层面,问题就复杂多了。比如,一个简单的员工打卡记录查询,总部管理员需要看到全公司的数据,而部门经理只能看到本部门的数据。如果每个查询接口都手动去拼接WHERE dpt_id = #{currentUserDeptId}这样的条件,代码会变得极其臃肿、难以维护,且容易出错。更麻烦的是,这种逻辑会散落在无数个Mapper方法里,一旦权限规则变更(比如增加分公司维度),那就是一场灾难。

我最近在重构一个老项目的权限模块时,就深刻体会到了这种痛苦。项目里有几十张业务表,每张表都涉及部门数据隔离,手动添加WHERE条件的代码重复了上百次。后来,我们决定引入MyBatis拦截器来统一处理数据范围权限,效果立竿见影。这就像给SQL引擎装了一个“自动过滤器”,根据当前登录用户的权限,在SQL执行前动态地、无感地加上过滤条件。今天,我就把这个从踩坑到实现的完整过程,包括核心原理、代码细节、尤其是与常用插件(如PageHelper)的兼容性难题及其解决方案,毫无保留地分享出来。

2. 数据权限拦截器的核心设计思路

2.1 为什么选择MyBatis拦截器?

首先,我们得明白为什么要在MyBatis层面做,而不是在Service层或者DAO层。在Service层过滤,意味着要先查出所有数据到内存中,再用Java代码过滤,这在数据量稍大时性能是不可接受的。在DAO层(即Mapper接口)手动写,则违反了DRY(Don‘t Repeat Yourself)原则,且耦合度高。

MyBatis拦截器提供了一种AOP(面向切面编程)的能力,它允许我们在SQL语句被真正执行前,对其进行拦截和修改。这正是我们需要的:在SQL抵达数据库之前,根据规则重写它。这样,对上层业务代码完全是透明的,业务开发者只需要关心业务逻辑,无需感知复杂的数据权限规则。像我们熟知的PageHelper分页插件,其核心原理就是通过拦截器在原始SQL外面包裹一层分页查询。

2.2 拦截点的选择与权衡

MyBatis允许拦截四大核心组件的方法:

  1. Executor: 执行器,负责整个SQL执行过程的调度。update,query等方法在这里。
  2. StatementHandler: 语句处理器,负责操作Statement对象,与数据库交互。
  3. ParameterHandler: 参数处理器,负责将JavaBean转换为JDBC所需的参数。
  4. ResultSetHandler: 结果集处理器,负责将JDBC返回的ResultSet转换为Java对象。

对于数据权限过滤,我们的目标是改写SELECT语句的WHERE部分。拦截Executorquery方法是最佳选择。因为此时MyBatis已经完成了SQL的解析和参数的初步绑定(生成了BoundSql对象),但还没有真正创建数据库连接和执行。我们在这个时机拿到SQL字符串进行修改,风险最小,也最符合流程。

注意:拦截StatementHandlerprepare方法理论上也可以,但那时SQL可能已经被数据库驱动预处理了,修改起来更复杂。而拦截Executor是PageHelper等成熟插件采用的方案,经过了大量实践验证,更为稳妥。

2.3 权限规则的抽象与注解驱动

我们不可能也不应该对所有查询都进行数据权限过滤。比如一些字典表查询、全局配置查询,或者总部管理员的全量查询。因此,我们需要一个“开关”和“规则描述器”。

我们采用自定义注解的方式来实现这一点。在需要数据权限过滤的Mapper方法上,打上一个注解,拦截器检测到这个注解,才执行SQL重写逻辑。这样设计非常灵活:

  • 精准控制:只对标注的方法生效,避免误伤。
  • 规则可配置:通过注解属性,可以传递表别名、过滤字段名等元信息,应对复杂的SQL场景(如多表关联查询)。
  • 低侵入性:业务代码几乎无感知,只需要加一个注解即可。

3. 拦截器实现详解与核心代码拆解

下面,我将结合代码,一步步拆解这个拦截器的实现。为了清晰,我会先给出核心骨架,再逐一解释关键部分。

3.1 定义数据权限注解

这是我们的“开关”和“说明书”。

import java.lang.annotation.*; /** * 数据范围权限限制注解 * 标注在MyBatis Mapper接口的方法上,声明此查询需要进行数据权限过滤 */ @Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited public @interface DataScope { /** * 需要进行权限过滤的表别名 * 例如:SQL为 `select * from user u where u.name=xx`,则alias应为 "u" * 默认为空,表示直接使用字段名,适用于单表或主表查询 */ String alias() default ""; /** * 权限过滤依据的数据库字段名 * 例如:根据部门过滤,此字段通常为 "dept_id" * 默认为 "dept_id",可在拦截器配置中设置全局默认值 */ String field() default "dept_id"; }

3.2 实现核心拦截器类

这是重头戏,我们命名为DataScopeInterceptor

import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.apache.ibatis.cache.CacheKey; import org.apache.ibatis.executor.Executor; import org.apache.ibatis.mapping.BoundSql; import org.apache.ibatis.mapping.MappedStatement; import org.apache.ibatis.mapping.SqlSource; import org.apache.ibatis.plugin.*; import org.apache.ibatis.session.ResultHandler; import org.apache.ibatis.session.RowBounds; import org.springframework.core.annotation.AnnotationUtils; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import javax.servlet.http.HttpServletRequest; import java.lang.reflect.Method; import java.util.Properties; /** * 数据范围权限拦截器 * 核心原理:拦截Executor的query方法,在SQL执行前,根据当前用户权限动态添加WHERE条件 */ @Intercepts({ @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}), @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}) }) @Slf4j public class DataScopeInterceptor implements Interceptor { /** * 默认的权限字段名,可通过配置覆盖 */ private String defaultFilterField = "dept_id"; @Override public Object intercept(Invocation invocation) throws Throwable { // 1. 获取当前执行的MappedStatement和SQL信息 MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0]; Object parameter = invocation.getArgs()[1]; BoundSql boundSql = mappedStatement.getBoundSql(parameter); String originalSql = boundSql.getSql(); // 2. 判断当前方法是否需要数据权限过滤 DataScope dataScope = findDataScopeAnnotation(mappedStatement); if (dataScope == null) { // 无需处理,直接放行 return invocation.proceed(); } // 3. 获取当前登录用户的权限信息(这里是关键,根据你的用户体系实现) String userFilterValue = getCurrentUserFilterValue(); if (StringUtils.isBlank(userFilterValue)) { // 如果获取不到用户权限信息,出于安全考虑,可以抛出异常或返回空结果 // 这里我们选择直接放行,但记录警告日志。实际项目需根据安全策略调整。 log.warn("数据权限拦截生效,但未能获取到当前用户的过滤条件值。SQL ID: {}", mappedStatement.getId()); return invocation.proceed(); } // 4. 根据注解信息,重写SQL,添加WHERE条件 String finalSql = rewriteSqlWithCondition(originalSql, dataScope, userFilterValue); log.debug("数据权限过滤 - 原SQL: [{}], 新SQL: [{}]", originalSql, finalSql); // 5. 创建新的BoundSql和MappedStatement,替换掉原来的 BoundSql newBoundSql = new BoundSql( mappedStatement.getConfiguration(), finalSql, boundSql.getParameterMappings(), boundSql.getParameterObject() ); // 将新的BoundSql包装成SqlSource SqlSource newSqlSource = new BoundSqlSqlSource(newBoundSql); // 复制原MappedStatement,仅替换SqlSource MappedStatement newMappedStatement = copyMappedStatement(mappedStatement, newSqlSource); // 用新的MappedStatement替换掉Invocation中的原对象 invocation.getArgs()[0] = newMappedStatement; // 6. 继续执行拦截器链 return invocation.proceed(); } /** * 根据Mapper方法ID,查找其上的@DataScope注解 */ private DataScope findDataScopeAnnotation(MappedStatement mappedStatement) { try { String mapperMethodId = mappedStatement.getId(); // Mapper方法ID格式:com.xxx.mapper.UserMapper.selectById String className = mapperMethodId.substring(0, mapperMethodId.lastIndexOf(".")); String methodName = mapperMethodId.substring(mapperMethodId.lastIndexOf(".") + 1); Class<?> mapperInterface = Class.forName(className); // 找到对应的方法 for (Method method : mapperInterface.getMethods()) { if (method.getName().equals(methodName)) { // 使用Spring的工具类,可以处理注解继承等情况 return AnnotationUtils.findAnnotation(method, DataScope.class); } } } catch (Exception e) { log.error("查找DataScope注解失败, mapperId: {}", mappedStatement.getId(), e); } return null; } /** * 获取当前用户的权限过滤值(例如:部门ID) * 这是需要你根据项目用户体系实现的核心方法 * 通常从ThreadLocal、SecurityContextHolder或Request中获取 */ private String getCurrentUserFilterValue() { // 示例:从Spring Security Context获取 // Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); // if (authentication != null && authentication.getPrincipal() instanceof CustomUserDetails) { // return ((CustomUserDetails) authentication.getPrincipal()).getDeptId(); // } // 示例:从HttpServletRequest属性中获取(需确保过滤器或拦截器已提前注入) RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); if (requestAttributes instanceof ServletRequestAttributes) { HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest(); // 假设用户信息已通过JWT或Session过滤器存入request属性 Object deptId = request.getAttribute("CURRENT_USER_DEPT_ID"); return deptId != null ? deptId.toString() : null; } return null; } /** * SQL重写核心逻辑:在合适的位置插入 WHERE 条件 */ private String rewriteSqlWithCondition(String originalSql, DataScope dataScope, String filterValue) { // 确定最终的字段名:如果有别名,则拼接“别名.字段名” String field = dataScope.field(); if (StringUtils.isNotBlank(dataScope.alias())) { field = dataScope.alias() + "." + field; } // 构建要添加的条件片段 String conditionToAdd = field + " = '" + filterValue + "'"; // 注意:这里假设值是字符串,数字类型需处理 StringBuilder sqlBuilder = new StringBuilder(originalSql); // 查找原始SQL中 WHERE 关键字的位置(不区分大小写) String upperCaseSql = originalSql.toUpperCase(); int whereIndex = upperCaseSql.indexOf(" WHERE "); if (whereIndex == -1) { // 原SQL没有WHERE子句 // 需要找到ORDER BY, GROUP BY, LIMIT等子句的位置,在它们之前插入WHERE int insertPos = findInsertPositionBeforeSpecialClause(originalSql); if (insertPos == originalSql.length()) { // 末尾追加 sqlBuilder.append(" WHERE ").append(conditionToAdd); } else { // 在特定子句前插入 sqlBuilder.insert(insertPos, " WHERE " + conditionToAdd + " "); } } else { // 原SQL已有WHERE子句,在它后面追加 AND 条件 // 需要找到原始SQL中“WHERE”之后第一个非空格字符的位置 int pos = whereIndex + 6; // “ WHERE ” 的长度是6 while (pos < originalSql.length() && Character.isWhitespace(originalSql.charAt(pos))) { pos++; } sqlBuilder.insert(pos, conditionToAdd + " AND "); } return sqlBuilder.toString(); } /** * 辅助方法:在ORDER BY/GROUP BY/LIMIT等子句前插入WHERE * 这是一个简化版,复杂SQL需要更完善的SQL解析器 */ private int findInsertPositionBeforeSpecialClause(String sql) { String lowerSql = sql.toLowerCase(); int orderByPos = lowerSql.indexOf("order by"); int groupByPos = lowerSql.indexOf("group by"); int limitPos = lowerSql.indexOf("limit"); int havingPos = lowerSql.indexOf("having"); // 找到最早出现的一个特殊子句的位置 int minPos = sql.length(); for (int pos : new int[]{orderByPos, groupByPos, limitPos, havingPos}) { if (pos != -1 && pos < minPos) { minPos = pos; } } return minPos; } /** * 复制并替换MappedStatement中的SqlSource */ private MappedStatement copyMappedStatement(MappedStatement ms, SqlSource newSqlSource) { MappedStatement.Builder builder = new MappedStatement.Builder( ms.getConfiguration(), ms.getId(), newSqlSource, ms.getSqlCommandType() ); builder.resource(ms.getResource()); builder.fetchSize(ms.getFetchSize()); builder.statementType(ms.getStatementType()); builder.keyGenerator(ms.getKeyGenerator()); if (ms.getKeyProperties() != null && ms.getKeyProperties().length > 0) { builder.keyProperty(String.join(",", ms.getKeyProperties())); } builder.timeout(ms.getTimeout()); builder.parameterMap(ms.getParameterMap()); builder.resultMaps(ms.getResultMaps()); builder.resultSetType(ms.getResultSetType()); builder.cache(ms.getCache()); builder.flushCacheRequired(ms.isFlushCacheRequired()); builder.useCache(ms.isUseCache()); return builder.build(); } /** * 一个简单的SqlSource包装类,用于直接返回我们构建好的BoundSql */ private static class BoundSqlSqlSource implements SqlSource { private final BoundSql boundSql; public BoundSqlSqlSource(BoundSql boundSql) { this.boundSql = boundSql; } @Override public BoundSql getBoundSql(Object parameterObject) { return boundSql; } } @Override public Object plugin(Object target) { // 使用MyBatis提供的Plugin.wrap方法创建代理对象 return Plugin.wrap(target, this); } @Override public void setProperties(Properties properties) { // 可以从MyBatis配置文件中读取属性,例如默认字段名 if (properties != null) { String field = properties.getProperty("defaultFilterField"); if (StringUtils.isNotBlank(field)) { this.defaultFilterField = field; } } } }

3.3 关键代码段解析与避坑指南

1.@Intercepts注解:这里我们拦截了Executor的两个query方法签名。这是因为MyBatis内部在不同情况下可能会调用不同参数列表的query方法。两个都拦截能确保覆盖所有查询场景。

2.findDataScopeAnnotation方法:这是定位注解的关键。通过MappedStatement.getId()可以获取到完整的Mapper接口方法路径(如com.xxx.UserMapper.selectList)。我们通过反射找到对应的方法,并检查其上的@DataScope注解。这里使用Spring的AnnotationUtils比直接getAnnotation更健壮,因为它能处理注解继承等特殊情况。

3.getCurrentUserFilterValue方法:这是整个拦截器与业务系统对接的核心。你需要根据自己项目的权限体系来实现它。常见做法有:

  • Spring Security:从SecurityContextHolder.getContext().getAuthentication()中获取。
  • ThreadLocal:在登录过滤器或拦截器中,将用户信息存入ThreadLocal,这里直接取出。
  • Request Attribute:如示例所示,通过RequestContextHolder获取当前请求,再从请求属性中获取预先存入的用户信息。

重要提示:确保获取用户信息的逻辑在拦截器执行时是可用的。如果拦截器在非Web环境(如定时任务)下执行,RequestContextHolder会为null,需要做好判空和降级处理。

4.rewriteSqlWithCondition方法:这是SQL重写的核心,也是最容易出bug的地方。

  • 字段拼接:正确处理表别名。如果注解指定了别名alias=”a”,且字段为dept_id,则最终条件应为a.dept_id = ‘xxx’
  • WHERE子句处理
    • 原SQL无WHERE:我们需要在合适的位置(通常在FROM/JOIN子句之后,ORDER/GROUP BY子句之前)插入WHERE
    • 原SQL有WHERE:我们需要在现有WHERE条件后追加AND。这里示例代码做了简化,直接查找“ WHERE ”字符串。但在生产环境中,这非常危险!SQL中可能包含字符串常量、注释(如-- WHERE/* WHERE */),这些都会被错误匹配。
  • SQL注入风险:示例中直接将filterValue拼接进SQL字符串,这存在SQL注入漏洞。在实际项目中,绝对不允许这样做!我们应该利用MyBatis的参数映射机制。正确做法是:
    1. 将过滤条件作为一个新的参数添加到parameterObject中。
    2. 在构建新的BoundSql时,创建新的参数映射 (ParameterMapping)。
    3. 在SQL中使用#{...}占位符来引用这个参数。 由于篇幅和复杂度,示例代码做了简化,但你必须意识到这一点并在实现中解决。

5.copyMappedStatement方法:MyBatis的MappedStatement是不可变对象。我们需要基于原对象复制一份,并替换其中的SqlSource。注意要复制所有必要的属性,如keyGenerator,keyProperties(涉及主键回写),否则可能导致功能异常。

4. 与PageHelper等插件的兼容性难题与终极解决方案

这是实现过程中最大的一个“坑”。当你同时使用PageHelper(或其他MyBatis插件)和自定义的数据权限拦截器时,执行顺序会直接决定最终SQL的正确性。

4.1 问题复现:顺序为何如此重要?

假设我们有如下调用链:

  1. 业务调用PageHelper.startPage()开始分页。
  2. 执行Mapper方法(带有@DataScope注解)。
  3. MyBatis执行查询。

理想中的SQL转换顺序应该是:

原始SQL: SELECT * FROM employee 1. 数据权限拦截器: SELECT * FROM employee WHERE dept_id = ‘123’ 2. PageHelper拦截器: SELECT COUNT(*) FROM (SELECT * FROM employee WHERE dept_id = ‘123’) -- 计算总数 3. PageHelper拦截器: SELECT * FROM employee WHERE dept_id = ‘123’ LIMIT 0, 10 -- 分页查询

关键点:数据权限过滤必须在最内层,即先加WHERE条件,再进行分页包装。

如果顺序反过来(PageHelper先执行):

原始SQL: SELECT * FROM employee 1. PageHelper拦截器: SELECT * FROM employee LIMIT 0, 10 -- 先分页 2. 数据权限拦截器: SELECT * FROM (SELECT * FROM employee LIMIT 0, 10) WHERE dept_id = ‘123’ -- 错误!

这样会导致先分页出10条数据,再在这10条里找dept_id=‘123’的,逻辑完全错误,且分页总数计算也不对。

4.2 尝试与失败:@Order、@DependsOn为何无效?

在Spring环境中,我们很自然地想到用@Order@DependsOn注解来控制Bean的加载或执行顺序。

  • @Order注解:它主要影响的是同一类型Bean集合(如List )的排序,或者在某些特定场景(如AOP通知、事件监听器)下的执行顺序。但MyBatis拦截器被添加到Configuration的时机,以及它们在拦截器链中的位置,并不直接由Spring Bean的@Order控制。MyBatis内部维护自己的拦截器链,其顺序通常由拦截器被addInterceptor添加的顺序决定。
  • @DependsOn注解:它声明了Bean之间的实例化依赖关系,确保B在A之后实例化。但这并不能保证A的@PostConstruct方法或初始化逻辑在B的拦截器注册之后执行。在我们的场景里,PageHelperAutoConfiguration(自动配置类)可能在某个时间点注册了它的拦截器,我们需要的是在这个之后再注册我们的拦截器。

4.3 可靠解决方案:ApplicationRunner手动后置注册

经过多次测试,最稳定可靠的方案是放弃将拦截器声明为Spring Bean并自动注入的方式,转而使用ApplicationRunnerCommandLineRunner接口。这些接口的run方法会在Spring Boot应用完全启动后执行,此时所有自动配置(包括PageHelper的)都已经完成。

我们可以在这个时机,手动获取SqlSessionFactory,并将我们的拦截器添加进去。关键点:要添加到拦截器链的头部,以确保最先执行(即最后包装)。

import org.apache.ibatis.session.SqlSessionFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.stereotype.Component; import java.util.List; @Component public class DataScopeInterceptorRegister implements ApplicationRunner { /** * 注入所有的SqlSessionFactory(兼容多数据源场景) */ @Autowired private List<SqlSessionFactory> sqlSessionFactoryList; @Override public void run(ApplicationArguments args) { // 1. 创建我们的拦截器实例 DataScopeInterceptor dataScopeInterceptor = new DataScopeInterceptor(); // 可以在这里通过setter方法配置拦截器属性 // dataScopeInterceptor.setDefaultFilterField("tenant_id"); // 2. 遍历所有SqlSessionFactory,注册拦截器 for (SqlSessionFactory sqlSessionFactory : sqlSessionFactoryList) { org.apache.ibatis.session.Configuration configuration = sqlSessionFactory.getConfiguration(); // 获取已存在的所有拦截器 List<Interceptor> existingInterceptors = configuration.getInterceptors(); // 3. 关键步骤:将我们的拦截器插入到链的最开始 // MyBatis执行拦截器的顺序是:InterceptorChain.pluginAll() 顺序包装, // 执行时是逆序。所以先加入的拦截器反而后执行。 // 为了让我们数据权限的WHERE条件在最内层,我们需要它最先执行(即最后被代理)。 // 因此,我们把它加到现有拦截器列表的头部,再重新设置回去。 // 但更简单直接的方法是使用addInterceptor,它默认加在链的末尾。 // 经过测试和PageHelper源码分析,PageHelper也是通过addInterceptor加入的。 // 所以,我们后加入,就会在PageHelper之后执行,从而包装PageHelper生成的SQL。 configuration.addInterceptor(dataScopeInterceptor); log.info("已向SqlSessionFactory [{}] 注册数据权限拦截器", sqlSessionFactory); } log.info("数据权限拦截器注册完成。"); } }

原理剖析:在MyBatis拦截器链中,执行顺序与添加顺序相反。假设拦截器链是[A, B, C]A最先被添加。执行时,MyBatis会创建一个代理链:target -> C -> B -> A。所以实际执行顺序是A.intercept() -> B.intercept() -> C.intercept() -> 执行目标方法configuration.addInterceptor()方法是将拦截器追加到列表末尾。因此,我们在所有自动配置(包括PageHelper)完成之后,再调用addInterceptor,我们的拦截器D就会被加到链的末尾(即[A, B, C, D])。根据上述规则,执行顺序将是D -> C -> B -> A -> 目标方法D(我们的数据权限拦截器)最先执行,它添加的WHERE条件就在最内层,随后C(可能是PageHelper)再在外面包裹分页逻辑,顺序就正确了。

4.4 验证执行顺序

注册完成后,你可以通过DEBUG日志查看拦截器链。在DataScopeInterceptorintercept方法开始处打印日志,在PageHelper的拦截器中也打印日志,观察它们的执行先后。确保你的拦截器日志先于PageHelper的日志出现,这表示你的WHERE条件添加在先。

5. 高级话题:复杂场景下的SQL解析与优化

上面的rewriteSqlWithCondition方法是一个极度简化的版本,它通过字符串查找和拼接来修改SQL,这在简单查询中或许可行,但在生产环境的复杂SQL面前非常脆弱。

5.1 简单字符串处理的局限性

  1. 子查询SELECT * FROM (SELECT * FROM user WHERE status=1) t,我们的字符串查找WHERE会找到子查询里的WHERE,从而在错误的位置插入条件。
  2. 注释:SQL中可能包含-- WHERE xxx/* WHERE xxx */,字符串匹配会误判。
  3. 关键字别名SELECT “WHERE” AS keyword FROM table,字符串常量中的WHERE也会被匹配。
  4. UNION查询SELECT a FROM t1 UNION SELECT b FROM t2,应该在哪部分加WHERE条件?可能需要两边都加。
  5. INSERT ... SELECT, CREATE TABLE ... AS SELECT等复杂语句。

5.2 引入SQL解析器(JSqlParser)

对于生产环境,强烈建议使用成熟的SQL解析库,如JSqlParser。它可以将SQL字符串解析成抽象语法树(AST),允许你以编程方式精准地访问和修改SQL的各个部分。

使用JSqlParser重写SQL的简化示例:

import net.sf.jsqlparser.parser.CCJSqlParserUtil; import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.statement.select.PlainSelect; import net.sf.jsqlparser.statement.select.Select; import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.operators.relational.EqualsTo; import net.sf.jsqlparser.expression.LongValue; import net.sf.jsqlparser.schema.Column; private String rewriteSqlWithJsParser(String originalSql, DataScope dataScope, String filterValue) throws Exception { Statement statement = CCJSqlParserUtil.parse(originalSql); if (!(statement instanceof Select)) { // 如果不是SELECT语句,直接返回原SQL(或者根据需求处理UPDATE/DELETE) return originalSql; } Select select = (Select) statement; PlainSelect plainSelect = (PlainSelect) select.getSelectBody(); // 处理简单SELECT,需递归处理子查询 // 构建新的过滤条件表达式: dept_id = 123 Column column = new Column(); if (StringUtils.isNotBlank(dataScope.alias())) { column.setTable(new Table(null, dataScope.alias())); } column.setColumnName(dataScope.field()); EqualsTo equalsTo = new EqualsTo(); equalsTo.setLeftExpression(column); // 注意:这里应使用JSQLParser的表达式,并处理好参数类型(字符串加引号) equalsTo.setRightExpression(new StringValue(filterValue)); // 或 new LongValue(...) // 获取现有的WHERE表达式 Expression where = plainSelect.getWhere(); if (where == null) { // 没有WHERE,直接设置新条件 plainSelect.setWhere(equalsTo); } else { // 已有WHERE,用AND连接新旧条件 AndExpression and = new AndExpression(where, equalsTo); plainSelect.setWhere(and); } // 将修改后的AST重新转换为SQL字符串 return plainSelect.toString(); }

使用JSqlParser后,你可以精准地定位到主查询的WHERE子句,轻松处理别名,并能递归处理子查询中的SELECT语句,极大地提高了鲁棒性和准确性。当然,这需要引入额外的依赖,并编写更复杂的AST遍历逻辑。

5.3 性能考量与缓存

每次查询都解析和重写SQL会有一定的性能开销。对于QPS很高的服务,可以考虑:

  • 缓存重写后的SQL:以原始SQL + 用户权限标识作为Key,缓存重写后的SQL。但要注意SQL中的参数可能变化(如IN列表),缓存策略需要精心设计。
  • 预编译Statement:MyBatis本身有缓存预编译Statement(MappedStatement)。我们通过创建新的MappedStatement并替换原对象,如果每次查询的权限值(dept_id)都不同,会导致无法命中缓存,生成大量MappedStatement优化方向:将权限值作为SQL参数(#{deptId})而不是直接拼接,这样对于同一SQL模板,无论权限值如何变,MappedStatement都是同一个,可以充分利用MyBatis的一级/二级缓存和预编译语句缓存。

6. 总结与最佳实践建议

通过MyBatis拦截器实现数据范围权限,是一种优雅且强大的解耦方案。回顾整个实现过程,有以下几个核心要点和最佳实践:

  1. 注解驱动,按需启用:使用自定义注解(如@DataScope)来标记需要过滤的Mapper方法,避免全局拦截带来的性能损耗和潜在问题。
  2. 拦截时机选择:优先选择拦截Executor.query方法,这是修改SQL的最佳切入点。
  3. 用户上下文获取:实现getCurrentUserFilterValue()方法时,要确保其在Web请求和非Web场景(如异步任务、定时任务)下都能安全、正确地获取到权限信息。通常需要结合ThreadLocal和降级策略。
  4. SQL重写的安全性绝对避免简单的字符串拼接,它有SQL注入风险且处理复杂SQL能力差。对于简单项目,可以严格限定SQL格式;对于复杂项目,必须引入JSqlParser等SQL解析库进行精准的AST操作。
  5. 插件执行顺序:这是与PageHelper等插件共存时的最大挑战。通过实现ApplicationRunnerCommandLineRunner,在Spring Boot应用完全启动后手动向SqlSessionFactory注册拦截器,是控制执行顺序最可靠的方式。记住:后添加的拦截器先执行(在代理链的外层)。
  6. 测试覆盖:必须为拦截器编写全面的单元测试和集成测试,覆盖各种SQL场景(单表、多表JOIN、子查询、UNION、带有注释的SQL等)以及不同的用户权限情况。确保过滤条件被正确添加,且不影响分页、排序、聚合等原有功能。
  7. 监控与日志:在生产环境,为拦截器添加DEBUG或TRACE级别的日志,记录原始SQL和重写后的SQL,这对于排查权限过滤失效或SQL错误的问题至关重要。但要注意日志量,避免影响性能。

最后,这种方案虽然强大,但也不是银弹。在超大规模、对性能极度敏感的场景下,或者在SQL极其复杂多变的情况下,可能需要考虑其他方案,比如在数据库层面使用视图(View)、行级安全策略(如PostgreSQL的RLS),或者在查询引擎层进行优化。但对于绝大多数基于MyBatis的Java Web应用来说,这个拦截器方案在灵活性、开发效率和可维护性之间取得了很好的平衡,是解决数据权限问题的利器。

http://www.jsqmd.com/news/868055/

相关文章:

  • 量子电路压缩技术:WZCC相位网格对齐优化
  • DeepSeek-R1开源版性能实测报告(附17项Benchmark对比表):为何中小团队在Q3必须切换?
  • 紧急提醒!项目管理人员不要乱签字,否则真会坐牢!
  • 2026年期刊投稿论文降AI攻略:学术期刊AIGC超标免费4.8元知网达标完整方案
  • 5分钟快速上手akshare:零基础获取金融数据的完整指南
  • 基于Intel MAX 10 FPGA的Z80与8051双核SoC设计与实现
  • Arm架构下printf导致RTL仿真卡死的解决方案
  • OPPO Find X5系列深度解析:自研芯片与生态协同如何重塑旗舰体验
  • 从零到一:eTs声明式UI开发入门与Button控件实战
  • 基于RK3568嵌入式主板的智能炒菜机方案:从硬件选型到系统集成实战
  • 谷歌SEO完整入门攻略,小白也能快速上手
  • 2026年Q2断柱处理实力品牌盘点:迈向鑫无震动技术引领者 - 2026年企业推荐榜
  • 基于RK3568的智能炒菜机方案:从硬件驱动到AI烹饪算法全解析
  • 基于SYZYGY标准的多功能FPGA扩展板设计与工程实践
  • OPPO马里亚纳X芯片:自研影像NPU如何重塑计算摄影体验
  • 消费级EEG眼动追踪技术解析与应用
  • HarmonyOS ArkUI开发:eTs语言核心特性与实战指南
  • 嵌入式硬知识篇---半导体:信息时代的 “魔法基石“
  • 科学数据压缩技术:原理、应用与优化
  • RZ/T2H单芯多轴驱控一体方案:工业机器人实时控制与工业以太网集成
  • RISC-V处理器全栈验证:基于FPGA原型平台的软硬件协同实战
  • 从开题到终稿,okbiye 如何用「高校级规范」重新定义毕业论文写作效率
  • 有限状态机进阶:复合状态与历史机制的设计实践
  • Keil MDK调试器兼容性问题解决方案
  • RK3568开发板4G模块上网全流程调试与问题排查指南
  • C语言DSP嵌入式开发实战:从架构理解到算法优化全解析
  • ChatGPT开源实现全景图:从RLHF原理到主流项目实战指南
  • 通过curl命令快速测试Taotoken平台API连通性与模型列表
  • 从选题到定稿零返工:9 款 AI 毕业论文工具横评(2026 实测版)
  • 行业关键信号识别不准?架构师教你用企业级AI Agent重塑数字化感知力