Spring Boot 业务接口开发:分层、校验与数据库操作
商品管理接口接收 SKU、名称和价格,将它们保存到数据库,再提供查询、修改和删除。一次修改还要留下操作记录;两个人同时编辑时,后提交的旧版本应得到冲突响应。完成这些功能,需要把 HTTP 输入、业务处理和数据库操作连接起来。
接口、数据与代码怎样组织
先确定允许读写什么
| 请求 | 输入 | 正常结果 |
|---|---|---|
POST /products | JSON:sku、name、price | 201 Created,返回商品和 Location |
GET /products/{id} | 路径中的商品 ID | 200 OK,返回商品 |
GET /products?page=1&size=20&name=键盘 | 页码、页大小、可选名称条件 | 200 OK,返回当前页和总数 |
PUT /products/{id} | JSON:name、price、version | 200 OK,返回修改后的商品 |
DELETE /products/{id} | 路径中的商品 ID | 204 No Content,没有响应体 |
SKU 是商品编码,创建后保持不变;名称和价格可以修改。数据库生成 ID,应用负责维护版本。客户端不能在创建时指定 ID,也不能借修改接口更换 SKU。更新请求要求完整提交可修改字段,缺字段会被拒绝;需要“只修改传入字段”的接口时,应另行定义 PATCH 中缺失、显式 null 和具体值的含义。
价格在 Java 中使用 BigDecimal,数据库列使用 numeric(12,2),允许十位整数和两位小数;接口要求大于零。应用同时限制小数位,防止传入三位小数后由数据库舍入成另一个值。SKU 唯一性由数据库约束最终保证,应用中的一次查询无法排除并发创建。
数据分为两张表:products 保存当前商品,product_events 保存商品 ID 与操作类型。创建、更新和删除都写入操作记录。操作记录中的 ID 独立保留,因此删除商品后仍能查询曾发生的操作;这里的物理删除不适合直接套用到已被订单、账务等业务引用的商品,应按业务需要改为下架或保留历史记录。
MVC 与三层结构的分工
com.example.products
├── ProductApplication 启动与组件扫描入口
├── ProductController HTTP 路由、参数、状态码
├── ProductRequests 创建和修改请求 DTO
├── ProductResponse 对外商品表示
├── PageResponse 对外分页表示
├── ProductService 用例执行、事务、结果判断
├── Product / ProductEvent 数据库映射对象
├── ProductMapper 商品数据访问
├── ProductEventMapper 操作记录写入
├── MybatisConfig 分页与乐观锁插件
└── ApiExceptionHandler 错误到 HTTP 响应的转换Spring MVC 接收请求,找到 Controller,完成参数解析,再处理返回值。JSON 接口使用 @RestController,返回对象经消息转换器序列化,不要求使用服务端模板页面。MVC 的 Model、View、Controller 与业务项目中的 Controller、Service、Mapper 并非一一对应;后者是常见的应用分层。Spring 首个应用中的 MVC 说明
Controller 只处理 HTTP 相关事项,Service 决定一次业务操作需要执行哪些步骤,Mapper 负责访问数据库。这样,定时任务或消息消费者需要创建商品时,可以复用业务服务,不必构造一个 HTTP 请求再绕回自己的 Controller。
ProductApplication 位于这些类的共同父包 com.example.products,声明启动入口:
@SpringBootApplication
public class ProductApplication {
public static void main(String[] args) {
SpringApplication.run(ProductApplication.class, args);
}
}@SpringBootApplication 启用应用配置、自动装配和组件扫描。项目扩大后,可以先按 catalog、order 等业务划分包,再在业务包内部安排 Web、应用和持久化代码,避免所有功能的 Controller、Service、Mapper 各自挤在三个巨型目录里。分层与依赖组织解释了两种组织方式的取舍。
建工程并连接 PostgreSQL
选择依赖,不重复装配框架
创建工程可以使用 Spring Initializr,选择 Maven、Java、JAR,添加 Spring Web、Validation、PostgreSQL Driver 和 Lombok。MyBatis-Plus 按它的安装文档加入 POM。IDE 中以 Maven 工程打开项目后,等待依赖导入完成,再运行启动类。
可复现的工程使用 Spring Boot 4.1.1、MyBatis-Plus 3.5.17、Lombok 1.18.48,编译目标 Java 17。完整源码、测试和 Docker Compose 配置位于商品管理工程 ZIP,解压后进入 boot-business。使用已有工程时,不必重新生成 Initializr 项目;选择已有 POM 导入即可。
关键依赖各自承担的工作如下:
| 依赖 | 作用 |
|---|---|
spring-boot-starter-webmvc | Spring MVC、JSON 响应和嵌入式 Web 服务器 |
spring-boot-starter-validation | 请求对象和方法参数的约束校验 |
mybatis-plus-spring-boot4-starter | MyBatis-Plus 与 Boot 4 的装配 |
mybatis-plus-jsqlparser | 分页等需要 SQL 解析的功能 |
postgresql | PostgreSQL JDBC 驱动 |
lombok | 编译期生成所选构造器、访问器等代码 |
| 测试 Starter | 启动测试上下文、执行断言等测试支持 |
Boot 管理的依赖沿用父 POM 的版本,MyBatis-Plus 和 Lombok 显式固定版本。不要同时再添加原生 MyBatis Boot Starter;Boot 3 与 Boot 4 的 MyBatis-Plus Starter 也需要分别选择。MyBatis-Plus 安装
工程的父 POM 与 Java 目标声明为:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<properties>
<java.version>17</java.version>
</properties><dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot4-starter</artifactId>
<version>3.5.17</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>3.5.17</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.48</version>
<scope>provided</scope>
</dependency>这些声明放入 pom.xml 的 <dependencies>。Lombok 还要在 Maven Compiler Plugin 中显式配置处理器,版本与依赖一致;使用 JDK 23 及以上编译时尤其不能依赖隐式扫描。release=17 控制目标字节码和可用 API,编译器本身仍可能是 JDK 25。Lombok Maven 配置
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<release>17</release>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.48</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>构建插件位于 <build><plugins>。工程还配置了 Spring Boot Maven Plugin,将应用打包成可执行 JAR,并显式排除 Lombok。Boot 的重新打包会收集 provided 依赖,不能仅凭这个 scope 推断 Lombok 已被排除。可执行归档的依赖处理
启动数据库、构建应用
在 Linux Bash 中操作,需要 Docker Engine、Docker Compose V2、unzip、curl 7.76+ 和 jq。使用具有 Docker 权限的普通宿主用户;Docker 权限本身能控制容器和宿主挂载。所有命令均在解压后的 boot-business 目录执行。
工程的 compose.yaml 定义三个服务:db 运行 PostgreSQL 18.6,verify 使用 Maven 构建和测试,app 运行打包后的应用。数据库不发布宿主端口,应用通过 Compose 网络中的 db:5432 访问它;容器里的 localhost 指向该容器自身。
mkdir -p .m2
docker compose up -d --wait db
docker compose run --rm --user "$(id -u):$(id -g)" verify
docker compose up -d --build app
docker compose ps
docker compose logs --tail=60 appverify 应完成真实 PostgreSQL 集成测试并显示 BUILD SUCCESS,生成 target/boot-business-1.0.0.jar;测试失败时先处理报告,不继续打包运行。Maven 构建容器使用宿主 UID/GID,源码、target 和 .m2 缓存必须可写。应用运行身份为 UID 10001;PostgreSQL 官方镜像的初始化及降权由它自己的入口脚本处理,与 Java 容器的身份分别配置。
应用端口发布到 127.0.0.1:18088。这组服务使用仅供本机练习的数据库账号,没有接入登录和权限控制,不能直接改成公网监听。对外提供商品管理功能前,需要在入口加入身份认证和授权,并为应用分配合适的数据库权限。
application.yml 提供数据源与 MyBatis-Plus 配置。Compose 通过 SPRING_DATASOURCE_URL、SPRING_DATASOURCE_USERNAME、SPRING_DATASOURCE_PASSWORD 覆盖连接信息。应用连接 product_app,测试连接 product_test;两个数据库分开,测试清理不会删除手动创建的商品。
例如,compose.yaml 中应用的连接配置为:
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/product_app
SPRING_DATASOURCE_USERNAME: product_app
SPRING_DATASOURCE_PASSWORD: local-app-only这里的密码是随工程提供的本地演示值。正式凭据通过部署环境的密钥机制注入,不复制演示值,也不提交到源码。IDE 启动同样需要数据源配置;当前数据库未开放宿主端口,直接在宿主运行启动类不能连接 db:5432。可以继续用 Compose 运行应用,或为本机开发单独添加回环数据库端口映射,再把 JDBC 地址改成宿主可访问的地址。
db/init.sql 创建实验数据库,应用的 schema.sql 建立商品与操作记录表。Boot 通过 spring.sql.init.mode=always 对 PostgreSQL 启用脚本初始化;CREATE TABLE IF NOT EXISTS 只帮助首次建表,不会迁移已有表的列和约束。持续演进的项目应选用 Flyway 或 Liquibase 管理变更,避免同时让多套初始化机制维护同一套 Schema。数据库初始化
等待日志出现 Started ProductApplication 后,查询一个尚无数据的列表:
API=http://127.0.0.1:18088
curl -q --noproxy '*' --fail-with-body -sS \
"$API/products?page=1&size=20"响应应为 JSON,记录列表为空、总数为零。连接拒绝先查看 docker compose ps 和应用日志,确认数据库连接与应用启动是否成功;端口占用则修改 Compose 发布端口,并同步修改 API。依赖下载失败时检查 Maven 仓库连接或企业镜像配置,不能通过跳过测试来修复下载问题。
从请求校验到一次完整写入
请求 DTO 限定客户端输入
创建请求只接收三个业务字段:
{"sku":"KB-001","name":"机械键盘","price":299.00}ProductRequests.Create 把输入约束放在字段旁边:
public record Create(
@NotBlank @Pattern(regexp = "[A-Z0-9-]{1,32}") String sku,
@NotBlank @Size(max = 80) String name,
@NotNull @Positive @Digits(integer = 10, fraction = 2) BigDecimal price) {
}约束注解来自 jakarta.validation.constraints,金额类型来自 java.math.BigDecimal。@NotNull 声明必填,@Positive 要求大于零,@Digits 限制整数位和小数位。修改 DTO 另外接收 version,不接收 id 或 sku。
Controller 参数上的 @Valid @RequestBody 将 JSON 转成请求对象,并触发对象约束检查。分页参数上的 @Min、@Max 则属于方法参数校验。两种失败可能分别产生 MethodArgumentNotValidException 和 HandlerMethodValidationException,错误处理需要覆盖实际使用的签名。Spring MVC 校验
JSON 解析失败、价格小数位不合法和 SKU 已存在发生在不同阶段。前两项可以在写库前拒绝;SKU 唯一冲突必须处理数据库写入结果。对同一 SKU 并发创建时,即使两个请求都通过格式校验,也最多只能成功插入一条。
持久化对象不直接充当请求对象。否则,增加一个内部列后,它可能同时变成客户端可提交的字段。响应也转换成 ProductResponse,只公开接口需要的字段;数据库映射调整不必直接改变客户端契约。
ProductController 中创建方法的职责如下:
@PostMapping
public ResponseEntity<ProductResponse> create(
@Valid @RequestBody ProductRequests.Create request) {
var product = service.create(request);
return ResponseEntity.created(URI.create("/products/" + product.id()))
.body(product);
}类上的 @RequestMapping("/products") 与 @PostMapping 共同构成创建路由。业务调用成功后,ResponseEntity.created 设置 201 和新资源地址;调用失败则交给异常处理器,不能无论成功失败都返回 200。
Lombok 减少重复代码,职责仍在类本身
商品映射对象使用 @Getter、@Setter 生成访问器:
@Getter
@Setter
@TableName("products")
public class Product {
@TableId(type = IdType.AUTO)
private Long id;
private String sku;
private String name;
private BigDecimal price;
@Version
private Integer version;
}@TableName 指定表,IdType.AUTO 选择数据库自增主键,@Version 配合乐观锁插件参与条件更新。它们来自 MyBatis-Plus;@Getter、@Setter 来自 Lombok。对于 price 字段,生成的访问器只读取字段或给字段赋值,价格是否合法仍由输入与数据约束处理。完整访问级别与生成规则可查 Getter/Setter 文档。
Service 的 Mapper 依赖声明为未初始化的 final 字段,@RequiredArgsConstructor 生成构造器。例如:
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductMapper products;
private final ProductEventMapper events;
}它生成的核心部分相当于:
public ProductService(ProductMapper products, ProductEventMapper events) {
this.products = products;
this.events = events;
}@Service 使位于扫描范围内的类注册为 Spring 组件。Spring 负责创建实例并提供依赖,Lombok 在编译阶段生成构造器。没有使用 Lombok 时,显式写出该构造器即可。其他构造器注解及参数见官方构造器文档。
选择注解应对应实际需要:映射对象需要访问器,服务需要构造注入,请求和响应可以使用 Java record。@Data 还会组合相等比较、字符串表示等行为,采用前应确认这些生成规则适合当前类型。Data 组合注解
Mapper 与事务各自完成什么
MyBatis-Plus 根据实体映射信息提供通用数据访问方法:
@Mapper
public interface ProductMapper extends BaseMapper<Product> {
}@Mapper 来自 MyBatis,BaseMapper 来自 MyBatis-Plus。实体上的表名、主键策略和字段映射决定这些方法操作哪张表;接口方法由框架提供实现,不需要编写一个空的 ProductMapperImpl。普通查询可以使用通用方法,复杂 SQL 仍可定义在 Mapper 注解或 XML 中。持久层接口
创建商品时,ProductService.create 连续完成两次写入:
@Transactional
public ProductResponse create(ProductRequests.Create request) {
var product = new Product();
product.setSku(request.sku());
product.setName(request.name());
product.setPrice(request.price());
product.setVersion(0);
products.insert(product);
recordEvent(product.getId(), "CREATED");
return ProductResponse.from(product);
}商品的自增主键由数据库产生,插入后回填到 product.id,因此后续记录操作时可以使用同一个 ID。recordEvent 构造 ProductEvent 并调用操作记录 Mapper 的 insert。两次写入使用同一数据源,在当前事务中一起提交。
Controller 调用 Spring 管理的 ProductService
└─ 事务代理开启事务
├─ INSERT products:取得商品 ID
├─ INSERT product_events:保存 CREATED
├─ 正常返回:代理提交,Controller 组织 201 响应
└─ 运行时异常离开方法:代理回滚,异常处理器组织错误响应@Transactional 放在用例入口,是因为商品和操作记录需要作为一次操作提交。给每个 Mapper 调用各开一个独立事务,会允许商品已经提交而记录失败。代理方式下,外部调用 Spring 管理的 Service 才经过事务拦截;类内直接自调用与 checked exception 的默认回滚行为需要单独判断。事务注解语义
发出创建请求并核对结果
在前面定义了 API 的宿主 Bash 中执行。响应文件放入本次新建的临时目录,商品 ID 从实际响应读取:
RUN_DIR=$(mktemp -d)
curl -q --noproxy '*' --fail-with-body -sS \
-D "$RUN_DIR/create.headers" -o "$RUN_DIR/product.json" \
-H 'Content-Type: application/json' \
--data '{"sku":"KB-001","name":"机械键盘","price":299.00}' \
"$API/products"
head -n 1 "$RUN_DIR/create.headers"
jq . "$RUN_DIR/product.json"
PRODUCT_ID=$(jq -er '.id' "$RUN_DIR/product.json")
VERSION=$(jq -er '.version' "$RUN_DIR/product.json")状态行应为 201,响应包含数据库生成的 id、提交的三个字段和初始 version: 0,Location 指向 /products/{id}。数据库序列可能因先前操作或失败插入产生间隔,不要求 ID 恰好为 1。
直接查看演示数据库,确认商品和记录同时存在:
docker compose exec -T db psql -U postgres -d product_app \
-c "SELECT id, sku, name, price, version FROM products WHERE id=$PRODUCT_ID;"
docker compose exec -T db psql -U postgres -d product_app \
-c "SELECT product_id, operation FROM product_events WHERE product_id=$PRODUCT_ID ORDER BY id;"应查到价格 299.00、版本 0 和一条 CREATED。这里通过数据库容器内的管理员连接检查结果;Java 应用实际使用独立的 product_app 账号。重复执行相同 SKU 的创建命令会得到 409,需要查看已有商品或为新的实验选择另一个 SKU。
查询、分页与修改已有数据
查询返回接口需要的数据
单条查询调用 products.selectById(id)。返回 null 时转成“商品不存在”,由 HTTP 层响应 404;找到记录后转换为 ProductResponse。分页查询使用同样的转换,避免直接把 MyBatis-Plus 的分页内部对象当作公共接口。
ProductService.list 的主体为:
var query = Wrappers.<Product>lambdaQuery()
.like(StringUtils.hasText(name), Product::getName, name)
.orderByAsc(Product::getId);
var result = products.selectPage(new Page<Product>(page, size), query);
return new PageResponse<>(
result.getRecords().stream().map(ProductResponse::from).toList(),
result.getCurrent(), result.getSize(), result.getTotal());名称有实际内容时才加入 LIKE 条件;Product::getName 指向实体属性,值通过参数绑定传入。请求不能提供任意 SQL 字段或排序片段。LIKE 中的 %、_ 仍有通配含义,参数绑定不会自动将它们变成普通字符;如果产品要求按字面搜索这些字符,应明确转义策略。条件构造器
分页需要在带有 @Configuration 注解的 MybatisConfig 类中注册插件;配置类同样放在启动类的扫描范围内:
@Bean
MybatisPlusInterceptor mybatisPlusInterceptor() {
var interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
var pagination = new PaginationInnerInterceptor(DbType.POSTGRE_SQL);
pagination.setMaxLimit(100L);
pagination.setOverflow(false);
interceptor.addInnerInterceptor(pagination);
return interceptor;
}分页拦截器放在插件链最后。它结合页码、页大小和数据库类型生成分页 SQL,并按配置执行总数查询;mybatis-plus-jsqlparser 是当前版本所需的独立依赖。分页插件
接口将页码限制为 1–1000000、页大小限制为 1–100。超过请求约定直接返回 400;合法但超过总页数的请求返回空列表,不自动跳回首页。按唯一 ID 排序能消除同值排序的不确定性,但不同请求之间若有新增或删除,偏移分页仍可能发生记录移动。大数据量和连续翻页场景可进一步使用游标分页。
curl -q --noproxy '*' --fail-with-body -sS "$API/products/$PRODUCT_ID"
curl -q --noproxy '*' --fail-with-body -sS --get \
--data-urlencode 'name=键盘' \
--data-urlencode 'page=1' --data-urlencode 'size=20' \
"$API/products"第二个响应的形态如下;ID 以实际返回为准:
items:包含机械键盘的商品数组
page:1
size:20
total:匹配名称条件的商品总数修改要携带读到的版本
PUT /products/{id} 修改名称和价格。请求中的 version 来自上一次读取,Service 先确认商品存在,再构造更新:
var product = requireProduct(id);
product.setName(request.name());
product.setPrice(request.price());
product.setVersion(request.version());
if (products.updateById(product) != 1) {
// 抛出业务冲突异常,由 HTTP 层映射为 409
throw new ConcurrentProductChangeException();
}
recordEvent(id, "UPDATED");
return ProductResponse.from(product);MyBatis-Plus 的乐观锁插件读取实体的旧版本,在更新时加入版本条件,并计算下一版本。决定是否更新成功的是数据库受影响行数,不能只看代码执行到了 updateById。乐观锁插件
-- 关键条件形态;实际生成语句还包含当前实体的其他更新列
UPDATE products
SET name = ?, price = ?, version = ?
WHERE id = ? AND version = ?;两个编辑者都读到版本 0 时,第一个请求更新成功,将版本变为 1;第二个请求仍带版本 0,无法匹配更新条件。Service 抛出冲突异常,不写入 UPDATED 记录。若商品在首次查询前就不存在,返回 404;首次查询后才被修改或删除,则按更新失败返回 409。
成功路径可以直接使用前面保存的版本:
UPDATE_JSON=$(jq -nc --argjson version "$VERSION" \
'{name:"机械键盘 Pro",price:329.00,version:$version}')
curl -q --noproxy '*' --fail-with-body -sS \
-X PUT -H 'Content-Type: application/json' \
--data "$UPDATE_JSON" "$API/products/$PRODUCT_ID" \
-o "$RUN_DIR/updated.json"
jq . "$RUN_DIR/updated.json"响应应为修改后的名称、价格和 version: 1。UPDATE_JSON 仍保存旧版本,正好可以验证重复提交:
STATUS=$(curl -q --noproxy '*' -sS \
-X PUT -H 'Content-Type: application/json' \
--data "$UPDATE_JSON" "$API/products/$PRODUCT_ID" \
-o "$RUN_DIR/conflict.json" -w '%{http_code}')
test "$?" -eq 0 && test "$STATUS" = 409
jq . "$RUN_DIR/conflict.json"这里预期 HTTP 错误,所以没有使用 --fail-with-body;仍检查了 curl 传输是否成功以及状态码是否恰好为 409。处理冲突时先重新读取商品,让调用方比较新旧值,不能自动换成最新版本后覆盖别人的修改。
删除的结果与历史记录
删除方法在事务中调用 deleteById,影响一行才记录 DELETED 并返回 204;零行转成 404。此接口执行物理删除,没有使用逻辑删除插件,也没有定义携带版本的条件删除。
curl -q --noproxy '*' --fail-with-body -sS -i \
-X DELETE "$API/products/$PRODUCT_ID"
STATUS=$(curl -q --noproxy '*' -sS "$API/products/$PRODUCT_ID" \
-o "$RUN_DIR/missing.json" -w '%{http_code}')
test "$?" -eq 0 && test "$STATUS" = 404第一次响应为 204,随后查询得到 404。数据库中的商品被删除,操作记录保留 CREATED、UPDATED、DELETED;前面被拒绝的旧版本请求没有新增一条更新记录。
错误处理、测试与后续扩展
在 HTTP 层转换错误
ApiExceptionHandler 使用 @RestControllerAdvice,继承 ResponseEntityExceptionHandler 处理 MVC 自身的异常,再添加业务异常和数据库约束的映射。Spring 错误响应
| 失败 | HTTP 结果 | 处理方式 |
|---|---|---|
| JSON 无法解析、未知字段、尾随内容、字段或参数约束失败 | 400 | 修改请求,不执行写入 |
| 商品不存在 | 404 | 核对 ID 或刷新列表 |
uk_products_sku 唯一约束冲突 | 409 | 查看已有商品或使用其他 SKU |
| 版本条件未匹配 | 409 | 重新读取后比较冲突,再决定是否提交 |
| 未预期的数据库或程序异常 | 500 | 服务端记录原因,客户端不接收 SQL、堆栈或凭据 |
未知字段和尾随内容的拒绝由工程中显式的 Jackson 配置启用,不依赖默认设置。内部错误统一返回“服务内部错误”,并保留服务端日志。数据库异常中只有 SQLState 23505 且约束名为 uk_products_sku 时才解释为 SKU 冲突;其他约束失败不能都转换成“商品已存在”。
业务异常的 HTTP 转换可以保持很短,例如:
@ExceptionHandler(ProductNotFoundException.class)
ResponseEntity<ProblemDetail> notFound(ProductNotFoundException ex,
WebRequest request) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(problem(HttpStatus.NOT_FOUND, ex.getMessage(), request));
}problem 是工程内的辅助方法,创建 ProblemDetail 并填入当前请求路径。响应的 status 与真实 HTTP 状态一致,detail 用于说明当前问题,instance 标识请求位置。客户端根据状态和结构处理错误,不从任意异常字符串里猜业务结果。
负价格请求可以验证输入失败:
STATUS=$(curl -q --noproxy '*' -sS \
-H 'Content-Type: application/json' \
--data '{"sku":"BAD-001","name":"错误价格","price":-1}' \
"$API/products" -o "$RUN_DIR/invalid.json" -w '%{http_code}')
test "$?" -eq 0 && test "$STATUS" = 400
jq . "$RUN_DIR/invalid.json"
docker compose exec -T db psql -U postgres -d product_app \
-c "SELECT count(*) FROM products WHERE sku='BAD-001';"应得到 400、问题说明和数据库计数 0。应用返回了错误之后,还要关注是否已经产生部分写入;这正是事务测试需要检查数据库结果的原因。
用真实 HTTP 和数据库检查事务
ProductApiTest 使用 @SpringBootTest(webEnvironment = RANDOM_PORT) 启动真实应用,Java HttpClient 请求实际端口,JdbcTemplate 查询独立的测试库。测试没有在方法外层加 @Transactional,因此观察到的是应用事务提交或回滚后的状态,而不是测试结束时统一回滚制造的空表。
测试覆盖正常创建、筛选分页、修改删除、输入错误、重复 SKU、旧版本更新和资源不存在。回滚测试在 product_test 中临时安装一个拒绝操作记录 INSERT 的触发器,稳定地让第二次写入失败;触发器仅由测试代码创建和清理,业务接口没有“强制失败”参数。
例如,更新失败后的关键断言为:
long id = create("BOOK-1", "Book");
rejectEventInserts();
assertProblem(send("PUT", "/products/" + id,
"{\"name\":\"Changed\",\"price\":99.90,\"version\":0}"), 500);
var current = body(send("GET", "/products/" + id, null));
assertThat(current).containsEntry("name", "Book");
assertThat(number(current, "version")).isZero();
assertThat(count("product_events")).isEqualTo(1);这些辅助方法均在测试类中:send 发送 HTTP,body 解析 JSON,count 查询测试表。断言分别检查名称未改变、版本未推进、操作记录仍只有创建时的一条。若删掉 Service 的事务,第一条 UPDATE 可能单独提交,名称和版本断言就会发现问题;只检查 HTTP 500 无法发现这种半完成状态。
修改代码后,在同一工程目录重新执行测试和镜像构建:
docker compose run --rm --user "$(id -u):$(id -g)" verify
docker compose up -d --build app默认 Maven 镜像使用 Java 17。检查 Java 25 下的编译与运行兼容性,可以只改变构建镜像再次执行:
MAVEN_IMAGE=maven:3.9.12-eclipse-temurin-25 \
docker compose run --rm --user "$(id -u):$(id -g)" verify测试报告位于 target/surefire-reports。默认 Dockerfile 使用 Java 25 运行产物;编译目标仍为 17。失败时先读取具体失败断言或最深异常,再区分依赖、数据库、请求契约和业务结果;不要先关闭校验、插件或测试来换取构建成功。Boot 测试与打包进一步解释不同测试装配和最终制品验证。
常见接入问题从哪里查
| 现象 | 首先检查 | 修复后验证 |
|---|---|---|
| 编译找不到访问器或构造器 | pom.xml 的 Lombok 依赖和处理器、实际 JDK | 用同一 verify 命令编译,不只看 IDE 是否消除红线 |
| Mapper 无法注入或找不到映射 | 启动类扫描包、@Mapper、Boot4 Starter 是否被重复依赖干扰 | 查看启动异常中的 Mapper 类型,修正后查询商品 |
| 无法连接数据库 | docker compose ps、应用日志、数据源环境变量 | 使用容器服务名 db 和正确数据库、账号重启 |
| 表已存在但新增列缺失 | 既有数据库 Schema 与初始化脚本的差异 | 对真实数据库执行经过测试的迁移;不通过删生产库重建 |
| 分页返回过多记录 | MybatisConfig 是否生效、parser 依赖、页大小限制 | 请求 size=1 并检查返回条数与 total |
| 旧版本仍能覆盖修改 | @Version、乐观锁插件、更新是否检查影响行数 | 先成功修改一次,再以旧版本重试,应得到 409 |
| 错误响应后商品仍被修改 | Service 是否由 Spring 管理、调用是否经过事务代理 | 运行第二次写入失败的测试并检查商品与操作记录 |
只查看本次实验的日志和数据库连接状态:
docker compose logs --tail=100 app
docker compose exec -T db pg_isready -h 127.0.0.1 -U postgres -d postgres数据库接受连接后,仍要通过商品接口确认应用使用的账号和 Schema 正常。实验结束执行 docker compose down 停止并删除当前 Compose 项目的容器与网络,命名卷中的数据保留。源码、Maven 缓存、JAR 和测试报告也保留;不需要清空 Docker 的其他镜像或卷。
业务规则增长后,怎样引入领域模型
商品资料维护的规则较少,短小 Service 加持久化映射就能清楚表达。若增加改价审批、生效条件、上下架状态,以及被其他业务引用后允许执行的动作,需要先与业务人员统一这些概念及规则,再决定哪些对象必须一起保持有效。
例如,“已发布价格不能直接覆盖,必须提交并批准一份改价申请”同时涉及申请状态、审核动作和生效价格。可以把允许的状态变化放入领域对象,让应用服务负责加载对象、调用业务方法和保存结果。这样,HTTP、任务和消息入口能够执行同一组业务规则。具体的实体、值对象与 DTO,以及应用服务、领域服务与仓储,分别承担不同职责。
DDD 强调围绕业务领域建模;Spring MVC 仍可以作为它的 HTTP 入口,MyBatis-Plus 则可以放在持久化实现中。一个带访问器和表映射的 Product 只说明数据库记录的结构,不能仅凭它位于 domain 包就认为已经完成领域建模。是否引入聚合和仓储接口,要看需要保护什么规则、哪些对象一起变化,以及变化是否已有清楚的业务含义。领域驱动设计 改价申请如何据此形成聚合、批准动作和持久化事务,见 DDD 业务建模与 Spring Boot 实现。
权威资料与规范地址
工程创建、构建与数据初始化
| 资料 | 完整地址 |
|---|---|
| Spring Initializr | https://start.spring.io/ |
| Spring Boot 首个应用 | https://docs.spring.io/spring-boot/tutorial/first-application/index.html |
| Boot 可执行归档打包 | https://docs.spring.io/spring-boot/maven-plugin/packaging.html |
| 数据库初始化 | https://docs.spring.io/spring-boot/how-to/data-initialization.html |
