前言
根据上一节课的了解,我们知道我们优惠卷是有不同的规则的;
优惠券规则从类型来说就4种:
- 每满减:例如每满100减10
- 折扣:例如满100打9.5折,最大不超过50
- 无门槛:例如直接抵扣10元
- 满减:例如满100减15 优惠类型(discountType)对应到数据库的字段就是discount_type.
另外,优惠券描述中基本都会包含下列字段:
- 优惠门槛(thresholdAmount):也就是优惠的基本条件,例如满100
- 优惠值(discountValue):也就是具体折扣,例如减10、打9.5折
- 最大优惠(maxDiscountValue):最大折扣,限制折扣金额
对应的数据库表结构如下:
因此各种各样的优惠券其规则都可以用上述4个字段标示,而规则根据优惠类型(discountType)来看就分为4种,不同优惠仅仅是其它3个字段值不同而已。
像这种定义使用场景可以利用策略模式来定义规则
首先我们定义了一个优惠卷使用的通用接口:主要是通用的三个方法
- 判断一个优惠券是否可用,也就是检查订单金额是否达到优惠券使用门槛
- 按照优惠规则计算优惠金额,能够计算才能比较并找出最优方案
- 生成优惠券规则描述,目的是在页面直观的展示各种方案,供用户选择
Discount.java
/**
* <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 abstract class BaseDiscount {
Integer discountValue;
Integer thresholdAmount;
Integer maxDiscountAmount;
}
最后定义了一个简单工厂类,用于存放各种策略
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);
}
}
以每满100减10最高减30为例子,这个优惠策略定义如下:继承抽象类,实现通用接口
@RequiredArgsConstructor
public class PerPriceDiscount extends BaseDiscount implements Discount{
private final static String RULE_TEMPLATE = "每满{}减{},上限{}";
public PerPriceDiscount(Integer discountValue, Integer thresholdAmount, Integer maxDiscountAmount) {
this.discountValue = discountValue;
this.thresholdAmount = thresholdAmount;
this.maxDiscountAmount = maxDiscountAmount;
}
@Override
public boolean canUse(int totalAmount, Coupon coupon) {
return totalAmount >= coupon.getThresholdAmount();
}
@Override
public int calculateDiscount(int totalAmount, Coupon coupon) {
int discount = 0;
Integer thresholdAmount = coupon.getThresholdAmount();
Integer discountValue = coupon.getDiscountValue();
while (totalAmount >= thresholdAmount) {
discount += discountValue;
totalAmount -= thresholdAmount;
}
return Math.min(discount, coupon.getMaxDiscountAmount());
}
@Override
public String getRule(Coupon coupon) {
return StringUtils.format(
RULE_TEMPLATE,
NumberUtils.scaleToStr(coupon.getThresholdAmount(), 2),
NumberUtils.scaleToStr(coupon.getDiscountValue(), 2),
NumberUtils.scaleToStr(coupon.getMaxDiscountAmount(), 2));
}
}
- Discount:规则策略接口
- NoThresholdDiscount:无门槛折扣类型规则
- PerPriceDiscount:每满减折扣类型规则
- PriceDiscount:满减类型规则
- RateDiscount:打折类型规则
- DiscountStrategy:折扣策略的工厂,可以根据DiscountType枚举来获取某个折扣策略对象
业务流程分析
优惠券的使用流程就是下单购物的流程,核心流程如下
优惠券智能推荐
好了,优惠券规则定义好之后,我们就可以正式开发优惠券的相关功能了。
第一个就是优惠券券方案推荐功能。在订单确认页面,前端会向交易微服务发起预下单请求,以获取id和优惠方案列表,页面请求如图:
可以看出这个请求的地址是 /ts/orders,也就是交易服务。交易服务首先需要查询课程信息,生成订单id,然后还需要调用优惠促销服务。而促销服务则需要根据订单中的课程信息查询当前用户的优惠券,并给出推荐的优惠组合方案,供用户在页面选择:
我们要实现的就是优惠券的查询和组合方案推荐的部分。
思路分析
简单来说,这就是一个查询优惠券、计算折扣、筛选最优解的过程。整体流程如下:
最终找出优惠券组合方案的最优解,需要满足:
- 用券相同时,优惠金额最高的方案
- 优惠金额相同时,用券最少的方案
OK,接下来我们就逐步来完成这个功能,大概步骤包括:
- 定义接口
- 查询用户的优惠券
- 初步筛选
- 细筛并完成全排列
- 计算优惠明细
- 基于CompleteableFuture做并行计算
- 筛选最优解
查询用户所拥有的优惠卷
编写的SQL语句如下:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.tianji.promotion.mapper.UserCouponMapper">
<select id="queryMyCoupons" resultType="com.tianji.promotion.domain.po.Coupon">
SELECT c.id, c.discount_type, c.`specific`, c.discount_value, c.threshold_amount,
c.max_discount_amount, uc.id AS creater
FROM user_coupon uc
INNER JOIN coupon c ON uc.coupon_id = c.id
WHERE uc.user_id = #{userId} AND uc.status = 1
</select>
</mapper>
实现筛选
细筛步骤有两步:
- 首先要基于优惠券的限定范围对课程筛选,找出可用课程。如果没有可用课程,则优惠券不可用。
- 然后对可用课程计算总价,判断是否达到优惠门槛,没有达到门槛则优惠券不可用
代码如下:
private final ICouponScopeService scopeService;
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) {
// 1.找出优惠券的可用的课程
List<OrderCourseDTO> availableCourses = courses;
if (coupon.getSpecific()) {
// 1.1.限定了范围,查询券的可用范围
List<CouponScope> scopes = scopeService.lambdaQuery().eq(CouponScope::getCouponId, coupon.getId()).list();
// 1.2.获取范围对应的分类id
Set<Long> scopeIds = scopes.stream().map(CouponScope::getBizId).collect(Collectors.toSet());
// 1.3.筛选课程
availableCourses = courses.stream()
.filter(c -> scopeIds.contains(c.getCateId())).collect(Collectors.toList());
}
if (CollUtils.isEmpty(availableCourses)) {
// 没有任何可用课程,抛弃
continue;
}
// 2.计算课程总价
int totalAmount = availableCourses.stream().mapToInt(OrderCourseDTO::getPrice).sum();
// 3.判断是否可用
Discount discount = DiscountStrategy.getDiscount(coupon.getDiscountType());
if (discount.canUse(totalAmount, coupon)) {
map.put(coupon, availableCourses);
}
}
return map;
}
对符合条件的优惠卷进行全排列组合
在完成优惠券细筛以后,我们就可以拿到所有的优惠券。由于优惠券的使用顺序不同,可能导致最终的优惠金额不同。 我们要找出优惠金额最高的优惠券组合,就必须先找出所有的排列组合,然后分别计算出优惠金额,然后对比并找出最优解。 这里我们采用的思路是这样的:
- 优惠券放在一个List集合中,他们的角标就是0~N的数字
- 找优惠券的全排列组合,就是找N个不重复数字的全排列组合
- 例如2个数字:[0,1],排列就包含:[0,1]、[1,0]两种
- 然后按照角标排列优惠券即可
@Slf4j
@Service
@RequiredArgsConstructor
public class DiscountServiceImpl implements IDiscountService {
private final UserCouponMapper userCouponMapper;
private final ICouponScopeService scopeService;
@Override
public List<CouponDiscountDTO> findDiscountSolution(List<OrderCourseDTO> orderCourses) {
// 1.查询我的所有可用优惠券
List<Coupon> coupons = userCouponMapper.queryMyCoupons(UserContext.getUser());
if (CollUtils.isEmpty(coupons)) {
return CollUtils.emptyList();
}
// 2.初筛
// 2.1.计算订单总价
int totalAmount = orderCourses.stream().mapToInt(OrderCourseDTO::getPrice).sum();
// 2.2.筛选可用券
List<Coupon> availableCoupons = coupons.stream()
.filter(c -> DiscountStrategy.getDiscount(c.getDiscountType()).canUse(totalAmount, c))
.collect(Collectors.toList());
if (CollUtils.isEmpty(availableCoupons)) {
return CollUtils.emptyList();
}
// 3.排列组合出所有方案
// 3.1.细筛(找出每一个优惠券的可用的课程,判断课程总价是否达到优惠券的使用需求)
Map<Coupon, List<OrderCourseDTO>> availableCouponMap = findAvailableCoupon(availableCoupons, orderCourses);
if (CollUtils.isEmpty(availableCouponMap)) {
return CollUtils.emptyList();
}
// 3.2.排列组合
availableCoupons = new ArrayList<>(availableCouponMap.keySet());
List<List<Coupon>> solutions = PermuteUtil.permute(availableCoupons);
// 3.3.添加单券的方案
for (Coupon c : availableCoupons) {
solutions.add(List.of(c));
}
// 4.计算方案的优惠明细
// 5.筛选最优解
return null;
}
}
接下来就是对优惠明细的计算了
计算优惠明细
单张优惠券算法
单张优惠券的优惠金额计算流程如下:
- 1)判断优惠券限定范围,找出范围内的课程
- 2)计算课程总价
- 3)判断券是否可用
- 4)计算优惠金额
例如现在有一些商品:
然后有一张优惠券:
我们按照上述算法来判断:
- 1)判断限定范围:这张券限定分类 b,对应的商品序号是2、3
- 2)计算课程总价:商品序号2、3的总价为200
- 3)判断是否可用:总价刚好达到优惠券满减门槛200,可以使用
- 4)计算优惠:满200减100,因此最终优惠金额就是100元
券叠加算法
券叠加就是按照券组合的顺序,依次计算每张券的优惠金额,最终优惠金额就是所有权的优惠累加。
需要注意的是:由于一张券计算完优惠后,商品的金额会发生变化,因此下一张券的计算金额会随之改变,因此券叠加的顺序非常重要。
而且为了方便计算后续券的优惠金额,我们必须知道商品金额具体的变化,也就是弄清楚每一张优惠券使用后,每个商品的具体优惠金额,我们称之为优惠明细,我们可以用一个表格来记录:
因此,券叠加算法比单券算法需要多一步:
- 1)判断优惠券限定范围,找出范围内的课程
- 2)计算课程总价
- 3)判断券是否可用
- 4)计算优惠金额
- 5)计算优惠明细
例如现在有一些商品:
然后有一组优惠券:
最终的计算步骤如下:
券1的计算步骤如下:
- 1)判断范围:券1可用于所有分类,因此商品序号1、2、3都可以用
- 2)计算总价:所有商品累加共300元
- 3)判断是否可用:券1门槛是100,符合要求
- 4)计算优惠金额:每满100减20,因此总共折扣就是60元
- 5)计算优惠明细:
优惠明细的计算算法如下:
- 正常情况下,按照商品价格在商品总价中的比例,乘以优惠总金额
- 最后一个商品,为了避免出现精度损失导致的金额不一致,最后一个商品的优惠明细等于优惠总金额减去其它商品的优惠明细之和
例如,商品1、2的折扣:(100 / 300)* 60 = 20 ,商品3的折扣等于:60 - 20 - 20 = 20
券2的计算步骤如下:
- 1)判断范围:券2可用于分类b,因此商品序号2、3都可以用
- 2)计算总价:商品2已经优惠了20,现在价格是80,商品3已经优惠了20,现在价格是80。因此商品总价是160
- 3)判断是否可用:券2门槛是200,不符合要求,跳过
券3的计算步骤如下:
- 1)判断范围:券3可用于所有分类,因此商品序号1可以用
- 2)计算总价:商品1原价100元,已经优惠20,现价80元
- 3)判断是否可用:券3门槛是80,符合要求
- 4)计算优惠金额:满80减20,因此总共折扣就是20元
- 5)计算优惠明细:由于只有商品1可用,商品1优惠明细就是20元
编码实现算法 首先是查询优惠方案的主体方法,如下:
@Slf4j
@Service
@RequiredArgsConstructor
public class DiscountServiceImpl implements IDiscountService {
private final UserCouponMapper userCouponMapper;
@Override
public List<CouponDiscountDTO> findDiscountSolution(List<OrderCourseDTO> orderCourses) {
// 1.查询我的所有可用优惠券
List<Coupon> coupons = userCouponMapper.queryMyCoupons(UserContext.getUser());
if (CollUtils.isEmpty(coupons)) {
return CollUtils.emptyList();
}
// 2.初筛
// 2.1.计算订单总价
int totalAmount = orderCourses.stream().mapToInt(OrderCourseDTO::getPrice).sum();
// 2.2.筛选可用券
List<Coupon> availableCoupons = coupons.stream()
.filter(c -> DiscountStrategy.getDiscount(c.getDiscountType()).canUse(totalAmount, c))
.collect(Collectors.toList());
if (CollUtils.isEmpty(availableCoupons)) {
return CollUtils.emptyList();
}
// 3.排列组合出所有方案
// 3.1.细筛(找出每一个优惠券的可用的课程,判断课程总价是否达到优惠券的使用需求)
Map<Coupon, List<OrderCourseDTO>> availableCouponMap = findAvailableCoupon(availableCoupons, orderCourses);
if (CollUtils.isEmpty(availableCouponMap)) {
return CollUtils.emptyList();
}
// 3.2.排列组合
availableCoupons = new ArrayList<>(availableCouponMap.keySet());
List<List<Coupon>> solutions = PermuteUtil.permute(availableCoupons);
// 3.3.添加单券的方案
for (Coupon c : availableCoupons) {
solutions.add(List.of(c));
}
// 4.计算方案的优惠明细
List<CouponDiscountDTO> list =
Collections.synchronizedList(new ArrayList<>(solutions.size()));
for (List<Coupon> solution : solutions) {
list.add(calculateSolutionDiscount(availableCouponMap, orderCourses, solution));
}
// 5.筛选最优解
return null;
}
// ... 略
}
具体的计算逻辑同样在DiscountServiceImpl中,具体如下:
private CouponDiscountDTO calculateSolutionDiscount(
Map<Coupon, List<OrderCourseDTO>> couponMap, List<OrderCourseDTO> courses, List<Coupon> solution) {
// 1.初始化DTO
CouponDiscountDTO dto = new CouponDiscountDTO();
// 2.初始化折扣明细的映射
Map<Long, Integer> detailMap = courses.stream().collect(Collectors.toMap(OrderCourseDTO::getId, oc -> 0));
// 3.计算折扣
for (Coupon coupon : solution) {
// 3.1.获取优惠券限定范围对应的课程
List<OrderCourseDTO> availableCourses = couponMap.get(coupon);
// 3.2.计算课程总价(课程原价 - 折扣明细)
int totalAmount = availableCourses.stream()
.mapToInt(oc -> oc.getPrice() - detailMap.get(oc.getId())).sum();
// 3.3.判断是否可用
Discount discount = DiscountStrategy.getDiscount(coupon.getDiscountType());
if (!discount.canUse(totalAmount, coupon)) {
// 券不可用,跳过
continue;
}
// 3.4.计算优惠金额
int discountAmount = discount.calculateDiscount(totalAmount, coupon);
// 3.5.计算优惠明细
calculateDiscountDetails(detailMap, availableCourses, totalAmount, discountAmount);
// 3.6.更新DTO数据
dto.getIds().add(coupon.getCreater());
dto.getRules().add(discount.getRule(coupon));
dto.setDiscountAmount(discountAmount + dto.getDiscountAmount());
}
return dto;
}
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()));
}
}
CompleteableFuture并发计算
可以发现,上节的优惠券算法还是比较复杂的。而且由于优惠方案很多,目前我们此案有的是for循环逐个方案串行计算,整体性能可想而知。
所以,为了提高计算效率,我们可以利用多线程并行计算。具体步骤如下:
- 定义一个线程池
- for循环将每个方案交给一个线程去任务执行
- 等待所有任务计算完毕,返回结果
这里的难点有两个:
- 1)线程任务是带返回值的任务
- 2)虽然是多线程运行,但是我们要等待所有线程都执行完毕后才返回结果
针对第二个点,我们可以利用JUC包提供的工具CountDownLatch来实现。 针对第一个点,我们则需要利用一个JDK1.8的新工具:CompletableFuture来实现。
设置核心参数
@Slf4j
@Configuration
public class PromotionConfig {
// ... 略
@Bean
public Executor discountSolutionExecutor(){
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 1.核心线程池大小
executor.setCorePoolSize(12);
// 2.最大线程池大小
executor.setMaxPoolSize(12);
// 3.队列大小
executor.setQueueCapacity(99999);
// 4.线程名称
executor.setThreadNamePrefix("discount-solution-calculator-");
// 5.拒绝策略
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
executor.initialize();
return executor;
}
}
计算优惠方案使用CompleatableFature实现
// 4.计算方案的优惠明细
List<CouponDiscountDTO> list = Collections.synchronizedList(new ArrayList<>(solutions.size()));
// 4.1.定义闭锁
CountDownLatch latch = new CountDownLatch(solutions.size());
for (List<Coupon> solution : solutions) {
// 4.2.异步计算
CompletableFuture
.supplyAsync(
() -> calculateSolutionDiscount(availableCouponMap, orderCourses, solution),
discountSolutionExecutor
).thenAccept(dto -> {
// 4.3.提交任务结果
list.add(dto);
latch.countDown();
});
}
// 4.4.等待运算结束
try {
latch.await(1, TimeUnit.SECONDS);
} catch (InterruptedException e) {
log.error("优惠方案计算被中断,{}", e.getMessage());
}
筛选最优解
现在,我们计算出了成吨的优惠方案及其优惠金额,但是该如何从其中筛选出最优方案呢?最优方案的标准又是什么呢?
首先来看最优标准:
- 用券相同时,优惠金额最高的方案
- 优惠金额相同时,用券最少的方案
这里的用券相同只关心用了哪些券,不关心顺序。例如【1,2,3】、【2,1,3】、【3,1,2】都用了1、2、3这三张券,属于用券相同,我们要找出其中优惠金额最高的方案。再比如:【1,2,3】和【2,3】、【3,1】就是用券不同,就需要分别去找优惠金额更高的。
那么该如何寻找最优解呢?
其实寻找最优解的流程跟找数组中最小值类似:
- 定义一个变量记录最小值
- 逐个遍历数组,判断当前元素是否比最小值更小
- 如果是,则覆盖最小值;如果否,则放弃
- 循环结束,变量中记录的就是最小值
我们寻找最优解可以参考上述过程,不过略有差异,核心原因是:券组合有多种,因此最优解不止一个。因此我们不能用一个变量类记录最优解,而是用Map来记录,结构如下:
其中:
- 第一个Map用来记录用券相同时,优惠金额最高的方案;
- 第二个Map用来记录优惠金额相同时,用券最少的方案。
最终,两个Map的values的交集就是我们要找的最优解。
核心代码如下:
// 5.筛选最优解
return findBestSolution(list);
}
private List<CouponDiscountDTO> findBestSolution(List<CouponDiscountDTO> list) {
// 1.准备Map记录最优解
Map<String, CouponDiscountDTO> moreDiscountMap = new HashMap<>();
Map<Integer, CouponDiscountDTO> lessCouponMap = new HashMap<>();
// 2.遍历,筛选最优解
for (CouponDiscountDTO solution : list) {
// 2.1.计算当前方案的id组合
String ids = solution.getIds().stream()
.sorted(Long::compare).map(String::valueOf).collect(Collectors.joining(","));
// 2.2.比较用券相同时,优惠金额是否最大
CouponDiscountDTO best = moreDiscountMap.get(ids);
if (best != null && best.getDiscountAmount() >= solution.getDiscountAmount()) {
// 当前方案优惠金额少,跳过
continue;
}
// 2.3.比较金额相同时,用券数量是否最少
best = lessCouponMap.get(solution.getDiscountAmount());
int size = solution.getIds().size();
if (size > 1 && best != null && best.getIds().size() <= size) {
// 当前方案用券更多,放弃
continue;
}
// 2.4.更新最优解
moreDiscountMap.put(ids, solution);
lessCouponMap.put(solution.getDiscountAmount(), solution);
}
// 3.求交集
Collection<CouponDiscountDTO> bestSolutions = CollUtils
.intersection(moreDiscountMap.values(), lessCouponMap.values());
// 4.排序,按优惠金额降序
return bestSolutions.stream()
.sorted(Comparator.comparingInt(CouponDiscountDTO::getDiscountAmount).reversed())
.collect(Collectors.toList());
}
总结:
你们的优惠券规则是如何编码实现的?
我们的优惠规则是基于策略模式来定义的。在初期做调研的时候也考虑过规则引擎,不过考虑到我们的优惠规则并不复杂,而且规则引擎太重,增加了学习和维护成本,最终选择了基于策略模式来自定义规则。
你在项目中有没有使用到设计模式?
当然用到过,比如在优惠券功能中就使用了策略模式来定义优惠规则。还有我实现的基于注解的通用分布式锁组件,也使用到了策略模式、工厂模式
你在项目中有没有使用到线程池或者并发编程?
当然,项目中很多地方都有用到。比如在实现优惠券的推荐算法时,我们采用的是排列组合多种优惠方案,然后分别计算,最终筛选出最优解的思路。
由于需要计算的优惠方案可能较多,为了提高计算效率,我们利用了CompletableFuture来实现多方案的并行计算。并且由于要筛选最优解,那就需要等待所有方案都计算完毕,再来筛选。因此就使用了CountdownLatch来做多线程的并行控制。
那你能不能聊一聊CountdownLatch的基本原理?
参考我之前写的一篇文章 :CountDownLatcn在实战中的使用
使用优惠券的订单可能包含多个商品,如果出现部分商品退款的情况,你们如何处理退款金额?优惠券是如何处理的?
这里处理的方案有很多种,可以选择退券或不退券。不过基于产品的需求,我们采用的是不退券的方案。
具体来说,就是在一开始下单时,就会根据优惠券本身的使用范围,筛选出订单中可以参与优惠的商品,然后计算出每一个被优惠的商品具体的优惠金额分成,以及对应的实付金额。
而在退款的时候,如果用户选择只退部分商品,我们就可以根据每个商品的实付金额来退款,实现订单拆分退款。同时也满足退款不退券的原则。
当然,如果订单未支付,直接取消或者超时关闭,是可以退还优惠券的。
评论( 0 )