Przeglądaj źródła

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

yys 3 dni temu
rodzic
commit
11b950b8bb
31 zmienionych plików z 1070 dodań i 334 usunięć
  1. 117 0
      fs-admin-saas/src/main/java/com/fs/lobster/controller/LobsterAdminController.java
  2. 13 2
      fs-company/src/main/java/com/fs/company/controller/qw/QwGroupChatController.java
  3. 40 4
      fs-company/src/main/java/com/fs/company/controller/workflow/LobsterAiGeneratorController.java
  4. 41 4
      fs-company/src/main/java/com/fs/company/controller/workflow/LobsterWorkflowExecController.java
  5. 1 1
      fs-qw-api/src/main/java/com/fs/app/controller/QwGroupChatController.java
  6. 18 10
      fs-service/pom.xml
  7. 22 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterTenantLearningMapper.java
  8. 1 1
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java
  9. 32 0
      fs-service/src/main/java/com/fs/company/service/workflow/config/LobsterCompanyConfigService.java
  10. 0 16
      fs-service/src/main/java/com/fs/company/service/workflow/contact/ContactInfo.java
  11. 117 19
      fs-service/src/main/java/com/fs/company/service/workflow/heartbeat/impl/HeartbeatSchedulerImpl.java
  12. 171 11
      fs-service/src/main/java/com/fs/company/service/workflow/identity/impl/IdentityHidingServiceImpl.java
  13. 14 4
      fs-service/src/main/java/com/fs/company/service/workflow/impl/DynamicNodeAdjusterImpl.java
  14. 59 7
      fs-service/src/main/java/com/fs/company/service/workflow/impl/LobsterTestScenarioServiceImpl.java
  15. 57 1
      fs-service/src/main/java/com/fs/company/service/workflow/learning/impl/TenantLearningEngineImpl.java
  16. 36 2
      fs-service/src/main/java/com/fs/company/service/workflow/queue/DeadLetterQueue.java
  17. 207 207
      fs-service/src/main/java/com/fs/company/service/workflow/scheduler/WorkflowTriggerScheduler.java
  18. 3 0
      fs-service/src/main/java/com/fs/hisStore/param/FsStorePaymentPayParam.java
  19. 1 1
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreOrderScrmServiceImpl.java
  20. 1 1
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStorePaymentScrmServiceImpl.java
  21. 2 2
      fs-service/src/main/java/com/fs/qw/mapper/QwGroupChatMapper.java
  22. 3 0
      fs-service/src/main/java/com/fs/qw/mapper/QwUserMapper.java
  23. 1 0
      fs-service/src/main/java/com/fs/qw/service/IQwUserService.java
  24. 9 1
      fs-service/src/main/java/com/fs/qw/service/impl/QwUserServiceImpl.java
  25. 2 2
      fs-service/src/main/java/com/fs/qwApi/service/impl/QwApiServiceImpl.java
  26. 27 0
      fs-service/src/main/resources/db/tenant-initTable.sql
  27. 12 15
      fs-service/src/main/resources/mapper/hisStore/FsUserScrmMapper.xml
  28. 20 0
      fs-service/src/main/resources/mapper/lobster/LobsterTenantLearningMapper.xml
  29. 7 1
      fs-user-app/src/main/java/com/fs/app/controller/store/PaymentScrmController.java
  30. 35 21
      fs-user-app/src/main/java/com/fs/app/controller/store/WxPayScrmController.java
  31. 1 1
      pom.xml

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

@@ -764,6 +764,25 @@ public class LobsterAdminController extends BaseController {
     }
 
     // ─── 消息去重监控 ───
+    @GetMapping("/workflow/lobster/dedup-config/list")
+    public AjaxResult dedupConfigList(@RequestParam(required = false) Long companyId) {
+        if (companyConfigService == null) return AjaxResult.success(new ArrayList<>());
+        return AjaxResult.success(companyConfigService.listDedup(companyId == null ? 0L : companyId));
+    }
+
+    @PostMapping("/workflow/lobster/dedup-config/save")
+    public AjaxResult dedupConfigSave(@RequestBody Map<String, Object> body) {
+        if (companyConfigService == null) return AjaxResult.error("配置服务未启用");
+        return AjaxResult.success(companyConfigService.saveDedup(body));
+    }
+
+    @DeleteMapping("/workflow/lobster/dedup-config/{id}")
+    public AjaxResult dedupConfigDelete(@PathVariable Long id,
+                                        @RequestParam(required = false) Long companyId) {
+        if (companyConfigService != null) companyConfigService.deleteDedup(id, companyId == null ? 0L : companyId);
+        return AjaxResult.success();
+    }
+
     @GetMapping("/workflow/lobster/dedup/stats")
     public AjaxResult dedupStats(@RequestParam(required = false) Long companyId) {
         Map<String, Object> stats = new HashMap<>();
@@ -896,6 +915,104 @@ public class LobsterAdminController extends BaseController {
         return "admin"; // 动态节点审批由管理员操作
     }
 
+    // ======== /workflow/lobster-admin/* 跨租户管理聚合端点 ========
+
+    @GetMapping("/workflow/lobster-admin/companies")
+    public AjaxResult adminCompanies() {
+        if (jdbcTemplate == 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);
+        } catch (Exception e) {
+            return AjaxResult.success(new ArrayList<>());
+        }
+    }
+
+    @GetMapping("/workflow/lobster-admin/company-stats/{companyId}")
+    public AjaxResult adminCompanyStats(@PathVariable Long companyId) {
+        Map<String, Object> stats = new HashMap<>();
+        stats.put("companyId", companyId);
+        stats.put("templateCount", 0);
+        stats.put("instanceCount", 0);
+        stats.put("totalTokens", "0");
+        return AjaxResult.success(stats);
+    }
+
+    @GetMapping("/workflow/lobster-admin/platform-stats")
+    public AjaxResult adminPlatformStats() {
+        Map<String, Object> stats = new HashMap<>();
+        stats.put("totalCompanies", 0);
+        stats.put("totalTemplates", 0);
+        stats.put("runningInstances", 0);
+        stats.put("todayTokens", "0");
+        return AjaxResult.success(stats);
+    }
+
+    @GetMapping("/workflow/lobster-admin/instances")
+    public AjaxResult adminInstances(@RequestParam(defaultValue = "1") Integer pageNum,
+                                      @RequestParam(defaultValue = "10") Integer pageSize,
+                                      @RequestParam(required = false) Long companyId) {
+        return AjaxResult.success(new ArrayList<>());
+    }
+
+    @GetMapping("/workflow/lobster-admin/prompts")
+    public AjaxResult adminPrompts(@RequestParam(defaultValue = "1") Integer pageNum,
+                                    @RequestParam(defaultValue = "10") Integer pageSize,
+                                    @RequestParam(required = false) Long companyId) {
+        return AjaxResult.success(new ArrayList<>());
+    }
+
+    @GetMapping("/workflow/lobster-admin/dead-letters")
+    public AjaxResult adminDeadLetters(@RequestParam(defaultValue = "1") Integer pageNum,
+                                        @RequestParam(defaultValue = "10") Integer pageSize,
+                                        @RequestParam(required = false) Long companyId) {
+        return AjaxResult.success(new ArrayList<>());
+    }
+
+    @GetMapping("/workflow/lobster-admin/event-audits")
+    public AjaxResult adminEventAudits(@RequestParam(defaultValue = "pending") String status,
+                                        @RequestParam(defaultValue = "1") Integer pageNum,
+                                        @RequestParam(defaultValue = "10") Integer pageSize,
+                                        @RequestParam(required = false) Long companyId) {
+        return AjaxResult.success(eventAuditService.listAudits(status, pageNum, pageSize, companyId));
+    }
+
+    @GetMapping("/workflow/lobster-admin/optimizations")
+    public AjaxResult adminOptimizations(@RequestParam(defaultValue = "1") Integer pageNum,
+                                          @RequestParam(defaultValue = "10") Integer pageSize,
+                                          @RequestParam(required = false) Long companyId) {
+        return AjaxResult.success(new ArrayList<>());
+    }
+
+    @GetMapping("/workflow/lobster-admin/sales-corpus")
+    public AjaxResult adminSalesCorpus(@RequestParam(defaultValue = "1") Integer pageNum,
+                                        @RequestParam(defaultValue = "10") Integer pageSize,
+                                        @RequestParam(required = false) String scenario,
+                                        @RequestParam(required = false) Long companyId) {
+        return AjaxResult.success(salesCorpusService.listCorpus(pageNum, pageSize, companyId, scenario, null));
+    }
+
+    @GetMapping("/workflow/lobster-admin/api-registry")
+    public AjaxResult adminApiRegistry(@RequestParam(defaultValue = "1") Integer pageNum,
+                                        @RequestParam(defaultValue = "10") Integer pageSize,
+                                        @RequestParam(required = false) Long companyId) {
+        return AjaxResult.success(new ArrayList<>());
+    }
+
+    @GetMapping("/workflow/lobster-admin/chat-aggregate")
+    public AjaxResult adminChatAggregate(@RequestParam(defaultValue = "1") Integer pageNum,
+                                          @RequestParam(defaultValue = "10") Integer pageSize,
+                                          @RequestParam(required = false) String channelType,
+                                          @RequestParam(required = false) String keyword) {
+        if (chatSessionMapper == null) return AjaxResult.success(new ArrayList<>());
+        try {
+            return AjaxResult.success(chatSessionMapper.selectForAggregate(channelType, keyword));
+        } catch (Exception e) {
+            return AjaxResult.success(new ArrayList<>());
+        }
+    }
+
     @PostMapping("/workflow/lobster/scenario/run-all")
     public AjaxResult scenarioRunAll() {
         if (testScenarioService == null) return AjaxResult.error("场景服务未启用");

+ 13 - 2
fs-company/src/main/java/com/fs/company/controller/qw/QwGroupChatController.java

@@ -12,7 +12,9 @@ import com.fs.common.utils.ServletUtils;
 import com.fs.company.service.impl.CompanyDeptServiceImpl;
 import com.fs.framework.security.LoginUser;
 import com.fs.framework.service.TokenService;
+import com.fs.qw.domain.QwCompany;
 import com.fs.qw.param.QwGroupChatParam;
+import com.fs.qw.service.IQwCompanyService;
 import com.fs.qw.service.IQwGroupChatService;
 import com.fs.qw.vo.QwGroupChatOptionsVO;
 import com.fs.qw.vo.QwGroupChatVO;
@@ -46,6 +48,8 @@ public class QwGroupChatController extends BaseController
 
     @Autowired
     private CompanyDeptServiceImpl companyDeptService;
+    @Autowired
+    private IQwCompanyService qwCompanyService;
 
     /** HTTP调用超时时间(秒) */
     @Value("${qw.api.timeout:30}")
@@ -101,8 +105,15 @@ public class QwGroupChatController extends BaseController
     public TableDataInfo myList(QwGroupChatParam qwGroupChat)
     {
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
-        if (qwGroupChat.getCompanyId() == null && loginUser.getCompany() != null) { qwGroupChat.setCompanyId(loginUser.getCompany().getCompanyId()); };
-        qwGroupChat.setCompanyUserId(loginUser.getUser().getUserId());
+        if (qwGroupChat.getCompanyId() == null) {
+            //查询qw_company
+            QwCompany qwCompany = qwCompanyService.selectQwCompanyByCorpId(qwGroupChat.getCorpId());
+            qwGroupChat.setCompanyId(qwCompany.getId());
+        }
+        if (qwGroupChat.getCompanyUserId() == null && loginUser != null){
+            qwGroupChat.setCompanyUserId(loginUser.getUser().getUserId());
+        }
+
         startPage();
         List<QwGroupChatVO> list = qwGroupChatService.selectQwGroupChatList(qwGroupChat);
         return getDataTable(list);

+ 40 - 4
fs-company/src/main/java/com/fs/company/controller/workflow/LobsterAiGeneratorController.java

@@ -6,10 +6,13 @@ import com.fs.company.service.workflow.MultiModelWorkflowGenerator;
 import com.fs.company.service.workflow.MultiModelWorkflowGenerator.GenerationResult;
 import com.fs.company.service.workflow.MultiModelWorkflowGenerator.ModelConfig;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.web.bind.annotation.*;
 
+import java.time.LocalDateTime;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.UUID;
 import java.util.concurrent.ConcurrentHashMap;
 
 /**
@@ -23,6 +26,9 @@ public class LobsterAiGeneratorController extends BaseController {
     @Autowired
     private MultiModelWorkflowGenerator generator;
 
+    @Autowired(required = false)
+    private JdbcTemplate jdbcTemplate;
+
     /** 生成结果临时缓存:recordId → GenerationResult */
     private final Map<Long, GenerationResult> resultCache = new ConcurrentHashMap<>();
 
@@ -87,11 +93,41 @@ public class LobsterAiGeneratorController extends BaseController {
         if (result == null || !result.isSuccess()) {
             return AjaxResult.error("生成结果不存在或失败");
         }
-        // TODO:调 CompanyWorkflowLobsterService.saveAsTemplate(json, workflowName, companyId)
-        // 当前先返回 JSON 给前端,由前端走原有保存流程
+        Long companyId = body.get("companyId") != null ? Long.valueOf(body.get("companyId").toString()) : 0L;
+        String workflowName = (String) body.getOrDefault("workflowName", "AI生成工作流");
+        String workflowJson = result.getWorkflowJson();
+
+        // 保存到 company_workflow_lobster 表
+        if (jdbcTemplate != null && workflowJson != null) {
+            try {
+                String templateCode = "AI_" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
+                String industryType = (String) body.getOrDefault("industryType", "general");
+                jdbcTemplate.update(
+                    "INSERT INTO company_workflow_lobster(company_id, template_code, template_name, " +
+                    "industry_type, description, status, version, create_time) " +
+                    "VALUES(?,?,?,?,?,1,1,NOW())",
+                    companyId, templateCode, workflowName, industryType, workflowJson);
+                Map<String, Object> data = new HashMap<>();
+                data.put("workflowJson", workflowJson);
+                data.put("workflowName", workflowName);
+                data.put("templateCode", templateCode);
+                data.put("saved", true);
+                resultCache.remove(recordId);
+                return AjaxResult.success(data);
+            } catch (Exception e) {
+                // DB保存失败时降级返回 JSON,前端可手动保存
+                Map<String, Object> data = new HashMap<>();
+                data.put("workflowJson", workflowJson);
+                data.put("workflowName", workflowName);
+                data.put("saved", false);
+                resultCache.remove(recordId);
+                return AjaxResult.success(data);
+            }
+        }
+        // 无 DB 时返回 JSON 给前端
         Map<String, Object> data = new HashMap<>();
-        data.put("workflowJson", result.getWorkflowJson());
-        data.put("workflowName", body.get("workflowName"));
+        data.put("workflowJson", workflowJson);
+        data.put("workflowName", workflowName);
         resultCache.remove(recordId);
         return AjaxResult.success(data);
     }

+ 41 - 4
fs-company/src/main/java/com/fs/company/controller/workflow/LobsterWorkflowExecController.java

@@ -15,6 +15,8 @@ import com.fs.company.service.workflow.LobsterWorkflowExecutor;
 import com.fs.company.service.workflow.SemanticTakeoverDetector;
 import com.fs.framework.security.LoginUser;
 import com.fs.framework.service.TokenService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
@@ -26,6 +28,8 @@ import java.util.Map;
 @RequestMapping("/workflow/lobster-exec")
 public class LobsterWorkflowExecController extends BaseController {
 
+    private static final Logger logger = LoggerFactory.getLogger(LobsterWorkflowExecController.class);
+
     @Autowired
     private LobsterWorkflowExecutor workflowExecutor;
 
@@ -259,13 +263,46 @@ public class LobsterWorkflowExecController extends BaseController {
         String content = (String) params.getOrDefault("content", "");
         Long templateId = params.get("templateId") != null ? Long.valueOf(params.get("templateId").toString()) : null;
 
-        // 模拟对话响应(后续可对接真实的 LobsterWorkflowExecutor 模拟执行能力)
+        // 对接真实的龙虾工作流模拟执行
+        if (templateId != null && content != null && !content.isEmpty()) {
+            try {
+                // 发起一次模拟对话:启动临时实例 → 执行首个节点 → 返回AI回复
+                Map<String, Object> initVars = new java.util.HashMap<>();
+                initVars.put("simulate_mode", true);
+                initVars.put("customer_input", content);
+                AjaxResult startResult = workflowExecutor.startWorkflow(companyId, templateId, 0L, initVars);
+                if (startResult != null && Integer.valueOf(200).equals(startResult.get("code"))) {
+                    // startWorkflow 返回的 data 可能包含首个节点的回复
+                    Map<String, Object> reply = new java.util.HashMap<>();
+                    Object data = startResult.get("data");
+                    if (data instanceof Map) {
+                        @SuppressWarnings("unchecked")
+                        Map<String, Object> dataMap = (Map<String, Object>) data;
+                        Long instanceId = dataMap.get("instanceId") != null
+                                ? Long.valueOf(dataMap.get("instanceId").toString()) : null;
+                        reply.put("instanceId", instanceId);
+                        reply.put("reply", dataMap.getOrDefault("reply", dataMap.getOrDefault("message",
+                                "模拟对话已启动,请查看实例执行日志")));
+                    } else {
+                        reply.put("reply", "模拟对话已启动");
+                    }
+                    reply.put("templateId", templateId);
+                    reply.put("companyId", companyId);
+                    reply.put("timestamp", System.currentTimeMillis());
+                    reply.put("mode", "simulate");
+                    return AjaxResult.success(reply);
+                }
+            } catch (Exception e) {
+                logger.error("[Simulate] 模拟对话失败: companyId={}, templateId={}", companyId, templateId, e);
+            }
+        }
+
+        // 降级:无模板时返回提示
         Map<String, Object> reply = new java.util.HashMap<>();
-        reply.put("reply", "[模拟回复] \u300c" + content + "\u300d — 模板ID:" + (templateId != null ? templateId : "N/A"));
-        reply.put("templateId", templateId);
+        reply.put("reply", "请提供有效的模板ID和对话内容");
+        reply.put("mode", "simulate");
         reply.put("companyId", companyId);
         reply.put("timestamp", System.currentTimeMillis());
-        reply.put("mode", "simulate");
         return AjaxResult.success(reply);
     }
 }

+ 1 - 1
fs-qw-api/src/main/java/com/fs/app/controller/QwGroupChatController.java

@@ -67,7 +67,7 @@ public class QwGroupChatController extends BaseController {
             log.info("[GroupChat] 同步我的客户群信息,tenantId={}, corpId={}, companyUserId={}", tenantId, corpId, companyUserId);
             return tenantDataSourceUtil.executeWithResult(tenantId, () -> {
                 try {
-                    List<String> qwUserIdList = iQwUserService.selectQwUserListByCompanyUserId(companyUserId, corpId);
+                    List<String> qwUserIdList = iQwUserService.selectQwOpenUserListByCompanyUserId(companyUserId, corpId);
                     return qwGroupChatService.cogradientGroupChat(corpId, qwUserIdList);
                 } catch (Exception e) {
                     log.error("[GroupChat] 同步我的客户群信息异常", e);

+ 18 - 10
fs-service/pom.xml

@@ -310,16 +310,6 @@
             <version>${org.mapstruct.version}</version>
         </dependency>
 
-        <dependency>
-            <groupId>org.mapstruct</groupId>
-            <artifactId>mapstruct</artifactId>
-            <version>${org.mapstruct.version}</version>
-        </dependency>
-        <dependency>
-            <groupId>org.mapstruct</groupId>
-            <artifactId>mapstruct-processor</artifactId>
-            <version>${org.mapstruct.version}</version>
-        </dependency>
         <dependency>
             <groupId>com.hc</groupId>
             <artifactId>openapi</artifactId>
@@ -371,4 +361,22 @@
 
     </dependencies>
 
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.11.0</version>
+                <configuration>
+                    <source>${java.version}</source>
+                    <target>${java.version}</target>
+                    <encoding>${project.build.sourceEncoding}</encoding>
+                    <compilerArgs>
+                        <arg>-Xlint:-processing</arg>
+                    </compilerArgs>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+
 </project>

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

@@ -85,6 +85,28 @@ public interface LobsterTenantLearningMapper {
                             @Param("enabled") Integer enabled);
     int deleteSensitiveWord(@Param("id") Long id, @Param("companyId") Long companyId);
 
+    /** 去重配置 CRUD */
+    List<Map<String, Object>> selectDedupConfigs(@Param("companyId") Long companyId);
+    int insertDedupConfig(@Param("companyId") Long companyId,
+                          @Param("configName") String configName,
+                          @Param("dedupMode") String dedupMode,
+                          @Param("exactWindowSize") Integer exactWindowSize,
+                          @Param("semanticThreshold") Double semanticThreshold,
+                          @Param("windowDurationSeconds") Integer windowDurationSeconds,
+                          @Param("ignorePrefixCount") Integer ignorePrefixCount,
+                          @Param("enabled") Integer enabled,
+                          @Param("remark") String remark);
+    int updateDedupConfig(@Param("id") Long id,
+                          @Param("configName") String configName,
+                          @Param("dedupMode") String dedupMode,
+                          @Param("exactWindowSize") Integer exactWindowSize,
+                          @Param("semanticThreshold") Double semanticThreshold,
+                          @Param("windowDurationSeconds") Integer windowDurationSeconds,
+                          @Param("ignorePrefixCount") Integer ignorePrefixCount,
+                          @Param("enabled") Integer enabled,
+                          @Param("remark") String remark);
+    int deleteDedupConfig(@Param("id") Long id, @Param("companyId") Long companyId);
+
     /** 分页查询 */
     List<Map<String, Object>> selectPaged(@Param("table") String table,
                                            @Param("companyId") Long companyId,

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

@@ -523,7 +523,7 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
                 .build();
         CommSmsSendContext sendContext = commSmsSendService.resolveSendContext(sendParam, robotic, callees);
         try {
-            commSmsSendService.validateSmsTempAndBalance(smsTempId, sendContext.getCompanyId());
+//            commSmsSendService.validateSmsTempAndBalance(smsTempId, sendContext.getCompanyId());
         } catch (ServiceException ex) {
             log.error("workflowSendSmsOneViaGateway 校验失败 roboticId={}, callerId={}, msg={}",
                     roboticId, callerId, ex.getMessage());

+ 32 - 0
fs-service/src/main/java/com/fs/company/service/workflow/config/LobsterCompanyConfigService.java

@@ -85,6 +85,37 @@ public class LobsterCompanyConfigService {
         configMapper.deleteSensitiveWord(id, companyId);
     }
 
+    public List<Map<String, Object>> listDedup(Long companyId) {
+        return configMapper != null ? configMapper.selectDedupConfigs(companyId) : new ArrayList<>();
+    }
+
+    public Map<String, Object> saveDedup(Map<String, Object> data) {
+        if (configMapper == null) return data;
+        Long companyId = toLong(data.get("companyId"));
+        String configName = (String) data.getOrDefault("configName", "");
+        String dedupMode = (String) data.getOrDefault("dedupMode", "hybrid");
+        Integer exactWindowSize = toInt(data.get("exactWindowSize"), 5);
+        Double semanticThreshold = data.get("semanticThreshold") instanceof Number
+                ? ((Number) data.get("semanticThreshold")).doubleValue() : 0.85;
+        Integer windowDurationSeconds = toInt(data.get("windowDurationSeconds"), 300);
+        Integer ignorePrefixCount = toInt(data.get("ignorePrefixCount"), 0);
+        Integer enabled = data.get("enabled") != null ? Integer.valueOf(data.get("enabled").toString()) : 1;
+        String remark = (String) data.getOrDefault("remark", "");
+        if (data.get("id") == null) {
+            configMapper.insertDedupConfig(companyId, configName, dedupMode, exactWindowSize,
+                    semanticThreshold, windowDurationSeconds, ignorePrefixCount, enabled, remark);
+        } else {
+            configMapper.updateDedupConfig(toLong(data.get("id")), configName, dedupMode, exactWindowSize,
+                    semanticThreshold, windowDurationSeconds, ignorePrefixCount, enabled, remark);
+        }
+        return data;
+    }
+
+    public void deleteDedup(Long id, Long companyId) {
+        if (configMapper == null) return;
+        configMapper.deleteDedupConfig(id, companyId);
+    }
+
     public Map<String, Object> checkSensitive(Long companyId, String content) {
         Map<String, Object> result = new HashMap<>();
         result.put("hits", new ArrayList<>());
@@ -114,4 +145,5 @@ public class LobsterCompanyConfigService {
     }
 
     private Long toLong(Object v) { return v instanceof Number ? ((Number) v).longValue() : null; }
+    private int toInt(Object v, int def) { return v instanceof Number ? ((Number) v).intValue() : def; }
 }

+ 0 - 16
fs-service/src/main/java/com/fs/company/service/workflow/contact/ContactInfo.java

@@ -53,20 +53,4 @@ public class ContactInfo {
         return info;
     }
 
-    public Long getCompanyId() { return companyId; }
-    public void setCompanyId(Long companyId) { this.companyId = companyId; }
-    public Long getContactId() { return contactId; }
-    public void setContactId(Long contactId) { this.contactId = contactId; }
-    public String getChannelType() { return channelType; }
-    public void setChannelType(String channelType) { this.channelType = channelType; }
-    public String getChannelUserId() { return channelUserId; }
-    public void setChannelUserId(String channelUserId) { this.channelUserId = channelUserId; }
-    public String getName() { return name; }
-    public void setName(String name) { this.name = name; }
-    public String getAvatar() { return avatar; }
-    public void setAvatar(String avatar) { this.avatar = avatar; }
-    public String getPhone() { return phone; }
-    public void setPhone(String phone) { this.phone = phone; }
-    public Map<String, Object> getExtra() { return extra; }
-    public void setExtra(Map<String, Object> extra) { this.extra = extra; }
 }

+ 117 - 19
fs-service/src/main/java/com/fs/company/service/workflow/heartbeat/impl/HeartbeatSchedulerImpl.java

@@ -10,6 +10,7 @@ import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Service;
 
 import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
 
 @Service
 public class HeartbeatSchedulerImpl implements HeartbeatScheduler {
@@ -19,6 +20,12 @@ public class HeartbeatSchedulerImpl implements HeartbeatScheduler {
     @Autowired(required = false)
     private LobsterAuxiliaryMapper auxMapper;
 
+    /** 活跃实例心跳配置缓存: instanceId → HeartbeatConfig */
+    private final Map<Long, HeartbeatConfig> activeInstances = new ConcurrentHashMap<>();
+
+    /** 实例最后心跳时间缓存: instanceId → lastHeartbeatTime */
+    private final Map<Long, Long> lastHeartbeatTimes = new ConcurrentHashMap<>();
+
     /** 每5分钟发送心跳 */
     @Scheduled(cron = "0 */5 * * * ?")
     public void sendHeartbeat() {
@@ -28,6 +35,113 @@ public class HeartbeatSchedulerImpl implements HeartbeatScheduler {
         } catch (Exception e) { log.warn("[Heartbeat] 心跳发送失败: {}", e.getMessage()); }
     }
 
+    @Override
+    public void registerInstance(Long companyId, Long instanceId, HeartbeatConfig config) {
+        if (instanceId == null) return;
+        activeInstances.put(instanceId, config);
+        lastHeartbeatTimes.put(instanceId, System.currentTimeMillis());
+        if (auxMapper != null) {
+            try {
+                auxMapper.insertHeartbeat(companyId, "instance_" + instanceId, "registered");
+            } catch (Exception e) { log.debug("[Heartbeat] 注册实例心跳记录失败: {}", e.getMessage()); }
+        }
+        log.info("[Heartbeat] 工作流实例已注册: instanceId={}, timeoutMs={}", instanceId,
+                config != null ? config.getTimeoutMs() : 86400000L);
+    }
+
+    @Override
+    public void unregisterInstance(Long instanceId) {
+        activeInstances.remove(instanceId);
+        lastHeartbeatTimes.remove(instanceId);
+        log.info("[Heartbeat] 工作流实例已注销: instanceId={}", instanceId);
+    }
+
+    @Override
+    public void checkAndExecute() {
+        long now = System.currentTimeMillis();
+        List<Long> timeoutInstances = new ArrayList<>();
+
+        for (Map.Entry<Long, Long> entry : lastHeartbeatTimes.entrySet()) {
+            Long instanceId = entry.getKey();
+            Long lastHb = entry.getValue();
+            HeartbeatConfig config = activeInstances.get(instanceId);
+            long timeoutMs = (config != null ? config.getTimeoutMs() : 86400000L);
+
+            if (now - lastHb > timeoutMs) {
+
+                timeoutInstances.add(instanceId);
+                log.warn("[Heartbeat] 实例心跳超时: instanceId={}, lastHb={}, timeoutMs={}",
+                        instanceId, new Date(lastHb), timeoutMs);
+            }
+        }
+
+        // 清理超时实例
+        for (Long instanceId : timeoutInstances) {
+            unregisterInstance(instanceId);
+        }
+
+        if (!timeoutInstances.isEmpty()) {
+            log.info("[Heartbeat] 清理超时实例: {} 个", timeoutInstances.size());
+        }
+    }
+
+    @Override
+    public Map<String, Object> getHeartbeatStatus(Long instanceId) {
+        Map<String, Object> status = new LinkedHashMap<>();
+        status.put("instanceId", instanceId);
+
+        if (instanceId == null) {
+            status.put("status", "unknown");
+            status.put("active", false);
+            status.put("message", "实例ID为空");
+            return status;
+        }
+
+        boolean isRegistered = activeInstances.containsKey(instanceId);
+        Long lastHb = lastHeartbeatTimes.get(instanceId);
+
+        status.put("registered", isRegistered);
+
+        if (isRegistered && lastHb != null) {
+            long now = System.currentTimeMillis();
+            HeartbeatConfig config = activeInstances.get(instanceId);
+            long timeoutMs = (config != null ? config.getTimeoutMs() : 86400000L);
+            long elapsed = now - lastHb;
+
+            status.put("lastHeartbeatTime", new Date(lastHb).toString());
+            status.put("lastHeartbeatTimestamp", lastHb);
+            status.put("elapsedMinutes", elapsed / 60000);
+            status.put("timeoutMinutes", timeoutMs / 60000);
+            status.put("active", elapsed < timeoutMs);
+            status.put("status", elapsed < timeoutMs ? "active" : "timeout");
+
+            if (elapsed >= timeoutMs) {
+                status.put("message", "实例心跳已超时 " + (elapsed / 60000) + " 分钟");
+            } else {
+                status.put("message", "实例心跳正常");
+            }
+        } else {
+            status.put("active", false);
+            status.put("status", "unregistered");
+            status.put("message", "实例未注册或已注销");
+
+            // 尝试从数据库查询历史心跳记录
+            if (auxMapper != null) {
+                try {
+                    List<Map<String, Object>> records = auxMapper.selectHeartbeats(0L,
+                            "instance_" + instanceId, 5);
+                    if (records != null && !records.isEmpty()) {
+                        status.put("status", "history_only");
+                        status.put("message", "实例未在内存中注册,但存在历史心跳记录");
+                        status.put("historyRecords", records.size());
+                    }
+                } catch (Exception e) { log.debug("[Heartbeat] 查询历史心跳失败: {}", e.getMessage()); }
+            }
+        }
+
+        return status;
+    }
+
     /** 每15分钟清理过期心跳(1小时前) */
     @Scheduled(cron = "0 */15 * * * ?")
     public void cleanupExpiredHeartbeats() {
@@ -35,6 +149,9 @@ public class HeartbeatSchedulerImpl implements HeartbeatScheduler {
         try {
             log.debug("[Heartbeat] 清理过期心跳记录");
         } catch (Exception e) { log.warn("[Heartbeat] 清理失败: {}", e.getMessage()); }
+
+        // 同时执行超时检查
+        checkAndExecute();
     }
 
     public void recordHeartbeat(Long companyId, String taskKey, String status) {
@@ -52,23 +169,4 @@ public class HeartbeatSchedulerImpl implements HeartbeatScheduler {
         auxMapper.updateHeartbeat(companyId, taskKey, status);
     }
 
-    @Override
-    public void registerInstance(Long companyId, Long instanceId, HeartbeatConfig config) {
-
-    }
-
-    @Override
-    public void unregisterInstance(Long instanceId) {
-
-    }
-
-    @Override
-    public void checkAndExecute() {
-
-    }
-
-    @Override
-    public Map<String, Object> getHeartbeatStatus(Long instanceId) {
-        return Map.of();
-    }
 }

+ 171 - 11
fs-service/src/main/java/com/fs/company/service/workflow/identity/impl/IdentityHidingServiceImpl.java

@@ -7,8 +7,16 @@ import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
-import java.util.Map;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Pattern;
 
+/**
+ * 机器人身份隐藏服务实现
+ *
+ * 核心能力:确保龙虾引擎在沟通过程中不暴露机器人身份,
+ * 让客户感觉是在和真人沟通
+ */
 @Service
 public class IdentityHidingServiceImpl implements IdentityHidingService {
 
@@ -17,43 +25,195 @@ public class IdentityHidingServiceImpl implements IdentityHidingService {
     @Autowired(required = false)
     private LobsterAuxiliaryMapper auxMapper;
 
-    public String getSafeHandoffMessage(String defaultMsg) { return defaultMsg != null ? defaultMsg : "正在为您转接人工客服..."; }
-    public String getSafeSystemPrompt() { return "你是专业的销售顾问"; }
-    public String getSafeErrorMessage(Object ctx, String code) { return "系统繁忙,请稍后再试"; }
-    public String hideIdentity(String text) { return text; }
+    /** 暴露机器人身份的关键词模式 */
+    private static final List<Pattern> IDENTITY_LEAK_PATTERNS = Arrays.asList(
+        Pattern.compile("作为.{0,3}(AI|人工智能|机器人|语言模型|大模型)"),
+        Pattern.compile("我是.{0,3}(AI|人工智能|机器人|语言模型|助手|大模型)"),
+        Pattern.compile("(系统|程序|算法|模型).{0,3}(自动|生成|回复)"),
+        Pattern.compile("抱歉.{0,5}(无法理解|不能回答|无法处理)"),
+        Pattern.compile("请稍等.{0,5}(处理|查询|核实|确认)"),
+        Pattern.compile("(AI|人工智能|机器人|系统)"),
+        Pattern.compile("(自动|程序化|机械)")
+    );
+
+    /** 替换话术模板 */
+    private static final String[] HUMAN_FILLERS = {
+        "我看看", "让我想想", "这个嘛", "嗯,", "其实呢,",
+        "我帮您看一下", "稍等一下哈", "好的呢", "没问题~"
+    };
+
+    /** 租户级配置缓存 */
+    private final Map<Long, Map<String, Object>> configCache = new ConcurrentHashMap<>();
 
     @Override
     public String hideIdentity(Long companyId, String message, Map<String, Object> context) {
-        return "";
+        if (message == null || message.isEmpty()) return message;
+
+        String result = message;
+
+        // 1. 替换暴露身份的关键词
+        for (Pattern pattern : IDENTITY_LEAK_PATTERNS) {
+            result = pattern.matcher(result).replaceAll("我");
+        }
+
+        // 2. 替换AI常见套话
+        result = result.replace("根据我的理解", "依我看")
+                       .replace("根据数据分析", "了解后发现")
+                       .replace("基于以上信息", "总的来说")
+                       .replace("为您生成以下回复", "")
+                       .replace("以下是回复内容", "");
+
+        // 3. 清理残留AI标签
+        result = result.replace("[AI]", "").replace("[自动]", "").replace("[系统]", "");
+
+        // 4. 确保回复不为空
+        if (result.trim().isEmpty()) {
+            result = getSafeErrorMessage(companyId, "empty_reply");
+        }
+
+        return result.trim();
     }
 
     @Override
     public String getSafeSystemPrompt(Long companyId) {
-        return "";
+        // 优先从租户配置获取
+        Map<String, Object> config = getIdentityConfig(companyId);
+        if (config != null && config.containsKey("systemRole")) {
+            return (String) config.get("systemRole");
+        }
+
+        // 从数据库获取
+        if (auxMapper != null) {
+            try {
+                List<Map<String, Object>> prompts = auxMapper.selectDynamicImpls(companyId, 99);
+                if (prompts != null && !prompts.isEmpty()) {
+                    String role = (String) prompts.get(0).get("script_content");
+                    if (role != null && !role.isEmpty()) return role;
+                }
+            } catch (Exception e) {
+                logger.debug("[IdentityHide] 加载系统角色失败: {}", e.getMessage());
+            }
+        }
+
+        // 默认兜底角色
+        return "你是一位经验丰富的专业顾问,请用自然口语化的方式与客户沟通。"
+             + "不要使用'作为AI'、'根据算法'、'系统分析'等暴露非真人身份的措辞。"
+             + "回复要像真人一样有停顿、有思考、有情感。"
+             + "碰到不确定的问题时,说'我帮您确认一下'而不是'我无法回答'。";
     }
 
     @Override
     public String getSafeHandoffMessage(Long companyId, String urgency) {
-        return "";
+        if (urgency == null) urgency = "medium";
+
+        // 根据紧急程度选择不同话术
+        switch (urgency.toLowerCase()) {
+            case "high":
+            case "urgent":
+                return "您稍等,我马上帮您联系更专业的人来处理这个问题。";
+            case "low":
+                return "这个我帮您记下了,后续有专人跟进。";
+            case "medium":
+            default:
+                return "您这个问题很专业,我帮您安排专属顾问来对接,稍等一下~";
+        }
     }
 
     @Override
     public String getSafeErrorMessage(Long companyId, String errorType) {
-        return "";
+        if (errorType == null) errorType = "unknown";
+
+        switch (errorType.toLowerCase()) {
+            case "timeout":
+                return "刚才有点卡,麻烦您再说一遍?";
+            case "overload":
+                return "这会儿咨询的人有点多,我缓一下马上回复您~";
+            case "empty_reply":
+                return "嗯,我再确认一下具体情况。";
+            case "unknown":
+            default:
+                return "不好意思,我需要确认一下,稍等哈~";
+        }
     }
 
     @Override
     public long calculateHumanLikeDelay(String message, Map<String, Object> context) {
-        return 0;
+        if (message == null || message.isEmpty()) return 1000L;
+
+        int length = message.length();
+        // 模拟真人打字速度:中文约3字/秒,加上思考时间
+        long baseDelay = length * 300L; // 每字符300ms
+
+        // 消息越长,思考时间越多
+        if (length > 100) baseDelay += 2000L;  // 长回复,多思考
+        else if (length > 50) baseDelay += 1000L;
+        else baseDelay += 500L;
+
+        // 随机波动 ±30%,避免过于机械
+        double jitter = 0.7 + Math.random() * 0.6;
+        long result = (long) (baseDelay * jitter);
+
+        // 限制在1-10秒之间
+        return Math.max(1000L, Math.min(10000L, result));
     }
 
     @Override
     public Map<String, Object> getIdentityConfig(Long companyId) {
-        return Map.of();
+        // 先从缓存获取
+        Map<String, Object> cached = configCache.get(companyId);
+        if (cached != null) return cached;
+
+        Map<String, Object> config = new LinkedHashMap<>();
+        // 默认配置
+        config.put("hideIdentity", true);
+        config.put("systemRole", null);
+        config.put("humanLikeDelay", true);
+        config.put("delayMinMs", 1000);
+        config.put("delayMaxMs", 8000);
+        config.put("useFillers", true);
+        config.put("strictMode", false);
+
+        // 从数据库加载
+        if (auxMapper != null) {
+            try {
+                List<Map<String, Object>> rows = auxMapper.selectDynamicImpls(companyId, 98);
+                if (rows != null && !rows.isEmpty()) {
+                    String content = (String) rows.get(0).get("script_content");
+                    if (content != null && !content.isEmpty()) {
+                        try {
+                            @SuppressWarnings("unchecked")
+                            Map<String, Object> dbConfig =
+                                com.alibaba.fastjson.JSON.parseObject(content, Map.class);
+                            if (dbConfig != null) config.putAll(dbConfig);
+                        } catch (Exception ignored) {}
+                    }
+                }
+            } catch (Exception e) {
+                logger.debug("[IdentityHide] 加载配置失败: {}", e.getMessage());
+            }
+        }
+
+        configCache.put(companyId, config);
+        return config;
     }
 
     @Override
     public void updateIdentityConfig(Long companyId, Map<String, Object> config) {
+        if (config == null) return;
+        // 更新缓存
+        Map<String, Object> existing = getIdentityConfig(companyId);
+        existing.putAll(config);
+        configCache.put(companyId, existing);
 
+        // 持久化到数据库
+        if (auxMapper != null) {
+            try {
+                String json = com.alibaba.fastjson.JSON.toJSONString(existing);
+                auxMapper.insertDynamicImpl(companyId, 98, "identity_config", "identity_config",
+                        json, "ACTIVE");
+            } catch (Exception e) {
+                logger.error("[IdentityHide] 保存配置失败: {}", e.getMessage());
+            }
+        }
     }
 }

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

@@ -23,10 +23,20 @@ public class DynamicNodeAdjusterImpl implements DynamicNodeAdjuster {
         if (auxMapper == null) return new ArrayList<>();
         return auxMapper.selectDynamicImpls(companyId, null);
     }
-
+    // 合并后补全的 stub 实现:提供安全的默认行为,避免启动时缺少 bean。
+    // 完整动态调节逻辑可在后续补充(依赖更多 Lobster 表和 AI 服务)。
     @Override
-    public AdjustmentResult adjustNode(Long instanceId, Long companyId, String externalUserId, String customerMessage, String currentNodeCode, Map<String, Object> variables) {
-        return null;
+    public DynamicNodeAdjuster.AdjustmentResult adjustNode(Long instanceId, Long companyId, String externalUserId,
+                                       String customerMessage, String currentNodeCode,
+                                       Map<String, Object> variables) {
+        DynamicNodeAdjuster.AdjustmentResult r = new DynamicNodeAdjuster.AdjustmentResult();
+        r.setNextNodeCode(null);           // 不改变节点
+        r.setAdjustmentReason("stub-after-merge (no dynamic adjust)");
+        r.setTransferToHuman(false);
+        r.setDetectedIntent(null);
+        r.setDetectedSentiment(null);
+        r.setUpdatedVariables(variables);
+        return r;
     }
 
     @Override
@@ -36,6 +46,6 @@ public class DynamicNodeAdjusterImpl implements DynamicNodeAdjuster {
 
     @Override
     public String detectIntent(String customerMessage) {
-        return "";
+        return null;
     }
 }

+ 59 - 7
fs-service/src/main/java/com/fs/company/service/workflow/impl/LobsterTestScenarioServiceImpl.java

@@ -1,6 +1,7 @@
 package com.fs.company.service.workflow.impl;
 
 import com.fs.company.mapper.LobsterAuxiliaryMapper;
+import com.fs.company.service.workflow.LobsterTestScenarioService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -9,31 +10,82 @@ import org.springframework.stereotype.Service;
 import java.util.*;
 
 @Service
-public class LobsterTestScenarioServiceImpl {
+public class LobsterTestScenarioServiceImpl implements LobsterTestScenarioService {
 
     private static final Logger logger = LoggerFactory.getLogger(LobsterTestScenarioServiceImpl.class);
 
     @Autowired(required = false)
     private LobsterAuxiliaryMapper auxMapper;
 
-    public List<Map<String, Object>> list(Long companyId) {
+    @Override
+    public List<Map<String, Object>> listScenarios(Long companyId, Integer enabled, Integer pageNum, Integer pageSize) {
         if (auxMapper == null) return new ArrayList<>();
         return auxMapper.selectTestScenarios(companyId);
     }
 
-    public Map<String, Object> getById(Long id, Long companyId) {
+    @Override
+    public Map<String, Object> getScenario(Long id) {
         if (auxMapper == null) return null;
-        return auxMapper.selectTestScenarioById(id, companyId);
+        return auxMapper.selectTestScenarioById(id, 0L);
     }
 
-    public Long save(Long companyId, String name, Long workflowId, String testData) {
+    @Override
+    public Long createScenario(Map<String, Object> params) {
         if (auxMapper == null) return null;
+        Long companyId = toLong(params.get("companyId"));
+        String name = (String) params.getOrDefault("name", "");
+        Long workflowId = toLong(params.get("workflowId"));
+        String testData = (String) params.getOrDefault("testData", "");
         auxMapper.insertTestScenario(companyId, name, workflowId, testData);
         return auxMapper.selectLastInsertId();
     }
 
-    public void update(Long id, String name, String testData) { if (auxMapper != null) auxMapper.updateTestScenario(id, name, testData); }
-    public void delete(Long id, Long companyId) { if (auxMapper != null) auxMapper.deleteTestScenario(id, companyId); }
+    @Override
+    public void updateScenario(Long id, Map<String, Object> params) {
+        if (auxMapper == null) return;
+        String name = (String) params.getOrDefault("name", null);
+        String testData = (String) params.getOrDefault("testData", null);
+        auxMapper.updateTestScenario(id, name, testData);
+    }
+
+    @Override
+    public void deleteScenario(Long id) {
+        if (auxMapper != null) auxMapper.deleteTestScenario(id, 0L);
+    }
+
+    @Override
+    public String runScenarioNow(Long id) {
+        String runId = UUID.randomUUID().toString().substring(0, 8);
+        logger.info("[Scenario] runScenarioNow id={} runId={}", id, runId);
+        // 异步执行由上层调度触发
+        return runId;
+    }
+
+    @Override
+    public int runAllEnabledScenarios() {
+        if (auxMapper == null) return 0;
+        List<Map<String, Object>> list = auxMapper.selectTestScenarios(null);
+        int count = 0;
+        for (Map<String, Object> s : list) {
+            Object enabled = s.get("enabled");
+            if (enabled != null && Integer.valueOf(enabled.toString()) == 1) {
+                Object idObj = s.get("id");
+                if (idObj != null) {
+                    runScenarioNow(Long.valueOf(idObj.toString()));
+                    count++;
+                }
+            }
+        }
+        return count;
+    }
+
+    // ---- 辅助方法 ----
+
+    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; }
+    }
 
     public void recordResult(Long companyId, Long scenarioId, boolean passed, String detail) {
         if (auxMapper == null) return;

+ 57 - 1
fs-service/src/main/java/com/fs/company/service/workflow/learning/impl/TenantLearningEngineImpl.java

@@ -206,7 +206,63 @@ public class TenantLearningEngineImpl implements TenantLearningEngine {
         } catch (Exception e) { return 0; }
     }
 
-    private int analyzeCustomerProfileCorrelations(Long companyId) { return 0; }
+    private int analyzeCustomerProfileCorrelations(Long companyId) {
+        if (learningMapper == null) return 0;
+        try {
+            // 从replay buffer中分析客户画像与对话效果的关联
+            List<Map<String, Object>> events = learningMapper.selectReplayBuffer(companyId);
+            if (events == null || events.isEmpty()) return 0;
+
+            int discoveries = 0;
+            Map<String, Integer> profileStats = new LinkedHashMap<>();
+            Map<String, Integer> profileWins = new LinkedHashMap<>();
+
+            for (Map<String, Object> event : events) {
+                String customerMessage = (String) event.get("customer_message");
+                Integer qualityScore = event.get("quality_score") instanceof Number
+                        ? ((Number) event.get("quality_score")).intValue() : null;
+
+                if (customerMessage == null || customerMessage.isEmpty()) continue;
+
+                String profileTag = inferProfileTag(customerMessage);
+                profileStats.merge(profileTag, 1, Integer::sum);
+                if (qualityScore != null && qualityScore >= 120) {
+                    profileWins.merge(profileTag, 1, Integer::sum);
+                }
+            }
+
+            for (String tag : profileStats.keySet()) {
+                int total = profileStats.getOrDefault(tag, 0);
+                int wins = profileWins.getOrDefault(tag, 0);
+                if (total >= 3) {
+                    double rate = wins * 100.0 / total;
+                    String insight = "画像[" + tag + "]: 总交互" + total + "次,高质量率" + String.format("%.0f%%", rate);
+                    learningMapper.upsertPattern(companyId, "profile", tag, insight, rate / 100.0, "ProfileAnalyzer");
+                    discoveries++;
+                }
+            }
+
+            return discoveries;
+        } catch (Exception e) { return 0; }
+    }
+
+    /** 从消息内容中推断客户画像标签 */
+    private String inferProfileTag(String message) {
+        if (message == null || message.isEmpty()) return "unknown";
+        if (message.contains("价格") || message.contains("优惠") || message.contains("便宜") || message.contains("贵"))
+            return "price_sensitive";
+        if (message.contains("品牌") || message.contains("质量") || message.contains("正品"))
+            return "quality_seeker";
+        if (message.contains("急") || message.contains("快") || message.contains("马上") || message.contains("立刻"))
+            return "urgent";
+        if (message.contains("推荐") || message.contains("哪个好") || message.contains("帮我选"))
+            return "guidance_seeker";
+        if (message.contains("退货") || message.contains("退款") || message.contains("投诉"))
+            return "at_risk";
+        if (message.contains("可以") || message.contains("好") || message.contains("喜欢") || message.contains("不错"))
+            return "satisfied";
+        return "general";
+    }
 
     private int generateStrategyRecommendations(Long companyId) {
         if (learningMapper == null) return 0;

+ 36 - 2
fs-service/src/main/java/com/fs/company/service/workflow/queue/DeadLetterQueue.java

@@ -61,12 +61,46 @@ public class DeadLetterQueue {
         return result;
     }
 
+    /**
+     * 获取待重试死信数量(retry_count < max_retries 的消息)
+     */
     public int getPendingCount() {
-        return getDeadLetterList().size();
+        if (auxMapper == null) return 0;
+        int pending = 0;
+        try {
+            List<Map<String, Object>> rows = auxMapper.selectDeadLetters(null, 500);
+            if (rows != null) {
+                for (Map<String, Object> row : rows) {
+                    Object retryCount = row.get("retry_count");
+                    int count = retryCount instanceof Number ? ((Number) retryCount).intValue() : 0;
+                    if (count < 3) pending++;
+                }
+            }
+        } catch (Exception e) {
+            logger.warn("[DeadLetter] getPendingCount failed: {}", e.getMessage());
+        }
+        return pending;
     }
 
+    /**
+     * 获取已死消息数量(retry_count >= max_retries 的消息,需人工处理)
+     */
     public int getDeadCount() {
-        return getDeadLetterList().size();
+        if (auxMapper == null) return 0;
+        int dead = 0;
+        try {
+            List<Map<String, Object>> rows = auxMapper.selectDeadLetters(null, 500);
+            if (rows != null) {
+                for (Map<String, Object> row : rows) {
+                    Object retryCount = row.get("retry_count");
+                    int count = retryCount instanceof Number ? ((Number) retryCount).intValue() : 0;
+                    if (count >= 3) dead++;
+                }
+            }
+        } catch (Exception e) {
+            logger.warn("[DeadLetter] getDeadCount failed: {}", e.getMessage());
+        }
+        return dead;
     }
 
     public int retryAllDead(Predicate<DeadMessage> filter) {

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

@@ -1,207 +1,207 @@
-package com.fs.company.service.workflow.scheduler;
-
-import com.fs.company.domain.CompanyWorkflowLobster;
-import com.fs.company.mapper.CompanyWorkflowLobsterMapper;
-import com.fs.company.service.workflow.LobsterWorkflowExecutor;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.scheduling.annotation.Scheduled;
-import org.springframework.stereotype.Component;
-
-import javax.annotation.PostConstruct;
-import java.time.LocalDateTime;
-import java.time.LocalTime;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
-
-/**
- * 工作流触发调度器(skill.md 要求)
- * <p>
- * 三种触发机制:
- *   1. 时间触发 — Cron / 固定时间 → 扫描启用的工作流并触发
- *   2. 事件触发 — 客户消息到达时由消息处理器主动调用 fire(eventType)
- *   3. 条件触发 — 定时巡检触发条件(活跃天数/沉默小时数等)
- * <p>
- * 实现:使用 Spring @Scheduled(每分钟检查一次),由 @EnableScheduling 启用
- */
-@Component
-public class WorkflowTriggerScheduler {
-
-    private static final Logger log = LoggerFactory.getLogger(WorkflowTriggerScheduler.class);
-
-    @Autowired(required = false)
-    private CompanyWorkflowLobsterMapper workflowMapper;
-
-    @Autowired(required = false)
-    private LobsterWorkflowExecutor workflowExecutor;
-
-    @Autowired(required = false)
-    private com.fs.company.service.workflow.LobsterTestScenarioService testScenarioService;
-
-    /** 最近一次触发时间(防重复) — workflowId → lastFireTime */
-    private final ConcurrentHashMap<Long, LocalDateTime> lastFireTime = new ConcurrentHashMap<>();
-
-    @PostConstruct
-    public void init() {
-        log.info("[WorkflowTriggerScheduler] 启动,每分钟扫描一次工作流触发");
-    }
-
-    /**
-     * 每天凌晨 3 点跑全部启用的 E2E 测试场景(回归测试)
-     */
-    @Scheduled(cron = "0 0 3 * * ?")
-    public void runDailyE2eRegression() {
-        if (testScenarioService == null) return;
-        try {
-            int n = testScenarioService.runAllEnabledScenarios();
-            log.info("[E2E回归] 已触发 {} 个测试场景", n);
-        } catch (Exception ex) {
-            log.error("[E2E回归] 失败: {}", ex.getMessage(), ex);
-        }
-    }
-
-    /**
-     * 每分钟扫描一次启用状态的工作流,按其触发配置决定是否触发
-     */
-    @Scheduled(cron = "0 * * * * ?")
-    public void scanAndTrigger() {
-        if (workflowMapper == null) return;
-        try {
-            CompanyWorkflowLobster q = new CompanyWorkflowLobster();
-            q.setStatus(1); // 仅启用
-            List<CompanyWorkflowLobster> list = workflowMapper.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>(q));
-            if (list == null || list.isEmpty()) return;
-
-            LocalDateTime now = LocalDateTime.now();
-            int triggered = 0;
-            for (CompanyWorkflowLobster wf : list) {
-                if (shouldTrigger(wf, now)) {
-                    fire(wf, now);
-                    triggered++;
-                }
-            }
-            if (triggered > 0) {
-                log.info("[WorkflowTriggerScheduler] 本轮触发 {} 个工作流", triggered);
-            }
-        } catch (Exception e) {
-            log.error("[WorkflowTriggerScheduler] 扫描异常: {}", e.getMessage(), e);
-        }
-    }
-
-    /** 事件触发入口(消息到达/订单创建等场景,由业务方主动调用) */
-    public void fireByEvent(String eventType, Long companyId, Object payload) {
-        log.info("[WorkflowTriggerScheduler] 事件触发 type={} company={} payload={}", eventType, companyId, payload);
-        if (workflowMapper == null || workflowExecutor == null) return;
-        try {
-            CompanyWorkflowLobster q = new CompanyWorkflowLobster();
-            q.setCompanyId(companyId);
-            q.setStatus(1);
-            List<CompanyWorkflowLobster> list = workflowMapper.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>(q));
-            if (list == null) return;
-            for (CompanyWorkflowLobster wf : list) {
-                String evtCfg = parseEventTypeFromCanvas(wf.getCanvasData());
-                if (eventType != null && eventType.equalsIgnoreCase(evtCfg)) {
-                    Map<String, Object> vars = new HashMap<>();
-                    vars.put("eventType", eventType);
-                    if (payload != null) vars.put("payload", payload);
-                    Long contactId = payload instanceof Map ?
-                            (Long) ((Map<?, ?>) payload).get("contactId") : null;
-                    try {
-                        workflowExecutor.startWorkflow(companyId, wf.getId(), contactId, vars);
-                        log.info("[WorkflowTriggerScheduler] 事件 {} 启动工作流 id={}", eventType, wf.getId());
-                    } catch (Exception ex) {
-                        log.warn("[WorkflowTriggerScheduler] 启动工作流 {} 失败: {}", wf.getId(), ex.getMessage());
-                    }
-                }
-            }
-        } catch (Exception e) {
-            log.error("[WorkflowTriggerScheduler] 事件触发异常: {}", e.getMessage(), e);
-        }
-    }
-
-    // ════════════ 私有方法 ════════════
-
-    /**
-     * 判断工作流是否应在当前时刻触发
-     * 规则:
-     *   - 优先解析 canvasData 中的 startNode.config.fireTime(HH:mm)
-     *   - 同一工作流 1 分钟内不重复触发
-     */
-    private boolean shouldTrigger(CompanyWorkflowLobster wf, LocalDateTime now) {
-        if (wf == null || wf.getId() == null) return false;
-        LocalDateTime last = lastFireTime.get(wf.getId());
-        if (last != null && java.time.Duration.between(last, now).getSeconds() < 60) {
-            return false;
-        }
-        String fireTime = parseFireTimeFromCanvas(wf.getCanvasData());
-        if (fireTime != null && fireTime.matches("\\d{2}:\\d{2}")) {
-            try {
-                LocalTime target = LocalTime.parse(fireTime);
-                LocalTime curr = now.toLocalTime();
-                return target.getHour() == curr.getHour() && target.getMinute() == curr.getMinute();
-            } catch (Exception ignored) {}
-        }
-        return false;
-    }
-
-    /** 从 canvasData JSON 抓 startNode.config.fireTime(HH:mm) */
-    private String parseFireTimeFromCanvas(String canvasData) {
-        if (canvasData == null || canvasData.isEmpty()) return null;
-        try {
-            com.alibaba.fastjson.JSONObject json = com.alibaba.fastjson.JSON.parseObject(canvasData);
-            com.alibaba.fastjson.JSONArray nodes = json.getJSONArray("nodes");
-            if (nodes == null) return null;
-            for (int i = 0; i < nodes.size(); i++) {
-                com.alibaba.fastjson.JSONObject node = nodes.getJSONObject(i);
-                if (node == null) continue;
-                Integer type = node.getInteger("type");
-                if (type != null && type == 1) { // 1=开始节点
-                    com.alibaba.fastjson.JSONObject cfg = node.getJSONObject("config");
-                    if (cfg != null) return cfg.getString("fireTime");
-                }
-            }
-        } catch (Exception ignored) {}
-        return null;
-    }
-
-    private void fire(CompanyWorkflowLobster wf, LocalDateTime now) {
-        lastFireTime.put(wf.getId(), now);
-        log.info("[WorkflowTriggerScheduler] 触发工作流 id={} template={}", wf.getId(), wf.getTemplateName());
-        if (workflowExecutor == null) {
-            log.warn("[WorkflowTriggerScheduler] workflowExecutor 未注入,跳过");
-            return;
-        }
-        try {
-            Map<String, Object> vars = new HashMap<>();
-            vars.put("triggerSource", "scheduler");
-            vars.put("triggerTime", now.toString());
-            // 定时触发不绑定具体 contact,contactId=null
-            workflowExecutor.startWorkflow(wf.getCompanyId(), wf.getId(), null, vars);
-        } catch (Exception e) {
-            log.error("[WorkflowTriggerScheduler] 工作流 {} 执行失败: {}", wf.getId(), e.getMessage(), e);
-        }
-    }
-
-    /** 从 canvasData startNode.config.eventType 解析事件触发类型 */
-    private String parseEventTypeFromCanvas(String canvasData) {
-        if (canvasData == null || canvasData.isEmpty()) return null;
-        try {
-            com.alibaba.fastjson.JSONObject json = com.alibaba.fastjson.JSON.parseObject(canvasData);
-            com.alibaba.fastjson.JSONArray nodes = json.getJSONArray("nodes");
-            if (nodes == null) return null;
-            for (int i = 0; i < nodes.size(); i++) {
-                com.alibaba.fastjson.JSONObject node = nodes.getJSONObject(i);
-                if (node == null) continue;
-                Integer type = node.getInteger("type");
-                if (type != null && type == 1) {
-                    com.alibaba.fastjson.JSONObject cfg = node.getJSONObject("config");
-                    if (cfg != null) return cfg.getString("eventType");
-                }
-            }
-        } catch (Exception ignored) {}
-        return null;
-    }
-}
+//package com.fs.company.service.workflow.scheduler;
+//
+//import com.fs.company.domain.CompanyWorkflowLobster;
+//import com.fs.company.mapper.CompanyWorkflowLobsterMapper;
+//import com.fs.company.service.workflow.LobsterWorkflowExecutor;
+//import org.slf4j.Logger;
+//import org.slf4j.LoggerFactory;
+//import org.springframework.beans.factory.annotation.Autowired;
+//import org.springframework.scheduling.annotation.Scheduled;
+//import org.springframework.stereotype.Component;
+//
+//import javax.annotation.PostConstruct;
+//import java.time.LocalDateTime;
+//import java.time.LocalTime;
+//import java.util.HashMap;
+//import java.util.List;
+//import java.util.Map;
+//import java.util.concurrent.ConcurrentHashMap;
+//
+///**
+// * 工作流触发调度器(skill.md 要求)
+// * <p>
+// * 三种触发机制:
+// *   1. 时间触发 — Cron / 固定时间 → 扫描启用的工作流并触发
+// *   2. 事件触发 — 客户消息到达时由消息处理器主动调用 fire(eventType)
+// *   3. 条件触发 — 定时巡检触发条件(活跃天数/沉默小时数等)
+// * <p>
+// * 实现:使用 Spring @Scheduled(每分钟检查一次),由 @EnableScheduling 启用
+// */
+//@Component
+//public class WorkflowTriggerScheduler {
+//
+//    private static final Logger log = LoggerFactory.getLogger(WorkflowTriggerScheduler.class);
+//
+//    @Autowired(required = false)
+//    private CompanyWorkflowLobsterMapper workflowMapper;
+//
+//    @Autowired(required = false)
+//    private LobsterWorkflowExecutor workflowExecutor;
+//
+//    @Autowired(required = false)
+//    private com.fs.company.service.workflow.LobsterTestScenarioService testScenarioService;
+//
+//    /** 最近一次触发时间(防重复) — workflowId → lastFireTime */
+//    private final ConcurrentHashMap<Long, LocalDateTime> lastFireTime = new ConcurrentHashMap<>();
+//
+//    @PostConstruct
+//    public void init() {
+//        log.info("[WorkflowTriggerScheduler] 启动,每分钟扫描一次工作流触发");
+//    }
+//
+//    /**
+//     * 每天凌晨 3 点跑全部启用的 E2E 测试场景(回归测试)
+//     */
+//    @Scheduled(cron = "0 0 3 * * ?")
+//    public void runDailyE2eRegression() {
+//        if (testScenarioService == null) return;
+//        try {
+//            int n = testScenarioService.runAllEnabledScenarios();
+//            log.info("[E2E回归] 已触发 {} 个测试场景", n);
+//        } catch (Exception ex) {
+//            log.error("[E2E回归] 失败: {}", ex.getMessage(), ex);
+//        }
+//    }
+//
+//    /**
+//     * 每分钟扫描一次启用状态的工作流,按其触发配置决定是否触发
+//     */
+//    @Scheduled(cron = "0 * * * * ?")
+//    public void scanAndTrigger() {
+//        if (workflowMapper == null) return;
+//        try {
+//            CompanyWorkflowLobster q = new CompanyWorkflowLobster();
+//            q.setStatus(1); // 仅启用
+//            List<CompanyWorkflowLobster> list = workflowMapper.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>(q));
+//            if (list == null || list.isEmpty()) return;
+//
+//            LocalDateTime now = LocalDateTime.now();
+//            int triggered = 0;
+//            for (CompanyWorkflowLobster wf : list) {
+//                if (shouldTrigger(wf, now)) {
+//                    fire(wf, now);
+//                    triggered++;
+//                }
+//            }
+//            if (triggered > 0) {
+//                log.info("[WorkflowTriggerScheduler] 本轮触发 {} 个工作流", triggered);
+//            }
+//        } catch (Exception e) {
+//            log.error("[WorkflowTriggerScheduler] 扫描异常: {}", e.getMessage(), e);
+//        }
+//    }
+//
+//    /** 事件触发入口(消息到达/订单创建等场景,由业务方主动调用) */
+//    public void fireByEvent(String eventType, Long companyId, Object payload) {
+//        log.info("[WorkflowTriggerScheduler] 事件触发 type={} company={} payload={}", eventType, companyId, payload);
+//        if (workflowMapper == null || workflowExecutor == null) return;
+//        try {
+//            CompanyWorkflowLobster q = new CompanyWorkflowLobster();
+//            q.setCompanyId(companyId);
+//            q.setStatus(1);
+//            List<CompanyWorkflowLobster> list = workflowMapper.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>(q));
+//            if (list == null) return;
+//            for (CompanyWorkflowLobster wf : list) {
+//                String evtCfg = parseEventTypeFromCanvas(wf.getCanvasData());
+//                if (eventType != null && eventType.equalsIgnoreCase(evtCfg)) {
+//                    Map<String, Object> vars = new HashMap<>();
+//                    vars.put("eventType", eventType);
+//                    if (payload != null) vars.put("payload", payload);
+//                    Long contactId = payload instanceof Map ?
+//                            (Long) ((Map<?, ?>) payload).get("contactId") : null;
+//                    try {
+//                        workflowExecutor.startWorkflow(companyId, wf.getId(), contactId, vars);
+//                        log.info("[WorkflowTriggerScheduler] 事件 {} 启动工作流 id={}", eventType, wf.getId());
+//                    } catch (Exception ex) {
+//                        log.warn("[WorkflowTriggerScheduler] 启动工作流 {} 失败: {}", wf.getId(), ex.getMessage());
+//                    }
+//                }
+//            }
+//        } catch (Exception e) {
+//            log.error("[WorkflowTriggerScheduler] 事件触发异常: {}", e.getMessage(), e);
+//        }
+//    }
+//
+//    // ════════════ 私有方法 ════════════
+//
+//    /**
+//     * 判断工作流是否应在当前时刻触发
+//     * 规则:
+//     *   - 优先解析 canvasData 中的 startNode.config.fireTime(HH:mm)
+//     *   - 同一工作流 1 分钟内不重复触发
+//     */
+//    private boolean shouldTrigger(CompanyWorkflowLobster wf, LocalDateTime now) {
+//        if (wf == null || wf.getId() == null) return false;
+//        LocalDateTime last = lastFireTime.get(wf.getId());
+//        if (last != null && java.time.Duration.between(last, now).getSeconds() < 60) {
+//            return false;
+//        }
+//        String fireTime = parseFireTimeFromCanvas(wf.getCanvasData());
+//        if (fireTime != null && fireTime.matches("\\d{2}:\\d{2}")) {
+//            try {
+//                LocalTime target = LocalTime.parse(fireTime);
+//                LocalTime curr = now.toLocalTime();
+//                return target.getHour() == curr.getHour() && target.getMinute() == curr.getMinute();
+//            } catch (Exception ignored) {}
+//        }
+//        return false;
+//    }
+//
+//    /** 从 canvasData JSON 抓 startNode.config.fireTime(HH:mm) */
+//    private String parseFireTimeFromCanvas(String canvasData) {
+//        if (canvasData == null || canvasData.isEmpty()) return null;
+//        try {
+//            com.alibaba.fastjson.JSONObject json = com.alibaba.fastjson.JSON.parseObject(canvasData);
+//            com.alibaba.fastjson.JSONArray nodes = json.getJSONArray("nodes");
+//            if (nodes == null) return null;
+//            for (int i = 0; i < nodes.size(); i++) {
+//                com.alibaba.fastjson.JSONObject node = nodes.getJSONObject(i);
+//                if (node == null) continue;
+//                Integer type = node.getInteger("type");
+//                if (type != null && type == 1) { // 1=开始节点
+//                    com.alibaba.fastjson.JSONObject cfg = node.getJSONObject("config");
+//                    if (cfg != null) return cfg.getString("fireTime");
+//                }
+//            }
+//        } catch (Exception ignored) {}
+//        return null;
+//    }
+//
+//    private void fire(CompanyWorkflowLobster wf, LocalDateTime now) {
+//        lastFireTime.put(wf.getId(), now);
+//        log.info("[WorkflowTriggerScheduler] 触发工作流 id={} template={}", wf.getId(), wf.getTemplateName());
+//        if (workflowExecutor == null) {
+//            log.warn("[WorkflowTriggerScheduler] workflowExecutor 未注入,跳过");
+//            return;
+//        }
+//        try {
+//            Map<String, Object> vars = new HashMap<>();
+//            vars.put("triggerSource", "scheduler");
+//            vars.put("triggerTime", now.toString());
+//            // 定时触发不绑定具体 contact,contactId=null
+//            workflowExecutor.startWorkflow(wf.getCompanyId(), wf.getId(), null, vars);
+//        } catch (Exception e) {
+//            log.error("[WorkflowTriggerScheduler] 工作流 {} 执行失败: {}", wf.getId(), e.getMessage(), e);
+//        }
+//    }
+//
+//    /** 从 canvasData startNode.config.eventType 解析事件触发类型 */
+//    private String parseEventTypeFromCanvas(String canvasData) {
+//        if (canvasData == null || canvasData.isEmpty()) return null;
+//        try {
+//            com.alibaba.fastjson.JSONObject json = com.alibaba.fastjson.JSON.parseObject(canvasData);
+//            com.alibaba.fastjson.JSONArray nodes = json.getJSONArray("nodes");
+//            if (nodes == null) return null;
+//            for (int i = 0; i < nodes.size(); i++) {
+//                com.alibaba.fastjson.JSONObject node = nodes.getJSONObject(i);
+//                if (node == null) continue;
+//                Integer type = node.getInteger("type");
+//                if (type != null && type == 1) {
+//                    com.alibaba.fastjson.JSONObject cfg = node.getJSONObject("config");
+//                    if (cfg != null) return cfg.getString("eventType");
+//                }
+//            }
+//        } catch (Exception ignored) {}
+//        return null;
+//    }
+//}

+ 3 - 0
fs-service/src/main/java/com/fs/hisStore/param/FsStorePaymentPayParam.java

@@ -22,4 +22,7 @@ public class FsStorePaymentPayParam implements Serializable
     private String code;
 
     private String appId;
+
+    // 租户编码
+    private String tenantCode;
 }

+ 1 - 1
fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreOrderScrmServiceImpl.java

@@ -4522,7 +4522,7 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
                     payConfig.setSubAppId(org.apache.commons.lang3.StringUtils.trimToNull(null));
                     payConfig.setSubMchId(org.apache.commons.lang3.StringUtils.trimToNull(null));
                     payConfig.setKeyPath(fsPayConfig.getKeyPath());
-                    payConfig.setNotifyUrl(fsPayConfig.getNotifyUrlScrm());
+                    payConfig.setNotifyUrl(fsPayConfig.getNotifyUrlScrm()+"/"+request.getHeader(HEADER_TENANT_CODE));// 加上租户的编码
                     wxPayService.setConfig(payConfig);
                     WxPayUnifiedOrderRequest orderRequest = new WxPayUnifiedOrderRequest();
                     orderRequest.setOpenid(user.getMaOpenId());//公众号支付提供用户openid

+ 1 - 1
fs-service/src/main/java/com/fs/hisStore/service/impl/FsStorePaymentScrmServiceImpl.java

@@ -958,7 +958,7 @@ public class FsStorePaymentScrmServiceImpl implements IFsStorePaymentScrmService
                 payConfig.setSubAppId(org.apache.commons.lang3.StringUtils.trimToNull(null));
                 payConfig.setSubMchId(org.apache.commons.lang3.StringUtils.trimToNull(null));
                 payConfig.setKeyPath(null);
-                payConfig.setNotifyUrl(fsPayConfig.getNotifyUrlScrm());
+                payConfig.setNotifyUrl(fsPayConfig.getNotifyUrlScrm()+"/"+param.getTenantCode());
                 wxPayService.setConfig(payConfig);
                 WxPayUnifiedOrderRequest orderRequest = new WxPayUnifiedOrderRequest();
                 orderRequest.setOpenid(openId);//公众号支付提供用户openid

+ 2 - 2
fs-service/src/main/java/com/fs/qw/mapper/QwGroupChatMapper.java

@@ -48,7 +48,7 @@ public interface QwGroupChatMapper
             "FROM " +
             "    qw_group_chat gc " +
             "LEFT JOIN qw_group_chat_user gcu ON gc.chat_id = gcu.chat_id  " +
-            " left join qw_user qu on gc.owner=qu.qw_user_id  and qu.is_del=0 and qu.corp_id=gc.corp_id " +
+            " left join qw_user qu on gc.owner=qu.qw_open_user_id  and qu.is_del=0 and qu.corp_id=gc.corp_id " +
             " left join company_user cu on cu.qw_user_id=qu.id " +
             "    AND gc.corp_id = gcu.corp_id  " +
             "<where> " +
@@ -63,7 +63,7 @@ public interface QwGroupChatMapper
             "             <if test=\"map.corpId != null  and map.corpId != ''\"> and gc.corp_id = #{map.corpId}</if>" +
             "             <if test=\"map.corpId != null  and map.corpId != ''\"> and qu.corp_id = #{map.corpId}</if>" +
             "             <if test=\"map.qwUserList != null  and map.qwUserList != ''\"> " +
-            "           and gc.owner in " +
+            "           and qu.qw_user_id in " +
             "                   <foreach  item='item' index='index' collection='qwUserIds' open='(' separator=',' close=')'> #{item}   </foreach> " +
             "           </if>" +
             "            <if test=\"map.cuDeptIdList != null and !map.cuDeptIdList.isEmpty() and  map.userType != '00' \">" +

+ 3 - 0
fs-service/src/main/java/com/fs/qw/mapper/QwUserMapper.java

@@ -423,6 +423,9 @@ public interface QwUserMapper extends BaseMapper<QwUser>
     @Select("select qw_user_id from qw_user where company_user_id = #{userId} and corp_id = #{corpId}")
     List<String> selectQwUserListByCompanyUserId(@Param("userId") Long userId,@Param("corpId") String corpId);
 
+    @Select("select qw_open_user_id from qw_user where company_user_id = #{userId} and corp_id = #{corpId}")
+    List<String> selectQwOpenUserListByCompanyUserId(@Param("userId") Long userId,@Param("corpId") String corpId);
+
     @Select("<script>" +
             "select qu.id from qw_user qu " +
             "left join company_user cu on cu.user_id=qu.company_user_id " +

+ 1 - 0
fs-service/src/main/java/com/fs/qw/service/IQwUserService.java

@@ -165,6 +165,7 @@ public interface IQwUserService
     List<QwWorkTask> selectQwWorkTaskList(SelectQwWorkTaskListParam param);
 
     List<String> selectQwUserListByCompanyUserId(Long userId,String corpId );
+    List<String> selectQwOpenUserListByCompanyUserId(Long userId, String corpId);
     List<Long> selectQwUserListByCuDeptIdList(QwSop qwSop);
 
 

+ 9 - 1
fs-service/src/main/java/com/fs/qw/service/impl/QwUserServiceImpl.java

@@ -282,6 +282,14 @@ public class QwUserServiceImpl implements IQwUserService
 
     }
 
+    @Override
+    public List<String> selectQwOpenUserListByCompanyUserId(Long userId, String corpId) {
+        return qwUserMapper.selectQwOpenUserListByCompanyUserId(userId,corpId);
+
+    }
+
+
+
     @Override
     public List<Long> selectQwUserListByCuDeptIdList(QwSop qwSop) {
         return qwUserMapper.selectQwUserListByCuDeptIdList(qwSop);
@@ -946,7 +954,7 @@ public class QwUserServiceImpl implements IQwUserService
         QwCompany qwCompany = iQwCompanyService.selectQwCompanyByCorpId(corpId);
         for (DeptUser user : deptUser) {
             String userid = user.getUserid();
-            String openUserIdRedisKey = "corpId:" + userid;
+            String openUserIdRedisKey = "corpId:" + corpId + userid;
             String  redisResult= redisCache.getCacheObject(openUserIdRedisKey);
             String openUserId = "";
             if (StringUtils.isNotBlank(redisResult)){

+ 2 - 2
fs-service/src/main/java/com/fs/qwApi/service/impl/QwApiServiceImpl.java

@@ -928,7 +928,7 @@ public class QwApiServiceImpl implements QwApiService {
 
     @Override
     public String getOpenUserid(String accessToken,String userId,String corpId) {
-        String openUserIdRedisKey = "corpId:" + userId;
+        String openUserIdRedisKey = "corpId:" + corpId + userId;
         String  redisResult= redisCache.getCacheObject(openUserIdRedisKey);
         if(StringUtils.isNotBlank(redisResult)){
             return redisResult;
@@ -973,7 +973,7 @@ public class QwApiServiceImpl implements QwApiService {
 
     @Override
     public String getOpenExternalUserid(String accessToken,String userId,String corpId) {
-        String openUserIdRedisKey = "externalUserid:corpId:" + userId;
+        String openUserIdRedisKey = "externalUserid:corpId:" + corpId + userId;
         String  redisResult= redisCache.getCacheObject(openUserIdRedisKey);
         if(StringUtils.isNotBlank(redisResult)){
             return redisResult;

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

@@ -18272,6 +18272,33 @@ CREATE TABLE `company_workflow_lobster_variable`
     KEY             `idx_workflow_var` (`workflow_id`,`var_code`),
     KEY             `idx_cwlv_workflow_del` (`workflow_id`,`del_flag`)
 ) ENGINE=InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='工作流龙虾变量';
+
+-- ----------------------------
+-- Table structure for company_tag_template_binding
+-- ----------------------------
+DROP TABLE IF EXISTS `company_tag_template_binding`;
+CREATE TABLE `company_tag_template_binding`
+(
+    `id`              bigint       NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `company_id`      bigint       NOT NULL COMMENT '企业ID',
+    `tag_code`        varchar(128) NOT NULL COMMENT '标签编码',
+    `tag_name`        varchar(128) NOT NULL COMMENT '标签名称',
+    `template_id`     bigint       NOT NULL COMMENT '绑定的工作流模板ID',
+    `template_name`   varchar(256) DEFAULT NULL COMMENT '模板名称(冗余)',
+    `priority`        int          DEFAULT '0' COMMENT '优先级(数值越大优先级越高)',
+    `match_condition` text COMMENT '匹配条件(JSON格式)',
+    `status`          tinyint      DEFAULT '1' COMMENT '状态:0-禁用, 1-启用',
+    `del_flag`        tinyint      DEFAULT '0' COMMENT '删除标志 0正常 1删除',
+    `create_by`       varchar(64)  DEFAULT '' COMMENT '创建者',
+    `create_time`     datetime     DEFAULT NULL COMMENT '创建时间',
+    `update_by`       varchar(64)  DEFAULT '' COMMENT '更新者',
+    `update_time`     datetime     DEFAULT NULL COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `idx_cttb_company_tag_template` (`company_id`,`tag_code`,`template_id`,`del_flag`),
+    KEY               `idx_cttb_company_del` (`company_id`,`del_flag`),
+    KEY               `idx_cttb_template` (`company_id`,`template_id`,`del_flag`),
+    KEY               `idx_cttb_status` (`company_id`,`status`,`del_flag`)
+) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='标签-模板绑定表';
 -- ----------------------------
 -- Table structure for company_ai_sensitive_word
 -- ----------------------------

+ 12 - 15
fs-service/src/main/resources/mapper/hisStore/FsUserScrmMapper.xml

@@ -28,7 +28,6 @@
         <result property="isDel"    column="is_del"    />
         <result property="userCode"    column="user_code"    />
         <result property="remark"    column="remark"    />
-        <result property="nickname"    column="nick_name"    />
         <result property="createTime"    column="create_time"    />
         <result property="updateTime"    column="update_time"    />
         <result property="lastIp"    column="last_ip"    />
@@ -88,7 +87,7 @@
             <if test="birthday != null ">and birthday = #{birthday}</if>
             <if test="idCard != null  and idCard != ''">and id_card = #{idCard}</if>
             <if test="remark != null  and remark != ''">and remark = #{remark}</if>
-            <if test="nickname != null  and nickname != ''">and nick_name like concat('%', #{nickname}, '%')</if>
+            <if test="nickName != null  and nickName != ''">and nick_name like concat('%', #{nickName}, '%')</if>
             <if test="avatar != null  and avatar != ''">and avatar = #{avatar}</if>
             <if test="phone != null  and phone != ''">and phone = #{phone}</if>
             <if test="lastIp != null  and lastIp != ''">and last_ip = #{lastIp}</if>
@@ -157,11 +156,11 @@
             <if test="registerCode != null   and registerCode != '' ">and register_code = #{registerCode}</if>
             <if test="source != null  and source != '' ">and source = #{source}</if>
             <if test="isShow != null  ">and is_show = #{isShow}</if>
-            <if test="(username != null  and username != '') or (userId != null  and userId != '') or (nickname != null  and nickname != '') or (phone != null  and phone != '')">
+            <if test="(username != null  and username != '') or (userId != null  and userId != '') or (nickName != null  and nickName != '') or (phone != null  and phone != '')">
                 and (
                 <if test="username != null  and username != ''">username like concat('%', #{username}, '%')</if>
                 <if test="userId != null  and userId != ''">or user_id = #{userId}</if>
-                <if test="nickname != null  and nickname != ''">or nick_name like concat('%', #{nickname}, '%')</if>
+                <if test="nickName != null  and nickName != ''">or nick_name like concat('%', #{nickName}, '%')</if>
                 <if test="phone != null  and phone != ''">or phone like concat('%',#{phone},'%')</if>
                 )
             </if>
@@ -264,8 +263,8 @@
         <if test = "maps.userId != null" >
             AND u.user_id = #{maps.userId}
         </if >
-        <if test = "maps.nickname != null and  maps.nickname !='' " >
-            AND u.nick_name LIKE CONCAT("%",#{maps.nickname},"%")
+        <if test = "maps.nickName != null and  maps.nickName !='' " >
+            AND u.nick_name LIKE CONCAT("%",#{maps.nickName},"%")
         </if >
         <if test = "maps.phone != null   and  maps.phone !='' " >
         AND u.phone LIKE CONCAT("%",#{maps.phone},"%")
@@ -339,8 +338,8 @@
         LEFT JOIN company on company.company_id = company_user.company_id
         <where>
         1 = 1 and u.nick_name is not null
-        <if test = "maps.nickname != null and  maps.nickname !='' " >
-            AND u.nick_name LIKE CONCAT("%",#{maps.nickname},"%")
+        <if test = "maps.nickName != null and  maps.nickName !='' " >
+            AND u.nick_name LIKE CONCAT("%",#{maps.nickName},"%")
         </if >
             <if test = "maps.userId != null and  maps.userId !='' " >
             AND u.user_id = #{maps.userId}
@@ -408,7 +407,6 @@
             <if test="isDel != null">is_del,</if>
             <if test="userCode != null">user_code,</if>
             <if test="remark != null">remark,</if>
-            <if test="nickname != null">nick_name,</if>
             <if test="createTime != null">create_time,</if>
             <if test="updateTime != null">update_time,</if>
             <if test="lastIp != null">last_ip,</if>
@@ -475,7 +473,6 @@
             <if test="isDel != null">#{isDel},</if>
             <if test="userCode != null">#{userCode},</if>
             <if test="remark != null">#{remark},</if>
-            <if test="nickname != null">#{nickname},</if>
             <if test="createTime != null">#{createTime},</if>
             <if test="updateTime != null">#{updateTime},</if>
             <if test="lastIp != null">#{lastIp},</if>
@@ -523,7 +520,7 @@
     <update id="updateFsUser">
         update fs_user
         <trim prefix="SET" suffixOverrides=",">
-            <if test="nickname != null">nick_name = #{nickname},</if>
+            <if test="nickName != null">nick_name = #{nickName},</if>
             <if test="avatar != null">avatar = #{avatar},</if>
             <if test="phone != null">phone = #{phone},</if>
             <if test="integral != null">integral = #{integral},</if>
@@ -631,8 +628,8 @@
                     #{item}
                 </foreach>
             </if>
-            <if test="nickname != null and nickname != ''">
-                AND fs_user.nick_name like concat('%', #{nickname},'%')
+            <if test="nickName != null and nickName != ''">
+                AND fs_user.nick_name like concat('%', #{nickName},'%')
             </if>
             <if test="phone != null and phone != ''">
                 AND fs_user.phone like concat('%', #{phone},'%')
@@ -1699,8 +1696,8 @@
                     #{item}
                 </foreach>
             </if>
-            <if test="nickname != null and nickname!=''">
-                AND fs_user.nick_name like concat(#{nickname},'%')
+            <if test="nickName != null and nickName!=''">
+                AND fs_user.nick_name like concat(#{nickName},'%')
             </if>
             <if test="phone != null and phone!=''">
                 AND fs_user.phone like concat('%', #{phone},'%')

+ 20 - 0
fs-service/src/main/resources/mapper/lobster/LobsterTenantLearningMapper.xml

@@ -128,6 +128,26 @@
         DELETE FROM company_lobster_sensitive_word WHERE id=#{id} AND company_id=#{companyId}
     </delete>
 
+    <!-- === company_lobster_dedup_config === -->
+    <select id="selectDedupConfigs" resultType="java.util.Map">
+        SELECT * FROM company_lobster_dedup_config WHERE company_id = #{companyId}
+    </select>
+    <insert id="insertDedupConfig" useGeneratedKeys="true" keyProperty="id">
+        INSERT INTO company_lobster_dedup_config(company_id, config_name, dedup_mode, exact_window_size,
+            semantic_threshold, window_duration_seconds, ignore_prefix_count, enabled, remark, create_time)
+        VALUES(#{companyId}, #{configName}, #{dedupMode}, #{exactWindowSize},
+            #{semanticThreshold}, #{windowDurationSeconds}, #{ignorePrefixCount}, #{enabled}, #{remark}, NOW())
+    </insert>
+    <update id="updateDedupConfig">
+        UPDATE company_lobster_dedup_config SET config_name=#{configName}, dedup_mode=#{dedupMode},
+            exact_window_size=#{exactWindowSize}, semantic_threshold=#{semanticThreshold},
+            window_duration_seconds=#{windowDurationSeconds}, ignore_prefix_count=#{ignorePrefixCount},
+            enabled=#{enabled}, remark=#{remark}, update_time=NOW() WHERE id=#{id}
+    </update>
+    <delete id="deleteDedupConfig">
+        DELETE FROM company_lobster_dedup_config WHERE id=#{id} AND company_id=#{companyId}
+    </delete>
+
     <!-- === 通用分页 === -->
     <select id="selectPaged" resultType="java.util.Map">
         SELECT * FROM ${table} WHERE company_id = #{companyId}

+ 7 - 1
fs-user-app/src/main/java/com/fs/app/controller/store/PaymentScrmController.java

@@ -19,6 +19,7 @@ import com.fs.wx.miniapp.config.WxMaProperties;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
 import me.chanjar.weixin.common.error.WxErrorException;
+import org.apache.commons.lang3.StringUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -74,9 +75,14 @@ public class PaymentScrmController extends AppBaseController {
     @ApiOperation("收款订单支付")
     @PostMapping("/paymentByWxaCode")
     @RepeatSubmit
-    public R paymentByWxaCode(@Validated @RequestBody FsStorePaymentPayParam payment)
+    public R paymentByWxaCode(@Validated @RequestBody FsStorePaymentPayParam payment, HttpServletRequest request)
     {
         payment.setUserId(Long.parseLong(getUserId()));
+        payment.setTenantCode(request.getHeader("X-Tenant-Code"));
+        if (StringUtils.isEmpty(payment.getTenantCode())){
+            logger.info("未配置租户");
+            return R.error("未配置租户");
+        }
         return paymentService.paymentByWxaCode(payment);
     }
 

+ 35 - 21
fs-user-app/src/main/java/com/fs/app/controller/store/WxPayScrmController.java

@@ -1,13 +1,16 @@
 package com.fs.app.controller.store;
 
 
+import com.fs.common.config.RedisTenantContext;
 import com.fs.common.core.controller.BaseController;
 import com.fs.common.core.domain.R;
-import com.fs.course.service.IFsCourseRedPacketLogService;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.framework.datasource.TenantDataSourceManager;
 import com.fs.hisStore.param.WxSendRedPacketParam;
 import com.fs.hisStore.service.*;
 import com.fs.sop.service.ISopUserLogsInfoService;
-import com.fs.system.service.ISysConfigService;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.service.TenantInfoService;
 import com.github.binarywang.wxpay.bean.notify.WxPayNotifyResponse;
 import com.github.binarywang.wxpay.bean.notify.WxPayOrderNotifyResult;
 import com.github.binarywang.wxpay.service.WxPayService;
@@ -17,8 +20,6 @@ import org.apache.commons.io.IOUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.context.ApplicationEventPublisher;
-import org.springframework.transaction.annotation.Transactional;
 import org.springframework.web.bind.annotation.*;
 
 import javax.servlet.http.HttpServletRequest;
@@ -36,22 +37,15 @@ public class WxPayScrmController {
     @Autowired
     private IFsStoreOrderScrmService orderService;
     @Autowired
-    private IFsStoreOrderStatusScrmService orderStatusService;
-    @Autowired
-    private IFsUserBillScrmService billService;
-    @Autowired
-    private IFsUserScrmService userService;
-    @Autowired
-    private ApplicationEventPublisher publisher;
-    @Autowired
     private IFsStorePaymentScrmService storePaymentService;
 
     @Autowired
-    private IFsCourseRedPacketLogService redPacketLogService;
+    private ISopUserLogsInfoService iSopUserLogsInfoService;
     @Autowired
-    private ISysConfigService configService;
+    private TenantInfoService tenantInfoService;
     @Autowired
-    private ISopUserLogsInfoService iSopUserLogsInfoService;
+    private TenantDataSourceManager tenantDataSourceManager;
+
 
     /**
      * 微信回调
@@ -61,21 +55,34 @@ public class WxPayScrmController {
      * @throws Exception
      */
     @ApiOperation("微信回调")
-    @PostMapping("/wxPayNotify")
-    @Transactional
-    public String wxPayNotify(HttpServletRequest request) throws Exception {
+    @PostMapping({"/wxPayNotify", "/wxPayNotify/{tenantCode}"})
+    public String wxPayNotify(@PathVariable(value = "tenantCode", required = false) String tenantCode,
+                              HttpServletRequest request) throws Exception {
         logger.info("====================进入微信回调接口===================");
+        boolean switched = false;
         try {
+            if (tenantCode != null && !tenantCode.isEmpty()) {
+                TenantInfo tenant = tenantInfoService.selectTenantInfoByCode(tenantCode);
+                if (tenant != null) {
+                    tenantDataSourceManager.switchTenant(tenant);
+                    RedisTenantContext.setTenantId(tenant.getId());
+                    switched = true;
+                    logger.info("微信回调切库成功: tenantCode={}, tenantId={}", tenantCode, tenant.getId());
+                } else {
+                    logger.error("微信回调切库失败-租户不存在: tenantCode={}", tenantCode);
+                    return WxPayNotifyResponse.fail("租户不存在");
+                }
+            }
+
             String xmlResult = IOUtils.toString(request.getInputStream(), request.getCharacterEncoding());
             logger.info("xml result:{}", xmlResult);
             WxPayOrderNotifyResult result = wxPayService.parseOrderNotifyResult(xmlResult,"MD5");
-            System.out.println(result.getReturnCode());
+//            WxPayOrderNotifyResult result = WxPayOrderNotifyResult.fromXML(xmlResult);
+//            logger.info("微信回调结果:{}", result);
             if("SUCCESS".equals(result.getReturnCode())){
                 //订单号
                 String outtradeno = result.getOutTradeNo();
                 String tradeNo = result.getTransactionId();
-                System.out.println(outtradeno);
-                System.out.println(tradeNo);
                 String[] orderId=outtradeno.split("-");
                 switch (orderId[0]){
                     case "store":
@@ -96,6 +103,13 @@ public class WxPayScrmController {
         } catch (Exception e) {
             logger.error("微信回调结果异常,异常原因{}", e.getMessage());
             return WxPayNotifyResponse.fail(e.getMessage());
+        } finally {
+            if (switched) {
+                RedisTenantContext.clear();
+                tenantDataSourceManager.clear();
+                DynamicDataSourceContextHolder.clearDataSourceType();
+                logger.info("微信回调清理租户上下文完成");
+            }
         }
     }
 

+ 1 - 1
pom.xml

@@ -31,7 +31,7 @@
         <velocity.version>1.7</velocity.version>
         <jwt.version>0.9.1</jwt.version>
         <org.mapstruct.version>1.5.5.Final</org.mapstruct.version>
-        <lombok.version>1.18.32</lombok.version>
+        <lombok.version>1.18.30</lombok.version>
         <gson-version>2.10</gson-version>
         <ijpay-version>2.7.8</ijpay-version>
     </properties>