package com.tcm.training.admin.live.service; import cn.idev.excel.FastExcel; 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.common.PhoneMasker; import com.tcm.training.admin.live.entity.LiveSession; import com.tcm.training.admin.live.excel.LiveReserveExportRow; import com.tcm.training.admin.live.excel.LiveWatchExportRow; 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.LiveSessionQuizRecordMapper; import com.tcm.training.admin.live.mapper.LiveWatchRecordMapper; import com.tcm.training.admin.live.vo.LiveQuizItemVO; import com.tcm.training.admin.live.vo.LiveQuizRow; import com.tcm.training.admin.live.vo.LiveReserveItemVO; import com.tcm.training.admin.live.vo.LiveReservePageVO; import com.tcm.training.admin.live.vo.LiveReserveRow; import com.tcm.training.admin.live.vo.LiveReserveSummaryRow; import com.tcm.training.admin.live.vo.LiveReserveSummaryVO; import com.tcm.training.admin.live.vo.LiveWatchItemVO; import com.tcm.training.admin.live.vo.LiveWatchPageVO; import com.tcm.training.admin.live.vo.LiveWatchRow; import com.tcm.training.admin.live.vo.LiveWatchSummaryRow; import com.tcm.training.admin.live.vo.LiveWatchSummaryVO; import com.tcm.training.admin.live.vo.UserVO; import org.springframework.stereotype.Service; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.math.BigDecimal; import java.math.RoundingMode; import java.net.URLEncoder; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @Service public class LiveRecordAppService { private final LiveCourseMapper liveCourseMapper; private final LiveSessionMapper liveSessionMapper; private final LiveReserveMapper liveReserveMapper; private final LiveWatchRecordMapper liveWatchRecordMapper; private final LiveSessionQuizRecordMapper liveSessionQuizRecordMapper; public LiveRecordAppService(LiveCourseMapper liveCourseMapper, LiveSessionMapper liveSessionMapper, LiveReserveMapper liveReserveMapper, LiveWatchRecordMapper liveWatchRecordMapper, LiveSessionQuizRecordMapper liveSessionQuizRecordMapper) { this.liveCourseMapper = liveCourseMapper; this.liveSessionMapper = liveSessionMapper; this.liveReserveMapper = liveReserveMapper; this.liveWatchRecordMapper = liveWatchRecordMapper; this.liveSessionQuizRecordMapper = liveSessionQuizRecordMapper; } public PageResponse pageReserves(Long courseId, Long sessionId, int page, int pageSize, String keyword, Integer isCancel) { requireSession(courseId, sessionId); IPage p = liveReserveMapper.selectReservePage(new Page(page, pageSize), courseId, sessionId, keyword, isCancel); return PageResponse.of(toReserveItems(p.getRecords()), p.getTotal(), p.getCurrent(), p.getSize()); } public LiveReservePageVO pageGlobalReserves(int page, int pageSize, Long courseId, Long sessionId, String phone, String courseName, Integer liveStatus, Integer hasWatched, LocalDateTime reserveFrom, LocalDateTime reserveTo) { IPage result = liveReserveMapper.selectGlobalReservePage( new Page(page, pageSize), courseId, sessionId, trimToNull(phone), trimToNull(courseName), liveStatus, hasWatched, reserveFrom, reserveTo ); LiveReservePageVO vo = new LiveReservePageVO(); vo.setList(toReserveItems(result.getRecords())); vo.setTotal(result.getTotal()); vo.setPage(result.getCurrent()); vo.setPageSize(result.getSize()); vo.setSummary(buildReserveSummary(courseId, sessionId, phone, courseName, liveStatus, hasWatched, reserveFrom, reserveTo)); return vo; } public void exportGlobalReserves(HttpServletResponse response, Long courseId, Long sessionId, String phone, String courseName, Integer liveStatus, Integer hasWatched, LocalDateTime reserveFrom, LocalDateTime reserveTo) throws IOException { response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); String filename = URLEncoder.encode("直播预约记录", "UTF-8").replace("+", "%20"); response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + filename + ".xlsx"); List rows = liveReserveMapper.selectGlobalReserveList( courseId, sessionId, trimToNull(phone), trimToNull(courseName), liveStatus, hasWatched, reserveFrom, reserveTo ); List exports = new ArrayList(); for (int i = 0; i < rows.size(); i++) { LiveReserveRow row = rows.get(i); LiveReserveExportRow export = new LiveReserveExportRow(); export.setSerialNo(i + 1); export.setNickname(defaultText(row.getUserNickname())); export.setPhone(PhoneMasker.mask(row.getUserPhone())); export.setCourseTitle(defaultText(row.getCourseTitle())); export.setCategoryName(defaultText(row.getCategoryName())); export.setStartTime(formatMinute(row.getStartTime())); export.setLiveStatus(liveStatusText(row.getLiveStatus())); export.setReserveTime(DateTimes.format(row.getReserveTime())); export.setHasWatched(boolText(row.getHasWatched())); exports.add(export); } FastExcel.write(response.getOutputStream(), LiveReserveExportRow.class) .sheet("直播预约记录") .doWrite(exports); } public PageResponse pageWatchRecords(Long courseId, Long sessionId, int page, int pageSize, String keyword, Integer isCompleted, Integer isEffective) { requireSession(courseId, sessionId); IPage p = liveWatchRecordMapper.selectWatchPage(new Page(page, pageSize), courseId, sessionId, keyword, isCompleted, isEffective); return PageResponse.of(toWatchItems(p.getRecords()), p.getTotal(), p.getCurrent(), p.getSize()); } public LiveWatchPageVO pageGlobalWatchRecords(int page, int pageSize, Long courseId, Long sessionId, String regionCode, String clinicName, String phone, String courseName, Integer hasReserve, Integer isCompleted, Integer isEffective, LocalDateTime watchFrom, LocalDateTime watchTo) { IPage result = liveWatchRecordMapper.selectGlobalWatchPage( new Page(page, pageSize), courseId, sessionId, trimToNull(regionCode), trimToNull(clinicName), trimToNull(phone), trimToNull(courseName), hasReserve, isCompleted, isEffective, watchFrom, watchTo ); LiveWatchPageVO vo = new LiveWatchPageVO(); vo.setList(toWatchItems(result.getRecords())); vo.setTotal(result.getTotal()); vo.setPage(result.getCurrent()); vo.setPageSize(result.getSize()); vo.setSummary(buildWatchSummary(courseId, sessionId, regionCode, clinicName, phone, courseName, hasReserve, isCompleted, isEffective, watchFrom, watchTo)); return vo; } public void exportGlobalWatchRecords(HttpServletResponse response, Long courseId, Long sessionId, String regionCode, String clinicName, String phone, String courseName, Integer hasReserve, Integer isCompleted, Integer isEffective, LocalDateTime watchFrom, LocalDateTime watchTo) throws IOException { response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); String filename = URLEncoder.encode("直播观看记录", "UTF-8").replace("+", "%20"); response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + filename + ".xlsx"); List rows = liveWatchRecordMapper.selectGlobalWatchList( courseId, sessionId, trimToNull(regionCode), trimToNull(clinicName), trimToNull(phone), trimToNull(courseName), hasReserve, isCompleted, isEffective, watchFrom, watchTo ); List exports = new ArrayList(); for (int i = 0; i < rows.size(); i++) { LiveWatchRow row = rows.get(i); LiveWatchExportRow export = new LiveWatchExportRow(); export.setSerialNo(i + 1); export.setNickname(defaultText(row.getUserNickname())); export.setPhone(PhoneMasker.mask(row.getUserPhone())); export.setClinicName(defaultText(row.getClinicName())); export.setClinicArea(defaultText(row.getClinicArea())); export.setCourseTitle(defaultText(row.getCourseTitle())); export.setCategoryName(defaultText(row.getCategoryName())); export.setStartTime(formatMinute(row.getStartTime())); export.setHasReserve(boolText(row.getHasReserve())); export.setWatchTime(DateTimes.format(row.getWatchStart())); export.setIsCompleted(boolText(row.getIsCompleted())); exports.add(export); } FastExcel.write(response.getOutputStream(), LiveWatchExportRow.class) .sheet("直播观看记录") .doWrite(exports); } public PageResponse pageQuizRecords(Long courseId, Long sessionId, int page, int pageSize, String keyword, Integer isCorrect) { requireSession(courseId, sessionId); IPage p = liveSessionQuizRecordMapper.selectQuizPage(new Page(page, pageSize), sessionId, keyword, isCorrect); List list = new ArrayList(); for (LiveQuizRow r : p.getRecords()) { LiveQuizItemVO vo = new LiveQuizItemVO(); vo.setId(r.getId()); vo.setUser(new UserVO(r.getUserId(), r.getUserNickname(), PhoneMasker.mask(r.getUserPhone()), null)); vo.setQuestionSort(r.getQuestionSort()); vo.setStem(r.getStem()); vo.setChosenOption(r.getChosenOption()); vo.setCorrectOption(r.getCorrectOption()); vo.setIsCorrect(r.getIsCorrect()); vo.setPointsEarned(r.getPointsEarned()); vo.setAnsweredAt(r.getAnsweredAt()); list.add(vo); } return PageResponse.of(list, p.getTotal(), p.getCurrent(), p.getSize()); } private void requireSession(Long courseId, Long sessionId) { if (liveCourseMapper.selectById(courseId) == null) { throw new IllegalArgumentException("课程不存在"); } LiveSession s = liveSessionMapper.selectById(sessionId); if (s == null) { throw new IllegalArgumentException("场次不存在"); } if (!courseId.equals(s.getLiveCourseId())) { throw new IllegalArgumentException("场次不属于该课程"); } } private List toReserveItems(List rows) { List list = new ArrayList(); for (LiveReserveRow row : rows) { LiveReserveItemVO vo = new LiveReserveItemVO(); vo.setId(row.getId()); vo.setCourseId(row.getCourseId()); vo.setSessionId(row.getSessionId()); vo.setUser(new UserVO(row.getUserId(), row.getUserNickname(), PhoneMasker.mask(row.getUserPhone()), row.getUserAvatar())); vo.setCourseTitle(row.getCourseTitle()); vo.setCategoryName(row.getCategoryName()); vo.setStartTime(row.getStartTime()); vo.setLiveStatus(row.getLiveStatus()); vo.setReserveTime(row.getReserveTime()); vo.setIsCancel(row.getIsCancel()); vo.setHasWatched(row.getHasWatched()); list.add(vo); } return list; } private List toWatchItems(List rows) { List list = new ArrayList(); for (LiveWatchRow row : rows) { LiveWatchItemVO vo = new LiveWatchItemVO(); vo.setId(row.getId()); vo.setCourseId(row.getCourseId()); vo.setSessionId(row.getSessionId()); vo.setUser(new UserVO(row.getUserId(), row.getUserNickname(), PhoneMasker.mask(row.getUserPhone()), row.getUserAvatar())); vo.setClinicName(row.getClinicName()); vo.setClinicArea(row.getClinicArea()); vo.setCourseTitle(row.getCourseTitle()); vo.setCategoryName(row.getCategoryName()); vo.setStartTime(row.getStartTime()); vo.setHasReserve(row.getHasReserve()); vo.setWatchStart(row.getWatchStart()); vo.setWatchEnd(row.getWatchEnd()); vo.setDurationSeconds(row.getDurationSeconds()); vo.setIsCompleted(row.getIsCompleted()); vo.setIsEffective(row.getIsEffective()); list.add(vo); } return list; } private LiveReserveSummaryVO buildReserveSummary(Long courseId, Long sessionId, String phone, String courseName, Integer liveStatus, Integer hasWatched, LocalDateTime reserveFrom, LocalDateTime reserveTo) { LiveReserveSummaryRow row = liveReserveMapper.selectGlobalReserveSummary( courseId, sessionId, trimToNull(phone), trimToNull(courseName), liveStatus, hasWatched, reserveFrom, reserveTo ); LiveReserveSummaryVO vo = new LiveReserveSummaryVO(); long reserveTotal = row == null || row.getReserveTotal() == null ? 0L : row.getReserveTotal(); long watchedTotal = row == null || row.getWatchedTotal() == null ? 0L : row.getWatchedTotal(); vo.setReserveTotal(reserveTotal); vo.setWatchedTotal(watchedTotal); vo.setReserveConversionRate(percent(watchedTotal, reserveTotal)); return vo; } private LiveWatchSummaryVO buildWatchSummary(Long courseId, Long sessionId, String regionCode, String clinicName, String phone, String courseName, Integer hasReserve, Integer isCompleted, Integer isEffective, LocalDateTime watchFrom, LocalDateTime watchTo) { LiveWatchSummaryRow row = liveWatchRecordMapper.selectGlobalWatchSummary( courseId, sessionId, trimToNull(regionCode), trimToNull(clinicName), trimToNull(phone), trimToNull(courseName), hasReserve, isCompleted, isEffective, watchFrom, watchTo ); LiveWatchSummaryVO vo = new LiveWatchSummaryVO(); long watchUserTotal = row == null || row.getWatchUserTotal() == null ? 0L : row.getWatchUserTotal(); long completedUserTotal = row == null || row.getCompletedUserTotal() == null ? 0L : row.getCompletedUserTotal(); long effectiveUserTotal = row == null || row.getEffectiveUserTotal() == null ? 0L : row.getEffectiveUserTotal(); vo.setWatchUserTotal(watchUserTotal); vo.setCompletedUserTotal(completedUserTotal); vo.setEffectiveUserTotal(effectiveUserTotal); vo.setCompletionRate(percent(completedUserTotal, watchUserTotal)); vo.setEffectiveWatchRate(percent(effectiveUserTotal, watchUserTotal)); return vo; } private BigDecimal percent(long numerator, long denominator) { if (denominator <= 0) { return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); } return BigDecimal.valueOf(numerator) .multiply(BigDecimal.valueOf(100)) .divide(BigDecimal.valueOf(denominator), 2, RoundingMode.HALF_UP); } private String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } private String defaultText(String value) { return value == null || value.trim().isEmpty() ? "-" : value; } private String boolText(Integer value) { return value != null && value == 1 ? "是" : "否"; } private String liveStatusText(Integer value) { if (value == null) { return "-"; } if (value == 1) { return "直播中"; } if (value == 2) { return "已结束"; } return "未开始"; } private String formatMinute(LocalDateTime value) { if (value == null) { return "-"; } String text = DateTimes.format(value); return text == null || text.length() < 16 ? defaultText(text) : text.substring(0, 16); } }