Selaa lähdekoodia

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

Long 1 kuukausi sitten
vanhempi
commit
7563a4d037
100 muutettua tiedostoa jossa 7339 lisäystä ja 892 poistoa
  1. 12 3
      .vscode/settings.json
  2. 12 0
      compile.bat
  3. 1 1
      docs/fs-task模块说明.md
  4. 259 0
      fs-admin-saas/src/main/java/com/fs/admin/controller/company/SysRedpacketConfigMoreController.java
  5. 18 0
      fs-admin-saas/src/main/java/com/fs/company/controller/CompanySmsTempController.java
  6. 27 10
      fs-admin-saas/src/main/java/com/fs/company/controller/CompanyVoiceApiController.java
  7. 19 1
      fs-admin-saas/src/main/java/com/fs/live/controller/LiveAfterSalesController.java
  8. 265 38
      fs-admin-saas/src/main/java/com/fs/lobster/controller/LobsterAdminController.java
  9. 18 10
      fs-admin/src/main/java/com/fs/admin/controller/CompanyVoiceApiTenantController.java
  10. 248 0
      fs-admin/src/main/java/com/fs/admin/controller/company/controller/SysRedpacketConfigMoreController.java
  11. 6 6
      fs-admin/src/main/java/com/fs/qw/controller/QwCompanyController.java
  12. 105 3
      fs-admin/src/main/java/com/fs/web/controller/system/CompanySmsApiTenantController.java
  13. 1 95
      fs-company/src/main/java/com/fs/company/controller/bridge/CompanyBridgeController.java
  14. 9 0
      fs-company/src/main/java/com/fs/company/controller/companyWorkflow/CompanyWorkflowLobsterController.java
  15. 18 1
      fs-company/src/main/java/com/fs/company/controller/qw/QwSopTempController.java
  16. 193 0
      fs-company/src/main/java/com/fs/company/controller/workflow/LobsterE2eController.java
  17. 33 0
      fs-company/src/main/java/com/fs/company/controller/workflow/LobsterEngineController.java
  18. 93 0
      fs-company/src/main/java/com/fs/company/controller/workflow/LobsterInboundController.java
  19. 117 0
      fs-company/src/main/java/com/fs/company/controller/workflow/LobsterInstanceMonitorController.java
  20. 214 0
      fs-company/src/main/java/com/fs/company/controller/workflow/LobsterPlatformAdminController.java
  21. 25 8
      fs-service/src/main/java/com/fs/comm/service/CommSmsSendService.java
  22. 11 2
      fs-service/src/main/java/com/fs/common/service/impl/SmsServiceImpl.java
  23. 12 0
      fs-service/src/main/java/com/fs/company/domain/CompanySmsTemp.java
  24. 2 0
      fs-service/src/main/java/com/fs/company/domain/CompanyWorkflowLobsterTask.java
  25. 27 7
      fs-service/src/main/java/com/fs/company/domain/LobsterConversationSummary.java
  26. 64 0
      fs-service/src/main/java/com/fs/company/domain/LobsterE2eRun.java
  27. 64 0
      fs-service/src/main/java/com/fs/company/domain/LobsterE2eRunNode.java
  28. 17 7
      fs-service/src/main/java/com/fs/company/domain/LobsterEvolutionLog.java
  29. 16 5
      fs-service/src/main/java/com/fs/company/domain/LobsterEvolutionSuggestion.java
  30. 6 0
      fs-service/src/main/java/com/fs/company/domain/LobsterNodeExecutionLog.java
  31. 58 0
      fs-service/src/main/java/com/fs/company/domain/LobsterTestScenario.java
  32. 42 0
      fs-service/src/main/java/com/fs/company/domain/tenant/TenantCompanyVoiceApi.java
  33. 2 1
      fs-service/src/main/java/com/fs/company/mapper/LobsterAuxiliaryMapper.java
  34. 28 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterE2eRunMapper.java
  35. 19 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterE2eRunNodeMapper.java
  36. 3 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterEvolutionConfigMapper.java
  37. 3 6
      fs-service/src/main/java/com/fs/company/mapper/LobsterEvolutionSuggestionMapper.java
  38. 23 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterTestScenarioMapper.java
  39. 22 0
      fs-service/src/main/java/com/fs/company/mapper/tenant/TenantCompanyVoiceApiMapper.java
  40. 5 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanySmsTempServiceImpl.java
  41. 39 3
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceApiServiceImpl.java
  42. 0 218
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceApiTenantServiceImpl.java
  43. 15 0
      fs-service/src/main/java/com/fs/company/service/tenant/ITenantCompanyVoiceApiQueryService.java
  44. 29 0
      fs-service/src/main/java/com/fs/company/service/tenant/ITenantCompanyVoiceApiSyncService.java
  45. 16 0
      fs-service/src/main/java/com/fs/company/service/tenant/ITenantMasterSmsApiQueryService.java
  46. 26 0
      fs-service/src/main/java/com/fs/company/service/tenant/impl/TenantCompanyVoiceApiQueryServiceImpl.java
  47. 222 0
      fs-service/src/main/java/com/fs/company/service/tenant/impl/TenantCompanyVoiceApiSyncServiceImpl.java
  48. 35 0
      fs-service/src/main/java/com/fs/company/service/tenant/impl/TenantMasterSmsApiQueryServiceImpl.java
  49. 153 0
      fs-service/src/main/java/com/fs/company/service/workflow/capability/LobsterNodeCapabilityRegistry.java
  50. 13 8
      fs-service/src/main/java/com/fs/company/service/workflow/channel/impl/DouyinDmMessageChannel.java
  51. 14 11
      fs-service/src/main/java/com/fs/company/service/workflow/channel/impl/TmallMessageChannel.java
  52. 36 0
      fs-service/src/main/java/com/fs/company/service/workflow/evolution/impl/EvolutionEngineImpl.java
  53. 4 0
      fs-service/src/main/java/com/fs/company/service/workflow/evolution/impl/EvolutionSchedulerImpl.java
  54. 229 14
      fs-service/src/main/java/com/fs/company/service/workflow/impl/DynamicNodeAdjusterImpl.java
  55. 773 29
      fs-service/src/main/java/com/fs/company/service/workflow/impl/DynamicNodeExecutorImpl.java
  56. 556 19
      fs-service/src/main/java/com/fs/company/service/workflow/impl/LobsterE2eTestServiceImpl.java
  57. 1 1
      fs-service/src/main/java/com/fs/company/service/workflow/impl/LobsterEvolutionEngineImpl.java
  58. 129 28
      fs-service/src/main/java/com/fs/company/service/workflow/impl/LobsterTestScenarioServiceImpl.java
  59. 254 11
      fs-service/src/main/java/com/fs/company/service/workflow/impl/LobsterWorkflowExecutorImpl.java
  60. 308 24
      fs-service/src/main/java/com/fs/company/service/workflow/impl/ToolCallFrameworkImpl.java
  61. 143 0
      fs-service/src/main/java/com/fs/company/service/workflow/inbound/LobsterInboundService.java
  62. 146 12
      fs-service/src/main/java/com/fs/company/service/workflow/learning/impl/TenantLearningEngineImpl.java
  63. 22 3
      fs-service/src/main/java/com/fs/company/service/workflow/pay/PayService.java
  64. 314 207
      fs-service/src/main/java/com/fs/company/service/workflow/scheduler/WorkflowTriggerScheduler.java
  65. 191 9
      fs-service/src/main/java/com/fs/company/service/workflow/vector/impl/VectorPatternMatcherImpl.java
  66. 27 30
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  67. 82 0
      fs-service/src/main/java/com/fs/his/domain/SysRedpacketConfigMore.java
  68. 63 0
      fs-service/src/main/java/com/fs/his/mapper/SysRedpacketConfigMoreMapper.java
  69. 9 0
      fs-service/src/main/java/com/fs/his/param/UpdateChangeMchIdParam.java
  70. 70 0
      fs-service/src/main/java/com/fs/his/service/ISysRedpacketConfigMoreService.java
  71. 12 2
      fs-service/src/main/java/com/fs/his/service/impl/FsStorePaymentServiceImpl.java
  72. 220 0
      fs-service/src/main/java/com/fs/his/service/impl/SysRedpacketConfigMoreServiceImpl.java
  73. 44 10
      fs-service/src/main/java/com/fs/his/utils/PhoneUtil.java
  74. 1 1
      fs-service/src/main/java/com/fs/proxy/domain/ServiceFeeConfig.java
  75. 28 0
      fs-service/src/main/java/com/fs/proxy/domain/SmsApiTenantBatchAddResult.java
  76. 7 0
      fs-service/src/main/java/com/fs/proxy/mapper/CompanySmsApiTenantMapper.java
  77. 7 0
      fs-service/src/main/java/com/fs/proxy/service/ICompanySmsApiTenantService.java
  78. 63 0
      fs-service/src/main/java/com/fs/proxy/service/impl/CompanySmsApiTenantServiceImpl.java
  79. 258 0
      fs-service/src/main/java/com/fs/wx/mp/service/TenantCourseWxMpServiceProvider.java
  80. 87 6
      fs-service/src/main/resources/db/tenant-initTable.sql
  81. 5 1
      fs-service/src/main/resources/mapper/company/CompanySmsTempMapper.xml
  82. 7 5
      fs-service/src/main/resources/mapper/company/CompanyWorkflowLobsterMapper.xml
  83. 9 5
      fs-service/src/main/resources/mapper/company/CompanyWorkflowLobsterTaskMapper.xml
  84. 9 0
      fs-service/src/main/resources/mapper/company/LobsterConversationSummaryMapper.xml
  85. 9 6
      fs-service/src/main/resources/mapper/company/LobsterNodeExecutionLogMapper.xml
  86. 4 1
      fs-service/src/main/resources/mapper/company/LobsterSystemPromptMapper.xml
  87. 8 3
      fs-service/src/main/resources/mapper/company/LobsterWorkflowInstanceMapper.xml
  88. 74 0
      fs-service/src/main/resources/mapper/company/tenant/TenantCompanyVoiceApiMapper.xml
  89. 126 0
      fs-service/src/main/resources/mapper/his/SysRedpacketConfigMoreMapper.xml
  90. 8 4
      fs-service/src/main/resources/mapper/lobster/LobsterAuxiliaryMapper.xml
  91. 70 0
      fs-service/src/main/resources/mapper/lobster/LobsterE2eRunMapper.xml
  92. 59 0
      fs-service/src/main/resources/mapper/lobster/LobsterE2eRunNodeMapper.xml
  93. 13 6
      fs-service/src/main/resources/mapper/lobster/LobsterEvolutionConfigMapper.xml
  94. 54 0
      fs-service/src/main/resources/mapper/lobster/LobsterEvolutionSuggestionMapper.xml
  95. 3 2
      fs-service/src/main/resources/mapper/lobster/LobsterMultiTurnDialogueMapper.xml
  96. 64 0
      fs-service/src/main/resources/mapper/lobster/LobsterTestScenarioMapper.xml
  97. 29 0
      fs-service/src/main/resources/mapper/proxy/CompanySmsApiTenantMapper.xml
  98. 10 1
      fs-service/src/main/resources/schema/lobster_node_type.sql
  99. 4 8
      fs-task/src/main/java/com/fs/admin/sync/LobsterBridgeDataSyncService.java
  100. 11 0
      fs-task/src/main/java/com/fs/task/FsCompanyTask.java

+ 12 - 3
.vscode/settings.json

@@ -1,7 +1,16 @@
 {
-  // 使用当前用户的 Maven settings.xml(其中已配置 localRepository = D:\\Tool\\repository)
   "java.configuration.maven.userSettings": "C:\\Users\\Administrator\\.m2\\settings.xml",
-  // Maven 可执行文件路径,便于 IDE 与终端使用
   "maven.executable.path": "D:\\Tool\\apache-maven-3.6.3\\bin\\mvn.cmd",
-  "java.compile.nullAnalysis.mode": "automatic"
+  "java.compile.nullAnalysis.mode": "automatic",
+  "java.jdt.ls.java.home": "D:\\AICALL\\jdk-17.0.12+7",
+  "java.configuration.runtimes": [
+    {
+      "name": "JavaSE-17",
+      "path": "D:\\AICALL\\jdk-17.0.12+7",
+      "default": true
+    }
+  ],
+  "terminal.integrated.env.windows": {
+    "JAVA_HOME": "D:\\AICALL\\jdk-17.0.12+7"
+  }
 }

+ 12 - 0
compile.bat

@@ -0,0 +1,12 @@
+@echo off
+chcp 65001 >nul
+call "%~dp0set-java17.bat"
+cd /d "%~dp0"
+echo.
+echo [compile] fs-service, fs-company, fs-admin-saas ...
+call mvn compile -pl fs-service,fs-company,fs-admin-saas -am -DskipTests -q
+if errorlevel 1 (
+  echo [compile] FAILED
+  exit /b 1
+)
+echo [compile] SUCCESS

+ 1 - 1
docs/fs-task模块说明.md

@@ -1,4 +1,4 @@
-# fs-task 定时任务模块说明
+# fs-task 定时任务模块说明
 
 ## 架构
 

+ 259 - 0
fs-admin-saas/src/main/java/com/fs/admin/controller/company/SysRedpacketConfigMoreController.java

@@ -0,0 +1,259 @@
+package com.fs.admin.controller.company;
+
+
+import com.fs.common.BeanCopyUtils;
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.framework.datasource.TenantDataSourceContextHelper;
+import com.fs.his.domain.SysRedpacketConfigMore;
+import com.fs.his.param.UpdateChangeMchIdParam;
+import com.fs.his.service.ISysRedpacketConfigMoreService;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.service.TenantInfoService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@Slf4j
+
+/**
+ * 多商户配置Controller
+ *
+ * @author fs
+ * @date 2025-11-27
+ */
+@RestController
+@RequestMapping("/redPacket/more")
+public class SysRedpacketConfigMoreController extends BaseController
+{
+    @Autowired
+    private ISysRedpacketConfigMoreService sysRedpacketConfigMoreService;
+
+    @Autowired
+    private TenantInfoService tenantInfoService;
+
+    @Autowired
+    private TenantDataSourceContextHelper tenantContextHelper;
+
+    /**
+     * 查询多商户配置列表
+     */
+    @PreAuthorize("@ss.hasPermi('redPacket:more:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(SysRedpacketConfigMore sysRedpacketConfigMore)
+    {
+        startPage();
+        List<SysRedpacketConfigMore> list = sysRedpacketConfigMoreService.selectSysRedpacketConfigMoreList(sysRedpacketConfigMore);
+        return getDataTable(list);
+    }
+
+
+    /**
+     * 获取多商户配置详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('redPacket:more:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(sysRedpacketConfigMoreService.selectSysRedpacketConfigMoreById(id));
+    }
+
+    /**
+     * 获取当前发红包的商户号
+     */
+    @PreAuthorize("@ss.hasPermi('redPacket:more:editConfig')")
+    @GetMapping(value = "/getRedPacketConfig")
+    public AjaxResult getRedPacketConFig()
+    {
+        return AjaxResult.success(sysRedpacketConfigMoreService.getRedPacketConFig());
+    }
+
+
+
+
+    /**
+     * 新增多商户配置
+     */
+    @PreAuthorize("@ss.hasPermi('redPacket:more:add')")
+    @Log(title = "多商户配置", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody SysRedpacketConfigMore sysRedpacketConfigMore)
+    {
+        int result = sysRedpacketConfigMoreService.insertSysRedpacketConfigMore(sysRedpacketConfigMore);
+
+        // 如果指定了租户,同步到租户库
+        if (result > 0 && sysRedpacketConfigMore.getTenantId() != null) {
+            try {
+                syncToTenant(sysRedpacketConfigMore, sysRedpacketConfigMore.getTenantId());
+            } catch (Exception e) {
+                log.error("同步商户配置到租户库失败, tenantId={}, mchId={}",
+                    sysRedpacketConfigMore.getTenantId(), sysRedpacketConfigMore.getMchId(), e);
+                // 不影响主流程,记录日志即可
+            }
+        }
+
+        return toAjax(result);
+    }
+
+    /**
+     * 修改发商户号的商户
+     */
+    @Log(title = "修改发商户号的商户", businessType = BusinessType.UPDATE)
+    @PostMapping("/updateChangeMchId")
+    public R updateChangeMchId(@RequestBody UpdateChangeMchIdParam param)
+    {
+        return sysRedpacketConfigMoreService.updateChangeMchId(param);
+    }
+
+    /**
+     * 清空当前红包商户号配置
+     */
+    @PreAuthorize("@ss.hasPermi('redPacket:more:editConfig')")
+    @Log(title = "清空红包商户号配置", businessType = BusinessType.UPDATE)
+    @PostMapping("/clearRedPacketMchId")
+    public R clearRedPacketMchId()
+    {
+        return sysRedpacketConfigMoreService.clearRedPacketMchId();
+    }
+
+    /**
+     * 修改多商户配置
+     */
+    @PreAuthorize("@ss.hasPermi('redPacket:more:edit')")
+    @Log(title = "多商户配置", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody SysRedpacketConfigMore sysRedpacketConfigMore)
+    {
+        // 查询原数据,获取原租户ID
+        SysRedpacketConfigMore oldConfig = sysRedpacketConfigMoreService.selectSysRedpacketConfigMoreById(sysRedpacketConfigMore.getId());
+        Long oldTenantId = oldConfig != null ? oldConfig.getTenantId() : null;
+        Long newTenantId = sysRedpacketConfigMore.getTenantId();
+        String mchId = sysRedpacketConfigMore.getMchId();
+
+        int result = sysRedpacketConfigMoreService.updateSysRedpacketConfigMore(sysRedpacketConfigMore);
+
+        if (result > 0) {
+            try {
+                // 如果原租户存在且与新租户不同,从原租户库删除
+                if (oldTenantId != null && !oldTenantId.equals(newTenantId)) {
+                    deleteFromTenant(mchId, oldTenantId);
+                }
+                // 如果指定了新租户,同步到新租户库
+                if (newTenantId != null) {
+                    syncToTenant(sysRedpacketConfigMore, newTenantId);
+                }
+            } catch (Exception e) {
+                log.error("同步商户配置到租户库失败, mchId={}, oldTenantId={}, newTenantId={}",
+                    mchId, oldTenantId, newTenantId, e);
+            }
+        }
+
+        return toAjax(result);
+    }
+
+    /**
+     * 删除多商户配置
+     */
+    @PreAuthorize("@ss.hasPermi('redPacket:more:remove')")
+    @Log(title = "多商户配置", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        // 先查询要删除的数据,获取租户信息
+        for (Long id : ids) {
+            SysRedpacketConfigMore config = sysRedpacketConfigMoreService.selectSysRedpacketConfigMoreById(id);
+            if (config != null && config.getTenantId() != null) {
+                try {
+                    deleteFromTenant(config.getMchId(), config.getTenantId());
+                } catch (Exception e) {
+                    log.error("从租户库删除商户配置失败, tenantId={}, mchId={}",
+                        config.getTenantId(), config.getMchId(), e);
+                }
+            }
+        }
+
+        return toAjax(sysRedpacketConfigMoreService.deleteSysRedpacketConfigMoreByIds(ids));
+    }
+
+    /**
+     * 获取租户列表(用于下拉选择)
+     */
+    @PreAuthorize("@ss.hasPermi('redPacket:more:list')")
+    @GetMapping("/tenantList")
+    public AjaxResult tenantList()
+    {
+        TenantInfo query = new TenantInfo();
+        query.setStatus(1); // 只查询启用的租户
+        List<TenantInfo> list = tenantInfoService.selectTenantInfoList(query);
+        return AjaxResult.success(list);
+    }
+
+    /**
+     * 同步商户配置到租户库
+     */
+    private void syncToTenant(SysRedpacketConfigMore config, Long tenantId) {
+        if (tenantId == null) {
+            return;
+        }
+        tenantContextHelper.runInTenant(tenantId, () -> {
+            try {
+                // 查询租户库是否已存在该商户号的配置
+                SysRedpacketConfigMore existConfig = sysRedpacketConfigMoreService.getOne(
+                    new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<SysRedpacketConfigMore>()
+                        .eq("mch_id", config.getMchId())
+                );
+
+                // 复制一份数据用于租户库
+                SysRedpacketConfigMore tenantConfig = new SysRedpacketConfigMore();
+                BeanCopyUtils.copy(config, tenantConfig);
+
+                if (existConfig != null) {
+                    // 更新现有记录
+                    tenantConfig.setId(existConfig.getId());
+                    sysRedpacketConfigMoreService.updateSysRedpacketConfigMore(tenantConfig);
+                    log.info("同步商户配置到租户库-更新, tenantId={}, mchId={}", tenantId, config.getMchId());
+                } else {
+                    // 插入新记录,清空ID让数据库自增
+                    tenantConfig.setId(null);
+                    sysRedpacketConfigMoreService.insertSysRedpacketConfigMore(tenantConfig);
+                    log.info("同步商户配置到租户库-新增, tenantId={}, mchId={}", tenantId, config.getMchId());
+                }
+            } catch (Exception e) {
+                log.error("同步商户配置到租户库异常, tenantId={}, mchId={}", tenantId, config.getMchId(), e);
+                throw new RuntimeException("同步到租户库失败: " + e.getMessage(), e);
+            }
+        });
+    }
+
+    /**
+     * 从租户库删除商户配置
+     */
+    private void deleteFromTenant(String mchId, Long tenantId) {
+        if (tenantId == null || mchId == null) {
+            return;
+        }
+        tenantContextHelper.runInTenant(tenantId, () -> {
+            try {
+                // 查询租户库中的记录
+                SysRedpacketConfigMore config = sysRedpacketConfigMoreService.getOne(
+                    new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<SysRedpacketConfigMore>()
+                        .eq("mch_id", mchId)
+                );
+                if (config != null) {
+                    sysRedpacketConfigMoreService.deleteSysRedpacketConfigMoreById(config.getId());
+                    log.info("从租户库删除商户配置, tenantId={}, mchId={}", tenantId, mchId);
+                }
+            } catch (Exception e) {
+                log.error("从租户库删除商户配置异常, tenantId={}, mchId={}", tenantId, mchId, e);
+                throw new RuntimeException("从租户库删除失败: " + e.getMessage(), e);
+            }
+        });
+    }
+}

+ 18 - 0
fs-admin-saas/src/main/java/com/fs/company/controller/CompanySmsTempController.java

@@ -7,8 +7,10 @@ import com.fs.common.core.domain.R;
 import com.fs.common.core.page.TableDataInfo;
 import com.fs.common.enums.BusinessType;
 import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.common.utils.SecurityUtils;
 import com.fs.company.domain.CompanySmsTemp;
 import com.fs.company.service.ICompanySmsTempService;
+import com.fs.company.service.tenant.ITenantMasterSmsApiQueryService;
 import com.fs.company.vo.CompanySmsTempListVO;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
@@ -29,6 +31,22 @@ public class CompanySmsTempController extends BaseController
     @Autowired
     private ICompanySmsTempService companySmsTempService;
 
+    @Autowired
+    private ITenantMasterSmsApiQueryService tenantMasterSmsApiQueryService;
+
+    /**
+     * 查询当前租户已绑定的短信接口(主库 company_sms_api_tenant,供模板绑定下拉)
+     */
+    @PreAuthorize("@ss.hasPermi('company:companySmsTemp:list')")
+    @GetMapping("/smsApiOptions")
+    public AjaxResult smsApiOptions() {
+        Long tenantId = SecurityUtils.getTenantId();
+        if (tenantId == null) {
+            return AjaxResult.error("无法获取当前租户信息");
+        }
+        return AjaxResult.success(tenantMasterSmsApiQueryService.selectBoundSmsApisByTenantId(tenantId));
+    }
+
     /**
      * 查询短信模板列表
      */

+ 27 - 10
fs-admin-saas/src/main/java/com/fs/company/controller/CompanyVoiceApiController.java

@@ -8,7 +8,9 @@ import com.fs.common.core.page.TableDataInfo;
 import com.fs.common.enums.BusinessType;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.company.domain.CompanyVoiceApi;
+import com.fs.company.domain.tenant.TenantCompanyVoiceApi;
 import com.fs.company.service.ICompanyVoiceApiService;
+import com.fs.company.service.tenant.ITenantCompanyVoiceApiQueryService;
 import com.fs.voice.service.IVoiceService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
@@ -17,7 +19,7 @@ import org.springframework.web.bind.annotation.*;
 import java.util.List;
 
 /**
- * 呼叫接口Controller
+ * 呼叫接口Controller(租户总后台:读租户库 company_voice_api)
  *
  * @author fs
  * @date 2021-10-04
@@ -28,16 +30,19 @@ public class CompanyVoiceApiController extends BaseController {
     @Autowired
     private ICompanyVoiceApiService companyVoiceApiService;
     @Autowired
+    private ITenantCompanyVoiceApiQueryService tenantCompanyVoiceApiQueryService;
+    @Autowired
     private IVoiceService voiceService;
 
     /**
-     * 查询呼叫接口列表
+     * 查询呼叫接口列表(租户库,含售价/优先级等同步字段)
      */
     @PreAuthorize("@ss.hasPermi('company:companyVoiceApi:list')")
     @GetMapping("/list")
     public TableDataInfo list(CompanyVoiceApi companyVoiceApi) {
         startPage();
-        List<CompanyVoiceApi> list = companyVoiceApiService.selectCompanyVoiceApiList(companyVoiceApi);
+        List<TenantCompanyVoiceApi> list = tenantCompanyVoiceApiQueryService.selectTenantCompanyVoiceApiList(
+                toTenantQuery(companyVoiceApi));
         return getDataTable(list);
     }
 
@@ -48,8 +53,9 @@ public class CompanyVoiceApiController extends BaseController {
     @Log(title = "呼叫接口", businessType = BusinessType.EXPORT)
     @GetMapping("/export")
     public AjaxResult export(CompanyVoiceApi companyVoiceApi) {
-        List<CompanyVoiceApi> list = companyVoiceApiService.selectCompanyVoiceApiList(companyVoiceApi);
-        ExcelUtil<CompanyVoiceApi> util = new ExcelUtil<CompanyVoiceApi>(CompanyVoiceApi.class);
+        List<TenantCompanyVoiceApi> list = tenantCompanyVoiceApiQueryService.selectTenantCompanyVoiceApiList(
+                toTenantQuery(companyVoiceApi));
+        ExcelUtil<TenantCompanyVoiceApi> util = new ExcelUtil<>(TenantCompanyVoiceApi.class);
         return util.exportExcel(list, "companyVoiceApi");
     }
 
@@ -59,7 +65,7 @@ public class CompanyVoiceApiController extends BaseController {
     @PreAuthorize("@ss.hasPermi('company:companyVoiceApi:query')")
     @GetMapping(value = "/{apiId}")
     public AjaxResult getInfo(@PathVariable("apiId") Long apiId) {
-        return AjaxResult.success(companyVoiceApiService.selectCompanyVoiceApiById(apiId));
+        return AjaxResult.success(tenantCompanyVoiceApiQueryService.selectTenantCompanyVoiceApiById(apiId));
     }
 
     /**
@@ -94,11 +100,22 @@ public class CompanyVoiceApiController extends BaseController {
 
     @GetMapping("/getVoiceApiList")
     public R getVoiceApiList() {
-        CompanyVoiceApi map = new CompanyVoiceApi();
-        map.setStatus(1);
-        List<CompanyVoiceApi> list = companyVoiceApiService.selectCompanyVoiceApiList(map);
+        TenantCompanyVoiceApi query = new TenantCompanyVoiceApi();
+        query.setStatus(1);
+        List<TenantCompanyVoiceApi> list = tenantCompanyVoiceApiQueryService.selectTenantCompanyVoiceApiList(query);
         return R.ok().put("data", list);
     }
 
-
+    private TenantCompanyVoiceApi toTenantQuery(CompanyVoiceApi companyVoiceApi) {
+        TenantCompanyVoiceApi query = new TenantCompanyVoiceApi();
+        if (companyVoiceApi == null) {
+            return query;
+        }
+        query.setApiName(companyVoiceApi.getApiName());
+        if (companyVoiceApi.getApiType() != null) {
+            query.setApiType(String.valueOf(companyVoiceApi.getApiType()));
+        }
+        query.setStatus(companyVoiceApi.getStatus());
+        return query;
+    }
 }

+ 19 - 1
fs-admin-saas/src/main/java/com/fs/live/controller/LiveAfterSalesController.java

@@ -12,6 +12,8 @@ import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.framework.web.service.TokenService;
 import com.fs.system.service.ISysConfigService;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.service.TenantInfoService;
 import com.fs.his.domain.FsStoreAfterSalesLogs;
 import com.fs.his.domain.FsUser;
 import com.fs.his.enums.FsStoreAfterSalesStatusEnum;
@@ -67,6 +69,9 @@ public class LiveAfterSalesController extends BaseController
     @Autowired
     private ISysConfigService sysConfigService;
 
+    @Autowired
+    private TenantInfoService tenantInfoService;
+
     /**
      * 获取售后记录详细信息
      */
@@ -104,6 +109,19 @@ public class LiveAfterSalesController extends BaseController
         return getDataTable(list);
     }
 
+    /**
+     * 获取当前租户的公司名称
+     * @return 租户名称,如果未获取到则返回null
+     */
+    private String getCurrentTenantCompanyName() {
+        Long tenantId = com.fs.wxcid.utils.TenantHelper.getTenantId();
+        if (tenantId == null) {
+            return null;
+        }
+        TenantInfo tenantInfo = tenantInfoService.getById(tenantId);
+        return tenantInfo != null ? tenantInfo.getTenantName() : null;
+    }
+
     /**
      * 导出售后记录列表
      */
@@ -115,7 +133,7 @@ public class LiveAfterSalesController extends BaseController
         PageHelper.clearPage();
         PageHelper.startPage(1, 10000, "");
         List<LiveAfterSalesVo> list = liveAfterSalesService.selectLiveAfterSalesVoListExport(liveAfterSales);
-        if("北京卓美".equals(sysConfigService.getProjectConfig().getCloudHost().getCompanyName())){
+        if("北京卓美".equals(getCurrentTenantCompanyName())){
             List<FsStoreOrderItemExportRefundZMVO> zmvoList = list.stream()
                     .map(vo -> {
                         FsStoreOrderItemExportRefundZMVO zmvo = new FsStoreOrderItemExportRefundZMVO();

+ 265 - 38
fs-admin-saas/src/main/java/com/fs/lobster/controller/LobsterAdminController.java

@@ -19,8 +19,8 @@ import java.time.LocalDateTime;
 import java.util.*;
 
 /**
- * 龙虾引擎管理端Controller(fs-admin-saas,替代原 AdminLobsterBridgeController
- * 全部使用 MyBatis Service,无 JdbcTemplate,无桥接镜像表
+ * 龙虾引擎管理端 Controller(fs-admin-saas)
+ * 直连 MyBatis Service / JdbcTemplate 租户库,无桥接镜像表
  */
 @RestController
 public class LobsterAdminController extends BaseController {
@@ -64,6 +64,15 @@ public class LobsterAdminController extends BaseController {
     @Autowired(required = false)
     private com.fs.company.mapper.LobsterChatMsgMapper chatMsgMapper;
 
+    @Autowired(required = false)
+    private com.fs.company.service.workflow.evolution.EvolutionEngine evolutionEngine;
+
+    @Autowired(required = false)
+    private com.fs.company.service.workflow.heartbeat.HeartbeatScheduler heartbeatScheduler;
+
+    @Autowired(required = false)
+    private com.fs.company.service.workflow.channel.MessageChannelRouter messageChannelRouter;
+
     @Autowired
     private TokenService tokenService;
 
@@ -185,10 +194,40 @@ public class LobsterAdminController extends BaseController {
     // ======== 以下为占位端点(无 MyBatis Service 实现的,返回空数据,前端不报 404) ========
 
     @GetMapping({"/workflow/lobster/generate", "/workflow/lobster/generate/list"})
-    public AjaxResult lobsterGenerate() { return AjaxResult.success(new ArrayList<>()); }
+    public AjaxResult lobsterGenerate(@RequestParam(required = false) Long companyId) {
+        if (jdbcTemplate == null) return AjaxResult.success(new ArrayList<>());
+        try {
+            String sql = "SELECT id, company_id, workflow_id, node_code, suggestion_type, reason, confidence, status, create_time " +
+                    "FROM lobster_evolution_suggestion WHERE 1=1";
+            List<Object> params = new ArrayList<>();
+            if (companyId != null) {
+                sql += " AND company_id=?";
+                params.add(companyId);
+            }
+            sql += " ORDER BY create_time DESC LIMIT 100";
+            return AjaxResult.success(jdbcTemplate.queryForList(sql, params.toArray()));
+        } catch (Exception e) {
+            return AjaxResult.success(new ArrayList<>());
+        }
+    }
 
     @GetMapping({"/workflow/lobster/canvas", "/workflow/lobster/canvas/list"})
-    public AjaxResult lobsterCanvas() { return AjaxResult.success(new ArrayList<>()); }
+    public AjaxResult lobsterCanvas(@RequestParam(required = false) Long companyId) {
+        if (jdbcTemplate == null) return AjaxResult.success(new ArrayList<>());
+        try {
+            String sql = "SELECT id, company_id, template_code, template_name, industry_type, status, version, " +
+                    "canvas_data, update_time, create_time FROM company_workflow_lobster WHERE del_flag=0";
+            List<Object> params = new ArrayList<>();
+            if (companyId != null) {
+                sql += " AND company_id=?";
+                params.add(companyId);
+            }
+            sql += " ORDER BY update_time DESC LIMIT 200";
+            return AjaxResult.success(jdbcTemplate.queryForList(sql, params.toArray()));
+        } catch (Exception e) {
+            return AjaxResult.success(new ArrayList<>());
+        }
+    }
 
     @GetMapping({"/workflow/lobster/template", "/workflow/lobster/template/list"})
     public AjaxResult lobsterTemplate() {
@@ -266,39 +305,94 @@ public class LobsterAdminController extends BaseController {
     }
 
     @GetMapping({"/workflow/lobster/instance", "/workflow/lobster/instance/list"})
-    public AjaxResult lobsterInstance() { return AjaxResult.success(new ArrayList<>()); }
+    public AjaxResult lobsterInstance(@RequestParam(required = false) Long companyId,
+                                       @RequestParam(required = false) String status) {
+        return lobsterExecInstanceList(companyId, null, status);
+    }
 
     @GetMapping("/workflow/lobster/instance/stats")
-    public AjaxResult lobsterInstanceStats() {
+    public AjaxResult lobsterInstanceStats(@RequestParam(required = false) Long companyId) {
         Map<String, Object> stats = new HashMap<>();
-        stats.put("running", 0); stats.put("paused", 0);
-        stats.put("deadLetters", 0); stats.put("todayTokens", "0");
+        if (jdbcTemplate != null) {
+            try {
+                String base = " FROM lobster_workflow_instance WHERE del_flag=0";
+                List<Object> params = new ArrayList<>();
+                if (companyId != null) { base += " AND company_id=?"; params.add(companyId); }
+                stats.put("running", jdbcTemplate.queryForObject(
+                        "SELECT COUNT(*)" + base + " AND status='running'", params.toArray(), Integer.class));
+                stats.put("paused", jdbcTemplate.queryForObject(
+                        "SELECT COUNT(*)" + base + " AND status='paused'", params.toArray(), Integer.class));
+                stats.put("completed", jdbcTemplate.queryForObject(
+                        "SELECT COUNT(*)" + base + " AND status='completed'", params.toArray(), Integer.class));
+                stats.put("deadLetters", jdbcTemplate.queryForObject(
+                        "SELECT COUNT(*) FROM lobster_dead_letter_queue WHERE status='pending'"
+                                + (companyId != null ? " AND company_id=?" : ""),
+                        companyId != null ? new Object[]{companyId} : new Object[]{}, Integer.class));
+                Object tokens = jdbcTemplate.queryForObject(
+                        "SELECT COALESCE(SUM(token_count),0) FROM lobster_token_consume_log WHERE DATE(create_time)=CURDATE()"
+                                + (companyId != null ? " AND company_id=?" : ""),
+                        companyId != null ? new Object[]{companyId} : new Object[]{}, Object.class);
+                stats.put("todayTokens", tokens != null ? tokens.toString() : "0");
+            } catch (Exception e) {
+                stats.put("running", 0); stats.put("paused", 0);
+                stats.put("deadLetters", 0); stats.put("todayTokens", "0");
+            }
+        } else {
+            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);
+    public AjaxResult lobsterInstanceDetail(@PathVariable Long instanceId,
+                                             @RequestParam(required = false) Long companyId) {
+        return lobsterExecInstanceGet(instanceId, companyId);
     }
 
     @GetMapping("/workflow/lobster/instance/node-logs/{instanceId}")
-    public AjaxResult lobsterInstanceNodeLogs(@PathVariable String instanceId) {
-        return AjaxResult.success(new ArrayList<>());
+    public AjaxResult lobsterInstanceNodeLogs(@PathVariable Long instanceId,
+                                               @RequestParam(required = false) Long companyId) {
+        return lobsterExecNodeLogs(instanceId, companyId);
     }
 
     @PostMapping("/workflow/lobster/instance/terminate/{instanceId}")
-    public AjaxResult lobsterInstanceTerminate(@PathVariable String instanceId) {
-        return AjaxResult.success("操作成功");
+    public AjaxResult lobsterInstanceTerminate(@PathVariable Long instanceId,
+                                                @RequestParam(defaultValue = "管理员手动终止") String reason) {
+        return lobsterExecTerminate(String.valueOf(instanceId), reason);
     }
 
     @GetMapping({"/workflow/lobster/optimization", "/workflow/lobster/optimization/list"})
-    public AjaxResult lobsterOptimization() { return AjaxResult.success(new ArrayList<>()); }
+    public AjaxResult lobsterOptimization(@RequestParam(required = false) Long companyId) {
+        if (jdbcTemplate == null) return AjaxResult.success(new ArrayList<>());
+        try {
+            if (companyId != null) {
+                return AjaxResult.success(jdbcTemplate.queryForList(
+                        "SELECT * FROM lobster_evolution_suggestion WHERE company_id=? ORDER BY create_time DESC LIMIT 200",
+                        companyId));
+            }
+            return AjaxResult.success(jdbcTemplate.queryForList(
+                    "SELECT * FROM lobster_evolution_suggestion ORDER BY create_time DESC LIMIT 200"));
+        } catch (Exception e) {
+            return AjaxResult.success(new ArrayList<>());
+        }
+    }
 
     @GetMapping("/workflow/lobster/optimization/pending-audit")
-    public AjaxResult lobsterOptimizationPendingAudit() { return AjaxResult.success(new ArrayList<>()); }
+    public AjaxResult lobsterOptimizationPendingAudit(@RequestParam(required = false) Long companyId) {
+        if (jdbcTemplate == null) return AjaxResult.success(new ArrayList<>());
+        try {
+            if (companyId != null) {
+                return AjaxResult.success(jdbcTemplate.queryForList(
+                        "SELECT * FROM lobster_evolution_suggestion WHERE company_id=? AND status=0 ORDER BY create_time DESC LIMIT 100",
+                        companyId));
+            }
+            return AjaxResult.success(jdbcTemplate.queryForList(
+                    "SELECT * FROM lobster_evolution_suggestion WHERE status=0 ORDER BY create_time DESC LIMIT 100"));
+        } catch (Exception e) {
+            return AjaxResult.success(new ArrayList<>());
+        }
+    }
 
     @PostMapping("/workflow/lobster/optimization/batch-audit")
     public AjaxResult lobsterOptimizationBatchAudit() { return AjaxResult.success("审核完成"); }
@@ -309,17 +403,43 @@ public class LobsterAdminController extends BaseController {
     }
 
     @PostMapping("/workflow/lobster/optimization/analyze")
-    public AjaxResult lobsterOptimizationAnalyze() {
+    public AjaxResult lobsterOptimizationAnalyze(@RequestParam(required = false) Long companyId,
+                                                  @RequestParam(required = false) Long workflowId) {
+        if (evolutionEngine != null && companyId != null && workflowId != null) {
+            return AjaxResult.success(evolutionEngine.analyzeAndSuggest(companyId, workflowId));
+        }
         Map<String, Object> result = new HashMap<>();
-        result.put("totalSuggestions", 0);
+        if (jdbcTemplate != null && companyId != null) {
+            try {
+                Integer total = jdbcTemplate.queryForObject(
+                        "SELECT COUNT(*) FROM lobster_evolution_suggestion WHERE company_id=?", Integer.class, companyId);
+                result.put("totalSuggestions", total != null ? total : 0);
+            } catch (Exception e) {
+                result.put("totalSuggestions", 0);
+            }
+        } else {
+            result.put("totalSuggestions", 0);
+        }
         return AjaxResult.success(result);
     }
 
     @GetMapping("/workflow/lobster/optimization/stats")
-    public AjaxResult lobsterOptimizationStats() {
+    public AjaxResult lobsterOptimizationStats(@RequestParam(required = false) Long companyId) {
         Map<String, Object> stats = new HashMap<>();
         stats.put("total", 0); stats.put("pending", 0);
         stats.put("approved", 0); stats.put("rejected", 0);
+        if (jdbcTemplate != null && companyId != null) {
+            try {
+                stats.put("total", jdbcTemplate.queryForObject(
+                        "SELECT COUNT(*) FROM lobster_evolution_suggestion WHERE company_id=?", Integer.class, companyId));
+                stats.put("pending", jdbcTemplate.queryForObject(
+                        "SELECT COUNT(*) FROM lobster_evolution_suggestion WHERE company_id=? AND status=0", Integer.class, companyId));
+                stats.put("approved", jdbcTemplate.queryForObject(
+                        "SELECT COUNT(*) FROM lobster_evolution_suggestion WHERE company_id=? AND status=1", Integer.class, companyId));
+                stats.put("rejected", jdbcTemplate.queryForObject(
+                        "SELECT COUNT(*) FROM lobster_evolution_suggestion WHERE company_id=? AND status=2", Integer.class, companyId));
+            } catch (Exception ignored) { }
+        }
         return AjaxResult.success(stats);
     }
 
@@ -459,12 +579,49 @@ public class LobsterAdminController extends BaseController {
         return m;
     }
 
-    // ======== lobster-exec 占位端点 ========
+    // ======== lobster-exec(管理端实例监控,JDBC 直查 + 执行器) ========
+    @Autowired(required = false)
+    private com.fs.company.mapper.LobsterWorkflowInstanceMapper workflowInstanceMapper;
+
+    @Autowired(required = false)
+    private com.fs.company.mapper.LobsterNodeExecutionLogMapper nodeExecutionLogMapper;
+
     @GetMapping({"/workflow/lobster-exec/instance", "/workflow/lobster-exec/instance/list"})
-    public AjaxResult lobsterExecInstanceList() { return AjaxResult.success(new ArrayList<>()); }
+    public AjaxResult lobsterExecInstanceList(@RequestParam(required = false) Long companyId,
+                                               @RequestParam(required = false) Long workflowId,
+                                               @RequestParam(required = false) String status) {
+        if (jdbcTemplate != null) {
+            StringBuilder sql = new StringBuilder(
+                "SELECT id, company_id, workflow_id, instance_name, status, contact_id, control_mode, " +
+                "current_node_index, current_node_name, total_nodes, completed_nodes, create_time, update_time " +
+                "FROM lobster_workflow_instance WHERE del_flag=0");
+            List<Object> params = new ArrayList<>();
+            if (companyId != null) { sql.append(" AND company_id=?"); params.add(companyId); }
+            if (workflowId != null) { sql.append(" AND workflow_id=?"); params.add(workflowId); }
+            if (status != null && !status.isEmpty()) { sql.append(" AND status=?"); params.add(status); }
+            sql.append(" ORDER BY create_time DESC LIMIT 500");
+            return AjaxResult.success(jdbcTemplate.queryForList(sql.toString(), params.toArray()));
+        }
+        if (workflowInstanceMapper == null) return AjaxResult.success(new ArrayList<>());
+        if (companyId == null) return AjaxResult.success(new ArrayList<>());
+        List<com.fs.company.domain.LobsterWorkflowInstance> list = workflowInstanceMapper.selectByCompanyId(companyId);
+        return AjaxResult.success(list != null ? list : new ArrayList<>());
+    }
 
     @GetMapping("/workflow/lobster-exec/instance/{instanceId}")
-    public AjaxResult lobsterExecInstanceGet(@PathVariable String instanceId) {
+    public AjaxResult lobsterExecInstanceGet(@PathVariable Long instanceId,
+                                                @RequestParam(required = false) Long companyId) {
+        if (workflowExecutor != null && companyId != null) {
+            return AjaxResult.success(workflowExecutor.getInstanceState(companyId, instanceId));
+        }
+        if (jdbcTemplate != null) {
+            try {
+                return AjaxResult.success(jdbcTemplate.queryForMap(
+                    "SELECT * FROM lobster_workflow_instance WHERE id=? AND del_flag=0", instanceId));
+            } catch (Exception e) {
+                return AjaxResult.error("实例不存在");
+            }
+        }
         Map<String, Object> data = new HashMap<>();
         data.put("instanceId", instanceId);
         data.put("status", "unknown");
@@ -472,12 +629,35 @@ public class LobsterAdminController extends BaseController {
     }
 
     @GetMapping("/workflow/lobster-exec/node-logs/{instanceId}")
-    public AjaxResult lobsterExecNodeLogs(@PathVariable String instanceId) {
+    public AjaxResult lobsterExecNodeLogs(@PathVariable Long instanceId,
+                                           @RequestParam(required = false) Long companyId) {
+        if (jdbcTemplate != null) {
+            return AjaxResult.success(jdbcTemplate.queryForList(
+                "SELECT * FROM lobster_node_execution_log WHERE instance_id=? ORDER BY create_time DESC LIMIT 200",
+                instanceId));
+        }
+        if (nodeExecutionLogMapper != null && companyId != null) {
+            return AjaxResult.success(nodeExecutionLogMapper.selectByInstanceId(instanceId, companyId));
+        }
         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/start")
+    public AjaxResult lobsterExecStart(@RequestParam Long workflowId,
+                                        @RequestParam(required = false, defaultValue = "0") Long contactId,
+                                        @RequestParam(required = false) Long companyId,
+                                        @RequestBody(required = false) Map<String, Object> initVariables) {
+        if (workflowExecutor == null) return AjaxResult.error("执行器不可用");
+        return workflowExecutor.startWorkflow(companyId, workflowId, contactId, initVariables);
+    }
+
+    @PostMapping("/workflow/lobster-exec/next-node")
+    public AjaxResult lobsterExecNext(@RequestParam Long instanceId,
+                                       @RequestParam(required = false) String customerReply,
+                                       @RequestParam(required = false) Long companyId) {
+        if (workflowExecutor == null) return AjaxResult.error("执行器不可用");
+        return workflowExecutor.executeNextNode(companyId, instanceId, customerReply);
+    }
 
     @PostMapping("/workflow/lobster-exec/pause/{instanceId}")
     public AjaxResult lobsterExecPause(@PathVariable String instanceId) {
@@ -667,29 +847,74 @@ public class LobsterAdminController extends BaseController {
         return AjaxResult.success(stats);
     }
 
-    // ======== 引擎核心占位端点 ========
+    // ======== 引擎核心端点(对接 EvolutionEngine / Heartbeat / Channels) ========
     @GetMapping("/workflow/lobster/engine/evolution/metrics")
-    public AjaxResult lobsterEngineEvolutionMetrics() {
+    public AjaxResult lobsterEngineEvolutionMetrics(@RequestParam(required = false) Long companyId) {
+        if (evolutionEngine != null && companyId != null) {
+            return AjaxResult.success(evolutionEngine.getEvolutionMetrics(companyId));
+        }
         Map<String, Object> data = new HashMap<>();
-        data.put("totalEvolutions", 0); data.put("appliedCount", 0); data.put("pendingCount", 0);
+        if (jdbcTemplate != null && companyId != null) {
+            try {
+                data.put("totalEvolutions", jdbcTemplate.queryForObject(
+                        "SELECT COUNT(*) FROM lobster_evolution_log WHERE company_id=?", Integer.class, companyId));
+                data.put("appliedCount", jdbcTemplate.queryForObject(
+                        "SELECT COUNT(*) FROM lobster_evolution_suggestion WHERE company_id=? AND status=1", Integer.class, companyId));
+                data.put("pendingCount", jdbcTemplate.queryForObject(
+                        "SELECT COUNT(*) FROM lobster_evolution_suggestion WHERE company_id=? AND status=0", Integer.class, companyId));
+            } catch (Exception e) {
+                data.put("totalEvolutions", 0); data.put("appliedCount", 0); data.put("pendingCount", 0);
+            }
+        } else {
+            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<>()); }
+    public AjaxResult lobsterEngineEvolutionAnalyze(@RequestParam Long companyId,
+                                                     @RequestParam Long workflowId) {
+        if (evolutionEngine != null) {
+            return AjaxResult.success(evolutionEngine.analyzeAndSuggest(companyId, workflowId));
+        }
+        return AjaxResult.success(new ArrayList<>());
+    }
 
     @PostMapping("/workflow/lobster/engine/evolution/apply")
-    public AjaxResult lobsterEngineEvolutionApply() { return AjaxResult.success("操作成功"); }
+    public AjaxResult lobsterEngineEvolutionApply(@RequestParam Long companyId,
+                                                   @RequestParam Long suggestionId) {
+        if (evolutionEngine != null) {
+            evolutionEngine.applySuggestion(companyId, suggestionId);
+            return AjaxResult.success("优化建议已应用");
+        }
+        return AjaxResult.error("进化引擎不可用");
+    }
 
     @GetMapping("/workflow/lobster/engine/heartbeat/status")
-    public AjaxResult lobsterEngineHeartbeat() {
+    public AjaxResult lobsterEngineHeartbeat(@RequestParam Long instanceId) {
+        if (heartbeatScheduler != null) {
+            return AjaxResult.success(heartbeatScheduler.getHeartbeatStatus(instanceId));
+        }
         Map<String, Object> data = new HashMap<>();
-        data.put("status", "healthy");
+        data.put("status", "unknown");
+        data.put("instanceId", instanceId);
         return AjaxResult.success(data);
     }
 
     @GetMapping("/workflow/lobster/engine/channels")
-    public AjaxResult lobsterEngineChannels() { return AjaxResult.success(new ArrayList<>()); }
+    public AjaxResult lobsterEngineChannels() {
+        if (messageChannelRouter != null) {
+            Map<String, Object> channels = new LinkedHashMap<>();
+            messageChannelRouter.getAllChannels().forEach((type, channel) -> {
+                Map<String, Object> info = new HashMap<>();
+                info.put("type", type);
+                info.put("available", true);
+                channels.put(type, info);
+            });
+            return AjaxResult.success(channels);
+        }
+        return AjaxResult.success(new ArrayList<>());
+    }
 
     // ════════════════════════════════════════════════════════════════
     // 画像配置 / 摘要配置 / 敏感词 / 消息去重 — 走真实 LobsterCompanyConfigService
@@ -952,8 +1177,10 @@ public class LobsterAdminController extends BaseController {
     @GetMapping("/workflow/lobster-admin/instances")
     public AjaxResult adminInstances(@RequestParam(defaultValue = "1") Integer pageNum,
                                       @RequestParam(defaultValue = "10") Integer pageSize,
-                                      @RequestParam(required = false) Long companyId) {
-        return AjaxResult.success(new ArrayList<>());
+                                      @RequestParam(required = false) Long companyId,
+                                      @RequestParam(required = false) Long workflowId,
+                                      @RequestParam(required = false) String status) {
+        return lobsterExecInstanceList(companyId, workflowId, status);
     }
 
     @GetMapping("/workflow/lobster-admin/prompts")

+ 18 - 10
fs-admin/src/main/java/com/fs/admin/controller/CompanyVoiceApiTenantController.java

@@ -104,6 +104,7 @@ public class CompanyVoiceApiTenantController extends BaseController {
                 if (tenantMap.get("tenantName") != null) {
                     tenant.setTenantName(tenantMap.get("tenantName").toString());
                 }
+                applyPricingFieldsFromMap(tenant, tenantMap);
                 if (tenant.getTenantId() != null) {
                     list.add(tenant);
                 }
@@ -208,23 +209,30 @@ public class CompanyVoiceApiTenantController extends BaseController {
 
     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()));
+        applyPricingFieldsFromMap(pricing, body);
+        return pricing;
+    }
+
+    private void applyPricingFieldsFromMap(CompanyVoiceApiTenant tenant, Map<String, Object> map) {
+        if (tenant == null || map == null) {
+            return;
         }
-        if (body.get("priority") != null && StringUtils.isNotEmpty(body.get("priority").toString())) {
-            pricing.setPriority(Integer.valueOf(body.get("priority").toString()));
+        if (map.get("salePrice") != null && StringUtils.isNotEmpty(map.get("salePrice").toString())) {
+            tenant.setSalePrice(new java.math.BigDecimal(map.get("salePrice").toString()));
         }
-        if (body.get("isPrimary") != null && StringUtils.isNotEmpty(body.get("isPrimary").toString())) {
-            pricing.setIsPrimary(Integer.valueOf(body.get("isPrimary").toString()));
+        if (map.get("priority") != null && StringUtils.isNotEmpty(map.get("priority").toString())) {
+            tenant.setPriority(Integer.valueOf(map.get("priority").toString()));
         }
-        Object selectable = body.get("selectable");
+        if (map.get("isPrimary") != null && StringUtils.isNotEmpty(map.get("isPrimary").toString())) {
+            tenant.setIsPrimary(Integer.valueOf(map.get("isPrimary").toString()));
+        }
+        Object selectable = map.get("selectable");
         if (selectable == null) {
-            selectable = body.get("allowManual");
+            selectable = map.get("allowManual");
         }
         if (selectable != null && StringUtils.isNotEmpty(selectable.toString())) {
-            pricing.setSelectable(selectable.toString());
+            tenant.setSelectable(selectable.toString());
         }
-        return pricing;
     }
 
     @SuppressWarnings("unchecked")

+ 248 - 0
fs-admin/src/main/java/com/fs/admin/controller/company/controller/SysRedpacketConfigMoreController.java

@@ -0,0 +1,248 @@
+package com.fs.admin.controller.company.controller;
+
+
+import com.fs.common.BeanCopyUtils;
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.framework.datasource.TenantDataSourceContextHelper;
+import com.fs.his.domain.SysRedpacketConfigMore;
+import com.fs.his.param.UpdateChangeMchIdParam;
+import com.fs.his.service.ISysRedpacketConfigMoreService;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.service.TenantInfoService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@Slf4j
+
+/**
+ * 多商户配置Controller
+ *
+ * @author fs
+ * @date 2025-11-27
+ */
+@RestController
+@RequestMapping("/redPacket/more")
+public class SysRedpacketConfigMoreController extends BaseController
+{
+    @Autowired
+    private ISysRedpacketConfigMoreService sysRedpacketConfigMoreService;
+
+    @Autowired
+    private TenantInfoService tenantInfoService;
+
+    @Autowired
+    private TenantDataSourceContextHelper tenantContextHelper;
+
+    /**
+     * 查询多商户配置列表
+     */
+    @PreAuthorize("@ss.hasPermi('redPacket:more:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(SysRedpacketConfigMore sysRedpacketConfigMore)
+    {
+        startPage();
+        List<SysRedpacketConfigMore> list = sysRedpacketConfigMoreService.selectSysRedpacketConfigMoreList(sysRedpacketConfigMore);
+        return getDataTable(list);
+    }
+
+
+    /**
+     * 获取多商户配置详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('redPacket:more:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(sysRedpacketConfigMoreService.selectSysRedpacketConfigMoreById(id));
+    }
+
+    /**
+     * 获取当前发红包的商户号
+     */
+    @PreAuthorize("@ss.hasPermi('redPacket:more:editConfig')")
+    @GetMapping(value = "/getRedPacketConfig")
+    public AjaxResult getRedPacketConFig()
+    {
+        return AjaxResult.success(sysRedpacketConfigMoreService.getRedPacketConFig());
+    }
+
+
+
+
+    /**
+     * 新增多商户配置
+     */
+    @PreAuthorize("@ss.hasPermi('redPacket:more:add')")
+    @Log(title = "多商户配置", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody SysRedpacketConfigMore sysRedpacketConfigMore)
+    {
+        int result = sysRedpacketConfigMoreService.insertSysRedpacketConfigMore(sysRedpacketConfigMore);
+        
+        // 如果指定了租户,同步到租户库
+        if (result > 0 && sysRedpacketConfigMore.getTenantId() != null) {
+            try {
+                syncToTenant(sysRedpacketConfigMore, sysRedpacketConfigMore.getTenantId());
+            } catch (Exception e) {
+                log.error("同步商户配置到租户库失败, tenantId={}, mchId={}", 
+                    sysRedpacketConfigMore.getTenantId(), sysRedpacketConfigMore.getMchId(), e);
+                // 不影响主流程,记录日志即可
+            }
+        }
+        
+        return toAjax(result);
+    }
+
+    /**
+     * 修改发商户号的商户
+     */
+    @Log(title = "修改发商户号的商户", businessType = BusinessType.UPDATE)
+    @PostMapping("/updateChangeMchId")
+    public R updateChangeMchId(@RequestBody UpdateChangeMchIdParam param)
+    {
+        return sysRedpacketConfigMoreService.updateChangeMchId(param);
+    }
+
+    /**
+     * 修改多商户配置
+     */
+    @PreAuthorize("@ss.hasPermi('redPacket:more:edit')")
+    @Log(title = "多商户配置", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody SysRedpacketConfigMore sysRedpacketConfigMore)
+    {
+        // 查询原数据,获取原租户ID
+        SysRedpacketConfigMore oldConfig = sysRedpacketConfigMoreService.selectSysRedpacketConfigMoreById(sysRedpacketConfigMore.getId());
+        Long oldTenantId = oldConfig != null ? oldConfig.getTenantId() : null;
+        Long newTenantId = sysRedpacketConfigMore.getTenantId();
+        String mchId = sysRedpacketConfigMore.getMchId();
+        
+        int result = sysRedpacketConfigMoreService.updateSysRedpacketConfigMore(sysRedpacketConfigMore);
+        
+        if (result > 0) {
+            try {
+                // 如果原租户存在且与新租户不同,从原租户库删除
+                if (oldTenantId != null && !oldTenantId.equals(newTenantId)) {
+                    deleteFromTenant(mchId, oldTenantId);
+                }
+                // 如果指定了新租户,同步到新租户库
+                if (newTenantId != null) {
+                    syncToTenant(sysRedpacketConfigMore, newTenantId);
+                }
+            } catch (Exception e) {
+                log.error("同步商户配置到租户库失败, mchId={}, oldTenantId={}, newTenantId={}", 
+                    mchId, oldTenantId, newTenantId, e);
+            }
+        }
+        
+        return toAjax(result);
+    }
+
+    /**
+     * 删除多商户配置
+     */
+    @PreAuthorize("@ss.hasPermi('redPacket:more:remove')")
+    @Log(title = "多商户配置", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        // 先查询要删除的数据,获取租户信息
+        for (Long id : ids) {
+            SysRedpacketConfigMore config = sysRedpacketConfigMoreService.selectSysRedpacketConfigMoreById(id);
+            if (config != null && config.getTenantId() != null) {
+                try {
+                    deleteFromTenant(config.getMchId(), config.getTenantId());
+                } catch (Exception e) {
+                    log.error("从租户库删除商户配置失败, tenantId={}, mchId={}", 
+                        config.getTenantId(), config.getMchId(), e);
+                }
+            }
+        }
+        
+        return toAjax(sysRedpacketConfigMoreService.deleteSysRedpacketConfigMoreByIds(ids));
+    }
+
+    /**
+     * 获取租户列表(用于下拉选择)
+     */
+    @PreAuthorize("@ss.hasPermi('redPacket:more:list')")
+    @GetMapping("/tenantList")
+    public AjaxResult tenantList()
+    {
+        TenantInfo query = new TenantInfo();
+        query.setStatus(1); // 只查询启用的租户
+        List<TenantInfo> list = tenantInfoService.selectTenantInfoList(query);
+        return AjaxResult.success(list);
+    }
+
+    /**
+     * 同步商户配置到租户库
+     */
+    private void syncToTenant(SysRedpacketConfigMore config, Long tenantId) {
+        if (tenantId == null) {
+            return;
+        }
+        tenantContextHelper.runInTenant(tenantId, () -> {
+            try {
+                // 查询租户库是否已存在该商户号的配置
+                SysRedpacketConfigMore existConfig = sysRedpacketConfigMoreService.getOne(
+                    new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<SysRedpacketConfigMore>()
+                        .eq("mch_id", config.getMchId())
+                );
+
+                // 复制一份数据用于租户库
+                SysRedpacketConfigMore tenantConfig = new SysRedpacketConfigMore();
+                BeanCopyUtils.copy(config, tenantConfig);
+
+                if (existConfig != null) {
+                    // 更新现有记录
+                    tenantConfig.setId(existConfig.getId());
+                    sysRedpacketConfigMoreService.updateSysRedpacketConfigMore(tenantConfig);
+                    log.info("同步商户配置到租户库-更新, tenantId={}, mchId={}", tenantId, config.getMchId());
+                } else {
+                    // 插入新记录,清空ID让数据库自增
+                    tenantConfig.setId(null);
+                    sysRedpacketConfigMoreService.insertSysRedpacketConfigMore(tenantConfig);
+                    log.info("同步商户配置到租户库-新增, tenantId={}, mchId={}", tenantId, config.getMchId());
+                }
+            } catch (Exception e) {
+                log.error("同步商户配置到租户库异常, tenantId={}, mchId={}", tenantId, config.getMchId(), e);
+                throw new RuntimeException("同步到租户库失败: " + e.getMessage(), e);
+            }
+        });
+    }
+
+    /**
+     * 从租户库删除商户配置
+     */
+    private void deleteFromTenant(String mchId, Long tenantId) {
+        if (tenantId == null || mchId == null) {
+            return;
+        }
+        tenantContextHelper.runInTenant(tenantId, () -> {
+            try {
+                // 查询租户库中的记录
+                SysRedpacketConfigMore config = sysRedpacketConfigMoreService.getOne(
+                    new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<SysRedpacketConfigMore>()
+                        .eq("mch_id", mchId)
+                );
+                if (config != null) {
+                    sysRedpacketConfigMoreService.deleteSysRedpacketConfigMoreById(config.getId());
+                    log.info("从租户库删除商户配置, tenantId={}, mchId={}", tenantId, mchId);
+                }
+            } catch (Exception e) {
+                log.error("从租户库删除商户配置异常, tenantId={}, mchId={}", tenantId, mchId, e);
+                throw new RuntimeException("从租户库删除失败: " + e.getMessage(), e);
+            }
+        });
+    }
+}

+ 6 - 6
fs-admin/src/main/java/com/fs/qw/controller/QwCompanyController.java

@@ -58,12 +58,12 @@ public class QwCompanyController extends BaseController
     {
 
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
-        String json = configService.selectConfigByKey("course.config");
-        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
-        if(!loginUser.isAdmin() && config.getDept() != null && config.getDept()){
-            qwCompany.setCreateDeptId(loginUser.getDeptId());
-            qwCompany.setCreateUserId(loginUser.getUserId());
-        }
+//        String json = configService.selectConfigByKey("course.config");
+//        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
+//        if(!loginUser.isAdmin() && config.getDept() != null && config.getDept()){
+//            qwCompany.setCreateDeptId(loginUser.getDeptId());
+//            qwCompany.setCreateUserId(loginUser.getUserId());
+//        }
         startPage();
         List<QwCompany> list = qwCompanyService.selectQwCompanyList(qwCompany);
         return getDataTable(list);

+ 105 - 3
fs-admin/src/main/java/com/fs/web/controller/system/CompanySmsApiTenantController.java

@@ -2,13 +2,19 @@ package com.fs.web.controller.system;
 
 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.StringUtils;
 import com.fs.proxy.domain.CompanySmsApiTenant;
+import com.fs.proxy.domain.SmsApiTenantBatchAddResult;
 import com.fs.proxy.service.ICompanySmsApiTenantService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
+import java.math.BigDecimal;
+import java.util.ArrayList;
 import java.util.List;
+import java.util.Map;
 
 /**
  * 短信接口-租户绑定Controller (adminUI)
@@ -20,12 +26,13 @@ public class CompanySmsApiTenantController extends BaseController {
     @Autowired
     private ICompanySmsApiTenantService smsApiTenantService;
 
-    /** 查询绑定列表 */
+    /** 分页查询绑定列表 */
     @PreAuthorize("@ss.hasPermi('platform:smsApiTenant:list')")
     @GetMapping("/list")
-    public AjaxResult list(CompanySmsApiTenant query) {
+    public TableDataInfo list(CompanySmsApiTenant query) {
+        startPage();
         List<CompanySmsApiTenant> list = smsApiTenantService.selectSmsApiTenantList(query);
-        return AjaxResult.success(list);
+        return getDataTable(list);
     }
 
     /** 获取绑定详情 */
@@ -49,6 +56,38 @@ public class CompanySmsApiTenantController extends BaseController {
         return toAjax(smsApiTenantService.insertSmsApiTenant(tenant));
     }
 
+    /** 批量新增绑定 */
+    @PreAuthorize("@ss.hasPermi('platform:smsApiTenant:add')")
+    @PostMapping("/batch")
+    public AjaxResult batchAdd(@RequestBody Map<String, Object> body) {
+        List<Long> apiIds = parseIdList(body.get("apiIds"));
+        List<Long> tenantIds = parseIdList(body.get("tenantIds"));
+        if (apiIds.isEmpty()) {
+            apiIds = parseIdList(body.get("apiId"));
+        }
+        if (tenantIds.isEmpty()) {
+            tenantIds = parseIdList(body.get("tenantId"));
+        }
+        CompanySmsApiTenant template = new CompanySmsApiTenant();
+        if (body.get("price") != null && StringUtils.isNotEmpty(body.get("price").toString())) {
+            template.setPrice(new BigDecimal(body.get("price").toString()));
+        }
+        if (body.get("priority") != null && StringUtils.isNotEmpty(body.get("priority").toString())) {
+            template.setPriority(Integer.valueOf(body.get("priority").toString()));
+        }
+        if (body.get("allowManual") != null && StringUtils.isNotEmpty(body.get("allowManual").toString())) {
+            template.setAllowManual(Integer.valueOf(body.get("allowManual").toString()));
+        }
+        if (body.get("status") != null && StringUtils.isNotEmpty(body.get("status").toString())) {
+            template.setStatus(Integer.valueOf(body.get("status").toString()));
+        } else {
+            template.setStatus(1);
+        }
+        SmsApiTenantBatchAddResult result = smsApiTenantService.batchInsertSmsApiTenant(apiIds, tenantIds, template);
+        String msg = String.format("成功 %d 条,失败 %d 条", result.getSuccessCount(), result.getFailCount());
+        return AjaxResult.success(msg, result);
+    }
+
     /** 修改绑定(调价/启停) */
     @PreAuthorize("@ss.hasPermi('platform:smsApiTenant:edit')")
     @PutMapping
@@ -56,10 +95,73 @@ public class CompanySmsApiTenantController extends BaseController {
         return toAjax(smsApiTenantService.updateSmsApiTenant(tenant));
     }
 
+    /** 批量更新租户定价/绑定配置 */
+    @PreAuthorize("@ss.hasPermi('platform:smsApiTenant:edit')")
+    @PutMapping("/batchPricing")
+    public AjaxResult batchPricing(@RequestBody Map<String, Object> body) {
+        List<Long> ids = parseIdList(body.get("ids"));
+        if (ids.isEmpty()) {
+            return AjaxResult.error("请选择要更新的记录");
+        }
+        CompanySmsApiTenant pricing = parsePricingFields(body);
+        if (pricing.getPrice() == null && pricing.getPriority() == null && pricing.getAllowManual() == null) {
+            return AjaxResult.error("请至少填写一项要批量更新的配置");
+        }
+        return toAjax(smsApiTenantService.batchUpdatePricing(ids, pricing));
+    }
+
+    /** 批量更新状态 */
+    @PreAuthorize("@ss.hasPermi('platform:smsApiTenant:edit')")
+    @PutMapping("/batchStatus")
+    public AjaxResult batchStatus(@RequestBody Map<String, Object> body) {
+        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(smsApiTenantService.batchUpdateStatus(ids, status));
+    }
+
     /** 解除绑定 */
     @PreAuthorize("@ss.hasPermi('platform:smsApiTenant:remove')")
     @DeleteMapping("/{id}")
     public AjaxResult remove(@PathVariable Long id) {
         return toAjax(smsApiTenantService.deleteSmsApiTenantById(id));
     }
+
+    private CompanySmsApiTenant parsePricingFields(Map<String, Object> body) {
+        CompanySmsApiTenant pricing = new CompanySmsApiTenant();
+        if (body.get("price") != null && StringUtils.isNotEmpty(body.get("price").toString())) {
+            pricing.setPrice(new BigDecimal(body.get("price").toString()));
+        }
+        if (body.get("priority") != null && StringUtils.isNotEmpty(body.get("priority").toString())) {
+            pricing.setPriority(Integer.valueOf(body.get("priority").toString()));
+        }
+        if (body.get("allowManual") != null && StringUtils.isNotEmpty(body.get("allowManual").toString())) {
+            pricing.setAllowManual(Integer.valueOf(body.get("allowManual").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;
+    }
 }

+ 1 - 95
fs-company/src/main/java/com/fs/company/controller/bridge/CompanyBridgeController.java

@@ -539,41 +539,7 @@ public class CompanyBridgeController extends BaseController {
     public AjaxResult trafficLogExport() { return AjaxResult.success(); }
 
     // ==========================================
-    // /workflow/lobster/* API妗ユ帴
-    // ==========================================
-
-    @GetMapping({"/workflow/lobster/api", "/workflow/lobster/api/list"})
-    public TableDataInfo lobsterApi() { return safeListFromTable("lobster_api_registry"); }
-
-    @GetMapping({"/workflow/lobster/audit", "/workflow/lobster/audit/list"})
-    public TableDataInfo lobsterAudit() { return safeListFromTable("lobster_event_audit"); }
-
-    @GetMapping({"/workflow/lobster/billing", "/workflow/lobster/billing/list"})
-    public TableDataInfo lobsterBilling() { return safeListFromTable("lobster_billing"); }
-
-    @GetMapping({"/workflow/lobster/chat", "/workflow/lobster/chat/list"})
-    public TableDataInfo lobsterChat() { return safeListFromTable("lobster_chat_aggregate"); }
-
-    @GetMapping({"/workflow/lobster/corpus", "/workflow/lobster/corpus/list"})
-    public TableDataInfo lobsterCorpus() { return safeListFromTable("lobster_sales_corpus"); }
-
-    @GetMapping({"/workflow/lobster/deadletter", "/workflow/lobster/deadletter/list"})
-    public TableDataInfo lobsterDeadletter() { return safeListFromTable("lobster_dead_letter"); }
-
-    @GetMapping({"/workflow/lobster/edit", "/workflow/lobster/edit/list"})
-    public TableDataInfo lobsterEdit() { return safeListFromTable("lobster_api_registry"); }
-
-    @GetMapping({"/workflow/lobster/exec", "/workflow/lobster/exec/list"})
-    public TableDataInfo lobsterExec() { return safeListFromTable("lobster_instance"); }
-
-    @GetMapping({"/workflow/lobster/list", "/workflow/lobster/"})
-    public TableDataInfo lobsterList() { return safeListFromTable("lobster_canvas"); }
-
-    @GetMapping({"/workflow/lobster/optimization", "/workflow/lobster/optimization/list"})
-    public TableDataInfo lobsterOptimization() { return safeListFromTable("lobster_optimization"); }
-
-    // ==========================================
-    // /store/* 鍓╀綑缂哄けAPI妗ユ帴
+    // /store/* 剩余缺失API
     // ==========================================
 
     @GetMapping("/store/shippingTemplatesFree/")
@@ -1087,12 +1053,6 @@ public class CompanyBridgeController extends BaseController {
     @GetMapping({"/workflow/ai-generator/result/1", "/workflow/ai-generator/result/"})
     public AjaxResult workflowAiGeneratorResult() { return AjaxResult.success(new HashMap<>()); }
 
-    @GetMapping("/workflow/canvas/")
-    public TableDataInfo workflowCanvasRoot() { return safeListFromTable("lobster_canvas"); }
-
-    @GetMapping("/workflow/template/")
-    public TableDataInfo workflowTemplateRoot() { return safeListFromTable("lobster_canvas"); }
-
     // --- /qw* ---
     @GetMapping("/qwAssignRule/")
     public TableDataInfo qwAssignRuleRoot() { return safeListFromTable("qw_assign_rule_user"); }
@@ -3057,45 +3017,6 @@ public class CompanyBridgeController extends BaseController {
     public TableDataInfo bridge_withdrawalManage_list() { return safeListFromTable("withdrawalManage_list"); }
 
 
-    // --- /workflow/lobster-admin/* ---
-
-    @GetMapping("/workflow/lobster-admin/api-registry")
-    public AjaxResult bridge_workflow_lobster_admin_api_registry() { return AjaxResult.success(new HashMap<>()); }
-
-    @GetMapping("/workflow/lobster-admin/billing-records")
-    public AjaxResult bridge_workflow_lobster_admin_billing_records() { return AjaxResult.success(new HashMap<>()); }
-
-    @GetMapping("/workflow/lobster-admin/chat-aggregate")
-    public AjaxResult bridge_workflow_lobster_admin_chat_aggregate() { return AjaxResult.success(new HashMap<>()); }
-
-    @GetMapping("/workflow/lobster-admin/companies")
-    public AjaxResult bridge_workflow_lobster_admin_companies() { return AjaxResult.success(new HashMap<>()); }
-
-    @GetMapping("/workflow/lobster-admin/company-stats/")
-    public AjaxResult bridge_workflow_lobster_admin_company_stats() { return AjaxResult.success(new HashMap<>()); }
-
-    @GetMapping("/workflow/lobster-admin/dead-letters")
-    public AjaxResult bridge_workflow_lobster_admin_dead_letters() { return AjaxResult.success(new HashMap<>()); }
-
-    @GetMapping("/workflow/lobster-admin/event-audits")
-    public AjaxResult bridge_workflow_lobster_admin_event_audits() { return AjaxResult.success(new HashMap<>()); }
-
-    @GetMapping("/workflow/lobster-admin/instances")
-    public AjaxResult bridge_workflow_lobster_admin_instances() { return AjaxResult.success(new HashMap<>()); }
-
-    @GetMapping("/workflow/lobster-admin/optimizations")
-    public AjaxResult bridge_workflow_lobster_admin_optimizations() { return AjaxResult.success(new HashMap<>()); }
-
-    @GetMapping("/workflow/lobster-admin/platform-stats")
-    public AjaxResult bridge_workflow_lobster_admin_platform_stats() { return AjaxResult.success(new HashMap<>()); }
-
-    @GetMapping("/workflow/lobster-admin/prompts")
-    public AjaxResult bridge_workflow_lobster_admin_prompts() { return AjaxResult.success(new HashMap<>()); }
-
-    @GetMapping("/workflow/lobster-admin/sales-corpus")
-    public AjaxResult bridge_workflow_lobster_admin_sales_corpus() { return AjaxResult.success(new HashMap<>()); }
-
-
     // --- /wx/wxSop/* ---
 
     @GetMapping({"/wx/wxSop", "/wx/wxSop/"})
@@ -3177,21 +3098,6 @@ public class CompanyBridgeController extends BaseController {
     @GetMapping("/qwCustomerLink/")
     public TableDataInfo bridge_qwCustomerLink_root() { return safeListFromTable("qw_customer_link"); }
 
-    @GetMapping("/workflow/lobster/billing/stats")
-    public AjaxResult bridge_lobster_billing_stats() { return AjaxResult.success(new HashMap<>()); }
-
-    @GetMapping("/workflow/lobster/instance/{id}")
-    public AjaxResult bridge_lobster_instance_detail(@PathVariable("id") Long id) { return AjaxResult.success(new HashMap<>()); }
-
-    @GetMapping("/workflow/lobster/instance/node-logs/{id}")
-    public AjaxResult bridge_lobster_instance_nodeLogs(@PathVariable("id") Long id) { return AjaxResult.success(new ArrayList<>()); }
-
-    @GetMapping("/workflow/lobster/instance/stats")
-    public AjaxResult bridge_lobster_instance_stats() { return AjaxResult.success(new HashMap<>()); }
-
-    @PostMapping("/workflow/lobster/instance/terminate/{id}")
-    public AjaxResult bridge_lobster_instance_terminate(@PathVariable("id") Long id) { return AjaxResult.success(); }
-
     // === 恢复菜单的桥接端点 ===
 
     // CRM - AI话术润色 (前端 /crm/customer_ai_chat/*)

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

@@ -135,6 +135,15 @@ public class CompanyWorkflowLobsterController extends BaseController {
 
     // ==================== 画布编辑接口 ====================
 
+    /**
+     * 获取画布数据(与 /workflow/template/{id} 等价,供前端 canvas 路由使用)
+     */
+    @GetMapping("/canvas/{templateId}")
+    public AjaxResult getCanvas(@PathVariable Long templateId) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        return AjaxResult.success(lobsterService.getTemplate(loginUser.getCompany().getCompanyId(), templateId));
+    }
+
     /**
      * 保存画布数据(包含节点位置、连线等可视化信息)
      */

+ 18 - 1
fs-company/src/main/java/com/fs/company/controller/qw/QwSopTempController.java

@@ -16,6 +16,8 @@ import com.fs.company.vo.DocCompanyUserVO;
 import com.fs.framework.security.LoginUser;
 import com.fs.framework.service.TokenService;
 import com.fs.system.service.ISysConfigService;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.service.TenantInfoService;
 import com.fs.qw.service.IQwUserService;
 import com.fs.qw.vo.SortDayVo;
 import com.fs.sop.domain.QwSop;
@@ -56,11 +58,26 @@ public class QwSopTempController extends BaseController
     @Autowired
     private ISysConfigService sysConfigService;
     @Autowired
+    private TenantInfoService tenantInfoService;
+    @Autowired
     private CompanyDeptServiceImpl companyDeptService;
 
     @Autowired
     private CompanyUserServiceImpl companyUserService;
 
+    /**
+     * 获取当前租户的公司名称
+     * @return 租户名称,如果未获取到则返回null
+     */
+    private String getCurrentTenantCompanyName() {
+        Long tenantId = com.fs.wxcid.utils.TenantHelper.getTenantId();
+        if (tenantId == null) {
+            return null;
+        }
+        TenantInfo tenantInfo = tenantInfoService.getById(tenantId);
+        return tenantInfo != null ? tenantInfo.getTenantName() : null;
+    }
+
     /**
      * 查询sop模板列表(GET)
      */
@@ -155,7 +172,7 @@ public class QwSopTempController extends BaseController
     public TableDataInfo deptList(QwSopTemp qwSopTemp)
     {
 
-        if (!"今正科技".equals(sysConfigService.getProjectConfig().getCloudHost().getCompanyName())) {
+        if (!"今正科技".equals(getCurrentTenantCompanyName())) {
             return list(qwSopTemp);
         }
 

+ 193 - 0
fs-company/src/main/java/com/fs/company/controller/workflow/LobsterE2eController.java

@@ -0,0 +1,193 @@
+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.workflow.DynamicNodeImplService;
+import com.fs.company.service.workflow.LobsterE2eTestService;
+import com.fs.company.service.workflow.LobsterTestScenarioService;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 租户端 E2E 测试 / 测试剧本 / 动态节点审批(saasui)
+ */
+@RestController
+public class LobsterE2eController extends BaseController {
+
+    @Autowired(required = false)
+    private LobsterE2eTestService e2eTestService;
+
+    @Autowired(required = false)
+    private LobsterTestScenarioService testScenarioService;
+
+    @Autowired(required = false)
+    private DynamicNodeImplService dynamicNodeImplService;
+
+    @Autowired
+    private TokenService tokenService;
+
+    private Long currentCompanyId() {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        return loginUser.getCompany().getCompanyId();
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @PostMapping("/workflow/lobster/e2e/run")
+    public AjaxResult e2eRun(@RequestBody Map<String, Object> body) {
+        if (e2eTestService == null) return AjaxResult.error("E2E 测试服务未启用");
+        LobsterE2eTestService.E2eRequest req = buildE2eRequest(body, currentCompanyId());
+        return AjaxResult.success(e2eTestService.runE2e(req));
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @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));
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @GetMapping("/workflow/lobster/e2e/list")
+    public AjaxResult e2eList(@RequestParam(defaultValue = "1") Integer pageNum,
+                                @RequestParam(defaultValue = "20") Integer pageSize) {
+        if (e2eTestService == null) return AjaxResult.success(new ArrayList<>());
+        return AjaxResult.success(e2eTestService.listRuns(currentCompanyId(), pageNum, pageSize));
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:exec')")
+    @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 测试服务未启用");
+        String userInput = body != null ? (String) body.get("userInput") : null;
+        return AjaxResult.success(e2eTestService.stepNext(currentCompanyId(), instanceId, userInput));
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:exec')")
+    @PostMapping("/workflow/lobster/chat/multi-turn")
+    public AjaxResult multiTurn(@RequestBody Map<String, Object> body) {
+        if (e2eTestService == null) return AjaxResult.error("E2E 测试服务未启用");
+        List<String> inputs = parseStringList(body.get("userInputs"));
+        return AjaxResult.success(e2eTestService.multiTurn(
+                currentCompanyId(),
+                toLong(body.get("instanceId")),
+                (String) body.get("nodeCode"),
+                inputs));
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @GetMapping("/workflow/lobster/scenario/list")
+    public AjaxResult scenarioList(@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(currentCompanyId(), enabled, pageNum, pageSize));
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @GetMapping("/workflow/lobster/scenario/{id}")
+    public AjaxResult scenarioGet(@PathVariable Long id) {
+        if (testScenarioService == null) return AjaxResult.error("剧本服务未启用");
+        return AjaxResult.success(testScenarioService.getScenario(id));
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:edit')")
+    @PostMapping("/workflow/lobster/scenario/save")
+    public AjaxResult scenarioSave(@RequestBody Map<String, Object> body) {
+        if (testScenarioService == null) return AjaxResult.error("剧本服务未启用");
+        body.putIfAbsent("companyId", currentCompanyId());
+        Object idObj = body.get("id");
+        if (idObj == null) {
+            return AjaxResult.success(testScenarioService.createScenario(body));
+        }
+        testScenarioService.updateScenario(toLong(idObj), body);
+        return AjaxResult.success(idObj);
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:edit')")
+    @DeleteMapping("/workflow/lobster/scenario/{id}")
+    public AjaxResult scenarioDelete(@PathVariable Long id) {
+        if (testScenarioService != null) testScenarioService.deleteScenario(id);
+        return AjaxResult.success();
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:exec')")
+    @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);
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:exec')")
+    @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);
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @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, currentCompanyId()));
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:edit')")
+    @PostMapping("/workflow/lobster/dynamic-impl/{id}/approve")
+    public AjaxResult dynamicImplApprove(@PathVariable Long id) {
+        if (dynamicNodeImplService == null) return AjaxResult.error("服务未启用");
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        dynamicNodeImplService.approve(id, loginUser.getUsername());
+        return AjaxResult.success();
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:edit')")
+    @PostMapping("/workflow/lobster/dynamic-impl/{id}/reject")
+    public AjaxResult dynamicImplReject(@PathVariable Long id, @RequestParam String reason) {
+        if (dynamicNodeImplService == null) return AjaxResult.error("服务未启用");
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        dynamicNodeImplService.reject(id, loginUser.getUsername(), reason);
+        return AjaxResult.success();
+    }
+
+    private LobsterE2eTestService.E2eRequest buildE2eRequest(Map<String, Object> body, Long companyId) {
+        LobsterE2eTestService.E2eRequest req = new LobsterE2eTestService.E2eRequest();
+        req.setCompanyId(body.get("companyId") != null ? toLong(body.get("companyId")) : 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")));
+        req.setUserInputs(parseStringList(body.get("userInputs")));
+        return req;
+    }
+
+    private List<String> parseStringList(Object ui) {
+        List<String> in = new ArrayList<>();
+        if (ui instanceof List) {
+            for (Object o : (List<?>) ui) {
+                if (o != null) in.add(o.toString());
+            }
+        }
+        return in;
+    }
+
+    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; }
+    }
+}

+ 33 - 0
fs-company/src/main/java/com/fs/company/controller/workflow/LobsterEngineController.java

@@ -1,6 +1,8 @@
 package com.fs.company.controller.workflow;
 
 import com.fs.common.core.domain.AjaxResult;
+import com.fs.company.service.workflow.capability.LobsterNodeCapabilityRegistry;
+import com.fs.company.service.workflow.DynamicNodeExecutor;
 import com.fs.company.service.workflow.evolution.EvolutionEngine;
 import com.fs.company.service.workflow.evolution.EvolutionSuggestion;
 import com.fs.company.service.workflow.heartbeat.HeartbeatScheduler;
@@ -14,7 +16,10 @@ 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.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 
 @RestController
@@ -36,6 +41,34 @@ public class LobsterEngineController {
     @Autowired
     private TokenService tokenService;
 
+    @Autowired(required = false)
+    private DynamicNodeExecutor dynamicNodeExecutor;
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @GetMapping("/node-capabilities")
+    public AjaxResult getNodeCapabilities() {
+        Map<Integer, ?> handlers = dynamicNodeExecutor != null
+                ? dynamicNodeExecutor.getRegisteredHandlers() : java.util.Collections.emptyMap();
+        List<Map<String, Object>> items = new ArrayList<>();
+        List<Map<String, Object>> gaps = new ArrayList<>();
+        for (LobsterNodeCapabilityRegistry.NodeCapability cap : LobsterNodeCapabilityRegistry.all()) {
+            Map<String, Object> row = new LinkedHashMap<>(cap.toMap());
+            boolean implemented = handlers.containsKey(cap.code);
+            row.put("handlerRegistered", implemented);
+            row.put("gap", !implemented);
+            items.add(row);
+            if (!implemented) {
+                gaps.add(row);
+            }
+        }
+        Map<String, Object> payload = new LinkedHashMap<>();
+        payload.put("summary", LobsterNodeCapabilityRegistry.summary());
+        payload.put("capabilities", items);
+        payload.put("gaps", gaps);
+        payload.put("registeredHandlerCount", handlers.size());
+        return AjaxResult.success(payload);
+    }
+
     @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
     @GetMapping("/evolution/metrics")
     public AjaxResult getEvolutionMetrics() {

+ 93 - 0
fs-company/src/main/java/com/fs/company/controller/workflow/LobsterInboundController.java

@@ -0,0 +1,93 @@
+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.workflow.inbound.LobsterInboundService;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+/**
+ * 龙虾统一入站网关 API
+ */
+@RestController
+@RequestMapping("/workflow/lobster/inbound")
+public class LobsterInboundController extends BaseController {
+
+    @Autowired(required = false)
+    private LobsterInboundService inboundService;
+
+    @Autowired
+    private TokenService tokenService;
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:exec')")
+    @PostMapping("/message")
+    public AjaxResult inboundMessage(@RequestBody Map<String, Object> body) {
+        if (inboundService == null) return AjaxResult.error("入站服务不可用");
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getCompany().getCompanyId();
+        Long contactId = toLong(body.get("contactId"));
+        String channelType = body.get("channelType") != null ? body.get("channelType").toString() : "QW";
+        String message = body.get("message") != null ? body.get("message").toString() : null;
+        Long workflowId = toLong(body.get("workflowId"));
+        @SuppressWarnings("unchecked")
+        Map<String, Object> extra = body.get("extra") instanceof Map ? (Map<String, Object>) body.get("extra") : null;
+        return inboundService.handleInboundMessage(companyId, contactId, channelType, message, workflowId, extra);
+    }
+
+    /** Webhook 入口(内部集成 / 渠道回调可传 companyId) */
+    @PostMapping("/webhook/message")
+    public AjaxResult webhookMessage(@RequestBody Map<String, Object> body) {
+        if (inboundService == null) return AjaxResult.error("入站服务不可用");
+        Long companyId = toLong(body.get("companyId"));
+        if (companyId == null) {
+            LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+            if (loginUser != null && loginUser.getCompany() != null) {
+                companyId = loginUser.getCompany().getCompanyId();
+            }
+        }
+        if (companyId == null) return AjaxResult.error("companyId 必填");
+        Long contactId = toLong(body.get("contactId"));
+        String channelType = body.get("channelType") != null ? body.get("channelType").toString() : "QW";
+        String message = body.get("message") != null ? body.get("message").toString()
+                : (body.get("content") != null ? body.get("content").toString() : null);
+        Long workflowId = toLong(body.get("workflowId"));
+        @SuppressWarnings("unchecked")
+        Map<String, Object> extra = body.get("extra") instanceof Map ? (Map<String, Object>) body.get("extra") : null;
+        return inboundService.handleInboundMessage(companyId, contactId, channelType, message, workflowId, extra);
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:edit')")
+    @PostMapping("/event")
+    public AjaxResult inboundEvent(@RequestBody Map<String, Object> body) {
+        if (inboundService == null) return AjaxResult.error("入站服务不可用");
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getCompany().getCompanyId();
+        String eventType = body.get("eventType") != null ? body.get("eventType").toString() : null;
+        if (eventType == null || eventType.isBlank()) {
+            return AjaxResult.error("eventType 必填");
+        }
+        return inboundService.handleBusinessEvent(companyId, eventType, body);
+    }
+
+    @PostMapping("/webhook/event")
+    public AjaxResult webhookEvent(@RequestBody Map<String, Object> body) {
+        if (inboundService == null) return AjaxResult.error("入站服务不可用");
+        Long companyId = toLong(body.get("companyId"));
+        if (companyId == null) return AjaxResult.error("companyId 必填");
+        String eventType = body.get("eventType") != null ? body.get("eventType").toString() : null;
+        if (eventType == null) return AjaxResult.error("eventType 必填");
+        return inboundService.handleBusinessEvent(companyId, eventType, body);
+    }
+
+    private Long toLong(Object v) {
+        if (v == null) return null;
+        if (v instanceof Number) return ((Number) v).longValue();
+        try { return Long.parseLong(v.toString()); } catch (Exception e) { return null; }
+    }
+}

+ 117 - 0
fs-company/src/main/java/com/fs/company/controller/workflow/LobsterInstanceMonitorController.java

@@ -0,0 +1,117 @@
+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.LobsterNodeExecutionLog;
+import com.fs.company.domain.LobsterWorkflowInstance;
+import com.fs.company.mapper.LobsterNodeExecutionLogMapper;
+import com.fs.company.mapper.LobsterWorkflowInstanceMapper;
+import com.fs.company.service.workflow.LobsterWorkflowExecutor;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.*;
+
+/**
+ * 工作流实例监控(直连 Mapper / Executor,替代 CompanyBridgeController 空桩)
+ */
+@RestController
+@RequestMapping("/workflow/lobster/instance")
+public class LobsterInstanceMonitorController extends BaseController {
+
+    @Autowired
+    private TokenService tokenService;
+
+    @Autowired(required = false)
+    private LobsterWorkflowExecutor workflowExecutor;
+
+    @Autowired(required = false)
+    private LobsterWorkflowInstanceMapper instanceMapper;
+
+    @Autowired(required = false)
+    private LobsterNodeExecutionLogMapper executionLogMapper;
+
+    @Autowired(required = false)
+    private JdbcTemplate jdbcTemplate;
+
+    private Long companyId() {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        return loginUser.getCompany().getCompanyId();
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:list')")
+    @GetMapping({"", "/list"})
+    public AjaxResult list(@RequestParam(required = false) String status) {
+        if (instanceMapper == null) return AjaxResult.success(Collections.emptyList());
+        List<LobsterWorkflowInstance> list = instanceMapper.selectByCompanyId(companyId());
+        return AjaxResult.success(list != null ? list : Collections.emptyList());
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @GetMapping("/stats")
+    public AjaxResult stats() {
+        Long cid = companyId();
+        Map<String, Object> stats = new LinkedHashMap<>();
+        if (jdbcTemplate == null) {
+            stats.put("running", 0);
+            stats.put("paused", 0);
+            stats.put("completed", 0);
+            stats.put("deadLetters", 0);
+            stats.put("todayTokens", "0");
+            return AjaxResult.success(stats);
+        }
+        try {
+            String base = " FROM lobster_workflow_instance WHERE del_flag=0 AND company_id=?";
+            stats.put("running", jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*)" + base + " AND status='running'", Integer.class, cid));
+            stats.put("paused", jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*)" + base + " AND status='paused'", Integer.class, cid));
+            stats.put("completed", jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*)" + base + " AND status='completed'", Integer.class, cid));
+            stats.put("deadLetters", jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM lobster_dead_letter_queue WHERE company_id=?", Integer.class, cid));
+            Object tokens = jdbcTemplate.queryForObject(
+                    "SELECT COALESCE(SUM(token_count),0) FROM lobster_token_consume_log WHERE company_id=? AND DATE(create_time)=CURDATE()",
+                    Object.class, cid);
+            stats.put("todayTokens", tokens != null ? tokens.toString() : "0");
+        } catch (Exception e) {
+            stats.put("running", 0);
+            stats.put("paused", 0);
+            stats.put("completed", 0);
+            stats.put("deadLetters", 0);
+            stats.put("todayTokens", "0");
+        }
+        return AjaxResult.success(stats);
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @GetMapping("/{instanceId}")
+    public AjaxResult detail(@PathVariable Long instanceId) {
+        if (workflowExecutor != null) {
+            return AjaxResult.success(workflowExecutor.getInstanceState(companyId(), instanceId));
+        }
+        LobsterWorkflowInstance inst = instanceMapper != null ? instanceMapper.selectById(instanceId) : null;
+        return inst != null ? AjaxResult.success(inst) : AjaxResult.error("实例不存在");
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @GetMapping("/node-logs/{instanceId}")
+    public AjaxResult nodeLogs(@PathVariable Long instanceId) {
+        List<LobsterNodeExecutionLog> logs = executionLogMapper != null
+                ? executionLogMapper.selectByInstanceId(instanceId, companyId()) : Collections.emptyList();
+        return AjaxResult.success(logs != null ? logs : Collections.emptyList());
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:terminate')")
+    @PostMapping("/terminate/{instanceId}")
+    public AjaxResult terminate(@PathVariable Long instanceId,
+                                @RequestParam(required = false) String reason) {
+        if (workflowExecutor == null) return AjaxResult.error("执行器不可用");
+        return workflowExecutor.terminateWorkflow(companyId(), instanceId, reason);
+    }
+}

+ 214 - 0
fs-company/src/main/java/com/fs/company/controller/workflow/LobsterPlatformAdminController.java

@@ -0,0 +1,214 @@
+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.mapper.LobsterChatSessionMapper;
+import com.fs.company.mapper.LobsterWorkflowInstanceMapper;
+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.evolution.EvolutionEngine;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.*;
+
+/**
+ * ????????????? API????? Service / Mapper?????????????
+ * ¡¤???? saasui / saasadminui ?? lobster-admin.js ????
+ */
+@RestController
+@RequestMapping("/workflow/lobster-admin")
+public class LobsterPlatformAdminController extends BaseController {
+
+    @Autowired
+    private TokenService tokenService;
+
+    @Autowired(required = false)
+    private ILobsterSalesCorpusService salesCorpusService;
+
+    @Autowired(required = false)
+    private ILobsterEventAuditService eventAuditService;
+
+    @Autowired(required = false)
+    private ILobsterBillingService billingService;
+
+    @Autowired(required = false)
+    private EvolutionEngine evolutionEngine;
+
+    @Autowired(required = false)
+    private LobsterWorkflowInstanceMapper instanceMapper;
+
+    @Autowired(required = false)
+    private LobsterChatSessionMapper chatSessionMapper;
+
+    @Autowired(required = false)
+    private JdbcTemplate jdbcTemplate;
+
+    private Long currentCompanyId() {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        return loginUser.getCompany().getCompanyId();
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @GetMapping("/companies")
+    public AjaxResult companies() {
+        Long companyId = currentCompanyId();
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("id", companyId);
+        row.put("companyId", companyId);
+        if (instanceMapper != null) {
+            List<?> list = instanceMapper.selectByCompanyId(companyId);
+            row.put("instanceCount", list != null ? list.size() : 0);
+        } else {
+            row.put("instanceCount", 0);
+        }
+        return AjaxResult.success(Collections.singletonList(row));
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @GetMapping("/company-stats/{companyId}")
+    public AjaxResult companyStats(@PathVariable Long companyId) {
+        ensureSameTenant(companyId);
+        return AjaxResult.success(buildTenantStats(companyId));
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @GetMapping("/platform-stats")
+    public AjaxResult platformStats() {
+        return AjaxResult.success(buildTenantStats(currentCompanyId()));
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @GetMapping("/instances")
+    public AjaxResult instances(@RequestParam(required = false) Long workflowId,
+                                @RequestParam(required = false) String status) {
+        Long companyId = currentCompanyId();
+        if (instanceMapper == null) return AjaxResult.success(Collections.emptyList());
+        List<?> list = instanceMapper.selectByCompanyId(companyId);
+        return AjaxResult.success(list != null ? list : Collections.emptyList());
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @GetMapping("/event-audits")
+    public AjaxResult eventAudits(@RequestParam(defaultValue = "pending") String status,
+                                  @RequestParam(defaultValue = "1") int pageNum,
+                                  @RequestParam(defaultValue = "10") int pageSize) {
+        if (eventAuditService == null) return AjaxResult.success(Collections.emptyList());
+        return AjaxResult.success(eventAuditService.listAudits(status, pageNum, pageSize, currentCompanyId()));
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @GetMapping("/sales-corpus")
+    public AjaxResult salesCorpus(@RequestParam(defaultValue = "1") int pageNum,
+                                  @RequestParam(defaultValue = "10") int pageSize,
+                                  @RequestParam(required = false) String scenario) {
+        if (salesCorpusService == null) return AjaxResult.success(Collections.emptyList());
+        return AjaxResult.success(salesCorpusService.listCorpus(pageNum, pageSize, currentCompanyId(), scenario, null));
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @GetMapping("/chat-aggregate")
+    public AjaxResult chatAggregate(@RequestParam(required = false) String channelType,
+                                    @RequestParam(required = false) String keyword) {
+        if (chatSessionMapper == null) return AjaxResult.success(Collections.emptyList());
+        try {
+            return AjaxResult.success(chatSessionMapper.selectForAggregate(channelType, keyword));
+        } catch (Exception e) {
+            return AjaxResult.success(Collections.emptyList());
+        }
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @GetMapping("/dead-letters")
+    public AjaxResult deadLetters() {
+        if (jdbcTemplate == null) return AjaxResult.success(Collections.emptyList());
+        try {
+            Long companyId = currentCompanyId();
+            return AjaxResult.success(jdbcTemplate.queryForList(
+                    "SELECT * FROM lobster_dead_letter_queue WHERE company_id=? ORDER BY create_time DESC LIMIT 200",
+                    companyId));
+        } catch (Exception e) {
+            return AjaxResult.success(Collections.emptyList());
+        }
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @GetMapping("/optimizations")
+    public AjaxResult optimizations() {
+        if (evolutionEngine == null) return AjaxResult.success(Collections.emptyMap());
+        return AjaxResult.success(evolutionEngine.getEvolutionMetrics(currentCompanyId()));
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @GetMapping("/prompts")
+    public AjaxResult prompts() {
+        if (jdbcTemplate == null) return AjaxResult.success(Collections.emptyList());
+        try {
+            return AjaxResult.success(jdbcTemplate.queryForList(
+                    "SELECT * FROM lobster_prompt_template WHERE company_id=? ORDER BY update_time DESC LIMIT 200",
+                    currentCompanyId()));
+        } catch (Exception e) {
+            return AjaxResult.success(Collections.emptyList());
+        }
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @GetMapping("/api-registry")
+    public AjaxResult apiRegistry() {
+        if (jdbcTemplate == null) return AjaxResult.success(Collections.emptyList());
+        try {
+            return AjaxResult.success(jdbcTemplate.queryForList(
+                    "SELECT id, api_code, api_name, api_url, api_method, status, create_time FROM lobster_smart_api WHERE company_id=? ORDER BY create_time DESC LIMIT 200",
+                    currentCompanyId()));
+        } catch (Exception e) {
+            return AjaxResult.success(Collections.emptyList());
+        }
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @GetMapping("/billing-records")
+    public AjaxResult billingRecords(@RequestParam(defaultValue = "1") int page,
+                                     @RequestParam(defaultValue = "20") int size) {
+        if (billingService == null) return AjaxResult.success(Collections.emptyList());
+        return AjaxResult.success(billingService.listTokenRecords(page, size, currentCompanyId()));
+    }
+
+    private Map<String, Object> buildTenantStats(Long companyId) {
+        Map<String, Object> stats = new LinkedHashMap<>();
+        stats.put("companyId", companyId);
+        stats.put("templateCount", 0);
+        stats.put("instanceCount", 0);
+        stats.put("runningInstances", 0);
+        stats.put("totalTokens", "0");
+        if (jdbcTemplate != null) {
+            try {
+                stats.put("templateCount", jdbcTemplate.queryForObject(
+                        "SELECT COUNT(*) FROM company_workflow_lobster WHERE company_id=? AND del_flag=0",
+                        Integer.class, companyId));
+                stats.put("instanceCount", jdbcTemplate.queryForObject(
+                        "SELECT COUNT(*) FROM lobster_workflow_instance WHERE company_id=? AND del_flag=0",
+                        Integer.class, companyId));
+                stats.put("runningInstances", jdbcTemplate.queryForObject(
+                        "SELECT COUNT(*) FROM lobster_workflow_instance WHERE company_id=? AND del_flag=0 AND status='running'",
+                        Integer.class, companyId));
+            } catch (Exception ignored) { }
+        }
+        if (evolutionEngine != null) {
+            Map<String, Object> metrics = evolutionEngine.getEvolutionMetrics(companyId);
+            stats.put("evolutionMetrics", metrics);
+        }
+        return stats;
+    }
+
+    private void ensureSameTenant(Long companyId) {
+        if (companyId != null && !companyId.equals(currentCompanyId())) {
+            throw new SecurityException("?????????????????");
+        }
+    }
+}

+ 25 - 8
fs-service/src/main/java/com/fs/comm/service/CommSmsSendService.java

@@ -110,11 +110,11 @@ public class CommSmsSendService {
             throw new ServiceException("客户不存在");
         }
         String targetPhone = StringUtils.isNotBlank(param.getPhone()) ? param.getPhone() : customer.getMobile();
-        checkSmsBlacklist(companyId, targetPhone);
+        checkCustomerSmsBlacklist(companyId, targetPhone);
         if (companyUserId != null) {
             CompanyUser companyUser = companyUserService.selectCompanyUserById(companyUserId);
             if (companyUser != null && StringUtils.isNotBlank(companyUser.getPhonenumber())) {
-                checkSmsBlacklist(companyId, companyUser.getPhonenumber());
+                checkSalesSmsBlacklist(companyId, companyUser.getPhonenumber());
             }
         }
 
@@ -298,14 +298,14 @@ public class CommSmsSendService {
             for (Long customerId : param.getCustomerIds()) {
                 CrmCustomer customer = crmCustomerService.selectCrmCustomerById(customerId);
                 if (customer != null && StringUtils.isNotBlank(customer.getMobile())) {
-                    checkSmsBlacklist(param.getCompanyId(), customer.getMobile());
+                    checkCustomerSmsBlacklist(param.getCompanyId(), customer.getMobile());
                 }
             }
         }
         if (param.getCompanyUserId() != null) {
             CompanyUser companyUser = companyUserService.selectCompanyUserById(param.getCompanyUserId());
             if (companyUser != null && StringUtils.isNotBlank(companyUser.getPhonenumber())) {
-                checkSmsBlacklist(param.getCompanyId(), companyUser.getPhonenumber());
+                checkSalesSmsBlacklist(param.getCompanyId(), companyUser.getPhonenumber());
             }
         }
         try {
@@ -359,17 +359,34 @@ public class CommSmsSendService {
         }
     }
 
-    private void checkSmsBlacklist(Long companyId, String phone) {
+    private void checkCustomerSmsBlacklist(Long companyId, String phone) {
+        checkSmsBlacklistInternal(companyId, phone, false);
+    }
+
+    private void checkSalesSmsBlacklist(Long companyId, String phone) {
+        checkSmsBlacklistInternal(companyId, phone, true);
+    }
+
+    private void checkSmsBlacklistInternal(Long companyId, String phone, boolean salesSide) {
         if (StringUtils.isBlank(phone)) {
             return;
         }
+        String targetValue = PhoneUtil.resolvePhoneForBlacklist(phone);
+        if (StringUtils.isBlank(targetValue)) {
+            throw new ServiceException("手机号无效或解密失败");
+        }
         CompanyVoiceRoboticCallBlacklistCheckParam checkParam = new CompanyVoiceRoboticCallBlacklistCheckParam();
         checkParam.setCompanyId(companyId);
         checkParam.setBusinessType(BusinessTypeEnum.SMS.getCode());
-        checkParam.setTargetValue(PhoneUtil.decryptPhone(phone));
+        checkParam.setTargetType(1);
+        checkParam.setTargetValue(targetValue);
         CompanyVoiceRoboticCallBlacklistCheckVO vo = companyVoiceRoboticCallBlacklistService.checkBlacklist(checkParam);
-        if (!vo.getPass()) {
-            throw new ServiceException("号码命中短信黑名单: " + phone);
+        if (Boolean.TRUE.equals(vo.getPass())) {
+            return;
+        }
+        if (Boolean.TRUE.equals(vo.getHitBlacklist())) {
+            throw new ServiceException(salesSide ? "销售号码黑名单校验未通过" : "客户号码黑名单校验未通过");
         }
+        throw new ServiceException(StringUtils.defaultIfBlank(vo.getReason(), "黑名单校验未通过"));
     }
 }

+ 11 - 2
fs-service/src/main/java/com/fs/common/service/impl/SmsServiceImpl.java

@@ -33,6 +33,7 @@ import com.fs.crm.param.SmsSendParam;
 import com.fs.crm.param.SmsSendUserParam;
 import com.fs.crm.service.ICrmCustomerService;
 import com.fs.his.config.FsSmsConfig;
+import com.fs.his.utils.PhoneUtil;
 import com.fs.his.domain.FsPayConfig;
 import com.fs.his.domain.FsStoreOrder;
 import com.fs.his.mapper.FsPackageOrderMapper;
@@ -781,7 +782,11 @@ public class SmsServiceImpl implements ISmsService
             CompanyVoiceRoboticCallBlacklistCheckParam salesBlacklistParam = new CompanyVoiceRoboticCallBlacklistCheckParam();
             salesBlacklistParam.setCompanyId(param.getCompanyId());
             salesBlacklistParam.setBusinessType(BusinessTypeEnum.SMS.getCode());
-            salesBlacklistParam.setTargetValue(companyUser.getPhonenumber());
+            salesBlacklistParam.setTargetType(1);
+            salesBlacklistParam.setTargetValue(PhoneUtil.resolvePhoneForBlacklist(companyUser.getPhonenumber()));
+            if (StringUtils.isEmpty(salesBlacklistParam.getTargetValue())) {
+                throw new RuntimeException("销售手机号无效或解密失败");
+            }
             CompanyVoiceRoboticCallBlacklistCheckVO salesBlacklistVo = companyVoiceRoboticCallBlacklistService.checkBlacklist(salesBlacklistParam);
             if (!salesBlacklistVo.getPass()){
                 throw new RuntimeException("销售号码黑名单校验未通过");
@@ -796,7 +801,11 @@ public class SmsServiceImpl implements ISmsService
                 CompanyVoiceRoboticCallBlacklistCheckParam customerBlacklistParam = new CompanyVoiceRoboticCallBlacklistCheckParam();
                 customerBlacklistParam.setCompanyId(param.getCompanyId());
                 customerBlacklistParam.setBusinessType(BusinessTypeEnum.SMS.getCode());
-                customerBlacklistParam.setTargetValue(crmCustomer.getMobile());
+                customerBlacklistParam.setTargetType(1);
+                customerBlacklistParam.setTargetValue(PhoneUtil.resolvePhoneForBlacklist(crmCustomer.getMobile()));
+                if (StringUtils.isEmpty(customerBlacklistParam.getTargetValue())) {
+                    throw new RuntimeException("客户手机号无效或解密失败");
+                }
                 CompanyVoiceRoboticCallBlacklistCheckVO customerBlacklistVo = companyVoiceRoboticCallBlacklistService.checkBlacklist(customerBlacklistParam);
                 if (!customerBlacklistVo.getPass()){
                     throw new RuntimeException("客户号码黑名单校验未通过");

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

@@ -48,6 +48,17 @@ public class CompanySmsTemp extends BaseEntity
 
     private Integer isAudit;
 
+    /** 绑定的短信接口IDs(逗号分隔,存主库 api_id) */
+    private String smsApiIds;
+
+    public String getSmsApiIds() {
+        return smsApiIds;
+    }
+
+    public void setSmsApiIds(String smsApiIds) {
+        this.smsApiIds = smsApiIds;
+    }
+
     public Integer getIsAudit() {
         return isAudit;
     }
@@ -142,6 +153,7 @@ public class CompanySmsTemp extends BaseEntity
             .append("updateTime", getUpdateTime())
             .append("status", getStatus())
             .append("cateId", getCateId())
+            .append("smsApiIds", getSmsApiIds())
             .toString();
     }
 }

+ 2 - 0
fs-service/src/main/java/com/fs/company/domain/CompanyWorkflowLobsterTask.java

@@ -86,4 +86,6 @@ public class CompanyWorkflowLobsterTask extends BaseEntity {
     private Long qwUserId; // 企微用户id
 
     private Long bindingId;
+
+    private String externalUserId; // 企微外部联系人ID
 }

+ 27 - 7
fs-service/src/main/java/com/fs/company/domain/LobsterConversationSummary.java

@@ -12,13 +12,33 @@ public class LobsterConversationSummary {
     @TableId(type = IdType.AUTO)
     private Long id;
     private Long companyId;
-    private String externalUserId;
-    private String sessionId;
-    private String summaryText;
-    private String keywords;
-    private String sentiment;
-    private String intentCategory;
+    /** DDL instance_id: 工作流实例ID */
+    private Long instanceId;
+    private Long contactId;
+    private String summaryType;
+    /** DDL summary_content: 摘要内容(结构化写入) */
+    private String summaryContent;
+    private String keyPoints;
+    private String sentimentAnalysis;
+    private String nextActionSuggestion;
     private Integer messageCount;
-    private LocalDateTime summaryTime;
+    /** 自动摘要字段: summary_text(原始文本) */
+    private String summaryText;
+    /** 自动摘要字段: 外部联系人ID */
+    private String externalUserId;
+    /** 自动摘要字段: 聊天消息计数 */
+    private Integer chatMsgCount;
+    /** 自动摘要字段: 关键意图JSON */
+    private String keyIntents;
+    /** 自动摘要字段: 关键变量JSON */
+    private String keyVariables;
+    /** 自动摘要字段: 当前阶段 */
+    private String stage;
+    /** 自动摘要字段: 下一步行动提示 */
+    private String nextActionHint;
+    private Integer delFlag;
+    private String createBy;
     private LocalDateTime createTime;
+    private String updateBy;
+    private LocalDateTime updateTime;
 }

+ 64 - 0
fs-service/src/main/java/com/fs/company/domain/LobsterE2eRun.java

@@ -0,0 +1,64 @@
+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;
+
+/**
+ * 龙虾E2E测试运行头表
+ */
+@Data
+@TableName("lobster_e2e_run")
+public class LobsterE2eRun {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 运行ID(UUID) */
+    private String runId;
+
+    private Long companyId;
+
+    /** 工作流模板ID */
+    private Long templateId;
+
+    /** 工作流实例ID */
+    private Long instanceId;
+
+    /** 测试场景ID */
+    private Long scenarioId;
+
+    /** 业务描述(即时生成时) */
+    private String businessDesc;
+
+    /** 综合评分(0-100) */
+    private BigDecimal totalScore;
+
+    /** 通过节点数 */
+    private Integer passedNodeCnt;
+
+    /** 总节点数 */
+    private Integer totalNodeCnt;
+
+    /** 总耗时ms */
+    private Long durationMs;
+
+    /** RUNNING|SUCCESS|FAILED */
+    private String status;
+
+    /** 错误信息 */
+    private String errorMsg;
+
+    /** 生成的进化建议数 */
+    private Integer evolutionCount;
+
+    private String createBy;
+
+    private LocalDateTime createTime;
+
+    private LocalDateTime updateTime;
+}

+ 64 - 0
fs-service/src/main/java/com/fs/company/domain/LobsterE2eRunNode.java

@@ -0,0 +1,64 @@
+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;
+
+/**
+ * 龙虾E2E测试节点明细
+ */
+@Data
+@TableName("lobster_e2e_run_node")
+public class LobsterE2eRunNode {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 关联 lobster_e2e_run.run_id */
+    private String runId;
+
+    /** 节点序号 */
+    private Integer nodeSeq;
+
+    /** 节点编码 */
+    private String nodeCode;
+
+    /** 节点类型 */
+    private String nodeType;
+
+    private String nodeName;
+
+    /** 单节点轮次(多轮对话) */
+    private Integer turnNo;
+
+    /** 用户输入 */
+    private String userInput;
+
+    /** AI输出 */
+    private String aiOutput;
+
+    /** 本节点本轮评分 */
+    private BigDecimal score;
+
+    /** 维度评分JSON */
+    private String scoreDetail;
+
+    private Long durationMs;
+
+    /** 使用的模型 */
+    private String modelUsed;
+
+    /** 进化建议草稿 */
+    private String evolutionHint;
+
+    /** 0=未达标 1=达标 */
+    private Integer passed;
+
+    private String errorMsg;
+
+    private LocalDateTime createTime;
+}

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

@@ -6,6 +6,9 @@ import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
 import java.time.LocalDateTime;
 
+/**
+ * 龙虾进化交互日志
+ */
 @Data
 @TableName("lobster_evolution_log")
 public class LobsterEvolutionLog {
@@ -13,13 +16,20 @@ public class LobsterEvolutionLog {
     private Long id;
     private Long companyId;
     private Long workflowId;
-    private String actionType;
+    private Long instanceId;
+    private Long contactId;
+    /** 通道类型: QW/WX/IM */
+    private String channelType;
     private String nodeCode;
-    private String beforeContent;
-    private String afterContent;
-    private String changeDesc;
-    private Double improvementRate;
-    private String status;
-    private LocalDateTime evolveTime;
+    /** 发送的消息 */
+    private String sentMessage;
+    /** 客户回复 */
+    private String customerReply;
+    /** 交互结果: purchase/inquiry/complaint/positive/negative/schedule/other */
+    private String outcome;
+    /** 变量快照 JSON */
+    private String variables;
+    /** 响应时长(毫秒) */
+    private Long durationMs;
     private LocalDateTime createTime;
 }

+ 16 - 5
fs-service/src/main/java/com/fs/company/domain/LobsterEvolutionSuggestion.java

@@ -6,6 +6,9 @@ import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
 import java.time.LocalDateTime;
 
+/**
+ * 龙虾进化优化建议
+ */
 @Data
 @TableName("lobster_evolution_suggestion")
 public class LobsterEvolutionSuggestion {
@@ -15,10 +18,18 @@ public class LobsterEvolutionSuggestion {
     private Long workflowId;
     private String nodeCode;
     private String suggestionType;
-    private String suggestionContent;
-    private String originalContent;
-    private Double confidenceScore;
-    private String status;
-    private String createdBy;
+    /** 当前内容(对应 DDL current_content) */
+    private String currentContent;
+    /** 建议内容(对应 DDL suggested_content) */
+    private String suggestedContent;
+    /** 置信度 */
+    private Double confidence;
+    /** 优化原因 */
+    private String reason;
+    /** 相关指标 JSON */
+    private String metrics;
+    /** 状态: 0待处理 1已应用 2已忽略 */
+    private Integer status;
+    private LocalDateTime applyTime;
     private LocalDateTime createTime;
 }

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

@@ -21,6 +21,9 @@ public class LobsterNodeExecutionLog extends BaseEntity {
 
     private Long workflowId;
 
+    /** 节点编码(用于按节点维度统计) */
+    private String nodeCode;
+
     private Integer nodeIndex;
 
     private String nodeType;
@@ -43,5 +46,8 @@ public class LobsterNodeExecutionLog extends BaseEntity {
 
     private Integer retryCount;
 
+    /** 质量评分(0-100) */
+    private Integer qualityScore;
+
     private Integer delFlag;
 }

+ 58 - 0
fs-service/src/main/java/com/fs/company/domain/LobsterTestScenario.java

@@ -0,0 +1,58 @@
+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;
+
+/**
+ * 龙虾测试场景剧本(数据驱动回归)
+ */
+@Data
+@TableName("lobster_test_scenario")
+public class LobsterTestScenario {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long companyId;
+
+    /** 场景名 */
+    private String scenarioName;
+
+    /** 业务描述(留空则用 template_id) */
+    private String businessDesc;
+
+    /** 关联工作流模板 */
+    private Long templateId;
+
+    /** 用户输入数组JSON */
+    private String userInputsJson;
+
+    /** 期望命中的节点编码JSON */
+    private String expectedNodes;
+
+    /** 最低通过分 */
+    private BigDecimal minScore;
+
+    /** 是否启用回归 */
+    private Integer enabled;
+
+    /** 自定义cron(空则跟随全局) */
+    private String cron;
+
+    private String lastRunId;
+
+    private String lastRunStatus;
+
+    private LocalDateTime lastRunTime;
+
+    private String createBy;
+
+    private LocalDateTime createTime;
+
+    private LocalDateTime updateTime;
+}

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

@@ -0,0 +1,42 @@
+package com.fs.company.domain.tenant;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 租户库 company_voice_api 表实体(仅用于租户数据源读写,非总后台主库)
+ */
+@Data
+public class TenantCompanyVoiceApi {
+
+    private Long apiId;
+
+    private String apiName;
+
+    /** 租户库为 varchar,与主库 int 类型区分存储 */
+    private String apiType;
+
+    private String apiJson;
+
+    private Integer status;
+
+    private String remark;
+
+    private Integer priority;
+
+    private Integer selectable;
+
+    private BigDecimal salePrice;
+
+    private Integer isPrimary;
+
+    private String apiUrl;
+
+    private String dialogUrl;
+
+    private Integer isDel;
+
+    private Date createTime;
+}

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

@@ -131,7 +131,8 @@ public interface LobsterAuxiliaryMapper {
     int ensureE2eResultTable();
 
     // === lobster_test_scenario ===
-    List<Map<String, Object>> selectTestScenarios(@Param("companyId") Long companyId);
+    List<Map<String, Object>> selectTestScenarios(@Param("companyId") Long companyId,
+                                                   @Param("enabled") Integer enabled);
     Map<String, Object> selectTestScenarioById(@Param("id") Long id,
                                                 @Param("companyId") Long companyId);
     int insertTestScenario(@Param("companyId") Long companyId,

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

@@ -0,0 +1,28 @@
+package com.fs.company.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.company.domain.LobsterE2eRun;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 龙虾E2E测试运行Mapper
+ */
+public interface LobsterE2eRunMapper extends BaseMapper<LobsterE2eRun> {
+
+    int insertRun(LobsterE2eRun run);
+
+    LobsterE2eRun selectByRunId(@Param("runId") String runId);
+
+    List<LobsterE2eRun> selectByCompanyId(@Param("companyId") Long companyId);
+
+    int updateStatus(@Param("runId") String runId,
+                     @Param("status") String status,
+                     @Param("totalScore") java.math.BigDecimal totalScore,
+                     @Param("passedNodeCnt") Integer passedNodeCnt,
+                     @Param("totalNodeCnt") Integer totalNodeCnt,
+                     @Param("durationMs") Long durationMs,
+                     @Param("evolutionCount") Integer evolutionCount,
+                     @Param("errorMsg") String errorMsg);
+}

+ 19 - 0
fs-service/src/main/java/com/fs/company/mapper/LobsterE2eRunNodeMapper.java

@@ -0,0 +1,19 @@
+package com.fs.company.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.company.domain.LobsterE2eRunNode;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 龙虾E2E测试节点明细Mapper
+ */
+public interface LobsterE2eRunNodeMapper extends BaseMapper<LobsterE2eRunNode> {
+
+    int insertNode(LobsterE2eRunNode node);
+
+    int batchInsert(@Param("list") List<LobsterE2eRunNode> list);
+
+    List<LobsterE2eRunNode> selectByRunId(@Param("runId") String runId);
+}

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

@@ -89,4 +89,7 @@ public interface LobsterEvolutionConfigMapper {
     Integer countPendingSuggestion(@Param("companyId") Long companyId);
     Integer countApplied(@Param("companyId") Long companyId);
     int ensureSuggestionTable();
+
+    List<Map<String, Object>> selectPendingSuggestions(@Param("companyId") Long companyId,
+                                                        @Param("minConfidence") Double minConfidence);
 }

+ 3 - 6
fs-service/src/main/java/com/fs/company/mapper/LobsterEvolutionSuggestionMapper.java

@@ -12,18 +12,15 @@ public interface LobsterEvolutionSuggestionMapper extends BaseMapper<LobsterEvol
     @Select("SELECT COUNT(*) FROM lobster_evolution_suggestion WHERE company_id = #{companyId}")
     Integer countByCompanyId(@Param("companyId") Long companyId);
 
-    @Select("SELECT COUNT(*) FROM lobster_evolution_suggestion WHERE company_id = #{companyId} AND status = 'replied'")
-    Integer countRepliedByCompanyId(@Param("companyId") Long companyId);
-
-    @Select("SELECT COUNT(*) FROM lobster_evolution_suggestion WHERE company_id = #{companyId} AND status = 'pending'")
+    @Select("SELECT COUNT(*) FROM lobster_evolution_suggestion WHERE company_id = #{companyId} AND status = 0")
     Integer countPendingByCompanyId(@Param("companyId") Long companyId);
 
-    @Select("SELECT COUNT(*) FROM lobster_evolution_suggestion WHERE company_id = #{companyId} AND status = 'applied'")
+    @Select("SELECT COUNT(*) FROM lobster_evolution_suggestion WHERE company_id = #{companyId} AND status = 1")
     Integer countAppliedByCompanyId(@Param("companyId") Long companyId);
 
     @Select("SELECT * FROM lobster_evolution_suggestion WHERE company_id = #{companyId} AND workflow_id = #{workflowId} ORDER BY create_time DESC")
     List<LobsterEvolutionSuggestion> selectByCompanyAndWorkflow(@Param("companyId") Long companyId, @Param("workflowId") Long workflowId);
 
-    @Update("UPDATE lobster_evolution_suggestion SET status = 'applied' WHERE id = #{id}")
+    @Update("UPDATE lobster_evolution_suggestion SET status = 1, apply_time = NOW() WHERE id = #{id}")
     int markApplied(@Param("id") Long id);
 }

+ 23 - 0
fs-service/src/main/java/com/fs/company/mapper/LobsterTestScenarioMapper.java

@@ -0,0 +1,23 @@
+package com.fs.company.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.company.domain.LobsterTestScenario;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 龙虾测试场景剧本Mapper
+ */
+public interface LobsterTestScenarioMapper extends BaseMapper<LobsterTestScenario> {
+
+    int insertScenario(LobsterTestScenario scenario);
+
+    LobsterTestScenario selectById(@Param("id") Long id);
+
+    List<LobsterTestScenario> selectEnabledByCompanyId(@Param("companyId") Long companyId);
+
+    int updateLastRun(@Param("id") Long id,
+                      @Param("lastRunId") String lastRunId,
+                      @Param("lastRunStatus") String lastRunStatus);
+}

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

@@ -0,0 +1,22 @@
+package com.fs.company.mapper.tenant;
+
+import com.fs.company.domain.tenant.TenantCompanyVoiceApi;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 租户库 company_voice_api 表 Mapper(仅在切换租户数据源后调用)
+ */
+public interface TenantCompanyVoiceApiMapper {
+
+    TenantCompanyVoiceApi selectByApiId(@Param("apiId") Long apiId);
+
+    List<TenantCompanyVoiceApi> selectTenantCompanyVoiceApiList(TenantCompanyVoiceApi query);
+
+    int upsertTenantVoiceApi(TenantCompanyVoiceApi row);
+
+    int updateStatusByApiId(@Param("apiId") Long apiId, @Param("status") Integer status);
+
+    int markDeletedByApiId(@Param("apiId") Long apiId);
+}

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

@@ -1,7 +1,9 @@
 package com.fs.company.service.impl;
 
 import java.util.List;
+import com.fs.common.exception.ServiceException;
 import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.StringUtils;
 import com.fs.company.param.CompanySmsTempListQueryParam;
 import com.fs.company.vo.CompanySmsTempListQueryVO;
 import com.fs.company.vo.CompanySmsTempListVO;
@@ -56,6 +58,9 @@ public class CompanySmsTempServiceImpl implements ICompanySmsTempService
     @Override
     public int insertCompanySmsTemp(CompanySmsTemp companySmsTemp)
     {
+        if (StringUtils.isEmpty(companySmsTemp.getSmsApiIds())) {
+            throw new ServiceException("请选择绑定的短信接口");
+        }
         companySmsTemp.setCreateTime(DateUtils.getNowDate());
         return companySmsTempMapper.insertCompanySmsTemp(companySmsTemp);
     }

+ 39 - 3
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceApiServiceImpl.java

@@ -5,6 +5,9 @@ import com.fs.company.domain.CompanyVoiceApi;
 import com.fs.company.mapper.CompanyVoiceApiMapper;
 import com.fs.company.service.ICompanyVoiceApiService;
 import com.fs.company.service.ICompanyVoiceApiTenantService;
+import com.fs.company.service.tenant.ITenantCompanyVoiceApiSyncService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -20,12 +23,17 @@ import java.util.List;
 @Service
 public class CompanyVoiceApiServiceImpl implements ICompanyVoiceApiService 
 {
+    private static final Logger log = LoggerFactory.getLogger(CompanyVoiceApiServiceImpl.class);
+
     @Autowired
     private CompanyVoiceApiMapper companyVoiceApiMapper;
 
     @Autowired
     private ICompanyVoiceApiTenantService companyVoiceApiTenantService;
 
+    @Autowired
+    private ITenantCompanyVoiceApiSyncService tenantCompanyVoiceApiSyncService;
+
     /**
      * 查询呼叫接口
      * 
@@ -73,7 +81,9 @@ public class CompanyVoiceApiServiceImpl implements ICompanyVoiceApiService
     public int updateCompanyVoiceApi(CompanyVoiceApi companyVoiceApi)
     {
         prepareForSave(companyVoiceApi);
-        return companyVoiceApiMapper.updateCompanyVoiceApi(companyVoiceApi);
+        int rows = companyVoiceApiMapper.updateCompanyVoiceApi(companyVoiceApi);
+        syncMasterApiToAllTenantDbsQuietly(companyVoiceApi.getApiId());
+        return rows;
     }
 
     /** 保存前补全默认值(数据写入 account/password/api_url/dialog_url 等新字段,不使用 apiJson) */
@@ -105,7 +115,15 @@ public class CompanyVoiceApiServiceImpl implements ICompanyVoiceApiService
             return 0;
         }
         companyVoiceApiTenantService.disableTenantsByApiIds(apiIds);
-        return companyVoiceApiMapper.deleteCompanyVoiceApiByIds(apiIds);
+        int rows = companyVoiceApiMapper.deleteCompanyVoiceApiByIds(apiIds);
+        if (apiIds != null)
+        {
+            for (Long apiId : apiIds)
+            {
+                syncMasterApiToAllTenantDbsQuietly(apiId);
+            }
+        }
+        return rows;
     }
 
     /**
@@ -123,11 +141,29 @@ public class CompanyVoiceApiServiceImpl implements ICompanyVoiceApiService
             return 0;
         }
         companyVoiceApiTenantService.disableTenantsByApiId(apiId);
-        return companyVoiceApiMapper.deleteCompanyVoiceApiById(apiId);
+        int rows = companyVoiceApiMapper.deleteCompanyVoiceApiById(apiId);
+        syncMasterApiToAllTenantDbsQuietly(apiId);
+        return rows;
     }
 
     @Override
     public Integer selectCompanyVoiceApiCount() {
         return companyVoiceApiMapper.selectCompanyVoiceApiCount();
     }
+
+    private void syncMasterApiToAllTenantDbsQuietly(Long apiId)
+    {
+        if (tenantCompanyVoiceApiSyncService == null || apiId == null)
+        {
+            return;
+        }
+        try
+        {
+            tenantCompanyVoiceApiSyncService.syncMasterApiToAllTenantDbs(apiId);
+        }
+        catch (Exception e)
+        {
+            log.warn("[TenantVoiceApiSync] 主库接口变更同步租户库失败 apiId={}", apiId, e);
+        }
+    }
 }

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 218
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceApiTenantServiceImpl.java


+ 15 - 0
fs-service/src/main/java/com/fs/company/service/tenant/ITenantCompanyVoiceApiQueryService.java

@@ -0,0 +1,15 @@
+package com.fs.company.service.tenant;
+
+import com.fs.company.domain.tenant.TenantCompanyVoiceApi;
+
+import java.util.List;
+
+/**
+ * 租户库 company_voice_api 查询(租户总后台列表/详情)
+ */
+public interface ITenantCompanyVoiceApiQueryService {
+
+    List<TenantCompanyVoiceApi> selectTenantCompanyVoiceApiList(TenantCompanyVoiceApi query);
+
+    TenantCompanyVoiceApi selectTenantCompanyVoiceApiById(Long apiId);
+}

+ 29 - 0
fs-service/src/main/java/com/fs/company/service/tenant/ITenantCompanyVoiceApiSyncService.java

@@ -0,0 +1,29 @@
+package com.fs.company.service.tenant;
+
+import java.util.List;
+
+/**
+ * 总后台主库 → 租户库 company_voice_api 同步服务(仅操作租户数据源,非主库 CRUD)
+ */
+public interface ITenantCompanyVoiceApiSyncService {
+
+    /**
+     * 将主库接口定义 + 租户绑定关系同步到指定租户库
+     */
+    void syncBindingToTenantDb(Long tenantId, Long apiId);
+
+    /**
+     * 主库接口变更后,同步到所有已绑定该接口的租户库
+     */
+    void syncMasterApiToAllTenantDbs(Long apiId);
+
+    /**
+     * 解除绑定时,将租户库对应接口状态置为禁用
+     */
+    void disableInTenantDb(Long tenantId, Long apiId);
+
+    /**
+     * 批量同步绑定(定价/状态批量变更后)
+     */
+    void syncBindingsToTenantDb(List<Long> bindingIds);
+}

+ 16 - 0
fs-service/src/main/java/com/fs/company/service/tenant/ITenantMasterSmsApiQueryService.java

@@ -0,0 +1,16 @@
+package com.fs.company.service.tenant;
+
+import com.fs.proxy.domain.CompanySmsApiTenant;
+
+import java.util.List;
+
+/**
+ * 主库 company_sms_api_tenant 查询(租户总后台短信模板绑定下拉等)
+ */
+public interface ITenantMasterSmsApiQueryService {
+
+    /**
+     * 查询当前租户已绑定的短信接口(主库)
+     */
+    List<CompanySmsApiTenant> selectBoundSmsApisByTenantId(Long tenantId);
+}

+ 26 - 0
fs-service/src/main/java/com/fs/company/service/tenant/impl/TenantCompanyVoiceApiQueryServiceImpl.java

@@ -0,0 +1,26 @@
+package com.fs.company.service.tenant.impl;
+
+import com.fs.company.domain.tenant.TenantCompanyVoiceApi;
+import com.fs.company.mapper.tenant.TenantCompanyVoiceApiMapper;
+import com.fs.company.service.tenant.ITenantCompanyVoiceApiQueryService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+public class TenantCompanyVoiceApiQueryServiceImpl implements ITenantCompanyVoiceApiQueryService {
+
+    @Autowired
+    private TenantCompanyVoiceApiMapper tenantCompanyVoiceApiMapper;
+
+    @Override
+    public List<TenantCompanyVoiceApi> selectTenantCompanyVoiceApiList(TenantCompanyVoiceApi query) {
+        return tenantCompanyVoiceApiMapper.selectTenantCompanyVoiceApiList(query);
+    }
+
+    @Override
+    public TenantCompanyVoiceApi selectTenantCompanyVoiceApiById(Long apiId) {
+        return tenantCompanyVoiceApiMapper.selectByApiId(apiId);
+    }
+}

+ 222 - 0
fs-service/src/main/java/com/fs/company/service/tenant/impl/TenantCompanyVoiceApiSyncServiceImpl.java

@@ -0,0 +1,222 @@
+package com.fs.company.service.tenant.impl;
+
+import cn.hutool.json.JSONUtil;
+import com.fs.call.config.CallApiConfig;
+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.domain.tenant.TenantCompanyVoiceApi;
+import com.fs.company.mapper.CompanyVoiceApiMapper;
+import com.fs.company.mapper.CompanyVoiceApiTenantMapper;
+import com.fs.company.mapper.tenant.TenantCompanyVoiceApiMapper;
+import com.fs.company.service.tenant.ITenantCompanyVoiceApiSyncService;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.framework.datasource.TenantDataSourceManager;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * 总后台主库外呼接口/绑定同步至租户库 company_voice_api。
+ * 内部通过 TenantDataSourceManager.ensureSwitchByTenantId 切换租户数据源。
+ */
+@Service
+public class TenantCompanyVoiceApiSyncServiceImpl implements ITenantCompanyVoiceApiSyncService {
+
+    private static final Logger log = LoggerFactory.getLogger(TenantCompanyVoiceApiSyncServiceImpl.class);
+
+    @Autowired
+    private CompanyVoiceApiMapper companyVoiceApiMapper;
+
+    @Autowired
+    private CompanyVoiceApiTenantMapper companyVoiceApiTenantMapper;
+
+    @Autowired
+    private TenantCompanyVoiceApiMapper tenantCompanyVoiceApiMapper;
+
+    @Autowired
+    private TenantDataSourceManager tenantDataSourceManager;
+
+    @Override
+    public void syncBindingToTenantDb(Long tenantId, Long apiId) {
+        if (tenantId == null || apiId == null) {
+            return;
+        }
+        ensureMasterDataSource();
+        CompanyVoiceApi masterApi = companyVoiceApiMapper.selectCompanyVoiceApiById(apiId);
+        CompanyVoiceApiTenant binding = companyVoiceApiTenantMapper.selectByApiAndTenant(apiId, tenantId);
+        if (masterApi == null) {
+            markDeletedInTenantDb(tenantId, apiId);
+            return;
+        }
+        TenantCompanyVoiceApi tenantRow = buildTenantRow(masterApi, binding);
+        runInTenantDb(tenantId, () -> tenantCompanyVoiceApiMapper.upsertTenantVoiceApi(tenantRow));
+    }
+
+    @Override
+    public void syncMasterApiToAllTenantDbs(Long apiId) {
+        if (apiId == null) {
+            return;
+        }
+        ensureMasterDataSource();
+        CompanyVoiceApi masterApi = companyVoiceApiMapper.selectCompanyVoiceApiById(apiId);
+        List<CompanyVoiceApiTenant> bindings = companyVoiceApiTenantMapper.selectTenantsByApiId(apiId);
+        if (bindings == null || bindings.isEmpty()) {
+            return;
+        }
+        if (masterApi == null) {
+            for (CompanyVoiceApiTenant binding : bindings) {
+                if (binding == null || binding.getTenantId() == null) {
+                    continue;
+                }
+                try {
+                    markDeletedInTenantDb(binding.getTenantId(), apiId);
+                } catch (Exception e) {
+                    log.error("[TenantVoiceApiSync] 主库接口已删除,同步禁用租户库失败 apiId={}, tenantId={}",
+                            apiId, binding.getTenantId(), e);
+                }
+            }
+            return;
+        }
+        for (CompanyVoiceApiTenant binding : bindings) {
+            if (binding == null || binding.getTenantId() == null) {
+                continue;
+            }
+            try {
+                TenantCompanyVoiceApi tenantRow = buildTenantRow(masterApi, binding);
+                runInTenantDb(binding.getTenantId(),
+                        () -> tenantCompanyVoiceApiMapper.upsertTenantVoiceApi(tenantRow));
+            } catch (Exception e) {
+                log.error("[TenantVoiceApiSync] 同步主库接口到租户库失败 apiId={}, tenantId={}",
+                        apiId, binding.getTenantId(), e);
+            }
+        }
+    }
+
+    @Override
+    public void disableInTenantDb(Long tenantId, Long apiId) {
+        if (tenantId == null || apiId == null) {
+            return;
+        }
+        runInTenantDb(tenantId, () -> tenantCompanyVoiceApiMapper.updateStatusByApiId(apiId, 0));
+    }
+
+    /** 主库接口已删除时,租户库标记为删除(is_del=1) */
+    private void markDeletedInTenantDb(Long tenantId, Long apiId) {
+        if (tenantId == null || apiId == null) {
+            return;
+        }
+        runInTenantDb(tenantId, () -> tenantCompanyVoiceApiMapper.markDeletedByApiId(apiId));
+    }
+
+    @Override
+    public void syncBindingsToTenantDb(List<Long> bindingIds) {
+        if (bindingIds == null || bindingIds.isEmpty()) {
+            return;
+        }
+        ensureMasterDataSource();
+        Set<String> synced = new HashSet<>();
+        for (Long bindingId : bindingIds) {
+            if (bindingId == null) {
+                continue;
+            }
+            CompanyVoiceApiTenant binding = companyVoiceApiTenantMapper.selectCompanyVoiceApiTenantById(bindingId);
+            if (binding == null || binding.getTenantId() == null || binding.getApiId() == null) {
+                continue;
+            }
+            String key = binding.getTenantId() + ":" + binding.getApiId();
+            if (!synced.add(key)) {
+                continue;
+            }
+            try {
+                syncBindingToTenantDb(binding.getTenantId(), binding.getApiId());
+            } catch (Exception e) {
+                log.error("[TenantVoiceApiSync] 批量同步绑定失败 bindingId={}", bindingId, e);
+            }
+        }
+    }
+
+    private TenantCompanyVoiceApi buildTenantRow(CompanyVoiceApi masterApi, CompanyVoiceApiTenant binding) {
+        TenantCompanyVoiceApi row = new TenantCompanyVoiceApi();
+        row.setApiId(masterApi.getApiId());
+        row.setApiName(masterApi.getApiName());
+        row.setApiType(masterApi.getApiType() != null ? String.valueOf(masterApi.getApiType()) : null);
+        row.setApiJson(buildApiJson(masterApi));
+        row.setApiUrl(masterApi.getApiUrl());
+        row.setDialogUrl(masterApi.getDialogUrl());
+        row.setRemark(masterApi.getRemark());
+        row.setIsDel(resolveIsDel(masterApi));
+        row.setStatus(resolveEffectiveStatus(masterApi, binding));
+        if (binding != null) {
+            row.setSalePrice(binding.getSalePrice());
+            row.setPriority(binding.getPriority() != null ? binding.getPriority() : 1);
+            row.setIsPrimary(binding.getIsPrimary() != null ? binding.getIsPrimary() : 0);
+            row.setSelectable(parseSelectable(binding.getSelectable()));
+        } else {
+            row.setPriority(1);
+            row.setIsPrimary(0);
+            row.setSelectable(0);
+        }
+        return row;
+    }
+
+    private int resolveIsDel(CompanyVoiceApi masterApi) {
+        if (masterApi == null || masterApi.getIsDel() == null) {
+            return 0;
+        }
+        return masterApi.getIsDel() == 1 ? 1 : 0;
+    }
+
+    private int resolveEffectiveStatus(CompanyVoiceApi masterApi, CompanyVoiceApiTenant binding) {
+        int masterStatus = masterApi.getStatus() != null && masterApi.getStatus() == 1 ? 1 : 0;
+        if (binding == null) {
+            return 0;
+        }
+        int bindingStatus = binding.getStatus() != null && binding.getStatus() == 1 ? 1 : 0;
+        return masterStatus == 1 && bindingStatus == 1 ? 1 : 0;
+    }
+
+    private String buildApiJson(CompanyVoiceApi masterApi) {
+        if (StringUtils.isNotEmpty(masterApi.getApiJson())) {
+            return masterApi.getApiJson();
+        }
+        if (StringUtils.isEmpty(masterApi.getAccount())
+                && StringUtils.isEmpty(masterApi.getPassword())
+                && StringUtils.isEmpty(masterApi.getApiUrl())) {
+            return null;
+        }
+        CallApiConfig config = new CallApiConfig();
+        config.setAccount(masterApi.getAccount());
+        config.setPassword(masterApi.getPassword());
+        config.setUrl(masterApi.getApiUrl());
+        return JSONUtil.toJsonStr(config);
+    }
+
+    private int parseSelectable(String selectable) {
+        if (selectable == null) {
+            return 0;
+        }
+        return "1".equals(selectable) || "true".equalsIgnoreCase(selectable) ? 1 : 0;
+    }
+
+    private void runInTenantDb(Long tenantId, Runnable action) {
+        ensureMasterDataSource();
+        try {
+            tenantDataSourceManager.ensureSwitchByTenantId(tenantId);
+            action.run();
+        } finally {
+            tenantDataSourceManager.clear();
+            ensureMasterDataSource();
+        }
+    }
+
+    private void ensureMasterDataSource() {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+    }
+}

+ 35 - 0
fs-service/src/main/java/com/fs/company/service/tenant/impl/TenantMasterSmsApiQueryServiceImpl.java

@@ -0,0 +1,35 @@
+package com.fs.company.service.tenant.impl;
+
+import com.fs.common.enums.DataSourceType;
+import com.fs.company.service.tenant.ITenantMasterSmsApiQueryService;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.proxy.domain.CompanySmsApiTenant;
+import com.fs.proxy.mapper.CompanySmsApiTenantMapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+import java.util.List;
+
+@Service
+public class TenantMasterSmsApiQueryServiceImpl implements ITenantMasterSmsApiQueryService {
+
+    @Autowired
+    private CompanySmsApiTenantMapper companySmsApiTenantMapper;
+
+    @Override
+    public List<CompanySmsApiTenant> selectBoundSmsApisByTenantId(Long tenantId) {
+        if (tenantId == null) {
+            return Collections.emptyList();
+        }
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        try {
+            CompanySmsApiTenant query = new CompanySmsApiTenant();
+            query.setTenantId(tenantId);
+            query.setStatus(1);
+            return companySmsApiTenantMapper.selectSmsApiTenantList(query);
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+}

+ 153 - 0
fs-service/src/main/java/com/fs/company/service/workflow/capability/LobsterNodeCapabilityRegistry.java

@@ -0,0 +1,153 @@
+package com.fs.company.service.workflow.capability;
+
+import com.fs.company.enums.LobsterNodeTypeEnum;
+
+import java.util.*;
+
+/**
+ * 节点能力成熟度注册表(与 LobsterNodeTypeEnum 对齐)
+ * 星级 1-5:1=占位 2=stub 3=部分可用 4=生产可用 5=完整
+ */
+public final class LobsterNodeCapabilityRegistry {
+
+    private LobsterNodeCapabilityRegistry() {}
+
+    public enum ImplStatus { NONE, STUB, PARTIAL, FULL }
+
+    public static final class NodeCapability {
+        public final int code;
+        public final String codeName;
+        public final String name;
+        public final int maturityStars;
+        public final ImplStatus status;
+        public final String executor; // legacy | dynamic | both
+        public final String handler;
+        public final String gapNote;
+
+        public NodeCapability(int code, String codeName, String name, int maturityStars,
+                              ImplStatus status, String executor, String handler, String gapNote) {
+            this.code = code;
+            this.codeName = codeName;
+            this.name = name;
+            this.maturityStars = maturityStars;
+            this.status = status;
+            this.executor = executor;
+            this.handler = handler;
+            this.gapNote = gapNote;
+        }
+
+        public Map<String, Object> toMap() {
+            Map<String, Object> m = new LinkedHashMap<>();
+            m.put("code", code);
+            m.put("codeName", codeName);
+            m.put("name", name);
+            m.put("maturityStars", maturityStars);
+            m.put("status", status.name());
+            m.put("executor", executor);
+            m.put("handler", handler);
+            if (gapNote != null && !gapNote.isEmpty()) m.put("gapNote", gapNote);
+            return m;
+        }
+    }
+
+    private static final Map<Integer, NodeCapability> REGISTRY = new LinkedHashMap<>();
+
+    static {
+        reg(1, "start", 5, ImplStatus.FULL, "legacy", "handleStartNode", null);
+        reg(2, "message", 5, ImplStatus.FULL, "both", "handleMessageNode+evolve", null);
+        reg(3, "judgment", 4, ImplStatus.PARTIAL, "dynamic", "handleJudgmentNode", null);
+        reg(4, "wait", 4, ImplStatus.PARTIAL, "dynamic", "handleWaitNode", null);
+        reg(5, "end", 5, ImplStatus.FULL, "dynamic", "handleEndNode", null);
+        reg(6, "promotion_end", 4, ImplStatus.PARTIAL, "dynamic", "handlePromotionEndNode", null);
+        reg(7, "order_success", 3, ImplStatus.PARTIAL, "dynamic", "handleOrderSuccessNode", "不含真实支付闭环");
+        reg(8, "order_confirm", 4, ImplStatus.PARTIAL, "dynamic", "handleOrderConfirmNode", null);
+        reg(9, "tag_operation", 4, ImplStatus.PARTIAL, "dynamic", "handleTagOperationNode", null);
+        reg(10, "care", 4, ImplStatus.FULL, "dynamic", "handleCareNode", null);
+        reg(11, "survey", 4, ImplStatus.FULL, "dynamic", "handleSurveyNode", null);
+        reg(12, "profile_update", 4, ImplStatus.PARTIAL, "dynamic", "handleUserProfileNode", null);
+        reg(13, "repurchase", 4, ImplStatus.FULL, "dynamic", "handleRepurchaseNode", null);
+        reg(14, "smart_api", 4, ImplStatus.PARTIAL, "dynamic", "handleSmartApiNode", null);
+        reg(20, "intent_recognition", 4, ImplStatus.FULL, "dynamic", "handleIntentRecognitionNode", null);
+        reg(21, "takeover_detect", 4, ImplStatus.FULL, "dynamic", "handleTakeoverDetectNode", null);
+        reg(22, "quality_check", 5, ImplStatus.FULL, "dynamic", "handleQualityCheckNode", null);
+        reg(23, "knowledge_retrieval", 4, ImplStatus.PARTIAL, "dynamic", "handleKnowledgeRetrievalNode", null);
+        reg(24, "product_recommend", 4, ImplStatus.PARTIAL, "dynamic", "handleProductRecommendNode", null);
+        reg(25, "tag_match", 4, ImplStatus.PARTIAL, "dynamic", "handleTagMatchNode", null);
+        reg(30, "qw_message", 4, ImplStatus.FULL, "dynamic", "handleQwMessageNode", null);
+        reg(31, "im_message", 4, ImplStatus.FULL, "dynamic", "handleImMessageNode", null);
+        reg(32, "timed_delay", 4, ImplStatus.PARTIAL, "dynamic", "handleTimedDelayNode", null);
+        reg(33, "ai_chat", 5, ImplStatus.FULL, "both", "handleAiChatNode+evolve", null);
+        reg(34, "sms_message", 4, ImplStatus.PARTIAL, "dynamic", "handleSmsMessageNode", null);
+        reg(35, "email_message", 3, ImplStatus.PARTIAL, "dynamic", "handleEmailMessageNode", "邮件仅落库日志");
+        reg(40, "variable_assign", 4, ImplStatus.FULL, "dynamic", "handleVariableAssignNode", null);
+        reg(41, "add_tag", 4, ImplStatus.PARTIAL, "dynamic", "handleAddTagNode", null);
+        reg(42, "webhook", 4, ImplStatus.PARTIAL, "dynamic", "handleWebhookNode", null);
+        reg(43, "sub_workflow", 4, ImplStatus.PARTIAL, "dynamic", "handleSubWorkflowNode", null);
+        reg(44, "create_task", 4, ImplStatus.PARTIAL, "dynamic", "handleCreateTaskNode", null);
+        reg(50, "sop_execute", 4, ImplStatus.PARTIAL, "dynamic", "handleSopExecuteNode", null);
+        reg(51, "cid_task", 4, ImplStatus.PARTIAL, "dynamic", "handleCidTaskNode", null);
+        reg(52, "product_push", 4, ImplStatus.PARTIAL, "dynamic", "handleProductPushNode", null);
+        reg(53, "logistics_notify", 4, ImplStatus.PARTIAL, "dynamic", "handleLogisticsNotifyNode", null);
+        reg(100, "external_api", 4, ImplStatus.PARTIAL, "dynamic", "handleExternalApiNode", null);
+        reg(200, "custom_1", 3, ImplStatus.PARTIAL, "dynamic", "handleCustomNode", "配置驱动+AI兜底");
+        reg(201, "custom_2", 3, ImplStatus.PARTIAL, "dynamic", "handleCustomNode", "配置驱动+AI兜底");
+    }
+
+    private static void reg(int code, String codeName, int stars, ImplStatus status,
+                            String executor, String handler, String gap) {
+        LobsterNodeTypeEnum e = LobsterNodeTypeEnum.fromCode(code);
+        REGISTRY.put(code, new NodeCapability(code, codeName, e.getName(), stars, status, executor, handler, gap));
+    }
+
+    public static NodeCapability get(int code) {
+        return REGISTRY.get(code);
+    }
+
+    public static List<NodeCapability> all() {
+        return new ArrayList<>(REGISTRY.values());
+    }
+
+    public static List<NodeCapability> belowStars(int minStarsExclusive) {
+        List<NodeCapability> list = new ArrayList<>();
+        for (NodeCapability c : REGISTRY.values()) {
+            if (c.maturityStars <= minStarsExclusive) list.add(c);
+        }
+        return list;
+    }
+
+    public static Set<Integer> dynamicExecutorTypes() {
+        Set<Integer> set = new HashSet<>();
+        for (NodeCapability c : REGISTRY.values()) {
+            if ("dynamic".equals(c.executor) || "both".equals(c.executor)) {
+                if (c.code != 2 && c.code != 3 && c.code != 4 && c.code != 5 && c.code != 1) {
+                    set.add(c.code);
+                }
+            }
+        }
+        return set;
+    }
+
+    public static Map<String, Object> summary() {
+        Map<String, Object> s = new LinkedHashMap<>();
+        int total = REGISTRY.size();
+        int full = 0, partial = 0, stub = 0, none = 0;
+        int starSum = 0;
+        for (NodeCapability c : REGISTRY.values()) {
+            starSum += c.maturityStars;
+            switch (c.status) {
+                case FULL: full++; break;
+                case PARTIAL: partial++; break;
+                case STUB: stub++; break;
+                default: none++;
+            }
+        }
+        s.put("totalTypes", total);
+        s.put("avgMaturityStars", String.format("%.1f", starSum * 1.0 / total));
+        s.put("fullCount", full);
+        s.put("partialCount", partial);
+        s.put("stubCount", stub);
+        s.put("noneCount", none);
+        s.put("below4Stars", belowStars(3).size());
+        return s;
+    }
+}

+ 13 - 8
fs-service/src/main/java/com/fs/company/service/workflow/channel/impl/DouyinDmMessageChannel.java

@@ -33,14 +33,19 @@ public class DouyinDmMessageChannel implements MessageChannel {
     @Override
     public MessageChannelResult sendMessage(MessageChannelRequest request) {
         try {
-            if (apiRegistryService != null && apiRegistryService.get("douyin_dm") != null) {
-                ApiRegistryService.ApiEndpoint ep = apiRegistryService.get("douyin_dm");
-                HttpHeaders headers = new HttpHeaders();
-                headers.setContentType(MediaType.APPLICATION_JSON);
-                headers.set("access-token", getAccessToken(request.getCompanyId()));
-                String body = "{\"to_user_id\":\"" + request.getChannelUserId() + "\",\"msg_type\":\"text\",\"text\":\"" + escape(request.getContent()) + "\"}";
-                HttpEntity<String> entity = new HttpEntity<>(body, headers);
-                restTemplate.postForEntity(ep.baseUrl + "/im/send/", entity, String.class);
+            if (apiRegistryService == null || apiRegistryService.get("douyin_dm") == null) {
+                log.warn("[DOUYIN_DM] 通道未配置 douyin_dm API,拒绝发送");
+                return MessageChannelResult.fail(CHANNEL_TYPE, "抖音私信通道未配置,请在 API 注册中心配置 douyin_dm");
+            }
+            ApiRegistryService.ApiEndpoint ep = apiRegistryService.get("douyin_dm");
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            headers.set("access-token", getAccessToken(request.getCompanyId()));
+            String body = "{\"to_user_id\":\"" + request.getChannelUserId() + "\",\"msg_type\":\"text\",\"text\":\"" + escape(request.getContent()) + "\"}";
+            HttpEntity<String> entity = new HttpEntity<>(body, headers);
+            ResponseEntity<String> resp = restTemplate.postForEntity(ep.baseUrl + "/im/send/", entity, String.class);
+            if (!resp.getStatusCode().is2xxSuccessful()) {
+                return MessageChannelResult.fail(CHANNEL_TYPE, "抖音发送失败: HTTP " + resp.getStatusCodeValue());
             }
             log.info("[DOUYIN_DM] 消息发送: to={}", request.getChannelUserId());
             return MessageChannelResult.ok(CHANNEL_TYPE, "dy_" + System.currentTimeMillis());

+ 14 - 11
fs-service/src/main/java/com/fs/company/service/workflow/channel/impl/TmallMessageChannel.java

@@ -35,21 +35,24 @@ public class TmallMessageChannel implements MessageChannel {
     @Override
     public MessageChannelResult sendMessage(MessageChannelRequest request) {
         try {
+            if (apiRegistryService == null || apiRegistryService.get("tmall_msg") == null) {
+                log.warn("[TMALL] 通道未配置 tmall_msg API,拒绝发送");
+                return MessageChannelResult.fail(CHANNEL_TYPE, "天猫消息通道未配置,请在 API 注册中心配置 tmall_msg");
+            }
             String cfgJson = channelPluginService.getConfigJson(request.getCompanyId(), CHANNEL_TYPE);
             String token = extractToken(cfgJson);
 
-            if (apiRegistryService != null && apiRegistryService.get("tmall_msg") != null) {
-                ApiRegistryService.ApiEndpoint ep = apiRegistryService.get("tmall_msg");
-                HttpHeaders headers = new HttpHeaders();
-                headers.setContentType(MediaType.APPLICATION_JSON);
-                headers.set("Authorization", "Bearer " + (token != null ? token : ""));
-                String body = "{\"toUser\":\"" + request.getChannelUserId() + "\",\"msgType\":\"text\",\"text\":{\"content\":\"" + escape(request.getContent()) + "\"}}";
-                HttpEntity<String> entity = new HttpEntity<>(body, headers);
-                ResponseEntity<String> resp = restTemplate.postForEntity(ep.baseUrl + "/message/send", entity, String.class);
-                log.info("[TMALL] 发送完成: httpStatus={}", resp.getStatusCode());
-            } else {
-                log.info("[TMALL][降级] 消息已记录: to={}, preview={}", request.getChannelUserId(), truncate(request.getContent()));
+            ApiRegistryService.ApiEndpoint ep = apiRegistryService.get("tmall_msg");
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            headers.set("Authorization", "Bearer " + (token != null ? token : ""));
+            String body = "{\"toUser\":\"" + request.getChannelUserId() + "\",\"msgType\":\"text\",\"text\":{\"content\":\"" + escape(request.getContent()) + "\"}}";
+            HttpEntity<String> entity = new HttpEntity<>(body, headers);
+            ResponseEntity<String> resp = restTemplate.postForEntity(ep.baseUrl + "/message/send", entity, String.class);
+            if (!resp.getStatusCode().is2xxSuccessful()) {
+                return MessageChannelResult.fail(CHANNEL_TYPE, "天猫发送失败: HTTP " + resp.getStatusCodeValue());
             }
+            log.info("[TMALL] 发送完成: httpStatus={}", resp.getStatusCode());
             return MessageChannelResult.ok(CHANNEL_TYPE, "tmall_" + System.currentTimeMillis());
         } catch (Exception e) {
             return MessageChannelResult.fail(CHANNEL_TYPE, e.getMessage());

+ 36 - 0
fs-service/src/main/java/com/fs/company/service/workflow/evolution/impl/EvolutionEngineImpl.java

@@ -115,6 +115,42 @@ public class EvolutionEngineImpl implements EvolutionEngine {
         }
     }
 
+    /**
+     * 自动应用高置信度且开启 auto_apply 的待审建议
+     */
+    public int autoApplyHighConfidenceSuggestions(Long companyId, double minConfidence) {
+        if (evolutionConfigMapper == null || companyId == null) return 0;
+        int applied = 0;
+        try {
+            List<Map<String, Object>> pending = evolutionConfigMapper.selectPendingSuggestions(companyId, minConfidence);
+            if (pending == null || pending.isEmpty()) return 0;
+            for (Map<String, Object> row : pending) {
+                Object idObj = row.get("id");
+                if (!(idObj instanceof Number)) continue;
+                Long suggestionId = ((Number) idObj).longValue();
+                Long workflowId = row.get("workflow_id") instanceof Number
+                        ? ((Number) row.get("workflow_id")).longValue() : null;
+                String nodeCode = (String) row.get("node_code");
+                if (workflowId == null || nodeCode == null) continue;
+                Map<String, Object> cfg = evolutionConfigMapper.selectConfig(companyId, workflowId, nodeCode);
+                if (!isAutoApplyEnabled(cfg)) continue;
+                applySuggestion(companyId, suggestionId);
+                applied++;
+            }
+        } catch (Exception e) {
+            log.warn("自动应用优化建议失败: companyId={}", companyId, e);
+        }
+        return applied;
+    }
+
+    private boolean isAutoApplyEnabled(Map<String, Object> cfg) {
+        if (cfg == null) return false;
+        Object autoApply = cfg.get("auto_apply");
+        if (autoApply == null) return false;
+        String v = autoApply.toString();
+        return "1".equals(v) || "true".equalsIgnoreCase(v) || "yes".equalsIgnoreCase(v);
+    }
+
     @Override
     public Map<String, Object> getEvolutionMetrics(Long companyId) {
         Map<String, Object> metrics = new HashMap<>();

+ 4 - 0
fs-service/src/main/java/com/fs/company/service/workflow/evolution/impl/EvolutionSchedulerImpl.java

@@ -46,6 +46,10 @@ public class EvolutionSchedulerImpl {
                     // 触发一次进化分析(生成优化建议)
                     evolutionEngine.analyzeAndSuggest(tenantId, null);
                     analyzed++;
+                    int autoApplied = evolutionEngine.autoApplyHighConfidenceSuggestions(tenantId, 75.0);
+                    if (autoApplied > 0) {
+                        log.info("[EvolutionScheduler] 租户 {} 自动应用 {} 条优化建议", tenantId, autoApplied);
+                    }
                     // 收集当前指标后触发用户级优化
                     Map<String, Object> metrics = evolutionEngine.getEvolutionMetrics(tenantId);
                     if (metrics.get("pendingSuggestions") instanceof Number

+ 229 - 14
fs-service/src/main/java/com/fs/company/service/workflow/impl/DynamicNodeAdjusterImpl.java

@@ -1,51 +1,266 @@
 package com.fs.company.service.workflow.impl;
 
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.company.domain.CompanyWorkflowLobsterNode;
+import com.fs.company.domain.LobsterWorkflowInstance;
+import com.fs.company.mapper.CompanyWorkflowLobsterNodeMapper;
 import com.fs.company.mapper.LobsterAuxiliaryMapper;
+import com.fs.company.mapper.LobsterWorkflowInstanceMapper;
 import com.fs.company.service.workflow.DynamicNodeAdjuster;
+import com.fs.company.service.workflow.LobsterEvolutionEngine;
+import com.fs.company.service.workflow.SemanticTakeoverDetector;
+import com.fs.company.service.workflow.semantic.SemanticAnalyzer;
+import com.fs.company.service.workflow.semantic.SemanticResult;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
+import java.util.*;
 
 @Service
 public class DynamicNodeAdjusterImpl implements DynamicNodeAdjuster {
 
     private static final Logger logger = LoggerFactory.getLogger(DynamicNodeAdjusterImpl.class);
 
+    private static final String[] HANDOFF_KEYWORDS = {
+            "投诉", "人工", "客服", "经理", "退款", "律师", "报警", "12315"
+    };
+
     @Autowired(required = false)
     private LobsterAuxiliaryMapper auxMapper;
 
+    @Autowired(required = false)
+    private SemanticAnalyzer semanticAnalyzer;
+
+    @Autowired(required = false)
+    private SemanticTakeoverDetector takeoverDetector;
+
+    @Autowired(required = false)
+    private LobsterWorkflowInstanceMapper instanceMapper;
+
+    @Autowired(required = false)
+    private CompanyWorkflowLobsterNodeMapper nodeMapper;
+
+    @Autowired(required = false)
+    private LobsterEvolutionEngine evolutionEngine;
+
     public List<Map<String, Object>> listAdjustable(Long companyId) {
         if (auxMapper == null) return new ArrayList<>();
         return auxMapper.selectDynamicImpls(companyId, null);
     }
-    // 合并后补全的 stub 实现:提供安全的默认行为,避免启动时缺少 bean。
-    // 完整动态调节逻辑可在后续补充(依赖更多 Lobster 表和 AI 服务)。
+
     @Override
-    public DynamicNodeAdjuster.AdjustmentResult adjustNode(Long instanceId, Long companyId, String externalUserId,
+    public AdjustmentResult adjustNode(Long instanceId, Long companyId, String externalUserId,
                                        String customerMessage, String currentNodeCode,
                                        Map<String, Object> variables) {
-        DynamicNodeAdjuster.AdjustmentResult r = new DynamicNodeAdjuster.AdjustmentResult();
-        r.setNextNodeCode(null);           // 不改变节点
-        r.setAdjustmentReason("stub-after-merge (no dynamic adjust)");
-        r.setTransferToHuman(false);
-        r.setDetectedIntent(null);
-        r.setDetectedSentiment(null);
-        r.setUpdatedVariables(variables);
+        AdjustmentResult r = new AdjustmentResult();
+        Map<String, Object> vars = variables != null ? new HashMap<>(variables) : new HashMap<>();
+        r.setUpdatedVariables(vars);
+
+        if (customerMessage == null || customerMessage.isBlank()) {
+            r.setAdjustmentReason("no-message");
+            return r;
+        }
+
+        SemanticResult semantic = analyze(customerMessage, vars);
+        if (semantic != null) {
+            r.setDetectedIntent(semantic.getIntent());
+            if (semantic.getSentiment() != null) {
+                r.setDetectedSentiment(String.valueOf(semantic.getSentiment()));
+            }
+            vars.put("customerIntent", semantic.getIntent());
+            if (semantic.getSentiment() != null) vars.put("customerSentiment", semantic.getSentiment());
+            if (semantic.getKeywords() != null) vars.put("customerKeywords", semantic.getKeywords());
+        }
+
+        if (shouldTransferToHuman(customerMessage, vars)) {
+            r.setTransferToHuman(true);
+            r.setNextNodeCode("human_takeover");
+            r.setAdjustmentReason("handoff-detected");
+            return r;
+        }
+
+        String routed = resolveBranchNextNode(companyId, instanceId, currentNodeCode, semantic, vars);
+        if (routed != null) {
+            r.setNextNodeCode(routed);
+            r.setAdjustmentReason("branch-route:" + (semantic != null ? semantic.getIntent() : ""));
+            return r;
+        }
+
+        if (evolutionEngine != null && semantic != null && semantic.getIntent() != null) {
+            try {
+                String dynamicNode = evolutionEngine.enrichWithDynamicNode(
+                        instanceId, companyId, externalUserId, currentNodeCode,
+                        semantic.getIntent(),
+                        r.getDetectedSentiment(),
+                        extractProfile(vars),
+                        vars);
+                if (dynamicNode != null && !dynamicNode.isEmpty()) {
+                    r.setNextNodeCode(dynamicNode);
+                    r.setAdjustmentReason("dynamic-enrich:" + semantic.getIntent());
+                }
+            } catch (Exception e) {
+                logger.debug("[DynamicNodeAdjuster] enrich skipped: {}", e.getMessage());
+            }
+        }
+
+        if (r.getAdjustmentReason() == null) {
+            r.setAdjustmentReason("semantic-analyzed");
+        }
         return r;
     }
 
     @Override
     public boolean shouldTransferToHuman(String customerMessage, Map<String, Object> variables) {
-        return false;
+        if (customerMessage == null || customerMessage.isBlank()) return false;
+
+        if (takeoverDetector != null) {
+            try {
+                String ctx = variables != null ? JSON.toJSONString(variables) : "";
+                SemanticTakeoverDetector.TakeoverResult tr = takeoverDetector.detectTakeover(
+                        variables != null && variables.get("companyId") instanceof Number
+                                ? ((Number) variables.get("companyId")).longValue() : null,
+                        customerMessage, ctx);
+                if (tr != null && tr.isShouldTakeover() && tr.getConfidence() >= 0.65) {
+                    return true;
+                }
+            } catch (Exception e) {
+                logger.debug("[DynamicNodeAdjuster] takeover detect failed: {}", e.getMessage());
+            }
+        }
+
+        String lower = customerMessage.toLowerCase(Locale.ROOT);
+        for (String kw : HANDOFF_KEYWORDS) {
+            if (lower.contains(kw.toLowerCase(Locale.ROOT))) return true;
+        }
+        Object explicit = variables != null ? variables.get("forceHandoff") : null;
+        return Boolean.TRUE.equals(explicit);
     }
 
     @Override
     public String detectIntent(String customerMessage) {
+        SemanticResult r = analyze(customerMessage, null);
+        return r != null ? r.getIntent() : null;
+    }
+
+    private SemanticResult analyze(String message, Map<String, Object> vars) {
+        if (semanticAnalyzer == null) return null;
+        try {
+            return semanticAnalyzer.analyzeIntent(message, vars != null ? vars : Collections.emptyMap());
+        } catch (Exception e) {
+            logger.debug("[DynamicNodeAdjuster] semantic analyze failed: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    private String resolveBranchNextNode(Long companyId, Long instanceId, String currentNodeCode,
+                                         SemanticResult semantic, Map<String, Object> vars) {
+        if (nodeMapper == null || companyId == null) return null;
+        Long workflowId = resolveWorkflowId(instanceId, vars);
+        if (workflowId == null) return null;
+
+        List<CompanyWorkflowLobsterNode> nodes = nodeMapper.selectByWorkflowIdAndCompanyId(workflowId, companyId);
+        if (nodes == null || nodes.isEmpty()) return null;
+
+        CompanyWorkflowLobsterNode current = findNode(nodes, currentNodeCode, instanceId);
+        if (current == null) return null;
+
+        String branchNext = matchConditionBranches(current.getConditionExpr(), semantic, vars);
+        if (branchNext != null) return branchNext;
+
+        if (semantic != null && semantic.getIntent() != null) {
+            for (CompanyWorkflowLobsterNode n : nodes) {
+                if (n.getNodeName() != null && n.getNodeName().contains(semantic.getIntent())) {
+                    return n.getNodeCode();
+                }
+                if (n.getConditionExpr() != null && n.getConditionExpr().contains(semantic.getIntent())) {
+                    return n.getNextNodeCode() != null ? n.getNextNodeCode() : n.getNodeCode();
+                }
+            }
+        }
         return null;
     }
+
+    private CompanyWorkflowLobsterNode findNode(List<CompanyWorkflowLobsterNode> nodes,
+                                                String nodeCode, Long instanceId) {
+        if (nodeCode != null) {
+            for (CompanyWorkflowLobsterNode n : nodes) {
+                if (nodeCode.equals(n.getNodeCode())) return n;
+            }
+        }
+        if (instanceMapper != null && instanceId != null) {
+            LobsterWorkflowInstance inst = instanceMapper.selectById(instanceId);
+            if (inst != null && inst.getCurrentNodeIndex() != null) {
+                int idx = inst.getCurrentNodeIndex();
+                nodes.sort(Comparator.comparingInt(x -> x.getSortNo() != null ? x.getSortNo() : 0));
+                if (idx >= 0 && idx < nodes.size()) return nodes.get(idx);
+            }
+        }
+        return null;
+    }
+
+    private String matchConditionBranches(String conditionExpr, SemanticResult semantic, Map<String, Object> vars) {
+        if (conditionExpr == null || conditionExpr.isBlank()) return null;
+        try {
+            if (conditionExpr.trim().startsWith("{")) {
+                JSONObject json = JSON.parseObject(conditionExpr);
+                JSONArray branches = json.getJSONArray("branches");
+                if (branches != null) {
+                    for (int i = 0; i < branches.size(); i++) {
+                        JSONObject b = branches.getJSONObject(i);
+                        if (b == null) continue;
+                        String cond = b.getString("condition");
+                        if (matchesCondition(cond, semantic, vars)) {
+                            return b.getString("nextNode");
+                        }
+                    }
+                }
+            }
+            if (semantic != null && semantic.getIntent() != null && conditionExpr.contains(semantic.getIntent())) {
+                return null;
+            }
+        } catch (Exception e) {
+            logger.debug("[DynamicNodeAdjuster] branch parse failed: {}", e.getMessage());
+        }
+        return null;
+    }
+
+    private boolean matchesCondition(String cond, SemanticResult semantic, Map<String, Object> vars) {
+        if (cond == null || cond.isBlank()) return false;
+        String c = cond.toLowerCase(Locale.ROOT);
+        if (semantic != null && semantic.getIntent() != null && c.contains(semantic.getIntent().toLowerCase(Locale.ROOT))) {
+            return true;
+        }
+        if (vars != null) {
+            for (Map.Entry<String, Object> e : vars.entrySet()) {
+                if (e.getValue() != null && c.contains(String.valueOf(e.getValue()).toLowerCase(Locale.ROOT))) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    private Long resolveWorkflowId(Long instanceId, Map<String, Object> vars) {
+        if (instanceMapper != null && instanceId != null) {
+            LobsterWorkflowInstance inst = instanceMapper.selectById(instanceId);
+            if (inst != null) return inst.getWorkflowId();
+        }
+        if (vars != null && vars.get("workflowId") instanceof Number) {
+            return ((Number) vars.get("workflowId")).longValue();
+        }
+        return null;
+    }
+
+    private Map<String, Object> extractProfile(Map<String, Object> vars) {
+        if (vars == null) return Collections.emptyMap();
+        Map<String, Object> profile = new LinkedHashMap<>();
+        for (String k : new String[]{"contactName", "contactPhone", "customerIntent", "customerSentiment", "budget", "interest"}) {
+            if (vars.containsKey(k)) profile.put(k, vars.get(k));
+        }
+        return profile;
+    }
 }

+ 773 - 29
fs-service/src/main/java/com/fs/company/service/workflow/impl/DynamicNodeExecutorImpl.java

@@ -4,9 +4,14 @@ import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.fs.company.service.llm.MultiModelRouter;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.company.service.workflow.ConditionEvaluator;
 import com.fs.company.service.workflow.DynamicNodeExecutor;
 import com.fs.company.service.workflow.LobsterNodeTypeService;
+import com.fs.company.service.workflow.LobsterWorkflowExecutor;
 import com.fs.company.service.workflow.QualityScoringService;
+import com.fs.company.service.workflow.ToolCallFramework;
+import com.fs.company.service.workflow.vector.VectorPatternMatcher;
 import com.fs.company.service.workflow.channel.MessageChannelRequest;
 import com.fs.company.service.workflow.channel.MessageChannelResult;
 import com.fs.company.service.workflow.channel.MessageChannelRouter;
@@ -15,6 +20,7 @@ import com.fs.company.domain.LobsterWorkflowNodeType;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
 import org.springframework.http.HttpEntity;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpMethod;
@@ -30,12 +36,6 @@ import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
 
-import javax.annotation.PostConstruct;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
-
 @Service
 public class DynamicNodeExecutorImpl implements DynamicNodeExecutor {
 
@@ -65,6 +65,19 @@ public class DynamicNodeExecutorImpl implements DynamicNodeExecutor {
     @Autowired(required = false)
     private com.fs.company.service.workflow.pay.PayService payService;
 
+    @Autowired(required = false)
+    private ConditionEvaluator conditionEvaluator;
+
+    @Autowired(required = false)
+    private VectorPatternMatcher vectorPatternMatcher;
+
+    @Autowired(required = false)
+    @Lazy
+    private LobsterWorkflowExecutor workflowExecutor;
+
+    @Autowired(required = false)
+    private ToolCallFramework toolCallFramework;
+
     private final RestTemplate restTemplate = new RestTemplate();
 
     private final ConcurrentHashMap<Integer, NodeHandler> handlers = new ConcurrentHashMap<>();
@@ -80,8 +93,9 @@ public class DynamicNodeExecutorImpl implements DynamicNodeExecutor {
         handlers.put(3, this::handleJudgmentNode);
         handlers.put(4, this::handleWaitNode);
         handlers.put(5, this::handleEndNode);
+        handlers.put(6, this::handlePromotionEndNode);
         handlers.put(7, this::handleOrderSuccessNode);
-        handlers.put(8, this::handleCouponNode);
+        handlers.put(8, this::handleOrderConfirmNode);
         handlers.put(9, this::handleTagOperationNode);
         handlers.put(10, this::handleCareNode);
         handlers.put(11, this::handleSurveyNode);
@@ -91,15 +105,27 @@ public class DynamicNodeExecutorImpl implements DynamicNodeExecutor {
         handlers.put(20, this::handleIntentRecognitionNode);
         handlers.put(21, this::handleTakeoverDetectNode);
         handlers.put(22, this::handleQualityCheckNode);
+        handlers.put(23, this::handleKnowledgeRetrievalNode);
+        handlers.put(24, this::handleProductRecommendNode);
+        handlers.put(25, this::handleTagMatchNode);
         handlers.put(30, this::handleQwMessageNode);
         handlers.put(31, this::handleImMessageNode);
+        handlers.put(32, this::handleTimedDelayNode);
+        handlers.put(33, this::handleAiChatNode);
+        handlers.put(34, this::handleSmsMessageNode);
+        handlers.put(35, this::handleEmailMessageNode);
         handlers.put(40, this::handleVariableAssignNode);
+        handlers.put(41, this::handleAddTagNode);
         handlers.put(42, this::handleWebhookNode);
+        handlers.put(43, this::handleSubWorkflowNode);
+        handlers.put(44, this::handleCreateTaskNode);
         handlers.put(50, this::handleSopExecuteNode);
         handlers.put(51, this::handleCidTaskNode);
         handlers.put(52, this::handleProductPushNode);
         handlers.put(53, this::handleLogisticsNotifyNode);
         handlers.put(100, this::handleExternalApiNode);
+        handlers.put(200, this::handleCustomNode);
+        handlers.put(201, this::handleCustomNode);
     }
 
     @Override
@@ -221,6 +247,18 @@ public class DynamicNodeExecutorImpl implements DynamicNodeExecutor {
      * 简化评分:success=基础 60,有 outputVariables +20,有 messageToSend +15,errorMessage 为空 +5
      */
     private double scoreResult(NodeExecutionResult r, ExecutionContext ctx) {
+        if (qualityScoringService != null && r.getMessageToSend() != null && ctx.getCompanyId() != null) {
+            try {
+                String userQ = ctx.getLastMessage() != null ? ctx.getLastMessage() : "";
+                QualityScoringService.DetailedScore ds = qualityScoringService.score(
+                        ctx.getCompanyId(), r.getMessageToSend(), userQ, null, null, null);
+                if (ds != null && ds.getTotalScore() > 0) {
+                    return Math.min(100, ds.getTotalScore() * 100.0 / QualityScoringService.Threshold.FULL_SCORE);
+                }
+            } catch (Exception e) {
+                logger.debug("qualityScoringService fallback: {}", e.getMessage());
+            }
+        }
         double s = r.isSuccess() ? 60 : 0;
         if (r.getOutputVariables() != null) s += 20;
         if (r.getMessageToSend() != null && !r.getMessageToSend().isEmpty()) s += 15;
@@ -384,13 +422,25 @@ public class DynamicNodeExecutorImpl implements DynamicNodeExecutor {
         try {
             JSONObject config = JSON.parseObject(nodeConfig);
             String condition = config.getString("conditionExpr");
+            if (condition == null) condition = config.getString("condition");
             String trueNext = config.getString("trueNextNode");
             String falseNext = config.getString("falseNextNode");
-            
-            boolean conditionResult = evaluateCondition(condition, context.getVariables());
-            
+            String defaultNext = config.getString("defaultNextNode");
+
             NodeExecutionResult result = NodeExecutionResult.success();
-            result.setNextNodeCode(conditionResult ? trueNext : falseNext);
+            if (conditionEvaluator != null && condition != null && !condition.isEmpty()) {
+                String nextCode = conditionEvaluator.evaluate(condition, context.getVariables(), defaultNext);
+                result.setNextNodeCode(nextCode != null ? nextCode : defaultNext);
+            } else if (conditionEvaluator != null && config.containsKey("branches")) {
+                String nextCode = conditionEvaluator.evaluateNextNode(context.getVariables(), config.toJSONString());
+                result.setNextNodeCode(nextCode);
+            } else {
+                boolean conditionResult = evaluateCondition(condition, context.getVariables());
+                result.setNextNodeCode(conditionResult ? trueNext : falseNext);
+            }
+            Map<String, Object> outputs = new HashMap<>();
+            outputs.put("judgmentResult", result.getNextNodeCode());
+            result.setOutputVariables(outputs);
             return result;
         } catch (Exception e) {
             return NodeExecutionResult.fail("判断节点处理失败: " + e.getMessage());
@@ -490,6 +540,22 @@ public class DynamicNodeExecutorImpl implements DynamicNodeExecutor {
         return NodeExecutionResult.success();
     }
 
+    /** 节点 6:促单结束 */
+    private NodeExecutionResult handlePromotionEndNode(int nodeType, String nodeConfig, ExecutionContext context) {
+        try {
+            JSONObject config = parseConfig(nodeConfig);
+            String reason = config != null ? config.getString("promotionReason") : null;
+            if (reason == null) reason = config != null ? config.getString("reason") : "促单阶段结束";
+            Map<String, Object> outputs = new HashMap<>();
+            outputs.put("promotionEndReason", reason);
+            NodeExecutionResult r = NodeExecutionResult.success(outputs);
+            r.setMessageToSend(reason);
+            return r;
+        } catch (Exception e) {
+            return NodeExecutionResult.fail("促单结束节点处理失败: " + e.getMessage());
+        }
+    }
+
     private NodeExecutionResult handleOrderSuccessNode(int nodeType, String nodeConfig, ExecutionContext context) {
         try {
             JSONObject config = parseConfig(nodeConfig);
@@ -523,7 +589,69 @@ public class DynamicNodeExecutorImpl implements DynamicNodeExecutor {
         }
     }
 
-    // ── 节点 8:优惠券发放 ──
+    /** 节点 8:订单确认(本地状态,不含真实支付) */
+    private NodeExecutionResult handleOrderConfirmNode(int nodeType, String nodeConfig, ExecutionContext context) {
+        try {
+            JSONObject config = parseConfig(nodeConfig);
+            String orderField = config != null ? config.getString("orderField") : "orderId";
+            String confirmMessage = config != null ? config.getString("confirmMessage") : null;
+            boolean applyCoupon = config != null && config.getBooleanValue("applyCoupon");
+            Map<String, Object> outputs = new HashMap<>();
+
+            Object orderId = context.getVariables() != null ? context.getVariables().get(orderField) : null;
+            if (orderId == null && auxMapper != null && context.getCompanyId() != null) {
+                try {
+                    List<Map<String, Object>> rows = auxMapper.queryForList(
+                            "SELECT order_no, product_name, amount, status FROM lobster_order WHERE company_id="
+                                    + context.getCompanyId() + " AND customer_id='" + sqlEscape(context.getCustomerId())
+                                    + "' ORDER BY create_time DESC LIMIT 1",
+                            context.getCompanyId());
+                    if (!rows.isEmpty()) {
+                        Map<String, Object> row = rows.get(0);
+                        orderId = row.get("order_no");
+                        outputs.put("orderProduct", row.get("product_name"));
+                        outputs.put("orderAmount", row.get("amount"));
+                        outputs.put("orderStatus", row.get("status"));
+                    }
+                } catch (Exception e) { logger.debug("order lookup: {}", e.getMessage()); }
+            }
+            outputs.put("orderId", orderId);
+            outputs.put("orderConfirmed", true);
+            outputs.put("confirmTime", System.currentTimeMillis());
+
+            if (auxMapper != null && orderId != null && context.getCompanyId() != null) {
+                try {
+                    auxMapper.update(String.format(
+                            "UPDATE lobster_order SET status='confirmed', update_time=NOW() WHERE company_id=%d AND order_no='%s'",
+                            context.getCompanyId(), sqlEscape(orderId)));
+                } catch (Exception e) { logger.debug("order confirm update: {}", e.getMessage()); }
+            }
+
+            if (applyCoupon && toolCallFramework != null) {
+                Map<String, Object> couponParams = new HashMap<>();
+                couponParams.put("customerId", context.getCustomerId());
+                if (config != null) {
+                    if (config.getString("couponType") != null) couponParams.put("couponType", config.getString("couponType"));
+                    if (config.getString("amount") != null) couponParams.put("amount", config.getString("amount"));
+                }
+                ToolCallFramework.ToolCallResult couponResult = toolCallFramework.executeTool(
+                        "applyCoupon", couponParams, context.getCompanyId());
+                if (couponResult != null && couponResult.isSuccess() && couponResult.getData() != null) {
+                    outputs.put("couponApplied", couponResult.getData());
+                }
+            }
+
+            String msg = confirmMessage != null ? substituteVariables(confirmMessage, context)
+                    : (orderId != null ? "您的订单 " + orderId + " 已确认,我们会尽快为您安排。" : "订单已确认,感谢您的信任!");
+            NodeExecutionResult r = NodeExecutionResult.success(outputs);
+            r.setMessageToSend(msg);
+            return r;
+        } catch (Exception e) {
+            return NodeExecutionResult.fail("订单确认节点处理失败: " + e.getMessage());
+        }
+    }
+
+    /** 优惠券发放(可通过订单确认节点 applyCoupon 或工具调用触发) */
     private NodeExecutionResult handleCouponNode(int nodeType, String nodeConfig, ExecutionContext context) {
         try {
             JSONObject config = parseConfig(nodeConfig);
@@ -872,6 +1000,429 @@ public class DynamicNodeExecutorImpl implements DynamicNodeExecutor {
         }
     }
 
+    /** 节点 23:知识库检索(向量 + SQL 双通道) */
+    private NodeExecutionResult handleKnowledgeRetrievalNode(int nodeType, String nodeConfig, ExecutionContext context) {
+        try {
+            JSONObject config = parseConfig(nodeConfig);
+            String query = context.getLastMessage();
+            if (query == null || query.isEmpty()) {
+                query = config != null ? config.getString("defaultQuery") : "";
+            }
+            String kbCode = config != null ? config.getString("knowledgeBaseCode") : "default";
+            Map<String, Object> outputs = new HashMap<>();
+            outputs.put("query", query);
+            List<String> snippets = new java.util.ArrayList<>();
+            List<Map<String, Object>> vectorHits = new java.util.ArrayList<>();
+
+            if (vectorPatternMatcher != null && query != null && !query.isEmpty() && context.getCompanyId() != null) {
+                try {
+                    List<VectorPatternMatcher.VectorMatchResult> matches = vectorPatternMatcher.searchSimilar(
+                            context.getCompanyId(), "knowledge", query, 5, 0.55);
+                    for (VectorPatternMatcher.VectorMatchResult m : matches) {
+                        if (m.getText() != null) snippets.add(m.getText());
+                        Map<String, Object> hit = new LinkedHashMap<>();
+                        hit.put("key", m.getKey());
+                        hit.put("text", m.getText());
+                        hit.put("score", m.getScore());
+                        if (m.getMetadata() != null) hit.put("metadata", m.getMetadata());
+                        vectorHits.add(hit);
+                    }
+                } catch (Exception e) { logger.debug("vector knowledge search: {}", e.getMessage()); }
+            }
+
+            if (snippets.isEmpty() && auxMapper != null && kbCode != null && context.getCompanyId() != null && query != null && !query.isEmpty()) {
+                try {
+                    List<Map<String, Object>> rows = auxMapper.queryForList(
+                            "SELECT title, content FROM lobster_knowledge_chunk WHERE company_id="
+                                    + context.getCompanyId() + " AND kb_code='" + sqlEscape(kbCode)
+                                    + "' AND content LIKE '%" + sqlEscape(query) + "%' LIMIT 5",
+                            context.getCompanyId());
+                    for (Map<String, Object> row : rows) {
+                        Object c = row.get("content");
+                        if (c != null) snippets.add(c.toString());
+                    }
+                } catch (Exception e) { logger.debug("knowledge db lookup: {}", e.getMessage()); }
+            }
+
+            outputs.put("snippets", snippets);
+            outputs.put("vectorHits", vectorHits);
+            outputs.put("retrievedCount", snippets.size());
+
+            if (!snippets.isEmpty() && multiModelRouter != null) {
+                String contextText = String.join("\n---\n", snippets.subList(0, Math.min(3, snippets.size())));
+                String prompt = "根据以下知识片段回答客户问题,简洁准确。\n知识:\n" + contextText + "\n\n问题: " + query;
+                String answer = multiModelRouter.generateResponse(prompt, null, "knowledge_retrieval");
+                outputs.put("ragAnswer", answer);
+                NodeExecutionResult r = NodeExecutionResult.success(outputs);
+                r.setMessageToSend(answer != null ? answer.trim() : contextText);
+                return r;
+            }
+            if (snippets.isEmpty() && multiModelRouter != null && query != null && !query.isEmpty()) {
+                String prompt = "根据以下客户问题检索知识并回答,输出JSON: {\"answer\":\"...\"}\n问题: " + query;
+                String aiResp = multiModelRouter.generateResponse(prompt, null, "knowledge_retrieval");
+                outputs.put("ragAnswer", aiResp);
+                NodeExecutionResult r = NodeExecutionResult.success(outputs);
+                r.setMessageToSend(aiResp);
+                return r;
+            }
+            return NodeExecutionResult.success(outputs);
+        } catch (Exception e) {
+            return NodeExecutionResult.fail("知识库检索失败: " + e.getMessage());
+        }
+    }
+
+    /** 节点 24:商品推荐(标签 + 品类) */
+    private NodeExecutionResult handleProductRecommendNode(int nodeType, String nodeConfig, ExecutionContext context) {
+        try {
+            JSONObject config = parseConfig(nodeConfig);
+            String category = config != null ? config.getString("productCategory") : null;
+            String tagField = config != null ? config.getString("tagField") : null;
+            Map<String, Object> outputs = new HashMap<>();
+            List<Map<String, Object>> products = new java.util.ArrayList<>();
+            List<String> matchTags = new java.util.ArrayList<>();
+
+            if (auxMapper != null && context.getCompanyId() != null) {
+                if (tagField != null && context.getVariables() != null && context.getVariables().get(tagField) != null) {
+                    Object tv = context.getVariables().get(tagField);
+                    matchTags.add(tv.toString());
+                } else {
+                    try {
+                        List<Map<String, Object>> tagRows = auxMapper.queryForList(
+                                "SELECT tag_key, tag_value FROM customer_tag WHERE company_id="
+                                        + context.getCompanyId() + " AND external_user_id='" + sqlEscape(context.getCustomerId()) + "' LIMIT 10",
+                                context.getCompanyId());
+                        for (Map<String, Object> tr : tagRows) {
+                            Object v = tr.get("tag_value");
+                            if (v != null) matchTags.add(v.toString());
+                        }
+                    } catch (Exception e) { logger.debug("tag load for recommend: {}", e.getMessage()); }
+                }
+                try {
+                    String sql = "SELECT id, product_name, product_url, price, tags FROM lobster_product WHERE company_id="
+                            + context.getCompanyId();
+                    if (category != null && !category.isEmpty()) {
+                        sql += " AND (product_name LIKE '%" + sqlEscape(category) + "%' OR tags LIKE '%" + sqlEscape(category) + "%')";
+                    }
+                    sql += " ORDER BY update_time DESC LIMIT 20";
+                    List<Map<String, Object>> candidates = auxMapper.queryForList(sql, context.getCompanyId());
+                    if (!matchTags.isEmpty()) {
+                        for (Map<String, Object> p : candidates) {
+                            String tags = p.get("tags") != null ? p.get("tags").toString() : "";
+                            String name = p.get("product_name") != null ? p.get("product_name").toString() : "";
+                            for (String mt : matchTags) {
+                                if ((!tags.isEmpty() && tags.contains(mt)) || name.contains(mt)) {
+                                    products.add(p);
+                                    break;
+                                }
+                            }
+                            if (products.size() >= 3) break;
+                        }
+                    }
+                    if (products.isEmpty()) {
+                        products = candidates.size() > 3 ? candidates.subList(0, 3) : candidates;
+                    }
+                } catch (Exception e) { logger.debug("product recommend: {}", e.getMessage()); }
+            }
+            outputs.put("products", products);
+            outputs.put("matchTags", matchTags);
+            NodeExecutionResult r = NodeExecutionResult.success(outputs);
+            if (!products.isEmpty()) {
+                Map<String, Object> p = products.get(0);
+                String name = p.get("product_name") != null ? p.get("product_name").toString() : "精选商品";
+                String url = p.get("product_url") != null ? p.get("product_url").toString() : "";
+                r.setMessageToSend("为您推荐:" + name + (url.isEmpty() ? "" : "\n" + url));
+            }
+            return r;
+        } catch (Exception e) {
+            return NodeExecutionResult.fail("商品推荐失败: " + e.getMessage());
+        }
+    }
+
+    /** 节点 25:标签匹配分支 */
+    private NodeExecutionResult handleTagMatchNode(int nodeType, String nodeConfig, ExecutionContext context) {
+        try {
+            JSONObject config = parseConfig(nodeConfig);
+            String requiredTags = config != null ? config.getString("requiredTags") : null;
+            String matchMode = config != null ? config.getString("matchMode") : "any";
+            String trueNext = config != null ? config.getString("trueNextNode") : null;
+            String falseNext = config != null ? config.getString("falseNextNode") : null;
+            java.util.Set<String> required = new java.util.LinkedHashSet<>();
+            if (requiredTags != null && !requiredTags.isEmpty()) {
+                for (String t : requiredTags.split("[,;|]")) {
+                    if (!t.trim().isEmpty()) required.add(t.trim());
+                }
+            }
+            java.util.Set<String> owned = new java.util.LinkedHashSet<>();
+            if (auxMapper != null && context.getCompanyId() != null) {
+                try {
+                    List<Map<String, Object>> tagRows = auxMapper.queryForList(
+                            "SELECT tag_key, tag_value FROM customer_tag WHERE company_id="
+                                    + context.getCompanyId() + " AND external_user_id='" + sqlEscape(context.getCustomerId()) + "'",
+                            context.getCompanyId());
+                    for (Map<String, Object> tr : tagRows) {
+                        if (tr.get("tag_key") != null) owned.add(tr.get("tag_key").toString());
+                        if (tr.get("tag_value") != null) owned.add(tr.get("tag_value").toString());
+                    }
+                } catch (Exception e) { logger.debug("tag match load: {}", e.getMessage()); }
+            }
+            if (context.getVariables() != null) {
+                for (Map.Entry<String, Object> e : context.getVariables().entrySet()) {
+                    if (e.getKey() != null && e.getKey().startsWith("tag_") && e.getValue() != null) {
+                        owned.add(e.getValue().toString());
+                    }
+                }
+            }
+            boolean matched;
+            if (required.isEmpty()) {
+                matched = !owned.isEmpty();
+            } else if ("all".equalsIgnoreCase(matchMode)) {
+                matched = owned.containsAll(required);
+            } else {
+                matched = false;
+                for (String r : required) {
+                    if (owned.contains(r)) { matched = true; break; }
+                }
+            }
+            Map<String, Object> outputs = new HashMap<>();
+            outputs.put("tagMatched", matched);
+            outputs.put("ownedTags", new java.util.ArrayList<>(owned));
+            outputs.put("requiredTags", new java.util.ArrayList<>(required));
+            NodeExecutionResult result = NodeExecutionResult.success(outputs);
+            result.setNextNodeCode(matched ? trueNext : falseNext);
+            return result;
+        } catch (Exception e) {
+            return NodeExecutionResult.fail("标签匹配节点失败: " + e.getMessage());
+        }
+    }
+
+    /** 节点 32:定时延迟(独立配置项) */
+    private NodeExecutionResult handleTimedDelayNode(int nodeType, String nodeConfig, ExecutionContext context) {
+        try {
+            JSONObject config = parseConfig(nodeConfig);
+            long delaySeconds = config != null ? config.getLongValue("delaySeconds") : 0;
+            if (delaySeconds <= 0 && config != null) delaySeconds = config.getLongValue("waitSeconds");
+            if (delaySeconds <= 0) delaySeconds = 60;
+            String scheduleAt = config != null ? config.getString("scheduleAt") : null;
+            Map<String, Object> outputs = new HashMap<>();
+            long now = System.currentTimeMillis();
+            long waitUntil = scheduleAt != null ? parseScheduleAt(scheduleAt) : now + delaySeconds * 1000L;
+            outputs.put("waitType", "timed_delay");
+            outputs.put("delaySeconds", delaySeconds);
+            outputs.put("waitUntil", waitUntil);
+            outputs.put("scheduledAt", waitUntil);
+            return NodeExecutionResult.success(outputs);
+        } catch (Exception e) {
+            return handleWaitNode(nodeType, nodeConfig, context);
+        }
+    }
+
+    private long parseScheduleAt(String scheduleAt) {
+        try {
+            java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+            return sdf.parse(scheduleAt).getTime();
+        } catch (Exception e) {
+            return System.currentTimeMillis() + 3600_000L;
+        }
+    }
+
+    /** 节点 200/201:自定义节点(配置驱动 + AI 兜底) */
+    private NodeExecutionResult handleCustomNode(int nodeType, String nodeConfig, ExecutionContext context) {
+        try {
+            JSONObject config = parseConfig(nodeConfig);
+            String action = config != null ? config.getString("actionType") : "ai";
+            Map<String, Object> outputs = new HashMap<>();
+            if ("assign".equals(action) && config.getJSONObject("assignments") != null) {
+                JSONObject assignments = config.getJSONObject("assignments");
+                for (String k : assignments.keySet()) {
+                    outputs.put(k, substituteVariables(assignments.getString(k), context));
+                }
+                NodeExecutionResult r = NodeExecutionResult.success(outputs);
+                if (config.getString("messageTemplate") != null) {
+                    r.setMessageToSend(substituteVariables(config.getString("messageTemplate"), context));
+                }
+                return r;
+            }
+            if ("webhook".equals(action) && config.getString("url") != null) {
+                return handleWebhookNode(nodeType, nodeConfig, context);
+            }
+            String prompt = config != null && config.getString("prompt") != null
+                    ? substituteVariables(config.getString("prompt"), context)
+                    : "执行自定义节点(type=" + nodeType + "),上下文: " + JSON.toJSONString(context.getVariables());
+            String reply = multiModelRouter != null
+                    ? multiModelRouter.generateResponse(prompt, config != null ? config.getString("model") : null, "custom_node")
+                    : "自定义节点已执行";
+            outputs.put("customNodeType", nodeType);
+            NodeExecutionResult r = NodeExecutionResult.success(outputs);
+            r.setMessageToSend(reply != null ? reply.trim() : "");
+            return r;
+        } catch (Exception e) {
+            return NodeExecutionResult.fail("自定义节点失败: " + e.getMessage());
+        }
+    }
+
+    /** 节点 33:AI 对话 */
+    private NodeExecutionResult handleAiChatNode(int nodeType, String nodeConfig, ExecutionContext context) {
+        try {
+            JSONObject config = parseConfig(nodeConfig);
+            String systemPrompt = config != null ? config.getString("systemPrompt") : "你是一位专业销售顾问";
+            String userMsg = context.getLastMessage() != null ? context.getLastMessage() : "";
+            String prompt = systemPrompt + "\n客户: " + userMsg + "\n请给出简洁回复(仅输出回复文本)";
+            String reply = multiModelRouter != null
+                    ? multiModelRouter.generateResponse(prompt, config != null ? config.getString("model") : null, "ai_chat")
+                    : "您好,有什么可以帮您?";
+            Map<String, Object> outputs = new HashMap<>();
+            outputs.put("aiChat", true);
+            NodeExecutionResult r = NodeExecutionResult.success(outputs);
+            r.setMessageToSend(reply != null ? reply.trim() : "");
+            return r;
+        } catch (Exception e) {
+            return NodeExecutionResult.fail("AI对话节点失败: " + e.getMessage());
+        }
+    }
+
+    /** 节点 34:短信 */
+    private NodeExecutionResult handleSmsMessageNode(int nodeType, String nodeConfig, ExecutionContext context) {
+        try {
+            JSONObject config = parseConfig(nodeConfig);
+            String message = config != null ? config.getString("messageTemplate") : "";
+            message = substituteVariables(message, context);
+            String phone = config != null ? config.getString("phone") : null;
+            if (phone == null && context.getVariables() != null && context.getVariables().get("phone") != null) {
+                phone = context.getVariables().get("phone").toString();
+            }
+            MessageChannelRequest request = new MessageChannelRequest();
+            request.setCompanyId(context.getCompanyId());
+            request.setContactId(context.getCustomerId());
+            request.setContent(message);
+            request.setChannelType("sms");
+            request.setExtra(context.getVariables());
+            if (phone != null) {
+                Map<String, Object> extra = request.getExtra() != null ? new HashMap<>(request.getExtra()) : new HashMap<>();
+                extra.put("phone", phone);
+                request.setExtra(extra);
+            }
+            MessageChannelResult channelResult = messageChannelRouter.route(request);
+            NodeExecutionResult result = new NodeExecutionResult();
+            result.setSuccess(channelResult.isSuccess());
+            result.setMessageToSend(message);
+            if (!channelResult.isSuccess()) result.setErrorMessage(channelResult.getErrorMsg());
+            return result;
+        } catch (Exception e) {
+            return NodeExecutionResult.fail("短信节点失败: " + e.getMessage());
+        }
+    }
+
+    /** 节点 35:邮件 */
+    private NodeExecutionResult handleEmailMessageNode(int nodeType, String nodeConfig, ExecutionContext context) {
+        try {
+            JSONObject config = parseConfig(nodeConfig);
+            String subject = config != null ? config.getString("subject") : "通知";
+            String body = config != null ? config.getString("bodyTemplate") : "";
+            body = substituteVariables(body, context);
+            Map<String, Object> outputs = new HashMap<>();
+            outputs.put("emailSubject", subject);
+            outputs.put("emailSent", false);
+            if (auxMapper != null && context.getCompanyId() != null) {
+                try {
+                    auxMapper.update(String.format(
+                            "INSERT INTO lobster_email_log(company_id, customer_id, subject, body, create_time) " +
+                            "VALUES(%d, '%s', '%s', '%s', NOW())",
+                            context.getCompanyId(),
+                            sqlEscape(context.getCustomerId()),
+                            sqlEscape(subject), sqlEscape(body)));
+                    outputs.put("emailSent", true);
+                } catch (Exception e) { logger.debug("email log: {}", e.getMessage()); }
+            }
+            NodeExecutionResult r = NodeExecutionResult.success(outputs);
+            r.setMessageToSend(body);
+            return r;
+        } catch (Exception e) {
+            return NodeExecutionResult.fail("邮件节点失败: " + e.getMessage());
+        }
+    }
+
+    /** 节点 41:打标签(与 9 类似,独立入口) */
+    private NodeExecutionResult handleAddTagNode(int nodeType, String nodeConfig, ExecutionContext context) {
+        return handleTagOperationNode(nodeType, nodeConfig, context);
+    }
+
+    /** 节点 43:子流程(启动子工作流实例) */
+    private NodeExecutionResult handleSubWorkflowNode(int nodeType, String nodeConfig, ExecutionContext context) {
+        try {
+            JSONObject config = parseConfig(nodeConfig);
+            Long subWorkflowId = config != null && config.get("subWorkflowId") != null
+                    ? config.getLong("subWorkflowId") : null;
+            String resultVar = config != null && config.getString("resultVar") != null
+                    ? config.getString("resultVar") : "subResult";
+            Map<String, Object> outputs = new HashMap<>();
+            outputs.put("subWorkflowId", subWorkflowId);
+
+            if (auxMapper != null && context.getCompanyId() != null) {
+                String subIdSql = subWorkflowId != null ? subWorkflowId.toString() : "NULL";
+                auxMapper.update(String.format(
+                        "INSERT INTO lobster_sub_workflow_exec(company_id, parent_instance_id, sub_workflow_id, status, create_time) " +
+                        "VALUES(%d, %d, %s, 'triggered', NOW())",
+                        context.getCompanyId(),
+                        context.getWorkflowInstanceId() != null ? context.getWorkflowInstanceId() : 0,
+                        subIdSql));
+            }
+
+            if (workflowExecutor != null && subWorkflowId != null && context.getCompanyId() != null) {
+                Map<String, Object> subVars = context.getVariables() != null
+                        ? new HashMap<>(context.getVariables()) : new HashMap<>();
+                subVars.put("parentInstanceId", context.getWorkflowInstanceId());
+                subVars.put("channelType", context.getChannelType());
+                AjaxResult subResult = workflowExecutor.startWorkflow(
+                        context.getCompanyId(), subWorkflowId, context.getCustomerId(), subVars);
+                if (subResult != null && Integer.valueOf(200).equals(subResult.get("code"))) {
+                    Object data = subResult.get("data");
+                    if (data instanceof com.fs.company.domain.LobsterWorkflowInstance) {
+                        com.fs.company.domain.LobsterWorkflowInstance subInstance =
+                                (com.fs.company.domain.LobsterWorkflowInstance) data;
+                        outputs.put("subInstanceId", subInstance.getId());
+                        outputs.put(resultVar, "sub_instance_" + subInstance.getId());
+                    } else {
+                        outputs.put(resultVar, "sub_started");
+                    }
+                    outputs.put("subWorkflowTriggered", true);
+                } else {
+                    outputs.put("subWorkflowTriggered", false);
+                    outputs.put(resultVar, "sub_failed");
+                }
+            } else {
+                outputs.put("subWorkflowTriggered", subWorkflowId != null);
+            }
+            return NodeExecutionResult.success(outputs);
+        } catch (Exception e) {
+            return NodeExecutionResult.fail("子流程节点失败: " + e.getMessage());
+        }
+    }
+
+    /** 节点 44:创建任务 */
+    private NodeExecutionResult handleCreateTaskNode(int nodeType, String nodeConfig, ExecutionContext context) {
+        try {
+            JSONObject config = parseConfig(nodeConfig);
+            String taskTitle = config != null ? config.getString("taskTitle") : "跟进任务";
+            String taskContent = config != null ? config.getString("taskContent") : "";
+            taskContent = substituteVariables(taskContent, context);
+            Map<String, Object> outputs = new HashMap<>();
+            outputs.put("taskTitle", taskTitle);
+            if (auxMapper != null && context.getCompanyId() != null) {
+                auxMapper.update(String.format(
+                        "INSERT INTO lobster_task(company_id, instance_id, customer_id, task_title, task_content, status, create_time) " +
+                        "VALUES(%d, %d, '%s', '%s', '%s', 'pending', NOW())",
+                        context.getCompanyId(),
+                        context.getWorkflowInstanceId() != null ? context.getWorkflowInstanceId() : 0,
+                        sqlEscape(context.getCustomerId()),
+                        sqlEscape(taskTitle), sqlEscape(taskContent)));
+                outputs.put("taskCreated", true);
+            }
+            return NodeExecutionResult.success(outputs);
+        } catch (Exception e) {
+            return NodeExecutionResult.fail("创建任务失败: " + e.getMessage());
+        }
+    }
+
     /** 安全解析 config,兼容 null 和空字符串 */
     private JSONObject parseConfig(String cfg) {
         if (cfg == null || cfg.isEmpty() || "{}".equals(cfg)) return new JSONObject();
@@ -898,39 +1449,232 @@ public class DynamicNodeExecutorImpl implements DynamicNodeExecutor {
     }
 
     private NodeExecutionResult handleWebhookNode(int nodeType, String nodeConfig, ExecutionContext context) {
-        Map<String, Object> outputs = new HashMap<>();
-        outputs.put("webhookCalled", true);
-        return NodeExecutionResult.success(outputs);
+        try {
+            JSONObject config = parseConfig(nodeConfig);
+            Map<String, Object> outputs = invokeHttpFromConfig(config, context, "webhookUrl", "url");
+            outputs.put("webhookCalled", true);
+            return NodeExecutionResult.success(outputs);
+        } catch (Exception e) {
+            return NodeExecutionResult.fail("Webhook调用失败: " + e.getMessage());
+        }
     }
 
     private NodeExecutionResult handleSopExecuteNode(int nodeType, String nodeConfig, ExecutionContext context) {
-        Map<String, Object> outputs = new HashMap<>();
-        outputs.put("sopExecuted", true);
-        return NodeExecutionResult.success(outputs);
+        try {
+            JSONObject config = parseConfig(nodeConfig);
+            Map<String, Object> outputs = new HashMap<>();
+            String sopId = config != null ? config.getString("sopId") : null;
+            String sopName = config != null ? config.getString("sopName") : "default_sop";
+            outputs.put("sopId", sopId);
+            outputs.put("sopName", sopName);
+            if (config != null && (config.containsKey("webhookUrl") || config.containsKey("url"))) {
+                outputs.putAll(invokeHttpFromConfig(config, context, "webhookUrl", "url"));
+            } else if (auxMapper != null && context.getCompanyId() != null) {
+                auxMapper.update(String.format(
+                        "INSERT INTO lobster_sop_execution(company_id, instance_id, sop_id, sop_name, status, create_time) " +
+                        "VALUES(%d, %d, '%s', '%s', 'triggered', NOW())",
+                        context.getCompanyId(),
+                        context.getWorkflowInstanceId() != null ? context.getWorkflowInstanceId() : 0,
+                        sqlEscape(sopId != null ? sopId : ""),
+                        sqlEscape(sopName)));
+                outputs.put("sopExecuted", true);
+            }
+            return NodeExecutionResult.success(outputs);
+        } catch (Exception e) {
+            return NodeExecutionResult.fail("SOP执行失败: " + e.getMessage());
+        }
     }
 
     private NodeExecutionResult handleCidTaskNode(int nodeType, String nodeConfig, ExecutionContext context) {
-        Map<String, Object> outputs = new HashMap<>();
-        outputs.put("cidTaskCreated", true);
-        return NodeExecutionResult.success(outputs);
+        try {
+            JSONObject config = parseConfig(nodeConfig);
+            Map<String, Object> outputs = new HashMap<>();
+            String taskTemplate = config != null ? config.getString("taskTemplate") : null;
+            String taskName = config != null ? config.getString("taskName") : "cid_task";
+            outputs.put("taskTemplate", taskTemplate);
+            outputs.put("taskName", taskName);
+            if (config != null && (config.containsKey("apiUrl") || config.containsKey("url"))) {
+                outputs.putAll(invokeHttpFromConfig(config, context, "apiUrl", "url"));
+            } else if (auxMapper != null && context.getCompanyId() != null) {
+                auxMapper.update(String.format(
+                        "INSERT INTO lobster_cid_task(company_id, instance_id, customer_id, task_template, task_name, status, create_time) " +
+                        "VALUES(%d, %d, '%s', '%s', '%s', 'created', NOW())",
+                        context.getCompanyId(),
+                        context.getWorkflowInstanceId() != null ? context.getWorkflowInstanceId() : 0,
+                        sqlEscape(context.getCustomerId() != null ? String.valueOf(context.getCustomerId()) : ""),
+                        sqlEscape(taskTemplate != null ? taskTemplate : ""),
+                        sqlEscape(taskName)));
+                outputs.put("cidTaskCreated", true);
+            }
+            return NodeExecutionResult.success(outputs);
+        } catch (Exception e) {
+            return NodeExecutionResult.fail("CID任务创建失败: " + e.getMessage());
+        }
     }
 
     private NodeExecutionResult handleProductPushNode(int nodeType, String nodeConfig, ExecutionContext context) {
-        NodeExecutionResult result = NodeExecutionResult.success();
-        result.setMessageToSend("推荐商品链接");
-        return result;
+        try {
+            JSONObject config = parseConfig(nodeConfig);
+            String productName = config != null ? config.getString("productName") : null;
+            String productUrl = config != null ? config.getString("productUrl") : null;
+            String productId = config != null ? config.getString("productId") : null;
+            Map<String, Object> outputs = new HashMap<>();
+            if (productId != null && auxMapper != null && context.getCompanyId() != null) {
+                try {
+                    long pid = Long.parseLong(productId.replaceAll("[^0-9]", ""));
+                    List<Map<String, Object>> products = auxMapper.queryForList(
+                            "SELECT product_name, product_url, price FROM lobster_product WHERE id="
+                                    + pid + " AND company_id=" + context.getCompanyId(),
+                            context.getCompanyId());
+                    if (!products.isEmpty()) {
+                        Map<String, Object> p = products.get(0);
+                        productName = p.get("product_name") != null ? p.get("product_name").toString() : productName;
+                        productUrl = p.get("product_url") != null ? p.get("product_url").toString() : productUrl;
+                        outputs.put("price", p.get("price"));
+                    }
+                } catch (Exception e) { logger.debug("product lookup: {}", e.getMessage()); }
+            }
+            if (productName == null) productName = "精选商品";
+            if (productUrl == null) productUrl = config != null ? config.getString("fallbackUrl") : "";
+            outputs.put("productName", productName);
+            outputs.put("productUrl", productUrl);
+            String msg = "为您推荐:" + productName;
+            if (productUrl != null && !productUrl.isEmpty()) msg += "\n" + productUrl;
+            NodeExecutionResult result = NodeExecutionResult.success(outputs);
+            result.setMessageToSend(msg);
+            return result;
+        } catch (Exception e) {
+            return NodeExecutionResult.fail("商品推送失败: " + e.getMessage());
+        }
     }
 
     private NodeExecutionResult handleLogisticsNotifyNode(int nodeType, String nodeConfig, ExecutionContext context) {
-        NodeExecutionResult result = NodeExecutionResult.success();
-        result.setMessageToSend("您的订单已发货,物流单号:1234567890");
-        return result;
+        try {
+            JSONObject config = parseConfig(nodeConfig);
+            Map<String, Object> outputs = new HashMap<>();
+            String trackingNo = config != null ? config.getString("trackingNo") : null;
+            String carrier = config != null ? config.getString("carrier") : "快递";
+            if (trackingNo == null && context.getVariables() != null) {
+                Object v = context.getVariables().get("trackingNo");
+                if (v == null) v = context.getVariables().get("logisticsNo");
+                if (v != null) trackingNo = v.toString();
+            }
+            if (trackingNo == null && auxMapper != null && context.getCustomerId() != null) {
+                try {
+                    List<Map<String, Object>> orders = auxMapper.queryForList(
+                            "SELECT tracking_no, carrier FROM customer_order WHERE customer_id='"
+                                    + sqlEscape(String.valueOf(context.getCustomerId())) + "' AND company_id="
+                                    + context.getCompanyId() + " ORDER BY order_time DESC LIMIT 1",
+                            context.getCompanyId());
+                    if (!orders.isEmpty()) {
+                        trackingNo = orders.get(0).get("tracking_no") != null
+                                ? orders.get(0).get("tracking_no").toString() : trackingNo;
+                        if (orders.get(0).get("carrier") != null) {
+                            carrier = orders.get(0).get("carrier").toString();
+                        }
+                    }
+                } catch (Exception e) { logger.debug("logistics lookup: {}", e.getMessage()); }
+            }
+            if (trackingNo == null) trackingNo = "待更新";
+            outputs.put("trackingNo", trackingNo);
+            outputs.put("carrier", carrier);
+            NodeExecutionResult result = NodeExecutionResult.success(outputs);
+            result.setMessageToSend("您的订单已由" + carrier + "发出,物流单号:" + trackingNo);
+            return result;
+        } catch (Exception e) {
+            return NodeExecutionResult.fail("物流通知失败: " + e.getMessage());
+        }
     }
 
     private NodeExecutionResult handleExternalApiNode(int nodeType, String nodeConfig, ExecutionContext context) {
+        try {
+            JSONObject config = parseConfig(nodeConfig);
+            String apiCode = config != null ? config.getString("apiCode") : null;
+            Map<String, Object> outputs = new HashMap<>();
+            if (apiCode != null && smartApiMapper != null) {
+                Map<String, Object> api = smartApiMapper.selectByCode(apiCode);
+                if (api != null) {
+                    JSONObject apiConfig = new JSONObject();
+                    apiConfig.put("url", api.get("api_url"));
+                    apiConfig.put("method", api.get("api_method"));
+                    apiConfig.put("headers", api.get("headers_json"));
+                    apiConfig.put("body", api.get("body_template"));
+                    outputs.putAll(invokeHttpFromConfig(apiConfig, context, "url", "apiUrl"));
+                    outputs.put("apiCode", apiCode);
+                    outputs.put("apiCalled", true);
+                    return NodeExecutionResult.success(outputs);
+                }
+            }
+            outputs.putAll(invokeHttpFromConfig(config, context, "url", "apiUrl"));
+            outputs.put("apiCalled", true);
+            return NodeExecutionResult.success(outputs);
+        } catch (Exception e) {
+            return NodeExecutionResult.fail("外部API调用失败: " + e.getMessage());
+        }
+    }
+
+    /** 从节点配置发起 HTTP 调用,支持变量替换 */
+    private Map<String, Object> invokeHttpFromConfig(JSONObject config, ExecutionContext context,
+                                                      String... urlKeys) throws Exception {
         Map<String, Object> outputs = new HashMap<>();
-        outputs.put("apiCalled", true);
-        return NodeExecutionResult.success(outputs);
+        if (config == null) return outputs;
+        String url = null;
+        for (String key : urlKeys) {
+            url = config.getString(key);
+            if (url != null && !url.isEmpty()) break;
+        }
+        if (url == null || url.isEmpty()) return outputs;
+        url = substituteVariables(url, context);
+        String method = config.getString("method");
+        if (method == null) method = config.getString("apiMethod");
+        if (method == null) method = "POST";
+
+        HttpHeaders httpHeaders = new HttpHeaders();
+        httpHeaders.setContentType(MediaType.APPLICATION_JSON);
+        Object headersObj = config.get("headers");
+        if (headersObj instanceof String && !((String) headersObj).isEmpty()) {
+            JSONObject hdr = JSON.parseObject((String) headersObj);
+            for (String key : hdr.keySet()) {
+                httpHeaders.set(key, substituteVariables(hdr.getString(key), context));
+            }
+        } else if (headersObj instanceof JSONObject) {
+            JSONObject hdr = (JSONObject) headersObj;
+            for (String key : hdr.keySet()) {
+                httpHeaders.set(key, substituteVariables(hdr.getString(key), context));
+            }
+        }
+
+        String body = config.getString("body");
+        if (body == null) body = config.getString("bodyTemplate");
+        if (body == null) body = config.getString("payload");
+        if (body == null) body = "{}";
+        body = substituteVariables(body, context);
+
+        HttpEntity<String> entity = new HttpEntity<>(body, httpHeaders);
+        HttpMethod httpMethod = "GET".equalsIgnoreCase(method) ? HttpMethod.GET : HttpMethod.POST;
+        ResponseEntity<String> resp = restTemplate.exchange(url, httpMethod, entity, String.class);
+        outputs.put("httpStatus", resp.getStatusCodeValue());
+        outputs.put("responseBody", resp.getBody());
+        outputs.put("requestUrl", url);
+        return outputs;
+    }
+
+    private String substituteVariables(String text, ExecutionContext context) {
+        if (text == null) return null;
+        String result = text;
+        if (context.getVariables() != null) {
+            for (Map.Entry<String, Object> entry : context.getVariables().entrySet()) {
+                result = result.replace("${" + entry.getKey() + "}",
+                        entry.getValue() != null ? entry.getValue().toString() : "");
+            }
+        }
+        if (context.getCustomerId() != null) {
+            result = result.replace("${customerId}", String.valueOf(context.getCustomerId()));
+        }
+        if (context.getLastMessage() != null) {
+            result = result.replace("${lastMessage}", context.getLastMessage());
+        }
+        return result;
     }
 
     private boolean evaluateCondition(String condition, Map<String, Object> variables) {

+ 556 - 19
fs-service/src/main/java/com/fs/company/service/workflow/impl/LobsterE2eTestServiceImpl.java

@@ -1,44 +1,581 @@
 package com.fs.company.service.workflow.impl;
 
-import com.fs.company.mapper.LobsterAuxiliaryMapper;
+import com.alibaba.fastjson.JSON;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.fs.common.constant.HttpStatus;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.company.domain.CompanyWorkflowLobsterNode;
+import com.fs.company.domain.LobsterE2eRun;
+import com.fs.company.domain.LobsterE2eRunNode;
+import com.fs.company.domain.LobsterWorkflowInstance;
+import com.fs.company.mapper.CompanyWorkflowLobsterNodeMapper;
+import com.fs.company.mapper.LobsterE2eRunMapper;
+import com.fs.company.mapper.LobsterE2eRunNodeMapper;
+import com.fs.company.mapper.LobsterTestScenarioMapper;
+import com.fs.company.mapper.LobsterWorkflowInstanceMapper;
+import com.fs.company.service.workflow.LobsterE2eTestService;
+import com.fs.company.service.workflow.LobsterEvolutionEngine;
+import com.fs.company.service.workflow.LobsterWorkflowExecutor;
+import com.fs.company.service.workflow.QualityScoringService;
+import com.fs.company.service.workflow.evolution.EvolutionEngine;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
 import java.util.*;
 
 @Service
-public class LobsterE2eTestServiceImpl {
+public class LobsterE2eTestServiceImpl implements LobsterE2eTestService {
 
     private static final Logger logger = LoggerFactory.getLogger(LobsterE2eTestServiceImpl.class);
+    private static final double PASS_SCORE = 60.0;
+    private static final int RAW_FULL_SCORE = QualityScoringService.Threshold.FULL_SCORE;
 
     @Autowired(required = false)
-    private LobsterAuxiliaryMapper auxMapper;
+    private LobsterE2eRunMapper e2eRunMapper;
 
-    public Long createTest(Long companyId, String testName, Long workflowId, String testData) {
-        if (auxMapper == null) return null;
-        auxMapper.insertE2eTest(companyId, testName, workflowId, testData);
-        return auxMapper.selectLastInsertId();
+    @Autowired(required = false)
+    private LobsterE2eRunNodeMapper e2eRunNodeMapper;
+
+    @Autowired(required = false)
+    private LobsterTestScenarioMapper testScenarioMapper;
+
+    @Autowired(required = false)
+    private LobsterWorkflowExecutor workflowExecutor;
+
+    @Autowired(required = false)
+    private QualityScoringService qualityScoringService;
+
+    @Autowired(required = false)
+    private EvolutionEngine evolutionEngine;
+
+    @Autowired(required = false)
+    private LobsterEvolutionEngine lobsterEvolutionEngine;
+
+    @Autowired(required = false)
+    private CompanyWorkflowLobsterNodeMapper workflowNodeMapper;
+
+    @Autowired(required = false)
+    private LobsterWorkflowInstanceMapper workflowInstanceMapper;
+
+    @Override
+    public E2eReport runE2e(E2eRequest req) {
+        if (req == null || req.getCompanyId() == null) {
+            return failedReport(null, "companyId 不能为空");
+        }
+        if (workflowExecutor == null) {
+            return failedReport(null, "工作流执行器不可用");
+        }
+
+        String runId = UUID.randomUUID().toString().replace("-", "");
+        long startMs = System.currentTimeMillis();
+        Long companyId = req.getCompanyId();
+
+        resolveRequestFromScenario(req);
+
+        Long templateId = req.getTemplateId();
+        if (templateId == null) {
+            return persistFailed(runId, companyId, req, null, null, "缺少工作流模板 templateId", startMs);
+        }
+
+        LobsterE2eRun run = initRun(runId, companyId, req);
+        if (e2eRunMapper != null) {
+            e2eRunMapper.insertRun(run);
+        }
+
+        List<String> userInputs = req.getUserInputs() != null ? req.getUserInputs() : Collections.emptyList();
+        Long contactId = req.getTestContactId() != null ? req.getTestContactId() : 0L;
+
+        Map<String, Object> initVars = new LinkedHashMap<>();
+        initVars.put("channelType", "TEST");
+        initVars.put("e2eRunId", runId);
+
+        AjaxResult startResult = workflowExecutor.startWorkflow(companyId, templateId, contactId, initVars);
+        if (startResult == null || !Integer.valueOf(HttpStatus.SUCCESS).equals(startResult.get(AjaxResult.CODE_TAG))) {
+            String err = startResult != null ? String.valueOf(startResult.get(AjaxResult.MSG_TAG)) : "启动失败";
+            return persistFailed(runId, companyId, req, templateId, null, err, startMs);
+        }
+
+        Long instanceId = extractInstanceId(startResult.get(AjaxResult.DATA_TAG));
+        if (instanceId == null) {
+            return persistFailed(runId, companyId, req, templateId, null, "无法获取实例ID", startMs);
+        }
+
+        run.setInstanceId(instanceId);
+        run.setTemplateId(templateId);
+
+        List<NodeTrace> traces = new ArrayList<>();
+        int passedCnt = 0;
+        double scoreSum = 0;
+        int seq = 0;
+        String status = "SUCCESS";
+        String errorMsg = null;
+
+        String externalUserId = contactId != null ? contactId.toString() : "e2e_test_user";
+
+        for (String userInput : userInputs) {
+            seq++;
+            long stepStart = System.currentTimeMillis();
+            NodeTrace trace = new NodeTrace();
+            trace.setNodeSeq(seq);
+            trace.setTurnNo(1);
+            trace.setUserInput(userInput);
+
+            String nodeCode = resolveCurrentNodeCode(companyId, instanceId);
+            trace.setNodeCode(nodeCode);
+
+            String aiOutput;
+            double normalized;
+            boolean stepPassed;
+            boolean finished = false;
+
+            if (lobsterEvolutionEngine != null) {
+                LobsterEvolutionEngine.EvolutionResult evo = lobsterEvolutionEngine.evolve(
+                        instanceId, companyId, externalUserId, userInput, nodeCode);
+                aiOutput = evo != null ? evo.getReply() : "";
+                normalized = evo != null ? evo.getQualityScore() * 100.0 / RAW_FULL_SCORE : PASS_SCORE;
+                stepPassed = evo == null || evo.isQualityPassed() || normalized >= PASS_SCORE;
+                if (evo != null && evo.isTransferredToHuman()) {
+                    trace.setEvolutionHint("transferred_to_human");
+                }
+                if (evo != null && evo.getQualityDimensions() != null) {
+                    trace.setScoreDetail(evo.getQualityDimensions());
+                }
+            } else {
+                AjaxResult nextResult = workflowExecutor.executeNextNode(companyId, instanceId, userInput);
+                if (nextResult == null) {
+                    status = "FAILED";
+                    errorMsg = "节点推进无响应";
+                    trace.setErrorMsg(errorMsg);
+                    trace.setPassed(false);
+                    traces.add(trace);
+                    break;
+                }
+                String msg = String.valueOf(nextResult.get(AjaxResult.MSG_TAG));
+                if (!Integer.valueOf(HttpStatus.SUCCESS).equals(nextResult.get(AjaxResult.CODE_TAG))) {
+                    if (msg.contains("已完成")) {
+                        aiOutput = msg;
+                        normalized = PASS_SCORE;
+                        stepPassed = true;
+                        finished = true;
+                    } else {
+                        status = "FAILED";
+                        errorMsg = msg;
+                        trace.setErrorMsg(msg);
+                        trace.setPassed(false);
+                        traces.add(trace);
+                        break;
+                    }
+                } else {
+                    aiOutput = extractMessage(nextResult.get(AjaxResult.DATA_TAG));
+                    normalized = scoreReply(companyId, aiOutput, userInput, trace);
+                    stepPassed = normalized >= PASS_SCORE;
+                }
+            }
+
+            if (!finished && workflowExecutor != null) {
+                AjaxResult advance = workflowExecutor.executeNextNode(companyId, instanceId, userInput);
+                if (advance != null && !Integer.valueOf(HttpStatus.SUCCESS).equals(advance.get(AjaxResult.CODE_TAG))) {
+                    String msg = String.valueOf(advance.get(AjaxResult.MSG_TAG));
+                    finished = msg.contains("已完成");
+                    if (!finished && !msg.contains("冷却")) {
+                        status = "FAILED";
+                        errorMsg = msg;
+                        trace.setErrorMsg(msg);
+                        trace.setPassed(false);
+                        trace.setAiOutput(aiOutput);
+                        trace.setScore(normalized);
+                        trace.setDurationMs(System.currentTimeMillis() - stepStart);
+                        traces.add(trace);
+                        break;
+                    }
+                } else if (advance != null && (aiOutput == null || aiOutput.isEmpty())) {
+                    aiOutput = extractMessage(advance.get(AjaxResult.DATA_TAG));
+                }
+            }
+
+            fillNodeMeta(companyId, instanceId, trace);
+            trace.setAiOutput(aiOutput);
+            trace.setScore(normalized);
+            trace.setDurationMs(System.currentTimeMillis() - stepStart);
+            trace.setPassed(stepPassed);
+            if (stepPassed) passedCnt++;
+            scoreSum += normalized;
+
+            persistNodeTrace(runId, trace);
+            traces.add(trace);
+
+            if (finished) break;
+        }
+
+        int totalNodes = traces.size();
+        double avgScore = totalNodes > 0 ? scoreSum / totalNodes : 0;
+        int evolutionCount = 0;
+        if (evolutionEngine != null && templateId != null) {
+            try {
+                evolutionEngine.analyzeAndSuggest(companyId, templateId);
+                evolutionCount = 1;
+            } catch (Exception e) {
+                logger.debug("[E2E] evolution analyze skipped: {}", e.getMessage());
+            }
+        }
+
+        run.setStatus(status);
+        run.setErrorMsg(errorMsg);
+        run.setTotalScore(BigDecimal.valueOf(avgScore).setScale(2, RoundingMode.HALF_UP));
+        run.setPassedNodeCnt(passedCnt);
+        run.setTotalNodeCnt(totalNodes);
+        run.setDurationMs(System.currentTimeMillis() - startMs);
+        run.setEvolutionCount(evolutionCount);
+        updateRun(run);
+
+        return toReport(run, traces);
+    }
+
+    @Override
+    public E2eReport getReport(String runId) {
+        if (e2eRunMapper == null || runId == null) return null;
+        LobsterE2eRun run = e2eRunMapper.selectByRunId(runId);
+        if (run == null) return null;
+        List<NodeTrace> traces = loadTraces(runId);
+        return toReport(run, traces);
+    }
+
+    @Override
+    public StepResult stepNext(Long companyId, Long instanceId, String userInput) {
+        StepResult sr = new StepResult();
+        if (workflowExecutor == null) {
+            sr.setFinished(true);
+            return sr;
+        }
+        String nodeCode = resolveCurrentNodeCode(companyId, instanceId);
+        if (lobsterEvolutionEngine != null && userInput != null) {
+            LobsterEvolutionEngine.EvolutionResult evo = lobsterEvolutionEngine.evolve(
+                    instanceId, companyId, "e2e_step", userInput, nodeCode);
+            if (evo != null) {
+                sr.setReply(evo.getReply());
+                sr.setScore((int) Math.round(evo.getQualityScore() * 100.0 / RAW_FULL_SCORE));
+                sr.setNextNodeCode(evo.getNextNodeCode());
+            }
+        }
+        AjaxResult next = workflowExecutor.executeNextNode(companyId, instanceId, userInput);
+        if (sr.getReply() == null || sr.getReply().isEmpty()) {
+            sr.setReply(extractMessage(next != null ? next.get(AjaxResult.DATA_TAG) : null));
+        }
+        Map<String, Object> state = workflowExecutor.getInstanceState(companyId, instanceId);
+        sr.setCurrentNodeCode(resolveCurrentNodeCode(companyId, instanceId));
+        sr.setFinished("completed".equals(state.get("status")) || "terminated".equals(state.get("status")));
+        if (next != null && !Integer.valueOf(HttpStatus.SUCCESS).equals(next.get(AjaxResult.CODE_TAG))) {
+            sr.setFinished(sr.getFinished() || String.valueOf(next.get(AjaxResult.MSG_TAG)).contains("已完成"));
+        }
+        if (sr.getScore() == null && qualityScoringService != null && userInput != null) {
+            QualityScoringService.DetailedScore ds = qualityScoringService.score(
+                    companyId, sr.getReply(), userInput, null, null, null);
+            sr.setScore(normalizeScore(ds));
+        }
+        return sr;
+    }
+
+    @Override
+    public MultiTurnResult multiTurn(Long companyId, Long instanceId, String nodeCode, List<String> userInputs) {
+        MultiTurnResult result = new MultiTurnResult();
+        result.setNodeCode(nodeCode);
+        result.setMaxTurn(userInputs != null ? userInputs.size() : 0);
+        List<NodeTrace> turns = new ArrayList<>();
+        double scoreSum = 0;
+        if (userInputs != null) {
+            int turn = 0;
+            for (String input : userInputs) {
+                turn++;
+                long t0 = System.currentTimeMillis();
+                StepResult step = stepNext(companyId, instanceId, input);
+                NodeTrace trace = new NodeTrace();
+                trace.setNodeCode(nodeCode);
+                trace.setTurnNo(turn);
+                trace.setUserInput(input);
+                trace.setAiOutput(step.getReply());
+                trace.setScore(step.getScore() != null ? step.getScore().doubleValue() : null);
+                trace.setDurationMs(System.currentTimeMillis() - t0);
+                trace.setPassed(step.getScore() == null || step.getScore() >= PASS_SCORE);
+                turns.add(trace);
+                if (step.getScore() != null) scoreSum += step.getScore();
+                if (Boolean.TRUE.equals(step.getFinished())) break;
+            }
+        }
+        result.setTurns(turns);
+        result.setAvgScore(turns.isEmpty() ? 0 : scoreSum / turns.size());
+        return result;
+    }
+
+    @Override
+    public List<E2eReport> listRuns(Long companyId, Integer pageNum, Integer pageSize) {
+        if (e2eRunMapper == null) return Collections.emptyList();
+        List<LobsterE2eRun> all;
+        if (companyId != null) {
+            all = e2eRunMapper.selectByCompanyId(companyId);
+        } else {
+            all = e2eRunMapper.selectList(new QueryWrapper<LobsterE2eRun>().orderByDesc("create_time"));
+        }
+        if (all == null) return Collections.emptyList();
+        int page = pageNum != null && pageNum > 0 ? pageNum : 1;
+        int size = pageSize != null && pageSize > 0 ? pageSize : 20;
+        int from = (page - 1) * size;
+        int to = Math.min(from + size, all.size());
+        if (from >= all.size()) return Collections.emptyList();
+        List<E2eReport> reports = new ArrayList<>();
+        for (int i = from; i < to; i++) {
+            reports.add(toReport(all.get(i), null));
+        }
+        return reports;
+    }
+
+    private void resolveRequestFromScenario(E2eRequest req) {
+        if (req.getScenarioId() == null || testScenarioMapper == null) return;
+        try {
+            com.fs.company.domain.LobsterTestScenario scenario = testScenarioMapper.selectById(req.getScenarioId());
+            if (scenario == null) return;
+            if (req.getTemplateId() == null) req.setTemplateId(scenario.getTemplateId());
+            if (req.getBusinessDesc() == null) req.setBusinessDesc(scenario.getBusinessDesc());
+            if (req.getCompanyId() == null) req.setCompanyId(scenario.getCompanyId());
+            if (req.getUserInputs() == null || req.getUserInputs().isEmpty()) {
+                req.setUserInputs(parseUserInputs(scenario.getUserInputsJson()));
+            }
+        } catch (Exception e) {
+            logger.warn("[E2E] load scenario failed: {}", e.getMessage());
+        }
+    }
+
+    private List<String> parseUserInputs(String json) {
+        if (json == null || json.isBlank()) return Collections.emptyList();
+        try {
+            List<String> list = JSON.parseArray(json, String.class);
+            return list != null ? list : Collections.emptyList();
+        } catch (Exception e) {
+            return Arrays.asList(json.split("\n"));
+        }
+    }
+
+    private LobsterE2eRun initRun(String runId, Long companyId, E2eRequest req) {
+        LobsterE2eRun run = new LobsterE2eRun();
+        run.setRunId(runId);
+        run.setCompanyId(companyId);
+        run.setScenarioId(req.getScenarioId());
+        run.setTemplateId(req.getTemplateId());
+        run.setBusinessDesc(req.getBusinessDesc());
+        run.setStatus("RUNNING");
+        run.setPassedNodeCnt(0);
+        run.setTotalNodeCnt(0);
+        run.setDurationMs(0L);
+        run.setEvolutionCount(0);
+        run.setCreateTime(LocalDateTime.now());
+        return run;
+    }
+
+    private E2eReport persistFailed(String runId, Long companyId, E2eRequest req,
+                                    Long templateId, Long instanceId, String error, long startMs) {
+        LobsterE2eRun run = initRun(runId, companyId, req);
+        run.setTemplateId(templateId);
+        run.setInstanceId(instanceId);
+        run.setStatus("FAILED");
+        run.setErrorMsg(error);
+        run.setDurationMs(System.currentTimeMillis() - startMs);
+        if (e2eRunMapper != null) e2eRunMapper.insertRun(run);
+        return toReport(run, Collections.emptyList());
+    }
+
+    private E2eReport failedReport(String runId, String error) {
+        E2eReport r = new E2eReport();
+        r.setRunId(runId);
+        r.setStatus("FAILED");
+        r.setErrorMsg(error);
+        r.setNodeTraces(Collections.emptyList());
+        return r;
+    }
+
+    private void updateRun(LobsterE2eRun run) {
+        if (e2eRunMapper == null) return;
+        e2eRunMapper.updateStatus(run.getRunId(), run.getStatus(), run.getTotalScore(),
+                run.getPassedNodeCnt(), run.getTotalNodeCnt(), run.getDurationMs(),
+                run.getEvolutionCount(), run.getErrorMsg());
+    }
+
+    private void persistNodeTrace(String runId, NodeTrace trace) {
+        if (e2eRunNodeMapper == null) return;
+        LobsterE2eRunNode node = new LobsterE2eRunNode();
+        node.setRunId(runId);
+        node.setNodeSeq(trace.getNodeSeq());
+        node.setNodeCode(trace.getNodeCode());
+        node.setNodeType(trace.getNodeType());
+        node.setNodeName(trace.getNodeName());
+        node.setTurnNo(trace.getTurnNo());
+        node.setUserInput(trace.getUserInput());
+        node.setAiOutput(trace.getAiOutput());
+        if (trace.getScore() != null) {
+            node.setScore(BigDecimal.valueOf(trace.getScore()).setScale(2, RoundingMode.HALF_UP));
+        }
+        if (trace.getScoreDetail() != null) {
+            node.setScoreDetail(JSON.toJSONString(trace.getScoreDetail()));
+        }
+        node.setDurationMs(trace.getDurationMs());
+        node.setModelUsed(trace.getModelUsed());
+        node.setEvolutionHint(trace.getEvolutionHint());
+        node.setPassed(Boolean.TRUE.equals(trace.getPassed()) ? 1 : 0);
+        node.setErrorMsg(trace.getErrorMsg());
+        node.setCreateTime(LocalDateTime.now());
+        e2eRunNodeMapper.insertNode(node);
+    }
+
+    private List<NodeTrace> loadTraces(String runId) {
+        if (e2eRunNodeMapper == null) return Collections.emptyList();
+        List<LobsterE2eRunNode> nodes = e2eRunNodeMapper.selectByRunId(runId);
+        List<NodeTrace> traces = new ArrayList<>();
+        if (nodes == null) return traces;
+        for (LobsterE2eRunNode n : nodes) {
+            NodeTrace t = new NodeTrace();
+            t.setNodeSeq(n.getNodeSeq());
+            t.setNodeCode(n.getNodeCode());
+            t.setNodeType(n.getNodeType());
+            t.setNodeName(n.getNodeName());
+            t.setTurnNo(n.getTurnNo());
+            t.setUserInput(n.getUserInput());
+            t.setAiOutput(n.getAiOutput());
+            t.setScore(n.getScore() != null ? n.getScore().doubleValue() : null);
+            if (n.getScoreDetail() != null) {
+                try {
+                    @SuppressWarnings("unchecked")
+                    Map<String, Integer> detail = JSON.parseObject(n.getScoreDetail(), Map.class);
+                    t.setScoreDetail(detail);
+                } catch (Exception ignored) { }
+            }
+            t.setDurationMs(n.getDurationMs());
+            t.setModelUsed(n.getModelUsed());
+            t.setEvolutionHint(n.getEvolutionHint());
+            t.setPassed(n.getPassed() != null && n.getPassed() == 1);
+            t.setErrorMsg(n.getErrorMsg());
+            traces.add(t);
+        }
+        return traces;
+    }
+
+    private E2eReport toReport(LobsterE2eRun run, List<NodeTrace> traces) {
+        E2eReport r = new E2eReport();
+        r.setRunId(run.getRunId());
+        r.setCompanyId(run.getCompanyId());
+        r.setTemplateId(run.getTemplateId());
+        r.setInstanceId(run.getInstanceId());
+        r.setScenarioId(run.getScenarioId());
+        r.setBusinessDesc(run.getBusinessDesc());
+        r.setTotalScore(run.getTotalScore() != null ? run.getTotalScore().doubleValue() : null);
+        r.setPassedNodeCnt(run.getPassedNodeCnt());
+        r.setTotalNodeCnt(run.getTotalNodeCnt());
+        r.setDurationMs(run.getDurationMs());
+        r.setStatus(run.getStatus());
+        r.setErrorMsg(run.getErrorMsg());
+        r.setEvolutionCount(run.getEvolutionCount());
+        if (traces != null) {
+            r.setNodeTraces(traces);
+        } else if (run.getRunId() != null) {
+            r.setNodeTraces(loadTraces(run.getRunId()));
+        }
+        if (run.getCreateTime() != null) {
+            r.setCreateTime(run.getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
+        }
+        return r;
+    }
+
+    private Long extractInstanceId(Object data) {
+        if (data instanceof LobsterWorkflowInstance) {
+            return ((LobsterWorkflowInstance) data).getId();
+        }
+        if (data instanceof Map) {
+            Object id = ((Map<?, ?>) data).get("id");
+            if (id == null) id = ((Map<?, ?>) data).get("instanceId");
+            if (id instanceof Number) return ((Number) id).longValue();
+        }
+        return null;
+    }
+
+    private String extractMessage(Object data) {
+        if (data == null) return "";
+        if (data instanceof Map) {
+            Map<?, ?> map = (Map<?, ?>) data;
+            for (String key : new String[]{"message", "reply", "content", "aiReply"}) {
+                Object v = map.get(key);
+                if (v != null && !v.toString().isEmpty()) return v.toString();
+            }
+        }
+        return data.toString();
+    }
+
+    private void fillNodeMeta(Long companyId, Long instanceId, NodeTrace trace) {
+        Map<String, Object> state = workflowExecutor.getInstanceState(companyId, instanceId);
+        trace.setNodeCode(resolveNodeCode(companyId, instanceId, state));
+        if (workflowNodeMapper == null || workflowInstanceMapper == null) return;
+        try {
+            LobsterWorkflowInstance instance = workflowInstanceMapper.selectByIdAndCompanyId(instanceId, companyId);
+            if (instance == null) return;
+            Object idxObj = state.get("currentNodeIndex");
+            List<CompanyWorkflowLobsterNode> nodes = workflowNodeMapper.selectByWorkflowIdAndCompanyId(
+                    instance.getWorkflowId(), companyId);
+            if (nodes == null || idxObj == null) return;
+            int idx = ((Number) idxObj).intValue();
+            nodes.sort(Comparator.comparingInt(n -> n.getSortNo() != null ? n.getSortNo() : 0));
+            if (idx >= 0 && idx < nodes.size()) {
+                CompanyWorkflowLobsterNode node = nodes.get(idx);
+                trace.setNodeName(node.getNodeName());
+                trace.setNodeType(node.getNodeType() != null ? node.getNodeType().toString() : null);
+                if (trace.getNodeCode() == null) trace.setNodeCode(node.getNodeCode());
+            }
+        } catch (Exception e) {
+            logger.debug("[E2E] fillNodeMeta: {}", e.getMessage());
+        }
     }
 
-    public void recordResult(Long testId, Long companyId, boolean passed, String detail) {
-        if (auxMapper == null) return;
-        auxMapper.insertE2eResult(testId, companyId, passed, detail);
+    private String resolveCurrentNodeCode(Long companyId, Long instanceId) {
+        if (workflowInstanceMapper == null || workflowNodeMapper == null) {
+            return resolveNodeCode(companyId, instanceId, workflowExecutor.getInstanceState(companyId, instanceId));
+        }
+        LobsterWorkflowInstance inst = workflowInstanceMapper.selectByIdAndCompanyId(instanceId, companyId);
+        if (inst == null) return null;
+        List<CompanyWorkflowLobsterNode> nodes = workflowNodeMapper.selectByWorkflowIdAndCompanyId(
+                inst.getWorkflowId(), companyId);
+        if (nodes == null || inst.getCurrentNodeIndex() == null) return inst.getCurrentNodeName();
+        nodes.sort(Comparator.comparingInt(n -> n.getSortNo() != null ? n.getSortNo() : 0));
+        int idx = inst.getCurrentNodeIndex();
+        if (idx >= 0 && idx < nodes.size()) return nodes.get(idx).getNodeCode();
+        return inst.getCurrentNodeName();
     }
 
-    public List<Map<String, Object>> listTests(Long companyId, int page, int pageSize) {
-        if (auxMapper == null) return new ArrayList<>();
-        return auxMapper.selectE2eTests(companyId, (page - 1) * pageSize, pageSize);
+    private String resolveNodeCode(Long companyId, Long instanceId, Map<String, Object> state) {
+        if (state == null || state.isEmpty()) {
+            state = workflowExecutor.getInstanceState(companyId, instanceId);
+        }
+        Object name = state.get("currentNodeName");
+        return name != null ? name.toString() : null;
     }
 
-    public Map<String, Object> getResult(String runId) {
-        if (auxMapper == null) return null;
-        return auxMapper.selectE2eResult(runId);
+    private double scoreReply(Long companyId, String aiOutput, String userInput, NodeTrace trace) {
+        if (qualityScoringService == null || aiOutput == null) return PASS_SCORE;
+        QualityScoringService.DetailedScore ds = qualityScoringService.score(
+                companyId, aiOutput, userInput, null, null, null);
+        Map<String, Integer> dims = new LinkedHashMap<>();
+        dims.put("relevance", ds.getRelevance());
+        dims.put("professionalism", ds.getProfessionalism());
+        dims.put("completeness", ds.getCompleteness());
+        dims.put("naturalness", ds.getNaturalness());
+        dims.put("compliance", ds.getCompliance());
+        dims.put("humanLikeliness", ds.getHumanLikeliness());
+        trace.setScoreDetail(dims);
+        return normalizeScore(ds);
     }
 
-    public List<Map<String, Object>> getResultList(Long testId) {
-        if (auxMapper == null) return new ArrayList<>();
-        return auxMapper.selectE2eList(testId);
+    private int normalizeScore(QualityScoringService.DetailedScore ds) {
+        if (ds == null) return 0;
+        return (int) Math.round(ds.getTotalScore() * 100.0 / RAW_FULL_SCORE);
     }
 }

+ 1 - 1
fs-service/src/main/java/com/fs/company/service/workflow/impl/LobsterEvolutionEngineImpl.java

@@ -219,7 +219,7 @@ public class LobsterEvolutionEngineImpl implements LobsterEvolutionEngine {
 
             // Step 6: 工具调用检测与执行
             Map<String, Object> toolCall = toolCallFramework.extractToolCall(aiReply);
-            if (toolCall != null) {
+            if (toolCall != null && !toolCall.isEmpty() && toolCall.get("toolName") != null) {
                 String toolName = (String) toolCall.get("toolName");
                 @SuppressWarnings("unchecked")
                 Map<String, Object> toolParams = (Map<String, Object>) toolCall.get("parameters");

+ 129 - 28
fs-service/src/main/java/com/fs/company/service/workflow/impl/LobsterTestScenarioServiceImpl.java

@@ -1,12 +1,17 @@
 package com.fs.company.service.workflow.impl;
 
+import com.alibaba.fastjson.JSON;
 import com.fs.company.mapper.LobsterAuxiliaryMapper;
+import com.fs.company.mapper.LobsterTestScenarioMapper;
+import com.fs.company.domain.LobsterTestScenario;
+import com.fs.company.service.workflow.LobsterE2eTestService;
 import com.fs.company.service.workflow.LobsterTestScenarioService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
+import java.time.LocalDateTime;
 import java.util.*;
 
 @Service
@@ -17,78 +22,174 @@ public class LobsterTestScenarioServiceImpl implements LobsterTestScenarioServic
     @Autowired(required = false)
     private LobsterAuxiliaryMapper auxMapper;
 
+    @Autowired(required = false)
+    private LobsterTestScenarioMapper testScenarioMapper;
+
+    @Autowired(required = false)
+    private LobsterE2eTestService e2eTestService;
+
     @Override
     public List<Map<String, Object>> listScenarios(Long companyId, Integer enabled, Integer pageNum, Integer pageSize) {
         if (auxMapper == null) return new ArrayList<>();
-        return auxMapper.selectTestScenarios(companyId);
+        return auxMapper.selectTestScenarios(companyId, enabled);
     }
 
     @Override
     public Map<String, Object> getScenario(Long id) {
         if (auxMapper == null) return null;
-        return auxMapper.selectTestScenarioById(id, 0L);
+        return auxMapper.selectTestScenarioById(id, null);
     }
 
     @Override
     public Long createScenario(Map<String, Object> params) {
+        if (testScenarioMapper != null) {
+            LobsterTestScenario s = mapToEntity(params);
+            testScenarioMapper.insertScenario(s);
+            return s.getId();
+        }
         if (auxMapper == null) return null;
         Long companyId = toLong(params.get("companyId"));
-        String name = (String) params.getOrDefault("name", "");
-        Long workflowId = toLong(params.get("workflowId"));
-        String testData = (String) params.getOrDefault("testData", "");
-        auxMapper.insertTestScenario(companyId, name, workflowId, testData);
+        String name = stringVal(params.get("scenarioName"), params.get("name"), "");
+        Long templateId = toLong(params.get("templateId"));
+        String userJson = toUserInputsJson(params);
+        auxMapper.insertTestScenario(companyId, name, templateId, userJson);
         return auxMapper.selectLastInsertId();
     }
 
     @Override
     public void updateScenario(Long id, Map<String, Object> params) {
+        if (testScenarioMapper != null) {
+            LobsterTestScenario existing = testScenarioMapper.selectById(id);
+            if (existing == null) return;
+            if (params.get("scenarioName") != null) existing.setScenarioName(params.get("scenarioName").toString());
+            if (params.get("templateId") != null) existing.setTemplateId(toLong(params.get("templateId")));
+            if (params.get("businessDesc") != null) existing.setBusinessDesc(params.get("businessDesc").toString());
+            if (params.get("userInputs") != null) existing.setUserInputsJson(JSON.toJSONString(params.get("userInputs")));
+            if (params.get("minScore") != null) existing.setMinScore(new java.math.BigDecimal(params.get("minScore").toString()));
+            if (params.get("enabled") != null) existing.setEnabled(Integer.valueOf(params.get("enabled").toString()));
+            testScenarioMapper.updateById(existing);
+            return;
+        }
         if (auxMapper == null) return;
-        String name = (String) params.getOrDefault("name", null);
-        String testData = (String) params.getOrDefault("testData", null);
+        String name = (String) params.getOrDefault("scenarioName", null);
+        String testData = params.containsKey("userInputs") ? JSON.toJSONString(params.get("userInputs")) : null;
         auxMapper.updateTestScenario(id, name, testData);
     }
 
     @Override
     public void deleteScenario(Long id) {
+        if (testScenarioMapper != null) {
+            LobsterTestScenario s = testScenarioMapper.selectById(id);
+            if (s != null) {
+                s.setEnabled(0);
+                testScenarioMapper.updateById(s);
+            }
+            return;
+        }
         if (auxMapper != null) auxMapper.deleteTestScenario(id, 0L);
     }
 
     @Override
     public String runScenarioNow(Long id) {
-        String runId = UUID.randomUUID().toString().substring(0, 8);
-        logger.info("[Scenario] runScenarioNow id={} runId={}", id, runId);
-        // 异步执行由上层调度触发
+        if (e2eTestService == null) {
+            String runId = UUID.randomUUID().toString().substring(0, 8);
+            logger.warn("[Scenario] E2E service unavailable, stub runId={}", runId);
+            return runId;
+        }
+        Map<String, Object> scenario = getScenario(id);
+        if (scenario == null && testScenarioMapper != null) {
+            LobsterTestScenario entity = testScenarioMapper.selectById(id);
+            if (entity != null) scenario = entityToMap(entity);
+        }
+        if (scenario == null) {
+            logger.warn("[Scenario] scenario not found id={}", id);
+            return null;
+        }
+
+        LobsterE2eTestService.E2eRequest req = new LobsterE2eTestService.E2eRequest();
+        req.setScenarioId(id);
+        req.setCompanyId(toLong(scenario.get("company_id")) != null
+                ? toLong(scenario.get("company_id")) : toLong(scenario.get("companyId")));
+        req.setTemplateId(toLong(scenario.get("template_id")) != null
+                ? toLong(scenario.get("template_id")) : toLong(scenario.get("templateId")));
+        req.setBusinessDesc(stringVal(scenario.get("business_desc"), scenario.get("businessDesc"), null));
+
+        Object json = scenario.get("user_inputs_json");
+        if (json == null) json = scenario.get("userInputsJson");
+        if (json instanceof String) {
+            try {
+                req.setUserInputs(JSON.parseArray((String) json, String.class));
+            } catch (Exception e) {
+                req.setUserInputs(Arrays.asList(((String) json).split("\n")));
+            }
+        }
+
+        LobsterE2eTestService.E2eReport report = e2eTestService.runE2e(req);
+        String runId = report != null ? report.getRunId() : null;
+        if (testScenarioMapper != null && runId != null) {
+            testScenarioMapper.updateLastRun(id, runId, report.getStatus());
+        }
+        logger.info("[Scenario] runScenarioNow id={} runId={} status={}", id, runId,
+                report != null ? report.getStatus() : "null");
         return runId;
     }
 
     @Override
     public int runAllEnabledScenarios() {
-        if (auxMapper == null) return 0;
-        List<Map<String, Object>> list = auxMapper.selectTestScenarios(null);
+        List<Map<String, Object>> list = listScenarios(null, 1, 1, 1000);
         int count = 0;
         for (Map<String, Object> s : list) {
-            Object enabled = s.get("enabled");
-            if (enabled != null && Integer.valueOf(enabled.toString()) == 1) {
-                Object idObj = s.get("id");
-                if (idObj != null) {
-                    runScenarioNow(Long.valueOf(idObj.toString()));
-                    count++;
-                }
+            Object idObj = s.get("id");
+            if (idObj != null) {
+                runScenarioNow(Long.valueOf(idObj.toString()));
+                count++;
             }
         }
         return count;
     }
 
-    // ---- 辅助方法 ----
+    private LobsterTestScenario mapToEntity(Map<String, Object> params) {
+        LobsterTestScenario s = new LobsterTestScenario();
+        s.setCompanyId(toLong(params.get("companyId")));
+        s.setScenarioName(stringVal(params.get("scenarioName"), params.get("name"), "未命名场景"));
+        s.setTemplateId(toLong(params.get("templateId")));
+        s.setBusinessDesc((String) params.get("businessDesc"));
+        s.setUserInputsJson(toUserInputsJson(params));
+        if (params.get("minScore") != null) {
+            s.setMinScore(new java.math.BigDecimal(params.get("minScore").toString()));
+        }
+        Object enabled = params.get("enabled");
+        s.setEnabled(enabled != null ? Integer.valueOf(enabled.toString()) : 1);
+        s.setCreateTime(LocalDateTime.now());
+        return s;
+    }
+
+    private Map<String, Object> entityToMap(LobsterTestScenario s) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("id", s.getId());
+        m.put("company_id", s.getCompanyId());
+        m.put("template_id", s.getTemplateId());
+        m.put("business_desc", s.getBusinessDesc());
+        m.put("user_inputs_json", s.getUserInputsJson());
+        m.put("enabled", s.getEnabled());
+        return m;
+    }
+
+    private String toUserInputsJson(Map<String, Object> params) {
+        Object ui = params.get("userInputs");
+        if (ui != null) return JSON.toJSONString(ui);
+        return "[]";
+    }
 
-    private Long toLong(Object v) {
-        if (v == null) return null;
-        if (v instanceof Number) return ((Number) v).longValue();
-        try { return Long.valueOf(v.toString()); } catch (Exception e) { return null; }
+    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; }
     }
 
-    public void recordResult(Long companyId, Long scenarioId, boolean passed, String detail) {
-        if (auxMapper == null) return;
-        auxMapper.insertTestScenarioResult(companyId, scenarioId, passed, detail);
+    private static String stringVal(Object a, Object b, String def) {
+        if (a != null && !a.toString().isEmpty()) return a.toString();
+        if (b != null && !b.toString().isEmpty()) return b.toString();
+        return def;
     }
 }

+ 254 - 11
fs-service/src/main/java/com/fs/company/service/workflow/impl/LobsterWorkflowExecutorImpl.java

@@ -20,6 +20,8 @@ import com.fs.company.domain.LobsterChatSession;
 import com.fs.company.domain.LobsterChatMsg;
 import com.fs.company.service.llm.MultiModelRouter;
 import com.fs.company.service.workflow.ConditionEvaluator;
+import com.fs.company.service.workflow.DynamicNodeExecutor;
+import com.fs.company.service.workflow.LobsterEvolutionEngine;
 import com.fs.company.service.workflow.LobsterWorkflowExecutor;
 import com.fs.company.service.workflow.VariableSubstitutionEngine;
 import com.fs.company.service.workflow.channel.MessageChannelRequest;
@@ -239,6 +241,12 @@ public class LobsterWorkflowExecutorImpl implements LobsterWorkflowExecutor {
     @Autowired(required = false)
     private MultiTurnDialogueManager multiTurnDialogueManager;
 
+    @Autowired(required = false)
+    private LobsterEvolutionEngine lobsterEvolutionEngine;
+
+    @Autowired(required = false)
+    private DynamicNodeExecutor dynamicNodeExecutor;
+
     @Autowired(required = false)
     private com.fs.company.service.workflow.api.SmartApiCallNodeExecutor smartApiCallNodeExecutor;
 
@@ -277,6 +285,11 @@ public class LobsterWorkflowExecutorImpl implements LobsterWorkflowExecutor {
 
         Integer nodeType = currentNode.getNodeType();
         String nodeCode = currentNode.getNodeCode();
+        variables.put("companyId", companyId);
+        variables.put("instanceId", instanceId);
+        variables.put("contactId", instance.getContactId());
+        variables.put("externalUserId", instance.getContactId() != null ? instance.getContactId().toString() : null);
+        String evolutionNextNodeCode = null;
 
         /*
          * ===== 处理客户回复:语义分析 + 节点类型路由 =====
@@ -375,7 +388,7 @@ public class LobsterWorkflowExecutorImpl implements LobsterWorkflowExecutor {
             /* 7. 多轮对话检查:max_rounds > 0 且未达上限,停留当前节点 */
             Integer maxRounds = currentNode.getMaxRounds();
             if (maxRounds != null && maxRounds > 0 && nodeRound < maxRounds) {
-                String repeatMessage = generateNodeMessage(currentNode, variables);
+                String repeatMessage = evolveOrGenerateMessage(companyId, instance, currentNode, customerReply, variables);
                 instance.setVariables(JSON.toJSONString(variables));
                 instance.setLastActivityTime(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
                 instance.setUpdateBy("system");
@@ -421,6 +434,47 @@ public class LobsterWorkflowExecutorImpl implements LobsterWorkflowExecutor {
                 }
                 variables.putAll(dialogueResult.getCollectedVariables());
             }
+
+            /* 8.5 12步进化引擎:对交互型节点生成 AI 回复 */
+            if (isEvolutionInteractiveNode(nodeType)) {
+                LobsterEvolutionEngine.EvolutionResult evo = invokeEvolution(
+                        instanceId, companyId, instance.getContactId(), customerReply, nodeCode, variables);
+                if (evo != null) {
+                    if (evo.isTransferredToHuman()) {
+                        return handleTransferHuman(companyId, instance, currentNode, variables, customerReply);
+                    }
+                    if (evo.getUpdatedVariables() != null) {
+                        variables.putAll(evo.getUpdatedVariables());
+                    }
+                    if (evo.getNextNodeCode() != null && !evo.getNextNodeCode().isEmpty()) {
+                        evolutionNextNodeCode = evo.getNextNodeCode();
+                    }
+                    String evoReply = evo.getReply();
+                    if (evoReply != null && !evoReply.isEmpty()) {
+                        instance.setVariables(JSON.toJSONString(variables));
+                        instance.setLastActivityTime(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
+                        instance.setUpdateTime(DateUtils.getNowDate());
+                        instanceMapper.updateById(instance);
+                        logNodeExecution(companyId, instanceId, instance.getWorkflowId(), currentIndex,
+                                currentNode, evoReply, customerReply, "evo_reply");
+                        deliverMessage(companyId, instance.getContactId(), channelType, evoReply,
+                                variables, instanceId, instance.getWorkflowId());
+                        recordEvolutionOutcome(companyId, instance, variables, customerReply, evo);
+                        /* 多轮停留:已回复则不再推进 */
+                        if (maxRounds != null && maxRounds > 0 && nodeRound < maxRounds) {
+                            Map<String, Object> result = new HashMap<>();
+                            result.put("instanceId", instanceId);
+                            result.put("nodeIndex", currentIndex);
+                            result.put("nodeName", currentNode.getNodeName());
+                            result.put("message", evoReply);
+                            result.put("evolutionEngine", true);
+                            result.put("nodeRound", nodeRound);
+                            result.put("stayOnNode", true);
+                            return AjaxResult.success("进化引擎多轮回复", result);
+                        }
+                    }
+                }
+            }
         } else {
             /* 无客户回复:推进前先记录前序节点发送 */
             logNodeExecution(companyId, instanceId, instance.getWorkflowId(), currentIndex, currentNode, null, null, "received");
@@ -429,7 +483,8 @@ public class LobsterWorkflowExecutorImpl implements LobsterWorkflowExecutor {
         /*
          * ===== 推进到下一节点 =====
          */
-        String nextNodeCode = determineNextNode(currentNode, variables);
+        String nextNodeCode = evolutionNextNodeCode != null ? evolutionNextNodeCode
+                : determineNextNode(currentNode, variables);
         int nextIndex = findNodeIndex(nodes, nextNodeCode, currentIndex + 1);
 
         if (nextIndex >= nodes.size() || nextIndex < 0) {
@@ -485,19 +540,29 @@ public class LobsterWorkflowExecutorImpl implements LobsterWorkflowExecutor {
             return handleTagOperation(companyId, instance, nextNode, nodes, currentIndex, nextIndex, variables, channelType);
         }
 
-        /* 未知节点类型 → AI动态生成执行逻辑 */
+        /* 枚举对齐节点 → DynamicNodeExecutor(含 6-53/100/200 等) */
+        if (shouldUseDynamicExecutor(nextNode.getNodeType())) {
+            return advanceWithDynamicExecutor(companyId, instance, nextNode, nodes, currentIndex, nextIndex,
+                    variables, channelType, customerReply);
+        }
+
+        /* 未知节点类型 → DynamicNodeExecutor AI 兜底 */
         if (nextNode.getNodeType() != null && nextNode.getNodeType() > 0 &&
             nextNode.getNodeType() != NODE_TYPE_START && nextNode.getNodeType() != NODE_TYPE_END) {
             boolean isKnown = false;
             for (int knownType : new int[]{2,3,4,5,6,7,8,9,10,11,12,13,14,15,16}) {
                 if (nextNode.getNodeType() == knownType) { isKnown = true; break; }
             }
+            if (!isKnown && dynamicNodeExecutor != null) {
+                return advanceWithDynamicExecutor(companyId, instance, nextNode, nodes, currentIndex, nextIndex,
+                        variables, channelType, customerReply);
+            }
             if (!isKnown) {
                 return handleUnknownNodeDynamically(companyId, instance, nextNode, nodes, currentIndex, nextIndex, variables, channelType);
             }
         }
 
-        String message = generateNodeMessage(nextNode, variables);
+        String message = evolveOrGenerateMessage(companyId, instance, nextNode, null, variables);
 
         instance.setCurrentNodeIndex(nextIndex);
         instance.setCurrentNodeName(nextNode.getNodeName());
@@ -791,8 +856,8 @@ public class LobsterWorkflowExecutorImpl implements LobsterWorkflowExecutor {
 
             MessageChannelResult sendResult = messageChannelRouter.route(request);
 
-            /* 同步写入chat_msg打通ChatSession聚合页面 */
-            bridgeToChatMsg(companyId, contactId, channelType, message, instanceId, sendResult.isSuccess());
+            /* 同步写入 chat_msg,供 ChatSession 聚合页展示 */
+            syncOutboundChatMsg(companyId, contactId, channelType, message, instanceId, sendResult.isSuccess());
 
             return sendResult;
         } catch (Exception e) {
@@ -812,12 +877,9 @@ public class LobsterWorkflowExecutorImpl implements LobsterWorkflowExecutor {
     }
 
     /**
-     * 同步写入chat_msg表 打通ChatSession聚合页面
-     * 以lobster_unified_contact为桥梁,支持任意渠道即插即用
-     * 
-     * 架构: contact_id(channelType) → lobster_unified_contact → chat_session(contact_id+channel_source_id)
+     * 渠道发送成功后,同步写入 chat_msg / chat_session(同库直写,非跨模块桥接)
      */
-    private void bridgeToChatMsg(Long companyId, Long contactId, String channelType,
+    private void syncOutboundChatMsg(Long companyId, Long contactId, String channelType,
                                   String message, Long instanceId, boolean success) {
         if (chatSessionMapper == null || chatMsgMapper == null || message == null) return;
         if (channelType == null) channelType = "QW";
@@ -913,6 +975,82 @@ public class LobsterWorkflowExecutorImpl implements LobsterWorkflowExecutor {
         }
     }
 
+    private boolean isEvolutionInteractiveNode(Integer nodeType) {
+        if (nodeType == null) return false;
+        return nodeType == NODE_TYPE_AI_PROCESS || nodeType == 33
+                || nodeType == NODE_TYPE_COLLECT_INFO || nodeType == NODE_TYPE_HTTP_CALL
+                || nodeType == NODE_TYPE_RAG_QUERY || nodeType == NODE_TYPE_LOOP;
+    }
+
+    private LobsterEvolutionEngine.EvolutionResult invokeEvolution(Long instanceId, Long companyId,
+            Long contactId, String customerMessage, String nodeCode, Map<String, Object> variables) {
+        if (lobsterEvolutionEngine == null || customerMessage == null || customerMessage.isEmpty()) {
+            return null;
+        }
+        try {
+            String externalUserId = contactId != null ? contactId.toString()
+                    : (variables.get("externalUserId") != null ? variables.get("externalUserId").toString() : "unknown");
+            return lobsterEvolutionEngine.evolve(instanceId, companyId, externalUserId, customerMessage, nodeCode);
+        } catch (Exception e) {
+            logger.warn("[LobsterWorkflow] evolve failed instanceId={}: {}", instanceId, e.getMessage());
+            return null;
+        }
+    }
+
+    private String evolveOrGenerateMessage(Long companyId, LobsterWorkflowInstance instance,
+            CompanyWorkflowLobsterNode node, String customerReply, Map<String, Object> variables) {
+        if (customerReply != null && !customerReply.isEmpty() && lobsterEvolutionEngine != null) {
+            LobsterEvolutionEngine.EvolutionResult evo = invokeEvolution(
+                    instance.getId(), companyId, instance.getContactId(), customerReply,
+                    node.getNodeCode(), variables);
+            if (evo != null && evo.getReply() != null && !evo.getReply().isEmpty()) {
+                if (evo.getUpdatedVariables() != null) variables.putAll(evo.getUpdatedVariables());
+                recordEvolutionOutcome(companyId, instance, variables, customerReply, evo);
+                return evo.getReply();
+            }
+        }
+        return generateNodeMessage(node, variables);
+    }
+
+    private void recordEvolutionOutcome(Long companyId, LobsterWorkflowInstance instance,
+            Map<String, Object> variables, String customerReply,
+            LobsterEvolutionEngine.EvolutionResult evo) {
+        if (evolutionEngine == null) return;
+        try {
+            EvolutionContext context = new EvolutionContext();
+            context.setCompanyId(companyId);
+            context.setWorkflowId(instance.getWorkflowId());
+            context.setInstanceId(instance.getId());
+            context.setContactId(instance.getContactId());
+            context.setChannelType((String) variables.getOrDefault("channelType", "QW"));
+            context.setNodeCode(instance.getCurrentNodeName());
+            context.setCustomerReply(customerReply);
+            context.setSentMessage(evo.getReply());
+            context.setOutcome(mapCommercialOutcome(evo.getDetectedIntent(), customerReply));
+            context.setVariables(variables);
+            evolutionEngine.recordInteraction(context);
+        } catch (Exception e) {
+            logger.debug("[LobsterWorkflow] recordEvolutionOutcome: {}", e.getMessage());
+        }
+    }
+
+    private String mapCommercialOutcome(String intent, String customerReply) {
+        if (intent != null) {
+            String i = intent.toLowerCase();
+            if (i.contains("购买") || i.contains("下单") || i.contains("purchase")) return "purchase";
+            if (i.contains("咨询") || i.contains("inquiry")) return "inquiry";
+            if (i.contains("投诉") || i.contains("complaint")) return "complaint";
+            if (i.contains("预约") || i.contains("schedule")) return "schedule";
+        }
+        if (customerReply != null) {
+            String m = customerReply.toLowerCase();
+            if (m.contains("买") || m.contains("下单")) return "purchase";
+            if (m.contains("退款") || m.contains("投诉")) return "complaint";
+            if (m.contains("预约")) return "schedule";
+        }
+        return intent != null ? intent : "other";
+    }
+
     /**
      * 生成节点消息(千人千面/用户级优化版本)
      * 
@@ -1047,6 +1185,7 @@ public class LobsterWorkflowExecutorImpl implements LobsterWorkflowExecutor {
         log.setCompanyId(companyId);
         log.setInstanceId(instanceId);
         log.setWorkflowId(workflowId);
+        log.setNodeCode(node.getNodeCode());
         log.setNodeIndex(nodeIndex);
         log.setNodeType(node.getNodeType() != null ? String.valueOf(node.getNodeType()) : "unknown");
         log.setNodeName(node.getNodeName());
@@ -1712,6 +1851,106 @@ public class LobsterWorkflowExecutorImpl implements LobsterWorkflowExecutor {
         return AjaxResult.success(resultMsg, result);
     }
 
+    /**
+     * 是否通过 DynamicNodeExecutor 执行(与 LobsterNodeTypeEnum 对齐的已注册 handler)
+     */
+    private boolean shouldUseDynamicExecutor(Integer nodeType) {
+        if (nodeType == null || dynamicNodeExecutor == null) return false;
+        if (nodeType == 1 || nodeType == 2) return false;
+        return dynamicNodeExecutor.getRegisteredHandlers().containsKey(nodeType);
+    }
+
+    private String buildNodeConfigJson(CompanyWorkflowLobsterNode node) {
+        if (node.getNodeConfig() != null && !node.getNodeConfig().isEmpty()) {
+            return node.getNodeConfig();
+        }
+        JSONObject o = new JSONObject();
+        if (node.getMessageTemplate() != null) o.put("messageTemplate", node.getMessageTemplate());
+        return o.toJSONString();
+    }
+
+    /**
+     * 通过 DynamicNodeExecutor 推进节点(枚举类型 handler + AI 兜底)
+     */
+    private AjaxResult advanceWithDynamicExecutor(Long companyId, LobsterWorkflowInstance instance,
+                                                   CompanyWorkflowLobsterNode node,
+                                                   List<CompanyWorkflowLobsterNode> nodes,
+                                                   int currentIndex, int nextIndex,
+                                                   Map<String, Object> variables, String channelType,
+                                                   String customerReply) {
+        Long instanceId = instance.getId();
+        DynamicNodeExecutor.ExecutionContext ctx = DynamicNodeExecutor.ExecutionContext.builder()
+                .companyId(companyId)
+                .customerId(instance.getContactId())
+                .workflowInstanceId(instanceId)
+                .variables(variables)
+                .lastMessage(customerReply)
+                .channelType(channelType)
+                .build();
+
+        DynamicNodeExecutor.NodeExecutionResult exec = dynamicNodeExecutor.execute(
+                node.getNodeType(), buildNodeConfigJson(node), ctx);
+        if (!exec.isSuccess()) {
+            return AjaxResult.error(exec.getErrorMessage() != null ? exec.getErrorMessage() : "节点执行失败");
+        }
+        if (exec.getOutputVariables() != null) {
+            variables.putAll(exec.getOutputVariables());
+        }
+
+        if (node.getNodeType() != null && node.getNodeType() == 5) {
+            completeInstance(instance);
+            heartbeatScheduler.unregisterInstance(instanceId);
+            Map<String, Object> endResult = new HashMap<>();
+            endResult.put("instanceId", instanceId);
+            endResult.put("nodeName", node.getNodeName());
+            return AjaxResult.success("工作流已完成", endResult);
+        }
+
+        int effectiveNextIndex = nextIndex;
+        if (exec.getNextNodeCode() != null && !exec.getNextNodeCode().isEmpty()) {
+            int branchIdx = findNodeIndex(nodes, exec.getNextNodeCode(), nextIndex + 1);
+            if (branchIdx >= 0) effectiveNextIndex = branchIdx;
+        }
+
+        String message = exec.getMessageToSend();
+        if (message == null || message.isEmpty()) {
+            message = evolveOrGenerateMessage(companyId, instance, node, customerReply, variables);
+        }
+
+        instance.setCurrentNodeIndex(effectiveNextIndex);
+        instance.setCurrentNodeName(node.getNodeName());
+        instance.setCompletedNodes(currentIndex + 1);
+        instance.setVariables(JSON.toJSONString(variables));
+        instance.setLastActivityTime(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
+        instance.setUpdateBy("system");
+        instance.setUpdateTime(DateUtils.getNowDate());
+        instanceMapper.updateById(instance);
+
+        logNodeExecution(companyId, instanceId, instance.getWorkflowId(), effectiveNextIndex, node,
+                message, null, "sent");
+
+        MessageChannelResult sendResult = null;
+        if (message != null && !message.isEmpty()) {
+            sendResult = deliverMessage(companyId, instance.getContactId(), channelType, message, variables,
+                    instanceId, instance.getWorkflowId());
+            if (sendResult != null && !sendResult.isSuccess()) {
+                logger.warn("动态节点消息发送失败, instanceId={}, nodeType={}, error={}",
+                        instanceId, node.getNodeType(), sendResult.getErrorMsg());
+            }
+        }
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("instanceId", instanceId);
+        result.put("nodeIndex", effectiveNextIndex);
+        result.put("nodeName", node.getNodeName());
+        result.put("nodeType", node.getNodeType());
+        result.put("message", message);
+        result.put("dynamicExecutor", true);
+        result.put("channelType", channelType);
+        result.put("sendResult", sendResult);
+        return AjaxResult.success("动态节点执行成功", result);
+    }
+
     /**
      * 动态AI兜底:未知节点类型自动通过LLM推导执行逻辑并生成结果
      * 这是"即插即用"的关键——任何新节点类型无需改代码即可运行
@@ -1720,6 +1959,10 @@ public class LobsterWorkflowExecutorImpl implements LobsterWorkflowExecutor {
                                                       CompanyWorkflowLobsterNode node, List<CompanyWorkflowLobsterNode> nodes,
                                                       int currentIndex, int nextIndex, Map<String, Object> variables,
                                                       String channelType) {
+        if (dynamicNodeExecutor != null) {
+            return advanceWithDynamicExecutor(companyId, instance, node, nodes, currentIndex, nextIndex,
+                    variables, channelType, null);
+        }
         String nodeConfig = node.getNodeConfig() != null ? node.getNodeConfig() : "{}";
         java.util.Map<String, String> dynVars = new java.util.HashMap<>();
         dynVars.put("nodeName", node.getNodeName());

+ 308 - 24
fs-service/src/main/java/com/fs/company/service/workflow/impl/ToolCallFrameworkImpl.java

@@ -1,22 +1,47 @@
 package com.fs.company.service.workflow.impl;
 
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.company.domain.LobsterToolConfig;
+import com.fs.company.mapper.LobsterAuxiliaryMapper;
 import com.fs.company.mapper.LobsterToolCallMapper;
+import com.fs.company.mapper.LobsterToolConfigMapper;
 import com.fs.company.service.workflow.ToolCallFramework;
+import com.fs.company.service.workflow.api.ApiRegistryService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.*;
 import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
 
 import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 @Service
 public class ToolCallFrameworkImpl implements ToolCallFramework {
 
     private static final Logger logger = LoggerFactory.getLogger(ToolCallFrameworkImpl.class);
+    private static final Pattern TOOL_JSON_PATTERN = Pattern.compile(
+            "\\{\\s*\"toolName\"\\s*:\\s*\"([^\"]+)\"[^}]*\"parameters\"\\s*:\\s*(\\{[^}]*\\})", Pattern.DOTALL);
 
     @Autowired(required = false)
     private LobsterToolCallMapper toolMapper;
 
+    @Autowired(required = false)
+    private LobsterToolConfigMapper toolConfigMapper;
+
+    @Autowired(required = false)
+    private ApiRegistryService apiRegistryService;
+
+    @Autowired(required = false)
+    private LobsterAuxiliaryMapper auxMapper;
+
+    private final RestTemplate restTemplate = new RestTemplate();
+    private final ConcurrentHashMap<String, Map<String, Object>> runtimeTools = new ConcurrentHashMap<>();
+
     public Map<String, Object> queryOrder(String orderNo, Long companyId) {
         return toolMapper != null ? toolMapper.selectOrder(orderNo, companyId) : null;
     }
@@ -30,53 +55,312 @@ public class ToolCallFrameworkImpl implements ToolCallFramework {
         toolMapper.insertSmsLog(companyId, phone, content);
     }
 
-    public List<Map<String, Object>> querySmsLogs(Long companyId) {
-        return toolMapper != null ? toolMapper.selectSmsLogs(companyId) : new ArrayList<>();
+    @Override
+    public ToolCallResult executeTool(String toolName, Map<String, Object> parameters, Long companyId) {
+        if (toolName == null || toolName.isBlank()) {
+            return ToolCallResult.fail("", "toolName empty");
+        }
+        long t0 = System.currentTimeMillis();
+        try {
+            Map<String, Object> params = parameters != null ? parameters : Collections.emptyMap();
+            ToolCallResult result;
+            switch (toolName) {
+                case "query_order":
+                    result = ToolCallResult.ok(toolName,
+                            queryOrder(String.valueOf(params.getOrDefault("orderNo", "")), companyId),
+                            System.currentTimeMillis() - t0);
+                    break;
+                case "query_user_orders":
+                    result = ToolCallResult.ok(toolName, queryUserOrders(companyId), System.currentTimeMillis() - t0);
+                    break;
+                case "send_sms":
+                    sendSms(companyId,
+                            String.valueOf(params.getOrDefault("phone", "")),
+                            String.valueOf(params.getOrDefault("content", "")));
+                    result = ToolCallResult.ok(toolName, Map.of("sent", true), System.currentTimeMillis() - t0);
+                    break;
+                case "apply_coupon":
+                    result = ToolCallResult.ok(toolName, applyCoupon(companyId, params), System.currentTimeMillis() - t0);
+                    break;
+                case "query_product":
+                    result = ToolCallResult.ok(toolName, queryProduct(companyId, params), System.currentTimeMillis() - t0);
+                    break;
+                case "update_crm_followup":
+                    result = ToolCallResult.ok(toolName, updateCrmFollowup(companyId, params), System.currentTimeMillis() - t0);
+                    break;
+                default:
+                    result = executeConfiguredTool(toolName, params, companyId, t0);
+            }
+            recordExecLog(companyId, toolName, JSON.toJSONString(params),
+                    result.isSuccess() ? JSON.toJSONString(result.getData()) : result.getError());
+            return result;
+        } catch (Exception e) {
+            logger.warn("[ToolCall] {} failed: {}", toolName, e.getMessage());
+            return ToolCallResult.fail(toolName, e.getMessage());
+        }
     }
 
-    public List<Map<String, Object>> queryByParams(String sql, List<Object> params) {
-        return toolMapper != null ? toolMapper.selectByParams(sql, params) : new ArrayList<>();
-    }
+    private ToolCallResult executeConfiguredTool(String toolName, Map<String, Object> params,
+                                                  Long companyId, long t0) {
+        LobsterToolConfig cfg = toolConfigMapper != null
+                ? toolConfigMapper.selectByCompanyAndName(companyId, toolName) : null;
+        String runtimeKey = companyId + ":" + toolName;
+        Map<String, Object> runtimeCfg = runtimeTools.get(runtimeKey);
 
-    public void recordExecLog(Long companyId, String toolName, String params, String result) {
-        if (toolMapper == null) return;
-        toolMapper.insertExecLog(companyId, toolName, params, result);
-    }
+        if (cfg == null && runtimeCfg == null) {
+            return ToolCallResult.fail(toolName, "tool not registered: " + toolName);
+        }
 
-    public List<Map<String, Object>> getRecentExecLogs(Long companyId, int limit) {
-        return toolMapper != null ? toolMapper.selectRecentExecLogs(companyId, limit) : new ArrayList<>();
-    }
+        String toolType = cfg != null ? cfg.getToolType() : String.valueOf(runtimeCfg.get("toolType"));
+        String configJson = cfg != null ? cfg.getConfigJson() : JSON.toJSONString(runtimeCfg.get("config"));
 
-    public String getOrderStatusDesc(String code) {
-        switch (code) {
-            case "0": return "待支付";
-            case "1": return "已支付";
-            default: return "未知";
+        if ("http".equalsIgnoreCase(toolType) || "builtin".equalsIgnoreCase(toolType)) {
+            return invokeHttpTool(toolName, configJson, params, t0);
         }
+        return ToolCallResult.fail(toolName, "unsupported toolType: " + toolType);
     }
 
-    @Override
-    public ToolCallResult executeTool(String toolName, Map<String, Object> parameters, Long companyId) {
-        return null;
+    private ToolCallResult invokeHttpTool(String toolName, String configJson,
+                                           Map<String, Object> params, long t0) {
+        try {
+            JSONObject cfg = configJson != null ? JSON.parseObject(configJson) : new JSONObject();
+            String url = cfg.getString("url");
+            String apiKey = cfg.getString("apiKey");
+            String method = cfg.getString("method");
+
+            if (url == null && apiKey != null && apiRegistryService != null) {
+                ApiRegistryService.ApiEndpoint ep = apiRegistryService.get(apiKey);
+                if (ep != null) {
+                    url = ep.baseUrl;
+                    HttpHeaders headers = new HttpHeaders();
+                    apiRegistryService.buildAuthHeaders(apiKey).forEach(headers::set);
+                    HttpEntity<Map<String, Object>> entity = new HttpEntity<>(params, headers);
+                    ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
+                    return ToolCallResult.ok(toolName, resp.getBody(), System.currentTimeMillis() - t0);
+                }
+            }
+
+            if (url == null) return ToolCallResult.fail(toolName, "missing url in tool config");
+
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            HttpEntity<Map<String, Object>> entity = new HttpEntity<>(params, headers);
+            HttpMethod httpMethod = "GET".equalsIgnoreCase(method) ? HttpMethod.GET : HttpMethod.POST;
+            ResponseEntity<String> resp = restTemplate.exchange(url, httpMethod, entity, String.class);
+            return ToolCallResult.ok(toolName, resp.getBody(), System.currentTimeMillis() - t0);
+        } catch (Exception e) {
+            return ToolCallResult.fail(toolName, e.getMessage());
+        }
     }
 
     @Override
     public boolean isToolAvailable(String toolName, Long companyId) {
-        return false;
+        if (toolName == null) return false;
+        if ("query_order".equals(toolName) || "query_user_orders".equals(toolName) || "send_sms".equals(toolName)
+                || "apply_coupon".equals(toolName) || "query_product".equals(toolName)
+                || "update_crm_followup".equals(toolName)) {
+            return toolMapper != null || auxMapper != null;
+        }
+        if (toolConfigMapper != null) {
+            LobsterToolConfig cfg = toolConfigMapper.selectByCompanyAndName(companyId, toolName);
+            if (cfg != null && (cfg.getEnabled() == null || cfg.getEnabled() == 1)) return true;
+        }
+        return runtimeTools.containsKey(companyId + ":" + toolName);
     }
 
     @Override
     public List<Map<String, Object>> getAvailableTools(Long companyId) {
-        return List.of();
+        List<Map<String, Object>> list = new ArrayList<>();
+        list.add(toolMeta("query_order", "builtin", "查询订单"));
+        list.add(toolMeta("query_user_orders", "builtin", "查询用户订单列表"));
+        list.add(toolMeta("send_sms", "builtin", "发送短信"));
+        list.add(toolMeta("apply_coupon", "builtin", "发放优惠券"));
+        list.add(toolMeta("query_product", "builtin", "查询商品"));
+        list.add(toolMeta("update_crm_followup", "builtin", "更新CRM跟进"));
+        if (toolConfigMapper != null) {
+            List<LobsterToolConfig> cfgs = toolConfigMapper.selectEnabled(companyId);
+            if (cfgs != null) {
+                for (LobsterToolConfig c : cfgs) {
+                    list.add(toolMeta(c.getToolName(), c.getToolType(), c.getDescription()));
+                }
+            }
+        }
+        return list;
+    }
+
+    private Map<String, Object> toolMeta(String name, String type, String desc) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("toolName", name);
+        m.put("toolType", type);
+        m.put("description", desc);
+        return m;
     }
 
     @Override
     public void registerTool(Long companyId, String toolName, String toolType, Map<String, Object> config) {
-
+        if (companyId == null || toolName == null) return;
+        if (toolConfigMapper != null) {
+            LobsterToolConfig entity = new LobsterToolConfig();
+            entity.setCompanyId(companyId);
+            entity.setToolName(toolName);
+            entity.setToolType(toolType != null ? toolType : "http");
+            entity.setConfigJson(config != null ? JSON.toJSONString(config) : "{}");
+            entity.setEnabled(1);
+            entity.setDeleted(0);
+            toolConfigMapper.upsert(entity);
+        } else {
+            Map<String, Object> rt = new HashMap<>();
+            rt.put("toolType", toolType);
+            rt.put("config", config);
+            runtimeTools.put(companyId + ":" + toolName, rt);
+        }
     }
 
     @Override
     public Map<String, Object> extractToolCall(String aiReply) {
-        return Map.of();
+        if (aiReply == null || aiReply.isBlank()) return Collections.emptyMap();
+
+        try {
+            if (aiReply.trim().startsWith("{")) {
+                JSONObject obj = JSON.parseObject(aiReply.trim());
+                if (obj.containsKey("toolName")) {
+                    Map<String, Object> call = new LinkedHashMap<>();
+                    call.put("toolName", obj.getString("toolName"));
+                    call.put("parameters", obj.getJSONObject("parameters"));
+                    return call;
+                }
+            }
+        } catch (Exception ignored) { }
+
+        Matcher m = TOOL_JSON_PATTERN.matcher(aiReply);
+        if (m.find()) {
+            Map<String, Object> call = new LinkedHashMap<>();
+            call.put("toolName", m.group(1));
+            try {
+                call.put("parameters", JSON.parseObject(m.group(2)));
+            } catch (Exception e) {
+                call.put("parameters", Collections.emptyMap());
+            }
+            return call;
+        }
+
+        if (aiReply.contains("[TOOL:")) {
+            int start = aiReply.indexOf("[TOOL:") + 6;
+            int end = aiReply.indexOf("]", start);
+            if (end > start) {
+                String toolName = aiReply.substring(start, end).trim();
+                int jsonStart = aiReply.indexOf("{", end);
+                Map<String, Object> params = Collections.emptyMap();
+                if (jsonStart >= 0) {
+                    try {
+                        params = JSON.parseObject(aiReply.substring(jsonStart));
+                    } catch (Exception ignored) { }
+                }
+                Map<String, Object> call = new LinkedHashMap<>();
+                call.put("toolName", toolName);
+                call.put("parameters", params);
+                return call;
+            }
+        }
+        return Collections.emptyMap();
+    }
+
+    public void recordExecLog(Long companyId, String toolName, String params, String result) {
+        if (toolMapper == null) return;
+        toolMapper.insertExecLog(companyId, toolName, params, result);
+    }
+
+    private Map<String, Object> applyCoupon(Long companyId, Map<String, Object> params) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        String couponCode = String.valueOf(params.getOrDefault("couponCode",
+                params.getOrDefault("couponId", "DEFAULT")));
+        Object contactObj = params.get("contactId");
+        if (contactObj == null) contactObj = params.get("customerId");
+        Long contactId = contactObj instanceof Number ? ((Number) contactObj).longValue() : null;
+        result.put("couponCode", couponCode);
+        result.put("applied", true);
+        if (auxMapper != null && companyId != null) {
+            try {
+                auxMapper.update(String.format(
+                        "INSERT INTO lobster_coupon_record(company_id, customer_id, coupon_type, amount, description, create_time) " +
+                        "VALUES(%d, %s, '%s', %f, '%s', NOW())",
+                        companyId,
+                        contactId != null ? contactId : 0,
+                        sqlEscape(couponCode),
+                        parseDouble(params.get("amount"), 0),
+                        sqlEscape(String.valueOf(params.getOrDefault("desc", "AI tool apply_coupon")))));
+            } catch (Exception e) {
+                logger.debug("[ToolCall] apply_coupon record: {}", e.getMessage());
+            }
+        }
+        return result;
+    }
+
+    private Map<String, Object> queryProduct(Long companyId, Map<String, Object> params) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        String productId = params.get("productId") != null ? params.get("productId").toString() : null;
+        String keyword = params.get("keyword") != null ? params.get("keyword").toString() : null;
+        List<Map<String, Object>> products = new ArrayList<>();
+        if (auxMapper != null && companyId != null) {
+            try {
+                if (productId != null && !productId.isEmpty()) {
+                    long pid = Long.parseLong(productId.replaceAll("[^0-9]", ""));
+                    products = auxMapper.queryForList(
+                            "SELECT id, product_name, product_url, price FROM lobster_product WHERE id="
+                                    + pid + " AND company_id=" + companyId + " LIMIT 1",
+                            companyId);
+                } else if (keyword != null && !keyword.isEmpty()) {
+                    products = auxMapper.queryForList(
+                            "SELECT id, product_name, product_url, price FROM lobster_product WHERE company_id="
+                                    + companyId + " AND product_name LIKE '%" + sqlEscape(keyword)
+                                    + "%' ORDER BY update_time DESC LIMIT 5",
+                            companyId);
+                } else if (auxMapper != null) {
+                    products = auxMapper.queryForList(
+                            "SELECT id, product_name, product_url, price FROM lobster_product WHERE company_id="
+                                    + companyId + " ORDER BY update_time DESC LIMIT 5",
+                            companyId);
+                }
+            } catch (Exception e) {
+                logger.debug("[ToolCall] query_product: {}", e.getMessage());
+            }
+        }
+        result.put("products", products);
+        result.put("count", products.size());
+        return result;
+    }
+
+    private Map<String, Object> updateCrmFollowup(Long companyId, Map<String, Object> params) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        Object contactObj = params.get("contactId");
+        if (contactObj == null) contactObj = params.get("customerId");
+        String contactId = contactObj != null ? contactObj.toString() : "unknown";
+        String followupContent = String.valueOf(params.getOrDefault("content",
+                params.getOrDefault("followupContent", "")));
+        String followupType = String.valueOf(params.getOrDefault("followupType", "ai_tool"));
+        result.put("contactId", contactId);
+        result.put("updated", true);
+        if (auxMapper != null && companyId != null && !followupContent.isEmpty()) {
+            try {
+                auxMapper.update(String.format(
+                        "INSERT INTO lobster_crm_followup(company_id, contact_id, followup_type, content, create_time) " +
+                        "VALUES(%d, '%s', '%s', '%s', NOW())",
+                        companyId, sqlEscape(contactId), sqlEscape(followupType), sqlEscape(followupContent)));
+            } catch (Exception e) {
+                logger.debug("[ToolCall] update_crm_followup: {}", e.getMessage());
+            }
+        }
+        return result;
+    }
+
+    private static double parseDouble(Object v, double defaultVal) {
+        if (v == null) return defaultVal;
+        if (v instanceof Number) return ((Number) v).doubleValue();
+        try { return Double.parseDouble(v.toString()); } catch (Exception e) { return defaultVal; }
+    }
+
+    private static String sqlEscape(String val) {
+        if (val == null) return "";
+        return val.replace("'", "''").replace("\\", "\\\\");
     }
 }

+ 143 - 0
fs-service/src/main/java/com/fs/company/service/workflow/inbound/LobsterInboundService.java

@@ -0,0 +1,143 @@
+package com.fs.company.service.workflow.inbound;
+
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.company.domain.LobsterWorkflowInstance;
+import com.fs.company.mapper.CompanyWorkflowLobsterMapper;
+import com.fs.company.mapper.LobsterWorkflowInstanceMapper;
+import com.fs.company.service.workflow.LobsterWorkflowExecutor;
+import com.fs.company.service.workflow.scheduler.WorkflowTriggerScheduler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 全渠道统一入站网关:客户消息 / 业务事件 -> 工作流实例
+ */
+@Service
+public class LobsterInboundService {
+
+    private static final Logger log = LoggerFactory.getLogger(LobsterInboundService.class);
+
+    @Autowired(required = false)
+    private LobsterWorkflowExecutor workflowExecutor;
+
+    @Autowired(required = false)
+    private LobsterWorkflowInstanceMapper instanceMapper;
+
+    @Autowired(required = false)
+    private CompanyWorkflowLobsterMapper workflowMapper;
+
+    @Autowired(required = false)
+    private WorkflowTriggerScheduler workflowTriggerScheduler;
+
+    /**
+     * 处理入站客户消息
+     */
+    public AjaxResult handleInboundMessage(Long companyId, Long contactId, String channelType,
+                                           String message, Long workflowId, Map<String, Object> extra) {
+        if (workflowExecutor == null) {
+            return AjaxResult.error("工作流执行器不可用");
+        }
+        if (companyId == null || contactId == null) {
+            return AjaxResult.error("companyId/contactId 不能为空");
+        }
+        if (message == null || message.isBlank()) {
+            return AjaxResult.error("消息内容不能为空");
+        }
+        channelType = channelType != null ? channelType : "QW";
+
+        LobsterWorkflowInstance instance = findActiveInstance(companyId, contactId, workflowId);
+        if (instance == null) {
+            Long wfId = workflowId != null ? workflowId : resolveDefaultWorkflowId(companyId, channelType);
+            if (wfId == null) {
+                return AjaxResult.error("未找到可启动的工作流模板");
+            }
+            Map<String, Object> init = extra != null ? new HashMap<>(extra) : new HashMap<>();
+            init.put("channelType", channelType);
+            init.put("inboundSource", "gateway");
+            AjaxResult start = workflowExecutor.startWorkflow(companyId, wfId, contactId, init);
+            if (!Integer.valueOf(200).equals(start.get(AjaxResult.CODE_TAG))) {
+                return start;
+            }
+            instance = extractInstance(start.get(AjaxResult.DATA_TAG));
+            if (instance == null && instanceMapper != null) {
+                List<LobsterWorkflowInstance> active = instanceMapper.selectActiveByContactId(companyId, contactId);
+                if (active != null && !active.isEmpty()) {
+                    instance = active.get(0);
+                }
+            }
+        }
+
+        if (instance == null) {
+            return AjaxResult.error("无法创建或定位工作流实例");
+        }
+
+        log.info("[Inbound] company={} contact={} channel={} instance={} msgLen={}",
+                companyId, contactId, channelType, instance.getId(), message.length());
+        return workflowExecutor.executeNextNode(companyId, instance.getId(), message);
+    }
+
+    /**
+     * 处理业务事件(加粉/成单/打标签等)
+     */
+    public AjaxResult handleBusinessEvent(Long companyId, String eventType, Map<String, Object> payload) {
+        if (workflowTriggerScheduler == null) {
+            return AjaxResult.error("事件调度器不可用");
+        }
+        workflowTriggerScheduler.fireByEvent(eventType, companyId, payload);
+        return AjaxResult.success("事件已投递", Map.of("eventType", eventType, "companyId", companyId));
+    }
+
+    private LobsterWorkflowInstance findActiveInstance(Long companyId, Long contactId, Long workflowId) {
+        if (instanceMapper == null) return null;
+        List<LobsterWorkflowInstance> list = instanceMapper.selectActiveByContactId(companyId, contactId);
+        if (list == null || list.isEmpty()) return null;
+        if (workflowId == null) return list.get(0);
+        for (LobsterWorkflowInstance inst : list) {
+            if (workflowId.equals(inst.getWorkflowId())) return inst;
+        }
+        return list.get(0);
+    }
+
+    private Long resolveDefaultWorkflowId(Long companyId, String channelType) {
+        if (workflowMapper == null) return null;
+        try {
+            var q = new com.fs.company.domain.CompanyWorkflowLobster();
+            q.setCompanyId(companyId);
+            q.setStatus(1);
+            List<com.fs.company.domain.CompanyWorkflowLobster> list = workflowMapper.selectList(
+                    new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>(q)
+                            .orderByDesc("update_time").last("LIMIT 20"));
+            if (list == null || list.isEmpty()) return null;
+            for (var wf : list) {
+                if (wf.getCanvasData() != null && wf.getCanvasData().contains(channelType)) {
+                    return wf.getId();
+                }
+            }
+            return list.get(0).getId();
+        } catch (Exception e) {
+            log.warn("[Inbound] resolveDefaultWorkflowId failed: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    private LobsterWorkflowInstance extractInstance(Object data) {
+        if (data instanceof LobsterWorkflowInstance) {
+            return (LobsterWorkflowInstance) data;
+        }
+        if (data instanceof Map) {
+            Map<?, ?> map = (Map<?, ?>) data;
+            Object id = map.get("instanceId");
+            if (id == null) id = map.get("id");
+            if (id instanceof Number && instanceMapper != null) {
+                return instanceMapper.selectById(((Number) id).longValue());
+            }
+        }
+        return null;
+    }
+}

+ 146 - 12
fs-service/src/main/java/com/fs/company/service/workflow/learning/impl/TenantLearningEngineImpl.java

@@ -182,27 +182,146 @@ public class TenantLearningEngineImpl implements TenantLearningEngine {
     private int analyzeMessageEffectiveness(Long companyId) {
         if (learningMapper == null) return 0;
         try {
-            learningMapper.upsertPattern(companyId, "message", "effectiveness",
-                    "analyzed auto", 0.5, "auto");
-            return 1;
+            List<Map<String, Object>> events = learningMapper.selectReplayBuffer(companyId);
+            if (events == null || events.isEmpty()) return 0;
+            int high = 0, low = 0, discoveries = 0;
+            StringBuilder highSamples = new StringBuilder();
+            StringBuilder lowSamples = new StringBuilder();
+            for (Map<String, Object> event : events) {
+                Integer score = event.get("quality_score") instanceof Number
+                        ? ((Number) event.get("quality_score")).intValue() : null;
+                String reply = (String) event.get("ai_reply");
+                if (reply == null || reply.isEmpty()) continue;
+                String snippet = reply.length() > 40 ? reply.substring(0, 40) + "..." : reply;
+                if (score != null && score >= 120) {
+                    high++;
+                    if (highSamples.length() < 200) highSamples.append(snippet).append("; ");
+                } else if (score != null && score < 80) {
+                    low++;
+                    if (lowSamples.length() < 200) lowSamples.append(snippet).append("; ");
+                }
+            }
+            if (high + low >= 3) {
+                double rate = high * 100.0 / Math.max(1, high + low);
+                String insight = "高质量回复" + high + "条,低质量" + low + "条,有效率"
+                        + String.format("%.0f%%", rate) + "。优质样例: " + highSamples;
+                learningMapper.upsertPattern(companyId, "message", "effectiveness", insight, rate / 100.0, "ReplayAnalyzer");
+                discoveries++;
+            }
+            if (low >= 2 && !lowSamples.toString().isEmpty()) {
+                learningMapper.upsertPattern(companyId, "message", "weak_replies",
+                        "待改进回复样例: " + lowSamples, 0.4, "ReplayAnalyzer");
+                discoveries++;
+            }
+            return discoveries;
         } catch (Exception e) { return 0; }
     }
 
     private int analyzeTimingOptimization(Long companyId) {
         if (learningMapper == null) return 0;
         try {
-            learningMapper.upsertPattern(companyId, "timing", "timing",
-                    "timing analyzed", 0.5, "auto");
-            return 1;
+            List<Map<String, Object>> events = learningMapper.selectReplayBuffer(companyId);
+            if (events == null || events.size() < 5) return 0;
+            Map<Integer, Integer> hourCounts = new LinkedHashMap<>();
+            Map<Integer, Integer> hourHighQuality = new LinkedHashMap<>();
+            for (Map<String, Object> event : events) {
+                Object ct = event.get("create_time");
+                if (ct == null) continue;
+                int hour = -1;
+                if (ct instanceof java.sql.Timestamp) {
+                    hour = ((java.sql.Timestamp) ct).toLocalDateTime().getHour();
+                } else if (ct instanceof java.util.Date) {
+                    java.util.Calendar cal = java.util.Calendar.getInstance();
+                    cal.setTime((java.util.Date) ct);
+                    hour = cal.get(java.util.Calendar.HOUR_OF_DAY);
+                } else {
+                    String s = ct.toString();
+                    if (s.length() >= 13) {
+                        try { hour = Integer.parseInt(s.substring(11, 13)); } catch (Exception ignored) { }
+                    }
+                }
+                if (hour < 0) continue;
+                hourCounts.merge(hour, 1, Integer::sum);
+                Integer score = event.get("quality_score") instanceof Number
+                        ? ((Number) event.get("quality_score")).intValue() : null;
+                if (score != null && score >= 120) {
+                    hourHighQuality.merge(hour, 1, Integer::sum);
+                }
+            }
+            int discoveries = 0;
+            for (Integer hour : hourCounts.keySet()) {
+                int total = hourCounts.get(hour);
+                if (total < 2) continue;
+                int wins = hourHighQuality.getOrDefault(hour, 0);
+                double rate = wins * 100.0 / total;
+                String insight = hour + "点时段交互" + total + "次,高质量率" + String.format("%.0f%%", rate);
+                learningMapper.upsertPattern(companyId, "timing", "hour_" + hour, insight, rate / 100.0, "TimingAnalyzer");
+                discoveries++;
+            }
+            return discoveries;
         } catch (Exception e) { return 0; }
     }
 
     private int analyzeFlowBottlenecks(Long companyId) {
         if (learningMapper == null) return 0;
         try {
-            learningMapper.upsertPattern(companyId, "flow", "bottleneck",
-                    "bottleneck analyzed", 0.5, "auto");
-            return 1;
+            int discoveries = 0;
+            if (auxMapper != null) {
+                List<Map<String, Object>> stuck = auxMapper.selectEventStuckNodes(companyId, 30);
+                if (stuck != null) {
+                    for (Map<String, Object> row : stuck) {
+                        String nodeCode = String.valueOf(row.get("node_code"));
+                        Object cnt = row.get("stuck_count");
+                        Object wait = row.get("avg_wait");
+                        String insight = "节点[" + nodeCode + "]卡住" + cnt + "次,平均等待"
+                                + (wait != null ? wait + "秒" : "未知");
+                        learningMapper.upsertPattern(companyId, "flow", "stuck_" + nodeCode,
+                                insight, 0.8, "FlowAnalyzer");
+                        discoveries++;
+                    }
+                }
+                List<Map<String, Object>> stats = auxMapper.selectEventNodeStats(companyId);
+                if (stats != null) {
+                    Map<String, Integer> failByNode = new LinkedHashMap<>();
+                    for (Map<String, Object> row : stats) {
+                        String outcome = String.valueOf(row.get("outcome"));
+                        if ("fail".equalsIgnoreCase(outcome) || "error".equalsIgnoreCase(outcome)) {
+                            String nodeCode = String.valueOf(row.get("node_code"));
+                            int cnt = row.get("cnt") instanceof Number ? ((Number) row.get("cnt")).intValue() : 0;
+                            failByNode.merge(nodeCode, cnt, Integer::sum);
+                        }
+                    }
+                    for (Map.Entry<String, Integer> e : failByNode.entrySet()) {
+                        if (e.getValue() >= 2) {
+                            learningMapper.upsertPattern(companyId, "flow", "fail_" + e.getKey(),
+                                    "节点[" + e.getKey() + "]失败" + e.getValue() + "次", 0.7, "FlowAnalyzer");
+                            discoveries++;
+                        }
+                    }
+                }
+            }
+            if (discoveries == 0) {
+                List<Map<String, Object>> events = learningMapper.selectReplayBuffer(companyId);
+                Map<String, Integer> nodeLow = new LinkedHashMap<>();
+                if (events != null) {
+                    for (Map<String, Object> event : events) {
+                        String nodeCode = (String) event.get("node_code");
+                        Integer score = event.get("quality_score") instanceof Number
+                                ? ((Number) event.get("quality_score")).intValue() : null;
+                        if (nodeCode != null && score != null && score < 80) {
+                            nodeLow.merge(nodeCode, 1, Integer::sum);
+                        }
+                    }
+                }
+                for (Map.Entry<String, Integer> e : nodeLow.entrySet()) {
+                    if (e.getValue() >= 2) {
+                        learningMapper.upsertPattern(companyId, "flow", "low_quality_" + e.getKey(),
+                                "节点[" + e.getKey() + "]低质量交互" + e.getValue() + "次", 0.6, "FlowAnalyzer");
+                        discoveries++;
+                    }
+                }
+            }
+            return discoveries;
         } catch (Exception e) { return 0; }
     }
 
@@ -267,9 +386,24 @@ public class TenantLearningEngineImpl implements TenantLearningEngine {
     private int generateStrategyRecommendations(Long companyId) {
         if (learningMapper == null) return 0;
         try {
-            learningMapper.upsertPattern(companyId, "strategy", "recommendation",
-                    "automated strategy", 0.6, "auto");
-            return 1;
+            List<Map<String, Object>> patterns = learningMapper.selectPatterns(companyId);
+            if (patterns == null || patterns.isEmpty()) return 0;
+            int discoveries = 0;
+            StringBuilder sb = new StringBuilder();
+            for (Map<String, Object> p : patterns) {
+                Object conf = p.get("confidence");
+                double c = conf instanceof Number ? ((Number) conf).doubleValue() : 0;
+                if (c >= 0.6) {
+                    sb.append("[").append(p.get("pattern_key")).append("] ")
+                            .append(p.get("pattern_value")).append("; ");
+                }
+            }
+            if (sb.length() > 0) {
+                learningMapper.upsertPattern(companyId, "strategy", "recommendation",
+                        "综合策略建议: " + sb, 0.65, "StrategySynthesizer");
+                discoveries++;
+            }
+            return discoveries;
         } catch (Exception e) { return 0; }
     }
 }

+ 22 - 3
fs-service/src/main/java/com/fs/company/service/workflow/pay/PayService.java

@@ -80,14 +80,33 @@ public class PayService {
             Map<String, Object> status = payRouter.queryOrder(gateway, orderNo);
             result.putAll(status);
         } else {
-            // Mock: 本地查库
-            result.put("status", "mock_pending");
+            // 无支付网关时查本地订单表
+            result.put("status", "UNKNOWN");
+            if (auxMapper != null && orderNo != null) {
+                try {
+                    List<Map<String, Object>> rows = auxMapper.queryForList(
+                            "SELECT status, amount, product_name, pay_time FROM lobster_pay_order WHERE order_no='"
+                                    + sqlEscape(orderNo) + "' LIMIT 1", null);
+                    if (rows != null && !rows.isEmpty()) {
+                        Map<String, Object> row = rows.get(0);
+                        result.put("status", row.getOrDefault("status", "CREATED"));
+                        result.put("amount", row.get("amount"));
+                        result.put("productName", row.get("product_name"));
+                        result.put("payTime", row.get("pay_time"));
+                    } else {
+                        result.put("status", "NOT_FOUND");
+                    }
+                } catch (Exception e) {
+                    log.warn("[Pay] 查单失败 orderNo={}: {}", orderNo, e.getMessage());
+                    result.put("status", "QUERY_ERROR");
+                }
+            }
         }
         return result;
     }
 
     private String buildMockPayUrl(String orderNo) {
-        return "https://pay.example.com/cashier?orderNo=" + orderNo;
+        return "/pay/cashier?orderNo=" + orderNo;
     }
 
     private String randomSuffix() {

+ 314 - 207
fs-service/src/main/java/com/fs/company/service/workflow/scheduler/WorkflowTriggerScheduler.java

@@ -1,207 +1,314 @@
-//package com.fs.company.service.workflow.scheduler;
-//
-//import com.fs.company.domain.CompanyWorkflowLobster;
-//import com.fs.company.mapper.CompanyWorkflowLobsterMapper;
-//import com.fs.company.service.workflow.LobsterWorkflowExecutor;
-//import org.slf4j.Logger;
-//import org.slf4j.LoggerFactory;
-//import org.springframework.beans.factory.annotation.Autowired;
-//import org.springframework.scheduling.annotation.Scheduled;
-//import org.springframework.stereotype.Component;
-//
-//import javax.annotation.PostConstruct;
-//import java.time.LocalDateTime;
-//import java.time.LocalTime;
-//import java.util.HashMap;
-//import java.util.List;
-//import java.util.Map;
-//import java.util.concurrent.ConcurrentHashMap;
-//
-///**
-// * 工作流触发调度器(skill.md 要求)
-// * <p>
-// * 三种触发机制:
-// *   1. 时间触发 — Cron / 固定时间 → 扫描启用的工作流并触发
-// *   2. 事件触发 — 客户消息到达时由消息处理器主动调用 fire(eventType)
-// *   3. 条件触发 — 定时巡检触发条件(活跃天数/沉默小时数等)
-// * <p>
-// * 实现:使用 Spring @Scheduled(每分钟检查一次),由 @EnableScheduling 启用
-// */
-//@Component
-//public class WorkflowTriggerScheduler {
-//
-//    private static final Logger log = LoggerFactory.getLogger(WorkflowTriggerScheduler.class);
-//
-//    @Autowired(required = false)
-//    private CompanyWorkflowLobsterMapper workflowMapper;
-//
-//    @Autowired(required = false)
-//    private LobsterWorkflowExecutor workflowExecutor;
-//
-//    @Autowired(required = false)
-//    private com.fs.company.service.workflow.LobsterTestScenarioService testScenarioService;
-//
-//    /** 最近一次触发时间(防重复) — workflowId → lastFireTime */
-//    private final ConcurrentHashMap<Long, LocalDateTime> lastFireTime = new ConcurrentHashMap<>();
-//
-//    @PostConstruct
-//    public void init() {
-//        log.info("[WorkflowTriggerScheduler] 启动,每分钟扫描一次工作流触发");
-//    }
-//
-//    /**
-//     * 每天凌晨 3 点跑全部启用的 E2E 测试场景(回归测试)
-//     */
-//    @Scheduled(cron = "0 0 3 * * ?")
-//    public void runDailyE2eRegression() {
-//        if (testScenarioService == null) return;
-//        try {
-//            int n = testScenarioService.runAllEnabledScenarios();
-//            log.info("[E2E回归] 已触发 {} 个测试场景", n);
-//        } catch (Exception ex) {
-//            log.error("[E2E回归] 失败: {}", ex.getMessage(), ex);
-//        }
-//    }
-//
-//    /**
-//     * 每分钟扫描一次启用状态的工作流,按其触发配置决定是否触发
-//     */
-//    @Scheduled(cron = "0 * * * * ?")
-//    public void scanAndTrigger() {
-//        if (workflowMapper == null) return;
-//        try {
-//            CompanyWorkflowLobster q = new CompanyWorkflowLobster();
-//            q.setStatus(1); // 仅启用
-//            List<CompanyWorkflowLobster> list = workflowMapper.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>(q));
-//            if (list == null || list.isEmpty()) return;
-//
-//            LocalDateTime now = LocalDateTime.now();
-//            int triggered = 0;
-//            for (CompanyWorkflowLobster wf : list) {
-//                if (shouldTrigger(wf, now)) {
-//                    fire(wf, now);
-//                    triggered++;
-//                }
-//            }
-//            if (triggered > 0) {
-//                log.info("[WorkflowTriggerScheduler] 本轮触发 {} 个工作流", triggered);
-//            }
-//        } catch (Exception e) {
-//            log.error("[WorkflowTriggerScheduler] 扫描异常: {}", e.getMessage(), e);
-//        }
-//    }
-//
-//    /** 事件触发入口(消息到达/订单创建等场景,由业务方主动调用) */
-//    public void fireByEvent(String eventType, Long companyId, Object payload) {
-//        log.info("[WorkflowTriggerScheduler] 事件触发 type={} company={} payload={}", eventType, companyId, payload);
-//        if (workflowMapper == null || workflowExecutor == null) return;
-//        try {
-//            CompanyWorkflowLobster q = new CompanyWorkflowLobster();
-//            q.setCompanyId(companyId);
-//            q.setStatus(1);
-//            List<CompanyWorkflowLobster> list = workflowMapper.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>(q));
-//            if (list == null) return;
-//            for (CompanyWorkflowLobster wf : list) {
-//                String evtCfg = parseEventTypeFromCanvas(wf.getCanvasData());
-//                if (eventType != null && eventType.equalsIgnoreCase(evtCfg)) {
-//                    Map<String, Object> vars = new HashMap<>();
-//                    vars.put("eventType", eventType);
-//                    if (payload != null) vars.put("payload", payload);
-//                    Long contactId = payload instanceof Map ?
-//                            (Long) ((Map<?, ?>) payload).get("contactId") : null;
-//                    try {
-//                        workflowExecutor.startWorkflow(companyId, wf.getId(), contactId, vars);
-//                        log.info("[WorkflowTriggerScheduler] 事件 {} 启动工作流 id={}", eventType, wf.getId());
-//                    } catch (Exception ex) {
-//                        log.warn("[WorkflowTriggerScheduler] 启动工作流 {} 失败: {}", wf.getId(), ex.getMessage());
-//                    }
-//                }
-//            }
-//        } catch (Exception e) {
-//            log.error("[WorkflowTriggerScheduler] 事件触发异常: {}", e.getMessage(), e);
-//        }
-//    }
-//
-//    // ════════════ 私有方法 ════════════
-//
-//    /**
-//     * 判断工作流是否应在当前时刻触发
-//     * 规则:
-//     *   - 优先解析 canvasData 中的 startNode.config.fireTime(HH:mm)
-//     *   - 同一工作流 1 分钟内不重复触发
-//     */
-//    private boolean shouldTrigger(CompanyWorkflowLobster wf, LocalDateTime now) {
-//        if (wf == null || wf.getId() == null) return false;
-//        LocalDateTime last = lastFireTime.get(wf.getId());
-//        if (last != null && java.time.Duration.between(last, now).getSeconds() < 60) {
-//            return false;
-//        }
-//        String fireTime = parseFireTimeFromCanvas(wf.getCanvasData());
-//        if (fireTime != null && fireTime.matches("\\d{2}:\\d{2}")) {
-//            try {
-//                LocalTime target = LocalTime.parse(fireTime);
-//                LocalTime curr = now.toLocalTime();
-//                return target.getHour() == curr.getHour() && target.getMinute() == curr.getMinute();
-//            } catch (Exception ignored) {}
-//        }
-//        return false;
-//    }
-//
-//    /** 从 canvasData JSON 抓 startNode.config.fireTime(HH:mm) */
-//    private String parseFireTimeFromCanvas(String canvasData) {
-//        if (canvasData == null || canvasData.isEmpty()) return null;
-//        try {
-//            com.alibaba.fastjson.JSONObject json = com.alibaba.fastjson.JSON.parseObject(canvasData);
-//            com.alibaba.fastjson.JSONArray nodes = json.getJSONArray("nodes");
-//            if (nodes == null) return null;
-//            for (int i = 0; i < nodes.size(); i++) {
-//                com.alibaba.fastjson.JSONObject node = nodes.getJSONObject(i);
-//                if (node == null) continue;
-//                Integer type = node.getInteger("type");
-//                if (type != null && type == 1) { // 1=开始节点
-//                    com.alibaba.fastjson.JSONObject cfg = node.getJSONObject("config");
-//                    if (cfg != null) return cfg.getString("fireTime");
-//                }
-//            }
-//        } catch (Exception ignored) {}
-//        return null;
-//    }
-//
-//    private void fire(CompanyWorkflowLobster wf, LocalDateTime now) {
-//        lastFireTime.put(wf.getId(), now);
-//        log.info("[WorkflowTriggerScheduler] 触发工作流 id={} template={}", wf.getId(), wf.getTemplateName());
-//        if (workflowExecutor == null) {
-//            log.warn("[WorkflowTriggerScheduler] workflowExecutor 未注入,跳过");
-//            return;
-//        }
-//        try {
-//            Map<String, Object> vars = new HashMap<>();
-//            vars.put("triggerSource", "scheduler");
-//            vars.put("triggerTime", now.toString());
-//            // 定时触发不绑定具体 contact,contactId=null
-//            workflowExecutor.startWorkflow(wf.getCompanyId(), wf.getId(), null, vars);
-//        } catch (Exception e) {
-//            log.error("[WorkflowTriggerScheduler] 工作流 {} 执行失败: {}", wf.getId(), e.getMessage(), e);
-//        }
-//    }
-//
-//    /** 从 canvasData startNode.config.eventType 解析事件触发类型 */
-//    private String parseEventTypeFromCanvas(String canvasData) {
-//        if (canvasData == null || canvasData.isEmpty()) return null;
-//        try {
-//            com.alibaba.fastjson.JSONObject json = com.alibaba.fastjson.JSON.parseObject(canvasData);
-//            com.alibaba.fastjson.JSONArray nodes = json.getJSONArray("nodes");
-//            if (nodes == null) return null;
-//            for (int i = 0; i < nodes.size(); i++) {
-//                com.alibaba.fastjson.JSONObject node = nodes.getJSONObject(i);
-//                if (node == null) continue;
-//                Integer type = node.getInteger("type");
-//                if (type != null && type == 1) {
-//                    com.alibaba.fastjson.JSONObject cfg = node.getJSONObject("config");
-//                    if (cfg != null) return cfg.getString("eventType");
-//                }
-//            }
-//        } catch (Exception ignored) {}
-//        return null;
-//    }
-//}
+package com.fs.company.service.workflow.scheduler;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.company.domain.CompanyWorkflowLobster;
+import com.fs.company.domain.CompanyWorkflowLobsterTask;
+import com.fs.company.domain.LobsterWorkflowInstance;
+import com.fs.company.mapper.CompanyWorkflowLobsterMapper;
+import com.fs.company.mapper.CompanyWorkflowLobsterTaskMapper;
+import com.fs.company.mapper.LobsterWorkflowInstanceMapper;
+import com.fs.company.service.workflow.LobsterTestScenarioService;
+import com.fs.company.service.workflow.LobsterWorkflowExecutor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.scheduling.support.CronExpression;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.PostConstruct;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.ZoneId;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 工作流触发调度器
+ * 1. 时间触发 — canvas fireTime + lobster_task Cron
+ * 2. 事件触发 — fireByEvent
+ * 3. 沉默唤醒 — 超时无活动实例自动推进
+ */
+@Component
+public class WorkflowTriggerScheduler {
+
+    private static final Logger log = LoggerFactory.getLogger(WorkflowTriggerScheduler.class);
+
+    @Autowired(required = false)
+    private CompanyWorkflowLobsterMapper workflowMapper;
+
+    @Autowired(required = false)
+    private CompanyWorkflowLobsterTaskMapper taskMapper;
+
+    @Autowired(required = false)
+    private LobsterWorkflowInstanceMapper instanceMapper;
+
+    @Autowired(required = false)
+    private LobsterWorkflowExecutor workflowExecutor;
+
+    @Autowired(required = false)
+    private LobsterTestScenarioService testScenarioService;
+
+    private final ConcurrentHashMap<Long, LocalDateTime> lastFireTime = new ConcurrentHashMap<>();
+    private final ConcurrentHashMap<Long, LocalDateTime> lastTaskFire = new ConcurrentHashMap<>();
+
+    @PostConstruct
+    public void init() {
+        log.info("[WorkflowTriggerScheduler] 已启动:定时/任务/Cron/沉默唤醒/E2E回归");
+    }
+
+    @Scheduled(cron = "0 0 3 * * ?")
+    public void runDailyE2eRegression() {
+        if (testScenarioService == null) return;
+        try {
+            int n = testScenarioService.runAllEnabledScenarios();
+            log.info("[E2E回归] 已触发 {} 个测试场景", n);
+        } catch (Exception ex) {
+            log.error("[E2E回归] 失败: {}", ex.getMessage(), ex);
+        }
+    }
+
+    @Scheduled(cron = "0 * * * * ?")
+    public void scanAndTrigger() {
+        scanCanvasFireTime();
+        scanLobsterTasks();
+    }
+
+    /** 每15分钟:沉默实例唤醒 */
+    @Scheduled(cron = "0 */15 * * * ?")
+    public void scanSilenceWakeUp() {
+        if (instanceMapper == null || workflowExecutor == null) return;
+        try {
+            CompanyWorkflowLobster q = new CompanyWorkflowLobster();
+            q.setStatus(1);
+            List<CompanyWorkflowLobster> workflows = workflowMapper != null
+                    ? workflowMapper.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>(q))
+                    : null;
+            if (workflows == null) return;
+            int woken = 0;
+            for (CompanyWorkflowLobster wf : workflows) {
+                int silenceHours = parseSilenceHours(wf.getCanvasData());
+                if (silenceHours <= 0) continue;
+                List<LobsterWorkflowInstance> instances = instanceMapper.selectByWorkflowId(
+                        wf.getCompanyId(), wf.getId());
+                if (instances == null) continue;
+                for (LobsterWorkflowInstance inst : instances) {
+                    if (!"running".equals(inst.getStatus())) continue;
+                    if (isSilentTooLong(inst, silenceHours)) {
+                        workflowExecutor.executeNextNode(wf.getCompanyId(), inst.getId(),
+                                "[silence_wakeup] 您好,还有什么可以帮您的吗?");
+                        woken++;
+                    }
+                }
+            }
+            if (woken > 0) log.info("[WorkflowTriggerScheduler] 沉默唤醒 {} 个实例", woken);
+        } catch (Exception e) {
+            log.warn("[WorkflowTriggerScheduler] 沉默唤醒异常: {}", e.getMessage());
+        }
+    }
+
+    private void scanCanvasFireTime() {
+        if (workflowMapper == null) return;
+        try {
+            CompanyWorkflowLobster q = new CompanyWorkflowLobster();
+            q.setStatus(1);
+            List<CompanyWorkflowLobster> list = workflowMapper.selectList(
+                    new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>(q));
+            if (list == null || list.isEmpty()) return;
+            LocalDateTime now = LocalDateTime.now();
+            int triggered = 0;
+            for (CompanyWorkflowLobster wf : list) {
+                if (shouldTriggerCanvas(wf, now)) {
+                    fireWorkflow(wf, now, resolveContactId(wf, null));
+                    triggered++;
+                }
+            }
+            if (triggered > 0) log.info("[WorkflowTriggerScheduler] canvas 定时触发 {} 个", triggered);
+        } catch (Exception e) {
+            log.error("[WorkflowTriggerScheduler] canvas 扫描异常: {}", e.getMessage(), e);
+        }
+    }
+
+    private void scanLobsterTasks() {
+        if (taskMapper == null || workflowExecutor == null) return;
+        try {
+            Date now = new Date();
+            List<CompanyWorkflowLobsterTask> tasks = taskMapper.selectPendingTasks(null, now, 100);
+            if (tasks == null || tasks.isEmpty()) return;
+            for (CompanyWorkflowLobsterTask task : tasks) {
+                if (task.getCronExpression() != null && !task.getCronExpression().isEmpty()) {
+                    if (!shouldFireCron(task)) continue;
+                }
+                Long contactId = parseContactFromTaskContent(task.getTaskContent());
+                Map<String, Object> vars = new HashMap<>();
+                vars.put("triggerSource", "lobster_task");
+                vars.put("taskId", task.getId());
+                vars.put("taskName", task.getTaskName());
+                workflowExecutor.startWorkflow(task.getCompanyId(), task.getTemplateId(),
+                        contactId != null ? contactId : 0L, vars);
+                taskMapper.updateExecuteStatus(task.getId(), 2, null, now, null);
+                log.info("[WorkflowTriggerScheduler] 任务触发 template={} task={}", task.getTemplateId(), task.getId());
+            }
+        } catch (Exception e) {
+            log.warn("[WorkflowTriggerScheduler] 任务扫描异常: {}", e.getMessage());
+        }
+    }
+
+    public void fireByEvent(String eventType, Long companyId, Object payload) {
+        log.info("[WorkflowTriggerScheduler] 事件触发 type={} company={}", eventType, companyId);
+        if (workflowMapper == null || workflowExecutor == null || companyId == null) return;
+        try {
+            CompanyWorkflowLobster q = new CompanyWorkflowLobster();
+            q.setCompanyId(companyId);
+            q.setStatus(1);
+            List<CompanyWorkflowLobster> list = workflowMapper.selectList(
+                    new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>(q));
+            if (list == null) return;
+            for (CompanyWorkflowLobster wf : list) {
+                if (!matchesEvent(wf.getCanvasData(), eventType)) continue;
+                Map<String, Object> vars = new HashMap<>();
+                vars.put("eventType", eventType);
+                vars.put("triggerSource", "event");
+                if (payload instanceof Map) {
+                    vars.putAll((Map<String, Object>) payload);
+                } else if (payload != null) {
+                    vars.put("payload", payload);
+                }
+                Long contactId = payload instanceof Map
+                        ? toLong(((Map<?, ?>) payload).get("contactId")) : 0L;
+                contactId = resolveContactId(wf, contactId);
+                workflowExecutor.startWorkflow(companyId, wf.getId(), contactId, vars);
+                log.info("[WorkflowTriggerScheduler] 事件 {} 启动工作流 id={} contact={}", eventType, wf.getId(), contactId);
+            }
+        } catch (Exception e) {
+            log.error("[WorkflowTriggerScheduler] 事件触发异常: {}", e.getMessage(), e);
+        }
+    }
+
+    private boolean matchesEvent(String canvasData, String eventType) {
+        if (eventType == null) return false;
+        String cfg = parseStartNodeConfig(canvasData, "eventType");
+        if (eventType.equalsIgnoreCase(cfg)) return true;
+        if (canvasData != null && canvasData.toLowerCase().contains("\"eventtype\":\"" + eventType.toLowerCase() + "\"")) {
+            return true;
+        }
+        return canvasData != null && canvasData.contains(eventType);
+    }
+
+    private boolean shouldTriggerCanvas(CompanyWorkflowLobster wf, LocalDateTime now) {
+        if (wf == null || wf.getId() == null) return false;
+        LocalDateTime last = lastFireTime.get(wf.getId());
+        if (last != null && java.time.Duration.between(last, now).getSeconds() < 60) return false;
+        String fireTime = parseFireTimeFromCanvas(wf.getCanvasData());
+        if (fireTime != null && fireTime.matches("\\d{2}:\\d{2}")) {
+            try {
+                LocalTime target = LocalTime.parse(fireTime);
+                LocalTime curr = now.toLocalTime();
+                if (target.getHour() == curr.getHour() && target.getMinute() == curr.getMinute()) {
+                    lastFireTime.put(wf.getId(), now);
+                    return true;
+                }
+            } catch (Exception ignored) { }
+        }
+        return false;
+    }
+
+    private boolean shouldFireCron(CompanyWorkflowLobsterTask task) {
+        try {
+            CronExpression cron = CronExpression.parse(task.getCronExpression());
+            LocalDateTime now = LocalDateTime.now();
+            LocalDateTime last = lastTaskFire.get(task.getId());
+            LocalDateTime from = last != null ? last : now.minusMinutes(2);
+            LocalDateTime next = cron.next(from.atZone(ZoneId.systemDefault()).toLocalDateTime());
+            if (next != null && !next.isAfter(now)) {
+                lastTaskFire.put(task.getId(), now);
+                return true;
+            }
+        } catch (Exception e) {
+            log.debug("[WorkflowTriggerScheduler] cron parse fail task={}: {}", task.getId(), e.getMessage());
+        }
+        return false;
+    }
+
+    private void fireWorkflow(CompanyWorkflowLobster wf, LocalDateTime now, Long contactId) {
+        if (workflowExecutor == null) return;
+        try {
+            Map<String, Object> vars = new HashMap<>();
+            vars.put("triggerSource", "scheduler");
+            vars.put("triggerTime", now.toString());
+            workflowExecutor.startWorkflow(wf.getCompanyId(), wf.getId(), contactId != null ? contactId : 0L, vars);
+        } catch (Exception e) {
+            log.error("[WorkflowTriggerScheduler] 工作流 {} 执行失败: {}", wf.getId(), e.getMessage(), e);
+        }
+    }
+
+    private Long resolveContactId(CompanyWorkflowLobster wf, Long fromPayload) {
+        if (fromPayload != null && fromPayload > 0) return fromPayload;
+        String contactStr = parseStartNodeConfig(wf.getCanvasData(), "defaultContactId");
+        if (contactStr != null) {
+            Long c = toLong(contactStr);
+            if (c != null && c > 0) return c;
+        }
+        return 0L;
+    }
+
+    private int parseSilenceHours(String canvasData) {
+        String v = parseStartNodeConfig(canvasData, "silenceHours");
+        if (v == null) return 0;
+        try { return Integer.parseInt(v); } catch (Exception e) { return 0; }
+    }
+
+    private boolean isSilentTooLong(LobsterWorkflowInstance inst, int silenceHours) {
+        String last = inst.getLastActivityTime();
+        if (last == null || last.isEmpty()) return false;
+        try {
+            LocalDateTime lastAct = LocalDateTime.parse(last,
+                    java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
+            return lastAct.plusHours(silenceHours).isBefore(LocalDateTime.now());
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    private Long parseContactFromTaskContent(String taskContent) {
+        if (taskContent == null || taskContent.isEmpty()) return null;
+        try {
+            JSONObject json = JSON.parseObject(taskContent);
+            return toLong(json.get("contactId"));
+        } catch (Exception e) { return null; }
+    }
+
+    private String parseFireTimeFromCanvas(String canvasData) {
+        return parseStartNodeConfig(canvasData, "fireTime");
+    }
+
+    private String parseStartNodeConfig(String canvasData, String key) {
+        if (canvasData == null || canvasData.isEmpty()) return null;
+        try {
+            JSONObject json = JSON.parseObject(canvasData);
+            JSONArray nodes = json.getJSONArray("nodes");
+            if (nodes == null) return null;
+            for (int i = 0; i < nodes.size(); i++) {
+                JSONObject node = nodes.getJSONObject(i);
+                if (node == null) continue;
+                Integer type = node.getInteger("type");
+                if (type != null && type == 1) {
+                    JSONObject cfg = node.getJSONObject("config");
+                    if (cfg != null) return cfg.getString(key);
+                }
+            }
+        } catch (Exception ignored) { }
+        return null;
+    }
+
+    private static Long toLong(Object o) {
+        if (o == null) return 0L;
+        if (o instanceof Number) return ((Number) o).longValue();
+        try { return Long.valueOf(o.toString()); } catch (Exception e) { return 0L; }
+    }
+}

+ 191 - 9
fs-service/src/main/java/com/fs/company/service/workflow/vector/impl/VectorPatternMatcherImpl.java

@@ -1,28 +1,210 @@
 package com.fs.company.service.workflow.vector.impl;
 
-import com.fs.company.mapper.LobsterAuxiliaryMapper;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.fs.company.domain.LobsterVectorStore;
+import com.fs.company.mapper.LobsterVectorStoreMapper;
+import com.fs.company.service.llm.MultiModelRouter;
+import com.fs.company.service.vector.EmbeddingService;
+import com.fs.company.service.workflow.vector.VectorPatternMatcher;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
 import java.util.*;
+import java.util.stream.Collectors;
 
 @Service
-public class VectorPatternMatcherImpl {
+public class VectorPatternMatcherImpl implements VectorPatternMatcher {
 
     private static final Logger logger = LoggerFactory.getLogger(VectorPatternMatcherImpl.class);
 
     @Autowired(required = false)
-    private LobsterAuxiliaryMapper auxMapper;
+    private LobsterVectorStoreMapper vectorStoreMapper;
 
-    public List<Map<String, Object>> searchSimilar(Long companyId, String docType, String query, int topK, double threshold) {
-        if (auxMapper == null) return new ArrayList<>();
-        return auxMapper.selectVectorEmbeddings(companyId, docType);
+    @Autowired(required = false)
+    private EmbeddingService embeddingService;
+
+    @Autowired(required = false)
+    private MultiModelRouter multiModelRouter;
+
+    @Override
+    public void storeVector(Long companyId, String category, String key, String text, Map<String, Object> metadata) {
+        if (vectorStoreMapper == null || companyId == null || text == null || text.isBlank()) return;
+        try {
+            String vectorJson = buildEmbeddingJson(text);
+            LobsterVectorStore entity = new LobsterVectorStore();
+            entity.setCompanyId(companyId);
+            entity.setCategory(category != null ? category : "knowledge");
+            entity.setVecKey(key != null ? key : UUID.randomUUID().toString());
+            entity.setTextContent(text);
+            entity.setVector(vectorJson);
+            entity.setMetadata(metadata != null ? JSON.toJSONString(metadata) : "{}");
+            vectorStoreMapper.upsert(entity);
+        } catch (Exception e) {
+            logger.warn("[VectorPatternMatcher] storeVector failed: {}", e.getMessage());
+        }
+    }
+
+    @Override
+    public List<VectorMatchResult> searchSimilar(Long companyId, String category, String queryText,
+                                                  int topK, double minScore) {
+        if (vectorStoreMapper == null || companyId == null || queryText == null || queryText.isBlank()) {
+            return Collections.emptyList();
+        }
+        int limit = topK > 0 ? topK : 5;
+        try {
+            List<LobsterVectorStore> rows = vectorStoreMapper.selectByCompanyAndCategory(companyId, category);
+            if (rows == null || rows.isEmpty()) {
+                rows = keywordFallback(companyId, category, queryText, limit);
+            }
+            if (rows == null || rows.isEmpty()) return Collections.emptyList();
+
+            float[] queryVec = parseEmbedding(buildEmbeddingJson(queryText));
+            List<VectorMatchResult> scored = new ArrayList<>();
+            for (LobsterVectorStore row : rows) {
+                double score = scoreRow(queryVec, queryText, row);
+                if (score < minScore) continue;
+                VectorMatchResult r = new VectorMatchResult();
+                r.setId(row.getId());
+                r.setCompanyId(row.getCompanyId());
+                r.setCategory(row.getCategory());
+                r.setKey(row.getVecKey());
+                r.setText(row.getTextContent());
+                r.setScore(score);
+                if (row.getMetadata() != null) {
+                    try {
+                        @SuppressWarnings("unchecked")
+                        Map<String, Object> meta = JSON.parseObject(row.getMetadata(), Map.class);
+                        r.setMetadata(meta);
+                    } catch (Exception ignored) {
+                        r.setMetadata(Collections.emptyMap());
+                    }
+                }
+                scored.add(r);
+            }
+            scored.sort((a, b) -> Double.compare(b.getScore(), a.getScore()));
+            return scored.stream().limit(limit).collect(Collectors.toList());
+        } catch (Exception e) {
+            logger.warn("[VectorPatternMatcher] searchSimilar failed: {}", e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    @Override
+    public void deleteVector(Long companyId, String category, String key) {
+        if (vectorStoreMapper == null || companyId == null || key == null) return;
+        try {
+            vectorStoreMapper.deleteByKey(companyId, category, key);
+        } catch (Exception e) {
+            logger.warn("[VectorPatternMatcher] deleteVector failed: {}", e.getMessage());
+        }
+    }
+
+    @Override
+    public List<StrategyMatch> recommendByVector(Long companyId, String scenario, Map<String, Object> context) {
+        String query = scenario;
+        if (context != null && context.get("customerMessage") != null) {
+            query = query + " " + context.get("customerMessage");
+        }
+        List<VectorMatchResult> matches = searchSimilar(companyId, "strategy", query, 5, 0.35);
+        List<StrategyMatch> results = new ArrayList<>();
+        for (VectorMatchResult m : matches) {
+            StrategyMatch sm = new StrategyMatch();
+            sm.setStrategyContent(m.getText());
+            sm.setSemanticScore(m.getScore());
+            double perf = 0.5;
+            if (m.getMetadata() != null && m.getMetadata().get("performanceScore") instanceof Number) {
+                perf = ((Number) m.getMetadata().get("performanceScore")).doubleValue();
+            }
+            sm.setPerformanceScore(perf);
+            sm.setCombinedScore(m.getScore() * 0.7 + perf * 0.3);
+            sm.setMatchReason("语义相似度=" + String.format("%.2f", m.getScore()));
+            results.add(sm);
+        }
+        results.sort((a, b) -> Double.compare(b.getCombinedScore(), a.getCombinedScore()));
+        return results;
+    }
+
+    private List<LobsterVectorStore> keywordFallback(Long companyId, String category, String queryText, int limit) {
+        String keyword = queryText.length() > 20 ? queryText.substring(0, 20) : queryText;
+        return vectorStoreMapper.searchByKeyword(companyId, category, keyword, limit);
+    }
+
+    private double scoreRow(float[] queryVec, String queryText, LobsterVectorStore row) {
+        float[] rowVec = parseEmbedding(row.getVector());
+        if (queryVec != null && rowVec != null && queryVec.length == rowVec.length) {
+            return cosineSimilarity(queryVec, rowVec);
+        }
+        if (row.getTextContent() != null && queryText != null) {
+            String lowerQ = queryText.toLowerCase(Locale.ROOT);
+            String lowerT = row.getTextContent().toLowerCase(Locale.ROOT);
+            if (lowerT.contains(lowerQ) || lowerQ.contains(lowerT)) return 0.72;
+            long common = commonTokenCount(lowerQ, lowerT);
+            return common > 0 ? Math.min(0.65, 0.3 + common * 0.08) : 0.0;
+        }
+        return 0.0;
+    }
+
+    private String buildEmbeddingJson(String text) {
+        if (embeddingService != null) {
+            try {
+                List<Float> vec = embeddingService.embed(text);
+                if (vec != null && !vec.isEmpty()) return JSON.toJSONString(vec);
+            } catch (Exception e) {
+                logger.debug("[VectorPatternMatcher] EmbeddingService failed: {}", e.getMessage());
+            }
+        }
+        if (multiModelRouter != null) {
+            try {
+                String json = multiModelRouter.generateEmbedding(text);
+                if (json != null && json.startsWith("[")) return json;
+            } catch (Exception e) {
+                logger.debug("[VectorPatternMatcher] MultiModelRouter embedding failed: {}", e.getMessage());
+            }
+        }
+        return JSON.toJSONString(hashEmbedding(text, 128));
+    }
+
+    private float[] parseEmbedding(String vectorJson) {
+        if (vectorJson == null || vectorJson.isBlank() || "[]".equals(vectorJson.trim())) return null;
+        try {
+            List<Float> list = JSON.parseArray(vectorJson, Float.class);
+            if (list == null || list.isEmpty()) return null;
+            float[] arr = new float[list.size()];
+            for (int i = 0; i < list.size(); i++) arr[i] = list.get(i);
+            return arr;
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private double cosineSimilarity(float[] a, float[] b) {
+        double dot = 0, na = 0, nb = 0;
+        for (int i = 0; i < a.length; i++) {
+            dot += a[i] * b[i];
+            na += a[i] * a[i];
+            nb += b[i] * b[i];
+        }
+        if (na == 0 || nb == 0) return 0;
+        return dot / (Math.sqrt(na) * Math.sqrt(nb));
+    }
+
+    private List<Float> hashEmbedding(String text, int dim) {
+        float[] arr = new float[dim];
+        for (String token : text.toLowerCase(Locale.ROOT).split("\\s+")) {
+            int h = token.hashCode();
+            int idx = Math.floorMod(h, dim);
+            arr[idx] += 1.0f;
+        }
+        List<Float> out = new ArrayList<>(dim);
+        for (float v : arr) out.add(v);
+        return out;
     }
 
-    public void indexDocument(Long companyId, String docType, String content, Map<String, String> metadata) {
-        if (auxMapper == null) return;
-        auxMapper.insertVectorEmbedding(companyId, metadata.getOrDefault("source", "manual"), docType, content, "[]");
+    private long commonTokenCount(String a, String b) {
+        Set<String> ta = Arrays.stream(a.split("\\s+")).filter(s -> s.length() > 1).collect(Collectors.toSet());
+        return Arrays.stream(b.split("\\s+")).filter(ta::contains).count();
     }
 }

+ 27 - 30
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java

@@ -35,6 +35,7 @@ import com.fs.core.config.TenantConfigContext;
 import com.fs.course.config.CourseConfig;
 import com.fs.course.config.RandomRedPacketConfig;
 import com.fs.course.config.RandomRedPacketRule;
+import com.fs.course.config.RedPacketConfig;
 import com.fs.course.domain.*;
 import com.fs.course.dto.CoursePackageDTO;
 import com.fs.course.mapper.*;
@@ -83,6 +84,8 @@ import com.fs.sop.mapper.SopUserLogsInfoMapper;
 import com.fs.sop.service.ISopUserLogsInfoService;
 import com.fs.system.mapper.SysDictDataMapper;
 import com.fs.system.service.ISysConfigService;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.service.TenantInfoService;
 import com.fs.voice.utils.StringUtil;
 import com.github.binarywang.wxpay.bean.transfer.TransferBillsResult;
 import com.volcengine.service.vod.IVodService;
@@ -128,6 +131,8 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
     @Autowired
     private ISysConfigService sysConfigService;
     @Autowired
+    private TenantInfoService tenantInfoService;
+    @Autowired
     private OpenIMService openIMService;
     @Autowired
     private CompanyCompanyFsuserMapper companyCompanyFsuserMapper;
@@ -1172,8 +1177,21 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         }
     }
 
+    /**
+     * 获取当前租户的公司名称
+     * @return 租户名称,如果未获取到则返回null
+     */
+    private String getCurrentTenantCompanyName() {
+        Long tenantId = com.fs.wxcid.utils.TenantHelper.getTenantId();
+        if (tenantId == null) {
+            return null;
+        }
+        TenantInfo tenantInfo = tenantInfoService.getById(tenantId);
+        return tenantInfo != null ? tenantInfo.getTenantName() : null;
+    }
+
     private R addCustomerService(String qwUserById,String msg){
-        if ("济南联志健康".equals(sysConfigService.getProjectConfig().getCloudHost().getCompanyName())){
+        if ("济南联志健康".equals(getCurrentTenantCompanyName())){
             return R.error(400,msg).put("qrcode", "");
         }
         String json = configService.selectConfigByKey("course.config");
@@ -1675,40 +1693,19 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
             amount = redPackage.getRedPacketMoney();
         } else if (video != null) {
             amount = video.getRedPacketMoney();
-            //是否开启了随机红包
-            String json = configService.selectConfigByKey("randomRedpacket:config");
-            if (StringUtils.isNotBlank(json)) {
-                RandomRedPacketConfig randomRedPacket = JSONUtil.toBean(json, RandomRedPacketConfig.class);
-                //是否开启拼手气红包
-                if (null != randomRedPacket && randomRedPacket.getEnableRandomRedpacket()) {
-                    BigDecimal randomMoney = BigDecimal.ZERO;
-                    //优先读取课程配置随机红包规则
-                    String randomRedPacketRules = video.getRandomRedPacketRules();
-                    if (StringUtils.isNotBlank(randomRedPacketRules)) {
-                        JSONArray array = JSONObject.parseArray(randomRedPacketRules);
-                        List<RandomRedPacketRule> rules = new ArrayList<>();
-                        for (Object o : array) {
-                            rules.add(JSONObject.toJavaObject((JSONObject) o, RandomRedPacketRule.class));
-                        }
-                        randomMoney = getRandomMoneyByRules(rules);
-                    }
-                    //如果课程没有配置 读取后台默认随机规则
-                    else {
-                        randomMoney = getRandomMoneyByRules(randomRedPacket.getRules());
-                    }
-                    //兼容拼手气红包报错情况的发放红包情况
-                    if (BigDecimal.ZERO.compareTo(randomMoney) < 0) {
-                        amount = randomMoney;
-                    }
-                }
-            }
         }
 
         // 准备发送红包参数
         WxSendRedPacketParam packetParam = new WxSendRedPacketParam();
+        String json = redisCache.getCacheObject("sys_config:redPacket.config.new");
 
+        RedPacketConfig config1 = JSONUtil.toBean(json, RedPacketConfig.class);
+        // 判断配置是否为空,为空则提示绑定商户号
+        if (config1 == null) {
+            throw new RuntimeException("请先绑定商户号后再进行操作");
+        }
         //判断是否走服务号openId发红包
-        if (config.getMiniAppAuthType() == 2 && user.getMpOpenId() != null && !sysConfigService.getProjectConfig().getIsNewWxMerchant()) {
+        if (config.getMiniAppAuthType() == 2 && user.getMpOpenId() != null && config1.getIsNew()!=1) {
             packetParam.setOpenId(user.getMpOpenId());
         } else {
             //查询是否绑定小程序
@@ -2614,7 +2611,7 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         }
 
         if (userCompanyUser.getStatus() == 2) {
-            if ("福本源".equals(sysConfigService.getProjectConfig().getCloudHost().getCompanyName())) {
+            if ("福本源".equals(getCurrentTenantCompanyName())) {
                 return ResponseResult.fail(ExceptionCodeEnum.SERVICE_UNAVAILABLE.getCode(), ExceptionCodeEnum.SERVICE_UNAVAILABLE.getDescription());
             } else {
                 return ResponseResult.fail(ExceptionCodeEnum.USER_BLACKLISTED.getCode(), ExceptionCodeEnum.USER_BLACKLISTED.getDescription());

+ 82 - 0
fs-service/src/main/java/com/fs/his/domain/SysRedpacketConfigMore.java

@@ -0,0 +1,82 @@
+package com.fs.his.domain;
+
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 多商户配置对象 sys_redpacket_config_more
+ *
+ * @author fs
+ * @date 2025-11-27
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+public class SysRedpacketConfigMore {
+
+    /** $column.columnComment */
+    private Long id;
+
+    /** 0:老商户 商家转账到零钱 1:新商户 商家转账 */
+    @Excel(name = "0:老商户 商家转账到零钱 1:新商户 商家转账")
+    private Integer isNew;
+
+    /** 公众号appId */
+    @Excel(name = "公众号appId")
+    private String appId;
+
+    /** 小程序appId */
+    @Excel(name = "小程序appId")
+    private String miniappId;
+
+    /** 商户号 */
+    @Excel(name = "商户号")
+    private String mchId;
+
+    /** 商户密钥 */
+    @Excel(name = "商户密钥")
+    private String mchKey;
+
+    /** p12证书文件的绝对路径或者以classpath:开头的类路径. */
+    @Excel(name = "p12证书文件的绝对路径或者以classpath:开头的类路径.")
+    private String keyPath;
+
+    /** apiclient_key.pem证书文件的绝对路径或者以classpath:开头的类路径. */
+    @Excel(name = "apiclient_key.pem证书文件的绝对路径或者以classpath:开头的类路径.")
+    private String privateKeyPath;
+
+    /** apiclient_cert.pem证书文件的绝对路径或者以classpath:开头的类路径. */
+    @Excel(name = "apiclient_cert.pem证书文件的绝对路径或者以classpath:开头的类路径.")
+    private String privateCertPath;
+
+    /** apiV3 秘钥值. */
+    @Excel(name = "apiV3 秘钥值.")
+    private String apiV3Key;
+
+    /** 公钥ID */
+    @Excel(name = "公钥ID")
+    private String publicKeyId;
+
+    /** pub_key.pem证书文件的绝对路径或者以classpath:开头的类路径. */
+    @Excel(name = "pub_key.pem证书文件的绝对路径或者以classpath:开头的类路径.")
+    private String publicKeyPath;
+
+    /** 回调地址 */
+    @Excel(name = "回调地址")
+    private String notifyUrl;
+
+    /** $column.columnComment */
+    @Excel(name = "回调地址")
+    private String notifyUrlScrm;
+
+    /** 分配的租户ID */
+    @Excel(name = "分配的租户ID")
+    private Long tenantId;
+
+    /** 租户名称(非数据库字段) */
+    @TableField(exist = false)
+    private String tenantName;
+
+}

+ 63 - 0
fs-service/src/main/java/com/fs/his/mapper/SysRedpacketConfigMoreMapper.java

@@ -0,0 +1,63 @@
+package com.fs.his.mapper;
+
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.his.domain.SysRedpacketConfigMore;
+
+import java.util.List;
+
+/**
+ * 多商户配置Mapper接口
+ *
+ * @author fs
+ * @date 2025-11-27
+ */
+public interface SysRedpacketConfigMoreMapper extends BaseMapper<SysRedpacketConfigMore> {
+    /**
+     * 查询多商户配置
+     *
+     * @param id 多商户配置主键
+     * @return 多商户配置
+     */
+    SysRedpacketConfigMore selectSysRedpacketConfigMoreById(Long id);
+
+    /**
+     * 查询多商户配置列表
+     *
+     * @param sysRedpacketConfigMore 多商户配置
+     * @return 多商户配置集合
+     */
+    List<SysRedpacketConfigMore> selectSysRedpacketConfigMoreList(SysRedpacketConfigMore sysRedpacketConfigMore);
+
+    /**
+     * 新增多商户配置
+     *
+     * @param sysRedpacketConfigMore 多商户配置
+     * @return 结果
+     */
+    int insertSysRedpacketConfigMore(SysRedpacketConfigMore sysRedpacketConfigMore);
+
+    /**
+     * 修改多商户配置
+     *
+     * @param sysRedpacketConfigMore 多商户配置
+     * @return 结果
+     */
+    int updateSysRedpacketConfigMore(SysRedpacketConfigMore sysRedpacketConfigMore);
+
+    /**
+     * 删除多商户配置
+     *
+     * @param id 多商户配置主键
+     * @return 结果
+     */
+    int deleteSysRedpacketConfigMoreById(Long id);
+
+    /**
+     * 批量删除多商户配置
+     *
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    int deleteSysRedpacketConfigMoreByIds(Long[] ids);
+}

+ 9 - 0
fs-service/src/main/java/com/fs/his/param/UpdateChangeMchIdParam.java

@@ -0,0 +1,9 @@
+package com.fs.his.param;
+
+import lombok.Data;
+
+@Data
+public class UpdateChangeMchIdParam {
+    private Long oldChangeMchId;
+    private Long newChangeMchId;
+}

+ 70 - 0
fs-service/src/main/java/com/fs/his/service/ISysRedpacketConfigMoreService.java

@@ -0,0 +1,70 @@
+package com.fs.his.service;
+
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.fs.common.core.domain.R;
+import com.fs.his.domain.SysRedpacketConfigMore;
+import com.fs.his.param.UpdateChangeMchIdParam;
+
+import java.util.List;
+
+/**
+ * 多商户配置Service接口
+ *
+ * @author fs
+ * @date 2025-11-27
+ */
+public interface ISysRedpacketConfigMoreService extends IService<SysRedpacketConfigMore> {
+    /**
+     * 查询多商户配置
+     *
+     * @param id 多商户配置主键
+     * @return 多商户配置
+     */
+    SysRedpacketConfigMore selectSysRedpacketConfigMoreById(Long id);
+    Long getRedPacketConFig();
+    R updateChangeMchId(UpdateChangeMchIdParam param);
+    R clearRedPacketMchId();
+    void changeRedPacketConfig();
+
+
+    /**
+     * 查询多商户配置列表
+     *
+     * @param sysRedpacketConfigMore 多商户配置
+     * @return 多商户配置集合
+     */
+    List<SysRedpacketConfigMore> selectSysRedpacketConfigMoreList(SysRedpacketConfigMore sysRedpacketConfigMore);
+
+    /**
+     * 新增多商户配置
+     *
+     * @param sysRedpacketConfigMore 多商户配置
+     * @return 结果
+     */
+    int insertSysRedpacketConfigMore(SysRedpacketConfigMore sysRedpacketConfigMore);
+
+    /**
+     * 修改多商户配置
+     *
+     * @param sysRedpacketConfigMore 多商户配置
+     * @return 结果
+     */
+    int updateSysRedpacketConfigMore(SysRedpacketConfigMore sysRedpacketConfigMore);
+
+    /**
+     * 批量删除多商户配置
+     *
+     * @param ids 需要删除的多商户配置主键集合
+     * @return 结果
+     */
+    int deleteSysRedpacketConfigMoreByIds(Long[] ids);
+
+    /**
+     * 删除多商户配置信息
+     *
+     * @param id 多商户配置主键
+     * @return 结果
+     */
+    int deleteSysRedpacketConfigMoreById(Long id);
+}

+ 12 - 2
fs-service/src/main/java/com/fs/his/service/impl/FsStorePaymentServiceImpl.java

@@ -97,6 +97,7 @@ import com.github.binarywang.wxpay.service.TransferService;
 import com.github.binarywang.wxpay.service.WxPayService;
 import com.github.binarywang.wxpay.service.impl.WxPayServiceImpl;
 import com.hc.openapi.tool.fastjson.JSON;
+import io.netty.util.internal.StringUtil;
 import me.chanjar.weixin.common.error.WxErrorException;
 
 import org.apache.commons.lang3.exception.ExceptionUtils;
@@ -631,7 +632,10 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
             // 根据红包模式获取配置
             switch (param.getRedPacketMode()){
                 case 1:
-                    json = configService.selectConfigByKey("redPacket.config");
+                    json = redisCache.getCacheObject("sys_config:redPacket.config.new");
+                    if (StringUtil.isNullOrEmpty(json) || json.isEmpty()) {
+                        json = configService.selectConfigByKey("redPacket.config");
+                    }
                     config = JSONUtil.toBean(json, RedPacketConfig.class);
                     break;
                 case 2:
@@ -674,6 +678,7 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
                 WxPayException wxPayException = (WxPayException) e;
                 String customErrorMsg = wxPayException.getCustomErrorMsg();
                 if (null != customErrorMsg && customErrorMsg.startsWith("商户运营账户资金不足")) {
+                    redisCache.incr("sys_config:redPacket.config.newCount",1L);
                     return R.error("[红包领取] 账户余额不足,请联系管理员!");
                 }
             }
@@ -761,7 +766,10 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
             // 根据红包模式获取配置
             switch (param.getRedPacketMode()){
                 case 1:
-                    json = configService.selectConfigByKey("redPacket.config");
+                    json = redisCache.getCacheObject("sys_config:redPacket.config.new");
+                    if (StringUtil.isNullOrEmpty(json) || json.isEmpty()) {
+                        json = configService.selectConfigByKey("redPacket.config");
+                    }
                     config = JSONUtil.toBean(json, RedPacketConfig.class);
                     break;
                 case 2:
@@ -794,6 +802,7 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
             result.put("isNew",config.getIsNew());
             logger.info("红包返回:{}",result);
 
+
             // 更新账户余额
             logger.info("[更新账户余额] 当前余额{} 更新后余额{}",companyMoney.toPlainString(),companyMoney.subtract(amount).toPlainString());
 
@@ -939,6 +948,7 @@ public class FsStorePaymentServiceImpl implements IFsStorePaymentService {
                 WxPayException wxPayException = (WxPayException) e;
                 String customErrorMsg = wxPayException.getCustomErrorMsg();
                 if (null != customErrorMsg && customErrorMsg.startsWith("商户运营账户资金不足")) {
+                    redisCache.incr("sys_config:redPacket.config.newCount",1L);
                     return R.error("[红包领取] 账户余额不足,请联系管理员!");
                 }
             }

+ 220 - 0
fs-service/src/main/java/com/fs/his/service/impl/SysRedpacketConfigMoreServiceImpl.java

@@ -0,0 +1,220 @@
+package com.fs.his.service.impl;
+
+
+import cn.hutool.json.JSONUtil;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.fs.common.BeanCopyUtils;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.course.config.RedPacketConfig;
+import com.fs.his.domain.SysRedpacketConfigMore;
+import com.fs.his.mapper.SysRedpacketConfigMoreMapper;
+import com.fs.his.param.UpdateChangeMchIdParam;
+import com.fs.his.service.ISysRedpacketConfigMoreService;
+import com.fs.system.service.ISysConfigService;
+import io.netty.util.internal.StringUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * 多商户配置Service业务层处理
+ *
+ * @author fs
+ * @date 2025-11-27
+ */
+@Slf4j
+@Service
+public class SysRedpacketConfigMoreServiceImpl extends ServiceImpl<SysRedpacketConfigMoreMapper, SysRedpacketConfigMore> implements ISysRedpacketConfigMoreService {
+
+
+    @Autowired
+    private ISysConfigService configService;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    /**
+     * 查询多商户配置
+     *
+     * @param id 多商户配置主键
+     * @return 多商户配置
+     */
+    @Override
+    public SysRedpacketConfigMore selectSysRedpacketConfigMoreById(Long id)
+    {
+        return baseMapper.selectSysRedpacketConfigMoreById(id);
+    }
+
+    @Override
+    public Long getRedPacketConFig() {
+        String json = redisCache.getCacheObject("sys_config:redPacket.config.new");
+        if (StringUtil.isNullOrEmpty(json) || json.isEmpty()) {
+            json = configService.selectConfigByKey("redPacket.config");
+        }
+
+        // 如果没有配置,返回null,前端显示"未配置"
+        if (StringUtil.isNullOrEmpty(json) || json.isEmpty()) {
+            return null;
+        }
+
+        RedPacketConfig config = JSONUtil.toBean(json, RedPacketConfig.class);
+        if (config == null || config.getMchId() == null || config.getMchId().isEmpty()) {
+            return null;
+        }
+
+        return Long.valueOf(config.getMchId());
+    }
+
+    @Override
+    public R updateChangeMchId(UpdateChangeMchIdParam param) {
+
+        SysRedpacketConfigMore configMore = baseMapper.selectOne(new QueryWrapper<SysRedpacketConfigMore>().eq("mch_id", param.getNewChangeMchId()));
+
+        if (configMore == null || configMore.getMchId() == null) {
+            return R.error("商户号不存在");
+        }
+
+        RedPacketConfig config = new RedPacketConfig();
+        BeanCopyUtils.copy(configMore,config);
+
+        if (config.getMchId()!=null){
+            redisCache.setCacheObject("sys_config:redPacket.config.new", JSONUtil.toJsonStr(config));
+            redisCache.deleteObject("sys_config:redPacket.config.newCount");
+        }
+
+        return R.ok();
+    }
+
+    @Override
+    public R clearRedPacketMchId() {
+        // 删除 Redis 中的红包商户号配置
+        redisCache.deleteObject("sys_config:redPacket.config.new");
+        redisCache.deleteObject("sys_config:redPacket.config.newCount");
+        log.info("清空当前红包商户号配置");
+        return R.ok("已清空当前商户号配置");
+    }
+
+    @Override
+    public void changeRedPacketConfig() {
+        Integer count = redisCache.getCacheObject("sys_config:redPacket.config.newCount");
+        if (count >= 1000) {
+            String json = redisCache.getCacheObject("sys_config:redPacket.config.new");
+            if (StringUtil.isNullOrEmpty(json) || json.isEmpty()) {
+                json = configService.selectConfigByKey("redPacket.config");
+            }
+
+            RedPacketConfig config   = JSONUtil.toBean(json, RedPacketConfig.class);
+
+            SysRedpacketConfigMore configMore = baseMapper.selectOne(new QueryWrapper<SysRedpacketConfigMore>().eq("mch_id", config.getMchId()));
+
+            if (configMore == null) {
+                log.error("获取不到商户号");
+                return;
+            }
+
+            List<SysRedpacketConfigMore> configMoreList = baseMapper.selectList(
+                    new QueryWrapper<SysRedpacketConfigMore>().orderByAsc("id")
+            );
+
+            // 空列表处理
+            if (configMoreList.isEmpty()) {
+                log.error("商户号列表为空");
+            }
+
+
+            // 查找当前元素的索引
+            int currentIndex = -1;
+            Long currentId = configMore != null ? configMore.getId() : null;
+
+            for (int i = 0; i < configMoreList.size(); i++) {
+                if (configMoreList.get(i).getId().equals(currentId)) {
+                    currentIndex = i;
+                    break;
+                }
+            }
+
+            // 计算下一个索引(使用取模实现循环)
+            int nextIndex = (currentIndex + 1) % configMoreList.size();
+
+            SysRedpacketConfigMore configMoreNew = configMoreList.get(nextIndex);
+
+
+            RedPacketConfig configNew = new RedPacketConfig();
+            BeanCopyUtils.copy(configMoreNew,configNew);
+
+            if (configNew.getMchId()!=null){
+                log.info("自动切换商户号-原:"+configMore.getMchId()+"-新:"+configNew.getMchId());
+                redisCache.setCacheObject("sys_config:redPacket.config.new", JSONUtil.toJsonStr(configNew));
+                redisCache.deleteObject("sys_config:redPacket.config.newCount");
+            }
+        }
+    }
+
+
+
+
+    /**
+     * 查询多商户配置列表
+     *
+     * @param sysRedpacketConfigMore 多商户配置
+     * @return 多商户配置
+     */
+    @Override
+    public List<SysRedpacketConfigMore> selectSysRedpacketConfigMoreList(SysRedpacketConfigMore sysRedpacketConfigMore)
+    {
+        return baseMapper.selectSysRedpacketConfigMoreList(sysRedpacketConfigMore);
+    }
+
+    /**
+     * 新增多商户配置
+     *
+     * @param sysRedpacketConfigMore 多商户配置
+     * @return 结果
+     */
+    @Override
+    public int insertSysRedpacketConfigMore(SysRedpacketConfigMore sysRedpacketConfigMore)
+    {
+        return baseMapper.insertSysRedpacketConfigMore(sysRedpacketConfigMore);
+    }
+
+    /**
+     * 修改多商户配置
+     *
+     * @param sysRedpacketConfigMore 多商户配置
+     * @return 结果
+     */
+    @Override
+    public int updateSysRedpacketConfigMore(SysRedpacketConfigMore sysRedpacketConfigMore)
+    {
+        return baseMapper.updateSysRedpacketConfigMore(sysRedpacketConfigMore);
+    }
+
+    /**
+     * 批量删除多商户配置
+     *
+     * @param ids 需要删除的多商户配置主键
+     * @return 结果
+     */
+    @Override
+    public int deleteSysRedpacketConfigMoreByIds(Long[] ids)
+    {
+        return baseMapper.deleteSysRedpacketConfigMoreByIds(ids);
+    }
+
+    /**
+     * 删除多商户配置信息
+     *
+     * @param id 多商户配置主键
+     * @return 结果
+     */
+    @Override
+    public int deleteSysRedpacketConfigMoreById(Long id)
+    {
+        return baseMapper.deleteSysRedpacketConfigMoreById(id);
+    }
+}

+ 44 - 10
fs-service/src/main/java/com/fs/his/utils/PhoneUtil.java

@@ -26,30 +26,31 @@ public class PhoneUtil {
     * 解密
     */
     public static String decryptPhone(String encryptedText) {
-        String text=null;
+        String text = null;
         try {
             SecretKeySpec secretKey = new SecretKeySpec("AESAabCdeREssREA".getBytes(), "AES");
             Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
             cipher.init(Cipher.DECRYPT_MODE, secretKey);
             byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(encryptedText));
             text = new String(decryptedBytes);
-        } catch (Exception e) {
-            e.printStackTrace();
+        } catch (Exception ignored) {
+            // 非 AES 密文时解密失败属正常,由 decryptAutoPhone 兜底
         }
         return text;
     }
+
     /**
-    * 解密加*
-    */
+     * 解密加*
+     */
     public static String decryptPhoneMk(String encryptedText) {
-        String text=null;
+        String text = null;
         try {
             SecretKeySpec secretKey = new SecretKeySpec("AESAabCdeREssREA".getBytes(), "AES");
             Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
             cipher.init(Cipher.DECRYPT_MODE, secretKey);
             byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(encryptedText));
             text = new String(decryptedBytes);
-            text =text.replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2");
+            text = text.replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2");
         } catch (Exception e) {
             e.printStackTrace();
         }
@@ -64,10 +65,43 @@ public class PhoneUtil {
             return null;
         }
         String text = encryptedText.trim();
-        if (text.length() > 11) {
-            return decryptPhone(text);
+        if (text.length() <= 11 && text.matches("\\d+")) {
+            return text;
         }
-        return text;
+        if (looksLikePlainPhone(text)) {
+            return text;
+        }
+        String decrypted = decryptPhone(text);
+        if (decrypted != null && !decrypted.isEmpty()) {
+            return decrypted;
+        }
+        return looksLikePlainPhone(text) ? text : null;
+    }
+
+    /**
+     * 黑名单校验用手机号:自动解密并归一化(去 +86、空格等)
+     */
+    public static String resolvePhoneForBlacklist(String phone) {
+        String decrypted = decryptAutoPhone(phone);
+        if (decrypted == null || decrypted.isEmpty()) {
+            return null;
+        }
+        return decrypted.replace(" ", "")
+                .replace("-", "")
+                .replace("+86", "")
+                .replace("(", "")
+                .replace(")", "");
+    }
+
+    private static boolean looksLikePlainPhone(String text) {
+        if (text == null || text.isEmpty()) {
+            return false;
+        }
+        if (!text.matches("^[+\\d\\-\\s()]+$")) {
+            return false;
+        }
+        String digits = text.replaceAll("\\D", "");
+        return digits.length() >= 11 && digits.length() <= 15;
     }
 
     public static String decryptAutoPhoneMk(String encryptedText) {

+ 1 - 1
fs-service/src/main/java/com/fs/proxy/domain/ServiceFeeConfig.java

@@ -24,7 +24,7 @@ public class ServiceFeeConfig extends BaseEntity
     @Excel(name = "配置名称")
     private String configName;
 
-    /** 服务类型:1-AI模型训练 2-课程流量费 3-直播流量费 4-AI TOKEN 5-短信发送 6-手拨外呼 7-AI智能外呼 8-微助手 9-账户费 */
+    /** 服务类型:1-AI模型训练 2-课程流量费 3-直播流量费 4-AI TOKEN 5-短信发送 6-手拨外呼 7-AI智能外呼 8-微助手 9-账户费 10-线路外呼费用 */
     @Excel(name = "服务类型")
     private Integer serviceType;
 

+ 28 - 0
fs-service/src/main/java/com/fs/proxy/domain/SmsApiTenantBatchAddResult.java

@@ -0,0 +1,28 @@
+package com.fs.proxy.domain;
+
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 短信接口-租户批量绑定结果
+ */
+@Data
+public class SmsApiTenantBatchAddResult {
+
+    private int successCount;
+
+    private int failCount;
+
+    private List<String> failMessages = new ArrayList<>();
+
+    public void addSuccess() {
+        successCount++;
+    }
+
+    public void addFail(String message) {
+        failCount++;
+        failMessages.add(message);
+    }
+}

+ 7 - 0
fs-service/src/main/java/com/fs/proxy/mapper/CompanySmsApiTenantMapper.java

@@ -20,10 +20,17 @@ public interface CompanySmsApiTenantMapper {
 
     List<CompanySmsApiTenant> selectByCompanyId(@Param("tenantId") Long tenantId);
 
+    /** 按接口+租户查询绑定(唯一键 uk_api_company) */
+    CompanySmsApiTenant selectByApiIdAndTenantId(@Param("apiId") Long apiId, @Param("tenantId") Long tenantId);
+
     int insertSmsApiTenant(CompanySmsApiTenant tenant);
 
     int updateSmsApiTenant(CompanySmsApiTenant tenant);
 
+    int batchUpdatePricing(@Param("ids") List<Long> ids, @Param("data") CompanySmsApiTenant data);
+
+    int batchUpdateStatus(@Param("ids") List<Long> ids, @Param("status") Integer status);
+
     int deleteSmsApiTenantById(Long id);
 
     /** 批量删除某接口的所有租户绑定 */

+ 7 - 0
fs-service/src/main/java/com/fs/proxy/service/ICompanySmsApiTenantService.java

@@ -1,6 +1,7 @@
 package com.fs.proxy.service;
 
 import com.fs.proxy.domain.CompanySmsApiTenant;
+import com.fs.proxy.domain.SmsApiTenantBatchAddResult;
 
 import java.math.BigDecimal;
 import java.util.List;
@@ -21,7 +22,13 @@ public interface ICompanySmsApiTenantService {
 
     int insertSmsApiTenant(CompanySmsApiTenant tenant);
 
+    SmsApiTenantBatchAddResult batchInsertSmsApiTenant(List<Long> apiIds, List<Long> tenantIds, CompanySmsApiTenant template);
+
     int updateSmsApiTenant(CompanySmsApiTenant tenant);
 
+    int batchUpdatePricing(List<Long> ids, CompanySmsApiTenant data);
+
+    int batchUpdateStatus(List<Long> ids, Integer status);
+
     int deleteSmsApiTenantById(Long id);
 }

+ 63 - 0
fs-service/src/main/java/com/fs/proxy/service/impl/CompanySmsApiTenantServiceImpl.java

@@ -1,6 +1,9 @@
 package com.fs.proxy.service.impl;
 
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.StringUtils;
 import com.fs.proxy.domain.CompanySmsApiTenant;
+import com.fs.proxy.domain.SmsApiTenantBatchAddResult;
 import com.fs.proxy.mapper.CompanySmsApiTenantMapper;
 import com.fs.proxy.service.ICompanySmsApiTenantService;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -42,14 +45,74 @@ public class CompanySmsApiTenantServiceImpl implements ICompanySmsApiTenantServi
 
     @Override
     public int insertSmsApiTenant(CompanySmsApiTenant tenant) {
+        if (tenant.getApiId() == null || tenant.getTenantId() == null) {
+            throw new ServiceException("请选择短信接口和租户");
+        }
+        CompanySmsApiTenant existing = smsApiTenantMapper.selectByApiIdAndTenantId(
+                tenant.getApiId(), tenant.getTenantId());
+        if (existing != null) {
+            throw new ServiceException(buildDuplicateMessage(existing, tenant.getApiId(), tenant.getTenantId()));
+        }
         return smsApiTenantMapper.insertSmsApiTenant(tenant);
     }
 
+    @Override
+    public SmsApiTenantBatchAddResult batchInsertSmsApiTenant(List<Long> apiIds, List<Long> tenantIds,
+                                                              CompanySmsApiTenant template) {
+        if (apiIds == null || apiIds.isEmpty() || tenantIds == null || tenantIds.isEmpty()) {
+            throw new ServiceException("请选择至少一个短信接口和一个租户");
+        }
+        SmsApiTenantBatchAddResult result = new SmsApiTenantBatchAddResult();
+        for (Long apiId : apiIds) {
+            for (Long tenantId : tenantIds) {
+                CompanySmsApiTenant existing = smsApiTenantMapper.selectByApiIdAndTenantId(apiId, tenantId);
+                if (existing != null) {
+                    result.addFail(buildDuplicateMessage(existing, apiId, tenantId));
+                    continue;
+                }
+                CompanySmsApiTenant tenant = new CompanySmsApiTenant();
+                tenant.setApiId(apiId);
+                tenant.setTenantId(tenantId);
+                tenant.setPrice(template.getPrice());
+                tenant.setPriority(template.getPriority());
+                tenant.setAllowManual(template.getAllowManual());
+                tenant.setStatus(template.getStatus() != null ? template.getStatus() : 1);
+                smsApiTenantMapper.insertSmsApiTenant(tenant);
+                result.addSuccess();
+            }
+        }
+        return result;
+    }
+
+    private String buildDuplicateMessage(CompanySmsApiTenant existing, Long apiId, Long tenantId) {
+        String tenantName = existing != null && StringUtils.isNotEmpty(existing.getTenantName())
+                ? existing.getTenantName() : String.valueOf(tenantId);
+        String apiName = existing != null && StringUtils.isNotEmpty(existing.getApiName())
+                ? existing.getApiName() : String.valueOf(apiId);
+        return "租户「" + tenantName + "」已绑定短信接口「" + apiName + "」";
+    }
+
     @Override
     public int updateSmsApiTenant(CompanySmsApiTenant tenant) {
         return smsApiTenantMapper.updateSmsApiTenant(tenant);
     }
 
+    @Override
+    public int batchUpdatePricing(List<Long> ids, CompanySmsApiTenant data) {
+        if (ids == null || ids.isEmpty()) {
+            return 0;
+        }
+        return smsApiTenantMapper.batchUpdatePricing(ids, data);
+    }
+
+    @Override
+    public int batchUpdateStatus(List<Long> ids, Integer status) {
+        if (ids == null || ids.isEmpty()) {
+            return 0;
+        }
+        return smsApiTenantMapper.batchUpdateStatus(ids, status);
+    }
+
     @Override
     public int deleteSmsApiTenantById(Long id) {
         return smsApiTenantMapper.deleteSmsApiTenantById(id);

+ 258 - 0
fs-service/src/main/java/com/fs/wx/mp/service/TenantCourseWxMpServiceProvider.java

@@ -0,0 +1,258 @@
+package com.fs.wx.mp.service;
+
+import cn.hutool.crypto.digest.DigestUtil;
+import cn.hutool.json.JSONUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.fs.common.config.RedisTenantContext;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.course.config.CourseConfig;
+import com.fs.course.domain.FsCoursePlaySourceConfig;
+import com.fs.course.service.IFsCoursePlaySourceConfigService;
+import com.fs.system.service.ISysConfigService;
+import lombok.Data;
+import me.chanjar.weixin.mp.api.WxMpService;
+import me.chanjar.weixin.mp.api.impl.WxMpServiceImpl;
+import me.chanjar.weixin.mp.config.impl.WxMpDefaultConfigImpl;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.io.Serializable;
+import java.net.URI;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 按当前租户从 course.config + fs_course_play_source_config(type=2) 构建微信公众号 WxMpService。
+ */
+@Service
+public class TenantCourseWxMpServiceProvider {
+
+    private static final Logger log = LoggerFactory.getLogger(TenantCourseWxMpServiceProvider.class);
+
+    /** 公众号类型:2 */
+    private static final int MP_TYPE = 2;
+
+    private static final String REDIS_CONFIG_KEY = "wx:mp:course:auth:config";
+
+    /** 配置元数据短缓存,secret 变更后通过 fingerprint 自动失效 */
+    private static final int CACHE_MINUTES = 10;
+
+    private final ConcurrentHashMap<String, WxMpService> localServiceCache = new ConcurrentHashMap<>();
+
+    private final ConcurrentHashMap<Long, Object> tenantLocks = new ConcurrentHashMap<>();
+
+    @Autowired
+    private ISysConfigService configService;
+
+    @Autowired
+    private IFsCoursePlaySourceConfigService playSourceConfigService;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    /**
+     * 获取当前租户课程授权公众号 WxMpService(配置来自 course.config 的 userCourseAuthDomain)。
+     */
+    public WxMpService getWxMpService() {
+        Long tenantId = resolveTenantId();
+        WxMpConfigCache configCache = loadConfigCache(tenantId);
+        return buildOrGetLocalService(tenantId, configCache);
+    }
+
+    /**
+     * 清理当前租户公众号配置缓存(secret 变更后可调用)。
+     */
+    public void evictCache() {
+        Long tenantId = resolveTenantId();
+        redisCache.deleteObject(REDIS_CONFIG_KEY);
+        String tenantPrefix = (tenantId == null ? "0" : String.valueOf(tenantId)) + ":";
+        localServiceCache.keySet().removeIf(k -> k.startsWith(tenantPrefix));
+        log.info("[WxMp] 已清理租户 tenantId={} 的公众号配置缓存", tenantId);
+    }
+
+    /**
+     * 按指定 appId 获取 WxMpService(须与 course.config 解析出的 appId 一致)。
+     */
+    public WxMpService getWxMpService(String appId) {
+        WxMpService service = getWxMpService();
+        if (StringUtils.isNotEmpty(appId) && service.getWxMpConfigStorage() != null
+                && !appId.equals(service.getWxMpConfigStorage().getAppId())) {
+            throw new ServiceException("公众号appId与课程授权配置不一致");
+        }
+        return service;
+    }
+
+    private WxMpConfigCache loadConfigCache(Long tenantId) {
+        // 始终以数据库为准,Redis 仅作短缓存;secret 变更后 fingerprint 不同会自动刷新
+        WxMpConfigCache dbConfig = loadFromDb();
+        WxMpConfigCache cached = redisCache.getCacheObject(REDIS_CONFIG_KEY);
+        if (cached != null && cached.isValid()
+                && dbConfig.getConfigFingerprint().equals(cached.getConfigFingerprint())) {
+            return cached;
+        }
+        synchronized (tenantLock(tenantId)) {
+            dbConfig = loadFromDb();
+            cached = redisCache.getCacheObject(REDIS_CONFIG_KEY);
+            if (cached != null && cached.isValid()
+                    && dbConfig.getConfigFingerprint().equals(cached.getConfigFingerprint())) {
+                return cached;
+            }
+            redisCache.setCacheObject(REDIS_CONFIG_KEY, dbConfig, CACHE_MINUTES, TimeUnit.MINUTES);
+            purgeStaleLocalServices(tenantId);
+            log.info("[WxMp] 租户 tenantId={} 刷新公众号配置 appId={} fingerprint={}",
+                    tenantId, dbConfig.getAppId(), dbConfig.getConfigFingerprint());
+            return dbConfig;
+        }
+    }
+
+    private WxMpConfigCache loadFromDb() {
+        String courseJson = configService.selectConfigByKey("course.config");
+        if (StringUtils.isEmpty(courseJson)) {
+            throw new ServiceException("未配置 course.config,无法初始化微信公众号");
+        }
+        CourseConfig courseConfig = JSONUtil.toBean(courseJson, CourseConfig.class);
+        String appId = resolveAppId(courseConfig);
+        if (StringUtils.isEmpty(appId)) {
+            throw new ServiceException("course.config 中 userCourseAuthDomain 未配置有效 appid");
+        }
+
+        String domainAppId = extractAppIdFromUrl(courseConfig.getUserCourseAuthDomain());
+        String mpAppId = StringUtils.trimToNull(courseConfig.getMpAppId());
+        if (StringUtils.isNotEmpty(domainAppId) && StringUtils.isNotEmpty(mpAppId)
+                && !domainAppId.equals(mpAppId)) {
+            log.warn("[WxMp] course.config 授权 appId 不一致: userCourseAuthDomain={}, mpAppId={}",
+                    domainAppId, mpAppId);
+        }
+
+        LambdaQueryWrapper<FsCoursePlaySourceConfig> wrapper = Wrappers.<FsCoursePlaySourceConfig>lambdaQuery()
+                .eq(FsCoursePlaySourceConfig::getAppid, appId)
+                .eq(FsCoursePlaySourceConfig::getType, MP_TYPE)
+                .eq(FsCoursePlaySourceConfig::getIsDel, 0)
+                .orderByDesc(FsCoursePlaySourceConfig::getUpdateTime)
+                .last("limit 1");
+        FsCoursePlaySourceConfig sourceConfig = playSourceConfigService.getOne(wrapper);
+        if (sourceConfig == null) {
+            throw new ServiceException("未找到 appId=" + appId + " 且 type=2 的公众号点播配置,请检查点播配置表 secret 是否与微信公众平台一致");
+        }
+        String secret = StringUtils.trim(sourceConfig.getSecret());
+        if (StringUtils.isEmpty(secret)) {
+            throw new ServiceException("公众号点播配置 secret 为空,appId=" + appId);
+        }
+
+        WxMpConfigCache cache = new WxMpConfigCache();
+        cache.setAppId(StringUtils.trim(sourceConfig.getAppid()));
+        cache.setSecret(secret);
+        cache.setToken(sourceConfig.getToken() == null ? "" : sourceConfig.getToken().trim());
+        cache.setAesKey(sourceConfig.getAesKey() == null ? "" : sourceConfig.getAesKey().trim());
+        cache.setConfigFingerprint(buildFingerprint(cache.getAppId(), cache.getSecret()));
+        log.debug("[WxMp] 加载配置 appId={} secretLen={}", cache.getAppId(), secret.length());
+        return cache;
+    }
+
+    private WxMpService buildOrGetLocalService(Long tenantId, WxMpConfigCache configCache) {
+        String cacheKey = localKey(tenantId, configCache.getConfigFingerprint());
+        return localServiceCache.computeIfAbsent(cacheKey, k -> createWxMpService(configCache));
+    }
+
+    private void purgeStaleLocalServices(Long tenantId) {
+        String tenantPrefix = (tenantId == null ? "0" : String.valueOf(tenantId)) + ":";
+        localServiceCache.keySet().removeIf(k -> k.startsWith(tenantPrefix));
+    }
+
+    private WxMpService createWxMpService(WxMpConfigCache configCache) {
+        WxMpDefaultConfigImpl storage = new WxMpDefaultConfigImpl();
+        storage.setAppId(configCache.getAppId());
+        storage.setSecret(configCache.getSecret());
+        storage.setToken(configCache.getToken());
+        storage.setAesKey(configCache.getAesKey());
+
+        WxMpServiceImpl service = new WxMpServiceImpl();
+        service.setWxMpConfigStorage(storage);
+        return service;
+    }
+
+    /**
+     * 从 userCourseAuthDomain 解析 appid,例如:
+     * https://auth.ylrzfs.com/weixinOauth?appid=wx93ce67750e3cfba3
+     */
+    static String resolveAppId(CourseConfig courseConfig) {
+        if (courseConfig == null) {
+            return null;
+        }
+        String appId = extractAppIdFromUrl(courseConfig.getUserCourseAuthDomain());
+        if (StringUtils.isNotEmpty(appId)) {
+            return appId;
+        }
+        return StringUtils.trimToNull(courseConfig.getMpAppId());
+    }
+
+    static String extractAppIdFromUrl(String url) {
+        if (StringUtils.isEmpty(url)) {
+            return null;
+        }
+        try {
+            URI uri = URI.create(url.trim());
+            String query = uri.getQuery();
+            if (StringUtils.isNotEmpty(query)) {
+                for (String pair : query.split("&")) {
+                    String[] kv = pair.split("=", 2);
+                    if (kv.length == 2 && "appid".equalsIgnoreCase(kv[0].trim())) {
+                        return kv[1].trim();
+                    }
+                }
+            }
+        } catch (Exception ignored) {
+            // fall through
+        }
+        int idx = url.indexOf("appid=");
+        if (idx < 0) {
+            return null;
+        }
+        String rest = url.substring(idx + 6);
+        int amp = rest.indexOf('&');
+        return (amp > 0 ? rest.substring(0, amp) : rest).trim();
+    }
+
+    private Long resolveTenantId() {
+        Long tenantId = RedisTenantContext.getTenantId();
+        if (tenantId == null) {
+            tenantId = SecurityUtils.getTenantId();
+        }
+        return tenantId;
+    }
+
+    private static String localKey(Long tenantId, String fingerprint) {
+        return (tenantId == null ? "0" : tenantId) + ":" + fingerprint;
+    }
+
+    private static String buildFingerprint(String appId, String secret) {
+        return DigestUtil.md5Hex(appId + ":" + StringUtils.trim(secret));
+    }
+
+    private Object tenantLock(Long tenantId) {
+        long key = tenantId == null ? 0L : tenantId;
+        return tenantLocks.computeIfAbsent(key, k -> new Object());
+    }
+
+    @Data
+    public static class WxMpConfigCache implements Serializable {
+        private static final long serialVersionUID = 1L;
+
+        private String appId;
+        private String secret;
+        private String token;
+        private String aesKey;
+        private String configFingerprint;
+
+        boolean isValid() {
+            return StringUtils.isNotEmpty(appId) && StringUtils.isNotEmpty(secret)
+                    && StringUtils.isNotEmpty(configFingerprint);
+        }
+    }
+}

+ 87 - 6
fs-service/src/main/resources/db/tenant-initTable.sql

@@ -2418,12 +2418,20 @@ CREATE TABLE `company_voice`
 DROP TABLE IF EXISTS `company_voice_api`;
 CREATE TABLE `company_voice_api`
 (
-    `api_id`   int NOT NULL AUTO_INCREMENT COMMENT 'ID',
-    `api_name` varchar(100)   NULL DEFAULT NULL COMMENT 'API接口名',
-    `api_type` varchar(50)   NULL DEFAULT NULL COMMENT 'KEY',
-    `api_json` varchar(2000)   NULL DEFAULT NULL COMMENT '接口地址',
-    `status`   tinyint(1) NULL DEFAULT NULL COMMENT '状态',
-    `remark`   varchar(2000)   NULL DEFAULT NULL,
+    `api_id`      int NOT NULL AUTO_INCREMENT COMMENT 'ID',
+    `api_name`    varchar(100)   NULL DEFAULT NULL COMMENT 'API接口名',
+    `api_type`    varchar(50)   NULL DEFAULT NULL COMMENT 'KEY',
+    `api_json`    varchar(2000)   NULL DEFAULT NULL COMMENT '接口配置JSON',
+    `status`      tinyint(1) NULL DEFAULT NULL COMMENT '状态',
+    `remark`      varchar(2000)   NULL DEFAULT NULL,
+    `priority`    int NULL DEFAULT 1 COMMENT '优先级(数字越小越靠前)',
+    `selectable`  tinyint NULL DEFAULT 0 COMMENT '允许手动选择(0否1是)',
+    `sale_price`  decimal(10, 4) NULL DEFAULT NULL COMMENT '租户售价(元/分钟)',
+    `is_primary`  tinyint NULL DEFAULT 0 COMMENT '是否主线路(0否1是)',
+    `api_url`     varchar(500)   NULL DEFAULT NULL COMMENT '接口地址',
+    `dialog_url`  varchar(500)   NULL DEFAULT NULL COMMENT '话术跳转地址',
+    `is_del`      tinyint(1) NULL DEFAULT 0 COMMENT '是否删除(0否1是)',
+    `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
     PRIMARY KEY (`api_id`) USING BTREE
 ) ENGINE = InnoDB AUTO_INCREMENT = 1 COMMENT = '呼叫接口' ROW_FORMAT = DYNAMIC;
 
@@ -18684,6 +18692,60 @@ CREATE TABLE `lobster_learning_corpus`
     KEY                 `idx_company_status` (`company_id`, `status`),
     KEY                 `idx_company_scenario` (`company_id`, `scenario`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售对话语料学习表';
+
+DROP TABLE IF EXISTS `tencent_word`;
+CREATE TABLE `tencent_word`
+(
+    `id`               bigint                                                        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `title`            varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '标题',
+    `type`             varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '类型',
+    `template_version` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci  DEFAULT NULL COMMENT '模板版本',
+    `url`              varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '文档URL',
+    `file_id`          varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '文件ID',
+    `create_by`        varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci  DEFAULT NULL COMMENT '创建者',
+    `create_time`      datetime                                                      DEFAULT NULL COMMENT '创建时间',
+    `update_by`        varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci  DEFAULT NULL COMMENT '更新者',
+    `update_time`      datetime                                                      DEFAULT NULL COMMENT '更新时间',
+    PRIMARY KEY (`id`) USING BTREE,
+    KEY                `idx_file_id` (`file_id`) USING BTREE
+) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='腾讯文档表';
+
+DROP TABLE IF EXISTS `tencent_word_detail`;
+CREATE TABLE `tencent_word_detail`
+(
+    `id`          bigint                                                        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `file_id`     varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '文件ID',
+    `sheet_id`    varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci  NOT NULL COMMENT '工作表ID',
+    `q`           text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci COMMENT '问题',
+    `a`           text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci COMMENT '答案',
+    `create_by`   varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '创建者',
+    `create_time` datetime                                                     DEFAULT NULL COMMENT '创建时间',
+    `update_by`   varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '更新者',
+    `update_time` datetime                                                     DEFAULT NULL COMMENT '更新时间',
+    PRIMARY KEY (`id`) USING BTREE,
+    KEY           `idx_file_id` (`file_id`) USING BTREE,
+    KEY           `idx_sheet_id` (`sheet_id`) USING BTREE
+) ENGINE=InnoDB AUTO_INCREMENT=175 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='腾讯文档详情表';
+
+DROP TABLE IF EXISTS `tencent_word_sheet`;
+CREATE TABLE `tencent_word_sheet`
+(
+    `id`           bigint                                                        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `file_id`      varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '文件ID',
+    `sheet_id`     varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci  NOT NULL COMMENT '工作表ID',
+    `title`        varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '工作表标题',
+    `row_count`    int                                                           DEFAULT NULL COMMENT '行数',
+    `column_count` int                                                           DEFAULT NULL COMMENT '列数',
+    `row_total`    int                                                           DEFAULT NULL COMMENT '总行数',
+    `column_total` int                                                           DEFAULT NULL COMMENT '总列数',
+    `create_by`    varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci  DEFAULT NULL COMMENT '创建者',
+    `create_time`  datetime                                                      DEFAULT NULL COMMENT '创建时间',
+    `update_by`    varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci  DEFAULT NULL COMMENT '更新者',
+    `update_time`  datetime                                                      DEFAULT NULL COMMENT '更新时间',
+    PRIMARY KEY (`id`) USING BTREE,
+    KEY            `idx_file_id` (`file_id`) USING BTREE,
+    KEY            `idx_sheet_id` (`sheet_id`) USING BTREE
+) ENGINE=InnoDB AUTO_INCREMENT=20 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='腾讯文档工作表表';
 SET
 FOREIGN_KEY_CHECKS = 1;
 
@@ -18702,3 +18764,22 @@ CREATE TABLE `fs_course_coupon` (
   `title` varchar(200) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '标题',
   PRIMARY KEY (`id`) USING BTREE
 ) ENGINE=InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='课程优惠券';
+CREATE TABLE `sys_redpacket_config_more` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `is_new` tinyint DEFAULT '1' COMMENT '0:老商户 商家转账到零钱 1:新商户 商家转账',
+  `app_id` varchar(255) DEFAULT NULL COMMENT '公众号appId',
+  `miniapp_id` varchar(255) DEFAULT NULL COMMENT '小程序appId',
+  `mch_id` bigint NOT NULL COMMENT '商户号',
+  `mch_key` varchar(255) DEFAULT NULL COMMENT '商户密钥',
+  `key_path` varchar(255) DEFAULT NULL COMMENT 'p12证书文件的绝对路径或者以classpath:开头的类路径.',
+  `private_key_path` varchar(255) DEFAULT NULL COMMENT 'apiclient_key.pem证书文件的绝对路径或者以classpath:开头的类路径.',
+  `private_cert_path` varchar(255) DEFAULT NULL COMMENT 'apiclient_cert.pem证书文件的绝对路径或者以classpath:开头的类路径.',
+  `api_v3_key` varchar(255) DEFAULT NULL COMMENT 'apiV3 秘钥值.',
+  `public_key_id` varchar(255) DEFAULT NULL COMMENT '公钥ID',
+  `public_key_path` varchar(255) DEFAULT NULL COMMENT 'pub_key.pem证书文件的绝对路径或者以classpath:开头的类路径.',
+  `notify_url` varchar(255) DEFAULT NULL COMMENT '回调地址',
+  `notify_url_scrm` varchar(255) DEFAULT NULL,
+  `tenant_id` bigint DEFAULT NULL COMMENT '租户id',
+  PRIMARY KEY (`id`),
+  KEY `idx_mch_id` (`mch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='多商户配置';

+ 5 - 1
fs-service/src/main/resources/mapper/company/CompanySmsTempMapper.xml

@@ -16,10 +16,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="status"    column="status"    />
         <result property="cateId"    column="cate_id"    />
         <result property="isAudit"    column="is_audit"    />
+        <result property="smsApiIds"    column="sms_api_ids"    />
     </resultMap>
 
     <sql id="selectCompanySmsTempVo">
-        select temp_id, company_id, title, content, temp_code, temp_type, create_time, update_time, status, cate_id,is_audit from company_sms_temp
+        select temp_id, company_id, title, content, temp_code, temp_type, create_time, update_time, status, cate_id, is_audit, sms_api_ids from company_sms_temp
     </sql>
 
     <select id="selectCompanySmsTempList" resultMap="CompanySmsTempResult">
@@ -54,6 +55,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="status != null">status,</if>
             <if test="cateId != null">cate_id,</if>
             <if test="isAudit != null">is_audit,</if>
+            <if test="smsApiIds != null">sms_api_ids,</if>
          </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="companyId != null">#{companyId},</if>
@@ -66,6 +68,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="status != null">#{status},</if>
             <if test="cateId != null">#{cateId},</if>
             <if test="isAudit != null">#{isAudit},</if>
+            <if test="smsApiIds != null">#{smsApiIds},</if>
          </trim>
     </insert>
 
@@ -82,6 +85,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="status != null">status = #{status},</if>
             <if test="cateId != null">cate_id = #{cateId},</if>
             <if test="isAudit != null">is_audit = #{isAudit},</if>
+            <if test="smsApiIds != null">sms_api_ids = #{smsApiIds},</if>
         </trim>
         where temp_id = #{tempId}
     </update>

+ 7 - 5
fs-service/src/main/resources/mapper/company/CompanyWorkflowLobsterMapper.xml

@@ -16,11 +16,12 @@
         <result property="updateBy" column="update_by"/>
         <result property="updateTime" column="update_time"/>
         <result property="delFlag" column="del_flag"/>
+        <result property="canvasData" column="canvas_data"/>
     </resultMap>
 
     <select id="selectTemplateList" resultMap="CompanyWorkflowLobsterResult">
         select id, company_id, template_code, template_name, industry_type, description, status, version,
-               create_by, create_time, update_by, update_time, del_flag
+               create_by, create_time, update_by, update_time, del_flag, canvas_data
         from company_workflow_lobster
         where del_flag = 0
           and company_id = #{companyId}
@@ -29,14 +30,14 @@
 
     <insert id="insertTemplate" useGeneratedKeys="true" keyProperty="id">
         insert into company_workflow_lobster
-        (company_id, template_code, template_name, industry_type, description, status, version, create_by, create_time, update_by, update_time, del_flag)
+        (company_id, template_code, template_name, industry_type, description, status, version, create_by, create_time, update_by, update_time, del_flag, canvas_data)
         values
-        (#{companyId}, #{templateCode}, #{templateName}, #{industryType}, #{description}, #{status}, #{version}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime}, #{delFlag})
+        (#{companyId}, #{templateCode}, #{templateName}, #{industryType}, #{description}, #{status}, #{version}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime}, #{delFlag}, #{canvasData})
     </insert>
 
     <select id="selectTemplateByIdAndCompanyId" resultMap="CompanyWorkflowLobsterResult">
         select id, company_id, template_code, template_name, industry_type, description, status, version,
-               create_by, create_time, update_by, update_time, del_flag
+               create_by, create_time, update_by, update_time, del_flag, canvas_data
         from company_workflow_lobster
         where id = #{id}
           and company_id = #{companyId}
@@ -45,7 +46,7 @@
     </select>
     <select id="selectTemplateListByStatus" resultType="com.fs.company.domain.CompanyWorkflowLobster">
         select id, company_id, template_code, template_name, industry_type, description, status, version,
-               create_by, create_time, update_by, update_time, del_flag
+               create_by, create_time, update_by, update_time, del_flag, canvas_data
         from company_workflow_lobster
         where del_flag = 0
           and company_id = #{companyId}
@@ -63,6 +64,7 @@
             <if test="version != null">version = #{version},</if>
             <if test="updateBy != null">update_by = #{updateBy},</if>
             <if test="updateTime != null">update_time = #{updateTime},</if>
+            <if test="canvasData != null">canvas_data = #{canvasData},</if>
         </trim>
         where id = #{id}
           and company_id = #{companyId}

+ 9 - 5
fs-service/src/main/resources/mapper/company/CompanyWorkflowLobsterTaskMapper.xml

@@ -22,6 +22,10 @@
         <result property="corpId"          column="corp_id"/>
         <result property="companyUserId"   column="company_user_id"/>
         <result property="lobsterNodeId"   column="lobster_node_id"/>
+        <result property="sendTime"        column="send_time"/>
+        <result property="qwUserId"        column="qw_user_id"/>
+        <result property="bindingId"       column="binding_id"/>
+        <result property="externalUserId"  column="external_user_id"/>
         <result property="remark"          column="remark"/>
         <result property="createBy"        column="create_by"/>
         <result property="createTime"      column="create_time"/>
@@ -33,7 +37,7 @@
         select id, company_id, template_id, task_name, task_type, task_content,
                cron_expression, execute_status, execute_count, max_retry, retry_count,
                last_execute_time, next_execute_time, fail_reason, sort_order, del_flag,
-               corp_id, company_user_id, lobster_node_id, remark,
+               corp_id, company_user_id, lobster_node_id, send_time, qw_user_id, binding_id, external_user_id, remark,
                create_by, create_time, update_by, update_time
         from company_workflow_lobster_task
         where id = #{id}
@@ -46,7 +50,7 @@
         select id, company_id, template_id, task_name, task_type, task_content,
                cron_expression, execute_status, execute_count, max_retry, retry_count,
                last_execute_time, next_execute_time, fail_reason, sort_order, del_flag,
-               corp_id, company_user_id, lobster_node_id, remark,
+               corp_id, company_user_id, lobster_node_id, send_time, qw_user_id, binding_id, external_user_id, remark,
                create_by, create_time, update_by, update_time
         from company_workflow_lobster_task
         where template_id = #{templateId}
@@ -59,7 +63,7 @@
         select id, company_id, template_id, task_name, task_type, task_content,
                cron_expression, execute_status, execute_count, max_retry, retry_count,
                last_execute_time, next_execute_time, fail_reason, sort_order, del_flag,
-               corp_id, company_user_id, lobster_node_id, remark,
+               corp_id, company_user_id, lobster_node_id, send_time, qw_user_id, binding_id, external_user_id, remark,
                create_by, create_time, update_by, update_time
         from company_workflow_lobster_task
         where company_id = #{companyId}
@@ -75,13 +79,13 @@
         (company_id, template_id, task_name, task_type, task_content, cron_expression,
          execute_status, execute_count, max_retry, retry_count,
          last_execute_time, next_execute_time, fail_reason, sort_order, del_flag,
-         corp_id, company_user_id, lobster_node_id, remark,
+         corp_id, company_user_id, lobster_node_id, send_time, qw_user_id, binding_id, external_user_id, remark,
          create_by, create_time, update_by, update_time)
         values
         (#{companyId}, #{templateId}, #{taskName}, #{taskType}, #{taskContent}, #{cronExpression},
          #{executeStatus}, #{executeCount}, #{maxRetry}, #{retryCount},
          #{lastExecuteTime}, #{nextExecuteTime}, #{failReason}, #{sortOrder}, #{delFlag},
-         #{corpId}, #{companyUserId}, #{lobsterNodeId}, #{remark},
+         #{corpId}, #{companyUserId}, #{lobsterNodeId}, #{sendTime}, #{qwUserId}, #{bindingId}, #{externalUserId}, #{remark},
          #{createBy}, #{createTime}, #{updateBy}, #{updateTime})
     </insert>
 

+ 9 - 0
fs-service/src/main/resources/mapper/company/LobsterConversationSummaryMapper.xml

@@ -13,6 +13,13 @@
         <result property="sentimentAnalysis" column="sentiment_analysis"/>
         <result property="nextActionSuggestion" column="next_action_suggestion"/>
         <result property="messageCount" column="message_count"/>
+        <result property="summaryText" column="summary_text"/>
+        <result property="externalUserId" column="external_user_id"/>
+        <result property="chatMsgCount" column="chat_msg_count"/>
+        <result property="keyIntents" column="key_intents"/>
+        <result property="keyVariables" column="key_variables"/>
+        <result property="stage" column="stage"/>
+        <result property="nextActionHint" column="next_action_hint"/>
         <result property="delFlag" column="del_flag"/>
         <result property="createBy" column="create_by"/>
         <result property="createTime" column="create_time"/>
@@ -23,6 +30,8 @@
     <sql id="selectVo">
         select id, company_id, instance_id, contact_id, summary_type, summary_content,
                key_points, sentiment_analysis, next_action_suggestion, message_count,
+               summary_text, external_user_id, chat_msg_count, key_intents, key_variables,
+               stage, next_action_hint,
                del_flag, create_by, create_time, update_by, update_time
         from lobster_conversation_summary
     </sql>

+ 9 - 6
fs-service/src/main/resources/mapper/company/LobsterNodeExecutionLogMapper.xml

@@ -7,6 +7,7 @@
         <result property="companyId" column="company_id"/>
         <result property="instanceId" column="instance_id"/>
         <result property="workflowId" column="workflow_id"/>
+        <result property="nodeCode" column="node_code"/>
         <result property="nodeIndex" column="node_index"/>
         <result property="nodeType" column="node_type"/>
         <result property="nodeName" column="node_name"/>
@@ -18,6 +19,7 @@
         <result property="tokenUsage" column="token_usage"/>
         <result property="errorMessage" column="error_message"/>
         <result property="retryCount" column="retry_count"/>
+        <result property="qualityScore" column="quality_score"/>
         <result property="delFlag" column="del_flag"/>
         <result property="createBy" column="create_by"/>
         <result property="createTime" column="create_time"/>
@@ -26,9 +28,9 @@
     </resultMap>
 
     <sql id="selectVo">
-        select id, company_id, instance_id, workflow_id, node_index, node_type, node_name,
+        select id, company_id, instance_id, workflow_id, node_code, node_index, node_type, node_name,
                input_content, output_content, ai_model, status, duration_ms, token_usage,
-               error_message, retry_count, del_flag, create_by, create_time, update_by, update_time
+               error_message, retry_count, quality_score, del_flag, create_by, create_time, update_by, update_time
         from lobster_node_execution_log
     </sql>
 
@@ -46,13 +48,13 @@
 
     <insert id="insert" useGeneratedKeys="true" keyProperty="id">
         insert into lobster_node_execution_log (
-            company_id, instance_id, workflow_id, node_index, node_type, node_name,
+            company_id, instance_id, workflow_id, node_code, node_index, node_type, node_name,
             input_content, output_content, ai_model, status, duration_ms, token_usage,
-            error_message, retry_count, del_flag, create_by, create_time, update_by, update_time
+            error_message, retry_count, quality_score, del_flag, create_by, create_time, update_by, update_time
         ) values (
-            #{companyId}, #{instanceId}, #{workflowId}, #{nodeIndex}, #{nodeType}, #{nodeName},
+            #{companyId}, #{instanceId}, #{workflowId}, #{nodeCode}, #{nodeIndex}, #{nodeType}, #{nodeName},
             #{inputContent}, #{outputContent}, #{aiModel}, #{status}, #{durationMs}, #{tokenUsage},
-            #{errorMessage}, #{retryCount}, #{delFlag}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime}
+            #{errorMessage}, #{retryCount}, #{qualityScore}, #{delFlag}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime}
         )
     </insert>
 
@@ -65,6 +67,7 @@
             <if test="tokenUsage != null">token_usage = #{tokenUsage},</if>
             <if test="errorMessage != null">error_message = #{errorMessage},</if>
             <if test="retryCount != null">retry_count = #{retryCount},</if>
+            <if test="qualityScore != null">quality_score = #{qualityScore},</if>
             <if test="updateBy != null">update_by = #{updateBy},</if>
             update_time = #{updateTime}
         </set>

+ 4 - 1
fs-service/src/main/resources/mapper/company/LobsterSystemPromptMapper.xml

@@ -112,7 +112,10 @@
     </insert>
 
     <select id="selectByScope" resultMap="LobsterSystemPromptResult">
-        SELECT * FROM lobster_prompt_config WHERE scope = #{scope} AND company_id = #{companyId} AND deleted = 0
+        SELECT id, company_id, prompt_type AS prompt_category, content AS prompt_content,
+               workflow_code, node_code, scope, create_time, update_time
+        FROM lobster_prompt_config
+        WHERE scope = #{scope} AND company_id = #{companyId} AND deleted = 0
     </select>
 
     <select id="ensureTable" resultType="java.lang.Integer">

+ 8 - 3
fs-service/src/main/resources/mapper/company/LobsterWorkflowInstanceMapper.xml

@@ -10,6 +10,7 @@
         <result property="status" column="status"/>
         <result property="contactId" column="contact_id"/>
         <result property="contactName" column="contact_name"/>
+        <result property="channelType" column="channel_type"/>
         <result property="currentNodeIndex" column="current_node_index"/>
         <result property="currentNodeName" column="current_node_name"/>
         <result property="totalNodes" column="total_nodes"/>
@@ -32,7 +33,7 @@
 
     <sql id="selectVo">
         select id, company_id, workflow_id, instance_name, status, contact_id, contact_name,
-               current_node_index, current_node_name, total_nodes, completed_nodes,
+               channel_type, current_node_index, current_node_name, total_nodes, completed_nodes,
                context_snapshot, variables, start_time, end_time, last_activity_time,
                error_message, del_flag, control_mode, control_updated_by, control_updated_at,
                create_by, create_time, update_by, update_time
@@ -70,12 +71,12 @@
     <insert id="insert" useGeneratedKeys="true" keyProperty="id">
         insert into lobster_workflow_instance (
             company_id, workflow_id, instance_name, status, contact_id, contact_name,
-            current_node_index, current_node_name, total_nodes, completed_nodes,
+            channel_type, current_node_index, current_node_name, total_nodes, completed_nodes,
             context_snapshot, variables, start_time, end_time, last_activity_time,
             error_message, del_flag, create_by, create_time, update_by, update_time
         ) values (
             #{companyId}, #{workflowId}, #{instanceName}, #{status}, #{contactId}, #{contactName},
-            #{currentNodeIndex}, #{currentNodeName}, #{totalNodes}, #{completedNodes},
+            #{channelType}, #{currentNodeIndex}, #{currentNodeName}, #{totalNodes}, #{completedNodes},
             #{contextSnapshot}, #{variables}, #{startTime}, #{endTime}, #{lastActivityTime},
             #{errorMessage}, #{delFlag}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime}
         )
@@ -85,6 +86,7 @@
         update lobster_workflow_instance
         <set>
             <if test="status != null">status = #{status},</if>
+            <if test="channelType != null">channel_type = #{channelType},</if>
             <if test="currentNodeIndex != null">current_node_index = #{currentNodeIndex},</if>
             <if test="currentNodeName != null">current_node_name = #{currentNodeName},</if>
             <if test="completedNodes != null">completed_nodes = #{completedNodes},</if>
@@ -93,6 +95,9 @@
             <if test="endTime != null">end_time = #{endTime},</if>
             <if test="lastActivityTime != null">last_activity_time = #{lastActivityTime},</if>
             <if test="errorMessage != null">error_message = #{errorMessage},</if>
+            <if test="controlMode != null">control_mode = #{controlMode},</if>
+            <if test="controlUpdatedBy != null">control_updated_by = #{controlUpdatedBy},</if>
+            <if test="controlUpdatedAt != null">control_updated_at = #{controlUpdatedAt},</if>
             <if test="updateBy != null">update_by = #{updateBy},</if>
             update_time = #{updateTime}
         </set>

+ 74 - 0
fs-service/src/main/resources/mapper/company/tenant/TenantCompanyVoiceApiMapper.xml

@@ -0,0 +1,74 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.company.mapper.tenant.TenantCompanyVoiceApiMapper">
+
+    <resultMap type="com.fs.company.domain.tenant.TenantCompanyVoiceApi" id="TenantCompanyVoiceApiResult">
+        <result property="apiId"       column="api_id"      />
+        <result property="apiName"     column="api_name"    />
+        <result property="apiType"     column="api_type"    />
+        <result property="apiJson"     column="api_json"    />
+        <result property="status"      column="status"      />
+        <result property="remark"      column="remark"      />
+        <result property="priority"    column="priority"    />
+        <result property="selectable"  column="selectable"  />
+        <result property="salePrice"   column="sale_price"  />
+        <result property="isPrimary"   column="is_primary"  />
+        <result property="apiUrl"      column="api_url"     />
+        <result property="dialogUrl"   column="dialog_url"  />
+        <result property="isDel"       column="is_del"      />
+        <result property="createTime"  column="create_time" />
+    </resultMap>
+
+    <select id="selectByApiId" resultMap="TenantCompanyVoiceApiResult">
+        select api_id, api_name, api_type, api_json, status, remark,
+               priority, selectable, sale_price, is_primary, api_url, dialog_url, is_del, create_time
+        from company_voice_api
+        where api_id = #{apiId}
+    </select>
+
+    <select id="selectTenantCompanyVoiceApiList" resultMap="TenantCompanyVoiceApiResult">
+        select api_id, api_name, api_type, api_json, status, remark,
+               priority, selectable, sale_price, is_primary, api_url, dialog_url, is_del, create_time
+        from company_voice_api
+        <where>
+            and (is_del = 0 or is_del is null)
+            <if test="apiName != null and apiName != ''"> and api_name like concat('%', #{apiName}, '%')</if>
+            <if test="apiType != null and apiType != ''"> and api_type = #{apiType}</if>
+            <if test="status != null"> and status = #{status}</if>
+        </where>
+        order by priority asc, api_id desc
+    </select>
+
+    <insert id="upsertTenantVoiceApi">
+        insert into company_voice_api
+            (api_id, api_name, api_type, api_json, status, remark, priority, selectable,
+             sale_price, is_primary, api_url, dialog_url, is_del, create_time)
+        values
+            (#{apiId}, #{apiName}, #{apiType}, #{apiJson}, #{status}, #{remark}, #{priority}, #{selectable},
+             #{salePrice}, #{isPrimary}, #{apiUrl}, #{dialogUrl}, #{isDel}, NOW())
+        on duplicate key update
+            api_name = values(api_name),
+            api_type = values(api_type),
+            api_json = values(api_json),
+            status = values(status),
+            remark = values(remark),
+            priority = values(priority),
+            selectable = values(selectable),
+            sale_price = values(sale_price),
+            is_primary = values(is_primary),
+            api_url = values(api_url),
+            dialog_url = values(dialog_url),
+            is_del = values(is_del)
+    </insert>
+
+    <update id="updateStatusByApiId">
+        update company_voice_api set status = #{status} where api_id = #{apiId}
+    </update>
+
+    <update id="markDeletedByApiId">
+        update company_voice_api set status = 0, is_del = 1 where api_id = #{apiId}
+    </update>
+
+</mapper>

+ 126 - 0
fs-service/src/main/resources/mapper/his/SysRedpacketConfigMoreMapper.xml

@@ -0,0 +1,126 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.his.mapper.SysRedpacketConfigMoreMapper">
+
+    <resultMap type="SysRedpacketConfigMore" id="SysRedpacketConfigMoreResult">
+        <result property="id"    column="id"    />
+        <result property="isNew"    column="is_new"    />
+        <result property="appId"    column="app_id"    />
+        <result property="miniappId"    column="miniapp_id"    />
+        <result property="mchId"    column="mch_id"    />
+        <result property="mchKey"    column="mch_key"    />
+        <result property="keyPath"    column="key_path"    />
+        <result property="privateKeyPath"    column="private_key_path"    />
+        <result property="privateCertPath"    column="private_cert_path"    />
+        <result property="apiV3Key"    column="api_v3_key"    />
+        <result property="publicKeyId"    column="public_key_id"    />
+        <result property="publicKeyPath"    column="public_key_path"    />
+        <result property="notifyUrl"    column="notify_url"    />
+        <result property="notifyUrlScrm"    column="notify_url_scrm"    />
+        <result property="tenantId"    column="tenant_id"    />
+        <result property="tenantName"    column="tenant_name"    />
+    </resultMap>
+
+    <sql id="selectSysRedpacketConfigMoreVo">
+        select r.id, r.is_new, r.app_id, r.miniapp_id, r.mch_id, r.mch_key, r.key_path, r.private_key_path, r.private_cert_path, r.api_v3_key, r.public_key_id, r.public_key_path, r.notify_url, r.notify_url_scrm, r.tenant_id, t.tenant_name
+        from sys_redpacket_config_more r
+        left join tenant_info t on r.tenant_id = t.id
+    </sql>
+
+    <select id="selectSysRedpacketConfigMoreList" parameterType="SysRedpacketConfigMore" resultMap="SysRedpacketConfigMoreResult">
+        <include refid="selectSysRedpacketConfigMoreVo"/>
+        <where>
+            <if test="isNew != null "> and r.is_new = #{isNew}</if>
+            <if test="appId != null  and appId != ''"> and r.app_id = #{appId}</if>
+            <if test="miniappId != null  and miniappId != ''"> and r.miniapp_id = #{miniappId}</if>
+            <if test="mchId != null  and mchId != ''"> and r.mch_id = #{mchId}</if>
+            <if test="mchKey != null  and mchKey != ''"> and r.mch_key = #{mchKey}</if>
+            <if test="keyPath != null  and keyPath != ''"> and r.key_path = #{keyPath}</if>
+            <if test="privateKeyPath != null  and privateKeyPath != ''"> and r.private_key_path = #{privateKeyPath}</if>
+            <if test="privateCertPath != null  and privateCertPath != ''"> and r.private_cert_path = #{privateCertPath}</if>
+            <if test="apiV3Key != null  and apiV3Key != ''"> and r.api_v3_key = #{apiV3Key}</if>
+            <if test="publicKeyId != null  and publicKeyId != ''"> and r.public_key_id = #{publicKeyId}</if>
+            <if test="publicKeyPath != null  and publicKeyPath != ''"> and r.public_key_path = #{publicKeyPath}</if>
+            <if test="notifyUrl != null  and notifyUrl != ''"> and r.notify_url = #{notifyUrl}</if>
+            <if test="notifyUrlScrm != null  and notifyUrlScrm != ''"> and r.notify_url_scrm = #{notifyUrlScrm}</if>
+            <if test="tenantId != null"> and r.tenant_id = #{tenantId}</if>
+        </where>
+    </select>
+
+    <select id="selectSysRedpacketConfigMoreById" parameterType="Long" resultMap="SysRedpacketConfigMoreResult">
+        <include refid="selectSysRedpacketConfigMoreVo"/>
+        where r.id = #{id}
+    </select>
+
+    <insert id="insertSysRedpacketConfigMore" parameterType="SysRedpacketConfigMore">
+        insert into sys_redpacket_config_more
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="id != null">id,</if>
+            <if test="isNew != null">is_new,</if>
+            <if test="appId != null">app_id,</if>
+            <if test="miniappId != null">miniapp_id,</if>
+            <if test="mchId != null">mch_id,</if>
+            <if test="mchKey != null">mch_key,</if>
+            <if test="keyPath != null">key_path,</if>
+            <if test="privateKeyPath != null">private_key_path,</if>
+            <if test="privateCertPath != null">private_cert_path,</if>
+            <if test="apiV3Key != null">api_v3_key,</if>
+            <if test="publicKeyId != null">public_key_id,</if>
+            <if test="publicKeyPath != null">public_key_path,</if>
+            <if test="notifyUrl != null">notify_url,</if>
+            <if test="notifyUrlScrm != null">notify_url_scrm,</if>
+            <if test="tenantId != null">tenant_id,</if>
+         </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="id != null">#{id},</if>
+            <if test="isNew != null">#{isNew},</if>
+            <if test="appId != null">#{appId},</if>
+            <if test="miniappId != null">#{miniappId},</if>
+            <if test="mchId != null">#{mchId},</if>
+            <if test="mchKey != null">#{mchKey},</if>
+            <if test="keyPath != null">#{keyPath},</if>
+            <if test="privateKeyPath != null">#{privateKeyPath},</if>
+            <if test="privateCertPath != null">#{privateCertPath},</if>
+            <if test="apiV3Key != null">#{apiV3Key},</if>
+            <if test="publicKeyId != null">#{publicKeyId},</if>
+            <if test="publicKeyPath != null">#{publicKeyPath},</if>
+            <if test="notifyUrl != null">#{notifyUrl},</if>
+            <if test="notifyUrlScrm != null">#{notifyUrlScrm},</if>
+            <if test="tenantId != null">#{tenantId},</if>
+         </trim>
+    </insert>
+
+    <update id="updateSysRedpacketConfigMore" parameterType="SysRedpacketConfigMore">
+        update sys_redpacket_config_more
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="isNew != null">is_new = #{isNew},</if>
+            <if test="appId != null">app_id = #{appId},</if>
+            <if test="miniappId != null">miniapp_id = #{miniappId},</if>
+            <if test="mchId != null">mch_id = #{mchId},</if>
+            <if test="mchKey != null">mch_key = #{mchKey},</if>
+            <if test="keyPath != null">key_path = #{keyPath},</if>
+            <if test="privateKeyPath != null">private_key_path = #{privateKeyPath},</if>
+            <if test="privateCertPath != null">private_cert_path = #{privateCertPath},</if>
+            <if test="apiV3Key != null">api_v3_key = #{apiV3Key},</if>
+            <if test="publicKeyId != null">public_key_id = #{publicKeyId},</if>
+            <if test="publicKeyPath != null">public_key_path = #{publicKeyPath},</if>
+            <if test="notifyUrl != null">notify_url = #{notifyUrl},</if>
+            <if test="notifyUrlScrm != null">notify_url_scrm = #{notifyUrlScrm},</if>
+            <if test="tenantId != null">tenant_id = #{tenantId},</if>
+        </trim>
+        where id = #{id}
+    </update>
+
+    <delete id="deleteSysRedpacketConfigMoreById" parameterType="Long">
+        delete from sys_redpacket_config_more where id = #{id}
+    </delete>
+
+    <delete id="deleteSysRedpacketConfigMoreByIds" parameterType="String">
+        delete from sys_redpacket_config_more where id in
+        <foreach item="id" collection="array" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+</mapper>

+ 8 - 4
fs-service/src/main/resources/mapper/lobster/LobsterAuxiliaryMapper.xml

@@ -169,13 +169,17 @@
 
     <!-- === lobster_test_scenario === -->
     <select id="selectTestScenarios" resultType="java.util.Map">
-        SELECT * FROM lobster_test_scenario WHERE company_id=#{companyId} AND deleted=0 ORDER BY create_time DESC
+        SELECT * FROM lobster_test_scenario WHERE 1=1
+        <if test="companyId != null">AND company_id=#{companyId}</if>
+        <if test="enabled != null">AND enabled=#{enabled}</if>
+        ORDER BY create_time DESC
     </select>
     <select id="selectTestScenarioById" resultType="java.util.Map">
-        SELECT * FROM lobster_test_scenario WHERE id=#{id} AND company_id=#{companyId}
+        SELECT * FROM lobster_test_scenario WHERE id=#{id}
+        <if test="companyId != null">AND company_id=#{companyId}</if>
     </select>
     <insert id="insertTestScenario">
-        INSERT INTO lobster_test_scenario(company_id, scenario_name, workflow_id, test_data, create_time)
+        INSERT INTO lobster_test_scenario(company_id, scenario_name, template_id, user_inputs_json, create_time)
         VALUES(#{companyId},#{scenarioName},#{workflowId},#{testData},NOW())
     </insert>
     <update id="updateTestScenario">
@@ -183,7 +187,7 @@
         WHERE id=#{id}
     </update>
     <delete id="deleteTestScenario">
-        UPDATE lobster_test_scenario SET deleted=1, update_time=NOW() WHERE id=#{id} AND company_id=#{companyId}
+        UPDATE lobster_test_scenario SET enabled=0, update_time=NOW() WHERE id=#{id} AND company_id=#{companyId}
     </delete>
     <insert id="insertTestScenarioResult">
         INSERT INTO lobster_test_scenario_result(company_id, scenario_id, passed, detail, create_time)

+ 70 - 0
fs-service/src/main/resources/mapper/lobster/LobsterE2eRunMapper.xml

@@ -0,0 +1,70 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.company.mapper.LobsterE2eRunMapper">
+
+    <resultMap type="com.fs.company.domain.LobsterE2eRun" id="E2eRunResult">
+        <id property="id" column="id"/>
+        <result property="runId" column="run_id"/>
+        <result property="companyId" column="company_id"/>
+        <result property="templateId" column="template_id"/>
+        <result property="instanceId" column="instance_id"/>
+        <result property="scenarioId" column="scenario_id"/>
+        <result property="businessDesc" column="business_desc"/>
+        <result property="totalScore" column="total_score"/>
+        <result property="passedNodeCnt" column="passed_node_cnt"/>
+        <result property="totalNodeCnt" column="total_node_cnt"/>
+        <result property="durationMs" column="duration_ms"/>
+        <result property="status" column="status"/>
+        <result property="errorMsg" column="error_msg"/>
+        <result property="evolutionCount" column="evolution_count"/>
+        <result property="createBy" column="create_by"/>
+        <result property="createTime" column="create_time"/>
+        <result property="updateTime" column="update_time"/>
+    </resultMap>
+
+    <sql id="selectVo">
+        select id, run_id, company_id, template_id, instance_id, scenario_id,
+               business_desc, total_score, passed_node_cnt, total_node_cnt, duration_ms,
+               status, error_msg, evolution_count, create_by, create_time, update_time
+        from lobster_e2e_run
+    </sql>
+
+    <insert id="insertRun" useGeneratedKeys="true" keyProperty="id">
+        insert into lobster_e2e_run (
+            run_id, company_id, template_id, instance_id, scenario_id,
+            business_desc, total_score, passed_node_cnt, total_node_cnt, duration_ms,
+            status, error_msg, evolution_count, create_by, create_time
+        ) values (
+            #{runId}, #{companyId}, #{templateId}, #{instanceId}, #{scenarioId},
+            #{businessDesc}, #{totalScore}, #{passedNodeCnt}, #{totalNodeCnt}, #{durationMs},
+            #{status}, #{errorMsg}, #{evolutionCount}, #{createBy}, #{createTime}
+        )
+    </insert>
+
+    <select id="selectByRunId" resultMap="E2eRunResult">
+        <include refid="selectVo"/>
+        where run_id = #{runId}
+    </select>
+
+    <select id="selectByCompanyId" resultMap="E2eRunResult">
+        <include refid="selectVo"/>
+        where company_id = #{companyId}
+        order by create_time desc
+    </select>
+
+    <update id="updateStatus">
+        update lobster_e2e_run
+        <set>
+            <if test="status != null">status = #{status},</if>
+            <if test="totalScore != null">total_score = #{totalScore},</if>
+            <if test="passedNodeCnt != null">passed_node_cnt = #{passedNodeCnt},</if>
+            <if test="totalNodeCnt != null">total_node_cnt = #{totalNodeCnt},</if>
+            <if test="durationMs != null">duration_ms = #{durationMs},</if>
+            <if test="evolutionCount != null">evolution_count = #{evolutionCount},</if>
+            <if test="errorMsg != null">error_msg = #{errorMsg},</if>
+            update_time = NOW()
+        </set>
+        where run_id = #{runId}
+    </update>
+
+</mapper>

+ 59 - 0
fs-service/src/main/resources/mapper/lobster/LobsterE2eRunNodeMapper.xml

@@ -0,0 +1,59 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.company.mapper.LobsterE2eRunNodeMapper">
+
+    <resultMap type="com.fs.company.domain.LobsterE2eRunNode" id="E2eRunNodeResult">
+        <id property="id" column="id"/>
+        <result property="runId" column="run_id"/>
+        <result property="nodeSeq" column="node_seq"/>
+        <result property="nodeCode" column="node_code"/>
+        <result property="nodeType" column="node_type"/>
+        <result property="nodeName" column="node_name"/>
+        <result property="turnNo" column="turn_no"/>
+        <result property="userInput" column="user_input"/>
+        <result property="aiOutput" column="ai_output"/>
+        <result property="score" column="score"/>
+        <result property="scoreDetail" column="score_detail"/>
+        <result property="durationMs" column="duration_ms"/>
+        <result property="modelUsed" column="model_used"/>
+        <result property="evolutionHint" column="evolution_hint"/>
+        <result property="passed" column="passed"/>
+        <result property="errorMsg" column="error_msg"/>
+        <result property="createTime" column="create_time"/>
+    </resultMap>
+
+    <insert id="insertNode" useGeneratedKeys="true" keyProperty="id">
+        insert into lobster_e2e_run_node (
+            run_id, node_seq, node_code, node_type, node_name, turn_no,
+            user_input, ai_output, score, score_detail, duration_ms,
+            model_used, evolution_hint, passed, error_msg, create_time
+        ) values (
+            #{runId}, #{nodeSeq}, #{nodeCode}, #{nodeType}, #{nodeName}, #{turnNo},
+            #{userInput}, #{aiOutput}, #{score}, #{scoreDetail}, #{durationMs},
+            #{modelUsed}, #{evolutionHint}, #{passed}, #{errorMsg}, #{createTime}
+        )
+    </insert>
+
+    <insert id="batchInsert">
+        insert into lobster_e2e_run_node (
+            run_id, node_seq, node_code, node_type, node_name, turn_no,
+            user_input, ai_output, score, score_detail, duration_ms,
+            model_used, evolution_hint, passed, error_msg, create_time
+        ) values
+        <foreach collection="list" item="item" separator=",">
+            (#{item.runId}, #{item.nodeSeq}, #{item.nodeCode}, #{item.nodeType}, #{item.nodeName}, #{item.turnNo},
+             #{item.userInput}, #{item.aiOutput}, #{item.score}, #{item.scoreDetail}, #{item.durationMs},
+             #{item.modelUsed}, #{item.evolutionHint}, #{item.passed}, #{item.errorMsg}, #{item.createTime})
+        </foreach>
+    </insert>
+
+    <select id="selectByRunId" resultMap="E2eRunNodeResult">
+        select id, run_id, node_seq, node_code, node_type, node_name, turn_no,
+               user_input, ai_output, score, score_detail, duration_ms,
+               model_used, evolution_hint, passed, error_msg, create_time
+        from lobster_e2e_run_node
+        where run_id = #{runId}
+        order by node_seq asc, turn_no asc
+    </select>
+
+</mapper>

+ 13 - 6
fs-service/src/main/resources/mapper/lobster/LobsterEvolutionConfigMapper.xml

@@ -108,7 +108,7 @@
 
     <!-- === lobster_evolution_log === -->
     <insert id="insertEvolutionLog">
-        INSERT INTO lobster_evolution_log(company_id, workflow_id, node_code, action, detail, create_time)
+        INSERT INTO lobster_evolution_log(company_id, workflow_id, node_code, sent_message, outcome, create_time)
         VALUES(#{companyId}, #{workflowId}, #{nodeCode}, #{action}, #{detail}, NOW())
     </insert>
     <select id="selectEvolutionLogs" resultType="java.util.Map">
@@ -128,29 +128,36 @@
         INSERT INTO lobster_evolution_suggestion(company_id, workflow_id, node_code, suggestion_type,
             current_content, suggested_content, reason, confidence, status, create_time)
         VALUES(#{companyId}, #{workflowId}, #{nodeCode}, #{suggestionType},
-               #{currentContent}, #{suggestedContent}, #{reason}, #{confidence}, 'pending', NOW())
+               #{currentContent}, #{suggestedContent}, #{reason}, #{confidence}, 0, NOW())
     </insert>
     <update id="updateNodeMessage">
         UPDATE company_workflow_lobster_node SET message_template = #{suggestedContent}, update_time = NOW()
         WHERE workflow_id = #{workflowId} AND node_code = #{nodeCode}
     </update>
     <update id="acceptSuggestion">
-        UPDATE lobster_evolution_suggestion SET status = 'applied', apply_time = NOW() WHERE id = #{id}
+        UPDATE lobster_evolution_suggestion SET status = 1, apply_time = NOW() WHERE id = #{id}
     </update>
     <select id="countTotal" resultType="java.lang.Integer">
         SELECT COUNT(*) FROM lobster_evolution_suggestion WHERE company_id = #{companyId}
     </select>
     <select id="countReplied" resultType="java.lang.Integer">
-        SELECT COUNT(*) FROM lobster_evolution_suggestion WHERE company_id = #{companyId} AND status IN ('reply','applied')
+        SELECT COUNT(*) FROM lobster_evolution_suggestion WHERE company_id = #{companyId} AND status IN (1, 2)
     </select>
     <select id="countPendingSuggestion" resultType="java.lang.Integer">
-        SELECT COUNT(*) FROM lobster_evolution_suggestion WHERE company_id = #{companyId} AND status = 'pending'
+        SELECT COUNT(*) FROM lobster_evolution_suggestion WHERE company_id = #{companyId} AND status = 0
     </select>
     <select id="countApplied" resultType="java.lang.Integer">
-        SELECT COUNT(*) FROM lobster_evolution_suggestion WHERE company_id = #{companyId} AND status = 'applied'
+        SELECT COUNT(*) FROM lobster_evolution_suggestion WHERE company_id = #{companyId} AND status = 1
     </select>
     <select id="ensureSuggestionTable" resultType="java.lang.Integer">
         SELECT 1 FROM lobster_evolution_suggestion LIMIT 1
     </select>
 
+    <select id="selectPendingSuggestions" resultType="java.util.Map">
+        SELECT * FROM lobster_evolution_suggestion
+        WHERE company_id = #{companyId} AND status = 0
+        <if test="minConfidence != null">AND confidence &gt;= #{minConfidence}</if>
+        ORDER BY confidence DESC LIMIT 50
+    </select>
+
 </mapper>

+ 54 - 0
fs-service/src/main/resources/mapper/lobster/LobsterEvolutionSuggestionMapper.xml

@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.company.mapper.LobsterEvolutionSuggestionMapper">
+
+    <resultMap type="com.fs.company.domain.LobsterEvolutionSuggestion" id="EvolutionSuggestionResult">
+        <id property="id" column="id"/>
+        <result property="companyId" column="company_id"/>
+        <result property="workflowId" column="workflow_id"/>
+        <result property="nodeCode" column="node_code"/>
+        <result property="suggestionType" column="suggestion_type"/>
+        <result property="currentContent" column="current_content"/>
+        <result property="suggestedContent" column="suggested_content"/>
+        <result property="confidence" column="confidence"/>
+        <result property="reason" column="reason"/>
+        <result property="metrics" column="metrics"/>
+        <result property="status" column="status"/>
+        <result property="applyTime" column="apply_time"/>
+        <result property="createTime" column="create_time"/>
+    </resultMap>
+
+    <select id="selectPendingByCompanyId" resultMap="EvolutionSuggestionResult">
+        select id, company_id, workflow_id, node_code, suggestion_type,
+               current_content, suggested_content, confidence, reason, metrics,
+               status, apply_time, create_time
+        from lobster_evolution_suggestion
+        where company_id = #{companyId} and status = 0
+        order by create_time desc
+        limit #{limit}
+    </select>
+
+    <insert id="insertSuggestion" useGeneratedKeys="true" keyProperty="id">
+        insert into lobster_evolution_suggestion (
+            company_id, workflow_id, node_code, suggestion_type,
+            current_content, suggested_content, confidence, reason, metrics,
+            status, create_time
+        ) values (
+            #{companyId}, #{workflowId}, #{nodeCode}, #{suggestionType},
+            #{currentContent}, #{suggestedContent}, #{confidence}, #{reason}, #{metrics},
+            0, #{createTime}
+        )
+    </insert>
+
+    <select id="selectByWorkflowAndNode" resultMap="EvolutionSuggestionResult">
+        select id, company_id, workflow_id, node_code, suggestion_type,
+               current_content, suggested_content, confidence, reason, metrics,
+               status, apply_time, create_time
+        from lobster_evolution_suggestion
+        where company_id = #{companyId}
+          and workflow_id = #{workflowId}
+          and node_code = #{nodeCode}
+        order by create_time desc
+    </select>
+
+</mapper>

+ 3 - 2
fs-service/src/main/resources/mapper/lobster/LobsterMultiTurnDialogueMapper.xml

@@ -70,8 +70,9 @@
     </insert>
 
     <select id="selectNodeConfig" resultType="java.util.Map">
-        SELECT node_config FROM lobster_workflow_node
-        WHERE instance_id = #{instanceId} AND node_code = #{nodeCode} AND deleted = 0
+        SELECT n.node_config FROM company_workflow_lobster_node n
+        INNER JOIN lobster_workflow_instance i ON i.workflow_id = n.workflow_id AND i.company_id = n.company_id
+        WHERE i.instance_id = #{instanceId} AND n.node_code = #{nodeCode} AND n.del_flag = 0
         LIMIT 1
     </select>
 

+ 64 - 0
fs-service/src/main/resources/mapper/lobster/LobsterTestScenarioMapper.xml

@@ -0,0 +1,64 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.company.mapper.LobsterTestScenarioMapper">
+
+    <resultMap type="com.fs.company.domain.LobsterTestScenario" id="TestScenarioResult">
+        <id property="id" column="id"/>
+        <result property="companyId" column="company_id"/>
+        <result property="scenarioName" column="scenario_name"/>
+        <result property="businessDesc" column="business_desc"/>
+        <result property="templateId" column="template_id"/>
+        <result property="userInputsJson" column="user_inputs_json"/>
+        <result property="expectedNodes" column="expected_nodes"/>
+        <result property="minScore" column="min_score"/>
+        <result property="enabled" column="enabled"/>
+        <result property="cron" column="cron"/>
+        <result property="lastRunId" column="last_run_id"/>
+        <result property="lastRunStatus" column="last_run_status"/>
+        <result property="lastRunTime" column="last_run_time"/>
+        <result property="createBy" column="create_by"/>
+        <result property="createTime" column="create_time"/>
+        <result property="updateTime" column="update_time"/>
+    </resultMap>
+
+    <insert id="insertScenario" useGeneratedKeys="true" keyProperty="id">
+        insert into lobster_test_scenario (
+            company_id, scenario_name, business_desc, template_id,
+            user_inputs_json, expected_nodes, min_score, enabled, cron,
+            create_by, create_time
+        ) values (
+            #{companyId}, #{scenarioName}, #{businessDesc}, #{templateId},
+            #{userInputsJson}, #{expectedNodes}, #{minScore}, #{enabled}, #{cron},
+            #{createBy}, #{createTime}
+        )
+    </insert>
+
+    <select id="selectById" resultMap="TestScenarioResult">
+        select id, company_id, scenario_name, business_desc, template_id,
+               user_inputs_json, expected_nodes, min_score, enabled, cron,
+               last_run_id, last_run_status, last_run_time,
+               create_by, create_time, update_time
+        from lobster_test_scenario
+        where id = #{id}
+    </select>
+
+    <select id="selectEnabledByCompanyId" resultMap="TestScenarioResult">
+        select id, company_id, scenario_name, business_desc, template_id,
+               user_inputs_json, expected_nodes, min_score, enabled, cron,
+               last_run_id, last_run_status, last_run_time,
+               create_by, create_time, update_time
+        from lobster_test_scenario
+        where company_id = #{companyId} and enabled = 1
+        order by create_time desc
+    </select>
+
+    <update id="updateLastRun">
+        update lobster_test_scenario
+        set last_run_id = #{lastRunId},
+            last_run_status = #{lastRunStatus},
+            last_run_time = NOW(),
+            update_time = NOW()
+        where id = #{id}
+    </update>
+
+</mapper>

+ 29 - 0
fs-service/src/main/resources/mapper/proxy/CompanySmsApiTenantMapper.xml

@@ -62,6 +62,12 @@
         ORDER BY a.sms_type ASC
     </select>
 
+    <select id="selectByApiIdAndTenantId" resultMap="CompanySmsApiTenantResult">
+        <include refid="selectSmsApiTenantVo"/>
+        WHERE t.api_id = #{apiId} AND t.tenant_id = #{tenantId}
+        LIMIT 1
+    </select>
+
     <insert id="insertSmsApiTenant" useGeneratedKeys="true" keyProperty="id">
         INSERT INTO company_sms_api_tenant (api_id, tenant_id, price, priority, allow_manual, status, create_time)
         VALUES (#{apiId}, #{tenantId}, #{price}, #{priority}, #{allowManual}, #{status}, NOW())
@@ -79,6 +85,29 @@
         WHERE id = #{id}
     </update>
 
+    <update id="batchUpdatePricing">
+        UPDATE company_sms_api_tenant
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="data.price != null">price = #{data.price},</if>
+            <if test="data.priority != null">priority = #{data.priority},</if>
+            <if test="data.allowManual != null">allow_manual = #{data.allowManual},</if>
+            update_time = NOW(),
+        </trim>
+        WHERE id IN
+        <foreach item="id" collection="ids" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </update>
+
+    <update id="batchUpdateStatus">
+        UPDATE company_sms_api_tenant
+        SET status = #{status}, update_time = NOW()
+        WHERE id IN
+        <foreach item="id" collection="ids" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </update>
+
     <delete id="deleteSmsApiTenantById">
         DELETE FROM company_sms_api_tenant WHERE id = #{id}
     </delete>

+ 10 - 1
fs-service/src/main/resources/schema/lobster_node_type.sql

@@ -39,16 +39,25 @@ INSERT INTO lobster_workflow_node_type (node_type, node_name, code_name, descrip
 (22, '质检评分', 'quality_check', 'AI内容发送前质量检查', 'extended', 22),
 (23, '知识库检索', 'knowledge_retrieval', 'RAG向量检索', 'extended', 23),
 (24, '商品推荐', 'product_recommend', '根据标签推荐商品', 'extended', 24),
+(25, '标签匹配', 'tag_match', '匹配用户画像标签并分支', 'extended', 25),
 (30, '企微消息', 'qw_message', '发送企业微信消息', 'extended', 30),
 (31, '个微消息', 'im_message', '发送个人微信消息', 'extended', 31),
+(32, '定时延迟', 'timed_delay', '延时/定时发送', 'extended', 32),
+(33, 'AI对话', 'ai_chat', '多轮对话交互', 'extended', 33),
+(34, '短信消息', 'sms_message', '发送短信', 'extended', 34),
+(35, '邮件消息', 'email_message', '发送邮件', 'extended', 35),
 (40, '变量赋值', 'variable_assign', '设置/修改变量', 'extended', 40),
 (41, '打标签', 'add_tag', '为用户打标签', 'extended', 41),
 (42, 'Webhook', 'webhook', '回调外部系统API', 'external', 42),
+(43, '子流程', 'sub_workflow', '调用其他工作流', 'extended', 43),
+(44, '创建任务', 'create_task', '创建待办任务', 'extended', 44),
 (50, 'SOP模板执行', 'sop_execute', '调用SOP模板自动执行', 'extended', 50),
 (51, 'CID任务执行', 'cid_task', '调用CID任务模板', 'extended', 51),
 (52, '商品推送', 'product_push', '推送商品小程序地址', 'extended', 52),
 (53, '物流推送', 'logistics_notify', '推送物流信息', 'extended', 53),
-(100, '外部API', 'external_api', '调用外部API接口', 'external', 100);
+(100, '外部API', 'external_api', '调用外部API接口', 'external', 100),
+(200, '自定义节点1', 'custom_1', '用户自定义节点1', 'custom', 200),
+(201, '自定义节点2', 'custom_2', '用户自定义节点2', 'custom', 201);
 
 -- 大模型配置扩展表(工作流生成模型和质检评分模型)
 CREATE TABLE IF NOT EXISTS lobster_model_config (

+ 4 - 8
fs-task/src/main/java/com/fs/admin/sync/LobsterBridgeDataSyncService.java

@@ -9,6 +9,7 @@ import com.fs.tenant.service.TenantInfoService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.stereotype.Component;
 
@@ -16,16 +17,11 @@ import java.util.Date;
 import java.util.List;
 
 /**
- * 桥接数据同步服务
- * <p>
- * 解决 AdminLobsterBridgeController 从 ylrz_saas 桥接镜像表读取时数据为空的问题。
- * 定时从各租户库全量同步 lobster_* 等表到 ylrz_saas 桥接镜像表。
- * 同时每日执行租户模块使用统计。
- * <p>
- * 镜像表在 ylrz_saas 中有额外的 company_id 字段用于区分租户数据。
- * 同步策略:每10分钟全量清空+重写(数据量小时可接受)。
+ * 租户数据同步服务(已废弃桥接镜像表方案,默认关闭)。
+ * 启用需显式配置 lobster.legacy-bridge-sync.enabled=true
  */
 @Component
+@ConditionalOnProperty(name = "lobster.legacy-bridge-sync.enabled", havingValue = "true")
 public class LobsterBridgeDataSyncService {
 
     private static final Logger log = LoggerFactory.getLogger(LobsterBridgeDataSyncService.class);

+ 11 - 0
fs-task/src/main/java/com/fs/task/FsCompanyTask.java

@@ -8,6 +8,7 @@ import com.fs.company.mapper.CompanyMoneyLogsMapper;
 import com.fs.company.service.ICompanyService;
 import com.fs.company.vo.RedPacketMoneyVO;
 import com.fs.course.mapper.FsCourseRedPacketLogMapper;
+import com.fs.his.service.ISysRedpacketConfigMoreService;
 import lombok.AllArgsConstructor;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -35,6 +36,16 @@ public class FsCompanyTask {
     @Autowired
     private CompanyMoneyLogsMapper moneyLogsMapper;
 
+
+    @Autowired
+    private ISysRedpacketConfigMoreService sysRedpacketConfigMoreService;
+
+    //切换商户号
+    public void changeRedPacketConfig()
+    {
+        sysRedpacketConfigMoreService.changeRedPacketConfig();
+    }
+
     public void refreshCompanyMoney() {
         LocalDateTime now = LocalDateTime.now();
         // 获取上一个小时的开始时间

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä