Ver código fonte

Merge remote-tracking branch 'origin/master'

zyy 2 semanas atrás
pai
commit
938fe2832c
100 arquivos alterados com 5164 adições e 239 exclusões
  1. 0 1
      fs-admin/src/main/java/com/fs/course/business/FsVideoResourceBusinessService.java
  2. 34 3
      fs-admin/src/main/java/com/fs/his/task/Task.java
  3. 79 26
      fs-admin/src/main/java/com/fs/task/CrmCustomerAiProcessingTask.java
  4. 1 1
      fs-ai-call-task/src/main/java/com/fs/app/task/Task.java
  5. 27 4
      fs-company-app/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java
  6. 59 0
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceCloneController.java
  7. 9 3
      fs-company/src/main/java/com/fs/company/controller/course/FsCourseWatchLogController.java
  8. 0 1
      fs-company/src/main/java/com/fs/company/controller/crm/CrmCustomerAnalyzeController.java
  9. 0 7
      fs-company/src/main/java/com/fs/company/controller/crm/CrmCustomerController.java
  10. 107 0
      fs-company/src/main/java/com/fs/company/controller/crm/chat/CrmCustomerChatMessageController.java
  11. 156 0
      fs-company/src/main/java/com/fs/company/controller/crm/chat/CrmCustomerChatSessionController.java
  12. 65 0
      fs-company/src/main/java/com/fs/company/controller/im/FsImMsgSendLogController.java
  13. 192 0
      fs-company/src/main/java/com/fs/company/controller/statistic/courseStatisticController.java
  14. 158 39
      fs-company/src/main/java/com/fs/company/controller/store/FsStoreOrderController.java
  15. 24 2
      fs-company/src/main/java/com/fs/hisStore/controller/FsStoreOrderScrmController.java
  16. 38 0
      fs-service/src/main/java/com/fs/aicall/domain/CcTtsAliyun.java
  17. 57 0
      fs-service/src/main/java/com/fs/aicall/mapper/CcTtsAliyunMapper.java
  18. 48 0
      fs-service/src/main/java/com/fs/aicall/service/ICcTtsAliyunService.java
  19. 56 0
      fs-service/src/main/java/com/fs/aicall/service/impl/CcTtsAliyunServiceImpl.java
  20. 12 0
      fs-service/src/main/java/com/fs/app/service/AppPayService.java
  21. 63 0
      fs-service/src/main/java/com/fs/app/service/impl/AppPayServiceImpl.java
  22. 64 0
      fs-service/src/main/java/com/fs/app/service/param/FsImMsgSendLogRequest.java
  23. 78 0
      fs-service/src/main/java/com/fs/app/service/param/FsImMsgSendLogResponse.java
  24. 20 0
      fs-service/src/main/java/com/fs/app/service/param/FsImMsgSendLogStatisticsResponse.java
  25. 2 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceRoboticMapper.java
  26. 2 0
      fs-service/src/main/java/com/fs/company/param/EntryCustomerParam.java
  27. 36 0
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceCloneService.java
  28. 1 1
      fs-service/src/main/java/com/fs/company/service/IGeneralCustomerEntryService.java
  29. 391 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceCloneServiceImpl.java
  30. 6 1
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java
  31. 30 11
      fs-service/src/main/java/com/fs/company/service/impl/GeneralCustomerEntryServiceImpl.java
  32. 2 0
      fs-service/src/main/java/com/fs/course/domain/FsCourseWatchLog.java
  33. 3 0
      fs-service/src/main/java/com/fs/course/dto/BatchSendCourseDTO.java
  34. 5 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseAnswerLogsMapper.java
  35. 2 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseLinkMapper.java
  36. 4 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseRedPacketLogMapper.java
  37. 70 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseWatchLogMapper.java
  38. 2 0
      fs-service/src/main/java/com/fs/course/param/FsCourseLinkCreateParam.java
  39. 10 2
      fs-service/src/main/java/com/fs/course/param/FsCourseWatchLogStatisticsListParam.java
  40. 20 0
      fs-service/src/main/java/com/fs/course/param/FsUserCourseAppListParam.java
  41. 1 0
      fs-service/src/main/java/com/fs/course/param/newfs/FsCourseSortLinkParam.java
  42. 4 0
      fs-service/src/main/java/com/fs/course/param/newfs/FsUserCourseAddCompanyUserParam.java
  43. 10 0
      fs-service/src/main/java/com/fs/course/service/IFsCourseWatchLogService.java
  44. 12 0
      fs-service/src/main/java/com/fs/course/service/IFsUserCourseService.java
  45. 2 0
      fs-service/src/main/java/com/fs/course/service/IFsUserCourseVideoService.java
  46. 542 6
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java
  47. 128 3
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseServiceImpl.java
  48. 110 0
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  49. 42 0
      fs-service/src/main/java/com/fs/course/vo/FsUserCourseAppListVO.java
  50. 2 1
      fs-service/src/main/java/com/fs/crm/domain/CrmCustomerAnalyze.java
  51. 58 0
      fs-service/src/main/java/com/fs/crm/domain/CrmCustomerChatMessage.java
  52. 49 0
      fs-service/src/main/java/com/fs/crm/domain/CrmCustomerChatSession.java
  53. 86 0
      fs-service/src/main/java/com/fs/crm/mapper/CrmCustomerChatMessageMapper.java
  54. 60 0
      fs-service/src/main/java/com/fs/crm/mapper/CrmCustomerChatSessionMapper.java
  55. 4 10
      fs-service/src/main/java/com/fs/crm/mapper/CrmCustomerMapper.java
  56. 2 5
      fs-service/src/main/java/com/fs/crm/param/CrmCustomerListQueryParam.java
  57. 2 4
      fs-service/src/main/java/com/fs/crm/param/CrmLineCustomerListQueryParam.java
  58. 1 2
      fs-service/src/main/java/com/fs/crm/service/ICrmCustomerAnalyzeService.java
  59. 75 0
      fs-service/src/main/java/com/fs/crm/service/ICrmCustomerChatMessageService.java
  60. 53 0
      fs-service/src/main/java/com/fs/crm/service/ICrmCustomerChatSessionService.java
  61. 96 56
      fs-service/src/main/java/com/fs/crm/service/impl/CrmCustomerAnalyzeServiceImpl.java
  62. 158 0
      fs-service/src/main/java/com/fs/crm/service/impl/CrmCustomerChatMessageServiceImpl.java
  63. 163 0
      fs-service/src/main/java/com/fs/crm/service/impl/CrmCustomerChatSessionServiceImpl.java
  64. 27 16
      fs-service/src/main/java/com/fs/crm/utils/CrmCustomerAiTagUtil.java
  65. 1 1
      fs-service/src/main/java/com/fs/crm/vo/CrmLineCustomerListQueryVO.java
  66. 12 1
      fs-service/src/main/java/com/fs/gtPush/service/impl/uniPush2ServiceImpl.java
  67. 5 0
      fs-service/src/main/java/com/fs/his/domain/FsUser.java
  68. 2 1
      fs-service/src/main/java/com/fs/his/enums/PushLogTypeEnum.java
  69. 6 0
      fs-service/src/main/java/com/fs/his/mapper/FsUserMapper.java
  70. 47 15
      fs-service/src/main/java/com/fs/his/service/impl/FsInquiryOrderMsgServiceImpl.java
  71. 87 0
      fs-service/src/main/java/com/fs/his/vo/AppCourseReportVO.java
  72. 86 0
      fs-service/src/main/java/com/fs/his/vo/AppSalesCourseStatisticsVO.java
  73. 89 0
      fs-service/src/main/java/com/fs/his/vo/AppSalesWatchLogReportVO.java
  74. 146 0
      fs-service/src/main/java/com/fs/his/vo/AppWatchLogReportVO.java
  75. 177 0
      fs-service/src/main/java/com/fs/his/vo/WatchLogReportVO.java
  76. 4 1
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreOrderItemScrmMapper.java
  77. 3 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreOrderScrmMapper.java
  78. 8 0
      fs-service/src/main/java/com/fs/hisStore/param/FsStoreOrderParam.java
  79. 14 2
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreAfterSalesScrmServiceImpl.java
  80. 2 2
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreOrderScrmServiceImpl.java
  81. 2 1
      fs-service/src/main/java/com/fs/im/dto/OpenImBatchMsgDTO.java
  82. 8 0
      fs-service/src/main/java/com/fs/im/mapper/FsImMsgSendLogMapper.java
  83. 12 0
      fs-service/src/main/java/com/fs/im/service/IFsImMsgSendLogService.java
  84. 9 0
      fs-service/src/main/java/com/fs/im/service/OpenIMService.java
  85. 25 2
      fs-service/src/main/java/com/fs/im/service/impl/FsImMsgSendLogServiceImpl.java
  86. 153 0
      fs-service/src/main/java/com/fs/im/service/impl/OpenIMServiceImpl.java
  87. 1 1
      fs-service/src/main/java/com/fs/wxcid/threadExecutor/generalCustomerExecutor.java
  88. 1 1
      fs-service/src/main/resources/application-common.yml
  89. 4 0
      fs-service/src/main/resources/application-config-druid-hsyy.yml
  90. 5 1
      fs-service/src/main/resources/application-config-druid-sxjz.yml
  91. 87 0
      fs-service/src/main/resources/mapper/aicall/CcTtsAliyunMapper.xml
  92. 4 0
      fs-service/src/main/resources/mapper/company/CompanyVoiceRoboticMapper.xml
  93. 1 1
      fs-service/src/main/resources/mapper/company/CompanyWorkflowMapper.xml
  94. 25 0
      fs-service/src/main/resources/mapper/course/FsCourseAnswerLogsMapper.xml
  95. 6 0
      fs-service/src/main/resources/mapper/course/FsCourseLinkMapper.xml
  96. 25 0
      fs-service/src/main/resources/mapper/course/FsCourseRedPacketLogMapper.xml
  97. 362 3
      fs-service/src/main/resources/mapper/course/FsCourseWatchLogMapper.xml
  98. 1 1
      fs-service/src/main/resources/mapper/course/FsUserCourseVideoMapper.xml
  99. 9 0
      fs-service/src/main/resources/mapper/crm/CrmCustomerAnalyzeMapper.xml
  100. 115 0
      fs-service/src/main/resources/mapper/crm/CrmCustomerChatMessageMapper.xml

+ 0 - 1
fs-admin/src/main/java/com/fs/course/business/FsVideoResourceBusinessService.java

@@ -36,7 +36,6 @@ public class FsVideoResourceBusinessService {
                 .set("file_key", fsVideoResource.getFileKey())
                 .set("file_name", fsVideoResource.getFileName())
                 .set("thumbnail", fsVideoResource.getThumbnail())
-                .set("display_type", fsVideoResource.getDisplayType())
         );
     }
 }

+ 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);
+            }
         }
 
     }

+ 79 - 26
fs-admin/src/main/java/com/fs/task/CrmCustomerAiProcessingTask.java

@@ -7,6 +7,7 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.stereotype.Component;
 
+import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.*;
@@ -94,41 +95,93 @@ public class CrmCustomerAiProcessingTask {
         String threadName = Thread.currentThread().getName();
         long batchStartTime = System.currentTimeMillis();
 
-//        try {
+        try {
             log.info("线程 {} 开始处理批次, 数据量: {}", threadName, batch.size());
 
-            // 示例:处理每条数据
-            for (Map<String, String> data : batch) {
-                // 获取数据
-                Long customerId = Long.valueOf(data.get("customerId"));
-                String dataJson = data.get("data");
-                Long logId = Long.valueOf(data.get("logId"));
-                //todo 业务!!!!!!2.流失风险等级8.9.客户意向度 //都要异步处理
-                //客户画像
-                crmCustomerAnalyzeService.aiGeneratedCustomerPortrait(customerId,dataJson,logId);
-                //沟通总结
-                crmCustomerAnalyzeService.aiCommunicationSummary(customerId,dataJson,logId);
-                //沟通摘要
-                crmCustomerAnalyzeService.aiCommunicationAbstract(customerId,dataJson,logId);
-//                //流失风险等级 //ai暂时未提供,等ai提供
-                crmCustomerAnalyzeService.aiAttritionLevel(customerId,dataJson,logId);
-                //客户关注点
-                crmCustomerAnalyzeService.aiCustomerFocus(customerId,dataJson,logId);
-                //客户意向度
-                crmCustomerAnalyzeService.aiIntentionDegree(customerId,dataJson,logId);
+            List<CompletableFuture<Void>> customerFutures = batch.stream()
+                    .map(data -> CompletableFuture.runAsync(() -> {
+                        processSingleCustomer(data, successCount, failCount);
+                    }, executorService))
+                    .collect(Collectors.toList());
 
-            }
+            // 等待该批次内所有客户处理完成
+            CompletableFuture.allOf(customerFutures.toArray(new CompletableFuture[0]))
+                    .join();
+            // 示例:处理每条数据
+//            for (Map<String, String> data : batch) {
+//                // 获取数据
+//                Long customerId = Long.valueOf(data.get("customerId"));
+//                String dataJson = data.get("data");
+//                Long logId = Long.valueOf(data.get("logId"));
+//                //客户画像1
+//                crmCustomerAnalyzeService.aiGeneratedCustomerPortrait(customerId,dataJson,logId);
+//                //沟通总结
+//                crmCustomerAnalyzeService.aiCommunicationSummary(customerId,dataJson,logId);
+//                //沟通摘要
+//                crmCustomerAnalyzeService.aiCommunicationAbstract(customerId,dataJson,logId);
+//                //流失风险等级
+//                crmCustomerAnalyzeService.aiAttritionLevel(customerId,dataJson,logId);
+//                //客户关注点
+//                crmCustomerAnalyzeService.aiCustomerFocus(customerId,dataJson,logId);
+//                //客户意向度
+//                crmCustomerAnalyzeService.aiIntentionDegree(customerId,dataJson,logId);
+//
+//            }
 
             long costTime = System.currentTimeMillis() - batchStartTime;
             successCount.addAndGet(batch.size());
             log.info("线程 {} 批次处理完成, 数据量: {}, 耗时: {}ms",
                     threadName, batch.size(), costTime);
 
-//        } catch (Exception e) {
-//            failCount.addAndGet(batch.size());
-//            log.error("线程 {} 批次处理失败, 数据量: {}", threadName, batch.size(), e);
-//            throw new RuntimeException("批次处理失败", e);
-//        }
+        } catch (Exception e) {
+            failCount.addAndGet(batch.size());
+            log.error("线程 {} 批次处理失败, 数据量: {}", threadName, batch.size(), e);
+            throw new RuntimeException("批次处理失败", e);
+        }
+    }
+    /**
+     * 处理单个客户的AI分析(6个接口并行)
+     */
+    private void processSingleCustomer(Map<String, String> data,
+                                       AtomicInteger successCount,
+                                       AtomicInteger failCount) {
+        try {
+            Long customerId = Long.valueOf(data.get("customerId"));
+            String dataJson = data.get("data");
+            Long logId = Long.valueOf(data.get("logId"));
+
+            long startTime = System.currentTimeMillis();
+
+            // 并行调用6个AI分析接口
+            List<CompletableFuture<Void>> aiFutures = Arrays.asList(
+                    CompletableFuture.runAsync(() ->
+                            crmCustomerAnalyzeService.aiGeneratedCustomerPortrait(customerId, dataJson, logId), executorService),
+                    CompletableFuture.runAsync(() ->
+                            crmCustomerAnalyzeService.aiCommunicationSummary(customerId, dataJson, logId), executorService),
+                    CompletableFuture.runAsync(() ->
+                            crmCustomerAnalyzeService.aiCommunicationAbstract(customerId, dataJson, logId), executorService),
+                    CompletableFuture.runAsync(() ->
+                            crmCustomerAnalyzeService.aiAttritionLevel(customerId, dataJson, logId), executorService),
+                    CompletableFuture.runAsync(() ->
+                            crmCustomerAnalyzeService.aiCustomerFocus(customerId, dataJson, logId), executorService),
+                    CompletableFuture.runAsync(() ->
+                            crmCustomerAnalyzeService.aiIntentionDegree(customerId, dataJson, logId), executorService)
+            );
+
+            // 等待该客户的所有AI分析完成
+            CompletableFuture<Void> allAiFutures = CompletableFuture.allOf(
+                    aiFutures.toArray(new CompletableFuture[aiFutures.size()])
+            );
+            allAiFutures.get(30, TimeUnit.SECONDS);
+            long costTime = System.currentTimeMillis() - startTime;
+            successCount.incrementAndGet();
+            log.info("客户 {} 的AI分析完成, 耗时: {}ms", customerId, costTime);
+
+        } catch (Exception e) {
+            failCount.incrementAndGet();
+            log.error("处理客户数据失败, customerId: {}, logId: {}",
+                    data.get("customerId"), data.get("logId"), e);
+        }
     }
     /**
      * 处理失败的数据

+ 1 - 1
fs-ai-call-task/src/main/java/com/fs/app/task/Task.java

@@ -38,7 +38,7 @@ public class Task {
 //    }
 
 
-    @Scheduled(cron = "0 0/1 * * * ?")
+    @Scheduled(cron = "0/30 * * * * ?")
     public void cidWorkflowRun(){
         taskService.cidWorkflowCallRun();
     }

+ 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 {

+ 59 - 0
fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceCloneController.java

@@ -0,0 +1,59 @@
+package com.fs.company.controller.company;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.enums.BusinessType;
+import com.fs.company.service.ICompanyVoiceCloneService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * 豆包声音克隆 Controller
+ *
+ * @author fs
+ */
+@RestController
+@RequestMapping("/company/voiceClone")
+public class CompanyVoiceCloneController extends BaseController {
+
+    @Autowired
+    private ICompanyVoiceCloneService companyVoiceCloneService;
+
+    /**
+     * 上传音频文件并训练声音克隆音色
+     *
+     * @param file       音频文件(wav/mp3/ogg/m4a/aac)
+     * @param voiceName  音色名称
+     * @param speakerId  声音ID
+     * @param language   语种(0-中文, 1-英文, 2-日语, 3-西班牙语, 4-印尼语, 5-葡萄牙语, 6-德语, 7-法语)
+     * @param modelType  模型类型(1-ICL1.0, 2-DiT标准版, 3-DiT还原版, 4-ICL2.0)
+     */
+    @Log(title = "声音克隆-上传训练", businessType = BusinessType.IMPORT)
+    @PostMapping("/uploadAndTrain")
+    public AjaxResult uploadAndTrain(
+            @RequestParam("file") MultipartFile file,
+            @RequestParam("voice_name") String voiceName,
+            @RequestParam("speaker_id") String speakerId,
+            @RequestParam(value = "language", defaultValue = "0") Integer language,
+            @RequestParam(value = "model_type", defaultValue = "2") Integer modelType) {
+        return companyVoiceCloneService.uploadAndTrain(voiceName, speakerId, language, modelType, file);
+    }
+
+    /**
+     * TTS 语音合成测试
+     *
+     * @param speakerId  声音ID
+     * @param language   语种
+     * @param text       要合成的文本
+     */
+    @Log(title = "声音克隆-TTS测试", businessType = BusinessType.OTHER)
+    @PostMapping("/doubaoTtsTest")
+    public AjaxResult doubaoTtsTest(
+            @RequestParam("speakerId") String speakerId,
+            @RequestParam(value = "language", defaultValue = "0") Integer language,
+            @RequestParam("text") String text) {
+        return companyVoiceCloneService.doubaoTtsTest(speakerId, language, text);
+    }
+}

+ 9 - 3
fs-company/src/main/java/com/fs/company/controller/course/FsCourseWatchLogController.java

@@ -406,7 +406,9 @@ public class FsCourseWatchLogController extends BaseController
         }
         FsCourseSummaryDetailQueryParam param = new FsCourseSummaryDetailQueryParam();
         param.setCompanyId(loginUser.getCompany().getCompanyId());
-        param.setCompanyUserId(loginUser.getUser().getUserId());
+        if(!"00".equals(loginUser.getUser().getUserType())){
+            param.setCompanyUserId(loginUser.getUser().getUserId());
+        }
         param.setVideoId(videoId);
         param.setPeriodId(periodId);
         return R.ok().put("data", fsCourseWatchLogService.getCourseStatisticsDetail(param));
@@ -441,8 +443,10 @@ public class FsCourseWatchLogController extends BaseController
         param.setPeriodId(periodId);
         param.setPageNum(pageNum);
         param.setPageSize(pageSize);
-        param.setCompanyUserId(loginUser.getUser().getUserId());
         param.setCompanyId(loginUser.getCompany().getCompanyId());
+        if(!"00".equals(loginUser.getUser().getUserType())){
+            param.setCompanyUserId(loginUser.getUser().getUserId());
+        }
         PageHelper.startPage(pageNum, pageSize);
         return R.ok().put("data", new PageInfo<>(fsCourseWatchLogService.getCourseStatisticsUserDetailList(param)));
     }
@@ -467,7 +471,9 @@ public class FsCourseWatchLogController extends BaseController
         param.setVideoId(videoId);
         param.setPeriodId(periodId);
         param.setCompanyId(loginUser.getCompany().getCompanyId());
-        param.setCompanyUserId(loginUser.getUser().getUserId());
+        if(!"00".equals(loginUser.getUser().getUserType())){
+            param.setCompanyUserId(loginUser.getUser().getUserId());
+        }
         List<com.fs.course.vo.CourseStatisticsUserDetailVO> list = fsCourseWatchLogService.getCourseStatisticsUserDetailExportList(param);
         ExcelUtil<com.fs.course.vo.CourseStatisticsUserDetailVO> util = new ExcelUtil<>(com.fs.course.vo.CourseStatisticsUserDetailVO.class);
         return util.exportExcel(list, "用户看课数据");

+ 0 - 1
fs-company/src/main/java/com/fs/company/controller/crm/CrmCustomerAnalyzeController.java

@@ -120,7 +120,6 @@ public class CrmCustomerAnalyzeController extends BaseController
      * 话术润色
      */
     @PreAuthorize("@ss.hasPermi('crm:analyze:polishingScript')")
-    @Log(title = "话术润色", businessType = BusinessType.INSERT)
     @PostMapping("/polishingScript")
     public R polishingScript(@RequestBody PolishingScriptParam param)
     {

+ 0 - 7
fs-company/src/main/java/com/fs/company/controller/crm/CrmCustomerController.java

@@ -190,13 +190,6 @@ public class CrmCustomerController extends BaseController
 //        if(loginUser.getCompany().getCompanyId()==116){   // 河北湘银信息咨询服务有限公司(JZ-1)客户假删除不显示
 //            param.setCompanyId(0L);
 //        }
-        //默认值处理
-        if(param.getIntentionDegreeGt() != null && param.getIntentionDegreeGt() == 0){
-            param.setIntentionDegreeGt(null);
-        }
-        if(param.getIntentionDegreelt() != null && param.getIntentionDegreelt() == 0){
-            param.setIntentionDegreelt(null);
-        }
         if (param.getIsReceive() != null && param.getIsReceive() == 0){
             CrmLineCustomerListQueryParam param1 = new CrmLineCustomerListQueryParam();
             BeanUtils.copyProperties(param,param1);

+ 107 - 0
fs-company/src/main/java/com/fs/company/controller/crm/chat/CrmCustomerChatMessageController.java

@@ -0,0 +1,107 @@
+package com.fs.company.controller.crm.chat;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.crm.domain.CrmCustomerChatMessage;
+import com.fs.crm.service.ICrmCustomerChatMessageService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 聊天消息记录 Controller
+ * 
+ * @author ylrz
+ * @date 2026-03-30
+ */
+@RestController
+@RequestMapping("/crm/customer/chat/chatMsg")
+public class CrmCustomerChatMessageController extends BaseController {
+
+    @Autowired
+    private ICrmCustomerChatMessageService chatMessageService;
+    @Autowired
+    private ObjectMapper objectMapper;
+
+    /**
+     * 查询聊天消息记录列表
+     */
+    @GetMapping("/list")
+    public TableDataInfo list(CrmCustomerChatMessage chatMessage) {
+        // 支持游标分页参数
+        Long cursor = chatMessage.getCursor();
+        Integer limit = chatMessage.getLimit();
+        String direction = chatMessage.getDirection();
+
+        List<CrmCustomerChatMessage> list = chatMessageService.selectChatMessagesBySession(
+            chatMessage.getSessionId(), cursor, limit, direction);
+        return getDataTable(list);
+    }
+
+    /**
+     * 批量保存消息
+     */
+    @PostMapping("/batch")
+    public AjaxResult batchSave(@RequestBody Map<String, Object> params) {
+        try {
+            @SuppressWarnings("unchecked")
+            List<Map<String, Object>> messagesData = (List<Map<String, Object>>) params.get("messages");
+            
+            if (messagesData == null || messagesData.isEmpty()) {
+                return AjaxResult.error("消息列表不能为空");
+            }
+            
+            // 转换为 ChatMessage 对象
+            List<CrmCustomerChatMessage> messages = convertToChatMessages(messagesData);
+            
+            int result = chatMessageService.batchSaveChatMessages(messages);
+            return result > 0 ? AjaxResult.success("保存成功") : AjaxResult.error("保存失败");
+        } catch (Exception e) {
+            logger.error("批量保存消息失败", e);
+            return AjaxResult.error("批量保存失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 新增聊天消息记录
+     */
+    @PostMapping
+    public AjaxResult add(@RequestBody CrmCustomerChatMessage chatMessage) {
+        return toAjax(chatMessageService.insertChatMessage(chatMessage));
+    }
+
+    /**
+     * 修改聊天消息记录
+     */
+    @PutMapping
+    public AjaxResult edit(@RequestBody CrmCustomerChatMessage chatMessage) {
+        return toAjax(chatMessageService.updateChatMessage(chatMessage));
+    }
+
+    /**
+     * 删除聊天消息记录
+     */
+    @DeleteMapping("/{msgIds}")
+    public AjaxResult remove(@PathVariable Long[] msgIds) {
+        return toAjax(chatMessageService.deleteChatMessageByIds(msgIds));
+    }
+
+    /**
+     * 将 Map 转换为 ChatMessage 对象
+     */
+    private List<CrmCustomerChatMessage> convertToChatMessages(List<Map<String, Object>> messagesData) {
+        try {
+            // 使用 Jackson ObjectMapper 进行类型转换
+            String json = objectMapper.writeValueAsString(messagesData);
+            return objectMapper.readValue(json, objectMapper.getTypeFactory()
+                    .constructCollectionType(List.class, CrmCustomerChatMessage.class));
+        } catch (Exception e) {
+            logger.error("转换消息数据失败", e);
+            throw new RuntimeException("消息数据转换失败:" + e.getMessage());
+        }
+    }
+}

+ 156 - 0
fs-company/src/main/java/com/fs/company/controller/crm/chat/CrmCustomerChatSessionController.java

@@ -0,0 +1,156 @@
+package com.fs.company.controller.crm.chat;
+
+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.utils.ServletUtils;
+import com.fs.crm.domain.CrmCustomerChatMessage;
+import com.fs.crm.domain.CrmCustomerChatSession;
+import com.fs.crm.service.ICrmCustomerChatMessageService;
+import com.fs.crm.service.ICrmCustomerChatSessionService;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 聊天会话 Controller
+ * 
+ * @author ylrz
+ * @date 2026-03-30
+ */
+@RestController
+@RequestMapping("/crm/customer/chat/chatSession")
+public class CrmCustomerChatSessionController extends BaseController {
+
+    @Autowired
+    private ICrmCustomerChatSessionService chatSessionService;
+    @Autowired
+    private TokenService tokenService;
+    @Autowired
+    private ICrmCustomerChatMessageService chatMessageService;
+
+    /**
+     * 查询聊天会话列表
+     */
+    @GetMapping("/list")
+    public TableDataInfo list(CrmCustomerChatSession chatSession) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        chatSession.setUserId(loginUser.getUser().getUserId());
+        List<CrmCustomerChatSession> list = chatSessionService.selectChatSessionList(chatSession);
+        //根据会话查询对应的消息数据
+        list.forEach(session -> {
+            List<CrmCustomerChatMessage> messageList =chatMessageService.selectChatMessageBySessionIdLimit(session.getSessionId());
+            session.setMessageList(messageList);
+        });
+
+        return getDataTable(list);
+    }
+
+    /**
+     * 获取会话详情
+     */
+    @GetMapping("/{sessionId}")
+    public AjaxResult getInfo(@PathVariable Long sessionId) {
+        return AjaxResult.success(chatSessionService.selectChatSessionById(sessionId));
+    }
+
+    /**
+     * 新增聊天会话
+     */
+    @PostMapping
+    public AjaxResult add(@RequestBody CrmCustomerChatSession chatSession) {
+        // 设置默认值
+        if (chatSession.getTitle() == null || chatSession.getTitle().isEmpty()) {
+            chatSession.setTitle("新会话");
+        }
+
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        chatSession.setUserId(loginUser.getUser().getUserId());
+        int result = chatSessionService.insertChatSession(chatSession);
+        if (result > 0) {
+            return AjaxResult.success(chatSession);
+        }
+        return error("新增失败");
+    }
+
+    /**
+     * 更新会话标题
+     */
+    @PutMapping("/title")
+    public AjaxResult updateTitle(@RequestBody Map<String, Object> params) {
+        try {
+            Long sessionId = Long.valueOf(params.get("sessionId").toString());
+            String title = (String) params.get("title");
+            
+            if (title == null) {
+                return error("参数错误");
+            }
+            
+            int result = chatSessionService.updateChatSessionTitle(sessionId, title);
+            return result > 0 ? success("更新成功") : error("更新失败");
+        } catch (Exception e) {
+            logger.error("更新会话标题失败", e);
+            return error("更新失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 更新置顶状态
+     */
+    @PutMapping("/pin")
+    public AjaxResult updatePin(@RequestBody Map<String, Object> params) {
+        try {
+            Long sessionId = Long.valueOf(params.get("sessionId").toString());
+            Integer isPinned = (Integer) params.get("isPinned");
+            
+            if (isPinned == null) {
+                return error("置顶参数错误");
+            }
+            int result = chatSessionService.updateChatSessionPin(sessionId, isPinned);
+            return result > 0 ? success("置顶成功") : error("置顶失败");
+        } catch (Exception e) {
+            return error("置顶失败:" + e.getMessage());
+        }
+    }
+    /**
+     * 更新客户绑定
+     */
+    @PutMapping("/customer")
+    public AjaxResult updateCustomer(@RequestBody Map<String, Object> params) {
+        try {
+            Long sessionId = Long.valueOf(params.get("sessionId").toString());
+            Long customerId = Long.parseLong(params.get("customerId").toString());
+            int result = chatSessionService.updateChatSessionCustomer(sessionId, customerId);
+            return result > 0 ? success("更新客户绑定成功") : error("更新客户绑定失败");
+        } catch (Exception e) {
+            return error("更新客户绑定失败:" + e.getMessage());
+        }
+    }
+    /**
+     * 修改聊天会话
+     */
+    @PutMapping
+    public AjaxResult edit(@RequestBody CrmCustomerChatSession chatSession) {
+        return toAjax(chatSessionService.updateChatSession(chatSession));
+    }
+
+    /**
+     * 删除聊天会话
+     */
+    @DeleteMapping("/{sessionId}")
+    public AjaxResult remove(@PathVariable Long sessionId) {
+        return toAjax(chatSessionService.deleteChatSessionById(sessionId));
+    }
+
+    /**
+     * 批量删除聊天会话
+     */
+    @DeleteMapping
+    public AjaxResult removeBatch(@RequestBody Long[] sessionIds) {
+        return toAjax(chatSessionService.deleteChatSessionByIds(sessionIds));
+    }
+}

+ 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 看课统计报表");
+    }
+
+
+}

+ 158 - 39
fs-company/src/main/java/com/fs/company/controller/store/FsStoreOrderController.java

@@ -11,6 +11,7 @@ import com.fs.common.core.domain.R;
 import com.fs.common.core.page.TableDataInfo;
 import com.fs.common.enums.BusinessType;
 import com.fs.common.utils.ParseUtils;
+import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.crm.service.ICrmCustomerService;
@@ -19,6 +20,7 @@ import com.fs.erp.dto.ErpOrderQueryResponse;
 import com.fs.erp.service.IErpOrderService;
 import com.fs.framework.security.LoginUser;
 import com.fs.framework.security.SecurityUtils;
+import com.fs.framework.service.TokenService;
 import com.fs.his.config.FsSysConfig;
 import com.fs.his.domain.*;
 import com.fs.his.dto.ExpressInfoDTO;
@@ -44,6 +46,7 @@ import com.fs.hisStore.dto.StoreOrderProductDTO;
 import io.swagger.annotations.ApiOperation;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
@@ -82,6 +85,12 @@ public class FsStoreOrderController extends BaseController
     @Autowired
     private IFsStoreOrderItemScrmService orderItemScrmService;
 
+    @Value("${cloud_host.company_name}")
+    private String signProjectName;
+
+    @Autowired
+    private TokenService tokenService;
+
     /**
      * 查询直播/点播订单列表(fs_store_order_scrm 中 order_type=2 直播订单,order_type=3 点播订单)
      * 如果前端传了 orderType,则按指定类型查询;如果没传(null),则查询所有直播和点播订单(orderType IN (2,3))
@@ -89,7 +98,7 @@ public class FsStoreOrderController extends BaseController
      */
     @PostMapping("/healthLiveList")
     public FsStoreOrderListAndStatisticsVo healthLiveList(@RequestBody com.fs.hisStore.param.FsStoreOrderParam param) {
-        LoginUser loginUser = SecurityUtils.getLoginUser();
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         param.setCompanyId(loginUser.getCompany().getCompanyId());
         // 如果前端传了 orderType,使用前端传的值;如果没传(null),设置 orderType = -1(特殊值,SQL 中会转换为查询 orderType IN (2,3))
         if (param.getOrderType() == null) {
@@ -115,6 +124,7 @@ public class FsStoreOrderController extends BaseController
         Map<String, java.math.BigDecimal> statistics = fsStoreOrderScrmService.selectFsStoreOrderStatistics(param);
         String productInfoStr = fsStoreOrderScrmService.selectFsStoreOrderProductStatistics(param);
         PageHelper.startPage(param.getPageNum(), param.getPageSize());
+        param.setIsCompanyOrder(null);
         List<com.fs.hisStore.vo.FsStoreOrderVO> list = fsStoreOrderScrmService.selectFsStoreOrderListVO(param);
         if (list != null) {
             for (com.fs.hisStore.vo.FsStoreOrderVO vo : list) {
@@ -301,25 +311,95 @@ public class FsStoreOrderController extends BaseController
         if (fsStoreOrderScrmService.isEntityNull(param)) {
             return AjaxResult.error("请筛选数据导出");
         }
+        if(!StringUtils.isEmpty(param.getPayTimeRange())){
+            param.setPayTimeList(param.getPayTimeRange().split("--"));
+        }
+        if(!StringUtils.isEmpty(param.getDeliverySendTimeRange())){
+            param.setDeliverySendTimeList(param.getDeliverySendTimeRange().split("--"));
+        }
+        if(!StringUtils.isEmpty(param.getDeliveryImportTimeRange())){
+            param.setDeliveryImportTimeList(param.getDeliveryImportTimeRange().split("--"));
+        }
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if("北京卓美".equals(signProjectName)){
+            if(!"00".equals(loginUser.getUser().getUserType())){//非管理员看见自己数据
+                param.setCompanyUserId(loginUser.getUser().getUserId());
+            }
+        }
         List<FsStoreOrderItemExportVO> list = orderItemScrmService.selectFsStoreOrderItemListExportVO(param);
+        if ("北京卓美".equals(signProjectName)) {
+            List<com.fs.hisStore.vo.FsStoreOrderItemExportZMVO> zmvoList = list.stream()
+                    .map(vo -> {
+                        com.fs.hisStore.vo.FsStoreOrderItemExportZMVO zmvo = new com.fs.hisStore.vo.FsStoreOrderItemExportZMVO();
+                        try {
+                            BeanUtil.copyProperties(vo, zmvo);
+                        } catch (Exception e) {
+                            e.printStackTrace();
+                        }
+                        return zmvo;
+                    })
+                    .collect(java.util.stream.Collectors.toList());
+            if (zmvoList != null) {
+                for (com.fs.hisStore.vo.FsStoreOrderItemExportZMVO vo : zmvoList) {
+                    if("2".equals(vo.getOrderType())){
+                        vo.setOrderTypeStr("直播订单" );
+                    }else if ("3".equals(vo.getOrderType())){
+                        vo.setOrderTypeStr("点播订单" );
+                    }else{
+                        vo.setOrderTypeStr("商城订单" );
+                    }
+                    if(StringUtils.isNotEmpty(vo.getJsonInfo())){
+                        try {
+                            StoreOrderProductDTO orderProductDTO = com.alibaba.fastjson.JSONObject.parseObject(vo.getJsonInfo(), StoreOrderProductDTO.class);
+                            BeanUtil.copyProperties(orderProductDTO, vo);
+                        } catch (Exception e) {
+                            e.printStackTrace();
+                        }
+                    }
+
+                    if (vo.getUserPhone() != null) {
+                        String phone = vo.getUserPhone().replaceAll("(\\d{3})\\d*(\\d{1})", "$1****$2");
+                        vo.setUserPhone(phone);
+                    }
+                    if (!StringUtils.isEmpty(vo.getJsonInfo())) {
+                        try {
+                            StoreOrderProductDTO orderProductDTO = com.alibaba.fastjson.JSONObject.parseObject(vo.getJsonInfo(), StoreOrderProductDTO.class);
+                            BeanUtil.copyProperties(orderProductDTO, vo);
+                        } catch (Exception e) {
+                        }
+                    }
+                    if ((loginUser.getPermissions().contains("his:storeAfterSales:finance") || loginUser.getPermissions().contains("*:*:*")) && !java.util.Objects.isNull(vo.getCost())) {
+                        vo.setFPrice(vo.getCost().multiply(java.math.BigDecimal.valueOf(vo.getNum())));
+                    } else {
+                        vo.setPayPostage(java.math.BigDecimal.ZERO);
+                        vo.setPayDelivery(java.math.BigDecimal.ZERO);
+                        vo.setCost(java.math.BigDecimal.ZERO);
+                        vo.setFPrice(java.math.BigDecimal.ZERO);
+                        vo.setBarCode("");
+                        vo.setCateName("");
+                        vo.setBankTransactionId("");
+                    }
+                }
+            }
+            ExcelUtil<com.fs.hisStore.vo.FsStoreOrderItemExportZMVO> util = new ExcelUtil<>(com.fs.hisStore.vo.FsStoreOrderItemExportZMVO.class);
+            return util.exportExcel(zmvoList, "订单明细数据");
+        }
+        //对手机号脱敏
         if (list != null) {
-            LoginUser loginUser = SecurityUtils.getLoginUser();
             for (FsStoreOrderItemExportVO vo : list) {
                 if (vo.getUserPhone() != null) {
-                    vo.setUserPhone(vo.getUserPhone().replaceAll("(\\d{3})\\d*(\\d{1})", "$1****$2"));
+                    String phone = vo.getUserPhone().replaceAll("(\\d{3})\\d*(\\d{1})", "$1****$2");
+                    vo.setUserPhone(phone);
                 }
-                if (StringUtils.isNotEmpty(vo.getJsonInfo())) {
+                if (!StringUtils.isEmpty(vo.getJsonInfo())) {
                     try {
-                        StoreOrderProductDTO dto = com.alibaba.fastjson.JSONObject.parseObject(vo.getJsonInfo(), StoreOrderProductDTO.class);
-                        BeanUtil.copyProperties(dto, vo);
+                        StoreOrderProductDTO orderProductDTO = com.alibaba.fastjson.JSONObject.parseObject(vo.getJsonInfo(), StoreOrderProductDTO.class);
+                        BeanUtil.copyProperties(orderProductDTO, vo);
                     } catch (Exception e) {
-                        // ignore
                     }
                 }
-                if (vo.getUserAddress() != null) {
-                    vo.setUserAddress(ParseUtils.parseAddress(vo.getUserAddress()));
-                }
-                if ((loginUser.getPermissions().contains("his:storeAfterSales:finance") || loginUser.getPermissions().contains("*:*:*")) && vo.getCost() != null) {
+                //
+                if ((loginUser.getPermissions().contains("his:storeAfterSales:finance") || loginUser.getPermissions().contains("*:*:*") ) && !java.util.Objects.isNull(vo.getCost())) {
                     vo.setFPrice(vo.getCost().multiply(java.math.BigDecimal.valueOf(vo.getTotalNum())));
                 } else {
                     vo.setPayPostage(java.math.BigDecimal.ZERO);
@@ -329,17 +409,6 @@ public class FsStoreOrderController extends BaseController
                     vo.setCateName("");
                     vo.setBankTransactionId("");
                 }
-                // 设置订单类型中文显示
-                if (vo.getOrderType() != null) {
-                    String orderTypeStr = vo.getOrderType().toString();
-                    if ("2".equals(orderTypeStr)) {
-                        vo.setOrderType("直播订单");
-                    } else if ("3".equals(orderTypeStr)) {
-                        vo.setOrderType("点播订单");
-                    } else {
-                        vo.setOrderType("商城订单");
-                    }
-                }
             }
         }
         ExcelUtil<FsStoreOrderItemExportVO> util = new ExcelUtil<>(FsStoreOrderItemExportVO.class);
@@ -357,19 +426,80 @@ public class FsStoreOrderController extends BaseController
         if (fsStoreOrderScrmService.isEntityNull(param)) {
             return AjaxResult.error("请筛选数据导出");
         }
+        if(!StringUtils.isEmpty(param.getPayTimeRange())){
+            param.setPayTimeList(param.getPayTimeRange().split("--"));
+        }
+        if(!StringUtils.isEmpty(param.getDeliverySendTimeRange())){
+            param.setDeliverySendTimeList(param.getDeliverySendTimeRange().split("--"));
+        }
+        if(!StringUtils.isEmpty(param.getDeliveryImportTimeRange())){
+            param.setDeliveryImportTimeList(param.getDeliveryImportTimeRange().split("--"));
+        }
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if("北京卓美".equals(signProjectName)){
+            if(!"00".equals(loginUser.getUser().getUserType())){//非管理员看见自己数据
+                param.setCompanyUserId(loginUser.getUser().getUserId());
+            }
+        }
         List<FsStoreOrderItemExportVO> list = orderItemScrmService.selectFsStoreOrderItemListExportVO(param);
+        // 北京卓美项目特殊处理
+        if("北京卓美".equals(signProjectName)){
+            List<com.fs.hisStore.vo.FsStoreOrderItemExportZMVO> zmvoList = list.stream()
+                    .map(vo -> {
+                        com.fs.hisStore.vo.FsStoreOrderItemExportZMVO zmvo = new com.fs.hisStore.vo.FsStoreOrderItemExportZMVO();
+                        try {
+                            BeanUtil.copyProperties( vo,zmvo);
+                        } catch (Exception e) {
+                            // 处理异常
+                            e.printStackTrace();
+                        }
+                        return zmvo;
+                    })
+                    .collect(java.util.stream.Collectors.toList());
+            //对手机号脱敏
+            if (zmvoList != null) {
+                    for (com.fs.hisStore.vo.FsStoreOrderItemExportZMVO vo : zmvoList) {
+                        if ("2".equals(vo.getOrderType())) {
+                            vo.setOrderTypeStr("直播订单");
+                        }else if ("3".equals(vo.getOrderType())){
+                            vo.setOrderTypeStr("点播订单" );
+                        }else {
+                            vo.setOrderTypeStr("商城订单");
+                        }
+                        if (!StringUtils.isEmpty(vo.getJsonInfo())) {
+                            try {
+                                StoreOrderProductDTO orderProductDTO = com.alibaba.fastjson.JSONObject.parseObject(vo.getJsonInfo(), StoreOrderProductDTO.class);
+                                BeanUtil.copyProperties(orderProductDTO, vo);
+                            } catch (Exception e) {
+                            }
+                        }
+                        if ((loginUser.getPermissions().contains("his:storeAfterSales:finance") || loginUser.getPermissions().contains("*:*:*") ) && vo.getCost() != null) {
+                            vo.setFPrice(vo.getCost().multiply(java.math.BigDecimal.valueOf(vo.getNum())));
+                        } else {
+                            vo.setPayPostage(java.math.BigDecimal.ZERO);
+                            vo.setPayDelivery(java.math.BigDecimal.ZERO);
+                            vo.setCost(java.math.BigDecimal.ZERO);
+                            vo.setFPrice(java.math.BigDecimal.ZERO);
+                            vo.setBarCode("");
+                            vo.setCateName("");
+                            vo.setBankTransactionId("");
+                        }
+                    }
+                }
+                ExcelUtil<com.fs.hisStore.vo.FsStoreOrderItemExportZMVO> util = new ExcelUtil<>(com.fs.hisStore.vo.FsStoreOrderItemExportZMVO.class);
+                return util.exportExcel(zmvoList, "订单明细数据");
+        }
+        //对手机号脱敏
         if (list != null) {
-            LoginUser loginUser = SecurityUtils.getLoginUser();
             for (FsStoreOrderItemExportVO vo : list) {
-                if (StringUtils.isNotEmpty(vo.getJsonInfo())) {
+                if (!StringUtils.isEmpty(vo.getJsonInfo())) {
                     try {
-                        StoreOrderProductDTO dto = com.alibaba.fastjson.JSONObject.parseObject(vo.getJsonInfo(), StoreOrderProductDTO.class);
-                        BeanUtil.copyProperties(dto, vo);
+                        StoreOrderProductDTO orderProductDTO = com.alibaba.fastjson.JSONObject.parseObject(vo.getJsonInfo(), StoreOrderProductDTO.class);
+                        BeanUtil.copyProperties(orderProductDTO, vo);
                     } catch (Exception e) {
-                        // ignore
                     }
                 }
-                if ((loginUser.getPermissions().contains("his:storeAfterSales:finance") || loginUser.getPermissions().contains("*:*:*")) && vo.getCost() != null) {
+                if ((loginUser.getPermissions().contains("his:storeAfterSales:finance") || loginUser.getPermissions().contains("*:*:*") ) && vo.getCost() != null) {
                     vo.setFPrice(vo.getCost().multiply(java.math.BigDecimal.valueOf(vo.getTotalNum())));
                 } else {
                     vo.setPayPostage(java.math.BigDecimal.ZERO);
@@ -379,17 +509,6 @@ public class FsStoreOrderController extends BaseController
                     vo.setCateName("");
                     vo.setBankTransactionId("");
                 }
-                // 设置订单类型中文显示
-                if (vo.getOrderType() != null) {
-                    String orderTypeStr = vo.getOrderType().toString();
-                    if ("2".equals(orderTypeStr)) {
-                        vo.setOrderType("直播订单");
-                    } else if ("3".equals(orderTypeStr)) {
-                        vo.setOrderType("点播订单");
-                    } else {
-                        vo.setOrderType("商城订单");
-                    }
-                }
             }
         }
         ExcelUtil<FsStoreOrderItemExportVO> util = new ExcelUtil<>(FsStoreOrderItemExportVO.class);

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

@@ -129,6 +129,15 @@ public class FsStoreOrderScrmController extends BaseController
             }
         }
         startPage();
+
+        if("北京卓美".equals(cloudHostProper.getCompanyName())){
+            if(!"00".equals(loginUser.getUser().getUserType())){//非管理员看见自己数据
+                param.setCompanyUserId(loginUser.getUser().getUserId());
+            }
+            param.setIsCompanyOrder(1);//是否销售订单
+        }
+
+
         if(!StringUtils.isEmpty(param.getCreateTimeRange())){
             param.setCreateTimeList(param.getCreateTimeRange().split("--"));
         }
@@ -206,6 +215,12 @@ public class FsStoreOrderScrmController extends BaseController
         if(!StringUtils.isEmpty(param.getDeliveryImportTimeRange())){
             param.setDeliveryImportTimeList(param.getDeliveryImportTimeRange().split("--"));
         }
+        if("北京卓美".equals(cloudHostProper.getCompanyName())){
+            if(!"00".equals(loginUser.getUser().getUserType())){//非管理员看见自己数据
+                param.setCompanyUserId(loginUser.getUser().getUserId());
+            }
+            param.setIsCompanyOrder(1);//是否销售订单
+        }
         List<FsStoreOrderErpExportVO> list = fsStoreOrderService.selectFsStoreOrderListVOByExport(param);
         //对手机号脱敏
         if(list!=null){
@@ -279,7 +294,9 @@ public class FsStoreOrderScrmController extends BaseController
         }
 
         FsUser user=userService.selectFsUserById(order.getUserId());
-        user.setPhone(ParseUtils.parsePhone(user.getPhone()));
+        if(user != null && StringUtils.isNotEmpty(user.getPhone())){
+            user.setPhone(ParseUtils.parsePhone(user.getPhone()));
+        }
         FsStoreOrderItemScrm itemMap=new FsStoreOrderItemScrm();
         itemMap.setOrderId(order.getId());
         List<FsStoreOrderItemScrm> items=orderItemService.selectFsStoreOrderItemList(itemMap);
@@ -502,7 +519,12 @@ public class FsStoreOrderScrmController extends BaseController
         if(!StringUtils.isEmpty(param.getDeliveryImportTimeRange())){
             param.setDeliveryImportTimeList(param.getDeliveryImportTimeRange().split("--"));
         }
-
+        if("北京卓美".equals(cloudHostProper.getCompanyName())){
+            if(!"00".equals(loginUser.getUser().getUserType())){//非管理员看见自己数据
+                param.setCompanyUserId(loginUser.getUser().getUserId());
+            }
+            param.setIsCompanyOrder(1);//是否销售订单
+        }
         List<FsStoreOrderItemExportVO> list=orderItemService.selectFsStoreOrderItemListExportVO(param);
         //对手机号脱敏
         if(list!=null){

+ 38 - 0
fs-service/src/main/java/com/fs/aicall/domain/CcTtsAliyun.java

@@ -0,0 +1,38 @@
+package com.fs.aicall.domain;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+
+/**
+ * 音色表对象 cc_tts_aliyun(EASYCALL 数据源)
+ *
+ * @author fs
+ */
+@Data
+@Accessors(chain = true)
+public class CcTtsAliyun implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /** 主键id */
+    private Integer id;
+
+    /** tts发音人名称 */
+    private String voiceName;
+
+    /** tts发音人代码(speaker_id) */
+    private String voiceCode;
+
+    /** 是否启用 */
+    private Integer voiceEnabled;
+
+    /** 声音源: aliyun_tts、doubao_vcl_tts 等 */
+    private String voiceSource;
+
+    /** 优先级 */
+    private Integer priority;
+
+    /** 供应商: aliyun、doubao 等 */
+    private String provider;
+}

+ 57 - 0
fs-service/src/main/java/com/fs/aicall/mapper/CcTtsAliyunMapper.java

@@ -0,0 +1,57 @@
+package com.fs.aicall.mapper;
+
+import com.fs.aicall.domain.CcTtsAliyun;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
+
+import java.util.List;
+
+/**
+ * 音色表 Mapper 接口(EASYCALL 数据源)
+ *
+ * @author fs
+ */
+public interface CcTtsAliyunMapper {
+
+    /**
+     * 根据主键查询音色
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    CcTtsAliyun selectCcTtsAliyunById(Integer id);
+
+    /**
+     * 根据音色代码查询
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    CcTtsAliyun selectCcTtsAliyunByVoiceCode(String voiceCode);
+
+    /**
+     * 查询音色列表
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    List<CcTtsAliyun> selectCcTtsAliyunList(CcTtsAliyun ccTtsAliyun);
+
+    /**
+     * 新增音色
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    int insertCcTtsAliyun(CcTtsAliyun ccTtsAliyun);
+
+    /**
+     * 修改音色
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    int updateCcTtsAliyun(CcTtsAliyun ccTtsAliyun);
+
+    /**
+     * 删除音色
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    int deleteCcTtsAliyunById(Integer id);
+
+    /**
+     * 批量删除音色
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    int deleteCcTtsAliyunByIds(String ids);
+}

+ 48 - 0
fs-service/src/main/java/com/fs/aicall/service/ICcTtsAliyunService.java

@@ -0,0 +1,48 @@
+package com.fs.aicall.service;
+
+import com.fs.aicall.domain.CcTtsAliyun;
+
+import java.util.List;
+
+/**
+ * 音色表 Service 接口(EASYCALL 数据源)
+ *
+ * @author fs
+ */
+public interface ICcTtsAliyunService {
+
+    /**
+     * 根据主键查询音色
+     */
+    CcTtsAliyun selectCcTtsAliyunById(Integer id);
+
+    /**
+     * 根据音色代码查询
+     */
+    CcTtsAliyun selectCcTtsAliyunByVoiceCode(String voiceCode);
+
+    /**
+     * 查询音色列表
+     */
+    List<CcTtsAliyun> selectCcTtsAliyunList(CcTtsAliyun ccTtsAliyun);
+
+    /**
+     * 新增音色
+     */
+    int insertCcTtsAliyun(CcTtsAliyun ccTtsAliyun);
+
+    /**
+     * 修改音色
+     */
+    int updateCcTtsAliyun(CcTtsAliyun ccTtsAliyun);
+
+    /**
+     * 删除音色
+     */
+    int deleteCcTtsAliyunById(Integer id);
+
+    /**
+     * 批量删除音色
+     */
+    int deleteCcTtsAliyunByIds(String ids);
+}

+ 56 - 0
fs-service/src/main/java/com/fs/aicall/service/impl/CcTtsAliyunServiceImpl.java

@@ -0,0 +1,56 @@
+package com.fs.aicall.service.impl;
+
+import com.fs.aicall.domain.CcTtsAliyun;
+import com.fs.aicall.mapper.CcTtsAliyunMapper;
+import com.fs.aicall.service.ICcTtsAliyunService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * 音色表 Service 实现(EASYCALL 数据源)
+ *
+ * @author fs
+ */
+@Service
+public class CcTtsAliyunServiceImpl implements ICcTtsAliyunService {
+
+    @Autowired
+    private CcTtsAliyunMapper ccTtsAliyunMapper;
+
+    @Override
+    public CcTtsAliyun selectCcTtsAliyunById(Integer id) {
+        return ccTtsAliyunMapper.selectCcTtsAliyunById(id);
+    }
+
+    @Override
+    public CcTtsAliyun selectCcTtsAliyunByVoiceCode(String voiceCode) {
+        return ccTtsAliyunMapper.selectCcTtsAliyunByVoiceCode(voiceCode);
+    }
+
+    @Override
+    public List<CcTtsAliyun> selectCcTtsAliyunList(CcTtsAliyun ccTtsAliyun) {
+        return ccTtsAliyunMapper.selectCcTtsAliyunList(ccTtsAliyun);
+    }
+
+    @Override
+    public int insertCcTtsAliyun(CcTtsAliyun ccTtsAliyun) {
+        return ccTtsAliyunMapper.insertCcTtsAliyun(ccTtsAliyun);
+    }
+
+    @Override
+    public int updateCcTtsAliyun(CcTtsAliyun ccTtsAliyun) {
+        return ccTtsAliyunMapper.updateCcTtsAliyun(ccTtsAliyun);
+    }
+
+    @Override
+    public int deleteCcTtsAliyunById(Integer id) {
+        return ccTtsAliyunMapper.deleteCcTtsAliyunById(id);
+    }
+
+    @Override
+    public int deleteCcTtsAliyunByIds(String ids) {
+        return ccTtsAliyunMapper.deleteCcTtsAliyunByIds(ids);
+    }
+}

+ 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/company/mapper/CompanyVoiceRoboticMapper.java

@@ -82,5 +82,7 @@ public interface CompanyVoiceRoboticMapper extends BaseMapper<CompanyVoiceRoboti
 
     List<DictVO> getDictDataList(@Param("dictType") String dictType);
 
+    DictVO getDictDataByTypeAndValue(@Param("value") String val, @Param("dictType") String dictType);
+
     List<CompanyVoiceRobotic> selectSceneTaskByCompanyIdAndType(@Param("companyId") Long companyId, @Param("sceneType") Integer sceneType);
 }

+ 2 - 0
fs-service/src/main/java/com/fs/company/param/EntryCustomerParam.java

@@ -165,4 +165,6 @@ public class EntryCustomerParam {
     //投流id
     private String traceId;
 
+    private String remark;
+
 }

+ 36 - 0
fs-service/src/main/java/com/fs/company/service/ICompanyVoiceCloneService.java

@@ -0,0 +1,36 @@
+package com.fs.company.service;
+
+import com.fs.common.core.domain.AjaxResult;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * 豆包声音克隆 Service 接口
+ *
+ * @author fs
+ */
+public interface ICompanyVoiceCloneService {
+
+    /**
+     * 上传音频并训练声音克隆音色
+     *
+     * @param voiceName 音色名称
+     * @param speakerId 声音ID
+     * @param language  语种 (0-中文, 1-英文...)
+     * @param modelType 模型类型 (1-ICL1.0, 2-DiT标准版, 3-DiT还原版, 4-ICL2.0)
+     * @param file      音频文件
+     * @return 操作结果
+     */
+    AjaxResult uploadAndTrain(String voiceName, String speakerId,
+                              Integer language, Integer modelType,
+                              MultipartFile file);
+
+    /**
+     * TTS 语音合成测试
+     *
+     * @param speakerId 声音ID
+     * @param language  语种
+     * @param text      要合成的文本
+     * @return 操作结果(含 base64 音频数据)
+     */
+    AjaxResult doubaoTtsTest(String speakerId, Integer language, String text);
+}

+ 1 - 1
fs-service/src/main/java/com/fs/company/service/IGeneralCustomerEntryService.java

@@ -12,5 +12,5 @@ public interface IGeneralCustomerEntryService {
 
 //    R entryCustomer(String param);
 
-    String entryCustomer(EntryCustomerParam param);
+    void entryCustomer(EntryCustomerParam param);
 }

+ 391 - 0
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceCloneServiceImpl.java

@@ -0,0 +1,391 @@
+package com.fs.company.service.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.aicall.domain.CcTtsAliyun;
+import com.fs.aicall.service.ICcParamsService;
+import com.fs.aicall.service.ICcTtsAliyunService;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.service.ICompanyVoiceCloneService;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.*;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 豆包声音克隆 Service 实现
+ * <p>
+ * 参照 DoubaoVclController 实现:
+ * 1. uploadAndTrain: 从 EASYCALL 读取账号,HTTP 调用豆包训练接口,成功后写入音色表
+ * 2. doubaoTtsTest: HTTP 调用豆包 TTS 接口,返回 base64 音频数据
+ * </p>
+ *
+ * @author fs
+ */
+@Service
+@Slf4j
+public class CompanyVoiceCloneServiceImpl implements ICompanyVoiceCloneService {
+
+    private static final String HOST = "https://openspeech.bytedance.com";
+    private static final String TRAIN_URL = HOST + "/api/v1/mega_tts/audio/upload";
+    private static final String STATUS_URL = HOST + "/api/v1/mega_tts/status";
+    private static final String TTS_URL = HOST + "/api/v1/tts";
+
+    private static final String DOUBAO_ACCOUNT_PARAM_CODE = "doubao-tts-account-json";
+
+    private static final MediaType JSON_MEDIA_TYPE = MediaType.parse("application/json; charset=utf-8");
+
+    /** 错误码映射 */
+    private static final Map<Integer, String> ERROR_MAP = new HashMap<Integer, String>() {{
+        put(1001, "BadRequestError: 请求参数有误");
+        put(1101, "AudioUploadError: 音频上传失败");
+        put(1102, "ASRError: ASR(语音识别成文字)转写失败");
+        put(1103, "SIDError: SID声纹检测失败");
+        put(1104, "SIDFailError: 声纹检测未通过,声纹跟名人相似度过高");
+        put(1105, "GetAudioDataError: 获取音频数据失败");
+        put(1106, "SpeakerIDDuplicationError: SpeakerID重复");
+        put(1107, "SpeakerIDNotFoundError: SpeakerID未找到");
+        put(1108, "AudioConvertError: 音频转码失败");
+        put(1109, "WERError: wer检测错误,上传音频与请求携带文本对比字错率过高");
+        put(1111, "AEEDerror: aed检测错误,通常由于音频不包含说话声");
+        put(1112, "SNRError: SNR检测错误,通常由于信噪比过高");
+        put(1113, "DenoiseError: 降噪处理失败");
+        put(1114, "AudioQualityError: 音频质量低,降噪失败");
+        put(1122, "ASRNoSpeakerError: 未检测到人声");
+        put(1123, "MaxTrainNumLimitReached: 上传接口已经达到次数限制,目前同一个音色支持10次上传");
+        put(403, "ParameterError: 参数错误:请检查 声音ID、app_id、access_token 是否正确、检查 声音ID 是否和模型类型匹配?(也可能没有开通相关服务)");
+    }};
+
+    @Autowired
+    private ICcParamsService ccParamsService;
+
+    @Autowired
+    private ICcTtsAliyunService ccTtsAliyunService;
+
+    /**
+     * 上传并训练
+     * @param voiceName 音色名称
+     * @param speakerId 声音ID
+     * @param language  语种 (0-中文, 1-英文...)
+     * @param modelType 模型类型 (1-ICL1.0, 2-DiT标准版, 3-DiT还原版, 4-ICL2.0)
+     * @param file      音频文件
+     * @return
+     */
+    @Override
+    public AjaxResult uploadAndTrain(String voiceName, String speakerId,
+                                     Integer language, Integer modelType,
+                                     MultipartFile file) {
+        if (file == null || file.isEmpty()) {
+            return AjaxResult.error("请选择音频文件");
+        }
+        if (StringUtils.isEmpty(voiceName)) {
+            return AjaxResult.error("请填写音色名称");
+        }
+        if (StringUtils.isEmpty(speakerId)) {
+            return AjaxResult.error("请填写声音ID");
+        }
+
+        // 从 EASYCALL 数据源读取豆包账号配置
+        String ttsAccountJson = ccParamsService.getParamValueByCode(DOUBAO_ACCOUNT_PARAM_CODE, "{}");
+        JSONObject paramValues = JSONObject.parseObject(ttsAccountJson);
+        String appid = paramValues.getString("app_id");
+        String token = paramValues.getString("access_token");
+
+        if (StringUtils.isEmpty(appid) || StringUtils.isEmpty(token)) {
+            return AjaxResult.error("豆包账号未配置,请在系统参数中设置 " + DOUBAO_ACCOUNT_PARAM_CODE);
+        }
+
+        try {
+            // 获取音频格式和字节数据
+            String originalFilename = file.getOriginalFilename();
+            int strLen = originalFilename != null ? originalFilename.length() : 0;
+            String audioFormat = strLen > 3 ? originalFilename.substring(strLen - 3, strLen) : "wav";
+            byte[] audioBytes = file.getBytes();
+
+            // 调用豆包训练接口
+            String response = train(appid, token, audioFormat, audioBytes, speakerId,
+                    String.valueOf(language != null ? language : 0),
+                    String.valueOf(modelType != null ? modelType : 2));
+
+            if (!StringUtils.isEmpty(response)) {
+                JSONObject jsonObject = JSON.parseObject(response);
+                JSONObject baseResp = jsonObject.getJSONObject("BaseResp");
+                if (baseResp == null) {
+                    return AjaxResult.error("响应格式异常:" + response);
+                }
+                int statusCode = baseResp.getInteger("StatusCode");
+
+                if (statusCode == 0) {
+                    // 训练成功,写入音色表
+                    addSpeakerId(voiceName, speakerId);
+
+                    // 查询训练状态
+                    boolean modelReady = getStatus(appid, token, speakerId);
+                    String tips = modelReady ? "模型已就绪" : "模型训练中,请稍后查询状态";
+                    return AjaxResult.success("上传训练成功!\n" + tips);
+                } else {
+                    String errDetails = baseResp.getString("StatusMessage");
+                    String errMsg = ERROR_MAP.get(statusCode);
+                    if (!StringUtils.isEmpty(errMsg)) {
+                        return AjaxResult.error(errMsg + "\n" + errDetails);
+                    } else {
+                        return AjaxResult.error("未知错误!\n" + response);
+                    }
+                }
+            } else {
+                return AjaxResult.error("响应为空!");
+            }
+        } catch (IOException e) {
+            log.error("声音克隆上传训练异常,speakerId={}", speakerId, e);
+            return AjaxResult.error("服务器内部错误:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 调用豆包训练接口
+     * @param appid
+     * @param token
+     * @param audioFormat
+     * @param audioBytes
+     * @param spkId
+     * @param language
+     * @param modelType
+     * @return
+     * @throws IOException
+     */
+    private String train(String appid, String token, String audioFormat, byte[] audioBytes,
+                         String spkId, String language, String modelType) throws IOException {
+        String result;
+        OkHttpClient client = createHttpClient();
+
+        // 编码音频文件
+        String encodedData = Base64.getEncoder().encodeToString(audioBytes);
+
+        JSONArray audios = new JSONArray();
+        JSONObject audioObj = new JSONObject();
+        audioObj.put("audio_bytes", encodedData);
+        audioObj.put("audio_format", audioFormat);
+        audios.add(audioObj);
+
+        // 构建请求体
+        JSONObject requestBody = new JSONObject();
+        requestBody.put("appid", appid);
+        requestBody.put("speaker_id", spkId);
+        requestBody.put("audios", audios);
+        requestBody.put("source", 2);
+        requestBody.put("language", Integer.parseInt(language));
+        requestBody.put("model_type", Integer.parseInt(modelType));
+
+        RequestBody body = RequestBody.create(JSON_MEDIA_TYPE, requestBody.toString());
+
+        // 根据 modelType 决定 Resource-Id
+        String resourceId = "seed-icl-1.0";
+        if (Integer.parseInt(modelType) == 4) {
+            resourceId = "seed-icl-2.0";
+        }
+
+        Request request = new Request.Builder()
+                .url(TRAIN_URL)
+                .post(body)
+                .addHeader("Content-Type", "application/json")
+                .addHeader("Authorization", "Bearer;" + token)
+                .addHeader("Resource-Id", resourceId)
+                .build();
+
+        try (Response response = client.newCall(request).execute()) {
+            log.info("doubaovcl train http response status code {}", response.code());
+            String responseStr = response.body().string();
+            log.info("doubaovcl train response: {}", responseStr);
+
+            if (response.code() == 403) {
+                result = "{\"BaseResp\":{\"StatusCode\": 403 }}";
+            } else {
+                result = responseStr;
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 查询训练状态
+     * @param appid
+     * @param token
+     * @param spkId
+     * @return
+     */
+    private boolean getStatus(String appid, String token, String spkId) {
+        try {
+            OkHttpClient client = createHttpClient();
+            JSONObject requestBody = new JSONObject();
+            requestBody.put("appid", appid);
+            requestBody.put("speaker_id", spkId);
+
+            RequestBody body = RequestBody.create(JSON_MEDIA_TYPE, requestBody.toString());
+
+            Request request = new Request.Builder()
+                    .url(STATUS_URL)
+                    .post(body)
+                    .addHeader("Content-Type", "application/json")
+                    .addHeader("Authorization", "Bearer;" + token)
+                    .addHeader("Resource-Id", "volc.megatts.voiceclone")
+                    .build();
+
+            try (Response response = client.newCall(request).execute()) {
+                if (response.isSuccessful()) {
+                    String json = response.body().string();
+                    log.info("doubaovcl getStatus response: {}", json);
+                    JSONObject jsonObject = JSON.parseObject(json);
+                    Integer status = jsonObject.getInteger("status");
+                    return status != null && (status == 2 || status == 4);
+                }
+            }
+        } catch (Exception e) {
+            log.warn("查询训练状态异常:{}", e.getMessage());
+        }
+        return false;
+    }
+
+    /**
+     * 新增/更新音色到 EASYCALL 数据库
+     * @param nameParam
+     * @param speakerId
+     */
+    private synchronized void addSpeakerId(String nameParam, String speakerId) {
+        CcTtsAliyun ttsSpeaker = ccTtsAliyunService.selectCcTtsAliyunByVoiceCode(speakerId);
+        String name = nameParam.replace("'", "").replace(" ", "");
+        if (name.length() > 20) {
+            name = name.substring(0, 20);
+        }
+
+        boolean update = false;
+        if (ttsSpeaker == null) {
+            ttsSpeaker = new CcTtsAliyun();
+        } else {
+            update = true;
+        }
+
+        ttsSpeaker.setVoiceCode(speakerId);
+        ttsSpeaker.setVoiceEnabled(1);
+        ttsSpeaker.setVoiceName(String.format("[%s] - %s", speakerId, name));
+        ttsSpeaker.setVoiceSource("doubao_vcl_tts");
+        ttsSpeaker.setPriority(0);
+        ttsSpeaker.setProvider("doubao");
+
+        try {
+            if (update) {
+                ccTtsAliyunService.updateCcTtsAliyun(ttsSpeaker);
+            } else {
+                ccTtsAliyunService.insertCcTtsAliyun(ttsSpeaker);
+            }
+            log.info("save doubaovcl speakerId succeed. {} {}", name, speakerId);
+        } catch (Exception e) {
+            log.error("save doubaovcl speakerId error: {}", e.getMessage(), e);
+        }
+    }
+
+    /**
+     * TTS 测试
+     * @param speakerId 声音ID
+     * @param language  语种
+     * @param text      要合成的文本
+     * @return
+     */
+
+    @Override
+    public AjaxResult doubaoTtsTest(String speakerId, Integer language, String text) {
+        if (StringUtils.isEmpty(speakerId)) {
+            return AjaxResult.error("请填写声音ID");
+        }
+        if (StringUtils.isEmpty(text)) {
+            return AjaxResult.error("请输入测试文本");
+        }
+
+        // 从 EASYCALL 数据源读取豆包账号配置
+        String ttsAccountJson = ccParamsService.getParamValueByCode(DOUBAO_ACCOUNT_PARAM_CODE, "{}");
+        JSONObject paramValues = JSONObject.parseObject(ttsAccountJson);
+        String appid = paramValues.getString("app_id");
+        String token = paramValues.getString("access_token");
+
+        if (StringUtils.isEmpty(appid) || StringUtils.isEmpty(token)) {
+            return AjaxResult.error("豆包账号未配置,请在系统参数中设置 " + DOUBAO_ACCOUNT_PARAM_CODE);
+        }
+
+        try {
+            // 构建请求体(与 DoubaoVclController 保持一致)
+            Map<String, Object> requestBody = buildTtsRequestBody(appid, token, speakerId, language, text);
+            String reqBodyJson = JSON.toJSONString(requestBody);
+            log.info("doubaovcl tts request: {}", reqBodyJson);
+
+            OkHttpClient client = createHttpClient();
+            RequestBody body = RequestBody.create(JSON_MEDIA_TYPE, reqBodyJson);
+            Request ttsRequest = new Request.Builder()
+                    .url(TTS_URL)
+                    .header("Authorization", "Bearer;" + token)
+                    .post(body)
+                    .build();
+
+            try (Response response = client.newCall(ttsRequest).execute()) {
+                String respStr = response.body().string();
+                if (!response.isSuccessful()) {
+                    log.info("doubaovcl tts response: {}", respStr);
+                    return AjaxResult.error("语音合成失败!\n网络繁忙,请稍后重试");
+                }
+                return AjaxResult.success("tts request success.", respStr);
+            }
+        } catch (IOException e) {
+            log.error("TTS 测试异常,speakerId={}", speakerId, e);
+            return AjaxResult.error("TTS 合成失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 构建 TTS 请求体
+     */
+    private Map<String, Object> buildTtsRequestBody(String appid, String token,
+                                                     String speakerId, Integer language, String text) {
+        Map<String, Object> app = new HashMap<>();
+        app.put("appid", appid);
+        app.put("cluster", "volcano_icl");
+
+        Map<String, Object> user = new HashMap<>();
+        user.put("uid", "uid");
+
+        Map<String, Object> audio = new HashMap<>();
+        audio.put("encoding", "mp3");
+        audio.put("voice_type", speakerId);
+        audio.put("language", String.valueOf(language != null ? language : 0));
+
+        Map<String, Object> request = new HashMap<>();
+        request.put("reqid", UUID.randomUUID().toString());
+        request.put("operation", "query");
+        request.put("text", text);
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("app", app);
+        body.put("user", user);
+        body.put("audio", audio);
+        body.put("request", request);
+        return body;
+    }
+
+    /**
+     * 创建带超时配置的 OkHttpClient
+     */
+    private OkHttpClient createHttpClient() {
+        return new OkHttpClient.Builder()
+                .connectTimeout(30, TimeUnit.SECONDS)
+                .readTimeout(60, TimeUnit.SECONDS)
+                .writeTimeout(60, TimeUnit.SECONDS)
+                .build();
+    }
+}

+ 6 - 1
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java

@@ -832,6 +832,11 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
             return;
         }
         log.info("进入easyCall外呼结果查询结果callPhoneRes:{}", JSON.toJSONString(callPhoneRes));
+        //不处理非ai外呼回调请求
+        if(StringUtils.isBlank(callPhoneRes.getBizJson())){
+            log.error("easyCall外呼回调信息非调用:{}", JSON.toJSONString(result));
+            return;
+        }
         // intent(意向度)由对方异步评估写入,回调时可能尚未赋值,进入延迟重试队列等待
         if (StringUtils.isBlank(callPhoneRes.getIntent())) {
             String retryKey = EASYCALL_INTENT_RETRY_KEY + result.getUuid();
@@ -1260,8 +1265,8 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
         } else if (Integer.valueOf(1).equals(companyVoiceRobotic.getAddType())) {
             String intention = crmCustomer.getIntention();
             String queryIntention = intention;
-            List<SysDictData> customerIntentionLevel = sysDictTypeService.selectDictDataByType("customer_intention_level");
             if (!isPositiveInteger(intention)) {
+                List<SysDictData> customerIntentionLevel = sysDictTypeService.selectDictDataByType("customer_intention_level");
                 Optional<SysDictData> firstDict = customerIntentionLevel.stream().filter(e -> e.getDictLabel().equals(intention)).findFirst();
                 if (firstDict.isPresent()) {
                     SysDictData sysDictData = firstDict.get();

Diferenças do arquivo suprimidas por serem muito extensas
+ 30 - 11
fs-service/src/main/java/com/fs/company/service/impl/GeneralCustomerEntryServiceImpl.java


+ 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;
 }

+ 20 - 0
fs-service/src/main/java/com/fs/course/param/FsUserCourseAppListParam.java

@@ -0,0 +1,20 @@
+package com.fs.course.param;
+
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+
+@Data
+public class FsUserCourseAppListParam implements Serializable {
+    private Long userId;//用户id;
+
+    // 记录类型 1看课中 2完课 3待看课 4看课中断
+    private String logType;
+
+    @ApiModelProperty(value = "页码,默认为1")
+    private Integer pageNum =1;
+    @ApiModelProperty(value = "页大小,默认为10")
+    private Integer pageSize = 10;
+
+}

+ 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);
 }

+ 542 - 6
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) {
@@ -1886,13 +1895,13 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
                 List<FsStoreOrderItemVO> items = fsStoreOrderItemScrmMapper.selectFsStoreOrderItemListByOrderId(order.getId());
                 if (items == null || items.isEmpty()) continue;
                 long totalNum = order.getTotalNum() != null && order.getTotalNum() > 0 ? order.getTotalNum() : 1;
-                BigDecimal orderPayPrice = order.getPayPrice() != null ? order.getPayPrice() : BigDecimal.ZERO;
+//                BigDecimal orderPayPrice = order.getPayPrice() != null ? order.getPayPrice() : BigDecimal.ZERO;
 
                 for (FsStoreOrderItemVO item : items) {
                     FsStoreCartDTO cartDTO = JSONUtil.toBean(item.getJsonInfo(), FsStoreCartDTO.class);
                     if (item.getProductId() == null) continue;
                     long itemNum = item.getNum() != null ? item.getNum() : 0;
-                    BigDecimal itemAmount = totalNum > 0 ? orderPayPrice.multiply(BigDecimal.valueOf(itemNum)).divide(BigDecimal.valueOf(totalNum), 2, RoundingMode.HALF_UP) : BigDecimal.ZERO;
+                    BigDecimal itemAmount = totalNum > 0 ? cartDTO.getPrice().multiply(BigDecimal.valueOf(itemNum)) : BigDecimal.ZERO;
                     CourseProductSalesVO productSales = productSalesMap.computeIfAbsent(item.getProductId(), k -> {
                         CourseProductSalesVO pvo = new CourseProductSalesVO();
                         pvo.setProductName(cartDTO.getProductName());
@@ -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;
+
+}

+ 2 - 1
fs-service/src/main/java/com/fs/crm/domain/CrmCustomerAnalyze.java

@@ -65,5 +65,6 @@ public class CrmCustomerAnalyze extends BaseEntity{
     /** 预留字符串型字段 */
     @Excel(name = "预留字符串型字段")
     private String reserveStr;
-
+    /** 客户集合 */
+    private Long[] customerIds;
 }

+ 58 - 0
fs-service/src/main/java/com/fs/crm/domain/CrmCustomerChatMessage.java

@@ -0,0 +1,58 @@
+package com.fs.crm.domain;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import java.util.Date;
+
+/**
+ * 聊天消息记录对象 crm_customer_chat_message
+ * 
+ * @author ylrz
+ * @date 2026-03-30
+ */
+@Data
+public class CrmCustomerChatMessage extends BaseEntity {
+
+    /** 消息 ID */
+    private Long msgId;
+
+    /** 会话 ID */
+    private Long sessionId;
+
+    /** 发送者类型 (1-用户 2-AI) */
+    private Integer senderType;
+
+    /** 内容类型 (1-文本 2-图片 3-文件 4-语音) */
+    private Integer contentType;
+
+    /** 消息内容 */
+    private String content;
+
+    /** 元数据 (JSON 格式存储图片 URL/文件信息等) */
+    private String metadata;
+
+    /** 消耗的 token 数量 */
+    private Integer tokenCount;
+
+    /** AI 模型名称 */
+    private String modelName;
+
+    /** 耗时 (毫秒) */
+    private Integer costTime;
+
+    /** 状态 (1-成功 0-失败) */
+    private Integer status;
+
+    /** 创建时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    /** 更新时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date updateTime;
+    // 支持游标分页参数
+    private Long cursor;
+    private Integer limit;
+    private String direction;
+}

+ 49 - 0
fs-service/src/main/java/com/fs/crm/domain/CrmCustomerChatSession.java

@@ -0,0 +1,49 @@
+package com.fs.crm.domain;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 聊天会话对象 crm_customer_chat_session
+ * 
+ * @author ylrz
+ * @date 2026-03-30
+ */
+@Data
+public class CrmCustomerChatSession extends BaseEntity {
+
+    /** 会话 ID */
+    private Long sessionId;
+
+    /** 用户 ID */
+    private Long userId;
+
+    /** 关联客户 ID */
+    private Long customerId;
+
+    /** 会话标题 */
+    private String title;
+
+    /** AI 角色 ID */
+    private Long roleId;
+
+    /** 是否置顶 (0-否 1-是) */
+    private Integer isPinned;
+
+    /** 状态 (1-正常 0-删除) */
+    private Integer status;
+
+    /** 创建时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    /** 更新时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date updateTime;
+    /** 消息列表*/
+    private List<CrmCustomerChatMessage> messageList;
+}

+ 86 - 0
fs-service/src/main/java/com/fs/crm/mapper/CrmCustomerChatMessageMapper.java

@@ -0,0 +1,86 @@
+package com.fs.crm.mapper;
+import com.fs.crm.domain.CrmCustomerChatMessage;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+/**
+ * 聊天消息记录 Mapper 接口
+ * 
+ * @author ylrz
+ * @date 2026-03-30
+ */
+public interface CrmCustomerChatMessageMapper {
+
+    /**
+     * 查询聊天消息记录
+     * 
+     * @param msgId 消息 ID
+     * @return 聊天消息记录
+     */
+    CrmCustomerChatMessage selectChatMessageById(Long msgId);
+
+    /**
+     * 根据会话 ID 查询消息列表(带游标分页)
+     * 
+     * @param sessionId 会话 ID
+     * @param cursor 时间游标(加载该时间之前的消息)
+     * @param limit 每次加载的数量
+     * @param direction 方向(before-向前加载,after-向后加载)
+     * @return 消息列表
+     */
+    List<CrmCustomerChatMessage> selectChatMessagesBySession(
+        @Param("sessionId") Long sessionId,
+        @Param("cursor") Long cursor,
+        @Param("limit") Integer limit,
+        @Param("direction") String direction
+    );
+
+    /**
+     * 批量插入消息记录
+     * 
+     * @param messages 消息列表
+     * @return 影响行数
+     */
+    int batchInsertChatMessage(@Param("messages") List<CrmCustomerChatMessage> messages);
+
+    /**
+     * 新增聊天消息记录
+     * 
+     @param chatMessage 聊天消息记录
+     * @return 影响行数
+     */
+    int insertChatMessage(CrmCustomerChatMessage chatMessage);
+
+    /**
+     * 修改聊天消息记录
+     * 
+     * @param chatMessage 聊天消息记录
+     * @return 影响行数
+     */
+    int updateChatMessage(CrmCustomerChatMessage chatMessage);
+
+    /**
+     * 删除聊天消息记录
+     * 
+     * @param msgId 消息 ID
+     * @return 影响行数
+     */
+    int deleteChatMessageById(Long msgId);
+
+    /**
+     * 批量删除聊天消息记录
+     * 
+     * @param msgIds 需要删除的消息 ID 数组
+     * @return 影响行数
+     */
+    int deleteChatMessageByIds(Long[] msgIds);
+
+    /**
+     * 根据会话 ID 查询消息分页列表
+     * @param sessionId sessionId
+     */
+    @Select("SELECT * FROM crm_customer_chat_message WHERE session_id = #{sessionId} ORDER BY create_time DESC LIMIT 50")
+    List<CrmCustomerChatMessage> selectChatMessageBySessionIdLimit(@Param("sessionId") Long sessionId);
+}

+ 60 - 0
fs-service/src/main/java/com/fs/crm/mapper/CrmCustomerChatSessionMapper.java

@@ -0,0 +1,60 @@
+package com.fs.crm.mapper;
+
+import com.fs.crm.domain.CrmCustomerChatSession;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Update;
+
+import java.util.List;
+
+/**
+ * 聊天会话 Mapper 接口
+ */
+public interface CrmCustomerChatSessionMapper {
+
+    /**
+     * 查询聊天会话
+     */
+    CrmCustomerChatSession selectChatSessionById(Long sessionId);
+
+    /**
+     * 查询聊天会话列表
+     */
+    List<CrmCustomerChatSession> selectChatSessionList(CrmCustomerChatSession chatSession);
+
+    /**
+     * 新增聊天会话
+     */
+    int insertChatSession(CrmCustomerChatSession chatSession);
+
+    /**
+     * 修改聊天会话
+     */
+    int updateChatSession(CrmCustomerChatSession chatSession);
+
+    /**
+     * 删除聊天会话
+     */
+    int deleteChatSessionById(Long sessionId);
+
+    /**
+     * 批量删除聊天会话
+     */
+    int deleteChatSessionByIds(Long[] sessionIds);
+
+    /**
+     * 更新会话标题
+     */
+    int updateChatSessionTitle(@Param("sessionId") Long sessionId, @Param("title") String title);
+
+    /**
+     * 更新置顶状态
+     */
+    int updateChatSessionPin(@Param("sessionId") Long sessionId, @Param("isPinned") Integer isPinned);
+
+    int updateChatSessionCustomer(@Param("sessionId") Long sessionId, @Param("customerId") Long customerId);
+    /**
+     * 更新用户所有会话的置顶状态为0
+     */
+    @Update("update crm_customer_chat_session set is_pinned = 0 where user_id = #{userId}")
+    int updateIsPinnedByUserId(@Param("userId") Long userId);
+}

+ 4 - 10
fs-service/src/main/java/com/fs/crm/mapper/CrmCustomerMapper.java

@@ -357,11 +357,8 @@ public interface CrmCustomerMapper extends BaseMapper<CrmCustomer> {
             "<if test = 'maps.attritionLevel != null'> " +
             "and ca.attrition_level = #{maps.attritionLevel} " +
             "</if>" +
-            "<if test = 'maps.intentionDegreeGt != null'> " +
-            "and ca.intention_degree &gt;= #{maps.intentionDegreeGt} " +
-            "</if>" +
-            "<if test = 'maps.intentionDegreelt != null'> " +
-            "and ca.intention_degree &lt;= #{maps.intentionDegreelt} " +
+            "<if test = 'maps.intentionDegree != null and maps.intentionDegree != \"\" ' > " +
+            "and ca.intention_degree = #{maps.intentionDegree} " +
             "</if>" +
             "<if test = 'maps.companyId != null     '> " +
             "and c.company_id =#{maps.companyId} " +
@@ -503,11 +500,8 @@ public interface CrmCustomerMapper extends BaseMapper<CrmCustomer> {
             "<if test = 'maps.attritionLevel != null'> " +
             "and ca.attrition_level = #{maps.attritionLevel} " +
             "</if>" +
-            "<if test = 'maps.intentionDegreeGt != null'> " +
-            "and ca.intention_degree &gt;= #{maps.intentionDegreeGt} " +
-            "</if>" +
-            "<if test = 'maps.intentionDegreelt != null'> " +
-            "and ca.intention_degree &lt;= #{maps.intentionDegreelt} " +
+            "<if test = 'maps.intentionDegree != null and maps.intentionDegree != \"\" ' > " +
+            "and ca.intention_degree = #{maps.intentionDegree} " +
             "</if>" +
             " order by c.customer_id desc "+
             "</script>"})

+ 2 - 5
fs-service/src/main/java/com/fs/crm/param/CrmCustomerListQueryParam.java

@@ -105,10 +105,7 @@ public class CrmCustomerListQueryParam extends BaseQueryParam
 
     /** 流失风险等级 0:无风险;1:低风险;2:中风险;3:高风险 */
     private Long attritionLevel;
-    /** 意向度>= */
-    private Long intentionDegreeGt;
-    /** 意向度<= */
-    private Long intentionDegreelt;
-
+    /** 意向度 */
+    private String intentionDegree;
 
 }

+ 2 - 4
fs-service/src/main/java/com/fs/crm/param/CrmLineCustomerListQueryParam.java

@@ -87,9 +87,7 @@ public class CrmLineCustomerListQueryParam extends BaseQueryParam
 
     /** 流失风险等级 0:无风险;1:低风险;2:中风险;3:高风险 */
     private Long attritionLevel;
-    /** 意向度>= */
-    private Long intentionDegreeGt;
-    /** 意向度<= */
-    private Long intentionDegreelt;
 
+    /** 意向度 */
+    private String intentionDegree;
 }

+ 1 - 2
fs-service/src/main/java/com/fs/crm/service/ICrmCustomerAnalyzeService.java

@@ -2,7 +2,6 @@ package com.fs.crm.service;
 
 import java.util.List;
 import com.baomidou.mybatisplus.extension.service.IService;
-import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fs.crm.domain.CrmCustomerAnalyze;
 import com.fs.crm.param.PolishingScriptParam;
 
@@ -77,5 +76,5 @@ public interface ICrmCustomerAnalyzeService extends IService<CrmCustomerAnalyze>
 
     List<CrmCustomerAnalyze> selectCrmCustomerAnalyzeListAll(CrmCustomerAnalyze crmCustomerAnalyze);
 
-    String aiIntentionDegree(String content , Long chatId) throws JsonProcessingException;
+    String aiIntentionDegree(String content , Long chatId) ;
 }

+ 75 - 0
fs-service/src/main/java/com/fs/crm/service/ICrmCustomerChatMessageService.java

@@ -0,0 +1,75 @@
+package com.fs.crm.service;
+
+import com.fs.crm.domain.CrmCustomerChatMessage;
+
+import java.util.List;
+
+/**
+ * 聊天消息记录 Service 接口
+ * 
+ * @author ylrz
+ * @date 2026-03-30
+ */
+public interface ICrmCustomerChatMessageService {
+
+    /**
+     * 查询聊天消息记录
+     * 
+     * @param msgId 消息 ID
+     * @return 聊天消息记录
+     */
+    CrmCustomerChatMessage selectChatMessageById(Long msgId);
+
+    /**
+     * 根据会话 ID 查询消息列表(带游标分页)
+     * 
+     * @param sessionId 会话 ID
+     * @param cursor 时间游标
+     * @param limit 每次加载数量
+     * @param direction 方向
+     * @return 消息列表
+     */
+    List<CrmCustomerChatMessage> selectChatMessagesBySession(Long sessionId, Long cursor, Integer limit, String direction);
+
+    /**
+     * 批量保存消息
+     * 
+     * @param messages 消息列表
+     * @return 影响行数
+     */
+    int batchSaveChatMessages(List<CrmCustomerChatMessage> messages);
+
+    /**
+     * 新增聊天消息记录
+     * 
+     * @param chatMessage 聊天消息记录
+     * @return 影响行数
+     */
+    int insertChatMessage(CrmCustomerChatMessage chatMessage);
+
+    /**
+     * 修改聊天消息记录
+     * 
+     * @param chatMessage 聊天消息记录
+     * @return 影响行数
+     */
+    int updateChatMessage(CrmCustomerChatMessage chatMessage);
+
+    /**
+     * 删除聊天消息记录
+     * 
+     * @param msgIds 需要删除的消息 ID 数组
+     * @return 影响行数
+     */
+    int deleteChatMessageByIds(Long[] msgIds);
+
+    /**
+     * 批量删除聊天消息记录
+     * 
+     * @param msgIds 消息 ID 数组
+     * @return 影响行数
+     */
+    int batchDeleteChatMessage(Long[] msgIds);
+
+    List<CrmCustomerChatMessage> selectChatMessageBySessionIdLimit(Long sessionId);
+}

+ 53 - 0
fs-service/src/main/java/com/fs/crm/service/ICrmCustomerChatSessionService.java

@@ -0,0 +1,53 @@
+package com.fs.crm.service;
+
+import com.fs.crm.domain.CrmCustomerChatSession;
+
+import java.util.List;
+
+/**
+ * 聊天会话 Service 接口
+ */
+public interface ICrmCustomerChatSessionService {
+
+    /**
+     * 查询聊天会话
+     */
+    CrmCustomerChatSession selectChatSessionById(Long sessionId);
+
+    /**
+     * 查询聊天会话列表
+     */
+    List<CrmCustomerChatSession> selectChatSessionList(CrmCustomerChatSession chatSession);
+
+    /**
+     * 新增聊天会话
+     */
+    int insertChatSession(CrmCustomerChatSession chatSession);
+
+    /**
+     * 修改聊天会话
+     */
+    int updateChatSession(CrmCustomerChatSession chatSession);
+
+    /**
+     * 更新会话标题
+     */
+    int updateChatSessionTitle(Long sessionId, String title);
+
+    /**
+     * 更新置顶状态
+     */
+    int updateChatSessionPin(Long sessionId, Integer isPinned);
+
+    /**
+     * 批量删除聊天会话
+     */
+    int deleteChatSessionByIds(Long[] sessionIds);
+
+    /**
+     * 删除聊天会话信息
+     */
+    int deleteChatSessionById(Long sessionId);
+
+    int updateChatSessionCustomer(Long sessionId, Long customerId);
+}

+ 96 - 56
fs-service/src/main/java/com/fs/crm/service/impl/CrmCustomerAnalyzeServiceImpl.java

@@ -1,6 +1,7 @@
 package com.fs.crm.service.impl;
 
 import cn.hutool.core.lang.TypeReference;
+import cn.hutool.core.map.MapUtil;
 import cn.hutool.core.util.ObjectUtil;
 import cn.hutool.json.JSONUtil;
 import com.alibaba.fastjson.JSON;
@@ -21,7 +22,7 @@ import com.fs.system.mapper.SysDictDataMapper;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.scheduling.annotation.Async;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 
 import java.util.*;
@@ -113,15 +114,16 @@ public class CrmCustomerAnalyzeServiceImpl extends ServiceImpl<CrmCustomerAnalyz
     }
     private static final String AI_PORTRAIT = "crm_ai_portrait";
     private static final ObjectMapper mapper = new ObjectMapper();
+    @Value("${crm.customer.ai.Key:mygpt-oPG2ifhnq0ODGioOBMUvMfOZGrtCykqw3oMeYLchdUDK5He6iNiactrhFWA0sID}")
+    private String OTHER_KEY;
     //ai获取客户画像
     @Override
-    @Async
     public void aiGeneratedCustomerPortrait(Long customerId, String dataJson,Long logId) {
         Map<String, Object> stringObjectMap = buildRequestParam(customerId, dataJson);
         stringObjectMap.put("modelType","客户画像");
         log.info("请求参数:{}", stringObjectMap);
-        R aiResponse = CrmCustomerAiTagUtil.callAiService(stringObjectMap, logId);
-
+        R aiResponse = CrmCustomerAiTagUtil.callAiService(stringObjectMap, logId,OTHER_KEY);
+//        System.out.println(aiResponse);
         JSONObject root = JSON.parseObject(JSONUtil.toJsonStr(aiResponse));
 
 // 获取 data.responseData
@@ -159,18 +161,17 @@ public class CrmCustomerAnalyzeServiceImpl extends ServiceImpl<CrmCustomerAnalyz
     }
 
     @Override
-    @Async
     public void aiCommunicationSummary(Long customerId, String dataJson, Long logId) {
         Map<String, Object> stringObjectMap = buildRequestParam(customerId, dataJson);
         stringObjectMap.put("modelType","沟通总结");
         log.info("请求参数:{}", stringObjectMap);
-        R aiResponse = CrmCustomerAiTagUtil.callAiService(stringObjectMap, logId);
+        R aiResponse = CrmCustomerAiTagUtil.callAiService(stringObjectMap, logId,OTHER_KEY);
         JSONObject root = JSON.parseObject(JSONUtil.toJsonStr(aiResponse));
-        System.out.println(aiResponse);
+//        System.out.println(aiResponse);
 // 获取 data.responseData
         JSONArray responseData = root.getJSONObject("data").getJSONArray("responseData");
 
-        JSONArray summary = null;
+        JSONObject summary = null;
 
 // 遍历 responseData
         for (int i = 0; i < responseData.size(); i++) {
@@ -185,40 +186,40 @@ public class CrmCustomerAnalyzeServiceImpl extends ServiceImpl<CrmCustomerAnalyz
                     if ("AI".equals(historyItem.getString("obj"))) {
                         String valueStr = historyItem.getString("value");
                         JSONObject valueObj = JSON.parseObject(valueStr);
-                        summary = valueObj.getJSONArray("tagInfos");
+                        summary = valueObj.getJSONObject("userInfo");
                         break;
                     }
                 }
             }
             if (summary != null) break;
         }
-        StringBuilder summaryText = new StringBuilder();
-        if (summary!= null && !summary.isEmpty()) {
-            for (int i = 0; i < summary.size(); i++) {
-                summaryText.append(summary.get(i)).append(",");
-            }
-            summaryText.delete(summaryText.length()-1,summaryText.length());
+
+        if (!summary.isEmpty() ) {
+            String summaryText = summary.getString("沟通总结");
             CrmCustomerAnalyze crmCustomerAnalyze = new CrmCustomerAnalyze();
             crmCustomerAnalyze.setCustomerId(customerId);
-            crmCustomerAnalyze.setCommunicationSummary(summaryText.toString());
+            crmCustomerAnalyze.setCommunicationSummary(summaryText);
             baseMapper.updateCustomerPortrait(crmCustomerAnalyze);
         }
     }
 
     @Override
-    @Async
     public void aiCommunicationAbstract(Long customerId, String dataJson, Long logId) {
         Map<String, Object> stringObjectMap = buildRequestParam(customerId, dataJson);
         stringObjectMap.put("modelType","沟通摘要");
+        stringObjectMap.remove("userInfo");
+        HashMap<String, String> map = MapUtil.of("沟通摘要", "");
+        stringObjectMap.put("userInfo", map);
         log.info("请求参数:{}", stringObjectMap);
 
-        R aiResponse = CrmCustomerAiTagUtil.callAiService(stringObjectMap, logId);
+        R aiResponse = CrmCustomerAiTagUtil.callAiService(stringObjectMap, logId,OTHER_KEY);
         JSONObject root = JSON.parseObject(JSONUtil.toJsonStr(aiResponse));
+//        System.out.println(aiResponse);
 
 // 获取 data.responseData
         JSONArray responseData = root.getJSONObject("data").getJSONArray("responseData");
 
-        String summary = null;
+        JSONObject summary = null;
 
 // 遍历 responseData
         for (int i = 0; i < responseData.size(); i++) {
@@ -233,7 +234,7 @@ public class CrmCustomerAnalyzeServiceImpl extends ServiceImpl<CrmCustomerAnalyz
                     if ("AI".equals(historyItem.getString("obj"))) {
                         String valueStr = historyItem.getString("value");
                         JSONObject valueObj = JSON.parseObject(valueStr);
-                        summary = valueObj.getString("userContent");
+                        summary = valueObj.getJSONObject("userInfo");
                         break;
                     }
                 }
@@ -243,25 +244,28 @@ public class CrmCustomerAnalyzeServiceImpl extends ServiceImpl<CrmCustomerAnalyz
         if (summary != null){
             CrmCustomerAnalyze c = new CrmCustomerAnalyze();
             c.setCustomerId(customerId);
-            c.setCommunicationAbstract(summary);
+            c.setCommunicationAbstract(summary.getString("沟通摘要"));
             baseMapper.updateCustomerPortrait(c);
         }
     }
 
     @Override
-    @Async
     public void aiAttritionLevel(Long customerId, String dataJson, Long logId) {
         Map<String, Object> stringObjectMap = buildRequestParam(customerId, dataJson);
         stringObjectMap.put("modelType","流失风险等级");
+        stringObjectMap.remove("userInfo");
+        HashMap<String, String> map = MapUtil.of("流失风险等级", "");
+        stringObjectMap.put("userInfo", map);
         log.info("请求参数:{}", stringObjectMap);
 
-        R aiResponse = CrmCustomerAiTagUtil.callAiService(stringObjectMap, logId);
+        R aiResponse = CrmCustomerAiTagUtil.callAiService(stringObjectMap, logId,OTHER_KEY);
         JSONObject root = JSON.parseObject(JSONUtil.toJsonStr(aiResponse));
+//        System.out.println(aiResponse);
 
 // 获取 data.responseData
         JSONArray responseData = root.getJSONObject("data").getJSONArray("responseData");
 
-        String summary = null;
+        JSONObject summary = null;
 
 // 遍历 responseData
         for (int i = 0; i < responseData.size(); i++) {
@@ -276,33 +280,50 @@ public class CrmCustomerAnalyzeServiceImpl extends ServiceImpl<CrmCustomerAnalyz
                     if ("AI".equals(historyItem.getString("obj"))) {
                         String valueStr = historyItem.getString("value");
                         JSONObject valueObj = JSON.parseObject(valueStr);
-                        summary = valueObj.getString("tagInfos");
+                        summary = valueObj.getJSONObject("userInfo");
                         break;
                     }
                 }
             }
-            if (summary != null) break;
+            if (summary != null && !summary.isEmpty()) break;
         }
-        if (summary != null){
-            //todo 响应处理
+        if (summary != null && !summary.isEmpty()){
+            String level = summary.getString("流失风险等级");
+
+            CrmCustomerAnalyze c = new CrmCustomerAnalyze();
+            c.setCustomerId(customerId);
+            c.setAttritionLevel(getScore(level));
+            baseMapper.updateCustomerPortrait(c);
         }
     }
 
+
+        private static Long getScore(String level) {
+            if ("十分满意".equals(level) || "满意".equals(level) || "A".equals(level) || "B".equals(level)) return 1L;
+            if ("基本满意".equals(level) || "C".equals(level)) return 2L;
+            if ("不满意".equals(level) || "D".equals(level)) return 3L;
+            if ("十分不满意".equals(level) || "E".equals(level)) return 4L;
+            return 0L; // 未知
+        }
+
+
     @Override
-    @Async
     public void aiCustomerFocus(Long customerId, String dataJson, Long logId) {
         Map<String, Object> stringObjectMap = buildRequestParam(customerId, dataJson);
         stringObjectMap.put("modelType","客户关注点");
+        stringObjectMap.remove("userInfo");
+        HashMap<String, String> map = MapUtil.of("客户关注点", "");
+        stringObjectMap.put("userInfo", map);
         log.info("请求参数:{}", stringObjectMap);
 
-        R aiResponse = CrmCustomerAiTagUtil.callAiService(stringObjectMap, logId);
-        System.out.println(aiResponse);
+        R aiResponse = CrmCustomerAiTagUtil.callAiService(stringObjectMap, logId,OTHER_KEY);
+//        System.out.println(aiResponse);
         JSONObject root = JSON.parseObject(JSONUtil.toJsonStr(aiResponse));
 
 // 获取 data.responseData
         JSONArray responseData = root.getJSONObject("data").getJSONArray("responseData");
 
-        JSONArray summary = null;
+        JSONObject summary = null;
 
 // 遍历 responseData
         for (int i = 0; i < responseData.size(); i++) {
@@ -317,22 +338,17 @@ public class CrmCustomerAnalyzeServiceImpl extends ServiceImpl<CrmCustomerAnalyz
                     if ("AI".equals(historyItem.getString("obj"))) {
                         String valueStr = historyItem.getString("value");
                         JSONObject valueObj = JSON.parseObject(valueStr);
-                        summary = valueObj.getJSONArray("userContent");
+                        summary = valueObj.getJSONObject("userInfo");
                         break;
                     }
                 }
             }
-            if (summary != null) break;
+            if (summary != null && !summary.isEmpty()) break;
         }
-        if (summary != null){
-            List<String> list = new ArrayList<String>();
-            summary.forEach(o->{
-                JSONObject jsonObject = (JSONObject) o;
-                list.add(jsonObject.getString("tagName"));
-            });
+        if (summary != null && !summary.isEmpty()){
             CrmCustomerAnalyze crmCustomerAnalyze = new CrmCustomerAnalyze();
             crmCustomerAnalyze.setCustomerId(customerId);
-            crmCustomerAnalyze.setCustomerFocusJson(list.toString());
+            crmCustomerAnalyze.setCustomerFocusJson(summary.getString("客户关注点"));
             baseMapper.updateCustomerPortrait(crmCustomerAnalyze);
         }
     }
@@ -342,7 +358,8 @@ public class CrmCustomerAnalyzeServiceImpl extends ServiceImpl<CrmCustomerAnalyz
         Map<String, Object> requestParam = new HashMap<>();
         requestParam.put("history", param.getContent());
         requestParam.put("modelType","话术润色");
-        requestParam.putAll(param.getPortrait());
+        HashMap<String, Map<String, String>> userInfo = MapUtil.of("userInfo", param.getPortrait());
+        requestParam.putAll(userInfo);
 
         // 设置其他参数
         requestParam.put("tagInfos", Collections.emptyList());
@@ -350,26 +367,49 @@ public class CrmCustomerAnalyzeServiceImpl extends ServiceImpl<CrmCustomerAnalyz
         requestParam.put("userContent", "");
         requestParam.put("aiContent", "");
         requestParam.put("likeRatio", "");
-        R aiResponse = CrmCustomerAiTagUtil.callAiService(requestParam, Long.valueOf(param.getChatId()));
-        System.out.println(aiResponse);
+        R aiResponse = CrmCustomerAiTagUtil.callAiService(requestParam, Long.valueOf(param.getChatId()),OTHER_KEY);
+//        System.out.println(aiResponse);
+        JSONObject root = JSON.parseObject(JSONUtil.toJsonStr(aiResponse));
+        JSONArray responseData = root.getJSONObject("data").getJSONArray("responseData");
+
+// 遍历 responseData
+        for (int i = 0; i < responseData.size(); i++) {
+            JSONObject node = responseData.getJSONObject(i);
+            JSONArray historyPreview = node.getJSONArray("historyPreview");
+
+            if (historyPreview != null) {
+                for (int j = 0; j < historyPreview.size(); j++) {
+                    JSONObject historyItem = historyPreview.getJSONObject(j);
+
+                    // 找到 obj 为 "AI" 的项
+                    if ("AI".equals(historyItem.getString("obj"))) {
+                        String valueStr = historyItem.getString("value");
+                        JSONObject valueObj = JSON.parseObject(valueStr);
+                        return valueObj.getString("aiContent");
+                    }
+                }
+            }
+        }
         return "";
     }
 
     @Override
-    @Async
     public void aiIntentionDegree(Long customerId, String dataJson, Long logId) {
         Map<String, Object> stringObjectMap = buildRequestParam(customerId, dataJson);
         stringObjectMap.put("modelType","客户意向度");
+        stringObjectMap.remove("userInfo");
+        HashMap<String, String> map = MapUtil.of("客户意向度", "");
+        stringObjectMap.put("userInfo", map);
         log.info("请求参数:{}", stringObjectMap);
 
-        R aiResponse = CrmCustomerAiTagUtil.callAiService(stringObjectMap, logId);
-        System.out.println(aiResponse);
+        R aiResponse = CrmCustomerAiTagUtil.callAiService(stringObjectMap, logId,OTHER_KEY);
+//        System.out.println(aiResponse);
         JSONObject root = JSON.parseObject(JSONUtil.toJsonStr(aiResponse));
 
 // 获取 data.responseData
         JSONArray responseData = root.getJSONObject("data").getJSONArray("responseData");
 
-        String summary = null;
+        JSONObject summary = null;
 
 // 遍历 responseData
         for (int i = 0; i < responseData.size(); i++) {
@@ -384,17 +424,17 @@ public class CrmCustomerAnalyzeServiceImpl extends ServiceImpl<CrmCustomerAnalyz
                     if ("AI".equals(historyItem.getString("obj"))) {
                         String valueStr = historyItem.getString("value");
                         JSONObject valueObj = JSON.parseObject(valueStr);
-                        summary = valueObj.getString("userIntent");
+                        summary = valueObj.getJSONObject("userInfo");
                         break;
                     }
                 }
             }
-            if (summary != null) break;
+            if (summary != null && !summary.isEmpty()) break;
         }
-        if (summary != null) {
+        if (summary != null && !summary.isEmpty()) {
             CrmCustomerAnalyze crmCustomerAnalyze = new CrmCustomerAnalyze();
             crmCustomerAnalyze.setCustomerId(customerId);
-            crmCustomerAnalyze.setIntentionDegree(summary);
+            crmCustomerAnalyze.setIntentionDegree(summary.getString("客户意向度"));
             baseMapper.updateCustomerPortrait(crmCustomerAnalyze);
         }
     }
@@ -437,9 +477,9 @@ public class CrmCustomerAnalyzeServiceImpl extends ServiceImpl<CrmCustomerAnalyz
         requestParam.put("modelType","客户意向度");
         log.info("请求参数:{}", requestParam);
 
-        R aiResponse = CrmCustomerAiTagUtil.callAiService(requestParam, chatId);
+        R aiResponse = CrmCustomerAiTagUtil.callAiService(requestParam, chatId,OTHER_KEY);
         JSONObject root = JSON.parseObject(JSONUtil.toJsonStr(aiResponse));
-
+//        System.out.println(aiResponse);
 // 获取 data.responseData
         JSONArray responseData = root.getJSONObject("data").getJSONArray("responseData");
 
@@ -463,9 +503,9 @@ public class CrmCustomerAnalyzeServiceImpl extends ServiceImpl<CrmCustomerAnalyz
                     }
                 }
             }
-            if (summary != null) break;
+            if (summary != null && !summary.isEmpty()) break;
         }
-        if (summary != null) {
+        if (summary != null && !summary.isEmpty()) {
             return summary;
         }
         return null;

+ 158 - 0
fs-service/src/main/java/com/fs/crm/service/impl/CrmCustomerChatMessageServiceImpl.java

@@ -0,0 +1,158 @@
+package com.fs.crm.service.impl;
+
+import com.fs.common.utils.DateUtils;
+import com.fs.crm.domain.CrmCustomerChatMessage;
+import com.fs.crm.mapper.CrmCustomerChatMessageMapper;
+import com.fs.crm.service.ICrmCustomerChatMessageService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * 聊天消息记录 Service 业务层处理
+ * 
+ * @author ylrz
+ * @date 2026-03-30
+ */
+@Slf4j
+@Service
+public class CrmCustomerChatMessageServiceImpl implements ICrmCustomerChatMessageService {
+
+    @Autowired
+    private CrmCustomerChatMessageMapper chatMessageMapper;
+
+    /**
+     * 查询聊天消息记录
+     * 
+     * @param msgId 消息 ID
+     * @return 聊天消息记录
+     */
+    @Override
+    public CrmCustomerChatMessage selectChatMessageById(Long msgId) {
+        return chatMessageMapper.selectChatMessageById(msgId);
+    }
+
+    /**
+     * 根据会话 ID 查询消息列表(带游标分页)
+     * 
+     * @param sessionId 会话 ID
+     * @param cursor 时间游标
+     * @param limit 每次加载数量
+     * @param direction 方向
+     * @return 消息列表
+     */
+    @Override
+    public List<CrmCustomerChatMessage> selectChatMessagesBySession(Long sessionId, Long cursor, Integer limit, String direction) {
+        // 默认每次加载 50 条
+        if (limit == null) {
+            limit = 50;
+        }
+        return chatMessageMapper.selectChatMessagesBySession(sessionId, cursor, limit, direction);
+    }
+
+    /**
+     * 批量保存消息
+     * 
+     * @param messages 消息列表
+     * @return 影响行数
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public int batchSaveChatMessages(List<CrmCustomerChatMessage> messages) {
+        if (messages == null || messages.isEmpty()) {
+            return 0;
+        }
+        
+        try {
+            // 设置默认值
+            for (CrmCustomerChatMessage message : messages) {
+                if (message.getStatus() == null) {
+                    message.setStatus(1);
+                }
+                if (message.getContentType() == null) {
+                    message.setContentType(1); // 默认文本
+                }
+                if (message.getCreateTime() == null) {
+                    message.setCreateTime(DateUtils.getNowDate());
+                }
+            }
+            
+            // 批量插入
+            int result = chatMessageMapper.batchInsertChatMessage(messages);
+            log.info("批量保存消息成功,保存数量:{}", messages.size());
+            return result;
+        } catch (Exception e) {
+            log.error("批量保存消息失败", e);
+            throw new RuntimeException("批量保存消息失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 新增聊天消息记录
+     * 
+     * @param chatMessage 聊天消息记录
+     * @return 影响行数
+     */
+    @Override
+    public int insertChatMessage(CrmCustomerChatMessage chatMessage) {
+        if (chatMessage.getCreateTime() == null) {
+            chatMessage.setCreateTime(DateUtils.getNowDate());
+        }
+        if (chatMessage.getStatus() == null) {
+            chatMessage.setStatus(1);
+        }
+        int result = chatMessageMapper.insertChatMessage(chatMessage);
+        log.info("新增消息成功,msgId: {}", chatMessage.getMsgId());
+        return result;
+    }
+
+    /**
+     * 修改聊天消息记录
+     * 
+     * @param chatMessage 聊天消息记录
+     * @return 影响行数
+     */
+    @Override
+    public int updateChatMessage(CrmCustomerChatMessage chatMessage) {
+        chatMessage.setUpdateTime(DateUtils.getNowDate());
+        int result = chatMessageMapper.updateChatMessage(chatMessage);
+        log.info("更新消息成功,msgId: {}", chatMessage.getMsgId());
+        return result;
+    }
+
+    /**
+     * 批量删除聊天消息记录
+     * 
+     * @param msgIds 需要删除的消息 ID 数组
+     * @return 影响行数
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public int deleteChatMessageByIds(Long[] msgIds) {
+        if (msgIds == null || msgIds.length == 0) {
+            return 0;
+        }
+        int result = chatMessageMapper.deleteChatMessageByIds(msgIds);
+        log.info("批量删除消息成功,删除数量:{}", msgIds.length);
+        return result;
+    }
+
+    /**
+     * 删除聊天消息记录信息
+     * 
+     * @param msgIds 消息 ID
+     * @return 影响行数
+     */
+    @Override
+    public int batchDeleteChatMessage(Long[] msgIds) {
+        return this.deleteChatMessageByIds(msgIds);
+    }
+
+    @Override
+    public List<CrmCustomerChatMessage> selectChatMessageBySessionIdLimit(Long sessionId) {
+        return chatMessageMapper.selectChatMessageBySessionIdLimit(sessionId);
+    }
+}

+ 163 - 0
fs-service/src/main/java/com/fs/crm/service/impl/CrmCustomerChatSessionServiceImpl.java

@@ -0,0 +1,163 @@
+package com.fs.crm.service.impl;
+
+import com.fs.common.utils.DateUtils;
+import com.fs.crm.domain.CrmCustomerChatSession;
+import com.fs.crm.mapper.CrmCustomerChatSessionMapper;
+import com.fs.crm.service.ICrmCustomerChatSessionService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * 聊天会话 Service 业务层处理
+ * 
+ * @author ylrz
+ * @date 2026-03-30
+ */
+@Slf4j
+@Service
+public class CrmCustomerChatSessionServiceImpl implements ICrmCustomerChatSessionService {
+
+    @Autowired
+    private CrmCustomerChatSessionMapper chatSessionMapper;
+
+    /**
+     * 查询聊天会话
+     * 
+     * @param sessionId 会话 ID
+     * @return 聊天会话
+     */
+    @Override
+    public CrmCustomerChatSession selectChatSessionById(Long sessionId) {
+        return chatSessionMapper.selectChatSessionById(sessionId);
+    }
+
+    /**
+     * 查询聊天会话列表
+     * 
+     * @param chatSession 聊天会话
+     * @return 聊天会话
+     */
+    @Override
+    public List<CrmCustomerChatSession> selectChatSessionList(CrmCustomerChatSession chatSession) {
+        // 默认按置顶降序,时间降序排列
+        return chatSessionMapper.selectChatSessionList(chatSession);
+    }
+
+    /**
+     * 新增聊天会话
+     * 
+     * @param chatSession 聊天会话
+     * @return 影响行数
+     */
+    @Override
+    public int insertChatSession(CrmCustomerChatSession chatSession) {
+        if (chatSession.getCreateTime() == null) {
+            chatSession.setCreateTime(DateUtils.getNowDate());
+        }
+        if (chatSession.getStatus() == null) {
+            chatSession.setStatus(1);
+        }
+        if (chatSession.getIsPinned() == null) {
+            chatSession.setIsPinned(0);
+        }
+        int result = chatSessionMapper.insertChatSession(chatSession);
+        log.info("新增会话成功,sessionId: {}", chatSession.getSessionId());
+        return result;
+    }
+
+    /**
+     * 修改聊天会话
+     * 
+     * @param chatSession 聊天会话
+     * @return 影响行数
+     */
+    @Override
+    public int updateChatSession(CrmCustomerChatSession chatSession) {
+        chatSession.setUpdateTime(DateUtils.getNowDate());
+        int result = chatSessionMapper.updateChatSession(chatSession);
+        log.info("更新会话成功,sessionId: {}", chatSession.getSessionId());
+        return result;
+    }
+
+    /**
+     * 更新会话标题
+     * 
+     * @param sessionId 会话 ID
+     * @param title 新标题
+     * @return 影响行数
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public int updateChatSessionTitle(Long sessionId, String title) {
+        try {
+            int result = chatSessionMapper.updateChatSessionTitle(sessionId, title);
+            log.info("更新会话标题成功,sessionId: {}, title: {}", sessionId, title);
+            return result;
+        } catch (Exception e) {
+            log.error("更新会话标题失败", e);
+            throw new RuntimeException("更新会话标题失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 更新置顶状态
+     * 
+     * @param sessionId 会话 ID
+     * @param isPinned 是否置顶
+     * @return 影响行数
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public int updateChatSessionPin(Long sessionId, Integer isPinned) {
+        try {
+            CrmCustomerChatSession crmCustomerChatSession = chatSessionMapper.selectChatSessionById(sessionId);
+            if(crmCustomerChatSession != null){
+                chatSessionMapper.updateIsPinnedByUserId(crmCustomerChatSession.getUserId());
+                //再处理置顶数据为1
+                return chatSessionMapper.updateChatSessionPin(sessionId, isPinned);
+            }
+        } catch (Exception e) {
+            throw new RuntimeException("更新置顶状态失败:" + e.getMessage());
+        }
+        return 0;
+    }
+
+    /**
+     * 批量删除聊天会话
+     * 
+     * @param sessionIds 需要删除的会话 ID 数组
+     * @return 影响行数
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public int deleteChatSessionByIds(Long[] sessionIds) {
+        if (sessionIds == null || sessionIds.length == 0) {
+            return 0;
+        }
+        int result = chatSessionMapper.deleteChatSessionByIds(sessionIds);
+        log.info("批量删除会话成功,删除数量:{}", sessionIds.length);
+        return result;
+    }
+
+    /**
+     * 删除聊天会话信息
+     * 
+     * @param sessionId 会话 ID
+     * @return 影响行数
+     */
+    @Override
+    public int deleteChatSessionById(Long sessionId) {
+        int result = chatSessionMapper.deleteChatSessionById(sessionId);
+        log.info("删除会话成功,sessionId: {}", sessionId);
+        return result;
+    }
+
+    @Override
+    public int updateChatSessionCustomer(Long sessionId, Long customerId) {
+        return chatSessionMapper.updateChatSessionCustomer(sessionId, customerId);
+    }
+}

+ 27 - 16
fs-service/src/main/java/com/fs/crm/utils/CrmCustomerAiTagUtil.java

@@ -1,10 +1,9 @@
 package com.fs.crm.utils;
 
-import cn.hutool.core.collection.ListUtil;
 import cn.hutool.core.util.ObjectUtil;
 import cn.hutool.json.JSONUtil;
+import com.alibaba.fastjson.JSON;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.core.type.TypeReference;
@@ -12,9 +11,7 @@ import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.domain.entity.SysDictData;
-import com.fs.common.exception.CustomException;
 import com.fs.common.utils.DictUtils;
-import com.fs.common.utils.date.DateUtil;
 import com.fs.common.utils.spring.SpringUtils;
 import com.fs.config.ai.AiHostProper;
 import com.fs.crm.domain.CrmCustomer;
@@ -30,7 +27,7 @@ import com.fs.crm.vo.CrmCustomerAiTagVo;
 import com.fs.fastgptApi.param.ChatParam;
 import com.fs.fastgptApi.service.ChatService;
 import com.fs.hisapi.util.MapUtil;
-import lombok.extern.slf4j.Slf4j;
+import com.fs.system.mapper.SysDictDataMapper;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.beans.factory.annotation.Value;
@@ -48,9 +45,10 @@ public class CrmCustomerAiTagUtil {
 
     //行业字典名称
     private static final String TRADE_TYPE = "trade_type";
-    @Value("${crm.customer.ai.key:mygpt-xZeWD9u6tTebmfn3huS8bTQ63WL2bldp56LpXnB4FWhTnIQMqW4t}")
+    @Value("${crm.customer.ai.key:mygpt-oPG2ifhnq0ODGioOBMUvMfOZGrtCykqw3oMeYLchdUDK5He6iNiactrhFWA0sID}")
     private String appKey;
     private static final String CRM_AI_REDIS_KEY = "crm:AI:data:processing";
+    private static final String AI_PORTRAIT = "crm_ai_portrait";
 
     private static final ObjectMapper mapper = new ObjectMapper();
 
@@ -81,7 +79,8 @@ public class CrmCustomerAiTagUtil {
         Map<String, Object> requestParam = buildRequestParam(customerId, tradeType, communication);
 
         // 3. 调用AI服务
-        R aiResponse = callAiService(requestParam, logId);
+        R aiResponse = callAiService(requestParam, logId,APP_KEY);
+//        System.out.println(aiResponse);
         // 4. 解析响应并保存
         List<CrmCustomerAiTagVo> results = parseAiResponse(aiResponse, customerId);
 
@@ -129,7 +128,7 @@ public class CrmCustomerAiTagUtil {
                 .map(tag -> buildTagVo(tag, customerId))
                 .collect(Collectors.toList());
     }
-    public static R callAiService(Map<String, Object> requestParam, Long logId) {
+    public static R callAiService(Map<String, Object> requestParam, Long logId,String appKey) {
         try {
             String requestJson = mapper.writeValueAsString(requestParam);
 
@@ -146,7 +145,7 @@ public class CrmCustomerAiTagUtil {
             ChatService chatService = SpringUtils.getBean(ChatService.class);
             AiHostProper aiHost = SpringUtils.getBean(AiHostProper.class);
 
-            return chatService.initiatingTakeChat(param, aiHost.getAiApi(), APP_KEY);
+            return chatService.initiatingTakeChat(param, aiHost.getAiApi(), appKey);
         } catch (Exception e) {
             throw new RuntimeException("AI服务调用失败", e);
         }
@@ -226,7 +225,7 @@ public class CrmCustomerAiTagUtil {
                             // 如果 value 是字符串,需要再次解析
                             if (valueNode.isTextual()) {
                                 String valueStr = valueNode.asText();
-                                JsonNode tagInfosNode = mapper.readTree(valueStr).path("tagsInfo");
+                                JsonNode tagInfosNode = mapper.readTree(valueStr).path("tagInfos");
 
                                 if (tagInfosNode.isArray()) {
                                     return mapper.convertValue(tagInfosNode,
@@ -341,6 +340,8 @@ public class CrmCustomerAiTagUtil {
 
     private static Map<String, Object> getUserInfo(Long customerId) {
         CrmCustomerMapper customerMapper = SpringUtils.getBean(CrmCustomerMapper.class);
+        CrmCustomerAnalyzeMapper CrmCustomerAnalyzeMapper = SpringUtils.getBean(CrmCustomerAnalyzeMapper.class);
+
         CrmCustomer crmCustomer = customerMapper.selectCrmCustomerById(customerId);
         if (ObjectUtil.isEmpty(crmCustomer)) throw new RuntimeException("客户不存在");
         CrmCustomerInfo crmCustomerInfo = customerMapper.selectCrmCustomerInfoById(customerId);
@@ -350,13 +351,23 @@ public class CrmCustomerAiTagUtil {
                     .setTalk("首次交流");
             customerMapper.insertCrmCustomerInfo(crmCustomerInfo);
         }
+        CrmCustomerAnalyze crmCustomerAnalyze = CrmCustomerAnalyzeMapper.selectLatestOne(customerId);
+        if (ObjectUtil.isEmpty(crmCustomerAnalyze))throw new RuntimeException("客户信息不存在");
         HashMap<String, String> userInfo = new HashMap<String, String>();
-        userInfo.put("name", crmCustomerInfo.getName()==null?"" : crmCustomerInfo.getName());
-        userInfo.put("sex", crmCustomerInfo.getSex()==null?"" : crmCustomerInfo.getSex().toString());
-        userInfo.put("age", "");
-        userInfo.put("city", "");
-        userInfo.put("habits", "");
-        userInfo.put("describe", "");
+        List<SysDictData> portraits = SpringUtils.getBean(SysDictDataMapper.class).selectDictDataByType(AI_PORTRAIT);
+        List<String> dictValue = portraits.stream().map(SysDictData::getDictValue).collect(Collectors.toList());
+        if (crmCustomerAnalyze.getCustomerPortraitJson() != null){
+            Map<String, String> portraitList = JSON.parseObject(
+                    crmCustomerAnalyze.getCustomerPortraitJson(),
+                    new cn.hutool.core.lang.TypeReference<Map<String, String>>() {}
+            );
+            userInfo.putAll(portraitList);
+
+        }else {
+            dictValue.forEach(o->{
+                userInfo.put(o, "");
+            });
+        }
         HashMap<String, Object> result = new HashMap<>();
         result.put("userInfo", userInfo);
         result.put("likeRatio", ObjectUtil.isNotEmpty(crmCustomer.getIntention()) ? crmCustomer.getIntention() : "");

+ 1 - 1
fs-service/src/main/java/com/fs/crm/vo/CrmLineCustomerListQueryVO.java

@@ -111,7 +111,7 @@ public class CrmLineCustomerListQueryVO implements Serializable
     private Long attritionLevel;
     /** 意向度 */
     @Excel(name = "意向度")
-    private Long intentionDegree;
+    private String intentionDegree;
 
 
 }

+ 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;
+
+}

+ 4 - 1
fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreOrderItemScrmMapper.java

@@ -187,8 +187,11 @@ public interface FsStoreOrderItemScrmMapper
             "                and psps.product_name like concat('%', #{maps.productName}, '%')\n" +
             "            </if>"+
             "<if test = 'maps.isAudit != null'> " +
-            "and o.is_audit = #{maps.isAudit} " +
+            "and o.is_audit = #{maps.isAudit} \n" +
             "</if>" +
+            "            <if test=\"maps.isCompanyOrder != null and maps.isCompanyOrder > 0\">\n" +
+            "                and o.order_type != 3 and o.order_type != 2\n" +
+            "            </if>"+
             "GROUP BY  o.id order by o.id desc limit 50000"+
             "</script>"})
     List<FsStoreOrderItemExportVO> selectFsStoreOrderItemListExportVO(@Param("maps")FsStoreOrderParam fsStoreOrder);

+ 3 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreOrderScrmMapper.java

@@ -768,6 +768,9 @@ public interface FsStoreOrderScrmMapper
             "<if test = 'maps.isAudit != null'> " +
             "and o.is_audit = #{maps.isAudit} " +
             "</if>" +
+            "            <if test=\"maps.isCompanyOrder != null and maps.isCompanyOrder > 0\">\n" +
+            "                and o.order_type != 3 and o.order_type != 2\n" +
+            "            </if>"+
             " ${maps.params.dataScope} "+
             " order by o.id desc limit 50000"+
             "</script>"})

+ 8 - 0
fs-service/src/main/java/com/fs/hisStore/param/FsStoreOrderParam.java

@@ -133,4 +133,12 @@ public class FsStoreOrderParam extends BaseEntity implements Serializable
 
     // 多个销售id
     private List<Long> companyUserIds;
+
+    // 是否公司订单,1-是,0-否
+    private Integer isCompanyOrder;
+
+    private String userPhoneMk;
+
+    /** ID */
+    private Long taskId;
 }

+ 14 - 2
fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreAfterSalesScrmServiceImpl.java

@@ -76,6 +76,7 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.interceptor.TransactionAspectSupport;
@@ -210,6 +211,9 @@ public class FsStoreAfterSalesScrmServiceImpl implements IFsStoreAfterSalesScrmS
     @Autowired
     private CloudHostProper cloudHostProper;
 
+    @Value("${cloud_host.company_name}")
+    private String companyName;
+
     /**
      * 查询售后记录
      *
@@ -975,10 +979,18 @@ public class FsStoreAfterSalesScrmServiceImpl implements IFsStoreAfterSalesScrmS
 
                         Map<String, Object> extendInfoMap = new HashMap<>();
                         if (order.getPayType().equals("99")){
-                            request.setOrdAmt(payment.getPayMoney().setScale(2, RoundingMode.DOWN).toString());
+                            if("北京卓美".equals(companyName)){
+                                request.setOrdAmt(refundAmount.setScale(2, RoundingMode.DOWN).toString());
+                            }else {
+                                request.setOrdAmt(payment.getPayMoney().setScale(2, RoundingMode.DOWN).toString());
+                            }
                             extendInfoMap.put("org_req_seq_id", "store-"+payment.getPayCode());
                         }else {
-                            request.setOrdAmt(payment.getPayMoney().toString());
+                            if("北京卓美".equals(companyName)){
+                                request.setOrdAmt(refundAmount.setScale(2, RoundingMode.DOWN).toString());
+                            }else{
+                                request.setOrdAmt(payment.getPayMoney().toString());
+                            }
                             extendInfoMap.put("org_party_order_id", payment.getBankSerialNo());
                             request.setAppId(payment.getAppId());
                         }

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

@@ -897,7 +897,7 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
     public R createOrder(long userId, FsStoreOrderCreateParam param) {
 
         FsUserCompanyUser fsUserCompanyUser = fsUserCompanyUserMapper.selectFsUserCompanyUserByUserId(userId);
-        if (ObjectUtil.isNotEmpty(fsUserCompanyUser) && param.getVideoId()!=null){
+        if (ObjectUtil.isNotEmpty(fsUserCompanyUser) && param.getVideoId()!=null && param.getCompanyId() == null){
             param.setCompanyId(fsUserCompanyUser.getCompanyId());
             param.setCompanyUserId(fsUserCompanyUser.getCompanyUserId());
         }
@@ -960,7 +960,7 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
             StoreConfig config= JSONUtil.toBean(json, StoreConfig.class);
 
             //卓美商城正常下单
-            if("北京卓美".equals(companyName) && param.getVideoId()!=null || !"北京卓美".equals(companyName)){
+            if("北京卓美".equals(companyName) && param.getVideoId()!=null && storeOrder.getCompanyId() == null || !"北京卓美".equals(companyName)){
                 //绑定销售
                 FsUserScrm fsuser= userService.selectFsUserById(userId);
                 if(ObjectUtil.isEmpty(config.getOrderAttribution())

+ 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);

+ 1 - 1
fs-service/src/main/java/com/fs/wxcid/threadExecutor/generalCustomerExecutor.java

@@ -20,7 +20,7 @@ public class generalCustomerExecutor {
         executor.setQueueCapacity(10000);
         executor.setThreadNamePrefix("CustomerExec-");
         executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
-        executor.setKeepAliveSeconds(600);
+        executor.setKeepAliveSeconds(3600);
         executor.initialize();
         return executor;
     }

+ 1 - 1
fs-service/src/main/resources/application-common.yml

@@ -36,7 +36,7 @@ server:
 # 日志配置
 logging:
   level:
-    com.fs: info
+    com.fs: DEBUG
     org.springframework: warn
 
 express:

+ 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:

+ 5 - 1
fs-service/src/main/resources/application-config-druid-sxjz.yml

@@ -101,4 +101,8 @@ ipad:
 wx_miniapp_temp:
   pay_order_temp_id:
   inquiry_temp_id:
-
+weizou:
+  baseUrl: https://api.tiaobaoliansuo.com
+  appId: 598372344571410ab2598a05ba914178
+  appSecret: c3e51122dbf749b6aef170c4b3a712c7
+  createName: wz_7f35d1f365e448f6a60a6fc39d5613a1s

+ 87 - 0
fs-service/src/main/resources/mapper/aicall/CcTtsAliyunMapper.xml

@@ -0,0 +1,87 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.aicall.mapper.CcTtsAliyunMapper">
+    
+    <resultMap type="com.fs.aicall.domain.CcTtsAliyun" id="CcTtsAliyunResult">
+        <result property="id"           column="id" />
+        <result property="voiceName"    column="voice_name" />
+        <result property="voiceCode"    column="voice_code" />
+        <result property="voiceEnabled" column="voice_enabled" />
+        <result property="voiceSource"  column="voice_source" />
+        <result property="priority"     column="priority" />
+        <result property="provider"     column="provider" />
+    </resultMap>
+
+    <sql id="selectCcTtsAliyunVo">
+        select id, voice_name, voice_code, voice_enabled, voice_source, priority, provider
+        from cc_tts_aliyun
+    </sql>
+
+    <select id="selectCcTtsAliyunById" parameterType="Integer" resultMap="CcTtsAliyunResult">
+        <include refid="selectCcTtsAliyunVo"/>
+        where id = #{id}
+    </select>
+
+    <select id="selectCcTtsAliyunByVoiceCode" parameterType="String" resultMap="CcTtsAliyunResult">
+        <include refid="selectCcTtsAliyunVo"/>
+        where voice_code = #{voiceCode}
+    </select>
+
+    <select id="selectCcTtsAliyunList" parameterType="com.fs.aicall.domain.CcTtsAliyun" resultMap="CcTtsAliyunResult">
+        <include refid="selectCcTtsAliyunVo"/>
+        <where>
+            <if test="voiceName != null and voiceName != ''">and voice_name like concat('%', #{voiceName}, '%')</if>
+            <if test="voiceCode != null and voiceCode != ''">and voice_code = #{voiceCode}</if>
+            <if test="voiceEnabled != null">and voice_enabled = #{voiceEnabled}</if>
+            <if test="voiceSource != null and voiceSource != ''">and voice_source = #{voiceSource}</if>
+            <if test="provider != null and provider != ''">and provider = #{provider}</if>
+        </where>
+    </select>
+
+    <insert id="insertCcTtsAliyun" parameterType="com.fs.aicall.domain.CcTtsAliyun" useGeneratedKeys="true" keyProperty="id">
+        insert into cc_tts_aliyun
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="voiceName != null and voiceName != ''">voice_name,</if>
+            <if test="voiceCode != null and voiceCode != ''">voice_code,</if>
+            <if test="voiceEnabled != null">voice_enabled,</if>
+            <if test="voiceSource != null and voiceSource != ''">voice_source,</if>
+            <if test="priority != null">priority,</if>
+            <if test="provider != null and provider != ''">provider,</if>
+        </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="voiceName != null and voiceName != ''">#{voiceName},</if>
+            <if test="voiceCode != null and voiceCode != ''">#{voiceCode},</if>
+            <if test="voiceEnabled != null">#{voiceEnabled},</if>
+            <if test="voiceSource != null and voiceSource != ''">#{voiceSource},</if>
+            <if test="priority != null">#{priority},</if>
+            <if test="provider != null and provider != ''">#{provider},</if>
+        </trim>
+    </insert>
+
+    <update id="updateCcTtsAliyun" parameterType="com.fs.aicall.domain.CcTtsAliyun">
+        update cc_tts_aliyun
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="voiceName != null and voiceName != ''">voice_name = #{voiceName},</if>
+            <if test="voiceCode != null and voiceCode != ''">voice_code = #{voiceCode},</if>
+            <if test="voiceEnabled != null">voice_enabled = #{voiceEnabled},</if>
+            <if test="voiceSource != null and voiceSource != ''">voice_source = #{voiceSource},</if>
+            <if test="priority != null">priority = #{priority},</if>
+            <if test="provider != null and provider != ''">provider = #{provider},</if>
+        </trim>
+        where id = #{id}
+    </update>
+
+    <delete id="deleteCcTtsAliyunById" parameterType="Integer">
+        delete from cc_tts_aliyun where id = #{id}
+    </delete>
+
+    <delete id="deleteCcTtsAliyunByIds" parameterType="String">
+        delete from cc_tts_aliyun where id in
+        <foreach item="id" collection="array" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+
+</mapper>

+ 4 - 0
fs-service/src/main/resources/mapper/company/CompanyVoiceRoboticMapper.xml

@@ -222,6 +222,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         SELECT dict_type,dict_label,dict_value FROM `sys_dict_data` where  dict_type = #{dictType}
     </select>
 
+    <select id="getDictDataByTypeAndValue" resultType="com.fs.company.vo.DictVO">
+        SELECT dict_type,dict_label,dict_value FROM `sys_dict_data` where  dict_value = #{value} and dict_type = #{dictType}
+    </select>
+
     <select id="selectSceneTaskByCompanyIdAndType" resultType="CompanyVoiceRobotic">
         select * from company_voice_robotic where company_id = #{companyId}
                                               and scene_type = #{sceneType}

+ 1 - 1
fs-service/src/main/resources/mapper/company/CompanyWorkflowMapper.xml

@@ -114,7 +114,7 @@
           and aw.del_flag = 0
     </select>
     <select id="optionList" resultType="com.fs.company.vo.OptionVO">
-        select workflow_id value,workflow_name label from company_ai_workflow where company_id = #{companyId}
+        select workflow_id value,workflow_name label from company_ai_workflow where company_id = #{companyId} and del_flag = 0
     </select>
 
     <insert id="insertCompanyWorkflow" parameterType="CompanyWorkflow" useGeneratedKeys="true" keyProperty="workflowId">

+ 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>

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

@@ -144,11 +144,11 @@
             <if test="viewStartTime != null">#{viewStartTime},</if>
             <if test="viewEndTime != null">#{viewEndTime},</if>
             <if test="lastJoinTime != null">#{lastJoinTime},</if>
+            <if test="projectId != null">#{projectId},</if>
             <if test="isProduct != null">#{isProduct},</if>
             <if test="productId != null">#{productId},</if>
             <if test="listingStartTime != null">#{listingStartTime},</if>
             <if test="listingEndTime != null">#{listingEndTime},</if>
-            <if test="projectId != null">#{projectId},</if>
             <if test="userId != null">#{userId},</if>
             <if test="isFirst != null">#{isFirst},</if>
             <if test="isSpeed != null">#{isSpeed},</if>

+ 9 - 0
fs-service/src/main/resources/mapper/crm/CrmCustomerAnalyzeMapper.xml

@@ -130,6 +130,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="intentionDegree != null and intentionDegree != ''">
                 intention_degree = #{intentionDegree},
             </if>
+            <if test="attritionLevel != null and attritionLevel != ''">
+                attrition_level = #{attritionLevel},
+            </if>
 
         </set>
         where customer_id = #{customerId}
@@ -175,6 +178,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="aiChatRecord != null  and aiChatRecord != ''"> and a.ai_chat_record = #{aiChatRecord}</if>
             <if test="reserveInt != null "> and a.reserve_int = #{reserveInt}</if>
             <if test="reserveStr != null  and reserveStr != ''"> and a.reserve_str = #{reserveStr}</if>
+            <if test="customerIds != null and customerIds.length > 0">
+                and a.customer_id in
+                <foreach item="customerId" collection="customerIds" open="(" separator="," close=")">
+                    #{customerId}
+                </foreach>
+            </if>
         </where>
         order by a.create_time desc
     </select>

+ 115 - 0
fs-service/src/main/resources/mapper/crm/CrmCustomerChatMessageMapper.xml

@@ -0,0 +1,115 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.crm.mapper.CrmCustomerChatMessageMapper">
+
+    <resultMap type="CrmCustomerChatMessage" id="ChatMessageResult">
+        <result property="msgId"    column="msg_id"    />
+        <result property="sessionId"    column="session_id"    />
+        <result property="senderType"    column="sender_type"    />
+        <result property="contentType"    column="content_type"    />
+        <result property="content"    column="content"    />
+        <result property="metadata"    column="metadata"    />
+        <result property="tokenCount"    column="token_count"    />
+        <result property="modelName"    column="model_name"    />
+        <result property="costTime"    column="cost_time"    />
+        <result property="status"    column="status"    />
+        <result property="createTime"    column="create_time"    />
+        <result property="updateTime"    column="update_time"    />
+    </resultMap>
+
+    <!-- 查询消息列表(带游标分页) -->
+    <select id="selectChatMessagesBySession" parameterType="java.util.HashMap" resultMap="ChatMessageResult">
+        SELECT msg_id, session_id, sender_type, content_type, content, metadata,
+               token_count, model_name, cost_time, status, create_time
+        FROM crm_customer_chat_message
+        WHERE session_id = #{sessionId}
+          AND status = 1
+        <if test="cursor != null">
+            <choose>
+                <when test="direction == 'before'">
+                    AND create_time &lt; FROM_UNIXTIME(#{cursor} / 1000)
+                </when>
+                <when test="direction == 'after'">
+                    AND create_time &gt; FROM_UNIXTIME(#{cursor} / 1000)
+                </when>
+            </choose>
+        </if>
+        ORDER BY create_time DESC
+        LIMIT #{limit}
+    </select>
+    <select id="selectChatMessageById" parameterType="Long" resultType="com.fs.crm.domain.CrmCustomerChatMessage">
+        SELECT * FROM crm_customer_chat_message WHERE msg_id = #{msgId}
+    </select>
+
+    <!-- 批量插入消息 -->
+    <insert id="batchInsertChatMessage" parameterType="java.util.List">
+        INSERT INTO crm_customer_chat_message
+        (session_id, sender_type, content_type, content, metadata, token_count, model_name, cost_time, status, create_time)
+        VALUES
+        <foreach item="message" collection="messages" separator=",">
+            (#{message.sessionId}, #{message.senderType}, #{message.contentType}, #{message.content},
+             #{message.metadata}, #{message.tokenCount}, #{message.modelName}, #{message.costTime},
+             #{message.status}, NOW())
+        </foreach>
+    </insert>
+
+    <!-- 新增消息 -->
+    <insert id="insertChatMessage" parameterType="com.fs.crm.domain.CrmCustomerChatMessage">
+        INSERT INTO crm_customer_chat_message
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="sessionId != null">session_id,</if>
+            <if test="senderType != null">sender_type,</if>
+            <if test="contentType != null">content_type,</if>
+            <if test="content != null and content != ''">content,</if>
+            <if test="metadata != null">metadata,</if>
+            <if test="tokenCount != null">token_count,</if>
+            <if test="modelName != null">model_name,</if>
+            <if test="costTime != null">cost_time,</if>
+            <if test="status != null">status,</if>
+            create_time,
+        </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="sessionId != null">#{sessionId},</if>
+            <if test="senderType != null">#{senderType},</if>
+            <if test="contentType != null">#{contentType},</if>
+            <if test="content != null and content != ''">#{content},</if>
+            <if test="metadata != null">#{metadata},</if>
+            <if test="tokenCount != null">#{tokenCount},</if>
+            <if test="modelName != null">#{modelName},</if>
+            <if test="costTime != null">#{costTime},</if>
+            <if test="status != null">#{status},</if>
+            NOW(),
+        </trim>
+    </insert>
+
+    <!-- 更新消息 -->
+    <update id="updateChatMessage" parameterType="com.fs.crm.domain.CrmCustomerChatMessage">
+        UPDATE crm_customer_chat_message
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="content != null">content = #{content},</if>
+            <if test="metadata != null">metadata = #{metadata},</if>
+            <if test="tokenCount != null">token_count = #{tokenCount},</if>
+            <if test="modelName != null">model_name = #{modelName},</if>
+            <if test="costTime != null">cost_time = #{costTime},</if>
+            <if test="status != null">status = #{status},</if>
+            update_time = NOW(),
+        </trim>
+        WHERE msg_id = #{msgId}
+    </update>
+
+    <!-- 删除消息 -->
+    <delete id="deleteChatMessageById" parameterType="Long">
+        DELETE FROM crm_customer_chat_message WHERE msg_id = #{msgId}
+    </delete>
+
+    <!-- 批量删除消息 -->
+    <delete id="deleteChatMessageByIds" parameterType="Long">
+        DELETE FROM crm_customer_chat_message WHERE msg_id IN
+        <foreach item="msgId" collection="array" open="(" separator="," close=")">
+            #{msgId}
+        </foreach>
+    </delete>
+
+</mapper>

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff