GitHub仓库 :github.com/2530622506/...
01|(前端转全栈)前端人第一次打开 Spring Boot 项目,应该先看哪里?
02|(前端转全栈)从 pnpm dev 到 Spring Boot 启动:后端服务到底怎么跑起来?
03|(前端转全栈)Axios 请求进了后端之后:Controller、Request、Response 是怎么接住它的?
04|(前端转全栈)前端状态为什么不够用?从页面数据到 MySQL 持久化
05|(前端转全栈)不手写一堆 SQL,后端怎么操作数据库?MyBatis-Plus 入门
06|(前端转全栈)登录后端到底在做什么?JWT、Spring Security 和权限链路
07|(前端转全栈)为什么后端也要缓存?从前端缓存思维理解 Redis
08|(前端转全栈)一个商品详情接口背后的完整链路:HTTP、Redis、MySQL 与 JSON
09|(前端转全栈)商品为什么不能随便上下架?后端状态机思维入门
10|(前端转全栈)库存扣减为什么最容易出事故?SKU、并发与原子更新
11|(前端转全栈)购物车不能只存在前端:用户维度数据如何在后端落库
面向读者:你已经读完前面的商品、SKU、库存、购物车章节,知道本项目以 Spring Boot、MyBatis-Plus、MySQL、JWT 为核心。你现在要理解一个真正的后端交易问题:前端点击"提交订单"之后,后端为什么不能只是简单地把购物车数据复制到订单表?为什么一定要重新校验价格和库存?为什么要用事务?为什么要有
available_stock和locked_stock两个库存字段?重要说明:本文中的 Vue / TypeScript 代码只作为"前端侧示意代码",用于帮助前端同学类比理解后端接口,不代表当前仓库中存在真实前端源码。
1. 这篇解决什么问题
前端页面里的"提交订单"通常只是一个按钮:用户勾选购物车商品,点击按钮,前端发一个请求,页面跳转到订单详情或支付页。站在前端视角,这个流程像是一次普通的 POST 请求;站在后端视角,它却是整个电商系统最容易出错、也最需要严谨设计的链路之一。因为创建订单不只是新增一行数据,它同时牵涉购物车、商品、SKU、库存、订单主表、订单明细、支付过期时间、幂等键和用户隔离。
本项目的订单创建入口是 POST /api/orders。它不是让前端提交商品标题、价格、总金额、用户 ID,也不是让前端提交一整个购物车数组;它只依赖当前登录用户已经勾选的购物车行,并要求前端通过请求头传入 Idempotency-Key。后端会自己从 JWT 中拿到当前用户,查询该用户已勾选购物车,批量读取 SKU 和商品,重新判断是否可售,重新计算订单项金额和总金额,保存订单主表与订单项快照,调用库存 SQL 把 available_stock 转为 locked_stock,最后删除本次已结算的购物车项。所有这些步骤必须在同一个数据库事务里完成,任意一步失败都要整体回滚。
为什么这么复杂?因为交易系统不能接受"半成功"。如果订单主表已经写入,但库存没有锁定,用户可能付了钱却发现没货;如果库存锁定了,但订单项保存失败,库存会被白白占住;如果订单保存成功但购物车没有删除,用户刷新后可能重复下单;如果购物车删除成功但订单创建失败,用户会丢失购物车数据。后端事务的意义,就是让这组跨表修改要么全部成功,要么全部失败。
本篇会沿着当前工程真实代码讲清楚以下问题:
- 订单创建请求为什么只传
Idempotency-Key,而不是传价格和用户 ID; OrderController、OrderFacade、OrderCreateTransactionService各自负责什么;@Transactional到底保护了哪些数据库修改;mall_order和mall_order_item为什么要保存快照;available_stock、locked_stock和lockStockSQL 如何配合;- 幂等键如何避免前端重复点击、网络重试导致重复下单;
- 如何用 curl、测试和数据库字段验证订单创建是否正确;
- 前端工程师在转后端时最容易误解的订单一致性问题。
学完这篇,你应该能用自己的话解释:"为什么订单金额不能相信前端传参""为什么加入购物车不锁库存但创建订单要锁库存""为什么事务回滚后订单、订单项、库存、购物车都应该恢复原状"。
2. 用前端知识类比
2.1 从前端事件处理看订单创建
前端同学最熟悉的场景是按钮点击:
ts
// 前端侧示意代码:只用于解释交互,不代表仓库里存在真实前端源码。
async function submitOrder() {
if (submitting.value) return
submitting.value = true
try {
const res = await request.post('/api/orders', null, {
headers: {
'Idempotency-Key': createOrReuseIdempotencyKey(),
},
})
router.push(`/orders/${res.data.id}`)
} finally {
submitting.value = false
}
}
前端会做防重复点击,但后端不能只依赖前端按钮禁用。原因很简单:用户可能刷新页面,浏览器可能重发请求,移动端网络可能超时后自动重试,恶意用户也可以绕过页面直接用 curl 调接口。前端防抖只是用户体验优化,后端幂等才是业务安全保证。本项目使用 Idempotency-Key 请求头解决这个问题:同一个用户、同一个幂等键重复请求,应该返回同一个订单,而不是再次锁库存、再次生成订单。
你可以把 Idempotency-Key 类比成前端发请求时给一次业务操作生成的唯一 requestId。区别是:前端的 requestId 通常只存在浏览器内存里,而后端会把幂等键保存到 mall_order.idempotency_key,并通过数据库唯一索引 uk_mall_order_user_idempotency (user_id, idempotency_key) 让这个规则在并发下仍然成立。
2.2 从前端状态提交看服务端可信数据
在前端页面里,购物车状态可能长这样:
ts
// 前端侧示意代码:购物车页面本地状态,不代表后端会相信这些字段。
const checkedItems = [
{ skuId: 1, title: '九成新 iPhone 15', price: 4599, quantity: 2, checked: true },
]
const estimatedTotal = 9198
如果你刚开始写后端,可能会想:前端把 checkedItems 和 estimatedTotal 提交给后端,后端直接保存成订单不就行了吗?这正是交易系统的大坑。前端数据是不可信的:价格可以被篡改,商品标题可能过期,库存可能已经被别人抢走,用户 ID 也不能由请求体决定。后端必须把前端提交的数据视为"用户意图",而不是"交易事实"。真正的交易事实必须来自服务端数据库。
所以本项目的创建订单接口非常克制:OrderController.createOrder 只从请求头读取 Idempotency-Key,没有 @RequestBody。后端根据当前登录用户查询自己的已勾选购物车,重新读取 SKU 和商品,重新计算金额。这一点和前端表单校验也有类比:前端可以校验手机号格式、必填字段、金额显示,但后端仍要再校验一遍,因为只有后端校验才具备安全意义。
2.3 从前端状态快照理解订单快照
前端状态管理里有一个常见概念叫"快照":你在某一刻把数据复制出来,之后源数据变化,不应该影响这份历史记录。订单也是这样。商品下单时叫"九成新 iPhone 15",成交价是 4599.00;订单创建后,商家把商品改名、改价,历史订单也不能跟着变。否则用户支付、售后、对账都会混乱。
本项目的 mall_order_item 表保存了 product_title、sku_code、spec_text、unit_price、quantity、line_amount。这些字段不是冗余错误,而是订单快照。它们表达的是"下单那一刻的交易事实"。测试 orderQueryUsesSnapshotInsteadOfCurrentProductData 也专门验证了这一点:订单创建后再修改商品标题和 SKU 售价,查询订单仍然返回旧的订单项快照。
3. 后端核心概念讲解
3.1 事务:把多步数据库修改合成一个原子操作
事务可以先用一个前端类比理解:你在前端执行一组状态更新,如果中间失败,最好把状态恢复到更新前,否则页面会进入一半新、一半旧的异常状态。后端事务也是类似思想,但严肃得多,因为它操作的是数据库持久化数据。订单创建包含以下修改:
- 写入
mall_order订单主表; - 把多个 SKU 的
available_stock减少,把locked_stock增加; - 写入
mall_order_item订单项快照; - 删除当前用户本次已勾选购物车项。
这四步任何一步失败,都应该回到最初状态。本项目把它们放在 OrderCreateTransactionService.createOrder 方法中,并加上 @Transactional(rollbackFor = Exception.class)。这意味着方法内部抛出异常时,Spring 会让当前数据库事务回滚。测试 OrderTransactionRollbackTest.rollsBackOrderAndStockWhenSavingOrderItemsFails 专门模拟订单项保存失败,并断言订单表为 0、订单项表为 0、购物车仍在、available_stock 恢复、locked_stock 仍为 0。
3.2 库存锁定:下单占住库存,支付后确认售出
第 10 篇讲过库存并发,本篇进入交易语义。mall_product_sku 表有两个库存字段:
available_stock:当前还能卖给新订单的库存;locked_stock:已经被待支付订单占住,但还没有支付成功的库存。
创建订单时,本项目调用 SkuDbService.lockStock,底层 SQL 是一条原子更新:
sql
UPDATE mall_product_sku
SET available_stock = available_stock - #{quantity},
locked_stock = locked_stock + #{quantity},
version = version + 1,
updated_at = CURRENT_TIMESTAMP(3)
WHERE id = #{skuId}
AND available_stock >= #{quantity}
这条 SQL 的关键不只是修改两个字段,而是 WHERE available_stock >= #{quantity}。它让库存检查和库存转移在数据库里一次完成。并发情况下,两个订单同时抢最后 1 件商品,只有一个更新能成功,另一个影响行数为 0,业务层就抛出 ORDER_INSUFFICIENT_STOCK。这比"先查询库存,再在 Java 内存里判断,再更新库存"安全,因为后者在并发下会出现两个请求都看到库存足够的问题。
为什么不在加入购物车时锁库存?因为购物车只是意向,不是承诺。如果用户把商品放进购物车但一周不买,库存一直被占住,对其他用户不公平。真正的库存占用发生在创建待支付订单时,并且会设置 expires_at 支付截止时间。后续支付成功会确认锁定库存,取消或超时关单会释放锁定库存。支付和超时关单会在下一篇详细讲。
3.3 幂等:同一次业务操作重复请求只产生一个结果
前端点击"提交订单"后,最常见的问题是重复请求。可能是用户连点按钮,可能是浏览器超时重试,可能是网关重放,也可能是前端页面没有正确禁用按钮。后端用幂等键解决:同一个用户使用同一个 Idempotency-Key 创建订单,只能得到同一个订单。
本项目在 OrderFacade.createOrder 中先校验幂等键格式:^[A-Za-z0-9._:-]{8,64}$。然后用 orderDbService.getByUserIdAndIdempotencyKey(userId, idempotencyKey) 查是否已有订单。如果已有,直接组装响应返回;如果没有,进入事务创建订单。并发情况下,两个请求可能同时查不到已有订单,于是同时尝试插入。这时数据库唯一索引 uk_mall_order_user_idempotency (user_id, idempotency_key) 会让其中一个插入失败,代码捕获 DataIntegrityViolationException 后再次查询已存在订单并返回。
这就是后端常见的"双层保护":业务层提前查,给正常请求快速返回;数据库唯一约束兜底,保证并发下规则不会被突破。购物车的"同用户同 SKU 不能重复加入"也是同样模式。
3.4 用户隔离:订单永远属于当前登录用户
订单是典型的用户私有资源。查询订单不能只按 orderId,取消订单也不能只按 orderId。本项目的 OrderDbService.requireByIdAndUserId 使用 orderId + userId 查询,OrderFacade.queryMyOrder 和 cancelMyOrder 都先从 CurrentUserService.requireCurrentUserId() 拿当前用户,再带用户条件访问数据库。这样即使用户猜到别人的订单 ID,也只能得到 ORDER_NOT_FOUND。
前端类比是:路由路径 /orders/123 里的 123 只是资源标识,不代表当前用户一定有权限访问。前端可以隐藏别人订单入口,但真正防越权必须由后端 SQL 条件完成。
4. 在本项目中对应哪些文件
| 层次 | 文件 | 作用 |
|---|---|---|
| HTTP 入口 | backend/service/src/main/java/com/example/fullstackmall/service/order/OrderController.java |
定义 POST /api/orders、GET /api/orders/{orderId}、PATCH /api/orders/{orderId}/cancel |
| 对外契约 | backend/contract/src/main/java/com/example/fullstackmall/contract/order/IOrderFacade.java |
声明创建订单、查询我的订单、取消我的订单 |
| 响应 DTO | OrderResponse.java、OrderItemResponse.java |
返回订单主信息与订单项快照 |
| 状态枚举 | OrderStatus.java |
定义 PENDING_PAYMENT、PAID、CANCELLED、CLOSED |
| 用例门面 | OrderFacade.java |
处理当前用户、幂等、响应组装、调用事务服务 |
| 事务服务 | OrderCreateTransactionService.java |
在一个事务内读取购物车、校验、锁库存、写订单、删购物车 |
| 数据访问 | OrderDbService.java、OrderItemDbService.java |
封装订单主表和订单项表查询写入 |
| Entity | OrderEntity.java、OrderItemEntity.java |
映射 mall_order、mall_order_item |
| 库存 SQL | SkuDbService.java、SkuMapper.java |
lockStock 原子锁定库存 |
| 购物车 DB | CartItemDbService.java、CartItemMapper.java |
查询已勾选购物车、删除已结算购物车项 |
| 表结构 | sql/01_schema.sql |
定义订单表、订单项表、库存字段和索引 |
| 种子数据 | sql/02_seed.sql |
小明购物车中有 SKU 1,数量 2,勾选状态为 1 |
| 测试 | OrderControllerTest.java、OrderTransactionRollbackTest.java、OrderMySqlContainerTest.java |
验证创建订单、幂等、快照、并发、回滚 |
这张表就是你读订单模块的地图。前端同学看后端工程时,不要从所有文件里乱跳,应该按"接口入口 → 契约 → Facade → 事务服务 → DbService / Mapper → 表结构 → 测试"的顺序走。这样你会始终知道自己在读哪一层。
5. Mermaid 图:订单创建完整链路
5.1 从点击按钮到数据库事务
5.2 事务边界图

5.3 库存状态流转
5.4 幂等键防重复下单
5.5 订单快照和商品当前数据的区别

6. 逐段读源码
6.1 OrderController:订单 HTTP 入口
OrderController 位于 backend/service/src/main/java/com/example/fullstackmall/service/order/OrderController.java。它的类级路径是 @RequestMapping("/api/orders")。创建订单方法如下:
java
@PostMapping
@Operation(summary = "使用已勾选购物车创建订单")
public ResponseEntity<ApiResponse<OrderResponse>> createOrder(
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey,
HttpServletRequest servletRequest
) {
OrderResponse response = orderFacade.createOrder(idempotencyKey);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success(response, TraceIdContext.get(servletRequest)));
}
这里有几个关键点。第一,创建订单没有 @RequestBody,说明前端不需要提交价格、标题、购物车行数组。第二,幂等键来自请求头 Idempotency-Key,而不是 URL 参数或 JSON 字段。第三,成功时返回 HTTP 201 Created,符合"创建资源"的语义。第四,Controller 不直接操作数据库,只调用 orderFacade.createOrder。这和前端组件不应该直接写大量请求拼装和数据处理逻辑类似:组件负责响应用户事件,复杂业务应该下沉到 service / store。
查询订单方法是 GET /api/orders/{orderId},取消订单方法是 PATCH /api/orders/{orderId}/cancel。它们都没有接收 userId,因为当前用户必须从登录态获取。前端路由里的 orderId 只是要访问的资源编号,不代表权限。
6.2 IOrderFacade:把订单能力定义成契约
IOrderFacade 位于 backend/contract/src/main/java/com/example/fullstackmall/contract/order/IOrderFacade.java。它定义三个方法:
java
OrderResponse createOrder(String idempotencyKey);
OrderResponse queryMyOrder(Long orderId);
OrderResponse cancelMyOrder(Long orderId);
这个接口可以类比前端项目里的 API 类型声明。你不用先关心实现细节,只看契约就知道订单模块对外提供哪些能力。注意命名里的 My:queryMyOrder、cancelMyOrder 都强调"当前用户自己的订单"。这不是语法装饰,而是安全边界。后端方法名应该尽量把业务语义说清楚,避免写成模糊的 queryOrder、cancelOrder 之后忘记用户隔离。
6.3 OrderFacade.createOrder:身份、幂等和事务入口
OrderFacade 位于 backend/service/src/main/java/com/example/fullstackmall/service/order/OrderFacade.java。创建订单入口的核心逻辑是:
java
Long userId = currentUserService.requireCurrentUserId();
requireValidIdempotencyKey(idempotencyKey);
OrderEntity existingOrder = orderDbService.getByUserIdAndIdempotencyKey(userId, idempotencyKey);
if (existingOrder != null) {
return assembleOrder(existingOrder);
}
try {
Long orderId = orderCreateTransactionService.createOrder(userId, idempotencyKey);
return assembleOrder(orderDbService.requireByIdAndUserId(orderId, userId));
} catch (DataIntegrityViolationException exception) {
OrderEntity concurrentOrder = orderDbService.getByUserIdAndIdempotencyKey(userId, idempotencyKey);
if (concurrentOrder != null) {
return assembleOrder(concurrentOrder);
}
throw exception;
}
这段代码体现了 Facade 层的职责:它不亲自写订单和锁库存,而是做事务外的业务入口控制。它先拿当前用户,再校验幂等键,再检查同用户同幂等键是否已经存在订单。存在就直接返回,避免重复创建;不存在才调用事务服务。
为什么幂等查询放在事务外?从职责上看,OrderFacade 管"这次请求是不是重复请求",OrderCreateTransactionService 管"真正创建订单的一组数据库操作是否原子"。这是一种清晰分层。即便并发下两个请求同时查不到订单,数据库唯一索引仍然会兜底,catch DataIntegrityViolationException 后再次查询胜出的订单并返回。前端同学可以把它理解成:UI 层做防重复点击,业务层还要做幂等,数据库层再用唯一约束做最终保险。
6.4 幂等键格式校验
OrderFacade 里有一个正则:
java
private static final Pattern IDEMPOTENCY_KEY_PATTERN =
Pattern.compile("^[A-Za-z0-9._:-]{8,64}$");
这表示幂等键长度必须在 8 到 64 之间,只能包含字母、数字、点、下划线、冒号、短横线。太短的 key 容易碰撞,太长的 key 不适合入库,包含奇怪字符会增加日志、网关、数据库存储的处理复杂度。测试里也验证了无效幂等键会返回 ORDER_IDEMPOTENCY_KEY_INVALID。
前端侧示意代码可以这样生成并复用幂等键:
ts
// 前端侧示意代码:同一次提交操作应该复用同一个 key,成功或明确失败后再清理。
const orderSubmitKey = ref<string | null>(null)
function getOrderSubmitKey() {
if (!orderSubmitKey.value) {
orderSubmitKey.value = `web:${Date.now()}:${crypto.randomUUID()}`
}
return orderSubmitKey.value
}
注意:如果请求超时但前端不知道后端是否成功,不应该立刻换新 key 重试。换新 key 可能创建第二个订单。正确方式是用同一个 key 重试,让后端返回已经创建的订单。
6.5 OrderCreateTransactionService:真正的事务边界
OrderCreateTransactionService 是本篇最重要的类。它的注释已经写得很清楚:创建订单的事务边界,方法内任一步抛异常,订单、库存、订单项和购物车修改都会回滚。方法上有:
java
@Transactional(rollbackFor = Exception.class)
public Long createOrder(Long userId, String idempotencyKey) {
...
}
这意味着你读这个方法时,要把它看成一个整体,而不是几个孤立 SQL。方法开始先查当前用户已勾选购物车:
java
List<CartItemEntity> cartItems = cartItemDbService.listCheckedByUserId(userId)
.stream()
.sorted(Comparator.comparing(CartItemEntity::getSkuId))
.toList();
if (cartItems.isEmpty()) {
throw conflict(ApiCode.ORDER_CART_EMPTY);
}
这里有两个细节。第一,只读取当前用户已勾选购物车项,不读取未勾选项,更不会读取其他用户的购物车。第二,按照 skuId 排序。注释说得很明确:固定库存更新顺序,多个并发订单尽量以相同顺序加锁,降低死锁概率。前端同学可能很少接触死锁,但可以先记住一句话:多个事务如果以不同顺序更新多行数据,就更容易互相等待;固定顺序是一种降低风险的工程习惯。
6.6 批量查询 SKU 和商品,重新校验可售
事务服务随后收集 skuIds,调用 skuDbService.listExistingByIds(skuIds) 批量查询 SKU;再从 SKU 里收集 productIds,调用 productDbService.listExistingByIds(productIds) 批量查询商品。这个模式在第 11 篇购物车查询中也出现过:避免在循环里对每一行购物车分别查 SKU 和商品,减少 N+1 查询问题。
接下来每个购物车行都会进入 buildOrderItem:
java
SkuEntity sku = skuMap.get(cartItem.getSkuId());
ProductEntity product = sku == null ? null : productMap.get(sku.getProductId());
if (sku == null
|| product == null
|| !ProductStatus.ON_SALE.name().equals(product.getStatusCode())
|| sku.getSalePrice() == null
|| sku.getSalePrice().compareTo(BigDecimal.ZERO) <= 0) {
throw conflict(ApiCode.ORDER_ITEM_NOT_SALEABLE);
}
if (sku.getAvailableStock() == null || sku.getAvailableStock() < cartItem.getQuantity()) {
throw conflict(ApiCode.ORDER_INSUFFICIENT_STOCK);
}
这一步再次说明:购物车可结算不等于订单一定能创建。商品可能在用户打开购物车后被下架,SKU 可能被删除,价格可能异常,库存可能被别人下单占用。订单创建必须重新校验。前端展示 saleable=true 只是减少用户无效操作,不能替代后端校验。
6.7 构造订单项快照和总金额
校验通过后,buildOrderItem 会复制下单时的商品和 SKU 信息:
java
BigDecimal unitPrice = sku.getSalePrice().setScale(MONEY_SCALE, RoundingMode.HALF_UP);
BigDecimal lineAmount = unitPrice
.multiply(BigDecimal.valueOf(cartItem.getQuantity()))
.setScale(MONEY_SCALE, RoundingMode.HALF_UP);
OrderItemEntity item = new OrderItemEntity();
item.setSkuId(sku.getId());
item.setProductId(product.getId());
item.setProductTitle(product.getTitle());
item.setSkuCode(sku.getSkuCode());
item.setSpecText(sku.getSpecText());
item.setUnitPrice(unitPrice);
item.setQuantity(cartItem.getQuantity());
item.setLineAmount(lineAmount);
item.setCreatedAt(now);
注意,unitPrice 来自数据库当前 SKU 价格,不来自前端;lineAmount 由后端计算,不来自前端;productTitle、skuCode、specText 被复制到订单项,不是查询订单时再从商品表动态读取。随后总金额由所有订单项 lineAmount 相加:
java
BigDecimal totalAmount = orderItems.stream()
.map(OrderItemEntity::getLineAmount)
.reduce(zeroMoney(), BigDecimal::add)
.setScale(MONEY_SCALE, RoundingMode.HALF_UP);
金额使用 BigDecimal,而不是 double 或 float。这是 Java 后端处理钱的基本习惯,因为二进制浮点数会有精度误差。前端也会遇到 0.1 + 0.2 !== 0.3 的问题,后端金额更不能随便使用浮点数。
6.8 保存订单主表
创建订单主表对象的代码如下:
java
OrderEntity order = new OrderEntity();
order.setOrderNo("O" + UUID.randomUUID().toString().replace("-", "").toUpperCase());
order.setUserId(userId);
order.setStatusCode(OrderStatus.PENDING_PAYMENT.name());
order.setTotalAmount(totalAmount);
order.setIdempotencyKey(idempotencyKey);
order.setCreatedAt(now);
order.setUpdatedAt(now);
order.setExpiresAt(now.plusMinutes(paymentTimeoutMinutes));
if (!orderDbService.save(order)) {
throw new IllegalStateException("保存订单主表失败");
}
几个字段值得注意。orderNo 是服务端生成的对外订单号,前端不能指定。userId 来自登录态。statusCode 初始为 PENDING_PAYMENT,表示待支付。totalAmount 是后端计算出来的总金额。idempotencyKey 用于重复提交识别。expiresAt 是支付截止时间,默认配置来自 mall.order.payment-timeout-minutes,当前配置默认 15 分钟。
保存主表后,数据库会生成订单 ID,后续订单项需要 orderId 关联它。这里如果保存失败,抛异常,事务回滚,库存还没有锁定,购物车也不会删除。
6.9 锁定库存:available_stock 转入 locked_stock
保存订单主表后,代码遍历订单项锁定库存:
java
for (OrderItemEntity item : orderItems) {
int affectedRows = skuDbService.lockStock(item.getSkuId(), item.getQuantity());
if (affectedRows != 1) {
throw conflict(ApiCode.ORDER_INSUFFICIENT_STOCK);
}
item.setOrderId(order.getId());
}
lockStock 返回影响行数。影响 1 行才表示锁定成功;影响 0 行通常表示库存不足或 SKU 不满足更新条件。这里再次检查库存,是因为前面 buildOrderItem 的库存判断只是基于查询结果,不能抵抗所有并发。真正的并发防线是 UPDATE ... WHERE available_stock >= quantity。
这段逻辑体现了后端库存开发的核心原则:查询用于组装和提前提示,条件更新用于最终保证。前端类比是:你可以在按钮点击前判断表单是否有效,但提交时后端仍要以数据库约束为准。
6.10 保存订单项并删除购物车
库存锁定成功后,代码保存订单项:
java
if (!orderItemDbService.saveBatch(orderItems)) {
throw new IllegalStateException("保存订单项失败");
}
然后删除本次已结算的购物车项:
java
List<Long> cartItemIds = cartItems.stream()
.map(CartItemEntity::getId)
.toList();
int removedRows = cartItemDbService.removeCheckedByUserIdAndIds(userId, cartItemIds);
if (removedRows != cartItems.size()) {
throw conflict(ApiCode.ORDER_CART_CHANGED);
}
删除不是简单按 ID 删除。CartItemMapper.removeCheckedByUserIdAndIds 的 SQL 同时限制 user_id、checked=1 和 id IN (...)。这有三个作用:防止删别人的购物车,防止删未勾选商品,防止并发修改导致数据不一致。如果实际删除行数和原本读取的购物车行数不一致,说明下单过程中购物车发生变化,抛出 ORDER_CART_CHANGED,事务回滚。这样可以避免"订单按旧购物车创建,但购物车已经被用户或其他请求改掉"的混乱。
6.11 OrderEntity 和 OrderItemEntity:表到对象的映射
OrderEntity 映射 mall_order。核心字段包括 id、orderNo、userId、statusCode、totalAmount、idempotencyKey、createdAt、updatedAt、expiresAt、paidAt、cancelledAt、closedAt。这些字段描述一张订单主单的生命周期。
OrderItemEntity 映射 mall_order_item。它保存 orderId、skuId、productId、productTitle、skuCode、specText、unitPrice、quantity、lineAmount、createdAt。你可以把主表和明细表类比成前端订单详情页的数据结构:主表是订单头部信息,明细表是商品列表。区别是数据库要拆成两张表,因为一个订单可以有多个订单项。

6.12 订单响应组装:Entity 不直接暴露给前端
OrderFacade.assembleOrder 会查询订单项并组装 OrderResponse:
java
List<OrderItemResponse> items = orderItemDbService.listByOrderId(order.getId())
.stream()
.map(this::toItemResponse)
.toList();
return OrderResponse.builder()
.id(order.getId())
.orderNo(order.getOrderNo())
.status(OrderStatus.valueOf(order.getStatusCode()))
.totalAmount(order.getTotalAmount())
.items(items)
.createdAt(order.getCreatedAt())
.updatedAt(order.getUpdatedAt())
.expiresAt(order.getExpiresAt())
.paidAt(order.getPaidAt())
.cancelledAt(order.getCancelledAt())
.closedAt(order.getClosedAt())
.build();
这和前面章节反复强调的一样:Entity 是数据库映射对象,Response 是接口返回对象。不要把 Entity 直接暴露给前端。Response 可以隐藏数据库内部字段,可以把字符串状态转成 enum,也可以控制字段顺序和语义。前端只依赖合同层 DTO,不应该依赖数据库表结构。
7. 本地运行 / curl 验证
7.1 启动依赖和服务
如果你使用本项目的本地环境,先启动 MySQL:
bash
docker compose -f docker-compose.dev.yml up -d mysql
然后进入 backend/ 目录编译并启动后端服务:
bash
cd backend
mvn -pl service -am -DskipTests compile
mvn -pl service -am spring-boot:run
如果你的 Maven 命令需要指定本地仓库,可以按你机器上的方式添加 -Dmaven.repo.local=../.m2-repository。本系列重点是读懂后端链路,不强制你必须使用某一种启动命令。
7.2 登录获取 token
种子数据里普通用户 xiaoming 的密码是 Mall123456。先登录:
bash
curl -sS -X POST 'http://localhost:8080/api/auth/login' \
-H 'Content-Type: application/json' \
-d '{"username":"xiaoming","password":"Mall123456"}'
把响应中的 token 保存为环境变量:
bash
TOKEN='把登录响应里的 token 放这里'
7.3 创建订单
小明的种子购物车里有 SKU 1,数量 2,已勾选。创建订单:
bash
curl -i -X POST 'http://localhost:8080/api/orders' \
-H "Authorization: Bearer $TOKEN" \
-H 'Idempotency-Key: blog-order-0001'
期望结果:HTTP 状态为 201 Created,业务 code 为 SUCCESS,data.status 为 PENDING_PAYMENT,data.items 中包含订单项快照。按照种子数据,SKU 1 单价 4599.00,数量 2,所以总金额是 9198.00。
7.4 用同一个幂等键重试
再次执行同一个请求:
bash
curl -i -X POST 'http://localhost:8080/api/orders' \
-H "Authorization: Bearer $TOKEN" \
-H 'Idempotency-Key: blog-order-0001'
期望结果:后端返回同一个订单,不应该再生成新订单,也不应该再次锁库存。你可以通过查询数据库验证 mall_order 只有一条对应记录,available_stock 只减少一次,locked_stock 只增加一次。
7.5 查询订单详情
拿到创建订单响应中的 id 后查询:
bash
ORDER_ID='把订单 id 放这里'
curl -sS "http://localhost:8080/api/orders/$ORDER_ID" \
-H "Authorization: Bearer $TOKEN"
你会看到 OrderResponse:订单号、状态、总金额、订单项、创建时间、支付截止时间等。注意订单项里的标题、规格、价格是快照,不会随着商品当前数据变化。
7.6 常见失败请求
没有登录时:
bash
curl -i -X POST 'http://localhost:8080/api/orders' \
-H 'Idempotency-Key: blog-order-0002'
期望返回 401 Unauthorized。
幂等键太短时:
bash
curl -i -X POST 'http://localhost:8080/api/orders' \
-H "Authorization: Bearer $TOKEN" \
-H 'Idempotency-Key: bad'
期望返回业务错误 ORDER_IDEMPOTENCY_KEY_INVALID。
购物车没有已勾选商品时,期望返回 ORDER_CART_EMPTY。商品下架时,期望返回 ORDER_ITEM_NOT_SALEABLE。库存不足时,期望返回 ORDER_INSUFFICIENT_STOCK。
8. 常见错误
8.1 让前端提交总金额
这是最危险的错误之一。前端传 totalAmount=1,后端如果直接保存,就会把 4599 元商品卖成 1 元。正确做法是:前端可以展示预估金额,但订单创建时后端必须从数据库读取 SKU 当前价格,自己计算订单项金额和订单总金额。
8.2 让前端提交 userId
订单必须属于当前登录用户。前端请求体里的 userId 没有可信度。正确做法是:OrderFacade 调用 CurrentUserService.requireCurrentUserId(),后续查询购物车、保存订单、查询订单都围绕这个 userId 进行。
8.3 创建订单不加事务
如果不用事务,保存订单、锁库存、保存订单项、删购物车之间任何一步失败,都可能留下脏数据。你可能会看到订单存在但没有明细,库存被锁但没有订单,购物车被删但订单失败。这类问题在开发环境可能不明显,一旦上线遇到异常或并发,就会非常难修。
8.4 先查库存再普通更新
伪代码如下:
java
// 错误示意:并发下两个请求可能都看到库存足够。
Sku sku = skuDbService.getById(skuId);
if (sku.getAvailableStock() >= quantity) {
sku.setAvailableStock(sku.getAvailableStock() - quantity);
skuDbService.updateById(sku);
}
正确方式是本项目的条件更新:UPDATE ... WHERE available_stock >= quantity。检查和更新必须在数据库里成为一个原子动作。
8.5 把购物车金额当作订单金额
第 11 篇说过,CartResponse.estimatedTotal 只是购物车页预估金额,只统计已勾选且可售的行。订单创建时不能直接复制这个金额。购物车查询和订单创建之间可能发生商品改价、下架、库存变化,所以订单必须重新校验和计算。
8.6 订单查询动态读商品表
如果订单详情每次都从 mall_product、mall_product_sku 读取当前标题和价格,历史订单会被商品后续变更影响。正确做法是下单时保存订单项快照,查询订单时读取 mall_order_item。
8.7 幂等键每次重试都换新
如果前端超时后换一个新的 Idempotency-Key 重试,后端会把它当成新业务操作,可能创建第二个订单。正确做法是:同一次用户提交操作,在结果不明确时复用同一个幂等键。只有用户重新发起一次新的提交,才生成新 key。
8.8 取消订单和支付回调不考虑库存锁定
本篇重点讲创建订单。你需要先知道:创建订单只是把可售库存转成锁定库存,不代表商品已经售出。下一篇支付回调会讲支付成功如何确认锁定库存,取消和超时关单如何释放锁定库存。如果你在创建订单时直接把库存永久扣掉,取消订单就很难正确恢复。
8.9 忽略测试里的并发和回滚用例
很多后端 bug 不是普通单请求能测出来的。本项目的 OrderMySqlContainerTest 用真实 MySQL 容器验证并发库存锁定和同幂等键并发创建,OrderTransactionRollbackTest 验证异常时整体回滚。这些测试不是为了凑覆盖率,而是在保护交易系统最重要的底线。前端转后端时,一定要学会把测试当成业务文档读。
9. 本章小练习
练习 1:画出订单创建调用链
从 POST /api/orders 开始,按顺序写出经过的类和方法:OrderController.createOrder、OrderFacade.createOrder、OrderCreateTransactionService.createOrder、CartItemDbService.listCheckedByUserId、SkuDbService.lockStock、OrderItemDbService.saveBatch、CartItemDbService.removeCheckedByUserIdAndIds。
练习 2:解释为什么没有 RequestBody
用自己的话说明:创建订单接口为什么不让前端提交商品标题、单价、总金额和 userId?这些数据分别应该从哪里来?
练习 3:读懂 mall_order 唯一索引
找到 sql/01_schema.sql 中的 uk_mall_order_user_idempotency (user_id, idempotency_key),解释它如何支持"同一个用户同一个幂等键只能创建一个订单"。再思考:为什么不是只对 idempotency_key 建全局唯一索引?
练习 4:验证订单快照
阅读 OrderControllerTest.orderQueryUsesSnapshotInsteadOfCurrentProductData。它先创建订单,再修改商品标题和 SKU 价格,然后查询订单。请解释为什么响应仍应返回下单时的旧标题和旧价格。
练习 5:解释库存锁定 SQL
逐字解释 SkuMapper.lockStock:为什么同时更新 available_stock 和 locked_stock?为什么 WHERE 里要有 available_stock >= #{quantity}?为什么业务层要检查影响行数是否等于 1?
练习 6:模拟事务回滚
阅读 OrderTransactionRollbackTest.rollsBackOrderAndStockWhenSavingOrderItemsFails。请列出异常发生后 5 个应该恢复的结果:订单主表、订单项表、购物车、可售库存、锁定库存。
练习 7:设计前端幂等键生命周期
写一段前端侧示意流程:用户进入订单确认页生成 key,点击提交时使用 key,请求超时时复用 key 重试,成功后清理 key,用户返回购物车重新提交时生成新 key。注意标注为"前端侧示意代码"。
练习 8:区分购物车、订单、支付
用 3 句话说明:购物车为什么不锁库存?订单创建为什么锁库存?支付成功为什么还要确认锁定库存?如果你能说清楚这 3 句话,说明你已经理解交易链路的三个阶段。
10. 再深入一点:为什么事务不是万能的
事务能保证同一个数据库里的多步修改原子提交,但它不是所有问题的万能药。首先,事务不能替代业务校验。商品是否上架、价格是否有效、购物车是否为空、库存是否足够,仍然要在业务代码里检查。其次,事务不能替代数据库条件更新。即使整个方法有 @Transactional,如果你用"先查库存再普通更新"的方式,在并发下仍然可能出错。再次,事务不能无限放大。一个事务里做太多远程调用、太多慢查询、太多用户交互,会导致锁持有时间过长,系统吞吐下降。
本项目的做法比较克制:订单创建事务只包住必要的数据库操作,不在事务中等待用户支付,也不在事务中调用第三方支付。创建订单后状态是 PENDING_PAYMENT,库存处于锁定状态,支付流程在后续接口中继续推进。这个设计非常重要:用户支付可能需要几十秒甚至几分钟,不可能让数据库事务一直开着等用户付款。事务只应该保护"创建待支付订单"这个短动作。
前端同学可以类比:你不会在一个同步函数里阻塞等待用户几分钟后再点击另一个按钮;你会把流程拆成多个事件。后端也是一样,创建订单、创建支付流水、支付回调、取消订单、超时关单是不同业务事件,每个事件有自己的事务边界。
11. 排查订单创建问题的顺序
订单创建失败时,不要一上来就改代码。建议按下面顺序排查:

具体排查时可以记住 6 张表 / 字段:mall_cart_item.user_id、mall_cart_item.checked、mall_product.status_code、mall_product_sku.available_stock、mall_product_sku.locked_stock、mall_order.idempotency_key。订单创建的问题,绝大多数都能从这些位置找到原因。
同时要看业务错误码:ORDER_IDEMPOTENCY_KEY_INVALID 指向幂等键格式;ORDER_CART_EMPTY 指向没有已勾选购物车;ORDER_ITEM_NOT_SALEABLE 指向商品或 SKU 当前不可售;ORDER_INSUFFICIENT_STOCK 指向库存不足;ORDER_CART_CHANGED 指向下单过程中购物车被并发修改。错误码就是后端给前端和调试人员的路标。
12. 下一章预告:支付回调、幂等与超时关单
本篇讲到订单创建结束时,订单状态是 PENDING_PAYMENT,库存已经从 available_stock 转入 locked_stock。但交易还没有真正完成。接下来会发生三种可能:用户支付成功,系统收到支付回调,订单进入 PAID,锁定库存被确认售出;用户主动取消订单,订单进入 CANCELLED,锁定库存释放回可售库存;用户一直不支付,超过 expires_at 后定时任务关闭订单,订单进入 CLOSED,锁定库存同样释放。
下一篇会进入支付模块:PaymentController、PaymentCreateTransactionService、PaymentCallbackTransactionService、OrderCloseScheduler、OrderCloseTransactionService。你会看到为什么支付回调必须幂等,为什么外部系统可能重复通知,为什么订单状态只能从待支付进入一个终态,以及为什么取消、支付、关单之间会发生状态竞争。
13. 本篇总结
本篇你需要带走 12 个结论:
- 创建订单不是普通新增数据,而是跨购物车、商品、SKU、库存、订单、订单项的交易动作;
POST /api/orders不接收价格、总金额、商品标题、用户 ID,这些都必须由后端读取或生成;Idempotency-Key用来保证同一次提交重复请求不会重复创建订单;OrderFacade负责当前用户、幂等查询、幂等冲突兜底和响应组装;OrderCreateTransactionService是创建订单的事务边界;@Transactional(rollbackFor = Exception.class)让订单、订单项、库存、购物车修改整体成功或整体回滚;- 订单创建只读取当前用户
checked=1的购物车项; - 订单必须重新校验商品上架、SKU 存在、价格有效和库存充足;
- 订单项保存的是下单时快照,不能依赖商品表当前数据动态展示历史订单;
lockStock用一条条件 UPDATE 把available_stock转入locked_stock,并用影响行数判断是否成功;- 购物车删除必须带
user_id、checked=1和 ID 集合,并检查删除行数; - 事务不能等待用户支付,创建订单、支付回调、取消订单、超时关单应该是不同事务。
如果你能解释"订单主表已经保存后,订单项保存失败为什么订单和库存都会回滚",并能说清楚"同一个幂等键重复请求为什么不会重复锁库存",说明你已经进入真正的后端交易一致性思维。下一篇我们会继续沿着订单往后走,学习支付回调、订单终态、库存释放和超时关单。