Explorar o código

cid代码迁移

lmx hai 1 semana
pai
achega
1ad3cc693b
Modificáronse 100 ficheiros con 6331 adicións e 166 borrados
  1. 43 0
      fs-admin-saas/src/main/java/com/fs/his/controller/FsCompanyController.java
  2. 120 0
      fs-admin-saas/src/main/java/com/fs/sensitive/controller/CompanyAiSensitiveWordController.java
  3. 366 0
      fs-admin/src/main/java/com/fs/admin/controller/aicall/controller/CcLlmAgentAccountController.java
  4. 77 0
      fs-admin/src/main/java/com/fs/admin/controller/aicall/controller/CcLlmAgentProviderController.java
  5. 90 0
      fs-admin/src/main/java/com/fs/admin/controller/aicall/controller/CcLlmKbCatController.java
  6. 91 0
      fs-admin/src/main/java/com/fs/admin/controller/company/controller/CompanyVoiceCloneController.java
  7. 120 0
      fs-admin/src/main/java/com/fs/admin/controller/sensitive/controller/CompanyAiSensitiveWordController.java
  8. 135 3
      fs-ai-call-task/src/main/java/com/fs/app/service/CallTaskService.java
  9. 20 1
      fs-ai-call-task/src/main/java/com/fs/app/task/Task.java
  10. 63 8
      fs-cid-workflow/src/main/java/com/fs/app/service/CidWorkflowTaskService.java
  11. 114 0
      fs-cid-workflow/src/main/java/com/fs/app/service/OutboundRetryTaskService.java
  12. 19 0
      fs-cid-workflow/src/main/java/com/fs/app/task/CidTask.java
  13. 14 0
      fs-common/src/main/java/com/fs/common/core/redis/RedisCache.java
  14. 4 1
      fs-company-app/src/main/java/com/fs/app/controller/UserController.java
  15. 400 0
      fs-company-app/src/main/java/com/fs/app/controller/aiSipCall/AiSipCallController.java
  16. 52 0
      fs-company-app/src/main/java/com/fs/app/controller/crm/CrmAPPMsgController.java
  17. 135 2
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallOutboundCdrController.java
  18. 79 0
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallUserController.java
  19. 276 0
      fs-company/src/main/java/com/fs/company/controller/common/RecordingProxyController.java
  20. 115 0
      fs-company/src/main/java/com/fs/company/controller/company/AiOutboundCallDashboardController.java
  21. 85 0
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceCloneRefController.java
  22. 66 4
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticCallLogCallphoneController.java
  23. 60 6
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticController.java
  24. 5 0
      fs-company/src/main/java/com/fs/company/controller/company/CompanyWorkflowController.java
  25. 106 15
      fs-company/src/main/java/com/fs/company/controller/company/CompanyWxAccountController.java
  26. 19 3
      fs-company/src/main/java/com/fs/company/controller/company/EasyCallController.java
  27. 53 3
      fs-company/src/main/java/com/fs/company/controller/crm/CrmCustomerController.java
  28. 65 0
      fs-company/src/main/java/com/fs/company/controller/crm/CustomerAllController.java
  29. 111 0
      fs-company/src/main/java/com/fs/company/controller/crm/ManualOutboundCallLogController.java
  30. 117 0
      fs-company/src/main/java/com/fs/company/controller/sensitive/CompanyAiSensitiveWordController.java
  31. 7 0
      fs-service/pom.xml
  32. 21 0
      fs-service/src/main/java/com/fs/aiSipCall/RemoteCommon.java
  33. 38 0
      fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipCallOutboundCdr.java
  34. 14 1
      fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipCallUser.java
  35. 4 0
      fs-service/src/main/java/com/fs/aiSipCall/param/ApiCallRecordByUuidQueryParams.java
  36. 16 1
      fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallOutboundCdrService.java
  37. 36 0
      fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallUserService.java
  38. 163 9
      fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallOutboundCdrServiceImpl.java
  39. 169 0
      fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallUserServiceImpl.java
  40. 18 0
      fs-service/src/main/java/com/fs/aiSipCall/vo/AiSipCallUserNewVO.java
  41. 8 0
      fs-service/src/main/java/com/fs/aicall/domain/CcLlmAgentAccount.java
  42. 46 0
      fs-service/src/main/java/com/fs/company/domain/CompanyExtensionBind.java
  43. 3 0
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceCloneRef.java
  44. 1 0
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRobotic.java
  45. 42 0
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRoboticCallLogCallphone.java
  46. 182 0
      fs-service/src/main/java/com/fs/company/domain/CrmCustomerCallLog.java
  47. 47 0
      fs-service/src/main/java/com/fs/company/domain/OutboundLineLimitLog.java
  48. 18 0
      fs-service/src/main/java/com/fs/company/dto/AiOutboundCallDailyTrendDTO.java
  49. 15 0
      fs-service/src/main/java/com/fs/company/dto/AiOutboundCallDurationDTO.java
  50. 15 0
      fs-service/src/main/java/com/fs/company/dto/AiOutboundCallHourlyDTO.java
  51. 15 0
      fs-service/src/main/java/com/fs/company/dto/AiOutboundCallIntentDTO.java
  52. 48 0
      fs-service/src/main/java/com/fs/company/dto/AiOutboundCallStatisticsDTO.java
  53. 15 0
      fs-service/src/main/java/com/fs/company/dto/AiOutboundCallStatusDTO.java
  54. 27 0
      fs-service/src/main/java/com/fs/company/dto/AiOutboundCallViolationDTO.java
  55. 26 0
      fs-service/src/main/java/com/fs/company/dto/OutboundLimitResultVO.java
  56. 49 0
      fs-service/src/main/java/com/fs/company/mapper/AiOutboundCallDashboardMapper.java
  57. 28 0
      fs-service/src/main/java/com/fs/company/mapper/CcExtNumMapper.java
  58. 2 1
      fs-service/src/main/java/com/fs/company/mapper/CompanyAiWorkflowExecMapper.java
  59. 1 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyBindGatewayMapper.java
  60. 2 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyConfigMapper.java
  61. 80 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyExtensionBindMapper.java
  62. 18 2
      fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceCloneRefMapper.java
  63. 13 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceRoboticCallLogCallphoneMapper.java
  64. 33 0
      fs-service/src/main/java/com/fs/company/mapper/CrmCustomerCallLogMapper.java
  65. 18 0
      fs-service/src/main/java/com/fs/company/mapper/OutboundLineLimitLogMapper.java
  66. 18 0
      fs-service/src/main/java/com/fs/company/param/AiOutboundCallDashboardParam.java
  67. 25 0
      fs-service/src/main/java/com/fs/company/param/AppendCustomersParam.java
  68. 28 0
      fs-service/src/main/java/com/fs/company/param/BatchCreateExtensionParam.java
  69. 47 0
      fs-service/src/main/java/com/fs/company/service/IAiOutboundCallDashboardService.java
  70. 132 0
      fs-service/src/main/java/com/fs/company/service/ICompanyExtensionBindService.java
  71. 25 0
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceCloneService.java
  72. 17 7
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticCallLogCallphoneService.java
  73. 18 1
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticService.java
  74. 2 0
      fs-service/src/main/java/com/fs/company/service/ICompanyWorkflowService.java
  75. 30 0
      fs-service/src/main/java/com/fs/company/service/ICrmCustomerCallLogService.java
  76. 333 5
      fs-service/src/main/java/com/fs/company/service/easycall/EasyCallServiceImpl.java
  77. 10 1
      fs-service/src/main/java/com/fs/company/service/easycall/IEasyCallService.java
  78. 111 0
      fs-service/src/main/java/com/fs/company/service/impl/AiOutboundCallDashboardServiceImpl.java
  79. 248 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanyExtensionBindServiceImpl.java
  80. 17 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceCloneServiceImpl.java
  81. 118 24
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallLogCallphoneServiceImpl.java
  82. 448 16
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java
  83. 45 18
      fs-service/src/main/java/com/fs/company/service/impl/CompanyWorkflowEngineImpl.java
  84. 14 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanyWorkflowServiceImpl.java
  85. 38 0
      fs-service/src/main/java/com/fs/company/service/impl/CrmCustomerCallLogServiceImpl.java
  86. 52 0
      fs-service/src/main/java/com/fs/company/service/impl/call/EasyCallTaskControlService.java
  87. 85 0
      fs-service/src/main/java/com/fs/company/service/impl/call/node/AbstractWorkflowNode.java
  88. 49 7
      fs-service/src/main/java/com/fs/company/service/impl/call/node/AiAddWxTaskNewNode.java
  89. 4 2
      fs-service/src/main/java/com/fs/company/service/impl/call/node/AiAddWxTaskNode.java
  90. 51 14
      fs-service/src/main/java/com/fs/company/service/impl/call/node/AiCallTaskNode.java
  91. 8 8
      fs-service/src/main/java/com/fs/company/service/impl/call/node/AiQwAddWxTaskNode.java
  92. 8 0
      fs-service/src/main/java/com/fs/company/vo/AiCallConfigVO.java
  93. 6 2
      fs-service/src/main/java/com/fs/company/vo/AiCallWorkflowConditionVo.java
  94. 2 0
      fs-service/src/main/java/com/fs/company/vo/CallContentVO.java
  95. 5 0
      fs-service/src/main/java/com/fs/company/vo/CompanyVoiceRoboticCallLogCallPhoneVO.java
  96. 5 0
      fs-service/src/main/java/com/fs/company/vo/WorkflowExecRecordVo.java
  97. 6 0
      fs-service/src/main/java/com/fs/company/vo/easycall/EasyCallCallPhoneVO.java
  98. 18 0
      fs-service/src/main/java/com/fs/company/vo/easycall/EasyCallCreateTaskParam.java
  99. 34 0
      fs-service/src/main/java/com/fs/company/vo/easycall/ExtensionVO.java
  100. 16 1
      fs-service/src/main/java/com/fs/crm/domain/CrmCustomer.java

+ 43 - 0
fs-admin-saas/src/main/java/com/fs/his/controller/FsCompanyController.java

@@ -4,6 +4,7 @@ import java.util.List;
 
 import cn.hutool.json.JSONUtil;
 import com.alibaba.fastjson.JSON;
+import com.fs.aiSipCall.vo.CcExtNumVo;
 import com.fs.common.annotation.RepeatSubmit;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.domain.model.LoginUser;
@@ -15,6 +16,7 @@ import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.company.domain.Company;
 import com.fs.company.domain.CompanyDeduct;
 import com.fs.company.domain.CompanyRecharge;
+import com.fs.company.param.BatchCreateExtensionParam;
 import com.fs.company.param.CompanyDeductParam;
 import com.fs.company.param.CompanyDivConfigUpdateParam;
 import com.fs.company.param.CompanyRechargeParam;
@@ -81,6 +83,8 @@ public class FsCompanyController extends BaseController {
     private TenantDataSourceManager tenantDataSourceManager;
     @Autowired
     private TenantInfoMapper tenantInfoMapper;
+    @Autowired
+    private ICompanyExtensionBindService companyExtensionBindService;
     /**
      * 查询诊所管理列表
      */
@@ -384,4 +388,43 @@ public class FsCompanyController extends BaseController {
         if (value.isEmpty())return true;
         return false;
     }
+
+
+    /**
+     * batchCreateExtension
+     * @return
+     */
+    @PostMapping("/batchCreateExtension")
+    @Log(title = "一键生成分机", businessType = BusinessType.INSERT)
+    public R batchCreateExtension(@RequestBody BatchCreateExtensionParam param){
+        if (param.getCompanyId() == null) {
+            return R.error("公司ID不能为空");
+        }
+        if (param.getCreateNum() == null || param.getCreateNum() <= 0) {
+            return R.error("生成数量必须大于0");
+        }
+        if (StringUtils.isEmpty(param.getPassword())) {
+            param.setPassword("123456");
+        }
+
+        Long tenantId = SecurityUtils.getTenantId();
+        TenantInfo tenantInfo = new TenantInfo();
+        tenantInfo.setId(tenantId);
+//        String tenantCode = ServletUtils.getRequest().getHeader("tenant-code");
+//        TenantInfo tenantInfo = tenantInfoMapper.getTenByCode(tenantCode);
+//        if (tenantInfo == null) {
+//            return R.error("租户信息不存在");
+//        }
+
+        List<CcExtNumVo> extNums = companyExtensionBindService.createExtensionInEasycall(param, tenantId);
+        if (extNums == null || extNums.isEmpty()) {
+            return R.error("分机创建失败,请重试");
+        }
+
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        tenantDataSourceManager.switchTenant(tenantInfo);
+        companyExtensionBindService.bindExtensionToTenant(extNums, param.getCompanyId());
+
+        return R.ok("成功创建" + extNums.size() + "个分机");
+    }
 }

+ 120 - 0
fs-admin-saas/src/main/java/com/fs/sensitive/controller/CompanyAiSensitiveWordController.java

@@ -0,0 +1,120 @@
+package com.fs.sensitive.controller;
+
+import cn.hutool.json.JSONUtil;
+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.model.LoginUser;
+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.service.ICompanyService;
+import com.fs.course.config.CourseConfig;
+import com.fs.framework.web.service.TokenService;
+import com.fs.his.vo.OptionsVO;
+import com.fs.sensitive.domain.CompanyAiSensitiveWord;
+import com.fs.sensitive.service.ICompanyAiSensitiveWordService;
+import com.fs.system.service.ISysConfigService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 敏感词库Controller(总后台)
+ * <p>
+ * 总后台可管理当前租户下各公司敏感词,新增时需指定 companyId。
+ * </p>
+ */
+@RestController
+@RequestMapping("/sensitive/word")
+public class CompanyAiSensitiveWordController extends BaseController {
+
+    @Autowired
+    private ICompanyAiSensitiveWordService companyAiSensitiveWordService;
+    @Autowired
+    private ICompanyService companyService;
+    @Autowired
+    private TokenService tokenService;
+    @Autowired
+    private ISysConfigService configService;
+
+    /**
+     * 公司下拉列表(新增/筛选使用)
+     */
+    @GetMapping("/companyList")
+    public AjaxResult companyList() {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long deptId = null;
+        String json = configService.selectConfigByKey("course.config");
+        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
+        if (!loginUser.isAdmin() && config.getDept() != null && config.getDept()) {
+            deptId = loginUser.getDeptId();
+        }
+        List<OptionsVO> list = companyService.selectAllCompanyList(deptId);
+        return AjaxResult.success(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('sensitive:word:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(CompanyAiSensitiveWord companyAiSensitiveWord) {
+        startPage();
+        List<CompanyAiSensitiveWord> list =
+                companyAiSensitiveWordService.selectCompanyAiSensitiveWordList(companyAiSensitiveWord);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('sensitive:word:export')")
+    @Log(title = "敏感词库", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(CompanyAiSensitiveWord companyAiSensitiveWord) {
+        List<CompanyAiSensitiveWord> list =
+                companyAiSensitiveWordService.selectCompanyAiSensitiveWordList(companyAiSensitiveWord);
+        ExcelUtil<CompanyAiSensitiveWord> util = new ExcelUtil<>(CompanyAiSensitiveWord.class);
+        return util.exportExcel(list, "敏感词数据");
+    }
+
+    @PreAuthorize("@ss.hasPermi('sensitive:word:query')")
+    @GetMapping("/{wordId}")
+    public AjaxResult getInfo(@PathVariable("wordId") Long wordId) {
+        return AjaxResult.success(companyAiSensitiveWordService.selectCompanyAiSensitiveWordByWordId(wordId));
+    }
+
+    @PreAuthorize("@ss.hasPermi('sensitive:word:add')")
+    @Log(title = "敏感词库", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody CompanyAiSensitiveWord companyAiSensitiveWord) {
+        if (companyAiSensitiveWord.getCompanyId() == null) {
+            return AjaxResult.error("请选择公司");
+        }
+        int ret = companyAiSensitiveWordService.insertCompanyAiSensitiveWord(companyAiSensitiveWord);
+        if (ret == -1) {
+            return AjaxResult.error("该公司下该敏感词已存在");
+        }
+        return toAjax(ret);
+    }
+
+    @PreAuthorize("@ss.hasPermi('sensitive:word:edit')")
+    @Log(title = "敏感词库", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody CompanyAiSensitiveWord companyAiSensitiveWord) {
+        return toAjax(companyAiSensitiveWordService.updateCompanyAiSensitiveWord(companyAiSensitiveWord));
+    }
+
+    @PreAuthorize("@ss.hasPermi('sensitive:word:edit')")
+    @Log(title = "敏感词库", businessType = BusinessType.UPDATE)
+    @PutMapping("/changeEnabled")
+    public AjaxResult changeEnabled(@RequestBody CompanyAiSensitiveWord companyAiSensitiveWord) {
+        return toAjax(companyAiSensitiveWordService.changeEnabled(
+                companyAiSensitiveWord.getWordId(), companyAiSensitiveWord.getEnabled()));
+    }
+
+    @PreAuthorize("@ss.hasPermi('sensitive:word:remove')")
+    @Log(title = "敏感词库", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{wordIds}")
+    public AjaxResult remove(@PathVariable Long[] wordIds) {
+        return toAjax(companyAiSensitiveWordService.deleteCompanyAiSensitiveWordByWordIds(wordIds));
+    }
+}

+ 366 - 0
fs-admin/src/main/java/com/fs/admin/controller/aicall/controller/CcLlmAgentAccountController.java

@@ -0,0 +1,366 @@
+package com.fs.admin.controller.aicall.controller;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.aicall.domain.CcCallTask;
+import com.fs.aicall.domain.CcLlmAgentAccount;
+import com.fs.aicall.domain.CompanyBindAiModel;
+import com.fs.aicall.service.ICcCallTaskService;
+import com.fs.aicall.service.ICcLlmAgentAccountService;
+import com.fs.aicall.service.ICcParamsService;
+import com.fs.aicall.service.ICompanyBindAiModelService;
+import com.fs.aicall.utils.CommonUtils;
+import com.fs.aicall.utils.StringUtils;
+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.model.LoginUser;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.core.text.Convert;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.company.cache.ICompanyCacheService;
+import com.fs.framework.datasource.TenantDataSourceManager;
+import com.fs.framework.web.service.TokenService;
+import com.fs.system.domain.SysConfig;
+import com.fs.system.mapper.SysConfigMapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 机器人参数配置Controller(总后台)
+ * <p>
+ * 模型数据存于共享 EASYCALL 库,需通过当前租户的公司绑定关系过滤;
+ * 默认展示本租户下全部公司已绑定模型,可按 companyId 进一步筛选。
+ * </p>
+ */
+@RestController
+@RequestMapping("/aicall/account")
+public class CcLlmAgentAccountController extends BaseController {
+
+    @Autowired
+    private ICcLlmAgentAccountService ccLlmAgentAccountService;
+    @Autowired
+    private ICcParamsService ccParamsService;
+    @Autowired
+    private ICcCallTaskService ccCallTaskService;
+    @Autowired
+    private ICompanyBindAiModelService companyBindAiModelService;
+    @Autowired
+    private SysConfigMapper sysConfigMapper;
+    @Autowired
+    private ICompanyCacheService companyCacheService;
+    @Autowired
+    private TenantDataSourceManager tenantDataSourceManager;
+    @Autowired
+    private TokenService tokenService;
+
+    private static final List<String> HIDE_KEYS = Arrays.asList(
+            "apiKey", "oauthPrivateKey", "oauthPublicKeyId", "patToken");
+
+    /**
+     * 查询机器人参数配置列表(当前租户下全部公司已绑定模型;可选 companyId 筛选)
+     */
+    @PreAuthorize("@ss.hasPermi('aicall:account:list')")
+    @PostMapping("/list")
+    public TableDataInfo list(@RequestBody CcLlmAgentAccount ccLlmAgentAccount,
+                                @RequestParam(value = "companyId", required = false) Long companyId) {
+        List<Long> modelIds = resolveTenantModelIds(companyId);
+        if (modelIds.isEmpty()) {
+            return getDataTable(new ArrayList<>());
+        }
+        ccLlmAgentAccount.setModelIds(modelIds);
+
+        startPage();
+        List<CcLlmAgentAccount> list = ccLlmAgentAccountService.selectCcLlmAgentAccountList(ccLlmAgentAccount);
+        TableDataInfo tableDataInfo = getDataTable(list);
+        List<CcLlmAgentAccount> records = (List<CcLlmAgentAccount>) tableDataInfo.getRows();
+        fillCompanyBindInfo(records);
+        maskAccountJson(records);
+        return tableDataInfo;
+    }
+
+    /**
+     * 获取机器人参数配置详情
+     */
+    @PreAuthorize("@ss.hasPermi('aicall:account:query')")
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Integer id) {
+        CcLlmAgentAccount account = ccLlmAgentAccountService.selectCcLlmAgentAccountById(id);
+        if (StringUtils.isBlank(account.getInterruptIgnoreKeywords())) {
+            account.setInterruptIgnoreKeywords(
+                    ccParamsService.getParamValueByCode("default_interrupt_ignore_keywords", ""));
+        }
+        maskAccountJson(account);
+        fillCompanyBindInfo(Collections.singletonList(account));
+        return AjaxResult.success(account);
+    }
+
+    /**
+     * 导出机器人参数配置列表
+     */
+    @PreAuthorize("@ss.hasPermi('aicall:account:export')")
+    @Log(title = "机器人参数配置", businessType = BusinessType.EXPORT)
+    @PostMapping("/export")
+    public AjaxResult export(CcLlmAgentAccount ccLlmAgentAccount,
+                             @RequestParam(value = "companyId", required = false) Long companyId) {
+        List<Long> modelIds = resolveTenantModelIds(companyId);
+        if (modelIds.isEmpty()) {
+            ExcelUtil<CcLlmAgentAccount> emptyUtil = new ExcelUtil<>(CcLlmAgentAccount.class);
+            return emptyUtil.exportExcel(new ArrayList<>(), "机器人参数配置数据");
+        }
+        ccLlmAgentAccount.setModelIds(modelIds);
+        List<CcLlmAgentAccount> list = ccLlmAgentAccountService.selectCcLlmAgentAccountList(ccLlmAgentAccount);
+        ExcelUtil<CcLlmAgentAccount> util = new ExcelUtil<>(CcLlmAgentAccount.class);
+        return util.exportExcel(list, "机器人参数配置数据");
+    }
+
+    /**
+     * 新增保存机器人参数配置
+     */
+    @PreAuthorize("@ss.hasPermi('aicall:account:add')")
+    @Log(title = "配置模型新增", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult addSave(@RequestBody CcLlmAgentAccount ccLlmAgentAccount,
+                              @RequestParam(value = "companyId", required = false) Long companyId) {
+        prepareAccountEntity(ccLlmAgentAccount);
+        fillDeepSeekApiKey(ccLlmAgentAccount);
+        if (ccLlmAgentAccount.getKbCatId() == null) {
+            ccLlmAgentAccount.setKbCatId(-1);
+        }
+
+        int result = ccLlmAgentAccountService.insertCcLlmAgentAccount(ccLlmAgentAccount);
+        if (result > 0 && companyId != null) {
+            ensureTenantDataSource();
+            companyBindAiModelService.bindCompanyToModel(ccLlmAgentAccount.getId().longValue(), companyId);
+        }
+        return toAjax(result);
+    }
+
+    /**
+     * 修改保存机器人参数配置
+     */
+    @PreAuthorize("@ss.hasPermi('aicall:account:edit')")
+    @Log(title = "配置模型修改", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult editSave(@RequestBody CcLlmAgentAccount ccLlmAgentAccount) {
+        if (ccLlmAgentAccount.getId() != null && ccLlmAgentAccount.getId() > 0) {
+            String errMsg = checkEdit(ccLlmAgentAccount.getId());
+            if (StringUtils.isNotEmpty(errMsg)) {
+                return AjaxResult.error(errMsg);
+            }
+        }
+
+        prepareAccountEntity(ccLlmAgentAccount);
+
+        Integer originId = ccLlmAgentAccount.getId();
+        if (originId != null && originId < 0) {
+            originId = originId * -1;
+        }
+        if (originId != null) {
+            CcLlmAgentAccount oldAccount = ccLlmAgentAccountService.selectCcLlmAgentAccountById(originId);
+            JSONObject oldAccountJson = JSONObject.parseObject(oldAccount.getAccountJson());
+            JSONObject newAccountJson = JSONObject.parseObject(ccLlmAgentAccount.getAccountJson());
+            for (String key : newAccountJson.keySet()) {
+                if (HIDE_KEYS.contains(key) && newAccountJson.getString(key).contains("****")) {
+                    newAccountJson.put(key, oldAccountJson.getString(key));
+                }
+            }
+            ccLlmAgentAccount.setAccountJson(JSONObject.toJSONString(newAccountJson));
+        }
+        if (ccLlmAgentAccount.getKbCatId() == null) {
+            ccLlmAgentAccount.setKbCatId(-1);
+        }
+
+        if (ccLlmAgentAccount.getId() != null && ccLlmAgentAccount.getId() > 0) {
+            return toAjax(ccLlmAgentAccountService.updateCcLlmAgentAccount(ccLlmAgentAccount));
+        }
+        ccLlmAgentAccount.setId(null);
+        return toAjax(ccLlmAgentAccountService.insertCcLlmAgentAccount(ccLlmAgentAccount));
+    }
+
+    /**
+     * 删除机器人参数配置(同时清理全部公司绑定关系)
+     */
+    @PreAuthorize("@ss.hasPermi('aicall:account:remove')")
+    @Log(title = "机器人参数配置", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable String ids) {
+        ensureTenantDataSource();
+        for (String id : Convert.toStrArray(ids)) {
+            companyBindAiModelService.deleteCompanyBindAiModelByModelId(Long.parseLong(id));
+        }
+        return toAjax(ccLlmAgentAccountService.deleteCcLlmAgentAccountByIds(ids));
+    }
+
+    @PreAuthorize("@ss.hasPermi('aicall:account:list')")
+    @GetMapping("/all")
+    public AjaxResult all(@RequestParam(value = "companyId", required = false) Long companyId) {
+        CcLlmAgentAccount query = new CcLlmAgentAccount();
+        List<Long> modelIds = resolveTenantModelIds(companyId);
+        if (modelIds.isEmpty()) {
+            return AjaxResult.success(new ArrayList<>());
+        }
+        query.setModelIds(modelIds);
+        List<CcLlmAgentAccount> list = ccLlmAgentAccountService.selectCcLlmAgentAccountList(query);
+        fillCompanyBindInfo(list);
+        maskAccountJson(list);
+        return AjaxResult.success(list);
+    }
+
+    @GetMapping("/getCidConfig")
+    public R getCidConfig() {
+        SysConfig sysConfig = sysConfigMapper.selectConfigByConfigKey("cId.config");
+        return R.ok().put("data", sysConfig.getConfigValue());
+    }
+
+    /**
+     * 绑定模型与公司
+     */
+    @PreAuthorize("@ss.hasPermi('aicall:account:edit')")
+    @Log(title = "配置模型绑定公司", businessType = BusinessType.UPDATE)
+    @PostMapping("/bindCompany")
+    public AjaxResult bindCompany(@RequestParam Long modelId, @RequestParam Long companyId) {
+        ensureTenantDataSource();
+        return toAjax(companyBindAiModelService.bindCompanyToModel(modelId, companyId));
+    }
+
+    /**
+     * 解除模型与公司的绑定
+     */
+    @PreAuthorize("@ss.hasPermi('aicall:account:edit')")
+    @Log(title = "配置模型解绑公司", businessType = BusinessType.UPDATE)
+    @PostMapping("/unbindCompany")
+    public AjaxResult unbindCompany(@RequestParam Long modelId, @RequestParam Long companyId) {
+        ensureTenantDataSource();
+        companyBindAiModelService.deleteBindAiModelByCompanyIdAndModelIds(companyId, String.valueOf(modelId));
+        return AjaxResult.success();
+    }
+
+    private void prepareAccountEntity(CcLlmAgentAccount ccLlmAgentAccount) {
+        if ("Coze".equalsIgnoreCase(ccLlmAgentAccount.getProviderClassName())
+                || "LocalNlpChat".equalsIgnoreCase(ccLlmAgentAccount.getProviderClassName())) {
+            ccLlmAgentAccount.setAccountEntity("CozeAccount");
+        } else if ("JiutianWorkflow".equalsIgnoreCase(ccLlmAgentAccount.getProviderClassName())
+                || "JiutianAgent".equalsIgnoreCase(ccLlmAgentAccount.getProviderClassName())) {
+            ccLlmAgentAccount.setAccountEntity("JiutianAccount");
+        } else {
+            ccLlmAgentAccount.setAccountEntity("LlmAccount");
+        }
+    }
+
+    private void fillDeepSeekApiKey(CcLlmAgentAccount ccLlmAgentAccount) {
+        if (!"DeepSeekChat".equalsIgnoreCase(ccLlmAgentAccount.getProviderClassName())) {
+            return;
+        }
+        JSONObject jsonObject = JSONObject.parseObject(ccLlmAgentAccount.getAccountJson());
+        if (jsonObject == null || !jsonObject.containsKey("apiKey")
+                || !jsonObject.getString("apiKey").contains("**")) {
+            return;
+        }
+        SysConfig sysConfig = sysConfigMapper.selectConfigByConfigKey("cId.config");
+        if (sysConfig != null && StringUtils.isNotBlank(sysConfig.getConfigValue())) {
+            JSONObject configValue = JSONObject.parseObject(sysConfig.getConfigValue());
+            jsonObject.put("apiKey", configValue.getString("apiKey"));
+            ccLlmAgentAccount.setAccountJson(JSONObject.toJSONString(jsonObject));
+        }
+    }
+
+    private void maskAccountJson(List<CcLlmAgentAccount> records) {
+        for (CcLlmAgentAccount data : records) {
+            maskAccountJson(data);
+        }
+    }
+
+    private void maskAccountJson(CcLlmAgentAccount data) {
+        JSONObject accountJson = JSONObject.parseObject(data.getAccountJson());
+        for (String key : accountJson.keySet()) {
+            if (HIDE_KEYS.contains(key)) {
+                accountJson.put(key, CommonUtils.maskStringUtil(accountJson.getString(key)));
+            }
+        }
+        data.setAccountJson(JSONObject.toJSONString(accountJson));
+    }
+
+    private String checkEdit(Integer id) {
+        List<CcCallTask> ccCallTaskList = ccCallTaskService.selectCcCallTaskList(new CcCallTask().setLlmAccountId(id));
+        List<String> ids = new ArrayList<>();
+        for (CcCallTask ccCallTask : ccCallTaskList) {
+            if (ccCallTask.getTaskType() == 1
+                    && ccCallTask.getIfcall() == 1
+                    && ccCallTask.getAutoStop() == 0) {
+                ids.add(ccCallTask.getBatchName());
+            }
+        }
+        if (!ids.isEmpty()) {
+            return String.format("%s%s%s", "请先暂停任务:", StringUtils.join(ids, ","), ",再修改该配置,修改完成后再启动任务");
+        }
+        return "";
+    }
+
+    /**
+     * 解析当前租户可见的模型ID:
+     * - 指定 companyId 时,仅返回该公司绑定的模型
+     * - 未指定时,返回当前租户下所有公司已绑定的模型(company_bind_ai_model 在租户库)
+     */
+    private List<Long> resolveTenantModelIds(Long companyId) {
+        ensureTenantDataSource();
+        if (companyId != null) {
+            return companyBindAiModelService.selectModelIdsByCompanyId(companyId);
+        }
+        return companyBindAiModelService.selectCompanyBindAiModelList(new CompanyBindAiModel())
+                .stream()
+                .map(CompanyBindAiModel::getModelId)
+                .distinct()
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 填充模型绑定的公司ID、公司名称(多对多,逗号分隔展示)
+     */
+    private void fillCompanyBindInfo(List<CcLlmAgentAccount> records) {
+        if (records == null || records.isEmpty()) {
+            return;
+        }
+        ensureTenantDataSource();
+        List<CompanyBindAiModel> allBinds =
+                companyBindAiModelService.selectCompanyBindAiModelList(new CompanyBindAiModel());
+        Map<Long, List<Long>> modelCompanyMap = allBinds.stream()
+                .collect(Collectors.groupingBy(
+                        CompanyBindAiModel::getModelId,
+                        Collectors.mapping(CompanyBindAiModel::getCompanyId, Collectors.toList())));
+
+        for (CcLlmAgentAccount account : records) {
+            List<Long> companyIds = modelCompanyMap.getOrDefault(
+                    account.getId().longValue(), Collections.emptyList());
+            account.setCompanyIds(companyIds);
+            if (companyIds.isEmpty()) {
+                account.setCompanyId("");
+                account.setCompanyName("");
+                continue;
+            }
+            List<String> companyNames = new ArrayList<>();
+            for (Long cid : companyIds) {
+                String name = companyCacheService.selectCompanyNameById(cid);
+                companyNames.add(StringUtils.isNotBlank(name) ? name : String.valueOf(cid));
+            }
+            account.setCompanyId(companyIds.stream().map(String::valueOf).collect(Collectors.joining(",")));
+            account.setCompanyName(String.join(",", companyNames));
+        }
+    }
+
+    /**
+     * company_bind_ai_model 在租户库;查询 EASYCALL 后 @DataSource 切面会 clear 数据源,需重新切回租户库。
+     */
+    private void ensureTenantDataSource() {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if (loginUser != null && loginUser.getTenantId() != null) {
+            tenantDataSourceManager.ensureSwitchByTenantId(loginUser.getTenantId());
+        }
+    }
+}

+ 77 - 0
fs-admin/src/main/java/com/fs/admin/controller/aicall/controller/CcLlmAgentProviderController.java

@@ -0,0 +1,77 @@
+package com.fs.admin.controller.aicall.controller;
+
+import com.fs.aicall.domain.CcLlmAgentProvider;
+import com.fs.aicall.service.ICcLlmAgentProviderService;
+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 org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 大模型实现类列表 Controller(总后台)
+ */
+@RestController
+@RequestMapping("/aicall/provider")
+public class CcLlmAgentProviderController extends BaseController {
+
+    @Autowired
+    private ICcLlmAgentProviderService ccLlmAgentProviderService;
+
+    @PreAuthorize("@ss.hasPermi('aicall:provider:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(CcLlmAgentProvider ccLlmAgentProvider) {
+        startPage();
+        List<CcLlmAgentProvider> list = ccLlmAgentProviderService.selectCcLlmAgentProviderList(ccLlmAgentProvider);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('aicall:provider:query')")
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Integer id) {
+        return AjaxResult.success(ccLlmAgentProviderService.selectCcLlmAgentProviderById(id));
+    }
+
+    @GetMapping("/all")
+    public AjaxResult all() {
+        List<CcLlmAgentProvider> list =
+                ccLlmAgentProviderService.selectCcLlmAgentProviderList(new CcLlmAgentProvider());
+        return AjaxResult.success(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('aicall:provider:export')")
+    @Log(title = "大模型实现类列表", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(CcLlmAgentProvider ccLlmAgentProvider) {
+        List<CcLlmAgentProvider> list = ccLlmAgentProviderService.selectCcLlmAgentProviderList(ccLlmAgentProvider);
+        ExcelUtil<CcLlmAgentProvider> util = new ExcelUtil<>(CcLlmAgentProvider.class);
+        return util.exportExcel(list, "大模型实现类列表数据");
+    }
+
+    @PreAuthorize("@ss.hasPermi('aicall:provider:add')")
+    @Log(title = "大模型实现类列表", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody CcLlmAgentProvider ccLlmAgentProvider) {
+        return toAjax(ccLlmAgentProviderService.insertCcLlmAgentProvider(ccLlmAgentProvider));
+    }
+
+    @PreAuthorize("@ss.hasPermi('aicall:provider:edit')")
+    @Log(title = "大模型实现类列表", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody CcLlmAgentProvider ccLlmAgentProvider) {
+        return toAjax(ccLlmAgentProviderService.updateCcLlmAgentProvider(ccLlmAgentProvider));
+    }
+
+    @PreAuthorize("@ss.hasPermi('aicall:provider:remove')")
+    @Log(title = "大模型实现类列表", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable String ids) {
+        return toAjax(ccLlmAgentProviderService.deleteCcLlmAgentProviderByIds(ids));
+    }
+}

+ 90 - 0
fs-admin/src/main/java/com/fs/admin/controller/aicall/controller/CcLlmKbCatController.java

@@ -0,0 +1,90 @@
+package com.fs.admin.controller.aicall.controller;
+
+import com.fs.aicall.domain.CcLlmKbCat;
+import com.fs.aicall.service.ICcLlmKbCatService;
+import com.fs.aicall.service.ICcLlmKbService;
+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 org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 知识库 Controller(总后台)
+ */
+@RestController
+@RequestMapping("/aicall/kbcat")
+public class CcLlmKbCatController extends BaseController {
+
+    @Autowired
+    private ICcLlmKbCatService ccLlmKbCatService;
+    @Autowired
+    private ICcLlmKbService ccLlmKbService;
+
+    @PreAuthorize("@ss.hasPermi('aicall:kbcat:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(CcLlmKbCat ccLlmKbCat) {
+        startPage();
+        List<CcLlmKbCat> list = ccLlmKbCatService.selectCcLlmKbCatList(ccLlmKbCat);
+        for (CcLlmKbCat data : list) {
+            data.setContentCount(ccLlmKbService.selectCountByCatId(data.getId()));
+        }
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('aicall:kbcat:query')")
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id) {
+        return AjaxResult.success(ccLlmKbCatService.selectCcLlmKbCatById(id));
+    }
+
+    @GetMapping("/all")
+    public AjaxResult all() {
+        List<CcLlmKbCat> list = ccLlmKbCatService.selectCcLlmKbCatList(new CcLlmKbCat());
+        return AjaxResult.success(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('aicall:kbcat:export')")
+    @Log(title = "知识库", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(CcLlmKbCat ccLlmKbCat) {
+        List<CcLlmKbCat> list = ccLlmKbCatService.selectCcLlmKbCatList(ccLlmKbCat);
+        ExcelUtil<CcLlmKbCat> util = new ExcelUtil<>(CcLlmKbCat.class);
+        return util.exportExcel(list, "知识库数据");
+    }
+
+    @PreAuthorize("@ss.hasPermi('aicall:kbcat:add')")
+    @Log(title = "知识库", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody CcLlmKbCat ccLlmKbCat) {
+        CcLlmKbCat check = ccLlmKbCatService.selectCcLlmKbCatByCat(null, ccLlmKbCat.getCat());
+        if (check != null) {
+            return AjaxResult.error("知识库分类不能重复,请修改");
+        }
+        return toAjax(ccLlmKbCatService.insertCcLlmKbCat(ccLlmKbCat));
+    }
+
+    @PreAuthorize("@ss.hasPermi('aicall:kbcat:edit')")
+    @Log(title = "知识库", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody CcLlmKbCat ccLlmKbCat) {
+        CcLlmKbCat check = ccLlmKbCatService.selectCcLlmKbCatByCat(ccLlmKbCat.getId(), ccLlmKbCat.getCat());
+        if (check != null) {
+            return AjaxResult.error("知识库分类不能重复,请修改");
+        }
+        return toAjax(ccLlmKbCatService.updateCcLlmKbCat(ccLlmKbCat));
+    }
+
+    @PreAuthorize("@ss.hasPermi('aicall:kbcat:remove')")
+    @Log(title = "知识库", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable String ids) {
+        return toAjax(ccLlmKbCatService.deleteCcLlmKbCatByIds(ids));
+    }
+}

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

@@ -0,0 +1,91 @@
+package com.fs.admin.controller.company.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.company.domain.CompanyVoiceCloneRef;
+import com.fs.company.service.ICompanyVoiceCloneService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.List;
+
+/**
+ * 豆包声音克隆 Controller(总后台)
+ * <p>
+ * 总后台可查看全部公司音色数据,上传训练时需指定 companyId。
+ * </p>
+ */
+@RestController
+@RequestMapping("/company/voiceClone")
+public class CompanyVoiceCloneController extends BaseController {
+
+    @Autowired
+    private ICompanyVoiceCloneService companyVoiceCloneService;
+
+    /**
+     * 分页查询全部音色关联(可按 companyId、companyUserId 等筛选)
+     */
+    @PreAuthorize("@ss.hasPermi('company:voiceCloneRef:list')")
+    @GetMapping("/ref/list")
+    public TableDataInfo refList(CompanyVoiceCloneRef ref) {
+        startPage();
+        List<CompanyVoiceCloneRef> list = companyVoiceCloneService.selectRefList(ref);
+        return getDataTable(list);
+    }
+
+    /**
+     * 删除音色关联
+     */
+    @PreAuthorize("@ss.hasPermi('company:voiceCloneRef:remove')")
+    @Log(title = "声音克隆-删除音色", businessType = BusinessType.DELETE)
+    @DeleteMapping("/ref/{id}")
+    public AjaxResult removeRef(@PathVariable Long id) {
+        return toAjax(companyVoiceCloneService.deleteRefById(id));
+    }
+
+    /**
+     * 启用/禁用音色关联
+     */
+    @PreAuthorize("@ss.hasPermi('company:voiceCloneRef:update')")
+    @Log(title = "声音克隆-修改音色状态", businessType = BusinessType.UPDATE)
+    @PutMapping("/ref/changeStatus")
+    public AjaxResult changeRefStatus(@RequestBody CompanyVoiceCloneRef ref) {
+        return toAjax(companyVoiceCloneService.changeStatus(ref));
+    }
+
+    /**
+     * 上传音频文件并训练声音克隆音色
+     */
+    @PreAuthorize("@ss.hasPermi('company:voiceCloneRef:add')")
+    @Log(title = "声音克隆-上传训练", businessType = BusinessType.IMPORT)
+    @PostMapping("/uploadAndTrain")
+    public AjaxResult uploadAndTrain(
+            @RequestParam("file") MultipartFile file,
+            @RequestParam("voice_name") String voiceName,
+            @RequestParam("speaker_id") String speakerId,
+            @RequestParam("companyId") Long companyId,
+            @RequestParam(value = "companyUserId", required = false) Long companyUserId,
+            @RequestParam(value = "language", defaultValue = "0") Integer language,
+            @RequestParam(value = "model_type", defaultValue = "2") Integer modelType) {
+        return companyVoiceCloneService.uploadAndTrain(
+                voiceName, speakerId, language, modelType, file, companyId, companyUserId);
+    }
+
+    /**
+     * TTS 语音合成测试
+     */
+    @PreAuthorize("@ss.hasPermi('company:voiceCloneRef:list')")
+    @Log(title = "声音克隆-TTS测试", businessType = BusinessType.OTHER)
+    @PostMapping("/doubaoTtsTest")
+    public AjaxResult doubaoTtsTest(
+            @RequestParam("speakerId") String speakerId,
+            @RequestParam(value = "language", defaultValue = "0") Integer language,
+            @RequestParam("text") String text) {
+        return companyVoiceCloneService.doubaoTtsTest(speakerId, language, text);
+    }
+}

+ 120 - 0
fs-admin/src/main/java/com/fs/admin/controller/sensitive/controller/CompanyAiSensitiveWordController.java

@@ -0,0 +1,120 @@
+package com.fs.admin.controller.sensitive.controller;
+
+import cn.hutool.json.JSONUtil;
+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.model.LoginUser;
+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.service.ICompanyService;
+import com.fs.course.config.CourseConfig;
+import com.fs.framework.web.service.TokenService;
+import com.fs.his.vo.OptionsVO;
+import com.fs.sensitive.domain.CompanyAiSensitiveWord;
+import com.fs.sensitive.service.ICompanyAiSensitiveWordService;
+import com.fs.system.service.ISysConfigService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 敏感词库Controller(总后台)
+ * <p>
+ * 总后台可管理当前租户下各公司敏感词,新增时需指定 companyId。
+ * </p>
+ */
+@RestController
+@RequestMapping("/sensitive/word")
+public class CompanyAiSensitiveWordController extends BaseController {
+
+    @Autowired
+    private ICompanyAiSensitiveWordService companyAiSensitiveWordService;
+    @Autowired
+    private ICompanyService companyService;
+    @Autowired
+    private TokenService tokenService;
+    @Autowired
+    private ISysConfigService configService;
+
+    /**
+     * 公司下拉列表(新增/筛选使用)
+     */
+    @GetMapping("/companyList")
+    public AjaxResult companyList() {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long deptId = null;
+        String json = configService.selectConfigByKey("course.config");
+        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
+        if (!loginUser.isAdmin() && config.getDept() != null && config.getDept()) {
+            deptId = loginUser.getDeptId();
+        }
+        List<OptionsVO> list = companyService.selectAllCompanyList(deptId);
+        return AjaxResult.success(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('sensitive:word:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(CompanyAiSensitiveWord companyAiSensitiveWord) {
+        startPage();
+        List<CompanyAiSensitiveWord> list =
+                companyAiSensitiveWordService.selectCompanyAiSensitiveWordList(companyAiSensitiveWord);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('sensitive:word:export')")
+    @Log(title = "敏感词库", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(CompanyAiSensitiveWord companyAiSensitiveWord) {
+        List<CompanyAiSensitiveWord> list =
+                companyAiSensitiveWordService.selectCompanyAiSensitiveWordList(companyAiSensitiveWord);
+        ExcelUtil<CompanyAiSensitiveWord> util = new ExcelUtil<>(CompanyAiSensitiveWord.class);
+        return util.exportExcel(list, "敏感词数据");
+    }
+
+    @PreAuthorize("@ss.hasPermi('sensitive:word:query')")
+    @GetMapping("/{wordId}")
+    public AjaxResult getInfo(@PathVariable("wordId") Long wordId) {
+        return AjaxResult.success(companyAiSensitiveWordService.selectCompanyAiSensitiveWordByWordId(wordId));
+    }
+
+    @PreAuthorize("@ss.hasPermi('sensitive:word:add')")
+    @Log(title = "敏感词库", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody CompanyAiSensitiveWord companyAiSensitiveWord) {
+        if (companyAiSensitiveWord.getCompanyId() == null) {
+            return AjaxResult.error("请选择公司");
+        }
+        int ret = companyAiSensitiveWordService.insertCompanyAiSensitiveWord(companyAiSensitiveWord);
+        if (ret == -1) {
+            return AjaxResult.error("该公司下该敏感词已存在");
+        }
+        return toAjax(ret);
+    }
+
+    @PreAuthorize("@ss.hasPermi('sensitive:word:edit')")
+    @Log(title = "敏感词库", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody CompanyAiSensitiveWord companyAiSensitiveWord) {
+        return toAjax(companyAiSensitiveWordService.updateCompanyAiSensitiveWord(companyAiSensitiveWord));
+    }
+
+    @PreAuthorize("@ss.hasPermi('sensitive:word:edit')")
+    @Log(title = "敏感词库", businessType = BusinessType.UPDATE)
+    @PutMapping("/changeEnabled")
+    public AjaxResult changeEnabled(@RequestBody CompanyAiSensitiveWord companyAiSensitiveWord) {
+        return toAjax(companyAiSensitiveWordService.changeEnabled(
+                companyAiSensitiveWord.getWordId(), companyAiSensitiveWord.getEnabled()));
+    }
+
+    @PreAuthorize("@ss.hasPermi('sensitive:word:remove')")
+    @Log(title = "敏感词库", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{wordIds}")
+    public AjaxResult remove(@PathVariable Long[] wordIds) {
+        return toAjax(companyAiSensitiveWordService.deleteCompanyAiSensitiveWordByWordIds(wordIds));
+    }
+}

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

@@ -1,8 +1,13 @@
 package com.fs.app.service;
 
+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 lombok.RequiredArgsConstructor;
@@ -12,6 +17,7 @@ import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 
+import java.time.LocalTime;
 import java.util.*;
 import java.util.concurrent.*;
 
@@ -28,6 +34,8 @@ public class CallTaskService {
 
     private final RedisCache redisCache2;
     private final CompanyWorkflowEngine companyWorkflowEngine;
+    private final ICompanyVoiceRoboticService companyVoiceRoboticService;
+    private final CompanyVoiceRoboticMapper companyVoiceRoboticMapper;
     @Autowired
     @Qualifier("cidWorkFlowExecutor")
     private Executor cidWorkFlowExecutor;
@@ -41,6 +49,8 @@ public class CallTaskService {
         String delayCallKeyPrefix = AiCallTaskNode.getDelayCallKeyPrefix(cidGroupNo,null) + "*";
         Collection<String> keys = redisCache2.keys(delayCallKeyPrefix);
         log.info("共扫描到 {} 个待处理键", keys.size());
+        // 本地缓存已查询的任务暂停状态,避免同一批次重复查询
+        Map<Long, Boolean> pausedCache = new ConcurrentHashMap<>();
         keys.parallelStream().forEach(key -> {
             try {
                 //doExec
@@ -49,21 +59,143 @@ public class CallTaskService {
                         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);
                     }
-                }, cidWorkFlowExecutor).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);
+    }
+
 }

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

@@ -27,7 +27,7 @@ public class Task {
     private final CompanyVoiceRoboticMapper roboticMapper;
 
     private final CallTaskService taskService;
-    
+
     private final TenantTaskRunner tenantTaskRunner;
 
     /** SaaS 模式:为 true 时各定时任务按租户遍历执行;为 false 时保持原单库执行(私有化部署) */
@@ -63,4 +63,23 @@ public class Task {
         }
 
     }
+
+    @Scheduled(cron = "0 0/1 * * * ?")
+    public void taskStatusChange(){
+        // 防御性检查:确保依赖注入成功
+        if (tenantTaskRunner == null) {
+            log.error("[Task] tenantTaskRunner 未注入,请检查 Spring 配置");
+            return;
+        }
+        if (taskService == null) {
+            log.error("[Task] taskService 未注入,请检查 Spring 配置");
+            return;
+        }
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForResponsibleTenant("taskStatusChange", () -> taskService.taskStatusChange());
+        } else {
+            taskService.taskStatusChange();
+        }
+
+    }
 }

+ 63 - 8
fs-cid-workflow/src/main/java/com/fs/app/service/CidWorkflowTaskService.java

@@ -1,5 +1,7 @@
 package com.fs.app.service;
 import com.fs.common.core.redis.RedisCache;
+import com.alibaba.fastjson.JSONObject;
+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 RedisCache redisCache;
@@ -48,13 +51,24 @@ public class CidWorkflowTaskService {
         System.out.println(companyAiWorkflowExecs);
         log.info("runCidWorkflow得到租户id:{}",TenantHelper.getTenantId());
         if (null != companyAiWorkflowExecs && companyAiWorkflowExecs.size() > 0) {
+            // 本地缓存已查询的任务暂停状态,避免同一批次重复查询
+            Map<Long, Boolean> pausedCache = new HashMap<>();
             companyAiWorkflowExecs.forEach(exec -> {
 //                cidWorkFlowExecutor.execute(() -> {
-                    try {
-                        companyWorkflowEngine.executeNode(exec.getWorkflowInstanceId(), exec.getCurrentNodeKey());
-                    } catch (Exception e) {
-                        log.error("处理就绪任务异常 - exec: {}", exec, e);
+                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;
+                        }
                     }
+                    companyWorkflowEngine.executeNode(exec.getWorkflowInstanceId(), exec.getCurrentNodeKey());
+                } catch (Exception e) {
+                    log.error("处理就绪任务异常 - exec: {}", exec, e);
+                }
 //                });
             });
         }
@@ -67,13 +81,24 @@ public class CidWorkflowTaskService {
         List<CompanyAiWorkflowExec> companyAiWorkflowExecs = companyAiWorkflowExecMapper.selectExecListWithTimeAvailableByStatusAndGroupNo(ExecutionStatusEnum.PENDING.getValue(), cidGroupNo);
         log.info("activateTimeAvailableTask得到租户id:{}",TenantHelper.getTenantId());
         if (null != companyAiWorkflowExecs && companyAiWorkflowExecs.size() > 0) {
+            // 本地缓存已查询的任务暂停状态,避免同一批次重复查询
+            Map<Long, Boolean> pausedCache = new HashMap<>();
             companyAiWorkflowExecs.forEach(exec -> {
 //                cidWorkFlowExecutor.execute(() -> {
-                    try {
-                        companyWorkflowEngine.executeNode(exec.getWorkflowInstanceId(), exec.getCurrentNodeKey());
-                    } catch (Exception e) {
-                        log.error("处理就绪任务异常 - exec: {}", exec, e);
+                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;
+                        }
                     }
+                    companyWorkflowEngine.executeNode(exec.getWorkflowInstanceId(), exec.getCurrentNodeKey());
+                } catch (Exception e) {
+                    log.error("处理就绪任务异常 - exec: {}", exec, e);
+                }
 //                });
             });
         }
@@ -87,11 +112,23 @@ public class CidWorkflowTaskService {
         String s = String.format(AbstractWorkflowNode.CONTINUE_TIMER_EXECUTE_KEY_PREFIX, cidGroupNo);
         Collection<String> keys = redisCache.keys(s);
         log.info("定时服务执行共扫描到 {} 个待处理键", keys.size());
+        // 本地缓存已查询的任务暂停状态,避免同一批次重复查询
+        Map<Long, Boolean> pausedCache = new HashMap<>();
         // 使用 cidWorkFlowExecutor 替代 parallelStream,确保租户ID正确传递
         keys.forEach(key -> {
             cidWorkFlowExecutor.execute(() -> {
                 try {
                     ExecutionContext context = redisCache.getCacheObject(key);
+                    if (context == null) {
+                        redisCache.deleteObject(key);
+                        return;
+                    }
+                    // 任务暂停守卫检查(CONTINUE:TIMER key 不含时间分片,下次扫描还能找到,暂停时保留key)
+                    Long taskId = context.getVariable("roboticId", Long.class);
+                    if (taskId != null && pausedCache.computeIfAbsent(taskId, id -> companyVoiceRoboticService.isTaskPaused(id))) {
+                        log.info("任务已暂停,保留CONTINUE:TIMER key等待下次扫描 - taskId: {}, key: {}", taskId, key);
+                        return;
+                    }
                     CompanyAiWorkflowExec exec = companyAiWorkflowExecMapper.selectExecWithTimeAvailableByInstanceId(context.getWorkflowInstanceId());
                     if(null == exec){
                         return;
@@ -109,4 +146,22 @@ 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();
+    }
+
 }

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

+ 19 - 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;
@@ -30,6 +31,9 @@ public class CidTask {
     @Autowired
     private TenantTaskRunner tenantTaskRunner;
 
+    @Autowired
+    private OutboundRetryTaskService outboundRetryTaskService;
+
     /**
      * 扫描当前分组下就绪任务,并开启执行
      */
@@ -64,6 +68,21 @@ public class CidTask {
 //    }
 
 
+    /**
+     * 外呼重试任务 - 每30分钟执行一次
+     * 扫描 Redis 中被外呼限制拦截的待重试呼叫,到达 nextAvailableTime 后重新执行
+     */
+    @Scheduled(cron = "0 0/30 * * * ?")
+    public void retryOutboundCallTask() {
+        log.info("执行外呼限制重试机制");
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant("外呼限制重试", tenantInfo -> {
+                outboundRetryTaskService.processRetryTasks();
+            });
+        } else {
+            outboundRetryTaskService.processRetryTasks();
+        }
+    }
 
 
 }

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

@@ -496,4 +496,18 @@ public class RedisCache
     public boolean hasKey(String key) {
         return Boolean.TRUE.equals(redisTemplate.hasKey(key));
     }
+
+    /**
+     * 按 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);
+    }
 }

+ 4 - 1
fs-company-app/src/main/java/com/fs/app/controller/UserController.java

@@ -459,7 +459,10 @@ public class UserController extends AppBaseController {
     @PostMapping("/setPwd")
     public R setPwd(HttpServletRequest request, @RequestBody EditPwdParam param) {
         try {
-
+            // 新密码格式校验(与login一致)
+            if (!PatternUtils.checkPassword(param.getPassword())) {
+                return R.error("密码格式不正确,需包含字母、数字和特殊字符,长度为 8-20位");
+            }
             CompanyUser user = userService.selectCompanyUserById(Long.parseLong(getUserId()));
             if (!SecurityUtils.matchesPassword(param.getOldPassword(), user.getPassword())) {
                 return R.error("旧密码错误");

+ 400 - 0
fs-company-app/src/main/java/com/fs/app/controller/aiSipCall/AiSipCallController.java

@@ -0,0 +1,400 @@
+package com.fs.app.controller.aiSipCall;
+
+import com.fs.aiSipCall.domain.AiSipCallOutboundCdr;
+import com.fs.aiSipCall.domain.AiSipCallUser;
+import com.fs.aiSipCall.domain.CcCustInfo;
+import com.fs.aiSipCall.service.IAiSipCallOutboundCdrService;
+import com.fs.aiSipCall.service.IAiSipCallUserService;
+import com.fs.app.annotation.Login;
+import com.fs.app.controller.AppBaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.mapper.EasyCallMapper;
+import com.fs.company.service.ICompanyUserService;
+import com.fs.crm.domain.CrmCustomer;
+import com.fs.crm.mapper.CrmCustomerMapper;
+import com.fs.crm.service.ICrmCustomerService;
+import com.fs.his.utils.PhoneUtil;
+import com.github.pagehelper.PageHelper;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiParam;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.web.bind.annotation.*;
+
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 销售端APP - aiSIP软电话接口(手动外呼)
+ * <p>
+ * 提供给APP端浏览器/JsSIP软电话工具条所需的最小可用后端能力:
+ * <ul>
+ *     <li>拉取当前销售绑定的SIP分机账号 - 用于注册SIP UA</li>
+ *     <li>拉取IPCC工具条基础参数 - 用于建立IPCC WebSocket控制通道</li>
+ *     <li>通话前手机号密文转换 - 配合密文拨号模式</li>
+ *     <li>通话沟通信息查询/沟通记录补录</li>
+ * </ul>
+ * <p>
+ * 注意:本Controller仅复用 fs-service 中已有Service方法,
+ * 不修改 fs-service / fs-company / fs-framework 等任何已有模块代码。
+ * <p>
+ * 仍待用户决策的能力(依赖 fs-service 暂未提供的方法/字段,详见随附差缺清单):
+ * <ul>
+ *     <li>已补齐:agentLogin、callEndSyncByUuid、getCustCommunicationInfo(dialMode)</li>
+ * </ul>
+ *
+ * @author migrated from his_java/fs-company AiSipCallUserController & AiSipCallOutboundCdrController
+ */
+@Slf4j
+@Api("软电话接口")
+@RestController
+@RequestMapping("/app/aiSipCall")
+public class AiSipCallController extends AppBaseController {
+
+    @Autowired
+    private IAiSipCallUserService aiSipCallUserService;
+
+    @Autowired
+    private IAiSipCallOutboundCdrService aiSipCallOutboundCdrService;
+
+    @Autowired
+    private ICompanyUserService companyUserService;
+
+    @Autowired
+    private EasyCallMapper easyCallMapper;
+
+    @Autowired
+    private ICrmCustomerService crmCustomerService;
+
+    @Autowired
+    private CrmCustomerMapper crmCustomerMapper;
+
+    /**
+     * 是否使用自有线路(迁移自 his_java sip.call.myGateway,原放在 AiSipCallUserServiceImpl)。
+     * <p>SaaS 老 ServiceImpl 已被精简没有兜底逻辑,故在 Controller 层注入并保持 his_java 行为一致。
+     */
+    @Value("${sip.call.myGateway:false}")
+    private boolean isMyGateway;
+
+    /** 手动外呼网关前缀(迁移自 his_java sip.call.manualGatewayPrefix) */
+    @Value("${sip.call.manualGatewayPrefix:weizhi}")
+    private String manualGatewayPrefix;
+
+    /** 公共线路网关前缀(迁移自 his_java sip.call.publicGatewayPrefix) */
+    @Value("${sip.call.publicGatewayPrefix:outbound}")
+    private String publicGatewayPrefix;
+
+    /** 加密手机号末尾随机串长度(与前端约定,与 his_java 的 RandomUtil.generateRandomCode 等价) */
+    private static final int RANDOM_TAIL_LEN = 6;
+
+    private static final char[] RANDOM_CHARS =
+            "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".toCharArray();
+
+    private static final SecureRandom RANDOM = new SecureRandom();
+
+    /** XOR加密公钥(与 his_java 的 PhoneUtil.PUBLIC_KEY_STR 保持一致,避免污染 SaaS 原有 PhoneUtil) */
+    private static final String XOR_KEY = "ylrz112233";
+
+    private final String AUDIO_BASE_URL = "http://129.28.164.235:8899";
+    
+    private static final String SPLICE_ADD = "http://129.28.164.235:8899/recordings/files?filename=";
+
+    /**
+     * 查询当前登录销售绑定的SIP分机账号
+     * <p>前端 softPhone.vue 启动时调用,用于初始化JsSIP UA配置(user/domain/extPass)。
+     */
+    @Login
+    @ApiOperation("查询当前销售的SIP分机账号")
+    @GetMapping("/myCallUser")
+    public AjaxResult myCallUser(AiSipCallUser aiSipCallUser) {
+        if (aiSipCallUser == null) {
+            aiSipCallUser = new AiSipCallUser();
+        }
+        aiSipCallUser.setCompanyUserId(getCompanyUserId());
+        List<AiSipCallUser> list = aiSipCallUserService.selectAiSipCallUserList(aiSipCallUser);
+        if (list != null && !list.isEmpty()) {
+            return AjaxResult.success(list.get(0));
+        }
+        return AjaxResult.error("未创建sip角色");
+    }
+
+    /**
+     * 查询aiSIP工具条基础配置参数
+     * <p>前端获取IPCC WebSocket地址、外呼网关等参数。
+     *
+     * @param param 至少包含 extNum(分机号)
+     */
+    @Login
+    @ApiOperation("查询aiSIP工具条基础参数")
+    @PostMapping("/getToolbarBasicParam")
+    public AjaxResult getToolbarBasicParam(@RequestBody Map<String, String> param) {
+        if (param == null || param.get("extNum") == null) {
+            return AjaxResult.error("分机号参数缺失");
+        }
+        // ===== 步骤1:迁移自 fs-company AiSipCallUserController#getToolbarBasicParam =====
+        // 优先使用当前销售所属公司绑定的网关ids
+        Long companyId = getCompanyId();
+        if (companyId != null) {
+            String ids = aiSipCallUserService.getGateWayIdListByCompanyId(companyId);
+            if (StringUtils.isNotBlank(ids)) {
+                param.put("myGateway", ids);
+            }
+        }
+        // ===== 步骤2:迁移自 his_java AiSipCallUserServiceImpl#getToolbarBasicParam =====
+        // 公司没绑定网关 + 前端也没传 myGateway,按 yml 配置兜底(与 his_java 行为一致)
+//        String myGateway = param.get("myGateway");
+//        if (StringUtils.isBlank(myGateway)) {
+//            if (isMyGateway) {
+//                // 自己有线路 → 使用手动外呼网关前缀
+//                param.put("wgName", manualGatewayPrefix);
+//            } else {
+//                // 没有给默认线路 → 使用公共线路前缀
+//                param.put("wgName", publicGatewayPrefix);
+//            }
+//        }
+        return aiSipCallUserService.getToolbarBasicParam(param);
+    }
+
+    /**
+     * 获取手动外呼客户沟通信息
+     *
+     * @param phoneNum 手机号(明文或密文)
+     * @param callType 类型 1呼入 2外呼
+     * @param uuid     通话UUID
+     * @param dialMode plaintext / encrypted (为空按明文处理)
+     */
+    @Login
+    @ApiOperation("获取手动外呼客户沟通信息")
+    @GetMapping("/getCustCommunicationInfo")
+    public AjaxResult getCustCommunicationInfo(
+            @ApiParam(value = "手机号", required = true) @RequestParam("phoneNum") String phoneNum,
+            @ApiParam(value = "1呼入 2外呼", required = true) @RequestParam("callType") Integer callType,
+            @ApiParam(value = "通话UUID", required = true) @RequestParam("uuid") String uuid,
+            @ApiParam(value = "拨号模式 plaintext/encrypted") @RequestParam(value = "dialMode", required = false) String dialMode) {
+        return aiSipCallOutboundCdrService.getCustCommunicationInfo(phoneNum, callType, uuid, dialMode);
+    }
+
+    /**
+     * 新增保存手动外呼沟通记录
+     */
+    @Login
+    @ApiOperation("新增手动外呼沟通记录")
+    @PostMapping("/add/custcallrecord")
+    public AjaxResult addCustcallrecord(@RequestBody CcCustInfo ccCustInfo) {
+        return aiSipCallOutboundCdrService.addCustcallrecord(ccCustInfo);
+    }
+
+    /**
+     * 手机号密文转换:先解密再用xor重新加密 + 末尾追加随机串
+     * <p>用于密文拨号模式:前端把"原始密文+6位随机数"丢上来,后端返回"新密文+6位随机数"。
+     */
+    @Login
+    @ApiOperation("手机号密文转换(解密->xor加密)")
+    @PostMapping("/encryptMobile")
+    public AjaxResult encryptMobile(@RequestBody Map<String, String> request) {
+        String combined = request != null ? request.get("data") : null;
+        if (combined == null || combined.length() <= RANDOM_TAIL_LEN) {
+            return AjaxResult.error("加密参数缺失或格式不正确");
+        }
+        // 截掉末尾随机串
+        String original = combined.substring(0, combined.length() - RANDOM_TAIL_LEN);
+        try {
+            String decrypted = PhoneUtil.decryptPhone(original);
+            String encrypted = xorEncrypt(decrypted);
+            return AjaxResult.success("获取成功", encrypted + generateRandomTail());
+        } catch (Exception e) {
+            log.error("[aiSipCall][app] encryptMobile失败", e);
+            return AjaxResult.error("获取加密手机号失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 坐席登录IPCC外呼平台
+     * <p>前端 JsSIP 工具条启动后,使用此接口让 IPCC 平台知晓"该分机已上线、可派发呼叫"。
+     *
+     * @param param 至少包含 username/password/extNum 等表单参数
+     */
+    @Login
+    @ApiOperation("坐席登录IPCC外呼平台")
+    @PostMapping("/agentLogin")
+    public AjaxResult agentLogin(@RequestBody Map<String, Object> param) {
+        return aiSipCallUserService.agentLogin(param);
+    }
+
+    /**
+     * 通话挂断后根据UUID同步话单
+     * <p>前端 JsSIP 监听到 BYE/挂机后调用本接口,由后端从 IPCC 拉取话单并落库。
+     */
+    @Login
+    @ApiOperation("通话挂断后同步话单")
+    @PostMapping("/callEndSyncByUuid")
+    public AjaxResult callEndSyncByUuid(@RequestBody AiSipCallOutboundCdr request) {
+        if (request == null || StringUtils.isBlank(request.getUuid())) {
+            return AjaxResult.error("获取手动外呼通话记录同步失败,uuid为空");
+        }
+        // 用APP登录态填充必备的归属字段(覆盖前端任何尝试性传值,避免越权)
+        request.setSourceType("0");
+        Long companyId = getCompanyId();
+        Long companyUserId = getCompanyUserId();
+        request.setCompanyId(companyId);
+        request.setCompanyUserId(companyUserId);
+        if (companyUserId != null) {
+            CompanyUser companyUser = companyUserService.selectCompanyUserById(companyUserId);
+            if (companyUser != null) {
+                request.setCompanyUserName(companyUser.getUserName());
+            }
+        }
+        request.setStatus(0);
+        aiSipCallOutboundCdrService.callEndSyncByUuid(request);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 获取有效客户列表(effective_customer=1)
+     * <p>按销售id、创建时间范围、加密手机号、备注等条件筛选。
+     * 手机号传入明文,后端加密后进行LIKE匹配。
+     */
+    @Login
+    @ApiOperation("获取有效客户列表")
+    @GetMapping("/getEffectiveCustomerList")
+    public TableDataInfo getEffectiveCustomerList(
+            @RequestParam(value = "startTime", required = false) String startTime,
+            @RequestParam(value = "endTime", required = false) String endTime,
+            @RequestParam(value = "mobile", required = false) String mobile,
+            @RequestParam(value = "remark", required = false) String remark,
+            @RequestParam(value = "callStatus", required = false) Integer callStatus,
+            @RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum,
+            @RequestParam(value = "pageSize", defaultValue = "10") Integer pageSize) {
+        String encryptedMobile = null;
+        if (StringUtils.isNotBlank(mobile)) {
+            encryptedMobile = PhoneUtil.encryptPhone(mobile);
+        }
+        PageHelper.startPage(pageNum, pageSize);
+        List<CrmCustomer> list = crmCustomerMapper.selectEffectiveCustomerList(
+                getCompanyUserId(), startTime, endTime, encryptedMobile, remark, callStatus);
+        if (list != null) {
+            for (CrmCustomer c : list) {
+                if(StringUtils.isNotBlank(c.getMobile())){
+                    String decrypted = PhoneUtil.decryptPhone(c.getMobile());
+                    try {
+                        c.setMobile(decrypted.replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2"));
+                    } catch (Exception e) {
+                        log.warn("[aiSipCall] 解密手机号失败, customerId={}", c.getCustomerId());
+                    }
+                }
+                if(StringUtils.isNotBlank(c.getEffectiveRecordPath())){
+                    c.setEffectiveRecordPath(SPLICE_ADD + c.getEffectiveRecordPath());
+                }
+            }
+        }
+        return getDataTable(list);
+    }
+
+    /**
+     * 根据客户ID查询呼叫记录
+     */
+    @Login
+    @ApiOperation("根据客户ID查询呼叫记录")
+    @GetMapping("/getCallRecordByCustomerId")
+    public TableDataInfo getCallRecordByCustomerId(
+            @RequestParam(value = "customerId", required = false) Long customerId,
+            @RequestParam(value = "startTime", required = false) String startTime,
+            @RequestParam(value = "endTime", required = false) String endTime,
+            @RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum,
+            @RequestParam(value = "pageSize", defaultValue = "10") Integer pageSize) {
+        AiSipCallOutboundCdr query = new AiSipCallOutboundCdr();
+        if (customerId != null) {
+            query.setCustomerId(customerId);
+        } else {
+            CompanyUser companyUser = companyUserService.selectCompanyUserById(getCompanyUserId());
+            if (companyUser != null) {
+                query.setOpnum(companyUser.getUserName());
+            }
+        }
+        if (StringUtils.isNotBlank(startTime)) {
+            query.setStartTimeStart(startTime);
+        }
+        if (StringUtils.isNotBlank(endTime)) {
+            query.setStartTimeEnd(endTime);
+        }
+
+        PageHelper.startPage(pageNum, pageSize);
+        List<AiSipCallOutboundCdr> list = aiSipCallOutboundCdrService.selectAiSipCallOutboundCdrList(query);
+        //拼接音频文件
+        list.forEach(data -> {
+            if (StringUtils.isNotBlank(data.getWavfile())) {
+                data.setWavfile(AUDIO_BASE_URL + data.getWavfile());
+            }
+        });
+        return getDataTable(list);
+    }
+
+    /**
+     * 根据客户ID获取解密后的手机号明文
+     */
+    @Login
+    @ApiOperation("根据客户ID获取解密手机号")
+    @GetMapping("/decryptMobileByCustomerId")
+    public AjaxResult decryptMobileByCustomerId(@RequestParam("customerId") Long customerId) {
+        String encryptedMobile = crmCustomerMapper.selectCrmCustomerPhoneByCustomerId(customerId);
+        if (StringUtils.isBlank(encryptedMobile)) {
+            return AjaxResult.error("该客户无手机号");
+        }
+        try {
+            String decrypted = PhoneUtil.decryptPhone(encryptedMobile);
+            return AjaxResult.success("获取成功", decrypted);
+        } catch (Exception e) {
+            log.error("[aiSipCall] 解密手机号失败, customerId={}", customerId, e);
+            return AjaxResult.error("手机号解密失败");
+        }
+    }
+
+    /**
+     * 设置客户解密状态为已解锁
+     */
+    @Login
+    @ApiOperation("设置客户解密状态为已解锁")
+    @PostMapping("/unlockCustomerDecrypt")
+    public AjaxResult unlockCustomerDecrypt(@RequestParam("customerId") Long customerId) {
+        if (customerId == null) {
+            return AjaxResult.error("客户ID不能为空");
+        }
+        int rows = crmCustomerMapper.updateCanDecryptByCustomerId(customerId);
+        String encryptedMobile = crmCustomerMapper.selectCrmCustomerPhoneByCustomerId(customerId);
+        return rows > 0 ? AjaxResult.success(PhoneUtil.decryptPhone(encryptedMobile)) : AjaxResult.error("解锁失败,客户不存在");
+    }
+
+
+    /**
+     * 生成6位随机串(替代 his_java 中的 com.fs.his.utils.RandomUtil#generateRandomCode)
+     */
+    private String generateRandomTail() {
+        StringBuilder sb = new StringBuilder(RANDOM_TAIL_LEN);
+        for (int i = 0; i < RANDOM_TAIL_LEN; i++) {
+            sb.append(RANDOM_CHARS[RANDOM.nextInt(RANDOM_CHARS.length)]);
+        }
+        return sb.toString();
+    }
+
+    /**
+     * XOR 加密(与 his_java 的 PhoneUtil.xorEncrypt 算法一致)。
+     * <p>内联在此,避免修改 SaaS 原有 PhoneUtil。
+     */
+    private String xorEncrypt(String data) {
+        byte[] dataBytes = data.getBytes(StandardCharsets.UTF_8);
+        byte[] keyBytes = XOR_KEY.getBytes(StandardCharsets.UTF_8);
+        byte[] result = new byte[dataBytes.length];
+        for (int i = 0; i < dataBytes.length; i++) {
+            result[i] = (byte) (dataBytes[i] ^ keyBytes[i % keyBytes.length]);
+        }
+        return Base64.getEncoder().encodeToString(result);
+    }
+}

+ 52 - 0
fs-company-app/src/main/java/com/fs/app/controller/crm/CrmAPPMsgController.java

@@ -0,0 +1,52 @@
+package com.fs.app.controller.crm;
+
+import com.fs.app.annotation.Login;
+import com.fs.app.controller.AppBaseController;
+import com.fs.common.core.domain.R;
+import com.fs.crm.service.ICrmMsgService;
+import com.fs.crm.vo.CrmMsgTypeVO;
+import com.fs.system.service.ISysDictDataService;
+import com.fs.system.vo.DictVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ *  消息Controller(APP端)
+ *
+ * @author fs
+ * @date 2021-04-16
+ */
+@RestController
+@RequestMapping("/crm/msg")
+public class CrmAPPMsgController extends AppBaseController
+{
+    @Autowired
+    private ICrmMsgService crmMsgService;
+    @Autowired
+    private ISysDictDataService dictDataService;
+
+
+    @Login
+    @GetMapping("/getMsg")
+    public R getMsg(){
+        // APP端鉴权:从APPToken中解析companyUserId
+        Long companyUserId = getCompanyUserId();
+        //获取所有类型
+        List<DictVO> types = dictDataService.selectDictDataListByType("crm_msg_type");
+        List<CrmMsgTypeVO> counts = new ArrayList<>();
+        for(DictVO v : types){
+            Long count = crmMsgService.selectCrmMsgCountByUserId(companyUserId, Integer.parseInt(v.getDictValue()));
+            CrmMsgTypeVO typeBO = new CrmMsgTypeVO();
+            typeBO.setMsgType(Integer.parseInt(v.getDictValue()));
+            typeBO.setTotal(count);
+            typeBO.setMsgTypeName(v.getDictLabel());
+            counts.add(typeBO);
+        }
+        return R.ok().put("counts", counts);
+    }
+}

+ 135 - 2
fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallOutboundCdrController.java

@@ -1,5 +1,6 @@
 package com.fs.company.controller.aiSipCall;
 
+import cn.hutool.core.util.ObjectUtil;
 import com.fs.aiSipCall.domain.AiSipCallOutboundCdr;
 import com.fs.aiSipCall.domain.CcCustInfo;
 import com.fs.aiSipCall.param.ApiCallRecordByUuidQueryParams;
@@ -10,15 +11,26 @@ 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.exception.ServiceException;
+import com.fs.common.utils.SecurityUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.company.domain.CrmCustomerCallLog;
+import com.fs.company.mapper.CrmCustomerCallLogMapper;
+import com.fs.company.mapper.EasyCallMapper;
+import com.fs.company.vo.easycall.EasyCallOutBoundVO;
+import com.fs.framework.datasource.TenantDataSourceManager;
+import com.fs.framework.service.TokenService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
+import java.security.SecureRandom;
 import java.util.Date;
 import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
 
 /**
  * aiSIP手动外呼通话记录Controller
@@ -33,6 +45,15 @@ public class AiSipCallOutboundCdrController extends BaseController
 {
     @Autowired
     private IAiSipCallOutboundCdrService aiSipCallOutboundCdrService;
+    @Autowired
+    private EasyCallMapper easyCallMapper;
+    @Autowired
+    TenantDataSourceManager tenantDataSourceManager;
+    @Autowired
+    private TokenService tokenService;
+    @Autowired
+    private CrmCustomerCallLogMapper crmCustomerCallLogMapper;
+
 
     /**
      * 查询aiSIP手动外呼通话记录列表
@@ -147,6 +168,11 @@ public class AiSipCallOutboundCdrController extends BaseController
 
     /**
      * 同步aiSIP外呼通话记录
+     * 说明:
+     * 1. 如果有 workflowInstanceId 和 roboticId,走原来的机器人/工作流外呼记录
+     * 2. 如果没有 workflowInstanceId 和 roboticId,走用户手动外呼记录
+     * 3. 如果对方通话记录尚未写入,立即返回"正在同步录音中",后台异步重试3次(每次间隔10秒)
+     * 4. 3次重试后仍无数据,则用请求参数写入一条仅含基础信息的CrmCustomerCallLog(不含通话数据),后续可通过uuid手动同步
      */
     @PostMapping("/syncByUuid")
     public AjaxResult syncByUuid(@RequestBody ApiCallRecordByUuidQueryParams req) {
@@ -156,12 +182,119 @@ public class AiSipCallOutboundCdrController extends BaseController
         if (StringUtils.isBlank(req.getCallType())) {
             req.setCallType("03");
         }
-
-        int rows = aiSipCallOutboundCdrService.syncByUuid(req);
+        if (req == null || org.apache.commons.lang3.StringUtils.isBlank(req.getUuid())) {
+            throw new ServiceException("uuid不能为空");
+        }
+        //获取租户id
+        req.setTenantId(SecurityUtils.getTenantId());
+        EasyCallOutBoundVO callPhoneRes = easyCallMapper.getOutBoundInfoByUuid(req.getUuid());
+        tenantDataSourceManager.ensureSwitchByTenantId(SecurityUtils.getTenantId());
+        if (ObjectUtil.isEmpty(callPhoneRes)) {
+            // 对方通话记录尚未写入,异步重试
+            log.info("syncByUuid uuid={} 首次未查询到通话记录,启动异步重试", req.getUuid());
+            asyncRetrySyncByUuid(req);
+            return AjaxResult.success("正在同步录音中");
+        }
+        int rows;
+        if (req.getWorkflowInstanceId() != null && req.getRoboticId() != null) {
+            // 工作流外呼保存逻辑
+            rows = aiSipCallOutboundCdrService.syncByUuid(req,callPhoneRes);
+        } else {
+            // 用户手动外呼保存逻辑
+            if (req.getCustomerId() == null) {
+                return AjaxResult.error("客户ID不能为空");
+            }
+            rows = aiSipCallOutboundCdrService.syncCrmCustomerCallLogByUuid(req,callPhoneRes);
+        }
         if (rows > 0) {
             return AjaxResult.success("同步成功");
         }
         return AjaxResult.error("未查到对应通话记录或同步失败");
     }
+    /** 同步重试最大次数 */
+    private static final int SYNC_RETRY_MAX_TIMES = 3;
+    /** 同步重试间隔(秒) */
+    private static final long SYNC_RETRY_INTERVAL_SECONDS = 10L;
 
+    /**
+     * 异步重试同步通话记录
+     * 最多重试3次,每次间隔10秒;若3次均未查到数据,则用请求参数写入一条仅含基础信息的CrmCustomerCallLog
+     */
+    private void asyncRetrySyncByUuid(ApiCallRecordByUuidQueryParams req) {
+        Long tenantId = req.getTenantId();
+        CompletableFuture.runAsync(() -> {
+            EasyCallOutBoundVO callPhoneRes = null;
+            for (int i = 1; i <= SYNC_RETRY_MAX_TIMES; i++) {
+                try {
+                    TimeUnit.SECONDS.sleep(SYNC_RETRY_INTERVAL_SECONDS);
+                } catch (InterruptedException e) {
+                    log.error("syncByUuid uuid={} 重试等待被中断", req.getUuid(), e);
+                    Thread.currentThread().interrupt();
+                    break;
+                }
+                log.info("syncByUuid uuid={} 第{}次重试查询通话记录", req.getUuid(), i);
+                try {
+                    callPhoneRes = easyCallMapper.getOutBoundInfoByUuid(req.getUuid());
+                } catch (Exception e) {
+                    log.error("syncByUuid uuid={} 第{}次重试查询异常", req.getUuid(), i, e);
+                }
+                if (ObjectUtil.isNotEmpty(callPhoneRes)) {
+                    log.info("syncByUuid uuid={} 第{}次重试查询到通话记录", req.getUuid(), i);
+                    break;
+                }
+            }
+            // 切换回租户数据源
+            tenantDataSourceManager.ensureSwitchByTenantId(tenantId);
+            try {
+                if (ObjectUtil.isNotEmpty(callPhoneRes)) {
+                    // 重试成功,走正常同步逻辑
+                    if (req.getWorkflowInstanceId() != null && req.getRoboticId() != null) {
+                        aiSipCallOutboundCdrService.syncByUuid(req, callPhoneRes);
+                    } else {
+                        if (req.getCustomerId() != null) {
+                            aiSipCallOutboundCdrService.syncCrmCustomerCallLogByUuid(req, callPhoneRes);
+                        } else {
+                            log.warn("syncByUuid uuid={} 重试成功但客户ID为空,跳过手动外呼同步", req.getUuid());
+                        }
+                    }
+                    log.info("syncByUuid uuid={} 异步重试同步完成", req.getUuid());
+                } else {
+                    // 3次重试均无数据,用请求参数写入仅含基础信息的CrmCustomerCallLog(不含通话详细数据)
+                    log.warn("syncByUuid uuid={} 重试{}次后仍未查到通话记录,写入基础记录待后续手动同步", req.getUuid(), SYNC_RETRY_MAX_TIMES);
+                    insertBasicCrmCustomerCallLog(req);
+                }
+            } catch (Exception e) {
+                log.error("syncByUuid uuid={} 异步重试同步处理异常", req.getUuid(), e);
+            }
+        });
+    }
+
+    /**
+     * 用请求参数写入仅含基础信息的CrmCustomerCallLog(不含通话数据:录音、内容、时长、费用等)
+     * 后续可通过uuid手动同步补充通话数据
+     */
+    private void insertBasicCrmCustomerCallLog(ApiCallRecordByUuidQueryParams req) {
+        CrmCustomerCallLog callLog = new CrmCustomerCallLog();
+        callLog.setUuid(req.getUuid());
+        callLog.setStatus(req.getStatus());
+        callLog.setCompanyId(req.getCompanyId());
+        callLog.setCompanyUserId(req.getCompanyUserId());
+        callLog.setCustomerId(req.getCustomerId());
+        callLog.setIntention(req.getIntent());
+        callLog.setCreateTime(new Date());
+        callLog.setRunTime(new Date());
+        if (StringUtils.isNotBlank(req.getCallType())) {
+            callLog.setCallType(Integer.valueOf(req.getCallType()));
+        } else {
+            callLog.setCallType(3); // 默认03人工外呼
+        }
+        // 不写通话相关数据:recordPath、contentList、callerNum、calleeNum、callCreateTime、callAnswerTime、callTime、cost、billingMinute
+        // 后续可通过uuid手动同步补充
+        try {
+            crmCustomerCallLogMapper.insertCrmCustomerCallLog(callLog);
+            log.info("syncByUuid uuid={} 写入基础CrmCustomerCallLog记录成功", req.getUuid());
+        } catch (Exception e) {
+            log.error("syncByUuid uuid={} 写入基础CrmCustomerCallLog记录失败", req.getUuid(), e);
+        }
+    }
 }

+ 79 - 0
fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallUserController.java

@@ -2,13 +2,17 @@ package com.fs.company.controller.aiSipCall;
 
 import com.fs.aiSipCall.domain.AiSipCallUser;
 import com.fs.aiSipCall.service.IAiSipCallUserService;
+import com.fs.aiSipCall.vo.AiSipCallUserNewVO;
 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.SecurityUtils;
 import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.company.mapper.CcExtNumMapper;
 import com.fs.framework.security.LoginUser;
 import com.fs.framework.service.TokenService;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -34,6 +38,9 @@ public class AiSipCallUserController extends BaseController
     @Autowired
     private TokenService tokenService;
 
+    @Autowired
+    private CcExtNumMapper ccExtNumMapper;
+
     /**
      * 查询sip用户信息列表
      */
@@ -86,9 +93,32 @@ public class AiSipCallUserController extends BaseController
             aiSipCallUser.setCreateBy(loginUser.getUser().getUserName());
         }
         aiSipCallUser.setCreateTime(new Date());
+        aiSipCallUser.setUserSource("0");
         return toAjax(aiSipCallUserService.insertAiSipCallUser(aiSipCallUser));
     }
 
+    /**
+     * 新增sip用户信息
+     */
+    @PreAuthorize("@ss.hasPermi('company:aiSipCall:aiSipCallUser:add')")
+    @Log(title = "sip用户信息", businessType = BusinessType.INSERT)
+    @PostMapping("/addNew")
+    public AjaxResult addNew(@RequestBody AiSipCallUser aiSipCallUser)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if(aiSipCallUser.getCompanyUserId() == null){
+            aiSipCallUser.setCompanyId(loginUser.getCompany().getCompanyId());
+            aiSipCallUser.setCompanyUserId(loginUser.getUser().getUserId());
+        }
+        aiSipCallUser.setCreateBy(loginUser.getUser().getUserName());
+        int i = aiSipCallUserService.insertAiSipCallUserNew(aiSipCallUser);
+        aiSipCallUser.setCreateTime(new Date());
+        ccExtNumMapper.updateUserCodeByExtNum(aiSipCallUser.getExtNum(), aiSipCallUser.getLoginName());
+//        Long tenantId = SecurityUtils.getTenantId();
+//        tenantDataSourceManager.ensureSwitchByTenantId(tenantId);
+        return toAjax(i);
+    }
+
     /**
      * 修改sip用户信息
      */
@@ -149,9 +179,58 @@ public class AiSipCallUserController extends BaseController
     public AjaxResult getToolbarBasicParam(@RequestBody Map<String,String> param)
     {
         String extNum = param.get("extNum");
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        String ids = aiSipCallUserService.getGateWayIdListByCompanyId(loginUser.getCompany().getCompanyId());
+//        ids = "5";
+        if(StringUtils.isNotBlank(ids)){
+            param.put("myGateway",ids);
+        }
         if(extNum == null){
             return AjaxResult.error("分机号参数缺失");
         }
         return aiSipCallUserService.getToolbarBasicParam(param);
     }
+
+    /**
+     * 修改sip用户信息
+     */
+    @PreAuthorize("@ss.hasPermi('company:aiSipCall:aiSipCallUser:edit')")
+    @Log(title = "sip用户信息", businessType = BusinessType.UPDATE)
+    @PostMapping("/editNew")
+    public AjaxResult editNew(@RequestBody AiSipCallUser aiSipCallUser)
+    {
+        Long tenantId = SecurityUtils.getTenantId();
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+//        aiSipCallUser.setCompanyId(loginUser.getCompany().getCompanyId());
+//        aiSipCallUser.setCompanyUserId(loginUser.getUser().getUserId());
+        aiSipCallUser.setUpdateBy(loginUser.getUser().getUserName());
+        aiSipCallUser.setUpdateTime(new Date());
+
+        AiSipCallUserNewVO aiSipCallUserNewVO = aiSipCallUserService.updateAiSipCallUserNew(aiSipCallUser);
+        if(StringUtils.isNotBlank(aiSipCallUserNewVO.getOldExtNum())){
+            ccExtNumMapper.updateUserCodeByExtNum(aiSipCallUserNewVO.getOldExtNum(), tenantId + "_" + loginUser.getCompany().getCompanyId() + "_" + aiSipCallUserNewVO.getOldExtNum());
+        }
+        if(StringUtils.isNotBlank(aiSipCallUserNewVO.getNewExtNum()) && StringUtils.isNotBlank(aiSipCallUserNewVO.getNewUserCode())){
+            ccExtNumMapper.updateUserCodeByExtNum(aiSipCallUserNewVO.getNewExtNum(), aiSipCallUserNewVO.getNewUserCode());
+        }
+        return toAjax(1);
+    }
+
+    @GetMapping("/getUnBindExtnumNew/{sipUserId}")
+    public AjaxResult getUnBindExtnumNew(@PathVariable Long sipUserId)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        return aiSipCallUserService.getUnBindExtnumNew(loginUser.getCompany().getCompanyId(),sipUserId);
+    }
+
+    /**
+     * 登录外呼平台接口
+     * @param param 参数
+     * @return AjaxResult 结果
+     */
+    @PostMapping("/agentLogin")
+    public AjaxResult agentLogin(@RequestBody Map<String,Object> param)
+    {
+        return aiSipCallUserService.agentLogin(param);
+    }
 }

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

+ 115 - 0
fs-company/src/main/java/com/fs/company/controller/company/AiOutboundCallDashboardController.java

@@ -0,0 +1,115 @@
+package com.fs.company.controller.company;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.utils.ServletUtils;
+import com.fs.company.dto.*;
+import com.fs.company.param.AiOutboundCallDashboardParam;
+import com.fs.company.service.IAiOutboundCallDashboardService;
+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 java.util.List;
+
+/**
+ * AI外呼看板Controller
+ */
+@RestController
+@RequestMapping("/company/aiOutboundCallDashboard")
+public class AiOutboundCallDashboardController extends BaseController {
+
+    @Autowired
+    private IAiOutboundCallDashboardService aiOutboundCallDashboardService;
+
+    @Autowired
+    private TokenService tokenService;
+
+    /**
+     * 获取AI外呼统计数据(按任务维度)
+     */
+    @PreAuthorize("@ss.hasPermi('company:aiOutboundCallDashboard:list')")
+    @GetMapping("/statistics")
+    public AjaxResult statistics(AiOutboundCallDashboardParam param) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<AiOutboundCallStatisticsDTO> list = aiOutboundCallDashboardService.getCallStatistics(param);
+        return AjaxResult.success(list);
+    }
+
+    /**
+     * 获取每日外呼趋势数据
+     */
+    @PreAuthorize("@ss.hasPermi('company:aiOutboundCallDashboard:list')")
+    @GetMapping("/dailyTrend")
+    public AjaxResult dailyTrend(AiOutboundCallDashboardParam param) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<AiOutboundCallDailyTrendDTO> list = aiOutboundCallDashboardService.getDailyCallTrend(param);
+        return AjaxResult.success(list);
+    }
+
+    /**
+     * 获取通话状态分布数据
+     */
+    @PreAuthorize("@ss.hasPermi('company:aiOutboundCallDashboard:list')")
+    @GetMapping("/callStatusDistribution")
+    public AjaxResult callStatusDistribution(AiOutboundCallDashboardParam param) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<AiOutboundCallStatusDTO> list = aiOutboundCallDashboardService.getCallStatusDistribution(param);
+        return AjaxResult.success(list);
+    }
+
+    /**
+     * 获取意向分布数据
+     */
+    @PreAuthorize("@ss.hasPermi('company:aiOutboundCallDashboard:list')")
+    @GetMapping("/intentDistribution")
+    public AjaxResult intentDistribution(AiOutboundCallDashboardParam param) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<AiOutboundCallIntentDTO> list = aiOutboundCallDashboardService.getIntentDistribution(param);
+        return AjaxResult.success(list);
+    }
+
+    /**
+     * 获取时段分布数据(按小时)
+     */
+    @PreAuthorize("@ss.hasPermi('company:aiOutboundCallDashboard:list')")
+    @GetMapping("/hourlyDistribution")
+    public AjaxResult hourlyDistribution(AiOutboundCallDashboardParam param) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<AiOutboundCallHourlyDTO> list = aiOutboundCallDashboardService.getHourlyDistribution(param);
+        return AjaxResult.success(list);
+    }
+
+    /**
+     * 获取通话时长分布数据
+     */
+    @PreAuthorize("@ss.hasPermi('company:aiOutboundCallDashboard:list')")
+    @GetMapping("/durationDistribution")
+    public AjaxResult durationDistribution(AiOutboundCallDashboardParam param) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<AiOutboundCallDurationDTO> list = aiOutboundCallDashboardService.getDurationDistribution(param);
+        return AjaxResult.success(list);
+    }
+
+    /**
+     * 获取敏感词触发统计数据
+     */
+    @PreAuthorize("@ss.hasPermi('company:aiOutboundCallDashboard:list')")
+    @GetMapping("/violationStatistics")
+    public AjaxResult violationStatistics(AiOutboundCallDashboardParam param) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<AiOutboundCallViolationDTO> list = aiOutboundCallDashboardService.getViolationStatistics(param);
+        return AjaxResult.success(list);
+    }
+}

+ 85 - 0
fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceCloneRefController.java

@@ -0,0 +1,85 @@
+package com.fs.company.controller.company;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.ServletUtils;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.domain.CompanyVoiceCloneRef;
+import com.fs.company.service.ICompanyVoiceCloneService;
+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.*;
+
+/**
+ * 音色关联 Controller
+ * <p>
+ * 数据权限:销售管理员可查看全公司数据,子账号仅可查看自己的数据(companyUserId)
+ * </p>
+ *
+ * @author fs
+ */
+@RestController
+@RequestMapping("/company/voiceCloneRef")
+public class CompanyVoiceCloneRefController extends BaseController {
+
+    @Autowired
+    private ICompanyVoiceCloneService companyVoiceCloneService;
+
+    @Autowired
+    private TokenService tokenService;
+
+    /**
+     * 分页查询音色关联列表
+     * <p>
+     * 数据权限隔离:
+     * - 销售管理员(userType=00/02):查看本公司所有数据
+     * - 子账号:仅查看 companyUserId = 自己 userId 的数据
+     * </p>
+     */
+    @PreAuthorize("@ss.hasPermi('company:voiceCloneRef:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(CompanyVoiceCloneRef ref) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+
+        ref.setCompanyId(loginUser.getCompany().getCompanyId());
+        // 非管理员仅看自己的数据
+        if (!CompanyUser.isAdmin(loginUser.getUser().getUserType())) {
+            ref.setCompanyUserId(loginUser.getUser().getUserId());
+        }
+
+        startPage();
+        return getDataTable(companyVoiceCloneService.selectRefList(ref));
+    }
+
+    /**
+     * 删除音色关联(仅管理员可操作)
+     */
+    @PreAuthorize("@ss.hasPermi('company:voiceCloneRef:remove')")
+    @DeleteMapping("/{id}")
+    public AjaxResult remove(@PathVariable Long id) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if (!CompanyUser.isAdmin(loginUser.getUser().getUserType())) {
+            return AjaxResult.error("无删除权限,仅管理员可操作");
+        }
+        return toAjax(companyVoiceCloneService.deleteRefById(id));
+    }
+
+    /**
+     * 启用/禁用音色关联(仅管理员可操作)
+     */
+    @Log(title = "修改音色关联启用/禁用状态", businessType = BusinessType.UPDATE)
+    @PreAuthorize("@ss.hasPermi('company:voiceCloneRef:update')")
+    @PutMapping("/changeStatus")
+    public AjaxResult changeStatus(@RequestBody CompanyVoiceCloneRef ref) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        if (!CompanyUser.isAdmin(loginUser.getUser().getUserType())) {
+            return AjaxResult.error("无操作权限,仅管理员可操作");
+        }
+        return toAjax(companyVoiceCloneService.changeStatus(ref));
+    }
+}

+ 66 - 4
fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticCallLogCallphoneController.java

@@ -5,23 +5,27 @@ 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.common.utils.SecurityUtils;
 import com.fs.company.domain.CompanyVoiceRoboticCallLogCallphone;
 import com.fs.company.service.ICompanyVoiceRoboticCallLogCallphoneService;
+import com.fs.crm.domain.CrmCustomer;
+import com.fs.crm.service.ICrmCustomerService;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
+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 +36,10 @@ public class CompanyVoiceRoboticCallLogCallphoneController extends BaseControlle
     @Autowired
     private ICompanyVoiceRoboticCallLogCallphoneService companyVoiceRoboticCallLogCallphoneService;
 
+    @Autowired
+    private ICrmCustomerService crmCustomerService;
+    @Autowired
+    private TokenService tokenService;
     /**
      * 查询调用日志_ai打电话列表
      */
@@ -47,6 +55,7 @@ public class CompanyVoiceRoboticCallLogCallphoneController extends BaseControlle
                 companyVoiceRoboticCallLogCallphone.setCallerIds(calleeIds);
                 startPage();
                 List<CompanyVoiceRoboticCallLogCallphone> list = companyVoiceRoboticCallLogCallphoneService.selectCompanyVoiceRoboticCallLogCallphoneListData(companyVoiceRoboticCallLogCallphone);
+
                 return getDataTable(list);
             } else {
                 return getDataTable(new ArrayList<>());
@@ -91,6 +100,59 @@ public class CompanyVoiceRoboticCallLogCallphoneController extends BaseControlle
         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);
+        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打电话列表
      */

+ 60 - 6
fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticController.java

@@ -16,12 +16,14 @@ 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;
@@ -35,6 +37,7 @@ import com.fs.company.vo.WorkflowExecRecordVo;
 import com.fs.crm.service.ICrmCustomerPropertyService;
 import com.fs.framework.security.LoginUser;
 import com.fs.framework.service.TokenService;
+import com.fs.wxcid.utils.TenantHelper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
@@ -81,7 +84,22 @@ public class CompanyVoiceRoboticController extends BaseController
     @GetMapping("/list")
     public TableDataInfo list(CompanyVoiceRobotic companyVoiceRobotic){
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
-        if (companyVoiceRobotic.getCompanyId() == null && loginUser.getCompany() != null) { if (companyVoiceRobotic.getCompanyId() == null && loginUser.getCompany() != null) { if (companyVoiceRobotic.getCompanyId() == null && loginUser.getCompany() != null) { companyVoiceRobotic.setCompanyId(loginUser.getCompany().getCompanyId()); }; }; };
+        companyVoiceRobotic.setCompanyId(loginUser.getCompany().getCompanyId());
+        startPage();
+        List<CompanyVoiceRobotic> list = companyVoiceRoboticService.selectCompanyVoiceRoboticListCompany(companyVoiceRobotic);
+        fillUserInfo(list);
+        return getDataTable(list);
+    }
+
+    /**
+     * 查询我的机器人外呼任务列表(所有人只看自己的)
+     */
+    @PreAuthorize("@ss.hasPermi('system:companyVoiceRobotic:list')")
+    @GetMapping("/myList")
+    public TableDataInfo myList(CompanyVoiceRobotic companyVoiceRobotic) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        companyVoiceRobotic.setCompanyId(loginUser.getCompany().getCompanyId());
+        companyVoiceRobotic.setCompanyUserId(loginUser.getUser().getUserId());
         startPage();
         List<CompanyVoiceRobotic> list = companyVoiceRoboticService.selectCompanyVoiceRoboticListCompany(companyVoiceRobotic);
         fillUserInfo(list);
@@ -94,7 +112,7 @@ public class CompanyVoiceRoboticController extends BaseController
     @GetMapping("/listAll")
     public R listAll(CompanyVoiceRobotic companyVoiceRobotic){
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
-        if (companyVoiceRobotic.getCompanyId() == null && loginUser.getCompany() != null) { companyVoiceRobotic.setCompanyId(loginUser.getCompany().getCompanyId()); }
+        companyVoiceRobotic.setCompanyId(loginUser.getCompany().getCompanyId());
         return R.ok().put("data", companyVoiceRoboticService.selectCompanyVoiceRoboticListCompany(companyVoiceRobotic));
     }
     @PreAuthorize("@ss.hasPermi('system:companyVoiceRobotic:list')")
@@ -130,12 +148,27 @@ public class CompanyVoiceRoboticController extends BaseController
     public AjaxResult export(CompanyVoiceRobotic companyVoiceRobotic)
     {
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
-        if (companyVoiceRobotic.getCompanyId() == null && loginUser.getCompany() != null) { companyVoiceRobotic.setCompanyId(loginUser.getCompany().getCompanyId()); }
+        companyVoiceRobotic.setCompanyId(loginUser.getCompany().getCompanyId());
         List<CompanyVoiceRobotic> list = companyVoiceRoboticService.selectCompanyVoiceRoboticListCompany(companyVoiceRobotic);
         ExcelUtil<CompanyVoiceRobotic> util = new ExcelUtil<CompanyVoiceRobotic>(CompanyVoiceRobotic.class);
         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");
+    }
+
     /**
      * 获取机器人外呼任务详细信息
      */
@@ -157,7 +190,7 @@ public class CompanyVoiceRoboticController extends BaseController
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         companyVoiceRobotic.setCreateUser(loginUser.getUser().getUserId());
         companyVoiceRobotic.setCompanyUserId(loginUser.getUser().getUserId());
-        if (companyVoiceRobotic.getCompanyId() == null && loginUser.getCompany() != null) { companyVoiceRobotic.setCompanyId(loginUser.getCompany().getCompanyId()); }
+        companyVoiceRobotic.setCompanyId(loginUser.getCompany().getCompanyId());
         return toAjax(companyVoiceRoboticService.insertCompanyVoiceRobotic(companyVoiceRobotic));
     }
 
@@ -232,7 +265,13 @@ public class CompanyVoiceRoboticController extends BaseController
      */
     @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));
     }
 
     /**
@@ -274,6 +313,7 @@ public class CompanyVoiceRoboticController extends BaseController
      * 启动任务
      */
     @GetMapping("/taskRun")
+    @Log(title = "启动机器人外呼任务", businessType = BusinessType.OTHER)
     public R taskRun(Long id){
 
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
@@ -307,12 +347,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")
@@ -353,8 +394,21 @@ public class CompanyVoiceRoboticController extends BaseController
         });
     }
 
+    @Log(title = "外呼任务暂停操作", businessType = BusinessType.UPDATE)
     @PostMapping("/pauseRoboticActive")
     public R pauseRoboticActive(@RequestBody PauseRoboticActiveParam param){
+        TenantHelper.setTenantId(SecurityUtils.getTenantId());
         return companyVoiceRoboticService.pauseRoboticActive(param);
     }
+
+    /**
+     * 追加客户到运行中的普通任务
+     */
+    @PreAuthorize("@ss.hasPermi('system:companyVoiceRobotic:edit')")
+    @Log(title = "追加客户到运行中任务", businessType = BusinessType.INSERT)
+    @PostMapping("/appendCustomers")
+    public R appendCustomers(@RequestBody AppendCustomersParam param){
+        TenantHelper.setTenantId(SecurityUtils.getTenantId());
+        return companyVoiceRoboticService.appendCustomersToRunningTask(param.getTaskId(), param.getCustomerIds());
+    }
 }

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

@@ -205,4 +205,9 @@ public class CompanyWorkflowController extends BaseController {
         return AjaxResult.success(workflowId);
     }
 
+    @GetMapping("/nodeTypeCodes/{workflowId}")
+    public R getNodeTypeCodes(@PathVariable("workflowId") Long workflowId) {
+        List<String> typeCodes = companyWorkflowService.selectNodeTypeCodesByWorkflowId(workflowId);
+        return R.ok().put("data", typeCodes);
+    }
 }

+ 106 - 15
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,10 +45,24 @@ 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){
         List<CompanyWxAccount> list = companyWxAccountService.selectCompanyWxAccountListCompany(companyWxEnterpriseAccount);
@@ -53,7 +72,7 @@ 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)
@@ -63,10 +82,25 @@ public class CompanyWxAccountController extends BaseController
         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 +110,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,23 +144,65 @@ 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}")
+    @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')")
-	@GetMapping("/companyListAll")
+    @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));
     }
 
     /**

+ 19 - 3
fs-company/src/main/java/com/fs/company/controller/company/EasyCallController.java

@@ -7,6 +7,7 @@ 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.domain.CompanyUser;
 import com.fs.company.mapper.CompanyVoiceCloneRefMapper;
 import com.fs.company.service.easycall.IEasyCallService;
 import com.fs.company.vo.easycall.*;
@@ -99,12 +100,14 @@ public class EasyCallController extends BaseController {
     public R getVoiceCodeList() {
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         Long companyId = loginUser.getUser().getCompanyId();
-
-        List<Long> ttsIds = companyVoiceCloneRefMapper.selectByCompanyIdAndCompanyUserId(companyId, loginUser.getCompany().getUserId());
+        // 管理员查全公司启用的音色关联,子账号仅查自己的
+        Long userId = CompanyUser.isAdmin(loginUser.getUser().getUserType()) ? null : loginUser.getUser().getUserId();
+        List<Long> ttsIds = companyVoiceCloneRefMapper.selectActiveTtsIds(companyId, userId);
         List<CcTtsAliyun> ccTtsAliyuns = companyVoiceCloneRefMapper.selectCcTtsAliyunList();
 
         List<EasyCallVoiceCodeVO> result = ccTtsAliyuns.stream()
                 .filter(item ->
+                        // ttsIds.stream().anyMatch(id -> id.intValue() == item.getId())
                         item.getPriority() == 1 || (item.getPriority() == 0 && ttsIds.contains(item.getId()))
                 )
                 .map(item -> {
@@ -229,7 +232,7 @@ public class EasyCallController extends BaseController {
     public R addCommonCallList(@RequestBody EasyCallCommonAddCallListParam param) {
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         Long companyId = loginUser.getUser().getCompanyId();
-        easyCallService.addCommonCallList(param, companyId);
+        easyCallService.addCommonCallList(param, companyId, null);
         return R.ok("操作成功");
     }
 
@@ -248,4 +251,17 @@ public class EasyCallController extends BaseController {
         String fullUrl = easyCallService.getRecordFileUrl(wavFileUrl, companyId);
         return R.ok().put("data", fullUrl);
     }
+
+    /**
+     * 获取分机号码list
+     * @return
+     */
+    @ApiOperation("获取分机号码list")
+    @GetMapping("/getExtensionList")
+    public R getExtensionList(){
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getUser().getCompanyId();
+        List<ExtensionVO> extensionList = easyCallService.getExtensionList(companyId);
+        return  R.ok().put("data",extensionList);
+    }
 }

+ 53 - 3
fs-company/src/main/java/com/fs/company/controller/crm/CrmCustomerController.java

@@ -21,6 +21,7 @@ import com.fs.crm.service.ICrmCustomerUserService;
 import com.fs.crm.vo.*;
 import com.fs.framework.security.LoginUser;
 import com.fs.framework.service.TokenService;
+import com.fs.his.utils.PhoneUtil;
 import com.github.pagehelper.PageHelper;
 import io.swagger.annotations.ApiOperation;
 import io.swagger.annotations.ApiParam;
@@ -63,6 +64,10 @@ public class CrmCustomerController extends BaseController
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         PageHelper.startPage(param.getPageNum(), param.getPageSize());
         if (param.getCompanyId() == null && loginUser.getCompany() != null) { 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) {
@@ -100,12 +105,20 @@ public class CrmCustomerController extends BaseController
     }
     @PreAuthorize("@ss.hasPermi('crm:customer:list')")
     @GetMapping("/listAll")
-    public R listAll(CrmCustomerListQueryParam crmCustomer){
-        PageHelper.startPage(1, 1000);
+    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);
+        // 回填今日/累计 × 手动/AI 外呼次数(接通/总数),仅本接口调用
+        crmCustomerService.fillCallStats(list);
         if (list != null) {
             for (CrmCustomerListVO vo : list) {
                 if(vo.getMobile()!=null){
@@ -113,7 +126,7 @@ public class CrmCustomerController extends BaseController
                 }
             }
         }
-        return R.ok().put("rows", list);
+        return getDataTable(list);
     }
 
     @ApiOperation("获取我的协作客户列表")
@@ -149,6 +162,10 @@ public class CrmCustomerController extends BaseController
         if(!StringUtils.isEmpty(param.getCreateTimeRange())){
             param.setCustomerCreateTime(param.getCreateTimeRange().split("--"));
         }
+        if(!StringUtils.isEmpty(param.getEncryptedMobile())){
+            param.setMobile(PhoneUtil.encryptPhone(param.getEncryptedMobile()));
+            param.setEncryptedMobile(null);
+        }
         List<CrmMyCustomerListQueryVO> list = crmCustomerService.selectCrmMyCustomerListQuery(param);
         if (list != null) {
             for (CrmMyCustomerListQueryVO vo : list) {
@@ -457,4 +474,37 @@ public class CrmCustomerController extends BaseController
         return R.ok().put("data",list);
     }
 
+    @ApiOperation("获取我的客户手机号")
+    @PreAuthorize("@ss.hasPermi('crm:customer:myList')")
+    @GetMapping("/getMyCustomerPhone")
+    public R getMyCustomerPhone(@RequestParam("customerId") Long customerId){
+        String phone = crmCustomerService.selectCrmCustomerPhoneByCustomerId(customerId);
+        return R.ok(PhoneUtil.decryptPhone(phone));
+    }
+
+
+
+    @PreAuthorize("@ss.hasPermi('crm:customer:list')")
+    @GetMapping("/listNoPage")
+    public TableDataInfo 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("--"));
+        }
+        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 getDataTable(list);
+    }
+
 }

+ 65 - 0
fs-company/src/main/java/com/fs/company/controller/crm/CustomerAllController.java

@@ -0,0 +1,65 @@
+package com.fs.company.controller.crm;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.utils.ServletUtils;
+import com.fs.crm.param.CrmCustomeRecoverParam;
+import com.fs.crm.param.CrmCustomerAllListQueryParam;
+import com.fs.crm.service.ICrmCustomerService;
+import com.fs.crm.vo.CrmCustomerAllListQueryVO;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import com.github.pagehelper.PageHelper;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * @author MixLiu
+ * @date 2026/5/18 13:44
+ * @description 全部客户(含已分配)
+ */
+@RestController
+@RequestMapping("/crm/customerAll")
+public class CustomerAllController extends BaseController {
+
+    @Autowired
+    private ICrmCustomerService crmCustomerService;
+
+    @Autowired
+    private TokenService tokenService;
+
+    @ApiOperation("获取全部客户列表(含已分配)")
+    @PreAuthorize("@ss.hasPermi('crm:customerAll:list')")
+    @GetMapping("/getList")
+    public TableDataInfo getList(CrmCustomerAllListQueryParam param) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        PageHelper.startPage(param.getPageNum(), param.getPageSize());
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        List<CrmCustomerAllListQueryVO> list = crmCustomerService.selectCrmCustomerAllListQuery(param);
+        if (list != null) {
+            for (CrmCustomerAllListQueryVO vo : list) {
+                if (vo.getMobile() != null) {
+                    vo.setMobile(vo.getMobile().replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2"));
+                }
+            }
+        }
+        return getDataTable(list);
+    }
+
+    @ApiOperation("回收公海")
+    @PreAuthorize("@ss.hasPermi('crm:customer:recover')")
+    @PostMapping("/recover")
+    public R recover(@RequestBody CrmCustomeRecoverParam param)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        String operName = loginUser.getUsername();
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        param.setCompanyUserId(loginUser.getUser().getUserId());
+        return crmCustomerService.recover(param, operName);
+    }
+}

+ 111 - 0
fs-company/src/main/java/com/fs/company/controller/crm/ManualOutboundCallLogController.java

@@ -0,0 +1,111 @@
+package com.fs.company.controller.crm;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.company.domain.CrmCustomerCallLog;
+import com.fs.company.service.ICrmCustomerCallLogService;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.security.SecurityUtils;
+import com.fs.his.utils.PhoneUtil;
+import org.apache.commons.lang3.StringUtils;
+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 java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 手动外呼通话记录Controller
+ *
+ * @author MixLiu
+ * @date 2026/5/25 18:23
+ */
+@RestController
+@RequestMapping("/crm/manualOutboundCallLog")
+public class ManualOutboundCallLogController extends BaseController {
+
+    @Autowired
+    private ICrmCustomerCallLogService crmCustomerCallLogService;
+
+    /**
+     * 查询手动外呼通话记录列表(管理员-查看公司全部)
+     */
+    @PreAuthorize("@ss.hasPermi('crm:manualOutboundCall:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(CrmCustomerCallLog crmCustomerCallLog) {
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        crmCustomerCallLog.setCompanyId(loginUser.getCompany().getCompanyId());
+        encryptCallerNum(crmCustomerCallLog);
+        startPage();
+        List<CrmCustomerCallLog> list = crmCustomerCallLogService.selectCrmCustomerCallLogList(crmCustomerCallLog);
+        return getDataTable(list);
+    }
+
+    /**
+     * 查询我的手动外呼通话记录列表(个人-只看自己)
+     */
+    @PreAuthorize("@ss.hasPermi('crm:manualOutboundCall:myList')")
+    @GetMapping("/myList")
+    public TableDataInfo myList(CrmCustomerCallLog crmCustomerCallLog) {
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        crmCustomerCallLog.setCompanyId(loginUser.getCompany().getCompanyId());
+        crmCustomerCallLog.setCompanyUserId(loginUser.getUser().getUserId());
+        encryptCallerNum(crmCustomerCallLog);
+        startPage();
+        List<CrmCustomerCallLog> list = crmCustomerCallLogService.selectCrmCustomerCallLogList(crmCustomerCallLog);
+        return getDataTable(list);
+    }
+
+    /**
+     * 查询手动外呼通话记录计费分钟数总和(管理员-查看公司全部)
+     */
+    @PreAuthorize("@ss.hasPermi('crm:manualOutboundCall:list')")
+    @GetMapping("/sumBillingMinute")
+    public AjaxResult sumBillingMinute(CrmCustomerCallLog crmCustomerCallLog) {
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        crmCustomerCallLog.setCompanyId(loginUser.getCompany().getCompanyId());
+        encryptCallerNum(crmCustomerCallLog);
+        Long sum = crmCustomerCallLogService.selectSumBillingMinute(crmCustomerCallLog);
+        Map<String, Object> result = new HashMap<>();
+        result.put("sumBillingMinute", sum);
+        return AjaxResult.success(result);
+    }
+
+    /**
+     * 查询我的手动外呼通话记录计费分钟数总和(个人-只看自己)
+     */
+    @PreAuthorize("@ss.hasPermi('crm:manualOutboundCall:myList')")
+    @GetMapping("/mySumBillingMinute")
+    public AjaxResult mySumBillingMinute(CrmCustomerCallLog crmCustomerCallLog) {
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        crmCustomerCallLog.setCompanyId(loginUser.getCompany().getCompanyId());
+        crmCustomerCallLog.setCompanyUserId(loginUser.getUser().getUserId());
+        encryptCallerNum(crmCustomerCallLog);
+        Long sum = crmCustomerCallLogService.selectSumBillingMinute(crmCustomerCallLog);
+        Map<String, Object> result = new HashMap<>();
+        result.put("sumBillingMinute", sum);
+        return AjaxResult.success(result);
+    }
+
+    /**
+     * 处理客户号码查询参数,区分明文和加密两种匹配方式:
+     * 1. 手机(callerNum):输入明文号码,不加密,直接匹配 caller_num 字段(匹配历史明文数据)
+     * 2. 加密手机(encryptedCallerNum):输入明文号码,加密后设为 callerNum 匹配密文数据,
+     *    同时将原始明文保留到 plainCallerNum 匹配历史明文数据(OR条件覆盖两种存储格式)
+     * Mapper SQL: AND (caller_num = #{callerNum} OR caller_num = #{plainCallerNum})
+     */
+    private void encryptCallerNum(CrmCustomerCallLog param) {
+        if (StringUtils.isNotBlank(param.getEncryptedCallerNum())) {
+            // 加密手机输入:加密后匹配密文,原始明文保留匹配历史明文(OR覆盖)
+            param.setPlainCallerNum(param.getEncryptedCallerNum());
+            param.setCallerNum(PhoneUtil.encryptPhone(param.getEncryptedCallerNum()));
+            param.setEncryptedCallerNum(null);
+        }
+        // 手机输入:不加密,直接传明文匹配历史明文数据
+    }
+}

+ 117 - 0
fs-company/src/main/java/com/fs/company/controller/sensitive/CompanyAiSensitiveWordController.java

@@ -0,0 +1,117 @@
+package com.fs.company.controller.sensitive;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import com.fs.sensitive.domain.CompanyAiSensitiveWord;
+import com.fs.sensitive.service.ICompanyAiSensitiveWordService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 敏感词库Controller
+ *
+ * @author fs
+ */
+@RestController
+@RequestMapping("/sensitive/word")
+public class CompanyAiSensitiveWordController extends BaseController {
+
+    @Autowired
+    private ICompanyAiSensitiveWordService companyAiSensitiveWordService;
+    @Autowired
+    private TokenService tokenService;
+
+    /**
+     * 查询敏感词列表(当前公司)
+     */
+    @PreAuthorize("@ss.hasPermi('sensitive:word:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(CompanyAiSensitiveWord companyAiSensitiveWord) {
+        companyAiSensitiveWord.setCompanyId(getCurrentCompanyId());
+        startPage();
+        List<CompanyAiSensitiveWord> list = companyAiSensitiveWordService.selectCompanyAiSensitiveWordList(companyAiSensitiveWord);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出敏感词列表
+     */
+    @PreAuthorize("@ss.hasPermi('sensitive:word:export')")
+    @Log(title = "敏感词库", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(CompanyAiSensitiveWord companyAiSensitiveWord) {
+        companyAiSensitiveWord.setCompanyId(getCurrentCompanyId());
+        List<CompanyAiSensitiveWord> list = companyAiSensitiveWordService.selectCompanyAiSensitiveWordList(companyAiSensitiveWord);
+        ExcelUtil<CompanyAiSensitiveWord> util = new ExcelUtil<>(CompanyAiSensitiveWord.class);
+        return util.exportExcel(list, "敏感词数据");
+    }
+
+    /**
+     * 获取敏感词详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('sensitive:word:query')")
+    @GetMapping(value = "/{wordId}")
+    public AjaxResult getInfo(@PathVariable("wordId") Long wordId) {
+        return AjaxResult.success(companyAiSensitiveWordService.selectCompanyAiSensitiveWordByWordId(wordId));
+    }
+
+    /**
+     * 新增敏感词
+     */
+    @PreAuthorize("@ss.hasPermi('sensitive:word:add')")
+    @Log(title = "敏感词库", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody CompanyAiSensitiveWord companyAiSensitiveWord) {
+        companyAiSensitiveWord.setCompanyId(getCurrentCompanyId());
+        int ret = companyAiSensitiveWordService.insertCompanyAiSensitiveWord(companyAiSensitiveWord);
+        if (ret == -1) {
+            return AjaxResult.error("该公司下该敏感词已存在");
+        }
+        return toAjax(ret);
+    }
+
+    /**
+     * 修改敏感词
+     */
+    @PreAuthorize("@ss.hasPermi('sensitive:word:edit')")
+    @Log(title = "敏感词库", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody CompanyAiSensitiveWord companyAiSensitiveWord) {
+        return toAjax(companyAiSensitiveWordService.updateCompanyAiSensitiveWord(companyAiSensitiveWord));
+    }
+
+    /**
+     * 启用/禁用
+     */
+    @PreAuthorize("@ss.hasPermi('sensitive:word:edit')")
+    @Log(title = "敏感词库", businessType = BusinessType.UPDATE)
+    @PutMapping("/changeEnabled")
+    public AjaxResult changeEnabled(@RequestBody CompanyAiSensitiveWord companyAiSensitiveWord) {
+        return toAjax(companyAiSensitiveWordService.changeEnabled(companyAiSensitiveWord.getWordId(), companyAiSensitiveWord.getEnabled()));
+    }
+
+    /**
+     * 删除敏感词
+     */
+    @PreAuthorize("@ss.hasPermi('sensitive:word:remove')")
+    @Log(title = "敏感词库", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{wordIds}")
+    public AjaxResult remove(@PathVariable Long[] wordIds) {
+        return toAjax(companyAiSensitiveWordService.deleteCompanyAiSensitiveWordByWordIds(wordIds));
+    }
+
+    private Long getCurrentCompanyId() {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        return loginUser.getUser().getCompanyId();
+    }
+}

+ 7 - 0
fs-service/pom.xml

@@ -362,6 +362,13 @@
             <artifactId>oshi-core</artifactId>
         </dependency>
 
+        <!-- 敏感词匹配:AhoCorasick 双数组 Trie(DAT-AC),Apache-2.0 协议 -->
+        <dependency>
+            <groupId>com.hankcs</groupId>
+            <artifactId>aho-corasick-double-array-trie</artifactId>
+            <version>1.2.3</version>
+        </dependency>
+
     </dependencies>
 
 </project>

+ 21 - 0
fs-service/src/main/java/com/fs/aiSipCall/RemoteCommon.java

@@ -95,6 +95,11 @@ public class RemoteCommon {
      */
     public static final String QUERY_OUTBOUNDCDR_LIST_API = "/aicall/api/outboundcdrList";
 
+    /**
+     * 登录外呼平台接口
+     */
+    public static final String AI_CALL_LOGIN_API = "/login";
+
     /**
      * 发送get请求
      * @param url   地址
@@ -123,4 +128,20 @@ public class RemoteCommon {
         }
         return null;
     }
+
+    /**
+     * 发送POST表单提交请求
+     * @param url   地址
+     * @param params 表单参数
+     * @return  String  结果
+     */
+    public static String sendPostForm(String url, java.util.Map<String, Object> params){
+        try{
+            return HttpUtil.post(url, params, 10 * 1000);
+        }catch (Exception e){
+            e.printStackTrace();
+            log.info("sendPostForm error");
+        }
+        return null;
+    }
 }

+ 38 - 0
fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipCallOutboundCdr.java

@@ -94,4 +94,42 @@ public class AiSipCallOutboundCdr implements Serializable {
     private String endTimeStr;
     private String timeLenSec;
     private String timeLenValidStr;
+    /** 录音文件URL */
+    private String wavfile;
+
+    /** 外呼类型(0销售后台 1总后台 3APP) */
+    @Excel(name = "外呼类型", readConverterExp = "0=销售后台,1=总后台,3=APP")
+    private String sourceType;
+
+    /** 客户ID */
+    @Excel(name = "客户ID")
+    private Long customerId;
+
+    /** 销售公司ID */
+    @Excel(name = "销售公司ID")
+    private Long companyId;
+
+    /** 销售ID */
+    @Excel(name = "销售ID")
+    private Long companyUserId;
+
+    /** 销售公司名称 */
+    @Excel(name = "销售公司名称")
+    private String companyName;
+
+    /** 销售账号 */
+    @Excel(name = "销售账号")
+    private String companyUserName;
+
+    /** 总后台用户ID */
+    @Excel(name = "总后台用户ID")
+    private Long sysUserId;
+
+    /** 总后台用户账号 */
+    @Excel(name = "总后台用户账号")
+    private String sysUserName;
+
+    /** 状态(0正常 1删除) */
+    private Integer status;
+
 }

+ 14 - 1
fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipCallUser.java

@@ -90,7 +90,7 @@ public class AiSipCallUser extends BaseEntity{
 
     /** 绑定的分机号 */
     @Excel(name = "绑定的分机号")
-    private Long extNum;
+    private String extNum;
     /** 网关字符串 */
     @Excel(name = "网关字符串")
     private String gatewayIds;
@@ -98,5 +98,18 @@ public class AiSipCallUser extends BaseEntity{
     private Long companyId;
     private Long companyUserId;
 
+    /** 用户来源0销售 1总后台 */
+    private String userSource;
+    /** 总后台用户ID */
+    private Long sysUserId;
+    /** 总后台用户账号 */
+    private String sysUserName;
+    /** 分机密码(SIP注册必需) */
+    private String extPass;
+    /** 分机绑定用户工号 */
+    private String userCode;
+    /** 销售公司名称 */
+    private String companyName;
+
 
 }

+ 4 - 0
fs-service/src/main/java/com/fs/aiSipCall/param/ApiCallRecordByUuidQueryParams.java

@@ -29,5 +29,9 @@ public class ApiCallRecordByUuidQueryParams implements Serializable {
 
     private String intent;
 
+    private Long customerId;
+
+    private Long tenantId;
+
 
 }

+ 16 - 1
fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallOutboundCdrService.java

@@ -5,6 +5,7 @@ import com.fs.aiSipCall.domain.AiSipCallOutboundCdr;
 import com.fs.aiSipCall.domain.CcCustInfo;
 import com.fs.aiSipCall.param.ApiCallRecordByUuidQueryParams;
 import com.fs.common.core.domain.AjaxResult;
+import com.fs.company.vo.easycall.EasyCallOutBoundVO;
 
 import java.util.List;
 import java.util.concurrent.CompletableFuture;
@@ -70,5 +71,19 @@ public interface IAiSipCallOutboundCdrService extends IService<AiSipCallOutbound
 
     CompletableFuture<String> scheduledGetCallRecord();
 
-    int syncByUuid(ApiCallRecordByUuidQueryParams req);
+    int syncByUuid(ApiCallRecordByUuidQueryParams req, EasyCallOutBoundVO callPhoneRes);
+    /**
+     * 获取手动外呼客户沟通信息(带拨号模式 dialMode)
+     * <p>当 dialMode = "encrypted" 时,phoneNum 为密文,需先解密再透传给 IPCC。
+     * <p>本方法为 his_java 兼容方法,原 3 参方法保留不动。
+     */
+    AjaxResult getCustCommunicationInfo(String phoneNum, Integer callType, String uuid, String dialMode);
+
+    /**
+     * 通话挂断后根据UUID同步话单(迁移自 his_java,APP/PC软电话挂机回调使用)
+     * <p>调用方需先填好 sourceType / companyId / companyUserId / companyUserName / status / sysUserId 等字段。
+     */
+    void callEndSyncByUuid(AiSipCallOutboundCdr request);
+
+    int syncCrmCustomerCallLogByUuid(ApiCallRecordByUuidQueryParams req, EasyCallOutBoundVO callPhoneRes);
 }

+ 36 - 0
fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallUserService.java

@@ -2,6 +2,7 @@ package com.fs.aiSipCall.service;
 
 import com.baomidou.mybatisplus.extension.service.IService;
 import com.fs.aiSipCall.domain.AiSipCallUser;
+import com.fs.aiSipCall.vo.AiSipCallUserNewVO;
 import com.fs.common.core.domain.AjaxResult;
 
 import java.util.List;
@@ -71,4 +72,39 @@ public interface IAiSipCallUserService extends IService<AiSipCallUser>{
      * @return AjaxResult 结果
      */
     AjaxResult getToolbarBasicParam(Map<String,String> param);
+
+    /**
+     * 根据公司找到绑定网关
+     * @param companyId
+     * @return
+     */
+    String getGateWayIdListByCompanyId(Long companyId);
+
+    /**
+     * 坐席登录IPCC外呼平台(迁移自 his_java,APP/PC软电话工具条登录使用)
+     * @param param 包含 username/password/extNum 等表单参数
+     */
+    AjaxResult agentLogin(java.util.Map<String, Object> param);
+
+    /**
+     * 查询分机号码改
+     * @param companyId
+     * @return
+     */
+    AjaxResult getUnBindExtnumNew(Long companyId,Long sipUserId);
+
+    /**
+     * 修改sip用户信息New
+     * @param aiSipCallUser
+     * @return
+     */
+    AiSipCallUserNewVO updateAiSipCallUserNew(AiSipCallUser aiSipCallUser);
+
+    /**
+     *新增sip用户信息New
+     * @param aiSipCallUser
+     * @return
+     */
+    int insertAiSipCallUserNew(AiSipCallUser aiSipCallUser);
+
 }

+ 163 - 9
fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallOutboundCdrServiceImpl.java

@@ -20,13 +20,14 @@ import com.fs.common.core.redis.RedisCache;
 import com.fs.company.domain.CompanyAiWorkflowExec;
 import com.fs.company.domain.CompanyVoiceRoboticBusiness;
 import com.fs.company.domain.CompanyVoiceRoboticCallLogCallphone;
-import com.fs.company.mapper.CompanyAiWorkflowExecMapper;
-import com.fs.company.mapper.CompanyVoiceRoboticBusinessMapper;
-import com.fs.company.mapper.CompanyVoiceRoboticCallLogCallphoneMapper;
-import com.fs.company.mapper.EasyCallMapper;
+import com.fs.company.domain.CrmCustomerCallLog;
+import com.fs.company.mapper.*;
 import com.fs.company.service.CompanyWorkflowEngine;
 import com.fs.company.vo.CidConfigVO;
 import com.fs.company.vo.easycall.EasyCallOutBoundVO;
+import com.fs.his.utils.PhoneUtil;
+import com.fs.sensitive.DTO.AgentSensitiveWordDetectResultDTO;
+import com.fs.sensitive.component.AgentSensitiveWordDetector;
 import com.fs.system.service.ISysConfigService;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
@@ -36,6 +37,7 @@ import org.springframework.stereotype.Service;
 
 import java.math.BigDecimal;
 import java.math.RoundingMode;
+import java.net.URLDecoder;
 import java.util.*;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -77,7 +79,11 @@ public class AiSipCallOutboundCdrServiceImpl extends ServiceImpl<AiSipCallOutbou
     private static final BigDecimal DEFAULT_CALL_CHARGE = new BigDecimal("0.12");
     private static final BigDecimal ONE_MINUTES_SECOND = new BigDecimal("60");
 
+    @Autowired
+    private CrmCustomerCallLogMapper crmCustomerCallLogMapper;
 
+    @Autowired
+    private AgentSensitiveWordDetector agentSensitiveWordDetector;
 
     @Override
     public AiSipCallOutboundCdr selectAiSipCallOutboundCdrById(String id) {
@@ -184,6 +190,79 @@ public class AiSipCallOutboundCdrServiceImpl extends ServiceImpl<AiSipCallOutbou
         }
         return AjaxResult.error();
     }
+    /**
+     * 获取手动外呼客户沟通信息(带拨号模式 dialMode,迁移自 his_java)
+     */
+    @Override
+    public AjaxResult getCustCommunicationInfo(String phoneNum, Integer callType, String uuid, String dialMode) {
+        if (StringUtils.isNotBlank(dialMode) && "encrypted".equals(dialMode)) {
+            //密文需要解密
+            phoneNum = PhoneUtil.decryptPhone(phoneNum);
+        }
+        String paramStr = "?phoneNum=" + phoneNum + "&callType=" + callType + "&uuid=" + uuid;
+        String result = RemoteCommon.sendGet(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.GET_CUST_COMMUNICATION_INFO_API + paramStr);
+        String msg;
+        if (StringUtils.isNotBlank(result)) {
+            JSONObject jsonObject = JSONObject.parseObject(result);
+            if (jsonObject.getInteger("code") == 0) {
+                return JSONObject.parseObject(result, AjaxResult.class);
+            } else {
+                msg = "获取手动外呼客户沟通信息失败:" + jsonObject.getString("msg");
+            }
+        } else {
+            msg = "获取手动外呼客户沟通信息失败:接口返回为空";
+        }
+        return AjaxResult.error(msg);
+    }
+
+    /**
+     * 通话挂断后根据UUID同步话单(迁移自 his_java,APP/PC软电话挂机回调使用)
+     */
+    @Override
+    public void callEndSyncByUuid(AiSipCallOutboundCdr request) {
+        String result = RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.QUERY_OUTBOUNDCDR_LIST_API, JSONObject.toJSONString(request));
+        if (StringUtils.isNotBlank(result)) {
+            JSONObject jsonObject = JSONObject.parseObject(result);
+            Integer code = jsonObject.getInteger("code");
+            if (code != null && code == 0) {
+                String rows = jsonObject.getString("rows");
+                if (StringUtils.isNotBlank(rows)) {
+                    List<AiSipCallOutboundCdr> list = JSONObject.parseArray(rows, AiSipCallOutboundCdr.class);
+                    if (list != null && !list.isEmpty()) {
+                        AiSipCallOutboundCdr data = list.get(0);
+                        data.setSourceType("3");
+                        data.setCompanyId(request.getCompanyId());
+                        data.setCompanyUserId(request.getCompanyUserId());
+                        data.setCompanyUserName(request.getCompanyUserName());
+                        data.setStatus(request.getStatus());
+                        data.setSysUserId(request.getSysUserId());
+                        data.setSysUserName(request.getSysUserName());
+                        data.setCustomerId(request.getCustomerId());
+                        //拼接数据
+                        String filename = data.getRecordFilename().startsWith("/") ? data.getRecordFilename().substring(1) : data.getRecordFilename();
+                        data.setWavfile("/recordings/files?filename=" + filename);
+                        // 对opnum字段进行URL解码,防止乱码
+                        if (StringUtils.isNotBlank(data.getOpnum())) {
+                            try {
+                                data.setOpnum(URLDecoder.decode(data.getOpnum(), "UTF-8"));
+                            } catch (Exception e) {
+                                log.error("opnum字段URL解码失败,原值:{}", data.getOpnum(), e);
+                            }
+                        }
+                        this.save(data);
+                    } else {
+                        log.error("获取手动外呼记录接口转化数据失败:原数据:{},原因:{}", rows, jsonObject.getString("msg"));
+                    }
+                } else {
+                    log.error("获取手动外呼记录接口获取rows失败:原因:{}", jsonObject.getString("msg"));
+                }
+            } else {
+                log.error("同步手动外呼记录接口失败:返回状态码:{},原因:{}", code, jsonObject.getString("msg"));
+            }
+        } else {
+            log.error("同步手动外呼记录接口失败:无返回结果");
+        }
+    }
 
     @Override
     public AjaxResult addCustcallrecord(CcCustInfo ccCustInfo) {
@@ -483,7 +562,7 @@ public class AiSipCallOutboundCdrServiceImpl extends ServiceImpl<AiSipCallOutbou
     }
 
     @Override
-    public int syncByUuid(ApiCallRecordByUuidQueryParams req) {
+    public int syncByUuid(ApiCallRecordByUuidQueryParams req,EasyCallOutBoundVO callPhoneRes1) {
         if (req == null || StringUtils.isBlank(req.getUuid())) {
 //            throw new ServiceException("uuid不能为空");
         }
@@ -531,9 +610,22 @@ public class AiSipCallOutboundCdrServiceImpl extends ServiceImpl<AiSipCallOutbou
         companyVoiceRoboticCallLogCallphone.setIntention(req.getIntent());
         companyVoiceRoboticCallLogCallphone.setCompanyId(req.getCompanyId());
         companyVoiceRoboticCallLogCallphone.setCompanyUserId(req.getCompanyUserId());
-        companyVoiceRoboticCallLogCallphone.setCallTime(Long.valueOf(callPhoneRes.getTimeLen()));
+        companyVoiceRoboticCallLogCallphone.setCreateTime(new Date());
+        if(null != callPhoneRes.getTimeLenValid() && Integer.valueOf(0).compareTo(callPhoneRes.getTimeLenValid()) < 0){
+            BigDecimal divide = new BigDecimal(callPhoneRes.getTimeLenValid()).divide(new BigDecimal(1000), 0, RoundingMode.CEILING);
+            companyVoiceRoboticCallLogCallphone.setCallTime(divide.longValue());
+        }
+        companyVoiceRoboticCallLogCallphone.setCallTime(Long.valueOf(callPhoneRes.getTimeLenValid()));
         companyVoiceRoboticCallLogCallphone.setCallType(Integer.valueOf(callType));
 
+        //检测敏感词语
+        AgentSensitiveWordDetectResultDTO resultDTO = agentSensitiveWordDetector.detectAndHighlight(req.getUuid(), req.getTenantId(), callPhoneRes.getChatContent());
+        if(resultDTO != null && resultDTO.getCheckBoolean()){
+            companyVoiceRoboticCallLogCallphone.setIsWarning(1);
+            companyVoiceRoboticCallLogCallphone.setContentList(resultDTO.getProcessText());
+            companyVoiceRoboticCallLogCallphone.setViolationNum(resultDTO.getViolationCount());
+        }
+
         String json = configService.selectConfigByKey("cId.config");
         CidConfigVO cidConfigVO = JSONUtil.toBean(json, CidConfigVO.class);
         BigDecimal callCharge = cidConfigVO.getCallCharge();
@@ -542,9 +634,14 @@ public class AiSipCallOutboundCdrServiceImpl extends ServiceImpl<AiSipCallOutbou
             callCharge = DEFAULT_CALL_CHARGE;
         }
         //向上取整分钟数
-        BigDecimal divide = new BigDecimal(companyVoiceRoboticCallLogCallphone.getCallTime()).divide(ONE_MINUTES_SECOND, 0, RoundingMode.CEILING);
-        BigDecimal multiply = divide.multiply(callCharge);
-        companyVoiceRoboticCallLogCallphone.setCost(multiply);
+        Long callTime = companyVoiceRoboticCallLogCallphone.getCallTime();
+        if (callTime != null) {
+            // 毫秒转秒
+//            BigDecimal callTimeSecond = new BigDecimal(companyVoiceRoboticCallLogCallphone.getCallTime()).divide(new BigDecimal(1000), 0, RoundingMode.CEILING);
+            BigDecimal divide = new BigDecimal(callTime).divide(ONE_MINUTES_SECOND, 0, RoundingMode.CEILING);
+            BigDecimal multiply = divide.multiply(callCharge);
+            companyVoiceRoboticCallLogCallphone.setCost(multiply);
+        }
 
 
         int i = companyVoiceRoboticCallLogCallphoneMapper.insertCompanyVoiceRoboticCallLogCallphone(companyVoiceRoboticCallLogCallphone);
@@ -560,5 +657,62 @@ public class AiSipCallOutboundCdrServiceImpl extends ServiceImpl<AiSipCallOutbou
         return i;
     }
 
+    @Override
+    public int syncCrmCustomerCallLogByUuid(ApiCallRecordByUuidQueryParams req,EasyCallOutBoundVO callPhoneRes) {
+        String callType = StringUtils.isBlank(req.getCallType()) ? "03" : req.getCallType();
+
+        CrmCustomerCallLog callLog = new CrmCustomerCallLog();
+        callLog.setRunTime(callPhoneRes.getStartTime() == null ? new Date() : new Date(callPhoneRes.getStartTime()));
+        callLog.setRunParam(null);
+        callLog.setResult(null);
+        callLog.setStatus(req.getStatus());
+        callLog.setCreateTime(new Date());
+        callLog.setRecordPath(callPhoneRes.getRecordFilename());
+        callLog.setContentList(callPhoneRes.getChatContent());
+        callLog.setCallerNum(PhoneUtil.encryptPhone(callPhoneRes.getCallee()));
+        callLog.setCalleeNum(callPhoneRes.getCaller());
+        callLog.setUuid(req.getUuid());
+        callLog.setCallCreateTime(callPhoneRes.getStartTime());
+        callLog.setCallAnswerTime(callPhoneRes.getAnsweredTime());
+        callLog.setIntention(req.getIntent());
+        callLog.setCompanyId(req.getCompanyId());
+        callLog.setCompanyUserId(req.getCompanyUserId());
+        callLog.setCustomerId(req.getCustomerId());
+        if (callPhoneRes.getTimeLen() != null) {
+            callLog.setCallTime(Long.valueOf(callPhoneRes.getTimeLenValid()));
+        }
+        callLog.setCallType(Integer.valueOf(callType));
+
+        BigDecimal callCharge = DEFAULT_CALL_CHARGE;
+        String json = configService.selectConfigByKey("cId.config");
+        if (StringUtils.isNotBlank(json)) {
+            try {
+                CidConfigVO cidConfigVO = JSONUtil.toBean(json, CidConfigVO.class);
+
+                if (cidConfigVO != null && cidConfigVO.getCallCharge() != null) {
+                    callCharge = cidConfigVO.getCallCharge();
+                }
+            } catch (Exception e) {
+                log.error("解析 cId.config 配置失败", e);
+            }
+        }
+
+        if (callCharge == null) {
+            callCharge = DEFAULT_CALL_CHARGE;
+        }
+
+        Long callTime = callLog.getCallTime();
+        if (callTime != null) {
+            // 毫秒转秒
+            BigDecimal callTimeSecond = new BigDecimal(callTime).divide(new BigDecimal(1000), 0, RoundingMode.CEILING);
+            BigDecimal minuteCount = callTimeSecond.divide(ONE_MINUTES_SECOND, 0, RoundingMode.CEILING);
+            BigDecimal cost = minuteCount.multiply(callCharge);
+            callLog.setCost(cost);
+            callLog.setBillingMinute(minuteCount.intValue());
+        }
+
+        return crmCustomerCallLogMapper.insertCrmCustomerCallLog(callLog);
+    }
+
 
 }

+ 169 - 0
fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallUserServiceImpl.java

@@ -6,13 +6,21 @@ import com.fs.aiSipCall.RemoteCommon;
 import com.fs.aiSipCall.domain.AiSipCallUser;
 import com.fs.aiSipCall.mapper.AiSipCallUserMapper;
 import com.fs.aiSipCall.service.IAiSipCallUserService;
+import com.fs.aiSipCall.vo.AiSipCallUserNewVO;
 import com.fs.common.core.domain.AjaxResult;
+import com.fs.company.domain.CompanyExtensionBind;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.mapper.CompanyBindGatewayMapper;
 import com.fs.company.mapper.CompanyUserMapper;
+import com.fs.company.service.ICompanyExtensionBindService;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
+import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -29,6 +37,15 @@ public class AiSipCallUserServiceImpl extends ServiceImpl<AiSipCallUserMapper, A
     @Autowired
     private CompanyUserMapper companyUserMapper;
 
+    @Autowired
+    CompanyBindGatewayMapper companyBindGatewayMapper;
+
+    @Autowired
+    private ICompanyExtensionBindService companyExtensionBindService;
+
+    @Autowired
+    private AiSipCallUserMapper aiSipCallUserMapper;
+
     /**
      * 查询sip用户信息
      * 
@@ -82,6 +99,9 @@ public class AiSipCallUserServiceImpl extends ServiceImpl<AiSipCallUserMapper, A
                     if(remoteAiSipCallUser.getCreateTime() != null){
                         aiSipCallUser.setCreateTime(remoteAiSipCallUser.getCreateTime());
                     }
+                    if(StringUtils.isNotBlank(remoteAiSipCallUser.getExtPass())){
+                        aiSipCallUser.setExtPass(remoteAiSipCallUser.getExtPass());
+                    }
                     int i = baseMapper.insertAiSipCallUser(aiSipCallUser);
                     if( i> 0){
                         //绑定companyUser的aiSIP外呼用户
@@ -115,6 +135,15 @@ public class AiSipCallUserServiceImpl extends ServiceImpl<AiSipCallUserMapper, A
         if(StringUtils.isNotBlank(result)){
             JSONObject jsonObject = JSONObject.parseObject(result);
             if(jsonObject.getInteger("code") == 0){
+                String data = jsonObject.getString("data");
+                AiSipCallUser remoteAiSipCallUser = JSONObject.parseObject(data, AiSipCallUser.class);
+                if(remoteAiSipCallUser != null){
+                    if(StringUtils.isNotBlank(remoteAiSipCallUser.getExtPass())){
+                        aiSipCallUser.setExtPass(remoteAiSipCallUser.getExtPass());
+                    }
+                }else{
+                    log.error("新增时解析aiSIP外呼用户数据为空");
+                }
                 return baseMapper.updateAiSipCallUser(aiSipCallUser);
             }else{
                 log.error("修改aiSIP外呼任务失败:{}", jsonObject.getString("msg"));
@@ -181,4 +210,144 @@ public class AiSipCallUserServiceImpl extends ServiceImpl<AiSipCallUserMapper, A
         return AjaxResult.error();
     }
 
+    @Override
+    public String getGateWayIdListByCompanyId(Long companyId){
+        String gateWayIdListByCompanyId = companyBindGatewayMapper.getGateWayIdListByCompanyId(companyId);
+        if(StringUtils.isNotBlank(gateWayIdListByCompanyId)){
+            return gateWayIdListByCompanyId;
+        }
+        return "";
+    }
+
+    /**
+     * 坐席登录IPCC外呼平台(迁移自 his_java)
+     */
+    @Override
+    public AjaxResult agentLogin(Map<String, Object> param) {
+        String result = RemoteCommon.sendPostForm(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.AI_CALL_LOGIN_API, param);
+        String msg;
+        if (StringUtils.isNotBlank(result)) {
+            JSONObject jsonObject = JSONObject.parseObject(result);
+            Integer code = jsonObject.getInteger("code");
+            if (code != null && code == 0) {
+                return JSONObject.parseObject(result, AjaxResult.class);
+            } else {
+                msg = jsonObject.getString("msg");
+            }
+        } else {
+            msg = "登录外呼平台接口失败:接口返回为空";
+        }
+        return AjaxResult.error(msg);
+    }
+
+    /**
+     * 查询分机号码改
+     * @param companyId
+     * @param sipUserId
+     * @return
+     */
+    @Override
+    public AjaxResult getUnBindExtnumNew(Long companyId,Long sipUserId){
+        AiSipCallUser aiSipCallUser = aiSipCallUserMapper.selectAiSipCallUserByUserId(sipUserId);
+        List<CompanyExtensionBind> unBindList = companyExtensionBindService.selectUnBindAndSelfByCompanyId(companyId, aiSipCallUser!=null ? aiSipCallUser.getCompanyUserId() : -1L);
+        List<Map<String, Object>> resultList = new ArrayList<>();
+        for (CompanyExtensionBind bind : unBindList) {
+            Map<String, Object> map = new HashMap<>();
+            map.put("extId", bind.getExtId());
+            map.put("extNum", bind.getExtensionNum());
+            resultList.add(map);
+        }
+        return AjaxResult.success(resultList);
+    }
+
+    /**
+     * 修改sip用户信息New
+     * @param aiSipCallUser
+     * @return
+     */
+    @Override
+    @Transactional
+    public AiSipCallUserNewVO updateAiSipCallUserNew(AiSipCallUser aiSipCallUser){
+        AiSipCallUserNewVO result= new AiSipCallUserNewVO();
+        CompanyExtensionBind bind = companyExtensionBindService.selectUnBindByExtNum(String.valueOf(aiSipCallUser.getExtNum()), aiSipCallUser.getCompanyId());
+        if (bind != null && (bind.getCompanyUserId() == null || bind.getCompanyUserId().equals(aiSipCallUser.getCompanyUserId()))) {
+            AiSipCallUser oldUser = aiSipCallUserMapper.selectAiSipCallUserByUserId(aiSipCallUser.getUserId());
+            String oldExtNum = (oldUser != null) ? oldUser.getExtNum() : null;
+            String newExtNum = aiSipCallUser.getExtNum();
+            result.setNewExtNum(newExtNum);
+            result.setOldExtNum(oldExtNum);
+            result.setNewUserCode(aiSipCallUser.getLoginName());
+            if(StringUtils.isEmpty(oldUser.getExtPass()) && StringUtils.isNotBlank(bind.getExtensionPass())){
+                aiSipCallUser.setExtPass(bind.getExtensionPass());
+            }
+            int rows = aiSipCallUserMapper.updateAiSipCallUser(aiSipCallUser);
+            //解除绑定
+            companyExtensionBindService.clearBindByExtNum(oldExtNum, aiSipCallUser.getCompanyId(), aiSipCallUser.getCompanyUserId());
+            //绑定新分机号
+            companyExtensionBindService.updateBindByExtId(bind.getExtId(), aiSipCallUser.getCompanyUserId(), aiSipCallUser.getLoginName());
+        } else {
+            throw new RuntimeException("分机号已被绑定,请刷新后重试");
+        }
+        return result;
+
+//        AiSipCallUser oldUser = baseMapper.selectAiSipCallUserByUserId(aiSipCallUser.getUserId());
+//        String oldExtNum = (oldUser != null) ? oldUser.getExtNum() : null;
+//        String newExtNum = aiSipCallUser.getExtNum();
+//
+//        int rows = baseMapper.updateAiSipCallUser(aiSipCallUser);
+//        if (rows > 0) {
+//            if (oldExtNum != null && !oldExtNum.equals(newExtNum)) {
+//                ccExtNumMapper.updateUserCodeByExtNum(oldExtNum, null);
+//                companyExtensionBindService.clearBindByExtNum(String.valueOf(oldExtNum), aiSipCallUser.getCompanyId());
+//            }
+//            if (newExtNum != null) {
+//                ccExtNumMapper.updateUserCodeByExtNum(newExtNum, aiSipCallUser.getLoginName());
+//                CompanyExtensionBind bind = companyExtensionBindService.selectUnBindByExtNum(String.valueOf(newExtNum), aiSipCallUser.getCompanyId());
+//                if (bind != null) {
+//                    companyExtensionBindService.updateBindByExtId(bind.getExtId(), aiSipCallUser.getCompanyUserId(), aiSipCallUser.getLoginName());
+//                }
+//            }
+//        }
+//        return rows;
+    }
+
+    /**
+     * 新增sip用户信息New
+     * @param aiSipCallUser
+     * @return
+     */
+    @Override
+    @Transactional
+    public int insertAiSipCallUserNew(AiSipCallUser aiSipCallUser){
+        CompanyUser companyUser = companyUserMapper.selectCompanyUserByCompanyUserId(aiSipCallUser.getCompanyUserId());
+        if (aiSipCallUser.getCompanyId() == null) {
+            aiSipCallUser.setCompanyId(companyUser.getCompanyId());
+        }
+//        if(aiSipCallUser.getUserId() == null){
+//            aiSipCallUser.setUserId(companyUser.getUserId());
+//        }
+
+        int rows = baseMapper.insertAiSipCallUser(aiSipCallUser);
+        if (rows > 0) {
+            if (aiSipCallUser.getExtNum() != null) {
+//                ccExtNumMapper.updateUserCodeByExtNum(aiSipCallUser.getExtNum(), aiSipCallUser.getLoginName());
+                CompanyExtensionBind bind = companyExtensionBindService.selectUnBindByExtNum(String.valueOf(aiSipCallUser.getExtNum()), aiSipCallUser.getCompanyId());
+                if (bind != null && bind.getCompanyUserId() == null) {
+                    companyExtensionBindService.updateBindByExtId(bind.getExtId(), aiSipCallUser.getCompanyUserId(), aiSipCallUser.getLoginName());
+                }else{
+                    throw new RuntimeException("分机号已被绑定,请刷新后重试");
+                }
+                //获取分机密码
+                if(bind.getExtensionPass() != null){
+                    aiSipCallUser.setExtPass(bind.getExtensionPass());
+                    baseMapper.updateAiSipCallUser(aiSipCallUser);
+                }
+            }
+            if (aiSipCallUser.getCompanyUserId() != null && aiSipCallUser.getUserId() != null) {
+                companyUserMapper.updateCompanyUserByAiSipCall(aiSipCallUser.getCompanyUserId(), aiSipCallUser.getUserId());
+            }
+        }
+        return rows;
+    }
+
 }

+ 18 - 0
fs-service/src/main/java/com/fs/aiSipCall/vo/AiSipCallUserNewVO.java

@@ -0,0 +1,18 @@
+package com.fs.aiSipCall.vo;
+
+import lombok.Data;
+
+/**
+ * @author MixLiu
+ * @date 2026/5/20 09:49
+ * @description
+ */
+@Data
+public class AiSipCallUserNewVO {
+
+    private String oldExtNum;
+
+    private String newExtNum;
+
+    private String newUserCode;
+}

+ 8 - 0
fs-service/src/main/java/com/fs/aicall/domain/CcLlmAgentAccount.java

@@ -64,5 +64,13 @@ public class CcLlmAgentAccount implements Serializable {
 
     /** 模型ID列表,用于IN查询 */
     private List<Long> modelIds;
+    /** 绑定公司ID列表(展示字段) */
+    private List<Long> companyIds;
+
+    /** 绑定公司ID,逗号分隔(展示字段) */
+    private String companyId;
+
+    /** 绑定公司名称,逗号分隔(展示字段) */
+    private String companyName;
 
 }

+ 46 - 0
fs-service/src/main/java/com/fs/company/domain/CompanyExtensionBind.java

@@ -0,0 +1,46 @@
+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_extension_bind
+ *
+ * @author fs
+ * @date 2026-05-19
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class CompanyExtensionBind extends BaseEntity{
+
+    /** 主键id */
+    private Long id;
+
+    /** 公司id */
+    @Excel(name = "公司id")
+    private Long companyId;
+
+    /** 销售id(为空时没有绑定) */
+    @Excel(name = "销售id", readConverterExp = "为=空时没有绑定")
+    private Long companyUserId;
+
+    /** 分机号码 */
+    @Excel(name = "分机号码")
+    private String extensionNum;
+
+    /** 分机密码 */
+    @Excel(name = "分机密码")
+    private String extensionPass;
+
+    /** easycall使用字段-流水编号 */
+    @Excel(name = "easycall使用字段-流水编号")
+    private Long extId;
+
+    /** easycall使用字段-所属员工/绑定关系 */
+    @Excel(name = "easycall使用字段-所属员工/绑定关系")
+    private String userCode;
+
+
+}

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

@@ -25,6 +25,9 @@ public class CompanyVoiceCloneRef {
     /** 状态 */
     private Integer status;
 
+    /** 删除标志(0=正常,1=删除) */
+    private Integer delFlag;
+
     /** 创建人 */
     private String createBy;
 

+ 1 - 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 = "创建时间")

+ 42 - 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,15 @@ 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 roboticName;
 

+ 182 - 0
fs-service/src/main/java/com/fs/company/domain/CrmCustomerCallLog.java

@@ -0,0 +1,182 @@
+package com.fs.company.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 用户手动外呼记录对象 company_user_call_log
+ *
+ * @author fs
+ * @date 2026-05-10
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class CrmCustomerCallLog extends BaseEntity {
+
+    /**
+     * 主键ID
+     */
+    @TableId(type = IdType.AUTO)
+    private Long logId;
+
+    /**
+     * uuid回调标识
+     */
+    @Excel(name = "uuid回调标识")
+    private String callbackUuid;
+
+    /**
+     * 记录调用时间
+     */
+    @Excel(name = "记录调用时间")
+    private Date runTime;
+
+    /**
+     * 调用参数
+     */
+    @Excel(name = "调用参数")
+    private String runParam;
+
+    /**
+     * 回调返回结果
+     */
+    @Excel(name = "回调返回结果")
+    private String result;
+
+    /**
+     * 执行状态
+     * 1:执行中
+     * 2:执行成功
+     * 3:执行失败
+     */
+    @Excel(name = "执行状态")
+    private Integer status;
+
+    /**
+     * 创建时间
+     */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @Excel(name = "创建时间")
+    private Date createTime;
+
+    /**
+     * 录音地址
+     */
+    @Excel(name = "录音地址")
+    private String recordPath;
+
+    /**
+     * 通话详细内容
+     */
+    @Excel(name = "通话详细内容")
+    private String contentList;
+
+    /**
+     * 客户号码
+     */
+    @Excel(name = "客户号码")
+    private String callerNum;
+
+    /**
+     * 坐席号码
+     */
+    @Excel(name = "坐席号码")
+    private String calleeNum;
+
+    /**
+     * 通话唯一标识
+     */
+    @Excel(name = "通话唯一标识")
+    private String uuid;
+
+    /**
+     * 呼叫开始时间
+     */
+    @Excel(name = "呼叫开始时间")
+    private Long callCreateTime;
+
+    /**
+     * 接通时间
+     */
+    @Excel(name = "接通时间")
+    private Long callAnswerTime;
+
+    /**
+     * 客户意向度
+     */
+    @Excel(name = "客户意向度")
+    private String intention;
+
+    /**
+     * 公司ID
+     */
+    @Excel(name = "公司ID")
+    private Long companyId;
+
+    /**
+     * 销售人员ID
+     */
+    @Excel(name = "销售人员ID")
+    private Long companyUserId;
+
+    /**
+     * 客户ID
+     */
+    @Excel(name = "客户ID")
+    private Long customerId;
+
+    /**
+     * 通话时长(秒)
+     */
+    @Excel(name = "通话时长")
+    private Long callTime;
+
+    /**
+     * 通话费用
+     */
+    @Excel(name = "通话费用")
+    private BigDecimal cost;
+
+    /**
+     * 外呼类型
+     */
+    @Excel(name = "外呼类型")
+    private Integer callType;
+
+    /**
+     * 查询条件:加密客户号码(前端输入明文手机号后,由Controller加密设置到callerNum)
+     * 非持久化字段,insert SQL 使用显式字段列表,不受影响
+     */
+    private String encryptedCallerNum;
+
+    /**
+     * 查询条件:明文客户号码(由Controller将原始输入保留到此字段,用于匹配历史明文数据)
+     * 非持久化字段,insert SQL 使用显式字段列表,不受影响
+     */
+    private String plainCallerNum;
+
+    /**
+     * 查询条件:最小通话时长(秒),SQL使用CEILING(call_time/1000)匹配
+     * 非持久化字段,insert SQL 使用显式字段列表,不受影响
+     */
+    private Long minCallTime;
+    
+    /**
+     * 查询条件:最大通话时长(秒),SQL使用CEILING(call_time/1000)匹配
+     * 非持久化字段,insert SQL 使用显式字段列表,不受影响
+     */
+    private Long maxCallTime;
+
+    /**
+     * 计费分钟数
+     */
+    private Integer billingMinute;
+}

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

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

@@ -0,0 +1,18 @@
+package com.fs.company.dto;
+
+import lombok.Data;
+
+/**
+ * AI外呼看板-每日趋势DTO
+ */
+@Data
+public class AiOutboundCallDailyTrendDTO {
+    /** 日期 yyyy-MM-dd */
+    private String callDate;
+
+    /** 当日总外呼数 */
+    private Integer totalCalls;
+
+    /** 当日接通数 */
+    private Integer connectedCalls;
+}

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

@@ -0,0 +1,15 @@
+package com.fs.company.dto;
+
+import lombok.Data;
+
+/**
+ * AI外呼看板-通话时长分布DTO
+ */
+@Data
+public class AiOutboundCallDurationDTO {
+    /** 时长区间标签 */
+    private String range;
+
+    /** 数量 */
+    private Integer count;
+}

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

@@ -0,0 +1,15 @@
+package com.fs.company.dto;
+
+import lombok.Data;
+
+/**
+ * AI外呼看板-时段分布DTO
+ */
+@Data
+public class AiOutboundCallHourlyDTO {
+    /** 小时(0-23) */
+    private Integer hour;
+
+    /** 外呼数量 */
+    private Integer count;
+}

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

@@ -0,0 +1,15 @@
+package com.fs.company.dto;
+
+import lombok.Data;
+
+/**
+ * AI外呼看板-意向分布DTO
+ */
+@Data
+public class AiOutboundCallIntentDTO {
+    /** 意向标签名称 */
+    private String name;
+
+    /** 数量 */
+    private Integer value;
+}

+ 48 - 0
fs-service/src/main/java/com/fs/company/dto/AiOutboundCallStatisticsDTO.java

@@ -0,0 +1,48 @@
+package com.fs.company.dto;
+
+import lombok.Data;
+
+/**
+ * AI外呼看板-按任务维度统计DTO
+ */
+@Data
+public class AiOutboundCallStatisticsDTO {
+    /** 任务ID */
+    private Long roboticId;
+
+    /** 任务名称 */
+    private String taskName;
+
+    /** 总外呼数 */
+    private Integer totalCalls;
+
+    /** 接通数 */
+    private Integer connectedCalls;
+
+    /** 未接通数 */
+    private Integer unconnectedCalls;
+
+    /** 接通率(%) */
+    private Double connectRate;
+
+    /** 意向客户数 */
+    private Integer intentCalls;
+
+    /** 意向率(%) */
+    private Double intentRate;
+
+    /** 转人工数 */
+    private Integer transferCalls;
+
+    /** 转人工率(%) */
+    private Double transferRate;
+
+    /** 总通话时长(秒) */
+    private Long totalDuration;
+
+    /** 平均通话时长(秒) */
+    private Long avgDuration;
+
+    /** 呼叫类型: 01呼入 02AI外呼 03人工外呼 */
+    private String callType;
+}

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

@@ -0,0 +1,15 @@
+package com.fs.company.dto;
+
+import lombok.Data;
+
+/**
+ * AI外呼看板-通话状态分布DTO
+ */
+@Data
+public class AiOutboundCallStatusDTO {
+    /** 状态名称 */
+    private String name;
+
+    /** 数量 */
+    private Integer value;
+}

+ 27 - 0
fs-service/src/main/java/com/fs/company/dto/AiOutboundCallViolationDTO.java

@@ -0,0 +1,27 @@
+package com.fs.company.dto;
+
+import lombok.Data;
+
+/**
+ * AI外呼看板-敏感词触发统计DTO
+ */
+@Data
+public class AiOutboundCallViolationDTO {
+    /** 任务ID */
+    private Long roboticId;
+    
+    /** 任务名称 */
+    private String taskName;
+    
+    /** 总通话数 */
+    private Integer totalCalls;
+    
+    /** 触发敏感词次数(is_warning=1的记录数) */
+    private Integer warningCount;
+    
+    /** 违规词总数(violation_num求和) */
+    private Integer violationNum;
+    
+    /** 触发率(%) */
+    private Double warningRate;
+}

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

+ 49 - 0
fs-service/src/main/java/com/fs/company/mapper/AiOutboundCallDashboardMapper.java

@@ -0,0 +1,49 @@
+package com.fs.company.mapper;
+
+import com.fs.company.dto.*;
+import com.fs.company.param.AiOutboundCallDashboardParam;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+/**
+ * AI外呼看板数据访问层
+ */
+@Mapper
+public interface AiOutboundCallDashboardMapper {
+
+    /**
+     * 按任务维度查询外呼统计
+     */
+    List<AiOutboundCallStatisticsDTO> selectCallStatisticsByTask(AiOutboundCallDashboardParam param);
+
+    /**
+     * 查询每日外呼趋势
+     */
+    List<AiOutboundCallDailyTrendDTO> selectDailyCallTrend(AiOutboundCallDashboardParam param);
+
+    /**
+     * 查询通话状态分布
+     */
+    List<AiOutboundCallStatusDTO> selectCallStatusDistribution(AiOutboundCallDashboardParam param);
+
+    /**
+     * 查询意向分布
+     */
+    List<AiOutboundCallIntentDTO> selectIntentDistribution(AiOutboundCallDashboardParam param);
+
+    /**
+     * 查询时段分布(按小时)
+     */
+    List<AiOutboundCallHourlyDTO> selectHourlyDistribution(AiOutboundCallDashboardParam param);
+
+    /**
+     * 查询通话时长分布
+     */
+    List<AiOutboundCallDurationDTO> selectDurationDistribution(AiOutboundCallDashboardParam param);
+
+    /**
+     * 查询敏感词触发统计(按任务维度)
+     */
+    List<AiOutboundCallViolationDTO> selectViolationStatistics(AiOutboundCallDashboardParam param);
+}

+ 28 - 0
fs-service/src/main/java/com/fs/company/mapper/CcExtNumMapper.java

@@ -0,0 +1,28 @@
+package com.fs.company.mapper;
+
+import com.fs.aiSipCall.vo.CcExtNumVo;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
+import org.apache.ibatis.annotations.Param;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface CcExtNumMapper {
+
+    @DataSource(DataSourceType.EASYCALL)
+    CcExtNumVo selectLastExtNum();
+
+    @DataSource(DataSourceType.EASYCALL)
+    List<CcExtNumVo> selectExtNumByExtNums(@Param("extNums") List<Long> extNums);
+
+    @DataSource(DataSourceType.EASYCALL)
+    int insertCcExtNum(CcExtNumVo ccExtNumVo);
+
+    @DataSource(DataSourceType.EASYCALL)
+    int batchInsertCcExtNum(@Param("list") List<CcExtNumVo> list);
+
+    @DataSource(DataSourceType.EASYCALL)
+    int updateUserCodeByExtNum(@Param("extNum") String extNum, @Param("userCode") String userCode);
+}

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

@@ -109,7 +109,8 @@ public interface CompanyAiWorkflowExecMapper extends BaseMapper<CompanyAiWorkflo
             @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);

+ 1 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanyBindGatewayMapper.java

@@ -14,4 +14,5 @@ public interface CompanyBindGatewayMapper {
     int deleteDataByCompanyId(@Param("companyId") Long companyId);
 
     int insertData(@Param("companyId") Long companyId, @Param("gatewayIds") List<Long> gatewayIds);
+    String getGateWayIdListByCompanyId(@Param("companyId") Long companyId);
 }

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

@@ -3,6 +3,7 @@ package com.fs.company.mapper;
 import com.fs.company.domain.CompanyConfig;
 import com.fs.company.vo.CompanyMiniAppVO;
 import com.fs.system.domain.SysConfig;
+import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
 
@@ -14,6 +15,7 @@ import java.util.List;
  * @author fs
  * @date 2021-05-25
  */
+@Mapper
 public interface CompanyConfigMapper
 {
     /**

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

@@ -0,0 +1,80 @@
+package com.fs.company.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.company.domain.CompanyExtensionBind;
+import com.fs.company.vo.easycall.ExtensionVO;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 公司分机绑定Mapper接口
+ * 
+ * @author fs
+ * @date 2026-05-19
+ */
+public interface CompanyExtensionBindMapper extends BaseMapper<CompanyExtensionBind>{
+    /**
+     * 查询公司分机绑定
+     * 
+     * @param id 公司分机绑定主键
+     * @return 公司分机绑定
+     */
+    CompanyExtensionBind selectCompanyExtensionBindById(Long id);
+
+    /**
+     * 查询公司分机绑定列表
+     * 
+     * @param companyExtensionBind 公司分机绑定
+     * @return 公司分机绑定集合
+     */
+    List<CompanyExtensionBind> selectCompanyExtensionBindList(CompanyExtensionBind companyExtensionBind);
+
+    /**
+     * 新增公司分机绑定
+     * 
+     * @param companyExtensionBind 公司分机绑定
+     * @return 结果
+     */
+    int insertCompanyExtensionBind(CompanyExtensionBind companyExtensionBind);
+
+    /**
+     * 修改公司分机绑定
+     * 
+     * @param companyExtensionBind 公司分机绑定
+     * @return 结果
+     */
+    int updateCompanyExtensionBind(CompanyExtensionBind companyExtensionBind);
+
+    /**
+     * 删除公司分机绑定
+     * 
+     * @param id 公司分机绑定主键
+     * @return 结果
+     */
+    int deleteCompanyExtensionBindById(Long id);
+
+    /**
+     * 批量删除公司分机绑定
+     * 
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    int deleteCompanyExtensionBindByIds(Long[] ids);
+
+    int batchInsertCompanyExtensionBind(@Param("list") List<CompanyExtensionBind> list);
+
+    List<CompanyExtensionBind> selectUnBindByCompanyId(@Param("companyId") Long companyId);
+
+    List<CompanyExtensionBind> selectUnBindAndSelfByCompanyId(@Param("companyId") Long companyId, @Param("companyUserId") Long companyUserId);
+
+    int updateBindByExtId(@Param("extId") Long extId, @Param("companyUserId") Long companyUserId, @Param("userCode") String userCode);
+
+    CompanyExtensionBind selectByExtNumAndCompanyId(@Param("extensionNum") String extensionNum, @Param("companyId") Long companyId);
+
+    int clearBindByExtNum(@Param("extensionNum") String extensionNum, @Param("companyId") Long companyId, @Param("companyUserId") Long companyUserId);
+
+    int updateBindByExtNum(@Param("num") String num, @Param("companyId") Long companyId, @Param("companyUserId") Long companyUserId, @Param("userCode") String userCode);
+
+    List<ExtensionVO> getExtensionList(@Param("companyId") Long companyId);
+}

+ 18 - 2
fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceCloneRefMapper.java

@@ -19,15 +19,31 @@ public interface CompanyVoiceCloneRefMapper {
     int updateCompanyVoiceCloneRef(CompanyVoiceCloneRef ref);
 
     List<Long> selectByCompanyIdAndCompanyUserId(@Param("companyId") Long companyId,
-                                   @Param("companyUserId") Long companyUserId);
+                                                 @Param("companyUserId") Long companyUserId);
 
-    List<CompanyVoiceCloneRef> selectCompanyVoiceCloneRefList(CompanyVoiceCloneRef query);
+    /**
+     * 查询公司下启用且未删除的音色关联 tts_id 列表
+     * @param companyId 公司ID(必填)
+     * @param companyUserId 用户ID(可选,为 null 时查全公司)
+     * @return tts_id 列表
+     */
+    List<Long> selectActiveTtsIds(@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);
 
 
 

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

@@ -5,6 +5,7 @@ import com.fs.company.domain.CompanyVoiceRoboticCallLogCallphone;
 import com.fs.company.domain.CompanyVoiceRoboticCallees;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
+import com.fs.crm.vo.CustomerCallStatVO;
 import org.apache.ibatis.annotations.Param;
 
 import java.util.List;
@@ -93,4 +94,16 @@ public interface CompanyVoiceRoboticCallLogCallphoneMapper extends BaseMapper<Co
      * @return 公司ID
      */
     Long selectCompanyIdByBusinessId(@Param("businessId") Long businessId);
+
+    List<CompanyVoiceRoboticCallLogCallphone> selectManualAnsweredList(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
+
+    /**
+     * 按 customer_id(= callees.user_id)批量统计今日 AI 外呼总数与接通数
+     */
+    List<CustomerCallStatVO> selectAiCallStatToday(@Param("customerIds") List<Long> customerIds);
+
+    /**
+     * 按 customer_id(= callees.user_id)批量统计累计 AI 外呼总数与接通数
+     */
+    List<CustomerCallStatVO> selectAiCallStatTotal(@Param("customerIds") List<Long> customerIds);
 }

+ 33 - 0
fs-service/src/main/java/com/fs/company/mapper/CrmCustomerCallLogMapper.java

@@ -0,0 +1,33 @@
+package com.fs.company.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.company.domain.CrmCustomerCallLog;
+import com.fs.crm.vo.CustomerCallStatVO;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 调用日志人工手动打电话Mapper接口
+ *
+ * @author fs
+ * @date 2026-01-15
+ */
+public interface CrmCustomerCallLogMapper extends BaseMapper<CrmCustomerCallLog>{
+
+    int insertCrmCustomerCallLog(CrmCustomerCallLog callLog);
+
+    List<CrmCustomerCallLog> selectCrmCustomerCallLogList(CrmCustomerCallLog crmCustomerCallLog);
+
+    Long selectSumBillingMinute(CrmCustomerCallLog crmCustomerCallLog);
+
+    /**
+     * 按 customer_id 批量统计今日手动外呼记录总数与接通数
+     */
+    List<CustomerCallStatVO> selectManualCallStatToday(@Param("customerIds") List<Long> customerIds);
+
+    /**
+     * 按 customer_id 批量统计累计手动外呼记录总数与接通数
+     */
+    List<CustomerCallStatVO> selectManualCallStatTotal(@Param("customerIds") List<Long> customerIds);
+}

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

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

@@ -0,0 +1,18 @@
+package com.fs.company.param;
+
+import lombok.Data;
+
+/**
+ * AI外呼看板查询参数
+ */
+@Data
+public class AiOutboundCallDashboardParam {
+    /** 开始时间 yyyy-MM-dd */
+    private String startTime;
+
+    /** 结束时间 yyyy-MM-dd */
+    private String endTime;
+
+    /** 公司ID(Controller自动注入) */
+    private Long companyId;
+}

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

+ 28 - 0
fs-service/src/main/java/com/fs/company/param/BatchCreateExtensionParam.java

@@ -0,0 +1,28 @@
+package com.fs.company.param;
+
+import lombok.Data;
+
+/**
+ * @author MixLiu
+ * @date 2026/5/19 15:04
+ * @description
+ */
+@Data
+public class BatchCreateExtensionParam {
+
+    /**
+     * 公司id
+     */
+    private Long companyId;
+
+    /**
+     * 生成数量
+     */
+    private Integer createNum;
+
+    /**
+     * 分机密码
+     */
+    private String password;
+
+}

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

@@ -0,0 +1,47 @@
+package com.fs.company.service;
+
+import com.fs.company.dto.*;
+import com.fs.company.param.AiOutboundCallDashboardParam;
+
+import java.util.List;
+
+/**
+ * AI外呼看板服务接口
+ */
+public interface IAiOutboundCallDashboardService {
+
+    /**
+     * 获取AI外呼统计数据(按任务维度)
+     */
+    List<AiOutboundCallStatisticsDTO> getCallStatistics(AiOutboundCallDashboardParam param);
+
+    /**
+     * 获取每日外呼趋势数据
+     */
+    List<AiOutboundCallDailyTrendDTO> getDailyCallTrend(AiOutboundCallDashboardParam param);
+
+    /**
+     * 获取通话状态分布数据
+     */
+    List<AiOutboundCallStatusDTO> getCallStatusDistribution(AiOutboundCallDashboardParam param);
+
+    /**
+     * 获取意向分布数据
+     */
+    List<AiOutboundCallIntentDTO> getIntentDistribution(AiOutboundCallDashboardParam param);
+
+    /**
+     * 获取时段分布数据(按小时)
+     */
+    List<AiOutboundCallHourlyDTO> getHourlyDistribution(AiOutboundCallDashboardParam param);
+
+    /**
+     * 获取通话时长分布数据
+     */
+    List<AiOutboundCallDurationDTO> getDurationDistribution(AiOutboundCallDashboardParam param);
+
+    /**
+     * 获取敏感词触发统计数据(按任务维度)
+     */
+    List<AiOutboundCallViolationDTO> getViolationStatistics(AiOutboundCallDashboardParam param);
+}

+ 132 - 0
fs-service/src/main/java/com/fs/company/service/ICompanyExtensionBindService.java

@@ -0,0 +1,132 @@
+package com.fs.company.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.fs.aiSipCall.vo.CcExtNumVo;
+import com.fs.company.domain.CompanyExtensionBind;
+import com.fs.company.param.BatchCreateExtensionParam;
+
+import java.util.List;
+
+/**
+ * 公司分机绑定Service接口
+ * 
+ * @author fs
+ * @date 2026-05-19
+ */
+public interface ICompanyExtensionBindService extends IService<CompanyExtensionBind>{
+    /**
+     * 查询公司分机绑定
+     * 
+     * @param id 公司分机绑定主键
+     * @return 公司分机绑定
+     */
+    CompanyExtensionBind selectCompanyExtensionBindById(Long id);
+
+    /**
+     * 查询公司分机绑定列表
+     * 
+     * @param companyExtensionBind 公司分机绑定
+     * @return 公司分机绑定集合
+     */
+    List<CompanyExtensionBind> selectCompanyExtensionBindList(CompanyExtensionBind companyExtensionBind);
+
+    /**
+     * 新增公司分机绑定
+     * 
+     * @param companyExtensionBind 公司分机绑定
+     * @return 结果
+     */
+    int insertCompanyExtensionBind(CompanyExtensionBind companyExtensionBind);
+
+    /**
+     * 修改公司分机绑定
+     * 
+     * @param companyExtensionBind 公司分机绑定
+     * @return 结果
+     */
+    int updateCompanyExtensionBind(CompanyExtensionBind companyExtensionBind);
+
+    /**
+     * 批量删除公司分机绑定
+     * 
+     * @param ids 需要删除的公司分机绑定主键集合
+     * @return 结果
+     */
+    int deleteCompanyExtensionBindByIds(Long[] ids);
+
+    /**
+     * 删除公司分机绑定信息
+     * 
+     * @param id 公司分机绑定主键
+     * @return 结果
+     */
+    int deleteCompanyExtensionBindById(Long id);
+
+    /**
+     * 在easycall数据源中生成分机号并插入cc_ext_num表
+     *
+     * @param param 批量创建分机参数
+     * @param tenantId 租户ID
+     * @return 生成的分机号列表
+     */
+    List<CcExtNumVo> createExtensionInEasycall(BatchCreateExtensionParam param, Long tenantId);
+
+    /**
+     * 在租户数据源中将分机数据写入company_extension_bind表
+     *
+     * @param extNums 分机号数据列表
+     * @param companyId 公司ID
+     */
+    void bindExtensionToTenant(List<CcExtNumVo> extNums, Long companyId);
+
+    /**
+     * 查询公司下未绑定的分机列表
+     *
+     * @param companyId 公司ID
+     * @return 未绑定的分机列表
+     */
+    List<CompanyExtensionBind> selectUnBindByCompanyId(Long companyId);
+
+    /**
+     *
+     * @param companyId
+     * @param companyUserId
+     * @return
+     */
+    List<CompanyExtensionBind> selectUnBindAndSelfByCompanyId(Long companyId, Long companyUserId);
+
+    /**
+     * 根据extId更新绑定信息
+     *
+     * @param extId easycall流水编号
+     * @param companyUserId 销售ID
+     * @param userCode 用户编码
+     */
+    void updateBindByExtId(Long extId, Long companyUserId, String userCode);
+
+    /**
+     * 根据分机号和公司ID查询未绑定记录
+     *
+     * @param extensionNum 分机号码
+     * @param companyId 公司ID
+     * @return 分机绑定记录
+     */
+    CompanyExtensionBind selectUnBindByExtNum(String extensionNum, Long companyId);
+
+    /**
+     * 根据分机号清除绑定信息(company_user_id和user_code置空)
+     *
+     * @param extensionNum 分机号码
+     * @param companyId 公司ID
+     */
+    void clearBindByExtNum(String extensionNum, Long companyId,Long companyUserId);
+
+    /**
+     * 修改分机绑定信息
+     * @param num
+     * @param companyId
+     * @param companyUserId
+     * @param userCode
+     */
+    void updateBindByExtNum(String num,Long companyId,Long companyUserId,String userCode);
+}

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

@@ -38,4 +38,29 @@ public interface ICompanyVoiceCloneService {
      * @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);
 }

+ 17 - 7
fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticCallLogCallphoneService.java

@@ -9,14 +9,14 @@ import java.util.List;
 
 /**
  * 调用日志_ai打电话Service接口
- * 
+ *
  * @author fs
  * @date 2026-01-15
  */
 public interface ICompanyVoiceRoboticCallLogCallphoneService extends IService<CompanyVoiceRoboticCallLogCallphone>{
     /**
      * 查询调用日志_ai打电话
-     * 
+     *
      * @param logId 调用日志_ai打电话主键
      * @return 调用日志_ai打电话
      */
@@ -24,7 +24,7 @@ public interface ICompanyVoiceRoboticCallLogCallphoneService extends IService<Co
 
     /**
      * 查询调用日志_ai打电话列表
-     * 
+     *
      * @param companyVoiceRoboticCallLogCallphone 调用日志_ai打电话
      * @return 调用日志_ai打电话集合
      */
@@ -32,7 +32,7 @@ public interface ICompanyVoiceRoboticCallLogCallphoneService extends IService<Co
 
     /**
      * 新增调用日志_ai打电话
-     * 
+     *
      * @param companyVoiceRoboticCallLogCallphone 调用日志_ai打电话
      * @return 结果
      */
@@ -40,7 +40,7 @@ public interface ICompanyVoiceRoboticCallLogCallphoneService extends IService<Co
 
     /**
      * 修改调用日志_ai打电话
-     * 
+     *
      * @param companyVoiceRoboticCallLogCallphone 调用日志_ai打电话
      * @return 结果
      */
@@ -48,7 +48,7 @@ public interface ICompanyVoiceRoboticCallLogCallphoneService extends IService<Co
 
     /**
      * 批量删除调用日志_ai打电话
-     * 
+     *
      * @param logIds 需要删除的调用日志_ai打电话主键集合
      * @return 结果
      */
@@ -56,7 +56,7 @@ public interface ICompanyVoiceRoboticCallLogCallphoneService extends IService<Co
 
     /**
      * 删除调用日志_ai打电话信息
-     * 
+     *
      * @param logId 调用日志_ai打电话主键
      * @return 结果
      */
@@ -82,4 +82,14 @@ public interface ICompanyVoiceRoboticCallLogCallphoneService extends IService<Co
     CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallPhoneLogCount();
 
     List<CompanyVoiceRoboticCallLogCallPhoneVO> listByRoboticId(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
+
+    List<CompanyVoiceRoboticCallLogCallphone> selectManualAnsweredList(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
+
+    /**
+     * 标记外呼记录为已处理(handleFlag=1)
+     *
+     * @param logId 外呼记录主键
+     * @return 影响行数
+     */
+    int markHandleFlag(Long logId);
 }

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

@@ -94,7 +94,7 @@ public interface ICompanyVoiceRoboticService extends IService<CompanyVoiceRoboti
      * @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);
 
@@ -110,5 +110,22 @@ public interface ICompanyVoiceRoboticService extends IService<CompanyVoiceRoboti
 
     void updateDelFlag(Long id, Integer delFlag);
 
+    /**
+     * 判断任务是否处于暂停状态
+     * 优先从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);
 }

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

@@ -0,0 +1,30 @@
+package com.fs.company.service;
+
+import com.fs.company.domain.CrmCustomerCallLog;
+
+import java.util.List;
+
+/**
+ * 客户通话记录Service接口
+ *
+ * @author fs
+ * @date 2026-05-10
+ */
+public interface ICrmCustomerCallLogService {
+
+    /**
+     * 查询客户通话记录列表
+     *
+     * @param crmCustomerCallLog 客户通话记录
+     * @return 客户通话记录集合
+     */
+    List<CrmCustomerCallLog> selectCrmCustomerCallLogList(CrmCustomerCallLog crmCustomerCallLog);
+
+    /**
+     * 查询客户通话记录计费分钟数总和
+     *
+     * @param crmCustomerCallLog 客户通话记录查询条件
+     * @return 计费分钟数总和
+     */
+    Long selectSumBillingMinute(CrmCustomerCallLog crmCustomerCallLog);
+}

+ 333 - 5
fs-service/src/main/java/com/fs/company/service/easycall/EasyCallServiceImpl.java

@@ -1,12 +1,21 @@
 package com.fs.company.service.easycall;
 
+import cn.hutool.core.date.DateUtil;
 import cn.hutool.http.HttpRequest;
 import cn.hutool.http.HttpUtil;
 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.CompanyExtensionBindMapper;
 import com.fs.company.mapper.CompanyMapper;
+import com.fs.company.mapper.OutboundLineLimitLogMapper;
 import com.fs.company.vo.easycall.*;
 import com.fs.system.service.ISysConfigService;
 import lombok.extern.slf4j.Slf4j;
@@ -14,10 +23,16 @@ 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 外呼服务实现类
@@ -35,6 +50,21 @@ public class EasyCallServiceImpl implements IEasyCallService {
     @Autowired
     private ISysConfigService configService;
 
+    @Autowired
+    private CompanyConfigMapper companyConfigMapper;
+
+    @Autowired
+    private OutboundLineLimitLogMapper outboundLineLimitLogMapper;
+
+    @Autowired
+    private RedisCacheT<String> redisCacheT;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Autowired
+    private CompanyExtensionBindMapper companyExtensionBindMapper;
+
     /**
      * EasyCallCenter365 服务器基础地址,从配置文件 easycall.base-url 读取
      */
@@ -87,8 +117,11 @@ 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;
+    private CompanyMapper companyMapper;
     // =================== 基础数据查询接口 ===================
 
     /**
@@ -245,10 +278,30 @@ public class EasyCallServiceImpl implements IEasyCallService {
      * bizJson 字段可传入需要传递给机器人的业务数据(如客户姓名、订单号等)
      */
     @Override
-    public void addCommonCallList(EasyCallCommonAddCallListParam param, Long companyId) {
+    public boolean addCommonCallList(EasyCallCommonAddCallListParam param, Long companyId, Long gatewayId) {
+        // 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;
+        }
     }
 
     // =================== 录音相关接口 ===================
@@ -268,6 +321,266 @@ public class EasyCallServiceImpl implements IEasyCallService {
         return base + wavFileUrl;
     }
 
+    // =================== 外呼线路执行限制 ===================
+
+    /**
+     * 检查外呼线路是否达到限制
+     * <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();
+        }
+    }
+
+    /**
+     * 保存外呼重试任务到 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);
+        }
+    }
+
+    /**
+     * 外呼执行成功后,记录 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);
+        }
+    }
+
+    /**
+     * 保存外呼限制日志到数据库
+     */
+    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 +668,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;
     }
 
     /**
@@ -374,4 +689,17 @@ public class EasyCallServiceImpl implements IEasyCallService {
         pageResult.setRows(rows == null ? new ArrayList<>() : rows.toJavaList(clazz));
         return pageResult;
     }
+
+    /**
+     * 查询公司分机号
+     * @param companyId 公司id
+     * @return
+     */
+    @Override
+    public List<ExtensionVO> getExtensionList(Long companyId){
+
+        List<ExtensionVO> extensionList = companyExtensionBindMapper.getExtensionList(companyId);
+
+        return extensionList;
+    }
 }

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

@@ -97,8 +97,9 @@ public interface IEasyCallService {
      *
      * @param param     追加参数
      * @param companyId 公司id
+     * @param gatewayId
      */
-    void addCommonCallList(EasyCallCommonAddCallListParam param, Long companyId);
+    boolean addCommonCallList(EasyCallCommonAddCallListParam param, Long companyId, Long gatewayId);
 
     /**
      * 获取录音文件访问URL
@@ -108,4 +109,12 @@ public interface IEasyCallService {
      * @return 完整的录音文件URL
      */
     String getRecordFileUrl(String wavFileUrl, Long companyId);
+
+    /**
+     * 获取外呼分机列表
+     *
+     * @param companyId 公司id
+     * @return 外呼机列表
+     */
+    List<ExtensionVO> getExtensionList(Long companyId);
 }

+ 111 - 0
fs-service/src/main/java/com/fs/company/service/impl/AiOutboundCallDashboardServiceImpl.java

@@ -0,0 +1,111 @@
+package com.fs.company.service.impl;
+
+import com.fs.company.dto.*;
+import com.fs.company.mapper.AiOutboundCallDashboardMapper;
+import com.fs.company.param.AiOutboundCallDashboardParam;
+import com.fs.company.service.IAiOutboundCallDashboardService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * AI外呼看板服务实现类
+ */
+@Service
+public class AiOutboundCallDashboardServiceImpl implements IAiOutboundCallDashboardService {
+
+    @Autowired
+    private AiOutboundCallDashboardMapper aiOutboundCallDashboardMapper;
+
+    @Override
+    public List<AiOutboundCallStatisticsDTO> getCallStatistics(AiOutboundCallDashboardParam param) {
+        List<AiOutboundCallStatisticsDTO> list = aiOutboundCallDashboardMapper.selectCallStatisticsByTask(param);
+
+        // 业务计算逻辑
+        for (AiOutboundCallStatisticsDTO dto : list) {
+            int totalCalls = dto.getTotalCalls() != null ? dto.getTotalCalls() : 0;
+            int connectedCalls = dto.getConnectedCalls() != null ? dto.getConnectedCalls() : 0;
+            int intentCalls = dto.getIntentCalls() != null ? dto.getIntentCalls() : 0;
+            int transferCalls = dto.getTransferCalls() != null ? dto.getTransferCalls() : 0;
+            long totalDuration = dto.getTotalDuration() != null ? dto.getTotalDuration() : 0L;
+
+            // 计算未接通数
+            dto.setUnconnectedCalls(totalCalls - connectedCalls);
+
+            // 计算接通率(%)
+            if (totalCalls > 0) {
+                double rate = (double) connectedCalls / totalCalls * 100;
+                dto.setConnectRate(Math.round(rate * 100.0) / 100.0);
+            } else {
+                dto.setConnectRate(0.0);
+            }
+
+            // 计算意向率(%): 意向客户数/接通数
+            if (connectedCalls > 0) {
+                double intentRate = (double) intentCalls / connectedCalls * 100;
+                dto.setIntentRate(Math.round(intentRate * 100.0) / 100.0);
+            } else {
+                dto.setIntentRate(0.0);
+            }
+
+            // 计算转人工率(%): 转人工数/接通数
+            if (connectedCalls > 0) {
+                double transferRate = (double) transferCalls / connectedCalls * 100;
+                dto.setTransferRate(Math.round(transferRate * 100.0) / 100.0);
+            } else {
+                dto.setTransferRate(0.0);
+            }
+
+            // 平均通话时长(秒)
+            if (connectedCalls > 0) {
+                dto.setAvgDuration((long) Math.ceil((double) totalDuration / connectedCalls));
+            } else {
+                dto.setAvgDuration(0L);
+            }
+        }
+        return list;
+    }
+
+    @Override
+    public List<AiOutboundCallDailyTrendDTO> getDailyCallTrend(AiOutboundCallDashboardParam param) {
+        return aiOutboundCallDashboardMapper.selectDailyCallTrend(param);
+    }
+
+    @Override
+    public List<AiOutboundCallStatusDTO> getCallStatusDistribution(AiOutboundCallDashboardParam param) {
+        return aiOutboundCallDashboardMapper.selectCallStatusDistribution(param);
+    }
+
+    @Override
+    public List<AiOutboundCallIntentDTO> getIntentDistribution(AiOutboundCallDashboardParam param) {
+        return aiOutboundCallDashboardMapper.selectIntentDistribution(param);
+    }
+
+    @Override
+    public List<AiOutboundCallHourlyDTO> getHourlyDistribution(AiOutboundCallDashboardParam param) {
+        return aiOutboundCallDashboardMapper.selectHourlyDistribution(param);
+    }
+
+    @Override
+    public List<AiOutboundCallDurationDTO> getDurationDistribution(AiOutboundCallDashboardParam param) {
+        return aiOutboundCallDashboardMapper.selectDurationDistribution(param);
+    }
+
+    @Override
+    public List<AiOutboundCallViolationDTO> getViolationStatistics(AiOutboundCallDashboardParam param) {
+        List<AiOutboundCallViolationDTO> list = aiOutboundCallDashboardMapper.selectViolationStatistics(param);
+        // 计算触发率
+        for (AiOutboundCallViolationDTO dto : list) {
+            int totalCalls = dto.getTotalCalls() != null ? dto.getTotalCalls() : 0;
+            int warningCount = dto.getWarningCount() != null ? dto.getWarningCount() : 0;
+            if (totalCalls > 0) {
+                double rate = (double) warningCount / totalCalls * 100;
+                dto.setWarningRate(Math.round(rate * 100.0) / 100.0);
+            } else {
+                dto.setWarningRate(0.0);
+            }
+        }
+        return list;
+    }
+}

+ 248 - 0
fs-service/src/main/java/com/fs/company/service/impl/CompanyExtensionBindServiceImpl.java

@@ -0,0 +1,248 @@
+package com.fs.company.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.fs.aiSipCall.vo.CcExtNumVo;
+import com.fs.common.utils.DateUtils;
+import com.fs.company.domain.CompanyExtensionBind;
+import com.fs.company.mapper.CcExtNumMapper;
+import com.fs.company.mapper.CompanyExtensionBindMapper;
+import com.fs.company.param.BatchCreateExtensionParam;
+import com.fs.company.service.ICompanyExtensionBindService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 公司分机绑定Service业务层处理
+ * 
+ * @author fs
+ * @date 2026-05-19
+ */
+@Service
+public class CompanyExtensionBindServiceImpl extends ServiceImpl<CompanyExtensionBindMapper, CompanyExtensionBind> implements ICompanyExtensionBindService {
+
+    private static final Logger log = LoggerFactory.getLogger(CompanyExtensionBindServiceImpl.class);
+
+    @Autowired
+    private CcExtNumMapper ccExtNumMapper;
+
+    /**
+     * 查询公司分机绑定
+     * 
+     * @param id 公司分机绑定主键
+     * @return 公司分机绑定
+     */
+    @Override
+    public CompanyExtensionBind selectCompanyExtensionBindById(Long id)
+    {
+        return baseMapper.selectCompanyExtensionBindById(id);
+    }
+
+    /**
+     * 查询公司分机绑定列表
+     * 
+     * @param companyExtensionBind 公司分机绑定
+     * @return 公司分机绑定
+     */
+    @Override
+    public List<CompanyExtensionBind> selectCompanyExtensionBindList(CompanyExtensionBind companyExtensionBind)
+    {
+        return baseMapper.selectCompanyExtensionBindList(companyExtensionBind);
+    }
+
+    /**
+     * 新增公司分机绑定
+     * 
+     * @param companyExtensionBind 公司分机绑定
+     * @return 结果
+     */
+    @Override
+    public int insertCompanyExtensionBind(CompanyExtensionBind companyExtensionBind)
+    {
+        companyExtensionBind.setCreateTime(DateUtils.getNowDate());
+        return baseMapper.insertCompanyExtensionBind(companyExtensionBind);
+    }
+
+    /**
+     * 修改公司分机绑定
+     * 
+     * @param companyExtensionBind 公司分机绑定
+     * @return 结果
+     */
+    @Override
+    public int updateCompanyExtensionBind(CompanyExtensionBind companyExtensionBind)
+    {
+        return baseMapper.updateCompanyExtensionBind(companyExtensionBind);
+    }
+
+    /**
+     * 批量删除公司分机绑定
+     * 
+     * @param ids 需要删除的公司分机绑定主键
+     * @return 结果
+     */
+    @Override
+    public int deleteCompanyExtensionBindByIds(Long[] ids)
+    {
+        return baseMapper.deleteCompanyExtensionBindByIds(ids);
+    }
+
+    /**
+     * 删除公司分机绑定信息
+     * 
+     * @param id 公司分机绑定主键
+     * @return 结果
+     */
+    @Override
+    public int deleteCompanyExtensionBindById(Long id)
+    {
+        return baseMapper.deleteCompanyExtensionBindById(id);
+    }
+
+    @Override
+    public List<CcExtNumVo> createExtensionInEasycall(BatchCreateExtensionParam param, Long tenantId) {
+        int createNum = param.getCreateNum();
+        String password = param.getPassword();
+        Long companyId = param.getCompanyId();
+
+        CcExtNumVo lastExtNum = ccExtNumMapper.selectLastExtNum();
+        long currentMaxExtNum = (lastExtNum != null && lastExtNum.getExtNum() != null) ? lastExtNum.getExtNum() : 0;
+
+        List<CcExtNumVo> allNewExtNums = new ArrayList<>();
+        long nextExtNum = currentMaxExtNum + 1;
+        int userCodeSeq = 1;
+
+        for (int i = 0; i < createNum; i++) {
+            CcExtNumVo vo = new CcExtNumVo();
+            vo.setExtNum(nextExtNum + i);
+            vo.setExtPass(password);
+            vo.setUserCode(tenantId + "_" + companyId + "_" + userCodeSeq);
+            userCodeSeq++;
+            allNewExtNums.add(vo);
+        }
+
+        int maxRetry = 10;
+        for (int retry = 0; retry < maxRetry; retry++) {
+            List<Long> extNumsToCheck = allNewExtNums.stream()
+                    .map(CcExtNumVo::getExtNum)
+                    .collect(Collectors.toList());
+
+            List<CcExtNumVo> existingExtNums = ccExtNumMapper.selectExtNumByExtNums(extNumsToCheck);
+            if (CollectionUtils.isEmpty(existingExtNums)) {
+                break;
+            }
+
+            List<Long> existingExtNumValues = existingExtNums.stream()
+                    .map(CcExtNumVo::getExtNum)
+                    .collect(Collectors.toList());
+
+            long maxExisting = existingExtNumValues.stream().max(Long::compareTo).orElse(currentMaxExtNum);
+            nextExtNum = maxExisting + 1;
+
+            List<CcExtNumVo> replaceList = new ArrayList<>();
+            for (CcExtNumVo vo : allNewExtNums) {
+                if (existingExtNumValues.contains(vo.getExtNum())) {
+                    vo.setExtNum(nextExtNum);
+                    nextExtNum++;
+                    replaceList.add(vo);
+                }
+            }
+
+            if (CollectionUtils.isEmpty(replaceList)) {
+                break;
+            }
+        }
+
+        try {
+            ccExtNumMapper.batchInsertCcExtNum(allNewExtNums);
+        } catch (Exception e) {
+            log.warn("批量插入cc_ext_num失败,尝试逐条插入补偿", e);
+            List<CcExtNumVo> successList = new ArrayList<>();
+            for (CcExtNumVo vo : allNewExtNums) {
+                try {
+                    ccExtNumMapper.insertCcExtNum(vo);
+                    successList.add(vo);
+                } catch (Exception ex) {
+                    log.warn("插入分机号{}失败,可能已被其他线程占用,尝试补偿", vo.getExtNum());
+                    int compensateRetry = 0;
+                    boolean compensated = false;
+                    while (compensateRetry < 5 && !compensated) {
+                        CcExtNumVo currentLast = ccExtNumMapper.selectLastExtNum();
+                        long newExtNum = (currentLast != null && currentLast.getExtNum() != null) ? currentLast.getExtNum() + 1 : 1;
+                        vo.setExtNum(newExtNum);
+                        try {
+                            ccExtNumMapper.insertCcExtNum(vo);
+                            compensated = true;
+                        } catch (Exception ex2) {
+                            compensateRetry++;
+                        }
+                    }
+                    if (compensated) {
+                        successList.add(vo);
+                    }
+                }
+            }
+            allNewExtNums = successList;
+        }
+
+        return allNewExtNums;
+    }
+
+    @Override
+    public void bindExtensionToTenant(List<CcExtNumVo> extNums, Long companyId) {
+        List<CompanyExtensionBind> bindList = new ArrayList<>();
+        for (CcExtNumVo extNumVo : extNums) {
+            CompanyExtensionBind bind = new CompanyExtensionBind();
+            bind.setCompanyId(companyId);
+            bind.setExtensionNum(String.valueOf(extNumVo.getExtNum()));
+            bind.setExtensionPass(extNumVo.getExtPass());
+            bind.setExtId(extNumVo.getExtId());
+            bind.setUserCode(extNumVo.getUserCode());
+            bind.setCreateTime(DateUtils.getNowDate());
+            bindList.add(bind);
+        }
+
+        int batchSize = 100;
+        for (int i = 0; i < bindList.size(); i += batchSize) {
+            int end = Math.min(i + batchSize, bindList.size());
+            List<CompanyExtensionBind> batch = bindList.subList(i, end);
+            baseMapper.batchInsertCompanyExtensionBind(batch);
+        }
+    }
+
+    @Override
+    public List<CompanyExtensionBind> selectUnBindByCompanyId(Long companyId) {
+        return baseMapper.selectUnBindByCompanyId(companyId);
+    }
+
+    @Override
+    public List<CompanyExtensionBind> selectUnBindAndSelfByCompanyId(Long companyId, Long companyUserId){
+        return baseMapper.selectUnBindAndSelfByCompanyId(companyId,companyUserId);
+    }
+
+    @Override
+    public void updateBindByExtId(Long extId, Long companyUserId, String userCode) {
+        baseMapper.updateBindByExtId(extId, companyUserId, userCode);
+    }
+
+    @Override
+    public CompanyExtensionBind selectUnBindByExtNum(String extensionNum, Long companyId) {
+        return baseMapper.selectByExtNumAndCompanyId(extensionNum, companyId);
+    }
+
+    @Override
+    public void clearBindByExtNum(String extensionNum, Long companyId,Long companyUserId) {
+        baseMapper.clearBindByExtNum(extensionNum, companyId,companyUserId);
+    }
+
+    @Override
+    public void updateBindByExtNum(String num, Long companyId, Long companyUserId, String userCode) {
+        baseMapper.updateBindByExtNum(num, companyId,companyUserId, userCode);
+    }
+}

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

@@ -438,4 +438,21 @@ public class CompanyVoiceCloneServiceImpl implements ICompanyVoiceCloneService {
             throw new RuntimeException("保存公司音色关联失败", e);
         }
     }
+
+
+    @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);
+    }
+
 }

+ 118 - 24
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallLogCallphoneServiceImpl.java

@@ -5,10 +5,7 @@ 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.config.RedisTenantContext;
 import com.fs.common.core.domain.entity.SysDictData;
@@ -18,26 +15,33 @@ 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.CompanyAiWorkflowExecMapper;
 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.service.CompanyWorkflowEngine;
 import com.fs.company.service.ICompanyVoiceRoboticCallLogCallphoneService;
+import com.fs.company.service.ICompanyVoiceRoboticService;
 import com.fs.company.vo.CidConfigVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
 import com.fs.company.vo.easycall.EasyCallCallPhoneVO;
 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.sensitive.manager.SensitiveWordAcManager;
 import com.fs.system.service.ISysConfigService;
 import com.fs.system.service.impl.SysDictTypeServiceImpl;
 import com.fs.wxcid.utils.TenantHelper;
 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;
@@ -50,6 +54,7 @@ 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;
 
 /**
@@ -88,6 +93,15 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
 
     @Autowired
     SysDictTypeServiceImpl sysDictTypeService;
+    @Autowired
+    @Lazy
+    ICompanyVoiceRoboticService companyVoiceRoboticService;
+    @Autowired
+    CompanyAiWorkflowExecMapper companyAiWorkflowExecMapper;
+
+    @Autowired
+    private AgentSensitiveWordDetector agentSensitiveWordDetector;
+
     /**
      * 查询调用日志_ai打电话
      *
@@ -313,30 +327,38 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
 //                getDialogMapDomain getDialogMap = getDialogMapDomain.builder()
 //                        .uuid(uuid)
 //                        .build();
+
                     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(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();
+                    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.setCallType(2);
                     companyVoiceRoboticCallLog.setContentList(result.getDialogue());
-                    companyVoiceRoboticCallLog.setCallerNum(result.getTelephone());
+                    companyVoiceRoboticCallLog.setCallerNum(PhoneUtil.encryptPhone(result.getTelephone()));
                     companyVoiceRoboticCallLog.setCalleeNum(result.getCallerNumber());
                     companyVoiceRoboticCallLog.setUuid(result.getUuid());
                     Long createTime = result.getCalloutTime();
@@ -354,16 +376,40 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
                         }
                     }
                     companyVoiceRoboticCallLog.setIntention(intentf);
-                    companyVoiceRoboticCallLog.setCallTime(Long.valueOf(result.getTimeLen()/1000));
+                    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;
                     }
-                    //向上取整分钟数
-                    BigDecimal divide = new BigDecimal(companyVoiceRoboticCallLog.getCallTime()).divide(ONE_MINUTES_SECOND, 0, RoundingMode.CEILING);
-                    BigDecimal multiply = divide.multiply(callCharge);
-                    companyVoiceRoboticCallLog.setCost(multiply);
+                    //检测空避免报错
+                    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(), TenantHelper.getTenantId(), 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);
@@ -372,14 +418,21 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
                         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")) {
-                            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"));
-                            });
+                            // 暂停检查:任务暂停中则标记回调已到达但不触发工作流继续执行
+                            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"));
                     }
@@ -409,6 +462,34 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
 
     }
 
+    /**
+     * 标记工作流实例的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);
+        }
+    }
+
     @Async("callLogExcutor")
     public void asyncInsertCompanyVoiceRoboticCallLogBatch(List<CompanyVoiceRoboticCallLogCallphone> list) {
         try {
@@ -457,4 +538,17 @@ 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);
+    }
 }

+ 448 - 16
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java

@@ -30,6 +30,7 @@ import com.fs.company.mapper.*;
 import com.fs.company.param.ExecutionContext;
 import com.fs.company.param.PauseRoboticActiveParam;
 import com.fs.company.service.*;
+import com.fs.company.service.impl.call.EasyCallTaskControlService;
 import com.fs.company.vo.*;
 import com.fs.company.vo.easycall.EasyCallCallPhoneVO;
 import com.fs.core.config.TenantConfigContext;
@@ -41,6 +42,7 @@ import com.fs.crm.service.impl.CrmCustomerServiceImpl;
 import com.fs.enums.ExecutionStatusEnum;
 import com.fs.enums.NodeTypeEnum;
 import com.fs.enums.TaskTypeEnum;
+import com.fs.his.utils.PhoneUtil;
 import com.fs.qw.domain.QwUser;
 import com.fs.qw.mapper.QwUserMapper;
 import com.fs.qw.service.impl.QwExternalContactServiceImpl;
@@ -140,6 +142,12 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
 
     private final ICrmCustomerAnalyzeService crmCustomerAnalyzeService;
 
+    private final CompanyWorkflowNodeMapper companyWorkflowNodeMapper;
+
+    private final CompanySiptaskInfoMapper companySiptaskInfoMapper;
+
+    private final EasyCallTaskControlService easyCallTaskControlService;
+
     /** EasyCall intent 意向度重试队列 Redis key 前缀,value 为已重试次数 */
     private static final String EASYCALL_INTENT_RETRY_KEY = "easycall:intent:retry:";
     /** intent 意向度等待重试最大次数(每次间隔约30秒,最多等待 5*30=150秒) */
@@ -1068,7 +1076,7 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
             cacheString = cacheObj == null ? null : JSONObject.toJSONString(cacheObj);
         }
         if (StringUtils.isBlank(cacheString)) {
-            log.error("easyCall外呼回调缓存信息缺失, uuid={}", callPhoneRes.getUuid());
+            log.error("easyCall外呼回调缓存信息缺失, uuid={},callBackUuid:{}", callPhoneRes.getUuid(),bizJson.getString("callBackUuid"));
             return;
         }
         JSONObject cacheInfo = JSONObject.parseObject(cacheString);
@@ -1408,6 +1416,10 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
         final Long workflowId = robotic.getCompanyAiWorkflowId();
         final Long roboticId = robotic.getId();
 
+
+        companyWorkflowEngine.createSipTask(roboticId, workflowId);
+
+
         // 先初始化所有工作流实例
         List<ExecutionResult> initResults = new ArrayList<>();
         for (CompanyVoiceRoboticBusiness business : roboticBusinesseList) {
@@ -1739,11 +1751,16 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
                                               Integer pageSize,
                                               String customerName,
                                               String customerPhone,
-                                              Boolean onlyCallNode) {
+                                              Boolean onlyCallNode,
+                                              String encryptPhone) {
         //分页查询主数据
         PageHelper.startPage(pageNum, pageSize);
+        if(StringUtils.isNotBlank(encryptPhone)){
+            encryptPhone = PhoneUtil.encryptPhone(encryptPhone);
+        }
+        List<WorkflowExecRecordVo> records = companyAiWorkflowExecMapper.selectExecRecordsByRoboticId(roboticId, customerName, customerPhone, onlyCallNode,encryptPhone);
 
-        List<WorkflowExecRecordVo> records = companyAiWorkflowExecMapper.selectExecRecordsByRoboticId(roboticId, customerName, customerPhone, onlyCallNode);
+//        List<WorkflowExecRecordVo> records = companyAiWorkflowExecMapper.selectExecRecordsByRoboticId(roboticId, customerName, customerPhone, onlyCallNode);
 
         PageInfo<WorkflowExecRecordVo> pageInfo = new PageInfo<>(records);
 
@@ -1884,7 +1901,7 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
             return new ArrayList<>();
         }
         List<CompanyAiWorkflowExecLog> callLogs = logs.stream().filter(a -> "外呼".equals(a.getNodeName())).collect(Collectors.toList());
-        HashMap<Long,String> callContentMap;
+        HashMap<Long,CallContentVO> callContentMap;
         if (null != callLogs && !callLogs.isEmpty()) {
             callContentMap = selectCallContentByCallLogs(callLogs);
         } else {
@@ -1904,7 +1921,11 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
             vo.setDuration(log.getDuration());
             vo.setErrorMessage(log.getErrorMessage());
             vo.setOutputData(log.getOutputData());
-            vo.setNodeContentList(callContentMap.get(log.getId()));
+            CallContentVO callContentVO = callContentMap.get(log.getId());
+            if (callContentVO != null) {
+                vo.setNodeContentList(callContentVO.getCallContent());
+                vo.setNodeRecordPath(callContentVO.getRecordPath());
+            }
             return vo;
         }).collect(Collectors.toList());
     }
@@ -1914,15 +1935,15 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
      * @param callLogs
      * @return
      */
-    public  HashMap<Long,String> selectCallContentByCallLogs(List<CompanyAiWorkflowExecLog> callLogs){
+    public  HashMap<Long,CallContentVO> selectCallContentByCallLogs(List<CompanyAiWorkflowExecLog> callLogs){
         List<Long> ids = callLogs.stream().map(a -> a.getId()).collect(Collectors.toList());
         if(null == ids || ids.isEmpty()){
             return new HashMap<>();
         }
         List<CallContentVO> callContentVOS = companyAiWorkflowExecLogMapper.selectCallContent(ids);
         if(null != callContentVOS && !callContentVOS.isEmpty()){
-            HashMap<Long,String> map = new HashMap<>();
-            callContentVOS.forEach(a -> map.put(a.getLogId(),a.getCallContent()));
+            HashMap<Long,CallContentVO> map = new HashMap<>();
+            callContentVOS.forEach(a -> map.put(a.getLogId(), a));
             return map;
         }
         else{
@@ -1945,18 +1966,32 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
     public R pauseRoboticActive(PauseRoboticActiveParam param) {
         //暂停任务
         if (ACTIVE_TYPE_PAUSE.equals(param.getActiveType())) {
-
-            // 暂停任务更新
-
-            // 暂停任务创建的三方外呼任务
-
+            CompanyVoiceRobotic robotic = selectCompanyVoiceRoboticById(param.getTaskId());
+            if (robotic == null || robotic.getTaskStatus() != 1) {
+                return R.error("任务不在执行中状态,无法暂停");
+            }
+            robotic.setTaskStatus(2);
+            robotic.setPauseSource("manual");
+            updateCompanyVoiceRobotic(robotic);
+            redisCache2.setCacheObject("task:status:" + param.getTaskId(), 2);
+            pauseEasyCallTask(robotic);
+            return R.ok("暂停成功");
         }
-        //恢复任务继续进入可运行
         else if (ACTIVE_TYPE_CONTINUE.equals(param.getActiveType())) {
-
+            CompanyVoiceRobotic robotic = selectCompanyVoiceRoboticById(param.getTaskId());
+            if (robotic == null || robotic.getTaskStatus() != 2) {
+                return R.error("任务不在中断状态,无法继续");
+            }
+            robotic.setTaskStatus(1);
+            robotic.setPauseSource(null);
+            updateCompanyVoiceRobotic(robotic);
+            redisCache2.setCacheObject("task:status:" + param.getTaskId(), 1);
+            resumeEasyCallTask(robotic);
+            resumePausedInstances(param.getTaskId());
+            return R.ok("继续成功");
         }
 
-        return R.ok("操作成功");
+        return R.error("操作类型无效");
     }
 
     @Override
@@ -1984,4 +2019,401 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
         }
         return false;
     }
+
+    @Override
+    public R appendCustomersToRunningTask(Long taskId, List<Long> customerIds) {
+        // 1. 校验参数
+        if (taskId == null || customerIds == null || customerIds.isEmpty()) {
+            return R.error("参数不能为空");
+        }
+
+        // 2. 校验任务
+        CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectCompanyVoiceRoboticById(taskId);
+        if (robotic == null) {
+            return R.error("任务不存在: " + taskId);
+        }
+        if (!robotic.getTaskType().equals(TaskTypeEnum.ORDINARY.getValue())) {
+            return R.error("仅普通任务支持追加客户");
+        }
+        if (!Integer.valueOf(1).equals(robotic.getTaskStatus())) {
+            return R.error("任务不在执行中状态,无法追加客户");
+        }
+        if (robotic.getCompanyAiWorkflowId() == null) {
+            return R.error("任务未配置工作流");
+        }
+
+        // 3. 查询当前任务已有的callees的userId集合,过滤重复
+        List<CompanyVoiceRoboticCallees> existingCallees = companyVoiceRoboticCalleesMapper.selectByRoboticId(taskId);
+        Set<Long> existingUserIds = existingCallees.stream()
+                .map(CompanyVoiceRoboticCallees::getUserId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+
+        // 分离重复和非重复客户
+        List<Long> duplicateIds = customerIds.stream()
+                .filter(existingUserIds::contains)
+                .collect(Collectors.toList());
+        List<Long> newCustomerIds = customerIds.stream()
+                .filter(id -> !existingUserIds.contains(id))
+                .collect(Collectors.toList());
+
+        if (newCustomerIds.isEmpty()) {
+            String dupNames = getDuplicateCustomerNames(duplicateIds);
+            return R.error("所选客户已存在于任务中" + (dupNames.isEmpty() ? "" : ":" + dupNames));
+        }
+
+        // 4. 批量查询CRM客户信息
+        List<CrmCustomer> crmCustomers = crmCustomerService.selectCrmCustomerListByIds(
+                newCustomerIds.stream().map(String::valueOf).collect(Collectors.joining(",")));
+        Map<Long, CrmCustomer> customerMap = crmCustomers.stream()
+                .collect(Collectors.toMap(CrmCustomer::getCustomerId, c -> c, (a, b) -> a));
+
+        // 5. 判断是否需要加微分配
+        boolean hasAddWxNode = workflowHasAddWxNode(robotic.getCompanyAiWorkflowId());
+
+        int successCount = 0;
+        List<String> errorMessages = new ArrayList<>();
+
+        for (Long customerId : newCustomerIds) {
+            CrmCustomer crmCustomer = customerMap.get(customerId);
+            if (crmCustomer == null) {
+                errorMessages.add("客户不存在: " + customerId);
+                continue;
+            }
+            try {
+                // 5.1 创建CompanyWxClient记录
+                CompanyWxClient client = new CompanyWxClient();
+                client.setRoboticId(taskId);
+                client.setCustomerId(customerId);
+                client.setIsWeCom(robotic.getIsWeCom());
+                companyWxClientServiceImpl.insertCompanyWxClient(client);
+
+                // 5.2 创建CompanyVoiceRoboticCallees记录
+                CompanyVoiceRoboticCallees callee = new CompanyVoiceRoboticCallees();
+                callee.setUserId(crmCustomer.getCustomerId());
+                callee.setUserName(crmCustomer.getCustomerName());
+                callee.setPhone(crmCustomer.getMobile());
+                callee.setRoboticId(robotic.getId());
+                callee.setResult(0);
+                callee.setTaskFlow(robotic.getTaskFlow());
+                callee.setRunTaskFlow(robotic.getRunTaskFlow());
+                callee.setIsWeCom(robotic.getIsWeCom());
+                companyVoiceRoboticCalleesService.save(callee);
+
+                // 5.3 加微分配
+                if (hasAddWxNode && Integer.valueOf(0).equals(robotic.getAddType())) {
+                    allocateWx4SceneTask(robotic, client.getId());
+                } else if (hasAddWxNode && Integer.valueOf(1).equals(robotic.getAddType())) {
+                    String intention = crmCustomer.getIntention();
+                    String queryIntention = intention;
+                    if (!isPositiveInteger(intention)) {
+                        List<SysDictData> customerIntentionLevel = sysDictTypeService.selectDictDataByType("customer_intention_level");
+                        Optional<SysDictData> firstDict = customerIntentionLevel.stream()
+                                .filter(e -> e.getDictLabel().equals(intention)).findFirst();
+                        if (firstDict.isPresent()) {
+                            queryIntention = firstDict.get().getDictValue();
+                        }
+                    }
+                    List<CompanyVoiceRoboticWx> roboticWxList = companyVoiceRoboticWxMapper.selectByRoboticId(taskId, queryIntention);
+                    List<CompanyWxAccount> accountList = new ArrayList<>(companyWxAccountService.listByIds(PubFun.listToNewList(roboticWxList, CompanyVoiceRoboticWx::getAccountId)));
+                    Map<Long, CompanyWxAccount> accountMap = PubFun.listToMapByGroupObject(accountList, CompanyWxAccount::getId);
+                    roboticWxList.forEach(e -> e.setAccount(accountMap.get(e.getAccountId())));
+                    CompanyWxClient companyWxClient = companyWxClientServiceImpl.getOne(new QueryWrapper<CompanyWxClient>().eq("robotic_id", callee.getRoboticId()).eq("customer_id", callee.getUserId()));
+                    if (companyWxClient == null) {
+                        companyWxClient = new CompanyWxClient();
+                    }
+                    companyWxClient.setRoboticId(callee.getRoboticId());
+                    companyWxClient.setNickName(callee.getUserName());
+                    companyWxClient.setPhone(callee.getPhone());
+                    companyWxClient.setCustomerId(callee.getUserId());
+                    companyWxClient.setIntention(intention);
+                    bindCompany(companyWxClient, roboticWxList);
+                    companyWxClientServiceImpl.saveOrUpdate(companyWxClient);
+                }
+
+                // 5.4 创建CompanyVoiceRoboticBusiness记录
+                CompanyVoiceRoboticBusiness business = buildTaskBussiness4SceneTask(robotic, callee);
+
+                // 5.5 初始化工作流实例
+                Map<String, Object> inputVariables = new HashMap<>();
+                inputVariables.put("roboticId", robotic.getId());
+                inputVariables.put("businessId", business.getId());
+                inputVariables.put("cidGroupNo", robotic.getCidGroupNo());
+                inputVariables.put("runtimeRangeStart", robotic.getRuntimeRangeStart());
+                inputVariables.put("runtimeRangeEnd", robotic.getRuntimeRangeEnd());
+                ExecutionResult initResult = companyWorkflowEngine.initialize(robotic.getCompanyAiWorkflowId(), inputVariables);
+                if (!initResult.isSuccess()) {
+                    errorMessages.add("客户" + crmCustomer.getCustomerName() + "工作流初始化失败: " + initResult.getErrorMessage());
+                    continue;
+                }
+
+                successCount++;
+            } catch (Exception e) {
+                log.error("追加客户失败 - taskId: {}, customerId: {}", taskId, customerId, e);
+                errorMessages.add("客户" + crmCustomer.getCustomerName() + "追加失败: " + e.getMessage());
+            }
+        }
+
+        // 6. 为新增的callees生成AI标签信息
+        if (successCount > 0) {
+            try {
+                List<CompanyVoiceRoboticCallees> allCallees = companyVoiceRoboticCalleesMapper.selectByRoboticId(taskId);
+                List<CompanyWxClient> companyWxClients = companyWxClientMapper.selectListByRoboticId(taskId);
+                Map<String, CompanyWxClient> clientMp = companyWxClients.stream()
+                        .collect(Collectors.toMap(e -> e.getRoboticId() + "-" + e.getCustomerId(), e -> e, (a, b) -> a));
+                asyncCalleeProcessorService.generateCustomerInfo(allCallees, clientMp, robotic);
+            } catch (Exception e) {
+                log.warn("追加客户后生成AI标签信息异常 - taskId: {}", taskId, e);
+            }
+        }
+
+        // 7. 构建返回结果
+        Map<String, Object> resultData = new HashMap<>();
+        resultData.put("successCount", successCount);
+        if (!duplicateIds.isEmpty()) {
+            resultData.put("duplicateCustomerNames", getDuplicateCustomerNames(duplicateIds));
+        }
+        if (!errorMessages.isEmpty()) {
+            resultData.put("errorMessages", errorMessages);
+        }
+        return R.ok(resultData);
+    }
+
+    /**
+     * 根据客户ID列表获取重复客户名称
+     */
+    private String getDuplicateCustomerNames(List<Long> customerIds) {
+        if (customerIds == null || customerIds.isEmpty()) {
+            return "";
+        }
+        try {
+            List<CrmCustomer> customers = crmCustomerService.selectCrmCustomerListByIds(
+                    customerIds.stream().map(String::valueOf).collect(Collectors.joining(",")));
+            return customers.stream().map(CrmCustomer::getCustomerName).filter(Objects::nonNull).collect(Collectors.joining("、"));
+        } catch (Exception e) {
+            return customerIds.stream().map(String::valueOf).collect(Collectors.joining("、"));
+        }
+    }
+
+    private boolean workflowHasAddWxNode(Long workflowId) {
+        if (workflowId == null) {
+            return false;
+        }
+        List<CompanyWorkflowNode> nodes = companyWorkflowNodeMapper.selectCompanyWorkflowNodeByWorkflowId(workflowId);
+        if (nodes == null || nodes.isEmpty()) {
+            return false;
+        }
+        return nodes.stream().anyMatch(n ->
+                NodeTypeEnum.AI_ADD_WX_TASK_NEW.getCode().equals(n.getNodeType())
+                        || NodeTypeEnum.AI_QW_ADD_WX_TASK.getCode().equals(n.getNodeType())
+        );
+    }
+
+    private void pauseEasyCallTask(CompanyVoiceRobotic robotic) {
+        try {
+            CompanySiptaskInfo query = new CompanySiptaskInfo();
+            query.setTaskId(robotic.getId());
+            List<CompanySiptaskInfo> sipTasks = companySiptaskInfoMapper.selectCompanySiptaskInfoList(query);
+            if (sipTasks != null && !sipTasks.isEmpty()) {
+                for (CompanySiptaskInfo sipTask : sipTasks) {
+                    if (sipTask.getBatchId() != null) {
+                        easyCallTaskControlService.pauseTask(sipTask.getBatchId());
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.error("暂停EasyCall任务异常, roboticId={}", robotic.getId(), e);
+        }
+    }
+    /**
+     * 恢复该任务关联的所有EasyCall外呼任务
+     */
+    private void resumeEasyCallTask(CompanyVoiceRobotic robotic) {
+        try {
+            CompanySiptaskInfo query = new CompanySiptaskInfo();
+            query.setTaskId(robotic.getId());
+            List<CompanySiptaskInfo> sipTasks = companySiptaskInfoMapper.selectCompanySiptaskInfoList(query);
+            if (sipTasks != null && !sipTasks.isEmpty()) {
+                for (CompanySiptaskInfo sipTask : sipTasks) {
+                    if (sipTask.getBatchId() != null) {
+                        easyCallTaskControlService.resumeTask(sipTask.getBatchId());
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.error("恢复EasyCall任务异常, roboticId={}", robotic.getId(), e);
+        }
+    }
+
+    /**
+     * 异步恢复暂停期间被阻塞的工作流实例
+     * 查询该任务下所有PAUSED(4)/WAITING(5)状态的工作流实例,分批处理
+     *
+     * @param taskId 任务ID
+     */
+    @Async("cidWorkFlowExecutor")
+    public void resumePausedInstances(Long taskId) {
+        try {
+            log.info("开始恢复暂停实例, taskId={}", taskId);
+
+            // 先查询该任务下所有业务记录的ID(businessKey存的是CompanyVoiceRoboticBusiness.id,而非taskId)
+            LambdaQueryWrapper<CompanyVoiceRoboticBusiness> bizWrapper = new LambdaQueryWrapper<>();
+            bizWrapper.eq(CompanyVoiceRoboticBusiness::getRoboticId, taskId)
+                    .select(CompanyVoiceRoboticBusiness::getId);
+            List<CompanyVoiceRoboticBusiness> bizList = companyVoiceRoboticBusinessMapper.selectList(bizWrapper);
+            if (bizList == null || bizList.isEmpty()) {
+                log.info("未找到业务记录, taskId={}", taskId);
+                return;
+            }
+            List<Long> businessIds = bizList.stream().map(CompanyVoiceRoboticBusiness::getId).collect(java.util.stream.Collectors.toList());
+
+            // 查询该任务下所有PAUSED和WAITING状态的工作流实例
+            LambdaQueryWrapper<CompanyAiWorkflowExec> queryWrapper = new LambdaQueryWrapper<>();
+            queryWrapper.in(CompanyAiWorkflowExec::getBusinessKey, businessIds)
+                    .in(CompanyAiWorkflowExec::getStatus,
+                            ExecutionStatusEnum.PAUSED.getValue(),
+                            ExecutionStatusEnum.WAITING.getValue(),
+                            ExecutionStatusEnum.PENDING.getValue());
+            List<CompanyAiWorkflowExec> execList = companyAiWorkflowExecMapper.selectList(queryWrapper);
+
+            if (execList == null || execList.isEmpty()) {
+                log.info("无需恢复的暂停实例, taskId={}", taskId);
+                return;
+            }
+
+            log.info("找到待恢复实例 {} 个, taskId={}", execList.size(), taskId);
+
+            int batchSize = 50;
+            for (int i = 0; i < execList.size(); i++) {
+                CompanyAiWorkflowExec exec = execList.get(i);
+                try {
+                    processResumeInstance(exec);
+                } catch (Exception e) {
+                    log.error("恢复实例异常, instanceId={}", exec.getWorkflowInstanceId(), e);
+                }
+
+                // 每处理50个暂停2秒,避免压力过大
+                if ((i + 1) % batchSize == 0 && i + 1 < execList.size()) {
+                    try {
+                        Thread.sleep(2000);
+                    } catch (InterruptedException ie) {
+                        Thread.currentThread().interrupt();
+                        log.warn("恢复暂停实例被中断, taskId={}", taskId);
+                        return;
+                    }
+                }
+            }
+
+            log.info("恢复暂停实例完成, taskId={}, count={}", taskId, execList.size());
+        } catch (Exception e) {
+            log.error("恢复暂停实例整体异常, taskId={}", taskId, e);
+        }
+    }
+    /**
+     * 处理单个待恢复的工作流实例
+     */
+    private void processResumeInstance(CompanyAiWorkflowExec exec) {
+        Integer status = exec.getStatus();
+        Integer nodeType = exec.getCurrentNodeType();
+        String instanceId = exec.getWorkflowInstanceId();
+        String nodeKey = exec.getCurrentNodeKey();
+
+        // exec.getVariables() 存储的是 variables Map 的 JSON(不是完整 ExecutionContext)
+        Map<String, Object> inputData = new HashMap<>();
+        if (StringUtils.isNotEmpty(exec.getVariables())) {
+            try {
+                Map<String, Object> parsed = JSON.parseObject(exec.getVariables(), Map.class);
+                if (parsed != null) {
+                    inputData = parsed;
+                }
+            } catch (Exception e) {
+                log.error("反序列化variables失败, instanceId={}", instanceId, e);
+                return;
+            }
+        }
+
+        // PAUSED状态处理
+        if (ExecutionStatusEnum.PAUSED.getValue() == status) {
+            // AI_CALL_TASK + PAUSED:检查是否有回调
+            if (NodeTypeEnum.AI_CALL_TASK.getValue().equals(nodeType)) {
+                if (hasCallbackReceived(inputData)) {
+                    log.info("恢复AI外呼PAUSED实例, instanceId={}", instanceId);
+                    companyWorkflowEngine.resumeFromBlockingNode(instanceId, nodeKey, inputData);
+                }
+            }
+            // AI_ADD_WX_TASK / AI_QW_ADD_WX_TASK + PAUSED:检查是否有回调
+            else if (NodeTypeEnum.AI_ADD_WX_TASK.getValue().equals(nodeType)
+                    || NodeTypeEnum.AI_QW_ADD_WX_TASK.getValue().equals(nodeType)
+                    || NodeTypeEnum.AI_ADD_WX_TASK_NEW.getValue().equals(nodeType)) {
+                if (hasCallbackReceived(inputData)) {
+                    log.info("恢复加微PAUSED实例, instanceId={}, nodeType={}", instanceId, nodeType);
+                    companyWorkflowEngine.resumeFromBlockingNode(instanceId, nodeKey, inputData);
+                }
+            }
+        }
+        // WAITING状态处理
+        else if (ExecutionStatusEnum.WAITING.getValue() == status) {
+            // 优先从 variables 中获取延时目标节点key(暂停时同步存入的)
+            String targetNodeKey = inputData.containsKey("_delayTargetNodeKey")
+                    ? (String) inputData.get("_delayTargetNodeKey") : nodeKey;
+            // AI_CALL_TASK + WAITING:延时已过期,触发timeDoExecute
+            if (NodeTypeEnum.AI_CALL_TASK.getValue().equals(nodeType)) {
+                log.info("恢复AI外呼WAITING实例, instanceId={}, targetNodeKey={}", instanceId, targetNodeKey);
+                companyWorkflowEngine.timeDoExecute(instanceId, targetNodeKey, inputData);
+            }
+            // AI_ADD_WX_TASK / AI_QW_ADD_WX_TASK / AI_ADD_WX_TASK_NEW + WAITING
+            else if (NodeTypeEnum.AI_ADD_WX_TASK.getValue().equals(nodeType)
+                    || NodeTypeEnum.AI_QW_ADD_WX_TASK.getValue().equals(nodeType)
+                    || NodeTypeEnum.AI_ADD_WX_TASK_NEW.getValue().equals(nodeType)) {
+                if (hasCallbackReceived(inputData)) {
+                    log.info("恢复加微WAITING实例, instanceId={}, nodeType={}", instanceId, nodeType);
+                    companyWorkflowEngine.resumeFromBlockingNode(instanceId, nodeKey, inputData);
+                } else {
+                    // 无回调但有延时目标节点(加微延时场景)
+                    if (inputData.containsKey("_delayTargetNodeKey")) {
+                        log.info("恢复加微WAITING延时实例, instanceId={}, targetNodeKey={}", instanceId, targetNodeKey);
+                        companyWorkflowEngine.timeDoExecute(instanceId, targetNodeKey, inputData);
+                    }
+                }
+            }
+        }
+        // PENDING状态处理:重建Redis CONTINUE:TIMER:EXECUTE key
+        else if (ExecutionStatusEnum.PENDING.getValue() == status) {
+            if (!inputData.isEmpty()) {
+                ExecutionContext resumeContext = new ExecutionContext();
+                resumeContext.setWorkflowInstanceId(instanceId);
+                resumeContext.setCurrentNodeKey(nodeKey);
+                resumeContext.setVariables(inputData);
+                resumeContext.setBusinessId(exec.getBusinessKey());
+                log.info("重建PENDING实例Redis key, instanceId={}", instanceId);
+                rebuildContinueTimerKey(exec, resumeContext);
+            }
+        }
+    }
+
+    /**
+     * 检查variables中是否有回调标记
+     */
+    private boolean hasCallbackReceived(Map<String, Object> variables) {
+        if (variables == null) return false;
+        Object flag = variables.get("pause_callback_received");
+        return Boolean.TRUE.equals(flag) || "true".equals(String.valueOf(flag));
+    }
+
+    /**
+     * 重建CONTINUE:TIMER:EXECUTE Redis key
+     */
+    private void rebuildContinueTimerKey(CompanyAiWorkflowExec exec, ExecutionContext context) {
+        try {
+            Integer groupNo = exec.getCidGroupNo();
+            Date now = new Date();
+            int hour = now.getHours();
+            int minute = now.getMinutes();
+            String redisKey = "CONTINUE:TIMER:EXECUTE:" + groupNo + ":" + hour + ":" + minute;
+            String contextJson = JSON.toJSONString(context);
+            redisCache2.setCacheObject(redisKey + ":" + exec.getWorkflowInstanceId(), contextJson);
+        } catch (Exception e) {
+            log.error("重建CONTINUE:TIMER:EXECUTE Redis key异常, instanceId={}", exec.getWorkflowInstanceId(), e);
+        }
+    }
 }

+ 45 - 18
fs-service/src/main/java/com/fs/company/service/impl/CompanyWorkflowEngineImpl.java

@@ -4,6 +4,7 @@ import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fs.common.core.redis.RedisCache;
 import com.fs.common.exception.CustomException;
 import com.fs.common.utils.StringUtils;
 import com.fs.company.domain.*;
@@ -29,6 +30,7 @@ import org.springframework.stereotype.Service;
 import java.time.LocalDateTime;
 import java.time.LocalTime;
 import java.util.*;
+import java.util.concurrent.TimeUnit;
 
 /**
  * @author MixLiu
@@ -78,6 +80,9 @@ public class CompanyWorkflowEngineImpl implements CompanyWorkflowEngine {
     @Autowired
     private CloudHostProper cloudHostProper;
 
+    @Autowired
+    private RedisCache redisCache;
+
     /**
      * 初始化工作流
      * 创建工作流实例并保存初始状态
@@ -102,8 +107,6 @@ public class CompanyWorkflowEngineImpl implements CompanyWorkflowEngine {
                     definition.getStartNodeKey(), context, definition);
 
             log.info("工作流初始化成功: {} -> {}", workflowInstanceId, workflowDefinitionId);
-            //为任务创建sip任务并存入表数据
-            createSipTask(Long.parseLong(inputVariables.get("roboticId").toString()),workflowDefinitionId);
             return ExecutionResult.success()
                     .nextNodeKey(definition.getStartNodeKey())
                     .workflowInstanceId(workflowInstanceId).build();
@@ -572,37 +575,58 @@ public class CompanyWorkflowEngineImpl implements CompanyWorkflowEngine {
      * @param workFlowId
      */
     public Long createSipTask(Long roboticId, Long workFlowId) {
-        try {
-            List<String> nodeTypes = Arrays.asList(NodeTypeEnum.AI_CALL_TASK.getCode());
-            CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectCompanyVoiceRoboticById(roboticId);
-            List<CompanyWorkflowNode> companyWorkflowNodes = companyWorkflowNodeMapper.selectNodesByWorkflowIdAndTypes(workFlowId, nodeTypes);
-            //为所有外呼节点创建任务的对应sip外呼任务
-            for (CompanyWorkflowNode callNode : companyWorkflowNodes) {
+        List<String> nodeTypes = Arrays.asList(NodeTypeEnum.AI_CALL_TASK.getCode());
+        CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectCompanyVoiceRoboticById(roboticId);
+        List<CompanyWorkflowNode> companyWorkflowNodes = companyWorkflowNodeMapper.selectNodesByWorkflowIdAndTypes(workFlowId, nodeTypes);
+        for (CompanyWorkflowNode callNode : companyWorkflowNodes) {
+            String lockKey = "sipTask:lock:" + roboticId + ":" + callNode.getNodeKey();
+            boolean locked = redisCache.setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
+            if (!locked) {
+                log.info("createSipTask: 其他线程正在创建SIP任务,等待后重试 - roboticId: {}, nodeKey: {}", roboticId, callNode.getNodeKey());
+                for (int i = 0; i < 20; i++) {
+                    try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
+                    CompanySiptaskInfo existing = companySiptaskInfoMapper.selectSipTaskInfoByTaskIdAndNodeKey(roboticId, callNode.getNodeKey());
+                    if (existing != null && existing.getBatchId() != null) {
+                        return existing.getBatchId();
+                    }
+                }
+                log.warn("createSipTask: 等待超时,尝试直接创建 - roboticId: {}, nodeKey: {}", roboticId, callNode.getNodeKey());
+            }
+            try {
+                CompanySiptaskInfo existingSipTask = companySiptaskInfoMapper.selectSipTaskInfoByTaskIdAndNodeKey(roboticId, callNode.getNodeKey());
+                if (existingSipTask != null && existingSipTask.getBatchId() != null) {
+                    log.info("createSipTask: SIP任务已存在,跳过创建 - roboticId: {}, nodeKey: {}, batchId: {}", roboticId, callNode.getNodeKey(), existingSipTask.getBatchId());
+                    return existingSipTask.getBatchId();
+                }
                 String nodeConfig = callNode.getNodeConfig();
                 AiCallConfigVO callConfigVo = JSONObject.parseObject(nodeConfig, AiCallConfigVO.class);
                 EasyCallCreateTaskParam createParam = new EasyCallCreateTaskParam();
-                // 任务名称:使用任务名称_工作流id_节点key
                 createParam.setBatchName(cloudHostProper.getCompanyName()+"-"+robotic.getName() + "_" + workFlowId + "_" + callNode.getNodeKey());
                 if (null != callConfigVo.getMaxConcurrency()) {
                     createParam.setThreadNum(Long.valueOf(callConfigVo.getMaxConcurrency()));
                 } else {
                     createParam.setThreadNum(3L);
                 }
-                // AI 外呼模式
                 createParam.setTaskType(1);
-                // 外呼线路(网关)
                 createParam.setGatewayId(callConfigVo.getGatewayId());
-                // 大模型底座
                 createParam.setLlmAccountId(callConfigVo.getLlmAccountId());
-                // 音色编号
                 createParam.setVoiceCode(callConfigVo.getVoiceCode());
-                // 音色来源(如未配置默认留空,由 EasyCallCenter365 使用默认值)
                 createParam.setVoiceSource(callConfigVo.getVoiceSource());
-                // 技能组(转人工客服分组,可选)
                 createParam.setGroupId(callConfigVo.getBusiGroupId());
-                // 模型参数
                 createParam.setTtsModels(callConfigVo.getTtsModels());
 
+                if (callConfigVo.getExtensionList() != null && !callConfigVo.getExtensionList().isEmpty()) {
+                    createParam.setAiTransferType("extension");
+                    StringBuilder aiTransferExtNumber = new StringBuilder();
+                    callConfigVo.getExtensionList().forEach(extension -> {
+                        aiTransferExtNumber.append(extension.getExtensionNum()).append(" ");
+                    });
+                    if (null != aiTransferExtNumber && aiTransferExtNumber.length() > 0) {
+                        aiTransferExtNumber.deleteCharAt(aiTransferExtNumber.length() - 1);
+                    }
+                    createParam.setAiTransferExtNumber(aiTransferExtNumber.toString());
+                }
+
                 EasyCallTaskVO task = easyCallService.createTask(createParam, null);
                 if (task == null || task.getBatchId() == null) {
                     log.error("createSipTask: 创建 EasyCall 任务失败 - workflowInstanceId: {}", workFlowId);
@@ -616,9 +640,12 @@ public class CompanyWorkflowEngineImpl implements CompanyWorkflowEngine {
                 sipTaskInfo.setTaskJson(JSONObject.toJSONString(task));
                 companySiptaskInfoMapper.insertCompanySiptaskInfo(sipTaskInfo);
                 return task.getBatchId();
+            } catch (Exception ex) {
+                log.error("创建SIP任务失败:{}", ex);
+                return null;
+            } finally {
+                redisCache.deleteObject(lockKey);
             }
-        } catch (Exception ex) {
-            log.error("创建SIP任务失败:{}", ex);
         }
         return null;
     }

+ 14 - 0
fs-service/src/main/java/com/fs/company/service/impl/CompanyWorkflowServiceImpl.java

@@ -17,6 +17,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.util.*;
+import java.util.stream.Collectors;
 
 /**
  * AI工作流Service业务层处理
@@ -699,4 +700,17 @@ public class CompanyWorkflowServiceImpl implements ICompanyWorkflowService {
 
         return workflowId;
     }
+
+    @Override
+    public List<String> selectNodeTypeCodesByWorkflowId(Long workflowId) {
+        List<CompanyWorkflowNode> nodes = companyWorkflowNodeMapper.selectCompanyWorkflowNodeByWorkflowId(workflowId);
+        if (nodes == null || nodes.isEmpty()) {
+            return new ArrayList<>();
+        }
+        return nodes.stream()
+                .map(CompanyWorkflowNode::getNodeType)
+                .filter(Objects::nonNull)
+                .distinct()
+                .collect(Collectors.toList());
+    }
 }

+ 38 - 0
fs-service/src/main/java/com/fs/company/service/impl/CrmCustomerCallLogServiceImpl.java

@@ -0,0 +1,38 @@
+package com.fs.company.service.impl;
+
+import com.fs.company.domain.CrmCustomerCallLog;
+import com.fs.company.mapper.CrmCustomerCallLogMapper;
+import com.fs.company.service.ICrmCustomerCallLogService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * 客户通话记录Service业务层处理
+ *
+ * @author fs
+ * @date 2026-05-10
+ */
+@Service
+public class CrmCustomerCallLogServiceImpl implements ICrmCustomerCallLogService {
+
+    @Autowired
+    private CrmCustomerCallLogMapper crmCustomerCallLogMapper;
+
+    /**
+     * 查询客户通话记录列表
+     *
+     * @param crmCustomerCallLog 客户通话记录
+     * @return 客户通话记录
+     */
+    @Override
+    public List<CrmCustomerCallLog> selectCrmCustomerCallLogList(CrmCustomerCallLog crmCustomerCallLog) {
+        return crmCustomerCallLogMapper.selectCrmCustomerCallLogList(crmCustomerCallLog);
+    }
+
+    @Override
+    public Long selectSumBillingMinute(CrmCustomerCallLog crmCustomerCallLog) {
+        return crmCustomerCallLogMapper.selectSumBillingMinute(crmCustomerCallLog);
+    }
+}

+ 52 - 0
fs-service/src/main/java/com/fs/company/service/impl/call/EasyCallTaskControlService.java

@@ -0,0 +1,52 @@
+package com.fs.company.service.impl.call;
+
+import com.fs.company.service.easycall.IEasyCallService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+/**
+ * EasyCallCenter365 任务暂停/恢复控制服务
+ * <p>
+ * 通过HTTP接口调用EasyCall平台,实现对第三方外呼任务的暂停和恢复操作。
+ * 当前为Mock实现,待提供真实接口地址后替换。
+ */
+@Service
+@Slf4j
+public class EasyCallTaskControlService {
+
+    @Value("${easycall.api.base-url:http://localhost:8870}")
+    private String easyCallBaseUrl;
+
+    @Autowired
+    private IEasyCallService easyCallService;
+
+    /**
+     * 暂停EasyCall外呼任务
+     *
+     * @param batchId EasyCall任务批次ID
+     */
+    public void pauseTask(Long batchId) {
+        try {
+            easyCallService.stopTask(batchId, null);
+            log.info("成功暂停EasyCall任务: batchId={}", batchId);
+        } catch (Exception e) {
+            log.error("暂停EasyCall任务失败: batchId={}", batchId, e);
+        }
+    }
+
+    /**
+     * 恢复EasyCall外呼任务
+     *
+     * @param batchId EasyCall任务批次ID
+     */
+    public void resumeTask(Long batchId) {
+        try {
+            easyCallService.startTask(batchId, null);
+            log.info("成功恢复EasyCall任务: batchId={}", batchId);
+        } catch (Exception e) {
+            log.error("恢复EasyCall任务失败: batchId={}", batchId, e);
+        }
+    }
+}

+ 85 - 0
fs-service/src/main/java/com/fs/company/service/impl/call/node/AbstractWorkflowNode.java

@@ -9,6 +9,7 @@ import com.fs.common.utils.spring.SpringUtils;
 import com.fs.company.domain.*;
 import com.fs.company.mapper.*;
 import com.fs.company.param.ExecutionContext;
+import com.fs.company.service.ICompanyVoiceRoboticService;
 import com.fs.company.service.IWorkflowNode;
 import com.fs.company.vo.ExecutionResult;
 import com.fs.enums.ExecutionStatusEnum;
@@ -45,6 +46,7 @@ public abstract class AbstractWorkflowNode implements IWorkflowNode {
     public static final WorkflowNodeFactory workflowNodeFactory = SpringUtils.getBean(WorkflowNodeFactory.class);
     public static final ObjectMapper objectMapper = new ObjectMapper();
     public static final RedissonClient redissonClient = SpringUtils.getBean(RedissonClient.class);
+    public static final ICompanyVoiceRoboticService companyVoiceRoboticService = SpringUtils.getBean(ICompanyVoiceRoboticService.class);
     protected static final String NODE_EXEC_LOCK_PREFIX = "node_exec_lock_";
     protected static final String CONTINUE_TIMER_EXECUTE_KEY = "CONTINUE:TIMER:EXECUTE:%s:%s:%s:";
     public static final String CONTINUE_TIMER_EXECUTE_KEY_PREFIX = "CONTINUE:TIMER:EXECUTE:%s:*";
@@ -108,6 +110,13 @@ public abstract class AbstractWorkflowNode implements IWorkflowNode {
 
     @Override
     public ExecutionResult continueExecute(ExecutionContext context) {
+        // 暂停守卫:任务暂停时不处理继续请求
+        Long continueTaskId = getTaskIdFromContext(context);
+        if (isTaskPaused(continueTaskId)) {
+            log.info("任务已暂停,跳过continueExecute - taskId: {}, workflowInstanceId: {}, nodeKey: {}",
+                    continueTaskId, context.getWorkflowInstanceId(), nodeKey);
+            return null;
+        }
         if(!runnable(context) && getType() != NodeTypeEnum.END){
             log.info("当前流程已到达结束节点,节点继续执行失败:- {},- {} -,{}" , nodeName, nodeKey, context.getWorkflowInstanceId());
             return null;
@@ -477,6 +486,23 @@ public abstract class AbstractWorkflowNode implements IWorkflowNode {
         return context;
     }
 
+    /**
+     * 创建带businessId(roboticId)的执行上下文,用于延时任务中暂停守卫检查
+     */
+    protected ExecutionContext createExecutionContextWithBusinessId(String workflowInstanceId, String nodeKey) {
+        ExecutionContext context = createExecutionContext(workflowInstanceId, nodeKey);
+        try {
+            CompanyVoiceRoboticBusiness business = companyVoiceRoboticBusinessMapper
+                    .selectCompanyVoiceRoboticBusinessByWorkflowInstanceId(workflowInstanceId);
+            if (business != null) {
+                context.setBusinessId(business.getRoboticId());
+            }
+        } catch (Exception e) {
+            log.warn("createExecutionContextWithBusinessId获取roboticId失败 - workflowInstanceId: {}", workflowInstanceId, e);
+        }
+        return context;
+    }
+
     protected void execPointNextNode(ExecutionContext context) {
         try {
             CompanyAiWorkflowExec update = new CompanyAiWorkflowExec();
@@ -499,6 +525,14 @@ public abstract class AbstractWorkflowNode implements IWorkflowNode {
         if (StringUtils.isBlank(edge.getTargetNodeKey())) {
             return;
         }
+        // 暂停守卫:任务暂停时不触发下一节点执行,将实例状态设为READY并指向下一节点
+        Long nextTaskId = getTaskIdFromContext(context);
+        if (isTaskPaused(nextTaskId)) {
+            log.info("任务已暂停,runNextNode中止执行 - taskId: {}, workflowInstanceId: {}, nextNodeKey: {}",
+                    nextTaskId, context.getWorkflowInstanceId(), edge.getTargetNodeKey());
+            updateExecToReady(context, edge.getTargetNodeKey());
+            return;
+        }
         ExecutionContext nextContext = context.clone();
         CompanyWorkflowNode nextNode = getNodeByKey(edge.getTargetNodeKey());
         nextContext.setCurrentNodeKey(nextNode.getNodeKey());
@@ -550,4 +584,55 @@ public abstract class AbstractWorkflowNode implements IWorkflowNode {
     public static String getContinueTimerExecuteKey(Integer groupNo, LocalTime time) {
         return String.format(CONTINUE_TIMER_EXECUTE_KEY, groupNo,time.getHour(), time.getMinute());
     }
+
+    /**
+     * 从执行上下文中获取任务ID(roboticId)
+     * 通过workflowInstanceId查询业务关联表获取
+     */
+    protected Long getTaskIdFromContext(ExecutionContext context) {
+        try {
+            CompanyVoiceRoboticBusiness business = companyVoiceRoboticBusinessMapper
+                    .selectCompanyVoiceRoboticBusinessByWorkflowInstanceId(context.getWorkflowInstanceId());
+            if (business != null) {
+                return business.getRoboticId();
+            }
+        } catch (Exception e) {
+            log.warn("获取taskId失败 - workflowInstanceId: {}", context.getWorkflowInstanceId(), e);
+        }
+        return null;
+    }
+
+    /**
+     * 判断任务是否处于暂停状态
+     */
+    protected boolean isTaskPaused(Long taskId) {
+        if (taskId == null) {
+            return false;
+        }
+        return companyVoiceRoboticService.isTaskPaused(taskId);
+    }
+
+    /**
+     * 将工作流实例状态设为READY并指向下一节点(暂停守卫使用)
+     * 恢复后CidTask定时扫描会自然拾取READY状态的实例继续执行
+     */
+    protected void updateExecToReady(ExecutionContext context, String nextNodeKey) {
+        try {
+            CompanyWorkflowNode nextNode = getNodeByKey(nextNodeKey);
+            CompanyAiWorkflowExec update = new CompanyAiWorkflowExec();
+            update.setWorkflowInstanceId(context.getWorkflowInstanceId());
+            update.setStatus(ExecutionStatusEnum.READY.getValue());
+            update.setCurrentNodeKey(nextNodeKey);
+            update.setCurrentNodeName(nextNode.getNodeName());
+            update.setCurrentNodeType(NodeTypeEnum.fromCode(nextNode.getNodeType()).getValue());
+            update.setLastUpdateTime(LocalDateTime.now());
+            update.setVariables(objectMapper.writeValueAsString(context.getVariables()));
+            companyAiWorkflowExecMapper.updateByWorkflowInstanceId(update);
+            log.info("任务暂停,工作流实例已设为READY状态 - workflowInstanceId: {}, nextNodeKey: {}",
+                    context.getWorkflowInstanceId(), nextNodeKey);
+        } catch (Exception e) {
+            log.error("更新工作流为READY状态失败 - workflowInstanceId: {}, nextNodeKey: {}",
+                    context.getWorkflowInstanceId(), nextNodeKey, e);
+        }
+    }
 }

+ 49 - 7
fs-service/src/main/java/com/fs/company/service/impl/call/node/AiAddWxTaskNewNode.java

@@ -2,6 +2,7 @@ package com.fs.company.service.impl.call.node;
 
 import cn.hutool.core.util.RandomUtil;
 import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.fs.common.constant.Constants;
 import com.fs.common.core.redis.RedisCacheT;
 import com.fs.common.exception.CustomException;
@@ -18,6 +19,7 @@ import com.fs.company.vo.AiAddWxConfigVO;
 import com.fs.company.vo.AiCallWorkflowConditionVo;
 import com.fs.company.vo.ExecutionResult;
 import com.fs.crm.domain.CrmCustomer;
+import com.fs.crm.mapper.CrmCustomerMapper;
 import com.fs.crm.service.impl.CrmCustomerServiceImpl;
 import com.fs.enums.ExecutionStatusEnum;
 import com.fs.enums.NodeTypeEnum;
@@ -25,6 +27,7 @@ import com.fs.system.service.ISysConfigService;
 import com.fs.wxcid.domain.WxContact;
 import com.fs.wxcid.mapper.CidIpadServerMapper;
 import com.fs.wxcid.mapper.WxContactMapper;
+import com.fs.wxcid.utils.TenantHelper;
 import com.fs.wxwork.service.WxIpadService;
 import lombok.extern.slf4j.Slf4j;
 
@@ -49,6 +52,7 @@ public class AiAddWxTaskNewNode extends AbstractWorkflowNode {
     public static final String DELAY_ADD_WX_NEW_KEY = "addWxTaskNew:delay:%s:%s:%s:";
     private static final CompanyWxDialogMapper companyWxDialogMapper = SpringUtils.getBean(CompanyWxDialogMapper.class);
     private static final CrmCustomerServiceImpl crmCustomerService = SpringUtils.getBean(CrmCustomerServiceImpl.class);
+    private static final CrmCustomerMapper crmCustomerMapper = SpringUtils.getBean(CrmCustomerMapper.class);
     private static final ObjectPlaceholderResolver objectPlaceholderResolver = SpringUtils.getBean(ObjectPlaceholderResolver.class);
     private static final ISysConfigService sysConfigService = SpringUtils.getBean(ISysConfigService.class);
     private static final CidIpadServerMapper cidIpadServerMapper = SpringUtils.getBean(CidIpadServerMapper.class);
@@ -97,10 +101,25 @@ public class AiAddWxTaskNewNode extends AbstractWorkflowNode {
             }
 
             WxContact wxQuery = companyAiWorkflowExecMapper.selectWxContectByWorkflowInstanceId(context.getWorkflowInstanceId());
-            wxQuery.setRemark(wxQuery.getRemark() + RandomUtil.randomNumbers(10));
-            wxQuery.setNickName(wxQuery.getRemark());
-            wxQuery.setFriends(0);
-            wxContactMapper.insert(wxQuery);
+            Long crmUserId = wxQuery.getCrmUserId();
+            WxContact wxContact = wxContactMapper.selectOne(new QueryWrapper<WxContact>().eq("phone", wxQuery.getPhone()).eq("account_id", wxQuery.getAccountId()));
+            if(wxContact != null){
+                wxQuery = wxContact;
+                wxQuery.setRemark(wxQuery.getRemark());
+                wxQuery.setNickName(wxQuery.getRemark());
+                wxQuery.setFriends(0);
+                wxQuery.setCustomerId(crmUserId);
+                wxContactMapper.updateById(wxQuery);
+            }else{
+                wxQuery.setRemark(wxQuery.getRemark());
+                wxQuery.setNickName(wxQuery.getRemark());
+                wxQuery.setFriends(0);
+                wxQuery.setCrmUserId(crmUserId);
+                wxContactMapper.insert(wxQuery);
+            }
+            CrmCustomer crmCustomer = crmCustomerMapper.selectById(crmUserId);
+            crmCustomer.setWxContactId(wxQuery.getId());
+            crmCustomerService.updateCrmCustomer(crmCustomer);
 
             CompanyVoiceRoboticBusiness roboticBusiness = getRoboticBusiness(context.getWorkflowInstanceId());
             CompanyWxClient update = new CompanyWxClient();
@@ -111,7 +130,8 @@ public class AiAddWxTaskNewNode extends AbstractWorkflowNode {
             pendingAddWx(wxQuery.getAccountId(), wxQuery.getRemark(),
                     wxQuery.getPhone(),
                     addWxConfig.getDialogId(),
-                    wxQuery.getCrmUserId(),
+                    crmUserId,
+                    wxQuery.getId(),
                     context.getWorkflowInstanceId(),
                     context.getCurrentNodeKey());
             return ExecutionResult.paused()
@@ -187,6 +207,24 @@ public class AiAddWxTaskNewNode extends AbstractWorkflowNode {
             log.error("当前节点已流转 ,目标:{},实际:{}", nodeKey, exec.getCurrentNodeKey());
             return;
         }
+        // 从exec中恢复关键变量,确保延时Redis中的context包含暂停守卫所需的roboticId等信息
+        if (exec.getVariables() != null) {
+            Map<String, Object> savedVariables = JSONObject.parseObject(exec.getVariables(), Map.class);
+            if (savedVariables != null) {
+                Object roboticId = savedVariables.get("roboticId");
+                if (roboticId != null) {
+                    context.setVariable("roboticId", roboticId);
+                }
+                Object businessId = savedVariables.get("businessId");
+                if (businessId != null) {
+                    context.setVariable("businessId", businessId);
+                }
+                Object cidGroupNo = savedVariables.get("cidGroupNo");
+                if (cidGroupNo != null) {
+                    context.setVariable("cidGroupNo", cidGroupNo);
+                }
+            }
+        }
         // 更新加微日志执行状态
         super.updateLogStatusIfExist(context, ExecutionStatusEnum.PAUSED, ExecutionStatusEnum.WAITING);
         super.asyncWorkflowForBlockingNode(context.getWorkflowInstanceId(), nodeKey, context, ExecutionStatusEnum.WAITING);
@@ -278,7 +316,7 @@ public class AiAddWxTaskNewNode extends AbstractWorkflowNode {
      * @param instanceId
      * @param nodeKey
      */
-    private void pendingAddWx(Long accountId, String remark, String phone, Long dialogId, Long crmUserId,String instanceId,String nodeKey) {
+    private void pendingAddWx(Long accountId, String remark, String phone, Long dialogId, Long crmUserId, Long wxContactId,String instanceId,String nodeKey) {
         try {
             // 1. 获取基础数据
             CompanyWxAccount companyWxAccount = companyWxAccountMapper.selectCompanyWxAccountById(accountId);
@@ -306,7 +344,11 @@ public class AiAddWxTaskNewNode extends AbstractWorkflowNode {
             JSONObject bizJson = new JSONObject()
                     .fluentPut("instanceId",instanceId)
                     .fluentPut("nodeKey",nodeKey)
-                    .fluentPut("accountId",companyWxAccount.getId());
+                    .fluentPut("accountId",companyWxAccount.getId())
+                    .fluentPut("wxContactId",wxContactId)
+                    .fluentPut("crmUserId",crmUserId)
+                    .fluentPut("accountId",companyWxAccount.getId())
+                    .fluentPut("tenantId", TenantHelper.getTenantId());
             param.setBizJson(bizJson.toJSONString());
             wxService.addWx(param);
         } catch (Exception ex) {

+ 4 - 2
fs-service/src/main/java/com/fs/company/service/impl/call/node/AiAddWxTaskNode.java

@@ -13,6 +13,8 @@ import com.fs.company.param.ExecutionContext;
 import com.fs.company.vo.AiAddWxConfigVO;
 import com.fs.company.vo.AiCallWorkflowConditionVo;
 import com.fs.company.vo.ExecutionResult;
+
+import java.util.concurrent.TimeUnit;
 import com.fs.enums.ExecutionStatusEnum;
 import com.fs.enums.NodeTypeEnum;
 import lombok.extern.slf4j.Slf4j;
@@ -256,7 +258,7 @@ public class AiAddWxTaskNode extends AbstractWorkflowNode {
      * @param workflowInstanceId
      */
     public void doneAddwx(String workflowInstanceId) {
-        ExecutionContext context = createExecutionContext(workflowInstanceId, nodeKey);
+        ExecutionContext context = createExecutionContextWithBusinessId(workflowInstanceId, nodeKey);
         context.setVariable("lastNodeKey", nodeKey);
         //启动定时节点倒计时
         CompanyAiWorkflowExec exec = companyAiWorkflowExecMapper.selectByWorkflowInstanceId(context.getWorkflowInstanceId());
@@ -289,7 +291,7 @@ public class AiAddWxTaskNode extends AbstractWorkflowNode {
                     String redisKey = getDelayAddWxKeyPrefix(exec.getCidGroupNo(),l) + workflowInstanceId;
                     ExecutionContext nextContext = context.clone();
                     nextContext.setCurrentNodeKey(edge.getTargetNodeKey());
-                    super.redisCache.setCacheObject(redisKey, nextContext);
+                    super.redisCache.setCacheObject(redisKey, nextContext, 1, TimeUnit.DAYS);
                 }
             }
         });

+ 51 - 14
fs-service/src/main/java/com/fs/company/service/impl/call/node/AiCallTaskNode.java

@@ -6,6 +6,7 @@ import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.spring.SpringUtils;
 import com.fs.company.domain.*;
 import com.fs.company.enums.BusinessTypeEnum;
+import com.fs.company.vo.easycall.EasyCallCommonAddCallListParam;
 import com.fs.company.mapper.*;
 import com.fs.company.param.CompanyVoiceRoboticCallBlacklistCheckParam;
 import com.fs.company.param.ExecutionContext;
@@ -18,17 +19,15 @@ import com.fs.company.vo.AiCallConfigVO;
 import com.fs.company.vo.AiCallWorkflowConditionVo;
 import com.fs.company.vo.CompanyVoiceRoboticCallBlacklistCheckVO;
 import com.fs.company.vo.ExecutionResult;
-import com.fs.company.vo.easycall.EasyCallCommonAddCallListParam;
-import com.fs.company.vo.easycall.EasyCallCreateTaskParam;
 import com.fs.company.vo.easycall.EasyCallPhoneItemVO;
-import com.fs.company.vo.easycall.EasyCallTaskVO;
 import com.fs.crm.domain.CrmCustomer;
 import com.fs.crm.service.ICrmCustomerService;
 import com.fs.enums.ExecutionStatusEnum;
 import com.fs.enums.NodeTypeEnum;
-import com.fs.enums.TaskTypeEnum;
 import com.fs.his.config.CidPhoneConfig;
+import com.fs.his.utils.PhoneUtil;
 import com.fs.system.service.ISysConfigService;
+import com.fs.wxcid.utils.TenantHelper;
 import lombok.extern.slf4j.Slf4j;
 
 import java.util.*;
@@ -115,8 +114,8 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
                 //拨通
                 if (condition.isCallConnected() && callRes.getCallTime() != null && callRes.getCallTime() > 0) {
                     //如果含有意向度过滤
-                    if (StringUtils.isNotBlank(condition.getIntention())) {
-                        if (condition.getIntention().equals(callRes.getIntention())) {
+                    if (condition.getIntention() != null && !condition.getIntention().isEmpty()) {
+                        if (condition.getIntention().contains(callRes.getIntention())) {
                             runNextNode(context, edge);
                             runnableCount++;
                             break;
@@ -133,6 +132,15 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
                 }
                 //未拨通
                 else if (!condition.isCallConnected() && (callRes.getCallTime() == null || Long.valueOf(0).equals(callRes.getCallTime()) || callRes.getCallAnswerTime() == null)) {
+                    //如果含有未拨通类型过滤
+                    if (condition.getHangupType() != null && !condition.getHangupType().isEmpty()) {
+                        if (StringUtils.isNotBlank(callRes.getHangupType()) && condition.getHangupType().contains(callRes.getHangupType())) {
+                            //匹配到未拨通类型,继续执行
+                        } else {
+                            log.info("流程:{},节点:{},未拨通类型设置:{},实际未拨通类型:{}, 未拨通类型不符设置中断执行,", context.getWorkflowInstanceId(), nodeKey, condition.getHangupType(), callRes.getHangupType());
+                            continue;
+                        }
+                    }
                     //延时操作
                     if (null != condition.getCallTime() && condition.getCallTime() > 0) {
                         //计算延时分片分钟
@@ -360,13 +368,22 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
         addListParam.setBatchId(batchId);
         // 构建号码条目,bizJson 传入默认客户信息占位符
         EasyCallPhoneItemVO phoneItem = new EasyCallPhoneItemVO();
-        phoneItem.setPhoneNum(callees.getPhone());
+        // todo
+        phoneItem.setPhoneNum(PhoneUtil.decryptPhone(callees.getPhone()));
+//        phoneItem.setPhoneNum(callees.getPhone());
         // bizJson 默认传入客户姓名占位,运行时可根据实际业务填充
-        phoneItem.setBizJson(new JSONObject().fluentPut("custName", callees.getUserName()).fluentPut("callBackUuid",callBackUuid).fluentPut("callBackUrl",callBackUrl));
+        phoneItem.setBizJson(new JSONObject().fluentPut("custName", callees.getUserName()).fluentPut("tenantId", TenantHelper.getTenantId()).fluentPut("callBackUuid",callBackUuid).fluentPut("callBackUrl",callBackUrl));
         addListParam.setPhoneList(Collections.singletonList(phoneItem));
-        easyCallService.addCommonCallList(addListParam, null);
-        log.info("workflowCallPhoneOne4EasyCall: 名单追加成功 - batchId: {}, phone: {}",
-                batchId, callees.getPhone());
+        Long gatewayId = callConfigVo.getGatewayId();
+
+        boolean b = easyCallService.addCommonCallList(addListParam, robotic.getCompanyId(), gatewayId);
+        if(b){
+            log.info("workflowCallPhoneOne4EasyCall: 名单追加成功 - batchId: {}, phone: {}",
+                    batchId, callees.getPhone());
+        }else{
+            log.error("workflowCallPhoneOne4EasyCall: 名单追失败 - batchId: {}, phone: {}",
+                    batchId, callees.getPhone());
+        }
 
         // 6. 启动外呼任务
         easyCallService.startTask(batchId, null);
@@ -414,9 +431,29 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
         if(null != sipTaskInfo && null != sipTaskInfo.getBatchId()){
             return  sipTaskInfo.getBatchId();
         }
-        //没有的情况下创建任务并返回
-        CompanyAiWorkflowExec companyAiWorkflowExec = companyAiWorkflowExecMapper.selectByWorkflowInstanceId(workflowInstanceId);
-        return companyWorkflowEngine.createSipTask(roboticId, companyAiWorkflowExec.getWorkflowId());
+        String lockKey = "sipTask:lock:" + roboticId + ":" + nodeKey;
+        boolean locked = super.redisCache.setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
+        if (!locked) {
+            log.info("getTaskBatchId: 其他线程正在创建SIP任务,等待后重试 - roboticId: {}, nodeKey: {}", roboticId, nodeKey);
+            for (int i = 0; i < 20; i++) {
+                try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
+                sipTaskInfo = companySiptaskInfoMapper.selectSipTaskInfoByTaskIdAndNodeKey(roboticId, nodeKey);
+                if (null != sipTaskInfo && null != sipTaskInfo.getBatchId()) {
+                    return sipTaskInfo.getBatchId();
+                }
+            }
+            log.warn("getTaskBatchId: 等待超时,尝试直接创建 - roboticId: {}, nodeKey: {}", roboticId, nodeKey);
+        }
+        try {
+            sipTaskInfo = companySiptaskInfoMapper.selectSipTaskInfoByTaskIdAndNodeKey(roboticId, nodeKey);
+            if (null != sipTaskInfo && null != sipTaskInfo.getBatchId()) {
+                return sipTaskInfo.getBatchId();
+            }
+            CompanyAiWorkflowExec companyAiWorkflowExec = companyAiWorkflowExecMapper.selectByWorkflowInstanceId(workflowInstanceId);
+            return companyWorkflowEngine.createSipTask(roboticId, companyAiWorkflowExec.getWorkflowId());
+        } finally {
+            super.redisCache.deleteObject(lockKey);
+        }
     }
 //    @Override
 //    protected void postExecute(ExecutionContext context, ExecutionResult result) {

+ 8 - 8
fs-service/src/main/java/com/fs/company/service/impl/call/node/AiQwAddWxTaskNode.java

@@ -87,13 +87,13 @@ public class AiQwAddWxTaskNode extends AbstractWorkflowNode {
             // 加微失败,根据条件判断走哪条边
             CompanyWorkflowEdge edge = cList.get(0);
             List<AiCallWorkflowConditionVo> conditions = JSONObject.parseArray(edge.getConditionExpr(), AiCallWorkflowConditionVo.class);
-             AiCallWorkflowConditionVo condition = conditions.get(0);
-                // 匹配失败条件
-                if (!condition.isAdd()) {
-                    log.info("加微失败,执行失败分支 - workflowInstanceId: {}", context.getWorkflowInstanceId());
-                    super.runNextNode(context, edge);
-                    return null;
-                }
+            AiCallWorkflowConditionVo condition = conditions.get(0);
+            // 匹配失败条件
+            if (!condition.isAdd()) {
+                log.info("加微失败,执行失败分支 - workflowInstanceId: {}", context.getWorkflowInstanceId());
+                super.runNextNode(context, edge);
+                return null;
+            }
 
             log.error("加微失败但未找到失败分支 - workflowInstanceId: {}", context.getWorkflowInstanceId());
             return null;
@@ -208,7 +208,7 @@ public class AiQwAddWxTaskNode extends AbstractWorkflowNode {
      *
      */
     public void doneQwAddWx(String workflowInstanceId) {
-        ExecutionContext context = createExecutionContext(workflowInstanceId, nodeKey);
+        ExecutionContext context = createExecutionContextWithBusinessId(workflowInstanceId, nodeKey);
         context.setVariable("lastNodeKey", nodeKey);
         //启动定时节点倒计时
         CompanyAiWorkflowExec exec = companyAiWorkflowExecMapper.selectByWorkflowInstanceId(context.getWorkflowInstanceId());

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

@@ -1,7 +1,10 @@
 package com.fs.company.vo;
 
+import com.fs.company.vo.easycall.ExtensionVO;
 import lombok.Data;
 
+import java.util.List;
+
 /**
  * @author MixLiu
  * @date 2026/2/3 10:13
@@ -69,4 +72,9 @@ public class AiCallConfigVO {
 
     /** 最大并发数 */
     private Integer maxConcurrency;
+
+    /**
+     * 分机选择列表
+     */
+    private List<ExtensionVO> extensionList;
 }

+ 6 - 2
fs-service/src/main/java/com/fs/company/vo/AiCallWorkflowConditionVo.java

@@ -2,14 +2,18 @@ package com.fs.company.vo;
 
 import lombok.Data;
 
+import java.util.List;
+
 @Data
 public class AiCallWorkflowConditionVo {
     // 外呼配置-是否拨通
     private boolean callConnected;
     // 外呼配置-延迟时间
     private Integer callTime;
-    // 外呼配置-意向度
-    private String intention;
+    // 外呼配置-意向度(多选)
+    private List<String> intention;
+    // 外呼配置-未拨通类型(多选)
+    private List<String> hangupType;
 
 
     // 加微条件-是否同意

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

@@ -13,4 +13,6 @@ public class CallContentVO {
     private Long logId;
 
     private String callContent;
+
+    private String recordPath;
 }

+ 5 - 0
fs-service/src/main/java/com/fs/company/vo/CompanyVoiceRoboticCallLogCallPhoneVO.java

@@ -72,6 +72,9 @@ public class CompanyVoiceRoboticCallLogCallPhoneVO {
     @Excel(name = "录音地址")
     private String recordPath;
 
+    @Excel(name = "通话详细列表")
+    private String contentList;
+
     /** 花费金额 */
     @Excel(name = "花费金额")
     private BigDecimal cost;
@@ -79,4 +82,6 @@ public class CompanyVoiceRoboticCallLogCallPhoneVO {
     @Excel(name = "外呼类型")
     private Integer callType;
 
+    /** 是否警告(0否 1是)用于敏感词 */
+    private Integer isWarning;
 }

+ 5 - 0
fs-service/src/main/java/com/fs/company/vo/WorkflowExecRecordVo.java

@@ -200,5 +200,10 @@ public class WorkflowExecRecordVo {
          * 外呼节点的对话记录
          */
         private String nodeContentList;
+
+        /**
+         * 外呼节点的录音地址
+         */
+        private String nodeRecordPath;
     }
 }

+ 6 - 0
fs-service/src/main/java/com/fs/company/vo/easycall/EasyCallCallPhoneVO.java

@@ -213,4 +213,10 @@ public class EasyCallCallPhoneVO {
      * The duration of the manual agent service time.
      */
     private Long manualAnsweredTimeLen;
+
+
+    /**
+     * 未接通原因(新增)
+     */
+    private String unconnectedReason;
 }

+ 18 - 0
fs-service/src/main/java/com/fs/company/vo/easycall/EasyCallCreateTaskParam.java

@@ -35,4 +35,22 @@ public class EasyCallCreateTaskParam {
     private Double avgCallEndProcessTimeLen;
 
     private String ttsModels;
+
+    /** aiTransferType */
+    private String aiTransferType;
+
+    /** aiTransferData */
+    private String aiTransferData;
+
+    /** aiTransferGroupId */
+    private String aiTransferGroupId;
+
+    /** aiTransferGatewayId */
+    private String aiTransferGatewayId;
+
+    /** aiTransferGatewayDestNumber */
+    private String aiTransferGatewayDestNumber;
+
+    /** aiTransferExtNumber */
+    private String aiTransferExtNumber;
 }

+ 34 - 0
fs-service/src/main/java/com/fs/company/vo/easycall/ExtensionVO.java

@@ -0,0 +1,34 @@
+package com.fs.company.vo.easycall;
+
+import lombok.Data;
+
+/**
+ * @author MixLiu
+ * @date 2026/5/20 15:16
+ * @description
+ */
+@Data
+public class ExtensionVO {
+    /**
+     * 分机号码
+     */
+    private String extensionNum;
+
+    /**
+     * 公司id
+     */
+    private Long companyId;
+    /**
+     * 销售id
+     */
+    private Long companyUserId;
+    /**
+     * 销售名称
+     */
+    private String companyUserName;
+    /**
+     * 分机id EasyCall 的分机ID
+     */
+    private Integer extId;
+
+}

+ 16 - 1
fs-service/src/main/java/com/fs/crm/domain/CrmCustomer.java

@@ -1,6 +1,7 @@
 package com.fs.crm.domain;
 
 import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.fasterxml.jackson.annotation.JsonFormat;
 import com.fs.common.annotation.Excel;
@@ -184,6 +185,20 @@ public class CrmCustomer extends BaseEntity
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     private Date visitTime;
 
-
+    /** 历史沟通记录 */
+    @Excel(name = "历史沟通记录")
+    private String historicalCommunication;
+
+    //    有效客户:1:有效,0或者空:无效
+    private Integer effectiveCustomer;
+    //    AI外呼备注
+    private String aiCallRemark;
+    //    最后一次设置录音
+    private String effectiveRecordPath;
+    //    最后一次设置外呼记录id
+    private Long lastEffectiveCallphoneLogId;
+
+    /** 是否可解密手机号 1=已解密 0=未解密(默认) */
+    private Integer canDecrypt;
 
 }

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio