瀏覽代碼

cid外呼

wangxy 3 天之前
父節點
當前提交
71b76cb497
共有 100 個文件被更改,包括 6041 次插入845 次删除
  1. 9 0
      fs-admin/src/main/java/com/fs/company/controller/CompanyWxAccountController.java
  2. 13 18
      fs-admin/src/main/java/com/fs/his/controller/EasyCallController.java
  3. 3 2
      fs-admin/src/main/java/com/fs/his/controller/FsExternalOrderController.java
  4. 4 4
      fs-ai-call-task/pom.xml
  5. 149 3
      fs-ai-call-task/src/main/java/com/fs/app/service/CallTaskService.java
  6. 23 0
      fs-ai-call-task/src/main/java/com/fs/app/task/Task.java
  7. 106 3
      fs-cid-workflow/src/main/java/com/fs/app/service/CidWorkflowTaskService.java
  8. 114 0
      fs-cid-workflow/src/main/java/com/fs/app/service/OutboundRetryTaskService.java
  9. 25 0
      fs-cid-workflow/src/main/java/com/fs/app/task/CidTask.java
  10. 31 0
      fs-common/src/main/java/com/fs/common/annotation/CallbackIpCheck.java
  11. 45 11
      fs-common/src/main/java/com/fs/common/constant/Constants.java
  12. 14 0
      fs-common/src/main/java/com/fs/common/core/redis/RedisCache.java
  13. 276 0
      fs-company/src/main/java/com/fs/company/controller/common/RecordingProxyController.java
  14. 2 2
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticCallBlacklistController.java
  15. 12 1
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticCallLogAddwxController.java
  16. 158 5
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticCallLogCallphoneController.java
  17. 20 2
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticCallLogSendmsgController.java
  18. 2 2
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticCalleesController.java
  19. 131 13
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticController.java
  20. 1 1
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticWxController.java
  21. 67 0
      fs-company/src/main/java/com/fs/company/controller/company/CompanyWorkflowController.java
  22. 161 13
      fs-company/src/main/java/com/fs/company/controller/company/CompanyWxAccountController.java
  23. 13 2
      fs-company/src/main/java/com/fs/company/controller/company/CompanyWxClientController.java
  24. 9 2
      fs-company/src/main/java/com/fs/company/controller/company/CompanyWxDialogController.java
  25. 7 7
      fs-company/src/main/java/com/fs/company/controller/company/CompanyWxUserController.java
  26. 1 1
      fs-company/src/main/java/com/fs/company/controller/company/CompanyWxUserGroupController.java
  27. 261 0
      fs-company/src/main/java/com/fs/company/controller/company/EasyCallController.java
  28. 40 0
      fs-company/src/main/java/com/fs/company/controller/company/GeneralCustomerEntryController.java
  29. 247 37
      fs-company/src/main/java/com/fs/company/controller/crm/CrmCustomerController.java
  30. 19 10
      fs-company/src/main/java/com/fs/company/controller/store/FsExternalOrderController.java
  31. 11 10
      fs-company/src/main/java/com/fs/company/controller/store/FsFinanceOrderController.java
  32. 8 7
      fs-company/src/main/java/com/fs/company/controller/store/FsRejectNoReturnController.java
  33. 15 14
      fs-company/src/main/java/com/fs/company/controller/store/FsRejectReportController.java
  34. 15 14
      fs-company/src/main/java/com/fs/company/controller/store/FsReturnReportController.java
  35. 5 4
      fs-company/src/main/java/com/fs/company/controller/store/FsSignDetailReportController.java
  36. 8 7
      fs-company/src/main/java/com/fs/company/controller/store/FsSignReportController.java
  37. 116 0
      fs-company/src/main/java/com/fs/company/controller/wx/controller/WxSopController.java
  38. 116 0
      fs-company/src/main/java/com/fs/company/controller/wx/controller/WxSopLogsController.java
  39. 90 0
      fs-company/src/main/java/com/fs/company/controller/wx/controller/WxSopUserController.java
  40. 90 0
      fs-company/src/main/java/com/fs/company/controller/wx/controller/WxSopUserInfoController.java
  41. 3 0
      fs-company/src/main/java/com/fs/framework/config/SecurityConfig.java
  42. 2 0
      fs-service/src/main/java/com/fs/aicall/domain/CcTtsAliyun.java
  43. 30 0
      fs-service/src/main/java/com/fs/company/domain/CompanyInboundBind.java
  44. 42 0
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceCloneRef.java
  45. 7 0
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRobotic.java
  46. 6 0
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRoboticCallLogAddwx.java
  47. 67 0
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRoboticCallLogCallphone.java
  48. 3 0
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRoboticCallLogSendmsg.java
  49. 32 3
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRoboticCallees.java
  50. 0 1
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRoboticWx.java
  51. 8 0
      fs-service/src/main/java/com/fs/company/domain/CompanyWorkflow.java
  52. 50 2
      fs-service/src/main/java/com/fs/company/domain/CompanyWxAccount.java
  53. 3 1
      fs-service/src/main/java/com/fs/company/domain/CompanyWxDialog.java
  54. 111 0
      fs-service/src/main/java/com/fs/company/domain/EasyCallInboundCdrVO.java
  55. 47 0
      fs-service/src/main/java/com/fs/company/domain/OutboundLineLimitLog.java
  56. 26 0
      fs-service/src/main/java/com/fs/company/dto/OutboundLimitResultVO.java
  57. 32 1
      fs-service/src/main/java/com/fs/company/mapper/CompanyAiWorkflowExecMapper.java
  58. 42 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceCloneRefMapper.java
  59. 21 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceRoboticCallLogAddwxMapper.java
  60. 28 7
      fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceRoboticCallLogCallphoneMapper.java
  61. 10 10
      fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceRoboticCallLogSendmsgMapper.java
  62. 2 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceRoboticWxMapper.java
  63. 2 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyWxAccountMapper.java
  64. 9 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyWxClientMapper.java
  65. 14 0
      fs-service/src/main/java/com/fs/company/mapper/EasyCallMapper.java
  66. 18 0
      fs-service/src/main/java/com/fs/company/mapper/OutboundLineLimitLogMapper.java
  67. 25 0
      fs-service/src/main/java/com/fs/company/param/AppendCustomersParam.java
  68. 166 0
      fs-service/src/main/java/com/fs/company/param/EntryCustomerParam.java
  69. 18 0
      fs-service/src/main/java/com/fs/company/param/InboundCallbackParam.java
  70. 26 0
      fs-service/src/main/java/com/fs/company/param/PauseRoboticActiveParam.java
  71. 8 0
      fs-service/src/main/java/com/fs/company/service/CompanyWorkflowEngine.java
  72. 15 0
      fs-service/src/main/java/com/fs/company/service/ICompanyAiCallDataSyncService.java
  73. 62 0
      fs-service/src/main/java/com/fs/company/service/ICompanyInboundBindService.java
  74. 80 0
      fs-service/src/main/java/com/fs/company/service/ICompanyInboundCallManageService.java
  75. 2 1
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceApiService.java
  76. 2 1
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceBlacklistService.java
  77. 2 1
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceCallerService.java
  78. 15 0
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceCloneRefService.java
  79. 63 0
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceCloneService.java
  80. 2 1
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceConfigService.java
  81. 3 3
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceLogsService.java
  82. 13 0
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticCallLogAddwxService.java
  83. 29 6
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticCallLogCallphoneService.java
  84. 1 1
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticCallLogSendmsgService.java
  85. 43 5
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticService.java
  86. 2 0
      fs-service/src/main/java/com/fs/company/service/ICompanyWorkflowService.java
  87. 21 7
      fs-service/src/main/java/com/fs/company/service/ICompanyWxAccountService.java
  88. 9 9
      fs-service/src/main/java/com/fs/company/service/ICompanyWxChatService.java
  89. 16 9
      fs-service/src/main/java/com/fs/company/service/ICompanyWxClientService.java
  90. 9 9
      fs-service/src/main/java/com/fs/company/service/ICompanyWxDialogService.java
  91. 18 0
      fs-service/src/main/java/com/fs/company/service/IGeneralCustomerEntryService.java
  92. 336 12
      fs-service/src/main/java/com/fs/company/service/easycall/EasyCallServiceImpl.java
  93. 9 1
      fs-service/src/main/java/com/fs/company/service/easycall/IEasyCallService.java
  94. 84 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanyAiCallDataSyncServiceImpl.java
  95. 447 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceCloneServiceImpl.java
  96. 16 3
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallLogAddwxServiceImpl.java
  97. 450 222
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallLogCallphoneServiceImpl.java
  98. 9 9
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallLogSendmsgServiceImpl.java
  99. 7 1
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCalleesServiceImpl.java
  100. 981 324
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java

+ 9 - 0
fs-admin/src/main/java/com/fs/company/controller/CompanyWxAccountController.java

@@ -112,4 +112,13 @@ public class CompanyWxAccountController extends BaseController
     public R companyListAll(){
         return R.ok().put("data", companyWxEnterpriseAccountService.companyListAll());
     }
+
+    /**
+     * 同步微信联系人和群聊
+     */
+    @GetMapping("/syncWx")
+    public R syncWx(Long accountId){
+        companyWxEnterpriseAccountService.syncWx(accountId);
+        return R.ok();
+    }
 }

+ 13 - 18
fs-admin/src/main/java/com/fs/his/controller/EasyCallController.java

@@ -6,6 +6,7 @@ import com.fs.common.core.domain.R;
 import com.fs.common.utils.StringUtils;
 import com.fs.company.service.easycall.IEasyCallService;
 import com.fs.company.vo.easycall.*;
+import com.fs.his.config.CidPhoneConfig;
 import com.fs.system.domain.SysConfig;
 import com.fs.system.service.ISysConfigService;
 import io.swagger.annotations.Api;
@@ -74,25 +75,19 @@ public class EasyCallController extends BaseController {
             }
             SysConfig cidConf = iSysConfigService.selectConfigByConfigKey("cid.config");
             if (null != cidConf && StringUtils.isNotBlank(cidConf.getConfigValue())) {
-                JSONObject jo = JSONObject.parseObject(cidConf.getConfigValue());
-                if (null != jo && jo.containsKey("showGatewayIds") && jo.containsKey("enableGateWayLimit")) {
-                    Boolean enableGateWayLimit = jo.getBoolean("enableGateWayLimit");
-                    List<Long> gatewayIdList = jo.getJSONArray("showGatewayIds").toJavaList(Long.class);
-                    if (null != enableGateWayLimit && enableGateWayLimit && gatewayIdList != null && !gatewayIdList.isEmpty()) {
-                        // 将配置中的网关ID列表转换为Set集合便于快速匹配
-                        Set<Long> gatewayIdSet = gatewayIdList.stream()
-                                .filter(id -> id != null)
-                                .collect(Collectors.toSet());
-                        // 过滤list,只保留id在配置集合中的网关
-                        resList = list.stream()
-                                .filter(gateway -> gateway.getId() != null && gatewayIdSet.contains(gateway.getId()))
-                                .collect(Collectors.toList());
-                    } else {
-                        // 配置为空时返回原始列表
-                        resList = list;
-                    }
+                CidPhoneConfig cidPhoneConfig = JSONObject.parseObject(cidConf.getConfigValue(), CidPhoneConfig.class);
+                if (null != cidPhoneConfig
+                        && Boolean.TRUE.equals(cidPhoneConfig.getEnableGateWayLimit())
+                        && cidPhoneConfig.getShowGatewayIds() != null
+                        && !cidPhoneConfig.getShowGatewayIds().isEmpty()) {
+                    Set<Long> gatewayIdSet = cidPhoneConfig.getShowGatewayIds().stream()
+                            .filter(id -> id != null)
+                            .collect(Collectors.toSet());
+                    resList = list.stream()
+                            .filter(gateway -> gateway.getId() != null && gatewayIdSet.contains(gateway.getId()))
+                            .collect(Collectors.toList());
                 } else {
-                    // 未配置showGatewayIds时返回原始列表
+                    // 未开启网关限制或未配置网关时返回原始列表
                     resList = list;
                 }
             } else {

+ 3 - 2
fs-admin/src/main/java/com/fs/his/controller/FsExternalOrderController.java

@@ -9,6 +9,7 @@ import com.fs.common.enums.BusinessType;
 import com.fs.common.utils.SecurityUtils;
 import com.fs.his.domain.FsExternalOrder;
 import com.fs.his.domain.vo.FsExternalOrderListVO;
+import com.fs.his.param.FsExternalOrderQueryParam;
 import com.fs.his.service.IFsExternalOrderService;
 import com.fs.his.vo.FsExternalOrderDetailVO;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -26,9 +27,9 @@ public class FsExternalOrderController extends BaseController {
 
     @GetMapping("/list")
     @PreAuthorize("@ss.hasPermi('his:externalOrder:list')")
-    public TableDataInfo list(FsExternalOrder fsExternalOrder) {
+    public TableDataInfo list(FsExternalOrderQueryParam param) {
         startPage();
-        List<FsExternalOrderListVO> list = fsExternalOrderService.selectExternalOrderListVO(fsExternalOrder);
+        List<FsExternalOrderListVO> list = fsExternalOrderService.selectExternalOrderListVO(param);
         return getDataTable(list);
     }
 

+ 4 - 4
fs-ai-call-task/pom.xml

@@ -79,10 +79,10 @@
         </dependency>
 
         <!--        <dependency>-->
-<!--            <groupId>ru.yandex.clickhouse</groupId>-->
-<!--            <artifactId>clickhouse-jdbc</artifactId>-->
-<!--            <version>0.3.2</version>-->
-<!--        </dependency>-->
+        <!--            <groupId>ru.yandex.clickhouse</groupId>-->
+        <!--            <artifactId>clickhouse-jdbc</artifactId>-->
+        <!--            <version>0.3.2</version>-->
+        <!--        </dependency>-->
 
         <!-- 验证码 -->
         <dependency>

+ 149 - 3
fs-ai-call-task/src/main/java/com/fs/app/service/CallTaskService.java

@@ -1,9 +1,14 @@
 package com.fs.app.service;
 
 import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.core.redis.RedisCacheT;
+import com.fs.company.domain.CompanyVoiceRobotic;
+import com.fs.company.mapper.CompanyVoiceRoboticMapper;
 import com.fs.company.param.ExecutionContext;
+import com.fs.company.param.PauseRoboticActiveParam;
 import com.fs.company.service.*;
 import com.fs.company.service.impl.call.node.AiCallTaskNode;
 import com.fs.course.config.RedisKeyScanner;
@@ -11,9 +16,12 @@ import lombok.AllArgsConstructor;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Service;
 
+import java.time.LocalTime;
 import java.util.*;
 import java.util.concurrent.*;
 
@@ -30,6 +38,8 @@ public class CallTaskService {
 
     private final RedisCache redisCache2;
     private final CompanyWorkflowEngine companyWorkflowEngine;
+    private final ICompanyVoiceRoboticService companyVoiceRoboticService;
+    private final CompanyVoiceRoboticMapper companyVoiceRoboticMapper;
     private final ExecutorService cidExcutor = new ThreadPoolExecutor(
             32,
             64,
@@ -39,6 +49,11 @@ public class CallTaskService {
             r -> new Thread(r, "callPool-" + System.currentTimeMillis()),
             new ThreadPoolExecutor.CallerRunsPolicy()
     );
+
+    @Autowired
+    @Qualifier("cidWorkFlowExecutor")
+    private Executor cidWorkFlowExecutor;
+
     private final RedisKeyScanner redisKeyScanner;
 
     /**
@@ -49,25 +64,156 @@ public class CallTaskService {
         String delayCallKeyPrefix = AiCallTaskNode.getDelayCallKeyPrefix(cidGroupNo,null) + "*";
         Set<String> keys = redisKeyScanner.scanMatchKey(delayCallKeyPrefix);
         log.info("共扫描到 {} 个待处理键", keys.size());
+
+        // 本地缓存已查询的任务暂停状态,避免同一批次重复查询
+        Map<Long, Boolean> pausedCache = new ConcurrentHashMap<>();
         keys.parallelStream().forEach(key -> {
             try {
                 //doExec
                 CompletableFuture.runAsync(() -> {
                     try {
                         ExecutionContext context = redisCache2.getCacheObject(key);
+                        if (context == null) {
+                            log.warn("工作流延时任务context为空,跳过 - key: {}", key);
+                            redisCache2.deleteObject(key);
+                            return;
+                        }
+                        // 任务暂停守卫检查(roboticId即CompanyVoiceRobotic.id,是实际暂停操作的目标)
+                        Long taskId = context.getVariable("roboticId", Long.class);
+                        if (taskId != null && pausedCache.computeIfAbsent(taskId, id -> companyVoiceRoboticService.isTaskPaused(id))) {
+                            // 延时key是时间分片前缀,下一分钟就不会再扫到,直接删除
+                            // 同步context信息到DB exec,供恢复时resumePausedInstances使用
+                            context.setVariable("callSource", "callTaskTimer");
+                            context.setVariable("_delayTargetNodeKey", context.getCurrentNodeKey());
+                            companyWorkflowEngine.updateExecVariables(context.getWorkflowInstanceId(), context.getVariables());
+                            log.info("任务已暂停,删除延时key并同步exec,等待恢复时从DB重建 - taskId: {}, key: {}", taskId, key);
+                            redisCache2.deleteObject(key);
+                            return;
+                        }
                         context.setVariable("callRedisKey", key);
                         context.setVariable("callSource", "callTaskTimer");
                         companyWorkflowEngine.timeDoExecute(context.getWorkflowInstanceId(), context.getCurrentNodeKey(), context.getVariables());
+                        redisCache2.deleteObject(key);
                     } catch (Exception e) {
                         log.error("处理工作流延时任务异常 - key: {}", key, e);
                     }
-                }, cidExcutor).thenRun(()->{
-                    redisCache2.deleteObject(key);
-                });
+                }, cidWorkFlowExecutor);
             } catch (Exception ex) {
                 log.error("处理工作流延时任务异常 - key: {}", key, ex);
             }
         });
         log.info("===========工作流延时任务扫描结束===========");
     }
+
+
+    /**
+     * 检查任务状态来暂停外呼任务的外呼运行
+     */
+    public void taskStatusChange(){
+        LocalTime now = LocalTime.now();
+        log.info("===========任务状态变更扫描开始, 当前时间: {}, 分组: {}===========", now, cidGroupNo);
+        pauseOutOfRangeTasks(now);
+        resumeInRangeTasks(now);
+        log.info("===========任务状态变更扫描结束===========");
+    }
+
+    private void pauseOutOfRangeTasks(LocalTime now) {
+        List<CompanyVoiceRobotic> activeTasks = companyVoiceRoboticMapper.selectList(
+                new QueryWrapper<CompanyVoiceRobotic>()
+                        .eq("task_status", 1)
+                        .eq("task_type", 1)
+                        .eq("cid_group_no", cidGroupNo)
+                        .eq("del_flag", 0)
+        );
+        log.info("扫描到执行中的普通任务 {} 个", activeTasks.size());
+        if (activeTasks.isEmpty()) {
+            return;
+        }
+        List<CompletableFuture<Void>> futures = new ArrayList<>();
+        for (CompanyVoiceRobotic task : activeTasks) {
+            if (isOutOfRuntimeRange(task, now)) {
+                log.info("任务[{}]运行时间范围[{},{}),当前{}超出范围,准备自动暂停", task.getId(), task.getRuntimeRangeStart(), task.getRuntimeRangeEnd(), now);
+                futures.add(CompletableFuture.runAsync(() -> {
+                    try {
+                        PauseRoboticActiveParam param = new PauseRoboticActiveParam();
+                        param.setTaskId(task.getId());
+                        param.setActiveType(1);
+                        companyVoiceRoboticService.pauseRoboticActive(param);
+                        companyVoiceRoboticMapper.update(null,
+                                new LambdaUpdateWrapper<CompanyVoiceRobotic>()
+                                        .eq(CompanyVoiceRobotic::getId, task.getId())
+                                        .set(CompanyVoiceRobotic::getPauseSource, "auto")
+                        );
+                        log.info("任务[{}]超出运行时间范围,已自动暂停", task.getId());
+                    } catch (Exception e) {
+                        log.error("自动暂停任务[{}]异常", task.getId(), e);
+                    }
+                }, cidWorkFlowExecutor));
+            }
+        }
+        if (!futures.isEmpty()) {
+            log.info("本次需自动暂停 {} 个任务,等待并行执行完成", futures.size());
+            CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+        }
+    }
+
+    private void resumeInRangeTasks(LocalTime now) {
+        List<CompanyVoiceRobotic> pausedTasks = companyVoiceRoboticMapper.selectList(
+                new QueryWrapper<CompanyVoiceRobotic>()
+                        .eq("task_status", 2)
+                        .eq("task_type", 1)
+                        .eq("pause_source", "auto")
+                        .eq("cid_group_no", cidGroupNo)
+                        .eq("del_flag", 0)
+        );
+        log.info("扫描到自动暂停中的普通任务 {} 个", pausedTasks.size());
+        if (pausedTasks.isEmpty()) {
+            return;
+        }
+        List<CompletableFuture<Void>> futures = new ArrayList<>();
+        for (CompanyVoiceRobotic task : pausedTasks) {
+            if (isWithinRuntimeRange(task, now)) {
+                log.info("任务[{}]运行时间范围[{},{}),当前{}在范围内,准备自动恢复", task.getId(), task.getRuntimeRangeStart(), task.getRuntimeRangeEnd(), now);
+                futures.add(CompletableFuture.runAsync(() -> {
+                    try {
+                        PauseRoboticActiveParam param = new PauseRoboticActiveParam();
+                        param.setTaskId(task.getId());
+                        param.setActiveType(2);
+                        companyVoiceRoboticService.pauseRoboticActive(param);
+                        companyVoiceRoboticMapper.update(null,
+                                new LambdaUpdateWrapper<CompanyVoiceRobotic>()
+                                        .eq(CompanyVoiceRobotic::getId, task.getId())
+                                        .set(CompanyVoiceRobotic::getPauseSource, null)
+                        );
+                        log.info("任务[{}]回到运行时间范围,已自动恢复", task.getId());
+                    } catch (Exception e) {
+                        log.error("自动恢复任务[{}]异常", task.getId(), e);
+                    }
+                }, cidWorkFlowExecutor));
+            }
+        }
+        if (!futures.isEmpty()) {
+            log.info("本次需自动恢复 {} 个任务,等待并行执行完成", futures.size());
+            CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+        }
+    }
+
+    private boolean isOutOfRuntimeRange(CompanyVoiceRobotic task, LocalTime now) {
+        LocalTime start = task.getRuntimeRangeStart();
+        LocalTime end = task.getRuntimeRangeEnd();
+        if (start == null || end == null) {
+            return false;
+        }
+        if (start.isBefore(end)) {
+            return now.isBefore(start) || now.isAfter(end);
+        } else {
+            return now.isAfter(end) && now.isBefore(start);
+        }
+    }
+
+    private boolean isWithinRuntimeRange(CompanyVoiceRobotic task, LocalTime now) {
+        return !isOutOfRuntimeRange(task, now);
+    }
+
+
 }

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

@@ -38,8 +38,31 @@ public class Task {
 //    }
 
 
+//    @Scheduled(cron = "0/30 * * * * ?")
+//    public void cidWorkflowRun(){
+//        taskService.cidWorkflowCallRun();
+//    }
+
     @Scheduled(cron = "0/30 * * * * ?")
     public void cidWorkflowRun(){
+
+        if (taskService == null) {
+            log.error("[Task] taskService 未注入,请检查 Spring 配置");
+            return;
+        }
         taskService.cidWorkflowCallRun();
     }
+
+
+    @Scheduled(cron = "0 0/1 * * * ?")
+    public void taskStatusChange(){
+
+        if (taskService == null) {
+            log.error("[Task] taskService 未注入,请检查 Spring 配置");
+            return;
+        }
+
+            taskService.taskStatusChange();
+        }
+
 }

+ 106 - 3
fs-cid-workflow/src/main/java/com/fs/app/service/CidWorkflowTaskService.java

@@ -1,5 +1,7 @@
 package com.fs.app.service;
+import com.alibaba.fastjson.JSONObject;
 import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.StringUtils;
 import com.fs.company.domain.*;
 import com.fs.company.mapper.*;
 import com.fs.company.param.ExecutionContext;
@@ -29,6 +31,7 @@ public class CidWorkflowTaskService {
     Integer cidGroupNo;
     private final CompanyAiWorkflowExecMapper companyAiWorkflowExecMapper;
     private final CompanyWorkflowEngine companyWorkflowEngine;
+    private final ICompanyVoiceRoboticService companyVoiceRoboticService;
 
     private final RedisKeyScanner redisKeyScanner;
 
@@ -41,15 +44,51 @@ public class CidWorkflowTaskService {
     @Autowired
     private WorkflowNodeFactory nodeFactory;
 
+    /** 认领态(RUNNING)超时分钟数,超过该时间未流转的任务视为卡死并回扫重置为失败 */
+    @Value("${cid-workflow.running-timeout-minutes:30}")
+    Integer runningTimeoutMinutes;
+
     /**
      * 扫描当前分组下就绪任务,并开启执行
      */
     public void runCidWorkflow() {
+//        List<CompanyAiWorkflowExec> companyAiWorkflowExecs = companyAiWorkflowExecMapper.selectExecListWithTimeAvailableByStatusAndGroupNo(ExecutionStatusEnum.READY.getValue(), cidGroupNo);
+//        System.out.println(companyAiWorkflowExecs);
+//        if (null != companyAiWorkflowExecs && companyAiWorkflowExecs.size() > 0) {
+//            companyAiWorkflowExecs.parallelStream().forEach(exec -> {
+//                try {
+//                    companyWorkflowEngine.executeNode(exec.getWorkflowInstanceId(), exec.getCurrentNodeKey());
+//                } catch (Exception e) {
+//                    log.error("处理就绪任务异常 - exec: {}", exec, e);
+//                }
+//            });
+//        }
+
         List<CompanyAiWorkflowExec> companyAiWorkflowExecs = companyAiWorkflowExecMapper.selectExecListWithTimeAvailableByStatusAndGroupNo(ExecutionStatusEnum.READY.getValue(), cidGroupNo);
-        System.out.println(companyAiWorkflowExecs);
         if (null != companyAiWorkflowExecs && companyAiWorkflowExecs.size() > 0) {
-            companyAiWorkflowExecs.parallelStream().forEach(exec -> {
+            // 本地缓存已查询的任务暂停状态,避免同一批次重复查询
+            Map<Long, Boolean> pausedCache = new HashMap<>();
+            companyAiWorkflowExecs.forEach(exec -> {
                 try {
+                    // 任务暂停守卫检查(从 variables JSON 中提取 roboticId,即 CompanyVoiceRobotic.id)
+                    Long taskId = extractRoboticIdFromExec(exec);
+                    if (taskId != null) {
+                        boolean paused = pausedCache.computeIfAbsent(taskId, id -> companyVoiceRoboticService.isTaskPaused(id));
+                        if (paused) {
+                            log.debug("任务已暂停,跳过执行 - taskId: {}, execId: {}", taskId, exec.getId());
+                            return;
+                        }
+                    }
+                    // 原子认领:READY -> RUNNING,仅认领成功(影响行数=1)才提交执行,
+                    // 防止上一轮异步任务尚未完成时下一次定时扫描重复调度同一条记录
+                    int claimed = companyAiWorkflowExecMapper.claimExecForRun(
+                            exec.getId(),
+                            ExecutionStatusEnum.READY.getValue(),
+                            ExecutionStatusEnum.RUNNING.getValue());
+                    if (claimed == 0) {
+                        log.debug("任务已被认领,跳过重复执行 - execId: {}", exec.getId());
+                        return;
+                    }
                     companyWorkflowEngine.executeNode(exec.getWorkflowInstanceId(), exec.getCurrentNodeKey());
                 } catch (Exception e) {
                     log.error("处理就绪任务异常 - exec: {}", exec, e);
@@ -62,10 +101,41 @@ public class CidWorkflowTaskService {
      * 扫描可执行任务,并激活执行
      */
     public void activateTimeAvailableTask() {
+//        List<CompanyAiWorkflowExec> companyAiWorkflowExecs = companyAiWorkflowExecMapper.selectExecListWithTimeAvailableByStatusAndGroupNo(ExecutionStatusEnum.PENDING.getValue(), cidGroupNo);
+//        if (null != companyAiWorkflowExecs && companyAiWorkflowExecs.size() > 0) {
+//            companyAiWorkflowExecs.parallelStream().forEach(exec -> {
+//                try {
+//                    companyWorkflowEngine.executeNode(exec.getWorkflowInstanceId(), exec.getCurrentNodeKey());
+//                } catch (Exception e) {
+//                    log.error("处理就绪任务异常 - exec: {}", exec, e);
+//                }
+//            });
+//        }
         List<CompanyAiWorkflowExec> companyAiWorkflowExecs = companyAiWorkflowExecMapper.selectExecListWithTimeAvailableByStatusAndGroupNo(ExecutionStatusEnum.PENDING.getValue(), cidGroupNo);
         if (null != companyAiWorkflowExecs && companyAiWorkflowExecs.size() > 0) {
-            companyAiWorkflowExecs.parallelStream().forEach(exec -> {
+            // 本地缓存已查询的任务暂停状态,避免同一批次重复查询
+            Map<Long, Boolean> pausedCache = new HashMap<>();
+            companyAiWorkflowExecs.forEach(exec -> {
                 try {
+                    // 任务暂停守卫检查(从 variables JSON 中提取 roboticId,即 CompanyVoiceRobotic.id)
+                    Long taskId = extractRoboticIdFromExec(exec);
+                    if (taskId != null) {
+                        boolean paused = pausedCache.computeIfAbsent(taskId, id -> companyVoiceRoboticService.isTaskPaused(id));
+                        if (paused) {
+                            log.debug("任务已暂停,跳过执行 - taskId: {}, execId: {}", taskId, exec.getId());
+                            return;
+                        }
+                    }
+                    // 原子认领:PENDING -> RUNNING,仅认领成功(影响行数=1)才提交执行,
+                    // 防止上一轮异步任务尚未完成时下一次定时扫描重复激活同一条记录
+                    int claimed = companyAiWorkflowExecMapper.claimExecForRun(
+                            exec.getId(),
+                            ExecutionStatusEnum.PENDING.getValue(),
+                            ExecutionStatusEnum.RUNNING.getValue());
+                    if (claimed == 0) {
+                        log.debug("任务已被认领,跳过重复执行 - execId: {}", exec.getId());
+                        return;
+                    }
                     companyWorkflowEngine.executeNode(exec.getWorkflowInstanceId(), exec.getCurrentNodeKey());
                 } catch (Exception e) {
                     log.error("处理就绪任务异常 - exec: {}", exec, e);
@@ -108,4 +178,37 @@ public class CidWorkflowTaskService {
         });
     }
 
+    /**
+     * 从 exec 记录的 variables JSON 中提取 roboticId(CompanyVoiceRobotic.id)
+     * 用于暂停守卫检查,fallback 到 exec.getBusinessKey()
+     */
+    private Long extractRoboticIdFromExec(CompanyAiWorkflowExec exec) {
+        if (StringUtils.isNotBlank(exec.getVariables())) {
+            try {
+                JSONObject vars = JSONObject.parseObject(exec.getVariables());
+                if (vars != null && vars.containsKey("roboticId")) {
+                    return vars.getLong("roboticId");
+                }
+            } catch (Exception e) {
+                log.warn("extractRoboticIdFromExec解析variables失败 - execId: {}", exec.getId(), e);
+            }
+        }
+        return exec.getBusinessKey();
+    }
+
+    /**
+     * 回扫超时认领任务:将处于 RUNNING 态且超过 runningTimeoutMinutes 分钟未更新的记录重置为 FAILURE。
+     * 用于兜底进程重启 / 线程池拒绝导致任务永久卡在认领态(RUNNING)无法被再次扫描的情况。
+     */
+    public void resetTimeoutRunningTask() {
+        java.time.LocalDateTime timeoutTime = java.time.LocalDateTime.now().minusMinutes(runningTimeoutMinutes);
+        int reset = companyAiWorkflowExecMapper.resetTimeoutRunningExec(
+                cidGroupNo,
+                ExecutionStatusEnum.RUNNING.getValue(),
+                ExecutionStatusEnum.FAILURE.getValue(),
+                timeoutTime);
+        if (reset > 0) {
+            log.warn("回扫超时认领任务,重置为失败状态 - groupNo: {}, count: {}, timeoutMinutes: {}", cidGroupNo, reset, runningTimeoutMinutes);
+        }
+    }
 }

+ 114 - 0
fs-cid-workflow/src/main/java/com/fs/app/service/OutboundRetryTaskService.java

@@ -0,0 +1,114 @@
+package com.fs.app.service;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.company.service.easycall.IEasyCallService;
+import com.fs.company.vo.easycall.EasyCallCommonAddCallListParam;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Set;
+
+/**
+ * 外呼限制重试任务服务(ZSET 方案)
+ * <p>
+ * 使用 Redis Sorted Set 存储待重试任务,score 为 nextAvailableTime 毫秒时间戳。
+ * 定时任务通过 ZRANGEBYSCORE 按批次查询已到期任务,支撑百万级数据量。
+ */
+@Service
+@Slf4j
+public class OutboundRetryTaskService {
+
+    private static final String ZSET_KEY = "outbound:limit:retry:zset";
+    private static final int BATCH_SIZE = 1000;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Autowired
+    private IEasyCallService easyCallService;
+
+    /**
+     * 处理外呼重试任务(ZSET 方案)
+     * <p>
+     * 1. 用 ZRANGEBYSCORE 查询 score <= 当前时间 的任务(即已到期的)
+     * 2. 每次取一批(100 条),循环处理直到没有到期任务为止
+     * 3. 处理完后 ZREM 移除
+     */
+    public void processRetryTasks() {
+        int totalProcessed = 0;
+
+        try {
+            while (true) {
+                long now = System.currentTimeMillis();
+                // 使用 ZRANGEBYSCORE 查询已到期的任务(score <= now),每批最多 BATCH_SIZE 条
+                Set<String> readyTasks = redisCache.zRangeByScore(ZSET_KEY, 0, now, 0, BATCH_SIZE);
+                if (readyTasks == null || readyTasks.isEmpty()) {
+                    break;
+                }
+
+                for (String taskJson : readyTasks) {
+                    try {
+                        processOneRetryTask(taskJson);
+                    } catch (Exception e) {
+                        log.error("processRetryTasks: 处理重试任务异常", e);
+                        // 处理失败也要移除,避免死循环
+                        redisCache.zSetRemove(ZSET_KEY, taskJson);
+                    }
+                    totalProcessed++;
+                }
+            }
+        } catch (Exception e) {
+            log.error("processRetryTasks: 扫描重试任务异常", e);
+        }
+
+        if (totalProcessed > 0) {
+            log.info("processRetryTasks: 本次共处理 {} 个重试任务", totalProcessed);
+        }
+    }
+
+    /**
+     * 处理单个重试任务
+     */
+    private void processOneRetryTask(String taskJson) {
+        JSONObject retryData = JSON.parseObject(taskJson);
+        if (retryData == null) {
+            redisCache.zSetRemove(ZSET_KEY, taskJson);
+            return;
+        }
+
+        Long companyId = retryData.getLong("companyId");
+        Long gatewayId = retryData.getLong("gatewayId");
+        EasyCallCommonAddCallListParam param = retryData.getObject("param", EasyCallCommonAddCallListParam.class);
+
+        if (companyId == null || gatewayId == null || param == null) {
+            log.warn("processOneRetryTask: 参数不完整,移除无效任务");
+            redisCache.zSetRemove(ZSET_KEY, taskJson);
+            return;
+        }
+
+        // 从 param 中获取 batchId(正常流程中 addCommonCallList 前已通过 setBatchId 设置)
+        Long batchId = param.getBatchId();
+
+        log.info("processOneRetryTask: 开始重试外呼 - companyId: {}, gatewayId: {}", companyId, gatewayId);
+
+        // 重新调用 addCommonCallList(内部会再次检查限制,如果仍被限制会再次存入 ZSET)
+        boolean success = easyCallService.addCommonCallList(param, companyId, gatewayId);
+        if (success) {
+            log.info("processOneRetryTask: 重试成功 - companyId: {}, gatewayId: {}", companyId, gatewayId);
+            // 启动外呼任务
+            if (batchId != null) {
+                easyCallService.startTask(batchId, null);
+            } else {
+                log.warn("processOneRetryTask: batchId 为空,跳过启动任务 - companyId: {}, gatewayId: {}", companyId, gatewayId);
+            }
+        } else {
+            log.warn("processOneRetryTask: 重试未成功(可能仍被限制,已重新入队)- companyId: {}, gatewayId: {}", companyId, gatewayId);
+        }
+
+        // 无论成功失败都移除当前记录(如果仍被限制,addCommonCallList 内部会重新 saveRetryTask)
+        redisCache.zSetRemove(ZSET_KEY, taskJson);
+    }
+}

+ 25 - 0
fs-cid-workflow/src/main/java/com/fs/app/task/CidTask.java

@@ -1,6 +1,7 @@
 package com.fs.app.task;
 
 import com.fs.app.service.CidWorkflowTaskService;
+import com.fs.app.service.OutboundRetryTaskService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
@@ -23,6 +24,9 @@ public class CidTask {
     @Autowired
     private CidWorkflowTaskService cidWorkflowTaskService;
 
+    @Autowired
+    private OutboundRetryTaskService outboundRetryTaskService;
+
 
     /**
      * 扫描当前分组下就绪任务,并开启执行
@@ -48,7 +52,28 @@ public class CidTask {
 //        cidWorkflowTaskService.runContinueTask();
 //    }
 
+    /**
+     * 回扫超时认领任务 - 每5分钟执行一次
+     * 将卡在认领态(RUNNING)且长时间未流转的任务重置为失败,兜底进程重启/线程池拒绝导致的永久卡死
+     */
+    @Scheduled(cron = "0 0/5 * * * ?")
+    public void resetTimeoutRunningTask() {
+
+            cidWorkflowTaskService.resetTimeoutRunningTask();
+
+    }
+
+    /**
+     * 外呼重试任务 - 每30分钟执行一次
+     * 扫描 Redis 中被外呼限制拦截的待重试呼叫,到达 nextAvailableTime 后重新执行
+     */
+    @Scheduled(cron = "0 0/30 * * * ?")
+    public void retryOutboundCallTask() {
+        log.info("执行外呼限制重试机制");
+
+        outboundRetryTaskService.processRetryTasks();
 
+    }
 
 
 }

+ 31 - 0
fs-common/src/main/java/com/fs/common/annotation/CallbackIpCheck.java

@@ -0,0 +1,31 @@
+package com.fs.common.annotation;
+
+import java.lang.annotation.*;
+
+/**
+ * 回调来源IP校验注解
+ * <p>
+ * 标注在Controller方法上,自动校验请求来源IP是否在配置的合法IP列表中。
+ * 切面会从方法参数中提取companyId,查询company_config表获取配置,解析出legalIPs后进行校验。
+ * </p>
+ * <pre>
+ * 使用示例:
+ *   @PostMapping("/inboundCallback")
+ *   @CallbackIpCheck
+ *   public String inboundCallback(@RequestBody InboundCallbackParam param) {
+ *       // 业务逻辑 - IP校验已由切面自动完成
+ *   }
+ * </pre>
+ *
+ * @author MixLiu
+ */
+@Target({ElementType.METHOD})
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+public @interface CallbackIpCheck {
+
+    /**
+     * 公司配置的键名,用于从company_config表查询配置
+     */
+    String configKey() default "cId.config";
+}

+ 45 - 11
fs-common/src/main/java/com/fs/common/constant/Constants.java

@@ -172,22 +172,37 @@ public class Constants
     public static final String LOOKUP_LDAP = "ldap://";
 
     public static final Integer PAGE_SIZE =10;
-
+    /**
+     * CID 执行任务ID
+     */
+    public static final String TASK_ID = "cid:task:";
+    /**
+     * 外呼机器人打电话
+     */
+    public static final String CELL_PHONE = "cellPhone";
+    /**
+     * 添加微信
+     */
+    public static final String ADD_WX = "addWx";
+    /**
+     * 企微添加个微
+     */
+    public static final String QW_ADD_WX = "qwAddWx";
     /**
      * 发送短信
      */
     public static final String SEND_MSG = "sendMsg";
     /**
-     * 工作流企微加个微执行状态标记 Key: workflow:addwx:executed:{workflowInstanceId}:{wxClientId}
-     * Value: 1-已执行
-     * 用于实现回调成功和超时互斥,只执行一个
+     * CID 执行下一个 roboticID + calleesId
      */
-    public static final String WORKFLOW_QW_ADD_WX_EXECUTED = "workflow:qwaddwx:executed:";
+    public static final String CID_NEXT_TASK_ID = "cid:next:task:";
+
     /**
-     * 工作流企微加个微超时检测 Key: workflow:addwx:timeout:{workflowInstanceId}:{wxClientId}
+     * 工作流加微超时检测 Key: workflow:addwx:timeout:{workflowInstanceId}:{wxClientId}
      * Value: 超时时间戳
      */
-    public static final String WORKFLOW_QW_ADD_WX_TIMEOUT = "workflow:qwaddwx:timeout:";
+    public static final String WORKFLOW_ADD_WX_TIMEOUT = "workflow:addwx:timeout:";
+
     /**
      * 工作流加微执行状态标记 Key: workflow:addwx:executed:{workflowInstanceId}:{wxClientId}
      * Value: 1-已执行
@@ -195,11 +210,30 @@ public class Constants
      */
     public static final String WORKFLOW_ADD_WX_EXECUTED = "workflow:addwx:executed:";
     /**
-     * 外呼机器人打电话
+     * 工作流企微加个微执行状态标记 Key: workflow:addwx:executed:{workflowInstanceId}:{wxClientId}
+     * Value: 1-已执行
+     * 用于实现回调成功和超时互斥,只执行一个
      */
-    public static final String CELL_PHONE = "cellPhone";
+    public static final String WORKFLOW_QW_ADD_WX_EXECUTED = "workflow:qwaddwx:executed:";
     /**
-     * CID 执行任务ID
+     * 工作流企微加个微超时检测 Key: workflow:addwx:timeout:{workflowInstanceId}:{wxClientId}
+     * Value: 超时时间戳
      */
-    public static final String TASK_ID = "cid:task:";
+    public static final String WORKFLOW_QW_ADD_WX_TIMEOUT = "workflow:qwaddwx:timeout:";
+
+
+    /**
+     * CID 企微加个微 searchContact user_id 待匹配 Key 前缀(需拼接 tenantId 与 userId,避免跨租户冲突)
+     * 完整业务 Key:cid:qwAddWx:searchUserId:{tenantId}:{userId}
+     */
+    public static final String CID_QW_ADD_WX_SEARCH_USER_ID = "cid:qwAddWx:searchUserId:";
+    /**
+     * 构建 CID 企微加微 searchUserId 的 Redis 业务 Key(不含 Redis 序列化层的 tenantid 前缀)
+     */
+    public static String cidQwAddWxSearchUserIdKey(Long userId) {
+        if ( userId == null) {
+            return null;
+        }
+        return CID_QW_ADD_WX_SEARCH_USER_ID  + ":" + userId;
+    }
 }

+ 14 - 0
fs-common/src/main/java/com/fs/common/core/redis/RedisCache.java

@@ -314,6 +314,20 @@ public class RedisCache
         return redisTemplate.opsForZSet().reverseRange(key, start, end);
     }
 
+    /**
+     * 按 score 范围查询有序集合元素(带分页)
+     *
+     * @param key    有序集合键
+     * @param min    最小 score(包含)
+     * @param max    最大 score(包含)
+     * @param offset 偏移量
+     * @param count  数量限制
+     * @return 元素集合
+     */
+    public Set<String> zRangeByScore(String key, double min, double max, long offset, long count) {
+        return redisTemplate.opsForZSet().rangeByScore(key, min, max, offset, count);
+    }
+
     /**
      * 获取有序集合元素数量
      *

+ 276 - 0
fs-company/src/main/java/com/fs/company/controller/common/RecordingProxyController.java

@@ -0,0 +1,276 @@
+package com.fs.company.controller.common;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * 录音文件代理接口
+ * 解决 HTTPS 页面下无法直接访问 HTTP 录音资源的问题
+ */
+@RestController
+public class RecordingProxyController {
+
+    private static final Logger log = LoggerFactory.getLogger(RecordingProxyController.class);
+
+    /**
+     * 允许代理的录音服务器地址白名单
+     */
+    private static final List<String> ALLOWED_HOSTS = Arrays.asList(
+            "129.28.164.235:8899"
+    );
+
+    /**
+     * 允许代理的路径前缀
+     */
+    private static final String ALLOWED_PATH_PREFIX = "/recordings/";
+
+    /**
+     * 流式缓冲区大小
+     */
+    private static final int BUFFER_SIZE = 4096;
+
+    /**
+     * 连接超时时间(毫秒)
+     */
+    private static final int CONNECT_TIMEOUT = 5000;
+
+    /**
+     * 读取超时时间(毫秒)
+     */
+    private static final int READ_TIMEOUT = 30000;
+
+    /**
+     * 录音文件代理接口
+     * 前端通过此接口请求录音文件,后端转发到录音服务器获取文件流
+     *
+     * @param url      录音文件的原始 HTTP 地址
+     * @param request  HTTP 请求
+     * @param response HTTP 响应
+     */
+    @GetMapping("/common/proxy/recording")
+    public void proxyRecording(@RequestParam("url") String url,
+                               HttpServletRequest request,
+                               HttpServletResponse response) {
+        // 1. 安全校验
+        if (!isUrlAllowed(url)) {
+            log.warn("录音代理请求被拒绝,非法地址: {}", url);
+            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
+            try {
+                response.getWriter().write("Access denied: URL not allowed");
+            } catch (IOException e) {
+                log.error("写入错误响应失败", e);
+            }
+            return;
+        }
+
+        HttpURLConnection connection = null;
+        InputStream inputStream = null;
+        OutputStream outputStream = null;
+
+        try {
+            // 2. 建立到录音服务器的连接
+            URL targetUrl = new URL(url);
+            connection = (HttpURLConnection) targetUrl.openConnection();
+            connection.setRequestMethod("GET");
+            connection.setConnectTimeout(CONNECT_TIMEOUT);
+            connection.setReadTimeout(READ_TIMEOUT);
+            connection.setInstanceFollowRedirects(true);
+
+            // 3. 转发 Range 请求头(支持音频播放器拖动进度条)
+            String rangeHeader = request.getHeader("Range");
+            if (rangeHeader != null && !rangeHeader.isEmpty()) {
+                connection.setRequestProperty("Range", rangeHeader);
+            }
+
+            // 4. 发起请求
+            int responseCode = connection.getResponseCode();
+
+            // 5. 处理错误响应
+            if (responseCode >= 400) {
+                log.error("录音服务器返回错误状态码: {}, url: {}", responseCode, url);
+                response.setStatus(responseCode);
+                return;
+            }
+
+            // 6. 设置响应状态码(200 或 206)
+            response.setStatus(responseCode);
+
+            // 7. 设置 Content-Type
+            String contentType = connection.getContentType();
+            if (contentType != null && !contentType.isEmpty()) {
+                response.setContentType(contentType);
+            } else {
+                // 根据文件扩展名推断 Content-Type
+                response.setContentType(guessContentType(url));
+            }
+
+            // 7.1 设置 Content-Disposition,使浏览器下载时保存为正确的文件名
+            String fileName = extractFileName(url);
+            if (fileName != null) {
+                response.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"");
+            }
+
+            // 8. 转发关键响应头
+            String contentLength = connection.getHeaderField("Content-Length");
+            if (contentLength != null) {
+                response.setHeader("Content-Length", contentLength);
+            }
+
+            String contentRange = connection.getHeaderField("Content-Range");
+            if (contentRange != null) {
+                response.setHeader("Content-Range", contentRange);
+            }
+
+            String acceptRanges = connection.getHeaderField("Accept-Ranges");
+            if (acceptRanges != null) {
+                response.setHeader("Accept-Ranges", acceptRanges);
+            } else {
+                response.setHeader("Accept-Ranges", "bytes");
+            }
+
+            // 9. 流式传输文件内容
+            inputStream = connection.getInputStream();
+            outputStream = response.getOutputStream();
+            byte[] buffer = new byte[BUFFER_SIZE];
+            int bytesRead;
+            while ((bytesRead = inputStream.read(buffer)) != -1) {
+                outputStream.write(buffer, 0, bytesRead);
+            }
+            outputStream.flush();
+
+        } catch (IOException e) {
+            log.error("录音文件代理请求失败, url: {}", url, e);
+            if (!response.isCommitted()) {
+                response.setStatus(HttpServletResponse.SC_BAD_GATEWAY);
+            }
+        } finally {
+            if (inputStream != null) {
+                try {
+                    inputStream.close();
+                } catch (IOException e) {
+                    log.error("关闭输入流失败", e);
+                }
+            }
+            if (outputStream != null) {
+                try {
+                    outputStream.close();
+                } catch (IOException e) {
+                    log.error("关闭输出流失败", e);
+                }
+            }
+            if (connection != null) {
+                connection.disconnect();
+            }
+        }
+    }
+
+    /**
+     * 校验 URL 是否在允许的白名单范围内
+     *
+     * @param url 待校验的 URL
+     * @return 是否允许代理
+     */
+    private boolean isUrlAllowed(String url) {
+        if (url == null || url.isEmpty()) {
+            return false;
+        }
+
+        // 必须是 http 协议
+        if (!url.startsWith("http://") && !url.startsWith("https://")) {
+            return false;
+        }
+
+        try {
+            URL parsedUrl = new URL(url);
+            String host = parsedUrl.getHost();
+            int port = parsedUrl.getPort();
+            String hostWithPort = port > 0 ? host + ":" + port : host;
+            String path = parsedUrl.getPath();
+
+            // 校验主机地址是否在白名单内
+            boolean hostAllowed = ALLOWED_HOSTS.contains(hostWithPort);
+
+            // 校验路径是否包含允许的前缀
+            boolean pathAllowed = path != null && path.startsWith(ALLOWED_PATH_PREFIX);
+
+            return hostAllowed && pathAllowed;
+        } catch (Exception e) {
+            log.warn("URL 解析失败: {}", url, e);
+            return false;
+        }
+    }
+
+    /**
+     * 根据文件扩展名推断 Content-Type
+     *
+     * @param url 文件 URL
+     * @return Content-Type
+     */
+    private String guessContentType(String url) {
+        if (url == null) {
+            return "application/octet-stream";
+        }
+        String lowerUrl = url.toLowerCase();
+        if (lowerUrl.contains(".wav")) {
+            return "audio/wav";
+        } else if (lowerUrl.contains(".mp3")) {
+            return "audio/mpeg";
+        } else if (lowerUrl.contains(".ogg")) {
+            return "audio/ogg";
+        } else if (lowerUrl.contains(".flac")) {
+            return "audio/flac";
+        } else if (lowerUrl.contains(".m4a") || lowerUrl.contains(".aac")) {
+            return "audio/aac";
+        }
+        return "application/octet-stream";
+    }
+
+    /**
+     * 从录音文件 URL 中提取文件名
+     * 支持直接路径 /recordings/xxx.wav 和 query 参数 ?filename=xxx.wav
+     *
+     * @param url 录音文件 URL
+     * @return 文件名,无法提取时返回 null
+     */
+    private String extractFileName(String url) {
+        if (url == null || url.isEmpty()) {
+            return null;
+        }
+        try {
+            // 先尝试从 query 参数 filename 获取
+            URL parsedUrl = new URL(url);
+            String query = parsedUrl.getQuery();
+            if (query != null && query.contains("filename=")) {
+                for (String param : query.split("&")) {
+                    if (param.startsWith("filename=")) {
+                        return param.substring("filename=".length());
+                    }
+                }
+            }
+            // 从路径中提取文件名
+            String path = parsedUrl.getPath();
+            if (path != null && path.contains("/")) {
+                String name = path.substring(path.lastIndexOf('/') + 1);
+                if (!name.isEmpty()) {
+                    return name;
+                }
+            }
+        } catch (Exception e) {
+            log.debug("提取文件名失败: {}", url);
+        }
+        return null;
+    }
+}

+ 2 - 2
fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticCallBlacklistController.java

@@ -87,7 +87,7 @@ public class CompanyVoiceRoboticCallBlacklistController extends BaseController
      * */
     @PutMapping
     @PreAuthorize("@ss.hasPermi('company:companyVoiceBlacklist:update')")
-    @Log(title = "修改黑名单信息", businessType = BusinessType.INSERT)
+    @Log(title = "修改黑名单信息", businessType = BusinessType.UPDATE)
     public AjaxResult edit(@RequestBody CompanyVoiceRoboticCallBlacklist companyVoiceRoboticCallBlacklist)
     {
         return toAjax(companyVoiceRoboticCallBlacklistService.updateCompanyVoiceRoboticCallBlacklist(companyVoiceRoboticCallBlacklist));
@@ -104,7 +104,7 @@ public class CompanyVoiceRoboticCallBlacklistController extends BaseController
         return toAjax(companyVoiceRoboticCallBlacklistService.deleteCompanyVoiceRoboticCallBlacklistByIds(blacklistIds));
     }
 
-    @Log(title = "修改黑名单状态", businessType = BusinessType.UPDATE)
+    @Log(title = "修改黑名单失效或启用状态", businessType = BusinessType.UPDATE)
     @PreAuthorize("@ss.hasPermi('company:companyVoiceBlacklist:update')")
     @PutMapping("/changeStatus")
     public AjaxResult changeStatus(@RequestBody CompanyVoiceRoboticCallBlacklist companyVoiceRoboticCallBlacklist) {

+ 12 - 1
fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticCallLogAddwxController.java

@@ -5,12 +5,16 @@ 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.poi.ExcelUtil;
 import com.fs.company.domain.CompanyVoiceRoboticCallLogAddwx;
 import com.fs.company.domain.CompanyWxClient;
 import com.fs.company.service.ICompanyVoiceRoboticCallLogAddwxService;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogAddWxExportVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogAddwxVO;
+import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
@@ -31,7 +35,8 @@ public class CompanyVoiceRoboticCallLogAddwxController extends BaseController
 {
     @Autowired
     private ICompanyVoiceRoboticCallLogAddwxService companyVoiceRoboticCallLogAddwxService;
-
+    @Autowired
+    private TokenService tokenService;
     /**
      * 查询调用日志_加微信列表
      */
@@ -73,6 +78,12 @@ public class CompanyVoiceRoboticCallLogAddwxController extends BaseController
     public TableDataInfo listAll(CompanyVoiceRoboticCallLogAddwx companyVoiceRoboticCallLogAddwx)
     {
         startPage();
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        companyVoiceRoboticCallLogAddwx.setCompanyId(loginUser.getCompany().getCompanyId());
+
+        if (!"00".equals(loginUser.getUser().getUserType())){
+            companyVoiceRoboticCallLogAddwx.setCompanyUserId(loginUser.getUser().getUserId());
+        }
         List<CompanyVoiceRoboticCallLogAddwxVO> list = companyVoiceRoboticCallLogAddwxService.listAll(companyVoiceRoboticCallLogAddwx);
         TableDataInfo dataTable = getDataTable(list);
         Map<String, Long> countMap = companyVoiceRoboticCallLogAddwxService.countListAll(companyVoiceRoboticCallLogAddwx);

+ 158 - 5
fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticCallLogCallphoneController.java

@@ -5,23 +5,28 @@ 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.poi.ExcelUtil;
 import com.fs.company.domain.CompanyVoiceRoboticCallLogCallphone;
+import com.fs.company.service.ICompanyAiCallDataSyncService;
 import com.fs.company.service.ICompanyVoiceRoboticCallLogCallphoneService;
+import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
+import com.fs.crm.domain.CrmCustomer;
+import com.fs.crm.service.ICrmCustomerService;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.*;
 
 import java.util.ArrayList;
 import java.util.List;
 
 /**
  * 调用日志_ai打电话Controller
- * 
+ *
  * @author fs
  * @date 2026-01-15
  */
@@ -32,6 +37,30 @@ public class CompanyVoiceRoboticCallLogCallphoneController extends BaseControlle
     @Autowired
     private ICompanyVoiceRoboticCallLogCallphoneService companyVoiceRoboticCallLogCallphoneService;
 
+    @Autowired
+    private ICrmCustomerService crmCustomerService;
+    @Autowired
+    private TokenService tokenService;
+
+    @Autowired
+    private ICompanyAiCallDataSyncService companyAiCallDataSyncService;
+
+    /**
+     * 异步同步 AI 外呼数据(补偿 EasyCall 已完成但未回调的记录)
+     */
+    @PreAuthorize("@ss.hasPermi('company:callphoneDetail:list')")
+    @Log(title = "同步AI外呼数据", businessType = BusinessType.OTHER)
+    @PostMapping("/syncAiCallData")
+    public AjaxResult syncAiCallData() {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getCompany().getCompanyId();
+
+        if (!companyAiCallDataSyncService.tryStartSync(companyId)) {
+            return AjaxResult.error("正在同步中,请勿重复操作");
+        }
+        return AjaxResult.success("正在同步中");
+    }
+
     /**
      * 查询调用日志_ai打电话列表
      */
@@ -47,6 +76,7 @@ public class CompanyVoiceRoboticCallLogCallphoneController extends BaseControlle
                 companyVoiceRoboticCallLogCallphone.setCallerIds(calleeIds);
                 startPage();
                 List<CompanyVoiceRoboticCallLogCallphone> list = companyVoiceRoboticCallLogCallphoneService.selectCompanyVoiceRoboticCallLogCallphoneListData(companyVoiceRoboticCallLogCallphone);
+
                 return getDataTable(list);
             } else {
                 return getDataTable(new ArrayList<>());
@@ -67,6 +97,30 @@ public class CompanyVoiceRoboticCallLogCallphoneController extends BaseControlle
 
     }
 
+    /**
+     * AI外呼记录明细列表
+     */
+    @PreAuthorize("@ss.hasPermi('company:callphoneDetail:list')")
+    @GetMapping("/detailList")
+    public TableDataInfo detailList(CompanyVoiceRoboticCallLogCallphone param) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        companyVoiceRoboticCallLogCallphoneService.prepareDetailListQuery(param, loginUser.getCompany().getCompanyId());
+        startPage();
+        List<CompanyVoiceRoboticCallLogCallPhoneVO> list = companyVoiceRoboticCallLogCallphoneService.selectDetailList(param);
+        return getDataTable(list);
+    }
+
+    /**
+     * AI外呼记录明细统计
+     */
+    @PreAuthorize("@ss.hasPermi('company:callphoneDetail:list')")
+    @GetMapping("/detailSummary")
+    public AjaxResult detailSummary(CompanyVoiceRoboticCallLogCallphone param) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        companyVoiceRoboticCallLogCallphoneService.prepareDetailListQuery(param, loginUser.getCompany().getCompanyId());
+        return AjaxResult.success(companyVoiceRoboticCallLogCallphoneService.selectDetailSummary(param));
+    }
+
 
     /**
      * 查询调用日志_发送短信列表(按照任务id分组,任务id-任务名称-查询总任务数量-成功数量)
@@ -76,6 +130,14 @@ public class CompanyVoiceRoboticCallLogCallphoneController extends BaseControlle
     public TableDataInfo groupList(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone)
     {
         startPage();
+        if(null == companyVoiceRoboticCallLogCallphone.getCompanyId()){
+            LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+            companyVoiceRoboticCallLogCallphone.setCompanyId(loginUser.getUser().getCompanyId());
+            if (!"00".equals(loginUser.getUser().getUserType())){
+                companyVoiceRoboticCallLogCallphone.setCompanyUserId(loginUser.getUser().getUserId());
+            }
+        }
+
         List<CompanyVoiceRoboticCallLogCallphone> list = companyVoiceRoboticCallLogCallphoneService.selectCompanyVoiceRoboticCallPhoneLogGroupList(companyVoiceRoboticCallLogCallphone);
         return getDataTable(list);
     }
@@ -87,10 +149,72 @@ public class CompanyVoiceRoboticCallLogCallphoneController extends BaseControlle
     @GetMapping("/count")
     public AjaxResult selectCompanyVoiceRoboticCallPhoneLogCount()
     {
-        CompanyVoiceRoboticCallLogCount companyVoiceRoboticCallLogCount = companyVoiceRoboticCallLogCallphoneService.selectCompanyVoiceRoboticCallPhoneLogCount();
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getUser().getCompanyId();
+        Long companyUserId=null;
+        if (!"00".equals(loginUser.getUser().getUserType())){
+            companyUserId=loginUser.getUser().getUserId();
+        }
+        CompanyVoiceRoboticCallLogCount companyVoiceRoboticCallLogCount = companyVoiceRoboticCallLogCallphoneService.selectCompanyVoiceRoboticCallPhoneLogCount(companyId,companyUserId);
         return AjaxResult.success(companyVoiceRoboticCallLogCount);
     }
 
+    /**
+     * 查询转人工接听记录列表
+     */
+    @PreAuthorize("@ss.hasPermi('company:handleManualAnswered:query')")
+    @GetMapping("/manualAnsweredList")
+    public TableDataInfo manualAnsweredList(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        companyVoiceRoboticCallLogCallphone.setCompanyId(loginUser.getCompany().getCompanyId());
+        companyVoiceRoboticCallLogCallphone.setManualAnswered(1);
+        if(!loginUser.getUser().getUserType().equals("00")){
+            companyVoiceRoboticCallLogCallphone.setCompanyUserId(loginUser.getUser().getUserId());
+        }
+        startPage();
+        List<CompanyVoiceRoboticCallLogCallphone> list = companyVoiceRoboticCallLogCallphoneService.selectManualAnsweredList(companyVoiceRoboticCallLogCallphone);
+        return getDataTable(list);
+    }
+
+    /**
+     * 根据外呼记录ID查询CRM客户信息(处理数据弹窗用)
+     */
+    @PreAuthorize("@ss.hasPermi('company:handleManualAnswered:handle')")
+    @GetMapping("/getCrmCustomerByLogId")
+    public AjaxResult getCrmCustomerByLogId(@RequestParam("callphoneLogId") Long callphoneLogId)
+    {
+        CrmCustomer crmCustomer = crmCustomerService.selectCrmCustomerByCallphoneLogId(callphoneLogId);
+        return AjaxResult.success(crmCustomer);
+    }
+
+    /**
+     * 确认信息(仅标记外呼记录为已处理,不更新客户信息)
+     */
+    @PreAuthorize("@ss.hasPermi('company:handleManualAnswered:handle')")
+    @Log(title = "转人工接听确认信息", businessType = BusinessType.UPDATE)
+    @PostMapping("/confirmManualAnswered")
+    public AjaxResult confirmManualAnswered(@RequestBody CompanyVoiceRoboticCallLogCallphone param)
+    {
+        return toAjax(companyVoiceRoboticCallLogCallphoneService.markHandleFlag(param.getLogId()));
+    }
+
+    /**
+     * 处理数据(标记外呼记录为已处理 + 更新CRM客户信息)
+     */
+    @PreAuthorize("@ss.hasPermi('company:handleManualAnswered:handle')")
+    @Log(title = "转人工接听处理数据", businessType = BusinessType.UPDATE)
+    @PostMapping("/submitManualAnswered")
+    public AjaxResult submitManualAnswered(@RequestBody CrmCustomer param)
+    {
+        // 1. 标记外呼记录为已处理
+        if (param.getLastEffectiveCallphoneLogId() != null) {
+            companyVoiceRoboticCallLogCallphoneService.markHandleFlag(param.getLastEffectiveCallphoneLogId());
+        }
+        // 2. 更新CRM客户信息
+        return toAjax(crmCustomerService.updateCrmCustomer(param));
+    }
+
     /**
      * 导出调用日志_ai打电话列表
      */
@@ -104,6 +228,35 @@ public class CompanyVoiceRoboticCallLogCallphoneController extends BaseControlle
         return util.exportExcel(list, "调用日志_ai打电话数据");
     }
 
+    /**
+     * 导出详情外呼记录(任务名称、客户名称、解密手机号)
+     */
+    @PreAuthorize("@ss.hasPermi('company:callphonelog:exportPhone')")
+    @Log(title = "外呼记录详情手机号导出", businessType = BusinessType.EXPORT)
+    @GetMapping("/exportDetailPhone")
+    public AjaxResult exportDetailPhone(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        companyVoiceRoboticCallLogCallphoneService.prepareDetailListQuery(companyVoiceRoboticCallLogCallphone, loginUser.getCompany().getCompanyId());
+        List<CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO> list =
+                companyVoiceRoboticCallLogCallphoneService.listDecryptPhoneExport(companyVoiceRoboticCallLogCallphone);
+        ExcelUtil<CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO> util =
+                new ExcelUtil<>(CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO.class);
+        return util.exportExcel(list, "外呼记录详情手机号");
+    }
+
+    /**
+     * 查看外呼记录解密手机号(无CRM客户时按记录解密)
+     */
+    @PreAuthorize("@ss.hasPermi('crm:customer:queryPhone')")
+    @Log(title = "查看外呼记录手机号", businessType = BusinessType.GRANT)
+    @GetMapping("/queryPhone/{logId}")
+    public AjaxResult queryCallLogPhone(@PathVariable("logId") Long logId)
+    {
+        String mobile = companyVoiceRoboticCallLogCallphoneService.getDecryptPhoneByLogId(logId);
+        return (AjaxResult) AjaxResult.success().put("mobile", mobile);
+    }
+
 //    /**
 //     * 导出调用日志_ai打电话列表
 //     */

+ 20 - 2
fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticCallLogSendmsgController.java

@@ -5,12 +5,15 @@ 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.poi.ExcelUtil;
 import com.fs.company.domain.CompanyVoiceRoboticCallLogSendmsg;
 import com.fs.company.service.ICompanyVoiceRoboticCallLogCallphoneService;
 import com.fs.company.service.ICompanyVoiceRoboticCallLogSendmsgService;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogSendmsgVO;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.GetMapping;
@@ -22,7 +25,7 @@ import java.util.List;
 
 /**
  * 调用日志_发送短信Controller
- * 
+ *
  * @author fs
  * @date 2026-01-15
  */
@@ -35,6 +38,10 @@ public class CompanyVoiceRoboticCallLogSendmsgController extends BaseController
 
     @Autowired
     private ICompanyVoiceRoboticCallLogCallphoneService companyVoiceRoboticCallLogCallphoneService;
+
+    @Autowired
+    private TokenService tokenService;
+
     /**
      * 查询调用日志_发送短信列表(按照任务id分组,任务id-任务名称-查询总任务数量-成功数量)
      */
@@ -43,6 +50,11 @@ public class CompanyVoiceRoboticCallLogSendmsgController extends BaseController
     public TableDataInfo list(CompanyVoiceRoboticCallLogSendmsg companyVoiceRoboticCallLogSendmsg)
     {
         startPage();
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        companyVoiceRoboticCallLogSendmsg.setCompanyId(loginUser.getCompany().getCompanyId());
+        if (!"00".equals(loginUser.getUser().getUserType())){
+            companyVoiceRoboticCallLogSendmsg.setCompanyUserId(loginUser.getUser().getUserId());
+        }
         List<CompanyVoiceRoboticCallLogSendmsg> list = companyVoiceRoboticCallLogSendmsgService.selectCompanyVoiceRoboticCallLogSendmsgGroupList(companyVoiceRoboticCallLogSendmsg);
         return getDataTable(list);
     }
@@ -80,7 +92,13 @@ public class CompanyVoiceRoboticCallLogSendmsgController extends BaseController
     @GetMapping("/count")
     public AjaxResult selectCompanyVoiceRoboticCallLogSendMsgCount()
     {
-        CompanyVoiceRoboticCallLogCount companyVoiceRoboticCallLogCount = companyVoiceRoboticCallLogSendmsgService.selectCompanyVoiceRoboticCallLogSendMsgCount();
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId=loginUser.getCompany().getCompanyId();
+        Long companyUserId=null;
+        if (!"00".equals(loginUser.getUser().getUserType())){
+            companyUserId=loginUser.getUser().getUserId();
+        }
+        CompanyVoiceRoboticCallLogCount companyVoiceRoboticCallLogCount = companyVoiceRoboticCallLogSendmsgService.selectCompanyVoiceRoboticCallLogSendMsgCount(companyId,companyUserId);
         return AjaxResult.success(companyVoiceRoboticCallLogCount);
     }
 

+ 2 - 2
fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticCalleesController.java

@@ -16,7 +16,7 @@ import java.util.List;
 
 /**
  * 任务外呼电话Controller
- * 
+ *
  * @author fs
  * @date 2024-12-04
  */
@@ -89,7 +89,7 @@ public class CompanyVoiceRoboticCalleesController extends BaseController
      */
     @PreAuthorize("@ss.hasPermi('system:companyVoiceRoboticCallees:remove')")
     @Log(title = "任务外呼电话", businessType = BusinessType.DELETE)
-	@DeleteMapping("/{ids}")
+    @DeleteMapping("/{ids}")
     public AjaxResult remove(@PathVariable Long[] ids)
     {
         return toAjax(companyVoiceRoboticCalleesService.deleteCompanyVoiceRoboticCalleesByIds(ids));

+ 131 - 13
fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticController.java

@@ -14,18 +14,25 @@ import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.page.TableDataInfo;
 import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.SecurityUtils;
 import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.company.domain.CompanyVoiceRobotic;
 import com.fs.company.domain.CompanyVoiceRoboticCallees;
 import com.fs.company.domain.CompanyVoiceRoboticWx;
+import com.fs.company.param.AppendCustomersParam;
+import com.fs.company.param.PauseRoboticActiveParam;
 import com.fs.company.service.ICompanyVoiceRoboticCallLogCallphoneService;
 import com.fs.company.service.ICompanyVoiceRoboticCalleesService;
 import com.fs.company.service.ICompanyVoiceRoboticService;
 import com.fs.company.service.ICompanyVoiceRoboticWxService;
+import com.fs.company.service.impl.CompanyUserServiceImpl;
 import com.fs.company.vo.CalleeRoboticCallOutCountVO;
 import com.fs.company.vo.CdrDetailVo;
+import com.fs.company.vo.DocCompanyUserVO;
+import com.fs.crm.domain.CrmCustomerProperty;
+import com.fs.crm.service.ICrmCustomerPropertyService;
 import com.fs.framework.security.LoginUser;
 import com.fs.framework.service.TokenService;
 import lombok.extern.slf4j.Slf4j;
@@ -34,10 +41,8 @@ import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.web.bind.annotation.*;
 
-import java.util.Arrays;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
+import java.util.*;
+import java.util.function.Function;
 import java.util.stream.Collectors;
 
 /**
@@ -63,6 +68,11 @@ public class CompanyVoiceRoboticController extends BaseController
     private TokenService tokenService;
     @Autowired
     private ICompanyVoiceRoboticCallLogCallphoneService companyVoiceRoboticCallLogCallphoneService;
+    @Autowired
+    private CompanyUserServiceImpl companyUserService;
+
+    @Autowired
+    private ICrmCustomerPropertyService crmCustomerPropertyService;
 
     /**
      * 查询机器人外呼任务列表
@@ -83,15 +93,48 @@ public class CompanyVoiceRoboticController extends BaseController
     @GetMapping("/listAll")
     public R listAll(CompanyVoiceRobotic companyVoiceRobotic){
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if (!"00".equals(loginUser.getUser().getUserType())){
+            companyVoiceRobotic.setCompanyUserId(loginUser.getUser().getUserId());
+        }
         companyVoiceRobotic.setCompanyId(loginUser.getCompany().getCompanyId());
         return R.ok().put("data", companyVoiceRoboticService.selectCompanyVoiceRoboticListCompany(companyVoiceRobotic));
     }
+
+    /**
+     * 外呼任务下拉选项(分页 + 名称模糊搜索,按当前登录公司过滤)
+     */
+    @PreAuthorize("@ss.hasPermi('company:callphoneDetail:list')")
+    @GetMapping("/selectOptions")
+    public TableDataInfo selectOptions(CompanyVoiceRobotic companyVoiceRobotic) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        companyVoiceRobotic.setCompanyId(loginUser.getCompany().getCompanyId());
+        startPage();
+        List<CompanyVoiceRobotic> list = companyVoiceRoboticService.selectCompanyVoiceRoboticListCompany(companyVoiceRobotic);
+        return getDataTable(list);
+    }
+
     @PreAuthorize("@ss.hasPermi('system:companyVoiceRobotic:list')")
     @GetMapping("/calleesList")
     public TableDataInfo calleesList(Long id){
         startPage();
         List<CompanyVoiceRoboticCallees> list = companyVoiceRoboticCalleesService.selectCompanyVoiceRoboticCalleesListByRoboticId(id);
         if (list != null && !list.isEmpty() && id != null) {
+
+            List<Long> customerIds = list.stream()
+                    .map(CompanyVoiceRoboticCallees::getUserId)
+                    .filter(Objects::nonNull)
+                    .distinct()
+                    .collect(Collectors.toList());
+
+            Map<Long, List<CrmCustomerProperty>> propertyMap = new HashMap<>();
+            if (!customerIds.isEmpty()) {
+                List<CrmCustomerProperty> allProperties = crmCustomerPropertyService.selectCrmCustomerPropertyByCustomerIds(customerIds);
+                propertyMap = allProperties.stream()
+                        .collect(Collectors.groupingBy(CrmCustomerProperty::getCustomerId));
+            }
+
+
+
             LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
             Long companyId = loginUser.getCompany().getCompanyId();
             List<Long> calleeIds = list.stream().map(CompanyVoiceRoboticCallees::getId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
@@ -101,6 +144,10 @@ public class CompanyVoiceRoboticController extends BaseController
                 for (CompanyVoiceRoboticCallees row : list) {
                     long n = row.getId() == null ? 0L : countMap.getOrDefault(row.getId(), 0L);
                     row.setRoboticCallOutCount((int) n);
+
+                    if (row.getUserId() != null) {
+                        row.setTagList(propertyMap.getOrDefault(row.getUserId(), new ArrayList<>()));
+                    }
                 }
             }
         }
@@ -115,7 +162,7 @@ public class CompanyVoiceRoboticController extends BaseController
         return getDataTable(list);
     }
 
-//    @PreAuthorize("@ss.hasPermi('system:companyVoiceRobotic:wxListQw')")
+    //    @PreAuthorize("@ss.hasPermi('system:companyVoiceRobotic:wxListQw')")
     @GetMapping("/wxListQw")
     public TableDataInfo wxListQw(Long id){
         startPage();
@@ -138,6 +185,21 @@ public class CompanyVoiceRoboticController extends BaseController
         return util.exportExcel(list, "robotic");
     }
 
+    /**
+     * 导出我的机器人外呼任务列表
+     */
+    @PreAuthorize("@ss.hasPermi('system:companyVoiceRobotic:export')")
+    @Log(title = "我的机器人外呼任务", businessType = BusinessType.EXPORT)
+    @GetMapping("/myExport")
+    public AjaxResult myExport(CompanyVoiceRobotic companyVoiceRobotic) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        companyVoiceRobotic.setCompanyId(loginUser.getCompany().getCompanyId());
+        companyVoiceRobotic.setCompanyUserId(loginUser.getUser().getUserId());
+        List<CompanyVoiceRobotic> list = companyVoiceRoboticService.selectCompanyVoiceRoboticListCompany(companyVoiceRobotic);
+        ExcelUtil<CompanyVoiceRobotic> util = new ExcelUtil<CompanyVoiceRobotic>(CompanyVoiceRobotic.class);
+        return util.exportExcel(list, "myRobotic");
+    }
+
     /**
      * 获取机器人外呼任务详细信息
      */
@@ -180,7 +242,7 @@ public class CompanyVoiceRoboticController extends BaseController
      */
     @PreAuthorize("@ss.hasPermi('system:companyVoiceRobotic:remove')")
     @Log(title = "机器人外呼任务", businessType = BusinessType.DELETE)
-	@DeleteMapping("/{ids}")
+    @DeleteMapping("/{ids}")
     public AjaxResult remove(@PathVariable Long[] ids)
     {
         for (Long id : ids) {
@@ -191,7 +253,7 @@ public class CompanyVoiceRoboticController extends BaseController
     /**
      * 删除机器人外呼任务
      */
-	@GetMapping("/getTypes")
+    @GetMapping("/getTypes")
     public R getTypes(){
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         Long companyId = loginUser.getCompany().getCompanyId();
@@ -212,7 +274,7 @@ public class CompanyVoiceRoboticController extends BaseController
     /**
      * 启动任务
      */
-	@GetMapping("/startRobotic")
+    @GetMapping("/startRobotic")
     public R startRobotic(String taskId){
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         Long companyId = loginUser.getCompany().getCompanyId();
@@ -222,7 +284,7 @@ public class CompanyVoiceRoboticController extends BaseController
     /**
      * 停止任务
      */
-	@GetMapping("/stopRobotic")
+    @GetMapping("/stopRobotic")
     public R stopRobotic(String taskId){
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         Long companyId = loginUser.getCompany().getCompanyId();
@@ -232,15 +294,21 @@ public class CompanyVoiceRoboticController extends BaseController
     /**
      * 员工列表
      */
-	@GetMapping("/companyUserList")
+    @GetMapping("/companyUserList")
     public R qwUserList(){
-        return R.ok().put("data", companyVoiceRoboticService.qwUserListCompany(new CompanyVoiceRobotic()));
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getCompany().getCompanyId();
+        Long userId = loginUser.getUser().getUserId();
+        CompanyVoiceRobotic robotic = new CompanyVoiceRobotic();
+        robotic.setCompanyId(companyId);
+        robotic.setCompanyUserId(userId);
+        return R.ok().put("data", companyVoiceRoboticService.qwUserListCompany(robotic));
     }
 
     /**
      * 外呼回调
      */
-	@PostMapping("/callerResult")
+    @PostMapping("/callerResult")
     public R callerResult(@RequestBody PushIIntentionResult result){
         companyVoiceRoboticService.callerResult(result);
         return R.ok();
@@ -275,6 +343,7 @@ public class CompanyVoiceRoboticController extends BaseController
      * 启动任务
      */
     @GetMapping("/taskRun")
+    @Log(title = "启动机器人外呼任务", businessType = BusinessType.OTHER)
     public R taskRun(Long id){
         companyVoiceRoboticService.taskRun(id);
         return R.ok();
@@ -306,12 +375,13 @@ public class CompanyVoiceRoboticController extends BaseController
                             @RequestParam(defaultValue = "10") Integer pageSize,
                             @RequestParam(required = false) String customerName,
                             @RequestParam(required = false) String customerPhone,
+                            @RequestParam(required = false) String encryptPhone,
                             @RequestParam Boolean onlyCallNode) {
         if (roboticId == null) {
             return R.error("任务ID不能为空");
         }
         return R.ok(companyVoiceRoboticService.getExecRecords(roboticId, pageNum, pageSize, customerName,
-                 customerPhone,onlyCallNode));
+                customerPhone,onlyCallNode,encryptPhone));
     }
 
     @GetMapping("/getCurrentCompanyId")
@@ -319,4 +389,52 @@ public class CompanyVoiceRoboticController extends BaseController
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         return R.ok().put("companyId", loginUser.getCompany().getCompanyId());
     }
+    /**
+     * 填充创建人信息
+     */
+    private void fillUserInfo(List<CompanyVoiceRobotic> list) {
+        if (list == null || list.isEmpty()) {
+            return;
+        }
+
+        Set<Long> userIds = list.stream()
+                .map(CompanyVoiceRobotic::getCreateUser)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+
+        if (userIds.isEmpty()) {
+            return;
+        }
+
+        Map<Long, DocCompanyUserVO> userMap = companyUserService
+                .selectDocCompanyUserListByUserIds(userIds)
+                .stream()
+                .collect(Collectors.toMap(DocCompanyUserVO::getUserId, Function.identity()));
+
+        list.forEach(item -> {
+            if (item.getCreateUser() != null) {
+                DocCompanyUserVO user = userMap.get(item.getCreateUser());
+                if (user != null) {
+                    item.setCreateByName(user.getNickName());
+                    item.setCreateByDeptName(user.getDeptName());
+                }
+            }
+        });
+    }
+
+    @Log(title = "外呼任务暂停操作", businessType = BusinessType.UPDATE)
+    @PostMapping("/pauseRoboticActive")
+    public R pauseRoboticActive(@RequestBody PauseRoboticActiveParam param){
+        return companyVoiceRoboticService.pauseRoboticActive(param);
+    }
+
+    /**
+     * 追加客户到运行中的普通任务
+     */
+    @PreAuthorize("@ss.hasPermi('system:companyVoiceRobotic:edit')")
+    @Log(title = "追加客户到运行中任务", businessType = BusinessType.INSERT)
+    @PostMapping("/appendCustomers")
+    public R appendCustomers(@RequestBody AppendCustomersParam param){
+        return companyVoiceRoboticService.appendCustomersToRunningTask(param.getTaskId(), param.getCustomerIds());
+    }
 }

+ 1 - 1
fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticWxController.java

@@ -89,7 +89,7 @@ public class CompanyVoiceRoboticWxController extends BaseController
      */
     @PreAuthorize("@ss.hasPermi('company:VoiceRoboticWx:remove')")
     @Log(title = "AI外呼任务加微方式", businessType = BusinessType.DELETE)
-	@DeleteMapping("/{ids}")
+    @DeleteMapping("/{ids}")
     public AjaxResult remove(@PathVariable Long[] ids)
     {
         return toAjax(companyVoiceRoboticWxService.deleteCompanyVoiceRoboticWxByIds(ids));

+ 67 - 0
fs-company/src/main/java/com/fs/company/controller/company/CompanyWorkflowController.java

@@ -8,11 +8,14 @@ import com.fs.common.core.page.TableDataInfo;
 import com.fs.common.enums.BusinessType;
 import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.company.domain.CompanyVoiceRobotic;
 import com.fs.company.domain.CompanyWorkflow;
 import com.fs.company.domain.CompanyWorkflowNodeType;
 import com.fs.company.param.CompanyWorkflowSaveParam;
 import com.fs.company.param.CompanyWorkflowUpdateBindWCParam;
 import com.fs.company.service.ICompanyWorkflowService;
+import com.fs.company.service.impl.CompanyUserServiceImpl;
+import com.fs.company.vo.DocCompanyUserVO;
 import com.fs.company.vo.OptionVO;
 import com.fs.framework.security.LoginUser;
 import com.fs.framework.service.TokenService;
@@ -20,6 +23,11 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
 
 import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
 
 /**
  * AI工作流Controller
@@ -36,6 +44,9 @@ public class CompanyWorkflowController extends BaseController {
     @Autowired
     private TokenService tokenService;
 
+    @Autowired
+    private CompanyUserServiceImpl companyUserService;
+
     /**
      * 查询AI工作流列表
      */
@@ -45,9 +56,58 @@ public class CompanyWorkflowController extends BaseController {
         fsAiWorkflow.setCompanyId(loginUser.getCompany().getCompanyId());
         startPage();
         List<CompanyWorkflow> list = companyWorkflowService.selectCompanyWorkflowList(fsAiWorkflow);
+        fillUserInfo(list);
         return getDataTable(list);
     }
 
+    /**
+     * 查询AI工作流列表
+     */
+    @GetMapping("/myList")
+    public TableDataInfo myList(CompanyWorkflow fsAiWorkflow) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        fsAiWorkflow.setCompanyId(loginUser.getCompany().getCompanyId());
+        fsAiWorkflow.setCompanyUserId(loginUser.getUser().getUserId());
+        startPage();
+        List<CompanyWorkflow> list = companyWorkflowService.selectCompanyWorkflowList(fsAiWorkflow);
+        fillUserInfo(list);
+        return getDataTable(list);
+    }
+
+
+    /**
+     * 填充创建人信息
+     */
+    private void fillUserInfo(List<CompanyWorkflow> list) {
+        if (list == null || list.isEmpty()) {
+            return;
+        }
+
+        Set<Long> userIds = list.stream()
+                .map(CompanyWorkflow::getCompanyUserId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+
+        if (userIds.isEmpty()) {
+            return;
+        }
+
+        Map<Long, DocCompanyUserVO> userMap = companyUserService
+                .selectDocCompanyUserListByUserIds(userIds)
+                .stream()
+                .collect(Collectors.toMap(DocCompanyUserVO::getUserId, Function.identity()));
+
+        list.forEach(item -> {
+            if (item.getCompanyUserId() != null) {
+                DocCompanyUserVO user = userMap.get(item.getCompanyUserId());
+                if (user != null) {
+                    item.setCreateByName(user.getNickName());
+                    item.setCreateByDeptName(user.getDeptName());
+                }
+            }
+        });
+    }
+
     /**
      * 导出AI工作流列表
      */
@@ -179,6 +239,13 @@ public class CompanyWorkflowController extends BaseController {
         return R.ok().put("data",optionVOS);
     }
 
+    @GetMapping("/nodeTypeCodes/{workflowId}")
+    public R getNodeTypeCodes(@PathVariable("workflowId") Long workflowId) {
+        List<String> typeCodes = companyWorkflowService.selectNodeTypeCodesByWorkflowId(workflowId);
+        return R.ok().put("data", typeCodes);
+    }
+
+
     /**
      * 查询某个工作流的版本列表
      */

+ 161 - 13
fs-company/src/main/java/com/fs/company/controller/company/CompanyWxAccountController.java

@@ -6,10 +6,13 @@ import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.page.TableDataInfo;
 import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.company.domain.CompanyUser;
 import com.fs.company.domain.CompanyWxAccount;
 import com.fs.company.service.ICompanyWxAccountService;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
@@ -18,7 +21,7 @@ import java.util.List;
 
 /**
  * 企微账号Controller
- * 
+ *
  * @author fs
  * @date 2024-12-09
  */
@@ -28,11 +31,13 @@ public class CompanyWxAccountController extends BaseController
 {
     @Autowired
     private ICompanyWxAccountService companyWxAccountService;
+    @Autowired
+    private TokenService tokenService;
 
     /**
      * 查询企微账号列表
      */
-    @PreAuthorize("@ss.hasPermi('company:companyAccount:list')")
+    @PreAuthorize("@ss.hasPermi('company:companyWx:list')")
     @GetMapping("/list")
     public TableDataInfo list(CompanyWxAccount companyWxEnterpriseAccount)
     {
@@ -40,12 +45,28 @@ public class CompanyWxAccountController extends BaseController
         List<CompanyWxAccount> list = companyWxAccountService.selectCompanyWxAccountListCompany(companyWxEnterpriseAccount);
         return getDataTable(list);
     }
+
+    /**
+     * 查询我的企微账号列表(仅当前登录人)
+     */
+    @PreAuthorize("@ss.hasPermi('company:companyWx:myList')")
+    @GetMapping("/myList")
+    public TableDataInfo myList(CompanyWxAccount companyWxAccount)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        companyWxAccount.setCompanyUserId(loginUser.getUser().getUserId());
+        startPage();
+        List<CompanyWxAccount> list = companyWxAccountService.selectCompanyWxAccountListCompany(companyWxAccount);
+        return getDataTable(list);
+    }
     /**
      * 查询企微账号列表
      */
-    @PreAuthorize("@ss.hasPermi('company:companyAccount:list')")
+    @PreAuthorize("@ss.hasPermi('company:companyWx:list')")
     @GetMapping("/listAll")
     public R listAll(CompanyWxAccount companyWxEnterpriseAccount){
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        companyWxEnterpriseAccount.setCompanyId(loginUser.getCompany().getCompanyId());
         List<CompanyWxAccount> list = companyWxAccountService.selectCompanyWxAccountListCompany(companyWxEnterpriseAccount);
         return R.ok().put("data", list);
     }
@@ -53,20 +74,37 @@ public class CompanyWxAccountController extends BaseController
     /**
      * 导出企微账号列表
      */
-    @PreAuthorize("@ss.hasPermi('company:companyAccount:export')")
+    @PreAuthorize("@ss.hasPermi('company:companyWx:export')")
     @Log(title = "企微账号", businessType = BusinessType.EXPORT)
     @GetMapping("/export")
     public AjaxResult export(CompanyWxAccount companyWxEnterpriseAccount)
     {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        companyWxEnterpriseAccount.setCompanyId(loginUser.getCompany().getCompanyId());
         List<CompanyWxAccount> list = companyWxAccountService.selectCompanyWxAccountListCompany(companyWxEnterpriseAccount);
         ExcelUtil<CompanyWxAccount> util = new ExcelUtil<CompanyWxAccount>(CompanyWxAccount.class);
         return util.exportExcel(list, "companyAccount");
     }
 
+    /**
+     * 导出我的企微账号列表
+     */
+    @PreAuthorize("@ss.hasPermi('company:companyWx:myExport')")
+    @Log(title = "我的企微账号", businessType = BusinessType.EXPORT)
+    @GetMapping("/myExport")
+    public AjaxResult myExport(CompanyWxAccount companyWxAccount)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        companyWxAccount.setCompanyUserId(loginUser.getUser().getUserId());
+        List<CompanyWxAccount> list = companyWxAccountService.selectCompanyWxAccountListCompany(companyWxAccount);
+        ExcelUtil<CompanyWxAccount> util = new ExcelUtil<CompanyWxAccount>(CompanyWxAccount.class);
+        return util.exportExcel(list, "myCompanyAccount");
+    }
+
     /**
      * 获取企微账号详细信息
      */
-    @PreAuthorize("@ss.hasPermi('company:companyAccount:query')")
+    @PreAuthorize("@ss.hasPermi('company:companyWx:query')")
     @GetMapping(value = "/{id}")
     public AjaxResult getInfo(@PathVariable("id") Long id)
     {
@@ -76,18 +114,33 @@ public class CompanyWxAccountController extends BaseController
     /**
      * 新增企微账号
      */
-    @PreAuthorize("@ss.hasPermi('company:companyAccount:add')")
+    @PreAuthorize("@ss.hasPermi('company:companyWx:add')")
     @Log(title = "企微账号", businessType = BusinessType.INSERT)
     @PostMapping
-    public AjaxResult add(@RequestBody CompanyWxAccount companyWxEnterpriseAccount)
-    {
-        return toAjax(companyWxAccountService.insertCompanyWxAccount(companyWxEnterpriseAccount));
+    public AjaxResult add(@RequestBody CompanyWxAccount account){
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        account.setCompanyId(loginUser.getCompany().getCompanyId());
+        account.setCreateUser(loginUser.getUser().getUserId());
+        return toAjax(companyWxAccountService.insertCompanyWxAccount(account));
+    }
+
+    /**
+     * 新增我的企微账号(仅绑定当前登录人)
+     */
+    @PreAuthorize("@ss.hasPermi('company:companyWx:myAdd')")
+    @Log(title = "我的企微账号", businessType = BusinessType.INSERT)
+    @PostMapping("/myAdd")
+    public AjaxResult myAdd(@RequestBody CompanyWxAccount account){
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        account.setCompanyId(loginUser.getCompany().getCompanyId());
+        account.setCompanyUserId(loginUser.getUser().getUserId());
+        return toAjax(companyWxAccountService.insertCompanyWxAccount(account));
     }
 
     /**
      * 修改企微账号
      */
-    @PreAuthorize("@ss.hasPermi('company:companyAccount:edit')")
+    @PreAuthorize("@ss.hasPermi('company:companyWx:edit')")
     @Log(title = "企微账号", businessType = BusinessType.UPDATE)
     @PutMapping
     public AjaxResult edit(@RequestBody CompanyWxAccount companyWxEnterpriseAccount)
@@ -95,22 +148,117 @@ public class CompanyWxAccountController extends BaseController
         return toAjax(companyWxAccountService.updateCompanyWxAccount(companyWxEnterpriseAccount));
     }
 
+    /**
+     * 修改我的企微账号(仅允许修改当前登录人的数据)
+     */
+    @PreAuthorize("@ss.hasPermi('company:companyWx:myEdit')")
+    @Log(title = "我的企微账号", businessType = BusinessType.UPDATE)
+    @PutMapping("/myEdit")
+    public AjaxResult myEdit(@RequestBody CompanyWxAccount account)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyUserId = loginUser.getUser().getUserId();
+        CompanyWxAccount existAccount = companyWxAccountService.selectCompanyWxAccountById(account.getId());
+        if (existAccount == null || !companyUserId.equals(existAccount.getCompanyUserId())) {
+            return AjaxResult.error("无权修改该微信账号");
+        }
+        account.setCompanyUserId(companyUserId);
+        account.setCompanyId(loginUser.getCompany().getCompanyId());
+        return toAjax(companyWxAccountService.updateCompanyWxAccount(account));
+    }
+
     /**
      * 删除企微账号
      */
-    @PreAuthorize("@ss.hasPermi('company:companyAccount:remove')")
+    @PreAuthorize("@ss.hasPermi('company:companyWx:remove')")
     @Log(title = "企微账号", businessType = BusinessType.DELETE)
 	@DeleteMapping("/{ids}")
     public AjaxResult remove(@PathVariable Long[] ids)
     {
         return toAjax(companyWxAccountService.deleteCompanyWxAccountByIds(ids));
     }
+
+    /**
+     * 删除我的企微账号(仅允许删除当前登录人的数据)
+     */
+    @PreAuthorize("@ss.hasPermi('company:companyWx:myRemove')")
+    @Log(title = "我的企微账号", businessType = BusinessType.DELETE)
+    @DeleteMapping("/my/{ids}")
+    public AjaxResult myRemove(@PathVariable Long[] ids)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyUserId = loginUser.getUser().getUserId();
+        for (Long id : ids) {
+            CompanyWxAccount account = companyWxAccountService.selectCompanyWxAccountById(id);
+            if (account == null || !companyUserId.equals(account.getCompanyUserId())) {
+                return AjaxResult.error("无权删除该微信账号");
+            }
+        }
+        return toAjax(companyWxAccountService.deleteCompanyWxAccountByIds(ids));
+    }
     /**
      * 删除企微账号
      */
-    @PreAuthorize("@ss.hasPermi('company:companyAccount:list')")
+    @PreAuthorize("@ss.hasPermi('company:companyWx:list')")
 	@GetMapping("/companyListAll")
     public R companyListAll(){
-        return R.ok().put("data", companyWxAccountService.companyListAllCompany(new CompanyUser()));
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getCompany().getCompanyId();
+        CompanyUser companyUser = new CompanyUser();
+        companyUser.setCompanyId(companyId);
+        return R.ok().put("data", companyWxAccountService.companyListAllCompany(companyUser));
+    }
+    /**
+     * 绑定PAD服务器
+     */
+	@GetMapping("/bindService")
+    public R bindService(Long accountId){
+        return companyWxAccountService.bindService(accountId);
+    }
+    /**
+     * 获取登录二维码
+     */
+	@GetMapping("/getWxQrCode")
+    public R getWxQrCode(Long accountId, String ipadOrMac){
+        return R.ok().put("data", companyWxAccountService.getWxQrCode(accountId, ipadOrMac));
+    }
+    /**
+     * 获取扫码状态
+     */
+	@GetMapping("/getLoginStatus")
+    public R getLoginStatus(Long accountId){
+        return R.ok().put("data", companyWxAccountService.getLoginStatus(accountId));
+    }
+    /**
+     * 唤醒登录
+     */
+	@GetMapping("/wakeUpLogin")
+    public R wakeUpLogin(Long accountId){
+        companyWxAccountService.wakeUpLogin(accountId);
+        return R.ok();
+    }
+    /**
+     * 更新个微账号信息
+     */
+	@GetMapping("/updateWxInfo")
+    public R updateWxInfo(Long accountId){
+        companyWxAccountService.updateWxInfo(accountId);
+        return R.ok().put("data", true);
+    }
+    /**
+     * 退出登录
+     */
+	@GetMapping("/wxLoginOut")
+    public R wxLoginOut(Long accountId){
+        companyWxAccountService.wxLoginOut(accountId);
+        return R.ok().put("data", true);
+    }
+    /**
+     * 同步微信联系人和群聊
+     */
+	@GetMapping("/syncWx")
+    public R syncWx(Long accountId){
+        companyWxAccountService.syncWx(accountId);
+        return R.ok();
     }
 }

+ 13 - 2
fs-company/src/main/java/com/fs/company/controller/company/CompanyWxClientController.java

@@ -6,6 +6,7 @@ import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.page.TableDataInfo;
 import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.company.domain.CompanyWxClient;
 import com.fs.company.service.ICompanyVoiceRoboticService;
@@ -15,13 +16,14 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.web.bind.annotation.*;
-
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
 import java.time.LocalDate;
 import java.util.List;
 
 /**
  * 添加个微信账号Controller
- * 
+ *
  * @author fs
  * @date 2024-12-09
  */
@@ -34,6 +36,9 @@ public class CompanyWxClientController extends BaseController
     @Autowired
     private ICompanyVoiceRoboticService companyVoiceRoboticService;
 
+    @Autowired
+    private TokenService tokenService;
+
     /**
      * 查询添加个微信账号列表
      */
@@ -41,6 +46,8 @@ public class CompanyWxClientController extends BaseController
     @GetMapping("/list")
     public TableDataInfo list(CompanyWxClient companyWxClient){
         startPage();
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        companyWxClient.setCompanyId(loginUser.getCompany().getCompanyId());
         List<CompanyWxClient> list = companyWxClientService.selectCompanyWxClientListCompany(companyWxClient);
         return getDataTable(list);
     }
@@ -53,6 +60,8 @@ public class CompanyWxClientController extends BaseController
     @GetMapping("/export")
     public AjaxResult export(CompanyWxClient companyWxClient)
     {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        companyWxClient.setCompanyId(loginUser.getCompany().getCompanyId());
         List<CompanyWxClient> list = companyWxClientService.selectCompanyWxClientListCompany(companyWxClient);
         ExcelUtil<CompanyWxClient> util = new ExcelUtil<CompanyWxClient>(CompanyWxClient.class);
         return util.exportExcel(list, "companyClient");
@@ -110,6 +119,8 @@ public class CompanyWxClientController extends BaseController
     @GetMapping("/addWxStatistics")
     @Transactional
     public R addWxStatistics(CompanyWxClient companyWxClient){
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        companyWxClient.setCompanyId(loginUser.getCompany().getCompanyId());
         List<CompanyWxClient> clients = companyWxClientService.selectCompanyWxClientListCompany(companyWxClient);
         LocalDate now = LocalDate.now();
         return R.ok()

+ 9 - 2
fs-company/src/main/java/com/fs/company/controller/company/CompanyWxDialogController.java

@@ -6,9 +6,12 @@ import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.page.TableDataInfo;
 import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.company.domain.CompanyWxDialog;
 import com.fs.company.service.ICompanyWxDialogService;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
@@ -17,7 +20,7 @@ import java.util.List;
 
 /**
  * 添加微信话术Controller
- * 
+ *
  * @author fs
  * @date 2024-12-06
  */
@@ -27,6 +30,8 @@ public class CompanyWxDialogController extends BaseController
 {
     @Autowired
     private ICompanyWxDialogService companyWxDialogService;
+    @Autowired
+    private TokenService tokenService;
 
     /**
      * 查询添加微信话术列表
@@ -77,6 +82,8 @@ public class CompanyWxDialogController extends BaseController
     @PostMapping
     public AjaxResult add(@RequestBody CompanyWxDialog companyWxDialog)
     {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        companyWxDialog.setCreateUser(loginUser.getUser().getUserId());
         return toAjax(companyWxDialogService.insertCompanyWxDialog(companyWxDialog));
     }
 
@@ -96,7 +103,7 @@ public class CompanyWxDialogController extends BaseController
      */
     @PreAuthorize("@ss.hasPermi('company:wxDialog:remove')")
     @Log(title = "添加微信话术", businessType = BusinessType.DELETE)
-	@DeleteMapping("/{ids}")
+    @DeleteMapping("/{ids}")
     public AjaxResult remove(@PathVariable Long[] ids)
     {
         return toAjax(companyWxDialogService.deleteCompanyWxDialogByIds(ids));

+ 7 - 7
fs-company/src/main/java/com/fs/company/controller/company/CompanyWxUserController.java

@@ -111,15 +111,15 @@ public class CompanyWxUserController extends BaseController
      */
     @PreAuthorize("@ss.hasPermi('company:wxUser:remove')")
     @Log(title = "个微用户", businessType = BusinessType.DELETE)
-	@DeleteMapping("/{userIds}")
+    @DeleteMapping("/{userIds}")
     public AjaxResult remove(@PathVariable Long[] userIds)
     {
         return toAjax(companyWxUserService.deleteCompanyWxUserByUserIds(userIds));
     }
 
     /**
-    *  绑定小程序id
-    */
+     *  绑定小程序id
+     */
     @PutMapping("/bindMiniUserId")
     public R bindUserId(@RequestBody CompanyWxUserBindFsUserParam param)
     {
@@ -127,8 +127,8 @@ public class CompanyWxUserController extends BaseController
     }
 
     /**
-    * 批量添加分组
-    */
+     * 批量添加分组
+     */
 
     @PreAuthorize("@ss.hasPermi('company:wxUser:addGroup')")
     @Log(title = "批量添加分组", businessType = BusinessType.INSERT)
@@ -140,8 +140,8 @@ public class CompanyWxUserController extends BaseController
     }
 
     /**
-    * 批量移除分组
-    */
+     * 批量移除分组
+     */
     @PreAuthorize("@ss.hasPermi('company:wxUser:delGroup')")
     @Log(title = "批量移除分组", businessType = BusinessType.INSERT)
     @PostMapping("/delGroup")

+ 1 - 1
fs-company/src/main/java/com/fs/company/controller/company/CompanyWxUserGroupController.java

@@ -99,7 +99,7 @@ public class CompanyWxUserGroupController extends BaseController
      */
     @PreAuthorize("@ss.hasPermi('company:wxUserGroup:remove')")
     @Log(title = "个微 分组", businessType = BusinessType.DELETE)
-	@DeleteMapping("/{groupIds}")
+    @DeleteMapping("/{groupIds}")
     public AjaxResult remove(@PathVariable Long[] groupIds)
     {
         return toAjax(companyWxUserGroupService.deleteCompanyWxUserGroupByGroupIds(groupIds));

+ 261 - 0
fs-company/src/main/java/com/fs/company/controller/company/EasyCallController.java

@@ -0,0 +1,261 @@
+package com.fs.company.controller.company;
+
+import com.fs.aicall.domain.CcLlmAgentAccount;
+import com.fs.aicall.domain.CcTtsAliyun;
+import com.fs.aicall.service.ICcLlmAgentAccountService;
+import com.fs.aicall.service.ICompanyBindAiModelService;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.R;
+import com.fs.common.utils.ServletUtils;
+import com.fs.company.mapper.CompanyVoiceCloneRefMapper;
+import com.fs.company.service.ICompanyVoiceCloneService;
+import com.fs.company.service.easycall.IEasyCallService;
+import com.fs.company.vo.easycall.*;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * @author MixLiu
+ * @date 2026/3/6 14:28
+ * @description EasyCallCenter365 外呼管理控制器
+ * <p>
+ * 所有接口均需要登录态,通过 TokenService 获取当前登录用户的 companyId。
+ * 接口地址前缀:/company/easyCall
+ * 对应三方服务器:http://129.28.164.235:8899
+ */
+@Api(tags = "EasyCallCenter365外呼管理")
+@RestController
+@RequestMapping("/company/easyCall")
+public class EasyCallController extends BaseController {
+
+    @Autowired
+    private IEasyCallService easyCallService;
+
+    /** 用于获取当前登录用户信息,从中取出 companyId */
+    @Autowired
+    private TokenService tokenService;
+
+    @Autowired
+    private ICompanyBindAiModelService companyBindAiModelService;
+
+    @Autowired
+    private ICcLlmAgentAccountService ccLlmAgentAccountService;
+
+    @Autowired
+    private CompanyVoiceCloneRefMapper companyVoiceCloneRefMapper;
+
+
+
+    // =================== 基础数据查询 ===================
+
+    /**
+     * 获取外呼网关列表
+     * 网关是外呼连路的入口,创建任务时需要选择对应的网关 ID
+     */
+    @ApiOperation("获取网关列表")
+    @GetMapping("/gateway/list")
+    public R getGatewayList() {
+        // 获取当前登录用户的公司ID(当前仅用于传递,未涉及多公司隔离)
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getUser().getCompanyId();
+        List<EasyCallGatewayVO> list = easyCallService.getGatewayList(companyId);
+        return R.ok().put("data", list);
+    }
+
+    /**
+     * 获取大模型配置列表
+     * 创建 AI 外呼任务时需要选择大模型配置,决定机器人论某能力和话术
+     */
+    @ApiOperation("获取大模型配置列表")
+    @GetMapping("/llmAccount/list")
+    public R getLlmAccountList() {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getUser().getCompanyId();
+        CcLlmAgentAccount ccLlmAgentAccount = new CcLlmAgentAccount();
+        if (companyId != null) {
+            List<Long> modelIds = companyBindAiModelService.selectModelIdsByCompanyId(companyId);
+            if (!modelIds.isEmpty()) {
+                ccLlmAgentAccount.setModelIds(modelIds);
+            } else {
+                return R.ok().put("data", new ArrayList<>());
+            }
+        }
+        List<CcLlmAgentAccount> list = ccLlmAgentAccountService.selectCcLlmAgentAccountList(ccLlmAgentAccount);
+//        List<EasyCallLlmAccountVO> list = easyCallService.getLlmAccountList(companyId);
+        return R.ok().put("data", list);
+    }
+
+    /**
+     * 获取音色列表
+     * AI 外呼任务用于配置语音合成声音风格,如普通话男声、女声等
+     */
+    @ApiOperation("获取音色列表")
+    @GetMapping("/voiceCode/list")
+    public R getVoiceCodeList() {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getUser().getCompanyId();
+
+        List<Long> ttsIds = companyVoiceCloneRefMapper.selectByCompanyIdAndCompanyUserId(companyId, loginUser.getUser().getUserId());
+        List<CcTtsAliyun> ccTtsAliyuns = companyVoiceCloneRefMapper.selectCcTtsAliyunList();
+
+        java.util.Set<Integer> ttsIdSet = ttsIds.stream()
+                .map(Long::intValue)
+                .collect(java.util.stream.Collectors.toSet());
+
+        List<EasyCallVoiceCodeVO> result = ccTtsAliyuns.stream()
+                .filter(item ->
+                        item.getPriority() == 1 || (item.getPriority() == 0 && ttsIdSet.contains(item.getId()))
+                )
+                .map(item -> {
+                    EasyCallVoiceCodeVO vo = new EasyCallVoiceCodeVO();
+                    vo.setVoiceCode(item.getVoiceCode());
+                    vo.setVoiceName(item.getVoiceName()+"-"+loginUser.getUser().getUserName());
+                    vo.setVoiceSource(item.getVoiceSource());
+                    vo.setTtsModels(item.getTtsModels());
+                    return vo;
+                })
+                .collect(Collectors.toList());
+
+//        List<EasyCallVoiceCodeVO> list = easyCallService.getVoiceCodeList(companyId);
+        return R.ok().put("data", result);
+    }
+
+    /**
+     * 获取技能组列表
+     * 技能组也叫业务组,AI 外呼需要转人工时用于指定转入哪个客服组
+     */
+    @ApiOperation("获取技能组列表")
+    @GetMapping("/busiGroup/list")
+    public R getBusiGroupList() {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getUser().getCompanyId();
+        List<EasyCallBusiGroupVO> list = easyCallService.getBusiGroupList(companyId);
+        return R.ok().put("data", list);
+    }
+
+    // =================== 任务管理 ===================
+
+    /**
+     * 分页查询外呼任务列表
+     * 支持按任务ID、任务名称、创建时间范围进行过滤,同时返回拨打统计
+     */
+    @ApiOperation("任务列表查询")
+    @PostMapping("/task/list")
+    public R getTaskList(@RequestBody EasyCallTaskQueryParam param) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getUser().getCompanyId();
+        EasyCallPageResult<EasyCallTaskVO> result = easyCallService.getTaskList(param, companyId);
+        return R.ok().put("data", result);
+    }
+
+    /**
+     * 分页查询通话记录
+     * 支持按号码、拨打时间、通话时长、通话状态等多条件过滤
+     * callType:01-呼入,02-AI外呼,03-人工外呼,三种类型不支持混合查询
+     */
+    @ApiOperation("通话记录查询")
+    @PostMapping("/record/list")
+    public R getRecordList(@RequestBody EasyCallRecordQueryParam param) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getUser().getCompanyId();
+        EasyCallPageResult<EasyCallRecordVO> result = easyCallService.getRecordList(param, companyId);
+        return R.ok().put("data", result);
+    }
+
+    /**
+     * 创建外呼任务
+     * 创建成功后返回包含 batchId 的任务对象,后续操作(启动/追加名单)均需要该 batchId
+     */
+    @ApiOperation("创建外呼任务")
+    @PostMapping("/task/create")
+    public R createTask(@RequestBody EasyCallCreateTaskParam param) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getUser().getCompanyId();
+        EasyCallTaskVO task = easyCallService.createTask(param, companyId);
+        return R.ok().put("data", task);
+    }
+
+    /**
+     * 启动外呼任务
+     * 任务创建后默认处于待机状态,调用此接口才会开始对名单中的号码拨打
+     * @param batchId 任务ID,来自创建任务或任务列表查询返回的 batchId
+     */
+    @ApiOperation("启动外呼任务")
+    @GetMapping("/task/start")
+    public R startTask(@RequestParam Long batchId) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getUser().getCompanyId();
+        easyCallService.startTask(batchId, companyId);
+        return R.ok("操作成功");
+    }
+
+    /**
+     * 停止外呼任务
+     * 停止后任务不再拨打,可重新调用启动接口继续运行
+     * @param batchId 任务ID
+     */
+    @ApiOperation("停止外呼任务")
+    @GetMapping("/task/stop")
+    public R stopTask(@RequestParam Long batchId) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getUser().getCompanyId();
+        easyCallService.stopTask(batchId, companyId);
+        return R.ok("操作成功");
+    }
+
+    // =================== 名单管理 ===================
+
+    /**
+     * AI 外呼专用追加名单
+     * 仅支持 AI 外呼任务,phoneList 为纯手机号字符串列表
+     * 任务启动后也可以继续追加,实现动态补充
+     */
+    @ApiOperation("AI外呼追加名单")
+    @PostMapping("/callList/addAi")
+    public R addAiCallList(@RequestBody EasyCallAddCallListParam param) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getUser().getCompanyId();
+        easyCallService.addAiCallList(param, companyId);
+        return R.ok("操作成功");
+    }
+
+    /**
+     * 通用追加名单
+     * 同时支持 AI 外呼和通知提醒任务
+     * 通知提醒任务必须在每个号码条目中填写 noticeContent(具体提醒话术)
+     * bizJson 可传入需要传递给机器人的业务数据小弹口
+     */
+    @ApiOperation("通用追加名单")
+    @PostMapping("/callList/addCommon")
+    public R addCommonCallList(@RequestBody EasyCallCommonAddCallListParam param) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getUser().getCompanyId();
+        easyCallService.addCommonCallList(param, companyId,null);
+        return R.ok("操作成功");
+    }
+
+    // =================== 录音相关 ===================
+
+    /**
+     * 将通话记录中的录音相对路径拼接为可直接访问的完整 URL
+     * 录音查询接口返回的 wavFileUrl 属于相对路径,需要拼接 baseUrl 才能下载
+     * @param wavFileUrl 通话记录中返回的 wavFileUrl 字段
+     */
+    @ApiOperation("获取录音文件完整访问url")
+    @GetMapping("/record/fileUrl")
+    public R getRecordFileUrl(@RequestParam String wavFileUrl) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getUser().getCompanyId();
+        String fullUrl = easyCallService.getRecordFileUrl(wavFileUrl, companyId);
+        return R.ok().put("data", fullUrl);
+    }
+}

+ 40 - 0
fs-company/src/main/java/com/fs/company/controller/company/GeneralCustomerEntryController.java

@@ -0,0 +1,40 @@
+package com.fs.company.controller.company;
+
+import com.fs.common.annotation.CallbackIpCheck;
+import com.fs.company.param.InboundCallbackParam;
+import com.fs.company.service.IGeneralCustomerEntryService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * @author MixLiu
+ * @date 2026/3/17 15:01
+ * @description
+ */
+@RestController
+@RequestMapping("/company/general/customer")
+@Slf4j
+public class GeneralCustomerEntryController {
+
+    @Autowired
+    IGeneralCustomerEntryService iGeneralCustomerEntryService;
+
+//    @PostMapping("/entryCustomer")
+//    public R entryCustomer(@RequestBody EntryCustomerParam param){
+//       iGeneralCustomerEntryService.entryCustomer(param);
+//       return R.ok("success");
+//    }
+
+    @PostMapping("/inboundCallback")
+    @CallbackIpCheck
+    public String inboundCallback(@RequestBody InboundCallbackParam param){
+        log.info("呼入回调:{}", param);
+        iGeneralCustomerEntryService.inboundCallback(param);
+        return "success";
+    }
+
+}

+ 247 - 37
fs-company/src/main/java/com/fs/company/controller/crm/CrmCustomerController.java

@@ -5,23 +5,31 @@ 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.domain.R;
+import com.fs.common.core.domain.entity.SysDictData;
 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.company.domain.Company;
 import com.fs.company.domain.CompanyUser;
+import com.fs.company.service.ICompanyService;
 import com.fs.company.service.ICompanyUserService;
+import com.fs.company.service.ICompanyVoiceRoboticCallLogCallphoneService;
 import com.fs.company.util.OrderUtils;
+import com.fs.company.vo.CustomerRoboticCallOutCountVO;
 import com.fs.crm.domain.CrmCustomer;
 import com.fs.crm.param.*;
+import com.fs.crm.service.ICrmCustomerPropertyService;
 import com.fs.crm.service.ICrmCustomerService;
 import com.fs.crm.service.ICrmCustomerUserService;
 import com.fs.crm.vo.*;
 import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.PermissionService;
 import com.fs.framework.service.TokenService;
 import com.fs.his.utils.PhoneUtil;
-import com.fs.his.utils.RandomUtil;
+import com.fs.system.service.ISysConfigService;
+import com.fs.system.service.ISysDictTypeService;
 import com.github.pagehelper.PageHelper;
 import io.swagger.annotations.ApiOperation;
 import io.swagger.annotations.ApiParam;
@@ -33,7 +41,11 @@ import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 
 import javax.servlet.http.HttpServletRequest;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
 
 /**
  * 客户Controller
@@ -53,15 +65,33 @@ public class CrmCustomerController extends BaseController
     private TokenService tokenService;
     @Autowired
     ICrmCustomerUserService crmCustomerUserService;
+    @Autowired
+    private ICrmCustomerPropertyService crmCustomerPropertyService;
+    @Autowired
+    private ICompanyVoiceRoboticCallLogCallphoneService companyVoiceRoboticCallLogCallphoneService;
+
+    @Autowired
+    private ICompanyService companyService;
+
+    @Autowired
+    private ISysConfigService configService;
+    @Autowired
+    private ISysDictTypeService dictTypeService;
 
+    @Autowired
+    private PermissionService permissionService;
     @ApiOperation("获取线索客户")
     @PreAuthorize("@ss.hasPermi('crm:customer:lineList')")
     @GetMapping("/getLineCustomerList")
     public TableDataInfo getLineCustomerList(CrmLineCustomerListQueryParam param)
     {
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
-        param.setCompanyId(loginUser.getCompany().getCompanyId());
         PageHelper.startPage(param.getPageNum(), param.getPageSize());
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        if(!StringUtils.isEmpty(param.getEncryptedMobile())){
+            param.setMobile(PhoneUtil.encryptPhone(param.getEncryptedMobile()));
+            param.setEncryptedMobile(null);
+        }
         List<CrmLineCustomerListQueryVO> list = crmCustomerService.selectCrmLineCustomerListQuery(param);
         if (list != null) {
             for (CrmLineCustomerListQueryVO vo : list) {
@@ -70,6 +100,7 @@ public class CrmCustomerController extends BaseController
                 }
 
             }
+            fillLineCustomerListRoboticCallOutCount(list, param.getCompanyId());
         }
         return getDataTable(list);
     }
@@ -81,11 +112,11 @@ public class CrmCustomerController extends BaseController
     public TableDataInfo getFullCustomerList(CrmFullCustomerListQueryParam param)
     {
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        PageHelper.startPage(param.getPageNum(), param.getPageSize());
         param.setCompanyId(loginUser.getCompany().getCompanyId());
 //        if(loginUser.getCompany().getCompanyId()==116){   // 河北湘银信息咨询服务有限公司(JZ-1)客户假删除不显示
 //            param.setCompanyId(0L);
 //        }
-        PageHelper.startPage(param.getPageNum(), param.getPageSize());
         List<CrmFullCustomerListQueryVO> list = crmCustomerService.selectCrmFullCustomerListQuery(param);
         if (list != null) {
             for (CrmFullCustomerListQueryVO vo : list) {
@@ -97,18 +128,73 @@ public class CrmCustomerController extends BaseController
         }
         return getDataTable(list);
     }
+//    @PreAuthorize("@ss.hasPermi('crm:customer:list')")
+//    @GetMapping("/listAll")
+//    public R listAll(CrmCustomerListQueryParam crmCustomer){
+//
+//        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+//        crmCustomer.setCompanyId(loginUser.getCompany().getCompanyId());
+//
+//        if (!CompanyUser.isAdmin(loginUser.getUser().getUserType())) {
+//            crmCustomer.setCustomerUserId(loginUser.getUser().getUserId());
+//        }
+//
+//        PageHelper.startPage(1, 1000);
+//        if(!StringUtils.isEmpty(crmCustomer.getReceiveTimeRange())){
+//            crmCustomer.setReceiveTimeList(crmCustomer.getReceiveTimeRange().split("--"));
+//        }
+//        List<CrmCustomerListVO> list = crmCustomerService.selectCrmCustomerListQueryParam(crmCustomer);
+//        if (list != null) {
+//            for (CrmCustomerListVO vo : list) {
+//                if(vo.getMobile()!=null){
+//                    vo.setMobile(vo.getMobile().replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2"));
+//                }
+//            }
+//        }
+//        return R.ok().put("rows", list);
+//    }
+@PreAuthorize("@ss.hasPermi('crm:customer:list')")
+@GetMapping("/listAll")
+public TableDataInfo listAll(CrmCustomerListQueryParam crmCustomer){
+    LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+    crmCustomer.setCompanyId(loginUser.getCompany().getCompanyId());
+
+    if (!CompanyUser.isAdmin(loginUser.getUser().getUserType())) {
+        crmCustomer.setCustomerUserId(loginUser.getUser().getUserId());
+    }
+    PageHelper.startPage(crmCustomer.getPageNum(), crmCustomer.getPageSize());
+    if(!StringUtils.isEmpty(crmCustomer.getReceiveTimeRange())){
+        crmCustomer.setReceiveTimeList(crmCustomer.getReceiveTimeRange().split("--"));
+    }
+    List<CrmCustomerListVO> list = crmCustomerService.selectCrmCustomerListQueryParam(crmCustomer);
+
+    if (list != null) {
+        for (CrmCustomerListVO vo : list) {
+            if(vo.getMobile()!=null){
+                vo.setMobile(PhoneUtil.maskMobileForList(vo.getMobile()));
+            }
+        }
+    }
+    return getDataTable(list);
+}
     @PreAuthorize("@ss.hasPermi('crm:customer:list')")
-    @GetMapping("/listAll")
-    public R listAll(CrmCustomerListQueryParam crmCustomer){
+    @GetMapping("/listNoPage")
+    public R listNoPage(CrmCustomerListQueryParam crmCustomer){
+
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        crmCustomer.setCompanyId(loginUser.getCompany().getCompanyId());
+
+        if (!CompanyUser.isAdmin(loginUser.getUser().getUserType())) {
+            crmCustomer.setCustomerUserId(loginUser.getUser().getUserId());
+        }
         if(!StringUtils.isEmpty(crmCustomer.getReceiveTimeRange())){
             crmCustomer.setReceiveTimeList(crmCustomer.getReceiveTimeRange().split("--"));
         }
-        PageHelper.startPage(1, 1000);
         List<CrmCustomerListVO> list = crmCustomerService.selectCrmCustomerListQueryParam(crmCustomer);
         if (list != null) {
             for (CrmCustomerListVO vo : list) {
                 if(vo.getMobile()!=null){
-                    vo.setMobile(vo.getMobile().replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2"));
+                    vo.setMobile(PhoneUtil.maskMobileForList(vo.getMobile()));
                 }
             }
         }
@@ -120,17 +206,18 @@ public class CrmCustomerController extends BaseController
     @GetMapping("/getMyAssistList")
     public TableDataInfo getMyAssistList(CrmMyCustomerListQueryParam param){
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        PageHelper.startPage(param.getPageNum(), param.getPageSize());
         param.setCompanyUserId(loginUser.getUser().getUserId());
         param.setCompanyId(loginUser.getCompany().getCompanyId());
+
         if(!StringUtils.isEmpty(param.getCreateTimeRange())){
             param.setCustomerCreateTime(param.getCreateTimeRange().split("--"));
         }
-        PageHelper.startPage(param.getPageNum(), param.getPageSize());
         List<CrmMyCustomerListQueryVO> list = crmCustomerService.selectCrmMyAssistListQuery(param);
         if (list != null) {
             for (CrmMyCustomerListQueryVO vo : list) {
                 if(vo.getMobile()!=null){
-                    vo.setMobile(vo.getMobile().replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2"));
+                    vo.setMobile(PhoneUtil.maskMobileForList(vo.getMobile()));
                 }
             }
         }
@@ -142,19 +229,20 @@ public class CrmCustomerController extends BaseController
     @GetMapping("/getMyCustomerList")
     public TableDataInfo getMyCustomerList(CrmMyCustomerListQueryParam param){
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        PageHelper.startPage(param.getPageNum(), param.getPageSize());
         param.setCompanyUserId(loginUser.getUser().getUserId());
         param.setCompanyId(loginUser.getCompany().getCompanyId());
         if(!StringUtils.isEmpty(param.getCreateTimeRange())){
             param.setCustomerCreateTime(param.getCreateTimeRange().split("--"));
         }
-        PageHelper.startPage(param.getPageNum(), param.getPageSize());
         List<CrmMyCustomerListQueryVO> list = crmCustomerService.selectCrmMyCustomerListQuery(param);
         if (list != null) {
             for (CrmMyCustomerListQueryVO vo : list) {
                 if(vo.getMobile()!=null){
-                    vo.setMobile(vo.getMobile().replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2"));
+                    vo.setMobile(PhoneUtil.maskMobileForList(vo.getMobile()));
                 }
             }
+            fillMyCustomerListRoboticCallOutCount(list, param.getCompanyId());
         }
         return getDataTable(list);
 
@@ -165,6 +253,7 @@ public class CrmCustomerController extends BaseController
     @PreAuthorize("@ss.hasPermi('crm:customer:list')")
     public TableDataInfo getCustomerList(CrmCustomerListQueryParam param){
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        PageHelper.startPage(param.getPageNum(), param.getPageSize());
         param.setCompanyId(loginUser.getCompany().getCompanyId());
 //        if(loginUser.getCompany().getCompanyId()==116){   // 河北湘银信息咨询服务有限公司(JZ-1)客户假删除不显示
 //            param.setCompanyId(0L);
@@ -172,31 +261,79 @@ public class CrmCustomerController extends BaseController
         if (param.getIsReceive() != null && param.getIsReceive() == 0){
             CrmLineCustomerListQueryParam param1 = new CrmLineCustomerListQueryParam();
             BeanUtils.copyProperties(param,param1);
-            PageHelper.startPage(param.getPageNum(), param.getPageSize());
             List<CrmLineCustomerListQueryVO> list1 = crmCustomerService.selectCrmLineCustomerListQuery(param1);
             if (list1 != null) {
                 for (CrmLineCustomerListQueryVO vo : list1) {
                     if(vo.getMobile()!=null){
-                        vo.setMobile(vo.getMobile().replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2"));
+                        vo.setMobile(PhoneUtil.maskMobileForList(vo.getMobile()));
                     }
-
+                    vo.setProperties( crmCustomerPropertyService.selectCrmCustomerPropertyByCustomerId(vo.getCustomerId()));
                 }
+                fillLineCustomerListRoboticCallOutCount(list1, param.getCompanyId());
             }
             return getDataTable(list1);
         }else {
-            PageHelper.startPage(param.getPageNum(), param.getPageSize());
             List<CrmCustomerListQueryVO> list = crmCustomerService.selectCrmCustomerListQuery(param);
             if (list != null) {
                 for (CrmCustomerListQueryVO vo : list) {
                     if (vo.getMobile() != null) {
-                        vo.setMobile(vo.getMobile().replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2"));
+                        vo.setMobile(PhoneUtil.maskMobileForList(vo.getMobile()));
                     }
+                    vo.setProperties( crmCustomerPropertyService.selectCrmCustomerPropertyByCustomerId(vo.getCustomerId()));
 
                 }
+                fillCustomerListRoboticCallOutCount(list, param.getCompanyId());
             }
             return getDataTable(list);
         }
     }
+    private void fillCustomerListRoboticCallOutCount(List<CrmCustomerListQueryVO> list, Long companyId) {
+        if (list == null || list.isEmpty()) {
+            return;
+        }
+        List<Long> customerIds = list.stream().map(CrmCustomerListQueryVO::getCustomerId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
+        if (customerIds.isEmpty()) {
+            return;
+        }
+        Map<Long, Long> countMap = companyVoiceRoboticCallLogCallphoneService.countRoboticCallOutByCustomerIds(customerIds, companyId).stream()
+                .collect(Collectors.toMap(CustomerRoboticCallOutCountVO::getCustomerId, CustomerRoboticCallOutCountVO::getCallCount, (a, b) -> a));
+        for (CrmCustomerListQueryVO vo : list) {
+            long n = vo.getCustomerId() == null ? 0L : countMap.getOrDefault(vo.getCustomerId(), 0L);
+            vo.setRoboticCallOutCount((int) n);
+        }
+    }
+
+    private void fillLineCustomerListRoboticCallOutCount(List<CrmLineCustomerListQueryVO> list, Long companyId) {
+        if (list == null || list.isEmpty()) {
+            return;
+        }
+        List<Long> customerIds = list.stream().map(CrmLineCustomerListQueryVO::getCustomerId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
+        if (customerIds.isEmpty()) {
+            return;
+        }
+        Map<Long, Long> countMap = companyVoiceRoboticCallLogCallphoneService.countRoboticCallOutByCustomerIds(customerIds, companyId).stream()
+                .collect(Collectors.toMap(CustomerRoboticCallOutCountVO::getCustomerId, CustomerRoboticCallOutCountVO::getCallCount, (a, b) -> a));
+        for (CrmLineCustomerListQueryVO vo : list) {
+            long n = vo.getCustomerId() == null ? 0L : countMap.getOrDefault(vo.getCustomerId(), 0L);
+            vo.setRoboticCallOutCount((int) n);
+        }
+    }
+
+    private void fillMyCustomerListRoboticCallOutCount(List<CrmMyCustomerListQueryVO> list, Long companyId) {
+        if (list == null || list.isEmpty()) {
+            return;
+        }
+        List<Long> customerIds = list.stream().map(CrmMyCustomerListQueryVO::getCustomerId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
+        if (customerIds.isEmpty()) {
+            return;
+        }
+        Map<Long, Long> countMap = companyVoiceRoboticCallLogCallphoneService.countRoboticCallOutByCustomerIds(customerIds, companyId).stream()
+                .collect(Collectors.toMap(CustomerRoboticCallOutCountVO::getCustomerId, CustomerRoboticCallOutCountVO::getCallCount, (a, b) -> a));
+        for (CrmMyCustomerListQueryVO vo : list) {
+            long n = vo.getCustomerId() == null ? 0L : countMap.getOrDefault(vo.getCustomerId(), 0L);
+            vo.setRoboticCallOutCount((int) n);
+        }
+    }
     @ApiOperation("获取客户详情")
     @GetMapping("/getCustomerDetails")
     @PreAuthorize("@ss.hasPermi('crm:customer:query')")
@@ -223,10 +360,12 @@ public class CrmCustomerController extends BaseController
     ){
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         CrmCustomer customer=crmCustomerService.selectCrmCustomerById(customerId);
-        customer.setMobile(customer.getMobile().replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2"));
         Boolean isReceive=false;
-        if(customer.getIsReceive()!=null&&customer.getIsReceive()==1&&customer.getReceiveUserId()!=null&&loginUser.getUser().getUserId().equals(customer.getReceiveUserId())){
-            isReceive=true;
+        if (customer != null){
+            customer.setMobile(PhoneUtil.maskMobileForList(customer.getMobile()));
+            if(customer.getIsReceive()!=null&&customer.getIsReceive()==1&&customer.getReceiveUserId()!=null&&loginUser.getUser().getUserId().equals(customer.getReceiveUserId())){
+                isReceive=true;
+            }
         }
         return R.ok().put("customer",customer).put("isReceive",isReceive);
 
@@ -255,6 +394,29 @@ public class CrmCustomerController extends BaseController
 
     }
 
+    //条件分配-统计数量
+    @PreAuthorize("@ss.hasPermi('crm:customer:assignToUser')")
+    @PostMapping("/countByCondition")
+    public R countByCondition(@RequestBody CrmCustomerConditionAssignParam param)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.getQueryParams().put("companyId", loginUser.getCompany().getCompanyId());
+        int count = crmCustomerService.countByCondition(param.getQueryType(), param.getQueryParams());
+        return R.ok().put("count", count);
+    }
+
+    //条件分配-执行分配
+    @PreAuthorize("@ss.hasPermi('crm:customer:assignToUser')")
+    @PostMapping("/assignByCondition")
+    public R assignByCondition(@RequestBody CrmCustomerConditionAssignParam param)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        param.getQueryParams().put("companyId", loginUser.getCompany().getCompanyId());
+        return crmCustomerService.assignByCondition(loginUser.getUsername(), loginUser.getUser().getUserId(), param);
+    }
+
+
 
     @PreAuthorize("@ss.hasPermi('crm:customer:add')")
     @Log(title = "创建客户", businessType = BusinessType.INSERT)
@@ -396,15 +558,19 @@ public class CrmCustomerController extends BaseController
     {
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         param.setCompanyId(loginUser.getCompany().getCompanyId());
-
-
-
-
         List<CrmCustomerExportVO> list = crmCustomerService.selectCrmCustomerExportListQuery(param);
+        boolean hasQueryPhonePermission = permissionService.hasPermi("crm:customer:queryPhone");
         for(CrmCustomerExportVO customer:list){
             if(StringUtils.isNotEmpty(customer.getMobile())){
-
-                    customer.setMobile(customer.getMobile().replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2"));
+                customer.setMobile(PhoneUtil.maskMobileForList(customer.getMobile()));
+            }
+            if(hasQueryPhonePermission && StringUtils.isNotEmpty(customer.getMobile())){
+                String originalMobile = customer.getMobile().replaceAll("\\*+", "");
+                if(originalMobile.length() >= 11){
+                    customer.setMobilePlain(PhoneUtil.decryptPhone(customer.getMobile()));
+                }else{
+                    customer.setMobilePlain(originalMobile);
+                }
             }
         }
         ExcelUtil<CrmCustomerExportVO> util = new ExcelUtil<CrmCustomerExportVO>(CrmCustomerExportVO.class);
@@ -416,14 +582,21 @@ public class CrmCustomerController extends BaseController
     {
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         List<CrmCustomerExportVO> list = crmCustomerService.selectCrmCustomerExportListByIds(customerIds);
+        boolean hasQueryPhonePermission = permissionService.hasPermi("crm:customer:queryPhone");
         for(CrmCustomerExportVO customer:list){
             if(StringUtils.isNotEmpty(customer.getMobile())){
                 if(loginUser.getUser().getUserType().equals("00")){
+                    if(hasQueryPhonePermission){
+                        if(customer.getMobile().length() > 11){
+                            customer.setMobilePlain(PhoneUtil.decryptPhone(customer.getMobile()));
+                        }else{
+                            customer.setMobilePlain(customer.getMobile());
+                        }
+                    }
                 }
                 else{
-                    customer.setMobile(customer.getMobile().replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2"));
+                    customer.setMobile(PhoneUtil.maskMobileForList(customer.getMobile()));
                 }
-
             }
         }
         ExcelUtil<CrmCustomerExportVO> util = new ExcelUtil<CrmCustomerExportVO>(CrmCustomerExportVO.class);
@@ -456,17 +629,54 @@ public class CrmCustomerController extends BaseController
         return R.ok().put("data",list);
     }
 
-    @ApiOperation("获取SIP手机号")
-    @GetMapping("/getSipPhoneNumber")
-    public R getSipPhoneNumber(@RequestParam Long customerId){
-        CrmCustomer crmCustomer = crmCustomerService.selectCrmCustomerById(customerId);
-        if (crmCustomer == null || StringUtils.isBlank(crmCustomer.getMobile())){
-            return R.error("客户手机号不存在");
+    /**
+     * 查看手机号
+     */
+    @PreAuthorize("@ss.hasPermi('crm:customer:queryPhone')")
+    @Log(title = "查看手机号", businessType = BusinessType.GRANT)
+    @GetMapping(value = "/queryPhone/{customerId}")
+    public R queryPhone(@PathVariable("customerId") Long customerId)
+    {
+        CrmCustomer customer = crmCustomerService.selectCrmCustomerById(customerId);
+        if(null != customer){
+            String mobile = customer.getMobile();
+            if (mobile != null && mobile.length() > 11) {
+                mobile = PhoneUtil.decryptPhone(mobile);
+            }
+
+            return  R.ok().put("mobile", mobile);
         }
-        String original = crmCustomer.getMobile().trim();
-        String decrypted = original.length() > 11 ? PhoneUtil.decryptPhone(original) : original;
-        String encrypted = PhoneUtil.xorEncrypt(decrypted);
-        return R.ok().put("data", encrypted + RandomUtil.generateRandomCode());
+
+        return R.error("数据缺失,请联系管理员");
     }
 
+    @ApiOperation("动态数据字典查询")
+    @GetMapping("/tradeDicts")
+    public AjaxResult getTradeDicts() {
+
+        String[] dictTypes = {"crm_customer_source", "crm_customer_user_status",
+                "crm_customer_type", "crm_customer_tag"};
+        Map<String, List<SysDictData>> result = new HashMap<>();
+
+        for (String dictType : dictTypes) {
+            List<SysDictData> data = null;
+            // 保底逻辑:前缀查不到数据则使用原始字典
+            data = dictTypeService.selectDictDataByType(dictType);
+            result.put(dictType, data);
+        }
+
+        return AjaxResult.success(result);
+    }
+
+    private String getDictRemarkByValue(String dictType, String dictValue) {
+        List<SysDictData> dictDataList = dictTypeService.selectDictDataByType(dictType);
+        if (dictDataList != null) {
+            for (SysDictData data : dictDataList) {
+                if (dictValue.equals(data.getDictValue())) {
+                    return StringUtils.isNotEmpty(data.getRemark()) ? data.getRemark() : "";
+                }
+            }
+        }
+        return "";
+    }
 }

+ 19 - 10
fs-company/src/main/java/com/fs/company/controller/store/FsExternalOrderController.java

@@ -15,6 +15,7 @@ import com.fs.his.domain.FsExternalOrderItem;
 import com.fs.his.domain.vo.FsExternalOrderListVO;
 import com.fs.his.mapper.FsExternalOrderItemMapper;
 import com.fs.his.param.FsExternalOrderAddParam;
+import com.fs.his.param.FsExternalOrderQueryParam;
 import com.fs.his.param.FsOrderFinanceAuditApplyParam;
 import com.fs.his.service.IFsExternalOrderService;
 import com.fs.his.service.IFsStoreOrderService;
@@ -43,16 +44,24 @@ public class FsExternalOrderController extends BaseController {
     @Log(title = "外部订单列表", businessType = BusinessType.OTHER)
     @PreAuthorize("@ss.hasPermi('his:externalOrder:list')")
     @DataScope(deptAlias = "cu",userAlias = "cu")
-    public TableDataInfo list(FsExternalOrder fsExternalOrder) {
+    public TableDataInfo list(FsExternalOrderQueryParam queryParam) {
         startPage();
         LoginUser loginUser = SecurityUtils.getLoginUser();
-        fsExternalOrder.setCompanyId(loginUser.getCompany().getCompanyId());
-        // 会员电话需要加密后和数据库匹配
-        if (fsExternalOrder.getMemberPhone() != null && !fsExternalOrder.getMemberPhone().isEmpty()) {
-            fsExternalOrder.setMemberPhone(PhoneUtil.encryptPhone(fsExternalOrder.getMemberPhone()));
+        queryParam.setCompanyId(loginUser.getCompany().getCompanyId());
+        // 会员电话需要加密后和数据库匹配(支持多选:分别加密每个手机号后用逗号拼接,与SQL的FIND_IN_SET配合)
+        if (queryParam.getMemberPhone() != null && !queryParam.getMemberPhone().isEmpty()) {
+            String[] phones = queryParam.getMemberPhone().split(",");
+            StringBuilder sb = new StringBuilder();
+            for (String phone : phones) {
+                String trimmed = phone.trim();
+                if (trimmed.isEmpty()) continue;
+                if (sb.length() > 0) sb.append(",");
+                sb.append(PhoneUtil.encryptPhone(trimmed));
+            }
+            queryParam.setMemberPhone(sb.length() > 0 ? sb.toString() : null);
         }
-        List<FsExternalOrderListVO> list = fsExternalOrderService.selectExternalOrderListVO(fsExternalOrder);
-        java.util.Map<String, Object> summary = fsExternalOrderService.selectExternalOrderListVOSum(fsExternalOrder);
+        List<FsExternalOrderListVO> list = fsExternalOrderService.selectExternalOrderListVO(queryParam);
+        java.util.Map<String, Object> summary = fsExternalOrderService.selectExternalOrderListVOSum(queryParam);
         return getDataTable(list, summary);
     }
 
@@ -151,10 +160,10 @@ public class FsExternalOrderController extends BaseController {
     @Log(title = "导出外部订单", businessType = BusinessType.EXPORT)
     @PreAuthorize("@ss.hasPermi('store:externalOrder:export')")
     @GetMapping("/export")
-    public AjaxResult export(FsExternalOrder fsExternalOrder) {
+    public AjaxResult export(FsExternalOrderQueryParam queryParam) {
         LoginUser loginUser = SecurityUtils.getLoginUser();
-        fsExternalOrder.setCompanyId(loginUser.getCompany().getCompanyId());
-        List<FsExternalOrder> list = fsExternalOrderService.selectFsExternalOrderList(fsExternalOrder);
+        queryParam.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<FsExternalOrder> list = fsExternalOrderService.selectFsExternalOrderList(queryParam);
         ExcelUtil<FsExternalOrder> util = new ExcelUtil<>(FsExternalOrder.class);
         return util.exportExcel(list, "外部订单数据");
     }

+ 11 - 10
fs-company/src/main/java/com/fs/company/controller/store/FsFinanceOrderController.java

@@ -15,6 +15,7 @@ import com.fs.his.dto.FsFinanceOrderImportDTO;
 import com.fs.his.dto.FsFinanceOrderImportFailDTO;
 import com.fs.his.dto.FsFinanceOrderImportPreviewDTO;
 import com.fs.his.mapper.FsExternalOrderMapper;
+import com.fs.his.param.FsExternalOrderQueryParam;
 import org.apache.commons.collections4.CollectionUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
@@ -39,22 +40,22 @@ public class FsFinanceOrderController extends BaseController {
     @GetMapping("/list")
     @Log(title = "财务外部订单列表", businessType = BusinessType.OTHER)
     @PreAuthorize("@ss.hasPermi('store:financeOrder:list')")
-    public TableDataInfo list(FsExternalOrder fsExternalOrder) {
+    public TableDataInfo list(FsExternalOrderQueryParam queryParam) {
         startPage();
         LoginUser loginUser = SecurityUtils.getLoginUser();
-        fsExternalOrder.setCompanyId(loginUser.getCompany().getCompanyId());
-        List<FsFinanceOrderListVO> list = fsExternalOrderMapper.selectFinanceOrderListVO(fsExternalOrder);
-        java.util.Map<String, Object> summary = fsExternalOrderMapper.selectFinanceOrderListVOSum(fsExternalOrder);
+        queryParam.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<FsFinanceOrderListVO> list = fsExternalOrderMapper.selectFinanceOrderListVO(queryParam);
+        java.util.Map<String, Object> summary = fsExternalOrderMapper.selectFinanceOrderListVOSum(queryParam);
         return getDataTable(list, summary);
     }
 
     @Log(title = "导出财务外部订单", businessType = BusinessType.EXPORT)
     @PreAuthorize("@ss.hasPermi('store:financeOrder:export')")
     @GetMapping("/export")
-    public AjaxResult export(FsExternalOrder fsExternalOrder) {
+    public AjaxResult export(FsExternalOrderQueryParam queryParam) {
         LoginUser loginUser = SecurityUtils.getLoginUser();
-        fsExternalOrder.setCompanyId(loginUser.getCompany().getCompanyId());
-        List<FsFinanceOrderListVO> list = fsExternalOrderMapper.selectFinanceOrderListVO(fsExternalOrder);
+        queryParam.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<FsFinanceOrderListVO> list = fsExternalOrderMapper.selectFinanceOrderListVO(queryParam);
         ExcelUtil<FsFinanceOrderListVO> util = new ExcelUtil<>(FsFinanceOrderListVO.class);
         return util.exportExcel(list, "财务外部订单数据");
     }
@@ -302,11 +303,11 @@ public class FsFinanceOrderController extends BaseController {
     @Log(title = "一键转未回款", businessType = BusinessType.UPDATE)
     @PreAuthorize("@ss.hasPermi('store:financeOrder:edit')")
     @PostMapping("/batchReturnUnpaidAll")
-    public AjaxResult batchReturnUnpaidAll(FsExternalOrder fsExternalOrder) {
+    public AjaxResult batchReturnUnpaidAll(FsExternalOrderQueryParam queryParam) {
         LoginUser loginUser = SecurityUtils.getLoginUser();
-        fsExternalOrder.setCompanyId(loginUser.getCompany().getCompanyId());
+        queryParam.setCompanyId(loginUser.getCompany().getCompanyId());
         String updateBy = loginUser.getUser().getUserName();
-        List<FsFinanceOrderListVO> list = fsExternalOrderMapper.selectFinanceOrderListVO(fsExternalOrder);
+        List<FsFinanceOrderListVO> list = fsExternalOrderMapper.selectFinanceOrderListVO(queryParam);
         if (CollectionUtils.isEmpty(list)) {
             return AjaxResult.error("当前筛选条件下没有订单");
         }

+ 8 - 7
fs-company/src/main/java/com/fs/company/controller/store/FsRejectNoReturnController.java

@@ -15,6 +15,7 @@ import com.fs.his.mapper.FsStoreOrderMapper;
 import com.fs.his.mapper.FsExternalOrderMapper;
 import com.fs.his.mapper.FsGuCoinOrderMapper;
 import com.fs.his.param.FsStoreOrderParam;
+import com.fs.his.param.FsExternalOrderQueryParam;
 import com.fs.his.service.IFsExternalOrderService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
@@ -49,12 +50,12 @@ public class FsRejectNoReturnController extends BaseController {
     @GetMapping("/list")
     @Log(title = "拒收未还货查询列表", businessType = BusinessType.OTHER)
     @PreAuthorize("@ss.hasPermi('store:rejectNoReturn:list')")
-    public TableDataInfo list(FsExternalOrder fsExternalOrder) {
+    public TableDataInfo list(FsExternalOrderQueryParam queryParam) {
         startPage();
         LoginUser loginUser = SecurityUtils.getLoginUser();
-        fsExternalOrder.setCompanyId(loginUser.getCompany().getCompanyId());
-        List<FsRejectNoReturnReportVO> list = fsExternalOrderService.selectRejectNoReturnReport(fsExternalOrder);
-        java.util.Map<String, Object> summary = fsExternalOrderMapper.selectRejectNoReturnReportSum(fsExternalOrder);
+        queryParam.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<FsRejectNoReturnReportVO> list = fsExternalOrderService.selectRejectNoReturnReport(queryParam);
+        java.util.Map<String, Object> summary = fsExternalOrderMapper.selectRejectNoReturnReportSum(queryParam);
         return getDataTable(list, summary);
     }
 
@@ -64,10 +65,10 @@ public class FsRejectNoReturnController extends BaseController {
     @GetMapping("/export")
     @Log(title = "拒收未还货查询导出", businessType = BusinessType.EXPORT)
     @PreAuthorize("@ss.hasPermi('store:rejectNoReturn:list')")
-    public AjaxResult export(FsExternalOrder fsExternalOrder) {
+    public AjaxResult export(FsExternalOrderQueryParam queryParam) {
         LoginUser loginUser = SecurityUtils.getLoginUser();
-        fsExternalOrder.setCompanyId(loginUser.getCompany().getCompanyId());
-        List<FsRejectNoReturnReportVO> list = fsExternalOrderService.selectRejectNoReturnReport(fsExternalOrder);
+        queryParam.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<FsRejectNoReturnReportVO> list = fsExternalOrderService.selectRejectNoReturnReport(queryParam);
         ExcelUtil<FsRejectNoReturnReportVO> util = new ExcelUtil<>(FsRejectNoReturnReportVO.class);
         return util.exportExcel(list, "拒收未还货报表");
     }

+ 15 - 14
fs-company/src/main/java/com/fs/company/controller/store/FsRejectReportController.java

@@ -16,6 +16,7 @@ import com.fs.his.mapper.FsStoreOrderMapper;
 import com.fs.his.mapper.FsExternalOrderMapper;
 import com.fs.his.mapper.FsGuCoinOrderMapper;
 import com.fs.his.param.FsStoreOrderParam;
+import com.fs.his.param.FsExternalOrderQueryParam;
 import com.fs.his.service.IFsExternalOrderService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
@@ -50,12 +51,12 @@ public class FsRejectReportController extends BaseController {
     @GetMapping("/orderList")
     @Log(title = "拒收明细报表订单维度", businessType = BusinessType.OTHER)
     @PreAuthorize("@ss.hasPermi('store:rejectReport:list')")
-    public TableDataInfo orderList(FsExternalOrder fsExternalOrder) {
+    public TableDataInfo orderList(FsExternalOrderQueryParam queryParam) {
         startPage();
         LoginUser loginUser = SecurityUtils.getLoginUser();
-        fsExternalOrder.setCompanyId(loginUser.getCompany().getCompanyId());
-        List<FsRejectOrderReportVO> list = fsExternalOrderService.selectRejectOrderReport(fsExternalOrder);
-        java.util.Map<String, Object> summary = fsExternalOrderMapper.selectRejectOrderReportSum(fsExternalOrder);
+        queryParam.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<FsRejectOrderReportVO> list = fsExternalOrderService.selectRejectOrderReport(queryParam);
+        java.util.Map<String, Object> summary = fsExternalOrderMapper.selectRejectOrderReportSum(queryParam);
         return getDataTable(list, summary);
     }
 
@@ -65,10 +66,10 @@ public class FsRejectReportController extends BaseController {
     @GetMapping("/orderExport")
     @Log(title = "拒收明细报表订单维度导出", businessType = BusinessType.EXPORT)
     @PreAuthorize("@ss.hasPermi('store:rejectReport:list')")
-    public AjaxResult orderExport(FsExternalOrder fsExternalOrder) {
+    public AjaxResult orderExport(FsExternalOrderQueryParam queryParam) {
         LoginUser loginUser = SecurityUtils.getLoginUser();
-        fsExternalOrder.setCompanyId(loginUser.getCompany().getCompanyId());
-        List<FsRejectOrderReportVO> list = fsExternalOrderService.selectRejectOrderReport(fsExternalOrder);
+        queryParam.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<FsRejectOrderReportVO> list = fsExternalOrderService.selectRejectOrderReport(queryParam);
         ExcelUtil<FsRejectOrderReportVO> util = new ExcelUtil<>(FsRejectOrderReportVO.class);
         return util.exportExcel(list, "拒收明细订单报表");
     }
@@ -79,12 +80,12 @@ public class FsRejectReportController extends BaseController {
     @GetMapping("/productList")
     @Log(title = "拒收明细报表产品维度", businessType = BusinessType.OTHER)
     @PreAuthorize("@ss.hasPermi('store:rejectReport:list')")
-    public TableDataInfo productList(FsExternalOrder fsExternalOrder) {
+    public TableDataInfo productList(FsExternalOrderQueryParam queryParam) {
         startPage();
         LoginUser loginUser = SecurityUtils.getLoginUser();
-        fsExternalOrder.setCompanyId(loginUser.getCompany().getCompanyId());
-        List<FsRejectProductReportVO> list = fsExternalOrderService.selectRejectProductReport(fsExternalOrder);
-        java.util.Map<String, Object> summary = fsExternalOrderMapper.selectRejectProductReportSum(fsExternalOrder);
+        queryParam.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<FsRejectProductReportVO> list = fsExternalOrderService.selectRejectProductReport(queryParam);
+        java.util.Map<String, Object> summary = fsExternalOrderMapper.selectRejectProductReportSum(queryParam);
         return getDataTable(list, summary);
     }
 
@@ -94,10 +95,10 @@ public class FsRejectReportController extends BaseController {
     @GetMapping("/productExport")
     @Log(title = "拒收明细报表产品维度导出", businessType = BusinessType.EXPORT)
     @PreAuthorize("@ss.hasPermi('store:rejectReport:list')")
-    public AjaxResult productExport(FsExternalOrder fsExternalOrder) {
+    public AjaxResult productExport(FsExternalOrderQueryParam queryParam) {
         LoginUser loginUser = SecurityUtils.getLoginUser();
-        fsExternalOrder.setCompanyId(loginUser.getCompany().getCompanyId());
-        List<FsRejectProductReportVO> list = fsExternalOrderService.selectRejectProductReport(fsExternalOrder);
+        queryParam.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<FsRejectProductReportVO> list = fsExternalOrderService.selectRejectProductReport(queryParam);
         ExcelUtil<FsRejectProductReportVO> util = new ExcelUtil<>(FsRejectProductReportVO.class);
         return util.exportExcel(list, "拒收明细产品报表");
     }

+ 15 - 14
fs-company/src/main/java/com/fs/company/controller/store/FsReturnReportController.java

@@ -15,6 +15,7 @@ import com.fs.his.domain.vo.FsReturnProductReportVO;
 import com.fs.his.mapper.FsExternalOrderMapper;
 import com.fs.his.mapper.FsStoreOrderMapper;
 import com.fs.his.param.FsStoreOrderParam;
+import com.fs.his.param.FsExternalOrderQueryParam;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.GetMapping;
@@ -42,12 +43,12 @@ public class FsReturnReportController extends BaseController {
     @GetMapping("/orderList")
     @Log(title = "回款明细报表订单维度", businessType = BusinessType.OTHER)
     @PreAuthorize("@ss.hasPermi('store:returnReport:list')")
-    public TableDataInfo orderList(FsExternalOrder fsExternalOrder) {
+    public TableDataInfo orderList(FsExternalOrderQueryParam queryParam) {
         startPage();
         LoginUser loginUser = SecurityUtils.getLoginUser();
-        fsExternalOrder.setCompanyId(loginUser.getCompany().getCompanyId());
-        List<FsReturnOrderReportVO> list = fsExternalOrderMapper.selectReturnOrderReport(fsExternalOrder);
-        java.util.Map<String, Object> summary = fsExternalOrderMapper.selectReturnOrderReportSum(fsExternalOrder);
+        queryParam.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<FsReturnOrderReportVO> list = fsExternalOrderMapper.selectReturnOrderReport(queryParam);
+        java.util.Map<String, Object> summary = fsExternalOrderMapper.selectReturnOrderReportSum(queryParam);
         return getDataTable(list, summary);
     }
 
@@ -57,10 +58,10 @@ public class FsReturnReportController extends BaseController {
     @GetMapping("/orderExport")
     @Log(title = "回款明细报表订单维度导出", businessType = BusinessType.EXPORT)
     @PreAuthorize("@ss.hasPermi('store:returnReport:list')")
-    public AjaxResult orderExport(FsExternalOrder fsExternalOrder) {
+    public AjaxResult orderExport(FsExternalOrderQueryParam queryParam) {
         LoginUser loginUser = SecurityUtils.getLoginUser();
-        fsExternalOrder.setCompanyId(loginUser.getCompany().getCompanyId());
-        List<FsReturnOrderReportVO> list = fsExternalOrderMapper.selectReturnOrderReport(fsExternalOrder);
+        queryParam.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<FsReturnOrderReportVO> list = fsExternalOrderMapper.selectReturnOrderReport(queryParam);
         ExcelUtil<FsReturnOrderReportVO> util = new ExcelUtil<>(FsReturnOrderReportVO.class);
         return util.exportExcel(list, "回款明细订单报表");
     }
@@ -71,12 +72,12 @@ public class FsReturnReportController extends BaseController {
     @GetMapping("/productList")
     @Log(title = "回款明细报表产品维度", businessType = BusinessType.OTHER)
     @PreAuthorize("@ss.hasPermi('store:returnReport:list')")
-    public TableDataInfo productList(FsExternalOrder fsExternalOrder) {
+    public TableDataInfo productList(FsExternalOrderQueryParam queryParam) {
         startPage();
         LoginUser loginUser = SecurityUtils.getLoginUser();
-        fsExternalOrder.setCompanyId(loginUser.getCompany().getCompanyId());
-        List<FsReturnProductReportVO> list = fsExternalOrderMapper.selectReturnProductReport(fsExternalOrder);
-        java.util.Map<String, Object> summary = fsExternalOrderMapper.selectReturnProductReportSum(fsExternalOrder);
+        queryParam.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<FsReturnProductReportVO> list = fsExternalOrderMapper.selectReturnProductReport(queryParam);
+        java.util.Map<String, Object> summary = fsExternalOrderMapper.selectReturnProductReportSum(queryParam);
         return getDataTable(list, summary);
     }
 
@@ -86,10 +87,10 @@ public class FsReturnReportController extends BaseController {
     @GetMapping("/productExport")
     @Log(title = "回款明细报表产品维度导出", businessType = BusinessType.EXPORT)
     @PreAuthorize("@ss.hasPermi('store:returnReport:list')")
-    public AjaxResult productExport(FsExternalOrder fsExternalOrder) {
+    public AjaxResult productExport(FsExternalOrderQueryParam queryParam) {
         LoginUser loginUser = SecurityUtils.getLoginUser();
-        fsExternalOrder.setCompanyId(loginUser.getCompany().getCompanyId());
-        List<FsReturnProductReportVO> list = fsExternalOrderMapper.selectReturnProductReport(fsExternalOrder);
+        queryParam.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<FsReturnProductReportVO> list = fsExternalOrderMapper.selectReturnProductReport(queryParam);
         ExcelUtil<FsReturnProductReportVO> util = new ExcelUtil<>(FsReturnProductReportVO.class);
         return util.exportExcel(list, "回款明细产品报表");
     }

+ 5 - 4
fs-company/src/main/java/com/fs/company/controller/store/FsSignDetailReportController.java

@@ -11,6 +11,7 @@ import com.fs.his.domain.vo.FsSignDetailReportVO;
 import com.fs.his.mapper.FsStoreOrderMapper;
 import com.fs.his.mapper.FsGuCoinOrderMapper;
 import com.fs.his.param.FsStoreOrderParam;
+import com.fs.his.param.FsExternalOrderQueryParam;
 import com.fs.his.service.IFsExternalOrderService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
@@ -42,9 +43,9 @@ public class FsSignDetailReportController extends BaseController {
     @GetMapping("/list")
     @Log(title = "签收情况明细表查询", businessType = BusinessType.OTHER)
     @PreAuthorize("@ss.hasPermi('store:signDetailReport:list')")
-    public TableDataInfo list(FsExternalOrder fsExternalOrder) {
+    public TableDataInfo list(FsExternalOrderQueryParam queryParam) {
         startPage();
-        List<FsSignDetailReportVO> list = fsExternalOrderService.selectSignDetailReport(fsExternalOrder);
+        List<FsSignDetailReportVO> list = fsExternalOrderService.selectSignDetailReport(queryParam);
         return getDataTable(list);
     }
 
@@ -54,8 +55,8 @@ public class FsSignDetailReportController extends BaseController {
     @GetMapping("/export")
     @Log(title = "签收情况明细表导出", businessType = BusinessType.EXPORT)
     @PreAuthorize("@ss.hasPermi('store:signDetailReport:list')")
-    public AjaxResult export(FsExternalOrder fsExternalOrder) {
-        List<FsSignDetailReportVO> list = fsExternalOrderService.selectSignDetailReport(fsExternalOrder);
+    public AjaxResult export(FsExternalOrderQueryParam queryParam) {
+        List<FsSignDetailReportVO> list = fsExternalOrderService.selectSignDetailReport(queryParam);
         ExcelUtil<FsSignDetailReportVO> util = new ExcelUtil<>(FsSignDetailReportVO.class);
         return util.exportExcel(list, "签收情况明细表");
     }

+ 8 - 7
fs-company/src/main/java/com/fs/company/controller/store/FsSignReportController.java

@@ -14,6 +14,7 @@ import com.fs.his.domain.vo.FsSignOrderReportVO;
 import com.fs.his.mapper.FsStoreOrderMapper;
 import com.fs.his.mapper.FsExternalOrderMapper;
 import com.fs.his.param.FsStoreOrderParam;
+import com.fs.his.param.FsExternalOrderQueryParam;
 import com.fs.his.service.IFsExternalOrderService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
@@ -45,12 +46,12 @@ public class FsSignReportController extends BaseController {
     @GetMapping("/list")
     @Log(title = "签收未回款查询列表", businessType = BusinessType.OTHER)
     @PreAuthorize("@ss.hasPermi('store:signReport:list')")
-    public TableDataInfo list(FsExternalOrder fsExternalOrder) {
+    public TableDataInfo list(FsExternalOrderQueryParam queryParam) {
         startPage();
         LoginUser loginUser = SecurityUtils.getLoginUser();
-        fsExternalOrder.setCompanyId(loginUser.getCompany().getCompanyId());
-        List<FsSignOrderReportVO> list = fsExternalOrderService.selectSignOrderReport(fsExternalOrder);
-        java.util.Map<String, Object> summary = fsExternalOrderMapper.selectSignOrderReportSum(fsExternalOrder);
+        queryParam.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<FsSignOrderReportVO> list = fsExternalOrderService.selectSignOrderReport(queryParam);
+        java.util.Map<String, Object> summary = fsExternalOrderMapper.selectSignOrderReportSum(queryParam);
         return getDataTable(list, summary);
     }
 
@@ -60,10 +61,10 @@ public class FsSignReportController extends BaseController {
     @GetMapping("/export")
     @Log(title = "签收未回款查询导出", businessType = BusinessType.EXPORT)
     @PreAuthorize("@ss.hasPermi('store:signReport:list')")
-    public AjaxResult export(FsExternalOrder fsExternalOrder) {
+    public AjaxResult export(FsExternalOrderQueryParam queryParam) {
         LoginUser loginUser = SecurityUtils.getLoginUser();
-        fsExternalOrder.setCompanyId(loginUser.getCompany().getCompanyId());
-        List<FsSignOrderReportVO> list = fsExternalOrderService.selectSignOrderReport(fsExternalOrder);
+        queryParam.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<FsSignOrderReportVO> list = fsExternalOrderService.selectSignOrderReport(queryParam);
         ExcelUtil<FsSignOrderReportVO> util = new ExcelUtil<>(FsSignOrderReportVO.class);
         return util.exportExcel(list, "签收未回款报表");
     }

+ 116 - 0
fs-company/src/main/java/com/fs/company/controller/wx/controller/WxSopController.java

@@ -0,0 +1,116 @@
+package com.fs.company.controller.wx.controller;
+
+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.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import com.fs.wx.sop.domain.WxSop;
+import com.fs.wx.sop.params.SendWxSopMsgParam;
+import com.fs.wx.sop.service.IWxSopLogsService;
+import com.fs.wx.sop.service.IWxSopService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 个微SOPController
+ * 
+ * @author 吴树波
+ * @date 2026-02-24
+ */
+@RestController
+@RequestMapping("/wx/wxSop")
+public class WxSopController extends BaseController
+{
+    @Autowired
+    private IWxSopService wxSopService;
+    @Autowired
+    private TokenService tokenService;
+    @Autowired
+    private IWxSopLogsService wxSopLogsService;
+
+    /**
+     * 查询个微SOP列表
+     */
+    @GetMapping("/list")
+    public TableDataInfo list(WxSop wxSop)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        wxSop.setCompanyId(loginUser.getCompany().getCompanyId());
+        startPage();
+        List<WxSop> list = wxSopService.selectWxSopList(wxSop);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出个微SOP列表
+     */
+    @Log(title = "个微SOP", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(WxSop wxSop)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        wxSop.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<WxSop> list = wxSopService.selectWxSopList(wxSop);
+        ExcelUtil<WxSop> util = new ExcelUtil<WxSop>(WxSop.class);
+        return util.exportExcel(list, "个微SOP数据");
+    }
+
+    /**
+     * 获取个微SOP详细信息(含执行账号 companyUserIds)
+     */
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(wxSopService.selectWxSopDetailById(id));
+    }
+
+    /**
+     * 新增个微SOP
+     */
+    @Log(title = "个微SOP", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody WxSop wxSop)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        wxSop.setCompanyId(loginUser.getCompany().getCompanyId());
+        return toAjax(wxSopService.insertWxSop(wxSop));
+    }
+
+    /**
+     * 修改个微SOP
+     */
+    @Log(title = "个微SOP", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody WxSop wxSop)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        wxSop.setCompanyId(loginUser.getCompany().getCompanyId());
+        return toAjax(wxSopService.updateWxSop(wxSop));
+    }
+
+    /**
+     * 删除个微SOP
+     */
+    @Log(title = "个微SOP", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(wxSopService.deleteWxSopByIds(ids));
+    }
+
+    /**
+     * 个微SOP一键群发
+     */
+    @PostMapping("/sendMsg")
+    public R sendWxSopMsg(@RequestBody SendWxSopMsgParam param) {
+        return wxSopLogsService.sendWxSopMsg(param);
+    }
+}

+ 116 - 0
fs-company/src/main/java/com/fs/company/controller/wx/controller/WxSopLogsController.java

@@ -0,0 +1,116 @@
+package com.fs.company.controller.wx.controller;
+
+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.poi.ExcelUtil;
+import com.fs.wx.sop.domain.WxSopLogs;
+import com.fs.wx.sop.params.WxSopLogsParam;
+import com.fs.wx.sop.service.IWxSopLogsService;
+import com.fs.wx.sop.vo.WxSopLogsListVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletResponse;
+import java.util.List;
+
+/**
+ * 个微发送记录Controller
+ * 
+ * @author 吴树波
+ * @date 2026-02-24
+ */
+@RestController
+@RequestMapping("/wx/wxSopLogs")
+public class WxSopLogsController extends BaseController
+{
+    @Autowired
+    private IWxSopLogsService wxSopLogsService;
+
+    /**
+     * 查询个微发送记录列表
+     */
+    @GetMapping("/list")
+    public TableDataInfo list(WxSopLogs wxSopLogs)
+    {
+        startPage();
+        List<WxSopLogs> list = wxSopLogsService.selectWxSopLogsList(wxSopLogs);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出个微发送记录列表
+     */
+    @Log(title = "个微发送记录", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(WxSopLogs wxSopLogs)
+    {
+        List<WxSopLogs> list = wxSopLogsService.selectWxSopLogsList(wxSopLogs);
+        ExcelUtil<WxSopLogs> util = new ExcelUtil<WxSopLogs>(WxSopLogs.class);
+        return util.exportExcel(list, "个微发送记录数据");
+    }
+
+    /**
+     * 获取个微发送记录详细信息
+     */
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(wxSopLogsService.selectWxSopLogsById(id));
+    }
+
+    /**
+     * 新增个微发送记录
+     */
+    @Log(title = "个微发送记录", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody WxSopLogs wxSopLogs)
+    {
+        return toAjax(wxSopLogsService.insertWxSopLogs(wxSopLogs));
+    }
+
+    /**
+     * 修改个微发送记录
+     */
+    @Log(title = "个微发送记录", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody WxSopLogs wxSopLogs)
+    {
+        return toAjax(wxSopLogsService.updateWxSopLogs(wxSopLogs));
+    }
+
+    /**
+     * 删除个微发送记录
+     */
+    @Log(title = "个微发送记录", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(wxSopLogsService.deleteWxSopLogsByIds(ids));
+    }
+
+    /**
+     * 查询个微SOP执行记录列表(带关联信息)
+     */
+    @GetMapping("/listCVO")
+    public TableDataInfo listCVO(WxSopLogsParam param)
+    {
+        startPage();
+        List<WxSopLogsListVO> list = wxSopLogsService.selectWxSopLogsListBySopId(param);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出个微SOP执行记录列表
+     */
+    @Log(title = "个微SOP执行记录", businessType = BusinessType.EXPORT)
+    @PostMapping("/exportCVO")
+    public void exportCVO(HttpServletResponse response, WxSopLogsParam param) throws Exception
+    {
+        List<WxSopLogsListVO> list = wxSopLogsService.selectWxSopLogsListBySopId(param);
+        ExcelUtil<WxSopLogsListVO> util = new ExcelUtil<WxSopLogsListVO>(WxSopLogsListVO.class);
+        util.exportExcel(response, list, "个微SOP执行记录数据");
+    }
+}

+ 90 - 0
fs-company/src/main/java/com/fs/company/controller/wx/controller/WxSopUserController.java

@@ -0,0 +1,90 @@
+package com.fs.company.controller.wx.controller;
+
+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.poi.ExcelUtil;
+import com.fs.wx.sop.domain.WxSopUser;
+import com.fs.wx.sop.service.IWxSopUserService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 个微营期Controller
+ * 
+ * @author 吴树波
+ * @date 2026-02-24
+ */
+@RestController
+@RequestMapping("/wx/wxSopUser")
+public class WxSopUserController extends BaseController
+{
+    @Autowired
+    private IWxSopUserService wxSopUserService;
+
+    /**
+     * 查询个微营期列表
+     */
+    @GetMapping("/list")
+    public TableDataInfo list(WxSopUser wxSopUser)
+    {
+        startPage();
+        List<WxSopUser> list = wxSopUserService.selectWxSopUserList(wxSopUser);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出个微营期列表
+     */
+    @Log(title = "个微营期", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(WxSopUser wxSopUser)
+    {
+        List<WxSopUser> list = wxSopUserService.selectWxSopUserList(wxSopUser);
+        ExcelUtil<WxSopUser> util = new ExcelUtil<WxSopUser>(WxSopUser.class);
+        return util.exportExcel(list, "个微营期数据");
+    }
+
+    /**
+     * 获取个微营期详细信息
+     */
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(wxSopUserService.selectWxSopUserById(id));
+    }
+
+    /**
+     * 新增个微营期
+     */
+    @Log(title = "个微营期", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody WxSopUser wxSopUser)
+    {
+        return toAjax(wxSopUserService.insertWxSopUser(wxSopUser));
+    }
+
+    /**
+     * 修改个微营期
+     */
+    @Log(title = "个微营期", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody WxSopUser wxSopUser)
+    {
+        return toAjax(wxSopUserService.updateWxSopUser(wxSopUser));
+    }
+
+    /**
+     * 删除个微营期
+     */
+    @Log(title = "个微营期", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(wxSopUserService.deleteWxSopUserByIds(ids));
+    }
+}

+ 90 - 0
fs-company/src/main/java/com/fs/company/controller/wx/controller/WxSopUserInfoController.java

@@ -0,0 +1,90 @@
+package com.fs.company.controller.wx.controller;
+
+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.poi.ExcelUtil;
+import com.fs.wx.sop.domain.WxSopUserInfo;
+import com.fs.wx.sop.service.IWxSopUserInfoService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 个微营期详情Controller
+ * 
+ * @author 吴树波
+ * @date 2026-02-24
+ */
+@RestController
+@RequestMapping("/wx/wxSopUserInfo")
+public class WxSopUserInfoController extends BaseController
+{
+    @Autowired
+    private IWxSopUserInfoService wxSopUserInfoService;
+
+    /**
+     * 查询个微营期详情列表
+     */
+    @GetMapping("/list")
+    public TableDataInfo list(WxSopUserInfo wxSopUserInfo)
+    {
+        startPage();
+        List<WxSopUserInfo> list = wxSopUserInfoService.selectWxSopUserInfoList(wxSopUserInfo);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出个微营期详情列表
+     */
+    @Log(title = "个微营期详情", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(WxSopUserInfo wxSopUserInfo)
+    {
+        List<WxSopUserInfo> list = wxSopUserInfoService.selectWxSopUserInfoList(wxSopUserInfo);
+        ExcelUtil<WxSopUserInfo> util = new ExcelUtil<WxSopUserInfo>(WxSopUserInfo.class);
+        return util.exportExcel(list, "个微营期详情数据");
+    }
+
+    /**
+     * 获取个微营期详情详细信息
+     */
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(wxSopUserInfoService.selectWxSopUserInfoById(id));
+    }
+
+    /**
+     * 新增个微营期详情
+     */
+    @Log(title = "个微营期详情", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody WxSopUserInfo wxSopUserInfo)
+    {
+        return toAjax(wxSopUserInfoService.insertWxSopUserInfo(wxSopUserInfo));
+    }
+
+    /**
+     * 修改个微营期详情
+     */
+    @Log(title = "个微营期详情", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody WxSopUserInfo wxSopUserInfo)
+    {
+        return toAjax(wxSopUserInfoService.updateWxSopUserInfo(wxSopUserInfo));
+    }
+
+    /**
+     * 删除个微营期详情
+     */
+    @Log(title = "个微营期详情", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(wxSopUserInfoService.deleteWxSopUserInfoByIds(ids));
+    }
+}

+ 3 - 0
fs-company/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -112,6 +112,8 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                 ).permitAll()
                 .antMatchers("/test").anonymous()
                 .antMatchers("**/callerResult").anonymous()
+                .antMatchers("/company/companyVoiceRobotic/callerResult4EasyCall").anonymous()
+                .antMatchers("/company/general/customer/inboundCallback").anonymous()
                 .antMatchers("/qw/getJsapiTicket/**").anonymous()
                 .antMatchers("/msg/**").anonymous()
                 .antMatchers("/baiduBack/**").anonymous()
@@ -119,6 +121,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                 .antMatchers("/msg").anonymous()
                 .antMatchers("/common/getId**").anonymous()
                 .antMatchers("/common/uploadOSS**").anonymous()
+                .antMatchers("/common/proxy/recording").anonymous()
                 .antMatchers("/company/user/common/uploadOSS").anonymous()
                 .antMatchers("/pay/wxPay/payNotify**").anonymous()
                 .antMatchers("/common/uploadWang**").anonymous()

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

@@ -35,4 +35,6 @@ public class CcTtsAliyun implements Serializable {
 
     /** 供应商: aliyun、doubao 等 */
     private String provider;
+
+    private String ttsModels;
 }

+ 30 - 0
fs-service/src/main/java/com/fs/company/domain/CompanyInboundBind.java

@@ -0,0 +1,30 @@
+package com.fs.company.domain;
+
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 呼入线路模型绑定关系对象 company_inbound_bind
+ *
+ * @author fs
+ * @date 2026-04-27
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class CompanyInboundBind extends BaseEntity{
+
+    /** id */
+    private Long id;
+
+    /** 公司id */
+    @Excel(name = "公司id")
+    private Long companyId;
+
+    /** 呼入线路模型id */
+    @Excel(name = "呼入线路模型id")
+    private Long inboundLlmAccountId;
+
+
+}

+ 42 - 0
fs-service/src/main/java/com/fs/company/domain/CompanyVoiceCloneRef.java

@@ -0,0 +1,42 @@
+package com.fs.company.domain;
+
+import lombok.Data;
+
+import java.util.Date;
+
+@Data
+public class CompanyVoiceCloneRef {
+
+    private Long id;
+
+    private String voiceName;
+
+    private String voiceCode;
+
+    /** 公司ID */
+    private Long companyId;
+
+    /** 公司ID */
+    private Long companyUserId;
+
+    /** 音色ID,对应 cc_tts_aliyun.id */
+    private Integer ttsId;
+
+    /** 状态 */
+    private Integer status;
+
+    /** 创建人 */
+    private String createBy;
+
+    /** 创建时间 */
+    private Date createTime;
+
+    /** 更新人 */
+    private String updateBy;
+
+    /** 更新时间 */
+    private Date updateTime;
+
+    /** 备注 */
+    private String remark;
+}

+ 7 - 0
fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRobotic.java

@@ -104,6 +104,7 @@ public class CompanyVoiceRobotic {
     private String runTaskFlow;
     // 任务状态0待执行1执行中2执行中断3执行完成
     private Integer taskStatus;
+    private String pauseSource;
     private Integer addWxTime;
     /** 创建人 */
     @Excel(name = "创建时间")
@@ -138,4 +139,10 @@ public class CompanyVoiceRobotic {
 
     /** 删除标志 0正常 1删除 */
     private Integer delFlag;
+
+    @TableField(exist = false)
+    private String createByName;
+
+    @TableField(exist = false)
+    private String createByDeptName;
 }

+ 6 - 0
fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRoboticCallLogAddwx.java

@@ -56,6 +56,9 @@ public class CompanyVoiceRoboticCallLogAddwx extends BaseEntity{
     @Excel(name = "公司id")
     private Long companyId;
 
+    @TableField(exist = false)
+    private Long companyUserId;
+
     /** 个微账号id */
     @Excel(name = "个微账号id")
     private Long wxAccountId;
@@ -71,6 +74,9 @@ public class CompanyVoiceRoboticCallLogAddwx extends BaseEntity{
 
     private Integer qwWxAddWayId;
 
+    @TableField(exist = false)
+    private String successRate;
+
     public static CompanyVoiceRoboticCallLogAddwx initCallLog( String runParam, Long keyId, Long taskId,Long wxAccountId,Long companyId,int qwWxAddWayId) {
         CompanyVoiceRoboticCallLogAddwx log = new CompanyVoiceRoboticCallLogAddwx();
         log.wxClientId = keyId;

+ 67 - 0
fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRoboticCallLogCallphone.java

@@ -105,6 +105,39 @@ public class CompanyVoiceRoboticCallLogCallphone extends BaseEntity{
     @Excel(name = "外呼类型")
     private Integer callType;
 
+    /** 未拨通类型 */
+    @Excel(name = "未拨通类型")
+    private String hangupType;
+
+    /** 是否警告(0否 1是)用于敏感词 */
+    @Excel(name = "是否警告(0否 1是)用于敏感词")
+    private Integer isWarning;
+
+    /**
+     * 违规次数
+     * **/
+    private Integer violationNum;
+    /**
+     * 转人工接听:1:是,0或无:否
+     */
+    private Integer manualAnswered;
+    /**
+     * 是否被处理:0:未被处理,1:已处理
+     */
+    private Integer handleFlag;
+    /**
+     * 接听分机号码
+     */
+    private String answeredExtNum;
+    /**
+     * 人工接听时间
+     */
+    private Long manualAnsweredTime;
+    /**
+     * 人工接听时长
+     */
+    private Long manualAnsweredTimeLen;
+
     @TableField(exist = false)
     private String companyName;
 
@@ -117,6 +150,23 @@ public class CompanyVoiceRoboticCallLogCallphone extends BaseEntity{
     @TableField(exist = false)
     private List<Long> callerIds;
 
+    @TableField(exist = false)
+    private Integer isConnected;
+
+    @TableField(exist = false)
+    private Long minCallTime;
+
+    @TableField(exist = false)
+    private Long maxCallTime;
+
+    /** 详情筛选-手机号(明文) */
+    @TableField(exist = false)
+    private String phone;
+
+    /** 详情筛选-加密手机号(匹配 callees.phone) */
+    @TableField(exist = false)
+    private String encryptedPhone;
+
     @TableField(exist = false)
     private String roboticName;
 
@@ -132,6 +182,23 @@ public class CompanyVoiceRoboticCallLogCallphone extends BaseEntity{
     @TableField(exist = false)
     private Integer runningCount;
 
+    /** 详情筛选-运行时间起(yyyy-MM-dd) */
+    @TableField(exist = false)
+    private String beginRunTime;
+
+    /** 详情筛选-运行时间止(yyyy-MM-dd) */
+    @TableField(exist = false)
+    private String endRunTime;
+
+    /** 详情筛选-客户类型为「无」 */
+    @TableField(exist = false)
+    private Boolean intentionEmpty;
+
+    /**
+     * 重试次数
+     */
+    private Integer retryCount;
+
     public static CompanyVoiceRoboticCallLogCallphone initCallLog( String runParam, Long keyId, Long taskId,Long companyId) {
         CompanyVoiceRoboticCallLogCallphone log = new CompanyVoiceRoboticCallLogCallphone();
         log.callerId = keyId;

+ 3 - 0
fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRoboticCallLogSendmsg.java

@@ -92,6 +92,9 @@ public class CompanyVoiceRoboticCallLogSendmsg extends BaseEntity{
 
     private String phone;
 
+    @TableField(exist = false)
+    private String successRate;
+
 
 
     public static CompanyVoiceRoboticCallLogSendmsg initCallLog( String runParam, Long keyId, Long taskId,Long companyId,Long companyUserId,Long tempId) {

+ 32 - 3
fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRoboticCallees.java

@@ -1,12 +1,17 @@
 package com.fs.company.domain;
 
+import com.baomidou.mybatisplus.annotation.IdType;
 import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
 import com.fs.common.annotation.Excel;
+import com.fs.crm.domain.CrmCustomerProperty;
 import lombok.Data;
 
+import java.util.List;
+
 /**
  * 任务外呼电话对象 company_voice_robotic_callees
- * 
+ *
  * @author fs
  * @date 2024-12-04
  */
@@ -15,6 +20,7 @@ public class CompanyVoiceRoboticCallees{
     private static final long serialVersionUID = 1L;
 
     /** id */
+    @TableId(value = "id", type = IdType.AUTO)
     private Long id;
 
     /** 用户ID */
@@ -46,18 +52,41 @@ public class CompanyVoiceRoboticCallees{
     private Integer result;
     @Excel(name = "通话记录")
     private String json;
-
     //任务流程
     private String taskFlow;
     //已经执行流程
     private String runTaskFlow;
-    private Integer isWeCom;
+    //三方任务ID
+    private Long taskId;
+    //三方任务名称
+    private String taskName;
     //是否已发送短信:-1:不需要发送, 0:未发送,1:已发送
     private Integer isSendMsg;
+
+    @TableField(exist = false)
+    private String idToString;
+
+    private Integer isWeCom;
+
     //是否生成数据(0否,1是)
     private Integer isGenerate;
 
     /** 本任务下该 callee 的 AI 外呼呼出次数(非表字段,来自 call_log 统计) */
     @TableField(exist = false)
     private Integer roboticCallOutCount;
+
+    @TableField(exist = false)
+    private List<CrmCustomerProperty> tagList ;
+
+    /**
+    * 是否添加;0否1是2加微中
+    */
+    @TableField(exist = false)
+    private Integer isAdd;
+
+    /**
+    * 客服id
+    */
+    @TableField(exist = false)
+    private Integer customer_id;
 }

+ 0 - 1
fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRoboticWx.java

@@ -14,7 +14,6 @@ import lombok.Data;
 @Data
 public class CompanyVoiceRoboticWx extends BaseEntityTow {
     private static final long serialVersionUID = 1L;
-
     /** 是否微信 */
     @Excel(name = "是否微信")
     private Integer isWeCom;

+ 8 - 0
fs-service/src/main/java/com/fs/company/domain/CompanyWorkflow.java

@@ -1,6 +1,7 @@
 package com.fs.company.domain;
 
 import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.fs.common.annotation.Excel;
 import com.fs.common.core.domain.BaseEntity;
@@ -53,6 +54,13 @@ public class CompanyWorkflow extends BaseEntity {
     private String endNodeKey;
     private Long companyId;
 
+
+    @TableField(exist = false)
+    private String createByName;
+
+    @TableField(exist = false)
+    private String createByDeptName;
+
     public Boolean isStartNode(String nodeKey) {
         return nodeKey.equals(startNodeKey);
     }

+ 50 - 2
fs-service/src/main/java/com/fs/company/domain/CompanyWxAccount.java

@@ -1,10 +1,12 @@
 package com.fs.company.domain;
 
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.fasterxml.jackson.annotation.JsonFormat;
 import com.fs.common.annotation.Excel;
 import com.fs.common.core.domain.BaseEntity;
 import lombok.Data;
 
+import java.time.LocalDate;
 import java.time.LocalDateTime;
 
 /**
@@ -41,12 +43,19 @@ public class CompanyWxAccount extends BaseEntity
     @Excel(name = "创建人")
     private Long createUser;
 
+    /** 销售人员昵称(关联 company_user.nick_name,非数据库字段) */
+    @TableField(exist = false)
     private String companyUserName;
 
-    // 公司ID
-    private Long companyId;
+    // iPad地区ID
+    private Long addressId;
     // 服务器ID
     private Long serverId;
+    // 服务器绑定状态 0-未绑定 1-已绑定
+    private Integer serverStatus;
+
+    // 公司ID
+    private Long companyId;
     // 授权码
     private String authKey;
     private Integer allocateNum;
@@ -56,6 +65,45 @@ public class CompanyWxAccount extends BaseEntity
     private LocalDateTime loginTime;
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     private LocalDateTime outTime;
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime syncFriendTime;
     private String outRemark;
     private String headImgUrl;
+
+    /**
+     * 上一次添加微信时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime lastAddWxTime;
+
+    /**
+     * 账号创建时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    private LocalDate accountCreateTime;
+
+    /**
+     * 是否新账号0是1否
+     */
+    private Integer isNew;
+
+    /**
+     * 当天已经添加数量
+     */
+    private Integer isAddNum;
+
+    /**
+     * 每天添加账号数量
+     */
+    private Integer addNum;
+    /**
+     * 微信备注(唯一)
+     */
+    private String wxRemark;
+
+
+    /**
+     * 发送消息条数限制
+     */
+    private String sendMsgJson;
 }

+ 3 - 1
fs-service/src/main/java/com/fs/company/domain/CompanyWxDialog.java

@@ -3,7 +3,6 @@ package com.fs.company.domain;
 import com.baomidou.mybatisplus.annotation.IdType;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.fs.common.annotation.Excel;
-import com.fs.common.core.domain.BaseEntity;
 import com.fs.common.core.domain.BaseEntityTow;
 import lombok.Data;
 
@@ -29,5 +28,8 @@ public class CompanyWxDialog extends BaseEntityTow {
     @Excel(name = "模板")
     private String templateDetails;
 
+    /** 创建人 */
+    @Excel(name = "创建人")
+    private Long createUser;
 
 }

+ 111 - 0
fs-service/src/main/java/com/fs/company/domain/EasyCallInboundCdrVO.java

@@ -0,0 +1,111 @@
+package com.fs.company.domain;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 呼入通话记录对象 cc_inbound_cdr
+ *
+ * @author fs
+ */
+@Data
+@Accessors(chain = true)
+public class EasyCallInboundCdrVO implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /** 主键id */
+    private String id;
+
+    /** 主叫号码 */
+    private String caller;
+
+    /** 被叫号码 */
+    private String callee;
+
+    /** 呼入时间 */
+    private Long inboundTime;
+
+    /** 请求转接的坐席组 */
+    private String groupId;
+
+    /** 电话被接听时间 */
+    private Long answeredTime;
+
+    /** 接听电话的分机号码 */
+    private String extnum;
+
+    /** 接听电话的员工工号 */
+    private String opnum;
+
+    /** 挂机时间 */
+    private Long hangupTime;
+
+    /** 服务时长 */
+    private Long answeredTimeLen;
+
+    /** 通话总时长 */
+    private Long timeLen;
+
+    /** 通话uuid */
+    private String uuid;
+
+    /** 录音文件名 */
+    private String wavFile;
+
+    /** 录音文件url访问地址 */
+    private String wavFileUrl;
+
+    /** AI客服对话内容 */
+    private String chatContent;
+
+    /** asr时长(秒) */
+    private Integer asrSeconds;
+
+    /** tts调用次数(次) */
+    private Integer ttsTimes;
+
+    /** 大模型tts的字符数(字符) */
+    private Integer ttsFlowTokens;
+
+    /** 总输入token数 */
+    private Integer inputTokens;
+
+    /** 总输出token数 */
+    private Integer outputTokens;
+
+    /** 总调用费用(asr+tts+大模型) */
+    private BigDecimal totalCost;
+
+    /** 计费状态(1:已计费、0:未计费) */
+    private Integer billingStatus;
+
+    /** 整个ivr通话中的有效按键 */
+    private String ivrDtmfDigits;
+
+    /** 挂机原因 */
+    private String hangupCause;
+
+    /** 人工接听时间 */
+    private String manualAnsweredTime;
+
+    /** 人工接听时长 */
+    private String manualAnsweredTimeLen;
+
+    /************ 以下不是表结构字段 ************/
+
+    /** 业务组名称 */
+    private String groupName;
+
+    /** 请求参数(时间范围、时长范围等) */
+    @JsonInclude(JsonInclude.Include.NON_EMPTY)
+    private Map<String, Object> params;
+
+    /** 可见被叫号码列表(多租户隔离用) */
+    private List<String> visibleCallees;
+}

+ 47 - 0
fs-service/src/main/java/com/fs/company/domain/OutboundLineLimitLog.java

@@ -0,0 +1,47 @@
+package com.fs.company.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@TableName("outbound_line_limit_log")
+public class OutboundLineLimitLog {
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    private Long companyId;
+
+    private Long gatewayId;
+
+    /** 时间粒度(分钟): 30/60/120/360/1440 */
+    private Integer timeWindow;
+
+    /** 时间窗口起始时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime windowStart;
+
+    /** 该窗口最大允许呼出次数 */
+    private Integer maxCalls;
+
+    /** 当前窗口已呼叫次数 */
+    private Integer currentCount;
+
+    /** 是否触发限制: 0否 1是 */
+    private Integer isLimited;
+
+    /** 触发时的调用参数JSON */
+    private String callParam;
+
+    /** 下一个可用时间点 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime nextAvailableTime;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime createTime;
+}

+ 26 - 0
fs-service/src/main/java/com/fs/company/dto/OutboundLimitResultVO.java

@@ -0,0 +1,26 @@
+package com.fs.company.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.util.Date;
+
+@Data
+public class OutboundLimitResultVO {
+    private boolean passed;
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date nextAvailableTime;
+
+    public static OutboundLimitResultVO pass() {
+        OutboundLimitResultVO r = new OutboundLimitResultVO();
+        r.passed = true;
+        return r;
+    }
+
+    public static OutboundLimitResultVO limited(Date nextAvailableTime) {
+        OutboundLimitResultVO r = new OutboundLimitResultVO();
+        r.passed = false;
+        r.nextAvailableTime = nextAvailableTime;
+        return r;
+    }
+}

+ 32 - 1
fs-service/src/main/java/com/fs/company/mapper/CompanyAiWorkflowExecMapper.java

@@ -1,5 +1,6 @@
 package com.fs.company.mapper;
 
+import java.util.List;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.fs.company.domain.CompanyAiWorkflowExec;
 import com.fs.company.vo.WorkflowExecRecordVo;
@@ -105,12 +106,42 @@ public interface CompanyAiWorkflowExecMapper extends BaseMapper<CompanyAiWorkflo
      * **/
     int insertBatchInfo(@Param("list") List<CompanyAiWorkflowExec> list);
 //    List<WorkflowExecRecordVo> selectExecRecordsByRoboticId(@Param("roboticId") Long roboticId);
+
     List<WorkflowExecRecordVo> selectExecRecordsByRoboticId(
             @Param("roboticId") Long roboticId,
             @Param("customerName") String customerName,
             @Param("customerPhone") String customerPhone,
-            @Param(("onlyCallNode")) Boolean onlyCallNode
+            @Param(("onlyCallNode")) Boolean onlyCallNode,
+            @Param("encryptPhone") String encryptPhone
     );
 
     WxContact selectWxContectByWorkflowInstanceId(@Param("workflowInstanceId") String workflowInstanceId);
+
+    Long selectWxClientIdByWorkflowInstanceId(@Param("workflowInstanceId") String workflowInstanceId);
+
+    /**
+     * 原子认领待执行任务:仅当当前状态等于 expectStatus 时,才将状态更新为 targetStatus。
+     * 用于防止定时扫描在上一轮任务尚未完成时重复调度同一条记录。
+     * @param id 执行记录主键
+     * @param expectStatus 期望的原状态(如 READY=9)
+     * @param targetStatus 认领后的目标状态(如 RUNNING=3)
+     * @return 影响行数,1 表示认领成功,0 表示已被其它线程/上一轮认领
+     */
+    int claimExecForRun(@Param("id") Long id,
+                        @Param("expectStatus") Integer expectStatus,
+                        @Param("targetStatus") Integer targetStatus);
+
+    /**
+     * 回扫超时认领任务:将处于 runningStatus 且 last_update_time 早于 timeoutTime 的记录重置为 failStatus,
+     * 防止进程重启 / 线程池拒绝导致任务永久卡在认领态。
+     * @param groupNo cid 分组号
+     * @param runningStatus 认领态(如 RUNNING=3)
+     * @param failStatus 重置目标态(如 FAILURE=2)
+     * @param timeoutTime 超时时间界限,last_update_time 早于该时间的视为卡死
+     * @return 影响行数
+     */
+    int resetTimeoutRunningExec(@Param("groupNo") Integer groupNo,
+                                @Param("runningStatus") Integer runningStatus,
+                                @Param("failStatus") Integer failStatus,
+                                @Param("timeoutTime") java.time.LocalDateTime timeoutTime);
 }

+ 42 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceCloneRefMapper.java

@@ -0,0 +1,42 @@
+package com.fs.company.mapper;
+
+import com.fs.aicall.domain.CcTtsAliyun;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
+import com.fs.company.domain.CompanyVoiceCloneRef;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+public interface CompanyVoiceCloneRefMapper {
+
+    CompanyVoiceCloneRef selectByCompanyIdAndTtsId(@Param("companyId") Long companyId,
+                                                   @Param("companyUserId") Long companyUserId,
+                                                   @Param("ttsId") Integer ttsId);
+
+    int insertCompanyVoiceCloneRef(CompanyVoiceCloneRef ref);
+
+    int updateCompanyVoiceCloneRef(CompanyVoiceCloneRef ref);
+
+    List<Long> selectByCompanyIdAndCompanyUserId(@Param("companyId") Long companyId,
+                                   @Param("companyUserId") Long companyUserId);
+
+
+    @DataSource(DataSourceType.EASYCALL)
+    List<CcTtsAliyun> selectCcTtsAliyunList();
+
+
+    /**
+     * 分页查询公司音色关联列表
+     * @param ref 查询条件(companyId必填,companyUserId可选用于子账号过滤)
+     * @return 音色关联列表
+     */
+    List<CompanyVoiceCloneRef> selectCompanyVoiceCloneRefList(CompanyVoiceCloneRef ref);
+
+    int deleteById(@Param("id") Long id);
+
+    int updateStatusById(CompanyVoiceCloneRef ref);
+
+
+
+}

+ 21 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceRoboticCallLogAddwxMapper.java

@@ -3,6 +3,7 @@ package com.fs.company.mapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.fs.company.domain.CompanyVoiceRoboticCallLogAddwx;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogAddwxVO;
+import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
 
 import java.util.List;
 import java.util.Map;
@@ -70,4 +71,24 @@ public interface CompanyVoiceRoboticCallLogAddwxMapper extends BaseMapper<Compan
 
     Map<String, Long> countListAll(CompanyVoiceRoboticCallLogAddwx companyVoiceRoboticCallLogAddwx);
 
+    /**
+     * 查询加微记录分组列表
+     * @param companyVoiceRoboticCallLogAddwx
+     * @return
+     */
+    List<CompanyVoiceRoboticCallLogAddwx> selectCompanyVoiceRoboticCallLogAddwxGroupList (CompanyVoiceRoboticCallLogAddwx companyVoiceRoboticCallLogAddwx);
+
+    /**
+     * 查询加微记录详情
+     * @param companyVoiceRoboticCallLogAddwx
+     * @return
+     */
+    List<CompanyVoiceRoboticCallLogAddwxVO> listByRoboticId(CompanyVoiceRoboticCallLogAddwx companyVoiceRoboticCallLogAddwx);
+
+    /**
+     * 查询加微记录统计(所有任务)
+     * @return
+     */
+    CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticAddwxLogCount();
+
 }

+ 28 - 7
fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceRoboticCallLogCallphoneMapper.java

@@ -1,16 +1,12 @@
 package com.fs.company.mapper;
 
+import java.util.List;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.fs.company.domain.CompanyVoiceRoboticCallLogCallphone;
 import com.fs.company.domain.CompanyVoiceRoboticCallees;
-import com.fs.company.vo.CalleeRoboticCallOutCountVO;
-import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneVO;
-import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
-import com.fs.company.vo.CustomerRoboticCallOutCountVO;
+import com.fs.company.vo.*;
 import org.apache.ibatis.annotations.Param;
 
-import java.util.List;
-
 /**
  * 调用日志_ai打电话Mapper接口
  *
@@ -84,11 +80,18 @@ public interface CompanyVoiceRoboticCallLogCallphoneMapper extends BaseMapper<Co
 
     List<CompanyVoiceRoboticCallLogCallphone> selectCompanyVoiceRoboticCallPhoneLogGroupList(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
 
-    CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallPhoneLogCount();
+    CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallPhoneLogCount(@Param("companyId") Long companyId,@Param("companyUserId") Long companyUserId);
 
 
     List<CompanyVoiceRoboticCallLogCallPhoneVO> listByRoboticId(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
 
+    List<CompanyVoiceRoboticCallLogCallPhoneVO> selectDetailList(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
+
+    CompanyVoiceRoboticCallLogDetailSummary selectDetailSummary(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
+
+    List<CompanyVoiceRoboticCallLogCallPhoneDecryptQueryVO> listDecryptPhoneExport(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
+
+
     /**
      * 根据业务ID查询公司ID
      * @param businessId 业务ID (bes.id)
@@ -96,6 +99,9 @@ public interface CompanyVoiceRoboticCallLogCallphoneMapper extends BaseMapper<Co
      */
     Long selectCompanyIdByBusinessId(@Param("businessId") Long businessId);
 
+    List<CompanyVoiceRoboticCallLogCallphone> selectManualAnsweredList(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
+
+
     /**
      * 按客户统计 AI 外呼呼出次数(callphone.caller_id = callees.id,callees.user_id = 客户 customer_id)
      */
@@ -108,4 +114,19 @@ public interface CompanyVoiceRoboticCallLogCallphoneMapper extends BaseMapper<Co
     List<CalleeRoboticCallOutCountVO> countRoboticCallOutByCalleeIds(@Param("calleeIds") List<Long> calleeIds,
                                                                      @Param("roboticId") Long roboticId,
                                                                      @Param("companyId") Long companyId);
+
+    /**
+     * 查询待回调的记录(status=1且重试次数小于3)
+     */
+    List<CompanyVoiceRoboticCallLogCallphone> selectPendingCallbackRecords();
+
+    /**
+     * 更新重试次数和状态
+     */
+    int updateRetryCountAndStatus(CompanyVoiceRoboticCallLogCallphone record);
+
+    /**
+     * 查询执行中任务下、外呼记录仍为执行中的 callbackUuid 列表
+     */
+    List<String> selectRunningCallbackUuidsByCompanyId(@Param("companyId") Long companyId);
 }

+ 10 - 10
fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceRoboticCallLogSendmsgMapper.java

@@ -1,22 +1,22 @@
 package com.fs.company.mapper;
 
+import java.util.List;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.fs.company.domain.CompanyVoiceRoboticCallLogSendmsg;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogSendmsgVO;
-
-import java.util.List;
+import org.apache.ibatis.annotations.Param;
 
 /**
  * 调用日志_发送短信Mapper接口
- * 
+ *
  * @author fs
  * @date 2026-01-15
  */
 public interface CompanyVoiceRoboticCallLogSendmsgMapper extends BaseMapper<CompanyVoiceRoboticCallLogSendmsg>{
     /**
      * 查询调用日志_发送短信
-     * 
+     *
      * @param logId 调用日志_发送短信主键
      * @return 调用日志_发送短信
      */
@@ -24,7 +24,7 @@ public interface CompanyVoiceRoboticCallLogSendmsgMapper extends BaseMapper<Comp
 
     /**
      * 查询调用日志_发送短信列表
-     * 
+     *
      * @param companyVoiceRoboticCallLogSendmsg 调用日志_发送短信
      * @return 调用日志_发送短信集合
      */
@@ -34,7 +34,7 @@ public interface CompanyVoiceRoboticCallLogSendmsgMapper extends BaseMapper<Comp
 
     /**
      * 新增调用日志_发送短信
-     * 
+     *
      * @param companyVoiceRoboticCallLogSendmsg 调用日志_发送短信
      * @return 结果
      */
@@ -42,7 +42,7 @@ public interface CompanyVoiceRoboticCallLogSendmsgMapper extends BaseMapper<Comp
 
     /**
      * 修改调用日志_发送短信
-     * 
+     *
      * @param companyVoiceRoboticCallLogSendmsg 调用日志_发送短信
      * @return 结果
      */
@@ -50,7 +50,7 @@ public interface CompanyVoiceRoboticCallLogSendmsgMapper extends BaseMapper<Comp
 
     /**
      * 删除调用日志_发送短信
-     * 
+     *
      * @param logId 调用日志_发送短信主键
      * @return 结果
      */
@@ -58,7 +58,7 @@ public interface CompanyVoiceRoboticCallLogSendmsgMapper extends BaseMapper<Comp
 
     /**
      * 批量删除调用日志_发送短信
-     * 
+     *
      * @param logIds 需要删除的数据主键集合
      * @return 结果
      */
@@ -66,5 +66,5 @@ public interface CompanyVoiceRoboticCallLogSendmsgMapper extends BaseMapper<Comp
 
     List<CompanyVoiceRoboticCallLogSendmsgVO> listByCallerIdAndRoboticId(CompanyVoiceRoboticCallLogSendmsg companyVoiceRoboticCallLogSendmsg);
 
-    CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallLogSendMsgCount();
+    CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallLogSendMsgCount(@Param("companyId") Long companyId,@Param("companyUserId") Long companyUserId);
 }

+ 2 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceRoboticWxMapper.java

@@ -68,4 +68,6 @@ public interface CompanyVoiceRoboticWxMapper extends BaseMapper<CompanyVoiceRobo
     List<CompanyVoiceRoboticWx> selectByRoboticIdQw(@Param("id") Long id, @Param("intention") String intention);
 
     List<CompanyVoiceRoboticWx> selectByRoboticIdWithGroupBy(@Param("id") Long id);
+
+    CompanyVoiceRoboticWx selectAllocationTargetByTaskId(@Param("roboticId") Long roboticId);
 }

+ 2 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanyWxAccountMapper.java

@@ -68,4 +68,6 @@ public interface CompanyWxAccountMapper extends BaseMapper<CompanyWxAccount>
     List<CompanyUser> companyListAllCompany(CompanyUser companyUser);
 
     CompanyWxAccount selectByCompanyUserAndWxNo(@Param("userId") Long userId, @Param("wxNo") String wxNo);
+
+    CompanyWxAccount selectCompanyWxAccountByWxRemark(@Param("wxRemark") String wxRemark);
 }

+ 9 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanyWxClientMapper.java

@@ -3,6 +3,7 @@ package com.fs.company.mapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.fs.company.domain.CompanyUser;
 import com.fs.company.domain.CompanyWxClient;
+import com.fs.company.vo.CompanyWxClient4WorkFlowVO;
 import org.apache.ibatis.annotations.Param;
 
 import java.util.List;
@@ -69,6 +70,14 @@ public interface CompanyWxClientMapper extends BaseMapper<CompanyWxClient> {
 
     List<CompanyWxClient> listCompanyIds(@Param("ids") String[] ids);
 
+    List<CompanyWxClient> getAddWxList(@Param("accountIdList") List<Long> accountIdList, @Param("isWeCom") Integer isWeCom);
+
+    List<CompanyWxClient4WorkFlowVO> getAddWxList4Workflow(@Param("accountIdList") List<Long> accountIdList, @Param("execStatus") Integer execStatus, @Param("execNodeType") Integer execNodeType, @Param("cidGroupId") Integer cidGroupId);
+
+    List<CompanyWxClient> getQwAddWxList(@Param("accountIdList") List<Long> accountIdList, @Param("isWeCom") Integer isWeCom, @Param("cidGroupNo") Integer cidGroupNo);
+
+    List<CompanyWxClient4WorkFlowVO> getQwAddWxList4Workflow(@Param("accountIdList") List<Long> accountIdList, @Param("execStatus") Integer execStatus, @Param("execNodeType") Integer execNodeType, @Param("cidGroupNo") Integer cidGroupNo);
+
     CompanyWxClient selectOneByRoboticIdAndUserId(@Param("roboticId") Long roboticId,@Param("customerId") Long customerId);
 
     List<CompanyWxClient> selectListByRoboticId(@Param("roboticId") Long roboticId);

+ 14 - 0
fs-service/src/main/java/com/fs/company/mapper/EasyCallMapper.java

@@ -2,11 +2,14 @@ package com.fs.company.mapper;
 
 import com.fs.common.annotation.DataSource;
 import com.fs.common.enums.DataSourceType;
+import com.fs.company.vo.InboundCallInfo;
 import com.fs.company.vo.easycall.EasyCallCallPhoneVO;
 import com.fs.company.vo.easycall.EasyCallOutBoundVO;
 import org.apache.ibatis.annotations.Param;
 import org.springframework.stereotype.Repository;
 
+import java.util.List;
+
 /**
  * @author MixLiu
  * @date 2026/3/7 10:43
@@ -22,4 +25,15 @@ public interface EasyCallMapper {
     @DataSource(DataSourceType.EASYCALL)
     EasyCallOutBoundVO getOutBoundInfoByUuid(@Param("uuid") String uuid);
 
+    @DataSource(DataSourceType.EASYCALL)
+    InboundCallInfo selectInboundCallbackInfoByUuid(@Param("uuid") String uuid);
+
+    @DataSource(DataSourceType.EASYCALL)
+    EasyCallCallPhoneVO getCallPhoneInfoByCallBackUuid(@Param("callBackUuid") String callBackUuid);
+
+    /**
+     * 根据 callbackUuid 批量查询 EasyCall 已外呼完成的话单 uuid
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    List<String> selectCompletedUuidsByCallbackUuids(@Param("callbackUuids") List<String> callbackUuids);
 }

+ 18 - 0
fs-service/src/main/java/com/fs/company/mapper/OutboundLineLimitLogMapper.java

@@ -0,0 +1,18 @@
+package com.fs.company.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.company.domain.OutboundLineLimitLog;
+import org.apache.ibatis.annotations.Param;
+
+import java.time.LocalDateTime;
+
+public interface OutboundLineLimitLogMapper extends BaseMapper<OutboundLineLimitLog> {
+
+    int asyncInsert(OutboundLineLimitLog log);
+
+    int countByWindow(@Param("companyId") Long companyId,
+                      @Param("gatewayId") Long gatewayId,
+                      @Param("timeWindow") Integer timeWindow,
+                      @Param("windowStart") LocalDateTime windowStart,
+                      @Param("windowEnd") LocalDateTime windowEnd);
+}

+ 25 - 0
fs-service/src/main/java/com/fs/company/param/AppendCustomersParam.java

@@ -0,0 +1,25 @@
+package com.fs.company.param;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * @author MixLiu
+ * @date 2026/5/26
+ * @description 追加客户到运行中任务的请求参数
+ */
+@Data
+public class AppendCustomersParam {
+
+    /**
+     * 任务id
+     */
+    private Long taskId;
+
+    /**
+     * 要追加的CRM客户ID列表
+     */
+    private List<Long> customerIds;
+
+}

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

@@ -0,0 +1,166 @@
+package com.fs.company.param;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * @author MixLiu
+ * @date 2026/3/17 15:42
+ * @description
+ */
+@Data
+public class EntryCustomerParam {
+
+    /**主键*/
+    private Long customerId;
+
+    /** 组织机构代码 */
+    private String customerCode;
+
+    /** 客户名称 */
+    private String customerName;
+
+    /**
+     * 客户的企微名称
+     */
+    private String qwName;
+
+    /** 手机 */
+    //必传
+    private String mobile;
+
+    /** 流量来源 */
+    private String trafficSource;
+
+    /** 性别 */
+    private Integer sex;
+
+    /** $column.columnComment */
+    private String weixin;
+
+    /** 关联用户ID */
+    private Long userId;
+
+    /** 创建人ID */
+    private Long createUserId;
+
+    /** 当前认领用户 */
+    private Long receiveUserId;
+
+    /** 认领ID */
+    private Long customerUserId;
+
+    /** 省市区 */
+    private String address;
+
+    private String cityIds;
+
+    /** 定位信息 */
+    private String location;
+
+    /** 详细地址 */
+    private String detailAddress;
+
+    /** 地理位置经度 */
+    private String lng;
+
+    /** 地理位置维度 */
+    private String lat;
+
+    /** 客户状态  0锁定 1 正常 */
+    private Integer status;
+
+    /** 1 已认领 0未认领 */
+    private Integer isReceive;
+
+    /** 0 手动导入 1 自动导入 */
+    private Integer importType;
+
+
+
+    /** 所属部门ID */
+    private Long deptId;
+
+    /** 是否删除 */
+    private Integer isDel;
+
+    /** 客户类型 */
+    private Integer customerType;
+
+    /** 最后一次跟进时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    private Date receiveTime;
+
+    /** 入公海时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    private Date poolTime;
+
+    /** $column.columnComment */
+    //必传
+    private Long companyId;
+
+    /** 是否为线索客户 */
+    private Integer isLine;
+
+    /** 客户来源 */
+    private String source;
+
+    /** 标签 */
+    private String tags;
+
+    private String extJson;
+    //跟进阶段
+    private Integer visitStatus;
+    //进线日期
+    private String registerDate;
+    //进线客户连接
+    private String registerLinkUrl;
+    //进线客户详情
+    private String registerDesc;
+    //进线客户填写时间
+    private String registerSubmitTime;
+
+    /** 流量平台线索归属账号 */
+    private String thirdAccount;
+
+    /** 流量平台线索Id */
+    private String clueId;
+    //是否在公海
+    private Integer isPool;
+    //进线方式
+    private String registerType;
+    //消费金额
+    private BigDecimal payMoney;
+    //购买次数
+    private Integer buyCount;
+    //来源渠道编码
+    private String sourceCode;
+    //推线时间
+    private String pushTime;
+    //推线编码
+    private String pushCode;
+    private String intention;
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date visitTime;
+    // 购买商品
+    private String goodsName;
+    // 购买规格
+    private String goodsSpecification;
+    // 店铺名称
+    private String shopName;
+    // 平台名称
+    private String platformName;
+
+    //场景类型,字典task_scene_type
+    private Integer sceneType;
+    //对话图
+    private String dialogue;
+    //投流id ||在呼入场景下 此id存储值为呼入cdr的uuid
+    private String traceId;
+
+    private String remark;
+
+}

+ 18 - 0
fs-service/src/main/java/com/fs/company/param/InboundCallbackParam.java

@@ -0,0 +1,18 @@
+package com.fs.company.param;
+
+import lombok.Data;
+
+/**
+ * @author MixLiu
+ * @date 2026/4/28 09:59
+ * @description
+ */
+
+@Data
+public class InboundCallbackParam {
+
+    /**
+     * 数据uuid
+     */
+    private String uuid;
+}

+ 26 - 0
fs-service/src/main/java/com/fs/company/param/PauseRoboticActiveParam.java

@@ -0,0 +1,26 @@
+package com.fs.company.param;
+
+import lombok.Data;
+
+/**
+ * @author MixLiu
+ * @date 2026/4/29 14:33
+ * @description
+ */
+
+@Data
+public class PauseRoboticActiveParam {
+
+    /**
+     * 任务id
+     */
+    private Long taskId;
+
+    /**
+     * 操作类型 1、暂停 2、继续
+     */
+    private Integer activeType;
+
+
+
+}

+ 8 - 0
fs-service/src/main/java/com/fs/company/service/CompanyWorkflowEngine.java

@@ -1,5 +1,6 @@
 package com.fs.company.service;
 
+import com.alibaba.fastjson.JSONObject;
 import com.fs.company.domain.CompanyAiWorkflowExecLog;
 import com.fs.company.vo.ExecutionResult;
 
@@ -69,4 +70,11 @@ public interface CompanyWorkflowEngine {
      * @param workFlowId
      */
     Long createSipTask(Long roboticId,Long workFlowId);
+
+    /**
+     * 更新exec记录的variables字段(用于暂停时同步延时上下文到DB)
+     * @param workflowInstanceId
+     * @param variables 最新的variables map
+     */
+    void updateExecVariables(String workflowInstanceId, Map<String, Object> variables);
 }

+ 15 - 0
fs-service/src/main/java/com/fs/company/service/ICompanyAiCallDataSyncService.java

@@ -0,0 +1,15 @@
+package com.fs.company.service;
+
+/**
+ * AI外呼数据同步服务
+ */
+public interface ICompanyAiCallDataSyncService {
+
+    /**
+     * 尝试启动异步同步任务(同公司防重复)
+     *
+     * @param companyId 公司ID
+     * @return true-已启动;false-已有任务在运行
+     */
+    boolean tryStartSync(Long companyId);
+}

+ 62 - 0
fs-service/src/main/java/com/fs/company/service/ICompanyInboundBindService.java

@@ -0,0 +1,62 @@
+package com.fs.company.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.fs.company.domain.CompanyInboundBind;
+
+import java.util.List;
+
+/**
+ * 呼入线路模型绑定关系Service接口
+ *
+ * @author fs
+ * @date 2026-04-27
+ */
+public interface ICompanyInboundBindService extends IService<CompanyInboundBind>{
+    /**
+     * 查询呼入线路模型绑定关系
+     *
+     * @param id 呼入线路模型绑定关系主键
+     * @return 呼入线路模型绑定关系
+     */
+    CompanyInboundBind selectCompanyInboundBindById(Long id);
+
+    /**
+     * 查询呼入线路模型绑定关系列表
+     *
+     * @param companyInboundBind 呼入线路模型绑定关系
+     * @return 呼入线路模型绑定关系集合
+     */
+    List<CompanyInboundBind> selectCompanyInboundBindList(CompanyInboundBind companyInboundBind);
+
+    /**
+     * 新增呼入线路模型绑定关系
+     *
+     * @param companyInboundBind 呼入线路模型绑定关系
+     * @return 结果
+     */
+    int insertCompanyInboundBind(CompanyInboundBind companyInboundBind);
+
+    /**
+     * 修改呼入线路模型绑定关系
+     *
+     * @param companyInboundBind 呼入线路模型绑定关系
+     * @return 结果
+     */
+    int updateCompanyInboundBind(CompanyInboundBind companyInboundBind);
+
+    /**
+     * 批量删除呼入线路模型绑定关系
+     *
+     * @param ids 需要删除的呼入线路模型绑定关系主键集合
+     * @return 结果
+     */
+    int deleteCompanyInboundBindByIds(Long[] ids);
+
+    /**
+     * 删除呼入线路模型绑定关系信息
+     *
+     * @param id 呼入线路模型绑定关系主键
+     * @return 结果
+     */
+    int deleteCompanyInboundBindById(Long id);
+}

+ 80 - 0
fs-service/src/main/java/com/fs/company/service/ICompanyInboundCallManageService.java

@@ -0,0 +1,80 @@
+package com.fs.company.service;
+
+import com.fs.company.domain.EasyCallInboundCdrVO;
+import com.fs.company.vo.easycall.EasyCallInboundLlmVO;
+
+import java.util.List;
+
+/**
+ * 呼入大模型配置 Service接口
+ *
+ * @author fs
+ */
+public interface ICompanyInboundCallManageService {
+
+    /**
+     * 查询呼入大模型配置
+     *
+     * @param id 主键ID
+     * @return 配置信息
+     */
+    EasyCallInboundLlmVO selectInboundLlmById(Integer id);
+
+    /**
+     * 查询呼入大模型配置列表
+     *
+     * @param vo 查询条件
+     * @return 配置列表
+     */
+    List<EasyCallInboundLlmVO> selectInboundLlmList(EasyCallInboundLlmVO vo);
+
+    /**
+     * 根据被叫号码查询配置列表
+     *
+     * @param callee 被叫号码
+     * @return 配置列表
+     */
+    List<EasyCallInboundLlmVO> selectInboundLlmByCallee(String callee);
+
+    /**
+     * 新增呼入大模型配置
+     *
+     * @param vo 配置信息
+     * @return 影响行数
+     */
+    int insertInboundLlm(EasyCallInboundLlmVO vo);
+
+    /**
+     * 修改呼入大模型配置
+     *
+     * @param vo 配置信息
+     * @return 影响行数
+     */
+    int updateInboundLlm(EasyCallInboundLlmVO vo);
+
+    /**
+     * 删除呼入大模型配置
+     *
+     * @param id 主键ID
+     * @return 影响行数
+     */
+    int deleteInboundLlmById(Integer id);
+
+    /**
+     * 批量删除呼入大模型配置
+     *
+     * @param ids ID字符串,逗号分隔
+     * @return 影响行数
+     */
+    int deleteInboundLlmByIds(String ids,Long companyId);
+
+    /**
+     * 查询呼入通话记录列表
+     *
+     * @param vo 查询条件
+     * @return 通话记录列表
+     */
+    List<EasyCallInboundCdrVO> selectInboundCdrList(EasyCallInboundCdrVO vo);
+
+    Boolean checkCalleeInboundLlm(String callee);
+}

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

@@ -1,8 +1,9 @@
 package com.fs.company.service;
 
-import java.util.List;
 import com.fs.company.domain.CompanyVoiceApi;
 
+import java.util.List;
+
 /**
  * 呼叫接口Service接口
  * 

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

@@ -1,8 +1,9 @@
 package com.fs.company.service;
 
-import java.util.List;
 import com.fs.company.domain.CompanyVoiceBlacklist;
 
+import java.util.List;
+
 /**
  * 黑名单Service接口
  * 

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

@@ -1,11 +1,12 @@
 package com.fs.company.service;
 
-import java.util.List;
 import com.fs.company.domain.CompanyVoiceCaller;
 import com.fs.company.param.CompanyVoiceCallerImportParam;
 import com.fs.company.param.CompanyVoiceCallerParam;
 import com.fs.company.vo.CompanyVoiceCallerListVO;
 
+import java.util.List;
+
 /**
  * 企业用户坐席Service接口
  *

+ 15 - 0
fs-service/src/main/java/com/fs/company/service/ICompanyVoiceCloneRefService.java

@@ -0,0 +1,15 @@
+package com.fs.company.service;
+
+import com.fs.company.vo.easycall.EasyCallVoiceCodeVO;
+
+import java.util.List;
+
+/**
+ * 豆包声音克隆 Service 接口
+ *
+ * @author fs
+ */
+public interface ICompanyVoiceCloneRefService {
+
+    List<EasyCallVoiceCodeVO> getVoiceCodeList(Long companyId);
+}

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

@@ -0,0 +1,63 @@
+package com.fs.company.service;
+
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.company.domain.CompanyVoiceCloneRef;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.List;
+
+/**
+ * 豆包声音克隆 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,Long companyId,Long companyUserId);
+
+    /**
+     * TTS 语音合成测试
+     *
+     * @param speakerId 声音ID
+     * @param language  语种
+     * @param text      要合成的文本
+     * @return 操作结果(含 base64 音频数据)
+     */
+    AjaxResult doubaoTtsTest(String speakerId, Integer language, String text);
+
+    /**
+     * 分页查询音色关联列表
+     *
+     * @param ref 查询条件(companyId、companyUserId、voiceName、status)
+     * @return 音色关联列表
+     */
+    List<CompanyVoiceCloneRef> selectRefList(CompanyVoiceCloneRef ref);
+
+    /**
+     * 根据ID删除音色关联
+     *
+     * @param id 主键ID
+     * @return 删除行数
+     */
+    int deleteRefById(Long id);
+
+    /**
+     * 修改音色关联启用/禁用状态
+     *
+     * @param ref 包含 id 和 status
+     * @return 更新行数
+     */
+    int changeStatus(CompanyVoiceCloneRef ref);
+}

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

@@ -1,9 +1,10 @@
 package com.fs.company.service;
 
-import java.util.List;
 import com.fs.company.domain.CompanyVoiceConfig;
 import com.fs.company.vo.CompanyVoiceConfigListVO;
 
+import java.util.List;
+
 /**
  * 呼叫频率配置Service接口
  * 

+ 3 - 3
fs-service/src/main/java/com/fs/company/service/ICompanyVoiceLogsService.java

@@ -1,8 +1,5 @@
 package com.fs.company.service;
 
-import java.util.List;
-import java.util.Map;
-
 import com.alibaba.fastjson.JSONObject;
 import com.fs.company.domain.CompanyVoiceLogs;
 import com.fs.company.param.CompanyStatisticsParam;
@@ -13,6 +10,9 @@ import com.fs.company.vo.CompanyVoiceLogsListVO;
 import com.fs.company.vo.CompanyVoiceLogsStatisticsVO;
 import com.fs.company.vo.CompanyVoiceLogsVO;
 
+import java.util.List;
+import java.util.Map;
+
 /**
  * 通话记录Service接口
  *

+ 13 - 0
fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticCallLogAddwxService.java

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
 import com.fs.company.domain.CompanyVoiceRoboticCallLogAddwx;
 import com.fs.company.domain.CompanyWxClient;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogAddwxVO;
+import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
 
 import java.util.List;
 import java.util.Map;
@@ -76,4 +77,16 @@ public interface ICompanyVoiceRoboticCallLogAddwxService extends IService<Compan
 
     Map<String, Long> countListAll(CompanyVoiceRoboticCallLogAddwx companyVoiceRoboticCallLogAddwx);
 
+    List<CompanyVoiceRoboticCallLogAddwx> selectCompanyVoiceRoboticAddwxLogGroupList(CompanyVoiceRoboticCallLogAddwx companyVoiceRoboticCallLogAddwx);
+
+    CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticAddwxLogCount();
+
+    /**
+     * 查询加微记录详情
+     * @param companyVoiceRoboticCallLogAddwx
+     * @return
+     */
+    List<CompanyVoiceRoboticCallLogAddwxVO> listByRoboticId(CompanyVoiceRoboticCallLogAddwx companyVoiceRoboticCallLogAddwx);
+
+
 }

+ 29 - 6
fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticCallLogCallphoneService.java

@@ -2,10 +2,7 @@ package com.fs.company.service;
 
 import com.baomidou.mybatisplus.extension.service.IService;
 import com.fs.company.domain.CompanyVoiceRoboticCallLogCallphone;
-import com.fs.company.vo.CalleeRoboticCallOutCountVO;
-import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneVO;
-import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
-import com.fs.company.vo.CustomerRoboticCallOutCountVO;
+import com.fs.company.vo.*;
 
 import java.util.List;
 
@@ -72,7 +69,6 @@ public interface ICompanyVoiceRoboticCallLogCallphoneService extends IService<Co
      */
     List<CompanyVoiceRoboticCallLogCallphone> selectCompanyVoiceRoboticCallLogCallphoneListData(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
 
-    List<CustomerRoboticCallOutCountVO> countRoboticCallOutByCustomerIds(List<Long> customerIds, Long companyId);
 
     List<CalleeRoboticCallOutCountVO> countRoboticCallOutByCalleeIds(List<Long> calleeIds, Long roboticId, Long companyId);
 
@@ -85,7 +81,34 @@ public interface ICompanyVoiceRoboticCallLogCallphoneService extends IService<Co
 
     List<CompanyVoiceRoboticCallLogCallphone> selectCompanyVoiceRoboticCallPhoneLogGroupList(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
 
-    CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallPhoneLogCount();
+    CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallPhoneLogCount(Long companyId,Long companyUserId);
 
     List<CompanyVoiceRoboticCallLogCallPhoneVO> listByRoboticId(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
+
+    List<CustomerRoboticCallOutCountVO> countRoboticCallOutByCustomerIds(List<Long> customerIds, Long companyId);
+
+
+    void prepareDetailListQuery(CompanyVoiceRoboticCallLogCallphone query, Long companyId);
+
+    List<CompanyVoiceRoboticCallLogCallPhoneVO> selectDetailList(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
+
+    CompanyVoiceRoboticCallLogDetailSummary selectDetailSummary(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
+
+    List<CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO> listDecryptPhoneExport(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
+
+    List<CompanyVoiceRoboticCallLogCallphone> selectManualAnsweredList(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
+
+    /**
+     * 标记外呼记录为已处理(handleFlag=1)
+     *
+     * @param logId 外呼记录主键
+     * @return 影响行数
+     */
+    int markHandleFlag(Long logId);
+
+    /**
+     * 根据外呼记录ID获取解密后的手机号
+     */
+    String getDecryptPhoneByLogId(Long logId);
+
 }

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

@@ -66,5 +66,5 @@ public interface ICompanyVoiceRoboticCallLogSendmsgService extends IService<Comp
 
     List<CompanyVoiceRoboticCallLogSendmsgVO> listByCallerIdAndRoboticId(CompanyVoiceRoboticCallLogSendmsg companyVoiceRoboticCallLogSendmsg);
 
-    CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallLogSendMsgCount();
+    CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallLogSendMsgCount(Long companyId,Long companyUserId);
 }

+ 43 - 5
fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticService.java

@@ -1,23 +1,28 @@
 package com.fs.company.service;
 
+import com.baomidou.mybatisplus.extension.service.IService;
 import com.fs.aicall.domain.apiresult.PushIIntentionResult;
+import com.fs.aicall.domain.result.CalltaskcreateaiCustomizeResult;
+import com.fs.common.core.domain.R;
 import com.fs.company.domain.CompanyVoiceRobotic;
 import com.fs.company.param.ExecutionContext;
+import com.fs.company.param.PauseRoboticActiveParam;
 import com.fs.company.vo.AddWxClientVo;
+import com.fs.company.vo.AiCallConfigVO;
 import com.fs.company.vo.CdrDetailVo;
 import com.fs.company.vo.CompanyVoiceRoboticQwUserListVo;
 
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 /**
  * 机器人外呼任务Service接口
- * 
+ *
  * @author fs
  * @date 2024-12-04
  */
-public interface ICompanyVoiceRoboticService 
-{
+public interface ICompanyVoiceRoboticService extends IService<CompanyVoiceRobotic> {
     /**
      * 查询机器人外呼任务
      * 
@@ -42,6 +47,10 @@ public interface ICompanyVoiceRoboticService
      * @return 结果
      */
     public int insertCompanyVoiceRobotic(CompanyVoiceRobotic companyVoiceRobotic);
+    CalltaskcreateaiCustomizeResult addTask(CompanyVoiceRobotic companyVoiceRobotic);
+    CalltaskcreateaiCustomizeResult callPhoneOne(Long roboticId,Long callerId);
+    CalltaskcreateaiCustomizeResult workflowCallPhoneOne(Long roboticId, Long callerId, ExecutionContext context, AiCallConfigVO callConfigVo);
+    void sendMsgOne(Long roboticId,Long callerId);
 
     /**
      * 修改机器人外呼任务
@@ -72,7 +81,6 @@ public interface ICompanyVoiceRoboticService
 
     void callerResult(PushIIntentionResult result);
 
-    void test(Long id);
     void dispenseWx(Long id);
 
     void addCompany(AddWxClientVo vo);
@@ -99,5 +107,35 @@ public interface ICompanyVoiceRoboticService
      * @param roboticId 任务ID
      * @return 执行记录列表
      */
-    Map<String, Object> getExecRecords(Long roboticId, Integer pageNum, Integer pageSize,String customerName,String customerPhone, Boolean onlyCallNode);
+    Map<String, Object> getExecRecords(Long roboticId, Integer pageNum, Integer pageSize,String customerName,String customerPhone, Boolean onlyCallNode,String encryptPhone);
+
+    void finishAddWxByCallees(Set<Long> roboticIds);
+
+    /**
+     * 入流程执行
+     * @param taskId
+     * @param crmCustomerId
+     * @param traceId
+     */
+    void addNewExec4Task(Long taskId,Long crmCustomerId,String traceId);
+
+    R pauseRoboticActive(PauseRoboticActiveParam param);
+
+    /**
+     * 判断任务是否处于暂停状态
+     * 优先从Redis读取,Redis无数据则查DB并回填
+     *
+     * @param taskId 任务ID
+     * @return true=暂停中 false=非暂停
+     */
+    boolean isTaskPaused(Long taskId);
+
+    /**
+     * 追加客户到运行中的普通任务
+     *
+     * @param taskId      任务ID
+     * @param customerIds 要追加的CRM客户ID列表
+     * @return 追加结果(成功数、重复客户信息)
+     */
+    R appendCustomersToRunningTask(Long taskId, List<Long> customerIds);
 }

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

@@ -107,4 +107,6 @@ public interface ICompanyWorkflowService {
     List<CompanyWorkflowNodeVoiceVo> getMyWorkflowNodes(Long companyUserId);
 
     List<OptionVO> optionList(Long companyId);
+
+    List<String> selectNodeTypeCodesByWorkflowId(Long workflowId);
 }

+ 21 - 7
fs-service/src/main/java/com/fs/company/service/ICompanyWxAccountService.java

@@ -1,15 +1,15 @@
 package com.fs.company.service;
 
 import com.baomidou.mybatisplus.extension.service.IService;
+import com.fs.common.core.domain.R;
 import com.fs.company.domain.CompanyUser;
 import com.fs.company.domain.CompanyWxAccount;
 
-import javax.validation.constraints.NotBlank;
 import java.util.List;
 
 /**
  * 企微账号Service接口
- * 
+ *
  * @author fs
  * @date 2024-12-09
  */
@@ -17,7 +17,7 @@ public interface ICompanyWxAccountService extends IService<CompanyWxAccount>
 {
     /**
      * 查询企微账号
-     * 
+     *
      * @param id 企微账号ID
      * @return 企微账号
      */
@@ -25,7 +25,7 @@ public interface ICompanyWxAccountService extends IService<CompanyWxAccount>
 
     /**
      * 查询企微账号列表
-     * 
+     *
      * @param companyWxAccount 企微账号
      * @return 企微账号集合
      */
@@ -42,7 +42,7 @@ public interface ICompanyWxAccountService extends IService<CompanyWxAccount>
 
     /**
      * 修改企微账号
-     * 
+     *
      * @param companyWxAccount 企微账号
      * @return 结果
      */
@@ -50,7 +50,7 @@ public interface ICompanyWxAccountService extends IService<CompanyWxAccount>
 
     /**
      * 批量删除企微账号
-     * 
+     *
      * @param ids 需要删除的企微账号ID
      * @return 结果
      */
@@ -58,7 +58,7 @@ public interface ICompanyWxAccountService extends IService<CompanyWxAccount>
 
     /**
      * 删除企微账号信息
-     * 
+     *
      * @param id 企微账号ID
      * @return 结果
      */
@@ -69,5 +69,19 @@ public interface ICompanyWxAccountService extends IService<CompanyWxAccount>
 
     CompanyWxAccount selectByCompanyUserAndWxNo(Long userId, String wxNo);
 
+    String getWxQrCode(Long accountId, String ipadOrMac);
+
+    boolean getLoginStatus(Long accountId);
+
+    R bindService(Long accountId);
+
+    void wakeUpLogin(Long accountId);
+
+    void updateWxInfo(Long accountId);
+
+    void wxLoginOut(Long accountId);
+
+    void syncWx(Long accountId);
+
     void isCheckContact(String formUser, Long accountId);
 }

+ 9 - 9
fs-service/src/main/java/com/fs/company/service/ICompanyWxChatService.java

@@ -1,20 +1,20 @@
 package com.fs.company.service;
 
-import java.util.List;
-
 import com.baomidou.mybatisplus.extension.service.IService;
 import com.fs.company.domain.CompanyWxChat;
 
+import java.util.List;
+
 /**
  * 个微聊天Service接口
- * 
+ *
  * @author fs
  * @date 2025-01-15
  */
 public interface ICompanyWxChatService extends IService<CompanyWxChat> {
     /**
      * 查询个微聊天
-     * 
+     *
      * @param id 个微聊天主键
      * @return 个微聊天
      */
@@ -22,7 +22,7 @@ public interface ICompanyWxChatService extends IService<CompanyWxChat> {
 
     /**
      * 查询个微聊天列表
-     * 
+     *
      * @param companyWxChat 个微聊天
      * @return 个微聊天集合
      */
@@ -30,7 +30,7 @@ public interface ICompanyWxChatService extends IService<CompanyWxChat> {
 
     /**
      * 新增个微聊天
-     * 
+     *
      * @param companyWxChat 个微聊天
      * @return 结果
      */
@@ -38,7 +38,7 @@ public interface ICompanyWxChatService extends IService<CompanyWxChat> {
 
     /**
      * 修改个微聊天
-     * 
+     *
      * @param companyWxChat 个微聊天
      * @return 结果
      */
@@ -46,7 +46,7 @@ public interface ICompanyWxChatService extends IService<CompanyWxChat> {
 
     /**
      * 批量删除个微聊天
-     * 
+     *
      * @param ids 需要删除的个微聊天主键集合
      * @return 结果
      */
@@ -54,7 +54,7 @@ public interface ICompanyWxChatService extends IService<CompanyWxChat> {
 
     /**
      * 删除个微聊天信息
-     * 
+     *
      * @param id 个微聊天主键
      * @return 结果
      */

+ 16 - 9
fs-service/src/main/java/com/fs/company/service/ICompanyWxClientService.java

@@ -3,20 +3,20 @@ package com.fs.company.service;
 import com.baomidou.mybatisplus.extension.service.IService;
 import com.fs.company.domain.CompanyWxClient;
 import com.fs.company.vo.AddWxResultVo;
+import com.fs.company.vo.CompanyWxClient4WorkFlowVO;
 
 import java.util.List;
 
 /**
  * 添加个微信账号Service接口
- * 
+ *
  * @author fs
  * @date 2024-12-09
  */
-public interface ICompanyWxClientService extends IService<CompanyWxClient>
-{
+public interface ICompanyWxClientService extends IService<CompanyWxClient> {
     /**
      * 查询添加个微信账号
-     * 
+     *
      * @param id 添加个微信账号ID
      * @return 添加个微信账号
      */
@@ -24,7 +24,7 @@ public interface ICompanyWxClientService extends IService<CompanyWxClient>
 
     /**
      * 查询添加个微信账号列表
-     * 
+     *
      * @param companyWxClient 添加个微信账号
      * @return 添加个微信账号集合
      */
@@ -33,7 +33,7 @@ public interface ICompanyWxClientService extends IService<CompanyWxClient>
 
     /**
      * 新增添加个微信账号
-     * 
+     *
      * @param companyWxClient 添加个微信账号
      * @return 结果
      */
@@ -41,7 +41,7 @@ public interface ICompanyWxClientService extends IService<CompanyWxClient>
 
     /**
      * 修改添加个微信账号
-     * 
+     *
      * @param companyWxClient 添加个微信账号
      * @return 结果
      */
@@ -49,7 +49,7 @@ public interface ICompanyWxClientService extends IService<CompanyWxClient>
 
     /**
      * 批量删除添加个微信账号
-     * 
+     *
      * @param ids 需要删除的添加个微信账号ID
      * @return 结果
      */
@@ -57,7 +57,7 @@ public interface ICompanyWxClientService extends IService<CompanyWxClient>
 
     /**
      * 删除添加个微信账号信息
-     * 
+     *
      * @param id 添加个微信账号ID
      * @return 结果
      */
@@ -69,4 +69,11 @@ public interface ICompanyWxClientService extends IService<CompanyWxClient>
 
     void addWxTrueResult(AddWxResultVo vo);
 
+    List<CompanyWxClient> getAddWxList(List<Long> accountIdList,Integer isWeCom);
+
+    List<CompanyWxClient4WorkFlowVO> getAddWxList4Workflow(List<Long> accountIdList,Integer cidGroupId);
+
+    List<CompanyWxClient> getQwAddWxList(List<Long> accountIdList,Integer isWeCom,Integer cidGroupNo);
+
+    List<CompanyWxClient4WorkFlowVO> getQwAddWxList4Workflow(List<Long> accountIdList,Integer cidGroupNo);
 }

+ 9 - 9
fs-service/src/main/java/com/fs/company/service/ICompanyWxDialogService.java

@@ -1,20 +1,20 @@
 package com.fs.company.service;
 
+import com.baomidou.mybatisplus.extension.service.IService;
 import com.fs.company.domain.CompanyWxDialog;
 
 import java.util.List;
 
 /**
  * 添加微信话术Service接口
- * 
+ *
  * @author fs
  * @date 2024-12-06
  */
-public interface ICompanyWxDialogService 
-{
+public interface ICompanyWxDialogService extends IService<CompanyWxDialog> {
     /**
      * 查询添加微信话术
-     * 
+     *
      * @param id 添加微信话术ID
      * @return 添加微信话术
      */
@@ -22,7 +22,7 @@ public interface ICompanyWxDialogService
 
     /**
      * 查询添加微信话术列表
-     * 
+     *
      * @param companyWxDialog 添加微信话术
      * @return 添加微信话术集合
      */
@@ -30,7 +30,7 @@ public interface ICompanyWxDialogService
 
     /**
      * 新增添加微信话术
-     * 
+     *
      * @param companyWxDialog 添加微信话术
      * @return 结果
      */
@@ -38,7 +38,7 @@ public interface ICompanyWxDialogService
 
     /**
      * 修改添加微信话术
-     * 
+     *
      * @param companyWxDialog 添加微信话术
      * @return 结果
      */
@@ -46,7 +46,7 @@ public interface ICompanyWxDialogService
 
     /**
      * 批量删除添加微信话术
-     * 
+     *
      * @param ids 需要删除的添加微信话术ID
      * @return 结果
      */
@@ -54,7 +54,7 @@ public interface ICompanyWxDialogService
 
     /**
      * 删除添加微信话术信息
-     * 
+     *
      * @param id 添加微信话术ID
      * @return 结果
      */

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

@@ -0,0 +1,18 @@
+package com.fs.company.service;
+
+import com.fs.company.param.EntryCustomerParam;
+import com.fs.company.param.InboundCallbackParam;
+
+/**
+ * @author MixLiu
+ * @date 2026/3/17 15:49
+ * @description
+ */
+public interface IGeneralCustomerEntryService {
+
+//    R entryCustomer(String param);
+
+    void entryCustomer(EntryCustomerParam param);
+
+    void inboundCallback(InboundCallbackParam param);
+}

+ 336 - 12
fs-service/src/main/java/com/fs/company/service/easycall/EasyCallServiceImpl.java

@@ -1,23 +1,40 @@
 package com.fs.company.service.easycall;
 
-import cn.hutool.http.HttpRequest;
-import cn.hutool.http.HttpUtil;
+import cn.hutool.core.date.DateUtil;
+import cn.hutool.json.JSONUtil;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.core.redis.RedisCacheT;
 import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.CompanyConfig;
+import com.fs.company.domain.OutboundLineLimitLog;
+import com.fs.company.dto.OutboundLimitResultVO;
+import com.fs.company.mapper.CompanyConfigMapper;
 import com.fs.company.mapper.CompanyMapper;
+import com.fs.company.mapper.OutboundLineLimitLogMapper;
+import com.fs.company.vo.CidConfigVO;
 import com.fs.company.vo.easycall.*;
+import cn.hutool.http.HttpRequest;
+import cn.hutool.http.HttpUtil;
+import com.fs.his.config.CidPhoneConfig;
 import com.fs.system.service.ISysConfigService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Date;
 import java.util.List;
+import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
+import java.util.stream.IntStream;
 
 /**
  * EasyCallCenter365 外呼服务实现类
@@ -32,6 +49,15 @@ import java.util.stream.Collectors;
 @Slf4j
 public class EasyCallServiceImpl implements IEasyCallService {
 
+    @Autowired
+    private ISysConfigService configService;
+
+    @Autowired
+    private CompanyConfigMapper companyConfigMapper;
+
+    @Autowired
+    private OutboundLineLimitLogMapper outboundLineLimitLogMapper;
+
     /**
      * EasyCallCenter365 服务器基础地址,从配置文件 easycall.base-url 读取
      */
@@ -84,10 +110,19 @@ public class EasyCallServiceImpl implements IEasyCallService {
      */
     private static final String API_COMMON_ADD_LIST = "/aicall/api/common/addCallList";
 
+    /** 外呼线路执行限制 Redis key 前缀 */
+    private static final String OUTBOUND_LIMIT_REDIS_PREFIX = "outbound:limit:";
+
     @Autowired
     CompanyMapper companyMapper;
+
     @Autowired
-    private ISysConfigService configService;
+    private RedisCacheT<String> redisCacheT;
+
+    @Autowired
+    private RedisCache redisCache;
+
+
 
     // =================== 基础数据查询接口 ===================
 
@@ -108,17 +143,23 @@ public class EasyCallServiceImpl implements IEasyCallService {
             if(StringUtils.isNotBlank(gateWayList)){
                 List<Long> collect = Arrays.stream(gateWayList.split(",")).map(item -> Long.valueOf(item.trim())).collect(Collectors.toList());
                 resList = resList.stream().filter(item -> collect.contains(item.getId())).collect(Collectors.toList());
-            } else {
+            }else{
                 String json = configService.selectConfigByKey("cid.config");
                 if (StringUtils.isNotBlank(json)) {
-                    JSONObject obj = JSONObject.parseObject(json);
-                    if(null != obj && obj.containsKey("showGatewayIds")){
-                        List<Long> showGatewayIds = obj.getJSONArray("showGatewayIds").stream().map(item -> Long.valueOf(item.toString())).collect(Collectors.toList());
-                        resList = resList.stream().filter(item -> showGatewayIds.contains(item.getId())).collect(Collectors.toList());
+                    CidPhoneConfig cidPhoneConfig = JSONObject.parseObject(json, CidPhoneConfig.class);
+                    if (cidPhoneConfig != null
+                            && Boolean.TRUE.equals(cidPhoneConfig.getEnableGateWayLimit())
+                            && cidPhoneConfig.getShowGatewayIds() != null
+                            && !cidPhoneConfig.getShowGatewayIds().isEmpty()) {
+                        resList = resList.stream()
+                                .filter(item -> cidPhoneConfig.getShowGatewayIds().contains(item.getId()))
+                                .collect(Collectors.toList());
                     }
                 }
             }
         }
+
+
         return resList;
     }
 
@@ -245,10 +286,291 @@ public class EasyCallServiceImpl implements IEasyCallService {
      * bizJson 字段可传入需要传递给机器人的业务数据(如客户姓名、订单号等)
      */
     @Override
-    public void addCommonCallList(EasyCallCommonAddCallListParam param, Long companyId) {
+    public boolean addCommonCallList(EasyCallCommonAddCallListParam param, Long companyId,Long gatewayId) {
+//        String url = buildUrl(API_COMMON_ADD_LIST);
+//        JSONObject result = doPost(url, JSON.toJSONString(param));
+//        checkSuccess(result);
+        // 1. 检查外呼线路限制
+        OutboundLimitResultVO limitResult = checkOutboundLineLimit(companyId, gatewayId);
+        if (!limitResult.isPassed()) {
+            log.warn("addCommonCallList: 外呼线路执行限制触发 - companyId: {}, gatewayId: {}, 下次可用时间: {}",
+                    companyId, gatewayId, DateUtil.format(limitResult.getNextAvailableTime(), "yyyy-MM-dd HH:mm:ss"));
+            // 将未执行的呼叫参数存入 Redis,供定时任务重试
+            saveRetryTask(companyId, gatewayId, param, limitResult.getNextAvailableTime());
+            return false;
+        }
+        // 2. 调用外呼接口追加名单
         String url = buildUrl(API_COMMON_ADD_LIST);
-        JSONObject result = doPost(url, JSON.toJSONString(param));
-        checkSuccess(result);
+        try {
+            JSONObject result = doPost(url, JSON.toJSONString(param));
+            boolean success = checkSuccess(result);
+            if (success) {
+                // 3. 执行成功后记录OutboundLineLimitLog日志
+                saveExecutionLog(companyId, gatewayId, param);
+            }
+            return success;
+        } catch (Exception e) {
+            log.error("addCommonCallList: 外呼接口调用异常 - companyId: {}, gatewayId: {}", companyId, gatewayId, e);
+            return false;
+        }
+    }
+    /**
+     * 外呼执行成功后,记录 OutboundLineLimitLog 日志
+     * <p>
+     * 仅做数据库写入,不维护Redis计数缓存(计数以数据库为准)
+     *
+     * @param companyId 公司ID
+     * @param gatewayId 外呼线路(网关)ID
+     * @param callParam 调用参数(记录到日志便于追溯)
+     */
+    private void saveExecutionLog(Long companyId, Long gatewayId, EasyCallCommonAddCallListParam callParam) {
+        try {
+            if (gatewayId == null) {
+                return;
+            }
+
+            // 查询配置,获取匹配的规则
+            CompanyConfig companyConfig = companyConfigMapper.selectCompanyConfigByKey(companyId, "cId.config");
+            if (companyConfig == null || StringUtils.isEmpty(companyConfig.getConfigValue())) {
+                return;
+            }
+
+            JSONObject configJson = JSONObject.parseObject(companyConfig.getConfigValue());
+            Boolean enabled = configJson.getBoolean("outboundLineLimitEnabled");
+            if (enabled == null || !enabled) {
+                return;
+            }
+
+            JSONArray rules = configJson.getJSONArray("outboundLineRules");
+            if (rules == null || rules.isEmpty()) {
+                return;
+            }
+
+            LocalDateTime now = LocalDateTime.now();
+            LocalDateTime todayMidnight = now.toLocalDate().atStartOfDay();
+
+            IntStream.range(0, rules.size())
+                    .mapToObj(rules::getJSONObject)
+                    .filter(rule -> {
+                        Integer tw = rule.getInteger("timeWindow");
+                        Integer mc = rule.getInteger("maxCalls");
+                        return tw != null && tw > 0 && mc != null && mc > 0;
+                    })
+                    .filter(rule -> {
+                        JSONArray lineIdsArr = rule.getJSONArray("lineIds");
+                        if (lineIdsArr == null || lineIdsArr.isEmpty()) {
+                            return true;
+                        }
+                        return IntStream.range(0, lineIdsArr.size())
+                                .anyMatch(j -> gatewayId.equals(lineIdsArr.getLong(j)));
+                    })
+                    .forEach(rule -> {
+                        Integer timeWindow = rule.getInteger("timeWindow");
+                        Integer maxCalls = rule.getInteger("maxCalls");
+
+                        // 计算当前固定窗口(以零点对齐)
+                        long minutesSinceMidnight = Duration.between(todayMidnight, now).toMinutes();
+                        long windowIndex = minutesSinceMidnight / timeWindow;
+                        LocalDateTime windowStart = todayMidnight.plusMinutes(windowIndex * timeWindow);
+                        LocalDateTime windowEnd = windowStart.plusMinutes(timeWindow);
+
+                        // 查询当前窗口内的实际数量
+                        int currentCount = outboundLineLimitLogMapper.countByWindow(companyId, gatewayId, timeWindow, windowStart, windowEnd);
+
+                        // 记录成功执行日志(is_limited=0)
+                        saveLimitLog(companyId, gatewayId, timeWindow, windowStart, maxCalls,
+                                currentCount + 1, callParam, null, 0);
+                    });
+        } catch (Exception e) {
+            log.error("saveExecutionLog 异常 - companyId: {}, gatewayId: {}", companyId, gatewayId, e);
+        }
+    }
+
+    /**
+     * 保存外呼重试任务到 Redis(Sorted Set 结构)
+     * <p>
+     * ZSET key: outbound:limit:retry:zset
+     * score: nextAvailableTime 的毫秒时间戳(定时任务用 ZRANGEBYSCORE 查询已到期的任务)
+     * member: JSON 字符串,包含 companyId, gatewayId, param, nextAvailableTime, createTime
+     *
+     * @param companyId        公司ID
+     * @param gatewayId        网关ID
+     * @param param            外呼调用参数(定时任务重试时需要)
+     * @param nextAvailableTime 下一个可用时间点
+     */
+    private void saveRetryTask(Long companyId, Long gatewayId, EasyCallCommonAddCallListParam param, Date nextAvailableTime) {
+        try {
+            JSONObject retryData = new JSONObject();
+            retryData.put("companyId", companyId);
+            retryData.put("gatewayId", gatewayId);
+            retryData.put("param", param);
+            retryData.put("nextAvailableTime", nextAvailableTime.getTime());
+            retryData.put("createTime", System.currentTimeMillis());
+
+            // score = nextAvailableTime 时间戳,定时任务通过 ZRANGEBYSCORE 0 当前时间 查询到期任务
+            String zsetKey = OUTBOUND_LIMIT_REDIS_PREFIX + "retry:zset";
+            double score = nextAvailableTime.getTime();
+            redisCache.zSetAdd(zsetKey, retryData.toJSONString(), score);
+
+            log.info("saveRetryTask: 已保存重试任务到ZSET - companyId: {}, gatewayId: {}, nextAvailableTime: {}",
+                    companyId, gatewayId, DateUtil.format(nextAvailableTime, "yyyy-MM-dd HH:mm:ss"));
+        } catch (Exception e) {
+            log.error("saveRetryTask 异常 - companyId: {}, gatewayId: {}", companyId, gatewayId, e);
+        }
+    }
+
+
+    /**
+     * 检查外呼线路是否达到限制
+     * <p>
+     * 流程:
+     * 1. 根据 gatewayId 线路ID找到公司配置
+     * 2. 如果配置了外呼限制(outboundLineLimitEnabled=true),筛选匹配当前线路的规则
+     * 3. 直接从 OutboundLineLimitLog 数据库查询时间窗口内的实际外呼次数(不依赖Redis缓存计数)
+     * 4. 如果达到限制则返回 limited 状态,上一级方法中断执行
+     * 5. 如果未达到限制则返回 pass 状态,上一级方法继续执行
+     *
+     * @param companyId 公司ID
+     * @param gatewayId 外呼线路(网关)ID
+     * @return 限制检查结果
+     */
+    private OutboundLimitResultVO checkOutboundLineLimit(Long companyId, Long gatewayId) {
+        try {
+            // gatewayId为空时不做限制
+            if (gatewayId == null) {
+                return OutboundLimitResultVO.pass();
+            }
+
+            // 1. 查询公司配置
+            CompanyConfig companyConfig = companyConfigMapper.selectCompanyConfigByKey(companyId, "cId.config");
+            if (companyConfig == null || StringUtils.isEmpty(companyConfig.getConfigValue())) {
+                return OutboundLimitResultVO.pass();
+            }
+
+            // 2. 检查是否启用外呼限制
+            JSONObject configJson = JSONObject.parseObject(companyConfig.getConfigValue());
+            Boolean enabled = configJson.getBoolean("outboundLineLimitEnabled");
+            if (enabled == null || !enabled) {
+                return OutboundLimitResultVO.pass();
+            }
+
+            JSONArray rules = configJson.getJSONArray("outboundLineRules");
+            if (rules == null || rules.isEmpty()) {
+                return OutboundLimitResultVO.pass();
+            }
+
+            // 3. 预筛选:找出匹配当前 gatewayId 的规则(纯内存操作,无I/O)
+            List<JSONObject> matchedRules = IntStream.range(0, rules.size())
+                    .mapToObj(rules::getJSONObject)
+                    .filter(rule -> {
+                        Integer tw = rule.getInteger("timeWindow");
+                        Integer mc = rule.getInteger("maxCalls");
+                        return tw != null && tw > 0 && mc != null && mc > 0;
+                    })
+                    .filter(rule -> {
+                        JSONArray lineIdsArr = rule.getJSONArray("lineIds");
+                        if (lineIdsArr == null || lineIdsArr.isEmpty()) {
+                            return true; // 全局规则,匹配所有线路
+                        }
+                        return IntStream.range(0, lineIdsArr.size())
+                                .anyMatch(j -> gatewayId.equals(lineIdsArr.getLong(j)));
+                    })
+                    .collect(Collectors.toList());
+
+            if (matchedRules.isEmpty()) {
+                return OutboundLimitResultVO.pass();
+            }
+
+            // 4. 对匹配的规则逐一检查限制(数量通常很少,1-3条)
+            LocalDateTime now = LocalDateTime.now();
+            LocalDateTime todayMidnight = now.toLocalDate().atStartOfDay();
+            Date earliestNextTime = null;
+
+            for (JSONObject rule : matchedRules) {
+                Integer timeWindow = rule.getInteger("timeWindow");
+                Integer maxCalls = rule.getInteger("maxCalls");
+
+                // 计算当前固定窗口(以零点对齐)
+                // 例:timeWindow=30 → 00:00~00:29, 00:30~00:59, ...
+                // 例:timeWindow=60 → 00:00~00:59, 01:00~01:59, ...
+                // 例:timeWindow=1440 → 00:00~23:59(整天)
+                long minutesSinceMidnight = Duration.between(todayMidnight, now).toMinutes();
+                long windowIndex = minutesSinceMidnight / timeWindow;
+                LocalDateTime windowStart = todayMidnight.plusMinutes(windowIndex * timeWindow);
+                LocalDateTime windowEnd = windowStart.plusMinutes(timeWindow);
+
+                // 快速拒绝:检查 Redis blocked 标记
+                String blockedRedisKey = OUTBOUND_LIMIT_REDIS_PREFIX + companyId + ":" + gatewayId + ":blocked:" + timeWindow + ":" + windowIndex;
+                String cachedBlock = redisCacheT.getCacheObject(blockedRedisKey);
+                if (cachedBlock != null) {
+                    Date nextAvailable = JSON.parseObject(cachedBlock, Date.class);
+                    if (nextAvailable != null) {
+                        if (earliestNextTime == null || nextAvailable.before(earliestNextTime)) {
+                            earliestNextTime = nextAvailable;
+                        }
+                        continue;
+                    }
+                }
+
+                // 查数据库:当前窗口内的实际外呼数量
+                int currentCount = outboundLineLimitLogMapper.countByWindow(companyId, gatewayId, timeWindow, windowStart, windowEnd);
+
+                // 判断是否达到限制
+                if (currentCount >= maxCalls) {
+                    // nextAvailableTime = 下一个窗口的起始时间(定时任务扫描用)
+                    Date nextAvailable = Date.from(windowEnd.atZone(ZoneId.systemDefault()).toInstant());
+
+                    // 设置 Redis blocked 标记,过期时间到当前窗口结束
+                    long remainingMinutes = Duration.between(now, windowEnd).toMinutes();
+                    remainingMinutes = Math.max(1, remainingMinutes);
+                    redisCacheT.setCacheObject(blockedRedisKey, JSON.toJSONString(nextAvailable),
+                            remainingMinutes, TimeUnit.MINUTES);
+
+                    if (earliestNextTime == null || nextAvailable.before(earliestNextTime)) {
+                        earliestNextTime = nextAvailable;
+                    }
+
+                    // 记录限制触发日志
+                    LocalDateTime nextAvailableTime = windowEnd;
+                    saveLimitLog(companyId, gatewayId, timeWindow, windowStart, maxCalls,
+                            currentCount, null, nextAvailableTime, 1);
+                }
+            }
+
+            // 5. 返回结果
+            if (earliestNextTime != null) {
+                return OutboundLimitResultVO.limited(earliestNextTime);
+            }
+            return OutboundLimitResultVO.pass();
+
+        } catch (Exception e) {
+            log.error("checkOutboundLineLimit 异常,默认放行 - companyId: {}, gatewayId: {}", companyId, gatewayId, e);
+            return OutboundLimitResultVO.pass();
+        }
+    }
+
+    /**
+     * 保存外呼限制日志到数据库
+     */
+    private void saveLimitLog(Long companyId, Long gatewayId, Integer timeWindow, LocalDateTime windowStart,
+                              Integer maxCalls, int currentCount, Object callParam,
+                              LocalDateTime nextAvailableTime, int isLimited) {
+        try {
+            OutboundLineLimitLog logEntity = new OutboundLineLimitLog();
+            logEntity.setCompanyId(companyId);
+            logEntity.setGatewayId(gatewayId);
+            logEntity.setTimeWindow(timeWindow);
+            logEntity.setWindowStart(windowStart);
+            logEntity.setMaxCalls(maxCalls);
+            logEntity.setCurrentCount(currentCount);
+            logEntity.setIsLimited(isLimited);
+            logEntity.setCallParam(callParam != null ? JSON.toJSONString(callParam) : null);
+            logEntity.setNextAvailableTime(nextAvailableTime);
+            logEntity.setCreateTime(LocalDateTime.now());
+            outboundLineLimitLogMapper.asyncInsert(logEntity);
+        } catch (Exception e) {
+            log.error("saveLimitLog 异常 - companyId: {}, gatewayId: {}, isLimited: {}", companyId, gatewayId, isLimited, e);
+        }
     }
 
     // =================== 录音相关接口 ===================
@@ -355,8 +677,10 @@ public class EasyCallServiceImpl implements IEasyCallService {
      * 启动、停止、追加名单等操作类接口无 data 返回,
      * 成功与否已由 parseAndCheck 中的 code 判断保证,此处无需额外处理
      */
-    private void checkSuccess(JSONObject result) {
+    private boolean checkSuccess(JSONObject result) {
         // parseAndCheck 中 code != 0 时已抛异常,走到这里即代表操作成功
+        Integer code = result.getInteger("code");
+        return code != null && code == 0;
     }
 
     /**

+ 9 - 1
fs-service/src/main/java/com/fs/company/service/easycall/IEasyCallService.java

@@ -92,13 +92,21 @@ public interface IEasyCallService {
      */
     void addAiCallList(EasyCallAddCallListParam param, Long companyId);
 
+//    /**
+//     * 通用追加名单(支持AI外呼和通知提醒任务)
+//     *
+//     * @param param     追加参数
+//     * @param companyId 公司id
+//     */
+//    void addCommonCallList(EasyCallCommonAddCallListParam param, Long companyId);
     /**
      * 通用追加名单(支持AI外呼和通知提醒任务)
      *
      * @param param     追加参数
      * @param companyId 公司id
+     * @param gatewayId
      */
-    void addCommonCallList(EasyCallCommonAddCallListParam param, Long companyId);
+    boolean addCommonCallList(EasyCallCommonAddCallListParam param, Long companyId, Long gatewayId);
 
     /**
      * 获取录音文件访问URL

+ 84 - 0
fs-service/src/main/java/com/fs/company/service/impl/CompanyAiCallDataSyncServiceImpl.java

@@ -0,0 +1,84 @@
+package com.fs.company.service.impl;
+
+import com.fs.common.core.redis.RedisCache;
+import com.fs.company.mapper.CompanyVoiceRoboticCallLogCallphoneMapper;
+import com.fs.company.mapper.EasyCallMapper;
+import com.fs.company.service.ICompanyAiCallDataSyncService;
+import com.fs.company.service.ICompanyVoiceRoboticService;
+import com.fs.company.vo.CdrDetailVo;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * AI外呼数据同步:补偿 EasyCall 已外呼完成但未回调入库的记录
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class CompanyAiCallDataSyncServiceImpl implements ICompanyAiCallDataSyncService {
+
+    private static final String SYNC_LOCK_PREFIX = "ai_call_sync:company:";
+    private static final int BATCH_SIZE = 1000;
+    private static final long LOCK_TIMEOUT_HOURS = 2L;
+
+    private final RedisCache redisCache;
+    private final CompanyVoiceRoboticCallLogCallphoneMapper callLogCallphoneMapper;
+    private final EasyCallMapper easyCallMapper;
+    private final ICompanyVoiceRoboticService companyVoiceRoboticService;
+
+
+    @Override
+    public boolean tryStartSync(Long companyId) {
+        String lockKey = buildLockKey(companyId);
+        if (!redisCache.setIfAbsent(lockKey, "1", LOCK_TIMEOUT_HOURS, TimeUnit.HOURS)) {
+            return false;
+        }
+        doSyncAiCallDataAsync(companyId, lockKey);
+        return true;
+    }
+
+    @Async("callLogExcutor")
+    public void doSyncAiCallDataAsync(Long companyId, String lockKey) {
+        try {
+            List<String> callbackUuids = callLogCallphoneMapper.selectRunningCallbackUuidsByCompanyId(companyId);
+            if (callbackUuids == null || callbackUuids.isEmpty()) {
+                log.info("syncAiCallData: 无待补偿外呼记录, companyId={}", companyId);
+                return;
+            }
+            log.info("syncAiCallData: 开始同步, companyId={}, 待检查callbackUuid数={}", companyId, callbackUuids.size());
+            int triggered = 0;
+            for (int i = 0; i < callbackUuids.size(); i += BATCH_SIZE) {
+                List<String> batch = callbackUuids.subList(i, Math.min(i + BATCH_SIZE, callbackUuids.size()));
+                List<String> completedUuids = easyCallMapper.selectCompletedUuidsByCallbackUuids(batch);
+                if (completedUuids == null || completedUuids.isEmpty()) {
+                    continue;
+                }
+                for (String uuid : completedUuids) {
+                    try {
+                        CdrDetailVo cdrDetailVo = new CdrDetailVo();
+                        cdrDetailVo.setUuid(uuid);
+                        cdrDetailVo.setCdrType("outbound");
+                        companyVoiceRoboticService.callerResult4EasyCall(cdrDetailVo);
+                        triggered++;
+                    } catch (Exception e) {
+                        log.error("syncAiCallData: 触发回调失败, uuid={}, companyId={}", uuid, companyId, e);
+                    }
+                }
+            }
+            log.info("syncAiCallData: 同步完成, companyId={}, 触发回调数={}", companyId, triggered);
+        } catch (Exception e) {
+            log.error("syncAiCallData: 同步异常, companyId={}", companyId, e);
+        } finally {
+            redisCache.deleteObject(lockKey);
+        }
+    }
+
+    private String buildLockKey(Long companyId) {
+        return SYNC_LOCK_PREFIX + companyId;
+    }
+}

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

@@ -0,0 +1,447 @@
+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.domain.CompanyVoiceCloneRef;
+import com.fs.company.mapper.CompanyVoiceCloneRefMapper;
+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.*;
+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;
+
+    @Autowired
+    private CompanyVoiceCloneRefMapper companyVoiceCloneRefMapper;
+
+    /**
+     * 上传并训练
+     * @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,Long companyId,Long companyUserId) {
+        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) {
+                    // 训练成功,写入音色表
+                    CcTtsAliyun ttsAliyun = addSpeakerId(voiceName, speakerId);
+                    addCompanyVoiceRelation(ttsAliyun.getVoiceCode(),voiceName,companyId, ttsAliyun.getId(), companyUserId);
+
+                    // 查询训练状态
+                    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 CcTtsAliyun 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);
+            }
+
+            // 重新查一次,确保拿到最新id
+            CcTtsAliyun saved = ccTtsAliyunService.selectCcTtsAliyunByVoiceCode(speakerId);
+            log.info("save doubaovcl speakerId succeed. {} {}", name, speakerId);
+            return saved;
+        } catch (Exception e) {
+            log.error("save doubaovcl speakerId error: {}", e.getMessage(), e);
+            throw new RuntimeException("保存音色失败", 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());
+        }
+    }
+
+    @Override
+    public List<CompanyVoiceCloneRef> selectRefList(CompanyVoiceCloneRef ref) {
+        return companyVoiceCloneRefMapper.selectCompanyVoiceCloneRefList(ref);
+    }
+
+    @Override
+    public int deleteRefById(Long id) {
+        return companyVoiceCloneRefMapper.deleteById(id);
+    }
+
+    @Override
+    public int changeStatus(CompanyVoiceCloneRef ref) {
+        return companyVoiceCloneRefMapper.updateStatusById(ref);
+    }
+
+    /**
+     * 构建 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();
+    }
+
+    private synchronized void addCompanyVoiceRelation(String code,String name,Long companyId, Integer ttsId,Long companyUserId) {
+        if (companyId == null) {
+            throw new RuntimeException("companyId为空,无法保存公司音色关联");
+        }
+        if (ttsId == null) {
+            throw new RuntimeException("ttsId为空,无法保存公司音色关联");
+        }
+
+        try {
+            CompanyVoiceCloneRef ref = companyVoiceCloneRefMapper.selectByCompanyIdAndTtsId(companyId ,companyUserId, ttsId);
+            if (ref == null) {
+                ref = new CompanyVoiceCloneRef();
+                ref.setVoiceName(name);
+                ref.setVoiceCode(code);
+                ref.setCompanyId(companyId);
+                ref.setCompanyUserId(companyUserId);
+                ref.setTtsId(ttsId);
+                ref.setStatus(1);
+                ref.setCreateBy("system");
+                ref.setUpdateBy("system");
+                companyVoiceCloneRefMapper.insertCompanyVoiceCloneRef(ref);
+            } else {
+                ref.setStatus(1);
+                ref.setUpdateBy("system");
+                companyVoiceCloneRefMapper.updateCompanyVoiceCloneRef(ref);
+            }
+            log.info("保存公司音色关联成功,companyId={}, ttsId={}", companyId, ttsId);
+        } catch (Exception e) {
+            log.error("保存公司音色关联失败,companyId={}, ttsId={}", companyId, ttsId, e);
+            throw new RuntimeException("保存公司音色关联失败", e);
+        }
+    }
+}

+ 16 - 3
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallLogAddwxServiceImpl.java

@@ -9,6 +9,7 @@ import com.fs.company.mapper.CompanyVoiceRoboticCallLogAddwxMapper;
 import com.fs.company.mapper.CompanyWxClientMapper;
 import com.fs.company.service.ICompanyVoiceRoboticCallLogAddwxService;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogAddwxVO;
+import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.scheduling.annotation.Async;
@@ -25,9 +26,7 @@ import java.util.Map;
  */
 @Service
 @Slf4j
-public
-
-class CompanyVoiceRoboticCallLogAddwxServiceImpl extends ServiceImpl<CompanyVoiceRoboticCallLogAddwxMapper, CompanyVoiceRoboticCallLogAddwx> implements ICompanyVoiceRoboticCallLogAddwxService {
+public class CompanyVoiceRoboticCallLogAddwxServiceImpl extends ServiceImpl<CompanyVoiceRoboticCallLogAddwxMapper, CompanyVoiceRoboticCallLogAddwx> implements ICompanyVoiceRoboticCallLogAddwxService {
 
 
     @Autowired
@@ -160,5 +159,19 @@ class CompanyVoiceRoboticCallLogAddwxServiceImpl extends ServiceImpl<CompanyVoic
     public Map<String, Long> countListAll(CompanyVoiceRoboticCallLogAddwx companyVoiceRoboticCallLogAddwx){
         return baseMapper.countListAll(companyVoiceRoboticCallLogAddwx);
     }
+    @Override
+    public List<CompanyVoiceRoboticCallLogAddwx> selectCompanyVoiceRoboticAddwxLogGroupList(CompanyVoiceRoboticCallLogAddwx companyVoiceRoboticCallLogAddwx) {
+        return baseMapper.selectCompanyVoiceRoboticCallLogAddwxGroupList(companyVoiceRoboticCallLogAddwx);
+    }
+
+    @Override
+    public CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticAddwxLogCount() {
+        return baseMapper.selectCompanyVoiceRoboticAddwxLogCount();
+    }
+
+    @Override
+    public List<CompanyVoiceRoboticCallLogAddwxVO> listByRoboticId(CompanyVoiceRoboticCallLogAddwx companyVoiceRoboticCallLogAddwx) {
+        return baseMapper.listByRoboticId(companyVoiceRoboticCallLogAddwx);
+    }
 
 }

+ 450 - 222
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallLogCallphoneServiceImpl.java

@@ -5,42 +5,46 @@ import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
-import com.fs.aicall.domain.TaskInfo;
-import com.fs.aicall.domain.apiresult.Notify;
 import com.fs.aicall.domain.apiresult.PushIIntentionResult;
-import com.fs.aicall.domain.param.getDialogMapDomain;
 import com.fs.aicall.service.AiCallService;
 import com.fs.common.core.domain.entity.SysDictData;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.spring.SpringUtils;
 import com.fs.company.domain.*;
-import com.fs.company.mapper.CompanyVoiceRoboticBusinessMapper;
-import com.fs.company.mapper.CompanyVoiceRoboticCallLogCallphoneMapper;
-import com.fs.company.mapper.CompanyVoiceRoboticCalleesMapper;
-import com.fs.company.mapper.CompanyWxAccountMapper;
+import com.fs.company.mapper.*;
 import com.fs.company.service.CompanyWorkflowEngine;
 import com.fs.company.service.ICompanyVoiceRoboticCallLogCallphoneService;
+import com.fs.company.service.ICompanyVoiceRoboticService;
 import com.fs.company.vo.*;
 import com.fs.company.vo.easycall.EasyCallCallPhoneVO;
-import com.fs.crm.service.ICrmCustomerAnalyzeService;
+import com.fs.core.config.TenantConfigContext;
 import com.fs.crm.service.ICrmCustomerPropertyService;
+import com.fs.his.utils.PhoneUtil;
 import com.fs.qw.domain.QwUser;
 import com.fs.qw.mapper.QwUserMapper;
+import com.fs.sensitive.DTO.AgentSensitiveWordDetectResultDTO;
+import com.fs.sensitive.component.AgentSensitiveWordDetector;
 import com.fs.system.service.ISysConfigService;
 import com.fs.system.service.impl.SysDictTypeServiceImpl;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.context.annotation.Lazy;
 import org.springframework.scheduling.annotation.Async;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.stereotype.Service;
 
+import java.lang.reflect.Method;
 import java.math.BigDecimal;
 import java.math.RoundingMode;
 import java.util.*;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.Executor;
 
+import static com.fs.company.service.impl.call.node.AbstractWorkflowNode.companyVoiceRoboticMapper;
 import static com.fs.company.service.impl.call.node.AiCallTaskNode.EASYCALL_WORKFLOW_REDIS_KEY;
 
 /**
@@ -74,13 +78,20 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
     @Autowired
     QwUserMapper qwUserMapper;
     @Autowired
-    private ICrmCustomerAnalyzeService crmCustomerAnalyzeService;
-    @Autowired
     @Qualifier("cidWorkFlowExecutor")
     private Executor cidWorkFlowExecutor;
 
     @Autowired
     SysDictTypeServiceImpl sysDictTypeService;
+    @Autowired
+    @Lazy
+    ICompanyVoiceRoboticService companyVoiceRoboticService;
+    @Autowired
+    CompanyAiWorkflowExecMapper companyAiWorkflowExecMapper;
+
+    @Autowired
+    private AgentSensitiveWordDetector agentSensitiveWordDetector;
+
     /**
      * 查询调用日志_ai打电话
      *
@@ -178,200 +189,309 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
 
     @Async("callLogExcutor")
     public void asyncHandleCalleeCallBackResult(PushIIntentionResult result, CompanyVoiceRoboticCallees callees) {
-        try {
-//            String runTaskFlow = callees.getRunTaskFlow();
-//            if(StringUtils.isBlank(runTaskFlow)){
-//                runTaskFlow = Constants.CELL_PHONE;
-//            }else{
-//                if (!runTaskFlow.contains(Constants.CELL_PHONE)) {
-//                    runTaskFlow = runTaskFlow + "," + Constants.CELL_PHONE;
-//                }
+//        try {
+////            String runTaskFlow = callees.getRunTaskFlow();
+////            if(StringUtils.isBlank(runTaskFlow)){
+////                runTaskFlow = Constants.CELL_PHONE;
+////            }else{
+////                if (!runTaskFlow.contains(Constants.CELL_PHONE)) {
+////                    runTaskFlow = runTaskFlow + "," + Constants.CELL_PHONE;
+////                }
+////            }
+////            if(!runTaskFlow.equals(callees.getRunTaskFlow())){
+////                CompanyVoiceRoboticCallees updateCallees = new CompanyVoiceRoboticCallees();
+////                updateCallees.setId(callees.getId());
+////                updateCallees.setRunTaskFlow(runTaskFlow);
+////                companyVoiceRoboticCalleesMapper.updateById(updateCallees);
+////            }
+//            String json = configService.selectConfigByKey("cid.config");
+//            if (StringUtils.isBlank(json)) {
+//                log.error("未配置cid.config");
 //            }
-//            if(!runTaskFlow.equals(callees.getRunTaskFlow())){
-//                CompanyVoiceRoboticCallees updateCallees = new CompanyVoiceRoboticCallees();
-//                updateCallees.setId(callees.getId());
-//                updateCallees.setRunTaskFlow(runTaskFlow);
-//                companyVoiceRoboticCalleesMapper.updateById(updateCallees);
+//            CidConfigVO cidConfigVO = JSONUtil.toBean(json, CidConfigVO.class);
+//
+//            Notify notify = result.getNotify();
+//            String uuid = notify.getUuid();
+//
+//
+//            if (StringUtils.isNotBlank(uuid)) {
+//                getDialogMapDomain getDialogMap = getDialogMapDomain.builder()
+//                        .uuid(uuid)
+//                        .build();
+//                CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLog = companyVoiceRoboticCallLogCallphoneMapper.selectNoResultLogByCallees(callees);
+//
+//                companyVoiceRoboticCallLog.setStatus(2);
+//                companyVoiceRoboticCallLog.setResult(JSON.toJSONString(result));
+//
+//                CompanyWxClient companyWxClient = companyWxClientServiceImpl.getOne(new QueryWrapper<CompanyWxClient>().eq("robotic_id", callees.getRoboticId()).eq("customer_id", callees.getUserId()));
+//                CompanyVoiceRoboticWx roboticWx = companyVoiceRoboticWxServiceImpl.getById(companyWxClient.getRoboticWxId());
+//                Long setCompanyUserId = null;
+//                if(Integer.valueOf(1).equals(companyWxClient.getIsWeCom())){
+//                    CompanyWxAccount companyWxAccount = companyWxAccountMapper.selectCompanyWxAccountById(roboticWx.getAccountId());
+//                    setCompanyUserId =  companyWxAccount.getCompanyUserId();
+//                }else if(Integer.valueOf(2).equals(companyWxClient.getIsWeCom())){
+//                    QwUser qwUser = qwUserMapper.selectById(roboticWx.getAccountId());
+//                    setCompanyUserId = qwUser.getCompanyUserId();
+//                }
+//
+//                companyVoiceRoboticCallLog.setCompanyUserId(setCompanyUserId);
+//                // 调用接口查询通话其他信息
+//                TaskInfo dialogMap = aiCallService.getDialogMapNew(getDialogMap, companyVoiceRoboticCallLog.getCompanyId());
+//                // 写入其他记录
+//                JSONObject telData = dialogMap.getTelData();
+//                companyVoiceRoboticCallLog.setRecordPath((String) telData.getOrDefault("recordPath", ""));
+//                companyVoiceRoboticCallLog.setContentList(telData.containsKey("contentList") ? telData.getJSONArray("contentList").toJSONString() : "");
+//                companyVoiceRoboticCallLog.setCallerNum((String) telData.getOrDefault("callerNum", ""));
+//                companyVoiceRoboticCallLog.setCalleeNum((String) telData.getOrDefault("calleeNum", ""));
+//                companyVoiceRoboticCallLog.setUuid((String) telData.getOrDefault("uuid", ""));
+//                Long createTime = telData.getLong("createTime");
+//                companyVoiceRoboticCallLog.setCallCreateTime(createTime);
+//                Long answerTime = telData.getLong("answerTime");
+//                companyVoiceRoboticCallLog.setCallAnswerTime(answerTime);
+//                companyVoiceRoboticCallLog.setIntention((String) telData.getOrDefault("intention", ""));
+//                companyVoiceRoboticCallLog.setCallTime(telData.getLong("duration"));
+//                BigDecimal callCharge = cidConfigVO.getCallCharge();
+//                //
+//                if (null == callCharge) {
+//                    callCharge = DEFAULT_CALL_CHARGE;
+//                }
+//                //向上取整分钟数
+//                BigDecimal divide = new BigDecimal(companyVoiceRoboticCallLog.getCallTime()).divide(ONE_MINUTES_SECOND, 0, RoundingMode.CEILING);
+//                BigDecimal multiply = divide.multiply(callCharge);
+//                companyVoiceRoboticCallLog.setCost(multiply);
+//                baseMapper.updateCompanyVoiceRoboticCallLogCallphone(companyVoiceRoboticCallLog);
+//
+//                if (StringUtils.isNotBlank(notify.getUserData())) {
+//                    JSONObject userData = JSONObject.parseObject(redisCache2.getCacheObject(WORKFLOW_CALL_ONE_REDIS_KEY + notify.getUserData()), JSONObject.class);
+//                    if (null != userData && userData.containsKey("callBackUuid") && userData.containsKey("workflowInstanceId") && userData.containsKey("nodeKey")) {
+//                        Map<String, Object> param = new HashMap<>();
+//                        param.put("callBackUuid", userData.getString("callBackUuid"));
+//                        param.put("callSource", "callBack");
+//                        CompletableFuture.runAsync(() -> {
+//                            companyWorkflowEngine.resumeFromBlockingNode(userData.getString("workflowInstanceId"), userData.getString("nodeKey"), param);
+//                        }, cidWorkFlowExecutor).thenRun(() -> {
+//                            redisCache2.deleteObject(WORKFLOW_CALL_ONE_REDIS_KEY + notify.getUserData());
+//                        });
+//                    }
+//                    redisCache2.deleteObject(notify.getUserData());
+//                }
 //            }
-            String json = configService.selectConfigByKey("cid.config");
-            if (StringUtils.isBlank(json)) {
-                log.error("未配置cid.config");
-            }
-            CidConfigVO cidConfigVO = JSONUtil.toBean(json, CidConfigVO.class);
-
-            Notify notify = result.getNotify();
-            String uuid = notify.getUuid();
-
-
-            if (StringUtils.isNotBlank(uuid)) {
-                getDialogMapDomain getDialogMap = getDialogMapDomain.builder()
-                        .uuid(uuid)
-                        .build();
-                CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLog = companyVoiceRoboticCallLogCallphoneMapper.selectNoResultLogByCallees(callees);
-
-                companyVoiceRoboticCallLog.setStatus(2);
-                companyVoiceRoboticCallLog.setResult(JSON.toJSONString(result));
-
-                CompanyWxClient companyWxClient = companyWxClientServiceImpl.getOne(new QueryWrapper<CompanyWxClient>().eq("robotic_id", callees.getRoboticId()).eq("customer_id", callees.getUserId()));
-                CompanyVoiceRoboticWx roboticWx = companyVoiceRoboticWxServiceImpl.getById(companyWxClient.getRoboticWxId());
-                Long setCompanyUserId = null;
-                if(Integer.valueOf(1).equals(companyWxClient.getIsWeCom())){
-                    CompanyWxAccount companyWxAccount = companyWxAccountMapper.selectCompanyWxAccountById(roboticWx.getAccountId());
-                    setCompanyUserId =  companyWxAccount.getCompanyUserId();
-                }else if(Integer.valueOf(2).equals(companyWxClient.getIsWeCom())){
-                    QwUser qwUser = qwUserMapper.selectById(roboticWx.getAccountId());
-                    setCompanyUserId = qwUser.getCompanyUserId();
-                }
-
-                companyVoiceRoboticCallLog.setCompanyUserId(setCompanyUserId);
-                // 调用接口查询通话其他信息
-                TaskInfo dialogMap = aiCallService.getDialogMapNew(getDialogMap, companyVoiceRoboticCallLog.getCompanyId());
-                // 写入其他记录
-                JSONObject telData = dialogMap.getTelData();
-                companyVoiceRoboticCallLog.setRecordPath((String) telData.getOrDefault("recordPath", ""));
-                companyVoiceRoboticCallLog.setContentList(telData.containsKey("contentList") ? telData.getJSONArray("contentList").toJSONString() : "");
-                companyVoiceRoboticCallLog.setCallerNum((String) telData.getOrDefault("callerNum", ""));
-                companyVoiceRoboticCallLog.setCalleeNum((String) telData.getOrDefault("calleeNum", ""));
-                companyVoiceRoboticCallLog.setUuid((String) telData.getOrDefault("uuid", ""));
-                Long createTime = telData.getLong("createTime");
-                companyVoiceRoboticCallLog.setCallCreateTime(createTime);
-                Long answerTime = telData.getLong("answerTime");
-                companyVoiceRoboticCallLog.setCallAnswerTime(answerTime);
-                companyVoiceRoboticCallLog.setIntention((String) telData.getOrDefault("intention", ""));
-                companyVoiceRoboticCallLog.setCallTime(telData.getLong("duration"));
-                BigDecimal callCharge = cidConfigVO.getCallCharge();
-                //
-                if (null == callCharge) {
-                    callCharge = DEFAULT_CALL_CHARGE;
-                }
-                //向上取整分钟数
-                BigDecimal divide = new BigDecimal(companyVoiceRoboticCallLog.getCallTime()).divide(ONE_MINUTES_SECOND, 0, RoundingMode.CEILING);
-                BigDecimal multiply = divide.multiply(callCharge);
-                companyVoiceRoboticCallLog.setCost(multiply);
-                baseMapper.updateCompanyVoiceRoboticCallLogCallphone(companyVoiceRoboticCallLog);
-
-                if (StringUtils.isNotBlank(notify.getUserData())) {
-                    JSONObject userData = parseRedisCacheToJsonObject(redisCache2.getCacheObject(WORKFLOW_CALL_ONE_REDIS_KEY + notify.getUserData()));
-                    if (null != userData && userData.containsKey("callBackUuid") && userData.containsKey("workflowInstanceId") && userData.containsKey("nodeKey")) {
-                        Map<String, Object> param = new HashMap<>();
-                        param.put("callBackUuid", userData.getString("callBackUuid"));
-                        param.put("callSource", "callBack");
-                        CompletableFuture.runAsync(() -> {
-                            companyWorkflowEngine.resumeFromBlockingNode(userData.getString("workflowInstanceId"), userData.getString("nodeKey"), param);
-                        }, cidWorkFlowExecutor).thenRun(() -> {
-                            redisCache2.deleteObject(WORKFLOW_CALL_ONE_REDIS_KEY + notify.getUserData());
-                        });
-                    }
-                    redisCache2.deleteObject(notify.getUserData());
-                }
-            }
-
-        } catch (Exception ex) {
-            log.error("处理回调结果异常:{}", result, ex);
-        }
+//
+//        } catch (Exception ex) {
+//            log.error("处理回调结果异常:{}", result, ex);
+//        }
     }
     @Async("callLogExcutor")
-    public void asyncHandleCalleeCallBackResult4EasyCall(EasyCallCallPhoneVO result, CompanyVoiceRoboticCallees callees) {
+    public void asyncHandleCalleeCallBackResult4EasyCall(EasyCallCallPhoneVO result, Long calleeId) {
         try {
-            String json = configService.selectConfigByKey("cid.config");
-            if (StringUtils.isBlank(json)) {
-                log.error("未配置cid.config");
-            }
-            CidConfigVO cidConfigVO = JSONUtil.toBean(json, CidConfigVO.class);
-            if (null != result) {
+
+            CompanyVoiceRoboticCallees callees = companyVoiceRoboticCalleesMapper.selectCompanyVoiceRoboticCalleesById(calleeId);
+            try {
+                String json = configService.selectConfigByKey("cId.config");
+                if (StringUtils.isBlank(json)) {
+                    log.error("未配置cid.config");
+                }
+                CidConfigVO cidConfigVO = JSONUtil.toBean(json, CidConfigVO.class);
+                if (null != result) {
 //                getDialogMapDomain getDialogMap = getDialogMapDomain.builder()
 //                        .uuid(uuid)
 //                        .build();
-                CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLog = companyVoiceRoboticCallLogCallphoneMapper.selectNoResultLogByCallees(callees);
-
-                companyVoiceRoboticCallLog.setStatus(2);
-                companyVoiceRoboticCallLog.setResult(JSON.toJSONString(result));
-
-                CompanyWxClient companyWxClient = companyWxClientServiceImpl.getOne(new QueryWrapper<CompanyWxClient>().eq("robotic_id", callees.getRoboticId()).eq("customer_id", callees.getUserId()));
-                CompanyVoiceRoboticWx roboticWx = companyVoiceRoboticWxServiceImpl.getById(companyWxClient.getRoboticWxId());
-                Long setCompanyUserId = null;
-                if(Integer.valueOf(1).equals(companyWxClient.getIsWeCom())){
-                    CompanyWxAccount companyWxAccount = companyWxAccountMapper.selectCompanyWxAccountById(roboticWx.getAccountId());
-                    setCompanyUserId =  companyWxAccount.getCompanyUserId();
-                }else if(Integer.valueOf(2).equals(companyWxClient.getIsWeCom())){
-                    QwUser qwUser = qwUserMapper.selectById(roboticWx.getAccountId());
-                    setCompanyUserId = qwUser.getCompanyUserId();
-                }
 
-                companyVoiceRoboticCallLog.setCompanyUserId(setCompanyUserId);
-                // 调用接口查询通话其他信息
+                    CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLog = companyVoiceRoboticCallLogCallphoneMapper.selectNoResultLogByCallees(callees);
+
+                    companyVoiceRoboticCallLog.setStatus(2);
+                    companyVoiceRoboticCallLog.setResult(JSON.toJSONString(result));
+                    companyVoiceRoboticCallLog.setHangupType(result.getUnconnectedReason());
+                    CompanyWxClient companyWxClient = companyWxClientServiceImpl.getOne(new QueryWrapper<CompanyWxClient>().eq("robotic_id", callees.getRoboticId()).eq("customer_id", callees.getUserId()));
+                    CompanyVoiceRoboticWx roboticWx = companyVoiceRoboticWxServiceImpl.getById(companyWxClient.getRoboticWxId());
+                    Long setCompanyUserId = null;
+                    if(null != roboticWx){
+                        if(Integer.valueOf(1).equals(companyWxClient.getIsWeCom())){
+                            CompanyWxAccount companyWxAccount = companyWxAccountMapper.selectCompanyWxAccountById(roboticWx.getAccountId());
+                            setCompanyUserId =  companyWxAccount.getCompanyUserId();
+                        }else if(Integer.valueOf(2).equals(companyWxClient.getIsWeCom())){
+                            QwUser qwUser = qwUserMapper.selectById(roboticWx.getAccountId());
+                            setCompanyUserId = qwUser.getCompanyUserId();
+                        }
+                    }else{
+                        CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectCompanyVoiceRoboticById(callees.getRoboticId());
+                        setCompanyUserId = robotic.getCompanyUserId();
+                    }
+
+
+                    companyVoiceRoboticCallLog.setCompanyUserId(setCompanyUserId);
+                    // 调用接口查询通话其他信息
 //                TaskInfo dialogMap = aiCallService.getDialogMapNew(getDialogMap, companyVoiceRoboticCallLog.getCompanyId());
-                // 写入其他记录
+                    // 写入其他记录
 //                JSONObject telData = dialogMap.getTelData();
-                companyVoiceRoboticCallLog.setRecordPath(result.getWavfile());
-                companyVoiceRoboticCallLog.setContentList(result.getDialogue());
-                companyVoiceRoboticCallLog.setCallerNum(result.getTelephone());
-                companyVoiceRoboticCallLog.setCalleeNum(result.getCallerNumber());
-                companyVoiceRoboticCallLog.setUuid(result.getUuid());
-                Long createTime = result.getCalloutTime();
-                companyVoiceRoboticCallLog.setCallCreateTime(createTime);
-                Long answerTime = result.getCallEndTime();
-                companyVoiceRoboticCallLog.setCallAnswerTime(answerTime);
-                String intention = null;
-                if (StringUtils.isNotBlank(result.getDialogue())) {
-                    try {
-                        intention = crmCustomerAnalyzeService.aiIntentionDegree(
-                                result.getDialogue(),
-                                java.time.LocalTime.now().getLong(java.time.temporal.ChronoField.MILLI_OF_SECOND)
-                        );
-                    } catch (Exception e) {
-                        log.error("easyCall回调日志意向度AI解析失败,uuid={}", result.getUuid(), e);
+                    companyVoiceRoboticCallLog.setRecordPath(result.getWavfile());
+                    companyVoiceRoboticCallLog.setCallType(2);
+                    companyVoiceRoboticCallLog.setContentList(result.getDialogue());
+                    companyVoiceRoboticCallLog.setCallerNum(PhoneUtil.encryptPhone(result.getTelephone()));
+                    companyVoiceRoboticCallLog.setCalleeNum(result.getCallerNumber());
+                    companyVoiceRoboticCallLog.setUuid(result.getUuid());
+                    Long createTime = result.getCalloutTime();
+                    companyVoiceRoboticCallLog.setCallCreateTime(createTime);
+                    Long answerTime = result.getCallEndTime();
+                    companyVoiceRoboticCallLog.setCallAnswerTime(answerTime);
+                    // 【当前启用】读取 cc_call_phone.intent,再转换为系统字典 intention 数值
+                    String intentRaw = StringUtils.isNotBlank(result.getIntent()) ? result.getIntent().trim() : null;
+                    String intentf = null;
+                    final String intentionLabel = intentRaw;
+                    List<SysDictData> customerIntentionLevel = sysDictTypeService.selectDictDataByType("customer_intention_level");
+                    if (!isPositiveInteger(intentionLabel)) {
+                        Optional<SysDictData> firstDict = customerIntentionLevel.stream().filter(e -> e.getDictLabel().equals(intentionLabel)).findFirst();
+                        if (firstDict.isPresent()) {
+                            SysDictData sysDictData = firstDict.get();
+                            intentf = sysDictData.getDictValue();
+                        }
+                    } else {
+                        intentf = intentionLabel;
                     }
-                }
-                // 历史第三方值(当前不启用,保留用于回滚)
-                // String intention = result.getIntent();
-                String intentf = null;
-                final String intentionLabel = intention;
-                List<SysDictData> customerIntentionLevel = sysDictTypeService.selectDictDataByType("customer_intention_level");
-                if (!isPositiveInteger(intentionLabel)) {
-                    Optional<SysDictData> firstDict = customerIntentionLevel.stream().filter(e -> e.getDictLabel().equals(intentionLabel)).findFirst();
-                    if (firstDict.isPresent()) {
-                        SysDictData sysDictData = firstDict.get();
-                        intentf = sysDictData.getDictValue();
+                    if (StringUtils.isBlank(intentf)) {
+                        intentf = "0";
                     }
-                } else {
-                    intentf = intentionLabel;
-                }
-                if (StringUtils.isBlank(intentf)) intentf = "0";
-                companyVoiceRoboticCallLog.setIntention(intentf);
-                companyVoiceRoboticCallLog.setCallTime(Long.valueOf(result.getTimeLen()/1000));
-                BigDecimal callCharge = cidConfigVO.getCallCharge();
-                //
-                if (null == callCharge) {
-                    callCharge = DEFAULT_CALL_CHARGE;
-                }
-                //向上取整分钟数
-                BigDecimal divide = new BigDecimal(companyVoiceRoboticCallLog.getCallTime()).divide(ONE_MINUTES_SECOND, 0, RoundingMode.CEILING);
-                BigDecimal multiply = divide.multiply(callCharge);
-                companyVoiceRoboticCallLog.setCost(multiply);
-                baseMapper.updateCompanyVoiceRoboticCallLogCallphone(companyVoiceRoboticCallLog);
-                // 更新用户标签
-                crmCustomerPropertyService.addPropertyByCallLog(companyVoiceRoboticCallLog);
-
-                if (StringUtils.isNotBlank(result.getBizJson())) {
-                    JSONObject bizJson = JSONObject.parseObject(result.getBizJson());
-                    JSONObject userData = parseRedisCacheToJsonObject(redisCache2.getCacheObject(EASYCALL_WORKFLOW_REDIS_KEY + bizJson.getString("callBackUuid")));
-                    if (null != userData && userData.containsKey("callBackUuid") && userData.containsKey("workflowInstanceId") && userData.containsKey("nodeKey")) {
-                        Map<String, Object> param = new HashMap<>();
-                        param.put("callBackUuid", userData.getString("callBackUuid"));
-                        param.put("callSource", "callBack");
-                        CompletableFuture.runAsync(() -> {
-                            companyWorkflowEngine.resumeFromBlockingNode(userData.getString("workflowInstanceId"), userData.getString("nodeKey"), param);
-                        }, cidWorkFlowExecutor).thenRun(() -> {
-                            redisCache2.deleteObject(EASYCALL_WORKFLOW_REDIS_KEY +  bizJson.getString("callBackUuid"));
-                        });
+                    companyVoiceRoboticCallLog.setIntention(intentf);
+
+                    // ========== 【历史保留】回滚时可注释上方 EasyCall 逻辑并取消下方对应注释 ==========
+                    // 方案A:仅使用 EasyCall intent 字段(改动前的本文件写法)
+//                    String intention = result.getIntent();
+//                    String intentf = null;
+//                    List<SysDictData> customerIntentionLevel = sysDictTypeService.selectDictDataByType("customer_intention_level");
+//                    if (!isPositiveInteger(intention)) {
+//                        Optional<SysDictData> firstDict = customerIntentionLevel.stream().filter(e -> e.getDictLabel().equals(intention)).findFirst();
+//                        if (firstDict.isPresent()) {
+//                            SysDictData sysDictData = firstDict.get();
+//                            intentf = sysDictData.getDictValue();
+//                        }
+//                    }
+//                    companyVoiceRoboticCallLog.setIntention(intentf);
+
+                    // 方案B:自家 AI 根据 dialogue 计算意向度
+//                    String intention = null;
+//                    if (StringUtils.isNotBlank(result.getDialogue())) {
+//                        try {
+//                            intention = crmCustomerAnalyzeService.aiIntentionDegree(
+//                                    result.getDialogue(),
+//                                    java.time.LocalTime.now().getLong(java.time.temporal.ChronoField.MILLI_OF_SECOND)
+//                            );
+//                        } catch (Exception e) {
+//                            log.error("easyCall回调日志意向度AI解析失败,uuid={}", result.getUuid(), e);
+//                        }
+//                    }
+//                    String intentf = null;
+//                    final String intentionLabel = intention;
+//                    List<SysDictData> customerIntentionLevel = sysDictTypeService.selectDictDataByType("customer_intention_level");
+//                    if (!isPositiveInteger(intentionLabel)) {
+//                        Optional<SysDictData> firstDict = customerIntentionLevel.stream().filter(e -> e.getDictLabel().equals(intentionLabel)).findFirst();
+//                        if (firstDict.isPresent()) {
+//                            SysDictData sysDictData = firstDict.get();
+//                            intentf = sysDictData.getDictValue();
+//                        }
+//                    } else {
+//                        intentf = intentionLabel;
+//                    }
+//                    if (StringUtils.isBlank(intentf)) {
+//                        intentf = "0";
+//                    }
+//                    companyVoiceRoboticCallLog.setIntention(intentf);
+                    if(null != result.getValidTimeLen() && Integer.valueOf(0).compareTo(result.getValidTimeLen()) < 0){
+                        BigDecimal divide = new BigDecimal(result.getValidTimeLen()).divide(new BigDecimal(1000), 0, RoundingMode.CEILING);
+                        companyVoiceRoboticCallLog.setCallTime(divide.longValue());
+                    }
+                    BigDecimal callCharge = cidConfigVO.getCallCharge();
+                    //
+                    if (null == callCharge) {
+                        callCharge = DEFAULT_CALL_CHARGE;
+                    }
+                    //检测空避免报错
+                    if(companyVoiceRoboticCallLog.getCallTime()!=null){
+                        //向上取整分钟数
+                        BigDecimal divide = new BigDecimal(companyVoiceRoboticCallLog.getCallTime()).divide(ONE_MINUTES_SECOND, 0, RoundingMode.CEILING);
+                        BigDecimal multiply = divide.multiply(callCharge);
+                        companyVoiceRoboticCallLog.setCost(multiply);
+                    }
+
+                    //检测敏感词语
+                    AgentSensitiveWordDetectResultDTO resultDTO = agentSensitiveWordDetector.detectAndHighlight(result.getUuid(), result.getDialogue());
+                    if(resultDTO != null && resultDTO.getCheckBoolean()){
+                        companyVoiceRoboticCallLog.setIsWarning(1);
+                        companyVoiceRoboticCallLog.setContentList(resultDTO.getProcessText());
+                        companyVoiceRoboticCallLog.setViolationNum(resultDTO.getViolationCount());
+                    }
+
+                    //AI外呼 转人工逻辑
+                    if(StringUtils.isNotBlank(result.getAcdOpnum()) && !"robot".equalsIgnoreCase(result.getAcdOpnum())){
+                        companyVoiceRoboticCallLog.setManualAnswered(1);
+                        companyVoiceRoboticCallLog.setHandleFlag(0);
+                        companyVoiceRoboticCallLog.setAnsweredExtNum(result.getAcdOpnum());
+                        companyVoiceRoboticCallLog.setManualAnsweredTime(result.getManualAnsweredTime());
+                        companyVoiceRoboticCallLog.setManualAnsweredTimeLen(result.getManualAnsweredTimeLen());
+                    }
+
+                    baseMapper.updateCompanyVoiceRoboticCallLogCallphone(companyVoiceRoboticCallLog);
+                    // 更新用户标签
+                    crmCustomerPropertyService.addPropertyByCallLog(companyVoiceRoboticCallLog);
+
+                    if (StringUtils.isNotBlank(result.getBizJson())) {
+                        JSONObject bizJson = JSONObject.parseObject(result.getBizJson());
+                        JSONObject userData = JSONObject.parseObject(redisCache2.getCacheObject(EASYCALL_WORKFLOW_REDIS_KEY +  bizJson.getString("callBackUuid")), JSONObject.class);
+                        if (null != userData && userData.containsKey("callBackUuid") && userData.containsKey("workflowInstanceId") && userData.containsKey("nodeKey")) {
+                            // 暂停检查:任务暂停中则标记回调已到达但不触发工作流继续执行
+                            if (companyVoiceRoboticService.isTaskPaused(callees.getRoboticId())) {
+                                log.info("任务暂停中,EasyCall外呼回调数据已保存但不触发工作流继续执行 - taskId: {}, workflowInstanceId: {}",
+                                        callees.getRoboticId(), userData.getString("workflowInstanceId"));
+                                markCallbackReceivedInVariables(userData.getString("workflowInstanceId"));
+                            } else {
+                                Map<String, Object> param = new HashMap<>();
+                                param.put("callBackUuid", userData.getString("callBackUuid"));
+                                param.put("callSource", "callBack");
+                                CompletableFuture.runAsync(() -> {
+                                    companyWorkflowEngine.resumeFromBlockingNode(userData.getString("workflowInstanceId"), userData.getString("nodeKey"), param);
+                                }, cidWorkFlowExecutor).thenRun(() -> {
+                                    redisCache2.deleteObject(EASYCALL_WORKFLOW_REDIS_KEY +  bizJson.getString("callBackUuid"));
+                                });
+                            }
+                        }
+                        redisCache2.deleteObject(bizJson.getString("callBackUuid"));
                     }
-                    redisCache2.deleteObject(bizJson.getString("callBackUuid"));
                 }
+
+            } catch (Exception ex) {
+                log.error("处理回调结果异常:{}", result, ex);
             }
 
-        } catch (Exception ex) {
-            log.error("处理回调结果异常:{}", result, ex);
+        } catch (Exception e) {
+            log.error("asyncHandleCalleeCallBackResult4EasyCall执行失败", e);
+        }
+
+    }
+
+    /**
+     * 标记工作流实例的variables中回调已到达(任务暂停时使用,恢复时会检查此标记)
+     */
+    private void markCallbackReceivedInVariables(String workflowInstanceId) {
+        try {
+            CompanyAiWorkflowExec exec = companyAiWorkflowExecMapper.selectByWorkflowInstanceId(workflowInstanceId);
+            if (exec == null) {
+                log.warn("markCallbackReceived: 未找到工作流实例 - workflowInstanceId: {}", workflowInstanceId);
+                return;
+            }
+            String variablesJson = exec.getVariables();
+            JSONObject variables;
+            if (StringUtils.isNotBlank(variablesJson)) {
+                variables = JSONObject.parseObject(variablesJson);
+            } else {
+                variables = new JSONObject();
+            }
+            variables.put("pause_callback_received", true);
+            CompanyAiWorkflowExec update = new CompanyAiWorkflowExec();
+            update.setWorkflowInstanceId(workflowInstanceId);
+            update.setVariables(variables.toJSONString());
+            companyAiWorkflowExecMapper.updateByWorkflowInstanceId(update);
+            log.info("markCallbackReceived: 已标记回调到达 - workflowInstanceId: {}", workflowInstanceId);
+        } catch (Exception e) {
+            log.error("markCallbackReceived: 标记回调到达失败 - workflowInstanceId: {}", workflowInstanceId, e);
         }
     }
 
@@ -396,58 +516,119 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
     }
 
     @Override
-    public List<CustomerRoboticCallOutCountVO> countRoboticCallOutByCustomerIds(List<Long> customerIds, Long companyId) {
-        if (customerIds == null || customerIds.isEmpty()) {
-            return Collections.emptyList();
-        }
-        return baseMapper.countRoboticCallOutByCustomerIds(customerIds, companyId);
+    public List<Long> getCallerIdsByCustomerId(Long customerId) {
+        return companyVoiceRoboticCalleesMapper.getCallerIdsByCustomerId(customerId);
     }
 
     @Override
-    public List<CalleeRoboticCallOutCountVO> countRoboticCallOutByCalleeIds(List<Long> calleeIds, Long roboticId, Long companyId) {
-        if (calleeIds == null || calleeIds.isEmpty() || roboticId == null) {
-            return Collections.emptyList();
-        }
-        return baseMapper.countRoboticCallOutByCalleeIds(calleeIds, roboticId, companyId);
+    public List<CompanyVoiceRoboticCallLogCallphone> selectCompanyVoiceRoboticCallPhoneLogGroupList(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone) {
+        return baseMapper.selectCompanyVoiceRoboticCallPhoneLogGroupList(companyVoiceRoboticCallLogCallphone);
     }
-    /**
-     * Redis 中 workflow 回调缓存可能是 String 或已反序列化的 JSONObject,避免 ClassCastException。
-     */
-    private static JSONObject parseRedisCacheToJsonObject(Object cacheObj) {
-        if (cacheObj == null) {
-            return null;
-        }
-        if (cacheObj instanceof JSONObject) {
-            return (JSONObject) cacheObj;
+
+    @Override
+    public CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallPhoneLogCount(Long companyId,Long companyUserId) {
+        return baseMapper.selectCompanyVoiceRoboticCallPhoneLogCount(companyId,companyUserId);
+    }
+
+    @Override
+    public List<CompanyVoiceRoboticCallLogCallPhoneVO> listByRoboticId(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone) {
+        return baseMapper.listByRoboticId(companyVoiceRoboticCallLogCallphone);
+    }
+
+
+
+    @Override
+    public void prepareDetailListQuery(CompanyVoiceRoboticCallLogCallphone query, Long companyId) {
+        query.setCompanyId(companyId);
+        prepareDetailPhoneQuery(query);
+    }
+
+    @Override
+    public List<CompanyVoiceRoboticCallLogCallPhoneVO> selectDetailList(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone) {
+        List<CompanyVoiceRoboticCallLogCallPhoneVO> list = baseMapper.selectDetailList(companyVoiceRoboticCallLogCallphone);
+        if (list == null || list.isEmpty()) {
+            return list;
         }
-        if (cacheObj instanceof String) {
-            String s = (String) cacheObj;
-            if (StringUtils.isBlank(s)) {
-                return null;
+        for (CompanyVoiceRoboticCallLogCallPhoneVO vo : list) {
+            if (StringUtils.isNotEmpty(vo.getCallerNum())) {
+                vo.setCallerNum(PhoneUtil.decryptPhoneMk(vo.getCallerNum()));
             }
-            return JSONObject.parseObject(s);
         }
-        return JSONObject.parseObject(JSONObject.toJSONString(cacheObj));
+        return list;
     }
 
     @Override
-    public List<Long> getCallerIdsByCustomerId(Long customerId) {
-        return companyVoiceRoboticCalleesMapper.getCallerIdsByCustomerId(customerId);
+    public CompanyVoiceRoboticCallLogDetailSummary selectDetailSummary(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone) {
+        CompanyVoiceRoboticCallLogDetailSummary summary = baseMapper.selectDetailSummary(companyVoiceRoboticCallLogCallphone);
+        if (summary == null) {
+            summary = new CompanyVoiceRoboticCallLogDetailSummary();
+            summary.setTotalCount(0L);
+            summary.setSuccessCount(0L);
+            summary.setFailCount(0L);
+            summary.setConnectedCount(0L);
+            summary.setTotalBillingMinute(0L);
+        }
+        if (summary.getTotalBillingMinute() == null) {
+            summary.setTotalBillingMinute(0L);
+        }
+        long total = summary.getTotalCount() == null ? 0L : summary.getTotalCount();
+        long connected = summary.getConnectedCount() == null ? 0L : summary.getConnectedCount();
+        summary.setConnectRate(total > 0 ? (int) Math.round(connected * 100.0 / total) : 0);
+        return summary;
     }
 
     @Override
-    public List<CompanyVoiceRoboticCallLogCallphone> selectCompanyVoiceRoboticCallPhoneLogGroupList(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone) {
-        return baseMapper.selectCompanyVoiceRoboticCallPhoneLogGroupList(companyVoiceRoboticCallLogCallphone);
+    public List<CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO> listDecryptPhoneExport(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone) {
+        prepareDetailPhoneQuery(companyVoiceRoboticCallLogCallphone);
+        List<CompanyVoiceRoboticCallLogCallPhoneDecryptQueryVO> rows = baseMapper.listDecryptPhoneExport(companyVoiceRoboticCallLogCallphone);
+        List<CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO> exportList = new ArrayList<>();
+        if (rows == null || rows.isEmpty()) {
+            return exportList;
+        }
+        Map<String, String> intentionLabelMap = buildIntentionLabelMap();
+        for (CompanyVoiceRoboticCallLogCallPhoneDecryptQueryVO row : rows) {
+            CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO vo = new CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO();
+            vo.setRoboticName(row.getRoboticName());
+            vo.setUserName(row.getUserName());
+            vo.setIntention(resolveIntentionLabel(row.getIntention(), intentionLabelMap));
+            vo.setPhone(PhoneUtil.decryptPhone(row.getPhone()));
+            exportList.add(vo);
+        }
+        return exportList;
     }
 
-    @Override
-    public CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallPhoneLogCount() {
-        return baseMapper.selectCompanyVoiceRoboticCallPhoneLogCount();
+    private Map<String, String> buildIntentionLabelMap() {
+        Map<String, String> map = new HashMap<>();
+        List<SysDictData> dictList = sysDictTypeService.selectDictDataByType("customer_intention_level");
+        if (dictList != null) {
+            for (SysDictData dict : dictList) {
+                map.put(dict.getDictValue(), dict.getDictLabel());
+            }
+        }
+        return map;
     }
 
-    @Override
-    public List<CompanyVoiceRoboticCallLogCallPhoneVO> listByRoboticId(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone) {
-        return baseMapper.listByRoboticId(companyVoiceRoboticCallLogCallphone);
+    private String resolveIntentionLabel(String intention, Map<String, String> intentionLabelMap) {
+        if (StringUtils.isEmpty(intention)) {
+            return "";
+        }
+        String label = intentionLabelMap.get(intention);
+        if (StringUtils.isNotEmpty(label)) {
+            return label;
+        }
+        return intention;
+    }
+
+    private void prepareDetailPhoneQuery(CompanyVoiceRoboticCallLogCallphone query) {
+        if (StringUtils.isNotEmpty(query.getPhone())) {
+            query.setEncryptedPhone(PhoneUtil.encryptPhone(query.getPhone()));
+        }
+        if (StringUtils.isNotEmpty(query.getIntention()) && "0".equals(query.getIntention())) {
+            query.setIntentionEmpty(true);
+            query.setIntention(null);
+        } else {
+            query.setIntentionEmpty(null);
+        }
     }
     /**
      * 判断整数
@@ -458,4 +639,51 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
     public boolean isPositiveInteger(String str) {
         return str != null && str.matches("[1-9]\\d*");
     }
+
+    @Override
+    public List<CompanyVoiceRoboticCallLogCallphone> selectManualAnsweredList(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone) {
+        return baseMapper.selectManualAnsweredList(companyVoiceRoboticCallLogCallphone);
+    }
+
+    @Override
+    public int markHandleFlag(Long logId) {
+        CompanyVoiceRoboticCallLogCallphone updateObj = new CompanyVoiceRoboticCallLogCallphone();
+        updateObj.setLogId(logId);
+        updateObj.setHandleFlag(1);
+        return baseMapper.updateCompanyVoiceRoboticCallLogCallphone(updateObj);
+    }
+
+    @Override
+    public String getDecryptPhoneByLogId(Long logId) {
+        CompanyVoiceRoboticCallLogCallphone log = selectCompanyVoiceRoboticCallLogCallphoneByLogId(logId);
+        if (log == null) {
+            return null;
+        }
+        if (log.getCallerId() != null) {
+            CompanyVoiceRoboticCallees callees = companyVoiceRoboticCalleesMapper.selectCompanyVoiceRoboticCalleesById(log.getCallerId());
+            if (callees != null && StringUtils.isNotEmpty(callees.getPhone())) {
+                return PhoneUtil.decryptPhone(callees.getPhone());
+            }
+        }
+        if (StringUtils.isNotEmpty(log.getCallerNum())) {
+            return PhoneUtil.decryptPhone(log.getCallerNum());
+        }
+        return null;
+    }
+
+    @Override
+    public List<CalleeRoboticCallOutCountVO> countRoboticCallOutByCalleeIds(List<Long> calleeIds, Long roboticId, Long companyId) {
+        if (calleeIds == null || calleeIds.isEmpty() || roboticId == null) {
+            return Collections.emptyList();
+        }
+        return baseMapper.countRoboticCallOutByCalleeIds(calleeIds, roboticId, companyId);
+    }
+
+    @Override
+    public List<CustomerRoboticCallOutCountVO> countRoboticCallOutByCustomerIds(List<Long> customerIds, Long companyId) {
+        if (customerIds == null || customerIds.isEmpty()) {
+            return Collections.emptyList();
+        }
+        return baseMapper.countRoboticCallOutByCustomerIds(customerIds, companyId);
+    }
 }

+ 9 - 9
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallLogSendmsgServiceImpl.java

@@ -17,7 +17,7 @@ import java.util.List;
 
 /**
  * 调用日志_发送短信Service业务层处理
- * 
+ *
  * @author fs
  * @date 2026-01-15
  */
@@ -29,7 +29,7 @@ public class CompanyVoiceRoboticCallLogSendmsgServiceImpl extends ServiceImpl<Co
     CompanyVoiceRoboticBusinessMapper companyVoiceRoboticBusinessMapper;
     /**
      * 查询调用日志_发送短信
-     * 
+     *
      * @param logId 调用日志_发送短信主键
      * @return 调用日志_发送短信
      */
@@ -46,7 +46,7 @@ public class CompanyVoiceRoboticCallLogSendmsgServiceImpl extends ServiceImpl<Co
 
     /**
      * 查询调用日志_发送短信列表
-     * 
+     *
      * @param companyVoiceRoboticCallLogSendmsg 调用日志_发送短信
      * @return 调用日志_发送短信
      */
@@ -58,7 +58,7 @@ public class CompanyVoiceRoboticCallLogSendmsgServiceImpl extends ServiceImpl<Co
 
     /**
      * 新增调用日志_发送短信
-     * 
+     *
      * @param companyVoiceRoboticCallLogSendmsg 调用日志_发送短信
      * @return 结果
      */
@@ -71,7 +71,7 @@ public class CompanyVoiceRoboticCallLogSendmsgServiceImpl extends ServiceImpl<Co
 
     /**
      * 修改调用日志_发送短信
-     * 
+     *
      * @param companyVoiceRoboticCallLogSendmsg 调用日志_发送短信
      * @return 结果
      */
@@ -83,7 +83,7 @@ public class CompanyVoiceRoboticCallLogSendmsgServiceImpl extends ServiceImpl<Co
 
     /**
      * 批量删除调用日志_发送短信
-     * 
+     *
      * @param logIds 需要删除的调用日志_发送短信主键
      * @return 结果
      */
@@ -95,7 +95,7 @@ public class CompanyVoiceRoboticCallLogSendmsgServiceImpl extends ServiceImpl<Co
 
     /**
      * 删除调用日志_发送短信信息
-     * 
+     *
      * @param logId 调用日志_发送短信主键
      * @return 结果
      */
@@ -126,7 +126,7 @@ public class CompanyVoiceRoboticCallLogSendmsgServiceImpl extends ServiceImpl<Co
     }
 
     @Override
-    public CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallLogSendMsgCount() {
-        return baseMapper.selectCompanyVoiceRoboticCallLogSendMsgCount();
+    public CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallLogSendMsgCount(Long companyId,Long companyUserId) {
+        return baseMapper.selectCompanyVoiceRoboticCallLogSendMsgCount( companyId, companyUserId);
     }
 }

+ 7 - 1
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCalleesServiceImpl.java

@@ -97,6 +97,12 @@ public class CompanyVoiceRoboticCalleesServiceImpl extends ServiceImpl<CompanyVo
     public List<CompanyVoiceRoboticCallees> selectCompanyVoiceRoboticCalleesListByRoboticId(Long id) {
         CompanyVoiceRoboticCallees param = new CompanyVoiceRoboticCallees();
         param.setRoboticId(id);
-        return selectCompanyVoiceRoboticCalleesList(param);
+        List<CompanyVoiceRoboticCallees> companyVoiceRoboticCallees = selectCompanyVoiceRoboticCalleesList(param);
+        if(null != companyVoiceRoboticCallees && !companyVoiceRoboticCallees.isEmpty()){
+            companyVoiceRoboticCallees.forEach(item -> {
+                item.setIdToString(item.getId().toString());
+            });
+        }
+        return companyVoiceRoboticCallees;
     }
 }

文件差異過大導致無法顯示
+ 981 - 324
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java


部分文件因文件數量過多而無法顯示