在演示项目业务流程时,我们发现搜索课程、报名课程等流程都已经完成开发了。并且在《个人中心-我的订单》页面可以看到我们下单报名的课程:
然而,在我的课程页面中,却看不到这些课程:
看不到课程自然就无法学习。所以今天我们要完成的任务就是开发学习中心的《我的课程表》相关接口,让学员看到课程,然后才可以学习课程。
开发流程:
需要强调的一点是,开发中最重要的环节其实是前两步:
- 原型分析、接口设计
- 数据库设计
只要前两步分析完成,功能开发就比较简单了。因此,我们将遵循企业开发的流程,先分析原型、设计接口,再设计数据库结构,最后再开发接口功能。
接口设计
在用户的《个人中心》,有一个《我的课程》列表页,如图:
那么这些课程是从何而来的,原型的页面说明中有告诉我们:
从这里可以看出,凡是购买过的课程,都应该加入到课程列表中。
需要注意的是,刚刚加入课表的课程处于未学习状态,这个时候学员可以创建一个学习计划,规划后期的学习节奏:
页面开发规则
- 已购课程状态变化
1) 未学习,已购买课程还未开始学习,可以开始学习
2) 已学习,已购买课程已开始学习,展示学习进度,可以继续学习
3) 已学完,已购买课程已经学完,可以重新学习
4) 已失效,已购买课程已过期,不可继续学习,只能删除课程操作
推测出《课程表》的业务流转过程是这样的:
接口统计
加入课表
首先,用户支付完成后,需要将购买的课程加入课表:
而支付成功后,交易服务会基于MQ通知的方式,通知学习服务来执行加入课表的动作。因此,我们要实现的第一个接口就是:
分页查询课表
在加入课表以后,用户就可以在个人中心查看到这些课程:
因此,这里就需要第二个接口:
当课程学完后,可以选择删除课程:
所以,还要有删除课程的接口:第三个接口
除此以外,如果用户退款,也应该删除课表中的课程,这里同样是通过MQ通知来实现:第4个接口
查询学习进度
在个人中心,我的课表页面,还能看到用户最近的学习进度:
这里就包含两个接口:
查询指定课程学习状态
在课程详情页面,如果当前课程已经购买,也要展示出课程的学习进度:
接口:
内部访问接口
除了页面原型中看到的接口以外,其它微服务也对tj-learning服务有数据需求,并且也定义了一些需要我们实现的Feign接口。
在项目中,所有Feign接口都定义在了tj-api模块下,learning服务的接口定义在com.tianji.api.client.learning模块下:
这里包含两个接口:
总结 我的课表有关的接口
接口设计的核心要素
- 请求方式
- 请求路径
- 请求参数格式
- 返回值格式
分页查询我的课表
-
请求方式:按照Restful风格,查询请求应该使用GET方式。
-
请求路径:一般是资源名称,比如这里资源是课表,所以资源名可以使用lessons,同时这里是分页查询,可以在路径后跟一个/page,代表分页查询
-
请求参数:因为是分页查询,首先肯定要包含分页参数,一般有两个:
- pageNo:页码
- pageSize:每页大小
-
返回值:这里的返回值复杂一些,需要结合页面需要展示的信息来看:
最终的接口信息如下:
与这个接口对应的,我们需要定义一下几个实体:
- 统一的分页请求Query实体
- 统一的分页结果DTO实体
- 课表分页VO实体
由于分页请求、分页结果比较常见,我们提前在tj-common模块定义好了。
其中,统一分页请求实体,称为PageQuery:
统一分页结果实体,称为PageDTO:
最后,返回结果中的课表VO实体
@Data
@ApiModel(description = "课程表信息")
public class LearningLessonVO {
@ApiModelProperty("主键lessonId")
private Long id;
@ApiModelProperty("课程id")
private Long courseId;
@ApiModelProperty("课程名称")
private String courseName;
@ApiModelProperty("课程封面")
private String courseCoverUrl;
@ApiModelProperty("课程章节数量")
private Integer sections;
@ApiModelProperty("课程状态,0-未学习,1-学习中,2-已学完,3-已失效")
private LessonStatus status;
@ApiModelProperty("总已学习章节数")
private Integer learnedSections;
@ApiModelProperty("总已报名课程数")
private Integer courseAmount;
@ApiModelProperty("课程购买时间")
private LocalDateTime createTime;
@ApiModelProperty("课程过期时间,如果为null代表课程永久有效")
private LocalDateTime expireTime;
@ApiModelProperty("习计划状态,0-没有计划,1-计划进行中")
private PlanStatus planStatus;
@ApiModelProperty("计划的学习频率")
private Integer weekFreq;
@ApiModelProperty("最近学习的小节名")
private String latestSectionName;
@ApiModelProperty("最近学习的小节编号")
private Integer latestSectionIndex;
}
添加课程到课表(MQ异步)
当用户支付完成或者报名免费课程后,应该立刻将课程加入到课表中。交易服务会通过MQ通知学习服务,我们需要查看交易服务的源码,查看MQ通知的消息格式,来确定监听消息的格式。
获取用户id使用的threadlocal:基本用法:
- 在并发请求情况下,因为每次请求都有不同的用户信息,我们必须保证每次请求保存的用户信息互不干扰,线程独立。
- 注意:这里不是解决多线程资源共享问题,而是要保证每个线程都有自己的用户资源,互不干扰 ;
- ThreadLocal的作用主要是做数据隔离,填充的数据只属于当前线程,变量的数据对别的线程而言是相对隔离的
- ThreadLocal提供线程局部变量,一个变量用在多个线程中分别有独立的值(副本)
- 不同线程,内部有自己的ThreadLocalMap,因此Map中的资源互相不会干扰
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserDTO {
private Long id;
private String nickName;
private String icon;
}
public class UserHolder {
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
public static void saveUser(UserDTO user){
tl.set(user);
}
public static UserDTO getUser(){
return tl.get();
}
public static void removeUser(){
tl.remove();
}
}
然后我们需要在登录的拦截器中,也就是在preHandle()方法中就将用户信息存到ThreadLocal中,
也就是在执行所有controller之前,这个方法一般来说是用来通过校验token判断用户是否登录的。
String token= request.getHeader("Authorization");
我们以免费报名课程为例来看:
在trade-service的OrderController中,有一个报名免费课程的接口:
@ApiOperation("免费课立刻报名接口")
@PostMapping("/freeCourse/{courseId}")
public PlaceOrderResultVO enrolledFreeCourse(
@ApiParam("免费课程id") @PathVariable("courseId") Long courseId) {
return orderService.enrolledFreeCourse(courseId);
}
可以看到这里调用了OrderService的enrolledFreeCourse()方法:
@Override
@Transactional
public PlaceOrderResultVO enrolledFreeCourse(Long courseId) {
Long userId = UserContext.getUser();//从Threadlocal获取用户id
// 1.查询课程信息
List<Long> cIds = CollUtils.singletonList(courseId);
List<CourseSimpleInfoDTO> courseInfos = getOnShelfCourse(cIds);
if (CollUtils.isEmpty(courseInfos)) {
// 课程不存在
throw new BizIllegalException(TradeErrorInfo.COURSE_NOT_EXISTS);
}
CourseSimpleInfoDTO courseInfo = courseInfos.get(0);
if(!courseInfo.getFree()){
// 非免费课程,直接报错
throw new BizIllegalException(TradeErrorInfo.COURSE_NOT_FREE);
}
// 2.创建订单
Order order = new Order();
// 2.1.基本信息
order.setUserId(userId);
order.setTotalAmount(0);
order.setDiscountAmount(0);
order.setRealAmount(0);
order.setStatus(OrderStatus.ENROLLED.getValue());
order.setFinishTime(LocalDateTime.now());
order.setMessage(OrderStatus.ENROLLED.getProgressName());
// 2.2.订单id
Long orderId = IdWorker.getId(order);
order.setId(orderId);
// 3.订单详情
OrderDetail detail = packageOrderDetail(courseInfo, order);
// 4.写入数据库
saveOrderAndDetails(order, CollUtils.singletonList(detail));
// 5.发送MQ消息,通知报名成功
rabbitMqHelper.send(
MqConstants.Exchange.ORDER_EXCHANGE,
MqConstants.Key.ORDER_PAY_KEY,
OrderBasicDTO.builder().orderId(orderId).userId(userId).courseIds(cIds).build());
// 6.返回vo
return PlaceOrderResultVO.builder()
.orderId(orderId)
.payAmount(0)
.status(order.getStatus())
.build();
}
其中,通知报名成功的逻辑是这部分:
由此,我们可以得知发送消息的Exchange、RoutingKey,以及消息体。消息体的格式是OrderBasicDTO,包含四个字段:
- orderId:订单id
- userId:下单的用户id
- courseIds:购买的课程id集合
- finishTime:支付完成时间
因此,在学习服务,我们需要编写的消息监听接口规范如下:
其中的请求参数实体,由于是与交易服务公用的数据传递实体,也就是DTO,因此已经提前定义到了tj-api模块下的DTO包了。
查询正在学习的课程
页面原型中,有两个地方需要查看正在学习的课程。
第一个,在个人中心-我的课程:
另一个,在已登录情况下,首页的悬浮窗中:
最终的接口规则如下:
根据id查询某课程学习状态
在课程详情页,课程展示有两种不同形式:
- 对于未购买的课程:展示为立刻购买或加入购物车
- 对于已经购买的课程:展示为马上学习,并且显示学习的进度、有效期
最终的接口设计如下:
这里的返回值VO结构在之前定义的LearningLessonVO中都包含了,因此可以直接复用该VO,不再重复定义。
ER图
我们可以结合原型图中包含的信息来画一个ER图,分析我的课表包含的信息:
创建分支
一般开发新功能都需要创建一个feature类型分支,不能在DEV分支直接开发,因此这里我们新建一个功能分支。我们在项目目录中打开terminal控制台,输入命令:
git checkout -b feature-lessons
Feature:功能分支,从Develop分支创建得来。开发测试完成后会合并到Develop分支。
发现整个项目都切换到了新的功能分支:
MP代码生成
我们使用的是Mybatis作为持久层框架,并且引入了MybatisPlus来简化开发。因此,在创建据库以后,就需要创建对应的实体类、mapper、service等。
这些代码格式固定,编写起来又比较费时。好在IDEA中提供了一个MP插件,可以生成这些重复代码:
安装完成以后,我们先配置一下数据库地址:
然后配置代码自动生成的设置:
严格按照下图的模式去设置(图片放大后更清晰),不要填错项目名称和包名称:
最后,点击code generatro按钮,即可生成代码:
按照Restful风格,对请求路径做修改:
3.实现接口功能
- 添加课程到课表
- 分页查询我的课表
添加课程到课表
回顾一下接口信息:
其中的Exchange、RoutingKey都已经在tj-common中的MqConstants内定义好了:
我们只需要定义消息监听器就可以了。
定义消息监听器
在tj-learning服务中定义一个MQ的监听器:
@Slf4j
@Component
@RequiredArgsConstructor //用于构造注入
public class LessonChangeListener {
private final ILearningLessonService lessonService;
/**
* 监听订单支付或课程报名的消息
* @param order 订单信息
*/
@RabbitListener(bindings = @QueueBinding(
value = @Queue(value = "learning.lesson.pay.queue", durable = "true"),
exchange = @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());
}
}
订单中与课表有关的字段就是userId、courseId,因此这里要传递的就是这两个参数。
注意,这里添加课程的核心逻辑是在ILearningLessonService中实现的,首先是接口声明:
/**
* <p>
* 学生课程表 服务类
* </p>
*/
public interface ILearningLessonService extends IService<LearningLesson> {
void addUserLessons(Long userId, List<Long> courseIds);
}
@Service
public class LearningLessonServiceImpl extends ServiceImpl<LearningLessonMapper, LearningLesson>
implements ILearningLessonService {
@Override
public void addUserLessons(Long userId, List<Long> courseIds) {
// TODO 添加课程信息到用户课程表
}
}
订单中一个用户可能购买多个课程。因此请求参数中的courseId集合就需要逐个处理,将来会有多条课表数据。我们要做的事情就是根据courseId集合查询课程信息,然后分别计算每个课程的有效期,组织多个LearingLesson的数据,形成集合。最终批量新增到数据库即可。
流程如图:
我们该如何根据课程id查询课程信息呢?
获取课程信息
课程(course)的信息是由课程服务(course-service)来维护的,目前已经开发完成并部署到了虚拟机的开发环境中。
我们现在需要查询课程信息,自然需要调用课程服务暴露的Feign接口。
3.1实现添加课程到课表
我们正式实现LearningLessonServiceImpl中的addUserLessons方法:
package com.tianji.learning.service.impl;
// 略
@SuppressWarnings("ALL")
@Service
@RequiredArgsConstructor //用于构造注入
@Slf4j
public class LearningLessonServiceImpl extends ServiceImpl<LearningLessonMapper, LearningLesson>
implements ILearningLessonService {
//构造注入 远程服务对象
private final CourseClient courseClient;
@Override
@Transactional
public void addUserLessons(Long userId, List<Long> courseIds) {
// 1.根据课程id 从course服务中得到课程信息
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();
// 2.1.获取过期时间
Integer validDuration = cInfo.getValidDuration();
if (validDuration != null && validDuration > 0) {
LocalDateTime now = LocalDateTime.now();
lesson.setCreateTime(now);
lesson.setExpireTime(now.plusMonths(validDuration));
}
// 2.2.填充userId和courseId
lesson.setUserId(userId);
lesson.setCourseId(cInfo.getId());
list.add(lesson);
}
// 3.批量新增
saveBatch(list);
}
}
3.2.分页查询我的课表
回顾一下接口信息:
3.2.1.实体
在实现接口的时候,往往需要先把接口的请求参数、返回值对应的实体类声明出来。
3.2.1.1.Query实体
在这个接口中,请求参数是一个通用的分页参数,我们在tj-common已经声明了:
主要的四个字段如下:
@Data
@ApiModel(description = "分页请求参数")
@Accessors(chain = true)
public class PageQuery {
public static final Integer DEFAULT_PAGE_SIZE = 20;
public static final Integer DEFAULT_PAGE_NUM = 1;
@ApiModelProperty(value = "页码", example = "1")
@Min(value = 1, message = "页码不能小于1")
private Integer pageNo = DEFAULT_PAGE_NUM;
@ApiModelProperty(value = "每页大小", example = "5")
@Min(value = 1, message = "每页查询数量不能小于1")
private Integer pageSize = DEFAULT_PAGE_SIZE;
@ApiModelProperty(value = "是否升序", example = "true")
private Boolean isAsc = true;
@ApiModelProperty(value = "排序字段", example = "id")
private String sortBy;
3.2.1.2.DTO实体
返回值是一个分页结果,因为分页太常用了,所以我们在tj-common定义了一个通用的分页结果类:
3.2.1.3.VO实体
返回值的分页结果中有一个实体集合,也就是VO实体
3.2.2.接口声明
首先是controller,tj-learning服务的LearningLessonController:
@Api(tags = "我的课表相关接口")
@RestController
@RequestMapping("/lessons")
@RequiredArgsConstructor
public class LearningLessonController {
private final ILearningLessonService lessonService;
@ApiOperation("查询我的课表,排序字段 latest_learn_time:学习时间排序,create_time:购买时间排序")
@GetMapping("/page")
public PageDTO<LearningLessonVO> queryMyLessons(PageQuery query) {
//参数 : 3.2.1.1.Query实体
return lessonService.queryMyLessons(query);
}
}
然后是service的接口,tj-learning服务的ILearningLessonService:
PageDTO<LearningLessonVO> queryMyLessons(PageQuery query);
最后是实现类,tj-learning服务的LearningLessonServiceImpl:
@Override
public PageDTO<LearningLessonVO> queryMyLessons(PageQuery query) {
// TODO 分页查询我的课表
return null;
}
3.2.3.获取登录用户
既然是分页查询我的课表,除了分页信息以外,我还必须知道当前登录的用户是谁。那么,该从哪里获取用户信息呢?
3.2.3.1.实现思路
基于JWT实现登录,登录信息就保存在请求头的token中。因此要获取当前登录用户,只要获取请求头,解析其中的token即可。
但是,每个微服务都可能需要登录用户信息,在每个微服务都做token解析就属于重复编码了。因此我们的把token解析的行为放到了网关中,然后由网关把用户信息放入请求头,传递给下游微服务。
每个微服务要从请求头拿出用户信息,在业务中使用,也比较麻烦,所以我们定义了一个HandlerInterceptor,拦截进入微服务的请求,并获取用户信息,存入UserContext(底层基于ThreadLocal)。这样后续的业务处理时就能直接从UserContext中获取用户了
修改之前的tj-learning中的LearningLessonServiceImpl的queryMyLessons方法:
@Override
public PageDTO<LearningLessonVO> queryMyLessons(PageQuery query) {
// 1.获取当前登录用户
Long userId = UserContext.getUser();
// 2.分页查询
// select * from learning_lesson where user_id = #{userId} order by latest_learn_time limit 0, 5
//MP实现分页查询代码
Page<LearningLesson> page = lambdaQuery()
.eq(LearningLesson::getUserId, userId) // where user_id = #{userId}
.page(query.toMpPage("latest_learn_time", false));//以谁(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());
// 4.1.循环遍历,把LearningLesson转为VO
for (LearningLesson r : records) {
// 4.2.拷贝基础属性到vo
LearningLessonVO vo = BeanUtils.copyBean(r, LearningLessonVO.class);
// 4.3.获取课程信息,填充到vo
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.把课程集合处理成Map,key是courseId,值是course本身
Map<Long, CourseSimpleInfoDTO> cMap = cInfoList.stream()
.collect(Collectors.toMap(CourseSimpleInfoDTO::getId, c -> c));
return cMap;
}
3.3.查询正在学习的课程
回顾一下接口信息:
可以看到返回值结果与分页查询的课表VO基本类似,因此这里可以复用LearningLessonVO实体,但是需要添加几个字段:
- courseAmount
- latestSectionName
- latestSectionIndex
小节名称、序号信息都在课程微服务(course-service)中,因此可以通过课程微服务提供的接口来查询:
接口:
其中CataSimpleInfoDTO中就包含了章节信息:
@Data
public class CataSimpleInfoDTO {
@ApiModelProperty("目录id")
private Long id;
@ApiModelProperty("目录名称")
private String name;
@ApiModelProperty("数字序号,不包含章序号")
private Integer cIndex;
}
省略controller和service 直接写实现类:如下
private final CatalogueClient catalogueClient;
@Override
public LearningLessonVO queryMyCurrentLesson() {
// 1.获取当前登录的用户
Long userId = UserContext.getUser();
// 2.查询正在学习的课程 select * from xx where user_id = #{userId} AND status = 1 order by latest_learn_time limit 1
// 取最近的 一个 即最后学习的第一条记录 limit 1
LearningLesson lesson = lambdaQuery()
.eq(LearningLesson::getUserId, userId)
.eq(LearningLesson::getStatus, LessonStatus.LEARNING.getValue())//status = 1(LEARNING)表示正在学习的课程
.orderByDesc(LearningLesson::getLatestLearnTime)
.last("limit 1")
.one();
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.统计课表中的课程数量 select count(1) from xxx where user_id = #{userId}
Integer courseAmount = lambdaQuery()
.eq(LearningLesson::getUserId, userId)
.count();
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;
}