管理主页分类

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

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

多来买:购物车订单协作与并发边界实战

2420 字
12 分钟
多来买:购物车订单协作与并发边界实战

多来买:购物车订单协作与并发边界实战#

事实来源:当前 cart-service、order-service、product-api 的内部接口、核价与删车源码
证据状态:代码已确认,未启动购物车、订单或商品服务。
阅读目标:理解交易页怎样读取已选购物车、核价怎样刷新价格、下单后怎样删车,以及跨服务一致性边界。

1. 业务问题与边界#

购物车不是下单事实源,但订单需要它提供:

  1. 当前用户已勾选的商品;
  2. 商品数量和快照价格;
  3. 核价失败后的价格刷新入口;
  4. 订单保存后的购物车清理入口。

本文聚焦购物车与订单服务的契约。订单落库、仓储锁库存和延迟关单由订单专题展开。

2. 服务协作图#

sequenceDiagram participant C as 客户端 participant O as OrderController participant CA as CartApiClient participant CART as Cart Service participant P as Product Service participant DB as Order DB participant MQ as RocketMQ C->>O: GET /trade O->>CA: getCartCheckedList(userId) CA->>CART: 内部 API CART-->>O: 已选 CartInfoDTO O-->>C: 数量、金额、订单明细 C->>O: submitOrder O->>P: getSkuPrice(skuId) alt 提交价不同 O->>CART: refreshCartPrice(userId, skuId) O-->>C: 价格发生变化 else 价格一致 O->>DB: 保存订单与明细 O->>CART: removeCartProductsInOrder O->>MQ: 发送延迟消息 O-->>C: 下单结果 end

3. 购物车内部 API#

源码:duolaimall-cart/cart-service/src/main/java/com/cskaoyan/mall/cart/controller/inner/CartApiController.javaCartApiController

@GetMapping("/api/cart/inner/getCartCheckedList/{userId}")
public List<CartInfoDTO> getCartCheckedList(
@PathVariable String userId) {
return cartService.getCartCheckedList(userId);
}
@GetMapping("/api/cart/inner/refresh/{userId}/{skuId}")
public Result refreshCartPrice(
@PathVariable String userId,
@PathVariable Long skuId) {
cartService.refreshCartPrice(userId, skuId);
return Result.ok();
}
@PutMapping("/api/cart/inner/delete/order/cart/{userId}")
public Result removeCartProductsInOrder(
@PathVariable String userId,
@RequestBody List<Long> skuIds) {
cartService.delete(userId, skuIds);
return Result.ok();
}

三个接口分别对应“读已选、刷新单项价格、按订单商品批删”。

代码中没有额外内部调用认证;是否由部署网络限制访问,本轮未验证。

4. 订单侧 Feign 契约#

源码:duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/client/CartApiClient.javaCartApiClient

@FeignClient("service-cart")
public interface CartApiClient {
@GetMapping("/api/cart/inner/getCartCheckedList/{userId}")
List<CartInfoDTO> getCartCheckedList(
@PathVariable String userId);
@GetMapping("/api/cart/inner/refresh/{userId}/{skuId}")
Result refreshCartPrice(
@PathVariable String userId,
@PathVariable Long skuId);
@PutMapping("/api/cart/inner/delete/order/cart/{userId}")
Result removeCartProductsInOrder(
@PathVariable String userId,
@RequestBody List<Long> skuIds);
}

服务名、HTTP 方法、路径变量和请求体必须与 Cart Controller 一致。

这里不是共享 Java 接口继承,契约需要两端分别维护。

5. 只返回已选商品#

源码:duolaimall-cart/cart-service/src/main/java/com/cskaoyan/mall/cart/service/impl/CartServiceImpl.javagetCartCheckedList

public List<CartInfoDTO> getCartCheckedList(String userId) {
RMap<Long, CartInfoDTO> map =
redissonClient.getMap(buildKey(userId));
return map.entrySet().stream()
.filter(entry ->
entry.getValue().getIsChecked() == 1)
.map(Map.Entry::getValue)
.collect(Collectors.toList());
}

订单服务不必取得整车后再解释勾选状态,由购物车域直接输出已选视图。

如果 isCheckednull,与整数 1 比较可能因自动拆箱失败。

6. 交易页怎样计算数量和金额#

源码:duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/controller/OrderController.javaOrderController#trade

List<CartInfoDTO> cartItems =
cartApiClient.getCartCheckedList(userId);
List<OrderDetailDTO> orderDetails =
cartInfoConverter
.convertCartInfoDTOToOrderDetailDTOList(cartItems);
int totalNum = cartItems.stream()
.mapToInt(CartInfoDTO::getSkuNum)
.sum();
BigDecimal totalAmount = cartItems.stream()
.map(item -> item.getSkuPrice().multiply(
new BigDecimal(item.getSkuNum())))
.reduce(BigDecimal.ZERO, BigDecimal::add);

金额明确使用 skuPrice,不是 cartPrice

转换器同样把 skuPrice 写入订单明细价格:

@Mapping(source = "skuPrice", target = "orderPrice")
OrderDetailDTO convertCartInfoDTOToOrderDetailDTO(
CartInfoDTO cartInfoDTO);

7. 为什么提交时仍要核价#

购物车保存的是快照;加入购物车后商品可能调价,客户端也可能篡改提交参数。

订单提交会重新调用商品服务取得当前价格,再与请求中的订单价比较。

源码:OrderController#submitOrder 价格校验片段。

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

核价失败后不保存订单,而是刷新购物车并要求客户端重新提交。

BigDecimal.equals 同时比较数值和 scale,例如 10.010.00 不相等;这是订单侧候选风险。

8. 价格刷新字段不一致#

源码:CartServiceImpl#refreshCartPrice

public void refreshCartPrice(String userId, Long skuId) {
RMap<Long, CartInfoDTO> map =
redissonClient.getMap(buildKey(userId));
CartInfoDTO item = map.get(skuId);
BigDecimal currentPrice = productApiClient.getSkuPrice(skuId);
item.setCartPrice(currentPrice);
map.put(skuId, item);
}

该方法只更新 cartPrice

但交易页计算和购物车转订单明细读取 skuPrice,因此刷新后下一次交易和提交可能仍使用旧字段。

这是代码已确认的字段路径不一致;是否在真实页面流程中反复触发核价失败,本轮未运行验证。

SKU 不在购物车时,itemnull,也存在空指针触发路径。

9. 订单保存后的购物车清理#

源码:OrderController#submitOrder 相关片段。

Long orderId = orderService.saveOrderInfo(orderInfo);
List<Long> skuIds = orderDetailList.stream()
.map(OrderDetailParam::getSkuId)
.collect(Collectors.toList());
cartApiClient.removeCartProductsInOrder(userId, skuIds);
baseProducer.sendDelayMessage(
MqTopicConst.DELAY_ORDER_TOPIC,
orderId,
7);

顺序:

订单本地保存
-> Feign 删除购物车
-> 发送订单延迟消息

10. 购物车怎样批删#

源码:CartServiceImpl#delete

public void delete(String userId, List<Long> skuIds) {
RMap<Long, CartInfoDTO> map =
redissonClient.getMap(buildKey(userId));
skuIds.forEach(map::remove);
}

这不是单个批量 Redis 命令,而是对列表逐项 remove

若中途失败,可能只删除部分 SKU。

11. 跨服务事务边界#

订单本地保存、购物车 Redis 删除和 RocketMQ 发送不在同一个分布式事务中。

flowchart LR DB["订单数据库提交"] --> FEIGN["Feign 删除购物车"] FEIGN --> MQ["发送延迟消息"] DB -.不能自动回滚.-> FEIGN FEIGN -.不能自动回滚.-> MQ

订单服务的本地事务不能回滚购物车服务的 Redis,也不能保证 MQ 一定发送成功。

12. 失败矩阵#

失败位置已完成未完成当前后果
获取已选购物车失败交易页组装请求异常
商品核价 Feign 失败已收到提交核价、保存订单请求异常
核价不一致且刷新失败已确认价格变化更新购物车请求异常
订单保存失败可能部分本地动作删车、延迟消息取决于本地事务
订单保存成功、删车失败订单已落库购物车清理、消息跨服务中间状态
批删中途失败部分 SKU 已删除剩余 SKU部分完成
购物车删除成功、MQ 失败订单和购物车已改变延迟关单消息无跨服务回滚

这些是按语句顺序得到的静态判断,不是故障注入结果。

13. 并发边界#

13.1 提交期间购物车变化#

交易页读取与最终提交之间,用户仍可能:

  • 修改数量;
  • 取消勾选;
  • 删除商品;
  • 在另一设备添加相同 SKU。

订单以提交参数和实时价格继续处理,并不锁定购物车快照。

13.2 清理与新加购竞争#

若订单保存后、购物车批删前,用户又把同 SKU 加入购物车,按 field 删除可能把新状态一起删除。

当前没有按“下单时购物车版本”区分旧条目和新条目。

13.3 重试扩大重复提交#

订单已保存而删车失败时,客户端可能认为提交失败并重试。

普通订单提交的交易码防重证据不足,重复提交风险在订单专题中进一步说明。

14. HTTP 语义边界#

价格刷新内部接口使用 GET,但会修改 Redis:

GET /api/cart/inner/refresh/{userId}/{skuId}

这与常见 GET 无副作用语义不一致,可能影响缓存、监控和调用重放的理解。

删除订单购物车使用 PUT 并携带 SKU ID 请求体,契约两端当前一致。

15. 数据归属与信任#

内部 API 直接接收路径中的 userId,购物车服务按该 ID 拼接 Redis key。

代码本身没有再次校验调用方或用户归属。

在受控服务网络中可能由 Gateway、服务治理或网络策略保护,但本轮未验证部署边界,不能把内部路径等同于已完成鉴权。

16. 当前实现限制#

  • 订单交易金额和明细价格读取 skuPrice
  • 价格刷新只更新 cartPrice
  • 价格刷新对不存在 SKU 没有空值保护。
  • 已选过滤依赖 isChecked 非空且等于 1。
  • 下单后删除是逐项 remove
  • 订单保存、删车和 MQ 不在同一事务。
  • 内部刷新使用 GET 修改状态。
  • 没有购物车清理补偿或版本标记证据。

17. 候选风险#

风险触发机制缺失证据
反复核价失败更新字段与订单读取字段不同未运行接口
新加购被订单清理按 SKU field 删除且无版本未并发复现
订单重复提交保存成功后删车失败引发重试未故障注入
部分购物车残留批删逐项执行未连接 Redis 验证
内部接口越权只按路径 userId 操作未检查网络与网关配置

候选风险表示存在代码触发机制,不代表已经发生。

18. 如果重做(非当前实现)#

  1. 收敛购物车价格字段,刷新订单实际读取的字段;
  2. 金额比较使用业务明确的 scale 或 compareTo 规则;
  3. 下单建立交易幂等键,避免重试重复落库;
  4. 清理购物车使用幂等事件或带版本的批量操作;
  5. 对失败清理建立重试和可观测记录;
  6. 内部写接口使用符合语义的 HTTP 方法;
  7. 为内部 API 增加服务身份或受控网络保护。

19. 关键源码导航#

阅读问题项目相对路径类/方法
购物车内部入口duolaimall-cart/cart-service/src/main/java/com/cskaoyan/mall/cart/controller/inner/CartApiController.java已选、刷新、批删
购物车内部实现duolaimall-cart/cart-service/src/main/java/com/cskaoyan/mall/cart/service/impl/CartServiceImpl.javagetCartCheckedListrefreshCartPricedelete
订单 Feignduolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/client/CartApiClient.javaCartApiClient
交易页与提交duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/controller/OrderController.javatradesubmitOrder
购物车转订单duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/converter/CartInfoConverter.javaconvertCartInfoDTOToOrderDetailDTO
商品实时价格duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/client/ProductApiClient.javagetSkuPrice

20. 短复习点#

  1. 订单通过内部 Feign 只读取 isChecked=1 的购物车条目。
  2. 交易页和订单明细都读取 skuPrice,而刷新只修改 cartPrice
  3. 保存订单、删除购物车和发送延迟消息是三个跨边界步骤。
  4. 批删逐项 remove,失败时可能部分完成。
  5. 购物车是提交输入之一,不是价格和订单状态的最终事实源。

21. 一句话总结#

多来买通过内部 Feign 把已选购物车交给订单,并在核价失败或落库成功后回调购物车;服务职责清楚,但价格字段、批删原子性、提交幂等和跨服务补偿仍未闭环。

文章分享

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

多来买:购物车订单协作与并发边界实战
https://firefly-mu-weld.vercel.app/posts/duolaimai-cart-order-consistency/
作者
Daisy
发布于
2026-08-03
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
Daisy
Hello, I'm Daisy.
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签

文章目录