day07-优惠券管理

1.需求分析

需求分析的流程与以往类似,还是基于产品原型,三步走:

  • 分析业务流程
  • 统计业务接口
  • 设计数据库表

1.1.业务流程梳理

优惠券包括两大部分功能:

  • 优惠券管理和发放(管理端)
  • 优惠券的领取和使用(用户端)

在后台管理营销中心的优惠券管理页面,可以看到一个优惠券列表页:
day07-优惠券管理_第1张图片
我们可以在这里实现优惠券的基础的增删改查功能。
不过,新增的优惠券并不会立刻出现在用户端页面,管理员还需要对优惠券信息做审核,审核通过后则可以通过发放按钮来发布优惠券。

而优惠券的发布也有两种不同的方式:
day07-优惠券管理_第2张图片
一个是立刻发放,一个是定时发放。
不管怎么发,优惠券都有过期时间。当然,除了过期导致的结束发放以外,管理员也可以手动点击暂停发放:
day07-优惠券管理_第3张图片
也可以在需要的时候重新发放优惠券。

特别需要注意的是,优惠券的领取方式有两种,来看一下优惠券的新增表单:

day07-优惠券管理_第4张图片
领取方式有两种:

  • 手动领取:就是展示在用户端页面,由用户自己手动点击领取
  • 指定方法:就是兑换码模式,后台给优惠券生成N张兑换码,由管理员发放给指定用户。

这就要求我们在发放优惠券的时候做判断,如果发现是指定发放模式,则需要提前生成兑换码。

综上,优惠券管理的业务流程和优惠券的状态转换如图:
day07-优惠券管理_第5张图片

1.2.接口统计

首先,在优惠券的列表页:
day07-优惠券管理_第6张图片
页面规范如下:

  1. 搜索条件
  • 优惠类型:支持的类型有 1:满减,2:每满减,3:折扣,4:无门槛
  • 优惠券状态:包括 1:待发放,2:未开始 3:进行中,4:已结束,5:暂停
  1. 列表显示
  • 默认显示10条
  • 默认按照创建时间倒序排序
  • 使用/领取/发放:优惠券数量统计,已使用的数量/已领取的数量/总发放数量
  • 领用期限:就是券领取的开始和结束时间

可见这个列表就是一个典型的带过滤条件的分页查询。其它增删改查接口都比较简单,不再赘述。

定时任务接口:

  • 定时发放优惠券
  • 定时结束优惠券发放

还有一个是跟兑换码有关。就是在发放优惠券的时候,如果发现优惠券的领取方式是指定发放,则需要生成兑换码。因此页面有一个查询兑换码功能:
在这里插入图片描述
当我们点击查看兑换码时,就会进入一个兑换码展示页面:
day07-优惠券管理_第7张图片
可以看出来,这是一个有过滤条件的分页查询功能。综上,优惠券相关接口包括:
day07-优惠券管理_第8张图片

1.3.表结构设计

通过前面的接口分析,发现接口主要跟两个实体有关:

  • 优惠券
  • 兑换码

1.3.1.优惠券

首先从优惠券的新增表单来分析 优惠券表结构如下:

-- 导出  表 tj_promotion.coupon 结构
CREATE TABLE IF NOT EXISTS `coupon` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '优惠券id',
  `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '0' COMMENT '优惠券名称,可以和活动名称保持一致',
  `type` tinyint NOT NULL DEFAULT '1' COMMENT '优惠券类型,1:普通券。目前就一种,保留字段',
  `discount_type` tinyint NOT NULL COMMENT '折扣类型,1:满减,2:每满减,3:折扣,4:无门槛',
  `specific` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否限定作用范围,false:不限定,true:限定。默认false',
  `discount_value` int NOT NULL DEFAULT '1' COMMENT '折扣值,如果是满减则存满减金额,如果是折扣,则存折扣率,8折就是存80',
  `threshold_amount` int NOT NULL DEFAULT '0' COMMENT '使用门槛,0:表示无门槛,其他值:最低消费金额',
  `max_discount_amount` int NOT NULL DEFAULT '0' COMMENT '最高优惠金额,满减最大,0:表示没有限制,不为0,则表示该券有金额的限制',
  `obtain_way` tinyint NOT NULL DEFAULT '0' COMMENT '获取方式:1:手动领取,2:兑换码',
  `issue_begin_time` datetime DEFAULT NULL COMMENT '开始发放时间',
  `issue_end_time` datetime DEFAULT NULL COMMENT '结束发放时间',
  `term_days` int NOT NULL DEFAULT '0' COMMENT '优惠券有效期天数,0:表示有效期是指定有效期的',
  `term_begin_time` datetime DEFAULT NULL COMMENT '优惠券有效期开始时间',
  `term_end_time` datetime DEFAULT NULL COMMENT '优惠券有效期结束时间',
  `status` tinyint DEFAULT '1' COMMENT '优惠券配置状态,1:待发放,2:未开始   3:进行中,4:已结束,5:暂停',
  `total_num` int NOT NULL DEFAULT '0' COMMENT '总数量,不超过5000',
  `issue_num` int NOT NULL DEFAULT '0' COMMENT '已发行数量,用于判断是否超发',
  `used_num` int NOT NULL DEFAULT '0' COMMENT '已使用数量',
  `user_limit` int NOT NULL DEFAULT '1' COMMENT '每个人限领的数量,默认1',
  `ext_param` json DEFAULT NULL COMMENT '拓展参数字段,保留字段',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `creater` bigint NOT NULL COMMENT '创建人',
  `updater` bigint NOT NULL COMMENT '更新人',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1630563495906942979 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='优惠券的规则信息';

1.3.2.兑换码

兑换码的作用是让用户拿着这个码来兑换一张优惠券。因此一定与两个实体有关:

  • 优惠券
  • 用户
    也就是说,我们需要知道将来是谁来兑换的券,可以兑换哪张券。当然,兑换码的码肯定也要保持到数据库
CREATE TABLE IF NOT EXISTS `exchange_code` (
  `id` int NOT NULL COMMENT '兑换码id',
  `code` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '兑换码',
  `status` tinyint NOT NULL DEFAULT '1' COMMENT '兑换码状态, 1:待兑换,2:已兑换,3:兑换活动已结束',
  `user_id` bigint NOT NULL DEFAULT '0' COMMENT '兑换人',
  `type` tinyint NOT NULL DEFAULT '1' COMMENT '兑换类型,1:优惠券,以后再添加其它类型',
  `exchange_target_id` bigint NOT NULL DEFAULT '0' COMMENT '兑换码目标id,例如兑换优惠券,该id则是优惠券的配置id',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `expired_time` datetime NOT NULL COMMENT '兑换码过期时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`) USING BTREE,
  KEY `index_status` (`status`) USING BTREE,
  KEY `index_config_id` (`exchange_target_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='兑换码';

1.4.代码生成

首先,在DEV分支的基础上创建一个新的功能分支:
git checkout -b feature-promotions

1.4.1.创建新的模块、修改 yml

优惠券功能属于优惠促销的一部分,在项目中肯定属于独立的功能模块。我们需要创建一个新的module
bootstrap.yaml:

server:
  port: 8092  #端口
  tomcat:
    uri-encoding: UTF-8   #服务编码
spring:
  profiles:
    active: dev
  application:
    name: promotion-service
  cloud:
    nacos:
      config:
        file-extension: yaml
        shared-configs: # 共享配置
          - data-id: shared-spring.yaml # 共享spring配置
            refresh: false
          - data-id: shared-redis.yaml # 共享redis配置
            refresh: false
          - data-id: shared-mybatis.yaml # 共享mybatis配置
            refresh: false
          - data-id: shared-logs.yaml # 共享日志配置
            refresh: false
          - data-id: shared-feign.yaml # 共享feign配置
            refresh: false
          - data-id: shared-xxljob.yaml # 共享mq配置
            refresh: false
tj:
  swagger:
    enable: true
    enableResponseWrap: true
    package-path: com.tianji.promotion.controller
    title: 天机课堂 - 促销中心接口文档
    description: 该服务包含优惠促销有关的功能
    contact-name: 传智教育·研究院
    contact-url: http://www.itcast.cn/
    contact-email: [email protected]
    version: v1.0
  jdbc:
    database: tj_promotion
  auth:
    resource:
      enable: true # 开启登录拦截的功能

bootstrap-dev.yml:

spring:
  cloud:
    nacos:
      server-addr: 192.168.150.101:8848 # nacos注册中心
      discovery:
        namespace: f923fb34-cb0a-4c06-8fca-ad61ea61a3f0
        group: DEFAULT_GROUP
        ip: 192.168.150.101
logging:
  level:
    com.tianji: debug

bootstrap-local.yml:

spring:
  cloud:
    nacos:
      server-addr: 192.168.150.101:8848 # nacos注册中心
      discovery:
        namespace: f923fb34-cb0a-4c06-8fca-ad61ea61a3f0
        group: DEFAULT_GROUP
        ip: 192.168.150.1
logging:
  level:
    com.tianji: debug

启动类:

package com.tianji.promotion;


import lombok.extern.slf4j.Slf4j;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.core.env.Environment;

import java.net.InetAddress;
import java.net.UnknownHostException;

@SpringBootApplication
@MapperScan("com.tianji.promotion.mapper")
@Slf4j
public class PromotionApplication {
    public static void main(String[] args) throws UnknownHostException {
        SpringApplication app = new SpringApplicationBuilder(PromotionApplication.class).build(args);
        Environment env = app.run(args).getEnvironment();
        String protocol = "http";
        if (env.getProperty("server.ssl.key-store") != null) {
            protocol = "https";
        }
        log.info("--/\n---------------------------------------------------------------------------------------\n\t" +
                        "Application '{}' is running! Access URLs:\n\t" +
                        "Local: \t\t{}://localhost:{}\n\t" +
                        "External: \t{}://{}:{}\n\t" +
                        "Profile(s): \t{}" +
                        "\n---------------------------------------------------------------------------------------",
                env.getProperty("spring.application.name"),
                protocol,
                env.getProperty("server.port"),
                protocol,
                InetAddress.getLocalHost().getHostAddress(),
                env.getProperty("server.port"),
                env.getActiveProfiles());
    }
}

配置启动项,关键是设置运行环境为local:
day07-优惠券管理_第9张图片

1.4.3枚举

在优惠券实体中,有很多的类型或状态枚举:

  • 折扣类型
  • 优惠券状态
  • 领取方式
    兑换码中也有一个状态字段。
    这些都需要定义为枚举
    day07-优惠券管理_第10张图片
@Getter
@AllArgsConstructor
public enum CouponStatus implements BaseEnum {
    DRAFT(1, "待发放"),
    UN_ISSUE(2, "未开始"),
    ISSUING(3, "发放中"),
    FINISHED(4, "发放结束"),
    PAUSE(5, "暂停");
    @JsonValue
    @EnumValue
    private final int value;
    private final String desc;

    @JsonCreator(mode = JsonCreator.Mode.DELEGATING)
    public static CouponStatus of(Integer value) {
        if (value == null) {
            return null;
        }
        for (CouponStatus status : values()) {
            if (status.value == value) {
                return status;
            }
        }
        return null;
    }

    public static String desc(Integer value) {
        CouponStatus status = of(value);
        return status == null ? "" : status.desc;
    }
}

@Getter
@AllArgsConstructor
public enum ObtainType implements BaseEnum {
    PUBLIC(1, "手动领取"),
    ISSUE(2, "发放兑换码"),
    ;
    @EnumValue
    @JsonValue
    private final int value;
    private final String desc;

    @JsonCreator(mode = JsonCreator.Mode.DELEGATING)
    public static ObtainType of(Integer value) {
        if (value == null) {
            return null;
        }
        for (ObtainType status : values()) {
            if (status.value == value) {
                return status;
            }
        }
        return null;
    }
}

2.优惠券管理

优惠券的管理接口有

  • 分页查询优惠券列表
  • 新增优惠券
  • 编辑优惠券
  • 查看优惠券(根据id查询优惠券)
  • 删除优惠券

大部分都是基本的CRUD,这里我们讲解其中的两个接口:

  • 新增优惠券
  • 分页查询优惠券

2.1.新增优惠券

新增表单原型如图:
day07-优惠券管理_第11张图片

2.1.1.接口分析

需要特别注意的是,如果优惠券限定了使用范围,则需要保存限定的课程分类。而这些信息不再coupon表,而是一张中间关系表:coupon_scope
day07-优惠券管理_第12张图片

2.1.2.实体

请求参数比较复杂,需要定义一个对应的Form表单实体

@Data
@ApiModel(description = "优惠券表单数据")
public class CouponFormDTO {

    @ApiModelProperty("优惠券id,新增不需要添加,更新必填")
    private Long id;

    @ApiModelProperty("优惠券名称")
    @NotNull(message = "优惠券名称不能为空")
    @Size(max = 20, min = 4, message = "优惠券名称长度错误")
    private String name;

    @ApiModelProperty("是否添限定使用范围,true:限定了,false:没限定")
    private Boolean specific;

    @ApiModelProperty("优惠券使用范围")
    private List<Long> scopes;

    @ApiModelProperty("优惠券类型,1:每满减,2:折扣,3:无门槛,4:普通满减")
    @NotNull(message = "优惠券折扣类型不能为空")
    @EnumValid(enumeration = {1,2,3,4})
    private DiscountType discountType;

    @ApiModelProperty("折扣门槛,0代表无门槛")
    private Integer thresholdAmount;
    
    @ApiModelProperty("折扣值,满减填抵扣金额;打折填折扣值:80标示打8折")
    private Integer discountValue;
    
    @ApiModelProperty("最大优惠金额")
    private Integer maxDiscountAmount;

    @ApiModelProperty("优惠券总量")
    @Range(max = 5000, min = 1, message = "优惠券总量必须在1~5000")
    private Integer totalNum;
    
    @ApiModelProperty("每人领取的上限")
    @Range(max = 10, min = 1, message = "每人限领数量必须在1~10")
    private Integer userLimit;
    
    @ApiModelProperty("获取方式1:手动领取,2:指定发放(通过兑换码兑换)")
    @NotNull(message = "领取方式不能为空")
    @EnumValid(enumeration = {1, 2}, message = "领取方式不正确")
    private ObtainType obtainWay;
}

2.1.3.接口实现

controller接口:

@RestController
@RequiredArgsConstructor
@RequestMapping("/coupons")
@Api(tags = "优惠券相关接口")
public class CouponController {

    private final ICouponService couponService;

    @ApiOperation("新增优惠券接口")
    @PostMapping
    public void saveCoupon(@RequestBody @Valid CouponFormDTO dto){
        couponService.saveCoupon(dto);
    }
}

service接口:

public interface ICouponService extends IService<Coupon> {
    void saveCoupon(CouponFormDTO dto);
}

实现service方法:


@Service
@RequiredArgsConstructor
public class CouponServiceImpl extends ServiceImpl<CouponMapper, Coupon> implements ICouponService {

    private final ICouponScopeService scopeService;

    @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);
    }
}

2.2.分页查询优惠券

页面原型如图:
day07-优惠券管理_第13张图片
新增优惠券的接口设计如下:

day07-优惠券管理_第14张图片

day07-优惠券管理_第15张图片

2.1.2.实体

这里需要两个实体,一个是请求参数QUERY,一个是返回值VO实体。
QUERY:

@EqualsAndHashCode(callSuper = true)
@Data
@ApiModel(description = "优惠券查询参数")
@Accessors(chain = true)
public class CouponQuery extends PageQuery {

    @ApiModelProperty("优惠券折扣类型:1:每满减,2:折扣,3:无门槛,4:满减")
    private Integer type;

    @ApiModelProperty("优惠券状态,1:待发放,2:发放中,3:已结束, 4:取消/终止")
    private Integer status;

    @ApiModelProperty("优惠券名称")
    private String name;
}

VO:

@Data
@ApiModel(description = "优惠券分页数据")
public class CouponPageVO {
    @ApiModelProperty("优惠券id,新增不需要添加,更新必填")
    private Long id;
    @ApiModelProperty("优惠券名称")
    private String name;
    @ApiModelProperty("是否限定使用范围")
    private Boolean specific;

    @ApiModelProperty("优惠券类型,1:每满减,2:折扣,3:无门槛,4:普通满减")
    private DiscountType discountType;
    @ApiModelProperty("折扣门槛,0代表无门槛")
    private Integer thresholdAmount;
    @ApiModelProperty("折扣值,满减填抵扣金额;打折填折扣值:80标示打8折")
    private Integer discountValue;
    @ApiModelProperty("最大优惠金额")
    private Integer maxDiscountAmount;

    @ApiModelProperty("获取方式1:手动领取,2:指定发放(通过兑换码兑换)")
    private ObtainType obtainWay;
    @ApiModelProperty("已使用")
    private Integer usedNum;
    @ApiModelProperty("已发放数量")
    private Integer issueNum;
    @ApiModelProperty("优惠券总量")
    private Integer totalNum;

    @ApiModelProperty("优惠券创建时间")
    private LocalDateTime createTime;
    @ApiModelProperty("发放开始时间")
    private LocalDateTime issueBeginTime;
    @ApiModelProperty("发放结束时间")
    private LocalDateTime issueEndTime;

    @ApiModelProperty("有效天数")
    private Integer termDays;
    @ApiModelProperty("使用有效期开始时间")
    private LocalDateTime termBeginTime;
    @ApiModelProperty("使用有效期结束时间")
    private LocalDateTime termEndTime;

    @ApiModelProperty("状态")
    private CouponStatus status;
}

2.1.3.接口实现

service方法:

@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);
}

3.优惠券发放

优惠券新增之后并不会直接展示在用户端,而是处于一个待发放状态,等待管理员核对信息后,点击方法才行。
而发放的方式也分为立刻发放、定时发放两种。
对于定时发放的优惠券,还需要通过定时任务来定期完成发放功能。
另外,由于优惠券的领取方式不同,基于兑换码的优惠券还需要在发放时生成兑换码。

3.1.发放优惠券

处于暂停状态,或者待发放状态的优惠券,在优惠券列表中才会出现发放按钮,可以被发放:
day07-优惠券管理_第16张图片

3.1.1.接口分析

当我们点击发放按钮时,会弹出一个表单:
day07-优惠券管理_第17张图片
需要我们选择发放方式,使用期限。

发放方式分为两种:立刻发放和定时发放;使用期限也分两种:固定天数、固定时间段。如图:
day07-优惠券管理_第18张图片day07-优惠券管理_第19张图片

因此,在提交这个表单时,参数包括:

  • 发放(领用)开始时间:如果为空说明是立刻方法,开始时间就是当前时间
  • 发放(领域)结束时间
  • 有效期天数:如果为空说明是固定有效期
  • 使用期限开始时间:如果为空说明是固定天数有效期
  • 使用期限结束时间:如果为空说明是固定天数有效期
    最后,肯定要带上优惠券id,我们才知道发放的是哪张券,当然这个可以通过路径占位符传参。

day07-优惠券管理_第20张图片

3.1.2.实体

这里需要一个请求参数的DTO实体

@Data
@ApiModel(description = "优惠券发放的表单实体")
public class CouponIssueFormDTO {
    @ApiModelProperty("优惠券id")
    private Long id;
    
    @ApiModelProperty("发放开始时间")
    @DateTimeFormat(pattern = DateUtils.DEFAULT_DATE_TIME_FORMAT)
    @Future(message = "发放开始时间必须晚于当前时间")
    private LocalDateTime issueBeginTime;
    
    @ApiModelProperty("发放结束时间")
    @Future(message = "发放结束时间必须晚于当前时间")
    @NotNull(message = "发放结束时间不能为空")
    @DateTimeFormat(pattern = DateUtils.DEFAULT_DATE_TIME_FORMAT)
    private LocalDateTime issueEndTime;
    
    @ApiModelProperty("有效天数")
    private Integer termDays;
    
    @ApiModelProperty("使用有效期开始时间")
    @DateTimeFormat(pattern = DateUtils.DEFAULT_DATE_TIME_FORMAT)
    private LocalDateTime termBeginTime;
    
    @ApiModelProperty("使用有效期结束时间")
    @DateTimeFormat(pattern = DateUtils.DEFAULT_DATE_TIME_FORMAT)
    private LocalDateTime termEndTime;
}

3.1.3.接口实现

service方法


@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 兑换码生成
}

3.2.兑换码生成算法

优惠券的领取有两种方式:手动领取和指定发放:
指定发放模式是指使用兑换码来兑换优惠券。因此必须在优惠券发放的同时,生成兑换码。兑换码的格式如图:
day07-优惠券管理_第21张图片

3.2.1.兑换码的需求

兑换码并不是简单的一个字符串,它其实有很多的需求:
day07-优惠券管理_第22张图片

  • 长度不超过10个字符
  • 只能是24个大写字母和8个数字:ABCDEFGHJKLMNPQRSTUVWXYZ23456789

3.2.2.算法分析

要满足唯一性,很多同学会想到以下技术:

  • UUID(128位二进制)
  • Snowflake(64位二进制)
  • 自增id

我们的兑换码要求是24个大写字母和8个数字。而以上算法最终生成的结果都是数值类型,并不符合我们的需求!
有没有什么办法,可以把数字转为我们要求的格式呢?

3.2.2.1.Base32转码

当然可以了,大家思考一下,假如我们将24个字母和8个数字放到数组中,如下:
day07-优惠券管理_第23张图片
这样,0-31的角标刚好对应了我们的32个字符!而2的5次幂刚好就是32,因此5个二进制位有32个数(0-31)

那因此,只要我们让数字转为二进制的形式,然后每5个二进制位为一组,转10进制的结果是不是刚好对应一个角标,就能找到一个对应的字符呢?
这样就把一个数字转为我们想要的字符个数了。这种把二进制数经过加密得到字符的算法就是Base32法,类似的还有Base64法。

举例:假如我们经过自增id计算出一个复杂数字,转为二进制,并每5位一组,结果如下:

01001 00010 01100 10010 01101 11000 01101 00010 11110 11010

此时,我们看看每一组的结果:

  • 01001转10进制是9,查数组得字符为:K
  • 00010转10进制是2,查数组得字符为:C
  • 01100转10进制是12,查数组得字符为:N
  • 10010转10进制是18,查数组得字符为:B
  • 01101转10进制是13,查数组得字符为:P
  • 11000转10进制是24,查数组得字符为:2

  • 依此类推,最终那一串二进制数得到的结果就是KCNBP2PC84,刚好符合我们的需求。

但是大家思考一下,我们最终要求字符不能超过10位,而每个字符对应5个bit位,因此二进制数不能超过50个bit位。
UUID和Snowflake算法得到的结果,一个是128位,一个是64位,都远远超出了我们的要求。

那自增id算法符合我们的需求呢?
自增id从1增加到Integer的最大值,可以达到40亿以上个数字,而占用的字节仅仅4个字节,也就是32个bit位,距离50个bit位的限制还有很大的剩余,符合要求!

综上,我们可以利用自增id作为兑换码,但是要利用Base32加密,转为我们要求的格式。此时就符合了我们的几个要求了:

  • 可读性好:可以转为要求的字母和数字的格式,长度还不超过10个字符(可以为10以下个字符)
  • 数据量大:可以应对40亿以上的数据规模
  • 唯一性:自增id,绝对唯一

3.2.2.2.重兑校验算法

那重兑问题该如何判断呢?此处有两种方案:

  • 基于数据库:我们在设计数据库时有一个字段就是标示兑换码状态,每次兑换时可以到数据库查询状态,避免重兑。
    • 优点:简单
    • 缺点:对数据库压力大
  • 基于BitMap:兑换或没兑换就是两个状态,对应0和1,而兑换码使用的是自增id.我们如果每一个自增id对应一个bit位,用每一个bit位的状态表示兑换状态,是不是完美解决问题。而这种算法恰好就是BitMap的底层实现,而且Redis中的BitMap刚好能支持2^32个bit位。(bitmap底层是string,redis 的string最大空间是512M,对应42亿位bit
    • 优点:简答、高效、性能好
    • 缺点:依赖于Redis

我们的兑换码规律性不能太明显,否则很容易被人猜测到其它兑换码。但是,如果我们使用了自增id,那规律简直太明显了,岂不是很容易被人踩到其它兑换码?!

所以,我们采用自增id的同时,还需要利用某种校验算法对id做加密验证,避免他人找出规律,猜测到其它兑换码,甚至伪造、篡改兑换码。

3.2.2.3.防刷校验算法

非常可惜,没有一种现成的算法能满足我们的需求,我们必须自己设计一种算法来实现这个功能。

不过大家不用害怕,我们可以模拟其它验签的常用算法。比如大家熟悉的JWT技术。我们知道JWT分为三部分组成:

  • Header:记录算法
  • Payload:记录用户信息
  • Verify Signature:验证签名,用于验证整个token

JWT中的的Header和Payload采用的是Base64算法,与我们Base32类似,几乎算是明文传输,难道不怕其他人伪造、篡改token吗?

为了解决这个问题,JWT中才有了第三部分,验证签名。这个签名是有一个秘钥结合Header、Payload,利用MD5或者RSA算法生成的。因此:

  • 只要秘钥不泄露,其他人就无法伪造签名,也就无法伪造token。
  • 有人篡改了token,验签时会根据header和payload再次计算签名。数据被篡改,计算的到的签名肯定不一致,就是无效token

因此,我们也可以模拟这种思路:

  • 首先准备一个秘钥
  • 然后利用秘钥对自增id做加密生成签名
  • 将签名、自增id利用Base32转码后生成兑换码
    只要秘钥不泄露,就没有人能伪造兑换码。只要兑换码被篡改,就会导致验签不通过。

当然,这里我们不能采用MD5和RSA算法来生成签名,因为这些算法得到的签名都太长了,一般都是128位以上,超出了长度限制。

因此,这里我们必须采用一种特殊的签名算法。由于我们的兑换码核心是自增id,也就是数字,因此这里我们打算采用按位加权的签名算法

  • 将自增id(32位 int类型4字节 32bit)每4位分为一组,共8组,都转为10进制
  • 每一组给不同权重
  • 把每一组数加权求和,得到的结果就是签名

举例:
day07-优惠券管理_第24张图片

  • 最终的加权和就是:42 + 25 + 91 + 103 + 84 + 27 + 18 + 69 = 165 (签名)
  • 这里的权重数组就可以理解为加密的秘钥
  • 当然,为了避免秘钥被人猜测出规律,我们可以准备16组秘钥。在兑换码自增id前拼接一个4位的新鲜值,可以是随机的。这个值是多少,就取第几组秘钥。

day07-优惠券管理_第25张图片
这样就进一步增加了兑换码的复杂度。
最后,把加权和,也就是签名也转二进制,拼接到最前面,最终的兑换码就是这样:
day07-优惠券管理_第26张图片

3.3.异步生成兑换码

3.3.1.思路分析

在发放优惠券的时候,如果发现优惠券的领取方式是兑换码方式,则需要生成兑换码。
不过,需要注意的是,优惠券发放以后是可以暂停的,暂停之后还可以再次发放。
假如一个优惠券是通过兑换码方式领取。第一次发放时我们生产了兑换码,然后被暂停,然后再次发放,如果我们再次生成兑换码,这就重复了。
因此,判断是否需要生成兑换码,要同时满足两个要求:

  • 领取方式必须是兑换码方式
  • 之前的状态必须是待发放,不能是暂停

而且,由于生成兑换码的数量较多,可能比较耗时,这里推荐基于线程池异步生成。
流程如下:
day07-优惠券管理_第27张图片

3.3.2.代码实现

@Service
public class ExchangeCodeServiceImpl extends ServiceImpl<ExchangeCodeMapper, ExchangeCode> implements IExchangeCodeService {

    private final StringRedisTemplate redisTemplate;
    private final BoundValueOperations<String, String> serialOps;

    public ExchangeCodeServiceImpl(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
        this.serialOps = redisTemplate.boundValueOps(COUPON_CODE_SERIAL_KEY);
    }

    @Override
    @Async("generateExchangeCodeExecutor")
    public void asyncGenerateCode(Coupon coupon) {
        // 发放数量
        Integer totalNum = coupon.getTotalNum();
        // 1.获取Redis自增序列号   自增id 采用的是redis的自增id 每执行一次incr key value值就+1,
      	  //incrby key num(批量加),value的值就+num
        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);

首先,我们要定义一个线程池,用于异步生成兑换码:

你可能感兴趣的:(知学传课,数据库)