Przeglądaj źródła

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

xgb 3 godzin temu
rodzic
commit
fc648ae31b
47 zmienionych plików z 4513 dodań i 495 usunięć
  1. 83 0
      fs-admin-saas/src/main/java/com/fs/company/controller/AdminSaasVoiceSeatController.java
  2. 58 199
      fs-admin-saas/src/main/java/com/fs/lobster/controller/LobsterAdminController.java
  3. 188 0
      fs-admin/src/main/java/com/fs/admin/controller/VoiceSeatController.java
  4. 129 0
      fs-admin/src/main/java/com/fs/admin/controller/lobster/LobsterAdminController.java
  5. 53 0
      fs-admin/src/main/java/com/fs/admin/controller/lobster/LobsterPromptController.java
  6. 54 0
      fs-admin/src/main/java/com/fs/admin/controller/lobster/LobsterSalesCorpusController.java
  7. 125 4
      fs-admin/src/main/java/com/fs/admin/controller/tenant/TenantInfoController.java
  8. 30 15
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallUserController.java
  9. 2 4
      fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallUserService.java
  10. 71 64
      fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallUserServiceImpl.java
  11. 6 0
      fs-service/src/main/java/com/fs/company/domain/CompanyExtensionBind.java
  12. 12 0
      fs-service/src/main/java/com/fs/company/mapper/CcExtNumMapper.java
  13. 27 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyExtensionBindMapper.java
  14. 4 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyMapper.java
  15. 6 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterAuxiliaryMapper.java
  16. 42 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterEvolutionSuggestionMapper.java
  17. 3 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterWorkflowInstanceMapper.java
  18. 16 0
      fs-service/src/main/java/com/fs/company/param/AssignExtensionToCompanyParam.java
  19. 25 0
      fs-service/src/main/java/com/fs/company/param/SaasVoiceSeatQueryParam.java
  20. 27 0
      fs-service/src/main/java/com/fs/company/service/ISaasVoiceSeatService.java
  21. 250 0
      fs-service/src/main/java/com/fs/company/service/easycall/CcExtNumAllocator.java
  22. 4 85
      fs-service/src/main/java/com/fs/company/service/impl/CompanyExtensionBindServiceImpl.java
  23. 88 0
      fs-service/src/main/java/com/fs/company/service/impl/SaasVoiceSeatServiceImpl.java
  24. 27 0
      fs-service/src/main/java/com/fs/company/service/workflow/ILobsterEvolutionSuggestionService.java
  25. 11 0
      fs-service/src/main/java/com/fs/company/service/workflow/ILobsterInstanceStatsService.java
  26. 22 0
      fs-service/src/main/java/com/fs/company/service/workflow/IWorkflowTemplateAdminService.java
  27. 86 0
      fs-service/src/main/java/com/fs/company/service/workflow/impl/LobsterEvolutionSuggestionServiceImpl.java
  28. 48 0
      fs-service/src/main/java/com/fs/company/service/workflow/impl/LobsterInstanceStatsServiceImpl.java
  29. 162 0
      fs-service/src/main/java/com/fs/company/service/workflow/impl/WorkflowTemplateAdminServiceImpl.java
  30. 43 0
      fs-service/src/main/java/com/fs/company/vo/SaasVoiceSeatVO.java
  31. 57 0
      fs-service/src/main/java/com/fs/proxy/domain/TenantExtensionBind.java
  32. 33 0
      fs-service/src/main/java/com/fs/proxy/mapper/TenantExtensionBindMapper.java
  33. 22 0
      fs-service/src/main/java/com/fs/proxy/param/BatchCreateTenantExtensionParam.java
  34. 45 0
      fs-service/src/main/java/com/fs/proxy/service/ITenantExtensionBindService.java
  35. 261 0
      fs-service/src/main/java/com/fs/proxy/service/impl/TenantExtensionBindServiceImpl.java
  36. 6 0
      fs-service/src/main/java/com/fs/tenant/mapper/TenantInfoMapper.java
  37. 3 0
      fs-service/src/main/resources/db/tenant-initTable-migration.sql
  38. 1869 6
      fs-service/src/main/resources/db/tenant-initTable.sql
  39. 18 0
      fs-service/src/main/resources/mapper/company/CcExtNumMapper.xml
  40. 161 4
      fs-service/src/main/resources/mapper/company/CompanyExtensionBindMapper.xml
  41. 5 0
      fs-service/src/main/resources/mapper/company/LobsterWorkflowInstanceMapper.xml
  42. 10 0
      fs-service/src/main/resources/mapper/lobster/LobsterAuxiliaryMapper.xml
  43. 134 0
      fs-service/src/main/resources/mapper/proxy/TenantExtensionBindMapper.xml
  44. 10 0
      fs-service/src/main/resources/mapper/tenant/TenantInfoMapper.xml
  45. 27 6
      fs-task/src/main/java/com/fs/quartz/config/ScheduleJobRedisConfig.java
  46. 3 0
      fs-task/src/main/java/com/fs/quartz/service/SysJobSchedulerService.java
  47. 147 108
      fs-task/src/main/java/com/fs/task/support/impl/SopLogsTaskServiceImpl.java

+ 83 - 0
fs-admin-saas/src/main/java/com/fs/company/controller/AdminSaasVoiceSeatController.java

@@ -0,0 +1,83 @@
+package com.fs.company.controller;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.company.param.AssignExtensionToCompanyParam;
+import com.fs.company.param.SaasVoiceSeatQueryParam;
+import com.fs.company.service.ISaasVoiceSeatService;
+import com.fs.company.vo.SaasVoiceSeatVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 租户管理端分机管理
+ *
+ * @author MixLiu
+ * @date 2026/6/8
+ */
+@RestController
+@RequestMapping("/adminSaas/voiceSeat")
+public class AdminSaasVoiceSeatController extends BaseController {
+
+    @Autowired
+    private ISaasVoiceSeatService saasVoiceSeatService;
+
+    /**
+     * 分页查询分机列表
+     */
+    @PreAuthorize("@ss.hasPermi('company:companyVoiceCaller:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(SaasVoiceSeatQueryParam param) {
+        startPage();
+        List<SaasVoiceSeatVO> list = saasVoiceSeatService.selectList(param);
+        return getDataTable(list);
+    }
+
+    /**
+     * 分机池未分配列表
+     */
+    @PreAuthorize("@ss.hasPermi('company:companyVoiceCaller:list')")
+    @GetMapping("/poolList")
+    public TableDataInfo poolList(SaasVoiceSeatQueryParam param) {
+        startPage();
+        List<SaasVoiceSeatVO> list = saasVoiceSeatService.selectPoolList(param);
+        return getDataTable(list);
+    }
+
+    /**
+     * 分机详情
+     */
+    @PreAuthorize("@ss.hasPermi('company:companyVoiceCaller:list')")
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable Long id) {
+        SaasVoiceSeatVO data = saasVoiceSeatService.selectById(id);
+        if (data == null) {
+            return AjaxResult.error("分机记录不存在");
+        }
+        return AjaxResult.success(data);
+    }
+
+    /**
+     * 分配分机到公司
+     */
+    @PreAuthorize("@ss.hasPermi('company:companyVoiceCaller:bind')")
+    @Log(title = "分配分机到公司", businessType = BusinessType.UPDATE)
+    @PostMapping("/assign")
+    public AjaxResult assign(@RequestBody AssignExtensionToCompanyParam param) {
+        try {
+            int rows = saasVoiceSeatService.assignToCompany(param);
+            if (rows <= 0) {
+                return AjaxResult.error("分配失败,请确认所选分机均在分机池且状态可用");
+            }
+            return AjaxResult.success("成功分配 " + rows + " 个分机");
+        } catch (IllegalArgumentException e) {
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+}

+ 58 - 199
fs-admin-saas/src/main/java/com/fs/lobster/controller/LobsterAdminController.java

@@ -20,7 +20,7 @@ import java.util.*;
 
 /**
  * 龙虾引擎管理端 Controller(fs-admin-saas)
- * 直连 MyBatis Service / JdbcTemplate 租户库,无桥接镜像表
+ * 直连 MyBatis Service 租户库,无桥接镜像表
  */
 @RestController
 public class LobsterAdminController extends BaseController {
@@ -55,9 +55,6 @@ public class LobsterAdminController extends BaseController {
     @Autowired(required = false)
     private com.fs.company.service.workflow.LobsterWorkflowExecutor workflowExecutor;
 
-    @Autowired(required = false)
-    private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
-
     @Autowired(required = false)
     private com.fs.company.mapper.LobsterChatSessionMapper chatSessionMapper;
 
@@ -73,6 +70,18 @@ public class LobsterAdminController extends BaseController {
     @Autowired(required = false)
     private com.fs.company.service.workflow.channel.MessageChannelRouter messageChannelRouter;
 
+    @Autowired(required = false)
+    private com.fs.company.service.workflow.ILobsterInstanceStatsService instanceStatsService;
+
+    @Autowired(required = false)
+    private com.fs.company.service.workflow.ILobsterEvolutionSuggestionService evolutionSuggestionService;
+
+    @Autowired(required = false)
+    private com.fs.company.service.workflow.IWorkflowTemplateAdminService workflowTemplateAdminService;
+
+    @Autowired(required = false)
+    private com.fs.company.mapper.CompanyMapper companyMapper;
+
     @Autowired
     private TokenService tokenService;
 
@@ -195,113 +204,47 @@ public class LobsterAdminController extends BaseController {
 
     @GetMapping({"/workflow/lobster/generate", "/workflow/lobster/generate/list"})
     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<>());
+        if (evolutionSuggestionService != null) {
+            return AjaxResult.success(evolutionSuggestionService.listSuggestions(companyId));
         }
+        return AjaxResult.success(new ArrayList<>());
     }
 
     @GetMapping({"/workflow/lobster/canvas", "/workflow/lobster/canvas/list"})
     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<>());
+        if (workflowTemplateAdminService != null) {
+            return AjaxResult.success(workflowTemplateAdminService.listTemplates(companyId));
         }
+        return AjaxResult.success(new ArrayList<>());
     }
 
     @GetMapping({"/workflow/lobster/template", "/workflow/lobster/template/list"})
     public AjaxResult lobsterTemplate() {
-        if (jdbcTemplate == null) return AjaxResult.success(new ArrayList<>());
-        List<Map<String, Object>> list = jdbcTemplate.queryForList(
-            "SELECT id, template_code, template_name, industry_type, description, status, version, create_time, update_time " +
-            "FROM company_workflow_lobster WHERE del_flag=0 AND status=1 ORDER BY update_time DESC LIMIT 200");
-        return AjaxResult.success(list);
+        if (workflowTemplateAdminService != null) {
+            return AjaxResult.success(workflowTemplateAdminService.listPublishedTemplates());
+        }
+        return AjaxResult.success(new ArrayList<>());
     }
 
     /** 获取工作流节点列表(含模板信息) */
     @GetMapping("/workflow/lobster/nodes/{workflowId}")
     public AjaxResult getWorkflowNodes(@PathVariable Long workflowId) {
-        if (jdbcTemplate == null) return AjaxResult.error("DB不可用");
-        Map<String, Object> template = jdbcTemplate.queryForMap(
-            "SELECT id, template_code, template_name, industry_type, description, status " +
-            "FROM company_workflow_lobster WHERE id=? AND del_flag=0", workflowId);
-        List<Map<String, Object>> nodes = jdbcTemplate.queryForList(
-            "SELECT id, workflow_id, node_code, node_name, node_type, sort_no, " +
-            "next_node_code, message_template, condition_expr, node_config, scene_code, model_name, send_time, max_round " +
-            "FROM company_workflow_lobster_node WHERE workflow_id=? AND del_flag=0 ORDER BY sort_no", workflowId);
-        Map<String, Object> result = new HashMap<>();
-        result.put("template", template);
-        result.put("nodes", nodes);
-        return AjaxResult.success(result);
+        if (workflowTemplateAdminService != null) {
+            return AjaxResult.success(workflowTemplateAdminService.getTemplateWithNodes(workflowId));
+        }
+        return AjaxResult.error("模板服务不可用");
     }
 
     /** 保存工作流节点(先删后插) */
     @PostMapping("/workflow/lobster/nodes/save")
     public AjaxResult saveWorkflowNodes(@RequestBody Map<String, Object> body) {
-        if (jdbcTemplate == null) return AjaxResult.error("DB不可用");
-        Long workflowId = toLong(body.get("workflowId"));
-        if (workflowId == null) return AjaxResult.error("workflowId必填");
-        // 更新模板头
-        String templateName = (String) body.get("templateName");
-        String industryType = (String) body.get("industryType");
-        String description = (String) body.get("description");
-        if (templateName != null) {
-            jdbcTemplate.update(
-                "UPDATE company_workflow_lobster SET template_name=?, industry_type=?, description=?, update_time=NOW() WHERE id=?",
-                templateName, industryType, description, workflowId);
-        }
-        // 清空旧节点
-        jdbcTemplate.update("UPDATE company_workflow_lobster_node SET del_flag=1, update_time=NOW() WHERE workflow_id=?", workflowId);
-        // 插入新节点
-        @SuppressWarnings("unchecked")
-        List<Map<String, Object>> nodes = (List<Map<String, Object>>) body.get("nodes");
-        if (nodes != null) {
-            for (Map<String, Object> n : nodes) {
-                jdbcTemplate.update(
-                    "INSERT INTO company_workflow_lobster_node(workflow_id, node_code, node_name, node_type, sort_no, " +
-                    "next_node_code, message_template, condition_expr, node_config, scene_code, model_name, send_time, max_round, create_time) " +
-                    "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,NOW())",
-                    workflowId,
-                    n.getOrDefault("nodeCode", ""),
-                    n.getOrDefault("nodeName", ""),
-                    toInt(n, "nodeType", 2),
-                    toInt(n, "sortNo", 0),
-                    n.getOrDefault("nextNodeCode", null),
-                    n.getOrDefault("messageTemplate", null),
-                    n.getOrDefault("conditionExpr", null),
-                    n.getOrDefault("nodeConfig", null),
-                    n.getOrDefault("sceneCode", null),
-                    n.getOrDefault("modelName", null),
-                    n.getOrDefault("sendTime", null),
-                    toInt(n, "maxRound", 0));
-            }
+        if (workflowTemplateAdminService == null) return AjaxResult.error("模板服务不可用");
+        try {
+            workflowTemplateAdminService.saveTemplateNodes(body);
+            return AjaxResult.success("保存成功");
+        } catch (Exception e) {
+            return AjaxResult.error(e.getMessage());
         }
-        return AjaxResult.success("保存成功");
-    }
-
-    private int toInt(Map<String, Object> map, String key, int def) {
-        Object v = map.get(key);
-        return v instanceof Number ? ((Number) v).intValue() : def;
     }
 
     @GetMapping({"/workflow/lobster/instance", "/workflow/lobster/instance/list"})
@@ -312,36 +255,13 @@ public class LobsterAdminController extends BaseController {
 
     @GetMapping("/workflow/lobster/instance/stats")
     public AjaxResult lobsterInstanceStats(@RequestParam(required = false) Long companyId) {
-        Map<String, Object> stats = new HashMap<>();
-        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");
+        if (instanceStatsService != null) {
+            return AjaxResult.success(instanceStatsService.getStats(companyId));
         }
-        return AjaxResult.success(stats);
+        Map<String, Object> empty = new HashMap<>();
+        empty.put("running", 0); empty.put("paused", 0); empty.put("completed", 0);
+        empty.put("deadLetters", 0); empty.put("todayTokens", "0");
+        return AjaxResult.success(empty);
     }
 
     @GetMapping("/workflow/lobster/instance/{instanceId}")
@@ -364,34 +284,18 @@ public class LobsterAdminController extends BaseController {
 
     @GetMapping({"/workflow/lobster/optimization", "/workflow/lobster/optimization/list"})
     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<>());
+        if (evolutionSuggestionService != null) {
+            return AjaxResult.success(evolutionSuggestionService.listByStatus(companyId, -1, 200));
         }
+        return AjaxResult.success(new ArrayList<>());
     }
 
     @GetMapping("/workflow/lobster/optimization/pending-audit")
     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<>());
+        if (evolutionSuggestionService != null) {
+            return AjaxResult.success(evolutionSuggestionService.listPendingAudit(companyId, 100));
         }
+        return AjaxResult.success(new ArrayList<>());
     }
 
     @PostMapping("/workflow/lobster/optimization/batch-audit")
@@ -409,14 +313,8 @@ public class LobsterAdminController extends BaseController {
             return AjaxResult.success(evolutionEngine.analyzeAndSuggest(companyId, workflowId));
         }
         Map<String, Object> result = new HashMap<>();
-        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);
-            }
+        if (evolutionSuggestionService != null && companyId != null) {
+            result.put("totalSuggestions", evolutionSuggestionService.getStats(companyId).getOrDefault("total", 0));
         } else {
             result.put("totalSuggestions", 0);
         }
@@ -425,21 +323,12 @@ public class LobsterAdminController extends BaseController {
 
     @GetMapping("/workflow/lobster/optimization/stats")
     public AjaxResult lobsterOptimizationStats(@RequestParam(required = false) Long companyId) {
+        if (evolutionSuggestionService != null && companyId != null) {
+            return AjaxResult.success(evolutionSuggestionService.getStats(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);
     }
 
@@ -590,18 +479,6 @@ public class LobsterAdminController extends BaseController {
     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);
@@ -614,10 +491,9 @@ public class LobsterAdminController extends BaseController {
         if (workflowExecutor != null && companyId != null) {
             return AjaxResult.success(workflowExecutor.getInstanceState(companyId, instanceId));
         }
-        if (jdbcTemplate != null) {
+        if (workflowInstanceMapper != null) {
             try {
-                return AjaxResult.success(jdbcTemplate.queryForMap(
-                    "SELECT * FROM lobster_workflow_instance WHERE id=? AND del_flag=0", instanceId));
+                return AjaxResult.success(workflowInstanceMapper.selectById(instanceId));
             } catch (Exception e) {
                 return AjaxResult.error("实例不存在");
             }
@@ -631,11 +507,6 @@ public class LobsterAdminController extends BaseController {
     @GetMapping("/workflow/lobster-exec/node-logs/{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));
         }
@@ -853,21 +724,11 @@ public class LobsterAdminController extends BaseController {
         if (evolutionEngine != null && companyId != null) {
             return AjaxResult.success(evolutionEngine.getEvolutionMetrics(companyId));
         }
-        Map<String, Object> data = new HashMap<>();
-        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);
+        if (evolutionSuggestionService != null && companyId != null) {
+            return AjaxResult.success(evolutionSuggestionService.getEvolutionMetrics(companyId));
         }
+        Map<String, Object> data = new HashMap<>();
+        data.put("totalEvolutions", 0); data.put("appliedCount", 0); data.put("pendingCount", 0);
         return AjaxResult.success(data);
     }
 
@@ -1144,11 +1005,9 @@ public class LobsterAdminController extends BaseController {
 
     @GetMapping("/workflow/lobster-admin/companies")
     public AjaxResult adminCompanies() {
-        if (jdbcTemplate == null) return AjaxResult.success(new ArrayList<>());
+        if (companyMapper == null) return AjaxResult.success(new ArrayList<>());
         try {
-            List<Map<String, Object>> list = jdbcTemplate.queryForList(
-                "SELECT id, company_name, domain, status FROM company_info WHERE del_flag=0 ORDER BY id");
-            return AjaxResult.success(list);
+            return AjaxResult.success(companyMapper.selectAdminCompanyList());
         } catch (Exception e) {
             return AjaxResult.success(new ArrayList<>());
         }

+ 188 - 0
fs-admin/src/main/java/com/fs/admin/controller/VoiceSeatController.java

@@ -0,0 +1,188 @@
+package com.fs.admin.controller;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.proxy.domain.TenantExtensionBind;
+import com.fs.proxy.param.BatchCreateTenantExtensionParam;
+import com.fs.proxy.service.ITenantExtensionBindService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 租户坐席(分机)管理
+ *
+ * @author MixLiu
+ * @date 2026/6/8
+ */
+@RestController
+@RequestMapping("/admin/voiceSeat")
+public class VoiceSeatController extends BaseController {
+
+    @Autowired(required = false)
+    private ITenantExtensionBindService tenantExtensionBindService;
+
+    /**
+     * 分页查询租户分机列表
+     */
+    @GetMapping("/list")
+    public TableDataInfo list(TenantExtensionBind param) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        startPage();
+        List<TenantExtensionBind> list = tenantExtensionBindService != null
+                ? tenantExtensionBindService.selectList(param)
+                : new ArrayList<>();
+        return getDataTable(list);
+    }
+
+    /**
+     * 获取分机详情
+     */
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable Long id) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (tenantExtensionBindService == null) {
+            return AjaxResult.error("服务未就绪");
+        }
+        TenantExtensionBind data = tenantExtensionBindService.selectById(id);
+        if (data == null) {
+            return AjaxResult.error("分机记录不存在");
+        }
+        return AjaxResult.success(data);
+    }
+
+    /**
+     * 一键创建租户分机
+     */
+    @Log(title = "创建租户分机", businessType = BusinessType.INSERT)
+    @PostMapping("/batchCreate")
+    public AjaxResult batchCreate(@RequestBody BatchCreateTenantExtensionParam param) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (tenantExtensionBindService == null) {
+            return AjaxResult.error("服务未就绪");
+        }
+        if (param.getTenantId() == null) {
+            return AjaxResult.error("请选择租户");
+        }
+        if (param.getCreateNum() == null || param.getCreateNum() <= 0) {
+            return AjaxResult.error("生成数量必须大于0");
+        }
+        if (StringUtils.isEmpty(param.getPassword())) {
+            param.setPassword("123456");
+        }
+        try {
+            int count = tenantExtensionBindService.batchCreateExtension(param, getUsername());
+            if (count <= 0) {
+                return AjaxResult.error("分机创建失败,请重试");
+            }
+            return AjaxResult.success("成功创建" + count + "个分机");
+        } catch (IllegalArgumentException e) {
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 更新单条分机状态
+     */
+    @Log(title = "更新租户分机状态", businessType = BusinessType.UPDATE)
+    @PutMapping("/status")
+    public AjaxResult updateStatus(@RequestBody Map<String, Object> body) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (tenantExtensionBindService == null) {
+            return AjaxResult.error("服务未就绪");
+        }
+        if (body.get("id") == null) {
+            return AjaxResult.error("请选择分机记录");
+        }
+        if (body.get("status") == null) {
+            return AjaxResult.error("请指定状态");
+        }
+        Integer status = Integer.valueOf(body.get("status").toString());
+        if (status != 0 && status != 1) {
+            return AjaxResult.error("状态值无效");
+        }
+        Long id = Long.valueOf(body.get("id").toString());
+        return toAjax(tenantExtensionBindService.updateStatus(id, status));
+    }
+
+    /**
+     * 批量更新分机状态
+     */
+    @Log(title = "批量更新租户分机状态", businessType = BusinessType.UPDATE)
+    @PutMapping("/batchStatus")
+    public AjaxResult batchStatus(@RequestBody Map<String, Object> body) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (tenantExtensionBindService == null) {
+            return AjaxResult.error("服务未就绪");
+        }
+        List<Long> ids = parseIdList(body.get("ids"));
+        if (ids.isEmpty()) {
+            return AjaxResult.error("请选择要更新的记录");
+        }
+        if (body.get("status") == null) {
+            return AjaxResult.error("请指定状态");
+        }
+        Integer status = Integer.valueOf(body.get("status").toString());
+        if (status != 0 && status != 1) {
+            return AjaxResult.error("状态值无效");
+        }
+        return toAjax(tenantExtensionBindService.batchUpdateStatus(ids, status));
+    }
+
+    /**
+     * 批量逻辑删除分机
+     */
+    @Log(title = "删除租户分机", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (tenantExtensionBindService == null) {
+            return AjaxResult.error("服务未就绪");
+        }
+        if (ids == null || ids.length == 0) {
+            return AjaxResult.error("请选择要删除的记录");
+        }
+        return toAjax(tenantExtensionBindService.batchLogicDeleteByIds(java.util.Arrays.asList(ids)));
+    }
+
+    /**
+     * 导出租户分机列表
+     */
+    @Log(title = "导出租户分机", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(TenantExtensionBind param) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (tenantExtensionBindService == null) {
+            return AjaxResult.error("服务未就绪");
+        }
+        List<TenantExtensionBind> list = tenantExtensionBindService.selectListForExport(param);
+        ExcelUtil<TenantExtensionBind> util = new ExcelUtil<>(TenantExtensionBind.class);
+        return util.exportExcel(list, "租户分机数据");
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<Long> parseIdList(Object raw) {
+        List<Long> ids = new ArrayList<>();
+        if (raw == null) {
+            return ids;
+        }
+        if (raw instanceof List) {
+            for (Object item : (List<?>) raw) {
+                if (item != null) {
+                    ids.add(Long.valueOf(item.toString()));
+                }
+            }
+        }
+        return ids;
+    }
+}

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

@@ -0,0 +1,129 @@
+package com.fs.admin.controller.lobster;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.company.domain.LobsterWorkflowInstance;
+import com.fs.company.service.workflow.ILobsterBillingService;
+import com.fs.company.service.workflow.ILobsterEventAuditService;
+import com.fs.company.service.workflow.ILobsterSalesCorpusService;
+import com.fs.company.service.workflow.LobsterModelConfigService;
+import com.fs.framework.web.service.TokenService;
+import com.fs.proxy.service.AiChatQualityService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 龙虾引擎管理端 Controller(fs-admin-saas)
+ * 直连 MyBatis Service / JdbcTemplate 租户库,无桥接镜像表
+ */
+@RestController
+public class LobsterAdminController extends BaseController {
+
+    @Autowired(required = false)
+    private com.fs.company.service.workflow.ILobsterInstanceStatsService instanceStatsService;
+
+    @Autowired(required = false)
+    private com.fs.company.mapper.LobsterWorkflowInstanceMapper workflowInstanceMapper;
+
+    @Autowired
+    private ILobsterSalesCorpusService salesCorpusService;
+
+    @Autowired(required = false)
+    private com.fs.company.service.workflow.ILobsterEvolutionSuggestionService evolutionSuggestionService;
+
+    @Autowired(required = false)
+    private com.fs.company.mapper.CompanyMapper companyMapper;
+
+    @GetMapping("/workflow/lobster/instance/stats")
+    public AjaxResult lobsterInstanceStats(@RequestParam(required = false) Long companyId) {
+        if (instanceStatsService != null) {
+            return AjaxResult.success(instanceStatsService.getStats(companyId));
+        }
+        Map<String, Object> empty = new HashMap<>();
+        empty.put("running", 0); empty.put("paused", 0); empty.put("completed", 0);
+        empty.put("deadLetters", 0); empty.put("todayTokens", "0");
+        return AjaxResult.success(empty);
+    }
+
+    @GetMapping({"/workflow/lobster/instance", "/workflow/lobster/instance/list"})
+    public AjaxResult lobsterInstance(@RequestParam(required = false) Long companyId,
+                                      @RequestParam(required = false) String status) {
+        return lobsterExecInstanceList(companyId, null, status);
+    }
+
+    @GetMapping({"/workflow/lobster-exec/instance", "/workflow/lobster-exec/instance/list"})
+    public AjaxResult lobsterExecInstanceList(@RequestParam(required = false) Long companyId,
+                                              @RequestParam(required = false) Long workflowId,
+                                              @RequestParam(required = false) String status) {
+        if (workflowInstanceMapper == null) return AjaxResult.success(new ArrayList<>());
+        if (companyId == null) return AjaxResult.success(new ArrayList<>());
+        List<LobsterWorkflowInstance> list = workflowInstanceMapper.selectByCompanyId(companyId);
+        return AjaxResult.success(list != null ? list : new ArrayList<>());
+    }
+
+    @GetMapping({"/workflow/lobster/sales-corpus", "/workflow/lobster/sales-corpus/list",
+            "/workflow/lobster/corpus", "/workflow/lobster/corpus/list"})
+    public AjaxResult lobsterCorpus(@RequestParam(defaultValue = "1") int page,
+                                    @RequestParam(defaultValue = "10") int size,
+                                    @RequestParam(required = false) String scenario,
+                                    @RequestParam(required = false) String status,
+                                    @RequestParam(required = false) Long companyId) {
+        return AjaxResult.success(salesCorpusService.listCorpus(page, size, companyId, scenario, status));
+    }
+
+    @GetMapping("/workflow/lobster/sales-corpus/scenarios")
+    public AjaxResult lobsterCorpusScenarios() {
+        return AjaxResult.success(salesCorpusService.getScenarios());
+    }
+
+    @GetMapping("/workflow/lobster/optimization/pending-audit")
+    public AjaxResult lobsterOptimizationPendingAudit(@RequestParam(required = false) Long companyId) {
+        if (evolutionSuggestionService != null) {
+            return AjaxResult.success(evolutionSuggestionService.listPendingAudit(companyId, 100));
+        }
+        return AjaxResult.success(new ArrayList<>());
+    }
+
+    @GetMapping("/workflow/lobster/optimization/stats")
+    public AjaxResult lobsterOptimizationStats(@RequestParam(required = false) Long companyId) {
+        if (evolutionSuggestionService != null && companyId != null) {
+            return AjaxResult.success(evolutionSuggestionService.getStats(companyId));
+        }
+        Map<String, Object> stats = new HashMap<>();
+        stats.put("total", 0); stats.put("pending", 0);
+        stats.put("approved", 0); stats.put("rejected", 0);
+        return AjaxResult.success(stats);
+    }
+
+    @GetMapping("/workflow/lobster-admin/companies")
+    public AjaxResult adminCompanies() {
+        if (companyMapper == null) return AjaxResult.success(new ArrayList<>());
+        try {
+            return AjaxResult.success(companyMapper.selectAdminCompanyList());
+        } catch (Exception e) {
+            return AjaxResult.success(new ArrayList<>());
+        }
+    }
+
+    @GetMapping({"/workflow/lobster/dead-letter", "/workflow/lobster/dead-letter/list",
+            "/workflow/lobster/deadLetter", "/workflow/lobster/deadLetter/list"})
+    public AjaxResult lobsterDeadLetter() { return AjaxResult.success(new ArrayList<>()); }
+
+    @GetMapping("/workflow/lobster/dead-letter/stats")
+    public AjaxResult lobsterDeadLetterStats() {
+        Map<String, Object> stats = new HashMap<>();
+        stats.put("total", 0); stats.put("pending", 0); stats.put("retried", 0);
+        return AjaxResult.success(stats);
+    }
+
+    @PostMapping("/workflow/lobster/dead-letter/retry-all")
+    public AjaxResult lobsterDeadLetterRetryAll() { return AjaxResult.success("重试已提交"); }
+}

+ 53 - 0
fs-admin/src/main/java/com/fs/admin/controller/lobster/LobsterPromptController.java

@@ -0,0 +1,53 @@
+package com.fs.admin.controller.lobster;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.model.LoginUser;
+import com.fs.common.utils.ServletUtils;
+import com.fs.company.domain.LobsterSystemPrompt;
+import com.fs.company.param.LobsterPromptParam;
+import com.fs.company.service.workflow.ILobsterPromptService;
+import com.fs.framework.web.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.List;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * 龙虾系统提示词管理Controller
+ * 表: lobster_system_prompt
+ * 页面: Prompt管理 → 增删改查 + 租户/行业筛选 + 缓存刷新
+ */
+@RestController
+@RequestMapping("/workflow/lobster/prompt")
+public class LobsterPromptController extends BaseController {
+
+    @Autowired
+    private ILobsterPromptService promptService;
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @GetMapping("/list")
+    public AjaxResult list(@RequestParam(defaultValue = "1") int page,
+                           @RequestParam(defaultValue = "10") int size,
+                           @RequestParam(required = false) String category,
+                           @RequestParam(required = false) String search) {
+        Map<String, Object> result = promptService.listPrompts(page, size, category, search);
+        return AjaxResult.success(result);
+    }
+
+    @GetMapping("/categories")
+    public AjaxResult categories() {
+        List<String> cats = promptService.getCategories();
+        return AjaxResult.success(cats);
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:edit')")
+    @PostMapping("/refresh-cache")
+    public AjaxResult refreshCache() {
+        promptService.refreshCache();
+        return AjaxResult.success("缓存已刷新");
+    }
+}

+ 54 - 0
fs-admin/src/main/java/com/fs/admin/controller/lobster/LobsterSalesCorpusController.java

@@ -0,0 +1,54 @@
+package com.fs.admin.controller.lobster;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.company.service.workflow.ILobsterSalesCorpusService;
+import com.fs.company.service.workflow.learning.SalesCorpusAnalyzer;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 龙虾销冠语料管理Controller
+ *
+ * 表: lobster_sales_corpus
+ * 页面: 销冠语料 → 录入/批量导入/AI分析/话术库查询
+ *
+ * 核心价值: 租户上传销冠/金牌客服聊天话术 → AI分析提取沟通模式 → 进化引擎学习 → 全租户共享
+ */
+@RestController
+@RequestMapping("/workflow/lobster/sales-corpus")
+public class LobsterSalesCorpusController extends BaseController {
+
+    @Autowired(required = false)
+    private SalesCorpusAnalyzer corpusAnalyzer;
+
+    @Autowired
+    private ILobsterSalesCorpusService salesCorpusService;
+// todo 总后台无法获取companyId
+//    /**
+//     * AI分析销冠语料 — 触发AI提取沟通模式
+//     */
+//    @PreAuthorize("@ss.hasPermi('workflow:lobster:edit')")
+//    @PostMapping("/analyze")
+//    public AjaxResult analyze() {
+
+//        LoginUser loginUser = getLoginUser();
+//        Long companyId = loginUser.getCompany().getCompanyId();
+
+//        if (corpusAnalyzer == null) return AjaxResult.error("语料分析器未初始化");
+
+//        AnalysisReport report = corpusAnalyzer.analyzeCorpus(companyId);
+
+//        Map<String, Object> result = new LinkedHashMap<>();
+//        result.put("totalEntries", report.getTotalEntries());
+//        result.put("overallScore", report.getOverallScore());
+//        result.put("summary", report.getSummary());
+//        result.put("questionPatterns", report.getQuestionPatterns());
+//        result.put("answerPatterns", report.getAnswerPatterns());
+//        result.put("trustStrategies", report.getTrustStrategies());
+//        result.put("closingSkills", report.getClosingSkills());
+//        result.put("objectionHandling", report.getObjectionHandling());
+//        result.put("personalityTraits", report.getPersonalityTraits());
+//        return AjaxResult.success(result);
+//    }
+
+}

+ 125 - 4
fs-admin/src/main/java/com/fs/admin/controller/tenant/TenantInfoController.java

@@ -15,6 +15,7 @@ import com.fs.common.exception.CustomException;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.framework.datasource.TenantDataSourceContextHelper;
 import com.fs.framework.datasource.TenantDataSourceManager;
 import com.fs.system.domain.SysConfig;
 import com.fs.system.service.ISysConfigService;
@@ -30,8 +31,10 @@ import org.springframework.validation.annotation.Validated;
 import org.springframework.web.bind.annotation.*;
 
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.CompletableFuture;
 import java.util.stream.Collectors;
 
 /**
@@ -56,6 +59,9 @@ public class TenantInfoController extends BaseController
 
     @Autowired
     private TenantDataSourceManager tenantDataSourceManager;
+
+    @Autowired
+    private TenantDataSourceContextHelper tenantContextHelper;
     /**
      * 查询租户基础信息列表
      */
@@ -378,7 +384,12 @@ public class TenantInfoController extends BaseController
             return AjaxResult.error("修改菜单'" + menu.getMenuName() + "'失败,上级菜单不能选择自己");
         }
         menu.setUpdateBy(getUsername());
-        return toAjax(tenantInfoService.updateMenu(menu));
+        int result = tenantInfoService.updateMenu(menu);
+        if (result > 0) {
+            SysMenu fullMenu = tenantInfoService.selectMenuById(menu.getMenuId());
+            CompletableFuture.runAsync(() -> syncSysMenuUpdateToTenants(fullMenu));
+        }
+        return toAjax(result);
     }
 
     /**
@@ -402,7 +413,12 @@ public class TenantInfoController extends BaseController
             return AjaxResult.error("修改菜单'" + menu.getMenuName() + "'失败,上级菜单不能选择自己");
         }
         menu.setUpdateBy(getUsername());
-        return toAjax(tenantInfoService.updateComMenu(menu));
+        int result = tenantInfoService.updateComMenu(menu);
+        if (result > 0) {
+            TenantCompanyMenu fullMenu = tenantInfoService.getTenantComMenu(menu.getMenuId());
+            CompletableFuture.runAsync(() -> syncComMenuUpdateToTenants(fullMenu));
+        }
+        return toAjax(result);
     }
 
     @PreAuthorize("@ss.hasPermi('system:menu:remove')")
@@ -414,7 +430,11 @@ public class TenantInfoController extends BaseController
         {
             return AjaxResult.error("存在子菜单,不允许删除");
         }
-        return toAjax(tenantInfoService.deleteMenuById(menuId));
+        int result = tenantInfoService.deleteMenuById(menuId);
+        if (result > 0) {
+            CompletableFuture.runAsync(() -> syncSysMenuDeleteToTenants(menuId));
+        }
+        return toAjax(result);
     }
 
     @PreAuthorize("@ss.hasPermi('system:menu:remove')")
@@ -426,6 +446,107 @@ public class TenantInfoController extends BaseController
         {
             return AjaxResult.error("存在子菜单,不允许删除");
         }
-        return toAjax(tenantInfoService.deleteComMenuById(menuId));
+        int result = tenantInfoService.deleteComMenuById(menuId);
+        if (result > 0) {
+            CompletableFuture.runAsync(() -> syncComMenuDeleteToTenants(menuId));
+        }
+        return toAjax(result);
+    }
+
+    // ========== 菜单模板变更 → 异步同步到所有启用租户库 ==========
+
+    /**
+     * 同步 sys 菜单修改到所有 status=1 的租户库(仅已存在该菜单的租户执行 upsert)
+     */
+    private void syncSysMenuUpdateToTenants(SysMenu menu) {
+        List<TenantInfo> tenants = getActiveTenantsForSync();
+        for (TenantInfo tenant : tenants) {
+            try {
+                tenantContextHelper.executeInTenant(tenant, () -> {
+                    int count = tenantInfoMapper.countTenantSysMenuById(menu.getMenuId());
+                    if (count > 0) {
+                        tenantInfoMapper.upsertSysMenu(Collections.singletonList(menu));
+                    }
+                    return null;
+                });
+            } catch (Exception e) {
+                log.error("同步sys菜单修改失败: tenant={}, menuId={}", tenant.getTenantCode(), menu.getMenuId(), e);
+            }
+        }
+    }
+
+    /**
+     * 同步 sys 菜单删除到所有 status=1 的租户库(仅已存在该菜单的租户执行删除)
+     */
+    private void syncSysMenuDeleteToTenants(Long menuId) {
+        List<TenantInfo> tenants = getActiveTenantsForSync();
+        for (TenantInfo tenant : tenants) {
+            try {
+                tenantContextHelper.executeInTenant(tenant, () -> {
+                    int count = tenantInfoMapper.countTenantSysMenuById(menuId);
+                    if (count > 0) {
+                        List<Long> menuIds = Collections.singletonList(menuId);
+                        tenantInfoMapper.deleteSysRoleMenuByMenuIds(menuIds);
+                        tenantInfoMapper.deleteTenantSysMenuByIds(menuIds);
+                    }
+                    return null;
+                });
+            } catch (Exception e) {
+                log.error("同步sys菜单删除失败: tenant={}, menuId={}", tenant.getTenantCode(), menuId, e);
+            }
+        }
+    }
+
+    /**
+     * 同步 com 菜单修改到所有 status=1 的租户库(仅已存在该菜单的租户执行 upsert)
+     */
+    private void syncComMenuUpdateToTenants(TenantCompanyMenu menu) {
+        List<TenantInfo> tenants = getActiveTenantsForSync();
+        for (TenantInfo tenant : tenants) {
+            try {
+                tenantContextHelper.executeInTenant(tenant, () -> {
+                    int count = tenantInfoMapper.countTenantComMenuById(menu.getMenuId());
+                    if (count > 0) {
+                        tenantInfoMapper.upsertComMenu(Collections.singletonList(menu));
+                    }
+                    return null;
+                });
+            } catch (Exception e) {
+                log.error("同步com菜单修改失败: tenant={}, menuId={}", tenant.getTenantCode(), menu.getMenuId(), e);
+            }
+        }
+    }
+
+    /**
+     * 同步 com 菜单删除到所有 status=1 的租户库(仅已存在该菜单的租户执行删除)
+     */
+    private void syncComMenuDeleteToTenants(Long menuId) {
+        List<TenantInfo> tenants = getActiveTenantsForSync();
+        for (TenantInfo tenant : tenants) {
+            try {
+                tenantContextHelper.executeInTenant(tenant, () -> {
+                    int count = tenantInfoMapper.countTenantComMenuById(menuId);
+                    if (count > 0) {
+                        List<Long> menuIds = Collections.singletonList(menuId);
+                        tenantInfoMapper.deleteComRoleMenuByMenuIds(menuIds);
+                        tenantInfoMapper.deleteTenantComMenuByIds(menuIds);
+                    }
+                    return null;
+                });
+            } catch (Exception e) {
+                log.error("同步com菜单删除失败: tenant={}, menuId={}", tenant.getTenantCode(), menuId, e);
+            }
+        }
+    }
+
+    /**
+     * 查询所有 status=1(启用)的租户,在主库执行
+     */
+    private List<TenantInfo> getActiveTenantsForSync() {
+        return tenantContextHelper.executeInMaster(() -> {
+            TenantInfo query = new TenantInfo();
+            query.setStatus(1);
+            return tenantInfoService.selectTenantInfoList(query);
+        });
     }
 }

+ 30 - 15
fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallUserController.java

@@ -109,14 +109,20 @@ public class AiSipCallUserController extends BaseController
         if(aiSipCallUser.getCompanyUserId() == null){
             aiSipCallUser.setCompanyId(loginUser.getCompany().getCompanyId());
             aiSipCallUser.setCompanyUserId(loginUser.getUser().getUserId());
+        } else if (aiSipCallUser.getCompanyId() == null) {
+            aiSipCallUser.setCompanyId(loginUser.getCompany().getCompanyId());
         }
         aiSipCallUser.setCreateBy(loginUser.getUser().getUserName());
-        int i = aiSipCallUserService.insertAiSipCallUserNew(aiSipCallUser);
-        aiSipCallUser.setCreateTime(new Date());
-        ccExtNumMapper.updateUserCodeByExtNum(aiSipCallUser.getExtNum(), aiSipCallUser.getLoginName());
-//        Long tenantId = SecurityUtils.getTenantId();
-//        tenantDataSourceManager.ensureSwitchByTenantId(tenantId);
-        return toAjax(i);
+        try {
+            int i = aiSipCallUserService.insertAiSipCallUserNew(aiSipCallUser);
+            aiSipCallUser.setCreateTime(new Date());
+            if (StringUtils.isNotBlank(aiSipCallUser.getExtNum()) && StringUtils.isNotBlank(aiSipCallUser.getLoginName())) {
+                ccExtNumMapper.updateUserCodeByExtNum(aiSipCallUser.getExtNum(), aiSipCallUser.getLoginName());
+            }
+            return toAjax(i);
+        } catch (RuntimeException e) {
+            return AjaxResult.error(e.getMessage());
+        }
     }
 
     /**
@@ -201,21 +207,30 @@ public class AiSipCallUserController extends BaseController
     {
         Long tenantId = SecurityUtils.getTenantId();
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
-//        aiSipCallUser.setCompanyId(loginUser.getCompany().getCompanyId());
-//        aiSipCallUser.setCompanyUserId(loginUser.getUser().getUserId());
+        if (aiSipCallUser.getCompanyId() == null) {
+            aiSipCallUser.setCompanyId(loginUser.getCompany().getCompanyId());
+        }
         aiSipCallUser.setUpdateBy(loginUser.getUser().getUserName());
         aiSipCallUser.setUpdateTime(new Date());
 
-        AiSipCallUserNewVO aiSipCallUserNewVO = aiSipCallUserService.updateAiSipCallUserNew(aiSipCallUser);
-        if(StringUtils.isNotBlank(aiSipCallUserNewVO.getOldExtNum())){
-            ccExtNumMapper.updateUserCodeByExtNum(aiSipCallUserNewVO.getOldExtNum(), tenantId + "_" + loginUser.getCompany().getCompanyId() + "_" + aiSipCallUserNewVO.getOldExtNum());
-        }
-        if(StringUtils.isNotBlank(aiSipCallUserNewVO.getNewExtNum()) && StringUtils.isNotBlank(aiSipCallUserNewVO.getNewUserCode())){
-            ccExtNumMapper.updateUserCodeByExtNum(aiSipCallUserNewVO.getNewExtNum(), aiSipCallUserNewVO.getNewUserCode());
+        try {
+            AiSipCallUserNewVO aiSipCallUserNewVO = aiSipCallUserService.updateAiSipCallUserNew(aiSipCallUser);
+            if(StringUtils.isNotBlank(aiSipCallUserNewVO.getOldExtNum())){
+                ccExtNumMapper.updateUserCodeByExtNum(aiSipCallUserNewVO.getOldExtNum(),
+                        tenantId + "_" + loginUser.getCompany().getCompanyId() + "_" + aiSipCallUserNewVO.getOldExtNum());
+            }
+            if(StringUtils.isNotBlank(aiSipCallUserNewVO.getNewExtNum()) && StringUtils.isNotBlank(aiSipCallUserNewVO.getNewUserCode())){
+                ccExtNumMapper.updateUserCodeByExtNum(aiSipCallUserNewVO.getNewExtNum(), aiSipCallUserNewVO.getNewUserCode());
+            }
+            return toAjax(1);
+        } catch (RuntimeException e) {
+            return AjaxResult.error(e.getMessage());
         }
-        return toAjax(1);
     }
 
+    /**
+     * 查询本公司可绑定分机(sipUserId 传 0 表示新建绑定)
+     */
     @GetMapping("/getUnBindExtnumNew/{sipUserId}")
     public AjaxResult getUnBindExtnumNew(@PathVariable Long sipUserId)
     {

+ 2 - 4
fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallUserService.java

@@ -87,11 +87,9 @@ public interface IAiSipCallUserService extends IService<AiSipCallUser>{
     AjaxResult agentLogin(java.util.Map<String, Object> param);
 
     /**
-     * 查询分机号码改
-     * @param companyId
-     * @return
+     * 查询本公司可绑定的分机(已分配到本公司且未绑员工,修改时含当前员工已绑分机)
      */
-    AjaxResult getUnBindExtnumNew(Long companyId,Long sipUserId);
+    AjaxResult getUnBindExtnumNew(Long companyId, Long sipUserId);
 
     /**
      * 修改sip用户信息New

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

@@ -241,25 +241,50 @@ public class AiSipCallUserServiceImpl extends ServiceImpl<AiSipCallUserMapper, A
     }
 
     /**
-     * 查询分机号码改
-     * @param companyId
-     * @param sipUserId
-     * @return
+     * 查询本公司可绑定的分机列表(仅 company_id=当前公司,不含租户分机池)
      */
     @Override
-    public AjaxResult getUnBindExtnumNew(Long companyId,Long sipUserId){
-        AiSipCallUser aiSipCallUser = aiSipCallUserMapper.selectAiSipCallUserByUserId(sipUserId);
-        List<CompanyExtensionBind> unBindList = companyExtensionBindService.selectUnBindAndSelfByCompanyId(companyId, aiSipCallUser!=null ? aiSipCallUser.getCompanyUserId() : -1L);
+    public AjaxResult getUnBindExtnumNew(Long companyId, Long sipUserId) {
+        if (companyId == null || companyId <= 0) {
+            return AjaxResult.error("公司信息无效");
+        }
+        Long queryCompanyUserId = -1L;
+        if (sipUserId != null && sipUserId > 0) {
+            AiSipCallUser sipUser = aiSipCallUserMapper.selectAiSipCallUserByUserId(sipUserId);
+            if (sipUser != null && sipUser.getCompanyUserId() != null) {
+                queryCompanyUserId = sipUser.getCompanyUserId();
+            }
+        }
+        List<CompanyExtensionBind> unBindList = companyExtensionBindService
+                .selectUnBindAndSelfByCompanyId(companyId, queryCompanyUserId);
         List<Map<String, Object>> resultList = new ArrayList<>();
         for (CompanyExtensionBind bind : unBindList) {
             Map<String, Object> map = new HashMap<>();
             map.put("extId", bind.getExtId());
             map.put("extNum", bind.getExtensionNum());
+            map.put("extPass", bind.getExtensionPass());
             resultList.add(map);
         }
         return AjaxResult.success(resultList);
     }
 
+    /**
+     * 校验分机是否可绑定到指定员工
+     */
+    private CompanyExtensionBind resolveBindableExtension(String extNum, Long companyId, Long companyUserId, boolean allowSelf) {
+        CompanyExtensionBind bind = companyExtensionBindService.selectUnBindByExtNum(extNum, companyId);
+        if (bind == null) {
+            throw new RuntimeException("分机号不存在或未分配到本公司,请联系租户管理员分配");
+        }
+        Long boundUserId = bind.getCompanyUserId();
+        if (boundUserId != null && boundUserId > 0) {
+            if (!allowSelf || !boundUserId.equals(companyUserId)) {
+                throw new RuntimeException("分机号已被其他员工绑定,请刷新后重试");
+            }
+        }
+        return bind;
+    }
+
     /**
      * 修改sip用户信息New
      * @param aiSipCallUser
@@ -267,48 +292,34 @@ public class AiSipCallUserServiceImpl extends ServiceImpl<AiSipCallUserMapper, A
      */
     @Override
     @Transactional
-    public AiSipCallUserNewVO updateAiSipCallUserNew(AiSipCallUser aiSipCallUser){
-        AiSipCallUserNewVO result= new AiSipCallUserNewVO();
-        CompanyExtensionBind bind = companyExtensionBindService.selectUnBindByExtNum(String.valueOf(aiSipCallUser.getExtNum()), aiSipCallUser.getCompanyId());
-        if (bind != null && (bind.getCompanyUserId() == null || bind.getCompanyUserId().equals(aiSipCallUser.getCompanyUserId()))) {
-            AiSipCallUser oldUser = aiSipCallUserMapper.selectAiSipCallUserByUserId(aiSipCallUser.getUserId());
-            String oldExtNum = (oldUser != null) ? oldUser.getExtNum() : null;
-            String newExtNum = aiSipCallUser.getExtNum();
-            result.setNewExtNum(newExtNum);
-            result.setOldExtNum(oldExtNum);
-            result.setNewUserCode(aiSipCallUser.getLoginName());
-            if(StringUtils.isEmpty(oldUser.getExtPass()) && StringUtils.isNotBlank(bind.getExtensionPass())){
-                aiSipCallUser.setExtPass(bind.getExtensionPass());
-            }
-            int rows = aiSipCallUserMapper.updateAiSipCallUser(aiSipCallUser);
-            //解除绑定
+    public AiSipCallUserNewVO updateAiSipCallUserNew(AiSipCallUser aiSipCallUser) {
+        AiSipCallUserNewVO result = new AiSipCallUserNewVO();
+        if (aiSipCallUser.getCompanyId() == null || aiSipCallUser.getCompanyId() <= 0) {
+            throw new RuntimeException("公司信息无效");
+        }
+        CompanyExtensionBind bind = resolveBindableExtension(
+                String.valueOf(aiSipCallUser.getExtNum()),
+                aiSipCallUser.getCompanyId(),
+                aiSipCallUser.getCompanyUserId(),
+                true);
+
+        AiSipCallUser oldUser = aiSipCallUserMapper.selectAiSipCallUserByUserId(aiSipCallUser.getUserId());
+        String oldExtNum = (oldUser != null) ? oldUser.getExtNum() : null;
+        String newExtNum = aiSipCallUser.getExtNum();
+        result.setNewExtNum(newExtNum);
+        result.setOldExtNum(oldExtNum);
+        result.setNewUserCode(aiSipCallUser.getLoginName());
+
+        if (oldUser != null && StringUtils.isEmpty(oldUser.getExtPass()) && StringUtils.isNotBlank(bind.getExtensionPass())) {
+            aiSipCallUser.setExtPass(bind.getExtensionPass());
+        }
+        aiSipCallUserMapper.updateAiSipCallUser(aiSipCallUser);
+
+        if (StringUtils.isNotBlank(oldExtNum) && !oldExtNum.equals(newExtNum)) {
             companyExtensionBindService.clearBindByExtNum(oldExtNum, aiSipCallUser.getCompanyId(), aiSipCallUser.getCompanyUserId());
-            //绑定新分机号
-            companyExtensionBindService.updateBindByExtId(bind.getExtId(), aiSipCallUser.getCompanyUserId(), aiSipCallUser.getLoginName());
-        } else {
-            throw new RuntimeException("分机号已被绑定,请刷新后重试");
         }
+        companyExtensionBindService.updateBindByExtId(bind.getExtId(), aiSipCallUser.getCompanyUserId(), aiSipCallUser.getLoginName());
         return result;
-
-//        AiSipCallUser oldUser = baseMapper.selectAiSipCallUserByUserId(aiSipCallUser.getUserId());
-//        String oldExtNum = (oldUser != null) ? oldUser.getExtNum() : null;
-//        String newExtNum = aiSipCallUser.getExtNum();
-//
-//        int rows = baseMapper.updateAiSipCallUser(aiSipCallUser);
-//        if (rows > 0) {
-//            if (oldExtNum != null && !oldExtNum.equals(newExtNum)) {
-//                ccExtNumMapper.updateUserCodeByExtNum(oldExtNum, null);
-//                companyExtensionBindService.clearBindByExtNum(String.valueOf(oldExtNum), aiSipCallUser.getCompanyId());
-//            }
-//            if (newExtNum != null) {
-//                ccExtNumMapper.updateUserCodeByExtNum(newExtNum, aiSipCallUser.getLoginName());
-//                CompanyExtensionBind bind = companyExtensionBindService.selectUnBindByExtNum(String.valueOf(newExtNum), aiSipCallUser.getCompanyId());
-//                if (bind != null) {
-//                    companyExtensionBindService.updateBindByExtId(bind.getExtId(), aiSipCallUser.getCompanyUserId(), aiSipCallUser.getLoginName());
-//                }
-//            }
-//        }
-//        return rows;
     }
 
     /**
@@ -318,30 +329,26 @@ public class AiSipCallUserServiceImpl extends ServiceImpl<AiSipCallUserMapper, A
      */
     @Override
     @Transactional
-    public int insertAiSipCallUserNew(AiSipCallUser aiSipCallUser){
+    public int insertAiSipCallUserNew(AiSipCallUser aiSipCallUser) {
         CompanyUser companyUser = companyUserMapper.selectCompanyUserByCompanyUserId(aiSipCallUser.getCompanyUserId());
+        if (companyUser == null) {
+            throw new RuntimeException("员工信息不存在");
+        }
         if (aiSipCallUser.getCompanyId() == null) {
             aiSipCallUser.setCompanyId(companyUser.getCompanyId());
         }
-//        if(aiSipCallUser.getUserId() == null){
-//            aiSipCallUser.setUserId(companyUser.getUserId());
-//        }
 
         int rows = baseMapper.insertAiSipCallUser(aiSipCallUser);
-        if (rows > 0) {
-            if (aiSipCallUser.getExtNum() != null) {
-//                ccExtNumMapper.updateUserCodeByExtNum(aiSipCallUser.getExtNum(), aiSipCallUser.getLoginName());
-                CompanyExtensionBind bind = companyExtensionBindService.selectUnBindByExtNum(String.valueOf(aiSipCallUser.getExtNum()), aiSipCallUser.getCompanyId());
-                if (bind != null && bind.getCompanyUserId() == null) {
-                    companyExtensionBindService.updateBindByExtId(bind.getExtId(), aiSipCallUser.getCompanyUserId(), aiSipCallUser.getLoginName());
-                }else{
-                    throw new RuntimeException("分机号已被绑定,请刷新后重试");
-                }
-                //获取分机密码
-                if(bind.getExtensionPass() != null){
-                    aiSipCallUser.setExtPass(bind.getExtensionPass());
-                    baseMapper.updateAiSipCallUser(aiSipCallUser);
-                }
+        if (rows > 0 && aiSipCallUser.getExtNum() != null) {
+            CompanyExtensionBind bind = resolveBindableExtension(
+                    String.valueOf(aiSipCallUser.getExtNum()),
+                    aiSipCallUser.getCompanyId(),
+                    aiSipCallUser.getCompanyUserId(),
+                    false);
+            companyExtensionBindService.updateBindByExtId(bind.getExtId(), aiSipCallUser.getCompanyUserId(), aiSipCallUser.getLoginName());
+            if (bind.getExtensionPass() != null) {
+                aiSipCallUser.setExtPass(bind.getExtensionPass());
+                baseMapper.updateAiSipCallUser(aiSipCallUser);
             }
             if (aiSipCallUser.getCompanyUserId() != null && aiSipCallUser.getUserId() != null) {
                 companyUserMapper.updateCompanyUserByAiSipCall(aiSipCallUser.getCompanyUserId(), aiSipCallUser.getUserId());

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

@@ -42,5 +42,11 @@ public class CompanyExtensionBind extends BaseEntity{
     @Excel(name = "easycall使用字段-所属员工/绑定关系")
     private String userCode;
 
+    /** 状态:0停用,1可用 */
+    private Integer status;
+
+    /** 是否删除:0否,1是 */
+    private Integer isDel;
+
 
 }

+ 12 - 0
fs-service/src/main/java/com/fs/company/mapper/CcExtNumMapper.java

@@ -14,6 +14,18 @@ public interface CcExtNumMapper {
     @DataSource(DataSourceType.EASYCALL)
     CcExtNumVo selectLastExtNum();
 
+    /**
+     * 查询 SaaS 专用 6~7 位号池(100000~9999999)内的最大分机号
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    Long selectMaxSaasPoolExtNum();
+
+    /**
+     * 查询指定分层号段内的最大分机号
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    Long selectMaxSaasPoolExtNumInRange(@Param("rangeMin") long rangeMin, @Param("rangeMax") long rangeMax);
+
     @DataSource(DataSourceType.EASYCALL)
     List<CcExtNumVo> selectExtNumByExtNums(@Param("extNums") List<Long> extNums);
 

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

@@ -2,9 +2,12 @@ package com.fs.company.mapper;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.fs.company.domain.CompanyExtensionBind;
+import com.fs.company.param.SaasVoiceSeatQueryParam;
+import com.fs.company.vo.SaasVoiceSeatVO;
 import com.fs.company.vo.easycall.ExtensionVO;
 import org.apache.ibatis.annotations.Param;
 
+import java.util.Date;
 import java.util.List;
 
 /**
@@ -77,4 +80,28 @@ public interface CompanyExtensionBindMapper extends BaseMapper<CompanyExtensionB
     int updateBindByExtNum(@Param("num") String num, @Param("companyId") Long companyId, @Param("companyUserId") Long companyUserId, @Param("userCode") String userCode);
 
     List<ExtensionVO> getExtensionList(@Param("companyId") Long companyId);
+
+    int updateStatusByExtId(@Param("extId") Long extId, @Param("status") Integer status);
+
+    int batchUpdateStatusByExtIds(@Param("extIds") List<Long> extIds, @Param("status") Integer status);
+
+    int logicDeleteByExtId(@Param("extId") Long extId);
+
+    int batchLogicDeleteByExtIds(@Param("extIds") List<Long> extIds);
+
+    List<SaasVoiceSeatVO> selectSaasVoiceSeatList(SaasVoiceSeatQueryParam param);
+
+    SaasVoiceSeatVO selectSaasVoiceSeatById(@Param("id") Long id);
+
+    int assignExtensionToCompany(@Param("ids") List<Long> ids,
+                                 @Param("companyId") Long companyId,
+                                 @Param("updateTime") Date updateTime);
+
+    int recycleExtensionToPool(@Param("ids") List<Long> ids,
+                               @Param("poolCompanyId") Long poolCompanyId,
+                               @Param("updateTime") Date updateTime);
+
+    int updateStatusById(@Param("id") Long id, @Param("status") Integer status);
+
+    int batchUpdateStatusByIds(@Param("ids") List<Long> ids, @Param("status") Integer status);
 }

+ 4 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanyMapper.java

@@ -260,4 +260,8 @@ public interface CompanyMapper
     List<CompanyVO> getCompanyDropList();
 
     String getGateWayList(@Param("companyId") Long companyId);
+
+    /** 管理端:查询所有有效租户列表 */
+    @Select("SELECT company_id AS id, company_name, domain, status FROM company_info WHERE del_flag = 0 ORDER BY company_id")
+    List<java.util.Map<String, Object>> selectAdminCompanyList();
 }

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

@@ -113,6 +113,12 @@ public interface LobsterAuxiliaryMapper {
     int deleteDeadLetter(@Param("id") Long id);
     int ensureDeadLetterTable();
 
+    /** 统计待处理死信数量(可选租户过滤) */
+    Integer countDeadLetterPending(@Param("companyId") Long companyId);
+
+    /** 统计今日Token消耗总量(可选租户过滤) */
+    Long sumTodayTokens(@Param("companyId") Long companyId);
+
     // === lobster_e2e_test ===
     int insertE2eTest(@Param("companyId") Long companyId,
                       @Param("testName") String testName,

+ 42 - 0
fs-service/src/main/java/com/fs/company/mapper/LobsterEvolutionSuggestionMapper.java

@@ -18,9 +18,51 @@ public interface LobsterEvolutionSuggestionMapper extends BaseMapper<LobsterEvol
     @Select("SELECT COUNT(*) FROM lobster_evolution_suggestion WHERE company_id = #{companyId} AND status = 1")
     Integer countAppliedByCompanyId(@Param("companyId") Long companyId);
 
+    @Select("SELECT COUNT(*) FROM lobster_evolution_suggestion WHERE company_id = #{companyId} AND status = #{status}")
+    Integer countByStatus(@Param("companyId") Long companyId, @Param("status") int status);
+
+    @Select("SELECT COUNT(*) FROM lobster_evolution_suggestion")
+    Integer countAll();
+
+    @Select("SELECT COUNT(*) FROM lobster_evolution_suggestion WHERE status = #{status}")
+    Integer countAllByStatus(@Param("status") int status);
+
     @Select("SELECT * FROM lobster_evolution_suggestion WHERE company_id = #{companyId} AND workflow_id = #{workflowId} ORDER BY create_time DESC")
     List<LobsterEvolutionSuggestion> selectByCompanyAndWorkflow(@Param("companyId") Long companyId, @Param("workflowId") Long workflowId);
 
+    /** 列表查询(可选 companyId),LIMIT 限制 */
+    @Select("<script>" +
+            "SELECT * FROM lobster_evolution_suggestion " +
+            "<where>" +
+            "<if test='companyId != null'>AND company_id = #{companyId}</if>" +
+            "</where>" +
+            "ORDER BY create_time DESC LIMIT #{limit}" +
+            "</script>")
+    List<LobsterEvolutionSuggestion> selectList(@Param("companyId") Long companyId, @Param("limit") int limit);
+
+    /** 按状态查询(可选 companyId) */
+    @Select("<script>" +
+            "SELECT * FROM lobster_evolution_suggestion " +
+            "<where>" +
+            "<if test='companyId != null'>AND company_id = #{companyId}</if>" +
+            "AND status = #{status}" +
+            "</where>" +
+            "ORDER BY create_time DESC LIMIT #{limit}" +
+            "</script>")
+    List<LobsterEvolutionSuggestion> selectListByStatus(@Param("companyId") Long companyId,
+                                                         @Param("status") int status,
+                                                         @Param("limit") int limit);
+
     @Update("UPDATE lobster_evolution_suggestion SET status = 1, apply_time = NOW() WHERE id = #{id}")
     int markApplied(@Param("id") Long id);
+
+    @Select("<script>" +
+            "SELECT id, company_id, workflow_id, node_code, suggestion_type, reason, confidence, status, create_time " +
+            "FROM lobster_evolution_suggestion " +
+            "<where>" +
+            "<if test='companyId != null'>AND company_id = #{companyId}</if>" +
+            "</where>" +
+            "ORDER BY create_time DESC LIMIT #{limit}" +
+            "</script>")
+    List<java.util.Map<String, Object>> selectFieldsList(@Param("companyId") Long companyId, @Param("limit") int limit);
 }

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

@@ -25,4 +25,7 @@ public interface LobsterWorkflowInstanceMapper {
 
     /** 仅取该实例所属模板 ID(用于动态节点生成) */
     Long selectWorkflowIdById(@Param("id") Long id);
+
+    /** 按状态统计实例数量(可选租户过滤) */
+    Integer countByStatus(@Param("companyId") Long companyId, @Param("status") String status);
 }

+ 16 - 0
fs-service/src/main/java/com/fs/company/param/AssignExtensionToCompanyParam.java

@@ -0,0 +1,16 @@
+package com.fs.company.param;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 分机分配到公司参数
+ */
+@Data
+public class AssignExtensionToCompanyParam {
+
+    private Long companyId;
+
+    private List<Long> ids;
+}

+ 25 - 0
fs-service/src/main/java/com/fs/company/param/SaasVoiceSeatQueryParam.java

@@ -0,0 +1,25 @@
+package com.fs.company.param;
+
+import lombok.Data;
+
+/**
+ * 租户管理端分机查询参数
+ */
+@Data
+public class SaasVoiceSeatQueryParam {
+
+    /** 公司名称(模糊) */
+    private String companyName;
+
+    /** 分机号码 */
+    private String extensionNum;
+
+    /** 状态:0停用,1可用 */
+    private Integer status;
+
+    /** 分配状态:0分机池,1已分配公司 */
+    private Integer assignStatus;
+
+    /** 指定公司ID */
+    private Long companyId;
+}

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

@@ -0,0 +1,27 @@
+package com.fs.company.service;
+
+import com.fs.company.param.AssignExtensionToCompanyParam;
+import com.fs.company.param.SaasVoiceSeatQueryParam;
+import com.fs.company.vo.SaasVoiceSeatVO;
+
+import java.util.List;
+
+/**
+ * 租户管理端分机管理 Service
+ */
+public interface ISaasVoiceSeatService {
+
+    List<SaasVoiceSeatVO> selectList(SaasVoiceSeatQueryParam param);
+
+    SaasVoiceSeatVO selectById(Long id);
+
+    List<SaasVoiceSeatVO> selectPoolList(SaasVoiceSeatQueryParam param);
+
+    int assignToCompany(AssignExtensionToCompanyParam param);
+
+    int recycleToPool(List<Long> ids);
+
+    int updateStatus(Long id, Integer status);
+
+    int batchUpdateStatus(List<Long> ids, Integer status);
+}

+ 250 - 0
fs-service/src/main/java/com/fs/company/service/easycall/CcExtNumAllocator.java

@@ -0,0 +1,250 @@
+package com.fs.company.service.easycall;
+
+import com.fs.aiSipCall.vo.CcExtNumVo;
+import com.fs.company.mapper.CcExtNumMapper;
+import com.fs.common.core.redis.RedisCache;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.util.CollectionUtils;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.function.IntFunction;
+import java.util.stream.Collectors;
+
+/**
+ * cc_ext_num 分机号分配器(SaaS 专用 6~7 位分层号池:100000~9999999)
+ * <p>
+ * 分层规则:先 6 位段 100000~999999,再按百万分段 1000000~1999999 … 9000000~9999999。
+ * 每层独立 MAX+1,隔离号如 3331001 不会拉高全局起点。
+ * Redis 缓存当前层索引,下次优先从该层继续查询。
+ */
+@Component
+public class CcExtNumAllocator {
+
+    private static final Logger log = LoggerFactory.getLogger(CcExtNumAllocator.class);
+
+    /** SaaS 平台专用号段下限(6 位起) */
+    public static final long SAAS_EXT_NUM_MIN = 100_000L;
+
+    /** SaaS 平台专用号段上限(7 位满) */
+    public static final long SAAS_EXT_NUM_MAX = 9_999_999L;
+
+    /** Redis 键:当前分层号池层索引(0~9) */
+    private static final String REDIS_LAYER_KEY = "saas:cc_ext_num:alloc_layer";
+
+    /**
+     * 分层号段定义:第 0 层为 6 位段,后续每层 100 万
+     */
+    private static final long[][] LAYER_RANGES = {
+            {100_000L, 999_999L},
+            {1_000_000L, 1_999_999L},
+            {2_000_000L, 2_999_999L},
+            {3_000_000L, 3_999_999L},
+            {4_000_000L, 4_999_999L},
+            {5_000_000L, 5_999_999L},
+            {6_000_000L, 6_999_999L},
+            {7_000_000L, 7_999_999L},
+            {8_000_000L, 8_999_999L},
+            {9_000_000L, 9_999_999L},
+    };
+
+    private static final int CONFLICT_RETRY = 10;
+    private static final int COMPENSATE_RETRY = 5;
+
+    @Autowired
+    private CcExtNumMapper ccExtNumMapper;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    public List<CcExtNumVo> batchAllocate(int createNum, String password, IntFunction<String> userCodeFn) {
+        if (createNum <= 0) {
+            throw new IllegalArgumentException("生成数量必须大于0");
+        }
+
+        int startLayer = getCachedLayerIndex();
+        List<Long> extNums = allocateNumbersInLayers(startLayer, createNum, null);
+
+        List<CcExtNumVo> allNewExtNums = new ArrayList<>(createNum);
+        for (int i = 0; i < extNums.size(); i++) {
+            CcExtNumVo vo = new CcExtNumVo();
+            vo.setExtNum(extNums.get(i));
+            vo.setExtPass(password);
+            vo.setUserCode(userCodeFn.apply(i + 1));
+            allNewExtNums.add(vo);
+        }
+
+        resolveConflicts(allNewExtNums);
+
+        try {
+            ccExtNumMapper.batchInsertCcExtNum(allNewExtNums);
+        } catch (Exception e) {
+            log.warn("批量插入cc_ext_num失败,尝试逐条插入补偿", e);
+            allNewExtNums = insertOneByOne(allNewExtNums);
+        }
+
+        return allNewExtNums;
+    }
+
+    public static boolean isInSaasPool(long extNum) {
+        if (extNum < SAAS_EXT_NUM_MIN || extNum > SAAS_EXT_NUM_MAX) {
+            return false;
+        }
+        int digits = String.valueOf(extNum).length();
+        return digits == 6 || digits == 7;
+    }
+
+    /**
+     * 按分层顺序分配指定数量的分机号
+     *
+     * @param reserved 本批次已占用号码(未入库),避免同批重复
+     */
+    private List<Long> allocateNumbersInLayers(int startLayer, int count, Set<Long> reserved) {
+        Set<Long> occupied = reserved == null ? new HashSet<>() : new HashSet<>(reserved);
+        List<Long> result = new ArrayList<>(count);
+        int layerIndex = Math.max(0, Math.min(startLayer, LAYER_RANGES.length - 1));
+        int remaining = count;
+        int lastUsedLayer = layerIndex;
+
+        while (remaining > 0) {
+            if (layerIndex >= LAYER_RANGES.length) {
+                throw new IllegalStateException(
+                        "SaaS分机号池已满(" + SAAS_EXT_NUM_MIN + "~" + SAAS_EXT_NUM_MAX + "),请联系管理员");
+            }
+
+            long[] range = LAYER_RANGES[layerIndex];
+            long layerMin = range[0];
+            long layerMax = range[1];
+
+            Long layerDbMax = ccExtNumMapper.selectMaxSaasPoolExtNumInRange(layerMin, layerMax);
+            long nextInLayer = layerDbMax == null ? layerMin : layerDbMax + 1;
+
+            while (remaining > 0 && nextInLayer <= layerMax) {
+                if (occupied.contains(nextInLayer)) {
+                    nextInLayer++;
+                    continue;
+                }
+                if (!isInSaasPool(nextInLayer)) {
+                    throw new IllegalStateException("SaaS分机号超出号池范围: " + nextInLayer);
+                }
+                result.add(nextInLayer);
+                occupied.add(nextInLayer);
+                remaining--;
+                lastUsedLayer = layerIndex;
+                nextInLayer++;
+            }
+
+            if (remaining > 0) {
+                layerIndex++;
+            }
+        }
+
+        saveLayerIndex(lastUsedLayer);
+        return result;
+    }
+
+    private int getCachedLayerIndex() {
+        Integer layer = redisCache.getCacheObject(REDIS_LAYER_KEY);
+        if (layer == null || layer < 0 || layer >= LAYER_RANGES.length) {
+            return 0;
+        }
+        return layer;
+    }
+
+    private void saveLayerIndex(int layerIndex) {
+        if (layerIndex >= 0 && layerIndex < LAYER_RANGES.length) {
+            redisCache.setCacheObject(REDIS_LAYER_KEY, layerIndex);
+        }
+    }
+
+    private static int resolveLayerIndex(long extNum) {
+        for (int i = 0; i < LAYER_RANGES.length; i++) {
+            if (extNum >= LAYER_RANGES[i][0] && extNum <= LAYER_RANGES[i][1]) {
+                return i;
+            }
+        }
+        return 0;
+    }
+
+    private void resolveConflicts(List<CcExtNumVo> allNewExtNums) {
+        for (int retry = 0; retry < CONFLICT_RETRY; retry++) {
+            List<Long> extNumsToCheck = allNewExtNums.stream()
+                    .map(CcExtNumVo::getExtNum)
+                    .collect(Collectors.toList());
+
+            List<CcExtNumVo> existingExtNums = ccExtNumMapper.selectExtNumByExtNums(extNumsToCheck);
+            if (CollectionUtils.isEmpty(existingExtNums)) {
+                return;
+            }
+
+            Set<Long> existingValues = existingExtNums.stream()
+                    .map(CcExtNumVo::getExtNum)
+                    .collect(Collectors.toSet());
+
+            Set<Long> reserved = allNewExtNums.stream()
+                    .map(CcExtNumVo::getExtNum)
+                    .collect(Collectors.toSet());
+
+            boolean replaced = false;
+            for (CcExtNumVo vo : allNewExtNums) {
+                if (existingValues.contains(vo.getExtNum())) {
+                    reserved.remove(vo.getExtNum());
+                    int layer = resolveLayerIndex(vo.getExtNum());
+                    List<Long> replacements = allocateNumbersInLayers(layer, 1, reserved);
+                    if (replacements.isEmpty()) {
+                        throw new IllegalStateException("SaaS分机号池已无可用号码");
+                    }
+                    long newExtNum = replacements.get(0);
+                    vo.setExtNum(newExtNum);
+                    reserved.add(newExtNum);
+                    replaced = true;
+                }
+            }
+
+            if (!replaced) {
+                return;
+            }
+        }
+    }
+
+    private List<CcExtNumVo> insertOneByOne(List<CcExtNumVo> candidates) {
+        Set<Long> reserved = new HashSet<>();
+        List<CcExtNumVo> successList = new ArrayList<>();
+        for (CcExtNumVo vo : candidates) {
+            try {
+                ccExtNumMapper.insertCcExtNum(vo);
+                successList.add(vo);
+                reserved.add(vo.getExtNum());
+            } catch (Exception ex) {
+                log.warn("插入分机号{} 失败,尝试从号池重新取号", vo.getExtNum());
+                if (compensateInsert(vo, reserved)) {
+                    successList.add(vo);
+                }
+            }
+        }
+        return successList;
+    }
+
+    private boolean compensateInsert(CcExtNumVo vo, Set<Long> reserved) {
+        for (int retry = 0; retry < COMPENSATE_RETRY; retry++) {
+            int startLayer = getCachedLayerIndex();
+            List<Long> nums = allocateNumbersInLayers(startLayer, 1, reserved);
+            if (nums.isEmpty()) {
+                return false;
+            }
+            vo.setExtNum(nums.get(0));
+            try {
+                ccExtNumMapper.insertCcExtNum(vo);
+                reserved.add(nums.get(0));
+                return true;
+            } catch (Exception ignored) {
+            }
+        }
+        return false;
+    }
+}

+ 4 - 85
fs-service/src/main/java/com/fs/company/service/impl/CompanyExtensionBindServiceImpl.java

@@ -4,8 +4,8 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.fs.aiSipCall.vo.CcExtNumVo;
 import com.fs.common.utils.DateUtils;
 import com.fs.company.domain.CompanyExtensionBind;
-import com.fs.company.mapper.CcExtNumMapper;
 import com.fs.company.mapper.CompanyExtensionBindMapper;
+import com.fs.company.service.easycall.CcExtNumAllocator;
 import com.fs.company.param.BatchCreateExtensionParam;
 import com.fs.company.service.ICompanyExtensionBindService;
 import org.slf4j.Logger;
@@ -16,7 +16,6 @@ import org.springframework.util.CollectionUtils;
 
 import java.util.ArrayList;
 import java.util.List;
-import java.util.stream.Collectors;
 
 /**
  * 公司分机绑定Service业务层处理
@@ -30,7 +29,7 @@ public class CompanyExtensionBindServiceImpl extends ServiceImpl<CompanyExtensio
     private static final Logger log = LoggerFactory.getLogger(CompanyExtensionBindServiceImpl.class);
 
     @Autowired
-    private CcExtNumMapper ccExtNumMapper;
+    private CcExtNumAllocator ccExtNumAllocator;
 
     /**
      * 查询公司分机绑定
@@ -110,88 +109,8 @@ public class CompanyExtensionBindServiceImpl extends ServiceImpl<CompanyExtensio
         int createNum = param.getCreateNum();
         String password = param.getPassword();
         Long companyId = param.getCompanyId();
-
-        CcExtNumVo lastExtNum = ccExtNumMapper.selectLastExtNum();
-        long currentMaxExtNum = (lastExtNum != null && lastExtNum.getExtNum() != null) ? lastExtNum.getExtNum() : 0;
-
-        List<CcExtNumVo> allNewExtNums = new ArrayList<>();
-        long nextExtNum = currentMaxExtNum + 1;
-        int userCodeSeq = 1;
-
-        for (int i = 0; i < createNum; i++) {
-            CcExtNumVo vo = new CcExtNumVo();
-            vo.setExtNum(nextExtNum + i);
-            vo.setExtPass(password);
-            vo.setUserCode(tenantId + "_" + companyId + "_" + userCodeSeq);
-            userCodeSeq++;
-            allNewExtNums.add(vo);
-        }
-
-        int maxRetry = 10;
-        for (int retry = 0; retry < maxRetry; retry++) {
-            List<Long> extNumsToCheck = allNewExtNums.stream()
-                    .map(CcExtNumVo::getExtNum)
-                    .collect(Collectors.toList());
-
-            List<CcExtNumVo> existingExtNums = ccExtNumMapper.selectExtNumByExtNums(extNumsToCheck);
-            if (CollectionUtils.isEmpty(existingExtNums)) {
-                break;
-            }
-
-            List<Long> existingExtNumValues = existingExtNums.stream()
-                    .map(CcExtNumVo::getExtNum)
-                    .collect(Collectors.toList());
-
-            long maxExisting = existingExtNumValues.stream().max(Long::compareTo).orElse(currentMaxExtNum);
-            nextExtNum = maxExisting + 1;
-
-            List<CcExtNumVo> replaceList = new ArrayList<>();
-            for (CcExtNumVo vo : allNewExtNums) {
-                if (existingExtNumValues.contains(vo.getExtNum())) {
-                    vo.setExtNum(nextExtNum);
-                    nextExtNum++;
-                    replaceList.add(vo);
-                }
-            }
-
-            if (CollectionUtils.isEmpty(replaceList)) {
-                break;
-            }
-        }
-
-        try {
-            ccExtNumMapper.batchInsertCcExtNum(allNewExtNums);
-        } catch (Exception e) {
-            log.warn("批量插入cc_ext_num失败,尝试逐条插入补偿", e);
-            List<CcExtNumVo> successList = new ArrayList<>();
-            for (CcExtNumVo vo : allNewExtNums) {
-                try {
-                    ccExtNumMapper.insertCcExtNum(vo);
-                    successList.add(vo);
-                } catch (Exception ex) {
-                    log.warn("插入分机号{}失败,可能已被其他线程占用,尝试补偿", vo.getExtNum());
-                    int compensateRetry = 0;
-                    boolean compensated = false;
-                    while (compensateRetry < 5 && !compensated) {
-                        CcExtNumVo currentLast = ccExtNumMapper.selectLastExtNum();
-                        long newExtNum = (currentLast != null && currentLast.getExtNum() != null) ? currentLast.getExtNum() + 1 : 1;
-                        vo.setExtNum(newExtNum);
-                        try {
-                            ccExtNumMapper.insertCcExtNum(vo);
-                            compensated = true;
-                        } catch (Exception ex2) {
-                            compensateRetry++;
-                        }
-                    }
-                    if (compensated) {
-                        successList.add(vo);
-                    }
-                }
-            }
-            allNewExtNums = successList;
-        }
-
-        return allNewExtNums;
+        return ccExtNumAllocator.batchAllocate(createNum, password,
+                seq -> tenantId + "_" + companyId + "_" + seq);
     }
 
     @Override

+ 88 - 0
fs-service/src/main/java/com/fs/company/service/impl/SaasVoiceSeatServiceImpl.java

@@ -0,0 +1,88 @@
+package com.fs.company.service.impl;
+
+import com.fs.common.utils.DateUtils;
+import com.fs.company.mapper.CompanyExtensionBindMapper;
+import com.fs.company.param.AssignExtensionToCompanyParam;
+import com.fs.company.param.SaasVoiceSeatQueryParam;
+import com.fs.company.service.ICompanyService;
+import com.fs.company.service.ISaasVoiceSeatService;
+import com.fs.company.vo.SaasVoiceSeatVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+
+import java.util.List;
+
+/**
+ * 租户管理端分机管理 Service 实现
+ */
+@Service
+public class SaasVoiceSeatServiceImpl implements ISaasVoiceSeatService {
+
+    private static final Long POOL_COMPANY_ID = 0L;
+
+    @Autowired
+    private CompanyExtensionBindMapper companyExtensionBindMapper;
+
+    @Autowired
+    private ICompanyService companyService;
+
+    @Override
+    public List<SaasVoiceSeatVO> selectList(SaasVoiceSeatQueryParam param) {
+        return companyExtensionBindMapper.selectSaasVoiceSeatList(param);
+    }
+
+    @Override
+    public SaasVoiceSeatVO selectById(Long id) {
+        return companyExtensionBindMapper.selectSaasVoiceSeatById(id);
+    }
+
+    @Override
+    public List<SaasVoiceSeatVO> selectPoolList(SaasVoiceSeatQueryParam param) {
+        if (param == null) {
+            param = new SaasVoiceSeatQueryParam();
+        }
+        param.setAssignStatus(0);
+        return companyExtensionBindMapper.selectSaasVoiceSeatList(param);
+    }
+
+    @Override
+    public int assignToCompany(AssignExtensionToCompanyParam param) {
+        if (param == null || param.getCompanyId() == null || param.getCompanyId() <= 0) {
+            throw new IllegalArgumentException("请选择目标公司");
+        }
+        if (CollectionUtils.isEmpty(param.getIds())) {
+            throw new IllegalArgumentException("请选择要分配的分机");
+        }
+        if (companyService.selectCompanyById(param.getCompanyId()) == null) {
+            throw new IllegalArgumentException("目标公司不存在");
+        }
+        return companyExtensionBindMapper.assignExtensionToCompany(
+                param.getIds(), param.getCompanyId(), DateUtils.getNowDate());
+    }
+
+    @Override
+    public int recycleToPool(List<Long> ids) {
+        if (CollectionUtils.isEmpty(ids)) {
+            throw new IllegalArgumentException("请选择要回收的分机");
+        }
+        return companyExtensionBindMapper.recycleExtensionToPool(
+                ids, POOL_COMPANY_ID, DateUtils.getNowDate());
+    }
+
+    @Override
+    public int updateStatus(Long id, Integer status) {
+        if (id == null || status == null) {
+            return 0;
+        }
+        return companyExtensionBindMapper.updateStatusById(id, status);
+    }
+
+    @Override
+    public int batchUpdateStatus(List<Long> ids, Integer status) {
+        if (CollectionUtils.isEmpty(ids) || status == null) {
+            return 0;
+        }
+        return companyExtensionBindMapper.batchUpdateStatusByIds(ids, status);
+    }
+}

+ 27 - 0
fs-service/src/main/java/com/fs/company/service/workflow/ILobsterEvolutionSuggestionService.java

@@ -0,0 +1,27 @@
+package com.fs.company.service.workflow;
+
+import com.fs.company.domain.LobsterEvolutionSuggestion;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 龙虾引擎进化建议管理服务
+ */
+public interface ILobsterEvolutionSuggestionService {
+
+    /** 列表查询(可选过滤) */
+    List<Map<String, Object>> listSuggestions(Long companyId);
+
+    /** 按状态查询待审核/已审核 */
+    List<LobsterEvolutionSuggestion> listByStatus(Long companyId, int status, int limit);
+
+    /** 进化统计 */
+    Map<String, Object> getStats(Long companyId);
+
+    /** 引擎进化指标 */
+    Map<String, Object> getEvolutionMetrics(Long companyId);
+
+    /** 待审核列表 */
+    List<LobsterEvolutionSuggestion> listPendingAudit(Long companyId, int limit);
+}

+ 11 - 0
fs-service/src/main/java/com/fs/company/service/workflow/ILobsterInstanceStatsService.java

@@ -0,0 +1,11 @@
+package com.fs.company.service.workflow;
+
+import java.util.Map;
+
+/**
+ * 龙虾引擎实例统计服务
+ */
+public interface ILobsterInstanceStatsService {
+
+    Map<String, Object> getStats(Long companyId);
+}

+ 22 - 0
fs-service/src/main/java/com/fs/company/service/workflow/IWorkflowTemplateAdminService.java

@@ -0,0 +1,22 @@
+package com.fs.company.service.workflow;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 工作流模板/节点管理端服务(跨模块 company_workflow_lobster 表)
+ */
+public interface IWorkflowTemplateAdminService {
+
+    /** 工作流模板列表(可选租户过滤) */
+    List<Map<String, Object>> listTemplates(Long companyId);
+
+    /** 已发布模板列表 */
+    List<Map<String, Object>> listPublishedTemplates();
+
+    /** 获取模板 + 节点树 */
+    Map<String, Object> getTemplateWithNodes(Long workflowId);
+
+    /** 保存模板节点(先删后插) */
+    void saveTemplateNodes(Map<String, Object> body);
+}

+ 86 - 0
fs-service/src/main/java/com/fs/company/service/workflow/impl/LobsterEvolutionSuggestionServiceImpl.java

@@ -0,0 +1,86 @@
+package com.fs.company.service.workflow.impl;
+
+import com.fs.company.domain.LobsterEvolutionSuggestion;
+import com.fs.company.mapper.LobsterEvolutionLogMapper;
+import com.fs.company.mapper.LobsterEvolutionSuggestionMapper;
+import com.fs.company.service.workflow.ILobsterEvolutionSuggestionService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+
+@Service
+public class LobsterEvolutionSuggestionServiceImpl implements ILobsterEvolutionSuggestionService {
+
+    private static final Logger log = LoggerFactory.getLogger(LobsterEvolutionSuggestionServiceImpl.class);
+
+    @Autowired
+    private LobsterEvolutionSuggestionMapper suggestionMapper;
+
+    @Autowired
+    private LobsterEvolutionLogMapper evolutionLogMapper;
+
+    @Override
+    public List<Map<String, Object>> listSuggestions(Long companyId) {
+        try {
+            return suggestionMapper.selectFieldsList(companyId, 200);
+        } catch (Exception e) {
+            log.warn("[EvolutionSuggestion] 列表查询异常: companyId={}", companyId, e);
+            return new ArrayList<>();
+        }
+    }
+
+    @Override
+    public List<LobsterEvolutionSuggestion> listByStatus(Long companyId, int status, int limit) {
+        try {
+            if (status < 0) {
+                return suggestionMapper.selectList(companyId, limit);
+            }
+            return suggestionMapper.selectListByStatus(companyId, status, limit);
+        } catch (Exception e) {
+            log.warn("[EvolutionSuggestion] 按状态查询异常: companyId={}, status={}", companyId, status, e);
+            return new ArrayList<>();
+        }
+    }
+
+    @Override
+    public Map<String, Object> getStats(Long companyId) {
+        Map<String, Object> stats = new HashMap<>();
+        try {
+            stats.put("total", suggestionMapper.countByCompanyId(companyId));
+            stats.put("pending", suggestionMapper.countByStatus(companyId, 0));
+            stats.put("approved", suggestionMapper.countByStatus(companyId, 1));
+            stats.put("rejected", suggestionMapper.countByStatus(companyId, 2));
+        } catch (Exception e) {
+            log.warn("[EvolutionSuggestion] 统计异常: companyId={}", companyId, e);
+            stats.put("total", 0); stats.put("pending", 0);
+            stats.put("approved", 0); stats.put("rejected", 0);
+        }
+        return stats;
+    }
+
+    @Override
+    public Map<String, Object> getEvolutionMetrics(Long companyId) {
+        Map<String, Object> data = new HashMap<>();
+        try {
+            data.put("totalEvolutions", evolutionLogMapper.countByCompanyId(companyId));
+            data.put("appliedCount", suggestionMapper.countByStatus(companyId, 1));
+            data.put("pendingCount", suggestionMapper.countByStatus(companyId, 0));
+        } catch (Exception e) {
+            log.warn("[EvolutionSuggestion] 指标查询异常: companyId={}", companyId, e);
+            data.put("totalEvolutions", 0); data.put("appliedCount", 0); data.put("pendingCount", 0);
+        }
+        return data;
+    }
+
+    @Override
+    public List<LobsterEvolutionSuggestion> listPendingAudit(Long companyId, int limit) {
+        try {
+            return suggestionMapper.selectListByStatus(companyId, 0, limit);
+        } catch (Exception e) {
+            return new ArrayList<>();
+        }
+    }
+}

+ 48 - 0
fs-service/src/main/java/com/fs/company/service/workflow/impl/LobsterInstanceStatsServiceImpl.java

@@ -0,0 +1,48 @@
+package com.fs.company.service.workflow.impl;
+
+import com.fs.company.mapper.LobsterAuxiliaryMapper;
+import com.fs.company.mapper.LobsterWorkflowInstanceMapper;
+import com.fs.company.service.workflow.ILobsterInstanceStatsService;
+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.Map;
+
+@Service
+public class LobsterInstanceStatsServiceImpl implements ILobsterInstanceStatsService {
+
+    private static final Logger log = LoggerFactory.getLogger(LobsterInstanceStatsServiceImpl.class);
+
+    @Autowired
+    private LobsterWorkflowInstanceMapper instanceMapper;
+
+    @Autowired(required = false)
+    private LobsterAuxiliaryMapper auxMapper;
+
+    @Override
+    public Map<String, Object> getStats(Long companyId) {
+        Map<String, Object> stats = new HashMap<>();
+        try {
+            stats.put("running", instanceMapper.countByStatus(companyId, "running"));
+            stats.put("paused", instanceMapper.countByStatus(companyId, "paused"));
+            stats.put("completed", instanceMapper.countByStatus(companyId, "completed"));
+
+            Integer dead = auxMapper != null ? auxMapper.countDeadLetterPending(companyId) : 0;
+            stats.put("deadLetters", dead != null ? dead : 0);
+
+            Long tokens = auxMapper != null ? auxMapper.sumTodayTokens(companyId) : 0L;
+            stats.put("todayTokens", tokens != null ? String.valueOf(tokens) : "0");
+        } catch (Exception e) {
+            log.warn("[InstanceStats] 统计异常: companyId={}, {}", companyId, e.getMessage());
+            stats.put("running", 0);
+            stats.put("paused", 0);
+            stats.put("completed", 0);
+            stats.put("deadLetters", 0);
+            stats.put("todayTokens", "0");
+        }
+        return stats;
+    }
+}

+ 162 - 0
fs-service/src/main/java/com/fs/company/service/workflow/impl/WorkflowTemplateAdminServiceImpl.java

@@ -0,0 +1,162 @@
+package com.fs.company.service.workflow.impl;
+
+import com.fs.company.domain.CompanyWorkflowLobster;
+import com.fs.company.domain.CompanyWorkflowLobsterNode;
+import com.fs.company.mapper.CompanyWorkflowLobsterMapper;
+import com.fs.company.mapper.CompanyWorkflowLobsterNodeMapper;
+import com.fs.company.service.workflow.IWorkflowTemplateAdminService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+
+@Service
+public class WorkflowTemplateAdminServiceImpl implements IWorkflowTemplateAdminService {
+
+    private static final Logger log = LoggerFactory.getLogger(WorkflowTemplateAdminServiceImpl.class);
+
+    @Autowired
+    private CompanyWorkflowLobsterMapper templateMapper;
+
+    @Autowired
+    private CompanyWorkflowLobsterNodeMapper nodeMapper;
+
+    @Override
+    public List<Map<String, Object>> listTemplates(Long companyId) {
+        try {
+            List<CompanyWorkflowLobster> list;
+            if (companyId != null) {
+                list = templateMapper.selectTemplateList(companyId);
+            } else {
+                list = templateMapper.selectTemplateList(null);
+            }
+            List<Map<String, Object>> result = new ArrayList<>();
+            if (list != null) {
+                for (CompanyWorkflowLobster t : list) {
+                    Map<String, Object> m = new LinkedHashMap<>();
+                    m.put("id", t.getId());
+                    m.put("companyId", t.getCompanyId());
+                    m.put("templateCode", t.getTemplateCode());
+                    m.put("templateName", t.getTemplateName());
+                    m.put("industryType", t.getIndustryType());
+                    m.put("status", t.getStatus());
+                    m.put("version", t.getVersion());
+                    m.put("canvasData", t.getCanvasData());
+                    m.put("updateTime", t.getUpdateTime());
+                    m.put("createTime", t.getCreateTime());
+                    result.add(m);
+                }
+            }
+            return result;
+        } catch (Exception e) {
+            log.warn("[TemplateAdmin] 列表查询异常: companyId={}", companyId, e);
+            return new ArrayList<>();
+        }
+    }
+
+    @Override
+    public List<Map<String, Object>> listPublishedTemplates() {
+        try {
+            List<CompanyWorkflowLobster> list = templateMapper.selectTemplateListByStatus(null, 1);
+            List<Map<String, Object>> result = new ArrayList<>();
+            if (list != null) {
+                for (CompanyWorkflowLobster t : list) {
+                    Map<String, Object> m = new LinkedHashMap<>();
+                    m.put("id", t.getId());
+                    m.put("templateCode", t.getTemplateCode());
+                    m.put("templateName", t.getTemplateName());
+                    m.put("industryType", t.getIndustryType());
+                    m.put("description", t.getDescription());
+                    m.put("status", t.getStatus());
+                    m.put("version", t.getVersion());
+                    m.put("createTime", t.getCreateTime());
+                    m.put("updateTime", t.getUpdateTime());
+                    result.add(m);
+                }
+            }
+            return result;
+        } catch (Exception e) {
+            log.warn("[TemplateAdmin] 已发布模板查询异常", e);
+            return new ArrayList<>();
+        }
+    }
+
+    @Override
+    public Map<String, Object> getTemplateWithNodes(Long workflowId) {
+        Map<String, Object> result = new HashMap<>();
+        try {
+            CompanyWorkflowLobster template = templateMapper.selectTemplateByIdAndCompanyId(workflowId, null);
+            if (template != null) {
+                Map<String, Object> tm = new LinkedHashMap<>();
+                tm.put("id", template.getId());
+                tm.put("templateCode", template.getTemplateCode());
+                tm.put("templateName", template.getTemplateName());
+                tm.put("industryType", template.getIndustryType());
+                tm.put("description", template.getDescription());
+                tm.put("status", template.getStatus());
+                result.put("template", tm);
+            }
+            List<CompanyWorkflowLobsterNode> nodes = nodeMapper.selectByWorkflowId(workflowId);
+            result.put("nodes", nodes != null ? nodes : new ArrayList<>());
+        } catch (Exception e) {
+            log.warn("[TemplateAdmin] 获取模板节点异常: workflowId={}", workflowId, e);
+            result.put("template", null);
+            result.put("nodes", new ArrayList<>());
+        }
+        return result;
+    }
+
+    @Override
+    public void saveTemplateNodes(Map<String, Object> body) {
+        Long workflowId = toLong(body.get("workflowId"));
+        if (workflowId == null) throw new IllegalArgumentException("workflowId必填");
+
+        // 更新模板头
+        String templateName = (String) body.get("templateName");
+        if (templateName != null) {
+            CompanyWorkflowLobster template = new CompanyWorkflowLobster();
+            template.setId(workflowId);
+            template.setTemplateName(templateName);
+            template.setIndustryType((String) body.get("industryType"));
+            template.setDescription((String) body.get("description"));
+            templateMapper.updateTemplateById(template);
+        }
+
+        // 软删除旧节点
+        nodeMapper.deleteByWorkflowId(workflowId);
+
+        // 插入新节点
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> nodeList = (List<Map<String, Object>>) body.get("nodes");
+        if (nodeList != null && !nodeList.isEmpty()) {
+            List<CompanyWorkflowLobsterNode> entities = new ArrayList<>();
+            for (Map<String, Object> n : nodeList) {
+                CompanyWorkflowLobsterNode node = new CompanyWorkflowLobsterNode();
+                node.setWorkflowId(workflowId);
+                node.setNodeCode((String) n.getOrDefault("nodeCode", ""));
+                node.setNodeName((String) n.getOrDefault("nodeName", ""));
+                node.setNodeType(toInt(n, "nodeType", 2));
+                node.setSortNo(toInt(n, "sortNo", 0));
+                node.setNextNodeCode((String) n.getOrDefault("nextNodeCode", null));
+                node.setMessageTemplate((String) n.getOrDefault("messageTemplate", null));
+                node.setConditionExpr((String) n.getOrDefault("conditionExpr", null));
+                node.setNodeConfig((String) n.getOrDefault("nodeConfig", null));
+                entities.add(node);
+            }
+            nodeMapper.batchInsert(entities);
+        }
+    }
+
+    private int toInt(Map<String, Object> map, String key, int def) {
+        Object v = map.get(key);
+        return v instanceof Number ? ((Number) v).intValue() : def;
+    }
+
+    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; }
+    }
+}

+ 43 - 0
fs-service/src/main/java/com/fs/company/vo/SaasVoiceSeatVO.java

@@ -0,0 +1,43 @@
+package com.fs.company.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 租户管理端分机列表 VO
+ */
+@Data
+public class SaasVoiceSeatVO {
+
+    private Long id;
+
+    private Long companyId;
+
+    /** 所属公司名称,分机池显示「租户分机池」 */
+    private String companyName;
+
+    private Long companyUserId;
+
+    private String companyUserName;
+
+    private String extensionNum;
+
+    private String extensionPass;
+
+    private Long extId;
+
+    private String userCode;
+
+    /** 状态:0停用,1可用 */
+    private Integer status;
+
+    private String remark;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date updateTime;
+}

+ 57 - 0
fs-service/src/main/java/com/fs/proxy/domain/TenantExtensionBind.java

@@ -0,0 +1,57 @@
+package com.fs.proxy.domain;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 租户分机号码管理 tenant_extension_bind
+ *
+ * @author MixLiu
+ * @date 2026/6/8
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class TenantExtensionBind extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long id;
+
+    @Excel(name = "租户ID")
+    private Long tenantId;
+
+    @Excel(name = "分机号码")
+    private String extensionNum;
+
+    @Excel(name = "分机密码")
+    private String extensionPass;
+
+    /** easycall使用字段-流水编号 */
+    private Long extId;
+
+    /** easycall使用字段-所属员工/绑定关系 */
+    private String userCode;
+
+    /** 状态:0停用,1可用 */
+    @Excel(name = "状态", readConverterExp = "0=停用,1=可用")
+    private Integer status;
+
+    /** 是否删除:0否,1是 */
+    private Integer isDel;
+
+    /** 备注 */
+    @Excel(name = "备注")
+    private String remark;
+
+    /** 联查:租户名称 */
+    @TableField(exist = false)
+    @Excel(name = "租户名称")
+    private String tenantName;
+
+    /** 查询条件:租户名称模糊搜索 */
+    @TableField(exist = false)
+    private String companyName;
+}

+ 33 - 0
fs-service/src/main/java/com/fs/proxy/mapper/TenantExtensionBindMapper.java

@@ -0,0 +1,33 @@
+package com.fs.proxy.mapper;
+
+import com.fs.proxy.domain.TenantExtensionBind;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 租户分机绑定 Mapper
+ *
+ * @author MixLiu
+ * @date 2026/6/8
+ */
+public interface TenantExtensionBindMapper {
+
+    TenantExtensionBind selectById(Long id);
+
+    List<TenantExtensionBind> selectList(TenantExtensionBind param);
+
+    int insert(TenantExtensionBind record);
+
+    int batchInsert(@Param("list") List<TenantExtensionBind> list);
+
+    int updateStatus(@Param("id") Long id, @Param("status") Integer status);
+
+    int batchUpdateStatus(@Param("ids") List<Long> ids, @Param("status") Integer status);
+
+    List<TenantExtensionBind> selectByIds(@Param("ids") List<Long> ids);
+
+    int logicDeleteById(@Param("id") Long id);
+
+    int batchLogicDeleteByIds(@Param("ids") List<Long> ids);
+}

+ 22 - 0
fs-service/src/main/java/com/fs/proxy/param/BatchCreateTenantExtensionParam.java

@@ -0,0 +1,22 @@
+package com.fs.proxy.param;
+
+import lombok.Data;
+
+/**
+ * 批量创建租户分机参数
+ *
+ * @author MixLiu
+ * @date 2026/6/8
+ */
+@Data
+public class BatchCreateTenantExtensionParam {
+
+    /** 租户id */
+    private Long tenantId;
+
+    /** 生成数量 */
+    private Integer createNum;
+
+    /** 分机密码 */
+    private String password;
+}

+ 45 - 0
fs-service/src/main/java/com/fs/proxy/service/ITenantExtensionBindService.java

@@ -0,0 +1,45 @@
+package com.fs.proxy.service;
+
+import com.fs.aiSipCall.vo.CcExtNumVo;
+import com.fs.proxy.domain.TenantExtensionBind;
+import com.fs.proxy.param.BatchCreateTenantExtensionParam;
+
+import java.util.List;
+
+/**
+ * 租户分机绑定 Service
+ *
+ * @author MixLiu
+ * @date 2026/6/8
+ */
+public interface ITenantExtensionBindService {
+
+    TenantExtensionBind selectById(Long id);
+
+    List<TenantExtensionBind> selectList(TenantExtensionBind param);
+
+    /**
+     * 在 EASYCALL 数据源中生成分机号
+     */
+    List<CcExtNumVo> createExtensionInEasycall(BatchCreateTenantExtensionParam param);
+
+    /**
+     * 写入主库 tenant_extension_bind
+     */
+    void bindExtensionToTenant(List<CcExtNumVo> extNums, Long tenantId, String createBy);
+
+    /**
+     * 一键创建租户分机
+     */
+    int batchCreateExtension(BatchCreateTenantExtensionParam param, String createBy);
+
+    int updateStatus(Long id, Integer status);
+
+    int batchUpdateStatus(List<Long> ids, Integer status);
+
+    int logicDeleteById(Long id);
+
+    int batchLogicDeleteByIds(List<Long> ids);
+
+    List<TenantExtensionBind> selectListForExport(TenantExtensionBind param);
+}

+ 261 - 0
fs-service/src/main/java/com/fs/proxy/service/impl/TenantExtensionBindServiceImpl.java

@@ -0,0 +1,261 @@
+package com.fs.proxy.service.impl;
+
+import com.fs.aiSipCall.vo.CcExtNumVo;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.CompanyExtensionBind;
+import com.fs.company.mapper.CompanyExtensionBindMapper;
+import com.fs.company.service.easycall.CcExtNumAllocator;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.framework.datasource.TenantDataSourceManager;
+import com.fs.proxy.domain.TenantExtensionBind;
+import com.fs.proxy.mapper.TenantExtensionBindMapper;
+import com.fs.proxy.param.BatchCreateTenantExtensionParam;
+import com.fs.proxy.service.ITenantExtensionBindService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 租户分机绑定 Service 实现
+ *
+ * @author MixLiu
+ * @date 2026/6/8
+ */
+@Service
+public class TenantExtensionBindServiceImpl implements ITenantExtensionBindService {
+
+    private static final Logger log = LoggerFactory.getLogger(TenantExtensionBindServiceImpl.class);
+
+    /** 租户级分机池,company_id 使用 0 表示未分配到具体公司 */
+    private static final Long TENANT_POOL_COMPANY_ID = 0L;
+
+    @Autowired
+    private TenantExtensionBindMapper tenantExtensionBindMapper;
+
+    @Autowired
+    private CcExtNumAllocator ccExtNumAllocator;
+
+    @Autowired
+    private CompanyExtensionBindMapper companyExtensionBindMapper;
+
+    @Autowired
+    private TenantDataSourceManager tenantDataSourceManager;
+
+    @Override
+    public TenantExtensionBind selectById(Long id) {
+        return tenantExtensionBindMapper.selectById(id);
+    }
+
+    @Override
+    public List<TenantExtensionBind> selectList(TenantExtensionBind param) {
+        return tenantExtensionBindMapper.selectList(param);
+    }
+
+    @Override
+    public List<CcExtNumVo> createExtensionInEasycall(BatchCreateTenantExtensionParam param) {
+        int createNum = param.getCreateNum();
+        String password = param.getPassword();
+        Long tenantId = param.getTenantId();
+        return ccExtNumAllocator.batchAllocate(createNum, password,
+                seq -> tenantId + "_" + seq);
+    }
+
+    @Override
+    public void bindExtensionToTenant(List<CcExtNumVo> extNums, Long tenantId, String createBy) {
+        restoreMasterDataSource();
+        bindExtensionToMaster(extNums, tenantId, createBy);
+    }
+
+    @Override
+    public int batchCreateExtension(BatchCreateTenantExtensionParam param, String createBy) {
+        if (param.getTenantId() == null) {
+            throw new IllegalArgumentException("租户ID不能为空");
+        }
+        if (param.getCreateNum() == null || param.getCreateNum() <= 0) {
+            throw new IllegalArgumentException("生成数量必须大于0");
+        }
+        if (StringUtils.isEmpty(param.getPassword())) {
+            param.setPassword("123456");
+        }
+
+        List<CcExtNumVo> extNums = createExtensionInEasycall(param);
+        if (CollectionUtils.isEmpty(extNums)) {
+            return 0;
+        }
+        restoreMasterDataSource();
+        bindExtensionToMaster(extNums, param.getTenantId(), createBy);
+        syncCompanyExtensionBind(param.getTenantId(), extNums, createBy);
+        return extNums.size();
+    }
+
+    @Override
+    public int updateStatus(Long id, Integer status) {
+        restoreMasterDataSource();
+        TenantExtensionBind record = tenantExtensionBindMapper.selectById(id);
+        if (record == null) {
+            return 0;
+        }
+        int rows = tenantExtensionBindMapper.updateStatus(id, status);
+        if (rows > 0 && record.getExtId() != null) {
+            syncCompanyExtensionStatus(record.getTenantId(), java.util.Collections.singletonList(record.getExtId()), status);
+        }
+        return rows;
+    }
+
+    @Override
+    public int batchUpdateStatus(List<Long> ids, Integer status) {
+        if (CollectionUtils.isEmpty(ids)) {
+            return 0;
+        }
+        restoreMasterDataSource();
+        Map<Long, List<Long>> grouped = groupExtIdsByTenantId(ids);
+        int rows = tenantExtensionBindMapper.batchUpdateStatus(ids, status);
+        if (rows > 0) {
+            for (Map.Entry<Long, List<Long>> entry : grouped.entrySet()) {
+                syncCompanyExtensionStatus(entry.getKey(), entry.getValue(), status);
+            }
+        }
+        return rows;
+    }
+
+    @Override
+    public int logicDeleteById(Long id) {
+        restoreMasterDataSource();
+        TenantExtensionBind record = tenantExtensionBindMapper.selectById(id);
+        if (record == null) {
+            return 0;
+        }
+        int rows = tenantExtensionBindMapper.logicDeleteById(id);
+        if (rows > 0 && record.getExtId() != null) {
+            syncCompanyExtensionDelete(record.getTenantId(), java.util.Collections.singletonList(record.getExtId()));
+        }
+        return rows;
+    }
+
+    @Override
+    public int batchLogicDeleteByIds(List<Long> ids) {
+        if (CollectionUtils.isEmpty(ids)) {
+            return 0;
+        }
+        restoreMasterDataSource();
+        Map<Long, List<Long>> grouped = groupExtIdsByTenantId(ids);
+        int rows = tenantExtensionBindMapper.batchLogicDeleteByIds(ids);
+        if (rows > 0) {
+            for (Map.Entry<Long, List<Long>> entry : grouped.entrySet()) {
+                syncCompanyExtensionDelete(entry.getKey(), entry.getValue());
+            }
+        }
+        return rows;
+    }
+
+    @Override
+    public List<TenantExtensionBind> selectListForExport(TenantExtensionBind param) {
+        restoreMasterDataSource();
+        return tenantExtensionBindMapper.selectList(param);
+    }
+
+    private void bindExtensionToMaster(List<CcExtNumVo> extNums, Long tenantId, String createBy) {
+        List<TenantExtensionBind> bindList = new ArrayList<>();
+        for (CcExtNumVo extNumVo : extNums) {
+            TenantExtensionBind bind = new TenantExtensionBind();
+            bind.setTenantId(tenantId);
+            bind.setExtensionNum(String.valueOf(extNumVo.getExtNum()));
+            bind.setExtensionPass(extNumVo.getExtPass());
+            bind.setExtId(extNumVo.getExtId());
+            bind.setUserCode(extNumVo.getUserCode());
+            bind.setStatus(1);
+            bind.setIsDel(0);
+            bind.setCreateBy(createBy);
+            bind.setCreateTime(DateUtils.getNowDate());
+            bindList.add(bind);
+        }
+
+        int batchSize = 100;
+        for (int i = 0; i < bindList.size(); i += batchSize) {
+            int end = Math.min(i + batchSize, bindList.size());
+            tenantExtensionBindMapper.batchInsert(bindList.subList(i, end));
+        }
+    }
+
+    private void syncCompanyExtensionBind(Long tenantId, List<CcExtNumVo> extNums, String createBy) {
+        switchToTenantDb(tenantId);
+        try {
+            List<CompanyExtensionBind> bindList = new ArrayList<>();
+            for (CcExtNumVo extNumVo : extNums) {
+                CompanyExtensionBind bind = new CompanyExtensionBind();
+                bind.setCompanyId(TENANT_POOL_COMPANY_ID);
+                bind.setExtensionNum(String.valueOf(extNumVo.getExtNum()));
+                bind.setExtensionPass(extNumVo.getExtPass());
+                bind.setExtId(extNumVo.getExtId());
+                bind.setUserCode(extNumVo.getUserCode());
+                bind.setStatus(1);
+                bind.setIsDel(0);
+                bind.setCreateBy(createBy);
+                bind.setCreateTime(DateUtils.getNowDate());
+                bindList.add(bind);
+            }
+            int batchSize = 100;
+            for (int i = 0; i < bindList.size(); i += batchSize) {
+                int end = Math.min(i + batchSize, bindList.size());
+                companyExtensionBindMapper.batchInsertCompanyExtensionBind(bindList.subList(i, end));
+            }
+        } finally {
+            restoreMasterDataSource();
+        }
+    }
+
+    private void syncCompanyExtensionStatus(Long tenantId, List<Long> extIds, Integer status) {
+        if (CollectionUtils.isEmpty(extIds)) {
+            return;
+        }
+        switchToTenantDb(tenantId);
+        try {
+            companyExtensionBindMapper.batchUpdateStatusByExtIds(extIds, status);
+        } finally {
+            restoreMasterDataSource();
+        }
+    }
+
+    private void syncCompanyExtensionDelete(Long tenantId, List<Long> extIds) {
+        if (CollectionUtils.isEmpty(extIds)) {
+            return;
+        }
+        switchToTenantDb(tenantId);
+        try {
+            companyExtensionBindMapper.batchLogicDeleteByExtIds(extIds);
+        } finally {
+            restoreMasterDataSource();
+        }
+    }
+
+    private Map<Long, List<Long>> groupExtIdsByTenantId(List<Long> ids) {
+        List<TenantExtensionBind> records = tenantExtensionBindMapper.selectByIds(ids);
+        Map<Long, List<Long>> grouped = new HashMap<>();
+        for (TenantExtensionBind record : records) {
+            if (record.getExtId() == null) {
+                continue;
+            }
+            grouped.computeIfAbsent(record.getTenantId(), k -> new ArrayList<>()).add(record.getExtId());
+        }
+        return grouped;
+    }
+
+    private void switchToTenantDb(Long tenantId) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        tenantDataSourceManager.ensureSwitchByTenantId(tenantId);
+    }
+
+    private void restoreMasterDataSource() {
+        tenantDataSourceManager.clear();
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+    }
+}

+ 6 - 0
fs-service/src/main/java/com/fs/tenant/mapper/TenantInfoMapper.java

@@ -152,6 +152,12 @@ public interface TenantInfoMapper extends BaseMapper<TenantInfo> {
 
     int deleteComRoleMenuByMenuIds(@Param("menuIds") List<Long> menuIds);
 
+    /** 检查租户库 sys_menu 中是否存在指定菜单(须在租户数据源下调用) */
+    int countTenantSysMenuById(@Param("menuId") Long menuId);
+
+    /** 检查租户库 company_menu 中是否存在指定菜单(须在租户数据源下调用) */
+    int countTenantComMenuById(@Param("menuId") Long menuId);
+
     TenantInfo getTenByCode(String code);
 
     List<FeePlanItem> selectFeeItem(String feePlanCode);

+ 3 - 0
fs-service/src/main/resources/db/tenant-initTable-migration.sql

@@ -984,6 +984,9 @@ CREATE TABLE IF NOT EXISTS `lobster_sales_corpus` (
   `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
   `update_by` varchar(64) DEFAULT '',
   `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `content` text,
+  `source` varchar(50) DEFAULT NULL,
+  `usage_count` int DEFAULT '0',
   PRIMARY KEY (`id`),
   KEY `idx_scenario` (`scenario`),
   KEY `idx_company` (`company_id`)

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

@@ -1050,6 +1050,7 @@ CREATE TABLE `chat_msg`
     `nick_name`       varchar(200)   NULL DEFAULT NULL,
     `avatar`          varchar(200)   NULL DEFAULT NULL,
     `user_type`       tinyint(1) NULL DEFAULT 1 COMMENT '用户类型 1微信用户 2小程序用户 3销售用户',
+    `channel_type`    varchar(30) NULL DEFAULT 'QW' COMMENT '渠道类型',
     PRIMARY KEY (`msg_id`) USING BTREE
 ) ENGINE = InnoDB AUTO_INCREMENT = 1 COMMENT = '聊天消息记录表' ROW_FORMAT = DYNAMIC;
 
@@ -1113,6 +1114,15 @@ CREATE TABLE `chat_session`
     `user_type`        tinyint(1) NULL DEFAULT 1 COMMENT '用户类型 1微信用户 2小程序用户 3销售用户',
     `nick_name`        varchar(200)   NULL DEFAULT NULL,
     `avatar`           varchar(200)   NULL DEFAULT NULL,
+    `contact_id`       bigint NULL DEFAULT NULL COMMENT '触点ID',
+    `channel_type`     varchar(30) NULL DEFAULT 'QW' COMMENT '渠道类型: QW/WX/IM/WHATSAPP/...',
+    `channel_source_id` varchar(128) NULL DEFAULT NULL COMMENT '源平台用户ID',
+    `channel_source_type` varchar(100) NULL DEFAULT NULL COMMENT '源平台表名',
+    `last_msg`         varchar(500) NULL DEFAULT NULL COMMENT '最后消息摘要',
+    `last_msg_time`    datetime NULL DEFAULT NULL COMMENT '最后消息时间',
+    `unread_count`     int NULL DEFAULT 0 COMMENT '未读数',
+    `instance_id`      bigint NULL DEFAULT NULL COMMENT '工作流实例ID',
+    `external_user_id` varchar(128) NULL DEFAULT NULL COMMENT '外部用户ID',
     PRIMARY KEY (`session_id`) USING BTREE
 ) ENGINE = InnoDB AUTO_INCREMENT = 1 COMMENT = '会话表' ROW_FORMAT = DYNAMIC;
 
@@ -15833,7 +15843,8 @@ CREATE TABLE `qw_user`
     `send_msg_type`    int NULL DEFAULT 0 COMMENT '发送消息类型0侧边栏1pad',
     `is_auto`          char(2)   NULL DEFAULT '00' COMMENT '是否自动发课 00、禁用,01、启用',
     `video_get_status` int NULL DEFAULT 1 COMMENT '是否获取视频号消息(0否1是)',
-    `ai_status`        tinyint(1) NULL DEFAULT 0 COMMENT '角色状态 默认为0 0为启用 1为禁用',    `qw_open_user_id` varchar(100)   NULL DEFAULT NULL,
+    `ai_status`        tinyint(1) NULL DEFAULT 0 COMMENT '角色状态 默认为0 0为启用 1为禁用',
+    `qw_open_user_id` varchar(100)   NULL DEFAULT NULL,
 
     PRIMARY KEY (`id`) USING BTREE,
     UNIQUE INDEX `5`(`qw_user_id` ASC, `corp_id` ASC) USING BTREE,
@@ -18182,6 +18193,8 @@ CREATE TABLE `company_workflow_lobster_node`
     `message_template` text COMMENT '消息模板',
     `condition_expr`   text COMMENT '条件表达式JSON',
     `node_config`      text COMMENT '节点配置JSON',
+    `scene_code`       varchar(64)           DEFAULT NULL COMMENT 'AI场景编码',
+    `model_name`       varchar(128)          DEFAULT NULL COMMENT '指定模型名称',
     `greeting_config`  text COMMENT '问候配置JSON',
     `create_by`        varchar(64)           DEFAULT NULL,
     `create_time`      datetime              DEFAULT CURRENT_TIMESTAMP,
@@ -18189,10 +18202,11 @@ CREATE TABLE `company_workflow_lobster_node`
     `update_time`      datetime              DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     `del_flag`         tinyint      NOT NULL DEFAULT '0',
     `send_time`        time                  DEFAULT NULL COMMENT '发送时间',
-    `max_round`        int                   DEFAULT '0' COMMENT '最大循环数',
+    `max_rounds`       int                   DEFAULT '0' COMMENT '最大循环数',
     PRIMARY KEY (`id`),
     KEY                `idx_workflow_id` (`workflow_id`),
     KEY                `idx_node_code` (`node_code`),
+    KEY                `idx_node_scene_code` (`scene_code`),
     KEY                `idx_cwln_workflow_del_sort` (`workflow_id`,`del_flag`,`sort_no`)
 ) ENGINE=InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='工作流龙虾节点';
 
@@ -18593,19 +18607,24 @@ CREATE TABLE `crm_customer_property`
 DROP TABLE IF EXISTS `company_extension_bind`;
 CREATE TABLE `company_extension_bind`
 (
-    `id`              bigint NOT NULL AUTO_INCREMENT COMMENT '主键id',
-    `company_id`      bigint NOT NULL COMMENT '公司id',
+    `id`              bigint  NOT NULL AUTO_INCREMENT COMMENT '主键id',
+    `company_id`      bigint  NOT NULL COMMENT '公司id',
     `company_user_id` bigint NULL DEFAULT NULL COMMENT '销售id(为空时没有绑定)',
     `extension_num`   varchar(50) NULL DEFAULT NULL COMMENT '分机号码',
     `extension_pass`  varchar(50) NULL DEFAULT NULL COMMENT '分机密码',
     `ext_id`          int NULL DEFAULT NULL COMMENT 'easycall使用字段-流水编号',
     `user_code`       varchar(32) NULL DEFAULT NULL COMMENT 'easycall使用字段-所属员工/绑定关系',
+    `status`          tinyint NOT NULL DEFAULT 1 COMMENT '状态:0停用,1可用',
+    `is_del`          tinyint NOT NULL DEFAULT 0 COMMENT '是否删除:0否,1是',
     `create_time`     datetime NULL DEFAULT NULL,
+    `update_time`     datetime NULL DEFAULT NULL COMMENT '更新时间',
     `create_by`       varchar(255) NULL DEFAULT NULL,
     PRIMARY KEY (`id`) USING BTREE,
     INDEX             `company_company_user_idx`(`company_id`, `company_user_id`) USING BTREE,
-    INDEX             `company_extension_idx`(`company_id`, `extension_num`) USING BTREE
-) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
+    INDEX             `company_extension_idx`(`company_id`, `extension_num`) USING BTREE,
+    INDEX             `idx_ext_id`(`ext_id`) USING BTREE,
+    INDEX             `idx_status_del`(`status`, `is_del`) USING BTREE
+) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = DYNAMIC;
 
 
 -- ----------------------------
@@ -18789,3 +18808,1847 @@ CREATE TABLE `sys_redpacket_config_more` (
   PRIMARY KEY (`id`),
   KEY `idx_mch_id` (`mch_id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='多商户配置';
+
+
+-- ============================================================================
+-- tenant-initTable 补全:与 Java @TableName / Mapper 一致的缺失表
+-- ============================================================================
+
+-- ----------------------------
+-- Table structure for cc_llm_kb
+-- ----------------------------
+DROP TABLE IF EXISTS `cc_llm_kb`;
+CREATE TABLE `cc_llm_kb` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `name` varchar(100) DEFAULT NULL COMMENT '知识库名称',
+  `title` varchar(200) DEFAULT NULL COMMENT '标题',
+  `content` text COMMENT '内容',
+  `cat_id` bigint DEFAULT NULL COMMENT '分类ID',
+  `company_id` bigint DEFAULT NULL COMMENT '公司ID',
+  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_company_id` (`company_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI外呼LLM知识库';
+
+-- ----------------------------
+-- Table structure for company_ai_provider
+-- ----------------------------
+DROP TABLE IF EXISTS `company_ai_provider`;
+CREATE TABLE `company_ai_provider` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `company_id` bigint DEFAULT NULL,
+    `provider_code` varchar(50) DEFAULT NULL,
+    `provider_name` varchar(100) DEFAULT NULL,
+    `api_key` varchar(500) DEFAULT NULL,
+    `api_endpoint` varchar(500) DEFAULT NULL,
+    `model_name` varchar(100) DEFAULT NULL,
+    `max_tokens` int DEFAULT 4096,
+    `temperature` double DEFAULT 0.7,
+    `is_default` int DEFAULT 0,
+    `enabled` int DEFAULT 1,
+    `del_flag` int DEFAULT 0,
+    `create_by` varchar(64) DEFAULT NULL,
+    `create_time` datetime DEFAULT NULL,
+    `update_by` varchar(64) DEFAULT NULL,
+    `update_time` datetime DEFAULT NULL,
+    PRIMARY KEY (`id`),
+    KEY `idx_company_id` (`company_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企业AI供应商配置';
+
+-- ----------------------------
+-- Table structure for company_ai_workflow_company_user
+-- ----------------------------
+DROP TABLE IF EXISTS `company_ai_workflow_company_user`;
+CREATE TABLE `company_ai_workflow_company_user` (
+    `workflow_id` bigint NOT NULL COMMENT '工作流ID',
+    `company_user_id` bigint NOT NULL COMMENT '公司员工ID',
+    PRIMARY KEY (`workflow_id`, `company_user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI工作流-员工关联';
+
+-- ----------------------------
+-- Table structure for company_ai_workflow_company_voice
+-- ----------------------------
+DROP TABLE IF EXISTS `company_ai_workflow_company_voice`;
+CREATE TABLE `company_ai_workflow_company_voice` (
+    `workflow_id` bigint NOT NULL COMMENT '工作流ID',
+    `company_user_id` bigint NOT NULL COMMENT '公司员工ID',
+    `node_key` varchar(64) NOT NULL COMMENT '节点Key',
+    PRIMARY KEY (`workflow_id`, `company_user_id`, `node_key`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI工作流-员工语音节点关联';
+
+-- ----------------------------
+-- Table structure for company_lobster_profile_config
+-- ----------------------------
+DROP TABLE IF EXISTS `company_lobster_profile_config`;
+CREATE TABLE `company_lobster_profile_config` (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT,
+    company_id BIGINT NOT NULL,
+    field_key VARCHAR(64) NOT NULL COMMENT '客户画像字段(如 age, gender, occupation)',
+    `field_label` VARCHAR(64) COMMENT '字段中文名',
+    `field_type` VARCHAR(32) DEFAULT NULL COMMENT '字段类型',
+    `extract_rule` TEXT COMMENT '提取规则(关键词/正则/AI prompt 模板)',
+    extract_mode VARCHAR(32) DEFAULT 'ai' COMMENT 'keyword/regex/ai',
+    enabled TINYINT DEFAULT 1,
+    sort_order INT DEFAULT 0,
+    create_by VARCHAR(64),
+    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
+    update_by VARCHAR(64),
+    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    KEY idx_company (company_id),
+    KEY idx_company_field (company_id, field_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='龙虾画像配置';
+
+-- ----------------------------
+-- Table structure for company_lobster_sensitive_word
+-- ----------------------------
+DROP TABLE IF EXISTS `company_lobster_sensitive_word`;
+CREATE TABLE `company_lobster_sensitive_word` (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT,
+    company_id BIGINT NOT NULL,
+    word VARCHAR(128) NOT NULL,
+    category VARCHAR(32) COMMENT '分类:politic/violence/porn/finance/custom',
+    action VARCHAR(32) DEFAULT 'block' COMMENT 'block/replace/warn',
+    replace_text VARCHAR(128) COMMENT '替换文本(action=replace 时用)',
+    enabled TINYINT DEFAULT 1,
+    create_by VARCHAR(64),
+    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
+    update_by VARCHAR(64),
+    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    KEY idx_company (company_id),
+    KEY idx_company_word (company_id, word)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='龙虾敏感词';
+
+-- ----------------------------
+-- Table structure for company_lobster_summary_config
+-- ----------------------------
+DROP TABLE IF EXISTS `company_lobster_summary_config`;
+CREATE TABLE `company_lobster_summary_config` (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT,
+    company_id BIGINT NOT NULL,
+    scenario VARCHAR(64) NOT NULL COMMENT '场景:daily/handoff/closing',
+    summary_template TEXT COMMENT '摘要 prompt 模板',
+    max_length INT DEFAULT 500 COMMENT '摘要最大字数',
+    enabled TINYINT DEFAULT 1,
+    create_by VARCHAR(64),
+    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
+    update_by VARCHAR(64),
+    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    KEY idx_company (company_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='龙虾会话摘要配置';
+
+-- ----------------------------
+-- Table structure for company_sms_device
+-- ----------------------------
+DROP TABLE IF EXISTS `company_sms_device`;
+CREATE TABLE `company_sms_device` (
+  `device_id`        BIGINT NOT NULL AUTO_INCREMENT COMMENT '设备ID',
+  `tenant_id`        BIGINT NOT NULL COMMENT '所属租户',
+  `company_user_id`  BIGINT DEFAULT NULL COMMENT '绑定销售用户ID(NULL=未分配)',
+  `device_name`      VARCHAR(100) DEFAULT NULL COMMENT '设备名称',
+  `imei`             VARCHAR(50) NOT NULL COMMENT 'IMEI(唯一标识)',
+  `app_version`      VARCHAR(30) DEFAULT NULL COMMENT 'APP版本',
+  `middleware_id`    BIGINT DEFAULT NULL COMMENT '关联中间件ID',
+  `last_heartbeat`   DATETIME DEFAULT NULL COMMENT '最后心跳时间',
+  `status`           TINYINT DEFAULT 0 COMMENT '0离线/1在线/2禁用',
+  `remark`           VARCHAR(500) DEFAULT NULL COMMENT '备注',
+  `create_time`      DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_time`      DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`device_id`),
+  UNIQUE KEY `uk_imei` (`imei`),
+  KEY `idx_tenant_id` (`tenant_id`),
+  KEY `idx_company_user_id` (`company_user_id`),
+  KEY `idx_middleware_id` (`middleware_id`),
+  KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='手机设备管理';
+
+-- ----------------------------
+-- Table structure for company_voice_robotic_call_blacklist_intercept_log
+-- ----------------------------
+DROP TABLE IF EXISTS `company_voice_robotic_call_blacklist_intercept_log`;
+CREATE TABLE `company_voice_robotic_call_blacklist_intercept_log` (
+  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `company_id` bigint DEFAULT NULL COMMENT '租户/公司ID',
+  `scene_code` varchar(32) NOT NULL COMMENT '业务场景:CALL外呼 SMS短信 ADD_WX加微',
+  `blacklist_id` bigint DEFAULT NULL COMMENT '命中的黑名单记录ID',
+  `target_type` tinyint NOT NULL COMMENT '对象类型:1手机号 2客户ID 3企微客户ID',
+  `target_value` varchar(128) NOT NULL COMMENT '拦截时对象值(手机号加密)',
+  `blacklist_reason` varchar(255) DEFAULT NULL COMMENT '黑名单拉黑原因快照',
+  `intercept_reason` varchar(500) NOT NULL COMMENT '拦截说明',
+  `customer_id` bigint DEFAULT NULL COMMENT 'CRM客户ID',
+  `callee_id` bigint DEFAULT NULL COMMENT '外呼被叫人ID',
+  `robotic_id` bigint DEFAULT NULL COMMENT '外呼任务ID',
+  `company_user_id` bigint DEFAULT NULL COMMENT '操作人ID',
+  `biz_id` varchar(64) DEFAULT NULL COMMENT '业务ID',
+  `biz_trace_id` varchar(64) DEFAULT NULL COMMENT '链路追踪ID',
+  `request_ip` varchar(64) DEFAULT NULL COMMENT '请求IP',
+  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '拦截时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_company_time` (`company_id`, `create_time`),
+  KEY `idx_blacklist_id` (`blacklist_id`),
+  KEY `idx_scene_time` (`scene_code`, `create_time`),
+  KEY `idx_target_value` (`target_type`, `target_value`(32)),
+  KEY `idx_customer_id` (`customer_id`),
+  KEY `idx_robotic_callee` (`robotic_id`, `callee_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='外呼黑名单拦截明细';
+
+-- ----------------------------
+-- Table structure for customer_fact
+-- ----------------------------
+DROP TABLE IF EXISTS `customer_fact`;
+CREATE TABLE `customer_fact` (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    company_id BIGINT NOT NULL,
+    external_user_id VARCHAR(128) NOT NULL,
+    instance_id BIGINT DEFAULT NULL,
+    fact_key VARCHAR(200) NOT NULL,
+    fact_value VARCHAR(2000),
+    fact_type VARCHAR(30) DEFAULT 'EXTRACTED' COMMENT 'EXTRACTED/INFERRED/MANUAL',
+    confidence DOUBLE DEFAULT 1.0 COMMENT '1.0=新插入, 衰减至<0.3将被清理',
+    create_time DATETIME DEFAULT NOW(),
+    update_time DATETIME DEFAULT NOW(),
+    UNIQUE KEY uk_fact (company_id, external_user_id, instance_id, fact_key),
+    INDEX idx_company_user (company_id, external_user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ----------------------------
+-- Table structure for customer_habit
+-- ----------------------------
+DROP TABLE IF EXISTS `customer_habit`;
+CREATE TABLE `customer_habit` (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    company_id BIGINT NOT NULL,
+    external_user_id VARCHAR(128) NOT NULL,
+    habit_key VARCHAR(64) NOT NULL,
+    habit_value VARCHAR(512),
+    confidence DOUBLE DEFAULT 0.5,
+    source VARCHAR(32) DEFAULT 'AI',
+    create_time DATETIME DEFAULT NOW(),
+    update_time DATETIME DEFAULT NOW(),
+    UNIQUE KEY uk_habit (company_id, external_user_id, habit_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ----------------------------
+-- Table structure for customer_tag
+-- ----------------------------
+DROP TABLE IF EXISTS `customer_tag`;
+CREATE TABLE `customer_tag` (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    company_id BIGINT NOT NULL,
+    external_user_id VARCHAR(128) NOT NULL,
+    tag_key VARCHAR(100) NOT NULL,
+    tag_value VARCHAR(500),
+    create_time DATETIME DEFAULT NOW(),
+    update_time DATETIME DEFAULT NOW(),
+    UNIQUE KEY uk_tag (company_id, external_user_id, tag_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ----------------------------
+-- Table structure for fee_plan
+-- ----------------------------
+DROP TABLE IF EXISTS `fee_plan`;
+CREATE TABLE `fee_plan` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `plan_code` varchar(64) NOT NULL COMMENT '????',
+  `plan_name` varchar(128) NOT NULL COMMENT '????',
+  `version` int NOT NULL COMMENT '???',
+  `status` varchar(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/PUBLISHED/ARCHIVED',
+  `effective_time` datetime DEFAULT NULL COMMENT '????',
+  `expire_time` datetime DEFAULT NULL COMMENT '????',
+  `remark` varchar(500) DEFAULT NULL,
+  `create_by` varchar(64) DEFAULT NULL,
+  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_by` varchar(64) DEFAULT NULL,
+  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_plan_ver` (`plan_code`,`version`),
+  KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='??????';
+
+-- ----------------------------
+-- Table structure for fee_plan_item
+-- ----------------------------
+DROP TABLE IF EXISTS `fee_plan_item`;
+CREATE TABLE `fee_plan_item` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `plan_code` varchar(64) NOT NULL,
+  `version` int NOT NULL,
+  `item_code` varchar(64) NOT NULL COMMENT 'FLOW_POSTPAID/CALL_OUT/CALL_IN/AI_CALL/SOP_TOKEN/AI_REPLY_TOKEN/ADD_WECHAT/OPEN_ACCOUNT_NON_AI/OPEN_ACCOUNT_AI',
+  `unit` varchar(32) NOT NULL COMMENT 'GB/MIN/TOKEN/COUNT/TIME',
+  `unit_price` decimal(18,6) NOT NULL DEFAULT '0.000000' COMMENT '??',
+  `token_unit` bigint DEFAULT NULL COMMENT 'token????(?100000)',
+  `min_charge_unit` int DEFAULT NULL COMMENT '??????(?????1??)',
+  `enabled` tinyint NOT NULL DEFAULT '1',
+  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_plan_item` (`plan_code`,`version`,`item_code`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='?????';
+
+-- ----------------------------
+-- Table structure for lobster_api_registry
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_api_registry`;
+CREATE TABLE `lobster_api_registry` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `api_name` varchar(200) NOT NULL,
+  `api_key` varchar(100) NOT NULL,
+  `api_url` varchar(500) DEFAULT NULL,
+  `api_type` varchar(50) DEFAULT NULL,
+  `api_description` text,
+  `request_method` varchar(10) DEFAULT 'POST',
+  `request_headers` text,
+  `request_body_template` text,
+  `response_mapping` text,
+  `enabled` tinyint DEFAULT '1',
+  `category` varchar(100) DEFAULT NULL,
+  `company_id` bigint DEFAULT NULL,
+  `create_by` varchar(64) DEFAULT '',
+  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+  `update_by` varchar(64) DEFAULT '',
+  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_api_key` (`api_key`),
+  KEY `idx_category` (`category`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='API注册中心';
+
+-- ----------------------------
+-- Table structure for lobster_channel_plugin_config
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_channel_plugin_config`;
+CREATE TABLE `lobster_channel_plugin_config` (
+  id            BIGINT AUTO_INCREMENT PRIMARY KEY,
+  company_id    BIGINT       NOT NULL COMMENT '租户ID',
+  channel_type  VARCHAR(30)  NOT NULL COMMENT '渠道类型: QW/WX/IM/WHATSAPP/LINE/TELEGRAM/APP_IM/DOUYIN_DM/KUAISHOU_DM/XIAOHONGSHU_DM/TMALL/JD/OTHER',
+  enabled       TINYINT      DEFAULT 0 COMMENT '是否启用: 0-禁用 1-启用',
+  config_json   TEXT         COMMENT '渠道配置JSON(API Key/Webhook URL/Token/phoneNumberId等)',
+  create_time   DATETIME     DEFAULT CURRENT_TIMESTAMP,
+  update_time   DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  UNIQUE KEY uk_company_channel (company_id, channel_type)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='龙虾渠道插件配置表(即插即用多IM通道)';
+
+-- ----------------------------
+-- Table structure for lobster_compliance_audit
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_compliance_audit`;
+CREATE TABLE `lobster_compliance_audit` (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    company_id BIGINT NOT NULL,
+    rule_name VARCHAR(200) DEFAULT NULL,
+    severity INT DEFAULT 1,
+    matched_keyword VARCHAR(200) DEFAULT NULL,
+    content_snippet VARCHAR(500) DEFAULT NULL,
+    create_time DATETIME DEFAULT NOW(),
+    INDEX idx_company (company_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ----------------------------
+-- Table structure for lobster_compliance_rule
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_compliance_rule`;
+CREATE TABLE `lobster_compliance_rule` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `company_id` bigint DEFAULT NULL,
+  `rule_name` varchar(200) DEFAULT NULL,
+  `rule_type` varchar(50) DEFAULT NULL,
+  `pattern` text,
+  `description` text,
+  `action` varchar(200) DEFAULT NULL,
+  `severity` int DEFAULT NULL,
+  `enabled` int DEFAULT '1',
+  `del_flag` int DEFAULT '0',
+  `create_by` varchar(64) DEFAULT NULL,
+  `create_time` datetime DEFAULT NULL,
+  `update_by` varchar(64) DEFAULT NULL,
+  `update_time` datetime DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  KEY `idx_company_id` (`company_id`),
+  KEY `idx_rule_type` (`rule_type`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='龙虾合规规则';
+
+-- ----------------------------
+-- Table structure for lobster_conversation_summary
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_conversation_summary`;
+CREATE TABLE `lobster_conversation_summary` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `company_id` bigint DEFAULT NULL,
+  `instance_id` bigint DEFAULT NULL,
+  `contact_id` bigint DEFAULT NULL,
+  `summary_type` varchar(50) DEFAULT NULL,
+  `summary_content` text,
+  `key_points` text,
+  `sentiment_analysis` varchar(200) DEFAULT NULL,
+  `next_action_suggestion` text,
+  `message_count` int DEFAULT '0',
+  `del_flag` int DEFAULT '0',
+  `create_by` varchar(64) DEFAULT NULL,
+  `create_time` datetime DEFAULT NULL,
+  `update_by` varchar(64) DEFAULT NULL,
+  `update_time` datetime DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  KEY `idx_company_id` (`company_id`),
+  KEY `idx_instance_id` (`instance_id`),
+  KEY `idx_contact_id` (`contact_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='龙虾对话摘要';
+
+-- ----------------------------
+-- Table structure for lobster_dead_letter_queue
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_dead_letter_queue`;
+CREATE TABLE `lobster_dead_letter_queue` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `workflow_instance_id` bigint DEFAULT NULL,
+  `node_code` varchar(100) DEFAULT NULL,
+  `error_message` text,
+  `payload` text,
+  `retry_count` int DEFAULT '0',
+  `status` varchar(20) DEFAULT 'pending',
+  `company_id` bigint DEFAULT NULL,
+  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_workflow_instance` (`workflow_instance_id`),
+  KEY `idx_status` (`status`),
+    `queue_name` varchar(100) DEFAULT NULL COMMENT '队列名',
+    `error_msg` varchar(500) DEFAULT NULL COMMENT '错误信息') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='死信队列';
+
+-- ----------------------------
+-- Table structure for lobster_dialogue_state
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_dialogue_state`;
+CREATE TABLE `lobster_dialogue_state` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `company_id` bigint DEFAULT NULL,
+  `instance_id` bigint NOT NULL,
+  `node_code` varchar(100) NOT NULL,
+  `state_json` text,
+  `update_time` datetime DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_instance_node` (`instance_id`,`node_code`),
+  KEY `idx_company` (`company_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
+
+-- ----------------------------
+-- Table structure for lobster_dynamic_node_impl
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_dynamic_node_impl`;
+CREATE TABLE `lobster_dynamic_node_impl` (
+  id            BIGINT AUTO_INCREMENT PRIMARY KEY,
+  company_id    BIGINT       DEFAULT NULL                COMMENT '租户ID, 0/NULL=全租户通用',
+  node_type     INT          NOT NULL                    COMMENT '陌生节点类型编号',
+  node_type_code VARCHAR(64) DEFAULT NULL                COMMENT '节点编码(如knowledge_retrieval)',
+  fingerprint   VARCHAR(128) NOT NULL                    COMMENT 'nodeType + config 关键字段 hash, 同 fingerprint 复用',
+  sub_dsl_json  MEDIUMTEXT   NOT NULL                    COMMENT 'AI 拆解后的子节点 DSL(JSON Array)',
+  prompt_used   TEXT         DEFAULT NULL                COMMENT '生成时使用的 prompt 备份',
+  source_model  VARCHAR(64)  DEFAULT NULL                COMMENT '生成模型名',
+  quality_score DOUBLE       DEFAULT 0                   COMMENT '质量评分 0-100',
+  exec_count    INT          DEFAULT 0                   COMMENT '累计调用次数',
+  success_count INT          DEFAULT 0                   COMMENT '累计成功次数',
+  avg_duration_ms INT        DEFAULT 0                   COMMENT '平均执行毫秒',
+  status        VARCHAR(16)  DEFAULT 'DRAFT'             COMMENT 'DRAFT/PENDING/ACTIVE/REJECTED',
+  last_exec_time DATETIME    DEFAULT NULL,
+  reviewed_by   VARCHAR(64)  DEFAULT NULL,
+  reviewed_time DATETIME     DEFAULT NULL,
+  reject_reason VARCHAR(255) DEFAULT NULL,
+  create_time   DATETIME     DEFAULT CURRENT_TIMESTAMP,
+  update_time   DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  KEY idx_lookup (node_type, fingerprint, status),
+  KEY idx_company_status (company_id, status),
+  KEY idx_node_type (node_type)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='龙虾动态节点学习产物表';
+
+-- ----------------------------
+-- Table structure for lobster_e2e_run
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_e2e_run`;
+CREATE TABLE `lobster_e2e_run` (
+  `id`                BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `run_id`            VARCHAR(64)  NOT NULL COMMENT '运行ID(UUID)',
+  `company_id`        BIGINT       NULL     COMMENT '公司ID',
+  `template_id`       BIGINT       NULL     COMMENT '工作流模板ID',
+  `instance_id`       BIGINT       NULL     COMMENT '工作流实例ID',
+  `scenario_id`       BIGINT       NULL     COMMENT '测试场景ID',
+  `business_desc`     VARCHAR(500) NULL     COMMENT '业务描述(若是即时生成)',
+  `total_score`       DECIMAL(5,2) NULL     COMMENT '综合评分(0-100)',
+  `passed_node_cnt`   INT          DEFAULT 0 COMMENT '通过节点数',
+  `total_node_cnt`    INT          DEFAULT 0 COMMENT '总节点数',
+  `duration_ms`       BIGINT       DEFAULT 0 COMMENT '总耗时ms',
+  `status`            VARCHAR(20)  DEFAULT 'RUNNING' COMMENT 'RUNNING|SUCCESS|FAILED',
+  `error_msg`         TEXT         NULL     COMMENT '错误信息',
+  `evolution_count`   INT          DEFAULT 0 COMMENT '生成的进化建议数',
+  `create_by`         VARCHAR(64)  NULL,
+  `create_time`       DATETIME     DEFAULT CURRENT_TIMESTAMP,
+  `update_time`       DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_run_id` (`run_id`),
+  KEY `idx_template` (`template_id`),
+  KEY `idx_status_time` (`status`, `create_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='龙虾E2E测试运行头表';
+
+-- ----------------------------
+-- Table structure for lobster_e2e_run_node
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_e2e_run_node`;
+CREATE TABLE `lobster_e2e_run_node` (
+  `id`               BIGINT       NOT NULL AUTO_INCREMENT,
+  `run_id`           VARCHAR(64)  NOT NULL COMMENT '关联 lobster_e2e_run.run_id',
+  `node_seq`         INT          NOT NULL COMMENT '节点序号',
+  `node_code`        VARCHAR(64)  NOT NULL COMMENT '节点编码',
+  `node_type`        VARCHAR(32)  NULL     COMMENT '节点类型',
+  `node_name`        VARCHAR(128) NULL,
+  `turn_no`          INT          DEFAULT 1 COMMENT '单节点轮次(多轮对话)',
+  `user_input`       TEXT         NULL     COMMENT '用户输入',
+  `ai_output`        TEXT         NULL     COMMENT 'AI输出',
+  `score`            DECIMAL(5,2) NULL     COMMENT '本节点本轮评分',
+  `score_detail`     TEXT         NULL     COMMENT '维度评分JSON',
+  `duration_ms`      BIGINT       DEFAULT 0,
+  `model_used`       VARCHAR(64)  NULL     COMMENT '使用的模型',
+  `evolution_hint`   TEXT         NULL     COMMENT '进化建议草稿',
+  `passed`           TINYINT(1)   DEFAULT 1 COMMENT '0=未达标 1=达标',
+  `error_msg`        TEXT         NULL,
+  `create_time`      DATETIME     DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_run_seq` (`run_id`, `node_seq`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='龙虾E2E测试节点明细';
+
+-- ----------------------------
+-- Table structure for lobster_event_node_audit
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_event_node_audit`;
+CREATE TABLE `lobster_event_node_audit` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `company_id` bigint DEFAULT NULL COMMENT '公司ID',
+  `instance_id` bigint DEFAULT NULL COMMENT '工作流实例ID',
+  `workflow_id` varchar(100) DEFAULT NULL COMMENT '工作流ID',
+  `node_key` varchar(100) DEFAULT NULL COMMENT '节点key',
+  `node_name` varchar(200) DEFAULT NULL COMMENT '节点名称',
+  `node_type` varchar(50) DEFAULT NULL COMMENT '节点类型',
+  `node_json` text COMMENT '节点完整JSON',
+  `insert_at` varchar(50) DEFAULT NULL COMMENT '插入位置(before/after)',
+  `insert_ref_node` varchar(100) DEFAULT NULL COMMENT '参考节点key',
+  `decision_engine` varchar(100) DEFAULT NULL COMMENT '决策引擎名称',
+  `decision_score` decimal(5,2) DEFAULT NULL COMMENT 'AI决策置信度',
+  `decision_reason` text COMMENT 'AI决策理由',
+  `status` varchar(20) NOT NULL DEFAULT 'pending' COMMENT '状态:pending/approved/rejected',
+  `audit_by` varchar(64) DEFAULT NULL COMMENT '审核人',
+  `audit_comment` varchar(500) DEFAULT NULL COMMENT '审核备注',
+  `audit_time` datetime DEFAULT NULL COMMENT '审核时间',
+  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_company_status` (`company_id`,`status`),
+  KEY `idx_instance_id` (`instance_id`),
+  KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='龙虾事件节点审核';
+
+-- ----------------------------
+-- Table structure for lobster_evolution_log
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_evolution_log`;
+CREATE TABLE `lobster_evolution_log` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `company_id` bigint NOT NULL COMMENT '租户ID',
+  `workflow_id` bigint DEFAULT NULL COMMENT '工作流ID',
+  `instance_id` bigint DEFAULT NULL COMMENT '工作流实例ID',
+  `contact_id` bigint DEFAULT NULL COMMENT '联系人ID',
+  `channel_type` varchar(20) DEFAULT NULL COMMENT '通道类型:IM/WX/QW',
+  `node_code` varchar(100) DEFAULT NULL COMMENT '节点编码',
+  `sent_message` text COMMENT '发送的消息内容',
+  `customer_reply` text COMMENT '客户回复内容',
+  `outcome` varchar(50) DEFAULT NULL COMMENT '结果:replied/no_reply/converted/churned',
+  `variables` text COMMENT '工作流变量快照JSON',
+  `duration_ms` bigint DEFAULT NULL COMMENT '交互耗时(毫秒)',
+  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_company_workflow` (`company_id`,`workflow_id`),
+  KEY `idx_company_node` (`company_id`,`node_code`)
+) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='进化引擎-交互日志表';
+
+-- ----------------------------
+-- Table structure for lobster_evolution_suggestion
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_evolution_suggestion`;
+CREATE TABLE `lobster_evolution_suggestion` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `company_id` bigint NOT NULL COMMENT '租户ID',
+  `workflow_id` bigint DEFAULT NULL COMMENT '工作流ID',
+  `node_code` varchar(100) DEFAULT NULL COMMENT '节点编码',
+  `suggestion_type` varchar(50) DEFAULT NULL COMMENT '建议类型:message_optimize/timing_adjust/flow_restructure',
+  `current_content` text COMMENT '当前内容',
+  `suggested_content` text COMMENT '建议内容',
+  `confidence` double DEFAULT NULL COMMENT '置信度',
+  `reason` text COMMENT '优化原因',
+  `status` tinyint DEFAULT '0' COMMENT '状态:0-待审核,1-已应用,2-已拒绝',
+  `apply_time` datetime DEFAULT NULL COMMENT '应用时间',
+  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_company` (`company_id`),
+  KEY `idx_status` (`company_id`,`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='进化引擎-优化建议表';
+
+-- ----------------------------
+-- Table structure for lobster_handoff_events
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_handoff_events`;
+CREATE TABLE `lobster_handoff_events` (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    company_id BIGINT NOT NULL,
+    instance_id BIGINT DEFAULT NULL,
+    external_user_id VARCHAR(128) DEFAULT NULL,
+    trigger_type VARCHAR(50) COMMENT 'sensitive_word/sensitive_word_high_risk/semantic_takeover',
+    trigger_detail VARCHAR(500) DEFAULT NULL,
+    create_time DATETIME DEFAULT NOW(),
+    INDEX idx_company (company_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ----------------------------
+-- Table structure for lobster_heartbeat_registry
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_heartbeat_registry`;
+CREATE TABLE `lobster_heartbeat_registry` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `instance_id` bigint NOT NULL,
+  `company_id` bigint NOT NULL,
+  `workflow_id` bigint NOT NULL,
+  `contact_id` bigint DEFAULT NULL,
+  `channel_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT 'QW',
+  `interval_ms` bigint DEFAULT '300000',
+  `timeout_ms` bigint DEFAULT '86400000',
+  `max_retries` int DEFAULT '3',
+  `auto_execute` tinyint DEFAULT '1',
+  `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT 'ACTIVE',
+  `last_heartbeat` datetime DEFAULT CURRENT_TIMESTAMP,
+  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_instance_id` (`instance_id`),
+  KEY `idx_company` (`company_id`),
+  KEY `idx_status` (`status`),
+  KEY `idx_last_heartbeat` (`last_heartbeat`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='龙虾引擎-心跳注册表';
+
+-- ----------------------------
+-- Table structure for lobster_knowledge_usage_log
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_knowledge_usage_log`;
+CREATE TABLE `lobster_knowledge_usage_log` (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    company_id BIGINT NOT NULL,
+    instance_id BIGINT DEFAULT NULL,
+    knowledge_title VARCHAR(200) DEFAULT NULL,
+    knowledge_text VARCHAR(500) DEFAULT NULL,
+    retrieval_method VARCHAR(30) COMMENT 'vector/keyword',
+    source VARCHAR(50) DEFAULT 'context_assembler',
+    create_time DATETIME DEFAULT NOW(),
+    INDEX idx_company (company_id),
+    INDEX idx_company_title (company_id, knowledge_title)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ----------------------------
+-- Table structure for lobster_message_delivery_log
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_message_delivery_log`;
+CREATE TABLE `lobster_message_delivery_log` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `company_id` bigint NOT NULL,
+  `instance_id` bigint DEFAULT NULL,
+  `workflow_id` bigint DEFAULT NULL,
+  `contact_id` bigint DEFAULT NULL,
+  `channel_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
+  `channel_user_id` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
+  `content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci,
+  `msg_type` int DEFAULT '1',
+  `channel_msg_id` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
+  `success` tinyint DEFAULT '0',
+  `error_msg` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
+  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_company` (`company_id`),
+  KEY `idx_instance` (`instance_id`),
+  KEY `idx_channel_type` (`channel_type`),
+  KEY `idx_create_time` (`create_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='龙虾引擎-消息触达日志';
+
+-- ----------------------------
+-- Table structure for lobster_model_config
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_model_config`;
+CREATE TABLE `lobster_model_config` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `model_name` varchar(100) NOT NULL,
+  `model_key` varchar(100) NOT NULL,
+  `provider` varchar(100) DEFAULT NULL,
+  `api_endpoint` varchar(500) DEFAULT NULL,
+  `max_tokens` int DEFAULT '4096',
+  `temperature` decimal(3,2) DEFAULT '0.70',
+  `enabled` tinyint DEFAULT '1',
+  `token_coefficient` decimal(10,4) DEFAULT '1.0000',
+  `company_id` bigint DEFAULT NULL,
+  `create_by` varchar(64) DEFAULT '',
+  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+  `update_by` varchar(64) DEFAULT '',
+  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_model_key` (`model_key`),
+  KEY `idx_company` (`company_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='模型配置';
+
+-- ----------------------------
+-- Table structure for lobster_multi_turn_dialogue
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_multi_turn_dialogue`;
+CREATE TABLE `lobster_multi_turn_dialogue` (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    company_id BIGINT DEFAULT NULL,
+    instance_id BIGINT NOT NULL,
+    node_code VARCHAR(100) NOT NULL,
+    state_json TEXT,
+    turn_index INT DEFAULT 0,
+    direction INT COMMENT '1=客户 2=AI',
+    content TEXT,
+    create_time DATETIME DEFAULT NOW(),
+    update_time DATETIME DEFAULT NOW(),
+    UNIQUE KEY uk_instance_node (instance_id, node_code),
+    INDEX idx_company (company_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ----------------------------
+-- Table structure for lobster_node_execution_log
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_node_execution_log`;
+CREATE TABLE `lobster_node_execution_log` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `company_id` bigint DEFAULT NULL,
+  `instance_id` bigint DEFAULT NULL,
+  `workflow_id` bigint DEFAULT NULL,
+  `node_index` int DEFAULT NULL,
+  `node_type` varchar(50) DEFAULT NULL,
+  `node_name` varchar(100) DEFAULT NULL,
+  `input_content` text,
+  `output_content` text,
+  `status` varchar(50) DEFAULT NULL,
+  `retry_count` int DEFAULT '0',
+  `del_flag` int DEFAULT '0',
+  `create_by` varchar(64) DEFAULT NULL,
+  `create_time` datetime DEFAULT NULL,
+  `update_by` varchar(64) DEFAULT NULL,
+  `update_time` datetime DEFAULT NULL,
+  `ai_model` varchar(100) DEFAULT NULL,
+  `duration_ms` bigint DEFAULT NULL,
+  `token_usage` int DEFAULT NULL,
+  `error_message` text,
+  PRIMARY KEY (`id`),
+  KEY `idx_instance_id` (`instance_id`),
+  KEY `idx_company_id` (`company_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=57 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
+
+-- ----------------------------
+-- Table structure for lobster_pay_order
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_pay_order`;
+CREATE TABLE `lobster_pay_order` (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    company_id BIGINT NOT NULL,
+    contact_id BIGINT DEFAULT NULL,
+    instance_id BIGINT DEFAULT NULL,
+    order_no VARCHAR(64) NOT NULL,
+    product_name VARCHAR(256),
+    amount BIGINT DEFAULT 0 COMMENT '金额(分)',
+    status VARCHAR(20) DEFAULT 'CREATED',
+    gateway VARCHAR(20) DEFAULT 'wechat',
+    transaction_id VARCHAR(128) DEFAULT NULL,
+    extra TEXT,
+    create_time DATETIME DEFAULT NOW(),
+    paid_time DATETIME DEFAULT NULL,
+    update_time DATETIME DEFAULT NOW(),
+    UNIQUE KEY uk_order (order_no),
+    INDEX idx_company_status (company_id, status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ----------------------------
+-- Table structure for lobster_pending_knowledge
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_pending_knowledge`;
+CREATE TABLE `lobster_pending_knowledge` (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    company_id BIGINT NOT NULL,
+    external_user_id VARCHAR(128) DEFAULT NULL,
+    knowledge_type VARCHAR(50) DEFAULT 'auto',
+    content TEXT,
+    context_snapshot TEXT COMMENT '评分维度/上下文快照JSON',
+    source_node_code VARCHAR(100) DEFAULT NULL,
+    status VARCHAR(30) DEFAULT 'pending' COMMENT 'pending/APPROVED/REJECTED',
+    auditor_id BIGINT DEFAULT NULL,
+    audit_comment VARCHAR(500) DEFAULT NULL,
+    audit_time DATETIME DEFAULT NULL,
+    create_time DATETIME DEFAULT NOW(),
+    update_time DATETIME DEFAULT NOW(),
+    INDEX idx_company_status (company_id, status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ----------------------------
+-- Table structure for lobster_sales_corpus
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_sales_corpus`;
+CREATE TABLE `lobster_sales_corpus` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `dialog_text` text,
+  `scenario` varchar(100) DEFAULT NULL,
+  `score` decimal(5,2) DEFAULT NULL,
+  `source_type` varchar(50) DEFAULT NULL,
+  `external_user_id` varchar(100) DEFAULT NULL,
+  `company_id` bigint DEFAULT NULL,
+  `tags` varchar(500) DEFAULT NULL,
+  `enabled` tinyint DEFAULT '1',
+  `create_by` varchar(64) DEFAULT '',
+  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+  `update_by` varchar(64) DEFAULT '',
+  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_scenario` (`scenario`),
+  KEY `idx_company` (`company_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='销冠语料库';
+
+-- ----------------------------
+-- Table structure for lobster_sensitive_word
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_sensitive_word`;
+CREATE TABLE `lobster_sensitive_word` (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    company_id BIGINT NOT NULL,
+    word VARCHAR(200) NOT NULL,
+    category VARCHAR(50) DEFAULT '通用',
+    level VARCHAR(20) DEFAULT 'warn',
+    replacement VARCHAR(200) DEFAULT '***',
+    enabled INT DEFAULT 1,
+    create_time DATETIME DEFAULT NOW(),
+    create_by VARCHAR(64) DEFAULT 'SYSTEM',
+    INDEX idx_company (company_id),
+    UNIQUE KEY uk_word (company_id, word)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ----------------------------
+-- Table structure for lobster_smart_api
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_smart_api`;
+CREATE TABLE `lobster_smart_api` (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    company_id BIGINT DEFAULT NULL,
+    api_code VARCHAR(100) NOT NULL COMMENT 'API唯一标识',
+    api_name VARCHAR(200) DEFAULT NULL,
+    api_url VARCHAR(500) NOT NULL,
+    api_method VARCHAR(10) DEFAULT 'POST',
+    headers_json VARCHAR(1000) DEFAULT '{}',
+    body_template TEXT,
+    description VARCHAR(500) DEFAULT NULL,
+    enabled INT DEFAULT 1,
+    create_time DATETIME DEFAULT NOW(),
+    update_time DATETIME DEFAULT NOW(),
+    UNIQUE KEY uk_code (company_id, api_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ----------------------------
+-- Table structure for lobster_system_prompt
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_system_prompt`;
+CREATE TABLE `lobster_system_prompt` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `prompt_key` varchar(100) NOT NULL COMMENT '唯一键',
+  `prompt_name` varchar(200) DEFAULT NULL COMMENT '提示词名称',
+  `prompt_category` varchar(50) DEFAULT NULL COMMENT '分类: rag/ai/code_collect/handoff/kb',
+  `prompt_content` text NOT NULL COMMENT '提示词内容',
+  `model_name` varchar(100) DEFAULT 'doubao-lite' COMMENT '推荐模型',
+  `system_role` varchar(500) DEFAULT NULL COMMENT 'system角色提示',
+  `enabled` int DEFAULT '1' COMMENT '1启用0禁用',
+  `sort_order` int DEFAULT '0',
+  `create_by` varchar(64) DEFAULT 'admin',
+  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `company_id` bigint DEFAULT NULL COMMENT '租户ID,NULL=系统默认',
+  `industry_type` varchar(50) DEFAULT NULL COMMENT '行业类型: travel/medical/education/insurance/general,NULL=通用',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `prompt_key` (`prompt_key`),
+  KEY `idx_category` (`prompt_category`),
+  KEY `idx_enabled` (`enabled`),
+  KEY `idx_company_industry` (`company_id`,`industry_type`)
+) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
+
+-- ----------------------------
+-- Table structure for lobster_test_scenario
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_test_scenario`;
+CREATE TABLE `lobster_test_scenario` (
+  `id`                 BIGINT       NOT NULL AUTO_INCREMENT,
+  `company_id`         BIGINT       NULL,
+  `scenario_name`      VARCHAR(128) NOT NULL COMMENT '场景名',
+  `business_desc`      VARCHAR(500) NULL     COMMENT '业务描述(留空则用 template_id)',
+  `template_id`        BIGINT       NULL     COMMENT '关联工作流模板',
+  `user_inputs_json`   TEXT         NOT NULL COMMENT '用户输入数组JSON',
+  `expected_nodes`     TEXT         NULL     COMMENT '期望命中的节点编码JSON',
+  `min_score`          DECIMAL(5,2) DEFAULT 60.00 COMMENT '最低通过分',
+  `enabled`            TINYINT(1)   DEFAULT 1 COMMENT '是否启用回归',
+  `cron`               VARCHAR(64)  NULL     COMMENT '自定义cron(空则跟随全局)',
+  `last_run_id`        VARCHAR(64)  NULL,
+  `last_run_status`    VARCHAR(20)  NULL,
+  `last_run_time`      DATETIME     NULL,
+  `create_by`          VARCHAR(64)  NULL,
+  `create_time`        DATETIME     DEFAULT CURRENT_TIMESTAMP,
+  `update_time`        DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_enabled` (`enabled`),
+  KEY `idx_company` (`company_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='龙虾测试场景剧本';
+
+-- ----------------------------
+-- Table structure for lobster_unified_contact
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_unified_contact`;
+CREATE TABLE `lobster_unified_contact` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `company_id` bigint NOT NULL,
+  `contact_id` bigint NOT NULL,
+  `contact_name` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
+  `contact_phone` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
+  `channel_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
+  `channel_user_id` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
+  `extra` json DEFAULT NULL,
+  `del_flag` tinyint DEFAULT '0',
+  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_contact_channel` (`company_id`,`contact_id`,`channel_type`),
+  KEY `idx_company` (`company_id`),
+  KEY `idx_channel_type` (`channel_type`),
+  KEY `idx_channel_user` (`channel_user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='龙虾引擎-统一联系人';
+
+-- ----------------------------
+-- Table structure for lobster_user_profile
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_user_profile`;
+CREATE TABLE `lobster_user_profile` (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    company_id BIGINT NOT NULL,
+    external_user_id VARCHAR(128) NOT NULL,
+    nickname VARCHAR(128) DEFAULT NULL,
+    persona VARCHAR(500) DEFAULT NULL,
+    current_state VARCHAR(100) DEFAULT NULL,
+    lifecycle_stage VARCHAR(30) DEFAULT 'NEW' COMMENT 'NEW/ACTIVE/DORMANT/SLEEP/CHURN',
+    value_score INT DEFAULT 10,
+    total_purchase DECIMAL(12,2) DEFAULT 0,
+    interaction_count INT DEFAULT 0,
+    last_active_time DATETIME DEFAULT NULL,
+    internal_tags VARCHAR(1000) DEFAULT NULL,
+    variable_snapshot VARCHAR(2000) DEFAULT NULL,
+    deleted INT DEFAULT 0,
+    create_time DATETIME DEFAULT NOW(),
+    update_time DATETIME DEFAULT NOW(),
+    UNIQUE KEY uk_user (company_id, external_user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ----------------------------
+-- Table structure for lobster_workflow_instance
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_workflow_instance`;
+CREATE TABLE `lobster_workflow_instance` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `company_id` bigint DEFAULT NULL,
+  `workflow_id` bigint DEFAULT NULL,
+  `instance_name` varchar(200) DEFAULT NULL,
+  `status` varchar(20) DEFAULT 'pending',
+  `contact_id` bigint DEFAULT NULL,
+  `contact_name` varchar(100) DEFAULT NULL,
+  `channel_type` varchar(20) DEFAULT 'QW',
+  `current_node_index` int DEFAULT '0',
+  `current_node_name` varchar(100) DEFAULT NULL,
+  `total_nodes` int DEFAULT '0',
+  `completed_nodes` int DEFAULT '0',
+  `context_snapshot` text,
+  `variables` text,
+  `start_time` varchar(30) DEFAULT NULL,
+  `end_time` varchar(30) DEFAULT NULL,
+  `last_activity_time` varchar(30) DEFAULT NULL,
+  `error_message` text,
+  `del_flag` int DEFAULT '0',
+  `create_by` varchar(64) DEFAULT NULL,
+  `create_time` datetime DEFAULT NULL,
+  `update_by` varchar(64) DEFAULT NULL,
+  `update_time` datetime DEFAULT NULL,
+  `handoff_agent` varchar(100) DEFAULT NULL COMMENT '转人工目标',
+  `handoff_reason` varchar(500) DEFAULT NULL COMMENT '转人工原因',
+  `control_mode` varchar(20) DEFAULT 'ai' COMMENT '控制模式: ai=龙虾接管, human=人工接管',
+  `control_updated_by` varchar(64) DEFAULT NULL COMMENT '切换操作人',
+  `control_updated_at` datetime DEFAULT NULL COMMENT '切换时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_company_id` (`company_id`),
+  KEY `idx_workflow_id` (`workflow_id`),
+  KEY `idx_contact_id` (`contact_id`),
+  KEY `idx_status` (`status`)
+) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
+
+-- ----------------------------
+-- Table structure for lobster_workflow_node_type
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_workflow_node_type`;
+CREATE TABLE `lobster_workflow_node_type` (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT,
+    node_type INT NOT NULL UNIQUE COMMENT '节点编号(与执行器常量对齐)',
+    node_name VARCHAR(64) NOT NULL COMMENT '节点显示名称',
+    code_name VARCHAR(64) NOT NULL COMMENT '节点代码标识',
+    description VARCHAR(255) COMMENT '节点描述 + skill.md 别名',
+    category VARCHAR(32) COMMENT '分类:basic/flow/business/api',
+    icon VARCHAR(64) COMMENT '图标 class',
+    color VARCHAR(16) COMMENT '主题色 hex',
+    template_json TEXT COMMENT '默认配置模板 JSON',
+    enabled TINYINT DEFAULT 1,
+    sort_order INT DEFAULT 0,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='龙虾工作流节点类型字典';
+
+-- ----------------------------
+-- Table structure for lobster_workflow_variable
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_workflow_variable`;
+CREATE TABLE `lobster_workflow_variable` (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    instance_id BIGINT NOT NULL,
+    company_id BIGINT DEFAULT NULL,
+    var_key VARCHAR(200) NOT NULL,
+    var_value TEXT,
+    create_time DATETIME DEFAULT NOW(),
+    update_time DATETIME DEFAULT NOW(),
+    UNIQUE KEY uk_inst_key (instance_id, var_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ----------------------------
+-- Table structure for outbound_line_limit_log
+-- ----------------------------
+DROP TABLE IF EXISTS `outbound_line_limit_log`;
+CREATE TABLE `outbound_line_limit_log` (
+    `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `company_id` bigint DEFAULT NULL COMMENT '公司ID',
+    `gateway_id` bigint DEFAULT NULL COMMENT '网关ID',
+    `time_window` int DEFAULT NULL COMMENT '时间粒度(分钟)',
+    `window_start` datetime DEFAULT NULL COMMENT '时间窗口起始',
+    `max_calls` int DEFAULT NULL COMMENT '窗口最大呼出次数',
+    `current_count` int DEFAULT NULL COMMENT '当前已呼叫次数',
+    `is_limited` tinyint DEFAULT 0 COMMENT '是否触发限制 0否1是',
+    `call_param` text COMMENT '触发时调用参数JSON',
+    `next_available_time` datetime DEFAULT NULL COMMENT '下一可用时间',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_company_gateway_window` (`company_id`, `gateway_id`, `time_window`, `create_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='外呼线路限流日志';
+
+-- ----------------------------
+-- Table structure for qw_customer_property
+-- ----------------------------
+DROP TABLE IF EXISTS `qw_customer_property`;
+CREATE TABLE `qw_customer_property` (
+  `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'id',
+  `external_user_id` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '客户ID',
+  `property_id` bigint DEFAULT NULL COMMENT '字段ID',
+  `property_name` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '字段名称',
+  `property_value` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '字段内容',
+  `property_value_type` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '字段类型',
+  `trade_type` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '行业类型',
+  `ai_analysis` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '内容解析',
+  `intention` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '意向登记:high-高意向,medium-中意向,low-低意向,none-无意向',
+  `like_ratio` int DEFAULT NULL COMMENT '喜欢占比:0-100',
+  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `create_by` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人',
+  `update_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
+  `update_by` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人',
+  `remark` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '备注',
+  `deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除 0否 1是',
+  `delete_by` varchar(300) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '删除人',
+  `delete_time` datetime DEFAULT NULL COMMENT '删除时间',
+  `corp_id` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '企业id',
+  `qw_user_id` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '属于用户id',
+  PRIMARY KEY (`id`) USING BTREE,
+  KEY `qw_id_index` (`external_user_id`,`corp_id`,`qw_user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC;
+
+-- ----------------------------
+-- Table structure for qw_external_ai_analyze
+-- ----------------------------
+DROP TABLE IF EXISTS `qw_external_ai_analyze`;
+CREATE TABLE `qw_external_ai_analyze` (
+  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `external_user_id` varchar(255) COLLATE utf8mb4_general_ci NOT NULL COMMENT '外部联系人id',
+  `customer_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '客户姓名',
+  `customer_portrait_json` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '客户画像',
+  `communication_abstract` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '沟通摘要',
+  `communication_summary` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '沟通总结',
+  `attrition_level` tinyint DEFAULT '0' COMMENT '流失风险等级 0:未知;1:无风险;2:低风险;3:中风险;4:高风险',
+  `attrition_level_prompt` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '流失风险等级提示',
+  `customer_focus_json` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '客户关注点',
+  `intention_degree` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '意向度',
+  `ai_chat_record` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'ai通话聊天记录',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT '备注',
+  `reserve_int` bigint DEFAULT '0' COMMENT '预留数字型字段',
+  `reserve_str` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT '预留字符串型字段',
+  `corp_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '企业id',
+  `qw_user_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '属于用户id',
+  `session_id` bigint DEFAULT NULL COMMENT 'qw_external_ai_analyze_session的iD,ai会话',
+  PRIMARY KEY (`id`) USING BTREE,
+  KEY `idx_customer_id` (`external_user_id`) USING BTREE,
+  KEY `idx_customer_time_id` (`external_user_id`,`create_time`,`id`) USING BTREE,
+  KEY `indx_user_bind` (`external_user_id`,`corp_id`,`qw_user_id`) USING BTREE COMMENT '用户关系唯一绑定',
+  FULLTEXT KEY `idx_ai_chat_record_fulltext` (`ai_chat_record`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC;
+
+
+-- ============================================================================
+-- tenant-initTable 补全:Mapper/实体引用且 patch 无 DDL 的表
+-- ============================================================================
+
+-- ----------------------------
+-- Table structure for ai_chat_quality_record
+-- ----------------------------
+DROP TABLE IF EXISTS `ai_chat_quality_record`;
+CREATE TABLE `ai_chat_quality_record` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `session_id` varchar(64) DEFAULT NULL COMMENT '会话ID',
+    `company_id` bigint DEFAULT NULL COMMENT '公司ID',
+    `company_name` varchar(200) DEFAULT NULL COMMENT '公司名称',
+    `user_id` varchar(64) DEFAULT NULL COMMENT '用户ID',
+    `user_name` varchar(200) DEFAULT NULL COMMENT '用户名',
+    `content_summary` text COMMENT '内容摘要',
+    `quality_result` varchar(50) DEFAULT NULL COMMENT '质检结果',
+    `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+    `quality_user_id` bigint DEFAULT NULL COMMENT '质检人ID',
+    `quality_user_name` varchar(200) DEFAULT NULL COMMENT '质检人',
+    `quality_time` datetime DEFAULT NULL COMMENT '质检时间',
+    `risk_level` varchar(20) DEFAULT NULL COMMENT '风险等级',
+    `sensitive_words` varchar(500) DEFAULT NULL COMMENT '敏感词',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_company_id` (`company_id`),
+    KEY `idx_session_id` (`session_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI聊天质检记录';
+
+-- ----------------------------
+-- Table structure for cc_ext_num
+-- ----------------------------
+DROP TABLE IF EXISTS `cc_ext_num`;
+CREATE TABLE `cc_ext_num` (
+    `ext_id` bigint NOT NULL AUTO_INCREMENT,
+    `ext_num` varchar(50) NOT NULL COMMENT '分机号',
+    `ext_pass` varchar(100) DEFAULT NULL COMMENT '分机密码',
+    `user_code` varchar(100) DEFAULT NULL COMMENT '用户编码',
+    PRIMARY KEY (`ext_id`),
+    UNIQUE KEY `uk_ext_num` (`ext_num`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI外呼分机号';
+
+-- ----------------------------
+-- Table structure for company_knowledge_base
+-- ----------------------------
+DROP TABLE IF EXISTS `company_knowledge_base`;
+CREATE TABLE `company_knowledge_base` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `company_id` bigint NOT NULL COMMENT '公司ID',
+    `base_id` bigint DEFAULT NULL COMMENT '知识库ID',
+    `title` varchar(200) DEFAULT NULL COMMENT '标题',
+    `question` text COMMENT '问题',
+    `answer` text COMMENT '答案',
+    `industry_type` varchar(50) DEFAULT NULL COMMENT '行业类型',
+    `source` varchar(50) DEFAULT NULL COMMENT '来源',
+    `audit_status` tinyint DEFAULT 0 COMMENT '审核状态',
+    `audit_comment` varchar(500) DEFAULT NULL COMMENT '审核意见',
+    `auditor` varchar(64) DEFAULT NULL COMMENT '审核人',
+    `audit_time` datetime DEFAULT NULL COMMENT '审核时间',
+    `use_count` int DEFAULT 0 COMMENT '使用次数',
+    `fastgpt_id` varchar(64) DEFAULT NULL COMMENT 'FastGPT ID',
+    `sync_status` tinyint DEFAULT 0 COMMENT '同步状态',
+    `sync_time` datetime DEFAULT NULL COMMENT '同步时间',
+    `del_flag` tinyint DEFAULT 0 COMMENT '删除标记',
+    `create_by` varchar(64) DEFAULT NULL,
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    `update_by` varchar(64) DEFAULT NULL,
+    `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_company_id` (`company_id`),
+    KEY `idx_base_id` (`base_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='公司知识库';
+
+-- ----------------------------
+-- Table structure for company_knowledge_audit
+-- ----------------------------
+DROP TABLE IF EXISTS `company_knowledge_audit`;
+CREATE TABLE `company_knowledge_audit` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `company_id` bigint NOT NULL COMMENT '公司ID',
+    `knowledge_id` bigint DEFAULT NULL COMMENT '知识ID',
+    `source_type` varchar(50) DEFAULT NULL COMMENT '来源类型',
+    `source_id` bigint DEFAULT NULL COMMENT '来源ID',
+    `content` text COMMENT '内容',
+    `suggestion` text COMMENT '建议',
+    `audit_result` varchar(50) DEFAULT NULL COMMENT '审核结果',
+    `audit_comment` varchar(500) DEFAULT NULL COMMENT '审核意见',
+    `auditor` varchar(64) DEFAULT NULL COMMENT '审核人',
+    `audit_time` datetime DEFAULT NULL COMMENT '审核时间',
+    `del_flag` tinyint DEFAULT 0 COMMENT '删除标记',
+    `create_by` varchar(64) DEFAULT NULL,
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    `update_by` varchar(64) DEFAULT NULL,
+    `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_company_id` (`company_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='公司知识审核';
+
+-- ----------------------------
+-- Table structure for company_knowledge_suggestion
+-- ----------------------------
+DROP TABLE IF EXISTS `company_knowledge_suggestion`;
+CREATE TABLE `company_knowledge_suggestion` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `company_id` bigint NOT NULL COMMENT '公司ID',
+    `type` varchar(50) DEFAULT NULL COMMENT '建议类型',
+    `description` text COMMENT '描述',
+    `suggestion` text COMMENT '建议内容',
+    `impact` varchar(200) DEFAULT NULL COMMENT '影响',
+    `status` varchar(30) DEFAULT 'pending' COMMENT '状态',
+    `apply_time` datetime DEFAULT NULL COMMENT '应用时间',
+    `del_flag` tinyint DEFAULT 0 COMMENT '删除标记',
+    `create_by` varchar(64) DEFAULT NULL,
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    `update_by` varchar(64) DEFAULT NULL,
+    `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_company_id` (`company_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='公司知识建议';
+
+-- ----------------------------
+-- Table structure for company_lobster_dedup_config
+-- ----------------------------
+DROP TABLE IF EXISTS `company_lobster_dedup_config`;
+CREATE TABLE `company_lobster_dedup_config` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `company_id` bigint NOT NULL COMMENT '公司ID',
+    `config_name` varchar(100) DEFAULT NULL COMMENT '配置名称',
+    `dedup_mode` varchar(30) DEFAULT NULL COMMENT '去重模式',
+    `exact_window_size` int DEFAULT NULL COMMENT '精确窗口大小',
+    `semantic_threshold` double DEFAULT NULL COMMENT '语义阈值',
+    `window_duration_seconds` int DEFAULT NULL COMMENT '窗口时长(秒)',
+    `ignore_prefix_count` int DEFAULT NULL COMMENT '忽略前缀数',
+    `enabled` tinyint DEFAULT 1 COMMENT '是否启用',
+    `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_company_id` (`company_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='龙虾消息去重配置';
+
+-- ----------------------------
+-- Table structure for company_sms_api
+-- ----------------------------
+DROP TABLE IF EXISTS `company_sms_api`;
+CREATE TABLE `company_sms_api` (
+    `api_id` bigint NOT NULL AUTO_INCREMENT COMMENT '接口ID',
+    `api_name` varchar(100) DEFAULT NULL COMMENT '接口名称',
+    `provider` varchar(50) DEFAULT NULL COMMENT '服务商',
+    `sms_type` tinyint DEFAULT NULL COMMENT '短信类型',
+    `account` varchar(100) DEFAULT NULL COMMENT '账户名',
+    `password` varchar(200) DEFAULT NULL COMMENT '密码',
+    `url` varchar(500) DEFAULT NULL COMMENT '接口地址',
+    `callback_url` varchar(500) DEFAULT NULL COMMENT '回调地址',
+    `code` varchar(50) DEFAULT NULL COMMENT '扩展码',
+    `sign` varchar(50) DEFAULT NULL COMMENT '签名',
+    `cost_price` decimal(10,4) DEFAULT NULL COMMENT '成本价',
+    `is_default` tinyint DEFAULT 0 COMMENT '是否默认',
+    `status` tinyint DEFAULT 1 COMMENT '状态',
+    `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    `update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`api_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='短信接口配置';
+
+-- ----------------------------
+-- Table structure for company_sms_api_port
+-- ----------------------------
+DROP TABLE IF EXISTS `company_sms_api_port`;
+CREATE TABLE `company_sms_api_port` (
+    `port_id` bigint NOT NULL AUTO_INCREMENT COMMENT '端口ID',
+    `api_id` bigint NOT NULL COMMENT '接口ID',
+    `port_name` varchar(100) DEFAULT NULL COMMENT '端口名称',
+    `port_no` varchar(50) DEFAULT NULL COMMENT '端口号',
+    `account` varchar(100) DEFAULT NULL COMMENT '账户',
+    `password` varchar(200) DEFAULT NULL COMMENT '密码',
+    `sign` varchar(50) DEFAULT NULL COMMENT '签名',
+    `slot_index` tinyint DEFAULT 1 COMMENT '卡槽',
+    `status` tinyint DEFAULT 1 COMMENT '状态',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    `update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`port_id`),
+    KEY `idx_api_id` (`api_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='短信接口端口';
+
+-- ----------------------------
+-- Table structure for company_sms_card
+-- ----------------------------
+DROP TABLE IF EXISTS `company_sms_card`;
+CREATE TABLE `company_sms_card` (
+    `card_id` bigint NOT NULL AUTO_INCREMENT COMMENT '卡ID',
+    `port_id` bigint DEFAULT NULL COMMENT '端口ID',
+    `tenant_id` bigint NOT NULL COMMENT '租户ID',
+    `device_id` bigint DEFAULT NULL COMMENT '设备ID',
+    `slot_index` tinyint DEFAULT 1 COMMENT '卡槽(1或2)',
+    `imei` varchar(50) DEFAULT NULL COMMENT 'IMEI',
+    `device_name` varchar(100) DEFAULT NULL COMMENT '设备名称',
+    `sim_count` tinyint DEFAULT 1 COMMENT '卡槽数',
+    `phone_1` varchar(20) DEFAULT NULL COMMENT '卡槽1手机号',
+    `phone_2` varchar(20) DEFAULT NULL COMMENT '卡槽2手机号',
+    `last_heartbeat` datetime DEFAULT NULL COMMENT '最后心跳',
+    `status` tinyint DEFAULT 0 COMMENT '0离线/1在线/2禁用',
+    `app_version` varchar(30) DEFAULT NULL COMMENT 'APP版本',
+    `sms_sent_today` int DEFAULT 0 COMMENT '今日已发短信',
+    `sms_sent_date` date DEFAULT NULL COMMENT '短信计数日期',
+    `sms_sent_hour` int DEFAULT 0 COMMENT '当前小时已发',
+    `sms_sent_hour_num` tinyint DEFAULT NULL COMMENT '当前小时(0-23)',
+    `sms_hourly_limit` int DEFAULT NULL COMMENT '每小时限制',
+    `sms_daily_limit` int DEFAULT NULL COMMENT '每日限制',
+    `sms_balance` int DEFAULT NULL COMMENT '短信余额',
+    `call_sent_today` int DEFAULT 0 COMMENT '今日已拨号',
+    `call_sent_date` date DEFAULT NULL COMMENT '拨号计数日期',
+    `call_interval_seconds` int DEFAULT NULL COMMENT '拨打间隔(秒)',
+    `call_minutes_balance` decimal(10,2) DEFAULT NULL COMMENT '通话分钟余额',
+    `phone_bill_balance` decimal(10,2) DEFAULT NULL COMMENT '话费余额',
+    `allow_call_forward` tinyint DEFAULT 0 COMMENT '允许呼转',
+    `forward_phone` varchar(20) DEFAULT NULL COMMENT '呼转号码',
+    `last_call_time` datetime DEFAULT NULL COMMENT '最后拨号时间',
+    `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    `update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`card_id`),
+    KEY `idx_tenant_id` (`tenant_id`),
+    KEY `idx_device_id` (`device_id`),
+    KEY `idx_imei` (`imei`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='手机卡管理';
+
+-- ----------------------------
+-- Table structure for company_sms_card_middleware
+-- ----------------------------
+DROP TABLE IF EXISTS `company_sms_card_middleware`;
+CREATE TABLE `company_sms_card_middleware` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `api_id` bigint DEFAULT NULL COMMENT '接口ID',
+    `tenant_id` bigint DEFAULT NULL COMMENT '租户ID',
+    `middleware_name` varchar(100) DEFAULT NULL COMMENT '中间件名称',
+    `callback_url` varchar(500) DEFAULT NULL COMMENT '回调地址',
+    `heartbeat_url` varchar(500) DEFAULT NULL COMMENT '心跳地址',
+    `auth_token` varchar(200) DEFAULT NULL COMMENT '认证Token',
+    `max_retry` int DEFAULT 3 COMMENT '最大重试',
+    `timeout_seconds` int DEFAULT 30 COMMENT '超时秒数',
+    `status` tinyint DEFAULT 1 COMMENT '状态',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    `update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_api_id` (`api_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='手机卡中间件';
+
+-- ----------------------------
+-- Table structure for company_sms_device
+-- ----------------------------
+DROP TABLE IF EXISTS `company_sms_device`;
+CREATE TABLE `company_sms_device` (
+    `device_id` bigint NOT NULL AUTO_INCREMENT COMMENT '设备ID',
+    `tenant_id` bigint NOT NULL COMMENT '所属租户',
+    `company_user_id` bigint DEFAULT NULL COMMENT '绑定销售用户ID',
+    `device_name` varchar(100) DEFAULT NULL COMMENT '设备名称',
+    `imei` varchar(50) NOT NULL COMMENT 'IMEI',
+    `app_version` varchar(30) DEFAULT NULL COMMENT 'APP版本',
+    `middleware_id` bigint DEFAULT NULL COMMENT '关联中间件ID',
+    `last_heartbeat` datetime DEFAULT NULL COMMENT '最后心跳时间',
+    `status` tinyint DEFAULT 0 COMMENT '0离线/1在线/2禁用',
+    `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`device_id`),
+    UNIQUE KEY `uk_imei` (`imei`),
+    KEY `idx_tenant_id` (`tenant_id`),
+    KEY `idx_company_user_id` (`company_user_id`),
+    KEY `idx_middleware_id` (`middleware_id`),
+    KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='手机设备管理';
+
+-- ----------------------------
+-- Table structure for company_sms_port_assign
+-- ----------------------------
+DROP TABLE IF EXISTS `company_sms_port_assign`;
+CREATE TABLE `company_sms_port_assign` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `port_id` bigint NOT NULL COMMENT '端口ID',
+    `tenant_id` bigint NOT NULL COMMENT '租户ID',
+    `company_user_id` bigint DEFAULT NULL COMMENT '销售用户ID',
+    `status` tinyint DEFAULT 1 COMMENT '状态',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_port_id` (`port_id`),
+    KEY `idx_tenant_id` (`tenant_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='短信端口分配';
+
+-- ----------------------------
+-- Table structure for im_send_log
+-- ----------------------------
+DROP TABLE IF EXISTS `im_send_log`;
+CREATE TABLE `im_send_log` (
+    `log_id` bigint NOT NULL AUTO_INCREMENT COMMENT '日志ID',
+    `send_id` varchar(64) DEFAULT NULL COMMENT '发送方ID',
+    `recv_id` varchar(64) DEFAULT NULL COMMENT '接收方ID',
+    `send_title` varchar(200) DEFAULT NULL COMMENT '标题',
+    `plan_send_time` datetime DEFAULT NULL COMMENT '计划发送时间',
+    `actual_send_time` datetime DEFAULT NULL COMMENT '实际发送时间',
+    `send_type` tinyint DEFAULT NULL COMMENT '发送类型',
+    `param_json` text COMMENT '参数JSON',
+    `status` tinyint DEFAULT NULL COMMENT '状态',
+    `result_message` varchar(500) DEFAULT NULL COMMENT '结果信息',
+    `exception_info` text COMMENT '异常信息',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    PRIMARY KEY (`log_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='IM发送日志';
+
+-- ----------------------------
+-- Table structure for lobster_e2e_test
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_e2e_test`;
+CREATE TABLE `lobster_e2e_test` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `company_id` bigint DEFAULT NULL COMMENT '公司ID',
+    `test_name` varchar(128) DEFAULT NULL COMMENT '测试名称',
+    `workflow_id` bigint DEFAULT NULL COMMENT '工作流ID',
+    `test_data` text COMMENT '测试数据JSON',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_company_id` (`company_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='龙虾E2E测试(旧)';
+
+-- ----------------------------
+-- Table structure for lobster_e2e_test_result
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_e2e_test_result`;
+CREATE TABLE `lobster_e2e_test_result` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `test_id` bigint DEFAULT NULL COMMENT '测试ID',
+    `run_id` varchar(64) DEFAULT NULL COMMENT '运行ID',
+    `company_id` bigint DEFAULT NULL COMMENT '公司ID',
+    `passed` tinyint DEFAULT NULL COMMENT '是否通过',
+    `detail` text COMMENT '详情',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_test_id` (`test_id`),
+    KEY `idx_run_id` (`run_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='龙虾E2E测试结果(旧)';
+
+-- ----------------------------
+-- Table structure for lobster_feedback_records
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_feedback_records`;
+CREATE TABLE `lobster_feedback_records` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `company_id` bigint NOT NULL COMMENT '公司ID',
+    `instance_id` bigint DEFAULT NULL COMMENT '实例ID',
+    `node_code` varchar(100) DEFAULT NULL COMMENT '节点编码',
+    `feedback_type` varchar(50) DEFAULT NULL COMMENT '反馈类型',
+    `comment` text COMMENT '反馈内容',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_company_node` (`company_id`, `node_code`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='龙虾反馈记录';
+
+-- ----------------------------
+-- Table structure for lobster_heartbeat
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_heartbeat`;
+CREATE TABLE `lobster_heartbeat` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `company_id` bigint NOT NULL COMMENT '公司ID',
+    `task_key` varchar(100) NOT NULL COMMENT '任务Key',
+    `status` varchar(30) DEFAULT NULL COMMENT '状态',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_company_task` (`company_id`, `task_key`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='龙虾任务心跳';
+
+-- ----------------------------
+-- Table structure for lobster_knowledge_version
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_knowledge_version`;
+CREATE TABLE `lobster_knowledge_version` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `company_id` bigint NOT NULL COMMENT '公司ID',
+    `knowledge_id` bigint NOT NULL COMMENT '知识ID',
+    `version` int NOT NULL COMMENT '版本号',
+    `title` varchar(200) DEFAULT NULL COMMENT '标题',
+    `content` text COMMENT '内容',
+    `change_log` varchar(500) DEFAULT NULL COMMENT '变更说明',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_company_knowledge` (`company_id`, `knowledge_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='龙虾知识版本';
+
+-- ----------------------------
+-- Table structure for lobster_learned_pattern
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_learned_pattern`;
+CREATE TABLE `lobster_learned_pattern` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `company_id` bigint NOT NULL COMMENT '公司ID',
+    `pattern_type` varchar(50) DEFAULT NULL COMMENT '模式类型',
+    `pattern_key` varchar(200) NOT NULL COMMENT '模式Key',
+    `pattern_value` text COMMENT '模式值',
+    `confidence` double DEFAULT 0.5 COMMENT '置信度',
+    `source` varchar(100) DEFAULT NULL COMMENT '来源',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_company_pattern` (`company_id`, `pattern_type`, `pattern_key`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='龙虾学习模式';
+
+-- ----------------------------
+-- Table structure for lobster_learning_event_log
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_learning_event_log`;
+CREATE TABLE `lobster_learning_event_log` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `company_id` bigint NOT NULL COMMENT '公司ID',
+    `instance_id` bigint DEFAULT NULL COMMENT '实例ID',
+    `node_code` varchar(100) DEFAULT NULL COMMENT '节点编码',
+    `event_type` varchar(50) DEFAULT NULL COMMENT '事件类型',
+    `quality_score` double DEFAULT NULL COMMENT '质量分',
+    `context_snapshot` text COMMENT '上下文快照',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_company_event` (`company_id`, `event_type`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='龙虾学习事件日志';
+
+-- ----------------------------
+-- Table structure for lobster_learning_replay_buffer
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_learning_replay_buffer`;
+CREATE TABLE `lobster_learning_replay_buffer` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `company_id` bigint NOT NULL COMMENT '公司ID',
+    `instance_id` bigint DEFAULT NULL COMMENT '实例ID',
+    `node_code` varchar(100) DEFAULT NULL COMMENT '节点编码',
+    `customer_message` text COMMENT '客户消息',
+    `ai_reply` text COMMENT 'AI回复',
+    `quality_score` double DEFAULT NULL COMMENT '质量分',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_company_id` (`company_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='龙虾学习回放缓冲';
+
+-- ----------------------------
+-- Table structure for lobster_message_variants
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_message_variants`;
+CREATE TABLE `lobster_message_variants` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `company_id` bigint NOT NULL COMMENT '公司ID',
+    `node_code` varchar(100) DEFAULT NULL COMMENT '节点编码',
+    `content` text COMMENT '变体内容',
+    `generation_reason` varchar(500) DEFAULT NULL COMMENT '生成原因',
+    `status` varchar(30) DEFAULT 'active' COMMENT '状态',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_company_node` (`company_id`, `node_code`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='龙虾消息变体';
+
+-- ----------------------------
+-- Table structure for lobster_node_interaction
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_node_interaction`;
+CREATE TABLE `lobster_node_interaction` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `company_id` bigint NOT NULL COMMENT '公司ID',
+    `instance_id` bigint DEFAULT NULL COMMENT '实例ID',
+    `node_code` varchar(100) DEFAULT NULL COMMENT '节点编码',
+    `external_user_id` varchar(128) DEFAULT NULL COMMENT '外部用户ID',
+    `interaction_type` varchar(50) DEFAULT NULL COMMENT '交互类型',
+    `content` text COMMENT '内容',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_company_user_node` (`company_id`, `external_user_id`, `node_code`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='龙虾节点交互';
+
+-- ----------------------------
+-- Table structure for lobster_optimization_config
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_optimization_config`;
+CREATE TABLE `lobster_optimization_config` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `company_id` bigint NOT NULL COMMENT '公司ID',
+    `workflow_id` bigint NOT NULL COMMENT '工作流ID',
+    `node_code` varchar(100) DEFAULT NULL COMMENT '节点编码',
+    `enabled` tinyint DEFAULT 1 COMMENT '是否启用',
+    `auto_apply` tinyint DEFAULT 0 COMMENT '自动应用',
+    `config_json` text COMMENT '配置JSON',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_company_workflow_node` (`company_id`, `workflow_id`, `node_code`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='龙虾优化配置';
+
+-- ----------------------------
+-- Table structure for lobster_prompt_config
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_prompt_config`;
+CREATE TABLE `lobster_prompt_config` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `company_id` bigint NOT NULL COMMENT '公司ID',
+    `workflow_code` varchar(100) DEFAULT NULL COMMENT '工作流编码',
+    `node_code` varchar(100) DEFAULT NULL COMMENT '节点编码',
+    `prompt_type` varchar(50) DEFAULT NULL COMMENT 'Prompt类型',
+    `content` text COMMENT '内容',
+    `scope` varchar(50) DEFAULT NULL COMMENT '作用域',
+    `deleted` tinyint DEFAULT 0 COMMENT '删除标记',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_company_workflow_node` (`company_id`, `workflow_code`, `node_code`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='龙虾Prompt配置';
+
+-- ----------------------------
+-- Table structure for lobster_sms_log
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_sms_log`;
+CREATE TABLE `lobster_sms_log` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `company_id` bigint NOT NULL COMMENT '公司ID',
+    `phone` varchar(20) DEFAULT NULL COMMENT '手机号',
+    `content` text COMMENT '短信内容',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_company_id` (`company_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='龙虾短信日志';
+
+-- ----------------------------
+-- Table structure for lobster_test_scenario_result
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_test_scenario_result`;
+CREATE TABLE `lobster_test_scenario_result` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `company_id` bigint DEFAULT NULL COMMENT '公司ID',
+    `scenario_id` bigint DEFAULT NULL COMMENT '场景ID',
+    `passed` tinyint DEFAULT NULL COMMENT '是否通过',
+    `detail` text COMMENT '详情',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_scenario_id` (`scenario_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='龙虾测试场景结果';
+
+-- ----------------------------
+-- Table structure for lobster_token_consumption
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_token_consumption`;
+CREATE TABLE `lobster_token_consumption` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `company_id` bigint NOT NULL COMMENT '公司ID',
+    `instance_id` bigint DEFAULT NULL COMMENT '实例ID',
+    `node_code` varchar(100) DEFAULT NULL COMMENT '节点编码',
+    `model_identifier` varchar(200) DEFAULT NULL COMMENT '模型标识',
+    `consume_type` varchar(50) DEFAULT NULL COMMENT '消耗类型',
+    `token_count` bigint DEFAULT 0 COMMENT 'Token数',
+    `estimated_cost` decimal(18,6) DEFAULT NULL COMMENT '预估成本',
+    `request_time` datetime DEFAULT NULL COMMENT '请求时间',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_company_time` (`company_id`, `request_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='龙虾Token消耗';
+
+-- ----------------------------
+-- Table structure for lobster_tool_exec_log
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_tool_exec_log`;
+CREATE TABLE `lobster_tool_exec_log` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `company_id` bigint NOT NULL COMMENT '公司ID',
+    `tool_name` varchar(100) DEFAULT NULL COMMENT '工具名',
+    `params_json` text COMMENT '参数JSON',
+    `result_json` text COMMENT '结果JSON',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_company_id` (`company_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='龙虾工具执行日志';
+
+-- ----------------------------
+-- Table structure for lobster_user_preference
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_user_preference`;
+CREATE TABLE `lobster_user_preference` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `company_id` bigint NOT NULL COMMENT '公司ID',
+    `external_user_id` varchar(128) NOT NULL COMMENT '外部用户ID',
+    `snapshot_json` text COMMENT '偏好快照JSON',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_company_user` (`company_id`, `external_user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='龙虾用户偏好';
+
+-- ----------------------------
+-- Table structure for lobster_user_segment
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_user_segment`;
+CREATE TABLE `lobster_user_segment` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `company_id` bigint NOT NULL COMMENT '公司ID',
+    `segment_code` varchar(64) NOT NULL COMMENT '分群编码',
+    `segment_name` varchar(128) DEFAULT NULL COMMENT '分群名称',
+    `description` varchar(500) DEFAULT NULL COMMENT '描述',
+    `strategy_config` text COMMENT '策略配置JSON',
+    `deleted` tinyint DEFAULT 0 COMMENT '删除标记',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_company_segment` (`company_id`, `segment_code`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='龙虾用户分群';
+
+-- ----------------------------
+-- Table structure for lobster_vector_embeddings
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_vector_embeddings`;
+CREATE TABLE `lobster_vector_embeddings` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `company_id` bigint NOT NULL COMMENT '公司ID',
+    `doc_id` varchar(128) DEFAULT NULL COMMENT '文档ID',
+    `doc_type` varchar(50) DEFAULT NULL COMMENT '文档类型',
+    `content` text COMMENT '内容',
+    `embedding` blob COMMENT '向量',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_company_doc` (`company_id`, `doc_type`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='龙虾向量嵌入';
+
+-- ----------------------------
+-- Table structure for lobster_workflow_patch
+-- ----------------------------
+DROP TABLE IF EXISTS `lobster_workflow_patch`;
+CREATE TABLE `lobster_workflow_patch` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `company_id` bigint NOT NULL COMMENT '公司ID',
+    `target_table` varchar(100) DEFAULT NULL COMMENT '目标表',
+    `target_id` bigint DEFAULT NULL COMMENT '目标ID',
+    `field_name` varchar(100) DEFAULT NULL COMMENT '字段名',
+    `old_value` text COMMENT '旧值',
+    `new_value` text COMMENT '新值',
+    `reason` varchar(500) DEFAULT NULL COMMENT '原因',
+    `status` varchar(30) DEFAULT 'pending' COMMENT '状态',
+    `apply_time` datetime DEFAULT NULL COMMENT '应用时间',
+    `reject_time` datetime DEFAULT NULL COMMENT '拒绝时间',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_company_status` (`company_id`, `status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='龙虾工作流补丁';
+
+-- ----------------------------
+-- Table structure for user_daily_stats
+-- ----------------------------
+DROP TABLE IF EXISTS `user_daily_stats`;
+CREATE TABLE `user_daily_stats` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `company_id` bigint DEFAULT NULL COMMENT '公司ID',
+    `company_name` varchar(200) DEFAULT NULL COMMENT '公司名称',
+    `dept_id` bigint DEFAULT NULL COMMENT '部门ID',
+    `dept_name` varchar(200) DEFAULT NULL COMMENT '部门名称',
+    `user_id` bigint DEFAULT NULL COMMENT '用户ID',
+    `user_name` varchar(200) DEFAULT NULL COMMENT '用户名',
+    `nick_name` varchar(200) DEFAULT NULL COMMENT '昵称',
+    `statistics_time` varchar(20) DEFAULT NULL COMMENT '统计日期',
+    `line_num` int DEFAULT 0 COMMENT '进线数',
+    `active_num` int DEFAULT 0 COMMENT '活跃数',
+    `complete_num` int DEFAULT 0 COMMENT '完课数',
+    `answer_num` int DEFAULT 0 COMMENT '答题数',
+    `red_packet_num` int DEFAULT 0 COMMENT '红包数',
+    `red_packet_amount` decimal(10,2) DEFAULT NULL COMMENT '红包金额',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_user_date` (`user_id`, `statistics_time`),
+    KEY `idx_dept_date` (`dept_id`, `statistics_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户日统计';
+
+-- ----------------------------
+-- Table structure for watch_alarm_data
+-- ----------------------------
+DROP TABLE IF EXISTS `watch_alarm_data`;
+CREATE TABLE `watch_alarm_data` (
+    `id` char(36) NOT NULL COMMENT '主键UUID',
+    `device_id` varchar(64) DEFAULT NULL COMMENT '设备编号',
+    `title` varchar(200) DEFAULT NULL COMMENT '预警标题',
+    `description` text COMMENT '预警详情',
+    `date_time` varchar(50) DEFAULT NULL COMMENT '预警时间',
+    `type` varchar(50) DEFAULT NULL COMMENT '预警类型',
+    `extra` varchar(500) DEFAULT NULL COMMENT '预警事件',
+    `location` varchar(200) DEFAULT NULL COMMENT '经纬度',
+    `status` tinyint DEFAULT 0 COMMENT 'web已读 0未读1已读',
+    `app_status` tinyint DEFAULT 0 COMMENT 'app已读 0未读1已读',
+    `is_del` tinyint DEFAULT 0 COMMENT '是否删除',
+    `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
+    `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    KEY `idx_device_id` (`device_id`),
+    KEY `idx_app_status` (`app_status`, `is_del`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='设备预警数据';
+
+-- ----------------------------
+-- Table structure for wx_sop_logs
+-- ----------------------------
+DROP TABLE IF EXISTS `wx_sop_logs`;
+CREATE TABLE `wx_sop_logs` (
+    `id` bigint NOT NULL AUTO_INCREMENT,
+    `type` tinyint DEFAULT NULL COMMENT '类型',
+    `sop_id` bigint DEFAULT NULL COMMENT 'SOP ID',
+    `sop_user_id` bigint DEFAULT NULL COMMENT 'SOP用户ID',
+    `send_type` tinyint DEFAULT NULL COMMENT '发送类型',
+    `generate_type` tinyint DEFAULT NULL COMMENT '生成类型',
+    `account_id` bigint DEFAULT NULL COMMENT '账号ID',
+    `wx_contact_id` bigint DEFAULT NULL COMMENT '微信联系人ID',
+    `wx_contact_name` varchar(200) DEFAULT NULL COMMENT '联系人名称',
+    `wx_room_id` bigint DEFAULT NULL COMMENT '群ID',
+    `wx_room_name` varchar(200) DEFAULT NULL COMMENT '群名称',
+    `fs_user_id` bigint DEFAULT NULL COMMENT '销售ID',
+    `send_status` tinyint DEFAULT NULL COMMENT '发送状态',
+    `send_remark` varchar(500) DEFAULT NULL COMMENT '发送备注',
+    `send_sort` int DEFAULT NULL COMMENT '发送排序',
+    `content_json` text COMMENT '内容JSON',
+    `send_time` datetime DEFAULT NULL COMMENT '发送时间',
+    `expiration_time` datetime DEFAULT NULL COMMENT '过期时间',
+    `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+    `create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
+    `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+    `update_by` varchar(64) DEFAULT NULL COMMENT '更新人',
+    `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+    PRIMARY KEY (`id`),
+    KEY `idx_sop_id` (`sop_id`),
+    KEY `idx_sop_user_id` (`sop_user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='微信SOP发送日志';
+
+-- ----------------------------
+-- Table structure for tenant_extension_bind
+-- ----------------------------
+DROP TABLE IF EXISTS `tenant_extension_bind`;
+CREATE TABLE `tenant_extension_bind`
+(
+    `id`             bigint NOT NULL AUTO_INCREMENT COMMENT '主键id',
+    `tenant_id`      bigint NOT NULL COMMENT '租户id',
+    `extension_num`  varchar(50) NULL DEFAULT NULL COMMENT '分机号码',
+    `extension_pass` varchar(50) NULL DEFAULT NULL COMMENT '分机密码',
+    `ext_id`         int NULL DEFAULT NULL COMMENT 'easycall使用字段-流水编号',
+    `user_code`      varchar(32) NULL DEFAULT NULL COMMENT 'easycall使用字段-所属员工/绑定关系',
+    `status`         tinyint NULL DEFAULT NULL COMMENT '状态:0、停用,1、可用',
+    `is_del`         tinyint NULL DEFAULT NULL COMMENT '是否删除:0否,1是',
+    `remark`         varchar(255) NULL DEFAULT NULL COMMENT '备注',
+    `create_time`    datetime NULL DEFAULT NULL,
+    `create_by`      varchar(255) NULL DEFAULT NULL,
+    `update_time`    datetime NULL DEFAULT NULL,
+    PRIMARY KEY (`id`) USING BTREE,
+    INDEX            `company_company_user_idx`(`tenant_id`) USING BTREE,
+    INDEX            `company_extension_idx`(`tenant_id`, `extension_num`) USING BTREE
+) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '租户分机号码管理' ROW_FORMAT = DYNAMIC;

+ 18 - 0
fs-service/src/main/resources/mapper/company/CcExtNumMapper.xml

@@ -15,6 +15,24 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         select ext_id, ext_num, ext_pass, user_code from cc_ext_num order by ext_id desc limit 1
     </select>
 
+    <!-- SaaS 专用 6~7 位号池最大值(100000~9999999),与其他系统长号段/手机号隔离 -->
+    <select id="selectMaxSaasPoolExtNum" resultType="java.lang.Long">
+        select MAX(CAST(ext_num AS UNSIGNED))
+        from cc_ext_num
+        where ext_num REGEXP '^[0-9]{6,7}$'
+          and CAST(ext_num AS UNSIGNED) &gt;= 100000
+          and CAST(ext_num AS UNSIGNED) &lt;= 9999999
+    </select>
+
+    <!-- 分层号段内最大值(每层独立 MAX+1,避免跨层孤立号拉高起点) -->
+    <select id="selectMaxSaasPoolExtNumInRange" resultType="java.lang.Long">
+        select MAX(CAST(ext_num AS UNSIGNED))
+        from cc_ext_num
+        where ext_num REGEXP '^[0-9]{6,7}$'
+          and CAST(ext_num AS UNSIGNED) &gt;= #{rangeMin}
+          and CAST(ext_num AS UNSIGNED) &lt;= #{rangeMax}
+    </select>
+
     <select id="selectExtNumByExtNums" resultMap="CcExtNumResult">
         select ext_id, ext_num, ext_pass, user_code from cc_ext_num
         where ext_num in

+ 161 - 4
fs-service/src/main/resources/mapper/company/CompanyExtensionBindMapper.xml

@@ -12,12 +12,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="extensionPass"    column="extension_pass"    />
         <result property="extId"    column="ext_id"    />
         <result property="userCode"    column="user_code"    />
+        <result property="status"      column="status"       />
+        <result property="isDel"       column="is_del"       />
         <result property="createTime"    column="create_time"    />
         <result property="createBy"    column="create_by"    />
+        <result property="updateTime"    column="update_time"    />
     </resultMap>
 
     <sql id="selectCompanyExtensionBindVo">
-        select id, company_id, company_user_id, extension_num, extension_pass, ext_id, user_code, create_time, create_by from company_extension_bind
+        select id, company_id, company_user_id, extension_num, extension_pass, ext_id, user_code, status, is_del, create_time, create_by, update_time from company_extension_bind
     </sql>
 
     <select id="selectCompanyExtensionBindList" parameterType="CompanyExtensionBind" resultMap="CompanyExtensionBindResult">
@@ -29,6 +32,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="extensionPass != null  and extensionPass != ''"> and extension_pass = #{extensionPass}</if>
             <if test="extId != null "> and ext_id = #{extId}</if>
             <if test="userCode != null  and userCode != ''"> and user_code = #{userCode}</if>
+            and (is_del = 0 or is_del is null)
         </where>
     </select>
     
@@ -46,6 +50,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="extensionPass != null">extension_pass,</if>
             <if test="extId != null">ext_id,</if>
             <if test="userCode != null">user_code,</if>
+            <if test="status != null">status,</if>
+            <if test="isDel != null">is_del,</if>
             <if test="createTime != null">create_time,</if>
             <if test="createBy != null">create_by,</if>
          </trim>
@@ -56,6 +62,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="extensionPass != null">#{extensionPass},</if>
             <if test="extId != null">#{extId},</if>
             <if test="userCode != null">#{userCode},</if>
+            <if test="status != null">#{status},</if>
+            <if test="isDel != null">#{isDel},</if>
             <if test="createTime != null">#{createTime},</if>
             <if test="createBy != null">#{createBy},</if>
          </trim>
@@ -88,21 +96,23 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </delete>
 
     <insert id="batchInsertCompanyExtensionBind" parameterType="java.util.List">
-        insert into company_extension_bind (company_id, company_user_id, extension_num, extension_pass, ext_id, user_code, create_time)
+        insert into company_extension_bind (company_id, company_user_id, extension_num, extension_pass, ext_id, user_code, status, is_del, create_time, create_by)
         values
         <foreach item="item" collection="list" separator=",">
-            (#{item.companyId}, #{item.companyUserId}, #{item.extensionNum}, #{item.extensionPass}, #{item.extId}, #{item.userCode}, #{item.createTime})
+            (#{item.companyId}, #{item.companyUserId}, #{item.extensionNum}, #{item.extensionPass}, #{item.extId}, #{item.userCode}, #{item.status}, #{item.isDel}, #{item.createTime}, #{item.createBy})
         </foreach>
     </insert>
 
     <select id="selectUnBindByCompanyId" resultMap="CompanyExtensionBindResult">
         <include refid="selectCompanyExtensionBindVo"/>
         where company_id = #{companyId} and (company_user_id is null or company_user_id = 0)
+          and status = 1 and (is_del = 0 or is_del is null)
     </select>
 
     <select id="selectUnBindAndSelfByCompanyId" resultMap="CompanyExtensionBindResult">
         <include refid="selectCompanyExtensionBindVo"/>
         where company_id = #{companyId} and (company_user_id is null or company_user_id =#{companyUserId})
+          and status = 1 and (is_del = 0 or is_del is null)
     </select>
 
     <update id="updateBindByExtId">
@@ -113,7 +123,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 
     <select id="selectByExtNumAndCompanyId" resultMap="CompanyExtensionBindResult">
         <include refid="selectCompanyExtensionBindVo"/>
-        where extension_num = #{extensionNum} and company_id = #{companyId}
+        where extension_num = #{extensionNum}
+          and company_id = #{companyId}
+          and company_id &gt; 0
+          and status = 1
+          and (is_del = 0 or is_del is null)
     </select>
 
     <update id="clearBindByExtNum">
@@ -140,5 +154,148 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
                 left join company_user t2 on t1.company_user_id = t2.user_id
         WHERE
             t1.company_id = #{companyId}
+          and t1.status = 1 and (t1.is_del = 0 or t1.is_del is null)
     </select>
+
+    <update id="updateStatusByExtId">
+        update company_extension_bind
+        set status = #{status}, update_time = now()
+        where ext_id = #{extId} and (is_del = 0 or is_del is null)
+    </update>
+
+    <update id="batchUpdateStatusByExtIds">
+        update company_extension_bind
+        set status = #{status}, update_time = now()
+        where (is_del = 0 or is_del is null) and ext_id in
+        <foreach collection="extIds" item="extId" open="(" separator="," close=")">
+            #{extId}
+        </foreach>
+    </update>
+
+    <update id="logicDeleteByExtId">
+        update company_extension_bind
+        set is_del = 1, update_time = now()
+        where ext_id = #{extId} and (is_del = 0 or is_del is null)
+    </update>
+
+    <update id="batchLogicDeleteByExtIds">
+        update company_extension_bind
+        set is_del = 1, update_time = now()
+        where (is_del = 0 or is_del is null) and ext_id in
+        <foreach collection="extIds" item="extId" open="(" separator="," close=")">
+            #{extId}
+        </foreach>
+    </update>
+
+    <sql id="saasVoiceSeatWhere">
+        and (t.is_del = 0 or t.is_del is null)
+        <if test="extensionNum != null and extensionNum != ''">
+            and t.extension_num like concat('%', #{extensionNum}, '%')
+        </if>
+        <if test="status != null">
+            and t.status = #{status}
+        </if>
+        <if test="companyId != null">
+            and t.company_id = #{companyId}
+        </if>
+        <if test="assignStatus != null and assignStatus == 0">
+            and t.company_id = 0
+        </if>
+        <if test="assignStatus != null and assignStatus == 1">
+            and t.company_id &gt; 0
+        </if>
+        <if test="companyName != null and companyName != ''">
+            and (
+                (t.company_id = 0 and '租户分机池' like concat('%', #{companyName}, '%'))
+                or c.company_name like concat('%', #{companyName}, '%')
+            )
+        </if>
+    </sql>
+
+    <select id="selectSaasVoiceSeatList" resultType="com.fs.company.vo.SaasVoiceSeatVO">
+        select
+            t.id,
+            t.company_id as companyId,
+            case when t.company_id = 0 then '租户分机池' else c.company_name end as companyName,
+            t.company_user_id as companyUserId,
+            cu.nick_name as companyUserName,
+            t.extension_num as extensionNum,
+            t.extension_pass as extensionPass,
+            t.ext_id as extId,
+            t.user_code as userCode,
+            t.status,
+            t.create_time as createTime,
+            t.update_time as updateTime
+        from company_extension_bind t
+        left join company c on t.company_id = c.company_id and t.company_id &gt; 0
+        left join company_user cu on t.company_user_id = cu.user_id
+        <where>
+            1 = 1
+            <include refid="saasVoiceSeatWhere"/>
+        </where>
+        order by t.id desc
+    </select>
+
+    <select id="selectSaasVoiceSeatById" resultType="com.fs.company.vo.SaasVoiceSeatVO">
+        select
+            t.id,
+            t.company_id as companyId,
+            case when t.company_id = 0 then '租户分机池' else c.company_name end as companyName,
+            t.company_user_id as companyUserId,
+            cu.nick_name as companyUserName,
+            t.extension_num as extensionNum,
+            t.extension_pass as extensionPass,
+            t.ext_id as extId,
+            t.user_code as userCode,
+            t.status,
+            t.create_time as createTime,
+            t.update_time as updateTime
+        from company_extension_bind t
+        left join company c on t.company_id = c.company_id and t.company_id &gt; 0
+        left join company_user cu on t.company_user_id = cu.user_id
+        where t.id = #{id} and (t.is_del = 0 or t.is_del is null)
+    </select>
+
+    <update id="assignExtensionToCompany">
+        update company_extension_bind
+        set company_id = #{companyId},
+            update_time = #{updateTime}
+        where company_id = 0
+          and status = 1
+          and (is_del = 0 or is_del is null)
+          and (company_user_id is null or company_user_id = 0)
+          and id in
+        <foreach collection="ids" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </update>
+
+    <update id="recycleExtensionToPool">
+        update company_extension_bind
+        set company_id = #{poolCompanyId},
+            company_user_id = null,
+            update_time = #{updateTime}
+        where company_id &gt; 0
+          and (is_del = 0 or is_del is null)
+          and (company_user_id is null or company_user_id = 0)
+          and id in
+        <foreach collection="ids" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </update>
+
+    <update id="updateStatusById">
+        update company_extension_bind
+        set status = #{status}, update_time = now()
+        where id = #{id} and (is_del = 0 or is_del is null)
+    </update>
+
+    <update id="batchUpdateStatusByIds">
+        update company_extension_bind
+        set status = #{status}, update_time = now()
+        where (is_del = 0 or is_del is null) and id in
+        <foreach collection="ids" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </update>
 </mapper>

+ 5 - 0
fs-service/src/main/resources/mapper/company/LobsterWorkflowInstanceMapper.xml

@@ -112,4 +112,9 @@
         SELECT workflow_id FROM lobster_workflow_instance WHERE id = #{id}
     </select>
 
+    <select id="countByStatus" resultType="java.lang.Integer">
+        SELECT COUNT(*) FROM lobster_workflow_instance WHERE del_flag = 0 AND status = #{status}
+        <if test="companyId != null">AND company_id = #{companyId}</if>
+    </select>
+
 </mapper>

+ 10 - 0
fs-service/src/main/resources/mapper/lobster/LobsterAuxiliaryMapper.xml

@@ -142,6 +142,16 @@
         SELECT 1 FROM lobster_dead_letter_queue LIMIT 1
     </select>
 
+    <select id="countDeadLetterPending" resultType="java.lang.Integer">
+        SELECT COUNT(*) FROM lobster_dead_letter_queue WHERE status = 'pending'
+        <if test="companyId != null">AND company_id = #{companyId}</if>
+    </select>
+
+    <select id="sumTodayTokens" resultType="java.lang.Long">
+        SELECT COALESCE(SUM(token_count), 0) FROM lobster_token_consume_log WHERE DATE(create_time) = CURDATE()
+        <if test="companyId != null">AND company_id = #{companyId}</if>
+    </select>
+
     <!-- === lobster_e2e_test === -->
     <insert id="insertE2eTest" useGeneratedKeys="true" keyProperty="id">
         INSERT INTO lobster_e2e_test(company_id, test_name, workflow_id, test_data, create_time)

+ 134 - 0
fs-service/src/main/resources/mapper/proxy/TenantExtensionBindMapper.xml

@@ -0,0 +1,134 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.proxy.mapper.TenantExtensionBindMapper">
+
+    <resultMap type="TenantExtensionBind" id="TenantExtensionBindResult">
+        <result property="id"            column="id"             />
+        <result property="tenantId"      column="tenant_id"      />
+        <result property="extensionNum"  column="extension_num"  />
+        <result property="extensionPass" column="extension_pass" />
+        <result property="extId"         column="ext_id"         />
+        <result property="userCode"      column="user_code"      />
+        <result property="status"        column="status"         />
+        <result property="isDel"         column="is_del"         />
+        <result property="remark"        column="remark"         />
+        <result property="createTime"    column="create_time"    />
+        <result property="updateTime"    column="update_time"    />
+        <result property="createBy"      column="create_by"      />
+        <result property="tenantName"    column="tenant_name"    />
+    </resultMap>
+
+    <sql id="selectVo">
+        select t.id, t.tenant_id, t.extension_num, t.extension_pass, t.ext_id, t.user_code,
+               t.status, t.is_del, t.remark, t.create_time, t.update_time, t.create_by
+        from tenant_extension_bind t
+    </sql>
+
+    <select id="selectById" resultMap="TenantExtensionBindResult">
+        select t.id, t.tenant_id, t.extension_num, t.extension_pass, t.ext_id, t.user_code,
+               t.status, t.is_del, t.remark, t.create_time, t.update_time, t.create_by,
+               ti.tenant_name
+        from tenant_extension_bind t
+        left join tenant_info ti on ti.id = t.tenant_id
+        where t.id = #{id} and t.is_del = 0
+    </select>
+
+    <select id="selectList" resultMap="TenantExtensionBindResult">
+        select t.id, t.tenant_id, t.extension_num, t.extension_pass, t.ext_id, t.user_code,
+               t.status, t.is_del, t.remark, t.create_time, t.update_time, t.create_by,
+               ti.tenant_name
+        from tenant_extension_bind t
+        left join tenant_info ti on ti.id = t.tenant_id
+        <where>
+            t.is_del = 0
+            <if test="tenantId != null"> and t.tenant_id = #{tenantId}</if>
+            <if test="extensionNum != null and extensionNum != ''"> and t.extension_num = #{extensionNum}</if>
+            <if test="status != null"> and t.status = #{status}</if>
+            <if test="companyName != null and companyName != ''"> and ti.tenant_name like concat('%', #{companyName}, '%')</if>
+        </where>
+        order by t.id desc
+    </select>
+
+    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
+        insert into tenant_extension_bind
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="tenantId != null">tenant_id,</if>
+            <if test="extensionNum != null">extension_num,</if>
+            <if test="extensionPass != null">extension_pass,</if>
+            <if test="extId != null">ext_id,</if>
+            <if test="userCode != null">user_code,</if>
+            <if test="status != null">status,</if>
+            <if test="isDel != null">is_del,</if>
+            <if test="remark != null">remark,</if>
+            <if test="createBy != null">create_by,</if>
+            create_time,
+        </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="tenantId != null">#{tenantId},</if>
+            <if test="extensionNum != null">#{extensionNum},</if>
+            <if test="extensionPass != null">#{extensionPass},</if>
+            <if test="extId != null">#{extId},</if>
+            <if test="userCode != null">#{userCode},</if>
+            <if test="status != null">#{status},</if>
+            <if test="isDel != null">#{isDel},</if>
+            <if test="remark != null">#{remark},</if>
+            <if test="createBy != null">#{createBy},</if>
+            now(),
+        </trim>
+    </insert>
+
+    <insert id="batchInsert" parameterType="java.util.List">
+        insert into tenant_extension_bind
+            (tenant_id, extension_num, extension_pass, ext_id, user_code, status, is_del, create_time)
+        values
+        <foreach collection="list" item="item" separator=",">
+            (#{item.tenantId}, #{item.extensionNum}, #{item.extensionPass}, #{item.extId},
+             #{item.userCode}, #{item.status}, #{item.isDel}, #{item.createTime})
+        </foreach>
+    </insert>
+
+    <update id="updateStatus">
+        update tenant_extension_bind
+        set status = #{status}, update_time = now()
+        where id = #{id} and is_del = 0
+    </update>
+
+    <update id="batchUpdateStatus">
+        update tenant_extension_bind
+        set status = #{status}, update_time = now()
+        where is_del = 0 and id in
+        <foreach collection="ids" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </update>
+
+    <select id="selectByIds" resultMap="TenantExtensionBindResult">
+        select t.id, t.tenant_id, t.extension_num, t.extension_pass, t.ext_id, t.user_code,
+               t.status, t.is_del, t.remark, t.create_time, t.update_time, t.create_by,
+               ti.tenant_name
+        from tenant_extension_bind t
+        left join tenant_info ti on ti.id = t.tenant_id
+        where t.is_del = 0 and t.id in
+        <foreach collection="ids" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </select>
+
+    <update id="logicDeleteById">
+        update tenant_extension_bind
+        set is_del = 1, update_time = now()
+        where id = #{id} and is_del = 0
+    </update>
+
+    <update id="batchLogicDeleteByIds">
+        update tenant_extension_bind
+        set is_del = 1, update_time = now()
+        where is_del = 0 and id in
+        <foreach collection="ids" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </update>
+
+</mapper>

+ 10 - 0
fs-service/src/main/resources/mapper/tenant/TenantInfoMapper.xml

@@ -761,4 +761,14 @@
         UPDATE sys_user SET password = #{password}, update_time = NOW()
         WHERE user_id = 1
     </update>
+
+    <!-- 检查租户库 sys_menu 中是否存在指定菜单(须在租户数据源下调用) -->
+    <select id="countTenantSysMenuById" resultType="int">
+        SELECT COUNT(1) FROM `sys_menu` WHERE menu_id = #{menuId}
+    </select>
+
+    <!-- 检查租户库 company_menu 中是否存在指定菜单(须在租户数据源下调用) -->
+    <select id="countTenantComMenuById" resultType="int">
+        SELECT COUNT(1) FROM `company_menu` WHERE menu_id = #{menuId}
+    </select>
 </mapper>

+ 27 - 6
fs-task/src/main/java/com/fs/quartz/config/ScheduleJobRedisConfig.java

@@ -3,9 +3,11 @@ package com.fs.quartz.config;
 import com.fs.common.constant.ScheduleConstants;
 import com.fs.quartz.support.ScheduleJobSyncSubscriber;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Lazy;
 import org.springframework.data.redis.connection.RedisConnectionFactory;
 import org.springframework.data.redis.listener.ChannelTopic;
 import org.springframework.data.redis.listener.RedisMessageListenerContainer;
@@ -16,14 +18,15 @@ import java.util.concurrent.TimeUnit;
 /**
  * 定时任务 Redis 订阅配置:管理端修改 sys_job 后通过频道通知 fs-task 刷新 Quartz。
  * <p>
- * 通过 spring.redis.listener.enabled 控制是否启用(默认 false)。
+ * 通过 spring.redis.listener.enabled 控制是否启用(默认启用)。
  */
 @Slf4j
 @Configuration
-@ConditionalOnProperty(name = "spring.redis.listener.enabled", havingValue = "true", matchIfMissing = false)
+@ConditionalOnProperty(name = "spring.redis.listener.enabled", havingValue = "true", matchIfMissing = true)
+@ConditionalOnBean(RedisConnectionFactory.class)
 public class ScheduleJobRedisConfig {
 
-    @Bean
+    @Bean(destroyMethod = "stop")
     public RedisMessageListenerContainer scheduleJobRedisListenerContainer(
             RedisConnectionFactory connectionFactory,
             ScheduleJobSyncSubscriber scheduleJobSyncSubscriber) {
@@ -31,10 +34,28 @@ public class ScheduleJobRedisConfig {
         container.setConnectionFactory(connectionFactory);
         container.addMessageListener(scheduleJobSyncSubscriber,
                 new ChannelTopic(ScheduleConstants.REDIS_CHANNEL_JOB_SYNC));
-        // 增加超时时间到30秒,避免启动时 Redis 连接未就绪导致超时
-        container.setMaxSubscriptionRegistrationWaitingTime(TimeUnit.SECONDS.toMillis(30));
+        // 设置较长的超时时间,但使用异步启动避免阻塞
+        container.setMaxSubscriptionRegistrationWaitingTime(TimeUnit.SECONDS.toMillis(60));
         container.setTaskExecutor(Executors.newFixedThreadPool(2, r -> new Thread(r, "job-sync-listener")));
-        log.info("[ScheduleJobRedis] Redis pub/sub 监听器已启用,频道={}", ScheduleConstants.REDIS_CHANNEL_JOB_SYNC);
+        log.info("[ScheduleJobRedis] Redis pub/sub 监听器已配置,频道={}", ScheduleConstants.REDIS_CHANNEL_JOB_SYNC);
+        // 异步启动,不阻塞 Spring 启动流程
+        asyncStart(container);
         return container;
     }
+
+    /**
+     * 异步启动 Redis 监听器,避免阻塞 Spring 启动
+     */
+    private void asyncStart(RedisMessageListenerContainer container) {
+        Executors.newSingleThreadExecutor(r -> new Thread(r, "redis-listener-starter")).execute(() -> {
+            try {
+                // 延迟 5 秒,等待 Spring 完全启动
+                Thread.sleep(5000);
+                container.start();
+                log.info("[ScheduleJobRedis] Redis pub/sub 监听器启动成功");
+            } catch (Exception e) {
+                log.warn("[ScheduleJobRedis] Redis pub/sub 监听器启动失败,应用仍可正常运行: {}", e.getMessage());
+            }
+        });
+    }
 }

+ 3 - 0
fs-task/src/main/java/com/fs/quartz/service/SysJobSchedulerService.java

@@ -8,10 +8,13 @@ import com.fs.quartz.mapper.SysJobMapper;
 import com.fs.quartz.util.JobInvokeUtil;
 import com.fs.quartz.util.ScheduleUtils;
 import lombok.extern.slf4j.Slf4j;
+import org.quartz.CronTrigger;
 import org.quartz.JobDataMap;
 import org.quartz.JobKey;
 import org.quartz.Scheduler;
 import org.quartz.SchedulerException;
+import org.quartz.Trigger;
+import org.quartz.TriggerKey;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 

+ 147 - 108
fs-task/src/main/java/com/fs/task/support/impl/SopLogsTaskServiceImpl.java

@@ -5,6 +5,8 @@ import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONArray;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.fs.common.config.RedisTenantContext;
+import com.fs.common.constant.Constants;
+import com.fs.common.core.redis.RedisCache;
 import com.fs.framework.task.TenantTaskRunner;
 import com.fs.quartz.support.TenantTaskContextHelper;
 import com.fs.task.support.SopLogsTaskService;
@@ -44,6 +46,8 @@ import com.fs.sop.service.IQwSopTempRulesService;
 import com.fs.sop.service.IQwSopTempVoiceService;
 import com.fs.sop.vo.QwCreateLinkByAppVO;
 import com.fs.sop.vo.SopUserLogsVo;
+import com.fs.system.domain.SysConfig;
+import com.fs.system.mapper.SysConfigMapper;
 import com.fs.system.service.ISysConfigService;
 import com.fs.voice.utils.StringUtil;
 import org.springframework.beans.BeanUtils;
@@ -69,6 +73,7 @@ import java.time.temporal.ChronoUnit;
 import java.util.*;
 import java.util.concurrent.*;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
 import java.util.stream.Collectors;
 
 import static com.fs.course.utils.LinkUtil.generateRandomStringWithLock;
@@ -86,6 +91,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
 //    private static final String miniappRealLink = "/pages/index/index?course=";
 
+    private static final String COURSE_CONFIG_KEY = "course.config";
     private static final String QWSOP_KEY_PREFIX = "qwsop:";
     private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
     private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@@ -94,6 +100,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
     // Cached configurations and domain names
     private CourseConfig cachedCourseConfig;
+    /** 与 cachedCourseConfig 对应的租户 ID,null 表示主库/无租户上下文 */
+    private Long cachedCourseConfigTenantId;
     private final Object configLock = new Object();
 
     private List<FsCourseDomainName> cachedDomainNames;
@@ -134,6 +142,12 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     @Autowired
     private ISysConfigService configService;
 
+    @Autowired
+    private SysConfigMapper sysConfigMapper;
+
+    @Autowired
+    private RedisCache redisCache;
+
     @Autowired
     private FsCourseDomainNameMapper fsCourseDomainNameMapper;
 
@@ -150,12 +164,12 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     @Autowired
     private CloudHostProper cloudHostProper;
 
-    // Blocking queues with bounded capacity to implement backpressure
-    private final BlockingQueue<QwSopLogs> qwSopLogsQueue = new LinkedBlockingQueue<>(20000);
-    private final BlockingQueue<FsCourseWatchLog> watchLogsQueue = new LinkedBlockingQueue<>(20000);
-    private final BlockingQueue<FsCourseLink> linkQueue = new LinkedBlockingQueue<>(20000);
-    private final BlockingQueue<FsCourseSopAppLink> sopAppLinks = new LinkedBlockingQueue<>(20000);
-    private final BlockingQueue<LiveWatchLog> zmLiveWatchQueue = new LinkedBlockingQueue<>(20000);
+    // Blocking queues with bounded capacity to implement backpressure(携带 tenantId,供后台消费线程切库)
+    private final BlockingQueue<TenantQueueItem<QwSopLogs>> qwSopLogsQueue = new LinkedBlockingQueue<>(20000);
+    private final BlockingQueue<TenantQueueItem<FsCourseWatchLog>> watchLogsQueue = new LinkedBlockingQueue<>(20000);
+    private final BlockingQueue<TenantQueueItem<FsCourseLink>> linkQueue = new LinkedBlockingQueue<>(20000);
+    private final BlockingQueue<TenantQueueItem<FsCourseSopAppLink>> sopAppLinks = new LinkedBlockingQueue<>(20000);
+    private final BlockingQueue<TenantQueueItem<LiveWatchLog>> zmLiveWatchQueue = new LinkedBlockingQueue<>(20000);
 
     // Executors for consumer threads
     private ExecutorService qwSopLogsExecutor;
@@ -224,17 +238,49 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     }
 
     private void loadCourseConfig() {
+        synchronized (configLock) {
+            loadCourseConfigFromDatasource(RedisTenantContext.getTenantId());
+        }
+    }
+
+    /**
+     * 获取课程配置:缓存命中且租户一致则直接返回,否则从当前数据源 sys_config 查库。
+     */
+    private CourseConfig resolveCourseConfig() {
+        synchronized (configLock) {
+            Long tenantId = RedisTenantContext.getTenantId();
+            if (cachedCourseConfig != null && Objects.equals(cachedCourseConfigTenantId, tenantId)) {
+                return cachedCourseConfig;
+            }
+            return loadCourseConfigFromDatasource(tenantId);
+        }
+    }
+
+    private String courseConfigRedisKey() {
+        return Constants.SYS_CONFIG_KEY + COURSE_CONFIG_KEY;
+    }
+
+    private CourseConfig loadCourseConfigFromDatasource(Long tenantId) {
         try {
-            String json = configService.selectConfigByKey("course.config");
+            SysConfig dbConfig = sysConfigMapper.selectConfigByConfigKey(COURSE_CONFIG_KEY);
+            String json = dbConfig != null ? dbConfig.getConfigValue() : null;
+            if (StringUtils.isEmpty(json)) {
+                log.warn("course.config not found in sys_config, tenantId={}", tenantId);
+                return null;
+            }
             CourseConfig config = JSON.parseObject(json, CourseConfig.class);
-            if (config != null) {
-                cachedCourseConfig = config;
-                log.info("Loaded course.config successfully.");
-            } else {
-                log.warn("course.config not found in current datasource (normal in master db for fs-admin or before per-tenant config is synced).");
+            if (config == null) {
+                log.error("course.config JSON parse failed, tenantId={}", tenantId);
+                return null;
             }
+            redisCache.setCacheObject(courseConfigRedisKey(), json);
+            cachedCourseConfig = config;
+            cachedCourseConfigTenantId = tenantId;
+            log.info("Loaded course.config from datasource and synced to redis, tenantId={}", tenantId);
+            return config;
         } catch (Exception e) {
-            log.error("Exception while loading course.config: {}", e.getMessage(), e);
+            log.error("Exception while loading course.config, tenantId={}", tenantId, e);
+            return null;
         }
     }
 
@@ -287,18 +333,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
     private void doRefreshCourseConfig() {
         synchronized (configLock) {
-            try {
-                String json = configService.selectConfigByKey("course.config");
-                CourseConfig newConfig = JSON.parseObject(json, CourseConfig.class);
-                if (newConfig != null) {
-                    cachedCourseConfig = newConfig;
-                    log.info("Refreshed course.config.");
-                } else {
-                    log.error("Failed to refresh course.config.");
-                }
-            } catch (Exception e) {
-                log.error("Exception while refreshing course.config: {}", e.getMessage(), e);
-            }
+            loadCourseConfigFromDatasource(RedisTenantContext.getTenantId());
         }
     }
 
@@ -343,10 +378,11 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         long startTimeMillis = System.currentTimeMillis();
         log.info("====== 开始选择和处理 SOP 用户日志 ======");
 
-        // 获取缓存的配置
-        CourseConfig config;
-        synchronized(configLock) {
-            config = cachedCourseConfig;
+        CourseConfig config = resolveCourseConfig();
+        if (config == null) {
+            log.error("CourseConfig is not loaded, tenantId={}, abort SOP user logs processing.",
+                    RedisTenantContext.getTenantId());
+            return;
         }
 
         List<SopUserLogsVo> sopUserLogsVos = sopUserLogsMapper.selectSopUserLogsListByTime(sopidList);
@@ -1788,16 +1824,36 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     }
 
     private void enqueueZmLiveWatchLog(LiveWatchLog liveWatchLog) {
+        enqueueWithTenant(zmLiveWatchQueue, liveWatchLog, "LiveWatchLog");
+    }
+
+    private <T> void enqueueWithTenant(BlockingQueue<TenantQueueItem<T>> queue, T payload, String queueName) {
+        Long tenantId = RedisTenantContext.getTenantId();
+        if (tenantId == null) {
+            log.error("{} 入队失败:当前线程未设置租户上下文", queueName);
+            return;
+        }
         try {
-            boolean offered = zmLiveWatchQueue.offer(liveWatchLog, 5, TimeUnit.SECONDS);
+            boolean offered = queue.offer(new TenantQueueItem<>(tenantId, payload), 5, TimeUnit.SECONDS);
             if (!offered) {
-                log.error("LiveWatchLog 队列已满,无法添加日志: {}", JSON.toJSONString(liveWatchLog));
-                // 处理队列已满的情况,例如记录到失败队列或持久化存储
+                log.error("{} 队列已满,无法添加,tenantId={}", queueName, tenantId);
             }
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt();
-            log.error("插入 LiveWatchLog 队列时被中断: {}", e.getMessage(), e);
+            log.error("插入 {} 队列时被中断: {}", queueName, e.getMessage(), e);
+        }
+    }
+
+    private <T> void flushBatchByTenant(List<TenantQueueItem<T>> batch, Consumer<List<T>> insertAction) {
+        if (batch.isEmpty()) {
+            return;
         }
+        batch.stream()
+                .collect(Collectors.groupingBy(TenantQueueItem::getTenantId))
+                .forEach((tenantId, items) -> {
+                    List<T> payloads = items.stream().map(TenantQueueItem::getPayload).collect(Collectors.toList());
+                    tenantTaskContextHelper.runWithTenant(tenantId, () -> insertAction.accept(payloads));
+                });
     }
 
     /**
@@ -1829,80 +1885,44 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
      * 将 QwSopLogs 放入队列
      */
     private void enqueueQwSopLogs(QwSopLogs sopLogs) {
-        try {
-            boolean offered = qwSopLogsQueue.offer(sopLogs, 5, TimeUnit.SECONDS);
-            if (!offered) {
-                log.error("QwSopLogs 队列已满,无法添加日志: {}", JSON.toJSONString(sopLogs));
-                // 处理队列已满的情况,例如记录到失败队列或持久化存储
-            }
-        } catch (InterruptedException e) {
-            Thread.currentThread().interrupt();
-            log.error("插入 QwSopLogs 队列时被中断: {}", e.getMessage(), e);
-        }
+        enqueueWithTenant(qwSopLogsQueue, sopLogs, "QwSopLogs");
     }
 
     /**
      * 将 FsCourseWatchLog 放入队列
      */
     private void enqueueWatchLog(FsCourseWatchLog watchLog) {
-        try {
-            boolean offered = watchLogsQueue.offer(watchLog, 5, TimeUnit.SECONDS);
-            if (!offered) {
-                log.error("FsCourseWatchLog 队列已满,无法添加日志: {}", JSON.toJSONString(watchLog));
-                // 处理队列已满的情况,例如记录到失败队列或持久化存储
-            }
-        } catch (InterruptedException e) {
-            Thread.currentThread().interrupt();
-            log.error("插入 FsCourseWatchLog 队列时被中断: {}", e.getMessage(), e);
-        }
+        enqueueWithTenant(watchLogsQueue, watchLog, "FsCourseWatchLog");
     }
 
     /**
      * 将 FsCourseWatchLog 放入队列
      */
     private void enqueueCourseLink(FsCourseLink courseLink) {
-        try {
-            boolean offered = linkQueue.offer(courseLink, 5, TimeUnit.SECONDS);
-            if (!offered) {
-                log.error("FsCourseLink 队列已满,无法添加日志: {}", JSON.toJSONString(courseLink));
-                // 处理队列已满的情况,例如记录到失败队列或持久化存储
-            }
-        } catch (InterruptedException e) {
-            Thread.currentThread().interrupt();
-            log.error("插入 FsCourseLink 队列时被中断: {}", e.getMessage(), e);
-        }
+        enqueueWithTenant(linkQueue, courseLink, "FsCourseLink");
     }
 
     /**
      * 将 FsCourseSopAppLing 放入队列
      */
     private void enqueueCourseSopAppLink(FsCourseSopAppLink sopAppLink) {
-        try {
-            boolean offered = sopAppLinks.offer(sopAppLink, 5, TimeUnit.SECONDS);
-            if (!offered) {
-                log.error("FsCourseSopAppLink 队列已满,无法添加日志: {}", JSON.toJSONString(sopAppLink));
-                // 处理队列已满的情况,例如记录到失败队列或持久化存储
-            }
-        } catch (InterruptedException e) {
-            Thread.currentThread().interrupt();
-            log.error("插入 FsCourseLink 队列时被中断: {}", e.getMessage(), e);
-        }
+        enqueueWithTenant(sopAppLinks, sopAppLink, "FsCourseSopAppLink");
     }
 
     /**
      * 消费 QwSopLogs 队列并进行批量插入
      */
     private void consumeQwSopLogs() {
-        List<QwSopLogs> batch = new ArrayList<>(BATCH_SIZE);
+        List<TenantQueueItem<QwSopLogs>> batch = new ArrayList<>(BATCH_SIZE);
         while (running || !qwSopLogsQueue.isEmpty()) {
             try {
-                QwSopLogs log = qwSopLogsQueue.poll(1, TimeUnit.SECONDS);
-                if (log != null) {
-                    batch.add(log);
+                TenantQueueItem<QwSopLogs> item = qwSopLogsQueue.poll(1, TimeUnit.SECONDS);
+                if (item != null) {
+                    batch.add(item);
                 }
-                if (batch.size() >= BATCH_SIZE || (!batch.isEmpty() && log == null)) {
+                if (batch.size() >= BATCH_SIZE || (!batch.isEmpty() && item == null)) {
                     if (!batch.isEmpty()) {
-                        batchInsertQwSopLogs(new ArrayList<>(batch));
+                        flushBatchByTenant(new ArrayList<>(batch), this::batchInsertQwSopLogs);
                         batch.clear();
                     }
                 }
@@ -1914,7 +1934,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
         // 处理剩余的数据
         if (!batch.isEmpty()) {
-            batchInsertQwSopLogs(batch);
+            flushBatchByTenant(batch, this::batchInsertQwSopLogs);
         }
     }
 
@@ -1922,16 +1942,16 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
      * 消费 FsCourseWatchLog 队列并进行批量插入
      */
     private void consumeCourseLink() {
-        List<FsCourseLink> batch = new ArrayList<>(BATCH_SIZE);
+        List<TenantQueueItem<FsCourseLink>> batch = new ArrayList<>(BATCH_SIZE);
         while (running || !linkQueue.isEmpty()) {
             try {
-                FsCourseLink courseLink = linkQueue.poll(1, TimeUnit.SECONDS);
-                if (courseLink != null) {
-                    batch.add(courseLink);
+                TenantQueueItem<FsCourseLink> item = linkQueue.poll(1, TimeUnit.SECONDS);
+                if (item != null) {
+                    batch.add(item);
                 }
-                if (batch.size() >= BATCH_SIZE || (!batch.isEmpty() && courseLink == null)) {
+                if (batch.size() >= BATCH_SIZE || (!batch.isEmpty() && item == null)) {
                     if (!batch.isEmpty()) {
-                        batchInsertFsCourseLink(new ArrayList<>(batch));
+                        flushBatchByTenant(new ArrayList<>(batch), this::batchInsertFsCourseLink);
                         batch.clear();
                     }
                 }
@@ -1943,7 +1963,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
         // 处理剩余的数据
         if (!batch.isEmpty()) {
-            batchInsertFsCourseLink(batch);
+            flushBatchByTenant(batch, this::batchInsertFsCourseLink);
         }
     }
 
@@ -1951,16 +1971,16 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
      * 消费 FsCourseSopAppLink 队列并进行批量插入
      */
     private void consumeCourseSopAppLink() {
-        List<FsCourseSopAppLink> batch = new ArrayList<>(BATCH_SIZE);
+        List<TenantQueueItem<FsCourseSopAppLink>> batch = new ArrayList<>(BATCH_SIZE);
         while (running || !sopAppLinks.isEmpty()) {
             try {
-                FsCourseSopAppLink courseSopAppLink = sopAppLinks.poll(1, TimeUnit.SECONDS);
-                if (courseSopAppLink != null) {
-                    batch.add(courseSopAppLink);
+                TenantQueueItem<FsCourseSopAppLink> item = sopAppLinks.poll(1, TimeUnit.SECONDS);
+                if (item != null) {
+                    batch.add(item);
                 }
-                if (batch.size() >= BATCH_SIZE || (!batch.isEmpty() && courseSopAppLink == null)) {
+                if (batch.size() >= BATCH_SIZE || (!batch.isEmpty() && item == null)) {
                     if (!batch.isEmpty()) {
-                        batchInsertFsCourseSopAppLink(new ArrayList<>(batch));
+                        flushBatchByTenant(new ArrayList<>(batch), this::batchInsertFsCourseSopAppLink);
                         batch.clear();
                     }
                 }
@@ -1972,7 +1992,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
         // 处理剩余的数据
         if (!batch.isEmpty()) {
-            batchInsertFsCourseSopAppLink(batch);
+            flushBatchByTenant(batch, this::batchInsertFsCourseSopAppLink);
         }
     }
 
@@ -1980,16 +2000,16 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
      * 消费 FsCourseSopAppLink 队列并进行批量插入
      */
     private void consumeZmLiveWatchQueue() {
-        List<LiveWatchLog> batch = new ArrayList<>(BATCH_SIZE);
+        List<TenantQueueItem<LiveWatchLog>> batch = new ArrayList<>(BATCH_SIZE);
         while (running || !zmLiveWatchQueue.isEmpty()) {
             try {
-                LiveWatchLog livewatchLog = zmLiveWatchQueue.poll(1, TimeUnit.SECONDS);
-                if (livewatchLog != null) {
-                    batch.add(livewatchLog);
+                TenantQueueItem<LiveWatchLog> item = zmLiveWatchQueue.poll(1, TimeUnit.SECONDS);
+                if (item != null) {
+                    batch.add(item);
                 }
-                if (batch.size() >= BATCH_SIZE || (!batch.isEmpty() && livewatchLog == null)) {
+                if (batch.size() >= BATCH_SIZE || (!batch.isEmpty() && item == null)) {
                     if (!batch.isEmpty()) {
-                        batchInsertLiveWatchLog(new ArrayList<>(batch));
+                        flushBatchByTenant(new ArrayList<>(batch), this::batchInsertLiveWatchLog);
                         batch.clear();
                     }
                 }
@@ -2001,7 +2021,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
         // 处理剩余的数据
         if (!batch.isEmpty()) {
-            batchInsertLiveWatchLog(batch);
+            flushBatchByTenant(batch, this::batchInsertLiveWatchLog);
         }
     }
 
@@ -2009,16 +2029,16 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
      * 消费 FsCourseWatchLog 队列并进行批量插入
      */
     private void consumeWatchLogs() {
-        List<FsCourseWatchLog> batch = new ArrayList<>(BATCH_SIZE);
+        List<TenantQueueItem<FsCourseWatchLog>> batch = new ArrayList<>(BATCH_SIZE);
         while (running || !watchLogsQueue.isEmpty()) {
             try {
-                FsCourseWatchLog watchLog = watchLogsQueue.poll(1, TimeUnit.SECONDS);
-                if (watchLog != null) {
-                    batch.add(watchLog);
+                TenantQueueItem<FsCourseWatchLog> item = watchLogsQueue.poll(1, TimeUnit.SECONDS);
+                if (item != null) {
+                    batch.add(item);
                 }
-                if (batch.size() >= BATCH_SIZE || (!batch.isEmpty() && watchLog == null)) {
+                if (batch.size() >= BATCH_SIZE || (!batch.isEmpty() && item == null)) {
                     if (!batch.isEmpty()) {
-                        batchInsertFsCourseWatchLogs(new ArrayList<>(batch));
+                        flushBatchByTenant(new ArrayList<>(batch), this::batchInsertFsCourseWatchLogs);
                         batch.clear();
                     }
                 }
@@ -2030,7 +2050,7 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
         // 处理剩余的数据
         if (!batch.isEmpty()) {
-            batchInsertFsCourseWatchLogs(batch);
+            flushBatchByTenant(batch, this::batchInsertFsCourseWatchLogs);
         }
     }
 
@@ -2694,4 +2714,23 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     private boolean isValidExternalContact(QwExternalContact externalContact) {
         return externalContact.getStatus() == 0 || externalContact.getStatus() == 2 || externalContact.getStatus() == 3;
     }
+
+    /** 队列元素:携带入队时的租户 ID,供后台消费线程切库 */
+    private static final class TenantQueueItem<T> {
+        private final Long tenantId;
+        private final T payload;
+
+        TenantQueueItem(Long tenantId, T payload) {
+            this.tenantId = tenantId;
+            this.payload = payload;
+        }
+
+        Long getTenantId() {
+            return tenantId;
+        }
+
+        T getPayload() {
+            return payload;
+        }
+    }
 }