ソースを参照

企微聊天代码同步

Long 2 日 前
コミット
e2a6297ca3
34 ファイル変更1214 行追加62 行削除
  1. 130 0
      fs-company/src/main/java/com/fs/company/controller/qw/QwMsgController.java
  2. 4 0
      fs-qw-api-msg/pom.xml
  3. 170 3
      fs-qw-api-msg/src/main/java/com/fs/app/controller/QwMsgController.java
  4. 101 0
      fs-qw-api-msg/src/main/java/com/fs/app/socket/QwImSocket.java
  5. 22 0
      fs-qw-api-msg/src/main/java/com/fs/app/socket/configurator/QwImConfigurator.java
  6. 160 0
      fs-qw-api-msg/src/main/java/com/fs/app/util/AudioUtils.java
  7. 2 1
      fs-qw-api-msg/src/main/java/com/fs/framework/config/SecurityConfig.java
  8. 17 0
      fs-qw-api-msg/src/main/java/com/fs/framework/config/WebSocketConfig.java
  9. 4 0
      fs-service/src/main/java/com/fs/course/param/FsCourseLinkMiniParam.java
  10. 4 0
      fs-service/src/main/java/com/fs/course/param/FsCourseListBySidebarParam.java
  11. 2 0
      fs-service/src/main/java/com/fs/course/service/IFsUserCourseVideoService.java
  12. 24 0
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  13. 27 0
      fs-service/src/main/java/com/fs/fastGpt/service/AiHookService.java
  14. 146 0
      fs-service/src/main/java/com/fs/fastGpt/service/impl/AiHookServiceImpl.java
  15. 3 0
      fs-service/src/main/java/com/fs/qw/domain/QwUser.java
  16. 27 0
      fs-service/src/main/java/com/fs/qw/enums/MsgType.java
  17. 11 0
      fs-service/src/main/java/com/fs/qw/mapper/QwExternalContactMapper.java
  18. 8 0
      fs-service/src/main/java/com/fs/qw/mapper/QwSessionMapper.java
  19. 6 0
      fs-service/src/main/java/com/fs/qw/param/QwMsgSendParam.java
  20. 2 0
      fs-service/src/main/java/com/fs/qw/param/QwSessionParam.java
  21. 13 0
      fs-service/src/main/java/com/fs/qw/service/IQwExternalContactService.java
  22. 21 0
      fs-service/src/main/java/com/fs/qw/service/impl/QwExternalContactServiceImpl.java
  23. 249 29
      fs-service/src/main/java/com/fs/qw/service/impl/QwMsgServiceImpl.java
  24. 10 1
      fs-service/src/main/java/com/fs/qw/vo/QwContactListVO.java
  25. 5 0
      fs-service/src/main/java/com/fs/qw/vo/QwMessageListVO.java
  26. 0 22
      fs-service/src/main/java/com/fs/statis/param/WatchCourseStatisticsParam.java
  27. 0 1
      fs-service/src/main/java/com/fs/statis/service/impl/StatisticsServiceImpl.java
  28. 13 0
      fs-service/src/main/java/com/fs/wxwork/dto/WxWorkMessageDTO.java
  29. 1 1
      fs-service/src/main/java/com/fs/wxwork/service/WxWorkService.java
  30. 2 2
      fs-service/src/main/java/com/fs/wxwork/service/WxWorkServiceImpl.java
  31. 1 1
      fs-service/src/main/resources/application-config-dev.yml
  32. 7 0
      fs-service/src/main/resources/mapper/qw/QwExternalContactMapper.xml
  33. 16 0
      fs-service/src/main/resources/mapper/qw/QwSessionMapper.xml
  34. 6 1
      fs-service/src/main/resources/mapper/qw/QwUserMapper.xml

+ 130 - 0
fs-company/src/main/java/com/fs/company/controller/qw/QwMsgController.java

@@ -1,23 +1,39 @@
 package com.fs.company.controller.qw;
 
 import com.fs.common.annotation.Log;
+import com.fs.common.annotation.RepeatSubmit;
 import com.fs.common.core.controller.BaseController;
 import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.page.TableDataInfo;
 import com.fs.common.enums.BusinessType;
+import com.fs.common.exception.CustomException;
 import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.course.param.FsCourseLinkMiniParam;
+import com.fs.course.param.FsCourseListBySidebarParam;
+import com.fs.course.service.IFsUserCourseService;
+import com.fs.course.service.IFsUserCourseVideoService;
+import com.fs.course.vo.FsCourseListBySidebarVO;
+import com.fs.course.vo.FsCourseVideoListBySidebarVO;
 import com.fs.framework.security.LoginUser;
 import com.fs.framework.service.TokenService;
+import com.fs.qw.domain.QwExternalContact;
+import com.fs.qw.domain.QwExternalContactInfo;
 import com.fs.qw.domain.QwMsg;
 import com.fs.qw.domain.QwUser;
 import com.fs.qw.param.QwMsgSendParam;
 import com.fs.qw.param.QwSessionParam;
+import com.fs.qw.service.IQwExternalContactInfoService;
+import com.fs.qw.service.IQwExternalContactService;
 import com.fs.qw.service.IQwMsgService;
+import com.fs.qw.service.IQwUserService;
 import com.fs.qw.vo.QwContactListVO;
 import com.fs.qw.vo.QwMessageListVO;
+import com.fs.statis.service.IStatisticsService;
+import com.fs.statistics.dto.WatchCourseStatisticsDTO;
+import com.fs.statistics.param.WatchCourseStatisticsParam;
 import com.github.pagehelper.PageHelper;
 import com.github.pagehelper.PageInfo;
 import io.swagger.annotations.Api;
@@ -28,6 +44,7 @@ import org.springframework.web.bind.annotation.*;
 
 import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 
 /**
  * 企微聊天记录Controller
@@ -44,6 +61,18 @@ public class QwMsgController extends BaseController
     private IQwMsgService qwMsgService;
     @Autowired
     private TokenService tokenService;
+    @Autowired
+    private IQwExternalContactInfoService qwExternalContactInfoService;
+    @Autowired
+    private IQwExternalContactService qwExternalContactService;
+    @Autowired
+    private IQwUserService qwUserService;
+    @Autowired
+    private IStatisticsService statisticsService;
+    @Autowired
+    private IFsUserCourseService fsUserCourseService;
+    @Autowired
+    private IFsUserCourseVideoService fsUserCourseVideoService;
 
     /**
      * 查询企微聊天记录列表
@@ -160,4 +189,105 @@ public class QwMsgController extends BaseController
         QwContactListVO data = qwMsgService.selectQwSessionBycId(param.getConversationId(),param.getUserId());
         return R.ok().put("data",data);
     }
+
+    @GetMapping("/getQwExternalContactDetails")
+    public R getQwExternalContactDetails(@RequestParam(value = "qwExternalContactId") Long qwExternalContactId){
+        return R.ok().put("data", qwExternalContactService.getQwExternalContactDetailsById(qwExternalContactId));
+    }
+
+    @GetMapping("/getQwUserInfo")
+    @ApiOperation("获取企微用户信息")
+    public R getQwUserInfo(@RequestParam(value = "qwExternalContactId") Long qwExternalContactId){
+        if(qwExternalContactId == null) {
+            throw new CustomException("企微外部联系人id不能为空!");
+        }
+
+        QwExternalContactInfo contactInfo = qwExternalContactInfoService.selectQwExternalContactInfoByExternalContactId(qwExternalContactId);
+        if (contactInfo==null){
+            contactInfo = new QwExternalContactInfo();
+            contactInfo.setExternalContactId(qwExternalContactId);
+            qwExternalContactInfoService.insertQwExternalContactInfo(contactInfo);
+        }
+
+        // 已购状态
+        Boolean isBuy = qwExternalContactService.getBuyStatusByExtId(qwExternalContactId);
+        contactInfo.setIsBuy(isBuy ? "是" : "否");
+
+        return R.ok().put("moreInfo",contactInfo);
+    }
+
+    @PostMapping("/updateQwUserInfo")
+    @ApiOperation("更新企微用户信息")
+    public R updateQwUserInfo(@RequestBody QwExternalContactInfo qwExternalContactInfo){
+        if(qwExternalContactInfo.getExternalContactId() == null) {
+            throw new CustomException("企微外部联系人id不能为空!");
+        }
+        qwExternalContactInfoService.updateQwExternalContactInfoByExternalContactId(qwExternalContactInfo);
+        return R.ok();
+    }
+
+    @PostMapping("/course/watch")
+    @ApiOperation("查询看课记录")
+    public R queryCourseWatchStatistics(@RequestBody WatchCourseStatisticsParam param) {
+        if(param.getQwExternalContactId() == null) {
+            throw new CustomException("外部联系人id为空!");
+        }
+
+        WatchCourseStatisticsDTO watchCourseStatisticsDTO = statisticsService.queryWatchCourse(param);
+
+        return R.ok().put("data",watchCourseStatisticsDTO);
+    }
+
+    @PostMapping("/getFsCourseListBySidebar")
+    @ApiOperation("获取视频课程下拉列表 侧边栏")
+    public R getFsCourseListBySidebar(@RequestBody FsCourseListBySidebarParam param) {
+
+        QwExternalContact externalContact = qwExternalContactService.getById(param.getExtId());
+        QwUser qwUser = qwUserService.selectQwUserById(externalContact.getQwUserId());
+
+        if (qwUser == null || qwUser.getCompanyId() == null) {
+            return R.error("员工未绑定 销售公司 或 未获取到员工信息,请重试!");
+        }
+        param.setCompanyId(qwUser.getCompanyId());
+
+        PageHelper.startPage(param.getPageNum(), param.getPageSize());
+        List<FsCourseListBySidebarVO> fsCourseListBySidebar = fsUserCourseService.getFsCourseListBySidebar(param);
+        PageInfo<FsCourseListBySidebarVO> result = new PageInfo<>(fsCourseListBySidebar);
+        return R.ok().put("data", result);
+    }
+
+    @PostMapping("/getFsCourseVideoListBySidebar")
+    @ApiOperation("获取视频课程的课节下拉列表 侧边栏")
+    public R getFsCourseVideoListBySidebar(@RequestBody FsCourseListBySidebarParam param) {
+
+        if (param.getCourseId()==null){
+            return R.error("课程id不能为空");
+        }
+
+        PageHelper.startPage(param.getPageNum(), param.getPageSize());
+        List<FsCourseVideoListBySidebarVO> videoListBySidebar = fsUserCourseVideoService.getFsCourseVideoListBySidebar(param);
+        PageInfo<FsCourseVideoListBySidebarVO> result = new PageInfo<>(videoListBySidebar);
+        return R.ok().put("data", result);
+    }
+
+    /**
+     * 创建 发客户小程序
+     */
+    @RepeatSubmit
+    @PostMapping("/createMiniLink")
+    public R createMiniLink(@RequestBody FsCourseLinkMiniParam param) {
+
+        if (Objects.isNull(param.getCourseId())){
+            return R.error("课程id不能为空");
+        }
+        if (Objects.isNull(param.getVideoId())){
+            return R.error("视频id不能为空");
+        }
+
+        if (Objects.isNull(param.getExtId())){
+            return R.error("客户id不能为空");
+        }
+
+        return fsUserCourseVideoService.createMiniLinkByQwIm(param);
+    }
 }

+ 4 - 0
fs-qw-api-msg/pom.xml

@@ -117,6 +117,10 @@
             <artifactId>vosk</artifactId>
             <version>0.3.32</version>
         </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-websocket</artifactId>
+        </dependency>
         <dependency>
             <groupId>com.fs</groupId>
             <artifactId>fs-qw-api</artifactId>

+ 170 - 3
fs-qw-api-msg/src/main/java/com/fs/app/controller/QwMsgController.java

@@ -2,8 +2,11 @@ package com.fs.app.controller;
 
 import cn.hutool.core.util.StrUtil;
 import com.alibaba.fastjson.JSON;
+import com.fs.app.socket.QwImSocket;
+import com.fs.app.util.AudioUtils;
 import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
+import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.uuid.IdUtils;
 import com.fs.fastGpt.domain.FastGptRole;
 import com.fs.fastGpt.service.AiHookService;
@@ -21,6 +24,7 @@ import com.fs.qw.mapper.QwUserMapper;
 import com.fs.qw.service.IQwExternalContactService;
 import com.fs.qw.service.IQwUserService;
 import com.fs.qw.service.IQwUserVoiceLogService;
+import com.fs.qw.vo.QwMessageListVO;
 import com.fs.sop.mapper.QwSopLogsMapper;
 import com.fs.sop.mapper.SopUserLogsInfoMapper;
 import com.fs.sop.params.GetQwSopLogsByJsApiParam;
@@ -249,6 +253,7 @@ public class QwMsgController {
                 qwUser.setId(id);
                 qwUser.setVid(jsonObject.get("Vid").toString());
                 qwUser.setIpadStatus(1);
+                qwUser.setAvatar(jsonObject.get("avatar").toString());
                 qwUserMapper.updateQwUser(qwUser);
                 log.info("id:{}, 存Vid", id);
                 redisCache.setCacheObject("qrCodeUid:"+wxWorkMsgResp.getUuid(),104001,10, TimeUnit.MINUTES);
@@ -304,14 +309,27 @@ public class QwMsgController {
                 if (wxWorkMessageDTO.getReferid()!=0){
                     break;
                 }
+
+                Long receiver = wxWorkMessageDTO.getReceiver();
+                Long sender = wxWorkMessageDTO.getSender();
+
+                // 1客户 2销售
+                int sendType = 2;
+
+                // 消息发送者用户ID
+                Long userId = receiver;
+                if (2000000000000000L - receiver > 0){
+                    sendType = 1;
+                    userId = sender;
+                }
+
                 if (wxWorkMessageDTO.getMsgtype()==2||wxWorkMessageDTO.getMsgtype()==0||wxWorkMessageDTO.getMsgtype()==16||wxWorkMessageDTO.getMsgtype() == 101||wxWorkMessageDTO.getMsgtype() == 104){
 
                     String content = wxWorkMessageDTO.getContent();
                     log.info("id:{}, 接收人:"+wxWorkMessageDTO.getReceiver(), id);
                     log.info("id:{}, 发送人:"+wxWorkMessageDTO.getSender(), id);
                     log.info("id:{}, 内容:"+content, id);
-                    Long receiver = wxWorkMessageDTO.getReceiver();
-                    Long sender = wxWorkMessageDTO.getSender();
+
                     if(wxWorkMessageDTO.getMsgtype()==16){
                         WxwSpeechToTextEntityDTO ste = new WxwSpeechToTextEntityDTO();
                         ste.setMsgid(wxWorkMessageDTO.getMsg_id());
@@ -358,7 +376,6 @@ public class QwMsgController {
                     if (wxWorkMessageDTO.getRecordtype()==null){
                         break;
                     }
-                    Long receiver = wxWorkMessageDTO.getReceiver();
                     Long extId=null;
                     long totalSeconds=0L;
                     if (2000000000000000L-receiver>0){
@@ -404,6 +421,27 @@ public class QwMsgController {
                     qwUserVoiceLogService.addQuUserVoiceByIpadCallback(id,extId,recordType,totalSeconds,wxWorkMsgResp.getUuid());
                 }
 
+                // 处理文本消息
+                if (wxWorkMessageDTO.getMsgtype() == 2 || wxWorkMessageDTO.getMsgtype() == 0) {
+                    processTextMessage(id, userId, wxWorkMessageDTO.getContent(), wxWorkMsgResp, sendType);
+                }
+                // 语音消息
+                if (wxWorkMessageDTO.getMsgtype() == 16) {
+                    processVoiceMessage(serverId, wxWorkMessageDTO.getContent(), wxWorkMessageDTO, wxWorkMsgResp, id, userId, sendType);
+                }
+                // 图片消息
+                if (wxWorkMessageDTO.getMsgtype() == 101){
+                    processImageMessage(serverId, wxWorkMessageDTO, wxWorkMsgResp, id, userId, sendType);
+                }
+                // gif 表情消息
+                if (wxWorkMessageDTO.getMsgtype() == 104){
+                    processEmotionDynamicMessage(wxWorkMessageDTO, wxWorkMsgResp, id, userId, sendType);
+                }
+                // 小程序消息
+                if (wxWorkMessageDTO.getMsgtype() == 78) {
+                    processMiniAppMessage(serverId, wxWorkMessageDTO, wxWorkMsgResp, id, userId, sendType);
+                }
+
                 break;
 
         }
@@ -528,4 +566,133 @@ public class QwMsgController {
         }
     }
 
+    /**
+     * 处理文本消息
+     * @param id                企微用户ID
+     * @param userId            消息发送者ID
+     * @param content           消息内容
+     * @param wxWorkMsgResp     回调信息对象
+     * @param sendType          发送者类型 1客户 2销售
+     */
+    private void processTextMessage(Long id, Long userId, String content, WxWorkMsgResp wxWorkMsgResp, Integer sendType) {
+        // 保存聊天消息
+        QwMessageListVO message = aiHookService.saveQwMsg(id, userId, content, wxWorkMsgResp.getUuid(), sendType, wxWorkMsgResp.getJson(), 1);
+        QwImSocket.broadcast(message);
+    }
+
+    /**
+     * 处理语音消息
+     * @param serverId          服务器ID
+     * @param wxWorkMessageDTO  消息DTO
+     * @param content           翻译后的内容
+     * @param wxWorkMsgResp     回调信息对象
+     * @param id                企微用户ID
+     * @param userId            消息发送者ID
+     * @param sendType          发送者类型 1客户 2销售
+     */
+    private void processVoiceMessage(Long serverId, String content, WxWorkMessageDTO wxWorkMessageDTO, WxWorkMsgResp wxWorkMsgResp, Long id, Long userId, Integer sendType) {
+        String voiceFileName = IdUtils.fastSimpleUUID() + ".silk";
+        WxWorkResponseDTO<String> fileUrlResp =
+                aiHookService.getFileUrl(wxWorkMsgResp.getUuid(), wxWorkMessageDTO.getVoice_id(), wxWorkMessageDTO.getAes_key(), 5, voiceFileName, wxWorkMessageDTO.getVoice_size(), serverId);
+        if (fileUrlResp.getErrcode() != 0) {
+            log.warn("获取语音地址失败: {}", fileUrlResp.getErrmsg());
+            return;
+        }
+
+        // silk转map3
+        String url = AudioUtils.convertSilk2Mp3(fileUrlResp.getData());
+        if (StringUtils.isBlank(url)) {
+            log.warn("转换silk语音格式失败");
+            return;
+        }
+
+        // 转换内容为空时再尝试一次
+        if (StringUtils.isBlank(content)) {
+            WxwSpeechToTextEntityDTO ste = new WxwSpeechToTextEntityDTO();
+            ste.setMsgid(wxWorkMessageDTO.getMsg_id());
+            ste.setUuid(wxWorkMsgResp.getUuid());
+            WxWorkResponseDTO<WxwSpeechToTextEntityRespDTO> dto = wxWorkService.SpeechToTextEntity(ste, serverId);
+            content = dto.getData().getText();
+        }
+
+        com.alibaba.fastjson.JSONObject json = new com.alibaba.fastjson.JSONObject();
+        json.put("url", url);
+        json.put("content", content);
+
+        // 保存聊天消息
+        QwMessageListVO message = aiHookService.saveQwMsg(id, userId, json.toString(), wxWorkMsgResp.getUuid(), sendType, wxWorkMsgResp.getJson(), 4);
+        QwImSocket.broadcast(message);
+    }
+
+    /**
+     * 处理图片消息
+     * @param serverId          服务器ID
+     * @param wxWorkMessageDTO  消息DTO
+     * @param wxWorkMsgResp     回调信息对象
+     * @param id                企微用户ID
+     * @param userId            消息发送者ID
+     * @param sendType          发送者类型 1客户 2销售
+     */
+    private void processImageMessage(Long serverId, WxWorkMessageDTO wxWorkMessageDTO, WxWorkMsgResp wxWorkMsgResp, Long id, Long userId, Integer sendType) {
+        String fileName = IdUtils.fastSimpleUUID() + ".jpg";
+        WxWorkResponseDTO<String> fileUrlResp =
+                aiHookService.getFileUrl(wxWorkMsgResp.getUuid(), wxWorkMessageDTO.getFile_id(), wxWorkMessageDTO.getAes_key(), wxWorkMessageDTO.getOpenim_cdn_authkey(), fileName, wxWorkMessageDTO.getFile_size(), serverId);
+        if (fileUrlResp.getErrcode() != 0) {
+            log.warn("获取图片地址失败: {}", fileUrlResp.getErrmsg());
+            return;
+        }
+
+        String content = fileUrlResp.getData();
+        // 保存聊天消息
+        QwMessageListVO message = aiHookService.saveQwMsg(id, userId, content, wxWorkMsgResp.getUuid(), sendType, wxWorkMsgResp.getJson(), 2);
+        QwImSocket.broadcast(message);
+    }
+
+    /**
+     * 处理动态表情消息
+     * @param wxWorkMessageDTO  消息DTO
+     * @param wxWorkMsgResp     回调信息对象
+     * @param id                企微用户ID
+     * @param userId            消息发送者ID
+     * @param sendType          发送者类型 1客户 2销售
+     */
+    private void processEmotionDynamicMessage(WxWorkMessageDTO wxWorkMessageDTO, WxWorkMsgResp wxWorkMsgResp, Long id, Long userId, int sendType) {
+        String content = wxWorkMessageDTO.getUrl();
+        // 保存聊天消息
+        QwMessageListVO message = aiHookService.saveQwMsg(id, userId, content, wxWorkMsgResp.getUuid(), sendType, wxWorkMsgResp.getJson(), 3);
+        QwImSocket.broadcast(message);
+    }
+
+    /**
+     * 小程序消息处理
+     * @param serverId          服务器ID
+     * @param wxWorkMessageDTO  消息DTO
+     * @param wxWorkMsgResp     回调信息对象
+     * @param id                企微用户ID
+     * @param userId            消息发送者ID
+     * @param sendType          发送者类型 1客户 2销售
+     */
+    private void processMiniAppMessage(Long serverId, WxWorkMessageDTO wxWorkMessageDTO, WxWorkMsgResp wxWorkMsgResp, Long id, Long userId, int sendType) {
+        String thumbName = IdUtils.fastSimpleUUID() + ".jpg";
+        WxWorkResponseDTO<String> fileUrlResp =
+                aiHookService.getFileUrl(wxWorkMsgResp.getUuid(), wxWorkMessageDTO.getThumbFileId(), wxWorkMessageDTO.getThumbAESKey(), 1, thumbName, wxWorkMessageDTO.getSize(), serverId);
+        if (fileUrlResp.getErrcode() != 0) {
+            log.warn("获取图片地址失败: {}", fileUrlResp.getErrmsg());
+            return;
+        }
+
+        JSONObject json = new JSONObject();
+        json.put("appid", wxWorkMessageDTO.getAppid());
+        json.put("appName", wxWorkMessageDTO.getAppName());
+        json.put("weappIconUrl", wxWorkMessageDTO.getAppid());
+        json.put("desc", wxWorkMessageDTO.getDesc());
+        json.put("pagepath", wxWorkMessageDTO.getPagepath());
+        json.put("title", wxWorkMessageDTO.getTitle());
+        json.put("thumbnail", fileUrlResp.getData());
+
+        // 保存聊天消息
+        QwMessageListVO message = aiHookService.saveQwMsg(id, userId, json.toString(), wxWorkMsgResp.getUuid(), sendType, wxWorkMsgResp.getJson(), 5);
+        QwImSocket.broadcast(message);
+    }
+
 }

+ 101 - 0
fs-qw-api-msg/src/main/java/com/fs/app/socket/QwImSocket.java

@@ -0,0 +1,101 @@
+package com.fs.app.socket;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.app.socket.configurator.QwImConfigurator;
+import com.fs.qw.vo.QwMessageListVO;
+import org.springframework.stereotype.Component;
+
+import javax.websocket.OnClose;
+import javax.websocket.OnError;
+import javax.websocket.OnOpen;
+import javax.websocket.Session;
+import javax.websocket.server.PathParam;
+import javax.websocket.server.ServerEndpoint;
+import java.io.IOException;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArraySet;
+
+@ServerEndpoint(value = "/qwImSocket/{companyId}", configurator = QwImConfigurator.class)
+@Component
+public class QwImSocket {
+
+    private static final ConcurrentHashMap<Long, CopyOnWriteArraySet<Session>> companySessions = new ConcurrentHashMap<>();
+
+    /**
+     * 连接建立成功调用的方法
+     * @param session   连接会话
+     * @param companyId 公司ID
+     */
+    @OnOpen
+    public void onOpen(Session session, @PathParam("companyId") Long companyId) {
+        // 将当前会话加入到会话池中
+        companySessions.computeIfAbsent(companyId, k -> new CopyOnWriteArraySet<>()).add(session);
+    }
+
+    /**
+     * 连接关闭调用的方法
+     * @param session   连接会话
+     * @param companyId 公司ID
+     */
+    @OnClose
+    public void onClose(Session session, @PathParam("companyId") Long companyId) {
+        // 从会话池中移除当前会话
+        CopyOnWriteArraySet<Session> sessions = companySessions.get(companyId);
+        if (sessions != null) {
+            sessions.remove(session);
+            // 如果直播间没人了,可以移除该直播间
+            if (sessions.isEmpty()) {
+                companySessions.remove(companyId);
+            }
+        }
+    }
+
+    /**
+     * 发生错误时调用的方法
+     * @param session   连接会话
+     * @param companyId 公司ID
+     * @param error     错误对象
+     */
+    @OnError
+    public void onError(Session session, @PathParam("companyId") Long companyId, Throwable error) {
+        System.err.println("发生错误!会话ID: " + session.getId());
+        CopyOnWriteArraySet<Session> sessions = companySessions.get(companyId);
+        if (sessions != null) {
+            sessions.remove(session);
+            // 如果直播间没人了,可以移除该直播间
+            if (sessions.isEmpty()) {
+                companySessions.remove(companyId);
+            }
+        }
+    }
+
+    /**
+     * 群发消息
+     * @param message   要发送的消息
+     */
+    public static void broadcast(QwMessageListVO message) {
+        if (Objects.isNull(message)) {
+            return;
+        }
+
+        String msg = JSON.toJSONString(message);
+        CopyOnWriteArraySet<Session> sessions = companySessions.get(message.getCompanyId());
+        if (sessions != null) {
+            for (Session session : sessions) {
+                if (session.isOpen()) {
+                    try {
+                        session.getBasicRemote().sendText(msg);
+                    } catch (IOException e) {
+                        System.err.println("发送消息给会话[" + session.getId() + "]失败: " + e.getMessage());
+                        // 移除无效会话
+                        sessions.remove(session);
+                    }
+                } else {
+                    sessions.remove(session); // 移除已关闭的会话
+                }
+            }
+        }
+    }
+
+}

+ 22 - 0
fs-qw-api-msg/src/main/java/com/fs/app/socket/configurator/QwImConfigurator.java

@@ -0,0 +1,22 @@
+package com.fs.app.socket.configurator;
+
+import com.fs.app.exception.FSException;
+
+import javax.websocket.HandshakeResponse;
+import javax.websocket.server.HandshakeRequest;
+import javax.websocket.server.ServerEndpointConfig;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+public class QwImConfigurator extends ServerEndpointConfig.Configurator {
+
+    @Override
+    public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
+        Map<String, List<String>> parameterMap = request.getParameterMap();
+        List<String> token = parameterMap.get("token");
+        if (Objects.isNull(token)) {
+            throw new FSException("Unauthorized access to WebSocket endpoint.");
+        }
+    }
+}

+ 160 - 0
fs-qw-api-msg/src/main/java/com/fs/app/util/AudioUtils.java

@@ -0,0 +1,160 @@
+package com.fs.app.util;
+
+import com.fs.common.utils.uuid.IdUtils;
+import com.fs.system.oss.CloudStorageService;
+import com.fs.system.oss.OSSFactory;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.springframework.http.HttpStatus;
+
+import java.io.ByteArrayOutputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+@Slf4j
+public class AudioUtils {
+
+    /**
+     * silk转换为mp3
+     * @param silkUrl silk语音链接地址
+     * @return  mp3链接地址
+     */
+    public static String convertSilk2Mp3(String silkUrl) {
+        String uniqueId = IdUtils.fastSimpleUUID();
+        Path uploadDirPath = Paths.get(System.getProperty("java.io.tmpdir"), "/");
+        Path downloadedSilkFilePath = uploadDirPath.resolve(uniqueId + ".silk");
+        Path pcmFilePath = uploadDirPath.resolve(uniqueId + ".pcm");
+        Path mp3FilePath = uploadDirPath.resolve(uniqueId + ".mp3");
+
+        try {
+            // 1. 从网络下载 SILK 文件
+            downloadFile(silkUrl, downloadedSilkFilePath);
+            log.info("SILK file downloaded to: {}", downloadedSilkFilePath);
+
+            // 2. 使用 silk-v3-decoder 解码 SILK 到 PCM
+            List<String> silkDecodeCommand = new ArrayList<>();
+            silkDecodeCommand.add("silk_v3_decoder");
+            silkDecodeCommand.add(downloadedSilkFilePath.toString());
+            silkDecodeCommand.add(pcmFilePath.toString());
+
+            ProcessBuilder silkDecoderPb = new ProcessBuilder(silkDecodeCommand);
+            silkDecoderPb.redirectErrorStream(true); // 将错误流合并到标准输出
+            Process silkDecoderProcess = silkDecoderPb.start();
+
+            String silkDecoderOutput = readInputStreamToString(silkDecoderProcess.getInputStream());
+
+            boolean silkDecoderExited = silkDecoderProcess.waitFor(60, TimeUnit.SECONDS);
+            if (!silkDecoderExited || silkDecoderProcess.exitValue() != 0) {
+                log.error("silk conversion failed or timed out. error: {}", silkDecoderOutput);
+                return null;
+            }
+            log.info("SILK decoder to PCM successfully.");
+
+            // 3. 使用 FFmpeg 将 PCM 转码为 MP3
+            Process ffmpegProcess = getFfmpegProcess(pcmFilePath, mp3FilePath);
+            String ffmpegOutput = readInputStreamToString(ffmpegProcess.getInputStream());
+
+            boolean ffmpegExited = ffmpegProcess.waitFor(120, TimeUnit.SECONDS);
+            if (!ffmpegExited || ffmpegProcess.exitValue() != 0) {
+                log.error("ffmpeg conversion failed or timed out. error: {}", ffmpegOutput);
+                return null;
+            }
+            log.info("ffmpeg conversion to MP3 successfully.");
+
+            // 4. 上传oss
+            String fileName = mp3FilePath.getFileName().toString();
+            String suffix = fileName.substring(fileName.lastIndexOf("."));
+            CloudStorageService storage = OSSFactory.build();
+            return storage.uploadSuffix(Files.newInputStream(mp3FilePath), suffix);
+
+        } catch (IOException | InterruptedException | NullPointerException e) {
+            log.error("Conversion error: {}", e.getMessage());
+            return null;
+        } finally {
+            // 清理临时文件 (重要!)
+            try {
+                if (Files.exists(downloadedSilkFilePath)) Files.delete(downloadedSilkFilePath);
+                if (Files.exists(pcmFilePath)) Files.delete(pcmFilePath);
+                if (Files.exists(mp3FilePath)) Files.delete(mp3FilePath);
+            } catch (IOException e) {
+                log.error("Error cleaning up temporary files:: {}", e.getMessage());
+            }
+        }
+    }
+
+    /**
+     * 执行ffmpeg
+     * @param pcmFilePath   pcm文件
+     * @param mp3FilePath   mp3地址
+     * @return  process
+     * @throws IOException exception
+     */
+    private static Process getFfmpegProcess(Path pcmFilePath, Path mp3FilePath) throws IOException {
+        List<String> ffmpegCommand = new ArrayList<>();
+        ffmpegCommand.add("ffmpeg");
+        ffmpegCommand.add("-y");
+        ffmpegCommand.add("-f");
+        ffmpegCommand.add("s16le");
+        ffmpegCommand.add("-ar");
+        ffmpegCommand.add("24000"); // 注意:这里假设是 24kHz,如果你的 SILK 文件是其他采样率,请调整
+        ffmpegCommand.add("-ac");
+        ffmpegCommand.add("1");
+        ffmpegCommand.add("-i");
+        ffmpegCommand.add(pcmFilePath.toString());
+        ffmpegCommand.add(mp3FilePath.toString());
+
+        ProcessBuilder ffmpegPb = new ProcessBuilder(ffmpegCommand);
+        ffmpegPb.redirectErrorStream(true);
+        return ffmpegPb.start();
+    }
+
+    /**
+     * 处理文件流
+     * @param is 输入流
+     * @return  输出
+     * @throws IOException exception
+     */
+    private static String readInputStreamToString(InputStream is) throws IOException {
+        ByteArrayOutputStream bos = new ByteArrayOutputStream();
+        byte[] buffer = new byte[1024];
+        int len;
+        while ((len = is.read(buffer)) != -1) {
+            bos.write(buffer, 0, len);
+        }
+        return bos.toString("UTF-8"); // 使用 UTF-8 编码
+    }
+
+    /**
+     * 下载网络文件
+     * @param fileUrl       网络文件
+     * @param destination   临时文件
+     * @throws IOException  exception
+     */
+    private static void downloadFile(String fileUrl, Path destination) throws IOException {
+        try (CloseableHttpClient httpClient = HttpClients.createDefault();
+             CloseableHttpResponse response = httpClient.execute(new HttpGet(fileUrl));
+             InputStream inputStream = response.getEntity().getContent();
+             FileOutputStream outputStream = new FileOutputStream(destination.toFile())) {
+
+            if (response.getStatusLine().getStatusCode() != HttpStatus.OK.value()) {
+                throw new IOException("Failed to download file from " + fileUrl + ", HTTP Status: " + response.getStatusLine().getStatusCode());
+            }
+
+            byte[] buffer = new byte[4096];
+            int bytesRead;
+            while ((bytesRead = inputStream.read(buffer)) != -1) {
+                outputStream.write(buffer, 0, bytesRead);
+            }
+        }
+    }
+}

+ 2 - 1
fs-qw-api-msg/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -106,7 +106,8 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter
                         "/**/*.html",
                         "/**/*.css",
                         "/**/*.js",
-                        "/profile/**"
+                        "/profile/**",
+                        "/qwImSocket/**"
                 ).permitAll()
 
                 .antMatchers("/**").anonymous()

+ 17 - 0
fs-qw-api-msg/src/main/java/com/fs/framework/config/WebSocketConfig.java

@@ -0,0 +1,17 @@
+package com.fs.framework.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.socket.server.standard.ServerEndpointExporter;
+
+@Configuration
+public class WebSocketConfig {
+    /**
+     * ServerEndpointExporter 作用
+     * 这个Bean会自动注册使用@ServerEndpoint注解声明的websocket endpoint
+     */
+    @Bean
+    public ServerEndpointExporter serverEndpointExporter() {
+        return new ServerEndpointExporter();
+    }
+}

+ 4 - 0
fs-service/src/main/java/com/fs/course/param/FsCourseLinkMiniParam.java

@@ -25,5 +25,9 @@ public class FsCourseLinkMiniParam {
     * 客户的小程序id
     */
     private Long fsUserId;
+    /**
+     * 外部联系人主键
+     */
+    private Long extId;
 
 }

+ 4 - 0
fs-service/src/main/java/com/fs/course/param/FsCourseListBySidebarParam.java

@@ -35,4 +35,8 @@ public class FsCourseListBySidebarParam implements Serializable {
     * 客户信息的长字符串id
     */
     private String externalUserId;
+    /**
+     * 外部联系人主键ID
+     */
+    private Long extId;
 }

+ 2 - 0
fs-service/src/main/java/com/fs/course/service/IFsUserCourseVideoService.java

@@ -195,4 +195,6 @@ public interface IFsUserCourseVideoService
      * 查询选择使用的视频列表
      */
     List<FsUserCourseVideoChooseVO> getChooseCourseVideoListByMap(Map<String, Object> params);
+
+    R createMiniLinkByQwIm(FsCourseLinkMiniParam param);
 }

+ 24 - 0
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java

@@ -2809,5 +2809,29 @@ public class FsUserCourseVideoServiceImpl implements IFsUserCourseVideoService
         return fsUserCourseVideoMapper.getChooseCourseVideoListByMap(params);
     }
 
+    @Override
+    public R createMiniLinkByQwIm(FsCourseLinkMiniParam param) {
+        QwExternalContact qwExternalContact = qwExternalContactMapper.selectById(param.getExtId());
+        if (Objects.isNull(qwExternalContact)) {
+            return R.error("客户不存在");
+        }
+        QwUser qwUser = qwUserMapper.selectById(qwExternalContact.getQwUserId());
+        if (Objects.isNull(qwUser) || Objects.isNull(qwUser.getCompanyId()) || Objects.isNull(qwUser.getCompanyUserId())){
+            return R.error("员工未绑定 销售公司 或 销售 请先绑定");
+        }
+
+        QwCompany qwCompany = iQwCompanyService.getQwCompanyByRedis(qwUser.getCorpId());
+        if (Objects.isNull(qwCompany)) {
+            return R.error().put("msg","企业不存在,请联系管理员");
+        }
+
+        //看课记录
+        addWatchLogIfNeeded(param.getVideoId(), param.getCourseId(), qwExternalContact.getFsUserId(), qwUser, qwExternalContact.getId());
+
+        //生成小程序链接
+        String linkByMiniApp = createLinkByMiniApp(new Date(), param.getCourseId(), param.getVideoId(), qwUser, qwExternalContact.getId(),2,null, 0);
+        return R.ok().put("data", linkByMiniApp);
+    }
+
 }
 

+ 27 - 0
fs-service/src/main/java/com/fs/fastGpt/service/AiHookService.java

@@ -2,6 +2,7 @@ package com.fs.fastGpt.service;
 
 import com.fs.common.core.domain.R;
 import com.fs.im.vo.OpenImMsgCallBackVO;
+import com.fs.qw.vo.QwMessageListVO;
 import com.fs.qwHookApi.vo.QwHookVO;
 import com.fs.wxwork.dto.WxWorkResponseDTO;
 
@@ -23,4 +24,30 @@ public interface AiHookService {
     void expireAiMsg();
 
     WxWorkResponseDTO<String> getFileUrl(String uuid, String fileId, String aesKey, String authKey, String fileName, Integer fileSize, Long serverId);
+
+    /**
+     * 获取文件地址
+     * @param uuid      uuid
+     * @param fileId    fileId
+     * @param aesKey    aesKey
+     * @param fileType  fileType
+     * @param fileName  fileName
+     * @param fileSize  fileSize
+     * @param serverId  serverId
+     * @return  WxWorkResponseDTO
+     */
+    WxWorkResponseDTO<String> getFileUrl(String uuid, String fileId, String aesKey, Integer fileType, String fileName, Integer fileSize, Long serverId);
+
+    /**
+     * 保存企微聊天信息
+     *
+     * @param qwUserId 企微用户ID
+     * @param userId   用户ID
+     * @param content  聊天内容
+     * @param uuid     UUID
+     * @param sendType 发送者类型 1用户 2客服
+     * @param json     消息json
+     * @param msgType  消息类型 1文本 2图片 3动态表情 4语音 5小程序
+     */
+    QwMessageListVO saveQwMsg(Long qwUserId, Long userId, String content, String uuid, int sendType, String json, int msgType);
 }

+ 146 - 0
fs-service/src/main/java/com/fs/fastGpt/service/impl/AiHookServiceImpl.java

@@ -45,9 +45,11 @@ import com.fs.his.service.IFsStoreOrderService;
 import com.fs.im.dto.OpenImMsgDTO;
 import com.fs.im.vo.OpenImMsgCallBackVO;
 import com.fs.qw.domain.*;
+import com.fs.qw.enums.MsgType;
 import com.fs.qw.mapper.*;
 import com.fs.qw.param.QwAutoTagsRulesTags;
 import com.fs.qw.service.*;
+import com.fs.qw.vo.QwMessageListVO;
 import com.fs.qwApi.domain.QwResult;
 import com.fs.qwApi.param.QwEditUserTagParam;
 import com.fs.qwApi.param.QwSendMsgParam;
@@ -70,7 +72,9 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
+import javax.annotation.Resource;
 import java.lang.reflect.Field;
 import java.time.DayOfWeek;
 import java.time.LocalDate;
@@ -165,6 +169,10 @@ public class AiHookServiceImpl implements AiHookService {
     private IFastGptChatReplaceTextService fastGptChatReplaceTextService;
     @Autowired
     private ICrmMsgService crmMsgService;
+    @Autowired
+    private QwSessionMapper qwSessionMapper;
+    @Autowired
+    private QwMsgMapper qwMsgMapper;
 
     private static final String AI_REPLY = "AI_REPLY:";
     private static final String AI_REPLY_TAG = "AI_REPLY_TAG:";
@@ -2045,4 +2053,142 @@ public class AiHookServiceImpl implements AiHookService {
         return wxWorkService.downloadWeChatFile(weChatFileDTO, serverId);
     }
 
+    /**
+     * 获取文件地址
+     * @param uuid      uuid
+     * @param fileId    fileId
+     * @param aesKey    aesKey
+     * @param fileType  fileType
+     * @param fileName  fileName
+     * @param fileSize  fileSize
+     * @param serverId  serverId
+     * @return  WxWorkResponseDTO
+     */
+    @Override
+    public WxWorkResponseDTO<String> getFileUrl(String uuid, String fileId, String aesKey, Integer fileType, String fileName, Integer fileSize, Long serverId) {
+        WxDownloadFileDTO downloadFileDTO = new WxDownloadFileDTO();
+        downloadFileDTO.setUuid(uuid);
+        downloadFileDTO.setFileid(fileId);
+        downloadFileDTO.setAes_key(aesKey);
+        downloadFileDTO.setFiletype(fileType);
+        downloadFileDTO.setFile_name(fileName);
+        downloadFileDTO.setSize(fileSize);
+        return wxWorkService.downloadFile(downloadFileDTO, serverId);
+    }
+
+    /**
+     * 保存企微聊天信息
+     *
+     * @param qwUserId 企微用户ID
+     * @param userId   用户ID
+     * @param content  聊天内容
+     * @param uuid     UUID
+     * @param sendType 发送者类型 1用户 2客服
+     * @param json     消息json
+     * @param msgType  消息类型 1文本 2图片 3动态表情 4语音
+     */
+    @Transactional(rollbackFor = Exception.class)
+    @Override
+    public QwMessageListVO saveQwMsg(Long qwUserId, Long userId, String content, String uuid, int sendType, String json, int msgType) {
+        // 查询企微用户
+        QwUser qwUser = qwUserService.selectQwUserById(qwUserId);
+        if (Objects.isNull(qwUser)){
+            log.warn("企微用户不存在 qwUserId: {}", qwUserId);
+            return null;
+        }
+
+        // 查询外部联系人
+        QwExternalContact qwExternalContact = getExternalContact(userId, uuid, qwUser.getServerId(), qwUser.getCorpId(), qwUser.getQwUserId());
+        if (Objects.isNull(qwExternalContact)){
+            log.warn("外部联系人不存在 userId: {}, uuid: {}, serverId: {}, corpId: {}, qwUserId: {}", userId, uuid, qwUser.getServerId(), qwUser.getCorpId(), qwUser.getQwUserId());
+            return null;
+        }
+
+        // 查询会话
+        QwSession qwSession = qwSessionMapper.selectQwSessionByExtIdAndQwUserId(qwExternalContact.getId(), qwUser.getId());
+        if (qwSession == null) {
+            qwSession = new QwSession();
+            String chatId = UUID.randomUUID().toString();
+            qwSession.setChatId(chatId);
+            qwSession.setCorpId(qwUser.getCorpId());
+            qwSession.setQwExtWxId(String.valueOf(userId));
+            qwSession.setQwExtId(qwExternalContact.getId().toString());
+            qwSession.setQwUserId(qwUser.getId().toString());
+            qwSession.setStatus(1);
+            qwSession.setAvatar(qwExternalContact.getAvatar());
+            qwSession.setNickName(qwExternalContact.getName());
+            qwSession.setCompanyId(qwUser.getCompanyId());
+            qwSession.setCompanyUserId(qwUser.getCompanyUserId());
+            qwSession.setCreateTime(new Date());
+            qwSession.setUpdateTime(new Date());
+            qwSessionMapper.insertQwSession(qwSession);
+        }else {
+            qwSession.setUpdateTime(new Date());
+            qwSession.setNickName(qwExternalContact.getName());
+            qwSessionMapper.updateQwSession(qwSession);
+        }
+
+        // 保存聊天消息
+        QwMsg qwMsg = new QwMsg();
+        qwMsg.setContent(content);
+        qwMsg.setSessionId(qwSession.getSessionId());
+        qwMsg.setSendType(sendType);
+        qwMsg.setCompanyId(qwUser.getCompanyId());
+        qwMsg.setCompanyUserId(qwUser.getCompanyUserId());
+        qwMsg.setMsgType(msgType);
+        qwMsg.setMsgJson(json);
+        qwMsg.setStatus(0);
+        qwMsg.setQwUserId(qwSession.getQwUserId());
+        qwMsg.setQwExtId(qwSession.getQwExtId());
+        qwMsg.setAvatar(qwExternalContact.getAvatar());
+        qwMsg.setNickName(qwExternalContact.getName());
+        qwMsg.setCreateTime(new Date());
+        qwMsgMapper.insertQwMsg(qwMsg);
+        log.debug("保存企微聊天记录 msgId: {}", qwMsg.getMsgId());
+
+        // 组装返回消息结构
+        QwMessageListVO listVO = new QwMessageListVO();
+        QWFromUser qwFromUser = new QWFromUser();
+        if (sendType == 1) {
+            qwFromUser.setId(Long.parseLong(qwMsg.getQwExtId()));
+            qwFromUser.setAvatar(qwMsg.getAvatar());
+            qwFromUser.setDisplayName(qwMsg.getNickName());
+        }else if(sendType == 2){
+            qwFromUser.setId(Long.parseLong(qwMsg.getQwUserId()));
+            qwFromUser.setDisplayName(qwUser.getQwUserName());
+            qwFromUser.setAvatar(qwUser.getAvatar());
+        }
+
+        listVO.setCompanyId(qwUser.getCompanyId());
+        String type = "text";
+        MsgType messageType = MsgType.getMsgType(msgType);
+        if (Objects.nonNull(messageType)){
+            type = messageType.getValue();
+        }
+
+        listVO.setType(type);
+        listVO.setStatus("succeed");
+        listVO.setExtId(qwMsg.getQwExtId());
+        listVO.setFromUser(qwFromUser);
+        listVO.setSendTime(qwMsg.getCreateTime().getTime());
+        listVO.setId(qwMsg.getMsgId().toString());
+        listVO.setContent(qwMsg.getContent());
+        listVO.setToContactId(String.valueOf(qwSession.getSessionId()));
+        listVO.setAppKey(qwUser.getAppKey());
+        return listVO;
+    }
+
+    /**
+     * 查询外部联系人
+     * @param userId    用户ID
+     * @param uuid      UUID
+     * @param serverId  服务ID
+     * @param corpId    企微ID
+     * @param qwUserId  企微用户ID
+     * @return  QwExternalContact
+     */
+    private QwExternalContact getExternalContact(Long userId, String uuid, Long serverId, String corpId, String qwUserId) {
+        return qwExternalContactMapper.selectQwExternalContactByExternalUserIdAndQwUserId(getExtId(userId, uuid, serverId), corpId, qwUserId);
+    }
+
 }

+ 3 - 0
fs-service/src/main/java/com/fs/qw/domain/QwUser.java

@@ -27,6 +27,9 @@ public class QwUser extends BaseEntity
     @Excel(name = "企微用户名")
     private String qwUserName;
 
+    /** 头像 **/
+    private String avatar;
+
     /** 所属部门id */
     @Excel(name = "所属部门id")
     private String department;

+ 27 - 0
fs-service/src/main/java/com/fs/qw/enums/MsgType.java

@@ -0,0 +1,27 @@
+package com.fs.qw.enums;
+
+import lombok.Getter;
+
+import java.util.stream.Stream;
+
+@Getter
+public enum MsgType {
+    TEXT(1, "text"),
+    IMAGE(2, "image"),
+    EMOTION_DYNAMIC(3, "emotionDynamic"),
+    VOICE(4, "voice"),
+    MINI_PROGRAM(5, "miniprogram"),
+    ;
+
+    private final Integer code;
+    private final String value;
+
+    MsgType(Integer code, String value) {
+        this.code = code;
+        this.value = value;
+    }
+
+    public static MsgType getMsgType(Integer code) {
+        return Stream.of(values()).filter(t -> t.getCode().equals(code)).findFirst().orElse(null);
+    }
+}

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

@@ -505,4 +505,15 @@ public interface QwExternalContactMapper extends BaseMapper<QwExternalContact> {
     List<QwExternalContact> selectQwExternalContactByFsUserIdAndCompany(@Param("userId")Long userId,@Param("companyUserId") Long companyUserId);
 
     void updateQwExternalContactIsRePlyById(@Param("id")Long id);
+
+    @Select("SELECT id,external_user_id,name,avatar,remark,description,fs_user_id FROM qw_external_contact " +
+            " WHERE id = #{qwExternalContactId}")
+    QwExternalContact getQwExternalContactDetailsById(Long qwExternalContactId);
+
+    /**
+     * 根据外部联系人ID查询用户是否已购产品
+     * @param qwExternalContactId   外部联系人ID
+     * @return  Boolean
+     */
+    Boolean getBuyStatusByExtId(@Param("qwExternalContactId") Long qwExternalContactId);
 }

+ 8 - 0
fs-service/src/main/java/com/fs/qw/mapper/QwSessionMapper.java

@@ -2,6 +2,7 @@ package com.fs.qw.mapper;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.fs.qw.domain.QwSession;
+import com.fs.qw.vo.QwContactListVO;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
 
@@ -66,4 +67,11 @@ public interface QwSessionMapper extends BaseMapper<QwSession>
 
     @Select("select * from qw_session where qw_ext_id = #{qwExtId} and qw_user_id = #{qwUserId}")
     QwSession selectQwSessionByExtIdAndQwUserId(@Param("qwExtId") Long qwExtId, @Param("qwUserId") Long id);
+
+    /**
+     * 根据企微用户ID查询会话列表
+     * @param qwUserId  企微用户ID
+     * @return  list
+     */
+    List<QwContactListVO> selectContactListByQwUserId(@Param("qwUserId") Long qwUserId);
 }

+ 6 - 0
fs-service/src/main/java/com/fs/qw/param/QwMsgSendParam.java

@@ -9,5 +9,11 @@ public class QwMsgSendParam implements Serializable {
     private Long sessionId;
     private String content;//内容
     private String appKey;
+    // 消息类型 1文本 2图片 3动态表情 4语音 5小程序
+    private Integer msgType;
+    // 小程序标题
+    private String title;
+    // 小程序图片
+    private String image;
 
 }

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

@@ -7,4 +7,6 @@ import lombok.Data;
 public class QwSessionParam extends BaseQueryParam {
     private String conversationId;
     private Long userId;//企微id
+    // 消息ID
+    private Long msgId;
 }

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

@@ -247,4 +247,17 @@ public interface IQwExternalContactService extends IService<QwExternalContact> {
      */
     List<QwUserDelLossLogVO> selectQwUserDelLossLogList(QwUserDelLossLogParam param);
 
+    /**
+     * 根据id查询外部联系人信息
+     * @param qwExternalContactId id
+     * @return QwExternalContact
+     */
+    QwExternalContact getQwExternalContactDetailsById(Long qwExternalContactId);
+
+    /**
+     * 根据外部联系人ID查询用户是否已购产品
+     * @param qwExternalContactId   外部联系人ID
+     * @return  Boolean
+     */
+    Boolean getBuyStatusByExtId(Long qwExternalContactId);
 }

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

@@ -5829,4 +5829,25 @@ public class QwExternalContactServiceImpl extends ServiceImpl<QwExternalContactM
         }
         return false;
     }
+
+
+    /**
+     * 根据id查询外部联系人信息
+     * @param qwExternalContactId id
+     * @return QwExternalContact
+     */
+    @Override
+    public QwExternalContact getQwExternalContactDetailsById(Long qwExternalContactId) {
+        return qwExternalContactMapper.getQwExternalContactDetailsById(qwExternalContactId);
+    }
+
+    /**
+     * 根据外部联系人ID查询用户是否已购产品
+     * @param qwExternalContactId   外部联系人ID
+     * @return  Boolean
+     */
+    @Override
+    public Boolean getBuyStatusByExtId(Long qwExternalContactId) {
+        return qwExternalContactMapper.getBuyStatusByExtId(qwExternalContactId);
+    }
 }

+ 249 - 29
fs-service/src/main/java/com/fs/qw/service/impl/QwMsgServiceImpl.java

@@ -2,16 +2,25 @@ package com.fs.qw.service.impl;
 
 import cn.hutool.core.collection.CollectionUtil;
 import cn.hutool.http.HttpRequest;
-import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.fs.common.core.domain.R;
 import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.Company;
+import com.fs.company.domain.CompanyMiniapp;
+import com.fs.company.mapper.CompanyMapper;
+import com.fs.company.mapper.CompanyMiniappMapper;
+import com.fs.course.domain.FsCoursePlaySourceConfig;
+import com.fs.course.mapper.FsCoursePlaySourceConfigMapper;
 import com.fs.his.config.FsSysConfig;
 import com.fs.his.utils.ConfigUtil;
 import com.fs.qw.Bean.MsgBean;
 import com.fs.qw.domain.*;
+import com.fs.qw.enums.MsgType;
 import com.fs.qw.mapper.QwExternalContactMapper;
 import com.fs.qw.mapper.QwMsgMapper;
 import com.fs.qw.mapper.QwSessionMapper;
@@ -23,6 +32,8 @@ import com.fs.qw.service.IQwUserService;
 import com.fs.qw.vo.QwContactListVO;
 import com.fs.qw.vo.QwMessageListVO;
 import com.fs.qwHookApi.vo.QwHookMsgVO;
+import com.fs.wxwork.dto.*;
+import com.fs.wxwork.service.WxWorkService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
@@ -50,6 +61,12 @@ public class QwMsgServiceImpl extends ServiceImpl<QwMsgMapper, QwMsg> implements
 
     @Autowired
     private ConfigUtil configUtil;
+    @Autowired
+    private WxWorkService wxWorkService;
+    @Autowired
+    private CompanyMiniappMapper companyMiniappMapper;
+    @Autowired
+    private FsCoursePlaySourceConfigMapper playSourceConfigMapper;
 
     /**
      * 查询企微聊天记录
@@ -139,7 +156,7 @@ public class QwMsgServiceImpl extends ServiceImpl<QwMsgMapper, QwMsg> implements
                     qwSession.setQwUserId(qwuser.getId().toString());
                     qwSession.setStatus(1);
                     qwSession.setAvatar(qwExternalContact.getAvatar());
-                    qwSession.setNickName(qwExternalContact.getRemark());
+                    qwSession.setNickName(qwExternalContact.getName());
                     qwSession.setCompanyId(qwuser.getCompanyId());
                     qwSession.setCompanyUserId(qwuser.getCompanyUserId());
                     qwSession.setCreateTime(new Date());
@@ -147,7 +164,7 @@ public class QwMsgServiceImpl extends ServiceImpl<QwMsgMapper, QwMsg> implements
                     qwSessionMapper.insertQwSession(qwSession);
                 }else {
                     qwSession.setUpdateTime(new Date());
-                    qwSession.setNickName(qwExternalContact.getRemark());
+                    qwSession.setNickName(qwExternalContact.getName());
                     qwSessionMapper.updateQwSession(qwSession);
                 }
                 QwMsg qwMsg = new QwMsg();
@@ -161,7 +178,7 @@ public class QwMsgServiceImpl extends ServiceImpl<QwMsgMapper, QwMsg> implements
                 qwMsg.setQwUserId(qwSession.getQwUserId());
                 qwMsg.setQwExtId(qwSession.getQwExtId());
                 qwMsg.setAvatar(qwExternalContact.getAvatar());
-                qwMsg.setNickName(qwExternalContact.getRemark());
+                qwMsg.setNickName(qwExternalContact.getName());
                 qwMsg.setCreateTime(new Date());
                 if (qwMsgMapper.insertQwMsg(qwMsg) > 0) {
                     //发送socket
@@ -235,7 +252,7 @@ public class QwMsgServiceImpl extends ServiceImpl<QwMsgMapper, QwMsg> implements
             //客服发送
             qwFromUser.setId(Long.parseLong(qwMsg.getQwUserId()));
             qwFromUser.setDisplayName(user.getQwUserName());
-            qwFromUser.setAvatar("https://cos.his.cdwjyyh.com/fs/20241231/22a765a96da247d1b83ea94fef438a41.png");
+            qwFromUser.setAvatar(user.getAvatar());
             msg.setFromUser(qwFromUser);
             sendSocket("receiveMsg",JSONObject.toJSONString(msg),user.getAppKey());
         }
@@ -244,20 +261,205 @@ public class QwMsgServiceImpl extends ServiceImpl<QwMsgMapper, QwMsg> implements
 
     @Override
     public R sendMsg(QwMsgSendParam param) {
-        FsSysConfig config = configUtil.getSysConfig();
-        String domainName = config.getHookUrl();
-        HttpRequest.post(domainName+"/app/qwmsg/sendMsg")
-                .body(JSON.toJSONString(param),"application/json;charset=UTF-8")
-                .execute().body();
-        return R.ok();
+        if (StringUtils.isBlank(param.getContent())) {
+            return R.error("消息内容不能为空");
+        }
+
+        if (Objects.isNull(param.getSessionId())) {
+            return R.error("会话ID不能为空");
+        }
+
+        if (Objects.isNull(param.getMsgType())) {
+            return R.error("消息类型不能为空");
+        }
+
+        // 查询会话
+        QwSession qwSession = qwSessionMapper.selectQwSessionBySessionId(param.getSessionId());
+        if (Objects.isNull(qwSession)) {
+            return R.error("会话不存在");
+        }
+
+        // 外部联系人
+        QwExternalContact qwExternalContact = qwExternalContactMapper.selectQwExternalContactById(Long.valueOf(qwSession.getQwExtId()));
+        if (Objects.isNull(qwExternalContact)) {
+            return R.error("联系人不存在");
+        }
+
+        // 企微用户
+        QwUser qwUser = qwUserMapper.selectQwUserById(Long.parseLong(qwSession.getQwUserId()));
+        if (Objects.isNull(qwUser)) {
+            return R.error("用户不存在");
+        }
+
+        Long serverId = qwUser.getServerId();
+        String uuid = qwUser.getUid();
+        String openId = qwExternalContact.getExternalUserId();
+        String sCorpId = qwUser.getCorpId();
+
+        WxWorkUserId2VidDTO params = new WxWorkUserId2VidDTO();
+        params.setOpenid(Collections.singletonList(openId));
+        params.setUuid(uuid);
+        params.setScorpid(sCorpId);
+        WxWorkResponseDTO<List<WxWorkVid2UserIdRespDTO>> listWxWorkResponseDTO = wxWorkService.UserId2Vid(params, serverId);
+
+        if (listWxWorkResponseDTO.getErrcode() != 0) {
+            return R.error(listWxWorkResponseDTO.getErrmsg());
+        }
+        long sendUserId = listWxWorkResponseDTO.getData().get(0).getUser_id();
+        String msgJson;
+
+        MsgType msgType = MsgType.getMsgType(param.getMsgType());
+        // 发送消息  文本
+        if (MsgType.TEXT == msgType) {
+            WxWorkSendTextMsgDTO textMsgDTO = new WxWorkSendTextMsgDTO();
+            textMsgDTO.setUuid(uuid);
+            textMsgDTO.setSend_userid(sendUserId);
+            textMsgDTO.setIsRoom(false);
+            textMsgDTO.setContent(param.getContent());
+            WxWorkResponseDTO<WxWorkSendTextMsgRespDTO> msgRespDTOWxWorkResponseDTO = wxWorkService.SendTextMsg(textMsgDTO, serverId);
+
+            if (msgRespDTOWxWorkResponseDTO.getErrcode() != 0) {
+                return R.error(msgRespDTOWxWorkResponseDTO.getErrmsg());
+            }
+            msgJson = JSONObject.toJSONString(textMsgDTO);
+        }
+
+        // 图片
+        else if (MsgType.IMAGE == msgType) {
+            WxCdnUploadImgLinkDTO linkDTO = new WxCdnUploadImgLinkDTO();
+            linkDTO.setUuid(uuid);
+            linkDTO.setUrl(param.getContent());
+            WxWorkResponseDTO<WxCdnUploadImgLinkResp> imgLinkResp = wxWorkService.cdnUploadImgLink(linkDTO, serverId);
+            if (imgLinkResp.getErrcode() != 0) {
+                return R.error(imgLinkResp.getErrmsg());
+            }
+            WxCdnUploadImgLinkResp data = imgLinkResp.getData();
+
+            // 发送图片消息
+            WxwSendCDNImgMsgDTO imgMsgDTO = new WxwSendCDNImgMsgDTO();
+            imgMsgDTO.setUuid(uuid);
+            imgMsgDTO.setSend_userid(sendUserId);
+            imgMsgDTO.setIsRoom(false);
+            imgMsgDTO.setCdnkey(data.getCdn_key());
+            imgMsgDTO.setAeskey(data.getAes_key());
+            imgMsgDTO.setMd5(data.getMd5());
+            imgMsgDTO.setFileSize(data.getSize());
+            WxWorkResponseDTO<WxwSendCDNImgMsgRespDTO> imgMsgResp = wxWorkService.SendCDNImgMsg(imgMsgDTO, serverId);
+            if (imgMsgResp.getErrcode() != 0) {
+                return R.error(imgMsgResp.getErrmsg());
+            }
+            msgJson = JSONObject.toJSONString(imgMsgDTO);
+        }
+        // 小程序
+        else if (MsgType.MINI_PROGRAM == msgType) {
+            String pagepath = param.getContent();
+
+            // 查询公司对应小程序配置
+            CompanyMiniapp miniappParams = new CompanyMiniapp();
+            miniappParams.setCompanyId(qwUser.getCompanyId());
+            List<CompanyMiniapp> companyMiniapps = companyMiniappMapper.selectCompanyMiniappList(miniappParams);
+            if (companyMiniapps == null || companyMiniapps.isEmpty()) {
+                return R.error("用户所属销售公司主备小程序未配置");
+            }
+
+            FsCoursePlaySourceConfig config = null;
+            companyMiniapps.sort(Comparator.comparing(CompanyMiniapp::getType));
+            for (CompanyMiniapp companyMiniapp : companyMiniapps) {
+                if (config == null) {
+                    Wrapper<FsCoursePlaySourceConfig> queryWrapper = Wrappers.<FsCoursePlaySourceConfig>lambdaQuery()
+                            .eq(FsCoursePlaySourceConfig::getAppid, companyMiniapp.getAppId())
+                            .eq(FsCoursePlaySourceConfig::getIsDel, 0).last("limit 1");
+                    config = playSourceConfigMapper.selectOne(queryWrapper);
+                }
+            }
+
+            if (config == null) {
+                return R.error("用户所属销售公司主备小程序配置错误");
+            }
+
+            String img = StringUtils.isNotBlank(param.getImage()) ? param.getImage() : config.getImg();
+            WxCdnUploadImgLinkDTO linkDTO = new WxCdnUploadImgLinkDTO();
+            linkDTO.setUuid(uuid);
+            linkDTO.setUrl(img);
+            WxWorkResponseDTO<WxCdnUploadImgLinkResp> imgLinkResp = wxWorkService.cdnUploadImgLink(linkDTO, serverId);
+            if (imgLinkResp.getErrcode() != 0) {
+                return R.error(imgLinkResp.getErrmsg());
+            }
+            WxCdnUploadImgLinkResp data = imgLinkResp.getData();
+
+            // 发送小程序消息
+            WxWorkSendAppMsgDTO appMsgDTO = new WxWorkSendAppMsgDTO();
+            appMsgDTO.setUuid(uuid);
+            appMsgDTO.setSend_userid(sendUserId);
+            appMsgDTO.setDesc(param.getTitle());
+            appMsgDTO.setTitle(config.getName());
+            appMsgDTO.setWeappIconUrl(img);
+            appMsgDTO.setPagepath(pagepath);
+            appMsgDTO.setUsername(config.getOriginalId() + "@app");
+            appMsgDTO.setAppid(config.getAppid());
+            appMsgDTO.setCdnkey(data.getCdn_key());
+            appMsgDTO.setMd5(data.getMd5());
+            appMsgDTO.setAeskey(data.getAes_key());
+            appMsgDTO.setFileSize(data.getSize());
+            appMsgDTO.setIsRoom(false);
+            WxWorkResponseDTO<WxWorkSendAppMsgRespDTO> appMsgResp = wxWorkService.SendAppMsg(appMsgDTO, serverId);
+            if (appMsgResp.getErrcode() != 0) {
+                return R.error(appMsgResp.getErrmsg());
+            }
+
+            JSONObject json = new JSONObject();
+            json.put("appid", config.getAppid());
+            json.put("appName", config.getName());
+            json.put("weappIconUrl", img);
+            json.put("desc", param.getTitle());
+            json.put("pagepath", pagepath);
+            json.put("title", param.getTitle());
+            json.put("thumbnail", img);
+
+            msgJson = json.toJSONString();
+            param.setContent(msgJson);
+        } else {
+            return R.error("暂不支持的消息类型");
+        }
+
+        // 消息保存本地数据库
+        QwMsg qwMsg = new QwMsg();
+        qwMsg.setContent(param.getContent());
+        qwMsg.setSessionId(qwSession.getSessionId());
+        qwMsg.setSendType(2);
+        qwMsg.setCompanyId(qwUser.getCompanyId());
+        qwMsg.setCompanyUserId(qwUser.getCompanyUserId());
+        qwMsg.setMsgType(param.getMsgType());
+        qwMsg.setMsgJson(msgJson);
+        qwMsg.setStatus(0);
+        qwMsg.setQwUserId(qwSession.getQwUserId());
+        qwMsg.setQwExtId(qwSession.getQwExtId());
+        qwMsg.setAvatar(qwExternalContact.getAvatar());
+        qwMsg.setNickName(qwExternalContact.getName());
+        qwMsg.setCreateTime(new Date());
+        qwMsgMapper.insertQwMsg(qwMsg);
+
+        // 组装返回消息结构
+        QwMessageListVO listVO = new QwMessageListVO();
+        QWFromUser qwFromUser = new QWFromUser();
+        qwFromUser.setId(Long.parseLong(qwMsg.getQwUserId()));
+        qwFromUser.setDisplayName(qwUser.getQwUserName());
+        qwFromUser.setAvatar(qwUser.getAvatar());
+        listVO.setType(msgType.getValue());
+        listVO.setStatus("succeed");
+        listVO.setFromUser(qwFromUser);
+        listVO.setSendTime(qwMsg.getCreateTime().getTime());
+        listVO.setId(qwMsg.getMsgId().toString());
+        listVO.setContent(qwMsg.getContent());
+        listVO.setToContactId(String.valueOf(param.getSessionId()));
+        listVO.setAppKey(qwUser.getAppKey());
+        return R.ok().put("data", listVO);
     }
 
     @Override
     public List<QwUser> qwUserList(Long userId) {
         LambdaQueryWrapper<QwUser> lambdaQueryWrapper = new LambdaQueryWrapper<>();
         lambdaQueryWrapper.eq(QwUser::getCompanyUserId, userId);
-        lambdaQueryWrapper.eq(QwUser::getLoginStatus,1);
-        lambdaQueryWrapper.eq(QwUser::getToolStatus,1);
         List<QwUser> qwUsers = qwUserMapper.selectList(lambdaQueryWrapper);
         return qwUsers;
     }
@@ -288,23 +490,17 @@ public class QwMsgServiceImpl extends ServiceImpl<QwMsgMapper, QwMsg> implements
 
     @Override
     public List<QwContactListVO> selectQwConversationByUserId(Long userId) {
-        LambdaQueryWrapper<QwSession> sessionWrapper = new LambdaQueryWrapper<>();
-        sessionWrapper.eq(QwSession::getQwUserId, userId);
-        sessionWrapper.orderByDesc(QwSession::getUpdateTime);
-        List<QwSession> qwSessions = qwSessionMapper.selectList(sessionWrapper);
-        if (CollectionUtil.isEmpty(qwSessions)){
-            return Collections.EMPTY_LIST;
+        // 查询会话列表
+        List<QwContactListVO> contactList = qwSessionMapper.selectContactListByQwUserId(userId);
+        if (contactList.isEmpty()) {
+            return new ArrayList<>();
         }
+
         ArrayList<QwContactListVO> qwContactListVOS = new ArrayList<>();
-        for (QwSession qwSession : qwSessions) {
-            QwContactListVO listVO = new QwContactListVO();
-            listVO.setId(userId);
-            listVO.setAvatar(qwSession.getAvatar());
-            listVO.setConversationId(qwSession.getSessionId().toString());
-            listVO.setDisplayName(qwSession.getNickName());
-//            listVO.setIndex(qwSession.getNickName().substring(0, 1));
+        for (QwContactListVO listVO : contactList) {
             LambdaQueryWrapper<QwMsg> lambdaQueryWrapper = new LambdaQueryWrapper<>();
-            lambdaQueryWrapper.eq(QwMsg::getSessionId, qwSession.getSessionId());
+            lambdaQueryWrapper.select(QwMsg.class, q -> !q.getColumn().equals("remark"));
+            lambdaQueryWrapper.eq(QwMsg::getSessionId, Integer.parseInt(listVO.getConversationId()));
             lambdaQueryWrapper.orderByDesc(QwMsg::getMsgId);
             lambdaQueryWrapper.last("limit 1");
             List<QwMsg> qwMsgs = qwMsgMapper.selectList(lambdaQueryWrapper);
@@ -314,6 +510,19 @@ public class QwMsgServiceImpl extends ServiceImpl<QwMsgMapper, QwMsg> implements
                 qwContactListVOS.add(listVO);
                 break;
             }
+            QwMsg qwMsg = qwMsgs.get(0);
+            if (qwMsg.getMsgType() == 1) {
+                listVO.setType("text");
+            } else if (qwMsg.getMsgType() == 2) {
+                listVO.setType("image");
+            } else if (qwMsg.getMsgType() == 3) {
+                listVO.setType("emotionDynamic");
+            } else if (qwMsg.getMsgType() == 4) {
+                listVO.setType("voice");
+            } else if (qwMsg.getMsgType() == 5) {
+                listVO.setType("miniprogram");
+            }
+            listVO.setMsgId(qwMsg.getMsgId());
             listVO.setLastContent(qwMsgs.get(0).getContent());
             listVO.setLastSendTime(qwMsgs.get(0).getCreateTime().getTime());
             listVO.setUnread(0);
@@ -325,7 +534,11 @@ public class QwMsgServiceImpl extends ServiceImpl<QwMsgMapper, QwMsg> implements
     @Override
     public List<QwMsg> selectQwMsgBySession(QwSessionParam param) {
         LambdaQueryWrapper<QwMsg> lambdaQueryWrapper = new LambdaQueryWrapper<>();
+        lambdaQueryWrapper.select(QwMsg.class, q -> !q.getColumn().equals("remark"));
         lambdaQueryWrapper.eq(QwMsg::getSessionId, param.getConversationId());
+        if (Objects.nonNull(param.getMsgId())) {
+            lambdaQueryWrapper.gt(QwMsg::getMsgId, param.getMsgId());
+        }
         lambdaQueryWrapper.orderByDesc(QwMsg::getMsgId);
         List<QwMsg> records = qwMsgMapper.selectList(lambdaQueryWrapper);
         return records;
@@ -337,7 +550,12 @@ public class QwMsgServiceImpl extends ServiceImpl<QwMsgMapper, QwMsg> implements
         List<QwMessageListVO> qwMessageVOS = new ArrayList<>();
         for (QwMsg record : list) {
             QwMessageListVO listVO = new QwMessageListVO();
-            listVO.setType("text");
+            String type = "text";
+            MsgType msgType = MsgType.getMsgType(record.getMsgType());
+            if (Objects.nonNull(record.getMsgType())) {
+                type = msgType.getValue();
+            }
+            listVO.setType(type);
             listVO.setStatus("succeed");
             QWFromUser qwFromUser = new QWFromUser();
             //用户发送
@@ -348,13 +566,15 @@ public class QwMsgServiceImpl extends ServiceImpl<QwMsgMapper, QwMsg> implements
             }else if(record.getSendType() == 2){
                 qwFromUser.setId(Long.parseLong(record.getQwUserId()));
                 qwFromUser.setDisplayName(user.getQwUserName());
-                qwFromUser.setAvatar("https://cos.his.cdwjyyh.com/fs/20241231/22a765a96da247d1b83ea94fef438a41.png");
+                qwFromUser.setAvatar(user.getAvatar());
             }
+            listVO.setExtId(record.getQwExtId());
             listVO.setFromUser(qwFromUser);
             listVO.setSendTime(record.getCreateTime().getTime());
             listVO.setId(record.getMsgId().toString());
             listVO.setContent(record.getContent());
             listVO.setToContactId(param.getConversationId());
+            listVO.setAppKey(user.getAppKey());
             qwMessageVOS.add(listVO);
         }
         return qwMessageVOS;

+ 10 - 1
fs-service/src/main/java/com/fs/qw/vo/QwContactListVO.java

@@ -14,5 +14,14 @@ public class QwContactListVO {
     private Long roomId;
     private Long lastSendTime;
     private String lastContent;
-
+    // 消息ID
+    private Long msgId;
+    // 消息类型
+    private String type;
+    // 外部联系人ID
+    private String extId;
+    // 是否黑粉
+    private Boolean isBlack;
+    // 是否重粉
+    private Boolean isRepeat;
 }

+ 5 - 0
fs-service/src/main/java/com/fs/qw/vo/QwMessageListVO.java

@@ -1,5 +1,6 @@
 package com.fs.qw.vo;
 
+import com.alibaba.fastjson.annotation.JSONField;
 import com.fs.qw.domain.QWFromUser;
 import com.fs.qw.domain.QwMsg;
 import lombok.Data;
@@ -19,6 +20,10 @@ public class QwMessageListVO {
     private Integer duration; //时长
     private String toContactId;
     private QWFromUser fromUser;
+    private String appKey;
+    @JSONField(serialize = false)
+    private Long companyId;
+    private String extId;
 
     //获取fromUser
     public  QWFromUser getQwFromUser(Long senderId,QwMsg qwMsg){

+ 0 - 22
fs-service/src/main/java/com/fs/statis/param/WatchCourseStatisticsParam.java

@@ -1,22 +0,0 @@
- package com.fs.statis.param;
-
-import lombok.Data;
-
-import java.io.Serializable;
-
- /**
-  * 看课统计参数
-  */
- @Data
- public class WatchCourseStatisticsParam implements Serializable {
-
-     /**
-      * 0 七天
-      * 1 30天
-      */
-     private Integer type;
-     /**
-      * 企微外部联系人id
-      */
-     private Long qwExternalContactId;
- }

+ 0 - 1
fs-service/src/main/java/com/fs/statis/service/impl/StatisticsServiceImpl.java

@@ -17,7 +17,6 @@ import com.fs.qw.service.IQwIpadServerService;
 import com.fs.statis.StatisticsRedisConstant;
 import com.fs.statis.dto.*;
 import com.fs.statis.mapper.ConsumptionBalanceMapper;
-import com.fs.statis.param.WatchCourseStatisticsParam;
 import com.fs.statis.service.IStatisticsService;
 import com.fs.statis.service.utils.TrendDataFiller;
 

+ 13 - 0
fs-service/src/main/java/com/fs/wxwork/dto/WxWorkMessageDTO.java

@@ -33,4 +33,17 @@ public class WxWorkMessageDTO {
     // 语音
     private String voice_id;
     private Integer voice_size;
+
+    // 小程序
+    private String appid;
+    private String appName;
+    private String username;
+    private String desc;
+    private String weappIconUrl;
+    private String thumbFileId;
+    private String thumbMD5;
+    private String thumbAESKey;
+    private Integer size;
+    private String title;
+    private String pagepath;
 }

+ 1 - 1
fs-service/src/main/java/com/fs/wxwork/service/WxWorkService.java

@@ -36,7 +36,7 @@ public interface WxWorkService {
      * @param param 参数
      * @return QwWorkResponseDTO
      */
-    WxWorkSendAppMsgRespDTO SendAppMsg(WxWorkSendAppMsgDTO param,Long serverId);
+    WxWorkResponseDTO<WxWorkSendAppMsgRespDTO> SendAppMsg(WxWorkSendAppMsgDTO param,Long serverId);
 
     /**
      * 发送链接消息

+ 2 - 2
fs-service/src/main/java/com/fs/wxwork/service/WxWorkServiceImpl.java

@@ -69,9 +69,9 @@ public class WxWorkServiceImpl implements WxWorkService {
     }
 
     @Override
-    public WxWorkSendAppMsgRespDTO SendAppMsg(WxWorkSendAppMsgDTO param,Long serverId) {
+    public WxWorkResponseDTO<WxWorkSendAppMsgRespDTO> SendAppMsg(WxWorkSendAppMsgDTO param,Long serverId) {
         String url = getUrl(serverId) + "/SendAppMsg";
-        return WxWorkHttpUtil.postWithType(url, param, new TypeReference<WxWorkSendAppMsgRespDTO>() {
+        return WxWorkHttpUtil.postWithType(url, param, new TypeReference<WxWorkResponseDTO<WxWorkSendAppMsgRespDTO>>() {
         });
     }
 

+ 1 - 1
fs-service/src/main/resources/application-config-dev.yml

@@ -103,7 +103,7 @@ cloud_host:
 headerImg:
   imgUrl: https://jz-cos-1356808054.cos.ap-chengdu.myqcloud.com/fs/20250515/0877754b59814ea8a428fa3697b20e68.png
 ipad:
-  ipadUrl: http://ipad.cdwjyyh.com
+  ipadUrl: https://927d296b385a.ngrok-free.app
   aiApi: http://152.136.202.157:3000/api
   voiceApi:
   commonApi:

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

@@ -653,5 +653,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         where fs_user_id = #{userId} and company_user_id = #{companyUserId}
     </select>
 
+    <select id="getBuyStatusByExtId" resultType="java.lang.Boolean">
+        select IF(u.pay_count > 0, true, false)
+        from qw_external_contact ec
+        left join fs_user u on ec.fs_user_id = u.user_id
+        where ec.id = #{qwExternalContactId}
+    </select>
+
 
 </mapper>

+ 16 - 0
fs-service/src/main/resources/mapper/qw/QwSessionMapper.xml

@@ -46,6 +46,22 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         where session_id = #{sessionId}
     </select>
 
+    <select id="selectContactListByQwUserId" resultType="com.fs.qw.vo.QwContactListVO">
+        select
+            s.qw_user_id as id,
+            s.qw_ext_id extId,
+            s.avatar,
+            s.session_id conversationId,
+            s.nick_name displayName,
+            ec.comment_status isBlack,
+            if(u.qw_repeat = 1 OR u.user_repeat = 1, true, false) isRepeat
+        from qw_session s
+        left join qw_external_contact ec on s.qw_ext_id = ec.id
+        left join fs_user u on ec.fs_user_id = u.user_id
+        where s.qw_user_id = #{qwUserId}
+        order by s.update_time desc
+    </select>
+
     <insert id="insertQwSession" parameterType="QwSession" useGeneratedKeys="true" keyProperty="sessionId">
         insert into qw_session
         <trim prefix="(" suffix=")" suffixOverrides=",">

+ 6 - 1
fs-service/src/main/resources/mapper/qw/QwUserMapper.xml

@@ -34,10 +34,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="serverStatus"    column="server_status"    />
         <result property="isAuto"    column="is_auto"    />
         <result property="videoGetStatus"    column="video_get_status"    />
+        <result property="avatar"    column="avatar"    />
     </resultMap>
 
     <sql id="selectQwUserVo">
-        select id,is_auto, video_get_status, qw_user_id,server_id,server_status,ipad_status,config_id,vid,uid,contact_way,app_key, qw_user_name, department, openid, company_id, company_user_id, corp_id, status, is_del, welcome_text, welcome_image, is_send_msg,app_key,qw_hook_id,fastGpt_role_id,login_status,tool_status,login_code_url,version from qw_user
+        select id,is_auto, video_get_status, qw_user_id,server_id,server_status,ipad_status,config_id,vid,uid,contact_way,app_key, qw_user_name, department, openid, company_id, company_user_id, corp_id, status, is_del, welcome_text, welcome_image, is_send_msg,app_key,qw_hook_id,fastGpt_role_id,login_status,tool_status,login_code_url,version,avatar from qw_user
         </sql>
 
     <select id="selectQwUserList" parameterType="QwUser" resultMap="QwUserResult">
@@ -61,6 +62,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="loginCodeUrl != null "> and login_code_url = #{loginCodeUrl}</if>
             <if test="version != null "> and version = #{version}</if>
             <if test="isAuto != null "> and is_auto = #{isAuto}</if>
+            <if test="avatar != null "> and avatar = #{avatar}</if>
         </where>
     </select>
 
@@ -114,6 +116,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="serverStatus != null">server_status,</if>
             <if test="isAuto != null">is_auto,</if>
             <if test="videoGetStatus != null">video_get_status,</if>
+            <if test="avatar != null">avatar,</if>
          </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="qwUserId != null">#{qwUserId},</if>
@@ -142,6 +145,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="serverId != null">#{serverId},</if>
             <if test="isAuto != null">#{isAuto},</if>
             <if test="videoGetStatus != null">#{videoGetStatus},</if>
+            <if test="avatar != null">#{avatar},</if>
          </trim>
     </insert>
 
@@ -176,6 +180,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="serverStatus != null">server_status = #{serverStatus},</if>
             <if test="isAuto != null">is_auto = #{isAuto},</if>
             <if test="videoGetStatus != null">video_get_status = #{videoGetStatus},</if>
+            <if test="avatar != null">avatar = #{avatar},</if>
         </trim>
         where id = #{id}
     </update>