package com.tcm.training.admin.app.live.service;
|
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
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.dto.AppLiveWatchRecordUploadRequest;
|
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.AppLiveExpertVO;
|
import com.tcm.training.admin.app.live.vo.AppLiveQuizQuestionVO;
|
import com.tcm.training.admin.app.live.vo.AppLiveQuizStatusVO;
|
import com.tcm.training.admin.app.live.vo.AppLiveQuizSubmitResultVO;
|
import com.tcm.training.admin.app.live.vo.AppLiveReserveResultVO;
|
import com.tcm.training.admin.app.live.vo.AppLiveWatchRecordUploadResultVO;
|
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.entity.LiveWatchRecord;
|
import com.tcm.training.admin.live.mapper.ExpertMapper;
|
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.mapper.LiveSessionQuizRecordMapper;
|
import com.tcm.training.admin.live.mapper.LiveWatchRecordMapper;
|
import com.tcm.training.admin.points.entity.UserPointsAccount;
|
import com.tcm.training.admin.points.mapper.UserPointsAccountMapper;
|
import com.tcm.training.admin.points.service.PointsLedgerAppService;
|
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;
|
import java.util.HashMap;
|
import java.util.HashSet;
|
import java.util.List;
|
import java.util.Map;
|
import java.util.Set;
|
|
/**
|
* APP 养生直播详情、答题与观看记录服务。
|
*/
|
@Service
|
public class AppLiveCourseService {
|
|
private static final int COURSE_STATUS_ENABLED = 1;
|
private static final int EXPERT_STATUS_ENABLED = 1;
|
private static final int QUIZ_QUESTION_COUNT = 3;
|
private static final long DETAIL_WATCH_KEEP_MINUTES = 5L;
|
private static final String ACTION_STATUS_RESERVABLE = "RESERVABLE";
|
private static final String ACTION_STATUS_RESERVED = "RESERVED";
|
private static final String ACTION_STATUS_WATCHABLE = "WATCHABLE";
|
private static final String ACTION_STATUS_CLOSED = "CLOSED";
|
|
private final LiveCourseMapper liveCourseMapper;
|
private final LiveSessionMapper liveSessionMapper;
|
private final LiveReserveMapper liveReserveMapper;
|
private final ExpertMapper expertMapper;
|
private final LiveSessionQuestionMapper liveSessionQuestionMapper;
|
private final LiveSessionQuizRecordMapper liveSessionQuizRecordMapper;
|
private final LiveWatchRecordMapper liveWatchRecordMapper;
|
private final UserPointsAccountMapper userPointsAccountMapper;
|
private final PointsLedgerAppService pointsLedgerAppService;
|
private final AppAuthSessionService appAuthSessionService;
|
private final AppLiveCourseDisplaySupport appLiveCourseDisplaySupport;
|
private final AppLiveExpertService appLiveExpertService;
|
|
/**
|
* 注入 APP 直播详情与答题所需依赖。
|
*/
|
public AppLiveCourseService(LiveCourseMapper liveCourseMapper,
|
LiveSessionMapper liveSessionMapper,
|
LiveReserveMapper liveReserveMapper,
|
ExpertMapper expertMapper,
|
LiveSessionQuestionMapper liveSessionQuestionMapper,
|
LiveSessionQuizRecordMapper liveSessionQuizRecordMapper,
|
LiveWatchRecordMapper liveWatchRecordMapper,
|
UserPointsAccountMapper userPointsAccountMapper,
|
PointsLedgerAppService pointsLedgerAppService,
|
AppAuthSessionService appAuthSessionService,
|
AppLiveCourseDisplaySupport appLiveCourseDisplaySupport,
|
AppLiveExpertService appLiveExpertService) {
|
this.liveCourseMapper = liveCourseMapper;
|
this.liveSessionMapper = liveSessionMapper;
|
this.liveReserveMapper = liveReserveMapper;
|
this.expertMapper = expertMapper;
|
this.liveSessionQuestionMapper = liveSessionQuestionMapper;
|
this.liveSessionQuizRecordMapper = liveSessionQuizRecordMapper;
|
this.liveWatchRecordMapper = liveWatchRecordMapper;
|
this.userPointsAccountMapper = userPointsAccountMapper;
|
this.pointsLedgerAppService = pointsLedgerAppService;
|
this.appAuthSessionService = appAuthSessionService;
|
this.appLiveCourseDisplaySupport = appLiveCourseDisplaySupport;
|
this.appLiveExpertService = appLiveExpertService;
|
}
|
|
/**
|
* 获取 APP 养生直播详情页数据。
|
*/
|
public AppLiveCourseDetailVO detail(Long courseId) {
|
LiveCourse course = requireCourse(courseId);
|
List<LiveSession> sessions = listCourseSessions(courseId);
|
LocalDateTime now = LocalDateTime.now();
|
AppLiveCourseDisplaySupport.CourseDisplaySnapshot snapshot =
|
appLiveCourseDisplaySupport.resolveDisplay(sessions, now, DETAIL_WATCH_KEEP_MINUTES);
|
|
String coverImage = appLiveCourseDisplaySupport.trimToNull(course.getCoverImg());
|
String liveImage = appLiveCourseDisplaySupport.resolveLiveImage(course);
|
LiveSession actionSession = resolveActionSession(snapshot);
|
boolean reserved = false;
|
Long loginUserId = null;
|
if (appAuthSessionService.isLogin()) {
|
loginUserId = appAuthSessionService.getLoginUserId();
|
}
|
if (loginUserId != null && snapshot.getNextSession() != null && !snapshot.isEndedCooldown()) {
|
reserved = hasActiveReserve(loginUserId, snapshot.getNextSession().getId());
|
}
|
|
QuizUserState quizUserState = resolveQuizUserState(actionSession, loginUserId);
|
LocalDateTime playbackStartTime = resolvePlaybackStartTime(actionSession);
|
LocalDateTime quizAvailableAt = resolveQuizAvailableAt(playbackStartTime, course.getQuizDelayMinutes());
|
boolean quizEnabled = isQuizEnabled(actionSession, quizAvailableAt, now);
|
|
AppLiveCourseDetailVO detailVO = new AppLiveCourseDetailVO();
|
detailVO.setCourseId(course.getId());
|
detailVO.setTitle(appLiveCourseDisplaySupport.trimToNull(course.getTitle()));
|
detailVO.setCoverImage(coverImage);
|
detailVO.setBannerImages(resolveBannerImages(course, coverImage, liveImage));
|
detailVO.setLiveImage(liveImage);
|
detailVO.setDisplayImage(resolveDisplayImage(snapshot, coverImage, liveImage));
|
detailVO.setDisplayStatus(resolveDisplayStatus(snapshot));
|
detailVO.setDisplayStatusText(resolveDisplayStatusText(snapshot));
|
detailVO.setCountdownTime(resolveCountdownTime(snapshot));
|
detailVO.setActionSessionId(actionSession == null ? null : actionSession.getId());
|
detailVO.setVideoUrl(resolvePlayableVideoUrl(snapshot, actionSession));
|
detailVO.setPlaybackStartTime(playbackStartTime);
|
detailVO.setDurationSeconds(actionSession == null ? null : actionSession.getDurationSeconds());
|
detailVO.setReserveCount(resolveReserveCount(courseId, sessions));
|
detailVO.setWatchCount(resolveWatchCount(sessions));
|
detailVO.setQuizDelayMinutes(defaultNumber(course.getQuizDelayMinutes()));
|
detailVO.setQuizAvailableAt(quizAvailableAt);
|
detailVO.setQuizEnabled(Boolean.valueOf(quizEnabled));
|
detailVO.setWatchCompleted(Boolean.valueOf(quizUserState.isWatchCompleted()));
|
detailVO.setQuizSubmitted(Boolean.valueOf(quizUserState.isQuizSubmitted()));
|
detailVO.setQuizAllCorrect(Boolean.valueOf(quizUserState.isQuizAllCorrect()));
|
detailVO.setQuizPointsAwarded(Boolean.valueOf(quizUserState.isQuizPointsAwarded()));
|
detailVO.setQuizPoints(defaultNumber(course.getQuizPoints()));
|
detailVO.setTargetUsers(appLiveCourseDisplaySupport.trimToNull(course.getTargetUsers()));
|
detailVO.setIntro(appLiveCourseDisplaySupport.trimToNull(course.getIntro()));
|
detailVO.setExpert(resolveExpert(course.getExpertId()));
|
|
ActionDecision actionDecision = resolveActionDecision(snapshot, reserved);
|
detailVO.setActionStatus(actionDecision.getActionStatus());
|
detailVO.setActionText(actionDecision.getActionText());
|
return detailVO;
|
}
|
|
/**
|
* 获取 APP 观看页答题状态与题目数据。
|
*/
|
public AppLiveQuizStatusVO quizStatus(Long courseId, Long sessionId) {
|
Long userId = appAuthSessionService.requireLoginUserId();
|
LiveCourse course = requireCourse(courseId);
|
LiveSession session = requireCourseSession(courseId, sessionId);
|
List<LiveSessionQuestion> questions = listSessionQuestions(sessionId);
|
validateConfiguredQuestions(questions);
|
|
LocalDateTime quizAvailableAt = resolveQuizAvailableAt(resolvePlaybackStartTime(session), course.getQuizDelayMinutes());
|
QuizUserState quizUserState = resolveQuizUserState(course, session, userId, questions);
|
|
AppLiveQuizStatusVO vo = new AppLiveQuizStatusVO();
|
vo.setSessionId(sessionId);
|
vo.setQuizEnabled(Boolean.valueOf(isQuizEnabled(session, quizAvailableAt, LocalDateTime.now())));
|
vo.setQuizAvailableAt(quizAvailableAt);
|
vo.setWatchCompleted(Boolean.valueOf(quizUserState.isWatchCompleted()));
|
vo.setQuizSubmitted(Boolean.valueOf(quizUserState.isQuizSubmitted()));
|
vo.setQuizAllCorrect(Boolean.valueOf(quizUserState.isQuizAllCorrect()));
|
vo.setQuizPointsAwarded(Boolean.valueOf(quizUserState.isQuizPointsAwarded()));
|
vo.setPointsReward(Integer.valueOf(defaultNumber(course.getQuizPoints())));
|
vo.setQuestions(toQuizQuestions(questions));
|
return vo;
|
}
|
|
/**
|
* 提交 APP 观看页答题结果。
|
*/
|
@Transactional
|
public AppLiveQuizSubmitResultVO submitQuiz(Long courseId, Long sessionId, AppLiveQuizSubmitRequest request) {
|
Long userId = appAuthSessionService.requireLoginUserId();
|
LiveCourse course = requireCourse(courseId);
|
LiveSession session = requireCourseSession(courseId, sessionId);
|
List<LiveSessionQuestion> questions = listSessionQuestions(sessionId);
|
validateConfiguredQuestions(questions);
|
validateQuizOpen(course, session, LocalDateTime.now());
|
|
List<LiveSessionQuizRecord> existingRecords = listUserQuizRecords(sessionId, userId);
|
if (!existingRecords.isEmpty()) {
|
throw new IllegalArgumentException("当前场次已完成答题");
|
}
|
|
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) {
|
String chosenOption = normalizedAnswers.get(question.getId());
|
boolean correct = question.getCorrectOption() != null
|
&& question.getCorrectOption().trim().equalsIgnoreCase(chosenOption);
|
if (!correct) {
|
allCorrect = false;
|
}
|
LiveSessionQuizRecord record = new LiveSessionQuizRecord();
|
record.setSessionId(sessionId);
|
record.setUserId(userId);
|
record.setQuestionId(question.getId());
|
record.setChosenOption(chosenOption);
|
record.setIsCorrect(correct ? 1 : 0);
|
record.setPointsEarned(0);
|
record.setAnsweredAt(answeredAt);
|
liveSessionQuizRecordMapper.insert(record);
|
insertedRecords.add(record);
|
}
|
|
boolean pointsAwarded = false;
|
boolean dailyCoursePointsAlreadyAwarded = false;
|
// 当前业务改为只要三题全部答对就立即发放积分,不再依赖完整观看直播。
|
if (allCorrect) {
|
dailyCoursePointsAlreadyAwarded = hasAwardedCourseQuizPointsToday(course.getId(), userId, answeredAt);
|
pointsAwarded = awardQuizPoints(course, session, userId, insertedRecords, questions, answeredAt);
|
}
|
|
AppLiveQuizSubmitResultVO resultVO = new AppLiveQuizSubmitResultVO();
|
resultVO.setSubmitted(Boolean.TRUE);
|
resultVO.setAllCorrect(Boolean.valueOf(allCorrect));
|
resultVO.setWatchCompleted(Boolean.valueOf(watchSummary.isWatchCompleted()));
|
resultVO.setPointsAwarded(Boolean.valueOf(pointsAwarded));
|
resultVO.setPointsReward(Integer.valueOf(defaultNumber(course.getQuizPoints())));
|
resultVO.setMessage(resolveQuizSubmitMessage(allCorrect, pointsAwarded, dailyCoursePointsAlreadyAwarded));
|
return resultVO;
|
}
|
|
/**
|
* 上报 APP 观看页的观看时长片段。
|
*/
|
@Transactional
|
public AppLiveWatchRecordUploadResultVO uploadWatchRecord(Long courseId,
|
Long sessionId,
|
AppLiveWatchRecordUploadRequest request) {
|
Long userId = appAuthSessionService.requireLoginUserId();
|
requireCourse(courseId);
|
LiveSession session = requireCourseSession(courseId, sessionId);
|
validateWatchRecordRequest(request);
|
|
int cumulativeWatchSeconds = resolveCumulativeWatchSeconds(sessionId, userId) + defaultNumber(request.getDurationSeconds());
|
boolean watchCompleted = isWatchCompleted(cumulativeWatchSeconds, session);
|
|
LiveWatchRecord record = new LiveWatchRecord();
|
record.setLiveCourseId(courseId);
|
record.setSessionId(sessionId);
|
record.setUserId(userId);
|
record.setWatchStart(request.getWatchStart());
|
record.setWatchEnd(request.getWatchEnd());
|
record.setDurationSeconds(defaultNumber(request.getDurationSeconds()));
|
record.setIsCompleted(watchCompleted ? 1 : 0);
|
record.setIsEffective(defaultNumber(request.getDurationSeconds()) > 0 ? 1 : 0);
|
liveWatchRecordMapper.insert(record);
|
|
List<LiveSessionQuizRecord> quizRecords = listUserQuizRecords(sessionId, userId);
|
QuizUserState quizUserState = resolveQuizUserState(listSessionQuestions(sessionId), quizRecords, watchCompleted);
|
|
AppLiveWatchRecordUploadResultVO resultVO = new AppLiveWatchRecordUploadResultVO();
|
resultVO.setCumulativeWatchSeconds(Integer.valueOf(cumulativeWatchSeconds));
|
resultVO.setWatchCompleted(Boolean.valueOf(watchCompleted));
|
resultVO.setQuizSubmitted(Boolean.valueOf(quizUserState.isQuizSubmitted()));
|
resultVO.setQuizAllCorrect(Boolean.valueOf(quizUserState.isQuizAllCorrect()));
|
resultVO.setQuizPointsAwarded(Boolean.valueOf(quizUserState.isQuizPointsAwarded()));
|
return resultVO;
|
}
|
|
/**
|
* 提交 APP 养生直播预约。
|
*/
|
@Transactional
|
public AppLiveReserveResultVO reserve(Long courseId, Long sessionId) {
|
Long userId = appAuthSessionService.requireLoginUserId();
|
requireCourse(courseId);
|
LiveSession session = requireCourseSession(courseId, sessionId);
|
validateReservableSession(session, LocalDateTime.now());
|
|
LiveReserve liveReserve = liveReserveMapper.selectOne(new LambdaQueryWrapper<LiveReserve>()
|
.eq(LiveReserve::getSessionId, sessionId)
|
.eq(LiveReserve::getUserId, userId)
|
.last("limit 1"));
|
if (liveReserve == null) {
|
liveReserve = new LiveReserve();
|
liveReserve.setLiveCourseId(courseId);
|
liveReserve.setSessionId(sessionId);
|
liveReserve.setUserId(userId);
|
liveReserve.setReserveTime(LocalDateTime.now());
|
liveReserve.setIsCancel(0);
|
liveReserveMapper.insert(liveReserve);
|
} else if (appLiveCourseDisplaySupport.defaultNumber(liveReserve.getIsCancel()) == 1) {
|
liveReserve.setIsCancel(0);
|
liveReserve.setReserveTime(LocalDateTime.now());
|
liveReserveMapper.updateById(liveReserve);
|
}
|
|
AppLiveReserveResultVO resultVO = new AppLiveReserveResultVO();
|
resultVO.setCourseId(courseId);
|
resultVO.setSessionId(sessionId);
|
resultVO.setReserved(Boolean.TRUE);
|
resultVO.setReserveCount(resolveReserveCount(courseId, listCourseSessions(courseId)));
|
return resultVO;
|
}
|
|
/**
|
* 读取课程下全部场次,供详情页和预约逻辑复用。
|
*/
|
private List<LiveSession> listCourseSessions(Long courseId) {
|
return liveSessionMapper.selectList(new LambdaQueryWrapper<LiveSession>()
|
.eq(LiveSession::getLiveCourseId, courseId)
|
.eq(LiveSession::getDeleted, 0)
|
.orderByAsc(LiveSession::getStartTime)
|
.orderByAsc(LiveSession::getId));
|
}
|
|
/**
|
* 读取场次下题目配置,固定按题目序号排序。
|
*/
|
private List<LiveSessionQuestion> listSessionQuestions(Long sessionId) {
|
return liveSessionQuestionMapper.selectList(new LambdaQueryWrapper<LiveSessionQuestion>()
|
.eq(LiveSessionQuestion::getSessionId, sessionId)
|
.eq(LiveSessionQuestion::getDeleted, 0)
|
.orderByAsc(LiveSessionQuestion::getSort)
|
.orderByAsc(LiveSessionQuestion::getId));
|
}
|
|
/**
|
* 读取用户在指定场次的答题记录。
|
*/
|
private List<LiveSessionQuizRecord> listUserQuizRecords(Long sessionId, Long userId) {
|
if (userId == null) {
|
return Collections.emptyList();
|
}
|
return liveSessionQuizRecordMapper.selectList(new LambdaQueryWrapper<LiveSessionQuizRecord>()
|
.eq(LiveSessionQuizRecord::getSessionId, sessionId)
|
.eq(LiveSessionQuizRecord::getUserId, userId)
|
.orderByAsc(LiveSessionQuizRecord::getQuestionId)
|
.orderByAsc(LiveSessionQuizRecord::getId));
|
}
|
|
/**
|
* 读取用户在指定场次的全部观看记录。
|
*/
|
private List<LiveWatchRecord> listUserWatchRecords(Long sessionId, Long userId) {
|
if (userId == null) {
|
return Collections.emptyList();
|
}
|
return liveWatchRecordMapper.selectList(new LambdaQueryWrapper<LiveWatchRecord>()
|
.eq(LiveWatchRecord::getSessionId, sessionId)
|
.eq(LiveWatchRecord::getUserId, userId)
|
.orderByAsc(LiveWatchRecord::getWatchStart)
|
.orderByAsc(LiveWatchRecord::getId));
|
}
|
|
/**
|
* 校验课程存在且处于 APP 可见状态。
|
*/
|
private LiveCourse requireCourse(Long courseId) {
|
LiveCourse course = liveCourseMapper.selectOne(new LambdaQueryWrapper<LiveCourse>()
|
.eq(LiveCourse::getId, courseId)
|
.eq(LiveCourse::getDeleted, 0)
|
.eq(LiveCourse::getStatus, COURSE_STATUS_ENABLED)
|
.last("limit 1"));
|
if (course == null) {
|
throw new IllegalArgumentException("直播课程不存在");
|
}
|
return course;
|
}
|
|
/**
|
* 校验直播场次存在。
|
*/
|
private LiveSession requireSession(Long sessionId) {
|
LiveSession session = liveSessionMapper.selectOne(new LambdaQueryWrapper<LiveSession>()
|
.eq(LiveSession::getId, sessionId)
|
.eq(LiveSession::getDeleted, 0)
|
.last("limit 1"));
|
if (session == null) {
|
throw new IllegalArgumentException("直播场次不存在");
|
}
|
return session;
|
}
|
|
/**
|
* 校验直播场次属于当前课程。
|
*/
|
private LiveSession requireCourseSession(Long courseId, Long sessionId) {
|
LiveSession session = requireSession(sessionId);
|
if (!courseId.equals(session.getLiveCourseId())) {
|
throw new IllegalArgumentException("直播课程与场次不匹配");
|
}
|
return session;
|
}
|
|
/**
|
* 校验当前场次仍允许预约。
|
*/
|
private void validateReservableSession(LiveSession session, LocalDateTime now) {
|
if (appLiveCourseDisplaySupport.defaultNumber(session.getLiveStatus()) != AppLiveCourseDisplaySupport.SESSION_STATUS_NOT_STARTED) {
|
throw new IllegalArgumentException("当前场次暂不支持预约");
|
}
|
if (session.getStartTime() == null || !session.getStartTime().isAfter(now)) {
|
throw new IllegalArgumentException("当前场次暂不支持预约");
|
}
|
}
|
|
/**
|
* 校验当前场次题目已开放作答。
|
*/
|
private void validateQuizOpen(LiveCourse course, LiveSession session, LocalDateTime now) {
|
LocalDateTime quizAvailableAt = resolveQuizAvailableAt(resolvePlaybackStartTime(session), course.getQuizDelayMinutes());
|
if (!isQuizEnabled(session, quizAvailableAt, now)) {
|
throw new IllegalArgumentException("答题暂未开放");
|
}
|
}
|
|
/**
|
* 校验观看记录请求的时间范围与时长。
|
*/
|
private void validateWatchRecordRequest(AppLiveWatchRecordUploadRequest request) {
|
if (request.getWatchEnd().isBefore(request.getWatchStart())) {
|
throw new IllegalArgumentException("观看结束时间不能早于开始时间");
|
}
|
}
|
|
/**
|
* 校验答题配置满足固定三题的业务规则。
|
*/
|
private void validateConfiguredQuestions(List<LiveSessionQuestion> questions) {
|
if (questions == null || questions.size() != QUIZ_QUESTION_COUNT) {
|
throw new IllegalArgumentException("当前场次题目配置不完整");
|
}
|
}
|
|
/**
|
* 校验并标准化答题提交数据。
|
*/
|
private Map<Long, String> validateAndNormalizeAnswers(List<AppLiveQuizAnswerRequest> answers,
|
List<LiveSessionQuestion> questions) {
|
if (answers == null || answers.size() != questions.size()) {
|
throw new IllegalArgumentException("请完成全部答题");
|
}
|
Set<Long> questionIds = new HashSet<Long>();
|
for (LiveSessionQuestion question : questions) {
|
questionIds.add(question.getId());
|
}
|
Map<Long, String> normalized = new HashMap<Long, String>();
|
for (AppLiveQuizAnswerRequest answer : answers) {
|
if (answer == null || answer.getQuestionId() == null) {
|
throw new IllegalArgumentException("题目不能为空");
|
}
|
if (!questionIds.contains(answer.getQuestionId())) {
|
throw new IllegalArgumentException("题目数据已失效,请重新进入直播");
|
}
|
if (normalized.containsKey(answer.getQuestionId())) {
|
throw new IllegalArgumentException("请勿重复提交同一道题");
|
}
|
normalized.put(answer.getQuestionId(), normalizeOption(answer.getChosenOption()));
|
}
|
if (normalized.size() != questions.size()) {
|
throw new IllegalArgumentException("请完成全部答题");
|
}
|
return normalized;
|
}
|
|
/**
|
* 将前端答案统一转换为大写选项值。
|
*/
|
private String normalizeOption(String chosenOption) {
|
String normalized = chosenOption == null ? null : chosenOption.trim().toUpperCase();
|
if (!"A".equals(normalized) && !"B".equals(normalized) && !"C".equals(normalized)) {
|
throw new IllegalArgumentException("答案选项不合法");
|
}
|
return normalized;
|
}
|
|
/**
|
* 判断当前登录用户是否已预约指定场次。
|
*/
|
private boolean hasActiveReserve(Long userId, Long sessionId) {
|
if (userId == null || sessionId == null) {
|
return false;
|
}
|
Long count = liveReserveMapper.selectCount(new LambdaQueryWrapper<LiveReserve>()
|
.eq(LiveReserve::getSessionId, sessionId)
|
.eq(LiveReserve::getUserId, userId)
|
.eq(LiveReserve::getIsCancel, 0));
|
return count != null && count.longValue() > 0L;
|
}
|
|
/**
|
* 计算详情页显示的预约人数,包含基础人数和真实预约人数。
|
*/
|
private Long resolveReserveCount(Long courseId, List<LiveSession> sessions) {
|
long baseReserveTotal = 0L;
|
long realReserveTotal = 0L;
|
for (LiveSession session : sessions) {
|
baseReserveTotal += safeLong(session.getBaseReserveCount());
|
realReserveTotal += safeLong(session.getRealReserveCount());
|
}
|
Long activeReserveCount = liveReserveMapper.selectCount(new LambdaQueryWrapper<LiveReserve>()
|
.eq(LiveReserve::getLiveCourseId, courseId)
|
.eq(LiveReserve::getIsCancel, 0));
|
long effectiveReserveTotal = Math.max(realReserveTotal, activeReserveCount == null ? 0L : activeReserveCount.longValue());
|
return Long.valueOf(baseReserveTotal + effectiveReserveTotal);
|
}
|
|
/**
|
* 计算详情页显示的观看人数。
|
*/
|
private Long resolveWatchCount(List<LiveSession> sessions) {
|
long watchTotal = 0L;
|
for (LiveSession session : sessions) {
|
watchTotal += safeLong(session.getBaseWatchCount());
|
watchTotal += safeLong(session.getRealWatchCount());
|
}
|
return Long.valueOf(watchTotal);
|
}
|
|
/**
|
* 解析当前用户在指定场次的答题与观看状态。
|
*/
|
private QuizUserState resolveQuizUserState(LiveSession session, Long userId) {
|
if (session == null || userId == null) {
|
return new QuizUserState(false, false, false, false);
|
}
|
LiveCourse course = requireCourse(session.getLiveCourseId());
|
List<LiveSessionQuestion> questions = listSessionQuestions(session.getId());
|
return resolveQuizUserState(course, session, userId, questions);
|
}
|
|
/**
|
* 解析当前用户在指定场次的答题与观看状态。
|
*/
|
private QuizUserState resolveQuizUserState(LiveCourse course,
|
LiveSession session,
|
Long userId,
|
List<LiveSessionQuestion> questions) {
|
WatchProgressSummary watchProgressSummary = resolveWatchProgress(session.getId(), userId, session);
|
List<LiveSessionQuizRecord> quizRecords = listUserQuizRecords(session.getId(), userId);
|
return resolveQuizUserState(questions, quizRecords, watchProgressSummary.isWatchCompleted());
|
}
|
|
/**
|
* 根据题目记录与观看完成状态汇总答题业务状态。
|
*/
|
private QuizUserState resolveQuizUserState(List<LiveSessionQuestion> questions,
|
List<LiveSessionQuizRecord> quizRecords,
|
boolean watchCompleted) {
|
boolean submitted = quizRecords != null && !quizRecords.isEmpty();
|
boolean allCorrect = submitted
|
&& questions != null
|
&& questions.size() == quizRecords.size()
|
&& isAllCorrect(quizRecords);
|
return new QuizUserState(submitted, allCorrect, hasAwardedQuizPoints(quizRecords), watchCompleted);
|
}
|
|
/**
|
* 将后台题目配置转换成 APP 可直接展示的答题列表。
|
*/
|
private List<AppLiveQuizQuestionVO> toQuizQuestions(List<LiveSessionQuestion> questions) {
|
List<AppLiveQuizQuestionVO> result = new ArrayList<AppLiveQuizQuestionVO>();
|
for (LiveSessionQuestion question : questions) {
|
AppLiveQuizQuestionVO vo = new AppLiveQuizQuestionVO();
|
vo.setId(question.getId());
|
vo.setSort(question.getSort());
|
vo.setStem(question.getStem());
|
vo.setOptionA(question.getOptionA());
|
vo.setOptionB(question.getOptionB());
|
vo.setOptionC(question.getOptionC());
|
result.add(vo);
|
}
|
return result;
|
}
|
|
/**
|
* 汇总当前场次累计观看时长与完播状态。
|
*/
|
private WatchProgressSummary resolveWatchProgress(Long sessionId, Long userId, LiveSession session) {
|
int cumulativeWatchSeconds = resolveCumulativeWatchSeconds(sessionId, userId);
|
return new WatchProgressSummary(cumulativeWatchSeconds, isWatchCompleted(cumulativeWatchSeconds, session));
|
}
|
|
/**
|
* 统计用户在当前场次的累计观看秒数。
|
*/
|
private int resolveCumulativeWatchSeconds(Long sessionId, Long userId) {
|
int total = 0;
|
for (LiveWatchRecord watchRecord : listUserWatchRecords(sessionId, userId)) {
|
total += defaultNumber(watchRecord.getDurationSeconds());
|
}
|
return total;
|
}
|
|
/**
|
* 判断累计观看时长是否达到场次要求。
|
*/
|
private boolean isWatchCompleted(int cumulativeWatchSeconds, LiveSession session) {
|
int requiredDurationSeconds = session == null ? 0 : defaultNumber(session.getDurationSeconds());
|
return requiredDurationSeconds > 0 && cumulativeWatchSeconds >= requiredDurationSeconds;
|
}
|
|
/**
|
* 判断是否已经答对全部题目。
|
*/
|
private boolean isAllCorrect(List<LiveSessionQuizRecord> quizRecords) {
|
for (LiveSessionQuizRecord quizRecord : quizRecords) {
|
if (defaultNumber(quizRecord.getIsCorrect()) != 1) {
|
return false;
|
}
|
}
|
return true;
|
}
|
|
/**
|
* 判断当前场次是否已经发放过答题积分。
|
*/
|
private boolean hasAwardedQuizPoints(List<LiveSessionQuizRecord> quizRecords) {
|
for (LiveSessionQuizRecord quizRecord : quizRecords) {
|
if (defaultNumber(quizRecord.getPointsEarned()) > 0) {
|
return true;
|
}
|
}
|
return false;
|
}
|
|
/**
|
* 发放直播答题积分,并同步积分流水与统计字段。
|
*/
|
private boolean awardQuizPoints(LiveCourse course,
|
LiveSession session,
|
Long userId,
|
List<LiveSessionQuizRecord> quizRecords,
|
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);
|
if (rewardRecord == null) {
|
return false;
|
}
|
rewardRecord.setPointsEarned(pointsReward);
|
liveSessionQuizRecordMapper.updateById(rewardRecord);
|
increaseUserPointsBalance(userId, pointsReward);
|
increaseSessionQuizStats(session.getId(), pointsReward);
|
// 积分到账后立即同步流水,避免 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;
|
}
|
|
/**
|
* 选择承载整场直播积分的答题记录,固定落在题号最大的那一题上。
|
*/
|
private LiveSessionQuizRecord resolveRewardRecord(List<LiveSessionQuizRecord> quizRecords,
|
List<LiveSessionQuestion> questions) {
|
if (quizRecords == null || quizRecords.isEmpty() || questions == null || questions.isEmpty()) {
|
return null;
|
}
|
Map<Long, Integer> questionSortMap = new HashMap<Long, Integer>();
|
for (LiveSessionQuestion question : questions) {
|
questionSortMap.put(question.getId(), question.getSort());
|
}
|
LiveSessionQuizRecord rewardRecord = null;
|
int maxSort = Integer.MIN_VALUE;
|
for (LiveSessionQuizRecord quizRecord : quizRecords) {
|
Integer sort = questionSortMap.get(quizRecord.getQuestionId());
|
int safeSort = sort == null ? Integer.MIN_VALUE : sort.intValue();
|
if (rewardRecord == null || safeSort > maxSort) {
|
rewardRecord = quizRecord;
|
maxSort = safeSort;
|
}
|
}
|
return rewardRecord;
|
}
|
|
/**
|
* 给用户积分余额账户增加本次直播奖励积分。
|
*/
|
private void increaseUserPointsBalance(Long userId, int pointsReward) {
|
UserPointsAccount account = userPointsAccountMapper.selectOne(new LambdaQueryWrapper<UserPointsAccount>()
|
.eq(UserPointsAccount::getAppUserId, userId)
|
.eq(UserPointsAccount::getDeleted, 0)
|
.last("limit 1"));
|
if (account == null) {
|
account = new UserPointsAccount();
|
account.setAppUserId(userId);
|
account.setPointsBalance(pointsReward);
|
userPointsAccountMapper.insert(account);
|
return;
|
}
|
userPointsAccountMapper.update(null, new LambdaUpdateWrapper<UserPointsAccount>()
|
.eq(UserPointsAccount::getId, account.getId())
|
.setSql("points_balance = COALESCE(points_balance, 0) + " + pointsReward));
|
}
|
|
/**
|
* 同步直播场次答题正确人数与发放积分统计。
|
*/
|
private void increaseSessionQuizStats(Long sessionId, int pointsReward) {
|
liveSessionMapper.update(null, new LambdaUpdateWrapper<LiveSession>()
|
.eq(LiveSession::getId, sessionId)
|
.setSql("quiz_correct_count = COALESCE(quiz_correct_count, 0) + 1")
|
.setSql("points_issued = COALESCE(points_issued, 0) + " + pointsReward));
|
}
|
|
/**
|
* 构造答题提交后的提示文案。
|
*/
|
private String resolveQuizSubmitMessage(boolean allCorrect,
|
boolean pointsAwarded,
|
boolean dailyCoursePointsAlreadyAwarded) {
|
if (!allCorrect) {
|
return "回答错误,本次不获得积分";
|
}
|
if (pointsAwarded) {
|
return "答题成功,积分已到账";
|
}
|
if (dailyCoursePointsAlreadyAwarded) {
|
return "今日已获得积分,明日再来";
|
}
|
return "答题成功";
|
}
|
|
/**
|
* 将直播课程的轮播图字段整理为前端可直接使用的列表。
|
*/
|
private List<String> resolveBannerImages(LiveCourse course, String coverImage, String liveImage) {
|
List<String> bannerImages = new ArrayList<String>();
|
if (course.getBannerImgs() != null) {
|
for (String bannerImage : course.getBannerImgs()) {
|
String normalized = appLiveCourseDisplaySupport.trimToNull(bannerImage);
|
if (normalized != null) {
|
bannerImages.add(normalized);
|
}
|
}
|
}
|
if (!bannerImages.isEmpty()) {
|
return bannerImages;
|
}
|
if (coverImage != null) {
|
return Collections.singletonList(coverImage);
|
}
|
if (liveImage != null) {
|
return Collections.singletonList(liveImage);
|
}
|
return Collections.emptyList();
|
}
|
|
/**
|
* 解析详情页主展示图。
|
*/
|
private String resolveDisplayImage(AppLiveCourseDisplaySupport.CourseDisplaySnapshot snapshot,
|
String coverImage,
|
String liveImage) {
|
if (snapshot.getCurrentSession() != null || snapshot.isEndedCooldown()) {
|
return appLiveCourseDisplaySupport.resolveLivingDisplayImage(coverImage, liveImage);
|
}
|
return appLiveCourseDisplaySupport.resolveStaticDisplayImage(coverImage, liveImage);
|
}
|
|
/**
|
* 解析详情页展示状态,结束冷却期仍按结束态展示。
|
*/
|
private String resolveDisplayStatus(AppLiveCourseDisplaySupport.CourseDisplaySnapshot snapshot) {
|
if (snapshot.getCurrentSession() != null) {
|
return AppLiveCourseDisplaySupport.DISPLAY_STATUS_LIVING;
|
}
|
if (snapshot.getNextSession() != null && !snapshot.isEndedCooldown()) {
|
return AppLiveCourseDisplaySupport.DISPLAY_STATUS_NOT_STARTED;
|
}
|
return AppLiveCourseDisplaySupport.DISPLAY_STATUS_ENDED;
|
}
|
|
/**
|
* 解析详情页状态文案。
|
*/
|
private String resolveDisplayStatusText(AppLiveCourseDisplaySupport.CourseDisplaySnapshot snapshot) {
|
if (snapshot.getCurrentSession() != null) {
|
return "直播中";
|
}
|
if (snapshot.getNextSession() != null && !snapshot.isEndedCooldown()) {
|
return "待开播";
|
}
|
return "直播已结束";
|
}
|
|
/**
|
* 解析详情页下次开播时间,仅未开始态才回传。
|
*/
|
private LocalDateTime resolveCountdownTime(AppLiveCourseDisplaySupport.CourseDisplaySnapshot snapshot) {
|
if (snapshot.getNextSession() == null || snapshot.isEndedCooldown() || snapshot.getCurrentSession() != null) {
|
return null;
|
}
|
return snapshot.getNextSession().getStartTime();
|
}
|
|
/**
|
* 解析详情页按钮对应的目标场次。
|
*/
|
private LiveSession resolveActionSession(AppLiveCourseDisplaySupport.CourseDisplaySnapshot snapshot) {
|
if (snapshot.getCurrentSession() != null) {
|
return snapshot.getCurrentSession();
|
}
|
if (snapshot.isEndedCooldown()) {
|
return snapshot.getLatestEndedSession();
|
}
|
return snapshot.getNextSession();
|
}
|
|
/**
|
* 解析观看页可播放视频地址,直播结束冷却期只保留答题入口不继续播放视频。
|
*/
|
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) {
|
if (actionSession == null) {
|
return null;
|
}
|
// 模拟直播进度必须和真实开播时刻对齐,未记录实际开播时间时再回退到计划开播时间。
|
return actionSession.getActualStartTime() != null ? actionSession.getActualStartTime() : actionSession.getStartTime();
|
}
|
|
/**
|
* 根据直播开始时间与课程配置计算答题开放时间。
|
*/
|
private LocalDateTime resolveQuizAvailableAt(LocalDateTime playbackStartTime, Integer quizDelayMinutes) {
|
if (playbackStartTime == null) {
|
return null;
|
}
|
return playbackStartTime.plusMinutes(Math.max(defaultNumber(quizDelayMinutes), 0));
|
}
|
|
/**
|
* 判断当前场次是否已达到答题开放时间。
|
*/
|
private boolean isQuizEnabled(LiveSession session, LocalDateTime quizAvailableAt, LocalDateTime now) {
|
if (session == null || quizAvailableAt == null || quizAvailableAt.isAfter(now)) {
|
return false;
|
}
|
if (appLiveCourseDisplaySupport.defaultNumber(session.getLiveStatus()) == AppLiveCourseDisplaySupport.SESSION_STATUS_LIVING) {
|
return true;
|
}
|
// 直播结束后的五分钟冷却窗口内继续允许用户进入直播间并完成答题。
|
return appLiveCourseDisplaySupport.isInEndedCooldown(session, now, DETAIL_WATCH_KEEP_MINUTES);
|
}
|
|
/**
|
* 解析详情页底部主按钮状态。
|
*/
|
private ActionDecision resolveActionDecision(AppLiveCourseDisplaySupport.CourseDisplaySnapshot snapshot,
|
boolean reserved) {
|
if (snapshot.getCurrentSession() != null || snapshot.isEndedCooldown()) {
|
return new ActionDecision(ACTION_STATUS_WATCHABLE, "立即查看");
|
}
|
if (snapshot.getNextSession() != null) {
|
if (reserved) {
|
return new ActionDecision(ACTION_STATUS_RESERVED, "已预约");
|
}
|
return new ActionDecision(ACTION_STATUS_RESERVABLE, "立即预约");
|
}
|
return new ActionDecision(ACTION_STATUS_CLOSED, "已结束");
|
}
|
|
/**
|
* 解析课程绑定的启用专家信息。
|
*/
|
private AppLiveExpertVO resolveExpert(Long expertId) {
|
if (expertId == null) {
|
return null;
|
}
|
Expert expert = expertMapper.selectOne(new LambdaQueryWrapper<Expert>()
|
.eq(Expert::getId, expertId)
|
.eq(Expert::getDeleted, 0)
|
.eq(Expert::getStatus, EXPERT_STATUS_ENABLED)
|
.last("limit 1"));
|
return appLiveExpertService.toExpertVO(expert);
|
}
|
|
/**
|
* 安全读取整数人数值。
|
*/
|
private long safeLong(Integer value) {
|
return value == null ? 0L : value.longValue();
|
}
|
|
/**
|
* 将可空整数转换为非空值。
|
*/
|
private int defaultNumber(Integer value) {
|
return value == null ? 0 : value.intValue();
|
}
|
|
/**
|
* 当前用户在某个场次下的答题与观看状态汇总。
|
*/
|
private static class QuizUserState {
|
|
private final boolean quizSubmitted;
|
private final boolean quizAllCorrect;
|
private final boolean quizPointsAwarded;
|
private final boolean watchCompleted;
|
|
/**
|
* 构造答题状态聚合对象。
|
*/
|
private QuizUserState(boolean quizSubmitted,
|
boolean quizAllCorrect,
|
boolean quizPointsAwarded,
|
boolean watchCompleted) {
|
this.quizSubmitted = quizSubmitted;
|
this.quizAllCorrect = quizAllCorrect;
|
this.quizPointsAwarded = quizPointsAwarded;
|
this.watchCompleted = watchCompleted;
|
}
|
|
/**
|
* 判断是否已提交过答题结果。
|
*/
|
public boolean isQuizSubmitted() {
|
return quizSubmitted;
|
}
|
|
/**
|
* 判断是否全部答对。
|
*/
|
public boolean isQuizAllCorrect() {
|
return quizAllCorrect;
|
}
|
|
/**
|
* 判断是否已经发放积分。
|
*/
|
public boolean isQuizPointsAwarded() {
|
return quizPointsAwarded;
|
}
|
|
/**
|
* 判断是否已满足完整观看条件。
|
*/
|
public boolean isWatchCompleted() {
|
return watchCompleted;
|
}
|
}
|
|
/**
|
* 当前用户在某个场次下的观看统计汇总。
|
*/
|
private static class WatchProgressSummary {
|
|
private final int cumulativeWatchSeconds;
|
private final boolean watchCompleted;
|
|
/**
|
* 构造观看统计聚合对象。
|
*/
|
private WatchProgressSummary(int cumulativeWatchSeconds, boolean watchCompleted) {
|
this.cumulativeWatchSeconds = cumulativeWatchSeconds;
|
this.watchCompleted = watchCompleted;
|
}
|
|
/**
|
* 获取累计观看秒数。
|
*/
|
public int getCumulativeWatchSeconds() {
|
return cumulativeWatchSeconds;
|
}
|
|
/**
|
* 判断是否已完成观看。
|
*/
|
public boolean isWatchCompleted() {
|
return watchCompleted;
|
}
|
}
|
|
/**
|
* 详情页按钮决策结果。
|
*/
|
private static class ActionDecision {
|
|
private final String actionStatus;
|
private final String actionText;
|
|
/**
|
* 构造详情页按钮决策。
|
*/
|
private ActionDecision(String actionStatus, String actionText) {
|
this.actionStatus = actionStatus;
|
this.actionText = actionText;
|
}
|
|
/**
|
* 获取按钮状态。
|
*/
|
public String getActionStatus() {
|
return actionStatus;
|
}
|
|
/**
|
* 获取按钮文案。
|
*/
|
public String getActionText() {
|
return actionText;
|
}
|
}
|
}
|