COD 带 checkout_token 结账链路说明

本文档说明 带 checkout_token 的 COD 结账(/{store}/cod-checkouts/{checkout_token}):购物车 / buynow 进入 COD 着陆页,在同一页选地址、物流、券后 一次 POST 下单。与 cod-one-page-checkout.md(无 token、商品维单页)并行存在。

与当前仓库代码一致;冲突时以代码为准。

相关:cart-checkout-token-visit-id.md、cod-shipping-zone-plan.md、promotion-discount-checkout.md(COD 计价差异)。

目录


1. 背景与目标

  • 店铺配置 cod_checkout_type 为 仅 COD 或 COD 首选 时,购物车 / buynow 入口会落到 cod-checkouts/{checkout_token},而非在线 checkouts/。
  • 货到付款:无在线支付网关;订单写入 o_cod_order 系列表,不写 o_order。
  • 单 POST 创单:地址 + 物流 + 券 + 备注一次提交;无分步 presave / email 创单。
  • 计价在 内存 CodCartDto 完成;不读 o_order、不走 CartService::getList 的订单 reconcile。
  • 不写 o_order_diy_offer(积分/Seel 等订单级插件不支持 COD token 链路)。

2. 入口:checkout_token 与 URL

2.1 token 来源(与在线相同)

入口说明
POST /cartCartService::getCheckoutUrl() → 写 Redis checkoutcart
Buy NowCartService::createBuynow()
InstantGET /express/checkout

token / Redis 结构见 cart-checkout-token-visit-id.md。

2.2 URL 如何变成 COD

CheckoutService::generateCheckoutUrlByCodConfig() 读取 storeConfig.cod_checkout_type:

值含义默认跳转
1 ONLY_ONLINE仅在线在线 checkout URL
2 ONLY_COD仅 COD/cod-checkouts/{token}
3 FIRST_ONLINE在线首选在线 URL;页面可切换 COD
4 FIRST_CODCOD 首选COD URL;页面可切换在线

快捷支付(Express)强制在线 URL(isExpressPayment)。

已存在 COD 单:generateCheckoutUrlByCodOrder() 若 o_cod_order 已有该 token → 固定 COD URL。

2.3 URL 形态

/{storeId}-{token前6位}/cod-checkouts/{checkout_token}

成功页:

/cod-checkouts/success/{checkout_token}

(home 路由组,无 store_random 前缀)

2.4 在线 / COD 双入口(可选)

SSR 首屏传入 checkout_url_array(CheckoutService::generateCheckoutUrlArrayByCodConfig):

{
  "online_checkout_url": "/{store}-{rand}/checkouts/{token}",
  "cod_checkout_url": "/{store}-{rand}/cod-checkouts/{token}"
}

已有 COD 订单后 返回空数组,不允许切换。


3. 业务流程总览

sequenceDiagram
    participant U as 用户
    participant H as home/OrderCodPage
    participant API as homeapi/OrderCodPage
    participant CS as CodCartService
    participant OH as CodOrderHandlerService

    U->>H: GET cod-checkouts/{token}
    H->>CS: getCartList(仅 Redis)
    H-->>U: Liquid checkout 单页

    U->>API: GET shippings?country_id=
    API->>CS: getCartList
    API-->>U: 物流列表

    U->>API: GET tax?country_id=&province_id=
    API-->>U: 税费试算

    U->>API: POST use-coupon(可选)
    API-->>U: 券结果 + Cache 预券

    U->>API: POST /(saveOrder)
    API->>OH: saveOrder
    OH-->>U: checkout_url → success

与在线三种的核心差异:

维度COD token在线 standard 等
订单表o_cod_ordero_order
创单一次 POST分步 / presave / email
购物车数据源仅 RedisRedis + 有单时 MySQL reconcile
支付无 gatewaypayment_gateway
order_diy_offer无shipping / complete 写入

4. home 页面(SSR)

路由:GET /:store_random/cod-checkouts/:checkout_token
Controller:app/home/controller/OrderCodPage.php::index

顺序动作RedisMySQL
1CodCartService::getCartList读 checkoutcart + guest/customer cart 或 cart_data不读
2CodOrderModel::getOrderInfoByCheckToken—若 已有 COD 单 → redirect success
3CheckoutService::generateCheckoutUrlArrayByCodConfig——
4Liquid 模板 checkout——

传入模板:cart(CodCartDto toArray)、tip_setting、checkout_token、checkout_url_array。

成功页:GET /cod-checkouts/success/:checkout_token → OrderCodPage::success → CodOrderService::orderFetch。


5. homeapi 路由与 API

前缀:/:store_random/cod-checkouts/:checkout_token
Controller:app/homeapi/controller/OrderCodPage.php

5.1 路由一览

方法路径后缀Controller 方法写库
GET/shippingsgetShippingMethods否
GET/taxgetTaxPrice否
GET/address/:type/:country_id/:pathgetAddressList否
GET/shipping-addongetShippingInfoAddon否
POST/(根)saveOrder是
POST/use-couponuseCoupon否(写 Cache 预券)
POST/cancel-couponcancelCoupon否

5.2 GET shippings(物流列表)

Query(必填):

参数类型说明
country_idint收货国家

流程:

CodCartService::getCartList(customerId, checkout_token)
→ CodShippingZoneHandlerService::getShippingMethodsByCountryId(cartDto, countryId)

Redis:读 checkoutcart、guest/customer cart。
MySQL:读 COD 物流区域/方案配置(CodShippingZoneAreaService、CodShippingZonePlanService),运费计算见 cod-shipping-zone-plan.md。

响应:outputJson($shippingMethods) — 物流方案数组(含 shipping_id、价格等)。

调用示例:

GET /{store_random}/cod-checkouts/{checkout_token}/shippings?country_id=840

5.3 GET tax(税费试算)

Query:

参数类型
country_idint
province_idint(可选)

流程:

CodCartService::getCartList
→ TaxService::getCartTaxes(cartDto.toArray(), countryId, provinceId)

响应:税费结构(含 tax_price 等)。


5.4 GET address(711 / 全家等三级地址)

路径参数:type、country_id、path
Service:CodAddressService::getAddressList
响应:经 OrderCodAddressListResponse 格式化。


5.5 GET shipping-addon(附属信息)

Query:country_id
Service:CodCheckoutInfoService::getShippingInfoAddon
响应:结账附加字段配置(随国家变化)。


5.6 POST use-coupon(用券)

Body(JSON):

{ "code": "SAVE10" }

流程:

CodCartService::getCartList
→ CodCouponService::useCoupon(cartDto, code)
→ Cache::set(customerIdentity, couponCode, 3个月)

注意:COD 用券 未走在线的 checkCouponUseWithPromotionStatus;与满减叠加行为见优惠文档排障。

响应:校验成功时含 id、price 等;失败 { code: -1, msg: "..." }。


5.7 POST cancel-coupon(取消券)

流程:CouponHandlerService::delPreCouponCode() → 返回最新 getCartList().toArray()。


5.8 POST saveOrder(下单)

Body(JSON,CheckoutCodPageRequest 校验):

{
  "order_info": {
    "checkout_token": "{必须与 URL token 一致}"
  },
  "shipping_address": {
    "email": "user@example.com",
    "first_name": "John",
    "last_name": "Doe",
    "country_id": 840,
    "province": "NY",
    "address1": "123 Main St",
    "city": "NY",
    "zip": "10001",
    "phone": "+1...",
    "contact": "optional",
    "address_ext": {}
  },
  "trans_info": {
    "shipping_id": 123,
    "coupon_code": "SAVE10",
    "tip_price": 2.0,
    "note": "备注",
    "checkoutinfo": {}
  }
}

幂等:若 o_cod_order 已存在该 checkout_token → 直接返回 success URL,不重复创单。

流程(CodOrderHandlerService::saveOrder 32–97 行):

顺序ServiceMySQL
1黑名单 / ReqLimiter—
2CodCustomerService::saveCustomero_customer
3CodOrderService::saveOrdero_cod_order(checkout_type=cod)
4OrderService::checkFirstOrder—
5CodOrderProductService::saveOrderProductso_cod_order_product, o_cod_order_product_property
6CodOrderShippingAddressService::saveOrderShippingAddresso_cod_order_shipping_address
7CodOrderShippingZoneplanService::saveOrderShippingZonePlano_cod_order_shipping_zone_plan
8CodOrderCustomerSysInfoService::saveCustomerSysInfoo_cod_order_customer_sysinfo
9CodOrderUtmService::saveOrderUtmo_cod_order_utm
10CodOrderTagService::saveOrderTago_cod_order_tag, o_cod_order_tag_rel
11CodOrderCouponService::couponSubtract扣券库存 + del 预券 Cache

事务:上述在 Db::startTrans() 内;失败 rollback + 限流计数 decr。

Redis 清理(下单成功且 cookie checkout_token 匹配):

removeCheckoutVisitId()
CartService::clearCart(customerId)   // 删 guest/customer cart

不调用 OrderService::saveCheckoutCart;不改 checkoutcart type(在线创单才改 order)。

事件:

  • codOrderSubmitSuccess
  • codOrderPaid

响应:

{
  "code": 0,
  "data": {
    "checkout_url": "/cod-checkouts/success/{checkout_token}"
  }
}

6. CodCartService 计价(内存)

getCartList()(20–61 行)只读 Redis,组装 CodCartDto:

getCartListDataByCheckoutToken     // checkoutcart → cart_token 或 cart_data
→ codCartDataComposition           // 商品详情、变体、属性价
→ defaultDiyOffer(minmaxoffer)
→ if !has_minmaxoffer: cartDiyOffers(promotion, bundlesale, skubundlesale, gift)  // 同轮
→ PromotionHandlerService::getCartPromotion
→ CodCouponService::calPreCouponPrice   // 读 Cache 预券
→ cartBuildEnding

与在线 CartService::getList 差异:

  • 不读 o_order、无 reconcile 写库
  • gift 与 bundlesale 同轮(在线 gift 单独第二轮)
  • 无 updateDiyOfferInfo、无税费写库(税费在 saveOrder 时 orderInitTaxPriceHandler 算入 o_cod_order)

7. Redis 交互汇总

Key时机
{store}:checkoutcart:{token}进入 COD 页、所有 GET/POST 读商品来源
{store}:guestcart:{visit_id} / customercart:{id}type=cart 时经 cart_token 读
buynow/instantcheckoutcart 内 cart_data JSON
预券 Cache(CouponHandlerService::getCustomerIdentity())use-coupon 写;calPreCouponPrice 读;下单后 del
{store}:coupons:id:{id}券详情
ReqLimiter order_codsaveOrder 限流
customer cache注册/更新顾客后 del

TTL:与在线 cart 相同(guest/customer 90 天,buynow checkoutcart 7 天)。


8. MySQL 表汇总

阶段表
saveOrdero_cod_order
商品o_cod_order_product, o_cod_order_product_property
地址o_cod_order_shipping_address
物流o_cod_order_shipping_zone_plan
顾客o_customer
系统信息o_cod_order_customer_sysinfo
UTM / Tago_cod_order_utm, o_cod_order_tag, o_cod_order_tag_rel
券o_coupon 扣减(via CouponModel::useCoupon)

不存在:o_order、o_order_diy_offer、o_order_billing_address(COD 无在线账单地址表)。


9. 可用优惠(promotion / coupon / diy_offer / order_diy_offer)

总对照见 promotions-by-checkout-type.md。

体系COD 带 token
diy_offer✅ CodCartService::getCartList(gift 与 bundlesale 同轮)
promotion✅ getCartPromotion
coupon✅ POST use-coupon → Cache;⚠️ 无 checkCouponUseWithPromotionStatus
order_diy_offer❌ 不支持(无积分/Seel)

顺序(内存 DTO,不写 o_order):行价 → minmaxoffer → diy_offer+ gift → 满减 → CodCouponService::calPreCouponPrice → saveOrder 一次落库 o_cod_order / o_cod_order_product。

落库字段:current_promotion_price、current_coupon_price、current_offer_price(cart 级 diy 折扣,非 o_order_diy_offer 表)。


9. 与 COD 单页(无 token)对比

维度COD token(本文)COD 单页 cod-one-page-checkout.md
入口购物车 / buynow + tokenDiy cod_page 商品着陆
API 前缀/cod-checkouts/{token}/cod-one-page-checkouts/shippings、/order
商品来源Redis checkoutcart请求 product_info 临时构造
checkout_token进入结账前已有服务端 getCheckoutVisitId(true) 生成
下单 ServiceCodOrderHandlerService::saveOrder同核心,经 CodOnePageOrderHandlerService 编排
漏斗事件以 codOrderSubmitSuccess 为准额外 AddToCart、BeginCheckout

两套 并行,按需选用。


10. 相关文件索引

模块路径
home 页面app/home/controller/OrderCodPage.php
homeapi APIapp/homeapi/controller/OrderCodPage.php
下单编排common/services/CodOrderHandlerService.php
COD 订单写common/services/CodOrderService.php
购物车 DTOcommon/services/CodCartService.php
物流common/services/CodShippingZoneHandlerService.php
券common/services/CodCouponService.php
Requestapp/homeapi/req/CheckoutCodPageRequest.php
URL / 双入口common/services/CheckoutService.php
home 路由app/home/route/route.php(131–137 行)
homeapi 路由app/homeapi/route/route.php(165–173 行)

11. 联调备忘

  • order_info.checkout_token 必填且与 URL 一致(CheckoutCodPageRequest)。
  • 返回「Cart is empty」:查 Redis checkoutcart 是否过期、商品是否下架、checkoutcart.type 与 cart_token 是否有效。
  • 已有 COD 单时 GET 页与 POST 均 redirect / 返回 success,不可用同一 token 再下单。
  • 税费:先 GET /tax 试算展示;最终以 saveOrder 内 orderInitTaxPriceHandler 写入 o_cod_order.current_tax_price 为准。
  • 切换在线/COD:仅未创单时 checkout_url_array 有效;创单后 URL 锁定。
  • 限流 key:order_cod(IP + 店铺维度 firewall)。

在线结账对比:standard-checkout-flow.md