餐饮 SaaS 优惠券系统架构演进(三):优惠计算引擎——商品级计价、冲突策略与优惠分摊

这一篇进入优惠券系统真正的"算法核心"。 当前代码已经不是简单的 orderAmount - couponAmount:它会读取用户券快照、匹配门店/场景/商品/规格、计算三类券、处理券与商品活动的冲突策略,并把商品级优惠稳定分摊到购物车行。

1. 为什么优惠券最终会进入 Pricing Engine

订单价格通常同时受到商品活动、会员价、优惠券、加料、打包费和场景规则影响。如果优惠券独立拿"订单总价"计算,就无法回答一个基本问题:这 20 元到底优惠了哪一行、哪一个规格、是否与商品活动冲突?

图 1:当前营销计价的核心组件关系

2. CouponCalculationService:统一入口,三类算法

图 2:三类优惠券从 user_claim 快照计算商品级优惠

统一入口会根据 couponType 读取对应的用户领取实例,只接受 status=0、未删除的 claim,并优先使用 couponSnapshot。结果不是一个总优惠金额,而是 Map<productId, BigDecimal>,兑换券还会返回 productSpecLimitMap 限定具体规格。

券类型 核心计算基数 关键限制
折扣券 适用商品金额 × (1 - discountRate) threshold、scene/store、商品范围、maxDiscountQuantity、maxDiscountAmount
满减券 适用商品金额 + 可计入门槛的额外金额 thresholdAmount 达标后减 discountAmount
商品兑换券 目标 productId/specId 的单件规格价 门槛、兑换目标必须在当前商品和券范围内;加料/打包费不作为兑换优惠本身

3. 活动冲突不是 if/else:系统同时构造两个候选

图 3:CouponActivityConflictPolicyEngine 的候选策略

策略常量明确支持 COUPON_FIRST=1、ACTIVITY_FIRST=2、BEST_PRICE=3。BEST_PRICE 的实现不是简单比较"券优惠金额"和"活动优惠金额",而是分别计算两个完整候选的 finalTotal,再选择用户最终支付更低的结果。

复制代码
chooseBest(activityFirst, couponFirst):
  if couponFirst.finalTotal < activityFirst.finalTotal: choose couponFirst
  if couponFirst.finalTotal > activityFirst.finalTotal: choose activityFirst
  if finalTotal equal: compare totalCouponDiscount

4. 为什么优惠分摊不能"平均除"

图 4:CouponDiscountAllocator 的稳定分摊逻辑

当前 Allocator 维护 remainingCouponDiscount、remainingSubtotal 和 remainingItemCount。非最后一行按小计比例计算,最后一行直接拿剩余优惠,因此可以吸收四舍五入尾差;同时每行优惠不会超过自身 subtotal。这个细节对退款、对账、行级金额展示非常重要。

5. 一个真实代码里很容易被忽略的 ID 问题

图 5:couponId 多表命名空间冲突与兼容逻辑

源码中有一条非常关键的注释:couponId 在三类券表内不是全局唯一。因此旧接口如果没有传 couponType,只靠 couponId 可能同时在三类 user_claim 表命中。当前兼容逻辑会从候选里按最近领取时间与 claimId 选择。

6. Troubleshooting / RCA:用户选择 A 券,计价却像用了 B 券

Symptoms

订单前端只上传 couponId,服务端日志显示同一个 couponId 在多个券类型中都找到了可用 claim,最终计价券类型与用户界面选择不一致。

Investigation

检查数据库发现三类券分别自增主键,因此 discount_coupon.id=12 与 full_reduction_coupon.id=12 可以同时存在。继续追踪 resolveSelectedCandidate(),确认旧调用在未传 couponType 时存在兼容选择逻辑。

Root Cause

问题不是优惠算法本身,而是业务标识不完整:couponId 只在各自表内唯一,却被旧调用当成全局券标识。

Resolution

订单与客户端优先传 claimId + couponType,couponId 只作为模板关联键;新接口避免让服务端靠"猜测候选"定位用户真正选择的权益。

Verification

构造三类表相同 couponId 的测试数据,分别传 claimId+couponType,验证命中唯一用户券;同时保留旧接口回归,确保兼容逻辑不会中断历史客户端。

Lessons Learned

数据库主键唯一,不等于领域 ID 全局唯一。 多表多态模型必须明确"ID 的命名空间",跨服务传参尤其要带类型或使用真正全局唯一的权益实例 ID。

7. 当前计算引擎最值得保留的设计

设计点 价值
从 user_claim 快照计算 历史用户权益不被模板修改污染
商品级 discountMap 为订单行展示、退款、分摊和活动冲突提供基础
productSpecLimitMap 兑换券可以精确约束规格,不把同商品其他规格误优惠
ConflictPolicyEngine 把"券优先 / 活动优先 / 最优价"从业务 if/else 中抽离
CouponDiscountAllocator 保证总优惠与行级优惠对得上,控制舍入误差

8. 架构演进建议

当前 CouponCalculationServiceImpl 仍注入了大量 Mapper,并同时承担"查用户券、范围匹配、券算法、兼容候选选择"等职责。下一步更适合做模块内解耦,而不是立刻拆微服务:CouponClaimResolver → CouponRuleCalculator → ScopeMatcher → ConflictPolicy → DiscountAllocator。

下一篇:《餐饮 SaaS 优惠券系统架构演进(四):高并发与最终一致性------库存模型、消息、补偿与批量任务》

相关推荐
晒太羊的猫8 分钟前
hot100子串
java·算法·leetcode
这个DBA有点耶15 分钟前
测试全绿,上线三周数据没入账——那个95%的兼容度报告,漏掉的是边界
数据库·mysql·架构
1024奇点24 分钟前
【仓颉语言入门 · 第二篇 第 15 课】
开发语言·数据库·ide·开源
开开心心就好31 分钟前
本地文件搜索工具,双击Ctrl就能呼出搜索
java·服务器·前端·支持向量机·ffmpeg·音视频·模拟退火算法
独隅36 分钟前
Claude 高效使用指南深度评测
java·开发语言·claude·claude code
ByteCoder.43 分钟前
班前十分钟的清单,按体检引导单来写
java·服务器·前端·助播虾
海的透彻1 小时前
如何让docker容器启动时用自定义的用户
java·docker·dubbo
疯狂学习GIS1 小时前
将谷歌地球引擎账号升级为Contributor与Partner层级的方法
java·数据库·人工智能
gf13211111 小时前
python_高德地图驾车时长查询
数据库·python·mysql
GG.WinGel1 小时前
质数数目(蓝桥杯练习题)
java·数据结构·算法·青少年编程·蓝桥杯