前言

拼单系统最核心的入口之一是商品试算。用户还没有真正下单,只是在页面上看一杯奶茶能不能参团、优惠后多少钱、是否命中人群限制。这个接口一旦写成一串 if else,后面加活动开关、渠道限制、人群标签、库存校验时会越来越难维护。

所以我把试算链路设计成规则树。每个节点只处理一个判断或计算,然后把结果写入动态上下文,再由节点决定下一步走向。这个设计和责任链类似,但规则树更强调分支:成功继续、失败进入错误节点、命中标签后进入结束节点。

为什么用规则树

营销规则的特点是变化频繁,并且经常有组合关系。比如活动必须处于有效期内,商品要绑定活动,用户要命中标签,优惠策略要存在,最后才能返回试算结果。如果写在一个方法里,很难知道某个规则失败后应该返回什么,也很难给不同活动插拔节点。

规则树把流程拆成 RootNode、SwitchNode、MarketNode、TagNode、EndNode、ErrorNode。RootNode 做入口校验,SwitchNode 判断活动开关,MarketNode 加载活动和商品并计算优惠,TagNode 判断可见性和参与资格,EndNode 组装结果,ErrorNode 兜底异常返回。

@Service
public class DefaultActivityStrategyFactory {

    private final RootNode rootNode;

    public DefaultActivityStrategyFactory(RootNode rootNode) {
        this.rootNode = rootNode;
    }

    public StrategyHandler<MarketProductEntity, DynamicContext, TrialBalanceEntity> strategyHandler() {
        return rootNode;
    }
}

工厂只暴露根节点,外部调用方不需要知道树里有几个节点。以后要新增一个库存节点,只需要改树的路由关系,而不是把调用方一起拖进来。

动态上下文

规则树里最重要的是 DynamicContext。它不是随便放数据的 Map,而是一次试算过程的业务快照。活动配置、商品信息、优惠后价格、可见性、可参与状态都在这里流转。这样节点之间不需要互相调用,数据通过上下文传递。

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public static class DynamicContext {
    private GroupBuyActivityDiscountVO groupBuyActivityDiscountVO;
    private SkuVO skuVO;
    private BigDecimal deductionPrice;
    private boolean visible;
    private boolean enable;
}

这个上下文也方便做日志。线上排查试算异常时,我不只看入口参数,还会看每个节点写入了什么字段。比如 groupBuyActivityDiscountVO 为空,说明活动配置没有查到;deductionPrice 为空,说明优惠策略没有成功计算;visible 为 false,说明人群或活动限制没有通过。

节点如何流转

MarketNode 是试算链路里最重的节点,它负责加载活动配置和 SKU,并调用不同优惠策略。这里有两个关键设计:第一,活动和商品并行查询,避免串行等待;第二,优惠策略通过 Spring Map 注入,避免硬编码策略选择。

IDiscountCalculateService discountCalculateService =
        discountCalculateServiceMap.get(groupBuyDiscount.getMarketPlan());

if (null == discountCalculateService) {
    throw new AppException(ResponseCode.E0001.getCode(), ResponseCode.E0001.getInfo());
}

BigDecimal deductionPrice = discountCalculateService.calculate(
        requestParameter.getUserId(),
        skuVO.getOriginalPrice(),
        groupBuyDiscount);

dynamicContext.setDeductionPrice(deductionPrice);
return router(requestParameter, dynamicContext);

这里的 marketPlan 可以是 MJ、ZK、N、ZJ 等策略标识。策略 Bean 使用 @Service("MJ") 这样的名称注册,Map 的 key 就自然成为策略编码。这样新增一种优惠,只需要新增一个实现类并约定编码。

异常节点和收口

规则树一定要有失败出口。活动不存在、商品不存在、策略不存在、用户不在可见范围内,都不能让接口返回空指针。ErrorNode 的意义是把不可试算的情况变成可解释结果,EndNode 则把正常结果组装成前端可使用的 TrialBalanceEntity。

我喜欢把错误节点看成业务兜底,而不是异常吞掉。比如活动没配置,前端可以展示原价和不可参团;用户没命中标签,可以展示普通价;系统异常才返回错误码。营销系统不能因为一个活动配错就拖垮整个商品页。

小结

规则树的价值不是把代码写复杂,而是让复杂规则有地方放。试算链路天然会越来越长,如果一开始就把节点、上下文、路由和异常收口设计好,后面加 DCC、标签、库存和渠道限制都会更从容。