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. 背景与目标
- 2. 入口:checkout_token 与 URL
- 3. 业务流程总览
- 4. home 页面(SSR)
- 5. homeapi 路由与 API
- 6. CodCartService 计价(内存)
- 7. Redis 交互汇总
- 8. MySQL 表汇总
- 9. 可用优惠(promotion / coupon / diy_offer / order_diy_offer)
- 9. 与 COD 单页(无 token)对比
- 10. 相关文件索引
- 11. 联调备忘
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 /cart | CartService::getCheckoutUrl() → 写 Redis checkoutcart |
| Buy Now | CartService::createBuynow() |
| Instant | GET /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_COD | COD 首选 | 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_order | o_order |
| 创单 | 一次 POST | 分步 / presave / email |
| 购物车数据源 | 仅 Redis | Redis + 有单时 MySQL reconcile |
| 支付 | 无 gateway | payment_gateway |
| order_diy_offer | 无 | shipping / complete 写入 |
4. home 页面(SSR)
路由:GET /:store_random/cod-checkouts/:checkout_token
Controller:app/home/controller/OrderCodPage.php::index
| 顺序 | 动作 | Redis | MySQL |
|---|---|---|---|
| 1 | CodCartService::getCartList | 读 checkoutcart + guest/customer cart 或 cart_data | 不读 |
| 2 | CodOrderModel::getOrderInfoByCheckToken | — | 若 已有 COD 单 → redirect success |
| 3 | CheckoutService::generateCheckoutUrlArrayByCodConfig | — | — |
| 4 | Liquid 模板 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 | /shippings | getShippingMethods | 否 |
| GET | /tax | getTaxPrice | 否 |
| GET | /address/:type/:country_id/:path | getAddressList | 否 |
| GET | /shipping-addon | getShippingInfoAddon | 否 |
| POST | /(根) | saveOrder | 是 |
| POST | /use-coupon | useCoupon | 否(写 Cache 预券) |
| POST | /cancel-coupon | cancelCoupon | 否 |
5.2 GET shippings(物流列表)
Query(必填):
| 参数 | 类型 | 说明 |
|---|---|---|
country_id | int | 收货国家 |
流程:
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=8405.3 GET tax(税费试算)
Query:
| 参数 | 类型 |
|---|---|
country_id | int |
province_id | int(可选) |
流程:
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 行):
| 顺序 | Service | MySQL |
|---|---|---|
| 1 | 黑名单 / ReqLimiter | — |
| 2 | CodCustomerService::saveCustomer | o_customer |
| 3 | CodOrderService::saveOrder | o_cod_order(checkout_type=cod) |
| 4 | OrderService::checkFirstOrder | — |
| 5 | CodOrderProductService::saveOrderProducts | o_cod_order_product, o_cod_order_product_property |
| 6 | CodOrderShippingAddressService::saveOrderShippingAddress | o_cod_order_shipping_address |
| 7 | CodOrderShippingZoneplanService::saveOrderShippingZonePlan | o_cod_order_shipping_zone_plan |
| 8 | CodOrderCustomerSysInfoService::saveCustomerSysInfo | o_cod_order_customer_sysinfo |
| 9 | CodOrderUtmService::saveOrderUtm | o_cod_order_utm |
| 10 | CodOrderTagService::saveOrderTag | o_cod_order_tag, o_cod_order_tag_rel |
| 11 | CodOrderCouponService::couponSubtract | 扣券库存 + del 预券 Cache |
事务:上述在 Db::startTrans() 内;失败 rollback + 限流计数 decr。
Redis 清理(下单成功且 cookie checkout_token 匹配):
removeCheckoutVisitId()
CartService::clearCart(customerId) // 删 guest/customer cart
不调用 OrderService::saveCheckoutCart;不改 checkoutcart type(在线创单才改 order)。
事件:
codOrderSubmitSuccesscodOrderPaid
响应:
{
"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/instant | checkoutcart 内 cart_data JSON |
预券 Cache(CouponHandlerService::getCustomerIdentity()) | use-coupon 写;calPreCouponPrice 读;下单后 del |
{store}:coupons:id:{id} | 券详情 |
ReqLimiter order_cod | saveOrder 限流 |
| customer cache | 注册/更新顾客后 del |
TTL:与在线 cart 相同(guest/customer 90 天,buynow checkoutcart 7 天)。
8. MySQL 表汇总
| 阶段 | 表 |
|---|---|
| saveOrder | o_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 / Tag | o_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 + token | Diy cod_page 商品着陆 |
| API 前缀 | /cod-checkouts/{token} | /cod-one-page-checkouts/shippings、/order |
| 商品来源 | Redis checkoutcart | 请求 product_info 临时构造 |
| checkout_token | 进入结账前已有 | 服务端 getCheckoutVisitId(true) 生成 |
| 下单 Service | CodOrderHandlerService::saveOrder | 同核心,经 CodOnePageOrderHandlerService 编排 |
| 漏斗事件 | 以 codOrderSubmitSuccess 为准 | 额外 AddToCart、BeginCheckout |
两套 并行,按需选用。
10. 相关文件索引
| 模块 | 路径 |
|---|---|
| home 页面 | app/home/controller/OrderCodPage.php |
| homeapi API | app/homeapi/controller/OrderCodPage.php |
| 下单编排 | common/services/CodOrderHandlerService.php |
| COD 订单写 | common/services/CodOrderService.php |
| 购物车 DTO | common/services/CodCartService.php |
| 物流 | common/services/CodShippingZoneHandlerService.php |
| 券 | common/services/CodCouponService.php |
| Request | app/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