商城最复杂的业务逻辑之一就是促销系统。这篇记录我在商城项目中优惠券系统的完整设计,从券类型到优惠计算再到核销流程。
优惠券类型设计
从业务角度,我定义了三种基本优惠券类型:
满减券:满X元减Y元,最常见。比如满200减30。可以设置上限金额,防止极端情况。
折扣券:打X折,需要设最高优惠金额。比如8折券,最高优惠50元——一个500块的订单打8折优惠100,但因为有上限,实际只减50。
免运费券:抵扣运费,通常有门槛。比如满99免运费。
除了类型,每张券还需要定义适用范围:
- 全品类通用
- 指定分类可用(如只限服装类)
- 指定商品可用
- 指定商品排除(全品类但排除某些低利润商品)
领取规则
发券方式直接决定系统设计复杂度:
主动领取:用户在券中心手动领取,是最简单的模式。需要控制的参数包括——每人限领张数、总发行量、领取时间窗口。
系统发放:运营后台批量发到指定用户账户。常见场景是新用户注册礼包、老用户召回、客诉补偿。
活动触发:满足特定条件自动发放。比如首单完成后发一张复购券,连续签到7天发券。这个需要对接事件系统。
领取限制的核心是防刷。几个关键点:
- 同一用户同一券的领取次数限制(数据库唯一索引)
- 总库存原子扣减(Redis DECR + 数据库最终一致)
- 领取接口做频率限制
- 有些券需要限制设备维度,防止多账号刷券
使用规则:互斥与叠加
这是促销系统里最容易出问题的部分。我的方案是给优惠券分组,定义组间互斥关系:
组的概念:每张券属于一个互斥组。同组的券之间互斥(只能用一张),不同组的券可以叠加。
举个例子:
- A组(商品优惠):满减券、折扣券
- B组(运费优惠):免运费券
- C组(平台补贴):平台通用券
A组内的满减和折扣只能二选一,但可以和B组的免运费券叠加,也可以和C组的平台券叠加。
这样设计的好处是规则清晰、可扩展。运营想加新的互斥关系,只要调整分组就行,不需要改代码逻辑。
叠加计算顺序也有讲究:先算商品优惠(按比例分摊到每个商品),再算运费优惠,最后算平台补贴。顺序不同会导致金额差异,需要和业务方明确约定。
优惠计算:最优组合
用户结算时可能有多张可用券,系统需要找到最优组合。
由于互斥分组的存在,问题简化为:每个组内选最优的一张(或不选),组间直接叠加。
单组最优选择:遍历该组内用户持有的、满足使用条件的所有券,分别计算优惠金额,取最大的。
满减券的计算很直接。折扣券要注意先算折扣金额,再和最高优惠额取min。
跨组叠加:各组最优结果直接相加。但要注意一个细节——后面的组计算时,订单金额应该减去前面组已经优惠的部分。比如一个300元订单,A组满减了50,B组免运费券的门槛应该按250元判断还是300元?我们选择的策略是按原始订单金额判断门槛,这样对用户更友好,实现也更简单。
分摊逻辑:一张券优惠的金额需要按比例分摊到每个商品行上,这是为了退款时能精确计算退多少。分摊规则是按商品金额占比,最后一个商品用减法兜底(避免分摊误差)。
核销流程
优惠券从领取到使用,状态变化:未使用 → 锁定中 → 已使用 / 已释放。
下单锁定:用户提交订单时,先锁定选中的优惠券(状态改为锁定中,记录订单号)。这一步在订单创建事务中完成,保证原子性。
支付成功核销:支付回调确认后,将券状态从锁定改为已使用。
订单取消释放:如果用户取消订单或支付超时,将券状态从锁定改回未使用。同时要检查券是否过期,如果在锁定期间过期了就直接改为已过期。
退款处理:如果订单全额退款,需要考虑是否退还优惠券。我们的策略是——全额退款退券(状态改回未使用),部分退款不退券。退回的券如果没过期可以继续使用。
这里有个并发问题:取消订单和支付回调可能同时到达。解决方案是用数据库乐观锁,更新时带上当前状态作为条件。
数据库设计
核心表结构:
coupon_template(券模板):定义券的基本信息——类型、面值、使用条件、有效期规则(固定时间段 / 领取后N天)、发行量、已领取量、互斥组ID、适用范围(关联范围表)。
coupon_instance(券实例):用户持有的具体券——关联模板ID、用户ID、状态(未使用/锁定中/已使用/已过期)、领取时间、使用时间、关联订单号、过期时间(根据模板规则在领取时计算好)。
coupon_scope(适用范围):模板关联的适用范围——范围类型(全品类/指定分类/指定商品/排除商品)、关联的分类ID或商品ID列表。
coupon_use_record(使用记录):核销明细——券实例ID、订单ID、优惠金额、分摊明细JSON。主要用于退款时反查和数据对账。
几个关键索引:
- coupon_instance的(user_id, status)联合索引,查询用户可用券
- coupon_instance的(template_id, user_id)唯一索引,控制领取次数
- coupon_instance的expire_time索引,定时任务扫描过期券
过期处理用定时任务,每分钟扫描一批即将过期的券。不建议用延迟队列处理过期,因为券数量可能很大且过期不是时效性很强的操作。
踩过的坑
金额精度:所有金额字段用整数存储(单位:分),不用float/decimal。计算过程中也全程用整数,只在展示层转换为元。
并发领取:高并发领券场景,先用Redis原子扣减库存,再异步写数据库。如果数据库写入失败要补偿Redis。
券过期时间:有两种模式——固定时间段(1月1日到1月31日)和领取后N天。后者在领取时计算具体过期时间,写入实例表。不要每次查询时动态计算,那样没法建索引。
展示与实际不一致:用户在商品页看到"可用优惠券"和结算页看到的可能不同,因为结算时才知道完整订单信息(多商品组合、运费等)。需要在UI上做好说明。
总结
促销系统的核心难度不在技术实现,而在业务规则的梳理和边界情况的处理。和运营确认清楚互斥规则、计算顺序、退款策略,把这些写成文档锁定,再开始开发会顺利很多。技术上注意金额精度、并发控制和状态机一致性就差不多了。