Просмотр исходного кода

Merge remote-tracking branch 'origin/saas-api' into saas-api

# Conflicts:
#	fs-service/src/main/resources/db/tenant-initData.sql
xgb 1 неделя назад
Родитель
Сommit
2fb7ad1eec
100 измененных файлов с 4799 добавлено и 138 удалено
  1. 3 3
      fs-ad-api/src/main/java/com/fs/framework/config/ResourcesConfig.java
  2. 110 0
      fs-admin-saas/src/main/java/com/fs/company/controller/CompanyVoiceDialogController.java
  3. 43 0
      fs-admin-saas/src/main/java/com/fs/his/controller/FsCompanyController.java
  4. 120 0
      fs-admin-saas/src/main/java/com/fs/sensitive/controller/CompanyAiSensitiveWordController.java
  5. 2 0
      fs-admin-saas/src/main/resources/application-common.yml
  6. 9 6
      fs-admin-saas/src/main/resources/logback.xml
  7. 366 0
      fs-admin/src/main/java/com/fs/admin/controller/aicall/controller/CcLlmAgentAccountController.java
  8. 77 0
      fs-admin/src/main/java/com/fs/admin/controller/aicall/controller/CcLlmAgentProviderController.java
  9. 90 0
      fs-admin/src/main/java/com/fs/admin/controller/aicall/controller/CcLlmKbCatController.java
  10. 91 0
      fs-admin/src/main/java/com/fs/admin/controller/company/controller/CompanyVoiceCloneController.java
  11. 120 0
      fs-admin/src/main/java/com/fs/admin/controller/sensitive/controller/CompanyAiSensitiveWordController.java
  12. 2 0
      fs-admin/src/main/resources/application-common.yml
  13. 8 6
      fs-admin/src/main/resources/logback.xml
  14. 3 3
      fs-ai-api/src/main/java/com/fs/framework/config/ResourcesConfig.java
  15. 135 3
      fs-ai-call-task/src/main/java/com/fs/app/service/CallTaskService.java
  16. 20 1
      fs-ai-call-task/src/main/java/com/fs/app/task/Task.java
  17. 3 3
      fs-ai-call-task/src/main/java/com/fs/framework/config/ResourcesConfig.java
  18. 63 8
      fs-cid-workflow/src/main/java/com/fs/app/service/CidWorkflowTaskService.java
  19. 114 0
      fs-cid-workflow/src/main/java/com/fs/app/service/OutboundRetryTaskService.java
  20. 19 0
      fs-cid-workflow/src/main/java/com/fs/app/task/CidTask.java
  21. 3 3
      fs-cid-workflow/src/main/java/com/fs/framework/config/ResourcesConfig.java
  22. 14 0
      fs-common/src/main/java/com/fs/common/core/redis/RedisCache.java
  23. 4 1
      fs-company-app/src/main/java/com/fs/app/controller/UserController.java
  24. 400 0
      fs-company-app/src/main/java/com/fs/app/controller/aiSipCall/AiSipCallController.java
  25. 52 0
      fs-company-app/src/main/java/com/fs/app/controller/crm/CrmAPPMsgController.java
  26. 3 3
      fs-company-app/src/main/java/com/fs/core/config/ResourcesConfig.java
  27. 135 2
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallOutboundCdrController.java
  28. 79 0
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallUserController.java
  29. 276 0
      fs-company/src/main/java/com/fs/company/controller/common/RecordingProxyController.java
  30. 115 0
      fs-company/src/main/java/com/fs/company/controller/company/AiOutboundCallDashboardController.java
  31. 85 0
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceCloneRefController.java
  32. 66 4
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticCallLogCallphoneController.java
  33. 60 6
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticController.java
  34. 5 0
      fs-company/src/main/java/com/fs/company/controller/company/CompanyWorkflowController.java
  35. 116 15
      fs-company/src/main/java/com/fs/company/controller/company/CompanyWxAccountController.java
  36. 20 3
      fs-company/src/main/java/com/fs/company/controller/company/EasyCallController.java
  37. 53 3
      fs-company/src/main/java/com/fs/company/controller/crm/CrmCustomerController.java
  38. 65 0
      fs-company/src/main/java/com/fs/company/controller/crm/CustomerAllController.java
  39. 111 0
      fs-company/src/main/java/com/fs/company/controller/crm/ManualOutboundCallLogController.java
  40. 117 0
      fs-company/src/main/java/com/fs/company/controller/sensitive/CompanyAiSensitiveWordController.java
  41. 3 3
      fs-doctor-app/src/main/java/com/fs/framework/config/ResourcesConfig.java
  42. 3 3
      fs-ipad-task/src/main/java/com/fs/framework/config/ResourcesConfig.java
  43. 3 3
      fs-live-app/src/main/java/com/fs/framework/config/ResourcesConfig.java
  44. 3 3
      fs-qw-api-msg/src/main/java/com/fs/framework/config/ResourcesConfig.java
  45. 3 3
      fs-qw-api/src/main/java/com/fs/framework/config/ResourcesConfig.java
  46. 3 3
      fs-qw-mq/src/main/java/com/fs/framework/config/ResourcesConfig.java
  47. 3 3
      fs-qw-task/src/main/java/com/fs/framework/config/ResourcesConfig.java
  48. 3 3
      fs-qw-voice/src/main/java/com/fs/framework/config/ResourcesConfig.java
  49. 3 3
      fs-qwhook-msg/src/main/java/com/fs/framework/config/ResourcesConfig.java
  50. 3 3
      fs-qwhook-sop/src/main/java/com/fs/framework/config/ResourcesConfig.java
  51. 3 3
      fs-qwhook/src/main/java/com/fs/framework/config/ResourcesConfig.java
  52. 3 3
      fs-redis/src/main/java/com/fs/framework/config/ResourcesConfig.java
  53. 3 3
      fs-repeat-api/src/main/java/com/fs/framework/config/ResourcesConfig.java
  54. 7 0
      fs-service/pom.xml
  55. 21 0
      fs-service/src/main/java/com/fs/aiSipCall/RemoteCommon.java
  56. 38 0
      fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipCallOutboundCdr.java
  57. 14 1
      fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipCallUser.java
  58. 4 0
      fs-service/src/main/java/com/fs/aiSipCall/param/ApiCallRecordByUuidQueryParams.java
  59. 16 1
      fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallOutboundCdrService.java
  60. 36 0
      fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallUserService.java
  61. 163 9
      fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallOutboundCdrServiceImpl.java
  62. 169 0
      fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallUserServiceImpl.java
  63. 18 0
      fs-service/src/main/java/com/fs/aiSipCall/vo/AiSipCallUserNewVO.java
  64. 8 0
      fs-service/src/main/java/com/fs/aicall/domain/CcLlmAgentAccount.java
  65. 3 0
      fs-service/src/main/java/com/fs/aicall/domain/CcTtsAliyun.java
  66. 46 0
      fs-service/src/main/java/com/fs/company/domain/CompanyExtensionBind.java
  67. 3 0
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceCloneRef.java
  68. 1 0
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRobotic.java
  69. 42 0
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRoboticCallLogCallphone.java
  70. 0 4
      fs-service/src/main/java/com/fs/company/domain/CompanyWxAccount.java
  71. 182 0
      fs-service/src/main/java/com/fs/company/domain/CrmCustomerCallLog.java
  72. 47 0
      fs-service/src/main/java/com/fs/company/domain/OutboundLineLimitLog.java
  73. 18 0
      fs-service/src/main/java/com/fs/company/dto/AiOutboundCallDailyTrendDTO.java
  74. 15 0
      fs-service/src/main/java/com/fs/company/dto/AiOutboundCallDurationDTO.java
  75. 15 0
      fs-service/src/main/java/com/fs/company/dto/AiOutboundCallHourlyDTO.java
  76. 15 0
      fs-service/src/main/java/com/fs/company/dto/AiOutboundCallIntentDTO.java
  77. 48 0
      fs-service/src/main/java/com/fs/company/dto/AiOutboundCallStatisticsDTO.java
  78. 15 0
      fs-service/src/main/java/com/fs/company/dto/AiOutboundCallStatusDTO.java
  79. 27 0
      fs-service/src/main/java/com/fs/company/dto/AiOutboundCallViolationDTO.java
  80. 26 0
      fs-service/src/main/java/com/fs/company/dto/OutboundLimitResultVO.java
  81. 49 0
      fs-service/src/main/java/com/fs/company/mapper/AiOutboundCallDashboardMapper.java
  82. 28 0
      fs-service/src/main/java/com/fs/company/mapper/CcExtNumMapper.java
  83. 2 1
      fs-service/src/main/java/com/fs/company/mapper/CompanyAiWorkflowExecMapper.java
  84. 1 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyBindGatewayMapper.java
  85. 2 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyConfigMapper.java
  86. 80 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyExtensionBindMapper.java
  87. 18 2
      fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceCloneRefMapper.java
  88. 13 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceRoboticCallLogCallphoneMapper.java
  89. 33 0
      fs-service/src/main/java/com/fs/company/mapper/CrmCustomerCallLogMapper.java
  90. 18 0
      fs-service/src/main/java/com/fs/company/mapper/OutboundLineLimitLogMapper.java
  91. 18 0
      fs-service/src/main/java/com/fs/company/param/AiOutboundCallDashboardParam.java
  92. 25 0
      fs-service/src/main/java/com/fs/company/param/AppendCustomersParam.java
  93. 28 0
      fs-service/src/main/java/com/fs/company/param/BatchCreateExtensionParam.java
  94. 47 0
      fs-service/src/main/java/com/fs/company/service/IAiOutboundCallDashboardService.java
  95. 132 0
      fs-service/src/main/java/com/fs/company/service/ICompanyExtensionBindService.java
  96. 25 0
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceCloneService.java
  97. 17 7
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticCallLogCallphoneService.java
  98. 18 1
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticService.java
  99. 2 0
      fs-service/src/main/java/com/fs/company/service/ICompanyWorkflowService.java
  100. 30 0
      fs-service/src/main/java/com/fs/company/service/ICrmCustomerCallLogService.java

+ 3 - 3
fs-ad-api/src/main/java/com/fs/framework/config/ResourcesConfig.java

@@ -35,7 +35,7 @@ public class ResourcesConfig implements WebMvcConfigurer
     }
 
     /**
-     * 鑷�畾涔夋嫤鎴��鍒?
+     * 鑷�畾涔夋嫤鎴��
      */
     @Override
     public void addInterceptors(InterceptorRegistry registry)
@@ -56,9 +56,9 @@ public class ResourcesConfig implements WebMvcConfigurer
         config.addAllowedOriginPattern("*");
         // 璁剧疆璁块棶婧愯�姹傚ご
         config.addAllowedHeader("*");
-        // 璁剧疆璁块棶婧愯�姹傛柟娉?
+        // 璁剧疆璁块棶婧愯�姹傛柟
         config.addAllowedMethod("*");
-        // 瀵规帴鍙i厤缃�法鍩熻�缃?
+        // 瀵规帴鍙i厤缃�法鍩熻�
         source.registerCorsConfiguration("/**", config);
         return new CorsFilter(source);
     }

+ 110 - 0
fs-admin-saas/src/main/java/com/fs/company/controller/CompanyVoiceDialogController.java

@@ -0,0 +1,110 @@
+package com.fs.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.common.utils.poi.ExcelUtil;
+import com.fs.company.domain.CompanyVoiceDialog;
+import com.fs.company.service.ICompanyVoiceDialogService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * AI外呼话术Controller(fs-admin-saas 桥接)
+ * <p>
+ * 由于 com.fs.company.controller.company 子包在 fs-saasadmin 中被排除加载,
+ * 原 CompanyVoiceDialogController(位于 fs-company 模块的 company 子包下)无法被扫描到。
+ * 本控制器在 com.fs.company.controller 根包下提供相同的 API 端点,确保
+ * saasadminui 前端通过 port 8004 的请求正常路由。
+ *
+ * @author fs
+ * @date 2024-12-04
+ */
+@RestController
+@RequestMapping("/company/companyVoiceDialog")
+public class CompanyVoiceDialogController extends BaseController {
+
+    @Autowired
+    private ICompanyVoiceDialogService companyVoiceDialogService;
+
+    /**
+     * 查询AI外呼话术列表
+     */
+    @PreAuthorize("@ss.hasPermi('system:companyVoiceDialog:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(CompanyVoiceDialog companyVoiceDialog) {
+        startPage();
+        List<CompanyVoiceDialog> list = companyVoiceDialogService.selectCompanyVoiceDialogList(companyVoiceDialog);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出AI外呼话术列表
+     */
+    @PreAuthorize("@ss.hasPermi('system:companyVoiceDialog:export')")
+    @Log(title = "AI外呼话术", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(CompanyVoiceDialog companyVoiceDialog) {
+        List<CompanyVoiceDialog> list = companyVoiceDialogService.selectCompanyVoiceDialogList(companyVoiceDialog);
+        ExcelUtil<CompanyVoiceDialog> util = new ExcelUtil<CompanyVoiceDialog>(CompanyVoiceDialog.class);
+        return util.exportExcel(list, "dialog");
+    }
+
+    /**
+     * 获取AI外呼话术详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('system:companyVoiceDialog:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id) {
+        return AjaxResult.success(companyVoiceDialogService.selectCompanyVoiceDialogById(id));
+    }
+
+    /**
+     * 新增AI外呼话术
+     */
+    @PreAuthorize("@ss.hasPermi('system:companyVoiceDialog:add')")
+    @Log(title = "AI外呼话术", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody CompanyVoiceDialog companyVoiceDialog) {
+        return toAjax(companyVoiceDialogService.insertCompanyVoiceDialog(companyVoiceDialog));
+    }
+
+    /**
+     * 修改AI外呼话术
+     */
+    @PreAuthorize("@ss.hasPermi('system:companyVoiceDialog:edit')")
+    @Log(title = "AI外呼话术", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody CompanyVoiceDialog companyVoiceDialog) {
+        return toAjax(companyVoiceDialogService.updateCompanyVoiceDialog(companyVoiceDialog));
+    }
+
+    /**
+     * 删除AI外呼话术
+     */
+    @PreAuthorize("@ss.hasPermi('system:companyVoiceDialog:remove')")
+    @Log(title = "AI外呼话术", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids) {
+        return toAjax(companyVoiceDialogService.deleteCompanyVoiceDialogByIds(ids));
+    }
+
+    /**
+     * 获取话术配置链接
+     */
+    @PreAuthorize("@ss.hasPermi('system:companyVoiceDialog:list')")
+    @Log(title = "话术配置链接", businessType = BusinessType.OTHER)
+    @GetMapping("/getConfigUrl")
+    public AjaxResult getConfigUrl(Long id) {
+        try {
+            return AjaxResult.success("", companyVoiceDialogService.getConfigUrl(id));
+        } catch (Exception e) {
+            return AjaxResult.error("获取话术配置链接失败:" + e.getMessage());
+        }
+    }
+}

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

+ 2 - 0
fs-admin-saas/src/main/resources/application-common.yml

@@ -38,6 +38,8 @@ logging:
   level:
     com.fs: info
     org.springframework: warn
+    com.baomidou.mybatisplus: warn
+    org.apache.ibatis: warn
 
 express:
   omsCode: "SF.0235402855"

+ 9 - 6
fs-admin-saas/src/main/resources/logback.xml

@@ -63,15 +63,18 @@
 		</encoder>
     </appender>
 
-	<!-- 系统模块日志级别控制  -->
-	<logger name="com.fs" level="debug" />
+	<!-- 系统模块日志级别控制(与 application-common.yml 保持一致) -->
+	<logger name="com.fs" level="info" />
 	<!-- Spring日志级别控制  -->
 	<logger name="org.springframework" level="warn" />
 
-    <!-- log4j2.xml -->
-    <Logger name="com.fs.his.mapper" level="debug"/>
-    <Logger name="com.fs.company.mapper" level="debug"/>
-    <Logger name="org.apache.ibatis" level="debug"/>
+	<!-- MyBatis-Plus 启动时会为每个 Mapper 注册 MappedStatement,debug 会刷屏 -->
+	<logger name="com.baomidou.mybatisplus" level="warn" />
+	<logger name="org.apache.ibatis" level="warn" />
+	<!-- 需要调试 SQL 时,仅打开对应 mapper 包即可 -->
+	<logger name="com.fs.his.mapper" level="debug" />
+	<logger name="com.fs.company.mapper" level="debug" />
+	<logger name="com.fs.hisStore.mapper" level="debug" />
 
 
     <root level="info">

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

+ 2 - 0
fs-admin/src/main/resources/application-common.yml

@@ -38,6 +38,8 @@ logging:
   level:
     com.fs: info
     org.springframework: warn
+    com.baomidou.mybatisplus: warn
+    org.apache.ibatis: warn
 
 express:
   omsCode: "SF.0235402855"

+ 8 - 6
fs-admin/src/main/resources/logback.xml

@@ -63,20 +63,22 @@
 		</encoder>
     </appender>
 
-	<!-- 系统模块日志级别控制  -->
-	<logger name="com.fs" level="debug" />
+	<!-- 系统模块日志级别控制(与 application-common.yml 保持一致) -->
+	<logger name="com.fs" level="info" />
 	<!-- Spring日志级别控制  -->
 	<logger name="org.springframework" level="warn" />
 
-	<!-- MyBatis SQL调试日志 -->
+	<!-- MyBatis-Plus 启动时会为每个 Mapper 注册 MappedStatement,debug 会刷屏 -->
+	<logger name="com.baomidou.mybatisplus" level="warn" />
+	<logger name="org.apache.ibatis" level="warn" />
+	<!-- 需要调试 SQL 时,仅打开对应 mapper 包即可 -->
 	<logger name="com.fs.his.mapper" level="debug" />
 	<logger name="com.fs.company.mapper" level="debug" />
 	<logger name="com.fs.admin.mapper" level="debug" />
 	<logger name="com.fs.tenant.mapper" level="debug" />
 	<logger name="com.fs.fee.mapper" level="debug" />
-	<logger name="org.apache.ibatis" level="debug" />
-	<logger name="com.baomidou.mybatisplus" level="debug" />
-	<logger name="com.fs.framework.datasource" level="debug" />
+	<logger name="com.fs.hisStore.mapper" level="debug" />
+	<logger name="com.fs.framework.datasource" level="info" />
 
 
     <root level="info">

+ 3 - 3
fs-ai-api/src/main/java/com/fs/framework/config/ResourcesConfig.java

@@ -35,7 +35,7 @@ public class ResourcesConfig implements WebMvcConfigurer
     }
 
     /**
-     * 鑷�畾涔夋嫤鎴��鍒?
+     * 鑷�畾涔夋嫤鎴��
      */
     @Override
     public void addInterceptors(InterceptorRegistry registry)
@@ -56,9 +56,9 @@ public class ResourcesConfig implements WebMvcConfigurer
         config.addAllowedOriginPattern("*");
         // 璁剧疆璁块棶婧愯�姹傚ご
         config.addAllowedHeader("*");
-        // 璁剧疆璁块棶婧愯�姹傛柟娉?
+        // 璁剧疆璁块棶婧愯�姹傛柟
         config.addAllowedMethod("*");
-        // 瀵规帴鍙i厤缃�法鍩熻�缃?
+        // 瀵规帴鍙i厤缃�法鍩熻�
         source.registerCorsConfiguration("/**", config);
         return new CorsFilter(source);
     }

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

+ 3 - 3
fs-ai-call-task/src/main/java/com/fs/framework/config/ResourcesConfig.java

@@ -39,7 +39,7 @@ public class ResourcesConfig implements WebMvcConfigurer
     }
 
     /**
-     * 鑷�畾涔夋嫤鎴��鍒?
+     * 鑷�畾涔夋嫤鎴��
      */
     @Override
     public void addInterceptors(InterceptorRegistry registry)
@@ -60,9 +60,9 @@ public class ResourcesConfig implements WebMvcConfigurer
         config.addAllowedOriginPattern("*");
         // 璁剧疆璁块棶婧愯�姹傚ご
         config.addAllowedHeader("*");
-        // 璁剧疆璁块棶婧愯�姹傛柟娉?
+        // 璁剧疆璁块棶婧愯�姹傛柟
         config.addAllowedMethod("*");
-        // 瀵规帴鍙i厤缃�法鍩熻�缃?
+        // 瀵规帴鍙i厤缃�法鍩熻�
         source.registerCorsConfiguration("/**", config);
         return new CorsFilter(source);
     }

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

+ 3 - 3
fs-cid-workflow/src/main/java/com/fs/framework/config/ResourcesConfig.java

@@ -39,7 +39,7 @@ public class ResourcesConfig implements WebMvcConfigurer
     }
 
     /**
-     * 鑷�畾涔夋嫤鎴��鍒?
+     * 鑷�畾涔夋嫤鎴��
      */
     @Override
     public void addInterceptors(InterceptorRegistry registry)
@@ -60,9 +60,9 @@ public class ResourcesConfig implements WebMvcConfigurer
         config.addAllowedOriginPattern("*");
         // 璁剧疆璁块棶婧愯�姹傚ご
         config.addAllowedHeader("*");
-        // 璁剧疆璁块棶婧愯�姹傛柟娉?
+        // 璁剧疆璁块棶婧愯�姹傛柟
         config.addAllowedMethod("*");
-        // 瀵规帴鍙i厤缃�法鍩熻�缃?
+        // 瀵规帴鍙i厤缃�法鍩熻�
         source.registerCorsConfiguration("/**", config);
         return new CorsFilter(source);
     }

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

+ 3 - 3
fs-company-app/src/main/java/com/fs/core/config/ResourcesConfig.java

@@ -36,7 +36,7 @@ public class ResourcesConfig implements WebMvcConfigurer
     }
 
     /**
-     * 自定义拦截规�?
+     * 自定义拦截规
      */
     @Override
     public void addInterceptors(InterceptorRegistry registry)
@@ -56,9 +56,9 @@ public class ResourcesConfig implements WebMvcConfigurer
         config.addAllowedOriginPattern("*");
         // 设置访问源请求头
         config.addAllowedHeader("*");
-        // 设置访问源请求方�?
+        // 设置访问源请求方
         config.addAllowedMethod("*");
-        // 对接口配置跨域设�?
+        // 对接口配置跨域设
         source.registerCorsConfiguration("/**", config);
         return new CorsFilter(source);
     }

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

+ 116 - 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,22 +144,74 @@ 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));
+    }
+
+    /**
+     * 同步个微客户数据
+     */
+    @PreAuthorize("@ss.hasPermi('company:companyWx:edit')")
+    @GetMapping("/syncWx")
+    public R syncWx(@RequestParam("accountId") Long accountId){
+        companyWxAccountService.syncWx(accountId);
+        return R.ok();
     }
 }

+ 20 - 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 -> {
@@ -112,6 +115,7 @@ public class EasyCallController extends BaseController {
                     vo.setVoiceCode(item.getVoiceCode());
                     vo.setVoiceName(item.getVoiceName());
                     vo.setVoiceSource(item.getVoiceSource());
+                    vo.setTtsModels(item.getTtsModels());
                     return vo;
                 })
                 .collect(Collectors.toList());
@@ -229,7 +233,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 +252,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();
+    }
+}

+ 3 - 3
fs-doctor-app/src/main/java/com/fs/framework/config/ResourcesConfig.java

@@ -35,7 +35,7 @@ public class ResourcesConfig implements WebMvcConfigurer
     }
 
     /**
-     * 鑷�畾涔夋嫤鎴��鍒?
+     * 鑷�畾涔夋嫤鎴��
      */
     @Override
     public void addInterceptors(InterceptorRegistry registry)
@@ -56,9 +56,9 @@ public class ResourcesConfig implements WebMvcConfigurer
         config.addAllowedOriginPattern("*");
         // 璁剧疆璁块棶婧愯�姹傚ご
         config.addAllowedHeader("*");
-        // 璁剧疆璁块棶婧愯�姹傛柟娉?
+        // 璁剧疆璁块棶婧愯�姹傛柟
         config.addAllowedMethod("*");
-        // 瀵规帴鍙i厤缃�法鍩熻�缃?
+        // 瀵规帴鍙i厤缃�法鍩熻�
         source.registerCorsConfiguration("/**", config);
         return new CorsFilter(source);
     }

+ 3 - 3
fs-ipad-task/src/main/java/com/fs/framework/config/ResourcesConfig.java

@@ -39,7 +39,7 @@ public class ResourcesConfig implements WebMvcConfigurer
     }
 
     /**
-     * 鑷�畾涔夋嫤鎴��鍒?
+     * 鑷�畾涔夋嫤鎴��
      */
     @Override
     public void addInterceptors(InterceptorRegistry registry)
@@ -60,9 +60,9 @@ public class ResourcesConfig implements WebMvcConfigurer
         config.addAllowedOriginPattern("*");
         // 璁剧疆璁块棶婧愯�姹傚ご
         config.addAllowedHeader("*");
-        // 璁剧疆璁块棶婧愯�姹傛柟娉?
+        // 璁剧疆璁块棶婧愯�姹傛柟
         config.addAllowedMethod("*");
-        // 瀵规帴鍙i厤缃�法鍩熻�缃?
+        // 瀵规帴鍙i厤缃�法鍩熻�
         source.registerCorsConfiguration("/**", config);
         return new CorsFilter(source);
     }

+ 3 - 3
fs-live-app/src/main/java/com/fs/framework/config/ResourcesConfig.java

@@ -35,7 +35,7 @@ public class ResourcesConfig implements WebMvcConfigurer
     }
 
     /**
-     * 鑷�畾涔夋嫤鎴��鍒?
+     * 鑷�畾涔夋嫤鎴��
      */
     @Override
     public void addInterceptors(InterceptorRegistry registry)
@@ -56,9 +56,9 @@ public class ResourcesConfig implements WebMvcConfigurer
         config.addAllowedOriginPattern("*");
         // 璁剧疆璁块棶婧愯�姹傚ご
         config.addAllowedHeader("*");
-        // 璁剧疆璁块棶婧愯�姹傛柟娉?
+        // 璁剧疆璁块棶婧愯�姹傛柟
         config.addAllowedMethod("*");
-        // 瀵规帴鍙i厤缃�法鍩熻�缃?
+        // 瀵规帴鍙i厤缃�法鍩熻�
         source.registerCorsConfiguration("/**", config);
         return new CorsFilter(source);
     }

+ 3 - 3
fs-qw-api-msg/src/main/java/com/fs/framework/config/ResourcesConfig.java

@@ -35,7 +35,7 @@ public class ResourcesConfig implements WebMvcConfigurer
     }
 
     /**
-     * 鑷�畾涔夋嫤鎴��鍒?
+     * 鑷�畾涔夋嫤鎴��
      */
     @Override
     public void addInterceptors(InterceptorRegistry registry)
@@ -56,9 +56,9 @@ public class ResourcesConfig implements WebMvcConfigurer
         config.addAllowedOriginPattern("*");
         // 璁剧疆璁块棶婧愯�姹傚ご
         config.addAllowedHeader("*");
-        // 璁剧疆璁块棶婧愯�姹傛柟娉?
+        // 璁剧疆璁块棶婧愯�姹傛柟
         config.addAllowedMethod("*");
-        // 瀵规帴鍙i厤缃�法鍩熻�缃?
+        // 瀵规帴鍙i厤缃�法鍩熻�
         source.registerCorsConfiguration("/**", config);
         return new CorsFilter(source);
     }

+ 3 - 3
fs-qw-api/src/main/java/com/fs/framework/config/ResourcesConfig.java

@@ -35,7 +35,7 @@ public class ResourcesConfig implements WebMvcConfigurer
     }
 
     /**
-     * 鑷�畾涔夋嫤鎴��鍒?
+     * 鑷�畾涔夋嫤鎴��
      */
     @Override
     public void addInterceptors(InterceptorRegistry registry)
@@ -56,9 +56,9 @@ public class ResourcesConfig implements WebMvcConfigurer
         config.addAllowedOriginPattern("*");
         // 璁剧疆璁块棶婧愯�姹傚ご
         config.addAllowedHeader("*");
-        // 璁剧疆璁块棶婧愯�姹傛柟娉?
+        // 璁剧疆璁块棶婧愯�姹傛柟
         config.addAllowedMethod("*");
-        // 瀵规帴鍙i厤缃�法鍩熻�缃?
+        // 瀵规帴鍙i厤缃�法鍩熻�
         source.registerCorsConfiguration("/**", config);
         return new CorsFilter(source);
     }

+ 3 - 3
fs-qw-mq/src/main/java/com/fs/framework/config/ResourcesConfig.java

@@ -35,7 +35,7 @@ public class ResourcesConfig implements WebMvcConfigurer
     }
 
     /**
-     * 鑷�畾涔夋嫤鎴��鍒?
+     * 鑷�畾涔夋嫤鎴��
      */
     @Override
     public void addInterceptors(InterceptorRegistry registry)
@@ -56,9 +56,9 @@ public class ResourcesConfig implements WebMvcConfigurer
         config.addAllowedOriginPattern("*");
         // 璁剧疆璁块棶婧愯�姹傚ご
         config.addAllowedHeader("*");
-        // 璁剧疆璁块棶婧愯�姹傛柟娉?
+        // 璁剧疆璁块棶婧愯�姹傛柟
         config.addAllowedMethod("*");
-        // 瀵规帴鍙i厤缃�法鍩熻�缃?
+        // 瀵规帴鍙i厤缃�法鍩熻�
         source.registerCorsConfiguration("/**", config);
         return new CorsFilter(source);
     }

+ 3 - 3
fs-qw-task/src/main/java/com/fs/framework/config/ResourcesConfig.java

@@ -39,7 +39,7 @@ public class ResourcesConfig implements WebMvcConfigurer
     }
 
     /**
-     * 鑷�畾涔夋嫤鎴��鍒?
+     * 鑷�畾涔夋嫤鎴��
      */
     @Override
     public void addInterceptors(InterceptorRegistry registry)
@@ -60,9 +60,9 @@ public class ResourcesConfig implements WebMvcConfigurer
         config.addAllowedOriginPattern("*");
         // 璁剧疆璁块棶婧愯�姹傚ご
         config.addAllowedHeader("*");
-        // 璁剧疆璁块棶婧愯�姹傛柟娉?
+        // 璁剧疆璁块棶婧愯�姹傛柟
         config.addAllowedMethod("*");
-        // 瀵规帴鍙i厤缃�法鍩熻�缃?
+        // 瀵规帴鍙i厤缃�法鍩熻�
         source.registerCorsConfiguration("/**", config);
         return new CorsFilter(source);
     }

+ 3 - 3
fs-qw-voice/src/main/java/com/fs/framework/config/ResourcesConfig.java

@@ -35,7 +35,7 @@ public class ResourcesConfig implements WebMvcConfigurer
     }
 
     /**
-     * 鑷�畾涔夋嫤鎴��鍒?
+     * 鑷�畾涔夋嫤鎴��
      */
     @Override
     public void addInterceptors(InterceptorRegistry registry)
@@ -56,9 +56,9 @@ public class ResourcesConfig implements WebMvcConfigurer
         config.addAllowedOriginPattern("*");
         // 璁剧疆璁块棶婧愯�姹傚ご
         config.addAllowedHeader("*");
-        // 璁剧疆璁块棶婧愯�姹傛柟娉?
+        // 璁剧疆璁块棶婧愯�姹傛柟
         config.addAllowedMethod("*");
-        // 瀵规帴鍙i厤缃�法鍩熻�缃?
+        // 瀵规帴鍙i厤缃�法鍩熻�
         source.registerCorsConfiguration("/**", config);
         return new CorsFilter(source);
     }

+ 3 - 3
fs-qwhook-msg/src/main/java/com/fs/framework/config/ResourcesConfig.java

@@ -35,7 +35,7 @@ public class ResourcesConfig implements WebMvcConfigurer
     }
 
     /**
-     * 鑷�畾涔夋嫤鎴��鍒?
+     * 鑷�畾涔夋嫤鎴��
      */
     @Override
     public void addInterceptors(InterceptorRegistry registry)
@@ -56,9 +56,9 @@ public class ResourcesConfig implements WebMvcConfigurer
         config.addAllowedOriginPattern("*");
         // 璁剧疆璁块棶婧愯�姹傚ご
         config.addAllowedHeader("*");
-        // 璁剧疆璁块棶婧愯�姹傛柟娉?
+        // 璁剧疆璁块棶婧愯�姹傛柟
         config.addAllowedMethod("*");
-        // 瀵规帴鍙i厤缃�法鍩熻�缃?
+        // 瀵规帴鍙i厤缃�法鍩熻�
         source.registerCorsConfiguration("/**", config);
         return new CorsFilter(source);
     }

+ 3 - 3
fs-qwhook-sop/src/main/java/com/fs/framework/config/ResourcesConfig.java

@@ -35,7 +35,7 @@ public class ResourcesConfig implements WebMvcConfigurer
     }
 
     /**
-     * 鑷�畾涔夋嫤鎴��鍒?
+     * 鑷�畾涔夋嫤鎴��
      */
     @Override
     public void addInterceptors(InterceptorRegistry registry)
@@ -56,9 +56,9 @@ public class ResourcesConfig implements WebMvcConfigurer
         config.addAllowedOriginPattern("*");
         // 璁剧疆璁块棶婧愯�姹傚ご
         config.addAllowedHeader("*");
-        // 璁剧疆璁块棶婧愯�姹傛柟娉?
+        // 璁剧疆璁块棶婧愯�姹傛柟
         config.addAllowedMethod("*");
-        // 瀵规帴鍙i厤缃�法鍩熻�缃?
+        // 瀵规帴鍙i厤缃�法鍩熻�
         source.registerCorsConfiguration("/**", config);
         return new CorsFilter(source);
     }

+ 3 - 3
fs-qwhook/src/main/java/com/fs/framework/config/ResourcesConfig.java

@@ -35,7 +35,7 @@ public class ResourcesConfig implements WebMvcConfigurer
     }
 
     /**
-     * 鑷�畾涔夋嫤鎴��鍒?
+     * 鑷�畾涔夋嫤鎴��
      */
     @Override
     public void addInterceptors(InterceptorRegistry registry)
@@ -56,9 +56,9 @@ public class ResourcesConfig implements WebMvcConfigurer
         config.addAllowedOriginPattern("*");
         // 璁剧疆璁块棶婧愯�姹傚ご
         config.addAllowedHeader("*");
-        // 璁剧疆璁块棶婧愯�姹傛柟娉?
+        // 璁剧疆璁块棶婧愯�姹傛柟
         config.addAllowedMethod("*");
-        // 瀵规帴鍙i厤缃�法鍩熻�缃?
+        // 瀵规帴鍙i厤缃�法鍩熻�
         source.registerCorsConfiguration("/**", config);
         return new CorsFilter(source);
     }

+ 3 - 3
fs-redis/src/main/java/com/fs/framework/config/ResourcesConfig.java

@@ -35,7 +35,7 @@ public class ResourcesConfig implements WebMvcConfigurer
     }
 
     /**
-     * 鑷�畾涔夋嫤鎴��鍒?
+     * 鑷�畾涔夋嫤鎴��
      */
     @Override
     public void addInterceptors(InterceptorRegistry registry)
@@ -56,9 +56,9 @@ public class ResourcesConfig implements WebMvcConfigurer
         config.addAllowedOriginPattern("*");
         // 璁剧疆璁块棶婧愯�姹傚ご
         config.addAllowedHeader("*");
-        // 璁剧疆璁块棶婧愯�姹傛柟娉?
+        // 璁剧疆璁块棶婧愯�姹傛柟
         config.addAllowedMethod("*");
-        // 瀵规帴鍙i厤缃�法鍩熻�缃?
+        // 瀵规帴鍙i厤缃�法鍩熻�
         source.registerCorsConfiguration("/**", config);
         return new CorsFilter(source);
     }

+ 3 - 3
fs-repeat-api/src/main/java/com/fs/framework/config/ResourcesConfig.java

@@ -35,7 +35,7 @@ public class ResourcesConfig implements WebMvcConfigurer
     }
 
     /**
-     * 鑷�畾涔夋嫤鎴��鍒?
+     * 鑷�畾涔夋嫤鎴��
      */
     @Override
     public void addInterceptors(InterceptorRegistry registry)
@@ -56,9 +56,9 @@ public class ResourcesConfig implements WebMvcConfigurer
         config.addAllowedOriginPattern("*");
         // 璁剧疆璁块棶婧愯�姹傚ご
         config.addAllowedHeader("*");
-        // 璁剧疆璁块棶婧愯�姹傛柟娉?
+        // 璁剧疆璁块棶婧愯�姹傛柟
         config.addAllowedMethod("*");
-        // 瀵规帴鍙i厤缃�法鍩熻�缃?
+        // 瀵规帴鍙i厤缃�法鍩熻�
         source.registerCorsConfiguration("/**", config);
         return new CorsFilter(source);
     }

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

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

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

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

+ 0 - 4
fs-service/src/main/java/com/fs/company/domain/CompanyWxAccount.java

@@ -102,8 +102,4 @@ public class CompanyWxAccount extends BaseEntity
     @TableField(exist = false)
     private String companyUserName;
 
-    /**
-     * 微信备注(唯一)
-     */
-    private String wxRemark;
 }

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

Некоторые файлы не были показаны из-за большого количества измененных файлов