多来买:购物车订单协作与并发边界实战
多来买:购物车订单协作与并发边界实战
事实来源:当前 cart-service、order-service、product-api 的内部接口、核价与删车源码
证据状态:代码已确认,未启动购物车、订单或商品服务。
阅读目标:理解交易页怎样读取已选购物车、核价怎样刷新价格、下单后怎样删车,以及跨服务一致性边界。
1. 业务问题与边界
购物车不是下单事实源,但订单需要它提供:
- 当前用户已勾选的商品;
- 商品数量和快照价格;
- 核价失败后的价格刷新入口;
- 订单保存后的购物车清理入口。
本文聚焦购物车与订单服务的契约。订单落库、仓储锁库存和延迟关单由订单专题展开。
2. 服务协作图
3. 购物车内部 API
源码:duolaimall-cart/cart-service/src/main/java/com/cskaoyan/mall/cart/controller/inner/CartApiController.java,CartApiController。
@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.java,CartApiClient。
@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.java,getCartCheckedList。
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());}订单服务不必取得整车后再解释勾选状态,由购物车域直接输出已选视图。
如果 isChecked 为 null,与整数 1 比较可能因自动拆箱失败。
6. 交易页怎样计算数量和金额
源码:duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/controller/OrderController.java,OrderController#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.0 与 10.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 不在购物车时,item 为 null,也存在空指针触发路径。
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 发送不在同一个分布式事务中。
订单服务的本地事务不能回滚购物车服务的 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. 如果重做(非当前实现)
- 收敛购物车价格字段,刷新订单实际读取的字段;
- 金额比较使用业务明确的 scale 或
compareTo规则; - 下单建立交易幂等键,避免重试重复落库;
- 清理购物车使用幂等事件或带版本的批量操作;
- 对失败清理建立重试和可观测记录;
- 内部写接口使用符合语义的 HTTP 方法;
- 为内部 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.java | getCartCheckedList、refreshCartPrice、delete |
| 订单 Feign | duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/client/CartApiClient.java | CartApiClient |
| 交易页与提交 | duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/controller/OrderController.java | trade、submitOrder |
| 购物车转订单 | duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/converter/CartInfoConverter.java | convertCartInfoDTOToOrderDetailDTO |
| 商品实时价格 | duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/client/ProductApiClient.java | getSkuPrice |
20. 短复习点
- 订单通过内部 Feign 只读取
isChecked=1的购物车条目。 - 交易页和订单明细都读取
skuPrice,而刷新只修改cartPrice。 - 保存订单、删除购物车和发送延迟消息是三个跨边界步骤。
- 批删逐项 remove,失败时可能部分完成。
- 购物车是提交输入之一,不是价格和订单状态的最终事实源。
21. 一句话总结
多来买通过内部 Feign 把已选购物车交给订单,并在核价失败或落库成功后回调购物车;服务职责清楚,但价格字段、批删原子性、提交幂等和跨服务补偿仍未闭环。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!