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

整体流程概述
优惠方案计算主要在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工具),这些技术的详细解释会在后面的文章中给出
