Przeglądaj źródła

Merge remote-tracking branch 'origin/master'

xw 21 godzin temu
rodzic
commit
a49c1cbde0
63 zmienionych plików z 2304 dodań i 105 usunięć
  1. 5 0
      fs-admin/src/main/java/com/fs/third/controller/TencentWordOpenApiController.java
  2. 6 0
      fs-ai-api/src/main/java/com/fs/ai/rag/controller/QdrantController.java
  3. 1 0
      fs-ai-api/src/main/java/com/fs/ai/rag/dto/QdrantPointDeleteReq.java
  4. 1 0
      fs-ai-api/src/main/java/com/fs/ai/rag/dto/QdrantPointSearchReq.java
  5. 22 4
      fs-ai-api/src/main/java/com/fs/ai/rag/service/impl/QdrantServiceImpl.java
  6. 117 0
      fs-ai-call-task/src/main/java/com/fs/app/service/CallTaskService.java
  7. 19 0
      fs-ai-call-task/src/main/java/com/fs/app/task/Task.java
  8. 4 1
      fs-company-app/src/main/java/com/fs/app/controller/UserController.java
  9. 391 0
      fs-company-app/src/main/java/com/fs/app/controller/aiSipCall/AiSipCallController.java
  10. 52 0
      fs-company-app/src/main/java/com/fs/app/controller/crm/CrmAPPMsgController.java
  11. 15 0
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallOutboundCdrController.java
  12. 13 0
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallUserController.java
  13. 78 0
      fs-company/src/main/java/com/fs/company/controller/company/CompanyUserController.java
  14. 1 0
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticCallLogCallphoneController.java
  15. 8 0
      fs-company/src/main/java/com/fs/company/controller/crm/CrmCustomerController.java
  16. 111 0
      fs-company/src/main/java/com/fs/company/controller/crm/ManualOutboundCallLogController.java
  17. 5 0
      fs-company/src/main/java/com/fs/company/controller/third/TencentWordOpenApiController.java
  18. 21 0
      fs-service/src/main/java/com/fs/aiSipCall/RemoteCommon.java
  19. 38 0
      fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipCallOutboundCdr.java
  20. 12 0
      fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipCallUser.java
  21. 13 0
      fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallOutboundCdrService.java
  22. 6 0
      fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallUserService.java
  23. 78 1
      fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallOutboundCdrServiceImpl.java
  24. 42 1
      fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallUserServiceImpl.java
  25. 1 0
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRobotic.java
  26. 4 0
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRoboticCallLogCallphone.java
  27. 24 1
      fs-service/src/main/java/com/fs/company/domain/CrmCustomerCallLog.java
  28. 2 0
      fs-service/src/main/java/com/fs/company/mapper/CrmCustomerCallLogMapper.java
  29. 8 0
      fs-service/src/main/java/com/fs/company/service/ICrmCustomerCallLogService.java
  30. 3 2
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallLogCallphoneServiceImpl.java
  31. 4 7
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java
  32. 33 18
      fs-service/src/main/java/com/fs/company/service/impl/CompanyWorkflowEngineImpl.java
  33. 5 0
      fs-service/src/main/java/com/fs/company/service/impl/CrmCustomerCallLogServiceImpl.java
  34. 34 5
      fs-service/src/main/java/com/fs/company/service/impl/call/node/AiCallTaskNode.java
  35. 6 2
      fs-service/src/main/java/com/fs/company/vo/AiCallWorkflowConditionVo.java
  36. 5 0
      fs-service/src/main/java/com/fs/company/vo/easycall/EasyCallCallPhoneVO.java
  37. 9 0
      fs-service/src/main/java/com/fs/crm/domain/CrmCustomer.java
  38. 26 0
      fs-service/src/main/java/com/fs/crm/mapper/CrmCustomerMapper.java
  39. 3 0
      fs-service/src/main/java/com/fs/crm/param/CrmLineCustomerListQueryParam.java
  40. 3 0
      fs-service/src/main/java/com/fs/crm/param/CrmMyCustomerListQueryParam.java
  41. 15 0
      fs-service/src/main/java/com/fs/fastGpt/domain/FastGptChatConversation.java
  42. 2 0
      fs-service/src/main/java/com/fs/fastGpt/domain/FastGptRole.java
  43. 1 1
      fs-service/src/main/java/com/fs/fastGpt/mapper/FastGptRoleMapper.java
  44. 452 4
      fs-service/src/main/java/com/fs/fastGpt/service/impl/AiHookServiceImpl.java
  45. 20 5
      fs-service/src/main/java/com/fs/fastgptApi/util/AudioUtils.java
  46. 2 0
      fs-service/src/main/java/com/fs/qw/mapper/QwTagGroupMapper.java
  47. 2 0
      fs-service/src/main/java/com/fs/qw/service/IQwTagGroupService.java
  48. 14 0
      fs-service/src/main/java/com/fs/qw/service/impl/QwTagGroupServiceImpl.java
  49. 1 1
      fs-service/src/main/java/com/fs/qw/service/impl/QwUserServiceImpl.java
  50. 9 2
      fs-service/src/main/java/com/fs/system/oss/OSSFactory.java
  51. 2 0
      fs-service/src/main/java/com/fs/third/service/ITencentWordService.java
  52. 199 35
      fs-service/src/main/java/com/fs/third/service/impl/TencentWordServiceImpl.java
  53. 2 2
      fs-service/src/main/java/com/fs/wxcid/dto/message/CdnUploadVideoResult.java
  54. 283 2
      fs-service/src/main/resources/db/tenant-initTable.sql
  55. 11 1
      fs-service/src/main/resources/mapper/aiSipCall/AiSipCallOutboundCdrMapper.xml
  56. 2 1
      fs-service/src/main/resources/mapper/aiSipCall/AiSipCallUserMapper.xml
  57. 5 1
      fs-service/src/main/resources/mapper/company/CompanyVoiceRoboticCallLogCallphoneMapper.xml
  58. 2 0
      fs-service/src/main/resources/mapper/company/CompanyVoiceRoboticMapper.xml
  59. 29 2
      fs-service/src/main/resources/mapper/company/CrmCustomerCallLogMapper.xml
  60. 1 1
      fs-service/src/main/resources/mapper/fastGpt/FastGptChatSessionMapper.xml
  61. 5 1
      fs-service/src/main/resources/mapper/fastGpt/FastGptRoleMapper.xml
  62. 11 0
      fs-service/src/main/resources/mapper/qw/QwTagGroupMapper.xml
  63. 5 4
      fs-service/src/main/resources/mapper/third/TencentWordDetailMapper.xml

+ 5 - 0
fs-admin/src/main/java/com/fs/third/controller/TencentWordOpenApiController.java

@@ -37,6 +37,11 @@ public class TencentWordOpenApiController extends BaseController {
         return R.ok();
     }
 
+    @PostMapping("/syncToKnowledgeBase")
+    public R syncToKnowledgeBase(@RequestParam String fileId, @RequestParam Long knowledgeBaseId) {
+        return tencentWordService.syncToKnowledgeBase(fileId, knowledgeBaseId);
+    }
+
     @GetMapping("/authorizeCallback")
     public void authorizeCallback(Request request) {
         //tencent开放平台登录回调(二维码登录等)

+ 6 - 0
fs-ai-api/src/main/java/com/fs/ai/rag/controller/QdrantController.java

@@ -47,6 +47,12 @@ public class QdrantController {
         return AjaxResult.success();
     }
 
+    @PostMapping("/point/delete/filter")
+    public AjaxResult deletePointByFilter(@RequestBody QdrantPointDeleteReq req) {
+        qdrantService.deletePoints(req);
+        return AjaxResult.success();
+    }
+
     @PostMapping("/point/get")
     public AjaxResult getPoint(@RequestBody QdrantPointGetReq req) {
         return AjaxResult.success(qdrantService.getPoint(req));

+ 1 - 0
fs-ai-api/src/main/java/com/fs/ai/rag/dto/QdrantPointDeleteReq.java

@@ -8,4 +8,5 @@ import java.util.List;
 public class QdrantPointDeleteReq {
     private String collectionName;
     private List<Long> ids;
+    private java.util.Map<String, Object> filter;
 }

+ 1 - 0
fs-ai-api/src/main/java/com/fs/ai/rag/dto/QdrantPointSearchReq.java

@@ -10,5 +10,6 @@ public class QdrantPointSearchReq {
     private String collectionName;
     private List<Float> vector;
     private Integer topK;
+    private Double scoreThreshold;
     private Map<String, Object> filter;
 }

+ 22 - 4
fs-ai-api/src/main/java/com/fs/ai/rag/service/impl/QdrantServiceImpl.java

@@ -83,11 +83,21 @@ public class QdrantServiceImpl implements QdrantService {
 
     @Override
     public void deletePoints(QdrantPointDeleteReq req) {
-        if (req == null || StringUtils.isBlank(req.getCollectionName()) || req.getIds() == null || req.getIds().isEmpty()) {
-            throw new IllegalArgumentException("collectionName 和 ids 不能为空");
+        if (req == null || StringUtils.isBlank(req.getCollectionName())) {
+            throw new IllegalArgumentException("collectionName 不能为空");
+        }
+        boolean hasIds = req.getIds() != null && !req.getIds().isEmpty();
+        boolean hasFilter = req.getFilter() != null && !req.getFilter().isEmpty();
+        if (!hasIds && !hasFilter) {
+            throw new IllegalArgumentException("ids 和 filter 至少提供一个");
         }
         Map<String, Object> body = new LinkedHashMap<>();
-        body.put("points", req.getIds());
+        if (hasIds) {
+            body.put("points", req.getIds());
+        }
+        if (hasFilter) {
+            body.put("filter", buildFilter(req.getFilter()));
+        }
         body.put("wait", true);
         try {
             exchange(pointsDeleteUrl(req.getCollectionName()), Method.POST, body);
@@ -128,7 +138,7 @@ public class QdrantServiceImpl implements QdrantService {
         body.put("limit", req.getTopK() == null ? 5 : req.getTopK());
         body.put("with_payload", true);
         body.put("with_vector", true);
-        body.put("score_threshold", 0.5);
+        body.put("score_threshold", req.getScoreThreshold() != null ? req.getScoreThreshold() : 0.5);
         if (req.getFilter() != null && !req.getFilter().isEmpty()) {
             body.put("filter", buildFilter(req.getFilter()));
         }
@@ -227,6 +237,14 @@ public class QdrantServiceImpl implements QdrantService {
     }
 
     private Map<String, Object> buildFilter(Map<String, Object> filterMap) {
+        if (filterMap == null || filterMap.isEmpty()) {
+            return new LinkedHashMap<>();
+        }
+
+        if (filterMap.containsKey("must") || filterMap.containsKey("should") || filterMap.containsKey("must_not")) {
+            return filterMap;
+        }
+
         List<Map<String, Object>> must = new ArrayList<>();
         for (Map.Entry<String, Object> entry : filterMap.entrySet()) {
             if (entry.getValue() == null) {

+ 117 - 0
fs-ai-call-task/src/main/java/com/fs/app/service/CallTaskService.java

@@ -1,8 +1,13 @@
 package com.fs.app.service;
 
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.core.redis.RedisCacheT;
+import com.fs.company.domain.CompanyVoiceRobotic;
+import com.fs.company.mapper.CompanyVoiceRoboticMapper;
 import com.fs.company.param.ExecutionContext;
+import com.fs.company.param.PauseRoboticActiveParam;
 import com.fs.company.service.*;
 import com.fs.company.service.impl.call.node.AiCallTaskNode;
 import lombok.RequiredArgsConstructor;
@@ -12,6 +17,7 @@ import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 
+import java.time.LocalTime;
 import java.util.*;
 import java.util.concurrent.*;
 
@@ -29,6 +35,7 @@ public class CallTaskService {
     private final RedisCache redisCache2;
     private final CompanyWorkflowEngine companyWorkflowEngine;
     private final ICompanyVoiceRoboticService companyVoiceRoboticService;
+    private final CompanyVoiceRoboticMapper companyVoiceRoboticMapper;
     @Autowired
     @Qualifier("cidWorkFlowExecutor")
     private Executor cidWorkFlowExecutor;
@@ -81,4 +88,114 @@ public class CallTaskService {
         });
         log.info("===========工作流延时任务扫描结束===========");
     }
+
+    /**
+     * 检查任务状态来暂停外呼任务的外呼运行
+     */
+    public void taskStatusChange(){
+        LocalTime now = LocalTime.now();
+        log.info("===========任务状态变更扫描开始, 当前时间: {}, 分组: {}===========", now, cidGroupNo);
+        pauseOutOfRangeTasks(now);
+        resumeInRangeTasks(now);
+        log.info("===========任务状态变更扫描结束===========");
+    }
+
+    private void pauseOutOfRangeTasks(LocalTime now) {
+        List<CompanyVoiceRobotic> activeTasks = companyVoiceRoboticMapper.selectList(
+                new QueryWrapper<CompanyVoiceRobotic>()
+                        .eq("task_status", 1)
+                        .eq("task_type", 1)
+                        .eq("cid_group_no", cidGroupNo)
+                        .eq("del_flag", 0)
+        );
+        log.info("扫描到执行中的普通任务 {} 个", activeTasks.size());
+        if (activeTasks.isEmpty()) {
+            return;
+        }
+        List<CompletableFuture<Void>> futures = new ArrayList<>();
+        for (CompanyVoiceRobotic task : activeTasks) {
+            if (isOutOfRuntimeRange(task, now)) {
+                log.info("任务[{}]运行时间范围[{},{}),当前{}超出范围,准备自动暂停", task.getId(), task.getRuntimeRangeStart(), task.getRuntimeRangeEnd(), now);
+                futures.add(CompletableFuture.runAsync(() -> {
+                    try {
+                        PauseRoboticActiveParam param = new PauseRoboticActiveParam();
+                        param.setTaskId(task.getId());
+                        param.setActiveType(1);
+                        companyVoiceRoboticService.pauseRoboticActive(param);
+                        companyVoiceRoboticMapper.update(null,
+                                new LambdaUpdateWrapper<CompanyVoiceRobotic>()
+                                        .eq(CompanyVoiceRobotic::getId, task.getId())
+                                        .set(CompanyVoiceRobotic::getPauseSource, "auto")
+                        );
+                        log.info("任务[{}]超出运行时间范围,已自动暂停", task.getId());
+                    } catch (Exception e) {
+                        log.error("自动暂停任务[{}]异常", task.getId(), e);
+                    }
+                }, cidWorkFlowExecutor));
+            }
+        }
+        if (!futures.isEmpty()) {
+            log.info("本次需自动暂停 {} 个任务,等待并行执行完成", futures.size());
+            CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+        }
+    }
+
+    private void resumeInRangeTasks(LocalTime now) {
+        List<CompanyVoiceRobotic> pausedTasks = companyVoiceRoboticMapper.selectList(
+                new QueryWrapper<CompanyVoiceRobotic>()
+                        .eq("task_status", 2)
+                        .eq("task_type", 1)
+                        .eq("pause_source", "auto")
+                        .eq("cid_group_no", cidGroupNo)
+                        .eq("del_flag", 0)
+        );
+        log.info("扫描到自动暂停中的普通任务 {} 个", pausedTasks.size());
+        if (pausedTasks.isEmpty()) {
+            return;
+        }
+        List<CompletableFuture<Void>> futures = new ArrayList<>();
+        for (CompanyVoiceRobotic task : pausedTasks) {
+            if (isWithinRuntimeRange(task, now)) {
+                log.info("任务[{}]运行时间范围[{},{}),当前{}在范围内,准备自动恢复", task.getId(), task.getRuntimeRangeStart(), task.getRuntimeRangeEnd(), now);
+                futures.add(CompletableFuture.runAsync(() -> {
+                    try {
+                        PauseRoboticActiveParam param = new PauseRoboticActiveParam();
+                        param.setTaskId(task.getId());
+                        param.setActiveType(2);
+                        companyVoiceRoboticService.pauseRoboticActive(param);
+                        companyVoiceRoboticMapper.update(null,
+                                new LambdaUpdateWrapper<CompanyVoiceRobotic>()
+                                        .eq(CompanyVoiceRobotic::getId, task.getId())
+                                        .set(CompanyVoiceRobotic::getPauseSource, null)
+                        );
+                        log.info("任务[{}]回到运行时间范围,已自动恢复", task.getId());
+                    } catch (Exception e) {
+                        log.error("自动恢复任务[{}]异常", task.getId(), e);
+                    }
+                }, cidWorkFlowExecutor));
+            }
+        }
+        if (!futures.isEmpty()) {
+            log.info("本次需自动恢复 {} 个任务,等待并行执行完成", futures.size());
+            CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+        }
+    }
+
+    private boolean isOutOfRuntimeRange(CompanyVoiceRobotic task, LocalTime now) {
+        LocalTime start = task.getRuntimeRangeStart();
+        LocalTime end = task.getRuntimeRangeEnd();
+        if (start == null || end == null) {
+            return false;
+        }
+        if (start.isBefore(end)) {
+            return now.isBefore(start) || now.isAfter(end);
+        } else {
+            return now.isAfter(end) && now.isBefore(start);
+        }
+    }
+
+    private boolean isWithinRuntimeRange(CompanyVoiceRobotic task, LocalTime now) {
+        return !isOutOfRuntimeRange(task, now);
+    }
+
 }

+ 19 - 0
fs-ai-call-task/src/main/java/com/fs/app/task/Task.java

@@ -63,4 +63,23 @@ public class Task {
         }
 
     }
+
+    @Scheduled(cron = "0 0/1 * * * ?")
+    public void taskStatusChange(){
+        // 防御性检查:确保依赖注入成功
+        if (tenantTaskRunner == null) {
+            log.error("[Task] tenantTaskRunner 未注入,请检查 Spring 配置");
+            return;
+        }
+        if (taskService == null) {
+            log.error("[Task] taskService 未注入,请检查 Spring 配置");
+            return;
+        }
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForResponsibleTenant("taskStatusChange", () -> taskService.taskStatusChange());
+        } else {
+            taskService.taskStatusChange();
+        }
+
+    }
 }

+ 4 - 1
fs-company-app/src/main/java/com/fs/app/controller/UserController.java

@@ -464,7 +464,10 @@ public class UserController extends AppBaseController {
     @PostMapping("/setPwd")
     public R setPwd(HttpServletRequest request, @RequestBody EditPwdParam param) {
         try {
-
+            // 新密码格式校验(与login一致)
+            if (!PatternUtils.checkPassword(param.getPassword())) {
+                return R.error("密码格式不正确,需包含字母、数字和特殊字符,长度为 8-20位");
+            }
             CompanyUser user = userService.selectCompanyUserById(Long.parseLong(getUserId()));
             if (!SecurityUtils.matchesPassword(param.getOldPassword(), user.getPassword())) {
                 return R.error("旧密码错误");

+ 391 - 0
fs-company-app/src/main/java/com/fs/app/controller/aiSipCall/AiSipCallController.java

@@ -0,0 +1,391 @@
+package com.fs.app.controller.aiSipCall;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.fs.aiSipCall.domain.AiSipCallOutboundCdr;
+import com.fs.aiSipCall.domain.AiSipCallUser;
+import com.fs.aiSipCall.domain.CcCustInfo;
+import com.fs.aiSipCall.param.ApiCallRecordByUuidQueryParams;
+import com.fs.aiSipCall.service.IAiSipCallOutboundCdrService;
+import com.fs.aiSipCall.service.IAiSipCallUserService;
+import com.fs.app.annotation.Login;
+import com.fs.app.controller.AppBaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.utils.SecurityUtils;
+import com.fs.company.domain.CompanyUser;
+import com.fs.company.mapper.EasyCallMapper;
+import com.fs.company.service.ICompanyUserService;
+import com.fs.company.util.OrderUtils;
+import com.fs.company.vo.easycall.EasyCallOutBoundVO;
+import com.fs.crm.domain.CrmCustomer;
+import com.fs.crm.param.CrmCustomeReceiveParam;
+import com.fs.crm.param.CrmFullCustomerListQueryParam;
+import com.fs.crm.param.CrmMyCustomerListQueryParam;
+import com.fs.crm.mapper.CrmCustomerMapper;
+import com.fs.crm.service.ICrmCustomerService;
+import com.fs.crm.vo.CrmFullCustomerListQueryVO;
+import com.fs.crm.vo.CrmMyCustomerListQueryVO;
+import com.fs.his.utils.PhoneUtil;
+import com.github.pagehelper.PageHelper;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiParam;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 销售端APP - aiSIP软电话接口(手动外呼)
+ * <p>
+ * 提供给APP端浏览器/JsSIP软电话工具条所需的最小可用后端能力:
+ * <ul>
+ *     <li>拉取当前销售绑定的SIP分机账号 - 用于注册SIP UA</li>
+ *     <li>拉取IPCC工具条基础参数 - 用于建立IPCC WebSocket控制通道</li>
+ *     <li>通话前手机号密文转换 - 配合密文拨号模式</li>
+ *     <li>通话沟通信息查询/沟通记录补录</li>
+ * </ul>
+ * <p>
+ * 注意:本Controller仅复用 fs-service 中已有Service方法,
+ * 不修改 fs-service / fs-company / fs-framework 等任何已有模块代码。
+ * <p>
+ * 仍待用户决策的能力(依赖 fs-service 暂未提供的方法/字段,详见随附差缺清单):
+ * <ul>
+ *     <li>已补齐:agentLogin、callEndSyncByUuid、getCustCommunicationInfo(dialMode)</li>
+ * </ul>
+ *
+ * @author migrated from his_java/fs-company AiSipCallUserController & AiSipCallOutboundCdrController
+ */
+@Slf4j
+@Api("软电话接口")
+@RestController
+@RequestMapping("/app/aiSipCall")
+public class AiSipCallController extends AppBaseController {
+
+    @Autowired
+    private IAiSipCallUserService aiSipCallUserService;
+
+    @Autowired
+    private IAiSipCallOutboundCdrService aiSipCallOutboundCdrService;
+
+    @Autowired
+    private ICompanyUserService companyUserService;
+
+    @Autowired
+    private EasyCallMapper easyCallMapper;
+
+    @Autowired
+    private ICrmCustomerService crmCustomerService;
+
+    @Autowired
+    private CrmCustomerMapper crmCustomerMapper;
+
+    private final String AUDIO_BASE_URL = "http://129.28.164.235:8899";
+
+    /**
+     * 是否使用自有线路(迁移自 his_java sip.call.myGateway,原放在 AiSipCallUserServiceImpl)。
+     * <p>SaaS 老 ServiceImpl 已被精简没有兜底逻辑,故在 Controller 层注入并保持 his_java 行为一致。
+     */
+    @Value("${sip.call.myGateway:false}")
+    private boolean isMyGateway;
+
+    /**
+     * 手动外呼网关前缀(迁移自 his_java sip.call.manualGatewayPrefix)
+     */
+    @Value("${sip.call.manualGatewayPrefix:weizhi}")
+    private String manualGatewayPrefix;
+
+    /**
+     * 公共线路网关前缀(迁移自 his_java sip.call.publicGatewayPrefix)
+     */
+    @Value("${sip.call.publicGatewayPrefix:outbound}")
+    private String publicGatewayPrefix;
+
+    /**
+     * 加密手机号末尾随机串长度(与前端约定,与 his_java 的 RandomUtil.generateRandomCode 等价)
+     */
+    private static final int RANDOM_TAIL_LEN = 6;
+
+    private static final char[] RANDOM_CHARS =
+            "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".toCharArray();
+
+    private static final SecureRandom RANDOM = new SecureRandom();
+
+    /**
+     * XOR加密公钥(与 his_java 的 PhoneUtil.PUBLIC_KEY_STR 保持一致,避免污染 SaaS 原有 PhoneUtil)
+     */
+    private static final String XOR_KEY = "ylrz112233";
+
+    /**
+     * 查询当前登录销售绑定的SIP分机账号
+     * <p>前端 softPhone.vue 启动时调用,用于初始化JsSIP UA配置(user/domain/extPass)。
+     */
+    @Login
+    @ApiOperation("查询当前销售的SIP分机账号")
+    @GetMapping("/myCallUser")
+    public AjaxResult myCallUser(AiSipCallUser aiSipCallUser) {
+        if (aiSipCallUser == null) {
+            aiSipCallUser = new AiSipCallUser();
+        }
+        aiSipCallUser.setCompanyUserId(getCompanyUserId());
+        List<AiSipCallUser> list = aiSipCallUserService.selectAiSipCallUserList(aiSipCallUser);
+        if (list != null && !list.isEmpty()) {
+            return AjaxResult.success(list.get(0));
+        }
+        return AjaxResult.error("未创建sip角色");
+    }
+
+    /**
+     * 查询aiSIP工具条基础配置参数
+     * <p>前端获取IPCC WebSocket地址、外呼网关等参数。
+     *
+     * @param param 至少包含 extNum(分机号)
+     */
+    @Login
+    @ApiOperation("查询aiSIP工具条基础参数")
+    @PostMapping("/getToolbarBasicParam")
+    public AjaxResult getToolbarBasicParam(@RequestBody Map<String, String> param) {
+        if (param == null || param.get("extNum") == null) {
+            return AjaxResult.error("分机号参数缺失");
+        }
+        // ===== 步骤1:迁移自 fs-company AiSipCallUserController#getToolbarBasicParam =====
+        // 优先使用当前销售所属公司绑定的网关ids
+        Long companyId = getCompanyId();
+        if (companyId != null) {
+            String ids = aiSipCallUserService.getGateWayIdListByCompanyId(companyId);
+            if (StringUtils.isNotBlank(ids)) {
+                param.put("myGateway", ids);
+            }
+        }
+        // ===== 步骤2:迁移自 his_java AiSipCallUserServiceImpl#getToolbarBasicParam =====
+        // 公司没绑定网关 + 前端也没传 myGateway,按 yml 配置兜底(与 his_java 行为一致)
+//        String myGateway = param.get("myGateway");
+//        if (StringUtils.isBlank(myGateway)) {
+//            if (isMyGateway) {
+//                // 自己有线路 → 使用手动外呼网关前缀
+//                param.put("wgName", manualGatewayPrefix);
+//            } else {
+//                // 没有给默认线路 → 使用公共线路前缀
+//                param.put("wgName", publicGatewayPrefix);
+//            }
+//        }
+        return aiSipCallUserService.getToolbarBasicParam(param);
+    }
+
+    /**
+     * 获取手动外呼客户沟通信息
+     *
+     * @param phoneNum 手机号(明文或密文)
+     * @param callType 类型 1呼入 2外呼
+     * @param uuid     通话UUID
+     * @param dialMode plaintext / encrypted (为空按明文处理)
+     */
+    @Login
+    @ApiOperation("获取手动外呼客户沟通信息")
+    @GetMapping("/getCustCommunicationInfo")
+    public AjaxResult getCustCommunicationInfo(
+            @ApiParam(value = "手机号", required = true) @RequestParam("phoneNum") String phoneNum,
+            @ApiParam(value = "1呼入 2外呼", required = true) @RequestParam("callType") Integer callType,
+            @ApiParam(value = "通话UUID", required = true) @RequestParam("uuid") String uuid,
+            @ApiParam(value = "拨号模式 plaintext/encrypted") @RequestParam(value = "dialMode", required = false) String dialMode) {
+        return aiSipCallOutboundCdrService.getCustCommunicationInfo(phoneNum, callType, uuid, dialMode);
+    }
+
+    /**
+     * 新增保存手动外呼沟通记录
+     */
+    @Login
+    @ApiOperation("新增手动外呼沟通记录")
+    @PostMapping("/add/custcallrecord")
+    public AjaxResult addCustcallrecord(@RequestBody CcCustInfo ccCustInfo) {
+        return aiSipCallOutboundCdrService.addCustcallrecord(ccCustInfo);
+    }
+
+    /**
+     * 手机号密文转换:先解密再用xor重新加密 + 末尾追加随机串
+     * <p>用于密文拨号模式:前端把"原始密文+6位随机数"丢上来,后端返回"新密文+6位随机数"。
+     */
+    @Login
+    @ApiOperation("手机号密文转换(解密->xor加密)")
+    @PostMapping("/encryptMobile")
+    public AjaxResult encryptMobile(@RequestBody Map<String, String> request) {
+        String combined = request != null ? request.get("data") : null;
+        if (combined == null || combined.length() <= RANDOM_TAIL_LEN) {
+            return AjaxResult.error("加密参数缺失或格式不正确");
+        }
+        // 截掉末尾随机串
+        String original = combined.substring(0, combined.length() - RANDOM_TAIL_LEN);
+        try {
+            String decrypted = PhoneUtil.decryptPhone(original);
+            String encrypted = xorEncrypt(decrypted);
+            return AjaxResult.success("获取成功", encrypted + generateRandomTail());
+        } catch (Exception e) {
+            log.error("[aiSipCall][app] encryptMobile失败", e);
+            return AjaxResult.error("获取加密手机号失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 坐席登录IPCC外呼平台
+     * <p>前端 JsSIP 工具条启动后,使用此接口让 IPCC 平台知晓"该分机已上线、可派发呼叫"。
+     *
+     * @param param 至少包含 username/password/extNum 等表单参数
+     */
+    @Login
+    @ApiOperation("坐席登录IPCC外呼平台")
+    @PostMapping("/agentLogin")
+    public AjaxResult agentLogin(@RequestBody Map<String, Object> param) {
+        return aiSipCallUserService.agentLogin(param);
+    }
+
+    /**
+     * 通话挂断后根据UUID同步话单
+     * <p>前端 JsSIP 监听到 BYE/挂机后调用本接口,由后端从 IPCC 拉取话单并落库。
+     */
+    @Login
+    @ApiOperation("通话挂断后同步话单")
+    @PostMapping("/callEndSyncByUuid")
+    public AjaxResult callEndSyncByUuid(@RequestBody AiSipCallOutboundCdr request) {
+        if (request == null || StringUtils.isBlank(request.getUuid())) {
+            return AjaxResult.error("获取手动外呼通话记录同步失败,uuid为空");
+        }
+        // 用APP登录态填充必备的归属字段(覆盖前端任何尝试性传值,避免越权)
+        request.setSourceType("0");
+        Long companyId = getCompanyId();
+        Long companyUserId = getCompanyUserId();
+        request.setCompanyId(companyId);
+        request.setCompanyUserId(companyUserId);
+        if (companyUserId != null) {
+            CompanyUser companyUser = companyUserService.selectCompanyUserById(companyUserId);
+            if (companyUser != null) {
+                request.setCompanyUserName(companyUser.getUserName());
+            }
+        }
+        request.setStatus(0);
+        aiSipCallOutboundCdrService.callEndSyncByUuid(request);
+        return AjaxResult.success();
+    }
+
+    /**
+     * 获取有效客户列表(effective_customer=1)
+     * <p>按销售id、创建时间范围、加密手机号、备注等条件筛选。
+     * 手机号传入明文,后端加密后进行LIKE匹配。
+     */
+    @Login
+    @ApiOperation("获取有效客户列表")
+    @GetMapping("/getEffectiveCustomerList")
+    public TableDataInfo getEffectiveCustomerList(
+            @RequestParam(value = "startTime", required = false) String startTime,
+            @RequestParam(value = "endTime", required = false) String endTime,
+            @RequestParam(value = "mobile", required = false) String mobile,
+            @RequestParam(value = "remark", required = false) String remark,
+            @RequestParam(value = "callStatus", required = false) Integer callStatus,
+            @RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum,
+            @RequestParam(value = "pageSize", defaultValue = "10") Integer pageSize) {
+        String encryptedMobile = null;
+        if (StringUtils.isNotBlank(mobile)) {
+            encryptedMobile = PhoneUtil.encryptPhone(mobile);
+        }
+        PageHelper.startPage(pageNum, pageSize);
+        List<CrmCustomer> list = crmCustomerMapper.selectEffectiveCustomerList(
+                getCompanyUserId(), startTime, endTime, encryptedMobile, remark, callStatus);
+        if (list != null) {
+            for (CrmCustomer c : list) {
+                if (StringUtils.isNotBlank(c.getMobile())) {
+                    try {
+                        String decrypted = PhoneUtil.decryptPhone(c.getMobile());
+                        c.setMobile(decrypted.replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2"));
+                        if (Integer.valueOf(1).equals(c.getCanDecrypt())) {
+                            c.setDecryptedMobile(decrypted);
+                        }
+                    } catch (Exception e) {
+                        log.warn("[aiSipCall] 解密手机号失败, customerId={}", c.getCustomerId());
+                    }
+                }
+            }
+        }
+        return getDataTable(list);
+    }
+
+    /**
+     * 根据客户ID查询呼叫记录
+     */
+    @Login
+    @ApiOperation("根据客户ID查询呼叫记录")
+    @GetMapping("/getCallRecordByCustomerId")
+    public TableDataInfo getCallRecordByCustomerId(
+            @RequestParam("customerId") Long customerId,
+            @RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum,
+            @RequestParam(value = "pageSize", defaultValue = "10") Integer pageSize) {
+        AiSipCallOutboundCdr query = new AiSipCallOutboundCdr();
+        query.setCustomerId(customerId);
+        PageHelper.startPage(pageNum, pageSize);
+        List<AiSipCallOutboundCdr> list = aiSipCallOutboundCdrService.selectAiSipCallOutboundCdrList(query);
+        //拼接音频文件
+        list.forEach(data -> {
+            if (StringUtils.isNotBlank(data.getWavfile())) {
+                data.setWavfile(AUDIO_BASE_URL + data.getWavfile());
+            }
+        });
+        return getDataTable(list);
+    }
+
+    /**
+     * 根据客户ID获取解密后的手机号明文
+     */
+    @Login
+    @ApiOperation("根据客户ID获取解密手机号")
+    @GetMapping("/decryptMobileByCustomerId")
+    public AjaxResult decryptMobileByCustomerId(@RequestParam("customerId") Long customerId) {
+        String encryptedMobile = crmCustomerMapper.selectCrmCustomerPhoneByCustomerId(customerId);
+        if (StringUtils.isBlank(encryptedMobile)) {
+            return AjaxResult.error("该客户无手机号");
+        }
+        try {
+            String decrypted = PhoneUtil.decryptPhone(encryptedMobile);
+            return AjaxResult.success("获取成功", decrypted);
+        } catch (Exception e) {
+            log.error("[aiSipCall] 解密手机号失败, customerId={}", customerId, e);
+            return AjaxResult.error("手机号解密失败");
+        }
+    }
+
+
+    /**
+     * 生成6位随机串(替代 his_java 中的 com.fs.his.utils.RandomUtil#generateRandomCode)
+     */
+    private String generateRandomTail() {
+        StringBuilder sb = new StringBuilder(RANDOM_TAIL_LEN);
+        for (int i = 0; i < RANDOM_TAIL_LEN; i++) {
+            sb.append(RANDOM_CHARS[RANDOM.nextInt(RANDOM_CHARS.length)]);
+        }
+        return sb.toString();
+    }
+
+    /**
+     * XOR 加密(与 his_java 的 PhoneUtil.xorEncrypt 算法一致)。
+     * <p>内联在此,避免修改 SaaS 原有 PhoneUtil。
+     */
+    private String xorEncrypt(String data) {
+        byte[] dataBytes = data.getBytes(StandardCharsets.UTF_8);
+        byte[] keyBytes = XOR_KEY.getBytes(StandardCharsets.UTF_8);
+        byte[] result = new byte[dataBytes.length];
+        for (int i = 0; i < dataBytes.length; i++) {
+            result[i] = (byte) (dataBytes[i] ^ keyBytes[i % keyBytes.length]);
+        }
+        return Base64.getEncoder().encodeToString(result);
+    }
+}

+ 52 - 0
fs-company-app/src/main/java/com/fs/app/controller/crm/CrmAPPMsgController.java

@@ -0,0 +1,52 @@
+package com.fs.app.controller.crm;
+
+import com.fs.app.annotation.Login;
+import com.fs.app.controller.AppBaseController;
+import com.fs.common.core.domain.R;
+import com.fs.crm.service.ICrmMsgService;
+import com.fs.crm.vo.CrmMsgTypeVO;
+import com.fs.system.service.ISysDictDataService;
+import com.fs.system.vo.DictVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ *  消息Controller(APP端)
+ *
+ * @author fs
+ * @date 2021-04-16
+ */
+@RestController
+@RequestMapping("/crm/msg")
+public class CrmAPPMsgController extends AppBaseController
+{
+    @Autowired
+    private ICrmMsgService crmMsgService;
+    @Autowired
+    private ISysDictDataService dictDataService;
+
+
+    @Login
+    @GetMapping("/getMsg")
+    public R getMsg(){
+        // APP端鉴权:从APPToken中解析companyUserId
+        Long companyUserId = getCompanyUserId();
+        //获取所有类型
+        List<DictVO> types = dictDataService.selectDictDataListByType("crm_msg_type");
+        List<CrmMsgTypeVO> counts = new ArrayList<>();
+        for(DictVO v : types){
+            Long count = crmMsgService.selectCrmMsgCountByUserId(companyUserId, Integer.parseInt(v.getDictValue()));
+            CrmMsgTypeVO typeBO = new CrmMsgTypeVO();
+            typeBO.setMsgType(Integer.parseInt(v.getDictValue()));
+            typeBO.setTotal(count);
+            typeBO.setMsgTypeName(v.getDictLabel());
+            counts.add(typeBO);
+        }
+        return R.ok().put("counts", counts);
+    }
+}

+ 15 - 0
fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallOutboundCdrController.java

@@ -19,6 +19,8 @@ import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.company.mapper.EasyCallMapper;
 import com.fs.company.vo.easycall.EasyCallOutBoundVO;
 import com.fs.framework.datasource.TenantDataSourceManager;
+import com.fs.framework.service.TokenService;
+import com.fs.his.utils.PhoneUtil;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
@@ -26,8 +28,12 @@ import org.springframework.security.core.Authentication;
 import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.web.bind.annotation.*;
 
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.util.Base64;
 import java.util.Date;
 import java.util.List;
+import java.util.Map;
 
 /**
  * aiSIP手动外呼通话记录Controller
@@ -46,6 +52,15 @@ public class AiSipCallOutboundCdrController extends BaseController
     private EasyCallMapper easyCallMapper;
     @Autowired
     TenantDataSourceManager tenantDataSourceManager;
+    @Autowired
+    private TokenService tokenService;
+
+    /** XOR加密公钥(与 his_java PhoneUtil.PUBLIC_KEY_STR 保持一致) */
+    private static final String XOR_KEY = "ylrz112233";
+    private static final int RANDOM_TAIL_LEN = 6;
+    private static final char[] RANDOM_CHARS =
+            "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".toCharArray();
+    private static final SecureRandom RANDOM = new SecureRandom();
 
     /**
      * 查询aiSIP手动外呼通话记录列表

+ 13 - 0
fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallUserController.java

@@ -106,6 +106,7 @@ public class AiSipCallUserController extends BaseController
             aiSipCallUser.setCreateBy(loginUser.getUser().getUserName());
         }
         aiSipCallUser.setCreateTime(new Date());
+        aiSipCallUser.setUserSource("0");
         return toAjax(aiSipCallUserService.insertAiSipCallUser(aiSipCallUser));
     }
 
@@ -141,6 +142,7 @@ public class AiSipCallUserController extends BaseController
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         aiSipCallUser.setUpdateBy(loginUser.getUser().getUserName());
         aiSipCallUser.setUpdateTime(new Date());
+        aiSipCallUser.setUserSource("0");
         return toAjax(aiSipCallUserService.updateAiSipCallUser(aiSipCallUser));
     }
 
@@ -232,4 +234,15 @@ public class AiSipCallUserController extends BaseController
         }
         return aiSipCallUserService.getToolbarBasicParam(param);
     }
+
+    /**
+     * 登录外呼平台接口
+     * @param param 参数
+     * @return AjaxResult 结果
+     */
+    @PostMapping("/agentLogin")
+    public AjaxResult agentLogin(@RequestBody Map<String,Object> param)
+    {
+        return aiSipCallUserService.agentLogin(param);
+    }
 }

+ 78 - 0
fs-company/src/main/java/com/fs/company/controller/company/CompanyUserController.java

@@ -21,14 +21,17 @@ import com.fs.company.domain.*;
 import com.fs.company.param.CompanyUserAreaParam;
 import com.fs.company.param.CompanyUserCodeParam;
 import com.fs.company.param.CompanyUserQwParam;
+import com.fs.company.param.companyUserAddPrintParam;
 import com.fs.company.service.*;
 import com.fs.company.service.impl.CompanyDeptServiceImpl;
 import com.fs.company.utils.DomainUtil;
 import com.fs.company.utils.QwStatusEnum;
 import com.fs.company.vo.*;
+import com.fs.config.ai.AiHostProper;
 import com.fs.config.cloud.CloudHostProper;
 import com.fs.config.saas.ProjectConfig;
 import com.fs.course.config.CourseConfig;
+import com.fs.fastgptApi.util.AudioUtils;
 import com.fs.framework.datasource.DynamicDataSourceContextHolder;
 import com.fs.framework.datasource.TenantDataSourceManager;
 import com.fs.framework.security.LoginUser;
@@ -46,8 +49,11 @@ import com.fs.qw.service.IQwCompanyService;
 import com.fs.qw.service.IQwUserService;
 import com.fs.qw.vo.CompanyUserQwVO;
 import com.fs.qw.vo.QwUserVO;
+import com.fs.sop.service.IQwSopTempVoiceService;
 import com.fs.system.domain.SysConfig;
 import com.fs.system.mapper.SysConfigMapper;
+import com.fs.system.oss.CloudStorageService;
+import com.fs.system.oss.OSSFactory;
 import com.fs.system.service.ISysConfigService;
 import com.fs.tenant.domain.TenantInfo;
 import com.fs.tenant.mapper.TenantInfoMapper;
@@ -65,6 +71,8 @@ import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 
 import javax.servlet.http.HttpServletRequest;
+import java.io.File;
+import java.io.FileInputStream;
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 import java.util.*;
@@ -963,4 +971,74 @@ public class CompanyUserController extends BaseController {
         List<com.fs.hisStore.domain.FsUserScrm> userList = companyUserService.selectBoundFsUsersByCompanyUserId(companyUserId);
         return R.ok().put("data", userList);
     }
+
+    @ApiOperation("上传声纹")
+    @PostMapping("/addVoicePrintUrl")
+    public R addVoicePrintUrl(@RequestBody companyUserAddPrintParam param) throws Exception {
+        Long userId = param.getCompanyUserId();
+        if (userId == null) {
+            try {
+                userId = getUserId();
+            } catch (Exception e) {
+                return R.error("用户失效");
+            }
+        }
+        if (userId == null) {
+            return R.error("用户失效");
+        }
+        if (StringUtils.isEmpty(param.getVoicePrintUrl())) {
+            return R.error("声纹地址不能为空");
+        }
+        CompanyUser companyUser = new CompanyUser();
+        companyUser.setUserId(userId);
+        companyUser.setVoicePrintUrl(param.getVoicePrintUrl());
+
+        //转换音频格式 mp3-wav
+        String s = AudioUtils.audioWAVFromUrl(param.getVoicePrintUrl());
+        if (StringUtils.isEmpty(s)) {
+            return R.error("音频转换失败,请检查 ffmpeg 是否可用及 c:\\hook 目录是否存在");
+        }
+        File file = new File(s);
+        if (!file.isFile()) {
+            return R.error("音频转换失败,未生成 wav 文件: " + s);
+        }
+
+        //保存文件并且上传存储桶
+        FileInputStream fileInputStream = new FileInputStream(file);
+        CloudStorageService storage = OSSFactory.build();
+        String wavUrl = storage.uploadSuffix(fileInputStream, ".wav");
+
+        //更新销售员工声纹
+        companyUser.setVoicePrintUrl(wavUrl);
+        companyUserService.updateCompanyUser(companyUser);
+
+//        try {
+//            CloseableHttpClient httpClient = HttpClients.createDefault();
+//            HttpPost httpPost = new HttpPost(aiHostProper.getCommonApi()+"/app/common/addCompanyAudio");
+//            String json = "{\"url\":\""+wavUrl+"\",\"id\":\""+userId+"\"}";
+//            StringEntity entity = new StringEntity(json);
+//            httpPost.setEntity(entity);
+//            httpPost.setHeader("Content-type", "application/json");
+//            HttpResponse response = httpClient.execute(httpPost);
+//
+//            if (response.getStatusLine().getStatusCode() == 200) {
+//                String responseBody = EntityUtils.toString(response.getEntity());
+//                com.alibaba.fastjson.JSONObject jsonObject = JSON.parseObject(responseBody);
+//                Integer code = (Integer)jsonObject.get("code");
+//                if (code==200){
+//                    voiceService.insertQwSopTempVoiceModel(userId);
+//                    return R.ok();
+//                }
+//            } else {
+//                return R.error();
+//            }
+//
+//            httpClient.close();
+//        } catch (Exception e) {
+//            e.printStackTrace();
+//        }
+
+        return R.ok();
+
+    }
 }

+ 1 - 0
fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticCallLogCallphoneController.java

@@ -55,6 +55,7 @@ public class CompanyVoiceRoboticCallLogCallphoneController extends BaseControlle
                 companyVoiceRoboticCallLogCallphone.setCallerIds(calleeIds);
                 startPage();
                 List<CompanyVoiceRoboticCallLogCallphone> list = companyVoiceRoboticCallLogCallphoneService.selectCompanyVoiceRoboticCallLogCallphoneListData(companyVoiceRoboticCallLogCallphone);
+
                 return getDataTable(list);
             } else {
                 return getDataTable(new ArrayList<>());

+ 8 - 0
fs-company/src/main/java/com/fs/company/controller/crm/CrmCustomerController.java

@@ -80,6 +80,10 @@ public class CrmCustomerController extends BaseController
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         PageHelper.startPage(param.getPageNum(), param.getPageSize());
         param.setCompanyId(loginUser.getCompany().getCompanyId());
+        if(!StringUtils.isEmpty(param.getEncryptedMobile())){
+            param.setMobile(PhoneUtil.encryptPhone(param.getEncryptedMobile()));
+            param.setEncryptedMobile(null);
+        }
         List<CrmLineCustomerListQueryVO> list = crmCustomerService.selectCrmLineCustomerListQuery(param);
         if (list != null) {
             for (CrmLineCustomerListQueryVO vo : list) {
@@ -195,6 +199,10 @@ public class CrmCustomerController extends BaseController
         if(!StringUtils.isEmpty(param.getCreateTimeRange())){
             param.setCustomerCreateTime(param.getCreateTimeRange().split("--"));
         }
+        if(!StringUtils.isEmpty(param.getEncryptedMobile())){
+            param.setMobile(PhoneUtil.encryptPhone(param.getEncryptedMobile()));
+            param.setEncryptedMobile(null);
+        }
         List<CrmMyCustomerListQueryVO> list = crmCustomerService.selectCrmMyCustomerListQuery(param);
         if (list != null) {
             for (CrmMyCustomerListQueryVO vo : list) {

+ 111 - 0
fs-company/src/main/java/com/fs/company/controller/crm/ManualOutboundCallLogController.java

@@ -0,0 +1,111 @@
+package com.fs.company.controller.crm;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.company.domain.CrmCustomerCallLog;
+import com.fs.company.service.ICrmCustomerCallLogService;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.security.SecurityUtils;
+import com.fs.his.utils.PhoneUtil;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 手动外呼通话记录Controller
+ *
+ * @author MixLiu
+ * @date 2026/5/25 18:23
+ */
+@RestController
+@RequestMapping("/crm/manualOutboundCallLog")
+public class ManualOutboundCallLogController extends BaseController {
+
+    @Autowired
+    private ICrmCustomerCallLogService crmCustomerCallLogService;
+
+    /**
+     * 查询手动外呼通话记录列表(管理员-查看公司全部)
+     */
+    @PreAuthorize("@ss.hasPermi('crm:manualOutboundCall:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(CrmCustomerCallLog crmCustomerCallLog) {
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        crmCustomerCallLog.setCompanyId(loginUser.getCompany().getCompanyId());
+        encryptCallerNum(crmCustomerCallLog);
+        startPage();
+        List<CrmCustomerCallLog> list = crmCustomerCallLogService.selectCrmCustomerCallLogList(crmCustomerCallLog);
+        return getDataTable(list);
+    }
+
+    /**
+     * 查询我的手动外呼通话记录列表(个人-只看自己)
+     */
+    @PreAuthorize("@ss.hasPermi('crm:manualOutboundCall:myList')")
+    @GetMapping("/myList")
+    public TableDataInfo myList(CrmCustomerCallLog crmCustomerCallLog) {
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        crmCustomerCallLog.setCompanyId(loginUser.getCompany().getCompanyId());
+        crmCustomerCallLog.setCompanyUserId(loginUser.getUser().getUserId());
+        encryptCallerNum(crmCustomerCallLog);
+        startPage();
+        List<CrmCustomerCallLog> list = crmCustomerCallLogService.selectCrmCustomerCallLogList(crmCustomerCallLog);
+        return getDataTable(list);
+    }
+
+    /**
+     * 查询手动外呼通话记录计费分钟数总和(管理员-查看公司全部)
+     */
+    @PreAuthorize("@ss.hasPermi('crm:manualOutboundCall:list')")
+    @GetMapping("/sumBillingMinute")
+    public AjaxResult sumBillingMinute(CrmCustomerCallLog crmCustomerCallLog) {
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        crmCustomerCallLog.setCompanyId(loginUser.getCompany().getCompanyId());
+        encryptCallerNum(crmCustomerCallLog);
+        Long sum = crmCustomerCallLogService.selectSumBillingMinute(crmCustomerCallLog);
+        Map<String, Object> result = new HashMap<>();
+        result.put("sumBillingMinute", sum);
+        return AjaxResult.success(result);
+    }
+
+    /**
+     * 查询我的手动外呼通话记录计费分钟数总和(个人-只看自己)
+     */
+    @PreAuthorize("@ss.hasPermi('crm:manualOutboundCall:myList')")
+    @GetMapping("/mySumBillingMinute")
+    public AjaxResult mySumBillingMinute(CrmCustomerCallLog crmCustomerCallLog) {
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        crmCustomerCallLog.setCompanyId(loginUser.getCompany().getCompanyId());
+        crmCustomerCallLog.setCompanyUserId(loginUser.getUser().getUserId());
+        encryptCallerNum(crmCustomerCallLog);
+        Long sum = crmCustomerCallLogService.selectSumBillingMinute(crmCustomerCallLog);
+        Map<String, Object> result = new HashMap<>();
+        result.put("sumBillingMinute", sum);
+        return AjaxResult.success(result);
+    }
+
+    /**
+     * 处理客户号码查询参数,区分明文和加密两种匹配方式:
+     * 1. 手机(callerNum):输入明文号码,不加密,直接匹配 caller_num 字段(匹配历史明文数据)
+     * 2. 加密手机(encryptedCallerNum):输入明文号码,加密后设为 callerNum 匹配密文数据,
+     *    同时将原始明文保留到 plainCallerNum 匹配历史明文数据(OR条件覆盖两种存储格式)
+     * Mapper SQL: AND (caller_num = #{callerNum} OR caller_num = #{plainCallerNum})
+     */
+    private void encryptCallerNum(CrmCustomerCallLog param) {
+        if (StringUtils.isNotBlank(param.getEncryptedCallerNum())) {
+            // 加密手机输入:加密后匹配密文,原始明文保留匹配历史明文(OR覆盖)
+            param.setPlainCallerNum(param.getEncryptedCallerNum());
+            param.setCallerNum(PhoneUtil.encryptPhone(param.getEncryptedCallerNum()));
+            param.setEncryptedCallerNum(null);
+        }
+        // 手机输入:不加密,直接传明文匹配历史明文数据
+    }
+}

+ 5 - 0
fs-company/src/main/java/com/fs/company/controller/third/TencentWordOpenApiController.java

@@ -31,6 +31,11 @@ public class TencentWordOpenApiController extends BaseController {
         return R.ok();
     }
 
+    @PostMapping("/syncToKnowledgeBase")
+    public R syncToKnowledgeBase(@RequestParam String fileId, @RequestParam Long knowledgeBaseId) {
+        return tencentWordService.syncToKnowledgeBase(fileId, knowledgeBaseId);
+    }
+
     @GetMapping("/authorizeCallback")
     public void authorizeCallback(Request request) {
         //tencent开放平台登录回调(二维码登录等)

+ 21 - 0
fs-service/src/main/java/com/fs/aiSipCall/RemoteCommon.java

@@ -95,6 +95,11 @@ public class RemoteCommon {
      */
     public static final String QUERY_OUTBOUNDCDR_LIST_API = "/aicall/api/outboundcdrList";
 
+    /**
+     * 登录外呼平台接口
+     */
+    public static final String AI_CALL_LOGIN_API = "/login";
+
     /**
      * 发送get请求
      * @param url   地址
@@ -123,4 +128,20 @@ public class RemoteCommon {
         }
         return null;
     }
+
+    /**
+     * 发送POST表单提交请求
+     * @param url   地址
+     * @param params 表单参数
+     * @return  String  结果
+     */
+    public static String sendPostForm(String url, java.util.Map<String, Object> params){
+        try{
+            return HttpUtil.post(url, params, 10 * 1000);
+        }catch (Exception e){
+            e.printStackTrace();
+            log.info("sendPostForm error");
+        }
+        return null;
+    }
 }

+ 38 - 0
fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipCallOutboundCdr.java

@@ -69,6 +69,44 @@ public class AiSipCallOutboundCdr implements Serializable {
     @Excel(name = "挂断原因")
     private String hangupCause;
 
+    /** 录音文件URL */
+    private String wavfile;
+
+    /** 外呼类型(0销售后台 1总后台 3APP) */
+    @Excel(name = "外呼类型", readConverterExp = "0=销售后台,1=总后台,3=APP")
+    private String sourceType;
+
+    /** 客户ID */
+    @Excel(name = "客户ID")
+    private Long customerId;
+
+    /** 销售公司ID */
+    @Excel(name = "销售公司ID")
+    private Long companyId;
+
+    /** 销售ID */
+    @Excel(name = "销售ID")
+    private Long companyUserId;
+
+    /** 销售公司名称 */
+    @Excel(name = "销售公司名称")
+    private String companyName;
+
+    /** 销售账号 */
+    @Excel(name = "销售账号")
+    private String companyUserName;
+
+    /** 总后台用户ID */
+    @Excel(name = "总后台用户ID")
+    private Long sysUserId;
+
+    /** 总后台用户账号 */
+    @Excel(name = "总后台用户账号")
+    private String sysUserName;
+
+    /** 状态(0正常 1删除) */
+    private Integer status;
+
     /** 通话总时长起止 */
     private Long timeLenStart;
     private Long timeLenEnd;

+ 12 - 0
fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipCallUser.java

@@ -98,5 +98,17 @@ public class AiSipCallUser extends BaseEntity{
     private Long companyId;
     private Long companyUserId;
 
+    /** 用户来源0销售 1总后台 */
+    private String userSource;
+    /** 总后台用户ID */
+    private Long sysUserId;
+    /** 总后台用户账号 */
+    private String sysUserName;
+    /** 分机密码(SIP注册必需) */
+    private String extPass;
+    /** 分机绑定用户工号 */
+    private String userCode;
+    /** 销售公司名称 */
+    private String companyName;
 
 }

+ 13 - 0
fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallOutboundCdrService.java

@@ -67,8 +67,21 @@ public interface IAiSipCallOutboundCdrService extends IService<AiSipCallOutbound
 
     AjaxResult getCustCommunicationInfo(String phoneNum, Integer callType, String uuid);
 
+    /**
+     * 获取手动外呼客户沟通信息(带拨号模式 dialMode)
+     * <p>当 dialMode = "encrypted" 时,phoneNum 为密文,需先解密再透传给 IPCC。
+     * <p>本方法为 his_java 兼容方法,原 3 参方法保留不动。
+     */
+    AjaxResult getCustCommunicationInfo(String phoneNum, Integer callType, String uuid, String dialMode);
+
     AjaxResult addCustcallrecord(CcCustInfo ccCustInfo);
 
+    /**
+     * 通话挂断后根据UUID同步话单(迁移自 his_java,APP/PC软电话挂机回调使用)
+     * <p>调用方需先填好 sourceType / companyId / companyUserId / companyUserName / status / sysUserId 等字段。
+     */
+    void callEndSyncByUuid(AiSipCallOutboundCdr request);
+
     CompletableFuture<String> scheduledGetCallRecord();
 
     int syncByUuid(ApiCallRecordByUuidQueryParams req, EasyCallOutBoundVO callPhoneRes);

+ 6 - 0
fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallUserService.java

@@ -99,4 +99,10 @@ public interface IAiSipCallUserService extends IService<AiSipCallUser>{
      * @return
      */
     String getGateWayIdListByCompanyId(Long companyId);
+
+    /**
+     * 坐席登录IPCC外呼平台(迁移自 his_java,APP/PC软电话工具条登录使用)
+     * @param param 包含 username/password/extNum 等表单参数
+     */
+    AjaxResult agentLogin(java.util.Map<String, Object> param);
 }

+ 78 - 1
fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallOutboundCdrServiceImpl.java

@@ -17,6 +17,7 @@ import com.fs.aiSipCall.service.IAiSipCallOutboundCdrService;
 import com.fs.aiSipCall.utils.DateUtils;
 import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.core.redis.RedisCache;
+import com.fs.his.utils.PhoneUtil;
 import com.fs.company.domain.CompanyAiWorkflowExec;
 import com.fs.company.domain.CrmCustomerCallLog;
 import com.fs.company.domain.CompanyVoiceRoboticBusiness;
@@ -36,6 +37,7 @@ import org.springframework.stereotype.Service;
 
 import java.math.BigDecimal;
 import java.math.RoundingMode;
+import java.net.URLDecoder;
 import java.util.*;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -208,6 +210,80 @@ public class AiSipCallOutboundCdrServiceImpl extends ServiceImpl<AiSipCallOutbou
         return AjaxResult.error();
     }
 
+    /**
+     * 获取手动外呼客户沟通信息(带拨号模式 dialMode,迁移自 his_java)
+     */
+    @Override
+    public AjaxResult getCustCommunicationInfo(String phoneNum, Integer callType, String uuid, String dialMode) {
+        if (StringUtils.isNotBlank(dialMode) && "encrypted".equals(dialMode)) {
+            //密文需要解密
+            phoneNum = PhoneUtil.decryptPhone(phoneNum);
+        }
+        String paramStr = "?phoneNum=" + phoneNum + "&callType=" + callType + "&uuid=" + uuid;
+        String result = RemoteCommon.sendGet(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.GET_CUST_COMMUNICATION_INFO_API + paramStr);
+        String msg;
+        if (StringUtils.isNotBlank(result)) {
+            JSONObject jsonObject = JSONObject.parseObject(result);
+            if (jsonObject.getInteger("code") == 0) {
+                return JSONObject.parseObject(result, AjaxResult.class);
+            } else {
+                msg = "获取手动外呼客户沟通信息失败:" + jsonObject.getString("msg");
+            }
+        } else {
+            msg = "获取手动外呼客户沟通信息失败:接口返回为空";
+        }
+        return AjaxResult.error(msg);
+    }
+
+    /**
+     * 通话挂断后根据UUID同步话单(迁移自 his_java,APP/PC软电话挂机回调使用)
+     */
+    @Override
+    public void callEndSyncByUuid(AiSipCallOutboundCdr request) {
+        String result = RemoteCommon.sendPost(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.QUERY_OUTBOUNDCDR_LIST_API, JSONObject.toJSONString(request));
+        if (StringUtils.isNotBlank(result)) {
+            JSONObject jsonObject = JSONObject.parseObject(result);
+            Integer code = jsonObject.getInteger("code");
+            if (code != null && code == 0) {
+                String rows = jsonObject.getString("rows");
+                if (StringUtils.isNotBlank(rows)) {
+                    List<AiSipCallOutboundCdr> list = JSONObject.parseArray(rows, AiSipCallOutboundCdr.class);
+                    if (list != null && !list.isEmpty()) {
+                        AiSipCallOutboundCdr data = list.get(0);
+                        data.setSourceType("3");
+                        data.setCompanyId(request.getCompanyId());
+                        data.setCompanyUserId(request.getCompanyUserId());
+                        data.setCompanyUserName(request.getCompanyUserName());
+                        data.setStatus(request.getStatus());
+                        data.setSysUserId(request.getSysUserId());
+                        data.setSysUserName(request.getSysUserName());
+                        data.setCustomerId(request.getCustomerId());
+                        //拼接数据
+                        String filename = data.getRecordFilename().startsWith("/") ? data.getRecordFilename().substring(1) : data.getRecordFilename();
+                        data.setWavfile("/recordings/files?filename=" + filename);
+                        // 对opnum字段进行URL解码,防止乱码
+                        if (StringUtils.isNotBlank(data.getOpnum())) {
+                            try {
+                                data.setOpnum(URLDecoder.decode(data.getOpnum(), "UTF-8"));
+                            } catch (Exception e) {
+                                log.error("opnum字段URL解码失败,原值:{}", data.getOpnum(), e);
+                            }
+                        }
+                        this.save(data);
+                    } else {
+                        log.error("获取手动外呼记录接口转化数据失败:原数据:{},原因:{}", rows, jsonObject.getString("msg"));
+                    }
+                } else {
+                    log.error("获取手动外呼记录接口获取rows失败:原因:{}", jsonObject.getString("msg"));
+                }
+            } else {
+                log.error("同步手动外呼记录接口失败:返回状态码:{},原因:{}", code, jsonObject.getString("msg"));
+            }
+        } else {
+            log.error("同步手动外呼记录接口失败:无返回结果");
+        }
+    }
+
     private final AtomicBoolean isRunning = new AtomicBoolean(false);
     @Override
     @Async
@@ -592,7 +668,7 @@ public class AiSipCallOutboundCdrServiceImpl extends ServiceImpl<AiSipCallOutbou
         callLog.setCreateTime(new Date());
         callLog.setRecordPath(callPhoneRes.getRecordFilename());
         callLog.setContentList(callPhoneRes.getChatContent());
-        callLog.setCallerNum(callPhoneRes.getCallee());
+        callLog.setCallerNum(PhoneUtil.encryptPhone(callPhoneRes.getCallee()));
         callLog.setCalleeNum(callPhoneRes.getCaller());
         callLog.setUuid(req.getUuid());
         callLog.setCallCreateTime(callPhoneRes.getStartTime());
@@ -631,6 +707,7 @@ public class AiSipCallOutboundCdrServiceImpl extends ServiceImpl<AiSipCallOutbou
             BigDecimal minuteCount = callTimeSecond.divide(ONE_MINUTES_SECOND, 0, RoundingMode.CEILING);
             BigDecimal cost = minuteCount.multiply(callCharge);
             callLog.setCost(cost);
+            callLog.setBillingMinute(minuteCount.intValue());
         }
 
         return crmCustomerCallLogMapper.insertCrmCustomerCallLog(callLog);

+ 42 - 1
fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallUserServiceImpl.java

@@ -101,6 +101,9 @@ public class AiSipCallUserServiceImpl extends ServiceImpl<AiSipCallUserMapper, A
                     if(remoteAiSipCallUser.getCreateTime() != null){
                         aiSipCallUser.setCreateTime(remoteAiSipCallUser.getCreateTime());
                     }
+                    if(StringUtils.isNotBlank(remoteAiSipCallUser.getExtPass())){
+                        aiSipCallUser.setExtPass(remoteAiSipCallUser.getExtPass());
+                    }
                     int i = baseMapper.insertAiSipCallUser(aiSipCallUser);
                     if( i> 0){
                         //绑定companyUser的aiSIP外呼用户
@@ -146,6 +149,11 @@ public class AiSipCallUserServiceImpl extends ServiceImpl<AiSipCallUserMapper, A
                 }else{
                     throw new RuntimeException("分机号已被绑定,请刷新后重试");
                 }
+                //获取分机密码
+                if(bind.getExtensionPass() != null){
+                    aiSipCallUser.setExtPass(bind.getExtensionPass());
+                    baseMapper.updateAiSipCallUser(aiSipCallUser);
+                }
             }
             if (aiSipCallUser.getCompanyUserId() != null && aiSipCallUser.getUserId() != null) {
                 companyUserMapper.updateCompanyUserByAiSipCall(aiSipCallUser.getCompanyUserId(), aiSipCallUser.getUserId());
@@ -167,6 +175,15 @@ public class AiSipCallUserServiceImpl extends ServiceImpl<AiSipCallUserMapper, A
         if(StringUtils.isNotBlank(result)){
             JSONObject jsonObject = JSONObject.parseObject(result);
             if(jsonObject.getInteger("code") == 0){
+                String data = jsonObject.getString("data");
+                AiSipCallUser remoteAiSipCallUser = JSONObject.parseObject(data, AiSipCallUser.class);
+                if(remoteAiSipCallUser != null){
+                    if(StringUtils.isNotBlank(remoteAiSipCallUser.getExtPass())){
+                        aiSipCallUser.setExtPass(remoteAiSipCallUser.getExtPass());
+                    }
+                }else{
+                    log.error("新增时解析aiSIP外呼用户数据为空");
+                }
                 return baseMapper.updateAiSipCallUser(aiSipCallUser);
             }else{
                 log.error("修改aiSIP外呼任务失败:{}", jsonObject.getString("msg"));
@@ -187,13 +204,16 @@ public class AiSipCallUserServiceImpl extends ServiceImpl<AiSipCallUserMapper, A
     public AiSipCallUserNewVO updateAiSipCallUserNew(AiSipCallUser aiSipCallUser){
         AiSipCallUserNewVO result= new AiSipCallUserNewVO();
         CompanyExtensionBind bind = companyExtensionBindService.selectUnBindByExtNum(String.valueOf(aiSipCallUser.getExtNum()), aiSipCallUser.getCompanyId());
-        if (bind != null && bind.getCompanyUserId() == null) {
+        if (bind != null && (bind.getCompanyUserId() == null || bind.getCompanyUserId().equals(aiSipCallUser.getCompanyUserId()))) {
             AiSipCallUser oldUser = aiSipCallUserMapper.selectAiSipCallUserByUserId(aiSipCallUser.getUserId());
             String oldExtNum = (oldUser != null) ? oldUser.getExtNum() : null;
             String newExtNum = aiSipCallUser.getExtNum();
             result.setNewExtNum(newExtNum);
             result.setOldExtNum(oldExtNum);
             result.setNewUserCode(aiSipCallUser.getLoginName());
+            if(StringUtils.isEmpty(oldUser.getExtPass()) && StringUtils.isNotBlank(bind.getExtensionPass())){
+                aiSipCallUser.setExtPass(bind.getExtensionPass());
+            }
             int rows = aiSipCallUserMapper.updateAiSipCallUser(aiSipCallUser);
             //解除绑定
             companyExtensionBindService.clearBindByExtNum(oldExtNum, aiSipCallUser.getCompanyId(), aiSipCallUser.getCompanyUserId());
@@ -310,4 +330,25 @@ public class AiSipCallUserServiceImpl extends ServiceImpl<AiSipCallUserMapper, A
         return "";
     }
 
+    /**
+     * 坐席登录IPCC外呼平台(迁移自 his_java)
+     */
+    @Override
+    public AjaxResult agentLogin(Map<String, Object> param) {
+        String result = RemoteCommon.sendPostForm(RemoteCommon.REMOTE_ADDERSS_PREFIX + RemoteCommon.AI_CALL_LOGIN_API, param);
+        String msg;
+        if (StringUtils.isNotBlank(result)) {
+            JSONObject jsonObject = JSONObject.parseObject(result);
+            Integer code = jsonObject.getInteger("code");
+            if (code != null && code == 0) {
+                return JSONObject.parseObject(result, AjaxResult.class);
+            } else {
+                msg = jsonObject.getString("msg");
+            }
+        } else {
+            msg = "登录外呼平台接口失败:接口返回为空";
+        }
+        return AjaxResult.error(msg);
+    }
+
 }

+ 1 - 0
fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRobotic.java

@@ -104,6 +104,7 @@ public class CompanyVoiceRobotic {
     private String runTaskFlow;
     // 任务状态0待执行1执行中2执行中断3执行完成
     private Integer taskStatus;
+    private String pauseSource;
     private Integer addWxTime;
     /** 创建人 */
     @Excel(name = "创建时间")

+ 4 - 0
fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRoboticCallLogCallphone.java

@@ -105,6 +105,10 @@ public class CompanyVoiceRoboticCallLogCallphone extends BaseEntity{
     @Excel(name = "外呼类型")
     private Integer callType;
 
+    /** 未拨通类型 */
+    @Excel(name = "未拨通类型")
+    private String hangupType;
+
     /** 是否警告(0否 1是)用于敏感词 */
     @Excel(name = "是否警告(0否 1是)用于敏感词")
     private Integer isWarning;

+ 24 - 1
fs-service/src/main/java/com/fs/company/domain/CrmCustomerCallLog.java

@@ -152,8 +152,31 @@ public class CrmCustomerCallLog extends BaseEntity {
     private Integer callType;
 
     /**
-     * 查询条件:最小通话时长(秒),仅过滤 call_time &gt; minCallTime 的记录;传 0 即可筛选“已接通”
+     * 查询条件:加密客户号码(前端输入明文手机号后,由Controller加密设置到callerNum)
+     * 非持久化字段,insert SQL 使用显式字段列表,不受影响
+     */
+    private String encryptedCallerNum;
+
+    /**
+     * 查询条件:明文客户号码(由Controller将原始输入保留到此字段,用于匹配历史明文数据)
+     * 非持久化字段,insert SQL 使用显式字段列表,不受影响
+     */
+    private String plainCallerNum;
+
+    /**
+     * 查询条件:最小通话时长(秒),SQL使用CEILING(call_time/1000)匹配
      * 非持久化字段,insert SQL 使用显式字段列表,不受影响
      */
     private Long minCallTime;
+    
+    /**
+     * 查询条件:最大通话时长(秒),SQL使用CEILING(call_time/1000)匹配
+     * 非持久化字段,insert SQL 使用显式字段列表,不受影响
+     */
+    private Long maxCallTime;
+
+    /**
+     * 计费分钟数
+     */
+    private Integer billingMinute;
 }

+ 2 - 0
fs-service/src/main/java/com/fs/company/mapper/CrmCustomerCallLogMapper.java

@@ -16,4 +16,6 @@ public interface CrmCustomerCallLogMapper extends BaseMapper<CrmCustomerCallLog>
     int insertCrmCustomerCallLog(CrmCustomerCallLog callLog);
 
     List<CrmCustomerCallLog> selectCrmCustomerCallLogList(CrmCustomerCallLog crmCustomerCallLog);
+
+    Long selectSumBillingMinute(CrmCustomerCallLog crmCustomerCallLog);
 }

+ 8 - 0
fs-service/src/main/java/com/fs/company/service/ICrmCustomerCallLogService.java

@@ -19,4 +19,12 @@ public interface ICrmCustomerCallLogService {
      * @return 客户通话记录集合
      */
     List<CrmCustomerCallLog> selectCrmCustomerCallLogList(CrmCustomerCallLog crmCustomerCallLog);
+
+    /**
+     * 查询客户通话记录计费分钟数总和
+     *
+     * @param crmCustomerCallLog 客户通话记录查询条件
+     * @return 计费分钟数总和
+     */
+    Long selectSumBillingMinute(CrmCustomerCallLog crmCustomerCallLog);
 }

+ 3 - 2
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallLogCallphoneServiceImpl.java

@@ -29,6 +29,7 @@ import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
 import com.fs.company.vo.easycall.EasyCallCallPhoneVO;
 import com.fs.core.config.TenantConfigContext;
 import com.fs.crm.service.ICrmCustomerPropertyService;
+import com.fs.his.utils.PhoneUtil;
 import com.fs.qw.domain.QwUser;
 import com.fs.qw.mapper.QwUserMapper;
 import com.fs.sensitive.DTO.AgentSensitiveWordDetectResultDTO;
@@ -331,7 +332,7 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
 
                     companyVoiceRoboticCallLog.setStatus(2);
                     companyVoiceRoboticCallLog.setResult(JSON.toJSONString(result));
-
+                    companyVoiceRoboticCallLog.setHangupType(result.getUnconnectedReason());
                     CompanyWxClient companyWxClient = companyWxClientServiceImpl.getOne(new QueryWrapper<CompanyWxClient>().eq("robotic_id", callees.getRoboticId()).eq("customer_id", callees.getUserId()));
                     CompanyVoiceRoboticWx roboticWx = companyVoiceRoboticWxServiceImpl.getById(companyWxClient.getRoboticWxId());
                     Long setCompanyUserId = null;
@@ -357,7 +358,7 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
                     companyVoiceRoboticCallLog.setRecordPath(result.getWavfile());
                     companyVoiceRoboticCallLog.setCallType(2);
                     companyVoiceRoboticCallLog.setContentList(result.getDialogue());
-                    companyVoiceRoboticCallLog.setCallerNum(result.getTelephone());
+                    companyVoiceRoboticCallLog.setCallerNum(PhoneUtil.encryptPhone(result.getTelephone()));
                     companyVoiceRoboticCallLog.setCalleeNum(result.getCallerNumber());
                     companyVoiceRoboticCallLog.setUuid(result.getUuid());
                     Long createTime = result.getCalloutTime();

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

@@ -1431,6 +1431,8 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
         final Long workflowId = robotic.getCompanyAiWorkflowId();
         final Long roboticId = robotic.getId();
 
+        companyWorkflowEngine.createSipTask(roboticId, workflowId);
+
         // 先初始化所有工作流实例
         List<ExecutionResult> initResults = new ArrayList<>();
         for (CompanyVoiceRoboticBusiness business : roboticBusinesseList) {
@@ -1973,27 +1975,22 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
                 return R.error("任务不在执行中状态,无法暂停");
             }
             robotic.setTaskStatus(2);
+            robotic.setPauseSource("manual");
             updateCompanyVoiceRobotic(robotic);
-            // 更新Redis缓存
             redisCache2.setCacheObject("task:status:" + param.getTaskId(), 2);
-            // 调用EasyCall暂停
             pauseEasyCallTask(robotic);
             return R.ok("暂停成功");
         }
-        //恢复任务继续进入可运行
         else if (ACTIVE_TYPE_CONTINUE.equals(param.getActiveType())) {
             CompanyVoiceRobotic robotic = selectCompanyVoiceRoboticById(param.getTaskId());
             if (robotic == null || robotic.getTaskStatus() != 2) {
                 return R.error("任务不在中断状态,无法继续");
             }
             robotic.setTaskStatus(1);
+            robotic.setPauseSource(null);
             updateCompanyVoiceRobotic(robotic);
-            // 更新Redis缓存
             redisCache2.setCacheObject("task:status:" + param.getTaskId(), 1);
-            // 调用EasyCall恢复
             resumeEasyCallTask(robotic);
-            // 异步执行恢复扫描(分批处理,避免阻塞接口)
-            // 通过SpringUtils获取代理对象调用,确保@Async生效
             SpringUtils.getBean(CompanyVoiceRoboticServiceImpl.class).resumePausedInstances(param.getTaskId());
             return R.ok("继续成功");
         }

+ 33 - 18
fs-service/src/main/java/com/fs/company/service/impl/CompanyWorkflowEngineImpl.java

@@ -4,6 +4,7 @@ import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fs.common.core.redis.RedisCache;
 import com.fs.common.exception.CustomException;
 import com.fs.common.utils.StringUtils;
 import com.fs.company.domain.*;
@@ -29,6 +30,7 @@ import org.springframework.stereotype.Service;
 import java.time.LocalDateTime;
 import java.time.LocalTime;
 import java.util.*;
+import java.util.concurrent.TimeUnit;
 
 /**
  * @author MixLiu
@@ -75,6 +77,9 @@ public class CompanyWorkflowEngineImpl implements CompanyWorkflowEngine {
     @Autowired
     CompanyWxClientMapper companyWxClientMapper;
 
+    @Autowired
+    private RedisCache redisCache;
+
     @Autowired
     private CloudHostProper cloudHostProper;
 
@@ -102,8 +107,6 @@ public class CompanyWorkflowEngineImpl implements CompanyWorkflowEngine {
                     definition.getStartNodeKey(), context, definition);
 
             log.info("工作流初始化成功: {} -> {}", workflowInstanceId, workflowDefinitionId);
-            //为任务创建sip任务并存入表数据
-            createSipTask(Long.parseLong(inputVariables.get("roboticId").toString()),workflowDefinitionId);
             return ExecutionResult.success()
                     .nextNodeKey(definition.getStartNodeKey())
                     .workflowInstanceId(workflowInstanceId).build();
@@ -572,35 +575,44 @@ public class CompanyWorkflowEngineImpl implements CompanyWorkflowEngine {
      * @param workFlowId
      */
     public Long createSipTask(Long roboticId, Long workFlowId) {
-        try {
-            List<String> nodeTypes = Arrays.asList(NodeTypeEnum.AI_CALL_TASK.getCode());
-            CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectCompanyVoiceRoboticById(roboticId);
-            List<CompanyWorkflowNode> companyWorkflowNodes = companyWorkflowNodeMapper.selectNodesByWorkflowIdAndTypes(workFlowId, nodeTypes);
-            //为所有外呼节点创建任务的对应sip外呼任务
-            for (CompanyWorkflowNode callNode : companyWorkflowNodes) {
+        List<String> nodeTypes = Arrays.asList(NodeTypeEnum.AI_CALL_TASK.getCode());
+        CompanyVoiceRobotic robotic = companyVoiceRoboticMapper.selectCompanyVoiceRoboticById(roboticId);
+        List<CompanyWorkflowNode> companyWorkflowNodes = companyWorkflowNodeMapper.selectNodesByWorkflowIdAndTypes(workFlowId, nodeTypes);
+        for (CompanyWorkflowNode callNode : companyWorkflowNodes) {
+            String lockKey = "sipTask:lock:" + roboticId + ":" + callNode.getNodeKey();
+            boolean locked = redisCache.setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
+            if (!locked) {
+                log.info("createSipTask: 其他线程正在创建SIP任务,等待后重试 - roboticId: {}, nodeKey: {}", roboticId, callNode.getNodeKey());
+                for (int i = 0; i < 20; i++) {
+                    try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
+                    CompanySiptaskInfo existing = companySiptaskInfoMapper.selectSipTaskInfoByTaskIdAndNodeKey(roboticId, callNode.getNodeKey());
+                    if (existing != null && existing.getBatchId() != null) {
+                        return existing.getBatchId();
+                    }
+                }
+                log.warn("createSipTask: 等待超时,尝试直接创建 - roboticId: {}, nodeKey: {}", roboticId, callNode.getNodeKey());
+            }
+            try {
+                CompanySiptaskInfo existingSipTask = companySiptaskInfoMapper.selectSipTaskInfoByTaskIdAndNodeKey(roboticId, callNode.getNodeKey());
+                if (existingSipTask != null && existingSipTask.getBatchId() != null) {
+                    log.info("createSipTask: SIP任务已存在,跳过创建 - roboticId: {}, nodeKey: {}, batchId: {}", roboticId, callNode.getNodeKey(), existingSipTask.getBatchId());
+                    return existingSipTask.getBatchId();
+                }
                 String nodeConfig = callNode.getNodeConfig();
                 AiCallConfigVO callConfigVo = JSONObject.parseObject(nodeConfig, AiCallConfigVO.class);
                 EasyCallCreateTaskParam createParam = new EasyCallCreateTaskParam();
-                // 任务名称:使用任务名称_工作流id_节点key
                 createParam.setBatchName(cloudHostProper.getCompanyName()+"-"+robotic.getName() + "_" + workFlowId + "_" + callNode.getNodeKey());
                 if (null != callConfigVo.getMaxConcurrency()) {
                     createParam.setThreadNum(Long.valueOf(callConfigVo.getMaxConcurrency()));
                 } else {
                     createParam.setThreadNum(3L);
                 }
-                // AI 外呼模式
                 createParam.setTaskType(1);
-                // 外呼线路(网关)
                 createParam.setGatewayId(callConfigVo.getGatewayId());
-                // 大模型底座
                 createParam.setLlmAccountId(callConfigVo.getLlmAccountId());
-                // 音色编号
                 createParam.setVoiceCode(callConfigVo.getVoiceCode());
-                // 音色来源(如未配置默认留空,由 EasyCallCenter365 使用默认值)
                 createParam.setVoiceSource(callConfigVo.getVoiceSource());
-                // 技能组(转人工客服分组,可选)
                 createParam.setGroupId(callConfigVo.getBusiGroupId());
-                // 模型参数
                 createParam.setTtsModels(callConfigVo.getTtsModels());
 
                 if (callConfigVo.getExtensionList() != null && !callConfigVo.getExtensionList().isEmpty()) {
@@ -628,9 +640,12 @@ public class CompanyWorkflowEngineImpl implements CompanyWorkflowEngine {
                 sipTaskInfo.setTaskJson(JSONObject.toJSONString(task));
                 companySiptaskInfoMapper.insertCompanySiptaskInfo(sipTaskInfo);
                 return task.getBatchId();
+            } catch (Exception ex) {
+                log.error("创建SIP任务失败:{}", ex);
+                return null;
+            } finally {
+                redisCache.deleteObject(lockKey);
             }
-        } catch (Exception ex) {
-            log.error("创建SIP任务失败:{}", ex);
         }
         return null;
     }

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

@@ -30,4 +30,9 @@ public class CrmCustomerCallLogServiceImpl implements ICrmCustomerCallLogService
     public List<CrmCustomerCallLog> selectCrmCustomerCallLogList(CrmCustomerCallLog crmCustomerCallLog) {
         return crmCustomerCallLogMapper.selectCrmCustomerCallLogList(crmCustomerCallLog);
     }
+
+    @Override
+    public Long selectSumBillingMinute(CrmCustomerCallLog crmCustomerCallLog) {
+        return crmCustomerCallLogMapper.selectSumBillingMinute(crmCustomerCallLog);
+    }
 }

+ 34 - 5
fs-service/src/main/java/com/fs/company/service/impl/call/node/AiCallTaskNode.java

@@ -114,8 +114,8 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
                 //拨通
                 if (condition.isCallConnected() && callRes.getCallTime() != null && callRes.getCallTime() > 0) {
                     //如果含有意向度过滤
-                    if (StringUtils.isNotBlank(condition.getIntention())) {
-                        if (condition.getIntention().equals(callRes.getIntention())) {
+                    if (condition.getIntention() != null && !condition.getIntention().isEmpty()) {
+                        if (condition.getIntention().contains(callRes.getIntention())) {
                             runNextNode(context, edge);
                             runnableCount++;
                             break;
@@ -132,6 +132,15 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
                 }
                 //未拨通
                 else if (!condition.isCallConnected() && (callRes.getCallTime() == null || Long.valueOf(0).equals(callRes.getCallTime()) || callRes.getCallAnswerTime() == null)) {
+                    //如果含有未拨通类型过滤
+                    if (condition.getHangupType() != null && !condition.getHangupType().isEmpty()) {
+                        if (StringUtils.isNotBlank(callRes.getHangupType()) && condition.getHangupType().contains(callRes.getHangupType())) {
+                            //匹配到未拨通类型,继续执行
+                        } else {
+                            log.info("流程:{},节点:{},未拨通类型设置:{},实际未拨通类型:{}, 未拨通类型不符设置中断执行,", context.getWorkflowInstanceId(), nodeKey, condition.getHangupType(), callRes.getHangupType());
+                            continue;
+                        }
+                    }
                     //延时操作
                     if (null != condition.getCallTime() && condition.getCallTime() > 0) {
                         //计算延时分片分钟
@@ -422,9 +431,29 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
         if(null != sipTaskInfo && null != sipTaskInfo.getBatchId()){
             return  sipTaskInfo.getBatchId();
         }
-        //没有的情况下创建任务并返回
-        CompanyAiWorkflowExec companyAiWorkflowExec = companyAiWorkflowExecMapper.selectByWorkflowInstanceId(workflowInstanceId);
-        return companyWorkflowEngine.createSipTask(roboticId, companyAiWorkflowExec.getWorkflowId());
+        String lockKey = "sipTask:lock:" + roboticId + ":" + nodeKey;
+        boolean locked = super.redisCache.setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
+        if (!locked) {
+            log.info("getTaskBatchId: 其他线程正在创建SIP任务,等待后重试 - roboticId: {}, nodeKey: {}", roboticId, nodeKey);
+            for (int i = 0; i < 20; i++) {
+                try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
+                sipTaskInfo = companySiptaskInfoMapper.selectSipTaskInfoByTaskIdAndNodeKey(roboticId, nodeKey);
+                if (null != sipTaskInfo && null != sipTaskInfo.getBatchId()) {
+                    return sipTaskInfo.getBatchId();
+                }
+            }
+            log.warn("getTaskBatchId: 等待超时,尝试直接创建 - roboticId: {}, nodeKey: {}", roboticId, nodeKey);
+        }
+        try {
+            sipTaskInfo = companySiptaskInfoMapper.selectSipTaskInfoByTaskIdAndNodeKey(roboticId, nodeKey);
+            if (null != sipTaskInfo && null != sipTaskInfo.getBatchId()) {
+                return sipTaskInfo.getBatchId();
+            }
+            CompanyAiWorkflowExec companyAiWorkflowExec = companyAiWorkflowExecMapper.selectByWorkflowInstanceId(workflowInstanceId);
+            return companyWorkflowEngine.createSipTask(roboticId, companyAiWorkflowExec.getWorkflowId());
+        } finally {
+            super.redisCache.deleteObject(lockKey);
+        }
     }
 //    @Override
 //    protected void postExecute(ExecutionContext context, ExecutionResult result) {

+ 6 - 2
fs-service/src/main/java/com/fs/company/vo/AiCallWorkflowConditionVo.java

@@ -2,14 +2,18 @@ package com.fs.company.vo;
 
 import lombok.Data;
 
+import java.util.List;
+
 @Data
 public class AiCallWorkflowConditionVo {
     // 外呼配置-是否拨通
     private boolean callConnected;
     // 外呼配置-延迟时间
     private Integer callTime;
-    // 外呼配置-意向度
-    private String intention;
+    // 外呼配置-意向度(多选)
+    private List<String> intention;
+    // 外呼配置-未拨通类型(多选)
+    private List<String> hangupType;
 
 
     // 加微条件-是否同意

+ 5 - 0
fs-service/src/main/java/com/fs/company/vo/easycall/EasyCallCallPhoneVO.java

@@ -213,4 +213,9 @@ public class EasyCallCallPhoneVO {
      * The duration of the manual agent service time.
      */
     private Long manualAnsweredTimeLen;
+
+    /**
+     * 未接通原因(新增)
+     */
+    private String unconnectedReason;
 }

+ 9 - 0
fs-service/src/main/java/com/fs/crm/domain/CrmCustomer.java

@@ -1,6 +1,7 @@
 package com.fs.crm.domain;
 
 import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.fasterxml.jackson.annotation.JsonFormat;
 import com.fs.common.annotation.Excel;
@@ -197,4 +198,12 @@ public class CrmCustomer extends BaseEntity
     //    最后一次设置外呼记录id
     private Long lastEffectiveCallphoneLogId;
 
+    /** 是否可解密手机号 1=可解密 */
+    @TableField(exist = false)
+    private Integer canDecrypt;
+
+    /** 解密后的手机号明文 */
+    @TableField(exist = false)
+    private String decryptedMobile;
+
 }

+ 26 - 0
fs-service/src/main/java/com/fs/crm/mapper/CrmCustomerMapper.java

@@ -1012,6 +1012,32 @@ public interface CrmCustomerMapper extends BaseMapper<CrmCustomer> {
     @Select("select customer_id  from  crm_customer where company_id = #{companyId} and  mobile=#{remarkMobile} limit 1")
     Long selectCrmCustomerByCrmMobileAndCompanyId(@Param("companyId") Long companyId, @Param("remarkMobile") String remarkMobile);
 
+    /**
+     * 查询有效客户列表(effective_customer=1)
+     * callStatus: 0-全部, 1-已解密(有外呼记录), 2-未解密(无外呼记录)
+     */
+    @Select({"<script> " +
+            "SELECT c.*, CASE WHEN cdr.customer_id IS NOT NULL THEN 1 ELSE 0 END AS canDecrypt " +
+            "FROM crm_customer c " +
+            "LEFT JOIN (SELECT DISTINCT customer_id FROM ai_sip_call_outbound_cdr WHERE status = 0) cdr ON cdr.customer_id = c.customer_id " +
+            "WHERE c.effective_customer = 1 " +
+            "<if test='receiveUserId != null'> AND c.receive_user_id = #{receiveUserId} </if>" +
+            "<if test='startTime != null and startTime != \"\"'> AND c.create_time &gt; #{startTime} </if>" +
+            "<if test='endTime != null and endTime != \"\"'> AND c.create_time &lt;= #{endTime} </if>" +
+            "<if test='mobile != null and mobile != \"\"'> AND c.mobile LIKE CONCAT('%', #{mobile}, '%') </if>" +
+            "<if test='remark != null and remark != \"\"'> AND c.remark LIKE CONCAT('%', #{remark}, '%') </if>" +
+            "<if test='callStatus != null and callStatus == 1'> AND EXISTS (SELECT 1 FROM ai_sip_call_outbound_cdr cdr2 WHERE cdr2.customer_id = c.customer_id) </if>" +
+            "<if test='callStatus != null and callStatus == 2'> AND NOT EXISTS (SELECT 1 FROM ai_sip_call_outbound_cdr cdr2 WHERE cdr2.customer_id = c.customer_id) </if>" +
+            " ORDER BY c.customer_id DESC " +
+            "</script>"})
+    List<CrmCustomer> selectEffectiveCustomerList(
+            @Param("receiveUserId") Long receiveUserId,
+            @Param("startTime") String startTime,
+            @Param("endTime") String endTime,
+            @Param("mobile") String mobile,
+            @Param("remark") String remark,
+            @Param("callStatus") Integer callStatus);
+
     /**
      * 批量插入客户
      *

+ 3 - 0
fs-service/src/main/java/com/fs/crm/param/CrmLineCustomerListQueryParam.java

@@ -25,6 +25,9 @@ public class CrmLineCustomerListQueryParam extends BaseQueryParam
     @Excel(name = "手机")
     private String mobile;
 
+    /** 加密手机 */
+    private String encryptedMobile;
+
     /** 性别 */
     @Excel(name = "性别",readConverterExp = "1=男,2=女")
     private Integer sex;

+ 3 - 0
fs-service/src/main/java/com/fs/crm/param/CrmMyCustomerListQueryParam.java

@@ -27,6 +27,9 @@ public class CrmMyCustomerListQueryParam extends BaseQueryParam
     @Excel(name = "手机")
     private String mobile;
 
+    /** 加密手机 */
+    private String encryptedMobile;
+
     /** 性别 */
     @Excel(name = "性别",readConverterExp = "1=男,2=女")
     private Integer sex;

+ 15 - 0
fs-service/src/main/java/com/fs/fastGpt/domain/FastGptChatConversation.java

@@ -4,6 +4,9 @@ import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import lombok.Data;
 
+import java.util.List;
+import java.util.Map;
+
 @Data
 public class FastGptChatConversation {
     private JSONObject userInfo;
@@ -12,4 +15,16 @@ public class FastGptChatConversation {
     private String isRepository;
     private String userContent;
     private String aiContent;
+    //向量知识库检索结果
+    private List<Map<String,String>> knowledgeBase;
+    //企微标签
+    /**
+     * List<Map<分组名,Map<标签名,标签id>>>
+     */
+    private List<Map<String,Map<String,String>>> tagMapList;
+
+    /**
+     *  ai实际返回的标签
+     */
+    private String tags;
 }

+ 2 - 0
fs-service/src/main/java/com/fs/fastGpt/domain/FastGptRole.java

@@ -87,4 +87,6 @@ public class FastGptRole extends BaseEntity
 
     //需要获取的客户信息
     private String userInfo;
+
+    private String tagGroups;
 }

+ 1 - 1
fs-service/src/main/java/com/fs/fastGpt/mapper/FastGptRoleMapper.java

@@ -90,7 +90,7 @@ public interface FastGptRoleMapper
 
     @Select("select id dictValue,name dictLabel from fastgpt_role_type ")
     List<OptionsVO> selectFastGptRoleType();
-    @Select("select r.role_id, r.role_name,t.contact_info,r.company_id, r.create_time, r.update_time, r.role_type, r.mode_config_json, r.mode, r.kf_id, r.kf_url, r.avatar, r.kf_media_id,r.reminder_words, r.bind_corp_id,r.channel_type,r.send_course_status,r.course_id,r.user_info from fastgpt_role r LEFT JOIN fastgpt_role_type t on t.id =r.role_type where role_id = #{roleId}")
+    @Select("select r.role_id, r.role_name,t.contact_info,r.company_id, r.create_time, r.update_time, r.role_type, r.mode_config_json, r.mode, r.kf_id, r.kf_url, r.avatar, r.kf_media_id,r.reminder_words, r.bind_corp_id,r.channel_type,r.send_course_status,r.course_id,r.user_info,r.tag_groups from fastgpt_role r LEFT JOIN fastgpt_role_type t on t.id =r.role_type where role_id = #{roleId}")
     FastGptRole selectFastGptRoleTypeByRoleId(Long roleId);
 
     List<FastGptRole> selectFastGptRoleByRoleIds(@Param("roleIds") List<Long> roleIds);

+ 452 - 4
fs-service/src/main/java/com/fs/fastGpt/service/impl/AiHookServiceImpl.java

@@ -6,13 +6,17 @@ import cn.hutool.json.JSONUtil;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONArray;
 import com.fs.common.annotation.Excel;
-import com.fs.common.config.FSConfig;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.PubFun;
+import com.fs.common.utils.ServletUtils;
+import com.fs.company.domain.Company;
 import com.fs.company.domain.CompanyConfig;
 import com.fs.company.domain.CompanyUser;
 import com.fs.company.mapper.CompanyConfigMapper;
 import com.fs.company.mapper.CompanyUserMapper;
+import com.fs.company.service.ICompanyService;
+import com.fs.company.service.ICompanyUserService;
 import com.fs.config.ai.AiHostProper;
 import com.fs.config.cloud.CloudHostProper;
 import com.fs.course.domain.FsUserCourseVideo;
@@ -50,10 +54,20 @@ import com.fs.his.utils.ConfigUtil;
 import com.fs.hisStore.enums.SysConfigEnum;
 import com.fs.im.dto.OpenImMsgDTO;
 import com.fs.im.vo.OpenImMsgCallBackVO;
+import com.fs.ipad.IpadSendUtils;
+import com.fs.ipad.param.WxSendAtMsgParam;
+import com.fs.ipad.vo.WxGetSessionRoomListVo;
+import com.fs.ipad.vo.WxRoomUserListVo;
 import com.fs.qw.domain.*;
 import com.fs.qw.mapper.*;
 import com.fs.qw.param.QwAutoTagsRulesTags;
+import com.fs.qw.param.QwExternalContactAddTagParam;
+import com.fs.qw.param.QwExternalContactParam;
 import com.fs.qw.service.*;
+import com.fs.qw.vo.QwExternalContactVO;
+import com.fs.qw.vo.QwTagGroupListVO;
+import com.fs.qw.vo.QwTagGroupVO;
+import com.fs.qw.vo.QwTagVO;
 import com.fs.qwApi.config.OpenQwConfig;
 import com.fs.qwApi.domain.QwResult;
 import com.fs.qwApi.param.QwEditUserTagParam;
@@ -76,6 +90,7 @@ import org.jetbrains.annotations.Nullable;
 import org.json.JSONObject;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
@@ -94,6 +109,7 @@ import java.time.LocalTime;
 import java.time.format.DateTimeFormatter;
 import java.util.*;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
@@ -188,6 +204,22 @@ public class AiHookServiceImpl implements AiHookService {
     @Autowired
     private CloudHostProper cloudHostProper;
 
+    @Autowired
+    private com.fs.company.service.AiKnowledgeBaseService aiKnowledgeBaseService;
+
+    @Value("${ai.api.base-url:http://localhost:9009}")
+    private String aiApiBaseUrl;
+
+    @Autowired
+    ICompanyService companyService;
+
+    @Autowired
+    IpadSendUtils ipadSendUtils;
+
+    @Autowired
+    ICompanyUserService companyUserService;
+
+
     private static final String AI_REPLY = "AI_REPLY:";
     private static final String AI_REPLY_TAG = "AI_REPLY_TAG:";
 
@@ -490,6 +522,18 @@ public class AiHookServiceImpl implements AiHookService {
         if(qwExternalContacts.getType() == 1){
             FastGptChatSession fastGptChatSession= getFastGptSession(qwExternalContacts,user,dto);
             if (qwContent.contains("验证请求") || qwContent.contains("联系人验证请求") || qwContent.contains("我已经添加了你")){
+                Calendar calendar1 = Calendar.getInstance();
+                //定时任务会处理10分钟以内的,所以设置20分钟
+                calendar1.add(Calendar.MINUTE, 60);
+                Date expireTime = calendar1.getTime();
+
+                FastGptChatSession chatSession = new FastGptChatSession();
+                chatSession.setLastTime(expireTime);
+                chatSession.setIsArtificial(1);
+                chatSession.setUserId(String.valueOf(sender));
+                chatSession.setSessionId(fastGptChatSession.getSessionId());
+
+                fastGptChatSessionMapper.updateFastGptChatSession(chatSession);
                 return R.ok();
             }
             if(type == 104||type == 101){
@@ -628,6 +672,12 @@ public class AiHookServiceImpl implements AiHookService {
             }
             Gson gson = new Gson();
             contentKh = finalContentKh;*/
+
+            if(contentKh.contains("【转人工】")){
+                log.error("ai请求转人工:"+role.getRoleId()+":"+qwExternalContacts.getName());
+                notifyArtificial(fastGptChatSession.getSessionId(),user,qwExternalContacts.getName(),"ai请求转人工",qwExternalContacts.getId(),sender);
+                return R.ok();
+            }
             Gson gson = new Gson();
             FastGptChatConversation fastGptChatConversation = gson.fromJson(contentKh, FastGptChatConversation.class);
             String content = fastGptChatConversation.getAiContent();
@@ -638,6 +688,15 @@ public class AiHookServiceImpl implements AiHookService {
                 int tokens = responseDatum.getTokens();
                 token+=tokens;
             }
+
+
+            /**
+             * 客户打标签
+             */
+            if(fastGptChatConversation.getTags() != null && !fastGptChatConversation.getTags().isEmpty()){
+                saveQwExtTags(qwExternalContacts,fastGptChatConversation,fastGptChatSession,user,tenantId);
+            }
+
             //存聊天记录
             addSaveAiMsg(2,2,contentKh,user,fastGptChatSession.getSessionId(),role.getRoleId(),qwExternalContacts,fastGptChatSession.getUserId(),result.getUsage().prompt_tokens,result.getUsage().completion_tokens,token);
             if (!content.isEmpty()){
@@ -705,6 +764,41 @@ public class AiHookServiceImpl implements AiHookService {
         return R.ok();
     }
 
+    /**
+     * 租户给客户打标签
+     * @param qwExternalContacts
+     * @param fastGptChatConversation
+     * @param fastGptChatSession
+     * @param user
+     * @param tenantId
+     */
+    private void saveQwExtTags(QwExternalContact qwExternalContacts,FastGptChatConversation fastGptChatConversation,FastGptChatSession fastGptChatSession,QwUser user,Long tenantId) {
+        QwExternalContactAddTagParam Param = new QwExternalContactAddTagParam();
+
+        //添加需要打标签的客户
+        QwExternalContactParam param1 = new QwExternalContactParam();
+        param1.setCompanyId(user.getCompanyId());
+        List<Long> list = new ArrayList<>();
+        list.add(qwExternalContacts.getId());
+        Param.setUserIds(list);
+
+        try {
+            String[] split = fastGptChatConversation.getTags().split(",");
+            Param.setTagIds(Arrays.asList(split));
+            Param.setCorpId(user.getCorpId());
+        } catch (Exception e) {
+            System.out.println("标签格式错误,会话id:" + fastGptChatSession.getSessionId());
+        }
+
+        String url = OpenQwConfig.baseApi + "/addTag?tenantId=" + tenantId;
+        String result = HttpUtil.createPost(url)
+                .body(JSON.toJSONString(Param))
+                .execute()
+                .body();
+
+        System.out.println(result);
+    }
+
     private void sendImgMsg(String contentKh, Long sender, String uuid, Long serverId) {
         com.alibaba.fastjson.JSONObject jsonObject = com.alibaba.fastjson.JSONObject.parseObject(contentKh);
         JSONArray imgUrls = jsonObject.getJSONArray("imgUrl");
@@ -1278,7 +1372,71 @@ public class AiHookServiceImpl implements AiHookService {
         crmMsg.setCompanyId(user.getCompanyId());
         crmMsg.setCompanyUserId(user.getCompanyUserId());
         crmMsgService.insertCrmMsg(crmMsg);
+        try {
+            this.asyncAtMsg(user, "你的客户:" + chatSession.getNickName() + ", 因  \"" + reason + "\"  需要转人工,请及时回复");
+        } catch (Exception e) {
+            System.out.println("转人工发送消息失败:"+e.getMessage());
+        }
+
+    }
 
+    /**
+     * 异步发送掉线提醒
+     */
+    @Async
+    public void asyncAtMsg(QwUser qwUser, String msg) {
+        atMsg(qwUser, msg);
+    }
+
+    public void atMsg(QwUser qwUser, String msg) {
+        try {
+            String corpId = qwUser.getCorpId();
+            log.info("掉线提醒通知:{}, {}, {}", qwUser.getId(), qwUser.getQwUserName(), corpId);
+            // 获取通知账号
+            QwUser user = qwUserMapper.selectOfflineUser();
+            if(user == null){
+                log.info("qwId:{}=====未找到通知账号", qwUser.getId());
+                return;
+            }
+            Company company = companyService.selectCompanyById(qwUser.getCompanyId());
+            log.info("查到主体:{}", qwUser);
+            List<WxGetSessionRoomListVo.RoomList> sessionRoomList = ipadSendUtils.getSessionRoomList(user.getUid(), user.getServerId());
+            Optional<WxGetSessionRoomListVo.RoomList> optional = sessionRoomList.stream().filter(e -> e.getNickname().equals(company.getGroupName()) || e.getNickname().equals(company.getCompanyName())).findFirst();
+            if(!optional.isPresent()){
+                log.warn("qwId:{}=====会话管理未找到群聊,corpId:{},群聊名称:{}, 查到群聊名称:{}", qwUser.getId(), corpId, company.getCompanyName(), PubFun.listToNewList(sessionRoomList, WxGetSessionRoomListVo.RoomList::getNickname));
+                log.info("qwId:{}=====会话管理未找到群聊,corpId:{},群聊名称:{}, 查到群聊名称:{}", qwUser.getId(), corpId, company.getCompanyName(), PubFun.listToNewList(sessionRoomList, WxGetSessionRoomListVo.RoomList::getNickname));
+                return;
+            }
+            WxGetSessionRoomListVo.RoomList room = optional.get();
+            log.info("找到会话群聊:{}", room);
+            CompanyUser companyUser = companyUserService.selectCompanyUserById(qwUser.getCompanyUserId());
+            log.info("企微账号:{}", JSON.toJSONString(companyUser));
+            List<WxRoomUserListVo.MemberList> memberLists = ipadSendUtils.getSessionRoomUserList(user.getUid(), room.getRoom_id(), user.getServerId());
+            Function<WxRoomUserListVo.MemberList, String> getName = e -> StringUtils.isEmpty(e.getRoom_nickname()) ? e.getNickname() : e.getRoom_nickname();
+            Optional<WxRoomUserListVo.MemberList> first = memberLists.stream().filter(e -> getName.apply(e).equals(companyUser.getUserName()) || getName.apply(e).equals(companyUser.getNickName())).findFirst();
+            String sendMsg = "企微账号:" + qwUser.getQwUserName() + " - " + msg;
+            if(!first.isPresent()){
+                WxWorkSendTextMsgDTO dto = new WxWorkSendTextMsgDTO();
+                dto.setUuid(user.getUid());
+                dto.setSend_userid(room.getRoom_id());
+                dto.setIsRoom(true);
+                dto.setContent(sendMsg);
+                ipadSendUtils.sendTxtNoVo(dto, user.getServerId());
+            }else{
+                WxRoomUserListVo.MemberList memberList = first.get();
+                log.info("找到掉线人:{}", memberList);
+                WxSendAtMsgParam param = new WxSendAtMsgParam();
+                param.setUuid(user.getUid());
+                param.setContent(sendMsg);
+                param.setSend_userid(room.getRoom_id());
+                param.setAtids(Collections.singletonList(memberList.getUin()));
+                param.setRoom(true);
+                log.info("发送数据组装:{}", param);
+                ipadSendUtils.sendTextAtMsg(param, user.getServerId());
+            }
+        }catch (Exception e){
+            log.warn("掉线提醒发送失败", e);
+        }
     }
 
     void sendQwAppMsg(String corpId, Integer agentId,String qwUserId,String content){
@@ -1578,7 +1736,7 @@ public class AiHookServiceImpl implements AiHookService {
             }
 
             //添加关键词
-            addPromptWordNew(messageList,msgC,qwExternalContactsId,role,fastGptChatSession);
+            addPromptWordNew(messageList,msgC,qwExternalContactsId,role,fastGptChatSession,user);
             R r = chatService.initiatingTakeChat(param, "http://129.28.170.206:3000/api/", appKey);
             Object data1 = r.get("data");
             if(!(data1 instanceof KnowledgeBaseResult)){
@@ -1598,6 +1756,30 @@ public class AiHookServiceImpl implements AiHookService {
         }
 
     }
+
+    private void addQwUserTags(FastGptChatConversation conversation, QwUser user,FastGptRole role) {
+        String tagGroups = role.getTagGroups();
+        if (tagGroups==null){
+            return;
+        }
+
+        List<QwTagGroupListVO> qwTagGroupListVO = qwTagGroupService.selectQwTagGroupByIds(tagGroups,user.getCorpId());
+
+        List<Map<String, Map<String,String>>> arrayList = new ArrayList<>();
+        Map<String, Map<String,String>> hashMap = new HashMap<>();
+        for (QwTagGroupListVO groupListVO : qwTagGroupListVO) {
+            Map<String,String> map = new HashMap<>();
+            List<QwTagVO> tag = groupListVO.getTag();
+            for (QwTagVO qwTagVO : tag) {
+                map.put(qwTagVO.getName(),qwTagVO.getTagId());
+            }
+            hashMap.put(groupListVO.getName(), map);
+        }
+
+        arrayList.add(hashMap);
+        conversation.setTagMapList(arrayList);
+    }
+
     /** 增加课程信息 **/
     private void addCourseWatchLog(Long id) {
         FsCourseWatchLogVO log = fsCourseWatchLogMapper.selectFsCourseWatchLogByExtId(id);
@@ -1631,11 +1813,12 @@ public class AiHookServiceImpl implements AiHookService {
         }
     }
     /** 组装发送AI内容 **/
-    private void addPromptWordNew(List<ChatParam.Message> messageList,String count,Long extId,FastGptRole role,FastGptChatSession fastGptChatSession){
+    private void addPromptWordNew(List<ChatParam.Message> messageList,String count,Long extId,FastGptRole role,FastGptChatSession fastGptChatSession,QwUser user){
 
         FastGptChatConversation conversation = new FastGptChatConversation();
         conversation.setUserInfo(new com.alibaba.fastjson.JSONObject());
         conversation.setHistory(new com.alibaba.fastjson.JSONArray());
+        List<Map<String, String>> knowledgeBase = new ArrayList<>();
 
         if(role.getReminderWords() != null && !role.getReminderWords().isEmpty()){
             conversation.setAiInfo(role.getReminderWords());
@@ -1669,10 +1852,14 @@ public class AiHookServiceImpl implements AiHookService {
 
 
         List<FastGptChatMsg> msgs=fastGptChatMsgService.selectFastGptChatMsgByMsgSessionIdAndExtId(fastGptChatSession.getSessionId(),extId);
+
+        String contextQuery = count;
         if (!msgs.isEmpty()){
             com.alibaba.fastjson.JSONArray historyArray = new com.alibaba.fastjson.JSONArray();
             Collections.reverse(msgs);
             msgs.remove(msgs.size() - 1);
+            StringBuilder contextBuilder = new StringBuilder();
+            int historyCount = 0;
             for (FastGptChatMsg msg : msgs) {
                 Integer sendType = msg.getSendType();
                 String content = msg.getContent();
@@ -1685,14 +1872,103 @@ public class AiHookServiceImpl implements AiHookService {
                 msgObj.put("role", sendType==1?"user":"ai");
                 msgObj.put("content", content);
                 historyArray.add(msgObj);
+                if (content != null && !content.trim().isEmpty() && historyCount < 6) {
+                    if (sendType == 1) {
+                        contextBuilder.insert(0, "用户:" + content + "\n");
+                    } else {
+                        contextBuilder.insert(0, "AI:" + content + "\n");
+                    }
+                    historyCount++;
+                }
+            }
+            if (contextBuilder.length() > 0 && count != null && !count.trim().isEmpty()) {
+                contextQuery = contextBuilder.toString().trim() + "\n用户:" + count;
             }
             conversation.setHistory(historyArray);
         }
 
+        //从向量知识库中检索相关内容
+        if (count != null && !count.trim().isEmpty()) {
+            String searchQuery = contextQuery != null ? contextQuery : count;
+            log.info("知识库检索查询文本 | original={} | contextQuery={}", count, searchQuery);
+            try {
+                com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.fs.company.domain.AiKnowledgeBase> lqw =
+                        new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>();
+                lqw.eq(com.fs.company.domain.AiKnowledgeBase::getDelFlag, 0);
+                List<com.fs.company.domain.AiKnowledgeBase> kbList = aiKnowledgeBaseService.list(lqw);
+                if (kbList == null || kbList.isEmpty()) {
+                    log.warn("向量知识库检索跳过: 当前租户下无知识库 | roleId={}", role.getRoleId());
+                } else {
+                    Set<Long> addedIds = new HashSet<>();
+                    for (com.fs.company.domain.AiKnowledgeBase kb : kbList) {
+                        String collectionName = kb.getCollectionName();
+                        if (collectionName == null || collectionName.trim().isEmpty()) {
+                            continue;
+                        }
+
+                        // 第一路:向量语义搜索(使用上下文查询)
+                        List<Float> vector = createEmbedding(searchQuery);
+                        if (vector != null && !vector.isEmpty()) {
+                            List<Map<String, Object>> searchResults = searchQdrant(collectionName, vector, 3, 0.3);
+                            if (searchResults != null) {
+                                for (Map<String, Object> item : searchResults) {
+                                    Long pointId = extractPointId(item);
+                                    if (pointId != null && addedIds.contains(pointId)) {
+                                        continue;
+                                    }
+                                    if (pointId != null) {
+                                        addedIds.add(pointId);
+                                    }
+                                    Map<String, String> kbItem = extractPayloadItem(item);
+                                    if (!kbItem.isEmpty()) {
+                                        knowledgeBase.add(kbItem);
+                                    }
+                                }
+                            }
+                        }
+
+                        // 第二路:Payload关键词过滤搜索(从上下文和当前消息中提取关键词)
+                        List<String> keywords = extractKeywords(searchQuery);
+                        List<String> currentKeywords = extractKeywords(count);
+                        Set<String> allKeywords = new LinkedHashSet<>(currentKeywords);
+                        allKeywords.addAll(keywords);
+                        if (!allKeywords.isEmpty()) {
+                            for (String keyword : allKeywords) {
+                                List<Map<String, Object>> filterResults = searchQdrantByPayload(collectionName, keyword, 10);
+                                if (filterResults != null) {
+                                    for (Map<String, Object> item : filterResults) {
+                                        Long pointId = extractPointId(item);
+                                        if (pointId != null && addedIds.contains(pointId)) {
+                                            continue;
+                                        }
+                                        if (pointId != null) {
+                                            addedIds.add(pointId);
+                                        }
+                                        Map<String, String> kbItem = extractPayloadItem(item);
+                                        if (!kbItem.isEmpty()) {
+                                            knowledgeBase.add(kbItem);
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            } catch (Exception e) {
+                log.error("向量知识库检索失败 | roleId={} | content={}", role.getRoleId(), count, e);
+            }
+        }
+        log.info("向量知识库检索完成 | knowledgeBaseSize={} | roleId={}", knowledgeBase.size(), role.getRoleId());
+        conversation.setKnowledgeBase(knowledgeBase);
+
         if (count!=null&& !count.isEmpty()){
             conversation.setUserContent(count);
         }
 
+        /**
+         * 添加企微标签
+         */
+        addQwUserTags(conversation,user,role);
 
         ChatParam.Message message1=new ChatParam.Message();
         message1.setRole("user");
@@ -1701,6 +1977,178 @@ public class AiHookServiceImpl implements AiHookService {
         message1.setContent(jsonStr);
         messageList.add(message1);
     }
+
+    private Long extractPointId(Map<String, Object> item) {
+        Object id = item.get("id");
+        if (id instanceof Number) {
+            return ((Number) id).longValue();
+        }
+        return null;
+    }
+
+    private Map<String, String> extractPayloadItem(Map<String, Object> item) {
+        Map<String, String> kbItem = new HashMap<>();
+        Object payloadObj = item.get("payload");
+        if (payloadObj instanceof Map) {
+            Map<?, ?> payload = (Map<?, ?>) payloadObj;
+            Object qObj = payload.get("q");
+            Object aObj = payload.get("a");
+            if (qObj != null) {
+                kbItem.put("q", qObj.toString());
+            }
+            if (aObj != null) {
+                kbItem.put("a", aObj.toString());
+            }
+        }
+        return kbItem;
+    }
+
+    private List<String> extractKeywords(String text) {
+        List<String> keywords = new ArrayList<>();
+        if (text == null || text.trim().isEmpty()) {
+            return keywords;
+        }
+        java.util.regex.Matcher durationMatcher = java.util.regex.Pattern.compile("\\d+\\s*[天日周月年]").matcher(text);
+        while (durationMatcher.find()) {
+            keywords.add(durationMatcher.group().replaceAll("\\s+", ""));
+        }
+        String[] productWords = {"套餐", "方案", "服务", "产品", "价格", "费用", "优惠", "活动", "会员", "课程", "项目"};
+        for (String word : productWords) {
+            if (text.contains(word)) {
+                keywords.add(word);
+            }
+        }
+        return keywords;
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<Map<String, Object>> searchQdrantByPayload(String collectionName, String keyword, int limit) {
+        try {
+            com.alibaba.fastjson.JSONObject req = new com.alibaba.fastjson.JSONObject();
+            req.put("collectionName", collectionName);
+            req.put("vector", Collections.nCopies(1024, 0.0f));
+            req.put("topK", limit);
+            req.put("scoreThreshold", 0.0);
+
+            com.alibaba.fastjson.JSONObject filter = new com.alibaba.fastjson.JSONObject();
+            com.alibaba.fastjson.JSONArray should = new com.alibaba.fastjson.JSONArray();
+            com.alibaba.fastjson.JSONObject qMatch = new com.alibaba.fastjson.JSONObject();
+            qMatch.put("key", "q");
+            com.alibaba.fastjson.JSONObject qMatchValue = new com.alibaba.fastjson.JSONObject();
+            qMatchValue.put("value", keyword);
+            qMatch.put("match", qMatchValue);
+            should.add(qMatch);
+            com.alibaba.fastjson.JSONObject aMatch = new com.alibaba.fastjson.JSONObject();
+            aMatch.put("key", "a");
+            com.alibaba.fastjson.JSONObject aMatchValue = new com.alibaba.fastjson.JSONObject();
+            aMatchValue.put("value", keyword);
+            aMatch.put("match", aMatchValue);
+            should.add(aMatch);
+            filter.put("should", should);
+            req.put("filter", filter);
+
+            String url = aiApiBaseUrl + "/qdrant/point/search";
+            String result = HttpUtil.post(url, req.toJSONString());
+            com.alibaba.fastjson.JSONObject resp = com.alibaba.fastjson.JSONObject.parseObject(result);
+            Integer code = resp.getInteger("code");
+            if (code == null || code != 200) {
+                return null;
+            }
+            Object dataObj = resp.get("data");
+            if (dataObj instanceof List) {
+                List<Map<String, Object>> results = new ArrayList<>();
+                for (Object item : (List<?>) dataObj) {
+                    if (item instanceof Map) {
+                        results.add((Map<String, Object>) item);
+                    }
+                }
+                return results;
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("Payload关键词搜索失败 | collectionName={} | keyword={}", collectionName, keyword, e);
+            return null;
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<Float> createEmbedding(String text) {
+        try {
+            com.alibaba.fastjson.JSONObject req = new com.alibaba.fastjson.JSONObject();
+            req.put("text", text);
+            String url = aiApiBaseUrl + "/ai/embedding/create";
+            log.info("请求Embedding API | url={} | textLength={}", url, text.length());
+            String result = HttpUtil.post(url, req.toJSONString());
+            log.info("Embedding API响应 | respLength={}", result != null ? result.length() : 0);
+            com.alibaba.fastjson.JSONObject resp = com.alibaba.fastjson.JSONObject.parseObject(result);
+            Integer code = resp.getInteger("code");
+            if (code == null || code != 200) {
+                log.error("Embedding API返回错误 | code={} | msg={} | resp={}", code, resp.getString("msg"), result);
+                return null;
+            }
+            com.alibaba.fastjson.JSONArray embeddingArray = resp.getJSONArray("data");
+            if (embeddingArray == null || embeddingArray.isEmpty()) {
+                log.error("Embedding API返回data为空 | resp={}", result);
+                return null;
+            }
+            List<Float> vector = new ArrayList<>();
+            for (Object item : embeddingArray) {
+                if (item instanceof Number) {
+                    vector.add(((Number) item).floatValue());
+                }
+            }
+            log.info("Embedding向量解析成功 | vectorSize={}", vector.size());
+            return vector;
+        } catch (Exception e) {
+            log.error("生成Embedding向量失败 | text={}", text, e);
+            return null;
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<Map<String, Object>> searchQdrant(String collectionName, List<Float> vector, int topK, double scoreThreshold) {
+        try {
+            com.alibaba.fastjson.JSONObject req = new com.alibaba.fastjson.JSONObject();
+            req.put("collectionName", collectionName);
+            req.put("vector", vector);
+            req.put("topK", topK);
+            req.put("scoreThreshold", scoreThreshold);
+            String url = aiApiBaseUrl + "/qdrant/point/search";
+            log.info("请求Qdrant搜索 | url={} | collectionName={} | topK={} | scoreThreshold={}", url, collectionName, topK, scoreThreshold);
+            String result = HttpUtil.post(url, req.toJSONString());
+            log.info("Qdrant搜索响应 | collectionName={} | respLength={}", collectionName, result != null ? result.length() : 0);
+            com.alibaba.fastjson.JSONObject resp = com.alibaba.fastjson.JSONObject.parseObject(result);
+            Integer code = resp.getInteger("code");
+            if (code == null || code != 200) {
+                log.error("Qdrant搜索API返回错误 | code={} | msg={} | collectionName={} | resp={}", code, resp.getString("msg"), collectionName, result);
+                return null;
+            }
+            Object dataObj = resp.get("data");
+            if (dataObj == null) {
+                log.error("Qdrant搜索API返回data为null | collectionName={} | resp={}", collectionName, result);
+                return null;
+            }
+            if (dataObj instanceof List) {
+                List<Map<String, Object>> results = new ArrayList<>();
+                for (Object item : (List<?>) dataObj) {
+                    if (item instanceof Map) {
+                        Map<String, Object> resultMap = (Map<String, Object>) item;
+                        Object score = resultMap.get("score");
+                        log.info("Qdrant搜索结果 | collectionName={} | score={} | id={}", collectionName, score, resultMap.get("id"));
+                        results.add(resultMap);
+                    }
+                }
+                log.info("Qdrant搜索完成 | collectionName={} | resultCount={}", collectionName, results.size());
+                return results;
+            }
+            log.error("Qdrant搜索API返回data类型异常 | collectionName={} | dataType={}", collectionName, dataObj.getClass().getName());
+            return null;
+        } catch (Exception e) {
+            log.error("Qdrant搜索失败 | collectionName={}", collectionName, e);
+            return null;
+        }
+    }
+
     /** 组装发送AI内容 **/
     private void addPromptWord(List<ChatParam.Message> messageList,String count,Long extId,String words,String countInfo,Long sessionId){
 
@@ -2224,7 +2672,7 @@ public class AiHookServiceImpl implements AiHookService {
                     if (oneDayAgo.getTime().after(fastGptChatSession.getLastTime())) {
                         Calendar calendar1 = Calendar.getInstance();
                         //定时任务会处理10分钟以内的,所以设置20分钟
-                        calendar1.add(Calendar.MINUTE, 20);
+                        calendar1.add(Calendar.MINUTE, 30);
                         Date expireTime = calendar1.getTime();
 
                         FastGptChatSession chatSession = new FastGptChatSession();

+ 20 - 5
fs-service/src/main/java/com/fs/fastgptApi/util/AudioUtils.java

@@ -44,10 +44,25 @@ public class AudioUtils {
     }
 
     /**
-     * 工具地址
+     * 工具地址(历史部署默认 c:\ffmpeg.exe)
      **/
     static String path = "c:\\";
     static String destinationDir = "c:\\hook\\";
+
+    /**
+     * 解析 ffmpeg 可执行文件:优先环境变量 FFMPEG_PATH,其次 c:\ffmpeg.exe,最后使用 PATH 中的 ffmpeg。
+     */
+    private static String resolveFfmpegExecutable() {
+        String envPath = System.getenv("FFMPEG_PATH");
+        if (envPath != null && !envPath.trim().isEmpty()) {
+            return envPath.trim();
+        }
+        File legacy = new File(path + "ffmpeg.exe");
+        if (legacy.isFile()) {
+            return legacy.getAbsolutePath();
+        }
+        return "ffmpeg";
+    }
     public static AudioVO createVoiceUrl(Long id,String userVoiceUrl){
         String fileUrl = staticAiHostProper.getVoiceApi() + "/app/common/createVoiceUrl?id=" + id + "&userVoiceUrl=" + userVoiceUrl;
 
@@ -232,7 +247,7 @@ public class AudioUtils {
             String WAVPath = destinationDir + "WAV_" + time + ".wav";
             // 构建FFmpeg命令
             String[] command = {
-                    path + "ffmpeg.exe",
+                    resolveFfmpegExecutable(),
                     "-i", audioFilePath,WAVPath
             };
             log.info(command[2]);
@@ -254,7 +269,7 @@ public class AudioUtils {
             // 构建FFmpeg命令
             String[] command = {
                     "cmd", "/c", "start", "/b",
-                    path + "ffmpeg.exe",
+                    resolveFfmpegExecutable(),
                     "-i", audioFilePath
             };
             // 启动进程执行命令
@@ -518,7 +533,7 @@ public class AudioUtils {
         commend.add("/c");
         commend.add("start");
         commend.add("/b");
-        commend.add(path + "ffmpeg.exe");
+        commend.add(resolveFfmpegExecutable());
         commend.add("-y");
         commend.add("-i");
         commend.add(fpath);
@@ -550,7 +565,7 @@ public class AudioUtils {
         Process process = null;
         try {
             List<String> command = new ArrayList<String>();
-            command.add(path + "ffmpeg.exe");
+            command.add(resolveFfmpegExecutable());
             command.add("-y");
             command.add("-i");
             command.add(fpath);

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

@@ -96,4 +96,6 @@ public interface QwTagGroupMapper
     List<QwTagGroupListVO> selectQwTagGroups(QwTagGroup qwTagGroup);
 
     QwTagGroup selectQwTagGroupByName(@Param("tagGroup") String tagGroup, @Param("corpId") String corpId);
+
+    List<QwTagGroupListVO> selectQwTagGroupByIds(@Param("tagGroups") String tagGroups, @Param("corpId") String corpId);
 }

+ 2 - 0
fs-service/src/main/java/com/fs/qw/service/IQwTagGroupService.java

@@ -89,4 +89,6 @@ public interface IQwTagGroupService
     void delQwTagByAi(String trimTag, Long extId);
 
     List<QwTagGroupListVO> selectQwTagGroupListVOPage(QwTagGroup qwTagGroup);
+
+    List<QwTagGroupListVO> selectQwTagGroupByIds(String tagGroups,String corpId);
 }

+ 14 - 0
fs-service/src/main/java/com/fs/qw/service/impl/QwTagGroupServiceImpl.java

@@ -650,4 +650,18 @@ public class QwTagGroupServiceImpl implements IQwTagGroupService {
         }
         return vo;
     }
+
+    @Override
+    public List<QwTagGroupListVO> selectQwTagGroupByIds(String tagGroups,String corpId) {
+        List<QwTagGroupListVO> vo = qwTagGroupMapper.selectQwTagGroupByIds(tagGroups,corpId);
+
+        for (QwTagGroupListVO qwTagGroupListVO : vo) {
+            QwTag qwTag = new QwTag();
+            qwTag.setGroupId(qwTagGroupListVO.getGroupId());
+            qwTag.setCompanyId(qwTagGroupListVO.getCompanyId());
+            List<QwTagVO> qwTags = qwTagMapper.selectQwTagListVO(qwTag);
+            qwTagGroupListVO.setTag(qwTags);
+        }
+        return vo;
+    }
 }

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

@@ -1311,7 +1311,7 @@ public class QwUserServiceImpl implements IQwUserService
 
         System.out.println("回调地址"+"http://saasqwapimsg.ylrzcloud.com/msg/callback/"+serverId + "/"+loginParam.getTenantId());
         wxWorkSetCallbackUrlDTO.setUrl("http://saasqwapimsg.ylrzcloud.com/msg/callback/"+serverId+ "/"+loginParam.getTenantId());
-//        wxWorkSetCallbackUrlDTO.setUrl("http://wf89b6de.natappfree.cc/msg/callback/"+serverId+ "/"+loginParam.getTenantId());
+        //wxWorkSetCallbackUrlDTO.setUrl("http://cn-hk-bgp-4.ofalias.net:55081/msg/callback/"+serverId+ "/"+loginParam.getTenantId());
         wxWorkSetCallbackUrlDTO.setUuid(data.getUuid());
         wxWorkService.SetCallbackUrl(wxWorkSetCallbackUrlDTO,serverId);
 

+ 9 - 2
fs-service/src/main/java/com/fs/system/oss/OSSFactory.java

@@ -1,6 +1,8 @@
 package com.fs.system.oss;
 
 import com.alibaba.fastjson.JSON;
+import com.fs.common.exception.ServiceException;
+import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.spring.SpringUtils;
 import com.fs.system.service.ISysConfigService;
 
@@ -19,8 +21,13 @@ public final class OSSFactory
     public static CloudStorageService build()
     {
         String jsonconfig = sysConfigService.selectConfigByKey(CloudConstant.CLOUD_STORAGE_CONFIG_KEY);
-        // 获取云存储配置信息
+        if (StringUtils.isEmpty(jsonconfig)) {
+            throw new ServiceException("云存储未配置,请在系统参数中配置 key=" + CloudConstant.CLOUD_STORAGE_CONFIG_KEY);
+        }
         CloudStorageConfig config = JSON.parseObject(jsonconfig, CloudStorageConfig.class);
+        if (config == null || config.getType() == null) {
+            throw new ServiceException("云存储配置解析失败,请检查 " + CloudConstant.CLOUD_STORAGE_CONFIG_KEY + " 的 JSON 格式");
+        }
         if (config.getType() == CloudConstant.CloudService.QINIU.getValue())
         {
             return new QiniuCloudStorageService(config);
@@ -37,6 +44,6 @@ public final class OSSFactory
         {
             return new HuaweiCloudStorageService(config);
         }
-        return null;
+        throw new ServiceException("不支持的云存储类型: " + config.getType());
     }
 }

+ 2 - 0
fs-service/src/main/java/com/fs/third/service/ITencentWordService.java

@@ -31,4 +31,6 @@ public interface ITencentWordService {
     List<TencentWord> getFiles();
 
     void synchronization(String fileId);
+
+    R syncToKnowledgeBase(String fileId, Long knowledgeBaseId);
 }

+ 199 - 35
fs-service/src/main/java/com/fs/third/service/impl/TencentWordServiceImpl.java

@@ -6,6 +6,8 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
+import com.fs.company.domain.AiKnowledgeBase;
+import com.fs.company.mapper.AiKnowledgeBaseMapper;
 import com.fs.hisStore.enums.SysConfigEnum;
 import com.fs.system.domain.SysConfig;
 import com.fs.system.mapper.SysConfigMapper;
@@ -16,15 +18,14 @@ import com.fs.third.mapper.TencentWordMapper;
 import com.fs.third.mapper.TencentWordDetailMapper;
 import com.fs.third.mapper.TencentWordSheetMapper;
 import com.fs.third.service.ITencentWordService;
+import cn.hutool.http.HttpUtil;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import okhttp3.*;
 import org.springframework.stereotype.Service;
 
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
+import java.util.*;
 import java.util.stream.Collectors;
 
 
@@ -43,6 +44,9 @@ public class TencentWordServiceImpl extends ServiceImpl<TencentWordMapper, Tence
     private final RedisCache redisCache;
     private final TencentWordSheetMapper tencentWordSheetMapper;
     private final TencentWordDetailMapper tencentWordDetailMapper;
+    private final AiKnowledgeBaseMapper aiKnowledgeBaseMapper;
+
+    private static final String AI_API_BASE_URL = "http://localhost:9009";
 
     @Override
     public TencentWord selectTencentWordById(Long id) {
@@ -138,7 +142,6 @@ public class TencentWordServiceImpl extends ServiceImpl<TencentWordMapper, Tence
 
     @Override
     public void synchronization(String fileId) {
-        // 获取sheet
         Map<String, String> sysConfigCache = getSysConfigCache();
 
         String clientId = sysConfigCache.get("clientId");
@@ -162,17 +165,33 @@ public class TencentWordServiceImpl extends ServiceImpl<TencentWordMapper, Tence
                 JSONObject result = JSONObject.parseObject(responseBody);
                 JSONArray properties = result.getJSONArray("properties");
                 if (properties != null && !properties.isEmpty()) {
+                    List<TencentWordSheet> existingSheets = tencentWordSheetMapper.selectList(
+                            new LambdaQueryWrapper<TencentWordSheet>().eq(TencentWordSheet::getFileId, fileId));
+                    Map<String, TencentWordSheet> existingSheetMap = existingSheets.stream()
+                            .collect(Collectors.toMap(TencentWordSheet::getSheetId, s -> s, (a, b) -> a));
+
                     for (int i = 0; i < properties.size(); i++) {
                         JSONObject sheetJson = properties.getJSONObject(i);
-                        TencentWordSheet sheet = new TencentWordSheet();
-                        sheet.setFileId(fileId);
-                        sheet.setSheetId(sheetJson.getString("sheetId"));
-                        sheet.setTitle(sheetJson.getString("title"));
-                        sheet.setRowCount(sheetJson.getInteger("rowCount"));
-                        sheet.setColumnCount(sheetJson.getInteger("columnCount"));
-                        sheet.setRowTotal(sheetJson.getInteger("rowTotal"));
-                        sheet.setColumnTotal(sheetJson.getInteger("columnTotal"));
-                        tencentWordSheetMapper.insert(sheet);
+                        String sheetId = sheetJson.getString("sheetId");
+                        TencentWordSheet sheet = existingSheetMap.get(sheetId);
+                        if (sheet != null) {
+                            sheet.setTitle(sheetJson.getString("title"));
+                            sheet.setRowCount(sheetJson.getInteger("rowCount"));
+                            sheet.setColumnCount(sheetJson.getInteger("columnCount"));
+                            sheet.setRowTotal(sheetJson.getInteger("rowTotal"));
+                            sheet.setColumnTotal(sheetJson.getInteger("columnTotal"));
+                            tencentWordSheetMapper.updateById(sheet);
+                        } else {
+                            sheet = new TencentWordSheet();
+                            sheet.setFileId(fileId);
+                            sheet.setSheetId(sheetId);
+                            sheet.setTitle(sheetJson.getString("title"));
+                            sheet.setRowCount(sheetJson.getInteger("rowCount"));
+                            sheet.setColumnCount(sheetJson.getInteger("columnCount"));
+                            sheet.setRowTotal(sheetJson.getInteger("rowTotal"));
+                            sheet.setColumnTotal(sheetJson.getInteger("columnTotal"));
+                            tencentWordSheetMapper.insert(sheet);
+                        }
                     }
                 }
             } else {
@@ -182,11 +201,13 @@ public class TencentWordServiceImpl extends ServiceImpl<TencentWordMapper, Tence
         } catch (IOException e) {
             log.error("腾讯文档获取工作表请求异常 | fileId={}", fileId, e);
         }
-        List<TencentWordSheet> sheets = tencentWordSheetMapper.selectList(new LambdaQueryWrapper<TencentWordSheet>().eq(TencentWordSheet::getFileId, fileId));
+
+        List<TencentWordSheet> sheets = tencentWordSheetMapper.selectList(
+                new LambdaQueryWrapper<TencentWordSheet>().eq(TencentWordSheet::getFileId, fileId));
         ArrayList<TencentWordDetail> updates = new ArrayList<>();
         ArrayList<TencentWordDetail> inserts = new ArrayList<>();
         sheets.forEach(sheet -> {
-            if (sheet.getRowCount() > 1) {
+            if (sheet.getRowCount() != null && sheet.getRowCount() > 1) {
                 getSheetInfo(fileId, sheet, updates, inserts);
             }
         });
@@ -196,6 +217,22 @@ public class TencentWordServiceImpl extends ServiceImpl<TencentWordMapper, Tence
         if (!updates.isEmpty()) {
             tencentWordDetailMapper.updateBatchById(updates);
         }
+
+        //同步到向量知识库(当前租户下的所有知识库)
+        List<AiKnowledgeBase> kbList = aiKnowledgeBaseMapper.selectList(
+                new LambdaQueryWrapper<AiKnowledgeBase>().eq(AiKnowledgeBase::getDelFlag, 0));
+        if (kbList != null && !kbList.isEmpty()) {
+            for (AiKnowledgeBase kb : kbList) {
+                try {
+                    R syncResult = syncToKnowledgeBase(fileId, kb.getId());
+                    log.info("腾讯文档同步后自动写入向量知识库 | fileId={} | knowledgeBaseId={} | collectionName={} | result={}",
+                            fileId, kb.getId(), kb.getCollectionName(), syncResult.get("msg"));
+                } catch (Exception e) {
+                    log.error("腾讯文档同步后自动写入向量知识库失败 | fileId={} | knowledgeBaseId={}",
+                            fileId, kb.getId(), e);
+                }
+            }
+        }
     }
 
     private void getSheetInfo(String fileId, TencentWordSheet sheet, ArrayList<TencentWordDetail> updates, ArrayList<TencentWordDetail> inserts) {
@@ -218,11 +255,15 @@ public class TencentWordServiceImpl extends ServiceImpl<TencentWordMapper, Tence
                 .build();
 
         try (Response response = client.newCall(request).execute()) {
-            List<TencentWordDetail> tencentWordDetails = tencentWordDetailMapper.selectList(new LambdaQueryWrapper<TencentWordDetail>().eq(TencentWordDetail::getFileId, fileId).eq(TencentWordDetail::getSheetId, sheet.getSheetId()));
-            Map<String, Long> qIdMap = tencentWordDetails.stream().collect(Collectors.toMap(TencentWordDetail::getQ, TencentWordDetail::getId));
-            if (response.isSuccessful() && response.body() != null) {
-
+            List<TencentWordDetail> tencentWordDetails = tencentWordDetailMapper.selectList(
+                    new LambdaQueryWrapper<TencentWordDetail>()
+                            .eq(TencentWordDetail::getFileId, fileId)
+                            .eq(TencentWordDetail::getSheetId, sheet.getSheetId()));
+            Map<String, TencentWordDetail> existingDetailMap = tencentWordDetails.stream()
+                    .filter(d -> d.getQ() != null)
+                    .collect(Collectors.toMap(TencentWordDetail::getQ, d -> d, (a, b) -> a));
 
+            if (response.isSuccessful() && response.body() != null) {
                 String responseBody = response.body().string();
                 log.info("腾讯文档获取表格数据成功 | fileId={} | sheetId={} | response={}", fileId, sheet.getSheetId(), responseBody);
                 JSONObject result = JSONObject.parseObject(responseBody);
@@ -236,26 +277,27 @@ public class TencentWordServiceImpl extends ServiceImpl<TencentWordMapper, Tence
                             if (values != null && values.size() >= 2) {
                                 String q = getCellText(values.getJSONObject(0));
                                 String a = getCellText(values.getJSONObject(1));
-                                TencentWordDetail detail = new TencentWordDetail();
-                                detail.setFileId(fileId);
-                                detail.setSheetId(sheet.getSheetId());
-                                Long l = qIdMap.get(q);
-                                detail.setQ(q);
-                                detail.setA(a);
-                                if (l!= null){
-                                    detail.setId(l);
-                                    updates.add(detail);
+                                if (q == null || q.trim().isEmpty()) {
                                     continue;
                                 }
-                                inserts.add(detail);
-//                                tencentWordDetailMapper.insert(detail);
+                                TencentWordDetail existing = existingDetailMap.get(q);
+                                if (existing != null) {
+                                    if (!q.equals(existing.getQ()) || (a != null && !a.equals(existing.getA())) || (a == null && existing.getA() != null)) {
+                                        existing.setA(a);
+                                        updates.add(existing);
+                                    }
+                                } else {
+                                    TencentWordDetail detail = new TencentWordDetail();
+                                    detail.setFileId(fileId);
+                                    detail.setSheetId(sheet.getSheetId());
+                                    detail.setQ(q);
+                                    detail.setA(a);
+                                    inserts.add(detail);
+                                }
                             }
                         }
                     }
                 }
-//                if (!updates.isEmpty()) {
-//                    tencentWordDetailMapper.updateBatchById(updates);
-//                }
             } else {
                 String errorBody = response.body() != null ? response.body().string() : "";
                 log.error("腾讯文档获取表格数据失败 | fileId={} | sheetId={} | status={} | body={}", fileId, sheet.getSheetId(), response.code(), errorBody);
@@ -270,8 +312,16 @@ public class TencentWordServiceImpl extends ServiceImpl<TencentWordMapper, Tence
             return null;
         }
         JSONObject cellValue = cell.getJSONObject("cellValue");
-        if (cellValue != null) {
-            return cellValue.getString("text");
+        if (cellValue == null) {
+            return null;
+        }
+        String text = cellValue.getString("text");
+        if (text != null && !text.trim().isEmpty()) {
+            return text;
+        }
+        Object value = cellValue.get("value");
+        if (value != null) {
+            return String.valueOf(value);
         }
         return null;
     }
@@ -289,4 +339,118 @@ public class TencentWordServiceImpl extends ServiceImpl<TencentWordMapper, Tence
         }
         return sysConfigMap;
     }
+
+    @Override
+    public R syncToKnowledgeBase(String fileId, Long knowledgeBaseId) {
+        AiKnowledgeBase knowledgeBase = aiKnowledgeBaseMapper.selectById(knowledgeBaseId);
+        if (knowledgeBase == null) {
+            return R.error("知识库不存在");
+        }
+        String collectionName = knowledgeBase.getCollectionName();
+        if (collectionName == null || collectionName.trim().isEmpty()) {
+            return R.error("知识库向量集合未初始化");
+        }
+
+        List<TencentWordDetail> details = tencentWordDetailMapper.selectList(
+                new LambdaQueryWrapper<TencentWordDetail>().eq(TencentWordDetail::getFileId, fileId));
+        if (details.isEmpty()) {
+            log.info("该文件下没有可同步的问答数据, 跳过向量知识库同步 | fileId={}", fileId);
+            return R.ok("无同步数据, 不做处理");
+        }
+
+        // 生成向量和payload
+        List<Long> ids = new ArrayList<>();
+        List<List<Float>> vectors = new ArrayList<>();
+        List<String> documents = new ArrayList<>();
+        List<Map<String, Object>> payloads = new ArrayList<>();
+
+        for (TencentWordDetail detail : details) {
+            String q = detail.getQ();
+            String a = detail.getA();
+            if (q == null || q.trim().isEmpty()) {
+                continue;
+            }
+
+            List<Float> embedding;
+            try {
+                embedding = createEmbedding(q);
+            } catch (Exception e) {
+                log.error("生成向量失败 | detailId={} | q={}", detail.getId(), q, e);
+                continue;
+            }
+
+            ids.add(detail.getId());
+            vectors.add(embedding);
+
+            String documentText = "问题:" + q;
+            if (a != null && !a.trim().isEmpty()) {
+                documentText += "\n答案:" + a;
+            }
+            documents.add(documentText);
+
+            Map<String, Object> payload = new LinkedHashMap<>();
+            payload.put("fileId", detail.getFileId());
+            payload.put("sheetId", detail.getSheetId());
+            payload.put("q", q);
+            payload.put("a", a);
+            payloads.add(payload);
+        }
+
+        if (ids.isEmpty()) {
+            return R.error("没有成功生成向量的数据");
+        }
+
+        // 删除旧collection
+        try {
+            String deleteResult = HttpUtil.post(AI_API_BASE_URL + "/qdrant/collection/delete?collectionName=" + collectionName, "");
+            log.info("删除旧collection | collectionName={} | result={}", collectionName, deleteResult);
+        } catch (Exception e) {
+            log.warn("删除旧collection失败(可能不存在), 继续创建 | collectionName={}", collectionName, e);
+        }
+
+        // 重新创建collection
+        try {
+            JSONObject createReq = new JSONObject();
+            JSONObject vectorsConfig = new JSONObject();
+            vectorsConfig.put("size", 1024);
+            vectorsConfig.put("distance", "Cosine");
+            createReq.put("collectionName", collectionName);
+            createReq.put("vectorSize", 1024);
+            String createResult = HttpUtil.post(AI_API_BASE_URL + "/qdrant/collection/create", createReq.toJSONString());
+            log.info("重新创建collection | collectionName={} | result={}", collectionName, createResult);
+        } catch (Exception e) {
+            log.error("创建collection失败 | collectionName={}", collectionName, e);
+            return R.error("创建向量知识库集合失败: " + e.getMessage());
+        }
+
+        // 写入向量数据
+        JSONObject upsertReq = new JSONObject();
+        upsertReq.put("collectionName", collectionName);
+        upsertReq.put("ids", ids);
+        upsertReq.put("vectors", vectors);
+        upsertReq.put("documents", documents);
+        upsertReq.put("payloads", payloads);
+
+        try {
+            String result = HttpUtil.post(AI_API_BASE_URL + "/qdrant/point/upsert", upsertReq.toJSONString());
+            log.info("向量数据写入Qdrant完成 | collectionName={} | count={} | result={}", collectionName, ids.size(), result);
+        } catch (Exception e) {
+            log.error("向量数据写入Qdrant失败 | collectionName={}", collectionName, e);
+            return R.error("写入向量数据库失败: " + e.getMessage());
+        }
+
+        return R.ok("成功同步" + ids.size() + "条数据到知识库");
+    }
+
+    private List<Float> createEmbedding(String text) {
+        JSONObject req = new JSONObject();
+        req.put("text", text);
+        String result = HttpUtil.post(AI_API_BASE_URL + "/ai/embedding/create", req.toJSONString());
+        JSONObject resp = JSONObject.parseObject(result);
+        JSONArray embeddingArray = resp.getJSONArray("data");
+        if (embeddingArray == null || embeddingArray.isEmpty()) {
+            throw new RuntimeException("Embedding返回向量为空");
+        }
+        return embeddingArray.toJavaList(Float.class);
+    }
 }

+ 2 - 2
fs-service/src/main/java/com/fs/wxcid/dto/message/CdnUploadVideoResult.java

@@ -57,8 +57,8 @@ public class CdnUploadVideoResult {
     @JSONField(name = "RetCode")
     private Integer retCode;
 
-    @JSONField(name = "FileID")
-    private String fileID;
+    @JSONField(name = "FileId")
+    private String fileId;
 
     @JSONField(name = "ThumbHeight")
     private Integer thumbHeight;

+ 283 - 2
fs-service/src/main/resources/db/tenant-initTable.sql

@@ -2645,6 +2645,7 @@ CREATE TABLE `company_voice_robotic`
     `available_start_time`   time NULL DEFAULT NULL COMMENT '应用场景指定时间范围开始',
     `available_end_time`     time NULL DEFAULT NULL COMMENT '应用场景指定时间范围结束',
     `del_flag`               tinyint NULL DEFAULT 0 COMMENT '删除标志 0正常 1删除',
+    `pause_source` varchar(20) NULL DEFAULT NULL COMMENT '暂停来源:auto-自动暂停(超出运行时间范围) manual-手动暂停',
     PRIMARY KEY (`id`) USING BTREE,
     INDEX                    `company_scene_idx`(`company_id`, `scene_type`, `del_flag`) USING BTREE COMMENT '公司,场景索引'
 ) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '机器人外呼任务' ROW_FORMAT = DYNAMIC;
@@ -3073,8 +3074,8 @@ CREATE TABLE `crm_customer`
     `historical_communication` text NULL COMMENT '历史沟通内容',
     `effective_customer` tinyint NULL DEFAULT NULL COMMENT '有效客户:1:有效,0或者空:无效',
     `ai_call_remark` varchar(500) NULL DEFAULT NULL COMMENT 'AI外呼备注',
-    `effective_record_path` varchar(1000) NULL DEFAULT NULL COMMENT '最后一次设置有效时录音',
-    `last_effective_callphone_log_id` bigint NULL DEFAULT NULL COMMENT '最后一次设置有效时外呼记录id',
+    `effective_record_path` varchar(1000) NULL DEFAULT NULL COMMENT '最后一次设置录音',
+    `last_effective_callphone_log_id` bigint NULL DEFAULT NULL COMMENT '最后一次设置外呼记录id',
     PRIMARY KEY (`customer_id`) USING BTREE,
     UNIQUE INDEX `customer_code`(`customer_code`) USING BTREE,
     INDEX                   `create_user_id`(`create_user_id`) USING BTREE,
@@ -3817,6 +3818,7 @@ CREATE TABLE `fastgpt_role` (
   `send_course_status` tinyint(1) DEFAULT NULL COMMENT '是否发送新客先导课',
   `course_id` bigint DEFAULT NULL COMMENT '课程id',
   `user_info` varchar(3000) DEFAULT '昵称,性别,联系方式,预算范围,行程时长,预计出行时间,同行关系,核心需求,意向套餐' COMMENT '用户信息',
+  `tag_groups` varchar(255) DEFAULT NULL COMMENT '需要打标签的标签组',
   PRIMARY KEY (`role_id`) USING BTREE
 ) ENGINE=InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='应用表';
 
@@ -18412,6 +18414,12 @@ CREATE TABLE `ai_sip_call_user`
     `company_id`      bigint NULL DEFAULT NULL COMMENT '企业ID',
     `company_user_id` bigint NULL DEFAULT NULL COMMENT '销售ID',
     `gateway_ids`     varchar(255)  NULL DEFAULT NULL COMMENT '网关ids',
+    `ext_pass`        varchar(64)   NULL DEFAULT NULL COMMENT '分机密码(SIP注册必需)',
+    `user_code`       varchar(64)   NULL DEFAULT NULL COMMENT '分机绑定用户工号',
+    `user_source`     varchar(2)    NULL DEFAULT '0' COMMENT '用户来源(0销售 1总后台)',
+    `sys_user_id`     bigint        NULL DEFAULT NULL COMMENT '总后台用户ID',
+    `sys_user_name`   varchar(64)   NULL DEFAULT NULL COMMENT '总后台用户账号',
+    `company_name`    varchar(128)  NULL DEFAULT NULL COMMENT '销售公司名称',
     PRIMARY KEY (`user_id`) USING BTREE
 ) ENGINE = InnoDB  AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'sip用户信息表' ROW_FORMAT = DYNAMIC;
 
@@ -18436,6 +18444,15 @@ CREATE TABLE `ai_sip_call_outbound_cdr`
     `record_filename` varchar(100)  NOT NULL COMMENT '录音文件名',
     `chat_content`    text  NULL COMMENT '对话内容',
     `hangup_cause`    varchar(50)   NOT NULL COMMENT '挂断原因',
+    `wavfile`            varchar(255) NULL DEFAULT NULL COMMENT '录音文件URL',
+    `source_type`        varchar(2)   NULL DEFAULT NULL COMMENT '外呼类型(0销售后台 1总后台)',
+    `company_id`         bigint       NULL DEFAULT NULL COMMENT '销售公司ID',
+    `company_user_id`    bigint       NULL DEFAULT NULL COMMENT '销售ID',
+    `company_name`       varchar(128) NULL DEFAULT NULL COMMENT '销售公司名称',
+    `company_user_name`  varchar(64)  NULL DEFAULT NULL COMMENT '销售账号',
+    `sys_user_id`        bigint       NULL DEFAULT NULL COMMENT '总后台用户ID',
+    `sys_user_name`      varchar(64)  NULL DEFAULT NULL COMMENT '总后台用户账号',
+    `status`             tinyint      NULL DEFAULT 0    COMMENT '状态(0正常 1删除)',
     PRIMARY KEY (`id`) USING BTREE
 ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = DYNAMIC;
 
@@ -18551,6 +18568,7 @@ CREATE TABLE `crm_customer_call_log`
     `update_by`        varchar(255)  NULL DEFAULT NULL,
     `update_time`      datetime NULL DEFAULT NULL,
     `call_type`        int NULL DEFAULT NULL COMMENT '外呼类型',
+    `billing_minute` int NULL DEFAULT NULL COMMENT '计费分钟数,单位分钟',
     PRIMARY KEY (`log_id`) USING BTREE,
     INDEX              `company_and_company_user_idx`(`company_id`, `company_user_id`) USING BTREE,
     INDEX              `customer_id_idx`(`customer_id`) USING BTREE
@@ -18604,5 +18622,268 @@ CREATE TABLE `company_extension_bind`
     INDEX             `company_extension_idx`(`company_id`, `extension_num`) USING BTREE
 ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
 
+-- ----------------------------
+-- Table structure for company_lobster_tag_user_rel
+-- ----------------------------
+DROP TABLE IF EXISTS `company_lobster_tag_user_rel`;
+CREATE TABLE `company_lobster_tag_user_rel`
+(
+    `id`                  bigint       NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `company_id`          bigint       NOT NULL COMMENT '公司ID(租户隔离)',
+    `binding_id`          bigint       NOT NULL COMMENT '标签模板绑定ID(company_tag_template_binding.id)',
+    `tag_code`            varchar(128) NOT NULL COMMENT '标签编码(冗余)',
+    `template_id`         bigint       NOT NULL COMMENT '工作流模板ID(冗余)',
+    `external_contact_id` bigint       NOT NULL COMMENT '企微外部联系人ID(qw_external_contact.id)',
+    `create_by`           varchar(64)  DEFAULT NULL COMMENT '创建人',
+    `create_time`         datetime     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `corp_id`             varchar(255) DEFAULT NULL COMMENT '企微主体id',
+    `company_user_id`     bigint       DEFAULT NULL COMMENT '销售id',
+    `qw_user_id`          bigint       DEFAULT NULL,
+    `del_flag`            tinyint      DEFAULT '0' COMMENT '0正常1删除',
+    `update_time`         datetime     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    `update_by`           varchar(64)  DEFAULT NULL COMMENT '更新人',
+    PRIMARY KEY (`id`) USING BTREE,
+    UNIQUE KEY `uk_binding_contact` (`binding_id`,`external_contact_id`) USING BTREE,
+    KEY                   `idx_company_id` (`company_id`) USING BTREE,
+    KEY                   `idx_binding_id` (`binding_id`) USING BTREE,
+    KEY                   `idx_external_contact` (`external_contact_id`) USING BTREE
+) ENGINE=InnoDB AUTO_INCREMENT=18 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='龙虾标签-企微客户关联表';
+
+-- ----------------------------
+-- Table structure for company_workflow_lobster
+-- ----------------------------
+DROP TABLE IF EXISTS `company_workflow_lobster`;
+CREATE TABLE `company_workflow_lobster`
+(
+    `id`            bigint       NOT NULL AUTO_INCREMENT,
+    `company_id`    bigint       NOT NULL COMMENT '公司ID',
+    `template_code` varchar(64)  NOT NULL COMMENT '模板编码',
+    `template_name` varchar(128) NOT NULL COMMENT '模板名称',
+    `industry_type` varchar(64)           DEFAULT 'general' COMMENT '行业类型',
+    `description`   text COMMENT '描述',
+    `canvas_data`   longtext COMMENT '画布数据JSON(包含节点位置、连线、缩放等可视化信息)',
+    `status`        tinyint      NOT NULL DEFAULT '0' COMMENT '状态:0草稿 1发布 2停用',
+    `version`       int          NOT NULL DEFAULT '1' COMMENT '版本',
+    `create_by`     varchar(64)           DEFAULT NULL,
+    `create_time`   datetime              DEFAULT CURRENT_TIMESTAMP,
+    `update_by`     varchar(64)           DEFAULT NULL,
+    `update_time`   datetime              DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    `del_flag`      tinyint      NOT NULL DEFAULT '0',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_template_code` (`template_code`),
+    KEY             `idx_company_id` (`company_id`),
+    KEY             `idx_cwl_company_del_create` (`company_id`,`del_flag`,`create_time`)
+) ENGINE=InnoDB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='工作流龙虾模板';
+
+-- ----------------------------
+-- Table structure for company_workflow_lobster_edge
+-- ----------------------------
+DROP TABLE IF EXISTS `company_workflow_lobster_edge`;
+CREATE TABLE `company_workflow_lobster_edge`
+(
+    `id`               bigint      NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `workflow_id`      bigint      NOT NULL COMMENT '工作流ID',
+    `edge_key`         varchar(64) NOT NULL COMMENT '连线唯一标识',
+    `source_node_code` varchar(64) NOT NULL COMMENT '源节点编码',
+    `target_node_code` varchar(64) NOT NULL COMMENT '目标节点编码',
+    `source_port`      varchar(32)  DEFAULT 'right' COMMENT '源节点连接点',
+    `target_port`      varchar(32)  DEFAULT 'left' COMMENT '目标节点连接点',
+    `edge_label`       varchar(128) DEFAULT NULL COMMENT '连线标签',
+    `edge_color`       varchar(32)  DEFAULT '#999' COMMENT '连线颜色',
+    `condition_expr`   text COMMENT '条件表达式(判断节点分支)',
+    `sort_no`          int          DEFAULT '0' COMMENT '排序',
+    `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`),
+    KEY                `idx_workflow_del` (`workflow_id`,`del_flag`)
+) ENGINE=InnoDB AUTO_INCREMENT=149 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='工作流龙虾连线表';
+
+-- ----------------------------
+-- Table structure for company_workflow_lobster_node
+-- ----------------------------
+DROP TABLE IF EXISTS `company_workflow_lobster_node`;
+CREATE TABLE `company_workflow_lobster_node`
+(
+    `id`               bigint       NOT NULL AUTO_INCREMENT,
+    `workflow_id`      bigint       NOT NULL COMMENT '模板ID',
+    `node_code`        varchar(64)  NOT NULL COMMENT '节点编码',
+    `node_name`        varchar(128) NOT NULL COMMENT '节点名称',
+    `node_type`        tinyint      NOT NULL COMMENT '1开始 2消息 3判断 4等待 5结束 6API调用',
+    `sort_no`          int          NOT NULL DEFAULT '0' COMMENT '排序',
+    `position_x`       int                   DEFAULT '0' COMMENT '节点X坐标',
+    `position_y`       int                   DEFAULT '0' COMMENT '节点Y坐标',
+    `width`            int                   DEFAULT '200' COMMENT '节点宽度',
+    `height`           int                   DEFAULT '80' COMMENT '节点高度',
+    `next_node_code`   varchar(64)           DEFAULT NULL COMMENT '下一节点',
+    `message_template` text COMMENT '消息模板',
+    `condition_expr`   text COMMENT '条件表达式JSON',
+    `node_config`      text COMMENT '节点配置JSON',
+    `greeting_config`  text COMMENT '问候配置JSON',
+    `create_by`        varchar(64)           DEFAULT NULL,
+    `create_time`      datetime              DEFAULT CURRENT_TIMESTAMP,
+    `update_by`        varchar(64)           DEFAULT NULL,
+    `update_time`      datetime              DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    `del_flag`         tinyint      NOT NULL DEFAULT '0',
+    `send_time`        time                  DEFAULT NULL COMMENT '发送时间',
+    PRIMARY KEY (`id`),
+    KEY                `idx_workflow_id` (`workflow_id`),
+    KEY                `idx_node_code` (`node_code`),
+    KEY                `idx_cwln_workflow_del_sort` (`workflow_id`,`del_flag`,`sort_no`)
+) ENGINE=InnoDB AUTO_INCREMENT=272 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='工作流龙虾节点';
+
+-- ----------------------------
+-- Table structure for company_workflow_lobster_record
+-- ----------------------------
+DROP TABLE IF EXISTS `company_workflow_lobster_record`;
+CREATE TABLE `company_workflow_lobster_record`
+(
+    `id`               bigint      NOT NULL AUTO_INCREMENT,
+    `company_id`       bigint      NOT NULL COMMENT '公司ID',
+    `record_no`        varchar(64) NOT NULL COMMENT '生成记录号',
+    `requirement`      text COMMENT '用户需求',
+    `selected_api_ids` varchar(500)         DEFAULT NULL COMMENT '选择的接口ID',
+    `status`           tinyint     NOT NULL DEFAULT '0' COMMENT '0生成中 1成功 2失败 3已确认',
+    `error_msg`        varchar(500)         DEFAULT NULL COMMENT '失败原因',
+    `result_json`      longtext COMMENT '生成结果JSON',
+    `create_by`        varchar(64)          DEFAULT NULL,
+    `create_time`      datetime             DEFAULT CURRENT_TIMESTAMP,
+    `update_by`        varchar(64)          DEFAULT NULL,
+    `update_time`      datetime             DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    `del_flag`         tinyint     NOT NULL DEFAULT '0',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_record_no` (`record_no`),
+    KEY                `idx_company_status` (`company_id`,`status`)
+) ENGINE=InnoDB AUTO_INCREMENT=26 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='工作流龙虾生成记录';
+
+-- ----------------------------
+-- Table structure for company_workflow_lobster_task
+-- ----------------------------
+DROP TABLE IF EXISTS `company_workflow_lobster_task`;
+CREATE TABLE `company_workflow_lobster_task`
+(
+    `id`                bigint       NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `company_id`        bigint       NOT NULL COMMENT '公司ID(租户隔离)',
+    `template_id`       bigint       NOT NULL COMMENT '工作流模板ID(company_workflow_lobster.id)',
+    `task_name`         varchar(128) NOT NULL COMMENT '任务名称',
+    `task_type`         tinyint      NOT NULL COMMENT '任务类型1开始 2消息 3判断 4等待 5结束 6API调用',
+    `task_content`      text COMMENT '任务内容(JSON格式,存储节点配置参数)',
+    `cron_expression`   varchar(64)           DEFAULT NULL COMMENT 'Cron表达式(定时触发用)',
+    `execute_status`    tinyint      NOT NULL DEFAULT '0' COMMENT '执行状态:0-待执行, 1-执行中, 2-执行成功, 3-执行失败, 4-已取消, 5-已过期',
+    `execute_count`     int          NOT NULL DEFAULT '0' COMMENT '已执行次数',
+    `max_retry`         int          NOT NULL DEFAULT '0' COMMENT '最大重试次数(0表示不重试)',
+    `retry_count`       int          NOT NULL DEFAULT '0' COMMENT '已重试次数',
+    `last_execute_time` datetime              DEFAULT NULL COMMENT '上次执行时间',
+    `next_execute_time` datetime              DEFAULT NULL COMMENT '下次执行时间',
+    `fail_reason`       varchar(512)          DEFAULT NULL COMMENT '失败原因',
+    `sort_order`        int          NOT NULL DEFAULT '0' COMMENT '排序号',
+    `del_flag`          tinyint      NOT NULL DEFAULT '0' COMMENT '删除标志:0-正常, 1-已删除',
+    `remark`            varchar(256)          DEFAULT NULL COMMENT '备注',
+    `create_by`         varchar(64)           DEFAULT NULL COMMENT '创建人',
+    `create_time`       datetime              DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_by`         varchar(64)           DEFAULT NULL COMMENT '更新人',
+    `update_time`       datetime              DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    `corp_id`           varchar(255)          DEFAULT NULL,
+    `company_user_id`   bigint                DEFAULT NULL,
+    `lobster_node_id`   bigint                DEFAULT NULL,
+    `send_time`         datetime              DEFAULT NULL,
+    `qw_user_id`        bigint                DEFAULT NULL,
+    `binding_id`        bigint                DEFAULT NULL,
+    `external_user_id`  varchar(255)          DEFAULT NULL,
+    PRIMARY KEY (`id`) USING BTREE,
+    KEY                 `idx_company_id` (`company_id`) USING BTREE,
+    KEY                 `idx_template_id` (`template_id`) USING BTREE,
+    KEY                 `idx_task_type` (`task_type`) USING BTREE,
+    KEY                 `idx_send_time` (`send_time`) USING BTREE
+) ENGINE=InnoDB AUTO_INCREMENT=108 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='龙虾工作流模板自动任务表';
+
+-- ----------------------------
+-- Table structure for company_workflow_lobster_variable
+-- ----------------------------
+DROP TABLE IF EXISTS `company_workflow_lobster_variable`;
+CREATE TABLE `company_workflow_lobster_variable`
+(
+    `id`            bigint       NOT NULL AUTO_INCREMENT,
+    `workflow_id`   bigint       NOT NULL COMMENT '模板ID',
+    `var_code`      varchar(64)  NOT NULL COMMENT '变量编码',
+    `var_name`      varchar(128) NOT NULL COMMENT '变量名称',
+    `var_type`      varchar(32)  NOT NULL DEFAULT 'string' COMMENT '变量类型',
+    `source_type`   varchar(32)  NOT NULL DEFAULT 'manual' COMMENT '来源',
+    `required`      tinyint      NOT NULL DEFAULT '0' COMMENT '是否必填',
+    `default_value` varchar(255)          DEFAULT NULL COMMENT '默认值',
+    `description`   varchar(255)          DEFAULT NULL COMMENT '说明',
+    `create_by`     varchar(64)           DEFAULT NULL,
+    `create_time`   datetime              DEFAULT CURRENT_TIMESTAMP,
+    `update_by`     varchar(64)           DEFAULT NULL,
+    `update_time`   datetime              DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    `del_flag`      tinyint      NOT NULL DEFAULT '0',
+    `status`        tinyint      NOT NULL DEFAULT '0' COMMENT '0正常,1禁用',
+    PRIMARY KEY (`id`),
+    KEY             `idx_workflow_var` (`workflow_id`,`var_code`),
+    KEY             `idx_cwlv_workflow_del` (`workflow_id`,`del_flag`)
+) ENGINE=InnoDB AUTO_INCREMENT=40 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='标签-模板绑定表';
+
 SET
 FOREIGN_KEY_CHECKS = 1;
+
+-- ----------------------------
+-- Table structure for crm_customer_call_app_log
+-- ----------------------------
+DROP TABLE IF EXISTS `crm_customer_call_app_log`;
+CREATE TABLE `crm_customer_call_app_log`
+(
+    `log_id`           bigint NOT NULL AUTO_INCREMENT,
+    `customer_id`      bigint NULL DEFAULT NULL COMMENT '客户ID',
+    `run_time`         datetime NULL DEFAULT NULL COMMENT '记录调用时间',
+    `run_param`        text  NULL COMMENT '调用参数',
+    `result`           text  NULL COMMENT '回调返回结果',
+    `status`           tinyint NULL DEFAULT NULL COMMENT '执行状态:1、执行中,2、执行成功,3、执行失败',
+    `create_time`      datetime NULL DEFAULT NULL COMMENT '创建时间',
+    `record_path`      varchar(1000)  NULL DEFAULT NULL COMMENT '录音地址',
+    `content_list`     text  NULL COMMENT '通话详细列表',
+    `caller_num`       varchar(50)  NULL DEFAULT NULL COMMENT '客户号码',
+    `callee_num`       varchar(50)  NULL DEFAULT NULL COMMENT '话术号码',
+    `uuid`             varchar(100)  NULL DEFAULT NULL COMMENT '通话的唯一标识',
+    `call_create_time` bigint NULL DEFAULT NULL COMMENT '呼入时间',
+    `call_answer_time` bigint NULL DEFAULT NULL COMMENT '应答时间',
+    `intention`        varchar(255)  NULL DEFAULT NULL COMMENT '客户类型',
+    `company_id`       int NULL DEFAULT NULL COMMENT '公司id',
+    `company_user_id`  bigint NULL DEFAULT NULL COMMENT '销售id',
+    `call_time`        int NULL DEFAULT NULL COMMENT '通话时长,单位秒',
+    `cost`             decimal(10, 2) NULL DEFAULT NULL COMMENT '花费金额',
+    `create_by`        varchar(255)  NULL DEFAULT NULL,
+    `update_by`        varchar(255)  NULL DEFAULT NULL,
+    `update_time`      datetime NULL DEFAULT NULL
+    PRIMARY KEY (`log_id`) USING BTREE,
+    INDEX              `company_and_company_user_idx`(`company_id`, `company_user_id`) USING BTREE,
+    INDEX              `customer_id_idx`(`customer_id`) USING BTREE
+) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = DYNAMIC;

+ 11 - 1
fs-service/src/main/resources/mapper/aiSipCall/AiSipCallOutboundCdrMapper.xml

@@ -19,10 +19,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="recordFilename"    column="record_filename"    />
         <result property="chatContent"    column="chat_content"    />
         <result property="hangupCause"    column="hangup_cause"    />
+        <result property="sourceType"    column="source_type"    />
+        <result property="customerId"    column="customer_id"    />
     </resultMap>
 
     <sql id="selectAiSipCallOutboundCdrVo">
-        select id, caller, opnum, callee, start_time, answered_time, end_time, uuid, call_type, time_len, time_len_valid, record_filename, chat_content, hangup_cause from ai_sip_call_outbound_cdr
+        select id, caller, opnum, callee, start_time, answered_time, end_time, uuid, call_type, time_len, time_len_valid, record_filename, chat_content, hangup_cause, source_type, customer_id,wavfile from ai_sip_call_outbound_cdr
     </sql>
 
     <select id="selectAiSipCallOutboundCdrList" parameterType="AiSipCallOutboundCdr" resultMap="AiSipCallOutboundCdrResult">
@@ -41,6 +43,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="recordFilename != null  and recordFilename != ''"> and record_filename like concat('%', #{recordFilename}, '%')</if>
             <if test="chatContent != null  and chatContent != ''"> and chat_content = #{chatContent}</if>
             <if test="hangupCause != null  and hangupCause != ''"> and hangup_cause = #{hangupCause}</if>
+            <if test="sourceType != null  and sourceType != ''"> and source_type = #{sourceType}</if>
+            <if test="customerId != null"> and customer_id = #{customerId}</if>
             <if test="timeLenStart != null  and timeLenStart != ''"> and time_len &gt;= #{timeLenStart}</if>
             <if test="timeLenEnd != null  and timeLenEnd != ''"> and time_len &lt;= #{timeLenEnd}</if>
             <if test="startTimeStartLong != null  and startTimeStartLong != ''"> and start_time &gt;= #{startTimeStartLong}</if>
@@ -75,6 +79,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="recordFilename != null and recordFilename != ''">record_filename,</if>
             <if test="chatContent != null">chat_content,</if>
             <if test="hangupCause != null and hangupCause != ''">hangup_cause,</if>
+            <if test="sourceType != null and sourceType != ''">source_type,</if>
+            <if test="customerId != null">customer_id,</if>
          </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="id != null">#{id},</if>
@@ -91,6 +97,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="recordFilename != null and recordFilename != ''">#{recordFilename},</if>
             <if test="chatContent != null">#{chatContent},</if>
             <if test="hangupCause != null and hangupCause != ''">#{hangupCause},</if>
+            <if test="sourceType != null and sourceType != ''">#{sourceType},</if>
+            <if test="customerId != null">#{customerId},</if>
          </trim>
     </insert>
 
@@ -110,6 +118,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="recordFilename != null and recordFilename != ''">record_filename = #{recordFilename},</if>
             <if test="chatContent != null">chat_content = #{chatContent},</if>
             <if test="hangupCause != null and hangupCause != ''">hangup_cause = #{hangupCause},</if>
+            <if test="sourceType != null and sourceType != ''">source_type = #{sourceType},</if>
+            <if test="customerId != null">customer_id = #{customerId},</if>
         </trim>
         where id = #{id}
     </update>

+ 2 - 1
fs-service/src/main/resources/mapper/aiSipCall/AiSipCallUserMapper.xml

@@ -34,7 +34,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </resultMap>
 
     <sql id="selectAiSipCallUserVo">
-        select user_id, dept_id, login_name, user_name, user_type, email, phonenumber, sex, avatar, password, salt, status, del_flag, login_ip, login_date, pwd_update_date, remark, logo, ext_num, create_by, create_time, update_by, update_time, company_id,company_user_id,gateway_ids from ai_sip_call_user
+        select user_id, dept_id, login_name, user_name, user_type, email, phonenumber, sex, avatar, password, salt, status, del_flag, login_ip, login_date, pwd_update_date, remark, logo, ext_num, create_by, create_time, update_by, update_time, company_id,company_user_id,gateway_ids,ext_pass from ai_sip_call_user
     </sql>
 
     <select id="selectAiSipCallUserList" parameterType="AiSipCallUser" resultMap="AiSipCallUserResult">
@@ -154,6 +154,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="extNum != null and extNum != ''">ext_num = #{extNum},</if>
             <if test="companyId != null">company_id = #{companyId},</if>
             <if test="gatewayIds != null and gatewayIds != ''">gateway_ids = #{gatewayIds},</if>
+            <if test="extPass != null and extPass != ''">ext_pass = #{extPass},</if>
         </trim>
         where user_id = #{userId}
     </update>

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

@@ -26,11 +26,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="callTime"    column="call_time"    />
         <result property="cost"    column="cost"    />
         <result property="callType"    column="call_type"    />
+        <result property="hangupType"    column="hangup_type"    />
         <result property="isWarning"    column="is_warning"    />
     </resultMap>
 
     <sql id="selectCompanyVoiceRoboticCallLogCallphoneVo">
-        select log_id, robotic_id, caller_id, run_time, run_param, result, status, create_time, record_path, content_list, caller_num, callee_num, uuid, call_create_time, call_answer_time, intention, company_id, company_user_id, call_time, cost, call_type, is_warning from company_voice_robotic_call_log_callphone
+        select log_id, robotic_id, caller_id, run_time, run_param, result, status, create_time, record_path, content_list, caller_num, callee_num, uuid, call_create_time, call_answer_time, intention, company_id, company_user_id, call_time, cost, call_type, hangup_type, is_warning from company_voice_robotic_call_log_callphone
     </sql>
 
     <select id="selectCompanyVoiceRoboticCallLogCallphoneList" parameterType="CompanyVoiceRoboticCallLogCallphone" resultMap="CompanyVoiceRoboticCallLogCallphoneResult">
@@ -88,6 +89,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="callTime != null">call_time,</if>
             <if test="cost != null">cost,</if>
             <if test="callType != null">call_type,</if>
+            <if test="hangupType != null">hangup_type,</if>
             <if test="isWarning != null">is_warning,</if>
             <if test="violationNum != null">violation_num,</if>
          </trim>
@@ -113,6 +115,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="callTime != null">#{callTime},</if>
             <if test="cost != null">#{cost},</if>
             <if test="callType != null">#{callType},</if>
+            <if test="hangupType != null">#{hangupType},</if>
             <if test="isWarning != null">#{isWarning},</if>
             <if test="violationNum != null">#{violationNum},</if>
          </trim>
@@ -141,6 +144,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="callTime != null">call_time = #{callTime},</if>
             <if test="cost != null">cost = #{cost},</if>
             <if test="callType != null">call_type = #{callType},</if>
+            <if test="hangupType != null">hangup_type = #{hangupType},</if>
             <if test="isWarning != null">is_warning = #{isWarning},</if>
             <if test="violationNum != null">violation_num = #{violationNum},</if>
             <if test="manualAnswered != null">manual_answered = #{manualAnswered},</if>

+ 2 - 0
fs-service/src/main/resources/mapper/company/CompanyVoiceRoboticMapper.xml

@@ -27,6 +27,7 @@
         <result property="companyAiWorkflowId"    column="company_ai_workflow_id"    />
         <result property="delFlag"    column="del_flag"    />
         <result property="isWeCom"    column="is_we_com"    />
+        <result property="pauseSource"    column="pause_source"    />
 
     </resultMap>
 
@@ -187,6 +188,7 @@
             <if test="createUser != null">create_user = #{createUser},</if>
             <if test="companyAiWorkflowId != null">company_ai_workflow_id = #{companyAiWorkflowId},</if>
             <if test="taskStatus != null">task_status = #{taskStatus},</if>
+            <if test="pauseSource != null">pause_source = #{pauseSource},</if>
         </trim>
         where id = #{id}
     </update>

+ 29 - 2
fs-service/src/main/resources/mapper/company/CrmCustomerCallLogMapper.xml

@@ -22,19 +22,26 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="callType" column="call_type" />
         <result property="createTime" column="create_time" />
         <result property="contentList" column="content_list" />
+        <result property="billingMinute" column="billing_minute" />
     </resultMap>
 
     <select id="selectCrmCustomerCallLogList" parameterType="CrmCustomerCallLog" resultMap="CrmCustomerCallLogResult">
         select log_id, uuid, customer_id, company_id, company_user_id, caller_num, callee_num,
                call_create_time, call_answer_time, call_time, record_path, status, intention,
-               cost, call_type, create_time, content_list
+               cost, call_type, create_time, content_list, billing_minute
         from crm_customer_call_log
         <where>
             <if test="customerId != null">AND customer_id = #{customerId}</if>
             <if test="companyId != null">AND company_id = #{companyId}</if>
             <if test="companyUserId != null">AND company_user_id = #{companyUserId}</if>
             <if test="status != null">AND status = #{status}</if>
-            <if test="minCallTime != null">AND call_time &gt; #{minCallTime}</if>
+            <if test="minCallTime != null">AND CEILING(call_time/1000) &gt;= #{minCallTime}</if>
+            <if test="maxCallTime != null">AND CEILING(call_time/1000) &lt;= #{maxCallTime}</if>
+            <if test="callerNum != null and callerNum != ''">AND (caller_num = #{callerNum} OR caller_num = #{plainCallerNum})</if>
+            <if test="beginTime != null and beginTime != '' and endTime != null and endTime != ''">
+                AND date_format(create_time,'%Y-%m-%d') &gt;= #{beginTime}
+                AND date_format(create_time,'%Y-%m-%d') &lt;= #{endTime}
+            </if>
         </where>
         order by create_time desc
     </select>
@@ -67,6 +74,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="updateBy != null">update_by,</if>
             <if test="updateTime != null">update_time,</if>
             <if test="callType != null">call_type,</if>
+            <if test="billingMinute != null">billing_minute,</if>
         </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="runTime != null">#{runTime},</if>
@@ -91,8 +99,27 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="updateBy != null">#{updateBy},</if>
             <if test="updateTime != null">#{updateTime},</if>
             <if test="callType != null">#{callType},</if>
+            <if test="billingMinute != null">#{billingMinute},</if>
         </trim>
     </insert>
 
+    <select id="selectSumBillingMinute" parameterType="CrmCustomerCallLog" resultType="java.lang.Long">
+        select IFNULL(SUM(billing_minute), 0)
+        from crm_customer_call_log
+        <where>
+            <if test="customerId != null">AND customer_id = #{customerId}</if>
+            <if test="companyId != null">AND company_id = #{companyId}</if>
+            <if test="companyUserId != null">AND company_user_id = #{companyUserId}</if>
+            <if test="status != null">AND status = #{status}</if>
+            <if test="minCallTime != null">AND CEILING(call_time/1000) &gt;= #{minCallTime}</if>
+            <if test="maxCallTime != null">AND CEILING(call_time/1000) &lt;= #{maxCallTime}</if>
+            <if test="callerNum != null and callerNum != ''">AND (caller_num = #{callerNum} OR caller_num = #{plainCallerNum})</if>
+            <if test="beginTime != null and beginTime != '' and endTime != null and endTime != ''">
+                AND date_format(create_time,'%Y-%m-%d') &gt;= #{beginTime}
+                AND date_format(create_time,'%Y-%m-%d') &lt;= #{endTime}
+            </if>
+        </where>
+    </select>
+
 
 </mapper>

+ 1 - 1
fs-service/src/main/resources/mapper/fastGpt/FastGptChatSessionMapper.xml

@@ -29,7 +29,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </resultMap>
 
     <sql id="selectFastGptChatSessionVo">
-        select session_id,remind_time,is_reply,last_time,remind_status,remind_count,qw_ext_id,qw_user_id,chat_id,is_artificial, user_id, kf_id, create_time, update_time, status, company_id, is_look, user_type, nick_name, avatar,userInfo from fastgpt_chat_session
+        select session_id,remind_time,is_reply,last_time,remind_status,remind_count,qw_ext_id,qw_user_id,chat_id,is_artificial, user_id, kf_id, create_time, update_time, status, company_id, is_look, user_type, nick_name, avatar,user_info from fastgpt_chat_session
     </sql>
 
     <select id="selectFastGptChatSessionList" parameterType="FastGptChatSession" resultMap="FastGptChatSessionResult">

+ 5 - 1
fs-service/src/main/resources/mapper/fastGpt/FastGptRoleMapper.xml

@@ -28,12 +28,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="sendCourseStatus"    column="send_course_status"    />
         <result property="courseId"    column="course_id"    />
         <result property="userInfo"    column="user_info"    />
+        <result property="tagGroups"    column="tag_groups"    />
     </resultMap>
 
     <sql id="selectFastGptRoleVo">
         select role_id, role_name,contact_info,company_id, create_time, update_time, role_type, mode_config_json,
                mode, kf_id, kf_url, avatar, kf_media_id,reminder_words, bind_corp_id,channel_type,logistics,forbid_send_start,
-               forbid_send_end,forbid_status,send_course_status,course_id,user_info
+               forbid_send_end,forbid_status,send_course_status,course_id,user_info,tag_groups
         from fastgpt_role
     </sql>
 
@@ -113,6 +114,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="bindCorpId != null">bind_corp_id,</if>
             <if test="contactInfo != null">contact_info,</if>
             <if test="userInfo != null">user_info,</if>
+            <if test="tagGroups != null">tag_groups,</if>
         </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="roleName != null">#{roleName},</if>
@@ -130,6 +132,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="bindCorpId != null">#{bindCorpId},</if>
             <if test="contactInfo != null">#{contactInfo},</if>
             <if test="userInfo != null">#{userInfo},</if>
+            <if test="tagGroups != null">#{tagGroups},</if>
         </trim>
     </insert>
 
@@ -156,6 +159,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="sendCourseStatus != null">send_course_status = #{sendCourseStatus},</if>
             <if test="courseId != null">course_id = #{courseId},</if>
             <if test="userInfo != null">user_info = #{userInfo},</if>
+            <if test="tagGroups != null">tag_groups = #{tagGroups},</if>
         </trim>
         where role_id = #{roleId}
     </update>

+ 11 - 0
fs-service/src/main/resources/mapper/qw/QwTagGroupMapper.xml

@@ -114,4 +114,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     <select id="selectQwTagGroupByName" resultType="com.fs.qw.domain.QwTagGroup">
         select * from qw_tag_group where name=#{tagGroup} and corp_id=#{corpId} limit 1
     </select>
+    <select id="selectQwTagGroupByIds" resultType="com.fs.qw.vo.QwTagGroupListVO">
+        select * from qw_tag_group
+        <where>
+            <if test="tagGroups != null and tagGroups != '' ">
+                and find_in_set(id,#{tagGroups})
+            </if>
+            <if test="corpId != null and corpId != '' ">
+                and corp_id= #{corpId}
+            </if>
+        </where>
+    </select>
 </mapper>

+ 5 - 4
fs-service/src/main/resources/mapper/third/TencentWordDetailMapper.xml

@@ -5,7 +5,7 @@
 <mapper namespace="com.fs.third.mapper.TencentWordDetailMapper">
     <resultMap id="TencentWordDetailResult" type="com.fs.third.domain.TencentWordDetail">
         <id property="id" column="id"/>
-        <result property="fileID" column="file_id"/>
+        <result property="fileId" column="file_id"/>
         <result property="sheetId" column="sheet_id"/>
         <result property="q" column="q"/>
         <result property="a" column="a"/>
@@ -15,9 +15,10 @@
         <result property="updateTime" column="update_time"/>
     </resultMap>
     <insert id="insertBatch">
-        <foreach collection="list" item="item" separator=";">
-            insert into tencent_word_detail (file_id, sheet_id, q, a, create_by, create_time, update_by, update_time)
-            values (#{item.fileID}, #{item.sheetId}, #{item.q}, #{item.a}, #{item.createBy}, now(), #{item.updateBy}, now())
+        insert into tencent_word_detail (file_id, sheet_id, q, a, create_by, create_time, update_by, update_time)
+        values
+        <foreach collection="list" item="item" separator=",">
+            (#{item.fileId}, #{item.sheetId}, #{item.q}, #{item.a}, #{item.createBy}, now(), #{item.updateBy}, now())
         </foreach>
     </insert>