优惠券管理
开发流程
需求分析,接口统计,数据库设计,创建分支,创建新模块(依赖,配置,启动类),生成代码,引入枚举状态
优惠券管理
增删改查的业务代码,没有新的知识点
新增优惠券
@Override@Transactionalpublic void saveCoupon(CouponFormDTO dto) {// 1.保存优惠券// 1.1.转POCoupon coupon = BeanUtils.copyBean(dto, Coupon.class);// 1.2.保存save(coupon);if (!dto.getSpecific()) {// 没有范围限定return;}Long couponId = coupon.getId();// 2.保存限定范围List<Long> scopes = dto.getScopes();if (CollUtils.isEmpty(scopes)) {throw new BadRequestException("限定范围不能为空");}// 2.1.转换POList<CouponScope> list = scopes.stream().map(bizId -> new CouponScope().setBizId(bizId).setCouponId(couponId)).collect(Collectors.toList());// 2.2.保存scopeService.saveBatch(list);}
分页查询优惠券
@Override
public PageDTO<CouponPageVO> queryCouponByPage(CouponQuery query) {Integer status = query.getStatus();String name = query.getName();Integer type = query.getType();// 1.分页查询Page<Coupon> page = lambdaQuery().eq(type != null, Coupon::getDiscountType, type).eq(status != null, Coupon::getStatus, status).like(StringUtils.isNotBlank(name), Coupon::getName, name).page(query.toMpPageDefaultSortByCreateTimeDesc());// 2.处理VOList<Coupon> records = page.getRecords();if (CollUtils.isEmpty(records)) {return PageDTO.empty(page);}List<CouponPageVO> list = BeanUtils.copyList(records, CouponPageVO.class);// 3.返回return PageDTO.of(page, list);
}
修改优惠券(练习)
@Overridepublic void updateById(CouponFormDTO dto, Long id) {//1.校验参数Long dtoId = dto.getId();//如果dto的id和路径id都存在但id不一致,或者都不存在,则抛出异常if((dtoId!=null && id!=null && !dtoId.equals(id)) || (dtoId==null&&id==null)){throw new BadRequestException("参数错误");}//2.更新优惠券基本信息Coupon coupon = BeanUtils.copyBean(dto, Coupon.class);//只更新状态为1的优惠券基本信息,如果失败则是状态已修改boolean update = lambdaUpdate().eq(Coupon::getStatus, 1).update(coupon);//基本信息更新失败则无需更新优惠券范围信息if(!update){return;}//3.更新优惠券范围信息List<Long> scopeIds = dto.getScopes();//3.1只要是优惠券状态不为1,或者优惠券范围为空,则不更新优惠券范围信息//3.2个人写法是先删除优惠券范围信息,再重新插入List<Long> ids = scopeService.lambdaQuery().select(CouponScope::getId).eq(CouponScope::getCouponId, dto.getId()).list().stream().map(CouponScope::getId).collect(Collectors.toList());scopeService.removeByIds(ids);//3.3删除成功后,并且有范围再插入if(CollUtils.isNotEmpty(scopeIds)){List<CouponScope> lis = scopeIds.stream().map(i -> new CouponScope().setCouponId(dto.getId()).setType(1).setBizId(i)).collect(Collectors.toList());scopeService.saveBatch(lis);}}
删除优惠券(练习)
@Overridepublic void deleteById(Long id) {//1.查询优惠券是否存在并删除boolean remove = lambdaUpdate().eq(Coupon::getId, id).eq(Coupon::getStatus, 1).remove();if(!remove){throw new BadRequestException("删除失败,当前优惠券状态非待发放状态");}//2.查询优惠券范围信息并删除scopeService.lambdaUpdate().eq(CouponScope::getCouponId, id).remove();}
根据id查询优惠券(练习)
@Overridepublic CouponDetailVO queryById(Long id) {//1.查询优惠券基本信息Coupon coupon = lambdaQuery().eq(Coupon::getId, id).one();//2.查询优惠券范围列表List<CouponScope> couponScopes = scopeService.lambdaQuery().eq(CouponScope::getCouponId, coupon.getId()).list();//3.查询范围信息<分类id,分类名称>Map<Long, String> cateMap = categoryClient.getAllOfOneLevel().stream().collect(Collectors.toMap(CategoryBasicDTO::getId, CategoryBasicDTO::getName));//4.封装范围信息到范围列表List<CouponScopeVO> vos = couponScopes.stream().map(i -> new CouponScopeVO().setName(cateMap.get(i.getBizId())).setId(i.getBizId())).collect(Collectors.toList());//5.封装优惠券详细信息CouponDetailVO couponDetailVO = BeanUtils.copyBean(coupon, CouponDetailVO.class);couponDetailVO.setScopes(vos);return couponDetailVO;}
优惠券发放
发放优惠券
@Transactional
@Override
public void beginIssue(CouponIssueFormDTO dto) {// 1.查询优惠券Coupon coupon = getById(dto.getId());if (coupon == null) {throw new BadRequestException("优惠券不存在!");}// 2.判断优惠券状态,是否是暂停或待发放if(coupon.getStatus() != CouponStatus.DRAFT && coupon.getStatus() != PAUSE){throw new BizIllegalException("优惠券状态错误!");}// 3.判断是否是立刻发放LocalDateTime issueBeginTime = dto.getIssueBeginTime();LocalDateTime now = LocalDateTime.now();boolean isBegin = issueBeginTime == null || !issueBeginTime.isAfter(now);// 4.更新优惠券// 4.1.拷贝属性到POCoupon c = BeanUtils.copyBean(dto, Coupon.class);// 4.2.更新状态if (isBegin) {c.setStatus(ISSUING);c.setIssueBeginTime(now);}else{c.setStatus(UN_ISSUE);}// 4.3.写入数据库updateById(c);// TODO 兑换码生成
}
兑换码生成算法
兑换码的需求
算法分析
要满足唯一性,很多同学会想到以下技术:
-
UUID
-
Snowflake
-
自增id
我们的兑换码要求是24个大写字母和8个数字。而以上算法最终生成的结果都是数值类型,并不符合我们的需求!
有没有什么办法,可以把数字转为我们要求的格式呢?
Base32转码
假如我们将24个字母和8个数字放到数组中,如下:
角标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
字符 | A | B | C | D | E | F | G | H | J | K | L | M | N | P | Q | R |
角标 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
字符 | S | T | U | V | W | X | Y | Z | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
这样,0~31的角标刚好对应了我们的32个字符!而2的5次幂刚好就是32,因此5位二进制数的范围就是0~31
那因此,只要我们让数字转为二进制的形式,然后每5个二进制位为一组,转10进制的结果是不是刚好对应一个角标,就能找到一个对应的字符呢?
这样是不是就把一个数字转为我们想要的字符个数了。这种把二进制数经过加密得到字符的算法就是Base32法
我们最终要求字符不能超过10位,而每个字符对应5个bit位,因此二进制数不能超过50个bit位。
UUID和Snowflake算法得到的结果,一个是128位,一个是64位,都远远超出了我们的要求。
那自增id算法符合我们的需求呢?
自增id从1增加到Integer的最大值,可以达到40亿以上个数字,而占用的字节仅仅4个字节,也就是32个bit位,距离50个bit位的限制还有很大的剩余,符合要求
重兑校验算法
那重兑问题该如何判断呢?此处有两种方案:
-
基于数据库:我们在设计数据库时有一个字段就是标示兑换码状态,每次兑换时可以到数据库查询状态,避免重兑。
-
优点:简单
-
缺点:对数据库压力大
-
-
基于BitMap:兑换或没兑换就是两个状态,对应0和1,而兑换码使用的是自增id.我们如果每一个自增id对应一个bit位,用每一个bit位的状态表示兑换状态,是不是完美解决问题。而这种算法恰好就是BitMap的底层实现,而且Redis中的BitMap刚好能支持2^32个bit位。
-
优点:简答、高效、性能好
-
缺点:依赖于Redis
-
防刷校验算法
我们也可以模拟JWT的token的思路:
-
首先准备一个秘钥
-
然后利用秘钥对自增id做加密,生成签名
-
将签名、自增id利用Base32转码后生成兑换码
只要秘钥不泄露,就没有人能伪造兑换码。只要兑换码被篡改,就会导致验签不通过。
这里我们必须采用一种特殊的签名算法。由于我们的兑换码核心是自增id,也就是数字,因此这里我们打算采用按位加权的签名算法:
-
将自增id(32位)每4位分为一组,共8组,都转为10进制
-
每一组给不同权重
-
把每一组数加权求和,得到的结果就是签名
为了避免秘钥被人猜测出规律,我们可以准备16组秘钥。在兑换码自增id前拼接一个4位的新鲜值,可以是随机的。这个值是多少,就取第几组秘钥。
异步生成兑换码
判断是否需要生成兑换码,要同时满足两个要求:
-
领取方式必须是兑换码方式
-
之前的状态必须是待发放,不能是暂停
由于生成兑换码的数量较多,可能比较耗时,这里推荐基于线程池异步生成
@Slf4j
@Configuration
public class PromotionConfig {@Beanpublic Executor generateExchangeCodeExecutor(){ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();// 1.核心线程池大小executor.setCorePoolSize(2);// 2.最大线程池大小executor.setMaxPoolSize(5);// 3.队列大小executor.setQueueCapacity(200);// 4.线程名称executor.setThreadNamePrefix("exchange-code-handler-");// 5.拒绝策略executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());executor.initialize();return executor;}
实现思路
代码:
@Transactional
@Override
public void beginIssue(CouponIssueFormDTO dto) {// 1.查询优惠券Coupon coupon = getById(dto.getId());if (coupon == null) {throw new BadRequestException("优惠券不存在!");}// 2.判断优惠券状态,是否是暂停或待发放if(coupon.getStatus() != CouponStatus.DRAFT && coupon.getStatus() != PAUSE){throw new BizIllegalException("优惠券状态错误!");}// 3.判断是否是立刻发放LocalDateTime issueBeginTime = dto.getIssueBeginTime();LocalDateTime now = LocalDateTime.now();boolean isBegin = issueBeginTime == null || !issueBeginTime.isAfter(now);// 4.更新优惠券// 4.1.拷贝属性到POCoupon c = BeanUtils.copyBean(dto, Coupon.class);// 4.2.更新状态if (isBegin) {c.setStatus(ISSUING);c.setIssueBeginTime(now);}else{c.setStatus(UN_ISSUE);}// 4.3.写入数据库updateById(c);// 5.判断是否需要生成兑换码,优惠券类型必须是兑换码,优惠券状态必须是待发放if(coupon.getObtainWay() == ObtainType.ISSUE && coupon.getStatus() == CouponStatus.DRAFT){coupon.setIssueEndTime(c.getIssueEndTime());codeService.asyncGenerateCode(coupon);}
@Override@Async("generateExchangeCodeExecutor")public void asyncGenerateCode(Coupon coupon) {// 发放数量Integer totalNum = coupon.getTotalNum();// 1.获取Redis自增序列号Long result = serialOps.increment(totalNum);if (result == null) {return;}int maxSerialNum = result.intValue();List<ExchangeCode> list = new ArrayList<>(totalNum);for (int serialNum = maxSerialNum - totalNum + 1; serialNum <= maxSerialNum; serialNum++) {// 2.生成兑换码String code = CodeUtil.generateCode(serialNum, coupon.getId());ExchangeCode e = new ExchangeCode();e.setCode(code);e.setId(serialNum);e.setExchangeTargetId(coupon.getId());e.setExpiredTime(coupon.getIssueEndTime());list.add(e);}// 3.保存数据库saveBatch(list);// 4.写入Redis缓存,member:couponId,score:兑换码的最大序列号redisTemplate.opsForZSet().add(COUPON_RANGE_KEY, coupon.getId().toString(), maxSerialNum);}
暂停发放(练习)
@Override@Transactionalpublic void pauseIssue(Long id) {// 1.查询旧优惠券Coupon coupon = getById(id);if (coupon == null) {throw new BadRequestException("优惠券不存在");}// 2.当前券状态必须是未开始或进行中CouponStatus status = coupon.getStatus();if (status != UN_ISSUE && status != ISSUING) {// 状态错误,直接结束return;}// 3.更新状态boolean success = lambdaUpdate().set(Coupon::getStatus, PAUSE).eq(Coupon::getId, id).in(Coupon::getStatus, UN_ISSUE, ISSUING).update();if (!success) {// 可能是重复更新,结束log.error("重复暂停优惠券");}// 4.删除缓存redisTemplate.delete(PromotionConstants.COUPON_CACHE_KEY_PREFIX + id);}
查询兑换码(练习)
@Overridepublic PageDTO<ExchangeCodeVO> queryCodePage(CodeQuery query) {// 1.分页查询兑换码Page<ExchangeCode> page = lambdaQuery().eq(ExchangeCode::getStatus, query.getStatus()).eq(ExchangeCode::getExchangeTargetId, query.getCouponId()).page(query.toMpPage());// 2.返回数据return PageDTO.of(page, c -> new ExchangeCodeVO(c.getId(), c.getCode()));}
定时开始发放优惠券 (练习)
@XxlJob("couponIssueJobHandler")public void handleCouponIssueJob(){// 1.获取分片信息,作为页码,每页最多查询 20条int index = XxlJobHelper.getShardIndex() + 1;int size = Integer.parseInt(XxlJobHelper.getJobParam());// 2.查询<<未开始>>的优惠券Page<Coupon> page = couponService.lambdaQuery().eq(Coupon::getStatus, CouponStatus.UN_ISSUE).le(Coupon::getTermBeginTime, LocalDateTime.now()).page(new Page<>(index, size));// 3.发放优惠券List<Coupon> records = page.getRecords();if (CollUtils.isEmpty(records)) {return;}couponService.beginIssueBatch(records);}
@Overridepublic void beginIssueBatch(List<Coupon> coupons) {// 1.更新券状态for (Coupon c : coupons) {c.setStatus(CouponStatus.ISSUING);}updateBatchById(coupons);// 2.批量缓存redisTemplate.executePipelined((RedisCallback<Object>) connection -> {StringRedisConnection src = (StringRedisConnection) connection;for (Coupon coupon : coupons) {// 2.1.组织数据Map<String, String> map = new HashMap<>(4);map.put("issueBeginTime", String.valueOf(DateUtils.toEpochMilli(coupon.getIssueBeginTime())));map.put("issueEndTime", String.valueOf(DateUtils.toEpochMilli(coupon.getIssueEndTime())));map.put("totalNum", String.valueOf(coupon.getTotalNum()));map.put("userLimit", String.valueOf(coupon.getUserLimit()));// 2.2.写缓存src.hMSet(PromotionConstants.COUPON_CACHE_KEY_PREFIX + coupon.getId(), map);}return null;});}
定时结束发放优惠券 (练习)
回头再补上
面试
1
面试官:你们优惠券支持兑换码的方式是吧,哪兑换码是如何生成的呢?(请设计一个优惠券兑换码生成方案,可以支持20亿以上的唯一兑换码,兑换码长度不超过10,只能包含字母数字,并且要保证生成和校验算法的高效)
答:
首先要考虑兑换码的验证的高效性,最佳的方案肯定是用自增序列号。因为自增序列号可以借助于BitMap验证兑换状态,完全不用查询数据库,效率非常高。
要满足20亿的兑换码需求,只需要31个bit位就够了,也就是在Integer的取值范围内,非常节省空间。我们就按32位来算,支持42亿数据规模。
不过,仅仅使用自增序列还不够,因为容易被人爆刷。所以还需要设计一个加密验签算法。算法有很多,比如可以使用按位加权方案。
32位的自增序列,可以每4位一组,转为10进制,这样就有8个数字。提前准备一个长度为8的加权数组,作为秘钥。对自增序列的8个数字按位加权求和,得到的结果作为签名。
当然,考虑到秘钥的安全性,我们也可以准备多组加权数组,比如准备16组。然后生成兑换码时随机生成一个4位的新鲜值,取值范围刚好是0~15,新鲜值是几,我们就取第几组加权数组作为秘钥。然后把新鲜值、自增序列拼接后按位加权求和,得到签名。
最后把签名值的后14位、新鲜值(4位)、自增序列(32位)拼接,得到一个50位二进制数,然后与一个较大的质数做异或运算加以混淆,再基于Base32或Base64转码,即可的对兑换码。
如果是基于Base32转码,得到的兑换码恰好10位,符合要求。
需要注意的是,用来做异或的大质数、加权数组都属于秘钥,千万不能泄露。如有必要,也可以定期更换。
当我们要验签的时候,首先将结果 利用Base32转码为数字。然后与大质数异或得到原始数值。
接着取高14位,得到签名;取后36位得到新鲜值与自增序列的拼接结果。取中4位得到新鲜值。
根据新鲜值找到对应的秘钥(加权数组),然后再次对后36位加权求和,得到签名。与高14位的签名比较是否一致,如果不一致证明兑换码被篡改过,属于无效兑换码。如果一致,证明是有效兑换码。
接着,取出低32位,得到兑换码的自增序列号。利用BitMap验证兑换状态,是否兑换过即可。
整个验证过程完全不用访问数据库,效率非常高。
2
面试官:你在项目中哪些地方用到过线程池?
答:很多地方,比如我在实现优惠券的兑换码生成的时候。
当我们在发放优惠券的时候,会判断优惠券的领取方式,我们有基于页面手动领取,基于兑换码兑换领取等多种方式。
如果发现是兑换码领取,则会在发放的同时,生成兑换码。但由于兑换码数量比较多,如果在发放优惠券的同时生成兑换码,业务耗时会比较久。
因此,我们会采用线程池异步生成兑换码的方式。
3
面试官可能会追问:那你的线程池参数是怎么设置的?
答:线程池的常见参数包括:核心线程、最大线程、队列、线程名称、拒绝策略等。
这里核心线程数我们配置的是2,最大线程数是CPU核数。之所以这么配置是因为发放优惠券并不是高频业务,这里基于线程池做异步处理仅仅是为了减少业务耗时,提高用户体验。所以线程数无需特别高。
队列的大小设置的是200,而拒绝策略采用的是交给调用线程处理的方式。
由于业务访问频率较低,所以基本不会出现线程耗尽的情况,如果真的出现了,就交给调用线程处理,让客户稍微等待一下也行。