Kaynağa Gözat

从中康-把app手动发课 拿过来

三七 2 hafta önce
ebeveyn
işleme
c58316b515
52 değiştirilmiş dosya ile 3035 ekleme ve 39 silme
  1. 34 3
      fs-admin/src/main/java/com/fs/his/task/Task.java
  2. 27 4
      fs-company-app/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java
  3. 65 0
      fs-company/src/main/java/com/fs/company/controller/im/FsImMsgSendLogController.java
  4. 192 0
      fs-company/src/main/java/com/fs/company/controller/statistic/courseStatisticController.java
  5. 12 0
      fs-service/src/main/java/com/fs/app/service/AppPayService.java
  6. 63 0
      fs-service/src/main/java/com/fs/app/service/impl/AppPayServiceImpl.java
  7. 64 0
      fs-service/src/main/java/com/fs/app/service/param/FsImMsgSendLogRequest.java
  8. 78 0
      fs-service/src/main/java/com/fs/app/service/param/FsImMsgSendLogResponse.java
  9. 20 0
      fs-service/src/main/java/com/fs/app/service/param/FsImMsgSendLogStatisticsResponse.java
  10. 2 0
      fs-service/src/main/java/com/fs/course/domain/FsCourseWatchLog.java
  11. 3 0
      fs-service/src/main/java/com/fs/course/dto/BatchSendCourseDTO.java
  12. 5 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseAnswerLogsMapper.java
  13. 2 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseLinkMapper.java
  14. 4 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseRedPacketLogMapper.java
  15. 70 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseWatchLogMapper.java
  16. 2 0
      fs-service/src/main/java/com/fs/course/param/FsCourseLinkCreateParam.java
  17. 10 2
      fs-service/src/main/java/com/fs/course/param/FsCourseWatchLogStatisticsListParam.java
  18. 1 0
      fs-service/src/main/java/com/fs/course/param/newfs/FsCourseSortLinkParam.java
  19. 4 0
      fs-service/src/main/java/com/fs/course/param/newfs/FsUserCourseAddCompanyUserParam.java
  20. 10 0
      fs-service/src/main/java/com/fs/course/service/IFsCourseWatchLogService.java
  21. 12 0
      fs-service/src/main/java/com/fs/course/service/IFsUserCourseService.java
  22. 2 0
      fs-service/src/main/java/com/fs/course/service/IFsUserCourseVideoService.java
  23. 540 4
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java
  24. 128 3
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseServiceImpl.java
  25. 110 0
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  26. 42 0
      fs-service/src/main/java/com/fs/course/vo/FsUserCourseAppListVO.java
  27. 12 1
      fs-service/src/main/java/com/fs/gtPush/service/impl/uniPush2ServiceImpl.java
  28. 5 0
      fs-service/src/main/java/com/fs/his/domain/FsUser.java
  29. 2 1
      fs-service/src/main/java/com/fs/his/enums/PushLogTypeEnum.java
  30. 6 0
      fs-service/src/main/java/com/fs/his/mapper/FsUserMapper.java
  31. 47 15
      fs-service/src/main/java/com/fs/his/service/impl/FsInquiryOrderMsgServiceImpl.java
  32. 87 0
      fs-service/src/main/java/com/fs/his/vo/AppCourseReportVO.java
  33. 86 0
      fs-service/src/main/java/com/fs/his/vo/AppSalesCourseStatisticsVO.java
  34. 89 0
      fs-service/src/main/java/com/fs/his/vo/AppSalesWatchLogReportVO.java
  35. 146 0
      fs-service/src/main/java/com/fs/his/vo/AppWatchLogReportVO.java
  36. 177 0
      fs-service/src/main/java/com/fs/his/vo/WatchLogReportVO.java
  37. 2 1
      fs-service/src/main/java/com/fs/im/dto/OpenImBatchMsgDTO.java
  38. 8 0
      fs-service/src/main/java/com/fs/im/mapper/FsImMsgSendLogMapper.java
  39. 12 0
      fs-service/src/main/java/com/fs/im/service/IFsImMsgSendLogService.java
  40. 9 0
      fs-service/src/main/java/com/fs/im/service/OpenIMService.java
  41. 25 2
      fs-service/src/main/java/com/fs/im/service/impl/FsImMsgSendLogServiceImpl.java
  42. 153 0
      fs-service/src/main/java/com/fs/im/service/impl/OpenIMServiceImpl.java
  43. 4 0
      fs-service/src/main/resources/application-config-druid-hsyy.yml
  44. 25 0
      fs-service/src/main/resources/mapper/course/FsCourseAnswerLogsMapper.xml
  45. 6 0
      fs-service/src/main/resources/mapper/course/FsCourseLinkMapper.xml
  46. 25 0
      fs-service/src/main/resources/mapper/course/FsCourseRedPacketLogMapper.xml
  47. 362 3
      fs-service/src/main/resources/mapper/course/FsCourseWatchLogMapper.xml
  48. 29 0
      fs-service/src/main/resources/mapper/his/FsUserMapper.xml
  49. 77 0
      fs-service/src/main/resources/mapper/im/FsImMsgSendLogMapper.xml
  50. 3 0
      fs-user-app/src/main/java/com/fs/app/controller/AppLoginController.java
  51. 78 0
      fs-user-app/src/main/java/com/fs/app/controller/AppPayController.java
  52. 58 0
      fs-user-app/src/main/java/com/fs/app/controller/CourseController.java

+ 34 - 3
fs-admin/src/main/java/com/fs/his/task/Task.java

@@ -1778,13 +1778,44 @@ public class Task {
             return;
         }
         for (Map.Entry<String, BatchSendCourseAllDTO> entry : toSendMap) {
+            String key=entry.getKey();
             //执行发送消息任务
             BatchSendCourseAllDTO batchSendCourseAllDTO = entry.getValue();
-            openIMService.batchSendCourseTask(batchSendCourseAllDTO.getBatchSendCourseDTO(), batchSendCourseAllDTO.getOpenImBatchMsgDTO(), batchSendCourseAllDTO.getProject(), batchSendCourseAllDTO.getImMsgSendDetailList());
+            if (batchSendCourseAllDTO == null) {
+                logger.error("batchSendCourseAllDTO 为 null,key: {}", key);
+                redisTemplate.opsForHash().delete(redisKey, key);
+                continue; // 跳过当前循环
+            }
+            OpenImBatchMsgDTO openImBatchMsgDTO = batchSendCourseAllDTO.getOpenImBatchMsgDTO();
+            Integer nowCount=openImBatchMsgDTO.getCount();
+            if (nowCount == null) {
+                nowCount = 0;
+            }
+            OpenImResponseDTO responseDTO=new OpenImResponseDTO();
+            try {
+                responseDTO=  openIMService.batchSendCourseTask(batchSendCourseAllDTO.getBatchSendCourseDTO(), batchSendCourseAllDTO.getOpenImBatchMsgDTO(), batchSendCourseAllDTO.getProject(), batchSendCourseAllDTO.getImMsgSendDetailList());
+            } catch (Exception e) {
+                responseDTO.setErrCode(500);
+                responseDTO.setErrMsg(e.getMessage());
+                e.printStackTrace();
+            }
 
-            // 执行结束,删除
-            this.redisTemplate.<String, BatchSendCourseAllDTO>opsForHash().delete(redisKey, entry.getKey());
 
+            if(nowCount>1){ // 重试一次后放弃
+                logger.error("im会员定时发课重试三次后放弃,key{}", key);
+                redisTemplate.opsForHash().delete(redisKey, key);
+                continue;
+            }
+
+            if(responseDTO!=null && responseDTO.getErrCode() == 0){
+                // 执行结束,删除
+                redisTemplate.opsForHash().delete(redisKey, key);
+            }else {
+                openImBatchMsgDTO.setCount(nowCount + 1);// 次数加一
+                batchSendCourseAllDTO.setOpenImBatchMsgDTO(openImBatchMsgDTO);
+                // 错误更新次数 重新放入redis中
+                redisTemplate.opsForHash().put(redisKey, key, batchSendCourseAllDTO);
+            }
         }
 
     }

+ 27 - 4
fs-company-app/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java

@@ -190,11 +190,26 @@ public class FsUserCourseVideoController extends AppBaseController {
 
         R courseSortLink = fsUserCourseService.createCourseSortLink(fsCourseLinkCreateParam);
         String url = courseSortLink.get("url").toString();
+        String linkId=courseSortLink.get("linkId").toString();
         Map<String, Object> map = new HashMap<>();
         map.put("url", url);
+        map.put("linkId", linkId);
         return R.ok(map);
     }
 
+    /**
+     * @Description: 生成看课记录 中康APP 调取接口/courseSortLink 后发送后再生成看课记录
+     * @Param:
+     * @Return:
+     * @Author xgb
+     * @Date 2026/1/21 15:38
+     */
+    @Login
+    @PostMapping("/batchCreateCourseRecord")
+    public R batchCreateCourseRecord(@RequestBody BatchSendCourseDTO batchSendCourseDTO) {
+        return fsUserCourseService.batchCreateCourseRecord(batchSendCourseDTO);
+    }
+
     @Login
     @PostMapping("/courseImage")
     @ApiOperation("生成课程海报")
@@ -330,7 +345,7 @@ public class FsUserCourseVideoController extends AppBaseController {
         return ResponseResult.ok(liveService.getGotoWxAppLiveLink(linkStr,appid));
     }
 
-    @ApiOperation("会员批量发送课程消息")
+    @ApiOperation("会员批量发送课程消息 发课")
     @PostMapping("/batchSendCourse")
     public OpenImResponseDTO batchSendCourse(@RequestBody BatchSendCourseDTO batchSendCourseDTO) throws JsonProcessingException {
         // 生成看课短链
@@ -339,10 +354,18 @@ public class FsUserCourseVideoController extends AppBaseController {
         R courseSortLink = fsUserCourseService.createAppCourseSortLink(fsCourseLinkCreateParam);
         String url = courseSortLink.get("url").toString();
         batchSendCourseDTO.setUrl(url);
-        batchSendCourseDTO.setIsUrgeCourse(false);
-        return openIMService.batchSendCourse(batchSendCourseDTO);
-    }
+        batchSendCourseDTO.setLinkId(Long.parseLong(courseSortLink.get("linkId").toString()));
+        if(batchSendCourseDTO.getIsUrgeCourse()==null){
+            batchSendCourseDTO.setIsUrgeCourse(false);
+        }
 
+        // 异步调用
+        openIMService.batchSendCourseLimit(batchSendCourseDTO);
+        OpenImResponseDTO openImResponseDTO = new OpenImResponseDTO();
+        openImResponseDTO.setErrCode(0);
+        openImResponseDTO.setErrMsg("异步发送,详细请看明细");
+        return openImResponseDTO;
+    }
     @ApiOperation("会员一键催课")
     @PostMapping("/batchUrgeCourse")
     public OpenImResponseDTO batchUrgeCourse(@RequestBody BatchUrgeCourseDTO batchUrgeCourseDTO) throws JsonProcessingException {

+ 65 - 0
fs-company/src/main/java/com/fs/company/controller/im/FsImMsgSendLogController.java

@@ -0,0 +1,65 @@
+package com.fs.company.controller.im;
+
+import com.fs.app.service.param.FsImMsgSendLogRequest;
+import com.fs.app.service.param.FsImMsgSendLogResponse;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.utils.ServletUtils;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import com.fs.im.service.IFsImMsgSendLogService;
+import com.github.pagehelper.PageHelper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * OpenIM 消息发送记录主表 Controller
+ */
+@RestController
+@RequestMapping("/app/im")
+public class FsImMsgSendLogController extends BaseController {
+
+    @Autowired
+    private IFsImMsgSendLogService fsImMsgSendLogService;
+
+    @Autowired
+    private TokenService tokenService;
+
+    /**
+     * 查询 OpenIM 消息发送记录列表
+     */
+    @GetMapping("/listImMsgSendLog")
+    public TableDataInfo list(FsImMsgSendLogRequest request) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        request.setCompanyId(loginUser.getCompany().getCompanyId());
+
+        PageHelper.startPage(request.getPageNum(), request.getPageSize());
+        List<FsImMsgSendLogResponse> list = fsImMsgSendLogService.selectFsImMsgSendLogInfoList(request);
+        return getDataTable(list);
+    }
+
+//    /**
+//     * 导出 OpenIM 消息发送记录列表
+//     */
+//    @GetMapping("/exportImMsgSendLog")
+//    public void export(HttpServletResponse response, FsImMsgSendLogRequest request) {
+//        List<FsImMsgSendLog> list = fsImMsgSendLogService.exportFsImMsgSendLog(request);
+//        ExcelUtil<FsImMsgSendLog> util = new ExcelUtil<>(FsImMsgSendLog.class);
+//        util.exportExcel(response, list, "OpenIM 消息发送记录");
+//    }
+//
+    /**
+     * 获取发送状态统计
+     */
+    @GetMapping("/getImMsgSendStatistics")
+    public R statistics(FsImMsgSendLogRequest request) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        request.setCompanyId(loginUser.getCompany().getCompanyId());
+        return fsImMsgSendLogService.getFsImMsgSendStatistics(request);
+    }
+}

+ 192 - 0
fs-company/src/main/java/com/fs/company/controller/statistic/courseStatisticController.java

@@ -0,0 +1,192 @@
+package com.fs.company.controller.statistic;
+
+import com.fs.common.annotation.Excel;
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.course.param.FsCourseWatchLogStatisticsListParam;
+import com.fs.course.service.IFsCourseWatchLogService;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import com.fs.his.vo.AppSalesCourseStatisticsVO;
+import com.fs.his.vo.AppSalesWatchLogReportVO;
+import com.fs.his.vo.AppWatchLogReportVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * @description: app 看课统计
+ * @author: Xgb
+ * @createDate: 2026/3/16
+ * @version: 1.0
+ */
+@RestController
+@RequestMapping("/app/statistics")
+public class courseStatisticController extends BaseController {
+
+    @Autowired
+    private TokenService tokenService;
+
+    @Autowired
+    private IFsCourseWatchLogService courseWatchLogService;
+
+    /**
+     * 销售后台app看课统计 会员维度
+     * @param param
+     * @return
+     */
+    @GetMapping("/appWatchLogReport")
+    public TableDataInfo appWatchLogReport(FsCourseWatchLogStatisticsListParam param) {
+        startPage();
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        return getDataTable(courseWatchLogService.selectUserAppWatchLogReportVO(param));
+    }
+
+    /**
+     * 销售后台app看课统计 会员维度导出
+     * @param param
+     * @return
+     */
+    @GetMapping("/appWatchLogReportExport")
+    public AjaxResult appWatchLogReportExport(FsCourseWatchLogStatisticsListParam param) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<AppWatchLogReportVO> list = courseWatchLogService.selectUserAppWatchLogReportVO(param);
+        // 转换登录渠道和答题状态
+        list.forEach(this::convertAppWatchLogReportVO);
+        // 获取所有字段,排除会员维度的三个字段
+        List<String> selectedFields = getAppWatchLogReportFields();
+        ExcelUtil<AppWatchLogReportVO> util = new ExcelUtil<AppWatchLogReportVO>(AppWatchLogReportVO.class);
+        return util.exportExcelSelectedColumns(list, "APP看课统计报表", selectedFields);
+    }
+
+    /**
+     * 转换AppWatchLogReportVO字段
+     * - 登录渠道:有值显示"app",无值显示"小程序"
+     * - 答题状态:无值显示"未答题"
+     */
+    private void convertAppWatchLogReportVO(AppWatchLogReportVO vo) {
+        // 登录渠道转换
+        if (StringUtils.isNotEmpty(vo.getLoginChannel())) {
+            vo.setLoginChannel("app");
+        } else {
+            vo.setLoginChannel("小程序");
+        }
+        // 答题状态转换
+        if (StringUtils.isEmpty(vo.getAnswerStatus())) {
+            vo.setAnswerStatus("未答题");
+        }
+    }
+
+    /**
+     * 获取AppWatchLogReportVO需要导出的字段(排除特定字段)
+     */
+    private List<String> getAppWatchLogReportFields() {
+        // 需要排除的字段:app会员数、销售数、新注册app会员数
+        List<String> excludeFields = Arrays.asList("AppUserCount", "salesCount", "AppNewUser");
+
+        return Arrays.stream(AppWatchLogReportVO.class.getDeclaredFields())
+                .filter(field -> field.isAnnotationPresent(Excel.class))
+                .map(Field::getName)
+                .filter(fieldName -> !excludeFields.contains(fieldName))
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * APP端销售维度看课统计报表
+     * 注意:必须放在 /{logId} 之前,避免路径冲突
+     */
+//    @PreAuthorize("@ss.hasPermi('course:courseWatchLog:appSalesReport')")
+    @GetMapping("/appSalesWatchLogReport")
+    public TableDataInfo appSalesWatchLogReport(FsCourseWatchLogStatisticsListParam param)
+    {
+        startPage();
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<AppSalesWatchLogReportVO> list = courseWatchLogService.selectAppSalesWatchLogReportVO(param);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出APP端销售维度看课统计报表
+     */
+    @Log(title = "APP销售维度看课统计", businessType = BusinessType.EXPORT)
+    @GetMapping("/appSalesWatchLogReportExport")
+    public AjaxResult appSalesWatchLogReportExport(FsCourseWatchLogStatisticsListParam param)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<AppSalesWatchLogReportVO> list = courseWatchLogService.selectAppSalesWatchLogReportVO(param);
+        // 根据维度获取需要导出的字段
+        List<String> selectedFields = getAppSalesWatchLogReportFields(param.getDimension());
+        ExcelUtil<AppSalesWatchLogReportVO> util = new ExcelUtil<AppSalesWatchLogReportVO>(AppSalesWatchLogReportVO.class);
+        return util.exportExcelSelectedColumns(list, "APP销售维度看课统计报表", selectedFields);
+    }
+
+
+    /**
+     * 获取AppSalesWatchLogReportVO需要导出的字段
+     * @param dimension 维度:sales-销售维度, dept-销售部门维度
+     */
+    private List<String> getAppSalesWatchLogReportFields(String dimension) {
+        // 销售维度字段
+        List<String> salesFields = Arrays.asList(
+                "salesName", "appUserCount", "newAppUserCount", "salesDept",
+                "salesCompany", "trainingCampName", "periodName", "videoTitle",
+                "finishedCount", "unfinishedCount", "completionRate",
+                "notWatchedCount", "notAnsweredCount", "redPacketAmount", "historyOrderCount"
+        );
+
+        // 销售部门维度字段(去掉销售列,销售数放在销售部门后面)
+        List<String> deptFields = Arrays.asList(
+                "salesDept", "salesCount", "appUserCount", "newAppUserCount",
+                "salesCompany", "trainingCampName", "periodName", "videoTitle",
+                "finishedCount", "unfinishedCount", "completionRate",
+                "notWatchedCount", "notAnsweredCount", "redPacketAmount", "historyOrderCount"
+        );
+
+        return "dept".equals(dimension) ? deptFields : salesFields;
+    }
+
+
+    /**
+     * APP 端看课统计(销售维度)
+     * 对应前端页面:appWatchCourseStatistics.vue
+     */
+    @GetMapping("/appWatchCourseStatistics")
+    public TableDataInfo appWatchCourseStatistics(FsCourseWatchLogStatisticsListParam param) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<AppSalesCourseStatisticsVO> list = courseWatchLogService.selectAppSalesCourseStatisticsVO(param);
+        return getDataTable(list);
+    }
+
+    /**
+     * APP 端看课统计导出(销售维度)
+     */
+    @Log(title = "APP 看课统计", businessType = BusinessType.EXPORT)
+    @GetMapping("/appWatchCourseStatisticsExport")
+    public AjaxResult appWatchCourseStatisticsExport(FsCourseWatchLogStatisticsListParam param) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<AppSalesCourseStatisticsVO> list = courseWatchLogService.selectAppSalesCourseStatisticsVO(param);
+
+        ExcelUtil<AppSalesCourseStatisticsVO> util = new ExcelUtil<AppSalesCourseStatisticsVO>(AppSalesCourseStatisticsVO.class);
+        return util.exportExcel(list, "APP 看课统计报表");
+    }
+
+
+}

+ 12 - 0
fs-service/src/main/java/com/fs/app/service/AppPayService.java

@@ -0,0 +1,12 @@
+package com.fs.app.service;
+
+import com.github.binarywang.wxpay.bean.notify.WxPayOrderNotifyResult;
+
+public interface AppPayService {
+
+
+    /**
+     * 微信支付回调
+     */
+    String wxNotify(WxPayOrderNotifyResult result);
+}

+ 63 - 0
fs-service/src/main/java/com/fs/app/service/impl/AppPayServiceImpl.java

@@ -0,0 +1,63 @@
+package com.fs.app.service.impl;
+
+import com.fs.app.service.AppPayService;
+import com.fs.his.service.IFsInquiryOrderService;
+import com.fs.his.service.IFsPackageOrderService;
+import com.fs.hisStore.service.IFsStoreOrderScrmService;
+import com.fs.live.service.ILiveOrderService;
+import com.fs.system.mapper.SysConfigMapper;
+import com.github.binarywang.wxpay.bean.notify.WxPayNotifyResponse;
+import com.github.binarywang.wxpay.bean.notify.WxPayOrderNotifyResult;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.stereotype.Service;
+
+@Slf4j
+@Service
+public class AppPayServiceImpl implements AppPayService {
+
+    @Autowired
+    private SysConfigMapper sysConfigMapper;
+    @Autowired
+    private IFsInquiryOrderService inquiryOrderService;
+    @Lazy
+    @Autowired
+    private IFsStoreOrderScrmService storeOrderService;
+
+    @Lazy
+    @Autowired
+    private ILiveOrderService liveOrderService;
+    @Autowired
+    private IFsPackageOrderService packageOrderService;
+
+    @Override
+    public String wxNotify(WxPayOrderNotifyResult result) {
+        log.info("微信回调参数: {}", result);
+        if (!"SUCCESS".equals(result.getReturnCode())){
+            return WxPayNotifyResponse.success("微信回调失败");
+        }
+
+        if (!"SUCCESS".equals(result.getResultCode())){
+            return WxPayNotifyResponse.success("交易失败");
+        }
+
+        String[] tradeNoArr = result.getOutTradeNo().split("-");
+        switch (tradeNoArr[0]) {
+            case "inquiry":
+                inquiryOrderService.payConfirm("", tradeNoArr[1],"","",1,result.getTransactionId(),"");
+                break;
+            case "store":
+                storeOrderService.payConfirm(1, null,tradeNoArr[1],"",result.getTransactionId(),"");
+
+            case "live":
+                liveOrderService.payConfirm(1, null,tradeNoArr[1],"",result.getTransactionId(),"");
+                break;
+            case "package":
+                packageOrderService.payConfirm("", tradeNoArr[1],"","",1,result.getTransactionId(),"");
+                break;
+        }
+        return WxPayNotifyResponse.success("OK");
+    }
+
+}

+ 64 - 0
fs-service/src/main/java/com/fs/app/service/param/FsImMsgSendLogRequest.java

@@ -0,0 +1,64 @@
+package com.fs.app.service.param;
+
+import com.fs.common.core.page.PageDomain;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * OpenIM 消息发送记录请求参数
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FsImMsgSendLogRequest extends PageDomain {
+
+    /** 销售 ID */
+    private Long companyUserId;
+
+    /** 公司 ID */
+    private Long companyId;
+
+    /** 课程 ID */
+    private Long courseId;
+
+    /** 课程名称 */
+    private String courseName;
+
+    /** 视频 ID */
+    private Long videoId;
+
+    /** 视频标题 */
+    private String videoName;
+
+    /** 发送内容 */
+    private String sendTitle;
+
+    /** 预计发送开始时间 */
+    private String planSendStartTime;
+
+    /** 预计发送结束时间 */
+    private String planSendEndTime;
+
+    /** 发送类型(1-定时;2-实时) */
+    private Integer sendType;
+
+    /** 发送方式(1-APP;2-销售后台) */
+    private Integer sendMode;
+
+    /** 发送状态(1-已发送;2-待发送) */
+    private Integer sendStatus;
+
+    /** 执行状态 执行状态,0-正常;1-失败*/
+    private Integer status;
+
+    /** 消息类型(1-发课;2-催课) */
+    private Integer msgType;
+
+    /** 项目 ID */
+    private Long projectId;
+
+    /** 创建开始时间 */
+    private String createTimeStartTime;
+
+    /** 创建结束时间 */
+    private String createTimeEndTime;
+}

+ 78 - 0
fs-service/src/main/java/com/fs/app/service/param/FsImMsgSendLogResponse.java

@@ -0,0 +1,78 @@
+package com.fs.app.service.param;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * OpenIM 消息发送记录响应参数
+ */
+@Data
+public class FsImMsgSendLogResponse {
+
+    /** 发送记录 id */
+    private Long logId;
+
+    /** 用户 ID(从详情表获取) */
+    private Long userId;
+
+    /** 销售 id(发送人 id) */
+    private Long companyUserId;
+
+    /** 公司 id */
+    private Long companyId;
+
+    /** 课程 id */
+    private Long courseId;
+
+    /** 课程名称 */
+    private String courseName;
+
+    /** 视频 id */
+    private Long videoId;
+
+    /** 视频标题 */
+    private String videoName;
+
+    /** 项目 ID */
+    private Long projectId;
+
+    /** 发送内容 */
+    private String sendTitle;
+
+    /** 预计发送时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date planSendTime;
+
+    /** 发送类型,1-定时;2-实时 */
+    private Integer sendType;
+
+    /** 发送方式,1-app;2-销售后台 */
+    private Integer sendMode;
+
+    /** 是否催课,1-是;0-否 */
+    private Boolean isUrgeCourse;
+
+    /** 消息类型,1-发课;2-催课 */
+    private Integer msgType;
+
+    /** 发送状态,1-已发送;2-待发送 */
+    private Integer sendStatus;
+
+    /** 执行状态,0-正常;1-失败 */
+    private Integer status;
+
+    /** 重试次数 */
+    private Integer count;
+
+    /** 执行结果 */
+    private String resultMessage;
+
+    /** 异常信息 */
+    private String exceptionInfo;
+
+    /** 创建时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+}

+ 20 - 0
fs-service/src/main/java/com/fs/app/service/param/FsImMsgSendLogStatisticsResponse.java

@@ -0,0 +1,20 @@
+package com.fs.app.service.param;
+
+import lombok.Data;
+
+/**
+ * OpenIM 消息发送记录响应参数
+ */
+@Data
+public class FsImMsgSendLogStatisticsResponse {
+    // 总数
+    private Long total;
+    // 已发送
+    private Long sent;
+    // 等待中
+    private Long pending;
+    // 异常
+    private Long failed;
+
+
+}

+ 2 - 0
fs-service/src/main/java/com/fs/course/domain/FsCourseWatchLog.java

@@ -93,4 +93,6 @@ public class FsCourseWatchLog extends BaseEntity
 
     private Integer watchType;//看课方式:1 app  2 小程序
 
+    private Long linkId;
+
 }

+ 3 - 0
fs-service/src/main/java/com/fs/course/dto/BatchSendCourseDTO.java

@@ -78,4 +78,7 @@ public class BatchSendCourseDTO implements Serializable {
 
     @ApiModelProperty(value = "催课内容")
     private String urgeContent;
+
+    // 看课链接主键
+    private Long linkId;
 }

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

@@ -2,7 +2,9 @@ package com.fs.course.mapper;
 
 import com.fs.course.domain.FsCourseAnswerLogs;
 import com.fs.course.param.FsCourseAnswerLogsParam;
+import com.fs.course.param.FsCourseWatchLogStatisticsListParam;
 import com.fs.course.vo.FsCourseAnswerLogsListVO;
+import com.fs.his.vo.AppSalesCourseStatisticsVO;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
 
@@ -177,4 +179,7 @@ public interface FsCourseAnswerLogsMapper
                                             @Param("periodId") Long periodId,
                                             @Param("companyId") Long companyId,
                                             @Param("companyUserId") Long companyUserId);
+
+    List<AppSalesCourseStatisticsVO> selectAppSalesAnswerStatisticsVO(FsCourseWatchLogStatisticsListParam param);
+
 }

+ 2 - 0
fs-service/src/main/java/com/fs/course/mapper/FsCourseLinkMapper.java

@@ -86,4 +86,6 @@ public interface FsCourseLinkMapper
 
     List<FsCourseLink> selectLinkByCourseIdAndExIdAndQwUserId(@Param("courseId") Long courseId,@Param("videoId") Long videoId,@Param("qwExternalContactId") Long qwExternalContactId,@Param("qwUserId") String qwUserId);
 
+
+    FsCourseLink selectFsCourseLinkByVoideIdAndCompanyUserId(@Param("videoId") Long videoId,@Param("companyUserId") Long companyUserId);
 }

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

@@ -16,6 +16,7 @@ import com.fs.course.vo.FsCourseRedPacketLogListPVO;
 import com.fs.course.vo.FsCourseRedPacketLogListVO;
 import com.fs.course.vo.FsCourseWatchLogStatisticsListVO;
 import com.fs.course.vo.FsUserCourseOrderListPVO;
+import com.fs.his.vo.AppSalesCourseStatisticsVO;
 import org.apache.ibatis.annotations.MapKey;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
@@ -216,4 +217,7 @@ public interface FsCourseRedPacketLogMapper
     List<FsCourseRedPacketLog> selectFsCourseRedPacketLogListBySending(@Param("maps") Map<String, Object> map);
 
     List<FsCourseWatchLogStatisticsListVO> selectFsCourseRedPacketLogByQwUserIdList(@Param("param") FsCourseWatchLogStatisticsListParam param);
+
+    List<AppSalesCourseStatisticsVO> selectAppSalesRedPacketStatisticsVO(FsCourseWatchLogStatisticsListParam param);
+
 }

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

@@ -6,6 +6,10 @@ import com.fs.course.dto.WatchLogDTO;
 import com.fs.course.param.*;
 import com.fs.course.param.newfs.FsUserCourseVideoRemainTimeParam;
 import com.fs.course.vo.*;
+import com.fs.his.vo.AppSalesCourseStatisticsVO;
+import com.fs.his.vo.AppSalesWatchLogReportVO;
+import com.fs.his.vo.AppWatchLogReportVO;
+import com.fs.his.vo.WatchLogReportVO;
 import com.fs.qw.domain.QwExternalContact;
 import com.fs.qw.param.QwSidebarStatsParam;
 import com.fs.sop.vo.QwRatingVO;
@@ -839,4 +843,70 @@ public interface FsCourseWatchLogMapper extends BaseMapper<FsCourseWatchLog> {
             @Param("param") com.fs.course.param.CourseStatisticsUserDetailParam param);
 
     FSActualCompletionVO selectActualCompletionList(@Param("periodId") Long periodId, @Param("videoId") Long videoId, @Param("companyId") Long companyId,@Param("companyUserId") Long companyUserId);
+
+    List<AppSalesCourseStatisticsVO> selectAppSalesCourseStatisticsVO(FsCourseWatchLogStatisticsListParam param);
+
+    List<AppWatchLogReportVO> selectAppUserBaseData(FsCourseWatchLogStatisticsListParam param);
+
+    /**
+     * 销售端看课报表 营期训练营明细
+     * @param
+     * @return
+     */
+    List<WatchLogReportVO>  selectCampPeriodByPeriod(@Param("periodIds") List<Long> periodIds );
+
+    /**
+     * 销售端看课报表 红包
+     * @param
+     * @return
+     */
+    List<WatchLogReportVO> selectRedPacketStats(@Param("logIds") List<Long> logIds);
+
+    /**
+     * 答题
+     * @param logIds
+     * @return
+     */
+    List<WatchLogReportVO> selectAnswerStats(@Param("logIds") List<Long> logIds);
+
+
+    List<FsUserCourseAppListVO> selectCourseByUserIdForStatusFinish(Long userId);
+
+    List<FsUserCourseAppListVO> selectCourseByUserIdForStatusNotFinish(Long userId);
+
+    FsUserCourseAppListVO getAppCourseLearningOne(long userId);
+
+    /**
+     * 销售维度APP会员数统计
+     */
+    List<AppSalesWatchLogReportVO> selectAppSalesUserStats(FsCourseWatchLogStatisticsListParam param);
+
+    /**
+     * 销售维度基础数据+看课统计(合并查询)
+     */
+    List<AppSalesWatchLogReportVO> selectAppSalesWatchStats(FsCourseWatchLogStatisticsListParam param);
+
+
+    /**
+     * 销售维度订单统计
+     */
+    List<AppSalesWatchLogReportVO> selectAppSalesOrderStats(FsCourseWatchLogStatisticsListParam param);
+
+    List<AppSalesWatchLogReportVO> selectAppSalesCampPeriod(@Param("periodIds") List<Long> periodIds);
+
+
+    /**
+     * 销售部门维度APP会员数统计
+     */
+    List<AppSalesWatchLogReportVO> selectAppDeptUserStats(FsCourseWatchLogStatisticsListParam param);
+
+    /**
+     * 销售部门维度基础数据+看课统计(合并查询)
+     */
+    List<AppSalesWatchLogReportVO> selectAppDeptWatchStats(FsCourseWatchLogStatisticsListParam param);
+
+    /**
+     * 销售部门维度订单统计
+     */
+    List<AppSalesWatchLogReportVO> selectAppDeptOrderStats(FsCourseWatchLogStatisticsListParam param);
 }

+ 2 - 0
fs-service/src/main/java/com/fs/course/param/FsCourseLinkCreateParam.java

@@ -40,4 +40,6 @@ public class FsCourseLinkCreateParam {
 
     private Long projectId;//项目ID
 
+    private String type; // 1-app
+
 }

+ 10 - 2
fs-service/src/main/java/com/fs/course/param/FsCourseWatchLogStatisticsListParam.java

@@ -48,8 +48,8 @@ public class FsCourseWatchLogStatisticsListParam {
 
     private Long project;
 
-    private Long pageNum;
-    private Long pageSize;
+    private Integer pageNum;
+    private Integer pageSize;
 
     private Integer sendType; //归属发送方式:1 个微  2 企微
 
@@ -83,4 +83,12 @@ public class FsCourseWatchLogStatisticsListParam {
         });
         return longs;
     }
+
+
+    private String appId;
+
+    /**
+     * 手机号
+     */
+    private  String userPhone;
 }

+ 1 - 0
fs-service/src/main/java/com/fs/course/param/newfs/FsCourseSortLinkParam.java

@@ -41,4 +41,5 @@ public class FsCourseSortLinkParam {
     @ApiModelProperty(value = "项目id")
     private Long projectId;
 
+    private String type; // 1-app
 }

+ 4 - 0
fs-service/src/main/java/com/fs/course/param/newfs/FsUserCourseAddCompanyUserParam.java

@@ -37,4 +37,8 @@ public class FsUserCourseAddCompanyUserParam implements Serializable {
     @ApiModelProperty(value = "项目ID")
     private Long projectId;
     private Integer isOpenCourse;
+
+
+    // 1 app 2 小程序
+    private Integer watchType;
 }

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

@@ -5,6 +5,9 @@ import com.fs.common.core.domain.R;
 import com.fs.course.domain.FsCourseWatchLog;
 import com.fs.course.param.*;
 import com.fs.course.vo.*;
+import com.fs.his.vo.AppSalesCourseStatisticsVO;
+import com.fs.his.vo.AppSalesWatchLogReportVO;
+import com.fs.his.vo.AppWatchLogReportVO;
 import com.fs.qw.domain.QwExternalContact;
 import com.fs.qw.param.QwSidebarStatsParam;
 import com.fs.qw.vo.QwWatchLogStatisticsListVO;
@@ -193,4 +196,11 @@ public interface IFsCourseWatchLogService extends IService<FsCourseWatchLog> {
      * @return 用户详情列表
      */
     List<CourseStatisticsUserDetailVO> getCourseStatisticsUserDetailExportList(CourseStatisticsUserDetailParam param);
+
+    List<AppWatchLogReportVO> selectUserAppWatchLogReportVO(FsCourseWatchLogStatisticsListParam param);
+
+    List<AppSalesWatchLogReportVO> selectAppSalesWatchLogReportVO(FsCourseWatchLogStatisticsListParam param);
+
+    List<AppSalesCourseStatisticsVO> selectAppSalesCourseStatisticsVO(FsCourseWatchLogStatisticsListParam param);
+
 }

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

@@ -2,6 +2,7 @@ package com.fs.course.service;
 
 import com.fs.common.core.domain.R;
 import com.fs.course.domain.FsUserCourse;
+import com.fs.course.dto.BatchSendCourseDTO;
 import com.fs.course.param.*;
 import com.fs.course.param.newfs.FsUserCourseListParam;
 import com.fs.course.vo.*;
@@ -142,4 +143,15 @@ public interface IFsUserCourseService {
      * 获取课程选项列表
      */
     List<OptionsVO> selectCourseOptionsList();
+
+    R batchCreateCourseRecord(BatchSendCourseDTO batchSendCourseDTO);
+
+    R getAppCourseList(FsUserCourseAppListParam param);
+
+    R getLinkData(Long linkId);
+
+    R getAppCourseLearningOne(long userId);
+
 }
+
+

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

@@ -272,4 +272,6 @@ public interface IFsUserCourseVideoService extends IService<FsUserCourseVideo> {
      * 获取销售易看课详情
      */
     ResponseResult<FsUserCourseVideoLinkDetailsVO> getXiaoShouYiCourseVideoDetails(FsUserCourseVideoLinkParam param);
+
+    R registerQwFsUserFinish(FsUserCourseVideoAddKfUParam param);
 }

+ 540 - 4
fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java

@@ -11,6 +11,7 @@ import com.baomidou.mybatisplus.core.toolkit.ObjectUtils;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
+import com.fs.common.exception.base.BusinessException;
 import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.DictUtils;
 import com.fs.common.utils.date.DateUtil;
@@ -25,6 +26,11 @@ import com.fs.course.config.RedisKeyScanner;
 import com.fs.course.domain.*;
 import com.fs.course.mapper.*;
 import com.fs.course.param.*;
+import com.fs.his.mapper.FsUserMapper;
+import com.fs.his.vo.AppSalesCourseStatisticsVO;
+import com.fs.his.vo.AppSalesWatchLogReportVO;
+import com.fs.his.vo.AppWatchLogReportVO;
+import com.fs.his.vo.WatchLogReportVO;
 import com.fs.hisStore.domain.FsStoreOrderScrm;
 import com.fs.hisStore.dto.FsStoreCartDTO;
 import com.fs.hisStore.mapper.FsStoreOrderItemScrmMapper;
@@ -187,6 +193,9 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
     @Autowired
     private FsCourseAnswerLogsMapper fsCourseAnswerLogsMapper;
 
+    @Autowired
+    private FsUserMapper userMapper;
+
     /**
      * 查询短链课程看课记录
      *
@@ -1767,7 +1776,7 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
         Long companyUserId = param.getCompanyUserId() != null ? param.getCompanyUserId() : null;
 
         // 总体数据
-        
+
         // 1. 查询视频时长(只返回duration字段)
         FsUserCourseVideo fsUserCourseVideo = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoId(videoId);
         vo.setVideoDuration(fsUserCourseVideo != null ? fsUserCourseVideo.getDuration() : 0L);
@@ -1778,11 +1787,11 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
         // 2. 统计累计观看人数(对userId去重)
         Long totalWatchCount = fsCourseWatchLogMapper.countDistinctWatchUsers(videoId, periodId,companyId,companyUserId);
         vo.setTotalWatchCount(totalWatchCount != null ? totalWatchCount : 0L);
-        
+
         // 3. 统计累计完课人数(duration >= 1200秒,即20分钟,对userId去重)
         Long totalCompleteCount = fsCourseWatchLogMapper.countDistinctCompleteUsers(videoId, periodId,companyId,companyUserId);
         vo.setTotalCompleteCount(totalCompleteCount != null ? totalCompleteCount : 0L);
-        
+
         // 4. 计算到课完课率 = 累计完课人数 / 累计观看人数
         BigDecimal completeRate = BigDecimal.ZERO;
         if (vo.getTotalWatchCount() != null && vo.getTotalWatchCount() > 0) {
@@ -1908,7 +1917,7 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
             vo.setProductList(productList);
         }
 
-        
+
         return vo;
     }
 
@@ -1928,6 +1937,413 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
         return fsCourseWatchLogMapper.selectCourseStatisticsUserDetailExportList(param);
     }
 
+    @Override
+    public List<AppWatchLogReportVO> selectUserAppWatchLogReportVO(FsCourseWatchLogStatisticsListParam param) {
+        if (StringUtils.isNotEmpty(param.getUserPhone())) {
+            //加密手机号
+            param.setUserPhone(PhoneUtil.encryptPhone(param.getUserPhone()));
+        }
+        // 时间转字符串
+        if (param.getSTime() != null && param.getETime() != null) {
+            SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
+            param.setStartDate(simpleDateFormat.format(param.getSTime()));
+            param.setEndDate(simpleDateFormat.format(param.getETime()));
+        }
+        // 获取基础数据
+        List<AppWatchLogReportVO> baseData = fsCourseWatchLogMapper.selectAppUserBaseData(param);
+        if (CollectionUtils.isEmpty(baseData)) {
+            return Collections.emptyList();
+        }
+        // 获取统计数据和组装结果
+        return assembleAppStatisticsData(baseData, param);
+    }
+
+    @Override
+    public List<AppSalesWatchLogReportVO> selectAppSalesWatchLogReportVO(FsCourseWatchLogStatisticsListParam param) {
+        // 时间转字符串
+        if (param.getSTime() != null && param.getETime() != null) {
+            SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
+            param.setStartDate(simpleDateFormat.format(param.getSTime()));
+            param.setEndDate(simpleDateFormat.format(param.getETime()));
+        }
+
+        // 根据维度选择查询方式
+        String dimension = param.getDimension();
+        if ("dept".equals(dimension)) {
+            return selectAppDeptWatchLogReportVO(param);
+        } else {
+            return selectAppSalesWatchLogReportVOBySales(param);
+        }
+    }
+
+
+    /**
+     * 销售维度APP看课统计报表
+     */
+    private List<AppSalesWatchLogReportVO> selectAppSalesWatchLogReportVOBySales(FsCourseWatchLogStatisticsListParam param) {
+        // 1. 批量查询统计数据
+        // APP会员数统计(直接查fs_user表,按销售ID分组)
+
+        // 基础数据+看课统计+答题统计+红包统计(合并查询,按销售ID+营期ID+视频ID分组)
+        List<AppSalesWatchLogReportVO> watchStatsList = fsCourseWatchLogMapper.selectAppSalesWatchStats(param);
+        if (CollectionUtils.isEmpty(watchStatsList)) {
+            return Collections.emptyList();
+        }
+        List<AppSalesWatchLogReportVO> userStatsList = fsCourseWatchLogMapper.selectAppSalesUserStats(param);
+        // 订单统计
+//        List<AppSalesWatchLogReportVO> orderStatsList = fsCourseWatchLogMapper.selectAppSalesOrderStats(param);
+
+        // 2. 查询营期信息
+        List<Long> periodIds = watchStatsList.stream()
+                .map(AppSalesWatchLogReportVO::getPeriodId)
+                .distinct()
+                .collect(Collectors.toList());
+        List<AppSalesWatchLogReportVO> campPeriodList = fsCourseWatchLogMapper.selectAppSalesCampPeriod(periodIds);
+
+        // 3. 转换为Map便于查找
+        // APP会员数统计按销售ID分组
+        Map<Long, AppSalesWatchLogReportVO> userStatsMap = userStatsList.stream()
+                .collect(Collectors.toMap(
+                        AppSalesWatchLogReportVO::getSalesId,
+                        Function.identity(),
+                        (e, r) -> e
+                ));
+        // 看课统计(已包含基础数据、答题、红包)按销售ID+营期ID+视频ID分组
+        Map<String, AppSalesWatchLogReportVO> watchStatsMap = watchStatsList.stream()
+                .collect(Collectors.toMap(
+                        item -> item.getSalesId() + "_" + item.getPeriodId() + "_" + item.getVideoId(),
+                        Function.identity(),
+                        (e, r) -> e
+                ));
+        // 订单统计按销售ID+营期ID+视频ID分组
+//        Map<String, AppSalesWatchLogReportVO> orderStatsMap = orderStatsList.stream()
+//                .collect(Collectors.toMap(
+//                        item -> item.getSalesId() + "_" + item.getPeriodId() + "_" + item.getVideoId(),
+//                        Function.identity(),
+//                        (e, r) -> e
+//                ));
+        Map<Long, AppSalesWatchLogReportVO> campPeriodMap = campPeriodList.stream()
+                .collect(Collectors.toMap(
+                        AppSalesWatchLogReportVO::getPeriodId,
+                        Function.identity(),
+                        (e, r) -> e
+                ));
+
+        // 4. 组装数据
+        for (AppSalesWatchLogReportVO vo : watchStatsList) {
+            String key = vo.getSalesId() + "_" + vo.getPeriodId() + "_" + vo.getVideoId();
+
+            // APP会员数统计(按销售ID)
+            AppSalesWatchLogReportVO userStats = userStatsMap.get(vo.getSalesId());
+            if (userStats != null) {
+                vo.setAppUserCount(userStats.getAppUserCount());
+                vo.setNewAppUserCount(userStats.getNewAppUserCount());
+            }else {
+                vo.setAppUserCount(0);
+                vo.setNewAppUserCount(0);
+            }
+            // 计算完课率 = 完课数 / (完课数 + 未完课数 + 未看数) * 100%
+            int total = vo.getFinishedCount() + vo.getUnfinishedCount() + vo.getNotWatchedCount();
+            if (total > 0) {
+                vo.setCompletionRate(BigDecimal.valueOf(vo.getFinishedCount())
+                        .multiply(BigDecimal.valueOf(100))
+                        .divide(BigDecimal.valueOf(total), 2, RoundingMode.HALF_UP));
+            } else {
+                vo.setCompletionRate(BigDecimal.ZERO);
+            }
+
+            // 订单统计
+//            AppSalesWatchLogReportVO orderStats = orderStatsMap.get(key);
+//            if (orderStats != null) {
+//                vo.setHistoryOrderCount(orderStats.getHistoryOrderCount());
+//            }
+
+            // 营期信息
+            AppSalesWatchLogReportVO campPeriod = campPeriodMap.get(vo.getPeriodId());
+            if (campPeriod != null) {
+                vo.setPeriodName(campPeriod.getPeriodName());
+                vo.setTrainingCampName(campPeriod.getTrainingCampName());
+            }
+        }
+
+        return watchStatsList;
+    }
+
+    /**
+     * 销售部门维度APP看课统计报表
+     */
+    private List<AppSalesWatchLogReportVO> selectAppDeptWatchLogReportVO(FsCourseWatchLogStatisticsListParam param) {
+        // 1. 批量查询统计数据
+        // APP会员数统计(直接查fs_user表,按部门ID分组)
+        List<AppSalesWatchLogReportVO> userStatsList = fsCourseWatchLogMapper.selectAppDeptUserStats(param);
+        // 基础数据+看课统计+答题统计+红包统计(合并查询,按部门ID+营期ID+视频ID分组)
+        List<AppSalesWatchLogReportVO> watchStatsList = fsCourseWatchLogMapper.selectAppDeptWatchStats(param);
+        if (CollectionUtils.isEmpty(watchStatsList)) {
+            return Collections.emptyList();
+        }
+        // 订单统计
+        List<AppSalesWatchLogReportVO> orderStatsList = fsCourseWatchLogMapper.selectAppDeptOrderStats(param);
+
+        // 2. 查询营期信息
+        List<Long> periodIds = watchStatsList.stream()
+                .map(AppSalesWatchLogReportVO::getPeriodId)
+                .distinct()
+                .collect(Collectors.toList());
+        List<AppSalesWatchLogReportVO> campPeriodList = fsCourseWatchLogMapper.selectAppSalesCampPeriod(periodIds);
+
+        // 3. 转换为Map便于查找
+        // APP会员数统计按部门ID分组
+        Map<Long, AppSalesWatchLogReportVO> userStatsMap = userStatsList.stream()
+                .collect(Collectors.toMap(
+                        AppSalesWatchLogReportVO::getDeptId,
+                        Function.identity(),
+                        (e, r) -> e
+                ));
+        // 看课统计(已包含基础数据、答题、红包)按部门ID+营期ID+视频ID分组
+        Map<String, AppSalesWatchLogReportVO> watchStatsMap = watchStatsList.stream()
+                .collect(Collectors.toMap(
+                        item -> item.getDeptId() + "_" + item.getPeriodId() + "_" + item.getVideoId(),
+                        Function.identity(),
+                        (e, r) -> e
+                ));
+        // 订单统计按部门ID+营期ID+视频ID分组
+        Map<String, AppSalesWatchLogReportVO> orderStatsMap = orderStatsList.stream()
+                .collect(Collectors.toMap(
+                        item -> item.getDeptId() + "_" + item.getPeriodId() + "_" + item.getVideoId(),
+                        Function.identity(),
+                        (e, r) -> e
+                ));
+        Map<Long, AppSalesWatchLogReportVO> campPeriodMap = campPeriodList.stream()
+                .collect(Collectors.toMap(
+                        AppSalesWatchLogReportVO::getPeriodId,
+                        Function.identity(),
+                        (e, r) -> e
+                ));
+
+        // 4. 组装数据
+        for (AppSalesWatchLogReportVO vo : watchStatsList) {
+            String key = vo.getDeptId() + "_" + vo.getPeriodId() + "_" + vo.getVideoId();
+
+            // APP会员数统计(按部门ID)
+            AppSalesWatchLogReportVO userStats = userStatsMap.get(vo.getDeptId());
+            if (userStats != null) {
+                vo.setAppUserCount(userStats.getAppUserCount());
+                vo.setNewAppUserCount(userStats.getNewAppUserCount());
+                vo.setSalesCount(userStats.getSalesCount()); // 销售数(部门维度特有)
+            }else {
+                vo.setAppUserCount(0);
+                vo.setNewAppUserCount(0);
+                vo.setSalesCount(0);
+            }
+            // 计算完课率 = 完课数 / (完课数 + 未完课数 + 未看数) * 100%
+            int total = vo.getFinishedCount() + vo.getUnfinishedCount() + vo.getNotWatchedCount();
+            if (total > 0) {
+                vo.setCompletionRate(BigDecimal.valueOf(vo.getFinishedCount())
+                        .multiply(BigDecimal.valueOf(100))
+                        .divide(BigDecimal.valueOf(total), 2, RoundingMode.HALF_UP));
+            } else {
+                vo.setCompletionRate(BigDecimal.ZERO);
+            }
+
+            // 订单统计
+            AppSalesWatchLogReportVO orderStats = orderStatsMap.get(key);
+            if (orderStats != null) {
+                vo.setHistoryOrderCount(orderStats.getHistoryOrderCount());
+            }
+
+            // 营期信息
+            AppSalesWatchLogReportVO campPeriod = campPeriodMap.get(vo.getPeriodId());
+            if (campPeriod != null) {
+                vo.setPeriodName(campPeriod.getPeriodName());
+                vo.setTrainingCampName(campPeriod.getTrainingCampName());
+            }
+        }
+
+        return watchStatsList;
+    }
+
+    /**
+     * @Description: app 看课统计
+     * @Param:
+     * @Return:
+     * @Author xgb
+     * @Date 2026/3/23 16:10
+     */
+
+    @Override
+    public List<AppSalesCourseStatisticsVO> selectAppSalesCourseStatisticsVO(FsCourseWatchLogStatisticsListParam param) {
+
+        // 校验时间为必输字段而且不能大于一个月
+        if (StringUtils.isEmpty(param.getStartDate()) || StringUtils.isEmpty(param.getEndDate())) {
+            throw new BusinessException("请选择时间");
+        }
+
+        // 看课记录
+        CompletableFuture<List<AppSalesCourseStatisticsVO>> watchFuture = CompletableFuture.supplyAsync(() -> {
+            return fsCourseWatchLogMapper.selectAppSalesCourseStatisticsVO(param);
+        });
+
+        // 答题记录
+        CompletableFuture<List<AppSalesCourseStatisticsVO>> answerFuture = CompletableFuture.supplyAsync(() -> {
+            return fsCourseAnswerLogsMapper.selectAppSalesAnswerStatisticsVO(param);
+        });
+
+        // 红包记录
+        CompletableFuture<List<AppSalesCourseStatisticsVO>> redPacketFuture = CompletableFuture.supplyAsync(() -> {
+            return fsCourseRedPacketLogMapper.selectAppSalesRedPacketStatisticsVO(param);
+        });
+
+        CompletableFuture.allOf(watchFuture, answerFuture, redPacketFuture).join();
+
+        List<AppSalesCourseStatisticsVO> list = watchFuture.join();
+        if (CollectionUtils.isEmpty(list)) {
+            return Collections.emptyList();
+        }
+
+        // 整合数据
+        List<AppSalesCourseStatisticsVO> answerList = answerFuture.join();
+        List<AppSalesCourseStatisticsVO> redPacketList = redPacketFuture.join();
+
+        Map<String, AppSalesCourseStatisticsVO> dataMap = list.stream()
+                .collect(Collectors.toMap(
+                        item -> item.getCompanyUserId() + "_" + item.getCourseId() + "_" + item.getVideoId(),
+                        Function.identity(),
+                        (e, r) -> e
+                ));
+
+        if (CollectionUtils.isNotEmpty(answerList)) {
+            Map<String, AppSalesCourseStatisticsVO> answerMap = answerList.stream()
+                    .collect(Collectors.toMap(
+                            item -> item.getCompanyUserId() + "_" + item.getCourseId() + "_" + item.getVideoId(),
+                            Function.identity(),
+                            (e, r) -> e
+                    ));
+
+            for (Map.Entry<String, AppSalesCourseStatisticsVO> entry : dataMap.entrySet()) {
+                AppSalesCourseStatisticsVO vo = entry.getValue();
+                AppSalesCourseStatisticsVO answerVO = answerMap.get(entry.getKey());
+                if (answerVO != null) {
+                    vo.setAnsweredCount(answerVO.getAnsweredCount());
+                    vo.setCorrectCount(answerVO.getCorrectCount());
+                }
+            }
+        }
+
+
+        if (CollectionUtils.isNotEmpty(redPacketList)) {
+            Map<String, AppSalesCourseStatisticsVO> redPacketMap = redPacketList.stream()
+                    .collect(Collectors.toMap(
+                            item -> item.getCompanyUserId() + "_" + item.getCourseId() + "_" + item.getVideoId(),
+                            Function.identity(),
+                            (e, r) -> e
+                    ));
+
+            for (Map.Entry<String, AppSalesCourseStatisticsVO> entry : dataMap.entrySet()) {
+                AppSalesCourseStatisticsVO vo = entry.getValue();
+                AppSalesCourseStatisticsVO redPacketVO = redPacketMap.get(entry.getKey());
+                if (redPacketVO != null) {
+                    vo.setRedPacketAmount(redPacketVO.getRedPacketAmount());
+                }
+            }
+        }
+
+        // 获取销售的 app会员数和 新注册的会员数
+        CompletableFuture<List<AppSalesCourseStatisticsVO>> appNewUserFuture = CompletableFuture.supplyAsync(() -> {
+            return userMapper.selectAppSalesNewUserCountVO(param);
+        });
+
+        CompletableFuture<List<AppSalesCourseStatisticsVO>> appUserFuture = CompletableFuture.supplyAsync(() -> {
+            return userMapper.selectAppSalesUserCountVO(param);
+        });
+
+        CompletableFuture.allOf(appNewUserFuture, appUserFuture).join();
+
+        // 整合数据
+        List<AppSalesCourseStatisticsVO> appNewUserCountList = appNewUserFuture.join();
+        List<AppSalesCourseStatisticsVO> appUserCountList = appUserFuture.join();
+
+        if (CollectionUtils.isNotEmpty(appNewUserCountList)) {
+            Map<Long, Long> newUserCountMap = appNewUserCountList.stream()
+                    .collect(Collectors.toMap(
+                            AppSalesCourseStatisticsVO::getCompanyUserId,
+                            AppSalesCourseStatisticsVO::getNewAppUserCount,
+                            (e, r) -> e
+                    ));
+
+            for (AppSalesCourseStatisticsVO vo : dataMap.values()) {
+                Long newAppUserCount = newUserCountMap.get(vo.getCompanyUserId());
+                if (newAppUserCount != null) {
+                    vo.setNewAppUserCount(newAppUserCount);
+                }
+            }
+        }
+
+        if (CollectionUtils.isNotEmpty(appUserCountList)) {
+            Map<Long, Long> userCountMap = appUserCountList.stream()
+                    .collect(Collectors.toMap(
+                            AppSalesCourseStatisticsVO::getCompanyUserId,
+                            AppSalesCourseStatisticsVO::getAppUserCount,
+                            (e, r) -> e
+                    ));
+
+            for (AppSalesCourseStatisticsVO vo : dataMap.values()) {
+                Long appUserCount = userCountMap.get(vo.getCompanyUserId());
+                if (appUserCount != null) {
+                    vo.setAppUserCount(appUserCount);
+                }
+            }
+        }
+
+        List<AppSalesCourseStatisticsVO> resultList = new ArrayList<>(dataMap.values());
+        resultList.forEach(this::setDefaultValues);
+        return resultList;
+    }
+
+    private void setDefaultValues(AppSalesCourseStatisticsVO vo) {
+        vo.setFinishedCount(vo.getFinishedCount() != null ? vo.getFinishedCount() : 0);
+        vo.setNotWatchedCount(vo.getNotWatchedCount() != null ? vo.getNotWatchedCount() : 0);
+        vo.setInterruptCount(vo.getInterruptCount() != null ? vo.getInterruptCount() : 0);
+        vo.setWatchingCount(vo.getWatchingCount() != null ? vo.getWatchingCount() : 0);
+        vo.setAnsweredCount(vo.getAnsweredCount() != null ? vo.getAnsweredCount() : 0);
+        vo.setCorrectCount(vo.getCorrectCount() != null ? vo.getCorrectCount() : 0);
+        vo.setNewAppUserCount(vo.getNewAppUserCount() != null ? vo.getNewAppUserCount() : 0L);
+        vo.setAppUserCount(vo.getAppUserCount() != null ? vo.getAppUserCount() : 0L);
+        vo.setRedPacketCount(vo.getRedPacketCount() != null ? vo.getRedPacketCount() : 0);
+
+        if (vo.getRedPacketAmount() == null) {
+            vo.setRedPacketAmount(BigDecimal.ZERO);
+        }
+
+        // 计算完课率 = 完课数 / (完课数 + 未完课数 + 中断数 + 看课中数) * 100%,保留两位小数
+        int total = vo.getFinishedCount() + vo.getNotWatchedCount() + vo.getInterruptCount() + vo.getWatchingCount();
+        if (total > 0) {
+            BigDecimal finished = new BigDecimal(vo.getFinishedCount());
+            BigDecimal totalCount = new BigDecimal(total);
+            vo.setCompletionRate(finished.divide(totalCount, 4, RoundingMode.HALF_UP));
+        } else {
+            vo.setCompletionRate(BigDecimal.ZERO);
+        }
+        // 计算一下答题正确率
+        if (vo.getAnsweredCount() > 0) {
+            BigDecimal answered = new BigDecimal(vo.getAnsweredCount());
+            BigDecimal correct = new BigDecimal(vo.getCorrectCount());
+            vo.setCorrectRate(correct.divide(answered, 4, RoundingMode.HALF_UP));
+        } else {
+            vo.setCorrectRate(BigDecimal.ZERO);
+        }
+
+        if (vo.getSalesName() == null) {
+            vo.setSalesName("");
+        }
+
+        if (vo.getCourseName() == null) {
+            vo.setCourseName("");
+        }
+
+        if (vo.getVideoTitle() == null) {
+            vo.setVideoTitle("");
+        }
+    }
     /**
      * 从 Map 中安全获取 Long 值,兼容 MyBatis 返回的驼峰/小写键名
      */
@@ -1940,4 +2356,124 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
         try { return Long.parseLong(String.valueOf(v)); } catch (NumberFormatException e) { return null; }
     }
 
+
+    /**
+     * 组装APP统计数据
+     */
+    private List<AppWatchLogReportVO> assembleAppStatisticsData(List<AppWatchLogReportVO> baseData, FsCourseWatchLogStatisticsListParam param) {
+        // 准备查询条件
+        List<Long> periods = baseData.stream().map(AppWatchLogReportVO::getPeriodId).collect(Collectors.toList());
+        List<Long> logIds = baseData.stream().map(AppWatchLogReportVO::getLogId).collect(Collectors.toList());
+        List<Long> userIds = baseData.stream().map(AppWatchLogReportVO::getUserId).collect(Collectors.toList());
+
+        // 批量查询统计数据
+        // 营期数据
+        Map<Long, WatchLogReportVO> perMap = convertCampPeriodToMap(fsCourseWatchLogMapper.selectCampPeriodByPeriod(periods));
+
+        // 红包数据
+        Map<Long, WatchLogReportVO> redPacketMap = convertRedPacketToMap(
+                fsCourseWatchLogMapper.selectRedPacketStats(logIds)
+        );
+
+//        // 订单数据
+//        Map<Long, WatchLogReportVO> orderMap = convertOrderToMap(
+//                fsCourseWatchLogMapper.selectOrderStats(userIds, param)
+//        );
+
+        // 答题数据
+        Map<Long, WatchLogReportVO> answerMap = convertAnswerToMap(
+                fsCourseWatchLogMapper.selectAnswerStats(logIds)
+        );
+
+        // 学习时长数据(来自fs_user_course_study_log表)- 使用字符串时间
+//        Map<String, AppWatchLogReportVO> studyDurationMap = fsUserCourseStudyLogMapper.selectStudyDurationByUserIds(userIds, param.getStartDate(), param.getEndDate())
+//                .stream()
+//                .collect(Collectors.toMap(
+//                        item -> item.getUserId() + "_" + item.getVideoId(),
+//                        Function.identity()
+//                ));
+
+        // 组装数据
+        for (AppWatchLogReportVO item : baseData) {
+            // 营期数据
+            WatchLogReportVO watchStats = perMap.getOrDefault(item.getPeriodId(), null);
+            if (watchStats != null) {
+                item.setPeriodName(watchStats.getPeriodName());
+                item.setTrainingCampName(watchStats.getTrainingCampName());
+            }
+
+            // 红包数据
+            WatchLogReportVO redPacketStats = redPacketMap.getOrDefault(item.getLogId(), null);
+            if (redPacketStats != null) {
+                item.setRedPacketAmount(redPacketStats.getRedPacketAmount());
+            }
+
+//            // 订单数据
+//            WatchLogReportVO order = orderMap.getOrDefault(item.getUserId(), null);
+//            if (order != null) {
+//                item.setHistoryOrderCount(order.getHistoryOrderCount());
+//            }
+
+            // 答题数据
+            WatchLogReportVO answer = answerMap.getOrDefault(item.getLogId(), null);
+            if (answer != null) {
+                item.setAnswerStatus(answer.getAnswerStatus());
+            }
+
+            // 学习时长数据
+//            AppWatchLogReportVO studyDuration = studyDurationMap.get(item.getUserId() + "_" + item.getVideoId());
+//            if (studyDuration != null && studyDuration.getPublicCourseDuration() != null) {
+//                // 将秒转换为时分秒格式
+//                item.setPublicCourseDuration(formatDuration(Long.valueOf(studyDuration.getPublicCourseDuration())));
+//            }
+        }
+        return baseData;
+    }
+
+
+    /**
+     * 答题数据转Map
+     */
+    public Map<Long, WatchLogReportVO> convertAnswerToMap(List<WatchLogReportVO> list) {
+        if (list == null || list.isEmpty()) {
+            return new HashMap<>();
+        }
+        return list.stream()
+                .collect(Collectors.toMap(
+                        WatchLogReportVO::getLogId,
+                        Function.identity(),
+                        (existing, replacement) -> existing // 当出现重复键时,保留第一个值
+                ));
+    }
+
+    /**
+     * 红包数据转Map
+     */
+    public Map<Long, WatchLogReportVO> convertRedPacketToMap(List<WatchLogReportVO> list) {
+        if (list == null || list.isEmpty()) {
+            return new HashMap<>();
+        }
+        return list.stream()
+                .collect(Collectors.toMap(
+                        WatchLogReportVO::getLogId,
+                        Function.identity(),
+                        (existing, replacement) -> existing // 当出现重复键时,保留第一个值
+                ));
+    }
+
+    /**
+     * 营期数据转Map
+     */
+    public Map<Long, WatchLogReportVO> convertCampPeriodToMap(List<WatchLogReportVO> list) {
+        if (list == null || list.isEmpty()) {
+            return new HashMap<>();
+        }
+        return list.stream()
+                .collect(Collectors.toMap(
+                        WatchLogReportVO::getPeriodId,
+                        Function.identity(),
+                        (existing, replacement) -> existing // 当出现重复键时,保留第一个值
+                ));
+    }
+
 }

+ 128 - 3
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseServiceImpl.java

@@ -24,6 +24,7 @@ import com.fs.company.mapper.CompanyTagMapper;
 import com.fs.company.mapper.CompanyUserMapper;
 import com.fs.course.config.CourseConfig;
 import com.fs.course.domain.*;
+import com.fs.course.dto.BatchSendCourseDTO;
 import com.fs.course.mapper.*;
 import com.fs.course.param.*;
 import com.fs.course.param.newfs.FsUserCourseListParam;
@@ -603,7 +604,8 @@ public class FsUserCourseServiceImpl implements IFsUserCourseService
         if (i > 0){
             String domainName = getDomainName(param.getCompanyUserId(), config);
             String sortLink = domainName + shortLink + link.getLink();
-            return R.ok().put("url", sortLink).put("link", random);
+//            return R.ok().put("url", sortLink).put("link", random);
+            return R.ok().put("url", sortLink).put("link", random).put("linkId", link.getLinkId());
         }
         return R.error("生成链接失败!");
     }
@@ -786,11 +788,11 @@ public class FsUserCourseServiceImpl implements IFsUserCourseService
             if (CloudHostUtils.hasCloudHostName("中康")){
                 String domainName = getDomainName(param.getCompanyUserId(), config);
                 String sortLink = domainName + link.getRealLink().replace("/#","");
-                return R.ok().put("url", sortLink).put("link", random);
+                return R.ok().put("url", sortLink).put("link", random).put("linkId", link.getLinkId());
             }
             String domainName = getDomainName(param.getCompanyUserId(), config);
             String sortLink = domainName + appShortLink + link.getLink();
-            return R.ok().put("url", sortLink).put("link", random);
+            return R.ok().put("url", sortLink).put("link", random).put("linkId", link.getLinkId());
         }
         return R.error("生成链接失败!");
     }
@@ -811,6 +813,129 @@ public class FsUserCourseServiceImpl implements IFsUserCourseService
         return fsUserCourseMapper.selectCourseOptionsList();
     }
 
+    @Override
+    public R batchCreateCourseRecord(BatchSendCourseDTO batchSendCourseDTO) {
+        if(batchSendCourseDTO.getUserIds()== null|| batchSendCourseDTO.getUserIds().isEmpty()){
+            return R.error("请选择用户!");
+        }
+        FsUserCourse fsUserCourse = fsUserCourseMapper.selectFsUserCourseByCourseId(batchSendCourseDTO.getCourseId());
+        Long project = fsUserCourse != null ? fsUserCourse.getProject() : null;
+        List<FsCourseWatchLog> watchLogsInsertList = new LinkedList<>();
+        for (Long userId : batchSendCourseDTO.getUserIds()) {
+            FsCourseWatchLog fsCourseWatchLog = new FsCourseWatchLog();
+            BeanUtils.copyProperties(batchSendCourseDTO, fsCourseWatchLog);
+            fsCourseWatchLog.setUserId(userId);
+            fsCourseWatchLog.setSendType(1);
+            fsCourseWatchLog.setDuration(0L);
+            fsCourseWatchLog.setCreateTime(new Date());
+            fsCourseWatchLog.setLogType(3);
+            fsCourseWatchLog.setProject(project);
+            fsCourseWatchLog.setWatchType(1); // app
+            fsCourseWatchLog.setLinkId(batchSendCourseDTO.getLinkId());
+            watchLogsInsertList.add(fsCourseWatchLog);
+        }
+        fsCourseWatchLogMapper.insertFsCourseWatchLogBatch(watchLogsInsertList);
+        return R.ok();
+    }
+
+    @Override
+    public R getAppCourseList(FsUserCourseAppListParam param) {
+        // 查询看课记录
+        List<FsUserCourseAppListVO> list;
+        if("2".equals(param.getLogType())){ // 完课
+            list=fsCourseWatchLogMapper.selectCourseByUserIdForStatusFinish(param.getUserId());
+        }else if("3".equals(param.getLogType())){ // 待看课
+            list=fsCourseWatchLogMapper.selectCourseByUserIdForStatusNotFinish(param.getUserId());
+        }else {
+            return R.error("参数错误!");
+        }
+        list.forEach(item -> {
+            // link_id 有可能空 返回log_id 后续处理
+            item.setLinkId(item.getLogId());
+
+            String redisKey = "h5wxuser:watch:duration:" + item.getUserId() + ":" + item.getVideoId() + ":" + item.getCompanyUserId();
+            String durationCurrent = redisCache.getCacheObject(redisKey);
+            if(durationCurrent != null && !durationCurrent.isEmpty()){
+                item.setDuration(Long.parseLong(durationCurrent));
+            }
+            item.setVideoDuration(getFsUserVideoDuration(item.getVideoId()));
+        });
+
+        return R.ok().put("data", list);
+    }
+
+    @Override
+    public R getLinkData(Long logId) {
+        FsCourseWatchLog watchLog = fsCourseWatchLogMapper.selectFsCourseWatchLogByLogId(logId);
+        if(watchLog==null){
+            return R.error("看课记录不存在");
+        }
+
+        FsCourseLink fsCourseLink;
+        if(watchLog.getLinkId()!=null){
+
+            fsCourseLink = fsCourseLinkMapper.selectFsCourseLinkByLinkId(watchLog.getLinkId());
+        }else {
+            fsCourseLink=fsCourseLinkMapper.selectFsCourseLinkByVoideIdAndCompanyUserId(watchLog.getVideoId(), watchLog.getCompanyUserId());
+        }
+
+
+
+
+//        FsCourseLink fsCourseLink = fsCourseLinkMapper.selectFsCourseLinkByLinkId(linkId);
+        if (fsCourseLink == null){
+            return R.error("视频已过期!");
+        }
+        String json = configService.selectConfigByKey("course.config");
+        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
+        String domainName = getDomainName(fsCourseLink.getCompanyUserId(), config);
+        String sortLink = domainName+ fsCourseLink.getRealLink().replace("/#","");
+
+        Map<String, Object> data = new HashMap<>();
+        data.put("url", sortLink);
+        return R.ok().put("data", data);
+    }
+
+    @Override
+    public R getAppCourseLearningOne(long userId) {
+        //
+        FsUserCourseAppListVO item=fsCourseWatchLogMapper.getAppCourseLearningOne(userId);
+        if(item==null){
+            return R.error("查询无记录");
+        }else {
+            // link_id 有可能空 返回log_id 后续处理
+            item.setLinkId(item.getLogId());
+
+            String redisKey = "h5wxuser:watch:duration:" + item.getUserId() + ":" + item.getVideoId() + ":" + item.getCompanyUserId();
+            String durationCurrent = redisCache.getCacheObject(redisKey);
+            if(durationCurrent != null && !durationCurrent.isEmpty()){
+                item.setDuration(Long.parseLong(durationCurrent));
+            }
+            item.setVideoDuration(getFsUserVideoDuration(item.getVideoId()));
+        }
+        return R.ok().put("data", item);
+    }
+
+    public Long getFsUserVideoDuration(Long videoId){
+        //将视频时长也存到redis
+        String videoRedisKey = "h5wxuser:video:duration:" + videoId;
+        Long videoDuration=0L;
+        try {
+            videoDuration = redisCache.getCacheObject(videoRedisKey);
+        }catch (Exception e){
+            String string = redisCache.getCacheObject(videoRedisKey);
+            videoDuration=Long.parseLong(string);
+            log.error("key中id为S:{}", videoDuration);
+        }
+
+
+        if (videoDuration==null){
+            FsUserCourseVideo video = fsUserCourseVideoMapper.selectFsUserCourseVideoByVideoId(videoId);
+            videoDuration=video.getDuration();
+            redisCache.setCacheObject(videoRedisKey,video.getDuration());
+        }
+        return videoDuration;
+    }
 
     private Graphics2D initializeGraphics(BufferedImage combined) {
         Graphics2D graphics = combined.createGraphics();

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

@@ -4660,5 +4660,115 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
 
         return ResponseResult.ok(vo);
     }
+
+    @Override
+    public R registerQwFsUserFinish(FsUserCourseVideoAddKfUParam param) {// 查询用户
+        FsUser fsUser = fsUserMapper.selectFsUserByUserId(param.getUserId());
+
+        // 不能反401 前端会絮乱
+        // 用户不存在唤起重新授权
+        if (fsUser == null) {
+            return R.error("用户不存在");
+        }
+//        if (StringUtils.isNotEmpty(fsUser.getNickName())
+//                &&fsUser.getNickName().equals("微信用户")) {
+//            return R.error(409, "请重新登录用户!");
+//        }
+        if (fsUser.getStatus() != null && fsUser.getStatus() == 0) {
+            return R.error("会员被停用,无权限,请联系客服!");
+        }
+
+        // 处理群聊逻辑
+        if (param.getChatId() != null && StringUtils.isNotEmpty(param.getChatId())) {
+            QwGroupChat qwGroupChat = qwGroupChatMapper.selectQwGroupChatByChatId(param.getChatId());
+            if (qwGroupChat == null) {
+                logger.error("群聊不存在,chatId: {}", param.getChatId());
+                return R.error("群聊不存在!");
+            }
+
+            SopUserLogsInfo sopUserLogsInfo = new SopUserLogsInfo();
+            sopUserLogsInfo.setChatId(param.getChatId());
+            List<QwGroupChatUser> qwGroupChatUsers = qwGroupChatUserMapper.selectByChatId(sopUserLogsInfo);
+
+            if (qwGroupChatUsers == null || qwGroupChatUsers.isEmpty()) {
+                logger.error("群聊用户为空,chatId: {}", param.getChatId());
+                return R.error("群聊用户为空!");
+            }
+
+            QwExternalContact qwExternalContact =
+                    qwExternalContactMapper.selectOne(new QueryWrapper<QwExternalContact>()
+                            .eq("user_id", qwGroupChat.getOwner())
+                            .eq("fs_user_id", param.getUserId())
+                            .eq("corp_id", param.getCorpId())
+                            .eq("status",0));
+            if(null == qwExternalContact){
+                try{
+                    //修改成通过昵称匹配
+                    qwExternalContact =
+                            qwExternalContactMapper.selectOne(new QueryWrapper<QwExternalContact>()
+                                    .eq("user_id", qwGroupChat.getOwner())
+                                    .eq("name", fsUser.getNickName())
+                                    .eq("corp_id", param.getCorpId())
+                                    .eq("status",0));
+                } catch(Exception e){
+                    log.error("群聊用户昵称匹配异常,参数user_id:{},name:{},corp_id:{}",qwGroupChat.getOwner(),fsUser.getNickName(),param.getCorpId(),e);
+                }
+
+            }
+
+            if (qwExternalContact == null) {
+                return R.error("未查询到客户!");
+            }
+
+            QwExternalContact finalQwExternalContact = qwExternalContact;
+            if(qwGroupChatUsers.stream().noneMatch(e -> e.getUserId().equals(finalQwExternalContact.getExternalUserId()))){
+                log.error("客户不在群:{},里面:{}", qwGroupChat.getChatId(), qwExternalContact.getExternalUserId());
+                return R.error("客户不在群!");
+            }
+
+            logger.info("外部联系人数据:{}", qwExternalContact);
+
+            // 如果群在里面
+            if (qwExternalContact.getFsUserId() != null) {
+                // 有客户有小程序id,但登录的小程序id和根据外部联系人id查出来的小程序id不一致
+//                if (!qwExternalContact.getFsUserId().equals(param.getUserId())) {
+//                    logger.error("已注册,但绑定的userId,不一致param.getUserId{},qwExternalContact.getFsUserId(){}",param.getUserId(),qwExternalContact.getFsUserId());
+//                    return R.error("已注册!");
+//                }
+            }else {
+                // 未绑定
+                return R.error( "客户未绑定用户");
+            }
+
+
+        }else {
+            Long qwExternalId = param.getQwExternalId();
+
+            if (qwExternalId == null) {
+                return R.error("外部联系人ID不能为空");
+            }
+
+            // 查询外部联系人
+            QwExternalContact externalContact = qwExternalContactMapper.selectQwExternalContactById(qwExternalId);
+
+            // 如果查不出来客户信息,加好友
+            if (externalContact == null) {
+                return R.error("未查询到企微客户信息!");
+            }
+
+            if (externalContact.getFsUserId() != null) {
+                // 有客户有小程序id,但登录的小程序id和根据外部联系人id查出来的小程序id不一致
+//                if (!externalContact.getFsUserId().equals(param.getUserId())) {
+//                    logger.error("已注册,但绑定的userId,不一致param.getUserId{},qwExternalContact.getFsUserId(){}",param.getUserId(),externalContact.getFsUserId());
+//                    return R.error("已注册!");
+//                }
+            }else {
+                // 未绑定
+                return R.error( "客户未绑定用户");
+            }
+        }
+
+        return R.ok();
+    }
 }
 

+ 42 - 0
fs-service/src/main/java/com/fs/course/vo/FsUserCourseAppListVO.java

@@ -0,0 +1,42 @@
+package com.fs.course.vo;
+
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+
+/**
+ * 课程对象 fs_user_course
+ *
+ * @author fs
+ * @date 2024-05-15
+ */
+@Data
+public class FsUserCourseAppListVO extends BaseEntity
+{
+    private static final long serialVersionUID = 1L;
+
+
+    /** 课程名称 */
+    private String courseName;
+
+    /** 课程封面 */
+    private String imgUrl;
+
+    private String videoName;
+
+    private Long linkId;
+
+    // 播放时长
+    private Long duration;
+
+    // 视频时长
+    private Long videoDuration;
+
+    private Long videoId;
+
+    private Long userId;
+
+    private Long companyUserId;
+
+    private Long logId;
+
+}

+ 12 - 1
fs-service/src/main/java/com/fs/gtPush/service/impl/uniPush2ServiceImpl.java

@@ -82,6 +82,17 @@ public class uniPush2ServiceImpl implements uniPush2Service {
 
     }
 
+    /**
+     *
+     * @param userId 接收人id
+     * @param businessId 相关id
+     * @param purl 推送地址
+     * @param title
+     * @param content
+     * @param type 推送类型
+     * @param desType 推送类型详细类型
+     * @param imJsonString
+     */
     @Override
     public void pushIm(Long userId, Long businessId, String purl, String title, String content, Float type, Integer desType, String imJsonString) {
         try {
@@ -153,7 +164,7 @@ public class uniPush2ServiceImpl implements uniPush2Service {
             hw.put("/message/android/category", "WORK");
             hw.put("/message/android/notification/importance", "HIGH");
             hw.put("/message/android/notification/visibility", "PUBLIC");
-            hw.put("/message/android/notification/channel_id", "133892");
+            hw.put("/message/android/notification/channel_id", config.getHw_channel_id());
             android.put("HW", hw);
 
             // 鸿蒙

+ 5 - 0
fs-service/src/main/java/com/fs/his/domain/FsUser.java

@@ -240,6 +240,11 @@ public class FsUser extends BaseEntity
     @TableField(exist = false)
     private String nicknameExact;
 
+
+    // app注册时间
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date appCreateTime;
+
 //    /**
 //     * 搜索关键词-电话号码/会员id/会员昵称
 //     * **/

+ 2 - 1
fs-service/src/main/java/com/fs/his/enums/PushLogTypeEnum.java

@@ -17,7 +17,8 @@ public enum PushLogTypeEnum {
     ORDER_INTEGRAL(0.4f,"积分订单通知"),
     HEALTH(1f,"健康管理类通知"),
     MARKET(2f,"营销类通知"),
-    COURSE(3f,"课程类通知");
+    COURSE(3f,"课程类通知"),
+    UTOC(4f,"用户发给销售类通知");
 
 
 

+ 6 - 0
fs-service/src/main/java/com/fs/his/mapper/FsUserMapper.java

@@ -7,11 +7,13 @@ import java.util.Map;
 import com.fs.course.domain.FsUserWatchCourseStatistics;
 import com.fs.course.domain.FsUserWatchStatistics;
 import com.fs.course.param.CourseAnalysisParam;
+import com.fs.course.param.FsCourseWatchLogStatisticsListParam;
 import com.fs.course.vo.newfs.FsCourseAnalysisCountVO;
 import com.fs.his.domain.FsUser;
 import com.fs.his.dto.FindUsersByDTO;
 import com.fs.his.param.FindUserByParam;
 import com.fs.his.param.FsUserParam;
+import com.fs.his.vo.AppSalesCourseStatisticsVO;
 import com.fs.his.vo.FsUserVO;
 import com.fs.his.vo.FsUserExportListVO;
 import com.fs.his.vo.OptionsVO;
@@ -487,4 +489,8 @@ public interface FsUserMapper
      * @param userIds
      * **/
     List<FsUser> selectUserListByUserIds(@Param("userIds") List<Long> userIds);
+
+    List<AppSalesCourseStatisticsVO> selectAppSalesUserCountVO(FsCourseWatchLogStatisticsListParam param);
+
+    List<AppSalesCourseStatisticsVO> selectAppSalesNewUserCountVO(FsCourseWatchLogStatisticsListParam param);
 }

+ 47 - 15
fs-service/src/main/java/com/fs/his/service/impl/FsInquiryOrderMsgServiceImpl.java

@@ -27,6 +27,7 @@ import com.fs.his.domain.*;
 import com.fs.his.dto.FsInquiryOrderPatientDTO;
 import com.fs.his.dto.PayloadDTO;
 import com.fs.his.enums.PushLogDesTypeEnum;
+import com.fs.his.enums.PushLogTypeEnum;
 import com.fs.his.mapper.*;
 import com.fs.his.param.FsFollowReportParam;
 import com.fs.his.param.FsInquiryOrderMsgListDParam;
@@ -454,11 +455,13 @@ public class FsInquiryOrderMsgServiceImpl implements IFsInquiryOrderMsgService
 
             }
             String jsonStr = objectMapper.writeValueAsString(openImMsgCallBackVO);
+            PushLogTypeEnum pushLogTypeEnum = PushLogTypeEnum.HEALTH;
             if (msgContentType != null) {
                 if (to.startsWith("U")) {
                     a = to.replace("U", "");
                 } else if (to.startsWith("C")) {
                     a = to.replace("C", "");
+                    pushLogTypeEnum = PushLogTypeEnum.UTOC;
                 }
                 switch (msgContentType) {
                     case 1601:
@@ -483,7 +486,9 @@ public class FsInquiryOrderMsgServiceImpl implements IFsInquiryOrderMsgService
                         OpenImResponseDTOTest responseDTO1 = JSONUtil.toBean(result1, OpenImResponseDTOTest.class);
                         List<OpenIMServiceImpl.UserInfo> users = responseDTO1.getData().getUsersInfo();
 
-                        uniPush2Service.pushIm(Long.parseLong(a), 0l, "", users.get(0).getNickname(), "通话消息", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+
+//                        uniPush2Service.pushIm(Long.parseLong(a), 0l, "", users.get(0).getNickname(), "通话消息", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+                        uniPush2Service.pushIm(Long.parseLong(a), 0l, "", users.get(0).getNickname(), "通话消息", pushLogTypeEnum.getValue(), PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
 
                         break;
                     //普通消息
@@ -491,7 +496,8 @@ public class FsInquiryOrderMsgServiceImpl implements IFsInquiryOrderMsgService
                         type = 1;
                         jsonNode = objectMapper.readTree(content);
                         cont = jsonNode.get("content").asText();
-                        uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), cont, 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+//                        uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), cont, 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+                        uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), cont, pushLogTypeEnum.getValue(), PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
 
                         break;
                     //语音消息
@@ -519,7 +525,9 @@ public class FsInquiryOrderMsgServiceImpl implements IFsInquiryOrderMsgService
 ////                                deviceSetUpService.sendMp3(deviceSendParam);
 //                            }
 //                        }
-                        uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "语音消息", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+//                        uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "语音消息", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+                        uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "语音消息", pushLogTypeEnum.getValue(), PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+
                         break;
                     //图片消息
                     case 102:
@@ -536,7 +544,9 @@ public class FsInquiryOrderMsgServiceImpl implements IFsInquiryOrderMsgService
                         openImMsgCallBackVO.setContent("");
                         jsonStr = objectMapper.writeValueAsString(openImMsgCallBackVO);
                         type = 3;
-                        uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "图片消息", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+//                        uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "图片消息", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+                        uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "图片消息", pushLogTypeEnum.getValue(), PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+
                         break;
                     //视频消息
                     case 104:
@@ -553,7 +563,9 @@ public class FsInquiryOrderMsgServiceImpl implements IFsInquiryOrderMsgService
                         openImMsgCallBackVO.setContent("");
                         jsonStr = objectMapper.writeValueAsString(openImMsgCallBackVO);
                         type = 4;
-                        uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "视频消息", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+//                        uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "视频消息", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+                        uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "视频消息", pushLogTypeEnum.getValue(), PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+
                         break;
                     //文件消息
                     case 105:
@@ -562,7 +574,9 @@ public class FsInquiryOrderMsgServiceImpl implements IFsInquiryOrderMsgService
                         type = 4;
                         openImMsgCallBackVO.setContent("");
                         jsonStr = objectMapper.writeValueAsString(openImMsgCallBackVO);
-                        uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "文件消息", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+//                        uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "文件消息", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+                        uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "文件消息", pushLogTypeEnum.getValue(), PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+
                         break;
                     //自定义消息
                     case 110:
@@ -581,7 +595,9 @@ public class FsInquiryOrderMsgServiceImpl implements IFsInquiryOrderMsgService
                             type = 5;
                             user = fsUserMapper.selectFsUserByUserId(Long.parseLong(a));
                             if (StringUtils.isNotEmpty(user.getJpushId())) {
-                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "电子处方单", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+//                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "电子处方单", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "电子处方单", pushLogTypeEnum.getValue(), PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+
                             }
                             break;
                         } else if (data.equals("report")) {
@@ -594,7 +610,9 @@ public class FsInquiryOrderMsgServiceImpl implements IFsInquiryOrderMsgService
                             type = 6;
                             user = fsUserMapper.selectFsUserByUserId(Long.parseLong(a));
                             if (StringUtils.isNotEmpty(user.getJpushId())) {
-                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "问诊报告单", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+//                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "问诊报告单", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "问诊报告单", pushLogTypeEnum.getValue(), PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+
                             }
                             break;
                         } else if (data.equals("follow")) {
@@ -603,7 +621,9 @@ public class FsInquiryOrderMsgServiceImpl implements IFsInquiryOrderMsgService
                             //orderId = payload.get("extension").get("followId").asLong();
                             user = fsUserMapper.selectFsUserByUserId(Long.parseLong(a));
                             if (StringUtils.isNotEmpty(user.getJpushId())) {
-                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "随访单", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+//                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "随访单", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "随访单", pushLogTypeEnum.getValue(), PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+
                             }
                             break;
                         } else if (data.equals("drugReport")) {
@@ -612,7 +632,9 @@ public class FsInquiryOrderMsgServiceImpl implements IFsInquiryOrderMsgService
                             type = 8;
                             user = fsUserMapper.selectFsUserByUserId(Long.parseLong(a));
                             if (StringUtils.isNotEmpty(user.getJpushId())) {
-                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "用药报告单", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+//                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "用药报告单", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "用药报告单", pushLogTypeEnum.getValue(), PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+
                             }
                             break;
                         } else if (data.equals("package")) {
@@ -626,7 +648,9 @@ public class FsInquiryOrderMsgServiceImpl implements IFsInquiryOrderMsgService
                             openImMsgCallBackVO.setContent("");
                             jsonStr = objectMapper.writeValueAsString(openImMsgCallBackVO);
                             if (StringUtils.isNotEmpty(user.getJpushId())) {
-                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "套餐包", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+//                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "套餐包", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "套餐包", pushLogTypeEnum.getValue(), PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+
                             }
                             break;
                         } else if (data.equals("couponPackage")) {
@@ -637,7 +661,9 @@ public class FsInquiryOrderMsgServiceImpl implements IFsInquiryOrderMsgService
                             openImMsgCallBackVO.setContent("");
                             jsonStr = objectMapper.writeValueAsString(openImMsgCallBackVO);
                             if (StringUtils.isNotEmpty(user.getJpushId())) {
-                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "私域疗法券", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+//                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "私域疗法券", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "私域疗法券", pushLogTypeEnum.getValue(), PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+
                             }
                             break;
                         } else if (data.equals("inquirySelect")) {
@@ -648,7 +674,9 @@ public class FsInquiryOrderMsgServiceImpl implements IFsInquiryOrderMsgService
                             openImMsgCallBackVO.setContent("");
                             jsonStr = objectMapper.writeValueAsString(openImMsgCallBackVO);
                             if (StringUtils.isNotEmpty(user.getJpushId())) {
-                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "会诊", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+//                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "会诊", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "会诊", pushLogTypeEnum.getValue(), PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+
                             }
                             break;
                         } else if (data.equals("startInquiry") || data.equals("finishInquiry")) {
@@ -656,7 +684,9 @@ public class FsInquiryOrderMsgServiceImpl implements IFsInquiryOrderMsgService
                             type = 1;
                             user = fsUserMapper.selectFsUserByUserId(Long.parseLong(a));
                             if (StringUtils.isNotEmpty(user.getJpushId())) {
-                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "接诊通知", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+//                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "接诊通知", 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), "接诊通知", pushLogTypeEnum.getValue(), PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+
                             }
                             break;
                         } else if (data.equals("course")) {
@@ -664,7 +694,9 @@ public class FsInquiryOrderMsgServiceImpl implements IFsInquiryOrderMsgService
                             type = 1;
                             user = fsUserMapper.selectFsUserByUserId(Long.parseLong(a));
                             if (StringUtils.isNotEmpty(user.getJpushId())) {
-                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), cont, 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+//                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), cont, 1f, PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+                                uniPush2Service.pushIm(Long.parseLong(a), 0l, "", openImMsgCallBackVO.getSenderNickname(), cont, pushLogTypeEnum.getValue(), PushLogDesTypeEnum.IM_MSG.getValue(), jsonStr);
+
                             }
                             break;
                         }

+ 87 - 0
fs-service/src/main/java/com/fs/his/vo/AppCourseReportVO.java

@@ -0,0 +1,87 @@
+package com.fs.his.vo;
+
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+@Data
+public class AppCourseReportVO {
+    /** 公司id */
+    private  Long companyId;
+
+    /** 公司名称 */
+    @Excel(name = "销售公司")
+    private String companyName;
+
+    /**
+     * app新增注册人数
+     */
+    @Excel(name = "新增注册人数")
+    private  Integer appUserCount;
+
+    /** 活跃 APP 会员数 */
+    @Excel(name = "APP活跃人数")
+    private Integer activeAppUserCount;
+
+
+    /**
+     * 待看课人数
+     */
+    @Excel(name = "私域课待看课人次")
+    private  Integer pendingCount;
+
+    /**
+     * 看课中人数
+     */
+    @Excel(name = "私域课看课中人次")
+    private  Integer watchingCount;
+
+    /**
+     * 看课中人数
+     */
+    @Excel(name = "私域课看课中断人次")
+    private  Integer stopCount;
+
+
+    /**
+     * 完课人数
+     */
+    @Excel(name = "私域课完课人次")
+    private  Integer finishedCount;
+
+
+    /**
+     * 看课率
+     */
+    @Excel(name = "看课率")
+    private BigDecimal watchRate;
+
+    /**
+     * 答题人数
+     */
+    @Excel(name = "答题人次")
+    private  Integer answerUserCount;
+
+    /**
+     * 红包领取数
+     */
+    @Excel(name = "红包领取人次")
+    private  Integer packetUserCount;
+
+    /**
+     * 红包金额
+     */
+    @Excel(name = "红包金额")
+    private  BigDecimal packetAmount;
+
+    /**
+     * 总人数
+     */
+    private  Integer  accessCount;
+
+    /**
+     * 日志id
+     */
+    private  Long logId;
+}

+ 86 - 0
fs-service/src/main/java/com/fs/his/vo/AppSalesCourseStatisticsVO.java

@@ -0,0 +1,86 @@
+package com.fs.his.vo;
+
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * @description: TODO
+ * @author: Xgb
+ * @createDate: 2026/3/23
+ * @version: 1.0
+ */
+@Data
+public class AppSalesCourseStatisticsVO {
+        /** 销售名称 */
+        @Excel(name = "销售名称")
+        private String salesName;
+
+        // APP 会员数
+        private Long appUserCount;
+
+        // 新注册 APP 会员数
+        private Long newAppUserCount;
+
+
+        /** 发课时间 */
+        @Excel(name = "发课时间", dateFormat = "yyyy-MM-dd")
+        private String sendTime;
+
+        /** 课程名称 */
+        @Excel(name = "课程名称")
+        private String courseName;
+
+        /** 课程小节 */
+        @Excel(name = "课程小节")
+        private String videoTitle;
+
+        /** 完课数 */
+        @Excel(name = "完课数")
+        private Integer finishedCount;
+
+        /** 完课率 */
+        @Excel(name = "完课率")
+        private BigDecimal completionRate;
+
+        /** 未看课数 */
+        @Excel(name = "未看课数")
+        private Integer notWatchedCount;
+
+        /** 中断数 */
+        @Excel(name = "中断数")
+        private Integer interruptCount;
+
+        /** 看课中数 */
+        @Excel(name = "看课中数")
+        private Integer watchingCount;
+
+        /** 答题数 */
+        @Excel(name = "答题数")
+        private Integer answeredCount;
+
+        /** 答题数 */
+        @Excel(name = "答题数")
+        private Integer correctCount;
+
+
+        /** 完课率 */
+        @Excel(name = "完课率")
+        private BigDecimal correctRate;
+
+        @Excel(name = "红包个数")
+        private Integer redPacketCount;
+
+        /** 红包金额 */
+        @Excel(name = "红包金额")
+        private BigDecimal redPacketAmount;
+
+        private Long companyUserId;
+
+        private Long courseId;
+
+        private Long videoId;
+
+    }
+

+ 89 - 0
fs-service/src/main/java/com/fs/his/vo/AppSalesWatchLogReportVO.java

@@ -0,0 +1,89 @@
+package com.fs.his.vo;
+
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+@Data
+public class AppSalesWatchLogReportVO {
+
+    /** 销售ID */
+    private Long salesId;
+
+    /** 销售名称 */
+    @Excel(name = "销售")
+    private String salesName;
+
+    /** APP会员数 */
+    @Excel(name = "APP会员数")
+    private Integer appUserCount;
+
+    /** 新注册APP会员数 */
+    @Excel(name = "新注册APP会员数")
+    private Integer newAppUserCount;
+
+    /** 所属销售部门 */
+    @Excel(name = "所属销售部门")
+    private String salesDept;
+
+    /** 所属销售公司 */
+    @Excel(name = "所属销售公司")
+    private String salesCompany;
+
+    /** 训练营 */
+    @Excel(name = "训练营")
+    private String trainingCampName;
+
+    /** 营期 */
+    @Excel(name = "营期")
+    private String periodName;
+
+    /** 课程小节 */
+    @Excel(name = "课程小节")
+    private String videoTitle;
+
+    /** 完课数 */
+    @Excel(name = "完课数")
+    private Integer finishedCount;
+
+    /** 未完课数 */
+    @Excel(name = "未完课数")
+    private Integer unfinishedCount;
+
+    /** 完课率 */
+    @Excel(name = "完课率")
+    private BigDecimal completionRate;
+
+    /** 未看数 */
+    @Excel(name = "未看数")
+    private Integer notWatchedCount;
+
+    /** 未答题数 */
+    @Excel(name = "未答题数")
+    private Integer notAnsweredCount;
+
+    /** 红包金额 */
+    @Excel(name = "红包金额")
+    private BigDecimal redPacketAmount;
+
+    /** 历史疗法订单数 */
+    @Excel(name = "历史疗法订单数")
+    private Integer historyOrderCount;
+
+    /** 销售数(部门维度特有) */
+    @Excel(name = "销售数")
+    private Integer salesCount;
+
+    /** 营期ID */
+    private Long periodId;
+
+    /** 视频ID */
+    private Long videoId;
+
+    /** 部门ID */
+    private Long deptId;
+
+    /** 公司ID */
+    private Long companyId;
+}

+ 146 - 0
fs-service/src/main/java/com/fs/his/vo/AppWatchLogReportVO.java

@@ -0,0 +1,146 @@
+package com.fs.his.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+public class AppWatchLogReportVO {
+
+    @Excel(name = "会员id")
+    private Long userId;
+    /**
+     * 昵称
+     */
+    @Excel(name = "会员昵称")
+    private String nickName;
+
+
+    /**
+     * app会员数
+     */
+    @Excel(name = "app会员数")
+    private  Integer AppUserCount;
+
+    /**
+     * 新注册app会员数
+     */
+    @Excel(name = "新注册app会员数")
+    private  Integer AppNewUser;
+
+    /**
+     * 登录渠道
+     */
+    @Excel(name = "登录渠道")
+    private  String loginChannel;
+
+    /**
+     * 销售数
+     */
+    @Excel(name = "销售数")
+    private  Integer salesCount;
+
+
+    /**
+     * 所属销售数
+     */
+    @Excel(name = "所属销售")
+    private  String salesName;
+
+    /**
+     * 所属销售部门
+     */
+    @Excel(name = "销售部门")
+    private  String salesDept;
+
+    /**
+     * 所属销售公司
+     */
+    @Excel(name = "所属销售公司")
+    private  String salesCompany;
+
+    /**
+     * 培训营名称
+     */
+    @Excel(name = "训练营")
+    private String trainingCampName;
+
+    /**
+     * 营期
+     */
+    @Excel(name = "营期")
+    private  String periodName;
+
+    /**
+     * 视频名称
+     */
+    @Excel(name = "小节名称")
+    private  String videoTitle;
+
+
+    /**
+     * 公开课播放时长
+     */
+    @Excel(name = "公开课播放时长")
+    private  String publicCourseDuration;
+
+    /**
+     * 私欲看课状态
+     */
+    @Excel(name = "私域课看课状态")
+    private  String privateWatchStatus;
+
+    /**
+     * 私欲课播放时长
+      */
+    @Excel(name = "私域课播放时长")
+    private  String privateWatchDuration;
+
+    /**
+     * 观看完成时间
+     */
+    @Excel(name = "完课时间",dateFormat = "yyyy-MM-dd")
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    private Date finishTime;
+
+    /**
+     * 回答状态
+     */
+    @Excel(name = "答题状态")
+    private  String answerStatus;
+
+    /**
+     * 领取红包金额
+     */
+    @Excel(name = "红包金额")
+    private BigDecimal redPacketAmount;
+
+    /**
+     * 历史订单数
+     */
+    @Excel(name = "历史疗法订单数")
+    private  Integer historyOrderCount;
+
+
+    /**
+     * 营期id
+     */
+    private  Long periodId;
+
+    /**
+     * 视频id
+     */
+    private  Long videoId;
+
+    /**
+     * 观看记录id
+     */
+    private  Long logId;
+
+    private  Long deptId;
+
+
+}

+ 177 - 0
fs-service/src/main/java/com/fs/his/vo/WatchLogReportVO.java

@@ -0,0 +1,177 @@
+package com.fs.his.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+public class WatchLogReportVO {
+
+    @Excel(name = "会员id")
+    private Long userId;
+    /**
+     * 昵称
+     */
+    @Excel(name = "会员昵称")
+    private String nickName;
+
+    /**
+     * 会员数
+     */
+    @Excel(name = "会员数")
+    private  Integer userCount;
+
+
+    /**
+     * 销售数
+     */
+    @Excel(name = "销售数")
+    private  Integer salesCount;
+
+    /**
+     * 所属销售数
+     */
+    @Excel(name = "所属销售")
+    private  String salesName;
+
+    /**
+     * 所属销售部门
+     */
+    @Excel(name = "销售部门")
+    private  String salesDept;
+
+    /**
+     * 所属销售公司
+     */
+    @Excel(name = "所属销售公司")
+    private  String salesCompany;
+
+    /**
+     * 在线会员数
+     */
+    @Excel(name = "当前会员线上数")
+    private  Integer onlineUserCount;
+
+    /**
+     * 培训营名称
+     */
+    @Excel(name = "训练营")
+    private String trainingCampName;
+
+    /**
+     * 营期
+     */
+    @Excel(name = "营期")
+    private  String periodName;
+
+    /**
+     * 视频名称
+     */
+    @Excel(name = "小节名称")
+    private  String videoTitle;
+
+    /**
+     * 观看状态
+     */
+    @Excel(name = "看课状态")
+    private  String watchStatus;
+
+    /**
+     * 观看时长
+     */
+    @Excel(name = "观看时长")
+    private  String duration;
+
+    /**
+     * 观看完成数
+     */
+    @Excel(name = "完课数")
+    private  Integer finishedCount;
+
+    /**
+     * 观看未完成数
+     */
+    @Excel(name = "未完课")
+    private  Integer unfinishedCount;
+
+    /**
+     * 观看完成率
+     */
+    @Excel(name = "完课率")
+    private BigDecimal completionRate;
+
+    /**
+     * 看课时间
+     */
+    @Excel(name = "看课时间",dateFormat = "yyyy-MM-dd")
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    private Date courseTime;
+
+    /**
+     * 观看完成时间
+     */
+    @Excel(name = "完课时间",dateFormat = "yyyy-MM-dd")
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    private  Date  finishTime;
+
+    /**
+     * 未看课数
+     */
+    @Excel(name = "未看数")
+    private  Integer notWatchedCount;
+
+    /**
+     * 未回答数
+     */
+    @Excel(name = "未答题人数")
+    private  Integer notAnsweredCount;
+
+    /**
+     * 回答状态
+     */
+    @Excel(name = "答题状态")
+    private  String answerStatus;
+
+    /**
+     * 领取红包金额
+     */
+    @Excel(name = "红包金额")
+    private  BigDecimal redPacketAmount;
+
+    /**
+     * 历史订单数
+     */
+    @Excel(name = "历史疗法订单数")
+    private  Integer historyOrderCount;
+
+    /**
+     * 销售id
+     */
+    private  Long companyUserId;
+
+    /**
+     * 总观看人数
+     */
+    private  Long totalLogCount;
+
+    /**
+     * 营期id
+     */
+    private  Long periodId;
+
+    /**
+     * 视频id
+     */
+    private  Long videoId;
+
+    /**
+     * 观看记录id
+     */
+    private  Long logId;
+
+    private  Long deptId;
+
+}

+ 2 - 1
fs-service/src/main/java/com/fs/im/dto/OpenImBatchMsgDTO.java

@@ -28,7 +28,8 @@ public class OpenImBatchMsgDTO implements Serializable {
     private OfflinePushInfo offlinePushInfo;
     private String ex;
     private Boolean isSendAll; //是否发送给全部人
-
+    // 发送失败次数 重试三次 失败后不再发送
+    private Integer count = 0;
 
     @Data
     public static class Content implements Serializable {

+ 8 - 0
fs-service/src/main/java/com/fs/im/mapper/FsImMsgSendLogMapper.java

@@ -4,6 +4,9 @@ import java.util.List;
 import java.util.Map;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.app.service.param.FsImMsgSendLogRequest;
+import com.fs.app.service.param.FsImMsgSendLogResponse;
+import com.fs.app.service.param.FsImMsgSendLogStatisticsResponse;
 import com.fs.course.vo.newfs.FsImSendLogVO;
 import com.fs.im.domain.FsImMsgSendLog;
 import org.apache.ibatis.annotations.Param;
@@ -72,4 +75,9 @@ public interface FsImMsgSendLogMapper extends BaseMapper<FsImMsgSendLog>{
 
     List<FsImMsgSendLog> selectSendLogListByDetailId(@Param("params") Map<String, Object> params);
 
+
+
+    List<FsImMsgSendLogResponse> selectFsImMsgSendLogInfoList(FsImMsgSendLogRequest request);
+
+    FsImMsgSendLogStatisticsResponse getFsImMsgSendStatistics(FsImMsgSendLogRequest request);
 }

+ 12 - 0
fs-service/src/main/java/com/fs/im/service/IFsImMsgSendLogService.java

@@ -4,6 +4,9 @@ import java.util.List;
 import java.util.Map;
 
 import com.baomidou.mybatisplus.extension.service.IService;
+import com.fs.app.service.param.FsImMsgSendLogRequest;
+import com.fs.app.service.param.FsImMsgSendLogResponse;
+import com.fs.common.core.domain.R;
 import com.fs.common.core.domain.ResponseResult;
 import com.fs.course.vo.newfs.FsImSendLogVO;
 import com.fs.im.domain.FsImMsgSendLog;
@@ -91,4 +94,13 @@ public interface IFsImMsgSendLogService extends IService<FsImMsgSendLog>{
      */
     FsImMsgSendLogVO selectFsImMsgSendLogDetail(Long logId);
 
+    /**
+     * 查询 OpenIM 消息发送记录信息列表
+     * @param request 请求参数
+     * @return 消息发送记录列表
+     */
+    List<FsImMsgSendLogResponse> selectFsImMsgSendLogInfoList(FsImMsgSendLogRequest request);
+
+    R getFsImMsgSendStatistics(FsImMsgSendLogRequest request);
+
 }

+ 9 - 0
fs-service/src/main/java/com/fs/im/service/OpenIMService.java

@@ -55,6 +55,15 @@ public interface OpenIMService {
      */
     OpenImResponseDTO batchSendCourse(BatchSendCourseDTO batchSendCourseDTO) throws JsonProcessingException;
 
+    /**
+     * 会员批量发课
+     * @param batchSendCourseDTO 每次发送100人信息
+     * @return
+     * @throws JsonProcessingException
+     */
+    OpenImResponseDTO batchSendCourseLimit(BatchSendCourseDTO batchSendCourseDTO) throws JsonProcessingException;
+
+
     /**
      * 会员批量发课-任务
      * @param batchSendCourseDTO

+ 25 - 2
fs-service/src/main/java/com/fs/im/service/impl/FsImMsgSendLogServiceImpl.java

@@ -1,10 +1,15 @@
 package com.fs.im.service.impl;
 
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.fs.app.service.param.FsImMsgSendLogRequest;
+import com.fs.app.service.param.FsImMsgSendLogResponse;
+import com.fs.app.service.param.FsImMsgSendLogStatisticsResponse;
+import com.fs.common.core.domain.R;
 import com.fs.common.core.domain.ResponseResult;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.utils.DateUtils;
@@ -155,7 +160,8 @@ public class FsImMsgSendLogServiceImpl extends ServiceImpl<FsImMsgSendLogMapper,
             if(sendCourseMap != null && !sendCourseMap.isEmpty()){
                 // 组合key
                 if(fsImMsgSendLog.getPlanSendTime() != null) {
-                    String key = fsImMsgSendLog.getCourseId() + ":" + fsImMsgSendLog.getVideoId() + ":" + fsImMsgSendLog.getPlanSendTime().getTime();
+//                    String key = fsImMsgSendLog.getCourseId() + ":" + fsImMsgSendLog.getVideoId() + ":" + fsImMsgSendLog.getPlanSendTime().getTime();
+                    String key = fsImMsgSendLog.getCourseId() + ":" + fsImMsgSendLog.getVideoId() + ":" + fsImMsgSendLog.getPlanSendTime().getTime()+ ":"+fsImMsgSendLog.getLogId();
                     redisTemplate.opsForHash().delete(sendCourseRedisKey, key);
                 }
             }
@@ -166,7 +172,9 @@ public class FsImMsgSendLogServiceImpl extends ServiceImpl<FsImMsgSendLogMapper,
                 // 组合key
                 for (FsImMsgSendLog imMsgSendLog : fsImMsgSendLogs) {
                     if(imMsgSendLog.getPlanSendTime() != null && imMsgSendLog.getMsgType() == 2) {
-                        String key = imMsgSendLog.getCourseId() + ":" + imMsgSendLog.getVideoId() + ":" + imMsgSendLog.getPlanSendTime().getTime();
+//                        String key = imMsgSendLog.getCourseId() + ":" + imMsgSendLog.getVideoId() + ":" + imMsgSendLog.getPlanSendTime().getTime();
+                        String key = imMsgSendLog.getCourseId() + ":" + imMsgSendLog.getVideoId() + ":" + imMsgSendLog.getPlanSendTime().getTime()+ ":"+fsImMsgSendLog.getLogId();
+
                         redisTemplate.opsForHash().delete(urgeCourseRedisKey, key);
                     }
                 }
@@ -190,4 +198,19 @@ public class FsImMsgSendLogServiceImpl extends ServiceImpl<FsImMsgSendLogMapper,
         fsImMsgSendLogVO.setDetailList(fsImMsgSendDetails);
         return fsImMsgSendLogVO;
     }
+
+    /**
+     * 查询 OpenIM 消息发送记录信息列表
+     */
+    @Override
+    public List<FsImMsgSendLogResponse> selectFsImMsgSendLogInfoList(FsImMsgSendLogRequest request) {
+        return baseMapper.selectFsImMsgSendLogInfoList(request);
+    }
+
+    @Override
+    public R getFsImMsgSendStatistics(FsImMsgSendLogRequest request) {
+        FsImMsgSendLogStatisticsResponse data=baseMapper.getFsImMsgSendStatistics(request);
+
+        return R.ok().put("data",data);
+    }
 }

+ 153 - 0
fs-service/src/main/java/com/fs/im/service/impl/OpenIMServiceImpl.java

@@ -1165,6 +1165,157 @@ public class OpenIMServiceImpl implements OpenIMService {
         return openImResponseDTO;
     }
 
+    @Override
+    @Async
+    public OpenImResponseDTO batchSendCourseLimit(BatchSendCourseDTO batchSendCourseDTO) {
+        ObjectMapper objectMapper = new ObjectMapper();
+        objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); // 忽略 null 字段
+
+        //获取需要发送的人
+        List<String> userIds = this.getRecvIds(batchSendCourseDTO);
+
+        //注册和添加好友
+        for (String userId : userIds) {
+            String uId = userId.substring(1);
+            checkAndImportFriendByDianBo(batchSendCourseDTO.getCompanyUserId(), uId,null,false);
+        }
+
+        //组装发课消息数据
+        FsUserCourse fsUserCourse = fsUserCourseMapper.selectFsUserCourseByCourseId(batchSendCourseDTO.getCourseId());
+        Long project = fsUserCourse != null ? fsUserCourse.getProject() : null;
+        long planSendTimeStamp;
+        if(ObjectUtils.isNotEmpty(batchSendCourseDTO.getSendType())&&batchSendCourseDTO.getSendType() == 1 && batchSendCourseDTO.getSendTime() != null && batchSendCourseDTO.getSendTime().compareTo(new Date()) > 0){
+            planSendTimeStamp = batchSendCourseDTO.getSendTime().getTime();
+        } else {
+            planSendTimeStamp = System.currentTimeMillis();
+        }
+
+        String courseUrl = fsUserCourse != null ? fsUserCourse.getImgUrl() : null;
+
+        int sendType;
+        if(ObjectUtils.isNotEmpty(batchSendCourseDTO.getSendType())&&batchSendCourseDTO.getSendType() == 1 && batchSendCourseDTO.getSendTime() != null && batchSendCourseDTO.getSendTime().compareTo(new Date()) > 0) {
+            sendType = 1; //定时
+        } else {
+            sendType = 2; //实时
+        }
+
+        // 批量发送,每次最多 100 人
+        int BATCH_SIZE = 100;
+        OpenImResponseDTO finalResponseDTO = new OpenImResponseDTO();
+        int totalSent = 0;
+        int successCount = 0;
+        int failCount = 0;
+
+        // 分批处理
+        for (int i = 0; i < userIds.size(); i += BATCH_SIZE) {
+            int end = Math.min(i + BATCH_SIZE, userIds.size());
+            List<String> batchUserIds = userIds.subList(i, end);
+
+            try {
+                // 为每个批次创建独立的消息和记录
+                OpenImBatchMsgDTO openImBatchMsgDTO = makeOpenImBatchMsgDTO(batchSendCourseDTO, courseUrl, objectMapper, batchUserIds, planSendTimeStamp, "发课");
+
+                // 创建消息发送记录(每个批次独立的 logId)
+                String sendUnionId = UUID.randomUUID().toString();
+                List<FsImMsgSendDetail> imMsgSendDetailList = createImMsgSendLog("发课", batchSendCourseDTO, planSendTimeStamp, sendType, batchUserIds, sendUnionId);
+
+                OpenImResponseDTO responseDTO;
+                if(sendType == 1) {
+                    // 定时发送 - 每个批次独立缓存
+                    String redisKey = "openIm:batchSendMsg:sendCourse";
+                    Map<String, Object> redisMap = redisCache.getCacheMap(redisKey);
+                    if (redisMap == null) {
+                        redisMap = new HashMap<>();
+                    }
+
+                    BatchSendCourseAllDTO batchSendCourseAllDTO = new BatchSendCourseAllDTO();
+                    batchSendCourseAllDTO.setBatchSendCourseDTO(batchSendCourseDTO)
+                            .setOpenImBatchMsgDTO(openImBatchMsgDTO)
+                            .setProject(project)
+                            .setImMsgSendDetailList(imMsgSendDetailList);
+
+                    // 使用唯一的 key:课程 ID+ 视频 ID+ 时间戳  +logId
+                    String batchKey = batchSendCourseDTO.getCourseId() + ":" +
+                            batchSendCourseDTO.getVideoId() + ":" +
+                            batchSendCourseDTO.getSendTime().getTime() + ":" +
+                            imMsgSendDetailList.get(0).getLogId();
+
+                    redisMap.put(batchKey, batchSendCourseAllDTO);
+                    redisCache.setCacheMap(redisKey, redisMap);
+
+                    responseDTO = new OpenImResponseDTO();
+                    responseDTO.setErrCode(0);
+                    responseDTO.setErrMsg("计划发送创建成功,待消息发送");
+                    totalSent += batchUserIds.size();
+                    successCount += batchUserIds.size();
+
+                } else {
+                    // 实时发送 - 立即执行
+                    responseDTO = this.batchSendCourseTask(batchSendCourseDTO, openImBatchMsgDTO, project, imMsgSendDetailList);
+                    totalSent += batchUserIds.size();
+                    if (responseDTO != null && responseDTO.getErrCode() == 0) {
+                        successCount += batchUserIds.size();
+                    } else {
+                        failCount += batchUserIds.size();
+                        log.error("批次 {}/{} 发送失败:{}", i, end, responseDTO != null ? responseDTO.getErrMsg() : "unknown error");
+                    }
+
+                    // 每批之间休眠 100ms,避免请求过快
+                    try {
+                        Thread.sleep(100);
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                        break;
+                    }
+                }
+
+                // 保存最后一个批次的响应
+                finalResponseDTO = responseDTO;
+
+                //是否催课
+                if(batchSendCourseDTO.getIsUrgeCourse()){
+                    // 组装催课消息数据
+                    OpenImBatchMsgDTO openImBatchUrgeCourse = makeOpenImBatchMsgDTO(batchSendCourseDTO, courseUrl, objectMapper, batchUserIds, planSendTimeStamp, "催课");
+
+                    // 催课使用与发课相同的批次(batchUserIds),不需要再次拆分
+                    List<FsImMsgSendDetail> imMsgSendDetailUrgeList = createImMsgSendLog("催课", batchSendCourseDTO, planSendTimeStamp, 1, batchUserIds, sendUnionId);
+
+                    // 定时催课 - 缓存到 Redis
+                    String redisKey = "openIm:batchSendMsg:urgeCourse";
+                    Map<String, Object> redisMap = redisCache.getCacheMap(redisKey);
+                    if (redisMap == null) {
+                        redisMap = new HashMap<>();
+                    }
+
+                    BatchSendCourseAllDTO batchSendCourseAllDTO = new BatchSendCourseAllDTO();
+                    batchSendCourseAllDTO.setOpenImBatchMsgDTO(openImBatchUrgeCourse)
+                            .setImMsgSendDetailList(imMsgSendDetailUrgeList);
+
+                    // 使用唯一的 key:课程 ID+ 视频 ID+ 时间戳+logId
+                    String batchKey = batchSendCourseDTO.getCourseId() + ":" +
+                            batchSendCourseDTO.getVideoId() + ":" +
+                            batchSendCourseDTO.getUrgeTime().getTime() + ":" +
+                            imMsgSendDetailUrgeList.get(0).getLogId();
+
+                    redisMap.put(batchKey, batchSendCourseAllDTO);
+                    redisCache.setCacheMap(redisKey, redisMap);
+
+                }
+
+            } catch (Exception e) {
+                failCount += batchUserIds.size();
+                log.error("批次 {}/{} 发送异常:{}", i, end, e.getMessage(), e);
+            }
+        }
+
+        // 设置汇总结果
+        if (sendType == 2) { // 只有实时发送需要返回统计
+            finalResponseDTO.setErrMsg(String.format("发送完成:总数=%d, 成功=%d, 失败=%d", totalSent, successCount, failCount));
+        }
+
+        return finalResponseDTO;
+    }
+
     private OpenImBatchMsgDTO makeOpenImBatchMsgDTO(BatchSendCourseDTO batchSendCourseDTO, String courseUrl, ObjectMapper objectMapper, List<String> userIds, long planSendTimeStamp, String logType) throws JsonProcessingException {
          PayloadDTO.Extension extension = new PayloadDTO.Extension();
         OpenImBatchMsgDTO openImBatchMsgDTO = new OpenImBatchMsgDTO();
@@ -1402,6 +1553,8 @@ public class OpenIMServiceImpl implements OpenIMService {
             fsCourseWatchLog.setLogType(3);
             fsCourseWatchLog.setProject(project);
             fsCourseWatchLog.setImMsgSendDetailId(map.get(Long.parseLong(userId)).getLogDetailId());
+            fsCourseWatchLog.setWatchType(1); // app
+            fsCourseWatchLog.setLinkId(batchSendCourseDTO.getLinkId());
             watchLogsInsertList.add(fsCourseWatchLog);
         }
         courseWatchLogMapper.insertFsCourseWatchLogBatch(watchLogsInsertList);

+ 4 - 0
fs-service/src/main/resources/application-config-druid-hsyy.yml

@@ -46,6 +46,10 @@ wx:
         secret: 331032067e39dbd36f260b87e900716d # 公众号的appsecret
         token: PPKOdAlCoMO # 接口配置里的Token值
         aesKey: Eswa6VjwtVcw03qZy6Wllgrv5aytIA1SZPEU0kU2 # 接口配置里的EncodingAESKey值
+  # 开放平台app微信授权配置
+  open:
+    app-id: wxe428b242e881f866
+    secret: a4b2b87f814062bc72b43037de5f6b6a
 aifabu:  #爱链接
   appKey: 7b471be905ab17ef358c610dd117601d008
 watch:

+ 25 - 0
fs-service/src/main/resources/mapper/course/FsCourseAnswerLogsMapper.xml

@@ -214,4 +214,29 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </if>
     </select>
 
+    <select id="selectAppSalesAnswerStatisticsVO" resultType="com.fs.his.vo.AppSalesCourseStatisticsVO">
+        select cal.company_user_id,cal.course_id,cal.video_id,
+        count(cal.log_id) AS answeredCount,
+        SUM(CASE WHEN cal.is_right = 1 THEN 1 ELSE 0 END)  AS  correctCount
+        from fs_course_answer_logs cal
+        where cal.watch_type = 1
+        <if test="companyId != null ">
+            and cal.company_id = #{companyId}
+        </if>
+        <if test="companyUserId != null ">
+            and cal.company_user_id = #{companyUserId}
+        </if>
+        <if test="courseId != null ">
+            and cal.course_id = #{courseId}
+        </if>
+        <if test="videoId != null ">
+            and cal.video_id = #{videoId}
+        </if>
+        <if test="startDate != null and startDate != '' and endDate != null and endDate != ''">
+            and cal.create_time &gt;= #{startDate} and cal.create_time &lt;= #{endDate}
+        </if>
+
+        group by cal.company_user_id,cal.course_id,cal.video_id
+    </select>
+
 </mapper>

+ 6 - 0
fs-service/src/main/resources/mapper/course/FsCourseLinkMapper.xml

@@ -199,4 +199,10 @@
             #{linkId}
         </foreach>
     </delete>
+
+    <select id="selectFsCourseLinkByVoideIdAndCompanyUserId" resultType="com.fs.course.domain.FsCourseLink">
+        <include refid="selectFsCourseLinkVo"/>
+        where  company_user_id = #{companyUserId} and video_id = #{videoId} order by create_time desc limit 1
+    </select>
+
 </mapper>

+ 25 - 0
fs-service/src/main/resources/mapper/course/FsCourseRedPacketLogMapper.xml

@@ -301,5 +301,30 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         group by f1.qw_user_id,f1.video_id,f1.course_id,date_format(f1.create_time,'%Y-%m-%d')
     </select>
 
+    <select id="selectAppSalesRedPacketStatisticsVO" resultType="com.fs.his.vo.AppSalesCourseStatisticsVO">
+        select  rpl.company_user_id,rpl.course_id,rpl.video_id,
+        sum(rpl.log_id) as redPacketCount,
+        sum(rpl.amount) as redPacketAmount
+        from fs_course_red_packet_log rpl
+        where rpl.watch_type = 1
+        <if test="companyId != null ">
+            and rpl.company_id = #{companyId}
+        </if>
+        <if test="companyUserId != null ">
+            and rpl.company_user_id = #{companyUserId}
+        </if>
+        <if test="courseId != null ">
+            and rpl.course_id = #{courseId}
+        </if>
+        <if test="videoId != null ">
+            and rpl.video_id = #{videoId}
+        </if>
+        <if test="startDate != null and startDate != '' and endDate != null and endDate != ''">
+            and rpl.create_time &gt;= #{startDate} and rpl.create_time &lt;= #{endDate}
+        </if>
+
+        group by rpl.company_user_id,rpl.course_id,rpl.video_id
+    </select>
+
 
 </mapper>

+ 362 - 3
fs-service/src/main/resources/mapper/course/FsCourseWatchLogMapper.xml

@@ -509,7 +509,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
                 project,
                 period_id,
                 im_msg_send_detail_id,
-                watch_type
+                watch_type,
+                link_id
                 )
                 VALUES
                 <foreach collection="watchLogs" item="log" separator=",">
@@ -532,12 +533,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
                     #{log.project},
                     #{log.periodId},
                     #{log.imMsgSendDetailId},
-                    #{log.watchType}
+                    #{log.watchType},
+                    #{log.linkId}
                     )
                 </foreach>
                 ON DUPLICATE KEY UPDATE
                 update_time = NOW(),
-                im_msg_send_detail_id = VALUES(im_msg_send_detail_id)
+                im_msg_send_detail_id = VALUES(im_msg_send_detail_id),
+                link_id = VALUES(link_id)
             </insert>
 
 
@@ -1587,4 +1590,360 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         pd.period_id
         ) a
     </select>
+
+    <!-- 记录类型 1看课中 2完课 3待看课 4看课中断   -->
+    <select id="selectAppSalesCourseStatisticsVO" resultType="com.fs.his.vo.AppSalesCourseStatisticsVO">
+        SELECT l.company_user_id,l.course_id,l.video_id,
+        cu.nick_name as salesName,fuc.course_name as courseName,fuv.title as videoTitle,
+        SUM(CASE WHEN l.log_type = 2 THEN 1 ELSE 0 END) AS finishedCount,
+        SUM(CASE WHEN l.log_type = 3 THEN 1 ELSE 0 END) AS notWatchedCount,
+        SUM(CASE WHEN l.log_type = 4 THEN 1 ELSE 0 END) AS interruptCount,
+        SUM(CASE WHEN l.log_type = 1 THEN 1 ELSE 0 END) AS watchingCount
+        from fs_course_watch_log l
+        left join company_user cu on l.company_user_id=cu.user_id
+        left join fs_user_course fuc on l.course_id=fuc.course_id
+        left join fs_user_course_video fuv on l.video_id=fuv.video_id
+        where send_type = 1 and watch_type =1
+        <if test="companyId != null ">
+            and l.company_id = #{companyId}
+        </if>
+        <if test="companyUserId != null ">
+            and l.company_user_id = #{companyUserId}
+        </if>
+        <if test="courseId != null ">
+            and l.course_id = #{courseId}
+        </if>
+        <if test="videoId != null ">
+            and l.video_id = #{videoId}
+        </if>
+        <if test="startDate != null and startDate != '' and endDate != null and endDate != ''">
+            and l.create_time &gt;= #{startDate} and l.create_time &lt;= #{endDate}
+        </if>
+        group by l.company_user_id,l.course_id,l.video_id
+    </select>
+
+    <select id="selectAppUserBaseData" resultType="com.fs.his.vo.AppWatchLogReportVO">
+        SELECT
+        log.user_id userId,
+        u.nick_name AS nickName,
+        u.source loginChannel,
+        cu.nick_name AS salesName,
+        c.company_name AS salesCompany,
+        cd.dept_name AS salesDept,
+        log.period_id periodId,
+        log.video_id videoId,
+        log.log_id logId,
+        log.create_time courseTime,
+        log.finish_time finishTime,
+        log.duration privateWatchDuration,
+        log.log_type privateWatchStatus,
+        cv.title AS videoTitle
+        FROM
+        fs_course_watch_log log
+        LEFT JOIN fs_user u ON u.user_id = log.user_id
+        LEFT JOIN fs_user_company_user cuu ON cuu.user_id = u.user_id
+        LEFT JOIN company_user cu ON cuu.company_user_id = cu.user_id
+        LEFT JOIN company c ON log.company_id = c.company_id
+        LEFT JOIN company_dept cd ON cu.dept_id = cd.dept_id
+        LEFT JOIN fs_user_course_video cv ON log.video_id = cv.video_id
+        WHERE log.send_type = 1
+        AND log.watch_type = 1
+        <include refid="commonConditions"/>
+        group by log.user_id
+        ORDER BY u.register_date DESC
+    </select>
+
+
+    <select id="selectCampPeriodByPeriod" resultType="com.fs.his.vo.WatchLogReportVO">
+        SELECT
+        cp.period_id periodId,
+        cp.period_name periodName,
+        camp.training_camp_name
+        FROM
+        fs_user_course_period cp
+        LEFT JOIN fs_user_course_training_camp camp ON camp.training_camp_id = cp.training_camp_id
+        WHERE cp.period_id in
+        <foreach collection="periodIds" item="periodId" open="(" separator="," close=")">
+            #{periodId}
+        </foreach>
+    </select>
+
+    <select id="selectRedPacketStats" resultType="com.fs.his.vo.WatchLogReportVO">
+        SELECT
+        rp.watch_log_id  as  logId,
+        SUM(rp.amount) AS redPacketAmount
+        FROM fs_course_red_packet_log rp
+        WHERE  rp.watch_log_id IN
+        <foreach collection="logIds" item="logId" open="(" separator="," close=")">
+            #{logId}
+        </foreach>
+        GROUP BY rp.watch_log_id
+    </select>
+
+    <select id="selectAnswerStats" resultType="com.fs.his.vo.WatchLogReportVO">
+        SELECT
+        l.watch_log_id AS logId,
+        CASE WHEN l.log_id IS NOT NULL THEN '已答题' ELSE '未答题' END AS answerStatus,
+        (
+        SELECT COUNT(1)
+        FROM fs_course_watch_log wl
+        WHERE wl.log_id IN
+        <foreach collection="logIds" item="logId" open="(" separator="," close=")">
+            #{logId}
+        </foreach>
+        AND wl.log_id NOT IN (
+        SELECT watch_log_id
+        FROM fs_course_answer_logs
+        WHERE watch_log_id IS NOT NULL
+        )) AS notAnsweredCount
+        FROM fs_course_answer_logs l
+        WHERE l.watch_log_id IN
+        <foreach collection="logIds" item="logId" open="(" separator="," close=")">
+            #{logId}
+        </foreach>
+        GROUP BY l.watch_log_id
+    </select>
+
+    <select id="selectCourseByUserIdForStatusFinish" resultType="com.fs.course.vo.FsUserCourseAppListVO">
+        select c.course_name courseName,c.img_url imgUrl,r.title videoName,l.link_id linkId,l.duration,l.video_id videoId,l.user_id userId,l.company_user_id companyUserId,l.log_id logId   from fs_course_watch_log l
+                                                                                                                                                                                                     left join fs_user_course c on l.course_id =c.course_id
+                                                                                                                                                                                                     left join fs_user_course_video r on r.video_id=l.video_id
+        WHERE l.user_id = #{userId} and l.log_type = 2
+          and l.create_time &gt;= CONCAT(CURDATE(), ' 00:00:00')
+          and l.create_time &lt;= CONCAT(CURDATE(), ' 23:59:59')
+    </select>
+
+    <select id="selectCourseByUserIdForStatusNotFinish" resultType="com.fs.course.vo.FsUserCourseAppListVO">
+        select c.course_name courseName,c.img_url imgUrl,r.title videoName,l.link_id linkId,l.duration,l.video_id videoId,l.user_id userId,l.company_user_id companyUserId,l.log_id logId  from fs_course_watch_log l
+                                                                                                                                                                                                    left join fs_user_course c on l.course_id =c.course_id
+                                                                                                                                                                                                    left join fs_user_course_video r on r.video_id=l.video_id
+        WHERE l.user_id = #{userId} and l.log_type != 2
+          and l.create_time &gt;= CONCAT(CURDATE(), ' 00:00:00')
+          and l.create_time &lt;= CONCAT(CURDATE(), ' 23:59:59')
+    </select>
+
+    <select id="getAppCourseLearningOne" resultType="com.fs.course.vo.FsUserCourseAppListVO">
+        select c.course_name courseName,c.img_url imgUrl,r.title videoName,l.link_id linkId,l.duration,l.video_id videoId,l.user_id userId,l.company_user_id companyUserId,l.log_id logId
+        from fs_course_watch_log l
+        left join fs_user_course c on l.course_id =c.course_id
+        left join fs_user_course_video r on r.video_id=l.video_id
+        WHERE l.user_id = #{userId} and l.update_time is not null
+        order by l.update_time DESC LIMIT 1
+    </select>
+
+
+    <!-- 销售维度营期信息 -->
+    <select id="selectAppSalesCampPeriod" resultType="com.fs.his.vo.AppSalesWatchLogReportVO">
+        SELECT
+        cp.period_id periodId,
+        cp.period_name periodName,
+        camp.training_camp_name trainingCampName
+        FROM
+        fs_user_course_period cp
+        LEFT JOIN fs_user_course_training_camp camp ON camp.training_camp_id = cp.training_camp_id
+        WHERE cp.period_id in
+        <foreach collection="periodIds" item="periodId" open="(" separator="," close=")">
+            #{periodId}
+        </foreach>
+    </select>
+
+    <!-- 销售部门维度APP会员数统计 -->
+    <select id="selectAppDeptUserStats" resultType="com.fs.his.vo.AppSalesWatchLogReportVO">
+        SELECT
+        cd.dept_id AS deptId,
+        COUNT(DISTINCT CASE WHEN u.source IS NOT NULL THEN u.user_id END) AS appUserCount,
+        <choose>
+            <when test="startDate != null and startDate != '' and endDate != null and endDate != ''">
+                COUNT(DISTINCT CASE WHEN u.source IS NOT NULL AND u.register_date &gt;= #{startDate} AND u.register_date &lt; DATE_ADD(#{endDate}, INTERVAL 1 DAY) THEN u.user_id END)
+            </when>
+            <otherwise>
+                COUNT(DISTINCT CASE WHEN u.source IS NOT NULL THEN u.user_id END)
+            </otherwise>
+        </choose> AS newAppUserCount,
+        COUNT(DISTINCT cu.user_id) AS salesCount
+        FROM fs_user u
+        LEFT JOIN fs_user_company_user cuu ON cuu.user_id = u.user_id
+        LEFT JOIN company_user cu ON cuu.company_user_id = cu.user_id
+        LEFT JOIN company c ON cuu.company_id = c.company_id
+        LEFT JOIN company_dept cd ON cu.dept_id = cd.dept_id
+        WHERE u.source IS NOT NULL
+        <if test="companyId != null and companyId != ''">
+            AND cuu.company_id = #{companyId}
+        </if>
+        <if test="deptId != null and deptId != ''">
+            AND cu.dept_id = #{deptId}
+        </if>
+        <if test="salesId != null and salesId != ''">
+            AND cu.user_id = #{salesId}
+        </if>
+        <if test="project != null and project != ''">
+            AND cuu.project_id = #{project}
+        </if>
+        GROUP BY cd.dept_id
+    </select>
+
+    <!-- 销售部门维度基础数据+看课统计(合并查询) -->
+    <select id="selectAppDeptWatchStats" resultType="com.fs.his.vo.AppSalesWatchLogReportVO">
+        SELECT
+        cd.dept_id AS deptId,
+        cd.dept_name AS salesDept,
+        c.company_name AS salesCompany,
+        log.period_id AS periodId,
+        log.video_id AS videoId,
+        cv.title AS videoTitle,
+        c.company_id AS companyId,
+        COUNT(DISTINCT CASE WHEN log.log_type = '2' THEN log.log_id END) AS finishedCount,
+        COUNT(DISTINCT CASE WHEN log.log_type = '1' THEN log.log_id END) AS unfinishedCount,
+        COUNT(DISTINCT CASE WHEN log.log_type = '3' THEN log.log_id END) AS notWatchedCount,
+        COUNT(DISTINCT CASE WHEN a.log_id IS NULL THEN log.log_id END) AS notAnsweredCount,
+        COALESCE(SUM(rpl.amount), 0) AS redPacketAmount
+        FROM fs_course_watch_log log
+        LEFT JOIN company_user cu ON log.company_user_id = cu.user_id
+        LEFT JOIN company c ON log.company_id = c.company_id
+        LEFT JOIN company_dept cd ON cu.dept_id = cd.dept_id
+        LEFT JOIN fs_user_course_video cv ON log.video_id = cv.video_id
+        LEFT JOIN fs_course_answer_logs a ON a.watch_log_id = log.log_id
+        LEFT JOIN fs_course_red_packet_log rpl ON rpl.watch_log_id = log.log_id
+        WHERE log.send_type = 1
+        AND log.watch_type = 1
+        <include refid="commonConditions"/>
+        GROUP BY cd.dept_id, log.period_id, log.video_id
+        ORDER BY cd.dept_id
+    </select>
+
+    <!-- 销售部门维度订单统计 -->
+    <select id="selectAppDeptOrderStats" resultType="com.fs.his.vo.AppSalesWatchLogReportVO">
+        SELECT
+        cd.dept_id AS deptId,
+        log.period_id AS periodId,
+        log.video_id AS videoId,
+        COUNT(DISTINCT CASE WHEN po.status = 3 THEN po.order_id END) AS historyOrderCount
+        FROM fs_course_watch_log log
+        LEFT JOIN company_user cu ON log.company_user_id = cu.user_id
+        LEFT JOIN company c ON log.company_id = c.company_id
+        LEFT JOIN company_dept cd ON cu.dept_id = cd.dept_id
+        LEFT JOIN fs_package_order po ON po.user_id = log.user_id
+        WHERE log.send_type = 1
+        AND log.watch_type = 1
+        <include refid="commonConditions"/>
+        GROUP BY cd.dept_id, log.period_id, log.video_id
+    </select>
+    <!-- 销售维度APP会员数统计(直接查fs_user表) -->
+    <select id="selectAppSalesUserStats" resultType="com.fs.his.vo.AppSalesWatchLogReportVO">
+        SELECT
+        cu.user_id AS salesId,
+        COUNT(DISTINCT CASE WHEN u.source IS NOT NULL THEN u.user_id END) AS appUserCount,
+        <choose>
+            <when test="startDate != null and startDate != '' and endDate != null and endDate != ''">
+                COUNT(DISTINCT CASE WHEN u.source IS NOT NULL AND u.register_date &gt;= #{startDate} AND u.register_date &lt; DATE_ADD(#{endDate}, INTERVAL 1 DAY) THEN u.user_id END)
+            </when>
+            <otherwise>
+                COUNT(DISTINCT CASE WHEN u.source IS NOT NULL THEN u.user_id END)
+            </otherwise>
+        </choose> AS newAppUserCount
+        FROM fs_user u
+        LEFT JOIN fs_user_company_user cuu ON cuu.user_id = u.user_id
+        LEFT JOIN company_user cu ON cuu.company_user_id = cu.user_id
+        LEFT JOIN company c ON cuu.company_id = c.company_id
+        LEFT JOIN company_dept cd ON cu.dept_id = cd.dept_id
+        WHERE u.source IS NOT NULL
+        <if test="companyId != null and companyId != ''">
+            AND cuu.company_id = #{companyId}
+        </if>
+        <if test="deptId != null and deptId != ''">
+            AND cu.dept_id = #{deptId}
+        </if>
+        <if test="salesId != null and salesId != ''">
+            AND cu.user_id = #{salesId}
+        </if>
+        <if test="project != null and project != ''">
+            AND cuu.project_id = #{project}
+        </if>
+        GROUP BY cu.user_id
+    </select>
+
+
+
+    <!-- 销售维度基础数据+看课统计(合并查询) -->
+    <select id="selectAppSalesWatchStats" resultType="com.fs.his.vo.AppSalesWatchLogReportVO">
+        SELECT
+        cu.user_id AS salesId,
+        cu.nick_name AS salesName,
+        cd.dept_name AS salesDept,
+        c.company_name AS salesCompany,
+        log.period_id AS periodId,
+        log.video_id AS videoId,
+        cv.title AS videoTitle,
+        cd.dept_id AS deptId,
+        c.company_id AS companyId,
+        COUNT(DISTINCT CASE WHEN log.log_type = '2' THEN log.log_id END) AS finishedCount,
+        COUNT(DISTINCT CASE WHEN log.log_type = '1' THEN log.log_id END) AS unfinishedCount,
+        COUNT(DISTINCT CASE WHEN log.log_type = '3' THEN log.log_id END) AS notWatchedCount,
+        COUNT(DISTINCT CASE WHEN a.log_id IS NULL THEN log.log_id END) AS notAnsweredCount,
+        COALESCE(SUM(rpl.amount), 0) AS redPacketAmount
+        FROM fs_course_watch_log log
+        LEFT JOIN company_user cu ON log.company_user_id = cu.user_id
+        LEFT JOIN company c ON log.company_id = c.company_id
+        LEFT JOIN company_dept cd ON cu.dept_id = cd.dept_id
+        LEFT JOIN fs_user_course_video cv ON log.video_id = cv.video_id
+        LEFT JOIN fs_course_answer_logs a ON a.watch_log_id = log.log_id
+        LEFT JOIN fs_course_red_packet_log rpl ON rpl.watch_log_id = log.log_id
+        WHERE log.send_type = 1
+        AND log.watch_type = 1
+        <include refid="commonConditions"/>
+        GROUP BY cu.user_id, log.period_id, log.video_id
+        ORDER BY cu.user_id
+    </select>
+
+    <select id="selectAppSalesOrderStats" resultType="com.fs.his.vo.AppSalesWatchLogReportVO"></select>
+
+
+    <sql id="commonConditions">
+        <!-- 销售公司 -->
+        <if test="companyId != null and companyId != ''">
+            AND c.company_id = #{companyId}
+        </if>
+
+        <!-- 销售部门 -->
+        <if test="deptId != null and deptId != ''">
+            AND cd.dept_id = #{deptId}
+        </if>
+
+        <!-- 所属销售 -->
+        <if test="salesId != null and salesId != ''">
+            AND cu.user_id = #{salesId}
+        </if>
+
+        <!-- 项目 -->
+        <if test="project != null and project != ''">
+            AND log.project = #{project}
+        </if>
+        <!-- 时间范围 -->
+        <if test="startDate != null and startDate != '' and endDate != null and endDate != ''">
+            AND log.create_time &gt;= #{startDate} AND log.create_time &lt; DATE_ADD(#{endDate}, INTERVAL 1 DAY)
+        </if>
+        <!-- 训练营 -->
+        <if test="trainingCampId != null and trainingCampId != ''">
+            AND log.period_id IN (SELECT period_id FROM fs_user_course_period WHERE training_camp_id = #{trainingCampId})
+        </if>
+        <!-- 营期 -->
+        <if test="periodId != null and periodId != ''">
+            AND log.period_id = #{periodId}
+        </if>
+
+        <!-- 会员ID -->
+        <if test="userId != null and userId != ''">
+            AND u.user_id = #{userId}
+        </if>
+
+        <!-- 会员手机号 -->
+        <if test="userPhone != null and userPhone != ''">
+            AND u.phone LIKE CONCAT('%', #{userPhone}, '%')
+        </if>
+
+        <!-- 会员昵称 -->
+        <if test="nickName != null and nickName != ''">
+            AND u.nick_name LIKE CONCAT('%', #{nickName}, '%')
+        </if>
+    </sql>
 </mapper>

+ 29 - 0
fs-service/src/main/resources/mapper/his/FsUserMapper.xml

@@ -2540,4 +2540,33 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </foreach>
     </select>
 
+    <select id="selectAppSalesNewUserCountVO" resultType="com.fs.his.vo.AppSalesCourseStatisticsVO">
+        select count(distinct u.user_id) as newAppUserCount, ucu.company_user_id from fs_user u
+        left join fs_user_company_user ucu on u.user_id = ucu.user_id and ucu.status=1
+        where u.is_del = 0
+        <if test="companyId != null ">
+            and ucu.company_id = #{companyId}
+        </if>
+        <if test="companyUserId != null ">
+            and ucu.company_user_id = #{companyUserId}
+        </if>
+        <if test="startDate != null and startDate != '' and endDate != null and endDate != ''">
+            and u.app_create_time &gt;= #{startDate} and u.app_create_time &lt;= #{endDate}
+        </if>
+        group by ucu.company_user_id
+    </select>
+
+    <select id="selectAppSalesUserCountVO" resultType="com.fs.his.vo.AppSalesCourseStatisticsVO">
+        select count(distinct u.user_id) as appUserCount, ucu.company_user_id from fs_user u
+        left join fs_user_company_user ucu on u.user_id = ucu.user_id and ucu.status=1
+        where u.is_del = 0 and u.source is not null
+        <if test="companyId != null ">
+            and ucu.company_id = #{companyId}
+        </if>
+        <if test="companyUserId != null ">
+            and ucu.company_user_id = #{companyUserId}
+        </if>
+        group by ucu.company_user_id
+    </select>
+
 </mapper>

+ 77 - 0
fs-service/src/main/resources/mapper/im/FsImMsgSendLogMapper.xml

@@ -208,5 +208,82 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             </foreach>
     </select>
 
+    <select id="selectFsImMsgSendLogInfoList" resultType="com.fs.app.service.param.FsImMsgSendLogResponse">
+        SELECT
+        d.log_id AS logId,
+        l.company_user_id AS companyUserId,
+        l.company_id AS companyId,
+        l.course_id AS courseId,
+        l.course_name AS courseName,
+        l.video_id AS videoId,
+        l.video_name AS videoName,
+        l.project_id AS projectId,
+        l.send_title AS sendTitle,
+        l.plan_send_time AS planSendTime,
+        l.send_type AS sendType,
+        l.send_mode AS sendMode,
+        l.is_urge_course AS isUrgeCourse,
+        l.msg_type AS msgType,
+        l.send_status AS sendStatus,
+        l.count AS count,
+        l.create_time AS createTime,
+        d.user_id AS userId,
+        d.status AS status,
+        d.exception_info AS exceptionInfo
+        FROM  fs_im_msg_send_detail d
+        LEFT JOIN fs_im_msg_send_log l ON l.log_id = d.log_id
+        <where>
+            <if test="companyUserId != null"> AND l.company_user_id = #{companyUserId}</if>
+            <if test="companyId != null"> AND l.company_id = #{companyId}</if>
+            <if test="courseId != null"> AND l.course_id = #{courseId}</if>
+            <if test="courseName != null and courseName != ''"> AND l.course_name like concat('%', #{courseName}, '%')</if>
+            <if test="videoId != null"> AND l.video_id = #{videoId}</if>
+            <if test="videoName != null and videoName != ''"> AND l.video_name like concat('%', #{videoName}, '%')</if>
+            <if test="sendTitle != null and sendTitle != ''"> AND l.send_title like concat('%', #{sendTitle}, '%')</if>
+            <if test="planSendStartTime != null and planSendEndTime != null">
+                AND l.plan_send_time between #{planSendStartTime} and #{planSendEndTime}
+            </if>
+            <if test="sendType != null"> AND l.send_type = #{sendType}</if>
+            <if test="sendMode != null"> AND l.send_mode = #{sendMode}</if>
+            <if test="sendStatus != null"> AND l.send_status = #{sendStatus}</if>
+            <if test="msgType != null"> AND l.msg_type = #{msgType}</if>
+            <if test="projectId != null"> AND l.project_id = #{projectId}</if>
+            <if test="createTimeStartTime != null and createTimeEndTime != null">
+                AND d.create_time between #{createTimeStartTime} and #{createTimeEndTime}
+            </if>
+            <if test="status != null"> AND d.status = #{status}</if>
+        </where>
+        ORDER BY l.create_time DESC
+    </select>
+
+    <select id="getFsImMsgSendStatistics" resultType="com.fs.app.service.param.FsImMsgSendLogStatisticsResponse">
+        SELECT
+        count(d.log_detail_id) as total,
+        sum(case when d.status = 0 then 1 else 0 end) as sent,
+        sum(case when d.send_status = 2 then 1 else 0 end) as pending,
+        sum(case when d.status = 1 then 1 else 0 end) as failed
+        FROM  fs_im_msg_send_detail d
+        LEFT JOIN fs_im_msg_send_log l  ON l.log_id = d.log_id
+        <where>
+            <if test="companyUserId != null"> AND l.company_user_id = #{companyUserId}</if>
+            <if test="companyId != null"> AND l.company_id = #{companyId}</if>
+            <if test="courseId != null"> AND l.course_id = #{courseId}</if>
+            <if test="courseName != null and courseName != ''"> AND l.course_name like concat('%', #{courseName}, '%')</if>
+            <if test="videoId != null"> AND l.video_id = #{videoId}</if>
+            <if test="videoName != null and videoName != ''"> AND l.video_name like concat('%', #{videoName}, '%')</if>
+            <if test="sendTitle != null and sendTitle != ''"> AND l.send_title like concat('%', #{sendTitle}, '%')</if>
+            <if test="planSendStartTime != null and planSendEndTime != null">
+                AND l.plan_send_time between #{planSendStartTime} and #{planSendEndTime}
+            </if>
+            <if test="sendType != null"> AND l.send_type = #{sendType}</if>
+            <if test="sendMode != null"> AND l.send_mode = #{sendMode}</if>
+            <if test="msgType != null"> AND l.msg_type = #{msgType}</if>
+            <if test="projectId != null"> AND l.project_id = #{projectId}</if>
+            <if test="createTimeStartTime != null and createTimeEndTime != null">
+                AND d.create_time between #{createTimeStartTime} and #{createTimeEndTime}
+            </if>
+        </where>
+    </select>
+
 
 </mapper>

+ 3 - 0
fs-user-app/src/main/java/com/fs/app/controller/AppLoginController.java

@@ -660,6 +660,9 @@ public class AppLoginController extends AppBaseController{
         if (StringUtils.isNotEmpty(user.getAppOpenId())) {
             userMap.setAppOpenId(user.getAppOpenId());
         }
+        if(user.getAppCreateTime()== null){
+            userMap.setAppCreateTime(new Date());
+        }
         String ipAddr = IpUtils.getIpAddr(ServletUtils.getRequest());
         userMap.setLastIp(ipAddr);
         userService.updateFsUser(userMap);

+ 78 - 0
fs-user-app/src/main/java/com/fs/app/controller/AppPayController.java

@@ -0,0 +1,78 @@
+package com.fs.app.controller;
+
+
+import com.fs.app.service.AppPayService;
+import com.fs.common.exception.CustomException;
+import com.fs.common.utils.StringUtils;
+import com.fs.course.domain.FsCoursePlaySourceConfig;
+import com.fs.course.service.IFsCoursePlaySourceConfigService;
+import com.fs.his.config.AppConfig;
+import com.fs.his.domain.FsPayConfig;
+import com.fs.his.domain.MerchantAppConfig;
+import com.fs.his.service.IMerchantAppConfigService;
+import com.fs.system.domain.SysConfig;
+import com.fs.system.mapper.SysConfigMapper;
+import com.github.binarywang.wxpay.bean.notify.WxPayOrderNotifyResult;
+import com.github.binarywang.wxpay.config.WxPayConfig;
+import com.github.binarywang.wxpay.exception.WxPayException;
+import com.github.binarywang.wxpay.service.WxPayService;
+import com.github.binarywang.wxpay.service.impl.WxPayServiceImpl;
+import com.google.gson.Gson;
+import com.hc.openapi.tool.fastjson.JSON;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@Slf4j
+@Api("app支付接口")
+@RestController
+@RequestMapping("/appPay")
+public class AppPayController extends AppBaseController {
+
+    @Autowired
+    private AppPayService appPayService;
+
+    @Autowired
+    private IFsCoursePlaySourceConfigService fsCoursePlaySourceConfigService;
+   @Autowired
+    private IMerchantAppConfigService merchantAppConfigService;
+    @Autowired
+    private SysConfigMapper sysConfigMapper;
+
+
+    @ApiOperation("微信支付回调")
+    @PostMapping("/wxNotify")
+    public String wxNotify(@RequestBody String xmlData) throws WxPayException {
+        WxPayService payService = getWxPayService();
+        WxPayOrderNotifyResult result = payService.parseOrderNotifyResult(xmlData);
+        return appPayService.wxNotify(result);
+    }
+
+    private WxPayService getWxPayService(){
+        SysConfig sysConfig = sysConfigMapper.selectConfigByConfigKey("app.config");
+        AppConfig config = new Gson().fromJson(sysConfig.getConfigValue(), AppConfig.class);
+        FsCoursePlaySourceConfig fsCoursePlaySourceConfig = fsCoursePlaySourceConfigService.selectCoursePlaySourceConfigByAppId(config.getAppId());
+        if (fsCoursePlaySourceConfig == null) {
+            throw new CustomException("未找到appId对应的小程序配置: " + config.getAppId());
+        }
+        MerchantAppConfig merchantAppConfig = merchantAppConfigService.selectMerchantAppConfigById(fsCoursePlaySourceConfig.getMerchantConfigId());
+        FsPayConfig payConfig1 = JSON.parseObject(merchantAppConfig.getDataJson(), FsPayConfig.class);
+
+        WxPayConfig payConfig = new WxPayConfig();
+        payConfig.setAppId(payConfig1.getAppId());
+        payConfig.setMchId(payConfig1.getWxMchId());
+        payConfig.setMchKey(payConfig1.getWxMchKey());
+        payConfig.setSubAppId(StringUtils.trimToNull(null));
+        payConfig.setSubMchId(StringUtils.trimToNull(null));
+        payConfig.setKeyPath(null);
+        payConfig.setNotifyUrl(payConfig1.getNotifyUrlScrm());
+        WxPayServiceImpl payService = new WxPayServiceImpl();
+        payService.setConfig(payConfig);
+        return payService;
+    }
+}

+ 58 - 0
fs-user-app/src/main/java/com/fs/app/controller/CourseController.java

@@ -403,4 +403,62 @@ public class CourseController extends  AppBaseController{
         // 查询看课记录
         return linkService.getLinkInfo(param.getLogId());
     }
+
+
+    /**
+     * @Description: APP 用户获取课程列表
+     * @Param:
+     * @Return:
+     * @Author xgb
+     * @Date 2026/1/21 9:48
+     */
+    @ApiOperation("APP用户获取课程列表")
+    @Login
+    @GetMapping("/getAppCourseList")
+    public R getAppCourseList(FsUserCourseAppListParam param)
+    {
+        param.setUserId(Long.parseLong(getUserId()));
+        return courseService.getAppCourseList(param);
+    }
+
+
+
+    /**
+     * @Description: 获取链接数据 这个地方的linkId 实际上传的是log_id link_id 看课记录有可能为空
+     * @Param:
+     * @Return:
+     * @Author xgb
+     * @Date 2026/1/21 16:10
+     */
+    @ApiOperation("获取链接数据")
+    @GetMapping("/getLinkData")
+    public R getLinkData(Long linkId)
+    {
+        return courseService.getLinkData(linkId);
+    }
+
+    /**
+     * @Description: APP 用户获取课程列表
+     * @Param:
+     * @Return:
+     * @Author xgb
+     * @Date 2026/1/21 9:48
+     */
+    @ApiOperation("APP用户获取课程列表")
+    @Login
+    @GetMapping("/getAppCourseLearningOne")
+    public R getAppCourseLearningOne()
+    {
+        return courseService.getAppCourseLearningOne(Long.parseLong(getUserId()));
+    }
+
+    @Login
+    @ApiOperation("判断是否注册")
+    @PostMapping("/registerQwFsUserFinish")
+    public R registerQwFsUserFinish(@RequestBody FsUserCourseVideoAddKfUParam param) {
+        Long userId = Long.parseLong(getUserId());
+        param.setUserId(userId);
+        return courseVideoService.registerQwFsUserFinish(param);
+    }
+
 }