| | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; |
| | | import com.tcm.training.admin.app.auth.service.AppAuthSessionService; |
| | | import com.tcm.training.admin.app.course.dto.AppHealthCourseDownloadBatchDeleteRequest; |
| | | import com.tcm.training.admin.app.course.dto.AppHealthCourseDownloadReportRequest; |
| | | import com.tcm.training.admin.app.course.dto.AppHealthCourseFavoriteBatchCancelRequest; |
| | | import com.tcm.training.admin.app.course.dto.AppHealthCoursePlayRecordUploadRequest; |
| | | import com.tcm.training.admin.app.course.dto.AppHealthCoursePurchaseRequest; |
| | | import com.tcm.training.admin.app.course.vo.AppHealthCourseCategoryOptionVO; |
| | | import com.tcm.training.admin.app.course.vo.AppHealthCourseDetailVO; |
| | | import com.tcm.training.admin.app.course.vo.AppHealthCourseDownloadItemVO; |
| | | import com.tcm.training.admin.app.course.vo.AppHealthCourseExpertVO; |
| | | import com.tcm.training.admin.app.course.vo.AppHealthCourseFavoriteItemVO; |
| | | import com.tcm.training.admin.app.course.vo.AppHealthCourseFavoriteResultVO; |
| | | import com.tcm.training.admin.app.course.vo.AppHealthCourseListItemVO; |
| | | import com.tcm.training.admin.app.course.vo.AppHealthCoursePlayRecordUploadResultVO; |
| | | import com.tcm.training.admin.app.course.vo.AppHealthCoursePurchaseResultVO; |
| | | import com.tcm.training.admin.app.course.vo.AppHealthCourseVideoVO; |
| | | import com.tcm.training.admin.app.course.vo.AppMyHealthCourseDetailVO; |
| | | import com.tcm.training.admin.app.course.vo.AppMyHealthCourseItemVO; |
| | | import com.tcm.training.admin.app.course.vo.AppMyHealthCourseVideoItemVO; |
| | | import com.tcm.training.admin.live.entity.CoursePurchaseOrder; |
| | | import com.tcm.training.admin.live.entity.Expert; |
| | | import com.tcm.training.admin.live.entity.HealthCourse; |
| | | import com.tcm.training.admin.live.entity.HealthCourseDownloadRecord; |
| | | import com.tcm.training.admin.live.entity.HealthCourseFavoriteRecord; |
| | | import com.tcm.training.admin.live.entity.HealthCourseMemberJoinRecord; |
| | | import com.tcm.training.admin.live.entity.HealthCoursePlayRecord; |
| | | import com.tcm.training.admin.live.entity.HealthCourseVideo; |
| | | import com.tcm.training.admin.live.entity.LiveCourseCategory; |
| | | import com.tcm.training.admin.live.mapper.CoursePurchaseOrderMapper; |
| | | import com.tcm.training.admin.live.mapper.ExpertMapper; |
| | | import com.tcm.training.admin.live.mapper.HealthCourseDownloadRecordMapper; |
| | | import com.tcm.training.admin.live.mapper.HealthCourseFavoriteRecordMapper; |
| | | import com.tcm.training.admin.live.mapper.HealthCourseMapper; |
| | | import com.tcm.training.admin.live.mapper.HealthCourseMemberJoinRecordMapper; |
| | | import com.tcm.training.admin.live.mapper.HealthCoursePlayRecordMapper; |
| | | import com.tcm.training.admin.live.mapper.HealthCourseVideoMapper; |
| | | import com.tcm.training.admin.live.mapper.LiveCourseCategoryMapper; |
| | | import com.tcm.training.admin.live.row.AppHealthCourseDownloadRow; |
| | | import com.tcm.training.admin.live.row.AppHealthCourseFavoriteRow; |
| | | import com.tcm.training.admin.live.row.AppMyHealthCourseProgressRow; |
| | | import com.tcm.training.admin.live.row.AppMyHealthCourseVideoProgressRow; |
| | | import com.tcm.training.admin.live.service.HealthCourseAppService; |
| | | import com.tcm.training.admin.live.service.LiveCourseCategoryAppService; |
| | | import com.tcm.training.admin.live.vo.HealthCourseDetailVO; |
| | | import com.tcm.training.admin.live.vo.HealthCourseListRow; |
| | | import com.tcm.training.admin.live.vo.CoursePurchaseOrderListRow; |
| | | import com.tcm.training.admin.member.entity.AppUser; |
| | | import com.tcm.training.admin.member.mapper.UserMapper; |
| | | import com.tcm.training.admin.system.service.SystemSettingsAppService; |
| | |
| | | import java.time.LocalDateTime; |
| | | import java.util.ArrayList; |
| | | import java.util.Collections; |
| | | import java.util.Comparator; |
| | | import java.util.HashMap; |
| | | import java.util.LinkedHashSet; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.Set; |
| | | import java.time.format.DateTimeFormatter; |
| | | |
| | |
| | | private final HealthCourseVideoMapper healthCourseVideoMapper; |
| | | private final LiveCourseCategoryMapper liveCourseCategoryMapper; |
| | | private final ExpertMapper expertMapper; |
| | | private final HealthCourseDownloadRecordMapper healthCourseDownloadRecordMapper; |
| | | private final HealthCourseFavoriteRecordMapper healthCourseFavoriteRecordMapper; |
| | | private final HealthCourseMemberJoinRecordMapper healthCourseMemberJoinRecordMapper; |
| | | private final HealthCoursePlayRecordMapper healthCoursePlayRecordMapper; |
| | | private final AppAuthSessionService appAuthSessionService; |
| | | private final CoursePurchaseOrderMapper coursePurchaseOrderMapper; |
| | | private final UserMapper userMapper; |
| | |
| | | HealthCourseVideoMapper healthCourseVideoMapper, |
| | | LiveCourseCategoryMapper liveCourseCategoryMapper, |
| | | ExpertMapper expertMapper, |
| | | HealthCourseDownloadRecordMapper healthCourseDownloadRecordMapper, |
| | | HealthCourseFavoriteRecordMapper healthCourseFavoriteRecordMapper, |
| | | HealthCourseMemberJoinRecordMapper healthCourseMemberJoinRecordMapper, |
| | | HealthCoursePlayRecordMapper healthCoursePlayRecordMapper, |
| | | AppAuthSessionService appAuthSessionService, |
| | | CoursePurchaseOrderMapper coursePurchaseOrderMapper, |
| | | UserMapper userMapper, |
| | |
| | | this.healthCourseVideoMapper = healthCourseVideoMapper; |
| | | this.liveCourseCategoryMapper = liveCourseCategoryMapper; |
| | | this.expertMapper = expertMapper; |
| | | this.healthCourseDownloadRecordMapper = healthCourseDownloadRecordMapper; |
| | | this.healthCourseFavoriteRecordMapper = healthCourseFavoriteRecordMapper; |
| | | this.healthCourseMemberJoinRecordMapper = healthCourseMemberJoinRecordMapper; |
| | | this.healthCoursePlayRecordMapper = healthCoursePlayRecordMapper; |
| | | this.appAuthSessionService = appAuthSessionService; |
| | | this.coursePurchaseOrderMapper = coursePurchaseOrderMapper; |
| | | this.userMapper = userMapper; |
| | |
| | | detailVO.setCourseOwned(Boolean.valueOf(purchaseState.isCourseOwned())); |
| | | detailVO.setCoursePurchased(Boolean.valueOf(purchaseState.isCoursePurchased())); |
| | | return detailVO; |
| | | } |
| | | |
| | | /** |
| | | * 获取当前登录用户的收藏课程列表。 |
| | | */ |
| | | public List<AppHealthCourseFavoriteItemVO> favorites() { |
| | | Long userId = appAuthSessionService.requireLoginUserId(); |
| | | List<AppHealthCourseFavoriteRow> rows = healthCourseFavoriteRecordMapper.selectAppFavoriteCourses(userId); |
| | | if (rows == null || rows.isEmpty()) { |
| | | return Collections.emptyList(); |
| | | } |
| | | List<AppHealthCourseFavoriteItemVO> result = new ArrayList<AppHealthCourseFavoriteItemVO>(rows.size()); |
| | | for (AppHealthCourseFavoriteRow row : rows) { |
| | | AppHealthCourseFavoriteItemVO itemVO = new AppHealthCourseFavoriteItemVO(); |
| | | itemVO.setCourseId(row.getCourseId()); |
| | | itemVO.setTitle(trimToEmpty(row.getTitle())); |
| | | itemVO.setCoverImage(trimToEmpty(row.getCoverImage())); |
| | | itemVO.setLevelName(trimToEmpty(row.getLevelName())); |
| | | itemVO.setDifficultyName(trimToEmpty(row.getDifficultyName())); |
| | | itemVO.setTeacherName(trimToEmpty(row.getTeacherName())); |
| | | itemVO.setTeacherAvatar(trimToEmpty(row.getTeacherAvatar())); |
| | | itemVO.setLearnUserCount(defaultNumber(row.getLearnUserCount())); |
| | | itemVO.setFavoritedAt(row.getFavoritedAt()); |
| | | result.add(itemVO); |
| | | } |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * 获取当前登录用户的已完成下载列表。 |
| | | */ |
| | | public List<AppHealthCourseDownloadItemVO> downloads() { |
| | | Long userId = appAuthSessionService.requireLoginUserId(); |
| | | List<AppHealthCourseDownloadRow> rows = healthCourseDownloadRecordMapper.selectAppDownloads(userId); |
| | | if (rows == null || rows.isEmpty()) { |
| | | return Collections.emptyList(); |
| | | } |
| | | List<AppHealthCourseDownloadItemVO> result = new ArrayList<AppHealthCourseDownloadItemVO>(rows.size()); |
| | | for (AppHealthCourseDownloadRow row : rows) { |
| | | AppHealthCourseDownloadItemVO itemVO = new AppHealthCourseDownloadItemVO(); |
| | | itemVO.setId(row.getId()); |
| | | itemVO.setCourseId(row.getCourseId()); |
| | | itemVO.setCourseVideoId(row.getCourseVideoId()); |
| | | itemVO.setTitle(trimToEmpty(row.getTitle())); |
| | | itemVO.setSubtitle(trimToEmpty(row.getSubtitle())); |
| | | itemVO.setCoverImage(trimToEmpty(row.getCoverImage())); |
| | | itemVO.setDurationSeconds(defaultNumber(row.getDurationSeconds())); |
| | | itemVO.setDurationText(formatVideoDuration(itemVO.getDurationSeconds())); |
| | | itemVO.setVideoUrl(trimToEmpty(row.getVideoUrl())); |
| | | itemVO.setDownloadedAt(row.getDownloadedAt()); |
| | | result.add(itemVO); |
| | | } |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * 获取当前登录用户的我的课程列表。 |
| | | */ |
| | | public List<AppMyHealthCourseItemVO> myCourses() { |
| | | Long userId = appAuthSessionService.requireLoginUserId(); |
| | | List<HealthCourseOwnershipSnapshot> snapshots = listMyCourseSnapshots(userId); |
| | | if (snapshots.isEmpty()) { |
| | | return Collections.emptyList(); |
| | | } |
| | | Map<Long, CourseProgressSnapshot> progressSnapshotMap = buildCourseProgressSnapshotMap(userId, extractCourseIds(snapshots)); |
| | | List<AppMyHealthCourseItemVO> result = new ArrayList<AppMyHealthCourseItemVO>(snapshots.size()); |
| | | for (HealthCourseOwnershipSnapshot snapshot : snapshots) { |
| | | AppMyHealthCourseItemVO itemVO = new AppMyHealthCourseItemVO(); |
| | | itemVO.setCourseId(snapshot.getCourseId()); |
| | | itemVO.setTitle(trimToEmpty(snapshot.getTitle())); |
| | | itemVO.setCoverImage(trimToEmpty(snapshot.getCoverImage())); |
| | | itemVO.setHasHealthTag(Boolean.TRUE); |
| | | CourseProgressSnapshot progressSnapshot = progressSnapshotMap.get(snapshot.getCourseId()); |
| | | int completedLessons = progressSnapshot == null ? 0 : progressSnapshot.getCompletedLessons(); |
| | | int totalLessons = snapshot.getTotalLessons(); |
| | | itemVO.setCompletedLessons(Integer.valueOf(completedLessons)); |
| | | itemVO.setTotalLessons(Integer.valueOf(totalLessons)); |
| | | itemVO.setProgressPercent(Integer.valueOf(calculateProgressPercent(completedLessons, totalLessons))); |
| | | itemVO.setSourceType(snapshot.getSourceType()); |
| | | itemVO.setRemovable(Boolean.valueOf(snapshot.isRemovable())); |
| | | result.add(itemVO); |
| | | } |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * 删除当前登录用户的会员加课课程。 |
| | | */ |
| | | @Transactional |
| | | public void removeMyCourse(Long courseId) { |
| | | Long userId = appAuthSessionService.requireLoginUserId(); |
| | | requireEnabledCourse(courseId); |
| | | if (findLatestActivePaidCourseOrder(userId, courseId) != null) { |
| | | throw new IllegalArgumentException("已购买课程不支持移除"); |
| | | } |
| | | HealthCourseMemberJoinRecord joinRecord = findMemberJoinRecord(courseId, userId); |
| | | if (joinRecord == null) { |
| | | throw new IllegalArgumentException("课程不存在或已移除"); |
| | | } |
| | | healthCourseMemberJoinRecordMapper.update(null, new LambdaUpdateWrapper<HealthCourseMemberJoinRecord>() |
| | | .eq(HealthCourseMemberJoinRecord::getId, joinRecord.getId()) |
| | | .eq(HealthCourseMemberJoinRecord::getAppUserId, userId) |
| | | .eq(HealthCourseMemberJoinRecord::getDeleted, 0) |
| | | .set(HealthCourseMemberJoinRecord::getDeleted, 1)); |
| | | } |
| | | |
| | | /** |
| | | * 获取当前登录用户的我的课程详情。 |
| | | */ |
| | | public AppMyHealthCourseDetailVO myCourseDetail(Long courseId) { |
| | | Long userId = appAuthSessionService.requireLoginUserId(); |
| | | HealthCourseOwnershipSnapshot ownershipSnapshot = requireOwnedCourse(userId, courseId); |
| | | HealthCourse course = requireEnabledCourse(courseId); |
| | | List<HealthCourseVideo> enabledVideos = listEnabledVideos(courseId); |
| | | Map<Long, VideoProgressSnapshot> videoProgressSnapshotMap = buildVideoProgressSnapshotMap(userId, Collections.singletonList(courseId)); |
| | | |
| | | List<AppMyHealthCourseVideoItemVO> videos = new ArrayList<AppMyHealthCourseVideoItemVO>(enabledVideos.size()); |
| | | Long currentVideoId = null; |
| | | LocalDateTime latestLearnAt = null; |
| | | for (HealthCourseVideo video : enabledVideos) { |
| | | VideoProgressSnapshot snapshot = videoProgressSnapshotMap.get(video.getId()); |
| | | AppMyHealthCourseVideoItemVO itemVO = new AppMyHealthCourseVideoItemVO(); |
| | | itemVO.setVideoId(video.getId()); |
| | | itemVO.setChapterName(trimToEmpty(video.getChapterName())); |
| | | itemVO.setDurationSeconds(defaultNumber(video.getDurationSeconds())); |
| | | itemVO.setDurationText(formatVideoDuration(itemVO.getDurationSeconds())); |
| | | itemVO.setVideoUrl(trimToEmpty(video.getVideoUrl())); |
| | | itemVO.setCompleted(Boolean.valueOf(snapshot != null && snapshot.isCompleted())); |
| | | itemVO.setProgressPercent(Integer.valueOf(calculateVideoDisplayProgressPercent( |
| | | snapshot == null ? 0 : snapshot.getCumulativeWatchSeconds(), |
| | | itemVO.getDurationSeconds().intValue(), |
| | | snapshot != null && snapshot.isCompleted() |
| | | ))); |
| | | itemVO.setLastPlayPositionSeconds(Integer.valueOf(resolveVideoResumePosition(snapshot))); |
| | | itemVO.setDownloadable(Boolean.valueOf(hasVideoDownloadAccess(userId, courseId, video.getId()))); |
| | | videos.add(itemVO); |
| | | if (snapshot != null && snapshot.getLatestLearnAt() != null |
| | | && (latestLearnAt == null || snapshot.getLatestLearnAt().isAfter(latestLearnAt))) { |
| | | latestLearnAt = snapshot.getLatestLearnAt(); |
| | | currentVideoId = video.getId(); |
| | | } |
| | | } |
| | | if (currentVideoId == null && !enabledVideos.isEmpty()) { |
| | | currentVideoId = enabledVideos.get(0).getId(); |
| | | } |
| | | |
| | | AppMyHealthCourseDetailVO detailVO = new AppMyHealthCourseDetailVO(); |
| | | detailVO.setCourseId(course.getId()); |
| | | detailVO.setTitle(trimToEmpty(course.getTitle())); |
| | | detailVO.setCourseTitle(trimToEmpty(course.getTitle())); |
| | | detailVO.setCoverImage(trimToEmpty(course.getCoverImg())); |
| | | detailVO.setTotalLessons(Integer.valueOf(enabledVideos.size())); |
| | | detailVO.setTotalDurationText(formatCourseDuration(resolveTotalDurationSecondsByEntity(course, enabledVideos))); |
| | | detailVO.setCurrentVideoId(currentVideoId); |
| | | detailVO.setServicePhone(resolveServicePhone()); |
| | | detailVO.setIntroAvailable(Boolean.TRUE); |
| | | detailVO.setVideos(videos); |
| | | return detailVO; |
| | | } |
| | | |
| | | /** |
| | | * 上报当前登录用户的我的课程播放记录。 |
| | | */ |
| | | @Transactional |
| | | public AppHealthCoursePlayRecordUploadResultVO uploadMyCoursePlayRecord(Long courseId, |
| | | Long videoId, |
| | | AppHealthCoursePlayRecordUploadRequest request) { |
| | | Long userId = appAuthSessionService.requireLoginUserId(); |
| | | requireOwnedCourse(userId, courseId); |
| | | HealthCourseVideo video = requireEnabledVideo(courseId, videoId); |
| | | validatePlayRecordRequest(request); |
| | | |
| | | int videoDurationSeconds = defaultNumber(video.getDurationSeconds()); |
| | | int cumulativeWatchSeconds = resolveExistingCumulativeWatchSeconds(userId, courseId, videoId) |
| | | + defaultNumber(request.getDurationSeconds()); |
| | | boolean completed = videoDurationSeconds > 0 && cumulativeWatchSeconds >= videoDurationSeconds; |
| | | int lastPlayPositionSeconds = completed ? 0 : normalizePlayPosition(request.getLastPlayPositionSeconds(), videoDurationSeconds); |
| | | |
| | | HealthCoursePlayRecord playRecord = new HealthCoursePlayRecord(); |
| | | playRecord.setCourseId(courseId); |
| | | playRecord.setVideoId(videoId); |
| | | playRecord.setAppUserId(userId); |
| | | playRecord.setPlayStart(request.getPlayStart()); |
| | | playRecord.setPlayEnd(request.getPlayEnd()); |
| | | playRecord.setDurationSeconds(defaultNumber(request.getDurationSeconds())); |
| | | playRecord.setIsCompleted(completed ? 1 : 0); |
| | | playRecord.setLastPlayPositionSeconds(lastPlayPositionSeconds); |
| | | healthCoursePlayRecordMapper.insert(playRecord); |
| | | |
| | | Map<Long, VideoProgressSnapshot> videoProgressSnapshotMap = buildVideoProgressSnapshotMap(userId, Collections.singletonList(courseId)); |
| | | VideoProgressSnapshot videoProgressSnapshot = videoProgressSnapshotMap.get(videoId); |
| | | CourseProgressSnapshot courseProgressSnapshot = buildCourseProgressSnapshotMap(userId, Collections.singletonList(courseId)).get(courseId); |
| | | |
| | | AppHealthCoursePlayRecordUploadResultVO resultVO = new AppHealthCoursePlayRecordUploadResultVO(); |
| | | resultVO.setLastPlayPositionSeconds(Integer.valueOf(resolveVideoResumePosition(videoProgressSnapshot))); |
| | | resultVO.setCompleted(Boolean.valueOf(videoProgressSnapshot != null && videoProgressSnapshot.isCompleted())); |
| | | resultVO.setProgressPercent(Integer.valueOf(calculateVideoDisplayProgressPercent( |
| | | videoProgressSnapshot == null ? 0 : videoProgressSnapshot.getCumulativeWatchSeconds(), |
| | | videoDurationSeconds, |
| | | videoProgressSnapshot != null && videoProgressSnapshot.isCompleted() |
| | | ))); |
| | | int totalLessons = listEnabledVideos(courseId).size(); |
| | | int completedLessons = courseProgressSnapshot == null ? 0 : courseProgressSnapshot.getCompletedLessons(); |
| | | resultVO.setCompletedLessons(Integer.valueOf(completedLessons)); |
| | | resultVO.setTotalLessons(Integer.valueOf(totalLessons)); |
| | | resultVO.setCourseProgressPercent(Integer.valueOf(calculateProgressPercent(completedLessons, totalLessons))); |
| | | return resultVO; |
| | | } |
| | | |
| | | /** |
| | | * 记录当前登录用户的课程小节下载完成状态。 |
| | | */ |
| | | @Transactional |
| | | public void reportDownload(AppHealthCourseDownloadReportRequest request) { |
| | | Long userId = appAuthSessionService.requireLoginUserId(); |
| | | Long courseId = request == null ? null : request.getCourseId(); |
| | | Long courseVideoId = request == null ? null : request.getCourseVideoId(); |
| | | HealthCourse course = requireEnabledCourse(courseId); |
| | | HealthCourseVideo video = requireEnabledVideo(courseId, courseVideoId); |
| | | if (trimToEmpty(video.getVideoUrl()).isEmpty()) { |
| | | throw new IllegalArgumentException("当前课程小节暂未配置视频地址"); |
| | | } |
| | | if (!hasVideoDownloadAccess(userId, courseId, courseVideoId)) { |
| | | throw new IllegalArgumentException("当前课程小节未购买,无法下载"); |
| | | } |
| | | HealthCourseDownloadRecord existingRecord = findActiveDownloadRecord(userId, courseVideoId); |
| | | if (existingRecord == null) { |
| | | HealthCourseDownloadRecord record = buildDownloadRecord(userId, course, video); |
| | | healthCourseDownloadRecordMapper.insert(record); |
| | | return; |
| | | } |
| | | // 重复下载同一小节时只刷新快照与更新时间,避免列表产生重复项。 |
| | | HealthCourseDownloadRecord refreshRecord = buildDownloadRecord(userId, course, video); |
| | | refreshRecord.setId(existingRecord.getId()); |
| | | healthCourseDownloadRecordMapper.updateById(refreshRecord); |
| | | } |
| | | |
| | | /** |
| | | * 批量删除当前登录用户的下载记录。 |
| | | */ |
| | | @Transactional |
| | | public void batchDeleteDownloads(AppHealthCourseDownloadBatchDeleteRequest request) { |
| | | Long userId = appAuthSessionService.requireLoginUserId(); |
| | | Set<Long> recordIds = normalizeDownloadIds(request); |
| | | if (recordIds.isEmpty()) { |
| | | throw new IllegalArgumentException("请选择要删除的下载记录"); |
| | | } |
| | | for (Long recordId : recordIds) { |
| | | healthCourseDownloadRecordMapper.update(null, new LambdaUpdateWrapper<HealthCourseDownloadRecord>() |
| | | .eq(HealthCourseDownloadRecord::getId, recordId) |
| | | .eq(HealthCourseDownloadRecord::getAppUserId, userId) |
| | | .eq(HealthCourseDownloadRecord::getDeleted, 0) |
| | | .set(HealthCourseDownloadRecord::getDeleted, 1)); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 批量取消当前登录用户的养生课程收藏。 |
| | | */ |
| | | @Transactional |
| | | public void batchCancelFavorite(AppHealthCourseFavoriteBatchCancelRequest request) { |
| | | Long userId = appAuthSessionService.requireLoginUserId(); |
| | | Set<Long> courseIds = normalizeCourseIds(request); |
| | | if (courseIds.isEmpty()) { |
| | | throw new IllegalArgumentException("请选择要取消收藏的课程"); |
| | | } |
| | | for (Long courseId : courseIds) { |
| | | cancelFavoriteIfNeeded(courseId, userId); |
| | | } |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 汇总我的课程来源,已购课程优先级高于会员加课。 |
| | | */ |
| | | private List<HealthCourseOwnershipSnapshot> listMyCourseSnapshots(Long userId) { |
| | | Map<Long, HealthCourseOwnershipSnapshot> snapshotMap = new HashMap<Long, HealthCourseOwnershipSnapshot>(); |
| | | List<CoursePurchaseOrderListRow> paidCourseRows = coursePurchaseOrderMapper.selectPaidCourseSnapshots(userId); |
| | | if (paidCourseRows != null) { |
| | | for (CoursePurchaseOrderListRow row : paidCourseRows) { |
| | | Long courseId = row.getCourseId(); |
| | | if (courseId == null || snapshotMap.containsKey(courseId)) { |
| | | continue; |
| | | } |
| | | HealthCourse course = healthCourseMapper.selectById(courseId); |
| | | if (!isEnabledCourse(course)) { |
| | | continue; |
| | | } |
| | | snapshotMap.put(courseId, buildOwnershipSnapshot(course, "PURCHASED", false)); |
| | | } |
| | | } |
| | | List<Long> memberCourseIds = healthCourseMemberJoinRecordMapper.selectActiveCourseIdsByUserId(userId); |
| | | if (memberCourseIds != null) { |
| | | for (Long courseId : memberCourseIds) { |
| | | if (courseId == null || snapshotMap.containsKey(courseId)) { |
| | | continue; |
| | | } |
| | | HealthCourse course = healthCourseMapper.selectById(courseId); |
| | | if (!isEnabledCourse(course)) { |
| | | continue; |
| | | } |
| | | snapshotMap.put(courseId, buildOwnershipSnapshot(course, "MEMBER_JOIN", true)); |
| | | } |
| | | } |
| | | List<HealthCourseOwnershipSnapshot> snapshots = new ArrayList<HealthCourseOwnershipSnapshot>(snapshotMap.values()); |
| | | Collections.sort(snapshots, Comparator.comparing(HealthCourseOwnershipSnapshot::getCourseId).reversed()); |
| | | return snapshots; |
| | | } |
| | | |
| | | /** |
| | | * 校验课程是否属于当前登录用户的我的课程。 |
| | | */ |
| | | private HealthCourseOwnershipSnapshot requireOwnedCourse(Long userId, Long courseId) { |
| | | List<HealthCourseOwnershipSnapshot> snapshots = listMyCourseSnapshots(userId); |
| | | for (HealthCourseOwnershipSnapshot snapshot : snapshots) { |
| | | if (snapshot.getCourseId().equals(courseId)) { |
| | | return snapshot; |
| | | } |
| | | } |
| | | throw new IllegalArgumentException("课程不存在或未加入我的课程"); |
| | | } |
| | | |
| | | /** |
| | | * 构建课程归属快照,供我的课程相关接口复用。 |
| | | */ |
| | | private HealthCourseOwnershipSnapshot buildOwnershipSnapshot(HealthCourse course, String sourceType, boolean removable) { |
| | | return new HealthCourseOwnershipSnapshot( |
| | | course.getId(), |
| | | trimToEmpty(course.getTitle()), |
| | | trimToEmpty(course.getCoverImg()), |
| | | countEnabledVideos(course.getId()), |
| | | sourceType, |
| | | removable |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * 批量构建课程整体进度快照。 |
| | | */ |
| | | private Map<Long, CourseProgressSnapshot> buildCourseProgressSnapshotMap(Long userId, List<Long> courseIds) { |
| | | if (courseIds == null || courseIds.isEmpty()) { |
| | | return Collections.emptyMap(); |
| | | } |
| | | List<AppMyHealthCourseProgressRow> rows = healthCoursePlayRecordMapper.selectCourseProgressByCourseIds(userId, courseIds); |
| | | Map<Long, CourseProgressSnapshot> snapshotMap = new HashMap<Long, CourseProgressSnapshot>(); |
| | | if (rows == null) { |
| | | return snapshotMap; |
| | | } |
| | | for (AppMyHealthCourseProgressRow row : rows) { |
| | | if (row.getCourseId() == null) { |
| | | continue; |
| | | } |
| | | snapshotMap.put(row.getCourseId(), new CourseProgressSnapshot( |
| | | defaultNumber(row.getCompletedLessons()), |
| | | row.getLatestLearnAt() |
| | | )); |
| | | } |
| | | return snapshotMap; |
| | | } |
| | | |
| | | /** |
| | | * 批量构建章节进度快照,并以章节ID作为索引键。 |
| | | */ |
| | | private Map<Long, VideoProgressSnapshot> buildVideoProgressSnapshotMap(Long userId, List<Long> courseIds) { |
| | | if (courseIds == null || courseIds.isEmpty()) { |
| | | return Collections.emptyMap(); |
| | | } |
| | | List<AppMyHealthCourseVideoProgressRow> rows = healthCoursePlayRecordMapper.selectVideoProgressByCourseIds(userId, courseIds); |
| | | Map<Long, VideoProgressSnapshot> snapshotMap = new HashMap<Long, VideoProgressSnapshot>(); |
| | | if (rows == null) { |
| | | return snapshotMap; |
| | | } |
| | | for (AppMyHealthCourseVideoProgressRow row : rows) { |
| | | if (row.getVideoId() == null) { |
| | | continue; |
| | | } |
| | | snapshotMap.put(row.getVideoId(), new VideoProgressSnapshot( |
| | | defaultNumber(row.getCumulativeWatchSeconds()), |
| | | row.getCompleted() != null && row.getCompleted().intValue() == 1, |
| | | defaultNumber(row.getLastPlayPositionSeconds()), |
| | | row.getLatestLearnAt() |
| | | )); |
| | | } |
| | | return snapshotMap; |
| | | } |
| | | |
| | | /** |
| | | * 提取课程主键集合,便于批量查询进度。 |
| | | */ |
| | | private List<Long> extractCourseIds(List<HealthCourseOwnershipSnapshot> snapshots) { |
| | | List<Long> courseIds = new ArrayList<Long>(snapshots.size()); |
| | | for (HealthCourseOwnershipSnapshot snapshot : snapshots) { |
| | | courseIds.add(snapshot.getCourseId()); |
| | | } |
| | | return courseIds; |
| | | } |
| | | |
| | | /** |
| | | * 统计课程启用章节数量。 |
| | | */ |
| | | private int countEnabledVideos(Long courseId) { |
| | | return listEnabledVideos(courseId).size(); |
| | | } |
| | | |
| | | /** |
| | | * 计算百分比进度。 |
| | | */ |
| | | private int calculateProgressPercent(int current, int total) { |
| | | if (total <= 0 || current <= 0) { |
| | | return 0; |
| | | } |
| | | if (current >= total) { |
| | | return 100; |
| | | } |
| | | return Math.max(Math.min((int) Math.round((current * 100.0D) / total), 100), 0); |
| | | } |
| | | |
| | | /** |
| | | * 计算章节播放进度展示值,未完播章节最高仅展示到99%,避免与完播态混淆。 |
| | | */ |
| | | private int calculateVideoDisplayProgressPercent(int current, int total, boolean completed) { |
| | | int progressPercent = calculateProgressPercent(current, total); |
| | | if (!completed && progressPercent >= 100) { |
| | | return 99; |
| | | } |
| | | return progressPercent; |
| | | } |
| | | |
| | | /** |
| | | * 解析章节续播位置,已完播章节再次进入时从头播放。 |
| | | */ |
| | | private int resolveVideoResumePosition(VideoProgressSnapshot snapshot) { |
| | | if (snapshot == null || snapshot.isCompleted()) { |
| | | return 0; |
| | | } |
| | | return Math.max(snapshot.getLastPlayPositionSeconds(), 0); |
| | | } |
| | | |
| | | /** |
| | | * 校验播放记录上报请求。 |
| | | */ |
| | | private void validatePlayRecordRequest(AppHealthCoursePlayRecordUploadRequest request) { |
| | | if (request == null) { |
| | | throw new IllegalArgumentException("播放记录不能为空"); |
| | | } |
| | | if (request.getPlayStart() == null || request.getPlayEnd() == null) { |
| | | throw new IllegalArgumentException("播放时间不能为空"); |
| | | } |
| | | if (request.getPlayEnd().isBefore(request.getPlayStart())) { |
| | | throw new IllegalArgumentException("播放结束时间不能早于开始时间"); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 查询当前章节已累计观看时长。 |
| | | */ |
| | | private int resolveExistingCumulativeWatchSeconds(Long userId, Long courseId, Long videoId) { |
| | | List<HealthCoursePlayRecord> records = healthCoursePlayRecordMapper.selectList(new LambdaQueryWrapper<HealthCoursePlayRecord>() |
| | | .eq(HealthCoursePlayRecord::getAppUserId, userId) |
| | | .eq(HealthCoursePlayRecord::getCourseId, courseId) |
| | | .eq(HealthCoursePlayRecord::getVideoId, videoId)); |
| | | if (records == null || records.isEmpty()) { |
| | | return 0; |
| | | } |
| | | int sum = 0; |
| | | for (HealthCoursePlayRecord record : records) { |
| | | sum += defaultNumber(record.getDurationSeconds()); |
| | | } |
| | | return sum; |
| | | } |
| | | |
| | | /** |
| | | * 归一化播放位置,避免超出视频时长。 |
| | | */ |
| | | private int normalizePlayPosition(Integer lastPlayPositionSeconds, int videoDurationSeconds) { |
| | | int position = defaultNumber(lastPlayPositionSeconds); |
| | | if (videoDurationSeconds <= 0) { |
| | | return Math.max(position, 0); |
| | | } |
| | | return Math.max(Math.min(position, videoDurationSeconds), 0); |
| | | } |
| | | |
| | | /** |
| | | * 使用实体数据解析课程总时长。 |
| | | */ |
| | | private Integer resolveTotalDurationSecondsByEntity(HealthCourse course, List<HealthCourseVideo> videos) { |
| | | Integer totalDurationSeconds = course.getTotalDurationSeconds(); |
| | | if (totalDurationSeconds != null && totalDurationSeconds.intValue() > 0) { |
| | | return totalDurationSeconds; |
| | | } |
| | | int sum = 0; |
| | | for (HealthCourseVideo video : videos) { |
| | | sum += defaultNumber(video.getDurationSeconds()); |
| | | } |
| | | return Integer.valueOf(sum); |
| | | } |
| | | |
| | | /** |
| | | * 判断课程是否为启用状态。 |
| | | */ |
| | | private boolean isEnabledCourse(HealthCourse course) { |
| | | return course != null |
| | | && course.getDeleted() != null |
| | | && course.getDeleted().intValue() == 0 |
| | | && course.getStatus() != null |
| | | && course.getStatus().intValue() == COURSE_STATUS_ENABLED; |
| | | } |
| | | |
| | | /** |
| | | * 判断当前请求用户是否已收藏课程;游客统一视为未收藏。 |
| | | */ |
| | | private boolean resolveFavorited(Long courseId) { |
| | |
| | | return false; |
| | | } |
| | | return isFavorited(findLatestFavoriteRecord(courseId, userId)); |
| | | } |
| | | |
| | | /** |
| | | * 按课程集合规范化批量取消入参。 |
| | | */ |
| | | private Set<Long> normalizeCourseIds(AppHealthCourseFavoriteBatchCancelRequest request) { |
| | | if (request == null || request.getCourseIds() == null || request.getCourseIds().isEmpty()) { |
| | | return Collections.emptySet(); |
| | | } |
| | | Set<Long> courseIds = new LinkedHashSet<Long>(); |
| | | for (Long courseId : request.getCourseIds()) { |
| | | if (courseId != null) { |
| | | courseIds.add(courseId); |
| | | } |
| | | } |
| | | return courseIds; |
| | | } |
| | | |
| | | /** |
| | | * 按下载记录集合规范化批量删除入参。 |
| | | */ |
| | | private Set<Long> normalizeDownloadIds(AppHealthCourseDownloadBatchDeleteRequest request) { |
| | | if (request == null || request.getIds() == null || request.getIds().isEmpty()) { |
| | | return Collections.emptySet(); |
| | | } |
| | | Set<Long> recordIds = new LinkedHashSet<Long>(); |
| | | for (Long id : request.getIds()) { |
| | | if (id != null) { |
| | | recordIds.add(id); |
| | | } |
| | | } |
| | | return recordIds; |
| | | } |
| | | |
| | | /** |
| | | * 在当前课程仍为收藏态时写入取消流水并回收收藏数。 |
| | | */ |
| | | private void cancelFavoriteIfNeeded(Long courseId, Long userId) { |
| | | HealthCourse course = healthCourseMapper.selectById(courseId); |
| | | if (course == null || course.getDeleted() == null || course.getDeleted().intValue() != 0) { |
| | | return; |
| | | } |
| | | HealthCourseFavoriteRecord latestRecord = findLatestFavoriteRecord(courseId, userId); |
| | | if (!isFavorited(latestRecord)) { |
| | | return; |
| | | } |
| | | insertFavoriteRecord(courseId, userId, ACTION_TYPE_CANCEL); |
| | | updateFavoriteCount(courseId, false); |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 判断当前用户是否拥有指定小节的下载权限。 |
| | | */ |
| | | private boolean hasVideoDownloadAccess(Long userId, Long courseId, Long courseVideoId) { |
| | | PurchaseStateSnapshot purchaseState = resolvePurchaseState(courseId); |
| | | if (purchaseState.isCourseOwned()) { |
| | | return true; |
| | | } |
| | | return purchaseState.getPurchasedVideoIds().contains(courseVideoId); |
| | | } |
| | | |
| | | /** |
| | | * 读取当前用户对课程仍有效的已购小节集合。 |
| | | */ |
| | | private List<Long> resolvePurchasedVideoIds(Long userId, Long courseId, boolean courseOwned) { |
| | |
| | | .orderByDesc(CoursePurchaseOrder::getPaidAt) |
| | | .orderByDesc(CoursePurchaseOrder::getId) |
| | | .last("LIMIT 1")); |
| | | } |
| | | |
| | | /** |
| | | * 查询当前用户该小节最近一条未删除下载记录。 |
| | | */ |
| | | private HealthCourseDownloadRecord findActiveDownloadRecord(Long userId, Long courseVideoId) { |
| | | return healthCourseDownloadRecordMapper.selectOne(new LambdaQueryWrapper<HealthCourseDownloadRecord>() |
| | | .eq(HealthCourseDownloadRecord::getAppUserId, userId) |
| | | .eq(HealthCourseDownloadRecord::getCourseVideoId, courseVideoId) |
| | | .eq(HealthCourseDownloadRecord::getDeleted, 0) |
| | | .orderByDesc(HealthCourseDownloadRecord::getUpdatedAt) |
| | | .orderByDesc(HealthCourseDownloadRecord::getId) |
| | | .last("LIMIT 1")); |
| | | } |
| | | |
| | | /** |
| | | * 组装课程小节下载记录快照。 |
| | | */ |
| | | private HealthCourseDownloadRecord buildDownloadRecord(Long userId, HealthCourse course, HealthCourseVideo video) { |
| | | HealthCourseDownloadRecord record = new HealthCourseDownloadRecord(); |
| | | record.setAppUserId(userId); |
| | | record.setCourseId(course.getId()); |
| | | record.setCourseVideoId(video.getId()); |
| | | record.setCourseTitleSnapshot(trimToEmpty(course.getTitle())); |
| | | record.setChapterNameSnapshot(trimToEmpty(video.getChapterName())); |
| | | record.setCoverImgSnapshot(trimToEmpty(course.getCoverImg())); |
| | | record.setVideoUrlSnapshot(trimToEmpty(video.getVideoUrl())); |
| | | record.setDurationSecondsSnapshot(defaultNumber(video.getDurationSeconds())); |
| | | record.setDeleted(0); |
| | | return record; |
| | | } |
| | | |
| | | /** |
| | |
| | | return purchasedVideoIds; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 我的课程来源快照。 |
| | | */ |
| | | private static final class HealthCourseOwnershipSnapshot { |
| | | |
| | | private final Long courseId; |
| | | private final String title; |
| | | private final String coverImage; |
| | | private final int totalLessons; |
| | | private final String sourceType; |
| | | private final boolean removable; |
| | | |
| | | private HealthCourseOwnershipSnapshot(Long courseId, |
| | | String title, |
| | | String coverImage, |
| | | int totalLessons, |
| | | String sourceType, |
| | | boolean removable) { |
| | | this.courseId = courseId; |
| | | this.title = title; |
| | | this.coverImage = coverImage; |
| | | this.totalLessons = totalLessons; |
| | | this.sourceType = sourceType; |
| | | this.removable = removable; |
| | | } |
| | | |
| | | private Long getCourseId() { |
| | | return courseId; |
| | | } |
| | | |
| | | private String getTitle() { |
| | | return title; |
| | | } |
| | | |
| | | private String getCoverImage() { |
| | | return coverImage; |
| | | } |
| | | |
| | | private int getTotalLessons() { |
| | | return totalLessons; |
| | | } |
| | | |
| | | private String getSourceType() { |
| | | return sourceType; |
| | | } |
| | | |
| | | private boolean isRemovable() { |
| | | return removable; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 我的课程整体进度快照。 |
| | | */ |
| | | private static final class CourseProgressSnapshot { |
| | | |
| | | private final int completedLessons; |
| | | private final LocalDateTime latestLearnAt; |
| | | |
| | | private CourseProgressSnapshot(int completedLessons, LocalDateTime latestLearnAt) { |
| | | this.completedLessons = completedLessons; |
| | | this.latestLearnAt = latestLearnAt; |
| | | } |
| | | |
| | | private int getCompletedLessons() { |
| | | return completedLessons; |
| | | } |
| | | |
| | | private LocalDateTime getLatestLearnAt() { |
| | | return latestLearnAt; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 我的课程章节进度快照。 |
| | | */ |
| | | private static final class VideoProgressSnapshot { |
| | | |
| | | private final int cumulativeWatchSeconds; |
| | | private final boolean completed; |
| | | private final int lastPlayPositionSeconds; |
| | | private final LocalDateTime latestLearnAt; |
| | | |
| | | private VideoProgressSnapshot(int cumulativeWatchSeconds, |
| | | boolean completed, |
| | | int lastPlayPositionSeconds, |
| | | LocalDateTime latestLearnAt) { |
| | | this.cumulativeWatchSeconds = cumulativeWatchSeconds; |
| | | this.completed = completed; |
| | | this.lastPlayPositionSeconds = lastPlayPositionSeconds; |
| | | this.latestLearnAt = latestLearnAt; |
| | | } |
| | | |
| | | private int getCumulativeWatchSeconds() { |
| | | return cumulativeWatchSeconds; |
| | | } |
| | | |
| | | private boolean isCompleted() { |
| | | return completed; |
| | | } |
| | | |
| | | private int getLastPlayPositionSeconds() { |
| | | return lastPlayPositionSeconds; |
| | | } |
| | | |
| | | private LocalDateTime getLatestLearnAt() { |
| | | return latestLearnAt; |
| | | } |
| | | } |
| | | } |