基于MyBatis插件实现数据权限控制:原理、实现与避坑指南 1. 项目背景与核心痛点最近在做一个后台管理系统涉及到多租户、多部门的数据隔离需求。简单来说就是不同角色的用户登录后只能看到自己权限范围内的数据。比如部门经理只能看到本部门的订单普通员工只能看到自己创建的工单而超级管理员则能看到全公司的数据。这个需求在业内通常被称为“数据权限”或“行级权限”。听起来很简单不就是加个where条件吗但真做起来你会发现到处都是坑。最直接的实现方式就是在每一个查询数据库的地方手动拼接上user_id ?或者dept_id in (?)这样的条件。项目初期业务简单查询不多这么干还能应付。但随着业务模块爆炸式增长你会发现同样的权限过滤逻辑在几十个、上百个Mapper接口的SQL里重复出现。一旦权限规则发生变动比如从按部门过滤改成按项目组过滤那就是一场灾难你需要像大海捞针一样去修改每一个相关的SQL语句漏掉一个就可能造成严重的数据泄露风险。所以我们需要一个更优雅、更解耦的方案将数据权限的控制逻辑从业务SQL中剥离出来进行统一管理和自动注入。这样业务开发人员写SQL时只需要关注业务逻辑本身数据权限作为一个“切面”自动生效。MyBatis作为Java生态中最主流的ORM框架其强大的插件机制为我们实现这个目标提供了可能。今天我就结合Spring Boot和MyBatis来详细拆解一下数据权限的完整实现方案包括核心原理、详细步骤、避坑指南以及我趟过的一些雷。2. 方案选型为什么是 MyBatis 插件实现数据权限业内主要有几种思路我们来逐一分析看看为什么最终我选择了基于MyBatis插件的方案。2.1 几种常见方案的对比方案一在每一个Service或Mapper方法中硬编码这是最原始的方法。缺点刚才已经说了耦合度高难以维护违反DRY原则基本不可取。方案二使用视图View在数据库层为不同角色创建不同的视图业务代码直接查询视图。这种方式将权限逻辑下沉到数据库对应用透明。但缺点也很明显视图多了难以管理权限规则变更特别是动态规则需要DBA介入修改视图不灵活复杂的多表关联权限用视图实现会很繁琐。方案三使用 Spring AOP 拦截 Service 层在Service层的方法执行前后进行拦截动态修改传入Mapper的参数或对查询结果进行过滤。这种方式比硬编码好但存在局限性。对于查询你需要在AOP里解析方法参数构造复杂的权限条件对象逻辑会很重。更麻烦的是它难以处理MyBatis中动态SQL如if标签与权限条件的无缝拼接。方案四使用 MyBatis 插件Interceptor这是我们的主角。MyBatis插件可以拦截四大核心对象Executor,StatementHandler,ParameterHandler,ResultSetHandler的方法执行。我们可以在SQL被送往数据库执行前拦截并修改它动态注入权限过滤条件。这是最彻底的方案因为它作用于SQL生成这个最底层、最统一的环节。解耦彻底业务代码零侵入Mapper接口和XML中的SQL无需关心权限。灵活强大可以获取到完整的SQL语句、参数映射、MappedStatement等信息能实现非常复杂的权限规则注入。统一管理所有数据操作入口都被插件把守安全边界清晰。2.2 MyBatis 插件的工作原理与拦截点选择MyBatis插件通过实现Interceptor接口并使用Intercepts和Signature注解来声明要拦截的方法。Intercepts({ Signature(type StatementHandler.class, method prepare, args {Connection.class, Integer.class}) }) public class DataPermissionInterceptor implements Interceptor { // ... 实现逻辑 }这里最关键的是拦截点的选择。我们应该拦截StatementHandler.prepare方法。因为在这个时间点SqlSource已经根据DynamicContext等解析出了最终的、待执行的SQL字符串但还没有创建PreparedStatement同时BoundSql中也包含了所有的运行时参数。这是我们修改SQL的最佳时机。注意不要拦截Executor的query或update方法。虽然那里也能拿到SQL但Executor层面处理的是多个语句的执行过程如批处理、缓存而StatementHandler才是真正负责单个语句生成和参数设置的在这里修改更精准副作用更小。3. 核心实现构建数据权限拦截器理论说完了我们开始动手。整个核心在于实现这个DataPermissionInterceptor。我会分步讲解并附上关键代码。3.1 定义权限上下文与规则引擎首先我们需要一个地方来存放当前用户的权限信息比如用户ID、角色列表、部门ID、数据权限范围标识等。通常我们会把它放在ThreadLocal里确保线程安全。public class DataPermissionContextHolder { private static final ThreadLocalDataPermissionContext CONTEXT_HOLDER new ThreadLocal(); public static void setContext(DataPermissionContext context) { CONTEXT_HOLDER.set(context); } public static DataPermissionContext getContext() { return CONTEXT_HOLDER.get(); } public static void clearContext() { CONTEXT_HOLDER.remove(); } Data public static class DataPermissionContext { // 用户唯一标识 private Long userId; // 用户所属部门ID private Long deptId; // 用户角色列表 private ListString roles; // 数据权限范围ALL-全部DEPT-本部门及以下DEPT_ONLY-仅本部门SELF-仅自己 private String dataScope; // 其他自定义属性如项目组ID等 private MapString, Object attributes; } }然后定义一个规则引擎。它的职责是根据DataPermissionContext和当前要执行的SQL信息表名、操作类型生成对应的WHERE条件片段。这里我们可以设计一个简单的接口和基于配置的实现。public interface DataPermissionRuleEngine { /** * 判断是否需要对此SQL进行权限过滤 * param mappedStatementId MyBatis的语句ID * return true 需要过滤 */ boolean needFilter(String mappedStatementId); /** * 根据上下文生成数据权限SQL条件片段 * param context 权限上下文 * param tableAlias 查询中表的别名可能为空 * return SQL条件片段如 AND dept_id 123 */ String buildCondition(DataPermissionContext context, String tableAlias); }一个简单的实现可能是这样的基于注解或配置文件Component public class DefaultDataPermissionRuleEngine implements DataPermissionRuleEngine { // 可以从数据库或配置文件中加载规则 private MapString, String statementRuleMap new HashMap(); public DefaultDataPermissionRuleEngine() { // 示例key为Mapper方法全限定名value为规则表达式 statementRuleMap.put(com.example.mapper.OrderMapper.selectList, order); statementRuleMap.put(com.example.mapper.UserMapper.selectPage, user); } Override public boolean needFilter(String mappedStatementId) { // 排除一些不需要过滤的方法比如登录、获取用户信息等 if (mappedStatementId.contains(.UserMapper.selectByLogin)) { return false; } return statementRuleMap.containsKey(mappedStatementId); } Override public String buildCondition(DataPermissionContext context, String tableAlias) { if (context null) { return ; } String prefix tableAlias null ? : tableAlias .; StringBuilder condition new StringBuilder( AND ); switch (context.getDataScope()) { case ALL: return ; // 全部数据不加条件 case DEPT: // 假设有部门层级表这里简化处理为本部门 condition.append(prefix).append(dept_id ).append(context.getDeptId()); break; case SELF: condition.append(prefix).append(create_by ).append(context.getUserId()).append(); break; // ... 其他规则 default: condition.append(11); // 默认无权限 } return condition.toString(); } }3.2 实现拦截器逻辑现在我们来编写拦截器本身。它的核心逻辑是拦截prepare方法。判断当前执行的SQL是否需要添加数据权限。如果需要则解析原始SQL找到合适的插入点通常在WHERE关键字后拼接上我们生成的权限条件。使用修改后的SQL替换原来的BoundSql。Slf4j Component Intercepts({ Signature(type StatementHandler.class, method prepare, args {Connection.class, Integer.class}) }) public class DataPermissionInterceptor implements Interceptor { Autowired private DataPermissionRuleEngine ruleEngine; // 用于匹配WHERE关键字考虑换行和大小写 private static final Pattern WHERE_PATTERN Pattern.compile(\\s[Ww][Hh][Ee][Rr][Ee]\\s); // 匹配GROUP BY/ORDER BY等子句用于确定WHERE条件插入的边界 private static final Pattern GROUP_ORDER_LIMIT_PATTERN Pattern.compile(\\s(GROUP BY|ORDER BY|LIMIT|OFFSET)\\s, Pattern.CASE_INSENSITIVE); Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler statementHandler (StatementHandler) invocation.getTarget(); MetaObject metaObject SystemMetaObject.forObject(statementHandler); // 分离代理对象链获取原始的StatementHandler可能被多次代理 while (metaObject.hasGetter(h)) { Object h metaObject.getValue(h); metaObject SystemMetaObject.forObject(h); } while (metaObject.hasGetter(target)) { Object target metaObject.getValue(target); metaObject SystemMetaObject.forObject(target); } // 获取MappedStatement和BoundSql MappedStatement mappedStatement (MappedStatement) metaObject.getValue(delegate.mappedStatement); BoundSql boundSql (BoundSql) metaObject.getValue(delegate.boundSql); String originalSql boundSql.getSql(); // 1. 判断是否需要数据权限过滤 if (!ruleEngine.needFilter(mappedStatement.getId())) { return invocation.proceed(); } DataPermissionContext context DataPermissionContextHolder.getContext(); if (context null) { log.warn(DataPermissionContext is null for statement: {}, mappedStatement.getId()); return invocation.proceed(); } // 2. 生成权限条件片段 // 这里需要解析SQL获取主查询的表别名这是一个复杂点简化处理先假设表别名或使用空 String tableAlias parseTableAlias(originalSql); // 需要自己实现一个简单的解析器 String permissionCondition ruleEngine.buildCondition(context, tableAlias); if (StringUtils.isEmpty(permissionCondition)) { // 无需添加条件如权限为ALL return invocation.proceed(); } // 3. 修改SQL注入权限条件 String newSql injectCondition(originalSql, permissionCondition); log.debug(Original SQL: {}, originalSql); log.debug(New SQL with data permission: {}, newSql); // 4. 替换BoundSql中的SQL metaObject.setValue(delegate.boundSql.sql, newSql); // 5. 继续执行原流程 return invocation.proceed(); } /** * 将权限条件注入到原始SQL中 */ private String injectCondition(String originalSql, String condition) { if (!StringUtils.hasText(condition)) { return originalSql; } condition condition.trim(); // 确保条件以 AND 开头 if (!condition.toUpperCase().startsWith(AND)) { condition AND condition; } String upperCaseSql originalSql.toUpperCase(); int whereIndex upperCaseSql.indexOf( WHERE ); if (whereIndex -1) { // 原SQL没有WHERE子句需要构造 // 找到ORDER BY/GROUP BY/LIMIT等子句的位置 Matcher matcher GROUP_ORDER_LIMIT_PATTERN.matcher(originalSql); if (matcher.find()) { int insertPos matcher.start(); return originalSql.substring(0, insertPos) WHERE 11 condition originalSql.substring(insertPos); } else { // 没有这些子句直接追加在最后 return originalSql WHERE 11 condition; } } else { // 原SQL有WHERE子句在WHERE之后插入 // 找到WHERE关键字在原始SQL保留大小写中的准确位置 Matcher whereMatcher WHERE_PATTERN.matcher(originalSql); if (whereMatcher.find()) { int insertPos whereMatcher.end(); // WHERE关键字之后的位置 return originalSql.substring(0, insertPos) condition originalSql.substring(insertPos); } // 如果正则没匹配到理论上不会fallback到原逻辑 return originalSql.replaceFirst((?i)\\sWHERE\\s, WHERE condition AND ); } } // 简单的表别名解析示例实际应用需要更复杂的解析如使用JSqlParser private String parseTableAlias(String sql) { // 这是一个极其简化的示例仅用于说明。 // 真实场景强烈建议使用JSqlParser等SQL解析库。 // 例如假设我们约定查询主表别名为 t return t; } Override public Object plugin(Object target) { return Plugin.wrap(target, this); } Override public void setProperties(Properties properties) { // 可以从配置文件中读取属性 } }3.3 注册拦截器与填充上下文拦截器写好了需要把它注册到MyBatis的配置中。在Spring Boot中我们可以通过一个配置类来实现。Configuration public class MyBatisConfig { Bean public DataPermissionInterceptor dataPermissionInterceptor() { return new DataPermissionInterceptor(); } /** * 将拦截器添加到MyBatis的插件链中 */ Bean public ConfigurationCustomizer mybatisConfigurationCustomizer(DataPermissionInterceptor interceptor) { return configuration - configuration.addInterceptor(interceptor); } }接下来我们需要在用户请求进入时填充DataPermissionContextHolder。这通常在一个Filter或Spring Interceptor中完成。Component public class DataPermissionContextFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { // 1. 从Session、Token或请求头中获取当前用户信息 // 这里假设你有一个UserService能从安全上下文中获取用户详情 User currentUser getCurrentUser(request); if (currentUser ! null) { // 2. 构建权限上下文 DataPermissionContext context new DataPermissionContext(); context.setUserId(currentUser.getId()); context.setDeptId(currentUser.getDeptId()); context.setRoles(currentUser.getRoles()); context.setDataScope(currentUser.getDataScope()); // 这个字段需要你在用户表或角色权限表中定义 // 3. 设置到ThreadLocal DataPermissionContextHolder.setContext(context); } filterChain.doFilter(request, response); } finally { // 4. 请求结束后务必清除ThreadLocal防止内存泄漏 DataPermissionContextHolder.clearContext(); } } private User getCurrentUser(HttpServletRequest request) { // 实现你的用户获取逻辑例如从Spring Security的SecurityContextHolder中获取 // 示例return (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); return null; // 模拟 } }记得在SecurityConfig或WebMvcConfig中注册这个Filter。4. 高级场景与避坑指南基础功能跑通了但在实际项目中你会遇到更复杂的情况。下面分享几个我踩过的坑和解决方案。4.1 多表关联查询的权限注入这是最常见也最头疼的问题。我们的SQL常常是多表JOIN权限条件应该加在哪个表上比如SELECT o.*, u.name FROM order o LEFT JOIN user u ON o.create_by u.id WHERE o.status 1如果权限规则是“只能看自己创建的订单”那么条件o.create_by ?应该加在order表上。我们的拦截器需要能识别出SQL中的主表或者业务表并针对性地添加条件。解决方案使用注解标记在Mapper方法上使用自定义注解显式指定需要过滤的表名和别名。DataPermission(table order, alias o) ListOrderVO selectOrderList(OrderQuery query);拦截器解析这个注解获取表名和别名然后生成条件。这种方式最清晰但需要开发人员手动添加注解。使用SQL解析库引入JSqlParser这样的库在拦截器中解析SQL的抽象语法树AST。你可以分析FROM和JOIN子句识别出所有表然后根据某种规则如第一个表、或根据表名字典确定主业务表。这种方式更智能但复杂度高性能有轻微损耗且需要处理各种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; private String parseMainTableWithJSqlParser(String sql) { try { Statement statement CCJSqlParserUtil.parse(sql); if (statement instanceof Select) { Select select (Select) statement; if (select.getSelectBody() instanceof PlainSelect) { PlainSelect plainSelect (PlainSelect) select.getSelectBody(); FromItem fromItem plainSelect.getFromItem(); if (fromItem instanceof Table) { Table table (Table) fromItem; return table.getAlias() ! null ? table.getAlias().getName() : table.getName(); } } } } catch (JSQLParserException e) { log.error(Failed to parse SQL for data permission, e); } return null; }4.2 分页插件如 PageHelper的兼容性问题如果你的项目使用了PageHelper你会发现它也是一个MyBatis插件。插件执行是有顺序的。如果我们的数据权限插件在PageHelper之后执行那么PageHelper生成的count查询语句用于计算总数可能不会被我们的插件处理导致分页总数计算错误。解决方案 明确指定插件的执行顺序。在Spring Boot中可以通过Order注解或ConfigurationCustomizer中的添加顺序来控制。通常数据权限插件应该在分页插件之后执行以确保count查询也能被正确过滤。Bean public ConfigurationCustomizer mybatisConfigurationCustomizer(DataPermissionInterceptor dpInterceptor, PageInterceptor pageInterceptor) { return configuration - { configuration.addInterceptor(pageInterceptor); // 先加PageHelper configuration.addInterceptor(dpInterceptor); // 后加数据权限 }; }实测经验一定要测试带分页的查询对比插件生效前后的total数确保count查询的SQL也被正确修改了。这是最容易出问题的地方。4.3 权限条件的冲突与重复添加想象一个场景业务SQL本身有一个WHERE create_by #{userId}比如查询“我的待办”而我们的数据权限规则也是SELF只能看自己的数据。如果插件不加判断地直接追加一个AND create_by ?就会导致SQL出现重复条件虽然结果可能对但不够优雅也可能影响数据库优化器的判断。解决方案 在injectCondition方法中可以增加一个简单的分析逻辑。使用JSqlParser解析原始SQL的WHERE条件检查是否已经存在与我们即将添加的条件等价或更强的条件。如果存在则可以跳过添加或者进行合并。这是一个高级特性实现起来较复杂需要权衡开发成本和收益。对于大多数项目重复条件可能可以接受优先保证功能正确性。4.4 忽略特定的查询白名单机制不是所有查询都需要数据权限。例如登录验证、获取当前用户信息。下拉框数据字典查询通常需要全量。一些特殊的报表或管理员专属接口。解决方案 在我们的DataPermissionRuleEngine.needFilter()方法中实现白名单机制。可以通过注解、配置文件(如application.yml)或MapperID匹配规则来排除。Override public boolean needFilter(String mappedStatementId) { // 1. 注解白名单通过反射检查方法上是否有 DataPermissionIgnore 注解 // 2. 配置白名单从配置文件读取一组 regex 模式匹配则忽略 // 例如忽略所有以 .selectDict 结尾的查询 if (mappedStatementId.matches(.*\\.selectDict.*)) { return false; } // 3. 默认规则根据 mappedStatementId 判断是否需要 return statementRuleMap.containsKey(mappedStatementId); }5. 测试与验证如何确保插件正确工作实现完了怎么验证它是否按预期工作不能光看日志必须有系统的测试。5.1 单元测试拦截器逻辑为DataPermissionInterceptor和DataPermissionRuleEngine编写单元测试模拟不同的权限上下文验证生成的SQL条件片段是否正确。SpringBootTest class DataPermissionRuleEngineTest { Autowired private DataPermissionRuleEngine ruleEngine; Test void testBuildConditionForSelf() { DataPermissionContext context new DataPermissionContext(); context.setUserId(100L); context.setDataScope(SELF); String condition ruleEngine.buildCondition(context, t); assertEquals( AND t.create_by 100, condition); } Test void testBuildConditionForAll() { DataPermissionContext context new DataPermissionContext(); context.setDataScope(ALL); String condition ruleEngine.buildCondition(context, t); assertEquals(, condition); // 全部数据应返回空字符串 } }5.2 集成测试验证SQL修改编写一个集成测试启动一个真实的Spring上下文调用你的Mapper方法然后利用MyBatis提供的SqlSession或Interceptor机制捕获最终执行的SQL语句断言其是否包含了正确的权限条件。SpringBootTest AutoConfigureTestDatabase class OrderMapperDataPermissionTest { Autowired private OrderMapper orderMapper; Autowired private DataPermissionContextHolder contextHolder; Test void testSelectListWithDataPermission() { // 1. 模拟用户登录设置权限上下文 DataPermissionContext context new DataPermissionContext(); context.setUserId(123L); context.setDataScope(SELF); contextHolder.setContext(context); // 2. 执行查询 ListOrder orders orderMapper.selectList(new OrderQuery()); // 3. 这里无法直接断言SQL但可以断言查询结果 // 例如确保查出的所有订单的 create_by 都是 123 assertFalse(orders.isEmpty()); assertTrue(orders.stream().allMatch(order - order.getCreateBy().equals(123L))); // 4. 更直接的方式配置一个测试用的MyBatis ExecutorListener 或使用 p6spy 等SQL日志工具来捕获SQL进行断言 } }5.3 生产环境监控与日志在生产环境为拦截器打开DEBUG级别日志输出修改前后的SQL。这不仅是排查问题的利器也是审计数据权限是否生效的依据。可以考虑将关键的操作尤其是权限过滤失败或上下文为空的情况记录到更高级别的日志或审计系统中。6. 性能考量与优化建议动态修改SQL必然带来性能开销我们需要将其降到最低。减少不必要的解析在needFilter()方法中尽快返回false。使用高效的匹配机制比如将白名单MapperID放在HashSet中而不是遍历List。缓存权限规则DataPermissionRuleEngine中的规则如statementRuleMap不应该每次请求都从数据库查询。可以在应用启动时加载到内存或者使用Redis缓存并监听规则变更事件进行更新。慎用复杂的SQL解析如果使用JSqlParser要意识到它的解析是有成本的。对于确定的、简单的查询优先使用注解方式指定表别名避免每次执行都进行完整的SQL解析。注意ThreadLocal的内存泄漏我们的DataPermissionContextHolder使用了ThreadLocal。务必确保在Filter或Interceptor的finally块中调用clearContext()特别是在使用线程池的场景如Tomcat的HTTP线程池、Async异步任务否则可能导致内存泄漏和脏数据问题。条件预编译我们生成的权限条件如AND dept_id ?是字符串拼接进SQL的。要确保条件中的参数值是通过PreparedStatement设置的而不是直接字符串拼接以防止SQL注入。在我们的示例中条件值如context.getDeptId()被直接拼成了SQL的一部分这在实际中是不安全的。更安全的做法是拦截器不仅修改SQL还要向BoundSql的附加参数列表中添加新的参数映射。这涉及到对ParameterHandler的更深层次操作实现复杂度会大大增加。一个折中的方案是确保权限上下文中的值都是可信的如数字ID并且对字符串类型进行严格的转义或者直接规定权限条件只使用数字ID进行比较。最后数据权限是一个涉及安全和业务逻辑的复杂特性没有银弹。本文提供的方案是一个坚实起点你可以根据自己项目的复杂程度进行裁剪和增强。比如对于超大型系统可能需要将权限规则配置中心化、支持更复杂的RBAC基于角色的访问控制模型、甚至与Apache ShardingSphere这类中间件结合。但无论如何理解MyBatis插件这个核心机制是你构建任何灵活数据访问层的基础。