1. 项目概述与核心痛点在开发企业级应用尤其是后台管理系统时数据权限控制是一个绕不开的核心需求。想象一下一个全国性的销售系统总部管理员需要看到所有数据大区经理只能看到本大区的订单而城市销售代表只能查看自己负责客户的记录。这种“同一张表不同人看到不同数据行”的需求就是典型的数据范围权限控制。最近在重构一个老项目的权限模块时我再次直面了这个经典问题。早期的实现简单粗暴在每个查询的SQL语句里手动拼接WHERE条件比如WHERE dept_id IN (用户的可访问部门列表)。这种做法导致业务代码里充斥着重复且易错的权限逻辑一旦权限规则变更比如从按部门改成按项目就需要像“扫雷”一样去修改无数个DAO方法维护成本极高也极易遗漏。基于Mybatis拦截器来实现数据范围权限本质上是一种声明式、非侵入式的解决方案。它的核心思路是在Mybatis执行SQL之前通过拦截器动态地、统一地对SQL语句进行改写自动注入数据过滤条件。这样做的好处是业务开发人员可以像往常一样编写查询逻辑完全不用关心权限问题权限规则在拦截器这一层集中管理实现了关注点分离。今天我就结合最近一次实战把从设计思路、拦截器实现、到动态SQL生成、多租户适配以及那些“坑”里总结出的经验系统地梳理一遍。2. 整体设计与思路拆解2.1 为什么选择Mybatis拦截器面对数据权限问题常见的方案有好几种。最简单的是在应用层过滤即查询出所有数据到内存中再用Java代码进行筛选。这在小数据量时可行但数据量一大性能就是灾难。另一种是在每个Mapper的XML或注解SQL中手动拼接条件这是我们最初摒弃的方案因为它破坏了代码的整洁性和可维护性。Mybatis拦截器方案之所以胜出是因为它在数据库层解决了问题同时保持了代码层的优雅。Mybatis提供了Interceptor接口允许我们在SQL执行的几个关键生命周期点如执行查询前、处理参数后进行拦截和增强。我们可以在StatementHandler准备SQL时对最终的SQL字符串进行修改无缝地插入权限过滤子句。这相当于在数据出口处加了一个统一的“过滤器”所有经过此出口的数据都会自动被过滤。2.2 核心设计思路规则引擎 SQL改写整个方案可以抽象为两个核心部分权限规则引擎和SQL改写器。权限规则引擎负责根据当前登录用户的身份如用户ID、角色、所属部门等计算出该用户有权访问的数据范围。这个范围通常表示为一种“规则”例如dept_id 1001只能访问本部门dept_id IN (1001, 1002, 1003)可访问多个部门create_by ‘currentUserId’只能访问自己创建的更复杂的(dept_id 1001) OR (project_id IN (SELECT project_id FROM user_project WHERE user_id ?))SQL改写器则是Mybatis拦截器的核心职责。它需要识别目标判断当前执行的SQL是否需要添加数据权限过滤。通常我们只对查询SELECT语句进行拦截并且可以通过注解或表名白名单来排除一些特定的查询如数据字典查询、权限配置表查询。解析SQL分析原始SQL找到FROM子句后的主表或需要过滤的表并确定WHERE子句的位置。注入条件将权限规则引擎生成的过滤条件以AND的方式拼接到原有的WHERE条件中。如果原SQL没有WHERE子句则需要创建它。这个设计的关键在于“动态”和“透明”。权限规则可以配置在数据库或配置中心动态生效业务代码无需任何改动对开发者透明。2.3 方案选型考量注解驱动 vs. 全局配置在实现上主要有两种风格注解驱动在Mapper接口的方法上添加自定义注解如DataPermission注解中可以指定需要过滤的表名、字段名甚至更复杂的规则标识。拦截器检查该方法是否有此注解有则进行过滤。这种方式非常灵活可以细粒度控制但需要在每个方法上标注。全局配置在拦截器中配置一个全局的表名-字段名映射规则。拦截器解析SQL语句如果发现语句中包含了需要过滤的表如sys_order则自动对其应用规则。这种方式更省事但不够灵活对于不需要过滤的特定查询可能需要特殊排除。在实际项目中我采用了混合模式以全局配置为基础默认对所有指定表的查询进行过滤同时提供注解用于在特定方法上排除过滤DataPermission(ignore true)或覆盖/增强全局规则。这样在保证覆盖面的同时也保留了必要的灵活性。3. 核心细节解析与实操要点3.1 Mybatis拦截器的工作原理与切入点Mybatis拦截器需要实现org.apache.ibatis.plugin.Interceptor接口核心是实现intercept方法。为了拦截SQL执行我们需要拦截StatementHandler类的prepare方法。Intercepts({ Signature(type StatementHandler.class, method prepare, args {Connection.class, Integer.class}) }) Component public class DataPermissionInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler statementHandler (StatementHandler) invocation.getTarget(); // 获取原始的BoundSql里面包含了SQL语句和参数 BoundSql boundSql statementHandler.getBoundSql(); String originalSql boundSql.getSql(); // 1. 判断是否需要添加数据权限 if (!needDataPermission(statementHandler)) { return invocation.proceed(); } // 2. 获取当前用户的权限规则SQL片段 String permissionCondition getPermissionCondition(); if (StringUtils.isEmpty(permissionCondition)) { return invocation.proceed(); // 无权限限制直接放行 } // 3. 改写SQL String newSql rewriteSql(originalSql, permissionCondition); // 4. 利用反射将改写后的SQL设置回BoundSql Field sqlField BoundSql.class.getDeclaredField(sql); sqlField.setAccessible(true); sqlField.set(boundSql, newSql); return invocation.proceed(); } // ... 其他方法实现 }注意通过反射修改BoundSql中的私有字段sql是关键一步。在高版本的Mybatis中BoundSql可能被包装或缓存需要根据实际情况获取最内层的对象。这是一个常见的“坑点”。3.2 权限规则引擎的设计与实现权限规则引擎是业务逻辑的核心。它通常需要用户上下文从当前会话如Spring Security的SecurityContextHolder或ThreadLocal中获取登录用户信息。规则解析根据用户信息角色、部门、自定义属性等查询或计算出一组数据权限规则。这些规则可以存储在数据库中例如一张sys_data_scope表关联角色和规则详情。SQL片段生成将规则转换为合法的SQL WHERE条件片段。这里要特别注意SQL注入安全问题所有动态部分必须使用参数化查询而不是字符串拼接。一个简单的规则引擎示例Service public class DataScopeService { public String getDataFilterSql(String tableAlias) { User currentUser SecurityUtils.getCurrentUser(); if (currentUser null || currentUser.isAdmin()) { return ; // 管理员或未登录用户返回空表示不过滤 } ListDataScopeRule rules ruleMapper.selectByUserId(currentUser.getId()); if (rules.isEmpty()) { return 10; // 没有分配任何数据权限查询结果应为空 } // 假设规则是按部门过滤用户有多个可访问部门 ListLong deptIds rules.stream().map(DataScopeRule::getDeptId).collect(Collectors.toList()); if (deptIds.isEmpty()) { return 10; } // 使用参数化占位符避免SQL注入。注意这里生成的是带占位符的片段。 // 实际参数需要在拦截器中同步设置到BoundSql的参数列表中。 return String.format(%s.dept_id IN (%s), tableAlias, deptIds.stream().map(id - ?).collect(Collectors.joining(,))); } }3.3 SQL解析与安全改写这是技术难度最高的一环。我们不能简单地用字符串replace或在末尾拼接WHERE必须进行基本的SQL语法分析。核心任务提取表名与别名从FROM和JOIN子句中提取出主表及其别名。对于简单的单表查询这比较容易对于复杂的多表关联查询需要确定到底要对哪个表施加过滤条件。通常我们的规则是基于“业务主表”的。定位WHERE位置找到原始SQL中WHERE关键字的位置。如果没有则需要在表名之后、GROUP BY/ORDER BY等子句之前插入WHERE。构建新条件将权限条件与原有条件用AND连接。如果原有条件被括号包裹需要小心处理逻辑优先级。实操要点与避坑指南慎用字符串处理对于复杂的SQL包含子查询、嵌套查询、Common Table Expressions等简单的字符串匹配和替换极易出错。建议引入轻量级的SQL解析库如jsqlparser它可以帮你将SQL字符串解析成语法树修改后再转回字符串非常可靠。别名处理规则中的字段如dept_id必须与SQL中表的别名匹配。如果SQL中使用了别名t那么生成的过滤条件应该是t.dept_id IN (...), 而不是table_name.dept_id IN (...)。拦截器需要智能地获取或推断出别名。参数同步如果权限条件中包含动态参数如?在修改SQL字符串的同时必须将对应的参数值添加到BoundSql的parameterMappings和parameterObject中。这是另一个极易出错和遗忘的点否则执行时会报参数数量不匹配的错误。排除特定查询像SELECT COUNT(*)用于分页的查询或者一些系统级的查询可能不需要或不能添加数据权限。一定要设计一个排除机制如注解、SQL ID白名单。4. 实操过程与核心环节实现4.1 环境准备与依赖配置假设我们使用Spring Boot Mybatis-Plus它基于Mybatis拦截器机制通用的环境。引入依赖如果使用jsqlparser来辅助解析SQL需要在pom.xml中添加。dependency groupIdcom.github.jsqlparser/groupId artifactIdjsqlparser/artifactId version4.6/version !-- 使用当前稳定版本 -- /dependency创建拦截器类如上文DataPermissionInterceptor所示并标注Component让Spring管理。配置拦截器链在Mybatis的配置中如果是Mybatis-Plus则在MybatisPlusInterceptor中添加内嵌拦截器将我们的拦截器添加到执行链中。注意拦截器的顺序数据权限拦截器通常需要在分页插件如PageHelper之前执行因为分页查询是基于已经过滤后的数据总数进行的。Configuration public class MybatisConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 先添加数据权限拦截器 interceptor.addInnerInterceptor(new DataPermissionInterceptor()); // 后添加分页拦截器 interceptor.addInnerInterceptor(new PaginationInnerInterceptor()); return interceptor; } }4.2 基于jsqlparser的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.statement.select.SelectBody; import net.sf.jsqlparser.util.TablesNamesFinder; public class SqlRewriter { public String rewriteSelectSql(String originalSql, String permissionCondition, String primaryTableName) throws Exception { Statement statement CCJSqlParserUtil.parse(originalSql); if (!(statement instanceof Select)) { return originalSql; // 非查询语句直接返回 } Select select (Select) statement; SelectBody selectBody select.getSelectBody(); if (!(selectBody instanceof PlainSelect)) { // 处理Union等复杂查询这里简化处理直接返回。实际项目可能需要递归处理。 return originalSql; } PlainSelect plainSelect (PlainSelect) selectBody; // 1. 获取主表别名 (简化逻辑实际需更复杂的分析) String tableAlias findTableAlias(plainSelect, primaryTableName); // 将权限条件中的占位表别名替换为实际别名 String actualCondition permissionCondition.replace({alias}, tableAlias); // 2. 获取原有WHERE表达式 Expression where plainSelect.getWhere(); // 3. 构建新的WHERE表达式 Expression newWhere; Expression permExpression CCJSqlParserUtil.parseCondExpression(actualCondition); if (where null) { newWhere permExpression; } else { // 使用AND连接原条件和权限条件 AndExpression andExpression new AndExpression(where, permExpression); newWhere andExpression; } // 4. 设置新的WHERE表达式 plainSelect.setWhere(newWhere); // 5. 将修改后的语法树转回SQL字符串 return select.toString(); } private String findTableAlias(PlainSelect plainSelect, String tableName) { // 遍历FromItem查找表名和别名 // 这是一个简化示例实际逻辑需处理带别名、子查询等多种情况 // ... return t; // 返回假设的别名 } }在拦截器的rewriteSql方法中调用此工具类可以极大提升SQL改写的准确性和安全性。4.3 参数同步拦截器中的关键一步当权限条件包含?占位符时改写SQL字符串只是完成了一半工作。必须同步增加参数。public class DataPermissionInterceptor implements Interceptor { // ... 其他代码 private void setAdditionalParameters(BoundSql boundSql, ListObject extraParams) { if (extraParams null || extraParams.isEmpty()) { return; } // 获取原有的参数映射和参数对象 Object parameterObject boundSql.getParameterObject(); ListParameterMapping parameterMappings boundSql.getParameterMappings(); // 创建新的参数映射列表通常是原列表的拷贝因为它是不可变的 ListParameterMapping newParameterMappings new ArrayList(parameterMappings); // 为每个额外的参数创建新的ParameterMapping并添加到列表 Configuration configuration ... // 需要从MappedStatement或其它途径获取Configuration对象 for (Object param : extraParams) { ParameterMapping.Builder builder new ParameterMapping.Builder(configuration, param newParameterMappings.size(), param.getClass()); newParameterMappings.add(builder.build()); } // 合并参数对象通常将原参数对象放入Map与新参数一起构成新的参数Map MapString, Object newParameterMap new HashMap(); if (parameterObject ! null) { // 如果原参数是Map直接放入 if (parameterObject instanceof Map) { newParameterMap.putAll((Map)parameterObject); } else { // 如果原参数是实体对象Mybatis通常会为其生成一个唯一的key如param1 // 这里需要根据实际情况处理一个简单方法是使用MetaObject遍历属性放入Map // 更稳妥的方式是不修改parameterObject而是修改BoundSql的parameterMappings指向一个新的包装对象。 } } // 将额外参数以特定key加入Map这些key需要与生成的?占位符顺序对应 for (int i 0; i extraParams.size(); i) { newParameterMap.put(__dp_ i, extraParams.get(i)); } // 使用反射更新BoundSql中的parameterMappings和parameterObject // 注意此操作因Mybatis版本和封装不同而差异很大是另一个“深水区”。 // 一种常见做法是自定义一个BoundSql包装类在拦截器链中替换掉原来的BoundSql。 } }重要心得参数同步是数据权限拦截器实现中最棘手的部分之一。很多开源方案或文章只提SQL改写却对参数同步避而不谈或一笔带过。在实际开发中我强烈建议参考Mybatis-Plus的TenantLineInnerInterceptor多租户插件的实现它完整地处理了SQL改写和参数同步是非常好的学习范本。如果项目使用了Mybatis-Plus直接继承InnerInterceptor接口并模仿其实现方式会省去很多底层摸索的麻烦。5. 常见问题与排查技巧实录5.1 问题一拦截器不生效或执行顺序错误现象SQL语句没有被修改或者分页查询的总数不对权限过滤条件没有应用到COUNT查询上。排查检查拦截器是否被正确注册到Spring容器和Mybatis的拦截器链中。可以在拦截器的intercept方法入口打日志或断点。重点检查拦截器顺序。如果使用了分页插件PageHelper或Mybatis-Plus的PaginationInnerInterceptor数据权限拦截器必须在分页插件之前。因为分页插件会先执行一个COUNT查询获取总数如果数据权限在分页之后那么这个COUNT查询就无法被过滤导致总数大于实际可见数据数分页逻辑出错。检查Intercepts注解中的Signature定义是否正确是否拦截了StatementHandler.prepare方法。5.2 问题二SQL改写错误导致语法异常现象应用启动后执行查询报SQL语法错误。排查将拦截器改写前后的SQL语句打印到日志中对比分析。这是最直接的调试手段。检查权限条件片段本身是否是合法的SQL。例如当可访问部门列表为空时生成的IN ()是非法SQL应该处理为10。检查对复杂SQL如包含UNION、WITH子句、嵌套子查询的支持。初期可以先将这些复杂SQL模式加入排除名单后续再逐步完善解析器。使用jsqlparser等工具能极大减少此类错误。将原始SQL和改写后的SQL分别用jsqlparser解析一下看是否能成功。5.3 问题三参数绑定异常现象SQL改写正确但执行时抛出“参数数量不匹配”或“参数未设置”异常。排查确认在增加SQL中?占位符的同时是否同步增加了等量的参数值到BoundSql。检查参数对象的类型和ParameterMapping的创建是否正确。特别是当原SQL参数是复杂对象如Map、实体时合并新老参数需要小心处理。在拦截器中打印boundSql.getParameterMappings()的size和boundSql.getParameterObject()的内容与SQL中的?数量进行比对。5.4 问题四性能问题现象在SQL中注入非常复杂的子查询作为过滤条件导致查询性能急剧下降。优化规则简化尽量避免在数据权限规则中引入关联子查询。优先使用简单的IN列表。可以在用户登录或权限变更时将其所有可访问的数据ID如部门ID、项目ID预计算好存入缓存或用户上下文中拦截器直接使用这些ID列表生成IN (...)条件。缓存机制对于同一个用户在同一会话中的多次查询其数据权限规则通常是相同的。可以对计算出的权限SQL片段进行缓存避免重复计算。索引友好确保权限过滤条件所使用的字段如dept_id,create_by在数据库表上建立了合适的索引否则全表扫描加上过滤在大数据量下会很慢。5.5 问题五多表关联查询的过滤歧义现象一个查询关联了多张表如order表JOINcustomer表权限规则是基于customer表的region字段但SQL改写器可能错误地将条件加在了order表上或者不知道该加在哪个表后面。解决明确规则关联表在定义数据权限规则时不仅定义规则内容还要定义该规则关联的业务实体或表名。拦截器在解析SQL时需要找出这个实体对应的表在SQL中的位置可能通过别名。使用注解显式指定在复杂的Mapper查询方法上使用DataPermission(table “customer”, alias “c”)注解明确告知拦截器应该过滤哪个表/别名。谨慎处理JOIN对于多表JOIN默认策略通常是只对主查询的FROM主表施加过滤。如果需要对关联表过滤需要更复杂的规则定义和SQL解析逻辑这可能意味着方案需要升级。6. 进阶思考与扩展方向实现一个基础的数据权限拦截器只是起点。在实际大型应用中我们还会面临更多挑战规则引擎的抽象与扩展当前的规则可能只是“部门ID IN列表”。未来可能需要支持“本人数据”、“本部门及下属部门数据”、“特定角色可见数据”、“自定义动态SQL规则”等多种类型。可以设计一个规则接口和一组实现类通过配置决定使用哪种规则。与系统权限菜单/按钮的融合数据权限通常和功能权限能否访问某个菜单、点击某个按钮紧密相关。需要在设计之初就考虑如何统一管理用户-角色-数据范围之间的关系。数据权限的“穿透”问题例如用户A只能看到自己部门的订单那么他在查看订单列表时能否看到创建这些订单的用户名这些用户可能来自其他部门这涉及到关联数据是否也要受限于当前用户的数据视角。这个问题没有标准答案需要根据业务场景来定。审计与调试线上出现“该用户看不到应看的数据”或“看到了不该看的数据”是严重问题。需要在拦截器中增加详细的调试日志记录谁、在什么时候、对什么SQL、添加了什么过滤条件。这为问题排查提供了关键线索。最后我个人在多次实施这类方案后最深的体会是数据权限拦截器是一个强大的基础设施但切忌过度设计。在项目初期优先用最简单的方式比如明确的注解简单规则解决80%的核心场景。随着业务复杂度的上升再逐步迭代规则引擎和SQL解析的能力。一开始就追求一个能解析所有复杂SQL、支持所有权限规则的万能拦截器很容易陷入开发泥潭。保持代码清晰、可测试、方便替换远比功能强大更重要。当你的拦截器代码变得复杂时就是时候考虑将其拆分为“SQL解析”、“规则计算”、“条件注入”等多个独立模块的时候了。