活动 / 优惠券 / diy_offer 优惠体系
本文档梳理前台购物车与结账流程中,四套彼此独立、命名易混 的优惠体系:类型枚举、计算顺序、叠加/互斥规则、代码入口与订单字段映射。与实现冲突时以代码为准。
相关文档:
- checkout-flows.md — 四种下单方式(标准 / 单页 / 渐进式 / COD)逻辑关系与主流程
- checkout-flows-detail.md — 各形态 API 逐步、落库时机
- cod-one-page-checkout.md — COD 单页着陆(无 token)子场景
目录
- 1. 四套体系总览
- 2. 计算顺序与架构
- 3.
o_order_diy_offer— 订单级附加项 - 4.
o_diy_offer— 购物车插件活动 - 5.
o_promotion— 标准满减活动 - 6.
o_coupon— 优惠券 - 7. 叠加 / 互斥矩阵
- 8. Cart / Checkout 与四种下单方式
- 9. 运费折扣(独立模块)
- 10. 关键代码索引
- 11. 重构建议附录
- 12. Redis / Cache Key 索引
- 13. 前台 API 端点速查
- 14. 排障清单
- 15. 可进一步拆分的子文档
1. 四套体系总览
| 体系 | 数据表 | 购物车/订单字段 | 作用域 |
|---|---|---|---|
| 购物车插件活动 | o_diy_offer | 行上 diy_offer_id;汇总进 promotion_price | 商品行 / 购物车结构 |
| 标准满减活动 | o_promotion | cart.promotion[];promotion_price / current_promotion_price | 商品 subtotal |
| 优惠券 | o_coupon | coupon_price / current_coupon_price | 商品 subtotal(满减之后) |
| 订单级附加项 | o_order_diy_offer | diy_offer_price / current_offer_price | 订单级(积分、改价、Seel 等) |
另有 运费折扣(o_shipping_discount),只作用于运费方案价格,不参与商品 subtotal 优惠栈。
1.1 命名易混点(重构必读)
| 代码中的名称 | 实际含义 |
|---|---|
cart.diy_offers[] | o_diy_offer 插件活动在购物车阶段的折扣明细 |
cart.diy_offer_price | o_order_diy_offer 汇总金额,写入 order.current_offer_price |
order_diy_offer / OrderDiyOfferService | 订单级附加项,与 DiyOfferService(购物车插件)无关 |
2. 计算顺序与架构
主入口:CartService::getList()(common/services/CartService.php 523–758 行)。
flowchart TD subgraph cartPhase [CartService.getList 商品优惠阶段] A[cartDataComposition 行价组装] --> B[defaultDiyOffer minmaxoffer] B --> C{has_minmaxoffer?} C -->|否| D["cartDiyOffers: promotion / bundlesale / skubundlesale"] C -->|否| E[cartDiyOffers: gift] C -->|是| F[PromotionHandlerService.getCartPromotion] D --> E E --> F F --> G["promotion_price = Σ满减.discount + Σdiy_offers.discount"] G --> H[calPreCouponPrice 预用券] H --> I[checkCouponUseWithPromotionStatus 替换型券处理] end subgraph orderPhase [已有 checkout 订单阶段] I --> J[checkCartCoupon / 税费 / 运费 / 小费] J --> K[OrderDiyOfferService.updateDiyOfferInfo] K --> L[total_price 汇总] end
2.1 固定顺序(谁先谁后)
| 顺序 | 阶段 | 说明 |
|---|---|---|
| 1 | cartDataComposition | 从 Redis/订单组装行价;限时促销过期则清 diy_offer_id |
| 2 | defaultDiyOffer(['minmaxoffer']) | 店铺级订单自定义费用;激活后设 has_minmaxoffer=true |
| 3 | cartDiyOffers(['promotion','bundlesale','skubundlesale']) | 仅当 !has_minmaxoffer |
| 4 | cartDiyOffers(['gift']) | 仅当 !has_minmaxoffer |
| 5 | getCartPromotion | 标准满减(o_promotion);无论是否 minmaxoffer 均执行 |
| 6 | 汇总 promotion_price | 满减 discount + diy_offers[].discount |
| 7 | calPreCouponPrice | 从 Cookie/Cache 预应用券 |
| 8 | checkCouponUseWithPromotionStatus | 替换型券清零满减 |
| 9 | (有订单时)税费、运费、小费、OrderDiyOfferService | 订单级附加项 |
| 10 | total_price 汇总 | 见下文公式 |
代码锚点:
- minmaxoffer 与其他 diy_offer:
CartService.php524–545 行 - 满减与
promotion_price:CartService.php548–550 行 - 优惠券:
CartService.php554–555 行 - 订单级 diy_offer:
CartService.php708 行 - 总价:
CartService.php714–749 行
2.2 总价公式
优惠类字段均为 负数或 0(抵扣)或 正数(附加费,如 Seel)。
total_price = subtotal
+ shipping_price
+ payment_price // 支付手续费
+ tip_price
+ tax_price
+ coupon_price // 负
+ insurance_price
+ promotion_price // 负(含满减 + bundlesale 等 discount)
+ diy_offer_price // 来自 o_order_diy_offer(积分负、Seel 正等)
3. o_order_diy_offer — 订单级附加项
不是购物车活动,是结账/订单阶段的附加费用或抵扣。Model:OrderDiyOfferModel(表 o_order_diy_offer)。调度:OrderDiyOfferService / OrderDiyOfferHandleService。
3.1 已知 from_name 类型
from_name | 含义 | 价格方向 | Handler |
|---|---|---|---|
customer_points | 会员积分抵扣 | 负 | extend/orderdiyoffers/OrderDiyOfferPoints.php |
admin_custom_price | 后台手动改价 | 可正可负 | OrderService::updateOrderPriceDiyOffer() |
app_deliveryprotec | 运输保障插件 | 通常正 | extend/orderdiyoffers/OrderDiyOfferDeliveryprotec.php |
app_seel | Seel 延保/包裹保护 | 通常正 | extend/orderdiyoffers/OrderDiyOfferSeel.php |
扩展机制:OrderDiyOfferService::getExtendObj() 按 from_name 下划线后缀映射 orderdiyoffers\OrderDiyOffer{Type}。
3.2 各 Handler 详细行为
路由规则:OrderDiyOfferService::getExtendObj() — from_name 去掉第一个 _ 前缀后 camelCase → orderdiyoffers\OrderDiyOffer{Type}。
customer_points(积分)
| 项 | 说明 |
|---|---|
| save 入口 | OrderDiyOfferPoints::saveOrderDiyOffer → PointsBalanceService::usePoints/savePoints |
| 参数 | {"customer_points": 1} 使用 / 0 退还 |
| DB 行 | price 负值、values=积分数、key_name/from_name=customer_points |
| 积分基数 | subtotal + coupon + promotion + shipping + tax(不含 insurance/tip/seel/deliveryprotec) |
| Redis | 无 checkout 级 hash;启用时在 order_diy_offers_from_name:{storeId} 注册 |
admin_custom_price(后台改价)
| 项 | 说明 |
|---|---|
| checkout save | 空实现(不在前台流程写入) |
| 持久化 | Admin POST /api/order/updateOrderPriceDiyOffer → OrderService::updateOrderPriceDiyOffer() |
| 二次改价 | 从 subtotal+shipping+…+其他 offer 重算基数,排除自身 admin_custom_price |
app_deliveryprotec / app_seel
| 项 | deliveryprotec | seel |
|---|---|---|
| Redis Key | order_diy_offer:{storeId}{checkoutToken} | order_diy_offer:{storeId}seel:{checkoutToken} |
| PHP 侧 | 仅 hGetAll 读取,写入在主题 JS / 第三方 App | |
| save 流程 | del 旧行 → 读 Redis → insert → 打 order tag | |
| 强制处理 | saveDiyOffer() 始终 merge 这两种 type |
o_order_diy_offer 表字段:id, store_id, order_id, params, title, price, key_name, values, descript, from_id, from_name, created_at, updated_at
3.3 updateDiyOfferInfo vs saveDiyOffer
| 方法 | 触发 | 行为 |
|---|---|---|
saveDiyOffer | 前台 POST offer_from_name | 按 JSON 调各 Handler 写入 |
updateDiyOfferInfo | CartService::getList 有订单时(708 行) | 读 DB 已有 from_name,全量重跑 save(积分金额随订单变化 reconcile) |
3.4 offer_from_name 传参(可多个)
可以一次传多个。 字段类型为 字符串,内容是 JSON 对象(不是数组)。
入口:OrderDiyOfferHandleService::saveDiyOffer → json_decode($offerFromName, true) → OrderDiyOfferService::saveDiyOffer($order, $map)。
JSON 形状:key = from_name,value = 该 Handler 的开关/参数。
{
"customer_points": 1,
"app_seel": 1,
"app_deliveryprotec": 1
}| key(from_name) | value 含义 | 说明 |
|---|---|---|
customer_points | 1 使用积分抵扣 / 0 退还积分 | 由 OrderDiyOfferPoints 处理 |
app_seel | 任意 truthy(后端归一为 1) | 从 Redis seel hash 读价后入库 |
app_deliveryprotec | 同上 | 从 Redis deliveryprotec hash 读价后入库 |
admin_custom_price | — | 不走 前台 offer_from_name;仅 Admin API |
后端强制 merge(OrderDiyOfferService::saveDiyOffer 58–62 行):即使 POST 未带,也会 始终 处理 app_deliveryprotec、app_seel(值为 1)。
传空字符串 / 不传 → 仅跑上述两种 + 无其它前台项。
各形态 POST 位置:
| 形态 | 字段路径 |
|---|---|
| 标准 | shipping_method POST:offer_from_name |
| 渐进式 | shipping-method POST body:offer_from_name |
| 单页 | complete / preComplete:trans_info.offer_from_name(仍为 JSON 字符串) |
示例(标准结账 form):
offer_from_name={"customer_points":1}
示例(积分 + 依赖 Redis 的插件,一次提交):
offer_from_name={"customer_points":1,"app_seel":1}
多类型落库:每种 from_name 各写一行 o_order_diy_offer(同 type 先删后插);current_offer_price = 各行 price 之和。
查询已启用类型:结账页变量 order_offer_from_name ← OrderDiyOfferService::getOfferFromNameList()(Redis order_diy_offers_from_name:{storeId} + 积分规则)。
→ 详见 order-diy-offer-handlers.md §5
3.5 互斥与共存
| 规则 | 说明 |
|---|---|
| 多类型可共存 | 积分 + Seel + 后台改价可同时存在 |
同 from_name 互斥 | 各 Handler save 前先 delOrderOffer,同类型仅保留最新 |
| 积分不可重复用 | 已有 customer_points 记录则抛错 |
| 积分 UI 互斥 | 结账页若已有积分 offer,不再返回 points_rule |
| 已付订单禁退积分 | checkout 阶段 pointsBack() 对 FINANCIAL_ADDITIONAL_PAID 抛错 |
| 与商品优惠独立 | 不参与 promotion_price 计算链,只在 total 最后阶段加入 |
4. o_diy_offer — 购物车插件活动
配置表 o_diy_offer + 范围表 o_diy_offer_range。策略模式:DiyOfferService::getExtendObj($type) → extend/diyoffers/DiyOffers{Ucfirst(type)}.php。
4.1 活动类型与三种改价模式
所有购物车插件活动最终都落入三种 改价模式 之一,重构时应先统一抽象再映射到现有字段:
| 改价模式 | 含义 | 典型 type | 汇总方式 |
|---|---|---|---|
| InlinePrice | 直接改 items[].price/final_price | minmaxoffer、promotion/limitedtimeoffer | 体现在 subtotal;diy_offers[].discount 常为 0 |
| AccruedDiscount | 不改行价,产出负 discount | bundlesale、skubundlesale、o_promotion | 计入 promotion_price |
| LineRebuild | 增删 cart 行、礼品价置 0 | gift | subtotal 变化 + 可能 discount=0 |
| type | 实现类 | DB type | 改价模式 |
|---|---|---|---|
minmaxoffer | DiyOffersMinmaxoffer | minmaxoffer | InlinePrice |
promotion / limitedtimeoffer | DiyOffersPromotion | 均为 promotion | InlinePrice |
bundlesale | DiyOffersBundlesale | bundlesale | AccruedDiscount |
skubundlesale | DiyOffersSkubundlesale | skubundlesale | AccruedDiscount |
gift | DiyOffersGift | gift | LineRebuild |
limitedtimeoffer 无独立 PHP 类:DB
type=promotion,前台用data_from=app_limitedtimeoffer区分;离开意图促销用app_id=226→data_from=app_exitintent。
bundlesale 折扣子类型:fix(固定组合价)、percentage(折扣)、constant(直减)。
4.1.1 diy_offer 的 type=promotion vs 标准满减 o_promotion
二者名字都带 promotion,不是同一套体系:
| 对比项 | o_diy_offer type=promotion | o_promotion 标准满减 |
|---|---|---|
| 数据表 | o_diy_offer + o_diy_offer_range | o_promotion + o_promotion_range |
| 产品形态 | App 插件活动(限时促销、离开意图等) | 店铺后台 营销活动(满额减、满件折等 7 种 type) |
| 绑定方式 | 加购时行上 diy_offer_id + promotion_timer property | 购物车 自动匹配 范围,无需 per-line id |
| 计算类 | DiyOffersPromotion → changeCartPrice | PromotionHandlerService::getCartPromotion |
| 改价方式 | InlinePrice:直接改 items[].final_price | AccruedDiscount:产出负 discount 进 cart.promotion[] |
| 汇总字段 | 多数 不进 promotion_price(discount=0),体现在 subtotal | 计入 promotion_price / current_promotion_price |
| 订单追溯 | order_product.diy_offer_id、data_from=app_limitedtimeoffer 等 | order_product.promotion_id(行上)、满减名 |
| 限时 | 强依赖 ends_at、promotion_timer、Redis promotionsValidity | 活动 starts_at / ends_at |
| 与另一套关系 | 可叠加 标准满减(除非替换型券清满减) | 与 diy promotion 独立计算;捆绑 SKU 可能被 mutex 排除 |
limitedtimeoffer:DB 仍为 type=promotion,用 data_from=app_limitedtimeoffer 区分,无独立 PHP 类。
记忆口诀:
- diy promotion = 插件、绑在行上、改单价、像「单品促销/App 活动」
- o_promotion = 店铺满减引擎、扫整 cart、出 discount、像「满 100 减 10」
→ 类型参数见 diy-offer-types-reference.md §promotion · 满减见 promotion-types-reference.md
4.2 加购全链路
sequenceDiagram participant FE as 前台 participant API as homeapi/Cart.diyOffers participant CS as CartService.diyOffers participant DS as DiyOfferService.formatCartItems participant Redis as Redis cart FE->>API: POST action + products[] API->>CS: diyOffers(customerId, action, products) CS->>CS: cartStructBuild(diy_offer_id, data_from, ends_at) CS->>Redis: 写入原始 cart 行 CS->>DS: formatCartItems 按 type 打标 property DS->>Redis: addtocart 合并 / buynow 覆盖
请求 JSON 形状(CartService::diyOffers):
{
"action": "addtocart | buynow | batchbuynow | reducecart",
"products": [{
"product_id": 123,
"sku_code": "xxx-0-0-0-0-0-0",
"quantity": 1,
"diy_offer_id": 456,
"diy_offer_name": "活动名",
"data_from": "app_bundlesale",
"property": [{"plan_properties_id": 1}],
"second": 3600,
"unmodifiable": 0,
"unavailable": 0
}]
}second→ends_at = time() + second(限时促销倒计时)cartStructBuild():common/services/CartService.php~1452 行
能否一次多个:products[] 多行可各绑不同 diy_offer_id;购物车也可同时存在多行、多活动。计价按 id 分组计算,cart.diy_offers[] 可有多条。限制见 diy-offer-types-reference.md §5(minmax 全局独占、捆绑与满减互斥等)。
各 type 加购差异(formatCartItemInfo):
| type | 前端是否必传 diy_offer_id | formatCartItemInfo 行为 |
|---|---|---|
| minmaxoffer | 否(店铺级 defaultDiyOffer 自动打标) | 设 data_from=app_minmaxoffer、property |
| promotion / limitedtimeoffer | 是 | 追加 promotion_timer property |
| bundlesale | 是(buynow 场景) | 空实现,依赖前端传 data_from |
| skubundlesale | 是 | 仅设 diy_offer_name |
| gift | 是 | unmodifiable=1、property app_gift |
4.3 各 type 详细拆解
4.3.1 minmaxoffer(订单自定义费用)
文件:extend/diyoffers/DiyOffersMinmaxoffer.php
params JSON(validateSaveData 写入 DB):
{
"rule_type": 1,
"rule_min": { "amount": 100.00, "title": "最低客单价" },
"rule_max": { "amount": 500.00, "title": "最高客单价", "lock_max_order_price": 1 },
"hide_fee": 0
}rule_type:1=锁最低 /2=锁最高 /3=同时锁ends_at强制永久;product_range=all
calDiscount 行为:
- 返回
[$diyOfferDiscount=0, $productDiscounts=[]] - 直接改每个 item 的
price/unit_price/final_price/final_line_price - 设 cart 级:
has_minmaxoffer、has_maxoffer、minmaxoffer_diff_price、refreshProductData
边缘:recoverPrice() 不满足条件时恢复原价;PaymentService::lockMaxOrderPrice() 支付阶段修正 subtotal 精度差。
4.3.2 promotion / limitedtimeoffer
文件:extend/diyoffers/DiyOffersPromotion.php
params JSON:
{
"type": "products | collection | all | all_ai",
"data": [{
"id": 123,
"type": "definite_price | discount | reduction",
"value": 10,
"range": [0, 999]
}],
"show_page": ["product_detail", "cart"],
"timer": 30
}折扣算法(getDiscount):
definite_price→ 绝对价discount→ 百分比减reduction→ 固定减价
calDiscount 行为:
- 调
changeCartPrice($cart, 1|2|3)扫描整个 cart.items(不仅当前 diy_offer_id 组) - 找带
promotion_timerproperty 且plan_properties_id < 0的行改价 - 校验失败则清:
diy_offer_id=0、property=[]、data_from='' - 返回 discount=0,不走
cart.diy_offers[]
过期:cartDataComposition 834 行 + changeCartPrice 内双重校验 ends_at。
4.3.3 bundlesale(捆绑组合销售)
文件:extend/diyoffers/DiyOffersBundlesale.php
params JSON:
{
"products": [
{ "product_id": 1, "num": 2, "master": 1 },
{ "product_id": 2, "num": 1, "master": 0 }
],
"discount_type": "fix | percentage | constant",
"discount_value": 20,
"discount_rule": "all | partial"
}discount_rule=all:数量必须 精确等于numdiscount_rule=partial:数量 ≥num即参与
calDiscount 流程:
calTotalDiscount()校验数量 → 算总折扣(负值)- 折扣为 0 →
filterCartItemOfferInfo()清diy_offer_id/data_from calItemDiscount()按final_line_price升序逆序分摊- 写入
cart.diy_offers[]+mutexPromotionVariantUniqKey - 不改
final_price;折扣经OrderPriceService摊到discount_price
4.3.4 skubundlesale(多变体捆绑)
文件:extend/diyoffers/DiyOffersSkubundlesale.php
params JSON:
{
"products": [{ "product_id": 1 }, { "product_id": 2 }],
"packages": [{ "num": 3, "discount_type": "fix", "discount_value": 50 }]
}与 bundlesale 类似,但 filterCartItemOfferInfo 故意不清 diy_offer_id(2024-08 需求:无优惠也保留归因统计)。
4.3.5 gift(赠品)
文件:extend/diyoffers/DiyOffersGift.php
params JSON:
{
"discount_type": 1,
"no_limit": 0,
"rules": [{
"condition": 100,
"product_num": 1,
"products": [{ "id": 999 }]
}]
}discount_type:1=按金额 /2=按件数no_limit=1:每满 condition 送 product_num 件
calDiscount 流程(最复杂):
getDiyOfferGifts()按 range 统计 cart,匹配 rules- 删除 cart 中同
diy_offer_id的旧 gift 行 - 重建 gift 行:可买(价 0)/ 不可买(
unavailable=1) - 返回
[0, []];强制refreshProductData+ Redis reset
注意:getDiyOfferGifts 中 switch 分支 无 break(all→collection→products 累加),疑似 bug,重构时需单测覆盖。
4.4 cartDiyOffers 编排细节
DiyOfferService::cartDiyOffers()(427 行):
- 初始化
mutexPromotionVariantUniqKey、diy_offers - 按行
diy_offer_id分组 - 每组调
calDiscount() - discount≠0 → 写入
cart.diy_offers[] - bundlesale/skubundlesale → merge
mutexPromotionVariantUniqKey - 活动删除 / gift →
resetCartListData()回写 Redis
过期 / Reset 汇总:
| 场景 | 处理位置 | 行为 |
|---|---|---|
| limitedtimeoffer 到期 | cartDataComposition | 清 diy_offer_id |
| promotion timer 过期 | DiyOffersPromotion::changeCartPrice | 清 offer 标记 |
| 活动 DB 删除 | cartDiyOffers → reset | 清标记,回写 Redis |
| bundlesale 条件不满足 | filterCartItemOfferInfo | 清 diy_offer_id |
| skubundlesale 条件不满足 | — | 保留 diy_offer_id,discount=0 |
| gift 条件变化 | calDiscount | 删重建行 |
| minmaxoffer 激活 | 全局 | 跳过其他 diy_offer |
4.5 订单商品落库
OrderService::saveProducts()(~2116 行)从 cart item 写入:
diy_offer_id、diy_offer_name、data_fromprice←final_price(已被 calDiscount 改过)
OrderPriceService::calOrderProductDiscountPrice():bundlesale/skubundlesale 的 diy_offers[].products[].discount 摊到行 discount_price;promotion/minmaxoffer/gift 已在 final_price 体现。
5. o_promotion — 标准满减活动
Model:PromotionModel(表 o_promotion)。计算:PromotionHandlerService::getCartPromotion()。
注意:
PromotionModel.php部分行内注释与常量语义不一致;以下 type 含义以PromotionService.php801 行注释及PromotionHandlerService分支为准。
5.1 七种 type
| type | 常量 | 业务含义 |
|---|---|---|
| 1 | PROMOTION_FULL_AMOUNT_MINUS_AMOUNT | 满额减元 |
| 2 | PROMOTION_FULL_NUM_MINUS_OFF | 满件打折 |
| 3 | PROMOTION_FULL_NUM_MINUS_AMOUNT | 满件减元 |
| 4 | PROMOTION_FULL_AMOUNT_MINUS_OFF | 满额打折 |
| 5 | PROMOTION_SINCE_COUNT_DISCOUNT | 第 X 件 Y 折 |
| 6 | PROMOTION_FIXED_PRICE | 满件一口价 |
| 7 | PROMOTION_BUY_X_FREE_Y | 满 X 件免 Y 件(低价优先免) |
Type 5/6/7 有独立计算分支;1–4 走 calGeneralDiscount → PromotionService::discountPrice()。
5.4 getCartPromotion 三步流水线
PromotionHandlerService::getCartPromotion()(17–149 行):
| 步骤 | 行号 | 行为 |
|---|---|---|
| 0 | 19 | unsetProductsByMutexVariantUniqKey — 移除捆绑 SKU 行 |
| 1 | 29–52 | getAllCartPromotionByCache(),过滤时间窗,挂 ranges,设 product_range_sort |
| 2 | 64–100 | 按 sort 升序构建 not_ranges(已被窄范围活动占用的 product/collection) |
| 3 | 111–139 | 按 type 调 cal*,不过滤 discount=0 的活动 |
缓存键(PromotionService):
| Key | TTL | 失效 |
|---|---|---|
CacheKeyHelper::cartPromotion() | 7 天 | 活动 CRUD 时 expire |
CacheKeyHelper::cartPromotionRange($id) | 7 天 | 同上 |
CacheKeyHelper::promotionById($id) | 1 天 | 更新时 expire |
5.5 各 type 算法摘要
约定:promotion.discount 与 product_list[*].promotion_price 一般为 负数。
Type 1–4:calGeneralDiscount(294–470 行)
- 输入:范围内
price=Σ(unit_price×qty)、count=Σ qty - 范围三分支:全场 / 指定商品 / 专辑(均 respect
not_ranges) - 算法:
PromotionService::discountPrice($ruleParam, $price, $count, $type)- 满额类(1,4):按
$cartTotal与门槛ge比较 - 满件类(2,3):按
$cartCount allocation_limit=999:上不封顶(floor倍数);否则取最后一档满足条件
- 满额类(1,4):按
输出:discount(负)、product_list(按行小计比例分摊 promotion_price)。
Type 5:calSinceDiscount(157–286 行)— 第 X 件 Y 折
top_promotion=0:每层生效,按件序号逐档触发top_promotion≠0:仅最高档,对第一件达到 condition 的行减一次- 支持
promotion_xy_product_price_sort排序 unit_price
Type 6:calFixedPriceDiscount(579–682 行)— 满件一口价
- 商品按 unit_price 升序;滑动窗口找连续
ge件原价和 > 一口价 ALLOCATION_LIMIT时可循环「上不封顶」
Type 7:calBuyXFeeY(687–791 行)— 满 X 免 Y
- 低价优先排序;
free = value × floor(totalQty/ge)(上不封顶)或取最大档 - 按行顺序免最低价商品
5.6 not_ranges vs mutexPromotionVariantUniqKey
| 机制 | 语义 | 写入 | 消费 |
|---|---|---|---|
not_ranges | 多满减并存时 SKU/专辑 占用声明 | getCartPromotion step2 | 各 cal* 过滤 |
mutexPromotionVariantUniqKey | 捆绑 SKU 整行移出 满减计算 | cartDiyOffers 463–467 行 | getCartPromotion 入口 19 行 |
二者独立:前者是「先到先得占用」;后者是「物理剔除行」。
5.2 商品范围优先级
活动按 product_range_sort 升序排列:
指定商品 < 专辑 < 全场(999)
5.3 多活动共存规则
非「取最优折扣」,而是 先到先得 + 全部相加:
- 同一
product_id/collection_id只能归属 先排序到的活动(not_ranges机制,PromotionHandlerService59–100 行) - 各活动独立算
discount,array_sum全部相加 - 与捆绑销售 SKU 互斥(见 4.4 节)
6. o_coupon — 优惠券
Model:CouponModel(表 o_coupon)。计算:CouponService::getCouponPlan() / useCoupon();结账编排:CouponHandlerService。
6.1 与满减的关系(use_with_promotion)
| 值 | 常量 | 行为 |
|---|---|---|
| 0 | NOT_USE_WITH_PROMOTION | 互斥:promotion_price != 0 时拒绝(no_share) |
| 1 | USE_WITH_PROMOTION | 可叠加;满减已覆盖全部小计则拒绝;叠加超额则削减券额(CouponService::useCoupon 1207–1223 行) |
| 2 | REPLACE_WITH_PROMOTION | 替换满减:用后清零 promotion / promotion_price(CouponHandlerService::checkCouponUseWithPromotionStatus 567–617 行) |
计算顺序:代码注释明确 先算活动,再算优惠券(CouponHandlerService.php 582 行)。
替换型券特殊分支(checkCouponUseWithPromotionStatus):
action != useCoupon或controller == OrderSinglePage时:清零满减并重置订单current_promotion_priceaction == useCoupon时:设tag_refresh_promotion_price = false,防止订单满减字段被误覆盖(case1/2/3 注释)
6.2 门槛与优惠形式
| 维度 | 常量 | 含义 |
|---|---|---|
| 门槛-满件 | COUPON_DISCOUNT_TYPE_NUM | 满件数 |
| 门槛-满额 | COUPON_DISCOUNT_TYPE_AMOUNT | 满金额 |
| 优惠-折扣 | COUPON_DISCOUNT_OFF | 折扣 %(1–99) |
| 优惠-减额 | COUPON_DISCOUNT_AMOUNT | 固定减额 |
商品范围:PRODUCT_RANGE_ALL / PRODUCT_RANGE_NOT_ALL / PRODUCT_RANGE_COLLECTION。
6.3 旧接口 getDiscount() 差异
CouponService::getDiscount() 793–805 行:不可叠加券会从满减计算中 排除券适用商品 后再算满减。
购物车主流程(CartService::getList)不走此分支;主要用于旧/预览接口。
6.4 getCouponPlan 校验链(逐步)
CouponService::getCouponPlan()(819–970 行):
| 顺序 | 条件 | 失败 msg |
|---|---|---|
| 1 | $param 非空数组 | cart_empty |
| 2 | $couponCode 非空 | code_not_found |
| 3 | DB 查券存在 | — |
| 4 | $status && usage_limit==0 | 用完 |
| 5 | 商品范围过滤 | 无适用商品 |
| 6 | 范围内 totalPrice/totalNum > 0 | — |
| 7 | 门槛 condition(满件/满额,含 max_value) | 未达门槛 |
| 8 | 优惠额 discount(% 或固定额,固定额 min(value, totalPrice)) | — |
6.5 三个优惠券入口对比
| 方法 | 场景 | 写库 | 与活动关系 |
|---|---|---|---|
calPreCouponPrice | 无订单,Cookie 预券 | 否(内存) | 之后走 checkCouponUseWithPromotionStatus |
useCouponHandler | 用券 API / 有订单 | 有订单时 useCoupon + saveProducts | 传实时 promotion_price |
checkCartCoupon | getList 有订单 reconcile | 可能更新 order coupon 字段 | 调 checkCoupon |
标准结账顺序(548–559 行):活动 → calPreCouponPrice → checkCouponUseWithPromotionStatus →(有联系信息)checkCartCoupon。
6.6 预券 Storage
| 机制 | Key / 位置 | TTL |
|---|---|---|
| 预用券码 | CacheKeyHelper::PreUseCouponKey($customerId) | 90 天(useCouponReal) |
| 预填邮箱 | PreCustomerEmail | 同上 |
| Cookie | oem_coupons | — |
| COD | CodCouponService | 3 个月 |
6.7 REPLACE_WITH_PROMOTION 全路径
| 路径 | 文件 | 行为 |
|---|---|---|
useCoupon | CouponService:1226 | 空 break,不在此清活动 |
| 购物车清活动 | CouponHandlerService:594-615 | 非 useCoupon action 时清零 promotion |
| useCoupon action | CouponHandlerService:611 | tag_refresh_promotion_price=false |
| 税费 | TaxService:586 | disable_promotion_update 时不重算满减 |
| COD | CodCouponService | 未调 checkCouponUseWithPromotionStatus → 替换型券可能与活动双计 |
6.8 checkCoupon vs useCoupon 封顶差异
useCoupon(1207–1223):叠加型用 全单 subtotal 做封顶checkCoupon(1367–1371):用 券适用商品 totalPrice 做封顶
重构时需统一或明确文档化差异。
7. 叠加 / 互斥矩阵
| A | B | 关系 |
|---|---|---|
| minmaxoffer | 其他 diy_offer(bundlesale/gift/promotion 等) | 互斥(has_minmaxoffer 跳过 cartDiyOffers) |
| minmaxoffer | 标准满减 o_promotion | 可叠加(minmaxoffer 改行价后仍算满减) |
| bundlesale / skubundlesale SKU | 标准满减 | 互斥(mutexPromotionVariantUniqKey) |
多个 o_promotion | 彼此 | 可共存(不同商品/专辑;同商品先到先得,discount 相加) |
diy_offer promotion | o_promotion | 可叠加(先改行价,再算满减) |
diy_offer gift | o_promotion | 可叠加(gift 改结构后仍算满减) |
优惠券 NOT_USE_WITH_PROMOTION | 任何 promotion_price != 0 | 互斥 |
优惠券 USE_WITH_PROMOTION | promotion_price | 可叠加(有上限裁剪) |
优惠券 REPLACE_WITH_PROMOTION | promotion_price | 替换(清零满减) |
o_order_diy_offer | 上述所有商品优惠 | 独立叠加(不参与 promotion 链) |
o_shipping_discount | 商品优惠 | 独立(只改运费方案价格) |
8. Cart / Checkout 与四种下单方式
各形态可用优惠总表(含 COD 单页)见 promotions-by-checkout-type.md。
主流程与四形态对比见 checkout-flows.md;逐步 API 与落库表见 checkout-flows-detail.md。
本节只保留 与优惠计价直接相关 的摘要。
8.1 计价入口(按形态)
| 形态 | checkout_type | 计价 Service | 是否读 o_order |
|---|---|---|---|
| 标准 / 单页 / 渐进式 | standard / one_page / single_page | CartService::getList | 创单后是 |
| COD | cod | CodCartService::getCartList | 否 |
单页在 getList 之后另有 CheckoutOnePageService::calCartCouponPrice 等;渐进式用 POST use-coupon;标准用 GET /coupon/use/{token}。
8.2 order_diy_offer 写入时机(因形态而异)
| 形态 | 写入点 |
|---|---|
| 标准 | shipping_method POST |
| 单页 | complete / preComplete 的 handlerOrderDiyOffer |
| 渐进式 | shipping-method POST |
| COD | 无 o_order_diy_offer |
已有订单刷新:CartService::getList 708 行 updateDiyOfferInfo(仅在线三种)。
8.3 CartService::getList 两阶段(摘要)
阶段 A — 无订单(checkout Redis type ≠ order)
读 checkoutcart / guest|customer cart
→ cartDataComposition → diy_offer → promotion → calPreCouponPrice
→ 汇总 total(内存,不写 o_order)
阶段 B — 有订单
从 o_order hydrate current_* / coupon / shipping
→ 同阶段 A 活动计算
→ 若 has_contact_information 或 orderInfo:
· checkCartCoupon / tax / promotion / shipping reconcile
· orderModel->save(partial) // coupon/tax/promotion 字段
· saveProducts(updateTotal=true) // 商品行 + subtotal/total
· OrderDiyOfferService::updateDiyOfferInfo // 重跑 order diy offers
→ 汇总 total
8.4 各折扣持久化时机
详表见 checkout-flows-detail.md §5。
8.5 用券流程(摘要)
CouponHandlerService::useCouponHandler():
- 有订单:
CouponService::useCoupon(),传入实时promotion_price_currency→ 成功后OrderService::saveProducts()刷新行分摊 - 无订单(购物车):
useCoupon()+ Cookie 预存券码
验券:CouponService::checkCartCoupon() / checkCoupon()。
8.6 行级优惠分摊
OrderPriceService::calOrderProductDiscountPrice()(21–163 行),在 OrderService::saveProducts(2078 行)内调用:
| 步骤 | 行号 | 行为 |
|---|---|---|
| 基线 | 37–40 | discount_price = exchange(final_price)(单价级) |
| DIY offer | 43–52 | += exchange(diy行discount/quantity) |
| 优惠券 | 62–121 | 按适用商品 final_price×qty 比例减 |
| 满减 | 125–161 | 用 promotion.product_list 匹配 key 分摊 |
顺序:diy_offers → 券 → 满减(均减在同一 discount_price 字段)。
8.7 订单字段映射
CheckoutOnePageService::orderPreData() 964–968 行:
| Cart 字段 | Order 字段 | 来源体系 |
|---|---|---|
promotion_price | current_promotion_price | 满减 + diy_offers.discount |
diy_offer_price | current_offer_price | o_order_diy_offer |
coupon_price | current_coupon_price | o_coupon |
total_price | total_price | 汇总 |
行上活动来源:order_product.diy_offer_id 关联 o_diy_offer(bundlesale、limitedtimeoffer 等),计入 current_promotion_price 侧,不是 o_order_diy_offer。
8.8 优惠体现双轨
| 方式 | 典型 type | 汇总字段 |
|---|---|---|
| 直接改行价 | minmaxoffer、promotion/limitedtimeoffer、gift | 体现在 subtotal;diy_offers[].discount 可能为 0 |
| 产出 discount 字段 | bundlesale、skubundlesale、o_promotion | 计入 promotion_price |
| 订单级行外项 | o_order_diy_offer | diy_offer_price / current_offer_price |
9. 运费折扣(独立模块)
ShippingDiscountService::handlerShippingDiscount() 在选出配送方案后,按 o_shipping_discount 配置改 运费价格,不参与 promotion_price 或 CartService::getList 商品优惠栈。
活动类型:ACTIVITY_TYPE_FIXED(固定折扣)、ACTIVITY_TYPE_LIMIT(限时阶段折扣)。
10. 关键代码索引
| 职责 | 路径 |
|---|---|
| 购物车计价编排 | common/services/CartService.php — getList() |
| COD 购物车计价 | common/services/CodCartService.php — getCartList() |
| diy_offer 编排 | common/services/DiyOfferService.php — cartDiyOffers() / defaultDiyOffer() |
| diy_offer 插件实现 | extend/diyoffers/*.php |
| 标准满减计算 | common/services/PromotionHandlerService.php — getCartPromotion() |
| 满减 CRUD/缓存 | common/services/PromotionService.php |
| 优惠券验券/用券 | common/services/CouponService.php |
| 结账用券/预用券 | common/services/CouponHandlerService.php |
| 订单级 diy_offer | common/services/OrderDiyOfferService.php |
| 订单级 diy_offer 调度 | common/services/OrderDiyOfferHandleService.php |
| 行级分摊 | common/services/OrderPriceService.php — calOrderProductDiscountPrice() |
| 单页结账 | common/services/CheckoutOnePageService.php |
| 运费折扣 | common/services/ShippingDiscountService.php |
11. 重构建议附录
以下为当前实现的 已知复杂度,供重构时参考(本文档不改代码)。
11.1 命名分裂
diy_offer_price(订单级)vsdiy_offers[](购物车插件)vs 表名order_diy_offer/diy_offer三处语义不一致,易误改。- 建议重构时引入明确命名,如
order_addon_pricevscart_plugin_offers。
11.2 优惠体现双轨
同一 promotion_price 混合了「改行价型」与「discount 字段型」活动的汇总,排查问题时需区分 items[].price 是否已被 diy_offer 改写。
11.3 多满减非最优
多个 o_promotion 按范围排序先到先得、discount 相加,不会自动选择对消费者最优的组合。
11.4 替换型券分支
REPLACE_WITH_PROMOTION 与 useCoupon action、OrderSinglePage controller 有多处特殊判断(case1/2/3),重构券模块时需保留等价行为或补集成测试。
11.5 常量注释漂移
PromotionModel、CouponModel 部分常量旁注释与业务语义不符,应以 Service 层分支与 API 校验为准。
11.6 插件扩展点
- 购物车活动:新增
o_diy_offer.type→ 实现extend/diyoffers/DiyOffers{Type}.php→DiyOfferService::getExtendObj() - 订单附加项:新增
from_name→ 实现extend/orderdiyoffers/OrderDiyOffer{Type}.php→ 注册 Redisorder_diy_offers_from_name
11.7 建议的重构切分(代码模块)
pricing/
├── Pipeline/
│ ├── CartPricingPipeline.php # 从 CartService::getList 523-555 抽出
│ └── CheckoutPricingPipeline.php # One Page calCart* 系列
├── Mutex/
│ └── PromotionMutexRegistry.php # has_minmaxoffer + mutexPromotionVariantUniqKey + not_ranges
├── Ledger/
│ ├── LineAdjustmentLedger.php # InlinePrice / AccruedDiscount / LineRebuild
│ ├── OrderAddonLedger.php # o_order_diy_offer
│ └── ShippingAdjustmentLedger.php # o_shipping_discount
├── Promotion/
│ ├── PromotionPipeline.php # getCartPromotion 三步
│ ├── ProductRangeFilter.php # 合并 calGeneralDiscount + filterCartNotRangeItem
│ └── Strategies/ # 7 个 cal* Strategy
├── Coupon/
│ ├── CouponEligibilityService.php # getCouponPlan 校验链
│ └── CouponPromotionPolicy.php # NOT_USE / USE_WITH / REPLACE
├── DiyOffer/
│ ├── CartOfferOrchestrator.php # cartDiyOffers / defaultDiyOffer
│ └── Types/ # 5 个 Handler
└── Order/
└── OrderLineDiscountAllocator.php # OrderPriceService 拆分
11.8 已知行为差异(重构前先修或测)
| 问题 | 影响 |
|---|---|
COD 未调 checkCouponUseWithPromotionStatus | 替换型券与满减可能双计 |
checkCoupon vs useCoupon 封顶基数不一致 | 预览价与落库价可能不同 |
DiyOffersGift switch 无 break | gift 规则可能累加误判 |
getCartPromotion 返回 discount=0 活动 | 前端需自行过滤 |
calProductPromotion 同 key 后者覆盖 | 多活动同行时 promotion_id 不准 |
bundlesale formatCartItemInfo 空实现 | 强依赖前端传 data_from |
| deliveryprotec/seel Redis PHP 只读 | 未入库需查前端/App 写入 |
12. Redis / Cache Key 索引
12.1 购物车 diy_offer
| Key 方法 | 用途 | TTL |
|---|---|---|
diyOfferDetail($id) | 活动详情+range | 7 天 |
defaultDiyOfferLists() | hash,minmaxoffer 等店铺级配置 | 7 天 |
promotionsValidity($key, $visitId) | 限时促销倒计时 | 动态 |
promotionProductList($diyOfferId) | 促销落地页商品 | 5 分钟 |
giftProductList($md5) | 赠品关联商品 ES | 30 分钟 |
guestcartKey / customercartKey / checkoutcartKey | 原始 cart 行 | — |
12.2 满减 o_promotion
见 §5.4 缓存键表。
12.3 优惠券
见 §6.6 预券 Storage。
12.4 order_diy_offer
| Key | 用途 |
|---|---|
orderDiyOfferCacheKey(store, token) | deliveryprotec 暂存 |
orderDiyOfferSeelCacheKey(store, token) | seel 暂存 |
orderDiyOfferFromName(storeId) | 已启用 offer 类型 registry |
13. 前台 API 端点速查
| 用途 | 方法 | 路径 |
|---|---|---|
| 标准 checkout | GET/POST | /{store}/checkouts/{checkout_token} |
| 用券 | GET | /coupon/use/{checkout_token} |
| 验券 | GET | /coupon/check/{checkout_token} |
| 取消券 | DELETE | /coupon/cancel/{checkout_token} |
| order diy offer 查询 | POST | /order/orderdiyofferinfo |
| diy_offer 加购 | POST | homeapi Cart.diyOffers |
| diy_offer 前台列表 | GET | /diy-offers/{type}/... |
| One-page 全套 | POST | /{store}/one-page-checkouts/{token}/* |
| Single-page 全套 | GET/POST | /{store}/single-page-checkouts/{token}/* |
| COD 下单 | POST | /{store}/cod-checkouts/{token} |
| Admin 改价 | POST | /api/order/updateOrderPriceDiyOffer |
14. 排障清单
| 现象 | 排查方向 |
|---|---|
| 积分已扣但 total 不对 | updateDiyOfferInfo、getOrderPoints、主从延迟 |
| deliveryprotec 未入库 | Redis order_diy_offer:{store}{token} 是否有前端写入 |
| One-Page 积分未生效 | 是否调了 complete/preComplete 且传 offer_from_name |
| 券与活动同时使用报错 | use_with_promotion=NOT_USE 且 promotion_price≠0 |
| 替换型券后仍有满减 | 是否走了 COD;或 useCoupon action 特殊分支 |
| 捆绑后仍享满减 | 查 mutexPromotionVariantUniqKey 是否写入 |
| 限时促销价未变 | promotion_timer property、ends_at、Redis promotionsValidity |
| minmaxoffer 与其他活动并存 | 不应发生;查 has_minmaxoffer |
15. 可进一步拆分的子文档
主文档保留 架构、顺序、叠加矩阵;下列子文档用于 类型参数、Handler、单测 查阅。与 §11.7 代码模块建议 一一对应。
15.1 结账与优惠(checkout-process)
| 文档 | 内容 | 状态 |
|---|---|---|
| checkout-flows.md | 四种下单方式关系与主流程 | 已写 |
| checkout-flows-detail.md | API 逐步、落库表 | 已写 |
| promotions-by-checkout-type.md | 五形态可用优惠、顺序、落库 | 已写 |
| shopping-cart.md | 购物车 Redis、getList | 已写 |
| faq.md | checkoutcart、reconcile、登录 cart_token 等 | 已写 |
| cart-checkout-token-visit-id.md | token / Redis 结构 | 已写 |
| standard-checkout-flow.md 等 | 分形态逐步链路 | 已写 |
| cod-checkout-flow.md | COD 带 token | 已写 |
| cod-one-page-checkout.md | COD 单页 | 已写 |
15.2 优惠类型参考(promotion/)
| 文档 | 内容 | 对应代码模块(§11.7) | 状态 |
|---|---|---|---|
| diy-offer-types-reference.md | o_diy_offer 各 type、params、改价模式、单测要点 | DiyOffer/Types/* | 已写 |
| promotion-types-reference.md | o_promotion 七种 type、rule_param 样例 | Promotion/Strategies/* | 已写 |
| order-diy-offer-handlers.md | o_order_diy_offer Handler、Redis 写入方、触发时机 | OrderAddonLedger | 已写 |
| README.md | 上三篇索引 | — | 已写 |
15.3 仍可后续补充(非阻塞)
| 文档 | 建议内容 | 状态 |
|---|---|---|
promotion/coupon-reference.md | 券类型、use_with_promotion、REPLACE 全路径、check vs use 封顶差异 | 待写(主文档 §6 已有摘要) |
promotion/pricing-pipeline-sequence.md | getList / One-page calCart* 逐步时序图(对照 §11.7 Pipeline) | 待写 |
promotion/shipping-discount-reference.md | o_shipping_discount 与商品优惠栈边界 | 待写(主文档 §9 摘要) |
阅读顺序:先看本文 §1–§8 → 形态对照 promotions-by-checkout-type.md → 按需查 promotion 子文档。
文档版本:基于代码库当前实现整理(含细拆附录)。行号引用以 common/services/CartService.php 等文件为准,若代码变更请同步更新本文。