餐饮 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 优惠券系统架构演进(四):高并发与最终一致性------库存模型、消息、补偿与批量任务》

相关推荐
染指11101 小时前
135.Agent-多Agent框架-LangChain多智能体(SubAgents子代理)
数据库·人工智能·设计模式·langchain·agent·agents
vx_Biye_Design1 小时前
expressDeepSeek社团咨询助手的学生社团管理系统60746-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·python·课程设计·express
vx_Biye_Design1 小时前
springboot宠物领养与救助平台64334-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·mysql·课程设计·宠物
泡茶喝茶写代码1 小时前
A股量化数据工程:从 REST 接口到策略信号(第 5 篇):板块成分股映射与对齐
java·python·股票数据api·股票数据·股票数据api接口·股票api数据接口·股票量化数据接口
vx_Biye_Design1 小时前
django就业信息推荐系统61953-计算机课程设计、毕业设计
java·vue.js·spring boot·python·架构·django·课程设计
Elastic 中国社区官方博客1 小时前
错误最多的服务运行正常:使用 ES|QL 从日志进行根因分析
大数据·运维·数据库·sql·elasticsearch·搜索引擎·全文检索
知守观1 小时前
HTML 转 PDF 方案实战:iTextPDF 与 wkhtmltopdf 踩坑复盘,附 Flying Saucer / openhtmltopdf 选型对比
java·后端
2601_962071572 小时前
类变量和全局变量的隔离性有什么区别?
java·开发语言·jvm
海绵宝宝转agent2 小时前
LeetCode100 LRU缓存思路讲解
java·开发语言·缓存