ソースを参照

直播数据统计接口

yuhongqi 2 日 前
コミット
d2a7c06a6b
24 ファイル変更1607 行追加8 行削除
  1. 25 0
      fs-admin/src/main/java/com/fs/hisStore/task/LiveTask.java
  2. 60 2
      fs-admin/src/main/java/com/fs/live/controller/LiveDataController.java
  3. 62 0
      fs-company/src/main/java/com/fs/company/controller/live/LiveDataController.java
  4. 25 1
      fs-service/src/main/java/com/fs/live/domain/LiveData.java
  5. 99 0
      fs-service/src/main/java/com/fs/live/mapper/LiveDataMapper.java
  6. 10 0
      fs-service/src/main/java/com/fs/live/mapper/LiveMapper.java
  7. 27 0
      fs-service/src/main/java/com/fs/live/param/InviteCompareParam.java
  8. 34 0
      fs-service/src/main/java/com/fs/live/param/LiveRoomStudentParam.java
  9. 27 0
      fs-service/src/main/java/com/fs/live/param/ProductCompareParam.java
  10. 51 0
      fs-service/src/main/java/com/fs/live/service/ILiveDataService.java
  11. 314 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveDataServiceImpl.java
  12. 36 0
      fs-service/src/main/java/com/fs/live/vo/InviteCompareVO.java
  13. 25 0
      fs-service/src/main/java/com/fs/live/vo/InviteSalesOptionVO.java
  14. 22 0
      fs-service/src/main/java/com/fs/live/vo/LiveEntryTrendSeriesVO.java
  15. 19 0
      fs-service/src/main/java/com/fs/live/vo/LiveEntryTrendVO.java
  16. 35 0
      fs-service/src/main/java/com/fs/live/vo/LiveRoomStudentQueryVO.java
  17. 35 0
      fs-service/src/main/java/com/fs/live/vo/LiveRoomStudentVO.java
  18. 54 0
      fs-service/src/main/java/com/fs/live/vo/LiveStatisticsOverviewVO.java
  19. 30 0
      fs-service/src/main/java/com/fs/live/vo/ProductCompareVO.java
  20. 2 2
      fs-service/src/main/resources/application-config-druid-bjzm-test.yml
  21. 2 2
      fs-service/src/main/resources/application-config-druid-bjzm.yml
  22. 610 0
      fs-service/src/main/resources/mapper/live/LiveDataMapper.xml
  23. 2 0
      fs-service/src/main/resources/mapper/live/LiveMapper.xml
  24. 1 1
      fs-user-app/src/main/java/com/fs/app/controller/course/CourseFsUserController.java

+ 25 - 0
fs-admin/src/main/java/com/fs/hisStore/task/LiveTask.java

@@ -33,6 +33,7 @@ import com.fs.live.domain.LiveAfterSales;
 import com.fs.live.domain.LiveOrder;
 import com.fs.live.domain.LiveOrderItem;
 import com.fs.live.domain.LiveOrderPayment;
+import com.fs.live.mapper.LiveMapper;
 import com.fs.live.mapper.LiveOrderItemMapper;
 import com.fs.live.mapper.LiveOrderMapper;
 import com.fs.live.mapper.LiveOrderPaymentMapper;
@@ -170,6 +171,12 @@ public class LiveTask {
     @Autowired
     public RedisBatchHandler redisBatchHandler;
 
+    @Autowired
+    private LiveMapper liveMapper;
+
+    @Autowired
+    private ILiveDataService liveDataService;
+
     /**
      * 查询被拆分的订单,然后查询拆分订单的物流信息
      */
@@ -677,4 +684,22 @@ public class LiveTask {
         redisBatchHandler.consumeBatchData();
     }
 
+    /**
+     * 直播间数据概览缓存定时任务:查询结束时间在7天之内的直播间,执行数据概览统计并缓存到 live_data
+     */
+    public void cacheLiveStatisticsOverview() {
+        try {
+            List<Long> liveIds = liveMapper.selectLiveIdsByFinishTimeWithinDays(7);
+            if (liveIds == null || liveIds.isEmpty()) {
+                log.debug("直播间数据概览缓存任务:7天内无结束的直播间");
+                return;
+            }
+            log.info("直播间数据概览缓存任务:开始处理 {} 个直播间", liveIds.size());
+            liveDataService.calculateAndSaveOverviewForLive(liveIds);
+            log.info("直播间数据概览缓存任务:完成");
+        } catch (Exception e) {
+            log.error("直播间数据概览缓存任务异常", e);
+        }
+    }
+
 }

+ 60 - 2
fs-admin/src/main/java/com/fs/live/controller/LiveDataController.java

@@ -10,8 +10,7 @@ import com.fs.common.enums.BusinessType;
 import com.fs.common.utils.SecurityUtils;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.live.domain.LiveData;
-import com.fs.live.param.LiveDataCompanyParam;
-import com.fs.live.param.LiveDataParam;
+import com.fs.live.param.*;
 import com.fs.live.service.ILiveDataService;
 import com.fs.live.vo.LiveDataCompanyVO;
 import com.fs.live.vo.LiveUserFirstVo;
@@ -190,6 +189,65 @@ public class LiveDataController extends BaseController {
         return util.exportExcel(list, "直播间用户详情数据");
     }
 
+    /**
+     * 直播数据统计-数据概览(12项指标)
+     * @param liveIds 直播间ID列表,前端传入
+     */
+    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+    @PostMapping("/getLiveStatisticsOverview")
+    public R getLiveStatisticsOverview(@RequestBody List<Long> liveIds) {
+
+        return R.ok().put("data",liveDataService.getLiveStatisticsOverview(liveIds != null ? liveIds : Collections.emptyList()));
+    }
+
+    /**
+     * 直播趋势-进入人数折线图
+     * 基于 live_user_first_entry 与 live.start_time 计算相对时间,开播前进入的归为"开播前"
+     * @param liveIds 直播间ID列表
+     */
+    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+    @PostMapping("/getLiveEntryTrend")
+    public R getLiveEntryTrend(@RequestBody List<Long> liveIds) {
+        return R.ok().put("data", liveDataService.getLiveEntryTrend(liveIds != null ? liveIds : Collections.emptyList()));
+    }
+
+    /**
+     * 直播间学员列表(分页,基于 live_user_first_entry)
+     * 筛选:直播名称(liveIds)、首次访问时间范围
+     */
+    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+    @PostMapping("/listLiveRoomStudents")
+    public R listLiveRoomStudents(@RequestBody LiveRoomStudentParam param) {
+        return liveDataService.listLiveRoomStudents(param);
+    }
+
+    /**
+     * 商品对比统计(商品名称、下单未支付人数、成交人数、成交金额)
+     */
+    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+    @PostMapping("/listProductCompareStats")
+    public R listProductCompareStats(@RequestBody ProductCompareParam param) {
+        return liveDataService.listProductCompareStats(param);
+    }
+
+    /**
+     * 邀课对比-分享人选项列表(基于 live_user_first_entry 中存在的销售)
+     */
+    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+    @PostMapping("/listInviteSalesOptions")
+    public R listInviteSalesOptions(@RequestBody List<Long> liveIds) {
+        return R.ok().put("data", liveDataService.listInviteSalesOptions(liveIds != null ? liveIds : Collections.emptyList()));
+    }
+
+    /**
+     * 邀课对比统计(归属公司、销售名称、邀请人数、已支付订单数、订单总金额)
+     */
+    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+    @PostMapping("/listInviteCompareStats")
+    public R listInviteCompareStats(@RequestBody InviteCompareParam param) {
+        return liveDataService.listInviteCompareStats(param);
+    }
+
     /**
      * 查询分公司直播数据统计列表
      */

+ 62 - 0
fs-company/src/main/java/com/fs/company/controller/live/LiveDataController.java

@@ -15,6 +15,9 @@ import com.fs.framework.service.TokenService;
 import com.fs.live.domain.LiveData;
 import com.fs.live.param.LiveDataCompanyParam;
 import com.fs.live.param.LiveDataParam;
+import com.fs.live.param.LiveRoomStudentParam;
+import com.fs.live.param.InviteCompareParam;
+import com.fs.live.param.ProductCompareParam;
 import com.fs.live.service.ILiveDataService;
 import com.fs.live.vo.ColumnsConfigVo;
 import com.fs.live.vo.LiveDataCompanyVO;
@@ -46,6 +49,65 @@ public class LiveDataController extends BaseController
     @Autowired
     private TokenService tokenService;
 
+//    /**
+//     * 直播数据统计-数据概览(12项指标)
+//     * @param liveIds 直播间ID列表,前端传入
+//     */
+//    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+//    @PostMapping("/getLiveStatisticsOverview")
+//    public R getLiveStatisticsOverview(@RequestBody List<Long> liveIds) {
+//
+//        return R.ok().put("data",liveDataService.getLiveStatisticsOverview(liveIds != null ? liveIds : Collections.emptyList()));
+//    }
+//
+//    /**
+//     * 直播趋势-进入人数折线图
+//     * 基于 live_user_first_entry 与 live.start_time 计算相对时间,开播前进入的归为"开播前"
+//     * @param liveIds 直播间ID列表
+//     */
+//    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+//    @PostMapping("/getLiveEntryTrend")
+//    public R getLiveEntryTrend(@RequestBody List<Long> liveIds) {
+//        return R.ok().put("data", liveDataService.getLiveEntryTrend(liveIds != null ? liveIds : Collections.emptyList()));
+//    }
+//
+//    /**
+//     * 直播间学员列表(分页,基于 live_user_first_entry)
+//     * 筛选:直播名称(liveIds)、首次访问时间范围
+//     */
+//    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+//    @PostMapping("/listLiveRoomStudents")
+//    public R listLiveRoomStudents(@RequestBody LiveRoomStudentParam param) {
+//        return liveDataService.listLiveRoomStudents(param);
+//    }
+//
+//    /**
+//     * 商品对比统计(商品名称、下单未支付人数、成交人数、成交金额)
+//     */
+//    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+//    @PostMapping("/listProductCompareStats")
+//    public R listProductCompareStats(@RequestBody ProductCompareParam param) {
+//        return liveDataService.listProductCompareStats(param);
+//    }
+//
+//    /**
+//     * 邀课对比-分享人选项列表(基于 live_user_first_entry 中存在的销售)
+//     */
+//    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+//    @PostMapping("/listInviteSalesOptions")
+//    public R listInviteSalesOptions(@RequestBody List<Long> liveIds) {
+//        return R.ok().put("data", liveDataService.listInviteSalesOptions(liveIds != null ? liveIds : Collections.emptyList()));
+//    }
+//
+//    /**
+//     * 邀课对比统计(归属公司、销售名称、邀请人数、已支付订单数、订单总金额)
+//     */
+//    @PreAuthorize("@ss.hasPermi('liveData:liveData:query')")
+//    @PostMapping("/listInviteCompareStats")
+//    public R listInviteCompareStats(@RequestBody InviteCompareParam param) {
+//        return liveDataService.listInviteCompareStats(param);
+//    }
+
     /**
      * 查询直播间详情数据(SQL方式)
      * @param liveId 直播间ID

+ 25 - 1
fs-service/src/main/java/com/fs/live/domain/LiveData.java

@@ -4,6 +4,8 @@ package com.fs.live.domain;
 import com.fs.common.annotation.Excel;
 import lombok.Data;
 
+import java.math.BigDecimal;
+
 /**
  * 直播数据对象 live_data
  *
@@ -14,7 +16,6 @@ import lombok.Data;
 public class LiveData{
 
     /** 直播id */
-
     private Long liveId;
 
    /* *//** 直播名称 *//*
@@ -84,4 +85,27 @@ public class LiveData{
     @Excel(name = "回放点赞数")
     private Long replayLikeNum;
 
+    /** ========== 数据概览缓存字段(每个直播间独立缓存) ========== */
+    private Long overviewBeforeLiveUv;
+    private Long overviewTotalWatchUv;
+    private Long overviewOver10MinCount;
+    private Long overviewTotalWatchMinutes;
+    private Long overviewWatchTotalSeconds;
+    private Long overviewWatchUserCount;
+    private Long overviewReplayWatchUv;
+    private Long overviewReplayVisitPv;
+    private Long overviewReplayOnlyCount;
+    private Long overviewReplayTotalMinutes;
+    private Long overviewReplayTotalSeconds;
+    private Long overviewReplayUserCount;
+    private Long overviewCompleteCount;
+    private Long overviewSubscribeCount;
+    private Long overviewLotteryCount;
+    private Long overviewLotteryJoinCount;
+    private Long overviewLotteryWinCount;
+    private Long overviewPaidUserCount;
+    private Long overviewUnpaidUserCount;
+    private BigDecimal overviewTotalGmv;
+    private Long overviewTotalProductQty;
+
 }

+ 99 - 0
fs-service/src/main/java/com/fs/live/mapper/LiveDataMapper.java

@@ -7,8 +7,11 @@ import com.fs.live.domain.LiveData;
 import com.fs.live.param.LiveDataCompanyParam;
 import com.fs.live.vo.LiveDataCompanyVO;
 import com.fs.live.vo.LiveDataDetailVo;
+import com.fs.live.param.LiveRoomStudentParam;
 import com.fs.live.vo.LiveDataListVo;
 import com.fs.live.vo.LiveDataStatisticsVo;
+import com.fs.live.vo.LiveStatisticsOverviewVO;
+import com.fs.live.vo.LiveRoomStudentQueryVO;
 import com.fs.live.vo.LiveUserDetailVo;
 import com.fs.live.vo.LiveAppSimpleVO;
 import com.fs.live.vo.RecentLiveDataVo;
@@ -17,6 +20,7 @@ import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
 import org.springframework.stereotype.Repository;
 
+import java.math.BigDecimal;
 import java.util.List;
 import java.util.Map;
 
@@ -36,6 +40,21 @@ public interface LiveDataMapper {
      */
     LiveData selectLiveDataByLiveId(Long liveId);
 
+    /**
+     * 查询已有数据概览缓存的直播间
+     */
+    List<LiveData> selectLiveDataOverviewByLiveIds(@Param("liveIds") List<Long> liveIds);
+
+    /**
+     * 更新数据概览缓存
+     */
+    int updateLiveDataOverview(LiveData liveData);
+
+    /**
+     * 插入数据概览缓存
+     */
+    int insertLiveDataOverview(LiveData liveData);
+
     /**
      * 查询直播数据列表
      *
@@ -246,4 +265,84 @@ public interface LiveDataMapper {
      */
     @DataSource(DataSourceType.SLAVE)
     List<LiveAppSimpleVO> selectLivingLivesForApp();
+
+    /**
+     * 查询直播数据统计-数据概览(12项指标)- 已废弃,使用拆分查询替代
+     * @param liveIds 直播间ID列表
+     * @return 数据概览VO
+     */
+    @Deprecated
+    @DataSource(DataSourceType.SLAVE)
+    LiveStatisticsOverviewVO selectLiveStatisticsOverview(@Param("liveIds") List<Long> liveIds);
+
+    // ========== 数据概览拆分查询(每表独立查询,Java层合并) ==========
+    @DataSource(DataSourceType.SLAVE)
+    Long selectOverviewBeforeLiveUv(@Param("liveIds") List<Long> liveIds);
+    @DataSource(DataSourceType.SLAVE)
+    Long selectOverviewTotalWatchUv(@Param("liveIds") List<Long> liveIds);
+    @DataSource(DataSourceType.SLAVE)
+    Long selectOverviewOver10MinCount(@Param("liveIds") List<Long> liveIds);
+    @DataSource(DataSourceType.SLAVE)
+    Long selectOverviewWatchTotalSeconds(@Param("liveIds") List<Long> liveIds);
+    @DataSource(DataSourceType.SLAVE)
+    Long selectOverviewWatchUserCount(@Param("liveIds") List<Long> liveIds);
+    @DataSource(DataSourceType.SLAVE)
+    Long selectOverviewReplayWatchUv(@Param("liveIds") List<Long> liveIds);
+    @DataSource(DataSourceType.SLAVE)
+    Long selectOverviewReplayVisitPv(@Param("liveIds") List<Long> liveIds);
+    @DataSource(DataSourceType.SLAVE)
+    Long selectOverviewReplayOnlyCount(@Param("liveIds") List<Long> liveIds);
+    @DataSource(DataSourceType.SLAVE)
+    Long selectOverviewReplayTotalSeconds(@Param("liveIds") List<Long> liveIds);
+    @DataSource(DataSourceType.SLAVE)
+    Long selectOverviewReplayUserCount(@Param("liveIds") List<Long> liveIds);
+    @DataSource(DataSourceType.SLAVE)
+    Long selectOverviewCompleteCount(@Param("liveIds") List<Long> liveIds);
+    @DataSource(DataSourceType.SLAVE)
+    Long selectOverviewSubscribeCount(@Param("liveIds") List<Long> liveIds);
+    @DataSource(DataSourceType.SLAVE)
+    Long selectOverviewLotteryCount(@Param("liveIds") List<Long> liveIds);
+    @DataSource(DataSourceType.SLAVE)
+    Long selectOverviewLotteryJoinCount(@Param("liveIds") List<Long> liveIds);
+    @DataSource(DataSourceType.SLAVE)
+    Long selectOverviewLotteryWinCount(@Param("liveIds") List<Long> liveIds);
+    @DataSource(DataSourceType.SLAVE)
+    Long selectOverviewPaidUserCount(@Param("liveIds") List<Long> liveIds);
+    @DataSource(DataSourceType.SLAVE)
+    Long selectOverviewUnpaidUserCount(@Param("liveIds") List<Long> liveIds);
+    @DataSource(DataSourceType.SLAVE)
+    java.math.BigDecimal selectOverviewTotalGmv(@Param("liveIds") List<Long> liveIds);
+    @DataSource(DataSourceType.SLAVE)
+    Long selectOverviewTotalProductQty(@Param("liveIds") List<Long> liveIds);
+
+    /**
+     * 直播趋势:查询进入直播间的原始数据(live_id, live_name, start_time, entry_time)
+     * 用于计算相对时间并统计各时段累计进入人数
+     */
+    @DataSource(DataSourceType.SLAVE)
+    List<Map<String, Object>> selectLiveEntryTrendRawData(@Param("liveIds") List<Long> liveIds);
+
+    /**
+     * 直播间学员列表(基于 live_user_first_entry)
+     */
+    @DataSource(DataSourceType.SLAVE)
+    List<LiveRoomStudentQueryVO> selectLiveRoomStudentList(@Param("param") LiveRoomStudentParam param);
+
+    /**
+     * 商品对比统计:按商品汇总 下单未支付人数、成交人数、成交金额
+     */
+    @DataSource(DataSourceType.SLAVE)
+    List<com.fs.live.vo.ProductCompareVO> selectProductCompareList(@Param("param") com.fs.live.param.ProductCompareParam param);
+
+    /**
+     * 邀课对比-分享人选项列表(基于 live_user_first_entry 中存在的销售)
+     */
+    @DataSource(DataSourceType.SLAVE)
+    List<com.fs.live.vo.InviteSalesOptionVO> selectInviteSalesOptions(@Param("liveIds") List<Long> liveIds);
+
+    /**
+     * 邀课对比统计:按销售汇总 归属公司、销售名称、邀请人数、已支付订单数、订单总金额
+     */
+    @DataSource(DataSourceType.SLAVE)
+    List<com.fs.live.vo.InviteCompareVO> selectInviteCompareList(@Param("param") com.fs.live.param.InviteCompareParam param);
 }

+ 10 - 0
fs-service/src/main/java/com/fs/live/mapper/LiveMapper.java

@@ -241,4 +241,14 @@ public interface LiveMapper
     List<Live> listToLiveNoEnd(@Param("live") Live live);
 
     List<Live> selectLiveListNew(Live live);
+
+    /**
+     * 查询结束时间在指定天数内的直播间ID列表(用于数据概览缓存定时任务)
+     * @param days 天数,如7表示最近7天内结束的直播间
+     * @return live_id 列表
+     */
+    @DataSource(DataSourceType.SLAVE)
+    @Select("SELECT live_id FROM live WHERE finish_time >= DATE_SUB(NOW(), INTERVAL #{days} DAY) " +
+            "AND finish_time <= NOW() AND is_del = 0 AND is_audit = 1 AND status IN (3, 4)")
+    List<Long> selectLiveIdsByFinishTimeWithinDays(@Param("days") int days);
 }

+ 27 - 0
fs-service/src/main/java/com/fs/live/param/InviteCompareParam.java

@@ -0,0 +1,27 @@
+package com.fs.live.param;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 邀课对比统计查询参数(基于 live_user_first_entry 的销售信息)
+ *
+ * @author fs
+ * @date 2025-03-19
+ */
+@Data
+public class InviteCompareParam {
+
+    /** 直播间ID列表 */
+    private List<Long> liveIds;
+
+    /** 分享人(公司用户ID列表,company_user_id,可选,不传则查全部) */
+    private List<Long> companyUserIds;
+
+    /** 页码 */
+    private Integer pageNum = 1;
+
+    /** 每页条数 */
+    private Integer pageSize = 10;
+}

+ 34 - 0
fs-service/src/main/java/com/fs/live/param/LiveRoomStudentParam.java

@@ -0,0 +1,34 @@
+package com.fs.live.param;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 直播间学员查询参数
+ *
+ * @author fs
+ * @date 2025-03-18
+ */
+@Data
+public class LiveRoomStudentParam {
+
+    /** 直播间ID列表(传ids给后端) */
+    private List<Long> liveIds;
+
+    /** 首次访问时间-开始(live_user_first_entry.create_time 范围) */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date firstEntryTimeBegin;
+
+    /** 首次访问时间-结束(live_user_first_entry.create_time 范围) */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date firstEntryTimeEnd;
+
+    /** 页码,默认1 */
+    private Integer pageNum = 1;
+
+    /** 每页条数,默认10 */
+    private Integer pageSize = 10;
+}

+ 27 - 0
fs-service/src/main/java/com/fs/live/param/ProductCompareParam.java

@@ -0,0 +1,27 @@
+package com.fs.live.param;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 商品对比统计查询参数
+ *
+ * @author fs
+ * @date 2025-03-19
+ */
+@Data
+public class ProductCompareParam {
+
+    /** 直播间ID列表 */
+    private List<Long> liveIds;
+
+    /** 商品ID列表(可选,不传则查全部带货商品) */
+    private List<Long> productIds;
+
+    /** 页码 */
+    private Integer pageNum = 1;
+
+    /** 每页条数 */
+    private Integer pageSize = 10;
+}

+ 51 - 0
fs-service/src/main/java/com/fs/live/service/ILiveDataService.java

@@ -5,6 +5,7 @@ import com.fs.common.core.domain.R;
 import com.fs.live.domain.LiveData;
 import com.fs.live.param.LiveDataCompanyParam;
 import com.fs.live.param.LiveDataParam;
+import com.fs.live.param.LiveRoomStudentParam;
 import com.fs.live.vo.*;
 
 import java.util.List;
@@ -182,4 +183,54 @@ public interface ILiveDataService {
      * @return 分公司统计数据
      */
     List<LiveDataCompanyVO> listLiveDataCompany(LiveDataCompanyParam param);
+
+    /**
+     * 直播数据统计-数据概览(12项指标)
+     * @param liveIds 直播间ID列表
+     * @return 数据概览
+     */
+    LiveStatisticsOverviewVO getLiveStatisticsOverview(List<Long> liveIds);
+
+    /**
+     * 直播趋势-进入人数折线图数据
+     * 基于 live_user_first_entry 与 live.start_time 计算相对时间,开播前进入的归为"开播前"
+     * @param liveIds 直播间ID列表
+     * @return 折线图数据(xAxis + series)
+     */
+    LiveEntryTrendVO getLiveEntryTrend(List<Long> liveIds);
+
+    /**
+     * 直播间学员列表(分页,基于 live_user_first_entry)
+     * @param param 查询参数(liveIds、首次访问时间范围、分页)
+     * @return 分页结果
+     */
+    com.fs.common.core.domain.R listLiveRoomStudents(LiveRoomStudentParam param);
+
+    /**
+     * 商品对比统计(商品名称、下单未支付人数、成交人数、成交金额)
+     * @param param 查询参数(liveIds、productIds、分页)
+     * @return 分页结果
+     */
+    com.fs.common.core.domain.R listProductCompareStats(com.fs.live.param.ProductCompareParam param);
+
+    /**
+     * 邀课对比-分享人选项列表(基于 live_user_first_entry 中存在的销售)
+     * @param liveIds 直播间ID列表
+     * @return 分享人选项列表
+     */
+    java.util.List<com.fs.live.vo.InviteSalesOptionVO> listInviteSalesOptions(java.util.List<Long> liveIds);
+
+    /**
+     * 邀课对比统计(归属公司、销售名称、邀请人数、已支付订单数、订单总金额)
+     * @param param 查询参数(liveIds、companyUserIds、分页)
+     * @return 分页结果
+     */
+    com.fs.common.core.domain.R listInviteCompareStats(com.fs.live.param.InviteCompareParam param);
+
+    /**
+     * 直播间学员列表(分页,基于 live_user_first_entry)
+     * @LiveRoomStudentParam param 查询参数(liveIds、首次访问时间范围、分页)
+     * @return 分页结果
+     */
+    LiveData calculateAndSaveOverviewForLive(List<Long> liveIds);
 }

+ 314 - 0
fs-service/src/main/java/com/fs/live/service/impl/LiveDataServiceImpl.java

@@ -10,14 +10,18 @@ import com.fs.hisStore.domain.FsUserScrm;
 import com.fs.hisStore.mapper.FsUserScrmMapper;
 import com.fs.live.domain.*;
 import com.fs.live.mapper.*;
+import com.fs.common.utils.ParseUtils;
 import com.fs.live.param.LiveDataCompanyParam;
 import com.fs.live.param.LiveDataParam;
+import com.fs.live.param.LiveRoomStudentParam;
 import com.fs.live.service.ILiveDataService;
 import com.fs.live.service.ILiveUserFavoriteService;
 import com.fs.live.service.ILiveUserFollowService;
 import com.fs.live.service.ILiveUserLikeService;
 import com.fs.live.service.ILiveWatchUserService;
 import com.fs.live.vo.*;
+import com.fs.live.vo.LiveRoomStudentQueryVO;
+import com.fs.live.vo.LiveRoomStudentVO;
 import com.fs.company.domain.Company;
 import com.fs.company.domain.CompanyUser;
 import com.fs.company.mapper.CompanyMapper;
@@ -33,6 +37,7 @@ import com.fs.hisStore.vo.FsStoreOrderItemVO;
 import java.util.stream.Collectors;
 import java.util.function.BiConsumer;
 
+import com.github.pagehelper.PageHelper;
 import com.github.pagehelper.PageInfo;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -51,6 +56,7 @@ import java.util.*;
 import java.util.concurrent.*;
 
 import static com.fs.common.constant.LiveKeysConstant.*;
+import java.util.concurrent.CompletableFuture;
 
 
 /**
@@ -256,6 +262,314 @@ public class LiveDataServiceImpl implements ILiveDataService {
         return queryLiveDataCompanyByDateRange(param);
     }
 
+    @Override
+    public LiveStatisticsOverviewVO getLiveStatisticsOverview(List<Long> liveIds) {
+        LiveStatisticsOverviewVO vo = new LiveStatisticsOverviewVO();
+        if (liveIds == null || liveIds.isEmpty()) {
+            return vo;
+        }
+        // 1. 先从 live_data 查询已有缓存的直播间
+        List<LiveData> cachedList = liveDataMapper.selectLiveDataOverviewByLiveIds(liveIds);
+        Set<Long> cachedLiveIds = cachedList == null ? Collections.emptySet()
+                : cachedList.stream().map(LiveData::getLiveId).filter(Objects::nonNull).collect(Collectors.toSet());
+        List<Long> needCalcIds = liveIds.stream().filter(id -> !cachedLiveIds.contains(id)).collect(Collectors.toList());
+
+        // 2. 对未缓存的直播间进行统计并保存到 live_data
+        if (!needCalcIds.isEmpty()) {
+            for (Long liveId : needCalcIds) {
+                LiveData overviewData = calculateAndSaveOverviewForLive(Collections.singletonList(liveId));
+                if (overviewData != null) {
+                    cachedList = cachedList == null ? new ArrayList<>() : cachedList;
+                    overviewData.setLiveId(liveId);
+                    cachedList.add(overviewData);
+                }
+            }
+        }
+
+        // 3. 聚合所有直播间数据(来自缓存 + 新计算的)
+        if (cachedList == null || cachedList.isEmpty()) {
+            return vo;
+        }
+        long beforeLiveUv = 0, totalWatchUv = 0, over10MinCount = 0, totalWatchMinutes = 0;
+        long watchTotalSeconds = 0, watchUserCount = 0;
+        long replayWatchUv = 0, replayVisitPv = 0, replayOnlyCount = 0, replayTotalMinutes = 0;
+        long replayTotalSeconds = 0, replayUserCount = 0;
+        long completeCount = 0, subscribeCount = 0, lotteryCount = 0, lotteryJoinCount = 0, lotteryWinCount = 0;
+        long paidUserCount = 0, unpaidUserCount = 0, totalProductQty = 0;
+        BigDecimal totalGmv = BigDecimal.ZERO;
+        for (LiveData ld : cachedList) {
+            beforeLiveUv += nullToZero(ld.getOverviewBeforeLiveUv());
+            totalWatchUv += nullToZero(ld.getOverviewTotalWatchUv());
+            over10MinCount += nullToZero(ld.getOverviewOver10MinCount());
+            totalWatchMinutes += nullToZero(ld.getOverviewTotalWatchMinutes());
+            watchTotalSeconds += nullToZero(ld.getOverviewWatchTotalSeconds());
+            watchUserCount += nullToZero(ld.getOverviewWatchUserCount());
+            replayWatchUv += nullToZero(ld.getOverviewReplayWatchUv());
+            replayVisitPv += nullToZero(ld.getOverviewReplayVisitPv());
+            replayOnlyCount += nullToZero(ld.getOverviewReplayOnlyCount());
+            replayTotalMinutes += nullToZero(ld.getOverviewReplayTotalMinutes());
+            replayTotalSeconds += nullToZero(ld.getOverviewReplayTotalSeconds());
+            replayUserCount += nullToZero(ld.getOverviewReplayUserCount());
+            completeCount += nullToZero(ld.getOverviewCompleteCount());
+            subscribeCount += nullToZero(ld.getOverviewSubscribeCount());
+            lotteryCount += nullToZero(ld.getOverviewLotteryCount());
+            lotteryJoinCount += nullToZero(ld.getOverviewLotteryJoinCount());
+            lotteryWinCount += nullToZero(ld.getOverviewLotteryWinCount());
+            paidUserCount += nullToZero(ld.getOverviewPaidUserCount());
+            unpaidUserCount += nullToZero(ld.getOverviewUnpaidUserCount());
+            totalProductQty += nullToZero(ld.getOverviewTotalProductQty());
+            if (ld.getOverviewTotalGmv() != null) {
+                totalGmv = totalGmv.add(ld.getOverviewTotalGmv());
+            }
+        }
+        vo.setBeforeLiveUv(beforeLiveUv);
+        vo.setTotalWatchUv(totalWatchUv);
+        vo.setOver10MinCount(over10MinCount);
+        vo.setTotalWatchMinutes(totalWatchMinutes);
+        vo.setAvgWatchMinutes(calcAvgMinutes(watchTotalSeconds, watchUserCount));
+        vo.setReplayWatchUv(replayWatchUv);
+        vo.setReplayVisitPv(replayVisitPv);
+        vo.setReplayOnlyCount(replayOnlyCount);
+        vo.setReplayTotalMinutes(replayTotalMinutes);
+        vo.setReplayAvgMinutes(calcAvgMinutes(replayTotalSeconds, replayUserCount));
+        vo.setCompleteCount(completeCount);
+        vo.setSubscribeCount(subscribeCount);
+        vo.setLotteryCount(lotteryCount);
+        vo.setLotteryJoinCount(lotteryJoinCount);
+        vo.setLotteryWinCount(lotteryWinCount);
+        vo.setPaidUserCount(paidUserCount);
+        vo.setUnpaidUserCount(unpaidUserCount);
+        vo.setTotalGmv(totalGmv);
+        vo.setTotalProductQty(totalProductQty);
+        return vo;
+    }
+
+    /**
+     * 对指定直播间进行统计,保存到 live_data,并返回 LiveData
+     */
+    @Override
+    public LiveData calculateAndSaveOverviewForLive(List<Long> liveIds) {
+        if (liveIds == null || liveIds.isEmpty()) return null;
+        CompletableFuture<Long> fBeforeLiveUv = CompletableFuture.supplyAsync(() -> liveDataMapper.selectOverviewBeforeLiveUv(liveIds));
+        CompletableFuture<Long> fTotalWatchUv = CompletableFuture.supplyAsync(() -> liveDataMapper.selectOverviewTotalWatchUv(liveIds));
+        CompletableFuture<Long> fOver10MinCount = CompletableFuture.supplyAsync(() -> liveDataMapper.selectOverviewOver10MinCount(liveIds));
+        CompletableFuture<Long> fWatchTotalSeconds = CompletableFuture.supplyAsync(() -> liveDataMapper.selectOverviewWatchTotalSeconds(liveIds));
+        CompletableFuture<Long> fWatchUserCount = CompletableFuture.supplyAsync(() -> liveDataMapper.selectOverviewWatchUserCount(liveIds));
+        CompletableFuture<Long> fReplayWatchUv = CompletableFuture.supplyAsync(() -> liveDataMapper.selectOverviewReplayWatchUv(liveIds));
+        CompletableFuture<Long> fReplayVisitPv = CompletableFuture.supplyAsync(() -> liveDataMapper.selectOverviewReplayVisitPv(liveIds));
+        CompletableFuture<Long> fReplayOnlyCount = CompletableFuture.supplyAsync(() -> liveDataMapper.selectOverviewReplayOnlyCount(liveIds));
+        CompletableFuture<Long> fReplayTotalSeconds = CompletableFuture.supplyAsync(() -> liveDataMapper.selectOverviewReplayTotalSeconds(liveIds));
+        CompletableFuture<Long> fReplayUserCount = CompletableFuture.supplyAsync(() -> liveDataMapper.selectOverviewReplayUserCount(liveIds));
+        CompletableFuture<Long> fCompleteCount = CompletableFuture.supplyAsync(() -> liveDataMapper.selectOverviewCompleteCount(liveIds));
+        CompletableFuture<Long> fSubscribeCount = CompletableFuture.supplyAsync(() -> liveDataMapper.selectOverviewSubscribeCount(liveIds));
+        CompletableFuture<Long> fLotteryCount = CompletableFuture.supplyAsync(() -> liveDataMapper.selectOverviewLotteryCount(liveIds));
+        CompletableFuture<Long> fLotteryJoinCount = CompletableFuture.supplyAsync(() -> liveDataMapper.selectOverviewLotteryJoinCount(liveIds));
+        CompletableFuture<Long> fLotteryWinCount = CompletableFuture.supplyAsync(() -> liveDataMapper.selectOverviewLotteryWinCount(liveIds));
+        CompletableFuture<Long> fPaidUserCount = CompletableFuture.supplyAsync(() -> liveDataMapper.selectOverviewPaidUserCount(liveIds));
+        CompletableFuture<Long> fUnpaidUserCount = CompletableFuture.supplyAsync(() -> liveDataMapper.selectOverviewUnpaidUserCount(liveIds));
+        CompletableFuture<BigDecimal> fTotalGmv = CompletableFuture.supplyAsync(() -> liveDataMapper.selectOverviewTotalGmv(liveIds));
+        CompletableFuture<Long> fTotalProductQty = CompletableFuture.supplyAsync(() -> liveDataMapper.selectOverviewTotalProductQty(liveIds));
+
+        CompletableFuture.allOf(fBeforeLiveUv, fTotalWatchUv, fOver10MinCount, fWatchTotalSeconds, fWatchUserCount,
+                fReplayWatchUv, fReplayVisitPv, fReplayOnlyCount, fReplayTotalSeconds, fReplayUserCount,
+                fCompleteCount, fSubscribeCount, fLotteryCount, fLotteryJoinCount, fLotteryWinCount,
+                fPaidUserCount, fUnpaidUserCount, fTotalGmv, fTotalProductQty).join();
+
+        Long watchTotalSeconds = fWatchTotalSeconds.join();
+        Long watchUserCount = fWatchUserCount.join();
+        Long replayTotalSeconds = fReplayTotalSeconds.join();
+        Long replayUserCount = fReplayUserCount.join();
+        long totalWatchMinutes = nullToZero(watchTotalSeconds) / 60;
+        long replayTotalMinutes = nullToZero(replayTotalSeconds) / 60;
+
+        LiveData ld = new LiveData();
+        ld.setLiveId(liveIds.get(0));
+        ld.setOverviewBeforeLiveUv(fBeforeLiveUv.join());
+        ld.setOverviewTotalWatchUv(fTotalWatchUv.join());
+        ld.setOverviewOver10MinCount(fOver10MinCount.join());
+        ld.setOverviewTotalWatchMinutes(totalWatchMinutes);
+        ld.setOverviewWatchTotalSeconds(watchTotalSeconds);
+        ld.setOverviewWatchUserCount(watchUserCount);
+        ld.setOverviewReplayWatchUv(fReplayWatchUv.join());
+        ld.setOverviewReplayVisitPv(fReplayVisitPv.join());
+        ld.setOverviewReplayOnlyCount(fReplayOnlyCount.join());
+        ld.setOverviewReplayTotalMinutes(replayTotalMinutes);
+        ld.setOverviewReplayTotalSeconds(replayTotalSeconds);
+        ld.setOverviewReplayUserCount(replayUserCount);
+        ld.setOverviewCompleteCount(fCompleteCount.join());
+        ld.setOverviewSubscribeCount(fSubscribeCount.join());
+        ld.setOverviewLotteryCount(fLotteryCount.join());
+        ld.setOverviewLotteryJoinCount(fLotteryJoinCount.join());
+        ld.setOverviewLotteryWinCount(fLotteryWinCount.join());
+        ld.setOverviewPaidUserCount(fPaidUserCount.join());
+        ld.setOverviewUnpaidUserCount(fUnpaidUserCount.join());
+        ld.setOverviewTotalGmv(fTotalGmv.join());
+        ld.setOverviewTotalProductQty(fTotalProductQty.join());
+
+        // 保存到 live_data:先尝试 update,无行则 insert
+        int updated = liveDataMapper.updateLiveDataOverview(ld);
+        if (updated == 0) {
+            liveDataMapper.insertLiveDataOverview(ld);
+        }
+        return ld;
+    }
+
+    private long nullToZero(Long v) {
+        return v == null ? 0L : v;
+    }
+
+    private BigDecimal calcAvgMinutes(long totalSeconds, long userCount) {
+        if (userCount <= 0) return BigDecimal.ZERO;
+        return BigDecimal.valueOf(totalSeconds)
+                .divide(BigDecimal.valueOf(60 * userCount), 2, RoundingMode.HALF_UP);
+    }
+
+    @Override
+    public LiveEntryTrendVO getLiveEntryTrend(List<Long> liveIds) {
+        LiveEntryTrendVO vo = new LiveEntryTrendVO();
+        if (liveIds == null || liveIds.isEmpty()) {
+            vo.setXAxis(Collections.singletonList("开播前"));
+            vo.setSeries(Collections.emptyList());
+            return vo;
+        }
+        List<Map<String, Object>> rawList = liveDataMapper.selectLiveEntryTrendRawData(liveIds);
+        if (rawList == null || rawList.isEmpty()) {
+            vo.setXAxis(Collections.singletonList("开播前"));
+            vo.setSeries(Collections.emptyList());
+            return vo;
+        }
+        // 时间桶:开播前, 0min, 5min, 10min, ... (步长5分钟,最多到120分钟)
+        int stepMinutes = 5;
+        int maxMinutes = 120;
+        List<String> xAxis = new ArrayList<>();
+        xAxis.add("开播前");
+        for (int m = 0; m <= maxMinutes; m += stepMinutes) {
+            xAxis.add(m + "min");
+        }
+        // 按 liveId 分组,每个直播间一条折线(累计进入人数)
+        Map<String, Map<Integer, Integer>> liveBucketCount = new LinkedHashMap<>();
+        for (Map<String, Object> row : rawList) {
+            Object liveIdObj = row.get("liveId");
+            Object liveNameObj = row.get("liveName");
+            Object startTimeObj = row.get("startTime");
+            Object entryTimeObj = row.get("entryTime");
+            if (liveIdObj == null || startTimeObj == null || entryTimeObj == null) continue;
+            long liveId = ((Number) liveIdObj).longValue();
+            String liveName = liveNameObj != null ? liveNameObj.toString() : String.valueOf(liveId);
+            String seriesKey = liveId + "|" + liveName;
+            Date startTime = toDate(startTimeObj);
+            Date entryTime = toDate(entryTimeObj);
+            if (startTime == null || entryTime == null) continue;
+            long diffMinutes = (entryTime.getTime() - startTime.getTime()) / (60 * 1000);
+            int bucketIdx;
+            if (diffMinutes < 0) {
+                bucketIdx = 0; // 开播前
+            } else {
+                int m = (int) Math.min(diffMinutes, maxMinutes);
+                bucketIdx = 1 + (m / stepMinutes); // 1-based, 0 is 开播前
+            }
+            liveBucketCount
+                    .computeIfAbsent(seriesKey, k -> new LinkedHashMap<>())
+                    .merge(bucketIdx, 1, Integer::sum);
+        }
+        // 转为累计值并生成 series
+        List<LiveEntryTrendSeriesVO> seriesList = new ArrayList<>();
+        for (Map.Entry<String, Map<Integer, Integer>> e : liveBucketCount.entrySet()) {
+            String[] parts = e.getKey().split("\\|", 2);
+            String name = parts.length > 1 ? parts[1] + "(" + parts[0] + ")" : parts[0];
+            Map<Integer, Integer> countMap = e.getValue();
+            List<Integer> data = new ArrayList<>(xAxis.size());
+            int cumulative = 0;
+            for (int i = 0; i < xAxis.size(); i++) {
+                cumulative += countMap.getOrDefault(i, 0);
+                data.add(cumulative);
+            }
+            seriesList.add(new LiveEntryTrendSeriesVO(name, data));
+        }
+        vo.setXAxis(xAxis);
+        vo.setSeries(seriesList);
+        return vo;
+    }
+
+    @Override
+    public R listLiveRoomStudents(LiveRoomStudentParam param) {
+        if (param == null) {
+            param = new LiveRoomStudentParam();
+        }
+        if (param.getPageNum() == null || param.getPageNum() < 1) {
+            param.setPageNum(1);
+        }
+        if (param.getPageSize() == null || param.getPageSize() < 1) {
+            param.setPageSize(10);
+        }
+        PageHelper.startPage(param.getPageNum(), param.getPageSize());
+        List<LiveRoomStudentQueryVO> queryList = liveDataMapper.selectLiveRoomStudentList(param);
+        PageInfo<LiveRoomStudentQueryVO> pageInfo = new PageInfo<>(queryList);
+        List<LiveRoomStudentVO> resultList = new ArrayList<>();
+        if (queryList != null) {
+            for (LiveRoomStudentQueryVO q : queryList) {
+                LiveRoomStudentVO vo = new LiveRoomStudentVO();
+                vo.setAvatar(q.getAvatar());
+                vo.setUserName(q.getUserName());
+                vo.setLiveName(q.getLiveName());
+                vo.setSalesName(q.getSalesName());
+                vo.setUserCreateTime(q.getUserCreateTime());
+                vo.setContact(ParseUtils.parsePhone(q.getPhone()));
+                resultList.add(vo);
+            }
+        }
+        return R.ok().put("rows", resultList).put("total", pageInfo.getTotal());
+    }
+
+    @Override
+    public R listProductCompareStats(com.fs.live.param.ProductCompareParam param) {
+        if (param == null || param.getLiveIds() == null || param.getLiveIds().isEmpty()) {
+            return R.ok().put("rows", Collections.emptyList()).put("total", 0);
+        }
+        if (param.getPageNum() == null || param.getPageNum() < 1) {
+            param.setPageNum(1);
+        }
+        if (param.getPageSize() == null || param.getPageSize() < 1) {
+            param.setPageSize(10);
+        }
+        PageHelper.startPage(param.getPageNum(), param.getPageSize());
+        List<com.fs.live.vo.ProductCompareVO> list = liveDataMapper.selectProductCompareList(param);
+        PageInfo<com.fs.live.vo.ProductCompareVO> pageInfo = new PageInfo<>(list);
+        return R.ok().put("rows", pageInfo.getList()).put("total", pageInfo.getTotal());
+    }
+
+    @Override
+    public List<com.fs.live.vo.InviteSalesOptionVO> listInviteSalesOptions(List<Long> liveIds) {
+        if (liveIds == null || liveIds.isEmpty()) {
+            return Collections.emptyList();
+        }
+        return liveDataMapper.selectInviteSalesOptions(liveIds);
+    }
+
+    @Override
+    public R listInviteCompareStats(com.fs.live.param.InviteCompareParam param) {
+        if (param == null || param.getLiveIds() == null || param.getLiveIds().isEmpty()) {
+            return R.ok().put("rows", Collections.emptyList()).put("total", 0);
+        }
+        if (param.getPageNum() == null || param.getPageNum() < 1) {
+            param.setPageNum(1);
+        }
+        if (param.getPageSize() == null || param.getPageSize() < 1) {
+            param.setPageSize(10);
+        }
+        PageHelper.startPage(param.getPageNum(), param.getPageSize());
+        List<com.fs.live.vo.InviteCompareVO> list = liveDataMapper.selectInviteCompareList(param);
+        PageInfo<com.fs.live.vo.InviteCompareVO> pageInfo = new PageInfo<>(list);
+        return R.ok().put("rows", pageInfo.getList()).put("total", pageInfo.getTotal());
+    }
+
+    private Date toDate(Object val) {
+        if (val == null) return null;
+        if (val instanceof Date) return (Date) val;
+        if (val instanceof java.sql.Timestamp) return new Date(((java.sql.Timestamp) val).getTime());
+        return null;
+    }
+
     /**
      * 多线程分段查询:步长7天,等待各段数据返回后合并
      */

+ 36 - 0
fs-service/src/main/java/com/fs/live/vo/InviteCompareVO.java

@@ -0,0 +1,36 @@
+package com.fs.live.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 邀课对比统计VO(归属公司、销售名称、邀请人数、已支付订单数、订单总金额)
+ *
+ * @author fs
+ * @date 2025-03-19
+ */
+@Data
+public class InviteCompareVO {
+
+    /** 归属公司ID */
+    private Long companyId;
+
+    /** 归属公司名称 */
+    private String companyName;
+
+    /** 销售(公司用户)ID */
+    private Long companyUserId;
+
+    /** 销售名称 */
+    private String salesName;
+
+    /** 邀请人数 */
+    private Long inviteCount = 0L;
+
+    /** 已支付订单数 */
+    private Long paidOrderCount = 0L;
+
+    /** 订单总金额 */
+    private BigDecimal totalGmv = BigDecimal.ZERO;
+}

+ 25 - 0
fs-service/src/main/java/com/fs/live/vo/InviteSalesOptionVO.java

@@ -0,0 +1,25 @@
+package com.fs.live.vo;
+
+import lombok.Data;
+
+/**
+ * 邀课对比-分享人选项VO(用于筛选项下拉)
+ *
+ * @author fs
+ * @date 2025-03-19
+ */
+@Data
+public class InviteSalesOptionVO {
+
+    /** 公司ID */
+    private Long companyId;
+
+    /** 公司用户ID(company_user_id) */
+    private Long companyUserId;
+
+    /** 销售名称 */
+    private String salesName;
+
+    /** 归属公司名称 */
+    private String companyName;
+}

+ 22 - 0
fs-service/src/main/java/com/fs/live/vo/LiveEntryTrendSeriesVO.java

@@ -0,0 +1,22 @@
+package com.fs.live.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * 直播趋势-单条折线系列
+ *
+ * @author fs
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class LiveEntryTrendSeriesVO {
+    /** 系列名称,如 直播间名称(liveId) */
+    private String name;
+    /** 累计进入人数,与 xAxis 一一对应 */
+    private List<Integer> data;
+}

+ 19 - 0
fs-service/src/main/java/com/fs/live/vo/LiveEntryTrendVO.java

@@ -0,0 +1,19 @@
+package com.fs.live.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 直播趋势-进入人数折线图 VO
+ * 基于 live_user_first_entry 与 live.start_time 计算相对时间
+ *
+ * @author fs
+ */
+@Data
+public class LiveEntryTrendVO {
+    /** X轴:相对时间标签,如 开播前、0min、5min、10min */
+    private List<String> xAxis;
+    /** 折线系列:每个直播间一条线 */
+    private List<LiveEntryTrendSeriesVO> series;
+}

+ 35 - 0
fs-service/src/main/java/com/fs/live/vo/LiveRoomStudentQueryVO.java

@@ -0,0 +1,35 @@
+package com.fs.live.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 直播间学员查询结果VO(内部使用,含原始手机号)
+ *
+ * @author fs
+ * @date 2025-03-18
+ */
+@Data
+public class LiveRoomStudentQueryVO {
+
+    /** 用户头像 */
+    private String avatar;
+
+    /** 用户名 */
+    private String userName;
+
+    /** 直播间名称 */
+    private String liveName;
+
+    /** 销售名称 */
+    private String salesName;
+
+    /** 用户创建时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date userCreateTime;
+
+    /** 手机号(原始,用于加密) */
+    private String phone;
+}

+ 35 - 0
fs-service/src/main/java/com/fs/live/vo/LiveRoomStudentVO.java

@@ -0,0 +1,35 @@
+package com.fs.live.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 直播间学员VO
+ *
+ * @author fs
+ * @date 2025-03-18
+ */
+@Data
+public class LiveRoomStudentVO {
+
+    /** 用户头像 */
+    private String avatar;
+
+    /** 用户名 */
+    private String userName;
+
+    /** 直播间名称 */
+    private String liveName;
+
+    /** 销售名称 */
+    private String salesName;
+
+    /** 用户创建时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date userCreateTime;
+
+    /** 联系方式(手机号加密) */
+    private String contact;
+}

+ 54 - 0
fs-service/src/main/java/com/fs/live/vo/LiveStatisticsOverviewVO.java

@@ -0,0 +1,54 @@
+package com.fs.live.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 直播数据统计-数据概览VO
+ *
+ * @author fs
+ */
+@Data
+public class LiveStatisticsOverviewVO {
+    /** 1. 开播前访问人数(uv) */
+    private Long beforeLiveUv = 0L;
+    /** 2. 累计观看人数(uv) */
+    private Long totalWatchUv = 0L;
+    /** 3. 停留时长超过10分钟人数 */
+    private Long over10MinCount = 0L;
+    /** 4. 总观看时长(分钟) */
+    private Long totalWatchMinutes = 0L;
+    /** 5. 平均观看时长(分钟) */
+    private BigDecimal avgWatchMinutes = BigDecimal.ZERO;
+    /** 6. 累计回放观看人数(uv) */
+    private Long replayWatchUv = 0L;
+    /** 7. 累计回放访问人数(pv) */
+    private Long replayVisitPv = 0L;
+    /** 8. 仅看过回放的人数 */
+    private Long replayOnlyCount = 0L;
+    /** 9. 回放总观看时长(分钟) */
+    private Long replayTotalMinutes = 0L;
+    /** 10. 回放平均观看时长(分钟) */
+    private BigDecimal replayAvgMinutes = BigDecimal.ZERO;
+    /** 11. 完课人数(看课时长超过20分钟) */
+    private Long completeCount = 0L;
+    /** 12. 预约人数 */
+    private Long subscribeCount = 0L;
+
+    /** ========== 互动带货数据 ========== */
+    /** 13. 抽奖次数 */
+    private Long lotteryCount = 0L;
+    /** 14. 参与抽奖人数 */
+    private Long lotteryJoinCount = 0L;
+    /** 15. 中奖人数 */
+    private Long lotteryWinCount = 0L;
+    /** 16. 支付点击人数(已支付订单的用户数) */
+    private Long paidUserCount = 0L;
+    /** 17. 未支付人数(未支付订单的用户数) */
+    private Long unpaidUserCount = 0L;
+    /** 18. 总成交金额 */
+    private BigDecimal totalGmv = BigDecimal.ZERO;
+    /** 19. 商品总销量 */
+    private Long totalProductQty = 0L;
+}

+ 30 - 0
fs-service/src/main/java/com/fs/live/vo/ProductCompareVO.java

@@ -0,0 +1,30 @@
+package com.fs.live.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 商品对比统计VO
+ *
+ * @author fs
+ * @date 2025-03-19
+ */
+@Data
+public class ProductCompareVO {
+
+    /** 商品ID */
+    private Long productId;
+
+    /** 商品名称 */
+    private String productName;
+
+    /** 下单未支付人数 */
+    private Long unpaidUserCount = 0L;
+
+    /** 成交人数 */
+    private Long paidUserCount = 0L;
+
+    /** 成交金额 */
+    private BigDecimal totalGmv = BigDecimal.ZERO;
+}

+ 2 - 2
fs-service/src/main/resources/application-config-druid-bjzm-test.yml

@@ -15,8 +15,8 @@ logging:
 wx:
   miniapp:
     configs:
-      - appid: wxcfd4cd6e2375e42f   #云联融智
-        secret: 8261ae10c82f009310d92d61c141ea7e #北京卓美
+      - appid: wx40dcfa2797d6fc2d   #云联融智
+        secret: 3c44fb7a1d5ad3aa90efe576c9fb3d19 #北京卓美
         token: cbnd7lJvkripVOpyTFAna6NAWCxCrvC
         aesKey: HlEiBB55eaWUaeBVAQO3cWKWPYv1vOVQSq7nFNICw4E
         msgDataFormat: JSON

+ 2 - 2
fs-service/src/main/resources/application-config-druid-bjzm.yml

@@ -10,8 +10,8 @@ logging:
 wx:
   miniapp:
     configs:
-      - appid: wxcfd4cd6e2375e42f   #云联融智
-        secret: 8261ae10c82f009310d92d61c141ea7e #北京卓美
+      - appid: wx40dcfa2797d6fc2d   #云联融智
+        secret: 3c44fb7a1d5ad3aa90efe576c9fb3d19 #北京卓美
         token: cbnd7lJvkripVOpyTFAna6NAWCxCrvC
         aesKey: HlEiBB55eaWUaeBVAQO3cWKWPYv1vOVQSq7nFNICw4E
         msgDataFormat: JSON

+ 610 - 0
fs-service/src/main/resources/mapper/live/LiveDataMapper.xml

@@ -14,6 +14,27 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="peakConcurrentViewers"    column="peak_concurrent_viewers"    />
         <result property="favouriteNum"    column="favourite_num"    />
         <result property="followNum"    column="follow_num"    />
+        <result property="overviewBeforeLiveUv" column="overview_before_live_uv"/>
+        <result property="overviewTotalWatchUv" column="overview_total_watch_uv"/>
+        <result property="overviewOver10MinCount" column="overview_over_10_min_count"/>
+        <result property="overviewTotalWatchMinutes" column="overview_total_watch_minutes"/>
+        <result property="overviewWatchTotalSeconds" column="overview_watch_total_seconds"/>
+        <result property="overviewWatchUserCount" column="overview_watch_user_count"/>
+        <result property="overviewReplayWatchUv" column="overview_replay_watch_uv"/>
+        <result property="overviewReplayVisitPv" column="overview_replay_visit_pv"/>
+        <result property="overviewReplayOnlyCount" column="overview_replay_only_count"/>
+        <result property="overviewReplayTotalMinutes" column="overview_replay_total_minutes"/>
+        <result property="overviewReplayTotalSeconds" column="overview_replay_total_seconds"/>
+        <result property="overviewReplayUserCount" column="overview_replay_user_count"/>
+        <result property="overviewCompleteCount" column="overview_complete_count"/>
+        <result property="overviewSubscribeCount" column="overview_subscribe_count"/>
+        <result property="overviewLotteryCount" column="overview_lottery_count"/>
+        <result property="overviewLotteryJoinCount" column="overview_lottery_join_count"/>
+        <result property="overviewLotteryWinCount" column="overview_lottery_win_count"/>
+        <result property="overviewPaidUserCount" column="overview_paid_user_count"/>
+        <result property="overviewUnpaidUserCount" column="overview_unpaid_user_count"/>
+        <result property="overviewTotalGmv" column="overview_total_gmv"/>
+        <result property="overviewTotalProductQty" column="overview_total_product_qty"/>
     </resultMap>
 
     <sql id="selectLiveDataVo">
@@ -39,6 +60,68 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <include refid="selectLiveDataVo"/>
         where live_id = #{liveId}
     </select>
+
+    <!-- 查询已有数据概览缓存的直播间(overview_before_live_uv 不为空表示已缓存) -->
+    <select id="selectLiveDataOverviewByLiveIds" resultMap="LiveDataResult">
+        SELECT live_id, overview_before_live_uv, overview_total_watch_uv, overview_over_10_min_count,
+               overview_total_watch_minutes, overview_watch_total_seconds, overview_watch_user_count,
+               overview_replay_watch_uv, overview_replay_visit_pv, overview_replay_only_count,
+               overview_replay_total_minutes, overview_replay_total_seconds, overview_replay_user_count,
+               overview_complete_count, overview_subscribe_count, overview_lottery_count,
+               overview_lottery_join_count, overview_lottery_win_count, overview_paid_user_count,
+               overview_unpaid_user_count, overview_total_gmv, overview_total_product_qty
+        FROM live_data
+        WHERE live_id IN
+        <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+            #{liveId}
+        </foreach>
+        AND overview_before_live_uv IS NOT NULL
+    </select>
+
+    <!-- 更新数据概览缓存 -->
+    <update id="updateLiveDataOverview" parameterType="LiveData">
+        UPDATE live_data SET
+            overview_before_live_uv = #{overviewBeforeLiveUv},
+            overview_total_watch_uv = #{overviewTotalWatchUv},
+            overview_over_10_min_count = #{overviewOver10MinCount},
+            overview_total_watch_minutes = #{overviewTotalWatchMinutes},
+            overview_watch_total_seconds = #{overviewWatchTotalSeconds},
+            overview_watch_user_count = #{overviewWatchUserCount},
+            overview_replay_watch_uv = #{overviewReplayWatchUv},
+            overview_replay_visit_pv = #{overviewReplayVisitPv},
+            overview_replay_only_count = #{overviewReplayOnlyCount},
+            overview_replay_total_minutes = #{overviewReplayTotalMinutes},
+            overview_replay_total_seconds = #{overviewReplayTotalSeconds},
+            overview_replay_user_count = #{overviewReplayUserCount},
+            overview_complete_count = #{overviewCompleteCount},
+            overview_subscribe_count = #{overviewSubscribeCount},
+            overview_lottery_count = #{overviewLotteryCount},
+            overview_lottery_join_count = #{overviewLotteryJoinCount},
+            overview_lottery_win_count = #{overviewLotteryWinCount},
+            overview_paid_user_count = #{overviewPaidUserCount},
+            overview_unpaid_user_count = #{overviewUnpaidUserCount},
+            overview_total_gmv = #{overviewTotalGmv},
+            overview_total_product_qty = #{overviewTotalProductQty}
+        WHERE live_id = #{liveId}
+    </update>
+
+    <!-- 插入数据概览缓存(仅 overview 字段,live_id 必填) -->
+    <insert id="insertLiveDataOverview" parameterType="LiveData">
+        INSERT INTO live_data (live_id, overview_before_live_uv, overview_total_watch_uv, overview_over_10_min_count,
+            overview_total_watch_minutes, overview_watch_total_seconds, overview_watch_user_count,
+            overview_replay_watch_uv, overview_replay_visit_pv, overview_replay_only_count,
+            overview_replay_total_minutes, overview_replay_total_seconds, overview_replay_user_count,
+            overview_complete_count, overview_subscribe_count, overview_lottery_count,
+            overview_lottery_join_count, overview_lottery_win_count, overview_paid_user_count,
+            overview_unpaid_user_count, overview_total_gmv, overview_total_product_qty)
+        VALUES (#{liveId}, #{overviewBeforeLiveUv}, #{overviewTotalWatchUv}, #{overviewOver10MinCount},
+            #{overviewTotalWatchMinutes}, #{overviewWatchTotalSeconds}, #{overviewWatchUserCount},
+            #{overviewReplayWatchUv}, #{overviewReplayVisitPv}, #{overviewReplayOnlyCount},
+            #{overviewReplayTotalMinutes}, #{overviewReplayTotalSeconds}, #{overviewReplayUserCount},
+            #{overviewCompleteCount}, #{overviewSubscribeCount}, #{overviewLotteryCount},
+            #{overviewLotteryJoinCount}, #{overviewLotteryWinCount}, #{overviewPaidUserCount},
+            #{overviewUnpaidUserCount}, #{overviewTotalGmv}, #{overviewTotalProductQty})
+    </insert>
     <select id="getAllLiveIds" resultType="java.lang.Long">
         SELECT B.live_id
         FROM live B
@@ -363,6 +446,402 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </foreach>
     </select>
 
+    <!-- 直播数据统计-数据概览(12项指标) -->
+    <select id="selectLiveStatisticsOverview" resultType="com.fs.live.vo.LiveStatisticsOverviewVO">
+        SELECT
+            COALESCE((
+                SELECT COUNT(DISTINCT lufe.user_id)
+                FROM live_user_first_entry lufe
+                INNER JOIN live l2 ON lufe.live_id = l2.live_id
+                WHERE lufe.live_id IN
+                <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+                    #{liveId}
+                </foreach>
+                AND COALESCE(lufe.first_entry_time, lufe.create_time) &lt; l2.start_time
+            ), 0) AS beforeLiveUv,
+            COALESCE((
+                SELECT COUNT(DISTINCT user_id)
+                FROM live_user_first_entry
+                WHERE live_id IN
+                <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+                    #{liveId}
+                </foreach>
+            ), 0) AS totalWatchUv,
+            COALESCE((
+                SELECT COUNT(*)
+                FROM (
+                    SELECT live_id, user_id
+                    FROM live_watch_user
+                    WHERE live_id IN
+                    <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+                        #{liveId}
+                    </foreach>
+                    GROUP BY live_id, user_id
+                    HAVING COALESCE(SUM(COALESCE(online_seconds,0)), 0) > 600
+                ) t
+            ), 0) AS over10MinCount,
+            COALESCE((
+                SELECT FLOOR(SUM(COALESCE(online_seconds, 0)) / 60)
+                FROM live_watch_user
+                WHERE live_id IN
+                <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+                    #{liveId}
+                </foreach>
+            ), 0) AS totalWatchMinutes,
+            COALESCE((
+                SELECT CASE WHEN COUNT(DISTINCT user_id) > 0
+                    THEN ROUND(SUM(COALESCE(online_seconds, 0)) / 60.0 / COUNT(DISTINCT user_id), 2)
+                    ELSE 0 END
+                FROM live_watch_user
+                WHERE live_id IN
+                <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+                    #{liveId}
+                </foreach>
+            ), 0) AS avgWatchMinutes,
+            COALESCE((
+                SELECT COUNT(DISTINCT user_id)
+                FROM live_watch_user
+                WHERE live_id IN
+                <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+                    #{liveId}
+                </foreach>
+                AND replay_flag = 1
+            ), 0) AS replayWatchUv,
+            COALESCE((
+                SELECT COUNT(*)
+                FROM live_watch_user
+                WHERE live_id IN
+                <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+                    #{liveId}
+                </foreach>
+                AND replay_flag = 1
+            ), 0) AS replayVisitPv,
+            COALESCE((
+                SELECT COUNT(*)
+                FROM (
+                    SELECT lwu.user_id
+                    FROM live_watch_user lwu
+                    WHERE lwu.live_id IN
+                    <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+                        #{liveId}
+                    </foreach>
+                    AND lwu.replay_flag = 1
+                    AND NOT EXISTS (
+                        SELECT 1 FROM live_watch_user lwu2
+                        WHERE lwu2.live_id = lwu.live_id AND lwu2.user_id = lwu.user_id AND lwu2.live_flag = 1
+                    )
+                    GROUP BY lwu.live_id, lwu.user_id
+                ) t
+            ), 0) AS replayOnlyCount,
+            COALESCE((
+                SELECT FLOOR(SUM(COALESCE(online_seconds, 0)) / 60)
+                FROM live_watch_user
+                WHERE live_id IN
+                <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+                    #{liveId}
+                </foreach>
+                AND replay_flag = 1
+            ), 0) AS replayTotalMinutes,
+            COALESCE((
+                SELECT CASE WHEN COUNT(DISTINCT user_id) > 0
+                    THEN ROUND(SUM(COALESCE(online_seconds, 0)) / 60.0 / COUNT(DISTINCT user_id), 2)
+                    ELSE 0 END
+                FROM live_watch_user
+                WHERE live_id IN
+                <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+                    #{liveId}
+                </foreach>
+                AND replay_flag = 1
+            ), 0) AS replayAvgMinutes,
+            COALESCE((
+                SELECT COUNT(*)
+                FROM (
+                    SELECT live_id, user_id
+                    FROM live_watch_user
+                    WHERE live_id IN
+                    <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+                        #{liveId}
+                    </foreach>
+                    GROUP BY live_id, user_id
+                    HAVING COALESCE(SUM(COALESCE(online_seconds,0)), 0) >= 1200
+                ) t
+            ), 0) AS completeCount,
+            COALESCE((
+                SELECT COUNT(*)
+                FROM fs_miniprogram_sub_notify_task
+                WHERE task_name = '直播间预约提醒'
+                AND (
+                    <foreach collection="liveIds" item="liveId" separator=" OR ">
+                        page LIKE CONCAT('%liveId=', #{liveId}, '%')
+                    </foreach>
+                )
+            ), 0) AS subscribeCount,
+            COALESCE((SELECT COUNT(*) FROM live_lottery_conf WHERE live_id IN
+                <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">#{liveId}</foreach>
+            ), 0) AS lotteryCount,
+            COALESCE((SELECT COUNT(DISTINCT user_id) FROM live_lottery_registration WHERE live_id IN
+                <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">#{liveId}</foreach>
+            ), 0) AS lotteryJoinCount,
+            COALESCE((SELECT COUNT(*) FROM live_lottery_registration WHERE live_id IN
+                <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">#{liveId}</foreach>
+            AND is_win = 1), 0) AS lotteryWinCount,
+            COALESCE((SELECT COUNT(DISTINCT user_id) FROM live_order WHERE live_id IN
+                <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">#{liveId}</foreach>
+            AND (is_pay = '1' OR is_pay = 1)), 0) AS paidUserCount,
+            COALESCE((SELECT COUNT(DISTINCT user_id) FROM live_order WHERE live_id IN
+                <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">#{liveId}</foreach>
+            AND (is_pay IS NULL OR is_pay != '1' OR (is_pay IS NOT NULL AND is_pay != 1))), 0) AS unpaidUserCount,
+            COALESCE((SELECT SUM(COALESCE(pay_price, 0)) FROM live_order WHERE live_id IN
+                <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">#{liveId}</foreach>
+            AND (is_pay = '1' OR is_pay = 1)), 0) AS totalGmv,
+            COALESCE((SELECT SUM(COALESCE(loi.num, 0)) FROM live_order_item loi
+                INNER JOIN live_order lo ON loi.order_id = lo.order_id
+                WHERE lo.live_id IN
+                <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">#{liveId}</foreach>
+                AND (lo.is_pay = '1' OR lo.is_pay = 1)), 0) AS totalProductQty
+    </select>
+
+    <!-- ========== 数据概览拆分查询(每表独立查询,Java层合并) ========== -->
+    <!-- live_user_first_entry + live -->
+    <select id="selectOverviewBeforeLiveUv" resultType="java.lang.Long">
+        SELECT COUNT(DISTINCT lufe.user_id)
+        FROM live_user_first_entry lufe
+        INNER JOIN live l2 ON lufe.live_id = l2.live_id
+        WHERE lufe.live_id IN
+        <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+            #{liveId}
+        </foreach>
+        AND COALESCE(lufe.first_entry_time, lufe.create_time) &lt; l2.start_time
+    </select>
+    <!-- live_user_first_entry -->
+    <select id="selectOverviewTotalWatchUv" resultType="java.lang.Long">
+        SELECT COUNT(DISTINCT user_id)
+        FROM live_user_first_entry
+        WHERE live_id IN
+        <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+            #{liveId}
+        </foreach>
+    </select>
+    <!-- live_watch_user -->
+    <select id="selectOverviewOver10MinCount" resultType="java.lang.Long">
+        SELECT COUNT(*)
+        FROM (
+            SELECT live_id, user_id
+            FROM live_watch_user
+            WHERE live_id IN
+            <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+                #{liveId}
+            </foreach>
+            GROUP BY live_id, user_id
+            HAVING COALESCE(SUM(COALESCE(online_seconds,0)), 0) > 600
+        ) t
+    </select>
+    <select id="selectOverviewWatchTotalSeconds" resultType="java.lang.Long">
+        SELECT COALESCE(SUM(COALESCE(online_seconds, 0)), 0)
+        FROM live_watch_user
+        WHERE live_id IN
+        <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+            #{liveId}
+        </foreach>
+    </select>
+    <select id="selectOverviewWatchUserCount" resultType="java.lang.Long">
+        SELECT COUNT(DISTINCT user_id)
+        FROM live_watch_user
+        WHERE live_id IN
+        <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+            #{liveId}
+        </foreach>
+    </select>
+    <select id="selectOverviewReplayWatchUv" resultType="java.lang.Long">
+        SELECT COUNT(DISTINCT user_id)
+        FROM live_watch_user
+        WHERE live_id IN
+        <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+            #{liveId}
+        </foreach>
+        AND replay_flag = 1
+    </select>
+    <select id="selectOverviewReplayVisitPv" resultType="java.lang.Long">
+        SELECT COUNT(*)
+        FROM live_watch_user
+        WHERE live_id IN
+        <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+            #{liveId}
+        </foreach>
+        AND replay_flag = 1
+    </select>
+    <select id="selectOverviewReplayOnlyCount" resultType="java.lang.Long">
+        SELECT COUNT(*)
+        FROM (
+            SELECT lwu.user_id
+            FROM live_watch_user lwu
+            WHERE lwu.live_id IN
+            <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+                #{liveId}
+            </foreach>
+            AND lwu.replay_flag = 1
+            AND NOT EXISTS (
+                SELECT 1 FROM live_watch_user lwu2
+                WHERE lwu2.live_id = lwu.live_id AND lwu2.user_id = lwu.user_id AND lwu2.live_flag = 1
+            )
+            GROUP BY lwu.live_id, lwu.user_id
+        ) t
+    </select>
+    <select id="selectOverviewReplayTotalSeconds" resultType="java.lang.Long">
+        SELECT COALESCE(SUM(COALESCE(online_seconds, 0)), 0)
+        FROM live_watch_user
+        WHERE live_id IN
+        <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+            #{liveId}
+        </foreach>
+        AND replay_flag = 1
+    </select>
+    <select id="selectOverviewReplayUserCount" resultType="java.lang.Long">
+        SELECT COUNT(DISTINCT user_id)
+        FROM live_watch_user
+        WHERE live_id IN
+        <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+            #{liveId}
+        </foreach>
+        AND replay_flag = 1
+    </select>
+    <select id="selectOverviewCompleteCount" resultType="java.lang.Long">
+        SELECT COUNT(*)
+        FROM (
+            SELECT live_id, user_id
+            FROM live_watch_user
+            WHERE live_id IN
+            <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+                #{liveId}
+            </foreach>
+            GROUP BY live_id, user_id
+            HAVING COALESCE(SUM(COALESCE(online_seconds,0)), 0) >= 1200
+        ) t
+    </select>
+    <!-- fs_miniprogram_sub_notify_task -->
+    <select id="selectOverviewSubscribeCount" resultType="java.lang.Long">
+        SELECT COUNT(*)
+        FROM fs_miniprogram_sub_notify_task
+        WHERE task_name = '直播间预约提醒'
+        AND (
+            <foreach collection="liveIds" item="liveId" separator=" OR ">
+                page LIKE CONCAT('%liveId=', #{liveId}, '%')
+            </foreach>
+        )
+    </select>
+    <!-- live_lottery_conf -->
+    <select id="selectOverviewLotteryCount" resultType="java.lang.Long">
+        SELECT COUNT(*)
+        FROM live_lottery_conf
+        WHERE live_id IN
+        <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+            #{liveId}
+        </foreach>
+    </select>
+    <!-- live_lottery_registration -->
+    <select id="selectOverviewLotteryJoinCount" resultType="java.lang.Long">
+        SELECT COUNT(DISTINCT user_id)
+        FROM live_lottery_registration
+        WHERE live_id IN
+        <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+            #{liveId}
+        </foreach>
+    </select>
+    <select id="selectOverviewLotteryWinCount" resultType="java.lang.Long">
+        SELECT COUNT(*)
+        FROM live_lottery_registration
+        WHERE live_id IN
+        <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+            #{liveId}
+        </foreach>
+        AND is_win = 1
+    </select>
+    <!-- live_order -->
+    <select id="selectOverviewPaidUserCount" resultType="java.lang.Long">
+        SELECT COUNT(DISTINCT user_id)
+        FROM live_order
+        WHERE live_id IN
+        <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+            #{liveId}
+        </foreach>
+        AND (is_pay = '1' OR is_pay = 1)
+    </select>
+    <select id="selectOverviewUnpaidUserCount" resultType="java.lang.Long">
+        SELECT COUNT(DISTINCT user_id)
+        FROM live_order
+        WHERE live_id IN
+        <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+            #{liveId}
+        </foreach>
+        AND (is_pay IS NULL OR is_pay != '1' OR (is_pay IS NOT NULL AND is_pay != 1))
+    </select>
+    <select id="selectOverviewTotalGmv" resultType="java.math.BigDecimal">
+        SELECT COALESCE(SUM(COALESCE(pay_price, 0)), 0)
+        FROM live_order
+        WHERE live_id IN
+        <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+            #{liveId}
+        </foreach>
+        AND (is_pay = '1' OR is_pay = 1)
+    </select>
+    <!-- live_order_item + live_order -->
+    <select id="selectOverviewTotalProductQty" resultType="java.lang.Long">
+        SELECT COALESCE(SUM(COALESCE(loi.num, 0)), 0)
+        FROM live_order_item loi
+        INNER JOIN live_order lo ON loi.order_id = lo.order_id
+        WHERE lo.live_id IN
+        <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+            #{liveId}
+        </foreach>
+        AND (lo.is_pay = '1' OR lo.is_pay = 1)
+    </select>
+
+    <!-- 直播趋势:查询进入直播间原始数据(相对时间计算用) -->
+    <select id="selectLiveEntryTrendRawData" resultType="java.util.HashMap">
+        SELECT
+            l.live_id AS liveId,
+            l.live_name AS liveName,
+            l.start_time AS startTime,
+            COALESCE(lufe.first_entry_time, lufe.create_time) AS entryTime
+        FROM live_user_first_entry lufe
+        INNER JOIN live l ON lufe.live_id = l.live_id
+        WHERE lufe.live_id IN
+        <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+            #{liveId}
+        </foreach>
+        AND l.start_time IS NOT NULL
+    </select>
+
+    <!-- 直播间学员列表(基于 live_user_first_entry) -->
+    <select id="selectLiveRoomStudentList" resultType="com.fs.live.vo.LiveRoomStudentQueryVO">
+        SELECT
+            COALESCE(u.avatar, '') AS avatar,
+            COALESCE(u.nick_name, u.nickname, '未知用户') AS userName,
+            COALESCE(l.live_name, '') AS liveName,
+            COALESCE(cu.user_name, '') AS salesName,
+            u.create_time AS userCreateTime,
+            COALESCE(u.phone, '') AS phone
+        FROM live_user_first_entry lufe
+        LEFT JOIN fs_user u ON lufe.user_id = u.user_id
+        LEFT JOIN live l ON lufe.live_id = l.live_id
+        LEFT JOIN company_user cu ON lufe.company_user_id = cu.user_id
+        <where>
+            <if test="param.liveIds != null and param.liveIds.size() > 0">
+                AND lufe.live_id IN
+                <foreach collection="param.liveIds" item="liveId" open="(" separator="," close=")">
+                    #{liveId}
+                </foreach>
+            </if>
+            <if test="param.firstEntryTimeBegin != null">
+                AND lufe.create_time &gt;= #{param.firstEntryTimeBegin}
+            </if>
+            <if test="param.firstEntryTimeEnd != null">
+                AND lufe.create_time &lt;= #{param.firstEntryTimeEnd}
+            </if>
+        </where>
+        ORDER BY lufe.create_time DESC
+    </select>
+
     <!-- 查询直播间列表数据 -->
     <select id="selectLiveDataListByLiveIds" resultType="com.fs.live.vo.LiveDataListVo">
         SELECT
@@ -809,4 +1288,135 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </if>
         GROUP BY cu.company_id, c.company_name
     </select>
+
+    <!-- 商品对比统计:按商品汇总 下单未支付人数、成交人数、成交金额 -->
+    <select id="selectProductCompareList" resultType="com.fs.live.vo.ProductCompareVO">
+        SELECT
+            t.product_id AS productId,
+            COALESCE(fsp.product_name, '未知商品') AS productName,
+            t.unpaid_user_count AS unpaidUserCount,
+            t.paid_user_count AS paidUserCount,
+            t.total_gmv AS totalGmv
+        FROM (
+            SELECT
+                pid AS product_id,
+                COUNT(DISTINCT CASE WHEN is_paid = 0 THEN user_id END) AS unpaid_user_count,
+                COUNT(DISTINCT CASE WHEN is_paid = 1 THEN user_id END) AS paid_user_count,
+                COALESCE(SUM(CASE WHEN is_paid = 1 THEN gmv ELSE 0 END), 0) AS total_gmv
+            FROM (
+                <!-- 来自 live_order 单商品订单 -->
+                SELECT
+                    lo.product_id AS pid,
+                    lo.user_id AS user_id,
+                    CASE WHEN (lo.is_pay = '1' OR lo.is_pay = 1) THEN 1 ELSE 0 END AS is_paid,
+                    CASE WHEN (lo.is_pay = '1' OR lo.is_pay = 1) THEN COALESCE(lo.pay_price, 0) ELSE 0 END AS gmv
+                FROM live_order lo
+                WHERE lo.live_id IN
+                <foreach collection="param.liveIds" item="liveId" open="(" separator="," close=")">#{liveId}</foreach>
+                AND lo.product_id IS NOT NULL
+                <if test="param.productIds != null and param.productIds.size() > 0">
+                    AND lo.product_id IN
+                    <foreach collection="param.productIds" item="pid" open="(" separator="," close=")">#{pid}</foreach>
+                </if>
+                UNION ALL
+                <!-- 来自 live_order_item 多商品订单 -->
+                SELECT
+                    loi.product_id AS pid,
+                    lo.user_id AS user_id,
+                    CASE WHEN (lo.is_pay = '1' OR lo.is_pay = 1) THEN 1 ELSE 0 END AS is_paid,
+                    CASE WHEN (lo.is_pay = '1' OR lo.is_pay = 1)
+                        THEN COALESCE(lo.pay_price, 0) * COALESCE(loi.num, 0) / NULLIF(lo.total_num, 0)
+                        ELSE 0 END AS gmv
+                FROM live_order_item loi
+                INNER JOIN live_order lo ON loi.order_id = lo.order_id
+                WHERE lo.live_id IN
+                <foreach collection="param.liveIds" item="liveId" open="(" separator="," close=")">#{liveId}</foreach>
+                AND loi.product_id IS NOT NULL
+                AND (lo.product_id IS NULL OR lo.product_id != loi.product_id)
+                <if test="param.productIds != null and param.productIds.size() > 0">
+                    AND loi.product_id IN
+                    <foreach collection="param.productIds" item="pid" open="(" separator="," close=")">#{pid}</foreach>
+                </if>
+            ) sub
+            GROUP BY pid
+        ) t
+        LEFT JOIN fs_store_product_scrm fsp ON fsp.product_id = t.product_id
+        ORDER BY t.total_gmv DESC
+    </select>
+
+    <!-- 邀课对比-分享人选项(基于 live_user_first_entry 中存在的 company_id, company_user_id) -->
+    <select id="selectInviteSalesOptions" resultType="com.fs.live.vo.InviteSalesOptionVO">
+        SELECT DISTINCT
+            lufe.company_id AS companyId,
+            lufe.company_user_id AS companyUserId,
+            COALESCE(cu.user_name, '未知销售') AS salesName,
+            COALESCE(c.company_name, '未知公司') AS companyName
+        FROM live_user_first_entry lufe
+        LEFT JOIN company c ON lufe.company_id = c.company_id
+        LEFT JOIN company_user cu ON lufe.company_user_id = cu.user_id
+        WHERE lufe.live_id IN
+        <foreach collection="liveIds" item="liveId" open="(" separator="," close=")">
+            #{liveId}
+        </foreach>
+        AND lufe.company_id IS NOT NULL
+        AND lufe.company_user_id IS NOT NULL
+        ORDER BY c.company_name, cu.user_name
+    </select>
+
+    <!-- 邀课对比统计:按销售汇总 归属公司、销售名称、邀请人数、已支付订单数、订单总金额 -->
+    <select id="selectInviteCompareList" resultType="com.fs.live.vo.InviteCompareVO">
+        SELECT
+            t.company_id AS companyId,
+            COALESCE(c.company_name, '未知公司') AS companyName,
+            t.company_user_id AS companyUserId,
+            COALESCE(cu.user_name, '未知销售') AS salesName,
+            t.invite_count AS inviteCount,
+            COALESCE(ord.paid_order_count, 0) AS paidOrderCount,
+            COALESCE(ord.total_gmv, 0) AS totalGmv
+        FROM (
+            SELECT
+                lufe.company_id,
+                lufe.company_user_id,
+                COUNT(DISTINCT lufe.user_id) AS invite_count
+            FROM live_user_first_entry lufe
+            WHERE lufe.live_id IN
+            <foreach collection="param.liveIds" item="liveId" open="(" separator="," close=")">
+                #{liveId}
+            </foreach>
+            AND lufe.company_id IS NOT NULL
+            AND lufe.company_user_id IS NOT NULL
+            <if test="param.companyUserIds != null and param.companyUserIds.size() > 0">
+                AND lufe.company_user_id IN
+                <foreach collection="param.companyUserIds" item="uid" open="(" separator="," close=")">
+                    #{uid}
+                </foreach>
+            </if>
+            GROUP BY lufe.company_id, lufe.company_user_id
+        ) t
+        LEFT JOIN company c ON t.company_id = c.company_id
+        LEFT JOIN company_user cu ON t.company_user_id = cu.user_id
+        LEFT JOIN (
+            SELECT
+                lo.company_id,
+                lo.company_user_id,
+                COUNT(*) AS paid_order_count,
+                COALESCE(SUM(lo.pay_price), 0) AS total_gmv
+            FROM live_order lo
+            WHERE lo.live_id IN
+            <foreach collection="param.liveIds" item="liveId" open="(" separator="," close=")">
+                #{liveId}
+            </foreach>
+            AND (lo.is_pay = '1' OR lo.is_pay = 1)
+            AND lo.company_id IS NOT NULL
+            AND lo.company_user_id IS NOT NULL
+            <if test="param.companyUserIds != null and param.companyUserIds.size() > 0">
+                AND lo.company_user_id IN
+                <foreach collection="param.companyUserIds" item="uid" open="(" separator="," close=")">
+                    #{uid}
+                </foreach>
+            </if>
+            GROUP BY lo.company_id, lo.company_user_id
+        ) ord ON t.company_id = ord.company_id AND t.company_user_id = ord.company_user_id
+        ORDER BY ord.total_gmv DESC, t.invite_count DESC
+    </select>
 </mapper>

+ 2 - 0
fs-service/src/main/resources/mapper/live/LiveMapper.xml

@@ -105,6 +105,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <if test="rtmpUrl != null  and rtmpUrl != ''"> and rtmp_url = #{rtmpUrl}</if>
         <if test="flvHlsUrl != null  and flvHlsUrl != ''"> and flv_hls_url = #{flvHlsUrl}</if>
         <if test="isAudit != null "> and is_audit = #{isAudit}</if>
+        <if test="beginTime != null and beginTime != ''"> and a.create_time &gt;= STR_TO_DATE(#{beginTime}, '%Y-%m-%d %H:%i:%s')</if>
+        <if test="endTime != null and endTime != ''"> and a.create_time &lt;= STR_TO_DATE(#{endTime}, '%Y-%m-%d %H:%i:%s')</if>
         order by create_time desc
     </select>
 

+ 1 - 1
fs-user-app/src/main/java/com/fs/app/controller/course/CourseFsUserController.java

@@ -130,7 +130,7 @@ public class CourseFsUserController extends AppBaseController {
         if (config.getCompletionCountdown() != null && config.getCompletionCountdown()) {
             FsCourseWatchLog fsCourseWatchLog = fsCourseWatchLogMapper.selectFsCourseWatchLogWithUCCV(param.getFsUserId(), param.getCompanyUserId(), param.getCourseId(), param.getVideoId());
             if (fsCourseWatchLog == null) {
-                return R.error("未查询到用户的看课记录!");
+                return R.error("");
             }
             
             // 如果已经完课,剩余时间为0