【MyBatisPlus】SQL拦截器详解

无论是日常开发中的数据权限控制、SQL审计监控 ,还是框架层面的分页处理、多租户隔离、乐观锁实现,其底层都依赖MP的SQL拦截器机制。很多开发者只停留在"用内置拦截器"的层面,却不清楚其底层原理,遇到自定义扩展场景时无从下手;甚至滥用拦截器导致SQL性能退化、业务逻辑异常。

本篇文章将从「原理拆解→内置拦截器实战→自定义拦截器开发→企业级避坑」四个维度,结合真实业务场景,带你彻底吃透MP SQL拦截器!

🌟**【青柠代码录】--- Java全栈成长加速器** 🌟

🔥博客合集: https://www.yuque.com/u12587869/zplytb/ur5ohwqxd2axtiny 🔥

一、先搞懂:MyBatis-Plus SQL拦截器的底层逻辑

1.1 核心定位:不是新增功能,而是"切面增强"

MP的SQL拦截器,本质是对MyBatis原生拦截器机制的封装和增强。MyBatis允许通过动态代理,在SQL执行的关键节点插入自定义逻辑(类似AOP的环绕通知),而MP基于此,提供了更简洁的接口、更完善的拦截链管理,让开发者无需关注原生MyBatis的复杂细节,就能快速实现拦截逻辑。

核心:不侵入业务代码,所有拦截逻辑都独立于Service、Mapper层,通过配置生效,降低代码耦合度,这也是它在企业开发中被广泛使用的核心原因。

1.2 底层依赖:MyBatis的四大拦截点

MyBatis原生只允许拦截4个核心接口的方法,MP的所有拦截器(内置/自定义)都围绕这4个拦截点展开,这是理解拦截器的基础,务必牢记👇:

拦截接口 核心作用 MP常用拦截场景
Executor SQL执行的核心入口(负责调用StatementHandler执行SQL),拦截增删改查所有操作 SQL审计、慢SQL监控、数据变更日志
StatementHandler 负责SQL语句的准备、参数绑定,是修改SQL的核心拦截点 分页处理、多租户隔离、动态表名、SQL注入防护
ParameterHandler 负责SQL参数的处理和绑定(将Java参数转换为JDBC参数) 参数脱敏、参数格式转换
ResultSetHandler 负责SQL查询结果的封装(将JDBC结果集转换为Java实体) 查询结果脱敏、字段映射自定义、数据权限过滤(后置)

四大拦截点的执行顺序固定,对应SQL执行的生命周期,不可颠倒,顺序为:Executor → ParameterHandler → StatementHandler → ResultSetHandler

示例:比如用户登录时,密码参数需要加密后传递给SQL,此时需拦截ParameterHandler,将前端传递的明文密码转换为加密后的密文;而查询用户列表时,手机号需要脱敏后返回,此时需拦截ResultSetHandler,在结果封装后修改手机号字段值------两者拦截点不同,职责也不同,不可混淆。

1.3 MP拦截器架构:MybatisPlusInterceptor + InnerInterceptor

在MP 3.4.0版本之后,官方推荐使用「MybatisPlusInterceptor + InnerInterceptor」的组合模式实现拦截逻辑,替代了早期直接实现MyBatis Interceptor接口的方式,这种设计更符合"责任链模式",便于多拦截器协同工作。

1.3.1 核心组件说明

  • MybatisPlusInterceptor:MP的核心拦截器"容器",实现了MyBatis的Interceptor接口,负责管理所有InnerInterceptor,统一拦截入口,控制拦截链的执行顺序(先进后出)。

  • InnerInterceptor:MP提供的拦截器接口,封装了拦截逻辑的规范,开发者自定义拦截器时,只需实现该接口(或其子类),无需关注原生MyBatis的插件代理细节。

1.3.2 核心依赖引入

企业开发中,通常结合Spring Boot使用MP,需引入以下依赖(版本推荐3.5.3.1,稳定且兼容主流Spring Boot版本),避免版本冲突:

复制代码
<!-- Spring Boot Starter(核心依赖) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
​
<!-- MyBatis-Plus Starter(自动集成MyBatis、数据源) -->
<dependency>
    <groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.5.3.1</version>
</dependency>
​
<!-- 数据库驱动(MySQL 8.x,根据实际数据库调整) -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>
​
<!--  lombok(简化实体类,企业开发必备) -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>
​
<!-- 测试依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
​
<!-- SQL解析依赖(自定义拦截器修改SQL时必备) -->
<dependency>
    <groupId>com.github.jsqlparser</groupId>
    <artifactId>jsqlparser</artifactId>
    <version>4.6</version>
</dependency>

二、实战必备:MP内置SQL拦截器

MP提供了多个开箱即用的内置InnerInterceptor,覆盖企业开发中80%的常见场景,无需自定义,只需简单配置即可生效,重点讲解5个高频拦截器。

2.1 分页拦截器:PaginationInnerInterceptor(最常用)

2.1.1 应用场景

替代传统的PageHelper分页插件,实现物理分页(在SQL中拼接LIMIT/OFFSET,而非内存分页),支持MySQL、Oracle、PostgreSQL等多种数据库,适配MP的BaseMapper分页方法(selectPage)。

企业开发中,所有列表分页查询(如用户列表、订单列表)都依赖此拦截器,避免内存分页导致的性能问题。

2.1.2 完整配置

复制代码
package com.example.mp.interceptor.config;
​
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
​
/**
 * MyBatis-Plus 拦截器核心配置类(企业开发标准配置)
 * 所有内置/自定义拦截器,都需注册到MybatisPlusInterceptor中
 */
@Configuration
public class MyBatisPlusInterceptorConfig {
​
    /**
     * 分页拦截器配置(核心)
     * @return MybatisPlusInterceptor 拦截器容器
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        
        // 1. 分页拦截器(指定数据库类型,避免自动检测耗时)
        PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor();
        // 设置数据库类型(MySQL,根据实际项目调整:ORACLE、POSTGRE_SQL等)
        paginationInterceptor.setDbType(com.baomidou.mybatisplus.annotation.DbType.MYSQL);
        // 开启分页合理化:页码<=0 查第一页,页码>总页数 查最后一页(避免用户输入非法页码报错)
        paginationInterceptor.setOverflow(true);
        // 设置默认每页条数(可选,默认10条)
        paginationInterceptor.setDefaultLimit(10);
        
        // 将分页拦截器添加到容器中(顺序很重要,后面会讲)
        interceptor.addInnerInterceptor(paginationInterceptor);
        
        return interceptor;
    }
}

2.1.3 使用示例

复制代码
// 1. 实体类(User.java)
@Data
@TableName("t_user") // 对应数据库表名
public class User {
    @TableId(type = IdType.AUTO) // 自增主键
    private Long id;
    private String username;
    private String password; // 实际开发中会加密存储
    private Integer age;
    private String deptId; // 部门ID(用于后续数据权限)
    @TableField(fill = FieldFill.INSERT) // 插入时自动填充
    private LocalDateTime createTime;
    @TableField(fill = FieldFill.INSERT_UPDATE) // 插入/更新时自动填充
    private LocalDateTime updateTime;
}
​
// 2. Mapper接口(UserMapper.java)
public interface UserMapper extends BaseMapper<User> {
    // 无需手动编写分页SQL,BaseMapper已提供selectPage方法
}
​
// 3. Service层(UserService.java)
@Service
public class UserService {
​
    @Autowired
    private UserMapper userMapper;
​
    /**
     * 企业级分页查询示例:查询用户列表(带条件)
     * @param pageNum 页码
     * @param pageSize 每页条数
     * @param username 用户名模糊查询(可选条件)
     * @return 分页结果(包含总条数、总页数、当前页数据)
     */
    public IPage<User> getUserPage(Integer pageNum, Integer pageSize, String username) {
        // 1. 构建分页条件(Page是MP提供的分页对象)
        IPage<User> page = new Page<>(pageNum, pageSize);
        
        // 2. 构建查询条件(LambdaQueryWrapper,避免字段名硬编码,防SQL注入)
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        // 模糊查询(若username不为空,则添加条件)
        if (StrUtil.isNotBlank(username)) {
            queryWrapper.like(User::getUsername, username);
        }
        // 按创建时间降序排序(企业列表常用排序方式)
        queryWrapper.orderByDesc(User::getCreateTime);
        
        // 3. 调用BaseMapper的selectPage方法,分页拦截器自动生效
        // 底层逻辑:拦截StatementHandler.prepare方法,拼接LIMIT (pageNum-1)*pageSize, pageSize
        return userMapper.selectPage(page, queryWrapper);
    }
}
​
// 4. 测试类(实战验证)
@SpringBootTest
public class UserServiceTest {
​
    @Autowired
    private UserService userService;
​
    @Test
    public void testGetUserPage() {
        // 测试:查询第1页,每页5条,用户名包含"张"
        IPage<User> userPage = userService.getUserPage(1, 5, "张");
        
        // 输出分页信息(企业开发中会返回给前端)
        System.out.println("总条数:" + userPage.getTotal());
        System.out.println("总页数:" + userPage.getPages());
        System.out.println("当前页数据:" + userPage.getRecords());
    }
}

2.1.4 底层原理(简化理解)

分页拦截器拦截「StatementHandler.prepare」方法(SQL准备阶段),通过JSqlParser解析原始SQL,根据数据库类型拼接分页语句:

  • MySQL:原始SQL + 「LIMIT ? OFFSET ?」

  • Oracle:原始SQL + 「ROWNUM <= ? AND ROWNUM > ?」 同时,自动计算分页参数(offset = (pageNum-1)*pageSize),绑定到SQL中,实现物理分页。

2.2 多租户拦截器:TenantLineInnerInterceptor

2.2.1 应用场景

多租户架构(如SaaS系统)中,不同租户的数据存储在同一张表中,通过「租户ID」字段隔离数据,避免租户之间数据泄露。拦截所有查询/更新/删除SQL,自动拼接「tenant_id = ?」条件,无需手动添加。

示例:原始SQL「SELECT * FROM t_user」→ 拦截后「SELECT * FROM t_user WHERE tenant_id = 1」(1为当前登录租户ID)。

2.2.2 完整配置(结合分页拦截器)

复制代码
@Configuration
public class MyBatisPlusInterceptorConfig {
​
    /**
     * 多租户字段名(数据库表中存储租户ID的字段,企业开发中统一命名)
     */
    private static final String TENANT_ID_FIELD = "tenant_id";
​
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        
        // 1. 多租户拦截器(必须放在分页拦截器之前!顺序影响SQL解析)
        TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor(
                // 租户处理器:获取当前登录租户ID,拼接租户条件
                new TenantLineHandler() {
                    /**
                     * 返回当前租户ID(企业开发中,从ThreadLocal获取登录用户的租户ID)
                     * 实际场景:结合Spring Security/Spring Context,从上下文获取
                     */
                    @Override
                    public Expression getTenantId() {
                        // 模拟:当前登录租户ID为1(实际开发中替换为真实获取逻辑)
                        return new LongValue(1L);
                    }
​
                    /**
                     * 指定租户字段名(与数据库表中字段一致)
                     */
                    @Override
                    public String getTenantIdColumn() {
                        return TENANT_ID_FIELD;
                    }
​
                    /**
                     * 忽略租户拦截的表(如:系统字典表、租户信息表,所有租户共享)
                     * 避免拦截这些表,导致查询不到数据
                     */
                    @Override
                    public boolean ignoreTable(String tableName) {
                        // 示例:忽略t_tenant(租户表)和t_dict(字典表)
                        return "t_tenant".equals(tableName) || "t_dict".equals(tableName);
                    }
                }
        );
        
        // 2. 分页拦截器(放在多租户拦截器之后,先拼接租户条件,再分页)
        PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor();
        paginationInterceptor.setDbType(DbType.MYSQL);
        paginationInterceptor.setOverflow(true);
        
        // 添加拦截器(顺序:多租户 → 分页,至关重要!)
        interceptor.addInnerInterceptor(tenantInterceptor);
        interceptor.addInnerInterceptor(paginationInterceptor);
        
        return interceptor;
    }
}

2.2.3 优化:ThreadLocal获取当前租户ID

上面的示例中,租户ID是固定的,实际开发中,需从「当前登录用户上下文」获取,通常用ThreadLocal存储(请求链路中共享):

复制代码
// 1. 租户上下文工具类(企业开发标准封装)
public class TenantContextHolder {
    // ThreadLocal:线程隔离,存储当前请求的租户ID
    private static final ThreadLocal<Long> TENANT_ID_HOLDER = new ThreadLocal<>();
​
    // 设置租户ID(在拦截器/过滤器中调用,如:登录后设置)
    public static void setTenantId(Long tenantId) {
        TENANT_ID_HOLDER.set(tenantId);
    }
​
    // 获取租户ID(在多租户处理器中调用)
    public static Long getTenantId() {
        return TENANT_ID_HOLDER.get();
    }
​
    // 清除租户ID(请求结束后调用,避免内存泄漏)
    public static void clear() {
        TENANT_ID_HOLDER.remove();
    }
}
​
// 2. 修改多租户处理器的getTenantId方法
@Override
public Expression getTenantId() {
    // 从ThreadLocal获取当前租户ID,若为空则抛出异常(避免无租户ID查询全表)
    Long tenantId = TenantContextHolder.getTenantId();
    if (tenantId == null) {
        throw new RuntimeException("当前租户ID为空,无法执行查询操作");
    }
    return new LongValue(tenantId);
}
​
// 3. 过滤器示例(拦截所有请求,设置租户ID)
@Component
public class TenantFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        try {
            // 模拟:从请求头获取租户ID(实际开发中,从Token解析登录用户信息)
            String tenantIdStr = ((HttpServletRequest) request).getHeader("X-Tenant-Id");
            if (StrUtil.isNotBlank(tenantIdStr)) {
                Long tenantId = Long.parseLong(tenantIdStr);
                TenantContextHolder.setTenantId(tenantId);
            }
            // 继续执行请求
            chain.doFilter(request, response);
        } finally {
            // 请求结束,清除租户ID,避免ThreadLocal内存泄漏
            TenantContextHolder.clear();
        }
    }
}

2.3 乐观锁拦截器:OptimisticLockerInnerInterceptor

2.3.1 应用场景

解决并发更新冲突(如:多个用户同时修改同一条数据),通过「版本号」机制实现乐观锁,无需手动编写版本号条件。拦截更新SQL,自动拼接「version = ?」条件,并更新版本号。

示例:

原始更新SQL「UPDATE t_user SET username = ? WHERE id = ?」

→ 拦截后「UPDATE t_user SET username = ?, version = version + 1 WHERE id = ? AND version = ?」。

2.3.2 使用示例

复制代码
// 1. 配置类(添加乐观锁拦截器)
@Configuration
public class MyBatisPlusInterceptorConfig {
​
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        
        // 1. 多租户拦截器(最先执行)
        TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor(...);
        // 2. 分页拦截器
        PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
        paginationInterceptor.setOverflow(true);
        // 3. 乐观锁拦截器(放在分页之后,不修改SQL结构,只添加条件)
        OptimisticLockerInnerInterceptor optimisticLockerInterceptor = new OptimisticLockerInnerInterceptor();
        
        // 添加顺序:多租户 → 分页 → 乐观锁
        interceptor.addInnerInterceptor(tenantInterceptor);
        interceptor.addInnerInterceptor(paginationInterceptor);
        interceptor.addInnerInterceptor(optimisticLockerInterceptor);
        
        return interceptor;
    }
}
​
// 2. 实体类添加版本号字段(关键)
@Data
@TableName("t_user")
public class User {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String username;
    private String password;
    private Integer age;
    private String deptId;
    
    // 乐观锁版本号字段(必须添加@Version注解)
    @Version
    @TableField(fill = FieldFill.INSERT) // 插入时自动填充初始值(1)
    private Integer version;
    
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
}
​
// 3. 自动填充配置(给version、createTime等字段设置默认值)
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
    @Override
    public void insertFill(MetaObject metaObject) {
        // 插入时,version默认填充1
        strictInsertFill(metaObject, "version", Integer.class, 1);
        // 填充创建时间、更新时间
        strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
        strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
    }
​
    @Override
    public void updateFill(MetaObject metaObject) {
        // 更新时,填充更新时间
        strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
    }
}
​
// 4. 企业级并发更新示例(Service层)
@Service
public class UserService {
​
    @Autowired
    private UserMapper userMapper;
​
    /**
     * 并发更新用户信息(乐观锁自动生效)
     * @param id 用户ID
     * @param newUsername 新用户名
     * @return 是否更新成功(失败则说明数据已被其他用户修改)
     */
    @Transactional
    public boolean updateUserUsername(Long id, String newUsername) {
        // 1. 查询用户(获取当前version)
        User user = userMapper.selectById(id);
        if (user == null) {
            throw new RuntimeException("用户不存在");
        }
        
        // 2. 修改用户名(无需手动处理version)
        user.setUsername(newUsername);
        
        // 3. 调用updateById,乐观锁拦截器自动拼接version条件
        // 底层SQL:UPDATE t_user SET username=?, version=version+1, update_time=? WHERE id=? AND version=?
        int rows = userMapper.updateById(user);
        
        // 4. rows=0 说明更新失败(version不匹配,数据已被修改)
        return rows > 0;
    }
}

2.4 SQL审计拦截器:自定义审计+IllegalSQLInnerInterceptor

2.4.1 应用场景

企业开发中,SQL安全和可追溯性至关重要:

  1. 拦截风险SQL(如:无WHERE条件的DELETE/UPDATE,避免全表删除/更新);

  2. 记录所有SQL的执行日志(执行耗时、执行方法、参数、操作用户),用于问题排查和审计;

  3. 拦截非法SQL(如:关键字注入、不合理的查询语句)。

这里结合「IllegalSQLInnerInterceptor(MP内置非法SQL拦截)」和「自定义SQL审计拦截器」,实现企业级SQL审计功能。

2.4.2 完整实现(配置+自定义拦截器)

复制代码
// 1. 自定义SQL审计拦截器(拦截Executor,记录SQL执行日志、慢SQL告警)
@Component
public class SqlAuditInnerInterceptor implements InnerInterceptor {
​
    private static final Logger log = LoggerFactory.getLogger(SqlAuditInnerInterceptor.class);
    
    // 慢SQL阈值(企业可根据实际调整,如:500ms)
    private static final long SLOW_SQL_THRESHOLD = 500;
​
    /**
     * 拦截查询操作(执行前)
     * 可用于阻断非法查询、记录查询前信息
     */
    @Override
    public boolean willDoQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
        // 1. 获取SQL和执行方法(Mapper全路径+方法名,如:com.example.mp.mapper.UserMapper.selectById)
        String sql = boundSql.getSql().replaceAll("\\s+", " "); // 格式化SQL(去除多余空格)
        String mapperMethod = ms.getId();
        
        // 2. 示例:拦截查询全表且无分页的SQL(避免大数据量查询导致性能问题)
        if (ms.getSqlCommandType() == SqlCommandType.SELECT 
                && !sql.contains("WHERE") 
                && rowBounds == RowBounds.DEFAULT) {
            log.error("拦截非法查询SQL:无WHERE条件且无分页,method={}, sql={}", mapperMethod, sql);
            throw new SQLException("禁止执行无WHERE条件且无分页的全表查询");
        }
        
        log.info("SQL查询准备执行:method={}, sql={}, parameter={}", mapperMethod, sql, parameter);
        return true; // 返回true:继续执行SQL;返回false:阻断SQL执行
    }
​
    /**
     * 拦截查询操作(执行后)
     * 可用于记录执行耗时、慢SQL告警
     */
    @Override
    public void afterQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql, Exception e) throws SQLException {
        // 1. 获取SQL执行耗时(MP提供的工具类,获取当前SQL的执行时间)
        long costTime = System.currentTimeMillis() - boundSql.getParameterObject().hashCode(); // 简化写法,实际用MP提供的耗时工具
        String sql = boundSql.getSql().replaceAll("\\s+", " ");
        String mapperMethod = ms.getId();
        
        // 2. 慢SQL告警(超过阈值,打印WARN日志,企业可扩展为告警通知)
        if (costTime > SLOW_SQL_THRESHOLD) {
            log.warn("慢SQL告警:method={}, costTime={}ms, sql={}, parameter={}", 
                    mapperMethod, costTime, sql, parameter);
        } else {
            log.info("SQL查询执行完成:method={}, costTime={}ms, sql={}", 
                    mapperMethod, costTime, sql);
        }
    }
​
    /**
     * 拦截更新操作(INSERT/UPDATE/DELETE,执行前)
     */
    @Override
    public boolean willDoUpdate(Executor executor, MappedStatement ms, Object parameter, BoundSql boundSql) throws SQLException {
        String sql = boundSql.getSql().replaceAll("\\s+", " ");
        String mapperMethod = ms.getId();
        SqlCommandType commandType = ms.getSqlCommandType();
        
        // 3. 拦截无WHERE条件的DELETE/UPDATE(风险SQL,避免全表修改/删除)
        if ((commandType == SqlCommandType.DELETE || commandType == SqlCommandType.UPDATE) 
                && !sql.contains("WHERE")) {
            log.error("拦截风险SQL:无WHERE条件的{}操作,method={}, sql={}", 
                    commandType.name(), mapperMethod, sql);
            throw new SQLException("禁止执行无WHERE条件的" + commandType.name() + "操作");
        }
        
        log.info("SQL更新准备执行:method={}, type={}, sql={}, parameter={}", 
                mapperMethod, commandType.name(), sql, parameter);
        return true;
    }
​
    /**
     * 拦截更新操作(执行后)
     * 可用于记录数据变更日志(如:谁修改了什么数据,变更前后内容)
     */
    @Override
    public void afterUpdate(Executor executor, MappedStatement ms, Object parameter, BoundSql boundSql, int updateCount) throws SQLException {
        String sql = boundSql.getSql().replaceAll("\\s+", " ");
        String mapperMethod = ms.getId();
        SqlCommandType commandType = ms.getSqlCommandType();
        
        log.info("SQL更新执行完成:method={}, type={}, sql={}, updateCount={}条", 
                mapperMethod, commandType.name(), sql, updateCount);
        
        // 扩展:记录数据变更日志(企业级实战,结合ThreadLocal获取操作用户)
        String operator = CurrentUserContext.getCurrentUsername(); // 自定义上下文,获取登录用户
        log.info("数据变更记录:operator={}, type={}, sql={}, parameter={}", 
                operator, commandType.name(), sql, parameter);
    }
}
​
// 2. 配置类(添加SQL审计拦截器+内置非法SQL拦截器)
@Configuration
public class MyBatisPlusInterceptorConfig {
​
    @Autowired
    private SqlAuditInnerInterceptor sqlAuditInnerInterceptor;
​
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        
        // 1. 多租户拦截器
        TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor(...);
        // 2. 分页拦截器
        PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
        paginationInterceptor.setOverflow(true);
        // 3. 乐观锁拦截器
        OptimisticLockerInnerInterceptor optimisticLockerInterceptor = new OptimisticLockerInnerInterceptor();
        // 4. 内置非法SQL拦截器(拦截关键字注入、不合理SQL)
        IllegalSQLInnerInterceptor illegalSqlInterceptor = new IllegalSQLInnerInterceptor();
        // 5. 自定义SQL审计拦截器(记录日志、慢SQL、风险SQL)
        
        // 添加顺序(关键):多租户 → 分页 → 乐观锁 → 非法SQL → 审计
        interceptor.addInnerInterceptor(tenantInterceptor);
        interceptor.addInnerInterceptor(paginationInterceptor);
        interceptor.addInnerInterceptor(optimisticLockerInterceptor);
        interceptor.addInnerInterceptor(illegalSqlInterceptor);
        interceptor.addInnerInterceptor(sqlAuditInnerInterceptor);
        
        return interceptor;
    }
}

2.5 动态表名拦截器:DynamicTableNameInnerInterceptor(分表场景)

2.5.1 应用场景

分表场景(如:订单表按时间分表t_order_202401、t_order_202402),拦截SQL,根据业务规则(如:订单时间)动态修改表名,无需手动编写分表SQL。

2.5.2 示例

复制代码
@Configuration
public class MyBatisPlusInterceptorConfig {
​
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        
        // 动态表名拦截器(放在最前面,先修改表名,再执行其他拦截)
        DynamicTableNameInnerInterceptor dynamicTableNameInterceptor = new DynamicTableNameInnerInterceptor(
                // 表名处理器:根据原始表名和参数,返回动态表名
                (sql, tableName) -> {
                    // 示例1:订单表分表(原始表名t_order,动态拼接月份)
                    if ("t_order".equals(tableName)) {
                        // 从参数中获取订单时间(假设参数是OrderQuery对象,包含orderTime)
                        Object parameter = RequestHolder.getRequestParam(); // 自定义工具,获取请求参数
                        if (parameter instanceof OrderQuery orderQuery) {
                            LocalDateTime orderTime = orderQuery.getOrderTime();
                            // 拼接表名:t_order_202401(格式:yyyyMM)
                            String month = orderTime.format(DateTimeFormatter.ofPattern("yyyyMM"));
                            return "t_order_" + month;
                        }
                        // 无时间参数,默认查当前月份表
                        return "t_order_" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMM"));
                    }
                    // 其他表,返回原始表名(不动态修改)
                    return tableName;
                }
        );
        
        // 添加其他拦截器(顺序:动态表名 → 多租户 → 分页 ...)
        interceptor.addInnerInterceptor(dynamicTableNameInterceptor);
        // 后续添加多租户、分页等拦截器...
        
        return interceptor;
    }
}
​
// 使用示例(Service层)
@Service
public class OrderService {
​
    @Autowired
    private OrderMapper orderMapper;
​
    /**
     * 动态分表查询:根据订单时间,查询对应月份的订单表
     * @param orderQuery 查询条件(包含orderTime)
     * @return 订单列表
     */
    public List<Order> getOrderByTime(OrderQuery orderQuery) {
        // 绑定参数到RequestHolder(供动态表名处理器获取)
        RequestHolder.setRequestParam(orderQuery);
        
        LambdaQueryWrapper<Order> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(Order::getOrderTime, orderQuery.getOrderTime());
        
        // 原始SQL:SELECT * FROM t_order WHERE order_time = ?
        // 动态表名拦截后:SELECT * FROM t_order_202401 WHERE order_time = ?
        List<Order> orders = orderMapper.selectList(queryWrapper);
        
        // 清除参数,避免内存泄漏
        RequestHolder.clearRequestParam();
        return orders;
    }
}

三、进阶实战:自定义SQL拦截器

MP内置拦截器只能满足通用场景,实际开发中,很多个性化需求(如:数据脱敏、数据权限过滤、自定义SQL拼接)需要自定义拦截器。

下面结合2个高频场景,实现完整的自定义拦截器,带你掌握开发流程。

3.1 场景1:数据脱敏拦截器(查询结果脱敏)

3.1.1 需求说明

用户列表查询时,对敏感字段(如:手机号、身份证号)进行脱敏处理,避免敏感信息泄露: - 手机号:1381234 - 身份证号:1101011234

拦截点:ResultSetHandler(结果集封装阶段),在结果返回给Service层之前,修改敏感字段的值。

3.1.2 完整实现(自定义InnerInterceptor)

复制代码
// 1. 自定义脱敏注解(标记需要脱敏的字段,灵活配置脱敏规则)
@Target({ElementType.FIELD}) // 只作用于字段
@Retention(RetentionPolicy.RUNTIME) // 运行时生效,可通过反射获取
public @interface SensitiveField {
    // 脱敏类型(枚举,支持多种脱敏规则)
    SensitiveType type();
    
    // 脱敏枚举(企业可扩展更多类型,如:邮箱、地址)
    enum SensitiveType {
        PHONE, // 手机号
        ID_CARD, // 身份证号
        USERNAME // 用户名(如:张**)
    }
}
​
// 2. 脱敏工具类(企业级封装,通用工具)
public class SensitiveUtils {
​
    /**
     * 手机号脱敏:保留前3位、后4位,中间用****替换
     * 示例:13812345678 → 138****5678
     */
    public static String desensitizePhone(String phone) {
        if (StrUtil.isBlank(phone) || phone.length() != 11) {
            return phone;
        }
        return phone.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
    }
​
    /**
     * 身份证号脱敏:保留前6位、后4位,中间用****替换
     * 示例:110101199001011234 → 110101****1234
     */
    public static String desensitizeIdCard(String idCard) {
        if (StrUtil.isBlank(idCard) || (idCard.length() != 18 && idCard.length() != 15)) {
            return idCard;
        }
        return idCard.replaceAll("(\\d{6})\\d+(\\d{4})", "$1****$2");
    }
​
    /**
     * 用户名脱敏:保留第一个字,后面用**替换(2字及以上)
     * 示例:张三 → 张**,李四丰 → 李**
     */
    public static String desensitizeUsername(String username) {
        if (StrUtil.isBlank(username) || username.length() <= 1) {
            return username;
        }
        return username.substring(0, 1) + "**";
    }
​
    /**
     * 根据脱敏类型,执行对应的脱敏方法
     */
    public static String desensitize(String value, SensitiveField.SensitiveType type) {
        if (StrUtil.isBlank(value)) {
            return value;
        }
        return switch (type) {
            case PHONE -> desensitizePhone(value);
            case ID_CARD -> desensitizeIdCard(value);
            case USERNAME -> desensitizeUsername(value);
        };
    }
}
​
// 3. 自定义脱敏拦截器(实现InnerInterceptor,拦截ResultSetHandler)
@Component
public class SensitiveDataInnerInterceptor implements InnerInterceptor {
​
    /**
     * 拦截查询结果,对敏感字段进行脱敏
     * 核心:在结果集封装完成后,通过反射修改实体类的敏感字段值
     */
    @Override
    public void afterQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql, Exception e) throws SQLException {
        // 1. 判断结果处理器是否为默认的DefaultResultHandler(大部分场景适用)
        if (!(resultHandler instanceof DefaultResultHandler defaultResultHandler)) {
            return;
        }
        
        // 2. 获取查询结果集(DefaultResultHandler的resultList属性,存储查询结果)
        List<?> resultList = (List<?>) getFieldValue(defaultResultHandler, "resultList");
        if (CollectionUtils.isEmpty(resultList)) {
            return;
        }
        
        // 3. 遍历结果集,对每个实体类的敏感字段进行脱敏
        for (Object result : resultList) {
            desensitizeEntity(result);
        }
    }
​
    /**
     * 对单个实体类进行脱敏处理(核心方法)
     * @param entity 实体对象(如:User、Order)
     */
    private void desensitizeEntity(Object entity) {
        // 1. 获取实体类的所有字段(包括父类字段)
        Field[] fields = getAllFields(entity.getClass());
        
        for (Field field : fields) {
            // 2. 判断字段是否添加了@SensitiveField注解
            if (field.isAnnotationPresent(SensitiveField.class)) {
                SensitiveField annotation = field.getAnnotation(SensitiveField.class);
                try {
                    // 3. 开启字段访问权限(私有字段需设置)
                    field.setAccessible(true);
                    // 4. 获取字段原始值(String类型,脱敏只针对字符串)
                    Object originalValue = field.get(entity);
                    if (originalValue == null || !(originalValue instanceof String originalStr)) {
                        continue;
                    }
                    // 5. 根据脱敏类型,执行脱敏操作
                    String desensitizedValue = SensitiveUtils.desensitize(originalStr, annotation.type());
                    // 6. 设置脱敏后的值到实体类字段
                    field.set(entity, desensitizedValue);
                } catch (IllegalAccessException ex) {
                    log.error("敏感数据脱敏失败,实体类:{},字段:{}", entity.getClass().getName(), field.getName(), ex);
                } finally {
                    // 7. 关闭字段访问权限(避免安全问题)
                    field.setAccessible(false);
                }
            }
        }
    }
​
    /**
     * 工具方法:获取类的所有字段(包括父类)
     */
    private Field[] getAllFields(Class<?> clazz) {
        List<Field> fieldList = new ArrayList<>();
        // 遍历当前类及其父类,直到Object类
        while (clazz != null && clazz != Object.class) {
            fieldList.addAll(Arrays.asList(clazz.getDeclaredFields()));
            clazz = clazz.getSuperclass();
        }
        return fieldList.toArray(new Field[0]);
    }
​
    /**
     * 工具方法:通过反射获取对象的指定字段值(如:DefaultResultHandler的resultList)
     */
    private Object getFieldValue(Object obj, String fieldName) {
        try {
            Field field = obj.getClass().getDeclaredField(fieldName);
            field.setAccessible(true);
            return field.get(obj);
        } catch (NoSuchFieldException | IllegalAccessException ex) {
            log.error("获取字段值失败,对象:{},字段名:{}", obj.getClass().getName(), fieldName, ex);
            return null;
        }
    }
}
​
// 4. 实体类使用@SensitiveField注解(标记敏感字段)
@Data
@TableName("t_user")
public class User {
    @TableId(type = IdType.AUTO)
    private Long id;
    
    // 用户名脱敏(类型:USERNAME)
    @SensitiveField(type = SensitiveField.SensitiveType.USERNAME)
    private String username;
    
    private String password;
    
    private Integer age;
    
    private String deptId;
    
    // 手机号脱敏(类型:PHONE)
    @SensitiveField(type = SensitiveField.SensitiveType.PHONE)
    private String phone;
    
    // 身份证号脱敏(类型:ID_CARD)
    @SensitiveField(type = SensitiveField.SensitiveType.ID_CARD)
    private String idCard;
    
    @Version
    private Integer version;
    
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
}
​
// 5. 配置类添加脱敏拦截器(顺序:最后执行,不影响其他SQL逻辑)
@Configuration
public class MyBatisPlusInterceptorConfig {
​
    @Autowired
    private SensitiveDataInnerInterceptor sensitiveDataInnerInterceptor;
​
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        
        // 1. 动态表名(可选)
        // 2. 多租户
        // 3. 分页
        // 4. 乐观锁
        // 5. 非法SQL
        // 6. SQL审计
        // 7. 数据脱敏(最后执行,在结果返回前脱敏)
        interceptor.addInnerInterceptor(...); // 前面的拦截器
        interceptor.addInnerInterceptor(sensitiveDataInnerInterceptor);
        
        return interceptor;
    }
}
​
// 6. 测试效果(Service层)
@Service
public class UserService {
​
    @Autowired
    private UserMapper userMapper;
​
    public List<User> getUserList() {
        // 查询所有用户,脱敏拦截器自动生效
        List<User> userList = userMapper.selectList(null);
        // 输出结果:手机号13812345678 → 138****5678,身份证号110101199001011234 → 110101****1234
        userList.forEach(user -> log.info("脱敏后用户:{}", user));
        return userList;
    }
}

3.2 场景2:数据权限拦截器(行级权限控制)

3.2.1 需求说明

企业级系统中,不同角色的用户只能查看自己权限范围内的数据:

  • 管理员:查看所有部门数据;

  • 部门经理:查看本部门所有数据;

  • 普通员工:只能查看自己的数据。

拦截点:StatementHandler.prepare(SQL准备阶段),通过解析SQL,自动拼接数据权限条件(如:dept_id = ? 或 create_user_id = ?),无需手动在每个查询中添加条件。

3.2.2 完整实现(结合JSqlParser解析SQL)

复制代码
// 1. 权限常量(企业级权限体系,结合Spring Security)
public class PermissionConstant {
    // 角色类型
    public static final String ROLE_ADMIN = "ADMIN"; // 管理员
    public static final String ROLE_DEPT_MANAGER = "DEPT_MANAGER"; // 部门经理
    public static final String ROLE_EMPLOYEE = "EMPLOYEE"; // 普通员工
​
    // 数据权限字段(数据库表中对应的字段)
    public static final String DEPT_ID_FIELD = "dept_id"; // 部门ID字段
    public static final String CREATE_USER_ID_FIELD = "create_user_id"; // 创建人ID字段
}
​
// 2. 用户上下文工具类(获取当前登录用户的角色、部门ID、用户ID)
public class CurrentUserContext {
    // ThreadLocal存储当前登录用户信息
    private static final ThreadLocal<UserInfo> CURRENT_USER_HOLDER = new ThreadLocal<>();
​
    // 用户信息封装(实际开发中,从Token/Spring Security获取)
    @Data
    public static class UserInfo {
        private Long userId; // 用户ID
        private String username; // 用户名
        private String role; // 角色(ADMIN/DEPT_MANAGER/EMPLOYEE)
        private Long deptId; // 部门ID
    }
​
    // 设置当前用户信息(登录后调用)
    public static void setCurrentUser(UserInfo userInfo) {
        CURRENT_USER_HOLDER.set(userInfo);
    }
​
    // 获取当前用户信息
    public static UserInfo getCurrentUser() {
        return CURRENT_USER_HOLDER.get();
    }
​
    // 清除当前用户信息(请求结束后调用)
    public static void clearCurrentUser() {
        CURRENT_USER_HOLDER.remove();
    }
}
​
// 3. 自定义数据权限拦截器(核心,解析SQL并拼接权限条件)
@Component
public class DataPermissionInnerInterceptor implements InnerInterceptor {
​
    private static final Logger log = LoggerFactory.getLogger(DataPermissionInnerInterceptor.class);
​
    /**
     * 拦截SQL准备阶段,拼接数据权限条件
     * 核心:通过JSqlParser解析SQL,修改WHERE条件,添加权限过滤
     */
    @Override
    public void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) throws SQLException {
        // 1. 获取当前登录用户信息(无用户信息,不拦截,如:匿名访问)
        CurrentUserContext.UserInfo currentUser = CurrentUserContext.getCurrentUser();
        if (currentUser == null) {
            return;
        }
​
        // 2. 获取StatementHandler的BoundSql(包含原始SQL和参数)
        BoundSql boundSql = sh.getBoundSql();
        String originalSql = boundSql.getSql().replaceAll("\\s+", " "); // 格式化SQL
        String mappedStatementId = getMappedStatementId(sh); // 获取Mapper方法全路径
​
        // 3. 忽略不需要数据权限控制的方法(如:管理员查询接口、字典查询接口)
        if (ignoreDataPermission(mappedStatementId)) {
            log.info("忽略数据权限控制,method={}", mappedStatementId);
            return;
        }
​
        // 4. 解析SQL(使用JSqlParser,MP自定义SQL修改必备)
        try {
            // 解析SQL为Statement对象(支持SELECT/UPDATE/DELETE,此处只处理查询)
            Statement statement = CCJSqlParserUtil.parse(originalSql);
            if (!(statement instanceof Select select)) {
                // 非查询SQL(如更新/删除),暂不处理(可根据需求扩展)
                return;
            }
​
            // 5. 获取SQL的WHERE子句,拼接数据权限条件
            SelectBody selectBody = select.getSelectBody();
            if (selectBody instanceof PlainSelect plainSelect) {
                // 构建数据权限条件表达式(核心逻辑)
                Expression permissionCondition = buildPermissionCondition(currentUser);
                if (permissionCondition == null) {
                    return;
                }
​
                // 拼接条件:原有WHERE条件 + AND 权限条件(无原有条件则直接设为WHERE权限条件)
                Expression where = plainSelect.getWhere();
                if (where != null) {
                    // 原有WHERE条件存在,拼接AND
                    plainSelect.setWhere(new AndExpression(where, permissionCondition));
                } else {
                    // 原有WHERE条件不存在,直接设置权限条件为WHERE子句
                    plainSelect.setWhere(permissionCondition);
                }
​
                // 6. 重新生成修改后的SQL,设置回BoundSql
                String modifiedSql = select.toString().replaceAll("\\s+", " ");
                Field sqlField = boundSql.getClass().getDeclaredField("sql");
                sqlField.setAccessible(true);
                sqlField.set(boundSql, modifiedSql);
​
                log.info("数据权限SQL修改完成:originalSql={}, modifiedSql={}, userRole={}", originalSql, modifiedSql, currentUser.getRole());
            }
        } catch (Exception ex) {
            log.error("数据权限拦截器解析SQL失败,originalSql={}, method={}", originalSql, mappedStatementId, ex);
            // 解析失败,可选择抛出异常阻断SQL执行,或不处理(根据企业容错策略调整)
            throw new SQLException("数据权限控制异常,请联系管理员");
        }
    }
​
    /**
     * 构建数据权限条件表达式(根据用户角色生成不同条件)
     * @param currentUser 当前登录用户信息
     * @return 权限条件表达式(JSqlParser的Expression对象)
     */
    private Expression buildPermissionCondition(CurrentUserContext.UserInfo currentUser) {
        String role = currentUser.getRole();
        Long userId = currentUser.getUserId();
        Long deptId = currentUser.getDeptId();
​
        // 根据角色生成不同的权限条件
        return switch (role) {
            // 1. 管理员:无权限条件(查看所有数据)
            case PermissionConstant.ROLE_ADMIN -> null;
​
            // 2. 部门经理:查看本部门所有数据(dept_id = 当前部门ID)
            case PermissionConstant.ROLE_DEPT_MANAGER ->
                new EqualsTo(
                    new Column(PermissionConstant.DEPT_ID_FIELD), // 数据库字段:dept_id
                    new LongValue(deptId) // 值:当前用户的部门ID
                );
​
            // 3. 普通员工:只能查看自己创建的数据(create_user_id = 当前用户ID)
            case PermissionConstant.ROLE_EMPLOYEE ->
                new EqualsTo(
                    new Column(PermissionConstant.CREATE_USER_ID_FIELD), // 数据库字段:create_user_id
                    new LongValue(userId) // 值:当前用户ID
                );
​
            // 其他角色:无权限(阻断查询,可根据需求调整为抛出异常)
            default -> new EqualsTo(new Column("1"), new Column("0"));
        };
    }
​
    /**
     * 获取Mapper方法全路径(如:com.example.mp.mapper.UserMapper.selectList)
     * @param sh StatementHandler对象
     * @return Mapper方法全路径
     */
    private String getMappedStatementId(StatementHandler sh) throws NoSuchFieldException, IllegalAccessException {
        // 通过反射获取StatementHandler的delegate属性(实际是RoutingStatementHandler的delegate)
        Field delegateField = sh.getClass().getDeclaredField("delegate");
        delegateField.setAccessible(true);
        StatementHandler delegate = (StatementHandler) delegateField.get(sh);
​
        // 获取MappedStatement对象,进而获取方法ID
        Field mappedStatementField = delegate.getClass().getDeclaredField("mappedStatement");
        mappedStatementField.setAccessible(true);
        MappedStatement mappedStatement = (MappedStatement) mappedStatementField.get(delegate);
        return mappedStatement.getId();
    }
​
    /**
     * 忽略不需要数据权限控制的方法(企业可根据实际接口调整)
     * @param mappedStatementId Mapper方法全路径
     * @return true:忽略,false:需要控制
     */
    private boolean ignoreDataPermission(String mappedStatementId) {
        // 示例:忽略字典表、租户表、管理员专用接口的查询
        return mappedStatementId.contains("DictMapper") ||
               mappedStatementId.contains("TenantMapper") ||
               mappedStatementId.contains("AdminMapper");
    }
}
​
// 4. 配置类添加数据权限拦截器(顺序:在分页拦截器之前,先拼接权限条件再分页)
@Configuration
public class MyBatisPlusInterceptorConfig {
​
    @Autowired
    private DataPermissionInnerInterceptor dataPermissionInnerInterceptor;
​
    @Autowired
    private SensitiveDataInnerInterceptor sensitiveDataInnerInterceptor;
​
    @Autowired
    private SqlAuditInnerInterceptor sqlAuditInnerInterceptor;
​
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
​
        // 拦截器顺序(至关重要,错误顺序会导致SQL解析失败)
        // 1. 动态表名拦截器(可选)
        // 2. 多租户拦截器(先隔离租户数据)
        TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor(...);
​
        // 3. 数据权限拦截器(再拼接行级权限条件)
        interceptor.addInnerInterceptor(tenantInterceptor);
        interceptor.addInnerInterceptor(dataPermissionInnerInterceptor);
​
        // 4. 分页拦截器(最后拼接分页条件)
        PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
        paginationInterceptor.setOverflow(true);
        interceptor.addInnerInterceptor(paginationInterceptor);
​
        // 5. 乐观锁拦截器
        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
​
        // 6. 内置非法SQL拦截器
        interceptor.addInnerInterceptor(new IllegalSQLInnerInterceptor());
​
        // 7. SQL审计拦截器
        interceptor.addInnerInterceptor(sqlAuditInnerInterceptor);
​
        // 8. 数据脱敏拦截器(最后处理结果)
        interceptor.addInnerInterceptor(sensitiveDataInnerInterceptor);
​
        return interceptor;
    }
}
​
// 5. 测试效果(不同角色用户查询对比)
@SpringBootTest
public class DataPermissionTest {
​
    @Autowired
    private UserMapper userMapper;
​
    // 测试管理员角色(查看所有数据)
    @Test
    public void testAdminPermission() {
        // 模拟管理员登录,设置用户上下文
        CurrentUserContext.UserInfo admin = new CurrentUserContext.UserInfo();
        admin.setUserId(1L);
        admin.setRole(PermissionConstant.ROLE_ADMIN);
        CurrentUserContext.setCurrentUser(admin);
​
        // 原始SQL:SELECT * FROM t_user
        // 拦截后SQL:SELECT * FROM t_user(无权限条件)
        List<User> allUser = userMapper.selectList(null);
        log.info("管理员查看所有用户:{}条", allUser.size());
​
        // 清除上下文
        CurrentUserContext.clearCurrentUser();
    }
​
    // 测试部门经理角色(查看本部门数据)
    @Test
    public void testDeptManagerPermission() {
        // 模拟部门经理登录(部门ID=2)
        CurrentUserContext.UserInfo deptManager = new CurrentUserContext.UserInfo();
        deptManager.setUserId(2L);
        deptManager.setRole(PermissionConstant.ROLE_DEPT_MANAGER);
        deptManager.setDeptId(2L);
        CurrentUserContext.setCurrentUser(deptManager);
​
        // 原始SQL:SELECT * FROM t_user WHERE age > 25
        // 拦截后SQL:SELECT * FROM t_user WHERE age > 25 AND dept_id = 2
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.gt(User::getAge, 25);
        List<User> deptUser = userMapper.selectList(queryWrapper);
        log.info("部门经理查看本部门用户:{}条", deptUser.size());
​
        CurrentUserContext.clearCurrentUser();
    }
​
    // 测试普通员工角色(查看自己创建的数据)
    @Test
    public void testEmployeePermission() {
        // 模拟普通员工登录(用户ID=3)
        CurrentUserContext.UserInfo employee = new CurrentUserContext.UserInfo();
        employee.setUserId(3L);
        employee.setRole(PermissionConstant.ROLE_EMPLOYEE);
        employee.setDeptId(2L);
        CurrentUserContext.setCurrentUser(employee);
​
        // 原始SQL:SELECT * FROM t_user
        // 拦截后SQL:SELECT * FROM t_user WHERE create_user_id = 3
        List<User> selfUser = userMapper.selectList(null);
        log.info("普通员工查看自己创建的用户:{}条", selfUser.size());
​
        CurrentUserContext.clearCurrentUser();
    }
}

3.3 自定义拦截器开发总结

结合上面2个场景,总结自定义MP SQL拦截器的核心开发流程(必记),确保开发的拦截器规范、可复用、无隐患:

  1. 确定拦截点:根据需求选择对应的拦截点(Executor/StatementHandler/ParameterHandler/ResultSetHandler),比如修改SQL选StatementHandler,处理结果选ResultSetHandler。

  2. 实现InnerInterceptor接口:重写对应方法(如beforePrepare、afterQuery),无需关注原生MyBatis的代理逻辑,MP已封装好规范。

  3. 核心逻辑实现:修改SQL用JSqlParser解析,处理参数/结果用反射,注意异常捕获和容错(避免解析失败导致整个SQL执行异常)。

  4. 注册到MybatisPlusInterceptor:必须将自定义拦截器添加到拦截器容器中,且严格控制顺序(错误顺序会导致功能失效)。

  5. 测试验证:覆盖不同场景(正常情况、异常情况、边界情况),确保拦截逻辑生效,不影响原有SQL执行。

关键提醒:自定义拦截器尽量单一职责(一个拦截器只处理一个功能,如脱敏拦截器只做脱敏,数据权限拦截器只做权限控制),便于维护和扩展。

四、避坑指南

MP SQL拦截器虽然强大,但如果使用不当,会导致SQL性能退化、业务逻辑异常、数据安全问题,结合企业开发真实案例,总结5个高频坑点,附带解决方案,帮你避坑。

4.1 坑点1:拦截器顺序错误(最常见,直接导致功能失效)

问题现象

多拦截器协同工作时,功能失效:比如多租户拦截器放在分页拦截器之后,导致分页SQL拼接在租户条件之前,出现SQL语法错误;数据权限拦截器放在分页之后,导致权限条件未生效,查询到全表数据。

解决方案(必记顺序)

拦截器添加顺序遵循「先修改SQL结构,后补充条件,最后处理结果」的原则,标准顺序(从先到后):

  1. 动态表名拦截器(DynamicTableNameInnerInterceptor):最先修改表名,后续拦截器基于修改后的表名处理。

  2. 多租户拦截器(TenantLineInnerInterceptor):先隔离租户数据,避免跨租户查询。

  3. 数据权限拦截器(自定义):再拼接行级权限条件,进一步过滤数据。

  4. 分页拦截器(PaginationInnerInterceptor):基于前面的条件,拼接分页语句(最后修改SQL结构)。

  5. 乐观锁拦截器(OptimisticLockerInnerInterceptor):只修改更新SQL的条件,不影响SQL结构。

  6. 非法SQL拦截器(IllegalSQLInnerInterceptor):拦截风险SQL,避免安全问题。

  7. SQL审计拦截器(自定义):记录SQL执行日志,用于排查问题。

  8. 数据脱敏拦截器(自定义):最后处理查询结果,不影响SQL执行。

记忆口诀:表名→租户→权限→分页→乐观锁→非法→审计→脱敏

4.2 坑点2:滥用拦截器,导致SQL性能退化

问题现象

自定义拦截器中做复杂操作(如:频繁反射、循环遍历大量数据、远程调用),导致SQL执行耗时大幅增加,比如脱敏拦截器中遍历十万条结果集,导致接口响应超时。

解决方案

  1. 拦截器逻辑尽量轻量化:避免复杂计算、远程调用,反射操作尽量缓存(如缓存实体类字段信息)。

  2. 针对性拦截:避免拦截所有SQL,通过ignoreTable、ignoreMethod等方式,忽略不需要拦截的表和方法。

  3. 结果集处理优化:批量处理数据,避免循环遍历单条数据,比如脱敏时批量处理列表数据。

4.3 坑点3:SQL解析不严谨,导致语法错误

问题现象

自定义拦截器修改SQL时,未考虑复杂SQL场景(如:子查询、联表查询、GROUP BY/HAVING子句),导致拼接条件后出现SQL语法错误。

解决方案

  1. 使用JSqlParser解析SQL:避免手动拼接字符串修改SQL,JSqlParser能正确解析复杂SQL结构,确保拼接条件的位置正确。

  2. 覆盖复杂SQL场景测试:测试时包含子查询、联表查询、分页+多条件等场景,确保SQL解析无误。

  3. 添加异常兜底:解析SQL失败时,抛出明确异常,便于排查问题,避免模糊报错。

4.4 坑点4:忽略ThreadLocal内存泄漏

问题现象

自定义拦截器中使用ThreadLocal存储上下文(如:租户ID、当前用户信息),但请求结束后未清除,导致线程池中的线程持有过期数据,出现数据错乱、内存泄漏。

解决方案

  1. 强制清除ThreadLocal数据:在过滤器/拦截器的finally块中,调用ThreadLocal的remove()方法,确保请求结束后清除数据。

  2. 使用Spring提供的RequestContextHolder:结合请求生命周期,自动清理上下文数据,避免手动管理。

4.5 坑点5:未忽略系统表/公共表,导致业务异常

问题现象

多租户、数据权限拦截器未忽略系统表(如:字典表、租户表、日志表),导致查询系统表时被拼接租户/权限条件,查询不到数据,出现系统异常。

解决方案

  1. 统一配置忽略表:在拦截器中通过ignoreTable方法,明确忽略系统表、公共表,避免拦截。

  2. 按方法忽略:对于部分不需要拦截的接口(如:管理员查询系统字典),通过方法名匹配忽略拦截。

相关推荐
予枫的编程笔记2 小时前
【Kafka进阶篇】Canal+Kafka+ES实战:内容平台数据同步难题,这样解最优雅
redis·mysql·elasticsearch·kafka·canal·数据同步·异步解耦
陈桴浮海2 小时前
MySQL 主从复制与 GTID 环形复制
linux·mysql·云原生
“αβ”2 小时前
MySQL数据类型
c语言·数据库·opencv·mysql·数据挖掘·数据类型·数据
Pluto_CSND2 小时前
Mybatis访问PostgreSql异常:PSQLException: 错误: 无法确定参数 $1 的数据类型
postgresql·mybatis
莫寒清2 小时前
MyBatis 与 MyBatis-Plus 的区别
面试·mybatis
亓才孓2 小时前
【MyBatis Plus】@Service标签应该放在ServiceImpl上(接口不可以实例化)
mybatis
笑我归无处14 小时前
Springboot+mybatisplus配置多数据源+分页
spring boot·后端·mybatis
海边的Kurisu15 小时前
Mybatis-Plus | 只做增强不做改变——为简化开发而生
java·开发语言·mybatis
zihan032117 小时前
若依(RuoYi)框架核心升级:全面适配 SpringData JPA,替换 MyBatis 持久层方案
java·开发语言·前端框架·mybatis·若依升级springboot