餐饮 SaaS 优惠券系统架构演进(七)营销定价引擎设计:活动、会员价、优惠券、买赠如何在同一 PriceQuote 中可解释地组合

基于当前统一优惠计算编排器,拆解 Fact → Candidate → Conflict → Allocation → Quote 五阶段模型;重点解释冲突策略、买赠分配、优惠券金额分摊、Money Policy、可解释输出,以及如何用 Shadow / Replay 把新 Pricing 安全上线。

Pricing EngineConflict PolicyMoney AllocationShadow Replay

结论先行: 当前代码已经具备"定价引擎"的雏形:统一编排器内部拆出了活动刷新、会员价、限购分配、行定价、买赠分配、券-活动冲突和优惠券分摊等多个 Engine。下一步不是把这些类简单搬到新服务,而是把它们收敛成稳定 Fact、独立 Candidate、显式 Conflict、可验证 Allocation、不可变 Quote五个阶段。

阅读边界: 文中的"当前实现"来自项目源码;PriceQuote / PriceAdjustment / FactSnapshot / policyVersion 等属于目标模型建议。文章对外发布时已使用通用名称,不暴露真实商户、门店、账号或内部标识。

1. 为什么第六篇拆完领域后,Pricing 必须先被"工程化"

第六篇已经给出一个明确的迁移顺序:Pricing 是最适合先抽离的上下文,因为它副作用最少、结果可比对、失败后也最容易回退旧链路。但"适合先拆"并不代表"直接把现有类搬到新服务"。如果仍然沿用当前 ResultVO 和隐式调用顺序,新服务只会把原来的类调用耦合升级成网络耦合。

源码里的 ProductDiscountCalculationServiceImpl 已经承担统一编排职责:它批量加载商品,解析会员身份,刷新活动价,计算会员价,执行活动限购分配,建立每一行的 pricing state,再做买赠分配,最后根据券-活动冲突策略生成 Coupon Candidate 并分摊券金额。换句话说,真正的 Pricing 已经存在,只是还没有被"模型化"。

图 1:当前统一优惠计算编排器------多个 Engine 已经出现清晰职责边界,但最终仍收敛到 ProductDiscountCalculationResultVO,而不是稳定的 PriceQuote。

2. Current State:源码里的定价链到底做了哪些事

阶段 当前类/方法 真实职责
商品事实 GoodsBatchQueryService / resolveProductMap 批量获取商品与规格;允许调用方传入 preloadedProductMap 减少重复查询
活动事实 ActivityPriceRefreshEngine.refresh 批量调用商品活动计算接口;按 specId 回填 activityGoodData,并返回 activityMemberPriceStrategy / productDiscountPriorityType
会员价 MemberPriceEngine 解析会员身份、会员等级与可享受商品范围,计算 vipPrice;支持 excludeMemberDiscount
活动数量分配 ActivityLimitAllocator 对活动可优惠数量做分配,并使用高价优先/低价优先稳定排序
行级定价 CartLinePricingEngine 生成 LinePricing:原价、活动价、会员价、实际单价、小计、活动数量、会员数量、第二件折扣等
买赠 BuyGiftAllocationEngine 支持同品赠与低价赠;把 giftQuantity / giftDiscountAmount / groupId 分配回原始商品行,并重建券计算基数
券冲突 CouponActivityConflictPolicyEngine 支持 COUPON_FIRST / ACTIVITY_FIRST / BEST_PRICE,计算 Candidate 并应用行级冲突结果
券金额分摊 CouponDiscountAllocator 将商品级券优惠稳定分摊到行,处理规格限制、paidQuantity、比例舍入与最后一行余数
展示结果 ProductDiscountCalculationResultVO 输出 activity/member/coupon/gift 明细、offerSummary、conflictResult、finalPrice 等

这段代码的价值在于:它已经避免了"所有营销规则都塞在一个方法里"的最坏形态。但它仍然存在一个工程化缺口:阶段之间依赖的是可变 LinePricing / ProductVO 状态,而不是可版本化、可重放的中间事实。

3. 目标模型:Fact → Candidate → Conflict → Allocation → Quote

图 2:五阶段定价模型------不是为了"多一层抽象",而是让每一步都有清晰输入、输出、测试边界和回放能力。

五阶段模型解决的是"规则越来越多以后,怎样避免顺序、舍入和副作用失控"。它要求每一个阶段都回答一个明确问题:

  • **Fact:**本次报价到底看到了哪些商品、活动、会员、券和买赠事实?
  • **Candidate:**每一种优惠规则在不影响最终结果的前提下,分别能产生什么候选优惠?
  • **Conflict:**哪些候选可以共存,哪些互斥,谁优先,是否需要 BEST_PRICE?
  • **Allocation:**订单级/商品级优惠怎样精确落到每一行、每一个规格、每一个数量单位?
  • **Quote:**最终价格、每行调整项、解释文本、策略版本和输入快照如何被不可变地保存?

4. Fact:定价正确性的第一前提,是冻结本次计算看到的事实

图 3:Fact Snapshot------将商品、活动、会员、优惠券和买赠输入冻结成一次报价的稳定事实。

当前代码已经在事实上收集这些数据,只是它们散落在 ProductVO、ProductActivityDataVO、会员实体、Coupon Snapshot 与 LinePricing 中。目标模型应该把它们收敛成只读 Fact DTO,而不是让 Engine 直接修改跨域对象。

4.1 为什么 requestItemKey 很重要

MarketingItemKeys.requestItemKey(index, productId, specId) 看似只是一个内部 key,实际上解决了"同一商品/规格在购物车中出现多行时如何稳定定位"的问题。Pricing 独立以后,应继续保留行级稳定键,并补充 quoteTraceId、policyVersion、factVersion。

4.2 活动事实必须批量化,而不是拆服务后做 N+1 RPC

ActivityPriceRefreshEngine 当前一次性收集所有请求规格,再调用 batchCalculateProductActivitiesForSpecs。这是拆服务以后必须保留的模式:跨域读取应该是"一次请求获取一份 Fact Snapshot",不能把当前类级调用粒度原样变成网络 RPC。

5. Candidate:规则先"提出方案",不要直接修改最终价格

现在部分 Engine 会直接修改 LinePricing,例如买赠会把 paidQuantity / giftQuantity / subtotal 写回准备对象。目标 Pricing 更适合把每一种规则先建模成 Candidate,例如:

复制代码
PromotionCandidate {
  sourceRefId,
  lineId,
  discountType,
  candidateUnitPrice,
  candidateQty,
  amount,
  priority,
  explain
}

CouponCandidate {
  mode,                // COUPON_FIRST / ACTIVITY_FIRST
  lineCouponDiscounts,
  totalCouponDiscount,
  finalTotal
}

Candidate 的关键不是 DTO 名字,而是先计算、后决策。只有这样,BEST_PRICE 才能在同一组 Fact 上同时计算两条路径,再做可解释选择。

6. Conflict:三种券-活动策略如何进入同一个决策模型

图 4:券-活动冲突策略------当前代码已经具备 Candidate + chooseBest 雏形,下一步应把冲突策略从"全局取第一个"升级成显式组级/行级决策。

6.1 COUPON_FIRST / ACTIVITY_FIRST / BEST_PRICE

当前 CouponActivityConflictPolicyEngine 已经将冲突逻辑单独抽离,并维护 CouponCandidate。BEST_PRICE 会分别计算 Activity-First 与 Coupon-First 两个 Candidate,比较 finalTotal,总价更低者胜出。一个很值得保留的细节是:如果两边 finalTotal 相同,代码会倾向于选择优惠券折扣金额更少的方案,从而让更多优惠由活动承担、少消耗券价值。

6.2 当前实现有一个值得专项验证的风险点

resolveStrategy() 会遍历 PricingPreparation.linePricingMap,找到第一个带 couponActivityConflictStrategy 的活动就返回。由于该容器本身是 HashMap,代码没有表现出"同一购物车内所有冲突活动必须同策略"的强约束。

**风险判断:**如果业务配置确实保证整单只有一种券-活动冲突策略,则当前实现成立;如果允许不同活动有不同策略,就应该按 activity group / line 显式计算 Conflict,而不是依赖 map 中第一个策略。这个点需要通过配置契约和集成测试确认。

7. Allocation:一分钱如何稳定落到商品行

图 5:三类分配引擎------活动数量、买赠单位和优惠券金额使用不同算法,但最终必须共同守住同一组行级不变量。

7.1 优惠券分摊为什么"最后一行吃余数"

CouponDiscountAllocator 按 product 维护剩余优惠、剩余小计和剩余行数。非最后一行按 remainingDiscount × lineSubtotal / remainingSubtotal 使用 HALF_UP 保留两位;当剩余行数为 1 时,直接把剩余优惠全部给最后一行。这个设计比"每行都独立算比例"更重要,因为它能确保总分摊金额与券优惠总额完全一致。

复制代码
if (remainingItemCount == 1) {
    lineDiscount = remainingDiscount;  // 吃掉舍入余数
} else {
    lineDiscount = remainingDiscount * lineSubtotal / remainingSubtotal;
}
lineDiscount = min(lineDiscount, lineSubtotal);
remainingDiscount -= lineDiscount;

7.2 规格限制与赠品必须先改变券基数

Coupon 计算并不是简单对原始商品总价打折。当前代码支持 productSpecLimitMap,只有允许的规格行才进入分摊;而 BuyGiftAllocationEngine 分配完赠品后,会重新计算 paidQuantity 并重建 Coupon Base,确保赠品不会继续作为可用券抵扣数量。

8. Quote:从"结果 VO"升级为不可变报价事实

图 6:目标 PriceQuote 数据模型------Quote 保存订单级事实,LineQuote 保存行级结果,Adjustment 解释每一笔价格变化,FactSnapshot 支持回放。

当前 ProductDiscountCalculationResultVO 已经包含大量有价值的解释字段:actualDiscountTypes、offerSummary、activityDiscountQty、memberDiscountQty、couponDiscountQty、giftQuantity、giftDiscountAmount、couponActivityConflictResult。这其实已经非常接近可解释 Quote。

目标模型不应该丢掉这些字段,而是把它们从"接口展示 VO"升级成稳定的定价事实:

模型 职责 为什么需要
PriceQuote 一次完整报价的不可变头信息 支持 quoteId / policyVersion / inputHash / originalTotal / payTotal / createdAt
PriceQuoteLine 商品行最终状态 记录原价、活动价、会员价、付费数量、赠品数量、最终小计
PriceAdjustment 每一个优惠来源的金额变化 sourceType + sourceRefId + lineId + amount,使售后/审计无需重算历史规则
PriceExplanation 冲突与展示解释 保存 conflictResult / offerSummary / reasonCode / activityTypeQty
FactSnapshot 本次定价读取的只读事实或版本引用 用于回放、Shadow Diff、客服解释与争议审计

9. Money Policy:定价引擎最危险的 bug 往往只有 0.01 元

图 7:Money Policy------金额 scale、rounding、clamp 与 remainder 必须成为统一领域策略,而不是散落在各 Engine 中。

当前代码大量使用 BigDecimal.setScale(2, RoundingMode.HALF_UP),并且 MarketingPricePolicy.clampSubtotalToOriginal() 会防止小计异常高于原价小计。这些都是正确方向,但规则仍分布在不同 Engine。

Pricing 独立后应该把 Money Policy 版本化,至少统一:

  • 货币 scale 与 roundingMode;中间乘除使用什么精度;最终落账在哪一步 setScale;
  • 活动价与会员价叠加时,是"活动价 × 会员折扣率"还是"两个价格取低";
  • 优惠券分摊余数由哪一行承担;排序 key 是否稳定;
  • 加料是否参与券门槛、是否参与分摊、是否参与活动折扣;
  • 所有 line subtotal / final subtotal 都必须满足非负与不超过原价基线的约束。

10. 幂等与副作用:Pricing 为什么不能顺手 useCoupon

Price Quote 会被反复刷新:用户改数量、换门店、切配送方式、重新选券、支付页自动刷新,甚至网络重试都会再次触发计算。如果每次 Pricing 都顺手修改 Coupon Claim、扣 Benefit、写 Order 状态,那么"报价"就会变成一个高副作用命令,幂等和补偿复杂度会指数级上升。

因此建议保留第六篇的边界:Pricing 只读取 Fact 并产出 Quote;真正创建订单时,由 Order Saga 执行 Coupon Reserve / Benefit Reserve;订单确认后 Confirm,取消则 Release。

11. Shadow + Replay:新 Pricing 如何在真实流量上证明自己

图 8:Shadow / Replay 验证链------旧引擎继续出生产结果,新引擎旁路计算,同一 Fact Snapshot 做行级 Diff,再由 Gate 决定是否扩大灰度。

11.1 Golden Case 不是几个手写单测

至少要覆盖:活动价 + 会员价优先级、第二件折扣、买赠同品赠、低价赠、券规格限制、满减门槛额外金额、同一商品多购物车行、部分行赠品后再用券、COUPON_FIRST / ACTIVITY_FIRST / BEST_PRICE、金额舍入边界。

11.2 Shadow Diff 应该比较什么

对比维度 不能只比较 推荐比较
总价 payAmount 是否相同 originalTotal / payTotal / couponTotal / promotionTotal / memberTotal
行级金额 每行 finalPrice 每个 LineQuote 的 subtotal / finalSubtotal / paidQty / giftQty
来源解释 offerSummary 文本 Adjustment 的 sourceType / sourceRefId / amount / priority
冲突决策 最终便宜即可 conflictResult / selectedCandidate / rejected reason
性能 接口平均 RT P95/P99 / 远程 Fact RPC 次数 / cache hit / error rate

12. Troubleshooting:定价引擎最容易踩的六个坑

Symptoms Investigation Root Cause Resolution / Verification
新旧结果经常差 0.01 逐行比较 adjustment、分摊顺序与 setScale 时机 没有统一 Money Policy 统一 scale/rounding/remainder;Golden Case 固化逐行金额
BEST_PRICE 偶尔选择不稳定 记录每个 Candidate finalTotal / couponDiscount 与策略来源 冲突策略解析依赖非业务顺序或多活动策略不一致 按 activity group/line 解析并排序;配置发布时做策略一致性校验
买赠后优惠券门槛异常 检查 paidQuantity 与 Coupon Base 是否重建 赠品仍进入券可用数量/金额基数 买赠分配后统一 rebuild Coupon Base;加入"赠品不可重复抵扣"不变量
同商品多行券分摊漂移 检查 requestItemKey 与 allocator 行顺序 行键或排序不稳定 保留 index + productId + specId 稳定键;必要时加入 cartId/lineId
拆成 Pricing 服务后变慢 统计一次 Quote 的远程调用次数 把类调用粒度原样搬成 RPC Fact Provider 批量加载;禁止 N+1 RPC;短 TTL / request cache
售后重算历史订单金额不一致 检查历史规则是否已变化 用"今天的规则"重算"昨天的订单" 订单冻结 quoteId / adjustments / policyVersion / fact snapshot 引用

图 9:5 Whys------"一分钱差异"最终通常不是数学题,而是 Money Policy 没有被当成版本化领域契约。

13. Verification:上线前必须守住哪些不变量

复制代码
Invariant 1: originalSubtotal >= finalSubtotal >= 0
Invariant 2: sum(lineFinalSubtotal) == quote.payTotal
Invariant 3: sum(lineCouponDiscount) == quote.couponDiscountTotal
Invariant 4: giftQuantity + paidQuantity == orderedQuantity
Invariant 5: couponDiscount <= eligibleCouponBase
Invariant 6: same Fact + same policyVersion => deterministic Quote
Invariant 7: pricing calculation has no Coupon/Order/Benefit state mutation

除了示例化不变量,还建议加 property-based testing:随机生成商品行、数量、活动、会员价、券折扣和舍入边界,验证所有金额不变量长期成立。对复杂定价系统来说,这比继续增加几个 if 分支的单元测试更有价值。

14. Lessons Learned

  • **第一,**统一优惠计算的核心不是"把所有优惠都算出来",而是把动态事实、候选方案、冲突决策、金额分摊和最终报价拆成稳定阶段。
  • **第二,**当前源码已经出现了这个方向:多个 Engine、CouponCandidate、LinePricing、Allocator 和 offerSummary 都是非常好的雏形。
  • **第三,**BEST_PRICE 的关键不是多算一次,而是必须在同一个 Fact Snapshot 上计算可比较 Candidate,确保结果可解释。
  • **第四,**Money Policy 是领域契约。一分钱差异如果没有统一解释,会直接演变为订单、退款、财务和客服争议。
  • **第五,**Pricing 最适合通过 Shadow / Replay 先证明自己,因为它应该是低副作用、确定性的计算服务。
  • **第六,**真正成熟的 PriceQuote 不只是一个总价,而是一份可以用于下单、售后、审计、对账、客服和回放的不可变价格事实。

最终结论: 从当前统一优惠编排器走向独立 Pricing Engine,真正的重构目标不是"拆服务",而是把价格计算变成确定性函数 + 版本化策略 + 稳定 Fact + 可解释 Adjustment + 不可变 Quote。做到这一步,活动、会员价、优惠券和买赠才能在一个平台里持续增加规则,而不会让价格计算逐渐失控。

15. 下一篇

第八篇建议继续完成 Pricing 工程化闭环:《PriceQuote 版本化与回放测试:Golden Case、Shadow Diff、策略灰度与历史价格审计》。重点会深入 policyVersion / factVersion、报价签名、历史回放、差异诊断、属性测试、灰度 Gate 与订单售后如何直接消费 Adjustment。

相关推荐
码匠许师傅1 小时前
【C++三方组件】redis-plus-plus:现代 Redis C++ 客户端
java·c++·redis
ab1237681 小时前
MQTT笔记
java·笔记·mqtt·学习
二宝哥1 小时前
【问题】代理转发到后端使用request.getRequestURL()地址没有端口
java
霸道流氓气质1 小时前
HashiCorp Vault 与云 KMS 入门实战:动态密钥、SecretLeaseContainer 与 Java 集成完整指南
java·开发语言
Frank_refuel1 小时前
分布式IoT数据采集与存储系统(项目复盘)
运维·服务器
wuminyu1 小时前
stackChunkOop内部结构与GC屏障的配合工作原理剖析
java·linux·c语言·jvm·c++
xuxigifxfh1 小时前
牛客:HJ21 简单密码
java·开发语言·算法
小π军1 小时前
C 和 C++ 是否都支持函数的重载?
java·c语言·c++
qq_408606861 小时前
确界的倒数性质证明
学习·算法