Kaynağa Gözat

Merge remote-tracking branch 'origin/master'

ct 3 gün önce
ebeveyn
işleme
ac4149e39b
37 değiştirilmiş dosya ile 1762 ekleme ve 55 silme
  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. 4 1
      fs-company-app/src/main/java/com/fs/app/controller/UserController.java
  7. 391 0
      fs-company-app/src/main/java/com/fs/app/controller/aiSipCall/AiSipCallController.java
  8. 52 0
      fs-company-app/src/main/java/com/fs/app/controller/crm/CrmAPPMsgController.java
  9. 15 0
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallOutboundCdrController.java
  10. 13 0
      fs-company/src/main/java/com/fs/company/controller/aiSipCall/AiSipCallUserController.java
  11. 5 0
      fs-company/src/main/java/com/fs/company/controller/third/TencentWordOpenApiController.java
  12. 21 0
      fs-service/src/main/java/com/fs/aiSipCall/RemoteCommon.java
  13. 38 0
      fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipCallOutboundCdr.java
  14. 12 0
      fs-service/src/main/java/com/fs/aiSipCall/domain/AiSipCallUser.java
  15. 13 0
      fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallOutboundCdrService.java
  16. 6 0
      fs-service/src/main/java/com/fs/aiSipCall/service/IAiSipCallUserService.java
  17. 76 0
      fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallOutboundCdrServiceImpl.java
  18. 41 0
      fs-service/src/main/java/com/fs/aiSipCall/service/impl/AiSipCallUserServiceImpl.java
  19. 9 0
      fs-service/src/main/java/com/fs/crm/domain/CrmCustomer.java
  20. 26 0
      fs-service/src/main/java/com/fs/crm/mapper/CrmCustomerMapper.java
  21. 15 0
      fs-service/src/main/java/com/fs/fastGpt/domain/FastGptChatConversation.java
  22. 2 0
      fs-service/src/main/java/com/fs/fastGpt/domain/FastGptRole.java
  23. 1 1
      fs-service/src/main/java/com/fs/fastGpt/mapper/FastGptRoleMapper.java
  24. 452 4
      fs-service/src/main/java/com/fs/fastGpt/service/impl/AiHookServiceImpl.java
  25. 2 0
      fs-service/src/main/java/com/fs/qw/mapper/QwTagGroupMapper.java
  26. 2 0
      fs-service/src/main/java/com/fs/qw/service/IQwTagGroupService.java
  27. 14 0
      fs-service/src/main/java/com/fs/qw/service/impl/QwTagGroupServiceImpl.java
  28. 1 1
      fs-service/src/main/java/com/fs/qw/service/impl/QwUserServiceImpl.java
  29. 2 0
      fs-service/src/main/java/com/fs/third/service/ITencentWordService.java
  30. 199 35
      fs-service/src/main/java/com/fs/third/service/impl/TencentWordServiceImpl.java
  31. 2 2
      fs-service/src/main/java/com/fs/wxcid/dto/message/CdnUploadVideoResult.java
  32. 279 0
      fs-service/src/main/resources/db/tenant-initTable.sql
  33. 11 1
      fs-service/src/main/resources/mapper/aiSipCall/AiSipCallOutboundCdrMapper.xml
  34. 2 1
      fs-service/src/main/resources/mapper/aiSipCall/AiSipCallUserMapper.xml
  35. 5 1
      fs-service/src/main/resources/mapper/fastGpt/FastGptRoleMapper.xml
  36. 11 0
      fs-service/src/main/resources/mapper/qw/QwTagGroupMapper.xml
  37. 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) {

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

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

+ 76 - 0
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

+ 41 - 0
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"));
@@ -194,6 +211,9 @@ public class AiSipCallUserServiceImpl extends ServiceImpl<AiSipCallUserMapper, A
             result.setNewExtNum(newExtNum);
             result.setOldExtNum(oldExtNum);
             result.setNewUserCode(aiSipCallUser.getLoginName());
+            if(StringUtils.isNotBlank(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);
+    }
+
 }

+ 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);
+
     /**
      * 批量插入客户
      *

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

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

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

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

@@ -3818,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='应用表';
 
@@ -18413,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;
 
@@ -18437,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;
 
@@ -18605,5 +18621,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/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>