zjk
2026-09-23 fb33ef7ea01af3e59178b8437f3ecf8d76b2b32c
fix: 修复直播时间精度问题并更新APP与H5配置

后端直播服务:截断开播时间至秒级精度,避免数据库纳秒进位引发异常状态
更新应用版本号从100到147,配置H5路由基础路径为/share/
导出iOS App Store链接常量供全局使用
为H5端添加iOS浏览器直接跳转App Store的逻辑
临时注释课程详情页的只读模式章节点击限制
新增参数化测试覆盖时间精度边界场景
5个文件已修改
134 ■■■■■ 已修改文件
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/LiveSessionAppService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/live/service/LiveSessionAppServiceTest.java 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/manifest.json 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/courseDetail/courseDetail.vue 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/utils/app-version.js 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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());
        }
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) {
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
        }
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',
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。