Explorar o código

Merge remote-tracking branch 'origin/master'

yys hai 2 días
pai
achega
84532202e2
Modificáronse 48 ficheiros con 1988 adicións e 334 borrados
  1. 337 1
      fs-admin/src/main/java/com/fs/his/task/Task.java
  2. 0 2
      fs-admin/src/main/java/com/fs/live/controller/LiveController.java
  3. 66 38
      fs-ai-call-task/src/main/java/com/fs/app/service/CallTaskService.java
  4. 104 19
      fs-cid-workflow/src/main/java/com/fs/app/service/CidWorkflowTaskService.java
  5. 13 0
      fs-cid-workflow/src/main/java/com/fs/app/task/CidTask.java
  6. 1 1
      fs-cid-workflow/src/main/resources/application.yml
  7. 3 1
      fs-company/src/main/java/com/fs/company/controller/common/CaptchaController.java
  8. 35 1
      fs-company/src/main/java/com/fs/company/controller/company/CompanyVoiceRoboticCallLogCallphoneController.java
  9. 27 3
      fs-company/src/main/java/com/fs/company/controller/crm/CrmCustomerController.java
  10. 4 2
      fs-company/src/main/java/com/fs/company/controller/qw/QwGroupChatController.java
  11. 1 11
      fs-company/src/main/resources/application.yml
  12. 2 6
      fs-qw-api/src/main/java/com/fs/app/controller/OpenQwApiController.java
  13. 2 2
      fs-qw-api/src/main/java/com/fs/app/service/OpenQwApiService.java
  14. 8 6
      fs-qw-api/src/main/java/com/fs/app/service/impl/OpenQwApiServiceImpl.java
  15. 42 9
      fs-service/src/main/java/com/fs/company/config/AsyncCalleeConfig.java
  16. 8 0
      fs-service/src/main/java/com/fs/company/domain/CompanyVoiceRoboticCallLogCallphone.java
  17. 26 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyAiWorkflowExecMapper.java
  18. 4 1
      fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceRoboticCallLogCallphoneMapper.java
  19. 9 1
      fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticCallLogCallphoneService.java
  20. 123 5
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallLogCallphoneServiceImpl.java
  21. 124 56
      fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticServiceImpl.java
  22. 1 1
      fs-service/src/main/java/com/fs/company/service/impl/call/node/AiCallTaskNode.java
  23. 23 0
      fs-service/src/main/java/com/fs/company/vo/CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO.java
  24. 19 0
      fs-service/src/main/java/com/fs/company/vo/CompanyVoiceRoboticCallLogCallPhoneDecryptQueryVO.java
  25. 3 0
      fs-service/src/main/java/com/fs/company/vo/CompanyVoiceRoboticCallLogCallPhoneVO.java
  26. 1 1
      fs-service/src/main/java/com/fs/company/vo/easycall/EasyCallCallPhoneVO.java
  27. 11 0
      fs-service/src/main/java/com/fs/config/tencent/TencentCOSClientConfig.java
  28. 1 1
      fs-service/src/main/java/com/fs/core/config/WxPayConfiguration.java
  29. 391 0
      fs-service/src/main/java/com/fs/crm/mapper/CrmCustomerMapper.java
  30. 35 0
      fs-service/src/main/java/com/fs/crm/param/CrmCustomerConditionAssignParam.java
  31. 19 0
      fs-service/src/main/java/com/fs/crm/service/ICrmCustomerService.java
  32. 60 0
      fs-service/src/main/java/com/fs/crm/service/impl/CrmCustomerServiceImpl.java
  33. 3 2
      fs-service/src/main/java/com/fs/fastGpt/domain/FastGptChatConversation.java
  34. 141 63
      fs-service/src/main/java/com/fs/fastGpt/service/impl/AiHookServiceImpl.java
  35. 0 2
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsUserScrmServiceImpl.java
  36. 4 0
      fs-service/src/main/java/com/fs/qw/mapper/QwExternalContactMapper.java
  37. 2 2
      fs-service/src/main/java/com/fs/qw/mapper/QwGroupChatMapper.java
  38. 5 2
      fs-service/src/main/java/com/fs/qw/mapper/QwUserMapper.java
  39. 33 1
      fs-service/src/main/java/com/fs/qw/service/AsyncQwAiChatSopService.java
  40. 4 0
      fs-service/src/main/java/com/fs/qw/service/IQwExternalContactService.java
  41. 14 0
      fs-service/src/main/java/com/fs/qw/service/impl/QwExternalContactServiceImpl.java
  42. 5 1
      fs-service/src/main/java/com/fs/qw/service/impl/QwGroupChatServiceImpl.java
  43. 50 2
      fs-service/src/main/resources/application-dev-test.yml
  44. 20 1
      fs-service/src/main/resources/mapper/company/CompanyAiWorkflowExecMapper.xml
  45. 47 1
      fs-service/src/main/resources/mapper/company/CompanyVoiceRoboticCallLogCallphoneMapper.xml
  46. 14 0
      fs-service/src/main/resources/mapper/qw/QwExternalContactMapper.xml
  47. 19 7
      fs-service/src/main/resources/mapper/third/TencentWordDetailMapper.xml
  48. 124 82
      fs-wx-task/src/main/java/com/fs/app/service/WxTaskService.java

+ 337 - 1
fs-admin/src/main/java/com/fs/his/task/Task.java

@@ -2,13 +2,18 @@ package com.fs.his.task;
 
 import cn.hutool.core.collection.CollectionUtil;
 import cn.hutool.core.date.DateTime;
+import cn.hutool.http.HttpException;
+import cn.hutool.http.HttpUtil;
 import cn.hutool.json.JSONUtil;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
-import com.fs.common.core.domain.R;
+import com.fs.FSApplication;
+import com.fs.common.config.RedisTenantContext;
 import com.fs.common.core.redis.RedisCache;
+import com.fs.common.core.redis.RedisCacheTenant;
+import com.fs.common.exception.base.BaseException;
 import com.fs.common.service.impl.SmsServiceImpl;
 import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.SecurityUtils;
@@ -20,6 +25,7 @@ import com.fs.company.service.ICompanyService;
 import com.fs.company.service.ICompanyUserService;
 import com.fs.company.vo.QwIpadTotalVo;
 import com.fs.company.vo.RedPacketMoneyVO;
+import com.fs.core.config.TenantConfigContext;
 import com.fs.course.dto.BatchSendCourseAllDTO;
 import com.fs.course.mapper.FsCourseRedPacketLogMapper;
 import com.fs.course.service.IFsCourseWatchLogService;
@@ -33,6 +39,7 @@ import com.fs.fastGpt.domain.*;
 import com.fs.fastGpt.mapper.FastGptChatSessionMapper;
 import com.fs.fastGpt.mapper.FastgptChatVoiceHomoMapper;
 import com.fs.fastGpt.service.AiHookService;
+import com.fs.fastGpt.service.IFastGptChatMsgService;
 import com.fs.fastGpt.service.IFastgptEventLogTotalService;
 import com.fs.framework.task.TenantTaskRunner;
 import com.fs.fastgptApi.util.AudioUtils;
@@ -60,24 +67,35 @@ import com.fs.huifuPay.service.HuiFuService;
 import com.fs.im.dto.*;
 import com.fs.im.service.IImService;
 import com.fs.im.service.OpenIMService;
+import com.fs.ipad.IpadSendUtils;
+import com.fs.ipad.vo.*;
 import com.fs.qw.domain.QwCompany;
+import com.fs.qw.domain.QwExternalContact;
 import com.fs.qw.domain.QwUser;
 import com.fs.qw.mapper.QwRestrictionPushRecordMapper;
 import com.fs.qw.mapper.QwUserMapper;
+import com.fs.qw.param.QwExternalContactAddTagParam;
+import com.fs.qw.param.QwExternalContactParam;
 import com.fs.qw.service.*;
+import com.fs.qwApi.config.OpenQwConfig;
 import com.fs.sop.domain.QwSopTempVoice;
 import com.fs.sop.service.IQwSopTempVoiceService;
 import com.fs.system.domain.SysConfig;
 import com.fs.system.mapper.SysConfigMapper;
 import com.fs.system.service.ISysConfigService;
 import com.fs.utils.OrderContextHolder;
+import com.fs.wxwork.dto.*;
+import com.fs.wxwork.service.WxWorkService;
+import com.fs.wxwork.service.WxWorkServiceNew;
 import com.google.gson.Gson;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
+import org.junit.jupiter.api.Test;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@@ -85,11 +103,15 @@ import org.springframework.stereotype.Component;
 
 import java.text.ParseException;
 import java.text.SimpleDateFormat;
+import java.time.LocalDate;
 import java.time.LocalDateTime;
+import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.util.*;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
 import java.util.function.Consumer;
+import java.util.function.Supplier;
 import java.util.stream.Collectors;
 
 @Slf4j
@@ -239,6 +261,10 @@ public class Task {
 
     @Autowired
     private IFsStoreOrderScrmService orderScrmService;
+
+    @Autowired
+    private IQwExternalContactService qwExternalContactService;
+
     /**
      * 定时任务,处理ai禁止回复之后的消息
      */
@@ -275,6 +301,316 @@ public class Task {
         }
     }*/
 
+    @Scheduled(cron = "0 0/5 * * * ?")
+    public void addTagByQwExtCreateTimeNew() {
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant("addTagByQwExtCreateTime", this::addTagByQwExtCreateTime);
+        } else {
+            addTagByQwExtCreateTime();
+        }
+    }
+
+    /**
+     * 最美叙酱给客户打标签(其他客户关闭,临时用)
+     */
+    public void addTagByQwExtCreateTime() {
+        try {
+            Long tenantId = RedisTenantContext.getTenantId();
+            if(41L != tenantId) return;
+            QwExternalContact qwExternalContact = new QwExternalContact();
+            qwExternalContact.setIsReply(1);
+            qwExternalContact.setTagIds("etotXidAAAMrYqb-nEijbTYQs4PJDbLg");
+            List<QwExternalContact> qwExternalContacts = qwExternalContactService.selectQwExternalContactListBycreateTime(qwExternalContact);
+            if(qwExternalContacts.isEmpty()) return;
+            Date now = new Date();
+            Date oneHourAgo = new Date(now.getTime() - 10 * 60 * 60 * 1000);
+
+            List<QwExternalContact> filteredContacts = qwExternalContacts.stream()
+                    .filter(contact -> {
+                        if (contact.getCreateTime() == null) {
+                            return false;
+                        }
+                        return contact.getCreateTime().before(oneHourAgo);
+                    })
+                    .collect(Collectors.toList());
+
+            if (filteredContacts.isEmpty()) {
+                log.info("过滤后没有创建时间超过10小时的联系人");
+                return;
+            }
+
+            for (QwExternalContact qwExt : filteredContacts) {
+
+                QwExternalContactAddTagParam param = new QwExternalContactAddTagParam();
+                Long qwUserId = qwExt.getQwUserId();
+                if(qwUserId == null) continue;
+                QwUser user = qwUserService.selectQwUserById(qwUserId);
+
+
+                //添加需要打标签的客户
+                List<Long> list = new ArrayList<>();
+                list.add(qwExt.getId());
+                param.setUserIds(list);
+
+                try {
+                    String[] split = new String[]{"etotXidAAAwBK56Zp4OmU71zH8NqlA6Q"};
+                    param.setTagIds(Arrays.asList(split));
+                    param.setCorpId(user.getCorpId());
+                } catch (Exception e) {
+                    System.out.println("标签格式错误,租户41,外部联系人id:" + qwExt.getId());
+                }
+
+                String url = OpenQwConfig.baseApi + "/addTag?tenantId=41";
+                String result = HttpUtil.createPost(url)
+                        .body(JSON.toJSONString(param))
+                        .execute()
+                        .body();
+                System.out.println(result);
+            }
+        } catch (HttpException e) {
+            System.out.println("请求异常:" + e.getMessage());
+        }
+    }
+
+    @Scheduled(cron = "0 0/1 * * * ?")
+    public void sendMsgByQwExtCreateTimeNew() {
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant("sendMsgByQwExtCreateTime", this::sendMsgByQwExtCreateTime);
+        } else {
+            sendMsgByQwExtCreateTime();
+        }
+    }
+
+    @Autowired
+    WxWorkServiceNew wxWorkService;
+
+    @Autowired
+    RedisCacheTenant<Long> redisCacheLong;
+
+    @Autowired
+    private IFastGptChatMsgService fastGptChatMsgService;
+
+    @Autowired
+    IpadSendUtils ipadSendUtils;
+
+    public void sendMsgByQwExtCreateTime() {
+        Long tenantId = RedisTenantContext.getTenantId();
+        if(41L != tenantId) return;
+        QwExternalContact qwExternalContact = new QwExternalContact();
+        qwExternalContact.setTagIds("etotXidAAAcNhR-YuTMgC9B2VxH9QU3g");
+        qwExternalContact.setCreateTime(new Date());
+        List<QwExternalContact> qwExternalContacts = qwExternalContactService.selectQwExternalContactListByCreateTimeNew(qwExternalContact);
+        if(qwExternalContacts.isEmpty()) return;
+        Date now = new Date();
+        Date oneHourAgo = new Date(now.getTime() - 2 * 60 * 1000);
+        List<QwExternalContact> filteredContacts = qwExternalContacts.stream()
+                .filter(contact -> {
+                    if (contact.getCreateTime() == null) {
+                        return false;
+                    }
+                    return contact.getCreateTime().before(oneHourAgo);
+                })
+                .collect(Collectors.toList());
+
+        for (QwExternalContact qwExt : filteredContacts) {
+
+            QwExternalContactAddTagParam param = new QwExternalContactAddTagParam();
+            Long qwUserId = qwExt.getQwUserId();
+            if(qwUserId == null) continue;
+            QwUser user = qwUserService.selectQwUserById(qwUserId);
+            QwCompany qwCompany = qwCompanyService.selectQwCompanyByCorpId(user.getCorpId());
+
+
+            FastGptChatSession fastGptChatSession = fastGptChatSessionMapper.selectFastGptChatSessionByQwExternalContactsAndUserId(qwExt.getId(), qwUserId);
+            if(fastGptChatSession == null){
+                if(qwExt.getType()!=null&&qwExt.getType()==1){
+                    fastGptChatSession = new FastGptChatSession();
+                    String chatId = UUID.randomUUID().toString();
+                    fastGptChatSession.setChatId(chatId);
+                    if(user.getFastGptRoleId() != null){
+                        fastGptChatSession.setKfId(user.getFastGptRoleId().toString());
+                    }
+                    fastGptChatSession.setStatus(1);
+                    fastGptChatSession.setRemindCount(0);
+                    fastGptChatSession.setRemindStatus(0);
+                    fastGptChatSession.setCreateTime(new Date());
+                    fastGptChatSession.setQwExtId(qwExt.getId());
+                    fastGptChatSession.setQwUserId(user.getId());
+                    fastGptChatSession.setIsArtificial(0);
+                    fastGptChatSession.setAvatar(qwExt.getAvatar());
+                    fastGptChatSession.setNickName(qwExt.getName());
+                    fastGptChatSession.setCompanyId(user.getCompanyId());
+                    fastGptChatSession.setLastTime(new Date());
+                    fastGptChatSession.setIsReply(0);
+                    fastGptChatSessionMapper.insertFastGptChatSession(fastGptChatSession);
+                }
+            }
+
+            BaseVo parentVo = new BaseVo();
+            assert fastGptChatSession != null;
+            if(fastGptChatSession.getSessionId() == null){
+                parentVo.setId(user.getId());
+            }else{
+                parentVo.setId(fastGptChatSession.getSessionId());
+            }
+            parentVo.setRoom(false);
+            parentVo.setUuid(user.getUid());
+            parentVo.setAgentId(qwCompany.getServerAgentId());
+            parentVo.setExId(qwExt.getExternalUserId());
+            parentVo.setServerId(user.getServerId());
+            parentVo.setCorpCode(parentVo.getCorpCode());
+            parentVo.setCorpId(parentVo.getCorpId());
+            parentVo.setQwUserId(user.getId());
+
+
+            FileVo voiceVo;
+            CompanyUser companyUser = companyUserMapper.selectCompanyUserById(user.getCompanyUserId());
+            if(companyUser == null) continue;
+            if("0".equals(companyUser.getSex())){
+                voiceVo = FileVo.builder()
+                        .url("https://kyt-1323137866.cos.ap-chongqing.myqcloud.com/test/1.silk")
+                        .voiceTime(36)
+                        .build();
+            }else{
+                voiceVo = FileVo.builder()
+                        .url("https://kyt-1323137866.cos.ap-chongqing.myqcloud.com/test/2.silk")
+                        .voiceTime(32)
+                        .build();
+            }
+
+
+            voiceVo.setBase(parentVo);
+            ipadSendUtils.sendVoice(voiceVo);
+
+            String content = "本次免费领取活动真实有效,明天上午十点您准时来直播间观看并免费领取。明天开播前我会把直播链接发给您,看完直播第一时间找我登记领取,好吧哥!";
+            TxtVo txtVo = TxtVo.builder().content(content).build();
+            txtVo.setBase(parentVo);
+            saveQwUserMsg(fastGptChatSession,1,content,user);
+            ipadSendUtils.sendTxt(txtVo);
+
+            FileVo imgVo = FileVo.builder().url("https://ylrz-1323137866.cos.ap-chongqing.myqcloud.com/ylrz/20260528/eb81adca120a45dcbf7ae31e3daacd74.png").build();
+            imgVo.setBase(parentVo);
+            ipadSendUtils.sendImg(imgVo);
+
+            FileVo imgVo1 = FileVo.builder().url("https://ysy-1329817240.cos.ap-guangzhou.myqcloud.com/ysy/20260527/1ee711bf934642cba3886a5f07ac3cdd.png").build();
+            imgVo1.setBase(parentVo);
+            ipadSendUtils.sendImg(imgVo1);
+
+
+            //添加需要打标签的客户
+            List<Long> list = new ArrayList<>();
+            list.add(qwExt.getId());
+            param.setUserIds(list);
+
+            try {
+                String[] split = new String[]{"etotXidAAAcNhR-YuTMgC9B2VxH9QU3g"};
+                param.setTagIds(Arrays.asList(split));
+                param.setCorpId(user.getCorpId());
+            } catch (Exception e) {
+                System.out.println("标签格式错误,租户41,外部联系人id:" + qwExt.getId());
+            }
+
+            String url = OpenQwConfig.baseApi + "/delTag?tenantId=41";
+            String result = HttpUtil.createPost(url)
+                    .body(JSON.toJSONString(param))
+                    .execute()
+                    .body();
+            System.out.println(result);
+        }
+    }
+
+
+    @Scheduled(cron = "0 10 9 * * ?")
+    public void sendMsgByQwExtCreateTimeTwoDayNew() {
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant("sendMsgByQwExtCreateTimeTwoDay", this::sendMsgByQwExtCreateTimeTwoDay);
+        } else {
+            sendMsgByQwExtCreateTimeTwoDay();
+        }
+    }
+
+    public void sendMsgByQwExtCreateTimeTwoDay() {
+        Long tenantId = RedisTenantContext.getTenantId();
+        if(41L != tenantId) return;
+        // 昨天日期
+        Date yesterdayDate = Date.from(
+                LocalDate.now().minusDays(1)
+                        .atStartOfDay(ZoneId.systemDefault())
+                        .toInstant());
+
+        QwExternalContact qwExternalContact = new QwExternalContact();
+        qwExternalContact.setCreateTime(yesterdayDate);
+        List<QwExternalContact> qwExternalContacts = qwExternalContactService.selectQwExternalContactListByCreateTimeNew(qwExternalContact);
+        if(qwExternalContacts.isEmpty()) return;
+
+
+        for (QwExternalContact qwExt : qwExternalContacts) {
+            Long qwUserId = qwExt.getQwUserId();
+            if(qwUserId == null) continue;
+            QwUser user = qwUserService.selectQwUserById(qwUserId);
+            QwCompany qwCompany = qwCompanyService.selectQwCompanyByCorpId(user.getCorpId());
+            if(qwCompany == null) continue;
+
+
+            BaseVo parentVo = new BaseVo();
+            parentVo.setId(user.getId());
+            parentVo.setRoom(false);
+            parentVo.setUuid(user.getUid());
+            parentVo.setAgentId(qwCompany.getServerAgentId());
+            parentVo.setExId(qwExt.getExternalUserId());
+            parentVo.setServerId(user.getServerId());
+            parentVo.setCorpCode(parentVo.getCorpCode());
+            parentVo.setCorpId(parentVo.getCorpId());
+            parentVo.setQwUserId(user.getId());
+
+
+            FileVo voiceVo;
+            CompanyUser companyUser = companyUserMapper.selectCompanyUserById(user.getCompanyUserId());
+            if(companyUser == null) continue;
+            if("0".equals(companyUser.getSex())){
+                voiceVo = FileVo.builder()
+                        .url("https://kyt-1323137866.cos.ap-chongqing.myqcloud.com/test/13.silk")
+                        .voiceTime(19)
+                        .build();
+            }else{
+                voiceVo = FileVo.builder()
+                        .url("https://kyt-1323137866.cos.ap-chongqing.myqcloud.com/test/12.silk")
+                        .voiceTime(17)
+                        .build();
+            }
+
+            voiceVo.setBase(parentVo);
+            ipadSendUtils.sendVoice(voiceVo);
+        }
+    }
+    /** 存聊天记录  **/
+    private void saveQwUserMsg(FastGptChatSession fastGptChatSession,Integer sendType,String content,QwUser sendUser) {
+        if(content.isEmpty()){
+            return;
+        }
+        FastGptChatMsg fastGptChatMsgAi = new FastGptChatMsg();
+        fastGptChatMsgAi.setContent(content);
+        fastGptChatMsgAi.setSessionId(fastGptChatSession.getSessionId());
+        if(sendUser.getFastGptRoleId() != null){
+            fastGptChatMsgAi.setRoleId(Long.parseLong(fastGptChatSession.getKfId()));
+        }
+        fastGptChatMsgAi.setSendType(sendType);
+        fastGptChatMsgAi.setCompanyId(fastGptChatSession.getCompanyId());
+        fastGptChatMsgAi.setCompanyUserId(sendUser.getCompanyUserId());
+        fastGptChatMsgAi.setUserId(fastGptChatSession.getUserId());
+        fastGptChatMsgAi.setUserType(1);
+        fastGptChatMsgAi.setMsgType(1);
+        fastGptChatMsgAi.setStatus(0);
+        fastGptChatMsgAi.setAvatar(fastGptChatSession.getAvatar());
+        fastGptChatMsgAi.setNickName(fastGptChatSession.getNickName());
+        fastGptChatMsgAi.setCreateTime(new Date());
+        fastGptChatMsgAi.setExtId(fastGptChatSession.getQwExtId()+"");
+        fastGptChatMsgService.insertFastGptChatMsg(fastGptChatMsgAi);
+        log.info("新增消息:"+fastGptChatMsgAi);
+    }
+
+
+
     /**
      * sop任务token消耗统计
      */

+ 0 - 2
fs-admin/src/main/java/com/fs/live/controller/LiveController.java

@@ -11,8 +11,6 @@ import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.company.vo.CompanyVO;
 import com.fs.framework.web.service.TokenService;
-import com.fs.hisStore.task.LiveTask;
-import com.fs.hisStore.task.MallStoreTask;
 import com.fs.live.domain.Live;
 import com.fs.live.service.ILiveService;
 import com.fs.live.vo.LiveListVo;

+ 66 - 38
fs-ai-call-task/src/main/java/com/fs/app/service/CallTaskService.java

@@ -10,8 +10,11 @@ import com.fs.company.param.ExecutionContext;
 import com.fs.company.param.PauseRoboticActiveParam;
 import com.fs.company.service.*;
 import com.fs.company.service.impl.call.node.AiCallTaskNode;
+import com.fs.wxcid.utils.TenantHelper;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.beans.factory.annotation.Value;
@@ -40,53 +43,78 @@ public class CallTaskService {
     @Qualifier("cidWorkFlowExecutor")
     private Executor cidWorkFlowExecutor;
 
+    @Autowired
+    private RedissonClient redissonClient;
+
+    /** 外呼延时扫描分布式锁 key 前缀,按 租户id:分组 隔离,避免多实例/重入重复扫描同批延时key */
+    private static final String CALL_DELAY_LOCK_PREFIX = "cid_workflow:call_delay_lock:";
+
 
     /**
      * 扫描工作流延时任务
      */
     public void cidWorkflowCallRun() {
-        log.info("===========工作流延时任务开始扫描===========");
-        String delayCallKeyPrefix = AiCallTaskNode.getDelayCallKeyPrefix(cidGroupNo,null) + "*";
-        Collection<String> keys = redisCache2.keys(delayCallKeyPrefix);
-        log.info("共扫描到 {} 个待处理键", keys.size());
-        // 本地缓存已查询的任务暂停状态,避免同一批次重复查询
-        Map<Long, Boolean> pausedCache = new ConcurrentHashMap<>();
-        keys.parallelStream().forEach(key -> {
-            try {
-                //doExec
-                CompletableFuture.runAsync(() -> {
-                    try {
-                        ExecutionContext context = redisCache2.getCacheObject(key);
-                        if (context == null) {
-                            log.warn("工作流延时任务context为空,跳过 - key: {}", key);
-                            redisCache2.deleteObject(key);
-                            return;
-                        }
-                        // 任务暂停守卫检查(roboticId即CompanyVoiceRobotic.id,是实际暂停操作的目标)
-                        Long taskId = context.getVariable("roboticId", Long.class);
-                        if (taskId != null && pausedCache.computeIfAbsent(taskId, id -> companyVoiceRoboticService.isTaskPaused(id))) {
-                            // 延时key是时间分片前缀,下一分钟就不会再扫到,直接删除
-                            // 同步context信息到DB exec,供恢复时resumePausedInstances使用
+        // 分布式锁:按 租户id:分组 隔离,拿不到锁说明已有实例在扫描,直接跳过本轮(延时key下轮仍可扫到,不丢失)
+        String lockKey = CALL_DELAY_LOCK_PREFIX + TenantHelper.getTenantId() + ":" + cidGroupNo;
+        RLock lock = redissonClient.getLock(lockKey);
+        boolean locked = false;
+        try {
+            // waitTime=0 拿不到立即返回;leaseTime=-1 启用看门狗自动续期,避免大数据量执行超时被提前释放
+            locked = lock.tryLock(0, -1, TimeUnit.SECONDS);
+            if (!locked) {
+                log.info("cidWorkflowCallRun 已有实例在执行,跳过本轮 - lockKey: {}", lockKey);
+                return;
+            }
+            log.info("===========工作流延时任务开始扫描===========");
+            String delayCallKeyPrefix = AiCallTaskNode.getDelayCallKeyPrefix(cidGroupNo,null) + "*";
+            Collection<String> keys = redisCache2.keys(delayCallKeyPrefix);
+            log.info("共扫描到 {} 个待处理键", keys.size());
+            // 本地缓存已查询的任务暂停状态,避免同一批次重复查询
+            Map<Long, Boolean> pausedCache = new ConcurrentHashMap<>();
+            keys.parallelStream().forEach(key -> {
+                try {
+                    //doExec
+                    CompletableFuture.runAsync(() -> {
+                        try {
+                            ExecutionContext context = redisCache2.getCacheObject(key);
+                            if (context == null) {
+                                log.warn("工作流延时任务context为空,跳过 - key: {}", key);
+                                redisCache2.deleteObject(key);
+                                return;
+                            }
+                            // 任务暂停守卫检查(roboticId即CompanyVoiceRobotic.id,是实际暂停操作的目标)
+                            Long taskId = context.getVariable("roboticId", Long.class);
+                            if (taskId != null && pausedCache.computeIfAbsent(taskId, id -> companyVoiceRoboticService.isTaskPaused(id))) {
+                                // 延时key是时间分片前缀,下一分钟就不会再扫到,直接删除
+                                // 同步context信息到DB exec,供恢复时resumePausedInstances使用
+                                context.setVariable("callSource", "callTaskTimer");
+                                context.setVariable("_delayTargetNodeKey", context.getCurrentNodeKey());
+                                companyWorkflowEngine.updateExecVariables(context.getWorkflowInstanceId(), context.getVariables());
+                                log.info("任务已暂停,删除延时key并同步exec,等待恢复时从DB重建 - taskId: {}, key: {}", taskId, key);
+                                redisCache2.deleteObject(key);
+                                return;
+                            }
+                            context.setVariable("callRedisKey", key);
                             context.setVariable("callSource", "callTaskTimer");
-                            context.setVariable("_delayTargetNodeKey", context.getCurrentNodeKey());
-                            companyWorkflowEngine.updateExecVariables(context.getWorkflowInstanceId(), context.getVariables());
-                            log.info("任务已暂停,删除延时key并同步exec,等待恢复时从DB重建 - taskId: {}, key: {}", taskId, key);
+                            companyWorkflowEngine.timeDoExecute(context.getWorkflowInstanceId(), context.getCurrentNodeKey(), context.getVariables());
                             redisCache2.deleteObject(key);
-                            return;
+                        } catch (Exception e) {
+                            log.error("处理工作流延时任务异常 - key: {}", key, e);
                         }
-                        context.setVariable("callRedisKey", key);
-                        context.setVariable("callSource", "callTaskTimer");
-                        companyWorkflowEngine.timeDoExecute(context.getWorkflowInstanceId(), context.getCurrentNodeKey(), context.getVariables());
-                        redisCache2.deleteObject(key);
-                    } catch (Exception e) {
-                        log.error("处理工作流延时任务异常 - key: {}", key, e);
-                    }
-                }, cidWorkFlowExecutor);
-            } catch (Exception ex) {
-                log.error("处理工作流延时任务异常 - key: {}", key, ex);
+                    }, cidWorkFlowExecutor);
+                } catch (Exception ex) {
+                    log.error("处理工作流延时任务异常 - key: {}", key, ex);
+                }
+            });
+            log.info("===========工作流延时任务扫描结束===========");
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.warn("cidWorkflowCallRun 获取分布式锁被中断 - lockKey: {}", lockKey, e);
+        } finally {
+            if (locked && lock.isHeldByCurrentThread()) {
+                lock.unlock();
             }
-        });
-        log.info("===========工作流延时任务扫描结束===========");
+        }
     }
 
     /**

+ 104 - 19
fs-cid-workflow/src/main/java/com/fs/app/service/CidWorkflowTaskService.java

@@ -15,6 +15,8 @@ import com.fs.enums.NodeTypeEnum;
 import com.fs.wxcid.utils.TenantHelper;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.beans.factory.annotation.Value;
@@ -29,6 +31,11 @@ public class CidWorkflowTaskService {
 
     @Value("${cid-group-no:0}")
     Integer cidGroupNo;
+
+    /** 认领态(RUNNING)超时分钟数,超过该时间未流转的任务视为卡死并回扫重置为失败 */
+    @Value("${cid-workflow.running-timeout-minutes:30}")
+    Integer runningTimeoutMinutes;
+
     private final CompanyAiWorkflowExecMapper companyAiWorkflowExecMapper;
     private final CompanyWorkflowEngine companyWorkflowEngine;
     private final ICompanyVoiceRoboticService companyVoiceRoboticService;
@@ -43,18 +50,36 @@ public class CidWorkflowTaskService {
     @Autowired
     private WorkflowNodeFactory nodeFactory;
 
+    @Autowired
+    private RedissonClient redissonClient;
+
+    /** 扫描分布式锁 key 前缀,按 租户id:分组 隔离,保证同一批数据同一时刻仅一个实例扫描 */
+    private static final String SCAN_LOCK_PREFIX = "cid_workflow:scan_lock:";
+
+    /** 激活可执行任务的分布式锁 key 前缀,与扫描就绪任务的锁独立,避免两个定时任务互相阻塞 */
+    private static final String ACTIVATE_LOCK_PREFIX = "cid_workflow:activate_lock:";
+
     /**
      * 扫描当前分组下就绪任务,并开启执行
      */
     public void runCidWorkflow() {
-        List<CompanyAiWorkflowExec> companyAiWorkflowExecs = companyAiWorkflowExecMapper.selectExecListWithTimeAvailableByStatusAndGroupNo(ExecutionStatusEnum.READY.getValue(), cidGroupNo);
-        System.out.println(companyAiWorkflowExecs);
-        log.info("runCidWorkflow得到租户id:{}",TenantHelper.getTenantId());
-        if (null != companyAiWorkflowExecs && companyAiWorkflowExecs.size() > 0) {
-            // 本地缓存已查询的任务暂停状态,避免同一批次重复查询
-            Map<Long, Boolean> pausedCache = new HashMap<>();
-            companyAiWorkflowExecs.forEach(exec -> {
-//                cidWorkFlowExecutor.execute(() -> {
+        // 分布式锁:按 租户id:分组 隔离,拿不到锁说明同批数据已有实例在扫描,直接跳过本轮(周期任务下轮会再扫,不积压)
+        String lockKey = SCAN_LOCK_PREFIX + TenantHelper.getTenantId() + ":" + cidGroupNo;
+        RLock lock = redissonClient.getLock(lockKey);
+        boolean locked = false;
+        try {
+            // waitTime=0 拿不到立即返回;leaseTime=-1 启用看门狗自动续期,避免大数据量执行超时被提前释放
+            locked = lock.tryLock(0, -1, TimeUnit.SECONDS);
+            if (!locked) {
+                log.info("runCidWorkflow 已有实例在执行,跳过本轮 - lockKey: {}", lockKey);
+                return;
+            }
+            List<CompanyAiWorkflowExec> companyAiWorkflowExecs = companyAiWorkflowExecMapper.selectExecListWithTimeAvailableByStatusAndGroupNo(ExecutionStatusEnum.READY.getValue(), cidGroupNo);
+            log.info("runCidWorkflow得到租户id:{}",TenantHelper.getTenantId());
+            if (null != companyAiWorkflowExecs && companyAiWorkflowExecs.size() > 0) {
+                // 本地缓存已查询的任务暂停状态,避免同一批次重复查询
+                Map<Long, Boolean> pausedCache = new HashMap<>();
+                companyAiWorkflowExecs.forEach(exec -> {
                     try {
                         // 任务暂停守卫检查(从 variables JSON 中提取 roboticId,即 CompanyVoiceRobotic.id)
                         Long taskId = extractRoboticIdFromExec(exec);
@@ -65,12 +90,29 @@ public class CidWorkflowTaskService {
                                 return;
                             }
                         }
+                        // 原子认领:READY -> RUNNING,仅认领成功(影响行数=1)才提交执行,
+                        // 防止上一轮异步任务尚未完成时下一次定时扫描重复调度同一条记录
+                        int claimed = companyAiWorkflowExecMapper.claimExecForRun(
+                                exec.getId(),
+                                ExecutionStatusEnum.READY.getValue(),
+                                ExecutionStatusEnum.RUNNING.getValue());
+                        if (claimed == 0) {
+                            log.debug("任务已被认领,跳过重复执行 - execId: {}", exec.getId());
+                            return;
+                        }
                         companyWorkflowEngine.executeNode(exec.getWorkflowInstanceId(), exec.getCurrentNodeKey());
                     } catch (Exception e) {
                         log.error("处理就绪任务异常 - exec: {}", exec, e);
                     }
-//                });
-            });
+                });
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.warn("runCidWorkflow 获取分布式锁被中断 - lockKey: {}", lockKey, e);
+        } finally {
+            if (locked && lock.isHeldByCurrentThread()) {
+                lock.unlock();
+            }
         }
     }
 
@@ -78,13 +120,23 @@ public class CidWorkflowTaskService {
      * 扫描可执行任务,并激活执行
      */
     public void activateTimeAvailableTask() {
-        List<CompanyAiWorkflowExec> companyAiWorkflowExecs = companyAiWorkflowExecMapper.selectExecListWithTimeAvailableByStatusAndGroupNo(ExecutionStatusEnum.PENDING.getValue(), cidGroupNo);
-        log.info("activateTimeAvailableTask得到租户id:{}",TenantHelper.getTenantId());
-        if (null != companyAiWorkflowExecs && companyAiWorkflowExecs.size() > 0) {
-            // 本地缓存已查询的任务暂停状态,避免同一批次重复查询
-            Map<Long, Boolean> pausedCache = new HashMap<>();
-            companyAiWorkflowExecs.forEach(exec -> {
-//                cidWorkFlowExecutor.execute(() -> {
+        // 分布式锁:与 runCidWorkflow 锁独立,按 租户id:分组 隔离,拿不到锁说明已有实例在激活,直接跳过本轮
+        String lockKey = ACTIVATE_LOCK_PREFIX + TenantHelper.getTenantId() + ":" + cidGroupNo;
+        RLock lock = redissonClient.getLock(lockKey);
+        boolean locked = false;
+        try {
+            // waitTime=0 拿不到立即返回;leaseTime=-1 启用看门狗自动续期,避免大数据量执行超时被提前释放
+            locked = lock.tryLock(0, -1, TimeUnit.SECONDS);
+            if (!locked) {
+                log.info("activateTimeAvailableTask 已有实例在执行,跳过本轮 - lockKey: {}", lockKey);
+                return;
+            }
+            List<CompanyAiWorkflowExec> companyAiWorkflowExecs = companyAiWorkflowExecMapper.selectExecListWithTimeAvailableByStatusAndGroupNo(ExecutionStatusEnum.PENDING.getValue(), cidGroupNo);
+            log.info("activateTimeAvailableTask得到租户id:{}",TenantHelper.getTenantId());
+            if (null != companyAiWorkflowExecs && companyAiWorkflowExecs.size() > 0) {
+                // 本地缓存已查询的任务暂停状态,避免同一批次重复查询
+                Map<Long, Boolean> pausedCache = new HashMap<>();
+                companyAiWorkflowExecs.forEach(exec -> {
                     try {
                         // 任务暂停守卫检查(从 variables JSON 中提取 roboticId,即 CompanyVoiceRobotic.id)
                         Long taskId = extractRoboticIdFromExec(exec);
@@ -95,12 +147,45 @@ public class CidWorkflowTaskService {
                                 return;
                             }
                         }
+                        // 原子认领:PENDING -> RUNNING,仅认领成功(影响行数=1)才提交执行,
+                        // 防止上一轮异步任务尚未完成时下一次定时扫描重复激活同一条记录
+                        int claimed = companyAiWorkflowExecMapper.claimExecForRun(
+                                exec.getId(),
+                                ExecutionStatusEnum.PENDING.getValue(),
+                                ExecutionStatusEnum.RUNNING.getValue());
+                        if (claimed == 0) {
+                            log.debug("任务已被认领,跳过重复执行 - execId: {}", exec.getId());
+                            return;
+                        }
                         companyWorkflowEngine.executeNode(exec.getWorkflowInstanceId(), exec.getCurrentNodeKey());
                     } catch (Exception e) {
                         log.error("处理就绪任务异常 - exec: {}", exec, e);
                     }
-//                });
-            });
+                });
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.warn("activateTimeAvailableTask 获取分布式锁被中断 - lockKey: {}", lockKey, e);
+        } finally {
+            if (locked && lock.isHeldByCurrentThread()) {
+                lock.unlock();
+            }
+        }
+    }
+
+    /**
+     * 回扫超时认领任务:将处于 RUNNING 态且超过 runningTimeoutMinutes 分钟未更新的记录重置为 FAILURE。
+     * 用于兜底进程重启 / 线程池拒绝导致任务永久卡在认领态(RUNNING)无法被再次扫描的情况。
+     */
+    public void resetTimeoutRunningTask() {
+        java.time.LocalDateTime timeoutTime = java.time.LocalDateTime.now().minusMinutes(runningTimeoutMinutes);
+        int reset = companyAiWorkflowExecMapper.resetTimeoutRunningExec(
+                cidGroupNo,
+                ExecutionStatusEnum.RUNNING.getValue(),
+                ExecutionStatusEnum.FAILURE.getValue(),
+                timeoutTime);
+        if (reset > 0) {
+            log.warn("回扫超时认领任务,重置为失败状态 - groupNo: {}, count: {}, timeoutMinutes: {}", cidGroupNo, reset, runningTimeoutMinutes);
         }
     }
 

+ 13 - 0
fs-cid-workflow/src/main/java/com/fs/app/task/CidTask.java

@@ -59,6 +59,19 @@ public class CidTask {
 
     }
 
+    /**
+     * 回扫超时认领任务 - 每5分钟执行一次
+     * 将卡在认领态(RUNNING)且长时间未流转的任务重置为失败,兜底进程重启/线程池拒绝导致的永久卡死
+     */
+    @Scheduled(cron = "0 0/5 * * * ?")
+    public void resetTimeoutRunningTask() {
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForResponsibleTenant("resetTimeoutRunningTask", () -> cidWorkflowTaskService.resetTimeoutRunningTask());
+        } else {
+            cidWorkflowTaskService.resetTimeoutRunningTask();
+        }
+    }
+
     /**
      * 外呼重试任务 - 每30分钟执行一次
      * 扫描 Redis 中被外呼限制拦截的待重试呼叫,到达 nextAvailableTime 后重新执行

+ 1 - 1
fs-cid-workflow/src/main/resources/application.yml

@@ -5,7 +5,7 @@ server:
 # Spring配置
 spring:
   profiles:
-    active: dev
+    active: dev-test
 #    active: druid-hcl
 #    active: druid-sxjz
 #    active: druid-hdt

+ 3 - 1
fs-company/src/main/java/com/fs/company/controller/common/CaptchaController.java

@@ -50,8 +50,10 @@ public class CaptchaController
     public AjaxResult getCode(HttpServletResponse response) throws IOException
     {
         AjaxResult ajax = AjaxResult.success();
-        boolean captchaOnOff = configService.selectCaptchaOnOff();
+//        boolean captchaOnOff = configService.selectCaptchaOnOff();
+        boolean captchaOnOff = true;
         ajax.put("captchaOnOff", captchaOnOff);
+//        captchaOnOff = true;
         if (!captchaOnOff)
         {
             return ajax;

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

@@ -12,6 +12,7 @@ import com.fs.company.domain.CompanyVoiceRoboticCallLogCallphone;
 import com.fs.company.service.ICompanyVoiceRoboticCallLogCallphoneService;
 import com.fs.crm.domain.CrmCustomer;
 import com.fs.crm.service.ICrmCustomerService;
+import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
 import com.fs.framework.security.LoginUser;
@@ -85,6 +86,10 @@ public class CompanyVoiceRoboticCallLogCallphoneController extends BaseControlle
     public TableDataInfo groupList(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone)
     {
         startPage();
+        if(null == companyVoiceRoboticCallLogCallphone.getCompanyId()){
+            LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+            companyVoiceRoboticCallLogCallphone.setCompanyId(loginUser.getUser().getCompanyId());
+        }
         List<CompanyVoiceRoboticCallLogCallphone> list = companyVoiceRoboticCallLogCallphoneService.selectCompanyVoiceRoboticCallPhoneLogGroupList(companyVoiceRoboticCallLogCallphone);
         return getDataTable(list);
     }
@@ -96,7 +101,9 @@ public class CompanyVoiceRoboticCallLogCallphoneController extends BaseControlle
     @GetMapping("/count")
     public AjaxResult selectCompanyVoiceRoboticCallPhoneLogCount()
     {
-        CompanyVoiceRoboticCallLogCount companyVoiceRoboticCallLogCount = companyVoiceRoboticCallLogCallphoneService.selectCompanyVoiceRoboticCallPhoneLogCount();
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getUser().getCompanyId();
+        CompanyVoiceRoboticCallLogCount companyVoiceRoboticCallLogCount = companyVoiceRoboticCallLogCallphoneService.selectCompanyVoiceRoboticCallPhoneLogCount(companyId);
         return AjaxResult.success(companyVoiceRoboticCallLogCount);
     }
 
@@ -166,6 +173,33 @@ public class CompanyVoiceRoboticCallLogCallphoneController extends BaseControlle
         return util.exportExcel(list, "调用日志_ai打电话数据");
     }
 
+    /**
+     * 导出详情外呼记录(任务名称、客户名称、解密手机号)
+     */
+    @PreAuthorize("@ss.hasPermi('company:callphonelog:exportPhone')")
+    @Log(title = "外呼记录详情手机号导出", businessType = BusinessType.EXPORT)
+    @GetMapping("/exportDetailPhone")
+    public AjaxResult exportDetailPhone(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone)
+    {
+        List<CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO> list =
+                companyVoiceRoboticCallLogCallphoneService.listDecryptPhoneExport(companyVoiceRoboticCallLogCallphone);
+        ExcelUtil<CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO> util =
+                new ExcelUtil<>(CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO.class);
+        return util.exportExcel(list, "外呼记录详情手机号");
+    }
+
+    /**
+     * 查看外呼记录解密手机号(无CRM客户时按记录解密)
+     */
+    @PreAuthorize("@ss.hasPermi('crm:customer:queryPhone')")
+    @Log(title = "查看外呼记录手机号", businessType = BusinessType.GRANT)
+    @GetMapping("/queryPhone/{logId}")
+    public AjaxResult queryCallLogPhone(@PathVariable("logId") Long logId)
+    {
+        String mobile = companyVoiceRoboticCallLogCallphoneService.getDecryptPhoneByLogId(logId);
+        return AjaxResult.success().put("mobile", mobile);
+    }
+
 //    /**
 //     * 导出调用日志_ai打电话列表
 //     */

+ 27 - 3
fs-company/src/main/java/com/fs/company/controller/crm/CrmCustomerController.java

@@ -288,10 +288,12 @@ public class CrmCustomerController extends BaseController
     ){
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         CrmCustomer customer=crmCustomerService.selectCrmCustomerById(customerId);
-        customer.setMobile(customer.getMobile().replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2"));
         Boolean isReceive=false;
-        if(customer.getIsReceive()!=null&&customer.getIsReceive()==1&&customer.getReceiveUserId()!=null&&loginUser.getUser().getUserId().equals(customer.getReceiveUserId())){
-            isReceive=true;
+        if(customer !=null){
+            customer.setMobile(customer.getMobile().replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2"));
+            if(customer.getIsReceive()!=null&&customer.getIsReceive()==1&&customer.getReceiveUserId()!=null&&loginUser.getUser().getUserId().equals(customer.getReceiveUserId())){
+                isReceive=true;
+            }
         }
         return R.ok().put("customer",customer).put("isReceive",isReceive);
 
@@ -320,6 +322,28 @@ public class CrmCustomerController extends BaseController
 
     }
 
+    //条件分配-统计数量
+    @PreAuthorize("@ss.hasPermi('crm:customer:assignToUser')")
+    @PostMapping("/countByCondition")
+    public R countByCondition(@RequestBody CrmCustomerConditionAssignParam param)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.getQueryParams().put("companyId", loginUser.getCompany().getCompanyId());
+        int count = crmCustomerService.countByCondition(param.getQueryType(), param.getQueryParams());
+        return R.ok().put("count", count);
+    }
+
+    //条件分配-执行分配
+    @PreAuthorize("@ss.hasPermi('crm:customer:assignToUser')")
+    @PostMapping("/assignByCondition")
+    public R assignByCondition(@RequestBody CrmCustomerConditionAssignParam param)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        param.setCompanyId(loginUser.getCompany().getCompanyId());
+        param.getQueryParams().put("companyId", loginUser.getCompany().getCompanyId());
+        return crmCustomerService.assignByCondition(loginUser.getUsername(), loginUser.getUser().getUserId(), param);
+    }
+
 
     @PreAuthorize("@ss.hasPermi('crm:customer:add')")
     @Log(title = "创建客户", businessType = BusinessType.INSERT)

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

@@ -128,7 +128,8 @@ public class QwGroupChatController extends BaseController
         String url = OpenQwConfig.api + "/qw/groupChat/cogradientGroupChat/" + corpId + "?tenantId=" + tenantId;
         try {
             HttpResponse response = HttpRequest.get(url)
-                    .timeout(apiTimeout * 1000)
+                    .timeout(1200 * 1000)
+                    .setReadTimeout(1200 * 1000)
                     .execute();
             if (response.getStatus() == 200) {
                 return JSON.parseObject(response.body(), R.class);
@@ -157,7 +158,8 @@ public class QwGroupChatController extends BaseController
                 + "&companyUserId=" + loginUser.getUser().getUserId();
         try {
             HttpResponse response = HttpRequest.get(url)
-                    .timeout(apiTimeout * 1000)
+                    .timeout(1200 * 1000)
+                    .setReadTimeout(1200 * 1000)
                     .execute();
             if (response.getStatus() == 200) {
                 return JSON.parseObject(response.body(), R.class);

+ 1 - 11
fs-company/src/main/resources/application.yml

@@ -3,14 +3,4 @@ server:
 # Spring配置
 spring:
   profiles:
-    active: dev
-#    active: druid-jnsyj-test
-#    active: druid-jnmy-test
-#    active: druid-jzzx-test
-#    active: druid-hdt
-#    active: druid-bjzm-test
-#    active: druid-yzt
-#    active: druid-myhk
-#    active: druid-sft
-#    active: dev-jnlzjk
-#    active: dev-yjb
+    active: dev-test

+ 2 - 6
fs-qw-api/src/main/java/com/fs/app/controller/OpenQwApiController.java

@@ -115,9 +115,7 @@ public class OpenQwApiController extends BaseController {
         try {
             log.info("[QwFriendWelcome] 添加标签,tenantId={}", tenantId);
             // 切换到指定租户数据源执行操作(TenantDataSourceUtil 会自动设置 Redis 租户上下文)
-            return tenantDataSourceUtil.executeWithResult(tenantId, () -> {
-                return openQwApiService.addTag(param);
-            });
+            return tenantDataSourceUtil.executeWithResult(tenantId, () -> openQwApiService.addTag(param, tenantId));
         } catch (IllegalArgumentException e) {
             log.error("[QwFriendWelcome] 添加标签失败,租户不存在或已禁用,tenantId={}", tenantId, e);
             return R.error("租户不存在或已禁用");
@@ -136,9 +134,7 @@ public class OpenQwApiController extends BaseController {
         try {
             log.info("[QwFriendWelcome] 移除标签,tenantId={}", tenantId);
             // 切换到指定租户数据源执行操作(TenantDataSourceUtil 会自动设置 Redis 租户上下文)
-            return tenantDataSourceUtil.executeWithResult(tenantId, () -> {
-                return openQwApiService.delTag(param);
-            });
+            return tenantDataSourceUtil.executeWithResult(tenantId, () -> openQwApiService.delTag(param, tenantId));
         } catch (IllegalArgumentException e) {
             log.error("[QwFriendWelcome] 移除标签失败,租户不存在或已禁用,tenantId={}", tenantId, e);
             return R.error("租户不存在或已禁用");

+ 2 - 2
fs-qw-api/src/main/java/com/fs/app/service/OpenQwApiService.java

@@ -16,9 +16,9 @@ public interface OpenQwApiService {
 
     int edit(QwExternalContact qwExternalContact);
 
-    R addTag(QwExternalContactAddTagParam param);
+    R addTag(QwExternalContactAddTagParam param, Long tenantId);
 
-    R delTag(QwExternalContactAddTagParam param);
+    R delTag(QwExternalContactAddTagParam param, Long tenantId);
 
     R getOpenExternalUserid(String externalUserid,String corpId,String qwUserId);
 }

+ 8 - 6
fs-qw-api/src/main/java/com/fs/app/service/impl/OpenQwApiServiceImpl.java

@@ -531,7 +531,7 @@ public class OpenQwApiServiceImpl implements OpenQwApiService {
     }
 
     @Override
-    public R addTag(QwExternalContactAddTagParam param) {
+    public R addTag(QwExternalContactAddTagParam param, Long tenantId) {
         // 获取当前日期和时间
         LocalDate currentDate = LocalDate.now();
         LocalTime localTime = LocalTime.now();
@@ -596,9 +596,11 @@ public class OpenQwApiServiceImpl implements OpenQwApiService {
                             qwExternal.setTagIds(JSON.toJSONString(uniqueIds));
                             qwExternal.setId(qwExternalContact.getId());
 
-                            List<String> tagIdsList = new ArrayList<>();
+                            List<String> tagIdsList;
                             if (qwExternal.getTagIds() != null && !qwExternal.getTagIds().isEmpty()) {
                                 tagIdsList = JSON.parseArray(qwExternal.getTagIds(), String.class);
+                            } else {
+                                tagIdsList = new ArrayList<>();
                             }
 
                             log.info("客户添加标签addUserTag:" + qwExternalContact.getName() +
@@ -607,8 +609,8 @@ public class OpenQwApiServiceImpl implements OpenQwApiService {
                                     "|总标签" + tagIdsList);
 
                             // 插件sop处理
-                            processTagsAll(qwExternalContact, qwExternalContact.getCorpId(),
-                                    tagIdsList, currentDate, localTime);
+                            tenantDataSourceUtil.execute(tenantId, () -> processTagsAll(qwExternalContact, qwExternalContact.getCorpId(),
+                                    tagIdsList, currentDate, localTime));
 
                             // 添加到批量更新列表
                             batchUpdateList.add(qwExternal);
@@ -676,7 +678,7 @@ public class OpenQwApiServiceImpl implements OpenQwApiService {
     }
 
     @Override
-    public R delTag(QwExternalContactAddTagParam param) {
+    public R delTag(QwExternalContactAddTagParam param, Long tenantId) {
         // 获取当前日期和时间
         LocalDate currentDate = LocalDate.now();
         LocalTime localTime = LocalTime.now();
@@ -737,7 +739,7 @@ public class OpenQwApiServiceImpl implements OpenQwApiService {
                                 log.info("客户移除标签delUserTag:"+qwExternalContact.getName()+"|公司"+qwExternalContact.getCorpId()+"|员工"+qwExternalContact.getUserId()+"|总标签"+ids);
 
                                 //检查sop
-                                processTagsAll(qwExternalContact,param.getCorpId(),ids,currentDate,localTime);
+                                tenantDataSourceUtil.execute(tenantId, () -> processTagsAll(qwExternalContact,param.getCorpId(),ids,currentDate,localTime));
 
                                 // 添加到批量更新列表
                                 batchUpdateList.add(qwExternal);

+ 42 - 9
fs-service/src/main/java/com/fs/company/config/AsyncCalleeConfig.java

@@ -6,6 +6,8 @@ import com.fs.common.utils.spring.SpringUtils;
 import com.fs.core.config.TenantConfigContext;
 import com.fs.wxcid.utils.TenantHelper;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.BeansException;
+import org.springframework.beans.factory.config.BeanPostProcessor;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.core.task.TaskDecorator;
@@ -39,6 +41,20 @@ public class AsyncCalleeConfig {
         return executor;
     }
 
+    @Bean
+    public BeanPostProcessor threadPoolTaskExecutorPostProcessor() {
+        return new BeanPostProcessor() {
+            @Override
+            public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
+                if ("threadPoolTaskExecutor".equals(beanName) && bean instanceof ThreadPoolTaskExecutor) {
+                    ((ThreadPoolTaskExecutor) bean).setTaskDecorator(new TenantContextTaskDecorator());
+                    log.info("已为 threadPoolTaskExecutor 设置 TenantContextTaskDecorator");
+                }
+                return bean;
+            }
+        };
+    }
+
     /**
      * 租户上下文任务装饰器
      * 在任务执行前捕获当前线程的租户ID,在子线程中恢复
@@ -46,25 +62,27 @@ public class AsyncCalleeConfig {
     public static class TenantContextTaskDecorator implements TaskDecorator {
         @Override
         public Runnable decorate(Runnable runnable) {
-            // 捕获当前线程的租户ID
-            Long tenantId = TenantHelper.getTenantId();
+            Long tenantId = resolveTenantId();
+            log.debug("TenantContextTaskDecorator 捕获租户ID: {}", tenantId);
             return () -> {
+                if (tenantId == null) {
+                    log.warn("TenantContextTaskDecorator 租户ID为空, 跳过上下文设置");
+                    runnable.run();
+                    return;
+                }
                 try {
-                    // 在子线程中设置租户ID
                     TenantHelper.setTenantId(tenantId);
                     Object manager = SpringUtils.getBean("tenantDataSourceManager");
                     Method method = manager.getClass().getMethod("ensureSwitchByTenantId", Long.class);
-                    method.invoke(manager, TenantHelper.getTenantId());
-                    // 设置租户到 SecurityContext,供 TenantKeyRedisSerializer 自动为 Redis Key 加 tenantid 前缀
+                    method.invoke(manager, tenantId);
                     SecurityContextHolder.getContext().setAuthentication(
                             new UsernamePasswordAuthenticationToken(
-                                    new TenantPrincipal(TenantHelper.getTenantId()),
+                                    new TenantPrincipal(tenantId),
                                     null,
                                     Collections.emptyList()
                             )
                     );
-                    // 切换 Redis 租户上下文
-                    RedisTenantContext.setTenantId(TenantHelper.getTenantId());
+                    RedisTenantContext.setTenantId(tenantId);
                     runnable.run();
                 } catch (InvocationTargetException e) {
                     throw new RuntimeException(e);
@@ -74,7 +92,6 @@ public class AsyncCalleeConfig {
                     throw new RuntimeException(e);
                 } finally {
                     try {
-                        // 清理子线程的租户ID
                         TenantHelper.removeTenantId();
                         TenantConfigContext.clear();
                         SecurityContextHolder.clearContext();
@@ -88,5 +105,21 @@ public class AsyncCalleeConfig {
                 }
             };
         }
+
+        private Long resolveTenantId() {
+            Long tenantId = TenantHelper.getTenantId();
+            if (tenantId != null) {
+                return tenantId;
+            }
+            try {
+                tenantId = com.fs.common.utils.SecurityUtils.getTenantId();
+                if (tenantId != null) {
+                    return tenantId;
+                }
+            } catch (Exception ignored) {
+            }
+            tenantId = RedisTenantContext.getTenantId();
+            return tenantId;
+        }
     }
 }

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

@@ -159,6 +159,14 @@ public class CompanyVoiceRoboticCallLogCallphone extends BaseEntity{
     @TableField(exist = false)
     private Long maxCallTime;
 
+    /** 详情筛选-手机号(明文) */
+    @TableField(exist = false)
+    private String phone;
+
+    /** 详情筛选-加密手机号(匹配 callees.phone) */
+    @TableField(exist = false)
+    private String encryptedPhone;
+
     @TableField(exist = false)
     private String roboticName;
 

+ 26 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanyAiWorkflowExecMapper.java

@@ -98,6 +98,32 @@ public interface CompanyAiWorkflowExecMapper extends BaseMapper<CompanyAiWorkflo
 
     CompanyAiWorkflowExec selectExecWithTimeAvailableByInstanceId(@Param("workflowInstanceId") String workflowInstanceId);
 
+    /**
+     * 原子认领待执行任务:仅当当前状态等于 expectStatus 时,才将状态更新为 targetStatus。
+     * 用于防止定时扫描在上一轮任务尚未完成时重复调度同一条记录。
+     * @param id 执行记录主键
+     * @param expectStatus 期望的原状态(如 READY=9)
+     * @param targetStatus 认领后的目标状态(如 RUNNING=3)
+     * @return 影响行数,1 表示认领成功,0 表示已被其它线程/上一轮认领
+     */
+    int claimExecForRun(@Param("id") Long id,
+                        @Param("expectStatus") Integer expectStatus,
+                        @Param("targetStatus") Integer targetStatus);
+
+    /**
+     * 回扫超时认领任务:将处于 runningStatus 且 last_update_time 早于 timeoutTime 的记录重置为 failStatus,
+     * 防止进程重启 / 线程池拒绝导致任务永久卡在认领态。
+     * @param groupNo cid 分组号
+     * @param runningStatus 认领态(如 RUNNING=3)
+     * @param failStatus 重置目标态(如 FAILURE=2)
+     * @param timeoutTime 超时时间界限,last_update_time 早于该时间的视为卡死
+     * @return 影响行数
+     */
+    int resetTimeoutRunningExec(@Param("groupNo") Integer groupNo,
+                                @Param("runningStatus") Integer runningStatus,
+                                @Param("failStatus") Integer failStatus,
+                                @Param("timeoutTime") java.time.LocalDateTime timeoutTime);
+
     /**
      * 批量新增数据
      * @param list

+ 4 - 1
fs-service/src/main/java/com/fs/company/mapper/CompanyVoiceRoboticCallLogCallphoneMapper.java

@@ -3,6 +3,7 @@ package com.fs.company.mapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.fs.company.domain.CompanyVoiceRoboticCallLogCallphone;
 import com.fs.company.domain.CompanyVoiceRoboticCallees;
+import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneDecryptQueryVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
 import com.fs.crm.vo.CustomerCallStatVO;
@@ -84,11 +85,13 @@ public interface CompanyVoiceRoboticCallLogCallphoneMapper extends BaseMapper<Co
 
     List<CompanyVoiceRoboticCallLogCallphone> selectCompanyVoiceRoboticCallPhoneLogGroupList(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
 
-    CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallPhoneLogCount();
+    CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallPhoneLogCount(@Param("companyId") Long companyId);
 
 
     List<CompanyVoiceRoboticCallLogCallPhoneVO> listByRoboticId(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
 
+    List<CompanyVoiceRoboticCallLogCallPhoneDecryptQueryVO> listDecryptPhoneExport(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
+
     /**
      * 根据业务ID查询公司ID
      * @param businessId 业务ID (bes.id)

+ 9 - 1
fs-service/src/main/java/com/fs/company/service/ICompanyVoiceRoboticCallLogCallphoneService.java

@@ -2,6 +2,7 @@ package com.fs.company.service;
 
 import com.baomidou.mybatisplus.extension.service.IService;
 import com.fs.company.domain.CompanyVoiceRoboticCallLogCallphone;
+import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
 
@@ -79,10 +80,12 @@ public interface ICompanyVoiceRoboticCallLogCallphoneService extends IService<Co
 
     List<CompanyVoiceRoboticCallLogCallphone> selectCompanyVoiceRoboticCallPhoneLogGroupList(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
 
-    CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallPhoneLogCount();
+    CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallPhoneLogCount(Long companyId);
 
     List<CompanyVoiceRoboticCallLogCallPhoneVO> listByRoboticId(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
 
+    List<CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO> listDecryptPhoneExport(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
+
     List<CompanyVoiceRoboticCallLogCallphone> selectManualAnsweredList(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone);
 
     /**
@@ -92,4 +95,9 @@ public interface ICompanyVoiceRoboticCallLogCallphoneService extends IService<Co
      * @return 影响行数
      */
     int markHandleFlag(Long logId);
+
+    /**
+     * 根据外呼记录ID获取解密后的手机号
+     */
+    String getDecryptPhoneByLogId(Long logId);
 }

+ 123 - 5
fs-service/src/main/java/com/fs/company/service/impl/CompanyVoiceRoboticCallLogCallphoneServiceImpl.java

@@ -24,6 +24,8 @@ import com.fs.company.service.CompanyWorkflowEngine;
 import com.fs.company.service.ICompanyVoiceRoboticCallLogCallphoneService;
 import com.fs.company.service.ICompanyVoiceRoboticService;
 import com.fs.company.vo.CidConfigVO;
+import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO;
+import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneDecryptQueryVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneVO;
 import com.fs.company.vo.CompanyVoiceRoboticCallLogCount;
 import com.fs.company.vo.easycall.EasyCallCallPhoneVO;
@@ -365,17 +367,67 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
                     companyVoiceRoboticCallLog.setCallCreateTime(createTime);
                     Long answerTime = result.getCallEndTime();
                     companyVoiceRoboticCallLog.setCallAnswerTime(answerTime);
-                    String intention = result.getIntent();
+                    // 【当前启用】读取 cc_call_phone.intent,再转换为系统字典 intention 数值
+                    String intentRaw = StringUtils.isNotBlank(result.getIntent()) ? result.getIntent().trim() : null;
                     String intentf = null;
+                    final String intentionLabel = intentRaw;
                     List<SysDictData> customerIntentionLevel = sysDictTypeService.selectDictDataByType("customer_intention_level");
-                    if (!isPositiveInteger(intention)) {
-                        Optional<SysDictData> firstDict = customerIntentionLevel.stream().filter(e -> e.getDictLabel().equals(intention)).findFirst();
+                    if (!isPositiveInteger(intentionLabel)) {
+                        Optional<SysDictData> firstDict = customerIntentionLevel.stream().filter(e -> e.getDictLabel().equals(intentionLabel)).findFirst();
                         if (firstDict.isPresent()) {
                             SysDictData sysDictData = firstDict.get();
                             intentf = sysDictData.getDictValue();
                         }
+                    } else {
+                        intentf = intentionLabel;
+                    }
+                    if (StringUtils.isBlank(intentf)) {
+                        intentf = "0";
                     }
                     companyVoiceRoboticCallLog.setIntention(intentf);
+
+                    // ========== 【历史保留】回滚时可注释上方 EasyCall 逻辑并取消下方对应注释 ==========
+                    // 方案A:仅使用 EasyCall intent 字段(改动前的本文件写法)
+//                    String intention = result.getIntent();
+//                    String intentf = null;
+//                    List<SysDictData> customerIntentionLevel = sysDictTypeService.selectDictDataByType("customer_intention_level");
+//                    if (!isPositiveInteger(intention)) {
+//                        Optional<SysDictData> firstDict = customerIntentionLevel.stream().filter(e -> e.getDictLabel().equals(intention)).findFirst();
+//                        if (firstDict.isPresent()) {
+//                            SysDictData sysDictData = firstDict.get();
+//                            intentf = sysDictData.getDictValue();
+//                        }
+//                    }
+//                    companyVoiceRoboticCallLog.setIntention(intentf);
+
+                    // 方案B:自家 AI 根据 dialogue 计算意向度
+//                    String intention = null;
+//                    if (StringUtils.isNotBlank(result.getDialogue())) {
+//                        try {
+//                            intention = crmCustomerAnalyzeService.aiIntentionDegree(
+//                                    result.getDialogue(),
+//                                    java.time.LocalTime.now().getLong(java.time.temporal.ChronoField.MILLI_OF_SECOND)
+//                            );
+//                        } catch (Exception e) {
+//                            log.error("easyCall回调日志意向度AI解析失败,uuid={}", result.getUuid(), e);
+//                        }
+//                    }
+//                    String intentf = null;
+//                    final String intentionLabel = intention;
+//                    List<SysDictData> customerIntentionLevel = sysDictTypeService.selectDictDataByType("customer_intention_level");
+//                    if (!isPositiveInteger(intentionLabel)) {
+//                        Optional<SysDictData> firstDict = customerIntentionLevel.stream().filter(e -> e.getDictLabel().equals(intentionLabel)).findFirst();
+//                        if (firstDict.isPresent()) {
+//                            SysDictData sysDictData = firstDict.get();
+//                            intentf = sysDictData.getDictValue();
+//                        }
+//                    } else {
+//                        intentf = intentionLabel;
+//                    }
+//                    if (StringUtils.isBlank(intentf)) {
+//                        intentf = "0";
+//                    }
+//                    companyVoiceRoboticCallLog.setIntention(intentf);
                     if(null != result.getValidTimeLen() && Integer.valueOf(0).compareTo(result.getValidTimeLen()) < 0){
                         BigDecimal divide = new BigDecimal(result.getValidTimeLen()).divide(new BigDecimal(1000), 0, RoundingMode.CEILING);
                         companyVoiceRoboticCallLog.setCallTime(divide.longValue());
@@ -521,14 +573,62 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
     }
 
     @Override
-    public CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallPhoneLogCount() {
-        return baseMapper.selectCompanyVoiceRoboticCallPhoneLogCount();
+    public CompanyVoiceRoboticCallLogCount selectCompanyVoiceRoboticCallPhoneLogCount(Long companyId) {
+        return baseMapper.selectCompanyVoiceRoboticCallPhoneLogCount(companyId);
     }
 
     @Override
     public List<CompanyVoiceRoboticCallLogCallPhoneVO> listByRoboticId(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone) {
         return baseMapper.listByRoboticId(companyVoiceRoboticCallLogCallphone);
     }
+
+    @Override
+    public List<CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO> listDecryptPhoneExport(CompanyVoiceRoboticCallLogCallphone companyVoiceRoboticCallLogCallphone) {
+        prepareDetailPhoneQuery(companyVoiceRoboticCallLogCallphone);
+        List<CompanyVoiceRoboticCallLogCallPhoneDecryptQueryVO> rows = baseMapper.listDecryptPhoneExport(companyVoiceRoboticCallLogCallphone);
+        List<CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO> exportList = new ArrayList<>();
+        if (rows == null || rows.isEmpty()) {
+            return exportList;
+        }
+        Map<String, String> intentionLabelMap = buildIntentionLabelMap();
+        for (CompanyVoiceRoboticCallLogCallPhoneDecryptQueryVO row : rows) {
+            CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO vo = new CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO();
+            vo.setRoboticName(row.getRoboticName());
+            vo.setUserName(row.getUserName());
+            vo.setIntention(resolveIntentionLabel(row.getIntention(), intentionLabelMap));
+            vo.setPhone(PhoneUtil.decryptPhone(row.getPhone()));
+            exportList.add(vo);
+        }
+        return exportList;
+    }
+
+    private Map<String, String> buildIntentionLabelMap() {
+        Map<String, String> map = new HashMap<>();
+        List<SysDictData> dictList = sysDictTypeService.selectDictDataByType("customer_intention_level");
+        if (dictList != null) {
+            for (SysDictData dict : dictList) {
+                map.put(dict.getDictValue(), dict.getDictLabel());
+            }
+        }
+        return map;
+    }
+
+    private String resolveIntentionLabel(String intention, Map<String, String> intentionLabelMap) {
+        if (StringUtils.isEmpty(intention)) {
+            return "";
+        }
+        String label = intentionLabelMap.get(intention);
+        if (StringUtils.isNotEmpty(label)) {
+            return label;
+        }
+        return intention;
+    }
+
+    private void prepareDetailPhoneQuery(CompanyVoiceRoboticCallLogCallphone query) {
+        if (StringUtils.isNotEmpty(query.getPhone())) {
+            query.setEncryptedPhone(PhoneUtil.encryptPhone(query.getPhone()));
+        }
+    }
     /**
      * 判断整数
      *
@@ -551,4 +651,22 @@ public class CompanyVoiceRoboticCallLogCallphoneServiceImpl extends ServiceImpl<
         updateObj.setHandleFlag(1);
         return baseMapper.updateCompanyVoiceRoboticCallLogCallphone(updateObj);
     }
+
+    @Override
+    public String getDecryptPhoneByLogId(Long logId) {
+        CompanyVoiceRoboticCallLogCallphone log = selectCompanyVoiceRoboticCallLogCallphoneByLogId(logId);
+        if (log == null) {
+            return null;
+        }
+        if (log.getCallerId() != null) {
+            CompanyVoiceRoboticCallees callees = companyVoiceRoboticCalleesMapper.selectCompanyVoiceRoboticCalleesById(log.getCallerId());
+            if (callees != null && StringUtils.isNotEmpty(callees.getPhone())) {
+                return PhoneUtil.decryptPhone(callees.getPhone());
+            }
+        }
+        if (StringUtils.isNotEmpty(log.getCallerNum())) {
+            return PhoneUtil.decryptPhone(log.getCallerNum());
+        }
+        return null;
+    }
 }

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

@@ -57,12 +57,10 @@ import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.stereotype.Service;
 
 import java.lang.reflect.Method;
-import java.time.temporal.ChronoField;
 import java.util.*;
 import java.util.stream.Collectors;
 
 import static com.fs.company.service.impl.call.node.AiCallTaskNode.EASYCALL_WORKFLOW_REDIS_KEY;
-import static java.time.LocalTime.now;
 
 
 /**
@@ -898,50 +896,48 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
                 } catch (Exception e) {
                     log.error("callerResult4EasyCall 切换租户数据源失败: tenantId={}", TenantHelper.getTenantId(), e);
                 }
-//                // intent(意向度)由对方异步评估写入,回调时可能尚未赋值,进入延迟重试队列等待
-//                if (StringUtils.isBlank(callPhoneRes.getIntent())) {
-//                    String retryKey = EASYCALL_INTENT_RETRY_KEY + result.getUuid();
-//                    Integer retryCount = redisCache2.getCacheObject(retryKey);
-//                    if (retryCount == null) {
-//                        retryCount = 0;
-//                    }
-//                    if (retryCount < EASYCALL_INTENT_MAX_RETRY) {
-//                        redisCache2.setCacheObject(retryKey, retryCount + 1, 10, java.util.concurrent.TimeUnit.MINUTES);
-//                        log.info("easyCall外呼回调intent意向度暂未评估完成,uuid={},第{}次放入延迟重试队列", result.getUuid(), retryCount + 1);
-//                        doRetryCallerResult4EasyCall(result, retryCount + 1);
-//                    } else {
-//                        // 超过最大重试次数,以 intent 为空(意向未知)兜底继续处理
-//                        log.warn("easyCall外呼回调intent意向度在{}次重试后仍为空,uuid={},以意向未知兜底处理", EASYCALL_INTENT_MAX_RETRY, result.getUuid());
-//                        redisCache2.deleteObject(retryKey);
-//                        doHandleEasyCallResult(callPhoneRes);
-//                    }
-//                    return;
-//                }
-//                // intent 已有值,直接正常处理
-//                redisCache2.deleteObject(EASYCALL_INTENT_RETRY_KEY + result.getUuid());
-//                doHandleEasyCallResult(callPhoneRes);
-                // dialogue(对话内容)由对方异步写入,回调时可能尚未赋值,进入延迟重试队列等待
-                if (isDialogueEmpty(callPhoneRes.getDialogue()) && !"未接通".equals(callPhoneRes.getIntent())) {
-                    String retryKey = EASYCALL_DIALOGUE_RETRY_KEY + result.getUuid();
+                // 【当前启用】cc_call_phone.intent 由 EasyCall 异步评估写入,回调时可能尚未赋值,进入延迟重试队列等待
+                if (StringUtils.isBlank(resolveCcCallPhoneIntent(callPhoneRes))) {
+                    String retryKey = EASYCALL_INTENT_RETRY_KEY + result.getUuid();
                     Integer retryCount = redisCache2.getCacheObject(retryKey);
                     if (retryCount == null) {
                         retryCount = 0;
                     }
-                    if (retryCount < EASYCALL_DIALOGUE_MAX_RETRY) {
+                    if (retryCount < EASYCALL_INTENT_MAX_RETRY) {
                         redisCache2.setCacheObject(retryKey, retryCount + 1, 10, java.util.concurrent.TimeUnit.MINUTES);
-                        log.info("easyCall外呼回调dialogue对话内容暂未写入,uuid={},第{}次放入延迟重试队列", result.getUuid(), retryCount + 1);
-                        doRetryDialogue4EasyCall(result, retryCount + 1);
+                        log.info("easyCall外呼回调intent意向度暂未评估完成,uuid={},第{}次放入延迟重试队列", result.getUuid(), retryCount + 1);
+                        doRetryCallerResult4EasyCall(result, retryCount + 1);
                     } else {
-                        // 超过最大重试次数,以 dialogue 为空兜底继续处理
-                        log.warn("easyCall外呼回调dialogue对话内容在{}次重试后仍为空,uuid={},以对话为空兜底处理", EASYCALL_DIALOGUE_MAX_RETRY, result.getUuid());
+                        log.warn("easyCall外呼回调intent意向度在{}次重试后仍为空,uuid={},以意向未知兜底处理", EASYCALL_INTENT_MAX_RETRY, result.getUuid());
                         redisCache2.deleteObject(retryKey);
                         doHandleEasyCallResult(callPhoneRes);
                     }
                     return;
                 }
-                // dialogue 已有值,直接正常处理
-                redisCache2.deleteObject(EASYCALL_DIALOGUE_RETRY_KEY + result.getUuid());
+                redisCache2.deleteObject(EASYCALL_INTENT_RETRY_KEY + result.getUuid());
                 doHandleEasyCallResult(callPhoneRes);
+
+                // ========== 【历史保留-自家AI】根据 dialogue 等待后走 AI 意向度,回滚时注释上方 intent 重试并取消下方注释 ==========
+//                // 当前:根据对话内容同步调用自家 AI 计算意向度,不依赖第三方 intent
+//                if (isDialogueEmpty(callPhoneRes.getDialogue()) && !"未接通".equals(callPhoneRes.getIntent())) {
+//                    String retryKey = EASYCALL_DIALOGUE_RETRY_KEY + result.getUuid();
+//                    Integer retryCount = redisCache2.getCacheObject(retryKey);
+//                    if (retryCount == null) {
+//                        retryCount = 0;
+//                    }
+//                    if (retryCount < EASYCALL_DIALOGUE_MAX_RETRY) {
+//                        redisCache2.setCacheObject(retryKey, retryCount + 1, 10, java.util.concurrent.TimeUnit.MINUTES);
+//                        log.info("easyCall外呼回调dialogue对话内容暂未写入,uuid={},第{}次放入延迟重试队列", result.getUuid(), retryCount + 1);
+//                        doRetryDialogue4EasyCall(result, retryCount + 1);
+//                    } else {
+//                        log.warn("easyCall外呼回调dialogue对话内容在{}次重试后仍为空,uuid={},以对话为空兜底处理", EASYCALL_DIALOGUE_MAX_RETRY, result.getUuid());
+//                        redisCache2.deleteObject(retryKey);
+//                        doHandleEasyCallResult(callPhoneRes);
+//                    }
+//                    return;
+//                }
+//                redisCache2.deleteObject(EASYCALL_DIALOGUE_RETRY_KEY + result.getUuid());
+//                doHandleEasyCallResult(callPhoneRes);
             } catch (Exception e) {
                 throw new RuntimeException(e);
             } finally {
@@ -1033,8 +1029,8 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
             log.error("easyCall intent重试时仍未查询到外呼结果, uuid={}", result.getUuid());
             return;
         }
-        if (StringUtils.isBlank(callPhoneRes.getIntent())) {
-            // intent 仍为空,继续判断是否还有剩余重试次数
+        if (StringUtils.isBlank(resolveCcCallPhoneIntent(callPhoneRes))) {
+            // cc_call_phone.intent 仍为空,继续判断是否还有剩余重试次数
             String retryKey = EASYCALL_INTENT_RETRY_KEY + result.getUuid();
             Integer retryCount = redisCache2.getCacheObject(retryKey);
             if (retryCount == null) {
@@ -1052,7 +1048,7 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
             return;
         }
         // intent 已评估完成,正常处理
-        log.info("easyCall intent重试第{}次成功获取到意向度={},uuid={}", currentRetry, callPhoneRes.getIntent(), result.getUuid());
+        log.info("easyCall intent重试第{}次成功获取到意向度={},uuid={}", currentRetry, resolveCcCallPhoneIntent(callPhoneRes), result.getUuid());
         redisCache2.deleteObject(EASYCALL_INTENT_RETRY_KEY + result.getUuid());
         doHandleEasyCallResult(callPhoneRes);
     }
@@ -1151,27 +1147,37 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
 //                    log.error("pushDialogContent4EasyCall 切换租户数据源失败: tenantId={}", TenantHelper.getTenantId(), e);
 //                }
 //            }
-            String intention = null;
-            String intentionDegree = null;
-            if (StringUtils.isNotBlank(callPhoneRes.getDialogue())) {
-                log.info("【验证】意向度来源=自家AI, uuid={}, dialogueLength={}", callPhoneRes.getUuid(),
-                        StringUtils.isBlank(callPhoneRes.getDialogue()) ? 0 : callPhoneRes.getDialogue().length());
-                try {
-                    intentionDegree = crmCustomerAnalyzeService.aiIntentionDegree(
-                            callPhoneRes.getDialogue(),
-                            now().getLong(ChronoField.MILLI_OF_SECOND)
-                    );
-                    log.info("【验证】意向度结果={}, uuid={}", intentionDegree, callPhoneRes.getUuid());
-                    intention = getIntention(intentionDegree);
-                } catch (Exception e) {
-                    log.error("easyCall意向度AI解析失败,uuid={},将使用意向未知兜底", callPhoneRes.getUuid(), e);
-                }
-            }
-            // 2) 最终兜底:意向未知
-//            String intention = getIntention(callPhoneRes.getIntent());
+            // 【当前启用】读取 cc_call_phone.intent,再转换为系统字典 intention 数值
+            String intentRaw = resolveCcCallPhoneIntent(callPhoneRes);
+            String intention = convertEasyCallIntent(intentRaw);
+            log.info("easyCall意向度来源=EasyCall平台, uuid={}, intent(raw)={}, intention(converted)={}",
+                    callPhoneRes.getUuid(), intentRaw, intention);
             if (StringUtils.isEmpty(intention)) {
                 intention = "0";
             }
+
+            // ========== 【历史保留-自家AI】回滚时注释上方 EasyCall 逻辑并取消下方注释 ==========
+//            String intention = null;
+//            String intentionDegree = null;
+//            if (StringUtils.isNotBlank(callPhoneRes.getDialogue())) {
+//                log.info("【验证】意向度来源=自家AI, uuid={}, dialogueLength={}", callPhoneRes.getUuid(),
+//                        StringUtils.isBlank(callPhoneRes.getDialogue()) ? 0 : callPhoneRes.getDialogue().length());
+//                try {
+//                    intentionDegree = crmCustomerAnalyzeService.aiIntentionDegree(
+//                            callPhoneRes.getDialogue(),
+//                            java.time.LocalTime.now().getLong(java.time.temporal.ChronoField.MILLI_OF_SECOND)
+//                    );
+//                    log.info("【验证】意向度结果={}, uuid={}", intentionDegree, callPhoneRes.getUuid());
+//                    intention = getIntention(intentionDegree);
+//                } catch (Exception e) {
+//                    log.error("easyCall意向度AI解析失败,uuid={},将使用意向未知兜底", callPhoneRes.getUuid(), e);
+//                }
+//            }
+//            if (StringUtils.isEmpty(intention)) {
+//                intention = "0";
+//            }
+//            // 历史第三方值(仅 intent 字段,未走 AI 时的写法)
+//            // String intention = getIntention(callPhoneRes.getIntent());
             CompanyVoiceRoboticCallees callee = companyVoiceRoboticCalleesMapper.selectCompanyVoiceRoboticCalleesById(cacheInfo.getLong("calleeId"));
             callee.setUuid(callPhoneRes.getUuid());
             callee.setIntention(intention);
@@ -1231,6 +1237,30 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
         return collect.isEmpty() ? null : collect.get(0).getDictValue();
     }
 
+    /**
+     * 读取 EasyCall cc_call_phone 表原始字段 intent(非本系统 intention 字典值)
+     */
+    private String resolveCcCallPhoneIntent(EasyCallCallPhoneVO callPhoneRes) {
+        if (callPhoneRes == null || StringUtils.isBlank(callPhoneRes.getIntent())) {
+            return null;
+        }
+        return callPhoneRes.getIntent().trim();
+    }
+
+    /**
+     * 将 cc_call_phone.intent 原始值转为系统字典数值 intention(customer_intention_level.dict_value)
+     */
+    private String convertEasyCallIntent(String intentRaw) {
+        if (StringUtils.isBlank(intentRaw)) {
+            return null;
+        }
+        String t = intentRaw.trim();
+        if (t.matches("^\\d+$")) {
+            return t;
+        }
+        return getIntention(t);
+    }
+
     public void pushBilling(PushIIntentionResult result) {
         Notify notify = result.getNotify();
         CompanyVoiceRoboticCallees callee = getResultCalleeInfo(notify);
@@ -1841,7 +1871,45 @@ public class CompanyVoiceRoboticServiceImpl extends ServiceImpl<CompanyVoiceRobo
             }
         });
 
-        return buildResult(pageInfo, records);
+
+        // 统计全量节点数据(不受分页限制):查全量instanceIds,再查全量nodeLogs
+        List<String> allInstanceIds = companyAiWorkflowExecMapper.selectExecRecordsByRoboticId(
+                roboticId, null, null, false, null)
+                .stream()
+                .map(WorkflowExecRecordVo::getWorkflowInstanceId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toList());
+
+        long callDone = 0;
+        long addWxDone = 0;
+        long sendMsgDone = 0;
+
+        if (!allInstanceIds.isEmpty()) {
+            List<CompanyAiWorkflowExecLog> allNodeLogs = companyAiWorkflowExecLogMapper.selectByInstanceIds(allInstanceIds);
+            // nodeType: 6=AI外呼电话, 7=AI发送短信, 8=AI添加微信, 9=AI企微添加个微, 10=AI添加微信(新)
+            callDone = allNodeLogs.stream()
+                    .filter(e -> e.getStatus() == 1)
+                    .filter(e -> e.getNodeType().equals(NodeTypeEnum.AI_CALL_TASK.getValue()))
+                    .count();
+            addWxDone = allNodeLogs.stream()
+                    .filter(e -> e.getStatus() == 1)
+                    .filter(e -> e.getNodeType().equals(NodeTypeEnum.AI_ADD_WX_TASK.getValue())
+                            || e.getNodeType().equals(NodeTypeEnum.AI_QW_ADD_WX_TASK.getValue())
+                            || e.getNodeType().equals(NodeTypeEnum.AI_ADD_WX_TASK_NEW.getValue()))
+                    .count();
+            sendMsgDone = allNodeLogs.stream()
+                    .filter(e -> e.getStatus() == 1)
+                    .filter(e -> e.getNodeType().equals(NodeTypeEnum.AI_SEND_MSG_TASK.getValue()))
+                    .count();
+        }
+
+        Map<String, Object> result = buildResult(pageInfo, records);
+        Map<String, Object> stats = new HashMap<>();
+        stats.put("callDone", callDone);
+        stats.put("addWxDone", addWxDone);
+        stats.put("sendMsgDone", sendMsgDone);
+        result.put("stats", stats);
+        return result;
     }
 
     private Map<String, Object> buildResult(PageInfo<?> pageInfo, List<?> records) {

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

@@ -299,7 +299,7 @@ public class AiCallTaskNode extends AbstractWorkflowNode {
         callbackInfo.put("workflowInstanceId", context.getWorkflowInstanceId());
         callbackInfo.put("calleeId", calleeId);
         super.redisCache.setCacheObject(EASYCALL_WORKFLOW_REDIS_KEY + callBackUuid,
-                callbackInfo.toJSONString(), 1, TimeUnit.DAYS);
+                callbackInfo.toJSONString(), 15, TimeUnit.DAYS);
         // 将 callBackUuid 写入 context,供后续回调时从 context 取用
         context.setVariable("callBackUuid", callBackUuid);
 

+ 23 - 0
fs-service/src/main/java/com/fs/company/vo/CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO.java

@@ -0,0 +1,23 @@
+package com.fs.company.vo;
+
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+/**
+ * 外呼记录详情导出(解密手机号)
+ */
+@Data
+public class CompanyVoiceRoboticCallLogCallPhoneDecryptExportVO {
+
+    @Excel(name = "任务名称")
+    private String roboticName;
+
+    @Excel(name = "客户名称")
+    private String userName;
+
+    @Excel(name = "客户类型")
+    private String intention;
+
+    @Excel(name = "手机号")
+    private String phone;
+}

+ 19 - 0
fs-service/src/main/java/com/fs/company/vo/CompanyVoiceRoboticCallLogCallPhoneDecryptQueryVO.java

@@ -0,0 +1,19 @@
+package com.fs.company.vo;
+
+import lombok.Data;
+
+/**
+ * 外呼记录详情导出查询中间对象
+ */
+@Data
+public class CompanyVoiceRoboticCallLogCallPhoneDecryptQueryVO {
+
+    private String roboticName;
+
+    private String userName;
+
+    /** 客户类型字典值(存库为数字) */
+    private String intention;
+
+    private String phone;
+}

+ 3 - 0
fs-service/src/main/java/com/fs/company/vo/CompanyVoiceRoboticCallLogCallPhoneVO.java

@@ -24,6 +24,9 @@ public class CompanyVoiceRoboticCallLogCallPhoneVO {
     @Excel(name = "caller_id")
     private Long callerId;
 
+    /** 客户ID(callees.user_id) */
+    private Long customerId;
+
     /** 记录调用时间 */
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     @Excel(name = "记录调用时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")

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

@@ -155,7 +155,7 @@ public class EasyCallCallPhoneVO {
     private String emptyNumberDetectionText;
 
     /**
-     * 客户意向
+     * 客户意向(EasyCall cc_call_phone 表原始字段 intent,多为等级字母或文案,需经字典转换为系统 intention)
      */
     private String intent;
 

+ 11 - 0
fs-service/src/main/java/com/fs/config/tencent/TencentCOSClientConfig.java

@@ -1,5 +1,6 @@
 package com.fs.config.tencent;
 
+import com.github.binarywang.wxpay.service.impl.WxPayServiceImpl;
 import com.qcloud.cos.COSClient;
 import com.qcloud.cos.ClientConfig;
 import com.qcloud.cos.auth.BasicCOSCredentials;
@@ -9,8 +10,11 @@ import lombok.AllArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Lazy;
+import org.springframework.core.env.Environment;
 import org.springframework.stereotype.Component;
 
+import java.util.Objects;
+
 @Component
 @AllArgsConstructor
 @Slf4j
@@ -20,9 +24,16 @@ public class TencentCOSClientConfig {
     @Lazy
     private final TencentProperties tencentProperties;
 
+    private Environment environment;
+
     @Bean
     public COSClient createClient() {
         log.info("创建COSClient...");
+        if(Objects.equals(environment.getProperty("spring.profiles.active"), "dev-test")){
+            String secretId = "";
+            String secretKey = "";
+            String region = "";
+        }
         // 检查配置
         if (!tencentProperties.isConfigValid()) {
             log.error("腾讯云配置不完整,无法创建COS客户端");

+ 1 - 1
fs-service/src/main/java/com/fs/core/config/WxPayConfiguration.java

@@ -31,7 +31,7 @@ public class WxPayConfiguration {
     try {
       // 每次创建服务时都获取最新的配置
       log.info("创建微信支付服务,检查配置...");
-      if(Objects.equals(environment.getProperty("spring.profiles.active"), "dev")){
+      if(Objects.equals(environment.getProperty("spring.profiles.active"), "dev-test")){
         return new WxPayServiceImpl();
       }
 

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

@@ -1137,4 +1137,395 @@ public interface CrmCustomerMapper extends BaseMapper<CrmCustomer> {
      * @return CRM客户
      */
     CrmCustomer selectCrmCustomerByCallphoneLogId(@Param("callphoneLogId") Long callphoneLogId);
+
+    // ==================== 条件分配:统计和查询ID ====================
+
+    /** 公海客户条件统计 */
+    @Select({"<script> " +
+            "select count(1) from crm_customer c " +
+            "where c.is_line=0 and is_del=0 and c.is_pool=1 " +
+            "<if test = 'maps.companyId != null     '> " +
+            "and c.company_id =#{maps.companyId} " +
+            "</if>" +
+            "<if test = 'maps.address != null and  maps.address !=\"\"    '> " +
+            "and c.address like CONCAT('%',#{maps.address},'%') " +
+            "</if>" +
+            "<if test = 'maps.customerCode != null and  maps.customerCode !=\"\"    '> " +
+            "and c.customer_code like CONCAT(#{maps.customerCode},'%') " +
+            "</if>" +
+            "<if test = 'maps.customerName != null and  maps.customerName !=\"\"    '> " +
+            "and c.customer_name like CONCAT('%',#{maps.customerName},'%') " +
+            "</if>" +
+            "<if test = 'maps.mobile != null and  maps.mobile !=\"\"    '> " +
+            "and c.mobile like CONCAT('%',#{maps.mobile},'%') " +
+            "</if>" +
+            "<if test = 'maps.status != null and maps.status !=\"\"     '> " +
+            "and c.status =#{maps.status} " +
+            "</if>" +
+            "<if test = 'maps.visitStatus != null and maps.visitStatus !=\"\"     '> " +
+            "and c.visit_status IN " +
+            "<foreach collection=\"maps.visitStatus.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.customerType != null      '> " +
+            "and c.customer_type IN " +
+            "<foreach collection=\"maps.customerType.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.isReceive != null      '> " +
+            "and c.is_receive =#{maps.isReceive} " +
+            "</if>" +
+            "<if test = 'maps.source != null and maps.source !=\"\"      '> " +
+            "and c.source IN " +
+            "<foreach collection=\"maps.source.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.tags != null      '> " +
+            "and  " +
+            "<foreach collection=\"maps.tags.split(',')\" item=\"tag\"   open=\"(\" close=\")\" separator=\"OR\">" +
+            "find_in_set(#{tag},c.tags)" +
+            "</foreach> " +
+            "</if>" +
+            "<if test = 'maps.beginTime != null and maps.beginTime != \"\" '> " +
+            "and date_format(c.create_time,'%y%m%d') &gt;= date_format(#{maps.beginTime},'%y%m%d') " +
+            "</if>" +
+            "<if test = 'maps.endTime != null and maps.endTime != \"\" '> " +
+            "and date_format(c.create_time,'%y%m%d') &lt;= date_format(#{maps.endTime},'%y%m%d') " +
+            "</if>" +
+            "<if test = 'maps.deptId != null  and maps.deptId != 0 '> " +
+            "AND (c.dept_id = #{maps.deptId} OR c.dept_id IN ( SELECT t.dept_id FROM company_dept t WHERE find_in_set(#{maps.deptId}, ancestors) )) " +
+            "</if>" +
+            "${maps.params.dataScope}"+
+            "</script>"})
+    int countCrmFullCustomerByCondition(@Param("maps") CrmFullCustomerListQueryParam param);
+
+    /** 公海客户条件查询ID */
+    @Select({"<script> " +
+            "select c.customer_id from crm_customer c " +
+            "where c.is_line=0 and is_del=0 and c.is_pool=1 " +
+            "<if test = 'maps.companyId != null     '> " +
+            "and c.company_id =#{maps.companyId} " +
+            "</if>" +
+            "<if test = 'maps.address != null and  maps.address !=\"\"    '> " +
+            "and c.address like CONCAT('%',#{maps.address},'%') " +
+            "</if>" +
+            "<if test = 'maps.customerCode != null and  maps.customerCode !=\"\"    '> " +
+            "and c.customer_code like CONCAT(#{maps.customerCode},'%') " +
+            "</if>" +
+            "<if test = 'maps.customerName != null and  maps.customerName !=\"\"    '> " +
+            "and c.customer_name like CONCAT('%',#{maps.customerName},'%') " +
+            "</if>" +
+            "<if test = 'maps.mobile != null and  maps.mobile !=\"\"    '> " +
+            "and c.mobile like CONCAT('%',#{maps.mobile},'%') " +
+            "</if>" +
+            "<if test = 'maps.status != null and maps.status !=\"\"     '> " +
+            "and c.status =#{maps.status} " +
+            "</if>" +
+            "<if test = 'maps.visitStatus != null and maps.visitStatus !=\"\"     '> " +
+            "and c.visit_status IN " +
+            "<foreach collection=\"maps.visitStatus.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.customerType != null      '> " +
+            "and c.customer_type IN " +
+            "<foreach collection=\"maps.customerType.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.isReceive != null      '> " +
+            "and c.is_receive =#{maps.isReceive} " +
+            "</if>" +
+            "<if test = 'maps.source != null and maps.source !=\"\"      '> " +
+            "and c.source IN " +
+            "<foreach collection=\"maps.source.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.tags != null      '> " +
+            "and  " +
+            "<foreach collection=\"maps.tags.split(',')\" item=\"tag\"   open=\"(\" close=\")\" separator=\"OR\">" +
+            "find_in_set(#{tag},c.tags)" +
+            "</foreach> " +
+            "</if>" +
+            "<if test = 'maps.beginTime != null and maps.beginTime != \"\" '> " +
+            "and date_format(c.create_time,'%y%m%d') &gt;= date_format(#{maps.beginTime},'%y%m%d') " +
+            "</if>" +
+            "<if test = 'maps.endTime != null and maps.endTime != \"\" '> " +
+            "and date_format(c.create_time,'%y%m%d') &lt;= date_format(#{maps.endTime},'%y%m%d') " +
+            "</if>" +
+            "<if test = 'maps.deptId != null  and maps.deptId != 0 '> " +
+            "AND (c.dept_id = #{maps.deptId} OR c.dept_id IN ( SELECT t.dept_id FROM company_dept t WHERE find_in_set(#{maps.deptId}, ancestors) )) " +
+            "</if>" +
+            "${maps.params.dataScope}"+
+            " order by c.customer_id desc limit 1000 "+
+            "</script>"})
+    List<Long> selectCrmFullCustomerIdsByCondition(@Param("maps") CrmFullCustomerListQueryParam param);
+
+    /** 我的客户条件统计 */
+    @Select({"<script> " +
+            "select count(1) from crm_customer_user cu inner join crm_customer c on c.customer_user_id=cu.customer_user_id " +
+            "where cu.is_pool=0 " +
+            "<if test = 'maps.companyId != null     '> " +
+            "and cu.company_id =#{maps.companyId} " +
+            "</if>" +
+            "<if test = 'maps.companyUserId != null     '> " +
+            "and cu.company_user_id =#{maps.companyUserId} " +
+            "</if>" +
+            "<if test = 'maps.address != null and  maps.address !=\"\"    '> " +
+            "and c.address like CONCAT('%',#{maps.address},'%') " +
+            "</if>" +
+            "<if test = 'maps.customerCode != null and  maps.customerCode !=\"\"    '> " +
+            "and c.customer_code like CONCAT('%',#{maps.customerCode},'%') " +
+            "</if>" +
+            "<if test = 'maps.customerName != null and  maps.customerName !=\"\"    '> " +
+            "and c.customer_name like CONCAT('%',#{maps.customerName},'%') " +
+            "</if>" +
+            "<if test = 'maps.mobile != null and  maps.mobile !=\"\"    '> " +
+            "and c.mobile like CONCAT('%',#{maps.mobile},'%') " +
+            "</if>" +
+            "<if test = 'maps.status != null and maps.status !=\"\"     '> " +
+            "and cu.status IN " +
+            "<foreach collection=\"maps.status.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.visitStatus != null and maps.visitStatus !=\"\"'> " +
+            "and c.visit_status IN " +
+            "<foreach collection=\"maps.visitStatus.split(',')\" item='item' index='index' open='(' separator=',' close=')'> #{item} </foreach>" +
+            "</if>" +
+            "<if test = 'maps.isHisOrder != null and maps.isHisOrder==1      '> " +
+            "and (select ifnull(count(1),0) from crm_customer_his_order h where h.customer_id=c.customer_id )  &gt; 0 " +
+            "</if>" +
+            "<if test = 'maps.isHisOrder != null and maps.isHisOrder==0      '> " +
+            "and (select ifnull(count(1),0) from crm_customer_his_order h where h.customer_id=c.customer_id )  = 0 " +
+            "</if>" +
+            "<if test = 'maps.isManualCall != null and maps.isManualCall==1      '> " +
+            "and (select ifnull(count(1),0) from crm_customer_call_log ccl where ccl.customer_id=c.customer_id and ccl.call_time &gt; 0)  &gt; 0 " +
+            "</if>" +
+            "<if test = 'maps.isManualCall != null and maps.isManualCall==0      '> " +
+            "and (select ifnull(count(1),0) from crm_customer_call_log ccl where ccl.customer_id=c.customer_id and ccl.call_time &gt; 0)  = 0 " +
+            "</if>" +
+            "<if test = 'maps.customerType != null      '> " +
+            "and c.customer_type IN " +
+            "<foreach collection=\"maps.customerType.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.source != null and maps.source !=\"\"      '> " +
+            "and c.source IN " +
+            "<foreach collection=\"maps.source.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.tags != null and maps.tags!=\"\"     '> " +
+            "and  " +
+            "<foreach collection=\"maps.tags.split(',')\" item=\"tag\"   open=\"(\" close=\")\" separator=\"OR\">" +
+            "find_in_set(#{tag},c.tags)" +
+            "</foreach> " +
+            "</if>" +
+            "<if test = 'maps.customerCreateTime != null    '> " +
+            " AND date_format(c.create_time,'%y%m%d') &gt;= date_format(#{maps.customerCreateTime[0]},'%y%m%d') " +
+            " AND date_format(c.create_time,'%y%m%d') &lt;= date_format(#{maps.customerCreateTime[1]},'%y%m%d') " +
+            "</if>" +
+            "<if test = 'maps.beginTime != null and maps.beginTime != \"\" '> " +
+            "and date_format(c.receive_time,'%y%m%d') &gt;= date_format(#{maps.beginTime},'%y%m%d') " +
+            "</if>" +
+            "<if test = 'maps.endTime != null and maps.endTime != \"\" '> " +
+            "and date_format(c.receive_time,'%y%m%d') &lt;= date_format(#{maps.endTime},'%y%m%d') " +
+            "</if>" +
+            "</script>"})
+    int countCrmMyCustomerByCondition(@Param("maps") CrmMyCustomerListQueryParam param);
+
+    /** 我的客户条件查询ID */
+    @Select({"<script> " +
+            "select c.customer_id from crm_customer_user cu inner join crm_customer c on c.customer_user_id=cu.customer_user_id " +
+            "where cu.is_pool=0 " +
+            "<if test = 'maps.companyId != null     '> " +
+            "and cu.company_id =#{maps.companyId} " +
+            "</if>" +
+            "<if test = 'maps.companyUserId != null     '> " +
+            "and cu.company_user_id =#{maps.companyUserId} " +
+            "</if>" +
+            "<if test = 'maps.address != null and  maps.address !=\"\"    '> " +
+            "and c.address like CONCAT('%',#{maps.address},'%') " +
+            "</if>" +
+            "<if test = 'maps.customerCode != null and  maps.customerCode !=\"\"    '> " +
+            "and c.customer_code like CONCAT('%',#{maps.customerCode},'%') " +
+            "</if>" +
+            "<if test = 'maps.customerName != null and  maps.customerName !=\"\"    '> " +
+            "and c.customer_name like CONCAT('%',#{maps.customerName},'%') " +
+            "</if>" +
+            "<if test = 'maps.mobile != null and  maps.mobile !=\"\"    '> " +
+            "and c.mobile like CONCAT('%',#{maps.mobile},'%') " +
+            "</if>" +
+            "<if test = 'maps.status != null and maps.status !=\"\"     '> " +
+            "and cu.status IN " +
+            "<foreach collection=\"maps.status.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.visitStatus != null and maps.visitStatus !=\"\"'> " +
+            "and c.visit_status IN " +
+            "<foreach collection=\"maps.visitStatus.split(',')\" item='item' index='index' open='(' separator=',' close=')'> #{item} </foreach>" +
+            "</if>" +
+            "<if test = 'maps.isHisOrder != null and maps.isHisOrder==1      '> " +
+            "and (select ifnull(count(1),0) from crm_customer_his_order h where h.customer_id=c.customer_id )  &gt; 0 " +
+            "</if>" +
+            "<if test = 'maps.isHisOrder != null and maps.isHisOrder==0      '> " +
+            "and (select ifnull(count(1),0) from crm_customer_his_order h where h.customer_id=c.customer_id )  = 0 " +
+            "</if>" +
+            "<if test = 'maps.isManualCall != null and maps.isManualCall==1      '> " +
+            "and (select ifnull(count(1),0) from crm_customer_call_log ccl where ccl.customer_id=c.customer_id and ccl.call_time &gt; 0)  &gt; 0 " +
+            "</if>" +
+            "<if test = 'maps.isManualCall != null and maps.isManualCall==0      '> " +
+            "and (select ifnull(count(1),0) from crm_customer_call_log ccl where ccl.customer_id=c.customer_id and ccl.call_time &gt; 0)  = 0 " +
+            "</if>" +
+            "<if test = 'maps.customerType != null      '> " +
+            "and c.customer_type IN " +
+            "<foreach collection=\"maps.customerType.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.source != null and maps.source !=\"\"      '> " +
+            "and c.source IN " +
+            "<foreach collection=\"maps.source.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.tags != null and maps.tags!=\"\"     '> " +
+            "and  " +
+            "<foreach collection=\"maps.tags.split(',')\" item=\"tag\"   open=\"(\" close=\")\" separator=\"OR\">" +
+            "find_in_set(#{tag},c.tags)" +
+            "</foreach> " +
+            "</if>" +
+            "<if test = 'maps.customerCreateTime != null    '> " +
+            " AND date_format(c.create_time,'%y%m%d') &gt;= date_format(#{maps.customerCreateTime[0]},'%y%m%d') " +
+            " AND date_format(c.create_time,'%y%m%d') &lt;= date_format(#{maps.customerCreateTime[1]},'%y%m%d') " +
+            "</if>" +
+            "<if test = 'maps.beginTime != null and maps.beginTime != \"\" '> " +
+            "and date_format(c.receive_time,'%y%m%d') &gt;= date_format(#{maps.beginTime},'%y%m%d') " +
+            "</if>" +
+            "<if test = 'maps.endTime != null and maps.endTime != \"\" '> " +
+            "and date_format(c.receive_time,'%y%m%d') &lt;= date_format(#{maps.endTime},'%y%m%d') " +
+            "</if>" +
+            " order by cu.customer_user_id desc limit 1000 "+
+            "</script>"})
+    List<Long> selectCrmMyCustomerIdsByCondition(@Param("maps") CrmMyCustomerListQueryParam param);
+
+    /** 客户管理条件统计 */
+    @Select({"<script> " +
+            "select count(1) from crm_customer c " +
+            "where 1=1 " +
+            "<if test = 'maps.companyId != null     '> " +
+            "and c.company_id =#{maps.companyId} " +
+            "</if>" +
+            "<if test = 'maps.customerCode != null and  maps.customerCode !=\"\"    '> " +
+            "and c.customer_code like CONCAT('%',#{maps.customerCode},'%') " +
+            "</if>" +
+            "<if test = 'maps.customerName != null and  maps.customerName !=\"\"    '> " +
+            "and c.customer_name like CONCAT('%',#{maps.customerName},'%') " +
+            "</if>" +
+            "<if test = 'maps.mobile != null and  maps.mobile !=\"\"    '> " +
+            "and c.mobile like CONCAT('%',#{maps.mobile},'%') " +
+            "</if>" +
+            "<if test = 'maps.status != null and maps.status !=\"\"     '> " +
+            "and c.status =#{maps.status} " +
+            "</if>" +
+            "<if test = 'maps.visitStatus != null       '> " +
+            "and c.visit_status IN " +
+            "<foreach collection=\"maps.visitStatus.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.isBuy != null and maps.isBuy==1      '> " +
+            "and c.buy_count &gt; 0 " +
+            "</if>" +
+            "<if test = 'maps.isBuy != null and maps.isBuy==0      '> " +
+            "and (c.buy_count = 0 or c.buy_count is null) " +
+            "</if>" +
+            "<if test = 'maps.isHisOrder != null and maps.isHisOrder==1      '> " +
+            "and (select ifnull(count(1),0) from crm_customer_his_order h where h.customer_id=c.customer_id )  &gt; 0 " +
+            "</if>" +
+            "<if test = 'maps.isHisOrder != null and maps.isHisOrder==0      '> " +
+            "and (select ifnull(count(1),0) from crm_customer_his_order h where h.customer_id=c.customer_id )  = 0 " +
+            "</if>" +
+            "<if test = 'maps.customerType != null      '> " +
+            "and c.customer_type IN " +
+            "<foreach collection=\"maps.customerType.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.source != null and maps.source !=\"\"      '> " +
+            "and c.source IN " +
+            "<foreach collection=\"maps.source.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.isReceive != null      '> " +
+            "and c.is_receive =#{maps.isReceive} " +
+            "</if>" +
+            "<if test = 'maps.isLine != null      '> " +
+            "and c.is_line =#{maps.isLine} " +
+            "</if>" +
+            "<if test = 'maps.tags != null      '> " +
+            "and  " +
+            "<foreach collection=\"maps.tags.split(',')\" item=\"tag\"   open=\"(\" close=\")\" separator=\"OR\">" +
+            "find_in_set(#{tag},c.tags)" +
+            "</foreach> " +
+            "</if>" +
+            "<if test = 'maps.beginTime != null and maps.beginTime != \"\" '> " +
+            "and date_format(c.create_time,'%y%m%d') &gt;= date_format(#{maps.beginTime},'%y%m%d') " +
+            "</if>" +
+            "<if test = 'maps.endTime != null and maps.endTime != \"\" '> " +
+            "and date_format(c.create_time,'%y%m%d') &lt;= date_format(#{maps.endTime},'%y%m%d') " +
+            "</if>" +
+            "<if test = 'maps.deptId != null  and maps.deptId != 0 '> " +
+            "AND (c.dept_id = #{maps.deptId} OR c.dept_id IN ( SELECT t.dept_id FROM company_dept t WHERE find_in_set(#{maps.deptId}, ancestors) )) " +
+            "</if>" +
+            "${maps.params.dataScope}"+
+            "</script>"})
+    int countCrmCustomerByCondition(@Param("maps") CrmCustomerListQueryParam param);
+
+    /** 客户管理条件查询ID */
+    @Select({"<script> " +
+            "select c.customer_id from crm_customer c " +
+            "where 1=1 " +
+            "<if test = 'maps.companyId != null     '> " +
+            "and c.company_id =#{maps.companyId} " +
+            "</if>" +
+            "<if test = 'maps.customerCode != null and  maps.customerCode !=\"\"    '> " +
+            "and c.customer_code like CONCAT('%',#{maps.customerCode},'%') " +
+            "</if>" +
+            "<if test = 'maps.customerName != null and  maps.customerName !=\"\"    '> " +
+            "and c.customer_name like CONCAT('%',#{maps.customerName},'%') " +
+            "</if>" +
+            "<if test = 'maps.mobile != null and  maps.mobile !=\"\"    '> " +
+            "and c.mobile like CONCAT('%',#{maps.mobile},'%') " +
+            "</if>" +
+            "<if test = 'maps.status != null and maps.status !=\"\"     '> " +
+            "and c.status =#{maps.status} " +
+            "</if>" +
+            "<if test = 'maps.visitStatus != null       '> " +
+            "and c.visit_status IN " +
+            "<foreach collection=\"maps.visitStatus.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.isBuy != null and maps.isBuy==1      '> " +
+            "and c.buy_count &gt; 0 " +
+            "</if>" +
+            "<if test = 'maps.isBuy != null and maps.isBuy==0      '> " +
+            "and (c.buy_count = 0 or c.buy_count is null) " +
+            "</if>" +
+            "<if test = 'maps.isHisOrder != null and maps.isHisOrder==1      '> " +
+            "and (select ifnull(count(1),0) from crm_customer_his_order h where h.customer_id=c.customer_id )  &gt; 0 " +
+            "</if>" +
+            "<if test = 'maps.isHisOrder != null and maps.isHisOrder==0      '> " +
+            "and (select ifnull(count(1),0) from crm_customer_his_order h where h.customer_id=c.customer_id )  = 0 " +
+            "</if>" +
+            "<if test = 'maps.customerType != null      '> " +
+            "and c.customer_type IN " +
+            "<foreach collection=\"maps.customerType.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.source != null and maps.source !=\"\"      '> " +
+            "and c.source IN " +
+            "<foreach collection=\"maps.source.split(',')\"  item='item' index='index'  open='(' separator=',' close=')'> #{item} </foreach>"+
+            "</if>" +
+            "<if test = 'maps.isReceive != null      '> " +
+            "and c.is_receive =#{maps.isReceive} " +
+            "</if>" +
+            "<if test = 'maps.isLine != null      '> " +
+            "and c.is_line =#{maps.isLine} " +
+            "</if>" +
+            "<if test = 'maps.tags != null      '> " +
+            "and  " +
+            "<foreach collection=\"maps.tags.split(',')\" item=\"tag\"   open=\"(\" close=\")\" separator=\"OR\">" +
+            "find_in_set(#{tag},c.tags)" +
+            "</foreach> " +
+            "</if>" +
+            "<if test = 'maps.beginTime != null and maps.beginTime != \"\" '> " +
+            "and date_format(c.create_time,'%y%m%d') &gt;= date_format(#{maps.beginTime},'%y%m%d') " +
+            "</if>" +
+            "<if test = 'maps.endTime != null and maps.endTime != \"\" '> " +
+            "and date_format(c.create_time,'%y%m%d') &lt;= date_format(#{maps.endTime},'%y%m%d') " +
+            "</if>" +
+            "<if test = 'maps.deptId != null  and maps.deptId != 0 '> " +
+            "AND (c.dept_id = #{maps.deptId} OR c.dept_id IN ( SELECT t.dept_id FROM company_dept t WHERE find_in_set(#{maps.deptId}, ancestors) )) " +
+            "</if>" +
+            "${maps.params.dataScope}"+
+            " order by c.customer_id desc limit 1000 "+
+            "</script>"})
+    List<Long> selectCrmCustomerIdsByCondition(@Param("maps") CrmCustomerListQueryParam param);
 }

+ 35 - 0
fs-service/src/main/java/com/fs/crm/param/CrmCustomerConditionAssignParam.java

@@ -0,0 +1,35 @@
+package com.fs.crm.param;
+
+import com.fs.crm.dto.CrmCustomerAssignUserDTO;
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 条件分配请求参数
+ * 根据筛选条件匹配客户进行批量分配,无需手动勾选
+ */
+@Data
+public class CrmCustomerConditionAssignParam extends BaseQueryParam {
+    /**
+     * 公司ID
+     */
+    private Long companyId;
+    /**
+     * 分配类型 2=公海分配 3=已领取转移
+     */
+    private Integer assignType;
+    /**
+     * 查询类型 "my" / "customer" / "full"
+     */
+    private String queryType;
+    /**
+     * 分配员工列表
+     */
+    private List<CrmCustomerAssignUserDTO> users;
+    /**
+     * 前端筛选条件(与列表查询接口参数一致)
+     */
+    private Map<String, Object> queryParams;
+}

+ 19 - 0
fs-service/src/main/java/com/fs/crm/service/ICrmCustomerService.java

@@ -183,4 +183,23 @@ public interface ICrmCustomerService
      * @return CRM客户
      */
     CrmCustomer selectCrmCustomerByCallphoneLogId(Long callphoneLogId);
+
+    /**
+     * 根据筛选条件统计客户数量
+     *
+     * @param queryType   查询类型 "my" / "customer" / "full"
+     * @param queryParams 前端筛选条件
+     * @return 符合条件的客户数量
+     */
+    int countByCondition(String queryType, Map<String, Object> queryParams);
+
+    /**
+     * 根据筛选条件批量分配客户
+     *
+     * @param operUserName 操作人姓名
+     * @param operUserId   操作人ID
+     * @param param        条件分配参数(含queryType、queryParams、users、assignType)
+     * @return 分配结果
+     */
+    R assignByCondition(String operUserName, Long operUserId, CrmCustomerConditionAssignParam param);
 }

+ 60 - 0
fs-service/src/main/java/com/fs/crm/service/impl/CrmCustomerServiceImpl.java

@@ -1161,4 +1161,64 @@ public class CrmCustomerServiceImpl extends ServiceImpl<CrmCustomerMapper, CrmCu
         return crmCustomerMapper.selectCrmCustomerByCallphoneLogId(callphoneLogId);
     }
 
+    @Override
+    public int countByCondition(String queryType, Map<String, Object> queryParams) {
+        switch (queryType) {
+            case "full":
+                CrmFullCustomerListQueryParam fullParam = JSONObject.parseObject(
+                        JSONObject.toJSONString(queryParams), CrmFullCustomerListQueryParam.class);
+                return crmCustomerMapper.countCrmFullCustomerByCondition(fullParam);
+            case "my":
+                CrmMyCustomerListQueryParam myParam = JSONObject.parseObject(
+                        JSONObject.toJSONString(queryParams), CrmMyCustomerListQueryParam.class);
+                return crmCustomerMapper.countCrmMyCustomerByCondition(myParam);
+            case "customer":
+                CrmCustomerListQueryParam customerParam = JSONObject.parseObject(
+                        JSONObject.toJSONString(queryParams), CrmCustomerListQueryParam.class);
+                return crmCustomerMapper.countCrmCustomerByCondition(customerParam);
+            default:
+                throw new CustomException("不支持的查询类型: " + queryType);
+        }
+    }
+
+    @Override
+    @Transactional
+    public R assignByCondition(String operUserName, Long operUserId, CrmCustomerConditionAssignParam param) {
+        String queryType = param.getQueryType();
+        Map<String, Object> queryParams = param.getQueryParams();
+        List<Long> customerIds;
+
+        switch (queryType) {
+            case "full":
+                CrmFullCustomerListQueryParam fullParam = JSONObject.parseObject(
+                        JSONObject.toJSONString(queryParams), CrmFullCustomerListQueryParam.class);
+                customerIds = crmCustomerMapper.selectCrmFullCustomerIdsByCondition(fullParam);
+                break;
+            case "my":
+                CrmMyCustomerListQueryParam myParam = JSONObject.parseObject(
+                        JSONObject.toJSONString(queryParams), CrmMyCustomerListQueryParam.class);
+                customerIds = crmCustomerMapper.selectCrmMyCustomerIdsByCondition(myParam);
+                break;
+            case "customer":
+                CrmCustomerListQueryParam customerParam = JSONObject.parseObject(
+                        JSONObject.toJSONString(queryParams), CrmCustomerListQueryParam.class);
+                customerIds = crmCustomerMapper.selectCrmCustomerIdsByCondition(customerParam);
+                break;
+            default:
+                throw new CustomException("不支持的查询类型: " + queryType);
+        }
+
+        if (customerIds == null || customerIds.isEmpty()) {
+            return R.error("未找到符合条件的客户");
+        }
+
+        // 复用已有的 assignToUser 逻辑
+        CrmCustomeAssignParam assignParam = new CrmCustomeAssignParam();
+        assignParam.setCustomerIds(customerIds);
+        assignParam.setAssignType(param.getAssignType());
+        assignParam.setUsers(param.getUsers());
+        assignParam.setCompanyId(param.getCompanyId());
+        return assignToUser(operUserName, operUserId, assignParam);
+    }
+
 }

+ 3 - 2
fs-service/src/main/java/com/fs/fastGpt/domain/FastGptChatConversation.java

@@ -15,8 +15,9 @@ public class FastGptChatConversation {
     private String isRepository;
     private String userContent;
     private String aiContent;
-    //向量知识库检索结果
-    private List<Map<String,String>> knowledgeBase;
+    //向量知识库检索结果(FastGPT 知识库搜索引用合并组件兼容格式)
+    //字段: id / q / a / sourceName / sourceId / datasetId / collectionId / chunkIndex / score([{type,value,index}])
+    private List<Map<String,Object>> knowledgeBase;
     //企微标签
     /**
      * List<Map<分组名,Map<标签名,标签id>>>

+ 141 - 63
fs-service/src/main/java/com/fs/fastGpt/service/impl/AiHookServiceImpl.java

@@ -1548,7 +1548,7 @@ public class AiHookServiceImpl implements AiHookService {
             fastGptChatSession.setNickName(qwExternalContacts.getName());
             fastGptChatSession.setCompanyId(user.getCompanyId());
             fastGptChatSession.setLastTime(new Date());
-            fastGptChatSession.setIsReply(0);
+            fastGptChatSession.setIsReply(1);
             fastGptChatSession.setUserId(String.valueOf(dto.getUser_id()));
             fastGptChatSessionMapper.insertFastGptChatSession(fastGptChatSession);
             addUserSex(qwExternalContacts);
@@ -1557,7 +1557,7 @@ public class AiHookServiceImpl implements AiHookService {
                 FastGptChatSession ss = new FastGptChatSession();
                 ss.setSessionId(fastGptChatSession.getSessionId());
                 ss.setRemindStatus(0);
-                ss.setIsReply(0);
+                ss.setIsReply(1);
                 ss.setUserId(String.valueOf(dto.getUser_id()));
                 fastGptChatSessionMapper.updateFastGptChatSession(ss);
             }
@@ -1818,7 +1818,7 @@ public class AiHookServiceImpl implements AiHookService {
         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<>();
+        List<Map<String, Object>> knowledgeBase = new ArrayList<>();
 
         if(role.getReminderWords() != null && !role.getReminderWords().isEmpty()){
             conversation.setAiInfo(role.getReminderWords());
@@ -1887,9 +1887,9 @@ public class AiHookServiceImpl implements AiHookService {
             conversation.setHistory(historyArray);
         }
 
-        //从向量知识库中检索相关内容
+        //从向量知识库中检索相关内容【输出与 FastGPT 知识库搜索引用合并组件兼容的引用数据,以便与固定知识库数据合并输出】
         if (count != null && !count.trim().isEmpty()) {
-            String searchQuery = contextQuery != null ? contextQuery : count;
+            String searchQuery = (contextQuery != null && !contextQuery.trim().isEmpty()) ? contextQuery : count;
             log.info("知识库检索查询文本 | original={} | contextQuery={}", count, searchQuery);
             try {
                 com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.fs.company.domain.AiKnowledgeBase> lqw =
@@ -1899,60 +1899,38 @@ public class AiHookServiceImpl implements AiHookService {
                 if (kbList == null || kbList.isEmpty()) {
                     log.warn("向量知识库检索跳过: 当前租户下无知识库 | roleId={}", role.getRoleId());
                 } else {
-                    Set<Long> addedIds = new HashSet<>();
+                    // 1、向量只生成一次,避免 KB 循环内重复调用 embedding接口
+                    List<Float> vector = createEmbedding(searchQuery);
+                    // 2、关键词一次性汇总,避免重复抽取
+                    List<String> ctxKeywords = extractKeywords(searchQuery);
+                    List<String> curKeywords = extractKeywords(count);
+                    Set<String> allKeywords = new LinkedHashSet<>(curKeywords);
+                    allKeywords.addAll(ctxKeywords);
+                    // 3、以 collectionId+pointId 作为去重主键,重复命中保留最高分
+                    Map<String, Map<String, Object>> mergedRefMap = new LinkedHashMap<>();
                     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);
-                                    }
-                                }
-                            }
+                            List<Map<String, Object>> hits = searchQdrant(collectionName, vector, KB_VECTOR_TOP_K, KB_SCORE_THRESHOLD);
+                            mergeRefItems(mergedRefMap, hits, kb, "embedding");
                         }
-
-                        // 第二路:Payload关键词过滤搜索(从上下文和当前消息中提取关键词)
-                        List<String> keywords = extractKeywords(searchQuery);
-                        List<String> currentKeywords = extractKeywords(count);
-                        Set<String> allKeywords = new LinkedHashSet<>(currentKeywords);
-                        allKeywords.addAll(keywords);
+                        // 路二:Payload 关键词全文检索召回
                         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);
-                                        }
-                                    }
-                                }
+                                List<Map<String, Object>> hits = searchQdrantByPayload(collectionName, keyword, KB_KEYWORD_TOP_K);
+                                mergeRefItems(mergedRefMap, hits, kb, "fullText");
                             }
                         }
                     }
+                    // 4、按 score 降序、截取 topN,输出与 FastGPT 引用合并组件兼容的数组
+                    knowledgeBase = mergedRefMap.values().stream()
+                            .sorted((a, b) -> Double.compare(toScore(b.get("score")), toScore(a.get("score"))))
+                            .limit(KB_FINAL_TOP_N)
+                            .collect(Collectors.toList());
                 }
             } catch (Exception e) {
                 log.error("向量知识库检索失败 | roleId={} | content={}", role.getRoleId(), count, e);
@@ -1978,29 +1956,129 @@ public class AiHookServiceImpl implements AiHookService {
         messageList.add(message1);
     }
 
+    //========向量知识库检索参数阈值(调优入口)========
+    /** 向量召回每个 collection 取 topK */
+    private static final int KB_VECTOR_TOP_K = 5;
+    /** 关键词召回每个 keyword 取 topK */
+    private static final int KB_KEYWORD_TOP_K = 5;
+    /** 向量召回分数阈值 */
+    private static final double KB_SCORE_THRESHOLD = 0.4D;
+    /** 最终输出到 conversation.knowledgeBase 的最大条数 */
+    private static final int KB_FINAL_TOP_N = 10;
+
+    /**
+     * 合并 Qdrant 检索结果到去重 map(同一 pointId 保留最高分)。
+     */
+    private void mergeRefItems(Map<String, Map<String, Object>> merged,
+                               List<Map<String, Object>> hits,
+                               com.fs.company.domain.AiKnowledgeBase kb,
+                               String searchMode) {
+        if (hits == null || hits.isEmpty()) {
+            return;
+        }
+        for (Map<String, Object> hit : hits) {
+            Map<String, Object> ref = toFastGptRefItem(hit, kb, searchMode);
+            if (ref == null) {
+                continue;
+            }
+            String dedupKey = buildDedupKey(ref);
+            Map<String, Object> exist = merged.get(dedupKey);
+            if (exist == null || toScore(ref.get("score")) > toScore(exist.get("score"))) {
+                merged.put(dedupKey, ref);
+            }
+        }
+    }
+
+    /**
+     * 将单条 Qdrant 检索结果转换为 FastGPT 知识库搜索引用合并组件兼容的引用项。
+     */
+    private Map<String, Object> toFastGptRefItem(Map<String, Object> hit,
+                                                 com.fs.company.domain.AiKnowledgeBase kb,
+                                                 String searchMode) {
+        if (hit == null) {
+            return null;
+        }
+        Object payloadObj = hit.get("payload");
+        if (!(payloadObj instanceof Map)) {
+            return null;
+        }
+        Map<?, ?> payload = (Map<?, ?>) payloadObj;
+        Object qObj = payload.get("q");
+        Object aObj = payload.get("a");
+        if (qObj == null && aObj == null) {
+            return null;
+        }
+
+        Map<String, Object> ref = new LinkedHashMap<>();
+        Object id = hit.get("id");
+        ref.put("id", id == null ? "" : id.toString());
+        ref.put("q", qObj == null ? "" : qObj.toString());
+        ref.put("a", aObj == null ? "" : aObj.toString());
+        // FastGPT 引用合并组件需要的源信息
+        ref.put("sourceName", kb.getName() == null ? "" : kb.getName());
+        ref.put("sourceId", kb.getId() == null ? "" : kb.getId().toString());
+        ref.put("datasetId", kb.getCollectionId() == null ? "" : kb.getCollectionId());
+        ref.put("collectionId", kb.getCollectionName() == null ? "" : kb.getCollectionName());
+        Object chunkIndex = payload.get("chunkIndex");
+        ref.put("chunkIndex", chunkIndex instanceof Number ? ((Number) chunkIndex).intValue() : 0);
+
+        // FastGPT score 采用数组结构:[{type, value, index}]
+        double scoreVal = toScore(hit.get("score"));
+        List<Map<String, Object>> scoreArr = new ArrayList<>(1);
+        Map<String, Object> scoreItem = new LinkedHashMap<>();
+        scoreItem.put("type", searchMode);
+        scoreItem.put("value", scoreVal);
+        scoreItem.put("index", 0);
+        scoreArr.add(scoreItem);
+        ref.put("score", scoreArr);
+        return ref;
+    }
+
+    /** 以 collectionId+pointId 构建去重键,没有 id 时退化为 q+a 文本哈希 */
+    private String buildDedupKey(Map<String, Object> ref) {
+        Object collectionId = ref.get("collectionId");
+        Object id = ref.get("id");
+        if (id != null && !id.toString().isEmpty()) {
+            return collectionId + ":" + id;
+        }
+        return collectionId + ":" + ref.get("q") + ":" + ref.get("a");
+    }
+
+    /** 提取分数,兼容 Number 与 FastGPT score 数组结构 */
+    private double toScore(Object scoreObj) {
+        if (scoreObj instanceof Number) {
+            return ((Number) scoreObj).doubleValue();
+        }
+        if (scoreObj instanceof List) {
+            double max = 0D;
+            for (Object o : (List<?>) scoreObj) {
+                if (o instanceof Map) {
+                    Object v = ((Map<?, ?>) o).get("value");
+                    if (v instanceof Number) {
+                        double dv = ((Number) v).doubleValue();
+                        if (dv > max) {
+                            max = dv;
+                        }
+                    }
+                }
+            }
+            return max;
+        }
+        return 0D;
+    }
+
     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());
+        if (id != null) {
+            try {
+                return Long.parseLong(id.toString());
+            } catch (NumberFormatException ignore) {
             }
         }
-        return kbItem;
+        return null;
     }
 
     private List<String> extractKeywords(String text) {

+ 0 - 2
fs-service/src/main/java/com/fs/hisStore/service/impl/FsUserScrmServiceImpl.java

@@ -105,8 +105,6 @@ public class FsUserScrmServiceImpl implements IFsUserScrmService
     @Autowired
     private ICompanyUserCacheService companyUserCacheService;
     @Autowired
-    private ICompanyUserService companyUserService;
-    @Autowired
     private FsUserCourseVideoMapper userCourseVideoMapper;
 
     @Autowired

+ 4 - 0
fs-service/src/main/java/com/fs/qw/mapper/QwExternalContactMapper.java

@@ -665,4 +665,8 @@ public interface QwExternalContactMapper extends BaseMapper<QwExternalContact> {
                                                                   @Param("corpId")String corpId,@Param("qwUserId") String qwUserId);
 
     void updateQwExternalContactByExternalUserIdAndUserId(@Param("map") QwExternalContact externalContact);
+
+    List<QwExternalContact> selectQwExternalContactListBycreateTime(QwExternalContact qwExternalContact);
+
+    List<QwExternalContact> selectQwExternalContactListByCreateTimeNew(QwExternalContact qwExternalContact);
 }

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

@@ -117,8 +117,8 @@ public interface QwGroupChatMapper
     @Select("select chat_id,name from qw_group_chat where  corp_id=#{corpId}")
     List<QwGroupChatOptionsVO> selectGroupChatOptionsVOList(String corpId);
 
-    @Select("select chat_id,name from qw_group_chat where corp_id = #{corpId} and find_in_set(owner,#{qwUserIds})")
-    List<QwGroupChatOptionsVO> listAllByQwUserList(@Param("qwUserIds") String qwUserIds, @Param("corpId") String corpId);
+    @Select("select chat_id,name from qw_group_chat where corp_id = #{corpId} and find_in_set(owner,#{qwOpenUserIds})")
+    List<QwGroupChatOptionsVO> listAllByQwUserList(@Param("qwOpenUserIds") String qwOpenUserIds, @Param("corpId") String corpId);
 
     List<QwGroupChat> selectQwGroupChatByChatIds(@Param("ids") String[] ids);
 

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

@@ -50,7 +50,7 @@ public interface QwUserMapper extends BaseMapper<QwUser>
     @Select("select company_user_id,company_id,welcome_text,qw_user_name,send_msg_type from qw_user where id = #{id}")
     public QwUser selectQwUserByIdByWeComeText(@Param("id") Long id);
 
-    @Select("select * from qw_user where qw_user_id = #{qwUserId} and corp_id = #{corpId} ")
+    @Select("select * from qw_user where qw_open_user_id = #{qwUserId} and corp_id = #{corpId} ")
     public QwUser selectQwUserByIdByWeComeText2(@Param("qwUserId") String qwUserId, @Param("corpId") String corpId);
     /**
      * 根据companyUserId查询企微用户
@@ -508,7 +508,7 @@ public interface QwUserMapper extends BaseMapper<QwUser>
     @Select("select * from qw_user where qw_user_id=#{qwUserId} and corp_id =#{corpId} limit 1")
     QwUser selectQwUserEntityByQwUserIdAndCorId(@Param("qwUserId")String qwUserId,@Param("corpId") String corpId);
 
-    @Select("select * from qw_user where ipad_status = 1 and corp_id=#{corpId} and qw_user_id=#{qwUserId} limit 1 ")
+    @Select("select * from qw_user where ipad_status = 1 and corp_id=#{corpId} and qw_open_user_id=#{qwUserId} limit 1 ")
     QwUser selectQwUserAppKeyAndIdByCorpIdAndUserIdAndIpad(@Param("corpId")String corpId,@Param("qwUserId") String qwUserId);
 
     // 批量查询
@@ -521,4 +521,7 @@ public interface QwUserMapper extends BaseMapper<QwUser>
     // 批量更新
     int batchUpdateQwUser(@Param("list") List<QwUser> users);
 
+    @Select("select group_concat(qw_open_user_id) from qw_user where corp_id = #{corpId} and find_in_set(qw_user_id, #{qwUserIds})")
+    String selectQwOpenUserIdByQwUserIdAndCorpId(@Param("qwUserIds") String qwUserIds, @Param("corpId") String corpId);
+
 }

+ 33 - 1
fs-service/src/main/java/com/fs/qw/service/AsyncQwAiChatSopService.java

@@ -1,8 +1,11 @@
 package com.fs.qw.service;
 
 import com.alibaba.fastjson.JSON;
+import com.fs.common.config.RedisTenantContext;
+import com.fs.common.core.domain.model.TenantPrincipal;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.date.DateUtil;
+import com.fs.common.utils.spring.SpringUtils;
 import com.fs.company.service.ICompanyMiniappService;
 import com.fs.course.config.CourseConfig;
 import com.fs.course.domain.FsCourseLink;
@@ -30,14 +33,19 @@ import com.fs.sop.params.QwSopAutoByTags;
 import com.fs.sop.service.IQwSopTempVoiceService;
 import com.fs.sop.service.impl.SopUserLogsInfoServiceImpl;
 import com.fs.system.service.ISysConfigService;
+import com.fs.tenant.mapper.TenantInfoMapper;
 import com.fs.voice.utils.StringUtil;
+import com.fs.wxcid.utils.TenantHelper;
 import lombok.AllArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.scheduling.annotation.Async;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.stereotype.Service;
 
+import java.lang.reflect.Method;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.LocalTime;
@@ -47,6 +55,7 @@ import java.util.Collections;
 import java.util.Date;
 import java.util.List;
 
+
 @Slf4j
 @Service
 @AllArgsConstructor
@@ -92,11 +101,34 @@ public class AsyncQwAiChatSopService {
     @Autowired
     private QwExternalContactInfoMapper qwExternalContactInfoMapper;
 
+    @Autowired
+    private TenantInfoMapper tenantInfoMapper;
+
     @Async("threadPoolTaskExecutor")
     public void executeQwAiChatSop(QwSopAutoByTags qwSopAutoByTags, String userID,
                                    QwUser qwUser, String externalUserID, String externalContactName,
                                    Long externalId, Long fsUserId, LocalDate currentDate, LocalTime localTime) {
-
+        Long tenantId = RedisTenantContext.getTenantId();
+        if (tenantId != null) {
+            try {
+                TenantHelper.setTenantId(tenantId);
+                Object manager = SpringUtils.getBean("tenantDataSourceManager");
+                Method method = manager.getClass().getMethod("ensureSwitchByTenantId", Long.class);
+                method.invoke(manager, tenantId);
+                // 设置租户到 SecurityContext,供 TenantKeyRedisSerializer 自动为 Redis Key 加 tenantid 前缀
+                SecurityContextHolder.getContext().setAuthentication(
+                        new UsernamePasswordAuthenticationToken(
+                                new TenantPrincipal(TenantHelper.getTenantId()),
+                                null,
+                                Collections.emptyList()
+                        )
+                );
+                // 切换 Redis 租户上下文
+                RedisTenantContext.setTenantId(TenantHelper.getTenantId());
+            } catch (Exception e) {
+                log.error("callerResult4EasyCall 切换租户数据源失败: tenantId={}", TenantHelper.getTenantId(), e);
+            }
+        }
 
         QwExternalContact contact;
         if(externalId != null){

+ 4 - 0
fs-service/src/main/java/com/fs/qw/service/IQwExternalContactService.java

@@ -265,4 +265,8 @@ public interface IQwExternalContactService extends IService<QwExternalContact> {
     List<QwMandatoryRegistrParam> selectQwExternalContactMandatoryRegistrationByIds(String corpId);
 
     int batchUpdateQwExternalContactMandatoryRegistration(List<QwMandatoryRegistrParam> batchList);
+
+    List<QwExternalContact> selectQwExternalContactListBycreateTime(QwExternalContact qwExternalContact);
+
+    List<QwExternalContact> selectQwExternalContactListByCreateTimeNew(QwExternalContact qwExternalContact);
 }

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

@@ -77,7 +77,9 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
 import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -216,6 +218,7 @@ public class QwExternalContactServiceImpl extends ServiceImpl<QwExternalContactM
     @Autowired
     private QwExternalContactTransferCompanyAuditUserMapper transferCompanyAuditUserMapper;
 
+    @Lazy
     @Autowired
     private AsyncQwAiChatSopService asyncQwAiChatSopService;
     @Autowired
@@ -6042,6 +6045,17 @@ public class QwExternalContactServiceImpl extends ServiceImpl<QwExternalContactM
     public int batchUpdateQwExternalContactMandatoryRegistration(List<QwMandatoryRegistrParam> batchList) {
         return qwExternalContactMapper.batchUpdateQwExternalContactMandatoryRegistration( batchList);
     }
+
+    @Override
+    public List<QwExternalContact> selectQwExternalContactListBycreateTime(QwExternalContact qwExternalContact) {
+        return qwExternalContactMapper.selectQwExternalContactListBycreateTime(qwExternalContact);
+    }
+
+    @Override
+    public List<QwExternalContact> selectQwExternalContactListByCreateTimeNew(QwExternalContact qwExternalContact) {
+        return qwExternalContactMapper.selectQwExternalContactListByCreateTimeNew(qwExternalContact);
+    }
+
     @Override
     public R getRepeat(RepeatParam param) {
         List<QwExternalContact> list = qwExternalContactMapper.selectList(new QueryWrapper<QwExternalContact>().eq("external_user_id", param.getExternalUserId()));

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

@@ -413,7 +413,11 @@ public class QwGroupChatServiceImpl implements IQwGroupChatService
 
     @Override
     public List<QwGroupChatOptionsVO> listAllByQwUserList(String qwUserIds, String corpId, String sopId) {
-        List<QwGroupChatOptionsVO> list = qwGroupChatMapper.listAllByQwUserList(qwUserIds, corpId);
+
+        // qwUserIds 转换为  qwOpenUserIds
+        String qwOpenUserIds = qwUserMapper.selectQwOpenUserIdByQwUserIdAndCorpId(qwUserIds, corpId);
+
+        List<QwGroupChatOptionsVO> list = qwGroupChatMapper.listAllByQwUserList(qwOpenUserIds, corpId);
         if(StringUtils.isNotEmpty(sopId)){
             QwSop qwSop = sopMapper.selectQwSopById(sopId);
             List<String> chatIds;

+ 50 - 2
fs-service/src/main/resources/application-dev-test.yml

@@ -43,9 +43,57 @@ spring:
             druid:
                 # 主库数据源
                 master:
-                    url: jdbc:mysql://cq-cdb-8fjmemkb.sql.tencentcdb.com:27220/his?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&allowMultiQueries=true
+                    url: jdbc:mysql://139.186.77.83:3306/yLrz_saas_his_scrm?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&allowMultiQueries=true
+                    username: Rtroot
+                    password: Rtroot
+                # 初始连接数
+                initialSize: 5
+                # 最小连接池数量
+                minIdle: 10
+                # 最大连接池数量
+                maxActive: 20
+                # 配置获取连接等待超时的时间
+                maxWait: 60000
+                # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
+                timeBetweenEvictionRunsMillis: 60000
+                # 配置一个连接在池中最小生存的时间,单位是毫秒
+                minEvictableIdleTimeMillis: 300000
+                # 配置一个连接在池中最大生存的时间,单位是毫秒
+                maxEvictableIdleTimeMillis: 900000
+                # 配置检测连接是否有效
+                validationQuery: SELECT 1 FROM DUAL
+                testWhileIdle: true
+                testOnBorrow: false
+                testOnReturn: false
+                webStatFilter:
+                    enabled: true
+                statViewServlet:
+                    enabled: true
+                    # 设置白名单,不填则允许所有访问
+                    allow:
+                    url-pattern: /druid/*
+                    # 控制台管理用户名和密码
+                    login-username: fs
+                    login-password: 123456
+                filter:
+                    stat:
+                        enabled: true
+                        # 慢SQL记录
+                        log-slow-sql: true
+                        slow-sql-millis: 1000
+                        merge-sql: true
+                    wall:
+                        config:
+                            multi-statement-allow: true
+        easycall:
+            type: com.alibaba.druid.pool.DruidDataSource
+            driverClassName: com.mysql.cj.jdbc.Driver
+            druid:
+                # 主库数据源
+                master:
+                    url: jdbc:mysql://129.28.164.235:3306/easycallcenter365?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
                     username: root
-                    password: Ylrz_1q2w3e4r5t6y
+                    password: easycallcenter365
                 # 初始连接数
                 initialSize: 5
                 # 最小连接池数量

+ 20 - 1
fs-service/src/main/resources/mapper/company/CompanyAiWorkflowExecMapper.xml

@@ -230,9 +230,28 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         FROM company_ai_workflow_exec t1
         where t1.status = #{status}
           and t1.cid_group_no = #{groupNo}
-          and NOW() BETWEEN t1.runtime_range_start and t1.runtime_range_end
+          and CURTIME() BETWEEN t1.runtime_range_start and t1.runtime_range_end
     </select>
 
+    <!-- 原子认领:仅当当前状态等于期望状态时才更新为目标状态,影响行数=1 表示认领成功 -->
+    <update id="claimExecForRun">
+        update company_ai_workflow_exec
+        set status = #{targetStatus},
+            last_update_time = NOW()
+        where id = #{id}
+          and status = #{expectStatus}
+    </update>
+
+    <!-- 回扫超时认领任务:将卡在认领态且长时间未更新的记录重置为失败态 -->
+    <update id="resetTimeoutRunningExec">
+        update company_ai_workflow_exec
+        set status = #{failStatus},
+            last_update_time = NOW()
+        where cid_group_no = #{groupNo}
+          and status = #{runningStatus}
+          and last_update_time &lt; #{timeoutTime}
+    </update>
+
     <insert id="insertBatchInfo" useGeneratedKeys="true" keyProperty="id">
         INSERT INTO company_ai_workflow_exec (
         workflow_instance_id, workflow_id, current_node_key,

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

@@ -234,6 +234,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         inner join company_voice_robotic cvr on cvr.id = t1.robotic_id
         <where>
             <if test="roboticId != null">and robotic_id = #{roboticId}</if>
+            <if test="companyId != null"> and cvr.company_id = #{companyId}</if>
         </where>
         group by robotic_id
         order by t1.run_time desc
@@ -246,6 +247,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             sum(case when status = 2 and run_time &gt;= CURDATE() and run_time &lt; DATE_ADD(CURDATE(), INTERVAL 1 DAY) then 1 else 0 end) as todaySuccessCount
         from company_voice_robotic_call_log_callphone cp
             inner join company_voice_robotic cvr on cvr.id = cp.robotic_id
+        where cvr.company_id = #{companyId}
     </select>
 
 
@@ -254,8 +256,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         t1.*,
         t2.company_name,
         t3.nick_name as companyUserName,
-        cvr.name as robotic_name
+        cvr.name as robotic_name,
+        ce.user_id as customer_id
         FROM company_voice_robotic_call_log_callphone t1
+        left join company_voice_robotic_callees ce on ce.id = t1.caller_id
         left join company t2 on t1.company_id = t2.company_id
         left join company_user t3 on t3.user_id = t1.company_user_id
         left join company_voice_robotic cvr on cvr.id = t1.robotic_id
@@ -289,6 +293,48 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 
     </select>
 
+    <select id="listDecryptPhoneExport" resultType="com.fs.company.vo.CompanyVoiceRoboticCallLogCallPhoneDecryptQueryVO" parameterType="com.fs.company.domain.CompanyVoiceRoboticCallLogCallphone">
+        SELECT
+        cvr.name as robotic_name,
+        ce.user_name as user_name,
+        t1.intention as intention,
+        ce.phone as phone
+        FROM company_voice_robotic_call_log_callphone t1
+        left join company_voice_robotic_callees ce on ce.id = t1.caller_id
+        left join company_voice_robotic cvr on cvr.id = t1.robotic_id
+        where 1=1
+        <if test="roboticId != null">and t1.robotic_id = #{roboticId}</if>
+        <if test="callerId != null">and t1.caller_id = #{callerId}</if>
+        <if test="callerIds != null and callerIds.size() > 0">
+            AND t1.caller_id IN
+            <foreach collection='callerIds' item='item' open='(' separator=',' close=')'>
+                #{item}
+            </foreach>
+        </if>
+        <if test="callerNum != null and callerNum != ''">
+            and t1.caller_num like concat('%', #{callerNum}, '%')
+        </if>
+        <if test="encryptedPhone != null and encryptedPhone != ''">
+            and ce.phone = #{encryptedPhone}
+        </if>
+        <if test="intention != null and intention != ''">
+            and t1.intention = #{intention}
+        </if>
+        <if test="isConnected != null and isConnected == 1">
+            and t1.call_time &gt; 0
+        </if>
+        <if test="isConnected != null and isConnected == 0">
+            and (t1.call_time is null or t1.call_time = 0)
+        </if>
+        <if test="minCallTime != null">
+            and t1.call_time &gt;= #{minCallTime}
+        </if>
+        <if test="maxCallTime != null">
+            and t1.call_time &lt;= #{maxCallTime}
+        </if>
+        order by t1.run_time desc
+    </select>
+
     <select id="selectCompanyIdByBusinessId" resultType="Long">
         SELECT company_id FROM company_voice_robotic vr INNER JOIN company_voice_robotic_business rb ON vr.id = rb.robotic_id WHERE rb.id = 20 LIMIT 1
     </select>

+ 14 - 0
fs-service/src/main/resources/mapper/qw/QwExternalContactMapper.xml

@@ -836,6 +836,20 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             and user_id = #{qwUserId}
         order by create_time desc limit 1
     </select>
+    <select id="selectQwExternalContactListBycreateTime" resultMap="QwExternalContactResult">
+        select qw.* from qw_external_contact qw left join fastgpt_chat_session fc on qw.id = fc.qw_ext_id
+        where qw.create_time is not null and qw.corp_id = 'wwd9ecb73d85949846'
+        <if test="tagIds != null  and tagIds != ''"> and qw.tag_ids like concat('%', #{tagIds}, '%')</if>
+        <if test="isReply != null  and isReply != ''"> and fc.is_reply = #{isReply}</if>
+    </select>
+
+    <select id="selectQwExternalContactListByCreateTimeNew" resultMap="QwExternalContactResult">
+        select qw.* from qw_external_contact qw left join fastgpt_chat_session fc on qw.id = fc.qw_ext_id
+        where qw.create_time is not null and qw.corp_id = 'wwd9ecb73d85949846'
+        <if test="createTime != null"> and date_format(qw.create_time,'%Y-%m-%d') = date_format(#{createTime},'%Y-%m-%d') </if>
+        <if test="tagIds != null  and tagIds != ''"> and qw.tag_ids like concat('%', #{tagIds}, '%')</if>
+        <if test="isReply != null  and isReply != ''"> and fc.is_reply = #{isReply}</if>
+    </select>
 
     <update id="updateQwExternalContactByExternalUserIdAndUserId" parameterType="QwExternalContact">
         update qw_external_contact set application_external_user_id = #{map.applicationExternalUserId}

+ 19 - 7
fs-service/src/main/resources/mapper/third/TencentWordDetailMapper.xml

@@ -23,13 +23,25 @@
     </insert>
 
     <update id="updateBatchById">
-        <foreach collection="list" item="item" separator=";">
-            update tencent_word_detail
-            <set>
-                <if test="item.a != null">a = #{item.a},</if>
-                update_time = now()
-            </set>
-            where id = #{item.id}
+        UPDATE tencent_word_detail
+        <trim prefix="SET" suffixOverrides=",">
+            <trim prefix="a = CASE" suffix="END,">
+                <foreach collection="list" item="item">
+                    <if test="item.a != null">
+                        WHEN id = #{item.id} THEN #{item.a}
+                    </if>
+                </foreach>
+            </trim>
+            update_time = CASE
+            <foreach collection="list" item="item">
+                WHEN id = #{item.id} THEN now()
+            </foreach>
+            END
+        </trim>
+        WHERE id IN
+        <foreach collection="list" item="item" open="(" close=")" separator=",">
+            #{item.id}
         </foreach>
     </update>
+
 </mapper>

+ 124 - 82
fs-wx-task/src/main/java/com/fs/app/service/WxTaskService.java

@@ -46,6 +46,7 @@ import com.fs.wx.sop.mapper.WxSopUserMapper;
 import com.fs.wx.sop.vo.WxSopUserMsgGenVO;
 import com.fs.wxcid.dto.friend.AddContactParam;
 import com.fs.wxcid.service.FriendService;
+import com.fs.wxcid.utils.TenantHelper;
 import com.fs.wxcid.vo.AddContactVo;
 import com.fs.wxwork.dto.WxAddSearchDTO;
 import com.fs.wxwork.dto.WxSearchContactDTO;
@@ -131,6 +132,12 @@ public class WxTaskService {
     private final QwExternalContactMapper qwExternalContactMapper;
     private final CompanyAiWorkflowExecLogMapper companyAiWorkflowExecLogMapper;
 
+    /** 加微延时扫描分布式锁 key 前缀,按 租户id:分组 隔离,避免多实例/重入重复扫描同批延时key */
+    private static final String ADD_WX_DELAY_LOCK_PREFIX = "cid_workflow:addwx_delay_lock:";
+
+    /** 企微加微延时扫描分布式锁 key 前缀,按 租户id:分组 隔离 */
+    private static final String QW_ADD_WX_DELAY_LOCK_PREFIX = "cid_workflow:qwaddwx_delay_lock:";
+
     public void addWx(List<Long> accountIdList) {
         log.info("==========执行加微信任务开始==========");
         String json = sysConfigService.selectConfigByKey("wx.config");
@@ -851,53 +858,69 @@ public class WxTaskService {
      * 扫描工作流延时任务
      */
     public void cidWorkflowAddWxRun() {
-        log.info("===========工作流延时任务开始扫描===========");
-//        String delayAddWxKeyPrefix = AiAddWxTaskNode.getDelayAddWxKeyPrefix(cidGroupNo,null) + "*";
-////        Set<String> keys = redisKeyScanner.scanMatchKey(delayAddWxKeyPrefix);
-//        Collection<String> keys = redisCache2.keys(delayAddWxKeyPrefix);
-        // 扫描新加微节点的延时Key
-        String delayAddWxNewKeyPrefix = AiAddWxTaskNewNode.getDelayAddWxKeyPrefix(cidGroupNo,null) + "*";
-        Collection<String> keys = redisCache2.keys(delayAddWxNewKeyPrefix);
-        log.info("cidWorkflowAddWxRun共扫描到 {} 个待处理键", keys.size());
-        // 本地缓存已查询的任务暂停状态,避免同一批次重复查询
-        Map<Long, Boolean> pausedCache = new ConcurrentHashMap<>();
-        keys.parallelStream().forEach(key -> {
-            try {
-                //doExec
-                CompletableFuture.runAsync(()->{
-                    try {
-                        ExecutionContext context = redisCache2.getCacheObject(key);
-                        if (context == null) {
-                            log.warn("工作流延时任务context为空,跳过 - key: {}", key);
-                            redisCache2.deleteObject(key);
-                            return;
-                        }
-                        // 任务暂停守卫检查(roboticId即CompanyVoiceRobotic.id,是实际暂停操作的目标)
-                        Long taskId = context.getVariable("roboticId", Long.class);
-                        if (taskId != null && pausedCache.computeIfAbsent(taskId, id -> companyVoiceRoboticService.isTaskPaused(id))) {
-                            // 延时key是时间分片前缀,下一分钟就不会再扫到,直接删除
-                            // 同步context信息到DB exec,供恢复时resumePausedInstances使用
-                            context.setVariable("callSource", "addWxTimer");
-                            context.setVariable("_delayTargetNodeKey", context.getCurrentNodeKey());
-                            companyWorkflowEngine.updateExecVariables(context.getWorkflowInstanceId(), context.getVariables());
-                            log.info("任务已暂停,删除延时key并同步exec,等待恢复时从DB重建 - taskId: {}, key: {}", taskId, key);
+        // 分布式锁:按 租户id:分组 隔离,拿不到锁说明已有实例在扫描,直接跳过本轮(延时key下轮仍可扫到,不丢失)
+        String lockKey = ADD_WX_DELAY_LOCK_PREFIX + TenantHelper.getTenantId() + ":" + cidGroupNo;
+        RLock methodLock = redissonClient.getLock(lockKey);
+        boolean locked = false;
+        try {
+            // waitTime=0 拿不到立即返回;leaseTime=-1 启用看门狗自动续期,避免大数据量执行超时被提前释放
+            locked = methodLock.tryLock(0, -1, TimeUnit.SECONDS);
+            if (!locked) {
+                log.info("cidWorkflowAddWxRun 已有实例在执行,跳过本轮 - lockKey: {}", lockKey);
+                return;
+            }
+            log.info("===========工作流延时任务开始扫描===========");
+            // 扫描新加微节点的延时Key
+            String delayAddWxNewKeyPrefix = AiAddWxTaskNewNode.getDelayAddWxKeyPrefix(cidGroupNo,null) + "*";
+            Collection<String> keys = redisCache2.keys(delayAddWxNewKeyPrefix);
+            log.info("cidWorkflowAddWxRun共扫描到 {} 个待处理键", keys.size());
+            // 本地缓存已查询的任务暂停状态,避免同一批次重复查询
+            Map<Long, Boolean> pausedCache = new ConcurrentHashMap<>();
+            keys.parallelStream().forEach(key -> {
+                try {
+                    //doExec
+                    CompletableFuture.runAsync(()->{
+                        try {
+                            ExecutionContext context = redisCache2.getCacheObject(key);
+                            if (context == null) {
+                                log.warn("工作流延时任务context为空,跳过 - key: {}", key);
+                                redisCache2.deleteObject(key);
+                                return;
+                            }
+                            // 任务暂停守卫检查(roboticId即CompanyVoiceRobotic.id,是实际暂停操作的目标)
+                            Long taskId = context.getVariable("roboticId", Long.class);
+                            if (taskId != null && pausedCache.computeIfAbsent(taskId, id -> companyVoiceRoboticService.isTaskPaused(id))) {
+                                // 延时key是时间分片前缀,下一分钟就不会再扫到,直接删除
+                                // 同步context信息到DB exec,供恢复时resumePausedInstances使用
+                                context.setVariable("callSource", "addWxTimer");
+                                context.setVariable("_delayTargetNodeKey", context.getCurrentNodeKey());
+                                companyWorkflowEngine.updateExecVariables(context.getWorkflowInstanceId(), context.getVariables());
+                                log.info("任务已暂停,删除延时key并同步exec,等待恢复时从DB重建 - taskId: {}, key: {}", taskId, key);
+                                redisCache2.deleteObject(key);
+                                return;
+                            }
+                            context.setVariable("callRedisKey",key);
+                            context.setVariable("callSource","addWxTimer");
+                            companyWorkflowEngine.timeDoExecute(context.getWorkflowInstanceId(),context.getCurrentNodeKey(),context.getVariables());
                             redisCache2.deleteObject(key);
-                            return;
+                        } catch (Exception e) {
+                            log.error("处理工作流延时任务异常 - key: {}", key, e);
                         }
-                        context.setVariable("callRedisKey",key);
-                        context.setVariable("callSource","addWxTimer");
-                        companyWorkflowEngine.timeDoExecute(context.getWorkflowInstanceId(),context.getCurrentNodeKey(),context.getVariables());
-                        redisCache2.deleteObject(key);
-                    } catch (Exception e) {
-                        log.error("处理工作流延时任务异常 - key: {}", key, e);
-                    }
-                }, cidWorkFlowExecutor);
+                    }, cidWorkFlowExecutor);
 
-            } catch (Exception ex) {
-                log.error("处理工作流延时任务异常 - key: {}", key, ex);
+                } catch (Exception ex) {
+                    log.error("处理工作流延时任务异常 - key: {}", key, ex);
+                }
+            });
+            log.info("===========工作流延时任务扫描结束===========");
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.warn("cidWorkflowAddWxRun 获取分布式锁被中断 - lockKey: {}", lockKey, e);
+        } finally {
+            if (locked && methodLock.isHeldByCurrentThread()) {
+                methodLock.unlock();
             }
-        });
-        log.info("===========工作流延时任务扫描结束===========");
+        }
     }
 
     /**
@@ -1677,49 +1700,68 @@ public class WxTaskService {
      * 扫描企微加微工作流延时任务
      */
     public void cidWorkflowQwAddWxRun() {
-        log.info("===========企微加微工作流延时任务开始扫描===========");
-        String delayAddWxKeyPrefix = AiQwAddWxTaskNode.getDelayAddWxKeyPrefix(cidGroupNo,null) + "*";
-        Collection<String> keys = redisCache2.keys(delayAddWxKeyPrefix);
-        log.info("企微加微共扫描到 {} 个待处理键", keys.size());
-        // 本地缓存已查询的任务暂停状态,避免同一批次重复查询
-        Map<Long, Boolean> pausedCache = new ConcurrentHashMap<>();
-        keys.parallelStream().forEach(key -> {
-            try {
-                //doExec
-                CompletableFuture.runAsync(()->{
-                    try {
-                        ExecutionContext context = redisCache2.getCacheObject(key);
-                        if (context == null) {
-                            log.warn("企微加微工作流延时任务context为空,跳过 - key: {}", key);
-                            redisCache2.deleteObject(key);
-                            return;
-                        }
-                        // 任务暂停守卫检查(roboticId即CompanyVoiceRobotic.id,是实际暂停操作的目标)
-                        Long taskId = context.getVariable("roboticId", Long.class);
-                        if (taskId != null && pausedCache.computeIfAbsent(taskId, id -> companyVoiceRoboticService.isTaskPaused(id))) {
-                            // 延时key是时间分片前缀,下一分钟就不会再扫到,直接删除
-                            // 同步context信息到DB exec,供恢复时resumePausedInstances使用
-                            context.setVariable("callSource", "qwAddWxTimer");
-                            context.setVariable("_delayTargetNodeKey", context.getCurrentNodeKey());
-                            companyWorkflowEngine.updateExecVariables(context.getWorkflowInstanceId(), context.getVariables());
-                            log.info("任务已暂停,删除延时key并同步exec,等待恢复时从DB重建 - taskId: {}, key: {}", taskId, key);
+        // 分布式锁:按 租户id:分组 隔离,拿不到锁说明已有实例在扫描,直接跳过本轮(延时key下轮仍可扫到,不丢失)
+        String lockKey = QW_ADD_WX_DELAY_LOCK_PREFIX + TenantHelper.getTenantId() + ":" + cidGroupNo;
+        RLock methodLock = redissonClient.getLock(lockKey);
+        boolean locked = false;
+        try {
+            // waitTime=0 拿不到立即返回;leaseTime=-1 启用看门狗自动续期,避免大数据量执行超时被提前释放
+            locked = methodLock.tryLock(0, -1, TimeUnit.SECONDS);
+            if (!locked) {
+                log.info("cidWorkflowQwAddWxRun 已有实例在执行,跳过本轮 - lockKey: {}", lockKey);
+                return;
+            }
+            log.info("===========企微加微工作流延时任务开始扫描===========");
+            String delayAddWxKeyPrefix = AiQwAddWxTaskNode.getDelayAddWxKeyPrefix(cidGroupNo,null) + "*";
+            Collection<String> keys = redisCache2.keys(delayAddWxKeyPrefix);
+            log.info("企微加微共扫描到 {} 个待处理键", keys.size());
+            // 本地缓存已查询的任务暂停状态,避免同一批次重复查询
+            Map<Long, Boolean> pausedCache = new ConcurrentHashMap<>();
+            keys.parallelStream().forEach(key -> {
+                try {
+                    //doExec
+                    CompletableFuture.runAsync(()->{
+                        try {
+                            ExecutionContext context = redisCache2.getCacheObject(key);
+                            if (context == null) {
+                                log.warn("企微加微工作流延时任务context为空,跳过 - key: {}", key);
+                                redisCache2.deleteObject(key);
+                                return;
+                            }
+                            // 任务暂停守卫检查(roboticId即CompanyVoiceRobotic.id,是实际暂停操作的目标)
+                            Long taskId = context.getVariable("roboticId", Long.class);
+                            if (taskId != null && pausedCache.computeIfAbsent(taskId, id -> companyVoiceRoboticService.isTaskPaused(id))) {
+                                // 延时key是时间分片前缀,下一分钟就不会再扫到,直接删除
+                                // 同步context信息到DB exec,供恢复时resumePausedInstances使用
+                                context.setVariable("callSource", "qwAddWxTimer");
+                                context.setVariable("_delayTargetNodeKey", context.getCurrentNodeKey());
+                                companyWorkflowEngine.updateExecVariables(context.getWorkflowInstanceId(), context.getVariables());
+                                log.info("任务已暂停,删除延时key并同步exec,等待恢复时从DB重建 - taskId: {}, key: {}", taskId, key);
+                                redisCache2.deleteObject(key);
+                                return;
+                            }
+                            context.setVariable("callRedisKey",key);
+                            context.setVariable("callSource","qwAddWxTimer");
+                            companyWorkflowEngine.timeDoExecute(context.getWorkflowInstanceId(),context.getCurrentNodeKey(),context.getVariables());
                             redisCache2.deleteObject(key);
-                            return;
+                        } catch (Exception e) {
+                            log.error("处理工作流延时任务异常 - key: {}", key, e);
                         }
-                        context.setVariable("callRedisKey",key);
-                        context.setVariable("callSource","qwAddWxTimer");
-                        companyWorkflowEngine.timeDoExecute(context.getWorkflowInstanceId(),context.getCurrentNodeKey(),context.getVariables());
-                        redisCache2.deleteObject(key);
-                    } catch (Exception e) {
-                        log.error("处理工作流延时任务异常 - key: {}", key, e);
-                    }
-                }, cidWorkFlowExecutor);
+                    }, cidWorkFlowExecutor);
 
-            } catch (Exception ex) {
-                log.error("处理工作流延时任务异常 - key: {}", key, ex);
+                } catch (Exception ex) {
+                    log.error("处理工作流延时任务异常 - key: {}", key, ex);
+                }
+            });
+            log.info("===========工作流延时任务扫描结束===========");
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.warn("cidWorkflowQwAddWxRun 获取分布式锁被中断 - lockKey: {}", lockKey, e);
+        } finally {
+            if (locked && methodLock.isHeldByCurrentThread()) {
+                methodLock.unlock();
             }
-        });
-        log.info("===========工作流延时任务扫描结束===========");
+        }
     }