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

发布时间:2026/5/23 2:04:21

MyBatis拦截器实现数据权限控制:原理、实现与PageHelper兼容方案 1. 项目概述与核心痛点在开发企业级后台管理系统时数据权限控制是一个绕不开的经典难题。前端菜单和按钮的权限我们通常可以通过配置角色与资源的关系来实现相对直观。但到了后端特别是数据库查询层面问题就复杂多了。比如一个简单的员工打卡记录查询总部管理员需要看到全公司的数据而部门经理只能看到本部门的数据。如果每个查询接口都手动去拼接WHERE dpt_id #{currentUserDeptId}这样的条件代码会变得极其臃肿、难以维护且容易出错。更麻烦的是这种逻辑会散落在无数个Mapper方法里一旦权限规则变更比如增加分公司维度那就是一场灾难。我最近在重构一个老项目的权限模块时就深刻体会到了这种痛苦。项目里有几十张业务表每张表都涉及部门数据隔离手动添加WHERE条件的代码重复了上百次。后来我们决定引入MyBatis拦截器来统一处理数据范围权限效果立竿见影。这就像给SQL引擎装了一个“自动过滤器”根据当前登录用户的权限在SQL执行前动态地、无感地加上过滤条件。今天我就把这个从踩坑到实现的完整过程包括核心原理、代码细节、尤其是与常用插件如PageHelper的兼容性难题及其解决方案毫无保留地分享出来。2. 数据权限拦截器的核心设计思路2.1 为什么选择MyBatis拦截器首先我们得明白为什么要在MyBatis层面做而不是在Service层或者DAO层。在Service层过滤意味着要先查出所有数据到内存中再用Java代码过滤这在数据量稍大时性能是不可接受的。在DAO层即Mapper接口手动写则违反了DRYDon‘t Repeat Yourself原则且耦合度高。MyBatis拦截器提供了一种AOP面向切面编程的能力它允许我们在SQL语句被真正执行前对其进行拦截和修改。这正是我们需要的在SQL抵达数据库之前根据规则重写它。这样对上层业务代码完全是透明的业务开发者只需要关心业务逻辑无需感知复杂的数据权限规则。像我们熟知的PageHelper分页插件其核心原理就是通过拦截器在原始SQL外面包裹一层分页查询。2.2 拦截点的选择与权衡MyBatis允许拦截四大核心组件的方法Executor: 执行器负责整个SQL执行过程的调度。update,query等方法在这里。StatementHandler: 语句处理器负责操作Statement对象与数据库交互。ParameterHandler: 参数处理器负责将JavaBean转换为JDBC所需的参数。ResultSetHandler: 结果集处理器负责将JDBC返回的ResultSet转换为Java对象。对于数据权限过滤我们的目标是改写SELECT语句的WHERE部分。拦截Executor的query方法是最佳选择。因为此时MyBatis已经完成了SQL的解析和参数的初步绑定生成了BoundSql对象但还没有真正创建数据库连接和执行。我们在这个时机拿到SQL字符串进行修改风险最小也最符合流程。注意拦截StatementHandler的prepare方法理论上也可以但那时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.namexx则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的参数映射机制。正确做法是将过滤条件作为一个新的参数添加到parameterObject中。在构建新的BoundSql时创建新的参数映射 (ParameterMapping)。在SQL中使用#{...}占位符来引用这个参数。 由于篇幅和复杂度示例代码做了简化但你必须意识到这一点并在实现中解决。5.copyMappedStatement方法MyBatis的MappedStatement是不可变对象。我们需要基于原对象复制一份并替换其中的SqlSource。注意要复制所有必要的属性如keyGenerator,keyProperties涉及主键回写否则可能导致功能异常。4. 与PageHelper等插件的兼容性难题与终极解决方案这是实现过程中最大的一个“坑”。当你同时使用PageHelper或其他MyBatis插件和自定义的数据权限拦截器时执行顺序会直接决定最终SQL的正确性。4.1 问题复现顺序为何如此重要假设我们有如下调用链业务调用PageHelper.startPage()开始分页。执行Mapper方法带有DataScope注解。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并自动注入的方式转而使用ApplicationRunner或CommandLineRunner接口。这些接口的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 ListSqlSessionFactory 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(); // 获取已存在的所有拦截器 ListInterceptor 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日志查看拦截器链。在DataScopeInterceptor的intercept方法开始处打印日志在PageHelper的拦截器中也打印日志观察它们的执行先后。确保你的拦截器日志先于PageHelper的日志出现这表示你的WHERE条件添加在先。5. 高级话题复杂场景下的SQL解析与优化上面的rewriteSqlWithCondition方法是一个极度简化的版本它通过字符串查找和拼接来修改SQL这在简单查询中或许可行但在生产环境的复杂SQL面前非常脆弱。5.1 简单字符串处理的局限性子查询SELECT * FROM (SELECT * FROM user WHERE status1) t我们的字符串查找WHERE会找到子查询里的WHERE从而在错误的位置插入条件。注释SQL中可能包含-- WHERE xxx或/* WHERE xxx */字符串匹配会误判。关键字别名SELECT “WHERE” AS keyword FROM table字符串常量中的WHERE也会被匹配。UNION查询SELECT a FROM t1 UNION SELECT b FROM t2应该在哪部分加WHERE条件可能需要两边都加。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列表缓存策略需要精心设计。预编译StatementMyBatis本身有缓存预编译StatementMappedStatement。我们通过创建新的MappedStatement并替换原对象如果每次查询的权限值dept_id都不同会导致无法命中缓存生成大量MappedStatement。优化方向将权限值作为SQL参数#{deptId}而不是直接拼接这样对于同一SQL模板无论权限值如何变MappedStatement都是同一个可以充分利用MyBatis的一级/二级缓存和预编译语句缓存。6. 总结与最佳实践建议通过MyBatis拦截器实现数据范围权限是一种优雅且强大的解耦方案。回顾整个实现过程有以下几个核心要点和最佳实践注解驱动按需启用使用自定义注解如DataScope来标记需要过滤的Mapper方法避免全局拦截带来的性能损耗和潜在问题。拦截时机选择优先选择拦截Executor.query方法这是修改SQL的最佳切入点。用户上下文获取实现getCurrentUserFilterValue()方法时要确保其在Web请求和非Web场景如异步任务、定时任务下都能安全、正确地获取到权限信息。通常需要结合ThreadLocal和降级策略。SQL重写的安全性绝对避免简单的字符串拼接它有SQL注入风险且处理复杂SQL能力差。对于简单项目可以严格限定SQL格式对于复杂项目必须引入JSqlParser等SQL解析库进行精准的AST操作。插件执行顺序这是与PageHelper等插件共存时的最大挑战。通过实现ApplicationRunner或CommandLineRunner在Spring Boot应用完全启动后手动向SqlSessionFactory注册拦截器是控制执行顺序最可靠的方式。记住后添加的拦截器先执行在代理链的外层。测试覆盖必须为拦截器编写全面的单元测试和集成测试覆盖各种SQL场景单表、多表JOIN、子查询、UNION、带有注释的SQL等以及不同的用户权限情况。确保过滤条件被正确添加且不影响分页、排序、聚合等原有功能。监控与日志在生产环境为拦截器添加DEBUG或TRACE级别的日志记录原始SQL和重写后的SQL这对于排查权限过滤失效或SQL错误的问题至关重要。但要注意日志量避免影响性能。最后这种方案虽然强大但也不是银弹。在超大规模、对性能极度敏感的场景下或者在SQL极其复杂多变的情况下可能需要考虑其他方案比如在数据库层面使用视图View、行级安全策略如PostgreSQL的RLS或者在查询引擎层进行优化。但对于绝大多数基于MyBatis的Java Web应用来说这个拦截器方案在灵活性、开发效率和可维护性之间取得了很好的平衡是解决数据权限问题的利器。

相关新闻