Jelajahi Sumber

龙虾引擎全量MyBatis化+定时调度闭环+深度修复:进化/学习/A-B测试/支付/渠道/安全/智能API/监控/推送,新增LobsterAdminController/PayCallbackController,JdbcTemplate清零,ScriptCache重命名,前端5页面补全

云联一号 4 hari lalu
induk
melakukan
80291a935b
100 mengubah file dengan 7099 tambahan dan 531 penghapusan
  1. 12 0
      fs-admin-saas/src/main/java/com/fs/course/controller/FsCoursePlaySourceConfigController.java
  2. 2 2
      fs-admin-saas/src/main/resources/application-dev.yml
  3. 69 10
      fs-admin/src/main/java/com/fs/admin/controller/AdminCompanyBridgeController.java
  4. 7 0
      fs-admin/src/main/java/com/fs/admin/controller/AiModelAdminController.java
  5. 245 0
      fs-admin/src/main/java/com/fs/admin/controller/CompanyVoiceApiTenantController.java
  6. 93 0
      fs-admin/src/main/java/com/fs/admin/controller/CompanyVoiceController.java
  7. 155 0
      fs-ai-call-task/src/main/resources/application-common.yml
  8. 137 0
      fs-ai-call-task/src/main/resources/application-config-dev.yml
  9. 132 0
      fs-ai-call-task/src/main/resources/application-dev.yml
  10. 155 0
      fs-cid-workflow/src/main/resources/application-common.yml
  11. 137 0
      fs-cid-workflow/src/main/resources/application-config-dev.yml
  12. 132 0
      fs-cid-workflow/src/main/resources/application-dev.yml
  13. 4 0
      fs-comm-gateway/Dockerfile
  14. 14 0
      fs-comm-gateway/nginx.conf
  15. 100 0
      fs-comm-gateway/pom.xml
  16. 33 0
      fs-comm-gateway/src/main/java/com/fs/CommGatewayApplication.java
  17. 70 0
      fs-comm-gateway/src/main/java/com/fs/comm/aspectj/CommCallbackIpCheckAspect.java
  18. 37 0
      fs-comm-gateway/src/main/java/com/fs/comm/auth/CommAuthController.java
  19. 36 0
      fs-comm-gateway/src/main/java/com/fs/comm/auth/CommSession.java
  20. 217 0
      fs-comm-gateway/src/main/java/com/fs/comm/auth/CommTokenService.java
  21. 18 0
      fs-comm-gateway/src/main/java/com/fs/comm/auth/dto/CommTokenRequest.java
  22. 21 0
      fs-comm-gateway/src/main/java/com/fs/comm/auth/dto/CommTokenResponse.java
  23. 33 0
      fs-comm-gateway/src/main/java/com/fs/comm/config/CommDataSourceTaskDecorator.java
  24. 24 0
      fs-comm-gateway/src/main/java/com/fs/comm/config/CommFilterConfig.java
  25. 50 0
      fs-comm-gateway/src/main/java/com/fs/comm/config/CommThreadPoolConfig.java
  26. 41 0
      fs-comm-gateway/src/main/java/com/fs/comm/context/CommAuthContext.java
  27. 29 0
      fs-comm-gateway/src/main/java/com/fs/comm/controller/CommCallController.java
  28. 28 0
      fs-comm-gateway/src/main/java/com/fs/comm/controller/CommCallbackController.java
  29. 32 0
      fs-comm-gateway/src/main/java/com/fs/comm/controller/CommQueryController.java
  30. 29 0
      fs-comm-gateway/src/main/java/com/fs/comm/controller/CommSmsController.java
  31. 27 0
      fs-comm-gateway/src/main/java/com/fs/comm/dto/CommApiResult.java
  32. 37 0
      fs-comm-gateway/src/main/java/com/fs/comm/dto/CommCallSendRequest.java
  33. 31 0
      fs-comm-gateway/src/main/java/com/fs/comm/dto/CommSmsSendRequest.java
  34. 24 0
      fs-comm-gateway/src/main/java/com/fs/comm/exception/CommGlobalExceptionHandler.java
  35. 30 0
      fs-comm-gateway/src/main/java/com/fs/comm/metrics/CommMetricsService.java
  36. 76 0
      fs-comm-gateway/src/main/java/com/fs/comm/ratelimit/CommRateLimitService.java
  37. 28 0
      fs-comm-gateway/src/main/java/com/fs/comm/security/CommAuthenticationEntryPoint.java
  38. 159 0
      fs-comm-gateway/src/main/java/com/fs/comm/security/CommTokenAuthFilter.java
  39. 68 0
      fs-comm-gateway/src/main/java/com/fs/comm/service/CommCallService.java
  40. 86 0
      fs-comm-gateway/src/main/java/com/fs/comm/service/CommCallbackService.java
  41. 73 0
      fs-comm-gateway/src/main/java/com/fs/comm/service/CommGatewayLineAuthService.java
  42. 56 0
      fs-comm-gateway/src/main/java/com/fs/comm/service/CommSmsService.java
  43. 31 0
      fs-comm-gateway/src/main/java/com/fs/framework/config/SecurityConfig.java
  44. 1 0
      fs-comm-gateway/src/main/resources/META-INF/spring-devtools.properties
  45. 155 0
      fs-comm-gateway/src/main/resources/application-common.yml
  46. 137 0
      fs-comm-gateway/src/main/resources/application-config-dev.yml
  47. 132 0
      fs-comm-gateway/src/main/resources/application-dev.yml
  48. 37 0
      fs-comm-gateway/src/main/resources/i18n/messages.properties
  49. 21 0
      fs-comm-gateway/src/main/resources/mybatis/mybatis-config.xml
  50. 635 0
      fs-comm-gateway/src/main/resources/static/chat-aggregate.html
  51. 283 0
      fs-comm-gateway/src/main/resources/static/sales-corpus.html
  52. 324 0
      fs-comm-gateway/src/main/resources/static/workflow-canvas.html
  53. 562 0
      fs-comm-gateway/对接文档.md
  54. 4 4
      fs-company/src/main/java/com/fs/company/controller/aicall/CcLlmAgentAccountController.java
  55. 121 0
      fs-company/src/main/java/com/fs/company/controller/company/CompanyAiWorkflowServerController.java
  56. 1 1
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceApiController.java
  57. 37 1
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticCallLogCallphoneController.java
  58. 9 0
      fs-company/src/main/java/com/fs/company/controller/companyWorkflow/CompanyWorkflowTagTemplateBindingController.java
  59. 4 2
      fs-company/src/main/java/com/fs/company/controller/qw/QwGroupChatController.java
  60. 97 0
      fs-company/src/main/java/com/fs/fastGpt/FastGptChatReplaceWordsController.java
  61. 155 0
      fs-company/src/main/resources/application-common.yml
  62. 137 0
      fs-company/src/main/resources/application-config-dev.yml
  63. 74 62
      fs-company/src/main/resources/application-dev.yml
  64. 2 0
      fs-framework/src/main/java/com/fs/framework/config/SecurityConfig.java
  65. 0 180
      fs-ipad-task/src/main/java/com/fs/framework/aspectj/TenantDataSourceAspect.java
  66. 2 2
      fs-ipad-task/src/main/resources/application.yml
  67. 1 1
      fs-qw-api-msg/src/main/resources/application.yml
  68. 27 6
      fs-qw-api/src/main/java/com/fs/app/controller/OpenQwApiController.java
  69. 2 2
      fs-qw-api/src/main/java/com/fs/app/service/OpenQwApiService.java
  70. 9 6
      fs-qw-api/src/main/java/com/fs/app/service/impl/OpenQwApiServiceImpl.java
  71. 1 0
      fs-service/src/main/java/com/fs/aicall/domain/CcLlmAgentAccount.java
  72. 14 15
      fs-service/src/main/java/com/fs/aicall/domain/CcLlmAgentProvider.java
  73. 12 11
      fs-service/src/main/java/com/fs/aicall/domain/CcLlmKbCat.java
  74. 50 0
      fs-service/src/main/java/com/fs/aicall/mapper/CcLlmAgentAccountMapper.java
  75. 50 0
      fs-service/src/main/java/com/fs/aicall/mapper/CcLlmAgentProviderMapper.java
  76. 50 0
      fs-service/src/main/java/com/fs/aicall/mapper/CcLlmKbCatMapper.java
  77. 53 0
      fs-service/src/main/java/com/fs/aicall/mapper/CcLlmKbMapper.java
  78. 2 0
      fs-service/src/main/java/com/fs/aicall/mapper/CompanyBindAiModelMapper.java
  79. 1 1
      fs-service/src/main/java/com/fs/aicall/service/impl/CcLlmKbCatServiceImpl.java
  80. 90 0
      fs-service/src/main/java/com/fs/comm/client/CommGatewayClient.java
  81. 33 0
      fs-service/src/main/java/com/fs/comm/model/CommCallSendParam.java
  82. 15 0
      fs-service/src/main/java/com/fs/comm/model/CommCallSendResult.java
  83. 31 0
      fs-service/src/main/java/com/fs/comm/model/CommSmsSendParam.java
  84. 15 0
      fs-service/src/main/java/com/fs/comm/model/CommSmsSendResult.java
  85. 248 0
      fs-service/src/main/java/com/fs/comm/service/CommCallSendService.java
  86. 283 0
      fs-service/src/main/java/com/fs/comm/service/CommSmsSendService.java
  87. 3 0
      fs-service/src/main/java/com/fs/common/service/ISmsService.java
  88. 24 7
      fs-service/src/main/java/com/fs/common/service/impl/SmsServiceImpl.java
  89. 53 60
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceApi.java
  90. 58 133
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceApiTenant.java
  91. 7 0
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRoboticCallLogCallphone.java
  92. 33 0
      fs-service/src/main/java/com/fs/company/domain/LobsterAbTest.java
  93. 30 0
      fs-service/src/main/java/com/fs/company/domain/LobsterApiRegistry.java
  94. 32 0
      fs-service/src/main/java/com/fs/company/domain/LobsterChannelTypeRegistry.java
  95. 12 25
      fs-service/src/main/java/com/fs/company/domain/LobsterConversationSummary.java
  96. 23 0
      fs-service/src/main/java/com/fs/company/domain/LobsterDialogueState.java
  97. 20 0
      fs-service/src/main/java/com/fs/company/domain/LobsterEvolutionConfig.java
  98. 20 0
      fs-service/src/main/java/com/fs/company/domain/LobsterEvolutionHistory.java
  99. 25 0
      fs-service/src/main/java/com/fs/company/domain/LobsterEvolutionLog.java
  100. 24 0
      fs-service/src/main/java/com/fs/company/domain/LobsterEvolutionSuggestion.java

+ 12 - 0
fs-admin-saas/src/main/java/com/fs/course/controller/FsCoursePlaySourceConfigController.java

@@ -242,4 +242,16 @@ public class FsCoursePlaySourceConfigController extends BaseController {
         return AjaxResult.success(result);
     }
 
+    @PostMapping("/bindMerchant")
+    public AjaxResult bindMerchant(@RequestBody Map<String, Object> params) {
+        String appId = (String) params.get("appId");
+        Object merchantConfigIdObj = params.get("merchantConfigId");
+        if (appId == null || merchantConfigIdObj == null) {
+            return AjaxResult.error("appId和merchantConfigId不能为空");
+        }
+        Long merchantConfigId = Long.valueOf(merchantConfigIdObj.toString());
+        fsCoursePlaySourceConfigService.bindMerchant(appId, merchantConfigId);
+        return AjaxResult.success();
+    }
+
 }

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

@@ -6,8 +6,8 @@ spring:
     # redis 配置
     redis:
         # 地址
-        #host: localhost
-        host: 172.27.0.7
+        host: localhost
+#        host: 172.27.0.7
         # 端口,默认为6379
         port: 6379
         # 数据库索引

+ 69 - 10
fs-admin/src/main/java/com/fs/admin/controller/AdminCompanyBridgeController.java

@@ -117,11 +117,11 @@ public class AdminCompanyBridgeController extends BaseController {
     }
 
     /** 查询租户已分配的接口列表(含定价信息) */
-    @GetMapping("/admin/voice-api/apis/{companyId}")
-    public AjaxResult voiceApiApis(@PathVariable Long companyId) {
+    @GetMapping("/admin/voice-api/apis/{tenantId}")
+    public AjaxResult voiceApiApis(@PathVariable Long tenantId) {
         DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
         List<CompanyVoiceApiTenant> list = companyVoiceApiTenantService != null ?
-            companyVoiceApiTenantService.selectEnabledApisByCompanyId(companyId) : new ArrayList<>();
+            companyVoiceApiTenantService.selectEnabledApisByTenantId(tenantId) : new ArrayList<>();
         return AjaxResult.success(list);
     }
 
@@ -133,22 +133,81 @@ public class AdminCompanyBridgeController extends BaseController {
         DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
         if (companyVoiceApiTenantService == null) return AjaxResult.error("服务未就绪");
         Long apiId = Long.valueOf(body.get("apiId").toString());
-        @SuppressWarnings("unchecked")
-        List<Integer> companyIds = (List<Integer>) body.get("companyIds");
-        List<Long> ids = new ArrayList<>();
-        for (Integer id : companyIds) { ids.add(id.longValue()); }
-        companyVoiceApiTenantService.batchAssignTenants(apiId, ids);
+        List<Long> tenantIds = parseIdList(body.get("tenantIds"));
+        if (tenantIds.isEmpty()) {
+            tenantIds = parseIdList(body.get("companyIds"));
+        }
+        if (tenantIds.isEmpty()) {
+            return AjaxResult.error("请选择租户");
+        }
+        companyVoiceApiTenantService.batchAssignTenants(apiId, parseTenantAssignList(body, apiId, tenantIds));
         return AjaxResult.success();
     }
 
+    @SuppressWarnings("unchecked")
+    private List<CompanyVoiceApiTenant> parseTenantAssignList(Map<String, Object> body, Long apiId, List<Long> tenantIds) {
+        List<CompanyVoiceApiTenant> list = new ArrayList<>();
+        Object rawTenants = body.get("tenants");
+        if (rawTenants instanceof List) {
+            for (Object item : (List<?>) rawTenants) {
+                if (!(item instanceof Map)) {
+                    continue;
+                }
+                Map<String, Object> tenantMap = (Map<String, Object>) item;
+                CompanyVoiceApiTenant tenant = new CompanyVoiceApiTenant();
+                tenant.setApiId(apiId);
+                if (tenantMap.get("tenantId") != null) {
+                    tenant.setTenantId(Long.valueOf(tenantMap.get("tenantId").toString()));
+                }
+                if (tenantMap.get("tenantCode") != null) {
+                    tenant.setTenantCode(tenantMap.get("tenantCode").toString());
+                }
+                if (tenantMap.get("tenantName") != null) {
+                    tenant.setTenantName(tenantMap.get("tenantName").toString());
+                }
+                if (tenant.getTenantId() != null) {
+                    list.add(tenant);
+                }
+            }
+        }
+        if (list.isEmpty()) {
+            for (Long tenantId : tenantIds) {
+                CompanyVoiceApiTenant tenant = new CompanyVoiceApiTenant();
+                tenant.setApiId(apiId);
+                tenant.setTenantId(tenantId);
+                list.add(tenant);
+            }
+        }
+        return list;
+    }
+
     /** 取消分配 */
     @PreAuthorize("@ss.hasPermi('company:companyVoiceApi:edit')")
     @Log(title = "取消通话接口分配", businessType = BusinessType.DELETE)
     @DeleteMapping("/admin/voice-api/unassignTenant")
-    public AjaxResult unassignTenant(@RequestParam Long apiId, @RequestParam Long companyId) {
+    public AjaxResult unassignTenant(@RequestParam Long apiId,
+                                     @RequestParam(required = false) Long tenantId,
+                                     @RequestParam(required = false) Long companyId) {
         DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
         if (companyVoiceApiTenantService == null) return AjaxResult.error("服务未就绪");
-        return toAjax(companyVoiceApiTenantService.unassignTenant(apiId, companyId));
+        Long tid = tenantId != null ? tenantId : companyId;
+        return toAjax(companyVoiceApiTenantService.unassignTenant(apiId, tid));
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<Long> parseIdList(Object raw) {
+        List<Long> ids = new ArrayList<>();
+        if (raw == null) {
+            return ids;
+        }
+        if (raw instanceof List) {
+            for (Object item : (List<?>) raw) {
+                if (item != null) {
+                    ids.add(Long.valueOf(item.toString()));
+                }
+            }
+        }
+        return ids;
     }
 
     /** 查询接口已分配租户数量 */

+ 7 - 0
fs-admin/src/main/java/com/fs/admin/controller/AiModelAdminController.java

@@ -39,6 +39,13 @@ public class AiModelAdminController extends BaseController {
         return AjaxResult.success(list);
     }
 
+    /** 公开访问的模型列表(无需权限验证) */
+    @GetMapping("/public/list")
+    public AjaxResult publicList() {
+        List<AdminAiModel> list = modelService.listAll();
+        return AjaxResult.success(list);
+    }
+
     /** 模型详情 */
     @PreAuthorize("@ss.hasPermi('admin:aiModel:list')")
     @GetMapping("/{id}")

+ 245 - 0
fs-admin/src/main/java/com/fs/admin/controller/CompanyVoiceApiTenantController.java

@@ -0,0 +1,245 @@
+package com.fs.admin.controller;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.CompanyVoiceApi;
+import com.fs.company.domain.CompanyVoiceApiTenant;
+import com.fs.company.service.ICompanyVoiceApiService;
+import com.fs.company.service.ICompanyVoiceApiTenantService;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+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.List;
+import java.util.Map;
+
+/**
+ * 外呼接口-租户分配与定价管理(新版 admin 专用)
+ *
+ * @author MixLiu
+ */
+@RestController
+@RequestMapping("/admin/companyVoiceApiTenant")
+public class CompanyVoiceApiTenantController extends BaseController {
+
+    @Autowired(required = false)
+    private ICompanyVoiceApiTenantService companyVoiceApiTenantService;
+
+    @Autowired(required = false)
+    private ICompanyVoiceApiService companyVoiceApiService;
+
+    /**
+     * 分页查询租户-接口绑定/定价列表
+     */
+    @GetMapping("/list")
+    public TableDataInfo list(CompanyVoiceApiTenant param) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        startPage();
+        List<CompanyVoiceApiTenant> list = companyVoiceApiTenantService != null
+                ? companyVoiceApiTenantService.selectCompanyVoiceApiTenantList(param)
+                : java.util.Collections.emptyList();
+        return getDataTable(list);
+    }
+
+    /**
+     * 外呼接口下拉选项(新增绑定时选择接口)
+     */
+    @GetMapping("/apiList")
+    public TableDataInfo apiList() {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        List<CompanyVoiceApi> list = companyVoiceApiService != null
+                ? companyVoiceApiService.selectCompanyVoiceApiList(new CompanyVoiceApi())
+                : java.util.Collections.emptyList();
+        return getDataTable(list);
+    }
+
+    /**
+     * 分配接口给租户(批量)
+     */
+    @PreAuthorize("@ss.hasPermi('company:companyVoiceApi:edit')")
+    @Log(title = "分配通话接口给租户", businessType = BusinessType.INSERT)
+    @PostMapping("/assign")
+    public AjaxResult assign(@RequestBody Map<String, Object> body) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (companyVoiceApiTenantService == null) {
+            return AjaxResult.error("服务未就绪");
+        }
+        Long apiId = Long.valueOf(body.get("apiId").toString());
+        List<Long> tenantIds = parseIdList(body.get("tenantIds"));
+        if (tenantIds.isEmpty()) {
+            tenantIds = parseIdList(body.get("companyIds"));
+        }
+        if (tenantIds.isEmpty()) {
+            return AjaxResult.error("请选择租户");
+        }
+        companyVoiceApiTenantService.batchAssignTenants(apiId, parseTenantAssignList(body, apiId, tenantIds));
+        return AjaxResult.success();
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<CompanyVoiceApiTenant> parseTenantAssignList(Map<String, Object> body, Long apiId, List<Long> tenantIds) {
+        List<CompanyVoiceApiTenant> list = new ArrayList<>();
+        Object rawTenants = body.get("tenants");
+        if (rawTenants instanceof List) {
+            for (Object item : (List<?>) rawTenants) {
+                if (!(item instanceof Map)) {
+                    continue;
+                }
+                Map<String, Object> tenantMap = (Map<String, Object>) item;
+                CompanyVoiceApiTenant tenant = new CompanyVoiceApiTenant();
+                tenant.setApiId(apiId);
+                if (tenantMap.get("tenantId") != null) {
+                    tenant.setTenantId(Long.valueOf(tenantMap.get("tenantId").toString()));
+                }
+                if (tenantMap.get("tenantCode") != null) {
+                    tenant.setTenantCode(tenantMap.get("tenantCode").toString());
+                }
+                if (tenantMap.get("tenantName") != null) {
+                    tenant.setTenantName(tenantMap.get("tenantName").toString());
+                }
+                if (tenant.getTenantId() != null) {
+                    list.add(tenant);
+                }
+            }
+        }
+        if (list.isEmpty()) {
+            for (Long tenantId : tenantIds) {
+                CompanyVoiceApiTenant tenant = new CompanyVoiceApiTenant();
+                tenant.setApiId(apiId);
+                tenant.setTenantId(tenantId);
+                list.add(tenant);
+            }
+        }
+        return list;
+    }
+
+    /**
+     * 取消分配
+     */
+    @PreAuthorize("@ss.hasPermi('company:companyVoiceApi:edit')")
+    @Log(title = "取消通话接口分配", businessType = BusinessType.DELETE)
+    @DeleteMapping("/unassign")
+    public AjaxResult unassign(@RequestParam Long apiId,
+                               @RequestParam(required = false) Long tenantId,
+                               @RequestParam(required = false) Long companyId) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (companyVoiceApiTenantService == null) {
+            return AjaxResult.error("服务未就绪");
+        }
+        Long tid = tenantId != null ? tenantId : companyId;
+        return toAjax(companyVoiceApiTenantService.unassignTenant(apiId, tid));
+    }
+
+    /**
+     * 更新租户定价/绑定配置
+     */
+    @PreAuthorize("@ss.hasPermi('company:companyVoiceApi:edit')")
+    @Log(title = "更新外呼租户定价", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult update(@RequestBody CompanyVoiceApiTenant data) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (companyVoiceApiTenantService == null) {
+            return AjaxResult.error("服务未就绪");
+        }
+        if (data.getId() == null && data.getApiId() != null && data.getTenantId() != null) {
+            CompanyVoiceApiTenant existing = companyVoiceApiTenantService.selectByApiAndTenant(
+                    data.getApiId(), data.getTenantId());
+            if (existing != null) {
+                data.setId(existing.getId());
+            }
+        }
+        return toAjax(companyVoiceApiTenantService.updateCompanyVoiceApiTenant(data));
+    }
+
+    /**
+     * 批量更新租户定价/绑定配置
+     */
+    @PreAuthorize("@ss.hasPermi('company:companyVoiceApi:edit')")
+    @Log(title = "批量更新外呼租户定价", businessType = BusinessType.UPDATE)
+    @PutMapping("/batchPricing")
+    public AjaxResult batchPricing(@RequestBody Map<String, Object> body) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (companyVoiceApiTenantService == null) {
+            return AjaxResult.error("服务未就绪");
+        }
+        List<Long> ids = parseIdList(body.get("ids"));
+        if (ids.isEmpty()) {
+            return AjaxResult.error("请选择要更新的记录");
+        }
+        CompanyVoiceApiTenant pricing = parsePricingFields(body);
+        if (pricing.getSalePrice() == null && pricing.getPriority() == null
+                && pricing.getIsPrimary() == null && pricing.getSelectable() == null) {
+            return AjaxResult.error("请至少填写一项要批量更新的配置");
+        }
+        return toAjax(companyVoiceApiTenantService.batchUpdatePricing(ids, pricing));
+    }
+
+    /**
+     * 批量更新状态
+     */
+    @PreAuthorize("@ss.hasPermi('company:companyVoiceApi:edit')")
+    @Log(title = "批量更新外呼租户状态", businessType = BusinessType.UPDATE)
+    @PutMapping("/batchStatus")
+    public AjaxResult batchStatus(@RequestBody Map<String, Object> body) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (companyVoiceApiTenantService == null) {
+            return AjaxResult.error("服务未就绪");
+        }
+        List<Long> ids = parseIdList(body.get("ids"));
+        if (ids.isEmpty()) {
+            return AjaxResult.error("请选择要更新的记录");
+        }
+        if (body.get("status") == null) {
+            return AjaxResult.error("请指定状态");
+        }
+        Integer status = Integer.valueOf(body.get("status").toString());
+        if (status != 0 && status != 1) {
+            return AjaxResult.error("状态值无效");
+        }
+        return toAjax(companyVoiceApiTenantService.batchUpdateStatus(ids, status));
+    }
+
+    private CompanyVoiceApiTenant parsePricingFields(Map<String, Object> body) {
+        CompanyVoiceApiTenant pricing = new CompanyVoiceApiTenant();
+        if (body.get("salePrice") != null && StringUtils.isNotEmpty(body.get("salePrice").toString())) {
+            pricing.setSalePrice(new java.math.BigDecimal(body.get("salePrice").toString()));
+        }
+        if (body.get("priority") != null && StringUtils.isNotEmpty(body.get("priority").toString())) {
+            pricing.setPriority(Integer.valueOf(body.get("priority").toString()));
+        }
+        if (body.get("isPrimary") != null && StringUtils.isNotEmpty(body.get("isPrimary").toString())) {
+            pricing.setIsPrimary(Integer.valueOf(body.get("isPrimary").toString()));
+        }
+        Object selectable = body.get("selectable");
+        if (selectable == null) {
+            selectable = body.get("allowManual");
+        }
+        if (selectable != null && StringUtils.isNotEmpty(selectable.toString())) {
+            pricing.setSelectable(selectable.toString());
+        }
+        return pricing;
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<Long> parseIdList(Object raw) {
+        List<Long> ids = new ArrayList<>();
+        if (raw == null) {
+            return ids;
+        }
+        if (raw instanceof List) {
+            for (Object item : (List<?>) raw) {
+                if (item != null) {
+                    ids.add(Long.valueOf(item.toString()));
+                }
+            }
+        }
+        return ids;
+    }
+}

+ 93 - 0
fs-admin/src/main/java/com/fs/admin/controller/CompanyVoiceController.java

@@ -0,0 +1,93 @@
+package com.fs.admin.controller;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.enums.DataSourceType;
+import com.fs.company.domain.CompanyVoiceApi;
+import com.fs.company.service.ICompanyVoiceApiService;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 外呼接口管理(新版 admin 专用)
+ *
+ * @author MixLiu
+ */
+@RestController
+@RequestMapping("/admin/companyVoice")
+public class CompanyVoiceController extends BaseController {
+
+    @Autowired(required = false)
+    private ICompanyVoiceApiService companyVoiceApiService;
+
+    /**
+     * 分页查询外呼接口列表
+     */
+    @GetMapping("/list")
+    public TableDataInfo list(CompanyVoiceApi param) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        startPage();
+        List<CompanyVoiceApi> list = companyVoiceApiService != null
+                ? companyVoiceApiService.selectCompanyVoiceApiList(param)
+                : java.util.Collections.emptyList();
+        return getDataTable(list);
+    }
+
+    /**
+     * 获取外呼接口详情
+     */
+    @GetMapping("/{apiId}")
+    public AjaxResult getInfo(@PathVariable Long apiId) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (companyVoiceApiService == null) {
+            return AjaxResult.error("服务未就绪");
+        }
+        CompanyVoiceApi api = companyVoiceApiService.selectCompanyVoiceApiById(apiId);
+        return AjaxResult.success(api);
+    }
+
+    /**
+     * 新增外呼接口
+     */
+    @Log(title = "外呼接口", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody CompanyVoiceApi companyVoiceApi) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (companyVoiceApiService == null) {
+            return AjaxResult.error("服务未就绪");
+        }
+        return toAjax(companyVoiceApiService.insertCompanyVoiceApi(companyVoiceApi));
+    }
+
+    /**
+     * 修改外呼接口
+     */
+    @Log(title = "外呼接口", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody CompanyVoiceApi companyVoiceApi) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (companyVoiceApiService == null) {
+            return AjaxResult.error("服务未就绪");
+        }
+        return toAjax(companyVoiceApiService.updateCompanyVoiceApi(companyVoiceApi));
+    }
+
+    /**
+     * 删除外呼接口(逻辑删除)
+     */
+    @Log(title = "外呼接口", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{apiId}")
+    public AjaxResult remove(@PathVariable Long apiId) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (companyVoiceApiService == null) {
+            return AjaxResult.error("服务未就绪");
+        }
+        return toAjax(companyVoiceApiService.deleteCompanyVoiceApiById(apiId));
+    }
+}

+ 155 - 0
fs-ai-call-task/src/main/resources/application-common.yml

@@ -0,0 +1,155 @@
+# 项目相关配置
+fs:
+  # 名称
+  name: fs
+  # 版本
+  version: 1.1.0
+  # 版权年份
+  copyrightYear: 2021
+  # 实例演示开关
+  demoEnabled: true
+  # 文件路径 示例( Windows配置D:/fs/uploadPath,Linux配置 /home/fs/uploadPath)
+  profile: c:/fs/uploadPath
+  # 获取ip地址开关
+  addressEnabled: false
+  # 验证码类型 math 数组计算 char 字符验证
+  captchaType: math
+#  jwt:
+#    # 加密秘钥
+#    secret: f4e2e52034348f86b67cde581c0f9eb5
+#    # token有效时长,7天,单位秒
+#    expire: 31536000
+#    header: AppToken
+# 开发环境配置
+server:
+  servlet:
+    # 应用的访问路径
+    context-path: /
+  tomcat:
+    # tomcat的URI编码
+    uri-encoding: UTF-8
+    # tomcat最大线程数,默认为200
+    max-threads: 800
+    # Tomcat启动初始化的线程数,默认值25
+    min-spare-threads: 30
+
+# 日志配置
+logging:
+  level:
+    com.fs: info
+    org.springframework: warn
+
+express:
+  omsCode: "SF.0235402855"
+# Spring配置
+spring:
+  main:
+    allow-circular-references: true
+  cache:
+    type: redis
+  # 资源信息
+  messages:
+    # 国际化资源文件路径
+    basename: i18n/messages
+  mvc:
+    async:
+      request-timeout: 600000
+
+  # 文件上传
+  servlet:
+     multipart:
+       # 单个文件大小
+       max-file-size:  3GB
+       # 设置总上传的文件大小
+       max-request-size:  3GB
+  # 服务模块
+  devtools:
+    restart:
+      # 热部署开关
+      enabled: true
+
+
+# token配置
+token:
+    # 令牌自定义标识
+    header: Authorization
+    # 令牌密钥
+    secret: YlrzSaas2026SecKey!@#QwErTyUiOpAsDfGhJkLzXcVbNm
+    # 令牌有效期(默认30分钟)
+    expireTime: 720
+mybatis-plus:
+  # 搜索指定包别名
+  typeAliasesPackage: com.fs.**.domain,com.fs.**.bo
+  # 配置mapper的扫描,找到所有的mapper.xml映射文件
+  mapperLocations: classpath*:/mapper/**/*.xml
+  configLocation: classpath:mybatis/mybatis-config.xml
+  # 全局配置
+  global-config:
+    db-config:
+      # 主键类型  0:"数据库ID自增", 1:"用户输入ID",2:"全局唯一ID (数字类型唯一ID)", 3:"全局唯一ID UUID";
+      idType: AUTO
+      # 字段策略 0:"忽略判断",1:"非 NULL 判断"),2:"非空判断"
+      fieldStrategy: NOT_EMPTY
+    banner: false
+    # 配置
+  configuration:
+    # 驼峰式命名
+    mapUnderscoreToCamelCase: true
+    # 全局映射器启用缓存
+    cacheEnabled: true
+    # 配置默认的执行器
+    defaultExecutorType: REUSE
+    # 允许 JDBC 支持自动生成主键
+    useGeneratedKeys: true
+
+# MyBatis配置
+mybatis:
+    # 搜索指定包别名
+    typeAliasesPackage: com.fs.**.domain
+    # 配置mapper的扫描,找到所有的mapper.xml映射文件
+    mapperLocations: classpath*:mapper/**/*Mapper.xml
+    # 加载全局的配置文件
+    configLocation: classpath:mybatis/mybatis-config.xml
+
+# PageHelper分页插件
+pagehelper:
+  helperDialect: mysql
+  reasonable: false #超出后不显示
+  supportMethodsArguments: false
+  params: count=countSql
+
+# Swagger配置
+swagger:
+  # 是否开启swagger
+  enabled: false
+  # 请求前缀
+  pathMapping: /dev-api
+
+# 防止XSS攻击
+xss:
+  # 过滤开关
+  enabled: true
+  # 排除链接(多个用逗号分隔)
+  excludes: /system/notice,/system/config/*
+  # 匹配链接
+  urlPatterns: /system/*,/monitor/*,/tool/*
+zhyf:
+  url: https://zhyf-testController.jingpai.com
+
+image:
+  storage:
+    local-path: C:\logoFile\logo.jpg
+    server-path: C:\logoFile\logo.jpg
+# application.properties
+wechat:
+  api:
+    base-url: https://api.weixin.qq.com
+    upload-shipping-info: /wxa/sec/order/upload_shipping_info
+hsy:
+  access_key: AKLTZTc4YTE4ZjI2OWViNDNjZGI2NjhiYTI5Njc5ZjA1Mzk
+  secret_key: WXpjelpUYzFOakF5TUdObE5EZGtNR0ZsWXpKaU1tTmtZakk1WXpObE4yRQ==
+  region: cn-north-1
+  role_access_key: AKLTNmMwNjJkNDFhYTVjNDIzYzhhNzEyZmZmZTlmYzBhNGM
+  role_secret_key: T0RaaFl6UmhZV1V4WXpKbU5EWTBNMkZpT0RNNU9UY3daak0wTjJFd09XUQ==
+  role_trn: trn:iam::2114522511:role/hylj
+

+ 137 - 0
fs-ai-call-task/src/main/resources/application-config-dev.yml

@@ -0,0 +1,137 @@
+baidu:
+  token: 12313231232
+  back-domain: https://www.xxxx.com
+#配置
+logging:
+  level:
+    org.springframework.web: debug
+    com.github.binarywang.demo.wx.cp: DEBUG
+    me.chanjar.weixin: DEBUG
+#wx:
+#  miniapp:
+#    configs:
+#      - appid: wx29d26f63f836be7f
+#        secret: 7542db9774355a89b1adce24defb6013
+#        token: Ncbnd7lJvkripVOpyTFAna6NAWCxCrvC
+#        aesKey: HlEiBB55eaWUaeBVAQO3cWKWPYv1vOVQSq7nFNICw4E
+#        msgDataFormat: JSON
+#  cp:
+#    corpId: wwb2a1055fb6c9a7c2
+#    appConfigs:
+#      - agentId: 1000005
+#        secret: ec7okROXJqkNafq66-L6aKNv0asTzQIG0CYrj3vyBbo
+#        token: PPKOdAlCoMO
+#        aesKey: PKvaxtpSv8NGpfTDm7VUHIK8Wok2ESyYX24qpXJAdMP
+#  pay:
+#    appId: wx73f85f8d62769119 #微信公众号或者小程序等的appid
+#    mchId: 1611402045 #微信支付商户号
+#    mchKey: 8cab128997a3547c1363b0898b877f38 #微信支付商户密钥
+#    subAppId:  #服务商模式下的子商户公众账号ID
+#    subMchId:  #服务商模式下的子商户号
+#    keyPath: c:\\cert\\apiclient_cert.p12 # p12证书的位置,可以指定绝对路径,也可以指定类路径(以classpath:开头)
+#    notifyUrl: https://userapp.his.runtzh.com/app/wxpay/wxPayNotify
+#  mp:
+#    useRedis: false
+#    redisConfig:
+#      host: 127.0.0.1
+#      port: 6379
+#      timeout: 2000
+#    configs:
+#      - appId: wx93ce67750e3cfba3 # 第一个公众号的appid  //公众号名称:云联融智
+#        secret: c172884087264160563bfe5775ca0f6f # 公众号的appsecret
+#        token: PPKOdAlCoMO # 接口配置里的Token值
+#        aesKey: Eswa6VjwtVMCcw03qZy6fWllgrv5aytIA1SZPEU0kU2 # 接口配置里的EncodingAESKey值
+#aifabu:  #爱链接
+#  appKey: 7b471be905ab17e00f3b858c6710dd117601d008
+#watch:
+#  watchUrl: watch.ylrzcloud.com/prod-api
+#  #  account: tcloud
+#  #  password: mdf-m2h_6yw2$hq
+#  account1: ccif #866655060138751
+#  password1: cp-t5or_6xw7$mt
+#  account2: tcloud #rt500台
+#  password2: mdf-m2h_6yw2$hq
+#  account3: whr
+#  password3: v9xsKuqn_$d2y
+#
+#fs :
+#  commonApi: http://172.16.0.16:8010
+#  h5CommonApi: http://119.29.195.254:8010
+#  jwt:
+#    # 加密秘钥
+#    secret: e10adc3949ba59abbe56e057f20f883e
+#    # token有效时长,7天,单位秒
+#    expire: 31536000
+#    header: AppToken
+#nuonuo:
+#  key: 10924508
+#  secret: A2EB20764D304D16
+#
+## 存储捅配置
+#tencent_cloud_config:
+#  secret_id: AKIDiMq9lDf2EOM9lIfqqfKo7FNgM5meD0sT
+#  secret_key: u5SuS80342xzx8FRBukza9lVNHKNMSaB
+#  bucket: myhk-1323137866
+#  app_id: 1323137866
+#  region: ap-chongqing
+#  proxy: myhk
+#cloud_host:
+#  company_name: 金康健
+#  projectCode: DEV
+#  spaceName:
+#  volcengineUrl:
+#headerImg:
+#  imgUrl: https://jz-cos-1356808054.cos.ap-chengdu.myqcloud.com/fs/20250515/0877754b59814ea8a428fa3697b20e68.png
+#ipad:
+#  url:
+#  ipadUrl: http://ipad.cdwjyyh.com
+#  aiApi: http://152.136.202.157:3000/api
+#  voiceApi:
+#  commonApi:
+#wx_miniapp_temp:
+#  pay_order_temp_id:
+#  inquiry_temp_id:
+## 聚水潭API配置
+#jst:
+##  app_key: a4b1fab173c84f67b3873857eea11d90 #聚水潭2025-07-25
+#  app_key: 871348458a964548a72bf8124cf917a4 #聚水潭2025-08-14
+#  app_secret: 5b7d9369dbcd414db45089bc047ebe1a #聚水潭2025-08-14
+##  app_secret: dfce1f8dc8a64ddc91212fc3fcdd9349 #聚水潭2025-07-25
+#  authorization_code: 666666
+#  shop_code: "18461733"
+#
+## RocketMQ配置
+#rocketmq:
+#  name-server: 127.0.0.1:9876
+#  producer:
+#    group: event-feedback-producer
+#    send-message-timeout: 3000
+#    retry-times-when-send-failed: 2
+#    retry-times-when-send-async-failed: 2
+#    max-message-size: 4194304
+#    compress-message-body-threshold: 4096
+#    retry-next-server: true
+#custom:
+#  token: "1o62d3YxvdHd4LEUiltnu7sK"
+#  encoding-aes-key: "UJfTQ5qKTKlegjkXtp1YuzJzxeHlUKvq5GyFbERN1iU"
+#  corp-id: "ww51717e2b71d5e2d3"configValue
+#  secret: "6ODAmw-8W4t6h9mdzHh2Z4Apwj8mnsyRnjEDZOHdA7k"
+#  private-key-path: "privatekey.pem"
+#  webhook-url: "https://your-server.com/wecom/archive"
+## token配置
+#token:
+#  # 令牌自定义标识
+#  header: Authorization
+#  # 令牌密钥
+#  secret: abcdefghijklmnopqrstuvwxyz
+#  # 令牌有效期(默认30分钟)
+#  expireTime: 180
+#openIM:
+#  secret: openIM123
+#  userID: imAdmin
+#  url: https://web.jnmyim.ylrzfs.com/api
+##是否为新商户,新商户不走mpOpenId
+#isNewWxMerchant: true
+##是否使用新im
+#im:
+#  type: OPENIM

+ 132 - 0
fs-ai-call-task/src/main/resources/application-dev.yml

@@ -0,0 +1,132 @@
+# 数据源配置
+spring:
+    # redis 配置
+    redis:
+        # 地址
+        host: localhost
+        # 端口,默认为6379
+        port: 6379
+        # 数据库索引
+        database: 0
+        # 密码
+        password:
+        # 连接超时时间
+        timeout: 20s
+        lettuce:
+            pool:
+                # 连接池中的最小空闲连接
+                min-idle: 0
+                # 连接池中的最大空闲连接
+                max-idle: 8
+                # 连接池的最大数据库连接数
+                max-active: 8
+                # #连接池最大阻塞等待时间(使用负值表示没有限制)
+                max-wait: -1ms
+    datasource:
+        mysql:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                initialSize: 5
+                minIdle: 10
+                maxActive: 20
+                maxWait: 60000
+                timeBetweenEvictionRunsMillis: 60000
+                minEvictableIdleTimeMillis: 300000
+                maxEvictableIdleTimeMillis: 900000
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                # 主库数据源
+                master:
+                    url: jdbc:mysql://cq-cdb-8fjmemkb.sql.tencentcdb.com:27220/ylrz_saas?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false
+                    username: root
+                    password: Ylrz_1q2w3e4r5t6y
+                    # 初始连接数
+                    initialSize: 5
+                    # 最小连接池数量
+                    minIdle: 10
+                    # 最大连接池数量
+                    maxActive: 20
+                    # 配置获取连接等待超时的时间
+                    maxWait: 60000
+                    # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                    timeBetweenEvictionRunsMillis: 60000
+                    # 配置一个连接在池中最小生存的时间,单位是毫秒
+                    minEvictableIdleTimeMillis: 300000
+                    # 配置一个连接在池中最大生存的时间,单位是毫秒
+                    maxEvictableIdleTimeMillis: 900000
+                    # 配置检测连接是否有效
+                    validationQuery: SELECT 1 FROM DUAL
+                    testWhileIdle: true
+                    testOnBorrow: false
+                    testOnReturn: false
+                    webStatFilter:
+                        enabled: true
+                    statViewServlet:
+                        enabled: true
+                        # 设置白名单,不填则允许所有访问
+                        allow:
+                        url-pattern: /druid/*
+                        # 控制台管理用户名和密码
+                        login-username: fs
+                        login-password: 123456
+                    filter:
+                        stat:
+                            enabled: true
+                            # 慢SQL记录
+                            log-slow-sql: true
+                            slow-sql-millis: 1000
+                            merge-sql: true
+                        wall:
+                            config:
+                                multi-statement-allow: true
+        easycall:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                # 主库数据源
+                master:
+                    url: jdbc:mysql://129.28.164.235:3306/easycallcenter365?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                    username: root
+                    password: easycallcenter365
+                # 初始连接数
+                initialSize: 5
+                # 最小连接池数量
+                minIdle: 10
+                # 最大连接池数量
+                maxActive: 20
+                # 配置获取连接等待超时的时间
+                maxWait: 60000
+                # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                timeBetweenEvictionRunsMillis: 60000
+                # 配置一个连接在池中最小生存的时间,单位是毫秒
+                minEvictableIdleTimeMillis: 300000
+                # 配置一个连接在池中最大生存的时间,单位是毫秒
+                maxEvictableIdleTimeMillis: 900000
+                # 配置检测连接是否有效
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                webStatFilter:
+                    enabled: true
+                statViewServlet:
+                    enabled: true
+                    # 设置白名单,不填则允许所有访问
+                    allow:
+                    url-pattern: /druid/*
+                    # 控制台管理用户名和密码
+                    login-username: fs
+                    login-password: 123456
+                filter:
+                    stat:
+                        enabled: true
+                        # 慢SQL记录
+                        log-slow-sql: true
+                        slow-sql-millis: 1000
+                        merge-sql: true
+                    wall:
+                        config:
+                            multi-statement-allow: true

+ 155 - 0
fs-cid-workflow/src/main/resources/application-common.yml

@@ -0,0 +1,155 @@
+# 项目相关配置
+fs:
+  # 名称
+  name: fs
+  # 版本
+  version: 1.1.0
+  # 版权年份
+  copyrightYear: 2021
+  # 实例演示开关
+  demoEnabled: true
+  # 文件路径 示例( Windows配置D:/fs/uploadPath,Linux配置 /home/fs/uploadPath)
+  profile: c:/fs/uploadPath
+  # 获取ip地址开关
+  addressEnabled: false
+  # 验证码类型 math 数组计算 char 字符验证
+  captchaType: math
+#  jwt:
+#    # 加密秘钥
+#    secret: f4e2e52034348f86b67cde581c0f9eb5
+#    # token有效时长,7天,单位秒
+#    expire: 31536000
+#    header: AppToken
+# 开发环境配置
+server:
+  servlet:
+    # 应用的访问路径
+    context-path: /
+  tomcat:
+    # tomcat的URI编码
+    uri-encoding: UTF-8
+    # tomcat最大线程数,默认为200
+    max-threads: 800
+    # Tomcat启动初始化的线程数,默认值25
+    min-spare-threads: 30
+
+# 日志配置
+logging:
+  level:
+    com.fs: info
+    org.springframework: warn
+
+express:
+  omsCode: "SF.0235402855"
+# Spring配置
+spring:
+  main:
+    allow-circular-references: true
+  cache:
+    type: redis
+  # 资源信息
+  messages:
+    # 国际化资源文件路径
+    basename: i18n/messages
+  mvc:
+    async:
+      request-timeout: 600000
+
+  # 文件上传
+  servlet:
+     multipart:
+       # 单个文件大小
+       max-file-size:  3GB
+       # 设置总上传的文件大小
+       max-request-size:  3GB
+  # 服务模块
+  devtools:
+    restart:
+      # 热部署开关
+      enabled: true
+
+
+# token配置
+token:
+    # 令牌自定义标识
+    header: Authorization
+    # 令牌密钥
+    secret: YlrzSaas2026SecKey!@#QwErTyUiOpAsDfGhJkLzXcVbNm
+    # 令牌有效期(默认30分钟)
+    expireTime: 720
+mybatis-plus:
+  # 搜索指定包别名
+  typeAliasesPackage: com.fs.**.domain,com.fs.**.bo
+  # 配置mapper的扫描,找到所有的mapper.xml映射文件
+  mapperLocations: classpath*:/mapper/**/*.xml
+  configLocation: classpath:mybatis/mybatis-config.xml
+  # 全局配置
+  global-config:
+    db-config:
+      # 主键类型  0:"数据库ID自增", 1:"用户输入ID",2:"全局唯一ID (数字类型唯一ID)", 3:"全局唯一ID UUID";
+      idType: AUTO
+      # 字段策略 0:"忽略判断",1:"非 NULL 判断"),2:"非空判断"
+      fieldStrategy: NOT_EMPTY
+    banner: false
+    # 配置
+  configuration:
+    # 驼峰式命名
+    mapUnderscoreToCamelCase: true
+    # 全局映射器启用缓存
+    cacheEnabled: true
+    # 配置默认的执行器
+    defaultExecutorType: REUSE
+    # 允许 JDBC 支持自动生成主键
+    useGeneratedKeys: true
+
+# MyBatis配置
+mybatis:
+    # 搜索指定包别名
+    typeAliasesPackage: com.fs.**.domain
+    # 配置mapper的扫描,找到所有的mapper.xml映射文件
+    mapperLocations: classpath*:mapper/**/*Mapper.xml
+    # 加载全局的配置文件
+    configLocation: classpath:mybatis/mybatis-config.xml
+
+# PageHelper分页插件
+pagehelper:
+  helperDialect: mysql
+  reasonable: false #超出后不显示
+  supportMethodsArguments: false
+  params: count=countSql
+
+# Swagger配置
+swagger:
+  # 是否开启swagger
+  enabled: false
+  # 请求前缀
+  pathMapping: /dev-api
+
+# 防止XSS攻击
+xss:
+  # 过滤开关
+  enabled: true
+  # 排除链接(多个用逗号分隔)
+  excludes: /system/notice,/system/config/*
+  # 匹配链接
+  urlPatterns: /system/*,/monitor/*,/tool/*
+zhyf:
+  url: https://zhyf-testController.jingpai.com
+
+image:
+  storage:
+    local-path: C:\logoFile\logo.jpg
+    server-path: C:\logoFile\logo.jpg
+# application.properties
+wechat:
+  api:
+    base-url: https://api.weixin.qq.com
+    upload-shipping-info: /wxa/sec/order/upload_shipping_info
+hsy:
+  access_key: AKLTZTc4YTE4ZjI2OWViNDNjZGI2NjhiYTI5Njc5ZjA1Mzk
+  secret_key: WXpjelpUYzFOakF5TUdObE5EZGtNR0ZsWXpKaU1tTmtZakk1WXpObE4yRQ==
+  region: cn-north-1
+  role_access_key: AKLTNmMwNjJkNDFhYTVjNDIzYzhhNzEyZmZmZTlmYzBhNGM
+  role_secret_key: T0RaaFl6UmhZV1V4WXpKbU5EWTBNMkZpT0RNNU9UY3daak0wTjJFd09XUQ==
+  role_trn: trn:iam::2114522511:role/hylj
+

+ 137 - 0
fs-cid-workflow/src/main/resources/application-config-dev.yml

@@ -0,0 +1,137 @@
+baidu:
+  token: 12313231232
+  back-domain: https://www.xxxx.com
+#配置
+logging:
+  level:
+    org.springframework.web: debug
+    com.github.binarywang.demo.wx.cp: DEBUG
+    me.chanjar.weixin: DEBUG
+#wx:
+#  miniapp:
+#    configs:
+#      - appid: wx29d26f63f836be7f
+#        secret: 7542db9774355a89b1adce24defb6013
+#        token: Ncbnd7lJvkripVOpyTFAna6NAWCxCrvC
+#        aesKey: HlEiBB55eaWUaeBVAQO3cWKWPYv1vOVQSq7nFNICw4E
+#        msgDataFormat: JSON
+#  cp:
+#    corpId: wwb2a1055fb6c9a7c2
+#    appConfigs:
+#      - agentId: 1000005
+#        secret: ec7okROXJqkNafq66-L6aKNv0asTzQIG0CYrj3vyBbo
+#        token: PPKOdAlCoMO
+#        aesKey: PKvaxtpSv8NGpfTDm7VUHIK8Wok2ESyYX24qpXJAdMP
+#  pay:
+#    appId: wx73f85f8d62769119 #微信公众号或者小程序等的appid
+#    mchId: 1611402045 #微信支付商户号
+#    mchKey: 8cab128997a3547c1363b0898b877f38 #微信支付商户密钥
+#    subAppId:  #服务商模式下的子商户公众账号ID
+#    subMchId:  #服务商模式下的子商户号
+#    keyPath: c:\\cert\\apiclient_cert.p12 # p12证书的位置,可以指定绝对路径,也可以指定类路径(以classpath:开头)
+#    notifyUrl: https://userapp.his.runtzh.com/app/wxpay/wxPayNotify
+#  mp:
+#    useRedis: false
+#    redisConfig:
+#      host: 127.0.0.1
+#      port: 6379
+#      timeout: 2000
+#    configs:
+#      - appId: wx93ce67750e3cfba3 # 第一个公众号的appid  //公众号名称:云联融智
+#        secret: c172884087264160563bfe5775ca0f6f # 公众号的appsecret
+#        token: PPKOdAlCoMO # 接口配置里的Token值
+#        aesKey: Eswa6VjwtVMCcw03qZy6fWllgrv5aytIA1SZPEU0kU2 # 接口配置里的EncodingAESKey值
+#aifabu:  #爱链接
+#  appKey: 7b471be905ab17e00f3b858c6710dd117601d008
+#watch:
+#  watchUrl: watch.ylrzcloud.com/prod-api
+#  #  account: tcloud
+#  #  password: mdf-m2h_6yw2$hq
+#  account1: ccif #866655060138751
+#  password1: cp-t5or_6xw7$mt
+#  account2: tcloud #rt500台
+#  password2: mdf-m2h_6yw2$hq
+#  account3: whr
+#  password3: v9xsKuqn_$d2y
+#
+#fs :
+#  commonApi: http://172.16.0.16:8010
+#  h5CommonApi: http://119.29.195.254:8010
+#  jwt:
+#    # 加密秘钥
+#    secret: e10adc3949ba59abbe56e057f20f883e
+#    # token有效时长,7天,单位秒
+#    expire: 31536000
+#    header: AppToken
+#nuonuo:
+#  key: 10924508
+#  secret: A2EB20764D304D16
+#
+## 存储捅配置
+#tencent_cloud_config:
+#  secret_id: AKIDiMq9lDf2EOM9lIfqqfKo7FNgM5meD0sT
+#  secret_key: u5SuS80342xzx8FRBukza9lVNHKNMSaB
+#  bucket: myhk-1323137866
+#  app_id: 1323137866
+#  region: ap-chongqing
+#  proxy: myhk
+#cloud_host:
+#  company_name: 金康健
+#  projectCode: DEV
+#  spaceName:
+#  volcengineUrl:
+#headerImg:
+#  imgUrl: https://jz-cos-1356808054.cos.ap-chengdu.myqcloud.com/fs/20250515/0877754b59814ea8a428fa3697b20e68.png
+#ipad:
+#  url:
+#  ipadUrl: http://ipad.cdwjyyh.com
+#  aiApi: http://152.136.202.157:3000/api
+#  voiceApi:
+#  commonApi:
+#wx_miniapp_temp:
+#  pay_order_temp_id:
+#  inquiry_temp_id:
+## 聚水潭API配置
+#jst:
+##  app_key: a4b1fab173c84f67b3873857eea11d90 #聚水潭2025-07-25
+#  app_key: 871348458a964548a72bf8124cf917a4 #聚水潭2025-08-14
+#  app_secret: 5b7d9369dbcd414db45089bc047ebe1a #聚水潭2025-08-14
+##  app_secret: dfce1f8dc8a64ddc91212fc3fcdd9349 #聚水潭2025-07-25
+#  authorization_code: 666666
+#  shop_code: "18461733"
+#
+## RocketMQ配置
+#rocketmq:
+#  name-server: 127.0.0.1:9876
+#  producer:
+#    group: event-feedback-producer
+#    send-message-timeout: 3000
+#    retry-times-when-send-failed: 2
+#    retry-times-when-send-async-failed: 2
+#    max-message-size: 4194304
+#    compress-message-body-threshold: 4096
+#    retry-next-server: true
+#custom:
+#  token: "1o62d3YxvdHd4LEUiltnu7sK"
+#  encoding-aes-key: "UJfTQ5qKTKlegjkXtp1YuzJzxeHlUKvq5GyFbERN1iU"
+#  corp-id: "ww51717e2b71d5e2d3"configValue
+#  secret: "6ODAmw-8W4t6h9mdzHh2Z4Apwj8mnsyRnjEDZOHdA7k"
+#  private-key-path: "privatekey.pem"
+#  webhook-url: "https://your-server.com/wecom/archive"
+## token配置
+#token:
+#  # 令牌自定义标识
+#  header: Authorization
+#  # 令牌密钥
+#  secret: abcdefghijklmnopqrstuvwxyz
+#  # 令牌有效期(默认30分钟)
+#  expireTime: 180
+#openIM:
+#  secret: openIM123
+#  userID: imAdmin
+#  url: https://web.jnmyim.ylrzfs.com/api
+##是否为新商户,新商户不走mpOpenId
+#isNewWxMerchant: true
+##是否使用新im
+#im:
+#  type: OPENIM

+ 132 - 0
fs-cid-workflow/src/main/resources/application-dev.yml

@@ -0,0 +1,132 @@
+# 数据源配置
+spring:
+    # redis 配置
+    redis:
+        # 地址
+        host: localhost
+        # 端口,默认为6379
+        port: 6379
+        # 数据库索引
+        database: 0
+        # 密码
+        password:
+        # 连接超时时间
+        timeout: 20s
+        lettuce:
+            pool:
+                # 连接池中的最小空闲连接
+                min-idle: 0
+                # 连接池中的最大空闲连接
+                max-idle: 8
+                # 连接池的最大数据库连接数
+                max-active: 8
+                # #连接池最大阻塞等待时间(使用负值表示没有限制)
+                max-wait: -1ms
+    datasource:
+        mysql:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                initialSize: 5
+                minIdle: 10
+                maxActive: 20
+                maxWait: 60000
+                timeBetweenEvictionRunsMillis: 60000
+                minEvictableIdleTimeMillis: 300000
+                maxEvictableIdleTimeMillis: 900000
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                # 主库数据源
+                master:
+                    url: jdbc:mysql://cq-cdb-8fjmemkb.sql.tencentcdb.com:27220/ylrz_saas?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false
+                    username: root
+                    password: Ylrz_1q2w3e4r5t6y
+                    # 初始连接数
+                    initialSize: 5
+                    # 最小连接池数量
+                    minIdle: 10
+                    # 最大连接池数量
+                    maxActive: 20
+                    # 配置获取连接等待超时的时间
+                    maxWait: 60000
+                    # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                    timeBetweenEvictionRunsMillis: 60000
+                    # 配置一个连接在池中最小生存的时间,单位是毫秒
+                    minEvictableIdleTimeMillis: 300000
+                    # 配置一个连接在池中最大生存的时间,单位是毫秒
+                    maxEvictableIdleTimeMillis: 900000
+                    # 配置检测连接是否有效
+                    validationQuery: SELECT 1 FROM DUAL
+                    testWhileIdle: true
+                    testOnBorrow: false
+                    testOnReturn: false
+                    webStatFilter:
+                        enabled: true
+                    statViewServlet:
+                        enabled: true
+                        # 设置白名单,不填则允许所有访问
+                        allow:
+                        url-pattern: /druid/*
+                        # 控制台管理用户名和密码
+                        login-username: fs
+                        login-password: 123456
+                    filter:
+                        stat:
+                            enabled: true
+                            # 慢SQL记录
+                            log-slow-sql: true
+                            slow-sql-millis: 1000
+                            merge-sql: true
+                        wall:
+                            config:
+                                multi-statement-allow: true
+        easycall:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                # 主库数据源
+                master:
+                    url: jdbc:mysql://129.28.164.235:3306/easycallcenter365?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                    username: root
+                    password: easycallcenter365
+                # 初始连接数
+                initialSize: 5
+                # 最小连接池数量
+                minIdle: 10
+                # 最大连接池数量
+                maxActive: 20
+                # 配置获取连接等待超时的时间
+                maxWait: 60000
+                # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                timeBetweenEvictionRunsMillis: 60000
+                # 配置一个连接在池中最小生存的时间,单位是毫秒
+                minEvictableIdleTimeMillis: 300000
+                # 配置一个连接在池中最大生存的时间,单位是毫秒
+                maxEvictableIdleTimeMillis: 900000
+                # 配置检测连接是否有效
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                webStatFilter:
+                    enabled: true
+                statViewServlet:
+                    enabled: true
+                    # 设置白名单,不填则允许所有访问
+                    allow:
+                    url-pattern: /druid/*
+                    # 控制台管理用户名和密码
+                    login-username: fs
+                    login-password: 123456
+                filter:
+                    stat:
+                        enabled: true
+                        # 慢SQL记录
+                        log-slow-sql: true
+                        slow-sql-millis: 1000
+                        merge-sql: true
+                    wall:
+                        config:
+                            multi-statement-allow: true

+ 4 - 0
fs-comm-gateway/Dockerfile

@@ -0,0 +1,4 @@
+FROM openjdk:17-jre
+COPY ./target/fs-comm-gateway.jar fs-comm-gateway.jar
+EXPOSE 8010
+ENTRYPOINT ["java","-jar","fs-comm-gateway.jar"]

+ 14 - 0
fs-comm-gateway/nginx.conf

@@ -0,0 +1,14 @@
+server {
+    listen 80;
+    server_name comm-gateway.local;
+
+    location /comm/ {
+        proxy_pass http://127.0.0.1:8010/comm/;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_connect_timeout 120s;
+        proxy_send_timeout 120s;
+        proxy_read_timeout 120s;
+    }
+}

+ 100 - 0
fs-comm-gateway/pom.xml

@@ -0,0 +1,100 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>fs</artifactId>
+        <groupId>com.fs</groupId>
+        <version>1.1.0</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>fs-comm-gateway</artifactId>
+    <description>通讯中间件:外呼/短信统一网关</description>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-aop</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-security</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-redis</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.springfox</groupId>
+            <artifactId>springfox-swagger2</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.springfox</groupId>
+            <artifactId>springfox-swagger-ui</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>mysql</groupId>
+            <artifactId>mysql-connector-java</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.alibaba</groupId>
+            <artifactId>druid-spring-boot-starter</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.baomidou</groupId>
+            <artifactId>dynamic-datasource-spring-boot-starter</artifactId>
+            <version>3.1.0</version>
+        </dependency>
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.jsonwebtoken</groupId>
+            <artifactId>jjwt</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.fs</groupId>
+            <artifactId>fs-service</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.fs</groupId>
+            <artifactId>fs-framework</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.fs</groupId>
+            <artifactId>fs-common</artifactId>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>2.7.18</version>
+                <configuration>
+                    <mainClass>com.fs.CommGatewayApplication</mainClass>
+                    <fork>true</fork>
+                </configuration>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>repackage</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+        <finalName>${project.artifactId}</finalName>
+    </build>
+</project>

+ 33 - 0
fs-comm-gateway/src/main/java/com/fs/CommGatewayApplication.java

@@ -0,0 +1,33 @@
+package com.fs;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.FilterType;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+
+/**
+ * 通讯中间件启动类(外呼/短信统一网关)
+ */
+@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
+@ComponentScan(
+        basePackages = "com.fs",
+        excludeFilters = @ComponentScan.Filter(type = FilterType.REGEX, pattern = {
+                "com\\.fs\\.framework\\.web\\.service\\.PermissionService",
+                "com\\.fs\\.framework\\.web\\.service\\.UserDetailsServiceImpl",
+                "com\\.fs\\.framework\\.web\\.service\\.SysLoginService",
+                "com\\.fs\\.framework\\.web\\.exception\\.GlobalExceptionHandler",
+                "com\\.fs\\.framework\\.security\\.filter\\.JwtAuthenticationTokenFilter"
+        })
+)
+@EnableTransactionManagement
+@EnableAsync
+public class CommGatewayApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(CommGatewayApplication.class, args);
+        System.out.println("通讯中间件 fs-comm-gateway 启动成功");
+    }
+}

+ 70 - 0
fs-comm-gateway/src/main/java/com/fs/comm/aspectj/CommCallbackIpCheckAspect.java

@@ -0,0 +1,70 @@
+package com.fs.comm.aspectj;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.aicall.utils.StringUtils;
+import com.fs.common.annotation.CallbackIpCheck;
+import com.fs.common.utils.IpUtil;
+import com.fs.company.util.IpCheckUtil;
+import com.fs.company.vo.CidConfigVO;
+import com.fs.system.domain.SysConfig;
+import com.fs.system.mapper.SysConfigMapper;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Pointcut;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+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.lang.reflect.Method;
+
+@Aspect
+@Component
+public class CommCallbackIpCheckAspect {
+
+    private static final Logger log = LoggerFactory.getLogger(CommCallbackIpCheckAspect.class);
+
+    @Autowired
+    private SysConfigMapper sysConfigMapper;
+
+    @Pointcut("@annotation(com.fs.common.annotation.CallbackIpCheck)")
+    public void ipCheckPointCut() {
+    }
+
+    @Around("ipCheckPointCut()")
+    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
+        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
+        Method method = signature.getMethod();
+        CallbackIpCheck annotation = method.getAnnotation(CallbackIpCheck.class);
+
+        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+        if (attributes == null) {
+            throw new IllegalStateException("CallbackIpCheck: 无法获取当前请求上下文");
+        }
+        HttpServletRequest request = attributes.getRequest();
+        String clientIp = IpUtil.getRequestIp(request);
+
+        String configKey = annotation.configKey();
+        SysConfig sysConfig = sysConfigMapper.selectConfigByConfigKey(configKey);
+        if (sysConfig == null || StringUtils.isBlank(sysConfig.getConfigValue())) {
+            log.error("CallbackIpCheck: 未找到配置, configKey={}, 请求IP: {}", configKey, clientIp);
+            throw new IllegalArgumentException("CallbackIpCheck: 未找到配置");
+        }
+
+        CidConfigVO cidConf = JSONObject.parseObject(sysConfig.getConfigValue(), CidConfigVO.class);
+        String legalIPs = cidConf.getLegalIPs();
+        if (!IpCheckUtil.isIpInList(clientIp, legalIPs)) {
+            log.warn("非法回调来源IP: {}, legalIPs: {}", clientIp, legalIPs);
+            if (method.getReturnType() == String.class) {
+                return "illegal IP";
+            }
+            throw new SecurityException("非法IP来源,请求IP: " + clientIp);
+        }
+        return joinPoint.proceed();
+    }
+}

+ 37 - 0
fs-comm-gateway/src/main/java/com/fs/comm/auth/CommAuthController.java

@@ -0,0 +1,37 @@
+package com.fs.comm.auth;
+
+import com.fs.comm.auth.dto.CommTokenRequest;
+import com.fs.comm.auth.dto.CommTokenResponse;
+import com.fs.comm.dto.CommApiResult;
+import com.fs.common.utils.ServletUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.validation.annotation.Validated;
+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;
+
+@RestController
+@RequestMapping("/comm/auth")
+public class CommAuthController {
+
+    @Autowired
+    private CommTokenService commTokenService;
+
+    @PostMapping("/token")
+    public CommApiResult<CommTokenResponse> token(@Validated @RequestBody CommTokenRequest request) {
+        CommTokenResponse response = commTokenService.login(request.getTenantCode(), request.getAccount(), request.getPassword());
+        return CommApiResult.success(response);
+    }
+
+    @PostMapping("/refresh")
+    public CommApiResult<CommTokenResponse> refresh() {
+        return CommApiResult.success(commTokenService.refreshToken(ServletUtils.getRequest()));
+    }
+
+    @PostMapping("/logout")
+    public CommApiResult<Void> logout() {
+        commTokenService.logout(ServletUtils.getRequest());
+        return CommApiResult.success(null);
+    }
+}

+ 36 - 0
fs-comm-gateway/src/main/java/com/fs/comm/auth/CommSession.java

@@ -0,0 +1,36 @@
+package com.fs.comm.auth;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 通讯网关登录会话(存 Redis)
+ */
+@Data
+public class CommSession implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 令牌 uuid */
+    private String token;
+
+    private Long tenantId;
+
+    private String tenantCode;
+
+    private Long companyId;
+
+    private Long companyUserId;
+
+    private String account;
+
+    /** external / internal */
+    private String scope;
+
+    private String ipaddr;
+
+    private Long loginTime;
+
+    private Long expireTime;
+}

+ 217 - 0
fs-comm-gateway/src/main/java/com/fs/comm/auth/CommTokenService.java

@@ -0,0 +1,217 @@
+package com.fs.comm.auth;
+
+import com.fs.comm.auth.dto.CommTokenResponse;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.enums.UserStatus;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.ip.IpUtils;
+import com.fs.common.utils.uuid.IdUtils;
+import com.fs.company.domain.Company;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.service.ICompanyService;
+import com.fs.company.service.ICompanyUserService;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.framework.datasource.TenantDataSourceManager;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.service.TenantInfoService;
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+@Slf4j
+@Service
+public class CommTokenService {
+
+    public static final String COMM_TOKEN_KEY = "comm_token_key";
+    public static final String CLAIM_TENANT_ID = "tenantId";
+    public static final String SCOPE_EXTERNAL = "external";
+    public static final String SCOPE_INTERNAL = "internal";
+
+    @Value("${token.header:Authorization}")
+    private String header;
+
+    @Value("${token.secret}")
+    private String secret;
+
+    @Value("${token.expireTime:120}")
+    private int expireTimeMinutes;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    @Autowired
+    private TenantInfoService tenantInfoService;
+
+    @Autowired
+    private ICompanyUserService companyUserService;
+
+    @Autowired
+    private ICompanyService companyService;
+
+    @Autowired
+    private TenantDataSourceManager tenantDataSourceManager;
+
+    public CommTokenResponse login(String tenantCode, String account, String password) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        TenantInfo tenantInfo = tenantInfoService.selectTenantInfoByCode(tenantCode);
+        if (tenantInfo == null) {
+            throw new ServiceException("租户不存在");
+        }
+        if (tenantInfo.getStatus() == null || tenantInfo.getStatus() != 1) {
+            throw new ServiceException("租户已禁用");
+        }
+        tenantDataSourceManager.switchTenant(tenantInfo);
+        try {
+            CompanyUser user = companyUserService.selectUserByUserName(account);
+            if (user == null) {
+                throw new ServiceException("账号或密码错误");
+            }
+            if (UserStatus.DELETED.getCode().equals(user.getDelFlag())) {
+                throw new ServiceException("账号已被删除");
+            }
+            if (UserStatus.DISABLE.getCode().equals(user.getStatus())) {
+                throw new ServiceException("账号已被停用");
+            }
+            if (!SecurityUtils.matchesPassword(password, user.getPassword())) {
+                throw new ServiceException("账号或密码错误");
+            }
+            Company company = companyService.selectCompanyById(user.getCompanyId());
+            if (company == null || company.getStatus() == 0 || company.getIsDel() == 1) {
+                throw new ServiceException("所属公司已停用");
+            }
+            CommSession session = buildSession(tenantInfo, user, SCOPE_EXTERNAL);
+            refreshSession(session);
+            return buildTokenResponse(session);
+        } finally {
+            tenantDataSourceManager.clear();
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        }
+    }
+
+    public CommSession createInternalSession(Long tenantId, Long companyId, Long companyUserId, String account) {
+        CommSession session = new CommSession();
+        session.setToken(IdUtils.fastUUID());
+        session.setTenantId(tenantId);
+        session.setCompanyId(companyId);
+        session.setCompanyUserId(companyUserId);
+        session.setAccount(StringUtils.isNotBlank(account) ? account : "internal");
+        session.setScope(SCOPE_INTERNAL);
+        session.setIpaddr("internal");
+        refreshSession(session);
+        return session;
+    }
+
+    public CommSession getSession(HttpServletRequest request) {
+        String jwt = resolveToken(request);
+        if (StringUtils.isBlank(jwt)) {
+            return null;
+        }
+        try {
+            Claims claims = parseToken(jwt);
+            String uuid = (String) claims.get(COMM_TOKEN_KEY);
+            Long tenantId = claims.get(CLAIM_TENANT_ID) == null ? null : ((Number) claims.get(CLAIM_TENANT_ID)).longValue();
+            if (StringUtils.isBlank(uuid)) {
+                return null;
+            }
+            return redisCache.getCacheObject(buildRedisKey(tenantId, uuid));
+        } catch (Exception e) {
+            log.debug("解析通讯网关token失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    public void verifySession(CommSession session) {
+        if (session == null || session.getExpireTime() == null) {
+            throw new ServiceException("登录状态已失效");
+        }
+        long remain = session.getExpireTime() - System.currentTimeMillis();
+        if (remain <= 0) {
+            throw new ServiceException("登录状态已过期");
+        }
+        if (remain <= 20 * 60 * 1000L) {
+            refreshSession(session);
+        }
+    }
+
+    public CommTokenResponse refreshToken(HttpServletRequest request) {
+        CommSession session = getSession(request);
+        verifySession(session);
+        refreshSession(session);
+        return buildTokenResponse(session);
+    }
+
+    public void logout(HttpServletRequest request) {
+        CommSession session = getSession(request);
+        if (session != null) {
+            redisCache.deleteObject(buildRedisKey(session.getTenantId(), session.getToken()));
+        }
+    }
+
+    private CommSession buildSession(TenantInfo tenantInfo, CompanyUser user, String scope) {
+        CommSession session = new CommSession();
+        session.setToken(IdUtils.fastUUID());
+        session.setTenantId(tenantInfo.getId());
+        session.setTenantCode(tenantInfo.getTenantCode());
+        session.setCompanyId(user.getCompanyId());
+        session.setCompanyUserId(user.getUserId());
+        session.setAccount(user.getUserName());
+        session.setScope(scope);
+        session.setIpaddr(IpUtils.getIpAddr(ServletUtils.getRequest()));
+        return session;
+    }
+
+    private void refreshSession(CommSession session) {
+        session.setLoginTime(System.currentTimeMillis());
+        session.setExpireTime(session.getLoginTime() + expireTimeMinutes * 60L * 1000L);
+        redisCache.setCacheObject(buildRedisKey(session.getTenantId(), session.getToken()), session, expireTimeMinutes, TimeUnit.MINUTES);
+    }
+
+    private CommTokenResponse buildTokenResponse(CommSession session) {
+        Map<String, Object> claims = new HashMap<>();
+        claims.put(COMM_TOKEN_KEY, session.getToken());
+        if (session.getTenantId() != null) {
+            claims.put(CLAIM_TENANT_ID, session.getTenantId());
+        }
+        String jwt = Jwts.builder().setClaims(claims).signWith(SignatureAlgorithm.HS512, secret).compact();
+        return CommTokenResponse.builder()
+                .accessToken(jwt)
+                .expiresIn((long) expireTimeMinutes * 60)
+                .tokenType("Bearer")
+                .tenantId(session.getTenantId())
+                .companyId(session.getCompanyId())
+                .companyUserId(session.getCompanyUserId())
+                .build();
+    }
+
+    public String resolveToken(HttpServletRequest request) {
+        String token = request.getHeader(header);
+        if (StringUtils.isNotEmpty(token) && token.startsWith(com.fs.common.constant.Constants.TOKEN_PREFIX)) {
+            token = token.replace(com.fs.common.constant.Constants.TOKEN_PREFIX, "");
+        }
+        return token;
+    }
+
+    private Claims parseToken(String token) {
+        return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
+    }
+
+    public static String buildRedisKey(Long tenantId, String uuid) {
+        if (tenantId != null) {
+            return "tenantid:" + tenantId + ":comm_token:" + uuid;
+        }
+        return "comm_token:" + uuid;
+    }
+}

+ 18 - 0
fs-comm-gateway/src/main/java/com/fs/comm/auth/dto/CommTokenRequest.java

@@ -0,0 +1,18 @@
+package com.fs.comm.auth.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+
+@Data
+public class CommTokenRequest {
+
+    @NotBlank(message = "tenantCode不能为空")
+    private String tenantCode;
+
+    @NotBlank(message = "account不能为空")
+    private String account;
+
+    @NotBlank(message = "password不能为空")
+    private String password;
+}

+ 21 - 0
fs-comm-gateway/src/main/java/com/fs/comm/auth/dto/CommTokenResponse.java

@@ -0,0 +1,21 @@
+package com.fs.comm.auth.dto;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class CommTokenResponse {
+
+    private String accessToken;
+
+    private Long expiresIn;
+
+    private String tokenType;
+
+    private Long tenantId;
+
+    private Long companyId;
+
+    private Long companyUserId;
+}

+ 33 - 0
fs-comm-gateway/src/main/java/com/fs/comm/config/CommDataSourceTaskDecorator.java

@@ -0,0 +1,33 @@
+package com.fs.comm.config;
+
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import org.slf4j.MDC;
+import org.springframework.core.task.TaskDecorator;
+
+import java.util.Map;
+
+/**
+ * 异步线程数据源上下文传递装饰器
+ */
+public class CommDataSourceTaskDecorator implements TaskDecorator {
+
+    @Override
+    public Runnable decorate(Runnable runnable) {
+        String dataSourceType = DynamicDataSourceContextHolder.getDataSourceType();
+        Map<String, String> contextMap = MDC.getCopyOfContextMap();
+        return () -> {
+            try {
+                if (dataSourceType != null) {
+                    DynamicDataSourceContextHolder.setDataSourceType(dataSourceType);
+                }
+                if (contextMap != null) {
+                    MDC.setContextMap(contextMap);
+                }
+                runnable.run();
+            } finally {
+                DynamicDataSourceContextHolder.clearDataSourceType();
+                MDC.clear();
+            }
+        };
+    }
+}

+ 24 - 0
fs-comm-gateway/src/main/java/com/fs/comm/config/CommFilterConfig.java

@@ -0,0 +1,24 @@
+package com.fs.comm.config;
+
+import com.fs.comm.security.CommTokenAuthFilter;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.Ordered;
+
+/**
+ * 通讯网关鉴权过滤器(Servlet Filter 注册,避免与 SecurityConfig 形成 AuthenticationManager 循环依赖)
+ */
+@Configuration
+public class CommFilterConfig {
+
+    @Bean
+    public FilterRegistrationBean<CommTokenAuthFilter> commTokenAuthFilterRegistration(CommTokenAuthFilter filter) {
+        FilterRegistrationBean<CommTokenAuthFilter> registration = new FilterRegistrationBean<>();
+        registration.setFilter(filter);
+        registration.addUrlPatterns("/comm/*");
+        registration.setName("commTokenAuthFilter");
+        registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 20);
+        return registration;
+    }
+}

+ 50 - 0
fs-comm-gateway/src/main/java/com/fs/comm/config/CommThreadPoolConfig.java

@@ -0,0 +1,50 @@
+package com.fs.comm.config;
+
+import com.fs.common.utils.Threads;
+import com.fs.comm.config.CommDataSourceTaskDecorator;
+import org.apache.commons.lang3.concurrent.BasicThreadFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.ThreadPoolExecutor;
+
+@Configuration
+public class CommThreadPoolConfig {
+
+    @Value("${comm.gateway.executor.core-pool-size:20}")
+    private int corePoolSize;
+
+    @Value("${comm.gateway.executor.max-pool-size:100}")
+    private int maxPoolSize;
+
+    @Value("${comm.gateway.executor.queue-capacity:2000}")
+    private int queueCapacity;
+
+    @Bean(name = "commExecutor")
+    public ThreadPoolTaskExecutor commExecutor() {
+        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+        executor.setCorePoolSize(corePoolSize);
+        executor.setMaxPoolSize(maxPoolSize);
+        executor.setQueueCapacity(queueCapacity);
+        executor.setKeepAliveSeconds(300);
+        executor.setThreadNamePrefix("comm-gateway-");
+        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+        executor.setTaskDecorator(new CommDataSourceTaskDecorator());
+        executor.initialize();
+        return executor;
+    }
+
+    @Bean(name = "commScheduledExecutor")
+    public java.util.concurrent.ScheduledExecutorService commScheduledExecutor() {
+        return new java.util.concurrent.ScheduledThreadPoolExecutor(corePoolSize,
+                new BasicThreadFactory.Builder().namingPattern("comm-schedule-%d").daemon(true).build()) {
+            @Override
+            protected void afterExecute(Runnable r, Throwable t) {
+                super.afterExecute(r, t);
+                Threads.printException(r, t);
+            }
+        };
+    }
+}

+ 41 - 0
fs-comm-gateway/src/main/java/com/fs/comm/context/CommAuthContext.java

@@ -0,0 +1,41 @@
+package com.fs.comm.context;
+
+import com.fs.comm.auth.CommSession;
+
+/**
+ * 当前请求通讯会话上下文
+ */
+public final class CommAuthContext {
+
+    private static final ThreadLocal<CommSession> HOLDER = new ThreadLocal<>();
+
+    private CommAuthContext() {
+    }
+
+    public static void set(CommSession session) {
+        HOLDER.set(session);
+    }
+
+    public static CommSession get() {
+        return HOLDER.get();
+    }
+
+    public static Long getTenantId() {
+        CommSession session = HOLDER.get();
+        return session == null ? null : session.getTenantId();
+    }
+
+    public static Long getCompanyId() {
+        CommSession session = HOLDER.get();
+        return session == null ? null : session.getCompanyId();
+    }
+
+    public static Long getCompanyUserId() {
+        CommSession session = HOLDER.get();
+        return session == null ? null : session.getCompanyUserId();
+    }
+
+    public static void clear() {
+        HOLDER.remove();
+    }
+}

+ 29 - 0
fs-comm-gateway/src/main/java/com/fs/comm/controller/CommCallController.java

@@ -0,0 +1,29 @@
+package com.fs.comm.controller;
+
+import com.fs.comm.context.CommAuthContext;
+import com.fs.comm.dto.CommApiResult;
+import com.fs.comm.dto.CommCallSendRequest;
+import com.fs.comm.service.CommCallService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+@RestController
+@RequestMapping("/comm/call")
+public class CommCallController {
+
+    @Autowired
+    private CommCallService commCallService;
+
+    @PostMapping("/send")
+    public CommApiResult<Map<String, Object>> send(@RequestBody CommCallSendRequest request) {
+        if (CommAuthContext.get() == null) {
+            return CommApiResult.error(401, "未认证");
+        }
+        return CommApiResult.success(commCallService.sendCall(request));
+    }
+}

+ 28 - 0
fs-comm-gateway/src/main/java/com/fs/comm/controller/CommCallbackController.java

@@ -0,0 +1,28 @@
+package com.fs.comm.controller;
+
+import com.fs.comm.service.CommCallbackService;
+import com.fs.common.annotation.CallbackIpCheck;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/comm/callback")
+public class CommCallbackController {
+
+    @Autowired
+    private CommCallbackService commCallbackService;
+
+    @PostMapping("/easycall")
+    @CallbackIpCheck
+    public String easyCall(@RequestBody String cdrStr) {
+        return commCallbackService.handleEasyCallCallback(cdrStr);
+    }
+
+    @PostMapping("/sms")
+    public String sms(@RequestBody String json) {
+        return commCallbackService.handleSmsCallback(json);
+    }
+}

+ 32 - 0
fs-comm-gateway/src/main/java/com/fs/comm/controller/CommQueryController.java

@@ -0,0 +1,32 @@
+package com.fs.comm.controller;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.comm.context.CommAuthContext;
+import com.fs.comm.dto.CommApiResult;
+import com.fs.company.domain.CompanyVoiceRoboticCallLogCallphone;
+import com.fs.company.mapper.CompanyVoiceRoboticCallLogCallphoneMapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/comm/query")
+public class CommQueryController {
+
+    @Autowired
+    private CompanyVoiceRoboticCallLogCallphoneMapper callLogCallphoneMapper;
+
+    @GetMapping("/call/{callBackUuid}")
+    public CommApiResult<JSONObject> queryCall(@PathVariable String callBackUuid) {
+        if (CommAuthContext.get() == null) {
+            return CommApiResult.error(401, "未认证");
+        }
+        CompanyVoiceRoboticCallLogCallphone log = callLogCallphoneMapper.selectCallLogByCallbackUuid(callBackUuid);
+        if (log == null) {
+            return CommApiResult.error(404, "未找到外呼记录");
+        }
+        return CommApiResult.success((JSONObject) JSONObject.toJSON(log));
+    }
+}

+ 29 - 0
fs-comm-gateway/src/main/java/com/fs/comm/controller/CommSmsController.java

@@ -0,0 +1,29 @@
+package com.fs.comm.controller;
+
+import com.fs.comm.context.CommAuthContext;
+import com.fs.comm.dto.CommApiResult;
+import com.fs.comm.dto.CommSmsSendRequest;
+import com.fs.comm.service.CommSmsService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+@RestController
+@RequestMapping("/comm/sms")
+public class CommSmsController {
+
+    @Autowired
+    private CommSmsService commSmsService;
+
+    @PostMapping("/send")
+    public CommApiResult<Map<String, Object>> send(@RequestBody CommSmsSendRequest request) {
+        if (CommAuthContext.get() == null) {
+            return CommApiResult.error(401, "未认证");
+        }
+        return CommApiResult.success(commSmsService.sendSms(request));
+    }
+}

+ 27 - 0
fs-comm-gateway/src/main/java/com/fs/comm/dto/CommApiResult.java

@@ -0,0 +1,27 @@
+package com.fs.comm.dto;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class CommApiResult<T> {
+
+    private int code;
+
+    private String msg;
+
+    private T data;
+
+    public static <T> CommApiResult<T> success(T data) {
+        return CommApiResult.<T>builder().code(200).msg("success").data(data).build();
+    }
+
+    public static <T> CommApiResult<T> error(String msg) {
+        return CommApiResult.<T>builder().code(500).msg(msg).build();
+    }
+
+    public static <T> CommApiResult<T> error(int code, String msg) {
+        return CommApiResult.<T>builder().code(code).msg(msg).build();
+    }
+}

+ 37 - 0
fs-comm-gateway/src/main/java/com/fs/comm/dto/CommCallSendRequest.java

@@ -0,0 +1,37 @@
+package com.fs.comm.dto;
+
+import lombok.Data;
+
+import java.util.Map;
+
+@Data
+public class CommCallSendRequest {
+
+    private String phone;
+
+    private Long calleeId;
+
+    private Long roboticId;
+
+    private Long businessId;
+
+    private Long gatewayId;
+
+    private Long llmAccountId;
+
+    private String voiceCode;
+
+    private String voiceSource;
+
+    private Long busiGroupId;
+
+    private Integer maxConcurrency;
+
+    private String nodeKey;
+
+    private String workflowInstanceId;
+
+    private String callbackUrl;
+
+    private Map<String, Object> bizParams;
+}

+ 31 - 0
fs-comm-gateway/src/main/java/com/fs/comm/dto/CommSmsSendRequest.java

@@ -0,0 +1,31 @@
+package com.fs.comm.dto;
+
+import lombok.Data;
+
+import java.util.Map;
+
+@Data
+public class CommSmsSendRequest {
+
+    private String phone;
+
+    private Long customerId;
+
+    private Long smsTempId;
+
+    private Long roboticId;
+
+    private Long calleeId;
+
+    private String nodeKey;
+
+    private String workflowInstanceId;
+
+    private Long companyUserId;
+
+    private String senderName;
+
+    private String cardUrl;
+
+    private Map<String, String> templateParams;
+}

+ 24 - 0
fs-comm-gateway/src/main/java/com/fs/comm/exception/CommGlobalExceptionHandler.java

@@ -0,0 +1,24 @@
+package com.fs.comm.exception;
+
+import com.fs.comm.dto.CommApiResult;
+import com.fs.common.exception.ServiceException;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+@Slf4j
+@RestControllerAdvice(basePackages = "com.fs.comm")
+public class CommGlobalExceptionHandler {
+
+    @ExceptionHandler(ServiceException.class)
+    public CommApiResult<Void> handleServiceException(ServiceException e) {
+        log.warn("业务异常: {}", e.getMessage());
+        return CommApiResult.error(e.getMessage());
+    }
+
+    @ExceptionHandler(Exception.class)
+    public CommApiResult<Void> handleException(Exception e) {
+        log.error("系统异常", e);
+        return CommApiResult.error("系统异常: " + e.getMessage());
+    }
+}

+ 30 - 0
fs-comm-gateway/src/main/java/com/fs/comm/metrics/CommMetricsService.java

@@ -0,0 +1,30 @@
+package com.fs.comm.metrics;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * 通讯网关关键指标埋点(QPS/成功率/线路用量)
+ */
+@Slf4j
+@Component
+public class CommMetricsService {
+
+    private final ConcurrentHashMap<String, AtomicLong> counters = new ConcurrentHashMap<>();
+
+    public void increment(String metric) {
+        counters.computeIfAbsent(metric, k -> new AtomicLong()).incrementAndGet();
+    }
+
+    public long getCount(String metric) {
+        AtomicLong counter = counters.get(metric);
+        return counter == null ? 0L : counter.get();
+    }
+
+    public void logSnapshot() {
+        counters.forEach((key, value) -> log.info("comm-metric {}={}", key, value.get()));
+    }
+}

+ 76 - 0
fs-comm-gateway/src/main/java/com/fs/comm/ratelimit/CommRateLimitService.java

@@ -0,0 +1,76 @@
+package com.fs.comm.ratelimit;
+
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.exception.ServiceException;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.script.DefaultRedisScript;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+
+/**
+ * 租户级 QPS 令牌桶限流(Redis Lua 原子操作,支持多实例横向扩容)
+ */
+@Slf4j
+@Service
+public class CommRateLimitService {
+
+    private static final String TOKEN_BUCKET_LUA =
+            "local key = KEYS[1]\n" +
+            "local capacity = tonumber(ARGV[1])\n" +
+            "local rate = tonumber(ARGV[2])\n" +
+            "local now = tonumber(ARGV[3])\n" +
+            "local requested = tonumber(ARGV[4])\n" +
+            "local data = redis.call('HMGET', key, 'tokens', 'timestamp')\n" +
+            "local tokens = tonumber(data[1])\n" +
+            "local timestamp = tonumber(data[2])\n" +
+            "if tokens == nil then tokens = capacity timestamp = now end\n" +
+            "local delta = math.max(0, now - timestamp)\n" +
+            "tokens = math.min(capacity, tokens + delta * rate)\n" +
+            "timestamp = now\n" +
+            "if tokens < requested then\n" +
+            "  redis.call('HMSET', key, 'tokens', tokens, 'timestamp', timestamp)\n" +
+            "  redis.call('EXPIRE', key, 60)\n" +
+            "  return 0\n" +
+            "end\n" +
+            "tokens = tokens - requested\n" +
+            "redis.call('HMSET', key, 'tokens', tokens, 'timestamp', timestamp)\n" +
+            "redis.call('EXPIRE', key, 60)\n" +
+            "return 1";
+
+    @Value("${comm.gateway.tenant-qps-limit:200}")
+    private int tenantQpsLimit;
+
+    @Autowired
+    private StringRedisTemplate stringRedisTemplate;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    public void checkTenantQps(Long tenantId) {
+        if (tenantId == null) {
+            return;
+        }
+        String key = "comm:ratelimit:tenant:" + tenantId;
+        DefaultRedisScript<Long> script = new DefaultRedisScript<>(TOKEN_BUCKET_LUA, Long.class);
+        Long allowed = stringRedisTemplate.execute(script, Collections.singletonList(key),
+                String.valueOf(tenantQpsLimit),
+                String.valueOf(tenantQpsLimit),
+                String.valueOf(System.currentTimeMillis() / 1000.0),
+                "1");
+        if (allowed == null || allowed == 0L) {
+            throw new ServiceException("租户请求频率超限,请稍后重试");
+        }
+    }
+
+    public boolean tryLock(String lockKey, long ttlSeconds) {
+        return redisCache.setIfAbsent(lockKey, "1", ttlSeconds, java.util.concurrent.TimeUnit.SECONDS);
+    }
+
+    public void unlock(String lockKey) {
+        redisCache.deleteObject(lockKey);
+    }
+}

+ 28 - 0
fs-comm-gateway/src/main/java/com/fs/comm/security/CommAuthenticationEntryPoint.java

@@ -0,0 +1,28 @@
+package com.fs.comm.security;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.constant.HttpStatus;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.Serializable;
+
+@Component("commAuthenticationEntryPoint")
+public class CommAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @Override
+    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e)
+            throws IOException {
+        String msg = StringUtils.format("请求访问:{},认证失败,无法访问通讯网关", request.getRequestURI());
+        ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(HttpStatus.UNAUTHORIZED, msg)));
+    }
+}

+ 159 - 0
fs-comm-gateway/src/main/java/com/fs/comm/security/CommTokenAuthFilter.java

@@ -0,0 +1,159 @@
+package com.fs.comm.security;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.comm.auth.CommSession;
+import com.fs.comm.auth.CommTokenService;
+import com.fs.comm.context.CommAuthContext;
+import com.fs.common.config.RedisTenantContext;
+import com.fs.common.constant.HttpStatus;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.core.config.TenantConfigContext;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.framework.datasource.TenantDataSourceManager;
+import com.fs.config.saas.ProjectConfig;
+import com.fs.system.domain.SysConfig;
+import com.fs.system.mapper.SysConfigMapper;
+import com.fs.wxcid.utils.TenantHelper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.Collections;
+
+@Component
+public class CommTokenAuthFilter extends OncePerRequestFilter {
+
+    public static final String HEADER_INTERNAL_SECRET = "X-Comm-Internal-Secret";
+    public static final String HEADER_TENANT_ID = "X-Comm-Tenant-Id";
+    public static final String HEADER_COMPANY_ID = "X-Comm-Company-Id";
+    public static final String HEADER_COMPANY_USER_ID = "X-Comm-Company-User-Id";
+
+    @Value("${comm.gateway.internal-secret:}")
+    private String internalSecret;
+
+    @Autowired
+    private CommTokenService commTokenService;
+
+    @Autowired
+    private TenantDataSourceManager tenantDataSourceManager;
+
+    @Autowired
+    private SysConfigMapper sysConfigMapper;
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
+            throws ServletException, IOException {
+        String path = request.getRequestURI();
+        try {
+            CommSession session = resolveSession(request);
+            if (session != null) {
+                commTokenService.verifySession(session);
+                if (session.getTenantId() != null) {
+                    TenantHelper.setTenantId(session.getTenantId());
+                    tenantDataSourceManager.ensureSwitchByTenantId(session.getTenantId());
+                    loadTenantConfig(session.getTenantId());
+                    RedisTenantContext.setTenantId(session.getTenantId());
+                } else {
+                    DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+                }
+                CommAuthContext.set(session);
+                UsernamePasswordAuthenticationToken authentication =
+                        new UsernamePasswordAuthenticationToken(session, null, Collections.emptyList());
+                SecurityContextHolder.getContext().setAuthentication(authentication);
+            } else if (requiresAuth(path)) {
+                writeUnauthorized(response, "未授权,请先获取通讯网关 Token 或使用内部调用头");
+                return;
+            }
+            chain.doFilter(request, response);
+        } finally {
+            TenantHelper.removeTenantId();
+            ProjectConfig.clearTenantConfigs();
+            TenantConfigContext.clear();
+            RedisTenantContext.clear();
+            CommAuthContext.clear();
+            tenantDataSourceManager.clear();
+            DynamicDataSourceContextHolder.clearDataSourceType();
+            SecurityContextHolder.clearContext();
+        }
+    }
+
+    private CommSession resolveSession(HttpServletRequest request) {
+        String path = request.getRequestURI();
+        if (path.startsWith("/comm/auth/token") || path.startsWith("/comm/callback/")) {
+            return null;
+        }
+        CommSession session = commTokenService.getSession(request);
+        if (session != null) {
+            return session;
+        }
+        if (isInternalRequest(request)) {
+            Long tenantId = parseLongHeader(request, HEADER_TENANT_ID);
+            Long companyId = parseLongHeader(request, HEADER_COMPANY_ID);
+            Long companyUserId = parseLongHeader(request, HEADER_COMPANY_USER_ID);
+            if (tenantId != null && companyId != null) {
+                return commTokenService.createInternalSession(tenantId, companyId, companyUserId, "internal");
+            }
+        }
+        return null;
+    }
+
+    private boolean requiresAuth(String path) {
+        if (!path.startsWith("/comm/")) {
+            return false;
+        }
+        return !path.startsWith("/comm/auth/token")
+                && !path.startsWith("/comm/callback/");
+    }
+
+    private boolean isInternalRequest(HttpServletRequest request) {
+        if (StringUtils.isBlank(internalSecret)) {
+            return false;
+        }
+        String secret = request.getHeader(HEADER_INTERNAL_SECRET);
+        return internalSecret.equals(secret);
+    }
+
+    private Long parseLongHeader(HttpServletRequest request, String headerName) {
+        String value = request.getHeader(headerName);
+        if (StringUtils.isBlank(value)) {
+            return null;
+        }
+        try {
+            return Long.valueOf(value);
+        } catch (NumberFormatException ex) {
+            return null;
+        }
+    }
+
+    private void loadTenantConfig(Long tenantId) {
+        if (tenantId == null) {
+            return;
+        }
+        SysConfig cfg = sysConfigMapper.selectConfigByConfigKey("projectConfig");
+        if (cfg != null && StringUtils.isNotBlank(cfg.getConfigValue())) {
+            TenantConfigContext.set(com.alibaba.fastjson.JSONObject.parseObject(cfg.getConfigValue()));
+            ProjectConfig.loadTenantConfigsFromContext();
+        }
+    }
+
+    @Override
+    protected boolean shouldNotFilter(HttpServletRequest request) {
+        return false;
+    }
+
+    public static void writeUnauthorized(HttpServletResponse response, String msg) throws IOException {
+        AjaxResult result = AjaxResult.error(HttpStatus.UNAUTHORIZED, msg);
+        ServletUtils.renderString(response, JSON.toJSONString(result));
+    }
+}

+ 68 - 0
fs-comm-gateway/src/main/java/com/fs/comm/service/CommCallService.java

@@ -0,0 +1,68 @@
+package com.fs.comm.service;
+
+import com.fs.comm.context.CommAuthContext;
+import com.fs.comm.dto.CommCallSendRequest;
+import com.fs.comm.metrics.CommMetricsService;
+import com.fs.comm.model.CommCallSendParam;
+import com.fs.comm.model.CommCallSendResult;
+import com.fs.comm.ratelimit.CommRateLimitService;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.StringUtils;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class CommCallService {
+
+    @Autowired
+    private CommRateLimitService commRateLimitService;
+
+    @Autowired
+    private CommGatewayLineAuthService commGatewayLineAuthService;
+
+    @Autowired
+    private CommCallSendService commCallSendService;
+
+    @Autowired
+    private CommMetricsService commMetricsService;
+
+    public Map<String, Object> sendCall(CommCallSendRequest request) {
+        Long companyId = CommAuthContext.getCompanyId();
+        Long tenantId = CommAuthContext.getTenantId();
+        if (companyId == null) {
+            throw new ServiceException("未获取到公司信息");
+        }
+        commRateLimitService.checkTenantQps(tenantId);
+        commGatewayLineAuthService.validateGateway(companyId, request.getGatewayId());
+
+        CommCallSendResult result = commCallSendService.sendWorkflowCall(CommCallSendParam.builder()
+                .roboticId(request.getRoboticId())
+                .calleeId(request.getCalleeId())
+                .businessId(request.getBusinessId())
+                .gatewayId(request.getGatewayId())
+                .nodeKey(request.getNodeKey())
+                .workflowInstanceId(request.getWorkflowInstanceId())
+                .companyId(companyId)
+                .tenantId(tenantId)
+                .callbackUrl(request.getCallbackUrl())
+                .phone(request.getPhone())
+                .bizParams(request.getBizParams())
+                .build());
+
+        if (StringUtils.isBlank(result.getPhone())) {
+            throw new ServiceException("外呼发起失败:未获取到有效被叫号码");
+        }
+
+        Map<String, Object> response = new HashMap<>();
+        response.put("callBackUuid", result.getCallBackUuid());
+        response.put("batchId", result.getBatchId());
+        response.put("phone", result.getPhone());
+        commMetricsService.increment("call.success");
+        return response;
+    }
+}

+ 86 - 0
fs-comm-gateway/src/main/java/com/fs/comm/service/CommCallbackService.java

@@ -0,0 +1,86 @@
+package com.fs.comm.service;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.config.RedisTenantContext;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.service.ICompanyVoiceRoboticService;
+import com.fs.common.service.ISmsService;
+import com.fs.company.vo.CdrDetailVo;
+import com.fs.common.core.domain.model.TenantPrincipal;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.framework.datasource.TenantDataSourceManager;
+import com.fs.wxcid.utils.TenantHelper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+
+@Slf4j
+@Service
+public class CommCallbackService {
+
+    @Autowired
+    private ICompanyVoiceRoboticService companyVoiceRoboticService;
+
+    @Autowired
+    private ISmsService smsService;
+
+    @Autowired
+    private TenantDataSourceManager tenantDataSourceManager;
+
+    public String handleEasyCallCallback(String cdrStr) {
+        CdrDetailVo cdrDetailVo = JSONObject.parseObject(cdrStr, CdrDetailVo.class);
+        companyVoiceRoboticService.callerResult4EasyCall(cdrDetailVo);
+        return "success";
+    }
+
+    public String handleSmsCallback(String json) {
+        Long tenantId = extractTenantId(json);
+        boolean switched = false;
+        try {
+            if (tenantId != null) {
+                switched = switchTenantContext(tenantId);
+            }
+            return smsService.smsNotify(json);
+        } finally {
+            if (switched) {
+                clearTenantContext();
+            }
+        }
+    }
+
+    private Long extractTenantId(String json) {
+        if (StringUtils.isBlank(json)) {
+            return null;
+        }
+        try {
+            JSONObject obj = JSONObject.parseObject(json);
+            if (obj.containsKey("tenantId")) {
+                return obj.getLong("tenantId");
+            }
+        } catch (Exception ignored) {
+        }
+        return null;
+    }
+
+    private boolean switchTenantContext(Long tenantId) {
+        TenantHelper.setTenantId(tenantId);
+        tenantDataSourceManager.ensureSwitchByTenantId(tenantId);
+        SecurityContextHolder.getContext().setAuthentication(
+                new UsernamePasswordAuthenticationToken(new TenantPrincipal(tenantId), null, Collections.emptyList()));
+        RedisTenantContext.setTenantId(tenantId);
+        return true;
+    }
+
+    private void clearTenantContext() {
+        TenantHelper.removeTenantId();
+        RedisTenantContext.clear();
+        tenantDataSourceManager.clear();
+        DynamicDataSourceContextHolder.clearDataSourceType();
+        SecurityContextHolder.clearContext();
+    }
+}

+ 73 - 0
fs-comm-gateway/src/main/java/com/fs/comm/service/CommGatewayLineAuthService.java

@@ -0,0 +1,73 @@
+package com.fs.comm.service;
+
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.mapper.CompanyMapper;
+import com.fs.company.service.easycall.IEasyCallService;
+import com.fs.company.vo.easycall.EasyCallGatewayVO;
+import com.fs.system.service.ISysConfigService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 外呼线路(gatewayId)归属鉴权
+ */
+@Slf4j
+@Service
+public class CommGatewayLineAuthService {
+
+    @Autowired
+    private IEasyCallService easyCallService;
+
+    @Autowired
+    private CompanyMapper companyMapper;
+
+    @Autowired
+    private ISysConfigService configService;
+
+    public void validateGateway(Long companyId, Long gatewayId) {
+        if (gatewayId == null) {
+            throw new ServiceException("gatewayId不能为空");
+        }
+        List<EasyCallGatewayVO> allowed = easyCallService.getGatewayList(companyId);
+        if (allowed == null || allowed.isEmpty()) {
+            validateByConfigOnly(companyId, gatewayId);
+            return;
+        }
+        boolean matched = allowed.stream().anyMatch(item -> gatewayId.equals(item.getId()));
+        if (!matched) {
+            throw new ServiceException("无权使用该外呼线路: " + gatewayId);
+        }
+    }
+
+    private void validateByConfigOnly(Long companyId, Long gatewayId) {
+        String gateWayList = companyMapper.getGateWayList(companyId);
+        if (StringUtils.isNotBlank(gateWayList)) {
+            List<Long> ids = Arrays.stream(gateWayList.split(","))
+                    .map(String::trim).filter(StringUtils::isNotBlank).map(Long::valueOf).collect(Collectors.toList());
+            if (!ids.contains(gatewayId)) {
+                throw new ServiceException("无权使用该外呼线路");
+            }
+            return;
+        }
+        String json = configService.selectConfigByKey("cId.config");
+        if (StringUtils.isBlank(json)) {
+            return;
+        }
+        JSONObject obj = JSONObject.parseObject(json);
+        JSONArray showGatewayIds = obj.getJSONArray("showGatewayIds");
+        if (showGatewayIds != null && !showGatewayIds.isEmpty()) {
+            List<Long> ids = showGatewayIds.stream().map(o -> Long.valueOf(o.toString())).collect(Collectors.toList());
+            if (!ids.contains(gatewayId)) {
+                throw new ServiceException("无权使用该外呼线路");
+            }
+        }
+    }
+}

+ 56 - 0
fs-comm-gateway/src/main/java/com/fs/comm/service/CommSmsService.java

@@ -0,0 +1,56 @@
+package com.fs.comm.service;
+
+import com.fs.comm.context.CommAuthContext;
+import com.fs.comm.dto.CommSmsSendRequest;
+import com.fs.comm.metrics.CommMetricsService;
+import com.fs.comm.model.CommSmsSendParam;
+import com.fs.comm.model.CommSmsSendResult;
+import com.fs.comm.ratelimit.CommRateLimitService;
+import com.fs.common.exception.ServiceException;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class CommSmsService {
+
+    @Autowired
+    private CommRateLimitService commRateLimitService;
+
+    @Autowired
+    private CommSmsSendService commSmsSendService;
+
+    @Autowired
+    private CommMetricsService commMetricsService;
+
+    public Map<String, Object> sendSms(CommSmsSendRequest request) {
+        Long companyId = CommAuthContext.getCompanyId();
+        Long tenantId = CommAuthContext.getTenantId();
+        commRateLimitService.checkTenantQps(tenantId);
+
+        CommSmsSendResult result = commSmsSendService.sendWorkflowSms(CommSmsSendParam.builder()
+                .roboticId(request.getRoboticId())
+                .calleeId(request.getCalleeId())
+                .smsTempId(request.getSmsTempId())
+                .nodeKey(request.getNodeKey())
+                .workflowInstanceId(request.getWorkflowInstanceId())
+                .companyId(companyId)
+                .companyUserId(request.getCompanyUserId() != null ? request.getCompanyUserId() : CommAuthContext.getCompanyUserId())
+                .senderName(request.getSenderName())
+                .phone(request.getPhone())
+                .customerId(request.getCustomerId())
+                .cardUrl(request.getCardUrl())
+                .build());
+
+        Map<String, Object> response = new HashMap<>();
+        response.put("callbackUuid", result.getCallbackUuid());
+        response.put("customerId", result.getCustomerId());
+        response.put("phone", result.getPhone());
+        commMetricsService.increment("sms.success");
+        return response;
+    }
+}

+ 31 - 0
fs-comm-gateway/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -0,0 +1,31 @@
+package com.fs.framework.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.web.SecurityFilterChain;
+
+/**
+ * 通讯网关 Spring Security(对齐 fs-wx-api 思路:全部放行,鉴权由 CommTokenAuthFilter 负责)
+ * <p>
+ * 不使用 WebSecurityConfigurerAdapter / AuthenticationManager,避免 Spring Security 5.7 循环依赖。
+ */
+@Configuration
+@EnableWebSecurity
+public class SecurityConfig {
+
+    @Bean
+    public SecurityFilterChain commGatewaySecurityFilterChain(HttpSecurity http) throws Exception {
+        http.csrf().disable()
+                .authorizeRequests()
+                .antMatchers("/**").permitAll();
+        return http.build();
+    }
+
+    @Bean
+    public BCryptPasswordEncoder bCryptPasswordEncoder() {
+        return new BCryptPasswordEncoder();
+    }
+}

+ 1 - 0
fs-comm-gateway/src/main/resources/META-INF/spring-devtools.properties

@@ -0,0 +1 @@
+restart.include.json=/com.alibaba.fastjson.*.jar

+ 155 - 0
fs-comm-gateway/src/main/resources/application-common.yml

@@ -0,0 +1,155 @@
+# 项目相关配置
+fs:
+  # 名称
+  name: fs
+  # 版本
+  version: 1.1.0
+  # 版权年份
+  copyrightYear: 2021
+  # 实例演示开关
+  demoEnabled: true
+  # 文件路径 示例( Windows配置D:/fs/uploadPath,Linux配置 /home/fs/uploadPath)
+  profile: c:/fs/uploadPath
+  # 获取ip地址开关
+  addressEnabled: false
+  # 验证码类型 math 数组计算 char 字符验证
+  captchaType: math
+#  jwt:
+#    # 加密秘钥
+#    secret: f4e2e52034348f86b67cde581c0f9eb5
+#    # token有效时长,7天,单位秒
+#    expire: 31536000
+#    header: AppToken
+# 开发环境配置
+server:
+  servlet:
+    # 应用的访问路径
+    context-path: /
+  tomcat:
+    # tomcat的URI编码
+    uri-encoding: UTF-8
+    # tomcat最大线程数,默认为200
+    max-threads: 800
+    # Tomcat启动初始化的线程数,默认值25
+    min-spare-threads: 30
+
+# 日志配置
+logging:
+  level:
+    com.fs: info
+    org.springframework: warn
+
+express:
+  omsCode: "SF.0235402855"
+# Spring配置
+spring:
+  main:
+    allow-circular-references: true
+  cache:
+    type: redis
+  # 资源信息
+  messages:
+    # 国际化资源文件路径
+    basename: i18n/messages
+  mvc:
+    async:
+      request-timeout: 600000
+
+  # 文件上传
+  servlet:
+     multipart:
+       # 单个文件大小
+       max-file-size:  3GB
+       # 设置总上传的文件大小
+       max-request-size:  3GB
+  # 服务模块
+  devtools:
+    restart:
+      # 热部署开关
+      enabled: true
+
+
+# token配置
+token:
+    # 令牌自定义标识
+    header: Authorization
+    # 令牌密钥
+    secret: YlrzSaas2026SecKey!@#QwErTyUiOpAsDfGhJkLzXcVbNm
+    # 令牌有效期(默认30分钟)
+    expireTime: 720
+mybatis-plus:
+  # 搜索指定包别名
+  typeAliasesPackage: com.fs.**.domain,com.fs.**.bo
+  # 配置mapper的扫描,找到所有的mapper.xml映射文件
+  mapperLocations: classpath*:/mapper/**/*.xml
+  configLocation: classpath:mybatis/mybatis-config.xml
+  # 全局配置
+  global-config:
+    db-config:
+      # 主键类型  0:"数据库ID自增", 1:"用户输入ID",2:"全局唯一ID (数字类型唯一ID)", 3:"全局唯一ID UUID";
+      idType: AUTO
+      # 字段策略 0:"忽略判断",1:"非 NULL 判断"),2:"非空判断"
+      fieldStrategy: NOT_EMPTY
+    banner: false
+    # 配置
+  configuration:
+    # 驼峰式命名
+    mapUnderscoreToCamelCase: true
+    # 全局映射器启用缓存
+    cacheEnabled: true
+    # 配置默认的执行器
+    defaultExecutorType: REUSE
+    # 允许 JDBC 支持自动生成主键
+    useGeneratedKeys: true
+
+# MyBatis配置
+mybatis:
+    # 搜索指定包别名
+    typeAliasesPackage: com.fs.**.domain
+    # 配置mapper的扫描,找到所有的mapper.xml映射文件
+    mapperLocations: classpath*:mapper/**/*Mapper.xml
+    # 加载全局的配置文件
+    configLocation: classpath:mybatis/mybatis-config.xml
+
+# PageHelper分页插件
+pagehelper:
+  helperDialect: mysql
+  reasonable: false #超出后不显示
+  supportMethodsArguments: false
+  params: count=countSql
+
+# Swagger配置
+swagger:
+  # 是否开启swagger
+  enabled: false
+  # 请求前缀
+  pathMapping: /dev-api
+
+# 防止XSS攻击
+xss:
+  # 过滤开关
+  enabled: true
+  # 排除链接(多个用逗号分隔)
+  excludes: /system/notice,/system/config/*
+  # 匹配链接
+  urlPatterns: /system/*,/monitor/*,/tool/*
+zhyf:
+  url: https://zhyf-testController.jingpai.com
+
+image:
+  storage:
+    local-path: C:\logoFile\logo.jpg
+    server-path: C:\logoFile\logo.jpg
+# application.properties
+wechat:
+  api:
+    base-url: https://api.weixin.qq.com
+    upload-shipping-info: /wxa/sec/order/upload_shipping_info
+hsy:
+  access_key: AKLTZTc4YTE4ZjI2OWViNDNjZGI2NjhiYTI5Njc5ZjA1Mzk
+  secret_key: WXpjelpUYzFOakF5TUdObE5EZGtNR0ZsWXpKaU1tTmtZakk1WXpObE4yRQ==
+  region: cn-north-1
+  role_access_key: AKLTNmMwNjJkNDFhYTVjNDIzYzhhNzEyZmZmZTlmYzBhNGM
+  role_secret_key: T0RaaFl6UmhZV1V4WXpKbU5EWTBNMkZpT0RNNU9UY3daak0wTjJFd09XUQ==
+  role_trn: trn:iam::2114522511:role/hylj
+

+ 137 - 0
fs-comm-gateway/src/main/resources/application-config-dev.yml

@@ -0,0 +1,137 @@
+baidu:
+  token: 12313231232
+  back-domain: https://www.xxxx.com
+#配置
+logging:
+  level:
+    org.springframework.web: debug
+    com.github.binarywang.demo.wx.cp: DEBUG
+    me.chanjar.weixin: DEBUG
+#wx:
+#  miniapp:
+#    configs:
+#      - appid: wx29d26f63f836be7f
+#        secret: 7542db9774355a89b1adce24defb6013
+#        token: Ncbnd7lJvkripVOpyTFAna6NAWCxCrvC
+#        aesKey: HlEiBB55eaWUaeBVAQO3cWKWPYv1vOVQSq7nFNICw4E
+#        msgDataFormat: JSON
+#  cp:
+#    corpId: wwb2a1055fb6c9a7c2
+#    appConfigs:
+#      - agentId: 1000005
+#        secret: ec7okROXJqkNafq66-L6aKNv0asTzQIG0CYrj3vyBbo
+#        token: PPKOdAlCoMO
+#        aesKey: PKvaxtpSv8NGpfTDm7VUHIK8Wok2ESyYX24qpXJAdMP
+#  pay:
+#    appId: wx73f85f8d62769119 #微信公众号或者小程序等的appid
+#    mchId: 1611402045 #微信支付商户号
+#    mchKey: 8cab128997a3547c1363b0898b877f38 #微信支付商户密钥
+#    subAppId:  #服务商模式下的子商户公众账号ID
+#    subMchId:  #服务商模式下的子商户号
+#    keyPath: c:\\cert\\apiclient_cert.p12 # p12证书的位置,可以指定绝对路径,也可以指定类路径(以classpath:开头)
+#    notifyUrl: https://userapp.his.runtzh.com/app/wxpay/wxPayNotify
+#  mp:
+#    useRedis: false
+#    redisConfig:
+#      host: 127.0.0.1
+#      port: 6379
+#      timeout: 2000
+#    configs:
+#      - appId: wx93ce67750e3cfba3 # 第一个公众号的appid  //公众号名称:云联融智
+#        secret: c172884087264160563bfe5775ca0f6f # 公众号的appsecret
+#        token: PPKOdAlCoMO # 接口配置里的Token值
+#        aesKey: Eswa6VjwtVMCcw03qZy6fWllgrv5aytIA1SZPEU0kU2 # 接口配置里的EncodingAESKey值
+#aifabu:  #爱链接
+#  appKey: 7b471be905ab17e00f3b858c6710dd117601d008
+#watch:
+#  watchUrl: watch.ylrzcloud.com/prod-api
+#  #  account: tcloud
+#  #  password: mdf-m2h_6yw2$hq
+#  account1: ccif #866655060138751
+#  password1: cp-t5or_6xw7$mt
+#  account2: tcloud #rt500台
+#  password2: mdf-m2h_6yw2$hq
+#  account3: whr
+#  password3: v9xsKuqn_$d2y
+#
+#fs :
+#  commonApi: http://172.16.0.16:8010
+#  h5CommonApi: http://119.29.195.254:8010
+#  jwt:
+#    # 加密秘钥
+#    secret: e10adc3949ba59abbe56e057f20f883e
+#    # token有效时长,7天,单位秒
+#    expire: 31536000
+#    header: AppToken
+#nuonuo:
+#  key: 10924508
+#  secret: A2EB20764D304D16
+#
+## 存储捅配置
+#tencent_cloud_config:
+#  secret_id: AKIDiMq9lDf2EOM9lIfqqfKo7FNgM5meD0sT
+#  secret_key: u5SuS80342xzx8FRBukza9lVNHKNMSaB
+#  bucket: myhk-1323137866
+#  app_id: 1323137866
+#  region: ap-chongqing
+#  proxy: myhk
+#cloud_host:
+#  company_name: 金康健
+#  projectCode: DEV
+#  spaceName:
+#  volcengineUrl:
+#headerImg:
+#  imgUrl: https://jz-cos-1356808054.cos.ap-chengdu.myqcloud.com/fs/20250515/0877754b59814ea8a428fa3697b20e68.png
+#ipad:
+#  url:
+#  ipadUrl: http://ipad.cdwjyyh.com
+#  aiApi: http://152.136.202.157:3000/api
+#  voiceApi:
+#  commonApi:
+#wx_miniapp_temp:
+#  pay_order_temp_id:
+#  inquiry_temp_id:
+## 聚水潭API配置
+#jst:
+##  app_key: a4b1fab173c84f67b3873857eea11d90 #聚水潭2025-07-25
+#  app_key: 871348458a964548a72bf8124cf917a4 #聚水潭2025-08-14
+#  app_secret: 5b7d9369dbcd414db45089bc047ebe1a #聚水潭2025-08-14
+##  app_secret: dfce1f8dc8a64ddc91212fc3fcdd9349 #聚水潭2025-07-25
+#  authorization_code: 666666
+#  shop_code: "18461733"
+#
+## RocketMQ配置
+#rocketmq:
+#  name-server: 127.0.0.1:9876
+#  producer:
+#    group: event-feedback-producer
+#    send-message-timeout: 3000
+#    retry-times-when-send-failed: 2
+#    retry-times-when-send-async-failed: 2
+#    max-message-size: 4194304
+#    compress-message-body-threshold: 4096
+#    retry-next-server: true
+#custom:
+#  token: "1o62d3YxvdHd4LEUiltnu7sK"
+#  encoding-aes-key: "UJfTQ5qKTKlegjkXtp1YuzJzxeHlUKvq5GyFbERN1iU"
+#  corp-id: "ww51717e2b71d5e2d3"configValue
+#  secret: "6ODAmw-8W4t6h9mdzHh2Z4Apwj8mnsyRnjEDZOHdA7k"
+#  private-key-path: "privatekey.pem"
+#  webhook-url: "https://your-server.com/wecom/archive"
+## token配置
+#token:
+#  # 令牌自定义标识
+#  header: Authorization
+#  # 令牌密钥
+#  secret: abcdefghijklmnopqrstuvwxyz
+#  # 令牌有效期(默认30分钟)
+#  expireTime: 180
+#openIM:
+#  secret: openIM123
+#  userID: imAdmin
+#  url: https://web.jnmyim.ylrzfs.com/api
+##是否为新商户,新商户不走mpOpenId
+#isNewWxMerchant: true
+##是否使用新im
+#im:
+#  type: OPENIM

+ 132 - 0
fs-comm-gateway/src/main/resources/application-dev.yml

@@ -0,0 +1,132 @@
+# 数据源配置
+spring:
+    # redis 配置
+    redis:
+        # 地址
+        host: localhost
+        # 端口,默认为6379
+        port: 6379
+        # 数据库索引
+        database: 0
+        # 密码
+        password:
+        # 连接超时时间
+        timeout: 20s
+        lettuce:
+            pool:
+                # 连接池中的最小空闲连接
+                min-idle: 0
+                # 连接池中的最大空闲连接
+                max-idle: 8
+                # 连接池的最大数据库连接数
+                max-active: 8
+                # #连接池最大阻塞等待时间(使用负值表示没有限制)
+                max-wait: -1ms
+    datasource:
+        mysql:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                initialSize: 5
+                minIdle: 10
+                maxActive: 20
+                maxWait: 60000
+                timeBetweenEvictionRunsMillis: 60000
+                minEvictableIdleTimeMillis: 300000
+                maxEvictableIdleTimeMillis: 900000
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                # 主库数据源
+                master:
+                    url: jdbc:mysql://cq-cdb-8fjmemkb.sql.tencentcdb.com:27220/ylrz_saas?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false
+                    username: root
+                    password: Ylrz_1q2w3e4r5t6y
+                    # 初始连接数
+                    initialSize: 5
+                    # 最小连接池数量
+                    minIdle: 10
+                    # 最大连接池数量
+                    maxActive: 20
+                    # 配置获取连接等待超时的时间
+                    maxWait: 60000
+                    # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                    timeBetweenEvictionRunsMillis: 60000
+                    # 配置一个连接在池中最小生存的时间,单位是毫秒
+                    minEvictableIdleTimeMillis: 300000
+                    # 配置一个连接在池中最大生存的时间,单位是毫秒
+                    maxEvictableIdleTimeMillis: 900000
+                    # 配置检测连接是否有效
+                    validationQuery: SELECT 1 FROM DUAL
+                    testWhileIdle: true
+                    testOnBorrow: false
+                    testOnReturn: false
+                    webStatFilter:
+                        enabled: true
+                    statViewServlet:
+                        enabled: true
+                        # 设置白名单,不填则允许所有访问
+                        allow:
+                        url-pattern: /druid/*
+                        # 控制台管理用户名和密码
+                        login-username: fs
+                        login-password: 123456
+                    filter:
+                        stat:
+                            enabled: true
+                            # 慢SQL记录
+                            log-slow-sql: true
+                            slow-sql-millis: 1000
+                            merge-sql: true
+                        wall:
+                            config:
+                                multi-statement-allow: true
+        easycall:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                # 主库数据源
+                master:
+                    url: jdbc:mysql://129.28.164.235:3306/easycallcenter365?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                    username: root
+                    password: easycallcenter365
+                # 初始连接数
+                initialSize: 5
+                # 最小连接池数量
+                minIdle: 10
+                # 最大连接池数量
+                maxActive: 20
+                # 配置获取连接等待超时的时间
+                maxWait: 60000
+                # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                timeBetweenEvictionRunsMillis: 60000
+                # 配置一个连接在池中最小生存的时间,单位是毫秒
+                minEvictableIdleTimeMillis: 300000
+                # 配置一个连接在池中最大生存的时间,单位是毫秒
+                maxEvictableIdleTimeMillis: 900000
+                # 配置检测连接是否有效
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                webStatFilter:
+                    enabled: true
+                statViewServlet:
+                    enabled: true
+                    # 设置白名单,不填则允许所有访问
+                    allow:
+                    url-pattern: /druid/*
+                    # 控制台管理用户名和密码
+                    login-username: fs
+                    login-password: 123456
+                filter:
+                    stat:
+                        enabled: true
+                        # 慢SQL记录
+                        log-slow-sql: true
+                        slow-sql-millis: 1000
+                        merge-sql: true
+                    wall:
+                        config:
+                            multi-statement-allow: true

+ 37 - 0
fs-comm-gateway/src/main/resources/i18n/messages.properties

@@ -0,0 +1,37 @@
+#错误消息
+not.null=* 必须填写
+user.jcaptcha.error=验证码错误
+user.jcaptcha.expire=验证码已失效
+user.not.exists=用户不存在/密码错误
+user.password.not.match=用户不存在/密码错误
+user.password.retry.limit.count=密码输入错误{0}次
+user.password.retry.limit.exceed=密码输入错误{0}次,帐户锁定10分钟
+user.password.delete=对不起,您的账号已被删除
+user.blocked=用户已封禁,请联系管理员
+role.blocked=角色已封禁,请联系管理员
+user.logout.success=退出成功
+
+length.not.valid=长度必须在{min}到{max}个字符之间
+
+user.username.not.valid=* 2到20个汉字、字母、数字或下划线组成,且必须以非数字开头
+user.password.not.valid=* 5-50个字符
+ 
+user.email.not.valid=邮箱格式错误
+user.mobile.phone.number.not.valid=手机号格式错误
+user.login.success=登录成功
+user.register.success=注册成功
+user.notfound=请重新登录
+user.forcelogout=管理员强制退出,请重新登录
+user.unknown.error=未知错误,请重新登录
+
+##文件上传消息
+upload.exceed.maxSize=上传的文件大小超出限制的文件大小!<br/>允许的文件最大大小是:{0}MB!
+upload.filename.exceed.length=上传的文件名最长{0}个字符
+
+##权限
+no.permission=您没有数据的权限,请联系管理员添加权限 [{0}]
+no.create.permission=您没有创建数据的权限,请联系管理员添加权限 [{0}]
+no.update.permission=您没有修改数据的权限,请联系管理员添加权限 [{0}]
+no.delete.permission=您没有删除数据的权限,请联系管理员添加权限 [{0}]
+no.export.permission=您没有导出数据的权限,请联系管理员添加权限 [{0}]
+no.view.permission=您没有查看数据的权限,请联系管理员添加权限 [{0}]

+ 21 - 0
fs-comm-gateway/src/main/resources/mybatis/mybatis-config.xml

@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE configuration
+PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-config.dtd">
+<configuration>
+
+	<settings>
+		<setting name="cacheEnabled"             value="true" />  <!-- 全局映射器启用缓存 -->
+		<setting name="useGeneratedKeys"         value="true" />  <!-- 允许 JDBC 支持自动生成主键 -->
+		<setting name="defaultExecutorType"      value="REUSE" /> <!-- 配置默认的执行器 -->
+		<setting name="logImpl"                  value="SLF4J" /> <!-- 指定 MyBatis 所用日志的具体实现 -->
+		 <setting name="mapUnderscoreToCamelCase" value="true"/>
+	</settings>
+
+
+	<typeHandlers>
+		<typeHandler handler="com.fs.framework.config.ArrayStringTypeHandler"/>
+	</typeHandlers>
+
+
+</configuration>

+ 635 - 0
fs-comm-gateway/src/main/resources/static/chat-aggregate.html

@@ -0,0 +1,635 @@
+<!DOCTYPE html>
+<html lang="zh">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>龙虾引擎 - 聚合聊天</title>
+<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
+<style>
+*{margin:0;padding:0;box-sizing:border-box}
+body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0a0a1a;color:#e0e0e0;height:100vh;overflow:hidden}
+.main{display:flex;height:100vh}
+
+/* 左侧账户列表 */
+.account-panel{width:80px;background:#0d0d1f;border-right:1px solid #1a1a3e;display:flex;flex-direction:column;padding:12px 0}
+.account-item{width:56px;height:56px;border-radius:50%;margin:0 auto 8px;cursor:pointer;position:relative;border:2px solid transparent;transition:.2s;display:flex;align-items:center;justify-content:center;font-size:24px}
+.account-item:hover{border-color:#e94560;transform:scale(1.05)}
+.account-item.active{border-color:#e94560;background:#e9456022}
+.account-item .badge{position:absolute;top:-2px;right:-2px;background:#e94560;color:#fff;border-radius:10px;padding:1px 5px;font-size:10px}
+.account-item.disabled{opacity:.4;cursor:not-allowed}
+
+/* 会话列表 */
+.session-panel{width:280px;background:#1a1a2e;border-right:1px solid #2a2a4a;display:flex;flex-direction:column}
+.session-header{padding:12px 16px;border-bottom:1px solid #2a2a4a}
+.session-header h3{font-size:14px;color:#e94560}
+.session-header .search{margin-top:8px}
+.session-header input{width:100%;padding:6px 10px;background:#0a0a1a;border:1px solid #2a2a4a;border-radius:4px;color:#e0e0e0;font-size:12px}
+.session-list{flex:1;overflow-y:auto;padding:8px}
+.session-item{display:flex;padding:10px;cursor:pointer;border-radius:6px;margin-bottom:4px;transition:.2s}
+.session-item:hover{background:#2a2a4a}
+.session-item.active{background:#0f3460}
+.session-avatar{width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,#e94560,#f59e0b);display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0}
+.session-info{flex:1;min-width:0;margin-left:10px}
+.session-info .name{font-size:13px;color:#e0e0e0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.session-info .msg{font-size:12px;color:#888;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:2px}
+.session-time{font-size:11px;color:#666;text-align:right}
+.session-item.unread .name{font-weight:600;color:#fff}
+.session-item.unread .badge{background:#e94560;color:#fff;border-radius:10px;padding:1px 5px;font-size:10px}
+
+/* 聊天区域 */
+.chat-panel{flex:1;display:flex;flex-direction:column;background:#0a0a1a}
+.chat-header{padding:12px 20px;background:#1a1a2e;border-bottom:1px solid #2a2a4a;display:flex;align-items:center;justify-content:space-between}
+.chat-title .name{font-size:15px;color:#e0e0e0}
+.chat-title .type{font-size:11px;color:#888;margin-left:8px}
+.chat-actions{display:flex;gap:8px}
+.chat-actions button{padding:6px 12px;background:#0f3460;border:none;border-radius:4px;color:#e0e0e0;font-size:12px;cursor:pointer}
+.chat-actions button:hover{background:#1a4a80}
+
+/* 消息列表 */
+.message-list{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column}
+.message-item{display:flex;margin-bottom:12px;max-width:80%}
+.message-item.sent{align-self:flex-end}
+.message-item.sent .msg-bubble{background:#e94560;color:#fff;border-radius:12px 12px 0 12px}
+.message-item.received{align-self:flex-start}
+.message-item.received .msg-bubble{background:#1a1a2e;color:#e0e0e0;border-radius:12px 12px 12px 0;border:1px solid #2a2a4a}
+.msg-avatar{width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,#3b82f6,#22c55e);display:flex;align-items:center;justify-content:center;font-size:12px;flex-shrink:0}
+.message-item.sent .msg-avatar{order:2;margin-left:8px}
+.message-item.received .msg-avatar{order:1;margin-right:8px}
+.msg-content{display:flex;flex-direction:column}
+.message-item.sent .msg-content{order:1}
+.message-item.received .msg-content{order:2}
+.msg-bubble{padding:10px 14px;max-width:max-content}
+.msg-text{font-size:13px;line-height:1.5}
+.msg-time{font-size:10px;color:#666;margin-top:4px;text-align:right}
+.message-item.sent .msg-time{color:#fff8}
+
+/* 输入区域 */
+.chat-input{padding:12px 20px;background:#1a1a2e;border-top:1px solid #2a2a4a}
+.input-row{display:flex;gap:10px}
+.input-row textarea{flex:1;padding:10px 14px;background:#0a0a1a;border:1px solid #2a2a4a;border-radius:8px;color:#e0e0e0;font-size:13px;resize:none;min-height:44px;max-height:120px;font-family:inherit}
+.input-row textarea:focus{outline:none;border-color:#e94560}
+.input-row button{padding:10px 24px;background:#e94560;color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:13px;font-weight:500;flex-shrink:0}
+.input-row button:hover{background:#d63850}
+.input-row button:disabled{opacity:.5;cursor:not-allowed}
+
+/* 客户信息面板 */
+.customer-panel{width:280px;background:#1a1a2e;border-left:1px solid #2a2a4a;display:flex;flex-direction:column}
+.customer-header{padding:16px;border-bottom:1px solid #2a2a4a;text-align:center}
+.customer-avatar{width:64px;height:64px;border-radius:50%;background:linear-gradient(135deg,#e94560,#f59e0b);display:flex;align-items:center;justify-content:center;font-size:24px;margin:0 auto}
+.customer-name{font-size:15px;color:#e0e0e0;margin-top:8px}
+.customer-id{font-size:11px;color:#666}
+.customer-tabs{display:flex;border-bottom:1px solid #2a2a4a}
+.customer-tabs .tab{flex:1;padding:8px;text-align:center;font-size:12px;color:#888;cursor:pointer;border-bottom:2px solid transparent}
+.customer-tabs .tab.active{border-color:#e94560;color:#e94560}
+.customer-detail{flex:1;overflow-y:auto;padding:12px}
+.detail-section{margin-bottom:16px}
+.detail-section h4{font-size:12px;color:#888;margin-bottom:8px}
+.detail-row{display:flex;justify-content:space-between;padding:4px 0;font-size:12px}
+.detail-row .label{color:#888}
+.detail-row .value{color:#e0e0e0}
+.tag-list{display:flex;flex-wrap:gap;gap:4px}
+.tag{display:inline-block;padding:3px 8px;background:#0f3460;color:#ccc;border-radius:4px;font-size:11px}
+.record-item{padding:8px;border-bottom:1px solid #1a1a3e}
+.record-item .time{font-size:11px;color:#666}
+.record-item .desc{font-size:12px;color:#e0e0e0;margin-top:2px}
+
+/* 渠道图标 */
+.channel-qw{background:linear-gradient(135deg,#1890ff,#096dd9)}
+.channel-wx{background:linear-gradient(135deg,#07c160,#10b981)}
+.channel-im{background:linear-gradient(135deg,#6366f1,#8b5cf6)}
+.channel-whatsapp{background:linear-gradient(135deg,#25d366,#10b981)}
+.channel-other{background:linear-gradient(135deg,#6b7280,#9ca3af)}
+
+/* 滚动条 */
+::-webkit-scrollbar{width:6px}
+::-webkit-scrollbar-track{background:#0a0a1a}
+::-webkit-scrollbar-thumb{background:#2a2a4a;border-radius:3px}
+::-webkit-scrollbar-thumb:hover{background:#3a3a5a}
+</style>
+</head>
+<body>
+<div id="app" class="main">
+  <!-- 左侧账户列表 -->
+  <div class="account-panel">
+    <div v-for="acc in accounts" :key="acc.id" 
+         :class="['account-item', acc.active ? 'active' : '', acc.connected ? '' : 'disabled']"
+         @click="selectAccount(acc)" :title="acc.name">
+      <span>{{acc.icon}}</span>
+      <span v-if="acc.unread>0" class="badge">{{acc.unread}}</span>
+    </div>
+  </div>
+
+  <!-- 会话列表 -->
+  <div class="session-panel">
+    <div class="session-header">
+      <h3>🗨️ 聊天列表</h3>
+      <div class="search">
+        <input v-model="searchKey" placeholder="搜索联系人..." @keyup="filterSessions">
+      </div>
+    </div>
+    <div class="session-list">
+      <div v-for="sess in filteredSessions" :key="sess.sessionId" 
+           :class="['session-item', sess.sessionId === currentSession?.sessionId ? 'active' : '', sess.unread > 0 ? 'unread' : '']"
+           @click="selectSession(sess)">
+        <div class="session-avatar">{{sess.avatar||'?'}}</div>
+        <div class="session-info">
+          <div class="name">{{sess.name}}</div>
+          <div class="msg">{{sess.lastMsg||'暂无消息'}}</div>
+        </div>
+        <div style="text-align:right;margin-left:8px">
+          <div class="session-time">{{sess.lastTime||''}}</div>
+          <span v-if="sess.unread>0" class="badge">{{sess.unread}}</span>
+        </div>
+      </div>
+      <div v-if="filteredSessions.length===0" style="text-align:center;padding:40px;color:#666;font-size:13px">
+        暂无会话记录
+      </div>
+    </div>
+  </div>
+
+  <!-- 聊天区域 -->
+  <div class="chat-panel">
+    <div v-if="currentSession" class="chat-header">
+      <div class="chat-title">
+        <span class="name">{{currentSession.name}}</span>
+        <span class="type">{{channelName(currentSession.channelType)}}</span>
+      </div>
+      <div class="chat-actions">
+        <button @click="toggleControlMode">{{currentSession.controlMode === 'ai' ? '🤖 AI接管中' : '👤 人工接管'}}</button>
+        <button @click="showCustomerInfo=true">👤 客户信息</button>
+      </div>
+    </div>
+    <div v-else class="chat-header">
+      <div class="chat-title">
+        <span class="name">请选择一个会话</span>
+      </div>
+    </div>
+
+    <div class="message-list" ref="messageList">
+      <div v-for="(msg, idx) in messages" :key="idx" :class="['message-item', msg.sendType === 1 ? 'received' : 'sent']">
+        <div class="msg-avatar">{{msg.sendType === 1 ? '👤' : '🤖'}}</div>
+        <div class="msg-content">
+          <div class="msg-bubble">
+            <div class="msg-text">{{msg.content}}</div>
+          </div>
+          <div class="msg-time">{{msg.time}}</div>
+        </div>
+      </div>
+      <div v-if="messages.length===0" style="text-align:center;padding:60px;color:#666">
+        <div style="font-size:48px;margin-bottom:12px">💬</div>
+        <p>开始与客户聊天</p>
+      </div>
+    </div>
+
+    <div class="chat-input">
+      <div class="input-row">
+        <textarea v-model="inputMsg" placeholder="输入消息..." @keyup.enter="sendMessage"></textarea>
+        <button @click="sendMessage" :disabled="!inputMsg.trim()">发送</button>
+      </div>
+    </div>
+  </div>
+
+  <!-- 客户信息面板 -->
+  <div class="customer-panel" v-if="showCustomerInfo">
+    <div class="customer-header">
+      <div class="customer-avatar">{{currentSession?.avatar||'?'}}</div>
+      <div class="customer-name">{{currentSession?.name||'-'}}</div>
+      <div class="customer-id">{{currentSession?.channelSourceId||'-'}}</div>
+    </div>
+    <div class="customer-tabs">
+      <div :class="['tab', customerTab==='basic'?'active':'']" @click="customerTab='basic'">基本信息</div>
+      <div :class="['tab', customerTab==='tags'?'active':'']" @click="customerTab='tags'">标签</div>
+      <div :class="['tab', customerTab==='records'?'active':'']" @click="customerTab='records'">访问记录</div>
+    </div>
+    <div class="customer-detail">
+      <div v-if="customerTab==='basic'" class="detail-section">
+        <h4>📋 基本信息</h4>
+        <div class="detail-row"><span class="label">渠道</span><span class="value">{{channelName(currentSession?.channelType)}}</span></div>
+        <div class="detail-row"><span class="label">来源ID</span><span class="value">{{currentSession?.channelSourceId||'-'}}</span></div>
+        <div class="detail-row"><span class="label">联系人ID</span><span class="value">{{currentSession?.contactId||'-'}}</span></div>
+        <div class="detail-row"><span class="label">会话ID</span><span class="value">{{currentSession?.sessionId||'-'}}</span></div>
+        <div class="detail-row"><span class="label">创建时间</span><span class="value">{{currentSession?.createTime||'-'}}</span></div>
+      </div>
+      <div v-if="customerTab==='tags'" class="detail-section">
+        <h4>🏷️ 客户标签</h4>
+        <div class="tag-list">
+          <span v-for="tag in customerTags" :key="tag" class="tag">{{tag}}</span>
+        </div>
+        <div v-if="customerTags.length===0" style="color:#666;font-size:12px">暂无标签</div>
+      </div>
+      <div v-if="customerTab==='records'" class="detail-section">
+        <h4>📊 访问记录</h4>
+        <div v-for="record in visitRecords" :key="record.time" class="record-item">
+          <div class="time">{{record.time}}</div>
+          <div class="desc">{{record.desc}}</div>
+        </div>
+        <div v-if="visitRecords.length===0" style="color:#666;font-size:12px">暂无访问记录</div>
+      </div>
+    </div>
+  </div>
+</div>
+
+<script>
+const {createApp, ref, computed, watch, nextTick, onMounted} = Vue;
+    createApp({
+        setup(){
+            const searchKey = ref('');
+            const inputMsg = ref('');
+            const showCustomerInfo = ref(true);
+            const customerTab = ref('basic');
+            const messageList = ref(null);
+            const loading = ref(false);
+            
+            // ====== API配置 ======
+            // 从URL参数获取配置(iframe嵌入时由父窗口传递)
+            const getUrlParam = (name) => {
+                const params = new URLSearchParams(window.location.search);
+                return params.get(name) || '';
+            };
+            
+            // 获取API基路径:URL参数 > 父窗口webpack环境变量 > 默认值
+            const getBaseApi = () => {
+                const fromUrl = getUrlParam('baseApi');
+                if (fromUrl) return fromUrl;
+                try {
+                    if (window.parent && window.parent.process && window.parent.process.env) {
+                        return window.parent.process.env.VUE_APP_BASE_API || '/dev-api';
+                    }
+                } catch(e) {}
+                return '/dev-api';
+            };
+            const BASE_API = getBaseApi();
+            
+            // 获取前端类型:URL参数 > 默认值
+            const getFrontendType = () => {
+                return getUrlParam('frontendType') || 'company';
+            };
+            const FRONTEND_TYPE = getFrontendType();
+            
+            // 从Cookie获取Token
+            const getToken = () => {
+                const match = document.cookie.match(/(?:^|;\s*)Web-Token=([^;]*)/);
+                return match ? match[1] : null;
+            };
+            
+            // 获取租户编码:URL参数 > localStorage
+            const getTenantCode = () => {
+                const fromUrl = getUrlParam('tenantCode');
+                if (fromUrl) return fromUrl;
+                try {
+                    return localStorage.getItem('tenantCode') || '';
+                } catch(e) { return ''; }
+            };
+            
+            // 认证请求工具
+            const request = async (url, options = {}) => {
+                const token = getToken();
+                const tenantCode = getTenantCode();
+                const headers = {
+                    'Content-Type': 'application/json',
+                    'X-Frontend-Type': FRONTEND_TYPE,
+                };
+                if (token) {
+                    headers['Authorization'] = 'Bearer ' + token;
+                }
+                if (tenantCode) {
+                    headers['tenant-code'] = tenantCode;
+                }
+                // 合并自定义headers
+                if (options.headers) {
+                    Object.assign(headers, options.headers);
+                }
+                
+                const fullUrl = url.startsWith('http') ? url : (BASE_API + url);
+                const response = await fetch(fullUrl, {
+                    ...options,
+                    headers,
+                    credentials: 'include',
+                });
+                if (!response.ok) {
+                    const text = await response.text().catch(() => '');
+                    throw new Error(`HTTP ${response.status}: ${text || response.statusText}`);
+                }
+                const data = await response.json();
+                if (data.code === 401) {
+                    // Token过期,通知父窗口刷新
+                    if (window.parent && window.parent.location) {
+                        window.parent.location.reload();
+                    }
+                    throw new Error('登录已过期');
+                }
+                return data;
+            };
+
+            // 账户列表
+            const accounts = ref([]);
+
+            // 会话列表
+            const sessions = ref([]);
+
+            // 当前会话
+            const currentSession = ref(null);
+
+            // 消息列表
+            const messages = ref([]);
+
+            // 客户标签
+            const customerTags = ref([]);
+
+            // 访问记录
+            const visitRecords = ref([]);
+
+            // 当前登录用户的企微账户
+            const currentAccount = ref(null);
+
+            // 过滤后的会话
+            const filteredSessions = computed(()=>{
+                if(!searchKey.value) return sessions.value;
+                const key = searchKey.value.toLowerCase();
+                return sessions.value.filter(s=>{
+                    const name = s.nickName || s.name || '';
+                    return name.toLowerCase().includes(key);
+                });
+            });
+
+            // 加载账户列表
+            const loadAccounts = async () => {
+                try {
+                    // 先获取当前登录用户绑定的企微账户
+                    const res = await request('/qw/user/getMyQwUserList');
+                    if (res.code === 200 || res.code === 0) {
+                        const qwAccounts = (res.data || []).map((acc, idx) => ({
+                            id: acc.id || `qw_${idx}`,
+                            name: acc.qwUserName || '企微账户',
+                            icon: '💼',
+                            active: idx === 0,
+                            connected: true,
+                            unread: 0,
+                            type: 'QW',
+                            corpId: acc.corpId,
+                            qwUserId: acc.qwUserId
+                        }));
+                        
+                        // 添加个微账户占位(后续扩展)
+                        const wxAccounts = []; // 暂时为空,后续实现个微账户绑定
+                        
+                        accounts.value = [...qwAccounts, ...wxAccounts];
+                        
+                        if (accounts.value.length > 0) {
+                            currentAccount.value = accounts.value[0];
+                        }
+                    }
+                } catch (error) {
+                    console.error('加载账户失败:', error);
+                    // 降级显示模拟账户
+                    accounts.value = [
+                        {id:'qw', name:'企业微信', icon:'💼', active:true, connected:true, unread:0, type:'QW'},
+                        {id:'wx', name:'个人微信', icon:'💬', active:false, connected:false, unread:0, type:'WX'},
+                    ];
+                    currentAccount.value = accounts.value[0];
+                }
+            };
+
+            // 加载会话列表
+            const loadSessions = async () => {
+                try {
+                    // 先尝试加载chat会话
+                    const chatRes = await request('/chat/chatSession/list?pageNum=1&pageSize=100');
+                    if (chatRes.code === 200 || chatRes.rows) {
+                        const chatSessions = (chatRes.rows || []).map(s => ({
+                            sessionId: s.sessionId,
+                            name: s.nickName || s.userName || '客户',
+                            avatar: (s.nickName || s.userName || '客').charAt(0),
+                            channelType: 'CHAT',
+                            channelSourceId: s.userId,
+                            contactId: s.userId,
+                            lastMsg: '',
+                            lastTime: s.createTime || '',
+                            unread: 0,
+                            createTime: s.createTime,
+                            status: s.status
+                        }));
+                        
+                        // 再尝试加载企微外部联系人作为会话
+                        const qwRes = await request('/qw/externalContact/list?pageNum=1&pageSize=100');
+                        if (qwRes.code === 200 || qwRes.rows) {
+                            const qwSessions = (qwRes.rows || []).map(c => ({
+                                sessionId: `qw_${c.id}`,
+                                name: c.name || c.remark || '企微客户',
+                                avatar: (c.name || c.remark || '客').charAt(0),
+                                channelType: 'QW',
+                                channelSourceId: c.externalUserId,
+                                contactId: c.id,
+                                lastMsg: '',
+                                lastTime: c.createTime || '',
+                                unread: 0,
+                                createTime: c.createTime,
+                                tagIds: c.tagIds,
+                                customerId: c.customerId,
+                                remarkMobiles: c.remarkMobiles
+                            }));
+                            sessions.value = [...qwSessions, ...chatSessions];
+                        } else {
+                            sessions.value = chatSessions;
+                        }
+                    }
+                } catch (error) {
+                    console.error('加载会话失败:', error);
+                    sessions.value = [];
+                }
+                
+                if (sessions.value.length > 0 && !currentSession.value) {
+                    selectSession(sessions.value[0]);
+                }
+            };
+
+            // 选择账户
+            const selectAccount = (acc)=>{
+                if(!acc.connected) return;
+                accounts.value.forEach(a=>a.active = false);
+                acc.active = true;
+                currentAccount.value = acc;
+                loadSessions();
+            };
+
+            // 选择会话
+            const selectSession = async (sess)=>{
+                currentSession.value = sess;
+                // 清除未读
+                sess.unread = 0;
+                // 加载消息
+                await loadMessages(sess);
+                // 加载客户标签和信息
+                await loadCustomerInfo(sess);
+            };
+
+            // 加载消息
+            const loadMessages = async (session) => {
+                messages.value = [];
+                try {
+                    if (session.channelType === 'CHAT') {
+                        // 加载chat会话消息
+                        const chatDetailRes = await request(`/chat/chatSession/${session.sessionId}`);
+                        if (chatDetailRes.code === 200 || chatDetailRes.data) {
+                            const msgRes = await request(`/chat/chatMsg/list?sessionId=${session.sessionId}&pageNum=1&pageSize=100`);
+                            if (msgRes.rows) {
+                                messages.value = msgRes.rows.map(m => ({
+                                    content: m.content,
+                                    sendType: m.sendType,
+                                    time: m.createTime || new Date().toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'})
+                                }));
+                            }
+                        }
+                    } else if (session.channelType === 'QW') {
+                        // TODO: 加载企微聊天记录
+                        messages.value = [];
+                    }
+                } catch (error) {
+                    console.error('加载消息失败:', error);
+                    messages.value = [];
+                }
+                
+                nextTick(()=>{
+                    if(messageList.value){
+                        messageList.value.scrollTop = messageList.value.scrollHeight;
+                    }
+                });
+            };
+
+            // 加载客户信息
+            const loadCustomerInfo = async (session) => {
+                customerTags.value = [];
+                visitRecords.value = [];
+                
+                try {
+                    if (session.tagIds && session.tagIds !== '[]') {
+                        // 解析标签ID并加载标签名称
+                        const tagIds = JSON.parse(session.tagIds);
+                        if (tagIds.length > 0) {
+                            const tagRes = await request(`/qw/tag/list?tagIds=${tagIds.join(',')}`);
+                            if (tagRes.rows) {
+                                customerTags.value = tagRes.rows.map(t => t.tagName || t.name);
+                            }
+                        }
+                    }
+                    
+                    // 加载CRM客户信息
+                    if (session.customerId) {
+                        const crmRes = await request(`/crm/customer/${session.customerId}`);
+                        if (crmRes.data) {
+                            // 补充客户基本信息
+                            if (crmRes.data.phone) {
+                                visitRecords.value.push({
+                                    time: new Date().toLocaleDateString('zh-CN'),
+                                    desc: `手机号: ${crmRes.data.phone}`
+                                });
+                            }
+                            if (crmRes.data.email) {
+                                visitRecords.value.push({
+                                    time: new Date().toLocaleDateString('zh-CN'),
+                                    desc: `邮箱: ${crmRes.data.email}`
+                                });
+                            }
+                        }
+                    }
+                    
+                    // 加载fastGpt聊天摘要
+                    try {
+                        const chatRes = await request('/fastGpt/fastGptChatSession/list?pageNum=1&pageSize=10');
+                        if (chatRes.rows) {
+                            const relatedChat = chatRes.rows.find(c => 
+                                c.externalUserId === session.channelSourceId || 
+                                c.userId === session.contactId
+                            );
+                            if (relatedChat) {
+                                visitRecords.value.push({
+                                    time: relatedChat.createTime || new Date().toLocaleDateString('zh-CN'),
+                                    desc: `最近聊天: ${relatedChat.lastMsg || '暂无消息'}`
+                                });
+                            }
+                        }
+                    } catch (err) {
+                        console.error('加载聊天摘要失败:', err);
+                    }
+                } catch (error) {
+                    console.error('加载客户信息失败:', error);
+                }
+            };
+
+            // 发送消息
+            const sendMessage = async ()=>{
+                if(!inputMsg.value.trim() || !currentSession.value) return;
+                
+                const msg = {
+                    content: inputMsg.value, 
+                    sendType: 2, 
+                    time: new Date().toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'})
+                };
+                messages.value.push(msg);
+                const contentToSend = inputMsg.value;
+                inputMsg.value = '';
+                
+                nextTick(()=>{
+                    if(messageList.value){
+                        messageList.value.scrollTop = messageList.value.scrollHeight;
+                    }
+                });
+                
+                // 调用发送消息接口
+                try {
+                    if (currentSession.value.channelType === 'QW') {
+                        // 企微发送消息
+                        await request('/qw/msg/send', {
+                            method: 'POST',
+                            body: JSON.stringify({
+                                externalUserId: currentSession.value.channelSourceId,
+                                content: contentToSend,
+                                qwUserId: currentAccount.value?.qwUserId
+                            })
+                        });
+                    } else if (currentSession.value.channelType === 'CHAT') {
+                        // Chat会话发送消息
+                        await request('/chat/chatMsg', {
+                            method: 'POST',
+                            body: JSON.stringify({
+                                sessionId: currentSession.value.sessionId,
+                                content: contentToSend,
+                                sendType: 2
+                            })
+                        });
+                    }
+                } catch (error) {
+                    console.error('发送消息失败:', error);
+                }
+            };
+
+            // 切换控制模式
+            const toggleControlMode = ()=>{
+                if(currentSession.value){
+                    currentSession.value.controlMode = currentSession.value.controlMode === 'ai' ? 'human' : 'ai';
+                }
+            };
+
+            // 渠道名称
+            const channelName = (type)=>{
+                const names = {QW:'企业微信', WX:'个人微信', IM:'系统IM', WHATSAPP:'WhatsApp', OTHER:'其他渠道', CHAT:'在线咨询'};
+                return names[type] || type;
+            };
+
+            // 初始化
+            onMounted(async () => {
+                await loadAccounts();
+                await loadSessions();
+            });
+
+            return {
+                searchKey, inputMsg, showCustomerInfo, customerTab, messageList, loading,
+                accounts, sessions, currentSession, messages, customerTags, visitRecords,
+                filteredSessions,
+                selectAccount, selectSession, sendMessage, toggleControlMode, channelName
+            };
+        }
+    }).mount('#app');
+</script>
+</body>
+</html>

+ 283 - 0
fs-comm-gateway/src/main/resources/static/sales-corpus.html

@@ -0,0 +1,283 @@
+<!DOCTYPE html>
+<html lang="zh">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>龙虾引擎 - 销冠语料学习</title>
+<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
+<style>
+*{margin:0;padding:0;box-sizing:border-box}
+body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0a0a1a;color:#e0e0e0;min-height:100vh}
+.header{background:#1a1a2e;padding:12px 24px;border-bottom:2px solid #e94560;display:flex;align-items:center;justify-content:space-between}
+.header h1{font-size:18px;color:#e94560}
+.header .nav{display:flex;gap:8px}
+.header .nav a{color:#aaa;text-decoration:none;padding:6px 12px;border-radius:4px;font-size:13px;transition:.2s}
+.header .nav a:hover,.header .nav a.active{background:#e94560;color:#fff}
+.main{padding:20px 24px;max-width:1400px;margin:0 auto}
+.card{background:#1a1a2e;border:1px solid #2a2a4a;border-radius:8px;padding:20px;margin-bottom:16px}
+.card h2{font-size:15px;color:#e94560;margin-bottom:12px;border-bottom:1px solid #2a2a4a;padding-bottom:8px}
+.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
+.grid3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px}
+.form-group{margin-bottom:12px}
+.form-group label{display:block;font-size:12px;color:#888;margin-bottom:4px}
+.form-group input,.form-group textarea,.form-group select{width:100%;padding:8px 10px;background:#0a0a1a;border:1px solid #2a2a4a;border-radius:4px;color:#e0e0e0;font-size:13px;font-family:inherit}
+.form-group textarea{resize:vertical;min-height:80px}
+.form-group input:focus,.form-group textarea:focus,.form-group select:focus{outline:none;border-color:#e94560}
+.btn{padding:8px 18px;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;transition:.2s}
+.btn-primary{background:#e94560;color:#fff}
+.btn-primary:hover{background:#d63850}
+.btn-secondary{background:#0f3460;color:#fff}
+.btn-secondary:hover{background:#1a4a80}
+.btn-success{background:#22c55e;color:#fff}
+.btn-success:hover{background:#1a9e4a}
+.btn-warning{background:#f59e0b;color:#000}
+.toolbar{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap}
+.stat-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
+.stat-card{background:linear-gradient(135deg,#16213e,#0f3460);padding:16px;border-radius:8px;text-align:center}
+.stat-card .num{font-size:28px;font-weight:bold;color:#e94560}
+.stat-card .label{font-size:11px;color:#999;margin-top:4px}
+table{width:100%;border-collapse:collapse;font-size:12px}
+table th{background:#0f3460;padding:8px 10px;text-align:left;font-weight:500;color:#ccc}
+table td{padding:8px 10px;border-bottom:1px solid #1a1a3e}
+table tr:hover{background:#16213e}
+.tag{display:inline-block;padding:2px 8px;border-radius:3px;font-size:10px;margin:1px 2px}
+.tag-raw{background:#f59e0b22;color:#f59e0b;border:1px solid #f59e0b44}
+.tag-analyzed{background:#22c55e22;color:#22c55e;border:1px solid #22c55e44}
+.tag-applied{background:#3b82f622;color:#3b82f6;border:1px solid #3b82f644}
+.analysis-panel{background:#0a0a1a;padding:16px;border-radius:6px;margin-top:12px;max-height:400px;overflow-y:auto}
+.analysis-panel h4{color:#e94560;font-size:13px;margin:10px 0 6px}
+.analysis-panel .item{font-size:12px;color:#ccc;padding:4px 0;border-left:2px solid #e94560;margin:4px 0;padding-left:10px}
+.analysis-panel .score{font-size:48px;font-weight:bold;color:#22c55e;text-align:center}
+.tab-bar{display:flex;border-bottom:1px solid #2a2a4a;margin-bottom:16px}
+.tab-bar .tab{padding:8px 16px;cursor:pointer;font-size:13px;border-bottom:2px solid transparent;transition:.2s}
+.tab-bar .tab:hover{color:#e94560}
+.tab-bar .tab.active{border-bottom-color:#e94560;color:#e94560}
+.toast{position:fixed;top:20px;right:20px;z-index:999;padding:10px 18px;border-radius:6px;font-size:13px;animation:slideIn .3s}
+.toast-success{background:#22c55e;color:#000}
+.toast-error{background:#e94560;color:#fff}
+@keyframes slideIn{from{transform:translateX(100px);opacity:0}to{transform:translateX(0);opacity:1}}
+.empty{text-align:center;padding:40px;color:#666;font-size:14px}
+.badge{background:#e94560;color:#fff;border-radius:10px;padding:1px 6px;font-size:10px}
+pre{background:#0a0a1a;padding:10px;border-radius:4px;overflow:auto;font-size:11px;color:#a0a0a0;max-height:300px}
+</style>
+</head>
+<body>
+<div id="app">
+<div class="header">
+  <h1>🦞 龙虾引擎 - 销冠语料学习</h1>
+  <div class="nav">
+    <a href="/workflow-canvas.html" target="_blank">工作流画布</a>
+    <a href="#" class="active">销冠语料</a>
+  </div>
+</div>
+
+<div class="main">
+  <!-- 统计卡片 -->
+  <div class="stat-grid" style="margin-bottom:16px">
+    <div class="stat-card"><div class="num">{{stats.total}}</div><div class="label">总语料</div></div>
+    <div class="stat-card"><div class="num">{{stats.raw}}</div><div class="label">待分析</div></div>
+    <div class="stat-card"><div class="num">{{stats.analyzed}}</div><div class="label">已分析</div></div>
+    <div class="stat-card"><div class="num">{{stats.applied}}</div><div class="label">已应用</div></div>
+  </div>
+
+  <!-- Tab切换 -->
+  <div class="tab-bar">
+    <div class="tab" :class="{active:tab==='list'}" @click="tab='list'">📋 语料列表</div>
+    <div class="tab" :class="{active:tab==='add'}" @click="tab='add'">✍️ 单条录入</div>
+    <div class="tab" :class="{active:tab==='batch'}" @click="tab='batch'">📥 批量导入</div>
+    <div class="tab" :class="{active:tab==='analyze'}" @click="tab='analyze'">🤖 AI分析</div>
+  </div>
+
+  <!-- 列表 -->
+  <div class="card" v-if="tab==='list'">
+    <div class="toolbar">
+      <select v-model="filterScenario" @change="loadList" style="padding:6px 10px;background:#0a0a1a;border:1px solid #2a2a4a;color:#e0e0e0;border-radius:4px">
+        <option value="">全部场景</option>
+        <option v-for="s in scenarios" :value="s.code">{{s.name}}</option>
+      </select>
+      <select v-model="filterStatus" @change="loadList" style="padding:6px 10px;background:#0a0a1a;border:1px solid #2a2a4a;color:#e0e0e0;border-radius:4px">
+        <option value="">全部状态</option>
+        <option value="raw">待分析</option>
+        <option value="analyzed">已分析</option>
+        <option value="applied">已应用</option>
+      </select>
+      <button class="btn btn-secondary" @click="loadList">🔍 查询</button>
+      <button class="btn btn-primary" @click="tab='add'">+ 录入</button>
+    </div>
+    <table v-if="list.length>0">
+      <thead><tr>
+        <th>ID</th><th>销冠</th><th>场景</th><th>客户问题</th><th>销冠回答</th><th>状态</th><th>引用</th><th>时间</th>
+      </tr></thead>
+      <tbody>
+        <tr v-for="item in list" :key="item.id">
+          <td>{{item.id}}</td>
+          <td>{{item.salesperson_name||'-'}}</td>
+          <td>{{scenarioLabel(item.scenario)}}</td>
+          <td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{item.customer_question||''}}</td>
+          <td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{item.sales_answer||''}}</td>
+          <td><span :class="'tag tag-'+item.status">{{item.status==='raw'?'待分析':item.status==='analyzed'?'已分析':'已应用'}}</span></td>
+          <td>{{item.usage_count||0}}</td>
+          <td>{{(item.create_time||'').substring(0,10)}}</td>
+        </tr>
+      </tbody>
+    </table>
+    <div v-else class="empty">暂无语料数据,请先录入</div>
+  </div>
+
+  <!-- 单条录入 -->
+  <div class="card" v-if="tab==='add'">
+    <h2>✍️ 录入销冠对话</h2>
+    <div class="grid2">
+      <div>
+        <div class="form-group"><label>销冠名称</label><input v-model="form.salespersonName" placeholder="例如: 张三"></div>
+        <div class="form-group"><label>场景</label>
+          <select v-model="form.scenario">
+            <option v-for="s in scenarios" :value="s.code">{{s.name}}</option>
+          </select>
+        </div>
+        <div class="form-group"><label>行业类型</label><input v-model="form.industryType" placeholder="education/travel/medical/ecommerce"></div>
+      </div>
+      <div>
+        <div class="form-group"><label>标签 (逗号分隔)</label><input v-model="form.tags" placeholder="高客单价,老客户,逼单"></div>
+      </div>
+    </div>
+    <div class="grid2">
+      <div class="form-group"><label>客户说的话</label><textarea v-model="form.customerQuestion" placeholder="例如: 你们这个课程适合多大孩子?" style="min-height:100px"></textarea></div>
+      <div class="form-group"><label>销冠回答</label><textarea v-model="form.salesAnswer" placeholder="例如: 咱们课程是专门为6-12岁孩子设计的,您家宝贝今年多大啦?" style="min-height:100px"></textarea></div>
+    </div>
+    <div class="toolbar">
+      <button class="btn btn-primary" @click="addDialog" :disabled="submitting">{{submitting?'提交中...':'✅ 录入'}}</button>
+      <button class="btn btn-secondary" @click="clearForm">重置</button>
+    </div>
+  </div>
+
+  <!-- 批量导入 -->
+  <div class="card" v-if="tab==='batch'">
+    <h2>📥 批量导入聊天记录</h2>
+    <p style="font-size:12px;color:#888;margin-bottom:12px">JSON格式,每行一条对话: [{"customer":"客户说","sales":"销冠答","scenario":"consult"}, ...]</p>
+    <div class="grid2">
+      <div>
+        <div class="form-group"><label>销冠名称</label><input v-model="batchForm.salespersonName" placeholder="张三"></div>
+        <div class="form-group"><label>行业类型</label><input v-model="batchForm.industryType" placeholder="education"></div>
+      </div>
+      <div></div>
+    </div>
+    <div class="form-group"><label>聊天JSON</label><textarea v-model="batchForm.chats" placeholder='[{"customer":"您好,我想咨询一下","sales":"您好!请问有什么可以帮您的?","scenario":"greeting"},{"customer":"价格多少?","sales":"这款产品现在有活动,原价..."}]' style="min-height:200px;font-family:monospace;font-size:12px"></textarea></div>
+    <div class="toolbar">
+      <button class="btn btn-primary" @click="batchImport" :disabled="submitting">{{submitting?'导入中...':'📤 批量导入'}}</button>
+      <button class="btn btn-secondary" @click="batchForm={salespersonName:'',industryType:'',chats:''}">清空</button>
+    </div>
+    <div v-if="batchResult" style="margin-top:12px;padding:10px;background:#22c55e22;border-radius:4px;font-size:13px;color:#22c55e">
+      ✅ 批量导入成功,共 {{batchResult.count}} 条语料
+    </div>
+  </div>
+
+  <!-- AI分析 -->
+  <div class="card" v-if="tab==='analyze'">
+    <h2>🤖 AI分析销冠语料</h2>
+    <p style="font-size:12px;color:#888;margin-bottom:12px">AI将自动分析待分析的语料,提取销冠的提问模式、回答策略、信任建立技巧等</p>
+    <div class="toolbar">
+      <button class="btn btn-warning" @click="runAnalyze" :disabled="analyzing">{{analyzing?'🤖 AI分析中,请稍候...':'🚀 开始AI分析'}}</button>
+      <span style="font-size:12px;color:#888;margin-left:8px">当前待分析: {{stats.raw}} 条</span>
+    </div>
+
+    <div v-if="analysisResult" class="analysis-panel">
+      <div class="score">{{analysisResult.overallScore||'-'}}<span style="font-size:16px">分</span></div>
+      <p style="text-align:center;color:#ccc;font-size:13px;margin:8px 0">{{analysisResult.summary}}</p>
+      <h4>❓ 提问模式 ({{(analysisResult.questionPatterns||[]).length}})</h4>
+      <div v-for="q in (analysisResult.questionPatterns||[])" class="item">{{q.pattern}} — "{{q.example}}"<br><small style="color:#888">{{q.why_effective}}</small></div>
+      <h4>💬 回答模式 ({{(analysisResult.answerPatterns||[]).length}})</h4>
+      <div v-for="a in (analysisResult.answerPatterns||[])" class="item">{{a.pattern}} <span class="badge">{{a.structure}}</span> — "{{a.example}}"</div>
+      <h4>🤝 信任策略 ({{(analysisResult.trustStrategies||[]).length}})</h4>
+      <div v-for="t in (analysisResult.trustStrategies||[])" class="item">{{t.strategy}} — {{t.technique}}</div>
+      <h4>🎯 促成技巧 ({{(analysisResult.closingSkills||[]).length}})</h4>
+      <div v-for="c in (analysisResult.closingSkills||[])" class="item">{{c.skill}} ({{c.timing}}) — "{{c.example}}"</div>
+      <h4>🛡️ 异议应对 ({{(analysisResult.objectionHandling||[]).length}})</h4>
+      <div v-for="o in (analysisResult.objectionHandling||[])" class="item">{{o.objection}} → {{o.response_pattern}}</div>
+      <h4>🧠 人格特质</h4>
+      <div style="display:flex;gap:6px;flex-wrap:wrap">
+        <span v-for="p in (analysisResult.personalityTraits||[])" class="tag tag-analyzed">{{p}}</span>
+      </div>
+    </div>
+  </div>
+</div>
+
+<div v-if="toast.visible" :class="'toast toast-'+toast.type">{{toast.msg}}</div>
+</div>
+
+<script>
+const {createApp} = Vue;
+createApp({
+  data(){return{
+    tab:'list',
+    form:{salespersonName:'',scenario:'general',industryType:'',customerQuestion:'',salesAnswer:'',tags:''},
+    batchForm:{salespersonName:'',industryType:'',chats:''},
+    batchResult:null,
+    filterScenario:'',filterStatus:'',
+    list:[],stats:{total:0,raw:0,analyzed:0,applied:0},
+    scenarios:[
+      {code:'greeting',name:'初次问候'},{code:'consult',name:'需求咨询'},{code:'complaint',name:'投诉处理'},
+      {code:'closing',name:'逼单促成'},{code:'followup',name:'跟进关怀'},{code:'objection',name:'异议应对'},
+      {code:'recommend',name:'产品推荐'},{code:'negotiation',name:'价格谈判'},{code:'after_sales',name:'售后服务'},
+      {code:'general',name:'通用场景'}
+    ],
+    submitting:false,analyzing:false,analysisResult:null,
+    toast:{visible:false,msg:'',type:'success'}
+  }},
+  mounted(){this.loadList();this.loadScenarios();},
+  methods:{
+    api(path,body){return fetch('/workflow/lobster/sales-corpus'+path,{method:body?'POST':'GET',headers:body?{'Content-Type':'application/json'}:{},body:body?JSON.stringify(body):null}).then(r=>r.json());},
+    showToast(msg,type='success'){this.toast={visible:true,msg,type};setTimeout(()=>this.toast.visible=false,3000);},
+    scenarioLabel(s){const f=this.scenarios.find(x=>x.code===s);return f?f.name:s||'-';},
+    clearForm(){this.form={salespersonName:'',scenario:'general',industryType:'',customerQuestion:'',salesAnswer:'',tags:''};},
+    async loadScenarios(){
+      try{const r=await this.api('/scenarios');if(r.code===0&&r.data)this.scenarios=r.data;}catch(e){}
+    },
+    async loadList(){
+      try{
+        let url='/list?page=1&size=200';
+        if(this.filterScenario)url+='&scenario='+this.filterScenario;
+        if(this.filterStatus)url+='&status='+this.filterStatus;
+        const r=await fetch(url).then(r=>r.json());
+        if(r.code===0&&r.data){
+          this.list=r.data.list||[];
+          this.stats={total:r.data.total||0,raw:r.data.raw||0,analyzed:r.data.analyzed||0,applied:r.data.applied||0};
+        }
+      }catch(e){this.showToast('加载失败','error');}
+    },
+    async addDialog(){
+      if(!this.form.customerQuestion||!this.form.salesAnswer){this.showToast('客户问题和销冠回答不能为空','error');return;}
+      this.submitting=true;
+      try{
+        const body={salespersonName:this.form.salespersonName||'销冠',scenario:this.form.scenario,industryType:this.form.industryType,customerQuestion:this.form.customerQuestion,salesAnswer:this.form.salesAnswer,tags:this.form.tags?this.form.tags.split(',').reduce((a,k)=>{a[k.trim()]=true;return a;},{}):{}};
+        const r=await this.api('/dialog',body);
+        if(r.code===0){this.showToast('录入成功');this.clearForm();this.loadList();}
+        else this.showToast(r.msg||'录入失败','error');
+      }catch(e){this.showToast('录入失败','error');}
+      this.submitting=false;
+    },
+    async batchImport(){
+      if(!this.batchForm.chats){this.showToast('请填写聊天JSON','error');return;}
+      this.submitting=true;this.batchResult=null;
+      try{
+        const body={salespersonName:this.batchForm.salespersonName||'销冠',industryType:this.batchForm.industryType,chats:this.batchForm.chats};
+        const r=await this.api('/batch-import',body);
+        if(r.code===0){this.batchResult={count:r.data.count};this.showToast('导入成功:'+r.data.count+'条');this.loadList();}
+        else this.showToast(r.msg||'导入失败','error');
+      }catch(e){this.showToast('导入失败','error');}
+      this.submitting=false;
+    },
+    async runAnalyze(){
+      this.analyzing=true;this.analysisResult=null;
+      try{
+        const r=await this.api('/analyze');
+        if(r.code===0){this.analysisResult=r.data;this.showToast('分析完成,得分:'+r.data.overallScore);this.loadList();}
+        else this.showToast(r.msg||'分析失败','error');
+      }catch(e){this.showToast('分析失败','error');}
+      this.analyzing=false;
+    }
+  }
+}).mount('#app');
+</script>
+</body>
+</html>

+ 324 - 0
fs-comm-gateway/src/main/resources/static/workflow-canvas.html

@@ -0,0 +1,324 @@
+<!DOCTYPE html>
+<html lang="zh">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>龙虾引擎 - 工作流可视化编辑器</title>
+<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
+<script src="https://unpkg.com/@vue-flow/core@latest/dist/vue-flow.umd.js"></script>
+<script src="https://unpkg.com/@vue-flow/background@latest/dist/vue-flow-background.umd.js"></script>
+<script src="https://unpkg.com/@vue-flow/minimap@latest/dist/vue-flow-minimap.umd.js"></script>
+<script src="https://unpkg.com/@vue-flow/controls@latest/dist/vue-flow-controls.umd.js"></script>
+<link href="https://unpkg.com/@vue-flow/core@latest/dist/style.css" rel="stylesheet">
+<link href="https://unpkg.com/@vue-flow/minimap@latest/dist/style.css" rel="stylesheet">
+<link href="https://unpkg.com/@vue-flow/controls@latest/dist/style.css" rel="stylesheet">
+
+<style>
+*{margin:0;padding:0;box-sizing:border-box}
+body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;height:100vh;overflow:hidden}
+#app{display:flex;height:100vh}
+.sidebar{width:220px;background:#1a1a2e;color:#fff;padding:12px;overflow-y:auto;flex-shrink:0}
+.sidebar h3{font-size:14px;margin:8px 0;color:#e94560;border-bottom:1px solid #333;padding-bottom:4px}
+.node-item{background:#16213e;margin:4px 0;padding:8px;border-radius:6px;cursor:grab;font-size:12px;border:1px solid #0f3460;transition:all .2s}
+.node-item:hover{background:#0f3460;border-color:#e94560;transform:translateX(2px)}
+.node-item .type-tag{display:inline-block;background:#e94560;color:#fff;padding:1px 6px;border-radius:3px;font-size:10px;margin-right:6px}
+.main{flex:1;position:relative}
+.toolbar{position:absolute;top:10px;left:10px;z-index:10;display:flex;gap:8px}
+.toolbar button{padding:6px 14px;border:none;border-radius:4px;cursor:pointer;font-size:12px}
+.btn-primary{background:#e94560;color:#fff}
+.btn-secondary{background:#0f3460;color:#fff}
+.btn-success{background:#22c55e;color:#fff}
+.prop-panel{width:320px;background:#f8f9fa;border-left:2px solid #dee2e6;padding:12px;overflow-y:auto;flex-shrink:0;font-size:13px}
+.prop-panel h3{color:#1a1a2e;margin-bottom:10px}
+.prop-panel label{display:block;margin:6px 0 2px;color:#555;font-weight:600}
+.prop-panel input,.prop-panel textarea,.prop-panel select{width:100%;padding:6px;border:1px solid #ccc;border-radius:4px;font-size:12px;margin-bottom:6px}
+.prop-panel textarea{min-height:60px;font-family:monospace}
+.vue-flow__node{font-size:12px;min-width:120px;text-align:center}
+.flow-node{padding:8px 12px;border-radius:8px;border:2px solid;background:#fff;box-shadow:0 2px 6px rgba(0,0,0,.1)}
+.flow-node.start{border-color:#22c55e}
+.flow-node.ai{border-color:#8b5cf6}
+.flow-node.msg{border-color:#3b82f6}
+.flow-node.cond{border-color:#f59e0b}
+.flow-node.end{border-color:#ef4444}
+.flow-node.default{border-color:#6b7280}
+.node-name{font-weight:700}
+.node-desc{font-size:10px;color:#888;margin-top:2px}
+.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000}
+.modal{background:#fff;border-radius:12px;padding:24px;width:500px;max-height:80vh;overflow-y:auto}
+.modal h4{margin-bottom:12px}
+.modal textarea{width:100%;min-height:120px;margin:8px 0;font-family:monospace;font-size:12px;padding:8px}
+.modal .actions{display:flex;gap:8px;justify-content:flex-end;margin-top:12px}
+</style>
+</head>
+<body>
+<div id="app">
+  <div class="sidebar">
+    <h3>🦞 龙虾引擎</h3>
+    <div v-for="(group,gname) in nodeGroups" :key="gname">
+      <h3>{{gname}}</h3>
+      <div class="node-item" v-for="nt in group" :key="nt.type"
+           @dragstart="onDragStart($event,nt)" draggable="true">
+        <span class="type-tag">{{nt.type}}</span>{{nt.name}}
+      </div>
+    </div>
+    <div style="margin-top:12px;border-top:1px solid #333;padding-top:8px">
+      <button class="btn-primary" style="width:100%;padding:8px;margin:4px 0" @click="showGenerate=true">🤖 AI生成工作流</button>
+      <button class="btn-secondary" style="width:100%;padding:8px;margin:4px 0" @click="exportJson">📋 导出JSON</button>
+      <button class="btn-success" style="width:100%;padding:8px;margin:4px 0" @click="saveWorkflow">💾 保存到服务器</button>
+      <button class="btn-secondary" style="width:100%;padding:8px;margin:4px 0" @click="showLoad=true">📂 加载工作流</button>
+    </div>
+  </div>
+
+  <div class="main" @drop="onDrop" @dragover.prevent>
+    <div class="toolbar">
+      <button class="btn-primary" @click="fitView()">🎯 适应视图</button>
+      <button class="btn-secondary" @click="undo()">↩ 撤销</button>
+    </div>
+    <vue-flow ref="flowRef" v-model="elements" :node-types="nodeTypes"
+      :default-edge-options="{animated:true,style:{stroke:'#64748b',strokeWidth:2}}"
+      @node-click="onNodeClick" @connect="onConnect" style="height:100%;background:#f1f5f9">
+      <template #node-start="props"><div class="flow-node start"><div class="node-name">🚀 {{props.data.label}}</div></div></template>
+      <template #node-ai="props"><div class="flow-node ai"><div class="node-name">🧠 {{props.data.label}}</div></div></template>
+      <template #node-msg="props"><div class="flow-node msg"><div class="node-name">💬 {{props.data.label}}</div></div></template>
+      <template #node-cond="props"><div class="flow-node cond"><div class="node-name">🔀 {{props.data.label}}</div></div></template>
+      <template #node-end="props"><div class="flow-node end"><div class="node-name">🏁 {{props.data.label}}</div></div></template>
+      <template #node-default="props"><div class="flow-node default"><div class="node-name">📌 {{props.data.label}}</div></div></template>
+      <vue-flow-background />
+      <vue-flow-minimap />
+      <vue-flow-controls />
+    </vue-flow>
+  </div>
+
+  <div class="prop-panel" v-if="selectedNode">
+    <h3>节点属性</h3>
+    <label>节点编码</label><input v-model="selectedNode.data.nodeCode">
+    <label>节点名称</label><input v-model="selectedNode.data.label">
+    <label>节点类型</label>
+    <select v-model.number="selectedNode.data.nodeType" @change="onTypeChange">
+      <option v-for="nt in allNodes" :value="nt.type" :key="nt.type">{{nt.type}}-{{nt.name}}</option>
+    </select>
+    <label>下一节点</label><input v-model="selectedNode.data.nextNodeCode" placeholder="next_node_code">
+    <label>话术模板</label><textarea v-model="selectedNode.data.messageTemplate" placeholder="支持 ${变量}"></textarea>
+    <label>条件表达式</label><textarea v-model="selectedNode.data.conditionExpr" placeholder='{"field":"intent","op":"eq","value":"purchase"}'></textarea>
+    <label>节点配置(JSON)</label><textarea v-model="selectedNode.data.nodeConfig" placeholder='{"collectFields":["1","2"]}'></textarea>
+    <label>最大轮次</label><input v-model.number="selectedNode.data.maxRounds" type="number" min="0">
+    <button class="btn-primary" style="width:100%;margin-top:8px;padding:8px" @click="selectedNode=null">关闭</button>
+  </div>
+</div>
+
+<div class="modal-overlay" v-if="showGenerate">
+  <div class="modal">
+    <h4>🤖 AI生成工作流</h4>
+    <label>需求描述</label>
+    <textarea v-model="genReq" placeholder="例如:我需要一个旅游行业的客户跟进流程,包括意图识别、信息收集、发送方案、条件判断、创建跟进任务..."></textarea>
+    <label>行业类型</label>
+    <select v-model="genIndustry">
+      <option value="travel">旅游</option><option value="medical">医美</option>
+      <option value="education">教育</option><option value="insurance">保险</option>
+      <option value="general">通用</option>
+    </select>
+    <label>已有工作流JSON(可选-迭代优化)</label>
+    <textarea v-model="genExisting" placeholder="粘贴已有工作流JSON进行迭代优化...留空则全新生成"></textarea>
+    <label>迭代指令(可选)</label>
+    <input v-model="genInstruction" placeholder="如:增加关怀节点、优化话术、调整条件">
+    <div class="actions">
+      <button class="btn-secondary" @click="showGenerate=false">取消</button>
+      <button class="btn-primary" @click="aiGenerate()">生成</button>
+    </div>
+    <div v-if="genResult" style="margin-top:12px;background:#f0fdf4;padding:10px;border-radius:6px;font-size:12px">
+      <b>评分:{{genResult.score}}</b> {{genResult.details}}
+    </div>
+  </div>
+</div>
+
+<div class="modal-overlay" v-if="showLoad">
+  <div class="modal">
+    <h4>📂 加载工作流</h4>
+    <textarea v-model="loadJson" placeholder="粘贴工作流JSON..."></textarea>
+    <div class="actions">
+      <button class="btn-secondary" @click="showLoad=false">取消</button>
+      <button class="btn-primary" @click="importJson()">加载</button>
+    </div>
+  </div>
+</div>
+</div>
+
+<script>
+const {createApp,ref,computed,reactive,onMounted} = Vue
+const nodeGroups = {
+  '流程控制':[{type:1,name:'开始',icon:'🚀'},{type:99,name:'结束',icon:'🏁'},{type:4,name:'等待',icon:'⏳'}],
+  'AI智能':[{type:2,name:'AI处理',icon:'🧠'},{type:11,name:'知识检索',icon:'🔍'}],
+  '交互触达':[{type:3,name:'发送消息',icon:'💬'},{type:7,name:'信息收集',icon:'📋'},{type:10,name:'HTTP调用',icon:'🌐'}],
+  '逻辑控制':[{type:5,name:'条件判断',icon:'🔀'},{type:13,name:'循环迭代',icon:'🔄'},{type:16,name:'变量赋值',icon:'📝'}],
+  '业务操作':[{type:6,name:'创建任务',icon:'✅'},{type:8,name:'转人工',icon:'👤'},{type:9,name:'标签操作',icon:'🏷'}],
+  '扩展能力':[{type:12,name:'代码执行',icon:'⚡'},{type:14,name:'数据库查询',icon:'🗄'},{type:15,name:'子流程',icon:'📦'}]
+}
+const allNodes = Object.values(nodeGroups).flat()
+
+const typeToCategory = {}
+allNodes.forEach(n => {
+  if (n.type === 1) typeToCategory[n.type] = 'start'
+  else if (n.type === 99) typeToCategory[n.type] = 'end'
+  else if (n.type === 2 || n.type === 11) typeToCategory[n.type] = 'ai'
+  else if (n.type === 3 || n.type === 7 || n.type === 10) typeToCategory[n.type] = 'msg'
+  else if (n.type === 5 || n.type === 13 || n.type === 16) typeToCategory[n.type] = 'cond'
+  else typeToCategory[n.type] = 'default'
+})
+
+const app = createApp({
+  setup(){
+    const flowRef = ref(null)
+    const selectedNode = ref(null)
+    const elements = ref([
+      {id:'1',type:'start',position:{x:300,y:50},data:{label:'开始',nodeCode:'START',nodeType:1,nextNodeCode:'MSG_1',messageTemplate:'',conditionExpr:'',nodeConfig:'{}',maxRounds:0}},
+      {id:'2',type:'msg',position:{x:300,y:180},data:{label:'欢迎消息',nodeCode:'MSG_1',nodeType:3,nextNodeCode:'END',messageTemplate:'您好${customerName},欢迎咨询!',conditionExpr:'',nodeConfig:'{}',maxRounds:0}},
+      {id:'3',type:'end',position:{x:300,y:310},data:{label:'结束',nodeCode:'END',nodeType:99,nextNodeCode:'',messageTemplate:'',conditionExpr:'',nodeConfig:'{}',maxRounds:0}},
+      {id:'e1-2',source:'1',target:'2',animated:true},
+    ])
+    const showGenerate = ref(false), showLoad = ref(false)
+    const genReq = ref(''), genIndustry = ref('travel'), genExisting = ref(''), genInstruction = ref('')
+    const genResult = ref(null), loadJson = ref('')
+    let nodeIdCounter = elements.value.length
+
+    const nodeTypes = {
+      start:{template:'#node-start'}, ai:{template:'#node-ai'}, msg:{template:'#node-msg'},
+      cond:{template:'#node-cond'}, end:{template:'#node-end'}, default:{template:'#node-default'}
+    }
+
+    function onDragStart(ev,nt){
+      ev.dataTransfer.setData('application/json',JSON.stringify(nt))
+      ev.dataTransfer.effectAllowed = 'move'
+    }
+    function onDrop(ev){
+      const nt = JSON.parse(ev.dataTransfer.getData('application/json'))
+      const pos = flowRef.value?.screenToFlowCoordinate?.({x:ev.clientX,y:ev.clientY}) || {x:ev.offsetX,y:ev.offsetY}
+      nodeIdCounter++
+      const cat = typeToCategory[nt.type] || 'default'
+      elements.value.push({
+        id:String(nodeIdCounter), type:cat, position:pos,
+        data:{label:nt.name,nodeCode:nt.name.toUpperCase()+'_'+nodeIdCounter,nodeType:nt.type,
+              nextNodeCode:'',messageTemplate:'',conditionExpr:'',nodeConfig:'{}',maxRounds:0}
+      })
+    }
+    function onNodeClick({node}){ selectedNode.value = node }
+    function onConnect(conn){
+      elements.value.push({id:'e'+conn.source+'-'+conn.target,source:conn.source,target:conn.target,animated:true})
+    }
+    function onTypeChange(){
+      if(selectedNode.value){
+        selectedNode.value.type = typeToCategory[selectedNode.value.data.nodeType] || 'default'
+      }
+    }
+    function fitView(){ flowRef.value?.fitView() }
+    function undo(){ elements.value.pop() }
+    function exportJson(){
+      const nodes = elements.value.filter(e=>e.type&&e.data).map(e=>({nodeCode:e.data.nodeCode,nodeName:e.data.label,nodeType:e.data.nodeType,sortNo:parseInt(e.id),nextNodeCode:e.data.nextNodeCode,messageTemplate:e.data.messageTemplate||'',conditionExpr:e.data.conditionExpr||'',nodeConfig:e.data.nodeConfig||'{}',maxRounds:e.data.maxRounds||0}))
+      const edges = elements.value.filter(e=>e.source&&e.target).map((e,i)=>({edgeKey:'EDGE_'+i,sourceNodeCode:elements.value.find(n=>n.id===e.source)?.data?.nodeCode||'',targetNodeCode:elements.value.find(n=>n.id===e.target)?.data?.nodeCode||'',edgeLabel:''}))
+      const json = JSON.stringify({templateName:'工作流模板',description:'',nodes,edges},null,2)
+      navigator.clipboard.writeText(json).then(()=>alert('已复制到剪贴板'))
+      console.log(json)
+    }
+
+    async function saveWorkflow(){
+      exportJson()
+      const token = localStorage.getItem('company_token') || ''
+      try{
+        const r = await fetch('http://localhost:8006/workflow/lobster/template/save',{
+          method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+token},
+          body:JSON.stringify(JSON.parse(await navigator.clipboard.readText()))
+        })
+        const d = await r.json()
+        alert(d.code===200?'保存成功!':'保存失败: '+d.msg)
+      }catch(e){alert('请先登录系统获取token,或在控制台复制JSON手动保存')}
+    }
+
+    async function aiGenerate(){
+      genResult.value = null
+      genStatus.value = 'submitting'
+      genProgress.value = '正在提交AI生成任务...'
+      
+      const body = {requirement:genReq.value,industryType:genIndustry.value,modelConfig:{modelA:'doubao-lite',modelB:'doubao-lite',modelC:'doubao-lite'}}
+      if(genExisting.value){
+        body.existingWorkflow = genExisting.value
+        body.modifyInstruction = genInstruction.value||'优化工作流结构'
+      }
+      try{
+        /* 第一步: 提交异步任务, 立即返回recordId */
+        const submitRes = await fetch('/workflow/ai-generator/generate',{
+          method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)
+        })
+        const submitData = await submitRes.json()
+        if(submitData.code!==200||!submitData.data||!submitData.data.recordId){
+          genStatus.value = 'error'
+          genProgress.value = '提交失败'
+          alert('AI生成任务提交失败')
+          return
+        }
+
+        const recordId = submitData.data.recordId
+        genProgress.value = 'AI正在生成工作流(模型A生成中)...'
+        
+        /* 第二步: 轮询任务状态, 每3秒查询一次, 最多轮询60次(3分钟) */
+        for(let i=0;i<60;i++){
+          await new Promise(r=>setTimeout(r,3000))
+          const pollRes = await fetch('/workflow/ai-generator/result/'+recordId+'/detail')
+          const pollData = await pollRes.json()
+          if(pollData.code===200&&pollData.data){
+            const result = pollData.data
+            if(result.status==='completed'||result.status==='done'){
+              genResult.value = {score:result.qualityScore||'85',details:result.suggestions||''}
+              elements.value = parseWorkflowJson(result.workflowJson || result.nodes || result)
+              genStatus.value = 'done'
+              genProgress.value = 'AI生成完成! (得分:'+(result.qualityScore||85)+')'
+              return
+            }
+            if(result.status==='failed'){
+              genStatus.value = 'error'
+              genProgress.value = '生成失败: '+(result.errorMsg||'未知错误')
+              return
+            }
+            /* 进度更新 */
+            if(i<5) genProgress.value = 'AI正在生成工作流(模型A生成中)...'
+            else if(i<10) genProgress.value = '模型B优化中...'
+            else genProgress.value = '模型C质检评分中...'
+          }
+        }
+        genStatus.value = 'error'
+        genProgress.value = '超时(3分钟),请重试'
+      }catch(e){
+        genStatus.value = 'error'
+        genProgress.value = '请求失败: '+e.message
+        alert('AI生成请求失败: '+e.message)
+      }
+    }
+
+    function importJson(){
+      try{
+        elements.value = parseWorkflowJson(JSON.parse(loadJson.value))
+        showLoad.value = false
+      }catch(e){alert('JSON格式错误: '+e.message)}
+    }
+
+    function parseWorkflowJson(wf){
+      if(typeof wf==='string') wf = JSON.parse(wf)
+      const nodes = (wf.nodes||[]).map((n,i)=>({id:String(i+1),type:typeToCategory[n.nodeType]||'default',position:{x:300,y:50+i*130},data:{label:n.nodeName||n.name||'',nodeCode:n.nodeCode||'',nodeType:n.nodeType||3,nextNodeCode:n.nextNodeCode||'',messageTemplate:n.messageTemplate||'',conditionExpr:n.conditionExpr||'',nodeConfig:typeof n.nodeConfig==='string'?n.nodeConfig:JSON.stringify(n.nodeConfig),maxRounds:n.maxRounds||0}}))
+      const edgeMap = {}
+      nodes.forEach(n=>{if(n.data.nextNodeCode) edgeMap[n.data.nodeCode]=n.data.nextNodeCode})
+      const edges = (wf.edges||[]).map((e,i)=>({id:'e'+i,source:nodes.find(n=>n.data.nodeCode===e.sourceNodeCode)?.id||'',target:nodes.find(n=>n.data.nodeCode===e.targetNodeCode)?.id||'',animated:true}))
+      nodeIdCounter = nodes.length
+      return [...nodes,...edges]
+    }
+
+    return {nodeGroups,allNodes,flowRef,elements,selectedNode,nodeTypes,
+      showGenerate,showLoad,genReq,genIndustry,genExisting,genInstruction,genResult,loadJson,
+      onDragStart,onDrop,onNodeClick,onConnect,onTypeChange,fitView,undo,exportJson,saveWorkflow,aiGenerate,importJson}
+  }
+})
+app.component('vue-flow',VueFlow.default)
+app.component('vue-flow-background',VueFlowBackground.default)
+app.component('vue-flow-minimap',VueFlowMinimap.default)
+app.component('vue-flow-controls',VueFlowControls.default)
+app.mount('#app')
+</script>
+</body>
+</html>

+ 562 - 0
fs-comm-gateway/对接文档.md

@@ -0,0 +1,562 @@
+# fs-comm-gateway 通讯中间件对接文档
+
+## 1. 概述
+
+`fs-comm-gateway` 是 SaaS 平台统一的**外呼 / 短信通讯网关**,对外提供标准化 HTTP API,对内供工作流引擎、业务服务通过 `CommGatewayClient` 调用。
+
+主要能力:
+
+| 能力 | 说明 |
+|------|------|
+| AI 外呼 | 对接 EasyCallCenter365,创建任务、追加名单、启动外呼 |
+| 短信发送 | 基于租户短信模板与余额,批量发送 AI 短信 |
+| 工作流回调 | 接收 EasyCall / 短信平台回调,续跑阻塞节点 |
+| 外呼记录查询 | 按 `callBackUuid` 查询外呼日志 |
+
+默认服务端口:**8010**  
+建议通过 Nginx 反向代理暴露:`/comm/` → `http://127.0.0.1:8010/comm/`
+
+---
+
+## 2. 环境与依赖
+
+网关运行依赖以下基础设施(由运维按环境配置):
+
+| 组件 | 用途 |
+|------|------|
+| MySQL 主库 | 租户信息、公司账号鉴权 |
+| MySQL 租户库 | 业务数据(被叫人、模板、日志等) |
+| Redis | Token 会话、限流、工作流回调上下文 |
+| EasyCallCenter365 | 实际外呼平台,`easycall.base-url` 配置 |
+
+---
+
+## 3. 鉴权方式
+
+网关支持两种调用身份,**二选一**即可访问业务接口(除白名单路径外)。
+
+### 3.1 外部对接:JWT Token(推荐三方系统使用)
+
+#### 3.1.1 获取 Token
+
+```http
+POST /comm/auth/token
+Content-Type: application/json
+```
+
+**请求体:**
+
+```json
+{
+  "tenantCode": "your_tenant_code",
+  "account": "company_user_account",
+  "password": "plain_password"
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| tenantCode | string | 是 | 租户编码 |
+| account | string | 是 | 公司端登录账号 |
+| password | string | 是 | 登录密码(BCrypt 校验) |
+
+**成功响应:**
+
+```json
+{
+  "code": 200,
+  "msg": "success",
+  "data": {
+    "accessToken": "eyJhbGciOiJIUzUxMiJ9...",
+    "expiresIn": 7200,
+    "tokenType": "Bearer",
+    "tenantId": 33,
+    "companyId": 1001,
+    "companyUserId": 2001
+  }
+}
+```
+
+| 字段 | 说明 |
+|------|------|
+| accessToken | JWT,后续请求携带 |
+| expiresIn | 有效秒数(默认 120 分钟,见 `token.expireTime`) |
+| tokenType | 固定 `Bearer` |
+| tenantId / companyId / companyUserId | 当前会话绑定的租户与公司上下文 |
+
+#### 3.1.2 携带 Token 调用业务接口
+
+```http
+Authorization: Bearer {accessToken}
+Content-Type: application/json
+```
+
+#### 3.1.3 刷新 / 注销 Token
+
+```http
+POST /comm/auth/refresh
+Authorization: Bearer {accessToken}
+```
+
+```http
+POST /comm/auth/logout
+Authorization: Bearer {accessToken}
+```
+
+---
+
+### 3.2 内部服务调用:Internal Secret 请求头
+
+平台内部服务(如工作流引擎)通过 `CommGatewayClient` 调用,无需 JWT,使用共享密钥 + 租户/公司头:
+
+| 请求头 | 必填 | 说明 |
+|--------|------|------|
+| X-Comm-Internal-Secret | 是 | 与配置 `comm.gateway.internal-secret` 一致 |
+| X-Comm-Tenant-Id | 是 | 租户 ID |
+| X-Comm-Company-Id | 是 | 公司 ID |
+| X-Comm-Company-User-Id | 否 | 公司用户 ID |
+
+配置项(调用方 `application.yml`):
+
+```yaml
+comm:
+  gateway:
+    base-url: http://127.0.0.1:8010
+    internal-secret: ${COMM_INTERNAL_SECRET}
+    enabled: true
+    fallback-local: false   # 网关失败时是否降级本地直连
+```
+
+---
+
+### 3.3 免鉴权路径
+
+以下路径**不需要** Token 或 Internal Secret:
+
+| 路径 | 说明 |
+|------|------|
+| POST `/comm/auth/token` | 登录换 Token |
+| POST `/comm/callback/easycall` | EasyCall 外呼结果回调(含 IP 白名单校验) |
+| POST `/comm/callback/sms` | 短信回执回调 |
+
+---
+
+## 4. 统一响应格式
+
+业务 Controller 统一返回 `CommApiResult`:
+
+```json
+{
+  "code": 200,
+  "msg": "success",
+  "data": { }
+}
+```
+
+| code | 含义 |
+|------|------|
+| 200 | 成功 |
+| 401 | 未认证(如查询接口未带 Token) |
+| 404 | 资源不存在 |
+| 500 | 业务失败或系统异常 |
+
+**判定规则:**
+
+- HTTP 状态码通常为 **200**,请以响应体 **`code` 字段** 判断业务成败。
+- `code != 200` 时,`msg` 为失败原因,对接方应记录并向上游返回失败。
+
+**常见失败 msg 示例:**
+
+| msg | 场景 |
+|-----|------|
+| 被叫人手机号解密失败或号码无效 | 被叫号码为空或解密失败 |
+| 被叫人命中外呼黑名单 | 黑名单拦截 |
+| 外呼名单追加失败或线路限流 | EasyCall 未追加名单或线路限流 |
+| 成功追加0个名单 | EasyCall 返回 0 条(号码无效等) |
+| 无权使用该外呼线路 | gatewayId 不在公司可用线路内 |
+| 租户请求频率超限,请稍后重试 | 触发 QPS 限流 |
+| 剩余短信数量不足,请充值 | 短信余额不足 |
+| 短信模板不存在或未审核 | 模板无效 |
+
+> 注意:Filter 层未授权时返回 `AjaxResult` 格式(`code: 401`),与 `CommApiResult` 字段结构一致。
+
+---
+
+## 5. 接口明细
+
+### 5.1 发起外呼
+
+```http
+POST /comm/call/send
+Authorization: Bearer {accessToken}
+Content-Type: application/json
+```
+
+**请求体:**
+
+```json
+{
+  "calleeId": 25,
+  "roboticId": 174,
+  "gatewayId": 5,
+  "businessId": null,
+  "nodeKey": "call_node_1",
+  "workflowInstanceId": "wf-instance-uuid",
+  "callbackUrl": "",
+  "phone": null,
+  "llmAccountId": 1,
+  "voiceCode": "xiaoyun",
+  "voiceSource": "ali",
+  "busiGroupId": 10,
+  "maxConcurrency": 1,
+  "bizParams": {}
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| calleeId | long | 是 | 被叫人 ID(`company_voice_robotic_callees.id`) |
+| roboticId | long | 是 | AI 外呼任务 ID |
+| gatewayId | long | 是 | EasyCall 外呼线路 ID |
+| businessId | long | 否 | 商机 ID,传入时校验当日拨打次数上限 |
+| nodeKey | string | 工作流场景必填 | 工作流节点 Key |
+| workflowInstanceId | string | 工作流场景必填 | 工作流实例 ID |
+| callbackUrl | string | 否 | 自定义 EasyCall 回调地址,空则读租户/公司配置 |
+| phone | string | 否 | 指定被叫号码(明文或 AES 密文);为空则从 calleeId 解析 |
+| llmAccountId / voiceCode / voiceSource / busiGroupId / maxConcurrency | - | 否 | AI 外呼扩展参数(工作流节点配置透传) |
+| bizParams | object | 否 | 追加到 EasyCall `bizJson` 的自定义字段 |
+
+**成功响应 data:**
+
+```json
+{
+  "callBackUuid": "4ca54a59-fcdf-4c55-8783-1112dd3405cf",
+  "batchId": 159576,
+  "phone": "13800138000"
+}
+```
+
+| 字段 | 说明 |
+|------|------|
+| callBackUuid | 本次外呼唯一标识,用于查询与回调关联 |
+| batchId | EasyCall 任务批次 ID |
+| phone | 实际外呼号码(明文) |
+
+**处理流程简述:**
+
+1. 校验线路归属、黑名单、号码有效性  
+2. 写入 Redis 工作流回调上下文(`easycall:workflow:callback:{callBackUuid}`)  
+3. 调用 EasyCall `addCallList` + `startTask`  
+4. 异步写入租户库 `company_voice_robotic_call_log_callphone`  
+
+---
+
+### 5.2 发送短信
+
+```http
+POST /comm/sms/send
+Authorization: Bearer {accessToken}
+Content-Type: application/json
+```
+
+**请求体:**
+
+```json
+{
+  "roboticId": 174,
+  "calleeId": 25,
+  "smsTempId": 88,
+  "nodeKey": "sms_node_1",
+  "workflowInstanceId": "wf-instance-uuid",
+  "phone": null,
+  "customerId": null,
+  "companyUserId": null,
+  "senderName": null,
+  "cardUrl": null,
+  "templateParams": {}
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| roboticId | long | 是 | AI 任务 ID |
+| calleeId | long | 是 | 被叫人 ID |
+| smsTempId | long | 是 | 短信模板 ID(须已审核且启用) |
+| nodeKey | string | 工作流场景建议填 | 节点 Key |
+| workflowInstanceId | string | 工作流场景建议填 | 工作流实例 ID |
+| phone | string | 否 | 指定手机号,默认取客户 mobile |
+| customerId | long | 否 | 客户 ID,默认取 callee 关联 userId |
+| companyUserId / senderName | - | 否 | 发送销售信息,空则自动从微信绑定关系解析 |
+| cardUrl | string | 否 | 卡片链接 |
+| templateParams | map | 否 | 模板变量(预留) |
+
+**成功响应 data:**
+
+```json
+{
+  "callbackUuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+  "customerId": 10001,
+  "phone": "13800138000"
+}
+```
+
+发送结果异步写入租户库 `company_voice_robotic_call_log_sendmsg`(status:1 进行中 / 2 成功 / 3 失败)。
+
+---
+
+### 5.3 查询外呼记录
+
+```http
+GET /comm/query/call/{callBackUuid}
+Authorization: Bearer {accessToken}
+```
+
+**成功响应 data:** 外呼日志对象(`company_voice_robotic_call_log_callphone` 表字段 JSON 化),含 `status`、`result`、`callTime`、`intention` 等。
+
+**失败示例:**
+
+```json
+{
+  "code": 404,
+  "msg": "未找到外呼记录",
+  "data": null
+}
+```
+
+---
+
+### 5.4 平台回调接口(运维 / EasyCall 配置)
+
+#### EasyCall 外呼回调
+
+```http
+POST /comm/callback/easycall
+Content-Type: application/json
+```
+
+- 请求体:EasyCall CDR JSON 字符串(原样 POST)
+- 响应:成功返回 `"success"`,非法 IP 返回 `"illegal IP"`
+- 安全:`@CallbackIpCheck` 校验来源 IP 是否在租户 `cId.config.legalIPs` 白名单内
+- 处理:解析 `bizJson.tenantId` 切换租户库,更新外呼日志并续跑工作流阻塞节点
+
+**EasyCall 侧需配置的回调地址示例:**
+
+```
+https://{your-domain}/comm/callback/easycall
+```
+
+`bizJson` 中需包含(网关外呼时自动写入):
+
+```json
+{
+  "tenantId": 33,
+  "callBackUuid": "4ca54a59-fcdf-4c55-8783-1112dd3405cf",
+  "callBackUrl": "",
+  "custName": "张三"
+}
+```
+
+#### 短信回执回调
+
+```http
+POST /comm/callback/sms
+Content-Type: application/json
+```
+
+- 请求体:短信平台回执 JSON,需包含 `tenantId` 字段以便切库
+- 响应:由 `ISmsService.smsNotify` 返回(通常为 `"success"` 或平台约定字符串)
+
+---
+
+## 6. 对接时序
+
+### 6.1 外呼 + 工作流续跑
+
+```mermaid
+sequenceDiagram
+    participant Client as 调用方
+    participant GW as fs-comm-gateway
+    participant EC as EasyCall
+    participant WF as 工作流引擎
+
+    Client->>GW: POST /comm/call/send
+    GW->>GW: 校验鉴权/线路/号码/黑名单
+    GW->>EC: addCallList + startTask
+    GW-->>Client: callBackUuid, batchId, phone
+
+    EC->>GW: POST /comm/callback/easycall
+    GW->>GW: 切租户库、更新 call_log
+    GW->>WF: resumeFromBlockingNode
+```
+
+### 6.2 三方系统最小对接步骤
+
+1. 调用 `/comm/auth/token` 获取 `accessToken`  
+2. 准备业务数据:`roboticId`、`calleeId`、`gatewayId`(或 `smsTempId`)  
+3. 调用 `/comm/call/send` 或 `/comm/sms/send`  
+4. **必须检查响应 `code === 200`**,并保存 `callBackUuid` / `callbackUuid`  
+5. 轮询 `GET /comm/query/call/{callBackUuid}` 或等待 EasyCall 回调触发后续流程  
+
+---
+
+## 7. 限流与线路鉴权
+
+### 7.1 租户 QPS 限流
+
+- 配置项:`comm.gateway.tenant-qps-limit`(默认 200)
+- 外呼、短信发送前均校验
+- 超限返回:`租户请求频率超限,请稍后重试`
+
+### 7.2 外呼线路鉴权
+
+`gatewayId` 必须属于当前公司可用线路,校验顺序:
+
+1. EasyCall `getGatewayList(companyId)` 返回列表  
+2. 否则读公司 `gateWayList` 配置  
+3. 否则读全局 `cId.config.showGatewayIds`  
+
+---
+
+## 8. 配置参考
+
+`application.yml` 核心项(生产环境请通过环境变量或配置中心注入,**勿提交明文密钥**):
+
+```yaml
+server:
+  port: 8010
+
+token:
+  header: Authorization
+  secret: ${COMM_TOKEN_SECRET}
+  expireTime: 120          # Token 有效分钟数
+
+comm:
+  gateway:
+    internal-secret: ${COMM_INTERNAL_SECRET}
+    tenant-qps-limit: 200
+    executor:
+      core-pool-size: 20
+      max-pool-size: 100
+      queue-capacity: 2000
+
+easycall:
+  base-url: http://{easycall-host}:{port}
+```
+
+---
+
+## 9. 调用示例
+
+### 9.1 cURL:登录 + 外呼
+
+```bash
+# 1. 获取 Token
+TOKEN_RESP=$(curl -s -X POST "http://127.0.0.1:8010/comm/auth/token" \
+  -H "Content-Type: application/json" \
+  -d '{"tenantCode":"demo","account":"admin","password":"your_password"}')
+
+ACCESS_TOKEN=$(echo $TOKEN_RESP | jq -r '.data.accessToken')
+
+# 2. 发起外呼
+curl -s -X POST "http://127.0.0.1:8010/comm/call/send" \
+  -H "Authorization: Bearer $ACCESS_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "calleeId": 25,
+    "roboticId": 174,
+    "gatewayId": 5,
+    "nodeKey": "node_call_1",
+    "workflowInstanceId": "your-workflow-instance-id"
+  }'
+```
+
+### 9.2 cURL:内部服务调用
+
+```bash
+curl -s -X POST "http://127.0.0.1:8010/comm/call/send" \
+  -H "Content-Type: application/json" \
+  -H "X-Comm-Internal-Secret: your_internal_secret" \
+  -H "X-Comm-Tenant-Id: 33" \
+  -H "X-Comm-Company-Id: 1001" \
+  -d '{
+    "calleeId": 25,
+    "roboticId": 174,
+    "gatewayId": 5,
+    "nodeKey": "node_call_1",
+    "workflowInstanceId": "your-workflow-instance-id"
+  }'
+```
+
+### 9.3 Java(内部 CommGatewayClient)
+
+```java
+Map<String, Object> body = new HashMap<>();
+body.put("calleeId", 25L);
+body.put("roboticId", 174L);
+body.put("gatewayId", 5L);
+body.put("nodeKey", "node_call_1");
+body.put("workflowInstanceId", workflowInstanceId);
+
+JSONObject result = commGatewayClient.sendCall(tenantId, companyId, companyUserId, body);
+String callBackUuid = result.getString("callBackUuid");
+String phone = result.getString("phone");
+```
+
+---
+
+## 10. 部署说明
+
+### 10.1 构建
+
+```bash
+cd ylrz_saas_his_scrm
+mvn clean package -pl fs-comm-gateway -am -DskipTests
+```
+
+### 10.2 Docker
+
+```bash
+docker build -t fs-comm-gateway:latest fs-comm-gateway/
+docker run -d -p 8010:8010 \
+  -e SPRING_PROFILES_ACTIVE=prod \
+  fs-comm-gateway:latest
+```
+
+### 10.3 Nginx 反代
+
+参考模块内 `nginx.conf`:
+
+```nginx
+location /comm/ {
+    proxy_pass http://127.0.0.1:8010/comm/;
+    proxy_set_header Host $host;
+    proxy_set_header X-Real-IP $remote_addr;
+    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+    proxy_read_timeout 120s;
+}
+```
+
+---
+
+## 11. 附录:相关数据表
+
+| 表名 | 说明 |
+|------|------|
+| company_voice_robotic_call_log_callphone | AI 外呼执行日志 |
+| company_voice_robotic_call_log_sendmsg | 短信发送日志 |
+| company_voice_robotic_call_log_addwx | 加微执行日志(其他模块写入) |
+
+日志写入均路由至**当前租户库**,非主库。
+
+---
+
+## 12. 版本与联系
+
+| 项 | 值 |
+|----|-----|
+| 模块 | fs-comm-gateway |
+| 默认端口 | 8010 |
+| API 前缀 | `/comm` |
+| 文档更新 | 2026-06-03 |
+
+如有对接问题,请提供:`tenantId`、`callBackUuid`、请求体、完整响应 JSON 及服务端日志时间点,便于排查。

+ 4 - 4
fs-company/src/main/java/com/fs/company/controller/aicall/CcLlmAgentAccountController.java

@@ -152,7 +152,7 @@ public class CcLlmAgentAccountController extends BaseController
      * 新增保存机器人参数配置
      */
     @PreAuthorize("@ss.hasPermi('aicall:account:add')")
-    @Log(title = "机器人参数配置", businessType = BusinessType.INSERT)
+    @Log(title = "配置模型新增", businessType = BusinessType.INSERT)
     @PostMapping("/add")
     @ResponseBody
     public AjaxResult addSave(@RequestBody CcLlmAgentAccount ccLlmAgentAccount, HttpServletRequest request)
@@ -181,7 +181,7 @@ public class CcLlmAgentAccountController extends BaseController
                 }
             }
         }
-        
+
         // 新增模型
         int result = ccLlmAgentAccountService.insertCcLlmAgentAccount(ccLlmAgentAccount);
 
@@ -199,7 +199,7 @@ public class CcLlmAgentAccountController extends BaseController
         if (result > 0 && companyId != null) {
             companyBindAiModelService.bindCompanyToModel(ccLlmAgentAccount.getId().longValue(), companyId);
         }
-        
+
         return toAjax(result);
     }
 
@@ -256,7 +256,7 @@ public class CcLlmAgentAccountController extends BaseController
      * 修改保存机器人参数配置
      */
     @PreAuthorize("@ss.hasPermi('aicall:account:edit')")
-    @Log(title = "机器人参数配置", businessType = BusinessType.UPDATE)
+    @Log(title = "配置模型修改", businessType = BusinessType.UPDATE)
     @PostMapping("/edit")
     @ResponseBody
     public AjaxResult editSave(@RequestBody CcLlmAgentAccount ccLlmAgentAccount)

+ 121 - 0
fs-company/src/main/java/com/fs/company/controller/company/CompanyAiWorkflowServerController.java

@@ -0,0 +1,121 @@
+package com.fs.company.controller.company;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.company.domain.CompanyAiWorkflowServer;
+import com.fs.company.param.BindCidServerParam;
+import com.fs.company.service.ICompanyAiWorkflowServerService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * cid服务Controller
+ * 
+ * @author fs
+ * @date 2026-02-26
+ */
+@RestController
+@RequestMapping("/company/cid/server")
+public class CompanyAiWorkflowServerController extends BaseController
+{
+    @Autowired
+    private ICompanyAiWorkflowServerService companyAiWorkflowServerService;
+
+    /**
+     * 查询cid服务列表
+     */
+    @PreAuthorize("@ss.hasPermi('company:server:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(CompanyAiWorkflowServer companyAiWorkflowServer)
+    {
+        startPage();
+        List<CompanyAiWorkflowServer> list = companyAiWorkflowServerService.selectCompanyAiWorkflowServerList(companyAiWorkflowServer);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出cid服务列表
+     */
+    @PreAuthorize("@ss.hasPermi('company:server:export')")
+    @Log(title = "cid服务", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(CompanyAiWorkflowServer companyAiWorkflowServer)
+    {
+        List<CompanyAiWorkflowServer> list = companyAiWorkflowServerService.selectCompanyAiWorkflowServerList(companyAiWorkflowServer);
+        ExcelUtil<CompanyAiWorkflowServer> util = new ExcelUtil<CompanyAiWorkflowServer>(CompanyAiWorkflowServer.class);
+        return util.exportExcel(list, "cid服务数据");
+    }
+
+    /**
+     * 获取cid服务详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('company:server:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(companyAiWorkflowServerService.selectCompanyAiWorkflowServerById(id));
+    }
+
+    /**
+     * 新增cid服务
+     */
+    @PreAuthorize("@ss.hasPermi('company:server:add')")
+    @Log(title = "cid服务", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody CompanyAiWorkflowServer companyAiWorkflowServer)
+    {
+        return toAjax(companyAiWorkflowServerService.insertCompanyAiWorkflowServer(companyAiWorkflowServer));
+    }
+
+    /**
+     * 修改cid服务
+     */
+    @PreAuthorize("@ss.hasPermi('company:server:edit')")
+    @Log(title = "cid服务", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody CompanyAiWorkflowServer companyAiWorkflowServer)
+    {
+        return toAjax(companyAiWorkflowServerService.updateCompanyAiWorkflowServer(companyAiWorkflowServer));
+    }
+
+    /**
+     * 删除cid服务
+     */
+    @PreAuthorize("@ss.hasPermi('company:server:remove')")
+    @Log(title = "cid服务", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(companyAiWorkflowServerService.deleteCompanyAiWorkflowServerByIds(ids));
+    }
+
+    /**
+     * 绑定cid服务
+     * @param param
+     * @return
+     */
+    @PostMapping("/bindCidServer")
+    public R bindCidServer(@RequestBody BindCidServerParam param){
+        return companyAiWorkflowServerService.bindCidServer(param);
+    }
+
+    /**
+     * 解绑cid服务
+     * @param param
+     * @return
+     */
+    @PostMapping("/unbindCidServer")
+    public R unbindCidServer(@RequestBody BindCidServerParam param){
+        return companyAiWorkflowServerService.unbindCidServer(param);
+    }
+
+
+}

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

@@ -87,7 +87,7 @@ public class CompanyVoiceApiController extends BaseController {
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         Long companyId = loginUser.getCompany() != null ? loginUser.getCompany().getCompanyId() : null;
         if (companyId == null) { return AjaxResult.error("请选择租户"); }
-        List<CompanyVoiceApiTenant> list = companyVoiceApiTenantService.selectEnabledApisByCompanyId(companyId);
+        List<CompanyVoiceApiTenant> list = companyVoiceApiTenantService.selectEnabledApisByTenantId(companyId);
         return AjaxResult.success(list);
     }
 

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

@@ -10,6 +10,7 @@ import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.common.utils.SecurityUtils;
 import com.fs.company.domain.CompanyVoiceRoboticCallLogCallphone;
 import com.fs.company.service.ICompanyVoiceRoboticCallLogCallphoneService;
+import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO;
 import com.fs.crm.domain.CrmCustomer;
 import com.fs.crm.service.ICrmCustomerService;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneVO;
@@ -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,35 @@ public class CompanyVoiceRoboticCallLogCallphoneController extends BaseControlle
         return util.exportExcel(list, "调用日志_ai打电话数据");
     }
 
+
+    /**
+     * 导出详情外呼记录(任务名称、客户名称、解密手机号)
+     */
+    @PreAuthorize("@ss.hasPermi('company:callphonelog:exportPhone')")
+    @Log(title = "外呼记录详情手机号导出", businessType = BusinessType.EXPORT)
+    @GetMapping("/exportDetailPhone")
+    public AjaxResult exportDetailPhone(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone)
+    {
+        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);
+        AjaxResult success = AjaxResult.success();
+        success.put("mobile", mobile);
+        return success;
+    }
 //    /**
 //     * 导出调用日志_ai打电话列表
 //     */

+ 9 - 0
fs-company/src/main/java/com/fs/company/controller/companyWorkflow/CompanyWorkflowTagTemplateBindingController.java

@@ -125,4 +125,13 @@ public class CompanyWorkflowTagTemplateBindingController extends BaseController
                 loginUser.getCompany().getCompanyId(), loginUser.getUsername(),
                 param.getQwCorpId(), param.getUserIds(), param.getTagCodes(),loginUser.getUser().getUserId());
     }
+
+    /**
+     * 企微客户获取龙虾标签
+     */
+    @PostMapping("/lobsterTags")
+    public AjaxResult lobsterTags(@RequestBody List<Long> userIds) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        return tagTemplateBindingService.lobsterTags(userIds,loginUser.getUser().getUserId(),loginUser.getCompany().getCompanyId());
+    }
 }

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

+ 97 - 0
fs-company/src/main/java/com/fs/fastGpt/FastGptChatReplaceWordsController.java

@@ -0,0 +1,97 @@
+package com.fs.fastGpt;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.fastGpt.domain.FastGptChatReplaceWords;
+import com.fs.fastGpt.service.IFastGptChatReplaceWordsService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 违规词语Controller
+ *
+ * @author fs
+ * @date 2025-01-18
+ */
+@RestController
+@RequestMapping("/fastGpt/fastGptChatReplaceWords")
+public class FastGptChatReplaceWordsController extends BaseController
+{
+    @Autowired
+    private IFastGptChatReplaceWordsService fastGptChatReplaceWordsService;
+
+    /**
+     * 查询违规词语列表
+     */
+    @PreAuthorize("@ss.hasPermi('fastGpt:fastGptChatReplaceWords:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(FastGptChatReplaceWords fastGptChatReplaceWords)
+    {
+        startPage();
+        List<FastGptChatReplaceWords> list = fastGptChatReplaceWordsService.selectFastGptChatReplaceWordsList(fastGptChatReplaceWords);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出违规词语列表
+     */
+    @PreAuthorize("@ss.hasPermi('fastGpt:fastGptChatReplaceWords:export')")
+    @Log(title = "违规词语", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(FastGptChatReplaceWords fastGptChatReplaceWords)
+    {
+        List<FastGptChatReplaceWords> list = fastGptChatReplaceWordsService.selectFastGptChatReplaceWordsList(fastGptChatReplaceWords);
+        ExcelUtil<FastGptChatReplaceWords> util = new ExcelUtil<FastGptChatReplaceWords>(FastGptChatReplaceWords.class);
+        return util.exportExcel(list, "违规词语数据");
+    }
+
+//    /**
+//     * 获取违规词语详细信息
+//     */
+//    @PreAuthorize("@ss.hasPermi('fastGpt:fastGptChatReplaceWords:query')")
+//    @GetMapping(value = "/{id}")
+//    public AjaxResult getInfo(@PathVariable("id") Long id)
+//    {
+//        return AjaxResult.success(fastGptChatReplaceWordsService.selectFastGptChatReplaceWordsById(id));
+//    }
+
+//    /**
+//     * 新增违规词语
+//     */
+//    @PreAuthorize("@ss.hasPermi('fastGpt:fastGptChatReplaceWords:add')")
+//    @Log(title = "违规词语", businessType = BusinessType.INSERT)
+//    @PostMapping
+//    public AjaxResult add(@RequestBody FastGptChatReplaceWords fastGptChatReplaceWords)
+//    {
+//        return toAjax(fastGptChatReplaceWordsService.insertFastGptChatReplaceWords(fastGptChatReplaceWords));
+//    }
+//
+//    /**
+//     * 修改违规词语
+//     */
+//    @PreAuthorize("@ss.hasPermi('fastGpt:fastGptChatReplaceWords:edit')")
+//    @Log(title = "违规词语", businessType = BusinessType.UPDATE)
+//    @PutMapping
+//    public AjaxResult edit(@RequestBody FastGptChatReplaceWords fastGptChatReplaceWords)
+//    {
+//        return toAjax(fastGptChatReplaceWordsService.updateFastGptChatReplaceWords(fastGptChatReplaceWords));
+//    }
+//
+    /**
+     * 删除违规词语
+     */
+    @PreAuthorize("@ss.hasPermi('fastGpt:fastGptChatReplaceWords:remove')")
+    @Log(title = "违规词语", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(fastGptChatReplaceWordsService.deleteFastGptChatReplaceWordsByIds(ids));
+    }
+}

+ 155 - 0
fs-company/src/main/resources/application-common.yml

@@ -0,0 +1,155 @@
+# 项目相关配置
+fs:
+  # 名称
+  name: fs
+  # 版本
+  version: 1.1.0
+  # 版权年份
+  copyrightYear: 2021
+  # 实例演示开关
+  demoEnabled: true
+  # 文件路径 示例( Windows配置D:/fs/uploadPath,Linux配置 /home/fs/uploadPath)
+  profile: c:/fs/uploadPath
+  # 获取ip地址开关
+  addressEnabled: false
+  # 验证码类型 math 数组计算 char 字符验证
+  captchaType: math
+#  jwt:
+#    # 加密秘钥
+#    secret: f4e2e52034348f86b67cde581c0f9eb5
+#    # token有效时长,7天,单位秒
+#    expire: 31536000
+#    header: AppToken
+# 开发环境配置
+server:
+  servlet:
+    # 应用的访问路径
+    context-path: /
+  tomcat:
+    # tomcat的URI编码
+    uri-encoding: UTF-8
+    # tomcat最大线程数,默认为200
+    max-threads: 800
+    # Tomcat启动初始化的线程数,默认值25
+    min-spare-threads: 30
+
+# 日志配置
+logging:
+  level:
+    com.fs: info
+    org.springframework: warn
+
+express:
+  omsCode: "SF.0235402855"
+# Spring配置
+spring:
+  main:
+    allow-circular-references: true
+  cache:
+    type: redis
+  # 资源信息
+  messages:
+    # 国际化资源文件路径
+    basename: i18n/messages
+  mvc:
+    async:
+      request-timeout: 600000
+
+  # 文件上传
+  servlet:
+     multipart:
+       # 单个文件大小
+       max-file-size:  3GB
+       # 设置总上传的文件大小
+       max-request-size:  3GB
+  # 服务模块
+  devtools:
+    restart:
+      # 热部署开关
+      enabled: true
+
+
+# token配置
+token:
+    # 令牌自定义标识
+    header: Authorization
+    # 令牌密钥
+    secret: YlrzSaas2026SecKey!@#QwErTyUiOpAsDfGhJkLzXcVbNm
+    # 令牌有效期(默认30分钟)
+    expireTime: 720
+mybatis-plus:
+  # 搜索指定包别名
+  typeAliasesPackage: com.fs.**.domain,com.fs.**.bo
+  # 配置mapper的扫描,找到所有的mapper.xml映射文件
+  mapperLocations: classpath*:/mapper/**/*.xml
+  configLocation: classpath:mybatis/mybatis-config.xml
+  # 全局配置
+  global-config:
+    db-config:
+      # 主键类型  0:"数据库ID自增", 1:"用户输入ID",2:"全局唯一ID (数字类型唯一ID)", 3:"全局唯一ID UUID";
+      idType: AUTO
+      # 字段策略 0:"忽略判断",1:"非 NULL 判断"),2:"非空判断"
+      fieldStrategy: NOT_EMPTY
+    banner: false
+    # 配置
+  configuration:
+    # 驼峰式命名
+    mapUnderscoreToCamelCase: true
+    # 全局映射器启用缓存
+    cacheEnabled: true
+    # 配置默认的执行器
+    defaultExecutorType: REUSE
+    # 允许 JDBC 支持自动生成主键
+    useGeneratedKeys: true
+
+# MyBatis配置
+mybatis:
+    # 搜索指定包别名
+    typeAliasesPackage: com.fs.**.domain
+    # 配置mapper的扫描,找到所有的mapper.xml映射文件
+    mapperLocations: classpath*:mapper/**/*Mapper.xml
+    # 加载全局的配置文件
+    configLocation: classpath:mybatis/mybatis-config.xml
+
+# PageHelper分页插件
+pagehelper:
+  helperDialect: mysql
+  reasonable: false #超出后不显示
+  supportMethodsArguments: false
+  params: count=countSql
+
+# Swagger配置
+swagger:
+  # 是否开启swagger
+  enabled: false
+  # 请求前缀
+  pathMapping: /dev-api
+
+# 防止XSS攻击
+xss:
+  # 过滤开关
+  enabled: true
+  # 排除链接(多个用逗号分隔)
+  excludes: /system/notice,/system/config/*
+  # 匹配链接
+  urlPatterns: /system/*,/monitor/*,/tool/*
+zhyf:
+  url: https://zhyf-testController.jingpai.com
+
+image:
+  storage:
+    local-path: C:\logoFile\logo.jpg
+    server-path: C:\logoFile\logo.jpg
+# application.properties
+wechat:
+  api:
+    base-url: https://api.weixin.qq.com
+    upload-shipping-info: /wxa/sec/order/upload_shipping_info
+hsy:
+  access_key: AKLTZTc4YTE4ZjI2OWViNDNjZGI2NjhiYTI5Njc5ZjA1Mzk
+  secret_key: WXpjelpUYzFOakF5TUdObE5EZGtNR0ZsWXpKaU1tTmtZakk1WXpObE4yRQ==
+  region: cn-north-1
+  role_access_key: AKLTNmMwNjJkNDFhYTVjNDIzYzhhNzEyZmZmZTlmYzBhNGM
+  role_secret_key: T0RaaFl6UmhZV1V4WXpKbU5EWTBNMkZpT0RNNU9UY3daak0wTjJFd09XUQ==
+  role_trn: trn:iam::2114522511:role/hylj
+

+ 137 - 0
fs-company/src/main/resources/application-config-dev.yml

@@ -0,0 +1,137 @@
+baidu:
+  token: 12313231232
+  back-domain: https://www.xxxx.com
+#配置
+logging:
+  level:
+    org.springframework.web: debug
+    com.github.binarywang.demo.wx.cp: DEBUG
+    me.chanjar.weixin: DEBUG
+#wx:
+#  miniapp:
+#    configs:
+#      - appid: wx29d26f63f836be7f
+#        secret: 7542db9774355a89b1adce24defb6013
+#        token: Ncbnd7lJvkripVOpyTFAna6NAWCxCrvC
+#        aesKey: HlEiBB55eaWUaeBVAQO3cWKWPYv1vOVQSq7nFNICw4E
+#        msgDataFormat: JSON
+#  cp:
+#    corpId: wwb2a1055fb6c9a7c2
+#    appConfigs:
+#      - agentId: 1000005
+#        secret: ec7okROXJqkNafq66-L6aKNv0asTzQIG0CYrj3vyBbo
+#        token: PPKOdAlCoMO
+#        aesKey: PKvaxtpSv8NGpfTDm7VUHIK8Wok2ESyYX24qpXJAdMP
+#  pay:
+#    appId: wx73f85f8d62769119 #微信公众号或者小程序等的appid
+#    mchId: 1611402045 #微信支付商户号
+#    mchKey: 8cab128997a3547c1363b0898b877f38 #微信支付商户密钥
+#    subAppId:  #服务商模式下的子商户公众账号ID
+#    subMchId:  #服务商模式下的子商户号
+#    keyPath: c:\\cert\\apiclient_cert.p12 # p12证书的位置,可以指定绝对路径,也可以指定类路径(以classpath:开头)
+#    notifyUrl: https://userapp.his.runtzh.com/app/wxpay/wxPayNotify
+#  mp:
+#    useRedis: false
+#    redisConfig:
+#      host: 127.0.0.1
+#      port: 6379
+#      timeout: 2000
+#    configs:
+#      - appId: wx93ce67750e3cfba3 # 第一个公众号的appid  //公众号名称:云联融智
+#        secret: c172884087264160563bfe5775ca0f6f # 公众号的appsecret
+#        token: PPKOdAlCoMO # 接口配置里的Token值
+#        aesKey: Eswa6VjwtVMCcw03qZy6fWllgrv5aytIA1SZPEU0kU2 # 接口配置里的EncodingAESKey值
+#aifabu:  #爱链接
+#  appKey: 7b471be905ab17e00f3b858c6710dd117601d008
+#watch:
+#  watchUrl: watch.ylrzcloud.com/prod-api
+#  #  account: tcloud
+#  #  password: mdf-m2h_6yw2$hq
+#  account1: ccif #866655060138751
+#  password1: cp-t5or_6xw7$mt
+#  account2: tcloud #rt500台
+#  password2: mdf-m2h_6yw2$hq
+#  account3: whr
+#  password3: v9xsKuqn_$d2y
+#
+#fs :
+#  commonApi: http://172.16.0.16:8010
+#  h5CommonApi: http://119.29.195.254:8010
+#  jwt:
+#    # 加密秘钥
+#    secret: e10adc3949ba59abbe56e057f20f883e
+#    # token有效时长,7天,单位秒
+#    expire: 31536000
+#    header: AppToken
+#nuonuo:
+#  key: 10924508
+#  secret: A2EB20764D304D16
+#
+## 存储捅配置
+#tencent_cloud_config:
+#  secret_id: AKIDiMq9lDf2EOM9lIfqqfKo7FNgM5meD0sT
+#  secret_key: u5SuS80342xzx8FRBukza9lVNHKNMSaB
+#  bucket: myhk-1323137866
+#  app_id: 1323137866
+#  region: ap-chongqing
+#  proxy: myhk
+#cloud_host:
+#  company_name: 金康健
+#  projectCode: DEV
+#  spaceName:
+#  volcengineUrl:
+#headerImg:
+#  imgUrl: https://jz-cos-1356808054.cos.ap-chengdu.myqcloud.com/fs/20250515/0877754b59814ea8a428fa3697b20e68.png
+#ipad:
+#  url:
+#  ipadUrl: http://ipad.cdwjyyh.com
+#  aiApi: http://152.136.202.157:3000/api
+#  voiceApi:
+#  commonApi:
+#wx_miniapp_temp:
+#  pay_order_temp_id:
+#  inquiry_temp_id:
+## 聚水潭API配置
+#jst:
+##  app_key: a4b1fab173c84f67b3873857eea11d90 #聚水潭2025-07-25
+#  app_key: 871348458a964548a72bf8124cf917a4 #聚水潭2025-08-14
+#  app_secret: 5b7d9369dbcd414db45089bc047ebe1a #聚水潭2025-08-14
+##  app_secret: dfce1f8dc8a64ddc91212fc3fcdd9349 #聚水潭2025-07-25
+#  authorization_code: 666666
+#  shop_code: "18461733"
+#
+## RocketMQ配置
+#rocketmq:
+#  name-server: 127.0.0.1:9876
+#  producer:
+#    group: event-feedback-producer
+#    send-message-timeout: 3000
+#    retry-times-when-send-failed: 2
+#    retry-times-when-send-async-failed: 2
+#    max-message-size: 4194304
+#    compress-message-body-threshold: 4096
+#    retry-next-server: true
+#custom:
+#  token: "1o62d3YxvdHd4LEUiltnu7sK"
+#  encoding-aes-key: "UJfTQ5qKTKlegjkXtp1YuzJzxeHlUKvq5GyFbERN1iU"
+#  corp-id: "ww51717e2b71d5e2d3"configValue
+#  secret: "6ODAmw-8W4t6h9mdzHh2Z4Apwj8mnsyRnjEDZOHdA7k"
+#  private-key-path: "privatekey.pem"
+#  webhook-url: "https://your-server.com/wecom/archive"
+## token配置
+#token:
+#  # 令牌自定义标识
+#  header: Authorization
+#  # 令牌密钥
+#  secret: abcdefghijklmnopqrstuvwxyz
+#  # 令牌有效期(默认30分钟)
+#  expireTime: 180
+#openIM:
+#  secret: openIM123
+#  userID: imAdmin
+#  url: https://web.jnmyim.ylrzfs.com/api
+##是否为新商户,新商户不走mpOpenId
+#isNewWxMerchant: true
+##是否使用新im
+#im:
+#  type: OPENIM

+ 74 - 62
fs-company/src/main/resources/application-dev.yml

@@ -1,19 +1,27 @@
+# 数据源配置
 spring:
-    devtools:
-        restart:
-            enabled: false
+#    devtools:
+#        restart:
+#            enabled: false
     redis:
-        #host: localhost
-        host: 172.27.0.7
+        host: localhost
+#        host: 172.27.0.7
         port: 6379
-        password:
+        # 数据库索引
         database: 0
+        # 密码
+        password:
+        # 连接超时时间
         timeout: 20s
         lettuce:
             pool:
+                # 连接池中的最小空闲连接
                 min-idle: 0
+                # 连接池中的最大空闲连接
                 max-idle: 8
+                # 连接池的最大数据库连接数
                 max-active: 8
+                # #连接池最大阻塞等待时间(使用负值表示没有限制)
                 max-wait: -1ms
     datasource:
         mysql:
@@ -31,37 +39,26 @@ spring:
                 testWhileIdle: true
                 testOnBorrow: false
                 testOnReturn: false
-                webStatFilter:
-                    enabled: true
-                statViewServlet:
-                    enabled: true
-                    allow:
-                    url-pattern: /druid/*
-                    login-username: fs
-                    login-password: 123456
-                filter:
-                    stat:
-                        enabled: true
-                        log-slow-sql: true
-                        slow-sql-millis: 1000
-                        merge-sql: true
-                    wall:
-                        config:
-                            multi-statement-allow: true
+                # 主库数据源
                 master:
                     url: jdbc:mysql://cq-cdb-8fjmemkb.sql.tencentcdb.com:27220/ylrz_saas?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false
                     username: root
                     password: Ylrz_1q2w3e4r5t6y
-#                    url: jdbc:mysql://139.186.77.83:3306/yLrz_saas_his_scrm?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&allowMultiQueries=true
-#                    username: Rtroot
-#                    password: Rtroot
+                    # 初始连接数
                     initialSize: 5
+                    # 最小连接池数量
                     minIdle: 10
+                    # 最大连接池数量
                     maxActive: 20
+                    # 配置获取连接等待超时的时间
                     maxWait: 60000
+                    # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
                     timeBetweenEvictionRunsMillis: 60000
+                    # 配置一个连接在池中最小生存的时间,单位是毫秒
                     minEvictableIdleTimeMillis: 300000
+                    # 配置一个连接在池中最大生存的时间,单位是毫秒
                     maxEvictableIdleTimeMillis: 900000
+                    # 配置检测连接是否有效
                     validationQuery: SELECT 1 FROM DUAL
                     testWhileIdle: true
                     testOnBorrow: false
@@ -70,52 +67,67 @@ spring:
                         enabled: true
                     statViewServlet:
                         enabled: true
+                        # 设置白名单,不填则允许所有访问
                         allow:
                         url-pattern: /druid/*
+                        # 控制台管理用户名和密码
                         login-username: fs
                         login-password: 123456
                     filter:
                         stat:
                             enabled: true
+                            # 慢SQL记录
                             log-slow-sql: true
                             slow-sql-millis: 1000
                             merge-sql: true
                         wall:
                             config:
                                 multi-statement-allow: true
-
-ai:
-  vector:
-    enabled: true
-    milvus-uri: ./milvus_data/milvus.db
-  embedding:
-    provider: auto
-    endpoint: https://ark.cn-beijing.volces.com/api/v3/embeddings
-    api-key: ""
-    ollama-url: http://localhost:11434
-    ollama-model: bge-m3
-    dimension: 1024
-  multi-model:
-    enabled: true
-    default: deepseek
-  doubao:
-    api-key: "ark-5c19ef54-158a-4e9d-ba90-be9c745a8ff6-5cc00"
-    endpoint: https://ark.cn-beijing.volces.com/api/v3/chat/completions
-    model: doubao-seed-2-0-mini-260428
-  deepseek:
-    api-key: "sk-a44ba22ffa8c4d3d8f593a1503058700"
-    endpoint: https://api.deepseek.com/chat/completions
-    model: deepseek-v4-pro
-  qwen:
-    api-key: ""
-    endpoint: https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions
-    model: qwen-plus
-  yuanbao:
-    api-key: ""
-    endpoint: https://api.hunyuan.cloud.tencent.com/v1/chat/completions
-    model: hunyuan-lite
-# token配置 - 必须与fs-admin一致才能共享JWT token
-token:
-    header: Authorization
-    secret: YlrzSaas2026SecKey!@#QwErTyUiOpAsDfGhJkLzXcVbNm
-    expireTime: 720
+        easycall:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                # 主库数据源
+                master:
+                    url: jdbc:mysql://129.28.164.235:3306/easycallcenter365?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                    username: root
+                    password: easycallcenter365
+                # 初始连接数
+                initialSize: 5
+                # 最小连接池数量
+                minIdle: 10
+                # 最大连接池数量
+                maxActive: 20
+                # 配置获取连接等待超时的时间
+                maxWait: 60000
+                # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                timeBetweenEvictionRunsMillis: 60000
+                # 配置一个连接在池中最小生存的时间,单位是毫秒
+                minEvictableIdleTimeMillis: 300000
+                # 配置一个连接在池中最大生存的时间,单位是毫秒
+                maxEvictableIdleTimeMillis: 900000
+                # 配置检测连接是否有效
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                webStatFilter:
+                    enabled: true
+                statViewServlet:
+                    enabled: true
+                    # 设置白名单,不填则允许所有访问
+                    allow:
+                    url-pattern: /druid/*
+                    # 控制台管理用户名和密码
+                    login-username: fs
+                    login-password: 123456
+                filter:
+                    stat:
+                        enabled: true
+                        # 慢SQL记录
+                        log-slow-sql: true
+                        slow-sql-millis: 1000
+                        merge-sql: true
+                    wall:
+                        config:
+                            multi-statement-allow: true

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

@@ -102,6 +102,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                 .antMatchers("/proxy/login").anonymous()
                 .antMatchers("/app/common/test").anonymous()
                 .antMatchers("/ad/adDyApi/authorized").anonymous()
+                .antMatchers("/admin/aiModel/public/**").anonymous()
                 .antMatchers(
                         HttpMethod.GET,
                         "/",
@@ -149,6 +150,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                 .antMatchers("/course/userVideo/videoTranscode").anonymous()
                 .antMatchers("/system/config/getConfigByKey/his.adminUi.config").permitAll()
                 .antMatchers("/erp/call/**").anonymous()
+                .antMatchers("/app/common/**").anonymous()
                 // 除上面外的所有请求全部需要鉴权认证
                 .anyRequest().authenticated()
                 .and()

+ 0 - 180
fs-ipad-task/src/main/java/com/fs/framework/aspectj/TenantDataSourceAspect.java

@@ -1,180 +0,0 @@
-package com.fs.framework.aspectj;
-
-import com.alibaba.druid.pool.DruidDataSource;
-import com.fs.common.annotation.TenantDataScope;
-import com.fs.common.core.redis.RedisCacheT;
-import com.fs.common.enums.TenantIdType;
-import com.fs.common.exception.CustomException;
-import com.fs.common.utils.StringUtils;
-import com.fs.framework.datasource.DynamicDataSource;
-import com.fs.framework.datasource.DynamicDataSourceContextHolder;
-import com.fs.huifuPay.sdk.opps.core.exception.BasePayException;
-import com.fs.tenant.domain.TenantInfo;
-import com.fs.tenant.mapper.TenantInfoMapper;
-import lombok.extern.slf4j.Slf4j;
-import org.aspectj.lang.ProceedingJoinPoint;
-import org.aspectj.lang.annotation.Around;
-import org.aspectj.lang.annotation.Aspect;
-import org.aspectj.lang.annotation.Pointcut;
-import org.aspectj.lang.reflect.MethodSignature;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.aop.aspectj.MethodInvocationProceedingJoinPoint;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.core.annotation.AnnotationUtils;
-import org.springframework.core.annotation.Order;
-import org.springframework.stereotype.Component;
-
-import javax.annotation.Resource;
-import javax.sql.DataSource;
-import java.lang.reflect.Field;
-import java.lang.reflect.Method;
-import java.util.Map;
-import java.util.Objects;
-import java.util.concurrent.ConcurrentHashMap;
-
-/**
- * 多数据源处理
- *
- */
-@Slf4j
-@Aspect
-@Order(1)
-@Component
-public class TenantDataSourceAspect {
-    private static final String TENANT_KEY = "tenant:info:";
-    @Resource
-    private DynamicDataSource dynamicDataSource;
-    @Value("${tenant-id}")
-    private Long ymlTenantId;
-    @Autowired
-    private TenantInfoMapper tenantInfoMapper;
-    @Autowired
-    private RedisCacheT<TenantInfo> redis;
-    /**
-     * 租户数据源缓存
-     */
-    private static final Map<String, DataSource> TENANT_DS_CACHE = new ConcurrentHashMap<>();
-
-    @Pointcut("@annotation(com.fs.common.annotation.TenantDataScope)"
-            + "|| @within(com.fs.common.annotation.TenantDataScope)")
-    public void dsPointCut() {
-
-    }
-
-    @Around("dsPointCut()")
-    public Object around(ProceedingJoinPoint point) throws Throwable {
-        MethodSignature signature = (MethodSignature) point.getSignature();
-        Method targetMethod = signature.getMethod(); // 拿到目标方法对象
-        log.info("执行方法:{}", targetMethod.getName());
-        TenantDataScope dataSource = getDataSource(point);
-        TenantIdType type = dataSource.type();
-        Long tenantId = 0L;
-        if(type.equals(TenantIdType.YML)){
-            tenantId = ymlTenantId;
-        }
-        if(type.equals(TenantIdType.REQUEST)){
-
-        }
-        switchTenant(tenantId);
-        try {
-            return point.proceed();
-        } finally {
-            // 销毁数据源 在执行方法之后
-            DynamicDataSourceContextHolder.clearDataSourceType();
-        }
-    }
-
-    public void switchTenant(Long id) {
-        TenantInfo tenantInfo = redis.getCacheObject(TENANT_KEY + id);
-        if(tenantInfo == null){
-            tenantInfo = tenantInfoMapper.selectById(id);
-            if(tenantInfo == null){
-                throw new CustomException("租户不存在请检查");
-            }
-            redis.setCacheObject(TENANT_KEY + id, tenantInfo);
-        }
-        // 用租户主键作为唯一标识
-        String tenantKey = buildTenantKey(tenantInfo.getId());
-
-        if (!TENANT_DS_CACHE.containsKey(tenantKey)) {
-            synchronized (this) {
-                if (!TENANT_DS_CACHE.containsKey(tenantKey)) {
-
-                    javax.sql.DataSource tenantDs = createTenantDataSource(tenantInfo);
-                    TENANT_DS_CACHE.put(tenantKey, tenantDs);
-
-                    // 动态追加到已解析的数据源
-                    Map<Object, DataSource> resolvedMap = getResolvedDataSources();
-                    resolvedMap.put(tenantKey, tenantDs);
-                }
-            }
-        }
-
-        // ThreadLocal 切库
-        DynamicDataSourceContextHolder.setDataSourceType(tenantKey);
-    }
-
-    private String buildTenantKey(Long tenantId) {
-        return "tenant:" + tenantId;
-    }
-
-
-
-    /**
-     * 清理 ThreadLocal
-     */
-    public void clear() {
-        DynamicDataSourceContextHolder.clearDataSourceType();
-    }
-
-    /**
-     * 创建租户数据源(MySQL + Druid)
-     */
-    private DataSource createTenantDataSource(TenantInfo tenant) {
-
-        DruidDataSource ds = new DruidDataSource();
-        ds.setUrl(tenant.getDbUrl());
-        ds.setUsername(tenant.getDbAccount());
-        ds.setPassword(tenant.getDbPwd());
-
-        // 统一 MySQL
-        ds.setDriverClassName("com.mysql.cj.jdbc.Driver");
-
-        ds.setInitialSize(5);
-        ds.setMinIdle(10);
-        ds.setMaxActive(20);
-        ds.setMaxWait(60000);
-
-        return ds;
-    }
-
-    /**
-     * 反射获取 AbstractRoutingDataSource.resolvedDataSources
-     */
-    @SuppressWarnings("unchecked")
-    private Map<Object, DataSource> getResolvedDataSources() {
-        try {
-            Field field = org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource
-                    .class.getDeclaredField("resolvedDataSources");
-            field.setAccessible(true);
-            return (Map<Object, DataSource>) field.get(dynamicDataSource);
-        } catch (Exception e) {
-            throw new IllegalStateException("获取 resolvedDataSources 失败", e);
-        }
-    }
-
-    /**
-     * 获取需要切换的数据源
-     */
-    public TenantDataScope getDataSource(ProceedingJoinPoint point) {
-        MethodSignature signature = (MethodSignature) point.getSignature();
-        TenantDataScope dataSource = AnnotationUtils.findAnnotation(signature.getMethod(), TenantDataScope.class);
-        if (Objects.nonNull(dataSource)) {
-            return dataSource;
-        }
-
-        return AnnotationUtils.findAnnotation(signature.getDeclaringType(), TenantDataScope.class);
-    }
-}

+ 2 - 2
fs-ipad-task/src/main/resources/application.yml

@@ -4,6 +4,6 @@ server:
 # Spring配置
 spring:
   profiles:
-    active: dev-test
+    active: dev,common
 group-no: 1
-tenant-id: 29
+tenant-id: 33

+ 1 - 1
fs-qw-api-msg/src/main/resources/application.yml

@@ -3,7 +3,7 @@ server:
 # Spring配置
 spring:
   profiles:
-    active: dev
+    active: dev,common
 #    active: druid-jzzx
 #    active: druid-hdt
 #    active: druid-sxjz

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

@@ -10,6 +10,7 @@ import com.fs.qw.domain.QwExternalContact;
 import com.fs.qw.param.QwExternalContactAddTagParam;
 import com.fs.qw.param.QwExternalContactUpdateNoteParam;
 import com.fs.qwApi.param.QwUploadImageByCourseParam;
+import com.fs.qwApi.service.QwApiService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
@@ -27,6 +28,9 @@ public class OpenQwApiController extends BaseController {
 
     @Autowired
     private TenantDataSourceUtil tenantDataSourceUtil;
+
+    @Autowired
+    private QwApiService qwApiService;
     /**
      * 同步企微员工
      * @param tenantId
@@ -116,9 +120,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("租户不存在或已禁用");
@@ -137,9 +139,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("租户不存在或已禁用");
@@ -169,6 +169,27 @@ public class OpenQwApiController extends BaseController {
         }
     }
 
+
+    /**
+     * 加密qwUser的userId
+     */
+    @PostMapping("/getOpenUserid")
+    public R getOpenUserid(@RequestParam("userId") String  userId,
+                                   @RequestParam("corpId") String  corpId,
+                                   @RequestParam("permanentCode") String  permanentCode,
+                                   @RequestParam(value = "tenantId", required = true) Long tenantId){
+        try {
+            return R.ok().put("userId",tenantDataSourceUtil.executeWithResult(tenantId,
+                    () ->  qwApiService.getOpenUserid(qwApiService.getToken(corpId,permanentCode),userId,corpId)));
+        } catch (IllegalArgumentException e) {
+            log.error("加密ExternalUserid,租户不存在或已禁用,tenantId={}", tenantId, e);
+            return R.error("租户不存在或已禁用");
+        } catch (Exception e) {
+            log.error("加密ExternalUserid,tenantId={}", tenantId, e);
+            return R.error("加密ExternalUserid失败: " + e.getMessage());
+        }
+    }
+
     /**
      * 上传图片
      */

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

@@ -17,9 +17,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);
 

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

@@ -274,6 +274,7 @@ public class OpenQwApiServiceImpl implements OpenQwApiService {
         qwUser.setIsDel(0);
         qwUser.setOpenid(openid);
         qwUser.setQwOpenUserId(apiUser.getUserid());
+        qwUser.setQwUserId(apiUser.getUserid());
 
         // 设置部门(取第一个部门)
         List<Integer> depts = apiUser.getDepartment();
@@ -540,7 +541,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();
@@ -605,9 +606,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() +
@@ -616,8 +619,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);
@@ -685,7 +688,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();
@@ -746,7 +749,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);

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

@@ -64,6 +64,7 @@ public class CcLlmAgentAccount implements Serializable {
 
     /** 模型ID列表,用于IN查询 */
     private List<Long> modelIds;
+
     /** 绑定公司ID列表(展示字段) */
     private List<Long> companyIds;
 

+ 14 - 15
fs-service/src/main/java/com/fs/aicall/domain/CcLlmAgentProvider.java

@@ -4,27 +4,26 @@ import com.fs.common.annotation.Excel;
 import lombok.Data;
 
 import java.io.Serializable;
-import java.util.Date;
 
+/**
+ * 大模型实现类列表对象 cc_llm_agent_provider
+ * 
+ * @author ruoyi
+ * @date 2025-06-16
+ */
 @Data
 public class CcLlmAgentProvider implements Serializable {
     private static final long serialVersionUID = 1L;
 
-    private Long id;
+    /** 主键id */
+    private Integer id;
 
-    @Excel(name = "供应商名称")
-    private String name;
+    /** 实现类 */
+    @Excel(name = "实现类")
+    private String providerClassName;
 
-    @Excel(name = "实现类名")
-    private String className;
-
-    @Excel(name = "配置JSON")
-    private String configJson;
-
-    private Long companyId;
-
-    private Date createTime;
-
-    private Date updateTime;
+    /** 备注 */
+    @Excel(name = "备注")
+    private String note;
 
 }

+ 12 - 11
fs-service/src/main/java/com/fs/aicall/domain/CcLlmKbCat.java

@@ -4,27 +4,28 @@ import lombok.Data;
 import lombok.experimental.Accessors;
 
 import java.io.Serializable;
-import java.util.Date;
 
+/**
+ * 知识库对象 cc_llm_kb_cat
+ * 
+ * @author ruoyi
+ * @date 2026-01-19
+ */
 @Data
 @Accessors(chain = true)
 public class CcLlmKbCat implements Serializable {
     private static final long serialVersionUID = 1L;
 
+    /** 主键id */
     private Long id;
 
-    private String name;
-
-    private Long parentId;
-
-    private Long companyId;
-
+    /** 分类 */
     private String cat;
 
-    private Integer contentCount;
-
-    private Date createTime;
+    /** 描述 */
+    private String description;
 
-    private Date updateTime;
+    /** 内容数量 */
+    private Integer contentCount;
 
 }

+ 50 - 0
fs-service/src/main/java/com/fs/aicall/mapper/CcLlmAgentAccountMapper.java

@@ -1,20 +1,70 @@
 package com.fs.aicall.mapper;
 
 import com.fs.aicall.domain.CcLlmAgentAccount;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
 
 import java.util.List;
 
+/**
+ * 机器人参数配置Mapper接口
+ * 
+ * @author ruoyi
+ * @date 2025-06-16
+ */
 public interface CcLlmAgentAccountMapper 
 {
+    /**
+     * 查询机器人参数配置
+     * 
+     * @param id 机器人参数配置主键
+     * @return 机器人参数配置
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public CcLlmAgentAccount selectCcLlmAgentAccountById(Integer id);
 
+    /**
+     * 查询机器人参数配置列表
+     * 
+     * @param ccLlmAgentAccount 机器人参数配置
+     * @return 机器人参数配置集合
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public List<CcLlmAgentAccount> selectCcLlmAgentAccountList(CcLlmAgentAccount ccLlmAgentAccount);
 
+    /**
+     * 新增机器人参数配置
+     * 
+     * @param ccLlmAgentAccount 机器人参数配置
+     * @return 结果
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public int insertCcLlmAgentAccount(CcLlmAgentAccount ccLlmAgentAccount);
 
+    /**
+     * 修改机器人参数配置
+     * 
+     * @param ccLlmAgentAccount 机器人参数配置
+     * @return 结果
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public int updateCcLlmAgentAccount(CcLlmAgentAccount ccLlmAgentAccount);
 
+    /**
+     * 删除机器人参数配置
+     * 
+     * @param id 机器人参数配置主键
+     * @return 结果
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public int deleteCcLlmAgentAccountById(Integer id);
 
+    /**
+     * 批量删除机器人参数配置
+     * 
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public int deleteCcLlmAgentAccountByIds(String[] ids);
 }

+ 50 - 0
fs-service/src/main/java/com/fs/aicall/mapper/CcLlmAgentProviderMapper.java

@@ -2,20 +2,70 @@ package com.fs.aicall.mapper;
 
 
 import com.fs.aicall.domain.CcLlmAgentProvider;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
 
 import java.util.List;
 
+/**
+ * 大模型实现类列表Mapper接口
+ * 
+ * @author ruoyi
+ * @date 2025-06-16
+ */
 public interface CcLlmAgentProviderMapper 
 {
+    /**
+     * 查询大模型实现类列表
+     * 
+     * @param id 大模型实现类列表主键
+     * @return 大模型实现类列表
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public CcLlmAgentProvider selectCcLlmAgentProviderById(Integer id);
 
+    /**
+     * 查询大模型实现类列表列表
+     * 
+     * @param ccLlmAgentProvider 大模型实现类列表
+     * @return 大模型实现类列表集合
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public List<CcLlmAgentProvider> selectCcLlmAgentProviderList(CcLlmAgentProvider ccLlmAgentProvider);
 
+    /**
+     * 新增大模型实现类列表
+     * 
+     * @param ccLlmAgentProvider 大模型实现类列表
+     * @return 结果
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public int insertCcLlmAgentProvider(CcLlmAgentProvider ccLlmAgentProvider);
 
+    /**
+     * 修改大模型实现类列表
+     * 
+     * @param ccLlmAgentProvider 大模型实现类列表
+     * @return 结果
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public int updateCcLlmAgentProvider(CcLlmAgentProvider ccLlmAgentProvider);
 
+    /**
+     * 删除大模型实现类列表
+     * 
+     * @param id 大模型实现类列表主键
+     * @return 结果
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public int deleteCcLlmAgentProviderById(Integer id);
 
+    /**
+     * 批量删除大模型实现类列表
+     * 
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public int deleteCcLlmAgentProviderByIds(String[] ids);
 }

+ 50 - 0
fs-service/src/main/java/com/fs/aicall/mapper/CcLlmKbCatMapper.java

@@ -1,20 +1,70 @@
 package com.fs.aicall.mapper;
 
 import com.fs.aicall.domain.CcLlmKbCat;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
 
 import java.util.List;
 
+/**
+ * 知识库Mapper接口
+ * 
+ * @author ruoyi
+ * @date 2026-01-19
+ */
 public interface CcLlmKbCatMapper 
 {
+    /**
+     * 查询知识库
+     * 
+     * @param id 知识库主键
+     * @return 知识库
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public CcLlmKbCat selectCcLlmKbCatById(Long id);
 
+    /**
+     * 查询知识库列表
+     * 
+     * @param ccLlmKbCat 知识库
+     * @return 知识库集合
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public List<CcLlmKbCat> selectCcLlmKbCatList(CcLlmKbCat ccLlmKbCat);
 
+    /**
+     * 新增知识库
+     * 
+     * @param ccLlmKbCat 知识库
+     * @return 结果
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public int insertCcLlmKbCat(CcLlmKbCat ccLlmKbCat);
 
+    /**
+     * 修改知识库
+     * 
+     * @param ccLlmKbCat 知识库
+     * @return 结果
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public int updateCcLlmKbCat(CcLlmKbCat ccLlmKbCat);
 
+    /**
+     * 删除知识库
+     * 
+     * @param id 知识库主键
+     * @return 结果
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public int deleteCcLlmKbCatById(Long id);
 
+    /**
+     * 批量删除知识库
+     * 
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public int deleteCcLlmKbCatByIds(String[] ids);
 }

+ 53 - 0
fs-service/src/main/java/com/fs/aicall/mapper/CcLlmKbMapper.java

@@ -1,26 +1,79 @@
 package com.fs.aicall.mapper;
 
 import com.fs.aicall.domain.CcLlmKb;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
 
 import java.util.List;
 
+/**
+ * 知识库内容Mapper接口
+ * 
+ * @author ruoyi
+ * @date 2026-01-19
+ */
 public interface CcLlmKbMapper 
 {
+    /**
+     * 查询知识库内容
+     * 
+     * @param id 知识库内容主键
+     * @return 知识库内容
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public CcLlmKb selectCcLlmKbById(Long id);
 
+    /**
+     * 查询知识库内容列表
+     * 
+     * @param ccLlmKb 知识库内容
+     * @return 知识库内容集合
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public List<CcLlmKb> selectCcLlmKbList(CcLlmKb ccLlmKb);
 
+    /**
+     * 新增知识库内容
+     * 
+     * @param ccLlmKb 知识库内容
+     * @return 结果
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public int insertCcLlmKb(CcLlmKb ccLlmKb);
 
+    /**
+     * 修改知识库内容
+     * 
+     * @param ccLlmKb 知识库内容
+     * @return 结果
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public int updateCcLlmKb(CcLlmKb ccLlmKb);
 
+    /**
+     * 删除知识库内容
+     * 
+     * @param id 知识库内容主键
+     * @return 结果
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public int deleteCcLlmKbById(Long id);
 
+    /**
+     * 批量删除知识库内容
+     * 
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    @DataSource(DataSourceType.EASYCALL)
     public int deleteCcLlmKbByIds(String[] ids);
 
+    @DataSource(DataSourceType.EASYCALL)
     Integer selectCountByCatId(Long catId);
 
+    @DataSource(DataSourceType.EASYCALL)
     void deleteCcLlmKbByCatId(Long catId);
 
+    @DataSource(DataSourceType.EASYCALL)
     void insertBatch(List<CcLlmKb> ccLlmKbList);
 }

+ 2 - 0
fs-service/src/main/java/com/fs/aicall/mapper/CompanyBindAiModelMapper.java

@@ -1,6 +1,7 @@
 package com.fs.aicall.mapper;
 
 import com.fs.aicall.domain.CompanyBindAiModel;
+import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 
 import java.util.List;
@@ -11,6 +12,7 @@ import java.util.List;
  * @author ruoyi
  * @date 2026-03-20
  */
+@Mapper
 public interface CompanyBindAiModelMapper
 {
     /**

+ 1 - 1
fs-service/src/main/java/com/fs/aicall/service/impl/CcLlmKbCatServiceImpl.java

@@ -96,7 +96,7 @@ public class CcLlmKbCatServiceImpl implements ICcLlmKbCatService
 
     @Override
     public CcLlmKbCat selectCcLlmKbCatByCat(Long id, String cat) {
-        List<CcLlmKbCat> list = ccLlmKbCatMapper.selectCcLlmKbCatList(new CcLlmKbCat().setName(cat));
+        List<CcLlmKbCat> list = ccLlmKbCatMapper.selectCcLlmKbCatList(new CcLlmKbCat().setCat(cat));
         if (!CollectionUtils.isEmpty(list)) {
             if (null == id) {
                 return list.get(0);

+ 90 - 0
fs-service/src/main/java/com/fs/comm/client/CommGatewayClient.java

@@ -0,0 +1,90 @@
+package com.fs.comm.client;
+
+import cn.hutool.http.HttpRequest;
+import cn.hutool.http.HttpResponse;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.StringUtils;
+import com.fs.wxcid.utils.TenantHelper;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+/**
+ * 通讯中间件 HTTP 客户端(工作流/内部服务调用)
+ */
+@Slf4j
+@Component
+public class CommGatewayClient {
+
+    @Value("${comm.gateway.base-url:http://127.0.0.1:8010}")
+    private String baseUrl;
+
+    @Value("${comm.gateway.internal-secret:CommGatewayInternal2026!@#}")
+    private String internalSecret;
+
+    @Getter
+    @Value("${comm.gateway.enabled:true}")
+    private boolean enabled;
+
+    @Getter
+    @Value("${comm.gateway.fallback-local:true}")
+    private boolean fallbackLocal;
+
+    public JSONObject sendCall(Long tenantId, Long companyId, Long companyUserId, Map<String, Object> body) {
+        return postInternal("/comm/call/send", tenantId, companyId, companyUserId, body);
+    }
+
+    public JSONObject sendSms(Long tenantId, Long companyId, Long companyUserId, Map<String, Object> body) {
+        return postInternal("/comm/sms/send", tenantId, companyId, companyUserId, body);
+    }
+
+    private JSONObject postInternal(String path, Long tenantId, Long companyId, Long companyUserId, Map<String, Object> body) {
+        if (!enabled) {
+            throw new ServiceException("通讯中间件未启用");
+        }
+        if (tenantId == null) {
+            tenantId = TenantHelper.getTenantId();
+        }
+        String url = trimSlash(baseUrl) + path;
+        try {
+            HttpResponse response = HttpRequest.post(url)
+                    .header("Content-Type", "application/json")
+                    .header("X-Comm-Internal-Secret", internalSecret)
+                    .header("X-Comm-Tenant-Id", tenantId == null ? "" : String.valueOf(tenantId))
+                    .header("X-Comm-Company-Id", companyId == null ? "" : String.valueOf(companyId))
+                    .header("X-Comm-Company-User-Id", companyUserId == null ? "" : String.valueOf(companyUserId))
+                    .body(JSON.toJSONString(body))
+                    .timeout(120000)
+                    .execute();
+            if (response.getStatus() != 200) {
+                throw new ServiceException("通讯网关HTTP异常: " + response.getStatus());
+            }
+            JSONObject result = JSON.parseObject(response.body());
+            if (result == null) {
+                throw new ServiceException("通讯网关返回空响应");
+            }
+            Integer code = result.getInteger("code");
+            if (code == null || code != 200) {
+                throw new ServiceException(StringUtils.defaultIfBlank(result.getString("msg"), "通讯网关调用失败"));
+            }
+            return result.getJSONObject("data");
+        } catch (ServiceException ex) {
+            throw ex;
+        } catch (Exception ex) {
+            log.error("调用通讯网关失败 path={}", path, ex);
+            throw new ServiceException("调用通讯网关失败: " + ex.getMessage());
+        }
+    }
+
+    private String trimSlash(String url) {
+        if (url.endsWith("/")) {
+            return url.substring(0, url.length() - 1);
+        }
+        return url;
+    }
+}

+ 33 - 0
fs-service/src/main/java/com/fs/comm/model/CommCallSendParam.java

@@ -0,0 +1,33 @@
+package com.fs.comm.model;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.Map;
+
+@Data
+@Builder
+public class CommCallSendParam {
+
+    private Long roboticId;
+
+    private Long calleeId;
+
+    private Long businessId;
+
+    private Long gatewayId;
+
+    private String nodeKey;
+
+    private String workflowInstanceId;
+
+    private Long companyId;
+
+    private Long tenantId;
+
+    private String callbackUrl;
+
+    private String phone;
+
+    private Map<String, Object> bizParams;
+}

+ 15 - 0
fs-service/src/main/java/com/fs/comm/model/CommCallSendResult.java

@@ -0,0 +1,15 @@
+package com.fs.comm.model;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class CommCallSendResult {
+
+    private String callBackUuid;
+
+    private Long batchId;
+
+    private String phone;
+}

+ 31 - 0
fs-service/src/main/java/com/fs/comm/model/CommSmsSendParam.java

@@ -0,0 +1,31 @@
+package com.fs.comm.model;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class CommSmsSendParam {
+
+    private Long roboticId;
+
+    private Long calleeId;
+
+    private Long smsTempId;
+
+    private String nodeKey;
+
+    private String workflowInstanceId;
+
+    private Long companyId;
+
+    private Long companyUserId;
+
+    private String senderName;
+
+    private String cardUrl;
+
+    private String phone;
+
+    private Long customerId;
+}

+ 15 - 0
fs-service/src/main/java/com/fs/comm/model/CommSmsSendResult.java

@@ -0,0 +1,15 @@
+package com.fs.comm.model;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class CommSmsSendResult {
+
+    private String callbackUuid;
+
+    private Long customerId;
+
+    private String phone;
+}

+ 248 - 0
fs-service/src/main/java/com/fs/comm/service/CommCallSendService.java

@@ -0,0 +1,248 @@
+package com.fs.comm.service;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.StringUtils;
+import com.fs.comm.model.CommCallSendParam;
+import com.fs.comm.model.CommCallSendResult;
+import com.fs.company.domain.*;
+import com.fs.company.enums.BusinessTypeEnum;
+import com.fs.company.mapper.*;
+import com.fs.company.param.CompanyVoiceRoboticCallBlacklistCheckParam;
+import com.fs.company.service.CompanyWorkflowEngine;
+import com.fs.company.service.ICompanyVoiceRoboticCallBlacklistService;
+import com.fs.company.service.easycall.IEasyCallService;
+import com.fs.company.service.impl.CompanyVoiceRoboticCallLogCallphoneServiceImpl;
+import com.fs.company.vo.CompanyVoiceRoboticCallBlacklistCheckVO;
+import com.fs.company.vo.easycall.EasyCallCommonAddCallListParam;
+import com.fs.company.vo.easycall.EasyCallPhoneItemVO;
+import com.fs.crm.domain.CrmCustomer;
+import com.fs.crm.service.ICrmCustomerService;
+import com.fs.his.config.CidPhoneConfig;
+import com.fs.his.utils.PhoneUtil;
+import com.fs.system.service.ISysConfigService;
+import com.fs.wxcid.utils.TenantHelper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 通讯网关外呼发送核心实现(工作流/开放 API 共用)
+ */
+@Slf4j
+@Service
+public class CommCallSendService {
+
+    public static final String EASYCALL_WORKFLOW_REDIS_KEY = "easycall:workflow:callback:";
+
+    @Autowired
+    private IEasyCallService easyCallService;
+
+    @Autowired
+    private CompanyConfigMapper companyConfigMapper;
+
+    @Autowired
+    private CompanyVoiceRoboticCalleesMapper companyVoiceRoboticCalleesMapper;
+
+    @Autowired
+    private CompanyVoiceRoboticMapper companyVoiceRoboticMapper;
+
+    @Autowired
+    private CompanySiptaskInfoMapper companySiptaskInfoMapper;
+
+    @Autowired
+    private CompanyAiWorkflowExecMapper companyAiWorkflowExecMapper;
+
+    @Autowired
+    private CompanyVoiceRoboticCallLogCallphoneMapper companyVoiceRoboticCallLogCallphoneMapper;
+
+    @Autowired
+    private CompanyVoiceRoboticCallLogCallphoneServiceImpl companyVoiceRoboticCallLogCallphoneService;
+
+    @Autowired
+    private ICompanyVoiceRoboticCallBlacklistService companyVoiceRoboticCallBlacklistService;
+
+    @Autowired
+    private ICrmCustomerService crmCustomerService;
+
+    @Autowired
+    private ISysConfigService configService;
+
+    @Autowired
+    private CompanyWorkflowEngine companyWorkflowEngine;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    public CommCallSendResult sendWorkflowCall(CommCallSendParam param) {
+        CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectById(param.getRoboticId());
+        if (robotic == null) {
+            throw new ServiceException("外呼任务不存在");
+        }
+        Long companyId = param.getCompanyId() != null ? param.getCompanyId() : robotic.getCompanyId();
+
+        if (param.getBusinessId() != null) {
+            checkPhoneCallLimit(param.getBusinessId(), companyId);
+        }
+
+        CompanyVoiceRoboticCallees callees = companyVoiceRoboticCalleesMapper.selectById(param.getCalleeId());
+        if (callees == null || StringUtils.isBlank(callees.getPhone())) {
+            throw new ServiceException("被叫人不存在或手机号为空");
+        }
+
+        CrmCustomer crmCustomer = crmCustomerService.selectCrmCustomerById(callees.getUserId());
+        CompanyVoiceRoboticCallBlacklistCheckParam blacklistParam = new CompanyVoiceRoboticCallBlacklistCheckParam();
+        blacklistParam.setCompanyId(crmCustomer.getCompanyId());
+        blacklistParam.setBusinessType(BusinessTypeEnum.CALL.getCode());
+        blacklistParam.setTargetValue(callees.getPhone());
+        CompanyVoiceRoboticCallBlacklistCheckVO blacklistVo = companyVoiceRoboticCallBlacklistService.checkBlacklist(blacklistParam);
+        if (!blacklistVo.getPass()) {
+            throw new ServiceException("被叫人命中外呼黑名单");
+        }
+
+        String phoneNum = resolveCalleePhone(param, callees);
+        if (StringUtils.isBlank(phoneNum)) {
+            throw new ServiceException("被叫人手机号解密失败或号码无效");
+        }
+
+        String callBackUrl = resolveCallbackUrl(robotic, param.getCallbackUrl());
+        String callBackUuid = UUID.randomUUID().toString();
+
+        JSONObject callbackInfo = new JSONObject();
+        callbackInfo.put("callBackUuid", callBackUuid);
+        callbackInfo.put("nodeKey", param.getNodeKey());
+        callbackInfo.put("workflowInstanceId", param.getWorkflowInstanceId());
+        callbackInfo.put("calleeId", param.getCalleeId());
+        redisCache.setCacheObject(EASYCALL_WORKFLOW_REDIS_KEY + callBackUuid, callbackInfo.toJSONString(), 15, TimeUnit.DAYS);
+
+        Long batchId = resolveBatchId(param);
+        EasyCallCommonAddCallListParam addListParam = new EasyCallCommonAddCallListParam();
+        addListParam.setBatchId(batchId);
+        EasyCallPhoneItemVO phoneItem = new EasyCallPhoneItemVO();
+        phoneItem.setPhoneNum(phoneNum);
+        JSONObject bizJson = new JSONObject();
+        bizJson.put("custName", callees.getUserName());
+        bizJson.put("tenantId", param.getTenantId() != null ? param.getTenantId() : TenantHelper.getTenantId());
+        bizJson.put("callBackUuid", callBackUuid);
+        bizJson.put("callBackUrl", callBackUrl);
+        if (param.getBizParams() != null) {
+            bizJson.putAll(param.getBizParams());
+        }
+        phoneItem.setBizJson(bizJson);
+        addListParam.setPhoneList(Collections.singletonList(phoneItem));
+
+        try {
+            boolean added = easyCallService.addCommonCallList(addListParam, companyId, param.getGatewayId());
+            if (!added) {
+                throw new ServiceException("外呼名单追加失败或线路限流");
+            }
+        } catch (ServiceException ex) {
+            redisCache.deleteObject(EASYCALL_WORKFLOW_REDIS_KEY + callBackUuid);
+            throw ex;
+        }
+        easyCallService.startTask(batchId, null);
+
+        JSONObject runParam = (JSONObject) JSON.toJSON(addListParam);
+        runParam.put("companyId", companyId);
+        CompanyVoiceRoboticCallLogCallphone addLog = CompanyVoiceRoboticCallLogCallphone.initCallLog(
+                runParam.toJSONString(), param.getCalleeId(), param.getRoboticId(), companyId);
+        addLog.setStatus(1);
+        addLog.setCallbackUuid(callBackUuid);
+        companyVoiceRoboticCallLogCallphoneService.asyncInsertCompanyVoiceRoboticCallLog(addLog);
+
+        return CommCallSendResult.builder()
+                .callBackUuid(callBackUuid)
+                .batchId(batchId)
+                .phone(phoneItem.getPhoneNum())
+                .build();
+    }
+
+    private String resolveCalleePhone(CommCallSendParam param, CompanyVoiceRoboticCallees callees) {
+        if (StringUtils.isNotBlank(param.getPhone())) {
+            return PhoneUtil.decryptAutoPhone(param.getPhone().trim());
+        }
+        return PhoneUtil.decryptAutoPhone(callees.getPhone());
+    }
+
+    private void checkPhoneCallLimit(Long businessId, Long companyId) {
+        String json = configService.selectConfigByKey("cId.config");
+        if (StringUtils.isEmpty(json)) {
+            return;
+        }
+        CidPhoneConfig config = JSONObject.parseObject(json, CidPhoneConfig.class);
+        if (config.getEnablePhoneLimitConfig() == null || !config.getEnablePhoneLimitConfig()) {
+            return;
+        }
+        int num = companyVoiceRoboticCallLogCallphoneMapper.countTodayCallsByBusinessId(businessId, companyId);
+        if (num >= config.getNumberCalls()) {
+            throw new ServiceException("今日拨打次数已达上限");
+        }
+    }
+
+    private String resolveCallbackUrl(CompanyVoiceRobotic robotic, String requestCallbackUrl) {
+        if (StringUtils.isNotBlank(requestCallbackUrl)) {
+            return requestCallbackUrl;
+        }
+        try {
+            CompanyConfig companyConfig = companyConfigMapper.selectCompanyConfigByKey(robotic.getCompanyId(), "cId.config");
+            if (companyConfig != null && StringUtils.isNotBlank(companyConfig.getConfigValue())) {
+                CidPhoneConfig cidConf = JSONObject.parseObject(companyConfig.getConfigValue(), CidPhoneConfig.class);
+                if (cidConf != null && StringUtils.isNotBlank(cidConf.getCallbackUrl())) {
+                    return cidConf.getCallbackUrl();
+                }
+            }
+            String s = configService.selectConfigByKey("cId.config");
+            JSONObject obj = JSONObject.parseObject(s);
+            if (obj != null && StringUtils.isNotBlank(obj.getString("callbackUrl"))) {
+                return obj.getString("callbackUrl");
+            }
+        } catch (Exception ex) {
+            log.warn("获取回调地址失败", ex);
+        }
+        return "";
+    }
+
+    private Long resolveBatchId(CommCallSendParam param) {
+        CompanySiptaskInfo sipTaskInfo = companySiptaskInfoMapper.selectSipTaskInfoByTaskIdAndNodeKey(param.getRoboticId(), param.getNodeKey());
+        if (sipTaskInfo != null && sipTaskInfo.getBatchId() != null) {
+            return sipTaskInfo.getBatchId();
+        }
+        String lockKey = "sipTask:lock:" + param.getRoboticId() + ":" + param.getNodeKey();
+        boolean locked = redisCache.setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
+        if (!locked) {
+            for (int i = 0; i < 20; i++) {
+                try {
+                    Thread.sleep(100);
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                }
+                sipTaskInfo = companySiptaskInfoMapper.selectSipTaskInfoByTaskIdAndNodeKey(param.getRoboticId(), param.getNodeKey());
+                if (sipTaskInfo != null && sipTaskInfo.getBatchId() != null) {
+                    return sipTaskInfo.getBatchId();
+                }
+            }
+        }
+        try {
+            sipTaskInfo = companySiptaskInfoMapper.selectSipTaskInfoByTaskIdAndNodeKey(param.getRoboticId(), param.getNodeKey());
+            if (sipTaskInfo != null && sipTaskInfo.getBatchId() != null) {
+                return sipTaskInfo.getBatchId();
+            }
+            if (StringUtils.isBlank(param.getWorkflowInstanceId())) {
+                throw new ServiceException("创建外呼任务缺少 workflowInstanceId");
+            }
+            CompanyAiWorkflowExec exec = companyAiWorkflowExecMapper.selectByWorkflowInstanceId(param.getWorkflowInstanceId());
+            if (exec == null) {
+                throw new ServiceException("工作流实例不存在");
+            }
+            return companyWorkflowEngine.createSipTask(param.getRoboticId(), exec.getWorkflowId());
+        } finally {
+            redisCache.deleteObject(lockKey);
+        }
+    }
+}

+ 283 - 0
fs-service/src/main/java/com/fs/comm/service/CommSmsSendService.java

@@ -0,0 +1,283 @@
+package com.fs.comm.service;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.constant.Constants;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.service.ISmsService;
+import com.fs.common.utils.StringUtils;
+import com.fs.comm.model.CommSmsSendParam;
+import com.fs.comm.model.CommSmsSendResult;
+import com.fs.company.domain.*;
+import com.fs.company.enums.BusinessTypeEnum;
+import com.fs.company.mapper.CompanyVoiceRoboticCalleesMapper;
+import com.fs.company.mapper.CompanyVoiceRoboticMapper;
+import com.fs.company.mapper.CompanyWxClientMapper;
+import com.fs.company.param.CompanyVoiceRoboticCallBlacklistCheckParam;
+import com.fs.company.service.*;
+import com.fs.company.vo.CompanyVoiceRoboticCallBlacklistCheckVO;
+import com.fs.crm.domain.CrmCustomer;
+import com.fs.crm.param.SmsSendBatchParam;
+import com.fs.crm.service.ICrmCustomerService;
+import com.fs.his.utils.PhoneUtil;
+import com.fs.qw.domain.QwUser;
+import com.fs.qw.service.impl.QwExternalContactServiceImpl;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 通讯网关短信发送核心实现(工作流/开放 API 共用)
+ */
+@Slf4j
+@Service
+public class CommSmsSendService {
+
+    public static final String WORKFLOW_SMS_ONE_REDIS_KEY = "workflow:sms:one:";
+
+    @Autowired
+    private CompanyVoiceRoboticMapper companyVoiceRoboticMapper;
+
+    @Autowired
+    private CompanyVoiceRoboticCalleesMapper companyVoiceRoboticCalleesMapper;
+
+    @Autowired
+    private CompanyWxClientMapper companyWxClientMapper;
+
+    @Autowired
+    private ICompanyWxAccountService companyWxAccountService;
+
+    @Autowired
+    private QwExternalContactServiceImpl qwExternalContactService;
+
+    @Autowired
+    private ICompanySmsTempService smsTempService;
+
+    @Autowired
+    private ICompanySmsService companySmsService;
+
+    @Autowired
+    private ISmsService smsService;
+
+    @Autowired
+    private ICompanyUserService companyUserService;
+
+    @Autowired
+    private ICrmCustomerService crmCustomerService;
+
+    @Autowired
+    private ICompanyVoiceRoboticCallBlacklistService companyVoiceRoboticCallBlacklistService;
+
+    @Autowired
+    private ICompanyVoiceRoboticCallLogSendmsgService companyVoiceRoboticCallLogSendmsgService;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    public CommSmsSendResult sendWorkflowSms(CommSmsSendParam param) {
+        CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectById(param.getRoboticId());
+        if (robotic == null) {
+            throw new ServiceException("外呼任务不存在");
+        }
+        CompanyVoiceRoboticCallees callees = companyVoiceRoboticCalleesMapper.selectById(param.getCalleeId());
+        if (callees == null) {
+            throw new ServiceException("被叫人不存在");
+        }
+
+        Long companyId = param.getCompanyId();
+        Long companyUserId = param.getCompanyUserId();
+        String senderName = param.getSenderName();
+        if (companyUserId == null || StringUtils.isBlank(senderName)) {
+            CompanyWxClient wxClient = companyWxClientMapper.selectOneByRoboticIdAndUserId(param.getRoboticId(), callees.getUserId());
+            if (wxClient != null) {
+                if (wxClient.getIsWeCom() == 2) {
+                    QwUser qwUser = qwExternalContactService.getQwUserByRedisForId(String.valueOf(wxClient.getAccountId()));
+                    companyId = qwUser.getCompanyId();
+                    companyUserId = qwUser.getCompanyUserId();
+                    senderName = qwUser.getQwUserName();
+                } else {
+                    CompanyWxAccount wxAccount = companyWxAccountService.selectCompanyWxAccountById(wxClient.getAccountId());
+                    companyId = wxAccount.getCompanyId();
+                    companyUserId = wxAccount.getCompanyUserId();
+                    senderName = wxAccount.getWxNickName();
+                }
+            }
+        }
+        if (companyId == null) {
+            companyId = robotic.getCompanyId();
+        }
+
+        CompanySmsTemp temp = smsTempService.selectCompanySmsTempById(param.getSmsTempId());
+        if (temp == null || !Integer.valueOf(1).equals(temp.getStatus()) || !Integer.valueOf(1).equals(temp.getIsAudit())) {
+            throw new ServiceException("短信模板不存在或未审核");
+        }
+
+        CompanySms sms = companySmsService.selectCompanySmsByCompanyId(companyId);
+        if (sms == null || sms.getRemainSmsCount() == null || sms.getRemainSmsCount() <= 0) {
+            throw new ServiceException("剩余短信数量不足,请充值");
+        }
+
+        Long customerId = param.getCustomerId() != null ? param.getCustomerId() : callees.getUserId();
+        CrmCustomer customer = crmCustomerService.selectCrmCustomerById(customerId);
+        if (customer == null) {
+            throw new ServiceException("客户不存在");
+        }
+        String targetPhone = StringUtils.isNotBlank(param.getPhone()) ? param.getPhone() : customer.getMobile();
+        checkSmsBlacklist(companyId, targetPhone);
+        if (companyUserId != null) {
+            CompanyUser companyUser = companyUserService.selectCompanyUserById(companyUserId);
+            if (companyUser != null && StringUtils.isNotBlank(companyUser.getPhonenumber())) {
+                checkSmsBlacklist(companyId, companyUser.getPhonenumber());
+            }
+        }
+
+        String callbackUuid = UUID.randomUUID().toString();
+        if (StringUtils.isNotBlank(param.getWorkflowInstanceId())) {
+            JSONObject userDataJson = new JSONObject();
+            userDataJson.put("callbackUuid", callbackUuid);
+            userDataJson.put("nodeKey", param.getNodeKey());
+            userDataJson.put("workflowInstanceId", param.getWorkflowInstanceId());
+            userDataJson.put("callerId", param.getCalleeId());
+            redisCache.setCacheObject(WORKFLOW_SMS_ONE_REDIS_KEY + callbackUuid, userDataJson.toJSONString(), 1, TimeUnit.DAYS);
+        }
+
+        SmsSendBatchParam smsSendBatchParam = new SmsSendBatchParam();
+        smsSendBatchParam.setCompanyId(companyId);
+        smsSendBatchParam.setCompanyUserId(companyUserId);
+        smsSendBatchParam.setSmsType(temp.getTempType());
+        smsSendBatchParam.setTempCode(temp.getTempCode());
+        smsSendBatchParam.setContent(temp.getContent());
+        smsSendBatchParam.setSenderName(senderName);
+        smsSendBatchParam.setCardUrl(param.getCardUrl());
+        smsSendBatchParam.setCustomerIds(new Long[]{customerId});
+
+        JSONObject runParam = (JSONObject) JSON.toJSON(smsSendBatchParam);
+        runParam.put("temp", temp);
+        CompanyVoiceRoboticCallLogSendmsg addLog = CompanyVoiceRoboticCallLogSendmsg.initCallLog(
+                runParam.toJSONString(),
+                param.getCalleeId(),
+                param.getRoboticId(),
+                companyId,
+                companyUserId,
+                temp.getTempId());
+        addLog.setStatus(1);
+        addLog.setCallbackUuid(callbackUuid);
+        addLog.setContentLen(calcSmsContentLen(smsSendBatchParam));
+
+        try {
+            smsService.batchSmsOp4AiSend(temp, smsSendBatchParam);
+            addLog.setStatus(2);
+        } catch (Exception ex) {
+            addLog.setStatus(3);
+            addLog.setResult(ex.getMessage());
+            log.error("CommSmsSendService 发送失败 roboticId={}, calleeId={}", param.getRoboticId(), param.getCalleeId(), ex);
+            throw ex instanceof ServiceException ? (ServiceException) ex : new ServiceException("短信发送失败: " + ex.getMessage());
+        } finally {
+            companyVoiceRoboticCallLogSendmsgService.asyncInsertCompanyVoiceRoboticCallLog(addLog);
+        }
+
+        updateSmsWorkflowProgress(param.getRoboticId(), param.getCalleeId());
+        return CommSmsSendResult.builder()
+                .callbackUuid(callbackUuid)
+                .customerId(customerId)
+                .phone(targetPhone)
+                .build();
+    }
+
+    /**
+     * 已组装好参数的 AI 短信批量发送(WxTask 等场景),统一收口 batchSmsOp4AiSend
+     */
+    public void sendPreparedBatch(CompanySmsTemp temp, SmsSendBatchParam param) {
+        if (temp == null || !Integer.valueOf(1).equals(temp.getStatus()) || !Integer.valueOf(1).equals(temp.getIsAudit())) {
+            throw new ServiceException("短信模板不存在或未审核");
+        }
+        if (param == null || param.getCompanyId() == null) {
+            throw new ServiceException("短信发送参数不完整");
+        }
+        CompanySms sms = companySmsService.selectCompanySmsByCompanyId(param.getCompanyId());
+        if (sms == null || sms.getRemainSmsCount() == null || sms.getRemainSmsCount() <= 0) {
+            throw new ServiceException("剩余短信数量不足,请充值");
+        }
+        if (param.getCustomerIds() != null) {
+            for (Long customerId : param.getCustomerIds()) {
+                CrmCustomer customer = crmCustomerService.selectCrmCustomerById(customerId);
+                if (customer != null && StringUtils.isNotBlank(customer.getMobile())) {
+                    checkSmsBlacklist(param.getCompanyId(), customer.getMobile());
+                }
+            }
+        }
+        if (param.getCompanyUserId() != null) {
+            CompanyUser companyUser = companyUserService.selectCompanyUserById(param.getCompanyUserId());
+            if (companyUser != null && StringUtils.isNotBlank(companyUser.getPhonenumber())) {
+                checkSmsBlacklist(param.getCompanyId(), companyUser.getPhonenumber());
+            }
+        }
+        try {
+            smsService.batchSmsOp4AiSend(temp, param);
+        } catch (Exception ex) {
+            log.error("CommSmsSendService.sendPreparedBatch 发送失败 companyId={}", param.getCompanyId(), ex);
+            throw ex instanceof ServiceException ? (ServiceException) ex : new ServiceException("短信发送失败: " + ex.getMessage());
+        }
+    }
+
+    private int calcSmsContentLen(SmsSendBatchParam param) {
+        if (param.getCustomerIds() == null || param.getCustomerIds().length == 0) {
+            return param.getContent() == null ? 0 : param.getContent().length();
+        }
+        CompanyUser companyUser = companyUserService.selectCompanyUserById(param.getCompanyUserId());
+        CrmCustomer crmCustomer = crmCustomerService.selectCrmCustomerById(param.getCustomerIds()[0]);
+        String content = param.getContent();
+        if (crmCustomer != null && StringUtils.isNotEmpty(crmCustomer.getCustomerName())) {
+            content = content.replace("${sms.csName}", crmCustomer.getCustomerName());
+        }
+        if (companyUser != null && StringUtils.isNotEmpty(companyUser.getPhonenumber())) {
+            content = content.replace("${sms.phoneNumber}", companyUser.getPhonenumber());
+        }
+        if (StringUtils.isNotEmpty(param.getCardUrl())) {
+            content = content.replace("${sms.cardUrl}", param.getCardUrl());
+        }
+        if (StringUtils.isNotEmpty(param.getSenderName())) {
+            content = content.replace("${sms.senderName}", param.getSenderName());
+        }
+        return content.length();
+    }
+
+    private void updateSmsWorkflowProgress(Long roboticId, Long callerId) {
+        CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectById(roboticId);
+        CompanyVoiceRoboticCallees callees = companyVoiceRoboticCalleesMapper.selectById(callerId);
+        if (StringUtils.isNotBlank(callees.getRunTaskFlow())) {
+            callees.setRunTaskFlow(callees.getRunTaskFlow() + "," + Constants.SEND_MSG);
+            callees.setIsSendMsg(1);
+        } else {
+            callees.setRunTaskFlow(Constants.SEND_MSG);
+        }
+        companyVoiceRoboticCalleesMapper.updateById(callees);
+        Integer unfulfilledTaskCount = companyVoiceRoboticCalleesMapper.getRoboticIsDoneByRoboticIdAndTaskFlow(roboticId, Constants.SEND_MSG);
+        if (unfulfilledTaskCount.compareTo(0) == 0) {
+            if (StringUtils.isNotBlank(robotic.getRunTaskFlow())) {
+                robotic.setRunTaskFlow(robotic.getRunTaskFlow() + "," + Constants.SEND_MSG);
+            } else {
+                robotic.setRunTaskFlow(Constants.SEND_MSG);
+            }
+            companyVoiceRoboticMapper.updateById(robotic);
+        }
+    }
+
+    private void checkSmsBlacklist(Long companyId, String phone) {
+        if (StringUtils.isBlank(phone)) {
+            return;
+        }
+        CompanyVoiceRoboticCallBlacklistCheckParam checkParam = new CompanyVoiceRoboticCallBlacklistCheckParam();
+        checkParam.setCompanyId(companyId);
+        checkParam.setBusinessType(BusinessTypeEnum.SMS.getCode());
+        checkParam.setTargetValue(PhoneUtil.decryptPhone(phone));
+        CompanyVoiceRoboticCallBlacklistCheckVO vo = companyVoiceRoboticCallBlacklistService.checkBlacklist(checkParam);
+        if (!vo.getPass()) {
+            throw new ServiceException("号码命中短信黑名单: " + phone);
+        }
+    }
+}

+ 3 - 0
fs-service/src/main/java/com/fs/common/service/ISmsService.java

@@ -1,6 +1,7 @@
 package com.fs.common.service;
 
 import com.fs.common.core.domain.R;
+import com.fs.company.domain.CompanySmsTemp;
 import com.fs.crm.param.SmsSendBatchParam;
 import com.fs.crm.param.SmsSendParam;
 import com.fs.crm.param.SmsSendUserParam;
@@ -16,6 +17,8 @@ public interface ISmsService
 
     R sendBatchSms(SmsSendBatchParam param);
 
+    void batchSmsOp4AiSend(CompanySmsTemp temp, SmsSendBatchParam param);
+
     R sendUserSms(String phone,String UserName,String code);
 
     R sendOrderMsg(SmsSendUserParam param);

+ 24 - 7
fs-service/src/main/java/com/fs/common/service/impl/SmsServiceImpl.java

@@ -733,18 +733,34 @@ public class SmsServiceImpl implements ISmsService
         }
     }
 
+    @Override
     public void batchSmsOp4AiSend(CompanySmsTemp temp, SmsSendBatchParam param){
         CompanyUser companyUser=companyUserService.selectCompanyUserById(param.getCompanyUserId());
-        CompanyVoiceRoboticCallBlacklistCheckParam companyVoiceRoboticCallBlacklistCheckParam = new CompanyVoiceRoboticCallBlacklistCheckParam();
-        companyVoiceRoboticCallBlacklistCheckParam.setCompanyId(param.getCompanyId());
-        companyVoiceRoboticCallBlacklistCheckParam.setBusinessType(BusinessTypeEnum.SMS.getCode());
-        companyVoiceRoboticCallBlacklistCheckParam.setTargetValue(companyUser.getPhonenumber());
-        CompanyVoiceRoboticCallBlacklistCheckVO companyVoiceRoboticCallBlacklistCheckVO = companyVoiceRoboticCallBlacklistService.checkBlacklist(companyVoiceRoboticCallBlacklistCheckParam);
-        if (!companyVoiceRoboticCallBlacklistCheckVO.getPass()){
-            throw new RuntimeException("黑名单校验未通过");
+        if (companyUser != null && StringUtils.isNotEmpty(companyUser.getPhonenumber())) {
+            CompanyVoiceRoboticCallBlacklistCheckParam salesBlacklistParam = new CompanyVoiceRoboticCallBlacklistCheckParam();
+            salesBlacklistParam.setCompanyId(param.getCompanyId());
+            salesBlacklistParam.setBusinessType(BusinessTypeEnum.SMS.getCode());
+            salesBlacklistParam.setTargetValue(companyUser.getPhonenumber());
+            CompanyVoiceRoboticCallBlacklistCheckVO salesBlacklistVo = companyVoiceRoboticCallBlacklistService.checkBlacklist(salesBlacklistParam);
+            if (!salesBlacklistVo.getPass()){
+                throw new RuntimeException("销售号码黑名单校验未通过");
+            }
         }
         for(Long id:param.getCustomerIds()){
             CrmCustomer crmCustomer=crmCustomerService.selectCrmCustomerById(id);
+            if (crmCustomer == null) {
+                throw new RuntimeException("客户不存在: " + id);
+            }
+            if (StringUtils.isNotEmpty(crmCustomer.getMobile())) {
+                CompanyVoiceRoboticCallBlacklistCheckParam customerBlacklistParam = new CompanyVoiceRoboticCallBlacklistCheckParam();
+                customerBlacklistParam.setCompanyId(param.getCompanyId());
+                customerBlacklistParam.setBusinessType(BusinessTypeEnum.SMS.getCode());
+                customerBlacklistParam.setTargetValue(crmCustomer.getMobile());
+                CompanyVoiceRoboticCallBlacklistCheckVO customerBlacklistVo = companyVoiceRoboticCallBlacklistService.checkBlacklist(customerBlacklistParam);
+                if (!customerBlacklistVo.getPass()){
+                    throw new RuntimeException("客户号码黑名单校验未通过");
+                }
+            }
             String content="";
             content=param.getContent();
             if(StringUtils.isNotEmpty(crmCustomer.getCustomerName())){
@@ -793,6 +809,7 @@ public class SmsServiceImpl implements ISmsService
                 }
             } else {
                 log.warn("batchSmsOp4AiSend: 发送失败 phone={}, result={}", crmCustomer.getMobile(), sendResult);
+                throw new RuntimeException("短信发送失败: " + sendResult);
             }
         }
     }

+ 53 - 60
fs-service/src/main/java/com/fs/company/domain/CompanyVoiceApi.java

@@ -2,79 +2,72 @@ package com.fs.company.domain;
 
 import com.fs.common.annotation.Excel;
 import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
 import org.apache.commons.lang3.builder.ToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringStyle;
 
+import java.math.BigDecimal;
+
 /**
  * 呼叫接口对象 company_voice_api
- * 
+ *
  * @author fs
  * @date 2021-10-04
  */
-public class CompanyVoiceApi extends BaseEntity
-{
+@Data
+public class CompanyVoiceApi extends BaseEntity {
     private static final long serialVersionUID = 1L;
 
-    /** ID */
     private Long apiId;
-
-    /** API接口名 */
-    @Excel(name = "API接口名")
+    /**
+     * API名称
+     */
     private String apiName;
-
-    /** KEY */
-    @Excel(name = "apiType")
-    private String apiType;
-
-    /** JSON */
-    @Excel(name = "JSON")
-    private String apiJson;
-
-    /** 状态 */
-    @Excel(name = "状态")
+    /**
+     * 接口类型:0 SIP,1 网关,2 API
+     */
+    private Integer apiType;
+
+    /**
+     * 状态: 0 禁用 ,1 启用
+     */
     private Integer status;
+    /**
+     * 备注
+     */
+    private String remark;
+    /**
+     * 成本价
+     */
+    private BigDecimal costPrice;
+    /**
+     * 账户
+     */
+    private String account;
+    /**
+     * 密码
+     */
+    private String password;
+    /**
+     * api地址
+     */
+    private String apiUrl;
+    /**
+     * 话术跳转地址(API类型)
+     */
+    private String dialogUrl;
+    /**
+     * 服务商
+     */
+    private String provider;
+    /**
+     * 历史字段,保留列暂不读写
+     */
+    private String apiJson;
 
-    public static long getSerialVersionUID() {
-        return serialVersionUID;
-    }
-
-    public Long getApiId() {
-        return apiId;
-    }
-
-    public void setApiId(Long apiId) {
-        this.apiId = apiId;
-    }
-
-    public String getApiName() {
-        return apiName;
-    }
-
-    public void setApiName(String apiName) {
-        this.apiName = apiName;
-    }
-
-    public String getApiType() {
-        return apiType;
-    }
-
-    public void setApiType(String apiType) {
-        this.apiType = apiType;
-    }
-
-    public String getApiJson() {
-        return apiJson;
-    }
-
-    public void setApiJson(String apiJson) {
-        this.apiJson = apiJson;
-    }
-
-    public Integer getStatus() {
-        return status;
-    }
+    /**
+     * 是否删除,0否 1是
+     */
+    private Integer isDel;
 
-    public void setStatus(Integer status) {
-        this.status = status;
-    }
 }

+ 58 - 133
fs-service/src/main/java/com/fs/company/domain/CompanyVoiceApiTenant.java

@@ -1,7 +1,9 @@
 package com.fs.company.domain;
 
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.fasterxml.jackson.annotation.JsonFormat;
 import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
 
 import java.math.BigDecimal;
 import java.util.Date;
@@ -12,164 +14,87 @@ import java.util.Date;
  * @author fs
  * @date 2026-05-21
  */
-public class CompanyVoiceApiTenant extends BaseEntity
-{
+@Data
+public class CompanyVoiceApiTenant extends BaseEntity {
     private static final long serialVersionUID = 1L;
 
-    /** 主键 */
+    /**
+     * 主键
+     */
     private Long id;
 
-    /** 通话接口ID */
+    /**
+     * 通话接口ID
+     */
     private Long apiId;
 
-    /** 租户ID */
-    private Long companyId;
+    /**
+     * 租户ID(tenant_info.id)
+     */
+    private Long tenantId;
 
-    /** 售价(元/分钟) */
-    private BigDecimal price;
+    /**
+     * 售价(元/分钟)
+     */
+    private BigDecimal salePrice;
 
-    /** 优先级 */
+    /**
+     * 优先级
+     */
     private Integer priority;
 
-    /** 是否主线路 1是 0否 */
+    /**
+     * 是否主线路 1是 0否
+     */
     private Integer isPrimary;
 
-    /** 是否允许手动选择 1允许 0禁止 */
-    private Integer allowManual;
-
-    /** 状态 1启用 0禁用 */
+    /**
+     * 状态 1启用 0禁用
+     */
     private Integer status;
 
-    /** 创建时间 */
+    /**
+     * 创建时间
+     */
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     private Date createTime;
 
-    /** 租户名(非表字段,关联查询用) */
-    private String companyName;
-
-    /** 接口名(非表字段,关联查询用) */
+    /**
+     * 接口名(非表字段,关联 company_voice_api 查询)
+     */
+    @TableField(exist = false)
     private String apiName;
 
-    /** 成本价(非表字段,关联查询用) */
+    /**
+     * 成本价(非表字段,关联 company_voice_api 查询)
+     */
+    @TableField(exist = false)
     private BigDecimal costPrice;
 
-    /** 接口类型(非表字段,关联查询用) */
+    /**
+     * 接口类型(非表字段,关联 company_voice_api 查询)
+     */
+    @TableField(exist = false)
     private Integer apiType;
 
-    /** 服务商(非表字段,关联查询用) */
+    /**
+     * 服务商(非表字段,关联 company_voice_api 查询)
+     */
+    @TableField(exist = false)
     private String provider;
 
-    public Long getId() {
-        return id;
-    }
-
-    public void setId(Long id) {
-        this.id = id;
-    }
-
-    public Long getApiId() {
-        return apiId;
-    }
-
-    public void setApiId(Long apiId) {
-        this.apiId = apiId;
-    }
-
-    public Long getCompanyId() {
-        return companyId;
-    }
-
-    public void setCompanyId(Long companyId) {
-        this.companyId = companyId;
-    }
-
-    public Integer getStatus() {
-        return status;
-    }
-
-    public void setStatus(Integer status) {
-        this.status = status;
-    }
-
-    @Override
-    public Date getCreateTime() {
-        return createTime;
-    }
-
-    @Override
-    public void setCreateTime(Date createTime) {
-        this.createTime = createTime;
-    }
-
-    public String getCompanyName() {
-        return companyName;
-    }
-
-    public void setCompanyName(String companyName) {
-        this.companyName = companyName;
-    }
-
-    public String getApiName() {
-        return apiName;
-    }
-
-    public void setApiName(String apiName) {
-        this.apiName = apiName;
-    }
-
-    public BigDecimal getPrice() {
-        return price;
-    }
-
-    public void setPrice(BigDecimal price) {
-        this.price = price;
-    }
-
-    public Integer getPriority() {
-        return priority;
-    }
-
-    public void setPriority(Integer priority) {
-        this.priority = priority;
-    }
-
-    public Integer getIsPrimary() {
-        return isPrimary;
-    }
-
-    public void setIsPrimary(Integer isPrimary) {
-        this.isPrimary = isPrimary;
-    }
-
-    public Integer getAllowManual() {
-        return allowManual;
-    }
-
-    public void setAllowManual(Integer allowManual) {
-        this.allowManual = allowManual;
-    }
-
-    public BigDecimal getCostPrice() {
-        return costPrice;
-    }
+    /**
+     * 租户编码(冗余字段,关联 tenant_info 补全)
+     */
+    private String tenantCode;
 
-    public void setCostPrice(BigDecimal costPrice) {
-        this.costPrice = costPrice;
-    }
+    /**
+     * 租户名称(冗余字段,关联 tenant_info 补全)
+     */
+    private String tenantName;
+    /**
+     * 是否可选
+     */
+    private String selectable;
 
-    public Integer getApiType() {
-        return apiType;
-    }
-
-    public void setApiType(Integer apiType) {
-        this.apiType = apiType;
-    }
-
-    public String getProvider() {
-        return provider;
-    }
-
-    public void setProvider(String provider) {
-        this.provider = provider;
-    }
 }

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

@@ -173,6 +173,13 @@ public class CompanyVoiceRoboticCallLogCallphone extends BaseEntity{
 
     @TableField(exist = false)
     private Integer runningCount;
+    /** 详情筛选-手机号(明文) */
+    @TableField(exist = false)
+    private String phone;
+
+    /** 详情筛选-加密手机号(匹配 callees.phone) */
+    @TableField(exist = false)
+    private String encryptedPhone;
 
     public static CompanyVoiceRoboticCallLogCallphone initCallLog( String runParam, Long keyId, Long taskId,Long companyId) {
         CompanyVoiceRoboticCallLogCallphone log = new CompanyVoiceRoboticCallLogCallphone();

+ 33 - 0
fs-service/src/main/java/com/fs/company/domain/LobsterAbTest.java

@@ -0,0 +1,33 @@
+package com.fs.company.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("lobster_ab_tests")
+public class LobsterAbTest {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long companyId;
+    private String nodeCode;
+
+    @TableField("original_message")
+    private String originalMessage;
+
+    @TableField("variant_message")
+    private String variantMessage;
+
+    @TableField("original_positive_rate")
+    private Double originalPositiveRate;
+
+    @TableField("variant_positive_rate")
+    private Double variantPositiveRate;
+
+    private String status;
+    private LocalDateTime appliedAt;
+    private LocalDateTime createTime;
+}

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

@@ -0,0 +1,30 @@
+package com.fs.company.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@TableName("lobster_api_registry")
+public class LobsterApiRegistry {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String apiKey;
+    private String apiName;
+    private String category;
+    private String provider;
+    private String baseUrl;
+    private String authType;
+    private String authConfig;
+    private Integer timeout;
+    private Integer priority;
+    private Integer isBackup;
+    private String description;
+    private Integer enabled;
+    private LocalDateTime createTime;
+    private LocalDateTime updateTime;
+}

+ 32 - 0
fs-service/src/main/java/com/fs/company/domain/LobsterChannelTypeRegistry.java

@@ -0,0 +1,32 @@
+package com.fs.company.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("lobster_channel_type_registry")
+public class LobsterChannelTypeRegistry {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    @TableField("channel_type")
+    private String channelType;
+
+    @TableField("display_name")
+    private String displayName;
+
+    @TableField("source_table")
+    private String sourceTable;
+
+    @TableField("user_id_column")
+    private String userIdColumn;
+
+    private Integer enabled;
+
+    private LocalDateTime createTime;
+    private LocalDateTime updateTime;
+}

+ 12 - 25
fs-service/src/main/java/com/fs/company/domain/LobsterConversationSummary.java

@@ -2,36 +2,23 @@ package com.fs.company.domain;
 
 import com.baomidou.mybatisplus.annotation.IdType;
 import com.baomidou.mybatisplus.annotation.TableId;
-import com.fs.common.core.domain.BaseEntity;
+import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
-import lombok.EqualsAndHashCode;
+import java.time.LocalDateTime;
 
 @Data
-@EqualsAndHashCode(callSuper = true)
-public class LobsterConversationSummary extends BaseEntity {
-
-    private static final long serialVersionUID = 1L;
-
+@TableName("lobster_conversation_summary")
+public class LobsterConversationSummary {
     @TableId(type = IdType.AUTO)
     private Long id;
-
     private Long companyId;
-
-    private Long instanceId;
-
-    private Long contactId;
-
-    private String summaryType;
-
-    private String summaryContent;
-
-    private String keyPoints;
-
-    private String sentimentAnalysis;
-
-    private String nextActionSuggestion;
-
+    private String externalUserId;
+    private String sessionId;
+    private String summaryText;
+    private String keywords;
+    private String sentiment;
+    private String intentCategory;
     private Integer messageCount;
-
-    private Integer delFlag;
+    private LocalDateTime summaryTime;
+    private LocalDateTime createTime;
 }

+ 23 - 0
fs-service/src/main/java/com/fs/company/domain/LobsterDialogueState.java

@@ -0,0 +1,23 @@
+package com.fs.company.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("lobster_dialogue_state")
+public class LobsterDialogueState {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long companyId;
+    private Long instanceId;
+    private String nodeCode;
+
+    @TableField("state_json")
+    private String stateJson;
+
+    private LocalDateTime updateTime;
+}

+ 20 - 0
fs-service/src/main/java/com/fs/company/domain/LobsterEvolutionConfig.java

@@ -0,0 +1,20 @@
+package com.fs.company.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("lobster_evolution_config")
+public class LobsterEvolutionConfig {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long companyId;
+    private String configKey;
+    private String configValue;
+    private String description;
+    private LocalDateTime createTime;
+    private LocalDateTime updateTime;
+}

+ 20 - 0
fs-service/src/main/java/com/fs/company/domain/LobsterEvolutionHistory.java

@@ -0,0 +1,20 @@
+package com.fs.company.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("lobster_evolution_history")
+public class LobsterEvolutionHistory {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long companyId;
+    private String evolveType;
+    private String description;
+    private String detail;
+    private String status;
+    private LocalDateTime createTime;
+}

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

@@ -0,0 +1,25 @@
+package com.fs.company.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("lobster_evolution_log")
+public class LobsterEvolutionLog {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long companyId;
+    private Long workflowId;
+    private String actionType;
+    private String nodeCode;
+    private String beforeContent;
+    private String afterContent;
+    private String changeDesc;
+    private Double improvementRate;
+    private String status;
+    private LocalDateTime evolveTime;
+    private LocalDateTime createTime;
+}

+ 24 - 0
fs-service/src/main/java/com/fs/company/domain/LobsterEvolutionSuggestion.java

@@ -0,0 +1,24 @@
+package com.fs.company.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("lobster_evolution_suggestion")
+public class LobsterEvolutionSuggestion {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long companyId;
+    private Long workflowId;
+    private String nodeCode;
+    private String suggestionType;
+    private String suggestionContent;
+    private String originalContent;
+    private Double confidenceScore;
+    private String status;
+    private String createdBy;
+    private LocalDateTime createTime;
+}

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