同学们,之前几天我们实现的都是学习辅助功能。学习辅助的目的是提升用户的学习体验,维护好老用户。而一个网站要想长久发展,除了要服务好老用户,还必须能吸引新用户,也就是拉新。而拉新最常见的手段就是优惠促销,比如优惠券。
从今天开始,我们就一起来实现一个优惠券管理的服务。优惠券功能比较复杂,包含的业务亮点也非常多,例如:
- 优惠券的优惠策略设计
- 优惠券的兑换码算法
- 优惠券领取的并发安全问题及解决方案
- 优惠券叠加方案的智能推荐
- 多商品、多券叠加时的优惠金额计算
- 多商品订单退款的拆单和退券问题
等等。。
这些方案不仅仅是在咱们项目,在所有电商类型的项目中都是热点、难点问题,在接下来几天的学习中我们会逐一分析和解决。
1.需求分析
需求分析的流程与以往类似,还是基于产品原型,三步走:
- 分析业务流程
- 统计业务接口
- 设计数据库表
1.1.业务流程梳理
优惠券包括两大部分功能:
- 优惠券管理和发放(管理端)
- 优惠券的领取和使用(用户端)
我们今天先实现管理端的功能。在后台管理营销中心的优惠券管理页面,可以看到一个优惠券列表页:
我们可以在这里实现优惠券的基础的增删改查功能。
不过,新增的优惠券并不会立刻出现在用户端页面,管理员还需要对优惠券信息做审核,审核通过后则可以通过发放按钮来发布优惠券。
而优惠券的发布也有两种不同的方式:
一个是立刻发放,一个是定时发放。
顾明思议,立刻发放就是优惠券立刻生效,会直接出现在用户端页面供用户领取。定时发放则需要指定一个发放开始时间,时间到期后才会进入出现在用户端页面。
而且无论是哪种发放方式,都需要指定一个过期时间,当优惠券过期后就会进入已结束状态,用户端页面无法领取。
当然,除了过期导致的结束发放以外,管理员也可以手动点击暂停发放:
也可以在需要的时候重新发放优惠券。
特别需要注意的是,优惠券的领取方式有两种,来看一下优惠券的新增表单:
领取方式有两种:
- 手动领取:就是展示在用户端页面,由用户自己手动点击领取
- 指定方法:就是兑换码模式,后台给优惠券生成N张兑换码,由管理员发放给指定用户。
这就要求我们在发放优惠券的时候做判断,如果发现是指定发放模式,则需要提前生成兑换码。
综上,优惠券管理的业务流程和优惠券的状态转换如图:
1.2.接口统计
首先,在优惠券的列表页:
页面规范如下:
- 搜索条件
- 优惠类型:天机学堂支持的类型有 1:满减,2:每满减,3:折扣,4:无门槛
- 优惠券状态:包括 1:待发放,2:未开始 3:进行中,4:已结束,5:暂停
- 列表显示
- 默认显示10条
- 默认按照创建时间倒序排序
- 使用/领取/发放:优惠券数量统计,已使用的数量/已领取的数量/总发放数量
- 领用期限:就是券领取的开始和结束时间
可见这个列表就是一个典型的带过滤条件的分页查询。其它增删改查接口都比较简单,不再赘述。
所有接口在页面都一目了然:
- 优惠券的基本管理接口:
- 分页查询优惠券列表
- 新增优惠券
- 编辑优惠券
- 查看优惠券(根据id查询优惠券)
- 删除优惠券
- 优惠券的方法接口:
- 发放优惠券
- 暂停发放优惠券
另外有几个比较隐蔽的接口。一个是发放优惠券时,如果选择的是定时方法,则需要指定发放时间,到期后才发放。这就需要一个定时任务,检索优惠券表,找到发放时间到期的券,完成发放功能。
另一个也是发放券问题,券除了有发放时间,还有过期时间,因此需要一个定时任务,检查券的过期时间,发现到期后需要更新券状态。
以上两个都是定时任务接口:
- 定时发放优惠券
- 定时结束优惠券发放
还有一个是跟兑换码有关。就是在发放优惠券的时候,如果发现优惠券的领取方式是指定发放,则需要生成兑换码。因此页面有一个查询兑换码功能:
当我们点击查看兑换码时,就会进入一个兑换码展示页面:
可以看出来,这是一个有过滤条件的分页查询功能。
综上,优惠券相关接口包括:
编号
接口简述
优惠券管理
1
新增优惠券
2
修改优惠券
3
分页查询优惠券
4
根据id查询优惠券
5
删除优惠券
优惠券发放
1
发放优惠券
2
暂停发放
3
查询兑换码
定时任务
1
定时发放优惠券
2
定时结束优惠券
1.3.表结构设计
通过前面的接口分析,发现接口主要跟两个实体有关:
- 优惠券
- 兑换码
所以,接下来要设计的表就是以上两张表。
1.3.1.优惠券
首先从优惠券的新增表单来分析,表单页面如下:
其中的字段包含:
-
优惠券名称:一个普通字符串
-
使用范围:这里有两种选择:全部课程、指定课程分类,也就是不限定课程、限定课程,可以用布尔类型来表示。不过一旦选定了课程分类,就需要指定真正限定的分类。
此处是允许多选的,也就是说一个优惠券可以限定多个课程分类。而一个分类也可能被不同的券作为限定范围。因此优惠券与限定的分类是多对多关系。需要一张中间表来保存关系。这个以后再说。
-
优惠券类型:包含满减、每满减、满折扣、无门槛四种,例如:
- 满100减15
- 每满100减10
- 满200打8折,不超过50
- 直减20
可以看出来,虽然规则不同,但都可以用以下几部分来表示: - 优惠的门槛:比如满100的100
- 优惠值:比如减15的15、打8折的8
- 优惠上限:比如不超过50
因此,我们完全要表示完整优惠策略就需要四个字段:优惠类型、优惠门槛、优惠值、优惠上限
-
推广方式:手动领取和指定发放
-
发放数量
-
每人限领数量
OK,表单中的字段就这么多。然后再看看分页页码:
与新增页面重复的就不再赘述了,这里多出的一些字段有:
-
已领取数量
-
已使用数量
-
领用期限:也就是优惠券开始发放、结束发放的时间
-
使用期限:用户领取券后的使用期限,有两种方式:
- 固定时间段:需要指定开始时间、结束时间
- 固体天数:指定天数,从用户领取之日起计算
-
优惠券状态
综上,优惠券表结构如下:
– 导出 表 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='优惠券的规则信息';
另外,用来表示优惠券使用范围时,需要一个优惠券与课程分类的中间关系表:
CREATE TABLE IF NOT EXISTS `coupon_scope` (
`id` bigint NOT NULL AUTO_INCREMENT,
`type` tinyint NOT NULL COMMENT '范围限定类型:1-分类,2-课程,等等',
`coupon_id` bigint NOT NULL COMMENT '优惠券id',
`biz_id` bigint NOT NULL COMMENT '优惠券作用范围的业务id,例如分类id、课程id',
PRIMARY KEY (`id`),
KEY `idx_coupon` (`coupon_id`)
) ENGINE=InnoDB AUTO_INCREMENT=19 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='兑换码';
所有的SQL文件都在课前资料中提供了:
1.4.代码生成
首先,在DEV分支的基础上创建一个新的功能分支:
git checkout -b feature-promotions
1.4.1.创建新的模块
优惠券功能属于优惠促销的一部分,在项目中肯定属于独立的功能模块。我们需要创建一个新的module:
选择Maven工程:
然后填写项目信息:
点击Finish,完成模块创建:
1.4.2.基础配置
项目创建完毕后,需要引入依赖,POM文件内容如下:
tjxt
com.tianji
1.0.0
4.0.0
<artifactId>tj-promotion</artifactId>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
</properties>
<dependencies>
<!--auth-sdk-->
<dependency>
<groupId>com.tianji</groupId>
<artifactId>tj-auth-resource-sdk</artifactId>
<version>1.0.0</version>
</dependency>
<!--api-->
<dependency>
<groupId>com.tianji</groupId>
<artifactId>tj-api</artifactId>
<version>1.0.0</version>
</dependency>
<!--web-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--mybatis-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!--Redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--redisson-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
</dependency>
<!--discovery-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!--config-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!--caffeine本地缓存-->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<!--xxl-job-->
<dependency>
<groupId>com.xuxueli</groupId>
<artifactId>xxl-job-core</artifactId>
</dependency>
<!--loadbalancer-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<