From 16a57a8fc6868cea9171a1119698572407a2fc03 Mon Sep 17 00:00:00 2001
From: hejianhao <15708179461@qq.com>
Date: 星期五, 28 八月 2026 15:21:46 +0800
Subject: [PATCH] fix0806: APP端去掉全部的市辖区选择
---
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/service/AppHealthCourseService.java | 1404 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 1,401 insertions(+), 3 deletions(-)
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/service/AppHealthCourseService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/service/AppHealthCourseService.java
index 660aa9e..62cad09 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/service/AppHealthCourseService.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/service/AppHealthCourseService.java
@@ -1,21 +1,71 @@
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 养生课程详情聚合服务。
@@ -25,20 +75,87 @@
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,
- ExpertMapper expertMapper) {
+ 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<AppHealthCourseListItemVO> list(Long levelCategoryId, Long difficultyCategoryId) {
+ List<HealthCourseListRow> rows = healthCourseMapper.selectCourseList(
+ null,
+ levelCategoryId,
+ difficultyCategoryId,
+ COURSE_STATUS_ENABLED
+ );
+ if (rows == null || rows.isEmpty()) {
+ return Collections.emptyList();
+ }
+ List<AppHealthCourseListItemVO> result = new ArrayList<AppHealthCourseListItemVO>(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()));
+ result.add(itemVO);
+ }
+ return result;
}
/**
@@ -66,12 +183,743 @@
detailVO.setTotalDurationSeconds(resolveTotalDurationSeconds(courseDetail, enabledVideos));
detailVO.setTotalDurationText(formatCourseDuration(detailVO.getTotalDurationSeconds()));
detailVO.setExpert(resolveExpert(courseDetail.getExpert() == null ? null : courseDetail.getExpert().getId()));
+ detailVO.setServicePhone(resolveServicePhone());
- List<AppHealthCourseVideoVO> videoVOList = toVideoVOList(enabledVideos);
+ PurchaseStateSnapshot purchaseState = resolvePurchaseState(courseDetail.getId());
+ List<AppHealthCourseVideoVO> 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<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));
+ PurchaseStateSnapshot purchaseStateSnapshot = resolvePurchaseState(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());
+ 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<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);
+ }
+ }
+
+ /**
+ * 收藏 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<AppHealthCourseCategoryOptionVO> listThemes() {
+ return listCategoriesByType(LiveCourseCategoryAppService.TYPE_LEVEL);
+ }
+
+ /**
+ * 获取 APP 养生课程难度筛选项。
+ */
+ public List<AppHealthCourseCategoryOptionVO> listDifficulties() {
+ return listCategoriesByType(LiveCourseCategoryAppService.TYPE_DIFFICULTY);
+ }
+
+ /**
+ * 按分类类型读取 APP 养生课程筛选项。
+ */
+ private List<AppHealthCourseCategoryOptionVO> listCategoriesByType(String categoryType) {
+ List<LiveCourseCategory> categories = liveCourseCategoryMapper.selectList(new LambdaQueryWrapper<LiveCourseCategory>()
+ .eq(LiveCourseCategory::getType, categoryType)
+ .eq(LiveCourseCategory::getDeleted, 0)
+ .orderByAsc(LiveCourseCategory::getSort)
+ .orderByAsc(LiveCourseCategory::getId));
+ if (categories == null || categories.isEmpty()) {
+ return Collections.emptyList();
+ }
+ List<AppHealthCourseCategoryOptionVO> result =
+ new ArrayList<AppHealthCourseCategoryOptionVO>(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<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) {
+ if (!appAuthSessionService.isLogin()) {
+ return false;
+ }
+ Long userId = appAuthSessionService.getLoginUserId();
+ if (userId == null) {
+ 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);
}
/**
@@ -136,7 +984,7 @@
/**
* 将启用视频列表转换为 APP 详情页可直接渲染的数据结构。
*/
- private List<AppHealthCourseVideoVO> toVideoVOList(List<HealthCourseVideo> videos) {
+ private List<AppHealthCourseVideoVO> toVideoVOList(List<HealthCourseVideo> videos, PurchaseStateSnapshot purchaseState) {
if (videos == null || videos.isEmpty()) {
return Collections.emptyList();
}
@@ -150,6 +998,15 @@
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;
@@ -201,6 +1058,378 @@
*/
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<Long> resolvePurchasedVideoIds(Long userId, Long courseId, boolean courseOwned) {
+ if (courseOwned) {
+ List<HealthCourseVideo> enabledVideos = listEnabledVideos(courseId);
+ List<Long> allVideoIds = new ArrayList<Long>(enabledVideos.size());
+ for (HealthCourseVideo video : enabledVideos) {
+ allVideoIds.add(video.getId());
+ }
+ return allVideoIds;
+ }
+ List<CoursePurchaseOrder> orders = listActivePaidOrders(userId, courseId);
+ Set<Long> purchasedVideoIds = new LinkedHashSet<Long>();
+ for (CoursePurchaseOrder order : orders) {
+ if (PURCHASE_TARGET_CHAPTER.equals(order.getPurchaseTargetType()) && order.getCourseVideoId() != null) {
+ purchasedVideoIds.add(order.getCourseVideoId());
+ }
+ }
+ return new ArrayList<Long>(purchasedVideoIds);
+ }
+
+ /**
+ * 查询用户当前课程下所有有效的已支付订单。
+ */
+ private List<CoursePurchaseOrder> listActivePaidOrders(Long userId, Long courseId) {
+ List<CoursePurchaseOrder> orders = coursePurchaseOrderMapper.selectList(new LambdaQueryWrapper<CoursePurchaseOrder>()
+ .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.<CoursePurchaseOrder>emptyList() : orders;
+ }
+
+ /**
+ * 查询用户当前课程最近一条有效整课订单。
+ */
+ private CoursePurchaseOrder findLatestActivePaidCourseOrder(Long userId, Long courseId) {
+ return coursePurchaseOrderMapper.selectOne(new LambdaQueryWrapper<CoursePurchaseOrder>()
+ .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<CoursePurchaseOrder>()
+ .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<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;
+ }
+
+ /**
+ * 查询用户当前课程有效的会员加课记录。
+ */
+ private HealthCourseMemberJoinRecord findMemberJoinRecord(Long courseId, Long userId) {
+ return healthCourseMemberJoinRecordMapper.selectOne(new LambdaQueryWrapper<HealthCourseMemberJoinRecord>()
+ .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<Long> 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.<Long>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<HealthCourseFavoriteRecord>()
+ .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<HealthCourse> updateWrapper = new LambdaUpdateWrapper<HealthCourse>()
+ .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);
+ }
+
+ /**
+ * 构造收藏操作返回结果,避免控制器层重复拼装。
+ */
+ 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;
}
/**
@@ -260,4 +1489,173 @@
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<Long> purchasedVideoIds;
+
+ private PurchaseStateSnapshot(boolean member,
+ boolean memberCourseJoined,
+ boolean courseOwned,
+ boolean coursePurchased,
+ List<Long> purchasedVideoIds) {
+ this.member = member;
+ this.memberCourseJoined = memberCourseJoined;
+ this.courseOwned = courseOwned;
+ this.coursePurchased = coursePurchased;
+ this.purchasedVideoIds = purchasedVideoIds == null ? Collections.<Long>emptyList() : purchasedVideoIds;
+ }
+
+ private static PurchaseStateSnapshot empty() {
+ return new PurchaseStateSnapshot(false, false, false, false, Collections.<Long>emptyList());
+ }
+
+ private boolean isMember() {
+ return member;
+ }
+
+ private boolean isMemberCourseJoined() {
+ return memberCourseJoined;
+ }
+
+ private boolean isCourseOwned() {
+ return courseOwned;
+ }
+
+ private boolean isCoursePurchased() {
+ return coursePurchased;
+ }
+
+ private List<Long> 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;
+ }
+ }
}
--
Gitblit v1.7.1