首页 > 其他分享 >给自己复盘的tjxt笔记day9

给自己复盘的tjxt笔记day9

时间:2024-08-28 12:24:09浏览次数:7  
标签:优惠券 dto day9 coupon id Coupon 兑换 复盘 tjxt

优惠券管理

开发流程

需求分析,接口统计,数据库设计,创建分支,创建新模块(依赖,配置,启动类),生成代码,引入枚举状态

优惠券管理

增删改查的业务代码,没有新的知识点

新增优惠券

@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,而拒绝策略采用的是交给调用线程处理的方式。

由于业务访问频率较低,所以基本不会出现线程耗尽的情况,如果真的出现了,就交给调用线程处理,让客户稍微等待一下也行。

标签:优惠券,dto,day9,coupon,id,Coupon,兑换,复盘,tjxt
From: https://blog.csdn.net/weixin_46321761/article/details/141558726

相关文章

  • 给自己复盘用的tjxt笔记day10
    领取优惠券开发流程页面原型分析,接口统计,数据库设计,生成代码,引入枚举状态接口开发查询发放中的优惠券根据页面原型和接口分析和前端设计的要求,获得四要素@OverridepublicList<CouponVO>queryIssuingCoupons(){//1.查询发放中的优惠券列表List<Coupon>......
  • 从研发效能的视角谈“故障复盘”
    原文连接:https://blog.csdn.net/yangjianrong1985/article/details/124854562?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522172465893316800182747579%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fall.%2522%257D&request_id=172465893316800......
  • 代码训练营 Day9 | 151.翻转字符串里的单词 | 认识KMP算法
    151.翻转字符串里的单词这道题的难度在于如何高效率的处理空格对于python来说不能实现空间O(1)的复杂度,不过对于其他语言来说下面思路可以使用双指针方法同时指向数组的头部循环遍历整个字符串直到数组末尾快指针不能为空,因为快指针是要获取字母的,慢指针是用来获取新的字......
  • day9第四章 字符串part02| 151.翻转字符串里的单词 |卡码网:55.右旋转字符串|28. 实现
    151.翻转字符串里的单词classSolution{publicStringreverseWords(Strings){////删除首尾空格,分割字符串String[]str=s.trim().split("");StringBuildersb=newStringBuilder();////倒序遍历单词列表for(inti......
  • 【读书笔记-《30天自制操作系统》-8】Day9
    本篇的主题围绕着内存管理进行展开。首先编写了内存容量获取的程序,接下来详细讲解了内存管理的具体内容,以及两种实现内存管理的方式。1.内存容量获取前面已经实现了访问内存的扩展,能够使用的内存大大增加了。但是不同的应用程序在运行时,对内存的使用会有不同的要求,这就需......
  • 给自己复盘的tjxt笔记day5第二部分
    评论相关接口(练习)评论相关接口有四个:新增回答或评论分页查询回答或评论管理端分页查询回答或评论管理端隐藏或显示回答或评论新增回答或评论(练习)设计思路注意回复本身只有一个简单属性:回复内容一个功能属性:是否匿名一个关联属性:问题id:回......
  • 给自己复盘的随想录笔记-移除元素
    双指针法双指针法(快慢指针法): 通过一个快指针和慢指针在一个for循环下完成两个for循环的工作。定义快慢指针快指针:寻找新数组的元素,新数组就是不含有目标元素的数组慢指针:指向更新新数组下标的位置相关题目删除有序数组中的重复项解题思路:解法:双指针首先注意数组......
  • 给自己复盘用的随想录笔记day1-二分查找
    二分法使用情景数组为有序数组,同时题目还强调数组中无重复元素,因为一旦有重复元素,使用二分查找法返回的元素下标可能不是唯一的,这些都是使用二分法的前提条件,当大家看到题目描述满足如上条件的时候,可要想一想是不是可以用二分法了。求某一个整数的平方根边界条件写二分法......
  • 【STM32】寻迹小车项目复盘
    寻迹小车项目复盘前言复盘简述项目无思路,无大局观描述复盘项目无架构描述复盘下次项目改进思路DEBUG无思路前言博主近日首次完成了一个简单的循迹小车。但让我意外的是,在我上手如此简单的项目时,我的思路却十分混乱,开发过程毫无逻辑,虽说跌跌撞撞的做出来了,但效率低......
  • Java后端面试题(mq相关)(day9)
    目录为什么用MQ?异步、削峰、解耦1.异步处理2.解耦3.削峰填谷Exchange类型什么是死信队列?如何保证消息的可靠性?RabbitMQ中如何解决消息堆积问题?RabbitMQ中如何保证消息有序性?如何防止消息重复消费?(如何保证消息幂等性)为什么用MQ?异步、削峰、解耦MQ(Message......