package com.tcm.training.admin.live.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.tcm.training.admin.common.DateTimes; import com.tcm.training.admin.common.PageResponse; import com.tcm.training.admin.live.dto.LiveSessionQuestionRequest; import com.tcm.training.admin.live.dto.LiveSessionUpsertRequest; import com.tcm.training.admin.live.entity.LiveCourse; import com.tcm.training.admin.live.entity.LiveSession; import com.tcm.training.admin.live.entity.LiveSessionQuestion; import com.tcm.training.admin.live.entity.LiveReserve; import com.tcm.training.admin.live.mapper.LiveCourseMapper; import com.tcm.training.admin.live.mapper.LiveReserveMapper; import com.tcm.training.admin.live.mapper.LiveSessionMapper; import com.tcm.training.admin.live.mapper.LiveSessionQuestionMapper; import com.tcm.training.admin.live.vo.LivePushNotificationVO; import com.tcm.training.admin.live.vo.LiveSessionQuestionVO; import com.tcm.training.admin.live.vo.LiveSessionStatusVO; import com.tcm.training.admin.live.vo.LiveSessionVO; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @Service public class LiveSessionAppService { private final LiveCourseMapper liveCourseMapper; private final LiveSessionMapper liveSessionMapper; private final LiveSessionQuestionMapper liveSessionQuestionMapper; private final LiveReserveMapper liveReserveMapper; private final LiveRemindDispatchService liveRemindDispatchService; public LiveSessionAppService(LiveCourseMapper liveCourseMapper, LiveSessionMapper liveSessionMapper, LiveSessionQuestionMapper liveSessionQuestionMapper, LiveReserveMapper liveReserveMapper, LiveRemindDispatchService liveRemindDispatchService) { this.liveCourseMapper = liveCourseMapper; this.liveSessionMapper = liveSessionMapper; this.liveSessionQuestionMapper = liveSessionQuestionMapper; this.liveReserveMapper = liveReserveMapper; this.liveRemindDispatchService = liveRemindDispatchService; } public PageResponse pageSessions(Long courseId, int page, int pageSize, String startTimeFrom, String startTimeTo, Integer liveStatus) { requireCourse(courseId); LambdaQueryWrapper qw = new LambdaQueryWrapper() .eq(LiveSession::getLiveCourseId, courseId); LocalDateTime from = DateTimes.parse(startTimeFrom); LocalDateTime to = DateTimes.parse(startTimeTo); if (from != null) { qw.ge(LiveSession::getStartTime, from); } if (to != null) { qw.le(LiveSession::getStartTime, to); } if (liveStatus != null) { qw.eq(LiveSession::getLiveStatus, liveStatus); } qw.orderByDesc(LiveSession::getStartTime).orderByDesc(LiveSession::getId); IPage p = liveSessionMapper.selectPage(new Page(page, pageSize), qw); List sessions = p.getRecords(); if (sessions == null || sessions.isEmpty()) { return PageResponse.of(Collections.emptyList(), p.getTotal(), p.getCurrent(), p.getSize()); } List sessionIds = new ArrayList(); for (LiveSession s : sessions) { sessionIds.add(s.getId()); } List qs = liveSessionQuestionMapper.selectList(new LambdaQueryWrapper() .in(LiveSessionQuestion::getSessionId, sessionIds) .orderByAsc(LiveSessionQuestion::getSort)); Map> map = new HashMap>(); for (LiveSessionQuestion q : qs) { List list = map.get(q.getSessionId()); if (list == null) { list = new ArrayList(); map.put(q.getSessionId(), list); } LiveSessionQuestionVO qvo = new LiveSessionQuestionVO(); qvo.setId(q.getId()); qvo.setSort(q.getSort()); qvo.setStem(q.getStem()); qvo.setOptionA(q.getOptionA()); qvo.setOptionB(q.getOptionB()); qvo.setOptionC(q.getOptionC()); qvo.setCorrectOption(q.getCorrectOption()); list.add(qvo); } List list = new ArrayList(); int baseSort = (page - 1) * pageSize; for (int i = 0; i < sessions.size(); i++) { LiveSession s = sessions.get(i); LiveSessionVO vo = new LiveSessionVO(); vo.setId(s.getId()); vo.setSort(baseSort + i + 1); vo.setStartTime(s.getStartTime()); vo.setVideoUrl(s.getVideoUrl()); vo.setDurationSeconds(s.getDurationSeconds()); vo.setLiveStatus(s.getLiveStatus()); vo.setBaseReserveCount(s.getBaseReserveCount()); vo.setBaseWatchCount(s.getBaseWatchCount()); vo.setRealReserveCount(s.getRealReserveCount()); vo.setRealWatchCount(s.getRealWatchCount()); vo.setReserveConvertRate(s.getReserveConvertRate()); vo.setCompletionRate(s.getCompletionRate()); vo.setEffectiveWatchRate(s.getEffectiveWatchRate()); vo.setQuizCorrectCount(s.getQuizCorrectCount()); vo.setPointsIssued(s.getPointsIssued()); vo.setQuestions(map.get(s.getId())); list.add(vo); } return PageResponse.of(list, p.getTotal(), p.getCurrent(), p.getSize()); } @Transactional public LiveSessionVO createSession(Long courseId, LiveSessionUpsertRequest req) { requireCourse(courseId); validateStartTime(req.getStartTime()); validateQuestions(req.getQuestions()); LiveSession s = new LiveSession(); s.setLiveCourseId(courseId); s.setStartTime(req.getStartTime()); s.setVideoUrl(req.getVideoUrl()); s.setDurationSeconds(req.getDurationSeconds()); s.setBaseReserveCount(safeInt(req.getBaseReserveCount())); s.setBaseWatchCount(safeInt(req.getBaseWatchCount())); s.setRealReserveCount(0); s.setRealWatchCount(0); s.setQuizCorrectCount(0); s.setPointsIssued(0); s.setLiveStatus(0); liveSessionMapper.insert(s); upsertQuestions(s.getId(), req.getQuestions()); return getSessionVo(s.getId(), 1); } @Transactional public LiveSessionVO updateSession(Long courseId, Long sessionId, LiveSessionUpsertRequest req) { LiveSession s = requireSession(courseId, sessionId); if (s.getLiveStatus() != null && s.getLiveStatus() != 0) { throw new IllegalArgumentException("仅允许修改未开始的场次"); } validateStartTime(req.getStartTime()); validateQuestions(req.getQuestions()); s.setStartTime(req.getStartTime()); s.setVideoUrl(req.getVideoUrl()); s.setDurationSeconds(req.getDurationSeconds()); s.setBaseReserveCount(safeInt(req.getBaseReserveCount())); s.setBaseWatchCount(safeInt(req.getBaseWatchCount())); liveSessionMapper.updateById(s); // 场次题目属于内嵌配置数据,编辑时直接物理删除后重建,避免逻辑删除记录占用唯一索引。 liveSessionQuestionMapper.hardDeleteBySessionId(sessionId); upsertQuestions(sessionId, req.getQuestions()); return getSessionVo(sessionId, 1); } @Transactional public LiveSessionVO copySession(Long courseId, Long sessionId) { LiveSession src = requireSession(courseId, sessionId); LiveSession dst = new LiveSession(); dst.setLiveCourseId(courseId); dst.setStartTime(null); dst.setVideoUrl(null); dst.setDurationSeconds(src.getDurationSeconds()); dst.setBaseReserveCount(safeInt(src.getBaseReserveCount())); dst.setBaseWatchCount(safeInt(src.getBaseWatchCount())); dst.setRealReserveCount(0); dst.setRealWatchCount(0); dst.setQuizCorrectCount(0); dst.setPointsIssued(0); dst.setLiveStatus(0); liveSessionMapper.insert(dst); List qs = liveSessionQuestionMapper.selectList(new LambdaQueryWrapper() .eq(LiveSessionQuestion::getSessionId, sessionId) .orderByAsc(LiveSessionQuestion::getSort)); if (qs != null && !qs.isEmpty()) { for (LiveSessionQuestion q : qs) { LiveSessionQuestion nq = new LiveSessionQuestion(); nq.setSessionId(dst.getId()); nq.setSort(q.getSort()); nq.setStem(q.getStem()); nq.setOptionA(q.getOptionA()); nq.setOptionB(q.getOptionB()); nq.setOptionC(q.getOptionC()); nq.setCorrectOption(q.getCorrectOption()); liveSessionQuestionMapper.insert(nq); } } return getSessionVo(dst.getId(), 1); } @Transactional public void deleteSession(Long courseId, Long sessionId) { requireSession(courseId, sessionId); liveSessionMapper.deleteById(sessionId); } /** * 手动将指定课程场次切换为直播中。 */ @Transactional public LiveSessionStatusVO start(Long courseId, Long sessionId) { LiveSession session = requireSession(courseId, sessionId); return markSessionLiving(session, LocalDateTime.now(), true); } /** * 定时任务将已到开播时间的场次切换为直播中。 */ @Transactional public LiveSessionStatusVO autoStart(Long sessionId, LocalDateTime actualStartTime) { LiveSession session = liveSessionMapper.selectById(sessionId); LocalDateTime resolvedStartTime = actualStartTime == null ? LocalDateTime.now() : actualStartTime; return markSessionLiving(session, resolvedStartTime, false); } /** * 定时任务将已到结束时间的直播中场次切换为已结束。 */ @Transactional public LiveSessionStatusVO autoEnd(Long sessionId, LocalDateTime actualEndTime) { LiveSession session = liveSessionMapper.selectById(sessionId); LocalDateTime resolvedEndTime = actualEndTime == null ? LocalDateTime.now() : actualEndTime; return markSessionEnded(session, resolvedEndTime, false); } /** * 手动结束正在直播中的场次。 */ @Transactional public LiveSessionStatusVO end(Long courseId, Long sessionId) { LiveSession s = requireSession(courseId, sessionId); return markSessionEnded(s, LocalDateTime.now(), true); } /** * 收敛同课程的直播中场次,只保留时间线上当前有效的一场。 */ @Transactional public int reconcileCourseLivingSessions(Long courseId, LocalDateTime reconcileTime) { if (courseId == null) { return 0; } LocalDateTime resolvedReconcileTime = reconcileTime == null ? LocalDateTime.now() : reconcileTime; List sessions = liveSessionMapper.selectList(new LambdaQueryWrapper() .eq(LiveSession::getLiveCourseId, courseId) .eq(LiveSession::getDeleted, 0) .orderByAsc(LiveSession::getStartTime) .orderByAsc(LiveSession::getId)); if (sessions == null || sessions.isEmpty()) { return 0; } LiveSession effectiveLivingSession = resolveEffectiveLivingSession(sessions, resolvedReconcileTime); int repairedCount = 0; for (LiveSession session : sessions) { if (session == null || safeInt(session.getLiveStatus()) != 1) { continue; } if (effectiveLivingSession != null && effectiveLivingSession.getId() != null && effectiveLivingSession.getId().equals(session.getId())) { continue; } // 同课程历史直播中残留数据会污染 APP 展示态,这里统一收敛为已结束。 session.setLiveStatus(2); if (session.getActualEndTime() == null) { session.setActualEndTime(resolvedReconcileTime); } liveSessionMapper.updateById(session); repairedCount++; } return repairedCount; } public LivePushNotificationVO pushNotification(Long courseId, Long sessionId) { LiveSession session = requireSession(courseId, sessionId); if (session.getLiveStatus() != null && session.getLiveStatus() != 0) { throw new IllegalArgumentException("仅允许对未开始的场次推送开播提醒"); } Long cnt = liveReserveMapper.selectCount(new LambdaQueryWrapper() .eq(LiveReserve::getLiveCourseId, courseId) .eq(LiveReserve::getSessionId, sessionId) .eq(LiveReserve::getIsCancel, 0)); // 后台手动触发时直接对当前场次预约用户执行一次真正的通知发放。 liveRemindDispatchService.dispatchManualRemind(session); return new LivePushNotificationVO(cnt == null ? 0L : cnt, LocalDateTime.now()); } /** * 执行未开始场次到直播中的公共状态迁移。 */ private LiveSessionStatusVO markSessionLiving(LiveSession session, LocalDateTime actualStartTime, boolean failWhenInvalid) { if (session == null) { if (failWhenInvalid) { throw new IllegalArgumentException("场次不存在"); } return null; } // DATETIME 仅保存秒;写库前截断,避免进位后被误判为未来开播并立即结束。 LocalDateTime persistedStartTime = actualStartTime.withNano(0); // 使用带状态条件的更新语句保证人工开播与定时开播并发时仍然幂等。 int affected = liveSessionMapper.markLivingIfPending(session.getId(), persistedStartTime); if (affected <= 0) { if (failWhenInvalid) { throw new IllegalArgumentException("场次状态不允许开播"); } return null; } reconcileCourseLivingSessions(session.getLiveCourseId(), persistedStartTime); LiveSession startedSession = liveSessionMapper.selectById(session.getId()); if (startedSession == null) { session.setLiveStatus(1); if (session.getActualStartTime() == null) { session.setActualStartTime(persistedStartTime); } return new LiveSessionStatusVO(session.getId(), session.getLiveStatus(), session.getActualStartTime(), session.getActualEndTime()); } return new LiveSessionStatusVO( startedSession.getId(), startedSession.getLiveStatus(), startedSession.getActualStartTime(), startedSession.getActualEndTime() ); } /** * 执行直播中场次到已结束的公共状态迁移。 */ private LiveSessionStatusVO markSessionEnded(LiveSession session, LocalDateTime actualEndTime, boolean failWhenInvalid) { if (session == null) { if (failWhenInvalid) { throw new IllegalArgumentException("场次不存在"); } return null; } if (session.getLiveStatus() == null || session.getLiveStatus().intValue() != 1) { if (failWhenInvalid) { throw new IllegalArgumentException("场次状态不允许结束"); } return null; } LocalDateTime resolvedEndTime = actualEndTime == null ? LocalDateTime.now() : actualEndTime; session.setLiveStatus(2); session.setActualEndTime(resolvedEndTime); liveSessionMapper.updateById(session); reconcileCourseLivingSessions(session.getLiveCourseId(), resolvedEndTime); return new LiveSessionStatusVO(session.getId(), session.getLiveStatus(), session.getActualStartTime(), session.getActualEndTime()); } /** * 校验课程存在性。 */ private LiveCourse requireCourse(Long courseId) { LiveCourse c = liveCourseMapper.selectById(courseId); if (c == null) { throw new IllegalArgumentException("课程不存在"); } return c; } /** * 解析同课程时间线下当前有效的直播中场次。 */ private LiveSession resolveEffectiveLivingSession(List sessions, LocalDateTime now) { LiveSession latestLivingSession = null; for (LiveSession session : sessions) { if (session == null || safeInt(session.getLiveStatus()) != 1) { continue; } LocalDateTime timelineTime = resolveLivingTimelineTime(session); if (timelineTime == null || timelineTime.isAfter(now)) { continue; } if (latestLivingSession == null || compareTimelineSession(latestLivingSession, session) < 0) { latestLivingSession = session; } } return latestLivingSession; } /** * 比较两场已开始场次在课程时间线中的先后,开始时间更晚者优先。 */ private int compareTimelineSession(LiveSession left, LiveSession right) { int startTimeCompare = compareSessionTime( left == null ? null : resolveLivingTimelineTime(left), right == null ? null : resolveLivingTimelineTime(right) ); if (startTimeCompare != 0) { return startTimeCompare; } Long leftId = left == null ? null : left.getId(); Long rightId = right == null ? null : right.getId(); if (leftId == null && rightId == null) { return 0; } if (leftId == null) { return -1; } if (rightId == null) { return 1; } return leftId.compareTo(rightId); } /** * 直播中场次优先按实际开播时间参与收敛,手动提前开播时不会再被计划开播时间误伤。 */ private LocalDateTime resolveLivingTimelineTime(LiveSession session) { if (session == null) { return null; } if (session.getActualStartTime() != null) { return session.getActualStartTime(); } return session.getStartTime(); } /** * 比较两个时间值,null 视为更早的时间。 */ private int compareSessionTime(LocalDateTime left, LocalDateTime right) { if (left == null && right == null) { return 0; } if (left == null) { return -1; } if (right == null) { return 1; } return left.compareTo(right); } /** * 校验场次存在且属于指定课程。 */ private LiveSession requireSession(Long courseId, Long sessionId) { requireCourse(courseId); LiveSession s = liveSessionMapper.selectById(sessionId); if (s == null) { throw new IllegalArgumentException("场次不存在"); } if (!courseId.equals(s.getLiveCourseId())) { throw new IllegalArgumentException("场次不属于该课程"); } return s; } /** * 查询单个场次详情并组装题目数据。 */ private LiveSessionVO getSessionVo(Long sessionId, int sort) { LiveSession s = liveSessionMapper.selectById(sessionId); if (s == null) { throw new IllegalArgumentException("场次不存在"); } List qs = liveSessionQuestionMapper.selectList(new LambdaQueryWrapper() .eq(LiveSessionQuestion::getSessionId, sessionId) .orderByAsc(LiveSessionQuestion::getSort)); List qvos = new ArrayList(); for (LiveSessionQuestion q : qs) { LiveSessionQuestionVO qvo = new LiveSessionQuestionVO(); qvo.setId(q.getId()); qvo.setSort(q.getSort()); qvo.setStem(q.getStem()); qvo.setOptionA(q.getOptionA()); qvo.setOptionB(q.getOptionB()); qvo.setOptionC(q.getOptionC()); qvo.setCorrectOption(q.getCorrectOption()); qvos.add(qvo); } LiveSessionVO vo = new LiveSessionVO(); vo.setId(s.getId()); vo.setSort(sort); vo.setStartTime(s.getStartTime()); vo.setVideoUrl(s.getVideoUrl()); vo.setDurationSeconds(s.getDurationSeconds()); vo.setLiveStatus(s.getLiveStatus()); vo.setBaseReserveCount(s.getBaseReserveCount()); vo.setBaseWatchCount(s.getBaseWatchCount()); vo.setRealReserveCount(s.getRealReserveCount()); vo.setRealWatchCount(s.getRealWatchCount()); vo.setReserveConvertRate(s.getReserveConvertRate()); vo.setCompletionRate(s.getCompletionRate()); vo.setEffectiveWatchRate(s.getEffectiveWatchRate()); vo.setQuizCorrectCount(s.getQuizCorrectCount()); vo.setPointsIssued(s.getPointsIssued()); vo.setQuestions(qvos); return vo; } /** * 重建场次题目配置。 */ private void upsertQuestions(Long sessionId, List questions) { if (questions == null || questions.isEmpty()) { return; } for (LiveSessionQuestionRequest q : questions) { LiveSessionQuestion nq = new LiveSessionQuestion(); nq.setSessionId(sessionId); nq.setSort(q.getSort()); nq.setStem(q.getStem()); nq.setOptionA(q.getOptionA()); nq.setOptionB(q.getOptionB()); nq.setOptionC(q.getOptionC()); nq.setCorrectOption(q.getCorrectOption()); liveSessionQuestionMapper.insert(nq); } } /** * 校验题目配置满足固定三题的业务规则。 */ private void validateQuestions(List questions) { if (questions == null || questions.isEmpty()) { throw new IllegalArgumentException("题目数量必须为3道"); } if (questions.size() != 3) { throw new IllegalArgumentException("题目数量必须为3道"); } Set sortSet = new HashSet(); for (LiveSessionQuestionRequest q : questions) { if (q == null || q.getSort() == null) { continue; } if (!sortSet.add(q.getSort())) { throw new IllegalArgumentException("题目序号重复"); } } } /** * 校验开始直播时间不能早于当前时间。 */ private void validateStartTime(LocalDateTime startTime) { if (startTime == null) { return; } if (startTime.isBefore(LocalDateTime.now())) { throw new IllegalArgumentException("开始直播时间不能早于当前时间"); } } /** * 将可空整数安全转换为非空值。 */ private int safeInt(Integer v) { return v == null ? 0 : v; } }