购物车处理逻辑

本文详细说明前台 购物车 从加购到进入结账的 Redis 结构Service 处理过程API 及与 checkoutcart 的关系。token 与 ID 见 cart-checkout-token-visit-id.md

与实现冲突时以 CartServicecommon/services/CartService.php)为准。COD 结账复用 Redis 读商品,计价走 CodCartService(见 promotions-by-checkout-type.md)。

目录


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 JSON90 天行数组
{store}:customercart:{customer_id}string JSON90 天行数组
{store}:checkoutcart:{checkout_token}string JSONcart 型 90 天;buynow 7 天见 §2.1

visit_id = md5(global_visit_id Cookie)checkout_token = md5(checkout_visit_id Cookie)

2.1 checkoutcart 对象

type字段商品来源
cartcart_token → 完整 key 字符串指向 guest/customer cart
buynow / instant_checkout / paypal_eccart_data JSON 字符串内嵌行数组
ordercart_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活动/赠品控制

不存储pricefinal_pricepromotion_id 等——在 cartDataComposition / getList 时查商品库计算。


4. 加购与改购

4.1 addToCart

CartService::addToCart($customerId, $productId, $skuCode, $qty, $dataFrom, $property)(72 行)

步骤动作
1cartAddPermissionCheck(库存、上下架、行数上限 50)
2buildVariantUniqKey(product_id + sku_code + property)
3getCartListData 读 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_limitedtimeofferends_at 过期 → 清 diy_offer_id
属性价formatProperty + 计入 price/final_price
汇总item_countsubtotal_price_currencyitems[]
过滤无效行下架/无变体行丢弃;若行数变少 回写 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_ECcheckoutcart.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

  1. compareCartData(redisCart, cart_list_data) 比较是否一致
  2. 不一致则用 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_codeProductVariantModel
限时促销过期判断data_from=app_limitedtimeofferends_at < time() → 清 diy_offer_id
组装属性价格formatProperty → 计入 price / final_price
汇总item_countsubtotal_price_currencyitems[]
过滤下架/无变体行丢弃;若行数变少 回写 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_price

7.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
写回订单saveProductso_order_productrenew → 重算 total_price
order_diy_offerOrderDiyOfferService::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 tokenCOD 单页
商品来源Redis checkoutcartRedis checkoutcartCodCartDto(请求构造)
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 / 逻辑
1cart_token(guest 或 customer)
2读已有 order by checkout_token → 可能 renew token
3写/更新 checkoutcart:{token} = { type: cart, cart_token }
4BeginCheckout 事件
5CheckoutService::generateCheckoutUrl → 各形态 URL

home:POST /cart → redirect checkout URL。


9. buynow / instant 与 checkoutcart type

入口方法checkoutcart
Buy NowcreateBuynow token;type=buynowcart_data 内嵌
InstantCartHandlerService::createInstanttype=instant_checkout
PayPal EC支付 Scripttype=paypal_ec

特点:指向 guestcart;getCheckoutVisitId(true) 新 token;TTL 7 天


10. 登录合并游客 cart

会员登录后合并(约 1050–1102 行):

  1. guestcart + customercart
  2. buildVariantUniqKey 合并 quantity
  3. customercart
  4. delete guestcart
  5. 若存在 checkoutcart,更新 cart_token 指向 customercart

11. 清理与 TTL

场景动作
clearCart(customerId)del guest/customer cart + removeCheckoutVisitId
clearCartByCheckoutTokendel cart_token 指向的 cart
支付成功clearCartByCheckoutToken + clearCart
空 cart saveProductsclearCartByCheckoutToken + 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/datacart 数据
POST/cart/diyoffersdiy_offer 活动加购
GET/cart订单合计等

homePOST /cartgetCheckoutUrl 跳转结账。

加购后通常 getList($customerId) 返回含计价结果的 cart JSON(CartListResponse 格式化)。


13. 与订单同步(checkout 阶段)

checkoutcart.type=ordercart_token 仍等于当前用户 cart key:

  1. compareCartData(redisCart, orderProducts)
  2. 若 Redis 更新 → 用 Redis 行 刷新 order 商品(497–518 行)
  3. getList reconcile 块(556–708 行)写回 o_ordersaveProducts

保证:用户在结账页外改 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. 代码索引

职责路径
购物车主 Servicecommon/services/CartService.php
COD 读 cart + 计价common/services/CodCartService.php
Redis Keyextend/helper/CacheKeyHelper.php
homeapi Controllerapp/homeapi/controller/Cart.php
设计注释extend/shoppingProcess/cartLogic.php
优惠顺序promotions-by-checkout-type.md
checkoutcart 说明faq.md

相关:cart-checkout-token-visit-id.md