管理主页分类

拖动分类调整顺序,并选择是否在主页显示。设置仅保存在当前浏览器。

  • 微服务课件 31
  • Java 基础 28
  • Java Web 开发 25
  • 多来买 18
  • LeetCode 题解 8
  • 开发工具 8
  • 网络工具 2
  • 黑马头条 2
  • Java 记忆恢复 1

多来买:交易确认、核价与订单落库实战

2429 字
12 分钟
多来买:交易确认、核价与订单落库实战

多来买:交易确认、核价与订单落库实战#

本篇范围:交易确认页、库存预查、实时核价、订单字段组装、主从表落库与购物车删除
事实来源:当前 OrderControllerOrderServiceImpl、订单 DTO/Param、user/cart/product/ware Feign Client
验证口径:代码已确认;未构建、未启动、未连接数据库或其他外部服务

本篇只回答一个问题:普通订单在真正进入支付前,怎样把“用户看到的结算快照”转换成订单数据库中的主表和明细,以及这段过程中哪些数据可信、哪些操作仍然在本地事务之外。

本篇主链路#

sequenceDiagram participant Client as "客户端" participant Order as "order-service" participant User as "user-service" participant Cart as "cart-service" participant Product as "product-service" participant Ware as "ware-service" participant DB as "订单数据库" Client->>Order: GET /order/auth/trade Order->>User: 查询收货地址 Order->>Cart: 查询已选商品 Order-->>Client: 地址、明细、件数、展示金额 Client->>Order: POST /order/auth/submitOrder Order->>Ware: 逐 SKU 预查库存 Order->>Product: 逐 SKU 查询实时价格 Order->>DB: 本地事务保存主表和明细 Order->>Cart: 删除已下单商品 Order-->>Client: 返回 orderId

阅读目标#

  • 区分结算展示金额与下单实时核价。
  • 说明库存预查为什么只用于快速失败,不能替代最终锁库存。
  • 说明 Gateway 透传的 userId 如何覆盖客户端参数。
  • 说明订单主表和明细为什么必须在一个本地事务中保存。
  • 识别订单保存、购物车删除和后续消息发送之间的事务边界。

接口契约与身份边界#

外部接口#

接口方法身份来源成功结果
/order/auth/tradeGETGateway 写入的 userId headerOrderTradeDTO
/order/auth/submitOrderPOSTGateway 写入的 userId header新订单 ID
/order/auth/{page}/{limit}GETuserId header当前用户订单分页

订单内部协作接口#

调用方向接口职责
order → user查询当前用户收货地址
order → cart查询已选商品、刷新价格、删除商品
order → product获取 SKU 实时价格
order → ware预查库存、支付后处理库存
pay → order通知订单支付成功
ware → order查询订单、拆单、回写库存任务结果
order → pay超时检查时查询本地支付与支付宝状态

提交订单参数#

代码路径:duolaimall-order/order-api/src/main/java/com/cskaoyan/mall/order/query/OrderInfoParam.java

@Data
public class OrderInfoParam {
Long userId;
String consignee;
String consigneeTel;
String deliveryAddress;
String orderComment;
String paymentWay = "ONLINE";
List<OrderDetailParam> orderDetailList;
}

明细参数包含 skuIdskuNameorderPriceskuNum。这些来自客户端请求,当前代码会重新查价格和库存,但商品名称、图片、数量合法范围等没有看到完整服务端重建或校验。

Controller 最终使用请求头 userId 覆盖转换后订单的用户 ID,因此不会直接把请求体中的 userId 作为当前登录身份。

第一步:聚合交易确认页#

代码路径:duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/controller/OrderController.java
关键方法:trade

获取地址与已选商品#

String userId = request.getHeader("userId");
List<UserAddressDTO> userAddressDTOS =
userApiClient.findUserAddressListByUserId(userId);
orderTradeDTO.setUserAddressList(userAddressDTOS);
List<CartInfoDTO> cartInfoDTOList =
cartApiClient.getCartCheckedList(userId);
List<OrderDetailDTO> orderDetailDTOS =
cartInfoConverter
.convertCartInfoDTOToOrderDetailDTOList(cartInfoDTOList);
orderTradeDTO.setDetailArrayList(orderDetailDTOS);

订单服务是确认页聚合者:

  • 用户服务拥有地址事实;
  • 购物车服务拥有用户本次勾选的 SKU;
  • 订单服务把两份数据组合成一次结算视图。

任一 Feign 调用失败,当前 Controller 没有局部降级逻辑,确认页会整体失败。

计算件数和金额#

int totalSum = cartInfoDTOList.stream()
.mapToInt(CartInfoDTO::getSkuNum)
.sum();
BigDecimal totalAmount = cartInfoDTOList.stream()
.map(item -> item.getSkuPrice()
.multiply(new BigDecimal(item.getSkuNum())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
orderTradeDTO.setTotalNum(totalSum);
orderTradeDTO.setTotalAmount(totalAmount);

这里使用购物车当前价格生成展示金额,但展示金额不是下单时的最终信任依据。提交订单还会逐项查询商品服务的实时价格。

OrderTradeDTO#

代码路径:duolaimall-order/order-api/src/main/java/com/cskaoyan/mall/order/dto/OrderTradeDTO.java

@Data
public class OrderTradeDTO {
List<UserAddressDTO> userAddressList;
List<OrderDetailDTO> detailArrayList;
Integer totalNum;
BigDecimal totalAmount;
}

确认页没有生成交易防重码。公共常量里虽然存在订单交易码前缀,但当前普通订单入口没有创建和消费它,所以不能声称提交接口已经防止重复点击。

第二步:提交前预查库存#

代码路径:duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/controller/OrderController.java
关键方法:submitOrder

List<OrderDetailParam> orderDetailList =
orderInfoParam.getOrderDetailList();
for (OrderDetailParam detail : orderDetailList) {
Long skuId = detail.getSkuId();
Integer skuNum = detail.getSkuNum();
Result result = wareApiClient.hasStock(skuId, skuNum);
if (!ResultCodeEnum.SUCCESS.getCode()
.equals(result.getCode())) {
return Result.fail()
.message(detail.getSkuName() + " 没有库存!");
}
}

仓储怎样计算可用库存#

代码路径:duolaimall-ware/ware-service/src/main/java/com/cskaoyan/mall/ware/service/impl/WareServiceImpl.java
关键方法:hasStockgetStockBySkuId

@Override
public Boolean hasStock(Long skuId, Integer num) {
Integer stock = getStockBySkuId(skuId);
return stock != null && stock >= num;
}
public Integer getStockBySkuId(Long skuId) {
return wareSkuMapper
.selectStockBySkuid(skuId.toString());
}

对应 SQL:

代码路径:duolaimall-ware/ware-service/src/main/resources/mapper/WareSkuMapper.xml

SELECT SUM(stock - IFNULL(stock_locked, 0))
FROM ware_sku
WHERE sku_id = #{skuid}

这个查询计算同一 SKU 在所有仓库中的总可用库存:

available = stock - stock_locked
totalAvailable(sku) = SUM(各仓库 available)

为什么预查不能防超卖#

预查只是一个普通读:

  1. 请求 A 查到库存 5;
  2. 请求 B 同时也查到库存 5;
  3. A 和 B 都要买 4,都会通过预查;
  4. 真正锁库存时必须再次检查。

因此预查的作用是尽早拒绝明显无库存订单,减少无效订单和支付尝试;它不是库存预占,也没有改变数据库库存。

第三步:重新核对实时价格#

for (OrderDetailParam detail : orderDetailList) {
BigDecimal orderPrice = detail.getOrderPrice();
BigDecimal currentPrice =
productApiClient.getSkuPrice(detail.getSkuId());
if (!orderPrice.equals(currentPrice)) {
cartApiClient.refreshCartPrice(
userId, detail.getSkuId());
return Result.fail()
.message(detail.getSkuName() + " 价格发生变动!");
}
}

核价成功才能继续保存订单。若价格变化:

  • 通知购物车服务刷新当前 SKU 价格;
  • 本次下单失败;
  • 用户需要重新确认金额并提交。

当前比较方式的边界#

BigDecimal.equals 同时比较数值和 scale:

new BigDecimal("10.0")
.equals(new BigDecimal("10.00")); // false

因此数值相同但小数位不同也会被判断为价格变化。若业务只关心金额数值,通常使用 compareTo(...) == 0。这是改进建议,不是当前实现。

核价并不等于完整商品校验#

当前服务端重新获取的是价格。请求中的 skuNameimgUrl 和数量仍主要来自客户端参数;也没有看到对数量必须为正、购买上限、SKU 上下架状态的完整校验。

第四步:组装订单业务字段#

OrderInfo orderInfo =
orderInfoConverter.convertOrderInfoParam(orderInfoParam);
orderInfo.setOrderStatus(OrderStatus.UNPAID.name());
orderInfo.setUserId(Long.valueOf(userId));
orderInfo.sumTotalAmount();
String snowflakeId =
hutoolIdUtil.getSnowflakeId().toString();
orderInfo.setOutTradeNo(snowflakeId);
orderInfo.setTradeBody(
orderDetailList.get(0).getSkuName());
orderInfo.setExpireTime(
DateUtil.datePlusMinutes(new Date(), 1));

关键字段含义:

字段来源或生成方式作用
orderStatus固定 UNPAID初始订单状态
userIdGateway 透传 header绑定真实登录用户
totalAmount由订单明细重新汇总本地订单金额
outTradeNo雪花 ID给支付平台使用的业务号
tradeBody第一条明细商品名支付标题
expireTime当前时间加 1 分钟本地计划过期时间

当前代码直接取 orderDetailList.get(0)。若明细为空会抛出异常,入口没有在此之前显式拒绝空订单。

第五步:本地事务保存订单主表与明细#

代码路径:duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/service/impl/OrderServiceImpl.java
关键方法:saveOrderInfo

@Override
@Transactional(
rollbackFor = RuntimeException.class,
propagation = Propagation.REQUIRED)
public Long saveOrderInfo(OrderInfo orderInfo) {
orderInfoMapper.insert(orderInfo);
Long orderInfoId = orderInfo.getId();
for (OrderDetail orderDetail
: orderInfo.getOrderDetailList()) {
orderDetail.setOrderId(orderInfoId);
orderDetail.setId(null);
orderDetailMapper.insert(orderDetail);
}
return orderInfoId;
}

为什么主表和明细必须同一事务#

假设订单主表插入成功,第三条明细插入失败:

  • 没有事务:数据库留下不完整订单;
  • 有事务:异常向外抛出,主表和已写明细一起回滚。

Controller 通过 Spring 注入的 orderService 调用此方法,因此普通订单入口会经过事务代理。

事务只覆盖本地订单数据库#

该事务不包括:

  • 保存前的库存和价格 Feign 调用;
  • saveOrderInfo 返回后的购物车删除;
  • RocketMQ 延迟消息发送;
  • 支付后的仓储调用。

本地事务不能跨越独立服务和外部中间件。

第六步:删除购物车商品#

List<Long> skuIds = orderDetailList.stream()
.map(OrderDetailParam::getSkuId)
.collect(Collectors.toList());
cartApiClient.removeCartProductsInOrder(
userId, skuIds);

调用顺序是“先提交订单事务,再远程删除购物车”。因此:

情况数据结果
订单保存失败不执行购物车删除
订单保存成功、购物车删除成功正常
订单保存成功、购物车删除失败订单存在,购物车仍保留商品
客户端因超时重复提交可能再次创建新订单

购物车残留比“订单创建失败却商品被删除”更容易恢复,但仍需要幂等订单、重试删除或前端重新拉取等机制。当前代码没有可靠补偿。

本篇限制与风险收束#

  • 普通订单入口没有生成并消费交易防重码,重复点击可能创建多张订单。
  • 入口直接读取第一条明细生成 tradeBody,空明细会触发异常。
  • BigDecimal.equals 同时比较数值和 scale,可能把 10.010.00 判断为不同价格。
  • 服务端只重新获取价格和库存;名称、图片、数量范围、上下架状态没有形成完整重建校验。
  • 购物车删除发生在订单本地事务提交之后,失败时会留下“订单已创建、购物车仍保留商品”的状态。

关键源码导航#

阅读顺序文件关键方法或类型
1duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/controller/OrderController.javatradesubmitOrder
2duolaimall-order/order-api/src/main/java/com/cskaoyan/mall/order/query/OrderInfoParam.java提交参数
3duolaimall-order/order-api/src/main/java/com/cskaoyan/mall/order/dto/OrderTradeDTO.java确认页聚合结果
4duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/service/impl/OrderServiceImpl.javasaveOrderInfo
5duolaimall-ware/ware-service/src/main/java/com/cskaoyan/mall/ware/service/impl/WareServiceImpl.javahasStock
6duolaimall-ware/ware-service/src/main/resources/mapper/WareSkuMapper.xml汇总可用库存 SQL

4 条短复习点#

  1. 结算页是展示快照,提交订单必须重新核价和查库存。
  2. 库存预查不加锁,也不改变库存,只能提前拒绝明显无货请求。
  3. saveOrderInfo 的事务只覆盖订单库主表和明细。
  4. 订单保存后的购物车删除属于跨服务操作,失败不能由本地事务回滚。

本章总结#

普通订单把结算页快照重新经过库存预查和实时核价,再在订单库本地事务中保存主表与明细;购物车清理等远程动作不在该事务中,必须作为跨服务失败边界单独处理。

下一篇:仓储工作单、拆单与锁库存实战

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

多来买:交易确认、核价与订单落库实战
https://firefly-mu-weld.vercel.app/posts/duolaimai-order-trade-create/
作者
Daisy
发布于
2026-08-03
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
Daisy
Hello, I'm Daisy.
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签

文章目录