搭建环境:springBoot + apache-maven-3.6.3 + mysql + Redisson3.15.4
项目背景
项目需要做一个记录视频播放进度的功能,有以下几点需要着重注意:
- 1、点击视频,播放到几小时几分几秒,下次同一个人点击进来依然是当前时间段
- 2、当一个维度下有多个视频可以看,分开记录当前视频或者文档是否已经看完。比如学习维度下有两个视频,一个文档,文档或者视频看完,直接显示当前视频已看完,但学习维度还显示正在学习,除非当前维度下的所有视频或者文档全部显示为已学完。
- 3、文档点击进入页面可以直接认定为已看完,视频看完必须要看完时长的三分之二,才能显示当前视频已看完。
- 4、记录当前天每个人实际学习时间总时长(额外需求开发)。
- 5、 单个媒体视频学完之后,直接已学完,这种逻辑很好实现。多个媒体在同一个维度下,比如娱乐维度下有三个视频,学习维度下有五个学习视频,只要当前维度下的所有视频全部显示已学完,才可以让当前维度更新数据库显示已学完,怎么统计呢?解决办法:当前维度下的单个视频,每次第一次进入就去更新是否已学完。还有当单个媒体最新状态显示已学完,只有这两个情况才进入方法去更新当前维度下的学习状态。
需求分析
会出现同一个人在不同的页面或者不同的浏览器打开同一门课的情况,当前情况可以能用redis分布式锁解决。
首先记录学习【媒体播放进度表】,应该包含:
- 已学习时长:用于判断当前是否已学完,当时长等于总长度三分二时。
- 媒体播放进度:用于返回给前端,上次已播放的位子。
- 媒体视频总时长:用于判断是否已学完,还是正在学。
- 学习状态:已学完COMPALETED,正在学LEARNING。
- 课程维度主键id:唯一性。
- 课程下媒体或者文档主键id:唯一性。
- 学习用户。
- 创建时间。
- 修改时间。
【课程维度】表,上面表记录单个视频是否已学完,课程维度表记录当前维度下的所有视频是否已学完。
- 课程主键id。
- 课程下媒体最后学习id。
- 用户id。
- 已学完总的视频媒体数量。
- 学习状态:已学完,未学完。
- 创建时间。
- 修改时间。
【学习时长】表,应该有
- 总学习时间。
- 当前日期。
- 记录当前天开始学习时间。
- 为了考虑延伸扩展性,加一个存储类型字段,当前类型可以根据天,周,月,年等维度来统计时长。
- 学习用户。
- 创建时间。
- 修改时间。
这样设计的目的,当前学习时长表不光在此需求中可以使用,以后在其他需求,也可以用此表。
既然是记录视频学习时长,所以前端肯定需要写一个定时器传json数据,为了防止调用频率太高,但又不能记录不到播放记录,所以定时时间定位15s一次。
- SectionId:代表媒体视频或者文档的id。
- CourseId:代表媒体视频或者文档属于哪个维度下的id,比如学习维度,娱乐维度。
- sectionType:类型,代表当前是媒体视频还是文档。
- deltaDuration:增量时间,非视频为0,视频就传新增的看视频时长。
- mediaProgress:视频播放节点,非视频为0,视频就传已看到的视频节点。
- first:是否第一次打开当前媒体。(方便以后扩展使用)
代码开发
先创建学习进度表和学习总时长表:
//学习进度表 create table user_learn_stats( `id` int not null auto_increment, course_id int comment '课程id', section_id int comment '节id', user_id varchar(32) comment '用户id', learned_duration bigint comment '已学习时长', media_progress bigint comment '媒体进度', media_duration bigint comment '媒体总时长', learned_status varchar(32) comment '已学完,学习中', create_date datetime, update_date datetime, primary key(id) ); //学习总时长表 create table user_learned( `id` int not null auto_increment, user_id varchar(32) comment '用户id', duration bigint comment '学习总时长', learn_status varchar(32) comment '学习类型', create_date datetime, update_date datetime, primary key (id) );
建立对应两个表的实体类。
/** * @author keying */ @Data public class UserLearned { private Long id; private Long courseId; private Long sectionId; private String userId; private Long duration; private String learnStats; private Date createDate; private Date updateDate; } /** * @author keying */ @Data public class UserLearnStats { private Long id; private Long courseId; private Long sectionId; private String userId; private Long learnedDuration; private Long mediaProgress; /** * 总时长 */ private Long mediaDuration; private String learnedStatus; private Date createDate; private Date updateDate; }
前端需要传递过来的json参数,
/** * 媒体播放进度 * * @author keying */ @Data public class ProgressRequest { /** * 节id(媒体视频id) */ private Long sectionId; /** * 代表媒体视频或者文档属于哪个维度下的id,比如学习维度,娱乐维度 */ @NotNull private Long courseId; /** * 类型,代表当前是媒体视频还是文档 */ private String sectionType; /** * 增量时间,非视频为0,视频就传新增的看视频时长 */ private Long deltaDuration; /** * 视频播放节点,非视频为0,视频就传已看到的视频节点 */ private Long mediaProgress; /** * 是否第一次打开当前媒体。(方便以后扩展使用) */ private Boolean first; }
准备完毕开始写接口,因为是一个节的进度记录,所以接口名称这样设计更合理:
/** * 媒体进度 * * @author keying */ @RestController @RequestMapping("/section") public class SectionController { @Autowired private ProgressService progressService; @PostMapping("/{id}/progress") public void progress(@PathVariable Long id, @RequestBody @Valid ProgressRequest request) { progressService.progress(id, request); } }
接下来看业务层:
/** * @author keying */ @Service public class ProgressServiceImpl implements ProgressService { //默认用户 public final String NAME = "keying"; @Autowired private UserLearnStatsMapper userLearnStatsMapper; @Autowired private UserLearnedMapper userLearnedMapper; @Override public void progress(Long id, ProgressRequest request) { //存入学习总时长 progressUserLearned(id, request); //统计当前课程 & 节是否已学完 insertUserLearnStats(id, request); } private void progressUserLearned(Long id, ProgressRequest request) { //总学习时长增加 UserLearned userLearnedSelect = new UserLearned(); userLearnedSelect.setCourseId(request.getCourseId()); userLearnedSelect.setSectionId(id); userLearnedSelect.setUserId(NAME); //获取到历史数据 UserLearned userLearned = userLearnedMapper.selectOne(userLearnedSelect); if (!Objects.isNull(userLearned)) { //修改学习时长 UserLearned userLearnedUpdate = new UserLearned(); userLearnedUpdate.setId(userLearned.getId()); userLearnedUpdate.setUpdateDate(new Date()); userLearnedUpdate.setDuration(userLearned.getDuration() + request.getDeltaDuration()); userLearnedMapper.updateById(userLearnedUpdate); return; } //新增学习时长 UserLearned userLearnedInsert = new UserLearned(); userLearnedInsert.setUserId(NAME); userLearnedInsert.setCourseId(request.getCourseId()); userLearnedInsert.setSectionId(id); userLearnedInsert.setCreateDate(new Date()); userLearnedInsert.setUpdateDate(new Date()); userLearnedInsert.setDuration(request.getDeltaDuration()); userLearnedMapper.insert(userLearnedInsert); } private void insertUserLearnStats(Long id, ProgressRequest request) { //处理节,媒体是否已学完 UserLearnStats userLearnStatsSelect = new UserLearnStats(); userLearnStatsSelect.setUserId(NAME); userLearnStatsSelect.setSectionId(id); userLearnStatsSelect.setCourseId(request.getCourseId()); //获取到历史数据 UserLearnStats userLearnStats = userLearnStatsMapper.selectOne(userLearnStatsSelect); //节状态 if (!Objects.isNull(userLearnStats)) { //修改 UserLearnStats updateUserLearnStats = new UserLearnStats(); updateUserLearnStats.setId(userLearnStats.getId()); updateUserLearnStats.setUpdateDate(new Date()); //获取是否已学完 boolean flag = calculateLearned(request, updateUserLearnStats.getMediaDuration(), request.getMediaProgress() + request.getMediaProgress()); updateUserLearnStats.setLearnedStatus("LEARNING"); if (flag) { updateUserLearnStats.setLearnedStatus("COMPALETE"); } userLearnStatsMapper.updateById(updateUserLearnStats); return; } //无历史数据则创建 UserLearnStats insertUserLearnStats = new UserLearnStats(); insertUserLearnStats.setCourseId(request.getCourseId()); insertUserLearnStats.setCreateDate(new Date()); insertUserLearnStats.setUpdateDate(new Date()); insertUserLearnStats.setLearnedDuration(request.getDeltaDuration()); //总时长默认都60*1000毫秒 insertUserLearnStats.setMediaDuration(60 * 1000L); //获取是否已学完 boolean flag = calculateLearned(request, insertUserLearnStats.getMediaDuration(), request.getMediaProgress() + request.getMediaProgress()); insertUserLearnStats.setLearnedStatus("LEARNING"); if (flag) { insertUserLearnStats.setLearnedStatus("COMPALETE"); } insertUserLearnStats.setMediaProgress(request.getMediaProgress()); insertUserLearnStats.setSectionId(id); insertUserLearnStats.setUserId(NAME); userLearnStatsMapper.insert(insertUserLearnStats); //处理课程 是否学完 } private boolean calculateLearned(ProgressRequest request, Long allDuration, Long learnedDuration) { if (request.getSectionType().equals("TEXT")) { return Boolean.TRUE; } if (request.getSectionType().equals("VIDEO")) { if (learnedDuration >= (allDuration * 2 / 3)) { return Boolean.TRUE; } } return Boolean.FALSE; } }
前面每日学习时长 和 单个视频,文档已学完都已经记录完毕,那维度下多个视频学完怎么处理呢?先建一个课程表和实体类:
mysql> create table user_course_learned( -> `id` int not null auto_increment, -> course_id int comment '课程id', -> last_section_id int comment '最后学习节id', -> learned_section_count bigint comment '学习完节数量', -> learn_status varchar(32) comment '已学完compalate,学习ing', -> create_date datetime, -> update_date datetime, -> primary key(id) -> ); Query OK, 0 rows affected (0.04 sec) //实体类 /** * @author keying */ @Data public class UserCourseLearned { private Long id; private Long courseId; private Long lastSectionId; private String userId; private Long learnedSectionCount; private String learnedStatus; private Date createDate; private Date updateDate; }
先引入redisson包,这里就不详细说明了,有需要的可以看看之前的文章怎么用redisson。
private void courseRedisson(Boolean aFalse, ProgressRequest request, String oldLearnedStatus, String newLearnedStatus) { // 默认连接上127.0.0.1:6379 RedissonClient redissonClient = Redisson.create(); // 一个分布式锁,指明锁的名称 RLock rLock = redissonClient.getLock(NAME + request.getCourseId()); try { if (rLock.tryLock(1, 1, TimeUnit.MINUTES)) { log.info("获取到锁"); courseProgress(Boolean.FALSE, request, oldLearnedStatus, newLearnedStatus); } } catch (Exception e) { } finally { rLock.unlock(); } }
之后当获取到锁的页面会进入业务层:
package com.alibaba.first.service.lmpl; import java.util.Date; import java.util.Objects; import java.util.concurrent.TimeUnit; import com.alibaba.first.mapper.UserCourseLearnedMapper; import com.alibaba.first.mapper.UserLearnStatsMapper; import com.alibaba.first.mapper.UserLearnedMapper; import com.alibaba.first.model.ProgressRequest; import com.alibaba.first.model.UserCourseLearned; import com.alibaba.first.model.UserLearnStats; import com.alibaba.first.model.UserLearned; import com.alibaba.first.service.ProgressService; import lombok.extern.slf4j.Slf4j; import org.redisson.Redisson; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; /** * @author keying * @date 2021/9/1 */ @Service @Slf4j public class ProgressServiceImpl implements ProgressService { //默认用户 public final String NAME = "keying"; @Autowired private UserLearnStatsMapper userLearnStatsMapper; @Autowired private UserLearnedMapper userLearnedMapper; @Autowired private UserCourseLearnedMapper userCourseLearnedMapper; @Override public void progress(Long id, ProgressRequest request) { //存入学习总时长 progressUserLearned(id, request); //统计当前课程 & 节是否已学完 insertUserLearnStats(id, request); } private void progressUserLearned(Long id, ProgressRequest request) { //总学习时长增加 UserLearned userLearnedSelect = new UserLearned(); userLearnedSelect.setCourseId(request.getCourseId()); userLearnedSelect.setSectionId(id); userLearnedSelect.setUserId(NAME); //获取到历史数据 UserLearned userLearned = userLearnedMapper.selectOne(userLearnedSelect); if (!Objects.isNull(userLearned)) { //修改学习时长 UserLearned userLearnedUpdate = new UserLearned(); userLearnedUpdate.setId(userLearned.getId()); userLearnedUpdate.setUpdateDate(new Date()); userLearnedUpdate.setDuration(userLearned.getDuration() + request.getDeltaDuration()); userLearnedMapper.updateById(userLearnedUpdate); return; } //新增学习时长 UserLearned userLearnedInsert = new UserLearned(); userLearnedInsert.setUserId(NAME); userLearnedInsert.setCourseId(request.getCourseId()); userLearnedInsert.setSectionId(id); userLearnedInsert.setCreateDate(new Date()); userLearnedInsert.setUpdateDate(new Date()); userLearnedInsert.setDuration(request.getDeltaDuration()); userLearnedMapper.insert(userLearnedInsert); } private void insertUserLearnStats(Long id, ProgressRequest request) { //处理节,媒体是否已学完 UserLearnStats userLearnStatsSelect = new UserLearnStats(); userLearnStatsSelect.setUserId(NAME); userLearnStatsSelect.setSectionId(id); userLearnStatsSelect.setCourseId(request.getCourseId()); //获取到历史数据 UserLearnStats userLearnStats = userLearnStatsMapper.selectOne(userLearnStatsSelect); //节状态 if (!Objects.isNull(userLearnStats)) { //修改 UserLearnStats updateUserLearnStats = new UserLearnStats(); updateUserLearnStats.setId(userLearnStats.getId()); updateUserLearnStats.setUpdateDate(new Date()); //获取是否已学完 boolean flag = calculateLearned(request, updateUserLearnStats.getMediaDuration(), request.getMediaProgress() + request.getMediaProgress()); updateUserLearnStats.setLearnedStatus("LEARNING"); if (flag) { updateUserLearnStats.setLearnedStatus("COMPALETE"); } userLearnStatsMapper.updateById(updateUserLearnStats); //处理课程 是否学完 courseRedisson(Boolean.FALSE, request, userLearnStats.getLearnedStatus(), updateUserLearnStats.getLearnedStatus()); return; } //无历史数据则创建 UserLearnStats insertUserLearnStats = new UserLearnStats(); insertUserLearnStats.setCourseId(request.getCourseId()); insertUserLearnStats.setCreateDate(new Date()); insertUserLearnStats.setUpdateDate(new Date()); insertUserLearnStats.setLearnedDuration(request.getDeltaDuration()); //总时长默认都60*1000毫秒 insertUserLearnStats.setMediaDuration(60 * 1000L); //获取是否已学完 boolean flag = calculateLearned(request, insertUserLearnStats.getMediaDuration(), request.getMediaProgress() + request.getMediaProgress()); insertUserLearnStats.setLearnedStatus("LEARNING"); if (flag) { insertUserLearnStats.setLearnedStatus("COMPALETE"); } insertUserLearnStats.setMediaProgress(request.getMediaProgress()); insertUserLearnStats.setSectionId(id); insertUserLearnStats.setUserId(NAME); userLearnStatsMapper.insert(insertUserLearnStats); //处理课程 是否学完 courseRedisson(Boolean.TRUE, request, null, insertUserLearnStats.getLearnedStatus()); } private void courseRedisson(Boolean aFalse, ProgressRequest request, String oldLearnedStatus, String newLearnedStatus) { // 默认连接上127.0.0.1:6379 RedissonClient redissonClient = Redisson.create(); // 一个分布式锁,指明锁的名称 RLock rLock = redissonClient.getLock(NAME + request.getCourseId()); try { if (rLock.tryLock(1, 1, TimeUnit.MINUTES)) { log.info("获取到锁"); courseProgress(Boolean.FALSE, request, oldLearnedStatus, newLearnedStatus); } } catch (Exception e) { } finally { rLock.unlock(); } } /** * @param aFalse 是否第一次进入 * @param request * @param oldLearnedStatus 老的学习状态 * @param newLearnedStatus 新的学习状态 */ private void courseProgress(Boolean aFalse, ProgressRequest request, String oldLearnedStatus, String newLearnedStatus) { //限制进入条件: // 当是第一次进入的时候,前面的百分百是false,导致全部为false。 //当第二次进入的时候,前面的百分之百为true。后面的就必须为false,才能跳过执行业务代码。 boolean flag = !aFalse && (newLearnedStatus.equals("LEARNING") || oldLearnedStatus.equals("COMPALETE") && newLearnedStatus.equals("COMPALETE")); //当为false的时候跳过,不执行return if (flag) { return; } Date now = new Date(); //查询是否是第一次 UserCourseLearned userCourseLearnedSelect = new UserCourseLearned(); userCourseLearnedSelect.setCourseId(request.getCourseId()); userCourseLearnedSelect.setUserId(NAME); UserCourseLearned userCourseLearnedOne = userCourseLearnedMapper.selectOne(userCourseLearnedSelect); //存在则修改 if (!Objects.isNull(userCourseLearnedOne)) { UserCourseLearned updateUserCourseLearned = new UserCourseLearned(); updateUserCourseLearned.setId(userCourseLearnedOne.getId()); updateUserCourseLearned.setUpdateDate(now); updateUserCourseLearned.setLastSectionId(request.getSectionId()); //填充学习状态和学习完的节数量 populateCourseStatus(updateUserCourseLearned, userCourseLearnedOne.getLearnedSectionCount(), newLearnedStatus); userCourseLearnedMapper.updateById(updateUserCourseLearned); return; } //不存在则新增 UserCourseLearned updateUserCourseInsert = new UserCourseLearned(); updateUserCourseInsert.setUserId(NAME); updateUserCourseInsert.setCourseId(request.getCourseId()); updateUserCourseInsert.setCreateDate(now); updateUserCourseInsert.setLastSectionId(request.getSectionId()); updateUserCourseInsert.setUpdateDate(now); //填充学习状态和学习完的节数量 populateCourseStatus(updateUserCourseInsert, 0L, newLearnedStatus); userCourseLearnedMapper.insert(updateUserCourseInsert); } /** * @param updateUserCourseInsert 参数 * @param count 旧的已学完节 * @param newLearnedStatus 新的学习状态 */ private void populateCourseStatus(UserCourseLearned updateUserCourseInsert, Long count, String newLearnedStatus) { //已学完 if (newLearnedStatus.equals("COMPALETE")) { //新增一节数量 updateUserCourseInsert.setLearnedSectionCount(count + 1L); //先查询课程下的总节数是多少,这里代码演示,没有建立课程表,直接 写死每个课程总节数是5 Long sectionLearnedCount = getSectionLearnedCount(); if(updateUserCourseInsert.getLearnedSectionCount() >= sectionLearnedCount){ updateUserCourseInsert.setLearnedStatus("COMPALETE"); }else{ updateUserCourseInsert.setLearnedStatus("LEARNING"); } return; } //未学完 updateUserCourseInsert.setLearnedSectionCount(count); updateUserCourseInsert.setLearnedStatus("LEARNING"); } private Long getSectionLearnedCount() { return 5L; } private boolean calculateLearned(ProgressRequest request, Long allDuration, Long learnedDuration) { if (request.getSectionType().equals("TEXT")) { return Boolean.TRUE; } if (request.getSectionType().equals("VIDEO")) { if (learnedDuration >= (allDuration * 2 / 3)) { return Boolean.TRUE; } } return Boolean.FALSE; } }
里面代码比较复杂,都写了注释详解,有不明白的可以联系本人,欢迎大家一起来沟通技术。