☰
Spring Boot 3整合MyBatis-Plus:CRUD、分页与逻辑删除
2026/10/25 2:48:53 网站建设 项目流程

简介:这是一份面向 Java Web 开发者的 Spring Boot 3 与 MyBatis-Plus 整合示例项目,适合正在学习框架整合、需要快速搭建数据访问层的初中级开发者参考。工程围绕依赖引入、数据源配置、实体注解、Mapper 接口、Service 与 Controller 分层展开,重点展示如何借助 BaseMapper、条件构造器、自动填充和分页思路减少重复 CRUD 代码,并保留清晰的项目结构便于对照修改。读者可据此理解 Spring Boot 3 自动装配与 MyBatis-Plus 通用 CRUD 的协作关系,并掌握字段映射、条件查询和基础测试的常见写法。压缩包共 24 个文件,包含 7 个 Java 源码、7 个编译后的 class、2 个 YAML 配置及 pom.xml、properties、jar、说明文档等,整体约 85KB,标准 Maven 目录便于导入 IDE 后直接查看配置关系和运行效果。已有 1435 人学习下载,可作为整合流程、注解用法与基础增删改查验证的参考样例,帮助读者理解 Spring 容器自动装配、MyBatis-Plus 无缝接入以及分层开发的实践方式。

1. 项目定位:Spring Boot 3 与 MyBatis-Plus 的整合能省掉多少手写 DAO

接手一个中后台项目的二期需求时,真正拖时间的往往不是业务判断,而是那几十张表来回写的 select、insert、update 语句。Spring Boot 3 配上 MyBatis-Plus 的整合方案,核心价值就是用一套BaseMapper加条件构造器,把单表 CRUD 的模板代码压到接近零,同时保留手写 XML 应付联表统计的出口,不像全 ORM 那样把 SQL 藏成黑匣子。这套组合适合正在做管理系统、内部工具、数据运营平台的团队,也适合刚从 JDK 8 迁到 JDK 17、想找一个比 JPA 更贴近原生 SQL 的持久层方案的开发者。下面按工程骨架、核心链路、增强能力、踩坑排查、二次开发的顺序,把一份能直接跑起来的整合工程拆开讲。

2. 工程骨架搭建:JDK 17 下的依赖坐标与 yml 配置一次配齐

动手之前先把版本关系理顺,这一步省下来的时间远大于敲代码的时间。Spring Boot 3 把基线抬到了 JDK 17,javax.*全部换成jakarta.*,MyBatis-Plus 也因此拆出了专门的 Boot3 Starter。很多人第一次整合翻车,不是代码写错,而是把 Boot2 时代的mybatis-plus-boot-starter直接粘过来,启动时抛一堆类找不到的异常,然后开始怀疑人生。

2.1 版本三角:JDK 17、Spring Boot 3 与 MyBatis-Plus 的对应关系

这三者的关系是硬约束,不是建议。Spring Boot 3.x 编译产物是 class 文件版本 61,JDK 8 和 11 直接编译不过;MyBatis-Plus 从 3.5.3 之后才开始提供 Boot3 专用的 starter,包名是mybatis-plus-spring-boot3-starter,和原来的mybatis-plus-boot-starter是两个不同的 artifact。Spring Boot 3.2 之后还引入了对虚拟线程的支持,如果你打算开spring.threads.virtual.enabled=true,那配套的连接池和 MyBatis 的SqlSession使用方式也要一并检查,别只改一个开关就上线。

组件选型要求不满足时的典型现象
JDK17 及以上编译报「无效的目标发行版」或类文件版本不匹配
Spring Boot3.0 及以上引入 Boot3 starter 后 Bean 装配失败
MyBatis-Plus使用 Boot3 专用 starter启动抛NoClassDefFoundError或缺FactoryBean类型
驱动mysql-connector-j(新坐标)旧坐标mysql:mysql-connector-java已停止维护
JSqlParser分页插件依赖,3.5.9 起需单独引入分页不生效、count 语句生成异常

我一般的做法是在父 pom 里用<properties>把这三个版本号集中管理,子模块只引 artifact 不写版本。这样后面升级的时候改一处就行,不会出现某个子模块偷偷用了老版本的情况。

2.2 pom.xml:starter 该引哪一个、哪些可以省

依赖清单里真正必须的只有四五个,剩下的都是锦上添花。Web 场景引spring-boot-starter-web,持久层引 Boot3 版 MyBatis-Plus Starter,数据库驱动用新坐标,参数校验用spring-boot-starter-validation,Lombok 看团队习惯——如果团队里有人用 IDEA 的编译插件配置不统一,Lombok 反而是个麻烦源,我倾向在实体类里手写 getter/setter 或者用 record 做 VO。

<properties> <java.version>17</java.version> <!-- Spring Boot 3.x 的父版本,按你本地仓库实际可用的稳定版填写 --> <spring-boot.version>3.2.5</spring-boot.version> <!-- MyBatis-Plus 需使用 3.5.3 以上的 Boot3 适配版本 --> <mybatis-plus.version>3.5.7</mybatis-plus.version> </properties> <dependencies> <!-- Web 能力,Controller 层与 JSON 序列化都靠它 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 关键:Boot3 专用 starter,不能用 mybatis-plus-boot-starter --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>${mybatis-plus.version}</version> </dependency> <!-- MySQL 驱动新坐标,runtime 作用域即可,代码里不该出现驱动类名 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- 参数校验,@NotNull / @Size 之类 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>

这段配置里有两个参数需要单独说。第一个是${mybatis-plus.version},写在 properties 里是为了让所有子模块共用同一个版本,避免 Maven 依赖调解时把 MyBatis 核心包降级。第二个是mysql-connector-j的runtime作用域,它意味着编译期你拿不到驱动类,也就不可能在业务代码里new Driver(),这个约束本身就是一种防呆设计。

如果你的 MyBatis-Plus 版本在 3.5.9 以上,还要再补一个 JSqlParser 模块,否则分页插件的PaginationInnerInterceptor会因为找不到解析器而在运行期出问题:

<!-- 3.5.9 起 JSqlParser 被拆成独立模块,分页、多租户等插件依赖它 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-jsqlparser</artifactId> <version>${mybatis-plus.version}</version> </dependency>

2.3 application.yml:数据源、Mapper 扫描与全局策略

配置文件是整合工程里最容易写错、也最难在报错信息里定位的部分。数据源部分注意时区和编码,serverTimezone不写的话,MySQL 8 在某些容器镜像下会把LocalDateTime偏移 8 小时;allowPublicKeyRetrieval=true是新版驱动在非 SSL 连接下做密码校验的必要开关,少了它会报「Public Key Retrieval is not allowed」。

server: port: 8081 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/demo_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456 hikari: # 连接池上限,按数据库 max_connections 反推,别随手写 50 maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 3000 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: # XML 位置,多个模块时用 classpath* 前缀避免只扫到第一个 jar mapper-locations: classpath*:/mapper/**/*.xml # 实体包别名,XML 里写 resultType 可以省略全限定名 type-aliases-package: com.example.demo.module.*.entity configuration: # 数据库下划线转 Java 驼峰,关掉它字段映射会大面积对不上 map-underscore-to-camel-case: true # 开发期打开,生产务必注释掉,否则 SQL 日志会把敏感参数全打出来 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: banner: false db-config: # 主键策略:ASSIGN_ID 走雪花算法,适合分布式;自增表改成 AUTO id-type: assign_id # 逻辑删除三件套,配好之后 BaseMapper 会自动拼条件 logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0

mapper-locations里的classpath*:前缀容易被忽略。单模块项目写classpath:没问题,一旦拆成多模块、XML 分散在不同 jar 里,就会变成「Mapper 找到了但方法找不到」的玄学问题,报错是Invalid bound statement (not found)。这个坑我踩过不止一次,后来一律用classpath*:。

log-impl选StdOutImpl只是图省事,正规点的做法是交给日志框架,把com.example.demo.module这个包的日志级别调到 debug,SQL 就会进你的日志文件,方便和业务日志对照时间线。

2.4 启动类与最小可运行验证

骨架搭完,先别急着写业务,跑一个最小闭环确认链路是通的。启动类上加@MapperScan,扫描路径要覆盖所有模块下的 mapper 包;如果每个 Mapper 接口都加@Mapper也行,但多模块项目里容易漏,集中在启动类上写一次更稳。

@SpringBootApplication // 多模块时用通配,避免新增模块忘记加扫描路径 @MapperScan("com.example.demo.module.**.mapper") public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }

验证顺序建议按「数据源 → 表映射 → 单表查询」三步走。先写一个只有一个selectById的接口,用手工插入的一行数据打通;确认返回的对象字段都正确,再往上叠条件构造和分页。反过来先写复杂联表查询,出问题时你要同时排查数据源、映射、SQL、分页四处,效率反而低。

提示:第一次跑通之后立刻把log-impl打开看一眼真实执行的 SQL,确认逻辑删除条件和分页 limit 都拼上去了,比读十遍文档管用。

3. 核心链路拆解:一次分页查询从 Controller 走到 SQL 的完整路径

把一条请求拆开看,MyBatis-Plus 做的事其实很朴素:Service 层把条件对象翻译成 SQL 片段,Mapper 层执行,插件层在 SQL 出去之前做改写。搞清楚这个顺序,后面无论是加拦截器还是排查「条件没生效」,都有明确的落点。

3.1 实体与表结构的映射:@TableName、@TableId 与驼峰规则

实体类是映射的起点。类名和表名不一致时用@TableName显式声明,主键用@TableId指定类型,普通字段靠全局的驼峰转换自动对齐。有几个注解值得单独记:@TableField(exist = false)用来标记「这个字段数据库里没有」,比如聚合出来的计数;@TableField(fill = ...)配合自动填充器使用;@TableLogic可以覆盖全局的逻辑删除配置,适用于个别表字段名不同的情况。

@Data @TableName("t_order") public class Order { // 雪花 ID:ASSIGN_ID 策略下由 MyBatis-Plus 生成,不依赖数据库自增 @TableId(type = IdType.ASSIGN_ID) private Long id; private String orderNo; private Long userId; private BigDecimal amount; // 只在插入时填充 @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; // 插入和更新都填充 @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; // 逻辑删除标记,查询时自动追加 deleted = 0 @TableLogic private Integer deleted; // 乐观锁版本号,配合 OptimisticLockerInnerInterceptor @Version private Integer version; // 数据库无此列,仅用于承接联表查询的附加信息 @TableField(exist = false) private String userName; }

参数说明上,IdType.ASSIGN_ID生成的是Long型雪花 ID,前端接收时要注意 JS 的Number精度只有 53 位,直接返回会丢精度,常见做法是给Long字段加@JsonSerialize(using = ToStringSerializer.class)转成字符串输出。IdType.AUTO则依赖数据库自增,分库分表场景下不能用。@Version字段在数据库里要有默认值 0,否则第一次更新时null + 1会出问题。

3.2 Mapper 与 Service 两层:BaseMapper 和 IService 该怎么选

MyBatis-Plus 给了两套 API。BaseMapper<T>是最薄的一层,方法名直白,selectById、selectList、insert、updateById,返回实体或集合;IService<T>在其上再封一层,加入了批量操作、saveOrUpdate、链式调用lambdaQuery()。我的选择标准是:Controller 直接调 Service,Service 内部对单表操作优先用lambdaQuery()链式写法,可读性好;需要联表或复杂统计时,在 Mapper 里加自定义方法配 XML,Service 只做编排。

public interface OrderMapper extends BaseMapper<Order> { /** * 自定义分页查询:联表取用户名,条件由 XML 动态拼 * 注意:分页场景下 IPage 必须是第一个参数,否则插件不生效 */ IPage<OrderVO> selectOrderPage(IPage<?> page, @Param("query") OrderQuery query); } @Service @RequiredArgsConstructor public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements OrderService { // 构造器注入 Mapper,ServiceImpl 里已经持有 baseMapper,此处仅作演示 private final OrderMapper orderMapper; @Override public IPage<OrderVO> pageWithUser(OrderQuery query) { // 页码从 0 开始计,这点和很多前端组件的 1 起始不一致,需要在 Controller 层减一 Page<OrderVO> page = new Page<>(query.getPageNum(), query.getPageSize()); return orderMapper.selectOrderPage(page, query); } }

这里有个IPage位置的细节值得强调:自定义分页方法里,IPage参数必须放在参数列表第一个,插件才会去解析它。放在第二个位置的现象是查询能跑通,但返回结果里total是 0、records是全量数据,很容易被误判成「分页插件没注册」。ServiceImpl提供的baseMapper字段已经能覆盖大部分场景,额外注入同类型 Mapper 只是个人习惯,按团队规范来。

3.3 分页与联表:Page 对象 + 自定义 XML 的混合写法

分页要生效,必须注册拦截器。这段配置是整合工程的核心,插件顺序错了会出现「乐观锁不生效」或「count 语句被改坏」的问题。官方建议的顺序是多租户、动态表名在前,分页和乐观锁居中,防全表更新放最后。

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 分页插件:显式指定方言,避免多数据源时猜错 PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); // 单页上限,防止前端传 pageSize=100000 把库打挂 pagination.setMaxLimit(500L); // 超过最大页时返回空列表而不是回到首页 pagination.setOverflow(false); interceptor.addInnerInterceptor(pagination); // 乐观锁:只对带 @Version 的实体生效 interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); // 防全表更新删除:拦下没有 where 条件的 update/delete interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); return interceptor; } }

setMaxLimit(500L)这个上限值得当作默认配置。后台管理页面里常见「导出全部」按钮,如果分页参数被前端随手写成pageSize=999999,一次查询就能把连接池占满。BlockAttackInnerInterceptor则是给手滑上的保险,update(new Order())这种写法会直接被拦下来抛异常,比等着 DBA 找上门强。

联表查询的 XML 写法上,MyBatis-Plus 并不会替你拼逻辑删除条件,这条得自己加:

<select id="selectOrderPage" resultType="com.example.demo.module.order.vo.OrderVO"> select o.id, o.order_no, o.amount, o.create_time, u.nickname as userName from t_order o left join t_user u on u.id = o.user_id <!-- 逻辑删除条件在手写 SQL 里不会自动追加,必须显式写 --> where o.deleted = 0 <if test="query.userId != null"> and o.user_id = #{query.userId} </if> <if test="query.keyword != null and query.keyword != ''"> and o.order_no like concat('%', #{query.keyword}, '%') </if> order by o.create_time desc </select>

resultType指向 VO 时,字段映射依赖驼峰转换,所以 SQL 里的userName别名要和 VO 的字段名对上,或者干脆用as user_name让它自动转换。concat('%', #{keyword}, '%')这种写法比${}安全,后者有注入风险,除非是排序字段这种必须动态拼接的场景——那种情况一定要在代码里做白名单校验。

3.4 条件构造器:LambdaQueryWrapper 的常用姿势与易错点

条件构造器是 MyBatis-Plus 里使用频率最高的 API。LambdaQueryWrapper用方法引用取字段名,避免了硬编码字符串带来的「改字段名忘记改 SQL」问题;QueryWrapper写字符串字段名更灵活,但重构时没有编译期保护。

public List<Order> searchOrders(OrderQuery query) { LambdaQueryWrapper<Order> wrapper = Wrappers.lambdaQuery(Order.class) // 等值条件:字段非空才拼,避免 where user_id = null .eq(query.getUserId() != null, Order::getUserId, query.getUserId()) // 范围条件 .ge(query.getStartTime() != null, Order::getCreateTime, query.getStartTime()) .le(query.getEndTime() != null, Order::getCreateTime, query.getEndTime()) // 模糊条件:likeRight 生成 'xx%',like 生成 '%xx%',前者能走索引 .likeRight(StringUtils.hasText(query.getOrderNo()), Order::getOrderNo, query.getOrderNo()) // 金额区间用 between,两端都为空时不拼接 .between(query.getMinAmount() != null && query.getMaxAmount() != null, Order::getAmount, query.getMinAmount(), query.getMaxAmount()) .orderByDesc(Order::getCreateTime); return list(wrapper); }

第一个参数是布尔条件,这是条件构造器最好用的设计,一行代码省掉一堆if。需要注意的坑有三个:一是like与likeRight的选择,左模糊%xx用不上索引,订单号这类前缀明确的字段一律用likeRight;二是eq传null值时,布尔条件为 true 但参数是 null,会生成where user_id = null,结果永远为空集,所以要么用布尔判断,要么保证入参非空;三是orderByDesc后面可以继续串联,但一旦调用过last()或者子查询,后续条件就不再保证顺序,复杂查询老实写 XML。另外wrapper是无状态构建、有状态使用,同一个 wrapper 传给两个查询会累积条件,我在 Service 里从来不跨方法复用。

4. 常用增强能力:逻辑删除、字段自动填充与乐观锁的落地写法

骨架和主链路通了之后,接下来这三件事基本每个项目都会用到。它们的共同点是「配好了几乎无感,配错了排查成本很高」,所以每一项都要理解它在 SQL 层面到底改了什么。

4.1 逻辑删除:一条配置替代手写 deleted = 0

逻辑删除在全局配置里写好之后,BaseMapper的所有单表方法都会自动带条件:selectById变成select ... where id = ? and deleted = 0,deleteById变成update ... set deleted = 1 where id = ?。业务代码里一行都不用改,这是它最舒服的地方。

// 全局配置了 logic-delete-field 之后,实体上加注解即可 @TableLogic(value = "0", delval = "1") private Integer deleted;

参数含义:value是未删除时的值,delval是已删除的值,如果和全局配置一致可以省略不写。数据库里这个字段必须有默认值 0 且非空,否则历史数据是null的话,deleted = 0条件会把这些行过滤掉,表现为「老数据突然查不到了」。这个坑我在一个迁移项目里遇到过,加了逻辑删除之后,上线前一天发现半年前的数据全部消失,最后靠一条update t_order set deleted = 0 where deleted is null补回来。

注意:逻辑删除只对单表 API 自动生效,手写 XML 和@Select注解里的 SQL 都要自己加deleted = 0,否则已删除的数据会从联表查询里漏出来。

4.2 自动填充:MetaObjectHandler 处理创建时间与操作人

创建时间、更新时间、创建人这些字段,如果每次 insert 都手写一遍setCreateTime(LocalDateTime.now()),早晚有人漏。MetaObjectHandler就是给这件事准备的钩子,插入和更新时各触发一次。

@Component public class AuditMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { LocalDateTime now = LocalDateTime.now(); // strictInsertFill 只在字段为 null 时填充,避免覆盖调用方显式设置的值 this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, now); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, now); // 操作人从当前登录上下文取,这里用占位方法示意 this.strictInsertFill(metaObject, "createBy", Long.class, currentUserId()); } @Override public void updateFill(MetaObject metaObject) { // 更新时间在每次 update 时强制刷新 this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }

两个方法名要记住:strictInsertFill和strictUpdateFill,带 strict 的版本只在该字段值为 null 时填充,不带 strict 的版本会无条件覆盖。一般插入用 strict,更新时如果业务允许覆盖就用非 strict 的setFieldValByName。还有一点,updateFill只在调用updateById(entity)或者update(entity, wrapper)时触发,用LambdaUpdateWrapper.set(...)手动指定字段的更新不会走填充器,得自己set(Order::getUpdateTime, LocalDateTime.now())。

4.3 乐观锁:@Version 与 updateById 的配合

并发修改同一条记录时,乐观锁比悲观锁轻量得多。用法是实体上@Version标注版本字段,拦截器在 update 时自动追加and version = ?并把version + 1写回。

// 业务层:先查再改,返回值为 0 表示版本冲突 public boolean updateAmount(Long orderId, BigDecimal amount) { Order order = getById(orderId); if (order == null) { throw new IllegalArgumentException("订单不存在"); } order.setAmount(amount); // updateById 会带上 version 条件,被别的线程改过则影响行数为 0 return updateById(order); }

调用方拿到false时说明数据已经被别人改过,可以重试一次(重新查一遍再改)或者直接返回提示。这里有个常见的误解:updateById更新时会把实体里所有非 null 字段都写进去,如果你的order对象是前端传上来的残缺对象,很可能把库里的其他字段覆盖成 null,进而触发FieldStrategy相关的配置。生产代码里我更倾向于用LambdaUpdateWrapper精确指定要改的字段,只在需要版本控制的场景配合实体更新。

4.4 多数据源:dynamic-datasource 的接法与边界

单库玩明白之后,读写分离或者分业务库的需求就来了。社区里用得最多的是dynamic-datasource-spring-boot3-starter,通过@DS注解在方法或类上指定数据源,本质是AbstractRoutingDataSource的封装。

@Service @DS("slave") // 整个类走从库,适合只读的报表服务 public class ReportServiceImpl extends ServiceImpl<OrderMapper, Order> implements ReportService { @DS("master") // 方法级注解优先级更高,需要强一致读时切回主库 public BigDecimal sumAmount(Long userId) { return baseMapper.sumAmount(userId); } }

配置文件里按约定的 key 声明多个数据源,主库标记primary: true。需要留意的边界有三条:一是@DS只对 Spring 代理的方法生效,同类内部方法互相调用不走代理,注解会失效;二是事务内跨数据源切换不生效,因为事务绑定的是开启时的连接;三是分页插件在多数据源下要显式指定DbType,否则方言猜错,limit语法可能在非 MySQL 库上直接报错。

5. 踩坑排查:整合期最常见的五类报错与定位手法

整合类的问题有个特点:报错信息指向的位置往往不是真正的病灶。下面这几条是我和同事在真实项目里反复遇到的,按出现阶段归了三类,每条都按现象、原因、解决的顺序写,方便对着排查。

5.1 启动期:类找不到与 Bean 装配失败

现象一:启动直接抛NoClassDefFoundError: org/mybatis/logging/Logger,或者报Invalid value type for attribute 'factoryBeanObjectType'。原因:引了 Boot2 时代的mybatis-plus-boot-starter,或者项目里同时存在新旧两个 starter,Maven 调解后留下的是老版本。解决:mvn dependency:tree -Dincludes=com.baomidou看一眼实际生效的坐标,把mybatis-plus-boot-starter全部替换成mybatis-plus-spring-boot3-starter,并在父 pom 里锁定版本。

现象二:启动报Invalid bound statement (not found),但 Mapper 接口明明存在。原因:八成是 XML 没被扫到。mapper-locations写成classpath:在单模块下能用,打包成多模块 jar 后就只扫第一个包;另一种可能是有 XML 放在src/main/java目录下,Maven 默认不打包非资源目录。解决:改成classpath*:/mapper/**/*.xml,并在 pom 的<resources>里把**/*.xml加进去。

5.2 运行期:分页不生效与自动填充失效

现象三:分页查询返回了全量数据,total一直是 0。原因:三种可能——拦截器没注册成@Bean、自定义方法的IPage参数没放第一个、或者 MyBatis-Plus 版本 3.5.9 以上没引mybatis-plus-jsqlparser。解决:先看日志里有没有生成select count(*),没有就是插件没生效;有 count 但没 limit,就检查参数位置和 JSqlParser 依赖。

现象四:createTime在插入后一直是 null。原因:MetaObjectHandler实现类没加@Component,或者实体字段没标@TableField(fill = ...),两者缺一不可;还有一种情况是用了saveBatch,某些版本下批量插入不走填充器。解决:确认实现类在启动类的扫描路径内,字段注解补全,批量场景在业务层手动设置时间。

5.3 数据层:时间偏移、字段映射与逻辑删除的连带问题

现象五:数据库里存的是2024-05-01 10:00:00,接口返回变成02:00:00,或者反过来。原因:JDBC URL 的serverTimezone、Jackson 的time-zone、MySQL 服务的时区三者不一致;另一个隐形来源是实体用了java.util.Date而参数用了LocalDateTime,转换过程中丢时区。解决:三处时区统一成Asia/Shanghai,实体统一用LocalDateTime,URL 里显式写serverTimezone。

除了上面五条,还有两个高频小问题:@TableField(exist = false)忘加导致查询报Unknown column 'xxx' in 'field list';map-underscore-to-camel-case被误关导致大面积字段为 null。后者的排查有个快捷方式——看返回对象的字段是否全部为 null,如果是,先怀疑映射全局配置,而不是逐个字段检查。

6. 二次开发:把通用 CRUD 抽成基类,并留好改造验证的回头路

业务表一多,每个 Controller 里那五个方法就成了复制粘贴的重灾区。真正值得抽的其实只有「查详情、分页、新增、改、删」这五件事,因为它们足够稳定;带业务语义的接口(比如「订单发货」)一旦塞进基类,后面就会变成参数越来越多、谁都不敢改的怪物。我一般抽一个抽象的BaseController,用泛型承接 Service 类型,具体子类只声明自己的实体和 VO。

public abstract class BaseController<S extends IService<T>, T> { // 由子类提供具体 Service 实例,避免泛型注入时的歧义 protected abstract S getService(); @GetMapping("/{id}") public R<T> detail(@PathVariable Serializable id) { return R.ok(getService().getById(id)); } @PostMapping public R<Boolean> create(@RequestBody @Validated T entity) { // 单表插入,主键由 MyBatis-Plus 按全局策略生成 return R.ok(getService().save(entity)); } @PutMapping public R<Boolean> modify(@RequestBody @Validated T entity) { // 走 updateById,若实体带 @Version 会自动做乐观锁校验 return R.ok(getService().updateById(entity)); } @DeleteMapping("/{id}") public R<Boolean> remove(@PathVariable Serializable id) { // 配置了逻辑删除时,这里是 update 而非 delete return R.ok(getService().removeById(id)); } }

子类只要三行:继承基类、实现getService()、声明@RequestMapping路径。分页这个方法我故意没放进基类,因为列表查询的过滤条件几乎每个模块都不一样,硬抽出来反而要写一堆泛型参数。真正落地时还会遇到两个需要提前想清楚的点:一是基类里的R返回结构要统一,错误码和异常处理交给全局@RestControllerAdvice,别让每个 Controller 各写一套;二是参数校验分组,新增和修改对id的要求不同,用@Validated(Update.class)这类分组接口区分,比在基类里写if (id == null)干净得多。

改造的验证方法建议按这个顺序走:先保留旧接口,用基类新开一组/v2路径,用同一批参数分别打新旧接口,比对响应 JSON 的字段和顺序;再跑一遍写操作,重点看逻辑删除后的deleted值、updateTime是否被填充、version有没有自增;最后压一轮并发更新同一条记录,确认冲突时返回的是业务提示而不是 500。这套流程跑完,心里才有底把旧接口下掉。

改造点推荐做法验证信号
通用 CRUD抽BaseController+ 泛型 Service新旧接口返回结构一致
权限与审计拦截器统一注入操作人createBy、updateTime有值
逻辑删除全局配置 + XML 显式补条件已删数据在联表查询里消失
分页上限setMaxLimit兜底传入超大 pageSize 被截断
异常处理全局@RestControllerAdvice冲突场景返回业务码而非 500

这套东西我从一个只有七张表的小系统一路用到三十多张表的中台,中间最大的教训是:基类只装「所有表都一样」的逻辑,哪怕只有一张表有特殊需求,也宁可让那个模块自己写一遍,别在基类里加if (type == xxx)。基类一旦开始长条件分支,就说明抽象错了位置,越早拆越好。后来每次动基类之前,我都会先把新旧接口的响应抓下来 diff 一遍,确认字段一个不差再提交——这个习惯救过我好几次,希望你也能少走点弯路,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询