前言

营销系统里最容易变化的是优惠规则。今天是满 100 减 10,明天是 8 折,后天是 N 元购,再过几天运营想给某个标签用户做直减。如果每次都改同一个大方法,代码会很快变成一团业务条件。

所以我把优惠计算做成策略模式。抽象类负责公共流程,比如人群标签过滤、最低金额保护;具体策略只关心自己的表达式怎么解释、优惠金额怎么算。这样规则可以横向扩展。

策略接口

public interface IDiscountCalculateService {
    BigDecimal calculate(
            String userId,
            BigDecimal originalPrice,
            GroupBuyActivityDiscountVO.GroupBuyDiscount groupBuyDiscount);
}

接口入参看起来很少,实际上已经够用。userId 用来做人群过滤,originalPrice 是商品原价,groupBuyDiscount 包含优惠类型、优惠表达式、标签 ID、活动编码等配置。计算结果统一返回应付金额,而不是返回优惠金额,这样上层不用关心每种策略的差异。

满减实现

满减策略的表达式设计成 `100,10`,含义是满 100 减 10。实现时先解析门槛 x 和减免 y,不满足门槛直接返回原价,满足后做扣减,并保证最低支付 0.01。

@Slf4j
@Service("MJ")
public class MJCalculateService extends AbstractDiscountCalculateService {

    @Override
    public BigDecimal doCalculate(BigDecimal originalPrice,
                                  GroupBuyActivityDiscountVO.GroupBuyDiscount groupBuyDiscount) {
        String marketExpr = groupBuyDiscount.getMarketExpr();
        String[] split = marketExpr.split(Constants.SPLIT);
        BigDecimal x = new BigDecimal(split[0].trim());
        BigDecimal y = new BigDecimal(split[1].trim());

        if (originalPrice.compareTo(x) < 0) {
            return originalPrice;
        }

        BigDecimal deductionPrice = originalPrice.subtract(y);
        if (deductionPrice.compareTo(BigDecimal.ZERO) <= 0) {
            return new BigDecimal("0.01");
        }
        return deductionPrice;
    }
}

这里没有把 `100,10` 写死在代码里,而是存在数据库配置里。这样运营调整活动时,只要改表达式,不需要发布应用。表达式虽然简单,但要做格式校验,否则配置成 `100-10` 就会在运行时炸掉。

人群过滤

抽象类里先做人群标签过滤,再调用 doCalculate。这样所有优惠策略都天然支持标签型优惠,不需要每个实现类都写一遍 userId 判断。

public abstract class AbstractDiscountCalculateService implements IDiscountCalculateService {

    @Override
    public BigDecimal calculate(String userId,
                                BigDecimal originalPrice,
                                GroupBuyActivityDiscountVO.GroupBuyDiscount groupBuyDiscount) {
        if (DiscountTypeEnum.TAG.equals(groupBuyDiscount.getDiscountType())) {
            boolean isCrowdRange = filterTagId(userId, groupBuyDiscount.getTagId());
            if (!isCrowdRange) return originalPrice;
        }
        return doCalculate(originalPrice, groupBuyDiscount);
    }

    protected abstract BigDecimal doCalculate(BigDecimal originalPrice,
                                              GroupBuyActivityDiscountVO.GroupBuyDiscount groupBuyDiscount);
}

这块和简历里写的 Redis + BitMap 人群管理是连着的。标签过滤不是只在优惠计算里做一个布尔判断,背后要有标签导入、去重、快速判断和统计更新。后面会单独写 BitMap 这一篇。

表达式解析

表达式越灵活,越要控制边界。满减可以是 `x,y`,折扣可以是 `0.8`,N 元购可以是 `9.9`,直减可以是 `5`。我不会一开始做一个复杂 DSL,而是先让每种策略定义自己的表达式格式,并在后台配置时校验。

enum MarketPlan {
    MJ("MJ", "满减", "x,y"),
    ZK("ZK", "折扣", "rate"),
    N("N", "N元购", "price"),
    ZJ("ZJ", "直减", "amount");

    private final String code;
    private final String name;
    private final String exprPattern;
}

面试时如果被问为什么不用 Drools 或 Aviator,我会解释:当前系统规则种类有限,且规则主要是价格计算和可见性判断,策略模式更轻、更容易测试。等规则组合复杂到需要运营自行编排,再考虑规则引擎。

小结

优惠计算的核心是让变化变成扩展,而不是反复修改老代码。抽象类收公共流程,具体策略处理表达式,Spring Bean 名称承接 marketPlan,规则树负责调度。这样一条链路既能解释清楚,也能支撑后续扩展。