商城系统开发(三):购物车与订单流程

商品服务搭完之后,下一个大模块就是购物车和订单。购物车看着简单,实际要考虑的东西不少——特别是和库存、价格的联动。订单就更复杂了,从下单到支付是一条很长的链路。这篇聊聊我的实现思路。

购物车存储方案

购物车需要频繁读写,对一致性要求没那么高(购物车里的数据丢了用户重新加就行),但对性能要求很高。最终选了 Redis Hash 来存储。

结构设计:

Key:   cart:{user_id}
Field: {sku_id}
Value: JSON { "sku_id": 123, "quantity": 2, "checked": true, "added_at": "..." }

为什么用 Hash 而不是 String?

  • 可以单独操作某个 SKU(加购、改数量),不需要读出整个购物车
  • HGETALL 一次取出所有商品,效率高
  • HDEL 可以删除单个商品

用 JSON 作为 Value 是因为每个购物车项需要存多个字段(数量、选中状态、加入时间等),如果每个字段都单独用一个 Hash Field 会导致 Field 数量爆炸。

购物车上限

购物车不能无限添加商品,设了 200 个 SKU 的上限。加购前先 HLEN 检查一下数量。

登录态问题

未登录用户也需要购物车。我的方案是给未登录用户分配一个临时 token(存 Cookie),购物车 Key 用 cart:tmp:{token}。用户登录后,把临时购物车合并到用户购物车中,合并逻辑是:相同 SKU 数量相加,不同 SKU 直接追加。合并完成后删除临时购物车。

购物车操作

加购

POST /cart/add
{
    "sku_id": 12345,
    "quantity": 1
}

加购流程:

  1. 校验 SKU 是否存在、是否上架
  2. 校验库存是否充足(这里只做轻量校验,不锁库存)
  3. 查询该 SKU 在购物车中是否已存在
  4. 已存在:数量累加;不存在:新增一条
  5. 返回购物车最新数据

修改数量

PUT /cart/update
{
    "sku_id": 12345,
    "quantity": 3
}

前端传的是最终数量而不是增减数量,这样可以避免并发问题。后端直接覆盖 quantity 字段。如果 quantity 为 0,等同于删除。

勾选/取消勾选

购物车里每个商品有一个 checked 状态,用来标记结算时包含哪些商品。还有"全选"和"全不选"操作,全选就是遍历所有 Field 把 checked 设为 true。

这个操作频率很高(用户反复勾选),所以我没有做数据库持久化,全部在 Redis 里操作。

删除

支持批量删除:

DELETE /cart/nemove
{
    "sku_ids": [12345, 67890]
}

HDEL 一次删多个 Field。

购物车展示数据

前端展示购物车时,需要的数据不仅仅是购物车本身存的那些字段。还需要:商品标题、图片、价格、库存状态。这些数据不应该存在购物车里(商品信息随时可能变化),而是展示时实时查询。

流程是:从 Redis 取出购物车数据(SKU ID 列表),然后批量查商品服务获取最新商品信息,合并后返回前端。如果某个 SKU 已经下架或删除了,在前端标记为"该商品已失效"。

下单流程

下单是从购物车到创建订单的过程,也是系统里最长的一条链路。

整体流程

购物车 → 确认订单页 → 提交订单 → 扣库存 → 创建订单 → 调支付

Step 1:确认订单页

用户在购物车里勾选商品,点击"去结算",进入确认订单页。这一步需要:

  • 获取用户默认收货地址
  • 计算运费(根据地址和商品重量)
  • 重新查询商品价格(防止购物车价格过期)
  • 展示优惠信息(后期做优惠券时再加)

确认订单页的数据通过一个聚合接口返回,后端同时查多个服务拼装数据。

Step 2:提交订单

用户点击"提交订单"后,后端开始创建订单。这是最核心的步骤:

  1. 防重校验:用订单 Token 做幂等,防止用户重复点击。进入确认订单页时后端生成一个 Token,提交时带上,后端校验后立即删除。
  2. 再次校验价格和库存:确认订单页到提交订单之间可能有时间差,价格和库存可能已经变了。
  3. 扣减库存:调用库存服务扣减。扣减失败直接返回失败。这里用的是"下单减库存"策略,不是"支付减库存"。
  4. 创建订单记录:写入 order 主表和 order_item 明细表。
  5. 清除购物车:删除已下单的购物车项。
  6. 发送延迟消息:创建一条30分钟的延迟消息,超时未支付自动关闭订单并回退库存。

Step 3:调支付

订单创建成功后,返回订单号给前端,前端跳转到支付页面。用户选择支付方式后,调用支付系统创建支付单。支付这块在之前的文章里写过了。

订单状态机

订单有一套完整的状态流转:

待支付 → 已支付/待发货 → 已发货/待收货 → 已完成
  ↓          ↓              ↓
已取消    退款中          退款中
            ↓              ↓
          已退款          已退款

状态转换规则:

  • 待支付 → 已取消:用户主动取消,或超时自动取消
  • 待支付 → 已支付:收到支付回调
  • 已支付 → 已发货:后台操作发货
  • 已发货 → 已完成:用户确认收货,或超时自动确认(15天)
  • 已支付/已发货 → 退款中:用户申请退款

每次状态变更我都记了一条 order_status_log,方便追踪问题。

库存扣减策略

库存扣减有三种策略,各有利弊:

  1. 下单减库存(我选的):下单时就扣库存。好处是不会超卖;坏处是可能有人恶意下单占库存不付款。通过超时关单+回退库存来缓解。
  2. 支付减库存:支付成功才扣库存。好处是不会被恶意占库存;坏处是可能超卖(多人同时下单都成功但库存不够)。
  3. 预扣库存:下单时预扣,支付后确认扣减,超时释放。最复杂但最合理。后面可能会改成这个方案。

当前用的"下单减库存",扣减操作用了 Redis + Lua 保证原子性,然后异步同步到数据库。

关键接口总结

POST   /cart/add         加购
PUT    /cart/update       修改数量
DELETE /cart/nemove       删除商品
GET    /cart/list         获取购物车列表
PUT    /cart/check-all    全选/全不选

GET    /order/confirm     确认订单页数据
POST   /order/submit      提交订单
GET    /order/detail/:id  订单详情
POST   /order/cancel/:id  取消订单
GET    /order/list        我的订单列表

购物车和订单做完之后,商城的核心链路就通了:浏览商品 → 加购物车 → 下单 → 支付。后面要做的就是不断完善细节——优惠券、物流跟踪、评价等。