项目中优惠券计算逻辑全解析(处理高并发)

其实这个部分的代码已经完成一阵子了,但是想了一下决定还是整理一下这部分的代码,因为最开始做的时候业务逻辑还是感觉挺有难度的

整体流程概述

优惠方案计算主要在DiscountServiceImpl类的findDiscountSolution方法中实现。整个计算过程可以分为以下五个步骤:

①查询用户可用优惠券

②初步筛选可用优惠券

③细化筛选并生成优惠券组合

④并行计算各种组合的优惠明细

⑥筛选最优方案

下面我们来逐一分析每个步骤的具体实现

第一步:查询用户可用优惠券

首先,系统需要获取当前用户持有的所有优惠券:

java 复制代码
Long user = UserContext.getUser();
List<Coupon> coupons = userCouponMapper.queryMyCoupons(user);

这一步通过用户上下文获取当前用户ID,然后查询该用户持有的所有未过期、未使用的优惠券。

第二步:初步筛选可用优惠券

初步筛选是基于订单总价进行的。系统会计算订单中所有课程的总价,然后筛选出满足使用门槛的优惠券:

java 复制代码
// 计算订单总价
int sum = orderCourses.stream()
        .mapToInt(OrderCourseDTO::getPrice)
        .sum();

// 筛选可用券
List<Coupon> availableCoupons = coupons.stream()
        .filter(c -> DiscountStrategy.getDiscount(c.getDiscountType()).canUse(sum, c))
        .collect(Collectors.toList());

这里使用了策略模式,根据优惠券类型获取对应的折扣计算策略,然后判断该优惠券是否可以在当前订单总价下使用。

第三步:细化筛选并生成优惠券组合

这一步是最复杂的,它包含两个子步骤:

3.1 细化筛选(找出每个优惠券的可用课程)

对于每张优惠券,需要根据其限定范围筛选出订单中可用的课程,并判断这些课程的总价是否满足优惠券使用条件:

java 复制代码
private Map<Coupon, List<OrderCourseDTO>> findAvailableCoupon(List<Coupon> coupons, List<OrderCourseDTO> courses) {
    Map<Coupon, List<OrderCourseDTO>> map = new HashMap<>(coupons.size());
    for (Coupon coupon : coupons) {
        // 找出优惠券的可用课程
        List<OrderCourseDTO> availableCourses = courses;
        if (coupon.getSpecific()) {
            // 如果优惠券限定了范围,查询券的可用范围
            List<CouponScope> scopes = scopeService.lambdaQuery()
                    .eq(CouponScope::getCouponId, coupon.getId()).list();
            // 获取范围对应的分类id
            Set<Long> scopeIds = scopes.stream()
                    .map(CouponScope::getBizId).collect(Collectors.toSet());
            // 筛选课程
            availableCourses = courses.stream()
                    .filter(c -> scopeIds.contains(c.getCateId()))
                    .collect(Collectors.toList());
        }
        
        if (CollUtils.isEmpty(availableCourses)) {
            // 没有任何可用课程,抛弃
            continue;
        }
        
        // 计算课程总价并判断是否可用
        int totalAmount = availableCourses.stream()
                .mapToInt(OrderCourseDTO::getPrice).sum();
        Discount discount = DiscountStrategy.getDiscount(coupon.getDiscountType());
        if (discount.canUse(totalAmount, coupon)) {
            map.put(coupon, availableCourses);
        }
    }
    return map;
}
3.2 生成优惠券组合方案

通过排列组合算法生成所有可能的优惠券组合,并添加单张优惠券的方案:

java 复制代码
availableCoupons = new ArrayList<>(availableCouponMap.keySet());
List<List<Coupon>> solutions = PermuteUtil.permute(availableCoupons);
// 添加单券的方案
for (Coupon c : availableCoupons) {
    solutions.add(List.of(c));
}

第四步:并行计算各种组合的优惠明细

对于生成的每种优惠券组合方案,系统会并行计算其优惠金额。这里使用了CompletableFuture和CountDownLatch来实现异步并行计算:

java 复制代码
List<CouponDiscountDTO> list = Collections.synchronizedList(new ArrayList<>(solutions.size()));
// 定义闭锁
CountDownLatch latch = new CountDownLatch(solutions.size());
for (List<Coupon> solution : solutions) {
    // 异步计算
    CompletableFuture
            .supplyAsync(
                    () -> calculateSolutionDiscount(availableCouponMap, orderCourses, solution),
                    discountSolutionExecutor
            ).thenAccept(dto -> {
                // 提交任务结果
                list.add(dto);
                latch.countDown();
            });
}
// 等待运算结束
try {
    latch.await(1, TimeUnit.SECONDS);
} catch (InterruptedException e) {
    log.error("优惠方案计算被中断,{}", e.getMessage());
}

其中,calculateSolutionDiscount方法负责具体计算一个组合方案的优惠明细:

java 复制代码
private CouponDiscountDTO calculateSolutionDiscount(
        Map<Coupon, List<OrderCourseDTO>> couponMap, 
        List<OrderCourseDTO> courses, 
        List<Coupon> solution) {
    // 初始化DTO
    CouponDiscountDTO dto = new CouponDiscountDTO();
    // 初始化折扣明细的映射
    Map<Long, Integer> detailMap = courses.stream()
            .collect(Collectors.toMap(OrderCourseDTO::getId, oc -> 0));
    
    // 计算折扣
    for (Coupon coupon : solution) {
        // 获取优惠券限定范围对应的课程
        List<OrderCourseDTO> availableCourses = couponMap.get(coupon);
        // 计算课程总价(课程原价 - 折扣明细)
        int totalAmount = availableCourses.stream()
                .mapToInt(oc -> oc.getPrice() - detailMap.get(oc.getId())).sum();
        // 判断是否可用
        Discount discount = DiscountStrategy.getDiscount(coupon.getDiscountType());
        if (!discount.canUse(totalAmount, coupon)) {
            // 券不可用,跳过
            continue;
        }
        // 计算优惠金额
        int discountAmount = discount.calculateDiscount(totalAmount, coupon);
        // 计算优惠明细
        calculateDiscountDetails(detailMap, availableCourses, totalAmount, discountAmount);
        // 更新DTO数据
        dto.getIds().add(coupon.getCreater());
        dto.getRules().add(discount.getRule(coupon));
        dto.setDiscountAmount(discountAmount + dto.getDiscountAmount());
    }
    return dto;
}

优惠明细的计算通过calculateDiscountDetails方法实现,它将总优惠金额按比例分摊到各个课程上:

java 复制代码
private void calculateDiscountDetails(
        Map<Long, Integer> detailMap, 
        List<OrderCourseDTO> courses, 
        int totalAmount, 
        int discountAmount) {
    int times = 0;
    int remainDiscount = discountAmount;
    for (OrderCourseDTO course : courses) {
        times++;
        int discount = 0;
        // 判断是否是最后一个课程
        if (times == courses.size()) {
            // 是最后一个课程,总折扣金额 - 之前所有商品的折扣金额之和
            discount = remainDiscount;
        } else {
            // 计算折扣明细(课程价格在总价中占的比例,乘以总的折扣)
            discount = discountAmount * course.getPrice() / totalAmount;
            remainDiscount -= discount;
        }
        // 更新折扣明细
        detailMap.put(course.getId(), discount + detailMap.get(course.getId()));
    }
}

第五步:筛选最优方案

最后一步是从所有可行的优惠方案中筛选出最优方案。最优方案的判断标准是:

①在使用相同优惠券组合的情况下,优惠金额最大

②在优惠金额相同的情况下,使用的优惠券数量最少

java 复制代码
private List<CouponDiscountDTO> findBestSolution(List<CouponDiscountDTO> list) {
    // 准备Map记录最优解
    Map<String, CouponDiscountDTO> moreDiscountMap = new HashMap<>();
    Map<Integer, CouponDiscountDTO> lessCouponMap = new HashMap<>();
    
    // 遍历,筛选最优解
    for (CouponDiscountDTO solution : list) {
        // 计算当前方案的id组合
        String ids = solution.getIds().stream()
                .sorted(Long::compare)
                .map(String::valueOf)
                .collect(Collectors.joining(","));
        
        // 比较用券相同时,优惠金额是否最大
        CouponDiscountDTO best = moreDiscountMap.get(ids);
        if (best != null && best.getDiscountAmount() >= solution.getDiscountAmount()) {
            // 当前方案优惠金额少,跳过
            continue;
        }
        
        // 比较金额相同时,用券数量是否最少
        best = lessCouponMap.get(solution.getDiscountAmount());
        int size = solution.getIds().size();
        if (size > 1 && best != null && best.getIds().size() <= size) {
            // 当前方案用券更多,放弃
            continue;
        }
        
        // 更新最优解
        moreDiscountMap.put(ids, solution);
        lessCouponMap.put(solution.getDiscountAmount(), solution);
    }
    
    // 求交集
    Collection<CouponDiscountDTO> bestSolutions = CollUtils
            .intersection(moreDiscountMap.values(), lessCouponMap.values());
    
    // 排序,按优惠金额降序
    return bestSolutions.stream()
            .sorted(Comparator.comparingInt(CouponDiscountDTO::getDiscountAmount).reversed())
            .collect(Collectors.toList());
}

总结

优惠方案计算通过以上五个步骤,能够为用户推荐最优化的优惠券使用方案。整个过程考虑了以下关键因素:

①优惠券的适用范围和使用门槛

②多张优惠券的组合使用

③并行计算提高性能

④优惠金额在订单商品间的合理分摊

⑤最优方案的选择策略

这种设计既保证了计算结果的准确性,又通过并行计算提高了性能,为用户提供了良好的购物体验,最后对于这其中所用到的一些新的技术,如(策略模式,CountdownLatch工具和CompletableFuture工具),这些技术的详细解释会在后面的文章中给出