购物车处理逻辑
本文详细说明前台 购物车 从加购到进入结账的 Redis 结构、Service 处理过程、API 及与 checkoutcart 的关系。token 与 ID 见 cart-checkout-token-visit-id.md。
与实现冲突时以 CartService(common/services/CartService.php)为准。COD 结账复用 Redis 读商品,计价走 CodCartService(见 promotions-by-checkout-type.md)。
目录
- 1. 架构概览
- 2. Redis 数据结构
- 3. 行数据:Redis 存什么
- 4. 加购与改购
- 5. 读购物车:getCartListData / getList
- 6. cartDataComposition:行价组装
- 7. 优惠计算(getList 内)
- 8. 进入结账:getCheckoutUrl
- 9. buynow / instant 与 checkoutcart type
- 10. 登录合并游客 cart
- 11. 清理与 TTL
- 12. 前台 API 一览
- 13. 与订单同步(checkout 阶段)
- 14. 排障
- 15. 代码索引
1. 架构概览
flowchart TB subgraph browser [浏览器] AT[加购 / 改数量] CK[去结账 POST /cart] end subgraph redis [Redis storeId 前缀] GC[guestcart:visit_id] CC[customercart:customer_id] CH[checkoutcart:checkout_token] end subgraph service [CartService] ADD[addToCart / update / remove] LIST[getList] URL[getCheckoutUrl] end AT --> ADD --> GC AT --> ADD --> CC CK --> URL --> CH CH -->|type=cart cart_token| GC CH -->|type=buynow cart_data| CH LIST --> GC LIST --> CC LIST --> CH
原则:
- 商品行 只存在
guestcart/customercart(JSON 数组)。 - checkoutcart 是 会话快照元数据(JSON 对象),不重复存完整商品(buynow 除外内嵌
cart_data)。 - 计价(变体价、活动、满减、券)在
getList内存计算,Redis 行数据不含final_price等活动结果。
2. Redis 数据结构
Key 前缀:{storeId}:(CacheKeyHelper::store())。
| Key | 类型 | TTL | 内容 |
|---|---|---|---|
{store}:guestcart:{visit_id} | string JSON | 90 天 | 行数组 |
{store}:customercart:{customer_id} | string JSON | 90 天 | 行数组 |
{store}:checkoutcart:{checkout_token} | string JSON | cart 型 90 天;buynow 7 天 | 见 §2.1 |
visit_id = md5(global_visit_id Cookie);checkout_token = md5(checkout_visit_id Cookie)。
2.1 checkoutcart 对象
| type | 字段 | 商品来源 |
|---|---|---|
cart | cart_token → 完整 key 字符串 | 指向 guest/customer cart |
buynow / instant_checkout / paypal_ec | cart_data JSON 字符串 | 内嵌行数组 |
order | cart_token(可选) | 在线:o_order + products;仍可能保留 cart_token 校验 |
创单后在线订单:OrderService::saveCheckoutCart 将 type 改为 order。
3. 行数据:Redis 存什么
CartService::cartStructBuild()(1452–1481 行)写入 Redis 的 最小快照:
| 字段 | 说明 |
|---|---|
product_id, sku_code, quantity | 主键维度 |
create_time, last_time | 时间戳 |
store_id, data_from, spm | 来源追踪 |
property | 定制属性(加购前经 DiyPlanService 格式化) |
diy_offer_id, diy_offer_name | 加购时绑定的 o_diy_offer |
unmodifiable, unavailable, label, ends_at | 活动/赠品控制 |
不存储:price、final_price、promotion_id 等——在 cartDataComposition / getList 时查商品库计算。
4. 加购与改购
4.1 addToCart
CartService::addToCart($customerId, $productId, $skuCode, $qty, $dataFrom, $property)(72 行)
| 步骤 | 动作 |
|---|---|
| 1 | cartAddPermissionCheck(库存、上下架、行数上限 50) |
| 2 | buildVariantUniqKey(product_id + sku_code + property) |
| 3 | getCartListData 读 Redis |
| 4 | 新行:saveCartListData;已有行:update 累加 quantity |
| 5 | 事件 AddToCart |
4.2 唯一行规则
同一 cart 内一行 = product_id + sku_code + property 唯一;property 不同视为不同行。
4.3 其它写操作
| 方法 | 说明 |
|---|---|
batchAddToCart | 批量加购 |
update | 改数量 / data_from |
remove / removeByLine | 删行 |
saveCartListData | 写 guest 或 customer key |
delCart | 删整个 cart key |
5. 读购物车:getCartListData / getList
5.1 无 checkout_token
getCartListData($customerId)
// 会员 → customercart:{id}
// 游客 → guestcart:{visit_id}用于:mini cart、GET /cartlist、加购后返回列表。
5.2 有 checkout_token(getList 374–493 行)
读 checkoutcart:{token}
→ switch type:
cart → getData(cart_token)
buynow… → json_decode(cart_data)
order → OrderService::getOrderByToken → products
→ 若 order 且 cart_token 仍指向当前 guest/customer cart:
compareCartData → 可能改用 Redis 最新行覆盖 order products
paid/pending/cancel 且 cookie checkout 匹配:renew token + clearCart(404–422 行)。
6. cartDataComposition:行价组装
cartDataComposition($cart_list_data, &$return)(801 行起)
| 步骤 | 说明 |
|---|---|
| 批量查商品 | ProductService::productDetailByIds |
| 匹配变体 | sku_code → variant |
| 限时促销过期 | data_from=app_limitedtimeoffer 且 ends_at 过期 → 清 diy_offer_id |
| 属性价 | formatProperty + 计入 price/final_price |
| 汇总 | item_count、subtotal_price_currency、items[] |
| 过滤无效行 | 下架/无变体行丢弃;若行数变少 回写 Redis(530–531 行) |
输出:$return['items'] 供后续 diy_offer / promotion 使用。
7. getList() 完整流程
主方法:CartService::getList($customer_id, $checkout_token = '')(367 行起)
7.1 阶段一:根据 checkout_token 确定购物车来源
有 checkout_token 时(386-507 行)
根据 Redis checkoutcart:{token} 中 type 分三种:
| type | 来源 | 说明 |
|---|---|---|
CHECKOUT_CART_TYPE_CART(cart) | checkoutcart.cart_token → Redis guestcart/customercart | 购物车结账 |
BUYNOW / INSTANT / PAYPAL_EC | checkoutcart.cart_data 内嵌 JSON | 立即购买,不指向 Redis 购物车 |
CHECKOUT_CART_TYPE_ORDER(order) | 查 o_order + $order['products'] | 已有订单重新进入结账;状态为 pending/paid/cancel 且 visit_id 匹配时清购物车返回空 |
order 类型时:从 o_order 恢复所有 current_* 字段到 $return(currency、地址、shipping_price、coupon_code、promotion_price、diy_offer_price 等),并设置 has_contact_information / has_billing_address / has_shipping_method / has_payment_method。
无 checkout_token 时(508-511 行)
从 Redis $this->guestCartKey 或顾客购物车 key 读取商品列表。
7.2 阶段二:同步购物车与订单商品(518-549 行)
若已有 order:
compareCartData(redisCart, cart_list_data)比较是否一致- 不一致则用 Redis 最新行覆盖
$cart_list_data
若已创建过订单(checkIsCreateOrder 返回 true):
cartStructBuildFromOrder从$order['products_list']重建购物车结构
7.3 阶段三:商品数据组装(552 行)
$cart_list_data_new = $this->cartDataComposition($cart_list_data, $return);cartDataComposition(801 行起):
| 步骤 | 说明 |
|---|---|
| 批量查商品 | ProductService::productDetailByIds |
| 匹配变体 | sku_code → ProductVariantModel |
| 限时促销过期判断 | data_from=app_limitedtimeoffer 且 ends_at < time() → 清 diy_offer_id |
| 组装属性价格 | formatProperty → 计入 price / final_price |
| 汇总 | item_count、subtotal_price_currency、items[] |
| 过滤 | 下架/无变体行丢弃;若行数变少 回写 Redis(560-561 行) |
输出:$return['items'] 供后续 diy_offer / promotion 使用。
7.4 阶段四:优惠活动计算(553-580 行)
④ minmaxoffer(553 行)
(new DiyOfferService())->defaultDiyOffer($return, ['minmaxoffer']);- 直接修改每个 item 的
price / unit_price / final_price - 若命中条件,设置
has_minmaxoffer = true
⑤ 若无 minmaxoffer,执行其他 diy_offer(556-558 行)
if (empty($return['has_minmaxoffer'])) {
(new DiyOfferService())->cartDiyOffers($return, ['promotion','bundlesale','skubundlesale']);
}⑥ gift 赠品(572-575 行)
if (empty($return['has_minmaxoffer'])) {
(new DiyOfferService())->cartDiyOffers($return, ['gift']);
}minmaxoffer 独占:一旦开启,跳过后续所有 bundlesale/skubundlesale/gift
⑦ 库存检查(547 行)
$return['has_out_of_stock'] = $this->checkCartSkuStock($return) ? false : true;⑧ 标准满减 o_promotion(548-550 行)
$promotion = (new PromotionHandlerService())->getCartPromotion($return);
$return['promotion'] = $promotion;
$return['promotion_price'] = price_format(
array_sum(array_column($promotion, 'discount')) // o_promotion 满减
+ array_sum(array_column($return['diy_offers'] ?? [], 'discount')) // bundlesale/skubundlesale
);无论是否有 minmaxoffer,o_promotion 始终执行
7.5 阶段五:优惠券(554-555 行)
$return = (new CouponHandlerService())->calPreCouponPrice($return); // 预券,读 Cache
$return = (new CouponHandlerService())->checkCouponUseWithPromotionStatus($return); // REPLACE_WITH_PROMOTION 时清 promotion_price7.6 阶段六:有订单时的 Reconcile(596-708 行)
进入条件:has_contact_information = true 或 $orderInfo 存在
| 步骤 | 说明 |
|---|---|
| 校验券 | CouponService::checkCartCoupon 重新验证 coupon 是否仍可用,变化则写 $order_price_update |
| 重算税 | TaxService::getCartTaxes 按国家/省份重算 |
| promotion_price 变化检测 | 对比实时值与 DB 值,差异则写回 o_order.current_promotion_price |
| 物流方式校验 | 重新拉可用的 shipping methods,原选方案不可用则清 has_shipping_method |
| 写回订单 | saveProducts → o_order_product;renew → 重算 total_price |
| order_diy_offer | OrderDiyOfferService::updateDiyOfferInfo(积分/Seel 等) |
7.7 阶段七:总价汇总(714-749 行)
$return['total_price'] += shipping_price
+= payment_price
+= tip_price
+= tax_price
+= coupon_price // 负
+= insurance_price
+= promotion_price // 负(含 o_promotion + bundlesale/skubundlesale discount)
+= diy_offer_price // 可正可负(积分负、Seel 正)同时用店铺主货币重算 $return['total_price_currency']。
7.8 流程图
getList(customerId, checkoutToken)
│
├─ 有token → 读Redis checkoutcart → 判断type
│ ├─ CART → 读Redis购物车(guestcart/customercart)
│ ├─ BUYNOW/INSTANT/PAYPAL_EC → 解析cart_data JSON
│ └─ ORDER → 查o_order,恢复所有current_*字段
│
├─ 无token → 读Redis游客/顾客购物车
│
├─ 同步:compareCartData + cartStructBuildFromOrder
│
├─ cartDataComposition → 组装商品数据+属性+价格
│
├─ minmaxoffer(如命中,跳过后续所有diy_offer)
├─ promotion/bundlesale/skubundlesale(无minmax时)
├─ gift(无minmax时)
├─ checkCartSkuStock → 库存检查
├─ getCartPromotion → o_promotion满减(始终执行)
├─ promotion_price = o_promotion discount + diy_offers discount
│
├─ calPreCouponPrice → 预券
├─ checkCouponUseWithPromotionStatus → 替换型券清promotion_price
│
├─ 有订单时 reconcile:
│ ├─ checkCartCoupon → 验券
│ ├─ getCartTaxes → 重算税
│ ├─ promotion_price变化 → 写回o_order
│ ├─ saveProducts → 写o_order_product
│ ├─ renew → 重算订单总价
│ └─ updateDiyOfferInfo → order_diy_offer积分/Seel
│
└─ total_price汇总 → return
7.9 各 checkout 形态的 getList 差异
| 对比项 | 在线(标准/单页/渐进式) | COD token | COD 单页 |
|---|---|---|---|
| 商品来源 | Redis checkoutcart | Redis checkoutcart | CodCartDto(请求构造) |
| o_promotion | ✅ 执行 | ✅ 执行 | ✅ 执行 |
| diy_offer 全类型 | ✅ | ✅ | ✅ |
| checkCouponUseWithPromotionStatus | ✅ | ❌ 未调用 | ❌ 未调用 |
| REPLACE_WITH_PROMOTION 处理 | 清 promotion_price | ❌ 无处理,可能双计 | ❌ 无处理,可能双计 |
| order_diy_offer | ✅ 支持(积分/Seel) | ❌ 不支持 | ❌ 不支持 |
| reconcile o_order | ✅ saveProducts + renew | ❌ 仅 saveOrder 一次写 | ❌ 仅 saveOrder 一次写 |
详见 promotions-by-checkout-type.md §3、§4。
8. 进入结账:getCheckoutUrl
getCheckoutUrl($customer_id, $step, $checkout_cart_data)(1118 行)
| 步骤 | Redis / 逻辑 |
|---|---|
| 1 | 取 cart_token(guest 或 customer) |
| 2 | 读已有 order by checkout_token → 可能 renew token |
| 3 | 写/更新 checkoutcart:{token} = { type: cart, cart_token } |
| 4 | BeginCheckout 事件 |
| 5 | CheckoutService::generateCheckoutUrl → 各形态 URL |
home:POST /cart → redirect checkout URL。
9. buynow / instant 与 checkoutcart type
| 入口 | 方法 | checkoutcart |
|---|---|---|
| Buy Now | createBuynow | 新 token;type=buynow,cart_data 内嵌 |
| Instant | CartHandlerService::createInstant | type=instant_checkout |
| PayPal EC | 支付 Script | type=paypal_ec |
特点:不指向 guestcart;getCheckoutVisitId(true) 新 token;TTL 7 天。
10. 登录合并游客 cart
会员登录后合并(约 1050–1102 行):
- 读
guestcart+customercart - 按
buildVariantUniqKey合并 quantity - 写
customercart - delete
guestcart - 若存在 checkoutcart,更新
cart_token指向 customercart
11. 清理与 TTL
| 场景 | 动作 |
|---|---|
clearCart(customerId) | del guest/customer cart + removeCheckoutVisitId |
clearCartByCheckoutToken | del cart_token 指向的 cart |
| 支付成功 | 常 clearCartByCheckoutToken + clearCart |
| 空 cart saveProducts | clearCartByCheckoutToken + remove cookie |
常量:GUEST_DATA_EXPIRE / CUSTOMER_DATA_EXPIRE = 90 天;BUYNOW_DATA_EXPIRE = 7 天。
12. 前台 API 一览
homeapi app/homeapi/controller/Cart.php(路由 Route::rule('/cart/:action_name', ...))
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /cart/add | 加购 JSON body |
| POST | /cart/batched | 批量加购 |
| PUT | /cart/update | 改数量 |
| DELETE | /cart/remove | 删行 |
| GET | /cartlist | 列表(getList 无 token) |
| GET | /cart/mini | 迷你 cart |
| GET | /cart/data | cart 数据 |
| POST | /cart/diyoffers | diy_offer 活动加购 |
| GET | /cart | 订单合计等 |
home:POST /cart → getCheckoutUrl 跳转结账。
加购后通常 getList($customerId) 返回含计价结果的 cart JSON(CartListResponse 格式化)。
13. 与订单同步(checkout 阶段)
当 checkoutcart.type=order 且 cart_token 仍等于当前用户 cart key:
compareCartData(redisCart, orderProducts)- 若 Redis 更新 → 用 Redis 行 刷新 order 商品(497–518 行)
getListreconcile 块(556–708 行)写回o_order与saveProducts
保证:用户在结账页外改 cart 时,下次 getList 可把变化同步到未支付订单(条件满足时)。
14. 排障
| 现象 | 排查 |
|---|---|
| 加购成功结账空 | checkoutcart 过期;buynow 用错 token |
| 两浏览器 cart 不同 | guestcart 绑 visit_id Cookie |
| 价格与商品页不一致 | 正常:cart 含活动;查 getList 链 |
| 行被 silently 删除 | cartDataComposition 过滤下架/无 variant |
| 付完款 cart 还在 | 查 clearCart 是否执行;checkoutcart TTL 未到期 |
| COD cart 与在线不同 | COD 用 CodCartService,不 reconcile o_order |
15. 代码索引
| 职责 | 路径 |
|---|---|
| 购物车主 Service | common/services/CartService.php |
| COD 读 cart + 计价 | common/services/CodCartService.php |
| Redis Key | extend/helper/CacheKeyHelper.php |
| homeapi Controller | app/homeapi/controller/Cart.php |
| 设计注释 | extend/shoppingProcess/cartLogic.php |
| 优惠顺序 | promotions-by-checkout-type.md |
| checkoutcart 说明 | faq.md |