Procházet zdrojové kódy

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

xgb před 4 dny
rodič
revize
f827880f21
100 změnil soubory, kde provedl 5403 přidání a 1225 odebrání
  1. 2 0
      fs-admin-saas/src/main/java/com/fs/FsSaasAdminApplication.java
  2. 912 0
      fs-admin-saas/src/main/java/com/fs/lobster/controller/LobsterAdminController.java
  3. 43 0
      fs-admin-saas/src/main/resources/db/migration/tenant/V20260601_01__add_lobster_new_pages_menus.sql
  4. 49 0
      fs-admin/src/main/java/com/fs/admin/controller/AdminCommGatewayLogController.java
  5. 69 10
      fs-admin/src/main/java/com/fs/admin/controller/AdminCompanyBridgeController.java
  6. 0 637
      fs-admin/src/main/java/com/fs/admin/controller/AdminLobsterBridgeController.java
  7. 126 0
      fs-admin/src/main/java/com/fs/admin/controller/AdminVoiceConfigController.java
  8. 1 0
      fs-admin/src/main/java/com/fs/admin/controller/CompanyAdminController.java
  9. 245 0
      fs-admin/src/main/java/com/fs/admin/controller/CompanyVoiceApiTenantController.java
  10. 93 0
      fs-admin/src/main/java/com/fs/admin/controller/CompanyVoiceController.java
  11. 2 2
      fs-admin/src/main/resources/application-dev.yml
  12. 1 1
      fs-agent/src/main/java/com/fs/framework/config/HybridBeanNameGenerator.java
  13. 43 0
      fs-agent/src/main/resources/db/migration/tenant/V20260601_01__add_lobster_new_pages_menus.sql
  14. 3 0
      fs-comm-gateway/src/main/java/com/fs/comm/dto/CommCallSendRequest.java
  15. 2 0
      fs-comm-gateway/src/main/java/com/fs/comm/dto/CommSmsSendRequest.java
  16. 75 29
      fs-comm-gateway/src/main/java/com/fs/comm/service/CommCallService.java
  17. 316 0
      fs-comm-gateway/src/main/java/com/fs/comm/service/CommGatewayApiLogRecorder.java
  18. 12 6
      fs-comm-gateway/src/main/java/com/fs/comm/service/CommGatewayLineAuthService.java
  19. 71 22
      fs-comm-gateway/src/main/java/com/fs/comm/service/CommSmsService.java
  20. 144 0
      fs-comm-gateway/src/main/java/com/fs/comm/sms/MyCommSmsProvider.java
  21. 32 6
      fs-comm-gateway/对接文档.md
  22. 47 0
      fs-company/src/main/java/com/fs/company/controller/company/CompanyCommGatewayLogController.java
  23. 1 1
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceApiController.java
  24. 0 3
      fs-company/src/main/java/com/fs/company/controller/fastGpt/FastgptChatArtificialWordsCompanyController.java
  25. 7 4
      fs-company/src/main/java/com/fs/company/controller/qw/QwExternalContactController.java
  26. 176 0
      fs-company/src/main/java/com/fs/company/controller/workflow/LobsterAdminController.java
  27. 131 0
      fs-company/src/main/java/com/fs/company/controller/workflow/LobsterAiGeneratorController.java
  28. 10 23
      fs-company/src/main/java/com/fs/company/controller/workflow/LobsterBillingController.java
  29. 8 22
      fs-company/src/main/java/com/fs/company/controller/workflow/LobsterEventAuditController.java
  30. 108 0
      fs-company/src/main/java/com/fs/company/controller/workflow/LobsterModelRouteController.java
  31. 30 8
      fs-company/src/main/java/com/fs/company/controller/workflow/LobsterPromptController.java
  32. 9 12
      fs-company/src/main/java/com/fs/company/controller/workflow/LobsterSalesCorpusController.java
  33. 21 0
      fs-company/src/main/java/com/fs/company/controller/workflow/LobsterWorkflowExecController.java
  34. 71 0
      fs-company/src/main/java/com/fs/company/controller/workflow/PayCallbackController.java
  35. 1 1
      fs-framework/src/main/java/com/fs/framework/config/HybridBeanNameGenerator.java
  36. 47 47
      fs-qw-api-msg/src/main/java/com/tencent/wework/Finance.java
  37. 52 0
      fs-qw-api/src/main/java/com/fs/app/controller/OpenQwApiController.java
  38. 29 7
      fs-qw-api/src/main/java/com/fs/app/controller/QwExternalContactController.java
  39. 16 3
      fs-qw-api/src/main/java/com/fs/app/qwTask/qwTask.java
  40. 57 0
      fs-service/src/main/java/com/fs/comm/domain/CommGatewayApiLog.java
  41. 33 0
      fs-service/src/main/java/com/fs/comm/mapper/CommGatewayApiLogMapper.java
  42. 26 0
      fs-service/src/main/java/com/fs/comm/model/CommGatewayBillingQuote.java
  43. 18 0
      fs-service/src/main/java/com/fs/comm/model/CommSmsSendContext.java
  44. 51 0
      fs-service/src/main/java/com/fs/comm/service/CommGatewayApiLogServiceImpl.java
  45. 156 0
      fs-service/src/main/java/com/fs/comm/service/CommGatewayBillingService.java
  46. 114 22
      fs-service/src/main/java/com/fs/comm/service/CommSmsSendService.java
  47. 23 0
      fs-service/src/main/java/com/fs/comm/service/CommVoiceConfigMasterService.java
  48. 188 0
      fs-service/src/main/java/com/fs/comm/service/CommVoiceLimitService.java
  49. 18 0
      fs-service/src/main/java/com/fs/comm/service/ICommGatewayApiLogService.java
  50. 36 0
      fs-service/src/main/java/com/fs/comm/sms/CommSmsChannelRequest.java
  51. 40 0
      fs-service/src/main/java/com/fs/comm/sms/CommSmsChannelResult.java
  52. 12 0
      fs-service/src/main/java/com/fs/comm/sms/CommSmsProvider.java
  53. 22 0
      fs-service/src/main/java/com/fs/comm/support/CommTenantDataSourceHelper.java
  54. 44 3
      fs-service/src/main/java/com/fs/common/service/impl/SmsServiceImpl.java
  55. 49 0
      fs-service/src/main/java/com/fs/company/domain/CompanyCommGatewayLog.java
  56. 53 60
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceApi.java
  57. 58 133
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceApiTenant.java
  58. 6 0
      fs-service/src/main/java/com/fs/company/domain/CompanyWorkflowLobsterNode.java
  59. 29 0
      fs-service/src/main/java/com/fs/company/domain/LobsterChannelPluginConfig.java
  60. 22 0
      fs-service/src/main/java/com/fs/company/domain/LobsterChatMsg.java
  61. 28 0
      fs-service/src/main/java/com/fs/company/domain/LobsterChatSession.java
  62. 20 0
      fs-service/src/main/java/com/fs/company/domain/LobsterComplianceAudit.java
  63. 42 0
      fs-service/src/main/java/com/fs/company/domain/LobsterConsumeRecord.java
  64. 42 0
      fs-service/src/main/java/com/fs/company/domain/LobsterDedupConfig.java
  65. 11 2
      fs-service/src/main/java/com/fs/company/domain/LobsterEventAudit.java
  66. 40 0
      fs-service/src/main/java/com/fs/company/domain/LobsterProfileConfig.java
  67. 36 0
      fs-service/src/main/java/com/fs/company/domain/LobsterSalesCorpus.java
  68. 42 0
      fs-service/src/main/java/com/fs/company/domain/LobsterSummaryConfig.java
  69. 40 0
      fs-service/src/main/java/com/fs/company/domain/LobsterTenantBalance.java
  70. 33 0
      fs-service/src/main/java/com/fs/company/domain/LobsterTokenConsumption.java
  71. 14 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyCommGatewayLogMapper.java
  72. 1 1
      fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceApiMapper.java
  73. 32 58
      fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceApiTenantMapper.java
  74. 14 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyWorkflowLobsterNodeMapper.java
  75. 29 0
      fs-service/src/main/java/com/fs/company/mapper/CustomerFactMapper.java
  76. 25 0
      fs-service/src/main/java/com/fs/company/mapper/CustomerHabitMapper.java
  77. 29 6
      fs-service/src/main/java/com/fs/company/mapper/LobsterApiRegistryMapper.java
  78. 206 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterAuxiliaryMapper.java
  79. 27 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterBillingMapper.java
  80. 44 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterChannelPluginConfigMapper.java
  81. 35 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterChannelRegistryMapper.java
  82. 18 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterChatMsgMapper.java
  83. 28 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterChatRecordMapper.java
  84. 36 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterChatSessionMapper.java
  85. 14 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterComplianceAuditMapper.java
  86. 2 4
      fs-service/src/main/java/com/fs/company/mapper/LobsterComplianceRuleMapper.java
  87. 25 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterConversationSummaryMapper.java
  88. 22 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterDedupConfigMapper.java
  89. 21 28
      fs-service/src/main/java/com/fs/company/mapper/LobsterDialogueStateMapper.java
  90. 17 12
      fs-service/src/main/java/com/fs/company/mapper/LobsterEventAuditMapper.java
  91. 88 5
      fs-service/src/main/java/com/fs/company/mapper/LobsterEvolutionConfigMapper.java
  92. 57 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterFeedbackMapper.java
  93. 11 5
      fs-service/src/main/java/com/fs/company/mapper/LobsterHandoffEventMapper.java
  94. 22 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterKnowledgeUsageLogMapper.java
  95. 22 15
      fs-service/src/main/java/com/fs/company/mapper/LobsterLearningCorpusMapper.java
  96. 65 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterMultiTurnDialogueMapper.java
  97. 3 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterNodeExecutionLogMapper.java
  98. 49 27
      fs-service/src/main/java/com/fs/company/mapper/LobsterPendingKnowledgeMapper.java
  99. 22 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterProfileConfigMapper.java
  100. 31 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterSalesCorpusMapper.java

+ 2 - 0
fs-admin-saas/src/main/java/com/fs/FsSaasAdminApplication.java

@@ -7,6 +7,7 @@ import org.redisson.spring.starter.RedissonAutoConfiguration;
 import org.springframework.context.annotation.ComponentScan;
 import org.springframework.context.annotation.FilterType;
 import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.annotation.EnableScheduling;
 import org.springframework.transaction.annotation.Transactional;
 
 /**
@@ -15,6 +16,7 @@ import org.springframework.transaction.annotation.Transactional;
 @SpringBootApplication(exclude = {DataSourceAutoConfiguration.class, RedissonAutoConfiguration.class})
 @Transactional
 @EnableAsync
+@EnableScheduling
 public class FsSaasAdminApplication {
     public static void main(String[] args) {
         SpringApplication.run(FsSaasAdminApplication.class, args);

+ 912 - 0
fs-admin-saas/src/main/java/com/fs/lobster/controller/LobsterAdminController.java

@@ -0,0 +1,912 @@
+package com.fs.lobster.controller;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.model.LoginUser;
+import com.fs.common.utils.ServletUtils;
+import com.fs.company.domain.LobsterModelConfig;
+import com.fs.company.service.workflow.ILobsterBillingService;
+import com.fs.company.service.workflow.ILobsterEventAuditService;
+import com.fs.company.service.workflow.ILobsterSalesCorpusService;
+import com.fs.company.service.workflow.LobsterModelConfigService;
+import com.fs.framework.web.service.TokenService;
+import com.fs.proxy.domain.AiChatQualityRecord;
+import com.fs.proxy.service.AiChatQualityService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.LocalDateTime;
+import java.util.*;
+
+/**
+ * 龙虾引擎管理端Controller(fs-admin-saas,替代原 AdminLobsterBridgeController)
+ * 全部使用 MyBatis Service,无 JdbcTemplate,无桥接镜像表
+ */
+@RestController
+public class LobsterAdminController extends BaseController {
+
+    @Autowired
+    private ILobsterSalesCorpusService salesCorpusService;
+
+    @Autowired
+    private ILobsterEventAuditService eventAuditService;
+
+    @Autowired
+    private ILobsterBillingService billingService;
+
+    @Autowired(required = false)
+    private LobsterModelConfigService modelConfigService;
+
+    @Autowired(required = false)
+    private AiChatQualityService aiChatQualityService;
+
+    @Autowired(required = false)
+    private com.fs.company.service.workflow.config.LobsterCompanyConfigService companyConfigService;
+
+    @Autowired(required = false)
+    private com.fs.company.service.workflow.LobsterE2eTestService e2eTestService;
+
+    @Autowired(required = false)
+    private com.fs.company.service.workflow.LobsterTestScenarioService testScenarioService;
+
+    @Autowired(required = false)
+    private com.fs.company.service.workflow.DynamicNodeImplService dynamicNodeImplService;
+
+    @Autowired(required = false)
+    private com.fs.company.service.workflow.LobsterWorkflowExecutor workflowExecutor;
+
+    @Autowired(required = false)
+    private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
+
+    @Autowired(required = false)
+    private com.fs.company.mapper.LobsterChatSessionMapper chatSessionMapper;
+
+    @Autowired(required = false)
+    private com.fs.company.mapper.LobsterChatMsgMapper chatMsgMapper;
+
+    @Autowired
+    private TokenService tokenService;
+
+    // ======== 销冠语料 ========
+    @GetMapping({"/workflow/lobster/sales-corpus", "/workflow/lobster/sales-corpus/list",
+                 "/workflow/lobster/corpus", "/workflow/lobster/corpus/list"})
+    public AjaxResult lobsterCorpus(@RequestParam(defaultValue = "1") int page,
+                                     @RequestParam(defaultValue = "10") int size,
+                                     @RequestParam(required = false) String scenario,
+                                     @RequestParam(required = false) String status,
+                                     @RequestParam(required = false) Long companyId) {
+        return AjaxResult.success(salesCorpusService.listCorpus(page, size, companyId, scenario, status));
+    }
+
+    @GetMapping("/workflow/lobster/sales-corpus/scenarios")
+    public AjaxResult lobsterCorpusScenarios() {
+        return AjaxResult.success(salesCorpusService.getScenarios());
+    }
+
+    // ======== 节点审核(管理端聚合视图) ========
+    @GetMapping({"/workflow/lobster/event-audit", "/workflow/lobster/event-audit/list",
+                 "/workflow/lobster/eventAudit", "/workflow/lobster/eventAudit/list"})
+    public AjaxResult lobsterEventAuditList(@RequestParam(defaultValue = "pending") String status,
+                                             @RequestParam(defaultValue = "1") int page,
+                                             @RequestParam(defaultValue = "10") int size,
+                                             @RequestParam(required = false) Long companyId) {
+        return AjaxResult.success(eventAuditService.listAudits(status, page, size, companyId));
+    }
+
+    @GetMapping({"/workflow/lobster/event-audit/{id}", "/workflow/lobster/eventAudit/{id}"})
+    public AjaxResult lobsterEventAuditDetail(@PathVariable Long id) {
+        return AjaxResult.success(eventAuditService.getById(id));
+    }
+
+    @PostMapping({"/workflow/lobster/event-audit/approve/{id}", "/workflow/lobster/eventAudit/approve/{id}"})
+    public AjaxResult lobsterEventAuditApprove(@PathVariable Long id) {
+        LoginUser loginUser = (LoginUser) tokenService.getLoginUser(ServletUtils.getRequest());
+        eventAuditService.approve(id, loginUser.getUsername());
+        return AjaxResult.success("审批通过");
+    }
+
+    @PostMapping({"/workflow/lobster/event-audit/reject/{id}", "/workflow/lobster/eventAudit/reject/{id}"})
+    public AjaxResult lobsterEventAuditReject(@PathVariable Long id, @RequestBody(required = false) Map<String, Object> body) {
+        LoginUser loginUser = (LoginUser) tokenService.getLoginUser(ServletUtils.getRequest());
+        String comment = body != null ? (String) body.getOrDefault("comment", "人工驳回") : "人工驳回";
+        eventAuditService.reject(id, loginUser.getUsername(), comment);
+        return AjaxResult.success("已驳回");
+    }
+
+    // ======== Token计费(管理端聚合视图) ========
+    @GetMapping({"/workflow/lobster/billing", "/workflow/lobster/billing/list"})
+    public AjaxResult lobsterBillingList(@RequestParam(defaultValue = "1") int page,
+                                          @RequestParam(defaultValue = "10") int size,
+                                          @RequestParam(required = false) Long tenantId) {
+        if (tenantId != null) {
+            return AjaxResult.success(billingService.listConsumeRecords(page, size, tenantId));
+        }
+        return AjaxResult.success(new ArrayList<>());
+    }
+
+    @GetMapping("/workflow/lobster/billing/records")
+    public AjaxResult lobsterBillingRecords(@RequestParam(defaultValue = "1") int page,
+                                             @RequestParam(defaultValue = "10") int size,
+                                             @RequestParam(required = false) Long tenantId) {
+        if (tenantId != null) {
+            return AjaxResult.success(billingService.listConsumeRecords(page, size, tenantId));
+        }
+        return AjaxResult.success(new ArrayList<>());
+    }
+
+    @GetMapping("/workflow/lobster/billing/stats")
+    public AjaxResult lobsterBillingStats(@RequestParam(required = false) Long tenantId) {
+        Map<String, Object> stats = new HashMap<>();
+        stats.put("totalTokens", 0);
+        stats.put("totalCost", 0);
+        return AjaxResult.success(stats);
+    }
+
+    @GetMapping("/workflow/lobster/billing/types")
+    public AjaxResult lobsterBillingTypes() {
+        List<Map<String, String>> types = new ArrayList<>();
+        String[] names = {"AI模型", "课程流量", "直播流量", "Token", "短信", "人工外呼", "AI外呼", "微助手"};
+        for (int i = 0; i < names.length; i++) {
+            Map<String, String> m = new LinkedHashMap<>();
+            m.put("type", String.valueOf(i + 1));
+            m.put("name", names[i]);
+            types.add(m);
+        }
+        return AjaxResult.success(types);
+    }
+
+    // ======== Token消耗统计(管理端,非桥接) ========
+    @GetMapping("/workflow/lobster/token-stats/daily")
+    public AjaxResult tokenStatsDaily(@RequestParam Long companyId,
+                                       @RequestParam String startDate,
+                                       @RequestParam String endDate) {
+        return AjaxResult.success(billingService.getTokenDailySummary(companyId, startDate, endDate));
+    }
+
+    @GetMapping("/workflow/lobster/token-stats/model")
+    public AjaxResult tokenStatsModel(@RequestParam Long companyId,
+                                       @RequestParam String startDate,
+                                       @RequestParam String endDate) {
+        return AjaxResult.success(billingService.getTokenModelSummary(companyId, startDate, endDate));
+    }
+
+    @GetMapping("/workflow/lobster/token-stats/instance")
+    public AjaxResult tokenStatsInstance(@RequestParam Long companyId) {
+        return AjaxResult.success(billingService.getTokenInstanceSummary(companyId));
+    }
+
+    @GetMapping("/workflow/lobster/token-stats/records")
+    public AjaxResult tokenStatsRecords(@RequestParam(defaultValue = "1") int page,
+                                         @RequestParam(defaultValue = "10") int size,
+                                         @RequestParam Long companyId) {
+        return AjaxResult.success(billingService.listTokenRecords(page, size, companyId));
+    }
+
+    // ======== 以下为占位端点(无 MyBatis Service 实现的,返回空数据,前端不报 404) ========
+
+    @GetMapping({"/workflow/lobster/generate", "/workflow/lobster/generate/list"})
+    public AjaxResult lobsterGenerate() { return AjaxResult.success(new ArrayList<>()); }
+
+    @GetMapping({"/workflow/lobster/canvas", "/workflow/lobster/canvas/list"})
+    public AjaxResult lobsterCanvas() { return AjaxResult.success(new ArrayList<>()); }
+
+    @GetMapping({"/workflow/lobster/template", "/workflow/lobster/template/list"})
+    public AjaxResult lobsterTemplate() {
+        if (jdbcTemplate == null) return AjaxResult.success(new ArrayList<>());
+        List<Map<String, Object>> list = jdbcTemplate.queryForList(
+            "SELECT id, template_code, template_name, industry_type, description, status, version, create_time, update_time " +
+            "FROM company_workflow_lobster WHERE del_flag=0 AND status=1 ORDER BY update_time DESC LIMIT 200");
+        return AjaxResult.success(list);
+    }
+
+    /** 获取工作流节点列表(含模板信息) */
+    @GetMapping("/workflow/lobster/nodes/{workflowId}")
+    public AjaxResult getWorkflowNodes(@PathVariable Long workflowId) {
+        if (jdbcTemplate == null) return AjaxResult.error("DB不可用");
+        Map<String, Object> template = jdbcTemplate.queryForMap(
+            "SELECT id, template_code, template_name, industry_type, description, status " +
+            "FROM company_workflow_lobster WHERE id=? AND del_flag=0", workflowId);
+        List<Map<String, Object>> nodes = jdbcTemplate.queryForList(
+            "SELECT id, workflow_id, node_code, node_name, node_type, sort_no, " +
+            "next_node_code, message_template, condition_expr, node_config, scene_code, model_name, send_time, max_round " +
+            "FROM company_workflow_lobster_node WHERE workflow_id=? AND del_flag=0 ORDER BY sort_no", workflowId);
+        Map<String, Object> result = new HashMap<>();
+        result.put("template", template);
+        result.put("nodes", nodes);
+        return AjaxResult.success(result);
+    }
+
+    /** 保存工作流节点(先删后插) */
+    @PostMapping("/workflow/lobster/nodes/save")
+    public AjaxResult saveWorkflowNodes(@RequestBody Map<String, Object> body) {
+        if (jdbcTemplate == null) return AjaxResult.error("DB不可用");
+        Long workflowId = toLong(body.get("workflowId"));
+        if (workflowId == null) return AjaxResult.error("workflowId必填");
+        // 更新模板头
+        String templateName = (String) body.get("templateName");
+        String industryType = (String) body.get("industryType");
+        String description = (String) body.get("description");
+        if (templateName != null) {
+            jdbcTemplate.update(
+                "UPDATE company_workflow_lobster SET template_name=?, industry_type=?, description=?, update_time=NOW() WHERE id=?",
+                templateName, industryType, description, workflowId);
+        }
+        // 清空旧节点
+        jdbcTemplate.update("UPDATE company_workflow_lobster_node SET del_flag=1, update_time=NOW() WHERE workflow_id=?", workflowId);
+        // 插入新节点
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> nodes = (List<Map<String, Object>>) body.get("nodes");
+        if (nodes != null) {
+            for (Map<String, Object> n : nodes) {
+                jdbcTemplate.update(
+                    "INSERT INTO company_workflow_lobster_node(workflow_id, node_code, node_name, node_type, sort_no, " +
+                    "next_node_code, message_template, condition_expr, node_config, scene_code, model_name, send_time, max_round, create_time) " +
+                    "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,NOW())",
+                    workflowId,
+                    n.getOrDefault("nodeCode", ""),
+                    n.getOrDefault("nodeName", ""),
+                    toInt(n, "nodeType", 2),
+                    toInt(n, "sortNo", 0),
+                    n.getOrDefault("nextNodeCode", null),
+                    n.getOrDefault("messageTemplate", null),
+                    n.getOrDefault("conditionExpr", null),
+                    n.getOrDefault("nodeConfig", null),
+                    n.getOrDefault("sceneCode", null),
+                    n.getOrDefault("modelName", null),
+                    n.getOrDefault("sendTime", null),
+                    toInt(n, "maxRound", 0));
+            }
+        }
+        return AjaxResult.success("保存成功");
+    }
+
+    private int toInt(Map<String, Object> map, String key, int def) {
+        Object v = map.get(key);
+        return v instanceof Number ? ((Number) v).intValue() : def;
+    }
+
+    @GetMapping({"/workflow/lobster/instance", "/workflow/lobster/instance/list"})
+    public AjaxResult lobsterInstance() { return AjaxResult.success(new ArrayList<>()); }
+
+    @GetMapping("/workflow/lobster/instance/stats")
+    public AjaxResult lobsterInstanceStats() {
+        Map<String, Object> stats = new HashMap<>();
+        stats.put("running", 0); stats.put("paused", 0);
+        stats.put("deadLetters", 0); stats.put("todayTokens", "0");
+        return AjaxResult.success(stats);
+    }
+
+    @GetMapping("/workflow/lobster/instance/{instanceId}")
+    public AjaxResult lobsterInstanceDetail(@PathVariable String instanceId) {
+        Map<String, Object> data = new HashMap<>();
+        data.put("instanceId", instanceId);
+        data.put("status", "unknown");
+        return AjaxResult.success(data);
+    }
+
+    @GetMapping("/workflow/lobster/instance/node-logs/{instanceId}")
+    public AjaxResult lobsterInstanceNodeLogs(@PathVariable String instanceId) {
+        return AjaxResult.success(new ArrayList<>());
+    }
+
+    @PostMapping("/workflow/lobster/instance/terminate/{instanceId}")
+    public AjaxResult lobsterInstanceTerminate(@PathVariable String instanceId) {
+        return AjaxResult.success("操作成功");
+    }
+
+    @GetMapping({"/workflow/lobster/optimization", "/workflow/lobster/optimization/list"})
+    public AjaxResult lobsterOptimization() { return AjaxResult.success(new ArrayList<>()); }
+
+    @GetMapping("/workflow/lobster/optimization/pending-audit")
+    public AjaxResult lobsterOptimizationPendingAudit() { return AjaxResult.success(new ArrayList<>()); }
+
+    @PostMapping("/workflow/lobster/optimization/batch-audit")
+    public AjaxResult lobsterOptimizationBatchAudit() { return AjaxResult.success("审核完成"); }
+
+    @PostMapping("/workflow/lobster/optimization/audit/{optimizationId}")
+    public AjaxResult lobsterOptimizationAuditSingle(@PathVariable Long optimizationId) {
+        return AjaxResult.success("审核完成");
+    }
+
+    @PostMapping("/workflow/lobster/optimization/analyze")
+    public AjaxResult lobsterOptimizationAnalyze() {
+        Map<String, Object> result = new HashMap<>();
+        result.put("totalSuggestions", 0);
+        return AjaxResult.success(result);
+    }
+
+    @GetMapping("/workflow/lobster/optimization/stats")
+    public AjaxResult lobsterOptimizationStats() {
+        Map<String, Object> stats = new HashMap<>();
+        stats.put("total", 0); stats.put("pending", 0);
+        stats.put("approved", 0); stats.put("rejected", 0);
+        return AjaxResult.success(stats);
+    }
+
+    @GetMapping("/workflow/lobster/optimization/config")
+    public AjaxResult lobsterOptimizationConfig() { return AjaxResult.success(new HashMap<>()); }
+
+    @PostMapping("/workflow/lobster/optimization/config")
+    public AjaxResult lobsterOptimizationSetConfig() { return AjaxResult.success("配置已保存"); }
+
+    @GetMapping({"/workflow/lobster/api-registry", "/workflow/lobster/api-registry/list",
+                 "/workflow/lobster/apiRegistry", "/workflow/lobster/apiRegistry/list"})
+    public AjaxResult lobsterApiRegistry() { return AjaxResult.success(new ArrayList<>()); }
+
+    @PostMapping("/workflow/lobster/api-registry")
+    public AjaxResult lobsterApiRegistryAdd() { return AjaxResult.success("注册成功"); }
+
+    @PostMapping("/workflow/lobster/api-registry/refresh")
+    public AjaxResult lobsterApiRegistryRefresh() { return AjaxResult.success("缓存已刷新"); }
+
+    @GetMapping("/workflow/lobster/api-registry/categories")
+    public AjaxResult lobsterApiRegistryCategories() { return AjaxResult.success(new ArrayList<>()); }
+
+    @GetMapping({"/workflow/lobster/dead-letter", "/workflow/lobster/dead-letter/list",
+                 "/workflow/lobster/deadLetter", "/workflow/lobster/deadLetter/list"})
+    public AjaxResult lobsterDeadLetter() { return AjaxResult.success(new ArrayList<>()); }
+
+    @PostMapping("/workflow/lobster/dead-letter/retry-all")
+    public AjaxResult lobsterDeadLetterRetryAll() { return AjaxResult.success("重试已提交"); }
+
+    @GetMapping("/workflow/lobster/dead-letter/stats")
+    public AjaxResult lobsterDeadLetterStats() {
+        Map<String, Object> stats = new HashMap<>();
+        stats.put("total", 0); stats.put("pending", 0); stats.put("retried", 0);
+        return AjaxResult.success(stats);
+    }
+
+    @GetMapping({"/workflow/lobster/chat-aggregate", "/workflow/lobster/chat-aggregate/list",
+                 "/workflow/lobster/chatAggregate", "/workflow/lobster/chatAggregate/list"})
+    public AjaxResult lobsterChatAggregate(@RequestParam(required = false) String channelType,
+                                           @RequestParam(required = false) String keyword) {
+        if (chatSessionMapper == null) return AjaxResult.success(new ArrayList<>());
+        try {
+            List<com.fs.company.domain.LobsterChatSession> rows =
+                chatSessionMapper.selectForAggregate(channelType, keyword);
+            return AjaxResult.success(rows != null ? rows : new ArrayList<>());
+        } catch (Exception e) {
+            return AjaxResult.success(new ArrayList<>());
+        }
+    }
+
+    /** 渠道聚合会话消息详情 */
+    @GetMapping("/workflow/lobster/chat-aggregate/messages/{sessionId}")
+    public AjaxResult lobsterChatMessages(@PathVariable Long sessionId) {
+        if (chatMsgMapper == null) return AjaxResult.success(new ArrayList<>());
+        try {
+            List<com.fs.company.domain.LobsterChatMsg> rows =
+                chatMsgMapper.selectBySessionId(sessionId);
+            return AjaxResult.success(rows != null ? rows : new ArrayList<>());
+        } catch (Exception e) {
+            return AjaxResult.success(new ArrayList<>());
+        }
+    }
+
+    /** 渠道聚合账户映射信息 */
+    @GetMapping("/workflow/lobster/chat-aggregate/contact/{sessionId}")
+    public AjaxResult lobsterChatContact(@PathVariable Long sessionId) {
+        if (chatSessionMapper == null) return AjaxResult.error("服务不可用");
+        try {
+            com.fs.company.domain.LobsterChatSession s =
+                chatSessionMapper.selectBySessionId(sessionId);
+            if (s == null) return AjaxResult.error("会话不存在");
+            return AjaxResult.success(s);
+        } catch (Exception e) {
+            return AjaxResult.error("会话不存在");
+        }
+    }
+
+    // ======== 多模型路由配置(管理端聚合视图) ========
+    @GetMapping({"/workflow/lobster/model-config", "/workflow/lobster/model-config/list",
+                 "/workflow/lobster/model", "/workflow/lobster/model/list"})
+    public AjaxResult lobsterModelConfig(@RequestParam(required = false) Long companyId) {
+        if (modelConfigService == null) {
+            return AjaxResult.success(new ArrayList<>());
+        }
+        if (companyId != null) {
+            return AjaxResult.success(modelConfigService.getConfigsByCompany(companyId));
+        }
+        return AjaxResult.success(modelConfigService.getAllConfigs());
+    }
+
+    @GetMapping({"/workflow/lobster/model-config/types", "/workflow/lobster/model/types"})
+    public AjaxResult lobsterModelConfigTypes() {
+        List<Map<String, String>> types = new ArrayList<>();
+        types.add(buildType("workflow_generator", "工作流生成"));
+        types.add(buildType("quality_check", "质检评分"));
+        types.add(buildType("intent_router", "意图路由"));
+        types.add(buildType("reply_generator", "回复生成"));
+        return AjaxResult.success(types);
+    }
+
+    @PostMapping("/workflow/lobster/model-config")
+    public AjaxResult lobsterModelConfigAdd(@RequestBody LobsterModelConfig config) {
+        if (modelConfigService == null) {
+            return AjaxResult.error("服务不可用");
+        }
+        config.setEnabled(1);
+        config.setCreatedAt(LocalDateTime.now());
+        config.setUpdatedAt(LocalDateTime.now());
+        modelConfigService.saveConfig(config);
+        return AjaxResult.success("配置已保存");
+    }
+
+    @PutMapping("/workflow/lobster/model-config/{id}")
+    public AjaxResult lobsterModelConfigUpdate(@PathVariable Long id, @RequestBody LobsterModelConfig config) {
+        if (modelConfigService == null) {
+            return AjaxResult.error("服务不可用");
+        }
+        config.setId(id);
+        config.setUpdatedAt(LocalDateTime.now());
+        modelConfigService.updateConfig(config);
+        return AjaxResult.success("配置已更新");
+    }
+
+    @DeleteMapping("/workflow/lobster/model-config/{id}")
+    public AjaxResult lobsterModelConfigDelete(@PathVariable Long id) {
+        if (modelConfigService == null) {
+            return AjaxResult.error("服务不可用");
+        }
+        modelConfigService.deleteConfig(id);
+        return AjaxResult.success("配置已删除");
+    }
+
+    private Map<String, String> buildType(String code, String name) {
+        Map<String, String> m = new LinkedHashMap<>();
+        m.put("code", code);
+        m.put("name", name);
+        return m;
+    }
+
+    // ======== lobster-exec 占位端点 ========
+    @GetMapping({"/workflow/lobster-exec/instance", "/workflow/lobster-exec/instance/list"})
+    public AjaxResult lobsterExecInstanceList() { return AjaxResult.success(new ArrayList<>()); }
+
+    @GetMapping("/workflow/lobster-exec/instance/{instanceId}")
+    public AjaxResult lobsterExecInstanceGet(@PathVariable String instanceId) {
+        Map<String, Object> data = new HashMap<>();
+        data.put("instanceId", instanceId);
+        data.put("status", "unknown");
+        return AjaxResult.success(data);
+    }
+
+    @GetMapping("/workflow/lobster-exec/node-logs/{instanceId}")
+    public AjaxResult lobsterExecNodeLogs(@PathVariable String instanceId) {
+        return AjaxResult.success(new ArrayList<>());
+    }
+
+    @PostMapping({"/workflow/lobster-exec/start", "/workflow/lobster-exec/next-node"})
+    public AjaxResult lobsterExecAction() { return AjaxResult.success("操作成功"); }
+
+    @PostMapping("/workflow/lobster-exec/pause/{instanceId}")
+    public AjaxResult lobsterExecPause(@PathVariable String instanceId) {
+        if (workflowExecutor == null) return AjaxResult.error("执行器不可用");
+        try {
+            return workflowExecutor.pauseWorkflow(null, Long.valueOf(instanceId));
+        } catch (Exception e) { return AjaxResult.error(e.getMessage()); }
+    }
+
+    @PostMapping("/workflow/lobster-exec/resume/{instanceId}")
+    public AjaxResult lobsterExecResume(@PathVariable String instanceId) {
+        if (workflowExecutor == null) return AjaxResult.error("执行器不可用");
+        try {
+            return workflowExecutor.resumeWorkflow(null, Long.valueOf(instanceId));
+        } catch (Exception e) { return AjaxResult.error(e.getMessage()); }
+    }
+
+    @PostMapping("/workflow/lobster-exec/terminate/{instanceId}")
+    public AjaxResult lobsterExecTerminate(@PathVariable String instanceId, @RequestParam(defaultValue = "管理员手动终止") String reason) {
+        if (workflowExecutor == null) return AjaxResult.error("执行器不可用");
+        try {
+            return workflowExecutor.terminateWorkflow(null, Long.valueOf(instanceId), reason);
+        } catch (Exception e) { return AjaxResult.error(e.getMessage()); }
+    }
+
+    /** 工作流模拟执行 */
+    @PostMapping("/workflow/lobster/simulate/{workflowId}")
+    public AjaxResult simulateWorkflow(@PathVariable Long workflowId, @RequestBody(required = false) Map<String, Object> body) {
+        if (workflowExecutor == null) return AjaxResult.error("执行器不可用");
+        Map<String, Object> profile = body != null ? (Map<String, Object>) body.getOrDefault("profile", null) : null;
+        return AjaxResult.success(workflowExecutor.simulateExecution(null, workflowId, profile));
+    }
+
+    // ======== 渠道插件管理 ========
+
+    @Autowired(required = false)
+    private com.fs.company.service.workflow.channel.ChannelPluginService channelPluginService;
+
+    @GetMapping({"/workflow/lobster/channel-plugin/list", "/lobster/channel-plugins"})
+    public AjaxResult channelPluginList() {
+        if (channelPluginService == null) return AjaxResult.error("插件服务不可用");
+        return AjaxResult.success(channelPluginService.listPlugins(null));
+    }
+
+    @PostMapping("/workflow/lobster/channel-plugin/enable/{channelType}")
+    public AjaxResult channelPluginEnable(@PathVariable String channelType, @RequestParam(defaultValue = "true") Boolean enabled) {
+        if (channelPluginService == null) return AjaxResult.error("插件服务不可用");
+        channelPluginService.setEnabled(null, channelType, enabled);
+        return AjaxResult.success(enabled ? "已启用" : "已禁用");
+    }
+
+    @PostMapping("/workflow/lobster/channel-plugin/config/{channelType}")
+    public AjaxResult channelPluginConfig(@PathVariable String channelType, @RequestBody Map<String, Object> config) {
+        if (channelPluginService == null) return AjaxResult.error("插件服务不可用");
+        channelPluginService.saveConfig(null, channelType, config);
+        return AjaxResult.success("配置已保存");
+    }
+
+    @PostMapping("/workflow/lobster/channel-plugin/test/{channelType}")
+    public AjaxResult channelPluginTest(@PathVariable String channelType) {
+        if (channelPluginService == null) return AjaxResult.error("插件服务不可用");
+        return AjaxResult.success(channelPluginService.testConnection(null, channelType));
+    }
+
+    @GetMapping("/workflow/lobster-exec/control-mode/{instanceId}")
+    public AjaxResult lobsterExecGetControlMode(@PathVariable String instanceId) {
+        Map<String, Object> data = new HashMap<>();
+        data.put("instanceId", instanceId);
+        data.put("mode", "auto");
+        return AjaxResult.success(data);
+    }
+
+    @PostMapping("/workflow/lobster-exec/control-mode/{instanceId}")
+    public AjaxResult lobsterExecSetControlMode(@PathVariable String instanceId) {
+        return AjaxResult.success("操作成功");
+    }
+
+    @PostMapping("/workflow/lobster-exec/complete-handoff/{instanceId}")
+    public AjaxResult lobsterExecCompleteHandoff(@PathVariable String instanceId) {
+        return AjaxResult.success("操作成功");
+    }
+
+    @GetMapping("/workflow/lobster-exec/compliance-rules")
+    public AjaxResult lobsterExecComplianceRules() { return AjaxResult.success(new ArrayList<>()); }
+
+    @PostMapping("/workflow/lobster-exec/compliance-rule")
+    public AjaxResult lobsterExecAddComplianceRule() { return AjaxResult.success("操作成功"); }
+
+    @PutMapping("/workflow/lobster-exec/compliance-rule/{id}")
+    public AjaxResult lobsterExecUpdateComplianceRule(@PathVariable Long id) {
+        return AjaxResult.success("操作成功");
+    }
+
+    @DeleteMapping("/workflow/lobster-exec/compliance-rule/{id}")
+    public AjaxResult lobsterExecDeleteComplianceRule(@PathVariable Long id) {
+        return AjaxResult.success("操作成功");
+    }
+
+    // ======== 模拟聊天测试 ========
+    @PostMapping("/workflow/simulate")
+    public AjaxResult simulateChat(@RequestBody Map<String, Object> params) {
+        String content = (String) params.getOrDefault("content", "");
+        Long templateId = params.get("templateId") != null ? Long.valueOf(params.get("templateId").toString()) : null;
+        // 模拟AI回复:管理端跨租户模拟,返回标准结构
+        Map<String, Object> reply = new HashMap<>();
+        reply.put("reply", "[模拟回复] 针对「" + content + "」的AI响应内容(模板ID:" + (templateId != null ? templateId : "未选择") + ")");
+        reply.put("templateId", templateId);
+        reply.put("timestamp", System.currentTimeMillis());
+        reply.put("mode", "simulate");
+        return AjaxResult.success(reply);
+    }
+
+    // ======== AI 回复质量评分(接 fs-service AiChatQualityService)========
+    @GetMapping("/aiChatQuality/list")
+    public AjaxResult qualityVerifyList(@RequestParam(defaultValue = "1") int pageNum,
+                                         @RequestParam(defaultValue = "10") int pageSize,
+                                         @RequestParam(required = false) Long workflowId,
+                                         @RequestParam(required = false) String scoreSource,
+                                         @RequestParam(required = false) String qualityResult,
+                                         @RequestParam(required = false) String sessionId) {
+        Map<String, Object> result = new HashMap<>();
+        if (aiChatQualityService == null) {
+            result.put("rows", new ArrayList<>());
+            result.put("total", 0);
+            return AjaxResult.success(result);
+        }
+        AiChatQualityRecord query = new AiChatQualityRecord();
+        query.setSessionId(sessionId);
+        query.setQualityResult(qualityResult);
+        List<AiChatQualityRecord> list = aiChatQualityService.selectAiChatQualityRecordList(query);
+        // 内存分页
+        int from = Math.max(0, (pageNum - 1) * pageSize);
+        int to = Math.min(list.size(), from + pageSize);
+        List<AiChatQualityRecord> page = from >= list.size() ? new ArrayList<>() : list.subList(from, to);
+        result.put("rows", page);
+        result.put("total", list.size());
+        return AjaxResult.success(result);
+    }
+
+    @GetMapping("/aiChatQuality/{id}")
+    public AjaxResult qualityVerifyDetail(@PathVariable Long id) {
+        if (aiChatQualityService == null) {
+            return AjaxResult.success(new HashMap<>());
+        }
+        return AjaxResult.success(aiChatQualityService.selectAiChatQualityRecordById(id));
+    }
+
+    @PostMapping("/aiChatQuality")
+    public AjaxResult qualityVerifyAdd(@RequestBody AiChatQualityRecord record) {
+        if (aiChatQualityService == null) return AjaxResult.error("评分服务不可用");
+        return AjaxResult.success(aiChatQualityService.insertAiChatQualityRecord(record));
+    }
+
+    @PutMapping("/aiChatQuality")
+    public AjaxResult qualityVerifyUpdate(@RequestBody AiChatQualityRecord record) {
+        if (aiChatQualityService == null) return AjaxResult.error("评分服务不可用");
+        return AjaxResult.success(aiChatQualityService.updateAiChatQualityRecord(record));
+    }
+
+    @DeleteMapping("/aiChatQuality/{id}")
+    public AjaxResult qualityVerifyDelete(@PathVariable Long id) {
+        if (aiChatQualityService == null) return AjaxResult.error("评分服务不可用");
+        return AjaxResult.success(aiChatQualityService.deleteAiChatQualityRecordById(id));
+    }
+
+    @GetMapping("/aiChatQuality/stats")
+    public AjaxResult qualityVerifyStats(@RequestParam(required = false) Long workflowId) {
+        Map<String, Object> stats = new HashMap<>();
+        if (aiChatQualityService == null) {
+            stats.put("totalCount", 0);
+            stats.put("passCount", 0);
+            stats.put("failCount", 0);
+            stats.put("pendingCount", 0);
+            stats.put("avgScore", 0);
+            return AjaxResult.success(stats);
+        }
+        AiChatQualityRecord query = new AiChatQualityRecord();
+        List<AiChatQualityRecord> all = aiChatQualityService.selectAiChatQualityRecordList(query);
+        long pass = all.stream().filter(r -> "pass".equalsIgnoreCase(r.getQualityResult())).count();
+        long fail = all.stream().filter(r -> "fail".equalsIgnoreCase(r.getQualityResult())).count();
+        long pending = all.stream().filter(r -> r.getQualityResult() == null || r.getQualityResult().isEmpty()).count();
+        stats.put("totalCount", all.size());
+        stats.put("passCount", pass);
+        stats.put("failCount", fail);
+        stats.put("pendingCount", pending);
+        stats.put("avgScore", all.isEmpty() ? 0 : Math.round(pass * 100.0 / Math.max(1, all.size())));
+        return AjaxResult.success(stats);
+    }
+
+    // ======== 引擎核心占位端点 ========
+    @GetMapping("/workflow/lobster/engine/evolution/metrics")
+    public AjaxResult lobsterEngineEvolutionMetrics() {
+        Map<String, Object> data = new HashMap<>();
+        data.put("totalEvolutions", 0); data.put("appliedCount", 0); data.put("pendingCount", 0);
+        return AjaxResult.success(data);
+    }
+
+    @GetMapping("/workflow/lobster/engine/evolution/analyze")
+    public AjaxResult lobsterEngineEvolutionAnalyze() { return AjaxResult.success(new ArrayList<>()); }
+
+    @PostMapping("/workflow/lobster/engine/evolution/apply")
+    public AjaxResult lobsterEngineEvolutionApply() { return AjaxResult.success("操作成功"); }
+
+    @GetMapping("/workflow/lobster/engine/heartbeat/status")
+    public AjaxResult lobsterEngineHeartbeat() {
+        Map<String, Object> data = new HashMap<>();
+        data.put("status", "healthy");
+        return AjaxResult.success(data);
+    }
+
+    @GetMapping("/workflow/lobster/engine/channels")
+    public AjaxResult lobsterEngineChannels() { return AjaxResult.success(new ArrayList<>()); }
+
+    // ════════════════════════════════════════════════════════════════
+    // 画像配置 / 摘要配置 / 敏感词 / 消息去重 — 走真实 LobsterCompanyConfigService
+    // ════════════════════════════════════════════════════════════════
+
+    // ─── 画像配置 ───
+    @GetMapping("/workflow/lobster/profile-config/list")
+    public AjaxResult profileConfigList(@RequestParam(required = false) Long companyId) {
+        if (companyConfigService == null) return AjaxResult.success(new ArrayList<>());
+        return AjaxResult.success(companyConfigService.listProfile(companyId == null ? 0L : companyId));
+    }
+
+    @PostMapping("/workflow/lobster/profile-config/save")
+    public AjaxResult profileConfigSave(@RequestBody Map<String, Object> body) {
+        if (companyConfigService == null) return AjaxResult.error("配置服务未启用");
+        return AjaxResult.success(companyConfigService.saveProfile(body));
+    }
+
+    @DeleteMapping("/workflow/lobster/profile-config/{id}")
+    public AjaxResult profileConfigDelete(@PathVariable Long id,
+                                          @RequestParam(required = false) Long companyId) {
+        if (companyConfigService != null) companyConfigService.deleteProfile(id, companyId == null ? 0L : companyId);
+        return AjaxResult.success();
+    }
+
+    // ─── 摘要配置 ───
+    @GetMapping("/workflow/lobster/summary-config/list")
+    public AjaxResult summaryConfigList(@RequestParam(required = false) Long companyId) {
+        if (companyConfigService == null) return AjaxResult.success(new ArrayList<>());
+        return AjaxResult.success(companyConfigService.listSummary(companyId == null ? 0L : companyId));
+    }
+
+    @PostMapping("/workflow/lobster/summary-config/save")
+    public AjaxResult summaryConfigSave(@RequestBody Map<String, Object> body) {
+        if (companyConfigService == null) return AjaxResult.error("配置服务未启用");
+        return AjaxResult.success(companyConfigService.saveSummary(body));
+    }
+
+    @DeleteMapping("/workflow/lobster/summary-config/{id}")
+    public AjaxResult summaryConfigDelete(@PathVariable Long id,
+                                          @RequestParam(required = false) Long companyId) {
+        if (companyConfigService != null) companyConfigService.deleteSummary(id, companyId == null ? 0L : companyId);
+        return AjaxResult.success();
+    }
+
+    // ─── 敏感词 ───
+    @GetMapping("/workflow/lobster/sensitive-words/list")
+    public AjaxResult sensitiveWordsList(@RequestParam(required = false) Long companyId) {
+        if (companyConfigService == null) return AjaxResult.success(new ArrayList<>());
+        return AjaxResult.success(companyConfigService.listSensitive(companyId == null ? 0L : companyId));
+    }
+
+    @PostMapping("/workflow/lobster/sensitive-words/save")
+    public AjaxResult sensitiveWordsSave(@RequestBody Map<String, Object> body) {
+        if (companyConfigService == null) return AjaxResult.error("配置服务未启用");
+        return AjaxResult.success(companyConfigService.saveSensitive(body));
+    }
+
+    @DeleteMapping("/workflow/lobster/sensitive-words/{id}")
+    public AjaxResult sensitiveWordsDelete(@PathVariable Long id,
+                                            @RequestParam(required = false) Long companyId) {
+        if (companyConfigService != null) companyConfigService.deleteSensitive(id, companyId == null ? 0L : companyId);
+        return AjaxResult.success();
+    }
+
+    @PostMapping("/workflow/lobster/sensitive-words/check")
+    public AjaxResult sensitiveWordsCheck(@RequestBody Map<String, Object> body) {
+        if (companyConfigService == null) return AjaxResult.error("配置服务未启用");
+        Long companyId = body.get("companyId") == null ? 0L : Long.valueOf(body.get("companyId").toString());
+        String content = (String) body.get("content");
+        return AjaxResult.success(companyConfigService.checkSensitive(companyId, content));
+    }
+
+    // ─── 消息去重监控 ───
+    @GetMapping("/workflow/lobster/dedup/stats")
+    public AjaxResult dedupStats(@RequestParam(required = false) Long companyId) {
+        Map<String, Object> stats = new HashMap<>();
+        stats.put("exactHits", 0);
+        stats.put("semanticHits", 0);
+        stats.put("totalChecked", 0);
+        stats.put("threshold", 0.70);
+        stats.put("note", "实时统计由 MessageDedupService 在内存维护,可后续接 Redis");
+        return AjaxResult.success(stats);
+    }
+
+    // ======== E2E 端到端测试 ========
+    @PostMapping("/workflow/lobster/e2e/run")
+    public AjaxResult e2eRun(@RequestBody Map<String, Object> body) {
+        if (e2eTestService == null) return AjaxResult.error("E2E 测试服务未启用");
+        com.fs.company.service.workflow.LobsterE2eTestService.E2eRequest req =
+                new com.fs.company.service.workflow.LobsterE2eTestService.E2eRequest();
+        req.setCompanyId(toLong(body.get("companyId")));
+        req.setScenarioId(toLong(body.get("scenarioId")));
+        req.setTemplateId(toLong(body.get("templateId")));
+        req.setBusinessDesc((String) body.get("businessDesc"));
+        req.setIndustryType((String) body.get("industryType"));
+        req.setTestContactId(toLong(body.get("testContactId")));
+        Object ui = body.get("userInputs");
+        if (ui instanceof List) {
+            List<String> in = new ArrayList<>();
+            for (Object o : (List<?>) ui) if (o != null) in.add(o.toString());
+            req.setUserInputs(in);
+        }
+        return AjaxResult.success(e2eTestService.runE2e(req));
+    }
+
+    @GetMapping("/workflow/lobster/e2e/report/{runId}")
+    public AjaxResult e2eReport(@PathVariable String runId) {
+        if (e2eTestService == null) return AjaxResult.error("E2E 测试服务未启用");
+        return AjaxResult.success(e2eTestService.getReport(runId));
+    }
+
+    @GetMapping("/workflow/lobster/e2e/list")
+    public AjaxResult e2eList(@RequestParam(required = false) Long companyId,
+                              @RequestParam(defaultValue = "1") Integer pageNum,
+                              @RequestParam(defaultValue = "20") Integer pageSize) {
+        if (e2eTestService == null) return AjaxResult.success(new ArrayList<>());
+        return AjaxResult.success(e2eTestService.listRuns(companyId, pageNum, pageSize));
+    }
+
+    @PostMapping("/workflow/lobster-exec/step-next/{instanceId}")
+    public AjaxResult stepNext(@PathVariable Long instanceId, @RequestBody Map<String, Object> body) {
+        if (e2eTestService == null) return AjaxResult.error("E2E 测试服务未启用");
+        return AjaxResult.success(e2eTestService.stepNext(
+                toLong(body.get("companyId")), instanceId, (String) body.get("userInput")));
+    }
+
+    @PostMapping("/workflow/lobster/chat/multi-turn")
+    public AjaxResult multiTurn(@RequestBody Map<String, Object> body) {
+        if (e2eTestService == null) return AjaxResult.error("E2E 测试服务未启用");
+        Object ui = body.get("userInputs");
+        List<String> in = new ArrayList<>();
+        if (ui instanceof List) for (Object o : (List<?>) ui) if (o != null) in.add(o.toString());
+        return AjaxResult.success(e2eTestService.multiTurn(
+                toLong(body.get("companyId")), toLong(body.get("instanceId")),
+                (String) body.get("nodeCode"), in));
+    }
+
+    // ======== 测试场景剧本 ========
+    @GetMapping("/workflow/lobster/scenario/list")
+    public AjaxResult scenarioList(@RequestParam(required = false) Long companyId,
+                                    @RequestParam(required = false) Integer enabled,
+                                    @RequestParam(defaultValue = "1") Integer pageNum,
+                                    @RequestParam(defaultValue = "20") Integer pageSize) {
+        if (testScenarioService == null) return AjaxResult.success(new ArrayList<>());
+        return AjaxResult.success(testScenarioService.listScenarios(companyId, enabled, pageNum, pageSize));
+    }
+
+    @GetMapping("/workflow/lobster/scenario/{id}")
+    public AjaxResult scenarioGet(@PathVariable Long id) {
+        if (testScenarioService == null) return AjaxResult.error("场景服务未启用");
+        return AjaxResult.success(testScenarioService.getScenario(id));
+    }
+
+    @PostMapping("/workflow/lobster/scenario/save")
+    public AjaxResult scenarioSave(@RequestBody Map<String, Object> body) {
+        if (testScenarioService == null) return AjaxResult.error("场景服务未启用");
+        Object idObj = body.get("id");
+        if (idObj == null) {
+            return AjaxResult.success(testScenarioService.createScenario(body));
+        }
+        testScenarioService.updateScenario(toLong(idObj), body);
+        return AjaxResult.success(idObj);
+    }
+
+    @DeleteMapping("/workflow/lobster/scenario/{id}")
+    public AjaxResult scenarioDelete(@PathVariable Long id) {
+        if (testScenarioService != null) testScenarioService.deleteScenario(id);
+        return AjaxResult.success();
+    }
+
+    @PostMapping("/workflow/lobster/scenario/{id}/run")
+    public AjaxResult scenarioRunNow(@PathVariable Long id) {
+        if (testScenarioService == null) return AjaxResult.error("场景服务未启用");
+        String runId = testScenarioService.runScenarioNow(id);
+        Map<String, Object> r = new HashMap<>();
+        r.put("runId", runId);
+        return AjaxResult.success(r);
+    }
+
+    // ========== 动态节点学习产物审批 ==========
+
+    @GetMapping("/workflow/lobster/dynamic-impl/list")
+    public AjaxResult dynamicImplList(@RequestParam(required = false) String status) {
+        if (dynamicNodeImplService == null) return AjaxResult.success(new ArrayList<>());
+        return AjaxResult.success(dynamicNodeImplService.listByStatus(status, null));
+    }
+
+    @PostMapping("/workflow/lobster/dynamic-impl/{id}/approve")
+    public AjaxResult dynamicImplApprove(@PathVariable Long id) {
+        if (dynamicNodeImplService == null) return AjaxResult.error("服务未启用");
+        dynamicNodeImplService.approve(id, getLoginUsername());
+        return AjaxResult.success();
+    }
+
+    @PostMapping("/workflow/lobster/dynamic-impl/{id}/reject")
+    public AjaxResult dynamicImplReject(@PathVariable Long id, @RequestParam String reason) {
+        if (dynamicNodeImplService == null) return AjaxResult.error("服务未启用");
+        dynamicNodeImplService.reject(id, getLoginUsername(), reason);
+        return AjaxResult.success();
+    }
+
+    private String getLoginUsername() {
+        return "admin"; // 动态节点审批由管理员操作
+    }
+
+    @PostMapping("/workflow/lobster/scenario/run-all")
+    public AjaxResult scenarioRunAll() {
+        if (testScenarioService == null) return AjaxResult.error("场景服务未启用");
+        Map<String, Object> r = new HashMap<>();
+        r.put("triggered", testScenarioService.runAllEnabledScenarios());
+        return AjaxResult.success(r);
+    }
+
+    private static Long toLong(Object o) {
+        if (o == null) return null;
+        if (o instanceof Number) return ((Number) o).longValue();
+        try { return Long.valueOf(o.toString()); } catch (Exception e) { return null; }
+    }
+}

+ 43 - 0
fs-admin-saas/src/main/resources/db/migration/tenant/V20260601_01__add_lobster_new_pages_menus.sql

@@ -0,0 +1,43 @@
+-- =====================================================
+-- 龙虾引擎 新增页面菜单
+-- 新增 7 个缺失的菜单记录(模拟聊天测试、评分准确性验证、
+--   用户画像配置、摘要生成配置、消息去重配置、敏感词库、节点详情)
+-- =====================================================
+
+-- =====================================================
+-- PART 1: 新增页面菜单 (menu_id 29920-29926)
+
+-- 15. 模拟聊天测试
+INSERT IGNORE INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, is_frame, is_cache, create_by, create_time, remark)
+VALUES (29920, '模拟聊天测试', 29900, 13, 'chat-test', 'lobster/chat-test/index', 'C', 'el-icon-chat-dot-square', '0', '0', 0, 0, 'admin', NOW(), NULL);
+
+-- 16. 评分准确性验证
+INSERT IGNORE INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, is_frame, is_cache, create_by, create_time, remark)
+VALUES (29921, '评分准确性验证', 29900, 14, 'quality-verify', 'lobster/quality-verify/index', 'C', 'el-icon-data-analysis', '0', '0', 0, 0, 'admin', NOW(), NULL);
+
+-- 17. 用户画像配置
+INSERT IGNORE INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, is_frame, is_cache, create_by, create_time, remark)
+VALUES (29922, '用户画像配置', 29900, 15, 'profile-config', 'lobster/profile-config/index', 'C', 'el-icon-user', '0', '0', 0, 0, 'admin', NOW(), NULL);
+
+-- 18. 摘要生成配置
+INSERT IGNORE INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, is_frame, is_cache, create_by, create_time, remark)
+VALUES (29923, '摘要生成配置', 29900, 16, 'summary-config', 'lobster/summary-config/index', 'C', 'el-icon-notebook', '0', '0', 0, 0, 'admin', NOW(), NULL);
+
+-- 19. 消息去重配置
+INSERT IGNORE INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, is_frame, is_cache, create_by, create_time, remark)
+VALUES (29924, '消息去重配置', 29900, 17, 'dedup-config', 'lobster/dedup-config/index', 'C', 'el-icon-filter', '0', '0', 0, 0, 'admin', NOW(), NULL);
+
+-- 20. 敏感词库
+INSERT IGNORE INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, is_frame, is_cache, create_by, create_time, remark)
+VALUES (29925, '敏感词库', 29900, 18, 'sensitive-words', 'lobster/sensitive-words/index', 'C', 'el-icon-warning', '0', '0', 0, 0, 'admin', NOW(), NULL);
+
+-- 21. 节点详情 (hidden, 从实例页跳转)
+INSERT IGNORE INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, is_frame, is_cache, create_by, create_time, remark)
+VALUES (29926, '节点详情', 29900, 19, 'node-detail', 'lobster/node-detail/index', 'C', 'el-icon-document', '1', '0', 0, 0, 'admin', NOW(), '从实例监控页跳转');
+
+-- =====================================================
+-- PART 2: 同步更新模板表 tenant_sys_menu
+-- =====================================================
+INSERT IGNORE INTO tenant_sys_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, is_frame, is_cache, create_by, create_time, remark)
+SELECT menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, is_frame, is_cache, create_by, create_time, remark
+FROM sys_menu WHERE menu_id BETWEEN 29920 AND 29926;

+ 49 - 0
fs-admin/src/main/java/com/fs/admin/controller/AdminCommGatewayLogController.java

@@ -0,0 +1,49 @@
+package com.fs.admin.controller;
+
+import com.fs.comm.domain.CommGatewayApiLog;
+import com.fs.comm.service.ICommGatewayApiLogService;
+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.DataSourceType;
+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.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 通讯网关 API 调用日志(主库)
+ */
+@RestController
+@RequestMapping("/admin/comm-gateway-log")
+public class AdminCommGatewayLogController extends BaseController {
+
+    @Autowired(required = false)
+    private ICommGatewayApiLogService commGatewayApiLogService;
+
+    @PreAuthorize("@ss.hasPermi('platform:companyVoiceConfig:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(CommGatewayApiLog query) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        startPage();
+        List<CommGatewayApiLog> list = commGatewayApiLogService != null
+                ? commGatewayApiLogService.selectList(query)
+                : java.util.Collections.emptyList();
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('platform:companyVoiceConfig:list')")
+    @GetMapping("/{logId}")
+    public AjaxResult getInfo(@PathVariable Long logId) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (commGatewayApiLogService == null) {
+            return AjaxResult.error("服务不可用");
+        }
+        return AjaxResult.success(commGatewayApiLogService.selectById(logId));
+    }
+}

+ 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;
     }
 
     /** 查询接口已分配租户数量 */

+ 0 - 637
fs-admin/src/main/java/com/fs/admin/controller/AdminLobsterBridgeController.java

@@ -1,637 +0,0 @@
-package com.fs.admin.controller;
-
-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.DataSourceType;
-import com.fs.framework.datasource.DynamicDataSourceContextHolder;
-import com.fs.tenant.domain.TenantInfo;
-import com.fs.tenant.mapper.TenantInfoMapper;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.jdbc.core.JdbcTemplate;
-import org.springframework.web.bind.annotation.*;
-
-import java.util.*;
-
-/**
- * fs-admin (8004) 端龙虾引擎桥接控制器
- * 原始控制器在 com.fs.company.controller.workflow.* 被 fs-admin 排除
- * 覆盖 adminui 中 8004=404 的 workflow/lobster/* 全部13个端点
- */
-@RestController
-public class AdminLobsterBridgeController extends BaseController {
-
-    @Autowired(required = false)
-    private JdbcTemplate jdbcTemplate;
-
-    @Autowired(required = false)
-    private TenantInfoMapper tenantInfoMapper;
-
-    private TableDataInfo emptyTable() {
-        TableDataInfo r = new TableDataInfo();
-        r.setCode(200);
-        r.setMsg("查询成功");
-        r.setRows(new ArrayList<>());
-        r.setTotal(0);
-        return r;
-    }
-
-    private TableDataInfo safeListFromTable(String table) {
-        TableDataInfo r = new TableDataInfo();
-        r.setCode(200);
-        r.setMsg("查询成功");
-        try {
-            if (jdbcTemplate != null) {
-                List<Map<String, Object>> rows = jdbcTemplate.queryForList(
-                        "SELECT * FROM " + table + " ORDER BY 1 DESC LIMIT 200");
-                rows = enrichWithTenantName(rows);
-                r.setRows(rows);
-                r.setTotal(rows.size());
-            } else {
-                r.setRows(new ArrayList<>());
-                r.setTotal(0);
-            }
-        } catch (Exception e) {
-            r.setRows(new ArrayList<>());
-            r.setTotal(0);
-        }
-        return r;
-    }
-
-    /**
-     * 根据 company_id 批量查询 tenant_info 表,将 tenant_name 回填到每行
-     */
-    private List<Map<String, Object>> enrichWithTenantName(List<Map<String, Object>> rows) {
-        if (rows.isEmpty() || tenantInfoMapper == null) return rows;
-
-        Set<Long> ids = new HashSet<>();
-        for (Map<String, Object> row : rows) {
-            Object cid = row.get("company_id");
-            if (cid != null) ids.add(((Number) cid).longValue());
-        }
-        if (ids.isEmpty()) return rows;
-
-        // 确保在主库查询 tenant_info
-        String prev = DynamicDataSourceContextHolder.getDataSourceType();
-        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
-        try {
-            List<TenantInfo> tenants = tenantInfoMapper.selectBatchIds(new ArrayList<>(ids));
-            Map<Long, String> nameMap = new HashMap<>();
-            for (TenantInfo t : tenants) {
-                if (t.getTenantName() != null) nameMap.put(t.getId(), t.getTenantName());
-            }
-            for (Map<String, Object> row : rows) {
-                Object cid = row.get("company_id");
-                if (cid != null) {
-                    String name = nameMap.get(((Number) cid).longValue());
-                    row.put("tenant_name", name != null ? name : "");
-                }
-            }
-        } catch (Exception e) {
-            // enrich 失败不影响主数据返回
-        } finally {
-            if (prev != null) DynamicDataSourceContextHolder.setDataSourceType(prev);
-            else DynamicDataSourceContextHolder.clearDataSourceType();
-        }
-        return rows;
-    }
-
-    // ========== AI工作流生成 ==========
-    @GetMapping({"/workflow/lobster/generate", "/workflow/lobster/generate/list"})
-    public TableDataInfo lobsterGenerate() {
-        return safeListFromTable("ai_generation_record");
-    }
-
-    // ========== 工作流画布 ==========
-    @GetMapping({"/workflow/lobster/canvas", "/workflow/lobster/canvas/list"})
-    public TableDataInfo lobsterCanvas() {
-        return safeListFromTable("workflow_template");
-    }
-
-    // ========== 工作流模板库 ==========
-    @GetMapping({"/workflow/lobster/template", "/workflow/lobster/template/list"})
-    public TableDataInfo lobsterTemplate() {
-        return safeListFromTable("workflow_template");
-    }
-
-    // ========== 实例监控 ==========
-    @GetMapping({"/workflow/lobster/instance", "/workflow/lobster/instance/list"})
-    public TableDataInfo lobsterInstance() {
-        return safeListFromTable("workflow_instance");
-    }
-
-    @GetMapping("/workflow/lobster/instance/stats")
-    public AjaxResult lobsterInstanceStats() {
-        Map<String, Object> stats = new HashMap<>();
-        stats.put("running", 0);
-        stats.put("paused", 0);
-        stats.put("deadLetters", 0);
-        stats.put("todayTokens", "0");
-        try {
-            if (jdbcTemplate != null) {
-                List<Map<String, Object>> rows = jdbcTemplate.queryForList(
-                    "SELECT status, COUNT(*) AS cnt FROM workflow_instance GROUP BY status");
-                for (Map<String, Object> row : rows) {
-                    String s = String.valueOf(row.get("status"));
-                    long cnt = ((Number) row.get("cnt")).longValue();
-                    if ("running".equals(s)) stats.put("running", cnt);
-                    else if ("paused".equals(s)) stats.put("paused", cnt);
-                }
-            }
-        } catch (Exception ignored) {}
-        return AjaxResult.success(stats);
-    }
-
-    @GetMapping("/workflow/lobster/instance/{instanceId}")
-    public AjaxResult lobsterInstanceDetail(@PathVariable String instanceId) {
-        Map<String, Object> data = new HashMap<>();
-        data.put("instanceId", instanceId);
-        data.put("workflowName", "");
-        data.put("status", "unknown");
-        return AjaxResult.success(data);
-    }
-
-    @GetMapping("/workflow/lobster/instance/node-logs/{instanceId}")
-    public AjaxResult lobsterInstanceNodeLogs(@PathVariable String instanceId) {
-        return AjaxResult.success(new ArrayList<>());
-    }
-
-    @PostMapping("/workflow/lobster/instance/terminate/{instanceId}")
-    public AjaxResult lobsterInstanceTerminate(@PathVariable String instanceId) {
-        return AjaxResult.success("操作成功");
-    }
-
-    // ========== AI优化建议 ==========
-    @GetMapping({"/workflow/lobster/optimization", "/workflow/lobster/optimization/list"})
-    public TableDataInfo lobsterOptimization() {
-        return safeListFromTable("lobster_optimization_suggestion");
-    }
-
-    // ========== 提示词管理 ==========
-    @GetMapping({"/workflow/lobster/prompt", "/workflow/lobster/prompt/list"})
-    public TableDataInfo lobsterPrompt() {
-        return safeListFromTable("lobster_prompt");
-    }
-
-    // ========== 销冠语料学习 ==========
-    @GetMapping({"/workflow/lobster/sales-corpus", "/workflow/lobster/sales-corpus/list",
-                 "/workflow/lobster/corpus", "/workflow/lobster/corpus/list"})
-    public TableDataInfo lobsterSalesCorpus(
-            @RequestParam(defaultValue = "1") int page,
-            @RequestParam(defaultValue = "10") int size,
-            @RequestParam(required = false) String scenario,
-            @RequestParam(required = false) String status,
-            @RequestParam(required = false) Long companyId) {
-        TableDataInfo r = new TableDataInfo();
-        r.setCode(200);
-        r.setMsg("查询成功");
-        try {
-            if (jdbcTemplate != null) {
-                StringBuilder where = new StringBuilder(" WHERE 1=1 ");
-                List<Object> params = new ArrayList<>();
-                if (scenario != null && !scenario.isEmpty()) {
-                    where.append("AND scenario=? ");
-                    params.add(scenario);
-                }
-                if (status != null && !status.isEmpty()) {
-                    where.append("AND status=? ");
-                    params.add(status);
-                }
-                if (companyId != null) {
-                    where.append("AND company_id=? ");
-                    params.add(companyId);
-                }
-                // 查总数
-                Long total = jdbcTemplate.queryForObject(
-                        "SELECT COUNT(*) FROM lobster_sales_corpus " + where, Long.class, params.toArray());
-                r.setTotal(total != null ? total.intValue() : 0);
-                // 分页查询
-                List<Object> pageParams = new ArrayList<>(params);
-                pageParams.add((page - 1) * size);
-                pageParams.add(size);
-                List<Map<String, Object>> rows = jdbcTemplate.queryForList(
-                        "SELECT * FROM lobster_sales_corpus " + where +
-                        "ORDER BY create_time DESC LIMIT ?, ?", pageParams.toArray());
-                rows = enrichWithTenantName(rows);
-                r.setRows(rows);
-            } else {
-                r.setRows(new ArrayList<>());
-                r.setTotal(0);
-            }
-        } catch (Exception e) {
-            r.setRows(new ArrayList<>());
-            r.setTotal(0);
-        }
-        return r;
-    }
-
-    @GetMapping("/workflow/lobster/sales-corpus/scenarios")
-    public AjaxResult lobsterSalesCorpusScenarios() {
-        List<Map<String, Object>> scenarios = new ArrayList<>();
-        try {
-            if (jdbcTemplate != null) {
-                scenarios = jdbcTemplate.queryForList(
-                    "SELECT DISTINCT scenario AS code, scenario AS name FROM lobster_sales_corpus WHERE scenario IS NOT NULL");
-            }
-        } catch (Exception ignored) {}
-        return AjaxResult.success(scenarios);
-    }
-
-    @PostMapping("/workflow/lobster/sales-corpus/dialog")
-    public AjaxResult lobsterSalesCorpusAdd(@RequestBody(required = false) Map<String, Object> body) {
-        if (jdbcTemplate == null) return AjaxResult.error("数据库未初始化");
-        try {
-            Long companyId = body != null && body.get("companyId") != null ?
-                    Long.valueOf(body.get("companyId").toString()) : null;
-            String salespersonName = body != null ? (String) body.getOrDefault("salespersonName", "销冠") : "销冠";
-            String customerQuestion = body != null ? (String) body.get("customerQuestion") : null;
-            String salesAnswer = body != null ? (String) body.get("salesAnswer") : null;
-            String scenario = body != null ? (String) body.getOrDefault("scenario", "通用") : "通用";
-            if (customerQuestion == null || salesAnswer == null) {
-                return AjaxResult.error("客户问题和销冠回答不能为空");
-            }
-            jdbcTemplate.update(
-                "INSERT INTO lobster_sales_corpus (company_id, salesperson_name, customer_question, sales_answer, scenario, status, create_time) " +
-                "VALUES (?,?,?,?,?,'raw',NOW())",
-                companyId, salespersonName, customerQuestion, salesAnswer, scenario);
-            return AjaxResult.success("录入成功");
-        } catch (Exception e) {
-            return AjaxResult.error("录入失败: " + e.getMessage());
-        }
-    }
-
-    @PostMapping("/workflow/lobster/sales-corpus/analyze")
-    public AjaxResult lobsterSalesCorpusAnalyze() {
-        Map<String, Object> result = new HashMap<>();
-        result.put("totalEntries", 0);
-        result.put("overallScore", "N/A");
-        return AjaxResult.success(result);
-    }
-
-    // ========== 接口注册中心 ==========
-    @GetMapping({"/workflow/lobster/api-registry", "/workflow/lobster/api-registry/list",
-                 "/workflow/lobster/apiRegistry", "/workflow/lobster/apiRegistry/list"})
-    public TableDataInfo lobsterApiRegistry() {
-        return safeListFromTable("lobster_api_registry");
-    }
-
-    // ========== 死信队列 ==========
-    @GetMapping({"/workflow/lobster/dead-letter", "/workflow/lobster/dead-letter/list",
-                 "/workflow/lobster/deadLetter", "/workflow/lobster/deadLetter/list"})
-    public TableDataInfo lobsterDeadLetter() {
-        return safeListFromTable("lobster_dead_letter");
-    }
-
-    // ========== 节点审核 ==========
-    @GetMapping({"/workflow/lobster/event-audit", "/workflow/lobster/event-audit/list",
-                 "/workflow/lobster/eventAudit", "/workflow/lobster/eventAudit/list"})
-    public TableDataInfo lobsterEventAudit() {
-        return safeListFromTable("lobster_event_node_audit");
-    }
-
-    // ========== 聚合聊天 ==========
-    @GetMapping({"/workflow/lobster/chat-aggregate", "/workflow/lobster/chat-aggregate/list",
-                 "/workflow/lobster/chatAggregate", "/workflow/lobster/chatAggregate/list"})
-    public TableDataInfo lobsterChatAggregate() {
-        return safeListFromTable("lobster_chat_aggregate");
-    }
-
-    // ========== 模型配置 ==========
-    @GetMapping({"/workflow/lobster/model-config", "/workflow/lobster/model-config/list",
-                 "/workflow/lobster/model", "/workflow/lobster/model/list"})
-    public TableDataInfo lobsterModelConfig() {
-        return safeListFromTable("lobster_model_config");
-    }
-
-    // ========== Token系数管理 ==========
-    @GetMapping({"/workflow/lobster/billing", "/workflow/lobster/billing/list"})
-    public TableDataInfo lobsterBilling() {
-        return safeListFromTable("lobster_billing_record");
-    }
-
-    // ========== 工作流执行引擎 lobster-exec ==========
-    @GetMapping({"/workflow/lobster-exec/instance", "/workflow/lobster-exec/instance/list"})
-    public TableDataInfo lobsterExecInstanceList() {
-        return safeListFromTable("workflow_instance");
-    }
-
-    @GetMapping("/workflow/lobster-exec/instance/{instanceId}")
-    public AjaxResult lobsterExecInstanceGet(@PathVariable String instanceId) {
-        return lobsterInstanceDetail(instanceId);
-    }
-
-    @GetMapping("/workflow/lobster-exec/node-logs/{instanceId}")
-    public AjaxResult lobsterExecNodeLogs(@PathVariable String instanceId) {
-        return lobsterInstanceNodeLogs(instanceId);
-    }
-
-    @PostMapping({"/workflow/lobster-exec/start", "/workflow/lobster-exec/next-node"})
-    public AjaxResult lobsterExecAction() {
-        return AjaxResult.success("操作成功");
-    }
-
-    @PostMapping("/workflow/lobster-exec/pause/{instanceId}")
-    public AjaxResult lobsterExecPause(@PathVariable String instanceId) {
-        return AjaxResult.success("操作成功");
-    }
-
-    @PostMapping("/workflow/lobster-exec/resume/{instanceId}")
-    public AjaxResult lobsterExecResume(@PathVariable String instanceId) {
-        return AjaxResult.success("操作成功");
-    }
-
-    @PostMapping("/workflow/lobster-exec/terminate/{instanceId}")
-    public AjaxResult lobsterExecTerminate(@PathVariable String instanceId) {
-        return AjaxResult.success("操作成功");
-    }
-
-    @GetMapping("/workflow/lobster-exec/control-mode/{instanceId}")
-    public AjaxResult lobsterExecGetControlMode(@PathVariable String instanceId) {
-        Map<String, Object> data = new HashMap<>();
-        data.put("instanceId", instanceId);
-        data.put("mode", "auto");
-        return AjaxResult.success(data);
-    }
-
-    @PostMapping("/workflow/lobster-exec/control-mode/{instanceId}")
-    public AjaxResult lobsterExecSetControlMode(@PathVariable String instanceId) {
-        return AjaxResult.success("操作成功");
-    }
-
-    @PostMapping("/workflow/lobster-exec/complete-handoff/{instanceId}")
-    public AjaxResult lobsterExecCompleteHandoff(@PathVariable String instanceId) {
-        return AjaxResult.success("操作成功");
-    }
-
-    // ========== 合规规则 lobster-exec/compliance ==========
-    @GetMapping("/workflow/lobster-exec/compliance-rules")
-    public TableDataInfo lobsterExecComplianceRules() {
-        return emptyTable();
-    }
-
-    @PostMapping("/workflow/lobster-exec/compliance-rule")
-    public AjaxResult lobsterExecAddComplianceRule() {
-        return AjaxResult.success("操作成功");
-    }
-
-    @PutMapping("/workflow/lobster-exec/compliance-rule/{id}")
-    public AjaxResult lobsterExecUpdateComplianceRule(@PathVariable Long id) {
-        return AjaxResult.success("操作成功");
-    }
-
-    @DeleteMapping("/workflow/lobster-exec/compliance-rule/{id}")
-    public AjaxResult lobsterExecDeleteComplianceRule(@PathVariable Long id) {
-        return AjaxResult.success("操作成功");
-    }
-
-    // ========== 引擎核心 lobster/engine ==========
-    @GetMapping("/workflow/lobster/engine/evolution/metrics")
-    public AjaxResult lobsterEngineEvolutionMetrics() {
-        Map<String, Object> data = new HashMap<>();
-        data.put("totalEvolutions", 0);
-        data.put("appliedCount", 0);
-        data.put("pendingCount", 0);
-        return AjaxResult.success(data);
-    }
-
-    @GetMapping("/workflow/lobster/engine/evolution/analyze")
-    public AjaxResult lobsterEngineEvolutionAnalyze() {
-        return AjaxResult.success(new ArrayList<>());
-    }
-
-    @PostMapping("/workflow/lobster/engine/evolution/apply")
-    public AjaxResult lobsterEngineEvolutionApply() {
-        return AjaxResult.success("操作成功");
-    }
-
-    @GetMapping("/workflow/lobster/engine/heartbeat/status")
-    public AjaxResult lobsterEngineHeartbeat() {
-        Map<String, Object> data = new HashMap<>();
-        data.put("status", "healthy");
-        return AjaxResult.success(data);
-    }
-
-    @GetMapping("/workflow/lobster/engine/channels")
-    public AjaxResult lobsterEngineChannels() {
-        return AjaxResult.success(new ArrayList<>());
-    }
-
-    // ========== 提示词管理 CRUD ==========
-    @GetMapping("/workflow/lobster/prompt/{id}")
-    public AjaxResult lobsterPromptGet(@PathVariable Long id) {
-        return AjaxResult.success();
-    }
-
-    @PostMapping("/workflow/lobster/prompt")
-    public AjaxResult lobsterPromptAdd() {
-        return AjaxResult.success("操作成功");
-    }
-
-    @PutMapping("/workflow/lobster/prompt/{id}")
-    public AjaxResult lobsterPromptUpdate(@PathVariable Long id) {
-        return AjaxResult.success("操作成功");
-    }
-
-    @DeleteMapping("/workflow/lobster/prompt/{id}")
-    public AjaxResult lobsterPromptDelete(@PathVariable Long id) {
-        return AjaxResult.success("操作成功");
-    }
-
-    @GetMapping("/workflow/lobster/prompt/categories")
-    public AjaxResult lobsterPromptCategories() {
-        return AjaxResult.success(new ArrayList<>());
-    }
-
-    @PostMapping("/workflow/lobster/prompt/refresh-cache")
-    public AjaxResult lobsterPromptRefreshCache() {
-        return AjaxResult.success("操作成功");
-    }
-
-    // ========== 销冠语料补充 ==========
-    @PostMapping("/workflow/lobster/sales-corpus/batch-import")
-    public AjaxResult lobsterSalesCorpusBatchImport(@RequestBody Map<String, Object> body) {
-        if (jdbcTemplate == null) return AjaxResult.error("数据库未初始化");
-        try {
-            Long companyId = body.get("companyId") != null ?
-                    Long.valueOf(body.get("companyId").toString()) : null;
-            String salespersonName = (String) body.getOrDefault("salespersonName", "销冠");
-            String scenario = (String) body.getOrDefault("scenario", "通用");
-            List<Map<String, Object>> dialogs = (List<Map<String, Object>>) body.get("dialogs");
-            if (dialogs == null || dialogs.isEmpty()) {
-                return AjaxResult.error("导入数据不能为空");
-            }
-            int count = 0;
-            for (Map<String, Object> dialog : dialogs) {
-                String customer = (String) dialog.get("customer");
-                String sales = (String) dialog.get("sales");
-                if (customer == null || customer.trim().isEmpty() || sales == null || sales.trim().isEmpty()) {
-                    continue;
-                }
-                jdbcTemplate.update(
-                    "INSERT INTO lobster_sales_corpus (company_id, salesperson_name, customer_question, sales_answer, scenario, status, create_time) " +
-                    "VALUES (?,?,?,?,?,'raw',NOW())",
-                    companyId, salespersonName, customer.trim(), sales.trim(), scenario);
-                count++;
-            }
-            Map<String, Object> result = new LinkedHashMap<>();
-            result.put("message", "批量导入完成");
-            result.put("count", count);
-            return AjaxResult.success(result);
-        } catch (Exception e) {
-            return AjaxResult.error("批量导入失败: " + e.getMessage());
-        }
-    }
-
-    // ========== 死信队列补充 ==========
-    @PostMapping("/workflow/lobster/dead-letter/retry-all")
-    public AjaxResult lobsterDeadLetterRetryAll() {
-        return AjaxResult.success("重试已提交");
-    }
-
-    @GetMapping("/workflow/lobster/dead-letter/stats")
-    public AjaxResult lobsterDeadLetterStats() {
-        Map<String, Object> stats = new HashMap<>();
-        stats.put("total", 0);
-        stats.put("pending", 0);
-        stats.put("retried", 0);
-        try {
-            if (jdbcTemplate != null) {
-                List<Map<String, Object>> rows = jdbcTemplate.queryForList(
-                    "SELECT status, COUNT(*) AS cnt FROM lobster_dead_letter GROUP BY status");
-                for (Map<String, Object> row : rows) {
-                    String s = String.valueOf(row.get("status"));
-                    long cnt = ((Number) row.get("cnt")).longValue();
-                    stats.put(s, cnt);
-                }
-            }
-        } catch (Exception ignored) {}
-        return AjaxResult.success(stats);
-    }
-
-    // ========== 节点审核补充 ==========
-    @GetMapping("/workflow/lobster/event-audit/{id}")
-    public AjaxResult lobsterEventAuditGet(@PathVariable Long id) {
-        return AjaxResult.success();
-    }
-
-    @PostMapping("/workflow/lobster/event-audit/approve/{id}")
-    public AjaxResult lobsterEventAuditApprove(@PathVariable Long id) {
-        return AjaxResult.success("审批通过");
-    }
-
-    @PostMapping("/workflow/lobster/event-audit/reject/{id}")
-    public AjaxResult lobsterEventAuditReject(@PathVariable Long id) {
-        return AjaxResult.success("已驳回");
-    }
-
-    // ========== 优化建议补充 ==========
-    @GetMapping("/workflow/lobster/optimization/pending-audit")
-    public TableDataInfo lobsterOptimizationPendingAudit() {
-        return safeListFromTable("lobster_optimization_suggestion");
-    }
-
-    @PostMapping("/workflow/lobster/optimization/batch-audit")
-    public AjaxResult lobsterOptimizationBatchAudit() {
-        return AjaxResult.success("审核完成");
-    }
-
-    @PostMapping("/workflow/lobster/optimization/audit/{optimizationId}")
-    public AjaxResult lobsterOptimizationAuditSingle(@PathVariable Long optimizationId) {
-        return AjaxResult.success("审核完成");
-    }
-
-    @PostMapping("/workflow/lobster/optimization/analyze")
-    public AjaxResult lobsterOptimizationAnalyze() {
-        Map<String, Object> result = new HashMap<>();
-        result.put("totalSuggestions", 0);
-        return AjaxResult.success(result);
-    }
-
-    @GetMapping("/workflow/lobster/optimization/stats")
-    public AjaxResult lobsterOptimizationStats() {
-        Map<String, Object> stats = new HashMap<>();
-        stats.put("total", 0);
-        stats.put("pending", 0);
-        stats.put("approved", 0);
-        stats.put("rejected", 0);
-        try {
-            if (jdbcTemplate != null) {
-                List<Map<String, Object>> rows = jdbcTemplate.queryForList(
-                    "SELECT status, COUNT(*) AS cnt FROM lobster_optimization_suggestion GROUP BY status");
-                for (Map<String, Object> row : rows) {
-                    String s = String.valueOf(row.get("status"));
-                    long cnt = ((Number) row.get("cnt")).longValue();
-                    stats.put(s, cnt);
-                }
-            }
-        } catch (Exception ignored) {}
-        return AjaxResult.success(stats);
-    }
-
-    @GetMapping("/workflow/lobster/optimization/config")
-    public AjaxResult lobsterOptimizationConfig() {
-        return AjaxResult.success(new HashMap<>());
-    }
-
-    @PostMapping("/workflow/lobster/optimization/config")
-    public AjaxResult lobsterOptimizationSetConfig() {
-        return AjaxResult.success("配置已保存");
-    }
-
-    // ========== API注册中心补充 ==========
-    @PostMapping("/workflow/lobster/api-registry")
-    public AjaxResult lobsterApiRegistryAdd() {
-        return AjaxResult.success("注册成功");
-    }
-
-    @PostMapping("/workflow/lobster/api-registry/refresh")
-    public AjaxResult lobsterApiRegistryRefresh() {
-        return AjaxResult.success("缓存已刷新");
-    }
-
-    @GetMapping("/workflow/lobster/api-registry/categories")
-    public AjaxResult lobsterApiRegistryCategories() {
-        return AjaxResult.success(new ArrayList<>());
-    }
-
-    // ========== Token计费补充 ==========
-    @GetMapping("/workflow/lobster/billing/token-coefficient")
-    public AjaxResult lobsterBillingTokenCoefficient() {
-        Map<String, Object> data = new HashMap<>();
-        data.put("coefficient", 1.0);
-        data.put("updatedAt", "");
-        return AjaxResult.success(data);
-    }
-
-    @PutMapping("/workflow/lobster/billing/token-coefficient")
-    public AjaxResult lobsterBillingUpdateTokenCoefficient() {
-        return AjaxResult.success("更新成功");
-    }
-
-    @GetMapping("/workflow/lobster/billing/records")
-    public TableDataInfo lobsterBillingRecords() {
-        return safeListFromTable("lobster_billing_record");
-    }
-
-    @GetMapping("/workflow/lobster/billing/stats")
-    public AjaxResult lobsterBillingStats() {
-        Map<String, Object> stats = new HashMap<>();
-        stats.put("totalTokens", 0);
-        stats.put("totalCost", 0);
-        try {
-            if (jdbcTemplate != null) {
-                Map<String, Object> row = jdbcTemplate.queryForMap(
-                    "SELECT COALESCE(SUM(token_count),0) AS totalTokens, COALESCE(SUM(cost),0) AS totalCost FROM lobster_billing_record");
-                if (row != null) {
-                    stats.putAll(row);
-                }
-            }
-        } catch (Exception ignored) {}
-        return AjaxResult.success(stats);
-    }
-
-    @GetMapping("/workflow/lobster/billing/types")
-    public AjaxResult lobsterBillingTypes() {
-        return AjaxResult.success(new ArrayList<>());
-    }
-}

+ 126 - 0
fs-admin/src/main/java/com/fs/admin/controller/AdminVoiceConfigController.java

@@ -0,0 +1,126 @@
+package com.fs.admin.controller;
+
+import cn.hutool.json.JSONUtil;
+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.DataSourceType;
+import com.fs.company.domain.CompanyVoiceConfig;
+import com.fs.company.param.CompanyVoiceConfigParam;
+import com.fs.company.service.ICompanyVoiceConfigService;
+import com.fs.company.vo.CompanyVoiceConfigListVO;
+import com.fs.company.vo.CompanyVoiceConfigVO;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.system.config.SystemVoiceConfig;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 呼叫频率配置(主库 company_voice_config)
+ */
+@RestController
+@RequestMapping("/company/companyVoiceConfig")
+public class AdminVoiceConfigController extends BaseController {
+
+    @Autowired(required = false)
+    private ICompanyVoiceConfigService companyVoiceConfigService;
+
+    @PreAuthorize("@ss.hasPermi('platform:companyVoiceConfig:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(CompanyVoiceConfig query) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        startPage();
+        List<CompanyVoiceConfigListVO> list = companyVoiceConfigService != null
+                ? companyVoiceConfigService.selectCompanyVoiceConfigListVO(query)
+                : java.util.Collections.emptyList();
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('platform:companyVoiceConfig:query')")
+    @GetMapping("/{configId}")
+    public AjaxResult getInfo(@PathVariable Long configId) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (companyVoiceConfigService == null) {
+            return AjaxResult.error("服务不可用");
+        }
+        CompanyVoiceConfig config = companyVoiceConfigService.selectCompanyVoiceConfigById(configId);
+        return AjaxResult.success(toParam(config));
+    }
+
+    @PreAuthorize("@ss.hasPermi('platform:companyVoiceConfig:add')")
+    @PostMapping
+    public AjaxResult add(@RequestBody CompanyVoiceConfigParam param) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (companyVoiceConfigService == null) {
+            return AjaxResult.error("服务不可用");
+        }
+        return toAjax(companyVoiceConfigService.insertCompanyVoiceConfig(fromParam(param)));
+    }
+
+    @PreAuthorize("@ss.hasPermi('platform:companyVoiceConfig:edit')")
+    @PutMapping
+    public AjaxResult edit(@RequestBody CompanyVoiceConfigParam param) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (companyVoiceConfigService == null) {
+            return AjaxResult.error("服务不可用");
+        }
+        return toAjax(companyVoiceConfigService.updateCompanyVoiceConfig(fromParam(param)));
+    }
+
+    @PreAuthorize("@ss.hasPermi('platform:companyVoiceConfig:remove')")
+    @DeleteMapping("/{configIds}")
+    public AjaxResult remove(@PathVariable Long[] configIds) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (companyVoiceConfigService == null) {
+            return AjaxResult.error("服务不可用");
+        }
+        return toAjax(companyVoiceConfigService.deleteCompanyVoiceConfigByIds(configIds));
+    }
+
+    private CompanyVoiceConfig fromParam(CompanyVoiceConfigParam param) {
+        CompanyVoiceConfig config = new CompanyVoiceConfig();
+        config.setConfigId(param.getConfigId());
+        config.setCompanyId(param.getCompanyId());
+        SystemVoiceConfig caller = new SystemVoiceConfig();
+        caller.setCallerMinute(param.getCallerMinute());
+        caller.setCallerHour(param.getCallerHour());
+        caller.setCallerDay(param.getCallerDay());
+        caller.setCallerWeek(param.getCallerWeek());
+        caller.setCallerMonth(param.getCallerMonth());
+        SystemVoiceConfig callee = new SystemVoiceConfig();
+        callee.setCalleeMinute(param.getCalleeMinute());
+        callee.setCalleeHour(param.getCalleeHour());
+        callee.setCalleeDay(param.getCalleeDay());
+        callee.setCalleeWeek(param.getCalleeWeek());
+        callee.setCalleeMonth(param.getCalleeMonth());
+        config.setCallerJson(JSONUtil.toJsonStr(caller));
+        config.setCalleeJson(JSONUtil.toJsonStr(callee));
+        return config;
+    }
+
+    private CompanyVoiceConfigVO toParam(CompanyVoiceConfig config) {
+        CompanyVoiceConfigVO vo = new CompanyVoiceConfigVO();
+        vo.setConfigId(config.getConfigId());
+        vo.setCompanyId(config.getCompanyId());
+        if (config.getCallerJson() != null) {
+            SystemVoiceConfig caller = JSONUtil.toBean(config.getCallerJson(), SystemVoiceConfig.class);
+            vo.setCallerMinute(caller.getCallerMinute());
+            vo.setCallerHour(caller.getCallerHour());
+            vo.setCallerDay(caller.getCallerDay());
+            vo.setCallerWeek(caller.getCallerWeek());
+            vo.setCallerMonth(caller.getCallerMonth());
+        }
+        if (config.getCalleeJson() != null) {
+            SystemVoiceConfig callee = JSONUtil.toBean(config.getCalleeJson(), SystemVoiceConfig.class);
+            vo.setCalleeMinute(callee.getCalleeMinute());
+            vo.setCalleeHour(callee.getCalleeHour());
+            vo.setCalleeDay(callee.getCalleeDay());
+            vo.setCalleeWeek(callee.getCalleeWeek());
+            vo.setCalleeMonth(callee.getCalleeMonth());
+        }
+        return vo;
+    }
+}

+ 1 - 0
fs-admin/src/main/java/com/fs/admin/controller/CompanyAdminController.java

@@ -69,6 +69,7 @@ public class CompanyAdminController extends BaseController {
     public void export(HttpServletResponse response, TenantInfo tenantInfo) throws IOException {
         List<TenantInfo> list = tenantInfoService.selectTenantInfoList(tenantInfo);
         ExcelUtil<TenantInfo> util = new ExcelUtil<>(TenantInfo.class);
+        response.setHeader("Content-Disposition", "attachment;filename=租户列表数据.xlsx");
         util.exportExcel(response, list, "租户列表数据");
     }
 

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

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

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

+ 1 - 1
fs-agent/src/main/java/com/fs/framework/config/HybridBeanNameGenerator.java

@@ -5,7 +5,7 @@ import org.springframework.beans.factory.support.BeanDefinitionRegistry;
 import org.springframework.context.annotation.AnnotationBeanNameGenerator;
 
 /**
- * Controller ʹ��ȫ���������� fs-saasadmin ͬ����ͻ��Service ������Ĭ�϶�����
+ * Controller 使用全限定类名避免与 fs-saasadmin 同名冲突,Service 则使用默认短名称
  */
 public class HybridBeanNameGenerator extends AnnotationBeanNameGenerator {
 

+ 43 - 0
fs-agent/src/main/resources/db/migration/tenant/V20260601_01__add_lobster_new_pages_menus.sql

@@ -0,0 +1,43 @@
+-- =====================================================
+-- 龙虾引擎 新增页面菜单
+-- 新增 7 个缺失的菜单记录(模拟聊天测试、评分准确性验证、
+--   用户画像配置、摘要生成配置、消息去重配置、敏感词库、节点详情)
+-- =====================================================
+
+-- =====================================================
+-- PART 1: 新增页面菜单 (menu_id 29920-29926)
+
+-- 15. 模拟聊天测试
+INSERT IGNORE INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, is_frame, is_cache, create_by, create_time, remark)
+VALUES (29920, '模拟聊天测试', 29900, 13, 'chat-test', 'lobster/chat-test/index', 'C', 'el-icon-chat-dot-square', '0', '0', 0, 0, 'admin', NOW(), NULL);
+
+-- 16. 评分准确性验证
+INSERT IGNORE INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, is_frame, is_cache, create_by, create_time, remark)
+VALUES (29921, '评分准确性验证', 29900, 14, 'quality-verify', 'lobster/quality-verify/index', 'C', 'el-icon-data-analysis', '0', '0', 0, 0, 'admin', NOW(), NULL);
+
+-- 17. 用户画像配置
+INSERT IGNORE INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, is_frame, is_cache, create_by, create_time, remark)
+VALUES (29922, '用户画像配置', 29900, 15, 'profile-config', 'lobster/profile-config/index', 'C', 'el-icon-user', '0', '0', 0, 0, 'admin', NOW(), NULL);
+
+-- 18. 摘要生成配置
+INSERT IGNORE INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, is_frame, is_cache, create_by, create_time, remark)
+VALUES (29923, '摘要生成配置', 29900, 16, 'summary-config', 'lobster/summary-config/index', 'C', 'el-icon-notebook', '0', '0', 0, 0, 'admin', NOW(), NULL);
+
+-- 19. 消息去重配置
+INSERT IGNORE INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, is_frame, is_cache, create_by, create_time, remark)
+VALUES (29924, '消息去重配置', 29900, 17, 'dedup-config', 'lobster/dedup-config/index', 'C', 'el-icon-filter', '0', '0', 0, 0, 'admin', NOW(), NULL);
+
+-- 20. 敏感词库
+INSERT IGNORE INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, is_frame, is_cache, create_by, create_time, remark)
+VALUES (29925, '敏感词库', 29900, 18, 'sensitive-words', 'lobster/sensitive-words/index', 'C', 'el-icon-warning', '0', '0', 0, 0, 'admin', NOW(), NULL);
+
+-- 21. 节点详情 (hidden, 从实例页跳转)
+INSERT IGNORE INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, is_frame, is_cache, create_by, create_time, remark)
+VALUES (29926, '节点详情', 29900, 19, 'node-detail', 'lobster/node-detail/index', 'C', 'el-icon-document', '1', '0', 0, 0, 'admin', NOW(), '从实例监控页跳转');
+
+-- =====================================================
+-- PART 2: 同步更新模板表 tenant_sys_menu
+-- =====================================================
+INSERT IGNORE INTO tenant_sys_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, is_frame, is_cache, create_by, create_time, remark)
+SELECT menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, is_frame, is_cache, create_by, create_time, remark
+FROM sys_menu WHERE menu_id BETWEEN 29920 AND 29926;

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

@@ -9,6 +9,9 @@ public class CommCallSendRequest {
 
     private String phone;
 
+    /** 调用人(内部调用时可随请求体传入,写入主库日志) */
+    private Long companyUserId;
+
     private Long calleeId;
 
     private Long roboticId;

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

@@ -17,6 +17,8 @@ public class CommSmsSendRequest {
 
     private Long calleeId;
 
+    private Long companyId;
+
     private String nodeKey;
 
     private String workflowInstanceId;

+ 75 - 29
fs-comm-gateway/src/main/java/com/fs/comm/service/CommCallService.java

@@ -6,6 +6,7 @@ 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.comm.support.CommTenantDataSourceHelper;
 import com.fs.common.exception.ServiceException;
 import com.fs.common.utils.StringUtils;
 import lombok.extern.slf4j.Slf4j;
@@ -31,38 +32,83 @@ public class CommCallService {
     @Autowired
     private CommMetricsService commMetricsService;
 
+    @Autowired
+    private CommVoiceLimitService commVoiceLimitService;
+
+    @Autowired
+    private CommGatewayApiLogRecorder commGatewayApiLogRecorder;
+
+    @Autowired
+    private CommTenantDataSourceHelper commTenantDataSourceHelper;
+
     public Map<String, Object> sendCall(CommCallSendRequest request) {
+        long startMs = System.currentTimeMillis();
         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("外呼发起失败:未获取到有效被叫号码");
-        }
+        Long gatewayId = request != null ? request.getGatewayId() : null;
+        String calleePhone = null;
+        String callerKey = null;
+        CommGatewayApiLogRecorder.CommApiRecordResult recordResult = null;
 
-        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;
+        try {
+            if (companyId == null) {
+                throw new ServiceException("未获取到公司信息");
+            }
+            calleePhone = commVoiceLimitService.resolveCallCalleePhone(request.getCalleeId(), request.getPhone());
+            callerKey = commVoiceLimitService.buildCompanyCallerKey(companyId, gatewayId);
+
+            commRateLimitService.checkTenantQps(tenantId);
+            commVoiceLimitService.checkCallLimit(companyId, tenantId, request.getCalleeId(), request.getPhone(), gatewayId);
+            commTenantDataSourceHelper.ensureTenant(tenantId);
+            commGatewayLineAuthService.validateGateway(companyId, tenantId, gatewayId);
+            commTenantDataSourceHelper.ensureTenant(tenantId);
+
+            CommCallSendResult result = commCallSendService.sendWorkflowCall(CommCallSendParam.builder()
+                    .roboticId(request.getRoboticId())
+                    .calleeId(request.getCalleeId())
+                    .businessId(request.getBusinessId())
+                    .gatewayId(gatewayId)
+                    .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("外呼发起失败:未获取到有效被叫号码");
+            }
+
+            if (StringUtils.isBlank(calleePhone)) {
+                calleePhone = commVoiceLimitService.normalizePhone(result.getPhone());
+            }
+
+            Map<String, Object> response = new HashMap<>();
+            response.put("callBackUuid", result.getCallBackUuid());
+            response.put("batchId", result.getBatchId());
+            response.put("phone", result.getPhone());
+
+            recordResult = commGatewayApiLogRecorder.buildSuccess(response, calleePhone, callerKey, gatewayId);
+            commMetricsService.increment("call.success");
+            return response;
+        } catch (ServiceException ex) {
+            boolean limitHit = CommGatewayApiLogRecorder.isLimitFailure(ex);
+            recordResult = limitHit
+                    ? commGatewayApiLogRecorder.buildLimitFailure(ex, calleePhone, callerKey, gatewayId)
+                    : commGatewayApiLogRecorder.buildFailure(ex, calleePhone, callerKey, gatewayId);
+            throw ex;
+        } catch (Exception ex) {
+            log.error("外呼调用异常 companyId={}", companyId, ex);
+            ServiceException wrapped = ex instanceof ServiceException
+                    ? (ServiceException) ex
+                    : new ServiceException(StringUtils.defaultIfBlank(ex.getMessage(), "外呼调用异常"));
+            recordResult = commGatewayApiLogRecorder.buildFailure(wrapped, calleePhone, callerKey, gatewayId);
+            throw wrapped;
+        } finally {
+            commGatewayApiLogRecorder.recordCallAttempt(companyId, tenantId, request, recordResult,
+                    calleePhone, callerKey, gatewayId, startMs);
+        }
     }
 }

+ 316 - 0
fs-comm-gateway/src/main/java/com/fs/comm/service/CommGatewayApiLogRecorder.java

@@ -0,0 +1,316 @@
+package com.fs.comm.service;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.comm.auth.CommSession;
+import com.fs.comm.context.CommAuthContext;
+import com.fs.comm.domain.CommGatewayApiLog;
+import com.fs.comm.dto.CommCallSendRequest;
+import com.fs.comm.dto.CommSmsSendRequest;
+import com.fs.comm.model.CommGatewayBillingQuote;
+import com.fs.comm.support.CommTenantDataSourceHelper;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.ServletUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.ip.IpUtils;
+import com.fs.company.domain.CompanyCommGatewayLog;
+import com.fs.company.service.ICompanyCommGatewayLogService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 通讯网关 API 调用日志记录(主库 + 租户库)
+ */
+@Slf4j
+@Service
+public class CommGatewayApiLogRecorder {
+
+    @Autowired
+    private ICommGatewayApiLogService commGatewayApiLogService;
+
+    @Autowired
+    private ICompanyCommGatewayLogService companyCommGatewayLogService;
+
+    @Autowired
+    private CommGatewayBillingService commGatewayBillingService;
+
+    @Autowired
+    private CommTenantDataSourceHelper commTenantDataSourceHelper;
+
+    public void recordCallAttempt(Long companyId, Long tenantId, CommCallSendRequest request,
+                                  CommApiRecordResult result, String calleePhone, String callerPhone,
+                                  Long gatewayId, long startMs) {
+        record(buildBaseLog(companyId, tenantId, CommGatewayApiLog.API_TYPE_CALL, "/comm/call/send",
+                request, result, calleePhone, callerPhone, gatewayId, startMs));
+    }
+
+    public void recordSmsAttempt(Long companyId, Long tenantId, CommSmsSendRequest request,
+                                 CommApiRecordResult result, String calleePhone, String callerPhone,
+                                 Long gatewayId, long startMs) {
+        record(buildBaseLog(companyId, tenantId, CommGatewayApiLog.API_TYPE_SMS, "/comm/sms/send",
+                request, result, calleePhone, callerPhone, gatewayId, startMs));
+    }
+
+    private CommGatewayApiLog buildBaseLog(Long companyId, Long tenantId, String apiType, String apiPath,
+                                           Object request, CommApiRecordResult result, String calleePhone,
+                                           String callerPhone, Long gatewayId, long startMs) {
+        CommSession session = CommAuthContext.get();
+        CommGatewayApiLog logEntity = new CommGatewayApiLog();
+        logEntity.setTenantId(tenantId);
+        logEntity.setCompanyId(companyId);
+        if (session != null) {
+            logEntity.setCompanyUserId(session.getCompanyUserId());
+            logEntity.setCallerAccount(session.getAccount());
+            logEntity.setAuthScope(session.getScope());
+        }
+        fillIdentityFromRequest(logEntity, request);
+        logEntity.setApiType(apiType);
+        logEntity.setApiPath(apiPath);
+        logEntity.setRequestBody(JSON.toJSONString(request));
+        logEntity.setDurationMs((int) (System.currentTimeMillis() - startMs));
+        try {
+            logEntity.setClientIp(IpUtils.getIpAddr(ServletUtils.getRequest()));
+        } catch (Exception ignored) {
+        }
+
+        if (result != null) {
+            logEntity.setResponseBody(result.getResponseBody());
+            logEntity.setResultCode(result.getResultCode());
+            logEntity.setResultMsg(result.getResultMsg());
+            logEntity.setSuccess(result.isSuccess() ? 1 : 0);
+            logEntity.setLimitHit(result.isLimitHit() ? 1 : 0);
+            logEntity.setLimitReason(result.getLimitReason());
+            logEntity.setCalleePhone(result.getCalleePhone());
+            logEntity.setCallerPhone(result.getCallerPhone());
+            logEntity.setGatewayId(result.getGatewayId());
+            applyBilling(logEntity, tenantId, apiType, result.isSuccess());
+        } else {
+            applyFallbackFields(logEntity, calleePhone, callerPhone, gatewayId, "调用未正常返回结果");
+        }
+
+        if (StringUtils.isBlank(logEntity.getCalleePhone()) && StringUtils.isNotBlank(calleePhone)) {
+            logEntity.setCalleePhone(calleePhone);
+        }
+        if (StringUtils.isBlank(logEntity.getCallerPhone()) && StringUtils.isNotBlank(callerPhone)) {
+            logEntity.setCallerPhone(callerPhone);
+        }
+        if (logEntity.getGatewayId() == null && gatewayId != null) {
+            logEntity.setGatewayId(gatewayId);
+        }
+        return logEntity;
+    }
+
+    private void applyBilling(CommGatewayApiLog logEntity, Long tenantId, String apiType, boolean success) {
+        logEntity.setBillingUnit(apiType);
+        if (!success) {
+            logEntity.setBillingAmount(BigDecimal.ZERO);
+            logEntity.setCostPrice(BigDecimal.ZERO);
+            logEntity.setCalcPrice(BigDecimal.ZERO);
+            logEntity.setBillingQuantity(0);
+            return;
+        }
+        CommGatewayBillingQuote quote = commGatewayBillingService.resolveQuote(tenantId, apiType, 1);
+        logEntity.setCostPrice(quote.getCostPrice());
+        logEntity.setCalcPrice(quote.getCalcPrice());
+        logEntity.setBillingQuantity(quote.getBillingQuantity());
+        logEntity.setBillingAmount(quote.getBillingAmount());
+    }
+
+    private void fillIdentityFromRequest(CommGatewayApiLog logEntity, Object request) {
+        if (request instanceof CommCallSendRequest) {
+            CommCallSendRequest callRequest = (CommCallSendRequest) request;
+            if (callRequest.getCompanyUserId() != null) {
+                logEntity.setCompanyUserId(callRequest.getCompanyUserId());
+            }
+        } else if (request instanceof CommSmsSendRequest) {
+            CommSmsSendRequest smsRequest = (CommSmsSendRequest) request;
+            if (smsRequest.getCompanyId() != null) {
+                logEntity.setCompanyId(smsRequest.getCompanyId());
+            }
+            if (smsRequest.getCompanyUserId() != null) {
+                logEntity.setCompanyUserId(smsRequest.getCompanyUserId());
+            }
+        }
+        if (logEntity.getCompanyUserId() == null) {
+            logEntity.setCompanyUserId(CommAuthContext.getCompanyUserId());
+        }
+    }
+
+    private void applyFallbackFields(CommGatewayApiLog logEntity, String calleePhone, String callerPhone,
+                                     Long gatewayId, String message) {
+        logEntity.setCalleePhone(calleePhone);
+        logEntity.setCallerPhone(callerPhone);
+        logEntity.setGatewayId(gatewayId);
+        logEntity.setSuccess(0);
+        logEntity.setLimitHit(0);
+        logEntity.setResultCode(500);
+        logEntity.setResultMsg(message);
+        logEntity.setBillingAmount(BigDecimal.ZERO);
+        logEntity.setCostPrice(BigDecimal.ZERO);
+        logEntity.setCalcPrice(BigDecimal.ZERO);
+        logEntity.setBillingQuantity(0);
+        logEntity.setBillingUnit(logEntity.getApiType());
+        Map<String, Object> body = new HashMap<>();
+        body.put("code", 500);
+        body.put("msg", message);
+        logEntity.setResponseBody(JSON.toJSONString(body));
+    }
+
+    private void record(CommGatewayApiLog logEntity) {
+        Long masterLogId = null;
+        try {
+            commGatewayApiLogService.saveLog(logEntity);
+            masterLogId = logEntity.getLogId();
+        } catch (Exception ex) {
+            log.error("写入主库通讯网关调用日志失败", ex);
+        }
+        saveTenantLog(logEntity, masterLogId);
+    }
+
+    private void saveTenantLog(CommGatewayApiLog logEntity, Long masterLogId) {
+        if (logEntity == null || logEntity.getTenantId() == null) {
+            return;
+        }
+        try {
+            commTenantDataSourceHelper.ensureTenant(logEntity.getTenantId());
+            companyCommGatewayLogService.saveLog(toTenantLog(logEntity, masterLogId));
+        } catch (Exception ex) {
+            log.error("写入租户通讯网关调用日志失败 tenantId={}, companyId={}",
+                    logEntity.getTenantId(), logEntity.getCompanyId(), ex);
+        }
+    }
+
+    private CompanyCommGatewayLog toTenantLog(CommGatewayApiLog source, Long masterLogId) {
+        CompanyCommGatewayLog target = new CompanyCommGatewayLog();
+        target.setMasterLogId(masterLogId);
+        target.setCompanyId(source.getCompanyId());
+        target.setCompanyUserId(source.getCompanyUserId());
+        target.setCallerAccount(source.getCallerAccount());
+        target.setApiType(source.getApiType());
+        target.setApiPath(source.getApiPath());
+        target.setRequestBody(source.getRequestBody());
+        target.setResponseBody(source.getResponseBody());
+        target.setResultCode(source.getResultCode());
+        target.setResultMsg(source.getResultMsg());
+        target.setSuccess(source.getSuccess());
+        target.setLimitHit(source.getLimitHit());
+        target.setLimitReason(source.getLimitReason());
+        target.setCalleePhone(source.getCalleePhone());
+        target.setCallerPhone(source.getCallerPhone());
+        target.setGatewayId(source.getGatewayId());
+        target.setBillingAmount(source.getBillingAmount());
+        target.setCostPrice(source.getCostPrice());
+        target.setCalcPrice(source.getCalcPrice());
+        target.setBillingQuantity(source.getBillingQuantity());
+        target.setBillingUnit(source.getBillingUnit());
+        target.setClientIp(source.getClientIp());
+        target.setAuthScope(source.getAuthScope());
+        target.setDurationMs(source.getDurationMs());
+        target.setCreateTime(source.getCreateTime());
+        return target;
+    }
+
+    /** 是否为频率/QPS 类限制失败 */
+    public static boolean isLimitFailure(ServiceException ex) {
+        if (ex == null || StringUtils.isBlank(ex.getMessage())) {
+            return false;
+        }
+        String message = ex.getMessage();
+        return message.contains("限制") || message.contains("超限") || message.contains("频率");
+    }
+
+    public CommApiRecordResult buildLimitFailure(ServiceException ex, String calleePhone, String callerPhone, Long gatewayId) {
+        Map<String, Object> body = new HashMap<>();
+        body.put("code", 500);
+        body.put("msg", ex.getMessage());
+        return CommApiRecordResult.builder()
+                .success(false)
+                .limitHit(true)
+                .limitReason(ex.getMessage())
+                .resultCode(500)
+                .resultMsg(ex.getMessage())
+                .responseBody(JSON.toJSONString(body))
+                .calleePhone(calleePhone)
+                .callerPhone(callerPhone)
+                .gatewayId(gatewayId)
+                .build();
+    }
+
+    public CommApiRecordResult buildSuccess(Object data, String calleePhone, String callerPhone, Long gatewayId) {
+        Map<String, Object> body = new HashMap<>();
+        body.put("code", 200);
+        body.put("msg", "success");
+        body.put("data", data);
+        return CommApiRecordResult.builder()
+                .success(true)
+                .limitHit(false)
+                .resultCode(200)
+                .resultMsg("success")
+                .responseBody(JSON.toJSONString(body))
+                .calleePhone(calleePhone)
+                .callerPhone(callerPhone)
+                .gatewayId(gatewayId)
+                .build();
+    }
+
+    public CommApiRecordResult buildFailure(ServiceException ex, String calleePhone, String callerPhone, Long gatewayId) {
+        Map<String, Object> body = new HashMap<>();
+        body.put("code", 500);
+        body.put("msg", ex.getMessage());
+        return CommApiRecordResult.builder()
+                .success(false)
+                .limitHit(false)
+                .resultCode(500)
+                .resultMsg(ex.getMessage())
+                .responseBody(JSON.toJSONString(body))
+                .calleePhone(calleePhone)
+                .callerPhone(callerPhone)
+                .gatewayId(gatewayId)
+                .build();
+    }
+
+    public static class CommApiRecordResult {
+        private boolean success;
+        private boolean limitHit;
+        private Integer resultCode;
+        private String resultMsg;
+        private String responseBody;
+        private String limitReason;
+        private String calleePhone;
+        private String callerPhone;
+        private Long gatewayId;
+
+        public static CommApiRecordResultBuilder builder() {
+            return new CommApiRecordResultBuilder();
+        }
+
+        public boolean isSuccess() { return success; }
+        public boolean isLimitHit() { return limitHit; }
+        public Integer getResultCode() { return resultCode; }
+        public String getResultMsg() { return resultMsg; }
+        public String getResponseBody() { return responseBody; }
+        public String getLimitReason() { return limitReason; }
+        public String getCalleePhone() { return calleePhone; }
+        public String getCallerPhone() { return callerPhone; }
+        public Long getGatewayId() { return gatewayId; }
+
+        public static class CommApiRecordResultBuilder {
+            private final CommApiRecordResult target = new CommApiRecordResult();
+
+            public CommApiRecordResultBuilder success(boolean success) { target.success = success; return this; }
+            public CommApiRecordResultBuilder limitHit(boolean limitHit) { target.limitHit = limitHit; return this; }
+            public CommApiRecordResultBuilder resultCode(Integer resultCode) { target.resultCode = resultCode; return this; }
+            public CommApiRecordResultBuilder resultMsg(String resultMsg) { target.resultMsg = resultMsg; return this; }
+            public CommApiRecordResultBuilder responseBody(String responseBody) { target.responseBody = responseBody; return this; }
+            public CommApiRecordResultBuilder limitReason(String limitReason) { target.limitReason = limitReason; return this; }
+            public CommApiRecordResultBuilder calleePhone(String calleePhone) { target.calleePhone = calleePhone; return this; }
+            public CommApiRecordResultBuilder callerPhone(String callerPhone) { target.callerPhone = callerPhone; return this; }
+            public CommApiRecordResultBuilder gatewayId(Long gatewayId) { target.gatewayId = gatewayId; return this; }
+            public CommApiRecordResult build() { return target; }
+        }
+    }
+}

+ 12 - 6
fs-comm-gateway/src/main/java/com/fs/comm/service/CommGatewayLineAuthService.java

@@ -2,9 +2,10 @@ package com.fs.comm.service;
 
 import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
+import com.fs.comm.support.CommTenantDataSourceHelper;
 import com.fs.common.exception.ServiceException;
 import com.fs.common.utils.StringUtils;
-import com.fs.company.mapper.CompanyMapper;
+import com.fs.company.mapper.CompanyBindGatewayMapper;
 import com.fs.company.service.easycall.IEasyCallService;
 import com.fs.company.vo.easycall.EasyCallGatewayVO;
 import com.fs.system.service.ISysConfigService;
@@ -27,18 +28,22 @@ public class CommGatewayLineAuthService {
     private IEasyCallService easyCallService;
 
     @Autowired
-    private CompanyMapper companyMapper;
+    private CompanyBindGatewayMapper companyBindGatewayMapper;
 
     @Autowired
     private ISysConfigService configService;
 
-    public void validateGateway(Long companyId, Long gatewayId) {
+    @Autowired
+    private CommTenantDataSourceHelper commTenantDataSourceHelper;
+
+    public void validateGateway(Long companyId, Long tenantId, Long gatewayId) {
         if (gatewayId == null) {
             throw new ServiceException("gatewayId不能为空");
         }
+        commTenantDataSourceHelper.ensureTenant(tenantId);
         List<EasyCallGatewayVO> allowed = easyCallService.getGatewayList(companyId);
         if (allowed == null || allowed.isEmpty()) {
-            validateByConfigOnly(companyId, gatewayId);
+            validateByConfigOnly(companyId, tenantId, gatewayId);
             return;
         }
         boolean matched = allowed.stream().anyMatch(item -> gatewayId.equals(item.getId()));
@@ -47,8 +52,9 @@ public class CommGatewayLineAuthService {
         }
     }
 
-    private void validateByConfigOnly(Long companyId, Long gatewayId) {
-        String gateWayList = companyMapper.getGateWayList(companyId);
+    private void validateByConfigOnly(Long companyId, Long tenantId, Long gatewayId) {
+        commTenantDataSourceHelper.ensureTenant(tenantId);
+        String gateWayList = companyBindGatewayMapper.getGateWayIdListByCompanyId(companyId);
         if (StringUtils.isNotBlank(gateWayList)) {
             List<Long> ids = Arrays.stream(gateWayList.split(","))
                     .map(String::trim).filter(StringUtils::isNotBlank).map(Long::valueOf).collect(Collectors.toList());

+ 71 - 22
fs-comm-gateway/src/main/java/com/fs/comm/service/CommSmsService.java

@@ -6,7 +6,9 @@ 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.comm.support.CommTenantDataSourceHelper;
 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;
@@ -27,30 +29,77 @@ public class CommSmsService {
     @Autowired
     private CommMetricsService commMetricsService;
 
+    @Autowired
+    private CommVoiceLimitService commVoiceLimitService;
+
+    @Autowired
+    private CommGatewayApiLogRecorder commGatewayApiLogRecorder;
+
+    @Autowired
+    private CommTenantDataSourceHelper commTenantDataSourceHelper;
+
     public Map<String, Object> sendSms(CommSmsSendRequest request) {
+        long startMs = System.currentTimeMillis();
         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;
+        String callerKey = null;
+        CommGatewayApiLogRecorder.CommApiRecordResult recordResult = null;
+        String calleePhone = null;
+
+        try {
+            if (companyId == null) {
+                throw new ServiceException("未获取到公司信息");
+            }
+            commTenantDataSourceHelper.ensureTenant(tenantId);
+            if (request != null) {
+                calleePhone = commVoiceLimitService.resolveSmsCalleePhone(
+                        request.getCalleeId(), request.getCustomerId(), request.getPhone());
+            }
+            callerKey = commVoiceLimitService.buildCompanyCallerKey(companyId, null);
+
+            commRateLimitService.checkTenantQps(tenantId);
+            commVoiceLimitService.checkSmsLimit(companyId, tenantId, request.getCalleeId(), request.getCustomerId(), request.getPhone());
+            commTenantDataSourceHelper.ensureTenant(tenantId);
+
+            CommSmsSendResult result = commSmsSendService.sendWorkflowSms(CommSmsSendParam.builder()
+                    .roboticId(request.getRoboticId())
+                    .calleeId(request.getCalleeId())
+                    .smsTempId(request.getSmsTempId())
+                    .nodeKey(request.getNodeKey())
+                    .workflowInstanceId(request.getWorkflowInstanceId())
+                    .companyId(request.getCompanyId() != null ? request.getCompanyId() : companyId)
+                    .companyUserId(request.getCompanyUserId() != null ? request.getCompanyUserId() : CommAuthContext.getCompanyUserId())
+                    .senderName(request.getSenderName())
+                    .phone(request.getPhone())
+                    .customerId(request.getCustomerId())
+                    .cardUrl(request.getCardUrl())
+                    .build());
+
+            calleePhone = commVoiceLimitService.normalizePhone(result.getPhone());
+            Map<String, Object> response = new HashMap<>();
+            response.put("callbackUuid", result.getCallbackUuid());
+            response.put("customerId", result.getCustomerId());
+            response.put("phone", result.getPhone());
+
+            recordResult = commGatewayApiLogRecorder.buildSuccess(response, calleePhone, callerKey, null);
+            commMetricsService.increment("sms.success");
+            return response;
+        } catch (ServiceException ex) {
+            boolean limitHit = CommGatewayApiLogRecorder.isLimitFailure(ex);
+            recordResult = limitHit
+                    ? commGatewayApiLogRecorder.buildLimitFailure(ex, calleePhone, callerKey, null)
+                    : commGatewayApiLogRecorder.buildFailure(ex, calleePhone, callerKey, null);
+            throw ex;
+        } catch (Exception ex) {
+            log.error("短信调用异常 companyId={}", companyId, ex);
+            ServiceException wrapped = ex instanceof ServiceException
+                    ? (ServiceException) ex
+                    : new ServiceException(StringUtils.defaultIfBlank(ex.getMessage(), "短信调用异常"));
+            recordResult = commGatewayApiLogRecorder.buildFailure(wrapped, calleePhone, callerKey, null);
+            throw wrapped;
+        } finally {
+            commGatewayApiLogRecorder.recordSmsAttempt(companyId, tenantId, request, recordResult,
+                    calleePhone, callerKey, null, startMs);
+        }
     }
 }

+ 144 - 0
fs-comm-gateway/src/main/java/com/fs/comm/sms/MyCommSmsProvider.java

@@ -0,0 +1,144 @@
+package com.fs.comm.sms;
+
+import cn.hutool.http.HttpRequest;
+import cn.hutool.json.JSONUtil;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.vo.SmsSendItemVO;
+import com.fs.common.vo.SmsSendVO;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 迈远(my)短信 HTTP 发送实现。
+ * 对接 company_sms_api / company_sms_api_port 配置的 url、account、password、sign、extno。
+ */
+@Slf4j
+@Service
+public class MyCommSmsProvider implements CommSmsProvider {
+
+    private static final String PROVIDER = "my";
+
+    @Override
+    public String provider() {
+        return PROVIDER;
+    }
+
+    @Override
+    public CommSmsChannelResult send(CommSmsChannelRequest request) {
+        String validateError = validate(request);
+        if (validateError != null) {
+            return CommSmsChannelResult.fail(validateError, null);
+        }
+
+        String sendUrl;
+        try {
+            sendUrl = buildSendUrl(request);
+        } catch (UnsupportedEncodingException e) {
+            log.error("MyCommSmsProvider URL编码异常 phone={}", request.getPhone(), e);
+            return CommSmsChannelResult.fail("ENCODE_ERROR", null);
+        }
+
+        String responseBody;
+        try {
+            responseBody = HttpRequest.get(sendUrl).timeout(15000).execute().body();
+        } catch (Exception e) {
+            log.error("MyCommSmsProvider HTTP请求异常 phone={}, url={}", request.getPhone(), maskUrl(sendUrl), e);
+            return CommSmsChannelResult.fail("HTTP_ERROR", e.getMessage());
+        }
+
+        return parseResponse(responseBody, request.getPhone());
+    }
+
+    private String validate(CommSmsChannelRequest request) {
+        if (request == null) {
+            return "INVALID_REQUEST";
+        }
+        if (StringUtils.isBlank(request.getPhone())) {
+            return "INVALID_PHONE";
+        }
+        if (StringUtils.isBlank(request.getUrl())) {
+            return "INVALID_URL";
+        }
+        if (StringUtils.isBlank(request.getAccount()) || StringUtils.isBlank(request.getPassword())) {
+            return "INVALID_CREDENTIAL";
+        }
+        if (request.getTempType() == null) {
+            return "UNSUPPORTED_TEMP_TYPE";
+        }
+        if (!Integer.valueOf(1).equals(request.getTempType()) && !Integer.valueOf(2).equals(request.getTempType())) {
+            return "UNSUPPORTED_TEMP_TYPE";
+        }
+        return null;
+    }
+
+    /**
+     * 迈远发送 URL:{url}sms?action=send&account=...&password=...&mobile=...&content=...&extno=...&rt=json
+     */
+    String buildSendUrl(CommSmsChannelRequest request) throws UnsupportedEncodingException {
+        String sign = StringUtils.defaultString(request.getSign());
+        String body = request.getContent();
+        if (Integer.valueOf(2).equals(request.getTempType())) {
+            body = body + "拒收请回复R";
+        }
+        String encodedContent = URLEncoder.encode(sign + body, StandardCharsets.UTF_8.name());
+        String baseUrl = normalizeBaseUrl(request.getUrl());
+        return baseUrl + "sms?action=send"
+                + "&account=" + request.getAccount()
+                + "&password=" + request.getPassword()
+                + "&mobile=" + request.getPhone()
+                + "&content=" + encodedContent
+                + "&extno=" + StringUtils.defaultString(request.getExtno())
+                + "&rt=json";
+    }
+
+    private CommSmsChannelResult parseResponse(String responseBody, String phone) {
+        if (StringUtils.isBlank(responseBody)) {
+            return CommSmsChannelResult.fail("EMPTY_RESPONSE", null);
+        }
+        try {
+            SmsSendVO vo = JSONUtil.toBean(responseBody, SmsSendVO.class);
+            if (vo == null || vo.getStatus() == null || !Integer.valueOf(0).equals(vo.getStatus())) {
+                log.warn("MyCommSmsProvider 发送失败 phone={}, response={}", phone, abbreviate(responseBody));
+                return CommSmsChannelResult.fail("SEND_FAILED", abbreviate(responseBody));
+            }
+            if (vo.getList() == null) {
+                return CommSmsChannelResult.fail("SEND_FAILED", abbreviate(responseBody));
+            }
+            for (SmsSendItemVO item : vo.getList()) {
+                if (item != null && "0".equals(item.getResult())) {
+                    return CommSmsChannelResult.ok(item.getMid());
+                }
+            }
+            log.warn("MyCommSmsProvider 无成功条目 phone={}, response={}", phone, abbreviate(responseBody));
+            return CommSmsChannelResult.fail("SEND_FAILED", abbreviate(responseBody));
+        } catch (Exception e) {
+            log.error("MyCommSmsProvider 响应解析异常 phone={}, response={}", phone, abbreviate(responseBody), e);
+            return CommSmsChannelResult.fail("PARSE_ERROR", abbreviate(responseBody));
+        }
+    }
+
+    private String normalizeBaseUrl(String url) {
+        if (StringUtils.isBlank(url)) {
+            return "";
+        }
+        return url.endsWith("/") ? url : url + "/";
+    }
+
+    private String maskUrl(String url) {
+        if (StringUtils.isBlank(url)) {
+            return url;
+        }
+        return url.replaceAll("password=[^&]*", "password=***");
+    }
+
+    private String abbreviate(String text) {
+        if (text == null) {
+            return null;
+        }
+        return text.length() > 500 ? text.substring(0, 500) : text;
+    }
+}

+ 32 - 6
fs-comm-gateway/对接文档.md

@@ -302,6 +302,28 @@ Content-Type: application/json
 
 发送结果异步写入租户库 `company_voice_robotic_call_log_sendmsg`(status:1 进行中 / 2 成功 / 3 失败)。
 
+**迈远(provider=my)发送说明:**
+
+网关进程内由 `MyCommSmsProvider` 负责实际 HTTP 下发,不再读取旧的 `his.sms` 全局配置,而是按租户在 Admin 配置的接口表路由:
+
+| 配置表 | 字段 | 说明 |
+|--------|------|------|
+| `company_sms_api` | `provider=my` | 固定为迈远 |
+| | `url` | 接口根地址 |
+| | `account` / `password` | 账户密码 |
+| | `sign` | 短信签名 |
+| `company_sms_api_port` | `port_no` | 迈远扩展码 `extno` |
+| | `account` / `password` / `sign` | 可选,覆盖接口级配置 |
+
+请求 URL 格式(与旧版 `sendCaptcha` 一致):
+
+```
+{url}sms?action=send&account={account}&password={password}&mobile={phone}&content={URLEncode(sign+content)}&extno={extno}&rt=json
+```
+
+- 模板类型 `tempType=1`(行业通知):内容为 `sign + content`
+- 模板类型 `tempType=2`(营销):内容为 `sign + content + 拒收请回复R`
+
 ---
 
 ### 5.3 查询外呼记录
@@ -540,13 +562,17 @@ location /comm/ {
 
 ## 11. 附录:相关数据表
 
-| 表名 | 说明 |
-|------|------|
-| company_voice_robotic_call_log_callphone | AI 外呼执行日志 |
-| company_voice_robotic_call_log_sendmsg | 短信发送日志 |
-| company_voice_robotic_call_log_addwx | 加微执行日志(其他模块写入) |
+| 表名 | 库 | 说明 |
+|------|-----|------|
+| comm_gateway_api_log | 主库 | 网关 API 调用日志(频率计数、Admin 查询) |
+| company_comm_gateway_log | 租户库 | 网关 API 调用记录(成功/失败/限频均写入) |
+| company_voice_robotic_call_log_callphone | 租户库 | AI 外呼执行日志 |
+| company_voice_robotic_call_log_sendmsg | 租户库 | 短信发送日志 |
+| company_voice_robotic_call_log_addwx | 租户库 | 加微执行日志(其他模块写入) |
+
+外呼/短信业务日志与 `company_comm_gateway_log` 均写入**当前租户库**;`comm_gateway_api_log` 写入主库用于全平台统计与频率限制计数。
 
-日志写入均路由至**当前租户库**,非主库。
+存量租户需执行:`fs-service/src/main/resources/db/20250604-company-comm-gateway-log.sql`
 
 ---
 

+ 47 - 0
fs-company/src/main/java/com/fs/company/controller/company/CompanyCommGatewayLogController.java

@@ -0,0 +1,47 @@
+package com.fs.company.controller.company;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.company.domain.CompanyCommGatewayLog;
+import com.fs.company.service.ICompanyCommGatewayLogService;
+import com.fs.framework.security.SecurityUtils;
+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;
+
+import java.util.List;
+
+/**
+ * 通讯网关 API 调用记录(租户库)
+ */
+@RestController
+@RequestMapping("/company/commGatewayLog")
+public class CompanyCommGatewayLogController extends BaseController {
+
+    @Autowired
+    private ICompanyCommGatewayLogService companyCommGatewayLogService;
+
+    @GetMapping("/list")
+    public TableDataInfo list(CompanyCommGatewayLog query) {
+        query.setCompanyId(currentCompanyId());
+        startPage();
+        List<CompanyCommGatewayLog> list = companyCommGatewayLogService.selectList(query);
+        return getDataTable(list);
+    }
+
+    @GetMapping("/{logId}")
+    public AjaxResult getInfo(@PathVariable Long logId) {
+        CompanyCommGatewayLog log = companyCommGatewayLogService.selectById(logId);
+        if (log == null || !currentCompanyId().equals(log.getCompanyId())) {
+            return AjaxResult.error("记录不存在");
+        }
+        return AjaxResult.success(log);
+    }
+
+    private Long currentCompanyId() {
+        return SecurityUtils.getLoginUser().getUser().getCompanyId();
+    }
+}

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

+ 0 - 3
fs-company/src/main/java/com/fs/company/controller/fastGpt/FastgptChatArtificialWordsCompanyController.java

@@ -60,9 +60,6 @@ public class FastgptChatArtificialWordsCompanyController extends BaseController
     @Autowired
     private TokenService tokenService;
 
-    @Autowired
-    private IdentityHidingService identityHidingService;
-
     /**
      * 查询转人工关键词列表(租户隔离)
      * 自动注入当前租户的company_id,只返回该租户的关键词

+ 7 - 4
fs-company/src/main/java/com/fs/company/controller/qw/QwExternalContactController.java

@@ -56,6 +56,7 @@ import org.springframework.beans.factory.annotation.Value;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
+import javax.servlet.http.HttpServletRequest;
 import javax.validation.Valid;
 import java.io.IOException;
 import java.net.SocketTimeoutException;
@@ -203,8 +204,8 @@ public class QwExternalContactController extends BaseController
     @Log(title = "同步企业微信客户", businessType = BusinessType.INSERT)
     @PostMapping
     public R add(@RequestBody QwExternalContact qwExternalContact) throws IOException {
-
-        return qwExternalContactService.syncQwExternalContact(qwExternalContact.getCorpId());
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        return qwExternalContactService.syncQwExternalContact(qwExternalContact.getCorpId(),loginUser.getTenantId());
     }
 
     /**
@@ -331,8 +332,10 @@ public class QwExternalContactController extends BaseController
 
     @Log(title = "同步我的企业微信客户", businessType = BusinessType.INSERT)
     @GetMapping("/syncMyExternalContact/{id}")
-    public R  syncMyExternalContact(@PathVariable("id") Long id ) throws IOException {
-        return qwExternalContactService.syncMyQwExternalContact(id);
+    public R  syncMyExternalContact(@PathVariable("id") Long id, HttpServletRequest request ) throws IOException {
+        LoginUser loginUser = tokenService.getLoginUser(request);
+        Long tenantId = loginUser.getTenantId();
+        return qwExternalContactService.syncMyQwExternalContact(id,tenantId);
     }
 
 

+ 176 - 0
fs-company/src/main/java/com/fs/company/controller/workflow/LobsterAdminController.java

@@ -0,0 +1,176 @@
+package com.fs.company.controller;
+
+import com.fs.company.service.workflow.evolution.EvolutionEngine;
+import com.fs.company.service.workflow.evolution.UserNodeOptimizer;
+import com.fs.company.service.workflow.evolution.impl.EvolutionSchedulerImpl;
+import com.fs.company.service.workflow.feedback.FeedbackDrivenEvolution;
+import com.fs.company.service.workflow.learning.TenantLearningEngine;
+import com.fs.company.service.workflow.monitor.DashboardService;
+import com.fs.company.service.workflow.pay.PayService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.*;
+
+/**
+ * 龙虾引擎 Admin REST API
+ * <p>
+ * 提供前端调用入口:进化审批 / 学习结果 / A/B测试 / 监控看板 / 支付查询
+ */
+@RestController
+@RequestMapping("/api/lobster/admin")
+public class LobsterAdminController {
+
+    private static final Logger log = LoggerFactory.getLogger(LobsterAdminController.class);
+
+    @Autowired(required = false)
+    private EvolutionEngine evolutionEngine;
+
+    @Autowired(required = false)
+    private UserNodeOptimizer userNodeOptimizer;
+
+    @Autowired(required = false)
+    private FeedbackDrivenEvolution feedbackDrivenEvolution;
+
+    @Autowired(required = false)
+    private TenantLearningEngine tenantLearningEngine;
+
+    @Autowired(required = false)
+    private EvolutionSchedulerImpl evolutionScheduler;
+
+    @Autowired(required = false)
+    private DashboardService dashboardService;
+
+    @Autowired(required = false)
+    private PayService payService;
+
+    // ════════════ 进化管理 ════════════
+
+    /** 租户进化指标统计 */
+    @GetMapping("/evolution/metrics/{companyId}")
+    public Map<String, Object> evolutionMetrics(@PathVariable Long companyId) {
+        return evolutionEngine != null ? evolutionEngine.getEvolutionMetrics(companyId) : Collections.emptyMap();
+    }
+
+    /** 应用优化建议 */
+    @PostMapping("/evolution/apply/{companyId}/{suggestionId}")
+    public Map<String, Object> applySuggestion(@PathVariable Long companyId, @PathVariable Long suggestionId) {
+        evolutionEngine.applySuggestion(companyId, suggestionId);
+        return Map.of("result", "applied");
+    }
+
+    // ════════════ 用户级优化 ════════════
+
+    /** 待审核优化列表 */
+    @GetMapping("/optimization/pending/{companyId}")
+    public Map<String, Object> pendingOptimizations(@PathVariable Long companyId,
+                                                     @RequestParam(defaultValue = "1") int page,
+                                                     @RequestParam(defaultValue = "20") int pageSize,
+                                                     @RequestParam(required = false) Long workflowId) {
+        return userNodeOptimizer != null
+            ? Map.of("list", userNodeOptimizer.getPendingAuditList(companyId, workflowId, page, pageSize))
+            : Collections.emptyMap();
+    }
+
+    /** 批量审核优化 */
+    @PostMapping("/optimization/audit/{companyId}")
+    public Map<String, Object> batchAudit(@PathVariable Long companyId,
+                                           @RequestParam String auditorId,
+                                           @RequestBody List<Map<String, Object>> items) {
+        List<UserNodeOptimizer.AuditItem> auditItems = new ArrayList<>();
+        for (Map<String, Object> item : items) {
+            UserNodeOptimizer.AuditItem ai = new UserNodeOptimizer.AuditItem();
+            ai.setOptimizationId(((Number) item.get("id")).longValue());
+            ai.setApproved(Boolean.TRUE.equals(item.get("approved")));
+            auditItems.add(ai);
+        }
+        return Map.of("result", userNodeOptimizer != null
+            ? userNodeOptimizer.batchAudit(companyId, auditItems, auditorId, "").getSummary()
+            : "optimizer unavailable");
+    }
+
+    // ════════════ 用户级优化 ════════════
+
+    /** 优化开关配置 */
+    @PostMapping("/optimization/config/{companyId}")
+    public Map<String, Object> setOptimizationConfig(@PathVariable Long companyId,
+                                                      @RequestParam Long workflowId,
+                                                      @RequestParam String nodeCode,
+                                                      @RequestParam boolean enabled,
+                                                      @RequestParam boolean autoApply,
+                                                      @RequestParam(defaultValue = "admin") String configBy) {
+        userNodeOptimizer.setOptimizationConfig(companyId, workflowId, nodeCode, enabled, autoApply, configBy);
+        return Map.of("result", "ok");
+    }
+
+    // ════════════ A/B 测试 ════════════
+
+    /** 活跃 A/B 测试列表 */
+    @GetMapping("/ab-test/active/{companyId}")
+    public Map<String, Object> activeAbTests(@PathVariable Long companyId) {
+        return feedbackDrivenEvolution != null
+            ? Map.of("tests", feedbackDrivenEvolution.getActiveABTests(companyId))
+            : Collections.emptyMap();
+    }
+
+    /** 应用 A/B 测试结果 */
+    @PostMapping("/ab-test/apply/{companyId}/{testId}")
+    public Map<String, Object> applyAbTest(@PathVariable Long companyId, @PathVariable Long testId) {
+        return feedbackDrivenEvolution != null
+            ? Map.of("result", feedbackDrivenEvolution.applyABTestResult(companyId, testId).getMessage())
+            : Map.of("result", "unavailable");
+    }
+
+    // ════════════ 学习引擎 ════════════
+
+    /** 触发学习周期 */
+    @PostMapping("/learning/trigger/{companyId}")
+    public Map<String, Object> triggerLearning(@PathVariable Long companyId) {
+        return tenantLearningEngine != null
+            ? Map.of("summary", tenantLearningEngine.triggerLearningCycle(companyId).getSummary())
+            : Map.of("result", "unavailable");
+    }
+
+    /** 学习结果列表 */
+    @GetMapping("/learning/results/{companyId}")
+    public Map<String, Object> learningResults(@PathVariable Long companyId) {
+        return tenantLearningEngine != null
+            ? Map.of("results", tenantLearningEngine.getLearningResults(companyId))
+            : Collections.emptyMap();
+    }
+
+    /** 应用学习结果 */
+    @PostMapping("/learning/apply/{companyId}/{resultId}")
+    public Map<String, Object> applyLearning(@PathVariable Long companyId, @PathVariable Long resultId) {
+        return tenantLearningEngine != null
+            ? Map.of("result", tenantLearningEngine.applyLearningResult(companyId, resultId, null).getMessage())
+            : Map.of("result", "unavailable");
+    }
+
+    // ════════════ 监控看板 ════════════
+
+    /** 租户全景看板 */
+    @GetMapping("/dashboard/{companyId}")
+    public Map<String, Object> dashboard(@PathVariable Long companyId) {
+        return dashboardService != null ? dashboardService.getTenantDashboard(companyId) : Collections.emptyMap();
+    }
+
+    // ════════════ 支付 ════════════
+
+    /** 支付状态查询 */
+    @GetMapping("/pay/status")
+    public Map<String, Object> payStatus(@RequestParam String orderNo, @RequestParam(defaultValue = "wechat") String gateway) {
+        return payService != null ? payService.checkPayStatus(orderNo, gateway) : Collections.emptyMap();
+    }
+
+    // ════════════ 测试结果 ════════════
+
+    /** 手动触发进化调度 */
+    @PostMapping("/scheduler/trigger")
+    public Map<String, Object> triggerScheduler() {
+        evolutionScheduler.recordTask(0L, "manual_trigger", "ok");
+        return Map.of("result", "triggered");
+    }
+}

+ 131 - 0
fs-company/src/main/java/com/fs/company/controller/workflow/LobsterAiGeneratorController.java

@@ -0,0 +1,131 @@
+package com.fs.company.controller.workflow;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.company.service.workflow.MultiModelWorkflowGenerator;
+import com.fs.company.service.workflow.MultiModelWorkflowGenerator.GenerationResult;
+import com.fs.company.service.workflow.MultiModelWorkflowGenerator.ModelConfig;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 龙虾引擎 AI 生成工作流 Controller
+ * 包装 MultiModelWorkflowGenerator,对接前端 /workflow/lobster/ai-generator/*
+ */
+@RestController
+@RequestMapping("/workflow/lobster/ai-generator")
+public class LobsterAiGeneratorController extends BaseController {
+
+    @Autowired
+    private MultiModelWorkflowGenerator generator;
+
+    /** 生成结果临时缓存:recordId → GenerationResult */
+    private final Map<Long, GenerationResult> resultCache = new ConcurrentHashMap<>();
+
+    /**
+     * 触发 AI 生成工作流
+     * 入参:requirement / industryType / companyId
+     */
+    @PostMapping("/generate")
+    public AjaxResult generate(@RequestBody Map<String, Object> body) {
+        Long companyId = body.get("companyId") != null ? Long.valueOf(body.get("companyId").toString()) : 0L;
+        String requirement = (String) body.getOrDefault("requirement", "");
+        String industryType = (String) body.getOrDefault("industryType", "general");
+
+        if (requirement.isEmpty()) {
+            return AjaxResult.error("requirement 不能为空");
+        }
+
+        // 模型配置走 admin_ai_scene 体系(workflow_generation 场景),传 null 即由 generator 内部按场景拉模型
+        ModelConfig modelConfig = null;
+        GenerationResult result = generator.generateWorkflowWithResult(companyId, requirement, industryType, modelConfig);
+
+        Long recordId = System.currentTimeMillis();
+        resultCache.put(recordId, result);
+
+        Map<String, Object> resp = new HashMap<>();
+        resp.put("recordId", recordId);
+        resp.put("success", result.isSuccess());
+        resp.put("errorMsg", result.getErrorMsg());
+        resp.put("qualityScore", result.getQualityScore());
+        return AjaxResult.success(resp);
+    }
+
+    /**
+     * 查询生成结果详情
+     */
+    @GetMapping("/result/{recordId}/detail")
+    public AjaxResult getResult(@PathVariable Long recordId) {
+        GenerationResult result = resultCache.get(recordId);
+        if (result == null) {
+            return AjaxResult.error("生成结果不存在或已过期");
+        }
+        Map<String, Object> data = new HashMap<>();
+        data.put("recordId", recordId);
+        data.put("success", result.isSuccess());
+        data.put("workflowJson", result.getWorkflowJson());
+        data.put("modelAOutput", result.getModelAOutput());
+        data.put("modelBOutput", result.getModelBOutput());
+        data.put("modelCOutput", result.getModelCOutput());
+        data.put("qualityScore", result.getQualityScore());
+        data.put("suggestions", result.getSuggestions());
+        data.put("errorMsg", result.getErrorMsg());
+        return AjaxResult.success(data);
+    }
+
+    /**
+     * 确认生成结果(保存为工作流模板)
+     * 入参:workflowName / companyId
+     */
+    @PostMapping("/confirm/{recordId}")
+    public AjaxResult confirm(@PathVariable Long recordId, @RequestBody Map<String, Object> body) {
+        GenerationResult result = resultCache.get(recordId);
+        if (result == null || !result.isSuccess()) {
+            return AjaxResult.error("生成结果不存在或失败");
+        }
+        // TODO:调 CompanyWorkflowLobsterService.saveAsTemplate(json, workflowName, companyId)
+        // 当前先返回 JSON 给前端,由前端走原有保存流程
+        Map<String, Object> data = new HashMap<>();
+        data.put("workflowJson", result.getWorkflowJson());
+        data.put("workflowName", body.get("workflowName"));
+        resultCache.remove(recordId);
+        return AjaxResult.success(data);
+    }
+
+    /**
+     * 确认(带前端编辑后内容)
+     */
+    @PostMapping("/confirm/{recordId}/edited")
+    public AjaxResult confirmEdited(@PathVariable Long recordId, @RequestBody Map<String, Object> body) {
+        String editedJson = (String) body.get("workflowJson");
+        Map<String, Object> data = new HashMap<>();
+        data.put("workflowJson", editedJson);
+        data.put("workflowName", body.get("workflowName"));
+        resultCache.remove(recordId);
+        return AjaxResult.success(data);
+    }
+
+    /**
+     * 基于现有工作流再次生成(迭代优化)
+     */
+    @PostMapping("/regenerate/{recordId}")
+    public AjaxResult regenerate(@PathVariable Long recordId, @RequestBody Map<String, Object> body) {
+        Long companyId = body.get("companyId") != null ? Long.valueOf(body.get("companyId").toString()) : 0L;
+        String existingJson = (String) body.getOrDefault("existingWorkflowJson", "");
+        String modifyInstruction = (String) body.getOrDefault("modifyInstruction", "");
+
+        GenerationResult result = generator.iterateOptimize(companyId, existingJson, modifyInstruction, null);
+        Long newRecordId = System.currentTimeMillis();
+        resultCache.put(newRecordId, result);
+
+        Map<String, Object> resp = new HashMap<>();
+        resp.put("recordId", newRecordId);
+        resp.put("success", result.isSuccess());
+        resp.put("errorMsg", result.getErrorMsg());
+        return AjaxResult.success(resp);
+    }
+}

+ 10 - 23
fs-company/src/main/java/com/fs/company/controller/workflow/LobsterBillingController.java

@@ -3,7 +3,8 @@ package com.fs.company.controller.workflow;
 import com.fs.common.core.controller.BaseController;
 import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.utils.ServletUtils;
-import com.fs.company.service.billing.BillingService;
+import com.fs.company.domain.LobsterTenantBalance;
+import com.fs.company.service.workflow.ILobsterBillingService;
 import com.fs.framework.security.LoginUser;
 import com.fs.framework.service.TokenService;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -22,8 +23,8 @@ import java.util.*;
 @RequestMapping("/workflow/lobster/billing")
 public class LobsterBillingController extends BaseController {
 
-    @Autowired(required = false)
-    private BillingService billingService;
+    @Autowired
+    private ILobsterBillingService billingService;
 
     @Autowired
     private TokenService tokenService;
@@ -35,8 +36,9 @@ public class LobsterBillingController extends BaseController {
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         Long tenantId = loginUser.getCompany().getCompanyId();
 
-        BigDecimal coef = billingService != null ?
-                billingService.getTokenCoefficient(tenantId) : new BigDecimal("1.00");
+        LobsterTenantBalance balance = billingService.getBalance(tenantId);
+        BigDecimal coef = (balance != null && balance.getTokenCoefficient() != null) ?
+                balance.getTokenCoefficient() : new BigDecimal("1.00");
 
         Map<String, Object> result = new LinkedHashMap<>();
         result.put("tenantId", tenantId);
@@ -66,12 +68,8 @@ public class LobsterBillingController extends BaseController {
             return AjaxResult.error("系数范围: 0.01 ~ 100");
         }
 
-        if (billingService != null) {
-            boolean ok = billingService.updateTokenCoefficient(tenantId, coefficient);
-            return ok ? AjaxResult.success("Token系数已更新为 " + coefficient) : AjaxResult.error("更新失败");
-        }
-
-        return AjaxResult.error("计费服务未初始化");
+        boolean ok = billingService.updateTokenCoefficient(tenantId, coefficient);
+        return ok ? AjaxResult.success("Token系数已更新为 " + coefficient) : AjaxResult.error("更新失败");
     }
 
     /** 消费记录列表 */
@@ -82,18 +80,7 @@ public class LobsterBillingController extends BaseController {
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         Long tenantId = loginUser.getCompany().getCompanyId();
 
-        Map<String, Object> result = new LinkedHashMap<>();
-        if (billingService != null) {
-            List<Map<String, Object>> list = billingService.getConsumeRecords(tenantId, page, size);
-            long total = billingService.countConsumeRecords(tenantId);
-            result.put("list", list);
-            result.put("total", total);
-        } else {
-            result.put("list", Collections.emptyList());
-            result.put("total", 0);
-        }
-        result.put("page", page);
-        result.put("size", size);
+        Map<String, Object> result = billingService.listConsumeRecords(page, size, tenantId);
         return AjaxResult.success(result);
     }
 

+ 8 - 22
fs-company/src/main/java/com/fs/company/controller/workflow/LobsterEventAuditController.java

@@ -28,7 +28,7 @@ import java.util.*;
 public class LobsterEventAuditController extends BaseController {
 
     @Autowired
-    private ILobsterEventAuditService eventAuditService;
+    private ILobsterEventAuditService auditService;
 
     @Autowired
     private TokenService tokenService;
@@ -47,20 +47,7 @@ public class LobsterEventAuditController extends BaseController {
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         Long companyId = loginUser.getCompany().getCompanyId();
 
-        List<LobsterEventAudit> list = eventAuditService.selectListByCompanyIdAndStatus(companyId, status, page, size);
-        long total = eventAuditService.countByCompanyIdAndStatus(companyId, status);
-
-        Map<String, Object> stats = new LinkedHashMap<>();
-        stats.put("pending", eventAuditService.countByCompanyIdAndStatus(companyId, "pending"));
-        stats.put("approved", eventAuditService.countByCompanyIdAndStatus(companyId, "approved"));
-        stats.put("rejected", eventAuditService.countByCompanyIdAndStatus(companyId, "rejected"));
-
-        Map<String, Object> result = new LinkedHashMap<>();
-        result.put("list", list);
-        result.put("total", total);
-        result.put("page", page);
-        result.put("size", size);
-        result.put("stats", stats);
+        Map<String, Object> result = auditService.listAudits(status, page, size, companyId);
         return AjaxResult.success(result);
     }
 
@@ -72,8 +59,8 @@ public class LobsterEventAuditController extends BaseController {
     public AjaxResult approve(@PathVariable Long id) {
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
 
-        LobsterEventAudit record = eventAuditService.selectByIdAndStatus(id, "pending");
-        if (record == null) {
+        LobsterEventAudit record = auditService.getById(id);
+        if (record == null || !"pending".equals(record.getStatus())) {
             return AjaxResult.error("审核记录不存在或已处理");
         }
 
@@ -91,8 +78,7 @@ public class LobsterEventAuditController extends BaseController {
             }
         }
 
-        String comment = patched ? "审核通过,节点已注入" : "审核通过";
-        eventAuditService.approve(id, loginUser.getUsername(), comment);
+        auditService.approve(id, loginUser.getUsername());
 
         Map<String, Object> result = new HashMap<>();
         result.put("patched", patched);
@@ -109,9 +95,9 @@ public class LobsterEventAuditController extends BaseController {
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
 
         String comment = body != null ? (String) body.getOrDefault("comment", "人工驳回") : "人工驳回";
-        eventAuditService.reject(id, loginUser.getUsername(), comment);
+        boolean ok = auditService.reject(id, loginUser.getUsername(), comment);
 
-        return AjaxResult.success("已驳回");
+        return ok ? AjaxResult.success("已驳回") : AjaxResult.error("审核记录不存在");
     }
 
     /**
@@ -120,7 +106,7 @@ public class LobsterEventAuditController extends BaseController {
     @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
     @GetMapping("/{id}")
     public AjaxResult detail(@PathVariable Long id) {
-        LobsterEventAudit record = eventAuditService.selectById(id);
+        LobsterEventAudit record = auditService.getById(id);
         return AjaxResult.success(record);
     }
 }

+ 108 - 0
fs-company/src/main/java/com/fs/company/controller/workflow/LobsterModelRouteController.java

@@ -0,0 +1,108 @@
+package com.fs.company.controller.workflow;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.utils.ServletUtils;
+import com.fs.company.domain.LobsterModelConfig;
+import com.fs.company.service.workflow.LobsterModelConfigService;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.LocalDateTime;
+import java.util.*;
+
+/**
+ * 龙虾多模型路由配置Controller(租户端,非桥接)
+ * 管理 lobster_model_config 表的CRUD
+ */
+@RestController
+@RequestMapping("/workflow/lobster/model-route")
+public class LobsterModelRouteController extends BaseController {
+
+    @Autowired
+    private LobsterModelConfigService modelConfigService;
+
+    @Autowired
+    private TokenService tokenService;
+
+    private Long getCurrentCompanyId() {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        return loginUser.getCompany().getCompanyId();
+    }
+
+    /**
+     * 获取当前租户的所有模型路由配置
+     */
+    @GetMapping("/list")
+    public AjaxResult list() {
+        Long companyId = getCurrentCompanyId();
+        List<LobsterModelConfig> configs = modelConfigService.getConfigsByCompany(companyId);
+        return AjaxResult.success(configs);
+    }
+
+    /**
+     * 根据配置类型获取配置
+     */
+    @GetMapping("/{configType}")
+    public AjaxResult getByType(@PathVariable String configType) {
+        Long companyId = getCurrentCompanyId();
+        LobsterModelConfig config = modelConfigService.getConfig(companyId, configType);
+        return AjaxResult.success(config);
+    }
+
+    /**
+     * 新增模型路由配置
+     */
+    @PostMapping
+    public AjaxResult add(@RequestBody LobsterModelConfig config) {
+        Long companyId = getCurrentCompanyId();
+        config.setCompanyId(companyId);
+        config.setEnabled(1);
+        config.setCreatedAt(LocalDateTime.now());
+        config.setUpdatedAt(LocalDateTime.now());
+        modelConfigService.saveConfig(config);
+        return AjaxResult.success("配置已保存");
+    }
+
+    /**
+     * 更新模型路由配置
+     */
+    @PutMapping("/{id}")
+    public AjaxResult update(@PathVariable Long id, @RequestBody LobsterModelConfig config) {
+        config.setId(id);
+        config.setUpdatedAt(LocalDateTime.now());
+        modelConfigService.updateConfig(config);
+        return AjaxResult.success("配置已更新");
+    }
+
+    /**
+     * 删除模型路由配置
+     */
+    @DeleteMapping("/{id}")
+    public AjaxResult delete(@PathVariable Long id) {
+        modelConfigService.deleteConfig(id);
+        return AjaxResult.success("配置已删除");
+    }
+
+    /**
+     * 获取支持的配置类型列表
+     */
+    @GetMapping("/types")
+    public AjaxResult types() {
+        List<Map<String, String>> types = new ArrayList<>();
+        types.add(buildType("workflow_generator", "工作流生成"));
+        types.add(buildType("quality_check", "质检评分"));
+        types.add(buildType("intent_router", "意图路由"));
+        types.add(buildType("reply_generator", "回复生成"));
+        return AjaxResult.success(types);
+    }
+
+    private Map<String, String> buildType(String code, String name) {
+        Map<String, String> m = new LinkedHashMap<>();
+        m.put("code", code);
+        m.put("name", name);
+        return m;
+    }
+}

+ 30 - 8
fs-company/src/main/java/com/fs/company/controller/workflow/LobsterPromptController.java

@@ -11,7 +11,7 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
-import java.util.Map;
+import java.util.*;
 
 /**
  * 龙虾系统提示词管理Controller
@@ -41,23 +41,44 @@ public class LobsterPromptController extends BaseController {
     @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
     @GetMapping("/{id}")
     public AjaxResult getById(@PathVariable Long id) {
-        LobsterSystemPrompt prompt = promptService.getById(id);
-        return AjaxResult.success(prompt);
+        LobsterSystemPrompt row = promptService.getById(id);
+        return AjaxResult.success(row);
     }
 
     @PreAuthorize("@ss.hasPermi('workflow:lobster:edit')")
     @PostMapping
-    public AjaxResult create(@RequestBody LobsterSystemPrompt body) {
+    public AjaxResult create(@RequestBody Map<String, Object> body) {
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
-        promptService.create(body, loginUser.getUsername(), loginUser.getCompany().getCompanyId());
+        String content = (String) body.get("promptContent");
+        if (content == null || content.isEmpty()) return AjaxResult.error("提示词内容不能为空");
+
+        LobsterSystemPrompt prompt = new LobsterSystemPrompt();
+        prompt.setPromptKey((String) body.getOrDefault("promptKey", "custom_" + System.currentTimeMillis()));
+        prompt.setPromptName((String) body.getOrDefault("promptName", "自定义提示词"));
+        prompt.setPromptCategory((String) body.getOrDefault("promptCategory", "custom"));
+        prompt.setPromptContent(content);
+        prompt.setModelName((String) body.getOrDefault("modelName", "doubao-lite"));
+        prompt.setSystemRole((String) body.get("systemRole"));
+        prompt.setIndustryType((String) body.get("industryType"));
+
+        promptService.create(prompt, loginUser.getUsername(), loginUser.getCompany().getCompanyId());
         return AjaxResult.success("创建成功");
     }
 
     @PreAuthorize("@ss.hasPermi('workflow:lobster:edit')")
     @PutMapping("/{id}")
-    public AjaxResult update(@PathVariable Long id, @RequestBody LobsterSystemPrompt body) {
+    public AjaxResult update(@PathVariable Long id, @RequestBody Map<String, Object> body) {
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
-        promptService.update(id, body, loginUser.getCompany().getCompanyId());
+
+        LobsterSystemPrompt prompt = new LobsterSystemPrompt();
+        prompt.setPromptName((String) body.getOrDefault("promptName", ""));
+        prompt.setPromptCategory((String) body.getOrDefault("promptCategory", ""));
+        prompt.setPromptContent((String) body.get("promptContent"));
+        prompt.setModelName((String) body.getOrDefault("modelName", "doubao-lite"));
+        prompt.setSystemRole((String) body.get("systemRole"));
+        prompt.setIndustryType((String) body.get("industryType"));
+
+        promptService.update(id, prompt, loginUser.getCompany().getCompanyId());
         return AjaxResult.success("更新成功");
     }
 
@@ -70,7 +91,8 @@ public class LobsterPromptController extends BaseController {
 
     @GetMapping("/categories")
     public AjaxResult categories() {
-        return AjaxResult.success(promptService.getCategories());
+        List<String> cats = promptService.getCategories();
+        return AjaxResult.success(cats);
     }
 
     @PreAuthorize("@ss.hasPermi('workflow:lobster:edit')")

+ 9 - 12
fs-company/src/main/java/com/fs/company/controller/workflow/LobsterSalesCorpusController.java

@@ -3,10 +3,8 @@ package com.fs.company.controller.workflow;
 import com.alibaba.fastjson.JSONObject;
 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.utils.ServletUtils;
-import com.fs.company.domain.LobsterLearningCorpus;
-import com.fs.company.service.workflow.ILobsterLearningCorpusService;
+import com.fs.company.service.workflow.ILobsterSalesCorpusService;
 import com.fs.company.service.workflow.learning.SalesCorpusAnalyzer;
 import com.fs.company.service.workflow.learning.SalesCorpusAnalyzer.AnalysisReport;
 import com.fs.company.service.workflow.learning.SalesCorpusAnalyzer.CorpusEntry;
@@ -21,7 +19,7 @@ import java.util.*;
 /**
  * 龙虾销冠语料管理Controller
  *
- * 表: lobster_learning_corpus
+ * 表: lobster_sales_corpus
  * 页面: 销冠语料 → 录入/批量导入/AI分析/话术库查询
  *
  * 核心价值: 租户上传销冠/金牌客服聊天话术 → AI分析提取沟通模式 → 进化引擎学习 → 全租户共享
@@ -34,7 +32,7 @@ public class LobsterSalesCorpusController extends BaseController {
     private SalesCorpusAnalyzer corpusAnalyzer;
 
     @Autowired
-    private ILobsterLearningCorpusService corpusService;
+    private ILobsterSalesCorpusService salesCorpusService;
 
     @Autowired
     private TokenService tokenService;
@@ -150,16 +148,15 @@ public class LobsterSalesCorpusController extends BaseController {
      */
     @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
     @GetMapping("/list")
-    public TableDataInfo list(@RequestParam(defaultValue = "1") int page,
-                              @RequestParam(defaultValue = "10") int size,
-                              @RequestParam(required = false) String scenario,
-                              @RequestParam(required = false) String status) {
+    public AjaxResult list(@RequestParam(defaultValue = "1") int page,
+                           @RequestParam(defaultValue = "10") int size,
+                           @RequestParam(required = false) String scenario,
+                           @RequestParam(required = false) String status) {
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         Long companyId = loginUser.getCompany().getCompanyId();
 
-        startPage();
-        List<LobsterLearningCorpus> list = corpusService.selectListByCompanyId(companyId, scenario, status);
-        return getDataTable(list);
+        Map<String, Object> result = salesCorpusService.listCorpus(page, size, companyId, scenario, status);
+        return AjaxResult.success(result);
     }
 
     /**

+ 21 - 0
fs-company/src/main/java/com/fs/company/controller/workflow/LobsterWorkflowExecController.java

@@ -247,4 +247,25 @@ public class LobsterWorkflowExecController extends BaseController {
         result.put("controlUpdatedAt", instance.getControlUpdatedAt());
         return AjaxResult.success(result);
     }
+
+    /**
+     * 模拟聊天测试:使用指定模板进行对话模拟
+     */
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:exec')")
+    @PostMapping("/simulate")
+    public AjaxResult simulateChat(@RequestBody Map<String, Object> params) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getCompany().getCompanyId();
+        String content = (String) params.getOrDefault("content", "");
+        Long templateId = params.get("templateId") != null ? Long.valueOf(params.get("templateId").toString()) : null;
+
+        // 模拟对话响应(后续可对接真实的 LobsterWorkflowExecutor 模拟执行能力)
+        Map<String, Object> reply = new java.util.HashMap<>();
+        reply.put("reply", "[模拟回复] \u300c" + content + "\u300d — 模板ID:" + (templateId != null ? templateId : "N/A"));
+        reply.put("templateId", templateId);
+        reply.put("companyId", companyId);
+        reply.put("timestamp", System.currentTimeMillis());
+        reply.put("mode", "simulate");
+        return AjaxResult.success(reply);
+    }
 }

+ 71 - 0
fs-company/src/main/java/com/fs/company/controller/workflow/PayCallbackController.java

@@ -0,0 +1,71 @@
+package com.fs.company.controller;
+
+import com.fs.company.service.workflow.pay.PayService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.*;
+
+/**
+ * 支付回调 Webhook 接收端点
+ * <p>
+ * 接收微信/支付宝/银联的异步支付通知,自动推进工作流
+ */
+@RestController
+@RequestMapping("/api/lobster/pay")
+public class PayCallbackController {
+
+    private static final Logger log = LoggerFactory.getLogger(PayCallbackController.class);
+
+    @Autowired(required = false)
+    private PayService payService;
+
+    /**
+     * 微信支付异步通知
+     */
+    @PostMapping("/notify/wechat")
+    public Map<String, Object> wechatNotify(@RequestBody String body) {
+        log.info("[PayCallback] 微信支付通知: {}", body);
+        try {
+            String orderNo = extractField(body, "out_trade_no");
+            String transactionId = extractField(body, "transaction_id");
+            if (orderNo != null && !orderNo.isEmpty()) {
+                if (payService != null) payService.checkPayStatus(orderNo, "wechat");
+            }
+            return Map.of("code", "SUCCESS", "message", "OK");
+        } catch (Exception e) {
+            return Map.of("code", "FAIL", "message", e.getMessage());
+        }
+    }
+
+    /**
+     * 支付宝异步通知
+     */
+    @PostMapping("/notify/alipay")
+    public Map<String, Object> alipayNotify(@RequestParam Map<String, String> params) {
+        log.info("[PayCallback] 支付宝支付通知: {}", params);
+        String orderNo = params.get("out_trade_no");
+        if (orderNo != null && payService != null) {
+            payService.checkPayStatus(orderNo, "alipay");
+        }
+        return Map.of("code", "SUCCESS", "message", "OK");
+    }
+
+    private String extractField(String xmlOrJson, String key) {
+        int idx = xmlOrJson.indexOf("\"" + key + "\"");
+        if (idx < 0) {
+            idx = xmlOrJson.indexOf("<" + key + ">");
+            if (idx >= 0) {
+                int start = idx + key.length() + 2;
+                int end = xmlOrJson.indexOf("</" + key + ">", start);
+                return end > start ? xmlOrJson.substring(start, end) : "";
+            }
+            return "";
+        }
+        int start = xmlOrJson.indexOf("\"", idx + key.length() + 3);
+        int end = xmlOrJson.indexOf("\"", start + 1);
+        return start > 0 && end > start ? xmlOrJson.substring(start + 1, end) : "";
+    }
+}

+ 1 - 1
fs-framework/src/main/java/com/fs/framework/config/HybridBeanNameGenerator.java

@@ -5,7 +5,7 @@ import org.springframework.beans.factory.support.BeanDefinitionRegistry;
 import org.springframework.context.annotation.AnnotationBeanNameGenerator;
 
 /**
- * Controller ʹ��ȫ���������� fs-saasadmin ͬ����ͻ��Service ������Ĭ�϶�����
+ * Controller 使用全限定类名避免与 fs-saasadmin 同名冲突,Service 则使用默认短名称
  */
 public class HybridBeanNameGenerator extends AnnotationBeanNameGenerator {
 

+ 47 - 47
fs-qw-api-msg/src/main/java/com/tencent/wework/Finance.java

@@ -19,58 +19,58 @@ public class Finance {
     public native static long NewSdk();
 
     /**
-     * ��ʼ������
-     * Returnֵ=0��ʾ��API���óɹ�
+     * 初始化函数
+     * Return值=0表示该API调用成功
      *
-     * @param [in] sdk			NewSdk���ص�sdkָ��
-     * @param [in] corpid      ������ҵ����ҵid�����磺wwd08c8exxxx5ab44d����������ҵ΢�Ź����--�ҵ���ҵ--��ҵ��Ϣ�鿴
-     * @param [in] secret		�������ݴ浵��Secret����������ҵ΢�Ź����--������--�������ݴ浵�鿴
-     * @return �����Ƿ��ʼ���ɹ�
-     * 0   - �ɹ�
-     * !=0 - ʧ��
+     * @param [in] sdk			NewSdk返回的sdk指针
+     * @param [in] corpid      调用企业的企业id,例如:wwd08c8exxxx5ab44d,登录企业微信管理后台--我的企业--企业信息查看
+     * @param [in] secret		会话内容存档的Secret,登录企业微信管理后台--管理工具--会话内容存档查看
+     * @return 返回是否初始化成功
+     * 0   - 成功
+     * !=0 - 失败
      */
     public native static int Init(long sdk, String corpid, String secret);
 
     /**
-     * ��ȡ�����¼����
-     * Returnֵ=0��ʾ��API���óɹ�
+     * 拉取聊天记录函数
+     * Return值=0表示该API调用成功
      *
-     * @param [in]  sdk				NewSdk���ص�sdkָ��
-     * @param [in]  seq				��ָ����seq��ʼ��ȡ��Ϣ��ע����Ƿ��ص���Ϣ��seq+1��ʼ���أ�seqΪ֮ǰ�ӿڷ��ص����seqֵ���״�ʹ����ʹ��seq:0
-     * @param [in]  limit			һ����ȡ����Ϣ���������ֵ1000��������1000���᷵�ش���
-     * @param [in]  proxy			ʹ�ô����������Ҫ�����������ӡ��磺socks5://10.0.0.1:8081 ���� http://10.0.0.1:8081
-     * @param [in]  passwd			�����˺����룬��Ҫ���������˺����롣�� user_name:passwd_123
-     * @param [out] chatDatas		���ر�����ȡ��Ϣ�����ݣ�slice�ṹ��.���ݰ���errcode/errmsg���Լ�ÿ����Ϣ���ݡ�
-     * @return �����Ƿ���óɹ�
-     * 0   - �ɹ�
-     * !=0 - ʧ��
+     * @param [in]  sdk				NewSdk返回的sdk指针
+     * @param [in]  seq				从指定的seq开始拉取消息,注意返回的消息从seq+1开始返回,seq为之前接口返回的最大seq值。首次使用请使用seq:0
+     * @param [in]  limit			一次拉取的消息条数,最大值1000,超过1000会返回错误
+     * @param [in]  proxy			使用代理的请求,需要接入代理的URL。如:socks5://10.0.0.1:8081 或者 http://10.0.0.1:8081
+     * @param [in]  passwd			代理账号密码,需要传入代理的账号密码。如 user_name:passwd_123
+     * @param [out] chatDatas		返回本次拉取消息的数据,slice结构体.内容包括errcode/errmsg,以及每条消息内容。
+     * @return 返回是否调用成功
+     * 0   - 成功
+     * !=0 - 失败
      */
     public native static int GetChatData(long sdk, long seq, long limit, String proxy, String passwd, long timeout, long chatData);
 
     /**
-     * ��ȡý����Ϣ����
-     * Returnֵ=0��ʾ��API���óɹ�
+     * 获取媒体消息数据
+     * Return值=0表示该API调用成功
      *
-     * @param [in]  sdk				NewSdk���ص�sdkָ��
-     * @param [in]  sdkFileid		��GetChatData���ص�������Ϣ�У�ý����Ϣ������sdkfileid
-     * @param [in]  proxy			ʹ�ô����������Ҫ�����������ӡ��磺socks5://10.0.0.1:8081 ���� http://10.0.0.1:8081
-     * @param [in]  passwd			�����˺����룬��Ҫ���������˺����롣�� user_name:passwd_123
-     * @param [in]  indexbuf		ý����Ϣ��Ƭ��ȡ����Ҫ����ÿ����ȡ��������Ϣ���״β���Ҫ��д��Ĭ����ȡ512k������ÿ�ε���ֻ��Ҫ���ϴε��÷��ص�outindexbuf���뼴�ɡ�
-     * @param [out] media_data		���ر�����ȡ��ý������.MediaData�ṹ��.���ݰ���data(��������)/outindexbuf(�´�����)/is_finish(��ȡ��ɱ��)
-     * @return �����Ƿ���óɹ�
-     * 0   - �ɹ�
-     * !=0 - ʧ��
+     * @param [in]  sdk				NewSdk返回的sdk指针
+     * @param [in]  sdkFileid		从GetChatData返回的聊天消息中,媒体消息包含的sdkfileid
+     * @param [in]  proxy			使用代理的请求,需要接入代理的URL。如:socks5://10.0.0.1:8081 或者 http://10.0.0.1:8081
+     * @param [in]  passwd			代理账号密码,需要传入代理的账号密码。如 user_name:passwd_123
+     * @param [in]  indexbuf		媒体消息分片拉取,需要填入每次拉取的索引信息。首次不需要填写,默认拉取512k,后续每次调用只需要将上次调用返回的outindexbuf带入即可。
+     * @param [out] media_data		返回本次拉取的媒体数据.MediaData结构体.内容包括data(消息数据)/outindexbuf(下次索引)/is_finish(拉取完成标记)
+     * @return 返回是否调用成功
+     * 0   - 成功
+     * !=0 - 失败
      */
     public native static int GetMediaData(long sdk, String indexbuf, String sdkField, String proxy, String passwd, long timeout, long mediaData);
 
     /**
-     * @param [in]  encrypt_key, getchatdata���ص�encrypt_key
-     * @param [in]  encrypt_msg, getchatdata���ص�content
-     * @param [out] msg, ���ܵ���Ϣ����
-     * @return �����Ƿ���óɹ�
-     * 0   - �ɹ�
-     * !=0 - ʧ��
-     * @brief ��������
+     * @param [in]  encrypt_key, getchatdata返回的encrypt_key
+     * @param [in]  encrypt_msg, getchatdata返回的content
+     * @param [out] msg, 解密的消息明文
+     * @return 返回是否调用成功
+     * 0   - 成功
+     * !=0 - 失败
+     * @brief 数据解密
      */
     public native static int DecryptData(long sdk, String encrypt_key, String encrypt_msg, long msg);
 
@@ -79,20 +79,20 @@ public class Finance {
     public native static long NewSlice();
 
     /**
-     * @return
-     * @brief �ͷ�slice����NewSlice�ɶ�ʹ��
+     * @return 
+     * @brief 释放slice,和NewSlice成对使用
      */
     public native static void FreeSlice(long slice);
 
     /**
-     * @return ����
-     * @brief ��ȡslice����
+     * @return 内容
+     * @brief 获取slice内容
      */
     public native static String GetContentFromSlice(long slice);
 
     /**
-     * @return ����
-     * @brief ��ȡslice���ݳ���
+     * @return 长度
+     * @brief 获取slice数据长度
      */
     public native static int GetSliceLen(long slice);
 
@@ -102,13 +102,13 @@ public class Finance {
 
     /**
      * @return outindex
-     * @brief ��ȡmediadata outindex
+     * @brief 获取mediadata outindex
      */
     public native static String GetOutIndexBuf(long mediaData);
 
     /**
      * @return data
-     * @brief ��ȡmediadata data����
+     * @brief 获取mediadata data数据
      */
     public native static byte[] GetData(long mediaData);
 
@@ -117,8 +117,8 @@ public class Finance {
     public native static int GetDataLen(long mediaData);
 
     /**
-     * @return 1��ɡ�0δ���
-     * @brief �ж�mediadata�Ƿ����
+     * @return 1完成、0未完成
+     * @brief 判断mediadata是否结束
      */
     public native static int IsMediaDataFinish(long mediaData);
 

+ 52 - 0
fs-qw-api/src/main/java/com/fs/app/controller/OpenQwApiController.java

@@ -9,6 +9,9 @@ import com.fs.framework.datasource.TenantDataSourceUtil;
 import com.fs.qw.domain.QwExternalContact;
 import com.fs.qw.param.QwExternalContactAddTagParam;
 import com.fs.qw.param.QwExternalContactUpdateNoteParam;
+import com.fs.qwApi.domain.QwExternalContactAllListResult;
+import com.fs.qwApi.domain.QwExternalContactListResult;
+import com.fs.qwApi.param.QwAllExternalcontactListParam;
 import com.fs.qwApi.param.QwUploadImageByCourseParam;
 import com.fs.qwApi.service.QwApiService;
 import lombok.extern.slf4j.Slf4j;
@@ -214,5 +217,54 @@ public class OpenQwApiController extends BaseController {
             return R.error("上传图片失败: " + e.getMessage());
         }
     }
+
+    /**
+     * 按企微用户查询外部联系人
+     */
+    @PostMapping("/getExternalcontactList")
+    public QwExternalContactListResult getExternalcontactList(@RequestParam("qwUserId") String qwUserId,
+                                                              @RequestParam("corpId") String corpId,
+                                                              @RequestParam("tenantId") Long tenantId) {
+        log.info("按企微用户查询外部联系人,tenantId={}, qwUserId={}, corpId={}", tenantId, qwUserId, corpId);
+
+        try {
+            return tenantDataSourceUtil.executeWithResult(tenantId, () ->
+                    qwApiService.getExternalcontactList(qwUserId, corpId)
+            );
+        } catch (Exception e) {
+            log.error("[QwFriendWelcome] 按企微用户查询外部联系人失败,tenantId={}, qwUserId={}, corpId={}",
+                    tenantId, qwUserId, corpId, e);
+
+            QwExternalContactListResult result = new QwExternalContactListResult();
+            result.setErrcode(1);
+            result.setErrmsg("按企微用户查询外部联系人失败: " + e.getMessage());
+            return result;
+        }
+    }
+
+    /**
+     * 批量获取客户详情
+     */
+    @PostMapping("/getAllExternalcontactList")
+    public QwExternalContactAllListResult getAllExternalcontactList(@RequestBody QwAllExternalcontactListParam param) {
+        log.info("批量获取客户详情,参数{}", param);
+        Long tenantId = param.getTenantId();
+        if (tenantId == null){
+            return null;
+        }
+        try {
+            return tenantDataSourceUtil.executeWithResult(tenantId, () ->
+                    qwApiService.getAllExternalcontactList(param, param.getCorpId(), param.getQwCompany())
+            );
+        } catch (Exception e) {
+            log.error("批量获取客户详情失败,tenantId={}, QwCompany={}, corpId={}",
+                    tenantId, param.getQwCompany(), param.getCorpId(), e);
+            QwExternalContactAllListResult result = new QwExternalContactAllListResult();
+            result.setErrcode(1);
+            result.setErrmsg("按企微用户查询外部联系人失败: " + e.getMessage());
+            return result;
+        }
+    }
+
 }
 

+ 29 - 7
fs-qw-api/src/main/java/com/fs/app/controller/QwExternalContactController.java

@@ -1,19 +1,24 @@
 package com.fs.app.controller;
 
+import cn.hutool.http.HttpUtil;
 import com.alibaba.fastjson.JSON;
 import com.fs.common.core.controller.BaseController;
 import com.fs.common.core.domain.R;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.bean.BeanUtils;
 import com.fs.framework.datasource.TenantDataSourceUtil;
 import com.fs.qw.domain.QwCompany;
 import com.fs.qw.domain.QwExternalContact;
 import com.fs.qw.domain.QwUser;
 import com.fs.qw.mapper.QwCompanyMapper;
 import com.fs.qw.mapper.QwExternalContactMapper;
+import com.fs.qwApi.config.OpenQwConfig;
 import com.fs.qwApi.domain.QwExternalContactAllListResult;
 import com.fs.qwApi.domain.QwUnassignedListResult;
 import com.fs.qwApi.domain.inner.ExternalContact;
 import com.fs.qwApi.domain.inner.ExternalContactInfo;
 import com.fs.qwApi.domain.inner.FollowInfo;
+import com.fs.qwApi.param.QwAllExternalcontactListParam;
 import com.fs.qwApi.param.QwExternalListParam;
 import com.fs.qwApi.param.QwUnassignedListParam;
 import com.fs.qwApi.service.QwApiService;
@@ -51,7 +56,7 @@ public class QwExternalContactController extends BaseController {
     /**
      * 同步企微外部联系人(递归分页)
      *
-     * @param qwUser      企微用户信息(JSON)
+     * @param qwUserJson      企微用户信息(JSON)
      * @param cursor      分页游标
      * @param tenantId    租户ID
      */
@@ -63,14 +68,14 @@ public class QwExternalContactController extends BaseController {
         return tenantDataSourceUtil.executeWithResult(tenantId, () -> {
             // 从JSON还原QwUser对象
             QwUser qwUser = JSON.parseObject(qwUserJson, QwUser.class);
-            return doSyncExternalContact(qwUser, cursor);
+            return doSyncExternalContact(qwUser, cursor,tenantId);
         });
     }
 
     /**
      * 递归同步外部联系人
      */
-    private R doSyncExternalContact(QwUser qwUser, String getNextCursor) {
+    private R doSyncExternalContact(QwUser qwUser, String getNextCursor,Long tenantId) {
         String qwUserId = qwUser.getQwUserId();
         String corpId = qwUser.getCorpId();
         Long companyId = qwUser.getCompanyId();
@@ -80,9 +85,26 @@ public class QwExternalContactController extends BaseController {
         param.setUserid_list(Arrays.asList(qwUserId));
         param.setCursor(getNextCursor);
         QwCompany qwCompany = qwCompanyMapper.selectQwCompanyByCorpId(corpId);
-        QwExternalContactAllListResult list = qwApiService.getAllExternalcontactList(param, corpId, qwCompany);
-
-        log.info("批量获取客户详情 {}", list);
+        QwExternalContactAllListResult list = new QwExternalContactAllListResult();
+//        QwExternalContactAllListResult list = qwApiService.getAllExternalcontactList(param, corpId, qwCompany);
+        QwAllExternalcontactListParam qwAllExternalcontactListParam = new QwAllExternalcontactListParam();
+        BeanUtils.copyProperties(param, qwAllExternalcontactListParam);
+        qwAllExternalcontactListParam.setCorpId(corpId);
+        qwAllExternalcontactListParam.setQwCompany(qwCompany);
+        qwAllExternalcontactListParam.setTenantId(tenantId);
+        String url = OpenQwConfig.baseApi + "/getAllExternalcontactList";
+        try {
+            String result = HttpUtil.createPost(url)
+                    .body(JSON.toJSONString(qwAllExternalcontactListParam))
+                    .execute()
+                    .body();
+            log.info("批量获取客户详情: result={}", result);
+            if (StringUtils.isNotBlank(result)){
+                list = JSON.parseObject(result, QwExternalContactAllListResult.class);
+            }
+        } catch (Exception e) {
+            log.error("批量获取客户详情失败: param:{}", qwAllExternalcontactListParam);
+        }
 
         if (list.getErrcode() == 0) {
             List<ExternalContactInfo> externalContactList = list.getExternal_contact_list();
@@ -137,7 +159,7 @@ public class QwExternalContactController extends BaseController {
         }
 
         if (!StringUtil.strIsNullOrEmpty(list.getNext_cursor())) {
-            return doSyncExternalContact(qwUser, list.getNext_cursor());
+            return doSyncExternalContact(qwUser, list.getNext_cursor(),tenantId);
         }
         return R.ok();
     }

+ 16 - 3
fs-qw-api/src/main/java/com/fs/app/qwTask/qwTask.java

@@ -1,6 +1,7 @@
 package com.fs.app.qwTask;
 
 import com.fs.app.service.OpenQwApiService;
+import com.fs.common.config.RedisTenantContext;
 import com.fs.course.service.IFinishCourseStatisticsSyncService;
 import com.fs.course.service.IFsUserCourseService;
 import com.fs.qw.domain.QwIpadServerLog;
@@ -13,6 +14,7 @@ import com.fs.sop.service.impl.QwSopServiceImpl;
 import com.fs.sop.service.ISopUserLogsService;
 import com.fs.statis.IFsStatisQwWatchService;
 import com.fs.statis.service.FsStatisSalerWatchService;
+import com.fs.tenant.domain.TenantInfo;
 import com.fs.wxwork.dto.WxWorkGetQrCodeDTO;
 import com.fs.wxwork.service.WxWorkService;
 import lombok.extern.slf4j.Slf4j;
@@ -29,6 +31,7 @@ import java.time.format.DateTimeFormatter;
 import java.util.Date;
 import java.util.List;
 import java.util.Optional;
+import java.util.function.Consumer;
 
 @Component
 @Slf4j
@@ -105,18 +108,28 @@ public class qwTask {
      * 否则直接执行(兼容单库模式或被 TenantTaskRunner 调用时)。
      */
     private void runWithSaaSTenant(String taskName, Runnable action) {
+        runWithSaaSTenant(taskName, tenant -> action.run());
+    }
+
+    /**
+     * SaaS 多租户定时任务:按租户切库后执行,并将当前租户信息传给业务逻辑。
+     */
+    private void runWithSaaSTenant(String taskName, Consumer<TenantInfo> tenantAction) {
         if (saasTaskEnabled && !TenantTaskRunner.isInTenantExecution()) {
-            tenantTaskRunner.runForEachTenant(taskName, action);
+            tenantTaskRunner.runForEachTenant(taskName, tenantAction);
             return;
         }
-        action.run();
+        TenantInfo tenant = new TenantInfo();
+        tenant.setId(RedisTenantContext.getTenantId());
+        tenantAction.accept(tenant);
     }
 
     //正在使用
     @Scheduled(cron = "0 0 1 * * ?")
     public void qwExternalContact()
     {
-        runWithSaaSTenant("qwExternalContact", () -> qwExternalContactService.qwExternalContactSync());
+        runWithSaaSTenant("qwExternalContact", tenant ->
+                qwExternalContactService.qwExternalContactSync(tenant.getId()));
     }
     //正在使用
 //    @Scheduled(cron = "1/1 * * * * ?")

+ 57 - 0
fs-service/src/main/java/com/fs/comm/domain/CommGatewayApiLog.java

@@ -0,0 +1,57 @@
+package com.fs.comm.domain;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 通讯网关 API 调用日志(主库 comm_gateway_api_log)
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class CommGatewayApiLog extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    public static final String API_TYPE_CALL = "call";
+    public static final String API_TYPE_SMS = "sms";
+
+    private Long logId;
+    private Long tenantId;
+    private Long companyId;
+    private Long companyUserId;
+    private String callerAccount;
+    private String apiType;
+    private String apiPath;
+    private String requestBody;
+    private String responseBody;
+    private Integer resultCode;
+    private String resultMsg;
+    private Integer success;
+    private Integer limitHit;
+    private String limitReason;
+    private String calleePhone;
+    private String callerPhone;
+    private Long gatewayId;
+    private BigDecimal billingAmount;
+    /** 成本价(单价) */
+    private BigDecimal costPrice;
+    /** 计算价/售价(单价) */
+    private BigDecimal calcPrice;
+    /** 计费数量 */
+    private Integer billingQuantity;
+    private String billingUnit;
+    private String clientIp;
+    private String authScope;
+    private Integer durationMs;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    /** 查询:公司名称(关联展示) */
+    private String companyName;
+}

+ 33 - 0
fs-service/src/main/java/com/fs/comm/mapper/CommGatewayApiLogMapper.java

@@ -0,0 +1,33 @@
+package com.fs.comm.mapper;
+
+import com.fs.comm.domain.CommGatewayApiLog;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 通讯网关 API 调用日志(主库)
+ */
+@DataSource(DataSourceType.MASTER)
+public interface CommGatewayApiLogMapper {
+
+    int insertCommGatewayApiLog(CommGatewayApiLog log);
+
+    CommGatewayApiLog selectCommGatewayApiLogById(Long logId);
+
+    List<CommGatewayApiLog> selectCommGatewayApiLogList(CommGatewayApiLog query);
+
+    Integer countByCalleePhone(@Param("companyId") Long companyId,
+                               @Param("calleePhone") String calleePhone,
+                               @Param("type") Integer type);
+
+    Integer countByCallerPhone(@Param("companyId") Long companyId,
+                               @Param("callerPhone") String callerPhone,
+                               @Param("type") Integer type);
+
+    Integer countByCompanyCall(@Param("companyId") Long companyId,
+                               @Param("apiType") String apiType,
+                               @Param("type") Integer type);
+}

+ 26 - 0
fs-service/src/main/java/com/fs/comm/model/CommGatewayBillingQuote.java

@@ -0,0 +1,26 @@
+package com.fs.comm.model;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 通讯网关单次调用计费报价
+ */
+@Data
+@Builder
+public class CommGatewayBillingQuote {
+
+    /** 成本价(单价,元) */
+    private BigDecimal costPrice;
+
+    /** 计算价/售价(单价,元) */
+    private BigDecimal calcPrice;
+
+    /** 计费数量 */
+    private Integer billingQuantity;
+
+    /** 计费总额 = calcPrice × billingQuantity */
+    private BigDecimal billingAmount;
+}

+ 18 - 0
fs-service/src/main/java/com/fs/comm/model/CommSmsSendContext.java

@@ -0,0 +1,18 @@
+package com.fs.comm.model;
+
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * 短信发送上下文(公司、调用人、发送人展示名)
+ */
+@Data
+@Builder
+public class CommSmsSendContext {
+
+    private Long companyId;
+
+    private Long companyUserId;
+
+    private String senderName;
+}

+ 51 - 0
fs-service/src/main/java/com/fs/comm/service/CommGatewayApiLogServiceImpl.java

@@ -0,0 +1,51 @@
+package com.fs.comm.service;
+
+import com.fs.comm.domain.CommGatewayApiLog;
+import com.fs.comm.mapper.CommGatewayApiLogMapper;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.utils.DateUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+public class CommGatewayApiLogServiceImpl implements ICommGatewayApiLogService {
+
+    @Autowired
+    private CommGatewayApiLogMapper commGatewayApiLogMapper;
+
+    @Override
+    @DataSource(DataSourceType.MASTER)
+    public void saveLog(CommGatewayApiLog log) {
+        if (log.getCreateTime() == null) {
+            log.setCreateTime(DateUtils.getNowDate());
+        }
+        commGatewayApiLogMapper.insertCommGatewayApiLog(log);
+    }
+
+    @Override
+    @DataSource(DataSourceType.MASTER)
+    public CommGatewayApiLog selectById(Long logId) {
+        return commGatewayApiLogMapper.selectCommGatewayApiLogById(logId);
+    }
+
+    @Override
+    @DataSource(DataSourceType.MASTER)
+    public List<CommGatewayApiLog> selectList(CommGatewayApiLog query) {
+        return commGatewayApiLogMapper.selectCommGatewayApiLogList(query);
+    }
+
+    @Override
+    @DataSource(DataSourceType.MASTER)
+    public int countByCalleePhone(Long companyId, String calleePhone, Integer type) {
+        return commGatewayApiLogMapper.countByCalleePhone(companyId, calleePhone, type);
+    }
+
+    @Override
+    @DataSource(DataSourceType.MASTER)
+    public int countByCallerPhone(Long companyId, String callerPhone, Integer type) {
+        return commGatewayApiLogMapper.countByCallerPhone(companyId, callerPhone, type);
+    }
+}

+ 156 - 0
fs-service/src/main/java/com/fs/comm/service/CommGatewayBillingService.java

@@ -0,0 +1,156 @@
+package com.fs.comm.service;
+
+import com.fs.comm.domain.CommGatewayApiLog;
+import com.fs.comm.model.CommGatewayBillingQuote;
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
+import com.fs.company.domain.CompanyVoiceApiTenant;
+import com.fs.company.mapper.CompanyVoiceApiTenantMapper;
+import com.fs.proxy.domain.CompanySmsApiTenant;
+import com.fs.proxy.domain.ServiceFeeConfig;
+import com.fs.proxy.domain.TenantTrafficPricing;
+import com.fs.proxy.enums.ConsumeTypeEnum;
+import com.fs.proxy.mapper.CompanySmsApiTenantMapper;
+import com.fs.proxy.mapper.TenantTrafficPricingMapper;
+import com.fs.proxy.service.BalanceService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 通讯网关计费报价(对齐 BalanceServiceImpl 定价规则)
+ */
+@Slf4j
+@Service
+public class CommGatewayBillingService {
+
+    @Value("${comm.gateway.call-unit-price:0.12}")
+    private BigDecimal defaultCallCalcPrice;
+
+    @Value("${comm.gateway.sms-unit-price:0.05}")
+    private BigDecimal defaultSmsCalcPrice;
+
+    @Autowired
+    private BalanceService balanceService;
+
+    @Autowired
+    private CompanySmsApiTenantMapper smsApiTenantMapper;
+
+    @Autowired
+    private CompanyVoiceApiTenantMapper voiceApiTenantMapper;
+
+    @Autowired
+    private TenantTrafficPricingMapper trafficPricingMapper;
+
+    @DataSource(DataSourceType.MASTER)
+    public CommGatewayBillingQuote resolveQuote(Long tenantId, String apiType, int quantity) {
+        if (quantity <= 0) {
+            quantity = 1;
+        }
+        if (CommGatewayApiLog.API_TYPE_SMS.equals(apiType)) {
+            return buildQuote(resolveSmsUnitPrice(tenantId), quantity);
+        }
+        if (CommGatewayApiLog.API_TYPE_CALL.equals(apiType)) {
+            return buildQuote(resolveCallUnitPrice(tenantId), quantity);
+        }
+        return zeroQuote();
+    }
+
+    private CommGatewayBillingQuote buildQuote(UnitPricePair pair, int quantity) {
+        BigDecimal calcPrice = pair.calcPrice != null ? pair.calcPrice : BigDecimal.ZERO;
+        BigDecimal costPrice = pair.costPrice != null ? pair.costPrice : BigDecimal.ZERO;
+        return CommGatewayBillingQuote.builder()
+                .costPrice(costPrice)
+                .calcPrice(calcPrice)
+                .billingQuantity(quantity)
+                .billingAmount(calcPrice.multiply(BigDecimal.valueOf(quantity)))
+                .build();
+    }
+
+    private CommGatewayBillingQuote zeroQuote() {
+        return CommGatewayBillingQuote.builder()
+                .costPrice(BigDecimal.ZERO)
+                .calcPrice(BigDecimal.ZERO)
+                .billingQuantity(0)
+                .billingAmount(BigDecimal.ZERO)
+                .build();
+    }
+
+    private UnitPricePair resolveSmsUnitPrice(Long tenantId) {
+        ServiceFeeConfig config = balanceService.getFeeConfig(ConsumeTypeEnum.SMS_SEND.getCode());
+        BigDecimal calcPrice = config != null && config.getFeeStandard() != null
+                ? config.getFeeStandard() : defaultSmsCalcPrice;
+        BigDecimal costPrice = config != null && config.getPlatformCost() != null
+                ? config.getPlatformCost() : BigDecimal.ZERO;
+
+        if (tenantId != null) {
+            List<CompanySmsApiTenant> bindings = smsApiTenantMapper.selectByCompanyId(tenantId);
+            if (bindings != null) {
+                for (CompanySmsApiTenant binding : bindings) {
+                    if (!Integer.valueOf(1).equals(binding.getStatus())) {
+                        continue;
+                    }
+                    if (binding.getPrice() != null && binding.getPrice().compareTo(BigDecimal.ZERO) > 0) {
+                        calcPrice = binding.getPrice();
+                    }
+                    if (binding.getCostPrice() != null) {
+                        costPrice = binding.getCostPrice();
+                    }
+                    break;
+                }
+            }
+        }
+        return new UnitPricePair(costPrice, calcPrice);
+    }
+
+    private UnitPricePair resolveCallUnitPrice(Long tenantId) {
+        ServiceFeeConfig config = balanceService.getFeeConfig(ConsumeTypeEnum.AI_CALL.getCode());
+        BigDecimal calcPrice = config != null && config.getFeeStandard() != null
+                ? config.getFeeStandard() : defaultCallCalcPrice;
+        BigDecimal costPrice = config != null && config.getPlatformCost() != null
+                ? config.getPlatformCost() : BigDecimal.ZERO;
+
+        if (tenantId != null) {
+            List<CompanyVoiceApiTenant> voiceBindings = voiceApiTenantMapper.selectEnabledApisByTenantId(tenantId);
+            if (voiceBindings != null && !voiceBindings.isEmpty()) {
+                CompanyVoiceApiTenant voiceBinding = voiceBindings.get(0);
+                if (voiceBinding.getSalePrice() != null && voiceBinding.getSalePrice().compareTo(BigDecimal.ZERO) > 0) {
+                    BigDecimal voicePrice = voiceBinding.getSalePrice();
+                    BigDecimal voiceCost = voiceBinding.getCostPrice() != null ? voiceBinding.getCostPrice() : BigDecimal.ZERO;
+
+                    BigDecimal aiSurcharge = config != null && config.getFeeStandard() != null
+                            ? config.getFeeStandard() : defaultCallCalcPrice;
+                    BigDecimal aiCost = config != null && config.getPlatformCost() != null
+                            ? config.getPlatformCost() : BigDecimal.ZERO;
+
+                    TenantTrafficPricing aiTrafficPricing = trafficPricingMapper
+                            .selectByTenantAndType(tenantId, ConsumeTypeEnum.AI_CALL.getCode());
+                    if (aiTrafficPricing != null && aiTrafficPricing.getPrice() != null
+                            && aiTrafficPricing.getPrice().compareTo(BigDecimal.ZERO) > 0) {
+                        aiSurcharge = aiTrafficPricing.getPrice();
+                        if (aiTrafficPricing.getCostPrice() != null) {
+                            aiCost = aiTrafficPricing.getCostPrice();
+                        }
+                    }
+                    calcPrice = voicePrice.add(aiSurcharge);
+                    costPrice = voiceCost.add(aiCost);
+                }
+            }
+        }
+        return new UnitPricePair(costPrice, calcPrice);
+    }
+
+    private static class UnitPricePair {
+        private final BigDecimal costPrice;
+        private final BigDecimal calcPrice;
+
+        private UnitPricePair(BigDecimal costPrice, BigDecimal calcPrice) {
+            this.costPrice = costPrice;
+            this.calcPrice = calcPrice;
+        }
+    }
+}

+ 114 - 22
fs-service/src/main/java/com/fs/comm/service/CommSmsSendService.java

@@ -7,6 +7,7 @@ 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.CommSmsSendContext;
 import com.fs.comm.model.CommSmsSendParam;
 import com.fs.comm.model.CommSmsSendResult;
 import com.fs.company.domain.*;
@@ -88,28 +89,10 @@ public class CommSmsSendService {
             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();
-        }
+        CommSmsSendContext sendContext = resolveSendContext(param, robotic, callees);
+        Long companyId = sendContext.getCompanyId();
+        Long companyUserId = sendContext.getCompanyUserId();
+        String senderName = sendContext.getSenderName();
 
         CompanySmsTemp temp = smsTempService.selectCompanySmsTempById(param.getSmsTempId());
         if (temp == null || !Integer.valueOf(1).equals(temp.getStatus()) || !Integer.valueOf(1).equals(temp.getIsAudit())) {
@@ -188,6 +171,115 @@ public class CommSmsSendService {
                 .build();
     }
 
+    /**
+     * 解析短信发送上下文:请求参数 → 微信绑定 → 外呼任务
+     */
+    public CommSmsSendContext resolveSendContext(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("被叫人不存在");
+        }
+        return resolveSendContext(param, robotic, callees);
+    }
+
+    public CommSmsSendContext resolveSendContext(CommSmsSendParam param, CompanyVoiceRobotic robotic,
+                                                 CompanyVoiceRoboticCallees callees) {
+        CommSmsSendContext context = CommSmsSendContext.builder()
+                .companyId(param.getCompanyId())
+                .companyUserId(param.getCompanyUserId())
+                .senderName(param.getSenderName())
+                .build();
+        fillFromWxClient(param.getRoboticId(), callees.getUserId(), context);
+        fillFromRobotic(robotic, context);
+        fillSenderNameIfBlank(context);
+        if (context.getCompanyId() == null) {
+            throw new ServiceException("未获取到公司信息");
+        }
+        return context;
+    }
+
+    public void validateSmsTempAndBalance(Long smsTempId, Long companyId) {
+        CompanySmsTemp temp = smsTempService.selectCompanySmsTempById(smsTempId);
+        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) {
+            throw new ServiceException("请充值");
+        }
+        if (sms.getRemainSmsCount() == null || sms.getRemainSmsCount() <= 0) {
+            throw new ServiceException("剩余短信数量不足,请充值");
+        }
+    }
+
+    private void fillFromWxClient(Long roboticId, Long customerUserId, CommSmsSendContext context) {
+        if (context.getCompanyUserId() != null && StringUtils.isNotBlank(context.getSenderName())) {
+            return;
+        }
+        CompanyWxClient wxClient = companyWxClientMapper.selectOneByRoboticIdAndUserId(roboticId, customerUserId);
+        if (wxClient == null) {
+            return;
+        }
+        try {
+            if (wxClient.getIsWeCom() == 2) {
+                QwUser qwUser = qwExternalContactService.getQwUserByRedisForId(String.valueOf(wxClient.getAccountId()));
+                if (qwUser != null) {
+                    if (context.getCompanyId() == null) {
+                        context.setCompanyId(qwUser.getCompanyId());
+                    }
+                    if (context.getCompanyUserId() == null) {
+                        context.setCompanyUserId(qwUser.getCompanyUserId());
+                    }
+                    if (StringUtils.isBlank(context.getSenderName())) {
+                        context.setSenderName(qwUser.getQwUserName());
+                    }
+                }
+            } else {
+                CompanyWxAccount wxAccount = companyWxAccountService.selectCompanyWxAccountById(wxClient.getAccountId());
+                if (wxAccount != null) {
+                    if (context.getCompanyId() == null) {
+                        context.setCompanyId(wxAccount.getCompanyId());
+                    }
+                    if (context.getCompanyUserId() == null) {
+                        context.setCompanyUserId(wxAccount.getCompanyUserId());
+                    }
+                    if (StringUtils.isBlank(context.getSenderName())) {
+                        context.setSenderName(wxAccount.getWxNickName());
+                    }
+                }
+            }
+        } catch (Exception ex) {
+            log.warn("解析微信发送人信息失败 roboticId={}", roboticId, ex);
+        }
+    }
+
+    private void fillFromRobotic(CompanyVoiceRobotic robotic, CommSmsSendContext context) {
+        if (context.getCompanyId() == null) {
+            context.setCompanyId(robotic.getCompanyId());
+        }
+        if (context.getCompanyUserId() == null) {
+            context.setCompanyUserId(robotic.getCompanyUserId());
+        }
+        if (StringUtils.isBlank(context.getSenderName()) && StringUtils.isNotBlank(robotic.getCreateByName())) {
+            context.setSenderName(robotic.getCreateByName());
+        }
+    }
+
+    private void fillSenderNameIfBlank(CommSmsSendContext context) {
+        if (StringUtils.isNotBlank(context.getSenderName()) || context.getCompanyUserId() == null) {
+            return;
+        }
+        CompanyUser companyUser = companyUserService.selectCompanyUserById(context.getCompanyUserId());
+        if (companyUser == null) {
+            return;
+        }
+        context.setSenderName(StringUtils.defaultIfBlank(companyUser.getNickName(), companyUser.getUserName()));
+    }
+
     /**
      * 已组装好参数的 AI 短信批量发送(WxTask 等场景),统一收口 batchSmsOp4AiSend
      */

+ 23 - 0
fs-service/src/main/java/com/fs/comm/service/CommVoiceConfigMasterService.java

@@ -0,0 +1,23 @@
+package com.fs.comm.service;
+
+import com.fs.common.annotation.DataSource;
+import com.fs.common.enums.DataSourceType;
+import com.fs.company.domain.CompanyVoiceConfig;
+import com.fs.company.mapper.CompanyVoiceConfigMapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+/**
+ * 从主库读取呼叫频率配置(与 admin 平台配置一致)
+ */
+@Service
+public class CommVoiceConfigMasterService {
+
+    @Autowired
+    private CompanyVoiceConfigMapper companyVoiceConfigMapper;
+
+    @DataSource(DataSourceType.MASTER)
+    public CompanyVoiceConfig selectByCompanyId(Long companyId) {
+        return companyVoiceConfigMapper.selectCompanyVoiceConfigByCompanyId(companyId);
+    }
+}

+ 188 - 0
fs-service/src/main/java/com/fs/comm/service/CommVoiceLimitService.java

@@ -0,0 +1,188 @@
+package com.fs.comm.service;
+
+import cn.hutool.json.JSONUtil;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.StringUtils;
+import com.fs.comm.support.CommTenantDataSourceHelper;
+import com.fs.company.domain.CompanyVoiceConfig;
+import com.fs.company.domain.CompanyVoiceRoboticCallees;
+import com.fs.company.mapper.CompanyVoiceRoboticCalleesMapper;
+import com.fs.crm.domain.CrmCustomer;
+import com.fs.crm.service.ICrmCustomerService;
+import com.fs.his.utils.PhoneUtil;
+import com.fs.system.config.SystemVoiceConfig;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+/**
+ * 通讯网关外呼/短信频率限制(对齐 company_voice_config 配置,计数走主库 comm_gateway_api_log)
+ */
+@Slf4j
+@Service
+public class CommVoiceLimitService {
+
+    @Autowired
+    private CommVoiceConfigMasterService commVoiceConfigMasterService;
+
+    @Autowired
+    private ICommGatewayApiLogService commGatewayApiLogService;
+
+    @Autowired
+    private CompanyVoiceRoboticCalleesMapper companyVoiceRoboticCalleesMapper;
+
+    @Autowired
+    private ICrmCustomerService crmCustomerService;
+
+    @Autowired
+    private CommTenantDataSourceHelper commTenantDataSourceHelper;
+
+    public void checkCallLimit(Long companyId, Long tenantId, Long calleeId, String phone, Long gatewayId) {
+        if (companyId == null) {
+            return;
+        }
+        String calleePhone = resolveCallCalleePhone(calleeId, phone);
+        String callerKey = buildCompanyCallerKey(companyId, gatewayId);
+        CompanyVoiceConfig config = commVoiceConfigMasterService.selectByCompanyId(companyId);
+        if (config == null) {
+            commTenantDataSourceHelper.ensureTenant(tenantId);
+            return;
+        }
+        applyLimits(companyId, config, callerKey, calleePhone);
+        commTenantDataSourceHelper.ensureTenant(tenantId);
+    }
+
+    public void checkSmsLimit(Long companyId, Long tenantId, Long calleeId, Long customerId, String phone) {
+        if (companyId == null) {
+            return;
+        }
+        String calleePhone = resolveSmsPhone(calleeId, customerId, phone);
+        String callerKey = buildCompanyCallerKey(companyId, null);
+        CompanyVoiceConfig config = commVoiceConfigMasterService.selectByCompanyId(companyId);
+        if (config == null) {
+            commTenantDataSourceHelper.ensureTenant(tenantId);
+            return;
+        }
+        applyLimits(companyId, config, callerKey, calleePhone);
+        commTenantDataSourceHelper.ensureTenant(tenantId);
+    }
+
+    private void applyLimits(Long companyId, CompanyVoiceConfig config, String callerKey, String calleePhone) {
+        SystemVoiceConfig callerConfig = parseConfig(config.getCallerJson());
+        if (callerConfig != null) {
+            checkCallerLimits(companyId, callerKey, callerConfig);
+        }
+        if (StringUtils.isNotBlank(calleePhone)) {
+            SystemVoiceConfig calleeConfig = parseConfig(config.getCalleeJson());
+            if (calleeConfig != null) {
+                checkCalleeLimits(companyId, calleePhone, calleeConfig);
+            }
+        }
+    }
+
+    private void checkCallerLimits(Long companyId, String callerKey, SystemVoiceConfig callerConfig) {
+        checkLimit(companyId, callerKey, true, 1, callerConfig.getCallerMinute(), "主叫分钟限制");
+        checkLimit(companyId, callerKey, true, 2, callerConfig.getCallerHour(), "主叫小时限制");
+        checkLimit(companyId, callerKey, true, 3, callerConfig.getCallerDay(), "主叫日限制");
+        checkLimit(companyId, callerKey, true, 4, callerConfig.getCallerWeek(), "主叫周限制");
+        checkLimit(companyId, callerKey, true, 5, callerConfig.getCallerMonth(), "主叫月限制");
+    }
+
+    private void checkCalleeLimits(Long companyId, String calleePhone, SystemVoiceConfig calleeConfig) {
+        checkLimit(companyId, calleePhone, false, 1, calleeConfig.getCalleeMinute(), "被叫分钟限制");
+        checkLimit(companyId, calleePhone, false, 2, calleeConfig.getCalleeHour(), "被叫小时限制");
+        checkLimit(companyId, calleePhone, false, 3, calleeConfig.getCalleeDay(), "被叫日限制");
+        checkLimit(companyId, calleePhone, false, 4, calleeConfig.getCalleeWeek(), "被叫周限制");
+        checkLimit(companyId, calleePhone, false, 5, calleeConfig.getCalleeMonth(), "被叫月限制");
+    }
+
+    private void checkLimit(Long companyId, String phoneKey, boolean callerSide, int type, Integer max, String message) {
+        if (max == null || max <= 0 || StringUtils.isBlank(phoneKey)) {
+            return;
+        }
+        int current = callerSide
+                ? commGatewayApiLogService.countByCallerPhone(companyId, phoneKey, type)
+                : commGatewayApiLogService.countByCalleePhone(companyId, phoneKey, type);
+        if (current >= max) {
+            throw new ServiceException(message);
+        }
+    }
+
+    public String resolveCallCalleePhone(Long calleeId, String phone) {
+        if (StringUtils.isNotBlank(phone)) {
+            return normalizePhone(PhoneUtil.decryptAutoPhone(phone.trim()));
+        }
+        if (calleeId == null) {
+            return null;
+        }
+        CompanyVoiceRoboticCallees callees = companyVoiceRoboticCalleesMapper.selectById(calleeId);
+        if (callees == null || StringUtils.isBlank(callees.getPhone())) {
+            return null;
+        }
+        return normalizePhone(PhoneUtil.decryptAutoPhone(callees.getPhone()));
+    }
+
+    /** 解析短信被叫号码(供日志与限频使用) */
+    public String resolveSmsCalleePhone(Long calleeId, Long customerId, String phone) {
+        return resolveSmsPhone(calleeId, customerId, phone);
+    }
+
+    private String resolveSmsPhone(Long calleeId, Long customerId, String phone) {
+        if (StringUtils.isNotBlank(phone)) {
+            return normalizePhone(PhoneUtil.decryptAutoPhone(phone.trim()));
+        }
+        if (customerId != null) {
+            CrmCustomer customer = crmCustomerService.selectCrmCustomerById(customerId);
+            if (customer != null && StringUtils.isNotBlank(customer.getMobile())) {
+                return normalizePhone(PhoneUtil.decryptAutoPhone(customer.getMobile()));
+            }
+        }
+        if (calleeId == null) {
+            return null;
+        }
+        CompanyVoiceRoboticCallees callees = companyVoiceRoboticCalleesMapper.selectById(calleeId);
+        if (callees == null) {
+            return null;
+        }
+        if (callees.getUserId() != null) {
+            CrmCustomer customer = crmCustomerService.selectCrmCustomerById(callees.getUserId());
+            if (customer != null && StringUtils.isNotBlank(customer.getMobile())) {
+                return normalizePhone(PhoneUtil.decryptAutoPhone(customer.getMobile()));
+            }
+        }
+        return normalizePhone(PhoneUtil.decryptAutoPhone(callees.getPhone()));
+    }
+
+    public String buildCompanyCallerKey(Long companyId, Long gatewayId) {
+        if (gatewayId != null) {
+            return "gw:" + gatewayId;
+        }
+        return "company:" + companyId;
+    }
+
+    public String normalizePhone(String phone) {
+        if (StringUtils.isBlank(phone)) {
+            return phone;
+        }
+        String text = phone.trim();
+        if (text.startsWith("+86")) {
+            return text;
+        }
+        if (text.matches("\\d+")) {
+            return "+86" + text;
+        }
+        return text;
+    }
+
+    private SystemVoiceConfig parseConfig(String json) {
+        if (StringUtils.isBlank(json)) {
+            return null;
+        }
+        try {
+            return JSONUtil.toBean(json, SystemVoiceConfig.class);
+        } catch (Exception ex) {
+            log.warn("解析呼叫频率配置失败: {}", ex.getMessage());
+            return null;
+        }
+    }
+}

+ 18 - 0
fs-service/src/main/java/com/fs/comm/service/ICommGatewayApiLogService.java

@@ -0,0 +1,18 @@
+package com.fs.comm.service;
+
+import com.fs.comm.domain.CommGatewayApiLog;
+
+import java.util.List;
+
+public interface ICommGatewayApiLogService {
+
+    void saveLog(CommGatewayApiLog log);
+
+    CommGatewayApiLog selectById(Long logId);
+
+    List<CommGatewayApiLog> selectList(CommGatewayApiLog query);
+
+    int countByCalleePhone(Long companyId, String calleePhone, Integer type);
+
+    int countByCallerPhone(Long companyId, String callerPhone, Integer type);
+}

+ 36 - 0
fs-service/src/main/java/com/fs/comm/sms/CommSmsChannelRequest.java

@@ -0,0 +1,36 @@
+package com.fs.comm.sms;
+
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * 短信通道发送请求(迈远等 HTTP 通道共用)
+ */
+@Data
+@Builder
+public class CommSmsChannelRequest {
+
+    /** 目标手机号 */
+    private String phone;
+
+    /** 短信正文(不含签名) */
+    private String content;
+
+    /** 模板类型: 1=行业通知 2=营销短信 */
+    private Integer tempType;
+
+    /** 账户 */
+    private String account;
+
+    /** 密码 */
+    private String password;
+
+    /** 短信签名 */
+    private String sign;
+
+    /** 接口根地址(迈远,需以 / 结尾或不含路径) */
+    private String url;
+
+    /** 扩展码 extno */
+    private String extno;
+}

+ 40 - 0
fs-service/src/main/java/com/fs/comm/sms/CommSmsChannelResult.java

@@ -0,0 +1,40 @@
+package com.fs.comm.sms;
+
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * 短信通道发送结果
+ */
+@Data
+@Builder
+public class CommSmsChannelResult {
+
+    /** 是否发送成功 */
+    private boolean success;
+
+    /** 平台消息 ID(迈远 mid) */
+    private String mid;
+
+    /** 失败原因码,成功时为 OK */
+    private String errorCode;
+
+    /** 原始响应摘要(便于排查) */
+    private String rawResponse;
+
+    public static CommSmsChannelResult ok(String mid) {
+        return CommSmsChannelResult.builder()
+                .success(true)
+                .mid(mid)
+                .errorCode("OK")
+                .build();
+    }
+
+    public static CommSmsChannelResult fail(String errorCode, String rawResponse) {
+        return CommSmsChannelResult.builder()
+                .success(false)
+                .errorCode(errorCode)
+                .rawResponse(rawResponse)
+                .build();
+    }
+}

+ 12 - 0
fs-service/src/main/java/com/fs/comm/sms/CommSmsProvider.java

@@ -0,0 +1,12 @@
+package com.fs.comm.sms;
+
+/**
+ * 短信通道发送 SPI(由 fs-comm-gateway 等模块提供具体实现)
+ */
+public interface CommSmsProvider {
+
+    /** 服务商标识,如 my / card */
+    String provider();
+
+    CommSmsChannelResult send(CommSmsChannelRequest request);
+}

+ 22 - 0
fs-service/src/main/java/com/fs/comm/support/CommTenantDataSourceHelper.java

@@ -0,0 +1,22 @@
+package com.fs.comm.support;
+
+import com.fs.framework.datasource.TenantDataSourceManager;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+/**
+ * 通讯网关模块专用:主库 @DataSource 调用后会 clear 数据源,需在租户表操作前显式切回租户库
+ */
+@Component
+public class CommTenantDataSourceHelper {
+
+    @Autowired
+    private TenantDataSourceManager tenantDataSourceManager;
+
+    public void ensureTenant(Long tenantId) {
+        if (tenantId == null) {
+            return;
+        }
+        tenantDataSourceManager.ensureSwitchByTenantId(tenantId);
+    }
+}

+ 44 - 3
fs-service/src/main/java/com/fs/common/service/impl/SmsServiceImpl.java

@@ -7,6 +7,9 @@ import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.proxy.enums.ConsumeTypeEnum;
 import com.fs.proxy.service.BalanceService;
+import com.fs.comm.sms.CommSmsChannelRequest;
+import com.fs.comm.sms.CommSmsChannelResult;
+import com.fs.comm.sms.CommSmsProvider;
 import com.fs.common.service.ISmsService;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.vo.SmsNotifyVO;
@@ -52,6 +55,7 @@ import org.springframework.transaction.annotation.Transactional;
 import java.io.UnsupportedEncodingException;
 import java.net.URLEncoder;
 import java.text.SimpleDateFormat;
+import java.util.Collections;
 import java.util.Date;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
@@ -113,6 +117,9 @@ public class SmsServiceImpl implements ISmsService
     @Autowired
     private com.fs.proxy.mapper.CompanySmsCardMapper smsCardMapper;
 
+    @Autowired(required = false)
+    private List<CommSmsProvider> commSmsProviders = Collections.emptyList();
+
     /**
      * 统一发送方法 - 替代原来6处硬编码的 his.sms 配置读取
      * 
@@ -167,10 +174,45 @@ public class SmsServiceImpl implements ISmsService
         }
     }
 
-    /** 迈远发送 */
+    /** 迈远发送(优先走 fs-comm-gateway 注册的 CommSmsProvider) */
     private String sendByRf(String phone, String content, Integer tempType,
                              String account, String password, String sign, String url, String extno,
                              Long tenantId, Long apiId, Long portId) {
+        CommSmsProvider myProvider = findCommSmsProvider("my");
+        if (myProvider != null) {
+            CommSmsChannelResult result = myProvider.send(CommSmsChannelRequest.builder()
+                    .phone(phone)
+                    .content(content)
+                    .tempType(tempType)
+                    .account(account)
+                    .password(password)
+                    .sign(sign)
+                    .url(url)
+                    .extno(extno)
+                    .build());
+            if (result.isSuccess()) {
+                return "OK";
+            }
+            return StringUtils.defaultIfBlank(result.getErrorCode(), "SEND_FAILED");
+        }
+        return sendByRfFallback(phone, content, tempType, account, password, sign, url, extno);
+    }
+
+    private CommSmsProvider findCommSmsProvider(String provider) {
+        if (commSmsProviders == null || commSmsProviders.isEmpty()) {
+            return null;
+        }
+        for (CommSmsProvider commSmsProvider : commSmsProviders) {
+            if (provider.equals(commSmsProvider.provider())) {
+                return commSmsProvider;
+            }
+        }
+        return null;
+    }
+
+    /** 非网关进程兜底:本地直连迈远 HTTP */
+    private String sendByRfFallback(String phone, String content, Integer tempType,
+                                    String account, String password, String sign, String url, String extno) {
         String urls;
         try {
             if (tempType.equals(1)) {
@@ -185,7 +227,7 @@ public class SmsServiceImpl implements ISmsService
                 return "UNSUPPORTED_TEMP_TYPE";
             }
         } catch (UnsupportedEncodingException e) {
-            log.error("sendByRf: URL编码异常", e);
+            log.error("sendByRfFallback: URL编码异常", e);
             return "ENCODE_ERROR";
         }
 
@@ -194,7 +236,6 @@ public class SmsServiceImpl implements ISmsService
         if (vo.getStatus().equals(0)) {
             for (SmsSendItemVO itemVO : vo.getList()) {
                 if (itemVO.getResult().equals("0")) {
-                    // 发送成功, 返回OK (调用方负责写日志)
                     return "OK";
                 }
             }

+ 49 - 0
fs-service/src/main/java/com/fs/company/domain/CompanyCommGatewayLog.java

@@ -0,0 +1,49 @@
+package com.fs.company.domain;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 通讯网关 API 调用记录(租户库 company_comm_gateway_log)
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class CompanyCommGatewayLog extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long logId;
+    /** 主库 comm_gateway_api_log.log_id */
+    private Long masterLogId;
+    private Long companyId;
+    private Long companyUserId;
+    private String callerAccount;
+    private String apiType;
+    private String apiPath;
+    private String requestBody;
+    private String responseBody;
+    private Integer resultCode;
+    private String resultMsg;
+    private Integer success;
+    private Integer limitHit;
+    private String limitReason;
+    private String calleePhone;
+    private String callerPhone;
+    private Long gatewayId;
+    private BigDecimal billingAmount;
+    private BigDecimal costPrice;
+    private BigDecimal calcPrice;
+    private Integer billingQuantity;
+    private String billingUnit;
+    private String clientIp;
+    private String authScope;
+    private Integer durationMs;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+}

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

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

@@ -38,6 +38,12 @@ public class CompanyWorkflowLobsterNode extends BaseEntity {
 
     private String nodeConfig;
 
+    /** AI 场景编码(对应 admin_ai_scene.scene_code),节点执行时按场景挑模型 */
+    private String sceneCode;
+
+    /** 指定具体模型(对应 admin_ai_model.model_name),优先级高于 sceneCode */
+    private String modelName;
+
     private String greetingConfig;
 
     private LocalTime sendTime;

+ 29 - 0
fs-service/src/main/java/com/fs/company/domain/LobsterChannelPluginConfig.java

@@ -0,0 +1,29 @@
+package com.fs.company.domain;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 龙虾渠道插件配置 Domain
+ * <p>
+ * 表: lobster_channel_plugin_config
+ * 即插即用多IM通道:每个租户可为每个渠道(QW/WX/WHATSAPP/LINE/...)独立配置启用状态和API凭证
+ */
+@Data
+public class LobsterChannelPluginConfig {
+
+    private Long id;
+    /** 租户ID */
+    private Long companyId;
+    /** 渠道类型: QW/WX/IM/WHATSAPP/LINE/TELEGRAM/APP_IM/OTHER */
+    private String channelType;
+    /** 是否启用: 0-禁用 1-启用 */
+    private Integer enabled;
+    /** 渠道配置 JSON (API Key / Webhook URL / Token / phoneNumberId 等) */
+    private String configJson;
+    /** 创建时间 */
+    private LocalDateTime createTime;
+    /** 更新时间 */
+    private LocalDateTime updateTime;
+}

+ 22 - 0
fs-service/src/main/java/com/fs/company/domain/LobsterChatMsg.java

@@ -0,0 +1,22 @@
+package com.fs.company.domain;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 龙虾聊天消息 Domain(对应 chat_msg 表 — 多渠道聚合)
+ */
+@Data
+public class LobsterChatMsg {
+
+    private Long msgId;
+    private Long sessionId;
+    private Long companyId;
+    private String channelType;
+    private String content;
+    private Integer msgType;
+    private Integer sendType;
+    private Integer status;
+    private LocalDateTime createTime;
+}

+ 28 - 0
fs-service/src/main/java/com/fs/company/domain/LobsterChatSession.java

@@ -0,0 +1,28 @@
+package com.fs.company.domain;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 龙虾聊天会话 Domain(对应 chat_session 表 — 多渠道聚合)
+ */
+@Data
+public class LobsterChatSession {
+
+    private Long sessionId;
+    private Long companyId;
+    private Long contactId;
+    private String channelType;
+    private String channelSourceId;
+    private String channelSourceType;
+    private String userId;
+    private String externalUserId;
+    private String lastMsg;
+    private LocalDateTime lastMsgTime;
+    private Integer unreadCount;
+    private Integer status;
+    private Long instanceId;
+    private LocalDateTime createTime;
+    private LocalDateTime updateTime;
+}

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

@@ -0,0 +1,20 @@
+package com.fs.company.domain;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 合规审计轨迹 Domain(对应 lobster_compliance_audit 表)
+ */
+@Data
+public class LobsterComplianceAudit {
+
+    private Long id;
+    private Long companyId;
+    private String ruleName;
+    private Integer severity;
+    private String matchedKeyword;
+    private String contentSnippet;
+    private LocalDateTime createTime;
+}

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

@@ -0,0 +1,42 @@
+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.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 租户消费记录实体 — 表: tenant_consume_record
+ */
+@Data
+@TableName("tenant_consume_record")
+public class LobsterConsumeRecord {
+
+    @TableId(type = IdType.AUTO)
+    private Long recordId;
+    private Long tenantId;
+    private String tenantName;
+    private Long proxyId;
+    private Integer consumeType;
+    private String consumeTypeName;
+    private BigDecimal amount;
+    private BigDecimal platformCost;
+    private BigDecimal tenantPrice;
+    private BigDecimal proxyRatio;
+    private BigDecimal proxyProfit;
+    private BigDecimal unitPrice;
+    private Integer quantity;
+    private BigDecimal beforeBalance;
+    private BigDecimal afterBalance;
+    private String description;
+    private String month;
+    private String remark;
+    private String orderNo;
+    private Integer status;
+    private String failReason;
+    private LocalDateTime consumeTime;
+    private LocalDateTime createTime;
+}

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

@@ -0,0 +1,42 @@
+package com.fs.company.domain;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 龙虾消息去重配置
+ */
+@Data
+public class LobsterDedupConfig {
+
+    private Long id;
+    /** 租户ID */
+    private Long companyId;
+    /** 配置名称 */
+    private String configName;
+    /** 去重模式: exact/semantic/hybrid */
+    private String dedupMode;
+    /** 精确去重窗口大小(消息数) */
+    private Integer exactWindowSize;
+    /** 语义去重相似度阈值(0.0-1.0) */
+    private Double semanticThreshold;
+    /** 去重窗口时长(秒) */
+    private Integer windowDurationSeconds;
+    /** 忽略前缀消息数 */
+    private Integer ignorePrefixCount;
+    /** 是否启用: 0-禁用 1-启用 */
+    private Integer enabled;
+    /** 删除标志 */
+    private Integer delFlag;
+    /** 创建人 */
+    private String createBy;
+    /** 创建时间 */
+    private LocalDateTime createTime;
+    /** 更新人 */
+    private String updateBy;
+    /** 更新时间 */
+    private LocalDateTime updateTime;
+    /** 备注 */
+    private String remark;
+}

+ 11 - 2
fs-service/src/main/java/com/fs/company/domain/LobsterEventAudit.java

@@ -5,8 +5,12 @@ import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
 
+import java.math.BigDecimal;
 import java.time.LocalDateTime;
 
+/**
+ * 龙虾事件节点审核实体 — 表: lobster_event_node_audit
+ */
 @Data
 @TableName("lobster_event_node_audit")
 public class LobsterEventAudit {
@@ -15,11 +19,16 @@ public class LobsterEventAudit {
     private Long id;
     private Long companyId;
     private Long instanceId;
-    private String nodeType;
+    private String workflowId;
+    private String nodeKey;
     private String nodeName;
+    private String nodeType;
     private String nodeJson;
     private String insertAt;
-    private String reason;
+    private String insertRefNode;
+    private String decisionEngine;
+    private BigDecimal decisionScore;
+    private String decisionReason;
     private String status;
     private String auditBy;
     private String auditComment;

+ 40 - 0
fs-service/src/main/java/com/fs/company/domain/LobsterProfileConfig.java

@@ -0,0 +1,40 @@
+package com.fs.company.domain;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 龙虾用户画像配置
+ */
+@Data
+public class LobsterProfileConfig {
+
+    private Long id;
+    /** 租户ID */
+    private Long companyId;
+    /** 配置名称 */
+    private String configName;
+    /** 画像字段映射规则(JSON) */
+    private String fieldMappings;
+    /** 画像来源: crm_order/crm_tag/wechat_profile/custom */
+    private String profileSource;
+    /** 刷新策略: realtime/hourly/daily/manual */
+    private String refreshStrategy;
+    /** 最大画像字段数 */
+    private Integer maxFields;
+    /** 是否启用: 0-禁用 1-启用 */
+    private Integer enabled;
+    /** 删除标志 */
+    private Integer delFlag;
+    /** 创建人 */
+    private String createBy;
+    /** 创建时间 */
+    private LocalDateTime createTime;
+    /** 更新人 */
+    private String updateBy;
+    /** 更新时间 */
+    private LocalDateTime updateTime;
+    /** 备注 */
+    private String remark;
+}

+ 36 - 0
fs-service/src/main/java/com/fs/company/domain/LobsterSalesCorpus.java

@@ -0,0 +1,36 @@
+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.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 龙虾销冠语料实体 — 表: lobster_sales_corpus
+ */
+@Data
+@TableName("lobster_sales_corpus")
+public class LobsterSalesCorpus {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String dialogText;
+    private String customerQuestion;
+    private String salesAnswer;
+    private String scenario;
+    private BigDecimal score;
+    private String sourceType;
+    private String externalUserId;
+    private Long companyId;
+    private String salespersonName;
+    private String tags;
+    private String status;
+    private Integer enabled;
+    private String createBy;
+    private LocalDateTime createTime;
+    private String updateBy;
+    private LocalDateTime updateTime;
+}

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

@@ -0,0 +1,42 @@
+package com.fs.company.domain;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 龙虾摘要生成配置
+ */
+@Data
+public class LobsterSummaryConfig {
+
+    private Long id;
+    /** 租户ID */
+    private Long companyId;
+    /** 配置名称 */
+    private String configName;
+    /** 触发间隔(消息数) */
+    private Integer triggerInterval;
+    /** 触发策略: message_count/duration/manual */
+    private String triggerStrategy;
+    /** 摘要模型标识符 */
+    private String modelIdentifier;
+    /** 最大上下文消息数 */
+    private Integer maxContextMessages;
+    /** 摘要最大长度(字符) */
+    private Integer maxSummaryLength;
+    /** 是否启用: 0-禁用 1-启用 */
+    private Integer enabled;
+    /** 删除标志 */
+    private Integer delFlag;
+    /** 创建人 */
+    private String createBy;
+    /** 创建时间 */
+    private LocalDateTime createTime;
+    /** 更新人 */
+    private String updateBy;
+    /** 更新时间 */
+    private LocalDateTime updateTime;
+    /** 备注 */
+    private String remark;
+}

+ 40 - 0
fs-service/src/main/java/com/fs/company/domain/LobsterTenantBalance.java

@@ -0,0 +1,40 @@
+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.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * 租户余额实体 — 表: tenant_balance
+ */
+@Data
+@TableName("tenant_balance")
+public class LobsterTenantBalance {
+
+    @TableId(type = IdType.AUTO)
+    private Long balanceId;
+    private Long tenantId;
+    private String tenantName;
+    private BigDecimal totalBalance;
+    private BigDecimal aiModelBalance;
+    private BigDecimal courseFlowBalance;
+    private BigDecimal liveFlowBalance;
+    private BigDecimal tokenBalance;
+    private BigDecimal smsBalance;
+    private BigDecimal manualCallBalance;
+    private BigDecimal aiCallBalance;
+    private BigDecimal wechatHelperBalance;
+    private BigDecimal frozenAmount;
+    private BigDecimal totalRecharge;
+    private BigDecimal totalConsume;
+    private LocalDate accountFeeExpireTime;
+    private Integer status;
+    private LocalDateTime createTime;
+    private LocalDateTime updateTime;
+    private BigDecimal tokenCoefficient;
+}

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

@@ -0,0 +1,33 @@
+package com.fs.company.domain;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 龙虾Token消耗记录(存于租户DB,管理端跨租户聚合查询)
+ */
+@Data
+public class LobsterTokenConsumption {
+
+    private Long id;
+    /** 租户ID */
+    private Long companyId;
+    /** 工作流实例ID */
+    private Long instanceId;
+    /** 节点编码 */
+    private String nodeCode;
+    /** 模型标识符 */
+    private String modelIdentifier;
+    /** 消耗类型: prompt/completion */
+    private String consumeType;
+    /** Token数量 */
+    private Integer tokenCount;
+    /** 预估费用 */
+    private BigDecimal estimatedCost;
+    /** 请求时间 */
+    private LocalDateTime requestTime;
+    /** 创建时间 */
+    private LocalDateTime createTime;
+}

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

@@ -0,0 +1,14 @@
+package com.fs.company.mapper;
+
+import com.fs.company.domain.CompanyCommGatewayLog;
+
+import java.util.List;
+
+public interface CompanyCommGatewayLogMapper {
+
+    int insertCompanyCommGatewayLog(CompanyCommGatewayLog log);
+
+    CompanyCommGatewayLog selectCompanyCommGatewayLogById(Long logId);
+
+    List<CompanyCommGatewayLog> selectCompanyCommGatewayLogList(CompanyCommGatewayLog query);
+}

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

@@ -59,6 +59,6 @@ public interface CompanyVoiceApiMapper
      * @return 结果
      */
     public int deleteCompanyVoiceApiByIds(Long[] apiIds);
-    @Select("select count(1) from company_voice_api")
+    @Select("select count(1) from company_voice_api where (is_del = 0 or is_del is null)")
     Integer selectCompanyVoiceApiCount();
 }

+ 32 - 58
fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceApiTenantMapper.java

@@ -14,64 +14,38 @@ import java.util.List;
  */
 public interface CompanyVoiceApiTenantMapper
 {
-    /**
-     * 查询分配关系
-     */
-    public CompanyVoiceApiTenant selectCompanyVoiceApiTenantById(Long id);
-
-    /**
-     * 按接口ID+租户ID查询分配关系
-     */
-    public CompanyVoiceApiTenant selectByApiAndCompany(@Param("apiId") Long apiId, @Param("companyId") Long companyId);
-
-    /**
-     * 查询接口已分配的租户列表(含租户名)
-     */
-    public List<CompanyVoiceApiTenant> selectTenantsByApiId(Long apiId);
-
-    /**
-     * 查询租户已分配的接口列表(含接口名)
-     */
-    public List<CompanyVoiceApiTenant> selectApisByCompanyId(Long companyId);
-
-    /**
-     * 查询分配关系列表
-     */
-    public List<CompanyVoiceApiTenant> selectCompanyVoiceApiTenantList(CompanyVoiceApiTenant param);
-
-    /**
-     * 新增分配关系
-     */
-    public int insertCompanyVoiceApiTenant(CompanyVoiceApiTenant companyVoiceApiTenant);
-
-    /**
-     * 批量新增分配关系
-     */
-    public int batchInsertCompanyVoiceApiTenant(@Param("list") List<CompanyVoiceApiTenant> list);
-
-    /**
-     * 修改分配关系
-     */
-    public int updateCompanyVoiceApiTenant(CompanyVoiceApiTenant companyVoiceApiTenant);
-
-    /**
-     * 删除分配关系
-     */
-    public int deleteCompanyVoiceApiTenantById(Long id);
-
-    /**
-     * 按接口ID+租户ID删除分配关系
-     */
-    public int deleteByApiAndCompany(@Param("apiId") Long apiId, @Param("companyId") Long companyId);
-
-    /**
-     * 按接口ID删除所有分配关系
-     */
-    public int deleteByApiId(Long apiId);
-
-    /**
-     * 查询接口已分配的租户数量
-     */
+    CompanyVoiceApiTenant selectCompanyVoiceApiTenantById(Long id);
+
+    CompanyVoiceApiTenant selectByApiAndTenant(@Param("apiId") Long apiId, @Param("tenantId") Long tenantId);
+
+    List<CompanyVoiceApiTenant> selectTenantsByApiId(Long apiId);
+
+    List<CompanyVoiceApiTenant> selectApisByTenantId(Long tenantId);
+
+    List<CompanyVoiceApiTenant> selectEnabledApisByTenantId(Long tenantId);
+
+    List<CompanyVoiceApiTenant> selectCompanyVoiceApiTenantList(CompanyVoiceApiTenant param);
+
+    int insertCompanyVoiceApiTenant(CompanyVoiceApiTenant companyVoiceApiTenant);
+
+    int batchInsertCompanyVoiceApiTenant(@Param("list") List<CompanyVoiceApiTenant> list);
+
+    int updateCompanyVoiceApiTenant(CompanyVoiceApiTenant companyVoiceApiTenant);
+
+    int deleteCompanyVoiceApiTenantById(Long id);
+
+    int deleteByApiAndTenant(@Param("apiId") Long apiId, @Param("tenantId") Long tenantId);
+
+    int deleteByApiId(Long apiId);
+
+    int disableByApiId(Long apiId);
+
+    int disableByApiIds(@Param("apiIds") Long[] apiIds);
+
+    int batchUpdatePricing(@Param("ids") List<Long> ids, @Param("data") CompanyVoiceApiTenant data);
+
+    int batchUpdateStatus(@Param("ids") List<Long> ids, @Param("status") Integer status);
+
     @Select("SELECT COUNT(1) FROM company_voice_api_tenant WHERE api_id = #{apiId} AND status = 1")
     Integer selectTenantCountByApiId(Long apiId);
 }

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

@@ -21,4 +21,18 @@ public interface CompanyWorkflowLobsterNodeMapper extends BaseMapper<CompanyWork
     int checkNodeGreeting(@Param("templateId") Long templateId);
 
     List<String> selectCallWordsByTemplateId(@Param("templateId") Long templateId);
+
+    /** 动态节点生成:取节点当前排序 */
+    Integer selectSortNoByCode(@Param("workflowId") Long workflowId, @Param("nodeCode") String nodeCode);
+
+    /** 动态节点生成:插入新节点(INSERT IGNORE 防止重复) */
+    int insertDynamicNode(@Param("workflowId") Long workflowId,
+                          @Param("nodeCode") String nodeCode,
+                          @Param("nodeName") String nodeName,
+                          @Param("nodeType") Integer nodeType,
+                          @Param("sortNo") Integer sortNo,
+                          @Param("nextNodeCode") String nextNodeCode,
+                          @Param("messageTemplate") String messageTemplate,
+                          @Param("conditionExpr") String conditionExpr,
+                          @Param("nodeConfig") String nodeConfig);
 }

+ 29 - 0
fs-service/src/main/java/com/fs/company/mapper/CustomerFactMapper.java

@@ -0,0 +1,29 @@
+package com.fs.company.mapper;
+
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 客户事实记忆 Mapper(customer_fact 表)
+ * 跨实例长期事实存储:目的地、金额、阶段、关键变量等
+ */
+public interface CustomerFactMapper {
+
+    /** 加载该客户最近20条历史事实(跨实例) */
+    List<Map<String, Object>> selectByUser(@Param("companyId") Long companyId,
+                                            @Param("externalUserId") String externalUserId);
+
+    /** 写入或更新单条事实(含 ON DUPLICATE KEY 更新) */
+    int upsert(@Param("companyId") Long companyId,
+               @Param("externalUserId") String externalUserId,
+               @Param("instanceId") Long instanceId,
+               @Param("factKey") String factKey,
+               @Param("factValue") String factValue,
+               @Param("factType") String factType);
+
+    /** 遗忘机制:删除过期事实(超过 expireDays 天未更新且 confidence 低于阈值) */
+    int deleteExpiredFacts(@Param("expireBefore") String expireBefore,
+                           @Param("maxConfidence") double maxConfidence);
+}

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

@@ -0,0 +1,25 @@
+package com.fs.company.mapper;
+
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 客户沟通习惯 Mapper(customer_habit 表)
+ * 4维度:回复速度、消息长度偏好、Emoji偏好、对话风格
+ */
+public interface CustomerHabitMapper {
+
+    /** 查询客户全部习惯维度(按置信度过滤) */
+    List<Map<String, Object>> selectByUser(@Param("companyId") Long companyId,
+                                            @Param("externalUserId") String externalUserId);
+
+    /** Upsert 习惯:confidence 采用 EWMA 衰减(旧*0.7 + 新*0.3) */
+    int upsert(@Param("companyId") Long companyId,
+               @Param("externalUserId") String externalUserId,
+               @Param("habitKey") String habitKey,
+               @Param("habitValue") String habitValue,
+               @Param("confidence") double confidence,
+               @Param("source") String source);
+}

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

@@ -1,13 +1,36 @@
 package com.fs.company.mapper;
 
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import com.fs.company.domain.LobsterApiRegistry;
-import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
 
 import java.util.List;
+import java.util.Map;
 
-@Mapper
-public interface LobsterApiRegistryMapper extends BaseMapper<LobsterApiRegistry> {
+/**
+ * 龙虾外部 API 注册 Mapper(lobster_api_registry 表)
+ */
+public interface LobsterApiRegistryMapper {
 
-    List<LobsterApiRegistry> selectEnabled();
+    List<Map<String, Object>> selectEnabled(@Param("category") String category);
+
+    int insert(@Param("apiKey") String apiKey,
+               @Param("apiName") String apiName,
+               @Param("category") String category,
+               @Param("provider") String provider,
+               @Param("baseUrl") String baseUrl,
+               @Param("authType") String authType,
+               @Param("authConfig") String authConfig,
+               @Param("timeout") Integer timeout,
+               @Param("priority") Integer priority,
+               @Param("isBackup") Integer isBackup,
+               @Param("description") String description);
+
+    int updateById(@Param("id") Long id,
+                   @Param("apiKey") String apiKey,
+                   @Param("baseUrl") String baseUrl,
+                   @Param("authConfig") String authConfig,
+                   @Param("enabled") Integer enabled);
+
+    int deleteById(@Param("id") Long id);
+
+    int ensureTable();
 }

+ 206 - 0
fs-service/src/main/java/com/fs/company/mapper/LobsterAuxiliaryMapper.java

@@ -0,0 +1,206 @@
+package com.fs.company.mapper;
+
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 龙虾知识版本 / 事件审计 / E2E / 死信等辅助模块 Mapper
+ */
+public interface LobsterAuxiliaryMapper {
+
+    // === lobster_knowledge_version ===
+    Integer selectMaxVersion(@Param("companyId") Long companyId,
+                             @Param("knowledgeId") Long knowledgeId);
+    Map<String, Object> selectKnowledgeById(@Param("companyId") Long companyId,
+                                             @Param("knowledgeId") Long knowledgeId);
+    int insertVersion(@Param("companyId") Long companyId,
+                      @Param("knowledgeId") Long knowledgeId,
+                      @Param("version") Integer version,
+                      @Param("title") String title,
+                      @Param("content") String content,
+                      @Param("changeLog") String changeLog);
+    List<Map<String, Object>> selectVersions(@Param("companyId") Long companyId,
+                                              @Param("knowledgeId") Long knowledgeId);
+    Map<String, Object> selectVersion(@Param("companyId") Long companyId,
+                                       @Param("knowledgeId") Long knowledgeId,
+                                       @Param("version") Integer version);
+    int rollbackVersion(@Param("companyId") Long companyId,
+                        @Param("knowledgeId") Long knowledgeId,
+                        @Param("toVersion") Integer toVersion);
+    int restoreVersion(@Param("companyId") Long companyId,
+                       @Param("knowledgeId") Long knowledgeId,
+                       @Param("fromVersion") Integer fromVersion);
+    int ensureVersionTable();
+
+    // === lobster_event_node_audit ===
+    List<Map<String, Object>> selectEventStuckNodes(@Param("companyId") Long companyId,
+                                                      @Param("sinceMinutes") int sinceMinutes);
+    List<Map<String, Object>> selectEventNodeStats(@Param("companyId") Long companyId);
+    int insertEventAudit(@Param("companyId") Long companyId,
+                         @Param("nodeCode") String nodeCode,
+                         @Param("stuckCount") int stuckCount,
+                         @Param("avgWaitMs") long avgWaitMs);
+    int ensureEventAuditTable();
+
+    // === lobster_user_events ===
+    int markEventProcessed(@Param("contactId") Long contactId);
+    Long countUserEvents(@Param("companyId") Long companyId);
+
+    // === lobster_dynamic_node_impl / company_dynamic_impl ===
+    List<Map<String, Object>> selectDynamicImpls(@Param("companyId") Long companyId,
+                                                  @Param("nodeType") Integer nodeType);
+    int insertDynamicImpl(@Param("companyId") Long companyId,
+                          @Param("nodeType") Integer nodeType,
+                          @Param("implName") String implName,
+                          @Param("implCode") String implCode,
+                          @Param("scriptContent") String scriptContent,
+                          @Param("status") String status);
+    Long selectLastInsertId();
+    Double selectAvgQualityScore(@Param("nodeType") Integer nodeType,
+                                  @Param("companyId") Long companyId);
+    String selectStatus(@Param("implId") Long implId);
+    int updateStatus(@Param("implId") Long implId, @Param("status") String status);
+    int updateDynamicImpl(@Param("implId") Long implId,
+                          @Param("scriptContent") String scriptContent,
+                          @Param("status") String status);
+    int deleteDynamicImpl(@Param("implId") Long implId);
+    int disableDynamicImpl(@Param("implId") Long implId);
+    List<Map<String, Object>> selectDynamicImplPaged(@Param("companyId") Long companyId,
+                                                      @Param("offset") int offset,
+                                                      @Param("limit") int limit);
+
+    // === lobster_distributed_patterns / practices ===
+    int upsertDistributedPattern(@Param("companyId") Long companyId,
+                                 @Param("industry") String industry,
+                                 @Param("scenario") String scenario,
+                                 @Param("patternType") String patternType,
+                                 @Param("content") String content,
+                                 @Param("score") double score);
+    List<Map<String, Object>> selectDistributedPatterns(@Param("companyId") Long companyId,
+                                                          @Param("industry") String industry,
+                                                          @Param("scenario") String scenario);
+    Map<String, Object> selectDistributedPractice(@Param("practiceId") Long practiceId);
+    int incrementPracticeUsage(@Param("practiceId") Long practiceId);
+    Integer countDistributedPatterns(@Param("companyId") Long companyId);
+    Integer countDistributedPractices(@Param("companyId") Long companyId);
+    Double avgDistributedScore(@Param("companyId") Long companyId);
+    Integer countDistributedContributors(@Param("companyId") Long companyId);
+    int ensureDistPatternTable();
+    int ensureDistPracticeTable();
+
+    // === lobster_heartbeat / scheduler ===
+    int insertHeartbeat(@Param("companyId") Long companyId,
+                        @Param("taskKey") String taskKey,
+                        @Param("status") String status);
+    List<Map<String, Object>> selectHeartbeats(@Param("companyId") Long companyId,
+                                                @Param("taskKey") String taskKey,
+                                                @Param("limit") int limit);
+    int updateHeartbeat(@Param("companyId") Long companyId,
+                        @Param("taskKey") String taskKey,
+                        @Param("status") String status);
+    int ensureHeartbeatTable();
+
+    // === lobster_dead_letter_queue ===
+    int insertDeadLetter(@Param("companyId") Long companyId,
+                         @Param("queueName") String queueName,
+                         @Param("payload") String payload,
+                         @Param("error") String error);
+    List<Map<String, Object>> selectDeadLetters(@Param("queueName") String queueName,
+                                                 @Param("limit") int limit);
+    int retryDeadLetter(@Param("id") Long id);
+    int deleteDeadLetter(@Param("id") Long id);
+    int ensureDeadLetterTable();
+
+    // === lobster_e2e_test ===
+    int insertE2eTest(@Param("companyId") Long companyId,
+                      @Param("testName") String testName,
+                      @Param("workflowId") Long workflowId,
+                      @Param("testData") String testData);
+    int insertE2eResult(@Param("testId") Long testId,
+                        @Param("companyId") Long companyId,
+                        @Param("passed") boolean passed,
+                        @Param("detail") String detail);
+    List<Map<String, Object>> selectE2eTests(@Param("companyId") Long companyId,
+                                              @Param("offset") int offset,
+                                              @Param("limit") int limit);
+    Map<String, Object> selectE2eResult(@Param("runId") String runId);
+    List<Map<String, Object>> selectE2eList(@Param("testId") Long testId);
+    int ensureE2eTestTable();
+    int ensureE2eResultTable();
+
+    // === lobster_test_scenario ===
+    List<Map<String, Object>> selectTestScenarios(@Param("companyId") Long companyId);
+    Map<String, Object> selectTestScenarioById(@Param("id") Long id,
+                                                @Param("companyId") Long companyId);
+    int insertTestScenario(@Param("companyId") Long companyId,
+                           @Param("scenarioName") String scenarioName,
+                           @Param("workflowId") Long workflowId,
+                           @Param("testData") String testData);
+    int updateTestScenario(@Param("id") Long id,
+                           @Param("scenarioName") String scenarioName,
+                           @Param("testData") String testData);
+    int deleteTestScenario(@Param("id") Long id, @Param("companyId") Long companyId);
+    int insertTestScenarioResult(@Param("companyId") Long companyId,
+                                  @Param("scenarioId") Long scenarioId,
+                                  @Param("passed") boolean passed,
+                                  @Param("detail") String detail);
+    int ensureScenarioTable();
+
+    // === lobster_workflow_patch ===
+    int insertPatch(@Param("companyId") Long companyId,
+                    @Param("targetTable") String targetTable,
+                    @Param("targetId") Long targetId,
+                    @Param("fieldName") String fieldName,
+                    @Param("oldValue") String oldValue,
+                    @Param("newValue") String newValue,
+                    @Param("reason") String reason);
+    List<Map<String, Object>> selectPatches(@Param("companyId") Long companyId);
+    int applyPatch(@Param("id") Long id);
+    int rejectPatch(@Param("id") Long id);
+    int ensurePatchTable();
+
+    // === vector tables ===
+    int insertVectorEmbedding(@Param("companyId") Long companyId,
+                              @Param("docId") String docId,
+                              @Param("docType") String docType,
+                              @Param("content") String content,
+                              @Param("embedding") String embedding);
+    List<Map<String, Object>> selectVectorEmbeddings(@Param("companyId") Long companyId,
+                                                      @Param("docType") String docType);
+    int ensureVectorTable();
+
+    // === 通用 ===
+    List<Map<String, Object>> selectPaged(@Param("table") String table,
+                                           @Param("whereClause") String whereClause,
+                                           @Param("offset") int offset,
+                                           @Param("limit") int limit);
+    int update(@Param("sql") String sql);
+
+    /** 带参数的 update/insert(占位符 ? 由 args 填充) */
+    int updateWithArgs(@Param("sql") String sql, @Param("args") List<Object> args);
+
+    /** ContactAdapter 使用:从平台用户表查原始ID */
+    Map<String, Object> selectSourceUserId(@Param("table") String sourceTable,
+                                            @Param("column") String userIdColumn,
+                                            @Param("companyId") Long companyId);
+
+    /** 配置读取(通用) */
+    String selectConfig(@Param("companyId") Long companyId,
+                        @Param("configKey") String configKey);
+
+    /** 兼容旧 jdbcTemplate.queryForList (单参数 companyId) */
+    List<Map<String, Object>> queryForList(@Param("sql") String sql,
+                                            @Param("customerId") Long customerId, @Param("companyId") Long companyId);
+    /** 兼容旧 jdbcTemplate.queryForList (单参数 companyId) */
+    List<Map<String, Object>> queryForList(@Param("sql") String sql,
+                                            @Param("companyId") Long companyId);
+
+    /** 兼容旧 jdbcTemplate.queryForList (单参数 + 返回类型) */
+    List<String> queryForStringList(@Param("sql") String sql,
+                                     @Param("companyId") Long companyId);
+
+    /** 兼容旧 jdbcTemplate.queryForObject → 表存在检查 */
+    int executeStatement(@Param("sql") String sql);
+}

+ 27 - 0
fs-service/src/main/java/com/fs/company/mapper/LobsterBillingMapper.java

@@ -0,0 +1,27 @@
+package com.fs.company.mapper;
+
+import com.fs.company.domain.LobsterConsumeRecord;
+import com.fs.company.domain.LobsterTenantBalance;
+import org.apache.ibatis.annotations.Param;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 龙虾计费 Mapper — 表: tenant_balance / tenant_consume_record
+ */
+public interface LobsterBillingMapper {
+
+    // ======== tenant_balance ========
+    LobsterTenantBalance selectBalanceByTenantId(@Param("tenantId") Long tenantId);
+
+    int updateTokenCoefficient(@Param("tenantId") Long tenantId,
+                               @Param("coefficient") BigDecimal coefficient);
+
+    // ======== tenant_consume_record ========
+    List<LobsterConsumeRecord> selectConsumeRecords(@Param("tenantId") Long tenantId,
+                                                     @Param("offset") int offset,
+                                                     @Param("limit") int limit);
+
+    long countConsumeRecords(@Param("tenantId") Long tenantId);
+}

+ 44 - 0
fs-service/src/main/java/com/fs/company/mapper/LobsterChannelPluginConfigMapper.java

@@ -0,0 +1,44 @@
+package com.fs.company.mapper;
+
+import com.fs.company.domain.LobsterChannelPluginConfig;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 龙虾渠道插件配置 Mapper
+ * <p>
+ * 表: lobster_channel_plugin_config
+ */
+public interface LobsterChannelPluginConfigMapper {
+
+    /**
+     * 查询租户指定渠道的配置
+     */
+    LobsterChannelPluginConfig selectByCompanyAndChannel(@Param("companyId") Long companyId,
+                                                          @Param("channelType") String channelType);
+
+    /**
+     * 查询租户所有已配置的渠道
+     */
+    List<LobsterChannelPluginConfig> selectByCompanyId(@Param("companyId") Long companyId);
+
+    /**
+     * 插入或更新(ON DUPLICATE KEY UPDATE)
+     */
+    int upsert(LobsterChannelPluginConfig config);
+
+    /**
+     * 更新启用状态
+     */
+    int updateEnabled(@Param("companyId") Long companyId,
+                      @Param("channelType") String channelType,
+                      @Param("enabled") Integer enabled);
+
+    /**
+     * 更新配置 JSON
+     */
+    int updateConfig(@Param("companyId") Long companyId,
+                     @Param("channelType") String channelType,
+                     @Param("configJson") String configJson);
+}

+ 35 - 0
fs-service/src/main/java/com/fs/company/mapper/LobsterChannelRegistryMapper.java

@@ -0,0 +1,35 @@
+package com.fs.company.mapper;
+
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 渠道注册 + 统一触点 Mapper
+ * 表: lobster_channel_type_registry / lobster_unified_contact
+ */
+public interface LobsterChannelRegistryMapper {
+
+    /** 从 unified_contact 查渠道用户ID */
+    String selectChannelUserId(@Param("companyId") Long companyId,
+                                @Param("contactId") Long contactId,
+                                @Param("channelType") String channelType);
+
+    /** 从平台用户表查原始ID */
+    Map<String, Object> selectSourceUserId(@Param("table") String sourceTable,
+                                            @Param("column") String userIdColumn,
+                                            @Param("companyId") Long companyId);
+
+    /** 绑定触点 */
+    int upsertUnifiedContact(@Param("companyId") Long companyId,
+                             @Param("contactId") Long contactId,
+                             @Param("channelType") String channelType,
+                             @Param("channelUserId") String channelUserId);
+
+    /** 加载DB渠道注册 */
+    List<Map<String, Object>> selectEnabledChannels();
+
+    /** 建表探针 */
+    int ensureTable();
+}

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

@@ -0,0 +1,18 @@
+package com.fs.company.mapper;
+
+import com.fs.company.domain.LobsterChatMsg;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 龙虾聊天消息 Mapper(chat_msg 表 — 多渠道聚合)
+ */
+public interface LobsterChatMsgMapper {
+
+    /** 插入消息 */
+    int insert(LobsterChatMsg msg);
+
+    /** 按 sessionId 查询消息列表 */
+    List<LobsterChatMsg> selectBySessionId(@Param("sessionId") Long sessionId);
+}

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

@@ -0,0 +1,28 @@
+package com.fs.company.mapper;
+
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 龙虾聊天记录 Mapper(lobster_chat_record 表)
+ */
+public interface LobsterChatRecordMapper {
+
+    /** 统计最近N分钟新增的消息数 */
+    Integer countNewMessages(@Param("externalUserId") String externalUserId,
+                              @Param("instanceId") Long instanceId,
+                              @Param("minutes") int minutes);
+
+    /** 获取上次摘要后的新对话 */
+    List<Map<String, Object>> selectNewChatsSince(@Param("externalUserId") String externalUserId,
+                                                    @Param("instanceId") Long instanceId,
+                                                    @Param("lastSummaryTime") String lastSummaryTime);
+
+    /** ContextAssembler 使用:加载最近 N 条对话(按 message_time 倒序) */
+    List<Map<String, Object>> selectRecentChats(@Param("externalUserId") String externalUserId,
+                                                 @Param("instanceId") Long instanceId,
+                                                 @Param("companyId") Long companyId,
+                                                 @Param("limit") int limit);
+}

+ 36 - 0
fs-service/src/main/java/com/fs/company/mapper/LobsterChatSessionMapper.java

@@ -0,0 +1,36 @@
+package com.fs.company.mapper;
+
+import com.fs.company.domain.LobsterChatSession;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 龙虾聊天会话 Mapper(chat_session 表 — 多渠道聚合)
+ */
+public interface LobsterChatSessionMapper {
+
+    /** 按 company_id + channel_source_id + channel_type 查询 */
+    LobsterChatSession selectBySourceAndChannel(@Param("companyId") Long companyId,
+                                                 @Param("channelSourceId") String channelSourceId,
+                                                 @Param("channelType") String channelType);
+
+    /** 按 company_id + user_id 查询(降级兼容) */
+    LobsterChatSession selectByCompanyAndUser(@Param("companyId") Long companyId,
+                                              @Param("userId") String userId);
+
+    /** 插入新会话,返回 sessionId */
+    int insert(LobsterChatSession session);
+
+    /** 更新最后消息 */
+    int updateLastMsg(@Param("sessionId") Long sessionId,
+                      @Param("lastMsg") String lastMsg);
+
+    /** 渠道聚合列表查询(带筛选) */
+    List<LobsterChatSession> selectForAggregate(@Param("channelType") String channelType,
+                                                 @Param("keyword") String keyword);
+
+    /** 按 sessionId 查询单个会话 */
+    LobsterChatSession selectBySessionId(@Param("sessionId") Long sessionId);
+}

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

@@ -0,0 +1,14 @@
+package com.fs.company.mapper;
+
+import com.fs.company.domain.LobsterComplianceAudit;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * 合规审计 Mapper(lobster_compliance_audit 表)
+ */
+public interface LobsterComplianceAuditMapper {
+
+    int insert(LobsterComplianceAudit audit);
+
+    int ensureTable();
+}

+ 2 - 4
fs-service/src/main/java/com/fs/company/mapper/LobsterComplianceRuleMapper.java

@@ -12,18 +12,16 @@ import java.util.List;
 @Mapper
 public interface LobsterComplianceRuleMapper extends BaseMapper<LobsterComplianceRule> {
 
-    @Select("SELECT * FROM lobster_compliance_rule WHERE company_id = #{companyId}")
     List<LobsterComplianceRule> selectByCompanyId(@Param("companyId") Long companyId);
 
-    @Select("SELECT * FROM lobster_compliance_rule WHERE company_id = #{companyId} AND enabled = 1")
     List<LobsterComplianceRule> selectEnabledByCompanyId(@Param("companyId") Long companyId);
 
-    @Select("SELECT * FROM lobster_compliance_rule WHERE id = #{id} AND company_id = #{companyId}")
     LobsterComplianceRule selectByIdAndCompanyId(@Param("id") Long id, @Param("companyId") Long companyId);
 
     @Select("SELECT pattern FROM lobster_compliance_rule WHERE company_id = #{companyId} AND rule_type = 'sensitive' AND enabled = 1")
     List<String> selectSensitivePatterns(@Param("companyId") Long companyId);
 
-    @Update("UPDATE lobster_compliance_rule SET deleted = 1 WHERE id = #{id} AND company_id = #{companyId}")
     int logicalDeleteById(@Param("id") Long id, @Param("companyId") Long companyId);
+
+    int ensureTable();
 }

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

@@ -14,4 +14,29 @@ public interface LobsterConversationSummaryMapper {
     int insert(LobsterConversationSummary summary);
 
     int updateById(LobsterConversationSummary summary);
+
+    /** 全局摘要:按 external_user_id 获取最近10条会话摘要 */
+    List<LobsterConversationSummary> selectByExternalUserId(@Param("companyId") Long companyId,
+                                                             @Param("externalUserId") String externalUserId);
+
+    /** 仅取最近一条 summary_text(按 user+instance) */
+    String selectLatestSummaryText(@Param("externalUserId") String externalUserId,
+                                    @Param("instanceId") Long instanceId);
+
+    /** 自动摘要写入(含 stage / nextActionHint / keyIntents / keyVariables 等扩展列) */
+    int insertAutoSummary(@Param("companyId") Long companyId,
+                          @Param("externalUserId") String externalUserId,
+                          @Param("instanceId") Long instanceId,
+                          @Param("summaryText") String summaryText,
+                          @Param("chatMsgCount") int chatMsgCount,
+                          @Param("keyIntents") String keyIntents,
+                          @Param("keyVariables") String keyVariables,
+                          @Param("stage") String stage,
+                          @Param("nextActionHint") String nextActionHint);
+
+    /** 摘要写入后回写用户画像状态 */
+    int updateUserProfile(@Param("companyId") Long companyId,
+                          @Param("externalUserId") String externalUserId,
+                          @Param("currentState") String currentState,
+                          @Param("variableSnapshot") String variableSnapshot);
 }

+ 22 - 0
fs-service/src/main/java/com/fs/company/mapper/LobsterDedupConfigMapper.java

@@ -0,0 +1,22 @@
+package com.fs.company.mapper;
+
+import com.fs.company.domain.LobsterDedupConfig;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 消息去重配置 Mapper
+ */
+public interface LobsterDedupConfigMapper {
+
+    LobsterDedupConfig selectByCompanyId(@Param("companyId") Long companyId);
+
+    List<LobsterDedupConfig> selectAll(@Param("companyId") Long companyId);
+
+    int insert(LobsterDedupConfig config);
+
+    int updateById(LobsterDedupConfig config);
+
+    int deleteById(@Param("id") Long id, @Param("companyId") Long companyId);
+}

+ 21 - 28
fs-service/src/main/java/com/fs/company/mapper/LobsterDialogueStateMapper.java

@@ -1,31 +1,24 @@
 package com.fs.company.mapper;
 
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import com.fs.company.domain.LobsterDialogueState;
-import org.apache.ibatis.annotations.*;
-
-@Mapper
-public interface LobsterDialogueStateMapper extends BaseMapper<LobsterDialogueState> {
-
-    @Select("SELECT state_json FROM lobster_dialogue_state WHERE instance_id = #{instanceId} AND node_code = #{nodeCode} AND company_id = #{companyId}")
-    String selectStateJson(@Param("instanceId") Long instanceId, @Param("nodeCode") String nodeCode, @Param("companyId") Long companyId);
-
-    @Select("SELECT state_json FROM lobster_dialogue_state WHERE instance_id = #{instanceId} AND node_code = #{nodeCode}")
-    String selectStateJsonNoCompany(@Param("instanceId") Long instanceId, @Param("nodeCode") String nodeCode);
-
-    @Delete("DELETE FROM lobster_dialogue_state WHERE instance_id = #{instanceId} AND node_code = #{nodeCode} AND company_id = #{companyId}")
-    int deleteByInstanceAndNode(@Param("instanceId") Long instanceId, @Param("nodeCode") String nodeCode, @Param("companyId") Long companyId);
-
-    @Delete("DELETE FROM lobster_dialogue_state WHERE instance_id = #{instanceId} AND node_code = #{nodeCode}")
-    int deleteByInstanceAndNodeNoCompany(@Param("instanceId") Long instanceId, @Param("nodeCode") String nodeCode);
-
-    @Insert("INSERT INTO lobster_dialogue_state (company_id, instance_id, node_code, state_json, update_time) " +
-            "VALUES (#{companyId}, #{instanceId}, #{nodeCode}, #{stateJson}, NOW()) " +
-            "ON DUPLICATE KEY UPDATE state_json = #{stateJson}, update_time = NOW()")
-    int upsert(LobsterDialogueState entity);
-
-    @Insert("INSERT INTO lobster_dialogue_state (instance_id, node_code, state_json, update_time) " +
-            "VALUES (#{instanceId}, #{nodeCode}, #{stateJson}, NOW()) " +
-            "ON DUPLICATE KEY UPDATE state_json = #{stateJson}, update_time = NOW()")
-    int upsertNoCompany(LobsterDialogueState entity);
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Map;
+
+/**
+ * 龙虾对话状态 Mapper(lobster_dialogue_state 表 — 断点续聊)
+ */
+public interface LobsterDialogueStateMapper {
+
+    /** 读最新状态 */
+    Map<String, Object> selectLatest(@Param("companyId") Long companyId,
+                                      @Param("externalUserId") String externalUserId);
+
+    /** UPSERT 写入对话状态 */
+    int upsert(@Param("instanceId") Long instanceId,
+               @Param("companyId") Long companyId,
+               @Param("externalUserId") String externalUserId,
+               @Param("nodeCode") String nodeCode,
+               @Param("intent") String intent,
+               @Param("sentiment") String sentiment,
+               @Param("lastReply") String lastReply);
 }

+ 17 - 12
fs-service/src/main/java/com/fs/company/mapper/LobsterEventAuditMapper.java

@@ -1,23 +1,28 @@
 package com.fs.company.mapper;
 
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.fs.company.domain.LobsterEventAudit;
-import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 
 import java.util.List;
 
-@Mapper
-public interface LobsterEventAuditMapper extends BaseMapper<LobsterEventAudit> {
+/**
+ * 龙虾事件节点审核 Mapper — 表: lobster_event_node_audit
+ */
+public interface LobsterEventAuditMapper {
 
-    List<LobsterEventAudit> selectListByCompanyIdAndStatus(@Param("companyId") Long companyId,
-                                                            @Param("status") String status,
-                                                            @Param("offset") int offset,
-                                                            @Param("limit") int limit);
+    List<LobsterEventAudit> selectList(@Param("companyId") Long companyId,
+                                        @Param("status") String status,
+                                        @Param("offset") int offset,
+                                        @Param("limit") int limit);
 
-    long countByCompanyIdAndStatus(@Param("companyId") Long companyId,
-                                   @Param("status") String status);
+    long countByStatus(@Param("companyId") Long companyId, @Param("status") String status);
 
-    LobsterEventAudit selectByIdAndStatus(@Param("id") Long id,
-                                           @Param("status") String status);
+    long countTotalByStatus(@Param("companyId") Long companyId, @Param("status") String status);
+
+    LobsterEventAudit selectById(@Param("id") Long id);
+
+    int updateStatus(@Param("id") Long id,
+                     @Param("status") String status,
+                     @Param("auditBy") String auditBy,
+                     @Param("auditComment") String auditComment);
 }

+ 88 - 5
fs-service/src/main/java/com/fs/company/mapper/LobsterEvolutionConfigMapper.java

@@ -1,9 +1,92 @@
 package com.fs.company.mapper;
 
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import com.fs.company.domain.LobsterEvolutionConfig;
-import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
 
-@Mapper
-public interface LobsterEvolutionConfigMapper extends BaseMapper<LobsterEvolutionConfig> {
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 龙虾进化配置 Mapper
+ * 表: lobster_node_optimization / lobster_optimization_config / lobster_node_interaction
+ */
+public interface LobsterEvolutionConfigMapper {
+
+    List<Map<String, Object>> selectOptimizationStats(@Param("companyId") Long companyId);
+    List<Map<String, Object>> selectOptimizationTypes(@Param("companyId") Long companyId);
+    List<Map<String, Object>> selectOptimizations(@Param("companyId") Long companyId,
+                                                   @Param("workflowId") Long workflowId,
+                                                   @Param("offset") int offset,
+                                                   @Param("limit") int limit);
+    List<Map<String, Object>> selectOptimizationByStatus(@Param("companyId") Long companyId,
+                                                          @Param("status") String status,
+                                                          @Param("offset") int offset,
+                                                          @Param("limit") int limit);
+    Map<String, Object> selectOptimizationById(@Param("id") Long id,
+                                                @Param("companyId") Long companyId);
+    int approveOptimization(@Param("id") Long id, @Param("auditorId") Long auditorId);
+    int rejectOptimization(@Param("id") Long id, @Param("auditorId") Long auditorId);
+    int ensureOptimizationTable();
+
+    Map<String, Object> selectConfig(@Param("companyId") Long companyId,
+                                      @Param("workflowId") Long workflowId,
+                                      @Param("nodeCode") String nodeCode);
+    Boolean selectConfigEnabled(@Param("companyId") Long companyId,
+                                 @Param("workflowId") Long workflowId,
+                                 @Param("nodeCode") String nodeCode);
+    int upsertConfig(@Param("companyId") Long companyId,
+                     @Param("workflowId") Long workflowId,
+                     @Param("nodeCode") String nodeCode,
+                     @Param("enabled") Boolean enabled,
+                     @Param("autoApply") String autoApply,
+                     @Param("configJson") String configJson);
+    int updateConfigStatus(@Param("companyId") Long companyId,
+                           @Param("workflowId") Long workflowId,
+                           @Param("nodeCode") String nodeCode,
+                           @Param("enabled") Boolean enabled);
+    int ensureConfigTable();
+
+    int insertInteraction(@Param("companyId") Long companyId,
+                          @Param("instanceId") Long instanceId,
+                          @Param("nodeCode") String nodeCode,
+                          @Param("externalUserId") String externalUserId,
+                          @Param("interactionType") String interactionType,
+                          @Param("content") String content);
+    List<String> selectInteractionTrace(@Param("externalUserId") String externalUserId,
+                                         @Param("nodeCode") String nodeCode,
+                                         @Param("companyId") Long companyId);
+    int ensureInteractionTable();
+
+    Double selectAverageQualityScore(@Param("companyId") Long companyId,
+                                      @Param("nodeCode") String nodeCode);
+
+    // === lobster_evolution_log (EvolutionEngineImpl) ===
+    int insertEvolutionLog(@Param("companyId") Long companyId,
+                           @Param("workflowId") Long workflowId,
+                           @Param("nodeCode") String nodeCode,
+                           @Param("action") String action,
+                           @Param("detail") String detail);
+    List<Map<String, Object>> selectEvolutionLogs(@Param("companyId") Long companyId,
+                                                   @Param("workflowId") Long workflowId);
+    int ensureEvolutionLogTable();
+
+    // === lobster_evolution_suggestion (EvolutionEngineImpl) ===
+    Map<String, Object> selectSuggestionById(@Param("id") Long id,
+                                              @Param("companyId") Long companyId);
+    int insertSuggestion(@Param("companyId") Long companyId,
+                         @Param("workflowId") Long workflowId,
+                         @Param("nodeCode") String nodeCode,
+                         @Param("suggestionType") String suggestionType,
+                         @Param("currentContent") String currentContent,
+                         @Param("suggestedContent") String suggestedContent,
+                         @Param("reason") String reason,
+                         @Param("confidence") Double confidence);
+    int updateNodeMessage(@Param("suggestedContent") String suggestedContent,
+                          @Param("workflowId") Long workflowId,
+                          @Param("nodeCode") String nodeCode);
+    int acceptSuggestion(@Param("id") Long id);
+    Integer countTotal(@Param("companyId") Long companyId);
+    Integer countReplied(@Param("companyId") Long companyId);
+    Integer countPendingSuggestion(@Param("companyId") Long companyId);
+    Integer countApplied(@Param("companyId") Long companyId);
+    int ensureSuggestionTable();
 }

+ 57 - 0
fs-service/src/main/java/com/fs/company/mapper/LobsterFeedbackMapper.java

@@ -0,0 +1,57 @@
+package com.fs.company.mapper;
+
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 龙虾反馈驱动进化 Mapper
+ * 表: lobster_feedback_records / lobster_message_variants / lobster_ab_tests
+ */
+public interface LobsterFeedbackMapper {
+
+    // ===== feedback_records =====
+    int insertFeedback(@Param("companyId") Long companyId,
+                       @Param("instanceId") Long instanceId,
+                       @Param("nodeCode") String nodeCode,
+                       @Param("feedbackType") String feedbackType,
+                       @Param("comment") String comment);
+
+    List<Map<String, Object>> selectFeedbackStats(@Param("companyId") Long companyId);
+
+    String selectCurrentMessage(@Param("nodeCode") String nodeCode,
+                                @Param("companyId") Long companyId);
+
+    List<String> selectNegativeComments(@Param("companyId") Long companyId,
+                                         @Param("nodeCode") String nodeCode);
+
+    int ensureFeedbackTable();
+
+    // ===== message_variants =====
+    int insertVariant(@Param("companyId") Long companyId,
+                      @Param("nodeCode") String nodeCode,
+                      @Param("content") String content,
+                      @Param("generationReason") String generationReason);
+
+    int ensureVariantTable();
+
+    // ===== ab_tests =====
+    List<Map<String, Object>> selectActiveAbTests(@Param("companyId") Long companyId);
+
+    Map<String, Object> selectAbTest(@Param("testId") Long testId,
+                                      @Param("companyId") Long companyId);
+
+    int applyVariantToNode(@Param("variantMessage") String variantMessage,
+                           @Param("nodeCode") String nodeCode,
+                           @Param("companyId") Long companyId);
+
+    int markAbTestApplied(@Param("testId") Long testId);
+
+    int markAbTestCompleted(@Param("testId") Long testId);
+
+    List<Map<String, Object>> selectWinAbTests(@Param("companyId") Long companyId,
+                                                @Param("winThreshold") double winThreshold);
+
+    int ensureAbTestTable();
+}

+ 11 - 5
fs-service/src/main/java/com/fs/company/mapper/LobsterHandoffEventMapper.java

@@ -1,9 +1,15 @@
 package com.fs.company.mapper;
 
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import com.fs.company.domain.LobsterHandoffEvent;
-import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
 
-@Mapper
-public interface LobsterHandoffEventMapper extends BaseMapper<LobsterHandoffEvent> {
+/**
+ * 龙虾转人工事件 Mapper(lobster_handoff_events 表)
+ */
+public interface LobsterHandoffEventMapper {
+
+    int insertEvent(@Param("companyId") Long companyId,
+                    @Param("instanceId") Long instanceId,
+                    @Param("externalUserId") String externalUserId,
+                    @Param("triggerType") String triggerType,
+                    @Param("triggerDetail") String triggerDetail);
 }

+ 22 - 0
fs-service/src/main/java/com/fs/company/mapper/LobsterKnowledgeUsageLogMapper.java

@@ -0,0 +1,22 @@
+package com.fs.company.mapper;
+
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Map;
+
+/**
+ * 知识使用反馈 Mapper(lobster_knowledge_usage_log 表)
+ * 追踪知识被检索后在 AI 回复中的实际使用情况
+ */
+public interface LobsterKnowledgeUsageLogMapper {
+
+    int insert(@Param("companyId") Long companyId,
+               @Param("instanceId") Long instanceId,
+               @Param("knowledgeTitle") String knowledgeTitle,
+               @Param("knowledgeText") String knowledgeText,
+               @Param("retrievalMethod") String retrievalMethod,
+               @Param("source") String source);
+
+    Map<String, Object> countUsage(@Param("companyId") Long companyId,
+                                    @Param("knowledgeTitle") String knowledgeTitle);
+}

+ 22 - 15
fs-service/src/main/java/com/fs/company/mapper/LobsterLearningCorpusMapper.java

@@ -1,28 +1,35 @@
 package com.fs.company.mapper;
 
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import com.fs.company.domain.LobsterLearningCorpus;
-import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 
 import java.util.List;
+import java.util.Map;
 
-@Mapper
-public interface LobsterLearningCorpusMapper extends BaseMapper<LobsterLearningCorpus> {
+/**
+ * 销冠语料库 Mapper(lobster_learning_corpus 表)
+ */
+public interface LobsterLearningCorpusMapper {
 
-    List<LobsterLearningCorpus> selectListByCompanyId(@Param("companyId") Long companyId,
-                                                       @Param("scenario") String scenario,
-                                                       @Param("status") String status);
+    int insert(@Param("companyId") Long companyId,
+               @Param("salespersonName") String salespersonName,
+               @Param("customerQuestion") String customerQuestion,
+               @Param("salesAnswer") String salesAnswer,
+               @Param("scenario") String scenario,
+               @Param("industryType") String industryType,
+               @Param("tags") String tags,
+               @Param("status") String status);
 
-    List<LobsterLearningCorpus> selectRawByCompanyId(@Param("companyId") Long companyId,
-                                                      @Param("limit") int limit);
+    List<Map<String, Object>> selectByScenario(@Param("companyId") Long companyId,
+                                                @Param("scenario") String scenario,
+                                                @Param("limit") int limit);
 
-    List<LobsterLearningCorpus> selectByScenario(@Param("companyId") Long companyId,
-                                                  @Param("scenario") String scenario,
-                                                  @Param("limit") int limit);
+    int incrementUsageCount(@Param("entryId") Long entryId);
+
+    List<Map<String, Object>> selectRawCorpus(@Param("companyId") Long companyId,
+                                               @Param("limit") int limit);
 
     int markAnalyzed(@Param("companyId") Long companyId,
-                     @Param("limit") int limit);
+                     @Param("count") int count);
 
-    int incrementUsage(@Param("id") Long id);
+    int ensureTable();
 }

+ 65 - 0
fs-service/src/main/java/com/fs/company/mapper/LobsterMultiTurnDialogueMapper.java

@@ -0,0 +1,65 @@
+package com.fs.company.mapper;
+
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 龙虾多轮对话管理 Mapper(lobster_multi_turn_dialogue 表)
+ */
+public interface LobsterMultiTurnDialogueMapper {
+
+    int deleteState(@Param("instanceId") Long instanceId,
+                    @Param("nodeCode") String nodeCode,
+                    @Param("companyId") Long companyId);
+
+    int deleteStateNoCompany(@Param("instanceId") Long instanceId,
+                             @Param("nodeCode") String nodeCode);
+
+    List<Map<String, Object>> selectDialogue(@Param("instanceId") Long instanceId,
+                                              @Param("nodeCode") String nodeCode,
+                                              @Param("companyId") Long companyId);
+
+    List<Map<String, Object>> selectDialogueNoCompany(@Param("instanceId") Long instanceId,
+                                                       @Param("nodeCode") String nodeCode);
+
+    int appendTurn(@Param("companyId") Long companyId,
+                   @Param("instanceId") Long instanceId,
+                   @Param("nodeCode") String nodeCode,
+                   @Param("turnIndex") int turnIndex,
+                   @Param("direction") int direction,
+                   @Param("content") String content);
+
+    int appendTurnNoCompany(@Param("instanceId") Long instanceId,
+                            @Param("nodeCode") String nodeCode,
+                            @Param("turnIndex") int turnIndex,
+                            @Param("direction") int direction,
+                            @Param("content") String content);
+
+    int getMaxTurnIndex(@Param("instanceId") Long instanceId,
+                        @Param("nodeCode") String nodeCode,
+                        @Param("companyId") Long companyId);
+
+    int ensureTable();
+
+    /** 按 state_json 方式存取 */
+    String selectStateJson(@Param("instanceId") Long instanceId,
+                           @Param("nodeCode") String nodeCode,
+                           @Param("companyId") Long companyId);
+
+    String selectStateJsonNoCompany(@Param("instanceId") Long instanceId,
+                                    @Param("nodeCode") String nodeCode);
+
+    int upsertState(@Param("companyId") Long companyId,
+                    @Param("instanceId") Long instanceId,
+                    @Param("nodeCode") String nodeCode,
+                    @Param("stateJson") String stateJson);
+
+    int upsertStateNoCompany(@Param("instanceId") Long instanceId,
+                             @Param("nodeCode") String nodeCode,
+                             @Param("stateJson") String stateJson);
+
+    List<Map<String, Object>> selectNodeConfig(@Param("instanceId") Long instanceId,
+                                                @Param("nodeCode") String nodeCode);
+}

+ 3 - 0
fs-service/src/main/java/com/fs/company/mapper/LobsterNodeExecutionLogMapper.java

@@ -14,4 +14,7 @@ public interface LobsterNodeExecutionLogMapper {
     int insert(LobsterNodeExecutionLog log);
 
     int updateById(LobsterNodeExecutionLog log);
+
+    /** 查询节点历史平均质量评分 */
+    Integer selectAverageQualityScore(@Param("companyId") Long companyId, @Param("nodeCode") String nodeCode);
 }

+ 49 - 27
fs-service/src/main/java/com/fs/company/mapper/LobsterPendingKnowledgeMapper.java

@@ -1,32 +1,54 @@
 package com.fs.company.mapper;
 
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import com.fs.company.domain.LobsterPendingKnowledge;
-import org.apache.ibatis.annotations.*;
+import org.apache.ibatis.annotations.Param;
 
 import java.util.List;
-
-@Mapper
-public interface LobsterPendingKnowledgeMapper extends BaseMapper<LobsterPendingKnowledge> {
-
-    @Select("SELECT * FROM lobster_pending_knowledge WHERE company_id = #{companyId} AND audit_status = 'pending'")
-    List<LobsterPendingKnowledge> selectPendingByCompanyId(@Param("companyId") Long companyId);
-
-    @Select("SELECT COUNT(*) FROM lobster_pending_knowledge WHERE company_id = #{companyId} AND audit_status = 'pending'")
-    int countPendingByCompanyId(@Param("companyId") Long companyId);
-
-    @Select("SELECT * FROM lobster_pending_knowledge WHERE id = #{id} AND company_id = #{companyId}")
-    LobsterPendingKnowledge selectByIdAndCompanyId(@Param("id") Long id, @Param("companyId") Long companyId);
-
-    @Update("UPDATE lobster_pending_knowledge SET question = #{question}, answer = #{answer}, update_by = #{updateBy}, update_time = NOW() WHERE id = #{id} AND company_id = #{companyId}")
-    int updateContent(@Param("id") Long id, @Param("companyId") Long companyId, @Param("question") String question, @Param("answer") String answer, @Param("updateBy") String updateBy);
-
-    @Update("UPDATE lobster_pending_knowledge SET audit_status = 'approved', auditor = #{auditor}, audit_time = NOW(), update_time = NOW() WHERE id = #{id} AND company_id = #{companyId}")
-    int approve(@Param("id") Long id, @Param("companyId") Long companyId, @Param("auditor") String auditor);
-
-    @Update("UPDATE lobster_pending_knowledge SET audit_status = 'rejected', audit_comment = #{comment}, auditor = #{auditor}, audit_time = NOW(), update_time = NOW() WHERE id = #{id} AND company_id = #{companyId} AND audit_status = 'pending'")
-    int reject(@Param("id") Long id, @Param("companyId") Long companyId, @Param("auditor") String auditor, @Param("comment") String comment);
-
-    @Delete("DELETE FROM lobster_pending_knowledge WHERE id = #{id} AND company_id = #{companyId}")
-    int deleteByIdAndCompanyId(@Param("id") Long id, @Param("companyId") Long companyId);
+import java.util.Map;
+
+/**
+ * 龙虾待审知识库 Mapper(lobster_pending_knowledge 表)
+ */
+public interface LobsterPendingKnowledgeMapper {
+
+    int insert(@Param("companyId") Long companyId,
+               @Param("externalUserId") String externalUserId,
+               @Param("knowledgeType") String knowledgeType,
+               @Param("content") String content,
+               @Param("contextSnapshot") String contextSnapshot,
+               @Param("sourceNodeCode") String sourceNodeCode,
+               @Param("status") String status);
+
+    List<Map<String, Object>> selectByCompany(@Param("companyId") Long companyId,
+                                               @Param("status") String status,
+                                               @Param("offset") int offset,
+                                               @Param("limit") int limit);
+
+    int countByCompany(@Param("companyId") Long companyId,
+                       @Param("status") String status);
+
+    List<Map<String, Object>> selectById(@Param("id") Long id,
+                                          @Param("companyId") Long companyId);
+
+    int auditApprove(@Param("id") Long id,
+                     @Param("companyId") Long companyId,
+                     @Param("auditorId") Long auditorId,
+                     @Param("auditComment") String auditComment);
+
+    int auditReject(@Param("id") Long id,
+                    @Param("companyId") Long companyId,
+                    @Param("auditorId") Long auditorId,
+                    @Param("auditComment") String auditComment);
+
+    int batchApprove(@Param("ids") List<Long> ids,
+                     @Param("companyId") Long companyId,
+                     @Param("auditorId") Long auditorId);
+
+    int batchReject(@Param("ids") List<Long> ids,
+                    @Param("companyId") Long companyId,
+                    @Param("auditorId") Long auditorId);
+
+    int delete(@Param("id") Long id,
+               @Param("companyId") Long companyId);
+
+    int ensureTable();
 }

+ 22 - 0
fs-service/src/main/java/com/fs/company/mapper/LobsterProfileConfigMapper.java

@@ -0,0 +1,22 @@
+package com.fs.company.mapper;
+
+import com.fs.company.domain.LobsterProfileConfig;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 用户画像配置 Mapper
+ */
+public interface LobsterProfileConfigMapper {
+
+    LobsterProfileConfig selectByCompanyId(@Param("companyId") Long companyId);
+
+    List<LobsterProfileConfig> selectAll(@Param("companyId") Long companyId);
+
+    int insert(LobsterProfileConfig config);
+
+    int updateById(LobsterProfileConfig config);
+
+    int deleteById(@Param("id") Long id, @Param("companyId") Long companyId);
+}

+ 31 - 0
fs-service/src/main/java/com/fs/company/mapper/LobsterSalesCorpusMapper.java

@@ -0,0 +1,31 @@
+package com.fs.company.mapper;
+
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 龙虾销售语料分析 Mapper(lobster_sales_corpus 表)
+ */
+public interface LobsterSalesCorpusMapper {
+
+    List<Map<String, Object>> selectByScenario(@Param("companyId") Long companyId,
+                                                @Param("scenario") String scenario,
+                                                @Param("limit") int limit);
+
+    int incrementUsageCount(@Param("entryId") Long entryId);
+
+    int insert(@Param("companyId") Long companyId,
+               @Param("scenario") String scenario,
+               @Param("content") String content,
+               @Param("source") String source,
+               @Param("score") double score);
+
+    List<Map<String, Object>> selectAll(@Param("companyId") Long companyId);
+
+    int updateScore(@Param("entryId") Long entryId,
+                    @Param("score") double score);
+
+    int ensureTable();
+}

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů