商城系统开发(六):促销与优惠券系统

商城最复杂的业务逻辑之一就是促销系统。这篇记录我在商城项目中优惠券系统的完整设计,从券类型到优惠计算再到核销流程。

优惠券类型设计

从业务角度,我定义了三种基本优惠券类型:

满减券:满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上做好说明。

总结

促销系统的核心难度不在技术实现,而在业务规则的梳理和边界情况的处理。和运营确认清楚互斥规则、计算顺序、退款策略,把这些写成文档锁定,再开始开发会顺利很多。技术上注意金额精度、并发控制和状态机一致性就差不多了。