结算分摊的策略模式:不同营销活动的扣点计算方案

电商平台的结算模块有一件事非做不可:搞清楚每一笔订单里,平台和商家各拿多少钱。听起来不太复杂,但是,有了营销活动后,就变得复杂起来了。

一个订单如果参加了拼团活动,平台要按拼团的分摊比例和扣点来算;如果是秒杀订单,又得按秒杀的活动价和扣点来算;满减活动则只有营销扣点,没有分摊比例这回事。每种活动的结算参数来源不同、校验规则不同、计算方式也不同。如果你在结算服务里用一长串if-else来处理这些差异,每新增一种活动类型就得改这个核心方法,改一次冒一次回归风险。

结算中心是怎么处理这个问题的?用策略模式。下面来看看这个方案具体怎么落地的。

结算分摊要解决什么问题

先说三个核心概念,后面所有代码都围绕它们转。

分摊比例(prorateDistribution):平台和商家分钱的依据。比如分摊比例为1,意味着平台按一定比例从商家应得金额中抽取一部分。这个值是从活动SKU维度配置的,不同SKU可以配不同的比例。

营销扣点(marketRatio):平台向商家收取的营销服务费比例。和分摊比例的区别在于,分摊比例是一个维度更粗的值,营销扣点则是更精确的结算费率。实际结算时,优先使用营销扣点;如果营销扣点为空或为-1,才回退到使用分摊比例。

活动售价(price):商品在活动期间的销售价格,直接影响商家应得金额的计算。

SettlementBO只有三个字段:分摊比例prorateDistribution、营销扣点marketRatio、活动售价price,都用Integer存储。每个Handler最终要返回的就是这个对象,调用方拿到之后根据业务规则决定用营销扣点还是分摊比例来完成最终的金额计算。

问题是,不同活动类型获取这三个值的逻辑差别很大。拼团要查拼团活动表和拼团SKU表,秒杀要查秒杀活动表和秒杀SKU表,满减的参数来源甚至不在SKU维度,而是在一个专门的满减结算配置表里。这就是策略模式的用武之地:把差异封装到各自的Handler里,上层代码只需要关心接口,不需要知道底层查的是哪张表。

策略模式的全局概览

整个方案的结构可以分成三层:

第一层是接口ProrateDistributionHandler,定义了三个方法,所有活动类型的Handler都实现这个接口。第二层是四个具体实现,分别对应拼团、秒杀、特价、满减四种活动。第三层是策略路由,负责根据活动类型找到对应的Handler实例。

调用方的代码始终是同一个套路:拿到活动类型,通过策略路由找到Handler,调用Handler的方法获取结算参数。新增活动类型时,只需要加一个Handler实现类,注册到Spring容器里就行,上层代码完全不用动。

接口设计

接口只定义了三个方法:type()返回活动类型编码,用于路由匹配,default实现抛异常而非返回0,这样实现类忘了覆盖就会在开发阶段直接报错;getActivityProrateDistribution()只返回分摊比例,是早期版本的接口;getActivitySettlementParam()返回完整的SettlementBO,是后来补充的增强接口。两个接口并存是因为历史代码中有些地方只依赖分摊比例,没有全部迁移到新接口。

入参ProrateDistributionDTO携带了结算所需的全部上下文:活动ID、活动类型、商品ID、SKU ID、子商品ID、子SKU ID,以及下单支付时间payTime。其中payTime在后续的有效期校验中起关键作用。

四个Handler各自怎么算

虽然四个Handler都实现同一个接口,但各自的计算逻辑有明显的差异。

拼团、秒杀、特价:过期归零

拼团、秒杀、特价这三种Handler的核心逻辑是一致的,可以归纳为两步:

第一步,查询活动信息,判断支付时间是否在活动有效期内。如果payTime早于活动开始时间或晚于活动结束时间,分摊比例直接返回0,结算时视同未参加活动。这个校验很有必要:用户可能在一个活动即将结束时下单,等支付完成时活动已经过期,这种情况下平台不应该再按活动扣点向商家收费。

第二步,如果活动仍在有效期,查询活动对应的SKU配置,从SKU维度获取分摊比例、营销扣点和活动售价。

以拼团Handler的getActivityProrateDistribution()为例,核心就是两步判断:

Java 复制代码
if (payTime.after(activity.getEndTime())
    || payTime.before(activity.getStartTime())) {
    return 0;
}
return Optional.ofNullable(grouponSku
    .getProrateDistribution()).orElse(0);

先检查支付时间是否在活动有效期内,过期则返回0;再从SKU配置读取分摊比例,未配置则默认0。getActivitySettlementParam()的逻辑同理,只是返回值从分摊比例变成了完整的SettlementBO,额外带上营销扣点和活动售价。

秒杀和特价的代码结构与拼团几乎一样,区别只在查询的表不同:拼团查groupon_activity和groupon_sku,秒杀查seckill_activity和seckill_sku,特价查tejia_activity和tejia_activity_sku。

这三种Handler有一个值得注意的细节:查询活动不存在时直接抛BizException,而不是返回0。因为活动ID是订单数据带的,如果查不到说明数据有问题,应该让调用方感知到这个异常,而不是悄悄按0处理。而SKU配置查不到也是抛异常,道理相同。只有SKU的分摊比例为null时,才用orElse(0)给一个默认值,因为null表示未配置,未配置按0处理是合理的。

满减:只有扣点,没有分摊比例

满减Handler的getActivitySettlementParam()和另外三个差异比较大:

Java 复制代码
FullOffActivityItemSettlement item =
    activityMapper.getByActivityIdAndParentSkuId(
        dto.getActivityId(), dto.getChildItemId(), dto.getSkuId());
if (item == null) return new SettlementBO(0);
return new SettlementBO(0,
    Optional.ofNullable(item.getMarketRatio()).orElse(0), item.getPrice());

第一个差异:满减没有活动有效期的校验。这不是遗漏,而是因为满减的结算参数维护在一个独立的配置表full_off_activity_item_settlement里,这个表没有startTime和endTime字段,结算参数直接绑定在商品维度上,生效期由PMS系统在活动维度控制。

第二个差异:满减的分摊比例硬编码为0。SettlementBO的第一个参数传的就是0,意味着满减活动在结算时只使用营销扣点,不使用分摊比例。满减Handler也没有实现getActivityProrateDistribution()方法,接口的default实现已经返回0了,不需要覆盖。

四种Handler的对比

拼团 秒杀 特价 满减
活动类型编码 2 1 3 6
有效期校验
分摊比例 从SKU配置读取 从SKU配置读取 从SKU配置读取 固定为0
营销扣点 从SKU配置读取 从SKU配置读取 从SKU配置读取 从结算配置表读取
活动售价 从SKU配置读取 从SKU配置读取 从SKU配置读取 从结算配置表读取
数据来源 活动表+SKU表 活动表+SKU表 活动表+SKU表 独立结算配置表

满减活动在数据来源上和其他三种完全不同,这也是它不能复用同一个查询逻辑的根本原因。策略模式在这里的价值不仅是消除if-else,更在于让每种活动类型的差异有了各自独立的封装空间。

策略路由的实现

有了接口和实现类,还需要一个机制根据活动类型找到对应的Handler。这个职责由ProrateDistributionProgress接口和它的实现类handlerProrateDistributionProgress承担。接口只定义了一个方法getProrateDistributionHandler(Integer handlerType),实现类利用Spring自动注入List的特性来完成路由:

Java 复制代码
@Autowired
private List<ProrateDistributionHandler> handlerList;

public ProrateDistributionHandler getProrateDistributionHandler(
        Integer handlerType) {
    for (ProrateDistributionHandler handler : handlerList) {
        if (Objects.equals(handler.type(), handlerType)) return handler;
    }
    return null;
}

Spring容器启动时,会把所有实现了ProrateDistributionHandler接口的Bean自动注入到handlerList里。路由时遍历这个List,调用每个Handler的type()方法,找到匹配的那个返回。

这种写法的好处是扩展时完全不需要改路由代码。新增一种活动类型,只需要写一个实现ProrateDistributionHandler的类,加上@Service注解,Spring自动把它收集进handlerList,路由自然就能找到它。

可能有人会问:遍历List查找的效率是不是有点低?在这个场景里,Handler只有4个,遍历的开销可以忽略不计。如果将来Handler数量增长到几十个,可以考虑换成Map来索引,但以当前的规模,List遍历是最简单、最不容易出问题的选择。

调用方只需三行代码:通过策略路由拿到Handler,判空后调用getActivitySettlementParam(),不关心活动类型是拼团还是秒杀,策略路由已经把差异屏蔽掉了。

兼容结算的两步查询

实际结算流程中,Handler的计算结果不是唯一的数据来源。结算中心还有一个兼容逻辑,按优先级从两个渠道获取分摊参数,这体现在getCompatibleSettlementtParam方法里。

第一步,查询活动提报分摊比例。活动在PMS系统里提报时,运营人员可以手动配置一个分摊比例,这个值存在activity_report相关的表里,并通过Redis缓存加速读取。如果查到了大于0的分摊比例,直接使用这个值,不再走Handler计算。

第二步,如果提报分摊比例没查到或者为0,才走到Handler计算逻辑,获取SettlementBO。这里有一个关键的判断:营销扣点marketRatio为-1时,认为扣点无效,结算时改用分摊比例prorateDistribution。

为什么要设计这个两步查询?因为活动提报的分摊比例是运营手动维护的,比Handler自动计算的结果更准确,也更灵活。运营可以针对某个SKU单独调整分摊比例,而不需要改代码或改活动配置。Handler计算是兜底方案,保证即使没有手动配置,结算也能正常进行。

这个设计思路可以提炼成一个通用原则:能用配置解决的,不要用代码逻辑兜底;代码逻辑是兜底方案,不是第一选择。 运营配置的优先级高于系统计算,这在电商结算领域是一个常见的做法,因为结算涉及资金,手动可干预的能力是必需的。

小结

策略模式在这个结算分摊场景里解决了两个层面的问题。表层是消除了if-else,让代码更容易扩展。但更深层的好处是:它让每种活动类型的结算逻辑有了独立的封装空间,拼团的时间校验、满减的独立数据来源、特价的SKU查询差异,这些都不需要互相迁就。

一个容易被忽略的设计细节是,过期归零的处理方式。活动过期后分摊比例返回0而不是抛异常,是因为过期是一个正常的业务场景,不是异常情况。用户晚付款导致活动过期,这种事在促销高峰期经常发生,结算系统应该安静地处理它,而不是抛一个异常中断整个流程。而活动ID查不到则抛异常,是因为这属于数据不一致,需要及时暴露。

另外,满减Handler没有做时间校验,这不是遗漏,而是因为满减活动的生效控制不在结算侧。理解为什么某个校验被省略,比理解为什么某个校验被加上,有时候更能看清系统的设计意图。

如果你想在自己的项目里落地策略模式,可以从一个判断标准入手:当同一类操作因为类型不同而需要查不同的表、走不同的校验逻辑、返回不同结构的参数时,策略模式就是合适的。如果只是参数值不同而逻辑完全一样,用一个配置表加通用查询就够了,不需要引入策略模式。


最近在知乎出了

  • 「应付6000万会员的秒杀系统专栏」
  • 「几亿用户,百万并发的C端商品系统实战」
  • 「技术团队DDD领域驱动设计三年落地实战」
  • 「应付亿级用户规模的支付系统代码实战」

专栏,感兴趣的可以订阅一下。至于知识星球的,可以搜:

  • 老码头的技术浮生录

它是一个能实际帮你解决难题的星球。有问题的,找知心的Sam哥,支持无限次语音一对一解决你遇到的难题。「另外后续我新写的所有对外的付费专栏,在星球内都是免费的,且可以拿到所有源代码。」

当前星球里免费看的专栏是:

  • 「应付6000万会员的秒杀系统专栏」
  • 「几亿用户,百万并发的C端商品系统实战」
  • 「技术团队DDD领域驱动设计三年落地实战」
  • 「应付亿级用户规模的支付系统代码实战」

知识星球内后续将推出20+个付费专栏,覆盖电商全链路:

选购线 用户会员营销线 中后台
购物车服务 营销系统 订单系统
商品服务 用户系统 支付系统
菜单服务 结算服务

从前台选购到中后台结算,星球成员全部免费,后续新增也不额外收费。

我的知乎账号:

  • SamDeepThinking
相关推荐
喵个咪1 小时前
技术复盘:基于 GoWind Admin 实现 Kratos 框架单体轻量化落地
后端·架构·go
●VON1 小时前
AtomGit Flutter鸿蒙客户端:API客户端与网络层
flutter·华为·架构·跨平台·harmonyos·鸿蒙
电商API_180079052471 小时前
高可用采集架构:分布式定时抓取淘宝商品详情项目设计
大数据·分布式·架构·数据挖掘·网络爬虫
兰令水1 小时前
leecodecode【回溯组合】【2026.6.5打卡-java版本】
java·开发语言
8Qi81 小时前
LeetCode 518:零钱兑换 II(Coin Change II)—— 题解 ✅
java·算法·leetcode·动态规划·完全背包
cfm_29141 小时前
SpringBoot整合RocketMQ极速实战
java·spring boot·后端
为爱停留1 小时前
让智能体「记住」对话:Checkpoint 功能、持久化数据接口与 thread_id 详解
java·数据库·elasticsearch
Sylvia33.2 小时前
2026世界杯全套数据API接入教程:WebSocket实时进球推送实例
java·网络·python·websocket·网络协议
linge_sun2 小时前
SpringAI 功能体验之SQL智能助手:用自然语言查询数据库
java·人工智能·ai编程