13404089107
2026-04-24 e24c81afde61a58d297f4b378fae95fe3385984e
Merge branch 'master' of http://120.76.84.145:10101/gitblit/r/java/tcm-training
6个文件已添加
24个文件已修改
2335 ■■■■■ 已修改文件
doc/nginx-tcm-training.conf 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/live/service/AppLiveCourseService.java 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/live/support/AppLiveCourseDisplaySupport.java 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/controller/OpenAppShareController.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/service/AppShareService.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/vo/AppShareQrcodeVO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/LiveSessionQuizRecordMapper.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/LiveSessionStatusSyncService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/service/ActivityNoticeDispatchService.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/resources/application.yml 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/resources/mapper/LiveSessionQuizRecordMapper.xml 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/live/service/AppLiveCourseServiceTest.java 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/system/service/ActivityNoticeAppServiceTest.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/system/service/ActivityNoticeDispatchServiceTest.java 157 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/App.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages.json 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/courseDetail/courseDetail.vue 565 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/downloadPlayer/downloadPlayer.vue 351 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/downloads/downloads.vue 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/home/home.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/inviteUser/inviteUser.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/mine/mine.vue 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/myCourseDetail/myCourseDetail.vue 183 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/register/register.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/watchLive/watchLive.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/utils/app-user.js 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/utils/course-download.js 175 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/utils/download-task-storage.js 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/utils/utils.js 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/utils/video-security.js 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/nginx-tcm-training.conf
@@ -39,6 +39,17 @@
        proxy_set_header X-Forwarded-Proto $scheme;
    }
    # 本地上传文件与 APP 安装包统一通过 Spring Boot 的 /uploads/** 静态映射对外提供访问。
    location /uploads/ {
        proxy_pass http://127.0.0.1:48080;
        proxy_read_timeout 60s;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
    # 邀请二维码落地页入口,补全尾部斜杠时保留邀请码查询参数。
    location = /app-download {
        return 301 /app-download/$is_args$args;
@@ -62,5 +73,7 @@
# 3. 线上部署目录建议:/usr/local/project/tcm-training/admin/
# 4. 线上访问地址:https://haizhentong.com/admin/
# 5. 后端接口地址:https://haizhentong.com/api/
# 6. 邀请二维码落地页地址:https://haizhentong.com/app-download/?inviteCode=XXXXXX
# 7. 管理后台生产环境接口基地址需要配置为 /api,避免页面继续请求本地开发地址。
# 6. 本地上传文件与 APP 安装包访问地址:https://haizhentong.com/uploads/...
# 7. 邀请二维码落地页地址:https://haizhentong.com/app-download/?inviteCode=XXXXXX
# 8. 管理后台生产环境接口基地址需要配置为 /api,避免页面继续请求本地开发地址。
# 9. 若 /uploads/... 线上返回 404,先确认本段 location 已合并并 reload,再检查文件是否写入 /data/tcm-training-admin/uploads/。
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/live/service/AppLiveCourseService.java
@@ -34,6 +34,7 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
@@ -138,7 +139,7 @@
        detailVO.setDisplayStatusText(resolveDisplayStatusText(snapshot));
        detailVO.setCountdownTime(resolveCountdownTime(snapshot));
        detailVO.setActionSessionId(actionSession == null ? null : actionSession.getId());
        detailVO.setVideoUrl(actionSession == null ? null : appLiveCourseDisplaySupport.trimToNull(actionSession.getVideoUrl()));
        detailVO.setVideoUrl(resolvePlayableVideoUrl(snapshot, actionSession));
        detailVO.setPlaybackStartTime(playbackStartTime);
        detailVO.setDurationSeconds(actionSession == null ? null : actionSession.getDurationSeconds());
        detailVO.setReserveCount(resolveReserveCount(courseId, sessions));
@@ -206,6 +207,7 @@
        Map<Long, String> normalizedAnswers = validateAndNormalizeAnswers(request.getAnswers(), questions);
        WatchProgressSummary watchSummary = resolveWatchProgress(sessionId, userId, session);
        LocalDateTime answeredAt = LocalDateTime.now();
        boolean allCorrect = true;
        List<LiveSessionQuizRecord> insertedRecords = new ArrayList<LiveSessionQuizRecord>();
        for (LiveSessionQuestion question : questions) {
@@ -222,15 +224,17 @@
            record.setChosenOption(chosenOption);
            record.setIsCorrect(correct ? 1 : 0);
            record.setPointsEarned(0);
            record.setAnsweredAt(LocalDateTime.now());
            record.setAnsweredAt(answeredAt);
            liveSessionQuizRecordMapper.insert(record);
            insertedRecords.add(record);
        }
        boolean pointsAwarded = false;
        boolean dailyCoursePointsAlreadyAwarded = false;
        // 当前业务改为只要三题全部答对就立即发放积分,不再依赖完整观看直播。
        if (allCorrect) {
            pointsAwarded = awardQuizPoints(course, session, userId, insertedRecords, questions);
            dailyCoursePointsAlreadyAwarded = hasAwardedCourseQuizPointsToday(course.getId(), userId, answeredAt);
            pointsAwarded = awardQuizPoints(course, session, userId, insertedRecords, questions, answeredAt);
        }
        AppLiveQuizSubmitResultVO resultVO = new AppLiveQuizSubmitResultVO();
@@ -239,7 +243,7 @@
        resultVO.setWatchCompleted(Boolean.valueOf(watchSummary.isWatchCompleted()));
        resultVO.setPointsAwarded(Boolean.valueOf(pointsAwarded));
        resultVO.setPointsReward(Integer.valueOf(defaultNumber(course.getQuizPoints())));
        resultVO.setMessage(resolveQuizSubmitMessage(allCorrect, pointsAwarded));
        resultVO.setMessage(resolveQuizSubmitMessage(allCorrect, pointsAwarded, dailyCoursePointsAlreadyAwarded));
        return resultVO;
    }
@@ -646,9 +650,13 @@
                                    LiveSession session,
                                    Long userId,
                                    List<LiveSessionQuizRecord> quizRecords,
                                    List<LiveSessionQuestion> questions) {
                                    List<LiveSessionQuestion> questions,
                                    LocalDateTime answeredAt) {
        int pointsReward = defaultNumber(course.getQuizPoints());
        if (pointsReward <= 0 || hasAwardedQuizPoints(quizRecords)) {
            return false;
        }
        if (hasAwardedCourseQuizPointsToday(course.getId(), userId, answeredAt)) {
            return false;
        }
        LiveSessionQuizRecord rewardRecord = resolveRewardRecord(quizRecords, questions);
@@ -662,6 +670,23 @@
        // 积分到账后立即同步流水,避免 APP 积分页与直播观看页状态延迟。
        pointsLedgerAppService.syncLedger();
        return true;
    }
    /**
     * 判断用户在同一直播课的答题自然日内是否已获得过积分奖励。
     */
    private boolean hasAwardedCourseQuizPointsToday(Long courseId, Long userId, LocalDateTime answeredAt) {
        if (courseId == null || userId == null) {
            return false;
        }
        LocalDate answerDate = (answeredAt == null ? LocalDateTime.now() : answeredAt).toLocalDate();
        Integer awardedCount = liveSessionQuizRecordMapper.countAwardedCourseQuizPoints(
                courseId,
                userId,
                answerDate.atStartOfDay(),
                answerDate.plusDays(1).atStartOfDay()
        );
        return awardedCount != null && awardedCount.intValue() > 0;
    }
    /**
@@ -722,12 +747,17 @@
    /**
     * 构造答题提交后的提示文案。
     */
    private String resolveQuizSubmitMessage(boolean allCorrect, boolean pointsAwarded) {
    private String resolveQuizSubmitMessage(boolean allCorrect,
                                            boolean pointsAwarded,
                                            boolean dailyCoursePointsAlreadyAwarded) {
        if (!allCorrect) {
            return "回答错误,本次不获得积分";
        }
        if (pointsAwarded) {
            return "答题成功,积分已到账";
        }
        if (dailyCoursePointsAlreadyAwarded) {
            return "今日该课程积分已获得";
        }
        return "答题成功";
    }
@@ -819,6 +849,17 @@
    }
    /**
     * 解析观看页可播放视频地址,直播结束冷却期只保留答题入口不继续播放视频。
     */
    private String resolvePlayableVideoUrl(AppLiveCourseDisplaySupport.CourseDisplaySnapshot snapshot,
                                           LiveSession actionSession) {
        if (snapshot.isEndedCooldown() || actionSession == null) {
            return null;
        }
        return appLiveCourseDisplaySupport.trimToNull(actionSession.getVideoUrl());
    }
    /**
     * 解析模拟直播回放进度的起播时间,优先使用实际开播时间。
     */
    private LocalDateTime resolvePlaybackStartTime(LiveSession actionSession) {
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/live/support/AppLiveCourseDisplaySupport.java
@@ -36,14 +36,14 @@
        CourseDisplaySnapshot snapshot = new CourseDisplaySnapshot();
        LiveSession latestStartedSession = findLatestStartedSession(safeSessions, now);
        snapshot.setLatestStartedSession(latestStartedSession);
        LiveSession currentSession = resolveCurrentSession(latestStartedSession);
        LiveSession currentSession = resolveCurrentSession(latestStartedSession, now);
        snapshot.setCurrentSession(currentSession);
        if (currentSession != null) {
            snapshot.setDisplayStatus(DISPLAY_STATUS_LIVING);
            return snapshot;
        }
        LiveSession latestEndedSession = resolveLatestEndedSession(safeSessions, latestStartedSession);
        LiveSession latestEndedSession = resolveLatestEndedSession(safeSessions, latestStartedSession, now);
        snapshot.setLatestEndedSession(latestEndedSession);
        snapshot.setLatestEndedAt(resolveEndedAt(latestEndedSession));
        if (isInEndedCooldown(latestEndedSession, now, endedCooldownMinutes)) {
@@ -115,7 +115,7 @@
    }
    /**
     * 获取直播结束时间,优先使用实际结束时间。
     * 获取直播结束时间,优先使用实际结束时间,其次按计划开播时间和配置时长计算。
     */
    public LocalDateTime resolveEndedAt(LiveSession session) {
        if (session == null) {
@@ -123,6 +123,9 @@
        }
        if (session.getActualEndTime() != null) {
            return session.getActualEndTime();
        }
        if (session.getStartTime() != null && defaultNumber(session.getDurationSeconds()) > 0) {
            return session.getStartTime().plusSeconds(defaultNumber(session.getDurationSeconds()));
        }
        return session.getStartTime();
    }
@@ -206,26 +209,44 @@
    /**
     * 将时间线上最近一场已开始场次解析为当前直播场次。
     */
    private LiveSession resolveCurrentSession(LiveSession latestStartedSession) {
    private LiveSession resolveCurrentSession(LiveSession latestStartedSession, LocalDateTime now) {
        if (latestStartedSession == null) {
            return null;
        }
        if (defaultNumber(latestStartedSession.getLiveStatus()) != SESSION_STATUS_LIVING) {
            return null;
        if (defaultNumber(latestStartedSession.getLiveStatus()) == SESSION_STATUS_LIVING) {
            if (defaultNumber(latestStartedSession.getDurationSeconds()) > 0) {
                LocalDateTime endedAt = resolveEndedAt(latestStartedSession);
                if (endedAt != null && !endedAt.isAfter(now)) {
                    return null;
                }
            }
            return latestStartedSession;
        }
        return latestStartedSession;
        if (defaultNumber(latestStartedSession.getLiveStatus()) == SESSION_STATUS_NOT_STARTED) {
            if (defaultNumber(latestStartedSession.getDurationSeconds()) <= 0) {
                return latestStartedSession;
            }
            LocalDateTime endedAt = resolveEndedAt(latestStartedSession);
            // APP 展示态按计划时间实时计算,避免低频定时任务导致到点后仍显示未开始。
            if (endedAt == null || endedAt.isAfter(now)) {
                return latestStartedSession;
            }
        }
        return null;
    }
    /**
     * 解析最近一场已结束场次,优先使用时间线上的最新已开始场次。
     */
    private LiveSession resolveLatestEndedSession(List<LiveSession> sessions, LiveSession latestStartedSession) {
        if (latestStartedSession != null && defaultNumber(latestStartedSession.getLiveStatus()) == SESSION_STATUS_ENDED) {
    private LiveSession resolveLatestEndedSession(List<LiveSession> sessions,
                                                  LiveSession latestStartedSession,
                                                  LocalDateTime now) {
        if (isEndedForDisplay(latestStartedSession, now)) {
            return latestStartedSession;
        }
        LiveSession result = null;
        for (LiveSession session : sessions) {
            if (defaultNumber(session.getLiveStatus()) != SESSION_STATUS_ENDED) {
            if (!isEndedForDisplay(session, now)) {
                continue;
            }
            if (result == null || compareEndedSession(result, session) < 0) {
@@ -236,6 +257,27 @@
    }
    /**
     * 判断场次在 APP 当前展示口径下是否已经结束。
     */
    private boolean isEndedForDisplay(LiveSession session, LocalDateTime now) {
        if (session == null) {
            return false;
        }
        if (defaultNumber(session.getLiveStatus()) == SESSION_STATUS_ENDED) {
            return true;
        }
        int status = defaultNumber(session.getLiveStatus());
        if (status != SESSION_STATUS_NOT_STARTED && status != SESSION_STATUS_LIVING) {
            return false;
        }
        if (defaultNumber(session.getDurationSeconds()) <= 0) {
            return false;
        }
        LocalDateTime endedAt = resolveEndedAt(session);
        return session.getStartTime() != null && endedAt != null && !endedAt.isAfter(now);
    }
    /**
     * 选择下一场待开播的场次。
     */
    private LiveSession findNextSession(List<LiveSession> sessions, LocalDateTime now) {
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/controller/OpenAppShareController.java
New file
@@ -0,0 +1,45 @@
package com.tcm.training.admin.app.share.controller;
import com.tcm.training.admin.app.share.service.AppShareService;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.TimeUnit;
/**
 * APP 分享二维码公开图片接口。
 */
@Validated
@RestController
@RequestMapping({"/api/open/app-share", "/api/v1/open/app-share"})
public class OpenAppShareController {
    private final AppShareService appShareService;
    /**
     * 注入邀请分享服务。
     */
    public OpenAppShareController(AppShareService appShareService) {
        this.appShareService = appShareService;
    }
    /**
     * 根据邀请码返回二维码 PNG 图片。
     */
    @GetMapping(value = "/qrcode-image", produces = MediaType.IMAGE_PNG_VALUE)
    public ResponseEntity<byte[]> getQrcodeImage(@RequestParam(value = "inviteCode", required = false) String inviteCode) {
        byte[] imageBytes = appShareService.generateQrcodeImage(inviteCode);
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"app-share-qrcode.png\"")
                .cacheControl(CacheControl.maxAge(10, TimeUnit.MINUTES).cachePublic())
                .contentType(MediaType.IMAGE_PNG)
                .body(imageBytes);
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/service/AppShareService.java
@@ -17,6 +17,7 @@
import com.tcm.training.admin.system.service.AppVersionSettingsAppService;
import com.tcm.training.admin.system.vo.AppVersionCheckVO;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
@@ -69,6 +70,7 @@
        result.setClinicName(trimToEmpty(clinic.getName()));
        result.setInviteCode(inviteCode);
        result.setShareLandingUrl(shareLandingUrl);
        result.setQrCodeImageUrl(buildQrCodeImageUrl(inviteCode));
        result.setQrCodeDataUrl(buildQrCodeDataUrl(shareLandingUrl));
        result.setBoundUsers(listBoundUsers(doctorUser));
        return result;
@@ -200,6 +202,46 @@
    }
    /**
     * 构建二维码图片公开访问地址。
     */
    private String buildQrCodeImageUrl(String inviteCode) {
        String landingBaseUrl = trimToNull(appShareProperties.getLandingBaseUrl());
        if (landingBaseUrl == null) {
            throw new IllegalArgumentException("邀请下载页地址暂未配置");
        }
        int schemeIndex = landingBaseUrl.indexOf("://");
        if (schemeIndex < 0) {
            throw new IllegalArgumentException("邀请下载页地址格式不正确");
        }
        int hostEndIndex = landingBaseUrl.indexOf('/', schemeIndex + 3);
        String origin = hostEndIndex >= 0 ? landingBaseUrl.substring(0, hostEndIndex) : landingBaseUrl;
        String normalizedInviteCode = trimToNull(inviteCode);
        if (!StringUtils.hasText(normalizedInviteCode)) {
            throw new IllegalArgumentException("邀请码不能为空");
        }
        return origin + "/api/open/app-share/qrcode-image?inviteCode=" + normalizedInviteCode;
    }
    /**
     * 生成二维码 PNG 图片字节流。
     */
    public byte[] generateQrcodeImage(String inviteCode) {
        Clinic clinic = findClinicByInviteCode(inviteCode);
        if (clinic == null) {
            throw new IllegalArgumentException("邀请码无效");
        }
        String shareLandingUrl = buildShareLandingUrl(requireInviteCode(clinic));
        try {
            BufferedImage image = QrCodeUtil.generate(shareLandingUrl, 400, 400);
            ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
            ImgUtil.write(image, "png", outputStream);
            return outputStream.toByteArray();
        } catch (Exception ex) {
            throw new IllegalArgumentException("生成分享二维码失败");
        }
    }
    /**
     * 构建绑定用户展示项。
     */
    private AppShareBoundUserItemVO toBoundUserItem(AppUser user) {
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/vo/AppShareQrcodeVO.java
@@ -18,6 +18,8 @@
    private String shareLandingUrl;
    private String qrCodeImageUrl;
    private String qrCodeDataUrl;
    private List<AppShareBoundUserItemVO> boundUsers;
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/LiveSessionQuizRecordMapper.java
@@ -7,6 +7,8 @@
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
@Mapper
public interface LiveSessionQuizRecordMapper extends BaseMapper<LiveSessionQuizRecord> {
@@ -15,4 +17,10 @@
                                      @Param("sessionId") Long sessionId,
                                      @Param("keyword") String keyword,
                                      @Param("isCorrect") Integer isCorrect);
    /** 查询用户在指定直播课答题日内是否已获得过积分。 */
    Integer countAwardedCourseQuizPoints(@Param("courseId") Long courseId,
                                         @Param("userId") Long userId,
                                         @Param("dayStart") LocalDateTime dayStart,
                                         @Param("dayEnd") LocalDateTime dayEnd);
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/LiveSessionStatusSyncService.java
@@ -34,7 +34,7 @@
    /**
     * 扫描已到开播时间的未开始场次,并自动切换为直播中。
     */
    @Scheduled(fixedDelayString = "${app.live.session-status-sync-interval-ms:60000}")
    @Scheduled(fixedDelayString = "${app.live.session-status-sync-interval-ms:5000}")
    public void syncDueSessions() {
        LocalDateTime now = LocalDateTime.now();
        List<LiveSession> dueSessions = liveSessionMapper.selectList(new LambdaQueryWrapper<LiveSession>()
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/service/ActivityNoticeDispatchService.java
@@ -13,6 +13,8 @@
import com.tcm.training.admin.system.event.ActivityNoticeCreatedEvent;
import com.tcm.training.admin.system.entity.ActivityNotice;
import com.tcm.training.admin.system.mapper.ActivityNoticeMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.event.TransactionalEventListener;
@@ -26,6 +28,8 @@
 */
@Service
public class ActivityNoticeDispatchService {
    private static final Logger log = LoggerFactory.getLogger(ActivityNoticeDispatchService.class);
    private static final int USER_STATUS_ENABLED = 1;
    private static final int USER_DELETED_NO = 0;
@@ -81,23 +85,7 @@
                break;
            }
            for (AppUser user : users) {
                if (user.getId() == null) {
                    continue;
                }
                boolean exists = appUserActivityNoticeMapper.selectCount(new LambdaQueryWrapper<AppUserActivityNotice>()
                        .eq(AppUserActivityNotice::getAppUserId, user.getId())
                        .eq(AppUserActivityNotice::getActivityNoticeId, event.getActivityNoticeId())
                        .eq(AppUserActivityNotice::getDeleted, RECORD_DELETED_NO)) > 0;
                if (exists) {
                    continue;
                }
                AppUserActivityNotice record = new AppUserActivityNotice();
                record.setAppUserId(user.getId());
                record.setActivityNoticeId(event.getActivityNoticeId());
                record.setDeleted(RECORD_DELETED_NO);
                appUserActivityNoticeMapper.insert(record);
                // 活动通知发布后同时写入统一系统通知表,并按双开关规则尝试极光推送。
                appSystemNoticeDispatchService.createAndPush(buildActivityNoticeCommand(user.getId(), activityNotice));
                distributeToUser(user, activityNotice);
            }
            if (page.getCurrent() >= page.getPages()) {
                break;
@@ -107,6 +95,33 @@
    }
    /**
     * 为单个 APP 用户分发活动通知,单用户失败不影响整批分发。
     */
    private void distributeToUser(AppUser user, ActivityNotice activityNotice) {
        if (user == null || user.getId() == null || activityNotice == null || activityNotice.getId() == null) {
            return;
        }
        try {
            boolean exists = appUserActivityNoticeMapper.selectCount(new LambdaQueryWrapper<AppUserActivityNotice>()
                    .eq(AppUserActivityNotice::getAppUserId, user.getId())
                    .eq(AppUserActivityNotice::getActivityNoticeId, activityNotice.getId())
                    .eq(AppUserActivityNotice::getDeleted, RECORD_DELETED_NO)) > 0;
            if (!exists) {
                AppUserActivityNotice record = new AppUserActivityNotice();
                record.setAppUserId(user.getId());
                record.setActivityNoticeId(activityNotice.getId());
                record.setDeleted(RECORD_DELETED_NO);
                appUserActivityNoticeMapper.insert(record);
            }
            // 活动通知发布后同时写入统一系统通知表,并按双开关规则尝试极光推送。
            appSystemNoticeDispatchService.createAndPush(buildActivityNoticeCommand(user.getId(), activityNotice));
        } catch (Exception ex) {
            log.error("activity notice distribute failed, activityNoticeId={}, appUserId={}",
                    activityNotice.getId(), user.getId(), ex);
        }
    }
    /**
     * 构建活动通知统一发放命令。
     */
    private AppSystemNoticeDispatchCommand buildActivityNoticeCommand(Long appUserId, ActivityNotice activityNotice) {
tcm-training-admin-backend/src/main/resources/application.yml
@@ -106,9 +106,9 @@
        code: ""
    wechat:
      # 微信开放平台 AppId,当前阶段可先留空,补齐后启用服务端换票模式
      app-id: ""
      app-id: "wx074c1f0cf5a0ecf4"
      # 微信开放平台 AppSecret,当前阶段可先留空,补齐后启用服务端换票模式
      app-secret: ""
      app-secret: "7a294b47a3a2f4b69cba764b0da17dbc"
      # 微信用户信息接口地址
      user-info-url: https://api.weixin.qq.com/sns/userinfo
      # 微信授权换票接口地址
@@ -117,8 +117,8 @@
    # 邀请二维码默认指向线上下载页,开发环境可在 application-local.yml 覆盖。
    landing-base-url: https://haizhentong.com/app-download/
jpush:
  appKey: 111
  masterSecret: 111
  appKey: d2c5362f73af203f25704b8d
  masterSecret: c6996db56be1a93ae16fc1b0
logging:
  level:
tcm-training-admin-backend/src/main/resources/mapper/LiveSessionQuizRecordMapper.xml
@@ -31,4 +31,16 @@
        ORDER BY r.id DESC
    </select>
    <!-- 查询同一用户在同一直播课同一答题自然日是否已获得过积分 -->
    <select id="countAwardedCourseQuizPoints" resultType="java.lang.Integer">
        SELECT COUNT(1)
        FROM tcm_live_session_quiz_record r
        INNER JOIN tcm_live_session s ON s.id = r.session_id AND s.deleted = 0
        WHERE s.live_course_id = #{courseId}
          AND r.app_user_id = #{userId}
          AND r.points_earned > 0
          AND r.answered_at <![CDATA[>=]]> #{dayStart}
          AND r.answered_at <![CDATA[<]]> #{dayEnd}
    </select>
</mapper>
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/live/service/AppLiveCourseServiceTest.java
@@ -3,13 +3,18 @@
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.tcm.training.admin.app.auth.service.AppAuthSessionService;
import com.tcm.training.admin.app.live.dto.AppLiveQuizAnswerRequest;
import com.tcm.training.admin.app.live.dto.AppLiveQuizSubmitRequest;
import com.tcm.training.admin.app.live.support.AppLiveCourseDisplaySupport;
import com.tcm.training.admin.app.live.vo.AppLiveCourseDetailVO;
import com.tcm.training.admin.app.live.vo.AppLiveQuizSubmitResultVO;
import com.tcm.training.admin.app.live.vo.AppLiveReserveResultVO;
import com.tcm.training.admin.live.entity.Expert;
import com.tcm.training.admin.live.entity.LiveCourse;
import com.tcm.training.admin.live.entity.LiveReserve;
import com.tcm.training.admin.live.entity.LiveSession;
import com.tcm.training.admin.live.entity.LiveSessionQuestion;
import com.tcm.training.admin.live.entity.LiveSessionQuizRecord;
import com.tcm.training.admin.live.mapper.ExpertMapper;
import com.tcm.training.admin.live.mapper.LiveCourseMapper;
import com.tcm.training.admin.live.mapper.LiveReserveMapper;
@@ -32,12 +37,15 @@
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -175,7 +183,7 @@
        assertEquals("立即查看", detailVO.getActionText());
        assertEquals(Long.valueOf(201L), detailVO.getActionSessionId());
        assertNull(detailVO.getCountdownTime());
        assertEquals("https://cdn.example/live.m3u8", detailVO.getVideoUrl());
        assertNull(detailVO.getVideoUrl());
    }
    /**
@@ -203,7 +211,39 @@
        assertEquals("ENDED", detailVO.getDisplayStatus());
        assertEquals("WATCHABLE", detailVO.getActionStatus());
        assertEquals(Long.valueOf(211L), detailVO.getActionSessionId());
        assertEquals("https://cdn.example/latest-ended.m3u8", detailVO.getVideoUrl());
        assertNull(detailVO.getVideoUrl());
    }
    /**
     * 同一直播课同一答题自然日已经获得过积分时,后续场次答对也不重复发放。
     */
    @Test
    void submitQuizShouldNotAwardPointsTwiceForSameCourseSameDay() {
        LiveCourse course = buildCourse(50L, 12L);
        LiveSession session = buildSession(501L, 50L, LocalDateTime.now().minusMinutes(30),
                AppLiveCourseDisplaySupport.SESSION_STATUS_LIVING);
        List<LiveSessionQuestion> questions = Arrays.asList(
                buildQuestion(1L, 501L, 1, "A"),
                buildQuestion(2L, 501L, 2, "B"),
                buildQuestion(3L, 501L, 3, "C")
        );
        when(appAuthSessionService.requireLoginUserId()).thenReturn(77L);
        when(liveCourseMapper.selectOne(any())).thenReturn(course);
        when(liveSessionMapper.selectOne(any())).thenReturn(session);
        when(liveSessionQuestionMapper.selectList(any())).thenReturn(questions);
        when(liveSessionQuizRecordMapper.selectList(any())).thenReturn(Collections.emptyList());
        when(liveSessionQuizRecordMapper.countAwardedCourseQuizPoints(anyLong(), anyLong(), any(), any())).thenReturn(1);
        AppLiveQuizSubmitResultVO resultVO = appLiveCourseService.submitQuiz(50L, 501L, buildQuizSubmitRequest());
        verify(liveSessionQuizRecordMapper, never()).updateById(any(LiveSessionQuizRecord.class));
        verify(userPointsAccountMapper, never()).update(any(), any());
        verify(pointsLedgerAppService, never()).syncLedger();
        assertEquals(Boolean.TRUE, resultVO.getSubmitted());
        assertEquals(Boolean.TRUE, resultVO.getAllCorrect());
        assertEquals(Boolean.FALSE, resultVO.getPointsAwarded());
        assertEquals("今日该课程积分已获得", resultVO.getMessage());
    }
    /**
@@ -292,6 +332,45 @@
    }
    /**
     * 构造直播答题题目测试数据。
     */
    private LiveSessionQuestion buildQuestion(Long questionId, Long sessionId, Integer sort, String correctOption) {
        LiveSessionQuestion question = new LiveSessionQuestion();
        question.setId(questionId);
        question.setSessionId(sessionId);
        question.setSort(sort);
        question.setStem("题目" + sort);
        question.setOptionA("选项A");
        question.setOptionB("选项B");
        question.setOptionC("选项C");
        question.setCorrectOption(correctOption);
        return question;
    }
    /**
     * 构造三题全对的答题提交请求。
     */
    private AppLiveQuizSubmitRequest buildQuizSubmitRequest() {
        AppLiveQuizSubmitRequest request = new AppLiveQuizSubmitRequest();
        request.setAnswers(Arrays.asList(
                buildQuizAnswer(1L, "A"),
                buildQuizAnswer(2L, "B"),
                buildQuizAnswer(3L, "C")
        ));
        return request;
    }
    /**
     * 构造单题答题请求。
     */
    private AppLiveQuizAnswerRequest buildQuizAnswer(Long questionId, String chosenOption) {
        AppLiveQuizAnswerRequest answer = new AppLiveQuizAnswerRequest();
        answer.setQuestionId(questionId);
        answer.setChosenOption(chosenOption);
        return answer;
    }
    /**
     * 构造启用中的专家测试数据。
     */
    private Expert buildExpert(Long expertId) {
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/system/service/ActivityNoticeAppServiceTest.java
@@ -4,9 +4,11 @@
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.tcm.training.admin.system.dto.ActivityNoticeCreateRequest;
import com.tcm.training.admin.system.entity.ActivityNotice;
import com.tcm.training.admin.system.event.ActivityNoticeCreatedEvent;
import com.tcm.training.admin.system.mapper.ActivityNoticeMapper;
import com.tcm.training.admin.system.vo.ActivityNoticeListItemVO;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.springframework.context.ApplicationEventPublisher;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -37,11 +39,20 @@
    @Mock
    private ActivityNoticeMapper activityNoticeMapper;
    @Mock
    private ActivityNoticeDispatchService activityNoticeDispatchService;
    @Mock
    private ApplicationEventPublisher applicationEventPublisher;
    @InjectMocks
    private ActivityNoticeAppService activityNoticeAppService;
    @Captor
    private ArgumentCaptor<ActivityNotice> activityNoticeCaptor;
    @Captor
    private ArgumentCaptor<ActivityNoticeCreatedEvent> eventCaptor;
    @Test
    void createShouldTrimTitleAndPersistContent() {
@@ -65,8 +76,10 @@
        ActivityNoticeListItemVO result = activityNoticeAppService.create(request);
        verify(activityNoticeMapper).insert(activityNoticeCaptor.capture());
        verify(applicationEventPublisher).publishEvent(eventCaptor.capture());
        assertEquals("春季活动", activityNoticeCaptor.getValue().getTitle());
        assertEquals("<p>活动内容</p>", activityNoticeCaptor.getValue().getContentHtml());
        assertEquals(Long.valueOf(1L), eventCaptor.getValue().getActivityNoticeId());
        assertEquals("春季活动", result.getTitle());
    }
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/system/service/ActivityNoticeDispatchServiceTest.java
New file
@@ -0,0 +1,157 @@
package com.tcm.training.admin.system.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.tcm.training.admin.app.notice.entity.AppUserActivityNotice;
import com.tcm.training.admin.app.notice.mapper.AppUserActivityNoticeMapper;
import com.tcm.training.admin.app.notice.service.AppSystemNoticeDispatchCommand;
import com.tcm.training.admin.app.notice.service.AppSystemNoticeDispatchService;
import com.tcm.training.admin.app.notice.service.AppSystemNoticeType;
import com.tcm.training.admin.member.entity.AppUser;
import com.tcm.training.admin.member.mapper.UserMapper;
import com.tcm.training.admin.system.entity.ActivityNotice;
import com.tcm.training.admin.system.event.ActivityNoticeCreatedEvent;
import com.tcm.training.admin.system.mapper.ActivityNoticeMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Arrays;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
 * 活动通知分发服务测试。
 */
@ExtendWith(MockitoExtension.class)
class ActivityNoticeDispatchServiceTest {
    @BeforeAll
    static void initLambdaCache() {
        MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
        TableInfoHelper.initTableInfo(assistant, ActivityNotice.class);
        TableInfoHelper.initTableInfo(assistant, AppUser.class);
        TableInfoHelper.initTableInfo(assistant, AppUserActivityNotice.class);
    }
    @Mock
    private UserMapper userMapper;
    @Mock
    private AppUserActivityNoticeMapper appUserActivityNoticeMapper;
    @Mock
    private ActivityNoticeMapper activityNoticeMapper;
    @Mock
    private AppSystemNoticeDispatchService appSystemNoticeDispatchService;
    @Captor
    private ArgumentCaptor<AppUserActivityNotice> activityRecordCaptor;
    @Captor
    private ArgumentCaptor<AppSystemNoticeDispatchCommand> commandCaptor;
    /**
     * 活动通知创建后应为启用用户写入 APP 活动记录和统一系统通知。
     */
    @Test
    void handleActivityNoticeCreatedShouldDispatchSystemNoticeToEnabledUsers() {
        ActivityNoticeDispatchService service = buildService();
        ActivityNotice notice = buildNotice(10L);
        AppUser user = buildUser(100L);
        when(activityNoticeMapper.selectOne(any())).thenReturn(notice);
        when(userMapper.selectPage(any(), any())).thenReturn(buildUserPage(user));
        when(appUserActivityNoticeMapper.selectCount(any())).thenReturn(0L);
        service.handleActivityNoticeCreated(new ActivityNoticeCreatedEvent(10L));
        verify(appUserActivityNoticeMapper).insert(activityRecordCaptor.capture());
        assertEquals(Long.valueOf(100L), activityRecordCaptor.getValue().getAppUserId());
        assertEquals(Long.valueOf(10L), activityRecordCaptor.getValue().getActivityNoticeId());
        verify(appSystemNoticeDispatchService).createAndPush(commandCaptor.capture());
        assertEquals(Long.valueOf(100L), commandCaptor.getValue().getAppUserId());
        assertEquals(AppSystemNoticeType.ACTIVITY_NOTICE, commandCaptor.getValue().getNoticeTypeCode());
        assertEquals("ACTIVITY_NOTICE", commandCaptor.getValue().getReferenceType());
        assertEquals(Long.valueOf(10L), commandCaptor.getValue().getReferenceId());
    }
    /**
     * 单个用户分发失败时不应中断后续用户分发。
     */
    @Test
    void handleActivityNoticeCreatedShouldContinueWhenSingleUserDispatchFails() {
        ActivityNoticeDispatchService service = buildService();
        ActivityNotice notice = buildNotice(20L);
        AppUser firstUser = buildUser(201L);
        AppUser secondUser = buildUser(202L);
        when(activityNoticeMapper.selectOne(any())).thenReturn(notice);
        when(userMapper.selectPage(any(), any())).thenReturn(buildUserPage(firstUser, secondUser));
        when(appUserActivityNoticeMapper.selectCount(any())).thenReturn(0L);
        doThrow(new IllegalStateException("推送异常"))
                .doReturn(null)
                .when(appSystemNoticeDispatchService).createAndPush(any(AppSystemNoticeDispatchCommand.class));
        service.handleActivityNoticeCreated(new ActivityNoticeCreatedEvent(20L));
        verify(appSystemNoticeDispatchService, org.mockito.Mockito.times(2)).createAndPush(commandCaptor.capture());
        assertEquals(Long.valueOf(201L), commandCaptor.getAllValues().get(0).getAppUserId());
        assertEquals(Long.valueOf(202L), commandCaptor.getAllValues().get(1).getAppUserId());
    }
    /**
     * 构造被测服务。
     */
    private ActivityNoticeDispatchService buildService() {
        return new ActivityNoticeDispatchService(
                userMapper,
                appUserActivityNoticeMapper,
                activityNoticeMapper,
                appSystemNoticeDispatchService
        );
    }
    /**
     * 构造活动通知主表记录。
     */
    private ActivityNotice buildNotice(Long id) {
        ActivityNotice notice = new ActivityNotice();
        notice.setId(id);
        notice.setTitle("活动通知");
        notice.setContentHtml("<p>活动内容</p>");
        notice.setDeleted(0);
        return notice;
    }
    /**
     * 构造启用 APP 用户。
     */
    private AppUser buildUser(Long id) {
        AppUser user = new AppUser();
        user.setId(id);
        user.setStatus(1);
        user.setDeleted(0);
        return user;
    }
    /**
     * 构造用户分页结果。
     */
    private Page<AppUser> buildUserPage(AppUser... users) {
        Page<AppUser> page = new Page<AppUser>(1, 500);
        page.setRecords(users == null ? Collections.<AppUser>emptyList() : Arrays.asList(users));
        page.setTotal(users == null ? 0 : users.length);
        return page;
    }
}
traditionalApp/App.vue
@@ -2,7 +2,7 @@
    import {
        onLaunch
    } from '@dcloudio/uni-app'
    import { cacheAppInviteCode, normalizeOptionalText } from '@/utils/utils.js'
    import { cacheAppInviteCode, extractInviteCode } from '@/utils/utils.js'
    /**
     * 应用启动时尝试读取系统剪贴板中的邀请码。
@@ -29,14 +29,6 @@
        })
    }
    /**
     * 从任意文本中提取 6 位邀请码。
     */
    function extractInviteCode(value) {
        const normalizedValue = normalizeOptionalText(value).toUpperCase()
        const matchedInviteCode = normalizedValue.match(/[A-Z0-9]{6}/)
        return matchedInviteCode ? matchedInviteCode[0] : ''
    }
</script>
<style lang="scss">
traditionalApp/pages.json
@@ -258,6 +258,12 @@
            }
        },
        {
            "path": "pages/downloadPlayer/downloadPlayer",
            "style": {
                "navigationStyle": "custom"
            }
        },
        {
            "path": "pages/point/point",
            "style": {
                "navigationStyle": "custom"
@@ -334,12 +340,6 @@
                "iconPath": "static/image/tabBar/home.png",
                "selectedIconPath": "static/image/tabBar/Yhome.png",
                "text": "首页"
            },
            {
                "pagePath": "pages/medicalHall/medicalHall",
                "iconPath": "static/image/tabBar/good.png",
                "selectedIconPath": "static/image/tabBar/good.png",
                "text": "商城"
            },
            {
                "pagePath": "pages/healthCourses/healthCourses",
traditionalApp/pages/courseDetail/courseDetail.vue
@@ -72,12 +72,7 @@
                        <text v-for="tag in displayTags" :key="tag" class="tag">{{ tag }}</text>
                    </view>
                    <view class="price-stats-box">
                        <view class="price-box">
                            <text class="price-symbol">¥</text>
                            <text class="price-integer">{{ priceParts.integer }}</text>
                            <text class="price-decimal">.{{ priceParts.decimal }}</text>
                        </view>
                    <view class="price-stats-box price-stats-box--plain">
                        <view class="stats-box">
                            <image class="person-icon" src="/static/image/home/person.png" mode="aspectFit"></image>
                            <text class="stats-text">{{ formatLearningCount(courseDetail.learnUserCount) }}人正在学</text>
@@ -85,24 +80,6 @@
                    </view>
                    <view class="desc-text">{{ courseDetail.summary || '暂无课程介绍' }}</view>
                    <!-- 会员横幅 -->
                    <view class="vip-banner" @tap="goToVipPage">
                        <image class="vip-bg" src="/static/image/healthCourses/openVip.png" mode="aspectFill"></image>
                        <view class="vip-content">
                            <view class="vip-left">
                                <view v-if="memberMinPriceParts" class="vip-price-box">
                                    <text class="vip-price-symbol">¥</text>
                                    <text class="vip-price-num">{{ memberMinPriceParts.integer }}</text>
                                    <text class="vip-price-decimal">.{{ memberMinPriceParts.decimal }}</text>
                                    <text class="vip-price-suffix">起</text>
                                </view>
                                <text v-else class="vip-price-empty">暂未开放</text>
                                <text class="vip-desc">开通会员尊享养生课程</text>
                            </view>
                            <view class="vip-btn">{{ vipActionText }}</view>
                        </view>
                    </view>
                </view>
                <!-- 课程列表模块 (也在课程tab中展示) -->
@@ -147,7 +124,7 @@
                                        :class="{ 'cost-btn--purchased': item.purchased || courseDetail.courseOwned }"
                                        @tap.stop="handlePaidVideoTap(item)"
                                    >
                                        {{ item.purchased || courseDetail.courseOwned ? '已购买' : `¥${formatPrice(item.salePrice)}` }}
                                        {{ item.purchased || courseDetail.courseOwned ? '已领取' : '领取' }}
                                    </view>
                                </view>
                            </view>
@@ -225,7 +202,7 @@
                                        :class="{ 'cost-btn--purchased': item.purchased || courseDetail.courseOwned }"
                                        @tap.stop="handlePaidVideoTap(item)"
                                    >
                                        {{ item.purchased || courseDetail.courseOwned ? '已购买' : `¥${formatPrice(item.salePrice)}` }}
                                        {{ item.purchased || courseDetail.courseOwned ? '已领取' : '领取' }}
                                    </view>
                                </view>
                            </view>
@@ -267,11 +244,6 @@
                    <text class="popup-title">支付</text>
                </view>
                <view class="amount-box">
                    <text class="amount-symbol">¥</text>
                    <text class="amount-value">{{ paymentAmount }}</text>
                </view>
                <view class="payment-methods">
                    <text class="method-title">选择支付方式</text>
@@ -294,7 +266,7 @@
                <view class="popup-footer">
                    <view class="pay-btn" :class="{ 'pay-btn--disabled': purchaseSubmitting }" @tap="submitPurchase">
                        {{ purchaseSubmitting ? '支付中...' : '立即支付' }}
                        {{ purchaseSubmitting ? '领取中...' : '领取' }}
                    </view>
                </view>
            </view>
@@ -304,18 +276,26 @@
<script setup lang="ts">
import { computed, getCurrentInstance, nextTick, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app'
import {
    favoriteHealthCourse,
    getHealthCourseDetail,
    getMemberOpenPage,
    memberJoinHealthCourse,
    purchaseHealthCourse,
    purchaseHealthCourseVideo,
    reportHealthCourseDownload,
    unfavoriteHealthCourse,
} from './service'
import { clearDownloadTask, listDownloadTasks, upsertDownloadTask } from '@/utils/download-task-storage.js'
import { startCourseVideoDownload, hasCourseDownloadTask } from '@/utils/course-download.js'
import { resolveCurrentAppUserId } from '@/utils/app-user.js'
import {
    bindVideoCaptureListener,
    buildVideoWatermarkText,
    disableVideoSecurityProtection,
    enableVideoSecurityProtection,
    isIosApp,
    unbindVideoCaptureListener,
} from '@/utils/video-security.js'
interface CourseExpert {
    id?: number | null
@@ -334,21 +314,6 @@
    purchased: boolean
    locked: boolean
    videoUrl?: string
}
interface LocalDownloadTask {
    clientTaskId: string
    courseId: number
    courseVideoId: number
    title: string
    subtitle: string
    coverImage: string
    durationSeconds: number
    durationText: string
    videoUrl: string
    progress: number
    status: 'downloading'
    updatedAt: string
}
interface CourseDetail {
@@ -377,18 +342,6 @@
    coursePurchased: boolean
}
interface MemberOpenPackage {
    type?: string
    title?: string
    price?: number | string | null
    days?: number | null
}
interface MemberOpenPageData {
    minPrice: number | string | null
    packages: MemberOpenPackage[]
}
interface SwiperChangeEvent {
    detail?: {
        current?: number
@@ -411,6 +364,7 @@
const currentTab = ref('course')
const currentSwiperIndex = ref(0)
const loading = ref(false)
const watermarkTick = ref(Date.now())
const favoriteSubmitting = ref(false)
const purchaseSubmitting = ref(false)
const memberJoinSubmitting = ref(false)
@@ -420,7 +374,6 @@
const activePurchaseVideoId = ref<number | null>(null)
const courseId = ref('')
const courseDetail = ref<CourseDetail>(createDefaultCourseDetail())
const memberOpenPage = ref<MemberOpenPageData>(createDefaultMemberOpenPage())
const activeVideoId = ref<number>(0)
const showVideoPlayer = ref(false)
const isVideoReady = ref(false)
@@ -428,6 +381,7 @@
const playbackFailed = ref(false)
const instance = getCurrentInstance()
let videoContext: ReturnType<typeof uni.createVideoContext> | null = null
let videoSecurityTimer: ReturnType<typeof setInterval> | null = null
/**
 * 创建课程详情页默认数据,避免接口返回前页面闪空。
@@ -460,16 +414,6 @@
    }
}
/**
 * 创建会员开通页摘要默认数据,避免课程详情页价格区域闪烁。
 */
function createDefaultMemberOpenPage(): MemberOpenPageData {
    return {
        minPrice: null,
        packages: [],
    }
}
const swiperList = computed(() => {
    if (courseDetail.value.bannerImages.length > 0) {
        return courseDetail.value.bannerImages
@@ -498,50 +442,16 @@
    return allVideos.value.filter((item) => !item.freeAccess)
})
const formattedPrice = computed(() => formatPrice(courseDetail.value.salePrice))
const hasMemberMinPrice = computed(() => {
    const normalizedPrice = Number(memberOpenPage.value.minPrice)
    return Number.isFinite(normalizedPrice) && normalizedPrice > 0
})
const memberMinPriceText = computed(() => {
    if (!hasMemberMinPrice.value) {
        return ''
    }
    return formatPrice(memberOpenPage.value.minPrice || 0)
})
const memberMinPriceParts = computed(() => {
    if (!hasMemberMinPrice.value) {
        return null
    }
    const [integer, decimal] = memberMinPriceText.value.split('.')
    return {
        integer: integer || '0',
        decimal: decimal || '00',
    }
})
const vipActionText = computed(() => {
    return courseDetail.value.isMember ? '立即续费' : '立即开通'
})
const courseBuyButtonText = computed(() => {
    if (courseDetail.value.courseOwned) {
        return '已购买'
        return '已领取'
    }
    if (courseDetail.value.isMember) {
        return '加入我的课程'
    }
    return '立即购买'
    return '领取'
})
const favoriteIcon = computed(() => {
    return courseDetail.value.favorited
        ? '/static/image/healthCourses/loved.png'
        : '/static/image/healthCourses/love.png'
})
const paymentAmount = computed(() => {
    if (activePurchaseTarget.value === 'VIDEO' && activePurchaseVideoId.value !== null) {
        const matchedVideo = allVideos.value.find((item) => item.id === activePurchaseVideoId.value)
        return formatPrice(matchedVideo?.salePrice)
    }
    return formatPrice(courseDetail.value.salePrice)
})
const currentVideo = computed(() => {
    return allVideos.value.find((item) => item.id === activeVideoId.value) || null
@@ -549,25 +459,17 @@
const currentVideoUrl = computed(() => currentVideo.value?.videoUrl || '')
const currentVideoPoster = computed(() => courseDetail.value.bannerImages[0] || '/static/image/home/class.png')
const videoWatermarkText = computed(() => {
    const currentUserId = resolveCurrentUserId()
    const currentUserId = resolveCurrentAppUserId()
    if (!currentUserId) {
        return ''
    }
    return `用户ID:${currentUserId}`
    return buildVideoWatermarkText(currentUserId, watermarkTick.value)
})
const showPlaybackRetry = computed(() => {
    if (!showVideoPlayer.value || !currentVideoUrl.value || !isVideoReady.value) {
        return false
    }
    return playbackFailed.value || (isH5 && !isPlaybackStarted.value)
})
const priceParts = computed(() => {
    const [integer, decimal] = formattedPrice.value.split('.')
    return {
        integer: integer || '0',
        decimal: decimal || '00',
    }
})
const courseContentNodes = computed(() => buildRichTextNodes(courseDetail.value.content, '暂无课程详情'))
@@ -578,7 +480,33 @@
onLoad((options) => {
    courseId.value = typeof options?.id === 'string' ? options.id : ''
    loadCourseDetail()
    loadMemberOpenPage()
})
/**
 * 页面显示时开启播放器安全能力。
 */
onShow(() => {
    enableVideoSecurityProtection()
    bindVideoCaptureListener(handleVideoCapture)
    startVideoWatermarkTicker()
})
/**
 * 页面隐藏时关闭播放器安全能力。
 */
onHide(() => {
    stopVideoWatermarkTicker()
    unbindVideoCaptureListener(handleVideoCapture)
    disableVideoSecurityProtection()
})
/**
 * 页面销毁时释放播放器安全能力。
 */
onUnload(() => {
    stopVideoWatermarkTicker()
    unbindVideoCaptureListener(handleVideoCapture)
    disableVideoSecurityProtection()
})
/**
@@ -623,33 +551,6 @@
        showLoadFailedToast()
    } finally {
        loading.value = false
    }
}
/**
 * 拉取会员开通页摘要数据,供课程详情横幅展示最低启用价格。
 */
async function loadMemberOpenPage() {
    try {
        const response = await getMemberOpenPage()
        if (response?.code !== 0 || !response.data) {
            memberOpenPage.value = createDefaultMemberOpenPage()
            return
        }
        memberOpenPage.value = {
            minPrice: response.data.minPrice ?? null,
            packages: Array.isArray(response.data.packages)
                ? response.data.packages.map((item) => ({
                    ...item,
                    type: typeof item?.type === 'string' ? item.type.trim() : '',
                    title: typeof item?.title === 'string' ? item.title.trim() : '',
                    days: Number.isFinite(Number(item?.days)) ? Number(item?.days) : null,
                    price: item?.price ?? null,
                }))
                : [],
        }
    } catch {
        memberOpenPage.value = createDefaultMemberOpenPage()
    }
}
@@ -812,6 +713,39 @@
}
/**
 * 收到截图时给出安全提示。
 */
function handleVideoCapture() {
    uni.showToast({
        title: isIosApp() ? '检测到截图,请注意内容安全' : '当前页面已限制截屏录屏',
        icon: 'none',
    })
}
/**
 * 刷新播放器水印时间片段。
 */
function startVideoWatermarkTicker() {
    if (videoSecurityTimer) {
        return
    }
    videoSecurityTimer = setInterval(() => {
        watermarkTick.value = Date.now()
    }, 60000)
}
/**
 * 关闭播放器水印刷新定时器。
 */
function stopVideoWatermarkTicker() {
    if (!videoSecurityTimer) {
        return
    }
    clearInterval(videoSecurityTimer)
    videoSecurityTimer = null
}
/**
 * 跳转专家介绍页,并携带专家主键加载真实内容。
 */
function goToExpertDetail() {
@@ -820,15 +754,6 @@
    }
    uni.navigateTo({
        url: `/pages/protocol/protocol?type=expert&id=${courseDetail.value.expert.id}`
    })
}
/**
 * 跳转会员开通页,供课程详情中的会员入口复用。
 */
function goToVipPage() {
    uni.navigateTo({
        url: '/pages/vip/vip',
    })
}
@@ -888,49 +813,14 @@
 * 读取当前缓存中的 APP 用户标识,供本地下载任务按账号隔离。
 */
function resolveCurrentUserId() {
    const cachedUser = uni.getStorageSync('appUserInfo')
    if (!cachedUser || typeof cachedUser !== 'object') {
        return 0
    }
    const userId = Number((cachedUser as { id?: number | string }).id || 0)
    if (!Number.isFinite(userId) || userId <= 0) {
        return 0
    }
    return userId
    return resolveCurrentAppUserId()
}
/**
 * 判断指定章节是否已存在下载中任务,避免重复触发原生下载。
 */
function hasActiveDownloadTask(userId: number, courseVideoId: number) {
    return listDownloadTasks(userId).some((task) => Number(task.courseVideoId) === Number(courseVideoId))
}
/**
 * 构建课程下载任务唯一标识。
 */
function buildDownloadTaskId(courseVideoId: number) {
    return `health-course-video-${courseVideoId}`
}
/**
 * 组装下载中任务的本地快照,供“我的下载”页面直接复用展示。
 */
function createDownloadTaskPayload(clientTaskId: string, item: CourseVideo): LocalDownloadTask {
    return {
        clientTaskId,
        courseId: Number(courseId.value || 0),
        courseVideoId: Number(item.id || 0),
        title: item.chapterName || '课程章节',
        subtitle: courseDetail.value.title || '养生课程',
        coverImage: swiperList.value[0] || '/static/image/home/class.png',
        durationSeconds: Number(item.durationSeconds || 0),
        durationText: item.durationText || '00:00',
        videoUrl: item.videoUrl || '',
        progress: 0,
        status: 'downloading',
        updatedAt: new Date().toISOString(),
    }
    return userId > 0 && hasCourseDownloadTask(courseVideoId)
}
/**
@@ -976,14 +866,6 @@
    if (!shouldShowVideoDownload(item)) {
        return
    }
    // #ifdef H5
    window.open(item.videoUrl, '_blank')
    uni.showToast({
        title: '已打开下载地址',
        icon: 'none',
    })
    return
    // #endif
    const currentUserId = resolveCurrentUserId()
    if (!currentUserId) {
        uni.showToast({
@@ -994,81 +876,29 @@
    }
    if (hasActiveDownloadTask(currentUserId, item.id)) {
        uni.showToast({
            title: '当前章节正在下载中',
            title: '当前章节已在本地下载列表中',
            icon: 'none',
        })
        return
    }
    const downloadTaskId = buildDownloadTaskId(item.id)
    const initialTask = createDownloadTaskPayload(downloadTaskId, item)
    upsertDownloadTask(currentUserId, initialTask)
    uni.showLoading({
        title: '下载中...',
        mask: true,
    })
    const downloadTask = uni.downloadFile({
        url: item.videoUrl || '',
        success: (downloadResult) => {
            if (downloadResult.statusCode !== 200 || !downloadResult.tempFilePath) {
                clearDownloadTask(currentUserId, downloadTaskId)
                uni.showToast({
                    title: '下载失败',
                    icon: 'none',
                })
                return
    startCourseVideoDownload({
        courseId: Number(courseId.value || 0),
        courseVideoId: item.id,
        title: item.chapterName || '课程章节',
        subtitle: courseDetail.value.title || '养生课程',
        coverImage: swiperList.value[0] || '/static/image/home/class.png',
        durationSeconds: Number(item.durationSeconds || 0),
        durationText: item.durationText || '00:00',
        videoUrl: item.videoUrl || '',
        reportDownload: async () => {
            const response = await reportHealthCourseDownload({
                courseId: Number(courseId.value),
                courseVideoId: item.id,
            })
            if (response?.code !== 0) {
                throw new Error('report failed')
            }
            // 先持久化保存,避免临时文件被系统回收后用户无法继续使用。
            uni.saveFile({
                tempFilePath: downloadResult.tempFilePath,
                success: async () => {
                    // 文件已持久化后再移除下载中状态,并把完成记录同步给后端列表。
                    clearDownloadTask(currentUserId, downloadTaskId)
                    try {
                        const response = await reportHealthCourseDownload({
                            courseId: Number(courseId.value),
                            courseVideoId: item.id,
                        })
                        if (response?.code !== 0) {
                            return
                        }
                        uni.showToast({
                            title: '下载完成',
                            icon: 'none',
                        })
                    } catch {
                        uni.showToast({
                            title: '下载完成,记录同步失败',
                            icon: 'none',
                        })
                    }
                },
                fail: () => {
                    clearDownloadTask(currentUserId, downloadTaskId)
                    uni.showToast({
                        title: '文件已下载到临时目录',
                        icon: 'none',
                    })
                },
            })
        },
        fail: () => {
            clearDownloadTask(currentUserId, downloadTaskId)
            uni.showToast({
                title: '下载失败',
                icon: 'none',
            })
        },
        complete: () => {
            uni.hideLoading()
        },
    })
    // 下载中的进度仅在当前设备维护,避免把临时任务状态同步到服务端。
    downloadTask.onProgressUpdate((progressEvent) => {
        upsertDownloadTask(currentUserId, {
            ...initialTask,
            progress: Number(progressEvent?.progress || 0),
            updatedAt: new Date().toISOString(),
        })
    })
}
@@ -1411,26 +1241,6 @@
        align-items: flex-end;
        margin-bottom: 24rpx;
        .price-box {
            display: flex;
            align-items: baseline;
            color: #D32E29;
            .price-symbol {
                font-size: 24rpx;
            }
            .price-integer {
                font-size: 40rpx;
                font-weight: 600;
            }
            .price-decimal {
                font-size: 28rpx;
                font-weight: 600;
            }
        }
        .stats-box {
            display: flex;
            align-items: center;
@@ -1455,164 +1265,6 @@
        margin-bottom: 32rpx;
    }
    .vip-banner {
        position: relative;
        width: 100%;
        height: 140rpx;
        border-radius: 16rpx;
        overflow: hidden;
        .vip-bg {
            position: absolute;
            width: 100%;
            height: 100%;
            top: 0;
            left: 0;
        }
        .vip-content {
            position: relative;
            width: 100%;
            height: 100%;
            box-sizing: border-box;
            padding-top: 20rpx;
            padding-right: 28rpx;
            padding-bottom: 30rpx;
            padding-left: 170rpx;
            display: flex;
            justify-content: space-between;
            align-items: center;
            .vip-left {
                display: flex;
                flex-direction: column;
                justify-content: center;
                .vip-price-box {
                    color: #AA5200;
                    display: flex;
                    align-items: baseline;
                    margin-bottom: 4rpx;
                    .vip-price-symbol {
                        font-size: 16rpx;
                    }
                    .vip-price-num {
                        font-size: 36rpx;
                        font-weight: bold;
                    }
                    .vip-price-decimal {
                        font-size: 24rpx;
                        font-weight: bold;
                    }
                    .vip-price-suffix {
                        font-size: 24rpx;
                        margin-left: 4rpx;
                    }
                }
                .vip-price-empty {
                    font-size: 32rpx;
                    font-weight: bold;
                    color: #AA5200;
                    margin-bottom: 4rpx;
                }
                .vip-desc {
                    font-size: 22rpx;
                    color: #AA5200;
                }
            }
            .vip-btn {
                width: 210rpx;
                height: 60rpx;
                background-color: #C63520;
                border-radius: 20rpx;
                color: #fff;
                font-size: 28rpx;
                display: flex;
                justify-content: center;
                align-items: center;
                /* 为了美观可以稍微调整字体大小,原要求28rpx放在34rpx高度里可能会有点大 */
                line-height: 1;
                transform: scale(0.8); /* 28rpx in 34rpx height needs some scaling to look right, or adjust font-size */
                transform-origin: right center;
            }
        }
    }
}
.module-section {
    background-color: #fff;
    padding: 32rpx;
    margin-bottom: 20rpx;
    &.last-module {
        margin-bottom: 0;
    }
    .module-title {
        font-size: 32rpx;
        color: #1D2129;
        font-weight: 600;
        margin-bottom: 24rpx;
    }
    .module-desc {
        font-size: 26rpx;
        color: #86909C;
        line-height: 1.5;
    }
    .expert-card {
        background-color: #FAF8E9;
        padding: 24rpx;
        border-radius: 16rpx;
        display: flex;
        align-items: center;
        .expert-avatar {
            width: 96rpx;
            height: 96rpx;
            border-radius: 50%;
            flex-shrink: 0;
        }
        .expert-info {
            margin-left: 16rpx;
            flex: 1;
            .expert-name {
                font-size: 28rpx;
                color: #A27F53;
                margin-bottom: 8rpx;
            }
            .expert-desc {
                font-size: 24rpx;
                color: #86909C;
                display: -webkit-box;
                -webkit-box-orient: vertical;
                -webkit-line-clamp: 2;
                overflow: hidden;
                line-height: 34rpx;
            }
        }
    }
    .detail-content {
        font-size: 26rpx;
        color: #1A1A1A;
        line-height: 1.6;
    }
    .rich-detail-content {
        width: 100%;
    }
}
.course-list-section {
@@ -1829,23 +1481,6 @@
    font-size: 32rpx;
    color: #1A1A1A;
    font-weight: 600;
}
.amount-box {
    display: flex;
    justify-content: center;
    align-items: baseline;
    margin-bottom: 40rpx;
    color: #C63520;
}
.amount-symbol {
    font-size: 32rpx;
}
.amount-value {
    font-size: 56rpx;
    font-weight: 700;
}
.payment-methods {
traditionalApp/pages/downloadPlayer/downloadPlayer.vue
New file
@@ -0,0 +1,351 @@
<template>
    <view class="container">
        <view class="custom-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
            <view class="nav-content">
                <image class="back-icon" src="/static/image/home/left.png" mode="aspectFit" @tap="goBack"></image>
                <text class="nav-title">本地视频播放</text>
                <view class="nav-right-placeholder"></view>
            </view>
        </view>
        <view class="video-section">
            <view v-if="videoSource" class="video-player-wrapper">
                <video
                    id="download-video-player"
                    class="video-player"
                    :src="videoSource"
                    :poster="coverImage"
                    :controls="true"
                    :autoplay="true"
                    object-fit="contain"
                    play-btn-position="center"
                    @loadedmetadata="handleVideoLoaded"
                    @error="handleVideoError"
                ></video>
                <wd-watermark
                    v-if="watermarkText"
                    class="video-watermark"
                    :content="watermarkText"
                    :full-screen="false"
                    :width="160"
                    :height="110"
                    :gutter-x="36"
                    :gutter-y="36"
                    :size="15"
                    color="rgba(255, 255, 255, 0.24)"
                    :rotate="-18"
                    :opacity="1"
                    :z-index="2"
                />
                <view v-if="showRetry" class="retry-mask" @tap.stop="retryPlayback">
                    <text class="retry-text">点击开始播放</text>
                </view>
            </view>
            <view v-else class="empty-box">
                <text class="empty-text">本地文件不存在,请重新下载</text>
            </view>
        </view>
        <view class="info-card">
            <text class="video-title">{{ title }}</text>
            <view class="info-row">
                <text class="video-subtitle">{{ subtitle }}</text>
                <text class="video-duration">{{ durationText }}</text>
            </view>
        </view>
    </view>
</template>
<script setup lang="ts">
import { computed, getCurrentInstance, ref } from 'vue'
import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app'
import { getDownloadTask } from '@/utils/download-task-storage.js'
import { resolveCurrentAppUserId } from '@/utils/app-user.js'
import {
    bindVideoCaptureListener,
    buildVideoWatermarkText,
    disableVideoSecurityProtection,
    enableVideoSecurityProtection,
    isIosApp,
    unbindVideoCaptureListener,
} from '@/utils/video-security.js'
interface DownloadTaskItem {
    clientTaskId: string
    title?: string
    subtitle?: string
    coverImage?: string
    durationText?: string
    savedFilePath?: string
}
const PLAYER_ID = 'download-video-player'
const DEFAULT_COVER = '/static/image/home/class.png'
const statusBarHeight = ref(20)
const taskInfo = ref<DownloadTaskItem | null>(null)
const playbackFailed = ref(false)
const isVideoReady = ref(false)
const watermarkTick = ref(Date.now())
let videoContext: ReturnType<typeof uni.createVideoContext> | null = null
let watermarkTimer: ReturnType<typeof setInterval> | null = null
const instance = getCurrentInstance()
const videoSource = computed(() => normalizeText(taskInfo.value?.savedFilePath))
const coverImage = computed(() => normalizeText(taskInfo.value?.coverImage) || DEFAULT_COVER)
const title = computed(() => normalizeText(taskInfo.value?.title) || '本地视频')
const subtitle = computed(() => normalizeText(taskInfo.value?.subtitle) || '养生课程')
const durationText = computed(() => normalizeText(taskInfo.value?.durationText) || '00:00')
const watermarkText = computed(() => {
    const userId = resolveCurrentAppUserId()
    if (!userId) {
        return ''
    }
    return buildVideoWatermarkText(userId, watermarkTick.value)
})
const showRetry = computed(() => !!videoSource.value && isVideoReady.value && playbackFailed.value)
/**
 * 页面加载时读取任务信息。
 */
onLoad((options) => {
    const systemInfo = uni.getSystemInfoSync()
    statusBarHeight.value = systemInfo.statusBarHeight || 20
    const clientTaskId = typeof options?.clientTaskId === 'string' ? options.clientTaskId : ''
    const currentUserId = resolveCurrentAppUserId()
    if (!currentUserId || !clientTaskId) {
        return
    }
    taskInfo.value = getDownloadTask(currentUserId, clientTaskId) as DownloadTaskItem | null
    videoContext = uni.createVideoContext(PLAYER_ID, instance?.proxy)
})
/**
 * 页面显示时启用安全保护。
 */
onShow(() => {
    enableVideoSecurityProtection()
    bindVideoCaptureListener(handleVideoCapture)
    startWatermarkTimer()
})
/**
 * 页面隐藏时关闭安全保护。
 */
onHide(() => {
    stopWatermarkTimer()
    unbindVideoCaptureListener(handleVideoCapture)
    disableVideoSecurityProtection()
})
/**
 * 页面销毁时释放资源。
 */
onUnload(() => {
    stopWatermarkTimer()
    unbindVideoCaptureListener(handleVideoCapture)
    disableVideoSecurityProtection()
    videoContext = null
})
/**
 * 视频元数据准备完成后尝试自动播放。
 */
function handleVideoLoaded() {
    isVideoReady.value = true
    retryPlayback()
}
/**
 * 视频播放失败时显示重试层。
 */
function handleVideoError() {
    playbackFailed.value = true
}
/**
 * 重新触发本地视频播放。
 */
function retryPlayback() {
    if (!videoContext || !videoSource.value) {
        return
    }
    playbackFailed.value = false
    try {
        videoContext.play()
    } catch (error) {
        playbackFailed.value = true
    }
}
/**
 * 返回上一页。
 */
function goBack() {
    uni.navigateBack()
}
/**
 * 收到截图时给出提示。
 */
function handleVideoCapture() {
    uni.showToast({
        title: isIosApp() ? '检测到截图,请注意内容安全' : '当前页面已限制截屏录屏',
        icon: 'none',
    })
}
/**
 * 定时刷新水印时间片段。
 */
function startWatermarkTimer() {
    if (watermarkTimer) {
        return
    }
    watermarkTimer = setInterval(() => {
        watermarkTick.value = Date.now()
    }, 60000)
}
/**
 * 关闭水印刷新定时器。
 */
function stopWatermarkTimer() {
    if (!watermarkTimer) {
        return
    }
    clearInterval(watermarkTimer)
    watermarkTimer = null
}
/**
 * 归一化文本。
 */
function normalizeText(value: unknown) {
    return typeof value === 'string' ? value.trim() : ''
}
</script>
<style lang="scss" scoped>
page {
    background-color: #ffffff;
}
.container {
    min-height: 100vh;
    background-color: #ffffff;
}
.custom-navbar {
    width: 100%;
    position: relative;
    z-index: 10;
    background: #ffffff;
}
.nav-content {
    height: 88rpx;
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0 32rpx;
}
.back-icon {
    width: 40rpx;
    height: 40rpx;
}
.nav-title {
    font-size: 34rpx;
    font-weight: 600;
    color: #1f1f1f;
    position: absolute;
    left: 50%;
    transform: translateX(-50%);
}
.nav-right-placeholder {
    width: 40rpx;
    height: 40rpx;
}
.video-section {
    padding: 24rpx 24rpx 0;
}
.video-player-wrapper {
    position: relative;
    width: 100%;
    height: 420rpx;
    border-radius: 24rpx;
    overflow: hidden;
    background: #000;
}
.video-player {
    width: 100%;
    height: 100%;
}
.video-watermark {
    position: absolute;
    inset: 0;
    pointer-events: none;
}
.retry-mask {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    background: rgba(0, 0, 0, 0.36);
}
.retry-text {
    padding: 18rpx 32rpx;
    border-radius: 999rpx;
    background: rgba(255, 255, 255, 0.14);
    color: #ffffff;
    font-size: 28rpx;
}
.empty-box {
    height: 420rpx;
    border-radius: 24rpx;
    background: #f7f7f7;
    display: flex;
    align-items: center;
    justify-content: center;
}
.empty-text {
    font-size: 28rpx;
    color: #999999;
}
.info-card {
    margin: 24rpx;
    padding: 28rpx 24rpx;
    border-radius: 24rpx;
    background: #fffaf4;
}
.video-title {
    display: block;
    font-size: 34rpx;
    font-weight: 600;
    color: #2f1d12;
    line-height: 1.5;
}
.info-row {
    margin-top: 16rpx;
    display: flex;
    align-items: center;
    justify-content: space-between;
    color: #8f8f8f;
    font-size: 26rpx;
}
</style>
traditionalApp/pages/downloads/downloads.vue
@@ -64,9 +64,11 @@
import { getHealthCourseDownloads, deleteHealthCourseDownloads } from './service'
import {
    getDownloadTaskChangeEventName,
    getDownloadTask,
    listDownloadTasks,
    removeDownloadTasks,
    removeDownloadTasksWithFiles,
} from '@/utils/download-task-storage.js'
import { resolveCurrentAppUserId } from '@/utils/app-user.js'
interface RemoteDownloadRecord {
    id?: number | null
@@ -92,7 +94,11 @@
    durationText?: string
    videoUrl?: string
    progress?: number | null
    status?: string
    updatedAt?: string
    savedFilePath?: string
    savedAt?: string
    fileSize?: number | null
}
type DownloadSourceType = 'remoteCompleted' | 'localDownloading'
@@ -112,6 +118,7 @@
    selected: boolean
    sourceType: DownloadSourceType
    sortTime: number
    savedFilePath: string
}
const DEFAULT_COURSE_IMAGE = '/static/image/home/class.png'
@@ -173,7 +180,7 @@
            return
        }
        const remoteItems = normalizeRemoteDownloadItems(response.data)
        downloadList.value = sortDownloadList([...localItems, ...remoteItems])
        downloadList.value = sortDownloadList(mergeDownloadItems(localItems, remoteItems))
    } catch {
        downloadList.value = sortDownloadList(localItems)
    }
@@ -222,6 +229,31 @@
        currentItem.selected = !currentItem.selected
        return
    }
    if (currentItem.status === 'downloading') {
        uni.showToast({
            title: '下载中暂不可播放',
            icon: 'none',
        })
        return
    }
    if (!currentItem.clientTaskId) {
        uni.showToast({
            title: '本地文件不存在,请重新下载',
            icon: 'none',
        })
        return
    }
    const taskInfo = getDownloadTask(resolveCurrentUserId(), currentItem.clientTaskId)
    if (!taskInfo || !normalizeOptionalText(taskInfo.savedFilePath)) {
        uni.showToast({
            title: '本地文件不存在,请重新下载',
            icon: 'none',
        })
        return
    }
    uni.navigateTo({
        url: `/pages/downloadPlayer/downloadPlayer?clientTaskId=${encodeURIComponent(currentItem.clientTaskId)}`,
    })
}
/**
@@ -264,7 +296,7 @@
        .filter((item) => item.sourceType === 'remoteCompleted' && item.remoteId)
        .map((item) => Number(item.remoteId))
    const localTaskIds = items
        .filter((item) => item.sourceType === 'localDownloading' && item.clientTaskId)
        .filter((item) => item.clientTaskId)
        .map((item) => item.clientTaskId)
    try {
@@ -275,8 +307,7 @@
            }
        }
        if (localTaskIds.length > 0) {
            // 下载中删除只移除本地任务展示,不强制终止底层原生下载。
            removeDownloadTasks(resolveCurrentUserId(), localTaskIds)
            await removeDownloadTasksWithFiles(resolveCurrentUserId(), localTaskIds)
        }
        downloadList.value = downloadList.value.filter((item) => !items.some((target) => target.id === item.id))
        resetBatchMode()
@@ -324,6 +355,7 @@
            selected: false,
            sourceType: 'remoteCompleted',
            sortTime: resolveDateTime(record.downloadedAt),
            savedFilePath: '',
        }
    })
}
@@ -347,13 +379,29 @@
            subtitle: normalizeOptionalText(item.subtitle) || '养生课程',
            duration: resolveDurationText(item.durationText, item.durationSeconds),
            image: resolveCourseImage(item.coverImage),
            status: 'downloading',
            status: item.status === 'downloaded' ? 'downloaded' : 'downloading',
            progress: normalizeProgress(item.progress),
            selected: false,
            sourceType: 'localDownloading',
            sortTime: resolveDateTime(item.updatedAt),
            sortTime: resolveDateTime(item.savedAt || item.updatedAt),
            savedFilePath: normalizeOptionalText(item.savedFilePath),
        }
    })
}
/**
 * 合并本地与远端下载项,优先保留本地任务。
 */
function mergeDownloadItems(localItems: DownloadListItem[], remoteItems: DownloadListItem[]) {
    const localVideoIdSet = new Set(
        localItems
            .map((item) => Number(item.courseVideoId || 0))
            .filter((item) => Number.isFinite(item) && item > 0)
    )
    return [
        ...localItems,
        ...remoteItems.filter((item) => !localVideoIdSet.has(Number(item.courseVideoId || 0))),
    ]
}
/**
@@ -372,15 +420,7 @@
 * 读取当前缓存中的 APP 用户标识。
 */
function resolveCurrentUserId() {
    const cachedUser = uni.getStorageSync('appUserInfo')
    if (!cachedUser || typeof cachedUser !== 'object') {
        return 0
    }
    const userId = Number((cachedUser as { id?: number | string }).id || 0)
    if (!Number.isFinite(userId) || userId <= 0) {
        return 0
    }
    return userId
    return resolveCurrentAppUserId()
}
/**
traditionalApp/pages/home/home.vue
@@ -427,9 +427,6 @@
        }
        if (currentBanner.jumpType === 'MEMBER') {
            uni.navigateTo({
                url: '/pages/vip/vip'
            });
            return;
        }
traditionalApp/pages/inviteUser/inviteUser.vue
@@ -37,7 +37,7 @@
                    
                    <view class="card-bottom">
                        <view class="qr-box">
                            <image class="qr-image" :src="qrcodeData.qrCodeDataUrl || '/static/image/mine/code.png'" mode="aspectFit"></image>
                            <image class="qr-image" :src="qrcodeData.qrCodeImageUrl || qrcodeData.qrCodeDataUrl || '/static/image/mine/code.png'" mode="aspectFit"></image>
                        </view>
                        <text class="qr-tip">扫码下载APP,注册账号</text>
                        <view class="save-btn" @tap="saveQRCode">保存二维码</view>
@@ -84,6 +84,7 @@
        clinicName: string
        inviteCode: string
        shareLandingUrl: string
        qrCodeImageUrl: string
        qrCodeDataUrl: string
        boundUsers: ShareBoundUserItem[]
    }
@@ -104,6 +105,7 @@
            clinicName: '',
            inviteCode: '',
            shareLandingUrl: '',
            qrCodeImageUrl: '',
            qrCodeDataUrl: '',
            boundUsers: []
        }
@@ -150,14 +152,15 @@
     * 保存当前二维码图片到相册。
     */
    function saveQRCode() {
        if (!qrcodeData.value.qrCodeDataUrl) {
        const saveTarget = qrcodeData.value.qrCodeImageUrl || qrcodeData.value.qrCodeDataUrl
        if (!saveTarget) {
            uni.showToast({
                title: '二维码生成中,请稍后重试',
                icon: 'none'
            })
            return
        }
        saveImage(qrcodeData.value.qrCodeDataUrl)
        saveImage(saveTarget)
    }
    /**
@@ -169,6 +172,7 @@
            clinicName: normalizeOptionalText(source.clinicName),
            inviteCode: normalizeOptionalText(source.inviteCode),
            shareLandingUrl: normalizeOptionalText(source.shareLandingUrl),
            qrCodeImageUrl: normalizeOptionalText(source.qrCodeImageUrl),
            qrCodeDataUrl: normalizeOptionalText(source.qrCodeDataUrl),
            boundUsers: normalizeBoundUsers(source.boundUsers)
        }
traditionalApp/pages/mine/mine.vue
@@ -24,18 +24,6 @@
                </view>
            </view>
            <view class="vip-banner">
                <image class="vip-bg" src="/static/image/mine/openVip.png" mode="aspectFill"></image>
                <view class="vip-content">
                    <view class="vip-left">
                        <view class="vip-title-box">
                            <text class="vip-title">VIP会员</text>
                        </view>
                        <text class="vip-desc">{{ vipDescription }}</text>
                    </view>
                    <view class="vip-btn" @tap="goPage('/pages/vip/vip')">{{ vipButtonText }}</view>
                </view>
            </view>
            <view class="grid-section card-box">
                <view class="grid-item" v-for="(item, index) in gridMenuList" :key="`${item.title}-${index}`" @tap="handleGridClick(item)">
@@ -61,7 +49,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { getMemberOpenPage, getUserCenter } from './service'
import { getUserCenter } from './service'
type UserRole = 'personal' | 'doctor'
@@ -98,8 +86,6 @@
const statusBarHeight = ref(0)
const userCenter = ref<UserCenterData>(createDefaultUserCenter())
const memberMinPrice = ref<number | string | null>(null)
const userRole = computed<UserRole>(() => {
    return Number(userCenter.value.type) === 1 ? 'doctor' : 'personal'
})
@@ -113,28 +99,10 @@
    const points = Number(userCenter.value.pointsBalance || 0)
    return Number.isFinite(points) ? points : 0
})
const isVipActive = computed(() => {
    return isMemberActive(userCenter.value.memberExpireAt)
})
const vipDescription = computed(() => {
    if (isVipActive.value) {
        return `${formatMemberExpireText(userCenter.value.memberExpireAt)}到期`
    }
    const formattedPrice = formatVipMinPrice(memberMinPrice.value)
    if (!formattedPrice) {
        return '开通畅享养生课'
    }
    return `${formattedPrice}元起开通畅享养生课`
})
const vipButtonText = computed(() => {
    return isVipActive.value ? '立即续费' : '立即开通'
})
const gridMenuList = computed<MenuItem[]>(() => {
    if (userRole.value === 'doctor') {
        return [
            { title: '购课记录', icon: '/static/image/mine/Book@2x.png', url: '/pages/courseRecord/courseRecord' },
            { title: '会员续费', icon: '/static/image/mine/vip.png', url: '/pages/vip/vip' },
            { title: '收藏列表', icon: '/static/image/mine/list.png', url: '/pages/favorites/favorites' },
            { title: '我的下载', icon: '/static/image/mine/downLoad.png', url: '/pages/downloads/downloads' },
            { title: '我的患者', icon: '/static/image/mine/person.png', url: '/pages/myPatients/myPatients' },
@@ -144,7 +112,6 @@
    }
    return [
        { title: '购课记录', icon: '/static/image/mine/Book@2x.png', url: '/pages/courseRecord/courseRecord' },
        { title: '会员续费', icon: '/static/image/mine/vip.png', url: '/pages/vip/vip' },
        { title: '兑换订单', icon: '/static/image/mine/Shopping@2x.png', url: '/pages/exchangeRecord/exchangeRecord' },
        { title: '收藏列表', icon: '/static/image/mine/list.png', url: '/pages/favorites/favorites' },
        { title: '我的下载', icon: '/static/image/mine/downLoad.png', url: '/pages/downloads/downloads' },
@@ -197,7 +164,6 @@
    const systemInfo = uni.getSystemInfoSync()
    statusBarHeight.value = systemInfo.statusBarHeight || 20
    syncCachedUserInfo()
    loadMemberOpenPage()
})
/**
@@ -206,7 +172,6 @@
onShow(() => {
    syncCachedUserInfo()
    loadUserCenter()
    loadMemberOpenPage()
})
/**
@@ -222,23 +187,6 @@
        cacheUserCenter(userCenter.value)
    } catch {
        // 未登录或网络异常时由统一请求层处理跳转,这里不重复 toast。
    }
}
/**
 * 拉取会员开通页最低价格,用于个人中心会员横幅文案展示。
 */
async function loadMemberOpenPage() {
    try {
        const response = await getMemberOpenPage()
        if (response?.code !== 0 || !response.data) {
            memberMinPrice.value = null
            return
        }
        memberMinPrice.value = response.data.minPrice ?? null
    } catch {
        // 文案接口失败时使用兜底文案,避免个人中心反复提示。
        memberMinPrice.value = null
    }
}
@@ -313,44 +261,6 @@
    uni.navigateTo({
        url,
    })
}
/**
 * 将会员最低价格式化为横幅文案所需的金额字符串。
 */
function formatVipMinPrice(value: number | string | null | undefined) {
    const price = Number(value)
    if (!Number.isFinite(price) || price <= 0) {
        return ''
    }
    const fixed = price.toFixed(2)
    return fixed.replace(/\.00$/, '').replace(/(\.\d)0$/, '$1')
}
/**
 * 判断当前会员是否仍在有效期内。
 */
function isMemberActive(memberExpireAt?: string) {
    const normalizedExpireAt = normalizeOptionalText(memberExpireAt)
    if (!normalizedExpireAt) {
        return false
    }
    const expireDate = new Date(normalizedExpireAt.replace(/-/g, '/'))
    if (Number.isNaN(expireDate.getTime())) {
        return false
    }
    return expireDate.getTime() > Date.now()
}
/**
 * 格式化会员到期日期,供个人中心会员卡片直接展示。
 */
function formatMemberExpireText(memberExpireAt?: string) {
    const normalizedExpireAt = normalizeOptionalText(memberExpireAt)
    if (!normalizedExpireAt) {
        return '会员'
    }
    return normalizedExpireAt.slice(0, 10)
}
/**
traditionalApp/pages/myCourseDetail/myCourseDetail.vue
@@ -107,8 +107,18 @@
import { computed, getCurrentInstance, ref } from 'vue'
import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app'
import { getMyHealthCourseDetail, uploadMyHealthCoursePlayRecord } from './service'
import { clearDownloadTask, listDownloadTasks, upsertDownloadTask } from '@/utils/download-task-storage.js'
import { listDownloadTasks } from '@/utils/download-task-storage.js'
import { reportHealthCourseDownload } from '@/pages/courseDetail/service'
import { startCourseVideoDownload, hasCourseDownloadTask } from '@/utils/course-download.js'
import { resolveCurrentAppUserId } from '@/utils/app-user.js'
import {
    bindVideoCaptureListener,
    buildVideoWatermarkText,
    disableVideoSecurityProtection,
    enableVideoSecurityProtection,
    isIosApp,
    unbindVideoCaptureListener,
} from '@/utils/video-security.js'
interface MyHealthCourseVideoItem {
    videoId: number
@@ -162,8 +172,11 @@
    durationText: string
    videoUrl: string
    progress: number
    status: 'downloading'
    status: 'downloading' | 'downloaded'
    updatedAt: string
    savedFilePath?: string
    savedAt?: string
    fileSize?: number
}
type VideoMetadataEvent = {
@@ -202,11 +215,13 @@
const currentWatchStartedAt = ref(0)
const currentPlaybackSeconds = ref(0)
const lastReportedPlaybackSeconds = ref(0)
const watermarkTick = ref(Date.now())
const backSubmitting = ref(false)
const watchSegmentSubmitting = ref(false)
const instance = getCurrentInstance()
let videoContext: ReturnType<typeof uni.createVideoContext> | null = null
let watchHeartbeatTimer: ReturnType<typeof setInterval> | null = null
let videoSecurityTimer: ReturnType<typeof setInterval> | null = null
/**
 * 生成默认详情数据,避免页面空对象访问。
@@ -237,7 +252,7 @@
    if (!currentUserId) {
        return ''
    }
    return `用户ID:${currentUserId}`
    return buildVideoWatermarkText(currentUserId, watermarkTick.value)
})
const lessonList = computed<LessonViewItem[]>(() => {
    const downloadingTasks = listCurrentDownloadTasks()
@@ -274,6 +289,9 @@
 * 页面显示时刷新详情,保证章节和进度为最新状态。
 */
onShow(() => {
    enableVideoSecurityProtection()
    bindVideoCaptureListener(handleVideoCapture)
    startVideoWatermarkTicker()
    loadMyHealthCourseDetail()
})
@@ -281,6 +299,9 @@
 * 页面隐藏时补提最后一段播放区间。
 */
onHide(() => {
    stopVideoWatermarkTicker()
    unbindVideoCaptureListener(handleVideoCapture)
    disableVideoSecurityProtection()
    clearWatchHeartbeatTimer()
    submitCurrentWatchSegment()
})
@@ -289,6 +310,9 @@
 * 页面卸载时补提最后一段播放区间。
 */
onUnload(() => {
    stopVideoWatermarkTicker()
    unbindVideoCaptureListener(handleVideoCapture)
    disableVideoSecurityProtection()
    unbindH5PlaybackLifecycle()
    clearWatchHeartbeatTimer()
    submitCurrentWatchSegment()
@@ -704,79 +728,29 @@
    }
    if (hasDownloadedTask(listCurrentDownloadTasks(), item.videoId)) {
        uni.showToast({
            title: '当前章节正在下载中',
            title: '当前章节已在本地下载列表中',
            icon: 'none',
        })
        return
    }
    const downloadTaskId = buildDownloadTaskId(item.videoId)
    const initialTask = createDownloadTaskPayload(downloadTaskId, item)
    upsertDownloadTask(currentUserId, initialTask)
    uni.showLoading({
        title: '下载中...',
        mask: true,
    })
    const downloadTask = uni.downloadFile({
        url: item.videoUrl,
        success: async (response) => {
            if (response.statusCode !== 200 || !response.tempFilePath) {
                clearDownloadTask(currentUserId, downloadTaskId)
                uni.showToast({
                    title: '下载失败',
                    icon: 'none',
                })
                return
    startCourseVideoDownload({
        courseId: detail.value.courseId,
        courseVideoId: item.videoId,
        title: detail.value.title || '养生课程',
        subtitle: item.title,
        coverImage: detail.value.coverImage,
        durationSeconds: item.durationSeconds,
        durationText: item.duration,
        videoUrl: item.videoUrl,
        reportDownload: async () => {
            const reportResponse = await reportHealthCourseDownload({
                courseId: detail.value.courseId,
                courseVideoId: item.videoId,
            })
            if (reportResponse?.code !== 0) {
                throw new Error('report failed')
            }
            // 先保存到持久化目录,再同步下载记录,避免临时文件失效后“我的下载”里只有记录没有文件。
            uni.saveFile({
                tempFilePath: response.tempFilePath,
                success: async () => {
                    clearDownloadTask(currentUserId, downloadTaskId)
                    try {
                        const reportResponse = await reportHealthCourseDownload({
                            courseId: detail.value.courseId,
                            courseVideoId: item.videoId,
                        })
                        if (reportResponse?.code !== 0) {
                            return
                        }
                        uni.showToast({
                            title: '下载完成',
                            icon: 'none',
                        })
                    } catch {
                        uni.showToast({
                            title: '下载完成,记录同步失败',
                            icon: 'none',
                        })
                    }
                },
                fail: () => {
                    clearDownloadTask(currentUserId, downloadTaskId)
                    uni.showToast({
                        title: '文件已下载到临时目录',
                        icon: 'none',
                    })
                },
            })
        },
        fail: () => {
            clearDownloadTask(currentUserId, downloadTaskId)
            uni.showToast({
                title: '下载失败',
                icon: 'none',
            })
        },
        complete: () => {
            uni.hideLoading()
        },
    })
    downloadTask.onProgressUpdate((progressEvent) => {
        upsertDownloadTask(currentUserId, {
            ...initialTask,
            progress: progressEvent.progress,
            updatedAt: new Date().toISOString(),
        })
    })
}
@@ -821,42 +795,45 @@
    })
}
/**
 * 构建下载任务快照。
 */
function createDownloadTaskPayload(clientTaskId: string, item: LessonViewItem): LocalDownloadTask {
    return {
        clientTaskId,
        courseId: detail.value.courseId,
        courseVideoId: item.videoId,
        title: detail.value.title || '养生课程',
        subtitle: item.title,
        coverImage: detail.value.coverImage,
        durationSeconds: item.durationSeconds,
        durationText: item.duration,
        videoUrl: item.videoUrl,
        progress: 0,
        status: 'downloading',
        updatedAt: new Date().toISOString(),
    }
}
/**
 * 构建下载任务唯一标识。
 */
function buildDownloadTaskId(videoId: number) {
    return `my-course-${detail.value.courseId}-${videoId}`
}
/**
 * 读取当前用户的下载任务列表。
 */
function listCurrentDownloadTasks(): LocalDownloadTask[] {
    const currentUserId = normalizeNumber(resolveCurrentUserId())
    if (!currentUserId) {
        return []
    }
    return listDownloadTasks(currentUserId) as LocalDownloadTask[]
}
/**
 * 播放器安全限制下收到截图事件时提示用户。
 */
function handleVideoCapture() {
    uni.showToast({
        title: isIosApp() ? '检测到截图,请注意内容安全' : '当前页面已限制截屏录屏',
        icon: 'none',
    })
}
/**
 * 启动水印刷新定时器,降低截图复用价值。
 */
function startVideoWatermarkTicker() {
    if (videoSecurityTimer) {
        return
    }
    videoSecurityTimer = setInterval(() => {
        watermarkTick.value = Date.now()
    }, 60000)
}
/**
 * 关闭水印刷新定时器。
 */
function stopVideoWatermarkTicker() {
    if (!videoSecurityTimer) {
        return
    }
    clearInterval(videoSecurityTimer)
    videoSecurityTimer = null
}
/**
@@ -870,11 +847,7 @@
 * 获取当前登录用户主键。
 */
function resolveCurrentUserId() {
    const userInfo = uni.getStorageSync('appUserInfo')
    if (!userInfo || typeof userInfo !== 'object') {
        return 0
    }
    return (userInfo.id || userInfo.userId || 0) as number
    return resolveCurrentAppUserId()
}
/**
traditionalApp/pages/register/register.vue
@@ -174,7 +174,7 @@
    sendRegisterSmsCode,
    uploadRegisterLicense
} from './service.js'
import { consumeAppInviteCode, normalizeOptionalText } from '@/utils/utils.js'
import { consumeAppInviteCode, extractInviteCode, normalizeOptionalText } from '@/utils/utils.js'
interface RegionItem {
    label?: string
@@ -340,12 +340,17 @@
}
/**
 * 扫码填充邀请码,统一转为大写。
 * 扫码填充邀请码,支持从邀请下载页 URL 中提取推广码。
 */
const scanCode = () => {
    uni.scanCode({
        success: (res) => {
            form.inviteCode = (res.result || '').toUpperCase()
            const inviteCode = normalizeInviteCode(res.result)
            if (!inviteCode) {
                showToast('未识别到有效邀请码')
                return
            }
            form.inviteCode = inviteCode
        }
    })
}
@@ -810,7 +815,7 @@
 * 将邀请码归一化为 6 位大写编码。
 */
const normalizeInviteCode = (value: unknown) => {
    const normalizedValue = normalizeOptionalText(value).toUpperCase()
    const normalizedValue = extractInviteCode(value)
    if (!/^[A-Z0-9]{6}$/.test(normalizedValue)) {
        return ''
    }
traditionalApp/pages/watchLive/watchLive.vue
@@ -158,6 +158,7 @@
    title?: string
    coverImage?: string
    displayImage?: string
    displayStatus?: string
    actionStatus?: LiveActionStatus
    actionSessionId?: number | string | null
    videoUrl?: string
@@ -267,6 +268,7 @@
        title: '',
        coverImage: '',
        displayImage: '',
        displayStatus: '',
        actionStatus: 'CLOSED',
        actionSessionId: null,
        videoUrl: '',
@@ -405,6 +407,11 @@
        && !!liveDetail.value.playbackStartTime
        && Number(liveDetail.value.durationSeconds || 0) > 0
})
const isEndedQuizWindow = computed(() => {
    return liveDetail.value.displayStatus === 'ENDED'
        && !!activeSessionId.value
        && !!quizState.value.quizEnabled
})
const watchCountText = computed(() => formatCount(liveDetail.value.watchCount))
const rewardPointsText = computed(() => String(quizState.value.pointsReward || liveDetail.value.quizPoints || 0))
const hasQuizQuestions = computed(() => questions.value.length > 0)
@@ -485,6 +492,9 @@
    if (!liveDetail.value.courseId) {
        return '直播信息不存在'
    }
    if (isEndedQuizWindow.value) {
        return '直播已结束,请完成答题'
    }
    if (!liveDetail.value.videoUrl) {
        return '直播视频准备中'
    }
traditionalApp/utils/app-user.js
New file
@@ -0,0 +1,14 @@
/**
 * 读取当前缓存中的 APP 用户标识。
 */
export function resolveCurrentAppUserId() {
    const cachedUser = uni.getStorageSync('appUserInfo')
    if (!cachedUser || typeof cachedUser !== 'object') {
        return 0
    }
    const userId = Number(cachedUser.id || 0)
    if (!Number.isFinite(userId) || userId <= 0) {
        return 0
    }
    return userId
}
traditionalApp/utils/course-download.js
New file
@@ -0,0 +1,175 @@
import {
    findDownloadTaskByVideoId,
    markDownloadTaskCompleted,
    removeDownloadTasks,
    upsertDownloadTask,
} from '@/utils/download-task-storage.js'
import { resolveCurrentAppUserId } from '@/utils/app-user.js'
/**
 * 构建课程视频下载任务唯一标识。
 */
export function buildCourseDownloadTaskId(courseId, courseVideoId) {
    return `health-course-video-${Number(courseId || 0)}-${Number(courseVideoId || 0)}`
}
/**
 * 判断当前小节是否已存在本地下载任务。
 */
export function hasCourseDownloadTask(courseVideoId) {
    const currentUserId = resolveCurrentAppUserId()
    if (!currentUserId) {
        return false
    }
    return !!findDownloadTaskByVideoId(currentUserId, courseVideoId)
}
/**
 * 发起课程视频下载,并同步本地持久化任务。
 */
export function startCourseVideoDownload(options) {
    const normalizedOptions = normalizeDownloadOptions(options)
    if (!normalizedOptions.videoUrl) {
        uni.showToast({
            title: '视频地址不存在',
            icon: 'none',
        })
        return
    }
    // #ifdef H5
    window.open(normalizedOptions.videoUrl, '_blank')
    uni.showToast({
        title: '已打开下载地址',
        icon: 'none',
    })
    return
    // #endif
    const currentUserId = resolveCurrentAppUserId()
    if (!currentUserId) {
        uni.showToast({
            title: '请先登录',
            icon: 'none',
        })
        return
    }
    const existingTask = findDownloadTaskByVideoId(currentUserId, normalizedOptions.courseVideoId)
    if (existingTask) {
        uni.showToast({
            title: existingTask.status === 'downloaded' ? '当前章节已下载' : '当前章节正在下载中',
            icon: 'none',
        })
        return
    }
    const downloadTaskId = buildCourseDownloadTaskId(normalizedOptions.courseId, normalizedOptions.courseVideoId)
    const initialTask = {
        clientTaskId: downloadTaskId,
        courseId: normalizedOptions.courseId,
        courseVideoId: normalizedOptions.courseVideoId,
        title: normalizedOptions.title,
        subtitle: normalizedOptions.subtitle,
        coverImage: normalizedOptions.coverImage,
        durationSeconds: normalizedOptions.durationSeconds,
        durationText: normalizedOptions.durationText,
        videoUrl: normalizedOptions.videoUrl,
        progress: 0,
        status: 'downloading',
        updatedAt: new Date().toISOString(),
    }
    upsertDownloadTask(currentUserId, initialTask)
    uni.showLoading({
        title: '下载中...',
        mask: true,
    })
    const downloadTask = uni.downloadFile({
        url: normalizedOptions.videoUrl,
        success: (downloadResult) => {
            if (downloadResult.statusCode !== 200 || !downloadResult.tempFilePath) {
                handleDownloadFailure(currentUserId, downloadTaskId, '下载失败')
                return
            }
            uni.saveFile({
                tempFilePath: downloadResult.tempFilePath,
                success: async (saveResult) => {
                    markDownloadTaskCompleted(currentUserId, downloadTaskId, {
                        savedFilePath: normalizeText(saveResult.savedFilePath),
                        fileSize: normalizeNumber(saveResult.size),
                    })
                    try {
                        if (typeof normalizedOptions.reportDownload === 'function') {
                            await normalizedOptions.reportDownload()
                        }
                        uni.showToast({
                            title: '下载完成',
                            icon: 'none',
                        })
                    } catch (error) {
                        uni.showToast({
                            title: '下载完成,记录同步失败',
                            icon: 'none',
                        })
                    }
                },
                fail: () => {
                    handleDownloadFailure(currentUserId, downloadTaskId, '文件持久化失败')
                },
            })
        },
        fail: () => {
            handleDownloadFailure(currentUserId, downloadTaskId, '下载失败')
        },
        complete: () => {
            uni.hideLoading()
        },
    })
    downloadTask.onProgressUpdate((progressEvent) => {
        upsertDownloadTask(currentUserId, {
            ...initialTask,
            progress: Number(progressEvent?.progress || 0),
            updatedAt: new Date().toISOString(),
        })
    })
}
/**
 * 归一化下载配置。
 */
function normalizeDownloadOptions(options) {
    const source = options && typeof options === 'object' ? options : {}
    return {
        courseId: normalizeNumber(source.courseId),
        courseVideoId: normalizeNumber(source.courseVideoId),
        title: normalizeText(source.title) || '课程章节',
        subtitle: normalizeText(source.subtitle) || '养生课程',
        coverImage: normalizeText(source.coverImage) || '/static/image/home/class.png',
        durationSeconds: normalizeNumber(source.durationSeconds),
        durationText: normalizeText(source.durationText) || '00:00',
        videoUrl: normalizeText(source.videoUrl),
        reportDownload: typeof source.reportDownload === 'function' ? source.reportDownload : null,
    }
}
/**
 * 处理下载失败收口。
 */
function handleDownloadFailure(userId, clientTaskId, message) {
    removeDownloadTasks(userId, [clientTaskId])
    uni.showToast({
        title: message,
        icon: 'none',
    })
}
/**
 * 归一化文本。
 */
function normalizeText(value) {
    return typeof value === 'string' ? value.trim() : ''
}
/**
 * 归一化数字。
 */
function normalizeNumber(value) {
    const normalizedValue = Number(value || 0)
    return Number.isFinite(normalizedValue) ? normalizedValue : 0
}
traditionalApp/utils/download-task-storage.js
@@ -24,6 +24,21 @@
}
/**
 * 按任务主键读取单条下载任务。
 */
export function getDownloadTask(userId, clientTaskId) {
    return listDownloadTasks(userId).find((item) => item.clientTaskId === String(clientTaskId || '')) || null
}
/**
 * 根据课程小节查询下载任务。
 */
export function findDownloadTaskByVideoId(userId, courseVideoId) {
    const normalizedVideoId = Number(courseVideoId || 0)
    return listDownloadTasks(userId).find((item) => Number(item.courseVideoId) === normalizedVideoId) || null
}
/**
 * 写入或刷新一条下载任务。
 */
export function upsertDownloadTask(userId, task) {
@@ -42,6 +57,25 @@
}
/**
 * 标记下载任务已完成,并记录本地持久化文件信息。
 */
export function markDownloadTaskCompleted(userId, clientTaskId, payload) {
    const task = getDownloadTask(userId, clientTaskId)
    if (!task) {
        return
    }
    upsertDownloadTask(userId, {
        ...task,
        ...payload,
        clientTaskId: task.clientTaskId,
        progress: 100,
        status: 'downloaded',
        updatedAt: new Date().toISOString(),
        savedAt: normalizeText(payload && payload.savedAt) || new Date().toISOString(),
    })
}
/**
 * 批量移除指定下载任务。
 */
export function removeDownloadTasks(userId, clientTaskIds) {
@@ -51,6 +85,22 @@
    const taskIdSet = new Set(clientTaskIds.map((item) => String(item)))
    const taskList = listDownloadTasks(userId).filter((item) => !taskIdSet.has(String(item.clientTaskId)))
    saveDownloadTasks(userId, taskList)
}
/**
 * 删除本地任务及其持久化文件。
 */
export async function removeDownloadTasksWithFiles(userId, clientTaskIds) {
    if (!Array.isArray(clientTaskIds) || clientTaskIds.length === 0) {
        return
    }
    const taskIdSet = new Set(clientTaskIds.map((item) => String(item)))
    const taskList = listDownloadTasks(userId)
    const targetTasks = taskList.filter((item) => taskIdSet.has(String(item.clientTaskId)))
    for (const task of targetTasks) {
        await removeSavedFile(task.savedFilePath)
    }
    removeDownloadTasks(userId, clientTaskIds)
}
/**
@@ -98,8 +148,11 @@
        durationText: normalizeText(value.durationText),
        videoUrl: normalizeText(value.videoUrl),
        progress: normalizeProgress(value.progress),
        status: 'downloading',
        status: normalizeStatus(value.status),
        updatedAt: normalizeText(value.updatedAt) || new Date().toISOString(),
        savedFilePath: normalizeText(value.savedFilePath),
        savedAt: normalizeText(value.savedAt),
        fileSize: normalizeNumber(value.fileSize),
    }
}
@@ -140,3 +193,27 @@
    }
    return progressValue
}
/**
 * 归一化下载状态。
 */
function normalizeStatus(value) {
    return value === 'downloaded' ? 'downloaded' : 'downloading'
}
/**
 * 删除持久化文件。
 */
function removeSavedFile(savedFilePath) {
    if (!normalizeText(savedFilePath) || typeof uni.removeSavedFile !== 'function') {
        return Promise.resolve()
    }
    return new Promise((resolve) => {
        uni.removeSavedFile({
            filePath: savedFilePath,
            complete: () => {
                resolve()
            },
        })
    })
}
traditionalApp/utils/utils.js
@@ -2,30 +2,21 @@
    uni.showLoading({
        title: '正在保存图片...'
    });
    if (normalizeOptionalText(url).startsWith('data:image/')) {
        uni.hideLoading();
        uni.showToast({
            title: "当前图片暂不支持直接保存,请稍后重试!",
            icon: 'none'
        });
        return
    }
    uni.getSetting({
        success: async (res) => {
            if (!res.authSetting["scope.writePhotosAlbum"]) {
                uni.authorize({
                    scope: "scope.writePhotosAlbum",
                    success: async () => {
                        const res = await uni.getImageInfo({
                            src: url
                        });
                        uni.saveImageToPhotosAlbum({
                            filePath: res.path,
                            success: () => {
                                uni.hideLoading();
                                uni.showToast({
                                    title: "保存成功!"
                                });
                            },
                            fail: () => {
                                uni.showToast({
                                    title: "保存失败,请重试!",
                                    icon: 'error'
                                });
                            }
                        });
                        await saveImageFile(url)
                    },
                    fail: () => {
                        uni.hideLoading();
@@ -41,24 +32,7 @@
                    }
                });
            } else {
                const res = await uni.getImageInfo({
                    src: url
                });
                uni.saveImageToPhotosAlbum({
                    filePath: res.path,
                    success: () => {
                        uni.hideLoading();
                        uni.showToast({
                            title: "保存成功!"
                        });
                    },
                    fail: () => {
                        uni.showToast({
                            title: "保存失败,请重试!",
                            icon: 'error'
                        });
                    }
                });
                await saveImageFile(url)
            }
        },
        fail: () => {
@@ -72,10 +46,51 @@
};
/**
 * 下载图片并写入系统相册。
 */
const saveImageFile = async (url) => {
    try {
        const imageInfo = await uni.getImageInfo({
            src: url
        });
        await uni.saveImageToPhotosAlbum({
            filePath: imageInfo.path,
        });
        uni.hideLoading();
        uni.showToast({
            title: "保存成功!"
        });
    } catch (error) {
        uni.hideLoading();
        uni.showToast({
            title: "保存失败,请重试!",
            icon: 'error'
        });
    }
}
/**
 * 将可选文本归一化为空字符串。
 */
export const normalizeOptionalText = (value) => {
    return typeof value === 'string' ? value.trim() : ''
}
/**
 * 从 URL、纯文本或剪贴板内容中提取 6 位邀请码。
 */
export const extractInviteCode = (value) => {
    const normalizedValue = normalizeOptionalText(value)
    if (!normalizedValue) {
        return ''
    }
    const queryMatchedInviteCode = normalizedValue.match(/[?&]inviteCode=([^&#]+)/i)
    if (queryMatchedInviteCode && queryMatchedInviteCode[1]) {
        const decodedInviteCode = decodeURIComponent(queryMatchedInviteCode[1]).trim().toUpperCase()
        return /^[A-Z0-9]{6}$/.test(decodedInviteCode) ? decodedInviteCode : ''
    }
    const matchedInviteCode = normalizedValue.toUpperCase().match(/[A-Z0-9]{6}/)
    return matchedInviteCode ? matchedInviteCode[0] : ''
}
const APP_INVITE_CODE_STORAGE_KEY = 'appAutoInviteCode'
@@ -84,7 +99,7 @@
 * 缓存自动识别的邀请码,供注册页优先读取。
 */
export const cacheAppInviteCode = (inviteCode) => {
    const normalizedInviteCode = normalizeOptionalText(inviteCode).toUpperCase()
    const normalizedInviteCode = extractInviteCode(inviteCode)
    if (!/^[A-Z0-9]{6}$/.test(normalizedInviteCode)) {
        return
    }
@@ -95,7 +110,7 @@
 * 读取并消费自动识别的邀请码。
 */
export const consumeAppInviteCode = () => {
    const inviteCode = normalizeOptionalText(uni.getStorageSync(APP_INVITE_CODE_STORAGE_KEY)).toUpperCase()
    const inviteCode = extractInviteCode(uni.getStorageSync(APP_INVITE_CODE_STORAGE_KEY))
    if (!/^[A-Z0-9]{6}$/.test(inviteCode)) {
        return ''
    }
traditionalApp/utils/video-security.js
New file
@@ -0,0 +1,102 @@
/**
 * 构建播放器动态水印文案。
 */
export function buildVideoWatermarkText(userId, tick) {
    const normalizedUserId = Number(userId || 0)
    if (!Number.isFinite(normalizedUserId) || normalizedUserId <= 0) {
        return ''
    }
    return `用户ID:${normalizedUserId} ${formatWatermarkTime(tick)}`
}
/**
 * 启用播放器安全限制。
 */
export function enableVideoSecurityProtection() {
    if (typeof plus === 'undefined' || !plus.os) {
        return
    }
    if (String(plus.os.name || '').toLowerCase() !== 'android') {
        return
    }
    try {
        const mainActivity = plus.android.runtimeMainActivity()
        if (!mainActivity) {
            return
        }
        const window = mainActivity.getWindow()
        const layoutParams = plus.android.importClass('android.view.WindowManager$LayoutParams')
        window.setFlags(layoutParams.FLAG_SECURE, layoutParams.FLAG_SECURE)
    } catch (error) {
        // 安全限制为增强能力,失败时保持静默回退。
    }
}
/**
 * 关闭播放器安全限制。
 */
export function disableVideoSecurityProtection() {
    if (typeof plus === 'undefined' || !plus.os) {
        return
    }
    if (String(plus.os.name || '').toLowerCase() !== 'android') {
        return
    }
    try {
        const mainActivity = plus.android.runtimeMainActivity()
        if (!mainActivity) {
            return
        }
        const window = mainActivity.getWindow()
        const layoutParams = plus.android.importClass('android.view.WindowManager$LayoutParams')
        window.clearFlags(layoutParams.FLAG_SECURE)
    } catch (error) {
        // 安全限制为增强能力,失败时保持静默回退。
    }
}
/**
 * 绑定系统截图监听。
 */
export function bindVideoCaptureListener(handler) {
    if (typeof handler !== 'function' || typeof uni.onUserCaptureScreen !== 'function') {
        return
    }
    uni.onUserCaptureScreen(handler)
}
/**
 * 解绑系统截图监听。
 */
export function unbindVideoCaptureListener(handler) {
    if (typeof uni.offUserCaptureScreen !== 'function') {
        return
    }
    if (typeof handler === 'function') {
        uni.offUserCaptureScreen(handler)
        return
    }
    uni.offUserCaptureScreen()
}
/**
 * 判断当前是否为 iOS APP。
 */
export function isIosApp() {
    if (typeof plus === 'undefined' || !plus.os) {
        return false
    }
    return String(plus.os.name || '').toLowerCase() === 'ios'
}
/**
 * 格式化水印时间片段。
 */
function formatWatermarkTime(value) {
    const date = value instanceof Date ? value : new Date(value || Date.now())
    const month = `${date.getMonth() + 1}`.padStart(2, '0')
    const day = `${date.getDate()}`.padStart(2, '0')
    const hours = `${date.getHours()}`.padStart(2, '0')
    const minutes = `${date.getMinutes()}`.padStart(2, '0')
    return `${month}-${day} ${hours}:${minutes}`
}