12|(前端转全栈)点击提交订单后,后端如何用事务守住价格、库存和订单?

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_stocklocked_stock 两个库存字段?

重要说明:本文中的 Vue / TypeScript 代码只作为"前端侧示意代码",用于帮助前端同学类比理解后端接口,不代表当前仓库中存在真实前端源码。

1. 这篇解决什么问题

前端页面里的"提交订单"通常只是一个按钮:用户勾选购物车商品,点击按钮,前端发一个请求,页面跳转到订单详情或支付页。站在前端视角,这个流程像是一次普通的 POST 请求;站在后端视角,它却是整个电商系统最容易出错、也最需要严谨设计的链路之一。因为创建订单不只是新增一行数据,它同时牵涉购物车、商品、SKU、库存、订单主表、订单明细、支付过期时间、幂等键和用户隔离。

本项目的订单创建入口是 POST /api/orders。它不是让前端提交商品标题、价格、总金额、用户 ID,也不是让前端提交一整个购物车数组;它只依赖当前登录用户已经勾选的购物车行,并要求前端通过请求头传入 Idempotency-Key。后端会自己从 JWT 中拿到当前用户,查询该用户已勾选购物车,批量读取 SKU 和商品,重新判断是否可售,重新计算订单项金额和总金额,保存订单主表与订单项快照,调用库存 SQL 把 available_stock 转为 locked_stock,最后删除本次已结算的购物车项。所有这些步骤必须在同一个数据库事务里完成,任意一步失败都要整体回滚。

为什么这么复杂?因为交易系统不能接受"半成功"。如果订单主表已经写入,但库存没有锁定,用户可能付了钱却发现没货;如果库存锁定了,但订单项保存失败,库存会被白白占住;如果订单保存成功但购物车没有删除,用户刷新后可能重复下单;如果购物车删除成功但订单创建失败,用户会丢失购物车数据。后端事务的意义,就是让这组跨表修改要么全部成功,要么全部失败。

本篇会沿着当前工程真实代码讲清楚以下问题:

  1. 订单创建请求为什么只传 Idempotency-Key,而不是传价格和用户 ID;
  2. OrderControllerOrderFacadeOrderCreateTransactionService 各自负责什么;
  3. @Transactional 到底保护了哪些数据库修改;
  4. mall_ordermall_order_item 为什么要保存快照;
  5. available_stocklocked_stocklockStock SQL 如何配合;
  6. 幂等键如何避免前端重复点击、网络重试导致重复下单;
  7. 如何用 curl、测试和数据库字段验证订单创建是否正确;
  8. 前端工程师在转后端时最容易误解的订单一致性问题。

学完这篇,你应该能用自己的话解释:"为什么订单金额不能相信前端传参""为什么加入购物车不锁库存但创建订单要锁库存""为什么事务回滚后订单、订单项、库存、购物车都应该恢复原状"。

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

如果你刚开始写后端,可能会想:前端把 checkedItemsestimatedTotal 提交给后端,后端直接保存成订单不就行了吗?这正是交易系统的大坑。前端数据是不可信的:价格可以被篡改,商品标题可能过期,库存可能已经被别人抢走,用户 ID 也不能由请求体决定。后端必须把前端提交的数据视为"用户意图",而不是"交易事实"。真正的交易事实必须来自服务端数据库。

所以本项目的创建订单接口非常克制:OrderController.createOrder 只从请求头读取 Idempotency-Key,没有 @RequestBody。后端根据当前登录用户查询自己的已勾选购物车,重新读取 SKU 和商品,重新计算金额。这一点和前端表单校验也有类比:前端可以校验手机号格式、必填字段、金额显示,但后端仍要再校验一遍,因为只有后端校验才具备安全意义。

2.3 从前端状态快照理解订单快照

前端状态管理里有一个常见概念叫"快照":你在某一刻把数据复制出来,之后源数据变化,不应该影响这份历史记录。订单也是这样。商品下单时叫"九成新 iPhone 15",成交价是 4599.00;订单创建后,商家把商品改名、改价,历史订单也不能跟着变。否则用户支付、售后、对账都会混乱。

本项目的 mall_order_item 表保存了 product_titlesku_codespec_textunit_pricequantityline_amount。这些字段不是冗余错误,而是订单快照。它们表达的是"下单那一刻的交易事实"。测试 orderQueryUsesSnapshotInsteadOfCurrentProductData 也专门验证了这一点:订单创建后再修改商品标题和 SKU 售价,查询订单仍然返回旧的订单项快照。

3. 后端核心概念讲解

3.1 事务:把多步数据库修改合成一个原子操作

事务可以先用一个前端类比理解:你在前端执行一组状态更新,如果中间失败,最好把状态恢复到更新前,否则页面会进入一半新、一半旧的异常状态。后端事务也是类似思想,但严肃得多,因为它操作的是数据库持久化数据。订单创建包含以下修改:

  1. 写入 mall_order 订单主表;
  2. 把多个 SKU 的 available_stock 减少,把 locked_stock 增加;
  3. 写入 mall_order_item 订单项快照;
  4. 删除当前用户本次已勾选购物车项。

这四步任何一步失败,都应该回到最初状态。本项目把它们放在 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.queryMyOrdercancelMyOrder 都先从 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/ordersGET /api/orders/{orderId}PATCH /api/orders/{orderId}/cancel
对外契约 backend/contract/src/main/java/com/example/fullstackmall/contract/order/IOrderFacade.java 声明创建订单、查询我的订单、取消我的订单
响应 DTO OrderResponse.javaOrderItemResponse.java 返回订单主信息与订单项快照
状态枚举 OrderStatus.java 定义 PENDING_PAYMENTPAIDCANCELLEDCLOSED
用例门面 OrderFacade.java 处理当前用户、幂等、响应组装、调用事务服务
事务服务 OrderCreateTransactionService.java 在一个事务内读取购物车、校验、锁库存、写订单、删购物车
数据访问 OrderDbService.javaOrderItemDbService.java 封装订单主表和订单项表查询写入
Entity OrderEntity.javaOrderItemEntity.java 映射 mall_ordermall_order_item
库存 SQL SkuDbService.javaSkuMapper.java lockStock 原子锁定库存
购物车 DB CartItemDbService.javaCartItemMapper.java 查询已勾选购物车、删除已结算购物车项
表结构 sql/01_schema.sql 定义订单表、订单项表、库存字段和索引
种子数据 sql/02_seed.sql 小明购物车中有 SKU 1,数量 2,勾选状态为 1
测试 OrderControllerTest.javaOrderTransactionRollbackTest.javaOrderMySqlContainerTest.java 验证创建订单、幂等、快照、并发、回滚

这张表就是你读订单模块的地图。前端同学看后端工程时,不要从所有文件里乱跳,应该按"接口入口 → 契约 → Facade → 事务服务 → DbService / Mapper → 表结构 → 测试"的顺序走。这样你会始终知道自己在读哪一层。

5. Mermaid 图:订单创建完整链路

5.1 从点击按钮到数据库事务

sequenceDiagram participant U as 用户 participant FE as 前端页面 participant C as OrderController participant F as OrderFacade participant T as OrderCreateTransactionService participant Cart as mall_cart_item participant SKU as mall_product_sku participant O as mall_order / mall_order_item U->>FE: 点击提交订单 FE->>C: POST /api/orders + Authorization + Idempotency-Key C->>F: createOrder(idempotencyKey) F->>F: 从 JWT 获取 userId 并校验幂等键 F->>O: 查询同用户同幂等键订单 alt 已存在 O-->>F: 返回原订单 F-->>C: 组装 OrderResponse else 不存在 F->>T: 进入事务 createOrder(userId, key) T->>Cart: 查询当前用户已勾选购物车 T->>SKU: 批量查询 SKU 并校验库存 T->>O: 保存订单主表 T->>SKU: 原子锁定库存 T->>O: 保存订单项快照 T->>Cart: 删除本次已结算购物车项 T-->>F: 返回 orderId F->>O: 查询订单并组装响应 end C-->>FE: 201 Created + OrderResponse

5.2 事务边界图

5.3 库存状态流转

stateDiagram-v2 [*] --> Available: 商品上架并有可售库存 Available --> Locked: 创建待支付订单 lockStock Locked --> Sold: 支付成功 confirmLockedStock Locked --> Available: 取消订单或超时关单 releaseLockedStock Sold --> [*]

5.4 幂等键防重复下单

flowchart LR A[请求 1: userId=1 key=abc] --> B{查订单是否存在} C[请求 2: userId=1 key=abc] --> B B -- 不存在 --> D[尝试插入 mall_order] D --> E{唯一索引 user_id + idempotency_key} E -- 一个成功 --> F[创建订单并锁库存] E -- 一个冲突 --> G[捕获异常后查询胜出的订单] F --> H[两个请求最终返回同一个订单] G --> H

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 类型声明。你不用先关心实现细节,只看契约就知道订单模块对外提供哪些能力。注意命名里的 MyqueryMyOrdercancelMyOrder 都强调"当前用户自己的订单"。这不是语法装饰,而是安全边界。后端方法名应该尽量把业务语义说清楚,避免写成模糊的 queryOrdercancelOrder 之后忘记用户隔离。

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 由后端计算,不来自前端;productTitleskuCodespecText 被复制到订单项,不是查询订单时再从商品表动态读取。随后总金额由所有订单项 lineAmount 相加:

java 复制代码
BigDecimal totalAmount = orderItems.stream()
        .map(OrderItemEntity::getLineAmount)
        .reduce(zeroMoney(), BigDecimal::add)
        .setScale(MONEY_SCALE, RoundingMode.HALF_UP);

金额使用 BigDecimal,而不是 doublefloat。这是 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_idchecked=1id IN (...)。这有三个作用:防止删别人的购物车,防止删未勾选商品,防止并发修改导致数据不一致。如果实际删除行数和原本读取的购物车行数不一致,说明下单过程中购物车发生变化,抛出 ORDER_CART_CHANGED,事务回滚。这样可以避免"订单按旧购物车创建,但购物车已经被用户或其他请求改掉"的混乱。

6.11 OrderEntityOrderItemEntity:表到对象的映射

OrderEntity 映射 mall_order。核心字段包括 idorderNouserIdstatusCodetotalAmountidempotencyKeycreatedAtupdatedAtexpiresAtpaidAtcancelledAtclosedAt。这些字段描述一张订单主单的生命周期。

OrderItemEntity 映射 mall_order_item。它保存 orderIdskuIdproductIdproductTitleskuCodespecTextunitPricequantitylineAmountcreatedAt。你可以把主表和明细表类比成前端订单详情页的数据结构:主表是订单头部信息,明细表是商品列表。区别是数据库要拆成两张表,因为一个订单可以有多个订单项。

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,业务 codeSUCCESSdata.statusPENDING_PAYMENTdata.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_productmall_product_sku 读取当前标题和价格,历史订单会被商品后续变更影响。正确做法是下单时保存订单项快照,查询订单时读取 mall_order_item

8.7 幂等键每次重试都换新

如果前端超时后换一个新的 Idempotency-Key 重试,后端会把它当成新业务操作,可能创建第二个订单。正确做法是:同一次用户提交操作,在结果不明确时复用同一个幂等键。只有用户重新发起一次新的提交,才生成新 key。

8.8 取消订单和支付回调不考虑库存锁定

本篇重点讲创建订单。你需要先知道:创建订单只是把可售库存转成锁定库存,不代表商品已经售出。下一篇支付回调会讲支付成功如何确认锁定库存,取消和超时关单如何释放锁定库存。如果你在创建订单时直接把库存永久扣掉,取消订单就很难正确恢复。

8.9 忽略测试里的并发和回滚用例

很多后端 bug 不是普通单请求能测出来的。本项目的 OrderMySqlContainerTest 用真实 MySQL 容器验证并发库存锁定和同幂等键并发创建,OrderTransactionRollbackTest 验证异常时整体回滚。这些测试不是为了凑覆盖率,而是在保护交易系统最重要的底线。前端转后端时,一定要学会把测试当成业务文档读。

9. 本章小练习

练习 1:画出订单创建调用链

POST /api/orders 开始,按顺序写出经过的类和方法:OrderController.createOrderOrderFacade.createOrderOrderCreateTransactionService.createOrderCartItemDbService.listCheckedByUserIdSkuDbService.lockStockOrderItemDbService.saveBatchCartItemDbService.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_stocklocked_stock?为什么 WHERE 里要有 available_stock >= #{quantity}?为什么业务层要检查影响行数是否等于 1?

练习 6:模拟事务回滚

阅读 OrderTransactionRollbackTest.rollsBackOrderAndStockWhenSavingOrderItemsFails。请列出异常发生后 5 个应该恢复的结果:订单主表、订单项表、购物车、可售库存、锁定库存。

练习 7:设计前端幂等键生命周期

写一段前端侧示意流程:用户进入订单确认页生成 key,点击提交时使用 key,请求超时时复用 key 重试,成功后清理 key,用户返回购物车重新提交时生成新 key。注意标注为"前端侧示意代码"。

练习 8:区分购物车、订单、支付

用 3 句话说明:购物车为什么不锁库存?订单创建为什么锁库存?支付成功为什么还要确认锁定库存?如果你能说清楚这 3 句话,说明你已经理解交易链路的三个阶段。

10. 再深入一点:为什么事务不是万能的

事务能保证同一个数据库里的多步修改原子提交,但它不是所有问题的万能药。首先,事务不能替代业务校验。商品是否上架、价格是否有效、购物车是否为空、库存是否足够,仍然要在业务代码里检查。其次,事务不能替代数据库条件更新。即使整个方法有 @Transactional,如果你用"先查库存再普通更新"的方式,在并发下仍然可能出错。再次,事务不能无限放大。一个事务里做太多远程调用、太多慢查询、太多用户交互,会导致锁持有时间过长,系统吞吐下降。

本项目的做法比较克制:订单创建事务只包住必要的数据库操作,不在事务中等待用户支付,也不在事务中调用第三方支付。创建订单后状态是 PENDING_PAYMENT,库存处于锁定状态,支付流程在后续接口中继续推进。这个设计非常重要:用户支付可能需要几十秒甚至几分钟,不可能让数据库事务一直开着等用户付款。事务只应该保护"创建待支付订单"这个短动作。

flowchart TD A[创建订单短事务] --> B[生成待支付订单] B --> C[事务提交] C --> D[用户跳转支付] D --> E[支付回调另一个事务] A -.不应该包含.-> X[等待用户支付几分钟]

前端同学可以类比:你不会在一个同步函数里阻塞等待用户几分钟后再点击另一个按钮;你会把流程拆成多个事件。后端也是一样,创建订单、创建支付流水、支付回调、取消订单、超时关单是不同业务事件,每个事件有自己的事务边界。

11. 排查订单创建问题的顺序

订单创建失败时,不要一上来就改代码。建议按下面顺序排查:

具体排查时可以记住 6 张表 / 字段:mall_cart_item.user_idmall_cart_item.checkedmall_product.status_codemall_product_sku.available_stockmall_product_sku.locked_stockmall_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,锁定库存同样释放。

下一篇会进入支付模块:PaymentControllerPaymentCreateTransactionServicePaymentCallbackTransactionServiceOrderCloseSchedulerOrderCloseTransactionService。你会看到为什么支付回调必须幂等,为什么外部系统可能重复通知,为什么订单状态只能从待支付进入一个终态,以及为什么取消、支付、关单之间会发生状态竞争。

13. 本篇总结

本篇你需要带走 12 个结论:

  1. 创建订单不是普通新增数据,而是跨购物车、商品、SKU、库存、订单、订单项的交易动作;
  2. POST /api/orders 不接收价格、总金额、商品标题、用户 ID,这些都必须由后端读取或生成;
  3. Idempotency-Key 用来保证同一次提交重复请求不会重复创建订单;
  4. OrderFacade 负责当前用户、幂等查询、幂等冲突兜底和响应组装;
  5. OrderCreateTransactionService 是创建订单的事务边界;
  6. @Transactional(rollbackFor = Exception.class) 让订单、订单项、库存、购物车修改整体成功或整体回滚;
  7. 订单创建只读取当前用户 checked=1 的购物车项;
  8. 订单必须重新校验商品上架、SKU 存在、价格有效和库存充足;
  9. 订单项保存的是下单时快照,不能依赖商品表当前数据动态展示历史订单;
  10. lockStock 用一条条件 UPDATE 把 available_stock 转入 locked_stock,并用影响行数判断是否成功;
  11. 购物车删除必须带 user_idchecked=1 和 ID 集合,并检查删除行数;
  12. 事务不能等待用户支付,创建订单、支付回调、取消订单、超时关单应该是不同事务。

如果你能解释"订单主表已经保存后,订单项保存失败为什么订单和库存都会回滚",并能说清楚"同一个幂等键重复请求为什么不会重复锁库存",说明你已经进入真正的后端交易一致性思维。下一篇我们会继续沿着订单往后走,学习支付回调、订单终态、库存释放和超时关单。

相关推荐
thesky1234561 小时前
智能体面试准备(六):Agent 记忆系统设计——短期、长期与向量记忆的架构与实现
人工智能·面试·agent·智能体·记忆系统
小林ixn1 小时前
React + TypeScript 实战:从“类型体操”到“数据持久化”,一次讲透组件通信与副作用管理
前端·react.js·typescript
何时梦醒1 小时前
React + TypeScript 企业级开发实战:从零搭建到组件架构演进
前端·react.js·全栈
Coffeeee1 小时前
AGP9.0的主要变更项,给Gradle来一次大变样
android·前端·gradle
用户8181870627461 小时前
第2章 JVM 异常处理机制源码级剖析
后端
数聚天成DeepSData1 小时前
外贸海关进出口数据去哪免费下载?从统计到明细的查找指南
linux·服务器·开发语言·前端·网络·人工智能·自然语言处理
Alan_751 小时前
文件上传接口设计-从单文件到分片上传
后端
newerp1 小时前
Go语言程序结构 —— 变量、声明与零值机制
后端
SamDeepThinking1 小时前
现场重构一段代码,展示什么叫「好代码,都在表达自己的意图」
java·后端·程序员