
简介Java结合Mybatis实现数据权限控制是To B项目中的常见需求。这份PDF详解文档面向有一定Mybatis基础、希望在不改动业务代码的前提下按角色动态过滤查询结果的Java开发人员。内容从RBAC四个要素出发梳理URL、界面元素与数据资源三类控制目标并重点讲解利用Mybatis Plugin机制与jsqlparser解析改写SQL的实现思路。文档中给出PermissionRule与PermissionHelper两个核心类的结构示例说明角色、实体、过滤表达式的配置方式也坦诚分析该方案在数据库方言、非关系型数据支持等方面的局限。包体为单个PDF文件压缩后大小93KB便于快速下载与离线阅读。资源上线以来已有4593人学习适合需要深入理解数据权限控制原理或在现有Mybatis项目中引入轻量级权限拦截机制的开发者参考。1. 先说结论数据权限控制为什么难在“看不见摸不着”做后台管理系统功能权限用 Shiro 或 Spring Security 就能挡住用户不该点的按钮但数据权限是另一回事——同一个列表接口A 角色只能看到自己部门的单子B 角色能看到整个大区的数据C 角色连金额字段都要置空。如果每个 Service 里都写if (role xxx) { query.setDeptId(...) }第一周还撑得住迭代两个月后你会发现权限判断散落在几十个方法里漏一个就是一次数据泄露事故。Mybatis 做数据权限控制的思路和传统写法完全不同它不是在业务代码里加判断而是在 SQL 执行前把权限条件注入到语句里让数据库自己去过滤。这样做的好处是权限逻辑集中、改动小、不容易漏难点在于怎么把条件拼得安全、优雅、不破坏原有 SQL。这篇文章就从拦截器原理、动态 SQL 改写、注解驱动、缓存避坑一直讲到自测方法每一步都给可复现的代码和参数说明你看完可以直接改造自己项目里的 Mapper。2. 先搞懂 Mybatis 的拦截器机制数据权限注入的“挂钩点”在哪2.1 Mybatis 四大组件里谁最适合做权限注入Mybatis 允许通过Interceptor拦截四大核心组件Executor、StatementHandler、ParameterHandler、ResultSetHandler。它们各管一段Executor负责调度整个 SQL 执行流程StatementHandler负责创建Statement并设置参数ParameterHandler负责给预编译 SQL 绑定参数ResultSetHandler负责把结果集映射成对象。数据权限注入必须在 SQL 组装完成后、发送到数据库之前而且最好能拿到完整 SQL 文本和参数对象。这样一对比StatementHandler的prepare()方法最合适——此时BoundSql已经生成里面包含最终 SQL、参数映射和参数值而且执行计划还没创建。老一点的做法是拦Executor的query()方法也能改 SQL但如果遇到嵌套查询或者延迟加载Executor层拿到的BoundSql和实际执行的并不完全一致容易改错地方。提示StatementHandler是更安全的挂载点但改动时要注意兼容 Mybatis 3.4 之后的版本因为prepare()方法签名在不同版本间有细微变化。2.2 写一个最小拦截器先学会拦截再谈权限先别急着写权限逻辑把拦截器框架搭出来、验证能拦到所有查询语句这是最稳妥的第一步。下面这个拦截器拦截所有查询操作并且打印出 Mapper 方法的完整 ID 和 SQL 语句Intercepts({ Signature(type StatementHandler.class, method prepare, args {Connection.class, Integer.class}) }) public class DataPermissionInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler statementHandler (StatementHandler) invocation.getTarget(); BoundSql boundSql statementHandler.getBoundSql(); String sql boundSql.getSql(); // MappedStatement 里能拿到 Mapper 方法的全限定名例如 com.example.mapper.OrderMapper.selectPage MappedStatement mappedStatement (MappedStatement) statementHandler.getParameterHandler().getParameterObject(); // 先打印出来确认拦截链路是否通畅 System.out.println(拦截到 SQL: sql); return invocation.proceed(); } Override public Object plugin(Object target) { return Plugin.wrap(target, this); } Override public void setProperties(Properties properties) { // 可以在 mybatis-config.xml 里通过 property 标签注入配置 } }这段代码里有两个关键点。第一Signature里 method 写的是prepareargs 是Connection.class和Integer.class这是 Mybatis 3.4 的固定签名写错任何一个都拦不到。第二BoundSql是整个拦截器操作的“黑匣子”它有四个核心方法getSql()拿 SQL 文本、getParameterMappings()拿参数映射列表、getParameterObject()拿实际参数值、getAdditionalParameters()拿附加参数。其中getAdditionalParameters()里存的是分页插件等组件动态追加的参数改 SQL 时不能丢。把这个拦截器注册进 Mybatis 有两种方式XML 配置时用plugins标签Spring Boot 场景下注册一个ConfigurationCustomizerBean。初学阶段推荐在mybatis-config.xml里配简单直接出了问题也好排查。2.3 注册拦截器时最容易被忽略的问题拦截器注册位置不对是新手最常见的翻车点。如果是 Spring Boot Mybatis 自动配置在MybatisAutoConfiguration之后注入拦截器才能生效正确做法是实现ConfigurationCustomizerConfiguration public class MybatisConfig { Bean public ConfigurationCustomizer dataPermissionCustomizer() { return configuration - { DataPermissionInterceptor interceptor new DataPermissionInterceptor(); interceptor.setProperties(new Properties()); configuration.addInterceptor(interceptor); }; } }这里的configuration.addInterceptor()每次都会把拦截器追加到拦截器链的末尾所以要注意一个坑如果你的项目里已经用了 PageHelper 等分页插件分页插件会先执行。分页插件会改写 SQL 并设置RowBounds相关参数你的权限拦截器如果也在Executor层改写 SQL可能会和分页插件的 SQL 解析逻辑冲突。把权限拦截器放在分页插件之前还是之后直接影响到分页 count 查询是否带权限条件这个细节在后面的避坑章节会专门展开。3. 动态改写 SQL从“能拦到”到“能改对”的关键一跃3.1 为什么不能直接拼字符串数据权限最朴素的做法就是在原始 SQL 后面直接加一段AND dept_id ?但真这么干会踩三个坑第一SQL 里如果已经有 WHERE 条件直接加 AND 会变成WHERE a 1 AND dept_id ?没问题但如果没有 WHERE而是FROM table ORDER BY就直接加出了语法错误。第二带子查询的复杂 SQL权限条件加在最外层可能会导致子查询先过滤了一层、外层又过滤一层结果集不对。第三如果原 SQL 里已经有dept_id相关的条件两个条件叠在一起反而把数据范围搞错。所以正确的姿势是把 SQL 解析成可操作的结构定位到 WHERE 子句的末尾再拼接权限条件。市面上有 JSqlParser 和 Druid SQL 解析器两条路Mybatis 生态里的通用做法是 JSqlParser它能把 SQL 解析成抽象语法树然后通过访问器模式修改 WHERE 部分。3.2 用 JSqlParser 改写 WHERE 子句完整可跑通的示例下面这段代码展示了如何安全地在查询 SQL 中注入部门权限条件覆盖了带 WHERE、不带 WHERE、带 ORDER BY/LIMIT 三种情况import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.LongValue; import net.sf.jsqlparser.expression.operators.relational.EqualsTo; import net.sf.jsqlparser.parser.CCJSqlParserUtil; import net.sf.jsqlparser.statement.select.PlainSelect; import net.sf.jsqlparser.statement.select.Select; import net.sf.jsqlparser.statement.select.SelectBody; public class SqlPermissionUtil { public static String injectPermission(String sql, String permissionColumn, Long permissionValue) throws Exception { Select select (Select) CCJSqlParserUtil.parse(sql); SelectBody selectBody select.getSelectBody(); if (!(selectBody instanceof PlainSelect)) { // 带 UNION、WITH 等复杂结构的 SQL这里先跳过后面会讲处理策略 return sql; } PlainSelect plainSelect (PlainSelect) selectBody; // 构造 dept_id ? 条件这里先用 LongValue 做演示生产环境建议用占位符 EqualsTo permissionCondition new EqualsTo(); permissionCondition.setLeftExpression(new Column(permissionColumn)); permissionCondition.setRightExpression(new LongValue(permissionValue)); // 如果原 SQL 有 WHERE用 AND 拼接否则直接作为 WHERE Expression where plainSelect.getWhere(); if (where null) { plainSelect.setWhere(permissionCondition); } else { AndExpression andExpression new AndExpression(where, permissionCondition); plainSelect.setWhere(andExpression); } return plainSelect.toString(); } }这段代码的核心逻辑是用CCJSqlParserUtil.parse()把 SQL 字符串解析成Select对象判断查询体是不是PlainSelect即最简单的 SELECT 语句再拿到当前的 WHERE 表达式最后把权限条件用AND拼进去。EqualsTo在 JSqlParser 里代表一个等值比较操作Column是列名LongValue是数值字面量。注意上面代码里权限值直接写死在 SQL 里这是为了演示解析过程。生产环境千万别这么干一定要改成占位符?并将值放到参数列表中否则第一防不了 SQL 注入第二 Mybatis 的预编译机制会被绕过性能和安全都会出问题。3.3 参数怎么传改写 SQL 的同时改写参数列表如果你用了占位符方案那么BoundSql里的参数映射也需要同步修改。Mybatis 的BoundSql在创建后是不可变的它的parameterMappings是ListParameterMapping可以通过反射改字段。常见做法是// 拿到原始 SQL 后先记录原始参数个数 ListParameterMapping originalMappings new ArrayList(boundSql.getParameterMappings()); // 在 SQL 中构造新增的占位符用 ? 而不是直接拼值 String newSql sql AND dept_id ?; // 通过反射修改 BoundSql 的 sql 字段和 parameterMappings 字段 Field sqlField BoundSql.class.getDeclaredField(sql); sqlField.setAccessible(true); sqlField.set(boundSql, newSql); // 为新增的占位符构造 ParameterMapping ParameterMapping newMapping new ParameterMapping.Builder(configuration, deptId, Long.class).build(); // 反射修改 parameterMappings追加新映射 Field mappingsField BoundSql.class.getDeclaredField(parameterMappings); mappingsField.setAccessible(true); ListParameterMapping newMappings new ArrayList(originalMappings); newMappings.add(newMapping); mappingsField.set(boundSql, newMappings);这段代码干了两件事第一在 SQL 末尾追加AND dept_id ?第二把deptId的映射信息追加到BoundSql的参数映射列表里。这样 Mybatis 在执行预编译时会自动从参数对象中读取deptId对应的值并绑定到?上。这里有个细节决定成败参数对象是什么类型。如果 Mapper 方法的参数是单个实体对象比如Order那deptId必须能通过order.getDeptId()取到如果参数是Param(userId) Long userId这种散参Mybatis 会把参数包装成ParamMap或者MapperMethod.ParamMap那你的deptId要么在 Map 里能取到要么得用一个特殊的AdditionalParameter机制。最常见的做法是不要让权限值依赖业务参数而是由拦截器从ThreadLocal或 Spring Security 上下文中获取当前用户信息再构造独立的参数映射。3.4 JOIN、子查询、UNION复杂 SQL 怎么处理实际项目中的 SQL 不可能都是简单的单表查询。一个订单列表联了三张表还有子查询统计金额这时候权限条件加在哪里加错了要么数据不全要么压根报错。先说 JOIN 场景如果用户权限是“只看本部门订单”而订单表order里没有dept_id但order的seller_id关联用户表用户表里有dept_id那权限条件应该加在 JOIN 条件里或者加到 WHERE 中并通过子查询关联-- 推荐做法用 EXISTS 子查询关联而不是直接改 JOIN 条件 SELECT o.id, o.order_no, o.amount FROM orders o WHERE EXISTS ( SELECT 1 FROM sys_user u WHERE u.id o.seller_id AND u.dept_id #{deptId} )用 EXISTS 的好处是无论原 SQL 怎么 JOIN、怎么分组EXISTS 子查询都能保持语义正确而且不会影响原有的 JOIN 结果集数量。如果用 JSqlParser 做改写可以构造一个ExistsExpression包裹子查询然后作为新增条件拼到 WHERE 末尾。UNION 场景更恶心两个 SELECT 用 UNION 拼在一起权限条件得分别注入到每个子查询里而不是只在最外层加一次。同样带 WITH 的 CTE 也需要注意——权限条件要注入到引用 CTE 的最外层查询而不是 CTE 内部。这些情况用 JSqlParser 都能拿到SetOperationList和WithItem去逐层处理但业务上如果你能限定“所有受控查询都是单层 SELECT”能少掉一半麻烦。我一般会在拦截器里做个降级策略如果 SQL 结构太复杂解析失败宁可放行也不能把 SQL 改坏——权限问题可以靠后续测试补SQL 语法错误直接导致线上故障。4. 用注解把权限策略抽出来一条 Mapper 只加一行注释4.1 先看效果一个注解搞定权限控制纯手工在拦截器里写死规则等于把权限逻辑又塞回代码里了只是从 Service 换到了 Interceptor没什么本质区别。更优雅的模式是用自定义注解描述“这个方法需要什么权限”拦截器读到注解后按规则动态生成条件。举个例子Mapper public interface OrderMapper { DataPermission(type DeptScope.class, column dept_id, joinTable orders) ListOrder selectPage(PageParam page, Param(query) OrderQuery query); }这个注解表达的意思是selectPage方法执行时必须按当前用户的部门范围过滤orders表的dept_id列。拦截器在运行时检查MappedStatement对应的 Mapper 方法上有没有DataPermission注解有就解析并注入条件没有就放行完全不影响老代码。4.2 注解定义和拦截器联动完整代码Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface DataPermission { // 权限类型决定条件怎么生成 Class? extends PermissionStrategy type() default DeptScopeStrategy.class; // 要约束的列名如 dept_id String column() default ; // 表别名或表名用于处理 JOIN 场景如 o.dept_id 里的 o String tableAlias() default ; }配合注解的策略接口每个策略实现一个方法这样以后新加权限范围不用改拦截器public interface PermissionStrategy { String buildCondition(DataPermission annotation, UserContext user, String sql); }public class DeptScopeStrategy implements PermissionStrategy { Override public String buildCondition(DataPermission annotation, UserContext user, String sql) { Long deptId user.getDeptId(); if (deptId null) { throw new PermissionDeniedException(当前用户无部门信息禁止访问受控数据); } // 表别名 列名的拼接等价于写入 dept_id #{deptId} return annotation.getTableAlias() . annotation.getColumn() deptId; } }拦截器里的核心流程变成从MappedStatement拿Id如com.example.mapper.OrderMapper.selectPage→ 反射加载 Mapper 接口 → 找到对应方法 → 读取注解 → 调用策略生成条件 → 注入 SQL。写这套代码时有个小坑Invocation里能拿到MappedStatement但它上面并不直接带着 Mapper 方法引用你只能通过MappedStatement.getId()拿到字符串形式的全限定名。反射加载类时要注意ClassLoader问题如果项目用了热部署插件类可能被重复加载注解读不到是常事——付费的 JRebel 用户尤其容易踩这个。4.3 按钮级 vs 数据级权限模型边界说清楚很多团队把字段权限和数据权限混在一起做。比如“A 角色看不到订单金额”是字段权限应该在ResultSetHandler层做结果集脱敏“A 角色只能看自己部门的订单”才是数据权限在 SQL 层做过滤。我的建议是数据权限务必在 SQL 层解决字段权限可以放到结果映射层但别把两套逻辑耦合在一个拦截器里。因为数据权限只影响“查多少行”字段权限影响“每行显示什么”两个目标不同代码写一起以后调整起来非常痛苦。5. 数据权限 Mybatis 缓存这是一对天然的冤家5.1 一级缓存和二级缓存为什么会串数据Mybatis 自带一级缓存SqlSession级别的本地缓存和二级缓存namespace级别的全局缓存。数据权限控制天生和缓存冲突——如果第一次查询时当前用户是 ASQL 被注入dept_id 1的条件查询结果被缓存了换一个 B 用户来查同一个方法明明应该注入dept_id 2但 Mybatis 一看namespace缓存没失效直接把之前的结果返回了。数据就串了而且是权限事故级别的串。selectPage这种分页查询一般不命中缓存但像selectById这种单行查询、或者被其他方法复用的查询就很容易踩雷。所以结论是凡是被数据权限拦截器接管的方法必须关掉二级缓存。具体做法是mapper namespacecom.example.mapper.OrderMapper !-- 关掉二级缓存或在全局配置里关闭 -- cache typeorg.mybatis.caches.ehcache.EhcacheCache evictionFIFO flushInterval60000 size512 readOnlytrue/ /mapperreadOnlytrue是好习惯权限过滤后的结果本来就不该被不同用户共享只读缓存能省掉一大堆序列化问题和脏数据问题。如果你必须开缓存那就在拦截器里把当前用户的部门 ID 拼到CacheKey上Executor.query()方法的CacheKey参数可以自定义更新让不同用户走不同缓存条目。这种做法能解决问题但缓存命中率会断崖式下跌等于是花钱买了安全。5.2 分页插件和权限拦截器的执行顺序PageHelper 这类分页插件拦截的是Executor的query()方法它会把原始 SQL 改写成SELECT COUNT(*)和LIMIT ?,?两条语句。如果你的权限拦截器拦的是StatementHandler两者执行顺序一般没问题但如果你的权限拦截器也在Executor层就要特别注意分页插件会先执行还是后执行直接决定COUNT(*)里有没有权限条件。正确的顺序是权限拦截器先执行分页插件后执行。这样分页插件生成的 count 查询是基于已经注入权限条件的 SQL 来的总条数才是用户真实可见的数据量。如果顺序反了count 查出来是所有数据的总数用户看到“共 100 条”但实际只有 10 条能看这体验不说炸裂至少是严重的逻辑漏洞。Spring Boot 里配置顺序的方法是在ConfigurationCustomizer里保证 addInterceptor 的先后顺序——后添加的拦截器在链上的位置更靠后注意 Mybatis 是“先添加先执行”还是“后添加先执行”取决于插件链的包装方式实际开发时用日志确认一下即可。5.3 三个常见问题快查表现象原因解决换用户后查到旧用户的数据二级缓存被命中受控 Mapper 关闭二级缓存或用 CacheKey 区分分页总条数比实际看得到的数据多权限条件晚于分页条件注入调整拦截器顺序权限先加分页后加加了权限条件后 SQL 报语法错误原 SQL 有 UNION/复杂嵌套WHERE 拼接位置错了上 JSqlParser 解析后正确注入或降级放行6. 避坑清单数据权限上线前必须做的五件事6.1 反射修改BoundSql时的版本兼容问题Mybatis 3.4 之前和之后BoundSql的字段名有变化字段不是sql就是sqlSource不同版本映射关系不同反射代码写死了版本号就等着升级时翻车。我一般会在代码里做一次反射字段探测先尝试sql再尝试sqlSource都找不到就直接抛异常宁可启动时挂也不愿运行时静默失败。6.2#{}和${}混用导致的 SQL 注入有些团队的数据权限值是从用户会话里取出来的如果注入方式用了#{}预编译安全没问题但有些为了图省事直接拼${deptId}一旦部门 ID 被篡改等于把整个表的查询条件都变成用户可控的这是数据权限控制里最致命的漏洞。原则是任何来自运行时上下文的值不管看起来多“安全”一律走#{}占位符。6.3 权限条件加在ON还是在WHERE上的语义差异跟着别人博客写的时候看到他把权限条件追加到JOIN ... ON后面就照抄了。注意ON后面加条件影响的是匹配方式左连接时会把主表数据全部保留权限完全失效。你加在WHERE后面才是真正的过滤。写代码前先想清楚业务语义左连接场景尤其别踩。6.4 测试环境的权限账号体系和生产不一致数据权限的逻辑测试环境很难完全覆盖最常见的就是“测试环境一跑全过生产环境某些领导角色看不到子部门数据”。原因是权限条件写死了“等于当前部门 ID”但真正的需求可能是“等于当前部门及所有子部门”。这个递归查询能用dept_id IN (SELECT id FROM dept WHERE path LIKE ?)解决但对部门表的设计有要求。上线前必须把权限模型和部门层级结构的匹配关系理清楚否则只能在线上反复救火。6.5 启动时没问题跑批任务时数据漏了定时任务、MQ 消费线程里通常没有用户上下文UserContext从 ThreadLocal 取出来是 null拦截器直接报错或者放行。报错算好的最怕放行——跑批任务把全量数据发给了下游等于数据权限在非 Web 场景下形同虚设。解决思路是拦截器遇到无上下文时默认拒绝受控查询然后跑批任务显式设置一个“系统用户”上下文并且该用户能看到的范围要和业务方确认清楚。7. 进阶级如何自测和验收数据权限控制效果当你把拦截器和注解都做完了离真正上线还差一步如何验证它真的生效了。肉眼查代码只能确认“写了”不能确认“写对了”。我的自测方案分三层。第一层是配置环境参数启用 Mybatis SQL 日志。把mybatis.configuration.log-impl设为org.apache.ibatis.logging.stdout.StdOutImpl或使用MapperScan项目中的application.yml里配logging.level.com.example.mapperDEBUG让控制台打印出注入后的最终 SQL。这一步能直接看到权限条件是否拼对了位置、有没有被括号包住、关联了哪个表。第二层是写一个集成测试造三个用户部门 A、部门 B、系统管理员分别调用同一个 Mapper 方法断言返回数据条数和首条数据的主键范围。不要手动去数据库里数数自动化断言才能防止后续改动把权限条件改没了。测试里可以故意把部门 ID 换成不存在的值查出来为空是对的至少证明 SQL 没有被静默吞掉。第三层是性能验证。权限条件注入后要关注是否让原本走索引的查询失效了。比如你注入的dept_id ?如果列上没索引每次查询全表扫描那功能没问题、性能事故。用EXPLAIN看一下执行计划确认索引命中情况是数据权限上线前最不值得省的一步。最后一个我自己的习惯是在拦截器入口加一个开关配置style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />