这一篇进入优惠券系统真正的"算法核心"。 当前代码已经不是简单的 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 优惠券系统架构演进(四):高并发与最终一致性------库存模型、消息、补偿与批量任务》