boss hai 1 día
pai
achega
5940e9e23a
Modificáronse 36 ficheiros con 4278 adicións e 524 borrados
  1. 12 3
      .vscode/settings.json
  2. 12 0
      compile.bat
  3. 265 38
      fs-admin-saas/src/main/java/com/fs/lobster/controller/LobsterAdminController.java
  4. 1 95
      fs-company/src/main/java/com/fs/company/controller/bridge/CompanyBridgeController.java
  5. 9 0
      fs-company/src/main/java/com/fs/company/controller/companyWorkflow/CompanyWorkflowLobsterController.java
  6. 193 0
      fs-company/src/main/java/com/fs/company/controller/workflow/LobsterE2eController.java
  7. 33 0
      fs-company/src/main/java/com/fs/company/controller/workflow/LobsterEngineController.java
  8. 93 0
      fs-company/src/main/java/com/fs/company/controller/workflow/LobsterInboundController.java
  9. 117 0
      fs-company/src/main/java/com/fs/company/controller/workflow/LobsterInstanceMonitorController.java
  10. 214 0
      fs-company/src/main/java/com/fs/company/controller/workflow/LobsterPlatformAdminController.java
  11. 2 1
      fs-service/src/main/java/com/fs/company/mapper/LobsterAuxiliaryMapper.java
  12. 3 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterEvolutionConfigMapper.java
  13. 153 0
      fs-service/src/main/java/com/fs/company/service/workflow/capability/LobsterNodeCapabilityRegistry.java
  14. 13 8
      fs-service/src/main/java/com/fs/company/service/workflow/channel/impl/DouyinDmMessageChannel.java
  15. 14 11
      fs-service/src/main/java/com/fs/company/service/workflow/channel/impl/TmallMessageChannel.java
  16. 36 0
      fs-service/src/main/java/com/fs/company/service/workflow/evolution/impl/EvolutionEngineImpl.java
  17. 4 0
      fs-service/src/main/java/com/fs/company/service/workflow/evolution/impl/EvolutionSchedulerImpl.java
  18. 229 14
      fs-service/src/main/java/com/fs/company/service/workflow/impl/DynamicNodeAdjusterImpl.java
  19. 773 29
      fs-service/src/main/java/com/fs/company/service/workflow/impl/DynamicNodeExecutorImpl.java
  20. 556 19
      fs-service/src/main/java/com/fs/company/service/workflow/impl/LobsterE2eTestServiceImpl.java
  21. 1 1
      fs-service/src/main/java/com/fs/company/service/workflow/impl/LobsterEvolutionEngineImpl.java
  22. 129 28
      fs-service/src/main/java/com/fs/company/service/workflow/impl/LobsterTestScenarioServiceImpl.java
  23. 253 11
      fs-service/src/main/java/com/fs/company/service/workflow/impl/LobsterWorkflowExecutorImpl.java
  24. 308 24
      fs-service/src/main/java/com/fs/company/service/workflow/impl/ToolCallFrameworkImpl.java
  25. 143 0
      fs-service/src/main/java/com/fs/company/service/workflow/inbound/LobsterInboundService.java
  26. 146 12
      fs-service/src/main/java/com/fs/company/service/workflow/learning/impl/TenantLearningEngineImpl.java
  27. 22 3
      fs-service/src/main/java/com/fs/company/service/workflow/pay/PayService.java
  28. 314 207
      fs-service/src/main/java/com/fs/company/service/workflow/scheduler/WorkflowTriggerScheduler.java
  29. 191 9
      fs-service/src/main/java/com/fs/company/service/workflow/vector/impl/VectorPatternMatcherImpl.java
  30. 6 2
      fs-service/src/main/resources/mapper/lobster/LobsterAuxiliaryMapper.xml
  31. 7 0
      fs-service/src/main/resources/mapper/lobster/LobsterEvolutionConfigMapper.xml
  32. 10 1
      fs-service/src/main/resources/schema/lobster_node_type.sql
  33. 4 8
      fs-task/src/main/java/com/fs/admin/sync/LobsterBridgeDataSyncService.java
  34. 6 0
      set-java17.bat
  35. 3 0
      start-admin.bat
  36. 3 0
      start-company.bat

+ 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

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

+ 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));
+    }
+
     /**
      * 保存画布数据(包含节点位置、连线等可视化信息)
      */

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

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

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

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

+ 253 - 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";
+    }
+
     /**
      * 生成节点消息(千人千面/用户级优化版本)
      * 
@@ -1713,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推导执行逻辑并生成结果
      * 这是"即插即用"的关键——任何新节点类型无需改代码即可运行
@@ -1721,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();
     }
 }

+ 6 - 2
fs-service/src/main/resources/mapper/lobster/LobsterAuxiliaryMapper.xml

@@ -169,10 +169,14 @@
 
     <!-- === lobster_test_scenario === -->
     <select id="selectTestScenarios" resultType="java.util.Map">
-        SELECT * FROM lobster_test_scenario WHERE company_id=#{companyId} AND enabled=1 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, template_id, user_inputs_json, create_time)

+ 7 - 0
fs-service/src/main/resources/mapper/lobster/LobsterEvolutionConfigMapper.xml

@@ -153,4 +153,11 @@
         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>

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

+ 6 - 0
set-java17.bat

@@ -0,0 +1,6 @@
+@echo off
+rem ÏîĿͳһ JDK 17 »·¾³£¨Temurin 17.0.12+7£©
+set "JAVA_HOME=D:\AICALL\jdk-17.0.12+7"
+set "PATH=%JAVA_HOME%\bin;%PATH%"
+echo JAVA_HOME=%JAVA_HOME%
+java -version

+ 3 - 0
start-admin.bat

@@ -1,5 +1,8 @@
 @echo off
 chcp 65001 >nul
+call "%~dp0set-java17.bat" 2>nul
+if not defined JAVA_HOME set "JAVA_HOME=D:\AICALL\jdk-17.0.12+7"
+set "PATH=%JAVA_HOME%\bin;%PATH%"
 echo ============================================
 echo   启动 FS-ADMIN (AdminUI + AgentUI)
 echo   端口: 8004

+ 3 - 0
start-company.bat

@@ -1,5 +1,8 @@
 @echo off
 chcp 65001 >nul
+call "%~dp0set-java17.bat" 2>nul
+if not defined JAVA_HOME set "JAVA_HOME=D:\AICALL\jdk-17.0.12+7"
+set "PATH=%JAVA_HOME%\bin;%PATH%"
 echo ============================================
 echo   启动 FS-SAAS (SaaSUI/SaaSAdminUI)
 echo   端口: 8006