第 1 篇收尾的时候我留了一句话:OrderRepository 接口先空着,MyBatis、JPA、MongoDB 三篇各给一个实现类,外层代码一行不动。这一篇来兑现第一个承诺:把内存仓储换成 MyBatis-Plus,订单落进 MySQL。接口契约我一个字没改,坑倒是比预想的多,那个悬了两篇的 Jackson 3 坑也在这一篇掉进去了。
代码全部可跑,工程在 springboot4-mybatis 目录下。需要一台能连的 MySQL,我用的库跑在树莓派的容器里,建表脚本一条命令跑完。
MyBatis-Plus 到底站在 MyBatis 的哪一层
我心里先给 MyBatis 定位:它是个 SQL 映射框架,SQL 由我来写,它负责塞参数、映射结果,不管 SQL 本身怎么写。MyBatis-Plus 没有替换它,是在它上面加了一层,加的东西归三类。
通用 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 上的四个要点
一,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 和插件这层。
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) 只存毫秒。我第一版创建接口返回:
紧接着查同一个订单:
两个值做等值比较永远不相等,填充时截断到毫秒。
条件构造器里那对括号
分页查询是这次改动最大的地方,从「内存过滤加切片」变成「拼一条 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 注入风险。
分页插件到底做了什么
注册拦截器:
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 上,要么框架生成,要么自己写。
小细节: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 在接口上的样子,浏览器打开演示数据第一条。
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,即文档说的「插入时逻辑删除字段的值不受限制」。
乐观锁。
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 篇契约里没有版本号,加可选字段兼容,加必填是破坏性变更。
字段校验失败,返回 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
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 以外的方言后,分页改写行为我没有验证;多租户和数据权限两个插件只在文档里读过,没在工程里注册过,等有真实场景我再补一篇实测。
参考链接