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