优惠券业务介绍
拉新业务衍生出的玩法:砍价,拼团,裂变,优惠券。
电商优惠券逻辑:
获取方式维度:
被动:
新⼈优惠券,⽆⻔槛现⾦劵
,…其他
第⼀次登录注册某平台,登录成功后进入到⾸个⻚⾯,弹出新⼈红包或者某固定位置领取新⼈红包,前端领取位置及细节不做详细讲解,根据实际业务场景⽽定。
主动:领取优惠券,满减劵
, …其他
促进商品订单成交,⽽设置的优惠券,提⾼下单⽀付率
- 使⽤⻔槛维度
- ⽆⻔槛现⾦劵
- 满减劵
- 满减折扣卷
- 运费抵扣券
- 兑换券
- 店铺劵
- 单品劵
- …
按照多维度设计,就需要⽤到规则引擎,这类⼀般是⼤⼚的营销中台设计的,复杂且容易出问题。
- 优惠券常⻅
属性
类型
:⽆⻔槛、满减、单品、折扣等每⼈领劵次数发券总量
- 优惠券开始
时间
和结束时间
优惠券微服务及数据库和表
优惠券业务需求:
- 新⽤户注册-发放后端配置的新⼈优惠券
- ⽤户可以主动领取优惠券
- 下单可以选择对应的优惠券抵扣
- ⽀持满减优惠券-⽆⻔槛优惠券两种
- 多种元数据配置
- 类型:⽆⻔槛、满减等
- 每⼈领劵次数限制
- 发券总量控制优惠券开始时间和结束时间
- 优惠券状态配置
核心知识:高并发下扣减劵库存,超发,单人超领取
原⽣分布式锁+redisson框架分布锁使⽤: 分布式锁+最佳实践
建立优惠券服务数据库: p_nla_coupon
优惠券表:
CREATE TABLE `coupon` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
`category` varchar(11) DEFAULT NULL COMMENT '优惠卷类型[NEW_USER注册赠券,TASK任务卷,PROMOTION促销劵]',
`publish` varchar(11) DEFAULT NULL COMMENT '发布状态, PUBLISH发布,DRAFT草稿,OFFLINE下线',
`coupon_img` varchar(524) DEFAULT NULL COMMENT '优惠券图片',
`coupon_title` varchar(128) DEFAULT NULL COMMENT '优惠券标题',
`price` decimal(16,2) DEFAULT NULL COMMENT '抵扣价格',
`user_limit` int(11) DEFAULT NULL COMMENT '每人限制张数',
`start_time` datetime DEFAULT NULL COMMENT '优惠券开始有效时间',
`end_time` datetime DEFAULT NULL COMMENT '优惠券失效时间',
`publish_count` int(11) DEFAULT NULL COMMENT '优惠券总量',
`stock` int(11) DEFAULT '0' COMMENT '库存',
`create_time` datetime DEFAULT NULL,
`condition_price` decimal(16,2) DEFAULT NULL COMMENT '满多少才可以使用',
`version` bigint(20) DEFAULT NULL COMMENT '版本', --当使用乐观锁的方式时
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COMMENT='优惠券表';
优惠券领劵记录表:
CREATE TABLE `coupon_record` (
`id` bigint(11) unsigned NOT NULL AUTO_INCREMENT,
`coupon_id` bigint(11) DEFAULT NULL COMMENT '优惠券id',
`create_time` datetime DEFAULT NULL COMMENT '创建时间获得时间',
`use_state` varchar(32) DEFAULT NULL COMMENT '使用状态: 可用NEW,已使用USED,过期EXPIRED;',
`user_id` bigint(11) DEFAULT NULL COMMENT '用户id',
`user_name` varchar(128) DEFAULT NULL COMMENT '用户昵称',
`coupon_title` varchar(128) DEFAULT NULL COMMENT '优惠券标题',
`start_time` datetime DEFAULT NULL COMMENT '开始时间',
`end_time` datetime DEFAULT NULL COMMENT '结束时间',
`order_id` bigint(11) DEFAULT NULL COMMENT '订单id',
`price` decimal(16,2) DEFAULT NULL COMMENT '抵扣价格',
`condition_price` decimal(16,2) DEFAULT NULL COMMENT '满多少才可以使用',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券领劵记录';
添加数据:
INSERT INTO `coupon` (`id`, `category`,
`publish`, `coupon_img`, `coupon_title`,
`price`, `user_limit`, `start_time`,
`end_time`, `publish_count`, `stock`,
`create_time`, `condition_price`)
VALUES
(1, 'NEW_USER', 'PUBLISH',
'https://file.xdclass.net/video/2020/alibabacloud/zt-alibabacloud.png', '永久有效-新人注册-0元满减-5元抵扣劵-限领取2张-不可叠加使用', 5.00, 2,
'2000-01-01 00:00:00', '2099-01-29 00:00:00',100000000, 99999991, '2020-12-26 16:33:02',0.00),
(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),
(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),
(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',
0.00),
(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
:
@Configuration
public class MybatisPlusPageConfig {
// 旧版本配置
// @Bean
// public PaginationInterceptorpaginationInterceptor() {
// return new PaginationInterceptor();
// }
/**
* 新的分⻚插件,⼀缓和⼆缓遵循mybatis的规则,
* 需要设置
* MybatisConfiguration#useDeprecatedExecutor =
* false 避免缓存出现问题
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
SwaggerConfiguration中配置优惠券服务接口文档配置
/**
* 对优惠劵服务端的接口文档
*/
@Bean
public Docket couponApiDoc() {
return new Docket(DocumentationType.OAS_30) // 版本3.0
.groupName("优惠劵端接口文档")
.pathMapping("/")
//定义是否开启Swagger,false是关闭,可以通过变量去控制,线上关闭
.enable(true)
//配置文档的元信息
.apiInfo(apiInfo())
.select()
.apis(RequestHandlerSelectors.basePackage("cn.nla"))
//正则匹配请求路径,并分配到当前项目组
.paths(PathSelectors.ant("/cop/**"))
.build()
// 新版SwaggerUI3.0
.globalRequestParameters(globalRequestParameters())
.globalResponses(HttpMethod.GET, getGlobalResponseMessage())
.globalResponses(HttpMethod.POST, getGlobalResponseMessage());
}
构建优惠券服务模块nla-coupon-service同时配置参数
contoller层开发
@Api(tags ="优惠券控制器")
@RestController
@RequestMapping("/cop/coupon/v1")
public class CouponController {
@Resource
private CouponService couponService;
@ApiOperation("分页查询优惠券")
@GetMapping("page_coupon")
public JsonData pageCouponList(
@ApiParam(value = "当前页") @RequestParam(value = "page", defaultValue = "1") int page,
@ApiParam(value = "每页显示多少条") @RequestParam(value = "size", defaultValue = "10") int size) {
return JsonData.buildSuccess(couponService.pageCouponActivity(page,size));
添加优惠卷相关字典
/**
* 优惠券状态
*/
public enum CouponPublishEnum {
/**
* 线上
*/
PUBLISH,
/**
* 草稿
*/
DRAFT,
/**
* 下线
*/
OFFLINE
}
public enum CouponCategoryEnum {
/**
* 新人注册
*/
NEW_USER,
/**
* 活动任务
*/
TASK,
/**
* 促销劵
*/
PROMOTION
}
定义优惠卷返回对象
@Data
public class CouponVO {
/**
* id
*/
private Long id;
/**
* 优惠卷类型[NEW_USER注册赠券,TASK任务卷,PROMOTION促销劵]
*/
private String category;
/**
* 优惠券图片
*/
@JsonProperty("coupon_img")
private String couponImg;
/**
* 优惠券标题
*/
@JsonProperty("coupon_title")
private String couponTitle;
/**
* 抵扣价格
*/
private BigDecimal price;
/**
* 每人限制张数
*/
@JsonProperty("user_limit")
private Integer userLimit;
/**
* 优惠券开始有效时间
*/
@JsonFormat(pattern = "yyyy-MM-dd hh:mm:ss",locale = "zh",timezone = "GMT+8")
@JsonProperty("start_time")
private Date startTime;
/**
* 优惠券失效时间
*/
@JsonFormat(pattern = "yyyy-MM-dd hh:mm:ss",locale = "zh",timezone = "GMT+8")
@JsonProperty("end_time")
private Date endTime;
/**
* 优惠券总量
*/
@JsonProperty("publish_count")
private Integer publishCount;
/**
* 库存
*/
private Integer stock;
/**
* 满多少才可以使用
*/
@JsonProperty("condition_price")
private BigDecimal conditionPrice;
}
service分页开发Page,IPage
:
@Service
public class CouponServiceImpl extends ServiceImpl<CouponMapper, CouponEntity> implements CouponService {
@Override
public Map<String, Object> pageCouponActivity(int page, int size) {
Page<CouponEntity> pageInfo = new Page<>(page,size);
IPage<CouponEntity> couponDOIPage = baseMapper.selectPage(pageInfo,
Wrappers.<CouponEntity>lambdaQuery().eq(CouponEntity::getPublish, CouponPublishEnum.PUBLISH)
.eq(CouponEntity::getCategory, CouponCategoryEnum.PROMOTION)
.orderByDesc(CouponEntity::getCreateTime));
Map<String,Object> pageMap = new HashMap<>(3);
//总条数
pageMap.put("total_record", couponDOIPage.getTotal());
//总页数
pageMap.put("total_page",couponDOIPage.getPages());
pageMap.put("current_data",couponDOIPage.getRecords().stream().map(this::beanProcess).collect(Collectors.toList()));
return pageMap;
}
private CouponVO beanProcess(CouponEntity entity) {
CouponVO couponVO = new CouponVO();
BeanUtils.copyProperties(entity,couponVO);
return couponVO;
}
}
注意:分页插件配置在公共模块,而优惠券服务是引入公共依赖,所以要添加次路径的扫描:否则不会生效
@SpringBootApplication(scanBasePackages = "cn.nla.*")
用户领劵核心业务逻辑 - 高并发下扣减劵库存,采用乐观锁
contoller层
/**
* 领取优惠券
*/
@ApiOperation("领取优惠券")
@GetMapping("/add/promotion/{coupon_id}")
public JsonData addPromotionCoupon(@ApiParam(value = "优惠券id", required = true) @PathVariable("coupon_id") long couponId) {
return couponService.addCoupon(couponId, CouponCategoryEnum.PROMOTION);
}
领劵接口业务逻辑:
1、获取优惠券是否存在
2、校验优惠券是否可以领取:时间、库存、超过限制
3、扣减库存
4、保存领劵记录
- 优惠券是否可以领取校验:
/**
* 校验是否可以领取
*/
private void checkCoupon(CouponEntity coupon, Long userId) {
if (coupon == null) {
throw new BizException(BizCodeEnum.COUPON_NO_EXITS);
}
//库存是否足够
if (coupon.getStock() <= 0) {
throw new BizException(BizCodeEnum.COUPON_NO_STOCK);
}
//判断是否是否发布状态
if (!coupon.getPublish().equals(CouponPublishEnum.PUBLISH.name())) {
throw new BizException(BizCodeEnum.COUPON_GET_FAIL);
}
//是否在领取时间范围
long time = CommonUtil.getCurrentTimestamp();
long start = coupon.getStartTime().getTime();
long end = coupon.getEndTime().getTime();
if (time < start || time > end) {
throw new BizException(BizCodeEnum.COUPON_OUT_OF_TIME);
}
//用户是否超过限制
int recordNum = couponRecordMapper.selectCount(
Wrappers.<CouponRecordEntity>lambdaQuery().eq(CouponRecordEntity::getCouponId, coupon.getId())
.eq(CouponRecordEntity::getUserId, userId));
if (recordNum >= coupon.getUserLimit()) {
throw new BizException(BizCodeEnum.COUPON_OUT_OF_LIMIT);
}
}
mapper.xml
<!--扣减库存-->
<update id="reduceStock">
update coupon set stock=stock-1 where id = #{couponId}
</update>
<!--扣减库存(加版本号,支持高并发下不超发问题)-->
<update id="reduceStockOpt">
update coupon set stock=stock-1,version=version+1 where id = #{couponId} and stock>0 and version=#{oldVersion}
</update>
对应的mapper
/**
* 扣减存储
*/
int reduceStock(@Param("couponId")long couponId);
/**
* 扣减存储
* @param couponId 优惠卷id
* @param oldVersion 版本号
*/
int reduceStockOpt(@Param("couponId") long couponId, @Param("oldVersion")long oldVersion);
领劵接口:库存扣减:⾼并发下扣减劵库存,采⽤乐观锁,当前stock做版本号,延伸多种防⽌超卖的问题,⼀次只能领取1张
@Override
public JsonData addCoupon(Long couponId, CouponCategoryEnum category) {
LoginUser loginUser = LoginInterceptor.threadLocal.get();
CouponEntity coupon = baseMapper.selectOne(
Wrappers.<CouponEntity>lambdaQuery().eq(CouponEntity::getId, couponId)
.eq(CouponEntity::getCategory, category.name()));
//优惠券是否可以领取
this.checkCoupon(coupon, loginUser.getId());
//构建领劵记录
CouponRecordEntity couponRecord = new CouponRecordEntity();
BeanUtils.copyProperties(coupon, couponRecord);
couponRecord.setCreateTime(new Date());
couponRecord.setUseState(CouponStateEnum.NEW.name());
couponRecord.setUserId(loginUser.getId());
couponRecord.setUserName(loginUser.getName());
couponRecord.setCouponId(couponId);
couponRecord.setId(null);
//扣减库存
// int rows = baseMapper.reduceStock(couponId);
//⾼并发下扣减劵库存,采⽤乐观锁,当前stock做版本号,延伸多种防⽌超卖的问题,⼀次只能领取1张
// 数据库添加字段: version INT DEFAULT 1, 根据当前的版本号更新
int rows = baseMapper.reduceStockOpt(couponId, coupon.getVersion());
if (rows == 1) {
//库存扣减成功才保存
couponRecordMapper.insert(couponRecord);
} else {
log.warn("发放优惠券失败:{},⽤ 户:{}", coupon, loginUser);
throw new BizException(BizCodeEnum.COUPON_NO_STOCK);
}
return JsonData.buildSuccess();
}