天机学堂是一个基于微服务架构的生产级在线教育项目。项目源码:https://gitee.com/yaoyu_turin/tj-exam。
管理端(B端):
演示地址:https://tjxt-admin.itheima.net/#/main/index,账号:12088888888
,密码:888itcast.CN764%...
学生端(C端):
演示地址:https://tjxt-user.itheima.net/#/main/index,账号:12077777777
,密码:888itcast.CN764%...
项目亮点:
技术架构:
微服务项目与传统项目相比,包含项目模块非常多,每个模块都要独立部署,因此在开发模式上有很大差别:
开发人员成倍增多,分组分别开发不同的微服务模块
微服务模块之间有业务关联,需要相互协作
我们开发时,是否能把所有的项目代码都拉取到本地,然后在本地部署运行、开发测试?
大型微服务项目显然不能,原因如下:
我们可能没有其它模块的代码拉取权限
微服务运行环境过于复杂,本地部署成本较高
微服务模块较多,本地计算机性能难以支撑
在开发的不同阶段,往往有不同的测试手段:
单元测试:测试最小的可测试单元
集成测试:验证某个功能接口
组件测试:验证微服务组件
端对端联调:验证整个系统
为了模拟真实开发环境,我们准备了一台虚拟机,在其中安装了各种各样的公共服务和组件。
虚拟机导入说明:https://b11et3un53m.feishu.cn/wiki/wikcnp21tivsf57nQrHckQuZ3Vd
自定义部署:https://b11et3un53m.feishu.cn/wiki/R068wmzUtifYg5kEMP7c6EMTn9d
我们在虚拟机中已经基于 Jenkins 实现了持续集成,访问 http://jenkins.tianji.com (账号:root/123
) 即可查看控制台:
我们在虚拟机环境中准备了一个Git私服,访问 http://git.tianji.com (账号:tjxt/123321
) 即可查看私服控制台:
将代码克隆到自己的 IDEA 工作空间中:
# master 分支是完整项目代码,上课要从 lesson-init 分支开发
git clone http://192.168.150.101:10880/tjxt/tianji.git -b lesson-init
以 lesson-init 分支为起点,创建一个 dev 分支,完成项目开发:
xxxxxxxxxx
cd tianji
git checkout -b dev
然后用 IDEA 打开项目即可。
首先是项目结构,目前企业微服务开发项目结构有两种模式:
每个项目下的每一个微服务,需要创建一个 Project,尽可能降低耦合
每个项目创建一个 Project ,项目下的多个微服务是 Project 下的 Module,方便管理(√)
SpringBoot 的配置文件支持多环境配置,在天机学堂中也基于不同环境有不同配置文件:
文件 | 说明 |
---|---|
bootstrap.yml | 通用配置属性,包含服务名、端口、日志等等各环境通用信息 |
bootstrap-dev.yml | 线上开发环境配置属性,虚拟机中部署使用 |
bootstrap-local.yml | 本地开发环境配置属性,本地开发、测试、部署使用 |
除了基本配置以外,其它的配置都放在了 Nacos 中共享,包括两大类:共享的配置、微服务中根据业务变化的配置。
登录 http://nacos.tianji.com (账号:nacos/nacos
) 即可看到所有被管理的配置信息。
BUG:都是普通用户,为什么用户 Jack 可以删除自己的订单,而用户 Rose 则无法成功删除?
如果部署的微服务不在本地,我们可以利用 IDEA 的远程调试功能:
xpackage com.tianji.trade.service.impl;
public void deleteOrder(Long id) {
if(userId != order.getUserId()) {
throw new BadRequestException("不能删除他人订单");
}
}
BUG 分析:Integer、Long 等包装类 -128 到 127 之间的数值使用共享对象,可以用 ==
判断,但其它则需要采用 equals
判断。
xxxxxxxxxx
package com.tianji.trade.service.impl;
public void deleteOrder(Long id) {
if(!userId.equals(order.getUserId())) {
throw new BadRequestException("不能删除他人订单");
}
}
当我们完成项目的 BUG 修复后,可以把项目提交并推送到 Git 私服,而 Jenkins 可以帮我们完成项目自动编译、构建。
由于我们现在是基于 dev 分支开发,所以必须修改 Jenkins 中的自动化构建脚本,让其监听 dev 分支的变动:
管理端 - 产品原型与接口:https://lanhuapp.com/link/#/invite?sid=qx03viNU,密码: Ssml
用户端 - 产品原型与接口:https://lanhuapp.com/link/#/invite?sid=qx0Fy3fa,密码: ZsP3
编号 | 接口简述 | 请求方式 |
---|---|---|
1 | 支付后,添加课程到课表中 | MQ 通知 |
2 | 分页查询我的课表 | GET |
3 | 查询学习中的课程 | GET |
4 | 查询指定课程状态 | GET |
5 | 删除课表中的课程 | DELETE |
6 | 退款后,移除课表中的课程 | MQ 通知 |
7 | 检查课程是否有效(Feign 接口) | GET |
8 | 统计课程学习人数(Feign 接口) | GET |
课表要记录的是用户的学习状态,所谓学习状态就是记录谁在学习哪个课程,学习的进度如何。
课表就是用户和课程的中间关系表,因此一定要包含三个字段:
userId
:用户 id,也就是谁
courseId
:课程 id,也就是学的课程
id
:唯一主键
学习进度则是一些附加的功能字段,页面需要哪些功能就添加哪些字段即可:
status
:课程学习状态。0-未学习,1-学习中,2-已学完,3-已过期
planStatus
:学习计划状态,0-没有计划,1-计划进行中
weekFreq
:计划的学习频率
learnedSections
:已学习小节数量,注意,课程总小节数、课程名称、封面等可由课程id查询得出,无需重复记录
latestSectionId
:最近一次学习的小节id,方便根据id查询最近学习的课程正在学第几节
latestLearnTime
:最近一次学习时间,用于分页查询的排序:
createTime
和 expireTime
:也就是课程加入时间和过期时间
课表对应的数据库结构应该是这样的:
xxxxxxxxxx
CREATE TABLE learning_lesson (
id bigint NOT NULL COMMENT '主键',
user_id bigint NOT NULL COMMENT '学员id',
course_id bigint NOT NULL COMMENT '课程id',
status tinyint DEFAULT '0' COMMENT '课程状态,0-未学习,1-学习中,2-已学完,3-已失效',
week_freq tinyint DEFAULT NULL COMMENT '每周学习频率,每周3天,每天2节,则频率为6',
plan_status tinyint NOT NULL DEFAULT '0' COMMENT '学习计划状态,0-没有计划,1-计划进行中',
learned_sections int NOT NULL DEFAULT '0' COMMENT '已学习小节数量',
latest_section_id bigint DEFAULT NULL COMMENT '最近一次学习的小节id',
latest_learn_time datetime DEFAULT NULL COMMENT '最近一次学习的时间',
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
expire_time datetime NOT NULL COMMENT '过期时间',
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY idx_user_id (user_id,course_id) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='学生课表';
在一个名为 tj_learning
的 database 中,执行资料提供的 SQL 脚本,创建 learning_lesson
表:
一般开发新功能都需要创建一个 feature 类型分支,不能在 DEV 分支直接开发:
xxxxxxxxxx
git checkout -b feature-lessons
我们使用的是 Mybatis 作为持久层框架,并且引入了 MybatisPlus 来简化开发。因此,在创建据库以后,就需要创建对应的实体类、mapper、service等。这些代码格式固定,编写起来又比较费时。好在 IDEA 中提供了一个 MP 插件,可以生成这些重复代码:
安装完成以后,我们先配置一下数据库地址:
严格按照下图的模式去设置,不要填错项目名称和包名称。最后,点击 code generatro
按钮,即可生成代码:
默认情况下 PO 的主键 ID 是自增长,而项目默认希望采用雪花算法作为 ID,因此这里需要对 LearningLesson
做修改:
xxxxxxxxxx
package com.tianji.learning.domain.po;
// @TableId(value = "id", type = IdType.AUTO)
value = "id", type = IdType.ASSIGN_ID) (
除此之外,我们还要按照Restful风格,对请求路径做修改:
xxxxxxxxxx
package com.tianji.learning.controller;
// @RequestMapping("/learning-lesson")
"/lessons") (
在数据结构中,课表是有学习状态的,学习计划也有状态。如果每次编码手写很容易写错,因此一般都会定义出枚举:
xxxxxxxxxx
package com.tianji.learning.enums;
public enum LessonStatus implements BaseEnum {...}
xxxxxxxxxx
package com.tianji.learning.enums;
public enum PlanStatus implements BaseEnum {...}
这样一来,我们就需要修改 PO 对象,用枚举类型替代原本的 Integer
类型:
xxxxxxxxxx
package com.tianji.learning.domain.po;
// 课程状态,0-未学习,1-学习中,2-已学完,3-已失效
private LessonStatus status;
// 学习计划状态,0-没有计划,1-计划进行中
private PlanStatus planStatus;
MybatisPlus
会在我们与数据库交互时实现自动的数据类型转换,无需我们操心。
需求:用户购买课程后,交易服务会通过 MQ 通知学习服务,学习服务将课程加入用户课表中。
在 tj-learning 服务中定义一个 MQ 的监听器:
xxxxxxxxxx
package com.tianji.learning.mq;
public class LessonChangeListener {
private final ILearningLessonService lessonService;
// 监听订单支付或课程报名的消息
bindings = ( (
value = (value = "learning.lesson.pay.queue", durable = "true"),
exchange = (name = MqConstants.Exchange.ORDER_EXCHANGE, type = ExchangeTypes.TOPIC),
key = MqConstants.Key.ORDER_PAY_KEY
))
public void listenLessonPay(OrderBasicDTO order){
// 1. 健壮性处理
if(order == null || order.getUserId() == null || CollUtils.isEmpty(order.getCourseIds())){
log.error("接收到MQ消息有误,订单数据为空");
return;
}
// 2. 添加课程
log.debug("监听到用户{}的订单{},需要添加课程{}到课表中",
order.getUserId(), order.getOrderId(), order.getCourseIds());
lessonService.addUserLessons(order.getUserId(), order.getCourseIds());
}
}
实现 LearningLessonServiceImpl 中的 addUserLessons
方法:
xxxxxxxxxx
public void addUserLessons(Long userId, List<Long> courseIds) {
// 1. 查询课程有效期
List<CourseSimpleInfoDTO> cInfoList = courseClient.getSimpleInfoList(courseIds);
if (CollUtils.isEmpty(cInfoList)) {
log.error("课程信息不存在,无法添加到课表");
return;
}
// 2. 循环遍历,处理LearningLesson数据
List<LearningLesson> list = new ArrayList<>(cInfoList.size());
for (CourseSimpleInfoDTO cInfo : cInfoList) {
LearningLesson lesson = new LearningLesson();
Integer validDuration = cInfo.getValidDuration(); // 获取过期时间
if (validDuration != null && validDuration > 0) {
LocalDateTime now = LocalDateTime.now();
lesson.setCreateTime(now);
lesson.setExpireTime(now.plusMonths(validDuration));
}
lesson.setUserId(userId); // 填充userId和courseId
lesson.setCourseId(cInfo.getId());
list.add(lesson);
}
// 3.批量新增
saveBatch(list);
}
网关解析 token,获取用户存入header,向下传递:
xxxxxxxxxx
package com.tianji.gateway.filter;
public class AccountAuthFilter implements GlobalFilter, Ordered {
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 尝试获取用户信息
List<String> authHeaders = exchange.getRequest().getHeaders().get(AUTHORIZATION_HEADER);
String token = authHeaders == null ? "" : authHeaders.get(0);
R<LoginUserDTO> r = authUtil.parseToken(token);
// 如果用户是登录状态,尝试更新请求头,传递用户信息
if(r.success()){
exchange.mutate()
.request(builder -> builder.header(USER_HEADER, r.getData().getUserId().toString()))
.build();
}
}
}
拦截器解析 header 中的用户信息,存入 UserContext:
xxxxxxxxxx
package com.tianji.authsdk.resource.interceptors;
public class UserInfoInterceptor implements HandlerInterceptor {
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 1. 尝试获取头信息中的用户信息
String authorization = request.getHeader(JwtConstants.USER_HEADER);
// 2. 判断是否为空
if (authorization == null) return true;
// 3. 转为用户id并保存
Long userId = Long.valueOf(authorization);
UserContext.setUser(userId);
return true;
}
}
UserContext 是一个基于 ThreadLocal 的工具,可以确保不同的请求之间互不干扰,避免线程安全问题发生:
xxxxxxxxxx
package com.tianji.common.utils;
public class UserContext {
private static final ThreadLocal<Long> TL = new ThreadLocal<>();
public static void setUser(Long userId){
TL.set(userId);
}
public static Long getUser(){
return TL.get();
}
public static void removeUser(){
TL.remove();
}
}
需求:在个人中心-我的课程页面,可以分页查询用户的课表及学习状态信息。
Query 实体类:
xxxxxxxxxx
package com.tianji.common.domain.query;
description = "分页请求参数") (
chain = true) (
public class PageQuery {
public <T> Page<T> toMpPage(String defaultSortBy, boolean isAsc) {
if (StringUtils.isBlank(sortBy)){
sortBy = defaultSortBy;
this.isAsc = isAsc;
}
Page<T> page = new Page<>(pageNo, pageSize);
OrderItem orderItem = new OrderItem();
orderItem.setAsc(this.isAsc);
orderItem.setColumn(sortBy);
page.addOrder(orderItem);
return page;
}
}
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
tags = "我的课程相关接口") (
"/lessons") (
public class LearningLessonController {
"分页查询我的课表") (
"page") (
public PageDTO<LearningLessonVO> queryMyLessons(PageQuery query){
return lessonService.queryMyLessons(query);
}
}
Service 层:
xxxxxxxxxx
public PageDTO<LearningLessonVO> queryMyLessons(PageQuery query) {
// 1. 获取当前登录用户
Long userId = UserContext.getUser();
// 2. 分页查询我的课表
Page<LearningLesson> page = lambdaQuery() // select * from learning_lesson
.eq(LearningLesson::getUserId, userId) // where user_id = #{userId}
.page(query.toMpPage("latest_learn_time", false)); // order by latest_learn_time
List<LearningLesson> records = page.getRecords();
if (CollUtils.isEmpty(records)) {
return PageDTO.empty(page);
}
// 3. 查询我的课程信息
Map<Long, CourseSimpleInfoDTO> cMap = queryCourseSimpleInfoList(records);
// 4. 封装 VO 并返回
List<LearningLessonVO> list = new ArrayList<>(records.size());
for (LearningLesson r : records) {
LearningLessonVO vo = BeanUtils.copyBean(r, LearningLessonVO.class);
CourseSimpleInfoDTO cInfo = cMap.get(r.getCourseId());
vo.setCourseName(cInfo.getName());
vo.setCourseCoverUrl(cInfo.getCoverUrl());
vo.setSections(cInfo.getSectionNum());
list.add(vo);
}
return PageDTO.of(page, list);
}
private Map<Long, CourseSimpleInfoDTO> queryCourseSimpleInfoList(List<LearningLesson> records) {
// 3.1 获取课程 id
Set<Long> cIds = records.stream().map(LearningLesson::getCourseId).collect(Collectors.toSet());
// 3.2 查询课程信息
List<CourseSimpleInfoDTO> cInfoList = courseClient.getSimpleInfoList(cIds);
if (CollUtils.isEmpty(cInfoList)) {
throw new BadRequestException("课程信息不存在!");
}
// 3.3 把 PO 集合处理成 Map, 否则要双重 for 设置到 VO 集合
Map<Long, CourseSimpleInfoDTO> cMap = cInfoList.stream()
.collect(Collectors.toMap(CourseSimpleInfoDTO::getId, c -> c));
return cMap;
}
需求:在首页、个人中心-课程表页,需要查询并展示当前用户最近一次学习的课程。
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
"/now") (
"查询正在学习的课程") (
public LearningLessonVO queryMyCurrentLesson() {
return lessonService.queryMyCurrentLesson();
}
Service 层:
xxxxxxxxxx
private final CatalogueClient catalogueClient;
public LearningLessonVO queryMyCurrentLesson() {
// 1. 获取当前登录的用户
Long userId = UserContext.getUser();
// 2. 查询正在学习的课程
LearningLesson lesson = lambdaQuery() // select * from learning_lesson
.eq(LearningLesson::getUserId, userId) // where user_id = #{userId}
.eq(LearningLesson::getStatus, LessonStatus.LEARNING.getValue())
.orderByDesc(LearningLesson::getLatestLearnTime) // order by latest_learn_time
.last("limit 1").one(); // limit 1 AND status = 1
if (lesson == null) return null;
// 3. 拷贝 PO 基础属性到 VO
LearningLessonVO vo = BeanUtils.copyBean(lesson, LearningLessonVO.class);
// 4. 查询我的课程信息
CourseFullInfoDTO cInfo = courseClient.getCourseInfoById(lesson.getCourseId(), false, false);
if (cInfo == null) throw new BadRequestException("课程不存在");
vo.setCourseName(cInfo.getName());
vo.setCourseCoverUrl(cInfo.getCoverUrl());
vo.setSections(cInfo.getSectionNum());
// 5. 统计课表中的课程数量
Integer courseAmount = lambdaQuery() // select count(1) from learning_lesson
.eq(LearningLesson::getUserId, userId).count(); // where user_id = #{userId}
vo.setCourseAmount(courseAmount);
// 6. 查询小节信息
List<CataSimpleInfoDTO> cataInfos =
catalogueClient.batchQueryCatalogue(CollUtils.singletonList(lesson.getLatestSectionId()));
if (!CollUtils.isEmpty(cataInfos)) {
CataSimpleInfoDTO cataInfo = cataInfos.get(0);
vo.setLatestSectionName(cataInfo.getName());
vo.setLatestSectionIndex(cataInfo.getCIndex());
}
return vo;
}
需求:用户可以直接删除《已失效》的课程,学习服务将删除用户课表中的课程。
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
"用户手动删除当前课程") (
"/{courseId}") (
public void deleteCourseFromLesson( ("courseId") Long couseId){
Long user = UserContext.getUser();
lessonService.deleteCourseFromLesson(user, couseId);
}
Service 层:
xxxxxxxxxx
public void deleteCourseFromLesson(Long userId, Long courseId) {
remove(buildUserIdAndCourseIdWrapper(userId, courseId));
}
需求:用户退款课程后,交易服务会通过 MQ 通知学习服务,学习服务将移除用户课表中的课程。
在 tj-learning 服务中定义一个 MQ 的监听器即可,deleteCourseFromLesson
方法已实现:
xxxxxxxxxx
package com.tianji.learning.mq;
public class LessonChangeListener {
private final ILearningLessonService lessonService;
bindings = ( (
value = (value = "trade.refund.result.queue", durable = "true"),
exchange = (name = MqConstants.Exchange.ORDER_EXCHANGE, type = ExchangeTypes.TOPIC),
key = MqConstants.Key.ORDER_REFUND_KEY
))
public void listenLessonRefund(OrderBasicDTO order){
// 1. 健壮性处理
if(order == null || order.getUserId() == null || CollUtils.isEmpty(order.getCourseIds())){
log.error("接收到MQ消息有误,订单数据为空");
return;
}
// 2. 删除课程表中删除该课程
log.debug("监听到用户{}的订单{},需要添加课程{}到课表中"
, order.getUserId(), order.getOrderId(), order.getCourseIds());
lessonService.deleteCourseFromLesson(order.getUserId(), order.getCourseIds().get(0));
}
}
需求:当用户学习课程时,可能需要播放视频。此时提供播放功能的媒资系统就需要校验用户是否有播放资格。
接口说明 | 根据课程 id,检查当前用户的课表中是否有该课程,课程状态是否有效。 |
---|---|
请求方式 | Http 请求,GET |
请求路径 | /ls/lessons/{courseId}/valid |
请求参数格式 | 课程 id,请求路径占位符,参数名:courseId |
返回值格式 | 课表 lessonId,如果是报名了则返回 lessonId,否则返回空 |
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
"校验当前用户是否可以学习当前课程") (
"/{courseId}/valid") (
public Long isLessonValid( ("courseId") Long courseId){
return lessonService.isLessonValid(courseId);
}
Service 层:
xxxxxxxxxx
public Long isLessonValid(Long courseId) {
Long user = UserContext.getUser();
if(user==null || courseId==null) return null;
LearningLesson lesson = lambdaQuery() // select * from learning_lesson
.eq(LearningLesson::getUserId, user) // where user_id = #{userId}
.eq(LearningLesson::getCourseId, courseId) // and course_id = #{courseId}
.one();
// 用户课表中没有该课程
if(lesson==null) return null;
// 课程的状态是已经过期
if(lesson.getExpireTime() != null && LocalDateTime.now().isAfter(lesson.getExpireTime())){
return null;
}
return lesson.getId();
}
需求:进入详情页以后,需要查询用户的课表中是否有该课程,如果有则返回课程的学习进度、课程有效期等信息。
接口说明 | 根据课程 id,查询课表中是否有该课程,如果有则返回课程的学习进度、课程有效期等信息。 |
---|---|
请求方式 | Http请求,GET |
请求路径 | /ls/lessons/{courseId} |
请求参数格式 | 课程 id,请求路径占位符,参数名:courseId |
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
"用户是否拥有该课程并返回学习进度") (
"/{courseId}") (
public LessonStatusVO queryLessonByCourseId(
value = "课程id", example = "1") ("courseId") Long couseId){ (
LessonStatusVO lessonStatusVO = lessonService.queryLessonByCourseId(couseId);
return lessonStatusVO;
}
Service 层:
xxxxxxxxxx
public LessonStatusVO queryLessonByCourseId(Long couseId) {
// 1. 获取用户信息
Long user = UserContext.getUser();
// 2. 查询用户是否有该课程
LambdaQueryWrapper<LearningLesson> wrapper = buildUserIdAndCourseIdWrapper(user, couseId);
LearningLesson lesson = getOne(wrapper);
if(lesson==null) return null;
// 3. 如果有则设置学习进度
LessonStatusVO vo = BeanUtils.copyBean(lesson, LessonStatusVO.class);
return vo;
}
需求:根据课程 id,统计该课程的报名人数。
接口说明 | 根据课程 id,统计该课程的报名人数。 |
---|---|
请求方式 | Http 请求,GET |
请求路径 | /ls/lessons/{courseId}/count |
请求参数格式 | 课程 id,请求路径占位符,参数名:courseId |
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
"查询该课程的报名人数") (
"/lessons/{courseId}/count") (
Integer countLearningLessonByCourse(
value = "课程id", example = "1") ("courseId") Long courseId){ (
return lessonService.countLearningLessonByCourse(courseId);
}
Service 层:
xxxxxxxxxx
public Integer countLearningLessonByCourse(Long courseId) {
Integer count = lambdaQuery() // select count(*) from learning_lesson
.eq(LearningLesson::getCourseId, courseId).count(); // where course_id = #{courseId}
return count;
}
提交学习记录:
需求:在课程学习页面播放视频时或考试后,需要提交学习记录信息到服务端保存。
查询学习记录:
需求:在课程学习页面需要查询出每一个小节的基本信息,以及小节对应的学习记录。
创建学习计划:
需求:当点击创建学习计划时,会提交课程信息和计划的学习频率到服务端。服务端需要将数据写入对应的课表中。
查询学习计划:
需求:在我的课程页面中,需要统计用户本周的学习计划及进度,数据较多,采用分页查询。
xxxxxxxxxx
CREATE TABLE `learning_record` (
`id` bigint NOT NULL COMMENT '学习记录的id',
`lesson_id` bigint NOT NULL COMMENT '对应课表的id',
`section_id` bigint NOT NULL COMMENT '对应小节的id',
`user_id` bigint NOT NULL COMMENT '用户id',
`moment` int NULL DEFAULT 0 COMMENT '视频的当前观看时间点,单位秒',
`finished` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否完成学习,默认false',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '第一次观看时间',
`finish_time` datetime NULL DEFAULT NULL COMMENT '完成学习的时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间(最近一次观看时间)',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `idx_user_id`(`user_id`, `section_id`) USING BTREE,
INDEX `idx_update_time`(`update_time`) USING BTREE,
INDEX `idx_lesson_id`(`lesson_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '学习记录表' ROW_FORMAT = DYNAMIC;
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
tags = "学习记录的相关接口") (
"/learning-records") (
public class LearningRecordController {
"提交学习记录") (
()
public void addLearningRecord( LearningRecordFormDTO formDTO){
recordService.addLearningRecord(formDTO);
}
}
Service 层:
xxxxxxxxxx
private final CourseClient courseClient;
public void addLearningRecord(LearningRecordFormDTO recordDTO) {
Long userId = UserContext.getUser(); // 1. 获取登录用户
boolean finished = false;
if (recordDTO.getSectionType() == SectionType.VIDEO) {
finished = handleVideoRecord(userId, recordDTO); // 2. 处理视频记录
}else{
finished = handleExamRecord(userId, recordDTO); // 3. 处理考试记录
}
handleLearningLessonsChanges(recordDTO, finished); // 4. 处理课表数据
}
xxxxxxxxxx
private boolean handleVideoRecord(Long userId, LearningRecordFormDTO recordDTO) {
// 2.1 判断记录是否已经存在
LearningRecord old = queryOldRecord(recordDTO.getLessonId(), recordDTO.getSectionId());
if (old == null) {
// 2.2 新增学习记录
LearningRecord record = BeanUtils.copyBean(recordDTO, LearningRecord.class);
record.setUserId(userId);
boolean success = save(record);
if (!success) throw new DbException("新增学习记录失败!");
return false;
}
// 2.3 更新学习记录
boolean finished = !old.getFinished() && recordDTO.getMoment() * 2 >= recordDTO.getDuration();
boolean success = lambdaUpdate() // update learning_record
.set(LearningRecord::getMoment, recordDTO.getMoment()) // set moment = #{moment},
.set(finished, LearningRecord::getFinished, true) // finished = true,
// finishTime = #{CommitTime}
.set(finished, LearningRecord::getFinishTime, recordDTO.getCommitTime())
.eq(LearningRecord::getId, old.getId()).update(); // where id = #{id}
if(!success) throw new DbException("更新学习记录失败!");
return finished ;
}
private LearningRecord queryOldRecord(Long lessonId, Long sectionId) {
return lambdaQuery() // select * from learning_record
.eq(LearningRecord::getLessonId, lessonId) // where lesson_id = #{lessonId}
.eq(LearningRecord::getSectionId, sectionId).one(); // and section_id = #{sectionId}
}
private boolean handleExamRecord(Long userId, LearningRecordFormDTO recordDTO) {
// 3. 新增学习记录
LearningRecord record = BeanUtils.copyBean(recordDTO, LearningRecord.class);
record.setUserId(userId);
record.setFinished(true);
record.setFinishTime(recordDTO.getCommitTime());
boolean success = save(record);
if (!success) throw new DbException("新增考试记录失败!");
return true;
}
private void handleLearningLessonsChanges(LearningRecordFormDTO recordDTO, boolean finished) {
// 4.1 判断是否学完小节
LearningLesson lesson = lessonService.getById(recordDTO.getLessonId());
if (lesson == null) throw new BizIllegalException("课程不存在,无法更新数据!");
boolean allLearned = false;
if(finished){
CourseFullInfoDTO cInfo = courseClient.getCourseInfoById(lesson.getCourseId(), false, false);
if (cInfo == null) throw new BizIllegalException("课程不存在,无法更新数据!");
// 4.2 判断是否学完全部小节
allLearned = lesson.getLearnedSections() + 1 >= cInfo.getSectionNum();
}
lessonService.lambdaUpdate()
// 如果是第一次观看视频,则设置 lesson 为正在学习
.set(lesson.getLearnedSections() == 0, LearningLesson::getStatus, LessonStatus.LEARNING.getValue())
// 如果学完全部小节,则更新 lesson 为完成状态
.set(allLearned, LearningLesson::getStatus, LessonStatus.FINISHED.getValue())
// 4.3 如果没学完小节,则更新最后学习时间、学习章节
.set(!finished, LearningLesson::getLatestSectionId, recordDTO.getSectionId())
.set(!finished, LearningLesson::getLatestLearnTime, recordDTO.getCommitTime())
// 4.4 如果已学完小节,则课表已学习小节数量 + 1
.setSql(finished, "learned_sections = learned_sections + 1")
.eq(LearningLesson::getId, lesson.getId()).update();
}
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
"查询指定课程的学习记录") (
"/course/{courseId}") (
public LearningLessonDTO queryLearningRecordByCourse(
value = "课程id", example = "2") ("courseId") Long courseId){ (
return recordService.queryLearningRecordByCourse(courseId);
}
Service 层:
xxxxxxxxxx
private final ILearningLessonService lessonService;
public LearningLessonDTO queryLearningRecordByCourse(Long courseId) {
// 1. 查询学习记录
Long userId = UserContext.getUser();
LearningLesson lesson = lessonService.queryByUserAndCourseId(userId, courseId);
List<LearningRecord> records = lambdaQuery() // select * from learning_record
.eq(LearningRecord::getLessonId, lesson.getId()).list(); // where lesson_id = #{lessonId}
// 2. 封装 DTO 并返回
LearningLessonDTO dto = new LearningLessonDTO();
dto.setId(lesson.getId());
dto.setLatestSectionId(lesson.getLatestSectionId());
dto.setRecords(BeanUtils.copyList(records, LearningRecordDTO.class));
return dto;
}
远程调用接口:
xxxxxxxxxx
package com.tianji.learning.service.impl;
public LearningLesson queryByUserAndCourseId(Long userId, Long courseId) {
return getOne(buildUserIdAndCourseIdWrapper(userId, courseId));
}
private LambdaQueryWrapper<LearningLesson> buildUserIdAndCourseIdWrapper(Long userId, Long courseId) {
LambdaQueryWrapper<LearningLesson> queryWrapper = new QueryWrapper<LearningLesson>()
.lambda() // select * from learning_lesson
.eq(LearningLesson::getUserId, userId) // where user_id = #{userId}
.eq(LearningLesson::getCourseId, courseId); // and course_id = #{courseId}
return queryWrapper;
}
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
"/lessons") (
tags = "我的课表相关接口") (
public class LearningLessonController {
"创建学习计划") (
"/plans") (
public void createLearningPlans( LearningPlanDTO planDTO){
lessonService.createLearningPlan(planDTO.getCourseId(), planDTO.getFreq());
}
}
Service 层:
xxxxxxxxxx
public void createLearningPlan(Long courseId, Integer freq) {
// 1. 查询课程有关的数据
Long userId = UserContext.getUser();
LearningLesson lesson = queryByUserAndCourseId(userId, courseId);
AssertUtils.isNotNull(lesson, "课程信息不存在!");
// 2. 更新课程数据
LearningLesson l = new LearningLesson();
l.setId(lesson.getId());
l.setWeekFreq(freq);
if(lesson.getPlanStatus() == PlanStatus.NO_PLAN) {
l.setPlanStatus(PlanStatus.PLAN_RUNNING);
}
updateById(l);
}
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
"查询我的学习计划") (
"/plans") (
public LearningPlanPageVO queryMyPlans(PageQuery query){
return lessonService.queryMyPlans(query);
}
Service 层:
xxxxxxxxxx
private final LearningRecordMapper recordMapper;
public LearningPlanPageVO queryMyPlans(PageQuery query) {
LearningPlanPageVO result = new LearningPlanPageVO();
// 1. 获取当前登录用户
Long userId = UserContext.getUser();
// 2. 获取本周起始时间
LocalDate now = LocalDate.now();
LocalDateTime begin = DateUtils.getWeekBeginTime(now);
LocalDateTime end = DateUtils.getWeekEndTime(now);
// 3. 查询总的统计数据
// 3.1. 本周总的已学习小节数量
Integer weekFinished = recordMapper.selectCount(
new LambdaQueryWrapper<LearningRecord>() // select * from learning_record
.eq(LearningRecord::getUserId, userId) // where user_id = #{userId}
.eq(LearningRecord::getFinished, true) // and finished = true
.gt(LearningRecord::getFinishTime, begin) // and finishTime > begin
.lt(LearningRecord::getFinishTime, end) // and finishTime < end;
);
result.setWeekFinished(weekFinished);
// 3.2 本周总的计划学习小节数量
Integer weekTotalPlan = getBaseMapper().queryTotalPlan(userId);
result.setWeekTotalPlan(weekTotalPlan);
// TODO 3.3 本周学习积分
// 4. 分页查询课表信息
Page<LearningLesson> p = lambdaQuery() // select * from learning_lesson
.eq(LearningLesson::getUserId, userId) // where user_id = #{userId}
.eq(LearningLesson::getPlanStatus, PlanStatus.PLAN_RUNNING)
// and plan_status = "计划进行中"
.in(LearningLesson::getStatus, LessonStatus.NOT_BEGIN, LessonStatus.LEARNING)
// and plan_status in ("未学习", "学习中")
.page(query.toMpPage("latest_learn_time", false)); // ordered by latest_learn_time DESC;
List<LearningLesson> records = p.getRecords();
if (CollUtils.isEmpty(records)) return result.emptyPage(p);
// 5. 查询课表对应的课程信息
Map<Long, CourseSimpleInfoDTO> cMap = queryCourseSimpleInfoList(records);
// 6. 统计每一个课程本周已学习小节数量
List<IdAndNumDTO> list = recordMapper.countLearnedSections(userId, begin, end);
Map<Long, Integer> countMap = IdAndNumDTO.toMap(list);
// 7. 封装 VO 并返回
List<LearningPlanVO> voList = new ArrayList<>(records.size());
for (LearningLesson r : records) {
// 7.1 拷贝属性到 VO
LearningPlanVO vo = BeanUtils.copyBean(r, LearningPlanVO.class);
// 7.2 设置详细信息
CourseSimpleInfoDTO cInfo = cMap.get(r.getCourseId());
if (cInfo != null) {
vo.setCourseName(cInfo.getName());
vo.setSections(cInfo.getSectionNum());
}
// 7.3 每个课程的本周已学习小节数量
vo.setWeekLearnedSections(countMap.getOrDefault(r.getId(), 0));
voList.add(vo);
}
return result.pageInfo(p.getTotal(), p.getPages(), voList);
}
远程调用接口:
xxxxxxxxxx
package com.tianji.learning.mapper;
public interface LearningRecordMapper extends BaseMapper<LearningRecord> {
List<IdAndNumDTO> countLearnedSections(
"userId") Long userId, (
"begin") LocalDateTime begin, (
"end") LocalDateTime end); (
}
xxxxxxxxxx
<select id="countLearnedSections" resultType="com.tianji.api.dto.IdAndNumDTO">
SELECT lesson_id AS id, COUNT(1) AS num
FROM learning_record WHERE user_id = #{userId}
AND finished = 1 AND finish_time > #{begin} AND finish_time < #{end}
GROUP BY lesson_id;
</select>
需求:编写一个 SpringTask 定时任务,定期检查 learning_lesson 表中的课程是否过期,如果过期则将课程状态修改为已过期。
xxxxxxxxxx
package com.tianji.learning.job;
public class LessonStatusCheckJob {
private final ILearningLessonService lessonService;
cron = "0 * * * * ?") // 每分钟执行一次 (
public void lessonStatusCheck() {
// 1. 查询状态为未过期的课程
List<LearningLesson> list = lessonService.list(
Wrappers.<LearningLesson>lambdaQuery().ne(LearningLesson::getStatus, LessonStatus.EXPIRED)
);
LocalDateTime now = LocalDateTime.now();
// 2. 判断是否过期
for (LearningLesson lesson : list) {
if (now.isAfter(lesson.getExpireTime())) {
lesson.setStatus(LessonStatus.EXPIRED);
}
}
// 3. 批量更新
lessonService.updateBatchById(list);
}
}
提高单机并发:
读:① 优化代码及 SQL;② 缓存。
写:① 优化代码及 SQL;② 变同步为异步;③ 合并写请求。
变同步为异步:
优点:① 无需等待,减少响应时间;② 利用 MQ 暂存消息,流量削峰整形作用;③ 降低写频率,减轻数据库并发压力。
缺点:① 依赖MQ可靠性;② 没有降低数据库写次数,仅仅降低写频率。
合并写请求:
优点:① 写缓存速度快,大大减少响应时间;② 降低了数据库写频率和写次数,减轻数据库并发压力。
缺点:① 实现相对复杂;② 依赖缓存系统可靠性;③ 不支持事务和复杂业务。
优化方案选择:
提交播放进度业务虽然看起来复杂,但大多数请求的处理很简单,就是更新播放进度。
并且播放进度数据是可以合并的(覆盖之前旧数据),所以采用合并写请求方案。
Redis 数据结构设计:
KEY:课表 id(用户 id + 课程 id):lessonId
HashKey:小节 id:sectionId
HashValue:记录 id:id
,播放进度:moment
,是否学完:finished
持久化思路:
延迟任务方案:
DelayQueue 的原理:
首先来看一下 DelayQueue 的源码:
xxxxxxxxxx
public class DelayQueue<E extends Delayed> extends AbstractQueue<E> implements BlockingQueue<E> {
private final transient ReentrantLock lock = new ReentrantLock();
private final PriorityQueue<E> q = new PriorityQueue<E>();
}
可以看到 DelayQueue 实现了 BlockingQueue
接口,是一个阻塞队列。DelayQueue 叫延迟队列,其中存储的就是延迟执行的任务。
根据 DelayQueue 的泛型定义,可知存入队列的元素必须是 Delayed
类型,这其实就是一个延迟任务的规范接口:
xxxxxxxxxx
public interface Delayed extends Comparable<Delayed> {
long getDelay(TimeUnit unit);
}
可以看出Delayed
类型必须具备两个方法:getDelay()
:获取任务剩余延迟时间;compareTo(T t)
:根据延迟时间判断执行顺序。
定义延迟任务工具类:(了解)
xxxxxxxxxx
package com.tianji.learning.utils;
public class LearningRecordDelayTaskHandler {
// 1. 查询 Redis 缓存中的指定小节的播放记录
public LearningRecord readRecordCache(Long lessonId, Long sectionId) {...}
// 2. 删除 Redis 缓存中的指定小节的播放记录
public void cleanRecordCache(Long lessonId, Long sectionId) {...}
// 3.1 添加播放记录到 Redis
public void writeRecordCache(LearningRecord record) {...}
// 3.2 添加一个延迟检测任务到 DelayQueue
public void addLearningRecordTask(LearningRecord record) {...}
// 4. 异步执行 DelayQueue 中的延迟检测任务,检测播放进度是否变化,如果无变化则写入数据库
private void handleDelayTask() {...}
}
改造提交学习记录功能:
xxxxxxxxxx
public void addLearningRecord(LearningRecordFormDTO recordDTO) {
if (!finished) return; // 如果未学完, 直接返回, 不操作数据库
}
private LearningRecord queryOldRecord(Long lessonId, Long sectionId) {
// ... // 1. 查询 Redis 缓存中的指定小节的播放记录
LearningRecord record = taskHandler.readRecordCache(lessonId, sectionId);
if (record != null) return record;
record = lambdaQuery()
.eq(LearningRecord::getLessonId, lessonId)
.eq(LearningRecord::getSectionId, sectionId).one();
taskHandler.writeRecordCache(record); // 3.1 添加播放记录到 Redis
return record;
}
private boolean handleVideoRecord(Long userId, LearningRecordFormDTO recordDTO) {
// ... // 3.2 添加一个延迟检测任务到 DelayQueue
if (!finished) {
LearningRecord record = new LearningRecord();
record.setLessonId(recordDTO.getLessonId());
record.setSectionId(recordDTO.getSectionId());
record.setMoment(recordDTO.getMoment());
record.setId(old.getId());
record.setFinished(old.getFinished());
taskHandler.addLearningRecordTask(record);
return false;
}
// ... // 2. 删除 Redis 缓存中的指定小节的播放记录
taskHandler.cleanRecordCache(recordDTO.getLessonId(), recordDTO.getSectionId());
}
xxxxxxxxxx
private void handleLearningLessonsChanges(LearningRecordFormDTO recordDTO, boolean finished) {
LearningLesson lesson = lessonService.getById(recordDTO.getLessonId());
if (lesson == null) throw new BizIllegalException("课程不存在,无法更新数据!");
CourseFullInfoDTO cInfo = courseClient.getCourseInfoById(lesson.getCourseId(), false, false);
if (cInfo == null) throw new BizIllegalException("课程不存在,无法更新数据!");
boolean allLearned = false;
allLearned = lesson.getLearnedSections() + 1 >= cInfo.getSectionNum();
lessonService.lambdaUpdate()
// 如果是第一次观看视频,则设置 lesson 为正在学习
.set(lesson.getLearnedSections() == 0, LearningLesson::getStatus, LessonStatus.LEARNING.getValue())
// 如果学完全部小节,则更新 lesson 为完成状态、完成时间
.set(allLearned, LearningLesson::getStatus, LessonStatus.FINISHED.getValue())
.set(allLearned, LearningLesson::getFinishTime, LocalDateTime.now())
// 如果已学完小节,则课表已学习小节数量 + 1
.setSql("learned_sections = learned_sections + 1")
.eq(LearningLesson::getId, lesson.getId()).update();
}
需求:目前我们的延迟任务执行还是单线程模式,大家将其改造为线程池模式,核心线程数与CPU核数一致即可。
xxxxxxxxxx
package com.tianji.learning.utils;
public class LearningRecordDelayTaskHandler {
// 建议1. 如果任务为 cpu 运算型,核心线程适合设置为 cpu 核心数
// 建议2. 如果任务为 io 型,推荐设置为 cpu 最大线程数
static ThreadPoolExecutor poolExecutor =
new ThreadPoolExecutor(16, 5, 60, TimeUnit.SECONDS, new LinkedBlockingDeque<>(10));
private void handleDelayTask(){
try {
poolExecutor.submit(new Runnable() {...});
} catch (Exception e) {
log.error("处理播放记录任务发生异常", e);
}
}
}
答:我参与了整个学习中心的功能开发,其中有很多的学习辅助功能都很有特色。比如视频播放的进度记录。
我们网站的课程是以录播视频为主,为了提高用户体验,需要实现续播功能。功能本身并不复杂,只不过我们产品提出的要求比较高:
首先续播时间误差要控制在 30 秒以内
而且要做到用户突然断开,甚至切换设备后,都可以继续上一次播放
要达成这个目的,传统的手段显然是不行的。首先,要做到切换设备后还能续播,用户的播放进度必须保存在服务端,而不是客户端。
其次,续播的时间误差不能超过 30 秒,那播放进度的记录频率就需要比较高。我们会在前端每隔 15 秒就发起一次心跳请求,提交最新的播放进度,记录到服务端。这样用户下一次续播时直接读取服务端的播放进度,就可以将时间误差控制在 15 秒左右。
答:提交播放记录最终肯定是要保存到数据库中的。 因为我们不仅要做视频续播,还有用户学习计划、学习进度统计等功能,都需要用到用户的播放记录数据。
但确实如你所说,前端每隔 15 秒一次请求,如果在用户量较大时,直接全部写入数据库,对数据库压力会比较大。
因此我们采用了合并写请求的方案,当用户提交播放进度时会先缓存在 Redis 中,后续再将数据保存到数据库即可。 由于播放进度会不断覆盖,只保留最后一次即可。这样就可以大大减少对于数据库的访问次数和访问频率了。
分析业务流程:
统计接口:
编号 | 接口简述 | |
---|---|---|
互动提问 | 1 | 新增互动问题(C 端) |
2 | 修改互动问题(C 端) | |
3 | 分页查询问题(C 端) | |
4 | 查询问题详情(C 端) | |
5 | 删除互动问题(C 端) | |
6 | 分页查询问题(B 端) | |
7 | 查询问题详情(B 端) | |
8 | 隐藏显示问题(B 端) | |
回答及评论 | 9 | 新增回答或评论(C 端) |
10 | 分页查询回答或评论(C 端) | |
11 | 分页查询回答或评论(B 端) | |
12 | 隐藏显示回答或评论(B 端) |
新增互动问题(C 端):
修改互动问题(C 端):
分页查询问题(C 端):
查询问题详情(C 端):
删除互动问题(C 端):
请求方式 | GET |
---|---|
请求路径 | /admin/questions/page |
xxxxxxxxxx
{ // 请求参数:
"pageNo": 1, // 页码
"pageSize": 5, // 每页大小
"courseName": "课程名称",
"status": 1, // 问题状态,1-已查看、0-未查看
"beginTime": "2023-01-01 12:00:00", // 提问时间的最小值
"endTime": "2023-01-02 12:00:00", // 提问时间的最大值
}
{ // 返回值:
"list": [
{
"id": "问题id",
"title": "问题标题",
"description": "问题描述",
"chapterName": "章名称",
"sectionName": "小节名称",
"courseName": "课程名称",
"categoryName": "分类名称1/分类名称2/分类名称3",
"userName": "提问者昵称"
"answerTimes": 0, // 回答数量
"createTime": "2022-7-18 19:52:36",
"hidden": true,
"status": 0, // 问题状态:0-未查看;1-已查看
}
],
"pages": 0, // 总页数
"total": 0 // 总条数
}
分页查询问题(B 端):
查询问题详情(B 端):
隐藏显示问题(B 端):
请求方式 | PUT |
---|---|
请求路径 | /admin/questions/{id}/hidden/{hidden} |
请求参数 | 路径占位符参数 |
新增回答或评论(C 端):
分页查询回答或评论(C 端):
请求方式 | GET |
---|---|
请求路径 | /replies/page |
xxxxxxxxxx
{ // 请求参数:
"pageNo": 1, // 页码
"pageSize": 5, // 每页大小
"courseName": "课程名称",
"status": 1, // 问题状态,1-已查看、0-未查看
"beginTime": "2023-01-01 12:00:00", // 提问时间的最小值
"endTime": "2023-01-02 12:00:00", // 提问时间的最大值
}
{ // 返回值:
"list": [
{
"id": "问题id",
"title": "问题标题",
"description": "问题描述",
"chapterName": "章名称",
"sectionName": "小节名称",
"courseName": "课程名称",
"categoryName": "分类名称1/分类名称2/分类名称3",
"userName": "提问者昵称"
"answerTimes": 0, // 回答数量
"createTime": "2022-7-18 19:52:36",
"hidden": true,
"status": 0, // 问题状态:0-未查看;1-已查看
}
],
"pages": 0, // 总页数
"total": 0 // 总条数
}
分页查询回答或评论(B 端):
隐藏显示回答或评论(B 端):
请求方式 | PUT |
---|---|
请求路径 | /admin/replies/{id}/hidden/{hidden} |
请求参数 | 路径占位符参数 |
问题表 interaction_question
:
xxxxxxxxxx
CREATE TABLE IF NOT EXISTS `interaction_question` (
`id` bigint NOT NULL COMMENT '主键,互动问题的id',
`title` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '互动问题的标题',
`description` varchar(2048) CHARACTER SET utf8mb4 NOT NULL DEFAULT '' COMMENT '问题描述信息',
`course_id` bigint NOT NULL COMMENT '所属课程id',
`chapter_id` bigint NOT NULL COMMENT '所属课程章id',
`section_id` bigint NOT NULL COMMENT '所属课程节id',
`user_id` bigint NOT NULL COMMENT '提问学员id',
`latest_answer_id` bigint DEFAULT NULL COMMENT '最新的一个回答的id',
`answer_times` int unsigned NOT NULL DEFAULT '0' COMMENT '问题下的回答数量',
`anonymity` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否匿名,默认false',
`hidden` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否被隐藏,默认false',
`status` tinyint DEFAULT '0' COMMENT '管理端问题状态:0-未查看,1-已查看',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '提问时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_course_id` (`course_id`) USING BTREE,
KEY `section_id` (`section_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='互动提问的问题表';
回答、评论表 interaction_reply
:
xxxxxxxxxx
CREATE TABLE IF NOT EXISTS `interaction_reply` (
`id` bigint NOT NULL COMMENT '互动问题的回答id',
`question_id` bigint NOT NULL COMMENT '互动问题问题id',
`answer_id` bigint DEFAULT '0' COMMENT '回复的上级回答id',
`user_id` bigint NOT NULL COMMENT '回答者id',
`content` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '回答内容',
`target_user_id` bigint DEFAULT '0' COMMENT '回复的目标用户id',
`target_reply_id` bigint DEFAULT '0' COMMENT '回复的目标回复id',
`reply_times` int NOT NULL DEFAULT '0' COMMENT '回答下的评论数量',
`liked_times` int NOT NULL DEFAULT '0' COMMENT '点赞数量',
`hidden` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否被隐藏,默认false',
`anonymity` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否匿名,默认false',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_question_id` (`question_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='互动问题的回答或评论';
需求:在课程详情页,或者用户学习视频页面,都可以对当前课程提出疑问。
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
"/questions") (
public class InteractionQuestionController {
"新增互动问题") (
public void saveQuestion( QuestionFormDTO questionDTO){
questionService.saveQuestion(questionDTO);
}
}
Service 层:
xxxxxxxxxx
public void saveQuestion(QuestionFormDTO questionDTO) {
Long userId = UserContext.getUser();
InteractionQuestion question = BeanUtils.toBean(questionDTO, InteractionQuestion.class);
question.setUserId(userId);
save(question);
}
需求:在课程详情页,用户可以对自己提出的问题做编辑,修改问题的相关信息。
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
"修改互动问题") (
"{id}") (
public void updateQuestion( ("id") Long id, QuestionFormDTO questionDTO){
questionService.updateQuestion(id, questionDTO);
}
Service 层:
xxxxxxxxxx
public void updateQuestion(Long id, QuestionFormDTO questionDTO) {
if(StringUtils.isBlank(questionDTO.getTitle()) // 标题为空
|| StringUtils.isBlank(questionDTO.getDescription()) // 描述为空
|| questionDTO.getAnonymity() == null){ // 匿名为空
throw new BadRequestException("请求参数不能为空");
}
InteractionQuestion question = getById(id);
if(question == null) {
throw new BizIllegalException("该问题已被删除");
}
question.setTitle(questionDTO.getTitle());
question.setDescription(questionDTO.getDescription());
question.setAnonymity(questionDTO.getAnonymity());
updateById(question);
}
需求:在课程详情页或视频学习页面,都可以分页查询课程相关的问题列表,数据较多采用分页查询。
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
"分页查询问题") (
"page") (
public PageDTO<QuestionVO> queryQuestionPage(QuestionPageQuery query){
return questionService.queryQuestionPage(query);
}
Service 层:
xxxxxxxxxx
private final InteractionReplyMapper replyMapper;
private final UserClient userClient;
public PageDTO<QuestionVO> queryQuestionPage(QuestionPageQuery query) {
// 1. 判断课程 id 和小节 id 是否为空
Long courseId = query.getCourseId();
Long sectionId = query.getSectionId();
if (courseId == null && sectionId == null) {
throw new BadRequestException("课程id和小节id不能都为空");
}
// 2. 分页查询
Page<InteractionQuestion> page = lambdaQuery()
.select(InteractionQuestion.class, info -> !info.getProperty().equals("description"))
.eq(query.getOnlyMine(), InteractionQuestion::getUserId, UserContext.getUser())
.eq(courseId != null, InteractionQuestion::getCourseId, courseId)
.eq(sectionId != null, InteractionQuestion::getSectionId, sectionId)
.eq(InteractionQuestion::getHidden, false)
.page(query.toMpPageDefaultSortByCreateTimeDesc());
List<InteractionQuestion> records = page.getRecords();
if (CollUtils.isEmpty(records)) {
return PageDTO.empty(page);
}
// 3. 根据 id 查询提问者和最近一次回答的信息
Set<Long> userIds = new HashSet<>();
Set<Long> answerIds = new HashSet<>();
// 3.1 得到提问者 id 和最近一次回答 id
for (InteractionQuestion q : records) {
if(!q.getAnonymity())
userIds.add(q.getUserId()); // 匿名问题直接忽略
answerIds.add(q.getLatestAnswerId());
}
// 3.2 根据 id 查询最近一次回答的信息
answerIds.remove(null);
Map<Long, InteractionReply> replyMap = new HashMap<>(answerIds.size());
if(CollUtils.isNotEmpty(answerIds)) {
List<InteractionReply> replies = replyMapper.selectBatchIds(answerIds);
for (InteractionReply reply : replies) {
replyMap.put(reply.getId(), reply);
if(!reply.getAnonymity())
userIds.add(reply.getUserId()); // 匿名用户直接忽略
}
}
// 3.3.根据 id 查询提问者的信息
userIds.remove(null);
Map<Long, UserDTO> userMap = new HashMap<>(userIds.size());
if(CollUtils.isNotEmpty(userIds)) {
List<UserDTO> users = userClient.queryUserByIds(userIds);
userMap = users.stream().collect(Collectors.toMap(UserDTO::getId, u -> u));
}
// 4. 封装 VO 并返回
List<QuestionVO> voList = new ArrayList<>(records.size());
for (InteractionQuestion r : records) {
// 4.1 将 PO 拷贝到 VO
QuestionVO vo = BeanUtils.copyBean(r, QuestionVO.class);
vo.setUserId(null);
voList.add(vo);
// 4.2 封装提问者的信息
if(!r.getAnonymity()){
UserDTO userDTO = userMap.get(r.getUserId());
if (userDTO != null) {
vo.setUserId(userDTO.getId());
vo.setUserName(userDTO.getName());
vo.setUserIcon(userDTO.getIcon());
}
}
// 4.3 封装最近一次回答的信息
InteractionReply reply = replyMap.get(r.getLatestAnswerId());
if (reply != null) {
vo.setLatestReplyContent(reply.getContent());
if(!reply.getAnonymity()){ // 匿名用户直接忽略
UserDTO user = userMap.get(reply.getUserId());
vo.setLatestReplyUser(user.getName());
}
}
}
return PageDTO.of(page, voList);
}
远程调用接口:
xxxxxxxxxx
package com.tianji.learning.mapper;
public interface InteractionReplyMapper extends BaseMapper<InteractionReply> {
}
xxxxxxxxxx
package com.tianji.api.client.user;
value = "user-service", fallbackFactory = UserClientFallback.class) (
public interface UserClient {
"/users/list") (
List<UserDTO> queryUserByIds( ("ids") Iterable<Long> ids);
}
需求:在课程详情页用户点击某个互动问题后,会进入问答详情页面,查看问题详细信息。
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
"查询问题详情") (
"/{id}") (
public QuestionVO queryQuestionById( (value = "问题id", example = "1") ("id") Long id){
return questionService.queryQuestionById(id);
}
Service 层:
xxxxxxxxxx
public QuestionVO queryQuestionById(Long id) {
// 1. 查询问题详情
InteractionQuestion question = getById(id);
// 2. 判断是否为空,或被隐藏了
if(question == null || question.getHidden()) return null;
// 3. 查询提问者信息
UserDTO user = null;
if(!question.getAnonymity()){
user = userClient.queryUserById(question.getUserId());
}
// 4. 封装 VO 并返回
QuestionVO vo = BeanUtils.copyBean(question, QuestionVO.class);
if (user != null) {
vo.setUserName(user.getName());
vo.setUserIcon(user.getIcon());
}
return vo;
}
需求:用户可以删除自己提问的问题。
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
"删除互动问题") (
"{id}") (
public void deleteQuestion( ("id") Long id){
questionService.deleteQuestion(id);
}
Service 层:
xxxxxxxxxx
public void deleteQuestion(Long id) {
Long user = UserContext.getUser();
InteractionQuestion question = getById(id);
if(question == null) return;
if(!user.equals(question.getUserId())){
throw new BizIllegalException("该问题提问者非当前用户,无法删除");
}
removeById(id);
}
在项目中,所有上线的课程数据都会存储到 Elasticsearch
中,方便用户检索课程。并且在 tj-search 模块中提供了相关的查询接口。
其中就有根据课程名称搜索课程信息的功能,并且这个功能还对外开放了一个 Feign 客户端方便我们调用:
xxxxxxxxxx
package com.tianji.api.client.search;
"search-service") (
public interface SearchClient {
"/courses/name") (
List<Long> queryCoursesIdByName( (value = "keyword", required = false) String keyword);
}
查询思路分析:
管理端除了要查询到问题,还需要返回问题所属的一系列信息。这些数据对应到 interaction_question
表中,只包含一些 id 字段。
课程分类信息在以往的查询中没有涉及过,课程分类在首页就能看到,共分为3级。 只要我们查询到了问题所属的课程,就能知道课程关联的三级分类 id,接下来只需要根据分类 id 查询出分类名称即可。
而在 course-service 服务中提供了一个接口,可以查询到所有的分类:
xxxxxxxxxx
package com.tianji.api.client.course;
contextId = "catalogue", value = "course-service",path = "catalogues") (
public interface CatalogueClient {
"/batchQuery") (
List<CataSimpleInfoDTO> batchQueryCatalogue( ("ids") Iterable<Long> ids);
}
多级缓存:
Redis 虽然能提高性能,但每次查询缓存还是会增加网络带宽消耗,也有网络延迟。
分类数据特点:数据量小、长时间不会发生变化。像这样的数据,还适合做本地缓存(Local Cache),形成多级缓存机制:
数据查询时优先查询本地缓存
本地缓存不存在,再查询 Redis 缓存
Redis 不存在,再去查询数据库
Caffeine 是一个基于 Java8 开发的,提供了最佳命中率的高性能的本地缓存库。目前 Spring 内部缓存使用的就是 Caffeine。
课程分类的本地缓存:(了解)
xxxxxxxxxx
package com.tianji.api.cache;
public class CategoryCache {...} // 课程分类的缓存工具类
xxxxxxxxxx
package com.tianji.api.config;
public class CategoryCacheConfig {...} // 课程分类的缓存配置
需求:在后台管理页面,教师可以查看学员提问的问题并予以答复,查询采用分页查询。
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
"/admin/questions") (
tags = "互动问答相关接口") (
public class InteractionQuestionAdminController {
"分页查询问题") (
"page") (
public PageDTO<QuestionAdminVO> queryQuestionPageAdmin(QuestionAdminPageQuery query){
return questionService.queryQuestionPageAdmin(query);
}
}
Service 层:
xxxxxxxxxx
private final SearchClient searchClient; // 远程调用: 课程名称模糊搜索
private final CatalogueClient catalogueClient; // 远程调用: 查询课程三级分类
private final CategoryCache categoryCache; // 远程调用: 课程分类本地缓存
public PageDTO<QuestionAdminVO> queryQuestionPageAdmin(QuestionAdminPageQuery query) {
// 1. 处理课程名称,得到课程 id
List<Long> courseIds = null;
if (StringUtils.isNotBlank(query.getCourseName())) {
courseIds = searchClient.queryCoursesIdByName(query.getCourseName());
if (CollUtils.isEmpty(courseIds)) {
return PageDTO.empty(0L, 0L);
}
}
// 2. 分页查询
Integer status = query.getStatus();
LocalDateTime begin = query.getBeginTime();
LocalDateTime end = query.getEndTime();
Page<InteractionQuestion> page = lambdaQuery() // select * from interaction_question
.in(courseIds != null, InteractionQuestion::getCourseId, courseIds)// where course_id in (courseIds)
.eq(status != null, InteractionQuestion::getStatus, status) // and status = #{status}
.gt(begin != null, InteractionQuestion::getCreateTime, begin) // and create_time > #{begin}
.lt(end != null, InteractionQuestion::getCreateTime, end) // and create_time < #{end}
.page(query.toMpPageDefaultSortByCreateTimeDesc()); // ordered by create_time ASC;
List<InteractionQuestion> records = page.getRecords();
if (CollUtils.isEmpty(records)) {
return PageDTO.empty(page);
}
// 3. 获取用户数据、课程数据、章节数据
Set<Long> userIds = new HashSet<>();
Set<Long> cIds = new HashSet<>();
Set<Long> cataIds = new HashSet<>();
// 3.1 获取各种数据的 id 集合
for (InteractionQuestion q : records) {
userIds.add(q.getUserId());
cIds.add(q.getCourseId());
cataIds.add(q.getChapterId());
cataIds.add(q.getSectionId());
}
// 3.2 根据 id 查询用户数据
List<UserDTO> users = userClient.queryUserByIds(userIds);
Map<Long, UserDTO> userMap = new HashMap<>(users.size());
if (CollUtils.isNotEmpty(users)) {
userMap = users.stream().collect(Collectors.toMap(UserDTO::getId, u -> u));
}
// 3.3 根据 id 查询课程数据
List<CourseSimpleInfoDTO> cInfos = courseClient.getSimpleInfoList(cIds);
Map<Long, CourseSimpleInfoDTO> cInfoMap = new HashMap<>(cInfos.size());
if (CollUtils.isNotEmpty(cInfos)) {
cInfoMap = cInfos.stream().collect(Collectors.toMap(CourseSimpleInfoDTO::getId, c -> c));
}
// 3.4 根据 id 查询章节数据
List<CataSimpleInfoDTO> catas = catalogueClient.batchQueryCatalogue(cataIds);
Map<Long, String> cataMap = new HashMap<>(catas.size());
if (CollUtils.isNotEmpty(catas)) {
cataMap = catas.stream()
.collect(Collectors.toMap(CataSimpleInfoDTO::getId, CataSimpleInfoDTO::getName));
}
// 4. 封装 VO 并返回
List<QuestionAdminVO> voList = new ArrayList<>(records.size());
for (InteractionQuestion q : records) {
// 4.1 将 PO 拷贝到 VO
QuestionAdminVO vo = BeanUtils.copyBean(q, QuestionAdminVO.class);
voList.add(vo);
// 4.2 封装用户信息
UserDTO user = userMap.get(q.getUserId());
if (user != null) {
vo.setUserName(user.getName());
}
// 4.3 封装课程信息以及分类信息
CourseSimpleInfoDTO cInfo = cInfoMap.get(q.getCourseId());
if (cInfo != null) {
vo.setCourseName(cInfo.getName());
vo.setCategoryName(categoryCache.getCategoryNames(cInfo.getCategoryIds()));
}
// 4.4 封装章节信息
vo.setChapterName(cataMap.getOrDefault(q.getChapterId(), ""));
vo.setSectionName(cataMap.getOrDefault(q.getSectionId(), ""));
}
return PageDTO.of(page, voList);
}
需求:在后台管理的问答列表中,管理员可以点击并查看某个问题的详细信息。
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
"查询问题详情") (
"{id}") (
public QuestionVO queryQuestionById( (value = "问题id", example = "1") ("id") Long id){
return questionService.queryQuestionById(id);
}
Service 层:
xxxxxxxxxx
private final UserClient userClient;
public QuestionVO queryQuestionById(Long id) {
// 1. 判断问题 id、问题是否为空,或被隐藏了
InteractionQuestion question = getById(id);
if(id == null || question == null || question.getHidden()) return null;
QuestionVO vo = BeanUtils.copyBean(question, QuestionVO.class);
// 2. 查询用户信息,封装 VO 并返回
if(!question.getAnonymity()){
UserDTO userDTO = userClient.queryUserById(question.getUserId());
if(userDTO != null){
vo.setUserName(userDTO.getName());
vo.setUserIcon(userDTO.getIcon());
}
}
return vo;
}
远程调用接口:
xxxxxxxxxx
package com.tianji.api.client.user;
"/users/{id}") (
UserDTO queryUserById( ("id") Long id);
需求:在管理端的互动问题列表中,管理员可以隐藏某个问题,这样就不会在用户端页面展示了。
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
"隐藏显示问题") (
"/questions/{id}/hidden/{hidden}") (
public void hiddenQuestionAdmin( ("id") Long id, ("hidden") Boolean hidden){
questionService.hiddenQuestionAdmin(id, hidden);
}
Service 层:
xxxxxxxxxx
public void hiddenQuestionAdmin(Long id, Boolean hidden) {
InteractionQuestion question = getById(id);
if(question == null){
throw new BadRequestException("该问题不存在");
}
question.setHidden(hidden);
updateById(question);
}
需求:在问题详情页面,学员可以回答问题,或者对他人的回答做评论。
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
tags = "用户端回答或评论相关接口") (
"/replies") (
public class InteractionReplyController {
"新增回答或评论") (
public void addReplyOrAnswer( ReplyDTO dto){
replyService.addReplyOrAnswer(dto);
}
}
Service 层:
xxxxxxxxxx
private final InteractionQuestionMapper questionMapper;
public void addReplyOrAnswer(ReplyDTO dto) {
// 1. 获取当前用户
Long user = UserContext.getUser();
// 2. 保存回答或评论
InteractionReply reply = BeanUtils.copyBean(dto, InteractionReply.class);
reply.setUserId(user);
InteractionQuestion question = questionMapper.selectById(dto.getQuestionId());
boolean save = save(reply);
if(!save) throw new DbException("数据库保存该回答/评论失败");
// 3. 判断是评论还是回答
if(dto.getAnswerId() == null && dto.getTargetReplyId() == null){
// 3.1 是回答,则更新 AnswerId 为 0 并累计回答次数
question.setAnswerTimes(question.getAnswerTimes()+1);
question.setLatestAnswerId(reply.getId());
} else {
// 3.2 是评论,则更新被评论对象的被评论次数
InteractionReply byId = getById(dto.getAnswerId());
byId.setReplyTimes(byId.getReplyTimes() + 1);
updateById(byId);
}
// 4. 是学生,则更新该问题状态为未查看
if(dto.getIsStudent()){
question.setStatus(QuestionStatus.UN_CHECK);
questionMapper.updateById(question);
}
}
远程调用接口:
xxxxxxxxxx
package com.tianji.learning.mapper;
public interface InteractionQuestionMapper extends BaseMapper<InteractionQuestion> {
}
需求:在问题详情页面,用户可以查看学员问题详情下的回答列表或者回答下的评论列表。
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
"分页查询回答或评论") (
"page") (
public PageDTO<ReplyVO> queryReplyOrAnswerPage(ReplyPageQuery query){
return replyService.queryReplyOrAnswerPage(query, Boolean.FALSE);
}
Service 层:
xxxxxxxxxx
private final UserClient userClient;
public PageDTO<ReplyVO> queryReplyOrAnswerPage(ReplyPageQuery query, Boolean isAdmin) {
// 判断 QuestionId 和 AnswerId 是否都为空
Long answerId = query.getAnswerId();
Long questionId = query.getQuestionId();
if(answerId == null && questionId == null){
throw new BadRequestException("错误参数,被回答id和问题id不能都为NULL");
}
// 1. 查询问题下的回答列表 或者 回答下的评论列表
Page<InteractionReply> page = lambdaQuery()
.eq(query.getQuestionId() != null, InteractionReply::getQuestionId, query.getQuestionId())
// 查询回答列表(在问题详情页面触发),或查询评论列表(在点击评论后触发)
.eq(InteractionReply::getAnswerId, query.getAnswerId() == null ? 0L : query.getAnswerId())
.eq(!isAdmin, InteractionReply::getHidden, Boolean.FALSE)
// 先按照点赞数量倒叙,相同点赞数量再按照创建时间倒叙
.page(query.toMpPage(new OrderItem(DATA_FIELD_NAME_LIKED_TIME, false),
new OrderItem(DATA_FIELD_NAME_CREATE_TIME, false)));
List<InteractionReply> replyList = page.getRecords();
if(CollUtils.isEmpty(replyList)){
return PageDTO.empty(page);
}
// 2. 远程批量查询用户信息
Set<Long> userIds = new HashSet<>();
Set<Long> replyIds = new HashSet<>();
for (InteractionReply rep : replyList) {
// 2.1 添加回复者 id
userIds.add(rep.getUserId());
// 2.2 添加被回复者 id
if(query.getAnswerId()!=null){
userIds.add(rep.getTargetUserId());
}
// 2.3 添加评论 id
replyIds.add(rep.getId());
}
Map<Long, UserDTO> userDTOMap = userClient
.queryUserByIds(userIds).stream().collect(Collectors.toMap(UserDTO::getId, c -> c));
// 3. TODO 查询用户点赞状态(入参是当前分页的评论id集合,出参是当前分页被点过赞的评论id集合)
// 4. 封装 VO 并返回
List<ReplyVO> vos = BeanUtils.copyList(replyList, ReplyVO.class);
List<ReplyVO> collect = vos.stream().map(i -> {
// 4.1设置用户信息
// 如果该回答或评论非匿名则设置用户信息
if (!i.getAnonymity() || isAdmin) {
UserDTO userDTO = userDTOMap.get(i.getUserId());
if (userDTO != null) {
i.setUserIcon(userDTO.getIcon());
i.setUserName(userDTO.getName());
i.setUserType(userDTO.getType());
}
}
// 4.2 设置被评论对象用户信息
if (i.getTargetReplyId() != 0) {
Long upId = i.getTargetReplyId();
// 得到上一级的评论
InteractionReply upReply = getById(upId);
// 上一级评论不是匿名才设置 TargetUserName
if(!upReply.getAnonymity() || isAdmin){
UserDTO userDTO = userDTOMap.get(upReply.getUserId());
if (userDTO != null) {
i.setTargetUserName(userDTO.getName());
}
}
}
// TODO 4.3 设置用户点赞信息(当前用户是否对该评论点过赞)
return i;
}).collect(Collectors.toList());
return PageDTO.of(page, collect);
}
远程调用接口:
xxxxxxxxxx
package com.tianji.api.client.user;
"/users/list") (
List<UserDTO> queryUserByIds( ("ids") Iterable<Long> ids);
需求:在后台管理页面,教师可以查看学员问题详情下的回答列表或者回答下的评论列表。
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
"分页查询回答或评论") (
"/replies/page") (
public PageDTO<ReplyVO> queryReplyOrAnswerPageAdmin(ReplyPageQuery query){
return replyService.queryReplyOrAnswerPageAdmin(query);
}
Service 层:
xxxxxxxxxx
public PageDTO<ReplyVO> queryReplyOrAnswerPageAdmin(ReplyPageQuery query) {
return queryReplyOrAnswerPage(query, Boolean.TRUE);
}
// queryReplyOrAnswerPage 方法已实现,与 C 端分页查询共用
需求:在管理端的回答或评论列表中,管理员可以隐藏回答或评论,这样就不会在用户端页面展示了。
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
"隐藏显示回答或评论") (
"/replies/{id}/hidden/{hidden}") (
public void hiddenReplyAdmin( ("id") Long id, ("hidden") Boolean hidden){
replyService.hiddenReplyAdmin(id, hidden);
}
Service 层:
xxxxxxxxxx
private final InteractionQuestionMapper questionMapper;
public void hiddenReplyAdmin(Long id, Boolean hidden) {
// 1. 查询回答或评论
InteractionReply reply = lambdaQuery() // select * from interaction_reply
.eq(InteractionReply::getId, id).one(); // where id = #{id}
if(hidden && reply.getHidden()){
throw new BadRequestException("参数错误,禁止重复提交");
}
// 2. 设置隐藏
reply.setHidden(hidden);
updateById(reply);
// 3. 设置该评论上级答案的评论数变化
int num = hidden ? -1 : 1;
if(reply.getAnswerId() != 0){ // 如果是评论,则设置答案的评论数变化
InteractionReply answer = lambdaQuery() // select * from interaction_reply where id = #{answer_id}
.eq(InteractionReply::getId, reply.getAnswerId()).one();
answer.setReplyTimes(answer.getReplyTimes() + num);
updateById(answer);
} else { // 如果是回答,则设置问题的回答数变化
InteractionQuestion question = questionMapper.selectById(reply.getQuestionId());
question.setAnswerTimes(question.getAnswerTimes() + num);
questionMapper.updateById(question);
}
}
远程调用接口:
xxxxxxxxxx
package com.tianji.learning.mapper;
public interface InteractionQuestionMapper extends BaseMapper<InteractionQuestion> {
}
xxxxxxxxxx
CREATE TABLE IF NOT EXISTS `liked_record` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键id',
`user_id` bigint NOT NULL COMMENT '用户id',
`biz_id` bigint NOT NULL COMMENT '点赞的目标id',
`biz_type` VARCHAR(16) NOT NULL COMMENT '点赞的目标类型',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_biz_user` (`biz_id`,`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='点赞记录表';
需求:用户可以给喜欢的回答、笔记等点赞,也可以取消点赞。
Controller 层:
xxxxxxxxxx
package com.tianji.remark.controller;
tags = "点赞相关接口") (
"/likes") (
public class LikedRecordController {
"点赞或取消") (
()
public void addLikeRecord( LikeRecordFormDTO dto){
likedRecordService.addLikeRecord(dto);
}
}
Service 层:
xxxxxxxxxx
private final StringRedisTemplate redisTemplate;
private final RabbitMqHelper mqHelper;
public void addLikeRecord(LikeRecordFormDTO recordDTO) {
// 1. 根据 liked 执行点赞或取消
boolean success = recordDTO.getLiked() ? like(recordDTO) : unlike(recordDTO);
// 2. 如果执行失败,则返回
if (!success) return;
// 3. 如果执行成功,则统计点赞数
Integer likedTimes = lambdaQuery() // select count(*) from liked_record where biz_id = #{BizId}
.eq(LikedRecord::getBizId, recordDTO.getBizId()).count();
// 4. 发送 MQ 通知
mqHelper.send( // send("{bizType}.times.changed", LikedTimesDTO)
LIKE_RECORD_EXCHANGE,StringUtils.format(LIKED_TIMES_KEY_TEMPLATE, recordDTO.getBizType()),
LikedTimesDTO.of(recordDTO.getBizId(), likedTimes));
}
private boolean like(LikeRecordFormDTO recordDTO) {
// 1. 查询点赞记录
Long userId = UserContext.getUser();
Integer count = lambdaQuery() // select count(*) from liked_record
.eq(LikedRecord::getUserId, userId) // where user_id = #{UserId}
.eq(LikedRecord::getBizId, recordDTO.getBizId()).count(); // and biz_id = #{BizId}
// 2. 如果已存在,则执行失败
if (count > 0) return false;
// 3. 如果不存在,则执行成功
LikedRecord r = new LikedRecord();
r.setUserId(userId);
r.setBizId(recordDTO.getBizId());
r.setBizType(recordDTO.getBizType());
save(r);
return true;
}
private boolean unlike(LikeRecordFormDTO recordDTO) {
return remove(
new QueryWrapper<LikedRecord>().lambda() // select * from liked_record
.eq(LikedRecord::getUserId, UserContext.getUser()) // where user_id = #{UserId}
.eq(LikedRecord::getBizId, recordDTO.getBizId())); // and biz_id = #{BizId}
}
远程调用接口:
xxxxxxxxxx
package com.tianji.common.autoconfigure.mq;
public <T> void send(String exchange, String routingKey, T t) {
log.debug("准备发送消息,exchange:{}, RoutingKey:{}, message:{}", exchange, routingKey, t);
// 1. 设置消息标示,用于消息确认,消息发送失败直接抛出异常,交给调用者处理
String id = UUID.randomUUID().toString(true);
CorrelationData correlationData = new CorrelationData(id);
// 2. 设置发送超时时间为 500 毫秒
rabbitTemplate.setReplyTimeout(500);
// 3. 发送消息,同时设置消息 id
rabbitTemplate.convertAndSend(exchange, routingKey, t, processor, correlationData);
}
需求:业务微服务有查询点赞状态的需求,要求:
编写查询点赞状态的接口
提供对应的 FeignClient
改造 分页查询回答或评论
接口,判断用户是否点赞并返回
Controller 层:
xxxxxxxxxx
package com.tianji.remark.controller;
"list") (
"查询指定业务id的点赞状态") (
public Set<Long> isBizLiked( ("bizIds") List<Long> bizIds){
return likedRecordService.isBizLiked(bizIds);
}
Service 层:
xxxxxxxxxx
public Set<Long> isBizLiked(List<Long> bizIds) {
Long userId = UserContext.getUser();
List<LikedRecord> list = lambdaQuery() // select * from liked_record
.in(LikedRecord::getBizId, bizIds) // where biz_id in (#{bizIds})
.eq(LikedRecord::getUserId, userId).list(); // and user_id = #{userId};
return list.stream().map(LikedRecord::getBizId).collect(Collectors.toSet()); // set(#{bizIds})
}
暴露 Feign 接口:
由于该接口是给其它微服务调用的,所以必须暴露出 Feign 客户端,并且定义好 fallback 降级处理:
xxxxxxxxxx
package com.tianji.api.client.remark;
value = "remark-service", fallbackFactory = RemarkClientFallBack.class) (
public interface RemarkClient {
"/likes/list") (
Set<Long> getLikedStatusByBizList( ("bizIds") Iterable<Long> bizIds);
}
xxxxxxxxxx
package com.tianji.api.client.remark.fallback;
public class RemarkClientFallBack implements FallbackFactory<RemarkClient> {
public RemarkClient create(Throwable cause) {
log.info("调用点赞服务失败,原因是:{}", cause.getMessage());
return new RemarkClient(){
public Set<Long> getLikedStatusByBizList(Iterable<Long> bizIds) {return null;}
};
}
}
由于每个微服务扫描包不一致,因此我们需要通过 SpringBoot 的自动加载机制来加载这些 fallback 类:
xxxxxxxxxx
package com.tianji.api.config;
public class FallbackConfig {
public LearningClientFallback learningClientFallback(){return new LearningClientFallback();}
public TradeClientFallback tradeClientFallback(){return new TradeClientFallback();}
public RemarkClientFallback remarkClientFallback(){return new RemarkClientFallback();}
}
xxxxxxxxxx
# META-INF/spring.factories
org.springframework.boot.autoconfigure.EnableAutoConfiguration= \
com.tianji.api.config.FallbackConfig
改造 分页查询回答或评论
接口:
xxxxxxxxxx
private final RemarkClient remarkClient;
public PageDTO<ReplyVO> queryReplyOrAnswerPage(ReplyPageQuery query, Boolean isAdmin) {
// 3.查询用户点赞状态(入参是当前分页的评论 id 集合,出参是当前分页被点过赞的评论 id 集合)
Set<Long> bizLiked = remarkClient.getLikedStatusByBizList(replyIds);
}
需求:在学习服务添加 MQ 监听器,监听点赞数变更的消息,更新回答的点赞数量。
xxxxxxxxxx
package com.tianji.learning.mq;
public class LikeTimesChangeListener {
private final IInteractionReplyService replyService;
bindings = ( (
value = (name = "qa.liked.times.queue", durable = "true"),
exchange = (name = LIKE_RECORD_EXCHANGE, type = ExchangeTypes.TOPIC),
key = QA_LIKED_TIMES_KEY
))
public void listenReplyLikedTimesChange(LikedTimesDTO dto){
log.debug("监听到回答或评论{}的点赞数变更:{}", dto.getBizId(), dto.getLikedTimes());
InteractionReply r = new InteractionReply();
r.setId(dto.getBizId());
r.setLikedTimes(dto.getLikedTimes());
replyService.updateById(r);
}
}
远程调用接口:
xxxxxxxxxx
package com.tianji.learning.service;
public interface IInteractionReplyService extends IService<InteractionReply> {
}
xxxxxxxxxx
public void addLikeRecord(LikeRecordFormDTO recordDTO) {
// 3. 如果执行成功,则统计点赞总数
Long likedTimes = redisTemplate.opsForSet() // SIZE bizId bizId
.size(RedisConstants.LIKES_BIZ_KEY_PREFIX + recordDTO.getBizId());
// 4. 缓存点赞总数到 Redis
redisTemplate.opsForZSet().add( // ADD bizType bizId likedTimes
RedisConstants.LIKES_TIMES_KEY_PREFIX + recordDTO.getBizType(),
recordDTO.getBizId().toString(), likedTimes);
}
当我们判断某用户是否点赞时,需要使用下面命令:SISMEMBER bizId userId
。
但这个命令只能判断一个用户对某一个业务的点赞状态。而我们的接口是要查询当前用户对多个业务的点赞状态。
Redis 中提供了一个功能 Pipeline,可以在一次请求中执行多个命令,实现批处理效果:
xxxxxxxxxx
public Set<Long> isBizLiked(List<Long> bizIds) {
// 2. 查询点赞状态
List<Object> objects = redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
StringRedisConnection src = (StringRedisConnection) connection;
for (Long bizId : bizIds) { // SISMEMBER bizId userId
src.sIsMember(RedisConstants.LIKES_BIZ_KEY_PREFIX + bizId, userId.toString());
}
return null;
});
// 3. 返回结果
return IntStream.range(0, objects.size()) // 创建从 0 到集合 size 的流
.filter(i -> (boolean) objects.get(i)) // 遍历每个元素,保留结果为 true 的角标 i
.mapToObj(bizIds::get) // 根据 i 取 bizIds 的数据,即点赞过的 id
.collect(Collectors.toSet()); // 封装为 set 集合
}
定时任务的实现方案有很多,简单的例如:SpringTask、Quartz。
还有一些依赖第三方服务的分布式任务框架:Elastic-Job、XXL-Job。此处使用简单的 SpringTask 来实现并测试效果。
在 tj-remark 模块的启动类上添加注解:
xxxxxxxxxx
// 其作用就是启用 Spring 的定时任务功能
定义一个定时任务处理器类:
xxxxxxxxxx
package com.tianji.remark.task;
public class LikedTimesCheckTask {
// 由于可能存在多个业务类型,不能厚此薄彼只处理部分业务。所以我们会遍历多种业务类型,分别处理。
private static final List<String> BIZ_TYPES = List.of("QA", "NOTE");
// 为了避免一次处理的业务过多,这里设定了每次处理的业务数量为30,当然这些都是可以调整的。
private static final int MAX_BIZ_SIZE = 30;
private final ILikedRecordService recordService;
fixedDelay = 20000) (
public void checkLikedTimes(){
for (String bizType : BIZ_TYPES) {
recordService.readLikedTimesAndSendMessage(bizType, MAX_BIZ_SIZE);
}
}
}
调用接口:
xxxxxxxxxx
package com.tianji.remark.service.impl;
public void readLikedTimesAndSendMessage(String bizType, int maxBizSize) {
// 1. 读取并移除 Redis 中缓存的点赞总数
String key = RedisConstants.LIKES_TIMES_KEY_PREFIX + bizType;
Set<ZSetOperations.TypedTuple<String>> tuples = redisTemplate.opsForZSet().popMin(key, maxBizSize);
if (CollUtils.isEmpty(tuples)) return;
// 2. 数据转换
List<LikedTimesDTO> list = new ArrayList<>(tuples.size());
for (ZSetOperations.TypedTuple<String> tuple : tuples) {
String bizId = tuple.getValue();
Double likedTimes = tuple.getScore();
if (bizId == null || likedTimes == null) continue;
list.add(LikedTimesDTO.of(Long.valueOf(bizId), likedTimes.intValue()));
}
// 3. 发送 MQ 消息
mqHelper.send(
LIKE_RECORD_EXCHANGE,
StringUtils.format(LIKED_TIMES_KEY_TEMPLATE, bizType), list);
}
xxxxxxxxxx
package com.tianji.learning.mq;
public class LikeTimesChangeListener {
public void listenReplyLikedTimesChange(List<LikedTimesDTO> likedTimesDTOs){
log.debug("监听到回答或评论的点赞数变更");
List<InteractionReply> list = new ArrayList<>(likedTimesDTOs.size());
for (LikedTimesDTO dto : likedTimesDTOs) {
InteractionReply r = new InteractionReply();
r.setId(dto.getBizId());
r.setLikedTimes(dto.getLikedTimes());
list.add(r);
}
replyService.updateBatchById(list);
}
}
答:首先在设计之初我们分析了一下点赞业务可能需要的一些要求。
在我们项目中需要用到点赞的业务不止一个,因此点赞系统必须具备通用性,独立性,不能跟具体业务耦合。
点赞业务可能会有较高的并发,我们要考虑到高并发写库的压力问题。(此处停顿,等待追问)
具体实现细节:
我们将点赞功能抽离出来作为独立服务,当然服务中除了点赞功能以外,还有与之关联的评价功能,不过这部分我没有参与了。
在数据层面也会用业务类型对不同点赞数据做隔离。从具体实现上来说,为了减少数据库压力:
首先利用 Redis 来保存点赞记录、点赞数量。然后,利用定时任务将点赞数量同步给业务方,持久化到数据库中。
答:我们使用了两种数据结构,SET 和 ZSET。
首先保存点赞记录,使用了 set 结构,key 是 业务type + 业务id
,value 是 点赞的用户id
。
当用户点赞就用 SADD
,当用户取消点赞就用 SREM
,当判断是否点赞就用 SISMEMBER
,当统计点赞数量时就用 SCARD
。
而 SET 结构在头信息中保存元素数量,因此 SCARD 直接读取该值,时间复杂度 O(1)
,性能非常好。(此处停顿,等待追问)
为什么不让用户 id 为 key,业务 id 为 value 呢?如果用户量很大,可能出现 BigKey 吧?
答:考虑到我们的项目数据量并不会很大,因此点赞数量通常不会超过 1000,因此不会出现 BigKey。
但如果考虑到有大用户量的场景,有两种选择:一种是选择您说的这种方案;
另一种则是对用户 id 做 hash 分片,将大 V 的 key 进行拆分,结构为 业务type + 业务id + 用户id高8位
。
不过页面需要判断用户有没有对某些业务点赞,会传来多个业务 id 的集合,而 SISMEMBER
只能一次判断一个业务的点赞状态。
要判断多个业务的点赞状态,就必须多次调用命令,与 Redis 多次交互,这显然是不合适的。(此处停顿,等待追问)
所以,我们就采用了 Pipeline
管道方式,这样就可以一次请求实现多个业务点赞状态的判断了。
那你 ZSET 干什么用的?
答:如果只有 SET,我们没办法知道哪些业务的点赞数发生了变化。因此,我们用 ZSET 来记录点赞数变化的业务及对应的点赞总数。
可以理解为一个待持久化的点赞任务队列。每当业务被点赞,不仅要缓存点赞记录,还要把 业务id
及 点赞总数
写入 ZSET。
这样定时任务开启时,只需要从 ZSET 中获取并移除数据,然后发送 MQ 给业务方,并持久化到数据库即可。
答:扔到 List 结构中虽然也能实现,但是存在一些问题:
一个业务如果在定时间隔内多次被点赞,那就会多次向 List 中添加点赞总数,数据库也要持久化多次,但只有最后一次才有效。
而使用 ZSET 是因为 member
的唯一性,多次添加会覆盖旧的点赞数量,最终只会持久化一次。(此处停顿,等待追问)
那就改为 SET 结构,SET 中只放业务 id,业务方收到 MQ 通知后再次查询不就行了。
答:这样会导致多次网络通信,增加系统网络负担。而 ZSET 则可以同时保存业务 id 及最新点赞数量,避免多次网络查询。
业务 | 编号 | 接口简述 |
---|---|---|
签到 | 1 | 签到 |
2 | 查询签到记录 | |
积分 | 3 | 新增积分记录 |
4 | 查询今日积分情况 | |
5 | 查询赛季列表 | |
排行榜 | 6 | 查询实时赛季积分榜 |
7 | 查询历史赛季积分榜 |
签到记录:
xxxxxxxxxx
CREATE TABLE `sign_record` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` bigint NOT NULL COMMENT '用户id',
`year` year NOT NULL COMMENT '签到年份',
`month` tinyint NOT NULL COMMENT '签到月份',
`date` date NOT NULL COMMENT '签到日期',
`is_backup` bit(1) NOT NULL COMMENT '是否补签',
PRIMARY KEY (`id`),
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='签到记录表';
积分记录:
xxxxxxxxxx
CREATE TABLE `points_record` (
`id` BIGINT(19) NOT NULL AUTO_INCREMENT COMMENT '积分记录表id',
`user_id` BIGINT(19) NOT NULL COMMENT '用户id',
`type` TINYINT(3) NOT NULL COMMENT '积分方式:1-课程学习,2-每日签到,3-课程问答, 4-课程笔记,5-课程评价',
`points` TINYINT(3) NOT NULL COMMENT '积分值',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE
)COMMENT='学习积分记录,每个月底清零';
排行榜记录:
xxxxxxxxxx
CREATE TABLE `points_board` (
`id` BIGINT(19) NOT NULL COMMENT '榜单id',
`user_id` BIGINT(19) NOT NULL COMMENT '学生id',
`points` INT(10) NOT NULL COMMENT '积分值',
`rank` TINYINT(3) NOT NULL COMMENT '名次',
`season` SMALLINT(5) NOT NULL COMMENT '赛季id',
PRIMARY KEY (`id`) USING BTREE
)COMMENT='学霸天梯榜';
CREATE TABLE `points_board_season` (
`id` INT(10) NOT NULL AUTO_INCREMENT COMMENT '自增长id,season标示',
`name` VARCHAR(32) NULL DEFAULT NULL COMMENT '赛季名称,例如:第1赛季',
`begin_time` DATE NOT NULL COMMENT '赛季开始时间',
`end_time` DATE NOT NULL COMMENT '赛季结束时间',
PRIMARY KEY (`id`) USING BTREE
)ENGINE=InnoDB;
我们按月来统计用户签到信息,签到记录为 1,未签到则记录为 0。 把每一个 bit 位对应当月的每一天,形成了映射关系。用 0 和 1 标示业务状态,这种思路就称为位图(BitMap)。
Redis 中提供了 BitMap 数据结构,并且提供了很多操作 bit 的命令。 BitMap 基于 String 结构,而 String 类型的最大空间是 512MB(2^31 个 bit),因此保存数据的量级是十分恐怖的。
修改 BitMap 中的数据:
xxxxxxxxxx
SETBIT bm 0 1 # 第 1 天签到
SETBIT bm 7 1 # 第 8 天签到
读取 BitMap 中的数据:
xxxxxxxxxx
# 例:bm = 10110000, 查询从第 1~4 天的签到记录:
BITFIELD bm GET u4 0 # 无符号数,返回 (1011)2 = 11
BITFIELD bm GET i4 0 # 有符号数,返回 (1101)2 = -5
需求:在个人中心的积分页面,每天都可以签到一次,连续签到有积分奖励。请实现签到接口,记录用户每天签到信息。
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
tags = "签到相关接口") (
"sign-records") (
public class SignRecordController {
"签到接口") (
public SignResultVO addSignRecords(){
return recordService.addSignRecords();
}
}
Service 层:
xxxxxxxxxx
public SignResultVO addSignRecords() {
// 1. 签到
Long userId = UserContext.getUser();
LocalDate now = LocalDate.now(); // key = sign:uid:userId:yyyyMM
String key = RedisConstants.SIGN_RECORD_KEY_PREFIX + userId
+ now.format(DateUtils.SIGN_DATE_SUFFIX_FORMATTER);
int offset = now.getDayOfMonth() - 1; // offset
Boolean exists = redisTemplate.opsForValue().setBit(key, offset, true);
if (BooleanUtils.isTrue(exists)) { // SETBIT key offset true
throw new BizIllegalException("不允许重复签到!");
}
// 2. 计算连续签到天数
int signDays = countSignDays(key, now.getDayOfMonth());
// 3. 计算签到得分
int rewardPoints = 0;
switch (signDays) {
case 7: // 连续签到一周, 奖励积分 10
rewardPoints = 10;
break;
case 14: // 连续签到两周, 奖励积分 20
rewardPoints = 20;
break;
case 28: // 连续签到四周, 奖励积分 40
rewardPoints = 40;
break;
}
// TODO 4. 保存积分明细记录
// 5. 封装 VO 并返回
SignResultVO vo = new SignResultVO();
vo.setSignDays(signDays);
vo.setRewardPoints(rewardPoints);
return vo;
}
xxxxxxxxxx
private int countSignDays(String key, int len) {
// 1. 获取本月从第一天开始,到今天为止的所有签到记录
List<Long> result = redisTemplate.opsForValue().bitField(key,
BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(len)).valueAt(0));
if (CollUtils.isEmpty(result)) { // BITFIELD key GET ulen 0
return 0;
} // 例: len = 8, result = {00010111, ...}
int num = result.get(0).intValue();
int count = 0; // num = 00010111
// 2. 循环与 1 做与运算,判断最后一个 bit,为 0 则终止
while ((num & 1) == 1) {
count++;
num >>>= 1;
}
return count; // count = 3, 即连续签到3天
}
需求:在个人中心,我的积分页面需要展示用户的签到记录。设计一个接口,返回当前用户本月到今天为止的所有签到记录。
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
"查询签到记录接口") (
public Deque<Integer> querySignRecords(){
return recordService.querySignRecords();
}
Service 层:
xxxxxxxxxx
public Deque<Integer> querySignRecords() {
// 1. 获取登录用户
Long user = UserContext.getUser();
// 2. 获取 key = sign:uid:userId:yyyyMM
LocalDate now = LocalDate.now();
String format = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = RedisConstants.SIGN_RECORD_KEY_PREFIX + user + format;
// 3. 获取本月签到记录
Deque<Integer> list = getDaysOfMonth(now.getDayOfMonth(), key);
return list;
}
xxxxxxxxxx
private Deque<Integer> getDaysOfMonth(String key, int len) {
// 1. 获取 bitMap 记录值
List<Long> list = redisTemplate.opsForValue().bitField(key, // BITFIELD key GET ulen 0
BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(len)).valueAt(0));
Long num = list.get(0); // 例:len = 8, num = 00010111
// 2. Long 值转 List<Integer>
String s = Long.toBinaryString(num);
Deque<Integer> arr = new LinkedList<>(); // s = "10111", s.length() = 5
for (char c : s.toCharArray()) {
arr.addLast(c == '1'? 1 : 0);
}
for(int i=0; i<len - s.length(); i++){ // i: 0 -> 2
arr.addFirst(0);
}
return arr; // arr = [0,0,0,1,0,1,1,1]
}
需求:用户签到、学习、回答问题、写笔记、笔记被采集等行为都可以产生积分,并基于积分形成排行榜。
虽然我们积分功能目前是在学习中心实现的,不过考虑的以后的扩展性,此处我们还是考虑使用 MQ 来实现异步解耦:
先定义一个 MQ 消息体:
xxxxxxxxxx
package com.tianji.learning.mq.message;
staticName = "of") (
public class SignInMessage {
private Long userId;
private Integer points;
}
然后改造签到功能:
xxxxxxxxxx
private final RabbitMqHelper mqHelper;
public SignResultVO addSignRecords() {
// 4. 保存积分明细记录
mqHelper.send(
MqConstants.Exchange.LEARNING_EXCHANGE, // learning.topic
MqConstants.Key.SIGN_IN, // key = sign.in
SignInMessage.of(userId, rewardPoints + 1)); // message = (userId, points)
}
xxxxxxxxxx
package com.tianji.learning.mq;
public class LearningPointsListener {
// 监听回答问题事件, 积分 +5
bindings = ( (
value = (name = "qa.points.queue", durable = "true"),
exchange = (name = MqConstants.Exchange.LEARNING_EXCHANGE, type = ExchangeTypes.TOPIC),
key = MqConstants.Key.WRITE_REPLY // key = reply.new, learning.topic, message = (userId)
))
public void listenWriteReplyMessage(Long userId){
recordService.addPointsRecord(userId, 5, PointsRecordType.QA);
}
// 监听签到事件, 积分 +points
bindings = ( (
value = (name = "sign.points.queue", durable = "true"),
exchange = (name = MqConstants.Exchange.LEARNING_EXCHANGE, type = ExchangeTypes.TOPIC),
key = MqConstants.Key.SIGN_IN // key = sign.in, learning.topic, message = (userId, points)
))
public void listenSignInMessage(SignInMessage message){
recordService.addPointsRecord(message.getUserId(), message.getPoints(), PointsRecordType.SIGN);
}
}
Service 层:
xxxxxxxxxx
public void addPointsRecord(Long userId, int points, PointsRecordType type) {
// 1. 判断是否有积分上限
LocalDateTime now = LocalDateTime.now();
int maxPoints = type.getMaxPoints();
int realPoints = points;
if(maxPoints > 0) {
// 2. 有积分上限,则查询今日已得积分
LocalDateTime begin = DateUtils.getDayStartTime(now);
LocalDateTime end = DateUtils.getDayEndTime(now);
int currentPoints = queryUserPointsByTypeAndDate(userId, type, begin, end);
// 3. 判断是否超过每日上限
if(currentPoints >= maxPoints) {
return;
}
if(points + currentPoints > maxPoints){
realPoints = maxPoints - currentPoints;
}
}
// 4. 没有积分上限,则直接保存积分记录
PointsRecord p = new PointsRecord();
p.setPoints(realPoints);
p.setUserId(userId);
p.setType(type);
save(p);
}
xxxxxxxxxx
private int queryUserPointsByTypeAndDate(
Long userId, PointsRecordType type, LocalDateTime begin, LocalDateTime end) {
QueryWrapper<PointsRecord> wrapper = new QueryWrapper<>();
wrapper.lambda() // select * from points_record
.eq(PointsRecord::getUserId, userId) // where user_id = #{userId}
.eq(type != null, PointsRecord::getType, type) // and type is not null and type = #{type}
// and begin is not null and end is not null and createTime between begin and end;
.between(begin != null && end != null, PointsRecord::getCreateTime, begin, end);
Integer points = getBaseMapper().queryUserPointsByTypeAndDate(wrapper);
return points == null ? 0 : points; // SELECT SUM(points) FROM points_record ${wrapper}
}
Mapper 层:
xxxxxxxxxx
public interface PointsRecordMapper extends BaseMapper<PointsRecord> {
"SELECT SUM(points) FROM points_record ${ew.customSqlSegment}") (
Integer queryUserPointsByTypeAndDate( (Constants.WRAPPER) QueryWrapper<PointsRecord> wrapper);
}
需求:在个人中心,用户可以查看当天各种不同类型的已获得的积分和积分上限。
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
"查询今日积分情况") (
"today") (
public List<PointsStatisticsVO> queryMyPointsToday(){
return pointsRecordService.queryMyPointsToday();
}
Service 层:
xxxxxxxxxx
public List<PointsStatisticsVO> queryMyPointsToday() {
// 1. 查询今日积分情况
Long userId = UserContext.getUser();
LocalDateTime now = LocalDateTime.now();
LocalDateTime begin = DateUtils.getDayStartTime(now);
LocalDateTime end = DateUtils.getDayEndTime(now);
QueryWrapper<PointsRecord> wrapper = new QueryWrapper<>();
wrapper.lambda() // select * from points_record
.eq(PointsRecord::getUserId, userId) // where user_id = #{userId}
.between(PointsRecord::getCreateTime, begin, end); // and createTime between begin and end;
List<PointsRecord> list = getBaseMapper().queryUserPointsByDate(wrapper);
// SELECT type, SUM(points) AS points FROM points_record ${wrapper} GROUP BY type
if (CollUtils.isEmpty(list)) {
return CollUtils.emptyList();
}
// 2. 封装 VO 集合并返回
List<PointsStatisticsVO> vos = new ArrayList<>(list.size());
for (PointsRecord p : list) {
PointsStatisticsVO vo = new PointsStatisticsVO();
vo.setType(p.getType().getDesc());
vo.setMaxPoints(p.getType().getMaxPoints());
vo.setPoints(p.getPoints());
vos.add(vo);
}
return vos;
}
Mapper 层:
xxxxxxxxxx
public interface PointsRecordMapper extends BaseMapper<PointsRecord> {
"SELECT type, SUM(points) AS points FROM points_record ${ew.customSqlSegment} GROUP BY type") (
List<PointsRecord> queryUserPointsByDate( (Constants.WRAPPER) QueryWrapper<PointsRecord> wrapper);
}
需求:在历史赛季榜单中,有一个下拉选框,可以选择历史赛季信息。
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
"/boards") (
tags = "积分相关接口") (
public class PointsBoardController {
"查询赛季信息列表") (
"/seasons/list") (
public List<PointsBoardSeasonVO> queryPointsBoardSeasons(){
List<PointsBoardSeason> list = seasonService
.lambdaQuery().lt(PointsBoardSeason::getBeginTime, LocalDateTime.now()).list();
if(CollUtils.isEmpty(list)){
return CollUtils.emptyList();
}
return BeanUtils.copyList(list, PointsBoardSeasonVO.class);
}
}
Service 层:
xxxxxxxxxx
public class PointsBoardSeasonServiceImpl extends ServiceImpl<PointsBoardSeasonMapper, PointsBoardSeason>
implements IPointsBoardSeasonService {
}
Mapper 层:
xxxxxxxxxx
public interface PointsBoardSeasonMapper extends BaseMapper<PointsBoardSeason> {
}
定义 Redis 的 KEY 前缀:
xxxxxxxxxx
package com.tianji.learning.constants;
public interface RedisConstants {
String POINTS_BORAD_KEY_PREFIX = "boards:";
}
xxxxxxxxxx
package com.tianji.common.utils;
public class DateUtils extends LocalDateTimeUtil {
public static final DateTimeFormatter POINTS_BOARD_SUFFIX_FORMATTER =
DateTimeFormatter.ofPattern("yyyyMM");
}
修改 保存积分明细
接口:
xxxxxxxxxx
private final StringRedisTemplate redisTemplate;
public void addPointsRecord(Long userId, int points, PointsRecordType type) {
// 4. 更新总积分到 Redis
String key = RedisConstants.POINTS_BOARD_KEY_PREFIX + // key = boards:yyyyMM
now.format(DateUtils.POINTS_BOARD_SUFFIX_FORMATTER); // ZINCRBY key realPoints userId
redisTemplate.opsForZSet().incrementScore(key, userId.toString(), realPoints);
}
需求:在个人中心,学生可以查看指定赛季积分排行榜(只显示前100 ),还可以查看自己总积分和排名。
Controller 层:
xxxxxxxxxx
package com.tianji.learning.controller;
"查询积分榜") (
public PointsBoardVO queryPointsBoardBySeason(PointsBoardQuery query){
return pointsBoardService.queryPointsBoardBySeason(query);
}
Service 层:
xxxxxxxxxx
private final StringRedisTemplate redisTemplate;
private final UserClient userClient;
public PointsBoardVO queryPointsBoardBySeason(PointsBoardQuery query) {
// 1. 判断是否是查询当前赛季
Long season = query.getSeason();
boolean isCurrent = season == null || season == 0;
// 2. 查询我的积分和排名
LocalDateTime now = LocalDateTime.now(); // key = boards:yyyyMM
String key = RedisConstants.POINTS_BOARD_KEY_PREFIX +
now.format(DateUtils.POINTS_BOARD_SUFFIX_FORMATTER);
PointsBoard myBoard = isCurrent ? // 查询当前榜单(Redis): 查询历史榜单(MySQL)TODO
queryMyCurrentBoard(key) : queryMyHistoryBoard(season);
// 3. 查询榜单列表
List<PointsBoard> list = isCurrent ? // 查询当前榜单列表 : 查询历史榜单列表 TODO
queryCurrentBoardList(key, query.getPageNo(), query.getPageSize()) : queryHistoryBoardList(query);
// 4. 封装 VO 并返回
PointsBoardVO vo = new PointsBoardVO();
// 4.1. 处理我的信息
if (myBoard != null) {
vo.setPoints(myBoard.getPoints());
vo.setRank(myBoard.getRank());
}
if (CollUtils.isEmpty(list)) return vo;
// 4.2. 查询用户信息 // select * from points_board where user_Id = #{userId}
Set<Long> uIds = list.stream().map(PointsBoard::getUserId).collect(Collectors.toSet());
List<UserDTO> users = userClient.queryUserByIds(uIds);
Map<Long, String> userMap = new HashMap<>(uIds.size());
if(CollUtils.isNotEmpty(users)) { // userMap = toMap(id, name)
userMap = users.stream().collect(Collectors.toMap(UserDTO::getId, UserDTO::getName));
}
// 4.3. 封装 VO
List<PointsBoardItemVO> items = new ArrayList<>(list.size());
for (PointsBoard p : list) {
PointsBoardItemVO v = new PointsBoardItemVO();
v.setPoints(p.getPoints());
v.setRank(p.getRank());
v.setName(userMap.get(p.getUserId()));
items.add(v);
}
vo.setBoardList(items);
return vo;
}
xxxxxxxxxx
private PointsBoard queryMyCurrentBoard(String key) {
BoundZSetOperations<String, String> ops = redisTemplate.boundZSetOps(key);
String userId = UserContext.getUser().toString();
Double points = ops.score(userId); // ZSCORE key userId 获取有序集合
Long rank = ops.reverseRank(userId); // ZREVRANK key userId 逆序排名积分
PointsBoard p = new PointsBoard();
p.setPoints(points == null ? 0 : points.intValue());
p.setRank(rank == null ? 0 : rank.intValue() + 1);
return p;
}
xxxxxxxxxx
public List<PointsBoard> queryCurrentBoardList(String key, Integer pageNo, Integer pageSize) {
// 1. 计算分页
int from = (pageNo - 1) * pageSize;
// 2. 查询当前榜单列表: ZREVRANGE key from to WITHSCORES
Set<ZSetOperations.TypedTuple<String>> tuples = redisTemplate.opsForZSet()
.reverseRangeWithScores(key, from, from + pageSize - 1);
if (CollUtils.isEmpty(tuples)) {
return CollUtils.emptyList();
}
// 3. 封装到 list
int rank = from + 1;
List<PointsBoard> list = new ArrayList<>(tuples.size());
for (ZSetOperations.TypedTuple<String> tuple : tuples) {
String userId = tuple.getValue();
Double points = tuple.getScore();
if (userId == null || points == null) continue;
PointsBoard p = new PointsBoard();
p.setUserId(Long.valueOf(userId));
p.setPoints(points.intValue());
p.setRank(rank++);
list.add(p);
}
return list;
}
远程调用接口:
xxxxxxxxxx
"/users/list") (
List<UserDTO> queryUserByIds( ("ids") Iterable<Long> ids);
分区:
分区是一种数据存储方案,可以解决单表数据较多的问题。MySQL5.1 开始支持表分区功能。
优点:提高数据检索、统计的性能;打破磁盘容量限制;根据分区清理数据效率高。
缺点:分区字段必须是索引的一部分或全部;分区方式不够灵活;只支持水平分区。
分表:
分表是一种表设计方案,由开发者在创建表时按照自己的业务需求拆分表。
优点:解决了字段多和数据多引起的各种问题;分区方式更灵活。
缺点:需要判断操作哪张表;垂直分表需要处理事务问题、数据关联问题;水平分表要处理聚合操作的数据合并问题。
分库和集群:
按照业务垂直分库
优点:避免了不同服务间数据耦合;请求分流,提高了整体的并发能力。
缺点:热点服务容易出现单点故障;部分库依然存在瓶颈;有分布式事务问题。
主从备份读写分离
优点:解决了海量数据存储的问题;提高了读写并发能力,避免了单点故障。
缺点:分片后数据聚合统计比较复杂;会有主从数据同步问题;存在分布式事务问题。
解决单表数据量大的问题有哪些方案?
首先是库内表分区或分表,可以解决大多数问题。如果单个库压力太大,再考虑分库。
水平分库结合分表,实现数据分片。进一步提高数据存储规模。
数据库的读写压力较大,并发较高该怎么办?
首先考虑垂直分表,看看能不能将写频繁的数据与其它数据分离,避免互相影响。
如果不行则考虑搭建主从集群,实现读写分离。
历史榜单数据量单表可能达到数千万,如何解决?
我们按照赛季对历史榜单分表,减少了单表存储量。而且根据赛季查询时只需要读一张表,提高了查询效率。 另外,我们可以按照榜单顺序持久化,采用递增 id,那么榜单 id 就是用户排名。避免了查询时的排序处理,查询效率大大提高。
历史赛季表是如何创建的?
在每个月初通过定时任务调用,完成上一赛季的表创建。
定时任务:
xxxxxxxxxx
package com.tianji.learning.handler;
public class PointsBoardPersistentHandler {
private final IPointsBoardSeasonService seasonService;
private final IPointsBoardService pointsBoardService;
cron = "0 0 3 1 * ?") // 每月 1 号,凌晨 3 点执行 (
public void createPointsBoardTableOfLastSeason(){
// 1. 获取上月时间
LocalDateTime time = LocalDateTime.now().minusMonths(1);
// 2. 查询赛季 id
Integer season = seasonService.querySeasonByTime(time);
if (season == null) return;
// 3. 创建榜单表
pointsBoardService.createPointsBoardTableBySeason(season);
}
}
查询赛季 id:
xxxxxxxxxx
public Integer querySeasonByTime(LocalDateTime time) {
Optional<PointsBoardSeason> optional = lambdaQuery() // select * from points_board_season
.le(PointsBoardSeason::getBeginTime, time) // where begin_time <= #{time}
.ge(PointsBoardSeason::getEndTime, time).oneOpt(); // and end_time >= #{time} limit 1
return optional.map(PointsBoardSeason::getId).orElse(null);
}
创建榜单表:
xxxxxxxxxx
public void createPointsBoardTableBySeason(Integer season) {
getBaseMapper().createPointsBoardTable(POINTS_BOARD_TABLE_PREFIX + season);
}
Mapper 层:
xxxxxxxxxx
void createPointsBoardTable( ("tableName") String tableName);
xxxxxxxxxx
<insert id="createPointsBoardTable" parameterType="java.lang.String">
CREATE TABLE `${tableName}` <!-- tableName = points_board_season -->
(
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '榜单id',
`user_id` BIGINT NOT NULL COMMENT '学生id',
`points` INT NOT NULL COMMENT '积分值',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_user_id` (`user_id`) USING BTREE
)
COMMENT ='学霸天梯榜'
COLLATE = 'utf8mb4_0900_ai_ci'
ENGINE = InnoDB
ROW_FORMAT = DYNAMIC
</insert>
XXL-JOB 是一个分布式任务调度平台,其设计目标是开发迅速、学习简单、轻量级、易扩展。已接入多家公司线上产品线,开箱即用。
部署调度中心:
运行资料中提供的,初始化 SQL 文件,创建 XXL-JOB 所需表。参考以下 Docker 命令创建容器:
xxxxxxxxxx
docker run -d \
-e PARAMS="--spring.datasource.url=jdbc:mysql://192.168.150.101:3306/xxl_job?Unicode=true&characterEncoding=UTF-8 \
--spring.datasource.username=root \
--spring.datasource.password=123" \
--restart=always \
-p 8880:8080 \
-v xxl-job-admin-applogs:/data/applogs \
--name xxl-job-admin \
xuxueli/xxl-job-admin:2.3.0
集成执行器:
首先引入 XXL-JOB 依赖,配置执行器:(了解)
xxxxxxxxxx
<dependency>
<groupId>com.xuxueli</groupId>
<artifactId>xxl-job-core</artifactId>
</dependency>
xxxxxxxxxx
package com.tianji.common.autoconfigure.xxljob;
public class XxlJobConfig {...}
xxxxxxxxxx
package com.tianji.common.autoconfigure.xxljob;
public class XxlJobProperties {...}
然后,在 PointsBoardPersistentHandler 中将任务的 @Scheduled
注解替换为 @XXLJob
注解:
xxxxxxxxxx
package com.tianji.learning.task;
"createTableJob") (
public void createPointsBoardTableOfLastSeason(){...}
注册执行器:登录 XXL-JOB 控制台,注册执行器:
配置任务调度:进入任务管理菜单,选中 学习中心执行器
,然后新增任务:
需求:每个月第一天凌晨,将 Redis 中的上月榜单数据持久化到数据库中,并清理 Redis 数据。
动态表名:
定义一个传递表名称的工具:
xxxxxxxxxx
package com.tianji.learning.utils;
public class TableInfoContext {
private static final ThreadLocal<String> TL = new ThreadLocal<>();
public static void setInfo(String info) {TL.set(info);}
public static String getInfo() {return TL.get();}
public static void remove() {TL.remove();}
}
定义一个配置类:
xxxxxxxxxx
package com.tianji.learning.config;
public class MybatisConfiguration {
public DynamicTableNameInnerInterceptor dynamicTableNameInnerInterceptor() {
// 准备一个Map,用于存储 TableNameHandler
Map<String, TableNameHandler> map = new HashMap<>(1);
// 存入一个 TableNameHandler,用来替换 points_board 表名称
map.put("points_board", (sql, tableName) ->
TableInfoContext.getInfo() == null ? tableName : TableInfoContext.getInfo());
return new DynamicTableNameInnerInterceptor(map);
}
}
修改 MybatisConfig 的拦截器配置:
xxxxxxxxxx
package com.tianji.common.autoconfigure.mybatis;
public MybatisPlusInterceptor mybatisPlusInterceptor(
required = false) DynamicTableNameInnerInterceptor dynamicTableNameInnerInterceptor) { (
// 定义插件主体,注意顺序:表名 > 多租户 > 分页 > 乐观锁 > 字段填充
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
if (dynamicTableNameInnerInterceptor != null) {
interceptor.addInnerInterceptor(dynamicTableNameInnerInterceptor);
}
PaginationInnerInterceptor paginationInnerInterceptor
= new PaginationInnerInterceptor(DbType.MYSQL);
paginationInnerInterceptor.setMaxLimit(200L);
interceptor.addInnerInterceptor(paginationInnerInterceptor); //分页插件
interceptor.addInnerInterceptor(new MyBatisAutoFillInterceptor()); //自动填充插件
return interceptor;
}
定时持久化任务:
xxxxxxxxxx
package com.tianji.learning.handler;
"savePointsBoard2DB") (
public void savePointsBoard2DB(){
// 1. 获取上月时间
LocalDateTime time = LocalDateTime.now().minusMonths(1);
// 2. 将动态表名 points_board_season 存入 ThreadLocal
Integer season = seasonService.querySeasonByTime(time);
TableInfoContext.setInfo(POINTS_BOARD_TABLE_PREFIX + season);
// 3. 查询榜单数据, key = points_board_yyyyMM
String key = RedisConstants.POINTS_BOARD_KEY_PREFIX
+ time.format(DateUtils.POINTS_BOARD_SUFFIX_FORMATTER);
int pageNo = 1;
int pageSize = 1000;
while (true) {
List<PointsBoard> boardList = pointsBoardService.queryCurrentBoardList(key, pageNo, pageSize);
if (CollUtils.isEmpty(boardList)) break;
// 4. 持久化到数据库
boardList.forEach(b -> {
b.setId(b.getRank().longValue());
b.setRank(null);
});
pointsBoardService.saveBatch(boardList);
pageNo++;
}
// 5. 任务结束,移除动态表名
TableInfoContext.remove();
}
XXL-JOB 任务分片:
刚才定义的定时持久化任务,通过 while 死循环,不停的查询数据,直到把所有数据都持久化为止。 如果数据量达到数百万,将来肯定会将学习服务多实例部署,这样就会有多个执行器并行执行。 但是如果交给多个任务执行器,大家都从第 1 页逐页处理数据,又会重复处理。怎样才能不重复呢?可以参考扑克牌发牌的原理:
要想知道每一个执行器执行哪些页数据,只要弄清楚两个关键参数即可:分页起始页码 index、分页跨度 total。
xxxxxxxxxx
package com.tianji.learning.handler;
"savePointsBoard2DB") (
public void savePointsBoard2DB(){
// 3. 查询榜单数据
int index = XxlJobHelper.getShardIndex();
int total = XxlJobHelper.getShardTotal();
int pageNo = index + 1;
int pageSize = 10;
while (true) { // ...
pageNo += total;
}
}
清理 Redis 缓存任务:
当任务持久化以后,我们还要清理 Redis 中的上赛季的榜单数据,避免过多的内存占用:
xxxxxxxxxx
package com.tianji.learning.handler;
public class PointsBoardPersistentHandler {
"clearPointsBoardFromRedis") (
public void clearPointsBoardFromRedis(){
LocalDateTime time = LocalDateTime.now().minusMonths(1);
String key = RedisConstants.POINTS_BOARD_KEY_PREFIX
+ time.format(DateUtils.POINTS_BOARD_SUFFIX_FORMATTER);
redisTemplate.unlink(key);
}
}
任务链:
进入任务管理页面,添加 3 个任务(createPointsBoardTable
、savePointsBoard2DB
、clearPointsBoardFromRedis
):
设置创建历史榜单表(ID = 10)的子任务是持久化榜单数据任务(ID = 12)
设置持久化榜单数据任务(ID = 12)的子任务是清理 Redis 中的历史榜单(ID = 13)
xxxxxxxxxx
package com.tianji.learning.service.impl;
public class PointsBoardServiceImpl implements IPointsBoardService {
// 查询用户历史指定赛季积分和排名,fromDB
private PointsBoard queryMyHistoryPoints(Long seasonId, Long userId) {
if(seasonId == null || userId == null){
throw new BadRequestException("错误参数");
}
PointsBoard one = lambdaQuery() // select * from points_board
.eq(PointsBoard::getSeason, seasonId) // where season = #{seasonId}
.eq(PointsBoard::getUserId, userId).one(); // and season_id = #{seasonId} limit 1;
return one;
}
// 分页查询历史赛季排行榜,fromDB
private List<PointsBoard> queryHistoryPoints(
Long seasonId, (value = 1, message = "页码不能小于1")
Integer pageNo, (value = 1, message = "每页查询数量不能小于1") Integer pageSize) {
if (seasonId == null) {
throw new BadRequestException("查询历史赛季排行榜失败,赛季id不能为空");
}
int offset = (pageNo - 1) * pageSize;
List<PointsBoard> list = lambdaQuery() // select * from points_board
.eq(PointsBoard::getSeason, seasonId) // where season = #{seasonId}
.orderByAsc(PointsBoard::getPoints) // ordered by points
.last("LIMIT " + pageSize + " OFFSET " + offset).list(); // limit #{pageSize} offset #{offset}
return list;
}
}
答:因为签到功能数据量非常大,我们用了 BitMap 结构。
BitMap 是用 bit 位来表示签到数据,31bit 位就能表示 1 个月的签到记录,非常节省空间,而且查询效率也比较高。
你使用 Redis 保存签到记录,那如果 Redis 宕机怎么办?
答:对于 Redis 的高可用数据安全问题,有很多种方案。
可以给 Redis 添加数据持久化机制,比如使用 AOF 持久化。这样宕机后也丢失的数据量不多,可以接受。
可以搭建 Redis 主从集群,再结合哨兵。主节点把数据持续同步给从节点,宕机后会有哨兵重新选主,不用担心数据丢失问题。
如果对于数据的安全性要求非常高,还是要用数据库来实现。但是为了解决签到数据量较大的问题,可能就需要分表处理了。
答:我们的排行榜功能分为两部分:一个是当前赛季排行榜,一个是历史排行榜。每个月为一个赛季,月初清零积分记录。
先说当前赛季榜单,我们采用了 Redis 的 SortedSet
来实现。member 是 用户id
,score 是 当月积分总值
。
每当用户产生积分行为时、获取积分时,就会更新 score 值。这样 Redis 就会自动形成榜单了。
再说历史赛季榜单,历史榜单肯定是保存到数据库了。不过由于数据过多,所以需要对数据做水平拆分。 我们目前的思路是按照赛季来拆分,也就是每一个赛季的榜单单独一张表。这样做有几个好处:
拆分数据时比较自然,无需做额外处理
查询数据时往往都是按照赛季来查询,这样一次只需要查一张表,不存在跨表查询问题。 因此我们就不需要分库分表了,直接在业务层利用 MybatisPlus 实现动态表名,进行动态插入。
利用一个定时任务在每月初生成上赛季的榜单表,一个定时任务持久化到数据库,一个定时任务清理 Redis 中的历史数据。 之所以要分开定义,是为了在部分任务失败时,可以单独重试。最终还要确保这三个任务的执行顺序,一定是依次执行的。
你们使用 Redis 的 SortedSet 来保存榜单数据,如果用户量非常多怎么办?
答:Redis 的 SortedSet 底层利用了跳表机制,性能还是非常不错的。即便有百万级别的用户量,也没什么问题。
当系统用户量规模达到数千万,乃至数亿时,我们可以采用分治的思想,将用户数据按照积分范围划分为多个桶。 然后为每个桶创建一个 SortedSet 类型的 key,这样就可以将数据分散,减少单个 KEY 的数据规模了。 要计算排名时,只需要按照范围查询出用户积分所在的桶,再累加分值范围比他高的桶的用户数量即可。
历史榜单采用的定时任务框架是?处理数百万的榜单数据时任务是如何分片的?你们是如何确保多个任务依次执行的呢?
答:我们采用的是 XXL-JOB 框架。XXL-JOB 自带任务分片广播机制,每一个任务执行器都能通过 API 得到自己的分片编号、分片总数量。
在做榜单数据批处理时,我们是按照分页查询的方式:
每个执行器的起始页是分片编号 + 1,跨度值是分片总数量。例如,第一个分片处理第 1、3、5 页,第二个分片处理第 2、4、6 页。
确保多个任务的执行顺序,可以利用 XXL-JOB 的子任务功能。
编号 | 接口简述 | |
---|---|---|
优惠券管理 | 1 | 新增优惠券 |
2 | 修改优惠券 | |
3 | 删除优惠券 | |
4 | 查询优惠券 | |
5 | 分页查询优惠券 | |
优惠券发放 | 6 | 发放优惠券 |
7 | 停发优惠券 | |
8 | 生成兑换码 |
需求:管理员可以在控制台添加新的优惠券,经过审核后可以发放。
Controller 层:
xxxxxxxxxx
package com.tianji.promotion.controller;
"/coupons") (
tags = "优惠券相关接口") (
public class CouponController {
"新增优惠券") (
public void saveCoupon( CouponFormDTO dto){
couponService.saveCoupon(dto);
}
}
Service 层:
xxxxxxxxxx
private final ICouponScopeService scopeService;
public void saveCoupon(CouponFormDTO dto) {
// 1. 保存优惠券
Coupon coupon = BeanUtils.copyBean(dto, Coupon.class);
save(coupon);
if (!dto.getSpecific()) return;
Long couponId = coupon.getId();
// 2. 保存限定范围
List<Long> scopes = dto.getScopes();
if (CollUtils.isEmpty(scopes)) {
throw new BadRequestException("限定范围不能为空");
}
// 3. 封装 PO 并保存
List<CouponScope> list = scopes.stream()
.map(bizId -> new CouponScope().setBizId(bizId).setCouponId(couponId))
.collect(Collectors.toList());
scopeService.saveBatch(list);
}
远程调用接口:
xxxxxxxxxx
public interface ICouponScopeService extends IService<CouponScope> {
}
需求:管理员可以在控制台的优惠券列表页,修改优惠券的信息。
Controller 层:
xxxxxxxxxx
package com.tianji.promotion.controller;
"修改优惠券") (
"{id}") (
public void updateById( CouponFormDTO dto, ("id") Long id){
couponService.updateById(dto, id);
}
Service 层:
xxxxxxxxxx
private final ICouponScopeService scopeService;
public void updateById(CouponFormDTO dto, Long id) {
// 1. 校验参数
Long dtoId = dto.getId();
if((dtoId!=null && id!=null && !dtoId.equals(id)) || (dtoId==null && id==null)){
throw new BadRequestException("参数错误");
}
// 2. 更新优惠券基本信息
Coupon coupon = BeanUtils.copyBean(dto, Coupon.class);
// 2.1 只更新状态为1的优惠券基本信息,如果失败则是状态已修改
boolean update = lambdaUpdate().eq(Coupon::getStatus, 1).update(coupon);
// 2.2 基本信息更新失败则无需更新优惠券范围信息
if(!update) return;
// 3. 更新优惠券范围信息
List<Long> scopeIds = dto.getScopes();
// 3.1 优惠券不满减,或优惠券范围为空,则不更新优惠券范围信息
// 3.2 先删除优惠券范围信息,再重新插入
List<Long> ids = scopeService.lambdaQuery()
.select(CouponScope::getId).eq(CouponScope::getCouponId, dto.getId()).list()
.stream().map(CouponScope::getId).collect(Collectors.toList());
scopeService.removeByIds(ids);
// 3.3 删除成功后,并且有范围再插入
if(CollUtils.isNotEmpty(scopeIds)){
List<CouponScope> lis = scopeIds.stream()
.map(i -> new CouponScope().setCouponId(dto.getId()).setType(1).setBizId(i))
.collect(Collectors.toList());
scopeService.saveBatch(lis);
}
}
需求:在管理控制台的优惠券分页列表中,点击某个待发放优惠券后的删除按钮,实现根据 id 删除优惠券功能。
Controller 层:
xxxxxxxxxx
package com.tianji.promotion.controller;
"删除优惠券") (
"{id}") (
public void deleteById( ("优惠券id") ("id") Long id){
couponService.deleteById(id);
}
Service 层:
xxxxxxxxxx
private final ICouponScopeService scopeService;
public void deleteById(Long id) {
// 1. 查询优惠券是否存在并删除: DELETE FROM coupon WHERE id = #{id} AND status = 1;
boolean remove = lambdaUpdate().eq(Coupon::getId, id).eq(Coupon::getStatus, 1).remove();
if(!remove){
throw new BadRequestException("删除失败,当前优惠券状态非待发放状态");
}
// 2. 查询优惠券范围信息并删除: DELETE FROM coupon_scope WHERE id = #{id};
scopeService.lambdaUpdate().eq(CouponScope::getCouponId, id).remove();
}
需求:在管理控制台的优惠券分页列表中,点击某个优惠券或者修改某个优惠券时,都需要根据 id 查询优惠券的详细信息。
Controller 层:
xxxxxxxxxx
package com.tianji.promotion.controller;
"查询优惠券接口") (
"{id}") (
public CouponDetailVO queryById( ("优惠券id") ("id") Long id){
return couponService.queryById(id);
}
Service 层:
xxxxxxxxxx
private final ICouponScopeService scopeService;
private final CategoryClient categoryClient;
public CouponDetailVO queryById(Long id) {
// 1. 查询优惠券基本信息: select * from coupon where id = #{id} limit 1;
Coupon coupon = lambdaQuery().eq(Coupon::getId, id).one();
// 2. 查询优惠券范围列表: select * from coupon_scope where couponId = #{id};
List<CouponScope> couponScopes = scopeService
.lambdaQuery().eq(CouponScope::getCouponId, coupon.getId()).list();
// 3. 查询范围信息<分类id,分类名称>: SELECT id, name FROM categories WHERE level = 1;
Map<Long, String> cateMap = categoryClient.getAllOfOneLevel().stream()
.collect(Collectors.toMap(CategoryBasicDTO::getId, CategoryBasicDTO::getName));
// 4. 封装范围信息到范围列表
List<CouponScopeVO> vos = couponScopes.stream()
.map(i -> new CouponScopeVO().setName(cateMap.get(i.getBizId())).setId(i.getBizId()))
.collect(Collectors.toList());
// 5. 封装优惠券详细信息
CouponDetailVO couponDetailVO = BeanUtils.copyBean(coupon, CouponDetailVO.class);
couponDetailVO.setScopes(vos);
return couponDetailVO;
}
远程调用接口:
xxxxxxxxxx
package com.tianji.api.client.course;
"getAllOfOneLevel") (
List<CategoryBasicDTO> getAllOfOneLevel();
需求:管理员可以在管理后台分页查询优惠券信息,并且可以基于条件做过滤。
Controller 层:
xxxxxxxxxx
package com.tianji.promotion.controller;
"分页查询优惠券") (
"/page") (
public PageDTO<CouponPageVO> queryCouponByPage(CouponQuery query){
return couponService.queryCouponByPage(query);
}
Service 层:
xxxxxxxxxx
public PageDTO<CouponPageVO> queryCouponByPage(CouponQuery query) {
Integer status = query.getStatus();
String name = query.getName();
Integer type = query.getType();
// 1. 分页查询
Page<Coupon> page = lambdaQuery() // SELECT * FROM coupon
.eq(type != null, Coupon::getDiscountType, type) // WHERE (type IS NULL OR discount_type = #{type})
.eq(status != null, Coupon::getStatus, status) // AND (status IS NULL OR status = #{status}) AND
.like(StringUtils.isNotBlank(name), Coupon::getName, name) // (name IS NULL OR name LIKE '%name%')
.page(query.toMpPageDefaultSortByCreateTimeDesc()); // // ORDER BY create_time DESC;
// 2. 封装 VO 并返回
List<Coupon> records = page.getRecords();
if (CollUtils.isEmpty(records)) {
return PageDTO.empty(page);
}
List<CouponPageVO> list = BeanUtils.copyList(records, CouponPageVO.class);
return PageDTO.of(page, list);
}
需求:管理员可以将待发放或暂停状态的优惠券重新发放,可以选择立即发放或定时发放。
Controller 层:
xxxxxxxxxx
package com.tianji.promotion.controller;
"发放优惠券") (
"/{id}/issue") (
public void beginIssue( CouponIssueFormDTO dto) {
couponService.beginIssue(dto);
}
Service 层:
xxxxxxxxxx
public void beginIssue(CouponIssueFormDTO dto) {
// 1. 查询优惠券
Coupon coupon = getById(dto.getId());
if (coupon == null) {
throw new BadRequestException("优惠券不存在!");
}
// 2. 判断优惠券状态,是否是 1: "待发放"、4: "暂停"
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. 更新优惠券
Coupon c = BeanUtils.copyBean(dto, Coupon.class);
if (isBegin) {
c.setStatus(ISSUING); // 3: "发放中"
c.setIssueBeginTime(now);
} else {
c.setStatus(UN_ISSUE); // 2: "未开始"
}
updateById(c);
// 5. TODO 生成兑换码
}
需求:管理员可以将一个发放中的优惠券状态修改为暂停, 暂停后学员无法领取或兑换该优惠券,用户端页面也不会展示。
Controller 层:
xxxxxxxxxx
package com.tianji.promotion.controller;
"停发优惠券") (
"/{id}/pause") (
public void pauseIssue( ("优惠券id") ("id") long id) {
couponService.pauseIssue(id);
}
Service 层:
xxxxxxxxxx
public void pauseIssue(long id) {
lambdaUpdate().eq(Coupon::getId, id).set(Coupon::getStatus, CouponStatus.PAUSE).update();
}
兑换码的需求:
可读性好:兑换码是要给用户使用的,因此可读性必须好:长度不超过 10 个字符,只能是 24 个大写字母和 8 个数字
数据量大:优惠活动比较频繁,必须有充足的兑换码,最好有 10 亿以上的量
唯一性:10 亿兑换码都必须唯一,不能重复,否则会出现兑换混乱的情况
不可重兑:兑换码必须便于校验兑换状态,避免重复兑换
防止爆刷:兑换码的规律性不能很明显,不能轻易被人猜测到其它兑换码
高效:兑换码生成、验证的算法必须保证效率,避免对数据库带来较大的压力
算法分析:
Base32 转码
角标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
字符 | A | B | C | D | E | F | G | H | J | K | L | M | N | P | Q | R |
角标 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
字符 | S | T | U | V | W | X | Y | Z | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
举例:假如我们经过自增 id 计算出一个复杂数字,转为二进制,并每 5 位一组,结果如下:
xxxxxxxxxx
01001 00010 01100 10010 01101 11000 01101 00010 11110 11010 // K C N B P 2
重兑校验算法
基于 BitMap:兑换或没兑换就是两个状态,对应 0 和 1,而兑换码使用的是自增 id。 我们如果每一个自增 id 对应一个 bit 位,可以用每一个 bit 位的状态表示兑换状态。
优点:简答、高效、性能好
缺点:依赖于 Redis
防刷校验算法
我们采用自增 id 的同时,还需要利用某种校验算法对 id 做加密验证,避免他人找出规律。
xxxxxxxxxx
权重(密钥): 2 5 1 3 4 7 8 9
自增序列: 0100 0010 1001 1010 1000 0010 0001 0110
加权和: 4*2 + 2*5 + 9*1 + 10*3 + 8*4 + 2*7 + 1*8 + 6*9 = 165
为了避免秘钥被人猜测出规律,我们可以准备 16 组秘钥。在兑换码自增 id 前拼接一个 4 位的新鲜值:
算法实现:(了解)
xxxxxxxxxx
package com.tianji.promotion.utils;
public class CodeUtil {
// 序列号加权运算的 16 组秘钥
private final static int[][] PRIME_TABLE = {...};
// 根据自增 id 生成兑换码。serialNum 是自增id,fresh 是新鲜值。
public static String generateCode(long serialNum, long fresh) {...}
// 验证并解析兑换码,返回自增id
public static long parseCode(String code) {...}
}
判断是否需要生成兑换码,要同时满足两个要求:① 领取方式是兑换码方式;② 之前的状态是待发放,不能是暂停。 优惠券发放以后是可以暂停的,暂停之后还可以再次发放,再次生成兑换码时,就重复了。
而且,由于生成兑换码的数量较多,可能比较耗时,这里推荐基于线程池异步生成:
定义线程池:
xxxxxxxxxx
package com.tianji.promotion.config;
public class PromotionConfig {
public Executor generateExchangeCodeExecutor(){
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2); // 1. 核心线程池大小
executor.setMaxPoolSize(5); // 2. 最大线程池大小
executor.setQueueCapacity(200); // 3. 队列大小
executor.setThreadNamePrefix("exchange-code-handler-"); // 4. 线程名称
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 5. 拒绝策略
executor.initialize();
return executor;
}
}
同时,在启动类添加 @EnableAsync
注解,开启异步功能。
xxxxxxxxxx
改造 发放优惠券
功能接口:
xxxxxxxxxx
private final IExchangeCodeService codeService;
public void beginIssue(CouponIssueFormDTO dto) {
// 5. 生成兑换码: 发放方式是 "发放兑换码" 且状态是 "待发放"
if(coupon.getObtainWay() == ObtainType.ISSUE && coupon.getStatus() == CouponStatus.DRAFT){
coupon.setIssueEndTime(c.getIssueEndTime());
codeService.asyncGenerateCode(coupon);
}
}
远程调用接口:
xxxxxxxxxx
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);
}
"generateExchangeCodeExecutor") (
public void asyncGenerateCode(Coupon coupon) {
// 1. 获取发放数量
Integer totalNum = coupon.getTotalNum();
// 2. 获取 Redis 自增序列号
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++) {
// 3. 生成兑换码
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);
}
// 4. 保存数据库
saveBatch(list);
// 5. 写入 Redis 缓存,member 是 couponId,score 是兑换码的最大序列号。
redisTemplate.opsForZSet().add(COUPON_RANGE_KEY, coupon.getId().toString(), maxSerialNum);
}
答:要考虑兑换码的验证的高效性,最佳的方案肯定是用自增序列号。因为可以借助于 BitMap 验证兑换状态,完全不用查询数据库。
要满足 20 亿的兑换码需求,只需要 31 个 bit 位就够了,也就是在 Integer 的取值范围内,非常节省空间。
首先,需要设计一个加密验签算法。32 位的自增序列,每 4 位一组转为 10 进制,这样就有8个数字。 准备一个长度为 8 的加权数组,作为秘钥。对自增序列的 8 个数字按位加权求和,得到的结果作为签名。
然后,考虑到秘钥的安全性,准备 16 组加权数组。再随机生成一个 4 位的新鲜值,取值范围对应数组下标 0~15。
最后,把签名值的后 14 位、新鲜值 4 位 、自增序列 32 位拼接,得到一个 50 位二进制数。 将它与一个较大的质数做异或运算加以混淆,再基于 Base32 或 Base64 转码,即可得到兑换码。
答:很多地方,比如我在实现优惠券的兑换码生成的时候。
当我们在发放优惠券的时候,会判断优惠券的领取方式,可以页面手动领取,也可以兑换码兑换领取。
如果是兑换码领取,则会在发放的同时生成兑换码。但由于数量比较多,如果在发放的同时才生成,业务耗时会比较久。
因此,我们采用线程池异步生成兑换码的方式。
那你的线程池参数是怎么设置的?
答:线程池的常见参数包括:核心线程
、最大线程
、队列
、线程名称
、拒绝策略
等。
核心线程数我们配置的是 2,最大线程数是 CPU 核数。 因为发放优惠券并不是高频业务,我们基于线程池做异步处理仅仅是为了减少业务耗时,所以线程数无需特别高。
队列大小设置的是 200,而拒绝策略采用的是交给调用线程处理的方式。 由于业务访问频率较低,所以基本不会出现线程耗尽的情况。就算真的耗尽了,就交给调用线程处理,让客户稍微等待一下也行。
查询优惠券列表(C 端):
领取优惠券(C 端):
优惠券:是用来封装优惠信息的实体,不属于任何人,因此不能在消费时使用。
用户券:是某个优惠券发放给某个用户后得到的实体,属于某一个用户,可以在消费时使用。
用户券表:用来保存用户和券的关系、使用状态等信息。用户券可以看做是用户和券的关系,即谁领了哪张券。
兑换优惠券(C 端):
查询我的优惠券(C 端):
xxxxxxxxxx
CREATE TABLE IF NOT EXISTS `user_coupon` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户券id',
`user_id` bigint NOT NULL COMMENT '优惠券的拥有者',
`coupon_id` bigint NOT NULL COMMENT '优惠券模板id',
`term_begin_time` datetime DEFAULT NULL COMMENT '优惠券有效期开始时间',
`term_end_time` datetime NOT NULL COMMENT '优惠券有效期结束时间',
`used_time` datetime DEFAULT NULL COMMENT '优惠券使用时间(核销时间)',
`status` tinyint NOT NULL DEFAULT '1' COMMENT '优惠券状态,1:未使用,2:已使用,3:已失效',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_coupon` (`coupon_id`),
KEY `idx_user_coupon` (`user_id`,`coupon_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户领取优惠券的记录,是真正使用的优惠券信息';
Controller 层:
xxxxxxxxxx
package com.tianji.promotion.controller;
"查询优惠券列表") (
"/list") (
public List<CouponVO> queryIssuingCoupons(){
return couponService.queryIssuingCoupons();
}
Service 层:
xxxxxxxxxx
public List<CouponVO> queryIssuingCoupons() {
// 1. 查询发放中的优惠券列表
List<Coupon> coupons = lambdaQuery() // SELECT * FROM coupon
.eq(Coupon::getStatus, ISSUING) // WHERE status = 3 (发放中)
.eq(Coupon::getObtainWay, ObtainType.PUBLIC).list(); // AND obtainWay = 1 (手动领取);
if (CollUtils.isEmpty(coupons)) {
return CollUtils.emptyList();
}
// 2. 统计当前用户已经领取的优惠券的信息
List<Long> couponIds = coupons.stream().map(Coupon::getId).collect(Collectors.toList());
// 2.1 查询当前用户已经领取的优惠券的数据
List<UserCoupon> userCoupons = userCouponService.lambdaQuery() // SELECT * FROM user_coupon
.eq(UserCoupon::getUserId, UserContext.getUser()) // WHERE id = #{userId}
.in(UserCoupon::getCouponId, couponIds).list(); // AND coupon_id in (#{couponIds});
// 2.2 统计当前用户对优惠券的已经领取数量
Map<Long, Long> issuedMap = userCoupons.stream()
.collect(Collectors.groupingBy(UserCoupon::getCouponId, Collectors.counting()));
// 2.3 统计当前用户对优惠券的已经领取并且未使用的数量
Map<Long, Long> unusedMap = userCoupons.stream()
.filter(uc -> uc.getStatus() == UserCouponStatus.UNUSED)
.collect(Collectors.groupingBy(UserCoupon::getCouponId, Collectors.counting()));
// 3. 封装 VO 集合并返回
List<CouponVO> list = new ArrayList<>(coupons.size());
for (Coupon c : coupons) {
// 3.1 拷贝 PO 属性到 VO
CouponVO vo = BeanUtils.copyBean(c, CouponVO.class);
list.add(vo);
// 3.2 是否可以领取:已经被领取的数量 < 优惠券总数量 && 当前用户已经领取的数量 < 每人限领数量
vo.setAvailable(
c.getIssueNum() < c.getTotalNum() && issuedMap.getOrDefault(c.getId(), 0L) < c.getUserLimit()
);
// 3.3 是否可以使用:当前用户已经领取并且未使用的优惠券数量 > 0
vo.setReceived(unusedMap.getOrDefault(c.getId(), 0L) > 0);
}
return list;
}
Controller 层:
xxxxxxxxxx
package com.tianji.promotion.controller;
"领取优惠券") (
"/{couponId}/receive") (
public void receiveCoupon( ("couponId") Long couponId){
userCouponService.receiveCoupon(couponId);
}
Service 层:
xxxxxxxxxx
private final CouponMapper couponMapper;
public void receiveCoupon(Long couponId) {
// 1. 查询优惠券
Coupon coupon = couponMapper.selectById(couponId);
if (coupon == null) {
throw new BadRequestException("优惠券不存在");
}
// 2. 校验发放时间
LocalDateTime now = LocalDateTime.now();
if (now.isBefore(coupon.getIssueBeginTime()) || now.isAfter(coupon.getIssueEndTime())) {
throw new BadRequestException("优惠券发放已经结束或尚未开始");
}
// 3. 校验库存
if (coupon.getIssueNum() >= coupon.getTotalNum()) {
throw new BadRequestException("优惠券库存不足");
}
Long userId = UserContext.getUser();
// 4. 校验每人限领数量
// 4.1 统计当前用户对当前优惠券的已经领取的数量
Integer count = lambdaQuery() // SELECT COUNT(*) FROM user_coupon
.eq(UserCoupon::getUserId(), userId) // WHERE user_id = #{userId}
.eq(UserCoupon::getCouponId(), couponId).count(); // AND coupon_id = #{couponId};
// 4.2 校验限领数量
if(count != null && count >= coupon.getUserLimit()){
throw new BadRequestException("超出领取数量");
}
// 5. 更新优惠券的已经发放的数量 + 1
couponMapper.incrIssueNum(coupon.getId());
// 6. 新增一个用户券
saveUserCoupon(coupon, userId);
}
xxxxxxxxxx
private void saveUserCoupon(Coupon coupon, Long userId) {
// 1. 基本信息
UserCoupon uc = new UserCoupon();
uc.setUserId(userId);
uc.setCouponId(coupon.getId());
// 2. 有效期信息
LocalDateTime termBeginTime = coupon.getTermBeginTime();
LocalDateTime termEndTime = coupon.getTermEndTime();
if (termBeginTime == null) {
termBeginTime = LocalDateTime.now();
termEndTime = termBeginTime.plusDays(coupon.getTermDays());
}
uc.setTermBeginTime(termBeginTime);
uc.setTermEndTime(termEndTime);
save(uc);
}
Mapper 层:
xxxxxxxxxx
"UPDATE coupon SET issue_num = issue_num + 1 WHERE id = #{couponId}") (
void incrIssueNum( ("couponId") Long couponId);
Controller 层:
xxxxxxxxxx
package com.tianji.promotion.controller;
"兑换优惠券") (
"/{code}/exchange") (
public void exchangeCoupon( ("code") String code){
userCouponService.exchangeCoupon(code);
}
Service 层:
xxxxxxxxxx
private final CouponMapper couponMapper;
private final IExchangeCodeService codeService;
public void exchangeCoupon(String code) {
// 1. 校验并解析兑换码
long serialNum = CodeUtil.parseCode(code);
// 2. 校验是否已经兑换 SETBIT KEY 4 1, 通过返回值来判断是否兑换过
boolean exchanged = codeService.updateExchangeMark(serialNum, true);
if (exchanged) {
throw new BizIllegalException("兑换码已经被兑换过了");
}
try {
// 3. 查询兑换码对应的优惠券 id
ExchangeCode exchangeCode = codeService.getById(serialNum);
if (exchangeCode == null) {
throw new BizIllegalException("兑换码不存在!");
}
// 4. 是否过期
LocalDateTime now = LocalDateTime.now();
if (now.isAfter(exchangeCode.getExpireTime())) {
throw new BizIllegalException("兑换码已经过期");
}
// 5. 校验并生成用户券
// 5.1 查询优惠券
Coupon coupon = couponMapper.selectById(exchangeCode.getCouponId());
// 5.2 查询用户
Long userId = UserContext.getUser();
// 5.3 校验并生成用户券,更新兑换码状态
checkAndCreateUserCoupon(coupon, userId, serialNum);
} catch (Exception e) {
// 重置兑换的标记 0
codeService.updateExchangeMark(serialNum, false);
throw e;
}
}
修改 领取优惠券
实现类代码:
xxxxxxxxxx
public void receiveCoupon(Long couponId) {
// 4. 校验并生成用户券
checkAndCreateUserCoupon(coupon, userId, null);
}
xxxxxxxxxx
private void checkAndCreateUserCoupon(Coupon coupon, Long userId, Integer serialNum){
// 1. 校验每人限领数量
// 1.1 统计当前用户对当前优惠券的已经领取的数量
Integer count = lambdaQuery() // SELECT COUNT(*) FROM user_coupon
.eq(UserCoupon::getUserId, userId) // WHERE user_id = #{userId}
.eq(UserCoupon::getCouponId, coupon.getId()).count(); // AND coupon_id = #{id};
// 1.2 校验限领数量
if(count != null && count >= coupon.getUserLimit()){
throw new BadRequestException("超出领取数量");
}
// 2. 更新优惠券的已经发放的数量 + 1
couponMapper.incrIssueNum(coupon.getId());
// 3. 新增一个用户券
saveUserCoupon(coupon, userId);
// 4. 更新兑换码状态
if (serialNum != null) {
codeService.lambdaUpdate() // UPDATE ExchangeCode
.set(ExchangeCode::getUserId, userId) // SET user_id = #{userId},
.set(ExchangeCode::getStatus, ExchangeCodeStatus.USED) // status = 2
.eq(ExchangeCode::getId, serialNum).update(); // WHERE id = #{serialNum};
}
}
远程调用接口:
xxxxxxxxxx
public boolean updateExchangeMark(long serialNum, boolean mark) {
// SETBIT coupon:code:map serialNum false;
Boolean boo = redisTemplate.opsForValue().setBit(COUPON_CODE_MAP_KEY, serialNum, mark);
return boo != null && boo;
}
Controller 层:
xxxxxxxxxx
package com.tianji.promotion.controller;
"查询我的优惠券") (
"/available") (
public List<CouponDiscountDTO> findDiscountSolution( List<OrderCourseDTO> orderCourses){
return userCouponService.findDiscountSolution(orderCourses);
}
Service 层:
xxxxxxxxxx
public List<CouponDiscountDTO> findDiscountSolution(List<OrderCourseDTO> orderCourses) {
// 1. 查询用户可用的优惠券
List<Coupon> coupons = baseMapper.queryMyCoupons(UserContext.getUser());
// 2. 过滤掉优惠券规则不满足的优惠券,条件:优惠券门槛不超过订单总价
// 2.1 计算订单总价
int totalNum = orderCourses.stream().mapToInt(OrderCourseDTO::getPrice).sum();
// 2.2 筛选出该订单可用券
List<Coupon> availableCoupons = coupons.stream()
.filter(c -> DiscountStrategy.getDiscount(c.getDiscountType()).canUse(totalNum, c))
.collect(Collectors.toList());
if(CollUtils.isEmpty(availableCoupons)){
return CollUtils.emptyList();
}
// 3.筛选出优惠券的全排列组合,组合包含单券和多券两种组合,多券组合为某张优惠券下可用的课程id列表,单券组合为所有优惠券集合
// 3.1 获取到每一张可用的优惠券可用课程集合的集合
Map<Coupon, List<OrderCourseDTO>> availableCouponsMap
= findAvailableCoupons(availableCoupons, orderCourses);
if(CollUtils.isEmpty(availableCouponsMap)){
return CollUtils.emptyList();
}
// 3.2 从map取出keys即优惠券Set,转换为优惠券List,并覆盖初筛的可用券集合
availableCoupons = new ArrayList<>(availableCouponsMap.keySet());
// 3.3 获得方案集合,即对细筛优惠券集合进行全排列组合
List<List<Coupon>> solutions = PermuteUtil.permute(availableCoupons);
// 3.4 将所有单券也加入到全排列中,因为页面除了展示优惠券组合,还要展示单券
for (Coupon availableCoupon : availableCoupons) {
solutions.add(CollUtils.toList(availableCoupon));
}
// 4. 计算每一种方案的优惠情况
List<CouponDiscountDTO> list = new ArrayList<>();
for (List<Coupon> couponList : solutions) {
list.add(calculateSolutionDiscount(availableCouponsMap, orderCourses, couponList));
}
// 5. 筛选出方案中最优解并返回
return findBestSolution(list);
}
xxxxxxxxxx
private CouponDiscountDTO calculateSolutionDiscount(Map<Coupon, List<OrderCourseDTO>> availableCouponsMap, List<OrderCourseDTO> orderCourses, List<Coupon> couponList) {
CouponDiscountDTO discountDTO = new CouponDiscountDTO();
// 1. 建立优惠明细映射Map<课程id,优惠金额>
Map<Long, Integer> courseDiscountMap = orderCourses.stream()
.collect(Collectors.toMap(OrderCourseDTO::getId, orderCourseDTO -> 0)); //初始化每个商品的已优惠金额为0
// 2. 计算该方案明细
// 2.1 循环方案中的优惠券
for (Coupon coupon : couponList) {
// 2.2 得到该优惠券可优惠的课程集合
List<OrderCourseDTO> courses = availableCouponsMap.get(coupon);
// 2.3 计算目前【剩余】的可优惠课程的总价(课程原价-对应优惠明细)
int price = courses.stream().mapToInt(
course.getPrice() - courseDiscountMap.get(course.getId())).sum();
// 2.4 判断总价是否符合该优惠券门槛
Discount discount = DiscountStrategy.getDiscount(coupon.getDiscountType()); //得到该优惠券的策略
if(!discount.canUse(price, coupon)) continue; //如果不满足门槛则判断下一张优惠券
// 2.5 计算总优惠金额
int discountPrice = discount.calculateDiscount(price, coupon);
// 2.6 将总优惠金额分摊到每个可优惠课程的优惠明细中
calculateDetailDiscount(courseDiscountMap, orderCourses, discountPrice, price);
// 2.7 保存该优惠券id到本方案中
discountDTO.getIds().add(coupon.getId());
// 2.8 保存该优惠券规则到本方案中(从策略中取出该优惠券的规则)
discountDTO.getRules().add(discount.getRule(coupon));
// 2.9 保存该方案的优惠金额(累加每张优惠券的优惠总金额)
discountDTO.setDiscountAmount(discountDTO.getDiscountAmount() + discountPrice);
}
return discountDTO;
}
Mapper 层:
xxxxxxxxxx
"SELECT c.id, c.discount_type, c.`specific`," + (
"c.discount_value, c.threshold_amount, c.max_discount_amount, uc.id AS creater" +
"FROM coupon c INNER JOIN user_coupon uc ON c.id = uc.coupon_id" +
"WHERE uc.`status` = 1 AND uc.user_id = #{userId};")
List<Coupon> queryMyCoupons( ("userId") Long userId);
分析原因:
我们对于优惠券库存的处理逻辑是这样的:先查询,再判断,再更新,而以上三步操作并不具备原子性。
超卖问题产生原因:① 多线程并行运行;② 多行代码操作共享资源,但不具备原子性。这就是典型的线程并发安全问题。
解决方案:
悲观锁:一种独占和排他的锁机制,悲观地认为数据会被其他事务修改,在整个数据处理过程中将数据处于锁定状态。
优点:安全性高
缺点:性能较差
乐观锁:一种较为乐观的控制机制,乐观地认为多用户并发不会产生安全问题,因此无需独占和锁定资源。 但在更新数据前,会先检查是否有其他线程修改了该数据,如果有则认为可能有风险,会放弃修改操作。
优点:安全性高、性能较好
缺点:并发较高时,更新成功率低
针对更新成功率低的问题,有一个改进方案:无需判断 issue_num
是否与原来一致,只要判断是否小于 total_num
即可:
xxxxxxxxxx
UPDATE coupon SET issue_num = issue_num + 1 WHERE id = 1 AND issue_num < total_num
实现方案:
修改更新库存的 SQL 语句:
xxxxxxxxxx
package com.tianji.promotion.mapper;
"UPDATE coupon SET issue_num = issue_num + 1 WHERE id = #{couponId} AND issue_num < total_num") (
int incrIssueNum( ("couponId") Long couponId);
修改 checkAndCreateUserCoupon 方法:
xxxxxxxxxx
package com.tianji.promotion.service.impl;
private void checkAndCreateUserCoupon(Coupon coupon, Long userId, Integer serialNum){
// 2. 更新优惠券的已经发放的数量 + 1
int r = couponMapper.incrIssueNum(coupon.getId());
// 如果超卖,则应该抛出异常,触发回滚。
if (r == 0) throw new BizIllegalException("优惠券库存不足");
}
分析原因:
除了优惠券库存判断,领券时还有对于用户限领数量的判断:先查询,再判断,再新增,以上三步也不具备原子性。
乐观锁常用在更新,所以这里只能采用悲观锁方案,也就是大家熟悉的 Synchronized
或者 Lock
。
用户限领数量判断是针对单个用户的,因此锁的范围不需要是整个方法,只要锁定某个用户即可:
xxxxxxxxxx
private void checkAndCreateUserCoupon(Coupon coupon, Long userId, Integer serialNum){
Synchronized(userId.toString()) {
// ...
}
}
测试发现并发问题依然存在,锁失效。原来是因为 userId 是 Long 类型,其中 toString()
采用的是 new String() 的方式。
也就是说,哪怕是同一个用户 id,但 toString()
得到的也是多个不同对象,也就是多把不同的锁。
解决方案:
String 类中提供了 intern()
方法,只要两个字符串 equals 的结果为 true,那么就能保证得到的结果用 == 判断也是 true:
xxxxxxxxxx
private void checkAndCreateUserCoupon(Coupon coupon, Long userId, Integer serialNum){
Synchronized(userId.toString().intern()) {
// ...
}
}
分析原因:
经过同步锁的改造,理论上问题已经是解决了。不过经过测试后,发现问题依然存在,用户还是会超领。
这是由于事务的隔离导致的,整个领券发放加了事务,而在内部我们加锁:
xxxxxxxxxx
// 外部加事务
public void receiveCoupon(Long couponId) {
checkAndCreateUserCoupon(coupon, userId, null); // 内部加锁
}
整体业务流程是这样的:开启事务 -> 获取锁 -> 执行业务 -> 释放锁 -> 提交事务
假设此时有两个线程并行执行这段逻辑:
线程 1 执行完业务释放锁,线程 2 立刻获取锁成功,开始执行业务
线程 1 尚未提交事务,于是线程 2 也新增一条券,安全问题就发生了
解决方案:
解决方法很简单,调整事务边界和锁边界:获取锁 -> 开启事务 -> 执行业务 -> 提交事务 -> 释放锁
xxxxxxxxxx
public void receiveCoupon(Long couponId) {
synchronized(userId.toString().intern()){ // 外部加锁
checkAndCreateUserCoupon(coupon, userId, null); // 内部加事务
}
}
public void checkAndCreateUserCoupon(Coupon coupon, Long userId, Integer serialNum){
// ...
}
分析原因:
事务失效的原因有很多,接下来我们就逐一分析一些常见的原因:
事务方法非 public 修饰:
xxxxxxxxxx
private void A() { }
非事务方法调用事务方法:(此次失效原因)
xxxxxxxxxx
public void A() { B(); }
public void B() { }
事务方法的异常被捕获了:
xxxxxxxxxx
public void A() { B(); }
private void B() {
try {} catch (Exception e) {}
}
事务异常类型不对:
xxxxxxxxxx
rollbackFor = RuntimeException.class) (
public void A() throws IOException {
throw new IOException();
}
事务传播行为不对:
xxxxxxxxxx
public void A(){ B(); C(); throw new RuntimeException("业务异常");}
public void B() {}
propagation = Propagation.REQUIRES_NEW) (
public void C() {}
没有被 Spring 管理:
xxxxxxxxxx
// @Service
public class TestService {
public void A() { }
}
解决方案:
既然事务失效的原因是方法内部调用走的是 this
,而不是代理对象
,那想办法获取代理对象
就可以了:
引入 AspectJ 依赖:
xxxxxxxxxx
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
在启动类上加注解,暴露代理对象:
xxxxxxxxxx
exposeProxy = true) (
使用代理对象:
xxxxxxxxxx
public void receiveCoupon(Long couponId) {
synchronized(userId.toString().intern()){ // 外部加锁
IUserCouponService userCouponService = (IUserCouponService) AopContext.currentProxy();
userCouponService.checkAndCreateUserCoupon(user, coupon, null); // 内部加事务
}
}
答:券超发问题常见的有两种场景:① 券库存不足导致超发;② 发券时超过了每个用户限领数量;
这两种问题产生的原因都是高并发下的线程安全问题。往往需要通过加锁来保证线程安全。不过在处理细节上,会有一些差别。
首先,针对库存不足导致的超发问题,也就是典型的库存超卖问题,我们可以通过乐观锁来解决。 也就是在库存扣减的 SQL 语句中添加对于库存余量的判断,直接判断库存是否小于 0 即可,这样提高了库存扣减的成功率。
其次,对于用户限领数量超出的问题,无法采用乐观锁。因为需要先查询已领数量,再判断是否超限,再新增领取记录。 这就导致后续的新增操作会影响超发的判断,只能利用悲观锁将几个操作封装为原子操作。
你这里聊到悲观锁,是用什么来实现的呢?
答:由于优惠券服务是多实例部署形成的负载均衡集群。因此考虑到分布式下 JVM锁
失效问题,采用了基于 Redisson 的分布式锁
。
不过 Redisson 分布式锁的加锁和释放对业务侵入比较多,因此我就对其做了二次封装: 利用自定义注解,AOP,以及 SPEL 表达式实现了基于注解的分布式锁。
我在封装的时候用 工厂模式
来选择不同的锁类型,用 策略模式
来选择锁失败重试策略,用 SPEL表达式
来实现动态锁名称。
综合来说包含 5 种失败重试策略:直接结束、直接抛异常、重试一段时间后结束、重试一段时间后抛异常、一直重试。
答:针对领券问题,我们采用了 MQ 来做异步领券,起到一个流量削峰和整型的作用,降低数据库压力。
具体来说,我们可以将优惠券的关键信息缓存到 Redis 中,用户请求进入后先读取缓存,做好优惠券库存、领取数量的校验。 如果校验不通过,则返回失败结果,如果校验通过,则通过 MQ 发送消息,异步去写数据库,然后告诉用户领取成功即可。
但是其中可能需要多次与 Redis 交互,因此利用 Redis 的 LUA 脚本代替 java 的校验逻辑,这样只有一次交互即可完成校验。
答:非事务方法调用、方法不是 public、方法的异常被捕获、抛出的异常类型不对、传播行为使用错误、Bean 没有被 Spring 管理。
编号 | 接口简述 |
---|---|
1 | 根据订单查询可用优惠方案(C 端) // TODO |
2 | 根据券方案计算订单优惠明细(C 端) |
3 | 核销优惠券(C 端) |
4 | 退还优惠券(C 端) |
5 | 查询优惠券(C 端) |
所谓的优惠券方案推荐,就是从用户的所有优惠券中筛选出可用的优惠券,并且计算哪种优惠方案用券最少,优惠金额最高。
canUse
:判断一个优惠券是否可用,也就是检查订单金额是否达到优惠券使用门槛
calculateDiscount
:按照优惠规则计算优惠金额,能够计算才能比较并找出最优方案
getRule
:生成优惠券规则描述,目的是在页面直观的展示各种方案,供用户选择
我们抽象一个接口来标示优惠券规则:
xxxxxxxxxx
package com.tianji.promotion.strategy.discount;
public interface Discount {
boolean canUse(int totalAmount, Coupon coupon);
int calculateDiscount(int totalAmount, Coupon coupon);
String getRule(Coupon coupon);
}
数据库表结构如下:
参数 | 说明 |
---|---|
请求方式 | POST |
请求路径 | /user-coupons/discount |
请求参数 | userCouponIds、courseList |
返回值 | discountAmount、discountDetail |
Controller 层:
xxxxxxxxxx
package com.tianji.promotion.controller;
"根据券方案计算订单优惠明细") (
"/discount") (
public CouponDiscountDTO queryDiscountDetailByOrder( OrderCouponDTO orderCouponDTO){
return discountService.queryDiscountDetailByOrder(orderCouponDTO);
}
Service 层:
xxxxxxxxxx
private final UserCouponMapper userCouponMapper;
public CouponDiscountDTO queryDiscountDetailByOrder(OrderCouponDTO orderCouponDTO) {
// 1. 查询用户优惠券
List<Long> userCouponIds = orderCouponDTO.getUserCouponIds();
List<Coupon> coupons = userCouponMapper
.queryCouponByUserCouponIds(userCouponIds, UserCouponStatus.UNUSED);
if (CollUtils.isEmpty(coupons)) {
return null;
}
// 2. 查询优惠券对应课程
Map<Coupon, List<OrderCourseDTO>> availableCouponMap
= findAvailableCoupon(coupons, orderCouponDTO.getCourseList());
if (CollUtils.isEmpty(availableCouponMap)) {
return null;
}
// 3. 查询优惠券规则
return calculateSolutionDiscount(availableCouponMap, orderCouponDTO.getCourseList(), coupons);
}
修改 calculateSolutionDiscount
方法:
xxxxxxxxxx
private CouponDiscountDTO calculateSolutionDiscount(...) {
discountDTO.setDiscountDetail(courseDiscountMap);
// 2. 计算该方案明细
}
Mapper 层:
xxxxxxxxxx
"SELECT c.id, c.discount_type, c.`specific`, c.discount_value," + (
"c.threshold_amount, c.max_discount_amount, uc.id AS creater" +
"FROM user_coupon uc INNER JOIN coupon c ON uc.coupon_id = c.id " +
"WHERE uc.id IN (:userCouponIds) AND uc.STATUS = #{status}")
List<Coupon> queryCouponByUserCouponIds( ("userCouponIds") List<Long> userCouponIds,
"status") UserCouponStatus status); (
定义 FeignClient 方法:
xxxxxxxxxx
package com.tianji.api.client.promotion.fallback;
public PromotionClient create(Throwable cause) {
log.error("查询促销服务出现异常,", cause);
return new PromotionClient() { // 添加接口的降级逻辑
public CouponDiscountDTO queryDiscountDetailByOrder(OrderCouponDTO orderCouponDTO) {
return null;
}
};
}
改造交易服务接口:
xxxxxxxxxx
package com.tianji.trade.service.impl;
public PlaceOrderResultVO placeOrder(PlaceOrderDTO placeOrderDTO) {
// 2.2 计算优惠金额
order.setDiscountAmount(0);
List<Long> couponIds = placeOrderDTO.getCouponIds();
CouponDiscountDTO discount = null;
if (CollUtils.isNotEmpty(couponIds)) {
List<OrderCourseDTO> orderCourses = courseInfos.stream()
.map(c -> new OrderCourseDTO()
.setId(c.getId()).setCateId(c.getThirdCateId()).setPrice(c.getPrice()))
.collect(Collectors.toList());
discount = promotionClient // 远程调用 queryDiscountDetailByOrder
.queryDiscountDetailByOrder(new OrderCouponDTO(couponIds, orderCourses));
if(discount != null) {
order.setDiscountAmount(discount.getDiscountAmount());
order.setCouponIds(discount.getIds());
}
}
// 3.封装订单详情
List<OrderDetail> orderDetails = new ArrayList<>(courseInfos.size());
for (CourseSimpleInfoDTO courseInfo : courseInfos) {
Integer discountValue = discount == null ?
0 : discount.getDiscountDetail().getOrDefault(courseInfo.getId(), 0);
orderDetails.add(packageOrderDetail(courseInfo, order, discountValue));
}
}
xxxxxxxxxx
public PlaceOrderResultVO enrolledFreeCourse(Long courseId) {
// 3. 订单详情
OrderDetail detail = packageOrderDetail(courseInfo, order, 0);
}
xxxxxxxxxx
private OrderDetail packageOrderDetail(CourseSimpleInfoDTO courseInfo, Order order, Integer discountValue) {
// ...
detail.setDiscountAmount(discountValue);
}
参数 | 说明 |
---|---|
请求方式 | PUT |
请求路径 | /user-coupons/use |
请求参数 | couponIds |
返回值 | 无 |
Controller 层:
xxxxxxxxxx
package com.tianji.promotion.controller;
"核销优惠券") (
"/use") (
public void writeOffCoupon( ("用户优惠券id集合") ("couponIds") List<Long> userCouponIds){
userCouponService.writeOffCoupon(userCouponIds);
}
Service 层:
xxxxxxxxxx
public void writeOffCoupon(List<Long> userCouponIds) {
// 1. 查询优惠券
List<UserCoupon> userCoupons = listByIds(userCouponIds);
if (CollUtils.isEmpty(userCoupons)) return;
// 2. 处理数据
List<UserCoupon> list = userCoupons.stream() // 过滤无效券
.filter(coupon -> {
if (coupon == null) return false;
if (UserCouponStatus.UNUSED != coupon.getStatus()) return false;
LocalDateTime now = LocalDateTime.now();
return !now.isBefore(coupon.getTermBeginTime()) && !now.isAfter(coupon.getTermEndTime());
}).map(coupon -> { // 组织新增数据
UserCoupon c = new UserCoupon();
c.setId(coupon.getId());
c.setStatus(UserCouponStatus.USED);
return c;
}).collect(Collectors.toList());
// 3. 核销,修改优惠券状态
boolean success = updateBatchById(list);
if (!success) return;
// 4.更新已使用数量
List<Long> couponIds = userCoupons.stream().map(UserCoupon::getCouponId).collect(Collectors.toList());
int c = couponMapper.incrUsedNum(couponIds, 1);
if (c < 1) {
throw new DbException("更新优惠券使用数量失败!");
}
}
定义 FeignClient 方法:
xxxxxxxxxx
package com.tianji.api.client.promotion.fallback;
public PromotionClient create(Throwable cause) {
log.error("查询促销服务出现异常,", cause);
return new PromotionClient() { // 添加接口的降级逻辑
public void writeOffCoupon(List<Long> userCouponIds) {
throw new BizIllegalException(500, "核销优惠券异常", cause);
}
};
}
改造交易服务接口:
xxxxxxxxxx
package com.tianji.trade.service.impl;
public PlaceOrderResultVO placeOrder(PlaceOrderDTO placeOrderDTO) {
// 6. 核销优惠券(远程调用 writeOffCoupon)
promotionClient.writeOffCoupon(couponIds);
}
参数 | 说明 |
---|---|
请求方式 | PUT |
请求路径 | /user-coupons/refund |
请求参数 | couponIds |
返回值 | 无 |
Controller 层:
xxxxxxxxxx
package com.tianji.promotion.controller;
"退还优惠券") (
"/refund") (
public void refundCoupon( ("用户优惠券id集合") ("couponIds") List<Long> userCouponIds){
userCouponService.refundCoupon(userCouponIds);
}
Service 层:
xxxxxxxxxx
public void refundCoupon(List<Long> userCouponIds) {
// 1. 查询优惠券
List<UserCoupon> userCoupons = listByIds(userCouponIds);
if (CollUtils.isEmpty(userCoupons)) {
return;
}
// 2. 处理优惠券数据
List<UserCoupon> list = userCoupons.stream()
// 过滤无效券
.filter(coupon -> coupon != null && UserCouponStatus.USED == coupon.getStatus())
// 更新状态字段
.map(coupon -> {
UserCoupon c = new UserCoupon();
c.setId(coupon.getId());
// 3. 判断有效期,是否已经过期,如果过期则状态为已过期,否则状态为未使用
LocalDateTime now = LocalDateTime.now();
UserCouponStatus status = now.isAfter(coupon.getTermEndTime()) ?
UserCouponStatus.EXPIRED : UserCouponStatus.UNUSED;
c.setStatus(status);
return c;
}).collect(Collectors.toList());
// 4. 修改优惠券状态
boolean success = updateBatchById(list);
if (!success) return;
// 5. 更新已使用数量
List<Long> couponIds = userCoupons.stream()
.map(UserCoupon::getCouponId).collect(Collectors.toList());
int c = couponMapper.incrUsedNum(couponIds, -1);
if (c < 1) {
throw new DbException("更新优惠券使用数量失败!");
}
}
定义 FeignClient 方法:
xxxxxxxxxx
package com.tianji.api.client.promotion.fallback;
public PromotionClient create(Throwable cause) {
log.error("查询促销服务出现异常,", cause);
return new PromotionClient() { // 添加接口的降级逻辑
public void refundCoupon(List<Long> userCouponIds) {
throw new BizIllegalException(500, "退还优惠券异常", cause);
}
};
}
改造交易服务接口:
xxxxxxxxxx
package com.tianji.trade.service.impl;
public void cancelOrder(Long orderId) {
// 6. 退还优惠券(远程调用 refundCoupon)
promotionClient.refundCoupon(order.getCouponIds());
}
参数 | 说明 |
---|---|
请求方式 | GET |
请求路径 | /user-coupons/rules |
请求参数 | couponIds |
返回值 | rules |
Controller 层:
xxxxxxxxxx
package com.tianji.promotion.controller;
"查询优惠券") (
"/rules") (
public List<String> queryDiscountRules(
"用户优惠券id集合") ("couponIds") List<Long> userCouponIds){ (
return userCouponService.queryDiscountRules(userCouponIds);
}
Service 层:
xxxxxxxxxx
public List<String> queryDiscountRules(List<Long> userCouponIds) {
// 1. 查询优惠券信息
List<Coupon> coupons = baseMapper
.queryCouponByUserCouponIds(userCouponIds, UserCouponStatus.USED);
if (CollUtils.isEmpty(coupons)) {
return CollUtils.emptyList();
}
// 2. 转换规则
return coupons.stream()
.map(c -> DiscountStrategy.getDiscount(c.getDiscountType()).getRule(c))
.collect(Collectors.toList());
}
定义 FeignClient 方法:
xxxxxxxxxx
package com.tianji.api.client.promotion.fallback;
public PromotionClient create(Throwable cause) {
log.error("查询促销服务出现异常,", cause);
return new PromotionClient() { // 添加接口的降级逻辑
public List<String> queryDiscountRules(List<Long> userCouponIds) {
return Collections.emptyList();
}
};
}
改造交易服务接口:
xxxxxxxxxx
package com.tianji.trade.service.impl;
public OrderVO queryOrderById(Long id) {
// 3.4 优惠明细(远程调用 queryDiscountRules)
List<String> rules = promotionClient.queryDiscountRules(order.getCouponIds());
vo.setCouponDesc(String.join("/", rules));
}
答:在初期做调研的时候也考虑过规则引擎,不过考虑到我们的优惠规则并不复杂,最终选择了基于策略模式来自定义规则。
答:在优惠券功能中使用了策略模式来定义优惠规则。还有基于注解的通用分布式锁组件,也使用到了策略模式、工厂模式。
答:在实现优惠券的推荐算法时,我们采用的是排列组合多种优惠方案,然后分别计算,最终筛选出最优解的思路。
由于需要计算的优惠方案可能较多,为了提高计算效率,我们利用了 CompletableFuture
来实现多方案的并行计算。
并且由于要筛选最优解,那就需要等待所有方案都计算完毕,再来筛选。因此就使用了 CountdownLatch
来做多线程的并行控制。
答:基于产品的需求,我们采用的是退款不退券的方案。
具体来说,就是在一开始下单时,就会根据优惠券本身的使用范围,筛选出订单中可以参与优惠的商品。 然后计算出每一个被优惠的商品具体的优惠金额分成,以及对应的实付金额。
如果用户选择只退部分商品,就可以根据每个商品的实付金额来退款,同时也满足退款不退券的原则。
当然,如果订单未支付、取消订单或者超时关闭,是可以退券的。