天机学堂是一个基于微服务架构的生产级在线教育项目。项目源码: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 分支,完成项目开发:
xxxxxxxxxxcd tianjigit 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 判断。
xxxxxxxxxxpackage 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:也就是课程加入时间和过期时间
课表对应的数据库结构应该是这样的:
xxxxxxxxxxCREATE 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 分支直接开发:
xxxxxxxxxxgit checkout -b feature-lessons我们使用的是 Mybatis 作为持久层框架,并且引入了 MybatisPlus 来简化开发。因此,在创建据库以后,就需要创建对应的实体类、mapper、service等。这些代码格式固定,编写起来又比较费时。好在 IDEA 中提供了一个 MP 插件,可以生成这些重复代码:

安装完成以后,我们先配置一下数据库地址:

严格按照下图的模式去设置,不要填错项目名称和包名称。最后,点击 code generatro 按钮,即可生成代码:

默认情况下 PO 的主键 ID 是自增长,而项目默认希望采用雪花算法作为 ID,因此这里需要对 LearningLesson 做修改:
xxxxxxxxxxpackage com.tianji.learning.domain.po;
// @TableId(value = "id", type = IdType.AUTO)(value = "id", type = IdType.ASSIGN_ID)除此之外,我们还要按照Restful风格,对请求路径做修改:
xxxxxxxxxxpackage com.tianji.learning.controller;
// @RequestMapping("/learning-lesson")("/lessons")在数据结构中,课表是有学习状态的,学习计划也有状态。如果每次编码手写很容易写错,因此一般都会定义出枚举:
xxxxxxxxxxpackage com.tianji.learning.enums;
public enum LessonStatus implements BaseEnum {...}xxxxxxxxxxpackage com.tianji.learning.enums;
public enum PlanStatus implements BaseEnum {...}这样一来,我们就需要修改 PO 对象,用枚举类型替代原本的 Integer 类型:
xxxxxxxxxxpackage com.tianji.learning.domain.po;
// 课程状态,0-未学习,1-学习中,2-已学完,3-已失效private LessonStatus status;
// 学习计划状态,0-没有计划,1-计划进行中private PlanStatus planStatus;MybatisPlus会在我们与数据库交互时实现自动的数据类型转换,无需我们操心。
需求:用户购买课程后,交易服务会通过 MQ 通知学习服务,学习服务将课程加入用户课表中。

在 tj-learning 服务中定义一个 MQ 的监听器:
xxxxxxxxxxpackage 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 方法:
xxxxxxxxxxpublic 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,向下传递:
xxxxxxxxxxpackage 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:
xxxxxxxxxxpackage 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 的工具,可以确保不同的请求之间互不干扰,避免线程安全问题发生:
xxxxxxxxxxpackage 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 实体类:
xxxxxxxxxxpackage 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 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
(tags = "我的课程相关接口")("/lessons")public class LearningLessonController { ("分页查询我的课表") ("page") public PageDTO<LearningLessonVO> queryMyLessons(PageQuery query){ return lessonService.queryMyLessons(query); }}Service 层:
xxxxxxxxxxpublic 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 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
("/now")("查询正在学习的课程")public LearningLessonVO queryMyCurrentLesson() { return lessonService.queryMyCurrentLesson();}Service 层:
xxxxxxxxxxprivate 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 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
("用户手动删除当前课程")("/{courseId}")public void deleteCourseFromLesson(("courseId") Long couseId){ Long user = UserContext.getUser(); lessonService.deleteCourseFromLesson(user, couseId);}Service 层:
xxxxxxxxxxpublic void deleteCourseFromLesson(Long userId, Long courseId) { remove(buildUserIdAndCourseIdWrapper(userId, courseId));}需求:用户退款课程后,交易服务会通过 MQ 通知学习服务,学习服务将移除用户课表中的课程。
在 tj-learning 服务中定义一个 MQ 的监听器即可,deleteCourseFromLesson 方法已实现:
xxxxxxxxxxpackage 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 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
("校验当前用户是否可以学习当前课程")("/{courseId}/valid")public Long isLessonValid(("courseId") Long courseId){ return lessonService.isLessonValid(courseId);}Service 层:
xxxxxxxxxxpublic 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 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
("用户是否拥有该课程并返回学习进度")("/{courseId}")public LessonStatusVO queryLessonByCourseId( (value = "课程id", example = "1") ("courseId") Long couseId){ LessonStatusVO lessonStatusVO = lessonService.queryLessonByCourseId(couseId); return lessonStatusVO;}Service 层:
xxxxxxxxxxpublic 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 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
("查询该课程的报名人数")("/lessons/{courseId}/count")Integer countLearningLessonByCourse( (value = "课程id", example = "1") ("courseId") Long courseId){ return lessonService.countLearningLessonByCourse(courseId);}Service 层:
xxxxxxxxxxpublic Integer countLearningLessonByCourse(Long courseId) { Integer count = lambdaQuery() // select count(*) from learning_lesson .eq(LearningLesson::getCourseId, courseId).count(); // where course_id = #{courseId} return count;}

提交学习记录:
需求:在课程学习页面播放视频时或考试后,需要提交学习记录信息到服务端保存。

查询学习记录:
需求:在课程学习页面需要查询出每一个小节的基本信息,以及小节对应的学习记录。

创建学习计划:
需求:当点击创建学习计划时,会提交课程信息和计划的学习频率到服务端。服务端需要将数据写入对应的课表中。

查询学习计划:
需求:在我的课程页面中,需要统计用户本周的学习计划及进度,数据较多,采用分页查询。


xxxxxxxxxxCREATE 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 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
(tags = "学习记录的相关接口")("/learning-records")public class LearningRecordController { ("提交学习记录") () public void addLearningRecord( LearningRecordFormDTO formDTO){ recordService.addLearningRecord(formDTO); }}Service 层:
xxxxxxxxxxprivate 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. 处理课表数据}
xxxxxxxxxxprivate 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 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
("查询指定课程的学习记录")("/course/{courseId}")public LearningLessonDTO queryLearningRecordByCourse( (value = "课程id", example = "2") ("courseId") Long courseId){ return recordService.queryLearningRecordByCourse(courseId);}Service 层:
xxxxxxxxxxprivate 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;}远程调用接口:
xxxxxxxxxxpackage 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 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
("/lessons")(tags = "我的课表相关接口")public class LearningLessonController { ("创建学习计划") ("/plans") public void createLearningPlans( LearningPlanDTO planDTO){ lessonService.createLearningPlan(planDTO.getCourseId(), planDTO.getFreq()); }}Service 层:
xxxxxxxxxxpublic 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 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
("查询我的学习计划")("/plans")public LearningPlanPageVO queryMyPlans(PageQuery query){ return lessonService.queryMyPlans(query);}Service 层:
xxxxxxxxxxprivate 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);}远程调用接口:
xxxxxxxxxxpackage 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 表中的课程是否过期,如果过期则将课程状态修改为已过期。
xxxxxxxxxxpackage 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 的源码:
xxxxxxxxxxpublic 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 类型,这其实就是一个延迟任务的规范接口:
xxxxxxxxxxpublic interface Delayed extends Comparable<Delayed> { long getDelay(TimeUnit unit);}可以看出Delayed 类型必须具备两个方法:getDelay():获取任务剩余延迟时间;compareTo(T t):根据延迟时间判断执行顺序。

定义延迟任务工具类:(了解)
xxxxxxxxxxpackage 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() {...}}改造提交学习记录功能:
xxxxxxxxxxpublic 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());}xxxxxxxxxxprivate 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核数一致即可。
xxxxxxxxxxpackage 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:
xxxxxxxxxxCREATE 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:
xxxxxxxxxxCREATE 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 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
("/questions")public class InteractionQuestionController { ("新增互动问题") public void saveQuestion( QuestionFormDTO questionDTO){ questionService.saveQuestion(questionDTO); }}Service 层:
xxxxxxxxxxpublic void saveQuestion(QuestionFormDTO questionDTO) { Long userId = UserContext.getUser(); InteractionQuestion question = BeanUtils.toBean(questionDTO, InteractionQuestion.class); question.setUserId(userId); save(question);}需求:在课程详情页,用户可以对自己提出的问题做编辑,修改问题的相关信息。
Controller 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
("修改互动问题")("{id}")public void updateQuestion(("id") Long id, QuestionFormDTO questionDTO){ questionService.updateQuestion(id, questionDTO);}Service 层:
xxxxxxxxxxpublic 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 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
("分页查询问题")("page")public PageDTO<QuestionVO> queryQuestionPage(QuestionPageQuery query){ return questionService.queryQuestionPage(query);}Service 层:
xxxxxxxxxxprivate 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);}远程调用接口:
xxxxxxxxxxpackage com.tianji.learning.mapper;
public interface InteractionReplyMapper extends BaseMapper<InteractionReply> {}xxxxxxxxxxpackage com.tianji.api.client.user;
(value = "user-service", fallbackFactory = UserClientFallback.class)public interface UserClient { ("/users/list") List<UserDTO> queryUserByIds(("ids") Iterable<Long> ids);}需求:在课程详情页用户点击某个互动问题后,会进入问答详情页面,查看问题详细信息。
Controller 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
("查询问题详情")("/{id}")public QuestionVO queryQuestionById((value = "问题id", example = "1") ("id") Long id){ return questionService.queryQuestionById(id);}Service 层:
xxxxxxxxxxpublic 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 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
("删除互动问题")("{id}")public void deleteQuestion(("id") Long id){ questionService.deleteQuestion(id);}Service 层:
xxxxxxxxxxpublic 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 客户端方便我们调用:
xxxxxxxxxxpackage 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 服务中提供了一个接口,可以查询到所有的分类:
xxxxxxxxxxpackage 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。
课程分类的本地缓存:(了解)
xxxxxxxxxxpackage com.tianji.api.cache;
public class CategoryCache {...} // 课程分类的缓存工具类xxxxxxxxxxpackage com.tianji.api.config;
public class CategoryCacheConfig {...} // 课程分类的缓存配置需求:在后台管理页面,教师可以查看学员提问的问题并予以答复,查询采用分页查询。
Controller 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
("/admin/questions")(tags = "互动问答相关接口")public class InteractionQuestionAdminController { ("分页查询问题") ("page") public PageDTO<QuestionAdminVO> queryQuestionPageAdmin(QuestionAdminPageQuery query){ return questionService.queryQuestionPageAdmin(query); }}Service 层:
xxxxxxxxxxprivate 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 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
("查询问题详情")("{id}")public QuestionVO queryQuestionById((value = "问题id", example = "1") ("id") Long id){ return questionService.queryQuestionById(id);}Service 层:
xxxxxxxxxxprivate 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;}远程调用接口:
xxxxxxxxxxpackage com.tianji.api.client.user;
("/users/{id}")UserDTO queryUserById(("id") Long id);需求:在管理端的互动问题列表中,管理员可以隐藏某个问题,这样就不会在用户端页面展示了。
Controller 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
("隐藏显示问题")("/questions/{id}/hidden/{hidden}")public void hiddenQuestionAdmin(("id") Long id, ("hidden") Boolean hidden){ questionService.hiddenQuestionAdmin(id, hidden);}Service 层:
xxxxxxxxxxpublic void hiddenQuestionAdmin(Long id, Boolean hidden) { InteractionQuestion question = getById(id); if(question == null){ throw new BadRequestException("该问题不存在"); } question.setHidden(hidden); updateById(question);}需求:在问题详情页面,学员可以回答问题,或者对他人的回答做评论。
Controller 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
(tags = "用户端回答或评论相关接口")("/replies")public class InteractionReplyController { ("新增回答或评论") public void addReplyOrAnswer( ReplyDTO dto){ replyService.addReplyOrAnswer(dto); }}Service 层:
xxxxxxxxxxprivate 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); }}远程调用接口:
xxxxxxxxxxpackage com.tianji.learning.mapper;
public interface InteractionQuestionMapper extends BaseMapper<InteractionQuestion> {}需求:在问题详情页面,用户可以查看学员问题详情下的回答列表或者回答下的评论列表。
Controller 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
("分页查询回答或评论")("page")public PageDTO<ReplyVO> queryReplyOrAnswerPage(ReplyPageQuery query){ return replyService.queryReplyOrAnswerPage(query, Boolean.FALSE);}Service 层:
xxxxxxxxxxprivate 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);}远程调用接口:
xxxxxxxxxxpackage com.tianji.api.client.user;
("/users/list")List<UserDTO> queryUserByIds(("ids") Iterable<Long> ids);需求:在后台管理页面,教师可以查看学员问题详情下的回答列表或者回答下的评论列表。
Controller 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
("分页查询回答或评论")("/replies/page")public PageDTO<ReplyVO> queryReplyOrAnswerPageAdmin(ReplyPageQuery query){ return replyService.queryReplyOrAnswerPageAdmin(query);}Service 层:
xxxxxxxxxxpublic PageDTO<ReplyVO> queryReplyOrAnswerPageAdmin(ReplyPageQuery query) { return queryReplyOrAnswerPage(query, Boolean.TRUE);}// queryReplyOrAnswerPage 方法已实现,与 C 端分页查询共用需求:在管理端的回答或评论列表中,管理员可以隐藏回答或评论,这样就不会在用户端页面展示了。
Controller 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
("隐藏显示回答或评论")("/replies/{id}/hidden/{hidden}")public void hiddenReplyAdmin(("id") Long id, ("hidden") Boolean hidden){ replyService.hiddenReplyAdmin(id, hidden);}Service 层:
xxxxxxxxxxprivate 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); }}远程调用接口:
xxxxxxxxxxpackage com.tianji.learning.mapper;
public interface InteractionQuestionMapper extends BaseMapper<InteractionQuestion> {}

xxxxxxxxxxCREATE 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 层:
xxxxxxxxxxpackage com.tianji.remark.controller;
(tags = "点赞相关接口")("/likes")public class LikedRecordController { ("点赞或取消") () public void addLikeRecord( LikeRecordFormDTO dto){ likedRecordService.addLikeRecord(dto); }}Service 层:
xxxxxxxxxxprivate 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}}远程调用接口:
xxxxxxxxxxpackage 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 层:
xxxxxxxxxxpackage com.tianji.remark.controller;
("list")("查询指定业务id的点赞状态")public Set<Long> isBizLiked(("bizIds") List<Long> bizIds){ return likedRecordService.isBizLiked(bizIds);}Service 层:
xxxxxxxxxxpublic 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 降级处理:
xxxxxxxxxxpackage com.tianji.api.client.remark;
(value = "remark-service", fallbackFactory = RemarkClientFallBack.class)public interface RemarkClient { ("/likes/list") Set<Long> getLikedStatusByBizList(("bizIds") Iterable<Long> bizIds);}xxxxxxxxxxpackage 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 类:
xxxxxxxxxxpackage 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改造 分页查询回答或评论 接口:
xxxxxxxxxxprivate final RemarkClient remarkClient;
public PageDTO<ReplyVO> queryReplyOrAnswerPage(ReplyPageQuery query, Boolean isAdmin) { // 3.查询用户点赞状态(入参是当前分页的评论 id 集合,出参是当前分页被点过赞的评论 id 集合) Set<Long> bizLiked = remarkClient.getLikedStatusByBizList(replyIds);}需求:在学习服务添加 MQ 监听器,监听点赞数变更的消息,更新回答的点赞数量。

xxxxxxxxxxpackage 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); }}远程调用接口:
xxxxxxxxxxpackage com.tianji.learning.service;
public interface IInteractionReplyService extends IService<InteractionReply> {}
xxxxxxxxxxpublic 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,可以在一次请求中执行多个命令,实现批处理效果:
xxxxxxxxxxpublic 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 的定时任务功能定义一个定时任务处理器类:
xxxxxxxxxxpackage 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); } }}调用接口:
xxxxxxxxxxpackage 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);}xxxxxxxxxxpackage 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 | 查询历史赛季积分榜 |
签到记录:
xxxxxxxxxxCREATE 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='签到记录表';积分记录:
xxxxxxxxxxCREATE 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='学习积分记录,每个月底清零';排行榜记录:
xxxxxxxxxxCREATE 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 中的数据:
xxxxxxxxxxSETBIT bm 0 1 # 第 1 天签到SETBIT bm 7 1 # 第 8 天签到读取 BitMap 中的数据:
xxxxxxxxxx# 例:bm = 10110000, 查询从第 1~4 天的签到记录:BITFIELD bm GET u4 0 # 无符号数,返回 (1011)2 = 11BITFIELD bm GET i4 0 # 有符号数,返回 (1101)2 = -5需求:在个人中心的积分页面,每天都可以签到一次,连续签到有积分奖励。请实现签到接口,记录用户每天签到信息。
Controller 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
(tags = "签到相关接口")("sign-records")public class SignRecordController { ("签到接口") public SignResultVO addSignRecords(){ return recordService.addSignRecords(); }}Service 层:
xxxxxxxxxxpublic 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;}xxxxxxxxxxprivate 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 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
("查询签到记录接口")public Deque<Integer> querySignRecords(){ return recordService.querySignRecords();}Service 层:
xxxxxxxxxxpublic 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;}xxxxxxxxxxprivate 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 消息体:
xxxxxxxxxxpackage com.tianji.learning.mq.message;
(staticName = "of")public class SignInMessage { private Long userId; private Integer points;}然后改造签到功能:
xxxxxxxxxxprivate 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)}xxxxxxxxxxpackage 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 层:
xxxxxxxxxxpublic 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);}xxxxxxxxxxprivate 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 层:
xxxxxxxxxxpublic interface PointsRecordMapper extends BaseMapper<PointsRecord> { ("SELECT SUM(points) FROM points_record ${ew.customSqlSegment}") Integer queryUserPointsByTypeAndDate((Constants.WRAPPER) QueryWrapper<PointsRecord> wrapper);}需求:在个人中心,用户可以查看当天各种不同类型的已获得的积分和积分上限。
Controller 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
("查询今日积分情况")("today")public List<PointsStatisticsVO> queryMyPointsToday(){ return pointsRecordService.queryMyPointsToday();}Service 层:
xxxxxxxxxxpublic 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 层:
xxxxxxxxxxpublic 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 层:
xxxxxxxxxxpackage 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 层:
xxxxxxxxxxpublic class PointsBoardSeasonServiceImpl extends ServiceImpl<PointsBoardSeasonMapper, PointsBoardSeason> implements IPointsBoardSeasonService {}Mapper 层:
xxxxxxxxxxpublic interface PointsBoardSeasonMapper extends BaseMapper<PointsBoardSeason> {}
定义 Redis 的 KEY 前缀:
xxxxxxxxxxpackage com.tianji.learning.constants;
public interface RedisConstants { String POINTS_BORAD_KEY_PREFIX = "boards:";}xxxxxxxxxxpackage com.tianji.common.utils;
public class DateUtils extends LocalDateTimeUtil { public static final DateTimeFormatter POINTS_BOARD_SUFFIX_FORMATTER = DateTimeFormatter.ofPattern("yyyyMM");}修改 保存积分明细 接口:
xxxxxxxxxxprivate 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 层:
xxxxxxxxxxpackage com.tianji.learning.controller;
("查询积分榜")public PointsBoardVO queryPointsBoardBySeason(PointsBoardQuery query){ return pointsBoardService.queryPointsBoardBySeason(query);}Service 层:
xxxxxxxxxxprivate 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;}xxxxxxxxxxprivate 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;}xxxxxxxxxxpublic 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 就是用户排名。避免了查询时的排序处理,查询效率大大提高。
历史赛季表是如何创建的?
在每个月初通过定时任务调用,完成上一赛季的表创建。
定时任务:
xxxxxxxxxxpackage 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:
xxxxxxxxxxpublic 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);}创建榜单表:
xxxxxxxxxxpublic void createPointsBoardTableBySeason(Integer season) { getBaseMapper().createPointsBoardTable(POINTS_BOARD_TABLE_PREFIX + season);}Mapper 层:
xxxxxxxxxxvoid 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 命令创建容器:
xxxxxxxxxxdocker 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>xxxxxxxxxxpackage com.tianji.common.autoconfigure.xxljob;
public class XxlJobConfig {...}xxxxxxxxxxpackage com.tianji.common.autoconfigure.xxljob;
public class XxlJobProperties {...}然后,在 PointsBoardPersistentHandler 中将任务的 @Scheduled 注解替换为 @XXLJob 注解:
xxxxxxxxxxpackage com.tianji.learning.task;
("createTableJob")public void createPointsBoardTableOfLastSeason(){...}注册执行器:登录 XXL-JOB 控制台,注册执行器:

配置任务调度:进入任务管理菜单,选中 学习中心执行器,然后新增任务:

需求:每个月第一天凌晨,将 Redis 中的上月榜单数据持久化到数据库中,并清理 Redis 数据。

动态表名:
定义一个传递表名称的工具:
xxxxxxxxxxpackage 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();}}定义一个配置类:
xxxxxxxxxxpackage 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 的拦截器配置:
xxxxxxxxxxpackage 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;}定时持久化任务:
xxxxxxxxxxpackage 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。
xxxxxxxxxxpackage 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 中的上赛季的榜单数据,避免过多的内存占用:
xxxxxxxxxxpackage 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)
xxxxxxxxxxpackage 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 层:
xxxxxxxxxxpackage com.tianji.promotion.controller;
("/coupons")(tags = "优惠券相关接口")public class CouponController { ("新增优惠券") public void saveCoupon( CouponFormDTO dto){ couponService.saveCoupon(dto); }}Service 层:
xxxxxxxxxxprivate 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);}远程调用接口:
xxxxxxxxxxpublic interface ICouponScopeService extends IService<CouponScope> {}需求:管理员可以在控制台的优惠券列表页,修改优惠券的信息。

Controller 层:
xxxxxxxxxxpackage com.tianji.promotion.controller;
("修改优惠券")("{id}")public void updateById( CouponFormDTO dto, ("id") Long id){ couponService.updateById(dto, id);}Service 层:
xxxxxxxxxxprivate 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 层:
xxxxxxxxxxpackage com.tianji.promotion.controller;
("删除优惠券") ("{id}") public void deleteById(("优惠券id") ("id") Long id){ couponService.deleteById(id); }Service 层:
xxxxxxxxxxprivate 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 层:
xxxxxxxxxxpackage com.tianji.promotion.controller;
("查询优惠券接口")("{id}")public CouponDetailVO queryById(("优惠券id") ("id") Long id){ return couponService.queryById(id);}Service 层:
xxxxxxxxxxprivate 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;}远程调用接口:
xxxxxxxxxxpackage com.tianji.api.client.course;
("getAllOfOneLevel")List<CategoryBasicDTO> getAllOfOneLevel();需求:管理员可以在管理后台分页查询优惠券信息,并且可以基于条件做过滤。

Controller 层:
xxxxxxxxxxpackage com.tianji.promotion.controller;
("分页查询优惠券")("/page")public PageDTO<CouponPageVO> queryCouponByPage(CouponQuery query){ return couponService.queryCouponByPage(query);}Service 层:
xxxxxxxxxxpublic 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 层:
xxxxxxxxxxpackage com.tianji.promotion.controller;
("发放优惠券")("/{id}/issue")public void beginIssue( CouponIssueFormDTO dto) { couponService.beginIssue(dto);}Service 层:
xxxxxxxxxxpublic 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 层:
xxxxxxxxxxpackage com.tianji.promotion.controller;
("停发优惠券")("/{id}/pause")public void pauseIssue(("优惠券id") ("id") long id) { couponService.pauseIssue(id);}Service 层:
xxxxxxxxxxpublic 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 位一组,结果如下:
xxxxxxxxxx01001 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 位的新鲜值:

算法实现:(了解)
xxxxxxxxxxpackage 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) {...}}判断是否需要生成兑换码,要同时满足两个要求:① 领取方式是兑换码方式;② 之前的状态是待发放,不能是暂停。 优惠券发放以后是可以暂停的,暂停之后还可以再次发放,再次生成兑换码时,就重复了。

而且,由于生成兑换码的数量较多,可能比较耗时,这里推荐基于线程池异步生成:
定义线程池:
xxxxxxxxxxpackage 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改造 发放优惠券 功能接口:
xxxxxxxxxxprivate 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); }}远程调用接口:
xxxxxxxxxxprivate 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 端):

xxxxxxxxxxCREATE 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 层:
xxxxxxxxxxpackage com.tianji.promotion.controller;
("查询优惠券列表")("/list")public List<CouponVO> queryIssuingCoupons(){ return couponService.queryIssuingCoupons();}Service 层:
xxxxxxxxxxpublic 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 层:
xxxxxxxxxxpackage com.tianji.promotion.controller;
("领取优惠券")("/{couponId}/receive")public void receiveCoupon(("couponId") Long couponId){ userCouponService.receiveCoupon(couponId);}Service 层:
xxxxxxxxxxprivate 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);}xxxxxxxxxxprivate 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 层:
xxxxxxxxxxpackage com.tianji.promotion.controller;
("兑换优惠券")("/{code}/exchange")public void exchangeCoupon(("code") String code){ userCouponService.exchangeCoupon(code);}Service 层:
xxxxxxxxxxprivate 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; }}修改 领取优惠券 实现类代码:
xxxxxxxxxxpublic void receiveCoupon(Long couponId) { // 4. 校验并生成用户券 checkAndCreateUserCoupon(coupon, userId, null);}xxxxxxxxxxprivate 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}; }}远程调用接口:
xxxxxxxxxxpublic 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 层:
xxxxxxxxxxpackage com.tianji.promotion.controller;
("查询我的优惠券")("/available")public List<CouponDiscountDTO> findDiscountSolution( List<OrderCourseDTO> orderCourses){ return userCouponService.findDiscountSolution(orderCourses);}Service 层:
xxxxxxxxxxpublic 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);}xxxxxxxxxxprivate 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 即可:
xxxxxxxxxxUPDATE coupon SET issue_num = issue_num + 1 WHERE id = 1 AND issue_num < total_num实现方案:
修改更新库存的 SQL 语句:
xxxxxxxxxxpackage 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 方法:
xxxxxxxxxxpackage 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。
用户限领数量判断是针对单个用户的,因此锁的范围不需要是整个方法,只要锁定某个用户即可:
xxxxxxxxxxprivate void checkAndCreateUserCoupon(Coupon coupon, Long userId, Integer serialNum){ Synchronized(userId.toString()) { // ... }}测试发现并发问题依然存在,锁失效。原来是因为 userId 是 Long 类型,其中 toString() 采用的是 new String() 的方式。
也就是说,哪怕是同一个用户 id,但 toString() 得到的也是多个不同对象,也就是多把不同的锁。
解决方案:
String 类中提供了 intern() 方法,只要两个字符串 equals 的结果为 true,那么就能保证得到的结果用 == 判断也是 true:
xxxxxxxxxxprivate 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 也新增一条券,安全问题就发生了
解决方案:
解决方法很简单,调整事务边界和锁边界:获取锁 -> 开启事务 -> 执行业务 -> 提交事务 -> 释放锁
xxxxxxxxxxpublic void receiveCoupon(Long couponId) { synchronized(userId.toString().intern()){ // 外部加锁 checkAndCreateUserCoupon(coupon, userId, null); // 内部加事务 }}
public void checkAndCreateUserCoupon(Coupon coupon, Long userId, Integer serialNum){ // ...}分析原因:
事务失效的原因有很多,接下来我们就逐一分析一些常见的原因:
事务方法非 public 修饰:
xxxxxxxxxxprivate void A() { }非事务方法调用事务方法:(此次失效原因)
xxxxxxxxxxpublic void A() { B(); }
public void B() { }事务方法的异常被捕获了:
xxxxxxxxxxpublic void A() { B(); }
private void B() { try {} catch (Exception e) {}}事务异常类型不对:
xxxxxxxxxx(rollbackFor = RuntimeException.class)public void A() throws IOException { throw new IOException();}事务传播行为不对:
xxxxxxxxxxpublic void A(){ B(); C(); throw new RuntimeException("业务异常");}
public void B() {}
(propagation = Propagation.REQUIRES_NEW)public void C() {}没有被 Spring 管理:
xxxxxxxxxx// @Servicepublic class TestService { public void A() { }}解决方案:
既然事务失效的原因是方法内部调用走的是 this,而不是代理对象,那想办法获取代理对象就可以了:
引入 AspectJ 依赖:
xxxxxxxxxx<dependency> <groupId>org.aspectj</groupId> <artifactId>aspectjweaver</artifactId></dependency>在启动类上加注解,暴露代理对象:
xxxxxxxxxx(exposeProxy = true)使用代理对象:
xxxxxxxxxxpublic 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:生成优惠券规则描述,目的是在页面直观的展示各种方案,供用户选择
我们抽象一个接口来标示优惠券规则:
xxxxxxxxxxpackage 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 层:
xxxxxxxxxxpackage com.tianji.promotion.controller;
("根据券方案计算订单优惠明细")("/discount")public CouponDiscountDTO queryDiscountDetailByOrder( OrderCouponDTO orderCouponDTO){ return discountService.queryDiscountDetailByOrder(orderCouponDTO);}Service 层:
xxxxxxxxxxprivate 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 方法:
xxxxxxxxxxprivate 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 方法:
xxxxxxxxxxpackage com.tianji.api.client.promotion.fallback;
public PromotionClient create(Throwable cause) { log.error("查询促销服务出现异常,", cause); return new PromotionClient() { // 添加接口的降级逻辑 public CouponDiscountDTO queryDiscountDetailByOrder(OrderCouponDTO orderCouponDTO) { return null; } };}改造交易服务接口:
xxxxxxxxxxpackage 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)); }}xxxxxxxxxxpublic PlaceOrderResultVO enrolledFreeCourse(Long courseId) { // 3. 订单详情 OrderDetail detail = packageOrderDetail(courseInfo, order, 0);}xxxxxxxxxxprivate OrderDetail packageOrderDetail(CourseSimpleInfoDTO courseInfo, Order order, Integer discountValue) { // ... detail.setDiscountAmount(discountValue);}| 参数 | 说明 |
|---|---|
| 请求方式 | PUT |
| 请求路径 | /user-coupons/use |
| 请求参数 | couponIds |
| 返回值 | 无 |
Controller 层:
xxxxxxxxxxpackage com.tianji.promotion.controller;
("核销优惠券")("/use")public void writeOffCoupon(("用户优惠券id集合") ("couponIds") List<Long> userCouponIds){ userCouponService.writeOffCoupon(userCouponIds);}Service 层:
xxxxxxxxxxpublic 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 方法:
xxxxxxxxxxpackage 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); } };}改造交易服务接口:
xxxxxxxxxxpackage com.tianji.trade.service.impl;
public PlaceOrderResultVO placeOrder(PlaceOrderDTO placeOrderDTO) { // 6. 核销优惠券(远程调用 writeOffCoupon) promotionClient.writeOffCoupon(couponIds);}| 参数 | 说明 |
|---|---|
| 请求方式 | PUT |
| 请求路径 | /user-coupons/refund |
| 请求参数 | couponIds |
| 返回值 | 无 |
Controller 层:
xxxxxxxxxxpackage com.tianji.promotion.controller;
("退还优惠券")("/refund")public void refundCoupon(("用户优惠券id集合") ("couponIds") List<Long> userCouponIds){ userCouponService.refundCoupon(userCouponIds);}Service 层:
xxxxxxxxxxpublic 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 方法:
xxxxxxxxxxpackage 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); } };}改造交易服务接口:
xxxxxxxxxxpackage com.tianji.trade.service.impl;
public void cancelOrder(Long orderId) { // 6. 退还优惠券(远程调用 refundCoupon) promotionClient.refundCoupon(order.getCouponIds());}| 参数 | 说明 |
|---|---|
| 请求方式 | GET |
| 请求路径 | /user-coupons/rules |
| 请求参数 | couponIds |
| 返回值 | rules |
Controller 层:
xxxxxxxxxxpackage com.tianji.promotion.controller;
("查询优惠券")("/rules")public List<String> queryDiscountRules( ("用户优惠券id集合") ("couponIds") List<Long> userCouponIds){ return userCouponService.queryDiscountRules(userCouponIds);}Service 层:
xxxxxxxxxxpublic 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 方法:
xxxxxxxxxxpackage 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(); } };}改造交易服务接口:
xxxxxxxxxxpackage 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 来做多线程的并行控制。
答:基于产品的需求,我们采用的是退款不退券的方案。
具体来说,就是在一开始下单时,就会根据优惠券本身的使用范围,筛选出订单中可以参与优惠的商品。 然后计算出每一个被优惠的商品具体的优惠金额分成,以及对应的实付金额。
如果用户选择只退部分商品,就可以根据每个商品的实付金额来退款,同时也满足退款不退券的原则。
当然,如果订单未支付、取消订单或者超时关闭,是可以退券的。