19| 优惠券微服务业务-库存扣减数据库乐观锁

2024-08-08 · · 原创 · · 本文共 1,415个字,预计阅读需要 5分钟。

优惠券业务介绍

拉新业务衍生出的玩法:砍价,拼团,裂变,优惠券。

电商优惠券逻辑:

  • 获取方式维度:

    被动:新⼈优惠券,⽆⻔槛现⾦劵 ,…其他

第⼀次登录注册某平台,登录成功后进入到⾸个⻚⾯,弹出新⼈红包或者某固定位置领取新⼈红包,前端领取位置及细节不做详细讲解,根据实际业务场景⽽定。

​ 主动:领取优惠券,满减劵, …其他

促进商品订单成交,⽽设置的优惠券,提⾼下单⽀付率

  • 使⽤⻔槛维度
    • ⽆⻔槛现⾦劵
    • 满减劵
    • 满减折扣卷
    • 运费抵扣券
    • 兑换券
    • 店铺劵
    • 单品劵

按照多维度设计,就需要⽤到规则引擎,这类⼀般是⼤⼚的营销中台设计的,复杂且容易出问题。

  • 优惠券常⻅属性
    • 类型:⽆⻔槛、满减、单品、折扣等每⼈领劵次数
    • 发券总量
    • 优惠券开始时间和结束时间

优惠券微服务及数据库和表

优惠券业务需求:

  • 新⽤户注册-发放后端配置的新⼈优惠券
  • ⽤户可以主动领取优惠券
  • 下单可以选择对应的优惠券抵扣
  • ⽀持满减优惠券-⽆⻔槛优惠券两种
  • 多种元数据配置
    • 类型:⽆⻔槛、满减等
    • 每⼈领劵次数限制
    • 发券总量控制优惠券开始时间和结束时间
    • 优惠券状态配置

核心知识:高并发下扣减劵库存,超发,单人超领取

原⽣分布式锁+redisson框架分布锁使⽤: 分布式锁+最佳实践

建立优惠券服务数据库: p_nla_coupon

优惠券表:

  1. CREATE TABLE `coupon` (
  2. `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
  3. `category` varchar(11) DEFAULT NULL COMMENT '优惠卷类型[NEW_USER注册赠券,TASK任务卷,PROMOTION促销劵]',
  4. `publish` varchar(11) DEFAULT NULL COMMENT '发布状态, PUBLISH发布,DRAFT草稿,OFFLINE下线',
  5. `coupon_img` varchar(524) DEFAULT NULL COMMENT '优惠券图片',
  6. `coupon_title` varchar(128) DEFAULT NULL COMMENT '优惠券标题',
  7. `price` decimal(16,2) DEFAULT NULL COMMENT '抵扣价格',
  8. `user_limit` int(11) DEFAULT NULL COMMENT '每人限制张数',
  9. `start_time` datetime DEFAULT NULL COMMENT '优惠券开始有效时间',
  10. `end_time` datetime DEFAULT NULL COMMENT '优惠券失效时间',
  11. `publish_count` int(11) DEFAULT NULL COMMENT '优惠券总量',
  12. `stock` int(11) DEFAULT '0' COMMENT '库存',
  13. `create_time` datetime DEFAULT NULL,
  14. `condition_price` decimal(16,2) DEFAULT NULL COMMENT '满多少才可以使用',
  15. `version` bigint(20) DEFAULT NULL COMMENT '版本', --当使用乐观锁的方式时
  16. PRIMARY KEY (`id`)
  17. ) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COMMENT='优惠券表';

优惠券领劵记录表:

  1. CREATE TABLE `coupon_record` (
  2. `id` bigint(11) unsigned NOT NULL AUTO_INCREMENT,
  3. `coupon_id` bigint(11) DEFAULT NULL COMMENT '优惠券id',
  4. `create_time` datetime DEFAULT NULL COMMENT '创建时间获得时间',
  5. `use_state` varchar(32) DEFAULT NULL COMMENT '使用状态: 可用NEW,已使用USED,过期EXPIRED;',
  6. `user_id` bigint(11) DEFAULT NULL COMMENT '用户id',
  7. `user_name` varchar(128) DEFAULT NULL COMMENT '用户昵称',
  8. `coupon_title` varchar(128) DEFAULT NULL COMMENT '优惠券标题',
  9. `start_time` datetime DEFAULT NULL COMMENT '开始时间',
  10. `end_time` datetime DEFAULT NULL COMMENT '结束时间',
  11. `order_id` bigint(11) DEFAULT NULL COMMENT '订单id',
  12. `price` decimal(16,2) DEFAULT NULL COMMENT '抵扣价格',
  13. `condition_price` decimal(16,2) DEFAULT NULL COMMENT '满多少才可以使用',
  14. PRIMARY KEY (`id`)
  15. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券领劵记录';

添加数据:

  1. INSERT INTO `coupon` (`id`, `category`,
  2. `publish`, `coupon_img`, `coupon_title`,
  3. `price`, `user_limit`, `start_time`,
  4. `end_time`, `publish_count`, `stock`,
  5. `create_time`, `condition_price`)
  6. VALUES
  7. (1, 'NEW_USER', 'PUBLISH',
  8. 'https://file.xdclass.net/video/2020/alibabacloud/zt-alibabacloud.png', '永久有效-新人注册-0元满减-5元抵扣劵-限领取2张-不可叠加使用', 5.00, 2,
  9. '2000-01-01 00:00:00', '2099-01-29 00:00:00',100000000, 99999991, '2020-12-26 16:33:02',0.00),
  10. (2, 'PROMOTION', 'PUBLISH','https://file.xdclass.net/video/2020/alibabacloud/zt-alibabacloud.png', '有效中-21年1月到25年1月-20元满减-5元抵扣劵-限领取2张-不可叠加使用', 5.00,2, '2000-01-29 00:00:00', '2025-01-29 00:00:00', 10, 3, '2020-12-26 16:33:03',20.00),
  11. (3, 'PROMOTION', 'PUBLISH','https://file.xdclass.net/video/2020/alibabacloud/zt-alibabacloud.png', '过期-20年8月到20年9月-商 品id3-6元抵扣劵-限领取1张-可叠加使用', 6.00, 1,'2020-08-01 00:00:00', '2020-09-29 00:00:00',100, 100, '2020-12-26 16:33:03', 0.00),
  12. (4, 'PROMOTION', 'PUBLISH','https://file.xdclass.net/video/2020/alibabacloud/zt-alibabacloud.png', '有效中-20年8月到21年9月-商品id1-8.8元抵扣劵-限领取2张-不可叠加使用', 8.80,2, '2020-08-01 00:00:00', '2021-09-29 00:00:00', 100, 96, '2020-12-26 16:33:03',
  13. 0.00),
  14. (5, 'PROMOTION', 'PUBLISH','https://file.xdclass.net/video/2020/alibabacloud/zt-alibabacloud.png', '有效中-20年8月到21年9月-商品id2-9.9元抵扣劵-限领取2张-可叠加使用', 8.80, 2,'2020-08-01 00:00:00', '2021-09-29 00:00:00',100, 96, '2020-12-26 16:33:03', 0.00);

Mybatis-plus 分页插件配置

公共添加分页插件配置MybatisPlusInterceptor

  1. @Configuration
  2. public class MybatisPlusPageConfig {
  3. // 旧版本配置
  4. // @Bean
  5. // public PaginationInterceptorpaginationInterceptor() {
  6. // return new PaginationInterceptor();
  7. // }
  8. /**
  9. * 新的分⻚插件,⼀缓和⼆缓遵循mybatis的规则,
  10. * 需要设置
  11. * MybatisConfiguration#useDeprecatedExecutor =
  12. * false 避免缓存出现问题
  13. */
  14. @Bean
  15. public MybatisPlusInterceptor mybatisPlusInterceptor() {
  16. MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
  17. interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
  18. return interceptor;
  19. }
  20. }

SwaggerConfiguration中配置优惠券服务接口文档配置

  1. /**
  2. * 对优惠劵服务端的接口文档
  3. */
  4. @Bean
  5. public Docket couponApiDoc() {
  6. return new Docket(DocumentationType.OAS_30) // 版本3.0
  7. .groupName("优惠劵端接口文档")
  8. .pathMapping("/")
  9. //定义是否开启Swagger,false是关闭,可以通过变量去控制,线上关闭
  10. .enable(true)
  11. //配置文档的元信息
  12. .apiInfo(apiInfo())
  13. .select()
  14. .apis(RequestHandlerSelectors.basePackage("cn.nla"))
  15. //正则匹配请求路径,并分配到当前项目组
  16. .paths(PathSelectors.ant("/cop/**"))
  17. .build()
  18. // 新版SwaggerUI3.0
  19. .globalRequestParameters(globalRequestParameters())
  20. .globalResponses(HttpMethod.GET, getGlobalResponseMessage())
  21. .globalResponses(HttpMethod.POST, getGlobalResponseMessage());
  22. }

构建优惠券服务模块nla-coupon-service同时配置参数

contoller层开发

  1. @Api(tags ="优惠券控制器")
  2. @RestController
  3. @RequestMapping("/cop/coupon/v1")
  4. public class CouponController {
  5. @Resource
  6. private CouponService couponService;
  7. @ApiOperation("分页查询优惠券")
  8. @GetMapping("page_coupon")
  9. public JsonData pageCouponList(
  10. @ApiParam(value = "当前页") @RequestParam(value = "page", defaultValue = "1") int page,
  11. @ApiParam(value = "每页显示多少条") @RequestParam(value = "size", defaultValue = "10") int size) {
  12. return JsonData.buildSuccess(couponService.pageCouponActivity(page,size));

添加优惠卷相关字典

  1. /**
  2. * 优惠券状态
  3. */
  4. public enum CouponPublishEnum {
  5. /**
  6. * 线上
  7. */
  8. PUBLISH,
  9. /**
  10. * 草稿
  11. */
  12. DRAFT,
  13. /**
  14. * 下线
  15. */
  16. OFFLINE
  17. }
  18. public enum CouponCategoryEnum {
  19. /**
  20. * 新人注册
  21. */
  22. NEW_USER,
  23. /**
  24. * 活动任务
  25. */
  26. TASK,
  27. /**
  28. * 促销劵
  29. */
  30. PROMOTION
  31. }

定义优惠卷返回对象

  1. @Data
  2. public class CouponVO {
  3. /**
  4. * id
  5. */
  6. private Long id;
  7. /**
  8. * 优惠卷类型[NEW_USER注册赠券,TASK任务卷,PROMOTION促销劵]
  9. */
  10. private String category;
  11. /**
  12. * 优惠券图片
  13. */
  14. @JsonProperty("coupon_img")
  15. private String couponImg;
  16. /**
  17. * 优惠券标题
  18. */
  19. @JsonProperty("coupon_title")
  20. private String couponTitle;
  21. /**
  22. * 抵扣价格
  23. */
  24. private BigDecimal price;
  25. /**
  26. * 每人限制张数
  27. */
  28. @JsonProperty("user_limit")
  29. private Integer userLimit;
  30. /**
  31. * 优惠券开始有效时间
  32. */
  33. @JsonFormat(pattern = "yyyy-MM-dd hh:mm:ss",locale = "zh",timezone = "GMT+8")
  34. @JsonProperty("start_time")
  35. private Date startTime;
  36. /**
  37. * 优惠券失效时间
  38. */
  39. @JsonFormat(pattern = "yyyy-MM-dd hh:mm:ss",locale = "zh",timezone = "GMT+8")
  40. @JsonProperty("end_time")
  41. private Date endTime;
  42. /**
  43. * 优惠券总量
  44. */
  45. @JsonProperty("publish_count")
  46. private Integer publishCount;
  47. /**
  48. * 库存
  49. */
  50. private Integer stock;
  51. /**
  52. * 满多少才可以使用
  53. */
  54. @JsonProperty("condition_price")
  55. private BigDecimal conditionPrice;
  56. }

service分页开发Page,IPage

  1. @Service
  2. public class CouponServiceImpl extends ServiceImpl<CouponMapper, CouponEntity> implements CouponService {
  3. @Override
  4. public Map<String, Object> pageCouponActivity(int page, int size) {
  5. Page<CouponEntity> pageInfo = new Page<>(page,size);
  6. IPage<CouponEntity> couponDOIPage = baseMapper.selectPage(pageInfo,
  7. Wrappers.<CouponEntity>lambdaQuery().eq(CouponEntity::getPublish, CouponPublishEnum.PUBLISH)
  8. .eq(CouponEntity::getCategory, CouponCategoryEnum.PROMOTION)
  9. .orderByDesc(CouponEntity::getCreateTime));
  10. Map<String,Object> pageMap = new HashMap<>(3);
  11. //总条数
  12. pageMap.put("total_record", couponDOIPage.getTotal());
  13. //总页数
  14. pageMap.put("total_page",couponDOIPage.getPages());
  15. pageMap.put("current_data",couponDOIPage.getRecords().stream().map(this::beanProcess).collect(Collectors.toList()));
  16. return pageMap;
  17. }
  18. private CouponVO beanProcess(CouponEntity entity) {
  19. CouponVO couponVO = new CouponVO();
  20. BeanUtils.copyProperties(entity,couponVO);
  21. return couponVO;
  22. }
  23. }

注意:分页插件配置在公共模块,而优惠券服务是引入公共依赖,所以要添加次路径的扫描:否则不会生效

  1. @SpringBootApplication(scanBasePackages = "cn.nla.*")

用户领劵核心业务逻辑 - 高并发下扣减劵库存,采用乐观锁

contoller层

  1. /**
  2. * 领取优惠券
  3. */
  4. @ApiOperation("领取优惠券")
  5. @GetMapping("/add/promotion/{coupon_id}")
  6. public JsonData addPromotionCoupon(@ApiParam(value = "优惠券id", required = true) @PathVariable("coupon_id") long couponId) {
  7. return couponService.addCoupon(couponId, CouponCategoryEnum.PROMOTION);
  8. }

领劵接口业务逻辑:

1、获取优惠券是否存在

2、校验优惠券是否可以领取:时间、库存、超过限制

3、扣减库存

4、保存领劵记录

  • 优惠券是否可以领取校验:
  1. /**
  2. * 校验是否可以领取
  3. */
  4. private void checkCoupon(CouponEntity coupon, Long userId) {
  5. if (coupon == null) {
  6. throw new BizException(BizCodeEnum.COUPON_NO_EXITS);
  7. }
  8. //库存是否足够
  9. if (coupon.getStock() <= 0) {
  10. throw new BizException(BizCodeEnum.COUPON_NO_STOCK);
  11. }
  12. //判断是否是否发布状态
  13. if (!coupon.getPublish().equals(CouponPublishEnum.PUBLISH.name())) {
  14. throw new BizException(BizCodeEnum.COUPON_GET_FAIL);
  15. }
  16. //是否在领取时间范围
  17. long time = CommonUtil.getCurrentTimestamp();
  18. long start = coupon.getStartTime().getTime();
  19. long end = coupon.getEndTime().getTime();
  20. if (time < start || time > end) {
  21. throw new BizException(BizCodeEnum.COUPON_OUT_OF_TIME);
  22. }
  23. //用户是否超过限制
  24. int recordNum = couponRecordMapper.selectCount(
  25. Wrappers.<CouponRecordEntity>lambdaQuery().eq(CouponRecordEntity::getCouponId, coupon.getId())
  26. .eq(CouponRecordEntity::getUserId, userId));
  27. if (recordNum >= coupon.getUserLimit()) {
  28. throw new BizException(BizCodeEnum.COUPON_OUT_OF_LIMIT);
  29. }
  30. }

mapper.xml

  1. <!--扣减库存-->
  2. <update id="reduceStock">
  3. update coupon set stock=stock-1 where id = #{couponId}
  4. </update>
  5. <!--扣减库存(加版本号,支持高并发下不超发问题)-->
  6. <update id="reduceStockOpt">
  7. update coupon set stock=stock-1,version=version+1 where id = #{couponId} and stock>0 and version=#{oldVersion}
  8. </update>

对应的mapper

  1. /**
  2. * 扣减存储
  3. */
  4. int reduceStock(@Param("couponId")long couponId);
  5. /**
  6. * 扣减存储
  7. * @param couponId 优惠卷id
  8. * @param oldVersion 版本号
  9. */
  10. int reduceStockOpt(@Param("couponId") long couponId, @Param("oldVersion")long oldVersion);

领劵接口:库存扣减:⾼并发下扣减劵库存,采⽤乐观锁,当前stock做版本号,延伸多种防⽌超卖的问题,⼀次只能领取1张

  1. @Override
  2. public JsonData addCoupon(Long couponId, CouponCategoryEnum category) {
  3. LoginUser loginUser = LoginInterceptor.threadLocal.get();
  4. CouponEntity coupon = baseMapper.selectOne(
  5. Wrappers.<CouponEntity>lambdaQuery().eq(CouponEntity::getId, couponId)
  6. .eq(CouponEntity::getCategory, category.name()));
  7. //优惠券是否可以领取
  8. this.checkCoupon(coupon, loginUser.getId());
  9. //构建领劵记录
  10. CouponRecordEntity couponRecord = new CouponRecordEntity();
  11. BeanUtils.copyProperties(coupon, couponRecord);
  12. couponRecord.setCreateTime(new Date());
  13. couponRecord.setUseState(CouponStateEnum.NEW.name());
  14. couponRecord.setUserId(loginUser.getId());
  15. couponRecord.setUserName(loginUser.getName());
  16. couponRecord.setCouponId(couponId);
  17. couponRecord.setId(null);
  18. //扣减库存
  19. // int rows = baseMapper.reduceStock(couponId);
  20. //⾼并发下扣减劵库存,采⽤乐观锁,当前stock做版本号,延伸多种防⽌超卖的问题,⼀次只能领取1张
  21. // 数据库添加字段: version INT DEFAULT 1, 根据当前的版本号更新
  22. int rows = baseMapper.reduceStockOpt(couponId, coupon.getVersion());
  23. if (rows == 1) {
  24. //库存扣减成功才保存
  25. couponRecordMapper.insert(couponRecord);
  26. } else {
  27. log.warn("发放优惠券失败:{},⽤ 户:{}", coupon, loginUser);
  28. throw new BizException(BizCodeEnum.COUPON_NO_STOCK);
  29. }
  30. return JsonData.buildSuccess();
  31. }