Kaynağa Gözat

营期 修改成为时分秒,点播数据统计(卓美),订单添加点播订单和看课相关数据

yuhongqi 1 gün önce
ebeveyn
işleme
e9b654831b
23 değiştirilmiş dosya ile 774 ekleme ve 25 silme
  1. 67 3
      fs-admin/src/main/java/com/fs/course/controller/FsCourseWatchLogController.java
  2. 27 0
      fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreOrderScrmController.java
  3. 5 5
      fs-company/src/main/java/com/fs/hisStore/controller/FsStoreOrderScrmController.java
  4. 4 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseAnswerLogsMapper.java
  5. 4 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseRedPacketLogMapper.java
  6. 62 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseWatchLogMapper.java
  7. 9 0
      fs-service/src/main/java/com/fs/course/mapper/FsUserCoursePeriodDaysMapper.java
  8. 24 0
      fs-service/src/main/java/com/fs/course/param/CourseStatisticsUserDetailParam.java
  9. 22 0
      fs-service/src/main/java/com/fs/course/service/IFsCourseWatchLogService.java
  10. 195 0
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java
  11. 43 16
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCoursePeriodDaysServiceImpl.java
  12. 13 0
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  13. 23 0
      fs-service/src/main/java/com/fs/course/vo/CourseProductSalesVO.java
  14. 72 0
      fs-service/src/main/java/com/fs/course/vo/CourseStatisticsDetailVO.java
  15. 34 0
      fs-service/src/main/java/com/fs/course/vo/CourseStatisticsUserDetailVO.java
  16. 8 0
      fs-service/src/main/java/com/fs/course/vo/UpdateCourseTimeVo.java
  17. 4 0
      fs-service/src/main/java/com/fs/hisStore/domain/FsStoreOrderScrm.java
  18. 12 0
      fs-service/src/main/java/com/fs/hisStore/domain/FsStoreProductScrm.java
  19. 4 0
      fs-service/src/main/java/com/fs/hisStore/param/FsStoreOrderCreateParam.java
  20. 2 0
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreOrderScrmServiceImpl.java
  21. 133 0
      fs-service/src/main/resources/mapper/course/FsCourseWatchLogMapper.xml
  22. 1 0
      fs-service/src/main/resources/mapper/hisStore/FsShippingTemplatesScrmMapper.xml
  23. 6 1
      fs-service/src/main/resources/mapper/hisStore/FsStoreOrderScrmMapper.xml

+ 67 - 3
fs-admin/src/main/java/com/fs/course/controller/FsCourseWatchLogController.java

@@ -9,9 +9,7 @@ import com.fs.course.param.FsCourseWatchLogListParam;
 import com.fs.course.param.FsCourseWatchLogStatisticsListParam;
 import com.fs.course.service.IFsUserCoursePeriodDaysService;
 import com.fs.course.service.IFsUserCoursePeriodService;
-import com.fs.course.vo.FsCourseOverVO;
-import com.fs.course.vo.FsCourseWatchLogListVO;
-import com.fs.course.vo.FsCourseWatchLogStatisticsListVO;
+import com.fs.course.vo.*;
 import com.fs.qw.param.QwWatchLogStatisticsListParam;
 import com.fs.qw.service.IQwWatchLogService;
 import com.github.pagehelper.PageHelper;
@@ -25,6 +23,7 @@ import org.springframework.web.bind.annotation.DeleteMapping;
 import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 import com.fs.common.annotation.Log;
 import com.fs.common.core.controller.BaseController;
@@ -236,4 +235,69 @@ public class FsCourseWatchLogController extends BaseController
         List<FsCourseOverVO> list = fsCourseWatchLogService.selectFsCourseWatchLogOverStatisticsListVO(param);
         return getDataTable(list);
     }
+
+    /**
+     * 查询课程小结详情总体数据
+     * @param videoId 视频ID
+     * @param periodId 营期ID
+     * @return 总体统计数据
+     */
+    @PreAuthorize("@ss.hasPermi('course:courseWatchLog:query')")
+    @GetMapping("/courseStatisticsDetail")
+    public R getCourseStatisticsDetail(@RequestParam("videoId") Long videoId, @RequestParam("periodId") Long periodId)
+    {
+        if (videoId == null || periodId == null) {
+            return R.error("视频ID和营期ID不能为空");
+        }
+        return R.ok().put("data", fsCourseWatchLogService.getCourseStatisticsDetail(videoId, periodId));
+    }
+
+    /**
+     * 课程小结-用户详情列表(分页)
+     * 根据videoId、periodId查询观看记录,区分首次/第2-n次观看时长,关联订单及销售公司
+     *
+     * @param videoId  视频ID
+     * @param periodId 营期ID
+     * @param pageNum  页码
+     * @param pageSize 每页条数
+     * @return 分页用户详情
+     */
+    @PreAuthorize("@ss.hasPermi('course:courseWatchLog:query')")
+    @GetMapping("/courseStatisticsUserDetail")
+    public R getCourseStatisticsUserDetail(
+            @RequestParam("videoId") Long videoId,
+            @RequestParam("periodId") Long periodId,
+            @RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum,
+            @RequestParam(value = "pageSize", defaultValue = "10") Integer pageSize) {
+        if (videoId == null || periodId == null) {
+            return R.error("视频ID和营期ID不能为空");
+        }
+        com.fs.course.param.CourseStatisticsUserDetailParam param = new com.fs.course.param.CourseStatisticsUserDetailParam();
+        param.setVideoId(videoId);
+        param.setPeriodId(periodId);
+        param.setPageNum(pageNum);
+        param.setPageSize(pageSize);
+        PageHelper.startPage(pageNum, pageSize);
+        return R.ok().put("data", new PageInfo<>(fsCourseWatchLogService.getCourseStatisticsUserDetailList(param)));
+    }
+
+    /**
+     * 课程小结-用户详情导出(按创建时间倒序,最多50000条)
+     */
+    @PreAuthorize("@ss.hasPermi('course:courseWatchLog:query')")
+    @Log(title = "课程小结用户详情导出", businessType = BusinessType.EXPORT)
+    @GetMapping("/courseStatisticsUserDetailExport")
+    public AjaxResult courseStatisticsUserDetailExport(
+            @RequestParam("videoId") Long videoId,
+            @RequestParam("periodId") Long periodId) {
+        if (videoId == null || periodId == null) {
+            return AjaxResult.error("视频ID和营期ID不能为空");
+        }
+        com.fs.course.param.CourseStatisticsUserDetailParam param = new com.fs.course.param.CourseStatisticsUserDetailParam();
+        param.setVideoId(videoId);
+        param.setPeriodId(periodId);
+        List<com.fs.course.vo.CourseStatisticsUserDetailVO> list = fsCourseWatchLogService.getCourseStatisticsUserDetailExportList(param);
+        ExcelUtil<com.fs.course.vo.CourseStatisticsUserDetailVO> util = new ExcelUtil<>(com.fs.course.vo.CourseStatisticsUserDetailVO.class);
+        return util.exportExcel(list, "用户看课数据");
+    }
 }

+ 27 - 0
fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreOrderScrmController.java

@@ -27,6 +27,13 @@ import com.fs.company.service.ICompanyService;
 import com.fs.company.service.ICompanyUserService;
 import com.fs.company.vo.CompanyStoreOrderMoneyLogsVO;
 import com.fs.config.cloud.CloudHostProper;
+import com.fs.course.domain.FsCourseWatchLog;
+import com.fs.course.domain.FsUserCoursePeriod;
+import com.fs.course.domain.FsUserCourseVideo;
+import com.fs.course.param.FsCourseWatchLogParam;
+import com.fs.course.service.IFsCourseWatchLogService;
+import com.fs.course.service.IFsUserCoursePeriodService;
+import com.fs.course.service.IFsUserCourseVideoService;
 import com.fs.erp.domain.ErpDeliverys;
 import com.fs.erp.domain.ErpOrderQuery;
 import com.fs.erp.dto.ErpOrderQueryRequert;
@@ -151,6 +158,13 @@ public class FsStoreOrderScrmController extends BaseController {
     @Autowired
     private IFsStoreOrderLogsScrmService fsStoreOrderLogsService;
 
+    @Autowired
+    private IFsCourseWatchLogService fsCourseWatchLogService;
+    @Autowired
+    private IFsUserCoursePeriodService fsUserCoursePeriodService;
+    @Autowired
+    private IFsUserCourseVideoService fsUserCourseVideoService;
+
     @Value("${cloud_host.company_name}")
     private String signProjectName;
 
@@ -672,6 +686,19 @@ public class FsStoreOrderScrmController extends BaseController {
             order.setCompanyName(company.getCompanyName());
         }
 
+        if (order.getOrderType() != null && order.getOrderType() == 3) {
+//            FsCourseWatchLogParam param = new FsCourseWatchLogParam();
+//            param.setVideoId(Long.valueOf(order.getVideoId()));
+//            FsCourseWatchLog log = fsCourseWatchLogService.selectFsCourseWatchLogWithUCCV(order.getUserId(), order.getCompanyUserId(), order.getCourseId(), order.getVideoId());
+            if (order.getPeriodId() != null) {
+                FsUserCoursePeriod fsUserCoursePeriod = fsUserCoursePeriodService.selectFsUserCoursePeriodById(Long.valueOf(order.getPeriodId()));
+                order.setPeriodName(fsUserCoursePeriod.getPeriodName());
+            }
+            if (order.getVideoId() != null) {
+                FsUserCourseVideo fsUserCourseVideo = fsUserCourseVideoService.selectFsUserCourseVideoByVideoId(Long.valueOf(order.getVideoId()));
+                order.setVideoName(fsUserCourseVideo.getTitle());
+            }
+        }
 
         FsStoreOrderItemScrm itemMap = new FsStoreOrderItemScrm();
         itemMap.setOrderId(order.getId());

+ 5 - 5
fs-company/src/main/java/com/fs/hisStore/controller/FsStoreOrderScrmController.java

@@ -265,11 +265,11 @@ public class FsStoreOrderScrmController extends BaseController
             order.setCompanyName(company.getCompanyName());
         }
         if (order.getOrderType() != null && order.getOrderType() == 3) {
-            FsCourseWatchLogParam param = new FsCourseWatchLogParam();
-            param.setVideoId(Long.valueOf(order.getVideoId()));
-            FsCourseWatchLog log = fsCourseWatchLogService.selectFsCourseWatchLogWithUCCV(order.getUserId(), order.getCompanyUserId(), order.getCourseId(), order.getVideoId());
-            if (log != null) {
-                FsUserCoursePeriod fsUserCoursePeriod = fsUserCoursePeriodService.selectFsUserCoursePeriodById(log.getPeriodId());
+//            FsCourseWatchLogParam param = new FsCourseWatchLogParam();
+//            param.setVideoId(Long.valueOf(order.getVideoId()));
+//            FsCourseWatchLog log = fsCourseWatchLogService.selectFsCourseWatchLogWithUCCV(order.getUserId(), order.getCompanyUserId(), order.getCourseId(), order.getVideoId());
+            if (order.getPeriodId() != null) {
+                FsUserCoursePeriod fsUserCoursePeriod = fsUserCoursePeriodService.selectFsUserCoursePeriodById(Long.valueOf(order.getPeriodId()));
                 order.setPeriodName(fsUserCoursePeriod.getPeriodName());
             }
             if (order.getVideoId() != null) {

+ 4 - 0
fs-service/src/main/java/com/fs/course/mapper/FsCourseAnswerLogsMapper.java

@@ -138,4 +138,8 @@ public interface FsCourseAnswerLogsMapper
 
     @Select("select * from fs_course_answer_logs where video_id = #{videoId} and user_id = #{userId} and is_right = 1 limit 1")
     FsCourseAnswerLogs selectRightLogByCourseVideoIsOpen(@Param("videoId") Long videoId,@Param("userId") Long userId);
+
+    /** 统计指定视频+营期下去重答题人数 */
+    @Select("SELECT COUNT(DISTINCT user_id) FROM fs_course_answer_logs WHERE video_id = #{videoId} AND period_id = #{periodId}")
+    Long countDistinctUsersByVideoAndPeriod(@Param("videoId") Long videoId, @Param("periodId") Long periodId);
 }

+ 4 - 0
fs-service/src/main/java/com/fs/course/mapper/FsCourseRedPacketLogMapper.java

@@ -188,6 +188,10 @@ public interface FsCourseRedPacketLogMapper
     @Select("select * from fs_course_red_packet_log where video_id = #{videoId} and user_id = #{userId} and period_id = #{periodId} limit 1")
     FsCourseRedPacketLog selectUserFsCourseRedPacketLog(@Param("videoId") Long videoId, @Param("userId")Long userId, @Param("periodId")Long periodId);
 
+    /** 统计指定视频+营期下去重领红包人数 */
+    @Select("SELECT COUNT(DISTINCT user_id) FROM fs_course_red_packet_log WHERE video_id = #{videoId} AND period_id = #{periodId}")
+    Long countDistinctUsersByVideoAndPeriod(@Param("videoId") Long videoId, @Param("periodId") Long periodId);
+
     @Select("SELECT * FROM fs_course_red_packet_log \n" +
             "WHERE create_time <= DATE_SUB(NOW(), INTERVAL 10 MINUTE)  -- 10 分钟前或更早\n" +
             "AND create_time >= DATE(NOW())  -- 但必须是今天\n" +

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

@@ -757,4 +757,66 @@ public interface FsCourseWatchLogMapper extends BaseMapper<FsCourseWatchLog> {
 
     @Select("select * from fs_course_watch_log where user_id=#{userId} and company_user_id=#{companyUserId} and course_id=#{courseId} and video_id=#{videoId} limit 1")
     FsCourseWatchLog selectFsCourseWatchLogWithUCCV(@Param("userId") Long userId,@Param("companyUserId") Long companyUserId,@Param("courseId") Integer courseId,@Param("videoId") Integer videoId);
+
+    /**
+     * 查询视频时长(只返回duration字段)
+     * @param videoId 视频ID
+     * @return 视频时长(秒)
+     */
+    @Select("SELECT duration FROM fs_user_course_video WHERE video_id = #{videoId} AND is_del = 0 LIMIT 1")
+    Long selectVideoDurationByVideoId(@Param("videoId") Long videoId);
+
+    /**
+     * 统计累计观看人数(对userId去重)
+     * @param videoId 视频ID
+     * @param periodId 营期ID
+     * @return 累计观看人数
+     */
+    @Select("SELECT COUNT(DISTINCT user_id) FROM fs_course_watch_log WHERE video_id = #{videoId} AND period_id = #{periodId}")
+    Long countDistinctWatchUsers(@Param("videoId") Long videoId, @Param("periodId") Long periodId);
+
+    /**
+     * 统计累计完课人数(duration >= 1200秒,即20分钟,对userId去重)
+     * @param videoId 视频ID
+     * @param periodId 营期ID
+     * @return 累计完课人数
+     */
+    @Select("SELECT COUNT(DISTINCT user_id) FROM fs_course_watch_log WHERE video_id = #{videoId} AND period_id = #{periodId} AND duration >= 1200")
+    Long countDistinctCompleteUsers(@Param("videoId") Long videoId, @Param("periodId") Long periodId);
+
+    /**
+     * 首次点播数据统计:观看人数、>=20分钟人数、>=30分钟人数
+     * 首次点播窗口 = [营期课程开始时间, 营期课程开始时间+视频时长](由XML内联计算)
+     * 用户观看开始时间 = COALESCE(finish_time - duration, update_time - duration, create_time)
+     *
+     * @param videoId 视频ID
+     * @param periodId 营期ID
+     * @return Map: firstWatchCount, firstWatch20MinCount, firstWatch30MinCount
+     */
+    Map<String, Object> selectFirstPlaybackStats(@Param("videoId") Long videoId,
+                                                 @Param("periodId") Long periodId);
+
+    /**
+     * 第2-n次观看数据统计:view_start不在首次点播窗口内的观看记录
+     * 首次点播窗口 = [营期课程开始时间, 营期课程开始时间+视频时长]
+     * 第2-n次:view_start < 窗口开始 或 view_start >= 窗口结束
+     *
+     * @param videoId 视频ID
+     * @param periodId 营期ID
+     * @return Map: repeatWatchCount, repeatWatch20MinCount, repeatWatch30MinCount
+     */
+    Map<String, Object> selectRepeatPlaybackStats(@Param("videoId") Long videoId,
+                                                 @Param("periodId") Long periodId);
+
+    /**
+     * 课程小结-用户详情列表(分页):按videoId+periodId查观看记录,区分首次/2-n次时长,关联订单及公司/销售
+     */
+    List<com.fs.course.vo.CourseStatisticsUserDetailVO> selectCourseStatisticsUserDetailList(
+            @Param("param") com.fs.course.param.CourseStatisticsUserDetailParam param);
+
+    /**
+     * 课程小结-用户详情导出:按创建时间倒序,最多50000条
+     */
+    List<com.fs.course.vo.CourseStatisticsUserDetailVO> selectCourseStatisticsUserDetailExportList(
+            @Param("param") com.fs.course.param.CourseStatisticsUserDetailParam param);
 }

+ 9 - 0
fs-service/src/main/java/com/fs/course/mapper/FsUserCoursePeriodDaysMapper.java

@@ -133,4 +133,13 @@ public interface FsUserCoursePeriodDaysMapper extends BaseMapper<FsUserCoursePer
     List<Long> selectFsUserCoursePeriodDaysForLastById(FsUserCoursePeriodDays param);
 
     List<FsUserCoursePeriodDays> selectFsUserCoursePeriodDaysByCourseId(@Param("courseId") Long courseId);
+
+    /**
+     * 根据营期ID和视频ID查询营期课程开始时间(首次点播窗口起始)
+     * @param periodId 营期ID
+     * @param videoId 视频ID
+     * @return 开始时间,无则null
+     */
+    @Select("SELECT start_date_time FROM fs_user_course_period_days WHERE period_id = #{periodId} AND video_id = #{videoId} AND del_flag = '0' ORDER BY lesson ASC LIMIT 1")
+    LocalDateTime selectStartDateTimeByPeriodAndVideo(@Param("periodId") Long periodId, @Param("videoId") Long videoId);
 }

+ 24 - 0
fs-service/src/main/java/com/fs/course/param/CourseStatisticsUserDetailParam.java

@@ -0,0 +1,24 @@
+package com.fs.course.param;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 课程小结-用户详情查询参数
+ *
+ * @author fs
+ * @date 2026-03-06
+ */
+@Data
+public class CourseStatisticsUserDetailParam implements Serializable {
+    private static final long serialVersionUID = 1L;
+    /** 视频ID */
+    private Long videoId;
+    /** 营期ID */
+    private Long periodId;
+    /** 页码 */
+    private Integer pageNum = 1;
+    /** 每页条数 */
+    private Integer pageSize = 10;
+}

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

@@ -171,4 +171,26 @@ public interface IFsCourseWatchLogService extends IService<FsCourseWatchLog> {
     List<FsCourseWatchLog> selectFsUserWatchLogByExtId(QwExternalContact qwExternalContact);
 
     FsCourseWatchLog selectFsCourseWatchLogWithUCCV(Long userId, Long companyUserId, Integer courseId, Integer videoId);
+
+    /**
+     * 查询课程小结详情总体数据
+     * @param videoId 视频ID
+     * @param periodId 营期ID
+     * @return 总体统计数据
+     */
+    CourseStatisticsDetailVO getCourseStatisticsDetail(Long videoId, Long periodId);
+
+    /**
+     * 查询课程小结-用户详情列表(分页)
+     * @param param 查询参数
+     * @return 用户详情列表
+     */
+    List<CourseStatisticsUserDetailVO> getCourseStatisticsUserDetailList(CourseStatisticsUserDetailParam param);
+
+    /**
+     * 课程小结-用户详情导出:按创建时间倒序,最多50000条
+     * @param param 查询参数
+     * @return 用户详情列表
+     */
+    List<CourseStatisticsUserDetailVO> getCourseStatisticsUserDetailExportList(CourseStatisticsUserDetailParam param);
 }

+ 195 - 0
fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java

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

+ 43 - 16
fs-service/src/main/java/com/fs/course/service/impl/FsUserCoursePeriodDaysServiceImpl.java

@@ -437,23 +437,50 @@ public class FsUserCoursePeriodDaysServiceImpl extends ServiceImpl<FsUserCourseP
 
     @Override
     public R updateCourseDate(UpdateCourseTimeVo vo) {
-        FsUserCoursePeriodDays day = getById(vo.getId());
-        FsUserCoursePeriod period = fsUserCoursePeriodMapper.selectFsUserCoursePeriodById(day.getPeriodId());
-        if(!DateUtil.isWithinRangeSafe(vo.getDayDate(), period.getPeriodStartingTime(), period.getPeriodEndTime())) return R.error("时间不在营期范围内");
-        day.setDayDate(vo.getDayDate());
-        day.setStartDateTime(LocalDateTime.of(day.getDayDate(), day.getStartDateTime().toLocalTime()));
-        day.setEndDateTime(LocalDateTime.of(day.getDayDate(), day.getEndDateTime().toLocalTime()));
-        day.setLastJoinTime(LocalDateTime.of(day.getDayDate(), day.getLastJoinTime().toLocalTime()));
-        // 默认开启今天及以后的两天,为进行中
-        LocalDateTime compareDayTime = LocalDateTime.now();
-        if(compareDayTime.isAfter(day.getStartDateTime()) && compareDayTime.isBefore(day.getEndDateTime())){
-            day.setStatus(1);
-        } else if(compareDayTime.isBefore(day.getStartDateTime())){
-            day.setStatus(0);
-        } else {
-            day.setStatus(2);
+        // 确定要更新的id列表:优先使用ids(批量),否则使用id(单个)
+        List<Long> idList = vo.getIds();
+        if (idList == null || idList.isEmpty()) {
+            if (vo.getId() == null) {
+                return R.error("请选择要修改的课程");
+            }
+            idList = java.util.Collections.singletonList(vo.getId());
+        }
+        // 必须提供开始和结束时间,且开始时间不能晚于结束时间
+        LocalDateTime startDateTime = vo.getStartDateTime();
+        LocalDateTime endDateTime = vo.getEndDateTime();
+        if (startDateTime == null || endDateTime == null) {
+            return R.error("请选择营期开始时间和结束时间");
+        }
+        if (!startDateTime.isBefore(endDateTime)) {
+            return R.error("开始时间必须早于结束时间");
+        }
+        LocalDate dayDate = startDateTime.toLocalDate();
+        for (Long dayId : idList) {
+            FsUserCoursePeriodDays day = getById(dayId);
+            if (day == null) {
+                continue;
+            }
+            FsUserCoursePeriod period = fsUserCoursePeriodMapper.selectFsUserCoursePeriodById(day.getPeriodId());
+            if (period == null) {
+                continue;
+            }
+            if (!DateUtil.isWithinRangeSafe(dayDate, period.getPeriodStartingTime(), period.getPeriodEndTime())) {
+                return R.error("营期时间不在营期范围内");
+            }
+            day.setDayDate(dayDate);
+            day.setStartDateTime(startDateTime);
+            day.setEndDateTime(endDateTime);
+            day.setLastJoinTime(endDateTime);
+            LocalDateTime compareDayTime = LocalDateTime.now();
+            if (compareDayTime.isAfter(day.getStartDateTime()) && compareDayTime.isBefore(day.getEndDateTime())) {
+                day.setStatus(1);
+            } else if (compareDayTime.isBefore(day.getStartDateTime())) {
+                day.setStatus(0);
+            } else {
+                day.setStatus(2);
+            }
+            updateById(day);
         }
-        updateById(day);
         return R.ok();
     }
 

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

@@ -4596,6 +4596,19 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
                     fsStoreProductScrm.setBarCode(validProduct.getBarCode());
                     fsStoreProductScrm.setPrice(validProduct.getPrice());
                     fsStoreProductScrm.setProductName(validProduct.getProductName());
+
+
+
+                    String onShelfTime = originalNode != null ? originalNode.path("onShelfTime").asText("00:00:00") : "00:00:00";
+                    String offShelfTime = originalNode != null ? originalNode.path("offShelfTime").asText("00:00:00") : "00:00:00";
+                    String cardPopupTime = originalNode != null ? originalNode.path("cardPopupTime").asText("00:00:00") : "00:00:00";
+                    String cardCloseTime = originalNode != null ? originalNode.path("cardCloseTime").asText("00:00:00") : "00:00:00";
+                    fsStoreProductScrm.setOnShelfTime(onShelfTime);
+                    fsStoreProductScrm.setOffShelfTime(offShelfTime);
+                    fsStoreProductScrm.setCardPopupTime(cardPopupTime);
+                    fsStoreProductScrm.setCardCloseTime(cardCloseTime);
+
+
                     fsPackageListVOS.add(fsStoreProductScrm);
                 }
                 vo.setFsStoreProductScrms(fsPackageListVOS);

+ 23 - 0
fs-service/src/main/java/com/fs/course/vo/CourseProductSalesVO.java

@@ -0,0 +1,23 @@
+package com.fs.course.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * 课程单品销量统计VO
+ *
+ * @author fs
+ * @date 2026-03-06
+ */
+@Data
+public class CourseProductSalesVO implements Serializable {
+    private static final long serialVersionUID = 1L;
+    /** 商品名称 */
+    private String productName;
+    /** 商品销量 */
+    private Long salesCount = 0L;
+    /** 商品销售额 */
+    private BigDecimal salesAmount = BigDecimal.ZERO;
+}

+ 72 - 0
fs-service/src/main/java/com/fs/course/vo/CourseStatisticsDetailVO.java

@@ -0,0 +1,72 @@
+package com.fs.course.vo;
+
+import lombok.Data;
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * 课程小结详情统计VO
+ *
+ * @author fs
+ * @date 2026-03-06
+ */
+@Data
+public class CourseStatisticsDetailVO implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /** 视频时长(秒) */
+    private Long videoDuration;
+
+    /** 累计观看人数 */
+    private Long totalWatchCount;
+
+    /** 累计完课人数 */
+    private Long totalCompleteCount;
+
+    /** 到课完课率(百分比,如:80.50 表示 80.50%) */
+    private BigDecimal completeRate;
+
+    // ========== 首次点播数据 ==========
+    /** 首次点播-观看人数(去重) */
+    private Long firstWatchCount;
+    /** 首次点播->=20分钟人数 */
+    private Long firstWatch20MinCount;
+    /** 首次点播->=30分钟人数 */
+    private Long firstWatch30MinCount;
+    /** 首次点播-到课完课率(>=20分钟) */
+    private BigDecimal firstCompleteRate20Min;
+    /** 首次点播-到课完课率(>=30分钟) */
+    private BigDecimal firstCompleteRate30Min;
+
+    // ========== 第2-n次观看数据 ==========
+    /** 第2-n次-观看人数(去重) */
+    private Long repeatWatchCount;
+    /** 第2-n次->=20分钟人数 */
+    private Long repeatWatch20MinCount;
+    /** 第2-n次->=30分钟人数 */
+    private Long repeatWatch30MinCount;
+    /** 第2-n次-到课完课率(>=20分钟) */
+    private BigDecimal repeatCompleteRate20Min;
+    /** 第2-n次-到课完课率(>=30分钟) */
+    private BigDecimal repeatCompleteRate30Min;
+
+    // ========== 订单数据(fs_store_order_scrm order_type=3,videoId+periodId 匹配) ==========
+    /** GMV=付款订单总金额 */
+    private BigDecimal gmv;
+    /** 付费人数=实际支付下单商品的人数(去重) */
+    private Long paidUserCount;
+    /** 付费单数=付费订单总数 */
+    private Long paidOrderCount;
+    /** 总付费转化率=付费人数/累计观看人数 */
+    private BigDecimal totalPaidConversionRate;
+    /** 20min付费转化率=付费人数/观看时长>=20分钟人数 */
+    private BigDecimal paidConversionRate20Min;
+    /** 完课R值=GMV/完课人数 */
+    private BigDecimal completeRValue;
+    /** 累计答题人数(去重) */
+    private Long answerUserCount;
+    /** 领红包人数(去重) */
+    private Long redPacketUserCount;
+    /** 单品销量统计 */
+    private java.util.List<CourseProductSalesVO> productList;
+}

+ 34 - 0
fs-service/src/main/java/com/fs/course/vo/CourseStatisticsUserDetailVO.java

@@ -0,0 +1,34 @@
+package com.fs.course.vo;
+
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * 课程小结-用户详情VO
+ *
+ * @author fs
+ * @date 2026-03-06
+ */
+@Data
+public class CourseStatisticsUserDetailVO implements Serializable {
+    private static final long serialVersionUID = 1L;
+    /** 用户ID */
+    private Long userId;
+    @Excel(name = "用户名称")
+    private String userName;
+    @Excel(name = "观看时长(秒)")
+    private Long watchDuration;
+    @Excel(name = "第2-n次观看时长(秒)")
+    private Long repeatWatchDuration;
+    @Excel(name = "订单数")
+    private Long orderCount;
+    @Excel(name = "订单金额")
+    private BigDecimal orderAmount;
+    @Excel(name = "分公司名称")
+    private String companyName;
+    @Excel(name = "销售名称")
+    private String salesName;
+}

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

@@ -12,6 +12,7 @@ import java.util.List;
 @Data
 public class UpdateCourseTimeVo {
 
+    /** 批量修改营期时间的id列表,与id二选一 */
     private List<Long> ids;
     private Long id;
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@@ -22,6 +23,13 @@ public class UpdateCourseTimeVo {
     private LocalDateTime joinTime;
     private LocalDate dayDate;
 
+    /** 修改营期时间:开始时间(与endDateTime成对使用,修改后与结束时间保持一致) */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime startDateTime;
+    /** 修改营期时间:结束时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime endDateTime;
+
     // 批量修改开关 0 关闭 1 开启 默认关闭 打开的话修改时间,后续的时间会一起改变
     private Integer batchUpdateSwitch;
 }

+ 4 - 0
fs-service/src/main/java/com/fs/hisStore/domain/FsStoreOrderScrm.java

@@ -382,4 +382,8 @@ public class FsStoreOrderScrm extends BaseEntity
     private Integer videoId;
     //课程ID
     private Integer courseId;
+    // 项目ID
+    private Integer projectId;
+    // 营期ID
+    private Integer periodId;
 }

+ 12 - 0
fs-service/src/main/java/com/fs/hisStore/domain/FsStoreProductScrm.java

@@ -350,6 +350,18 @@ public class FsStoreProductScrm extends BaseEntity
     @Excel(name = "限购数量")
     private Integer purchaseLimit;
 
+    @TableField(exist = false)
+    private String onShelfTime;
+
+    @TableField(exist = false)
+    private String cardPopupTime;
+
+    @TableField(exist = false)
+    private String cardCloseTime;
+
+    @TableField(exist = false)
+    private String offShelfTime;
+
     /** 过滤商品id */
     private Long[] excludeProductIds;
 }

+ 4 - 0
fs-service/src/main/java/com/fs/hisStore/param/FsStoreOrderCreateParam.java

@@ -61,4 +61,8 @@ public class FsStoreOrderCreateParam implements Serializable
     private Integer videoId;
     //课程ID
     private Integer courseId;
+    //项目ID
+    private Integer projectId;
+    //营期ID
+    private Integer periodId;
 }

+ 2 - 0
fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreOrderScrmServiceImpl.java

@@ -912,6 +912,8 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
             if ("北京卓美".equals(companyName) && param.getVideoId()!=null){
                 storeOrder.setVideoId(param.getVideoId());
                 storeOrder.setCourseId(param.getCourseId());
+                storeOrder.setPeriodId(param.getPeriodId());
+                storeOrder.setProjectId(param.getProjectId());
             }
             String json = configService.selectConfigByKey("store.config");
             StoreConfig config= JSONUtil.toBean(json, StoreConfig.class);

+ 133 - 0
fs-service/src/main/resources/mapper/course/FsCourseWatchLogMapper.xml

@@ -1361,4 +1361,137 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
           AND l.create_time &lt; CURDATE() + INTERVAL 1 DAY
         order by l.create_time desc
     </select>
+
+    <!-- 首次点播数据统计:看课时间在[营期开始时间, 营期开始时间+视频时长]范围内,view_start=COALESCE(finish_time-duration, update_time-duration, create_time) -->
+    <select id="selectFirstPlaybackStats" resultType="map">
+        SELECT
+            COUNT(DISTINCT l.user_id) AS firstWatchCount,
+            COUNT(DISTINCT CASE WHEN l.duration &gt;= 1200 THEN l.user_id END) AS firstWatch20MinCount,
+            COUNT(DISTINCT CASE WHEN l.duration &gt;= 1800 THEN l.user_id END) AS firstWatch30MinCount
+        FROM fs_course_watch_log l
+        INNER JOIN fs_user_course_period_days fcpd ON fcpd.period_id = l.period_id AND fcpd.video_id = l.video_id AND fcpd.del_flag = '0'
+        INNER JOIN fs_user_course_video v ON v.video_id = l.video_id AND v.is_del = 0
+        WHERE l.video_id = #{videoId}
+          AND l.period_id = #{periodId}
+          AND l.user_id IS NOT NULL
+          AND (
+              (COALESCE(DATE_SUB(l.finish_time, INTERVAL COALESCE(l.duration, 0) SECOND), DATE_SUB(l.update_time, INTERVAL COALESCE(l.duration, 0) SECOND), l.create_time) &gt;= fcpd.start_date_time)
+              AND
+              (COALESCE(DATE_SUB(l.finish_time, INTERVAL COALESCE(l.duration, 0) SECOND), DATE_SUB(l.update_time, INTERVAL COALESCE(l.duration, 0) SECOND), l.create_time) &lt; DATE_ADD(fcpd.start_date_time, INTERVAL COALESCE(v.duration, 0) SECOND))
+          )
+    </select>
+
+    <!-- 第2-n次观看数据统计:view_start不在[营期开始时间, 营期开始时间+视频时长]内 -->
+    <select id="selectRepeatPlaybackStats" resultType="map">
+        SELECT
+            COUNT(DISTINCT l.user_id) AS repeatWatchCount,
+            COUNT(DISTINCT CASE WHEN l.duration &gt;= 1200 THEN l.user_id END) AS repeatWatch20MinCount,
+            COUNT(DISTINCT CASE WHEN l.duration &gt;= 1800 THEN l.user_id END) AS repeatWatch30MinCount
+        FROM fs_course_watch_log l
+        INNER JOIN fs_user_course_period_days fcpd ON fcpd.period_id = l.period_id AND fcpd.video_id = l.video_id AND fcpd.del_flag = '0'
+        INNER JOIN fs_user_course_video v ON v.video_id = l.video_id AND v.is_del = 0
+        WHERE l.video_id = #{videoId}
+          AND l.period_id = #{periodId}
+          AND l.user_id IS NOT NULL
+          AND (
+              (COALESCE(DATE_SUB(l.finish_time, INTERVAL COALESCE(l.duration, 0) SECOND), DATE_SUB(l.update_time, INTERVAL COALESCE(l.duration, 0) SECOND), l.create_time) &lt; fcpd.start_date_time)
+              OR
+              (COALESCE(DATE_SUB(l.finish_time, INTERVAL COALESCE(l.duration, 0) SECOND), DATE_SUB(l.update_time, INTERVAL COALESCE(l.duration, 0) SECOND), l.create_time) &gt;= DATE_ADD(fcpd.start_date_time, INTERVAL COALESCE(v.duration, 0) SECOND))
+          )
+    </select>
+
+    <!-- 课程小结-用户详情列表:按user_id分组,区分首次/2-n次观看时长,关联订单,按创建时间倒序 -->
+    <select id="selectCourseStatisticsUserDetailList" resultType="com.fs.course.vo.CourseStatisticsUserDetailVO">
+        SELECT
+            ua.user_id AS userId,
+            COALESCE(u.nick_name, u.nickname, '未知用户') AS userName,
+            COALESCE(ua.first_dur, 0) AS watchDuration,
+            COALESCE(ua.repeat_dur, 0) AS repeatWatchDuration,
+            COALESCE(ord.order_count, 0) AS orderCount,
+            COALESCE(ord.order_amount, 0) AS orderAmount,
+            c.company_name AS companyName,
+            cu.nick_name AS salesName
+        FROM (
+            SELECT
+                l.user_id,
+                MAX(l.create_time) AS max_create_time,
+                SUM(CASE WHEN
+                    (COALESCE(DATE_SUB(l.finish_time, INTERVAL COALESCE(l.duration,0) SECOND), DATE_SUB(l.update_time, INTERVAL COALESCE(l.duration,0) SECOND), l.create_time) &gt;= fcpd.start_date_time)
+                    AND (COALESCE(DATE_SUB(l.finish_time, INTERVAL COALESCE(l.duration,0) SECOND), DATE_SUB(l.update_time, INTERVAL COALESCE(l.duration,0) SECOND), l.create_time) &lt; DATE_ADD(fcpd.start_date_time, INTERVAL COALESCE(v.duration,0) SECOND))
+                THEN COALESCE(l.duration,0) ELSE 0 END) AS first_dur,
+                SUM(CASE WHEN
+                    (COALESCE(DATE_SUB(l.finish_time, INTERVAL COALESCE(l.duration,0) SECOND), DATE_SUB(l.update_time, INTERVAL COALESCE(l.duration,0) SECOND), l.create_time) &lt; fcpd.start_date_time)
+                    OR (COALESCE(DATE_SUB(l.finish_time, INTERVAL COALESCE(l.duration,0) SECOND), DATE_SUB(l.update_time, INTERVAL COALESCE(l.duration,0) SECOND), l.create_time) &gt;= DATE_ADD(fcpd.start_date_time, INTERVAL COALESCE(v.duration,0) SECOND))
+                THEN COALESCE(l.duration,0) ELSE 0 END) AS repeat_dur,
+                MAX(l.company_id) AS company_id,
+                MAX(l.company_user_id) AS company_user_id
+            FROM fs_course_watch_log l
+            INNER JOIN fs_user_course_period_days fcpd ON fcpd.period_id = l.period_id AND fcpd.video_id = l.video_id AND fcpd.del_flag = '0'
+            INNER JOIN fs_user_course_video v ON v.video_id = l.video_id AND v.is_del = 0
+            WHERE l.video_id = #{param.videoId} AND l.period_id = #{param.periodId} AND l.user_id IS NOT NULL
+            GROUP BY l.user_id
+        ) ua
+        LEFT JOIN fs_user u ON u.user_id = ua.user_id
+        LEFT JOIN (
+            SELECT
+                o.user_id,
+                COUNT(o.id) AS order_count,
+                SUM(IFNULL(o.pay_price, 0)) AS order_amount,
+                MIN(o.id) AS min_order_id
+            FROM fs_store_order_scrm o
+            WHERE o.order_type = 3 AND o.video_id = #{param.videoId} AND o.period_id = #{param.periodId} AND o.paid = 1
+            GROUP BY o.user_id
+        ) ord ON ord.user_id = ua.user_id
+        LEFT JOIN company c ON c.company_id = ua.company_id
+        LEFT JOIN company_user cu ON cu.user_id = ua.company_user_id
+        ORDER BY ua.max_create_time DESC
+    </select>
+
+    <!-- 课程小结-用户详情导出:按创建时间倒序,最多50000条 -->
+    <select id="selectCourseStatisticsUserDetailExportList" resultType="com.fs.course.vo.CourseStatisticsUserDetailVO">
+        SELECT
+            ua.user_id AS userId,
+            COALESCE(u.nick_name, u.nickname, '未知用户') AS userName,
+            COALESCE(ua.first_dur, 0) AS watchDuration,
+            COALESCE(ua.repeat_dur, 0) AS repeatWatchDuration,
+            COALESCE(ord.order_count, 0) AS orderCount,
+            COALESCE(ord.order_amount, 0) AS orderAmount,
+            c.company_name AS companyName,
+            cu.nick_name AS salesName
+        FROM (
+            SELECT
+                l.user_id,
+                MAX(l.create_time) AS max_create_time,
+                SUM(CASE WHEN
+                    (COALESCE(DATE_SUB(l.finish_time, INTERVAL COALESCE(l.duration,0) SECOND), DATE_SUB(l.update_time, INTERVAL COALESCE(l.duration,0) SECOND), l.create_time) &gt;= fcpd.start_date_time)
+                    AND (COALESCE(DATE_SUB(l.finish_time, INTERVAL COALESCE(l.duration,0) SECOND), DATE_SUB(l.update_time, INTERVAL COALESCE(l.duration,0) SECOND), l.create_time) &lt; DATE_ADD(fcpd.start_date_time, INTERVAL COALESCE(v.duration,0) SECOND))
+                THEN COALESCE(l.duration,0) ELSE 0 END) AS first_dur,
+                SUM(CASE WHEN
+                    (COALESCE(DATE_SUB(l.finish_time, INTERVAL COALESCE(l.duration,0) SECOND), DATE_SUB(l.update_time, INTERVAL COALESCE(l.duration,0) SECOND), l.create_time) &lt; fcpd.start_date_time)
+                    OR (COALESCE(DATE_SUB(l.finish_time, INTERVAL COALESCE(l.duration,0) SECOND), DATE_SUB(l.update_time, INTERVAL COALESCE(l.duration,0) SECOND), l.create_time) &gt;= DATE_ADD(fcpd.start_date_time, INTERVAL COALESCE(v.duration,0) SECOND))
+                THEN COALESCE(l.duration,0) ELSE 0 END) AS repeat_dur,
+                MAX(l.company_id) AS company_id,
+                MAX(l.company_user_id) AS company_user_id
+            FROM fs_course_watch_log l
+            INNER JOIN fs_user_course_period_days fcpd ON fcpd.period_id = l.period_id AND fcpd.video_id = l.video_id AND fcpd.del_flag = '0'
+            INNER JOIN fs_user_course_video v ON v.video_id = l.video_id AND v.is_del = 0
+            WHERE l.video_id = #{param.videoId} AND l.period_id = #{param.periodId} AND l.user_id IS NOT NULL
+            GROUP BY l.user_id
+        ) ua
+        LEFT JOIN fs_user u ON u.user_id = ua.user_id
+        LEFT JOIN (
+            SELECT
+                o.user_id,
+                COUNT(o.id) AS order_count,
+                SUM(IFNULL(o.pay_price, 0)) AS order_amount,
+                MIN(o.id) AS min_order_id
+            FROM fs_store_order_scrm o
+            WHERE o.order_type = 3 AND o.video_id = #{param.videoId} AND o.period_id = #{param.periodId} AND o.paid = 1
+            GROUP BY o.user_id
+        ) ord ON ord.user_id = ua.user_id
+        LEFT JOIN company c ON c.company_id = ua.company_id
+        LEFT JOIN company_user cu ON cu.user_id = ua.company_user_id
+        ORDER BY ua.max_create_time DESC
+        LIMIT 50000
+    </select>
 </mapper>

+ 1 - 0
fs-service/src/main/resources/mapper/hisStore/FsShippingTemplatesScrmMapper.xml

@@ -32,6 +32,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="isDel != null "> and is_del = #{isDel}</if>
             <if test="sort != null "> and sort = #{sort}</if>
         </where>
+        order by sort , create_time desc
     </select>
     
     <select id="selectFsShippingTemplatesById" parameterType="Long" resultMap="FsShippingTemplatesResult">

+ 6 - 1
fs-service/src/main/resources/mapper/hisStore/FsStoreOrderScrmMapper.xml

@@ -93,7 +93,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </resultMap>
 
     <sql id="selectFsStoreOrderVo">
-        select id, order_code,service_fee, extend_order_id,pay_order_id,bank_order_id, user_id,order_visit, real_name, user_phone, user_address, cart_id, freight_price, total_num, total_price, total_postage, pay_price, pay_postage,pay_delivery,pay_money, deduction_price, coupon_id, coupon_price, paid, pay_time, pay_type, create_time, update_time, status, refund_status, refund_reason_wap_img, refund_reason_wap_explain, refund_reason_time, refund_reason_wap, refund_reason, refund_price, delivery_sn, delivery_name, delivery_type, delivery_id, gain_integral, use_integral, pay_integral, back_integral, mark, is_del, remark, cost, verify_code, store_id, shipping_type, is_channel, is_remind, is_sys_del,is_prescribe,prescribe_id ,company_id,company_user_id,is_package,package_json,item_json,order_type,package_id,finish_time,delivery_status,delivery_pay_status,delivery_time,delivery_pay_time,delivery_pay_money,tui_money,tui_money_status,delivery_import_time,tui_user_id,tui_user_money_status,order_create_type,store_house_code,dept_id,is_edit_money,customer_id,is_pay_remain,delivery_send_time,certificates,schedule_id,backend_edit_product_type,video_id,course_id from fs_store_order_scrm
+        select id, order_code,service_fee, extend_order_id,pay_order_id,bank_order_id, user_id,order_visit, real_name, user_phone, user_address, cart_id, freight_price, total_num, total_price, total_postage, pay_price, pay_postage,pay_delivery,pay_money, deduction_price, coupon_id, coupon_price, paid, pay_time, pay_type, create_time, update_time, status, refund_status, refund_reason_wap_img, refund_reason_wap_explain, refund_reason_time, refund_reason_wap, refund_reason, refund_price, delivery_sn, delivery_name, delivery_type, delivery_id, gain_integral, use_integral, pay_integral, back_integral, mark, is_del, remark, cost, verify_code, store_id, shipping_type, is_channel, is_remind, is_sys_del,is_prescribe,prescribe_id ,company_id,company_user_id,is_package,package_json,item_json,order_type,package_id,finish_time,delivery_status,delivery_pay_status,delivery_time,delivery_pay_time,delivery_pay_money,tui_money,tui_money_status,delivery_import_time,tui_user_id,tui_user_money_status,order_create_type,store_house_code,dept_id,is_edit_money,customer_id,is_pay_remain,delivery_send_time,certificates,schedule_id,backend_edit_product_type,video_id,course_id,project_id,period_id from fs_store_order_scrm
     </sql>
 
     <select id="selectFsStoreOrderList" parameterType="FsStoreOrderScrm" resultMap="FsStoreOrderResult">
@@ -157,6 +157,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="remark != null and remark != ''"> and remark = #{remark}</if>
             <if test="videoId != null and videoId != ''"> and video_id = #{videoId}</if>
             <if test="courseId != null and courseId != ''"> and course_id = #{courseId}</if>
+            <if test="periodId != null"> and period_id = #{periodId}</if>
         </where>
     </select>
 
@@ -272,6 +273,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="backendEditProductType != null">backend_edit_product_type,</if>
             <if test="videoId != null">video_id,</if>
             <if test="courseId != null" >course_id,</if>
+            <if test="projectId != null" >project_id,</if>
+            <if test="periodId != null" >period_id,</if>
          </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="orderCode != null and orderCode != ''">#{orderCode},</if>
@@ -361,6 +364,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="backendEditProductType != null">#{backendEditProductType},</if>
             <if test="videoId != null">#{videoId},</if>
             <if test="courseId != null" >#{courseId},</if>
+            <if test="projectId != null" >#{projectId},</if>
+            <if test="periodId != null" >#{periodId},</if>
          </trim>
     </insert>