From fb33ef7ea01af3e59178b8437f3ecf8d76b2b32c Mon Sep 17 00:00:00 2001
From: zjk <852185829@qq.com>
Date: 星期三, 23 九月 2026 14:52:45 +0800
Subject: [PATCH] fix: 修复直播时间精度问题并更新APP与H5配置

---
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/LiveSessionAppService.java     |    8 +-
 traditionalApp/utils/app-version.js                                                                         |    2 
 tcm-training-admin-backend/src/test/java/com/tcm/training/admin/live/service/LiveSessionAppServiceTest.java |   99 +++++++++++++++++++++++++++++---
 traditionalApp/pages/courseDetail/courseDetail.vue                                                          |   20 +++++-
 traditionalApp/manifest.json                                                                                |    5 +
 5 files changed, 115 insertions(+), 19 deletions(-)

diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/LiveSessionAppService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/LiveSessionAppService.java
index 1add5bb..8029317 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/LiveSessionAppService.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/LiveSessionAppService.java
@@ -323,8 +323,10 @@
             return null;
         }
 
+        // DATETIME 仅保存秒;写库前截断,避免进位后被误判为未来开播并立即结束。
+        LocalDateTime persistedStartTime = actualStartTime.withNano(0);
         // 使用带状态条件的更新语句保证人工开播与定时开播并发时仍然幂等。
-        int affected = liveSessionMapper.markLivingIfPending(session.getId(), actualStartTime);
+        int affected = liveSessionMapper.markLivingIfPending(session.getId(), persistedStartTime);
         if (affected <= 0) {
             if (failWhenInvalid) {
                 throw new IllegalArgumentException("场次状态不允许开播");
@@ -332,12 +334,12 @@
             return null;
         }
 
-        reconcileCourseLivingSessions(session.getLiveCourseId(), actualStartTime);
+        reconcileCourseLivingSessions(session.getLiveCourseId(), persistedStartTime);
         LiveSession startedSession = liveSessionMapper.selectById(session.getId());
         if (startedSession == null) {
             session.setLiveStatus(1);
             if (session.getActualStartTime() == null) {
-                session.setActualStartTime(actualStartTime);
+                session.setActualStartTime(persistedStartTime);
             }
             return new LiveSessionStatusVO(session.getId(), session.getLiveStatus(), session.getActualStartTime(), session.getActualEndTime());
         }
diff --git a/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/live/service/LiveSessionAppServiceTest.java b/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/live/service/LiveSessionAppServiceTest.java
index dec15c5..0f2dc2e 100644
--- a/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/live/service/LiveSessionAppServiceTest.java
+++ b/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/live/service/LiveSessionAppServiceTest.java
@@ -18,6 +18,8 @@
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.mockito.ArgumentCaptor;
 import org.mockito.Captor;
 import org.mockito.Mock;
@@ -230,26 +232,36 @@
     }
 
     /**
-     * 同课程时间线已推进到后续场次时,应将历史残留的直播中场次收敛为已结束。
+     * 仅后续直播中场次能替换当前直播,已结束的历史记录不能挤掉它。
      */
-    @Test
-    void reconcileCourseLivingSessionsShouldCloseHistoricalLivingSession() {
+    @ParameterizedTest
+    @ValueSource(ints = {1, 2})
+    void reconcileCourseLivingSessionsShouldOnlyCloseForNewerLivingSession(int latestStatus) {
         LiveSession staleLivingSession = new LiveSession();
         staleLivingSession.setId(90001L);
         staleLivingSession.setLiveCourseId(9000L);
         staleLivingSession.setLiveStatus(1);
         staleLivingSession.setStartTime(LocalDateTime.of(2026, 4, 8, 10, 0));
 
-        LiveSession latestEndedSession = new LiveSession();
-        latestEndedSession.setId(90002L);
-        latestEndedSession.setLiveCourseId(9000L);
-        latestEndedSession.setLiveStatus(2);
-        latestEndedSession.setStartTime(LocalDateTime.of(2026, 4, 9, 10, 0));
-        latestEndedSession.setActualEndTime(LocalDateTime.of(2026, 4, 9, 10, 30));
+        LiveSession latestSession = new LiveSession();
+        latestSession.setId(90002L);
+        latestSession.setLiveCourseId(9000L);
+        latestSession.setLiveStatus(latestStatus);
+        latestSession.setStartTime(LocalDateTime.of(2026, 4, 9, 10, 0));
+        if (latestStatus == 2) {
+            latestSession.setActualEndTime(LocalDateTime.of(2026, 4, 9, 10, 30));
+        }
 
-        when(liveSessionMapper.selectList(any())).thenReturn(Arrays.asList(staleLivingSession, latestEndedSession));
+        when(liveSessionMapper.selectList(any())).thenReturn(Arrays.asList(staleLivingSession, latestSession));
 
-        assertEquals(1, liveSessionAppService.reconcileCourseLivingSessions(9000L, LocalDateTime.of(2026, 4, 9, 10, 35)));
+        int repairedCount = liveSessionAppService.reconcileCourseLivingSessions(
+                9000L, LocalDateTime.of(2026, 4, 9, 10, 35));
+        if (latestStatus == 2) {
+            assertEquals(0, repairedCount);
+            verify(liveSessionMapper, never()).updateById(any(LiveSession.class));
+            return;
+        }
+        assertEquals(1, repairedCount);
         verify(liveSessionMapper).updateById(org.mockito.ArgumentMatchers.argThat(session ->
                 session != null
                         && Long.valueOf(90001L).equals(session.getId())
@@ -258,6 +270,71 @@
     }
 
     /**
+     * 模拟 DATETIME 按秒四舍五入,覆盖半秒边界及跨秒、跨日进位。
+     */
+    @ParameterizedTest
+    @ValueSource(ints = {0, 100000000, 499999999, 500000000, 800000000, 999999999})
+    void autoStartShouldRemainLivingWhenDatabaseRoundsTime(int nanos) {
+        LiveSession session = mockPendingSessionWithDatabaseRounding();
+        LocalDateTime startTime = LocalDateTime.of(2026, 9, 22, 23, 59, 59, nanos);
+        session.setStartTime(startTime.withNano(0));
+
+        LiveSessionStatusVO result = liveSessionAppService.autoStart(session.getId(), startTime);
+
+        assertEquals(Integer.valueOf(1), result.getLiveStatus());
+        assertEquals(startTime.withNano(0), result.getActualStartTime());
+        assertNull(result.getActualEndTime());
+        verify(liveSessionMapper, never()).updateById(any(LiveSession.class));
+        verify(liveSessionMapper).markLivingIfPending(session.getId(), startTime.withNano(0));
+    }
+
+    /**
+     * 人工提前开播也应经过相同的秒级归一化,而不是被未来的计划时间误结束。
+     */
+    @Test
+    void manualStartShouldUseSecondPrecisionBeforePlannedTime() {
+        LiveSession session = mockPendingSessionWithDatabaseRounding();
+        session.setStartTime(LocalDateTime.now().plusHours(1));
+        LiveCourse course = new LiveCourse();
+        course.setId(session.getLiveCourseId());
+        when(liveCourseMapper.selectById(course.getId())).thenReturn(course);
+
+        LiveSessionStatusVO result = liveSessionAppService.start(course.getId(), session.getId());
+
+        ArgumentCaptor<LocalDateTime> timeCaptor = ArgumentCaptor.forClass(LocalDateTime.class);
+        verify(liveSessionMapper).markLivingIfPending(
+                org.mockito.ArgumentMatchers.eq(session.getId()), timeCaptor.capture());
+        assertEquals(0, timeCaptor.getValue().getNano());
+        assertEquals(Integer.valueOf(1), result.getLiveStatus());
+        assertNull(result.getActualEndTime());
+        verify(liveSessionMapper, never()).updateById(any(LiveSession.class));
+    }
+
+    private LiveSession mockPendingSessionWithDatabaseRounding() {
+        LiveSession session = new LiveSession();
+        session.setId(90008L);
+        session.setLiveCourseId(9000L);
+        session.setLiveStatus(0);
+        session.setDurationSeconds(385);
+        when(liveSessionMapper.selectById(session.getId())).thenReturn(session);
+        when(liveSessionMapper.selectList(any())).thenReturn(Collections.singletonList(session));
+        when(liveSessionMapper.markLivingIfPending(
+                org.mockito.ArgumentMatchers.eq(session.getId()), any(LocalDateTime.class)))
+                .thenAnswer(invocation -> {
+                    LocalDateTime input = invocation.getArgument(1);
+                    // 保留与 MySQL 秒精度写入一致的进位模型,不依赖真实数据库。
+                    LocalDateTime persisted = input.withNano(0);
+                    if (input.getNano() >= 500000000) {
+                        persisted = persisted.plusSeconds(1);
+                    }
+                    session.setActualStartTime(persisted);
+                    session.setLiveStatus(1);
+                    return 1;
+                });
+        return session;
+    }
+
+    /**
      * 构造题目请求。
      */
     private LiveSessionQuestionRequest buildQuestion(int sort, String stem) {
diff --git a/traditionalApp/manifest.json b/traditionalApp/manifest.json
index 05fcbb4..a8a0802 100644
--- a/traditionalApp/manifest.json
+++ b/traditionalApp/manifest.json
@@ -4,7 +4,7 @@
     "appid" : "__UNI__33EC1E8",
     "description" : "",
     "versionName" : "1.0.47",
-    "versionCode" : 100,
+    "versionCode" : 147,
     "transformPx" : false,
     /* 5+App特有相关 */
     "app-plus" : {
@@ -367,6 +367,9 @@
         }
     },
     "h5" : {
+        "router" : {
+            "base" : "/share/"
+        },
         "unipush" : {
             "enable" : false
         }
diff --git a/traditionalApp/pages/courseDetail/courseDetail.vue b/traditionalApp/pages/courseDetail/courseDetail.vue
index 56c9c18..6a17b13 100644
--- a/traditionalApp/pages/courseDetail/courseDetail.vue
+++ b/traditionalApp/pages/courseDetail/courseDetail.vue
@@ -328,6 +328,7 @@
 	import { requestAppleIapPayment } from '@/utils/appleIap.js'
 	import { resolveCurrentAppUserId } from '@/utils/app-user.js'
 	import { wakeupOrInstallByOpeninstall } from '@/utils/openinstall-h5.js'
+        import { IOS_APP_STORE_URL } from '@/utils/app-version.js'
 	import {
 		bindVideoCaptureListener,
 		buildVideoWatermarkText,
@@ -874,9 +875,10 @@
 	 * 统一处理章节点击,可播放时切换顶部播放器,否则保留原有购买弹窗。
 	 */
 	function handleVideoSelect(item : CourseVideo) {
-		if (isReadonlyDisplayMode) {
-			return
-		}
+                // 暂时放开 H5 章节点击,仍由 canPlayVideo 校验观看权限。
+                // if (isReadonlyDisplayMode) {
+                //         return
+                // }
 		if (canPlayVideo(item)) {
 			playSelectedVideo(item)
 			return
@@ -1065,6 +1067,14 @@
 	 */
 	async function openApp() {
 		// #ifdef H5
+                // iOS 直接在点击事件内跳转,避免异步 SDK 调用影响商店跳转。
+                const userAgent = window.navigator.userAgent
+                const isIosBrowser = /iPad|iPhone|iPod/i.test(userAgent)
+                        || (/Macintosh/i.test(userAgent) && window.navigator.maxTouchPoints > 1)
+                if (isIosBrowser) {
+                        window.location.assign(IOS_APP_STORE_URL)
+                        return
+                }
 		const openinstallOpened = await wakeupOrInstallByOpeninstall({
 			page: 'courseDetail',
 			courseId: String(resolveCurrentCourseId() || '').trim()
@@ -1072,6 +1082,10 @@
 		if (openinstallOpened) {
 			return
 		}
+                if (config.OPENINSTALL_URL) {
+                        window.location.assign(config.OPENINSTALL_URL)
+                        return
+                }
 		uni.showToast({
 			title: '打开APP失败,请稍后重试',
 			icon: 'none',
diff --git a/traditionalApp/utils/app-version.js b/traditionalApp/utils/app-version.js
index 98f9493..a9cee08 100644
--- a/traditionalApp/utils/app-version.js
+++ b/traditionalApp/utils/app-version.js
@@ -1,7 +1,7 @@
 import request from '@/utils/request.js'
 
 let hasCheckedVersionThisLaunch = false
-const IOS_APP_STORE_URL = 'https://apps.apple.com/cn/app/%E6%B5%B7%E8%AF%8A%E5%BA%B7%E9%81%93/id6762694057'
+export const IOS_APP_STORE_URL = 'https://apps.apple.com/cn/app/%E6%B5%B7%E8%AF%8A%E5%BA%B7%E9%81%93/id6762694057'
 
 /**
  * 版本检查接口要求的平台字段:iOS 传 ios,Android 传 Android。

--
Gitblit v1.7.1