第12天 优惠卷的使用
怎么解决重复提交订单?
在订单确认页生成一个预订单ID,并返回给前端,真正下订单的时候会把这个id传给后端,把这个id作为数据库主键就可以防止重复提交订单。 idwork.getid()
inner join 和外连接区别
inner join 只返回两个表链结满足条件的,left right 外连接 不满足条件的
内连接,也被称为自然连接,只有两个表相匹配的行才能在结果集中出现。返回的结果集选取了两个表中所有相匹配的数据,舍弃了不匹配的数据。由于内连接是从结果表中删除与其他连接表中没有匹配的所有行,所以内连接可能会造成信息的丢失。
外连接不仅包含符合连接条件的行,还包含左表(左连接时)、右表(右连接时)或两个边接表(全外连接)中的所有数据行。SQL外连接共有三种类型:左外连接(关键字为LEFT OUTER JOIN)、右外连接(关键字为RIGHT OUTER JOIN)和全外连接(关键字为FULL OUTER JOIN)。外连接的用法和内连接一样,只是将INNER JOIN关键字替换为相应的外连接关键字即可。
内连接只显示符合连接条件的记录,外连接除了显示符合条件的记录外,还显示表中的记录,例如,如果使用右外连接,还显示右表中的记录。

maptoint

前端传ids =[1,2,3]s时,后端用@RequestParam接收
优惠券使用

不过,新的问题来了,用户购物的时候自然要选择优惠券来使用。而现在主流的购物网站都会有优惠券的智能推荐功能,那么:
-
优惠券的类型不同,折扣计算规则该如何用代码表示?
-
如何组合优惠券使用才能让用户得到最大优惠?
-
优惠券叠加的计算算法是怎样的?
-
如果下单时使用了优惠券,用户退款时又该如何处理?


优惠券规则定义
所谓的优惠券方案推荐,就是从用户的所有优惠券中筛选出可用的优惠券,并且计算哪种优惠方案用券最少,优惠金额最高。
因此这里包含了对优惠券的下列需求:
-
判断一个优惠券是否可用,也就是检查订单金额是否达到优惠券使用门槛
-
按照优惠规则计算优惠金额,能够计算才能比较并找出最优方案
-
生成优惠券规则描述,目的是在页面直观的展示各种方案,供用户选择
package com.tianji.promotion.strategy.discount;import com.tianji.promotion.domain.po.Coupon;/*** <p>优惠券折扣功能接口</p>*/
public interface Discount {/*** 判断当前价格是否满足优惠券使用限制* @param totalAmount 订单总价* @param coupon 优惠券信息* @return 是否可以使用优惠券*/boolean canUse(int totalAmount, Coupon coupon);/*** 计算折扣金额* @param totalAmount 总金额* @param coupon 优惠券信息* @return 折扣金额*/int calculateDiscount(int totalAmount, Coupon coupon);/*** 根据优惠券规则返回规则描述信息* @return 规则描述信息*/String getRule(Coupon coupon);
}

// 工厂模式
public class DiscountStrategy {private final static EnumMap<DiscountType, Discount> strategies;static {strategies = new EnumMap<>(DiscountType.class);strategies.put(DiscountType.NO_THRESHOLD, new NoThresholdDiscount());strategies.put(DiscountType.PER_PRICE_DISCOUNT, new PerPriceDiscount());strategies.put(DiscountType.RATE_DISCOUNT, new RateDiscount());strategies.put(DiscountType.PRICE_DISCOUNT, new PriceDiscount());}public static Discount getDiscount(DiscountType type) {return strategies.get(type);}
}
根据优惠卷的类型得到对象的实现对象,然后判断传过来金额数目,判断对于这个数目这个优惠卷是否可用,优惠金额是多少,规则描述是怎样的
就比如说订单金额1000,这个1000的金额是否达到这个优惠卷的门槛了

这个是无门槛优惠卷的实现
@RequiredArgsConstructor
public class NoThresholdDiscount implements Discount{private static final String RULE_TEMPLATE = "无门槛抵{}元";@Overridepublic boolean canUse(int totalAmount, Coupon coupon) {return totalAmount > coupon.getDiscountValue();}@Overridepublic int calculateDiscount(int totalAmount, Coupon coupon) {return coupon.getDiscountValue();}@Overridepublic String getRule(Coupon coupon) {return StringUtils.format(RULE_TEMPLATE, NumberUtils.scaleToStr(coupon.getDiscountValue(), 2));}
}
优惠券智能推荐
好了,优惠券规则定义好之后,我们就可以正式开发优惠券的相关功能了。
第一个就是优惠券券方案推荐功能。在订单确认页面,前端会向交易微服务发起预下单请求,以获取id和优惠方案列表,页面请求如图:

交易服务首先需要查询课程信息,生成订单id,然后还需要调用优惠促销服务。而促销服务则需要根据订单中的课程信息查询当前用户的优惠券,并给出推荐的优惠组合方案,供用户在页面选择:

思路分析
简单来说,这就是一个查询优惠券、计算折扣、筛选最优解的过程。整体流程如下:
1.查询用户的可用优惠卷
2.初步筛选(先不看使用范围,先直接把没有达到优惠金额门槛的筛掉)
3.细晒(查询出每个优惠卷的可有范围,查看在这个范围中是否可用)
4. 全排列,对每个排列组合查看优惠卷是否可用 ,优惠金额是多少
5.使用多线程计算优惠金额
6.选择最优方案(卷相同的话,选金额最高的(因为排列顺序不同,优惠金额也可能不同),优惠金额相同,选用卷数量最少的)

代码分析
首先弄明白返回什么,前端传递的参数是什么
返回的是多个list,每个list中是这套卷组合的优惠金额

参数是课程id 分类 价格
第一步:查询当前用户的优惠卷(记得判断是否为空)

第二步:初筛(把不能使用的优惠局去掉)

第三步:细筛(根据优惠卷适用范围)
循环遍历优惠卷是否有限定范围,有限定范围的话去查找该优惠卷限定范围,看限定范围里是否有前端传来的课程,没有下一个循环,有的话看是否达到优惠卷使用门槛,最后放到map集合中。
map中放的就是 优惠卷 对应该优惠卷对应前端传来的课程中可用的课程
private Map<Coupon,List<OrderCourseDTO>> findAvailableCoupons(List<Coupon> coupons,List<OrderCourseDTO> orderCourses){Map<Coupon,List<OrderCourseDTO>> map = new HashMap<>();//循环遍历初筛后的优惠卷集合for (Coupon coupon : coupons) {// 2. 找出每一个优惠卷的可用课程,默认都可用,如果有限定范围则删选出去List<OrderCourseDTO> availableCourses = orderCourses;// 2.1 判断优惠卷是否限定了范围,没有限定范围就是默认都可用if (coupon.getSpecific()){//2.2 查询限定范围 查询coupon_scope表List<CouponScope> scopeList = couponScopeService.lambdaQuery().eq(CouponScope::getCouponId, coupon.getId()).list();// 2.3得到限定范围的id集合List<Long> scopeIds = scopeList.stream().map(CouponScope::getCouponId).collect(Collectors.toList());// 2.4 从ordercourses 订单中所有的课程集合 筛选该范围内的课程availableCourses = orderCourses.stream().filter(new Predicate<OrderCourseDTO>() {@Overridepublic boolean test(OrderCourseDTO orderCourseDTO) {return scopeIds.contains(orderCourseDTO.getCateId());}}).collect(Collectors.toList());if (CollUtils.isEmpty(availableCourses)){continue; // 没有可用课程,直接下一次循环}}// 3.计算该优惠卷是否可用 如果可用 添加到mapint totalSum = availableCourses.stream().mapToInt(OrderCourseDTO::getPrice).sum();// 判断优惠卷是否可用 如果可用 则添加到mapDiscount discount = getDiscount(coupon.getDiscountType());if (discount.canUse(totalSum,coupon)){map.put(coupon,availableCourses);}}return map;}
第四步 全排列
全排列的工具类
/*** 基于回溯算法的全排列工具类*/
public class PermuteUtil {/*** 将[0~n)的所有数字重组,生成不重复的所有排列方案** @param n 数字n* @return 排列组合*/public static List<List<Byte>> permute(int n) {List<List<Byte>> res = new ArrayList<>();List<Byte> input = new ArrayList<>(n);for (byte i = 0; i < n; i++) {input.add(i);}backtrack(n, input, res, 0);return res;}/*** 将指定集合中的元素重组,生成所有的排列组合方案** @param input 输入的集合* @param <T> 集合类型* @return 重组后的集合方案*/public static <T> List<List<T>> permute(List<T> input) {List<List<T>> res = new ArrayList<>();backtrack(input.size(), input, res, 0);return res;}private static <T> void backtrack(int n, List<T> input, List<List<T>> res, int first) {// 所有数都填完了if (first == n) {res.add(new ArrayList<>(input));}for (int i = first; i < n; i++) {// 动态维护数组Collections.swap(input, first, i);// 继续递归填下一个数backtrack(n, input, res, first + 1);// 撤销操作Collections.swap(input, first, i);}}
}
细筛之后得到map,然后取得map中所有的key,对这些key做全排列,并添加 该组合中对应的单卷

第五步 对每套排列组合做循环,得到每一套组合的优惠明细
这个dto是用来记录,使用了方案后优惠的明细

detailmap是为了记录使用了某个优惠卷之后 每个课程的优惠价格,一开始初始化为优惠金额为0
/*** 查看每一种优惠卷排序方案对应的优惠卷优惠明细* @param avaMap 能用的优惠卷 对应订单中可用的课程的map* @param courses 订单中的课程* @param solution 优惠卷使用顺序* @return 这一套优惠卷使用顺序的 优惠价格等*/private CouponDiscountDTO calculateSolutionDiscount(Map<Coupon,List<OrderCourseDTO>> avaMap,List<OrderCourseDTO> courses,List<Coupon> solution){//1.创建方案结果dto对象CouponDiscountDTO dto = new CouponDiscountDTO();// 2. 初始化商品id 和 商品折扣明细的映射,初始折扣明细全为0,设置map key为 商品的id value初始值都为0Map<Long, Integer> detailMap = courses.stream().collect(Collectors.toMap(OrderCourseDTO::getId, c -> 0));// 3. 循环方案,计算优惠信息for (Coupon coupon : solution) {// 得出该优惠卷对应的可使用课程List<OrderCourseDTO> availableCourses = avaMap.get(coupon);// 计算可用课程的总金额(商品价格-该课程的折扣明细)int totalAmount = availableCourses.stream().mapToInt(value -> value.getPrice() - detailMap.get(value.getId())).sum();// 判断优惠卷是否可用Discount discount = getDiscount(coupon.getDiscountType());if (!discount.canUse(totalAmount,coupon)){continue; // 不可用 跳出循环 继续处理下一次循环}// 计算该优惠卷使用后的折扣值int discountAmount = discount.calculateDiscount(totalAmount, coupon);// 计算商品的折扣明细 更新到 detailMapcalculateDiscountDetails(detailMap, availableCourses, totalAmount, discountAmount);// 累加每一个优惠卷的优惠金额 赋值给方案结果dto对象dto.getIds().add(coupon.getId()); // 只要执行这句话一维这这个优惠卷生效了dto.getRules().add(discount.getRule(coupon));dto.setDiscountAmount(discountAmount + dto.getDiscountAmount()); // 不能覆盖 应该是所有生效的优惠卷 累加的结果}return dto;}
计算折扣明细,为了防止出现无穷,最后一个课程的折扣金额用 总的折扣金额 - 前面的课程的折扣金额
多线程改造计算优惠明细
CountDownLatch latch = new CountDownLatch(solutions.size());for (List<Coupon> solution : solutions) {CompletableFuture.supplyAsync(new Supplier<CouponDiscountDTO>() {@Overridepublic CouponDiscountDTO get() {CouponDiscountDTO dto = calculateSolutionDiscount(availableCouponMap,orderCourses,solution);return dto;}},discountSolutionExecutor).thenAccept(new Consumer<CouponDiscountDTO>() { // 上面return的dto就是下面的参数@Overridepublic void accept(CouponDiscountDTO dto) {dtos.add(dto);latch.countDown(); // 计数器减一}});try {latch.await(2, TimeUnit.SECONDS);} catch (InterruptedException e) {log.error("多线程计算优惠明细报错!!!");}}
最后一步:计算最优解
现在,我们计算出了成吨的优惠方案及其优惠金额,但是该如何从其中筛选出最优方案呢?最优方案的标准又是什么呢?
首先来看最优标准:
-
用券相同时,优惠金额最高的方案
-
优惠金额相同时,用券最少的方案
其实寻找最优解的流程跟找数组中最小值类似:
-
定义一个变量记录最小值
-
逐个遍历数组,判断当前元素是否比最小值更小
-
如果是,则覆盖最小值;如果否,则放弃
-
循环结束,变量中记录的就是最小值
例子:
比如 最开始 卷1 3 2 优惠金额是50,放入两个map中,然后是 卷 1 2 3 优惠金额20,小于之前的优惠金额,跳过,卷3 2 1 优惠金额是70,则会替换左边map的优惠方案,同时在右边map中新增一个70的键值对,卷1 2优惠金额也是70,则会替换掉原来70的值。这样下来,左边的map 同一个组合的只有一个。取交集正好满足最优方案。
private List<CouponDiscountDTO> findBestSolution(List<CouponDiscountDTO> solutions) {// 1. 创建两个map 分别记录用卷相同,金额最高 金额相同,用卷最少Map<String ,CouponDiscountDTO> moreDiscountMap = new HashMap<>();Map<Integer ,CouponDiscountDTO> lessCouponMap = new HashMap<>();// 2 循环方案,向map中记录 用卷相同 金额最高 金额相同,用卷最少for (CouponDiscountDTO solution : solutions) {// 2.1 对优惠卷id升序,转字符串然后以逗号拼接String ids = solution.getIds().stream().sorted(Comparator.comparing(Long::longValue)).map(String::valueOf).collect(Collectors.joining(","));// 2.2 从 moreDiscountMap中取 旧的记录判断旧方案是否大于等于 当前优惠方案CouponDiscountDTO old = moreDiscountMap.get(ids);if (old!=null && old.getDiscountAmount()>= solution.getDiscountAmount()){continue;}// 2.从lessCouponMap中取旧的记录 判断旧的方案用卷数量 小于当前方案用卷数量old = lessCouponMap.get(solution.getDiscountAmount());int newSize = solution.getIds().size(); //当前方案的用卷数量if (old!=null && newSize>1 && old.getIds().size() <= newSize){continue;}moreDiscountMap.put(ids,solution);lessCouponMap.put(solution.getDiscountAmount(),solution);}Collection<CouponDiscountDTO> bestSolution = CollUtils.intersection(moreDiscountMap.values(), lessCouponMap.values());// 排序 优惠金额降序return bestSolution.stream().sorted(Comparator.comparing(CouponDiscountDTO::getDiscountAmount).reversed()).collect(Collectors.toList());}

