多来买:交易确认、核价与订单落库实战
多来买:交易确认、核价与订单落库实战
本篇范围:交易确认页、库存预查、实时核价、订单字段组装、主从表落库与购物车删除
事实来源:当前OrderController、OrderServiceImpl、订单 DTO/Param、user/cart/product/ware Feign Client
验证口径:代码已确认;未构建、未启动、未连接数据库或其他外部服务
本篇只回答一个问题:普通订单在真正进入支付前,怎样把“用户看到的结算快照”转换成订单数据库中的主表和明细,以及这段过程中哪些数据可信、哪些操作仍然在本地事务之外。
本篇主链路
阅读目标
- 区分结算展示金额与下单实时核价。
- 说明库存预查为什么只用于快速失败,不能替代最终锁库存。
- 说明 Gateway 透传的
userId如何覆盖客户端参数。 - 说明订单主表和明细为什么必须在一个本地事务中保存。
- 识别订单保存、购物车删除和后续消息发送之间的事务边界。
接口契约与身份边界
外部接口
| 接口 | 方法 | 身份来源 | 成功结果 |
|---|---|---|---|
/order/auth/trade | GET | Gateway 写入的 userId header | OrderTradeDTO |
/order/auth/submitOrder | POST | Gateway 写入的 userId header | 新订单 ID |
/order/auth/{page}/{limit} | GET | userId 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
@Datapublic class OrderInfoParam { Long userId; String consignee; String consigneeTel; String deliveryAddress; String orderComment; String paymentWay = "ONLINE"; List<OrderDetailParam> orderDetailList;}明细参数包含 skuId、skuName、orderPrice 和 skuNum。这些来自客户端请求,当前代码会重新查价格和库存,但商品名称、图片、数量合法范围等没有看到完整服务端重建或校验。
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
@Datapublic 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
关键方法:hasStock、getStockBySkuId
@Overridepublic 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_skuWHERE sku_id = #{skuid}这个查询计算同一 SKU 在所有仓库中的总可用库存:
available = stock - stock_lockedtotalAvailable(sku) = SUM(各仓库 available)为什么预查不能防超卖
预查只是一个普通读:
- 请求 A 查到库存 5;
- 请求 B 同时也查到库存 5;
- A 和 B 都要买 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。这是改进建议,不是当前实现。
核价并不等于完整商品校验
当前服务端重新获取的是价格。请求中的 skuName、imgUrl 和数量仍主要来自客户端参数;也没有看到对数量必须为正、购买上限、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 | 初始订单状态 |
userId | Gateway 透传 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.0与10.00判断为不同价格。- 服务端只重新获取价格和库存;名称、图片、数量范围、上下架状态没有形成完整重建校验。
- 购物车删除发生在订单本地事务提交之后,失败时会留下“订单已创建、购物车仍保留商品”的状态。
关键源码导航
| 阅读顺序 | 文件 | 关键方法或类型 |
|---|---|---|
| 1 | duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/controller/OrderController.java | trade、submitOrder |
| 2 | duolaimall-order/order-api/src/main/java/com/cskaoyan/mall/order/query/OrderInfoParam.java | 提交参数 |
| 3 | duolaimall-order/order-api/src/main/java/com/cskaoyan/mall/order/dto/OrderTradeDTO.java | 确认页聚合结果 |
| 4 | duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/service/impl/OrderServiceImpl.java | saveOrderInfo |
| 5 | duolaimall-ware/ware-service/src/main/java/com/cskaoyan/mall/ware/service/impl/WareServiceImpl.java | hasStock |
| 6 | duolaimall-ware/ware-service/src/main/resources/mapper/WareSkuMapper.xml | 汇总可用库存 SQL |
4 条短复习点
- 结算页是展示快照,提交订单必须重新核价和查库存。
- 库存预查不加锁,也不改变库存,只能提前拒绝明显无货请求。
saveOrderInfo的事务只覆盖订单库主表和明细。- 订单保存后的购物车删除属于跨服务操作,失败不能由本地事务回滚。
本章总结
普通订单把结算页快照重新经过库存预查和实时核价,再在订单库本地事务中保存主表与明细;购物车清理等远程动作不在该事务中,必须作为跨服务失败边界单独处理。
下一篇:仓储工作单、拆单与锁库存实战。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!