package com.tcm.training.admin.app.course.service; 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 com.tcm.training.admin.system.vo.SystemSettingsVO; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; 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; /** * APP 养生课程详情聚合服务。 */ @Service public class AppHealthCourseService { private static final int COURSE_STATUS_ENABLED = 1; private static final int VIDEO_STATUS_ENABLED = 1; private static final String ACTION_TYPE_FAVORITE = "FAVORITE"; private static final String ACTION_TYPE_CANCEL = "CANCEL"; private static final String COURSE_TYPE_HEALTH = "HEALTH_COURSE"; private static final String PURCHASE_TARGET_COURSE = "COURSE"; private static final String PURCHASE_TARGET_CHAPTER = "COURSE_CHAPTER"; private static final String PAYMENT_METHOD_WECHAT = "WECHAT"; private static final String PAYMENT_METHOD_ALIPAY = "ALIPAY"; private static final String ORDER_STATUS_PAID = "PAID"; private static final DateTimeFormatter ORDER_NO_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); private final HealthCourseAppService healthCourseAppService; private final HealthCourseMapper healthCourseMapper; 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; private final SystemSettingsAppService systemSettingsAppService; /** * 注入 APP 养生课程详情所需依赖。 */ public AppHealthCourseService(HealthCourseAppService healthCourseAppService, HealthCourseMapper healthCourseMapper, HealthCourseVideoMapper healthCourseVideoMapper, LiveCourseCategoryMapper liveCourseCategoryMapper, ExpertMapper expertMapper, HealthCourseDownloadRecordMapper healthCourseDownloadRecordMapper, HealthCourseFavoriteRecordMapper healthCourseFavoriteRecordMapper, HealthCourseMemberJoinRecordMapper healthCourseMemberJoinRecordMapper, HealthCoursePlayRecordMapper healthCoursePlayRecordMapper, AppAuthSessionService appAuthSessionService, CoursePurchaseOrderMapper coursePurchaseOrderMapper, UserMapper userMapper, SystemSettingsAppService systemSettingsAppService) { this.healthCourseAppService = healthCourseAppService; this.healthCourseMapper = healthCourseMapper; 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; this.systemSettingsAppService = systemSettingsAppService; } /** * 获取 APP 养生课程列表页数据。 */ public List list(Long levelCategoryId, Long difficultyCategoryId) { List rows = healthCourseMapper.selectCourseList( null, levelCategoryId, difficultyCategoryId, COURSE_STATUS_ENABLED ); if (rows == null || rows.isEmpty()) { return Collections.emptyList(); } List result = new ArrayList(rows.size()); for (HealthCourseListRow row : rows) { AppHealthCourseListItemVO itemVO = new AppHealthCourseListItemVO(); itemVO.setId(row.getId()); itemVO.setTitle(trimToEmpty(row.getTitle())); itemVO.setCoverImage(trimToEmpty(row.getCoverImg())); itemVO.setLevelName(trimToEmpty(row.getLevelName())); itemVO.setDifficultyName(trimToEmpty(row.getDifficultyName())); itemVO.setSalePrice(defaultPrice(row.getSalePrice())); itemVO.setLearnUserCount(defaultNumber(row.getLearnUserCount())); itemVO.setStudyingCount(defaultNumber(row.getStudyingCount())); result.add(itemVO); } return result; } /** * 获取 APP 养生课程详情页数据。 */ public AppHealthCourseDetailVO detail(Long courseId) { HealthCourseDetailVO courseDetail = healthCourseAppService.detail(courseId); if (courseDetail.getStatus() == null || courseDetail.getStatus().intValue() != COURSE_STATUS_ENABLED) { throw new IllegalArgumentException("养生课程不存在"); } // 进入课程详情即视为一次学习行为,正在学习人数自增;返回值按自增后的口径展示 incrementStudyingCount(courseId); List enabledVideos = listEnabledVideos(courseId); AppHealthCourseDetailVO detailVO = new AppHealthCourseDetailVO(); detailVO.setId(courseDetail.getId()); detailVO.setTitle(trimToEmpty(courseDetail.getTitle())); detailVO.setBannerImages(resolveBannerImages(courseDetail)); detailVO.setLevelName(trimToEmpty(courseDetail.getLevelName())); detailVO.setDifficultyName(trimToEmpty(courseDetail.getDifficultyName())); detailVO.setSalePrice(defaultPrice(courseDetail.getSalePrice())); detailVO.setLearnUserCount(defaultNumber(courseDetail.getLearnUserCount())); detailVO.setStudyingCount(Integer.valueOf(defaultNumber(courseDetail.getStudyingCount()) + 1)); detailVO.setSummary(trimToEmpty(courseDetail.getSummary())); detailVO.setTargetUsers(trimToEmpty(courseDetail.getTargetUsers())); detailVO.setContent(trimToEmpty(courseDetail.getContent())); detailVO.setVideoCount(Integer.valueOf(enabledVideos.size())); detailVO.setTotalDurationSeconds(resolveTotalDurationSeconds(courseDetail, enabledVideos)); detailVO.setTotalDurationText(formatCourseDuration(detailVO.getTotalDurationSeconds())); detailVO.setExpert(resolveExpert(courseDetail.getExpert() == null ? null : courseDetail.getExpert().getId())); detailVO.setServicePhone(resolveServicePhone()); PurchaseStateSnapshot purchaseState = resolvePurchaseState(courseDetail.getId()); List videoVOList = toVideoVOList(enabledVideos, purchaseState); detailVO.setVideos(videoVOList); detailVO.setFreeVideoCount(countFreeVideos(videoVOList)); detailVO.setPaidVideoCount(countPaidVideos(videoVOList)); detailVO.setFavorited(Boolean.valueOf(resolveFavorited(courseDetail.getId()))); detailVO.setIsMember(Boolean.valueOf(purchaseState.isMember())); detailVO.setMemberCourseJoined(Boolean.valueOf(purchaseState.isMemberCourseJoined())); detailVO.setCourseOwned(Boolean.valueOf(purchaseState.isCourseOwned())); detailVO.setCoursePurchased(Boolean.valueOf(purchaseState.isCoursePurchased())); return detailVO; } /** * 获取当前登录用户的收藏课程列表。 */ public List favorites() { Long userId = appAuthSessionService.requireLoginUserId(); List rows = healthCourseFavoriteRecordMapper.selectAppFavoriteCourses(userId); if (rows == null || rows.isEmpty()) { return Collections.emptyList(); } List result = new ArrayList(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.setStudyingCount(defaultNumber(row.getStudyingCount())); itemVO.setFavoritedAt(row.getFavoritedAt()); result.add(itemVO); } return result; } /** * 获取当前登录用户的已完成下载列表。 */ public List downloads() { Long userId = appAuthSessionService.requireLoginUserId(); List rows = healthCourseDownloadRecordMapper.selectAppDownloads(userId); if (rows == null || rows.isEmpty()) { return Collections.emptyList(); } List result = new ArrayList(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 myCourses() { Long userId = appAuthSessionService.requireLoginUserId(); List snapshots = listMyCourseSnapshots(userId); if (snapshots.isEmpty()) { return Collections.emptyList(); } Map progressSnapshotMap = buildCourseProgressSnapshotMap(userId, extractCourseIds(snapshots)); List result = new ArrayList(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() .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 enabledVideos = listEnabledVideos(courseId); Map videoProgressSnapshotMap = buildVideoProgressSnapshotMap(userId, Collections.singletonList(courseId)); PurchaseStateSnapshot purchaseStateSnapshot = resolvePurchaseState(courseId); List videos = new ArrayList(enabledVideos.size()); Long currentVideoId = null; LocalDateTime latestLearnAt = null; for (HealthCourseVideo video : enabledVideos) { VideoProgressSnapshot snapshot = videoProgressSnapshotMap.get(video.getId()); boolean freeAccess = isFreeVideo(video.getSalePrice()); boolean purchased = purchaseStateSnapshot.isCourseOwned() || purchaseStateSnapshot.getPurchasedVideoIds().contains(video.getId()); boolean locked = !freeAccess && !purchased; AppMyHealthCourseVideoItemVO itemVO = new AppMyHealthCourseVideoItemVO(); itemVO.setVideoId(video.getId()); itemVO.setChapterName(trimToEmpty(video.getChapterName())); itemVO.setDurationSeconds(defaultNumber(video.getDurationSeconds())); itemVO.setDurationText(formatVideoDuration(itemVO.getDurationSeconds())); itemVO.setFreeAccess(Boolean.valueOf(freeAccess)); itemVO.setPurchased(Boolean.valueOf(purchased)); itemVO.setLocked(Boolean.valueOf(locked)); 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 (!locked && snapshot != null && snapshot.getLatestLearnAt() != null && (latestLearnAt == null || snapshot.getLatestLearnAt().isAfter(latestLearnAt))) { latestLearnAt = snapshot.getLatestLearnAt(); currentVideoId = video.getId(); } } if (currentVideoId == null) { for (AppMyHealthCourseVideoItemVO itemVO : videos) { if (!Boolean.TRUE.equals(itemVO.getLocked())) { currentVideoId = itemVO.getVideoId(); break; } } } 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 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 recordIds = normalizeDownloadIds(request); if (recordIds.isEmpty()) { throw new IllegalArgumentException("请选择要删除的下载记录"); } for (Long recordId : recordIds) { healthCourseDownloadRecordMapper.update(null, new LambdaUpdateWrapper() .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 courseIds = normalizeCourseIds(request); if (courseIds.isEmpty()) { throw new IllegalArgumentException("请选择要取消收藏的课程"); } for (Long courseId : courseIds) { cancelFavoriteIfNeeded(courseId, userId); } } /** * 收藏 APP 养生课程。 */ @Transactional public AppHealthCourseFavoriteResultVO favorite(Long courseId) { Long userId = appAuthSessionService.requireLoginUserId(); HealthCourse course = requireEnabledCourse(courseId); HealthCourseFavoriteRecord latestRecord = findLatestFavoriteRecord(courseId, userId); if (isFavorited(latestRecord)) { return buildFavoriteResult(course.getId(), true, course.getFavoriteCount()); } insertFavoriteRecord(courseId, userId, ACTION_TYPE_FAVORITE); updateFavoriteCount(courseId, true); return buildFavoriteResult(courseId, true, defaultNumber(course.getFavoriteCount()) + 1); } /** * 取消收藏 APP 养生课程。 */ @Transactional public AppHealthCourseFavoriteResultVO cancelFavorite(Long courseId) { Long userId = appAuthSessionService.requireLoginUserId(); HealthCourse course = requireEnabledCourse(courseId); HealthCourseFavoriteRecord latestRecord = findLatestFavoriteRecord(courseId, userId); if (!isFavorited(latestRecord)) { return buildFavoriteResult(course.getId(), false, course.getFavoriteCount()); } insertFavoriteRecord(courseId, userId, ACTION_TYPE_CANCEL); updateFavoriteCount(courseId, false); return buildFavoriteResult(courseId, false, Math.max(defaultNumber(course.getFavoriteCount()) - 1, 0)); } /** * 购买 APP 养生课程整课。 */ @Transactional public AppHealthCoursePurchaseResultVO purchaseCourse(Long courseId, AppHealthCoursePurchaseRequest request) { Long userId = appAuthSessionService.requireLoginUserId(); AppUser user = requireEnabledUser(userId); HealthCourse course = requireEnabledCourse(courseId); String paymentMethod = normalizePaymentMethod(request == null ? null : request.getPaymentMethod()); CoursePurchaseOrder existingOrder = findLatestActivePaidCourseOrder(userId, courseId); if (existingOrder != null) { return buildPurchaseResult(existingOrder, true, resolvePurchasedVideoIds(userId, courseId, true)); } CoursePurchaseOrder order = buildCourseOrder(user, course, paymentMethod, LocalDateTime.now()); coursePurchaseOrderMapper.insert(order); return buildPurchaseResult(order, true, resolvePurchasedVideoIds(userId, courseId, true)); } /** * 购买 APP 养生课程小节。 */ @Transactional public AppHealthCoursePurchaseResultVO purchaseVideo(Long courseId, Long videoId, AppHealthCoursePurchaseRequest request) { Long userId = appAuthSessionService.requireLoginUserId(); AppUser user = requireEnabledUser(userId); HealthCourse course = requireEnabledCourse(courseId); HealthCourseVideo video = requireEnabledVideo(courseId, videoId); String paymentMethod = normalizePaymentMethod(request == null ? null : request.getPaymentMethod()); if (isFreeVideo(video.getSalePrice())) { throw new IllegalArgumentException("免费小节无需购买"); } CoursePurchaseOrder existingCourseOrder = findLatestActivePaidCourseOrder(userId, courseId); if (existingCourseOrder != null) { return buildPurchaseResult(existingCourseOrder, true, resolvePurchasedVideoIds(userId, courseId, true)); } CoursePurchaseOrder existingVideoOrder = findLatestActivePaidVideoOrder(userId, courseId, videoId); if (existingVideoOrder != null) { return buildPurchaseResult(existingVideoOrder, false, resolvePurchasedVideoIds(userId, courseId, false)); } CoursePurchaseOrder order = buildVideoOrder(user, course, video, paymentMethod, LocalDateTime.now()); coursePurchaseOrderMapper.insert(order); return buildPurchaseResult(order, false, resolvePurchasedVideoIds(userId, courseId, false)); } /** * 会员将养生课程加入我的课程。 */ @Transactional public boolean memberJoinCourse(Long courseId) { Long userId = appAuthSessionService.requireLoginUserId(); AppUser user = requireEnabledUser(userId); requireEnabledCourse(courseId); if (!isActiveMember(user)) { throw new IllegalArgumentException("当前用户不是有效会员"); } if (findLatestActivePaidCourseOrder(userId, courseId) != null) { return true; } if (findMemberJoinRecord(courseId, userId) != null) { return true; } HealthCourseMemberJoinRecord record = new HealthCourseMemberJoinRecord(); record.setCourseId(courseId); record.setAppUserId(userId); record.setJoinedAt(LocalDateTime.now()); record.setDeleted(0); healthCourseMemberJoinRecordMapper.insert(record); return true; } /** * 获取 APP 养生课程主题筛选项。 */ public List listThemes() { return listCategoriesByType(LiveCourseCategoryAppService.TYPE_LEVEL); } /** * 获取 APP 养生课程难度筛选项。 */ public List listDifficulties() { return listCategoriesByType(LiveCourseCategoryAppService.TYPE_DIFFICULTY); } /** * 按分类类型读取 APP 养生课程筛选项。 */ private List listCategoriesByType(String categoryType) { List categories = liveCourseCategoryMapper.selectList(new LambdaQueryWrapper() .eq(LiveCourseCategory::getType, categoryType) .eq(LiveCourseCategory::getDeleted, 0) .orderByAsc(LiveCourseCategory::getSort) .orderByAsc(LiveCourseCategory::getId)); if (categories == null || categories.isEmpty()) { return Collections.emptyList(); } List result = new ArrayList(categories.size()); for (LiveCourseCategory category : categories) { AppHealthCourseCategoryOptionVO optionVO = new AppHealthCourseCategoryOptionVO(); optionVO.setId(category.getId()); optionVO.setName(trimToEmpty(category.getName())); result.add(optionVO); } return result; } /** * 汇总我的课程来源,已购课程优先级高于会员加课。 */ private List listMyCourseSnapshots(Long userId) { Map snapshotMap = new HashMap(); List 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 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 snapshots = new ArrayList(snapshotMap.values()); Collections.sort(snapshots, Comparator.comparing(HealthCourseOwnershipSnapshot::getCourseId).reversed()); return snapshots; } /** * 校验课程是否属于当前登录用户的我的课程。 */ private HealthCourseOwnershipSnapshot requireOwnedCourse(Long userId, Long courseId) { List 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 buildCourseProgressSnapshotMap(Long userId, List courseIds) { if (courseIds == null || courseIds.isEmpty()) { return Collections.emptyMap(); } List rows = healthCoursePlayRecordMapper.selectCourseProgressByCourseIds(userId, courseIds); Map snapshotMap = new HashMap(); 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 buildVideoProgressSnapshotMap(Long userId, List courseIds) { if (courseIds == null || courseIds.isEmpty()) { return Collections.emptyMap(); } List rows = healthCoursePlayRecordMapper.selectVideoProgressByCourseIds(userId, courseIds); Map snapshotMap = new HashMap(); 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 extractCourseIds(List snapshots) { List courseIds = new ArrayList(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 records = healthCoursePlayRecordMapper.selectList(new LambdaQueryWrapper() .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 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) { if (!appAuthSessionService.isLogin()) { return false; } Long userId = appAuthSessionService.getLoginUserId(); if (userId == null) { return false; } return isFavorited(findLatestFavoriteRecord(courseId, userId)); } /** * 按课程集合规范化批量取消入参。 */ private Set normalizeCourseIds(AppHealthCourseFavoriteBatchCancelRequest request) { if (request == null || request.getCourseIds() == null || request.getCourseIds().isEmpty()) { return Collections.emptySet(); } Set courseIds = new LinkedHashSet(); for (Long courseId : request.getCourseIds()) { if (courseId != null) { courseIds.add(courseId); } } return courseIds; } /** * 按下载记录集合规范化批量删除入参。 */ private Set normalizeDownloadIds(AppHealthCourseDownloadBatchDeleteRequest request) { if (request == null || request.getIds() == null || request.getIds().isEmpty()) { return Collections.emptySet(); } Set recordIds = new LinkedHashSet(); 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 List listEnabledVideos(Long courseId) { List records = healthCourseVideoMapper.selectList(new LambdaQueryWrapper() .eq(HealthCourseVideo::getCourseId, courseId) .eq(HealthCourseVideo::getDeleted, 0) .eq(HealthCourseVideo::getStatus, VIDEO_STATUS_ENABLED) .orderByAsc(HealthCourseVideo::getSort) .orderByAsc(HealthCourseVideo::getId)); return records == null ? Collections.emptyList() : records; } /** * 组装课程详情页需要展示的轮播图列表。 */ private List resolveBannerImages(HealthCourseDetailVO courseDetail) { List bannerImages = new ArrayList(); if (courseDetail.getBannerImgs() != null) { for (String bannerImage : courseDetail.getBannerImgs()) { String normalizedImage = trimToEmpty(bannerImage); if (!normalizedImage.isEmpty()) { bannerImages.add(normalizedImage); } } } // 课程未配置轮播图时,回退到封面图,避免前端轮播组件出现空白页。 if (bannerImages.isEmpty()) { String coverImage = trimToEmpty(courseDetail.getCoverImg()); if (!coverImage.isEmpty()) { bannerImages.add(coverImage); } } return bannerImages; } /** * 组装课程详情页专家卡片信息。 */ private AppHealthCourseExpertVO resolveExpert(Long expertId) { if (expertId == null) { return null; } Expert expert = expertMapper.selectOne(new LambdaQueryWrapper() .eq(Expert::getId, expertId) .eq(Expert::getDeleted, 0) .eq(Expert::getStatus, COURSE_STATUS_ENABLED) .last("LIMIT 1")); if (expert == null) { return null; } AppHealthCourseExpertVO expertVO = new AppHealthCourseExpertVO(); expertVO.setId(expert.getId()); expertVO.setName(trimToEmpty(expert.getName())); expertVO.setAvatar(trimToEmpty(expert.getAvatar())); expertVO.setSpecialty(trimToEmpty(expert.getSpecialty())); return expertVO; } /** * 将启用视频列表转换为 APP 详情页可直接渲染的数据结构。 */ private List toVideoVOList(List videos, PurchaseStateSnapshot purchaseState) { if (videos == null || videos.isEmpty()) { return Collections.emptyList(); } List result = new ArrayList(videos.size()); for (HealthCourseVideo video : videos) { AppHealthCourseVideoVO videoVO = new AppHealthCourseVideoVO(); videoVO.setId(video.getId()); videoVO.setChapterName(trimToEmpty(video.getChapterName())); videoVO.setDurationSeconds(defaultNumber(video.getDurationSeconds())); videoVO.setDurationText(formatVideoDuration(videoVO.getDurationSeconds())); videoVO.setSalePrice(defaultPrice(video.getSalePrice())); // 前端课程列表直接按该字段切分免费/付费列表,避免再次复制业务判断。 videoVO.setFreeAccess(Boolean.valueOf(isFreeVideo(videoVO.getSalePrice()))); boolean purchased = purchaseState.isCourseOwned() || purchaseState.getPurchasedVideoIds().contains(video.getId()); boolean locked = !Boolean.TRUE.equals(videoVO.getFreeAccess()) && !purchased; videoVO.setPurchased(Boolean.valueOf(purchased)); videoVO.setLocked(Boolean.valueOf(locked)); // 已购买章节需要直接拿到视频地址,前端才能切换为下载入口。 videoVO.setVideoUrl(trimToEmpty(video.getVideoUrl())); videoVO.setCreatedAt(video.getCreatedAt()); videoVO.setUpdatedAt(video.getUpdatedAt()); result.add(videoVO); } return result; } /** * 统计免费章节数量。 */ private Integer countFreeVideos(List videos) { int count = 0; for (AppHealthCourseVideoVO video : videos) { if (Boolean.TRUE.equals(video.getFreeAccess())) { count++; } } return Integer.valueOf(count); } /** * 统计付费章节数量。 */ private Integer countPaidVideos(List videos) { int count = 0; for (AppHealthCourseVideoVO video : videos) { if (!Boolean.TRUE.equals(video.getFreeAccess())) { count++; } } return Integer.valueOf(count); } /** * 解析课程总时长,优先复用课程表已汇总的时长字段。 */ private Integer resolveTotalDurationSeconds(HealthCourseDetailVO courseDetail, List videos) { Integer totalDurationSeconds = courseDetail.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 isFreeVideo(BigDecimal salePrice) { return salePrice == null || salePrice.compareTo(BigDecimal.ZERO) <= 0; } /** * 汇总当前登录用户的课程购买状态。 */ private PurchaseStateSnapshot resolvePurchaseState(Long courseId) { if (!appAuthSessionService.isLogin()) { return PurchaseStateSnapshot.empty(); } Long userId = appAuthSessionService.getLoginUserId(); if (userId == null) { return PurchaseStateSnapshot.empty(); } AppUser user = userMapper.selectById(userId); if (user == null || user.getDeleted() == null || user.getDeleted().intValue() != 0) { return PurchaseStateSnapshot.empty(); } CoursePurchaseOrder activeCourseOrder = findLatestActivePaidCourseOrder(userId, courseId); boolean coursePurchased = activeCourseOrder != null; boolean memberCourseJoined = findMemberJoinRecord(courseId, userId) != null; boolean courseOwned = coursePurchased || memberCourseJoined; return new PurchaseStateSnapshot( isActiveMember(user), memberCourseJoined, courseOwned, coursePurchased, resolvePurchasedVideoIds(userId, courseId, courseOwned) ); } /** * 判断当前用户是否拥有指定小节的下载权限。 */ 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 resolvePurchasedVideoIds(Long userId, Long courseId, boolean courseOwned) { if (courseOwned) { List enabledVideos = listEnabledVideos(courseId); List allVideoIds = new ArrayList(enabledVideos.size()); for (HealthCourseVideo video : enabledVideos) { allVideoIds.add(video.getId()); } return allVideoIds; } List orders = listActivePaidOrders(userId, courseId); Set purchasedVideoIds = new LinkedHashSet(); for (CoursePurchaseOrder order : orders) { if (PURCHASE_TARGET_CHAPTER.equals(order.getPurchaseTargetType()) && order.getCourseVideoId() != null) { purchasedVideoIds.add(order.getCourseVideoId()); } } return new ArrayList(purchasedVideoIds); } /** * 查询用户当前课程下所有有效的已支付订单。 */ private List listActivePaidOrders(Long userId, Long courseId) { List orders = coursePurchaseOrderMapper.selectList(new LambdaQueryWrapper() .eq(CoursePurchaseOrder::getAppUserId, userId) .eq(CoursePurchaseOrder::getCourseType, COURSE_TYPE_HEALTH) .eq(CoursePurchaseOrder::getCourseId, courseId) .eq(CoursePurchaseOrder::getStatus, ORDER_STATUS_PAID) .eq(CoursePurchaseOrder::getDeleted, 0) .orderByDesc(CoursePurchaseOrder::getPaidAt) .orderByDesc(CoursePurchaseOrder::getId)); return orders == null ? Collections.emptyList() : orders; } /** * 查询用户当前课程最近一条有效整课订单。 */ private CoursePurchaseOrder findLatestActivePaidCourseOrder(Long userId, Long courseId) { return coursePurchaseOrderMapper.selectOne(new LambdaQueryWrapper() .eq(CoursePurchaseOrder::getAppUserId, userId) .eq(CoursePurchaseOrder::getCourseType, COURSE_TYPE_HEALTH) .eq(CoursePurchaseOrder::getPurchaseTargetType, PURCHASE_TARGET_COURSE) .eq(CoursePurchaseOrder::getCourseId, courseId) .eq(CoursePurchaseOrder::getStatus, ORDER_STATUS_PAID) .eq(CoursePurchaseOrder::getDeleted, 0) .orderByDesc(CoursePurchaseOrder::getPaidAt) .orderByDesc(CoursePurchaseOrder::getId) .last("LIMIT 1")); } /** * 查询用户当前课程最近一条有效小节订单。 */ private CoursePurchaseOrder findLatestActivePaidVideoOrder(Long userId, Long courseId, Long videoId) { return coursePurchaseOrderMapper.selectOne(new LambdaQueryWrapper() .eq(CoursePurchaseOrder::getAppUserId, userId) .eq(CoursePurchaseOrder::getCourseType, COURSE_TYPE_HEALTH) .eq(CoursePurchaseOrder::getPurchaseTargetType, PURCHASE_TARGET_CHAPTER) .eq(CoursePurchaseOrder::getCourseId, courseId) .eq(CoursePurchaseOrder::getCourseVideoId, videoId) .eq(CoursePurchaseOrder::getStatus, ORDER_STATUS_PAID) .eq(CoursePurchaseOrder::getDeleted, 0) .orderByDesc(CoursePurchaseOrder::getPaidAt) .orderByDesc(CoursePurchaseOrder::getId) .last("LIMIT 1")); } /** * 查询当前用户该小节最近一条未删除下载记录。 */ private HealthCourseDownloadRecord findActiveDownloadRecord(Long userId, Long courseVideoId) { return healthCourseDownloadRecordMapper.selectOne(new LambdaQueryWrapper() .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; } /** * 查询用户当前课程有效的会员加课记录。 */ private HealthCourseMemberJoinRecord findMemberJoinRecord(Long courseId, Long userId) { return healthCourseMemberJoinRecordMapper.selectOne(new LambdaQueryWrapper() .eq(HealthCourseMemberJoinRecord::getCourseId, courseId) .eq(HealthCourseMemberJoinRecord::getAppUserId, userId) .eq(HealthCourseMemberJoinRecord::getDeleted, 0) .orderByDesc(HealthCourseMemberJoinRecord::getJoinedAt) .orderByDesc(HealthCourseMemberJoinRecord::getId) .last("LIMIT 1")); } /** * 组装整课订单数据,支付方案落地前直接记为已支付。 */ private CoursePurchaseOrder buildCourseOrder(AppUser user, HealthCourse course, String paymentMethod, LocalDateTime now) { CoursePurchaseOrder order = new CoursePurchaseOrder(); order.setOrderNo(buildOrderNo(user.getId(), now)); order.setAppUserId(user.getId()); order.setUserNickname(resolveUserNickname(user)); order.setUserPhone(trimToEmpty(user.getPhone())); order.setCourseType(COURSE_TYPE_HEALTH); order.setPurchaseTargetType(PURCHASE_TARGET_COURSE); order.setCourseId(course.getId()); order.setCourseVideoId(null); order.setProductNameSnapshot(trimToEmpty(course.getTitle())); order.setCourseTitleSnapshot(trimToEmpty(course.getTitle())); order.setChapterNameSnapshot(null); order.setCoverImgSnapshot(trimToEmpty(course.getCoverImg())); order.setOrderAmount(defaultPrice(course.getSalePrice())); order.setPaymentMethod(paymentMethod); order.setRefundAmount(null); order.setRefundRemark(null); order.setStatus(ORDER_STATUS_PAID); order.setPaidAt(now); order.setRefundedAt(null); order.setCancelledAt(null); order.setDeleted(0); return order; } /** * 组装课程小节订单数据,支付方案落地前直接记为已支付。 */ private CoursePurchaseOrder buildVideoOrder(AppUser user, HealthCourse course, HealthCourseVideo video, String paymentMethod, LocalDateTime now) { CoursePurchaseOrder order = new CoursePurchaseOrder(); order.setOrderNo(buildOrderNo(user.getId(), now)); order.setAppUserId(user.getId()); order.setUserNickname(resolveUserNickname(user)); order.setUserPhone(trimToEmpty(user.getPhone())); order.setCourseType(COURSE_TYPE_HEALTH); order.setPurchaseTargetType(PURCHASE_TARGET_CHAPTER); order.setCourseId(course.getId()); order.setCourseVideoId(video.getId()); order.setProductNameSnapshot(trimToEmpty(course.getTitle()) + "-" + trimToEmpty(video.getChapterName())); order.setCourseTitleSnapshot(trimToEmpty(course.getTitle())); order.setChapterNameSnapshot(trimToEmpty(video.getChapterName())); order.setCoverImgSnapshot(trimToEmpty(course.getCoverImg())); order.setOrderAmount(defaultPrice(video.getSalePrice())); order.setPaymentMethod(paymentMethod); order.setRefundAmount(null); order.setRefundRemark(null); order.setStatus(ORDER_STATUS_PAID); order.setPaidAt(now); order.setRefundedAt(null); order.setCancelledAt(null); order.setDeleted(0); return order; } /** * 组装购买结果响应,供详情页直接回写购买态。 */ private AppHealthCoursePurchaseResultVO buildPurchaseResult(CoursePurchaseOrder order, boolean coursePurchased, List purchasedVideoIds) { AppHealthCoursePurchaseResultVO resultVO = new AppHealthCoursePurchaseResultVO(); resultVO.setOrderId(order == null ? null : order.getId()); resultVO.setOrderNo(order == null ? null : order.getOrderNo()); resultVO.setPaymentMethod(order == null ? null : order.getPaymentMethod()); resultVO.setStatus(order == null ? ORDER_STATUS_PAID : order.getStatus()); resultVO.setPaidAt(order == null ? null : order.getPaidAt()); resultVO.setCoursePurchased(Boolean.valueOf(coursePurchased)); resultVO.setPurchasedVideoIds(purchasedVideoIds == null ? Collections.emptyList() : purchasedVideoIds); return resultVO; } /** * 校验当前登录用户存在且可用。 */ private AppUser requireEnabledUser(Long userId) { AppUser user = userMapper.selectById(userId); if (user == null || user.getDeleted() == null || user.getDeleted().intValue() != 0) { throw new IllegalArgumentException("用户不存在"); } if (user.getStatus() == null || user.getStatus().intValue() != 1) { throw new IllegalArgumentException("用户已禁用"); } return user; } /** * 校验课程小节存在、归属正确且已启用。 */ private HealthCourseVideo requireEnabledVideo(Long courseId, Long videoId) { HealthCourseVideo video = healthCourseVideoMapper.selectById(videoId); if (video == null || video.getDeleted() == null || video.getDeleted().intValue() != 0 || video.getStatus() == null || video.getStatus().intValue() != VIDEO_STATUS_ENABLED) { throw new IllegalArgumentException("课程小节不存在"); } if (!courseId.equals(video.getCourseId())) { throw new IllegalArgumentException("课程小节不存在"); } return video; } /** * 规范化支付方式入参,仅允许微信或支付宝。 */ private String normalizePaymentMethod(String paymentMethod) { String normalized = trimToEmpty(paymentMethod).toUpperCase(); if (PAYMENT_METHOD_WECHAT.equals(normalized) || PAYMENT_METHOD_ALIPAY.equals(normalized)) { return normalized; } throw new IllegalArgumentException("支付方式不合法"); } /** * 生成课程订单号。 */ private String buildOrderNo(Long userId, LocalDateTime now) { String seed = String.valueOf(Math.abs((userId == null ? 0L : userId.longValue()) % 10000L)); int nanoTail = Math.abs(now.getNano() / 1000) % 1000000; return "CPO" + ORDER_NO_TIME_FORMATTER.format(now) + String.format("%06d", nanoTail) + String.format("%04d", Integer.parseInt(seed)); } /** * 优先使用昵称快照,没有昵称时降级到微信昵称或用户编号。 */ private String resolveUserNickname(AppUser user) { String nickname = trimToEmpty(user.getNickname()); if (!nickname.isEmpty()) { return nickname; } String wechatNickname = trimToEmpty(user.getWechatNickname()); if (!wechatNickname.isEmpty()) { return wechatNickname; } return "用户" + user.getId(); } /** * 判断用户当前是否仍属于有效会员。 */ private boolean isActiveMember(AppUser user) { return user != null && user.getMemberExpireAt() != null && user.getMemberExpireAt().isAfter(LocalDateTime.now()); } /** * 读取用户对课程的最新收藏操作记录。 */ private HealthCourseFavoriteRecord findLatestFavoriteRecord(Long courseId, Long userId) { return healthCourseFavoriteRecordMapper.selectOne(new LambdaQueryWrapper() .eq(HealthCourseFavoriteRecord::getCourseId, courseId) .eq(HealthCourseFavoriteRecord::getAppUserId, userId) .orderByDesc(HealthCourseFavoriteRecord::getActionTime) .orderByDesc(HealthCourseFavoriteRecord::getId) .last("LIMIT 1")); } /** * 判断最新操作是否表示当前仍处于已收藏状态。 */ private boolean isFavorited(HealthCourseFavoriteRecord latestRecord) { return latestRecord != null && ACTION_TYPE_FAVORITE.equalsIgnoreCase(trimToEmpty(latestRecord.getActionType())); } /** * 写入一条收藏流水记录。 */ private void insertFavoriteRecord(Long courseId, Long userId, String actionType) { HealthCourseFavoriteRecord record = new HealthCourseFavoriteRecord(); record.setCourseId(courseId); record.setAppUserId(userId); record.setActionType(actionType); record.setActionTime(LocalDateTime.now()); healthCourseFavoriteRecordMapper.insert(record); } /** * 更新课程收藏计数,仅在状态发生切换时调用。 */ private void updateFavoriteCount(Long courseId, boolean increase) { LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper() .eq(HealthCourse::getId, courseId); if (increase) { updateWrapper.setSql("favorite_count = COALESCE(favorite_count, 0) + 1"); } else { updateWrapper.setSql("favorite_count = GREATEST(COALESCE(favorite_count, 0) - 1, 0)"); } healthCourseMapper.update(null, updateWrapper); } /** * 正在学习人数自增 1,进入课程详情时调用。 */ private void incrementStudyingCount(Long courseId) { LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper() .eq(HealthCourse::getId, courseId) .setSql("studying_count = COALESCE(studying_count, 0) + 1"); healthCourseMapper.update(null, updateWrapper); } /** * 构造收藏操作返回结果,避免控制器层重复拼装。 */ private AppHealthCourseFavoriteResultVO buildFavoriteResult(Long courseId, boolean favorited, Integer favoriteCount) { AppHealthCourseFavoriteResultVO resultVO = new AppHealthCourseFavoriteResultVO(); resultVO.setCourseId(courseId); resultVO.setFavorited(Boolean.valueOf(favorited)); resultVO.setFavoriteCount(Integer.valueOf(Math.max(defaultNumber(favoriteCount), 0))); return resultVO; } /** * 校验课程存在且为启用状态。 */ private HealthCourse requireEnabledCourse(Long courseId) { HealthCourse course = healthCourseMapper.selectById(courseId); if (course == null || course.getDeleted() == null || course.getDeleted().intValue() != 0 || course.getStatus() == null || course.getStatus().intValue() != COURSE_STATUS_ENABLED) { throw new IllegalArgumentException("养生课程不存在"); } return course; } /** * 兜底空价格字段,避免前端价格区展示异常。 */ private BigDecimal defaultPrice(BigDecimal price) { return price == null ? BigDecimal.ZERO : price; } /** * 兜底空数字字段。 */ private Integer defaultNumber(Integer value) { return value == null ? 0 : value; } /** * 将总时长秒数格式化为适合页头展示的简要文案。 */ private String formatCourseDuration(Integer durationSeconds) { int totalSeconds = defaultNumber(durationSeconds); if (totalSeconds <= 0) { return "0分钟"; } int hours = totalSeconds / 3600; int minutes = (totalSeconds % 3600) / 60; if (hours > 0 && minutes > 0) { return hours + "小时" + minutes + "分钟"; } if (hours > 0) { return hours + "小时"; } if (minutes > 0) { return minutes + "分钟"; } return totalSeconds + "秒"; } /** * 将章节时长格式化为 `HH:MM:SS`,便于与当前 APP 设计稿对齐。 */ private String formatVideoDuration(Integer durationSeconds) { int totalSeconds = defaultNumber(durationSeconds); int hours = totalSeconds / 3600; int minutes = (totalSeconds % 3600) / 60; int seconds = totalSeconds % 60; return String.format("%02d:%02d:%02d", hours, minutes, seconds); } /** * 将可空文本转换为空字符串。 */ private String trimToEmpty(String value) { if (value == null) { return ""; } String trimmed = value.trim(); return trimmed.isEmpty() ? "" : trimmed; } /** * 读取当前系统配置的客服电话,未配置时返回空字符串。 */ private String resolveServicePhone() { SystemSettingsVO systemSettings = systemSettingsAppService.current(); if (systemSettings == null) { return ""; } return trimToEmpty(systemSettings.getServicePhone()); } /** * APP 课程购买态快照。 */ private static final class PurchaseStateSnapshot { private final boolean member; private final boolean memberCourseJoined; private final boolean courseOwned; private final boolean coursePurchased; private final List purchasedVideoIds; private PurchaseStateSnapshot(boolean member, boolean memberCourseJoined, boolean courseOwned, boolean coursePurchased, List purchasedVideoIds) { this.member = member; this.memberCourseJoined = memberCourseJoined; this.courseOwned = courseOwned; this.coursePurchased = coursePurchased; this.purchasedVideoIds = purchasedVideoIds == null ? Collections.emptyList() : purchasedVideoIds; } private static PurchaseStateSnapshot empty() { return new PurchaseStateSnapshot(false, false, false, false, Collections.emptyList()); } private boolean isMember() { return member; } private boolean isMemberCourseJoined() { return memberCourseJoined; } private boolean isCourseOwned() { return courseOwned; } private boolean isCoursePurchased() { return coursePurchased; } private List getPurchasedVideoIds() { 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; } } }