Explorar el Código

木易:完课统计

wangxy hace 1 mes
padre
commit
9cbad0ff5f
Se han modificado 24 ficheros con 1252 adiciones y 218 borrados
  1. 48 7
      fs-admin/src/main/java/com/fs/company/controller/CompanyStatisticsController.java
  2. 8 0
      fs-admin/src/main/java/com/fs/course/controller/FsUserCourseController.java
  3. 8 0
      fs-admin/src/main/java/com/fs/course/controller/FsUserCourseVideoController.java
  4. 11 0
      fs-admin/src/main/java/com/fs/qw/qwTask/qwTask.java
  5. 69 0
      fs-service/src/main/java/com/fs/course/domain/FinishCourseStatistics.java
  6. 72 0
      fs-service/src/main/java/com/fs/course/mapper/FinishCourseStatisticsSyncMapper.java
  7. 15 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseWatchLogMapper.java
  8. 13 0
      fs-service/src/main/java/com/fs/course/mapper/FsUserCourseMapper.java
  9. 14 0
      fs-service/src/main/java/com/fs/course/mapper/FsUserCourseVideoMapper.java
  10. 36 0
      fs-service/src/main/java/com/fs/course/param/CourseStatisticsQueryParam.java
  11. 14 0
      fs-service/src/main/java/com/fs/course/param/FsCourseWatchLogStatisticsListParam.java
  12. 33 0
      fs-service/src/main/java/com/fs/course/service/IFinishCourseStatisticsSyncService.java
  13. 7 0
      fs-service/src/main/java/com/fs/course/service/IFsCourseWatchLogService.java
  14. 5 0
      fs-service/src/main/java/com/fs/course/service/IFsUserCourseService.java
  15. 8 0
      fs-service/src/main/java/com/fs/course/service/IFsUserCourseVideoService.java
  16. 322 0
      fs-service/src/main/java/com/fs/course/service/impl/FinishCourseStatisticsSyncServiceImpl.java
  17. 214 210
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java
  18. 5 0
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseServiceImpl.java
  19. 5 0
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  20. 59 0
      fs-service/src/main/java/com/fs/course/vo/FsCourseReportVO.java
  21. 198 0
      fs-service/src/main/resources/mapper/course/FinishCourseStatisticsSyncMapper.xml
  22. 72 1
      fs-service/src/main/resources/mapper/course/FsCourseWatchLogMapper.xml
  23. 8 0
      fs-service/src/main/resources/mapper/course/FsUserCourseMapper.xml
  24. 8 0
      fs-service/src/main/resources/mapper/course/FsUserCourseVideoMapper.xml

+ 48 - 7
fs-admin/src/main/java/com/fs/company/controller/CompanyStatisticsController.java

@@ -1,9 +1,12 @@
 package com.fs.company.controller;
 
 import com.alibaba.fastjson.JSONObject;
+import com.fs.common.annotation.Excel;
 import com.fs.common.core.controller.BaseController;
 import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.core.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.TimeUtils;
 import com.fs.common.utils.poi.ExcelUtil;
@@ -14,6 +17,10 @@ import com.fs.company.service.ICompanySmsLogsService;
 import com.fs.company.service.ICompanyUserService;
 import com.fs.company.service.ICompanyVoiceLogsService;
 import com.fs.company.vo.*;
+import com.fs.course.param.FsCourseWatchLogStatisticsListParam;
+import com.fs.course.service.IFinishCourseStatisticsSyncService;
+import com.fs.course.service.IFsCourseWatchLogService;
+import com.fs.course.vo.FsCourseReportVO;
 import com.fs.crm.param.CrmCustomerStatisticsParam;
 import com.fs.crm.service.ICrmCustomerService;
 import com.fs.crm.service.ICrmCustomerVisitService;
@@ -29,15 +36,11 @@ import com.fs.his.vo.FsStoreOrderAmountStatsVo;
 import com.fs.hisStore.service.IFsStoreOrderScrmService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PathVariable;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.*;
 
+import java.lang.reflect.Field;
 import java.math.BigDecimal;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
+import java.util.*;
 import java.util.stream.Collectors;
 
 /**
@@ -76,6 +79,12 @@ public class CompanyStatisticsController extends BaseController
     //app商城订单接口Service
     @Autowired
     private IFsStoreOrderScrmService fsStoreOrderScrmService;
+
+    @Autowired
+    private IFsCourseWatchLogService fsCourseWatchLogService;
+
+    @Autowired
+    private IFinishCourseStatisticsSyncService finishCourseStatisticsSyncService;
     @GetMapping("/storeOrder")
     public R storeOrder(FsStoreStatisticsParam param)
     {
@@ -751,4 +760,36 @@ public class CompanyStatisticsController extends BaseController
         return AjaxResult.success(scrmStatsVo);
     }
 
+    /**
+     * 木易华康特殊处理 课程完课统计数据
+     */
+    @GetMapping("/courseReport")
+    public TableDataInfo selectFsCourseReportVO(FsCourseWatchLogStatisticsListParam param) {
+        startPage();
+        List<FsCourseReportVO> fsCourseReportVOS = fsCourseWatchLogService.selectFsCourseReportVO(param);
+        return getDataTable(fsCourseReportVOS);
+    }
+
+    @GetMapping("/exportFsCourseReportVO")
+    public AjaxResult exportFsCourseReportVO(FsCourseWatchLogStatisticsListParam param) {
+        List<FsCourseReportVO> list = fsCourseWatchLogService.selectFsCourseReportVO(param);
+        List<String> allFields = Arrays.stream(FsCourseReportVO.class.getDeclaredFields())
+                .filter(field -> field.isAnnotationPresent(Excel.class))
+                .map(Field::getName)
+                .collect(Collectors.toList());
+        ExcelUtil<FsCourseReportVO> util = new ExcelUtil<FsCourseReportVO>(FsCourseReportVO.class);
+        return util.exportExcelSelectedColumns(list, "完课统计报表", allFields);
+    }
+
+    @PostMapping("/syncYesterday")
+    public AjaxResult syncYesterday() {
+        try {
+            Date startDate = DateUtils.parseDate("2026-01-07");
+            Date endDate = DateUtils.parseDate("2026-01-07");
+            finishCourseStatisticsSyncService.syncDailyStatistics(startDate, endDate);
+            return AjaxResult.success("同步指定范围数据成功");
+        } catch (Exception e) {
+            return AjaxResult.error("同步失败:" + e.getMessage());
+        }
+    }
 }

+ 8 - 0
fs-admin/src/main/java/com/fs/course/controller/FsUserCourseController.java

@@ -346,4 +346,12 @@ public class FsUserCourseController extends BaseController {
         sopTempService.syncTemplate(courseId);
         return toAjax(1);
     }
+
+    /**
+     * 课程下拉列表
+     */
+    @GetMapping("/selectCourseOptionsList")
+    public R getCourseList() {
+        return R.ok().put("data",fsUserCourseService.selectCourseOptionsList());
+    }
 }

+ 8 - 0
fs-admin/src/main/java/com/fs/course/controller/FsUserCourseVideoController.java

@@ -280,4 +280,12 @@ public class FsUserCourseVideoController extends BaseController
     public AjaxResult batchEditCover(@Validated @RequestBody BatchEditCoverParam param) {
         return toAjax(fsUserCourseVideoService.batchEditCover(param));
     }
+
+    /**
+     * 获取课程视频选项列表
+     */
+    @GetMapping("/getCourseVideoOptions")
+    public R getCourseVideoOptions(Long courseId) {
+        return R.ok().put("data", fsUserCourseVideoService.selectVideoOptionsByCourseId(courseId));
+    }
 }

+ 11 - 0
fs-admin/src/main/java/com/fs/qw/qwTask/qwTask.java

@@ -1,5 +1,6 @@
 package com.fs.qw.qwTask;
 
+import com.fs.course.service.IFinishCourseStatisticsSyncService;
 import com.fs.course.service.IFsUserCourseService;
 import com.fs.qw.domain.QwIpadServerLog;
 import com.fs.qw.domain.QwUser;
@@ -81,6 +82,9 @@ public class qwTask {
     @Autowired
     private IQwCompanyService iQwCompanyService;
 
+    @Autowired
+    private IFinishCourseStatisticsSyncService finishCourseStatisticsSyncService;
+
 
     //正在使用
     public void qwExternalContact()
@@ -369,4 +373,11 @@ public class qwTask {
 
         }
     }
+
+    /**
+     * 特殊处理(木易完课统计数据)
+     */
+    private  void  syncMultiDimensionStatistics(){
+        finishCourseStatisticsSyncService.syncMultiDimensionStatistics();
+    }
 }

+ 69 - 0
fs-service/src/main/java/com/fs/course/domain/FinishCourseStatistics.java

@@ -0,0 +1,69 @@
+package com.fs.course.domain;
+
+import com.baidu.dev2.thirdparty.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+public class FinishCourseStatistics {
+
+    /** 主键ID */
+    private Long id;
+
+    /** 公司ID */
+    @Excel(name = "公司ID")
+    private Long companyId;
+
+    /** 课程ID */
+    @Excel(name = "课程ID")
+    private Long courseId;
+
+    /** 小节ID */
+    @Excel(name = "小节ID")
+    private Long videoId;
+
+    /** 维度类型 */
+    @Excel(name = "维度类型")
+    private String dimensionType;
+
+    /** 统计日期 */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    @Excel(name = "统计日期", dateFormat = "yyyy-MM-dd")
+    private Date statDate;
+
+    /** 完成人数 */
+    @Excel(name = "完成人数")
+    private Integer finishedCount;
+
+    /** 完播数 */
+    @Excel(name = "完播数")
+    private Integer courseCompleteTimes;
+
+    /** 访问人数 */
+    @Excel(name = "访问人数")
+    private Integer accessCount;
+
+    /** 完成率 */
+    @Excel(name = "完成率")
+    private BigDecimal finishRate;
+
+    /** 同步类型 */
+    @Excel(name = "同步类型")
+    private String syncType;
+
+    /** 同步时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @Excel(name = "同步时间", dateFormat = "yyyy-MM-dd HH:mm:ss")
+    private Date syncTime;
+
+    /** 创建时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    /** 更新时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date updateTime;
+}

+ 72 - 0
fs-service/src/main/java/com/fs/course/mapper/FinishCourseStatisticsSyncMapper.java

@@ -0,0 +1,72 @@
+package com.fs.course.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.course.domain.FinishCourseStatistics;
+import com.fs.course.param.CourseStatisticsQueryParam;
+import com.fs.course.vo.FsCourseReportVO;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+public interface FinishCourseStatisticsSyncMapper extends BaseMapper<FinishCourseStatistics> {
+
+    /**
+     * 按公司维度查询统计
+     */
+    List<Map<String, Object>> selectCompanyStatistics(@Param("startTime") Date startTime,
+                                                      @Param("endTime") Date endTime);
+
+    /**
+     * 按课程维度查询统计
+     */
+    List<Map<String, Object>> selectCourseStatistics(@Param("startTime") Date startTime,
+                                                     @Param("endTime") Date endTime);
+
+    /**
+     * 按小节维度查询统计
+     */
+    List<Map<String, Object>> selectVideoStatistics(@Param("startTime") Date startTime,
+                                                    @Param("endTime") Date endTime);
+
+    /**
+     * 分维度查询统计结果
+     * @param courseStatisticsQueryParam
+     * @return
+     */
+    List<FsCourseReportVO> querySimpleStatistics(CourseStatisticsQueryParam courseStatisticsQueryParam);
+    /**
+     * 批量插入统计结果
+     */
+    int batchInsertStatistics(List<FinishCourseStatistics> list);
+
+    /**
+     * 删除指定维度的同步数据
+     */
+    int deleteByDimension(@Param("statDate") Date statDate,
+                          @Param("dimensionType") String dimensionType);
+
+    /**
+     * 检查指定维度数据是否已同步
+     */
+    int checkDimensionExists(@Param("statDate") Date statDate,
+                             @Param("dimensionType") String dimensionType);
+
+    /**
+     * 清理旧数据(保留最近N天)
+     */
+    int cleanOldData(@Param("keepDays") int keepDays);
+
+    /**
+     * 获取数据日期范围
+     */
+    Map<String, Date> selectDateRange();
+
+    /**
+     * 查询未同步的日期列表
+     */
+    List<Date> selectUnsyncedDates(@Param("startDate") Date startDate,
+                                   @Param("endDate") Date endDate);
+
+}

+ 15 - 0
fs-service/src/main/java/com/fs/course/mapper/FsCourseWatchLogMapper.java

@@ -719,4 +719,19 @@ public interface FsCourseWatchLogMapper extends BaseMapper<FsCourseWatchLog> {
             "</script>"
     })
     List<Long> getExContactIdsIdsByWatchLogIds(@Param("watchLogIds")List<Long> watchLogIds);
+
+    /**
+     *  公司基本信息
+     * @param param
+     * @return
+     */
+    List<FsCourseReportVO> selectCompanyBaseInfo(FsCourseWatchLogStatisticsListParam param);
+
+    /**
+     * 看课信息
+     * @param param
+     * @return
+     */
+    List<FsCourseReportVO> selectWatchStatistics(FsCourseWatchLogStatisticsListParam param);
+
 }

+ 13 - 0
fs-service/src/main/java/com/fs/course/mapper/FsUserCourseMapper.java

@@ -2,6 +2,7 @@ package com.fs.course.mapper;
 
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 import com.fs.common.annotation.DataSource;
 import com.fs.common.enums.DataSourceType;
@@ -338,4 +339,16 @@ public interface FsUserCourseMapper
      */
     @Update("update fs_user_course set config_json = #{configJson} where course_id = #{id}")
     void editConfig(@Param("id") Long id, @Param("configJson") String configJson);
+    /**
+     * 根据课程id查询课程名称
+     */
+    List<Map<String, Object>> selectCourseNamesByIds(@Param("courseIds") Set<Long> courseIds);
+
+    @Select(" SELECT course_id as dictValue, \n" +
+            "           course_name as dictLabel \n" +
+            "    FROM fs_user_course \n" +
+            "    WHERE is_del = 0 \n" +
+            "    GROUP BY course_id, course_name")
+    List<OptionsVO> selectCourseOptionsList();
+
 }

+ 14 - 0
fs-service/src/main/java/com/fs/course/mapper/FsUserCourseVideoMapper.java

@@ -17,6 +17,7 @@ import org.apache.ibatis.annotations.Update;
 import java.math.BigDecimal;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 /**
  * 课堂视频Mapper接口
@@ -292,4 +293,17 @@ public interface FsUserCourseVideoMapper extends BaseMapper<FsUserCourseVideo> {
      * 批量修改视频封面
      */
     int batchEditCover(BatchEditCoverParam param);
+
+    /**
+     * 批量修改视频名称
+     */
+    List<Map<String, Object>> selectVideoNamesByIds(@Param("videoIds") Set<Long> videoIds);
+
+    @Select("SELECT video_id as dictValue, \n" +
+            "           title as dictLabel \n" +
+            "    FROM fs_user_course_video \n" +
+            "    WHERE is_del = 0 \n" +
+            "      AND course_id = #{courseId}")
+    List<OptionsVO> selectVideoOptionsByCourseId(@Param("courseId") Long courseId);
+
 }

+ 36 - 0
fs-service/src/main/java/com/fs/course/param/CourseStatisticsQueryParam.java

@@ -0,0 +1,36 @@
+package com.fs.course.param;
+
+import lombok.Data;
+
+import java.time.LocalDate;
+
+/**
+ * 特殊处理(木易完课统计查询参数)
+ */
+@Data
+public class CourseStatisticsQueryParam {
+
+    /**
+     * 维度类型
+     * 可选值: company(公司), course(课程), video(视频)
+     */
+    private String dimensionType;
+
+    /**
+     * 开始日期
+     */
+    private String startDate;
+
+    /**
+     * 结束日期
+     */
+    private String endDate;
+
+    private  Long companyId;
+
+    private  Long courseId;
+
+    private  Long videoId;
+
+
+}

+ 14 - 0
fs-service/src/main/java/com/fs/course/param/FsCourseWatchLogStatisticsListParam.java

@@ -36,10 +36,24 @@ public class FsCourseWatchLogStatisticsListParam {
         return DateUtils.getEndOfDayString(eTime);
     }
 
+    private String startTime;
+
+    private String endTime;
+
     private Long project;
 
     private Long pageNum;
     private Long pageSize;
 
     private Integer sendType; //归属发送方式:1 个微  2 企微
+
+    /**
+     * 公司ids
+     */
+    private  List<Long> companyIds;
+
+    /**
+     * 标识('course','company')
+     */
+    private  String dimension ;
 }

+ 33 - 0
fs-service/src/main/java/com/fs/course/service/IFinishCourseStatisticsSyncService.java

@@ -0,0 +1,33 @@
+package com.fs.course.service;
+
+import java.util.Date;
+
+public interface IFinishCourseStatisticsSyncService {
+
+    /**
+     * 每日定时同步(同步前一天的数据)
+     */
+    void syncMultiDimensionStatistics();
+
+    /**
+     * 按指定日期范围同步多维度统计数据
+     * @param startDate 开始日期
+     * @param endDate 结束日期
+     */
+    void syncDailyStatistics(Date startDate, Date endDate);
+
+    /**
+     * 同步历史数据
+     */
+    void syncHistoryStatistics();
+
+    /**
+     * 增量同步(同步未同步的日期)
+     */
+    void syncIncrementalStatistics();
+
+    /**
+     * 重新同步指定日期范围
+     */
+    void resyncStatistics(Date startDate, Date endDate);
+}

+ 7 - 0
fs-service/src/main/java/com/fs/course/service/IFsCourseWatchLogService.java

@@ -154,4 +154,11 @@ public interface IFsCourseWatchLogService extends IService<FsCourseWatchLog> {
      * @return
      */
     List<Long> getExContactIdsIdsByWatchLogIds(List<Long> watchLogIds);
+
+    /**
+     * 看课统计报表
+     * @param param
+     * @return
+     */
+    List<FsCourseReportVO> selectFsCourseReportVO(FsCourseWatchLogStatisticsListParam param);
 }

+ 5 - 0
fs-service/src/main/java/com/fs/course/service/IFsUserCourseService.java

@@ -137,4 +137,9 @@ public interface IFsUserCourseService {
      * 修改课堂配置
      */
     void editConfig(Long id, String configJson);
+
+    /**
+     * 获取课程选项列表
+     */
+    List<OptionsVO> selectCourseOptionsList();
 }

+ 8 - 0
fs-service/src/main/java/com/fs/course/service/IFsUserCourseVideoService.java

@@ -254,4 +254,12 @@ public interface IFsUserCourseVideoService extends IService<FsUserCourseVideo> {
      * 批量修改视频封面
      */
     int batchEditCover(BatchEditCoverParam param);
+
+    /**
+     * 获取课程视频选项列表
+     *
+     * @param courseId 课程ID
+     * @return list
+     */
+    List<OptionsVO> selectVideoOptionsByCourseId(Long courseId);
 }

+ 322 - 0
fs-service/src/main/java/com/fs/course/service/impl/FinishCourseStatisticsSyncServiceImpl.java

@@ -0,0 +1,322 @@
+package com.fs.course.service.impl;
+
+import com.fs.common.utils.DateUtils;
+import com.fs.course.domain.FinishCourseStatistics;
+import com.fs.course.mapper.FinishCourseStatisticsSyncMapper;
+import com.fs.course.service.IFinishCourseStatisticsSyncService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+public class FinishCourseStatisticsSyncServiceImpl  implements IFinishCourseStatisticsSyncService {
+
+    private final FinishCourseStatisticsSyncMapper syncMapper;
+
+    public FinishCourseStatisticsSyncServiceImpl(FinishCourseStatisticsSyncMapper syncMapper) {
+        this.syncMapper = syncMapper;
+    }
+
+
+    // 维度定义
+    private static final List<String> DIMENSIONS = Arrays.asList(
+            "company", "course", "video"
+    );
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void syncMultiDimensionStatistics() {
+        try {
+            // 获取昨天的日期
+            Date yesterday = DateUtils.addDays(new Date(), -1);
+            String dateStr = DateUtils.parseDateToStr("yyyy-MM-dd", yesterday);
+
+            log.info("开始同步{}的统计数据", dateStr);
+
+            // 按维度分别检查和处理
+            for (String dimensionType : DIMENSIONS) {
+                try {
+                    // 检查该维度是否已经同步
+                    int exists = syncMapper.checkDimensionExists(yesterday, dimensionType);
+                    if (exists > 0) {
+                        log.info("维度 {} 的 {} 数据已同步,跳过",
+                                dimensionType, dateStr);
+                        continue; // 跳过该维度,继续处理其他维度
+                    }
+
+                    // 同步该维度数据
+                    log.info("开始同步维度 {} 的数据", dimensionType);
+                    long dimStartTime = System.currentTimeMillis();
+
+                    // 获取时间范围
+                    Date startTime = getStartOfDay(yesterday);
+
+                    Date endTime = getEndOfDay(yesterday);
+
+                    // 同步单个维度
+                    syncSingleDimension(dimensionType, yesterday, startTime, endTime);
+
+                    long dimEndTime = System.currentTimeMillis();
+                    log.info("维度 {} 同步完成,耗时:{}ms",
+                            dimensionType, dimEndTime - dimStartTime);
+
+                } catch (Exception e) {
+                    log.error("维度 {} 同步失败:{}", dimensionType, e.getMessage(), e);
+                    // 继续处理其他维度
+                }
+            }
+
+            // 清理旧数据(保留90天)
+            syncMapper.cleanOldData(90);
+
+            log.info("每日同步完成");
+
+        } catch (Exception e) {
+            log.error("每日同步任务执行失败:{}", e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 同步单个维度数据
+     */
+    private void syncSingleDimension(String dimensionType, Date statDate,
+                                     Date startTime, Date endTime) {
+        // 1. 先删除已存在的该维度数据
+        int deleted = syncMapper.deleteByDimension(statDate, dimensionType);
+        if (deleted > 0) {
+            log.debug("删除维度 {} 的{}条旧数据", dimensionType, deleted);
+        }
+
+        // 2. 查询该维度的统计数据
+        List<Map<String, Object>> statsData = queryDimensionStatistics(
+                dimensionType, startTime, endTime);
+
+        if (statsData.isEmpty()) {
+            log.debug("维度 {} 没有数据", dimensionType);
+            return;
+        }
+
+        // 3. 为每条数据添加日期和同步信息
+        // 3. 转换为实体
+        List<FinishCourseStatistics> statisticsList = convertToStatistics(
+                statsData, statDate, dimensionType, "DAILY");
+
+        // 4. 批量插入
+        int inserted = syncMapper.batchInsertStatistics(statisticsList);
+        log.debug("维度 {} 插入{}条数据", dimensionType, inserted);
+    }
+
+    /**
+     * 查询指定维度的统计数据
+     */
+    private List<Map<String, Object>> queryDimensionStatistics(String dimensionType,Date startTime, Date endTime) {
+        switch (dimensionType) {
+            case "company":
+                return syncMapper.selectCompanyStatistics(startTime,endTime);
+            case "course":
+                return syncMapper.selectCourseStatistics(startTime,endTime);
+            case "video":
+                return syncMapper.selectVideoStatistics(startTime,endTime);
+            default:
+                throw new IllegalArgumentException("不支持的维度类型:" + dimensionType);
+        }
+    }
+
+    /**
+     * 转换统计数据为实体
+     */
+    private List<FinishCourseStatistics> convertToStatistics(List<Map<String, Object>> statisticsData,
+                                                             Date statDate, String dimensionType, String syncType) {
+        return statisticsData.stream()
+                .map(map -> {
+                    FinishCourseStatistics stats = new FinishCourseStatistics();
+
+                    // 设置维度ID
+                    stats.setCompanyId(getLongValue(map.get("company_id")));
+                    stats.setCourseId(getLongValue(map.get("course_id")));
+                    stats.setVideoId(getLongValue(map.get("video_id")));
+                    stats.setDimensionType(dimensionType);
+                    stats.setStatDate(statDate);
+
+                    // 设置统计指标
+                    stats.setFinishedCount(getIntValue(map.get("finished_count")));
+                    stats.setCourseCompleteTimes(getIntValue(map.get("course_complete_times")));
+                    stats.setAccessCount(getIntValue(map.get("access_count")));
+                    stats.setFinishRate(getBigDecimalValue(map.get("finish_rate")));
+
+                    // 设置同步信息
+                    stats.setSyncType(syncType);
+                    stats.setSyncTime(new Date());
+                    stats.setCreateTime(new Date());
+                    stats.setUpdateTime(new Date());
+
+                    return stats;
+                })
+                .collect(Collectors.toList());
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void syncDailyStatistics(Date startDate, Date endDate) {
+        try {
+            String startDateStr = DateUtils.parseDateToStr("yyyy-MM-dd", startDate);
+            String endDateStr = DateUtils.parseDateToStr("yyyy-MM-dd", endDate);
+            log.info("开始同步{}到{}的统计数据", startDateStr, endDateStr);
+
+            // 计算日期范围内所有日期
+            List<Date> dateRange = getDateRange(startDate, endDate);
+
+            // 按维度分别检查和处理
+            for (String dimensionType : DIMENSIONS) {
+                log.info("开始同步维度 {} 的数据", dimensionType);
+                long dimStartTime = System.currentTimeMillis();
+
+                // 对日期范围内的每一天进行处理
+                for (Date statDate : dateRange) {
+                    try {
+                        // 检查该维度当天是否已经同步
+                        int exists = syncMapper.checkDimensionExists(statDate, dimensionType);
+                        if (exists > 0) {
+                            log.info("维度 {} 的 {} 数据已同步,跳过",
+                                    dimensionType, DateUtils.parseDateToStr("yyyy-MM-dd", statDate));
+                            continue; // 跳过该日期,继续处理下一天
+                        }
+
+                        // 获取当天的时间范围
+                        Date startTime = getStartOfDay(statDate);
+                        Date endTime = getEndOfDay(statDate);
+
+                        // 同步单个维度当天数据
+                        syncSingleDimension(dimensionType, statDate, startTime, endTime);
+
+                    } catch (Exception e) {
+                        log.error("维度 {} 在日期 {} 同步失败:{}",
+                                dimensionType, DateUtils.parseDateToStr("yyyy-MM-dd", statDate), e.getMessage(), e);
+                        // 继续处理下一天
+                    }
+                }
+
+                long dimEndTime = System.currentTimeMillis();
+                log.info("维度 {} 同步完成,耗时:{}ms", dimensionType, dimEndTime - dimStartTime);
+            }
+
+            // 清理旧数据(保留90天)
+            syncMapper.cleanOldData(90);
+
+            log.info("指定日期范围同步完成");
+
+        } catch (Exception e) {
+            log.error("指定日期范围同步任务执行失败:{}", e.getMessage(), e);
+            throw e;
+        }
+
+    }
+
+
+    /**
+     * 获取日期范围内的所有日期列表
+     */
+    private List<Date> getDateRange(Date startDate, Date endDate) {
+        List<Date> dateList = new ArrayList<>();
+        Calendar calendar = Calendar.getInstance();
+        calendar.setTime(startDate);
+
+        Calendar endCalendar = Calendar.getInstance();
+        endCalendar.setTime(endDate);
+
+        while (!calendar.getTime().after(endCalendar.getTime())) {
+            dateList.add(calendar.getTime());
+            calendar.add(Calendar.DAY_OF_MONTH, 1);
+        }
+
+        return dateList;
+    }
+
+
+    /**
+     * 获取一天的开始时间(00:00:00.000)
+     */
+    private Date getStartOfDay(Date date) {
+        Calendar calendar = Calendar.getInstance();
+        calendar.setTime(date);
+        calendar.set(Calendar.HOUR_OF_DAY, 0);
+        calendar.set(Calendar.MINUTE, 0);
+        calendar.set(Calendar.SECOND, 0);
+        calendar.set(Calendar.MILLISECOND, 0);
+        return calendar.getTime();
+    }
+
+    private Date getEndOfDay(Date date) {
+        Calendar calendar = Calendar.getInstance();
+        calendar.setTime(date);
+        calendar.set(Calendar.HOUR_OF_DAY, 23);
+        calendar.set(Calendar.MINUTE, 59);
+        calendar.set(Calendar.SECOND, 59);
+        calendar.set(Calendar.MILLISECOND, 999);
+        return calendar.getTime();
+    }
+
+    @Override
+    public void syncHistoryStatistics() {
+
+    }
+
+    @Override
+    public void syncIncrementalStatistics() {
+
+    }
+
+    @Override
+    public void resyncStatistics(Date startDate, Date endDate) {
+
+    }
+
+
+    // 辅助方法
+    private Long getLongValue(Object value) {
+        if (value == null) return null;
+        if (value instanceof Long) return (Long) value;
+        if (value instanceof Integer) return ((Integer) value).longValue();
+        if (value instanceof String) {
+            try {
+                return Long.parseLong((String) value);
+            } catch (NumberFormatException e) {
+                return null;
+            }
+        }
+        return null;
+    }
+
+    private Integer getIntValue(Object value) {
+        if (value == null) return 0;
+        if (value instanceof Integer) return (Integer) value;
+        if (value instanceof Long) return ((Long) value).intValue();
+        if (value instanceof String) {
+            try {
+                return Integer.parseInt((String) value);
+            } catch (NumberFormatException e) {
+                return 0;
+            }
+        }
+        return 0;
+    }
+
+    private BigDecimal getBigDecimalValue(Object value) {
+        if (value == null) return BigDecimal.ZERO;
+        if (value instanceof BigDecimal) return (BigDecimal) value;
+        if (value instanceof Double) return BigDecimal.valueOf((Double) value);
+        if (value instanceof Float) return BigDecimal.valueOf((Float) value);
+        if (value instanceof String) {
+            try {
+                return new BigDecimal((String) value);
+            } catch (NumberFormatException e) {
+                return BigDecimal.ZERO;
+            }
+        }
+        return BigDecimal.ZERO;
+    }
+}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 214 - 210
fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java


+ 5 - 0
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseServiceImpl.java

@@ -806,6 +806,11 @@ public class FsUserCourseServiceImpl implements IFsUserCourseService
         fsUserCourseMapper.editConfig(id, configJson);
     }
 
+    @Override
+    public List<OptionsVO> selectCourseOptionsList() {
+        return fsUserCourseMapper.selectCourseOptionsList();
+    }
+
 
     private Graphics2D initializeGraphics(BufferedImage combined) {
         Graphics2D graphics = combined.createGraphics();

+ 5 - 0
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java

@@ -4494,5 +4494,10 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
     public int batchEditCover(BatchEditCoverParam param) {
         return baseMapper.batchEditCover(param);
     }
+
+    @Override
+    public List<OptionsVO> selectVideoOptionsByCourseId(Long courseId) {
+        return fsUserCourseVideoMapper.selectVideoOptionsByCourseId(courseId);
+    }
 }
 

+ 59 - 0
fs-service/src/main/java/com/fs/course/vo/FsCourseReportVO.java

@@ -0,0 +1,59 @@
+package com.fs.course.vo;
+
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+@Data
+public class FsCourseReportVO {
+
+    /** 公司id */
+    private  Long companyId;
+
+    /** 公司名称 */
+    @Excel(name = "销售公司")
+    private String companyName;
+
+    /** 课程id */
+    private  Long courseId;
+
+
+    /** 课程名称 */
+    @Excel(name = "课程名称")
+    private  String courseName;
+
+
+    /**
+     * 进线人数
+     */
+    @Excel(name = "进线人数")
+    private  Integer accessCount;
+
+
+    /**
+     * 完课人数
+     */
+    @Excel(name = "完课人数")
+    private  Integer finishedCount;
+
+    /**
+     * 完播数
+     */
+    @Excel(name = "完播数")
+    private  Integer courseCompleteTimes;
+
+    /**
+     * 完课率
+     */
+    @Excel(name = "完课率")
+    private   BigDecimal finishRate;
+
+
+    /** 视频id */
+    private  Long videoId;
+
+    /** 视频名称 */
+    @Excel(name = "视频名称")
+    private  String videoName;
+}

+ 198 - 0
fs-service/src/main/resources/mapper/course/FinishCourseStatisticsSyncMapper.xml

@@ -0,0 +1,198 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.course.mapper.FinishCourseStatisticsSyncMapper">
+    <!-- 公司维度统计 -->
+    <select id="selectCompanyStatistics" resultType="map">
+        SELECT
+            fwl.company_id,
+            NULL as course_id,
+            NULL as video_id,
+            'company' as dimension_type,
+            COUNT(DISTINCT CASE WHEN fwl.log_type = 2 THEN fwl.user_id END) AS finished_count,
+            COUNT(CASE WHEN fwl.log_type = 2 THEN fwl.log_id END) AS course_complete_times,
+            COUNT(DISTINCT CASE WHEN fwl.log_type != 3 THEN fwl.user_id END) AS access_count,
+            IFNULL(
+                    ROUND(
+                            (COUNT(DISTINCT CASE WHEN fwl.log_type = 2 THEN fwl.user_id END) /
+                             NULLIF(COUNT(DISTINCT CASE WHEN fwl.log_type != 3 THEN fwl.user_id END), 0)) * 100,
+                            2
+                    ),
+                    0
+            ) AS finish_rate
+        FROM fs_course_watch_log fwl
+        WHERE fwl.send_type = 2
+        AND fwl.create_time &gt;= #{startTime}
+        AND fwl.create_time &lt; #{endTime}
+        GROUP BY fwl.company_id
+    </select>
+
+    <!-- 课程维度统计 -->
+    <select id="selectCourseStatistics" resultType="map">
+        SELECT
+            NULL as company_id,
+            fwl.course_id,
+            NULL as video_id,
+            'course' as dimension_type,
+            COUNT(DISTINCT CASE WHEN fwl.log_type = 2 THEN fwl.user_id END) AS finished_count,
+            COUNT(CASE WHEN fwl.log_type = 2 THEN fwl.log_id END) AS course_complete_times,
+            COUNT(DISTINCT CASE WHEN fwl.log_type != 3 THEN fwl.user_id END) AS access_count,
+            IFNULL(
+                    ROUND(
+                            (COUNT(DISTINCT CASE WHEN fwl.log_type = 2 THEN fwl.user_id END) /
+                             NULLIF(COUNT(DISTINCT CASE WHEN fwl.log_type != 3 THEN fwl.user_id END), 0)) * 100,
+                            2
+                    ),
+                    0
+            ) AS finish_rate
+        FROM fs_course_watch_log fwl
+        WHERE fwl.send_type = 2
+          AND fwl.create_time &gt;= #{startTime}
+          AND fwl.create_time &lt; #{endTime}
+        GROUP BY fwl.course_id
+    </select>
+
+    <!-- 小节维度统计 -->
+    <select id="selectVideoStatistics" resultType="map">
+        SELECT
+            NULL as company_id,
+            NULL as course_id,
+            fwl.video_id,
+            'video' as dimension_type,
+            COUNT(DISTINCT CASE WHEN fwl.log_type = 2 THEN fwl.user_id END) AS finished_count,
+            COUNT(CASE WHEN fwl.log_type = 2 THEN fwl.log_id END) AS course_complete_times,
+            COUNT(DISTINCT CASE WHEN fwl.log_type != 3 THEN fwl.user_id END) AS access_count,
+            IFNULL(
+                    ROUND(
+                            (COUNT(DISTINCT CASE WHEN fwl.log_type = 2 THEN fwl.user_id END) /
+                             NULLIF(COUNT(DISTINCT CASE WHEN fwl.log_type != 3 THEN fwl.user_id END), 0)) * 100,
+                            2
+                    ),
+                    0
+            ) AS finish_rate
+        FROM fs_course_watch_log fwl
+        WHERE fwl.send_type = 2
+          AND fwl.create_time &gt;= #{startTime}
+          AND fwl.create_time &lt; #{endTime}
+        GROUP BY fwl.video_id
+    </select>
+
+    <!-- 批量插入 -->
+    <insert id="batchInsertStatistics" parameterType="list">
+        INSERT INTO fs_finish_course_statistics_sync (
+        company_id, course_id, video_id, dimension_type,
+        stat_date, finished_count, course_complete_times, access_count,
+        finish_rate, sync_type, sync_time, create_time, update_time
+        ) VALUES
+        <foreach collection="list" item="item" separator=",">
+            (
+            #{item.companyId}, #{item.courseId}, #{item.videoId}, #{item.dimensionType},
+            #{item.statDate}, #{item.finishedCount}, #{item.courseCompleteTimes}, #{item.accessCount},
+            #{item.finishRate}, #{item.syncType}, #{item.syncTime},
+            #{item.createTime}, #{item.updateTime}
+            )
+        </foreach>
+        ON DUPLICATE KEY UPDATE
+        finished_count = VALUES(finished_count),
+        course_complete_times = VALUES(course_complete_times),
+        access_count = VALUES(access_count),
+        finish_rate = VALUES(finish_rate),
+        update_time = VALUES(update_time),
+        sync_time = VALUES(sync_time)
+    </insert>
+
+    <!-- 删除指定维度数据 -->
+    <delete id="deleteByDimension">
+        DELETE FROM fs_finish_course_statistics_sync
+        WHERE stat_date = #{statDate}
+          AND dimension_type = #{dimensionType}
+    </delete>
+
+    <!-- 检查维度数据是否存在 -->
+    <select id="checkDimensionExists" resultType="int">
+        SELECT COUNT(1)
+        FROM fs_finish_course_statistics_sync
+        WHERE stat_date = #{statDate}
+          AND dimension_type = #{dimensionType}
+    </select>
+
+    <!-- 清理旧数据 -->
+    <delete id="cleanOldData">
+        DELETE FROM fs_finish_course_statistics_sync
+        WHERE stat_date &lt; DATE_SUB(CURDATE(), INTERVAL #{keepDays} DAY)
+          AND sync_type = 'DAILY'
+    </delete>
+
+    <!-- 获取数据日期范围 -->
+    <select id="selectDateRange" resultType="map">
+        SELECT
+            MIN(DATE(create_time)) as start_date,
+            MAX(DATE(create_time)) as end_date
+        FROM fs_course_watch_log
+        WHERE send_type = 2
+    </select>
+
+    <!-- 查询未同步的日期 -->
+    <select id="selectUnsyncedDates" resultType="java.util.Date">
+        SELECT DISTINCT DATE(create_time) as stat_date
+        FROM fs_course_watch_log
+        WHERE send_type = 2
+          AND DATE(create_time) BETWEEN #{startDate} AND #{endDate}
+          AND DATE(create_time) NOT IN (
+            SELECT DISTINCT stat_date
+            FROM fs_finish_course_statistics_sync
+            WHERE sync_type = 'DAILY'
+          AND dimension_type = 'COMPANY'
+            )
+        ORDER BY stat_date
+    </select>
+    <select id="querySimpleStatistics" resultType="com.fs.course.vo.FsCourseReportVO">
+        SELECT
+        <choose>
+            <when test="dimensionType == 'company'">
+              company_id AS companyId,
+            </when>
+            <when test="dimensionType == 'course'">
+              course_id As courseId,
+            </when>
+            <when test="dimensionType == 'video'">
+              video_id AS videoId,
+            </when>
+        </choose>
+        SUM(access_count) AS accessCount,
+        SUM(finished_count) AS finishedCount,
+        SUM(course_complete_times) AS courseCompleteTimes,
+        ROUND(
+        IF(SUM(access_count) > 0,
+        SUM(finished_count) * 100.0 / SUM(access_count),
+        0),
+        2
+        ) AS finishRate
+
+        FROM `fs_his`.`fs_finish_course_statistics_sync`
+        WHERE dimension_type LIKE CONCAT('%', #{dimensionType}, '%')
+        <if test="startDate != null and startDate != ''">
+            AND stat_date &gt;= #{startDate}
+        </if>
+        <if test="endDate != null and endDate != ''">
+            AND stat_date  &lt;= #{endDate}
+        </if>
+        <if test="companyId != null">
+            AND company_id = #{companyId}
+        </if>
+        <if test="courseId != null">
+            AND course_id = #{courseId}
+        </if>
+        <if test="videoId != null">
+            AND video_id = #{videoId}
+        </if>
+        GROUP BY
+        <choose>
+            <when test="dimensionType == 'company'">company_id</when>
+            <when test="dimensionType == 'course'">course_id</when>
+            <otherwise>video_id</otherwise>
+        </choose>
+        ORDER BY accessCount desc
+    </select>
+
+</mapper>

+ 72 - 1
fs-service/src/main/resources/mapper/course/FsCourseWatchLogMapper.xml

@@ -2,7 +2,7 @@
 <!DOCTYPE mapper
 PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
-<mapper namespace="com.fs.course.mapper.FsCourseWatchLogMapper">
+    <mapper namespace="com.fs.course.mapper.FsCourseWatchLogMapper">
 
     <resultMap type="FsCourseWatchLog" id="FsCourseWatchLogResult">
         <result property="logId"    column="log_id"    />
@@ -1120,4 +1120,75 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         DATE(o.create_time)
         ) AS t
     </select>
+    <select id="selectCompanyBaseInfo" resultType="com.fs.course.vo.FsCourseReportVO">
+        SELECT
+        c.company_id AS companyId,
+        c.company_name AS companyName
+        FROM company c
+        <where>
+             c.is_del=0
+            <if test="companyId != null and companyId != ''">
+                AND c.company_id = #{companyId}
+            </if>
+        </where>
+        GROUP BY c.company_id, c.company_name
+        ORDER BY c.create_time DESC
+    </select>
+    <select id="selectWatchStatistics" resultType="com.fs.course.vo.FsCourseReportVO">
+        SELECT
+        <choose>
+            <when test="dimension == 'company'">
+                fwl.company_id AS companyId,
+            </when>
+            <when test="dimension == 'course'">
+                fwl.course_id As courseId,
+            </when>
+            <when test="dimension == 'video'">
+                fwl.video_id AS videoId,
+            </when>
+        </choose>
+        COUNT(DISTINCT CASE WHEN fwl.log_type = 2 THEN fwl.user_id END ) AS finishedCount,
+        COUNT(CASE WHEN fwl.log_type = 2 THEN fwl.log_id END) AS courseCompleteTimes,
+        COUNT(DISTINCT CASE WHEN fwl.log_type != 3 THEN fwl.user_id END) AS accessCount,
+        ifnull(
+        ROUND(
+        (
+        COUNT( DISTINCT CASE WHEN fwl.log_type = 2 THEN fwl.user_id END ) / count( DISTINCT CASE WHEN fwl.log_type != 3
+        THEN fwl.user_id END )) * 100,
+        2
+        ),
+        0
+        ) AS finishRate
+        FROM
+        fs_course_watch_log fwl
+        <where>
+            fwl.send_type = 2
+            <if test="startDate != null and startDate != '' and endDate != null and endDate != ''">
+                AND fwl.create_time &gt;= #{startDate} AND fwl.create_time &lt; DATE_ADD(#{endDate}, INTERVAL 1 DAY)
+            </if>
+            <choose>
+                <when test="dimension == 'company' and companyId != null and companyId > 0">
+                    AND fwl.company_id = #{companyId}
+                </when>
+                <when test="dimension == 'video' and videoId != null and videoId > 0">
+                    AND fwl.video_id = #{videoId}
+                </when>
+                <when test="dimension == 'course' and courseId != null and courseId != ''">
+                    AND fwl.course_id = #{courseId}
+                </when>
+            </choose>
+        </where>
+        <choose>
+            <when test="dimension == 'course'">
+                GROUP BY fwl.course_id
+            </when>
+            <when test="dimension == 'video'">
+                GROUP BY fwl.video_id
+            </when>
+            <otherwise>
+                GROUP BY fwl.company_id
+            </otherwise>
+        </choose>
+        ORDER BY accessCount desc
+    </select>
 </mapper>

+ 8 - 0
fs-service/src/main/resources/mapper/course/FsUserCourseMapper.xml

@@ -162,6 +162,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         where cwl.user_id = #{userId} and uc.project = #{projectId}
           and cwl.create_time between curdate() and date_add(curdate(), interval 1 day) and cwl.send_type = 1
     </select>
+    <select id="selectCourseNamesByIds" resultType="java.util.Map">
+        SELECT CAST(course_id AS SIGNED) AS courseId, course_name AS courseName
+        FROM fs_user_course
+        WHERE is_del = 0 AND course_id IN
+        <foreach collection="courseIds" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </select>
 
 
     <insert id="insertFsUserCourse" parameterType="FsUserCourse" useGeneratedKeys="true" keyProperty="courseId">

+ 8 - 0
fs-service/src/main/resources/mapper/course/FsUserCourseVideoMapper.xml

@@ -503,6 +503,14 @@
            and video.is_del = 0  and video.course_id= #{courseId}
             limit 1
     </select>
+    <select id="selectVideoNamesByIds" resultType="java.util.Map">
+        SELECT CAST(video_id AS SIGNED) AS videoId, title AS videoName
+        FROM fs_user_course_video
+        WHERE is_del = 0 AND video_id IN
+        <foreach collection="videoIds" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </select>
 
     <update id="batchDown" parameterType="String">
         update fs_user_course_video set is_on_put = 1 where video_id in

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio