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