优惠券管理
开发流程
需求分析,接口统计,数据库设计,创建分支,创建新模块(依赖,配置,启动类),生成代码,引入枚举状态
优惠券管理
增删改查的业务代码,没有新的知识点
新增优惠券
@Override
@Transactional
public void saveCoupon(CouponFormDTO dto) {
// 1.保存优惠券
// 1.1.转PO
Coupon 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.转换PO
List<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.处理VO
List<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);
}
修改优惠券(练习)
@Override
public 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);
}
}
删除优惠券(练习)
@Override
public 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查询优惠券(练习)
@Override
public 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.拷贝属性到PO
Coupon 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 {
@Bean
public 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.拷贝属性到PO
Coupon 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
@Transactional
public 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);
}
查询兑换码(练习)
@Override
public 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);
}
@Override
public 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,而拒绝策略采用的是交给调用线程处理的方式。
由于业务访问频率较低,所以基本不会出现线程耗尽的情况,如果真的出现了,就交给调用线程处理,让客户稍微等待一下也行。