| | |
| | | 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()); |
| | |
| | | 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.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.delete(new LambdaQueryWrapper<LiveSessionQuestion>() |
| | | .eq(LiveSessionQuestion::getSessionId, sessionId)); |
| | | // 场次题目属于内嵌配置数据,编辑时直接物理删除后重建,避免逻辑删除记录占用唯一索引。 |
| | | liveSessionQuestionMapper.hardDeleteBySessionId(sessionId); |
| | | upsertQuestions(sessionId, req.getQuestions()); |
| | | |
| | | return getSessionVo(sessionId, 1); |
| | |
| | | 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); |
| | |
| | | liveSessionMapper.deleteById(sessionId); |
| | | } |
| | | |
| | | /** |
| | | * 手动将指定课程场次切换为直播中。 |
| | | */ |
| | | @Transactional |
| | | public LiveSessionStatusVO start(Long courseId, Long sessionId) { |
| | | LiveSession s = requireSession(courseId, sessionId); |
| | | if (s.getLiveStatus() != null && s.getLiveStatus() != 0) { |
| | | throw new IllegalArgumentException("场次状态不允许开播"); |
| | | } |
| | | s.setLiveStatus(1); |
| | | s.setActualStartTime(LocalDateTime.now()); |
| | | liveSessionMapper.updateById(s); |
| | | return new LiveSessionStatusVO(s.getId(), s.getLiveStatus(), s.getActualStartTime(), null); |
| | | 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 end(Long courseId, Long sessionId) { |
| | | LiveSession s = requireSession(courseId, sessionId); |
| | |
| | | s.setLiveStatus(2); |
| | | s.setActualEndTime(LocalDateTime.now()); |
| | | liveSessionMapper.updateById(s); |
| | | reconcileCourseLivingSessions(s.getLiveCourseId(), s.getActualEndTime()); |
| | | return new LiveSessionStatusVO(s.getId(), s.getLiveStatus(), null, s.getActualEndTime()); |
| | | } |
| | | |
| | | /** |
| | | * 收敛同课程的直播中场次,只保留时间线上当前有效的一场。 |
| | | */ |
| | | @Transactional |
| | | public int reconcileCourseLivingSessions(Long courseId, LocalDateTime reconcileTime) { |
| | | if (courseId == null) { |
| | | return 0; |
| | | } |
| | | LocalDateTime resolvedReconcileTime = reconcileTime == null ? LocalDateTime.now() : reconcileTime; |
| | | List<LiveSession> sessions = liveSessionMapper.selectList(new LambdaQueryWrapper<LiveSession>() |
| | | .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) { |
| | |
| | | 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; |
| | | } |
| | | |
| | | // 使用带状态条件的更新语句保证人工开播与定时开播并发时仍然幂等。 |
| | | int affected = liveSessionMapper.markLivingIfPending(session.getId(), actualStartTime); |
| | | if (affected <= 0) { |
| | | if (failWhenInvalid) { |
| | | throw new IllegalArgumentException("场次状态不允许开播"); |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | reconcileCourseLivingSessions(session.getLiveCourseId(), actualStartTime); |
| | | LiveSession startedSession = liveSessionMapper.selectById(session.getId()); |
| | | if (startedSession == null) { |
| | | session.setLiveStatus(1); |
| | | if (session.getActualStartTime() == null) { |
| | | session.setActualStartTime(actualStartTime); |
| | | } |
| | | return new LiveSessionStatusVO(session.getId(), session.getLiveStatus(), session.getActualStartTime(), session.getActualEndTime()); |
| | | } |
| | | return new LiveSessionStatusVO( |
| | | startedSession.getId(), |
| | | startedSession.getLiveStatus(), |
| | | startedSession.getActualStartTime(), |
| | | startedSession.getActualEndTime() |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * 校验课程存在性。 |
| | | */ |
| | | private LiveCourse requireCourse(Long courseId) { |
| | | LiveCourse c = liveCourseMapper.selectById(courseId); |
| | | if (c == null) { |
| | |
| | | return c; |
| | | } |
| | | |
| | | /** |
| | | * 解析同课程时间线下当前有效的直播中场次。 |
| | | */ |
| | | private LiveSession resolveEffectiveLivingSession(List<LiveSession> sessions, LocalDateTime now) { |
| | | LiveSession latestStartedSession = null; |
| | | for (LiveSession session : sessions) { |
| | | if (session == null || session.getStartTime() == null || session.getStartTime().isAfter(now)) { |
| | | continue; |
| | | } |
| | | if (latestStartedSession == null || compareTimelineSession(latestStartedSession, session) < 0) { |
| | | latestStartedSession = session; |
| | | } |
| | | } |
| | | if (latestStartedSession == null || safeInt(latestStartedSession.getLiveStatus()) != 1) { |
| | | return null; |
| | | } |
| | | return latestStartedSession; |
| | | } |
| | | |
| | | /** |
| | | * 比较两场已开始场次在课程时间线中的先后,开始时间更晚者优先。 |
| | | */ |
| | | private int compareTimelineSession(LiveSession left, LiveSession right) { |
| | | int startTimeCompare = compareSessionTime(left == null ? null : left.getStartTime(), right == null ? null : right.getStartTime()); |
| | | 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); |
| | | } |
| | | |
| | | /** |
| | | * 比较两个时间值,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); |
| | |
| | | return s; |
| | | } |
| | | |
| | | /** |
| | | * 查询单个场次详情并组装题目数据。 |
| | | */ |
| | | private LiveSessionVO getSessionVo(Long sessionId, int sort) { |
| | | LiveSession s = liveSessionMapper.selectById(sessionId); |
| | | if (s == null) { |
| | |
| | | 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()); |
| | |
| | | return vo; |
| | | } |
| | | |
| | | /** |
| | | * 重建场次题目配置。 |
| | | */ |
| | | private void upsertQuestions(Long sessionId, List<LiveSessionQuestionRequest> questions) { |
| | | if (questions == null || questions.isEmpty()) { |
| | | return; |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 校验题目配置满足固定三题的业务规则。 |
| | | */ |
| | | private void validateQuestions(List<LiveSessionQuestionRequest> questions) { |
| | | if (questions == null || questions.isEmpty()) { |
| | | throw new IllegalArgumentException("题目数量必须为3道"); |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 校验开始直播时间不能早于当前时间。 |
| | | */ |
| | | private void validateStartTime(LocalDateTime startTime) { |
| | | if (startTime == null) { |
| | | return; |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 将可空整数安全转换为非空值。 |
| | | */ |
| | | private int safeInt(Integer v) { |
| | | return v == null ? 0 : v; |
| | | } |