天机学堂(学习计划和进度)
经过前面的努力,我们已经完成了《我的课程表》相关的功能的基础部分,不过还有功能实现的并不完善。还记得昨天给大家的练习题吗?《查询我正在学习的课程》,在原型图中有这样的一个需求:
我们需要在查询结果中返回已学习课时数、正在学习的章节名称。虽然我们在learning_lesson表中设计了两个字段:
-
learned_sections:已学习章节数
-
latest_learn_time:最近学习时间
但是,这几个字段默认都是空或0,我们该如何得知用户到底学习了几节?最近一次学习是什么时候?最近一次学习的是第几章节呢?
以上的问题归纳下来,就是一个学习进度统计问题,这在在线教育、视频播放领域是一个非常常见的问题。因此,学会了解决这套解决方案,你就能游刃有余的应对相关行业的类似问题了。
大家在学习这套解决方案的同时,也可以增强下面的能力:
-
需求分析和表设计能力
-
复杂SQL的编写能力
-
处理高并发写数据库的能力
1.分析产品原型
大部分人的学习自律性是比较差的,属于“买了就算会了”的状态。如果学员学习积极性下降,学习结果也会不尽人意,从而产生挫败感。导致购买课程的欲望也会随之下降,形成恶性循环,不利于我们卖课。
所以,我们推出学习计划的功能,让学员制定一套学习计划,每周要学几节课。系统会做数据统计,每一周计划是否达标,达标后给予奖励,未达标则提醒用户,达到督促用户持续学习的目的。
用户学习效果好了,产生了好的结果,就会有继续学习、购买课程的欲望,形成良性循环。
因此,学习计划、学习进度统计其实是学习辅助中必不可少的环节。
1.1.分析业务流程
我们从两个业务点来分析:
-
学习计划
-
学习进度统计
1.1.1.学习计划
在我的课程页面,可以对有效的课程添加学习计划:
学习计划就是简单设置一下用户每周计划学习几节课:
这个在昨天的数据库设计中已经有对应的字段了,只不过功能尚未完成。
有了计划以后,我们就可以在我的课程页面展示用户计划的完成情况,提醒用户尽快学习:
可以看到,在学习计划中是需要统计用户“已经学习的课时数量”的。那么我们该如何统计用户学了多少课时呢?
1.1.2.学习进度统计
要统计学习进度,需要先弄清楚用户学习的方式,学习的内容。在原型图《课程学习页-录播课-课程学习页-目录》中,可以看到学习课程的原型图:
一个课程往往包含很多个章(chapter),每一章下又包含了很多小节(section)。章本身没有课程内容,只是划分课程的一个概念,因此统计学习进度就是看用户学了多少个小节。
小节也分两种,一种是视频;一种是每章最后的阶段考试。用户学完一个视频,或者参加了最终的考试都算学完了一个小节。
考试只要提交了就算学完了,比较容易判断是否学完。但是视频该如何统计呢?达到什么样的标准才算这一小节的视频学完了呢?
这里我们不能要求用户一定要播放进度到100%,太苛刻了。所以,天机学堂的产品是这样设计的:
因此,只要视频播放进度达到50%就算是完成本节学习了。所以用户在播放视频的过程中,需要不断提交视频的播放进度,当我们发现视频进度超过50%时就可以标记这一小节为已学完。
当然,我们不能仅仅记录视频是否学完,还应该记录用户具体播放的进度到了第几秒。只有这样在用户关闭视频,再次播放时我们才能实现视频自动续播功能,用户体验会比较好。
也就是说,要记录用户学习进度,需要记录下列核心信息:
-
小节的基础信息(id、关联的课程id等)
-
当前的播放进度(第几秒)
-
当前小节是否已学完(播放进度是否超50%)
用户每学习一个小节,就会新增一条学习记录,当该课程的全部小节学习完毕,则该课程就从学习中进入已学完状态了。整体流程如图:
1.2.业务接口统计
接下来我们分析一下这部分功能相关的接口有哪些,按照用户的学习顺序,依次有下面几个接口:
-
创建学习计划
-
查询学习记录
-
提交学习记录
-
查询我的计划
1.2.1.创建学习计划
在个人中心的我的课表列表中,没有学习计划的课程都会有一个创建学习计划的按钮,在原型图就能看到:
创建学习计划,本质就是让用户设定自己每周的学习频率:
而学习频率我们在设计learning_lesson表的时候已经有两个字段来表示了:
CREATE TABLE `learning_lesson` (`id` bigint NOT NULL COMMENT '主键',`user_id` bigint NOT NULL COMMENT '学员id',`course_id` bigint NOT NULL COMMENT '课程id',`status` tinyint NULL DEFAULT 0 COMMENT '课程状态,0-未学习,1-学习中,2-已学完,3-已失效',`week_freq` tinyint NULL 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 NULL DEFAULT NULL COMMENT '最近一次学习的小节id',`latest_learn_time` datetime NULL 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`) USING BTREE,UNIQUE INDEX `idx_user_id`(`user_id`, `course_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '学生课程表' ROW_FORMAT = Dynamic;
当我们创建学习计划时,就是更新learning_lesson
表,写入week_freq
并更新plan_status
为计划进行中即可。因此请求参数就是课程的id、每周学习频率。
再按照Restful风格,最终接口如下:
参数 | 说明 | ||
---|---|---|---|
请求方式 | POST | ||
请求路径 | /lessons/plans | ||
请求参数 | 参数名 | 类型 | 说明 |
courseId | Long | 课程id | |
weekFreq | Integer | 计划每周学习频率 | |
返回值 | 无 |
1.2.2.查询学习记录
用户创建完计划自然要开始学习课程,在用户学习视频的页面,首先要展示课程的一些基础信息。例如课程信息、章节目录以及每个小节的学习进度:
其中,课程、章节、目录信息等数据都在课程微服务,而学习进度肯定是在学习微服务。课程信息是必备的,而学习进度却不一定存在。
因此,查询这个接口的请求肯定是请求到课程微服务,查询课程、章节信息,再由课程微服务向学习微服务查询学习进度,合并后一起返回给前端即可。
所以,学习中心要提供一个查询章节学习进度的Feign接口,事实上这个接口已经在tj-api模块的LearningClient中定义好了:
/*** 查询当前用户指定课程的学习进度* @param courseId 课程id* @return 课表信息、学习记录及进度信息*/
@GetMapping("/learning-records/course/{courseId}")
LearningLessonDTO queryLearningRecordByCourse(@PathVariable("courseId") Long courseId);
对应的DTO也都在tj-api模块定义好了,因此整个接口规范如下:
参数 | 说明 | ||||
---|---|---|---|---|---|
请求方式 | GET | ||||
请求路径 | /learning-records/course/{courseId} | ||||
请求参数 | 路径占位符参数,courseId:课表关联的课程id | ||||
返回值 | 参数名 | 类型 | 说明 | ||
id | Long | 课表id | |||
latestSectionid | Long | 最近学习的小节id | |||
records | array | 参数名 | 类型 | 说明 | |
sectionId | Long | 小节id | |||
moment | int | 视频播放进度,第几秒 | |||
finished | boolean | 是否学完 |
1.2.3.提交学习记录
之前分析业务流程的时候已经聊过,学习记录就是用户当前学了哪些小节,以及学习到该小节的进度如何。而小节类型分为考试、视频两种。
-
考试比较简单,只要提交了就说明这一节学完了。
-
视频比较麻烦,需要记录用户的播放进度,进度超过50%才算学完。因此视频播放的过程中需要不断提交播放进度到服务端,而服务端则需要保存学习记录到数据库。
只要记录了用户学过的每一个小节,以及小节对应的学习进度、是否学完。无论是视频续播、还是统计学习计划进度,都可以轻松实现了。
因此,提交学习记录就是提交小节的信息和小节的学习进度信息。考试提交一次即可,视频则是播放中频繁提交。提交的信息包括两大部分:
-
小节的基本信息
-
小节id
-
lessonId
-
小节类型:可能是视频,也可能是考试。考试无需提供播放进度信息
-
提交时间
-
-
播放进度信息
-
视频时长:时长结合播放进度可以判断有没有超过50%
-
视频播放进度:也就是第几秒
-
综上,提交学习记录的接口信息如下:
参数 | 说明 | ||
---|---|---|---|
请求方式 | POST | ||
请求路径 | /learning-records | ||
请求参数 | 参数名 | 类型 | 说明 |
lessonId | long | 课表id | |
sectionId | long | 小节id | |
sectionType | int | 小节类型:1-视频,2-考试 | |
commitTime | LocalDateTime | 提交时间 | |
duration | int | 视频总时长,单位秒 | |
moment | int | 视频播放进度,单位秒 | |
返回值 | 无 | ||
接口描述 |
|
1.2.4.查询我的学习计划
在个人中心的我的课程页面,会展示用户的学习计划及本周的学习进度,原型如图:
需要注意的是这个查询其实是一个分页查询,因为页面最多展示10行,而学员同时在学的课程可能会超过10个,这个时候就会分页展示,当然这个分页可能是滚动分页,所以没有进度条。另外,查询的是我的学习计划,隐含的查询条件就是当前登录用户,这个无需传递,通过请求头即可获得。
因此查询参数只需要分页参数即可。
查询结果中有很多对于已经学习的小节数量的统计,因此将来我们一定要保存用户对于每一个课程的学习记录,哪些小节已经学习了,哪些已经学完了。只有这样才能统计出学习进度。
查询的结果如页面所示,分上下两部分。:
总的统计信息:
-
本周已完成总章节数:需要对学习记录做统计
-
课程总计划学习数量:累加课程的总计划学习频率即可
-
本周学习积分:积分暂不实现
正在学习的N个课程信息的集合,其中每个课程包含下列字段:
-
该课程本周学了几节:统计学习记录
-
计划学习频率:在learning_lesson表中有对应字段
-
该课程总共学了几节:在learning_lesson表中有对应字段
-
课程总章节数:查询课程微服务
-
该课程最近一次学习时间:在learning_lesson表中有对应字段
综上,查询学习计划进度的接口信息如下:
参数 | 说明 | ||||
---|---|---|---|---|---|
请求方式 | GET | ||||
请求路径 | /lessons/plans | ||||
请求参数 | 分页参数:PageQuery | ||||
返回值 | 参数名 | 类型 | 说明 | ||
weekPoints | int | 本周学习积分 | |||
weekFinished | int | 本周已学完小节数量 | |||
weekTotalPlan | int | 本周计划学习小节数量 | |||
list | Array | 参数 | 类型 | 说明 | |
courseId | Long | 课程id | |||
courseName | String | 课程名称 | |||
weekLearnedSections | int | 本周学习的小节数量 | |||
weekFreq | int | 本周计划学习数量 | |||
learnedSections | int | 总已学习小节数量 | |||
sections | int | 总小节数量 | |||
latestLearnTime | LocalDateTime | 最近一次学习时间 |
1.3.设计数据库
数据表的设计要满足学习计划、学习进度的功能需求。学习计划信息在learning_lesson
表中已经设计,因此我们关键是设计学习进度记录表即可。
按照之前的分析,用户学习的课程包含多个小节,小节的类型包含两种:
-
视频:视频播放进度超过50%就算当节学完
-
考试:考完就算一节学完
学习进度除了要记录哪些小节学完,还要记录学过的小节、每小节的播放的进度(方便续播)。因此,需要记录的数据就包含以下部分:
-
学过的小节的基础信息
-
小节id
-
小节对应的lessonId
-
用户id:学习课程的人
-
-
小节的播放进度信息
-
视频播放进度:也就是播放到了第几秒
-
是否已经学完:播放进度有没有超过50%
-
第一次学完的时间:用户可能重复学习,第一次从未学完到学完的时间要记录下来
-
再加上一些表基础字段,整张表结构就出来了:
CREATE TABLE IF NOT EXISTS `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 DEFAULT '0' COMMENT '视频的当前观看时间点,单位秒',`finished` 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_update_time` (`update_time`) USING BTREE,KEY `idx_user_id` (`user_id`) USING BTREE,KEY `idx_lesson_id` (`lesson_id`,`section_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='学习记录表';
课前资料也提供了对应的SQL语句:
1.4.生成基础代码
接下来我们就可以生成数据库实体对应的基础代码了。
1.4.1.创建新分支
动手之前,不要忘了开发新功能需要创建新的分支。这里我们依然在DEV
分支基础上,创建一个新的feature
类型分支:feature-learning-records
我们可以选择用命令:
git checkout -b feature-learning-records
也可以选择图形界面方式:
1.4.2.代码生成
同样是使用MybatisPlus插件,这里不再赘述。效果如下:
需要注意的是,我们同样需要把生成的实体类的ID策略改成雪花算法:
另外,按照Restful风格, 把controller的路径做修改:
1.4.3.类型枚举
在昨天学习的课表中,有一种状态枚举,就是把课程的状态通过枚举定义出来,避免出现错误。而在学习记录中,有一个section_type字段,代表记录的小节有两种类型:
-
1,视频类型
-
2,考试类型
为了方便我们也定义为枚举,称为类型枚举:
具体代码:
package com.tianji.learning.enums;import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import com.tianji.common.enums.BaseEnum;
import lombok.Getter;@Getter
public enum SectionType implements BaseEnum {VIDEO(1, "视频"),EXAM(2, "考试"),;@JsonValue@EnumValueint value;String desc;SectionType(int value, String desc) {this.value = value;this.desc = desc;}@JsonCreator(mode = JsonCreator.Mode.DELEGATING)public static SectionType of(Integer value){if (value == null) {return null;}for (SectionType status : values()) {if (status.equalsValue(value)) {return status;}}return null;}
}
2.实现接口
2.1.查询学习记录
首先回顾一下接口基本信息:
参数 | 说明 | ||||
---|---|---|---|---|---|
请求方式 | GET | ||||
请求路径 | /learning-records/course/{courseId} | ||||
请求参数 | 路径占位符参数,courseId:课表关联的课程id | ||||
返回值 | 参数名 | 类型 | 说明 | ||
id | Long | 课表id | |||
latestSectionid | Long | 最近学习的小节id | |||
records | array | 参数名 | 类型 | 说明 | |
sectionId | Long | 小节id | |||
moment | int | 视频播放进度,第几秒 | |||
finished | boolean | 是否学完 |
2.1.1.思路分析
做个接口是给课程微服务调用的,因此在tj-api模块的LearningClient中定义好了:
/*** 查询当前用户指定课程的学习进度* @param courseId 课程id* @return 课表信息、学习记录及进度信息*/
@GetMapping("/learning-records/course/{courseId}")
LearningLessonDTO queryLearningRecordByCourse(@PathVariable("courseId") Long courseId);
对应的DTO也都在tj-api模块定义好了。我们直接实现接口即可。
由于请求参数是courseId
,而返回值中包含lessonId
和latestSectionid
都在learning_lesson
表中,因此我们需要根据courseId和userId查询出lesson信息。然后再根据lessonId查询学习记录。整体流程如下:
-
获取当前登录用户id
-
根据courseId和userId查询LearningLesson
-
判断是否存在或者是否过期
-
如果不存在或过期直接返回空
-
如果存在并且未过期,则继续
-
-
查询lesson对应的所有学习记录
2.1.2.代码实现
首先在tj-learning
模块下的com.tianji.learning.controller.LearningRecordController
下定义接口:
package com.tianji.learning.controller;import com.tianji.api.dto.leanring.LearningLessonDTO;
import com.tianji.learning.service.ILearningRecordService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;/*** <p>* 学习记录表 前端控制器* </p>*/
@RestController
@RequestMapping("/learning-records")
@Api(tags = "学习记录的相关接口")
@RequiredArgsConstructor
public class LearningRecordController {private final ILearningRecordService recordService;@ApiOperation("查询指定课程的学习记录")@GetMapping("/course/{courseId}")public LearningLessonDTO queryLearningRecordByCourse(@ApiParam(value = "课程id", example = "2") @PathVariable("courseId") Long courseId){return recordService.queryLearningRecordByCourse(courseId);}
}
然后在com.tianji.learning.service.ILearningRecordService
中定义方法:
package com.tianji.learning.service;import com.baomidou.mybatisplus.extension.service.IService;
import com.tianji.api.dto.leanring.LearningLessonDTO;
import com.tianji.learning.domain.po.LearningRecord;/*** <p>* 学习记录表 服务类* </p>*/
public interface ILearningRecordService extends IService<LearningRecord> {LearningLessonDTO queryLearningRecordByCourse(Long courseId);
}
最后在com.tianji.learning.service.impl.LearningRecordServiceImpl中定义实现类:
package com.tianji.learning.service.impl;import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.tianji.api.dto.leanring.LearningLessonDTO;
import com.tianji.api.dto.leanring.LearningRecordDTO;
import com.tianji.common.utils.BeanUtils;
import com.tianji.common.utils.UserContext;
import com.tianji.learning.domain.po.LearningLesson;
import com.tianji.learning.domain.po.LearningRecord;
import com.tianji.learning.mapper.LearningRecordMapper;
import com.tianji.learning.service.ILearningLessonService;
import com.tianji.learning.service.ILearningRecordService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;import java.util.List;/*** <p>* 学习记录表 服务实现类* </p>** @author 虎哥* @since 2022-12-10*/
@Service
@RequiredArgsConstructor
public class LearningRecordServiceImpl extends ServiceImpl<LearningRecordMapper, LearningRecord> implements ILearningRecordService {private final ILearningLessonService lessonService;@Overridepublic LearningLessonDTO queryLearningRecordByCourse(Long courseId) {// 1.获取登录用户Long userId = UserContext.getUser();// 2.查询课表LearningLesson lesson = lessonService.queryByUserAndCourseId(userId, courseId);// 3.查询学习记录// select * from xx where lesson_id = #{lessonId}List<LearningRecord> records = lambdaQuery().eq(LearningRecord::getLessonId, lesson.getId()).list();// 4.封装结果LearningLessonDTO dto = new LearningLessonDTO();dto.setId(lesson.getId());dto.setLatestSectionId(lesson.getLatestSectionId());dto.setRecords(BeanUtils.copyList(records, LearningRecordDTO.class));return dto;}
}
其中查询课表的时候,需要调用ILessonService
中的queryByUserAndCourseId()
方法,该方法代码如下:
@Override
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().eq(LearningLesson::getUserId, userId).eq(LearningLesson::getCourseId, courseId);return queryWrapper;
}
2.2.提交学习记录
回顾一下接口信息:
参数 | 说明 | ||
---|---|---|---|
请求方式 | POST | ||
请求路径 | /learning-records | ||
请求参数 | 参数名 | 类型 | 说明 |
lessonId | long | 课表id | |
sectionId | long | 小节id | |
sectionType | int | 小节类型:1-视频,2-考试 | |
commitTime | LocalDateTime | 提交时间 | |
duration | int | 视频总时长,单位秒 | |
moment | int | 视频播放进度,单位秒 | |
返回值 | 无 | ||
接口描述 |
|
2.2.1.思路分析
学习记录就是用户当前学了哪些小节,以及学习到该小节的进度如何。而小节类型分为考试、视频两种。
-
考试比较简单,只要提交了就说明这一节学完了。
-
视频比较麻烦,需要记录用户的播放进度,进度超过50%才算学完。因此视频播放的过程中需要不断提交播放进度到服务端,而服务端则需要保存学习记录到数据库。
以上信息都需要保存到learning_record表中。
特别需要注意的是,学习记录learning_record表记录的是每一个小节的学习进度。而在learning_lesson表也需要记录一些学习进度相关字段:
这些字段是整个课程的进度统计:
-
learned_sections:已学习小节数量
-
latest_section_id:最近一次学习的小节id
-
latest_learn_time:最近一次学习时间
每当有一个小节被学习,都应该更新latest_section_id
和latest_learn_time
;每当有一个小节学习完后,learned_sections
都应该累加1。不过这里有一点容易出错的地方:
-
考试只会被参加一次,考试提交则小节学完,
learned_sections
累加1 -
视频可以被重复播放,只有在第一次学完一个视频时,
learned_sections
才需要累加1
那么问题来了,如何判断视频是否是第一次学完?我认为应该同时满足两个条件:
-
视频播放进度超过50%
-
之前学习记录的状态为未学完
另外,随着learned_sections字段不断累加,最终会到达课程的最大小节数,这就意味着当前课程被全部学完了。那么课程状态需要从“学习中”变更为“已学完”。
综上,最终的提交学习记录处理流程如图:
2.2.2.表单实体
请求参数比较多,所以需要定义一个表单DTO实体,这个在课前资料已经提供好了:
具体代码如下:
package com.tianji.learning.domain.dto;import com.tianji.common.validate.annotations.EnumValid;
import com.tianji.learning.enums.SectionType;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;import javax.validation.constraints.NotNull;
import java.time.LocalDateTime;@Data
@ApiModel(description = "学习记录")
public class LearningRecordFormDTO {@ApiModelProperty("小节类型:1-视频,2-考试")@NotNull(message = "小节类型不能为空")@EnumValid(enumeration = {1, 2}, message = "小节类型错误,只能是:1-视频,2-考试")private SectionType sectionType;@ApiModelProperty("课表id")@NotNull(message = "课表id不能为空")private Long lessonId;@ApiModelProperty("对应节的id")@NotNull(message = "节的id不能为空")private Long sectionId;@ApiModelProperty("视频总时长,单位秒")private Integer duration;@ApiModelProperty("视频的当前观看时长,单位秒,第一次提交填0")private Integer moment;@ApiModelProperty("提交时间")private LocalDateTime commitTime;
}
2.2.3.代码实现
首先在tj-learning
模块下的com.tianji.learning.controller.LearningRecordController
下定义接口:
package com.tianji.learning.controller;import com.tianji.api.dto.leanring.LearningLessonDTO;
import com.tianji.learning.domain.dto.LearningRecordFormDTO;
import com.tianji.learning.service.ILearningRecordService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;/*** <p>* 学习记录表 前端控制器* </p>** @author 虎哥* @since 2022-12-10*/
@RestController
@RequestMapping("/learning-records")
@Api(tags = "学习记录的相关接口")
@RequiredArgsConstructor
public class LearningRecordController {private final ILearningRecordService recordService;@ApiOperation("查询指定课程的学习记录")@GetMapping("/course/{courseId}")public LearningLessonDTO queryLearningRecordByCourse(@ApiParam(value = "课程id", example = "2") @PathVariable("courseId") Long courseId){return recordService.queryLearningRecordByCourse(courseId);}@ApiOperation("提交学习记录")@PostMappingpublic void addLearningRecord(@RequestBody LearningRecordFormDTO formDTO){recordService.addLearningRecord(formDTO);}
}
然后在com.tianji.learning.service.ILearningRecordService
中定义方法:
package com.tianji.learning.service;import com.baomidou.mybatisplus.extension.service.IService;
import com.tianji.api.dto.leanring.LearningLessonDTO;
import com.tianji.learning.domain.dto.LearningRecordFormDTO;
import com.tianji.learning.domain.po.LearningRecord;/*** <p>* 学习记录表 服务类* </p>** @author 虎哥* @since 2022-12-10*/
public interface ILearningRecordService extends IService<LearningRecord> {LearningLessonDTO queryLearningRecordByCourse(Long courseId);void addLearningRecord(LearningRecordFormDTO formDTO);
}
最后在com.tianji.learning.service.impl.LearningRecordServiceImpl
中定义实现类:
package com.tianji.learning.service.impl;import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.tianji.api.client.course.CourseClient;
import com.tianji.api.dto.course.CourseFullInfoDTO;
import com.tianji.api.dto.leanring.LearningLessonDTO;
import com.tianji.api.dto.leanring.LearningRecordDTO;
import com.tianji.common.exceptions.BizIllegalException;
import com.tianji.common.exceptions.DbException;
import com.tianji.common.utils.BeanUtils;
import com.tianji.common.utils.UserContext;
import com.tianji.learning.domain.dto.LearningRecordFormDTO;
import com.tianji.learning.domain.po.LearningLesson;
import com.tianji.learning.domain.po.LearningRecord;
import com.tianji.learning.enums.LessonStatus;
import com.tianji.learning.enums.SectionType;
import com.tianji.learning.mapper.LearningRecordMapper;
import com.tianji.learning.service.ILearningLessonService;
import com.tianji.learning.service.ILearningRecordService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;import java.util.List;/*** <p>* 学习记录表 服务实现类* </p>*/
@Service
@RequiredArgsConstructor
public class LearningRecordServiceImpl extends ServiceImpl<LearningRecordMapper, LearningRecord> implements ILearningRecordService {private final ILearningLessonService lessonService;private final CourseClient courseClient;// 。。。略@Override@Transactionalpublic void addLearningRecord(LearningRecordFormDTO recordDTO) {// 1.获取登录用户Long userId = UserContext.getUser();// 2.处理学习记录boolean finished = false;if (recordDTO.getSectionType() == SectionType.VIDEO) {// 2.1.处理视频finished = handleVideoRecord(userId, recordDTO);}else{// 2.2.处理考试finished = handleExamRecord(userId, recordDTO);}// 3.处理课表数据handleLearningLessonsChanges(recordDTO, finished);}private void handleLearningLessonsChanges(LearningRecordFormDTO recordDTO, boolean finished) {// 1.查询课表LearningLesson lesson = lessonService.getById(recordDTO.getLessonId());if (lesson == null) {throw new BizIllegalException("课程不存在,无法更新数据!");}// 2.判断是否有新的完成小节boolean allLearned = false;if(finished){// 3.如果有新完成的小节,则需要查询课程数据CourseFullInfoDTO cInfo = courseClient.getCourseInfoById(lesson.getCourseId(), false, false);if (cInfo == null) {throw new BizIllegalException("课程不存在,无法更新数据!");}// 4.比较课程是否全部学完:已学习小节 >= 课程总小节allLearned = lesson.getLearnedSections() + 1 >= cInfo.getSectionNum(); }// 5.更新课表lessonService.lambdaUpdate().set(lesson.getLearnedSections() == 0, LearningLesson::getStatus, LessonStatus.LEARNING.getValue()).set(allLearned, LearningLesson::getStatus, LessonStatus.FINISHED.getValue()).set(!finished, LearningLesson::getLatestSectionId, recordDTO.getSectionId()).set(!finished, LearningLesson::getLatestLearnTime, recordDTO.getCommitTime()).setSql(finished, "learned_sections = learned_sections + 1").eq(LearningLesson::getId, lesson.getId()).update();}private boolean handleVideoRecord(Long userId, LearningRecordFormDTO recordDTO) {// 1.查询旧的学习记录LearningRecord old = queryOldRecord(recordDTO.getLessonId(), recordDTO.getSectionId());// 2.判断是否存在if (old == null) {// 3.不存在,则新增// 3.1.转换POLearningRecord record = BeanUtils.copyBean(recordDTO, LearningRecord.class);// 3.2.填充数据record.setUserId(userId);// 3.3.写入数据库boolean success = save(record);if (!success) {throw new DbException("新增学习记录失败!");}return false;}// 4.存在,则更新// 4.1.判断是否是第一次完成boolean finished = !old.getFinished() && recordDTO.getMoment() * 2 >= recordDTO.getDuration();// 4.2.更新数据boolean success = lambdaUpdate().set(LearningRecord::getMoment, recordDTO.getMoment()).set(finished, LearningRecord::getFinished, true).set(finished, LearningRecord::getFinishTime, recordDTO.getCommitTime()).eq(LearningRecord::getId, old.getId()).update();if(!success){throw new DbException("更新学习记录失败!");}return finished ;}private LearningRecord queryOldRecord(Long lessonId, Long sectionId) {return lambdaQuery().eq(LearningRecord::getLessonId, lessonId).eq(LearningRecord::getSectionId, sectionId).one();}private boolean handleExamRecord(Long userId, LearningRecordFormDTO recordDTO) {// 1.转换DTO为POLearningRecord record = BeanUtils.copyBean(recordDTO, LearningRecord.class);// 2.填充数据record.setUserId(userId);record.setFinished(true);record.setFinishTime(recordDTO.getCommitTime());// 3.写入数据库boolean success = save(record);if (!success) {throw new DbException("新增考试记录失败!");}return true;}
}
2.3.创建学习计划
回顾下接口信息:
参数 | 说明 | ||
---|---|---|---|
请求方式 | POST | ||
请求路径 | /lessons/plans | ||
请求参数 | 参数名 | 类型 | 说明 |
courseId | Long | 课程id | |
weekFreq | Integer | 计划每周学习频率 | |
返回值 | 无 |
2.3.1.思路分析
创建学习计划,本质就是让用户设定自己每周的学习频率:
虽说接口是创建学习计划,但本质这是一个更新的接口。因为学习计划字段都保存在learning_lesson表中。
CREATE TABLE `learning_lesson` (`id` bigint NOT NULL COMMENT '主键',`user_id` bigint NOT NULL COMMENT '学员id',`course_id` bigint NOT NULL COMMENT '课程id',`status` tinyint NULL DEFAULT 0 COMMENT '课程状态,0-未学习,1-学习中,2-已学完,3-已失效',`week_freq` tinyint NULL 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 NULL DEFAULT NULL COMMENT '最近一次学习的小节id',`latest_learn_time` datetime NULL 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`) USING BTREE,UNIQUE INDEX `idx_user_id`(`user_id`, `course_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '学生课程表' ROW_FORMAT = Dynamic;
当我们创建学习计划时,就是更新learning_lesson
表,写入week_freq
并更新plan_status
为计划进行中即可。
2.3.2.表单实体
表单包含两个字段:
-
courseId
-
weekFreq
前端是以JSON方式提交,我们需要定义一个表单DTO实体。在课前资料中已经提供给大家了:
具体代码:
package com.tianji.learning.domain.dto;import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import org.hibernate.validator.constraints.Range;import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;@Data
@ApiModel(description = "学习计划表单实体")
public class LearningPlanDTO {@NotNull@ApiModelProperty("课程表id")@Min(1)private Long courseId;@NotNull@Range(min = 1, max = 50)@ApiModelProperty("每周学习频率")private Integer freq;
}
2.3.3.代码实现
首先,在com.tianji.learning.controller.LearningLessonController
中添加一个接口:
package com.tianji.learning.controller;import com.tianji.learning.domain.dto.LearningPlanDTO;
import com.tianji.learning.service.ILearningLessonService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;import javax.validation.Valid;/*** <p>* 学生课程表 前端控制器* </p>** @author 虎哥* @since 2022-12-02*/
@RestController
@RequestMapping("/lessons")
@Api(tags = "我的课表相关接口")
@RequiredArgsConstructor
public class LearningLessonController {private final ILearningLessonService lessonService;// 略。。。@ApiOperation("创建学习计划")@PostMapping("/plans")public void createLearningPlans(@Valid @RequestBody LearningPlanDTO planDTO){lessonService.createLearningPlan(planDTO.getCourseId(), planDTO.getFreq());}
}
然后,在com.tianji.learning.service.ILearningLessonService
中定义service方法:
package com.tianji.learning.service;import com.baomidou.mybatisplus.extension.service.IService;
import com.tianji.learning.domain.po.LearningLesson;import java.util.List;/*** <p>* 学生课程表 服务类* </p>*/
public interface ILearningLessonService extends IService<LearningLesson> {// ... 略void createLearningPlan(Long courseId, Integer freq);
}
最后,在com.tianji.learning.service.impl.LearningLessonServiceImpl
中实现方法:
// ... 略@Override
public void createLearningPlan(Long courseId, Integer freq) {// 1.获取当前登录的用户Long userId = UserContext.getUser();// 2.查询课表中的指定课程有关的数据LearningLesson lesson = queryByUserAndCourseId(userId, courseId);AssertUtils.isNotNull(lesson, "课程信息不存在!");// 3.修改数据LearningLesson l = new LearningLesson();l.setId(lesson.getId());l.setWeekFreq(freq);if(lesson.getPlanStatus() == PlanStatus.NO_PLAN) {l.setPlanStatus(PlanStatus.PLAN_RUNNING);}updateById(l);
}// ... 略
2.4.查询学习计划进度
页面原型如图:
接口回顾:
参数 | 说明 | ||||
---|---|---|---|---|---|
请求方式 | GET | ||||
请求路径 | /lessons/plans | ||||
请求参数 | 分页参数:PageQuery | ||||
返回值 | 参数名 | 类型 | 说明 | ||
weekPoints | int | 本周学习积分 | |||
weekFinished | int | 本周已学完小节数量 | |||
weekTotalPlan | int | 本周计划学习小节数量 | |||
list | Array | 参数 | 类型 | 说明 | |
courseId | Long | 课程id | |||
courseName | String | 课程名称 | |||
weekLearnedSections | int | 本周学习的小节数量 | |||
weekFreq | int | 本周计划学习数量 | |||
learnedSections | int | 总已学习小节数量 | |||
sections | int | 总小节数量 | |||
latestLearnTime | LocalDateTime | 最近一次学习时间 |
2.4.1.思路分析
要查询的数据分为两部分:
-
本周计划学习的每个课程的学习进度
-
本周计划学习的课程总的学习进度
对于本周计划学习的每个课程的学习进度,首先需要查询出学习中的LearningLesson的信息,查询条件包括:
-
属于当前登录用户
-
学习计划进行中
查询到的LearningLesson可能有多个,而且查询到的PO数据跟最终的VO相比还有差距:
PO:
VO:
具体来说,PO中缺少了courseName和weekSections两个字段。其中courseName可以通过courseId去课程微服务查询。weekSections只能对学习记录做统计得到。
因此,我们需要搜集查询到的课表中的courseId,查询出对应的课程信息;还需要搜集查询到的课表的id,去learning_record中统计每个课表本周已学习的小节数量。
最终遍历处理每个PO,转换为VO格式。
除了本周每个课程的学习进度以外,我们还要统计本周计划学习的课程总的学习进度。其中的积分数据暂时不管,剩下的两个需要分别对两张表统计:
-
weekTotalPlan:对learning_lesson表统计,查询计划学习的课程的weekFreq字段做累加即可
-
weekFinished:对learning_record表,对已学完的小节记录做count即可
注意:
虽然这里是分页查询,但是每个用户购买的课程其实是有限的,为了便于数据统计,建议采用查询全部数据,然后手动逻辑分页的方式。这样在统计全部课程学习进度的时候会方便很多。
2.4.2.实体
VO实体已经在课前资料中给出:
2.4.3.代码实现
首先在tj-learning
模块的com.tianji.learning.controller.LearningLessonController
中定义controller
接口:
@ApiOperation("查询我的学习计划")
@GetMapping("/plans")
public LearningPlanPageVO queryMyPlans(PageQuery query){return lessonService.queryMyPlans(query);
}
然后在com.tianji.learning.service.ILearningLessonService
中定义service方法:
LearningPlanPageVO queryMyPlans(PageQuery query);
最后在com.tianji.learning.service.impl.LearningLessonServiceImpl
中实现该方法:
版本1:物理分页,分别统计
@Override
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>().eq(LearningRecord::getUserId, userId).eq(LearningRecord::getFinished, true).gt(LearningRecord::getFinishTime, begin).lt(LearningRecord::getFinishTime, end));result.setWeekFinished(weekFinished);// 3.2.本周总的计划学习小节数量Integer weekTotalPlan = getBaseMapper().queryTotalPlan(userId);result.setWeekTotalPlan(weekTotalPlan);// TODO 3.3.本周学习积分// 4.查询分页数据// 4.1.分页查询课表信息以及学习计划信息Page<LearningLesson> p = lambdaQuery().eq(LearningLesson::getUserId, userId).eq(LearningLesson::getPlanStatus, PlanStatus.PLAN_RUNNING).in(LearningLesson::getStatus, LessonStatus.NOT_BEGIN, LessonStatus.LEARNING).page(query.toMpPage("latest_learn_time", false));List<LearningLesson> records = p.getRecords();if (CollUtils.isEmpty(records)) {return result.emptyPage(p);}// 4.2.查询课表对应的课程信息Map<Long, CourseSimpleInfoDTO> cMap = queryCourseSimpleInfoList(records);// 4.3.统计每一个课程本周已学习小节数量List<IdAndNumDTO> list = recordMapper.countLearnedSections(userId, begin, end);Map<Long, Integer> countMap = IdAndNumDTO.toMap(list);// 4.4.组装数据VOList<LearningPlanVO> voList = new ArrayList<>(records.size());for (LearningLesson r : records) {// 4.4.1.拷贝基础属性到voLearningPlanVO vo = BeanUtils.copyBean(r, LearningPlanVO.class);// 4.4.2.填充课程详细信息CourseSimpleInfoDTO cInfo = cMap.get(r.getCourseId());if (cInfo != null) {vo.setCourseName(cInfo.getName());vo.setSections(cInfo.getSectionNum());}// 4.4.3.每个课程的本周已学习小节数量vo.setWeekLearnedSections(countMap.getOrDefault(r.getId(), 0));voList.add(vo);}return result.pageInfo(p.getTotal(), p.getPages(), voList);
}private Map<Long, CourseSimpleInfoDTO> queryCourseSimpleInfoList(List<LearningLesson> records) {// 3.1.获取课程idSet<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;
}
其中需要调用LearningRecordMapper实现对本周每个课程的已学习小节的统计,对应实现如下:
public interface LearningRecordMapper extends BaseMapper<LearningRecord> {List<IdAndNumDTO> countLearnedSections(@Param("userId") Long userId,@Param("begin") LocalDateTime begin,@Param("end") LocalDateTime end);
}
对应的SQL如下:
<select id="countLearnedSections" resultType="com.tianji.api.dto.IdAndNumDTO">SELECT lesson_id AS id, COUNT(1) AS numFROM learning_recordWHERE user_id = #{userId}AND finished = 1AND finish_time > #{begin} AND finish_time < #{end}GROUP BY lesson_id;
</select>
版本2,不分页,stream流统计:
@Override
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.查询本周计划学习的所有课程,满足三个条件:属于当前用户、有学习计划、学习中List<LearningLesson> lessons = lambdaQuery().eq(LearningLesson::getUserId, userId).eq(LearningLesson::getPlanStatus, PlanStatus.PLAN_RUNNING).in(LearningLesson::getStatus, LessonStatus.NOT_BEGIN, LessonStatus.LEARNING).list();if (CollUtils.isEmpty(lessons)) {return null;}// 4.统计当前用户每个课程的已学习小节数量List<LearningRecord> learnedRecords = recordMapper.selectList(new QueryWrapper<LearningRecord>().lambda().eq(LearningRecord::getUserId, userId).eq(LearningRecord::getFinished, true).gt(LearningRecord::getFinishTime, begin).lt(LearningRecord::getFinishTime, end));Map<Long, Long> countMap = learnedRecords.stream().collect(Collectors.groupingBy(LearningRecord::getLessonId, Collectors.counting()));// 5.查询总的统计数据// 5.1.本周总的已学习小节数量int weekFinished = learnedRecords.size();result.setWeekFinished(weekFinished);// 5.2.本周总的计划学习小节数量int weekTotalPlan = lessons.stream().mapToInt(LearningLesson::getWeekFreq).sum();result.setWeekTotalPlan(weekTotalPlan);// TODO 5.3.本周学习积分// 6.处理分页数据// 6.1.分页查询课表信息以及学习计划信息Page<LearningLesson> p = new Page<>(query.getPageNo(), query.getPageSize(), lessons.size());List<LearningLesson> records = CollUtils.sub(lessons, query.from(), query.from() + query.getPageSize());if (CollUtils.isEmpty(records)) {return result;}// 6.2.查询课表对应的课程信息Map<Long, CourseSimpleInfoDTO> cMap = queryCourseInfo(records);// 6.3.组装数据VOList<LearningPlanVO> voList = new ArrayList<>(records.size());for (LearningLesson r : records) {// 6.4.1.拷贝基础属性到voLearningPlanVO vo = BeanUtils.copyBean(r, LearningPlanVO.class);// 6.4.2.填充课程详细信息CourseSimpleInfoDTO cInfo = cMap.get(r.getCourseId());if (cInfo != null) {vo.setCourseName(cInfo.getName());vo.setSections(cInfo.getSectionNum());}// 6.4.3.每个课程的本周已学习小节数量vo.setWeekLearnedSections(countMap.getOrDefault(r.getId(), 0L).intValue());voList.add(vo);}return result.pageInfo(p.getTotal(), p.getPages(), voList);
}
3.练习
3.1.课程过期
编写一个SpringTask定时任务,定期检查learning_lesson表中的课程是否过期,如果过期则将课程状态修改为已过期。
3.2.方案思考
思考题:思考一下目前提交学习记录功能可能存在哪些问题?有哪些可以改进的方向?