經過前面的努力,我們已經完成了《我的課程表》相關的功能的基礎部分,不過還有功能實現的并不完善。還記得昨天給大家的練習題嗎?《查詢我正在學習的課程》,在原型圖中有這樣的一個需求:
我們需要在查詢結果中返回已學習課時數、正在學習的章節名稱。雖然我們在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.方案思考
思考題:思考一下目前提交學習記錄功能可能存在哪些問題?有哪些可以改進的方向?