Jelajahi Sumber

Merge remote-tracking branch 'origin/master'

lk 3 hari lalu
induk
melakukan
4d518ca6f8
100 mengubah file dengan 4647 tambahan dan 487 penghapusan
  1. 3 1
      docs/company_ai_sensitive_word.sql
  2. 11 0
      docs/wx_mp_subscribe.sql
  3. 370 0
      fs-admin/src/main/java/com/fs/aicall/controller/CcLlmAgentAccountController.java
  4. 77 0
      fs-admin/src/main/java/com/fs/aicall/controller/CcLlmAgentProviderController.java
  5. 90 0
      fs-admin/src/main/java/com/fs/aicall/controller/CcLlmKbCatController.java
  6. 91 0
      fs-admin/src/main/java/com/fs/company/controller/CompanyVoiceCloneController.java
  7. 337 1
      fs-admin/src/main/java/com/fs/his/task/Task.java
  8. 0 2
      fs-admin/src/main/java/com/fs/live/controller/LiveController.java
  9. 10 5
      fs-admin/src/main/java/com/fs/qw/controller/QwSopTempController.java
  10. 120 0
      fs-admin/src/main/java/com/fs/sensitive/controller/CompanyAiSensitiveWordController.java
  11. 124 0
      fs-admin/src/main/java/com/fs/wx/controller/WxMpPortalController.java
  12. 66 38
      fs-ai-call-task/src/main/java/com/fs/app/service/CallTaskService.java
  13. 104 19
      fs-cid-workflow/src/main/java/com/fs/app/service/CidWorkflowTaskService.java
  14. 13 0
      fs-cid-workflow/src/main/java/com/fs/app/task/CidTask.java
  15. 1 1
      fs-cid-workflow/src/main/resources/application.yml
  16. 46 21
      fs-company-app/src/main/java/com/fs/app/controller/aiSipCall/AiSipCallController.java
  17. 4 3
      fs-company-app/src/main/resources/application.yml
  18. 100 2
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallOutboundCdrController.java
  19. 1 0
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallUserController.java
  20. 3 1
      fs-company/src/main/java/com/fs/company/controller/common/CaptchaController.java
  21. 78 0
      fs-company/src/main/java/com/fs/company/controller/company/CompanyUserController.java
  22. 35 1
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticCallLogCallphoneController.java
  23. 14 1
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticController.java
  24. 80 0
      fs-company/src/main/java/com/fs/company/controller/company/CompanyWxAccountController.java
  25. 1 0
      fs-company/src/main/java/com/fs/company/controller/company/EasyCallController.java
  26. 29 3
      fs-company/src/main/java/com/fs/company/controller/crm/CrmCustomerController.java
  27. 16 0
      fs-company/src/main/java/com/fs/company/controller/crm/CustomerAllController.java
  28. 111 0
      fs-company/src/main/java/com/fs/company/controller/crm/ManualOutboundCallLogController.java
  29. 4 2
      fs-company/src/main/java/com/fs/company/controller/qw/QwGroupChatController.java
  30. 15 2
      fs-company/src/main/java/com/fs/company/controller/sensitive/CompanyAiSensitiveWordController.java
  31. 404 0
      fs-company/src/main/java/com/fs/company/controller/wx/WxMpSubscribeController.java
  32. 7 0
      fs-company/src/main/java/com/fs/framework/config/SecurityConfig.java
  33. 1 11
      fs-company/src/main/resources/application.yml
  34. 7 0
      fs-framework/src/main/java/com/fs/framework/config/SecurityConfig.java
  35. 23 18
      fs-ipad-task/src/main/java/com/fs/app/task/SendMsg.java
  36. 2 6
      fs-qw-api/src/main/java/com/fs/app/controller/OpenQwApiController.java
  37. 2 2
      fs-qw-api/src/main/java/com/fs/app/service/OpenQwApiService.java
  38. 8 6
      fs-qw-api/src/main/java/com/fs/app/service/impl/OpenQwApiServiceImpl.java
  39. 8 19
      fs-qw-task/src/main/java/com/fs/app/task/CourseWatchLogScheduler.java
  40. 110 81
      fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java
  41. 2 1
      fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallOutboundCdrServiceImpl.java
  42. 2 2
      fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallUserServiceImpl.java
  43. 9 0
      fs-service/src/main/java/com/fs/aicall/domain/CcLlmAgentAccount.java
  44. 2 0
      fs-service/src/main/java/com/fs/aicall/domain/CcTtsAliyun.java
  45. 42 9
      fs-service/src/main/java/com/fs/company/config/AsyncCalleeConfig.java
  46. 6 0
      fs-service/src/main/java/com/fs/company/domain/CompanyUser.java
  47. 12 0
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRoboticCallLogCallphone.java
  48. 24 1
      fs-service/src/main/java/com/fs/company/domain/CrmCustomerCallLog.java
  49. 28 1
      fs-service/src/main/java/com/fs/company/mapper/CompanyAiWorkflowExecMapper.java
  50. 6 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyUserMapper.java
  51. 15 1
      fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceRoboticCallLogCallphoneMapper.java
  52. 14 0
      fs-service/src/main/java/com/fs/company/mapper/CrmCustomerCallLogMapper.java
  53. 25 0
      fs-service/src/main/java/com/fs/company/param/AppendCustomersParam.java
  54. 9 1
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticCallLogCallphoneService.java
  55. 10 1
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticService.java
  56. 8 0
      fs-service/src/main/java/com/fs/company/service/ICrmCustomerCallLogService.java
  57. 124 6
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallLogCallphoneServiceImpl.java
  58. 315 64
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java
  59. 5 0
      fs-service/src/main/java/com/fs/company/service/impl/CrmCustomerCallLogServiceImpl.java
  60. 12 3
      fs-service/src/main/java/com/fs/company/service/impl/call/node/AiCallTaskNode.java
  61. 6 2
      fs-service/src/main/java/com/fs/company/vo/AiCallWorkflowConditionVo.java
  62. 2 0
      fs-service/src/main/java/com/fs/company/vo/CallContentVO.java
  63. 23 0
      fs-service/src/main/java/com/fs/company/vo/CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO.java
  64. 19 0
      fs-service/src/main/java/com/fs/company/vo/CompanyVoiceRoboticCallLogCallPhoneDecryptQueryVO.java
  65. 3 0
      fs-service/src/main/java/com/fs/company/vo/CompanyVoiceRoboticCallLogCallPhoneVO.java
  66. 5 0
      fs-service/src/main/java/com/fs/company/vo/WorkflowExecRecordVo.java
  67. 6 1
      fs-service/src/main/java/com/fs/company/vo/easycall/EasyCallCallPhoneVO.java
  68. 2 0
      fs-service/src/main/java/com/fs/company/vo/easycall/EasyCallVoiceCodeVO.java
  69. 2 0
      fs-service/src/main/java/com/fs/config/saas/ProjectConfig.java
  70. 11 0
      fs-service/src/main/java/com/fs/config/tencent/TencentCOSClientConfig.java
  71. 11 0
      fs-service/src/main/java/com/fs/core/config/WxMpProperties.java
  72. 1 1
      fs-service/src/main/java/com/fs/core/config/WxPayConfiguration.java
  73. 1 6
      fs-service/src/main/java/com/fs/crm/domain/CrmCustomer.java
  74. 409 6
      fs-service/src/main/java/com/fs/crm/mapper/CrmCustomerMapper.java
  75. 35 0
      fs-service/src/main/java/com/fs/crm/param/CrmCustomerConditionAssignParam.java
  76. 12 0
      fs-service/src/main/java/com/fs/crm/param/CrmCustomerListQueryParam.java
  77. 25 0
      fs-service/src/main/java/com/fs/crm/service/ICrmCustomerService.java
  78. 120 1
      fs-service/src/main/java/com/fs/crm/service/impl/CrmCustomerServiceImpl.java
  79. 24 0
      fs-service/src/main/java/com/fs/crm/vo/CrmCustomerListVO.java
  80. 26 0
      fs-service/src/main/java/com/fs/crm/vo/CustomerCallStatVO.java
  81. 3 2
      fs-service/src/main/java/com/fs/fastGpt/domain/FastGptChatConversation.java
  82. 141 63
      fs-service/src/main/java/com/fs/fastGpt/service/impl/AiHookServiceImpl.java
  83. 20 5
      fs-service/src/main/java/com/fs/fastgptApi/util/AudioUtils.java
  84. 0 2
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsUserScrmServiceImpl.java
  85. 4 0
      fs-service/src/main/java/com/fs/qw/mapper/QwExternalContactMapper.java
  86. 3 3
      fs-service/src/main/java/com/fs/qw/mapper/QwGroupChatMapper.java
  87. 5 2
      fs-service/src/main/java/com/fs/qw/mapper/QwUserMapper.java
  88. 33 1
      fs-service/src/main/java/com/fs/qw/service/AsyncQwAiChatSopService.java
  89. 4 0
      fs-service/src/main/java/com/fs/qw/service/IQwExternalContactService.java
  90. 14 0
      fs-service/src/main/java/com/fs/qw/service/impl/QwExternalContactServiceImpl.java
  91. 5 1
      fs-service/src/main/java/com/fs/qw/service/impl/QwGroupChatServiceImpl.java
  92. 1 1
      fs-service/src/main/java/com/fs/qwApi/service/impl/QwApiServiceImpl.java
  93. 4 0
      fs-service/src/main/java/com/fs/sensitive/domain/CompanyAiSensitiveWord.java
  94. 2 2
      fs-service/src/main/java/com/fs/sensitive/mapper/CompanyAiSensitiveWordMapper.java
  95. 2 2
      fs-service/src/main/java/com/fs/sensitive/service/impl/CompanyAiSensitiveWordServiceImpl.java
  96. 9 2
      fs-service/src/main/java/com/fs/system/oss/OSSFactory.java
  97. 26 27
      fs-service/src/main/java/com/fs/system/service/impl/SysUserServiceImpl.java
  98. 66 3
      fs-service/src/main/java/com/fs/wx/mp/handler/ScanHandler.java
  99. 63 18
      fs-service/src/main/java/com/fs/wx/mp/handler/SubscribeHandler.java
  100. 303 0
      fs-service/src/main/java/com/fs/wx/mp/service/WxMpSubscribeService.java

+ 3 - 1
docs/company_ai_sensitive_word.sql

@@ -23,6 +23,7 @@ SET FOREIGN_KEY_CHECKS = 0;
 DROP TABLE IF EXISTS `company_ai_sensitive_word`;
 CREATE TABLE `company_ai_sensitive_word`  (
                                               `word_id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
+                                              `company_id` bigint NULL DEFAULT NULL COMMENT '公司ID',
                                               `word` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '敏感词',
                                               `enabled` tinyint NULL DEFAULT 1 COMMENT '是否启用 0/1',
                                               `source` tinyint NULL DEFAULT 1 COMMENT '来源 1=手工 2=批量 3=内置',
@@ -33,7 +34,8 @@ CREATE TABLE `company_ai_sensitive_word`  (
                                               `update_time` datetime NULL DEFAULT NULL,
                                               `del_flag` tinyint NULL DEFAULT 0,
                                               PRIMARY KEY (`word_id`) USING BTREE,
-                                              UNIQUE INDEX `uk_word`(`word` ASC, `del_flag` ASC) USING BTREE,
+                                              UNIQUE INDEX `uk_word_company`(`word` ASC, `company_id` ASC, `del_flag` ASC) USING BTREE,
+                                              INDEX `idx_company_id`(`company_id` ASC) USING BTREE,
                                               INDEX `idx_enabled`(`enabled` ASC) USING BTREE
 ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '敏感词库' ROW_FORMAT = DYNAMIC;
 

+ 11 - 0
docs/wx_mp_subscribe.sql

@@ -0,0 +1,11 @@
+-- 微信公众号订阅通知功能 - 数据库变更脚本
+-- 注意:此脚本需要在所有租户库中执行
+-- 因为是SaaS多租户架构,每个租户有独立的数据库
+
+-- =============================================
+-- 1. 租户库SQL (在每个租户数据库中执行)
+-- =============================================
+
+-- 为company_user表新增公众号相关字段
+ALTER TABLE company_user ADD COLUMN mp_open_id VARCHAR(64) DEFAULT NULL COMMENT '微信公众号OpenId';
+ALTER TABLE company_user ADD COLUMN mp_subscribed INT(1) DEFAULT 0 COMMENT '是否已订阅公众号通知 (0未订阅 1已订阅)';

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

@@ -0,0 +1,370 @@
+package com.fs.aicall.controller;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.aicall.domain.CompanyBindAiModel;
+import com.fs.aicall.domain.CcCallTask;
+import com.fs.aicall.domain.CcLlmAgentAccount;
+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.page.TableDataInfo;
+import com.fs.common.core.text.Convert;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.company.cache.ICompanyCacheService;
+import com.fs.common.core.domain.model.LoginUser;
+import com.fs.common.utils.ServletUtils;
+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.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+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/aicall/controller/CcLlmAgentProviderController.java

@@ -0,0 +1,77 @@
+package com.fs.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/aicall/controller/CcLlmKbCatController.java

@@ -0,0 +1,90 @@
+package com.fs.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/company/controller/CompanyVoiceCloneController.java

@@ -0,0 +1,91 @@
+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.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);
+    }
+}

+ 337 - 1
fs-admin/src/main/java/com/fs/his/task/Task.java

@@ -2,13 +2,18 @@ package com.fs.his.task;
 
 import cn.hutool.core.collection.CollectionUtil;
 import cn.hutool.core.date.DateTime;
+import cn.hutool.http.HttpException;
+import cn.hutool.http.HttpUtil;
 import cn.hutool.json.JSONUtil;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
-import com.fs.common.core.domain.R;
+import com.fs.FSApplication;
+import com.fs.common.config.RedisTenantContext;
 import com.fs.common.core.redis.RedisCache;
+import com.fs.common.core.redis.RedisCacheTenant;
+import com.fs.common.exception.base.BaseException;
 import com.fs.common.service.impl.SmsServiceImpl;
 import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.SecurityUtils;
@@ -20,6 +25,7 @@ import com.fs.company.service.ICompanyService;
 import com.fs.company.service.ICompanyUserService;
 import com.fs.company.vo.QwIpadTotalVo;
 import com.fs.company.vo.RedPacketMoneyVO;
+import com.fs.core.config.TenantConfigContext;
 import com.fs.course.dto.BatchSendCourseAllDTO;
 import com.fs.course.mapper.FsCourseRedPacketLogMapper;
 import com.fs.course.service.IFsCourseWatchLogService;
@@ -33,6 +39,7 @@ import com.fs.fastGpt.domain.*;
 import com.fs.fastGpt.mapper.FastGptChatSessionMapper;
 import com.fs.fastGpt.mapper.FastgptChatVoiceHomoMapper;
 import com.fs.fastGpt.service.AiHookService;
+import com.fs.fastGpt.service.IFastGptChatMsgService;
 import com.fs.fastGpt.service.IFastgptEventLogTotalService;
 import com.fs.framework.task.TenantTaskRunner;
 import com.fs.fastgptApi.util.AudioUtils;
@@ -60,24 +67,35 @@ import com.fs.huifuPay.service.HuiFuService;
 import com.fs.im.dto.*;
 import com.fs.im.service.IImService;
 import com.fs.im.service.OpenIMService;
+import com.fs.ipad.IpadSendUtils;
+import com.fs.ipad.vo.*;
 import com.fs.qw.domain.QwCompany;
+import com.fs.qw.domain.QwExternalContact;
 import com.fs.qw.domain.QwUser;
 import com.fs.qw.mapper.QwRestrictionPushRecordMapper;
 import com.fs.qw.mapper.QwUserMapper;
+import com.fs.qw.param.QwExternalContactAddTagParam;
+import com.fs.qw.param.QwExternalContactParam;
 import com.fs.qw.service.*;
+import com.fs.qwApi.config.OpenQwConfig;
 import com.fs.sop.domain.QwSopTempVoice;
 import com.fs.sop.service.IQwSopTempVoiceService;
 import com.fs.system.domain.SysConfig;
 import com.fs.system.mapper.SysConfigMapper;
 import com.fs.system.service.ISysConfigService;
 import com.fs.utils.OrderContextHolder;
+import com.fs.wxwork.dto.*;
+import com.fs.wxwork.service.WxWorkService;
+import com.fs.wxwork.service.WxWorkServiceNew;
 import com.google.gson.Gson;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
+import org.junit.jupiter.api.Test;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@@ -85,11 +103,15 @@ import org.springframework.stereotype.Component;
 
 import java.text.ParseException;
 import java.text.SimpleDateFormat;
+import java.time.LocalDate;
 import java.time.LocalDateTime;
+import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.util.*;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
 import java.util.function.Consumer;
+import java.util.function.Supplier;
 import java.util.stream.Collectors;
 
 @Slf4j
@@ -239,6 +261,10 @@ public class Task {
 
     @Autowired
     private IFsStoreOrderScrmService orderScrmService;
+
+    @Autowired
+    private IQwExternalContactService qwExternalContactService;
+
     /**
      * 定时任务,处理ai禁止回复之后的消息
      */
@@ -275,6 +301,316 @@ public class Task {
         }
     }*/
 
+    @Scheduled(cron = "0 0/5 * * * ?")
+    public void addTagByQwExtCreateTimeNew() {
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant("addTagByQwExtCreateTime", this::addTagByQwExtCreateTime);
+        } else {
+            addTagByQwExtCreateTime();
+        }
+    }
+
+    /**
+     * 最美叙酱给客户打标签(其他客户关闭,临时用)
+     */
+    public void addTagByQwExtCreateTime() {
+        try {
+            Long tenantId = RedisTenantContext.getTenantId();
+            if(41L != tenantId) return;
+            QwExternalContact qwExternalContact = new QwExternalContact();
+            qwExternalContact.setIsReply(1);
+            qwExternalContact.setTagIds("etotXidAAAMrYqb-nEijbTYQs4PJDbLg");
+            List<QwExternalContact> qwExternalContacts = qwExternalContactService.selectQwExternalContactListBycreateTime(qwExternalContact);
+            if(qwExternalContacts.isEmpty()) return;
+            Date now = new Date();
+            Date oneHourAgo = new Date(now.getTime() - 10 * 60 * 60 * 1000);
+
+            List<QwExternalContact> filteredContacts = qwExternalContacts.stream()
+                    .filter(contact -> {
+                        if (contact.getCreateTime() == null) {
+                            return false;
+                        }
+                        return contact.getCreateTime().before(oneHourAgo);
+                    })
+                    .collect(Collectors.toList());
+
+            if (filteredContacts.isEmpty()) {
+                log.info("过滤后没有创建时间超过10小时的联系人");
+                return;
+            }
+
+            for (QwExternalContact qwExt : filteredContacts) {
+
+                QwExternalContactAddTagParam param = new QwExternalContactAddTagParam();
+                Long qwUserId = qwExt.getQwUserId();
+                if(qwUserId == null) continue;
+                QwUser user = qwUserService.selectQwUserById(qwUserId);
+
+
+                //添加需要打标签的客户
+                List<Long> list = new ArrayList<>();
+                list.add(qwExt.getId());
+                param.setUserIds(list);
+
+                try {
+                    String[] split = new String[]{"etotXidAAAwBK56Zp4OmU71zH8NqlA6Q"};
+                    param.setTagIds(Arrays.asList(split));
+                    param.setCorpId(user.getCorpId());
+                } catch (Exception e) {
+                    System.out.println("标签格式错误,租户41,外部联系人id:" + qwExt.getId());
+                }
+
+                String url = OpenQwConfig.baseApi + "/addTag?tenantId=41";
+                String result = HttpUtil.createPost(url)
+                        .body(JSON.toJSONString(param))
+                        .execute()
+                        .body();
+                System.out.println(result);
+            }
+        } catch (HttpException e) {
+            System.out.println("请求异常:" + e.getMessage());
+        }
+    }
+
+    @Scheduled(cron = "0 0/1 * * * ?")
+    public void sendMsgByQwExtCreateTimeNew() {
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant("sendMsgByQwExtCreateTime", this::sendMsgByQwExtCreateTime);
+        } else {
+            sendMsgByQwExtCreateTime();
+        }
+    }
+
+    @Autowired
+    WxWorkServiceNew wxWorkService;
+
+    @Autowired
+    RedisCacheTenant<Long> redisCacheLong;
+
+    @Autowired
+    private IFastGptChatMsgService fastGptChatMsgService;
+
+    @Autowired
+    IpadSendUtils ipadSendUtils;
+
+    public void sendMsgByQwExtCreateTime() {
+        Long tenantId = RedisTenantContext.getTenantId();
+        if(41L != tenantId) return;
+        QwExternalContact qwExternalContact = new QwExternalContact();
+        qwExternalContact.setTagIds("etotXidAAAcNhR-YuTMgC9B2VxH9QU3g");
+        qwExternalContact.setCreateTime(new Date());
+        List<QwExternalContact> qwExternalContacts = qwExternalContactService.selectQwExternalContactListByCreateTimeNew(qwExternalContact);
+        if(qwExternalContacts.isEmpty()) return;
+        Date now = new Date();
+        Date oneHourAgo = new Date(now.getTime() - 2 * 60 * 1000);
+        List<QwExternalContact> filteredContacts = qwExternalContacts.stream()
+                .filter(contact -> {
+                    if (contact.getCreateTime() == null) {
+                        return false;
+                    }
+                    return contact.getCreateTime().before(oneHourAgo);
+                })
+                .collect(Collectors.toList());
+
+        for (QwExternalContact qwExt : filteredContacts) {
+
+            QwExternalContactAddTagParam param = new QwExternalContactAddTagParam();
+            Long qwUserId = qwExt.getQwUserId();
+            if(qwUserId == null) continue;
+            QwUser user = qwUserService.selectQwUserById(qwUserId);
+            QwCompany qwCompany = qwCompanyService.selectQwCompanyByCorpId(user.getCorpId());
+
+
+            FastGptChatSession fastGptChatSession = fastGptChatSessionMapper.selectFastGptChatSessionByQwExternalContactsAndUserId(qwExt.getId(), qwUserId);
+            if(fastGptChatSession == null){
+                if(qwExt.getType()!=null&&qwExt.getType()==1){
+                    fastGptChatSession = new FastGptChatSession();
+                    String chatId = UUID.randomUUID().toString();
+                    fastGptChatSession.setChatId(chatId);
+                    if(user.getFastGptRoleId() != null){
+                        fastGptChatSession.setKfId(user.getFastGptRoleId().toString());
+                    }
+                    fastGptChatSession.setStatus(1);
+                    fastGptChatSession.setRemindCount(0);
+                    fastGptChatSession.setRemindStatus(0);
+                    fastGptChatSession.setCreateTime(new Date());
+                    fastGptChatSession.setQwExtId(qwExt.getId());
+                    fastGptChatSession.setQwUserId(user.getId());
+                    fastGptChatSession.setIsArtificial(0);
+                    fastGptChatSession.setAvatar(qwExt.getAvatar());
+                    fastGptChatSession.setNickName(qwExt.getName());
+                    fastGptChatSession.setCompanyId(user.getCompanyId());
+                    fastGptChatSession.setLastTime(new Date());
+                    fastGptChatSession.setIsReply(0);
+                    fastGptChatSessionMapper.insertFastGptChatSession(fastGptChatSession);
+                }
+            }
+
+            BaseVo parentVo = new BaseVo();
+            assert fastGptChatSession != null;
+            if(fastGptChatSession.getSessionId() == null){
+                parentVo.setId(user.getId());
+            }else{
+                parentVo.setId(fastGptChatSession.getSessionId());
+            }
+            parentVo.setRoom(false);
+            parentVo.setUuid(user.getUid());
+            parentVo.setAgentId(qwCompany.getServerAgentId());
+            parentVo.setExId(qwExt.getExternalUserId());
+            parentVo.setServerId(user.getServerId());
+            parentVo.setCorpCode(parentVo.getCorpCode());
+            parentVo.setCorpId(parentVo.getCorpId());
+            parentVo.setQwUserId(user.getId());
+
+
+            FileVo voiceVo;
+            CompanyUser companyUser = companyUserMapper.selectCompanyUserById(user.getCompanyUserId());
+            if(companyUser == null) continue;
+            if("0".equals(companyUser.getSex())){
+                voiceVo = FileVo.builder()
+                        .url("https://kyt-1323137866.cos.ap-chongqing.myqcloud.com/test/1.silk")
+                        .voiceTime(36)
+                        .build();
+            }else{
+                voiceVo = FileVo.builder()
+                        .url("https://kyt-1323137866.cos.ap-chongqing.myqcloud.com/test/2.silk")
+                        .voiceTime(32)
+                        .build();
+            }
+
+
+            voiceVo.setBase(parentVo);
+            ipadSendUtils.sendVoice(voiceVo);
+
+            String content = "本次免费领取活动真实有效,明天上午十点您准时来直播间观看并免费领取。明天开播前我会把直播链接发给您,看完直播第一时间找我登记领取,好吧哥!";
+            TxtVo txtVo = TxtVo.builder().content(content).build();
+            txtVo.setBase(parentVo);
+            saveQwUserMsg(fastGptChatSession,1,content,user);
+            ipadSendUtils.sendTxt(txtVo);
+
+            FileVo imgVo = FileVo.builder().url("https://ylrz-1323137866.cos.ap-chongqing.myqcloud.com/ylrz/20260528/eb81adca120a45dcbf7ae31e3daacd74.png").build();
+            imgVo.setBase(parentVo);
+            ipadSendUtils.sendImg(imgVo);
+
+            FileVo imgVo1 = FileVo.builder().url("https://ysy-1329817240.cos.ap-guangzhou.myqcloud.com/ysy/20260527/1ee711bf934642cba3886a5f07ac3cdd.png").build();
+            imgVo1.setBase(parentVo);
+            ipadSendUtils.sendImg(imgVo1);
+
+
+            //添加需要打标签的客户
+            List<Long> list = new ArrayList<>();
+            list.add(qwExt.getId());
+            param.setUserIds(list);
+
+            try {
+                String[] split = new String[]{"etotXidAAAcNhR-YuTMgC9B2VxH9QU3g"};
+                param.setTagIds(Arrays.asList(split));
+                param.setCorpId(user.getCorpId());
+            } catch (Exception e) {
+                System.out.println("标签格式错误,租户41,外部联系人id:" + qwExt.getId());
+            }
+
+            String url = OpenQwConfig.baseApi + "/delTag?tenantId=41";
+            String result = HttpUtil.createPost(url)
+                    .body(JSON.toJSONString(param))
+                    .execute()
+                    .body();
+            System.out.println(result);
+        }
+    }
+
+
+    @Scheduled(cron = "0 10 9 * * ?")
+    public void sendMsgByQwExtCreateTimeTwoDayNew() {
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant("sendMsgByQwExtCreateTimeTwoDay", this::sendMsgByQwExtCreateTimeTwoDay);
+        } else {
+            sendMsgByQwExtCreateTimeTwoDay();
+        }
+    }
+
+    public void sendMsgByQwExtCreateTimeTwoDay() {
+        Long tenantId = RedisTenantContext.getTenantId();
+        if(41L != tenantId) return;
+        // 昨天日期
+        Date yesterdayDate = Date.from(
+                LocalDate.now().minusDays(1)
+                        .atStartOfDay(ZoneId.systemDefault())
+                        .toInstant());
+
+        QwExternalContact qwExternalContact = new QwExternalContact();
+        qwExternalContact.setCreateTime(yesterdayDate);
+        List<QwExternalContact> qwExternalContacts = qwExternalContactService.selectQwExternalContactListByCreateTimeNew(qwExternalContact);
+        if(qwExternalContacts.isEmpty()) return;
+
+
+        for (QwExternalContact qwExt : qwExternalContacts) {
+            Long qwUserId = qwExt.getQwUserId();
+            if(qwUserId == null) continue;
+            QwUser user = qwUserService.selectQwUserById(qwUserId);
+            QwCompany qwCompany = qwCompanyService.selectQwCompanyByCorpId(user.getCorpId());
+            if(qwCompany == null) continue;
+
+
+            BaseVo parentVo = new BaseVo();
+            parentVo.setId(user.getId());
+            parentVo.setRoom(false);
+            parentVo.setUuid(user.getUid());
+            parentVo.setAgentId(qwCompany.getServerAgentId());
+            parentVo.setExId(qwExt.getExternalUserId());
+            parentVo.setServerId(user.getServerId());
+            parentVo.setCorpCode(parentVo.getCorpCode());
+            parentVo.setCorpId(parentVo.getCorpId());
+            parentVo.setQwUserId(user.getId());
+
+
+            FileVo voiceVo;
+            CompanyUser companyUser = companyUserMapper.selectCompanyUserById(user.getCompanyUserId());
+            if(companyUser == null) continue;
+            if("0".equals(companyUser.getSex())){
+                voiceVo = FileVo.builder()
+                        .url("https://kyt-1323137866.cos.ap-chongqing.myqcloud.com/test/13.silk")
+                        .voiceTime(19)
+                        .build();
+            }else{
+                voiceVo = FileVo.builder()
+                        .url("https://kyt-1323137866.cos.ap-chongqing.myqcloud.com/test/12.silk")
+                        .voiceTime(17)
+                        .build();
+            }
+
+            voiceVo.setBase(parentVo);
+            ipadSendUtils.sendVoice(voiceVo);
+        }
+    }
+    /** 存聊天记录  **/
+    private void saveQwUserMsg(FastGptChatSession fastGptChatSession,Integer sendType,String content,QwUser sendUser) {
+        if(content.isEmpty()){
+            return;
+        }
+        FastGptChatMsg fastGptChatMsgAi = new FastGptChatMsg();
+        fastGptChatMsgAi.setContent(content);
+        fastGptChatMsgAi.setSessionId(fastGptChatSession.getSessionId());
+        if(sendUser.getFastGptRoleId() != null){
+            fastGptChatMsgAi.setRoleId(Long.parseLong(fastGptChatSession.getKfId()));
+        }
+        fastGptChatMsgAi.setSendType(sendType);
+        fastGptChatMsgAi.setCompanyId(fastGptChatSession.getCompanyId());
+        fastGptChatMsgAi.setCompanyUserId(sendUser.getCompanyUserId());
+        fastGptChatMsgAi.setUserId(fastGptChatSession.getUserId());
+        fastGptChatMsgAi.setUserType(1);
+        fastGptChatMsgAi.setMsgType(1);
+        fastGptChatMsgAi.setStatus(0);
+        fastGptChatMsgAi.setAvatar(fastGptChatSession.getAvatar());
+        fastGptChatMsgAi.setNickName(fastGptChatSession.getNickName());
+        fastGptChatMsgAi.setCreateTime(new Date());
+        fastGptChatMsgAi.setExtId(fastGptChatSession.getQwExtId()+"");
+        fastGptChatMsgService.insertFastGptChatMsg(fastGptChatMsgAi);
+        log.info("新增消息:"+fastGptChatMsgAi);
+    }
+
+
+
     /**
      * sop任务token消耗统计
      */

+ 0 - 2
fs-admin/src/main/java/com/fs/live/controller/LiveController.java

@@ -11,8 +11,6 @@ import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.company.vo.CompanyVO;
 import com.fs.framework.web.service.TokenService;
-import com.fs.hisStore.task.LiveTask;
-import com.fs.hisStore.task.MallStoreTask;
 import com.fs.live.domain.Live;
 import com.fs.live.service.ILiveService;
 import com.fs.live.vo.LiveListVo;

+ 10 - 5
fs-admin/src/main/java/com/fs/qw/controller/QwSopTempController.java

@@ -6,6 +6,7 @@ import com.fs.common.annotation.Log;
 import com.fs.common.core.controller.BaseController;
 import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.core.domain.R;
+import com.fs.common.core.domain.entity.SysUser;
 import com.fs.common.core.domain.model.LoginUser;
 import com.fs.common.core.page.TableDataInfo;
 import com.fs.common.enums.BusinessType;
@@ -21,6 +22,7 @@ import com.fs.sop.domain.QwSopTempDay;
 import com.fs.sop.params.QwSopShareTempParam;
 import com.fs.sop.service.IQwSopTempService;
 import com.fs.sop.vo.UpdateRedVo;
+import com.fs.system.service.ISysUserService;
 import com.fs.voice.utils.StringUtil;
 import com.github.pagehelper.PageHelper;
 import com.github.pagehelper.PageInfo;
@@ -49,6 +51,9 @@ public class QwSopTempController extends BaseController
     private TokenService tokenService;
     @Autowired
     private CompanyUserServiceImpl companyUserService;
+
+    @Autowired
+    private ISysUserService userService;
     /**
      * 查询sop模板列表
      */
@@ -77,19 +82,19 @@ public class QwSopTempController extends BaseController
 
         if (!userIds.isEmpty()){
             // 批量查询用户信息
-            Map<Long, DocCompanyUserVO> userMap = companyUserService
-                    .selectDocCompanyUserListByUserIds(userIds)
+            Map<Long, SysUser> userMap = userService
+                    .selectUserListByIds(new ArrayList<>(userIds))
                     .stream()
-                    .collect(Collectors.toMap(DocCompanyUserVO::getUserId, Function.identity()));
+                    .collect(Collectors.toMap(SysUser::getUserId, Function.identity()));
 
 
             list.forEach(item->{
 
                 if (!StringUtil.strIsNullOrEmpty(item.getCreateBy())) {
-                    DocCompanyUserVO user = userMap.get(Long.valueOf(item.getCreateBy()));
+                    SysUser user = userMap.get(Long.valueOf(item.getCreateBy()));
                     if (user != null) {
                         item.setCreateByName(user.getNickName());
-                        item.setCreateByDeptName(user.getDeptName());
+                        item.setCreateByDeptName(user.getDept().getDeptName());
                     }
                 }
 

+ 120 - 0
fs-admin/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));
+    }
+}

+ 124 - 0
fs-admin/src/main/java/com/fs/wx/controller/WxMpPortalController.java

@@ -0,0 +1,124 @@
+package com.fs.wx.controller;
+
+import com.fs.core.config.WxMpProperties;
+import com.fs.wx.mp.service.WxMpSubscribeService;
+import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.mp.api.WxMpMessageRouter;
+import me.chanjar.weixin.mp.api.WxMpService;
+import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage;
+import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 微信公众号消息回调入口
+ * 接收微信服务器推送的事件消息(关注、扫码等)
+ * 配置在微信公众号后台的消息服务器URL
+ */
+@Slf4j
+@RestController
+@RequestMapping("/wx/mp/portal")
+public class WxMpPortalController {
+
+    @Autowired
+    private WxMpProperties wxMpProperties;
+
+    @Autowired
+    private WxMpMessageRouter messageRouter;
+
+    /**
+     * 微信服务器验证接口(GET)
+     */
+    @GetMapping(produces = "text/plain;charset=utf-8")
+    public String authGet(
+            @RequestParam(name = "signature", required = false) String signature,
+            @RequestParam(name = "timestamp", required = false) String timestamp,
+            @RequestParam(name = "nonce", required = false) String nonce,
+            @RequestParam(name = "echostr", required = false) String echostr) {
+
+        log.info("接收到微信服务器认证消息:signature={}, timestamp={}, nonce={}, echostr={}",
+                signature, timestamp, nonce, echostr);
+
+        if (StringUtils.isAnyBlank(signature, timestamp, nonce, echostr)) {
+            throw new IllegalArgumentException("请求参数非法");
+        }
+
+        try {
+            WxMpService wxMpService = wxMpProperties.createFirstWxMpService();
+            if (wxMpService.checkSignature(timestamp, nonce, signature)) {
+                return echostr;
+            }
+        } catch (Exception e) {
+            log.error("微信签名验证异常", e);
+        }
+
+        return "非法请求";
+    }
+
+    /**
+     * 微信消息回调接口(POST)
+     * 处理关注、扫码、菜单等事件
+     */
+    @PostMapping(produces = "application/xml; charset=UTF-8")
+    public String post(
+            @RequestBody String requestBody,
+            @RequestParam("signature") String signature,
+            @RequestParam("timestamp") String timestamp,
+            @RequestParam("nonce") String nonce,
+            @RequestParam("openid") String openid,
+            @RequestParam(name = "encrypt_type", required = false) String encType,
+            @RequestParam(name = "msg_signature", required = false) String msgSignature) {
+
+        log.info("接收微信请求:openid={}, signature={}, encType={}, timestamp={}, nonce={}",
+                openid, signature, encType, timestamp, nonce);
+        log.debug("微信请求体:{}", requestBody);
+
+        try {
+            WxMpService wxMpService = wxMpProperties.createFirstWxMpService();
+
+            if (!wxMpService.checkSignature(timestamp, nonce, signature)) {
+                throw new IllegalArgumentException("非法请求,可能属于伪造的请求!");
+            }
+
+            String out;
+            if (encType == null) {
+                // 明文消息
+                WxMpXmlMessage inMessage = WxMpXmlMessage.fromXml(requestBody);
+                WxMpXmlOutMessage outMessage = this.route(inMessage);
+                if (outMessage == null) {
+                    return "";
+                }
+                out = outMessage.toXml();
+            } else if ("aes".equalsIgnoreCase(encType)) {
+                // 加密消息
+                WxMpXmlMessage inMessage = WxMpXmlMessage.fromEncryptedXml(
+                        requestBody, wxMpService.getWxMpConfigStorage(), timestamp, nonce, msgSignature);
+                log.debug("消息解密后内容:{}", inMessage.toString());
+                WxMpXmlOutMessage outMessage = this.route(inMessage);
+                if (outMessage == null) {
+                    return "";
+                }
+                out = outMessage.toEncryptedXml(wxMpService.getWxMpConfigStorage());
+            } else {
+                return "";
+            }
+
+            log.debug("组装回复信息:{}", out);
+            return out;
+
+        } catch (Exception e) {
+            log.error("处理微信消息异常", e);
+            return "";
+        }
+    }
+
+    private WxMpXmlOutMessage route(WxMpXmlMessage message) {
+        try {
+            return this.messageRouter.route(message);
+        } catch (Exception e) {
+            log.error("路由微信消息异常", e);
+        }
+        return null;
+    }
+}

+ 66 - 38
fs-ai-call-task/src/main/java/com/fs/app/service/CallTaskService.java

@@ -10,8 +10,11 @@ import com.fs.company.param.ExecutionContext;
 import com.fs.company.param.PauseRoboticActiveParam;
 import com.fs.company.service.*;
 import com.fs.company.service.impl.call.node.AiCallTaskNode;
+import com.fs.wxcid.utils.TenantHelper;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.beans.factory.annotation.Value;
@@ -40,53 +43,78 @@ public class CallTaskService {
     @Qualifier("cidWorkFlowExecutor")
     private Executor cidWorkFlowExecutor;
 
+    @Autowired
+    private RedissonClient redissonClient;
+
+    /** 外呼延时扫描分布式锁 key 前缀,按 租户id:分组 隔离,避免多实例/重入重复扫描同批延时key */
+    private static final String CALL_DELAY_LOCK_PREFIX = "cid_workflow:call_delay_lock:";
+
 
     /**
      * 扫描工作流延时任务
      */
     public void cidWorkflowCallRun() {
-        log.info("===========工作流延时任务开始扫描===========");
-        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
-                CompletableFuture.runAsync(() -> {
-                    try {
-                        ExecutionContext context = redisCache2.getCacheObject(key);
-                        if (context == null) {
-                            log.warn("工作流延时任务context为空,跳过 - key: {}", key);
-                            redisCache2.deleteObject(key);
-                            return;
-                        }
-                        // 任务暂停守卫检查(roboticId即CompanyVoiceRobotic.id,是实际暂停操作的目标)
-                        Long taskId = context.getVariable("roboticId", Long.class);
-                        if (taskId != null && pausedCache.computeIfAbsent(taskId, id -> companyVoiceRoboticService.isTaskPaused(id))) {
-                            // 延时key是时间分片前缀,下一分钟就不会再扫到,直接删除
-                            // 同步context信息到DB exec,供恢复时resumePausedInstances使用
+        // 分布式锁:按 租户id:分组 隔离,拿不到锁说明已有实例在扫描,直接跳过本轮(延时key下轮仍可扫到,不丢失)
+        String lockKey = CALL_DELAY_LOCK_PREFIX + TenantHelper.getTenantId() + ":" + cidGroupNo;
+        RLock lock = redissonClient.getLock(lockKey);
+        boolean locked = false;
+        try {
+            // waitTime=0 拿不到立即返回;leaseTime=-1 启用看门狗自动续期,避免大数据量执行超时被提前释放
+            locked = lock.tryLock(0, -1, TimeUnit.SECONDS);
+            if (!locked) {
+                log.info("cidWorkflowCallRun 已有实例在执行,跳过本轮 - lockKey: {}", lockKey);
+                return;
+            }
+            log.info("===========工作流延时任务开始扫描===========");
+            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
+                    CompletableFuture.runAsync(() -> {
+                        try {
+                            ExecutionContext context = redisCache2.getCacheObject(key);
+                            if (context == null) {
+                                log.warn("工作流延时任务context为空,跳过 - key: {}", key);
+                                redisCache2.deleteObject(key);
+                                return;
+                            }
+                            // 任务暂停守卫检查(roboticId即CompanyVoiceRobotic.id,是实际暂停操作的目标)
+                            Long taskId = context.getVariable("roboticId", Long.class);
+                            if (taskId != null && pausedCache.computeIfAbsent(taskId, id -> companyVoiceRoboticService.isTaskPaused(id))) {
+                                // 延时key是时间分片前缀,下一分钟就不会再扫到,直接删除
+                                // 同步context信息到DB exec,供恢复时resumePausedInstances使用
+                                context.setVariable("callSource", "callTaskTimer");
+                                context.setVariable("_delayTargetNodeKey", context.getCurrentNodeKey());
+                                companyWorkflowEngine.updateExecVariables(context.getWorkflowInstanceId(), context.getVariables());
+                                log.info("任务已暂停,删除延时key并同步exec,等待恢复时从DB重建 - taskId: {}, key: {}", taskId, key);
+                                redisCache2.deleteObject(key);
+                                return;
+                            }
+                            context.setVariable("callRedisKey", key);
                             context.setVariable("callSource", "callTaskTimer");
-                            context.setVariable("_delayTargetNodeKey", context.getCurrentNodeKey());
-                            companyWorkflowEngine.updateExecVariables(context.getWorkflowInstanceId(), context.getVariables());
-                            log.info("任务已暂停,删除延时key并同步exec,等待恢复时从DB重建 - taskId: {}, key: {}", taskId, key);
+                            companyWorkflowEngine.timeDoExecute(context.getWorkflowInstanceId(), context.getCurrentNodeKey(), context.getVariables());
                             redisCache2.deleteObject(key);
-                            return;
+                        } catch (Exception e) {
+                            log.error("处理工作流延时任务异常 - key: {}", key, e);
                         }
-                        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);
-            } catch (Exception ex) {
-                log.error("处理工作流延时任务异常 - key: {}", key, ex);
+                    }, cidWorkFlowExecutor);
+                } catch (Exception ex) {
+                    log.error("处理工作流延时任务异常 - key: {}", key, ex);
+                }
+            });
+            log.info("===========工作流延时任务扫描结束===========");
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.warn("cidWorkflowCallRun 获取分布式锁被中断 - lockKey: {}", lockKey, e);
+        } finally {
+            if (locked && lock.isHeldByCurrentThread()) {
+                lock.unlock();
             }
-        });
-        log.info("===========工作流延时任务扫描结束===========");
+        }
     }
 
     /**

+ 104 - 19
fs-cid-workflow/src/main/java/com/fs/app/service/CidWorkflowTaskService.java

@@ -15,6 +15,8 @@ import com.fs.enums.NodeTypeEnum;
 import com.fs.wxcid.utils.TenantHelper;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.beans.factory.annotation.Value;
@@ -29,6 +31,11 @@ public class CidWorkflowTaskService {
 
     @Value("${cid-group-no:0}")
     Integer cidGroupNo;
+
+    /** 认领态(RUNNING)超时分钟数,超过该时间未流转的任务视为卡死并回扫重置为失败 */
+    @Value("${cid-workflow.running-timeout-minutes:30}")
+    Integer runningTimeoutMinutes;
+
     private final CompanyAiWorkflowExecMapper companyAiWorkflowExecMapper;
     private final CompanyWorkflowEngine companyWorkflowEngine;
     private final ICompanyVoiceRoboticService companyVoiceRoboticService;
@@ -43,18 +50,36 @@ public class CidWorkflowTaskService {
     @Autowired
     private WorkflowNodeFactory nodeFactory;
 
+    @Autowired
+    private RedissonClient redissonClient;
+
+    /** 扫描分布式锁 key 前缀,按 租户id:分组 隔离,保证同一批数据同一时刻仅一个实例扫描 */
+    private static final String SCAN_LOCK_PREFIX = "cid_workflow:scan_lock:";
+
+    /** 激活可执行任务的分布式锁 key 前缀,与扫描就绪任务的锁独立,避免两个定时任务互相阻塞 */
+    private static final String ACTIVATE_LOCK_PREFIX = "cid_workflow:activate_lock:";
+
     /**
      * 扫描当前分组下就绪任务,并开启执行
      */
     public void runCidWorkflow() {
-        List<CompanyAiWorkflowExec> companyAiWorkflowExecs = companyAiWorkflowExecMapper.selectExecListWithTimeAvailableByStatusAndGroupNo(ExecutionStatusEnum.READY.getValue(), cidGroupNo);
-        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(() -> {
+        // 分布式锁:按 租户id:分组 隔离,拿不到锁说明同批数据已有实例在扫描,直接跳过本轮(周期任务下轮会再扫,不积压)
+        String lockKey = SCAN_LOCK_PREFIX + TenantHelper.getTenantId() + ":" + cidGroupNo;
+        RLock lock = redissonClient.getLock(lockKey);
+        boolean locked = false;
+        try {
+            // waitTime=0 拿不到立即返回;leaseTime=-1 启用看门狗自动续期,避免大数据量执行超时被提前释放
+            locked = lock.tryLock(0, -1, TimeUnit.SECONDS);
+            if (!locked) {
+                log.info("runCidWorkflow 已有实例在执行,跳过本轮 - lockKey: {}", lockKey);
+                return;
+            }
+            List<CompanyAiWorkflowExec> companyAiWorkflowExecs = companyAiWorkflowExecMapper.selectExecListWithTimeAvailableByStatusAndGroupNo(ExecutionStatusEnum.READY.getValue(), cidGroupNo);
+            log.info("runCidWorkflow得到租户id:{}",TenantHelper.getTenantId());
+            if (null != companyAiWorkflowExecs && companyAiWorkflowExecs.size() > 0) {
+                // 本地缓存已查询的任务暂停状态,避免同一批次重复查询
+                Map<Long, Boolean> pausedCache = new HashMap<>();
+                companyAiWorkflowExecs.forEach(exec -> {
                     try {
                         // 任务暂停守卫检查(从 variables JSON 中提取 roboticId,即 CompanyVoiceRobotic.id)
                         Long taskId = extractRoboticIdFromExec(exec);
@@ -65,12 +90,29 @@ public class CidWorkflowTaskService {
                                 return;
                             }
                         }
+                        // 原子认领:READY -> RUNNING,仅认领成功(影响行数=1)才提交执行,
+                        // 防止上一轮异步任务尚未完成时下一次定时扫描重复调度同一条记录
+                        int claimed = companyAiWorkflowExecMapper.claimExecForRun(
+                                exec.getId(),
+                                ExecutionStatusEnum.READY.getValue(),
+                                ExecutionStatusEnum.RUNNING.getValue());
+                        if (claimed == 0) {
+                            log.debug("任务已被认领,跳过重复执行 - execId: {}", exec.getId());
+                            return;
+                        }
                         companyWorkflowEngine.executeNode(exec.getWorkflowInstanceId(), exec.getCurrentNodeKey());
                     } catch (Exception e) {
                         log.error("处理就绪任务异常 - exec: {}", exec, e);
                     }
-//                });
-            });
+                });
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.warn("runCidWorkflow 获取分布式锁被中断 - lockKey: {}", lockKey, e);
+        } finally {
+            if (locked && lock.isHeldByCurrentThread()) {
+                lock.unlock();
+            }
         }
     }
 
@@ -78,13 +120,23 @@ public class CidWorkflowTaskService {
      * 扫描可执行任务,并激活执行
      */
     public void activateTimeAvailableTask() {
-        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(() -> {
+        // 分布式锁:与 runCidWorkflow 锁独立,按 租户id:分组 隔离,拿不到锁说明已有实例在激活,直接跳过本轮
+        String lockKey = ACTIVATE_LOCK_PREFIX + TenantHelper.getTenantId() + ":" + cidGroupNo;
+        RLock lock = redissonClient.getLock(lockKey);
+        boolean locked = false;
+        try {
+            // waitTime=0 拿不到立即返回;leaseTime=-1 启用看门狗自动续期,避免大数据量执行超时被提前释放
+            locked = lock.tryLock(0, -1, TimeUnit.SECONDS);
+            if (!locked) {
+                log.info("activateTimeAvailableTask 已有实例在执行,跳过本轮 - lockKey: {}", lockKey);
+                return;
+            }
+            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 -> {
                     try {
                         // 任务暂停守卫检查(从 variables JSON 中提取 roboticId,即 CompanyVoiceRobotic.id)
                         Long taskId = extractRoboticIdFromExec(exec);
@@ -95,12 +147,45 @@ public class CidWorkflowTaskService {
                                 return;
                             }
                         }
+                        // 原子认领:PENDING -> RUNNING,仅认领成功(影响行数=1)才提交执行,
+                        // 防止上一轮异步任务尚未完成时下一次定时扫描重复激活同一条记录
+                        int claimed = companyAiWorkflowExecMapper.claimExecForRun(
+                                exec.getId(),
+                                ExecutionStatusEnum.PENDING.getValue(),
+                                ExecutionStatusEnum.RUNNING.getValue());
+                        if (claimed == 0) {
+                            log.debug("任务已被认领,跳过重复执行 - execId: {}", exec.getId());
+                            return;
+                        }
                         companyWorkflowEngine.executeNode(exec.getWorkflowInstanceId(), exec.getCurrentNodeKey());
                     } catch (Exception e) {
                         log.error("处理就绪任务异常 - exec: {}", exec, e);
                     }
-//                });
-            });
+                });
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.warn("activateTimeAvailableTask 获取分布式锁被中断 - lockKey: {}", lockKey, e);
+        } finally {
+            if (locked && lock.isHeldByCurrentThread()) {
+                lock.unlock();
+            }
+        }
+    }
+
+    /**
+     * 回扫超时认领任务:将处于 RUNNING 态且超过 runningTimeoutMinutes 分钟未更新的记录重置为 FAILURE。
+     * 用于兜底进程重启 / 线程池拒绝导致任务永久卡在认领态(RUNNING)无法被再次扫描的情况。
+     */
+    public void resetTimeoutRunningTask() {
+        java.time.LocalDateTime timeoutTime = java.time.LocalDateTime.now().minusMinutes(runningTimeoutMinutes);
+        int reset = companyAiWorkflowExecMapper.resetTimeoutRunningExec(
+                cidGroupNo,
+                ExecutionStatusEnum.RUNNING.getValue(),
+                ExecutionStatusEnum.FAILURE.getValue(),
+                timeoutTime);
+        if (reset > 0) {
+            log.warn("回扫超时认领任务,重置为失败状态 - groupNo: {}, count: {}, timeoutMinutes: {}", cidGroupNo, reset, runningTimeoutMinutes);
         }
     }
 

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

@@ -59,6 +59,19 @@ public class CidTask {
 
     }
 
+    /**
+     * 回扫超时认领任务 - 每5分钟执行一次
+     * 将卡在认领态(RUNNING)且长时间未流转的任务重置为失败,兜底进程重启/线程池拒绝导致的永久卡死
+     */
+    @Scheduled(cron = "0 0/5 * * * ?")
+    public void resetTimeoutRunningTask() {
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForResponsibleTenant("resetTimeoutRunningTask", () -> cidWorkflowTaskService.resetTimeoutRunningTask());
+        } else {
+            cidWorkflowTaskService.resetTimeoutRunningTask();
+        }
+    }
+
     /**
      * 外呼重试任务 - 每30分钟执行一次
      * 扫描 Redis 中被外呼限制拦截的待重试呼叫,到达 nextAvailableTime 后重新执行

+ 1 - 1
fs-cid-workflow/src/main/resources/application.yml

@@ -5,7 +5,7 @@ server:
 # Spring配置
 spring:
   profiles:
-    active: dev
+    active: dev-test
 #    active: druid-hcl
 #    active: druid-sxjz
 #    active: druid-hdt

+ 46 - 21
fs-company-app/src/main/java/com/fs/app/controller/aiSipCall/AiSipCallController.java

@@ -93,8 +93,6 @@ public class AiSipCallController extends AppBaseController {
     @Autowired
     private CrmCustomerMapper crmCustomerMapper;
 
-    private final String AUDIO_BASE_URL = "http://129.28.164.235:8899";
-
     /**
      * 是否使用自有线路(迁移自 his_java sip.call.myGateway,原放在 AiSipCallUserServiceImpl)。
      * <p>SaaS 老 ServiceImpl 已被精简没有兜底逻辑,故在 Controller 层注入并保持 his_java 行为一致。
@@ -102,21 +100,15 @@ public class AiSipCallController extends AppBaseController {
     @Value("${sip.call.myGateway:false}")
     private boolean isMyGateway;
 
-    /**
-     * 手动外呼网关前缀(迁移自 his_java sip.call.manualGatewayPrefix)
-     */
+    /** 手动外呼网关前缀(迁移自 his_java sip.call.manualGatewayPrefix) */
     @Value("${sip.call.manualGatewayPrefix:weizhi}")
     private String manualGatewayPrefix;
 
-    /**
-     * 公共线路网关前缀(迁移自 his_java sip.call.publicGatewayPrefix)
-     */
+    /** 公共线路网关前缀(迁移自 his_java sip.call.publicGatewayPrefix) */
     @Value("${sip.call.publicGatewayPrefix:outbound}")
     private String publicGatewayPrefix;
 
-    /**
-     * 加密手机号末尾随机串长度(与前端约定,与 his_java 的 RandomUtil.generateRandomCode 等价)
-     */
+    /** 加密手机号末尾随机串长度(与前端约定,与 his_java 的 RandomUtil.generateRandomCode 等价) */
     private static final int RANDOM_TAIL_LEN = 6;
 
     private static final char[] RANDOM_CHARS =
@@ -124,11 +116,13 @@ public class AiSipCallController extends AppBaseController {
 
     private static final SecureRandom RANDOM = new SecureRandom();
 
-    /**
-     * XOR加密公钥(与 his_java 的 PhoneUtil.PUBLIC_KEY_STR 保持一致,避免污染 SaaS 原有 PhoneUtil)
-     */
+    /** 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)。
@@ -304,17 +298,17 @@ public class AiSipCallController extends AppBaseController {
                 getCompanyUserId(), startTime, endTime, encryptedMobile, remark, callStatus);
         if (list != null) {
             for (CrmCustomer c : list) {
-                if (StringUtils.isNotBlank(c.getMobile())) {
+                if(StringUtils.isNotBlank(c.getMobile())){
+                    String decrypted = PhoneUtil.decryptPhone(c.getMobile());
                     try {
-                        String decrypted = PhoneUtil.decryptPhone(c.getMobile());
                         c.setMobile(decrypted.replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2"));
-                        if (Integer.valueOf(1).equals(c.getCanDecrypt())) {
-                            c.setDecryptedMobile(decrypted);
-                        }
                     } catch (Exception e) {
                         log.warn("[aiSipCall] 解密手机号失败, customerId={}", c.getCustomerId());
                     }
                 }
+                if(StringUtils.isNotBlank(c.getEffectiveRecordPath())){
+                    c.setEffectiveRecordPath(SPLICE_ADD + c.getEffectiveRecordPath());
+                }
             }
         }
         return getDataTable(list);
@@ -327,11 +321,27 @@ public class AiSipCallController extends AppBaseController {
     @ApiOperation("根据客户ID查询呼叫记录")
     @GetMapping("/getCallRecordByCustomerId")
     public TableDataInfo getCallRecordByCustomerId(
-            @RequestParam("customerId") Long customerId,
+            @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();
-        query.setCustomerId(customerId);
+        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);
         //拼接音频文件
@@ -363,6 +373,21 @@ public class AiSipCallController extends AppBaseController {
         }
     }
 
+    /**
+     * 设置客户解密状态为已解锁
+     */
+    @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)

+ 4 - 3
fs-company-app/src/main/resources/application.yml

@@ -1,9 +1,10 @@
 server:
-  # 服务器的HTTP端口,默认为8080
   port: 8007
-
 # Spring配置
 spring:
   profiles:
-#    active: druid-fcky-test
+    #    active: druid-fcky-test
     active: dev
+
+#  # 服务器的HTTP端口,默认为8080
+#  port: 8007

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

@@ -16,6 +16,8 @@ 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;
@@ -34,6 +36,8 @@ import java.util.Base64;
 import java.util.Date;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
 
 /**
  * aiSIP手动外呼通话记录Controller
@@ -54,6 +58,8 @@ public class AiSipCallOutboundCdrController extends BaseController
     TenantDataSourceManager tenantDataSourceManager;
     @Autowired
     private TokenService tokenService;
+    @Autowired
+    private CrmCustomerCallLogMapper crmCustomerCallLogMapper;
 
     /** XOR加密公钥(与 his_java PhoneUtil.PUBLIC_KEY_STR 保持一致) */
     private static final String XOR_KEY = "ylrz112233";
@@ -175,11 +181,18 @@ public class AiSipCallOutboundCdrController extends BaseController
     }
 
 
+    /** 同步重试最大次数 */
+    private static final int SYNC_RETRY_MAX_TIMES = 3;
+    /** 同步重试间隔(秒) */
+    private static final long SYNC_RETRY_INTERVAL_SECONDS = 10L;
+
     /**
      * 同步aiSIP外呼通话记录
      * 说明:
      * 1. 如果有 workflowInstanceId 和 roboticId,走原来的机器人/工作流外呼记录
      * 2. 如果没有 workflowInstanceId 和 roboticId,走用户手动外呼记录
+     * 3. 如果对方通话记录尚未写入,立即返回"正在同步录音中",后台异步重试3次(每次间隔10秒)
+     * 4. 3次重试后仍无数据,则用请求参数写入一条仅含基础信息的CrmCustomerCallLog(不含通话数据),后续可通过uuid手动同步
      */
     @PostMapping("/syncByUuid")
     public AjaxResult syncByUuid(@RequestBody ApiCallRecordByUuidQueryParams req) {
@@ -195,10 +208,13 @@ public class AiSipCallOutboundCdrController extends BaseController
         //获取租户id
         req.setTenantId(getTenantId());
         EasyCallOutBoundVO callPhoneRes = easyCallMapper.getOutBoundInfoByUuid(req.getUuid());
+        tenantDataSourceManager.ensureSwitchByTenantId(SecurityUtils.getTenantId());
         if (ObjectUtil.isEmpty(callPhoneRes)) {
-            return AjaxResult.error("未同步到对应通话记录");
+            // 对方通话记录尚未写入,异步重试
+            log.info("syncByUuid uuid={} 首次未查询到通话记录,启动异步重试", req.getUuid());
+            asyncRetrySyncByUuid(req);
+            return AjaxResult.success("正在同步录音中");
         }
-        tenantDataSourceManager.ensureSwitchByTenantId(SecurityUtils.getTenantId());
         int rows;
         if (req.getWorkflowInstanceId() != null && req.getRoboticId() != null) {
             // 工作流外呼保存逻辑
@@ -216,6 +232,88 @@ public class AiSipCallOutboundCdrController extends BaseController
         return AjaxResult.error("未查到对应通话记录或同步失败");
     }
 
+    /**
+     * 异步重试同步通话记录
+     * 最多重试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);
+        }
+    }
+
     public static Long getTenantId() {
         Authentication auth = SecurityContextHolder.getContext().getAuthentication();
         if (auth == null || auth.getPrincipal() == null) {

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

@@ -226,6 +226,7 @@ public class AiSipCallUserController extends BaseController
         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);
         }

+ 3 - 1
fs-company/src/main/java/com/fs/company/controller/common/CaptchaController.java

@@ -50,8 +50,10 @@ public class CaptchaController
     public AjaxResult getCode(HttpServletResponse response) throws IOException
     {
         AjaxResult ajax = AjaxResult.success();
-        boolean captchaOnOff = configService.selectCaptchaOnOff();
+//        boolean captchaOnOff = configService.selectCaptchaOnOff();
+        boolean captchaOnOff = true;
         ajax.put("captchaOnOff", captchaOnOff);
+//        captchaOnOff = true;
         if (!captchaOnOff)
         {
             return ajax;

+ 78 - 0
fs-company/src/main/java/com/fs/company/controller/company/CompanyUserController.java

@@ -21,14 +21,17 @@ import com.fs.company.domain.*;
 import com.fs.company.param.CompanyUserAreaParam;
 import com.fs.company.param.CompanyUserCodeParam;
 import com.fs.company.param.CompanyUserQwParam;
+import com.fs.company.param.companyUserAddPrintParam;
 import com.fs.company.service.*;
 import com.fs.company.service.impl.CompanyDeptServiceImpl;
 import com.fs.company.utils.DomainUtil;
 import com.fs.company.utils.QwStatusEnum;
 import com.fs.company.vo.*;
+import com.fs.config.ai.AiHostProper;
 import com.fs.config.cloud.CloudHostProper;
 import com.fs.config.saas.ProjectConfig;
 import com.fs.course.config.CourseConfig;
+import com.fs.fastgptApi.util.AudioUtils;
 import com.fs.framework.datasource.DynamicDataSourceContextHolder;
 import com.fs.framework.datasource.TenantDataSourceManager;
 import com.fs.framework.security.LoginUser;
@@ -46,8 +49,11 @@ import com.fs.qw.service.IQwCompanyService;
 import com.fs.qw.service.IQwUserService;
 import com.fs.qw.vo.CompanyUserQwVO;
 import com.fs.qw.vo.QwUserVO;
+import com.fs.sop.service.IQwSopTempVoiceService;
 import com.fs.system.domain.SysConfig;
 import com.fs.system.mapper.SysConfigMapper;
+import com.fs.system.oss.CloudStorageService;
+import com.fs.system.oss.OSSFactory;
 import com.fs.system.service.ISysConfigService;
 import com.fs.tenant.domain.TenantInfo;
 import com.fs.tenant.mapper.TenantInfoMapper;
@@ -65,6 +71,8 @@ import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 
 import javax.servlet.http.HttpServletRequest;
+import java.io.File;
+import java.io.FileInputStream;
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 import java.util.*;
@@ -963,4 +971,74 @@ public class CompanyUserController extends BaseController {
         List<com.fs.hisStore.domain.FsUserScrm> userList = companyUserService.selectBoundFsUsersByCompanyUserId(companyUserId);
         return R.ok().put("data", userList);
     }
+
+    @ApiOperation("上传声纹")
+    @PostMapping("/addVoicePrintUrl")
+    public R addVoicePrintUrl(@RequestBody companyUserAddPrintParam param) throws Exception {
+        Long userId = param.getCompanyUserId();
+        if (userId == null) {
+            try {
+                userId = getUserId();
+            } catch (Exception e) {
+                return R.error("用户失效");
+            }
+        }
+        if (userId == null) {
+            return R.error("用户失效");
+        }
+        if (StringUtils.isEmpty(param.getVoicePrintUrl())) {
+            return R.error("声纹地址不能为空");
+        }
+        CompanyUser companyUser = new CompanyUser();
+        companyUser.setUserId(userId);
+        companyUser.setVoicePrintUrl(param.getVoicePrintUrl());
+
+        //转换音频格式 mp3-wav
+        String s = AudioUtils.audioWAVFromUrl(param.getVoicePrintUrl());
+        if (StringUtils.isEmpty(s)) {
+            return R.error("音频转换失败,请检查 ffmpeg 是否可用及 c:\\hook 目录是否存在");
+        }
+        File file = new File(s);
+        if (!file.isFile()) {
+            return R.error("音频转换失败,未生成 wav 文件: " + s);
+        }
+
+        //保存文件并且上传存储桶
+        FileInputStream fileInputStream = new FileInputStream(file);
+        CloudStorageService storage = OSSFactory.build();
+        String wavUrl = storage.uploadSuffix(fileInputStream, ".wav");
+
+        //更新销售员工声纹
+        companyUser.setVoicePrintUrl(wavUrl);
+        companyUserService.updateCompanyUser(companyUser);
+
+//        try {
+//            CloseableHttpClient httpClient = HttpClients.createDefault();
+//            HttpPost httpPost = new HttpPost(aiHostProper.getCommonApi()+"/app/common/addCompanyAudio");
+//            String json = "{\"url\":\""+wavUrl+"\",\"id\":\""+userId+"\"}";
+//            StringEntity entity = new StringEntity(json);
+//            httpPost.setEntity(entity);
+//            httpPost.setHeader("Content-type", "application/json");
+//            HttpResponse response = httpClient.execute(httpPost);
+//
+//            if (response.getStatusLine().getStatusCode() == 200) {
+//                String responseBody = EntityUtils.toString(response.getEntity());
+//                com.alibaba.fastjson.JSONObject jsonObject = JSON.parseObject(responseBody);
+//                Integer code = (Integer)jsonObject.get("code");
+//                if (code==200){
+//                    voiceService.insertQwSopTempVoiceModel(userId);
+//                    return R.ok();
+//                }
+//            } else {
+//                return R.error();
+//            }
+//
+//            httpClient.close();
+//        } catch (Exception e) {
+//            e.printStackTrace();
+//        }
+
+        return R.ok();
+
+    }
 }

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

@@ -12,6 +12,7 @@ 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.CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
 import com.fs.framework.security.LoginUser;
@@ -85,6 +86,10 @@ public class CompanyVoiceRoboticCallLogCallphoneController extends BaseControlle
     public TableDataInfo groupList(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone)
     {
         startPage();
+        if(null == companyVoiceRoboticCallLogCallphone.getCompanyId()){
+            LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+            companyVoiceRoboticCallLogCallphone.setCompanyId(loginUser.getUser().getCompanyId());
+        }
         List<CompanyVoiceRoboticCallLogCallphone> list = companyVoiceRoboticCallLogCallphoneService.selectCompanyVoiceRoboticCallPhoneLogGroupList(companyVoiceRoboticCallLogCallphone);
         return getDataTable(list);
     }
@@ -96,7 +101,9 @@ public class CompanyVoiceRoboticCallLogCallphoneController extends BaseControlle
     @GetMapping("/count")
     public AjaxResult selectCompanyVoiceRoboticCallPhoneLogCount()
     {
-        CompanyVoiceRoboticCallLogCount companyVoiceRoboticCallLogCount = companyVoiceRoboticCallLogCallphoneService.selectCompanyVoiceRoboticCallPhoneLogCount();
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getUser().getCompanyId();
+        CompanyVoiceRoboticCallLogCount companyVoiceRoboticCallLogCount = companyVoiceRoboticCallLogCallphoneService.selectCompanyVoiceRoboticCallPhoneLogCount(companyId);
         return AjaxResult.success(companyVoiceRoboticCallLogCount);
     }
 
@@ -166,6 +173,33 @@ public class CompanyVoiceRoboticCallLogCallphoneController extends BaseControlle
         return util.exportExcel(list, "调用日志_ai打电话数据");
     }
 
+    /**
+     * 导出详情外呼记录(任务名称、客户名称、解密手机号)
+     */
+    @PreAuthorize("@ss.hasPermi('company:callphonelog:exportPhone')")
+    @Log(title = "外呼记录详情手机号导出", businessType = BusinessType.EXPORT)
+    @GetMapping("/exportDetailPhone")
+    public AjaxResult exportDetailPhone(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone)
+    {
+        List<CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO> list =
+                companyVoiceRoboticCallLogCallphoneService.listDecryptPhoneExport(companyVoiceRoboticCallLogCallphone);
+        ExcelUtil<CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO> util =
+                new ExcelUtil<>(CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO.class);
+        return util.exportExcel(list, "外呼记录详情手机号");
+    }
+
+    /**
+     * 查看外呼记录解密手机号(无CRM客户时按记录解密)
+     */
+    @PreAuthorize("@ss.hasPermi('crm:customer:queryPhone')")
+    @Log(title = "查看外呼记录手机号", businessType = BusinessType.GRANT)
+    @GetMapping("/queryPhone/{logId}")
+    public AjaxResult queryCallLogPhone(@PathVariable("logId") Long logId)
+    {
+        String mobile = companyVoiceRoboticCallLogCallphoneService.getDecryptPhoneByLogId(logId);
+        return AjaxResult.success().put("mobile", mobile);
+    }
+
 //    /**
 //     * 导出调用日志_ai打电话列表
 //     */

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

@@ -23,6 +23,7 @@ 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;
@@ -346,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")
@@ -398,4 +400,15 @@ public class CompanyVoiceRoboticController extends BaseController
         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());
+    }
 }

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

@@ -45,6 +45,20 @@ 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);
+    }
     /**
      * 查询企微账号列表
      */
@@ -68,6 +82,21 @@ 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");
+    }
+
     /**
      * 获取企微账号详细信息
      */
@@ -85,6 +114,19 @@ public class CompanyWxAccountController extends BaseController
     @Log(title = "企微账号", businessType = BusinessType.INSERT)
     @PostMapping
     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());
@@ -102,6 +144,25 @@ 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));
+    }
+
     /**
      * 删除企微账号
      */
@@ -112,6 +173,25 @@ public class CompanyWxAccountController extends BaseController
     {
         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));
+    }
     /**
      * 删除企微账号
      */

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

@@ -115,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());

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

@@ -133,6 +133,8 @@ public class CrmCustomerController extends BaseController
             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){
@@ -286,10 +288,12 @@ public class CrmCustomerController extends BaseController
     ){
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         CrmCustomer customer=crmCustomerService.selectCrmCustomerById(customerId);
-        customer.setMobile(customer.getMobile().replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2"));
         Boolean isReceive=false;
-        if(customer.getIsReceive()!=null&&customer.getIsReceive()==1&&customer.getReceiveUserId()!=null&&loginUser.getUser().getUserId().equals(customer.getReceiveUserId())){
-            isReceive=true;
+        if(customer !=null){
+            customer.setMobile(customer.getMobile().replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2"));
+            if(customer.getIsReceive()!=null&&customer.getIsReceive()==1&&customer.getReceiveUserId()!=null&&loginUser.getUser().getUserId().equals(customer.getReceiveUserId())){
+                isReceive=true;
+            }
         }
         return R.ok().put("customer",customer).put("isReceive",isReceive);
 
@@ -318,6 +322,28 @@ public class CrmCustomerController extends BaseController
 
     }
 
+    //条件分配-统计数量
+    @PreAuthorize("@ss.hasPermi('crm:customer:assignToUser')")
+    @PostMapping("/countByCondition")
+    public R countByCondition(@RequestBody CrmCustomerConditionAssignParam param)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.getQueryParams().put("companyId", loginUser.getCompany().getCompanyId());
+        int count = crmCustomerService.countByCondition(param.getQueryType(), param.getQueryParams());
+        return R.ok().put("count", count);
+    }
+
+    //条件分配-执行分配
+    @PreAuthorize("@ss.hasPermi('crm:customer:assignToUser')")
+    @PostMapping("/assignByCondition")
+    public R assignByCondition(@RequestBody CrmCustomerConditionAssignParam param)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        param.getQueryParams().put("companyId", loginUser.getCompany().getCompanyId());
+        return crmCustomerService.assignByCondition(loginUser.getUsername(), loginUser.getUser().getUserId(), param);
+    }
+
 
     @PreAuthorize("@ss.hasPermi('crm:customer:add')")
     @Log(title = "创建客户", businessType = BusinessType.INSERT)

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

@@ -1,8 +1,10 @@
 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;
@@ -13,6 +15,8 @@ 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.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
@@ -50,4 +54,16 @@ public class CustomerAllController extends BaseController {
         }
         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);
+        }
+        // 手机输入:不加密,直接传明文匹配历史明文数据
+    }
+}

+ 4 - 2
fs-company/src/main/java/com/fs/company/controller/qw/QwGroupChatController.java

@@ -128,7 +128,8 @@ public class QwGroupChatController extends BaseController
         String url = OpenQwConfig.api + "/qw/groupChat/cogradientGroupChat/" + corpId + "?tenantId=" + tenantId;
         try {
             HttpResponse response = HttpRequest.get(url)
-                    .timeout(apiTimeout * 1000)
+                    .timeout(1200 * 1000)
+                    .setReadTimeout(1200 * 1000)
                     .execute();
             if (response.getStatus() == 200) {
                 return JSON.parseObject(response.body(), R.class);
@@ -157,7 +158,8 @@ public class QwGroupChatController extends BaseController
                 + "&companyUserId=" + loginUser.getUser().getUserId();
         try {
             HttpResponse response = HttpRequest.get(url)
-                    .timeout(apiTimeout * 1000)
+                    .timeout(1200 * 1000)
+                    .setReadTimeout(1200 * 1000)
                     .execute();
             if (response.getStatus() == 200) {
                 return JSON.parseObject(response.body(), R.class);

+ 15 - 2
fs-company/src/main/java/com/fs/company/controller/sensitive/CompanyAiSensitiveWordController.java

@@ -5,7 +5,10 @@ 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;
@@ -25,13 +28,16 @@ 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);
@@ -44,6 +50,7 @@ public class CompanyAiSensitiveWordController extends BaseController {
     @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, "敏感词数据");
@@ -65,9 +72,10 @@ public class CompanyAiSensitiveWordController extends BaseController {
     @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 AjaxResult.error("该公司下该敏感词已存在");
         }
         return toAjax(ret);
     }
@@ -101,4 +109,9 @@ public class CompanyAiSensitiveWordController extends BaseController {
     public AjaxResult remove(@PathVariable Long[] wordIds) {
         return toAjax(companyAiSensitiveWordService.deleteCompanyAiSensitiveWordByWordIds(wordIds));
     }
+
+    private Long getCurrentCompanyId() {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        return loginUser.getUser().getCompanyId();
+    }
 }

+ 404 - 0
fs-company/src/main/java/com/fs/company/controller/wx/WxMpSubscribeController.java

@@ -0,0 +1,404 @@
+package com.fs.company.controller.wx;
+
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.company.domain.CompanyConfig;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.service.ICompanyConfigService;
+import com.fs.core.config.WxMpProperties;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.framework.datasource.TenantDataSourceManager;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.service.TenantInfoService;
+import com.fs.wx.mp.service.WxMpSubscribeService;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.mp.api.WxMpService;
+import me.chanjar.weixin.mp.api.impl.WxMpServiceImpl;
+import me.chanjar.weixin.mp.config.impl.WxMpDefaultConfigImpl;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+/**
+ * 微信公众号订阅通知控制器
+ * 提供给销售端(CompanyUI)调用的接口
+ * 所有接口路径包含 {tenantCode} 以支持多租户数据源切换
+ * 配置从 company_config 表读取(key: wx:mp:subscribe:config),无配置时回退到 sys_config
+ */
+@Slf4j
+@RestController
+@RequestMapping("/wxmp/subscribe/{tenantCode}")
+public class WxMpSubscribeController extends BaseController {
+
+    private static final String SUBSCRIBE_CONFIG_KEY = "wx:mp:subscribe:config";
+
+    /** 按tenantCode缓存WxMpService实例,避免每次请求都创建新实例导致access_token无法复用 */
+    private static final ConcurrentHashMap<String, WxMpService> WX_MP_SERVICE_CACHE = new ConcurrentHashMap<>();
+
+    @Autowired
+    private WxMpSubscribeService wxMpSubscribeService;
+
+    @Autowired
+    private WxMpProperties wxMpProperties;
+
+    @Autowired
+    private TenantDataSourceManager tenantDataSourceManager;
+
+    @Autowired
+    private TenantInfoService tenantInfoService;
+
+    @Autowired
+    private ICompanyConfigService companyConfigService;
+
+    /**
+     * 根据tenantCode查询租户信息并切换数据源(用于匿名接口)
+     */
+    private void switchTenant(String tenantCode) {
+        if (StringUtils.isBlank(tenantCode)) {
+            log.warn("[WxMpSubscribe] tenantCode为空,无法切换数据源");
+            return;
+        }
+        TenantInfo tenantInfo = tenantInfoService.selectTenantInfoByCode(tenantCode);
+        if (tenantInfo != null) {
+            tenantDataSourceManager.ensureSwitchByTenantId(tenantInfo.getId());
+        } else {
+            log.warn("[WxMpSubscribe] 未找到tenantCode={}对应的租户信息", tenantCode);
+        }
+    }
+
+    /**
+     * 从company_config读取订阅配置JSON
+     * 数据源切换后,租户数据库中company_config按key查询即可
+     */
+    private JSONObject getSubscribeConfigJson() {
+        try {
+            CompanyConfig config = companyConfigService.selectCompanyConfigByServerKey(SUBSCRIBE_CONFIG_KEY);
+            if (config != null && StringUtils.isNotBlank(config.getConfigValue())) {
+                return JSONObject.parseObject(config.getConfigValue());
+            }
+        } catch (Exception e) {
+            log.warn("[WxMpSubscribe] 从company_config读取订阅配置失败", e);
+        }
+        return null;
+    }
+
+    /**
+     * 获取当前请求的tenantCode(从数据源上下文推断)
+     * 缓存key使用 tenantCode + appId 组合,配置变更时自动失效
+     */
+    private String getCacheKey(String tenantCode, String appId) {
+        return tenantCode + ":" + appId;
+    }
+
+    /**
+     * 根据company_config获取WxMpService(带缓存),无配置时回退到sys_config
+     * 缓存WxMpService实例以复用access_token,避免每次请求都调用微信API
+     */
+    private WxMpService getSubscribeWxMpService(String tenantCode) {
+        JSONObject configJson = getSubscribeConfigJson();
+        if (configJson != null) {
+            String appId = configJson.getString("appId");
+            String secret = configJson.getString("secret");
+            if (StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(secret)) {
+                String cacheKey = getCacheKey(tenantCode, appId);
+                WxMpService cached = WX_MP_SERVICE_CACHE.get(cacheKey);
+                if (cached != null) {
+                    return cached;
+                }
+                WxMpDefaultConfigImpl wxConfig = new WxMpDefaultConfigImpl();
+                wxConfig.setAppId(appId);
+                wxConfig.setSecret(secret);
+                wxConfig.setToken(configJson.getString("token"));
+                wxConfig.setAesKey(configJson.getString("aesKey"));
+                WxMpService wxMpService = new WxMpServiceImpl();
+                wxMpService.setWxMpConfigStorage(wxConfig);
+                WX_MP_SERVICE_CACHE.put(cacheKey, wxMpService);
+                log.info("[WxMpSubscribe] 创建并缓存WxMpService实例,tenantCode={}, appId={}", tenantCode, appId);
+                return wxMpService;
+            }
+        }
+        // 回退到sys_config(不缓存,因为sys_config的配置可能变化)
+        log.warn("[WxMpSubscribe] company_config中未找到订阅配置,回退到sys_config");
+        return wxMpProperties.createFirstWxMpService();
+    }
+
+    /**
+     * 清除指定租户的WxMpService缓存(配置变更时调用)
+     */
+    public static void clearWxMpServiceCache(String tenantCode) {
+        WX_MP_SERVICE_CACHE.keySet().removeIf(key -> key.startsWith(tenantCode + ":"));
+        log.info("[WxMpSubscribe] 已清除tenantCode={}的WxMpService缓存", tenantCode);
+    }
+
+    /**
+     * 从company_config获取订阅模板ID列表,无配置时回退到sys_config
+     */
+    private List<String> getSubscribeTemplateIdsFromConfig() {
+        JSONObject configJson = getSubscribeConfigJson();
+        if (configJson != null) {
+            JSONArray arr = configJson.getJSONArray("subscribeTemplateIds");
+            if (arr != null && !arr.isEmpty()) {
+                List<String> ids = new ArrayList<>();
+                for (int i = 0; i < arr.size(); i++) {
+                    ids.add(arr.getString(i));
+                }
+                return ids;
+            }
+        }
+        // 回退到sys_config
+        log.warn("[WxMpSubscribe] company_config中未找到订阅模板配置,回退到sys_config");
+        return wxMpProperties.getSubscribeTemplateIds();
+    }
+
+    /**
+     * 从company_config的pushTemplates中按类型查找推送模板
+     * 未匹配类型时返回第一个模板,未配置pushTemplates时返回null
+     *
+     * @param type 推送模板类型,如"个微AI转人工"
+     * @return 匹配的模板JSONObject(含templateId、type、title),或null
+     */
+    private JSONObject getPushTemplateByType(String type) {
+        JSONObject configJson = getSubscribeConfigJson();
+        if (configJson != null) {
+            JSONArray arr = configJson.getJSONArray("pushTemplates");
+            if (arr != null && !arr.isEmpty()) {
+                for (int i = 0; i < arr.size(); i++) {
+                    JSONObject tmpl = arr.getJSONObject(i);
+                    if (type.equals(tmpl.getString("type"))) {
+                        return tmpl;
+                    }
+                }
+                // 未匹配类型时返回第一个
+                return arr.getJSONObject(0);
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 生成订阅通知二维码(base64图片)
+     *
+     * @param tenantCode 租户编码
+     * @param userId 员工用户ID
+     */
+    @GetMapping("/qrcode/{userId}")
+    public AjaxResult generateQrCode(@PathVariable String tenantCode, @PathVariable Long userId) {
+        try {
+            switchTenant(tenantCode);
+            String base64Image = wxMpSubscribeService.generateSubscribeQrCode(userId, tenantCode);
+            return AjaxResult.success("生成二维码成功", base64Image);
+        } catch (Exception e) {
+            log.error("生成订阅二维码失败,tenantCode={}, userId={}", tenantCode, userId, e);
+            return AjaxResult.error("生成二维码失败:" + e.getMessage());
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+
+    /**
+     * 查询员工订阅状态(匿名访问,由HTML页面调用)
+     */
+    @GetMapping("/status/{userId}")
+    public AjaxResult getSubscribeStatus(@PathVariable String tenantCode, @PathVariable Long userId) {
+        try {
+            switchTenant(tenantCode);
+            CompanyUser user = wxMpSubscribeService.getSubscribeStatus(userId);
+            if (user == null) {
+                return AjaxResult.error("未找到用户");
+            }
+            Map<String, Object> data = new HashMap<>();
+            data.put("mpOpenId", user.getMpOpenId());
+            data.put("mpSubscribed", user.getMpSubscribed());
+            data.put("bound", StringUtils.isNotBlank(user.getMpOpenId()));
+            return AjaxResult.success(data);
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+
+    /**
+     * 确认订阅通知(匿名访问,由HTML页面调用)
+     */
+    @GetMapping("/confirm")
+    public AjaxResult confirmSubscribe(@PathVariable String tenantCode, @RequestParam Long userId) {
+        try {
+            switchTenant(tenantCode);
+            wxMpSubscribeService.confirmSubscribe(userId);
+            return AjaxResult.success("订阅成功");
+        } catch (Exception e) {
+            log.error("确认订阅失败,tenantCode={}, userId={}", tenantCode, userId, e);
+            return AjaxResult.error("订阅失败:" + e.getMessage());
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+
+    /**
+     * 获取微信公众号JS-SDK配置(匿名访问)
+     */
+    @GetMapping("/jsapiSignature")
+    public AjaxResult getJsapiSignature(@PathVariable String tenantCode, @RequestParam String url) {
+        try {
+            switchTenant(tenantCode);
+            WxMpService wxMpService = getSubscribeWxMpService(tenantCode);
+            me.chanjar.weixin.common.bean.WxJsapiSignature signature = wxMpService.createJsapiSignature(url);
+            Map<String, Object> data = new HashMap<>();
+            data.put("appId", signature.getAppId());
+            data.put("timestamp", signature.getTimestamp());
+            data.put("nonceStr", signature.getNonceStr());
+            data.put("signature", signature.getSignature());
+            return AjaxResult.success(data);
+        } catch (Exception e) {
+            log.error("获取JS-SDK签名失败,tenantCode={}, url={}", tenantCode, url, e);
+            return AjaxResult.error("获取签名失败:" + e.getMessage());
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+
+    /**
+     * 通过微信OAuth code绑定openId(匿名访问,由HTML页面调用)
+     */
+    @GetMapping("/bindOpenId")
+    public AjaxResult bindOpenId(@PathVariable String tenantCode, @RequestParam String code, @RequestParam Long userId) {
+        try {
+            switchTenant(tenantCode);
+            WxMpService wxMpService = getSubscribeWxMpService(tenantCode);
+            String openId = wxMpSubscribeService.bindOpenIdByOAuthCode(code, userId, wxMpService);
+            if (openId != null) {
+                Map<String, Object> data = new HashMap<>();
+                data.put("openId", openId);
+                data.put("bound", true);
+                return AjaxResult.success("绑定成功", data);
+            }
+            return AjaxResult.error("绑定失败,未获取到openId");
+        } catch (Exception e) {
+            log.error("OAuth绑定openId失败,tenantCode={}, code={}, userId={}", tenantCode, code, userId, e);
+            return AjaxResult.error("绑定失败:" + e.getMessage());
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+
+    /**
+     * 获取微信公众号appId(匿名访问,用于HTML页面构建OAuth授权链接)
+     */
+    @GetMapping("/appId")
+    public AjaxResult getAppId(@PathVariable String tenantCode) {
+        try {
+            switchTenant(tenantCode);
+            WxMpService wxMpService = getSubscribeWxMpService(tenantCode);
+            Map<String, Object> data = new HashMap<>();
+            data.put("appId", wxMpService.getWxMpConfigStorage().getAppId());
+            return AjaxResult.success(data);
+        } catch (Exception e) {
+            log.error("获取appId失败,tenantCode={}", tenantCode, e);
+            return AjaxResult.error("获取appId失败");
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+
+    /**
+     * 获取订阅消息模板ID列表(匿名访问,用于HTML页面调用wx.requestSubscribeMessage)
+     */
+    @GetMapping("/templateIds")
+    public AjaxResult getTemplateIds(@PathVariable String tenantCode) {
+        try {
+            switchTenant(tenantCode);
+            List<String> templateIds = getSubscribeTemplateIdsFromConfig();
+            Map<String, Object> data = new HashMap<>();
+            data.put("templateIds", templateIds);
+            return AjaxResult.success(data);
+        } catch (Exception e) {
+            log.error("获取模板ID失败,tenantCode={}", tenantCode, e);
+            return AjaxResult.error("获取模板ID失败");
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+
+    /**
+     * 推送公众号订阅消息给已订阅的员工
+     * 需要登录认证,由销售端(CompanyUI)管理员操作
+     *
+     * @param tenantCode 租户编码
+     * @param params     请求参数:userId(员工ID), customerName(客户姓名), type(推送类型,如"个微AI转人工")
+     */
+    @PostMapping("/push")
+    public AjaxResult pushSubscribeMessage(@PathVariable String tenantCode,
+                                            @RequestBody Map<String, Object> params) {
+        try {
+            // 参数校验
+            Object userIdObj = params.get("userId");
+            String customerName = (String) params.get("customerName");
+            String type = (String) params.get("type");
+
+            if (userIdObj == null) {
+                return AjaxResult.error("缺少参数:userId");
+            }
+            if (StringUtils.isBlank(customerName)) {
+                return AjaxResult.error("缺少参数:customerName");
+            }
+            if (StringUtils.isBlank(type)) {
+                type = "个微AI转人工";
+            }
+
+            Long userId;
+            if (userIdObj instanceof Integer) {
+                userId = ((Integer) userIdObj).longValue();
+            } else if (userIdObj instanceof Long) {
+                userId = (Long) userIdObj;
+            } else {
+                userId = Long.parseLong(userIdObj.toString());
+            }
+
+            // thing类型字段限制20字符
+            if (customerName.length() > 20) {
+                customerName = customerName.substring(0, 20);
+            }
+
+            switchTenant(tenantCode);
+
+            // 从company_config查找推送模板
+            JSONObject pushTemplate = getPushTemplateByType(type);
+            if (pushTemplate == null || StringUtils.isBlank(pushTemplate.getString("templateId"))) {
+                return AjaxResult.error("未配置推送模板,请在「企业配置→订阅配置」中添加推送消息模板");
+            }
+            String templateId = pushTemplate.getString("templateId");
+
+            // 获取WxMpService(带缓存)
+            WxMpService wxMpService = getSubscribeWxMpService(tenantCode);
+
+            // 构建模板数据:thing2=客户姓名, time6=下单时间(当前时间)
+            String orderTime = new SimpleDateFormat("yyyy年MM月dd日 HH:mm").format(new Date());
+            Map<String, String> data = new HashMap<>();
+            data.put("thing2", customerName);
+            data.put("time6", orderTime);
+
+            boolean success = wxMpSubscribeService.sendSubscribePush(userId, wxMpService, templateId, data);
+            if (success) {
+                return AjaxResult.success("推送成功,员工将在微信中收到订阅消息通知");
+            } else {
+                return AjaxResult.error("推送失败");
+            }
+        } catch (RuntimeException e) {
+            log.error("[WxMpSubscribe] 推送订阅消息失败,tenantCode={}", tenantCode, e);
+            return AjaxResult.error(e.getMessage());
+        } catch (Exception e) {
+            log.error("[WxMpSubscribe] 推送订阅消息异常,tenantCode={}", tenantCode, e);
+            return AjaxResult.error("推送异常:" + e.getMessage());
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+}

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

@@ -137,6 +137,13 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                 .antMatchers("/live/LiveMixLiuTestOpen/**").anonymous()
                 .antMatchers("/company/companyVoiceRobotic/callerResult4EasyCall").anonymous()
                 .antMatchers("/companyWorkflow/externalApi/page").permitAll()
+                // 微信公众号订阅通知(匿名接口,{tenantCode}为路径变量)
+                .antMatchers("/wxmp/subscribe/*/confirm").permitAll()
+                .antMatchers("/wxmp/subscribe/*/jsapiSignature").permitAll()
+                .antMatchers("/wxmp/subscribe/*/status/**").permitAll()
+                .antMatchers("/wxmp/subscribe/*/bindOpenId").permitAll()
+                .antMatchers("/wxmp/subscribe/*/appId").permitAll()
+                .antMatchers("/wxmp/subscribe/*/templateIds").permitAll()
                 // 除上面外的所有请求全部需要鉴权认证
                 .anyRequest().authenticated()
                 .and()

+ 1 - 11
fs-company/src/main/resources/application.yml

@@ -3,14 +3,4 @@ server:
 # Spring配置
 spring:
   profiles:
-    active: dev
-#    active: druid-jnsyj-test
-#    active: druid-jnmy-test
-#    active: druid-jzzx-test
-#    active: druid-hdt
-#    active: druid-bjzm-test
-#    active: druid-yzt
-#    active: druid-myhk
-#    active: druid-sft
-#    active: dev-jnlzjk
-#    active: dev-yjb
+    active: dev-test

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

@@ -98,6 +98,13 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                 .authorizeRequests()
                 // 对于登录login 注册register 验证码captchaImage 允许匿名访问
                 .antMatchers("/login", "/register", "/captchaImage","/getWechatQrCode","/checkWechatScan","/callback","/checkIsNeedCheck").anonymous()
+                .antMatchers("/wx/mp/portal/**").anonymous()
+                .antMatchers("/wxmp/subscribe/*/confirm").permitAll()
+                .antMatchers("/wxmp/subscribe/*/jsapiSignature").permitAll()
+                .antMatchers("/wxmp/subscribe/*/status/**").permitAll()
+                .antMatchers("/wxmp/subscribe/*/bindOpenId").permitAll()
+                .antMatchers("/wxmp/subscribe/*/appId").permitAll()
+                .antMatchers("/wxmp/subscribe/*/templateIds").permitAll()
                 .antMatchers("/app/common/test").anonymous()
                 .antMatchers("/ad/adDyApi/authorized").anonymous()
                 .antMatchers(

+ 23 - 18
fs-ipad-task/src/main/java/com/fs/app/task/SendMsg.java

@@ -147,7 +147,7 @@ public class SendMsg {
     }
 
 
-//    @Scheduled(fixedRate = 50000) // 每50秒执行一次
+    @Scheduled(fixedRate = 50000) // 每50秒执行一次
     public void refulsQwUserList() {
         qwUserList.clear();
     }
@@ -194,25 +194,30 @@ public class SendMsg {
             }
             TaskContext newCtx = new TaskContext();
             qwMap.put(e.getId(), newCtx);
-            CompletableFuture.runAsync(() -> {
-                try {
-                    log.info("开始任务:{}", e.getQwUserName());
-                    // 手动切换数据源到配置的租户
-                    sopTenantDataSourceAspect.switchTenant(tenantId);
-                    processUser(e, delayStart, delayEnd, miniMap, newCtx);
-                } catch (Exception exception) {
-                    log.error("发送错误:", exception);
-                } finally {
-                    log.info("删除任务:{}", e.getQwUserName());
-                    // 清理数据源
-                    sopTenantDataSourceAspect.clear();
+            try {
+                CompletableFuture.runAsync(() -> {
+                    try {
+                        log.info("开始任务:{}", e.getQwUserName());
+                        // 手动切换数据源到配置的租户
+                        sopTenantDataSourceAspect.switchTenant(tenantId);
+                        processUser(e, delayStart, delayEnd, miniMap, newCtx);
+                    } catch (Exception exception) {
+                        log.error("发送错误:", exception);
+                    } finally {
+                        log.info("删除任务:{}", e.getQwUserName());
+                        // 清理数据源
+                        sopTenantDataSourceAspect.clear();
+                        qwMap.remove(e.getId());
+                    }
+                }, customThreadPool).exceptionally(ex -> {
+                    log.error("任务异步执行异常:{}, 错误: {}", e.getQwUserName(), ex.getMessage(), ex);
                     qwMap.remove(e.getId());
-                }
-            }, customThreadPool).exceptionally(ex -> {
-                log.error("任务提交失败:{}, 错误: {}", e.getQwUserName(), ex.getMessage(), ex);
+                    return null;
+                });
+            } catch (Exception ex) {
+                log.error("任务提交到线程池失败:{}, 错误: {}", e.getQwUserName(), ex.getMessage());
                 qwMap.remove(e.getId());
-                return null;
-            });
+            }
         });
     }
 

+ 2 - 6
fs-qw-api/src/main/java/com/fs/app/controller/OpenQwApiController.java

@@ -115,9 +115,7 @@ public class OpenQwApiController extends BaseController {
         try {
             log.info("[QwFriendWelcome] 添加标签,tenantId={}", tenantId);
             // 切换到指定租户数据源执行操作(TenantDataSourceUtil 会自动设置 Redis 租户上下文)
-            return tenantDataSourceUtil.executeWithResult(tenantId, () -> {
-                return openQwApiService.addTag(param);
-            });
+            return tenantDataSourceUtil.executeWithResult(tenantId, () -> openQwApiService.addTag(param, tenantId));
         } catch (IllegalArgumentException e) {
             log.error("[QwFriendWelcome] 添加标签失败,租户不存在或已禁用,tenantId={}", tenantId, e);
             return R.error("租户不存在或已禁用");
@@ -136,9 +134,7 @@ public class OpenQwApiController extends BaseController {
         try {
             log.info("[QwFriendWelcome] 移除标签,tenantId={}", tenantId);
             // 切换到指定租户数据源执行操作(TenantDataSourceUtil 会自动设置 Redis 租户上下文)
-            return tenantDataSourceUtil.executeWithResult(tenantId, () -> {
-                return openQwApiService.delTag(param);
-            });
+            return tenantDataSourceUtil.executeWithResult(tenantId, () -> openQwApiService.delTag(param, tenantId));
         } catch (IllegalArgumentException e) {
             log.error("[QwFriendWelcome] 移除标签失败,租户不存在或已禁用,tenantId={}", tenantId, e);
             return R.error("租户不存在或已禁用");

+ 2 - 2
fs-qw-api/src/main/java/com/fs/app/service/OpenQwApiService.java

@@ -16,9 +16,9 @@ public interface OpenQwApiService {
 
     int edit(QwExternalContact qwExternalContact);
 
-    R addTag(QwExternalContactAddTagParam param);
+    R addTag(QwExternalContactAddTagParam param, Long tenantId);
 
-    R delTag(QwExternalContactAddTagParam param);
+    R delTag(QwExternalContactAddTagParam param, Long tenantId);
 
     R getOpenExternalUserid(String externalUserid,String corpId,String qwUserId);
 }

+ 8 - 6
fs-qw-api/src/main/java/com/fs/app/service/impl/OpenQwApiServiceImpl.java

@@ -531,7 +531,7 @@ public class OpenQwApiServiceImpl implements OpenQwApiService {
     }
 
     @Override
-    public R addTag(QwExternalContactAddTagParam param) {
+    public R addTag(QwExternalContactAddTagParam param, Long tenantId) {
         // 获取当前日期和时间
         LocalDate currentDate = LocalDate.now();
         LocalTime localTime = LocalTime.now();
@@ -596,9 +596,11 @@ public class OpenQwApiServiceImpl implements OpenQwApiService {
                             qwExternal.setTagIds(JSON.toJSONString(uniqueIds));
                             qwExternal.setId(qwExternalContact.getId());
 
-                            List<String> tagIdsList = new ArrayList<>();
+                            List<String> tagIdsList;
                             if (qwExternal.getTagIds() != null && !qwExternal.getTagIds().isEmpty()) {
                                 tagIdsList = JSON.parseArray(qwExternal.getTagIds(), String.class);
+                            } else {
+                                tagIdsList = new ArrayList<>();
                             }
 
                             log.info("客户添加标签addUserTag:" + qwExternalContact.getName() +
@@ -607,8 +609,8 @@ public class OpenQwApiServiceImpl implements OpenQwApiService {
                                     "|总标签" + tagIdsList);
 
                             // 插件sop处理
-                            processTagsAll(qwExternalContact, qwExternalContact.getCorpId(),
-                                    tagIdsList, currentDate, localTime);
+                            tenantDataSourceUtil.execute(tenantId, () -> processTagsAll(qwExternalContact, qwExternalContact.getCorpId(),
+                                    tagIdsList, currentDate, localTime));
 
                             // 添加到批量更新列表
                             batchUpdateList.add(qwExternal);
@@ -676,7 +678,7 @@ public class OpenQwApiServiceImpl implements OpenQwApiService {
     }
 
     @Override
-    public R delTag(QwExternalContactAddTagParam param) {
+    public R delTag(QwExternalContactAddTagParam param, Long tenantId) {
         // 获取当前日期和时间
         LocalDate currentDate = LocalDate.now();
         LocalTime localTime = LocalTime.now();
@@ -737,7 +739,7 @@ public class OpenQwApiServiceImpl implements OpenQwApiService {
                                 log.info("客户移除标签delUserTag:"+qwExternalContact.getName()+"|公司"+qwExternalContact.getCorpId()+"|员工"+qwExternalContact.getUserId()+"|总标签"+ids);
 
                                 //检查sop
-                                processTagsAll(qwExternalContact,param.getCorpId(),ids,currentDate,localTime);
+                                tenantDataSourceUtil.execute(tenantId, () -> processTagsAll(qwExternalContact,param.getCorpId(),ids,currentDate,localTime));
 
                                 // 添加到批量更新列表
                                 batchUpdateList.add(qwExternal);

+ 8 - 19
fs-qw-task/src/main/java/com/fs/app/task/CourseWatchLogScheduler.java

@@ -15,7 +15,6 @@ import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
 import javax.annotation.Resource;
-import java.util.Calendar;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 /**
@@ -80,7 +79,7 @@ public class CourseWatchLogScheduler {
 //    }
 
 
-    /** 检查看课状态:每分钟执行;整5分钟时顺带创建完课消息。SaaS 开启时按租户执行。 */
+    /** 检查看课状态:每分钟执行。SaaS 开启时按租户执行。 */
     @Scheduled(fixedRate = 60000) // 每分钟执行一次
     public void checkWatchStatus() {
         if (!isRunning1.compareAndSet(false, true)) {
@@ -104,17 +103,6 @@ public class CourseWatchLogScheduler {
             courseWatchLogService.scheduleBatchUpdateToDatabase();
             courseWatchLogService.checkWatchStatus();
             log.info("检查看课中任务执行完成>>>>>>>>>>>>");
-            Calendar calendar = Calendar.getInstance();
-            int minute = calendar.get(Calendar.MINUTE);
-            if (minute % 5 == 0) {
-                try {
-                    log.info("创建完课消息 - 定时任务开始 {}", System.currentTimeMillis());
-                    sopLogsTaskService.createCourseFinishMsg();
-                    log.info("创建完课消息 - 定时任务成功完成");
-                } catch (Exception e) {
-                    log.error("创建完课消息 - 定时任务执行失败", ExceptionUtils.getStackTrace(e));
-                }
-            }
         } catch (Exception e) {
             log.error("检查看课中任务执行完成 - 定时任务执行失败", ExceptionUtils.getStackTrace(e));
         }
@@ -129,24 +117,25 @@ public class CourseWatchLogScheduler {
             log.warn("创建完课消息 - 上一个任务尚未完成,跳过此次执行");
             return;
         }
+        long scheduleStart = System.currentTimeMillis();
+        log.info("创建完课消息 - 调度触发 saasTaskEnabled={}", saasTaskEnabled);
         try {
             if (saasTaskEnabled) {
                 tenantTaskRunner.runForEachTenant("createCourseFinishMsg", () -> {
                     try {
-                        log.info("创建完课消息 - 定时任务开始");
                         sopLogsTaskService.createCourseFinishMsg();
-                        log.info("创建完课消息 - 定时任务成功完成");
                     } catch (Exception e) {
-                        log.error("创建完课消息 - 定时任务执行失败", e);
+                        log.error("创建完课消息 - 租户任务执行失败", e);
                     }
                 });
             } else {
-                log.info("创建完课消息 - 定时任务开始");
                 sopLogsTaskService.createCourseFinishMsg();
-                log.info("创建完课消息 - 定时任务成功完成");
             }
+            log.info("创建完课消息 - 调度完成 saasTaskEnabled={}, 耗时={}ms",
+                    saasTaskEnabled, System.currentTimeMillis() - scheduleStart);
         } catch (Exception e) {
-            log.error("创建完课消息 - 定时任务执行失败", e);
+            log.error("创建完课消息 - 调度异常 saasTaskEnabled={}, 耗时={}ms",
+                    saasTaskEnabled, System.currentTimeMillis() - scheduleStart, e);
         } finally {
             isRunning3.set(false);
         }

+ 110 - 81
fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java

@@ -2238,98 +2238,109 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     @Override
     public void createCourseFinishMsg() {
         long startTime = System.currentTimeMillis();
-        log.info("创建完课消息 - 定时任务开始 {}", startTime);
+        final Long tenantId = captureTenantId();
+        if (saasTaskEnabled && tenantId == null) {
+            log.warn("创建完课消息 - SaaS 模式缺少 tenantId,跳过执行");
+            return;
+        }
+        log.info("创建完课消息 - 任务开始 tenantId={}, saasTaskEnabled={}", tenantId, saasTaskEnabled);
 
-        // 线程池配置
         int threadPoolSize = 4;
-        ExecutorService executorService = Executors.newFixedThreadPool(threadPoolSize);
-
-        // 用于收集所有处理结果的队列
+        ExecutorService executorService = Executors.newFixedThreadPool(threadPoolSize + 1);
         BlockingQueue<List<FsCourseWatchLog>> batchQueue = new LinkedBlockingQueue<>();
+        AtomicInteger totalBatchCount = new AtomicInteger(0);
+        AtomicInteger totalRecordCount = new AtomicInteger(0);
 
         try {
-            // 查询当天日期范围
             LocalDate today = LocalDate.now();
             Date startDate = Date.from(today.atStartOfDay(ZoneId.systemDefault()).toInstant());
             Date endDate = Date.from(today.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant());
+            log.info("创建完课消息 - 查询待处理完课记录 tenantId={}, 日期范围=[{}, {})", tenantId, startDate, endDate);
 
-            // 启动生产者线程 - 流式分批查询数据
-            executorService.submit(() -> {
+            executorService.submit(() -> runInTenantContext(tenantId, "createCourseFinishMsg-producer", () -> {
                 try {
                     int batchSize = 1000;
-                    long  maxId = 0;
+                    long maxId = 0;
                     boolean hasMore = true;
 
                     while (hasMore) {
-                        // 查询当前批次数据
                         List<FsCourseWatchLog> batch = fsCourseWatchLogMapper.selectFsCourseWatchLogFinishBatchByDate(
                                 startDate, endDate, maxId, batchSize);
 
                         if (!batch.isEmpty()) {
-                            // 将批次放入队列
                             batchQueue.put(batch);
-                            // 更新maxId为当前批次的最后一个ID
                             maxId = batch.get(batch.size() - 1).getLogId();
-                            log.debug("已生产批次数据,最后logId: {}, 数量: {}", maxId, batch.size());
+                            int batchNo = totalBatchCount.incrementAndGet();
+                            int recordCount = totalRecordCount.addAndGet(batch.size());
+                            log.info("创建完课消息 - 生产批次 tenantId={}, batchNo={}, size={}, maxLogId={}, 累计记录数={}",
+                                    tenantId, batchNo, batch.size(), maxId, recordCount);
                         }
 
                         if (batch.size() < batchSize) {
                             hasMore = false;
-                            batchQueue.put(Collections.emptyList());// 结束标志
-                            log.info("数据生产完成,最后logId: {}", maxId);
+                            for (int i = 0; i < threadPoolSize; i++) {
+                                batchQueue.put(Collections.emptyList());
+                            }
+                            if (totalRecordCount.get() == 0) {
+                                log.info("创建完课消息 - 无待处理完课记录 tenantId={}, 查询范围=[{}, {})",
+                                        tenantId, startDate, endDate);
+                            } else {
+                                log.info("创建完课消息 - 数据生产完成 tenantId={}, 总批次数={}, 总记录数={}, 最后logId={}",
+                                        tenantId, totalBatchCount.get(), totalRecordCount.get(), maxId);
+                            }
                         }
                     }
                 } catch (Exception e) {
-                    log.error("生产数据时出错", e);
-                    try {
-                        batchQueue.put(Collections.emptyList()); // 确保消费者能退出
-                    } catch (InterruptedException ie) {
-                        Thread.currentThread().interrupt();
-                    }
+                    log.error("创建完课消息 - 生产数据失败 tenantId={}", tenantId, e);
+                    signalConsumersToStop(batchQueue, threadPoolSize);
                 }
-            });
+            }));
 
-            // 消费者线程处理数据
             List<Future<?>> futures = new ArrayList<>();
             for (int i = 0; i < threadPoolSize; i++) {
-                futures.add(executorService.submit(() -> {
+                final int consumerIndex = i + 1;
+                futures.add(executorService.submit(() -> runInTenantContext(tenantId, "createCourseFinishMsg-consumer", () -> {
                     try {
+                        log.info("创建完课消息 - 消费者启动 tenantId={}, consumer={}/{}", tenantId, consumerIndex, threadPoolSize);
                         while (true) {
                             List<FsCourseWatchLog> batch = batchQueue.take();
-
-                            // 空列表表示处理结束
                             if (batch.isEmpty()) {
-                                batchQueue.put(Collections.emptyList()); // 传递给其他消费者
+                                log.info("创建完课消息 - 消费者结束 tenantId={}, consumer={}/{}", tenantId, consumerIndex, threadPoolSize);
                                 break;
                             }
-                            log.info("开始处理批次数据");
-                            processBatch(batch); // 处理批次数据
+                            log.info("创建完课消息 - 消费者处理批次 tenantId={}, consumer={}, size={}",
+                                    tenantId, consumerIndex, batch.size());
+                            processBatch(batch, tenantId);
                         }
                     } catch (InterruptedException e) {
                         Thread.currentThread().interrupt();
-                        log.error("处理数据时被中断", e);
+                        log.error("创建完课消息 - 消费者被中断 tenantId={}, consumer={}", tenantId, consumerIndex, e);
                     } catch (Exception e) {
-                        log.error("处理数据时出错", e);
+                        log.error("创建完课消息 - 消费者处理失败 tenantId={}, consumer={}", tenantId, consumerIndex, e);
                     }
-                }));
+                })));
             }
 
-            // 等待所有任务完成
             for (Future<?> future : futures) {
                 try {
-                    future.get();
-                } catch (InterruptedException | ExecutionException e) {
-                    log.error("等待任务完成时出错", e);
+                    future.get(30, TimeUnit.MINUTES);
+                } catch (InterruptedException e) {
+                    log.error("创建完课消息 - 等待消费者完成时被中断 tenantId={}", tenantId, e);
                     Thread.currentThread().interrupt();
+                } catch (ExecutionException e) {
+                    log.error("创建完课消息 - 消费者执行异常 tenantId={}", tenantId, e.getCause());
+                } catch (TimeoutException e) {
+                    log.error("创建完课消息 - 处理超时(30分钟) tenantId={}", tenantId, e);
                 }
             }
 
-            log.info("所有批次处理完成,总耗时: {}ms", System.currentTimeMillis() - startTime);
-
+            log.info("创建完课消息 - 任务完成 tenantId={}, 总记录数={}, 耗时={}ms",
+                    tenantId, totalRecordCount.get(), System.currentTimeMillis() - startTime);
         } finally {
             executorService.shutdown();
             try {
                 if (!executorService.awaitTermination(60, TimeUnit.SECONDS)) {
+                    log.warn("创建完课消息 - 线程池未在60秒内结束,强制关闭 tenantId={}", tenantId);
                     executorService.shutdownNow();
                 }
             } catch (InterruptedException e) {
@@ -2339,83 +2350,101 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         }
     }
 
+    private void signalConsumersToStop(BlockingQueue<List<FsCourseWatchLog>> batchQueue, int consumerCount) {
+        log.warn("创建完课消息 - 发送停止信号,consumerCount={}", consumerCount);
+        for (int i = 0; i < consumerCount; i++) {
+            try {
+                batchQueue.put(Collections.emptyList());
+            } catch (InterruptedException ie) {
+                Thread.currentThread().interrupt();
+                break;
+            }
+        }
+    }
+
     // 处理单个批次的方法
-    private void processBatch(List<FsCourseWatchLog> batch) {
-        List<FsCourseWatchLog> finishLogsToUpdate = new ArrayList<>();
+    private void processBatch(List<FsCourseWatchLog> batch, Long tenantId) {
+        List<FsCourseWatchLog> finishLogsToMarkSent = new ArrayList<>();
         List<QwSopLogs> sopLogsToInsert = new ArrayList<>();
-        log.info("开始执行处理批次方法-数量:{}",batch.size());
+        int skipExternalNull = 0;
+        int skipTempNull = 0;
+        int skipSopNull = 0;
+        int skipInvalidContact = 0;
+        int skipException = 0;
+
+        log.info("创建完课消息 - 开始处理批次 tenantId={}, size={}", tenantId, batch.size());
         for (FsCourseWatchLog finishLog : batch) {
             try {
-
                 try {
-
                     asyncCourseWatchFinishService.executeCourseWatchFinish(finishLog);
-
-                }catch (Exception e){
-                    log.error("添加完课打备注失败",e);
+                } catch (Exception e) {
+                    log.error("创建完课消息 - 完课打备注失败 tenantId={}, logId={}, externalId={}",
+                            tenantId, finishLog.getLogId(), finishLog.getQwExternalContactId(), e);
                 }
 
-                // 查询外部联系人信息
                 QwExternalContact externalContact = qwExternalContactMapper.selectQwExternalContactById(finishLog.getQwExternalContactId());
                 if (externalContact == null) {
-                    log.error("外部联系人不存在: {}", finishLog.getQwExternalContactId());
+                    skipExternalNull++;
+                    log.warn("创建完课消息 - 外部联系人不存在 tenantId={}, logId={}, externalId={}",
+                            tenantId, finishLog.getLogId(), finishLog.getQwExternalContactId());
                     continue;
                 }
 
-                // 查询完课模板信息
-                FsCourseFinishTemp finishTemp = fsCourseFinishTempMapper.selectFsCourseFinishTempByCompanyId(finishLog.getCompanyUserId(),finishLog.getCompanyId(), finishLog.getVideoId());
-
-                // 设置 finishLog 为已发送状态,并加入批量更新列表
-                finishLog.setSendFinishMsg(1);
-                finishLogsToUpdate.add(finishLog);
-
+                FsCourseFinishTemp finishTemp = fsCourseFinishTempMapper.selectFsCourseFinishTempByCompanyId(
+                        finishLog.getCompanyUserId(), finishLog.getCompanyId(), finishLog.getVideoId());
                 if (finishTemp == null) {
-//                    log.error("完课模板不存在: " + finishLog.getQwUserId() + ", " + finishLog.getVideoId());
+                    skipTempNull++;
+                    log.warn("创建完课消息 - 完课模板不存在 tenantId={}, logId={}, companyUserId={}, companyId={}, videoId={}",
+                            tenantId, finishLog.getLogId(), finishLog.getCompanyUserId(), finishLog.getCompanyId(), finishLog.getVideoId());
                     continue;
                 }
 
-                // 构建 sopLogs 对象
                 QwSopLogs sopLogs = buildSopLogs(finishLog, externalContact, finishTemp);
                 if (sopLogs == null) {
-                    log.error("生成完课发送记录为空-:{}", finishLog.getQwExternalContactId());
+                    skipSopNull++;
+                    log.warn("创建完课消息 - 生成发送记录为空 tenantId={}, logId={}, externalId={}, videoId={}",
+                            tenantId, finishLog.getLogId(), finishLog.getQwExternalContactId(), finishLog.getVideoId());
                     continue;
                 }
 
-                // 如果客户状态有效,则加入批量插入列表
-                if (isValidExternalContact(externalContact)) {
-                    sopLogsToInsert.add(sopLogs);
-                } else {
-                    log.info("完课消息-客户信息有误,不生成完课消息: {}", finishLog.getQwExternalContactId());
+                if (!isValidExternalContact(externalContact)) {
+                    skipInvalidContact++;
+                    log.info("创建完课消息 - 客户状态无效,跳过 tenantId={}, logId={}, externalId={}, status={}",
+                            tenantId, finishLog.getLogId(), finishLog.getQwExternalContactId(), externalContact.getStatus());
+                    continue;
                 }
-//                try {
-//                    fsUserCompanyBindService.finish(externalContact.getFsUserId(), externalContact.getQwUserId(), externalContact.getCompanyUserId(), finishLog);
-//                }catch (Exception e){
-//                    log.error("更新重粉看课状态失败",e);
-//                }
-            } catch (Exception e) {
-                log.error("处理完课记录失败: {}", finishLog.getLogId(), e);
-            }
-        }
 
-        // 批量更新和插入
-        if (!finishLogsToUpdate.isEmpty()) {
-            try {
-                fsCourseWatchLogMapper.batchUpdateWatchLogSendMsg(finishLogsToUpdate);
-                log.info("批量更新 finishLog 成功,数量: {}", finishLogsToUpdate.size());
+                sopLogsToInsert.add(sopLogs);
+                finishLogsToMarkSent.add(finishLog);
+                log.debug("创建完课消息 - 待发送 tenantId={}, logId={}, externalId={}, externalName={}",
+                        tenantId, finishLog.getLogId(), finishLog.getQwExternalContactId(), externalContact.getName());
             } catch (Exception e) {
-                log.error("批量更新 finishLog 失败", e);
+                skipException++;
+                log.error("创建完课消息 - 处理完课记录异常 tenantId={}, logId={}", tenantId, finishLog.getLogId(), e);
             }
         }
 
         if (!sopLogsToInsert.isEmpty()) {
             try {
                 qwSopLogsService.batchInsertQwSopLogs(sopLogsToInsert);
-                log.info("批量插入 sopLogs 成功,数量: {}", sopLogsToInsert.size());
+                for (FsCourseWatchLog finishLog : finishLogsToMarkSent) {
+                    finishLog.setSendFinishMsg(1);
+                }
+                fsCourseWatchLogMapper.batchUpdateWatchLogSendMsg(finishLogsToMarkSent);
+                log.info("创建完课消息 - 批量写入成功 tenantId={}, 成功数={}, logIds={}",
+                        tenantId, sopLogsToInsert.size(),
+                        finishLogsToMarkSent.stream().map(FsCourseWatchLog::getLogId).collect(Collectors.toList()));
             } catch (Exception e) {
-                log.error("批量插入 sopLogs 失败", e);
+                log.error("创建完课消息 - 批量写入失败,send_finish_msg 保持不变以便重试 tenantId={}, 待写入数={}",
+                        tenantId, sopLogsToInsert.size(), e);
             }
+        } else {
+            log.info("创建完课消息 - 本批次无有效消息 tenantId={}, 输入数={}", tenantId, batch.size());
         }
-        log.info("结束处理批次方法-数量:{}",batch.size());
+
+        log.info("创建完课消息 - 批次处理汇总 tenantId={}, 输入={}, 成功={}, 跳过[联系人不存在={}, 模板缺失={}, 消息构建失败={}, 客户状态无效={}, 异常={}]",
+                tenantId, batch.size(), sopLogsToInsert.size(),
+                skipExternalNull, skipTempNull, skipSopNull, skipInvalidContact, skipException);
     }
 
     /**

+ 2 - 1
fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallOutboundCdrServiceImpl.java

@@ -668,7 +668,7 @@ public class AiSipCallOutboundCdrServiceImpl extends ServiceImpl<AiSipCallOutbou
         callLog.setCreateTime(new Date());
         callLog.setRecordPath(callPhoneRes.getRecordFilename());
         callLog.setContentList(callPhoneRes.getChatContent());
-        callLog.setCallerNum(callPhoneRes.getCallee());
+        callLog.setCallerNum(PhoneUtil.encryptPhone(callPhoneRes.getCallee()));
         callLog.setCalleeNum(callPhoneRes.getCaller());
         callLog.setUuid(req.getUuid());
         callLog.setCallCreateTime(callPhoneRes.getStartTime());
@@ -707,6 +707,7 @@ public class AiSipCallOutboundCdrServiceImpl extends ServiceImpl<AiSipCallOutbou
             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);

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

@@ -204,14 +204,14 @@ public class AiSipCallUserServiceImpl extends ServiceImpl<AiSipCallUserMapper, A
     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) {
+        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.isNotBlank(oldUser.getExtPass()) && StringUtils.isNotBlank(bind.getExtensionPass())){
+            if(StringUtils.isEmpty(oldUser.getExtPass()) && StringUtils.isNotBlank(bind.getExtensionPass())){
                 aiSipCallUser.setExtPass(bind.getExtensionPass());
             }
             int rows = aiSipCallUserMapper.updateAiSipCallUser(aiSipCallUser);

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

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

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

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

+ 42 - 9
fs-service/src/main/java/com/fs/company/config/AsyncCalleeConfig.java

@@ -6,6 +6,8 @@ import com.fs.common.utils.spring.SpringUtils;
 import com.fs.core.config.TenantConfigContext;
 import com.fs.wxcid.utils.TenantHelper;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.BeansException;
+import org.springframework.beans.factory.config.BeanPostProcessor;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.core.task.TaskDecorator;
@@ -39,6 +41,20 @@ public class AsyncCalleeConfig {
         return executor;
     }
 
+    @Bean
+    public BeanPostProcessor threadPoolTaskExecutorPostProcessor() {
+        return new BeanPostProcessor() {
+            @Override
+            public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
+                if ("threadPoolTaskExecutor".equals(beanName) && bean instanceof ThreadPoolTaskExecutor) {
+                    ((ThreadPoolTaskExecutor) bean).setTaskDecorator(new TenantContextTaskDecorator());
+                    log.info("已为 threadPoolTaskExecutor 设置 TenantContextTaskDecorator");
+                }
+                return bean;
+            }
+        };
+    }
+
     /**
      * 租户上下文任务装饰器
      * 在任务执行前捕获当前线程的租户ID,在子线程中恢复
@@ -46,25 +62,27 @@ public class AsyncCalleeConfig {
     public static class TenantContextTaskDecorator implements TaskDecorator {
         @Override
         public Runnable decorate(Runnable runnable) {
-            // 捕获当前线程的租户ID
-            Long tenantId = TenantHelper.getTenantId();
+            Long tenantId = resolveTenantId();
+            log.debug("TenantContextTaskDecorator 捕获租户ID: {}", tenantId);
             return () -> {
+                if (tenantId == null) {
+                    log.warn("TenantContextTaskDecorator 租户ID为空, 跳过上下文设置");
+                    runnable.run();
+                    return;
+                }
                 try {
-                    // 在子线程中设置租户ID
                     TenantHelper.setTenantId(tenantId);
                     Object manager = SpringUtils.getBean("tenantDataSourceManager");
                     Method method = manager.getClass().getMethod("ensureSwitchByTenantId", Long.class);
-                    method.invoke(manager, TenantHelper.getTenantId());
-                    // 设置租户到 SecurityContext,供 TenantKeyRedisSerializer 自动为 Redis Key 加 tenantid 前缀
+                    method.invoke(manager, tenantId);
                     SecurityContextHolder.getContext().setAuthentication(
                             new UsernamePasswordAuthenticationToken(
-                                    new TenantPrincipal(TenantHelper.getTenantId()),
+                                    new TenantPrincipal(tenantId),
                                     null,
                                     Collections.emptyList()
                             )
                     );
-                    // 切换 Redis 租户上下文
-                    RedisTenantContext.setTenantId(TenantHelper.getTenantId());
+                    RedisTenantContext.setTenantId(tenantId);
                     runnable.run();
                 } catch (InvocationTargetException e) {
                     throw new RuntimeException(e);
@@ -74,7 +92,6 @@ public class AsyncCalleeConfig {
                     throw new RuntimeException(e);
                 } finally {
                     try {
-                        // 清理子线程的租户ID
                         TenantHelper.removeTenantId();
                         TenantConfigContext.clear();
                         SecurityContextHolder.clearContext();
@@ -88,5 +105,21 @@ public class AsyncCalleeConfig {
                 }
             };
         }
+
+        private Long resolveTenantId() {
+            Long tenantId = TenantHelper.getTenantId();
+            if (tenantId != null) {
+                return tenantId;
+            }
+            try {
+                tenantId = com.fs.common.utils.SecurityUtils.getTenantId();
+                if (tenantId != null) {
+                    return tenantId;
+                }
+            } catch (Exception ignored) {
+            }
+            tenantId = RedisTenantContext.getTenantId();
+            return tenantId;
+        }
     }
 }

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

@@ -190,6 +190,12 @@ public class CompanyUser extends BaseEntity
     /** 微信小程序OPENID(如果有小程序授权) */
     private String  maOpenId;
 
+    /** 微信公众号OPENID(用于服务号订阅通知) */
+    private String mpOpenId;
+
+    /** 是否已订阅公众号通知(0未订阅 1已订阅) */
+    private Integer mpSubscribed;
+
     /** 是否需要单独注册会员,1-是,0-否(用于个微销售分享看课) */
     private Integer isNeedRegisterMember;
 

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

@@ -105,6 +105,10 @@ public class CompanyVoiceRoboticCallLogCallphone extends BaseEntity{
     @Excel(name = "外呼类型")
     private Integer callType;
 
+    /** 未拨通类型 */
+    @Excel(name = "未拨通类型")
+    private String hangupType;
+
     /** 是否警告(0否 1是)用于敏感词 */
     @Excel(name = "是否警告(0否 1是)用于敏感词")
     private Integer isWarning;
@@ -155,6 +159,14 @@ public class CompanyVoiceRoboticCallLogCallphone extends BaseEntity{
     @TableField(exist = false)
     private Long maxCallTime;
 
+    /** 详情筛选-手机号(明文) */
+    @TableField(exist = false)
+    private String phone;
+
+    /** 详情筛选-加密手机号(匹配 callees.phone) */
+    @TableField(exist = false)
+    private String encryptedPhone;
+
     @TableField(exist = false)
     private String roboticName;
 

+ 24 - 1
fs-service/src/main/java/com/fs/company/domain/CrmCustomerCallLog.java

@@ -152,8 +152,31 @@ public class CrmCustomerCallLog extends BaseEntity {
     private Integer callType;
 
     /**
-     * 查询条件:最小通话时长(秒),仅过滤 call_time &gt; minCallTime 的记录;传 0 即可筛选“已接通”
+     * 查询条件:加密客户号码(前端输入明文手机号后,由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;
 }

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

@@ -98,6 +98,32 @@ public interface CompanyAiWorkflowExecMapper extends BaseMapper<CompanyAiWorkflo
 
     CompanyAiWorkflowExec selectExecWithTimeAvailableByInstanceId(@Param("workflowInstanceId") String workflowInstanceId);
 
+    /**
+     * 原子认领待执行任务:仅当当前状态等于 expectStatus 时,才将状态更新为 targetStatus。
+     * 用于防止定时扫描在上一轮任务尚未完成时重复调度同一条记录。
+     * @param id 执行记录主键
+     * @param expectStatus 期望的原状态(如 READY=9)
+     * @param targetStatus 认领后的目标状态(如 RUNNING=3)
+     * @return 影响行数,1 表示认领成功,0 表示已被其它线程/上一轮认领
+     */
+    int claimExecForRun(@Param("id") Long id,
+                        @Param("expectStatus") Integer expectStatus,
+                        @Param("targetStatus") Integer targetStatus);
+
+    /**
+     * 回扫超时认领任务:将处于 runningStatus 且 last_update_time 早于 timeoutTime 的记录重置为 failStatus,
+     * 防止进程重启 / 线程池拒绝导致任务永久卡在认领态。
+     * @param groupNo cid 分组号
+     * @param runningStatus 认领态(如 RUNNING=3)
+     * @param failStatus 重置目标态(如 FAILURE=2)
+     * @param timeoutTime 超时时间界限,last_update_time 早于该时间的视为卡死
+     * @return 影响行数
+     */
+    int resetTimeoutRunningExec(@Param("groupNo") Integer groupNo,
+                                @Param("runningStatus") Integer runningStatus,
+                                @Param("failStatus") Integer failStatus,
+                                @Param("timeoutTime") java.time.LocalDateTime timeoutTime);
+
     /**
      * 批量新增数据
      * @param list
@@ -109,7 +135,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);

+ 6 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanyUserMapper.java

@@ -374,4 +374,10 @@ public interface CompanyUserMapper
     CompanyUserAnalyseVO selectCompanyUserPhoneLogCount(@Param("companyUserId") Long id);
 
     int unbindCidServer(@Param("companyUserId") Long companyUserId);
+
+    @Update("UPDATE company_user SET mp_open_id = #{mpOpenId}, mp_subscribed = #{mpSubscribed} WHERE user_id = #{userId}")
+    int updateMpOpenIdAndSubscribed(@Param("userId") Long userId, @Param("mpOpenId") String mpOpenId, @Param("mpSubscribed") Integer mpSubscribed);
+
+    @Update("UPDATE company_user SET mp_subscribed = #{mpSubscribed} WHERE user_id = #{userId}")
+    int updateMpSubscribed(@Param("userId") Long userId, @Param("mpSubscribed") Integer mpSubscribed);
 }

+ 15 - 1
fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceRoboticCallLogCallphoneMapper.java

@@ -3,8 +3,10 @@ package com.fs.company.mapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.fs.company.domain.CompanyVoiceRoboticCallLogCallphone;
 import com.fs.company.domain.CompanyVoiceRoboticCallees;
+import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneDecryptQueryVO;
 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;
@@ -83,11 +85,13 @@ public interface CompanyVoiceRoboticCallLogCallphoneMapper extends BaseMapper<Co
 
     List<CompanyVoiceRoboticCallLogCallphone> selectCompanyVoiceRoboticCallPhoneLogGroupList(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
 
-    CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallPhoneLogCount();
+    CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallPhoneLogCount(@Param("companyId") Long companyId);
 
 
     List<CompanyVoiceRoboticCallLogCallPhoneVO> listByRoboticId(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
 
+    List<CompanyVoiceRoboticCallLogCallPhoneDecryptQueryVO> listDecryptPhoneExport(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
+
     /**
      * 根据业务ID查询公司ID
      * @param businessId 业务ID (bes.id)
@@ -96,4 +100,14 @@ public interface CompanyVoiceRoboticCallLogCallphoneMapper extends BaseMapper<Co
     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);
 }

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

@@ -2,6 +2,8 @@ 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;
 
@@ -16,4 +18,16 @@ 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);
 }

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

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

@@ -2,6 +2,7 @@ package com.fs.company.service;
 
 import com.baomidou.mybatisplus.extension.service.IService;
 import com.fs.company.domain.CompanyVoiceRoboticCallLogCallphone;
+import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
 
@@ -79,10 +80,12 @@ public interface ICompanyVoiceRoboticCallLogCallphoneService extends IService<Co
 
     List<CompanyVoiceRoboticCallLogCallphone> selectCompanyVoiceRoboticCallPhoneLogGroupList(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
 
-    CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallPhoneLogCount();
+    CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallPhoneLogCount(Long companyId);
 
     List<CompanyVoiceRoboticCallLogCallPhoneVO> listByRoboticId(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
 
+    List<CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO> listDecryptPhoneExport(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
+
     List<CompanyVoiceRoboticCallLogCallphone> selectManualAnsweredList(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
 
     /**
@@ -92,4 +95,9 @@ public interface ICompanyVoiceRoboticCallLogCallphoneService extends IService<Co
      * @return 影响行数
      */
     int markHandleFlag(Long logId);
+
+    /**
+     * 根据外呼记录ID获取解密后的手机号
+     */
+    String getDecryptPhoneByLogId(Long logId);
 }

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

@@ -103,7 +103,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);
 
@@ -125,4 +125,13 @@ public interface ICompanyVoiceRoboticService extends IService<CompanyVoiceRoboti
      * @return true=暂停中 false=非暂停
      */
     boolean isTaskPaused(Long taskId);
+
+    /**
+     * 追加客户到运行中的普通任务
+     *
+     * @param taskId      任务ID
+     * @param customerIds 要追加的CRM客户ID列表
+     * @return 追加结果(成功数、重复客户信息)
+     */
+    R appendCustomersToRunningTask(Long taskId, List<Long> customerIds);
 }

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

@@ -19,4 +19,12 @@ public interface ICrmCustomerCallLogService {
      * @return 客户通话记录集合
      */
     List<CrmCustomerCallLog> selectCrmCustomerCallLogList(CrmCustomerCallLog crmCustomerCallLog);
+
+    /**
+     * 查询客户通话记录计费分钟数总和
+     *
+     * @param crmCustomerCallLog 客户通话记录查询条件
+     * @return 计费分钟数总和
+     */
+    Long selectSumBillingMinute(CrmCustomerCallLog crmCustomerCallLog);
 }

+ 124 - 6
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallLogCallphoneServiceImpl.java

@@ -24,6 +24,8 @@ import com.fs.company.service.CompanyWorkflowEngine;
 import com.fs.company.service.ICompanyVoiceRoboticCallLogCallphoneService;
 import com.fs.company.service.ICompanyVoiceRoboticService;
 import com.fs.company.vo.CidConfigVO;
+import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO;
+import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneDecryptQueryVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
 import com.fs.company.vo.easycall.EasyCallCallPhoneVO;
@@ -332,7 +334,7 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
 
                     companyVoiceRoboticCallLog.setStatus(2);
                     companyVoiceRoboticCallLog.setResult(JSON.toJSONString(result));
-
+                    companyVoiceRoboticCallLog.setHangupType(result.getUnconnectedReason());
                     CompanyWxClient companyWxClient = companyWxClientServiceImpl.getOne(new QueryWrapper<CompanyWxClient>().eq("robotic_id", callees.getRoboticId()).eq("customer_id", callees.getUserId()));
                     CompanyVoiceRoboticWx roboticWx = companyVoiceRoboticWxServiceImpl.getById(companyWxClient.getRoboticWxId());
                     Long setCompanyUserId = null;
@@ -365,17 +367,67 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
                     companyVoiceRoboticCallLog.setCallCreateTime(createTime);
                     Long answerTime = result.getCallEndTime();
                     companyVoiceRoboticCallLog.setCallAnswerTime(answerTime);
-                    String intention = result.getIntent();
+                    // 【当前启用】读取 cc_call_phone.intent,再转换为系统字典 intention 数值
+                    String intentRaw = StringUtils.isNotBlank(result.getIntent()) ? result.getIntent().trim() : null;
                     String intentf = null;
+                    final String intentionLabel = intentRaw;
                     List<SysDictData> customerIntentionLevel = sysDictTypeService.selectDictDataByType("customer_intention_level");
-                    if (!isPositiveInteger(intention)) {
-                        Optional<SysDictData> firstDict = customerIntentionLevel.stream().filter(e -> e.getDictLabel().equals(intention)).findFirst();
+                    if (!isPositiveInteger(intentionLabel)) {
+                        Optional<SysDictData> firstDict = customerIntentionLevel.stream().filter(e -> e.getDictLabel().equals(intentionLabel)).findFirst();
                         if (firstDict.isPresent()) {
                             SysDictData sysDictData = firstDict.get();
                             intentf = sysDictData.getDictValue();
                         }
+                    } else {
+                        intentf = intentionLabel;
+                    }
+                    if (StringUtils.isBlank(intentf)) {
+                        intentf = "0";
                     }
                     companyVoiceRoboticCallLog.setIntention(intentf);
+
+                    // ========== 【历史保留】回滚时可注释上方 EasyCall 逻辑并取消下方对应注释 ==========
+                    // 方案A:仅使用 EasyCall intent 字段(改动前的本文件写法)
+//                    String intention = result.getIntent();
+//                    String intentf = null;
+//                    List<SysDictData> customerIntentionLevel = sysDictTypeService.selectDictDataByType("customer_intention_level");
+//                    if (!isPositiveInteger(intention)) {
+//                        Optional<SysDictData> firstDict = customerIntentionLevel.stream().filter(e -> e.getDictLabel().equals(intention)).findFirst();
+//                        if (firstDict.isPresent()) {
+//                            SysDictData sysDictData = firstDict.get();
+//                            intentf = sysDictData.getDictValue();
+//                        }
+//                    }
+//                    companyVoiceRoboticCallLog.setIntention(intentf);
+
+                    // 方案B:自家 AI 根据 dialogue 计算意向度
+//                    String intention = null;
+//                    if (StringUtils.isNotBlank(result.getDialogue())) {
+//                        try {
+//                            intention = crmCustomerAnalyzeService.aiIntentionDegree(
+//                                    result.getDialogue(),
+//                                    java.time.LocalTime.now().getLong(java.time.temporal.ChronoField.MILLI_OF_SECOND)
+//                            );
+//                        } catch (Exception e) {
+//                            log.error("easyCall回调日志意向度AI解析失败,uuid={}", result.getUuid(), e);
+//                        }
+//                    }
+//                    String intentf = null;
+//                    final String intentionLabel = intention;
+//                    List<SysDictData> customerIntentionLevel = sysDictTypeService.selectDictDataByType("customer_intention_level");
+//                    if (!isPositiveInteger(intentionLabel)) {
+//                        Optional<SysDictData> firstDict = customerIntentionLevel.stream().filter(e -> e.getDictLabel().equals(intentionLabel)).findFirst();
+//                        if (firstDict.isPresent()) {
+//                            SysDictData sysDictData = firstDict.get();
+//                            intentf = sysDictData.getDictValue();
+//                        }
+//                    } else {
+//                        intentf = intentionLabel;
+//                    }
+//                    if (StringUtils.isBlank(intentf)) {
+//                        intentf = "0";
+//                    }
+//                    companyVoiceRoboticCallLog.setIntention(intentf);
                     if(null != result.getValidTimeLen() && Integer.valueOf(0).compareTo(result.getValidTimeLen()) < 0){
                         BigDecimal divide = new BigDecimal(result.getValidTimeLen()).divide(new BigDecimal(1000), 0, RoundingMode.CEILING);
                         companyVoiceRoboticCallLog.setCallTime(divide.longValue());
@@ -521,14 +573,62 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
     }
 
     @Override
-    public CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallPhoneLogCount() {
-        return baseMapper.selectCompanyVoiceRoboticCallPhoneLogCount();
+    public CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallPhoneLogCount(Long companyId) {
+        return baseMapper.selectCompanyVoiceRoboticCallPhoneLogCount(companyId);
     }
 
     @Override
     public List<CompanyVoiceRoboticCallLogCallPhoneVO> listByRoboticId(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone) {
         return baseMapper.listByRoboticId(companyVoiceRoboticCallLogCallphone);
     }
+
+    @Override
+    public List<CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO> listDecryptPhoneExport(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone) {
+        prepareDetailPhoneQuery(companyVoiceRoboticCallLogCallphone);
+        List<CompanyVoiceRoboticCallLogCallPhoneDecryptQueryVO> rows = baseMapper.listDecryptPhoneExport(companyVoiceRoboticCallLogCallphone);
+        List<CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO> exportList = new ArrayList<>();
+        if (rows == null || rows.isEmpty()) {
+            return exportList;
+        }
+        Map<String, String> intentionLabelMap = buildIntentionLabelMap();
+        for (CompanyVoiceRoboticCallLogCallPhoneDecryptQueryVO row : rows) {
+            CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO vo = new CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO();
+            vo.setRoboticName(row.getRoboticName());
+            vo.setUserName(row.getUserName());
+            vo.setIntention(resolveIntentionLabel(row.getIntention(), intentionLabelMap));
+            vo.setPhone(PhoneUtil.decryptPhone(row.getPhone()));
+            exportList.add(vo);
+        }
+        return exportList;
+    }
+
+    private Map<String, String> buildIntentionLabelMap() {
+        Map<String, String> map = new HashMap<>();
+        List<SysDictData> dictList = sysDictTypeService.selectDictDataByType("customer_intention_level");
+        if (dictList != null) {
+            for (SysDictData dict : dictList) {
+                map.put(dict.getDictValue(), dict.getDictLabel());
+            }
+        }
+        return map;
+    }
+
+    private String resolveIntentionLabel(String intention, Map<String, String> intentionLabelMap) {
+        if (StringUtils.isEmpty(intention)) {
+            return "";
+        }
+        String label = intentionLabelMap.get(intention);
+        if (StringUtils.isNotEmpty(label)) {
+            return label;
+        }
+        return intention;
+    }
+
+    private void prepareDetailPhoneQuery(CompanyVoiceRoboticCallLogCallphone query) {
+        if (StringUtils.isNotEmpty(query.getPhone())) {
+            query.setEncryptedPhone(PhoneUtil.encryptPhone(query.getPhone()));
+        }
+    }
     /**
      * 判断整数
      *
@@ -551,4 +651,22 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
         updateObj.setHandleFlag(1);
         return baseMapper.updateCompanyVoiceRoboticCallLogCallphone(updateObj);
     }
+
+    @Override
+    public String getDecryptPhoneByLogId(Long logId) {
+        CompanyVoiceRoboticCallLogCallphone log = selectCompanyVoiceRoboticCallLogCallphoneByLogId(logId);
+        if (log == null) {
+            return null;
+        }
+        if (log.getCallerId() != null) {
+            CompanyVoiceRoboticCallees callees = companyVoiceRoboticCalleesMapper.selectCompanyVoiceRoboticCalleesById(log.getCallerId());
+            if (callees != null && StringUtils.isNotEmpty(callees.getPhone())) {
+                return PhoneUtil.decryptPhone(callees.getPhone());
+            }
+        }
+        if (StringUtils.isNotEmpty(log.getCallerNum())) {
+            return PhoneUtil.decryptPhone(log.getCallerNum());
+        }
+        return null;
+    }
 }

+ 315 - 64
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java

@@ -39,6 +39,7 @@ import com.fs.crm.service.impl.CrmCustomerServiceImpl;
 import com.fs.enums.ExecutionStatusEnum;
 import com.fs.enums.NodeTypeEnum;
 import com.fs.enums.TaskTypeEnum;
+import com.fs.his.utils.PhoneUtil;
 import com.fs.qw.domain.QwUser;
 import com.fs.qw.mapper.QwUserMapper;
 import com.fs.qw.service.impl.QwExternalContactServiceImpl;
@@ -56,12 +57,10 @@ import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.stereotype.Service;
 
 import java.lang.reflect.Method;
-import java.time.temporal.ChronoField;
 import java.util.*;
 import java.util.stream.Collectors;
 
 import static com.fs.company.service.impl.call.node.AiCallTaskNode.EASYCALL_WORKFLOW_REDIS_KEY;
-import static java.time.LocalTime.now;
 
 
 /**
@@ -897,50 +896,48 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
                 } catch (Exception e) {
                     log.error("callerResult4EasyCall 切换租户数据源失败: tenantId={}", TenantHelper.getTenantId(), e);
                 }
-//                // intent(意向度)由对方异步评估写入,回调时可能尚未赋值,进入延迟重试队列等待
-//                if (StringUtils.isBlank(callPhoneRes.getIntent())) {
-//                    String retryKey = EASYCALL_INTENT_RETRY_KEY + result.getUuid();
-//                    Integer retryCount = redisCache2.getCacheObject(retryKey);
-//                    if (retryCount == null) {
-//                        retryCount = 0;
-//                    }
-//                    if (retryCount < EASYCALL_INTENT_MAX_RETRY) {
-//                        redisCache2.setCacheObject(retryKey, retryCount + 1, 10, java.util.concurrent.TimeUnit.MINUTES);
-//                        log.info("easyCall外呼回调intent意向度暂未评估完成,uuid={},第{}次放入延迟重试队列", result.getUuid(), retryCount + 1);
-//                        doRetryCallerResult4EasyCall(result, retryCount + 1);
-//                    } else {
-//                        // 超过最大重试次数,以 intent 为空(意向未知)兜底继续处理
-//                        log.warn("easyCall外呼回调intent意向度在{}次重试后仍为空,uuid={},以意向未知兜底处理", EASYCALL_INTENT_MAX_RETRY, result.getUuid());
-//                        redisCache2.deleteObject(retryKey);
-//                        doHandleEasyCallResult(callPhoneRes);
-//                    }
-//                    return;
-//                }
-//                // intent 已有值,直接正常处理
-//                redisCache2.deleteObject(EASYCALL_INTENT_RETRY_KEY + result.getUuid());
-//                doHandleEasyCallResult(callPhoneRes);
-                // dialogue(对话内容)由对方异步写入,回调时可能尚未赋值,进入延迟重试队列等待
-                if (isDialogueEmpty(callPhoneRes.getDialogue()) && !"未接通".equals(callPhoneRes.getIntent())) {
-                    String retryKey = EASYCALL_DIALOGUE_RETRY_KEY + result.getUuid();
+                // 【当前启用】cc_call_phone.intent 由 EasyCall 异步评估写入,回调时可能尚未赋值,进入延迟重试队列等待
+                if (StringUtils.isBlank(resolveCcCallPhoneIntent(callPhoneRes))) {
+                    String retryKey = EASYCALL_INTENT_RETRY_KEY + result.getUuid();
                     Integer retryCount = redisCache2.getCacheObject(retryKey);
                     if (retryCount == null) {
                         retryCount = 0;
                     }
-                    if (retryCount < EASYCALL_DIALOGUE_MAX_RETRY) {
+                    if (retryCount < EASYCALL_INTENT_MAX_RETRY) {
                         redisCache2.setCacheObject(retryKey, retryCount + 1, 10, java.util.concurrent.TimeUnit.MINUTES);
-                        log.info("easyCall外呼回调dialogue对话内容暂未写入,uuid={},第{}次放入延迟重试队列", result.getUuid(), retryCount + 1);
-                        doRetryDialogue4EasyCall(result, retryCount + 1);
+                        log.info("easyCall外呼回调intent意向度暂未评估完成,uuid={},第{}次放入延迟重试队列", result.getUuid(), retryCount + 1);
+                        doRetryCallerResult4EasyCall(result, retryCount + 1);
                     } else {
-                        // 超过最大重试次数,以 dialogue 为空兜底继续处理
-                        log.warn("easyCall外呼回调dialogue对话内容在{}次重试后仍为空,uuid={},以对话为空兜底处理", EASYCALL_DIALOGUE_MAX_RETRY, result.getUuid());
+                        log.warn("easyCall外呼回调intent意向度在{}次重试后仍为空,uuid={},以意向未知兜底处理", EASYCALL_INTENT_MAX_RETRY, result.getUuid());
                         redisCache2.deleteObject(retryKey);
                         doHandleEasyCallResult(callPhoneRes);
                     }
                     return;
                 }
-                // dialogue 已有值,直接正常处理
-                redisCache2.deleteObject(EASYCALL_DIALOGUE_RETRY_KEY + result.getUuid());
+                redisCache2.deleteObject(EASYCALL_INTENT_RETRY_KEY + result.getUuid());
                 doHandleEasyCallResult(callPhoneRes);
+
+                // ========== 【历史保留-自家AI】根据 dialogue 等待后走 AI 意向度,回滚时注释上方 intent 重试并取消下方注释 ==========
+//                // 当前:根据对话内容同步调用自家 AI 计算意向度,不依赖第三方 intent
+//                if (isDialogueEmpty(callPhoneRes.getDialogue()) && !"未接通".equals(callPhoneRes.getIntent())) {
+//                    String retryKey = EASYCALL_DIALOGUE_RETRY_KEY + result.getUuid();
+//                    Integer retryCount = redisCache2.getCacheObject(retryKey);
+//                    if (retryCount == null) {
+//                        retryCount = 0;
+//                    }
+//                    if (retryCount < EASYCALL_DIALOGUE_MAX_RETRY) {
+//                        redisCache2.setCacheObject(retryKey, retryCount + 1, 10, java.util.concurrent.TimeUnit.MINUTES);
+//                        log.info("easyCall外呼回调dialogue对话内容暂未写入,uuid={},第{}次放入延迟重试队列", result.getUuid(), retryCount + 1);
+//                        doRetryDialogue4EasyCall(result, retryCount + 1);
+//                    } else {
+//                        log.warn("easyCall外呼回调dialogue对话内容在{}次重试后仍为空,uuid={},以对话为空兜底处理", EASYCALL_DIALOGUE_MAX_RETRY, result.getUuid());
+//                        redisCache2.deleteObject(retryKey);
+//                        doHandleEasyCallResult(callPhoneRes);
+//                    }
+//                    return;
+//                }
+//                redisCache2.deleteObject(EASYCALL_DIALOGUE_RETRY_KEY + result.getUuid());
+//                doHandleEasyCallResult(callPhoneRes);
             } catch (Exception e) {
                 throw new RuntimeException(e);
             } finally {
@@ -1032,8 +1029,8 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
             log.error("easyCall intent重试时仍未查询到外呼结果, uuid={}", result.getUuid());
             return;
         }
-        if (StringUtils.isBlank(callPhoneRes.getIntent())) {
-            // intent 仍为空,继续判断是否还有剩余重试次数
+        if (StringUtils.isBlank(resolveCcCallPhoneIntent(callPhoneRes))) {
+            // cc_call_phone.intent 仍为空,继续判断是否还有剩余重试次数
             String retryKey = EASYCALL_INTENT_RETRY_KEY + result.getUuid();
             Integer retryCount = redisCache2.getCacheObject(retryKey);
             if (retryCount == null) {
@@ -1051,7 +1048,7 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
             return;
         }
         // intent 已评估完成,正常处理
-        log.info("easyCall intent重试第{}次成功获取到意向度={},uuid={}", currentRetry, callPhoneRes.getIntent(), result.getUuid());
+        log.info("easyCall intent重试第{}次成功获取到意向度={},uuid={}", currentRetry, resolveCcCallPhoneIntent(callPhoneRes), result.getUuid());
         redisCache2.deleteObject(EASYCALL_INTENT_RETRY_KEY + result.getUuid());
         doHandleEasyCallResult(callPhoneRes);
     }
@@ -1150,27 +1147,37 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
 //                    log.error("pushDialogContent4EasyCall 切换租户数据源失败: tenantId={}", TenantHelper.getTenantId(), e);
 //                }
 //            }
-            String intention = null;
-            String intentionDegree = null;
-            if (StringUtils.isNotBlank(callPhoneRes.getDialogue())) {
-                log.info("【验证】意向度来源=自家AI, uuid={}, dialogueLength={}", callPhoneRes.getUuid(),
-                        StringUtils.isBlank(callPhoneRes.getDialogue()) ? 0 : callPhoneRes.getDialogue().length());
-                try {
-                    intentionDegree = crmCustomerAnalyzeService.aiIntentionDegree(
-                            callPhoneRes.getDialogue(),
-                            now().getLong(ChronoField.MILLI_OF_SECOND)
-                    );
-                    log.info("【验证】意向度结果={}, uuid={}", intentionDegree, callPhoneRes.getUuid());
-                    intention = getIntention(intentionDegree);
-                } catch (Exception e) {
-                    log.error("easyCall意向度AI解析失败,uuid={},将使用意向未知兜底", callPhoneRes.getUuid(), e);
-                }
-            }
-            // 2) 最终兜底:意向未知
-//            String intention = getIntention(callPhoneRes.getIntent());
+            // 【当前启用】读取 cc_call_phone.intent,再转换为系统字典 intention 数值
+            String intentRaw = resolveCcCallPhoneIntent(callPhoneRes);
+            String intention = convertEasyCallIntent(intentRaw);
+            log.info("easyCall意向度来源=EasyCall平台, uuid={}, intent(raw)={}, intention(converted)={}",
+                    callPhoneRes.getUuid(), intentRaw, intention);
             if (StringUtils.isEmpty(intention)) {
                 intention = "0";
             }
+
+            // ========== 【历史保留-自家AI】回滚时注释上方 EasyCall 逻辑并取消下方注释 ==========
+//            String intention = null;
+//            String intentionDegree = null;
+//            if (StringUtils.isNotBlank(callPhoneRes.getDialogue())) {
+//                log.info("【验证】意向度来源=自家AI, uuid={}, dialogueLength={}", callPhoneRes.getUuid(),
+//                        StringUtils.isBlank(callPhoneRes.getDialogue()) ? 0 : callPhoneRes.getDialogue().length());
+//                try {
+//                    intentionDegree = crmCustomerAnalyzeService.aiIntentionDegree(
+//                            callPhoneRes.getDialogue(),
+//                            java.time.LocalTime.now().getLong(java.time.temporal.ChronoField.MILLI_OF_SECOND)
+//                    );
+//                    log.info("【验证】意向度结果={}, uuid={}", intentionDegree, callPhoneRes.getUuid());
+//                    intention = getIntention(intentionDegree);
+//                } catch (Exception e) {
+//                    log.error("easyCall意向度AI解析失败,uuid={},将使用意向未知兜底", callPhoneRes.getUuid(), e);
+//                }
+//            }
+//            if (StringUtils.isEmpty(intention)) {
+//                intention = "0";
+//            }
+//            // 历史第三方值(仅 intent 字段,未走 AI 时的写法)
+//            // String intention = getIntention(callPhoneRes.getIntent());
             CompanyVoiceRoboticCallees callee = companyVoiceRoboticCalleesMapper.selectCompanyVoiceRoboticCalleesById(cacheInfo.getLong("calleeId"));
             callee.setUuid(callPhoneRes.getUuid());
             callee.setIntention(intention);
@@ -1230,6 +1237,30 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
         return collect.isEmpty() ? null : collect.get(0).getDictValue();
     }
 
+    /**
+     * 读取 EasyCall cc_call_phone 表原始字段 intent(非本系统 intention 字典值)
+     */
+    private String resolveCcCallPhoneIntent(EasyCallCallPhoneVO callPhoneRes) {
+        if (callPhoneRes == null || StringUtils.isBlank(callPhoneRes.getIntent())) {
+            return null;
+        }
+        return callPhoneRes.getIntent().trim();
+    }
+
+    /**
+     * 将 cc_call_phone.intent 原始值转为系统字典数值 intention(customer_intention_level.dict_value)
+     */
+    private String convertEasyCallIntent(String intentRaw) {
+        if (StringUtils.isBlank(intentRaw)) {
+            return null;
+        }
+        String t = intentRaw.trim();
+        if (t.matches("^\\d+$")) {
+            return t;
+        }
+        return getIntention(t);
+    }
+
     public void pushBilling(PushIIntentionResult result) {
         Notify notify = result.getNotify();
         CompanyVoiceRoboticCallees callee = getResultCalleeInfo(notify);
@@ -1764,11 +1795,14 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
                                               Integer pageSize,
                                               String customerName,
                                               String customerPhone,
-                                              Boolean onlyCallNode) {
+                                              Boolean onlyCallNode,
+                                              String encryptPhone) {
         //分页查询主数据
         PageHelper.startPage(pageNum, pageSize);
-
-        List<WorkflowExecRecordVo> records = companyAiWorkflowExecMapper.selectExecRecordsByRoboticId(roboticId, customerName, customerPhone, onlyCallNode);
+        if(StringUtils.isNotBlank(encryptPhone)){
+            encryptPhone = PhoneUtil.encryptPhone(encryptPhone);
+        }
+        List<WorkflowExecRecordVo> records = companyAiWorkflowExecMapper.selectExecRecordsByRoboticId(roboticId, customerName, customerPhone, onlyCallNode,encryptPhone);
 
         PageInfo<WorkflowExecRecordVo> pageInfo = new PageInfo<>(records);
 
@@ -1837,7 +1871,45 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
             }
         });
 
-        return buildResult(pageInfo, records);
+
+        // 统计全量节点数据(不受分页限制):查全量instanceIds,再查全量nodeLogs
+        List<String> allInstanceIds = companyAiWorkflowExecMapper.selectExecRecordsByRoboticId(
+                roboticId, null, null, false, null)
+                .stream()
+                .map(WorkflowExecRecordVo::getWorkflowInstanceId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toList());
+
+        long callDone = 0;
+        long addWxDone = 0;
+        long sendMsgDone = 0;
+
+        if (!allInstanceIds.isEmpty()) {
+            List<CompanyAiWorkflowExecLog> allNodeLogs = companyAiWorkflowExecLogMapper.selectByInstanceIds(allInstanceIds);
+            // nodeType: 6=AI外呼电话, 7=AI发送短信, 8=AI添加微信, 9=AI企微添加个微, 10=AI添加微信(新)
+            callDone = allNodeLogs.stream()
+                    .filter(e -> e.getStatus() == 1)
+                    .filter(e -> e.getNodeType().equals(NodeTypeEnum.AI_CALL_TASK.getValue()))
+                    .count();
+            addWxDone = allNodeLogs.stream()
+                    .filter(e -> e.getStatus() == 1)
+                    .filter(e -> e.getNodeType().equals(NodeTypeEnum.AI_ADD_WX_TASK.getValue())
+                            || e.getNodeType().equals(NodeTypeEnum.AI_QW_ADD_WX_TASK.getValue())
+                            || e.getNodeType().equals(NodeTypeEnum.AI_ADD_WX_TASK_NEW.getValue()))
+                    .count();
+            sendMsgDone = allNodeLogs.stream()
+                    .filter(e -> e.getStatus() == 1)
+                    .filter(e -> e.getNodeType().equals(NodeTypeEnum.AI_SEND_MSG_TASK.getValue()))
+                    .count();
+        }
+
+        Map<String, Object> result = buildResult(pageInfo, records);
+        Map<String, Object> stats = new HashMap<>();
+        stats.put("callDone", callDone);
+        stats.put("addWxDone", addWxDone);
+        stats.put("sendMsgDone", sendMsgDone);
+        result.put("stats", stats);
+        return result;
     }
 
     private Map<String, Object> buildResult(PageInfo<?> pageInfo, List<?> records) {
@@ -1909,7 +1981,7 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
             return new ArrayList<>();
         }
         List<CompanyAiWorkflowExecLog> callLogs = logs.stream().filter(a -> "外呼".equals(a.getNodeName())).collect(Collectors.toList());
-        HashMap<Long,String> callContentMap;
+        HashMap<Long,CallContentVO> callContentMap;
         if (null != callLogs && !callLogs.isEmpty()) {
             callContentMap = selectCallContentByCallLogs(callLogs);
         } else {
@@ -1929,7 +2001,11 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
             vo.setDuration(log.getDuration());
             vo.setErrorMessage(log.getErrorMessage());
             vo.setOutputData(log.getOutputData());
-            vo.setNodeContentList(callContentMap.get(log.getId()));
+            CallContentVO callContentVO = callContentMap.get(log.getId());
+            if (callContentVO != null) {
+                vo.setNodeContentList(callContentVO.getCallContent());
+                vo.setNodeRecordPath(callContentVO.getRecordPath());
+            }
             return vo;
         }).collect(Collectors.toList());
     }
@@ -1939,15 +2015,15 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
      * @param callLogs
      * @return
      */
-    public  HashMap<Long,String> selectCallContentByCallLogs(List<CompanyAiWorkflowExecLog> callLogs){
+    public  HashMap<Long,CallContentVO> selectCallContentByCallLogs(List<CompanyAiWorkflowExecLog> callLogs){
         List<Long> ids = callLogs.stream().map(a -> a.getId()).collect(Collectors.toList());
         if(null == ids || ids.isEmpty()){
             return new HashMap<>();
         }
         List<CallContentVO> callContentVOS = companyAiWorkflowExecLogMapper.selectCallContent(ids);
         if(null != callContentVOS && !callContentVOS.isEmpty()){
-            HashMap<Long,String> map = new HashMap<>();
-            callContentVOS.forEach(a -> map.put(a.getLogId(),a.getCallContent()));
+            HashMap<Long,CallContentVO> map = new HashMap<>();
+            callContentVOS.forEach(a -> map.put(a.getLogId(), a));
             return map;
         }
         else{
@@ -2228,4 +2304,179 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
         }
         return false;
     }
+
+    @Override
+    public R appendCustomersToRunningTask(Long taskId, List<Long> customerIds) {
+        // 1. 校验参数
+        if (taskId == null || customerIds == null || customerIds.isEmpty()) {
+            return R.error("参数不能为空");
+        }
+
+        // 2. 校验任务
+        CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectCompanyVoiceRoboticById(taskId);
+        if (robotic == null) {
+            return R.error("任务不存在: " + taskId);
+        }
+        if (!robotic.getTaskType().equals(TaskTypeEnum.ORDINARY.getValue())) {
+            return R.error("仅普通任务支持追加客户");
+        }
+        if (!Integer.valueOf(1).equals(robotic.getTaskStatus())) {
+            return R.error("任务不在执行中状态,无法追加客户");
+        }
+        if (robotic.getCompanyAiWorkflowId() == null) {
+            return R.error("任务未配置工作流");
+        }
+
+        // 3. 查询当前任务已有的callees的userId集合,过滤重复
+        List<CompanyVoiceRoboticCallees> existingCallees = companyVoiceRoboticCalleesMapper.selectByRoboticId(taskId);
+        Set<Long> existingUserIds = existingCallees.stream()
+                .map(CompanyVoiceRoboticCallees::getUserId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+
+        // 分离重复和非重复客户
+        List<Long> duplicateIds = customerIds.stream()
+                .filter(existingUserIds::contains)
+                .collect(Collectors.toList());
+        List<Long> newCustomerIds = customerIds.stream()
+                .filter(id -> !existingUserIds.contains(id))
+                .collect(Collectors.toList());
+
+        if (newCustomerIds.isEmpty()) {
+            String dupNames = getDuplicateCustomerNames(duplicateIds);
+            return R.error("所选客户已存在于任务中" + (dupNames.isEmpty() ? "" : ":" + dupNames));
+        }
+
+        // 4. 批量查询CRM客户信息
+        List<CrmCustomer> crmCustomers = crmCustomerService.selectCrmCustomerListByIds(
+                newCustomerIds.stream().map(String::valueOf).collect(Collectors.joining(",")));
+        Map<Long, CrmCustomer> customerMap = crmCustomers.stream()
+                .collect(Collectors.toMap(CrmCustomer::getCustomerId, c -> c, (a, b) -> a));
+
+        // 5. 判断是否需要加微分配
+        boolean hasAddWxNode = workflowHasAddWxNode(robotic.getCompanyAiWorkflowId());
+
+        int successCount = 0;
+        List<String> errorMessages = new ArrayList<>();
+
+        for (Long customerId : newCustomerIds) {
+            CrmCustomer crmCustomer = customerMap.get(customerId);
+            if (crmCustomer == null) {
+                errorMessages.add("客户不存在: " + customerId);
+                continue;
+            }
+            try {
+                // 5.1 创建CompanyWxClient记录
+                CompanyWxClient client = new CompanyWxClient();
+                client.setRoboticId(taskId);
+                client.setCustomerId(customerId);
+                client.setIsWeCom(robotic.getIsWeCom());
+                companyWxClientServiceImpl.insertCompanyWxClient(client);
+
+                // 5.2 创建CompanyVoiceRoboticCallees记录
+                CompanyVoiceRoboticCallees callee = new CompanyVoiceRoboticCallees();
+                callee.setUserId(crmCustomer.getCustomerId());
+                callee.setUserName(crmCustomer.getCustomerName());
+                callee.setPhone(crmCustomer.getMobile());
+                callee.setRoboticId(robotic.getId());
+                callee.setResult(0);
+                callee.setTaskFlow(robotic.getTaskFlow());
+                callee.setRunTaskFlow(robotic.getRunTaskFlow());
+                callee.setIsWeCom(robotic.getIsWeCom());
+                companyVoiceRoboticCalleesService.save(callee);
+
+                // 5.3 加微分配
+                if (hasAddWxNode && Integer.valueOf(0).equals(robotic.getAddType())) {
+                    allocateWx4SceneTask(robotic, client.getId());
+                } else if (hasAddWxNode && Integer.valueOf(1).equals(robotic.getAddType())) {
+                    String intention = crmCustomer.getIntention();
+                    String queryIntention = intention;
+                    if (!isPositiveInteger(intention)) {
+                        List<SysDictData> customerIntentionLevel = sysDictTypeService.selectDictDataByType("customer_intention_level");
+                        Optional<SysDictData> firstDict = customerIntentionLevel.stream()
+                                .filter(e -> e.getDictLabel().equals(intention)).findFirst();
+                        if (firstDict.isPresent()) {
+                            queryIntention = firstDict.get().getDictValue();
+                        }
+                    }
+                    List<CompanyVoiceRoboticWx> roboticWxList = companyVoiceRoboticWxMapper.selectByRoboticId(taskId, queryIntention);
+                    List<CompanyWxAccount> accountList = new ArrayList<>(companyWxAccountService.listByIds(PubFun.listToNewList(roboticWxList, CompanyVoiceRoboticWx::getAccountId)));
+                    Map<Long, CompanyWxAccount> accountMap = PubFun.listToMapByGroupObject(accountList, CompanyWxAccount::getId);
+                    roboticWxList.forEach(e -> e.setAccount(accountMap.get(e.getAccountId())));
+                    CompanyWxClient companyWxClient = companyWxClientServiceImpl.getOne(new QueryWrapper<CompanyWxClient>().eq("robotic_id", callee.getRoboticId()).eq("customer_id", callee.getUserId()));
+                    if (companyWxClient == null) {
+                        companyWxClient = new CompanyWxClient();
+                    }
+                    companyWxClient.setRoboticId(callee.getRoboticId());
+                    companyWxClient.setNickName(callee.getUserName());
+                    companyWxClient.setPhone(callee.getPhone());
+                    companyWxClient.setCustomerId(callee.getUserId());
+                    companyWxClient.setIntention(intention);
+                    bindCompany(companyWxClient, roboticWxList);
+                    companyWxClientServiceImpl.saveOrUpdate(companyWxClient);
+                }
+
+                // 5.4 创建CompanyVoiceRoboticBusiness记录
+                CompanyVoiceRoboticBusiness business = buildTaskBussiness4SceneTask(robotic, callee);
+
+                // 5.5 初始化工作流实例
+                Map<String, Object> inputVariables = new HashMap<>();
+                inputVariables.put("roboticId", robotic.getId());
+                inputVariables.put("businessId", business.getId());
+                inputVariables.put("cidGroupNo", robotic.getCidGroupNo());
+                inputVariables.put("runtimeRangeStart", robotic.getRuntimeRangeStart());
+                inputVariables.put("runtimeRangeEnd", robotic.getRuntimeRangeEnd());
+                ExecutionResult initResult = companyWorkflowEngine.initialize(robotic.getCompanyAiWorkflowId(), inputVariables);
+                if (!initResult.isSuccess()) {
+                    errorMessages.add("客户" + crmCustomer.getCustomerName() + "工作流初始化失败: " + initResult.getErrorMessage());
+                    continue;
+                }
+
+                successCount++;
+            } catch (Exception e) {
+                log.error("追加客户失败 - taskId: {}, customerId: {}", taskId, customerId, e);
+                errorMessages.add("客户" + crmCustomer.getCustomerName() + "追加失败: " + e.getMessage());
+            }
+        }
+
+        // 6. 为新增的callees生成AI标签信息
+        if (successCount > 0) {
+            try {
+                List<CompanyVoiceRoboticCallees> allCallees = companyVoiceRoboticCalleesMapper.selectByRoboticId(taskId);
+                List<CompanyWxClient> companyWxClients = companyWxClientMapper.selectListByRoboticId(taskId);
+                Map<String, CompanyWxClient> clientMp = companyWxClients.stream()
+                        .collect(Collectors.toMap(e -> e.getRoboticId() + "-" + e.getCustomerId(), e -> e, (a, b) -> a));
+                asyncCalleeProcessorService.generateCustomerInfo(allCallees, clientMp, robotic);
+            } catch (Exception e) {
+                log.warn("追加客户后生成AI标签信息异常 - taskId: {}", taskId, e);
+            }
+        }
+
+        // 7. 构建返回结果
+        Map<String, Object> resultData = new HashMap<>();
+        resultData.put("successCount", successCount);
+        if (!duplicateIds.isEmpty()) {
+            resultData.put("duplicateCustomerNames", getDuplicateCustomerNames(duplicateIds));
+        }
+        if (!errorMessages.isEmpty()) {
+            resultData.put("errorMessages", errorMessages);
+        }
+        return R.ok(resultData);
+    }
+
+    /**
+     * 根据客户ID列表获取重复客户名称
+     */
+    private String getDuplicateCustomerNames(List<Long> customerIds) {
+        if (customerIds == null || customerIds.isEmpty()) {
+            return "";
+        }
+        try {
+            List<CrmCustomer> customers = crmCustomerService.selectCrmCustomerListByIds(
+                    customerIds.stream().map(String::valueOf).collect(Collectors.joining(",")));
+            return customers.stream().map(CrmCustomer::getCustomerName).filter(Objects::nonNull).collect(Collectors.joining("、"));
+        } catch (Exception e) {
+            return customerIds.stream().map(String::valueOf).collect(Collectors.joining("、"));
+        }
+    }
 }

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

@@ -30,4 +30,9 @@ public class CrmCustomerCallLogServiceImpl implements ICrmCustomerCallLogService
     public List<CrmCustomerCallLog> selectCrmCustomerCallLogList(CrmCustomerCallLog crmCustomerCallLog) {
         return crmCustomerCallLogMapper.selectCrmCustomerCallLogList(crmCustomerCallLog);
     }
+
+    @Override
+    public Long selectSumBillingMinute(CrmCustomerCallLog crmCustomerCallLog) {
+        return crmCustomerCallLogMapper.selectSumBillingMinute(crmCustomerCallLog);
+    }
 }

+ 12 - 3
fs-service/src/main/java/com/fs/company/service/impl/call/node/AiCallTaskNode.java

@@ -114,8 +114,8 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
                 //拨通
                 if (condition.isCallConnected() && callRes.getCallTime() != null && callRes.getCallTime() > 0) {
                     //如果含有意向度过滤
-                    if (StringUtils.isNotBlank(condition.getIntention())) {
-                        if (condition.getIntention().equals(callRes.getIntention())) {
+                    if (condition.getIntention() != null && !condition.getIntention().isEmpty()) {
+                        if (condition.getIntention().contains(callRes.getIntention())) {
                             runNextNode(context, edge);
                             runnableCount++;
                             break;
@@ -132,6 +132,15 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
                 }
                 //未拨通
                 else if (!condition.isCallConnected() && (callRes.getCallTime() == null || Long.valueOf(0).equals(callRes.getCallTime()) || callRes.getCallAnswerTime() == null)) {
+                    //如果含有未拨通类型过滤
+                    if (condition.getHangupType() != null && !condition.getHangupType().isEmpty()) {
+                        if (StringUtils.isNotBlank(callRes.getHangupType()) && condition.getHangupType().contains(callRes.getHangupType())) {
+                            //匹配到未拨通类型,继续执行
+                        } else {
+                            log.info("流程:{},节点:{},未拨通类型设置:{},实际未拨通类型:{}, 未拨通类型不符设置中断执行,", context.getWorkflowInstanceId(), nodeKey, condition.getHangupType(), callRes.getHangupType());
+                            continue;
+                        }
+                    }
                     //延时操作
                     if (null != condition.getCallTime() && condition.getCallTime() > 0) {
                         //计算延时分片分钟
@@ -290,7 +299,7 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
         callbackInfo.put("workflowInstanceId", context.getWorkflowInstanceId());
         callbackInfo.put("calleeId", calleeId);
         super.redisCache.setCacheObject(EASYCALL_WORKFLOW_REDIS_KEY + callBackUuid,
-                callbackInfo.toJSONString(), 1, TimeUnit.DAYS);
+                callbackInfo.toJSONString(), 15, TimeUnit.DAYS);
         // 将 callBackUuid 写入 context,供后续回调时从 context 取用
         context.setVariable("callBackUuid", callBackUuid);
 

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

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

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

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

+ 23 - 0
fs-service/src/main/java/com/fs/company/vo/CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO.java

@@ -0,0 +1,23 @@
+package com.fs.company.vo;
+
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+/**
+ * 外呼记录详情导出(解密手机号)
+ */
+@Data
+public class CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO {
+
+    @Excel(name = "任务名称")
+    private String roboticName;
+
+    @Excel(name = "客户名称")
+    private String userName;
+
+    @Excel(name = "客户类型")
+    private String intention;
+
+    @Excel(name = "手机号")
+    private String phone;
+}

+ 19 - 0
fs-service/src/main/java/com/fs/company/vo/CompanyVoiceRoboticCallLogCallPhoneDecryptQueryVO.java

@@ -0,0 +1,19 @@
+package com.fs.company.vo;
+
+import lombok.Data;
+
+/**
+ * 外呼记录详情导出查询中间对象
+ */
+@Data
+public class CompanyVoiceRoboticCallLogCallPhoneDecryptQueryVO {
+
+    private String roboticName;
+
+    private String userName;
+
+    /** 客户类型字典值(存库为数字) */
+    private String intention;
+
+    private String phone;
+}

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

@@ -24,6 +24,9 @@ public class CompanyVoiceRoboticCallLogCallPhoneVO {
     @Excel(name = "caller_id")
     private Long callerId;
 
+    /** 客户ID(callees.user_id) */
+    private Long customerId;
+
     /** 记录调用时间 */
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     @Excel(name = "记录调用时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")

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

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

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

@@ -155,7 +155,7 @@ public class EasyCallCallPhoneVO {
     private String emptyNumberDetectionText;
 
     /**
-     * 客户意向
+     * 客户意向(EasyCall cc_call_phone 表原始字段 intent,多为等级字母或文案,需经字典转换为系统 intention)
      */
     private String intent;
 
@@ -213,4 +213,9 @@ public class EasyCallCallPhoneVO {
      * The duration of the manual agent service time.
      */
     private Long manualAnsweredTimeLen;
+
+    /**
+     * 未接通原因(新增)
+     */
+    private String unconnectedReason;
 }

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

@@ -16,4 +16,6 @@ public class EasyCallVoiceCodeVO {
     /** 声音源:aliyun_tts */
     private String voiceSource;
     private Integer priority;
+
+    private String ttsModels;
 }

+ 2 - 0
fs-service/src/main/java/com/fs/config/saas/ProjectConfig.java

@@ -169,6 +169,8 @@ public class ProjectConfig {
             private Boolean useRedis;
             private RedisConfig redisConfig;
             private List<Config> configs;
+            /** 微信公众号订阅消息模板ID列表,用于wx.requestSubscribeMessage() */
+            private List<String> subscribeTemplateIds;
 
             @Data
             public static class RedisConfig {

+ 11 - 0
fs-service/src/main/java/com/fs/config/tencent/TencentCOSClientConfig.java

@@ -1,5 +1,6 @@
 package com.fs.config.tencent;
 
+import com.github.binarywang.wxpay.service.impl.WxPayServiceImpl;
 import com.qcloud.cos.COSClient;
 import com.qcloud.cos.ClientConfig;
 import com.qcloud.cos.auth.BasicCOSCredentials;
@@ -9,8 +10,11 @@ import lombok.AllArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Lazy;
+import org.springframework.core.env.Environment;
 import org.springframework.stereotype.Component;
 
+import java.util.Objects;
+
 @Component
 @AllArgsConstructor
 @Slf4j
@@ -20,9 +24,16 @@ public class TencentCOSClientConfig {
     @Lazy
     private final TencentProperties tencentProperties;
 
+    private Environment environment;
+
     @Bean
     public COSClient createClient() {
         log.info("创建COSClient...");
+        if(Objects.equals(environment.getProperty("spring.profiles.active"), "dev-test")){
+            String secretId = "";
+            String secretKey = "";
+            String region = "";
+        }
         // 检查配置
         if (!tencentProperties.isConfigValid()) {
             log.error("腾讯云配置不完整,无法创建COS客户端");

+ 11 - 0
fs-service/src/main/java/com/fs/core/config/WxMpProperties.java

@@ -393,4 +393,15 @@ public class WxMpProperties {
     public ProjectConfig.Wx.Mp getWxMpConfig() {
         return getWxMpConfigFromDB();
     }
+
+    /**
+     * 获取订阅消息模板ID列表
+     */
+    public List<String> getSubscribeTemplateIds() {
+        ProjectConfig.Wx.Mp mpConfig = getWxMpConfigFromDB();
+        if (mpConfig == null || mpConfig.getSubscribeTemplateIds() == null) {
+            return new ArrayList<>();
+        }
+        return mpConfig.getSubscribeTemplateIds();
+    }
 }

+ 1 - 1
fs-service/src/main/java/com/fs/core/config/WxPayConfiguration.java

@@ -31,7 +31,7 @@ public class WxPayConfiguration {
     try {
       // 每次创建服务时都获取最新的配置
       log.info("创建微信支付服务,检查配置...");
-      if(Objects.equals(environment.getProperty("spring.profiles.active"), "dev")){
+      if(Objects.equals(environment.getProperty("spring.profiles.active"), "dev-test")){
         return new WxPayServiceImpl();
       }
 

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

@@ -198,12 +198,7 @@ public class CrmCustomer extends BaseEntity
     //    最后一次设置外呼记录id
     private Long lastEffectiveCallphoneLogId;
 
-    /** 是否可解密手机号 1=可解密 */
-    @TableField(exist = false)
+    /** 是否可解密手机号 1=已解密 0=未解密(默认) */
     private Integer canDecrypt;
 
-    /** 解密后的手机号明文 */
-    @TableField(exist = false)
-    private String decryptedMobile;
-
 }

+ 409 - 6
fs-service/src/main/java/com/fs/crm/mapper/CrmCustomerMapper.java

@@ -244,6 +244,9 @@ public interface CrmCustomerMapper extends BaseMapper<CrmCustomer> {
             "<if test = 'maps.tags != null and  maps.tags !=\"\"    '> " +
             "and find_in_set(#{maps.tags}, c.tags)   " +
             "</if>" +
+            "<if test = 'maps.customTag != null and maps.customTag != \"\"   '> " +
+            "and c.tags like CONCAT('%',#{maps.customTag},'%') " +
+            "</if>" +
             "<if test = 'maps.status != null      '> " +
             "and c.status =#{maps.status} " +
             "</if>" +
@@ -283,6 +286,12 @@ public interface CrmCustomerMapper extends BaseMapper<CrmCustomer> {
             "<if test = 'maps.deptId != null  and maps.deptId != 0 '> " +
             "AND (c.dept_id = #{maps.deptId} OR c.dept_id IN ( SELECT t.dept_id FROM company_dept t WHERE find_in_set(#{maps.deptId}, ancestors) )) " +
             "</if>" +
+            "<if test = 'maps.roboticId != null and maps.taskCustomerFilter != null and maps.taskCustomerFilter == \"notInTask\"   '> " +
+            "and c.customer_id NOT IN (SELECT ce.user_id FROM company_voice_robotic_callees ce WHERE ce.robotic_id = #{maps.roboticId}) " +
+            "</if>" +
+            "<if test = 'maps.roboticId != null and maps.taskCustomerFilter != null and maps.taskCustomerFilter == \"inTask\"   '> " +
+            "and c.customer_id IN (SELECT ce.user_id FROM company_voice_robotic_callees ce WHERE ce.robotic_id = #{maps.roboticId}) " +
+            "</if>" +
             "${maps.params.dataScope}"+
             " order by c.customer_id desc "+
             "</script>"})
@@ -1017,17 +1026,14 @@ public interface CrmCustomerMapper extends BaseMapper<CrmCustomer> {
      * callStatus: 0-全部, 1-已解密(有外呼记录), 2-未解密(无外呼记录)
      */
     @Select({"<script> " +
-            "SELECT c.*, CASE WHEN cdr.customer_id IS NOT NULL THEN 1 ELSE 0 END AS canDecrypt " +
-            "FROM crm_customer c " +
-            "LEFT JOIN (SELECT DISTINCT customer_id FROM ai_sip_call_outbound_cdr WHERE status = 0) cdr ON cdr.customer_id = c.customer_id " +
-            "WHERE c.effective_customer = 1 " +
+            "SELECT c.* FROM crm_customer c WHERE c.effective_customer = 1 " +
             "<if test='receiveUserId != null'> AND c.receive_user_id = #{receiveUserId} </if>" +
             "<if test='startTime != null and startTime != \"\"'> AND c.create_time &gt; #{startTime} </if>" +
             "<if test='endTime != null and endTime != \"\"'> AND c.create_time &lt;= #{endTime} </if>" +
             "<if test='mobile != null and mobile != \"\"'> AND c.mobile LIKE CONCAT('%', #{mobile}, '%') </if>" +
             "<if test='remark != null and remark != \"\"'> AND c.remark LIKE CONCAT('%', #{remark}, '%') </if>" +
-            "<if test='callStatus != null and callStatus == 1'> AND EXISTS (SELECT 1 FROM ai_sip_call_outbound_cdr cdr2 WHERE cdr2.customer_id = c.customer_id) </if>" +
-            "<if test='callStatus != null and callStatus == 2'> AND NOT EXISTS (SELECT 1 FROM ai_sip_call_outbound_cdr cdr2 WHERE cdr2.customer_id = c.customer_id) </if>" +
+            "<if test='callStatus != null and callStatus == 1'> AND c.can_decrypt = 1 </if>" +
+            "<if test='callStatus != null and callStatus == 2'> AND (c.can_decrypt IS NULL OR c.can_decrypt = 0) </if>" +
             " ORDER BY c.customer_id DESC " +
             "</script>"})
     List<CrmCustomer> selectEffectiveCustomerList(
@@ -1038,6 +1044,12 @@ public interface CrmCustomerMapper extends BaseMapper<CrmCustomer> {
             @Param("remark") String remark,
             @Param("callStatus") Integer callStatus);
 
+    /**
+     * 更新客户解密状态为已解锁
+     */
+    @Update("UPDATE crm_customer SET can_decrypt = 1 WHERE customer_id = #{customerId}")
+    int updateCanDecryptByCustomerId(@Param("customerId") Long customerId);
+
     /**
      * 批量插入客户
      *
@@ -1125,4 +1137,395 @@ public interface CrmCustomerMapper extends BaseMapper<CrmCustomer> {
      * @return CRM客户
      */
     CrmCustomer selectCrmCustomerByCallphoneLogId(@Param("callphoneLogId") Long callphoneLogId);
+
+    // ==================== 条件分配:统计和查询ID ====================
+
+    /** 公海客户条件统计 */
+    @Select({"<script> " +
+            "select count(1) from crm_customer c " +
+            "where c.is_line=0 and is_del=0 and c.is_pool=1 " +
+            "<if test = 'maps.companyId != null     '> " +
+            "and c.company_id =#{maps.companyId} " +
+            "</if>" +
+            "<if test = 'maps.address != null and  maps.address !=\"\"    '> " +
+            "and c.address like CONCAT('%',#{maps.address},'%') " +
+            "</if>" +
+            "<if test = 'maps.customerCode != null and  maps.customerCode !=\"\"    '> " +
+            "and c.customer_code like CONCAT(#{maps.customerCode},'%') " +
+            "</if>" +
+            "<if test = 'maps.customerName != null and  maps.customerName !=\"\"    '> " +
+            "and c.customer_name like CONCAT('%',#{maps.customerName},'%') " +
+            "</if>" +
+            "<if test = 'maps.mobile != null and  maps.mobile !=\"\"    '> " +
+            "and c.mobile like CONCAT('%',#{maps.mobile},'%') " +
+            "</if>" +
+            "<if test = 'maps.status != null and maps.status !=\"\"     '> " +
+            "and c.status =#{maps.status} " +
+            "</if>" +
+            "<if test = 'maps.visitStatus != null and maps.visitStatus !=\"\"     '> " +
+            "and c.visit_status IN " +
+            "<foreach collection=\"maps.visitStatus.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.customerType != null      '> " +
+            "and c.customer_type IN " +
+            "<foreach collection=\"maps.customerType.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.isReceive != null      '> " +
+            "and c.is_receive =#{maps.isReceive} " +
+            "</if>" +
+            "<if test = 'maps.source != null and maps.source !=\"\"      '> " +
+            "and c.source IN " +
+            "<foreach collection=\"maps.source.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.tags != null      '> " +
+            "and  " +
+            "<foreach collection=\"maps.tags.split(',')\" item=\"tag\"   open=\"(\" close=\")\" separator=\"OR\">" +
+            "find_in_set(#{tag},c.tags)" +
+            "</foreach> " +
+            "</if>" +
+            "<if test = 'maps.beginTime != null and maps.beginTime != \"\" '> " +
+            "and date_format(c.create_time,'%y%m%d') &gt;= date_format(#{maps.beginTime},'%y%m%d') " +
+            "</if>" +
+            "<if test = 'maps.endTime != null and maps.endTime != \"\" '> " +
+            "and date_format(c.create_time,'%y%m%d') &lt;= date_format(#{maps.endTime},'%y%m%d') " +
+            "</if>" +
+            "<if test = 'maps.deptId != null  and maps.deptId != 0 '> " +
+            "AND (c.dept_id = #{maps.deptId} OR c.dept_id IN ( SELECT t.dept_id FROM company_dept t WHERE find_in_set(#{maps.deptId}, ancestors) )) " +
+            "</if>" +
+            "${maps.params.dataScope}"+
+            "</script>"})
+    int countCrmFullCustomerByCondition(@Param("maps") CrmFullCustomerListQueryParam param);
+
+    /** 公海客户条件查询ID */
+    @Select({"<script> " +
+            "select c.customer_id from crm_customer c " +
+            "where c.is_line=0 and is_del=0 and c.is_pool=1 " +
+            "<if test = 'maps.companyId != null     '> " +
+            "and c.company_id =#{maps.companyId} " +
+            "</if>" +
+            "<if test = 'maps.address != null and  maps.address !=\"\"    '> " +
+            "and c.address like CONCAT('%',#{maps.address},'%') " +
+            "</if>" +
+            "<if test = 'maps.customerCode != null and  maps.customerCode !=\"\"    '> " +
+            "and c.customer_code like CONCAT(#{maps.customerCode},'%') " +
+            "</if>" +
+            "<if test = 'maps.customerName != null and  maps.customerName !=\"\"    '> " +
+            "and c.customer_name like CONCAT('%',#{maps.customerName},'%') " +
+            "</if>" +
+            "<if test = 'maps.mobile != null and  maps.mobile !=\"\"    '> " +
+            "and c.mobile like CONCAT('%',#{maps.mobile},'%') " +
+            "</if>" +
+            "<if test = 'maps.status != null and maps.status !=\"\"     '> " +
+            "and c.status =#{maps.status} " +
+            "</if>" +
+            "<if test = 'maps.visitStatus != null and maps.visitStatus !=\"\"     '> " +
+            "and c.visit_status IN " +
+            "<foreach collection=\"maps.visitStatus.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.customerType != null      '> " +
+            "and c.customer_type IN " +
+            "<foreach collection=\"maps.customerType.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.isReceive != null      '> " +
+            "and c.is_receive =#{maps.isReceive} " +
+            "</if>" +
+            "<if test = 'maps.source != null and maps.source !=\"\"      '> " +
+            "and c.source IN " +
+            "<foreach collection=\"maps.source.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.tags != null      '> " +
+            "and  " +
+            "<foreach collection=\"maps.tags.split(',')\" item=\"tag\"   open=\"(\" close=\")\" separator=\"OR\">" +
+            "find_in_set(#{tag},c.tags)" +
+            "</foreach> " +
+            "</if>" +
+            "<if test = 'maps.beginTime != null and maps.beginTime != \"\" '> " +
+            "and date_format(c.create_time,'%y%m%d') &gt;= date_format(#{maps.beginTime},'%y%m%d') " +
+            "</if>" +
+            "<if test = 'maps.endTime != null and maps.endTime != \"\" '> " +
+            "and date_format(c.create_time,'%y%m%d') &lt;= date_format(#{maps.endTime},'%y%m%d') " +
+            "</if>" +
+            "<if test = 'maps.deptId != null  and maps.deptId != 0 '> " +
+            "AND (c.dept_id = #{maps.deptId} OR c.dept_id IN ( SELECT t.dept_id FROM company_dept t WHERE find_in_set(#{maps.deptId}, ancestors) )) " +
+            "</if>" +
+            "${maps.params.dataScope}"+
+            " order by c.customer_id desc limit 1000 "+
+            "</script>"})
+    List<Long> selectCrmFullCustomerIdsByCondition(@Param("maps") CrmFullCustomerListQueryParam param);
+
+    /** 我的客户条件统计 */
+    @Select({"<script> " +
+            "select count(1) from crm_customer_user cu inner join crm_customer c on c.customer_user_id=cu.customer_user_id " +
+            "where cu.is_pool=0 " +
+            "<if test = 'maps.companyId != null     '> " +
+            "and cu.company_id =#{maps.companyId} " +
+            "</if>" +
+            "<if test = 'maps.companyUserId != null     '> " +
+            "and cu.company_user_id =#{maps.companyUserId} " +
+            "</if>" +
+            "<if test = 'maps.address != null and  maps.address !=\"\"    '> " +
+            "and c.address like CONCAT('%',#{maps.address},'%') " +
+            "</if>" +
+            "<if test = 'maps.customerCode != null and  maps.customerCode !=\"\"    '> " +
+            "and c.customer_code like CONCAT('%',#{maps.customerCode},'%') " +
+            "</if>" +
+            "<if test = 'maps.customerName != null and  maps.customerName !=\"\"    '> " +
+            "and c.customer_name like CONCAT('%',#{maps.customerName},'%') " +
+            "</if>" +
+            "<if test = 'maps.mobile != null and  maps.mobile !=\"\"    '> " +
+            "and c.mobile like CONCAT('%',#{maps.mobile},'%') " +
+            "</if>" +
+            "<if test = 'maps.status != null and maps.status !=\"\"     '> " +
+            "and cu.status IN " +
+            "<foreach collection=\"maps.status.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.visitStatus != null and maps.visitStatus !=\"\"'> " +
+            "and c.visit_status IN " +
+            "<foreach collection=\"maps.visitStatus.split(',')\" item='item' index='index' open='(' separator=',' close=')'> #{item} </foreach>" +
+            "</if>" +
+            "<if test = 'maps.isHisOrder != null and maps.isHisOrder==1      '> " +
+            "and (select ifnull(count(1),0) from crm_customer_his_order h where h.customer_id=c.customer_id )  &gt; 0 " +
+            "</if>" +
+            "<if test = 'maps.isHisOrder != null and maps.isHisOrder==0      '> " +
+            "and (select ifnull(count(1),0) from crm_customer_his_order h where h.customer_id=c.customer_id )  = 0 " +
+            "</if>" +
+            "<if test = 'maps.isManualCall != null and maps.isManualCall==1      '> " +
+            "and (select ifnull(count(1),0) from crm_customer_call_log ccl where ccl.customer_id=c.customer_id and ccl.call_time &gt; 0)  &gt; 0 " +
+            "</if>" +
+            "<if test = 'maps.isManualCall != null and maps.isManualCall==0      '> " +
+            "and (select ifnull(count(1),0) from crm_customer_call_log ccl where ccl.customer_id=c.customer_id and ccl.call_time &gt; 0)  = 0 " +
+            "</if>" +
+            "<if test = 'maps.customerType != null      '> " +
+            "and c.customer_type IN " +
+            "<foreach collection=\"maps.customerType.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.source != null and maps.source !=\"\"      '> " +
+            "and c.source IN " +
+            "<foreach collection=\"maps.source.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.tags != null and maps.tags!=\"\"     '> " +
+            "and  " +
+            "<foreach collection=\"maps.tags.split(',')\" item=\"tag\"   open=\"(\" close=\")\" separator=\"OR\">" +
+            "find_in_set(#{tag},c.tags)" +
+            "</foreach> " +
+            "</if>" +
+            "<if test = 'maps.customerCreateTime != null    '> " +
+            " AND date_format(c.create_time,'%y%m%d') &gt;= date_format(#{maps.customerCreateTime[0]},'%y%m%d') " +
+            " AND date_format(c.create_time,'%y%m%d') &lt;= date_format(#{maps.customerCreateTime[1]},'%y%m%d') " +
+            "</if>" +
+            "<if test = 'maps.beginTime != null and maps.beginTime != \"\" '> " +
+            "and date_format(c.receive_time,'%y%m%d') &gt;= date_format(#{maps.beginTime},'%y%m%d') " +
+            "</if>" +
+            "<if test = 'maps.endTime != null and maps.endTime != \"\" '> " +
+            "and date_format(c.receive_time,'%y%m%d') &lt;= date_format(#{maps.endTime},'%y%m%d') " +
+            "</if>" +
+            "</script>"})
+    int countCrmMyCustomerByCondition(@Param("maps") CrmMyCustomerListQueryParam param);
+
+    /** 我的客户条件查询ID */
+    @Select({"<script> " +
+            "select c.customer_id from crm_customer_user cu inner join crm_customer c on c.customer_user_id=cu.customer_user_id " +
+            "where cu.is_pool=0 " +
+            "<if test = 'maps.companyId != null     '> " +
+            "and cu.company_id =#{maps.companyId} " +
+            "</if>" +
+            "<if test = 'maps.companyUserId != null     '> " +
+            "and cu.company_user_id =#{maps.companyUserId} " +
+            "</if>" +
+            "<if test = 'maps.address != null and  maps.address !=\"\"    '> " +
+            "and c.address like CONCAT('%',#{maps.address},'%') " +
+            "</if>" +
+            "<if test = 'maps.customerCode != null and  maps.customerCode !=\"\"    '> " +
+            "and c.customer_code like CONCAT('%',#{maps.customerCode},'%') " +
+            "</if>" +
+            "<if test = 'maps.customerName != null and  maps.customerName !=\"\"    '> " +
+            "and c.customer_name like CONCAT('%',#{maps.customerName},'%') " +
+            "</if>" +
+            "<if test = 'maps.mobile != null and  maps.mobile !=\"\"    '> " +
+            "and c.mobile like CONCAT('%',#{maps.mobile},'%') " +
+            "</if>" +
+            "<if test = 'maps.status != null and maps.status !=\"\"     '> " +
+            "and cu.status IN " +
+            "<foreach collection=\"maps.status.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.visitStatus != null and maps.visitStatus !=\"\"'> " +
+            "and c.visit_status IN " +
+            "<foreach collection=\"maps.visitStatus.split(',')\" item='item' index='index' open='(' separator=',' close=')'> #{item} </foreach>" +
+            "</if>" +
+            "<if test = 'maps.isHisOrder != null and maps.isHisOrder==1      '> " +
+            "and (select ifnull(count(1),0) from crm_customer_his_order h where h.customer_id=c.customer_id )  &gt; 0 " +
+            "</if>" +
+            "<if test = 'maps.isHisOrder != null and maps.isHisOrder==0      '> " +
+            "and (select ifnull(count(1),0) from crm_customer_his_order h where h.customer_id=c.customer_id )  = 0 " +
+            "</if>" +
+            "<if test = 'maps.isManualCall != null and maps.isManualCall==1      '> " +
+            "and (select ifnull(count(1),0) from crm_customer_call_log ccl where ccl.customer_id=c.customer_id and ccl.call_time &gt; 0)  &gt; 0 " +
+            "</if>" +
+            "<if test = 'maps.isManualCall != null and maps.isManualCall==0      '> " +
+            "and (select ifnull(count(1),0) from crm_customer_call_log ccl where ccl.customer_id=c.customer_id and ccl.call_time &gt; 0)  = 0 " +
+            "</if>" +
+            "<if test = 'maps.customerType != null      '> " +
+            "and c.customer_type IN " +
+            "<foreach collection=\"maps.customerType.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.source != null and maps.source !=\"\"      '> " +
+            "and c.source IN " +
+            "<foreach collection=\"maps.source.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.tags != null and maps.tags!=\"\"     '> " +
+            "and  " +
+            "<foreach collection=\"maps.tags.split(',')\" item=\"tag\"   open=\"(\" close=\")\" separator=\"OR\">" +
+            "find_in_set(#{tag},c.tags)" +
+            "</foreach> " +
+            "</if>" +
+            "<if test = 'maps.customerCreateTime != null    '> " +
+            " AND date_format(c.create_time,'%y%m%d') &gt;= date_format(#{maps.customerCreateTime[0]},'%y%m%d') " +
+            " AND date_format(c.create_time,'%y%m%d') &lt;= date_format(#{maps.customerCreateTime[1]},'%y%m%d') " +
+            "</if>" +
+            "<if test = 'maps.beginTime != null and maps.beginTime != \"\" '> " +
+            "and date_format(c.receive_time,'%y%m%d') &gt;= date_format(#{maps.beginTime},'%y%m%d') " +
+            "</if>" +
+            "<if test = 'maps.endTime != null and maps.endTime != \"\" '> " +
+            "and date_format(c.receive_time,'%y%m%d') &lt;= date_format(#{maps.endTime},'%y%m%d') " +
+            "</if>" +
+            " order by cu.customer_user_id desc limit 1000 "+
+            "</script>"})
+    List<Long> selectCrmMyCustomerIdsByCondition(@Param("maps") CrmMyCustomerListQueryParam param);
+
+    /** 客户管理条件统计 */
+    @Select({"<script> " +
+            "select count(1) from crm_customer c " +
+            "where 1=1 " +
+            "<if test = 'maps.companyId != null     '> " +
+            "and c.company_id =#{maps.companyId} " +
+            "</if>" +
+            "<if test = 'maps.customerCode != null and  maps.customerCode !=\"\"    '> " +
+            "and c.customer_code like CONCAT('%',#{maps.customerCode},'%') " +
+            "</if>" +
+            "<if test = 'maps.customerName != null and  maps.customerName !=\"\"    '> " +
+            "and c.customer_name like CONCAT('%',#{maps.customerName},'%') " +
+            "</if>" +
+            "<if test = 'maps.mobile != null and  maps.mobile !=\"\"    '> " +
+            "and c.mobile like CONCAT('%',#{maps.mobile},'%') " +
+            "</if>" +
+            "<if test = 'maps.status != null and maps.status !=\"\"     '> " +
+            "and c.status =#{maps.status} " +
+            "</if>" +
+            "<if test = 'maps.visitStatus != null       '> " +
+            "and c.visit_status IN " +
+            "<foreach collection=\"maps.visitStatus.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.isBuy != null and maps.isBuy==1      '> " +
+            "and c.buy_count &gt; 0 " +
+            "</if>" +
+            "<if test = 'maps.isBuy != null and maps.isBuy==0      '> " +
+            "and (c.buy_count = 0 or c.buy_count is null) " +
+            "</if>" +
+            "<if test = 'maps.isHisOrder != null and maps.isHisOrder==1      '> " +
+            "and (select ifnull(count(1),0) from crm_customer_his_order h where h.customer_id=c.customer_id )  &gt; 0 " +
+            "</if>" +
+            "<if test = 'maps.isHisOrder != null and maps.isHisOrder==0      '> " +
+            "and (select ifnull(count(1),0) from crm_customer_his_order h where h.customer_id=c.customer_id )  = 0 " +
+            "</if>" +
+            "<if test = 'maps.customerType != null      '> " +
+            "and c.customer_type IN " +
+            "<foreach collection=\"maps.customerType.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.source != null and maps.source !=\"\"      '> " +
+            "and c.source IN " +
+            "<foreach collection=\"maps.source.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.isReceive != null      '> " +
+            "and c.is_receive =#{maps.isReceive} " +
+            "</if>" +
+            "<if test = 'maps.isLine != null      '> " +
+            "and c.is_line =#{maps.isLine} " +
+            "</if>" +
+            "<if test = 'maps.tags != null      '> " +
+            "and  " +
+            "<foreach collection=\"maps.tags.split(',')\" item=\"tag\"   open=\"(\" close=\")\" separator=\"OR\">" +
+            "find_in_set(#{tag},c.tags)" +
+            "</foreach> " +
+            "</if>" +
+            "<if test = 'maps.beginTime != null and maps.beginTime != \"\" '> " +
+            "and date_format(c.create_time,'%y%m%d') &gt;= date_format(#{maps.beginTime},'%y%m%d') " +
+            "</if>" +
+            "<if test = 'maps.endTime != null and maps.endTime != \"\" '> " +
+            "and date_format(c.create_time,'%y%m%d') &lt;= date_format(#{maps.endTime},'%y%m%d') " +
+            "</if>" +
+            "<if test = 'maps.deptId != null  and maps.deptId != 0 '> " +
+            "AND (c.dept_id = #{maps.deptId} OR c.dept_id IN ( SELECT t.dept_id FROM company_dept t WHERE find_in_set(#{maps.deptId}, ancestors) )) " +
+            "</if>" +
+            "${maps.params.dataScope}"+
+            "</script>"})
+    int countCrmCustomerByCondition(@Param("maps") CrmCustomerListQueryParam param);
+
+    /** 客户管理条件查询ID */
+    @Select({"<script> " +
+            "select c.customer_id from crm_customer c " +
+            "where 1=1 " +
+            "<if test = 'maps.companyId != null     '> " +
+            "and c.company_id =#{maps.companyId} " +
+            "</if>" +
+            "<if test = 'maps.customerCode != null and  maps.customerCode !=\"\"    '> " +
+            "and c.customer_code like CONCAT('%',#{maps.customerCode},'%') " +
+            "</if>" +
+            "<if test = 'maps.customerName != null and  maps.customerName !=\"\"    '> " +
+            "and c.customer_name like CONCAT('%',#{maps.customerName},'%') " +
+            "</if>" +
+            "<if test = 'maps.mobile != null and  maps.mobile !=\"\"    '> " +
+            "and c.mobile like CONCAT('%',#{maps.mobile},'%') " +
+            "</if>" +
+            "<if test = 'maps.status != null and maps.status !=\"\"     '> " +
+            "and c.status =#{maps.status} " +
+            "</if>" +
+            "<if test = 'maps.visitStatus != null       '> " +
+            "and c.visit_status IN " +
+            "<foreach collection=\"maps.visitStatus.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.isBuy != null and maps.isBuy==1      '> " +
+            "and c.buy_count &gt; 0 " +
+            "</if>" +
+            "<if test = 'maps.isBuy != null and maps.isBuy==0      '> " +
+            "and (c.buy_count = 0 or c.buy_count is null) " +
+            "</if>" +
+            "<if test = 'maps.isHisOrder != null and maps.isHisOrder==1      '> " +
+            "and (select ifnull(count(1),0) from crm_customer_his_order h where h.customer_id=c.customer_id )  &gt; 0 " +
+            "</if>" +
+            "<if test = 'maps.isHisOrder != null and maps.isHisOrder==0      '> " +
+            "and (select ifnull(count(1),0) from crm_customer_his_order h where h.customer_id=c.customer_id )  = 0 " +
+            "</if>" +
+            "<if test = 'maps.customerType != null      '> " +
+            "and c.customer_type IN " +
+            "<foreach collection=\"maps.customerType.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.source != null and maps.source !=\"\"      '> " +
+            "and c.source IN " +
+            "<foreach collection=\"maps.source.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.isReceive != null      '> " +
+            "and c.is_receive =#{maps.isReceive} " +
+            "</if>" +
+            "<if test = 'maps.isLine != null      '> " +
+            "and c.is_line =#{maps.isLine} " +
+            "</if>" +
+            "<if test = 'maps.tags != null      '> " +
+            "and  " +
+            "<foreach collection=\"maps.tags.split(',')\" item=\"tag\"   open=\"(\" close=\")\" separator=\"OR\">" +
+            "find_in_set(#{tag},c.tags)" +
+            "</foreach> " +
+            "</if>" +
+            "<if test = 'maps.beginTime != null and maps.beginTime != \"\" '> " +
+            "and date_format(c.create_time,'%y%m%d') &gt;= date_format(#{maps.beginTime},'%y%m%d') " +
+            "</if>" +
+            "<if test = 'maps.endTime != null and maps.endTime != \"\" '> " +
+            "and date_format(c.create_time,'%y%m%d') &lt;= date_format(#{maps.endTime},'%y%m%d') " +
+            "</if>" +
+            "<if test = 'maps.deptId != null  and maps.deptId != 0 '> " +
+            "AND (c.dept_id = #{maps.deptId} OR c.dept_id IN ( SELECT t.dept_id FROM company_dept t WHERE find_in_set(#{maps.deptId}, ancestors) )) " +
+            "</if>" +
+            "${maps.params.dataScope}"+
+            " order by c.customer_id desc limit 1000 "+
+            "</script>"})
+    List<Long> selectCrmCustomerIdsByCondition(@Param("maps") CrmCustomerListQueryParam param);
 }

+ 35 - 0
fs-service/src/main/java/com/fs/crm/param/CrmCustomerConditionAssignParam.java

@@ -0,0 +1,35 @@
+package com.fs.crm.param;
+
+import com.fs.crm.dto.CrmCustomerAssignUserDTO;
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 条件分配请求参数
+ * 根据筛选条件匹配客户进行批量分配,无需手动勾选
+ */
+@Data
+public class CrmCustomerConditionAssignParam extends BaseQueryParam {
+    /**
+     * 公司ID
+     */
+    private Long companyId;
+    /**
+     * 分配类型 2=公海分配 3=已领取转移
+     */
+    private Integer assignType;
+    /**
+     * 查询类型 "my" / "customer" / "full"
+     */
+    private String queryType;
+    /**
+     * 分配员工列表
+     */
+    private List<CrmCustomerAssignUserDTO> users;
+    /**
+     * 前端筛选条件(与列表查询接口参数一致)
+     */
+    private Map<String, Object> queryParams;
+}

+ 12 - 0
fs-service/src/main/java/com/fs/crm/param/CrmCustomerListQueryParam.java

@@ -103,5 +103,17 @@ public class CrmCustomerListQueryParam extends BaseQueryParam
     /** 结束时间 */
     private String endTime;
 
+    private Integer attritionLevel;
+
+    private String intentionDegree;
+
+    /** AI外呼任务ID,用于“已在任务”筛选 */
+    private Long roboticId;
+
+    /** 任务客户筛选类型:notInTask=未在任务(可追加),inTask=已在任务(不可追加),null=全部 */
+    private String taskCustomerFilter;
+
+    /** 自定义标签模糊搜索(LIKE '%customTag%') */
+    private String customTag;
 
 }

+ 25 - 0
fs-service/src/main/java/com/fs/crm/service/ICrmCustomerService.java

@@ -92,6 +92,12 @@ public interface ICrmCustomerService
 
     List<CrmCustomerListVO> selectCrmCustomerListQueryParam(CrmCustomerListQueryParam crmCustomer);
 
+    /**
+     * 为客户选择/追加客户列表回填今日/累计 × 手动/AI 的外呼次数(接通/总数)
+     * 采用二阶段批量聚合,请求数与页大小无关
+     */
+    void fillCallStats(List<CrmCustomerListVO> list);
+
     List<CrmMyCustomerListQueryVO> selectCrmMyCustomerListQuery(CrmMyCustomerListQueryParam param);
 
     List<CrmCustomerListQueryVO> selectCrmCustomerListQuery(CrmCustomerListQueryParam param);
@@ -177,4 +183,23 @@ public interface ICrmCustomerService
      * @return CRM客户
      */
     CrmCustomer selectCrmCustomerByCallphoneLogId(Long callphoneLogId);
+
+    /**
+     * 根据筛选条件统计客户数量
+     *
+     * @param queryType   查询类型 "my" / "customer" / "full"
+     * @param queryParams 前端筛选条件
+     * @return 符合条件的客户数量
+     */
+    int countByCondition(String queryType, Map<String, Object> queryParams);
+
+    /**
+     * 根据筛选条件批量分配客户
+     *
+     * @param operUserName 操作人姓名
+     * @param operUserId   操作人ID
+     * @param param        条件分配参数(含queryType、queryParams、users、assignType)
+     * @return 分配结果
+     */
+    R assignByCondition(String operUserName, Long operUserId, CrmCustomerConditionAssignParam param);
 }

+ 120 - 1
fs-service/src/main/java/com/fs/crm/service/impl/CrmCustomerServiceImpl.java

@@ -19,6 +19,8 @@ import com.fs.company.domain.CompanyUser;
 import com.fs.company.mapper.CompanyDeptMapper;
 import com.fs.company.mapper.CompanyMapper;
 import com.fs.company.mapper.CompanyUserMapper;
+import com.fs.company.mapper.CompanyVoiceRoboticCallLogCallphoneMapper;
+import com.fs.company.mapper.CrmCustomerCallLogMapper;
 import com.fs.crm.domain.*;
 import com.fs.crm.dto.CrmCustomerAssignCompanyDTO;
 import com.fs.crm.dto.CrmCustomerAssignUserDTO;
@@ -93,6 +95,12 @@ public class CrmCustomerServiceImpl extends ServiceImpl<CrmCustomerMapper, CrmCu
     @Autowired
     private IWxSopExecuteService wxSopExecuteService;
 
+    @Autowired
+    private CrmCustomerCallLogMapper crmCustomerCallLogMapper;
+
+    @Autowired
+    private CompanyVoiceRoboticCallLogCallphoneMapper companyVoiceRoboticCallLogCallphoneMapper;
+
     /**
      * 查询客户
      *
@@ -303,6 +311,57 @@ public class CrmCustomerServiceImpl extends ServiceImpl<CrmCustomerMapper, CrmCu
         return crmCustomerMapper.selectCrmCustomerListQueryParam(crmCustomer);
     }
 
+    @Override
+    public void fillCallStats(List<CrmCustomerListVO> list) {
+        if (list == null || list.isEmpty()) {
+            return;
+        }
+        List<Long> customerIds = list.stream()
+                .map(CrmCustomerListVO::getCustomerId)
+                .filter(Objects::nonNull)
+                .distinct()
+                .collect(Collectors.toList());
+        if (customerIds.isEmpty()) {
+            return;
+        }
+        Map<Long, CustomerCallStatVO> manualToday = toStatMap(crmCustomerCallLogMapper.selectManualCallStatToday(customerIds));
+        Map<Long, CustomerCallStatVO> manualTotal = toStatMap(crmCustomerCallLogMapper.selectManualCallStatTotal(customerIds));
+        Map<Long, CustomerCallStatVO> aiToday = toStatMap(companyVoiceRoboticCallLogCallphoneMapper.selectAiCallStatToday(customerIds));
+        Map<Long, CustomerCallStatVO> aiTotal = toStatMap(companyVoiceRoboticCallLogCallphoneMapper.selectAiCallStatTotal(customerIds));
+
+        for (CrmCustomerListVO vo : list) {
+            Long id = vo.getCustomerId();
+            CustomerCallStatVO mt = id == null ? null : manualToday.get(id);
+            vo.setTodayManualTotalCount(mt == null || mt.getTotalCount() == null ? 0 : mt.getTotalCount());
+            vo.setTodayManualConnectCount(mt == null || mt.getConnectCount() == null ? 0 : mt.getConnectCount());
+
+            CustomerCallStatVO ml = id == null ? null : manualTotal.get(id);
+            vo.setTotalManualTotalCount(ml == null || ml.getTotalCount() == null ? 0 : ml.getTotalCount());
+            vo.setTotalManualConnectCount(ml == null || ml.getConnectCount() == null ? 0 : ml.getConnectCount());
+
+            CustomerCallStatVO at = id == null ? null : aiToday.get(id);
+            vo.setTodayAiTotalCount(at == null || at.getTotalCount() == null ? 0 : at.getTotalCount());
+            vo.setTodayAiConnectCount(at == null || at.getConnectCount() == null ? 0 : at.getConnectCount());
+
+            CustomerCallStatVO al = id == null ? null : aiTotal.get(id);
+            vo.setTotalAiTotalCount(al == null || al.getTotalCount() == null ? 0 : al.getTotalCount());
+            vo.setTotalAiConnectCount(al == null || al.getConnectCount() == null ? 0 : al.getConnectCount());
+        }
+    }
+
+    private Map<Long, CustomerCallStatVO> toStatMap(List<CustomerCallStatVO> stats) {
+        if (stats == null || stats.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        Map<Long, CustomerCallStatVO> map = new HashMap<>(stats.size() * 2);
+        for (CustomerCallStatVO s : stats) {
+            if (s.getCustomerId() != null) {
+                map.put(s.getCustomerId(), s);
+            }
+        }
+        return map;
+    }
+
     @Override
     public List<CrmMyCustomerListQueryVO> selectCrmMyCustomerListQuery(CrmMyCustomerListQueryParam param) {
         return crmCustomerMapper.selectCrmMyCustomerListQuery(param);
@@ -587,7 +646,7 @@ public class CrmCustomerServiceImpl extends ServiceImpl<CrmCustomerMapper, CrmCu
         List<CrmCustomer> batchList = new ArrayList<>();
         List<CrmCompanyLineCustomerImportParam> validParams = new ArrayList<>();
         List<String> phoneList = PubFun.listToNewList(list, e -> PhoneUtil.encryptPhone(e.getMobile()));
-        List<CrmCustomer> crmList = crmCustomerMapper.selectList(new QueryWrapper<CrmCustomer>().in("mobile", phoneList));
+        List<CrmCustomer> crmList = crmCustomerMapper.selectList(new QueryWrapper<CrmCustomer>().in("mobile", phoneList).eq("company_id",companyId));
 
 
         for (CrmCompanyLineCustomerImportParam customer : list) {
@@ -1102,4 +1161,64 @@ public class CrmCustomerServiceImpl extends ServiceImpl<CrmCustomerMapper, CrmCu
         return crmCustomerMapper.selectCrmCustomerByCallphoneLogId(callphoneLogId);
     }
 
+    @Override
+    public int countByCondition(String queryType, Map<String, Object> queryParams) {
+        switch (queryType) {
+            case "full":
+                CrmFullCustomerListQueryParam fullParam = JSONObject.parseObject(
+                        JSONObject.toJSONString(queryParams), CrmFullCustomerListQueryParam.class);
+                return crmCustomerMapper.countCrmFullCustomerByCondition(fullParam);
+            case "my":
+                CrmMyCustomerListQueryParam myParam = JSONObject.parseObject(
+                        JSONObject.toJSONString(queryParams), CrmMyCustomerListQueryParam.class);
+                return crmCustomerMapper.countCrmMyCustomerByCondition(myParam);
+            case "customer":
+                CrmCustomerListQueryParam customerParam = JSONObject.parseObject(
+                        JSONObject.toJSONString(queryParams), CrmCustomerListQueryParam.class);
+                return crmCustomerMapper.countCrmCustomerByCondition(customerParam);
+            default:
+                throw new CustomException("不支持的查询类型: " + queryType);
+        }
+    }
+
+    @Override
+    @Transactional
+    public R assignByCondition(String operUserName, Long operUserId, CrmCustomerConditionAssignParam param) {
+        String queryType = param.getQueryType();
+        Map<String, Object> queryParams = param.getQueryParams();
+        List<Long> customerIds;
+
+        switch (queryType) {
+            case "full":
+                CrmFullCustomerListQueryParam fullParam = JSONObject.parseObject(
+                        JSONObject.toJSONString(queryParams), CrmFullCustomerListQueryParam.class);
+                customerIds = crmCustomerMapper.selectCrmFullCustomerIdsByCondition(fullParam);
+                break;
+            case "my":
+                CrmMyCustomerListQueryParam myParam = JSONObject.parseObject(
+                        JSONObject.toJSONString(queryParams), CrmMyCustomerListQueryParam.class);
+                customerIds = crmCustomerMapper.selectCrmMyCustomerIdsByCondition(myParam);
+                break;
+            case "customer":
+                CrmCustomerListQueryParam customerParam = JSONObject.parseObject(
+                        JSONObject.toJSONString(queryParams), CrmCustomerListQueryParam.class);
+                customerIds = crmCustomerMapper.selectCrmCustomerIdsByCondition(customerParam);
+                break;
+            default:
+                throw new CustomException("不支持的查询类型: " + queryType);
+        }
+
+        if (customerIds == null || customerIds.isEmpty()) {
+            return R.error("未找到符合条件的客户");
+        }
+
+        // 复用已有的 assignToUser 逻辑
+        CrmCustomeAssignParam assignParam = new CrmCustomeAssignParam();
+        assignParam.setCustomerIds(customerIds);
+        assignParam.setAssignType(param.getAssignType());
+        assignParam.setUsers(param.getUsers());
+        assignParam.setCompanyId(param.getCompanyId());
+        return assignToUser(operUserName, operUserId, assignParam);
+    }
+
 }

+ 24 - 0
fs-service/src/main/java/com/fs/crm/vo/CrmCustomerListVO.java

@@ -129,4 +129,28 @@ public class CrmCustomerListVO implements Serializable
     @Excel(name = "进线客户提交日期" )
     private String registerSubmitTime;
 
+    /** 今日手动外呼总数 */
+    private Integer todayManualTotalCount;
+
+    /** 今日手动外呼接通数(call_time>0) */
+    private Integer todayManualConnectCount;
+
+    /** 今日AI外呼总数 */
+    private Integer todayAiTotalCount;
+
+    /** 今日AI外呼接通数(call_time>0) */
+    private Integer todayAiConnectCount;
+
+    /** 累计手动外呼总数 */
+    private Integer totalManualTotalCount;
+
+    /** 累计手动外呼接通数(call_time>0) */
+    private Integer totalManualConnectCount;
+
+    /** 累计AI外呼总数 */
+    private Integer totalAiTotalCount;
+
+    /** 累计AI外呼接通数(call_time>0) */
+    private Integer totalAiConnectCount;
+
 }

+ 26 - 0
fs-service/src/main/java/com/fs/crm/vo/CustomerCallStatVO.java

@@ -0,0 +1,26 @@
+package com.fs.crm.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 客户外呼统计聚合结果(按 customer_id 分组)
+ *
+ * 用于客户选择/追加客户列表中按批量聚合方式回填
+ * 今日/累计 × 手动/AI 的外呼次数(接通/总数)。
+ */
+@Data
+public class CustomerCallStatVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 客户ID */
+    private Long customerId;
+
+    /** 总数 */
+    private Integer totalCount;
+
+    /** 接通数(call_time > 0) */
+    private Integer connectCount;
+}

+ 3 - 2
fs-service/src/main/java/com/fs/fastGpt/domain/FastGptChatConversation.java

@@ -15,8 +15,9 @@ public class FastGptChatConversation {
     private String isRepository;
     private String userContent;
     private String aiContent;
-    //向量知识库检索结果
-    private List<Map<String,String>> knowledgeBase;
+    //向量知识库检索结果(FastGPT 知识库搜索引用合并组件兼容格式)
+    //字段: id / q / a / sourceName / sourceId / datasetId / collectionId / chunkIndex / score([{type,value,index}])
+    private List<Map<String,Object>> knowledgeBase;
     //企微标签
     /**
      * List<Map<分组名,Map<标签名,标签id>>>

+ 141 - 63
fs-service/src/main/java/com/fs/fastGpt/service/impl/AiHookServiceImpl.java

@@ -1548,7 +1548,7 @@ public class AiHookServiceImpl implements AiHookService {
             fastGptChatSession.setNickName(qwExternalContacts.getName());
             fastGptChatSession.setCompanyId(user.getCompanyId());
             fastGptChatSession.setLastTime(new Date());
-            fastGptChatSession.setIsReply(0);
+            fastGptChatSession.setIsReply(1);
             fastGptChatSession.setUserId(String.valueOf(dto.getUser_id()));
             fastGptChatSessionMapper.insertFastGptChatSession(fastGptChatSession);
             addUserSex(qwExternalContacts);
@@ -1557,7 +1557,7 @@ public class AiHookServiceImpl implements AiHookService {
                 FastGptChatSession ss = new FastGptChatSession();
                 ss.setSessionId(fastGptChatSession.getSessionId());
                 ss.setRemindStatus(0);
-                ss.setIsReply(0);
+                ss.setIsReply(1);
                 ss.setUserId(String.valueOf(dto.getUser_id()));
                 fastGptChatSessionMapper.updateFastGptChatSession(ss);
             }
@@ -1818,7 +1818,7 @@ public class AiHookServiceImpl implements AiHookService {
         FastGptChatConversation conversation = new FastGptChatConversation();
         conversation.setUserInfo(new com.alibaba.fastjson.JSONObject());
         conversation.setHistory(new com.alibaba.fastjson.JSONArray());
-        List<Map<String, String>> knowledgeBase = new ArrayList<>();
+        List<Map<String, Object>> knowledgeBase = new ArrayList<>();
 
         if(role.getReminderWords() != null && !role.getReminderWords().isEmpty()){
             conversation.setAiInfo(role.getReminderWords());
@@ -1887,9 +1887,9 @@ public class AiHookServiceImpl implements AiHookService {
             conversation.setHistory(historyArray);
         }
 
-        //从向量知识库中检索相关内容
+        //从向量知识库中检索相关内容【输出与 FastGPT 知识库搜索引用合并组件兼容的引用数据,以便与固定知识库数据合并输出】
         if (count != null && !count.trim().isEmpty()) {
-            String searchQuery = contextQuery != null ? contextQuery : count;
+            String searchQuery = (contextQuery != null && !contextQuery.trim().isEmpty()) ? contextQuery : count;
             log.info("知识库检索查询文本 | original={} | contextQuery={}", count, searchQuery);
             try {
                 com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.fs.company.domain.AiKnowledgeBase> lqw =
@@ -1899,60 +1899,38 @@ public class AiHookServiceImpl implements AiHookService {
                 if (kbList == null || kbList.isEmpty()) {
                     log.warn("向量知识库检索跳过: 当前租户下无知识库 | roleId={}", role.getRoleId());
                 } else {
-                    Set<Long> addedIds = new HashSet<>();
+                    // 1、向量只生成一次,避免 KB 循环内重复调用 embedding接口
+                    List<Float> vector = createEmbedding(searchQuery);
+                    // 2、关键词一次性汇总,避免重复抽取
+                    List<String> ctxKeywords = extractKeywords(searchQuery);
+                    List<String> curKeywords = extractKeywords(count);
+                    Set<String> allKeywords = new LinkedHashSet<>(curKeywords);
+                    allKeywords.addAll(ctxKeywords);
+                    // 3、以 collectionId+pointId 作为去重主键,重复命中保留最高分
+                    Map<String, Map<String, Object>> mergedRefMap = new LinkedHashMap<>();
                     for (com.fs.company.domain.AiKnowledgeBase kb : kbList) {
                         String collectionName = kb.getCollectionName();
                         if (collectionName == null || collectionName.trim().isEmpty()) {
                             continue;
                         }
-
-                        // 第一路:向量语义搜索(使用上下文查询)
-                        List<Float> vector = createEmbedding(searchQuery);
+                        // 路一:向量语义召回
                         if (vector != null && !vector.isEmpty()) {
-                            List<Map<String, Object>> searchResults = searchQdrant(collectionName, vector, 3, 0.3);
-                            if (searchResults != null) {
-                                for (Map<String, Object> item : searchResults) {
-                                    Long pointId = extractPointId(item);
-                                    if (pointId != null && addedIds.contains(pointId)) {
-                                        continue;
-                                    }
-                                    if (pointId != null) {
-                                        addedIds.add(pointId);
-                                    }
-                                    Map<String, String> kbItem = extractPayloadItem(item);
-                                    if (!kbItem.isEmpty()) {
-                                        knowledgeBase.add(kbItem);
-                                    }
-                                }
-                            }
+                            List<Map<String, Object>> hits = searchQdrant(collectionName, vector, KB_VECTOR_TOP_K, KB_SCORE_THRESHOLD);
+                            mergeRefItems(mergedRefMap, hits, kb, "embedding");
                         }
-
-                        // 第二路:Payload关键词过滤搜索(从上下文和当前消息中提取关键词)
-                        List<String> keywords = extractKeywords(searchQuery);
-                        List<String> currentKeywords = extractKeywords(count);
-                        Set<String> allKeywords = new LinkedHashSet<>(currentKeywords);
-                        allKeywords.addAll(keywords);
+                        // 路二:Payload 关键词全文检索召回
                         if (!allKeywords.isEmpty()) {
                             for (String keyword : allKeywords) {
-                                List<Map<String, Object>> filterResults = searchQdrantByPayload(collectionName, keyword, 10);
-                                if (filterResults != null) {
-                                    for (Map<String, Object> item : filterResults) {
-                                        Long pointId = extractPointId(item);
-                                        if (pointId != null && addedIds.contains(pointId)) {
-                                            continue;
-                                        }
-                                        if (pointId != null) {
-                                            addedIds.add(pointId);
-                                        }
-                                        Map<String, String> kbItem = extractPayloadItem(item);
-                                        if (!kbItem.isEmpty()) {
-                                            knowledgeBase.add(kbItem);
-                                        }
-                                    }
-                                }
+                                List<Map<String, Object>> hits = searchQdrantByPayload(collectionName, keyword, KB_KEYWORD_TOP_K);
+                                mergeRefItems(mergedRefMap, hits, kb, "fullText");
                             }
                         }
                     }
+                    // 4、按 score 降序、截取 topN,输出与 FastGPT 引用合并组件兼容的数组
+                    knowledgeBase = mergedRefMap.values().stream()
+                            .sorted((a, b) -> Double.compare(toScore(b.get("score")), toScore(a.get("score"))))
+                            .limit(KB_FINAL_TOP_N)
+                            .collect(Collectors.toList());
                 }
             } catch (Exception e) {
                 log.error("向量知识库检索失败 | roleId={} | content={}", role.getRoleId(), count, e);
@@ -1978,29 +1956,129 @@ public class AiHookServiceImpl implements AiHookService {
         messageList.add(message1);
     }
 
+    //========向量知识库检索参数阈值(调优入口)========
+    /** 向量召回每个 collection 取 topK */
+    private static final int KB_VECTOR_TOP_K = 5;
+    /** 关键词召回每个 keyword 取 topK */
+    private static final int KB_KEYWORD_TOP_K = 5;
+    /** 向量召回分数阈值 */
+    private static final double KB_SCORE_THRESHOLD = 0.4D;
+    /** 最终输出到 conversation.knowledgeBase 的最大条数 */
+    private static final int KB_FINAL_TOP_N = 10;
+
+    /**
+     * 合并 Qdrant 检索结果到去重 map(同一 pointId 保留最高分)。
+     */
+    private void mergeRefItems(Map<String, Map<String, Object>> merged,
+                               List<Map<String, Object>> hits,
+                               com.fs.company.domain.AiKnowledgeBase kb,
+                               String searchMode) {
+        if (hits == null || hits.isEmpty()) {
+            return;
+        }
+        for (Map<String, Object> hit : hits) {
+            Map<String, Object> ref = toFastGptRefItem(hit, kb, searchMode);
+            if (ref == null) {
+                continue;
+            }
+            String dedupKey = buildDedupKey(ref);
+            Map<String, Object> exist = merged.get(dedupKey);
+            if (exist == null || toScore(ref.get("score")) > toScore(exist.get("score"))) {
+                merged.put(dedupKey, ref);
+            }
+        }
+    }
+
+    /**
+     * 将单条 Qdrant 检索结果转换为 FastGPT 知识库搜索引用合并组件兼容的引用项。
+     */
+    private Map<String, Object> toFastGptRefItem(Map<String, Object> hit,
+                                                 com.fs.company.domain.AiKnowledgeBase kb,
+                                                 String searchMode) {
+        if (hit == null) {
+            return null;
+        }
+        Object payloadObj = hit.get("payload");
+        if (!(payloadObj instanceof Map)) {
+            return null;
+        }
+        Map<?, ?> payload = (Map<?, ?>) payloadObj;
+        Object qObj = payload.get("q");
+        Object aObj = payload.get("a");
+        if (qObj == null && aObj == null) {
+            return null;
+        }
+
+        Map<String, Object> ref = new LinkedHashMap<>();
+        Object id = hit.get("id");
+        ref.put("id", id == null ? "" : id.toString());
+        ref.put("q", qObj == null ? "" : qObj.toString());
+        ref.put("a", aObj == null ? "" : aObj.toString());
+        // FastGPT 引用合并组件需要的源信息
+        ref.put("sourceName", kb.getName() == null ? "" : kb.getName());
+        ref.put("sourceId", kb.getId() == null ? "" : kb.getId().toString());
+        ref.put("datasetId", kb.getCollectionId() == null ? "" : kb.getCollectionId());
+        ref.put("collectionId", kb.getCollectionName() == null ? "" : kb.getCollectionName());
+        Object chunkIndex = payload.get("chunkIndex");
+        ref.put("chunkIndex", chunkIndex instanceof Number ? ((Number) chunkIndex).intValue() : 0);
+
+        // FastGPT score 采用数组结构:[{type, value, index}]
+        double scoreVal = toScore(hit.get("score"));
+        List<Map<String, Object>> scoreArr = new ArrayList<>(1);
+        Map<String, Object> scoreItem = new LinkedHashMap<>();
+        scoreItem.put("type", searchMode);
+        scoreItem.put("value", scoreVal);
+        scoreItem.put("index", 0);
+        scoreArr.add(scoreItem);
+        ref.put("score", scoreArr);
+        return ref;
+    }
+
+    /** 以 collectionId+pointId 构建去重键,没有 id 时退化为 q+a 文本哈希 */
+    private String buildDedupKey(Map<String, Object> ref) {
+        Object collectionId = ref.get("collectionId");
+        Object id = ref.get("id");
+        if (id != null && !id.toString().isEmpty()) {
+            return collectionId + ":" + id;
+        }
+        return collectionId + ":" + ref.get("q") + ":" + ref.get("a");
+    }
+
+    /** 提取分数,兼容 Number 与 FastGPT score 数组结构 */
+    private double toScore(Object scoreObj) {
+        if (scoreObj instanceof Number) {
+            return ((Number) scoreObj).doubleValue();
+        }
+        if (scoreObj instanceof List) {
+            double max = 0D;
+            for (Object o : (List<?>) scoreObj) {
+                if (o instanceof Map) {
+                    Object v = ((Map<?, ?>) o).get("value");
+                    if (v instanceof Number) {
+                        double dv = ((Number) v).doubleValue();
+                        if (dv > max) {
+                            max = dv;
+                        }
+                    }
+                }
+            }
+            return max;
+        }
+        return 0D;
+    }
+
     private Long extractPointId(Map<String, Object> item) {
         Object id = item.get("id");
         if (id instanceof Number) {
             return ((Number) id).longValue();
         }
-        return null;
-    }
-
-    private Map<String, String> extractPayloadItem(Map<String, Object> item) {
-        Map<String, String> kbItem = new HashMap<>();
-        Object payloadObj = item.get("payload");
-        if (payloadObj instanceof Map) {
-            Map<?, ?> payload = (Map<?, ?>) payloadObj;
-            Object qObj = payload.get("q");
-            Object aObj = payload.get("a");
-            if (qObj != null) {
-                kbItem.put("q", qObj.toString());
-            }
-            if (aObj != null) {
-                kbItem.put("a", aObj.toString());
+        if (id != null) {
+            try {
+                return Long.parseLong(id.toString());
+            } catch (NumberFormatException ignore) {
             }
         }
-        return kbItem;
+        return null;
     }
 
     private List<String> extractKeywords(String text) {

+ 20 - 5
fs-service/src/main/java/com/fs/fastgptApi/util/AudioUtils.java

@@ -44,10 +44,25 @@ public class AudioUtils {
     }
 
     /**
-     * 工具地址
+     * 工具地址(历史部署默认 c:\ffmpeg.exe)
      **/
     static String path = "c:\\";
     static String destinationDir = "c:\\hook\\";
+
+    /**
+     * 解析 ffmpeg 可执行文件:优先环境变量 FFMPEG_PATH,其次 c:\ffmpeg.exe,最后使用 PATH 中的 ffmpeg。
+     */
+    private static String resolveFfmpegExecutable() {
+        String envPath = System.getenv("FFMPEG_PATH");
+        if (envPath != null && !envPath.trim().isEmpty()) {
+            return envPath.trim();
+        }
+        File legacy = new File(path + "ffmpeg.exe");
+        if (legacy.isFile()) {
+            return legacy.getAbsolutePath();
+        }
+        return "ffmpeg";
+    }
     public static AudioVO createVoiceUrl(Long id,String userVoiceUrl){
         String fileUrl = staticAiHostProper.getVoiceApi() + "/app/common/createVoiceUrl?id=" + id + "&userVoiceUrl=" + userVoiceUrl;
 
@@ -232,7 +247,7 @@ public class AudioUtils {
             String WAVPath = destinationDir + "WAV_" + time + ".wav";
             // 构建FFmpeg命令
             String[] command = {
-                    path + "ffmpeg.exe",
+                    resolveFfmpegExecutable(),
                     "-i", audioFilePath,WAVPath
             };
             log.info(command[2]);
@@ -254,7 +269,7 @@ public class AudioUtils {
             // 构建FFmpeg命令
             String[] command = {
                     "cmd", "/c", "start", "/b",
-                    path + "ffmpeg.exe",
+                    resolveFfmpegExecutable(),
                     "-i", audioFilePath
             };
             // 启动进程执行命令
@@ -518,7 +533,7 @@ public class AudioUtils {
         commend.add("/c");
         commend.add("start");
         commend.add("/b");
-        commend.add(path + "ffmpeg.exe");
+        commend.add(resolveFfmpegExecutable());
         commend.add("-y");
         commend.add("-i");
         commend.add(fpath);
@@ -550,7 +565,7 @@ public class AudioUtils {
         Process process = null;
         try {
             List<String> command = new ArrayList<String>();
-            command.add(path + "ffmpeg.exe");
+            command.add(resolveFfmpegExecutable());
             command.add("-y");
             command.add("-i");
             command.add(fpath);

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

@@ -105,8 +105,6 @@ public class FsUserScrmServiceImpl implements IFsUserScrmService
     @Autowired
     private ICompanyUserCacheService companyUserCacheService;
     @Autowired
-    private ICompanyUserService companyUserService;
-    @Autowired
     private FsUserCourseVideoMapper userCourseVideoMapper;
 
     @Autowired

+ 4 - 0
fs-service/src/main/java/com/fs/qw/mapper/QwExternalContactMapper.java

@@ -665,4 +665,8 @@ public interface QwExternalContactMapper extends BaseMapper<QwExternalContact> {
                                                                   @Param("corpId")String corpId,@Param("qwUserId") String qwUserId);
 
     void updateQwExternalContactByExternalUserIdAndUserId(@Param("map") QwExternalContact externalContact);
+
+    List<QwExternalContact> selectQwExternalContactListBycreateTime(QwExternalContact qwExternalContact);
+
+    List<QwExternalContact> selectQwExternalContactListByCreateTimeNew(QwExternalContact qwExternalContact);
 }

+ 3 - 3
fs-service/src/main/java/com/fs/qw/mapper/QwGroupChatMapper.java

@@ -48,7 +48,7 @@ public interface QwGroupChatMapper
             "FROM " +
             "    qw_group_chat gc " +
             "LEFT JOIN qw_group_chat_user gcu ON gc.chat_id = gcu.chat_id  " +
-            " left join qw_user qu on gc.owner=qu.qw_user_id  and qu.is_del=0 and qu.corp_id=gc.corp_id " +
+            " left join qw_user qu on gc.owner=qu.qw_open_user_id  and qu.is_del=0 and qu.corp_id=gc.corp_id " +
             " left join company_user cu on cu.qw_user_id=qu.id " +
             "    AND gc.corp_id = gcu.corp_id  " +
             "<where> " +
@@ -117,8 +117,8 @@ public interface QwGroupChatMapper
     @Select("select chat_id,name from qw_group_chat where  corp_id=#{corpId}")
     List<QwGroupChatOptionsVO> selectGroupChatOptionsVOList(String corpId);
 
-    @Select("select chat_id,name from qw_group_chat where corp_id = #{corpId} and find_in_set(owner,#{qwUserIds})")
-    List<QwGroupChatOptionsVO> listAllByQwUserList(@Param("qwUserIds") String qwUserIds, @Param("corpId") String corpId);
+    @Select("select chat_id,name from qw_group_chat where corp_id = #{corpId} and find_in_set(owner,#{qwOpenUserIds})")
+    List<QwGroupChatOptionsVO> listAllByQwUserList(@Param("qwOpenUserIds") String qwOpenUserIds, @Param("corpId") String corpId);
 
     List<QwGroupChat> selectQwGroupChatByChatIds(@Param("ids") String[] ids);
 

+ 5 - 2
fs-service/src/main/java/com/fs/qw/mapper/QwUserMapper.java

@@ -50,7 +50,7 @@ public interface QwUserMapper extends BaseMapper<QwUser>
     @Select("select company_user_id,company_id,welcome_text,qw_user_name,send_msg_type from qw_user where id = #{id}")
     public QwUser selectQwUserByIdByWeComeText(@Param("id") Long id);
 
-    @Select("select * from qw_user where qw_user_id = #{qwUserId} and corp_id = #{corpId} ")
+    @Select("select * from qw_user where qw_open_user_id = #{qwUserId} and corp_id = #{corpId} ")
     public QwUser selectQwUserByIdByWeComeText2(@Param("qwUserId") String qwUserId, @Param("corpId") String corpId);
     /**
      * 根据companyUserId查询企微用户
@@ -508,7 +508,7 @@ public interface QwUserMapper extends BaseMapper<QwUser>
     @Select("select * from qw_user where qw_user_id=#{qwUserId} and corp_id =#{corpId} limit 1")
     QwUser selectQwUserEntityByQwUserIdAndCorId(@Param("qwUserId")String qwUserId,@Param("corpId") String corpId);
 
-    @Select("select * from qw_user where ipad_status = 1 and corp_id=#{corpId} and qw_user_id=#{qwUserId} limit 1 ")
+    @Select("select * from qw_user where ipad_status = 1 and corp_id=#{corpId} and qw_open_user_id=#{qwUserId} limit 1 ")
     QwUser selectQwUserAppKeyAndIdByCorpIdAndUserIdAndIpad(@Param("corpId")String corpId,@Param("qwUserId") String qwUserId);
 
     // 批量查询
@@ -521,4 +521,7 @@ public interface QwUserMapper extends BaseMapper<QwUser>
     // 批量更新
     int batchUpdateQwUser(@Param("list") List<QwUser> users);
 
+    @Select("select group_concat(qw_open_user_id) from qw_user where corp_id = #{corpId} and find_in_set(qw_user_id, #{qwUserIds})")
+    String selectQwOpenUserIdByQwUserIdAndCorpId(@Param("qwUserIds") String qwUserIds, @Param("corpId") String corpId);
+
 }

+ 33 - 1
fs-service/src/main/java/com/fs/qw/service/AsyncQwAiChatSopService.java

@@ -1,8 +1,11 @@
 package com.fs.qw.service;
 
 import com.alibaba.fastjson.JSON;
+import com.fs.common.config.RedisTenantContext;
+import com.fs.common.core.domain.model.TenantPrincipal;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.date.DateUtil;
+import com.fs.common.utils.spring.SpringUtils;
 import com.fs.company.service.ICompanyMiniappService;
 import com.fs.course.config.CourseConfig;
 import com.fs.course.domain.FsCourseLink;
@@ -30,14 +33,19 @@ import com.fs.sop.params.QwSopAutoByTags;
 import com.fs.sop.service.IQwSopTempVoiceService;
 import com.fs.sop.service.impl.SopUserLogsInfoServiceImpl;
 import com.fs.system.service.ISysConfigService;
+import com.fs.tenant.mapper.TenantInfoMapper;
 import com.fs.voice.utils.StringUtil;
+import com.fs.wxcid.utils.TenantHelper;
 import lombok.AllArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.scheduling.annotation.Async;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.stereotype.Service;
 
+import java.lang.reflect.Method;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.LocalTime;
@@ -47,6 +55,7 @@ import java.util.Collections;
 import java.util.Date;
 import java.util.List;
 
+
 @Slf4j
 @Service
 @AllArgsConstructor
@@ -92,11 +101,34 @@ public class AsyncQwAiChatSopService {
     @Autowired
     private QwExternalContactInfoMapper qwExternalContactInfoMapper;
 
+    @Autowired
+    private TenantInfoMapper tenantInfoMapper;
+
     @Async("threadPoolTaskExecutor")
     public void executeQwAiChatSop(QwSopAutoByTags qwSopAutoByTags, String userID,
                                    QwUser qwUser, String externalUserID, String externalContactName,
                                    Long externalId, Long fsUserId, LocalDate currentDate, LocalTime localTime) {
-
+        Long tenantId = RedisTenantContext.getTenantId();
+        if (tenantId != null) {
+            try {
+                TenantHelper.setTenantId(tenantId);
+                Object manager = SpringUtils.getBean("tenantDataSourceManager");
+                Method method = manager.getClass().getMethod("ensureSwitchByTenantId", Long.class);
+                method.invoke(manager, tenantId);
+                // 设置租户到 SecurityContext,供 TenantKeyRedisSerializer 自动为 Redis Key 加 tenantid 前缀
+                SecurityContextHolder.getContext().setAuthentication(
+                        new UsernamePasswordAuthenticationToken(
+                                new TenantPrincipal(TenantHelper.getTenantId()),
+                                null,
+                                Collections.emptyList()
+                        )
+                );
+                // 切换 Redis 租户上下文
+                RedisTenantContext.setTenantId(TenantHelper.getTenantId());
+            } catch (Exception e) {
+                log.error("callerResult4EasyCall 切换租户数据源失败: tenantId={}", TenantHelper.getTenantId(), e);
+            }
+        }
 
         QwExternalContact contact;
         if(externalId != null){

+ 4 - 0
fs-service/src/main/java/com/fs/qw/service/IQwExternalContactService.java

@@ -265,4 +265,8 @@ public interface IQwExternalContactService extends IService<QwExternalContact> {
     List<QwMandatoryRegistrParam> selectQwExternalContactMandatoryRegistrationByIds(String corpId);
 
     int batchUpdateQwExternalContactMandatoryRegistration(List<QwMandatoryRegistrParam> batchList);
+
+    List<QwExternalContact> selectQwExternalContactListBycreateTime(QwExternalContact qwExternalContact);
+
+    List<QwExternalContact> selectQwExternalContactListByCreateTimeNew(QwExternalContact qwExternalContact);
 }

+ 14 - 0
fs-service/src/main/java/com/fs/qw/service/impl/QwExternalContactServiceImpl.java

@@ -77,7 +77,9 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
 import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -216,6 +218,7 @@ public class QwExternalContactServiceImpl extends ServiceImpl<QwExternalContactM
     @Autowired
     private QwExternalContactTransferCompanyAuditUserMapper transferCompanyAuditUserMapper;
 
+    @Lazy
     @Autowired
     private AsyncQwAiChatSopService asyncQwAiChatSopService;
     @Autowired
@@ -6042,6 +6045,17 @@ public class QwExternalContactServiceImpl extends ServiceImpl<QwExternalContactM
     public int batchUpdateQwExternalContactMandatoryRegistration(List<QwMandatoryRegistrParam> batchList) {
         return qwExternalContactMapper.batchUpdateQwExternalContactMandatoryRegistration( batchList);
     }
+
+    @Override
+    public List<QwExternalContact> selectQwExternalContactListBycreateTime(QwExternalContact qwExternalContact) {
+        return qwExternalContactMapper.selectQwExternalContactListBycreateTime(qwExternalContact);
+    }
+
+    @Override
+    public List<QwExternalContact> selectQwExternalContactListByCreateTimeNew(QwExternalContact qwExternalContact) {
+        return qwExternalContactMapper.selectQwExternalContactListByCreateTimeNew(qwExternalContact);
+    }
+
     @Override
     public R getRepeat(RepeatParam param) {
         List<QwExternalContact> list = qwExternalContactMapper.selectList(new QueryWrapper<QwExternalContact>().eq("external_user_id", param.getExternalUserId()));

+ 5 - 1
fs-service/src/main/java/com/fs/qw/service/impl/QwGroupChatServiceImpl.java

@@ -413,7 +413,11 @@ public class QwGroupChatServiceImpl implements IQwGroupChatService
 
     @Override
     public List<QwGroupChatOptionsVO> listAllByQwUserList(String qwUserIds, String corpId, String sopId) {
-        List<QwGroupChatOptionsVO> list = qwGroupChatMapper.listAllByQwUserList(qwUserIds, corpId);
+
+        // qwUserIds 转换为  qwOpenUserIds
+        String qwOpenUserIds = qwUserMapper.selectQwOpenUserIdByQwUserIdAndCorpId(qwUserIds, corpId);
+
+        List<QwGroupChatOptionsVO> list = qwGroupChatMapper.listAllByQwUserList(qwOpenUserIds, corpId);
         if(StringUtils.isNotEmpty(sopId)){
             QwSop qwSop = sopMapper.selectQwSopById(sopId);
             List<String> chatIds;

+ 1 - 1
fs-service/src/main/java/com/fs/qwApi/service/impl/QwApiServiceImpl.java

@@ -2071,7 +2071,7 @@ public class QwApiServiceImpl implements QwApiService {
 //            String appSecret = qwCompanyConfig.getOpen_Secret();
             QwCompany qwCompany = iQwCompanyService.selectQwCompanyByCorpId(corpId);
 
-            String appSecret = qwCompany.getOpenSecret();
+            String appSecret = qwCompany.getPermanentCode();
 
             String token = getToken(corpId,appSecret);
 

+ 4 - 0
fs-service/src/main/java/com/fs/sensitive/domain/CompanyAiSensitiveWord.java

@@ -21,6 +21,10 @@ public class CompanyAiSensitiveWord extends BaseEntity {
     /** 敏感词主键 */
     private Long wordId;
 
+    /** 公司ID */
+    @Excel(name = "公司ID")
+    private Long companyId;
+
     /** 敏感词内容 */
     @Excel(name = "敏感词")
     private String word;

+ 2 - 2
fs-service/src/main/java/com/fs/sensitive/mapper/CompanyAiSensitiveWordMapper.java

@@ -46,8 +46,8 @@ public interface CompanyAiSensitiveWordMapper {
     /**
      * 根据敏感词内容查询数量(用于重复校验,过滤已删除)
      */
-    @Select("select count(1) from company_ai_sensitive_word where word = #{word} and del_flag = 0")
-    int selectCountByWord(@Param("word") String word);
+    @Select("select count(1) from company_ai_sensitive_word where word = #{word} and company_id = #{companyId} and del_flag = 0")
+    int selectCountByWord(@Param("word") String word, @Param("companyId") Long companyId);
 
     /**
      * 查询所有启用的敏感词

+ 2 - 2
fs-service/src/main/java/com/fs/sensitive/service/impl/CompanyAiSensitiveWordServiceImpl.java

@@ -31,8 +31,8 @@ public class CompanyAiSensitiveWordServiceImpl implements ICompanyAiSensitiveWor
 
     @Override
     public int insertCompanyAiSensitiveWord(CompanyAiSensitiveWord companyAiSensitiveWord) {
-        // 敏感词重复校验
-        int count = baseMapper.selectCountByWord(companyAiSensitiveWord.getWord());
+        // 同一公司下敏感词重复校验
+        int count = baseMapper.selectCountByWord(companyAiSensitiveWord.getWord(), companyAiSensitiveWord.getCompanyId());
         if (count > 0) {
             return -1;
         }

+ 9 - 2
fs-service/src/main/java/com/fs/system/oss/OSSFactory.java

@@ -1,6 +1,8 @@
 package com.fs.system.oss;
 
 import com.alibaba.fastjson.JSON;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.spring.SpringUtils;
 import com.fs.system.service.ISysConfigService;
 
@@ -19,8 +21,13 @@ public final class OSSFactory
     public static CloudStorageService build()
     {
         String jsonconfig = sysConfigService.selectConfigByKey(CloudConstant.CLOUD_STORAGE_CONFIG_KEY);
-        // 获取云存储配置信息
+        if (StringUtils.isEmpty(jsonconfig)) {
+            throw new ServiceException("云存储未配置,请在系统参数中配置 key=" + CloudConstant.CLOUD_STORAGE_CONFIG_KEY);
+        }
         CloudStorageConfig config = JSON.parseObject(jsonconfig, CloudStorageConfig.class);
+        if (config == null || config.getType() == null) {
+            throw new ServiceException("云存储配置解析失败,请检查 " + CloudConstant.CLOUD_STORAGE_CONFIG_KEY + " 的 JSON 格式");
+        }
         if (config.getType() == CloudConstant.CloudService.QINIU.getValue())
         {
             return new QiniuCloudStorageService(config);
@@ -37,6 +44,6 @@ public final class OSSFactory
         {
             return new HuaweiCloudStorageService(config);
         }
-        return null;
+        throw new ServiceException("不支持的云存储类型: " + config.getType());
     }
 }

+ 26 - 27
fs-service/src/main/java/com/fs/system/service/impl/SysUserServiceImpl.java

@@ -32,7 +32,7 @@ import com.fs.system.service.ISysUserService;
 
 /**
  * 用户 业务层处理
- * 
+ *
 
  */
 @Service
@@ -60,7 +60,7 @@ public class SysUserServiceImpl implements ISysUserService
 
     /**
      * 根据条件分页查询用户列表
-     * 
+     *
      * @param user 用户信息
      * @return 用户信息集合信息
      */
@@ -85,14 +85,13 @@ public class SysUserServiceImpl implements ISysUserService
         return userMapper.selectUserIdsWithPage(params);
     }
     @Override
-    @DataScope(deptAlias = "d", userAlias = "u")
     public List<SysUser> selectUserListByIds(List<Long> userIds)
     {
         return userMapper.selectUserListByIds(userIds);
     }
     /**
      * 根据条件分页查询已分配用户角色列表
-     * 
+     *
      * @param user 用户信息
      * @return 用户信息集合信息
      */
@@ -105,7 +104,7 @@ public class SysUserServiceImpl implements ISysUserService
 
     /**
      * 根据条件分页查询未分配用户角色列表
-     * 
+     *
      * @param user 用户信息
      * @return 用户信息集合信息
      */
@@ -118,7 +117,7 @@ public class SysUserServiceImpl implements ISysUserService
 
     /**
      * 通过用户名查询用户
-     * 
+     *
      * @param userName 用户名
      * @return 用户对象信息
      */
@@ -130,7 +129,7 @@ public class SysUserServiceImpl implements ISysUserService
 
     /**
      * 通过用户ID查询用户
-     * 
+     *
      * @param userId 用户ID
      * @return 用户对象信息
      */
@@ -142,7 +141,7 @@ public class SysUserServiceImpl implements ISysUserService
 
     /**
      * 查询用户所属角色组
-     * 
+     *
      * @param userName 用户名
      * @return 结果
      */
@@ -164,7 +163,7 @@ public class SysUserServiceImpl implements ISysUserService
 
     /**
      * 查询用户所属岗位组
-     * 
+     *
      * @param userName 用户名
      * @return 结果
      */
@@ -186,7 +185,7 @@ public class SysUserServiceImpl implements ISysUserService
 
     /**
      * 校验用户名称是否唯一
-     * 
+     *
      * @param userName 用户名称
      * @return 结果
      */
@@ -239,7 +238,7 @@ public class SysUserServiceImpl implements ISysUserService
 
     /**
      * 校验用户是否允许操作
-     * 
+     *
      * @param user 用户信息
      */
     @Override
@@ -253,7 +252,7 @@ public class SysUserServiceImpl implements ISysUserService
 
     /**
      * 校验用户是否有数据权限
-     * 
+     *
      * @param userId 用户id
      */
     @Override
@@ -273,7 +272,7 @@ public class SysUserServiceImpl implements ISysUserService
 
     /**
      * 新增保存用户信息
-     * 
+     *
      * @param user 用户信息
      * @return 结果
      */
@@ -292,7 +291,7 @@ public class SysUserServiceImpl implements ISysUserService
 
     /**
      * 注册用户信息
-     * 
+     *
      * @param user 用户信息
      * @return 结果
      */
@@ -304,7 +303,7 @@ public class SysUserServiceImpl implements ISysUserService
 
     /**
      * 修改保存用户信息
-     * 
+     *
      * @param user 用户信息
      * @return 结果
      */
@@ -326,7 +325,7 @@ public class SysUserServiceImpl implements ISysUserService
 
     /**
      * 用户授权角色
-     * 
+     *
      * @param userId 用户ID
      * @param roleIds 角色组
      */
@@ -340,7 +339,7 @@ public class SysUserServiceImpl implements ISysUserService
 
     /**
      * 修改用户状态
-     * 
+     *
      * @param user 用户信息
      * @return 结果
      */
@@ -352,7 +351,7 @@ public class SysUserServiceImpl implements ISysUserService
 
     /**
      * 修改用户基本信息
-     * 
+     *
      * @param user 用户信息
      * @return 结果
      */
@@ -364,7 +363,7 @@ public class SysUserServiceImpl implements ISysUserService
 
     /**
      * 修改用户头像
-     * 
+     *
      * @param userName 用户名
      * @param avatar 头像地址
      * @return 结果
@@ -377,7 +376,7 @@ public class SysUserServiceImpl implements ISysUserService
 
     /**
      * 重置用户密码
-     * 
+     *
      * @param user 用户信息
      * @return 结果
      */
@@ -389,7 +388,7 @@ public class SysUserServiceImpl implements ISysUserService
 
     /**
      * 重置用户密码
-     * 
+     *
      * @param userName 用户名
      * @param password 密码
      * @return 结果
@@ -402,7 +401,7 @@ public class SysUserServiceImpl implements ISysUserService
 
     /**
      * 新增用户角色信息
-     * 
+     *
      * @param user 用户对象
      */
     public void insertUserRole(SysUser user)
@@ -428,7 +427,7 @@ public class SysUserServiceImpl implements ISysUserService
 
     /**
      * 新增用户岗位信息
-     * 
+     *
      * @param user 用户对象
      */
     public void insertUserPost(SysUser user)
@@ -454,7 +453,7 @@ public class SysUserServiceImpl implements ISysUserService
 
     /**
      * 新增用户角色信息
-     * 
+     *
      * @param userId 用户ID
      * @param roleIds 角色组
      */
@@ -480,7 +479,7 @@ public class SysUserServiceImpl implements ISysUserService
 
     /**
      * 通过用户ID删除用户
-     * 
+     *
      * @param userId 用户ID
      * @return 结果
      */
@@ -497,7 +496,7 @@ public class SysUserServiceImpl implements ISysUserService
 
     /**
      * 批量删除用户信息
-     * 
+     *
      * @param userIds 需要删除的用户ID
      * @return 结果
      */
@@ -518,7 +517,7 @@ public class SysUserServiceImpl implements ISysUserService
 
     /**
      * 导入用户数据
-     * 
+     *
      * @param userList 用户数据列表
      * @param isUpdateSupport 是否更新支持,如果已存在,则进行更新数据
      * @param operName 操作用户

+ 66 - 3
fs-service/src/main/java/com/fs/wx/mp/handler/ScanHandler.java

@@ -1,22 +1,85 @@
 package com.fs.wx.mp.handler;
 
+import com.fs.wx.mp.builder.TextBuilder;
+import com.fs.wx.mp.service.WxMpSubscribeService;
+import lombok.extern.slf4j.Slf4j;
 import me.chanjar.weixin.common.error.WxErrorException;
 import me.chanjar.weixin.common.session.WxSessionManager;
 import me.chanjar.weixin.mp.api.WxMpService;
 import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage;
 import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Component;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
 
+import javax.servlet.http.HttpServletRequest;
 import java.util.Map;
 
 
+@Slf4j
 @Component("ScanMpHandler")
 public class ScanHandler extends AbstractHandler {
 
+    @Autowired
+    private WxMpSubscribeService wxMpSubscribeService;
+
     @Override
-    public WxMpXmlOutMessage handle(WxMpXmlMessage wxMpXmlMessage, Map<String, Object> map,
+    public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage, Map<String, Object> map,
                                     WxMpService wxMpService, WxSessionManager wxSessionManager) throws WxErrorException {
-        // 扫码事件处理
+        String openId = wxMessage.getFromUser();
+        String eventKey = wxMessage.getEventKey();
+        log.info("扫码事件处理,openId={}, eventKey={}", openId, eventKey);
+
+        // 处理订阅通知场景的扫码
+        Long userId = WxMpSubscribeService.extractUserIdFromScene(eventKey);
+        if (userId != null) {
+            // 绑定openId到员工
+            wxMpSubscribeService.bindOpenIdToUser(openId, userId);
+            log.info("已关注用户扫码绑定openId,userId={}, openId={}", userId, openId);
+
+            // 返回提示消息,引导用户前往HTML页面确认订阅
+            try {
+                return new TextBuilder().build(
+                        "您已绑定通知服务。\n请点击下方链接确认订阅通知:\n" +
+                        buildSubscribePageUrl(userId),
+                        wxMessage, wxMpService);
+            } catch (Exception e) {
+                log.error("发送订阅确认链接失败", e);
+            }
+        }
+
         return null;
     }
-}
+
+    /**
+     * 构建订阅确认页面URL
+     * 使用当前系统请求的域名构建完整URL
+     */
+    private String buildSubscribePageUrl(Long userId) {
+        String domain = getCurrentDomain();
+        return domain + "/subscribe/index.html?userId=" + userId;
+    }
+
+    /**
+     * 获取当前系统域名(从回调请求中提取)
+     */
+    private String getCurrentDomain() {
+        try {
+            ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+            if (attributes != null) {
+                HttpServletRequest request = attributes.getRequest();
+                String scheme = request.getScheme();
+                String serverName = request.getServerName();
+                int port = request.getServerPort();
+                if ((scheme.equals("http") && port == 80) || (scheme.equals("https") && port == 443)) {
+                    return scheme + "://" + serverName;
+                }
+                return scheme + "://" + serverName + ":" + port;
+            }
+        } catch (Exception e) {
+            log.warn("获取当前请求域名失败", e);
+        }
+        return "https://YOUR_DOMAIN";
+    }
+}

+ 63 - 18
fs-service/src/main/java/com/fs/wx/mp/handler/SubscribeHandler.java

@@ -1,67 +1,112 @@
 package com.fs.wx.mp.handler;
 
 import com.fs.wx.mp.builder.TextBuilder;
+import com.fs.wx.mp.service.WxMpSubscribeService;
+import lombok.extern.slf4j.Slf4j;
 import me.chanjar.weixin.common.error.WxErrorException;
 import me.chanjar.weixin.common.session.WxSessionManager;
 import me.chanjar.weixin.mp.api.WxMpService;
 import me.chanjar.weixin.mp.bean.message.WxMpXmlMessage;
 import me.chanjar.weixin.mp.bean.message.WxMpXmlOutMessage;
 import me.chanjar.weixin.mp.bean.result.WxMpUser;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Component;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
 
+import javax.servlet.http.HttpServletRequest;
 import java.util.Map;
 
+
+@Slf4j
 @Component("SubscribeMpHandler")
 public class SubscribeHandler extends AbstractHandler {
 
+    @Autowired
+    private WxMpSubscribeService wxMpSubscribeService;
+
     @Override
     public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage,
                                     Map<String, Object> context, WxMpService weixinService,
                                     WxSessionManager sessionManager) throws WxErrorException {
 
-        this.logger.info("新关注用户 OPENID: " + wxMessage.getFromUser());
+        String openId = wxMessage.getFromUser();
+        log.info("新关注用户 OPENID: {}", openId);
 
         // 获取微信用户基本信息
         try {
             WxMpUser userWxInfo = weixinService.getUserService()
-                .userInfo(wxMessage.getFromUser(), null);
+                .userInfo(openId, null);
             if (userWxInfo != null) {
                 // TODO 可以添加关注用户到本地数据库
             }
         } catch (WxErrorException e) {
             if (e.getError().getErrorCode() == 48001) {
-                this.logger.info("该公众号没有获取用户信息权限!");
+                log.info("该公众号没有获取用户信息权限!");
             }
         }
 
+        // 处理扫码关注场景(带参数二维码)
+        String eventKey = wxMessage.getEventKey();
+        if (StringUtils.isNotBlank(eventKey) && eventKey.startsWith("qrscene_")) {
+            String sceneStr = eventKey.substring("qrscene_".length());
+            Long userId = WxMpSubscribeService.extractUserIdFromScene(sceneStr);
+            if (userId != null) {
+                // 绑定openId到员工
+                wxMpSubscribeService.bindOpenIdToUser(openId, userId);
+                log.info("扫码关注绑定openId,userId={}, openId={}", userId, openId);
 
-        WxMpXmlOutMessage responseResult = null;
-        try {
-            responseResult = this.handleSpecial(wxMessage);
-        } catch (Exception e) {
-            this.logger.error(e.getMessage(), e);
-        }
-
-        if (responseResult != null) {
-            return responseResult;
+                // 返回提示消息,引导用户前往HTML页面确认订阅
+                try {
+                    return new TextBuilder().build(
+                            "感谢关注!您已成功绑定通知服务。\n请点击下方链接确认订阅通知:\n" +
+                            buildSubscribePageUrl(userId),
+                            wxMessage, weixinService);
+                } catch (Exception e) {
+                    log.error("发送订阅确认链接失败", e);
+                }
+            }
         }
 
+        // 非订阅场景的默认关注回复
         try {
             return new TextBuilder().build("感谢关注", wxMessage, weixinService);
         } catch (Exception e) {
-            this.logger.error(e.getMessage(), e);
+            log.error(e.getMessage(), e);
         }
 
         return null;
     }
 
     /**
-     * 处理特殊请求,比如如果是扫码进来的,可以做相应处理
+     * 构建订阅确认页面URL
+     * 使用当前系统请求的域名构建完整URL
      */
-    private WxMpXmlOutMessage handleSpecial(WxMpXmlMessage wxMessage)
-        throws Exception {
-        //TODO
-        return null;
+    private String buildSubscribePageUrl(Long userId) {
+        String domain = getCurrentDomain();
+        return domain + "/subscribe/index.html?userId=" + userId;
     }
 
+    /**
+     * 获取当前系统域名(从回调请求中提取)
+     */
+    private String getCurrentDomain() {
+        try {
+            ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+            if (attributes != null) {
+                HttpServletRequest request = attributes.getRequest();
+                String scheme = request.getScheme();
+                String serverName = request.getServerName();
+                int port = request.getServerPort();
+                if ((scheme.equals("http") && port == 80) || (scheme.equals("https") && port == 443)) {
+                    return scheme + "://" + serverName;
+                }
+                return scheme + "://" + serverName + ":" + port;
+            }
+        } catch (Exception e) {
+            log.warn("获取当前请求域名失败", e);
+        }
+        return "https://YOUR_DOMAIN";
+    }
 }

+ 303 - 0
fs-service/src/main/java/com/fs/wx/mp/service/WxMpSubscribeService.java

@@ -0,0 +1,303 @@
+package com.fs.wx.mp.service;
+
+import com.fs.common.QRutils;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.mapper.CompanyUserMapper;
+import com.fs.core.config.WxMpProperties;
+import com.alibaba.fastjson.JSONObject;
+import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.common.bean.oauth2.WxOAuth2AccessToken;
+import me.chanjar.weixin.mp.api.WxMpService;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.Map;
+
+/**
+ * 微信公众号订阅通知服务
+ * 处理员工关注公众号并订阅通知的业务逻辑
+ */
+@Slf4j
+@Service
+public class WxMpSubscribeService {
+
+    /** 二维码场景值前缀,用于区分订阅通知场景 */
+    public static final String SCENE_PREFIX = "mpsub_";
+
+    /** 二维码尺寸 */
+    private static final int QR_CODE_SIZE = 300;
+
+    @Autowired
+    private CompanyUserMapper companyUserMapper;
+
+    @Autowired
+    private WxMpProperties wxMpProperties;
+
+    /**
+     * 生成订阅通知二维码(base64图片)
+     * 二维码内容为订阅页面URL,员工扫码后直接打开订阅页面
+     *
+     * @param userId 员工用户ID
+     * @param tenantCode 租户编码,用于构建订阅页面URL中的租户标识
+     * @return base64编码的二维码图片数据
+     */
+    public String generateSubscribeQrCode(Long userId, String tenantCode) {
+        String subscribeUrl = buildSubscribePageUrl(userId, tenantCode);
+        String base64Image = QRutils.getQRCodeBase64(subscribeUrl, QR_CODE_SIZE, QR_CODE_SIZE);
+        log.info("生成订阅二维码成功,userId={}, tenantCode={}, url={}", userId, tenantCode, subscribeUrl);
+        return base64Image;
+    }
+
+    /**
+     * 通过OAuth code获取openId并绑定到员工(使用默认wxMpProperties创建WxMpService)
+     *
+     * @param code 微信OAuth授权code
+     * @param userId 员工用户ID
+     * @return openId
+     */
+    public String bindOpenIdByOAuthCode(String code, Long userId) {
+        return bindOpenIdByOAuthCode(code, userId, wxMpProperties.createFirstWxMpService());
+    }
+
+    /**
+     * 通过OAuth code获取openId并绑定到员工(使用外部传入的WxMpService)
+     * 用于从company_config读取公众号配置后构建的WxMpService
+     *
+     * @param code 微信OAuth授权code
+     * @param userId 员工用户ID
+     * @param wxMpService 微信公众号服务实例
+     * @return openId
+     */
+    public String bindOpenIdByOAuthCode(String code, Long userId, WxMpService wxMpService) {
+        if (StringUtils.isBlank(code) || userId == null) {
+            log.warn("OAuth绑定参数无效,code={}, userId={}", code, userId);
+            return null;
+        }
+        try {
+            WxOAuth2AccessToken accessToken =
+                    wxMpService.getOAuth2Service().getAccessToken(code);
+            String openId = accessToken.getOpenId();
+            if (StringUtils.isNotBlank(openId)) {
+                bindOpenIdToUser(openId, userId);
+                log.info("OAuth绑定openId成功,userId={}, openId={}", userId, openId);
+                return openId;
+            }
+        } catch (Exception e) {
+            log.error("OAuth获取openId失败,code={}, userId={}", code, userId, e);
+        }
+        return null;
+    }
+
+    /**
+     * 绑定公众号openId到员工
+     *
+     * @param openId 微信公众号openId
+     * @param userId 员工用户ID
+     */
+    public void bindOpenIdToUser(String openId, Long userId) {
+        if (StringUtils.isBlank(openId) || userId == null) {
+            log.warn("绑定openId参数无效,openId={}, userId={}", openId, userId);
+            return;
+        }
+        CompanyUser user = companyUserMapper.selectCompanyUserById(userId);
+        if (user == null) {
+            log.warn("绑定openId时未找到用户,userId={}", userId);
+            return;
+        }
+        companyUserMapper.updateMpOpenIdAndSubscribed(userId, openId, 0);
+        log.info("绑定openId成功,userId={}, openId={}", userId, openId);
+    }
+
+    /**
+     * 确认订阅通知
+     *
+     * @param userId 员工用户ID
+     */
+    public void confirmSubscribe(Long userId) {
+        if (userId == null) {
+            log.warn("确认订阅参数无效,userId=null");
+            return;
+        }
+        CompanyUser user = companyUserMapper.selectCompanyUserById(userId);
+        if (user == null) {
+            log.warn("确认订阅时未找到用户,userId={}", userId);
+            return;
+        }
+        if (StringUtils.isBlank(user.getMpOpenId())) {
+            log.warn("用户尚未绑定公众号openId,无法确认订阅,userId={}", userId);
+            throw new RuntimeException("用户尚未关注公众号,无法确认订阅");
+        }
+        companyUserMapper.updateMpSubscribed(userId, 1);
+        log.info("确认订阅成功,userId={}", userId);
+    }
+
+    /**
+     * 查询员工订阅状态
+     *
+     * @param userId 员工用户ID
+     * @return 员工信息(含mpOpenId和mpSubscribed)
+     */
+    public CompanyUser getSubscribeStatus(Long userId) {
+        return companyUserMapper.selectCompanyUserById(userId);
+    }
+
+    /**
+     * 向已订阅的员工推送公众号订阅消息
+     * 优先使用WxJava SDK发送,若SDK API不可用则回退到HTTP直调微信接口
+     *
+     * @param userId      员工用户ID
+     * @param wxMpService 微信公众号服务实例(从company_config构建,带access_token缓存)
+     * @param templateId  订阅消息模板ID
+     * @param data        模板数据,key为字段名(如thing2),value为字段值
+     * @return 推送是否成功
+     */
+    public boolean sendSubscribePush(Long userId, WxMpService wxMpService, String templateId, Map<String, String> data) {
+        if (userId == null || wxMpService == null || StringUtils.isBlank(templateId)) {
+            log.warn("[WxMpSubscribe] 推送参数无效,userId={}, templateId={}", userId, templateId);
+            return false;
+        }
+        CompanyUser user = companyUserMapper.selectCompanyUserById(userId);
+        if (user == null) {
+            throw new RuntimeException("未找到用户,userId=" + userId);
+        }
+        if (StringUtils.isBlank(user.getMpOpenId())) {
+            throw new RuntimeException("该员工尚未绑定公众号,请先引导员工完成订阅");
+        }
+        if (user.getMpSubscribed() == null || user.getMpSubscribed() != 1) {
+            throw new RuntimeException("该员工尚未订阅通知,请先引导员工完成订阅");
+        }
+
+        String openId = user.getMpOpenId();
+        log.info("[WxMpSubscribe] 开始推送订阅消息,userId={}, openId={}, templateId={}", userId, openId, templateId);
+
+        // 优先尝试SDK方式
+        try {
+            return sendViaSdk(wxMpService, openId, templateId, data);
+        } catch (NoSuchMethodError | NoClassDefFoundError e) {
+            log.warn("[WxMpSubscribe] SDK不支持订阅消息发送API,回退到HTTP直调,error={}", e.getMessage());
+        } catch (Exception e) {
+            log.warn("[WxMpSubscribe] SDK发送订阅消息异常,回退到HTTP直调", e);
+        }
+
+        // HTTP直调回退
+        return sendViaHttp(wxMpService, openId, templateId, data);
+    }
+
+    /**
+     * 通过WxJava SDK发送公众号订阅消息
+     * SDK的WxMpSubscribeMessage使用dataMap(Map<String,String>)存储模板数据
+     * 发送方法为 wxMpService.getSubscribeMsgService().send(message)
+     */
+    private boolean sendViaSdk(WxMpService wxMpService, String openId, String templateId, Map<String, String> data) throws Exception {
+        me.chanjar.weixin.mp.bean.subscribe.WxMpSubscribeMessage message =
+                me.chanjar.weixin.mp.bean.subscribe.WxMpSubscribeMessage.builder()
+                        .toUser(openId)
+                        .templateId(templateId)
+                        .dataMap(data)
+                        .build();
+
+        wxMpService.getSubscribeMsgService().send(message);
+        log.info("[WxMpSubscribe] SDK推送订阅消息成功,openId={}, templateId={}", openId, templateId);
+        return true;
+    }
+
+    /**
+     * 通过HTTP直调微信API发送公众号订阅消息(SDK回退方案)
+     * 接口: POST https://api.weixin.qq.com/cgi-bin/message/subscribe/bizsend
+     */
+    private boolean sendViaHttp(WxMpService wxMpService, String openId, String templateId, Map<String, String> data) {
+        try {
+            String accessToken = wxMpService.getAccessToken();
+            String url = "https://api.weixin.qq.com/cgi-bin/message/subscribe/bizsend?access_token=" + accessToken;
+
+            JSONObject body = new JSONObject();
+            body.put("touser", openId);
+            body.put("template_id", templateId);
+
+            JSONObject dataJson = new JSONObject();
+            for (Map.Entry<String, String> entry : data.entrySet()) {
+                JSONObject valueObj = new JSONObject();
+                valueObj.put("value", entry.getValue());
+                dataJson.put(entry.getKey(), valueObj);
+            }
+            body.put("data", dataJson);
+
+            RestTemplate restTemplate = new RestTemplate();
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            HttpEntity<String> entity = new HttpEntity<>(body.toJSONString(), headers);
+
+            String response = restTemplate.postForObject(url, entity, String.class);
+            JSONObject result = JSONObject.parseObject(response);
+            int errcode = result.getIntValue("errcode");
+            if (errcode == 0) {
+                log.info("[WxMpSubscribe] HTTP推送订阅消息成功,openId={}, templateId={}", openId, templateId);
+                return true;
+            } else {
+                log.error("[WxMpSubscribe] HTTP推送订阅消息失败,errcode={}, errmsg={}", errcode, result.getString("errmsg"));
+                throw new RuntimeException("推送失败:" + result.getString("errmsg"));
+            }
+        } catch (RuntimeException e) {
+            throw e;
+        } catch (Exception e) {
+            log.error("[WxMpSubscribe] HTTP推送订阅消息异常,openId={}", openId, e);
+            throw new RuntimeException("推送异常:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 从场景值中提取用户ID(兼容微信带参数二维码场景)
+     *
+     * @param sceneStr 场景值,格式:mpsub_{userId}
+     * @return 用户ID,如果不是订阅场景则返回null
+     */
+    public static Long extractUserIdFromScene(String sceneStr) {
+        if (StringUtils.isBlank(sceneStr) || !sceneStr.startsWith(SCENE_PREFIX)) {
+            return null;
+        }
+        try {
+            return Long.parseLong(sceneStr.substring(SCENE_PREFIX.length()));
+        } catch (NumberFormatException e) {
+            log.warn("解析场景值中的userId失败,sceneStr={}", sceneStr);
+            return null;
+        }
+    }
+
+    /**
+     * 构建订阅确认页面URL(使用当前系统域名,包含tenantCode和userId)
+     */
+    private String buildSubscribePageUrl(Long userId, String tenantCode) {
+        String domain = getCurrentDomain();
+        return domain + "/subscribe/index.html?tenantCode=" + tenantCode + "&userId=" + userId;
+    }
+
+    /**
+     * 获取当前系统域名(从请求上下文中提取)
+     */
+    private String getCurrentDomain() {
+        try {
+            ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+            if (attributes != null) {
+                HttpServletRequest request = attributes.getRequest();
+                String scheme = request.getScheme();
+                String serverName = request.getServerName();
+                int port = request.getServerPort();
+                if ((scheme.equals("http") && port == 80) || (scheme.equals("https") && port == 443)) {
+                    return scheme + "://" + serverName;
+                }
+                return scheme + "://" + serverName + ":" + port;
+            }
+        } catch (Exception e) {
+            log.warn("获取当前请求域名失败", e);
+        }
+        return "https://YOUR_DOMAIN";
+    }
+}

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini