详解Java商城项目如何使用Drools规则引擎实现优惠活动

451 阅读4分钟

场景描述:Java商城项目,使用Drools规则引擎实现优惠券功能。优惠券有满减限制,店铺限制,商品种类限制,商品id限制,会员限制。只有会员券可以和普通券叠加一次,会员券不能和会员券叠加,会员券其实也是一种满减券,只是用户买会员才能领,两个优惠券如果同时使用,金额上的限制也都是按照原始金额计算的。用户提交订单时,查看用户所拥有的所有优惠券,自动计算出最省钱的优惠券并默认使用这个优惠券,如果两个优惠券的优惠力度相同,则优先使用快过期的优惠券,最终金额显示的是使用完优惠券的金额,用户也可以手动更改优惠券。

Drools 规则引擎在商城优惠活动中的优势

优势分析:

  1. 解耦业务逻辑

    • 将易变的优惠规则与核心代码分离,规则存储在外部文件(.drl)中
    • 修改规则无需重新编译部署,支持热更新
  2. 可维护性高

    • 规则使用声明式语法(类似自然语言),业务人员可参与编写
    • 集中管理所有促销规则,避免代码分散
  3. 复杂规则处理能力

    • 支持嵌套条件、多规则联动(如:满减 + 会员折扣 + 品类促销)
    • 内置 RETE 算法高效匹配海量规则
  4. 动态扩展性

    • 新增促销类型只需添加规则文件,无需修改主流程
    • 支持规则版本管理和灰度发布
  5. 决策透明度

    • 规则引擎自动记录触发规则,便于审计和客户解释

实现方案详解(Java 商城项目)

在Java商城项目中,使用Drools规则引擎实现优惠券功能的详细方案如下:

一、核心模型设计

// 优惠券模型
public class Coupon {
    private Long id;
    private String name;
    private int type; // 0-普通券 1-会员券
    private BigDecimal minAmount; // 满减门槛
    private BigDecimal discountAmount; // 优惠金额
    private Set<Long> applicableShopIds; // 店铺限制
    private Set<Long> applicableCategoryIds; // 商品类目限制
    private Set<Long> applicableItemIds; // 商品ID限制
    private boolean forMemberOnly; // 仅会员可用
    private Date expireTime; // 过期时间
    // getters/setters
}

// 订单上下文(规则引擎输入)
public class OrderContext {
    private BigDecimal originalAmount; // 订单原始金额
    private boolean isMember; // 是否会员
    private List<OrderItem> items; // 订单商品
    private List<Coupon> userCoupons; // 用户持有优惠券
    private List<Coupon> applicableCoupons = new ArrayList<>(); // 过滤后的可用券
    private List<Coupon> bestCombo = new ArrayList<>(); // 最优组合
    private BigDecimal finalAmount; // 最终金额
    // getters/setters
}

// 订单商品项
public class OrderItem {
    private Long itemId;
    private Long categoryId;
    private Long shopId;
    private BigDecimal price;
    // getters/setters
}

二、Drools规则实现(coupon-rules.drl)

1. 单券可用性规则

rule "Check coupon applicability"
salience 100
when
    $order: OrderContext()
    $coupon: Coupon() from $order.getUserCoupons()
    
    // 基础校验
    eval($coupon.getExpireTime().after(new Date())) // 未过期
    eval($order.getOriginalAmount().compareTo($coupon.getMinAmount()) >= 0) // 满足金额门槛
    
    // 会员限制校验
    if ($coupon.isForMemberOnly()) then
        eval($order.isMember())
    end
    
    // 店铺限制校验
    if (!$coupon.getApplicableShopIds().isEmpty()) then
        exists OrderItem(shopId in $coupon.getApplicableShopIds()) from $order.getItems()
    end
    
    // 类目限制校验
    if (!$coupon.getApplicableCategoryIds().isEmpty()) then
        exists OrderItem(categoryId in $coupon.getApplicableCategoryIds()) from $order.getItems()
    end
    
    // 商品ID限制校验
    if (!$coupon.getApplicableItemIds().isEmpty()) then
        exists OrderItem(itemId in $coupon.getApplicableItemIds()) from $order.getItems()
    end
then
    $order.getApplicableCoupons().add($coupon); // 添加可用券
end

2. 组合叠加规则

rule "Find best coupon combo"
salience 50
when
    $order: OrderContext(applicableCoupons.size() > 0)
    
    // 分组优惠券
    $memberCoupons: List() from collect(Coupon(type == 1) from $order.getApplicableCoupons())
    $normalCoupons: List() from collect(Coupon(type == 0) from $order.getApplicableCoupons())
    
    // 计算最优组合
    $bestDiscount: BigDecimal() from accumulate(
        $c: Coupon() from $order.getApplicableCoupons(),
        init(BigDecimal max = BigDecimal.ZERO;),
        action(if($c.getDiscountAmount().compareTo(max) > 0) max = $c.getDiscountAmount()),
        result(max)
    )
then
    // 实现组合策略(见下方Java策略)
    CouponComboStrategy strategy = new CouponComboStrategy();
    strategy.findBestCombo($order, $memberCoupons, $normalCoupons, $bestDiscount);
end

三、组合策略实现(Java)

public class CouponComboStrategy {
    public void findBestCombo(OrderContext order, 
                             List<Coupon> memberCoupons,
                             List<Coupon> normalCoupons,
                             BigDecimal bestDiscount) {
        
        // 按优惠力度+过期时间排序
        Comparator<Coupon> comparator = Comparator
            .comparing(Coupon::getDiscountAmount).reversed()
            .thenComparing(Coupon::getExpireTime);
        
        memberCoupons.sort(comparator);
        normalCoupons.sort(comparator);
        
        // 候选方案
        Coupon bestSingle = null;
        Coupon bestMember = memberCoupons.isEmpty() ? null : memberCoupons.get(0);
        Coupon bestNormal = normalCoupons.isEmpty() ? null : normalCoupons.get(0);
        
        // 方案1:单会员券
        if (bestMember != null) {
            bestSingle = bestMember;
        }
        
        // 方案2:单普通券
        if (bestNormal != null && 
            (bestSingle == null || 
             bestNormal.getDiscountAmount().compareTo(bestSingle.getDiscountAmount()) > 0)) {
            bestSingle = bestNormal;
        }
        
        // 方案3:会员券+普通券组合
        BigDecimal comboDiscount = BigDecimal.ZERO;
        List<Coupon> combo = new ArrayList<>();
        if (bestMember != null && !normalCoupons.isEmpty()) {
            combo.add(bestMember);
            combo.add(normalCoupons.get(0));
            comboDiscount = bestMember.getDiscountAmount().add(bestNormal.getDiscountAmount());
        }
        
        // 结果比较
        if (comboDiscount.compareTo(bestSingle.getDiscountAmount()) > 0) {
            order.setBestCombo(combo);
            order.setFinalAmount(order.getOriginalAmount().subtract(comboDiscount));
        } else {
            order.setBestCombo(Collections.singletonList(bestSingle));
            order.setFinalAmount(order.getOriginalAmount().subtract(bestSingle.getDiscountAmount()));
        }
    }
}

四、服务层调用逻辑

@Service
public class CouponService {
    @Autowired
    private KieContainer kieContainer;

    public OrderContext calculateBestCoupon(OrderContext context) {
        KieSession kieSession = kieContainer.newKieSession();
        kieSession.insert(context);
        
        // 插入用户优惠券
        context.getUserCoupons().forEach(kieSession::insert);
        
        // 插入订单商品
        context.getItems().forEach(kieSession::insert);
        
        kieSession.fireAllRules();
        kieSession.dispose();
        return context;
    }
}

五、订单处理流程

@Controller
public class OrderController {
    @Autowired
    private CouponService couponService;

    public Order submitOrder(OrderRequest request) {
        // 1. 构建订单上下文
        OrderContext context = new OrderContext();
        context.setOriginalAmount(calculateTotal(request.getItems()));
        context.setMember(userService.isMember(request.getUserId()));
        context.setItems(buildOrderItems(request));
        context.setUserCoupons(couponDao.getUserCoupons(request.getUserId()));
        
        // 2. 执行规则引擎计算
        couponService.calculateBestCoupon(context);
        
        // 3. 创建订单
        Order order = new Order();
        order.setCoupons(context.getBestCombo());
        order.setFinalAmount(context.getFinalAmount());
        orderDao.save(order);
        
        return order;
    }
    
    // 用户手动更换优惠券
    public BigDecimal recalculate(RecalculateRequest request) {
        OrderContext context = buildContext(request);
        return couponService.calculateBestCoupon(context).getFinalAmount();
    }
}

六、关键优化点

  1. 规则性能优化

    • 使用agenda-group分割规则阶段
    • 对大型商品列表使用collect替代exists
    rule "Shop restriction optimization"
    when
      $coupon: Coupon(!applicableShopIds.isEmpty())
      $order: OrderContext()
      $shopIds: Set() from collect(OrderItem(shopId != null) from $order.getItems())
    then
      // 使用Set交集判断
    end
    
  2. 组合策略扩展

    // 支持多策略模式
    public interface ComboStrategy {
        void findBestCombo(OrderContext ctx, 
                          List<Coupon> members, 
                          List<Coupon> normals);
    }
    
    // 注册策略
    @Component
    public class StrategyFactory {
        @Resource(name = "simpleCombo")
        private ComboStrategy defaultStrategy;
        
        public ComboStrategy getStrategy(int scenario) {
            return defaultStrategy; // 可扩展场景策略
        }
    }
    
  3. 缓存优化

    @Cacheable(value = "couponDecisions", 
               key = "{#userId, #items.hashCode()}")
    public OrderContext calculateCached(Long userId, List<Item> items) {
        // 计算逻辑
    }
    

七、前端交互设计

  1. 默认展示

    • 显示规则引擎计算出的finalAmount
    • 默认选中bestCombo中的优惠券
  2. 手动切换

    // 前端调用示例
    function recalculate(couponIds) {
        POST /order/recalculate {
            items: [商品数据],
            couponIds: [用户选择的券ID]
        }
    }
    
  3. 校验规则

    • 前端实时校验:会员券仅限会员使用
    • 禁止同时选择多张会员券
    • 金额门槛不足时置灰选项

八、完整流程

sequenceDiagram
    participant User
    participant Frontend
    participant Controller
    participant Service
    participant Drools
    participant DB
    
    User->>Frontend: 提交订单
    Frontend->>Controller: 请求创建订单
    Controller->>Service: 获取用户优惠券
    Service->>DB: 查询user_coupons
    Controller->>Service: 执行规则计算
    Service->>Drools: 传入OrderContext
    Drools-->>Service: 返回最佳组合
    Service->>DB: 持久化订单
    Controller->>Frontend: 返回订单结果
    Frontend->>User: 展示优惠后金额
    
    User->>Frontend: 手动更换优惠券
    Frontend->>Controller: 请求重算金额
    Controller->>Service: 执行重算逻辑
    Service->>Drools: 传入新券组合
    Drools-->>Service: 返回新金额
    Controller->>Frontend: 返回新金额
    Frontend->>User: 更新界面显示

九、注意事项

  1. 金额精度

    • 使用BigDecimal进行精确计算
    • 设置统一的MathContext舍入规则
  2. 规则热更新

    // 监控规则文件变化
    @Scheduled(fixedRate = 5000)
    public void reloadRules() {
        if (ruleFileChanged()) {
            kieContainer = kieServices.newKieContainer(kieContainer.getReleaseId());
        }
    }
    
  3. 叠加规则扩展

    • CouponComboStrategy中预留扩展点
    • 通过@Rule注解实现自定义策略
  4. 防御性编程

    // 在组合计算中添加保护
    if (comboDiscount.compareTo(order.getOriginalAmount()) > 0) {
        comboDiscount = order.getOriginalAmount(); // 避免负金额
    }
    

该方案通过Drools实现核心业务规则解耦,结合策略模式处理复杂优惠逻辑,在保证灵活性的同时提供最佳用户体验。

附:可视化工具Drools Workbench

可以参考这位大佬 @java手拉手 的文章 Drools workbench代码中来整合数据,执行drools workbench中的规则。 级别salience - 掘金