SpringBoot4系列02 - 集成MyBatis-Plus

第 1 篇收尾的时候我留了一句话:OrderRepository 接口先空着,MyBatis、JPA、MongoDB 三篇各给一个实现类,外层代码一行不动。这一篇来兑现第一个承诺:把内存仓储换成 MyBatis-Plus,订单落进 MySQL。接口契约我一个字没改,坑倒是比预想的多,那个悬了两篇的 Jackson 3 坑也在这一篇掉进去了。

代码全部可跑,工程在 springboot4-mybatis 目录下。需要一台能连的 MySQL,我用的库跑在树莓派的容器里,建表脚本一条命令跑完。

MyBatis-Plus 到底站在 MyBatis 的哪一层

我心里先给 MyBatis 定位:它是个 SQL 映射框架,SQL 由我来写,它负责塞参数、映射结果,不管 SQL 本身怎么写。MyBatis-Plus 没有替换它,是在它上面加了一层,加的东西归三类。

https://static.xiongneng.me/mybatis-plus-position-20260921211304.png

通用 CRUD:BaseMapper<T> 直接给增删改查,单表零 SQL。条件构造器:LambdaQueryWrapper 用方法引用指列名,属性改名编译期就报错。插件:分页、乐观锁、逻辑删除、多租户、数据权限以「拦截器」形式挂执行链,注册几个用几个。

这篇的工程里,第 1 篇那套接口一行路径没改,内存仓储换成 OrderMapper,我注册了分页和乐观锁两个插件。

方法路径变化
POST/api/orders照旧服务端生成,加唯一键冲突重试
GET/api/orders/{id}无
GET/api/orders分页改走 SQL 的 LIMIT
GET/api/orders/deleted新增,回收站,手写 SQL
GET/api/orders/stats新增,聚合统计,手写 SQL
PUT/api/orders/{id}多了可选版本号,支持乐观锁
PATCH/api/orders/{id}/status无
DELETE/api/orders/{id}从真删除变成逻辑删除

契约第 1 篇就写死了:OrderRepository 抽成接口,内存实现叫 InMemoryOrderRepository,等着今天换。

3.5.17 在 Boot 4 上的四个要点

https://static.xiongneng.me/mybatisplus-boot4-essentials-20260921211304.png

一,starter 坐标。Boot 4 下用 mybatis-plus-spring-boot4-starter,官方为 Boot 4 单独出的坐标,mybatis-plus-spring-boot-starter 不在 Boot 4 适配范围内。这个 starter 自带 spring-boot-starter-jdbc,pom 里再引一遍 JDBC starter 就是重复依赖,说实话不引白不引的便宜在这没有,多一个坐标多一分版本漂移的风险。

上一篇我留过「3.5.13 之后它专门出了适配 Boot 4 的 starter」,这次拿 Maven 挨个拉坐标验证了一遍:

1
2
3
mybatis-plus-spring-boot4-starter:3.5.12   → 不存在
mybatis-plus-spring-boot4-starter:3.5.13   → 存在
mybatis-plus-spring-boot4-starter:3.5.14   → 存在

起点是 3.5.13,这篇用 3.5.17。

二,分页插件不在 starter 里。

于 v3.5.9 起,PaginationInnerInterceptor 已分离出来。如需使用,则需单独引入 mybatis-plus-jsqlparser 依赖。

只引 starter,PaginationInnerInterceptor 这个类找不到,编译过不去,我第一把就卡死在这行 import 上。工程里导入官方 BOM 统一版本,避免 mybatis-plus-jsqlparser 和核心包错位。

三,MapperScan 的包路径。com.baomidou.mybatisplus.annotation.MapperScan 在 3.5.17 里不存在,写成这个包路径直接编译失败;官方推荐 org.mybatis.spring.annotation.MapperScan。我差点照着 IDEA 的自动补全把它写上去,好在先查了文档,没闹笑话。这篇接口上直接标 @Mapper,只有一个 Mapper,写配置类扫包不划算。

四,拦截器注册顺序有语义,官方文档也有提醒。

在使用多个插件时,请将分页插件放到插件执行链的最后面,以避免 COUNT SQL 执行不准确的问题。

拦截器按顺序改写 SQL,分页往 SQL 尾部追加 LIMIT,排在前头的话后面还有拦截器再动,COUNT 就可能算错。分页放最后。

依赖和配置

pom 里跟数据库有关的部分,我摘出来。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<properties>
    <java.version>21</java.version>
    <mybatis-plus.version>3.5.17</mybatis-plus.version>
</properties>

<dependencyManagement>
    <dependencies>
        <!-- 用官方 BOM 统一版本,避免核心包与 jsqlparser 错位 -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-bom</artifactId>
            <version>${mybatis-plus.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <!-- 注意 boot4 后缀,不能省 -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-spring-boot4-starter</artifactId>
    </dependency>
    <!-- 分页插件自 3.5.9 起分离,不引这个 PaginationInnerInterceptor 找不到 -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-jsqlparser</artifactId>
    </dependency>
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

实际拉出来的依赖树,跟这个主题有关的是这几个。

1
2
3
4
5
6
7
8
com.baomidou:mybatis-plus-spring-boot4-starter:jar:3.5.17
+- com.baomidou:mybatis-plus:jar:3.5.17
|  +- com.baomidou:mybatis-plus-core:jar:3.5.17
|  \- org.mybatis:mybatis:jar:3.5.19
+- org.mybatis:mybatis-spring:jar:4.0.0
\- org.springframework.boot:spring-boot-starter-jdbc:jar:4.1.1
com.baomidou:mybatis-plus-jsqlparser:jar:3.5.17
com.mysql:mysql-connector-j:jar:9.7.0

spring-boot-starter-jdbc 是 starter 自带的,HikariCP 也在里面,不用单独引;mybatis-spring 到了 4.0.0,版本号跟 Boot 4 走。

application.yml 里两处是重点,其余是常规数据源配置。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
spring:
  datasource:
    url: jdbc:mysql://${DB_HOST:192.168.1.97}:${DB_PORT:3306}/${DB_NAME:springboot4_mybatis}?...
    hikari:
      maximum-pool-size: 10
      connection-timeout: 5000

mybatis-plus:
  mapper-locations: classpath*:/mapper/**/*.xml
  configuration:
    map-underscore-to-camel-case: true
  global-config:
    banner: false
    db-config:
      logic-delete-value: 1
      logic-not-delete-value: 0

连接信息走环境变量,默认值指向我的局域网地址,你跑之前记得改成自己的;库名带 springboot4_ 前缀,不和别的工程撞。map-underscore-to-camel-case 必须开,order_no 到 orderNo 的转换靠它。逻辑删除两个值有默认值,我写出来是为了让删除标记在配置里一眼可见。

核心代码

调用链比第 1 篇长了一截,中间多了 MyBatis 和插件这层。

https://static.xiongneng.me/layered-architecture-20260921211304.png

Controller、Service、DTO 都是第 1 篇那套,一个字没改。变化在 Service 不再依赖 OrderRepository,直接拿 OrderMapper,往下是 MyBatis 执行链,插件挂在链上,落到 MySQL。

实体上的注解决定框架能做什么

先说我的结论:MyBatis-Plus 的能力从实体注解上读出来,不标,框架就不管。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@TableName(value = "t_order", autoResultMap = true)
public class Order {

    @TableId(type = IdType.AUTO)
    private Long id;

    @TableField(updateStrategy = FieldStrategy.ALWAYS)
    private String remark;

    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createdAt;

    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updatedAt;

    @TableField(typeHandler = Jackson3TypeHandler.class)
    private List<OrderItem> items;

    @TableLogic
    private Integer deleted;

    @Version
    @TableField(fill = FieldFill.INSERT)
    private Integer version;
}

每个注解背后都有一个「不写会怎样」,我逐个对过。

@TableId(type = IdType.AUTO) 不写,主键不走自增:默认策略 ASSIGN_ID 用雪花算法生成十九位长整型,AUTO_INCREMENT 列白设,必须显式声明。

FieldStrategy.ALWAYS 不写,更新接口传 remark: null 清空备注不生效:默认策略 NOT_NULL 把 null 当「这次不更新这一列」,不是「置空」。

@TableLogic 不写,deleteById 就是真删除。@Version 不写,updateById 就是无保护覆盖写。Jackson3TypeHandler 和 autoResultMap = true 放到 JSON 列那节细说。

实体我写成可变 POJO,没用 record:自动填充回写字段、乐观锁读版本号自增都依赖无参构造加 setter,record 得写一堆额外配置配合反射,不值。对外暴露的仍是不可变的 OrderResponse。

自动填充,以及一个不报错的坑

审计字段由 MetaObjectHandler 回写,业务代码不用碰。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@Component
public class AuditFieldHandler implements MetaObjectHandler {

    @Override
    public void insertFill(MetaObject metaObject) {
        LocalDateTime now = now();
        this.strictInsertFill(metaObject, "createdAt", LocalDateTime.class, now);
        this.strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, now);
        this.strictInsertFill(metaObject, "version", Integer.class, 0);
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, now());
    }

    private LocalDateTime now() {
        return LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS);
    }
}

我用 strictInsertFill 而不是 setFieldValByName:前者只在字段为 null 时填,显式赋的值不会被覆盖。

这里踩过一个不报错的坑,我到现在还记得当时对着响应发懵的样子。version 那行是插入时置 0,跟建表的 DEFAULT 0 对齐,写完创建订单,响应里没有 version。没有报错,静默跳过,最后在官方文档注意事项里找到了原因。

字段必须声明 @TableField 注解,并设置 fill 属性来选择填充策略。

strictInsertFill 只填充标了 @TableField(fill = ...) 的字段:createdAt、updatedAt 标了被填上,version 只标了 @Version,这行等于没写。不抛异常、不打日志,要不是写了测试,这个问题我可能到现在都没发现。补上 @TableField(fill = FieldFill.INSERT) 就好。

小细节是 truncatedTo(ChronoUnit.MILLIS)。LocalDateTime.now() 带纳秒,建表用的 DATETIME(3) 只存毫秒。我第一版创建接口返回:

紧接着查同一个订单:

两个值做等值比较永远不相等,填充时截断到毫秒。

https://static.xiongneng.me/auto-fill-and-version-20260921211304.png

条件构造器里那对括号

分页查询是这次改动最大的地方,从「内存过滤加切片」变成「拼一条 SQL」。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public PageResponse<OrderResponse> page(OrderPageQuery query) {
    // MyBatis-Plus 的页码从 1 开始,对外契约是 0 基,差异只在这一行消化
    Page<Order> page = new Page<>(query.page() + 1L, query.size());

    LambdaQueryWrapper<Order> wrapper = new LambdaQueryWrapper<Order>()
            .and(StringUtils.hasText(query.keyword()), w -> w
                    .like(Order::getOrderNo, query.keyword())
                    .or()
                    .like(Order::getCustomerName, query.keyword()))
            .eq(query.status() != null, Order::getStatus, query.status())
            .orderBy(true, !query.descending(),
                    SORT_COLUMNS.getOrDefault(query.sortBy(), Order::getCreatedAt));

    Page<Order> result = orderMapper.selectPage(page, wrapper);
    return PageResponse.of(result.getRecords(), query.page(), query.size(),
            result.getTotal(), OrderResponse::from);
}

先看每个方法的第一个布尔参数,它是「是否拼接这个条件」:keyword 为空时那个 like 整段丢掉,不用拼一堆 if-else。

第二个是 .and(...) 那对括号,必须有。我的直觉写法是链式接两个 like 用 or() 连,SQL 是 WHERE deleted=0 AND order_no LIKE ? OR customer_name LIKE ?,OR 优先级把 deleted=0 拖进去。包一层出来 WHERE deleted = 0 AND ((order_no LIKE ? OR customer_name LIKE ?)),语义才对。

第三个是页码。Page 从 1 开始,第 1 篇的对外契约是 page=0,我把差异只在 new Page<>(query.page() + 1L, ...) 这一行消化。Page 的字段是 current、size、total、records,跟 Spring Data 的 Pageable 完全不同。Page 不能直接返回给前端,用的是自己的 PageResponse。

第四个是排序白名单。ORDER BY 列名没法参数化,我上了方法引用加白名单双重限制。

1
2
3
4
private static final Map<String, SFunction<Order, ?>> SORT_COLUMNS = Map.of(
        "id", Order::getId, "orderNo", Order::getOrderNo,
        "customerName", Order::getCustomerName, "totalAmount", Order::getTotalAmount,
        "status", Order::getStatus, "createdAt", Order::getCreatedAt, "updatedAt", Order::getUpdatedAt);

客户端字符串先过 OrderPageQuery 的白名单,查不到的键 getOrDefault 回退默认字段,外部字符串进不了 SQL。官方文档在分页插件那一页也有提醒。

排序 OrderItem 对象 column 属性建议不要让前端传入,如果你允许前端传入,相关内容一定要做 白名单验证 避免 SQL 注入风险。

https://static.xiongneng.me/pagination-and-logic-delete-20260921211449.png

分页插件到底做了什么

注册拦截器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());

    PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL);
    pagination.setMaxLimit(100L);
    interceptor.addInnerInterceptor(pagination);   // 分页必须最后加
    return interceptor;
}

DbType.MYSQL 是我显式写的:单一数据库可以不配让它探测,多数据源下它会认错。maxLimit 设 100 是双保险,接口 size 已有 @Max(100),这层防绕过校验直接调 Mapper。

分页插件做了什么,看 SQL 日志最直接。我带关键字跑了一次分页查询,执行了两条语句。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
OrderMapper.selectList_mpCount: ==>  Preparing: SELECT COUNT(*) AS total FROM t_order
                                          WHERE deleted = 0 AND ((order_no LIKE ? OR customer_name LIKE ?))
OrderMapper.selectList_mpCount: ==>  Parameters: %ORD%(String), %ORD%(String)

OrderMapper.selectList: ==>  Preparing: SELECT id,order_no,customer_name,customer_phone,total_amount,
                                          status,remark,created_at,updated_at,items,deleted,version
                                      FROM t_order
                                      WHERE deleted=0 AND ((order_no LIKE ? OR customer_name LIKE ?))
                                      ORDER BY id ASC LIMIT ?
OrderMapper.selectList: ==>  Parameters: %ORD%(String), %ORD%(String), 2(Long)

挺有意思,一条方法调用拆成两条 SQL。三件事:selectList_mpCount 是框架按原查询生成的 COUNT,不用自己写,ORDER BY 被优化掉了;deleted = 0 是逻辑删除自动加的;第 0 页只有 LIMIT ? 没有偏移量,翻到后面才变 LIMIT ?,?,按方言拼的。

逻辑删除,和它管不到的地方

deleteById 执行的是一条 UPDATE。

1
2
3
OrderMapper.deleteById: ==>  Preparing: UPDATE t_order SET updated_at=?, deleted=1 WHERE id=? AND deleted=0
OrderMapper.deleteById: ==>  Parameters: 2026-09-17T23:43:23.226(LocalDateTime), 3(Long)
OrderMapper.deleteById: <==    Updates: 1

删除被转换成更新,标记记录已删除;updated_at 被填上了,逻辑删除不绕过自动填充,我实测 deleteById 同样触发 updateFill,官方文档「删除接口自动填充功能失效怎么办」的第一条建议就是 deleteById;WHERE 里的 deleted=0 是框架加的,第二次删同一行 Updates: 0,服务层返回 404。

还有一条边界,我原样抄在这里。

@TableLogic 的过滤只加在 MyBatis-Plus 生成的 SQL 上,手写 XML 不会被追加。

坏的方向:手写查询都要自己写 AND deleted = 0,否则已删除数据会重新冒出来,聚合统计的 WHERE deleted = 0 是我手敲的,漏了就含回收站订单。好的方向:回收站这就从这儿来的,手写 WHERE deleted = 1 查逻辑删除的行。

1
2
3
4
5
6
7
<select id="selectDeleted" resultMap="orderResultMap">
    SELECT <include refid="allColumns"/>
    FROM t_order
    WHERE deleted = 1
    ORDER BY updated_at DESC
    LIMIT #{limit}
</select>

用 ORDER BY updated_at 是因为逻辑删除会更新它,倒序最近删掉的最靠前。

JSON 列,以及上一篇说的那个 Jackson 3 的坑

工程给订单加了明细字段:一个订单若干条商品,整份列表序列化成 JSON 存进 VARCHAR 列,正好踩中上一篇预告的坑。

我的结论:Boot 4 下必须用 Jackson3TypeHandler,不能用 JacksonTypeHandler。mybatis-plus-extension 里有两个几乎一样的类,我拿 javap 看字段签名。

1
2
3
4
5
6
7
public class JacksonTypeHandler extends AbstractJsonTypeHandler<Object> {
  private static com.fasterxml.jackson.databind.ObjectMapper OBJECT_MAPPER;
}

public class Jackson3TypeHandler extends AbstractJsonTypeHandler<Object> {
  private static tools.jackson.databind.ObjectMapper OBJECT_MAPPER;
}

一个 com.fasterxml.jackson,一个 tools.jackson,对应 Jackson 3 换包名前后。Boot 4 只带 Jackson 3,用 JacksonTypeHandler 编译能过(类还在 jar 里),运行时直接找不到 com.fasterxml.jackson.databind.ObjectMapper。

Jackson3TypeHandler 是 3.5.17 才加的,我对比了两个版本的 jar:

1
2
3
4
5
6
mybatis-plus-extension-3.5.13.jar:
  com/baomidou/mybatisplus/extension/handlers/JacksonTypeHandler.class

mybatis-plus-extension-3.5.17.jar:
  com/baomidou/mybatisplus/extension/handlers/JacksonTypeHandler.class
  com/baomidou/mybatisplus/extension/handlers/Jackson3TypeHandler.class

上一篇留的话正是这里:「3.5.13 的 starter 能用了,但里面还有 Jackson 3 的坑」,用 3.5.13 没有这个类。

光标 typeHandler 不够,还有更隐蔽的一层,我在这里栽得最狠。

1
2
3
4
5
6
@TableName(value = "t_order", autoResultMap = true)
public class Order {

    @TableField(typeHandler = Jackson3TypeHandler.class)
    private List<OrderItem> items;
}

autoResultMap = true 必须打开。MyBatis 只在 resultMap 映射时应用自定义 typeHandler,MyBatis-Plus 默认走自动映射,不看 @TableField,找不到能转 List 的内置 handler 就报错。这个开关让框架生成带 typeHandler 的 resultMap,漏掉的症状是写入正常、读取永远 null、不报错。

第三处在手写 XML。回收站查询我一开始用 resultType,加 items 列就不行,resultType 走的也是自动映射,带自定义 typeHandler 的列必须用 resultMap。

1
2
3
4
5
6
7
<resultMap id="orderResultMap" type="com.xncoding.mybatis.domain.Order">
    <id column="id" property="id"/>
    <result column="order_no" property="orderNo"/>
    ...
    <result column="items" property="items"
            typeHandler="com.baomidou.mybatisplus.extension.handlers.Jackson3TypeHandler"/>
</resultMap>

聚合统计那条没有 JSON 列,resultType 够用。规律我一句话说完:只要涉及自定义 typeHandler,就必须落到某个 resultMap 上,要么框架生成,要么自己写。

https://static.xiongneng.me/json-column-typehandler-20260921211304.png

小细节:Jackson3TypeHandler 内部的 ObjectMapper 是它自己 new 的,javap 里能看到那一行。

1
2
0: new           #13                 // class tools/jackson/databind/ObjectMapper
4: invokespecial #15                 // Method tools/jackson/databind/ObjectMapper."<init>":()V

它跟 Spring 容器里的不是同一个实例:Boot 默认关掉「反序列化遇到未知字段报错」,裸实例是开着的,多塞一个字段读取就失败。所以 OrderItem 定字段时我一个多余的都没加,包括算出来就能得到的 subtotal,要算小计在别处算。

这份 JSON 在接口上的样子,浏览器打开演示数据第一条。

https://static.xiongneng.me/mybatis-browser-json-view-20260918125932.png

items 是一段规整数组,price 的 299.50 保住了尾零。Chrome 默认把响应体压成一行,看分层结构点左上角「美观输出」。

完整案例,跑起来看

先建库。

1
mysql -h 127.0.0.1 -P 3306 -uroot -p < src/main/resources/sql/schema.sql

脚本建库建表,写 6 条覆盖全部 5 个状态的演示数据,可重复执行,每次删表重建。然后起服务。

1
2
mvn clean package
java -jar target/springboot4-mybatis-1.0.0.jar

下面的响应是实际跑出来的,一个字没改。截图和响应头里的 18081 是我录输出时临时指定的,免得跟占着 8080 的其他服务撞车;项目默认端口 8080,不用加参数。

创建订单,这次带上了明细。

1
2
3
4
curl -i -X POST http://localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -d '{"customerName":"熊大","customerPhone":"13800138000","totalAmount":299.50,"remark":"演示下单",
       "items":[{"name":"机械键盘","quantity":1,"price":299.50},{"name":"腕托","quantity":2,"price":25.00}]}'
1
2
3
4
5
6
7
8
HTTP/1.1 201
Location: http://127.0.0.1:18081/api/orders/7
Content-Type: application/json

{"id":7,"orderNo":"ORD2026091860336229","customerName":"熊大","customerPhone":"138****8000",
 "totalAmount":299.50,"status":"CREATED","statusLabel":"已创建","remark":"演示下单",
 "items":[{"name":"机械键盘","quantity":1,"price":299.50},{"name":"腕托","quantity":2,"price":25.00}],
 "version":0,"createdAt":"2026-09-18T07:45:49.017","updatedAt":"2026-09-18T07:45:49.017"}

version 这次在了,插入语句里 items 已是一段 JSON 字符串。

1
2
3
4
5
6
7
==>  Preparing: INSERT INTO t_order ( order_no, customer_name, customer_phone, total_amount, status,
              remark, created_at, updated_at, items, version ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )
==>  Parameters: ORD2026091860336229(String), 熊大(String), 13800138000(String), 299.50(BigDecimal),
              CREATED(String), 演示下单(String),
              2026-09-18T07:45:49.017(LocalDateTime), 2026-09-18T07:45:49.017(LocalDateTime),
              [{"name":"机械键盘","quantity":1,"price":299.50},{"name":"腕托","quantity":2,"price":25.00}](String),
              0(Integer)

(String) 标注参数类型,列表进 JDBC 前已被处理。列清单里没有 deleted:实体上是 null,插入时跳过,落库靠列的 DEFAULT 0,即文档说的「插入时逻辑删除字段的值不受限制」。

https://static.xiongneng.me/mybatis-order-created-201-20260918130002.png

乐观锁。

1
2
3
4
5
6
7
8
9
# 第一次,带上当前版本号 0
curl -i -X PUT http://localhost:8080/api/orders/7 \
  -H "Content-Type: application/json" \
  -d '{"customerName":"熊大","customerPhone":"13800138000","totalAmount":199.00,"remark":"改价","version":0}'

# 第二次,还拿版本号 0,这个已经过期了
curl -i -X PUT http://localhost:8080/api/orders/7 \
  -H "Content-Type: application/json" \
  -d '{"customerName":"熊大","customerPhone":"13800138000","totalAmount":666.00,"remark":"并发写入","version":0}'

第一次返回 200,版本号变 1。

1
2
3
4
5
==>  Preparing: UPDATE t_order SET customer_name=?, customer_phone=?, total_amount=?, remark=?,
                  updated_at=?, version=? WHERE id=? AND version=? AND deleted=0
==>  Parameters: 熊大(String), 13800138000(String), 199(BigDecimal), 改价(String),
                  2026-09-18T07:45:49.509(LocalDateTime), 1(Integer), 7(Long), 0(Integer)
<==    Updates: 1

SET 里的 version 绑新值 1,WHERE 里绑旧值 0。没有写成 version = version + 1 交给数据库算,新版本号是 MyBatis-Plus 在 Java 侧算好绑上去的,官方文档写明整数类型 newVersion 是 oldVersion + 1 并回写实体。

第二次返回 409。

同样的 SQL,Updates 变 0,WHERE version = 0 匹配不到行了,这行数就是并发写被挡下的铁证。

版本号是可选的:OrderUpdateRequest 的 version 不传,乐观锁不生效直接覆盖,要不要并发保护由客户端决定。要强制就把注解从 @PositiveOrZero 换成 @NotNull。我留成可选是因为第 1 篇契约里没有版本号,加可选字段兼容,加必填是破坏性变更。

https://static.xiongneng.me/mybatis-optimistic-lock-409-20260918130002.png

字段校验失败,返回 400,逐字段原因。

1
2
3
4
curl -i -X POST http://localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -d '{"customerName":"熊大","customerPhone":"13800138000","totalAmount":10.00,
       "items":[{"name":"","quantity":0,"price":0}]}'

errors 里的 items[0].price 能让前端直接定位第几条明细的哪个字段。生效前提是 OrderCreateRequest 列表字段标了 @Valid。

1
2
3
@Valid
@Size(max = 20, message = "订单明细最多 20 条")
List<OrderItem> items

少了 @Valid,@Size 还能挡「明细超过 20 条」,但 OrderItem 自己的校验注解全不执行,非法数据直接进库,很隐蔽,因为列表本身合法。

逻辑删除与回收站。

1
2
3
curl -i -X DELETE http://localhost:8080/api/orders/3   # 204
curl -i     http://localhost:8080/api/orders/3         # 404,查不到了
curl -s     http://localhost:8080/api/orders/deleted   # 200,回收站里查得到

items 能出来,说明手写 XML 的 resultMap 有效;updatedAt 是删除那一刻的时间,说明 updated_at 被自动填充。

21 个场景是我一个一个 curl 过去的,状态码按序 200、200、200、201、200、200、409、200、200、422、204、404、200、200、400、404、400、400、405、200、422,全部符合预期,应用日志 ERROR 行数为 0。

这一篇的测试怎么写

这篇测试要连真实的 MySQL,我没换 H2:分页改写、逻辑删除过滤、乐观锁自增全是数据库层面的行为,换内存库就是「测了个仿制品」。为依赖外部数据库我做了两件小事。

第一件,上下文创建阶段不碰数据库。Hikari 建池默认立刻拿连接,拿不到抛异常,Spring 上下文创建失败,测试类一片红。initialization-fail-timeout 设 -1 改懒加载。

1
2
3
4
5
spring:
  datasource:
    hikari:
      initialization-fail-timeout: -1
      connection-timeout: 3000

第二件,用例执行前探测一次,连不上整体跳过。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@BeforeAll
void checkDatabase() {
    try (Connection connection = dataSource.getConnection()) {
        Assumptions.assumeTrue(connection.isValid(3),
                "MySQL 连接不可用,跳过需要数据库的集成测试");
    } catch (Exception ex) {
        Assumptions.assumeTrue(false,
                "MySQL 连接不可用(" + ex.getMessage() + "),跳过需要数据库的集成测试");
    }
}

没配 MySQL 的机器上跑 mvn test,看到的是 Skipped,不是一堆红色。类上加了 @Transactional,用例结束事务回滚。

1
2
3
4
5
6
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Transactional
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class OrderApiIntegrationTest {

不回滚的话演示数据跑几次就乱,还得每次重置;我实测跑完库里还是 7 行 1 条已删除,一行没多。

1
mvn test
1
2
Tests run: 24, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

24 个用例里有几个是为踩过的坑写的回归测试。创建接口必须返回 version,那个洞就是它发现的。

1
body.extractingPath("$.version").asNumber().isEqualTo(0);

这条把「时间戳与落库值一致」钉住。

1
2
assertThat(created.createdAt()).isEqualTo(reloaded.createdAt());
assertThat(created.createdAt().getNano() % 1_000_000).isZero();

这条最值钱,同时守着 autoResultMap 和 Jackson3TypeHandler 两个配置。

1
2
3
// 写入走的是实体上的 typeHandler,读取要靠 autoResultMap 生成的 resultMap,
// 少配任何一个,这里拿到的都是 null
assertThat(reloaded.items()).hasSize(2);

避坑指南

这部分是我实打实踩到的,按痛苦程度排序。

坑一,strictInsertFill 对没标 fill 的字段静默跳过。 不报错:version 标了 @Version 没标 @TableField(fill = FieldFill.INSERT),填充那行白写,响应少一个字段。我一开始怀疑 Jackson 过滤了 null,看 SQL 日志才发现插入语句里没有 version 列。教训:遇到「响应里少一个字段」先看 SQL,别先怀疑序列化。

坑二,JSON 列读取出来是 null,而且不报错。 标了 typeHandler 却没开 autoResultMap = true,写入正常读取永远 null。手写 XML 用 resultType 同样映射不了,规律:带自定义 typeHandler 的字段,必须落到某个 resultMap 上。

坑三,JacksonTypeHandler 在 Boot 4 下是坏的。 编译能过,运行期报 NoClassDefFoundError。com.fasterxml 开头是 Jackson 2,tools.jackson 开头是 Jackson 3,Boot 4 只能用后者,且得 3.5.17 往上的版本。

坑四,时间戳精度不匹配。 LocalDateTime.now() 带纳秒,DATETIME(3) 存到毫秒,不截断则创建接口返回一个值、查出来另一个。和第 1 篇「ProblemDetail 字段按字母序输出」同类,客户端等值比较踩空。判断标准是写入返回的值和随后读到的值必须一致。

坑五,分页拦截器的顺序。 分页插件放拦截器链最后,官方文档明确写的,否则 COUNT 可能算错。

坑六,乐观锁是可选的。 @Version 传 null 就是无保护覆盖写。接口文档没写清楚,调用方会默认有并发保护。OrderUpdateRequest 的 version 上写了文档注释说明区别,不能容忍覆盖写就改成必填。

坑七,ORDER BY 的列名没法参数化。 有人直接把客户端字符串丢进 orderBy,官方文档专门提醒过。排序字段必须走白名单,这不算防御性编程,这是必答题。

小结

选型上说我的看法:单表为主、想少写 SQL 的 CRUD 服务,MyBatis-Plus 是稳稳的赚,但要用「能力从注解上读」的思路去用它,标了它才管,而且大多数时候不告诉你。动手清单四条:starter 带 boot4 后缀、分页单独引 jsqlparser、MapperScan 用 mybatis 的包、JSON 列配 Jackson3TypeHandler 加 autoResultMap。分页插件永远放拦截器链尾,手写 SQL 的 deleted 过滤自己负责。实体注解一条条对着「不写会怎样」过一遍,比出问题后再查日志省时间。

没解决的事记两条:DbType 换成 MySQL 以外的方言后,分页改写行为我没有验证;多租户和数据权限两个插件只在文档里读过,没在工程里注册过,等有真实场景我再补一篇实测。

参考链接