From e24c81afde61a58d297f4b378fae95fe3385984e Mon Sep 17 00:00:00 2001
From: 13404089107 <puwei@sinata.cn>
Date: 星期五, 24 四月 2026 17:34:17 +0800
Subject: [PATCH] Merge branch 'master' of http://120.76.84.145:10101/gitblit/r/java/tcm-training

---
 traditionalApp/pages/home/home.vue                                                                                    |    3 
 traditionalApp/pages/mine/mine.vue                                                                                    |   92 --
 doc/nginx-tcm-training.conf                                                                                           |   17 
 traditionalApp/pages/watchLive/watchLive.vue                                                                          |   10 
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/vo/AppShareQrcodeVO.java                    |    2 
 traditionalApp/pages/courseDetail/courseDetail.vue                                                                    |  565 ++-----------
 traditionalApp/pages/downloadPlayer/downloadPlayer.vue                                                                |  351 +++++++++
 traditionalApp/App.vue                                                                                                |   10 
 traditionalApp/pages/myCourseDetail/myCourseDetail.vue                                                                |  183 ++--
 traditionalApp/pages/inviteUser/inviteUser.vue                                                                        |   10 
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/live/support/AppLiveCourseDisplaySupport.java     |   62 +
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/LiveSessionStatusSyncService.java        |    2 
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/controller/OpenAppShareController.java      |   45 +
 traditionalApp/utils/video-security.js                                                                                |  102 ++
 tcm-training-admin-backend/src/test/java/com/tcm/training/admin/system/service/ActivityNoticeAppServiceTest.java      |   13 
 traditionalApp/utils/course-download.js                                                                               |  175 ++++
 tcm-training-admin-backend/src/main/resources/application.yml                                                         |    8 
 tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/live/service/AppLiveCourseServiceTest.java        |   83 ++
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/live/service/AppLiveCourseService.java            |   53 +
 traditionalApp/utils/utils.js                                                                                         |   91 +
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/service/AppShareService.java                |   42 +
 traditionalApp/pages/downloads/downloads.vue                                                                          |   72 +
 tcm-training-admin-backend/src/test/java/com/tcm/training/admin/system/service/ActivityNoticeDispatchServiceTest.java |  157 ++++
 traditionalApp/utils/download-task-storage.js                                                                         |   79 ++
 traditionalApp/pages/register/register.vue                                                                            |   13 
 traditionalApp/utils/app-user.js                                                                                      |   14 
 traditionalApp/pages.json                                                                                             |   12 
 tcm-training-admin-backend/src/main/resources/mapper/LiveSessionQuizRecordMapper.xml                                  |   12 
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/LiveSessionQuizRecordMapper.java          |    8 
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/service/ActivityNoticeDispatchService.java     |   49 
 30 files changed, 1,552 insertions(+), 783 deletions(-)

diff --git a/doc/nginx-tcm-training.conf b/doc/nginx-tcm-training.conf
index 5b7048d..3a96e38 100644
--- a/doc/nginx-tcm-training.conf
+++ b/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/。
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/live/service/AppLiveCourseService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/live/service/AppLiveCourseService.java
index 9e219a0..b81e292 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/live/service/AppLiveCourseService.java
+++ b/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) {
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/live/support/AppLiveCourseDisplaySupport.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/live/support/AppLiveCourseDisplaySupport.java
index e51bf95..dd1958e 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/live/support/AppLiveCourseDisplaySupport.java
+++ b/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) {
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/controller/OpenAppShareController.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/controller/OpenAppShareController.java
new file mode 100644
index 0000000..7862c4f
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/controller/OpenAppShareController.java
@@ -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);
+    }
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/service/AppShareService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/service/AppShareService.java
index 2aef462..0877e3e 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/service/AppShareService.java
+++ b/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) {
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/vo/AppShareQrcodeVO.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/vo/AppShareQrcodeVO.java
index 1946c9f..fee74a6 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/vo/AppShareQrcodeVO.java
+++ b/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;
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/LiveSessionQuizRecordMapper.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/LiveSessionQuizRecordMapper.java
index 583f042..ce4f597 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/LiveSessionQuizRecordMapper.java
+++ b/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);
 }
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/LiveSessionStatusSyncService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/LiveSessionStatusSyncService.java
index d322a1d..67a38ee 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/LiveSessionStatusSyncService.java
+++ b/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>()
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/service/ActivityNoticeDispatchService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/service/ActivityNoticeDispatchService.java
index 0addd31..ff411d2 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/system/service/ActivityNoticeDispatchService.java
+++ b/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) {
diff --git a/tcm-training-admin-backend/src/main/resources/application.yml b/tcm-training-admin-backend/src/main/resources/application.yml
index 7d703b6..8b3116e 100644
--- a/tcm-training-admin-backend/src/main/resources/application.yml
+++ b/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:
diff --git a/tcm-training-admin-backend/src/main/resources/mapper/LiveSessionQuizRecordMapper.xml b/tcm-training-admin-backend/src/main/resources/mapper/LiveSessionQuizRecordMapper.xml
index 49f7b3d..19c1063 100644
--- a/tcm-training-admin-backend/src/main/resources/mapper/LiveSessionQuizRecordMapper.xml
+++ b/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>
diff --git a/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/live/service/AppLiveCourseServiceTest.java b/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/live/service/AppLiveCourseServiceTest.java
index 838d93a..193b5ad 100644
--- a/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/live/service/AppLiveCourseServiceTest.java
+++ b/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) {
diff --git a/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/system/service/ActivityNoticeAppServiceTest.java b/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/system/service/ActivityNoticeAppServiceTest.java
index aa0fec2..4cb3e37 100644
--- a/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/system/service/ActivityNoticeAppServiceTest.java
+++ b/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());
     }
 
diff --git a/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/system/service/ActivityNoticeDispatchServiceTest.java b/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/system/service/ActivityNoticeDispatchServiceTest.java
new file mode 100644
index 0000000..2797166
--- /dev/null
+++ b/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/system/service/ActivityNoticeDispatchServiceTest.java
@@ -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;
+    }
+}
diff --git a/traditionalApp/App.vue b/traditionalApp/App.vue
index 8412324..b5a8046 100644
--- a/traditionalApp/App.vue
+++ b/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">
diff --git a/traditionalApp/pages.json b/traditionalApp/pages.json
index 62515ff..0595237 100644
--- a/traditionalApp/pages.json
+++ b/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",
diff --git a/traditionalApp/pages/courseDetail/courseDetail.vue b/traditionalApp/pages/courseDetail/courseDetail.vue
index b3a7342..f9e5f84 100644
--- a/traditionalApp/pages/courseDetail/courseDetail.vue
+++ b/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 {
diff --git a/traditionalApp/pages/downloadPlayer/downloadPlayer.vue b/traditionalApp/pages/downloadPlayer/downloadPlayer.vue
new file mode 100644
index 0000000..36a49f4
--- /dev/null
+++ b/traditionalApp/pages/downloadPlayer/downloadPlayer.vue
@@ -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>
diff --git a/traditionalApp/pages/downloads/downloads.vue b/traditionalApp/pages/downloads/downloads.vue
index 47e3870..c7262c7 100644
--- a/traditionalApp/pages/downloads/downloads.vue
+++ b/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()
 }
 
 /**
diff --git a/traditionalApp/pages/home/home.vue b/traditionalApp/pages/home/home.vue
index d64d5f7..04837d1 100644
--- a/traditionalApp/pages/home/home.vue
+++ b/traditionalApp/pages/home/home.vue
@@ -427,9 +427,6 @@
 		}
 
 		if (currentBanner.jumpType === 'MEMBER') {
-			uni.navigateTo({
-				url: '/pages/vip/vip'
-			});
 			return;
 		}
 
diff --git a/traditionalApp/pages/inviteUser/inviteUser.vue b/traditionalApp/pages/inviteUser/inviteUser.vue
index 2277451..33e5073 100644
--- a/traditionalApp/pages/inviteUser/inviteUser.vue
+++ b/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)
 		}
diff --git a/traditionalApp/pages/mine/mine.vue b/traditionalApp/pages/mine/mine.vue
index 357421e..a74b264 100644
--- a/traditionalApp/pages/mine/mine.vue
+++ b/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)
 }
 
 /**
diff --git a/traditionalApp/pages/myCourseDetail/myCourseDetail.vue b/traditionalApp/pages/myCourseDetail/myCourseDetail.vue
index 32dd774..fcec3de 100644
--- a/traditionalApp/pages/myCourseDetail/myCourseDetail.vue
+++ b/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()
 }
 
 /**
diff --git a/traditionalApp/pages/register/register.vue b/traditionalApp/pages/register/register.vue
index cb6dccd..0e15cd2 100644
--- a/traditionalApp/pages/register/register.vue
+++ b/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 ''
 	}
diff --git a/traditionalApp/pages/watchLive/watchLive.vue b/traditionalApp/pages/watchLive/watchLive.vue
index 538ebbf..0cfd6ce 100644
--- a/traditionalApp/pages/watchLive/watchLive.vue
+++ b/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 '直播视频准备中'
 	}
diff --git a/traditionalApp/utils/app-user.js b/traditionalApp/utils/app-user.js
new file mode 100644
index 0000000..a85ea66
--- /dev/null
+++ b/traditionalApp/utils/app-user.js
@@ -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
+}
diff --git a/traditionalApp/utils/course-download.js b/traditionalApp/utils/course-download.js
new file mode 100644
index 0000000..813801a
--- /dev/null
+++ b/traditionalApp/utils/course-download.js
@@ -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
+}
diff --git a/traditionalApp/utils/download-task-storage.js b/traditionalApp/utils/download-task-storage.js
index 4a13c98..f3eb68f 100644
--- a/traditionalApp/utils/download-task-storage.js
+++ b/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()
+			},
+		})
+	})
+}
diff --git a/traditionalApp/utils/utils.js b/traditionalApp/utils/utils.js
index 327753d..6bf4230 100644
--- a/traditionalApp/utils/utils.js
+++ b/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 ''
 	}
diff --git a/traditionalApp/utils/video-security.js b/traditionalApp/utils/video-security.js
new file mode 100644
index 0000000..baaa2f3
--- /dev/null
+++ b/traditionalApp/utils/video-security.js
@@ -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}`
+}

--
Gitblit v1.7.1