优惠券服务
[TOC]
提示
认证服务:注册、登录、密码找回、密码修改、查询我的、查看登录信息等
用户服务:用户详情、完善、修改、足迹、历史、收藏等
会员服务:会员等级、积分变动、新增、消费、充值会员
签到服务:签到、校验、签到记录、抽奖、奖励等
优惠券服务:生成、领取、查询、抵扣等
订单服务:下单、查询、状态等
秒杀服务:下单、秒杀活动、状态等
评价服务:评价、查询、状态等
健身、健康、医疗、教育、社区、社交、电商、旅游、房产等等
通用型:任何项目基本上都可以使用
背景
0.1 优惠劵的目的
优惠券是我们系统中最常见的活跃用户的一种方式,简单的设计就能带来巨大的客流量。通常在活动、促销、甩卖等场景中,我们最常用到的手段无疑是优惠券了
0.2 优惠劵的意义
1、提升用户活跃度
人们总是对 “降价”、“打折” 这样的字眼充满了兴趣,用户也习惯于在了解到商品的价格及优惠力度后再决定购买,所以有优惠的商品才更具有吸引力。
2、增加产品曝光量
用户一券在手,总是让人忍不住翻看可以使用的商品,这无形中增加了平台商品的曝光量。同时好的优惠券会在用户的口口相传中得到推广,这对平台、商家和产品来说,都是一个很好的展现自己口碑的机会。
3、刺激用户的潜在购买需求
当用户的购买行为背后没有充分的购买动机,交易就会轻易的受到其他因素的影响而中断。优惠券的出现满足了用户 “赚到” 的心理,用户就愿意为潜在的购物需求买单。
4、提升用户的购买力
用户的购买力和收入水平成正比,和商品价格呈反比,当价格受到优惠时,用户的购买力也可以得到相应改善。
0.3 优惠劵使用条件的分类
1.体验券
一般针对新品或测试产品向用户免费发放的体验券,意在吸引用户的关注,倾听用户的意见,有时体验券也会以邀请码的形式出现。
2.代金券(又称现金券)
一般使用门槛较低,不会有金额、数量等方面的要求,可以直接使用,若购买商品不够券面金额,通常情况下是不退还差额的,如:新人大礼包、无门槛红包和员工福利等。
3.满减券
通常会有订购数量、订单总价、产品种类等方面的要求,满足条件的订单才可享受满减,如:生活缴费商品满 ¥100 减 ¥2 优惠券。
4.打折券
是直接对商品进行打折,一般商品较贵,购买的用户较少,或者用户订购量大会采用此类型优惠券,如:8.8 折优惠券等。
0.4 优惠劵使用范围分类
1.单品券
为购买单一商品时使用的优惠券
2.系列产品券
为购买某种特定系列产品时所使用的优惠券,用户只需要购买指定系列的产品就可以享用这张优惠券,如:购买无线宝 WiFi5 系列产品优惠券等
3.品类券
为购买某一类商品时使用的优惠券,如:购买清洁类、医药类、生鲜类等优惠券;
4.品牌券
为购买某一品牌商品时使用的优惠券,如:购买华为、京东云等品牌产品所用的优惠券。
0.5优惠劵发放主体分类
1、店铺优惠券
则是店铺自行发放的,如:关注有礼、抽奖、新老顾客回馈等;
2、平台优惠券
是由平台直接发放给用户的优惠券,针对的目标群体范围较广,如:购物津贴、百亿补贴等;
3.政企消费券
成本由政府、企业和平台共同承担,意在提升某些地区消费者的消费能力和消费水平,如:北京消费券等
一、需求
优惠券模板是由运营人员根据一定的条件来设定的,优惠券必须有数量限制并且必须有优惠券码
优惠券模板创建
二、分析
明白需求是什么?
模仿--使用功能
基于优惠券模板实现优惠券的发放,主要实现的是平台券,发放的途径:1.用户抢 2.系统发放
优惠券模板功能:
1.创建优惠券接口,设置各种条件
2.审核优惠券活动接口,根据情况,缓存
3.查询优惠券模板接口
难点:1.系统发放 线程池+分片算法 2.优惠券的缓存
用户优惠券功能:
1.领取接口,实现优惠券领取-超领
2.查询我的优惠券接口
3.查看优惠券抵扣接口
4.抵扣优惠券接口
三、设计
3.1 数据库脚本
CREATE TABLE `t_coupon_template` (
`id` int(11) AUTO_INCREMENT,
`flag` int(11) COMMENT '状态:41.未审核 42.审核通过 43.审核失败',
`name` varchar(64) COMMENT '名字',
`logo` varchar(256) ,
`intro` varchar(256) COMMENT '简介',
`category` int(11) COMMENT '种类: 51-满减;52-折扣;53-立减',
`scope` int(11) COMMENT '使用范围:61-单品;62-商品类型;63-全品',
`scope_id` int(11) COMMENT '对应的id:单品id;商品类型id;全品为0',
`expire_time` datetime COMMENT '优惠券发放结束日期',
`coupon_count` int(11) COMMENT '优惠券发放数量',
`create_time` datetime COMMENT '创建时间',
`user_id` int(11) COMMENT '创建人的ID,后台内部员工',
`user_audit` varchar(100) COMMENT '审核意见',
`template_key` varchar(128) COMMENT '优惠券模板的识别码(有一定的识别度)',
`target` int(11) COMMENT '优惠券作用的人群:71-全体;72-会员等级 73-新用户 74-收费会员',
`target_level` int(11) COMMENT '用户等级要求,默认0',
`send_type` int comment '发放类型:81.用户领取 82.系统发放',
`start_time` datetime comment '优惠券生效日期',
`end_time` datetime comment '优惠券失效日期',
`limitmoney` decimal(10, 2) comment '优惠券可以使用的金额,满减、满折等',
`discount` double comment '减免或折扣' ,
PRIMARY KEY (`id`)
) comment '8.优惠券模板表';
CREATE TABLE `t_usercoupon` (
`id` int(11) AUTO_INCREMENT,
`template_id` int(11) COMMENT '优惠券模板ID',
`user_id` int(11) COMMENT '前端用户ID',
`coupon_code` varchar(70) COMMENT '优惠券码',
`assign_date` datetime COMMENT '优惠券分发时间',
`status` int(11) COMMENT '优惠券状态',
PRIMARY KEY (`id`)
) comment '9.用户优惠券表';
3.2 Entity和DTO
实体对象Entity
//优惠券模板对象
//和数据库中的表一一对应
package org.qf.cloudcoupon.entity;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
/**
* 8.优惠券模板表(TCouponTemplate)实体类
*
* @author makejava
* @since 2022-09-26 14:46:36
*/
@Data
public class TCouponTemplate implements Serializable {
private static final long serialVersionUID = 778554933659508581L;
private Integer id;
/**
* 状态:41.未审核 42.审核通过 43.审核失败
*/
private Integer flag;
/**
* 名字
*/
private String name;
private String logo;
/**
* 简介
*/
private String intro;
/**
* 种类: 51-满减;52-折扣;53-立减
*/
private Integer category;
/**
* 使用范围:61-单品;62-商品类型;63-全品
*/
private Integer scope;
/**
* 对应的id:单品id;商品类型id;全品为0
*/
private Integer scopeId;
/**
* 优惠券发放结束日期
*/
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date expireTime;
/**
* 优惠券发放数量
*/
private Integer couponCount;
/**
* 创建时间
*/
// 解析参数的,前端传递的是string类型的字串时 string->date
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
// 实体对象转换为json字串的互转 json <-> date
// 前端传参如果是json字串 就按照JsonFormat进行反序列化
// 后端如果需要返回json字串 就按照JsonFormat进行序列化
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
/**
* 创建人的ID,后台内部员工
*/
private Integer userId;
/**
* 审核意见
*/
private String userAudit;
/**
* 优惠券模板的识别码(有一定的识别度)
*/
private String templateKey;
/**
* 优惠券作用的人群:71-全体;72-会员等级 73-新用户 74-收费会员
*/
private Integer target;
/**
* 用户等级要求,默认0
*/
private Integer targetLevel;
/**
* 发放类型:81.用户领取 82.系统发放
*/
private Integer sendType;
/**
* 优惠券生效日期
*/
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date startTime;
/**
* 优惠券失效日期
*/
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date endTime;
/**
* 优惠券可以使用的金额,满减、满折等
*/
private Double limitmoney;
/**
* 减免或折扣
*/
private Double discount;
}
package org.qf.cloudcoupon.entity;
import java.util.Date;
import lombok.Data;
import org.qf.cloudcoupon.config.SystemConfig;
import java.io.Serializable;
/**
* 9.用户优惠券表(TUsercoupon)实体类
*
* @author makejava
* @since 2022-09-26 14:47:20
*/
@Data
public class TUsercoupon implements Serializable {
private static final long serialVersionUID = -71212782766340228L;
private Integer id;
/**
* 优惠券模板ID
*/
private Integer templateId;
/**
* 前端用户ID
*/
private Integer userId;
/**
* 优惠券码
*/
private String couponCode;
/**
* 优惠券分发时间
*/
private Date assignDate;
/**
* 优惠券状态
*/
private Integer status;
// 在构造方法中 把固定的属性直接赋值
public TUsercoupon(Integer templateId, Integer userId, String couponCode) {
this.templateId = templateId;
this.userId = userId;
this.couponCode = couponCode;
this.assignDate=new Date();
this.status= SystemConfig.USER_COUPON_NO;
}
}
数据传输对象DTO
// 优惠券模板对象
@Data
public class CouponAddDto {
private String name;
private String logo;
private String intro;
private Integer category;
private Integer scope;
private Integer scopeId;
// @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date expireTime;
private Integer couponCount;
private Integer target;
private Integer targetLevel;
private Integer sendType;
// @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date startTime;
// @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date endTime;
private double limitmoney;
private double discount;
}
// 用户优惠券对象
@Data
public class UserCouponDto {
private Integer id;//用户优惠券id
private Integer tid;//用户优惠券模板id
private Integer uid;//用户id
private String couponCode;//用户优惠券券码
private Integer status;//用户优惠券id
private Date startTime;//优惠券生效起始时间
private Date endTime;//优惠券生效结束时间
private double limitmoney;//要求,满减
private double discount;//减免的金额或者打折
private Integer category;//优惠券减免额类型:种类: 51-满减;52-折扣;53-立减
private String name;//优惠券名称
private String logo;//优惠券显示的logo
}
// 优惠券审核对象
@Data
public class CouponAuditDto {
// 优惠券模板ID
private Integer id;
// 创建人ID 后台内部员工
private Integer aid;
// 优惠券状态
private Integer flag;
// 审核意见
private String info;
}
3.3 优惠券模板状态
/**
* 优惠券模板审核状态
* 枚举类 不需要set方法 枚举的几个值是固定
* 需要通过构造方法 设置枚举的几个值
*/
public enum CouponAudit {
未审核(41),审核通过(42),审核拒绝(43);
CouponAudit(int code) {
this.code = code;
}
private int code;
public int getCode() {
return code;
}
}
3.4 系统字典
系统配置常量
// 系统字典
public interface SystemConfig {
//自定义uid的消息头
String HEADER_UID="cp_uid";
//优惠券的发送类型 81:用户优惠券 82:系统优惠券
int COUPON_SEND_USER=81;
int COUPON_SEND_SYSTEM=82;
//优惠券作用的人群:71-全体;72-会员等级 73-新用户 74-收费会员
int COUPON_TARGET_ALL=71;
int COUPON_TARGET_LEVEL=72;
int COUPON_TARGET_NEW=73;
int COUPON_TARGET_PLUS=74;
//批量新增用户优惠券数据的数量 线程池的任务处理的数量
int THREAD_COUPON_BATCH=1000;
//优惠券的使用状态 91-未使用 92-已使用 93-无效
int USER_COUPON_NO=91;
int USER_COUPON_USED=92;
int USER_COUPON_DEAD=93;
}
MQ消息配置常量
public interface RabbitMQConstConfig {
//队列名
String Q_USERINIT="cp-userinit";
String Q_USERSCORE="cp-userscore";
String Q_COUPONSYS="cp-couponsys";
String Q_COUPONUSE="cp-couponuse";
String Q_ORDER_SYNC="cp-ordersync";//订单同步:redis-mysql
String Q_ORDER_DEAD="cp-orderdead";//订单死信消息
String Q_ORDER_TIMEOUT="cp-ordertimeout";//订单延迟
String Q_SKILLORDER_SYNC="cp-skillordersync";//秒杀订单同步:redis-mysql
String Q_SKILLORDER_DEAD="cp-skillorderdead";//秒杀订单死信消息
String Q_SKILLORDER_TIMEOUT="cp-skillordertimeout";//秒杀订单延迟
//交换器名
String EX_USERADD="ex-d-useradd";
String EX_COUPONTEM="ex-d-coupontem";
String EX_ORDERADD="ex-f-orderadd";//转发下单数据
String EX_DEAD="ex-d-dead";//死信交换器
String EX_SKILLORDERADD="ex-f-skillorderadd";//转发秒杀下单数据
//路由关键字
String RK_USERADD="rk-useradd";
// 路由key
String RK_COUPONSYS="rk-coupon-sys";
String RK_COUPONUSE="rk-coupon-use";
String RK_DEAD_ORDERTO="rk-order-timeout";
String RK_DEAD_SKILLORDERTO="rk-skillorder-timeout";
//MQ消息类型
int MQTYPE_USERADD=1;
int MQTYPE_USERSIGN=2;
int MQTYPE_USERLOFIN=3;
int MQTYPE_COUPONSYS=4;//优惠券模板 系统发放
int MQTYPE_COUPONUSE=5;//优惠券模板 用户领取
int MQTYPE_ORDERADD=6;//下单
int MQTYPE_SKILLORDERADD=8;//秒杀下单
int MQTYPE_ORDERSYNC=7;//订单同步
int MQTYPE_ORDERCOMMENT=9;//订单评价
}
redis的key的常量
// 记录所有的key
public interface RedisKeyConfig {
//存储优惠券信息
//追加优惠券模板id,值存储数量,有效期为优惠券模板的领取的结束时间
//public static final String COUPON_CACHE="cp:soupon:";//追加:模板id:等级id
String COUPON_CACHE="cp:coupon:";//追加:模板id List类型,第一个元素:数量 第二个元素:等级要求
//存储用户领过的优惠券,用来解决 用户领取优惠券的限领的问题,有效期:优惠券模板的领取的时间
String COUPON_USERS="cp:coupon:users:";//追加模板id,Set类型 值记录uid,有效期
//设置分布式锁的key,优惠券的领取 防止超领
String COUPON_LOCK="cp:coupon:rl:";//追加模板id
int COUPON_LOCK_TIME=10;//10秒
}
3.5 MQ消息对象
@Data
@NoArgsConstructor
@AllArgsConstructor
public class MqMsgBo implements Serializable {
private long id;//唯一id,防止消息重复,雪花算法
private int type;//类型
private Object data;//消息内容
}
3.6 雪花算法的工具类
使用单例模式的工具类,获取全局唯一的ID:
SnowFlowUtil.getInstance().nextId()
public class SnowFlowUtil {
private SnowFlowUtil() {
}
private static class SnowFlowIodh {
public static SnowFlowUtil obj=new SnowFlowUtil();
}
public static SnowFlowUtil getInstance(){
return SnowFlowIodh.obj;
}
//因为二进制里第一个 bit 为如果是 1,那么都是负数,但是我们生成的 id 都是正数,所以第一个 bit 统一都是 0。
//机器ID 2进制5位 32位减掉1位 31个
private long workerId;
//机房ID 2进制5位 32位减掉1位 31个
private long datacenterId;
//代表一毫秒内生成的多个id的最新序号 12位 4096 -1 = 4095 个
private long sequence;
//设置一个时间初始值 2^41 - 1 差不多可以用69年
private long twepoch = 1585644268888L;
//5位的机器id
private long workerIdBits = 5L;
//5位的机房id;。‘
private long datacenterIdBits = 5L;
//每毫秒内产生的id数 2 的 12次方
private long sequenceBits = 12L;
// 这个是二进制运算,就是5 bit最多只能有31个数字,也就是说机器id最多只能是32以内
private long maxWorkerId = -1L ^ (-1L << workerIdBits);
// 这个是一个意思,就是5 bit最多只能有31个数字,机房id最多只能是32以内
private long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
private long workerIdShift = sequenceBits;
private long datacenterIdShift = sequenceBits + workerIdBits;
private long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
// -1L 二进制就是1111 1111 为什么?
// -1 左移12位就是 1111 1111 0000 0000 0000 0000
// 异或 相同为0 ,不同为1
// 1111 1111 0000 0000 0000 0000
// ^
// 1111 1111 1111 1111 1111 1111
// 0000 0000 1111 1111 1111 1111 换算成10进制就是4095
private long sequenceMask = -1L ^ (-1L << sequenceBits);
//记录产生时间毫秒数,判断是否是同1毫秒
private long lastTimestamp = -1L;
public long getWorkerId(){
return workerId;
}
public long getDatacenterId() {
return datacenterId;
}
public long getTimestamp() {
return System.currentTimeMillis();
}
// 这个是核心方法,通过调用nextId()方法,
// 让当前这台机器上的snowflake算法程序生成一个全局唯一的id
public synchronized long nextId() {
// 这儿就是获取当前时间戳,单位是毫秒
long timestamp = timeGen();
// 判断是否小于上次时间戳,如果小于的话,就抛出异常
if (timestamp < lastTimestamp) {
System.err.printf("clock is moving backwards. Rejecting requests until %d.", lastTimestamp);
throw new RuntimeException(
String.format("Clock moved backwards. Refusing to generate id for %d milliseconds",
lastTimestamp - timestamp));
}
// 下面是说假设在同一个毫秒内,又发送了一个请求生成一个id
// 这个时候就得把seqence序号给递增1,最多就是4096
if (timestamp == lastTimestamp) {
// 这个意思是说一个毫秒内最多只能有4096个数字,无论你传递多少进来,
//这个位运算保证始终就是在4096这个范围内,避免你自己传递个sequence超过了4096这个范围
sequence = (sequence + 1) & sequenceMask;
//当某一毫秒的时间,产生的id数 超过4095,系统会进入等待,直到下一毫秒,系统继续产生ID
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0;
}
// 这儿记录一下最近一次生成id的时间戳,单位是毫秒
lastTimestamp = timestamp;
// 这儿就是最核心的二进制位运算操作,生成一个64bit的id
// 先将当前时间戳左移,放到41 bit那儿;将机房id左移放到5 bit那儿;将机器id左移放到5 bit那儿;将序号放最后12 bit
// 最后拼接起来成一个64 bit的二进制数字,转换成10进制就是个long型
return ((timestamp - twepoch) << timestampLeftShift) |
(datacenterId << datacenterIdShift) |
(workerId << workerIdShift) | sequence;
}
/**
* 当某一毫秒的时间,产生的id数 超过4095,系统会进入等待,直到下一毫秒,系统继续产生ID
* @param lastTimestamp
* @return
*/
private long tilNextMillis(long lastTimestamp) {
long timestamp = timeGen();
while (timestamp <= lastTimestamp) {
timestamp = timeGen();
}
return timestamp;
}
//获取当前时间戳
private long timeGen(){
return System.currentTimeMillis();
}
}
雪花算法的原理就是生成一个的 64 位比特位的 long 类型的唯一 id。此算法与系统时间和机器码有关
最高 1 位固定值 0,因为生成的 id 是正整数,如果是 1 就是负数了。
接下来 41 位存储毫秒级时间戳,2^41/(1000606024365)=69,大概可以使用 69 年。
再接下 10 位存储机器码,包括 5 位 datacenterId 和 5 位 workerId。最多可以部署 2^10=1024 台机器。
最后 12 位存储序列号。同一毫秒时间戳时,通过这个递增的序列号来区分。即对于同一台机器而言,同一毫秒时间戳下,可以生成 2^12=4096 个不重复 id。
可以将雪花算法作为一个单独的服务进行部署,然后需要全局唯一 id 的系统,请求雪花算法服务获取 id 即可。
————————————————
SnowFlake 中文意思为雪花,故称为雪花算法。最早是 Twitter 公司在其内部用于分布式环境下生成唯一 ID。在2014年开源 scala 语言版本。
现在的服务基本是分布式、微服务形式的,而且大数据量也导致分库分表的产生,对于水平分表就需要保证表中 id 的全局唯一性。
对于 MySQL 而言,一个表中的主键 id 一般使用自增的方式,但是如果进行水平分表之后,多个表中会生成重复的 id 值。那么如何保证水平分表后的多张表中的 id 是全局唯一性的呢?
如果还是借助数据库主键自增的形式,那么可以让不同表初始化一个不同的初始值,然后按指定的步长进行自增。例如有3张拆分表,初始主键值为1,2,3,自增步长为3。
当然也有人使用 UUID 来作为主键,但是 UUID 生成的是一个无序的字符串,对于 MySQL 推荐使用增长的数值类型值作为主键来说不适合。
也可以使用 Redis 的自增原子性来生成唯一 id,但是这种方式业内比较少用。
当然还有其他解决方案,不同互联网公司也有自己内部的实现方案。雪花算法是其中一个用于解决分布式 id 的高效方案,也是许多互联网公司在推荐使用的。
————————————————算法优缺点
雪花算法有以下几个优点:高并发分布式环境下生成不重复 id,每秒可生成百万个不重复 id。
基于时间戳,以及同一时间戳下序列号自增,基本保证 id 有序递增。
不依赖第三方库或者中间件。
算法简单,在内存中进行,效率高。
雪花算法有如下缺点:依赖服务器时间,服务器时钟回拨时可能会生成重复 id。算法中可通过记录最后一个生成 id 时的时间戳来解决,每次生成 id 之前比较当前服务器时钟是否被回拨,避免生成重复 id。
3.7 单例模式的线程池对象
/**
* @Description: 单例模式 封装线程池
* @Author: zed
* @Date: 2022/5/5 11:37
*/
public class ThreadPoolSignle {
//单例模式-IoDH实现
public ThreadPoolExecutor poolExecutor;
private ThreadPoolSignle(){
//七大参数
poolExecutor=new ThreadPoolExecutor(4,20,3, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(20),new DefaultManagedAwareThreadFactory(),new ThreadPoolExecutor.AbortPolicy());
}
private static class PoolSingle{
private static ThreadPoolSignle signle=new ThreadPoolSignle();
}
public static ThreadPoolSignle getInstance(){
return PoolSingle.signle;
}
}
3.8 时间日期的工具类
public class DateUtil {
/**
* 比较日期是否为同一天*/
public static boolean eqDate(Date d1,Date d2){
SimpleDateFormat sdf=new SimpleDateFormat("yyyy-MM-dd");
return sdf.format(d1).equals(sdf.format(d2));
}
/**
* 计算2个日期相差的天数*/
public static int diffDays(Date start,Date end){
Calendar cal1 = Calendar.getInstance();
cal1.setTime(start);
Calendar cal2 = Calendar.getInstance();
cal2.setTime(end);
int day1= cal1.get(Calendar.DAY_OF_YEAR);
int day2 = cal2.get(Calendar.DAY_OF_YEAR);
int year1 = cal1.get(Calendar.YEAR);
int year2 = cal2.get(Calendar.YEAR);
if(year1 != year2) //同一年
{
int timeDistance = 0 ;
for(int i = year1 ; i < year2 ; i ++)
{
if(i%4==0 && i%100!=0 || i%400==0) //闰年
{
timeDistance += 366;
}
else //不是闰年
{
timeDistance += 365;
}
}
return timeDistance + (day2-day1) ;
}
else //不同年
{
return day2-day1;
}
}
/**
* 获取今日剩余的秒数*/
public static long lastSeconds(){
SimpleDateFormat sdf1=new SimpleDateFormat("yyyy-MM-dd");
Date date=new Date();
SimpleDateFormat sdf=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
try {
Date date1=sdf.parse(sdf1.format(date)+" 23:59:59");
return (date1.getTime()-new Date().getTime())/1000;
} catch (ParseException e) {
e.printStackTrace();
}
return 0;
}
/**
* 获取今日剩余的秒数*/
public static long lastSeconds(Date date){
return (date.getTime()-new Date().getTime())/1000;
}
}
涉及技术栈:
Redis(1.缓存 2.分布式锁 3.倒计时)
RabbitMQ(1.削峰填谷,异步)
线程池(1.批处理,解决系统发放优惠券)
四、实现
基于微服务实现,优惠券服务
4.1 优惠券模板接口
1.新增优惠券活动接口 save
2.审核优惠券活动接口 audit
3.查询优惠券活动接口
优惠券模板控制器接口
package org.qf.cloudcoupon.controller;
import lombok.RequiredArgsConstructor;
import org.qf.cloudcoupon.entity.TCouponTemplate;
import org.qf.cloudcoupon.service.CouponTemplateService;
import org.qf.cloudentity.entity.Response;
import org.springframework.web.bind.annotation.*;
/**
* @author zed
* @date 2022/9/26
* 优惠券模板控制器
*/
@RestController
@RequestMapping("couponTemplate")
@RequiredArgsConstructor
public class CouponTemController {
private final CouponTemplateService couponTemplateService;
// 新增优惠券模板
@PostMapping("save")
public Response save(@RequestBody TCouponTemplate couponTemplate) {
return couponTemplateService.save(couponTemplate);
}
// 分页查看优惠券列表
@GetMapping("queryAll")
public Response queryAll(@RequestParam(value = "pageIndex", defaultValue = "1") Integer pageIndex,
@RequestParam(value = "pageSize", defaultValue = "10") Integer pageSize) {
return couponTemplateService.queryAll(pageIndex,pageSize);
}
// 优惠券审核接口
@PostMapping("audit")
public Response audit(@RequestBody CouponAuditDto dto) {
return couponTemplateService.audit(dto);
}
}
用户优惠券接口
@RestController
@RequestMapping("userCoupon")
@RequiredArgsConstructor
public class UserCouponController {
private final UserCouponService service;
// 根据优惠券状态查询,用户Id从请求头中获取
@GetMapping("query")
public Response query(@RequestParam int status, HttpServletRequest request){
return service.queryMy(request.getIntHeader(SystemConfig.HEADER_UID),status);
}
// 根据ID查询用户优惠券
@GetMapping("detail")
public Response<UserCouponDto> detail(@RequestParam int id){
return service.queryId(id);
}
// 用户领取优惠券
// ul:用户等级 ctid:优惠券模板ID
@PostMapping("save")
public Response save(@RequestParam int ul, @RequestParam int ctid,HttpServletRequest request){
return service.save(request.getIntHeader(SystemConfig.HEADER_UID),ul,ctid);
}
// 更新优惠券状态
@GetMapping("update")
public Response update(@RequestParam int id,@RequestParam int flag){
return service.update(id, flag);
}
}
4.2业务层核心代码
新增优惠券
DAO层代码省略. 可以代码生成或手写
package org.qf.cloudcoupon.service.impl;
import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.qf.cloudcommon.util.RedissionUtil;
import org.qf.cloudcommon.util.SnowFlowUtil;
import org.qf.cloudcoupon.config.CouponAudit;
import org.qf.cloudcoupon.config.RabbitMQConstConfig;
import org.qf.cloudcoupon.config.RedisKeyConfig;
import org.qf.cloudcoupon.config.SystemConfig;
import org.qf.cloudcoupon.dao.TCouponTemplateDao;
import org.qf.cloudcoupon.dao.TUsercouponDao;
import org.qf.cloudcoupon.dto.CouponAuditDto;
import org.qf.cloudcoupon.dto.MqMsgBo;
import org.qf.cloudcoupon.entity.TCouponTemplate;
import org.qf.cloudcoupon.entity.TUsercoupon;
import org.qf.cloudcoupon.service.CouponTemplateService;
import org.qf.cloudentity.entity.Response;
import org.redisson.api.RLock;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* @author zed
* @date 2022/9/26
* 优惠券模板业务实现
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class CouponTemplateServiceImpl implements CouponTemplateService {
private final TCouponTemplateDao couponTemplateDao;
private final RabbitTemplate rabbitTemplate;
private final TUsercouponDao usercouponDao;
@Override
public Response<PageInfo<TCouponTemplate>> queryAll(Integer pageIndex, Integer pageSize) {
// 在slf4j字串中使用占位符 {}
log.info("查询的是第{},每页{}条数据", pageIndex, pageSize);
PageHelper.startPage(pageIndex, pageSize);
List<TCouponTemplate> list = couponTemplateDao.queryAll();
PageInfo<TCouponTemplate> pageInfo = new PageInfo<>(list);
return Response.ok(pageInfo);
}
@Override
@Transactional
public Response save(TCouponTemplate couponTemplate) {
// 设置优惠券的状态是:41.未审核
couponTemplate.setFlag(CouponAudit.未审核.getCode());
// 优惠券创建时间
couponTemplate.setCreateTime(new Date());
// 优惠券模板的识别码(有一定的识别度)
couponTemplate.setTemplateKey(UUID.randomUUID().toString());
if (couponTemplateDao.insert(couponTemplate) > 0) {
log.info("新增优惠券模板成功");
return Response.ok();
}
return Response.fail("新增优惠券活动失败");
}
// 审核优惠券,审核通过后,可以直接发送优惠券,也可以使用异步发送MQ
@Override
@Transactional
public Response audit(CouponAuditDto dto) {
// 1、查询优惠券模板
TCouponTemplate template = couponTemplateDao.queryById(dto.getId());
// 2、查看优惠券模板的状态
if (Objects.nonNull(template) && template.getFlag() == CouponAudit.未审核.getCode()) {
// 3、更新优惠券
template.setFlag(dto.getFlag());
template.setUserId(dto.getAid());
template.setUserAudit(dto.getInfo());
if (couponTemplateDao.update(template) > 0) {
// 审核操作成功
// 4、验证优惠券活动是否审核成功
if (dto.getFlag() == CouponAudit.审核通过.getCode()) {
// 发送优惠券,直接发送操作数据库也可以 但是需要操作数据库耗时较长
// 可以使用异步发送优惠券 把优惠券对象放入MQ中 在MQ的消费者中操作数据库
// 5、构建一个MQ的消息对象
MqMsgBo msgBo = new MqMsgBo();
msgBo.setId(SnowFlowUtil.getInstance().nextId());
// 设置优惠券类型需要根据模板中的类型来指定
// sendType 发放类型:81.用户领取 82.系统发放
msgBo.setData(template);
// MQ消息的路由key
String rk = "";
if (template.getSendType() == SystemConfig.COUPON_SEND_SYSTEM) {
// 系统发放的优惠券
msgBo.setType(RabbitMQConstConfig.MQTYPE_COUPONSYS);
rk = RabbitMQConstConfig.RK_COUPONSYS;
} else if (template.getSendType() == SystemConfig.COUPON_SEND_USER) {
// 用户领取的优惠券
msgBo.setType(RabbitMQConstConfig.MQTYPE_COUPONUSE);
rk = RabbitMQConstConfig.RK_COUPONUSE;
}
// 通过MQ发送消息
if (StringUtils.hasLength(rk)) {
rabbitTemplate.convertAndSend(RabbitMQConstConfig.EX_COUPONTEM, rk, msgBo);
}
return Response.ok();
}
}
}
return Response.fail("亲,不是合法的操作!");
}
}
审核优惠券活动
因为要使用到MQ发送消息,所以需要
- 启动MQ服务
在虚拟机中启动RabbitMQ
- 添加AMQP依赖
- 增加MQ配置
maven新增依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
配置文件新增 5672:内部通讯端口 15672:WEB页面端口
spring:
rabbitmq:
addresses: 192.168.29.110:5672
配置优惠券相关的MQ的队列&交换机&路由key及绑定关系
package org.qf.cloudcoupon.config;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.DirectExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author zed
* @date 2022/9/27
* RabbitMQ的配置类
* 指定交换机/路由key和队列的绑定关系
*/
@Configuration
public class RabbitMQConfig {
// 系统优惠券的队列
@Bean
public Queue createSys(){
return new Queue(RabbitMQConstConfig.Q_COUPONSYS);
}
// 用户优惠券的队列
@Bean
public Queue createUser(){
return new Queue(RabbitMQConstConfig.Q_COUPONUSE);
}
// 优惠券模板专用的交换机
@Bean
public DirectExchange directExchange(){
return new DirectExchange(RabbitMQConstConfig.EX_COUPONTEM);
}
// 系统优惠券队列的绑定
@Bean
public Binding bindSys(DirectExchange e){
return BindingBuilder.bind(createSys()).to(e).with(RabbitMQConstConfig.RK_COUPONSYS);
}
// 用户优惠券队列的绑定
@Bean
public Binding bindUser(DirectExchange e){
return BindingBuilder.bind(createUser()).to(e).with(RabbitMQConstConfig.RK_COUPONUSE);
}
}
难点在于MQ发送优惠券对象后,在MQ的消费端真正发放优惠券,消费端异步发送给用户优惠券
// 审核优惠券,审核通过后,可以直接发送优惠券,也可以使用异步发送MQ
@Override
@Transactional
public Response audit(CouponAuditDto dto) {
// 1、查询优惠券模板
TCouponTemplate template = couponTemplateDao.queryById(dto.getId());
// 2、查看优惠券模板的状态
if (Objects.nonNull(template) && template.getFlag() == CouponAudit.未审核.getCode()) {
// 3、更新优惠券
template.setFlag(dto.getFlag());
template.setUserId(dto.getAid());
template.setUserAudit(dto.getInfo());
if (couponTemplateDao.update(template) > 0) {
// 审核操作成功
// 4、验证优惠券活动是否审核成功
if (dto.getFlag() == CouponAudit.审核通过.getCode()) {
// 发送优惠券,直接发送操作数据库也可以 但是需要操作数据库耗时较长
// 可以使用异步发送优惠券 把优惠券对象放入MQ中 在MQ的消费者中操作数据库
// 5、构建一个MQ的消息对象
MqMsgBo msgBo = new MqMsgBo();
msgBo.setId(SnowFlowUtil.getInstance().nextId());
// 设置优惠券类型需要根据模板中的类型来指定
// sendType 发放类型:81.用户领取 82.系统发放
msgBo.setData(template);
// MQ消息的路由key
String rk = "";
if (template.getSendType() == SystemConfig.COUPON_SEND_SYSTEM) {
// 系统发放的优惠券
msgBo.setType(RabbitMQConstConfig.MQTYPE_COUPONSYS);
rk = RabbitMQConstConfig.RK_COUPONSYS;
} else if (template.getSendType() == SystemConfig.COUPON_SEND_USER) {
// 用户领取的优惠券
msgBo.setType(RabbitMQConstConfig.MQTYPE_COUPONUSE);
rk = RabbitMQConstConfig.RK_COUPONUSE;
}
// 通过MQ发送消息
if (StringUtils.hasLength(rk)) {
rabbitTemplate.convertAndSend(RabbitMQConstConfig.EX_COUPONTEM, rk, msgBo);
}
return Response.ok();
}
}
}
return Response.fail("亲,不是合法的操作!");
}
基于MQ异步实现优惠券系统派发:线程池处理业务
系统发送的优惠券:
1、如果是给全体所有用户发放的,就获取所有用户的ID
2、如果是给某个级别用户发送的优惠券,就获取这个等级的所有用户的ID
然后给这些人发送对应的优惠券!本质上就是到用户优惠券表中批量新增记录!
package org.qf.cloudcoupon.listener;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.qf.cloudcommon.util.SnowFlowUtil;
import org.qf.cloudcommon.util.ThreadPoolSignle;
import org.qf.cloudcoupon.config.RabbitMQConstConfig;
import org.qf.cloudcoupon.config.SystemConfig;
import org.qf.cloudcoupon.dao.TUsercouponDao;
import org.qf.cloudcoupon.dto.MqMsgBo;
import org.qf.cloudcoupon.entity.TCouponTemplate;
import org.qf.cloudcoupon.entity.TUsercoupon;
import org.springframework.amqp.rabbit.annotation.RabbitHandler;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* @author zed
* @date 2022/9/27
* 系统级别MQ的监听器-消费者
*/
@Slf4j
@Component
@RequiredArgsConstructor
@RabbitListener(queues = RabbitMQConstConfig.Q_COUPONSYS)
public class CouponTemSystemListener {
private final TUsercouponDao usercouponDao;
@RabbitHandler
public void handler(MqMsgBo msgBo) {
// 1、判断消息类型 如果是系统级别的优惠券模板 就可以处理
if (msgBo.getType() == RabbitMQConstConfig.MQTYPE_COUPONSYS) {
TCouponTemplate template = (TCouponTemplate) msgBo.getData();
// 2、发送给哪些用户的优惠券
Integer target = template.getTarget();
if (target == SystemConfig.COUPON_TARGET_ALL || target == SystemConfig.COUPON_TARGET_LEVEL) {
// 3、根据用户级别 查询这个级别下的所有用户的ID
List<Integer> uids = levels(target);
// 4、给这些用户发送优惠券 使用线程池优化操作 分片操作
// 5、分片处理
int batchs = uids.size() / SystemConfig.THREAD_COUPON_BATCH;
batchs = uids.size() % SystemConfig.THREAD_COUPON_BATCH == 0 ? batchs : batchs + 1;
for (int i = 0; i < batchs; i++) {
int start = i * SystemConfig.THREAD_COUPON_BATCH;
// 6、使用线程池处理
ThreadPoolSignle.getInstance().poolExecutor.execute(() -> {
// 每一个线程要执行的任务
List<TUsercoupon> entities = new ArrayList<>();
// 7、使用stream流优化
List<Integer> list = uids.stream().skip(start).limit(SystemConfig.THREAD_COUPON_BATCH).collect(Collectors.toList());
list.forEach(uid->{
TUsercoupon usercoupon = new TUsercoupon(template.getId(), uid, "cp-sys-" + SnowFlowUtil.getInstance().nextId());
entities.add(usercoupon);
});
// 批量新增
usercouponDao.insertBatch(entities);
});
}
}
}
}
// 模拟获取某个等级的所有用户的ID
private List<Integer> levels(Integer level) {
List<Integer> uids = new ArrayList<>();
for (int i = 0; i < 5005; i++) {
uids.add(i);
}
return uids;
}
/*public static void main(String[] args) {
List<Integer> uids = new ArrayList<>();
for (int i = 0; i < 5005; i++) {
uids.add(i);
}
List<Integer> list = uids.stream().skip(5000).limit(1000).collect(Collectors.toList());
System.out.println(list);
}*/
}
使用stream流的批处理方式
stream().skip().limit()
/*
* 使用stream流的实现方式
*/
int pages = uids.size() / SystemConfig.THREAD_COUPON_BATCH;
pages = uids.size() % SystemConfig.THREAD_COUPON_BATCH == 0 ? pages : pages + 1;
for (int i = 0; i < pages; i++) {
List<Integer> list = uids.stream().skip(i * SystemConfig.THREAD_COUPON_BATCH).limit(SystemConfig.THREAD_COUPON_BATCH).collect(Collectors.toList());
System.out.println(list);
}
基于Redis异步实现用户级别优惠券派发:用户级别优惠券放入缓存
package org.qf.cloudcoupon.listener;
import lombok.extern.slf4j.Slf4j;
import org.qf.cloudcommon.util.DateUtil;
import org.qf.cloudcommon.util.RedissionUtil;
import org.qf.cloudcoupon.config.RabbitMQConstConfig;
import org.qf.cloudcoupon.config.RedisKeyConfig;
import org.qf.cloudcoupon.dto.MqMsgBo;
import org.qf.cloudcoupon.entity.TCouponTemplate;
import org.springframework.amqp.rabbit.annotation.RabbitHandler;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
/**
* @author zed
* @date 2022/9/27
*/
@Slf4j
@Component
@RabbitListener(queues = RabbitMQConstConfig.Q_COUPONUSE)
public class CouponTemUserListener {
@RabbitHandler
public void handler(MqMsgBo msgBo) {
// 判断优惠券的类型是用户级别的优惠券
if (msgBo.getType() == RabbitMQConstConfig.MQTYPE_COUPONUSE) {
// 发送优惠券,把优惠券放入缓存中
TCouponTemplate template = (TCouponTemplate) msgBo.getData();
Integer templateId = template.getId();
// 放入缓存中的redis的key
String key = RedisKeyConfig.COUPON_CACHE + templateId;
RedissionUtil.setList(key, template.getCouponCount());
RedissionUtil.setList(key, template.getTargetLevel());
// 设置优惠券模板key的过期时间 领取的过期时间
RedissionUtil.expire(key, DateUtil.lastSeconds(template.getExpireTime()));
log.info("用户优惠券发放成功!");
}
}
}
总结:
系统级优惠券,无需用户领取自动发送
用户级别的优惠券,需要用户手动点击进行领取
五、测试
测试用例:
新增优惠券模板:
{
"category": 52,
"couponCount": 1000,
"discount": 50,
"endTime": "2022-05-19 23:59:59",
"expireTime": "2022-05-17 23:59:59",
"intro": "测试系统发送优惠券",
"limitmoney": 100,
"logo": "",
"name": "测试方便面满100减50元",
"scope": 63,
"scopeId": 0,
"sendType": 82,
"startTime": "2022-05-18 00:00:00",
"target": 71,
"targetLevel": 0
}
{
"category": 53,
"couponCount": 10,
"discount": 50,
"endTime": "2022-05-20T10:08:55.000+08:00",
"expireTime": "2022-05-18T10:08:55.000+08:00",
"intro": "测试系统发送优惠券",
"limitmoney": 100,
"logo": "",
"name": "水饺立减50元",
"scope": 63,
"scopeId": 0,
"sendType": 81,
"startTime": "2022-05-18T10:08:55.000+08:00",
"target": 71,
"targetLevel": 0
}
六、领取
领取优惠券
单机锁:synchronized和Lock
synchronized:关键字底层Object 监视器 ,进入的时候验证Object 不存在 加1 ,执行完减1,每次执行完都会校验计数器是否为0,为0,释放锁,在锁上等待的线程会进入就绪状态
用法:3种,锁的范围(粒度)
修饰静态方法--Class对象
修饰实例方法--this
修饰代码块--对象变量
可重入:同一个线程对同一把锁,可以使用多次
公平锁:所有线程,按次序获取锁,排队
非公平锁:抢占、竞争
Lock:接口,实现类:ReentrantLock,底层:AQS(AbstractQueuedSynchronizer)+CAS算法(ABA问题)
//领取优惠券 ?难点-超领
@Override
public Response save(int uid, int ul, int ctid) {
//1.验证优惠券是否领取结束
if (RedissionUtil.checkKey(RedisKeyConfig.COUPON_CACHE + ctid)) {
//2.校验用户是否领过该优惠券--查询数据库,查询Redis
boolean r = true; // 该用户是否领取过
boolean istime = false;
if (RedissionUtil.checkKey(RedisKeyConfig.COUPON_USERS + ctid)) {
r = !RedissionUtil.exists(RedisKeyConfig.COUPON_USERS + ctid, uid);
} else {
istime = true;//第一次有人领取这个模板的优惠券
}
if (r) {//没有领取过
//3.验证数量是否足够
int count = (int) RedissionUtil.getList(RedisKeyConfig.COUPON_CACHE + ctid, 0);
//获取分布式锁的对象
RLock rLock = RedissionUtil.getLock(RedisKeyConfig.COUPON_LOCK + ctid);
try {
//尝试添加分布式锁
if (rLock.tryLock(RedisKeyConfig.COUPON_LOCK_TIME, TimeUnit.SECONDS)) {
if (count > 0) {
//4.校验领取是否有用户等级的限制
int level = (int) RedissionUtil.getList(RedisKeyConfig.COUPON_CACHE + ctid, 1);
if (level > 0) {
//改优惠券的领取,要求用户等级,关于等级:1.前端直接传递 2.远程服务调用
//5.查询用户等级,校验是否满足规则
if (ul < level) {
return Response.fail("亲,你不满足领取的资格!");
}
}
//6.生成用户优惠券信息
TUsercoupon coupon = new TUsercoupon(ctid, uid, "user_" + SnowFlowUtil.getInstance().nextId());
//7.新增优惠券到数据库
if (usercouponDao.insert(coupon) > 0) {
//更改数量 index 0:表示List中的第一个数据
RedissionUtil.setList(RedisKeyConfig.COUPON_CACHE + ctid, 0, count - 1);
//记录当前用户已经领取
RedissionUtil.setSet(RedisKeyConfig.COUPON_USERS + ctid, uid + "");
//设置 有效期,优惠券模板的剩余时间
if (istime) {
RedissionUtil.expire(RedisKeyConfig.COUPON_USERS + ctid, RedissionUtil.ttl(RedisKeyConfig.COUPON_CACHE + ctid));
}
//返回结果
return Response.ok();
} else {
return Response.fail("系统故障,领取失败!");
}
} else {
return Response.fail("亲,优惠券已被领完!");
}
}
} catch (InterruptedException e) {
e.printStackTrace();
return Response.fail("系统故障,领取失败!");
} finally {
rLock.unlock();//释放锁
}
} else {
//领取过
return Response.fail("亲,你已经领取过了!");
}
} else {
return Response.fail("亲,活动已结束!");
}
return Response.fail("系统故障,领取失败!");
}
分布式锁:解决集群下,同一资源排队访问
如果我们的项目是集群部署(多态服务器)下,防止线程安全,可以使用分布式锁
推荐使用:Redssion的RedLock
RedLock:Redis Distributed Lock;即使用redis实现的分布式锁
算法实现了多redis实例的情况,相对于单redis节点来说,优点在于防止了最低保证分布式锁的有效性及安全性的要求如下:
1.互斥;任何时刻只能有一个client获取锁
2.释放死锁;即使锁定资源的服务崩溃或者分区,仍然能释放锁
3.容错性;只要多数redis节点(一半以上)在使用,client就可以获取和释放锁单节点故障造成整个服务停止运行的情况;并且在多节点中锁的设计,及多节点同时崩溃等各种意外情况有自己独特的设计方法
七、测试
测试领取优惠券
用户优惠券控制器
// 根据ID查看优惠券详情
@GetMapping("detail")
public Response<UserCouponDto> detail(@RequestParam int id){
return service.queryId(id);
}
// 用户领取优惠券
@PostMapping("save")
public Response save(@RequestParam int ul,
@RequestParam int ctid,
HttpServletRequest request){
return service.save(request.getIntHeader(SystemConfig.HEADER_UID),ul,ctid));
}