|
|
@@ -25,6 +25,12 @@ import com.fs.course.config.RedisKeyScanner;
|
|
|
import com.fs.course.domain.*;
|
|
|
import com.fs.course.mapper.*;
|
|
|
import com.fs.course.param.*;
|
|
|
+import com.fs.hisStore.domain.FsStoreOrderScrm;
|
|
|
+import com.fs.hisStore.dto.FsStoreCartDTO;
|
|
|
+import com.fs.hisStore.mapper.FsStoreOrderItemScrmMapper;
|
|
|
+import com.fs.hisStore.mapper.FsStoreOrderScrmMapper;
|
|
|
+import com.fs.hisStore.mapper.FsStoreProductScrmMapper;
|
|
|
+import com.fs.hisStore.vo.FsStoreOrderItemVO;
|
|
|
import com.fs.course.service.IFsCourseWatchLogService;
|
|
|
import com.fs.course.service.IFsUserCoursePeriodDaysService;
|
|
|
import com.fs.course.service.IFsUserCoursePeriodService;
|
|
|
@@ -162,6 +168,24 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
|
|
|
@Autowired
|
|
|
private FsCourseRedPacketLogMapper fsCourseRedPacketLogMapper;
|
|
|
|
|
|
+ @Autowired
|
|
|
+ private FsUserCoursePeriodMapper fsUserCoursePeriodMapper;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private FsUserCoursePeriodDaysMapper fsUserCoursePeriodDaysMapper;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private FsStoreOrderScrmMapper fsStoreOrderScrmMapper;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private FsStoreOrderItemScrmMapper fsStoreOrderItemScrmMapper;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private FsStoreProductScrmMapper fsStoreProductScrmMapper;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private FsCourseAnswerLogsMapper fsCourseAnswerLogsMapper;
|
|
|
+
|
|
|
/**
|
|
|
* 查询短链课程看课记录
|
|
|
*
|
|
|
@@ -1729,4 +1753,175 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
|
|
|
return fsCourseWatchLogMapper.selectFsCourseWatchLogWithUCCV(userId, companyUserId, courseId, videoId);
|
|
|
}
|
|
|
|
|
|
+ @Override
|
|
|
+ public CourseStatisticsDetailVO getCourseStatisticsDetail(Long videoId, Long periodId) {
|
|
|
+ CourseStatisticsDetailVO vo = new CourseStatisticsDetailVO();
|
|
|
+
|
|
|
+ // 总体数据
|
|
|
+
|
|
|
+ // 1. 查询视频时长(只返回duration字段)
|
|
|
+ FsUserCourseVideo fsUserCourseVideo = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoId(videoId);
|
|
|
+ vo.setVideoDuration(fsUserCourseVideo != null ? fsUserCourseVideo.getDuration() : 0L);
|
|
|
+
|
|
|
+ FsUserCoursePeriod fsUserCoursePeriod = fsUserCoursePeriodMapper.selectFsUserCoursePeriodById(periodId);
|
|
|
+
|
|
|
+
|
|
|
+ // 2. 统计累计观看人数(对userId去重)
|
|
|
+ Long totalWatchCount = fsCourseWatchLogMapper.countDistinctWatchUsers(videoId, periodId);
|
|
|
+ vo.setTotalWatchCount(totalWatchCount != null ? totalWatchCount : 0L);
|
|
|
+
|
|
|
+ // 3. 统计累计完课人数(duration >= 1200秒,即20分钟,对userId去重)
|
|
|
+ Long totalCompleteCount = fsCourseWatchLogMapper.countDistinctCompleteUsers(videoId, periodId);
|
|
|
+ vo.setTotalCompleteCount(totalCompleteCount != null ? totalCompleteCount : 0L);
|
|
|
+
|
|
|
+ // 4. 计算到课完课率 = 累计完课人数 / 累计观看人数
|
|
|
+ BigDecimal completeRate = BigDecimal.ZERO;
|
|
|
+ if (vo.getTotalWatchCount() != null && vo.getTotalWatchCount() > 0) {
|
|
|
+ completeRate = BigDecimal.valueOf(vo.getTotalCompleteCount())
|
|
|
+ .divide(BigDecimal.valueOf(vo.getTotalWatchCount()), 4, RoundingMode.HALF_UP)
|
|
|
+ .multiply(BigDecimal.valueOf(100));
|
|
|
+ }
|
|
|
+ vo.setCompleteRate(completeRate);
|
|
|
+
|
|
|
+ // 首次点播数据:营期开始时间+视频时长内的观看记录,view_start=update_time-duration 或 finish_time-duration(SQL内联计算窗口)
|
|
|
+ if (periodId != null && videoId != null) {
|
|
|
+ Map<String, Object> firstStats = fsCourseWatchLogMapper.selectFirstPlaybackStats(videoId, periodId);
|
|
|
+ if (firstStats != null && !firstStats.isEmpty()) {
|
|
|
+ Long firstWatch = getLongFromMap(firstStats, "firstWatchCount");
|
|
|
+ Long first20 = getLongFromMap(firstStats, "firstWatch20MinCount");
|
|
|
+ Long first30 = getLongFromMap(firstStats, "firstWatch30MinCount");
|
|
|
+ vo.setFirstWatchCount(firstWatch != null ? firstWatch : 0L);
|
|
|
+ vo.setFirstWatch20MinCount(first20 != null ? first20 : 0L);
|
|
|
+ vo.setFirstWatch30MinCount(first30 != null ? first30 : 0L);
|
|
|
+ if (firstWatch != null && firstWatch > 0) {
|
|
|
+ vo.setFirstCompleteRate20Min(BigDecimal.valueOf(first20 != null ? first20 : 0)
|
|
|
+ .divide(BigDecimal.valueOf(firstWatch), 4, RoundingMode.HALF_UP).multiply(BigDecimal.valueOf(100)));
|
|
|
+ vo.setFirstCompleteRate30Min(BigDecimal.valueOf(first30 != null ? first30 : 0)
|
|
|
+ .divide(BigDecimal.valueOf(firstWatch), 4, RoundingMode.HALF_UP).multiply(BigDecimal.valueOf(100)));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 第2-n次观看数据:view_start不在首次点播窗口内的记录(窗口外=首次窗口前或窗口后)
|
|
|
+ if (periodId != null && videoId != null) {
|
|
|
+ Map<String, Object> repeatStats = fsCourseWatchLogMapper.selectRepeatPlaybackStats(videoId, periodId);
|
|
|
+ if (repeatStats != null && !repeatStats.isEmpty()) {
|
|
|
+ Long repeatWatch = getLongFromMap(repeatStats, "repeatWatchCount");
|
|
|
+ Long repeat20 = getLongFromMap(repeatStats, "repeatWatch20MinCount");
|
|
|
+ Long repeat30 = getLongFromMap(repeatStats, "repeatWatch30MinCount");
|
|
|
+ vo.setRepeatWatchCount(repeatWatch != null ? repeatWatch : 0L);
|
|
|
+ vo.setRepeatWatch20MinCount(repeat20 != null ? repeat20 : 0L);
|
|
|
+ vo.setRepeatWatch30MinCount(repeat30 != null ? repeat30 : 0L);
|
|
|
+ if (repeatWatch != null && repeatWatch > 0) {
|
|
|
+ vo.setRepeatCompleteRate20Min(BigDecimal.valueOf(repeat20 != null ? repeat20 : 0)
|
|
|
+ .divide(BigDecimal.valueOf(repeatWatch), 4, RoundingMode.HALF_UP).multiply(BigDecimal.valueOf(100)));
|
|
|
+ vo.setRepeatCompleteRate30Min(BigDecimal.valueOf(repeat30 != null ? repeat30 : 0)
|
|
|
+ .divide(BigDecimal.valueOf(repeatWatch), 4, RoundingMode.HALF_UP).multiply(BigDecimal.valueOf(100)));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 订单数据:fs_store_order_scrm order_type=3,videoId+periodId 匹配,paid=1
|
|
|
+ if (periodId != null && videoId != null) {
|
|
|
+ FsStoreOrderScrm orderQuery = new FsStoreOrderScrm();
|
|
|
+ orderQuery.setOrderType(3);
|
|
|
+ orderQuery.setVideoId(videoId.intValue());
|
|
|
+ orderQuery.setPeriodId(periodId.intValue());
|
|
|
+ orderQuery.setPaid(1);
|
|
|
+ List<FsStoreOrderScrm> orders = fsStoreOrderScrmMapper.selectFsStoreOrderList(orderQuery);
|
|
|
+ List<FsStoreOrderScrm> paidOrders = orders != null ? orders.stream()
|
|
|
+ .filter(o -> o.getPaid() != null && o.getPaid() == 1)
|
|
|
+ .collect(Collectors.toList()) : Collections.emptyList();
|
|
|
+
|
|
|
+ BigDecimal gmv = paidOrders.stream()
|
|
|
+ .map(FsStoreOrderScrm::getPayPrice)
|
|
|
+ .filter(Objects::nonNull)
|
|
|
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
|
+ vo.setGmv(gmv);
|
|
|
+
|
|
|
+ long paidUserCount = paidOrders.stream()
|
|
|
+ .filter(o -> o.getUserId() != null)
|
|
|
+ .map(FsStoreOrderScrm::getUserId)
|
|
|
+ .distinct()
|
|
|
+ .count();
|
|
|
+ vo.setPaidUserCount(paidUserCount);
|
|
|
+ vo.setPaidOrderCount((long) paidOrders.size());
|
|
|
+
|
|
|
+ if (vo.getTotalWatchCount() != null && vo.getTotalWatchCount() > 0 && paidUserCount > 0) {
|
|
|
+ vo.setTotalPaidConversionRate(BigDecimal.valueOf(paidUserCount * 100.0 / vo.getTotalWatchCount()).setScale(2, RoundingMode.HALF_UP));
|
|
|
+ }
|
|
|
+ if (vo.getTotalCompleteCount() != null && vo.getTotalCompleteCount() > 0 && paidUserCount > 0) {
|
|
|
+ vo.setPaidConversionRate20Min(BigDecimal.valueOf(paidUserCount * 100.0 / vo.getTotalCompleteCount()).setScale(2, RoundingMode.HALF_UP));
|
|
|
+ }
|
|
|
+ if (vo.getTotalCompleteCount() != null && vo.getTotalCompleteCount() > 0 && gmv != null && gmv.compareTo(BigDecimal.ZERO) > 0) {
|
|
|
+ vo.setCompleteRValue(gmv.divide(BigDecimal.valueOf(vo.getTotalCompleteCount()), 2, RoundingMode.HALF_UP));
|
|
|
+ }
|
|
|
+
|
|
|
+ Long answerCount = fsCourseAnswerLogsMapper.countDistinctUsersByVideoAndPeriod(videoId, periodId);
|
|
|
+ vo.setAnswerUserCount(answerCount != null ? answerCount : 0L);
|
|
|
+
|
|
|
+ Long redCount = fsCourseRedPacketLogMapper.countDistinctUsersByVideoAndPeriod(videoId, periodId);
|
|
|
+ vo.setRedPacketUserCount(redCount != null ? redCount : 0L);
|
|
|
+
|
|
|
+ // 单品销量统计:从订单明细汇总
|
|
|
+ Map<Long, CourseProductSalesVO> productSalesMap = new HashMap<>();
|
|
|
+ for (FsStoreOrderScrm order : paidOrders) {
|
|
|
+ // todo 数据量大的时候需要优化查询 外面批量查询 里面数据过滤
|
|
|
+ List<FsStoreOrderItemVO> items = fsStoreOrderItemScrmMapper.selectFsStoreOrderItemListByOrderId(order.getId());
|
|
|
+ if (items == null || items.isEmpty()) continue;
|
|
|
+ long totalNum = order.getTotalNum() != null && order.getTotalNum() > 0 ? order.getTotalNum() : 1;
|
|
|
+ BigDecimal orderPayPrice = order.getPayPrice() != null ? order.getPayPrice() : BigDecimal.ZERO;
|
|
|
+
|
|
|
+ for (FsStoreOrderItemVO item : items) {
|
|
|
+ FsStoreCartDTO cartDTO = JSONUtil.toBean(item.getJsonInfo(), FsStoreCartDTO.class);
|
|
|
+ if (item.getProductId() == null) continue;
|
|
|
+ long itemNum = item.getNum() != null ? item.getNum() : 0;
|
|
|
+ BigDecimal itemAmount = totalNum > 0 ? orderPayPrice.multiply(BigDecimal.valueOf(itemNum)).divide(BigDecimal.valueOf(totalNum), 2, RoundingMode.HALF_UP) : BigDecimal.ZERO;
|
|
|
+ CourseProductSalesVO productSales = productSalesMap.computeIfAbsent(item.getProductId(), k -> {
|
|
|
+ CourseProductSalesVO pvo = new CourseProductSalesVO();
|
|
|
+ pvo.setProductName(cartDTO.getProductName());
|
|
|
+ return pvo;
|
|
|
+ });
|
|
|
+
|
|
|
+ productSales.setSalesCount(productSales.getSalesCount() + itemNum);
|
|
|
+ productSales.setSalesAmount(productSales.getSalesAmount().add(itemAmount));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ List<CourseProductSalesVO> productList = new ArrayList<>(productSalesMap.values());
|
|
|
+ productList.sort((a, b) -> b.getSalesAmount().compareTo(a.getSalesAmount()));
|
|
|
+ vo.setProductList(productList);
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ return vo;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public List<CourseStatisticsUserDetailVO> getCourseStatisticsUserDetailList(CourseStatisticsUserDetailParam param) {
|
|
|
+ if (param == null || param.getVideoId() == null || param.getPeriodId() == null) {
|
|
|
+ return Collections.emptyList();
|
|
|
+ }
|
|
|
+ return fsCourseWatchLogMapper.selectCourseStatisticsUserDetailList(param);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public List<CourseStatisticsUserDetailVO> getCourseStatisticsUserDetailExportList(CourseStatisticsUserDetailParam param) {
|
|
|
+ if (param == null || param.getVideoId() == null || param.getPeriodId() == null) {
|
|
|
+ return Collections.emptyList();
|
|
|
+ }
|
|
|
+ return fsCourseWatchLogMapper.selectCourseStatisticsUserDetailExportList(param);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从 Map 中安全获取 Long 值,兼容 MyBatis 返回的驼峰/小写键名
|
|
|
+ */
|
|
|
+ private Long getLongFromMap(Map<String, Object> map, String key) {
|
|
|
+ if (map == null || key == null) return null;
|
|
|
+ Object v = map.get(key);
|
|
|
+ if (v == null) v = map.get(key.toLowerCase());
|
|
|
+ if (v == null) return null;
|
|
|
+ if (v instanceof Number) return ((Number) v).longValue();
|
|
|
+ try { return Long.parseLong(String.valueOf(v)); } catch (NumberFormatException e) { return null; }
|
|
|
+ }
|
|
|
+
|
|
|
}
|