Quellcode durchsuchen

feat:企微会话存档(添加会话key、查询展示)

caoliqin vor 1 Woche
Ursprung
Commit
9a5aa5294f

+ 122 - 0
fs-company/src/main/java/com/fs/company/controller/qw/QwMsgAuditMessageController.java

@@ -0,0 +1,122 @@
+package com.fs.company.controller.qw;
+
+import java.util.Collections;
+import java.util.List;
+
+import com.fs.qw.vo.QwMsgAuditConversationVO;
+import com.fs.qw.domain.audit.QwMsgAuditMessage;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.enums.BusinessType;
+import com.fs.qw.service.IQwMsgAuditMessageService;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.common.core.page.TableDataInfo;
+
+/**
+ * 企微会话存档结构化消息Controller
+ *
+ * @author fs
+ * @date 2026-03-19
+ */
+@RestController
+@RequestMapping("/qw/qwMsgAuditMessage")
+public class QwMsgAuditMessageController extends BaseController
+{
+    @Autowired
+    private IQwMsgAuditMessageService qwMsgAuditMessageService;
+
+    /**
+     * 查询企微会话存档结构化消息列表
+     */
+    @PreAuthorize("@ss.hasPermi('qw:qwMsgAuditMessage:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(QwMsgAuditMessage qwMsgAuditMessage)
+    {
+        startPage();
+        List<QwMsgAuditMessage> list = qwMsgAuditMessageService.selectQwMsgAuditMessageList(qwMsgAuditMessage);
+        return getDataTable(list);
+    }
+
+    /**
+     * 会话列表
+     */
+    @PreAuthorize("@ss.hasPermi('qw:qwMsgAuditMessage:conversationList')")
+    @GetMapping("/conversationList")
+    public TableDataInfo conversationList(@RequestParam String corpId, @RequestParam String qwUserId, @RequestParam String chatScope)
+    {
+        if ("group".equalsIgnoreCase(chatScope)) {
+            return getDataTable(Collections.emptyList());
+        }
+        startPage();
+        List<QwMsgAuditConversationVO> list = qwMsgAuditMessageService.selectConversationList(corpId, qwUserId, chatScope);
+        return getDataTable(list);
+    }
+
+    /**
+     * 导出企微会话存档结构化消息列表
+     */
+    @PreAuthorize("@ss.hasPermi('qw:qwMsgAuditMessage:export')")
+    @Log(title = "企微会话存档结构化消息", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(QwMsgAuditMessage qwMsgAuditMessage)
+    {
+        List<QwMsgAuditMessage> list = qwMsgAuditMessageService.selectQwMsgAuditMessageList(qwMsgAuditMessage);
+        ExcelUtil<QwMsgAuditMessage> util = new ExcelUtil<QwMsgAuditMessage>(QwMsgAuditMessage.class);
+        return util.exportExcel(list, "企微会话存档结构化消息数据");
+    }
+
+    /**
+     * 获取企微会话存档结构化消息详细信息
+     */
+    @PreAuthorize("@ss.hasPermi('qw:qwMsgAuditMessage:query')")
+    @GetMapping(value = "/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id)
+    {
+        return AjaxResult.success(qwMsgAuditMessageService.selectQwMsgAuditMessageById(id));
+    }
+
+    /**
+     * 新增企微会话存档结构化消息
+     */
+    @PreAuthorize("@ss.hasPermi('qw:qwMsgAuditMessage:add')")
+    @Log(title = "企微会话存档结构化消息", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody QwMsgAuditMessage qwMsgAuditMessage)
+    {
+        return toAjax(qwMsgAuditMessageService.insertQwMsgAuditMessage(qwMsgAuditMessage));
+    }
+
+    /**
+     * 修改企微会话存档结构化消息
+     */
+    @PreAuthorize("@ss.hasPermi('qw:qwMsgAuditMessage:edit')")
+    @Log(title = "企微会话存档结构化消息", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody QwMsgAuditMessage qwMsgAuditMessage)
+    {
+        return toAjax(qwMsgAuditMessageService.updateQwMsgAuditMessage(qwMsgAuditMessage));
+    }
+
+    /**
+     * 删除企微会话存档结构化消息
+     */
+    @PreAuthorize("@ss.hasPermi('qw:qwMsgAuditMessage:remove')")
+    @Log(title = "企微会话存档结构化消息", businessType = BusinessType.DELETE)
+	@DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids)
+    {
+        return toAjax(qwMsgAuditMessageService.deleteQwMsgAuditMessageByIds(ids));
+    }
+}

+ 44 - 0
fs-qw-api-msg/src/main/java/com/fs/app/msgarchives/handler/QwMsgAuditMsgHandler.java

@@ -1,9 +1,13 @@
 package com.fs.app.msgarchives.handler;
 
+import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
+import com.fs.app.msgarchives.MsgAuditConversationKeyUtil;
 import com.fs.qw.domain.audit.QwMsgAuditMessage;
+import org.springframework.util.StringUtils;
 
 import java.util.Date;
+import java.util.TreeSet;
 
 /**
  * 会话存档-消息处理策略(按 msgtype 扩展)
@@ -36,6 +40,11 @@ public interface QwMsgAuditMsgHandler {
             msg.setFromUser(plain.getString("from"));
             msg.setToList(plain.getJSONArray("tolist") == null ? null : plain.getJSONArray("tolist").toJSONString());
             msg.setRoomId(plain.getString("roomid"));
+            msg.setConversationKey(build(
+                    corpId,
+                    plain.getString("roomid"),
+                    plain.getString("from"),
+                    plain.getJSONArray("tolist")));
         }
     }
 
@@ -43,5 +52,40 @@ public interface QwMsgAuditMsgHandler {
      * 从明文 JSON 解析出结构化字段
      */
     QwMsgAuditMessage buildMessage(String corpId, Long seq, Long rawId, JSONObject plain);
+
+    /**
+     *  构建 conversationKey
+     * @param corpId
+     * @param roomId
+     * @param fromUser
+     * @param tolist
+     * @return
+     */
+    public static String build(String corpId, String roomId, String fromUser, JSONArray tolist) {
+        if (!StringUtils.hasText(corpId)) {
+            return null;
+        }
+        String cid = corpId.trim();
+        if (StringUtils.hasText(roomId)) {
+            return cid + ":room:" + roomId.trim();
+        }
+        TreeSet<String> ids = new TreeSet<>();
+        if (StringUtils.hasText(fromUser)) {
+            ids.add(fromUser.trim());
+        }
+        if (tolist != null) {
+            for (int i = 0; i < tolist.size(); i++) {
+                String id = tolist.getString(i);
+                if (StringUtils.hasText(id)) {
+                    ids.add(id.trim());
+                }
+            }
+        }
+        if (ids.isEmpty()) {
+            return cid + ":p2p:unknown";
+        }
+        return cid + ":p2p:" + String.join(":", ids);
+    }
+
 }
 

+ 29 - 0
fs-service/src/main/java/com/fs/qw/domain/audit/QwMsgAuditMessage.java

@@ -1,5 +1,6 @@
 package com.fs.qw.domain.audit;
 
+import com.baomidou.mybatisplus.annotation.TableField;
 import lombok.Data;
 
 import java.util.Date;
@@ -31,6 +32,11 @@ public class QwMsgAuditMessage {
      */
     private String roomId;
 
+    /**
+     * 会话维度键:群为 corpId:room:roomId;单聊为 corpId:p2p:参与方id字典序拼接,便于按会话查询
+     */
+    private String conversationKey;
+
     /**
      * 发送人身份:1 企业内部(销售) 2 企业外部
      */
@@ -64,5 +70,28 @@ public class QwMsgAuditMessage {
     private Long rawId;
 
     private Date createTime;
+
+    /**
+     * 销售id
+     */
+    @TableField(exist = false)
+    private String qwUserId;
+    /**
+     * 聊天区域,single-个人;group-群聊
+     */
+    @TableField(exist = false)
+    private String chatScope;
+
+    /**
+     * qw_external_contact.name(列表接口填充,非表字段)
+     */
+    @TableField(exist = false)
+    private String contactName;
+
+    /**
+     * qw_user.qw_user_name(列表接口填充,非表字段)
+     */
+    @TableField(exist = false)
+    private String qwUserName;
 }
 

+ 14 - 0
fs-service/src/main/java/com/fs/qw/dto/QwMsgAuditConversationDTO.java

@@ -0,0 +1,14 @@
+package com.fs.qw.dto;
+
+import lombok.Data;
+
+/**
+ * 单聊会话列表查询行
+ */
+@Data
+public class QwMsgAuditConversationDTO {
+
+    private String conversationKey;
+
+    private Long lastMsgTime;
+}

+ 6 - 0
fs-service/src/main/java/com/fs/qw/mapper/QwMsgAuditMessageMapper.java

@@ -2,6 +2,7 @@ package com.fs.qw.mapper;
 
 import java.util.List;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.qw.dto.QwMsgAuditConversationDTO;
 import com.fs.qw.domain.audit.QwMsgAuditMessage;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
@@ -64,6 +65,11 @@ public interface QwMsgAuditMessageMapper extends BaseMapper<QwMsgAuditMessage>{
     @Select("select * from qw_msg_audit_message where corp_id = #{corpId} and seq = #{seq} limit 1")
     QwMsgAuditMessage selectByCorpIdAndSeq(@Param("corpId") String corpId, @Param("seq") Long seq);
 
+    /**
+     * 单聊会话列表:按 conversation_key 分组,取最后消息时间(分页会话列表)
+     */
+    List<QwMsgAuditConversationDTO> selectSingleConversationList(@Param("corpId") String corpId, @Param("qwUserId") String qwUserId);
+
     /**
      * 查询指定企业下指定消息类型中 media_oss_url 为空的记录(待下载上传)
      */

+ 71 - 0
fs-service/src/main/java/com/fs/qw/service/IQwMsgAuditMessageService.java

@@ -0,0 +1,71 @@
+package com.fs.qw.service;
+
+import java.util.List;
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.fs.qw.vo.QwMsgAuditConversationVO;
+import com.fs.qw.domain.audit.QwMsgAuditMessage;
+
+/**
+ * 企微会话存档结构化消息Service接口
+ *
+ * @author fs
+ * @date 2026-03-19
+ */
+public interface IQwMsgAuditMessageService extends IService<QwMsgAuditMessage>{
+    /**
+     * 查询企微会话存档结构化消息
+     *
+     * @param id 企微会话存档结构化消息主键
+     * @return 企微会话存档结构化消息
+     */
+    QwMsgAuditMessage selectQwMsgAuditMessageById(Long id);
+
+    /**
+     * 查询企微会话存档结构化消息列表
+     *
+     * @param qwMsgAuditMessage 企微会话存档结构化消息
+     * @return 企微会话存档结构化消息集合
+     */
+    List<QwMsgAuditMessage> selectQwMsgAuditMessageList(QwMsgAuditMessage qwMsgAuditMessage);
+
+    /**
+     * 会话列表(单聊:聚合会话 + 客户信息;群聊:由调用方传 group 时返回空列表)
+     *
+     * @param corpId    企微主体
+     * @param qwUserId  员工 qw_user.qw_user_id
+     * @param chatScope single / group
+     */
+    List<QwMsgAuditConversationVO> selectConversationList(String corpId, String qwUserId, String chatScope);
+
+    /**
+     * 新增企微会话存档结构化消息
+     *
+     * @param qwMsgAuditMessage 企微会话存档结构化消息
+     * @return 结果
+     */
+    int insertQwMsgAuditMessage(QwMsgAuditMessage qwMsgAuditMessage);
+
+    /**
+     * 修改企微会话存档结构化消息
+     *
+     * @param qwMsgAuditMessage 企微会话存档结构化消息
+     * @return 结果
+     */
+    int updateQwMsgAuditMessage(QwMsgAuditMessage qwMsgAuditMessage);
+
+    /**
+     * 批量删除企微会话存档结构化消息
+     *
+     * @param ids 需要删除的企微会话存档结构化消息主键集合
+     * @return 结果
+     */
+    int deleteQwMsgAuditMessageByIds(Long[] ids);
+
+    /**
+     * 删除企微会话存档结构化消息信息
+     *
+     * @param id 企微会话存档结构化消息主键
+     * @return 结果
+     */
+    int deleteQwMsgAuditMessageById(Long id);
+}

+ 343 - 0
fs-service/src/main/java/com/fs/qw/service/impl/QwMsgAuditMessageServiceImpl.java

@@ -0,0 +1,343 @@
+package com.fs.qw.service.impl;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.fs.common.utils.DateUtils;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.fs.qw.domain.QwExternalContact;
+import com.fs.qw.dto.QwMsgAuditConversationDTO;
+import com.fs.qw.vo.QwMsgAuditConversationVO;
+import com.fs.qw.domain.audit.QwMsgAuditMessage;
+import com.fs.qw.mapper.QwExternalContactMapper;
+import com.fs.qw.mapper.QwMsgAuditMessageMapper;
+import com.fs.qw.mapper.QwUserMapper;
+import com.fs.qw.service.IQwMsgAuditMessageService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+/**
+ * 企微会话存档结构化消息Service业务层处理
+ *
+ * @author fs
+ * @date 2026-03-19
+ */
+@Service
+public class QwMsgAuditMessageServiceImpl extends ServiceImpl<QwMsgAuditMessageMapper, QwMsgAuditMessage> implements IQwMsgAuditMessageService {
+
+    @Autowired
+    private QwExternalContactMapper qwExternalContactMapper;
+
+    @Autowired
+    private QwUserMapper qwUserMapper;
+
+    /**
+     * 查询企微会话存档结构化消息
+     *
+     * @param id 企微会话存档结构化消息主键
+     * @return 企微会话存档结构化消息
+     */
+    @Override
+    public QwMsgAuditMessage selectQwMsgAuditMessageById(Long id)
+    {
+        return baseMapper.selectQwMsgAuditMessageById(id);
+    }
+
+    /**
+     * 查询企微会话存档结构化消息列表
+     *
+     * @param qwMsgAuditMessage 企微会话存档结构化消息
+     * @return 企微会话存档结构化消息
+     */
+    @Override
+    public List<QwMsgAuditMessage> selectQwMsgAuditMessageList(QwMsgAuditMessage qwMsgAuditMessage)
+    {
+        List<QwMsgAuditMessage> list = baseMapper.selectQwMsgAuditMessageList(qwMsgAuditMessage);
+//        fillContactNameAndQwUserName(list);
+        return list;
+    }
+
+    /**
+     * 填充 qw_external_contact.name、qw_user.qw_user_name:<br>
+     * 单聊(无 room_id):按 conversation_key 的 p2p 段拆分参与方,结合可选查询参数 qwUserId 或 wm/wo/wb 前缀区分客户与员工;<br>
+     * 群聊(有 room_id):按 from_user_role + from_user + to_list 解析。
+     */
+//    private void fillContactNameAndQwUserName(List<QwMsgAuditMessage> list)
+//    {
+//        if (list == null || list.isEmpty()) {
+//            return;
+//        }
+//        Map<String, String> qwUserNameCache = new HashMap<>();
+//        Map<String, String> contactNameCache = new HashMap<>();
+//        String[] salesAndExternal = new String[2];
+//        for (QwMsgAuditMessage m : list) {
+//            String corpId = m.getCorpId();
+//            if (!StringUtils.hasText(corpId)) {
+//                continue;
+//            }
+//            String salesQwUserId = null;
+//            String externalUserid = null;
+//            boolean isGroup = StringUtils.hasText(m.getRoomId());
+//            if (!isGroup) {
+//                resolveSingleChatSalesAndExternal(corpId, m.getConversationKey(), m.getQwUserId(), salesAndExternal);
+//                salesQwUserId = salesAndExternal[0];
+//                externalUserid = salesAndExternal[1];
+//            } else {
+//                resolveGroupChatSalesAndExternal(m, salesAndExternal);
+//                salesQwUserId = salesAndExternal[0];
+//                externalUserid = salesAndExternal[1];
+//            }
+//            if (StringUtils.hasText(salesQwUserId)) {
+//                String uk = corpId + "\0" + salesQwUserId;
+//                if (!qwUserNameCache.containsKey(uk)) {
+//                    String n = qwUserMapper.selectQwUserName(salesQwUserId, corpId);
+//                    qwUserNameCache.put(uk, n != null ? n : "");
+//                }
+//                String qn = qwUserNameCache.get(uk);
+//                if (StringUtils.hasText(qn)) {
+//                    m.setQwUserName(qn);
+//                }
+//            }
+//            if (StringUtils.hasText(salesQwUserId) && StringUtils.hasText(externalUserid)) {
+//                String ck = corpId + "\0" + salesQwUserId + "\0" + externalUserid;
+//                if (!contactNameCache.containsKey(ck)) {
+//                    QwExternalContact ec = qwExternalContactMapper.selectQwExternalContactUserIdAndExternalIdAndCompanyId(
+//                        externalUserid, salesQwUserId, corpId);
+//                    contactNameCache.put(ck, ec != null && ec.getName() != null ? ec.getName() : "");
+//                }
+//                String cn = contactNameCache.get(ck);
+//                if (StringUtils.hasText(cn)) {
+//                    m.setContactName(cn);
+//                }
+//            }
+//        }
+//    }
+//
+//    /**
+//     * 单聊:从 conversation_key 的 {@code corpId:p2p:} 后拆分参与方 id;优先用请求中的 qwUserId 锁定员工,另一方为外部联系人;
+//     * 否则用 wm/wo/wb 前缀识别外部联系人(与入库逻辑一致)。
+//     */
+//    private static void resolveSingleChatSalesAndExternal(String corpId, String conversationKey, String qwUserIdHint, String[] out)
+//    {
+//        out[0] = null;
+//        out[1] = null;
+//        if (conversationKey == null) {
+//            return;
+//        }
+//        String p2p = corpId + ":p2p:";
+//        if (!conversationKey.startsWith(p2p)) {
+//            return;
+//        }
+//        String rest = conversationKey.substring(p2p.length());
+//        if (rest.isEmpty() || "unknown".equalsIgnoreCase(rest)) {
+//            return;
+//        }
+//        String[] parts = rest.split(":", -1);
+//        if (StringUtils.hasText(qwUserIdHint)) {
+//            out[0] = qwUserIdHint;
+//            for (String part : parts) {
+//                if (part != null && !part.isEmpty() && !qwUserIdHint.equals(part)) {
+//                    out[1] = part;
+//                    return;
+//                }
+//            }
+//            return;
+//        }
+//        String ext = null;
+//        String nonExt = null;
+//        for (String part : parts) {
+//            if (part == null || part.isEmpty()) {
+//                continue;
+//            }
+//            if (isExternalWechatId(part)) {
+//                ext = part;
+//            } else if (nonExt == null) {
+//                nonExt = part;
+//            }
+//        }
+//        if (ext != null && nonExt != null) {
+//            out[0] = nonExt;
+//            out[1] = ext;
+//        }
+//    }
+//
+//    /**
+//     * 群聊:from_user_role=1 时 from 为员工、tolist 中另一参与方为客户;=2 时相反。
+//     */
+//    private static void resolveGroupChatSalesAndExternal(QwMsgAuditMessage m, String[] out)
+//    {
+//        out[0] = null;
+//        out[1] = null;
+//        Integer role = m.getFromUserRole();
+//        if (!Integer.valueOf(1).equals(role) && !Integer.valueOf(2).equals(role)) {
+//            return;
+//        }
+//        String from = m.getFromUser();
+//        JSONArray tolist = parseToListJson(m.getToList());
+//        if (Integer.valueOf(1).equals(role)) {
+//            out[0] = from;
+//            out[1] = firstOtherInTolist(tolist, from);
+//        } else {
+//            out[1] = from;
+//            out[0] = firstOtherInTolist(tolist, from);
+//        }
+//    }
+//
+//    /** 与 QwMsgAuditIngestService 一致:wm/wo/wb 视为外部联系人 id */
+//    private static boolean isExternalWechatId(String id)
+//    {
+//        if (id == null || id.length() < 2) {
+//            return false;
+//        }
+//        String prefix = id.substring(0, 2).toLowerCase(Locale.ROOT);
+//        return "wm".equals(prefix) || "wo".equals(prefix) || "wb".equals(prefix);
+//    }
+//
+//    private static JSONArray parseToListJson(String toListJson)
+//    {
+//        if (!StringUtils.hasText(toListJson)) {
+//            return null;
+//        }
+//        try {
+//            return JSON.parseArray(toListJson);
+//        } catch (Exception e) {
+//            return null;
+//        }
+//    }
+//
+//    /** tolist 中与发送方不同的第一个 id */
+//    private static String firstOtherInTolist(JSONArray arr, String fromUser)
+//    {
+//        if (arr == null || arr.isEmpty()) {
+//            return null;
+//        }
+//        for (int i = 0; i < arr.size(); i++) {
+//            String s = arr.getString(i);
+//            if (!StringUtils.hasText(s)) {
+//                continue;
+//            }
+//            if (!s.equals(fromUser)) {
+//                return s;
+//            }
+//        }
+//        return null;
+//    }
+
+    @Override
+    public List<QwMsgAuditConversationVO> selectConversationList(String corpId, String qwUserId, String chatScope)
+    {
+        if ("group".equalsIgnoreCase(chatScope)) {
+            return Collections.emptyList();
+        }
+        if (!StringUtils.hasText(corpId) || !StringUtils.hasText(qwUserId)) {
+            return Collections.emptyList();
+        }
+        List<QwMsgAuditConversationDTO> rows = baseMapper.selectSingleConversationList(corpId, qwUserId);
+        List<QwMsgAuditConversationVO> vos = new ArrayList<>(rows.size());
+        for (QwMsgAuditConversationDTO row : rows) {
+            QwMsgAuditConversationVO vo = new QwMsgAuditConversationVO();
+            vo.setConversationKey(row.getConversationKey());
+            vo.setLastMsgTime(row.getLastMsgTime());
+            vo.setRoomId(null);
+            String externalUserid = resolveExternalUseridFromP2pKey(row.getConversationKey(), corpId, qwUserId);
+            if (StringUtils.hasText(externalUserid)) {
+                QwExternalContact ec = qwExternalContactMapper.selectQwExternalContactUserIdAndExternalIdAndCompanyId(externalUserid, qwUserId, corpId);
+                if (ec != null) {
+                    vo.setExternalUserId(ec.getExternalUserId());
+                    vo.setName(ec.getName());
+                    vo.setRemark(ec.getRemark());
+                    vo.setAvatar(ec.getAvatar());
+                } else {
+                    vo.setExternalUserId(externalUserid);
+                }
+            }
+            vos.add(vo);
+        }
+        return vos;
+    }
+
+    /**
+     *  解析出外部联系人id
+     * @param conversationKey 会话key
+     * @param corpId
+     * @param qwUserId
+     * @return
+     */
+    private static String resolveExternalUseridFromP2pKey(String conversationKey, String corpId, String qwUserId)
+    {
+        if (!StringUtils.hasText(conversationKey) || !StringUtils.hasText(corpId) || !StringUtils.hasText(qwUserId)) {
+            return null;
+        }
+        String prefix = corpId + ":p2p:";
+        if (!conversationKey.startsWith(prefix)) {
+            return null;
+        }
+        String rest = conversationKey.substring(prefix.length());
+        if (rest.isEmpty() || "unknown".equalsIgnoreCase(rest)) {
+            return null;
+        }
+        String[] parts = rest.split(":", -1);
+        for (String part : parts) {
+            if (part != null && !part.isEmpty() && !qwUserId.equals(part)) {
+                return part;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 新增企微会话存档结构化消息
+     *
+     * @param qwMsgAuditMessage 企微会话存档结构化消息
+     * @return 结果
+     */
+    @Override
+    public int insertQwMsgAuditMessage(QwMsgAuditMessage qwMsgAuditMessage)
+    {
+        qwMsgAuditMessage.setCreateTime(DateUtils.getNowDate());
+        return baseMapper.insertQwMsgAuditMessage(qwMsgAuditMessage);
+    }
+
+    /**
+     * 修改企微会话存档结构化消息
+     *
+     * @param qwMsgAuditMessage 企微会话存档结构化消息
+     * @return 结果
+     */
+    @Override
+    public int updateQwMsgAuditMessage(QwMsgAuditMessage qwMsgAuditMessage)
+    {
+        return baseMapper.updateQwMsgAuditMessage(qwMsgAuditMessage);
+    }
+
+    /**
+     * 批量删除企微会话存档结构化消息
+     *
+     * @param ids 需要删除的企微会话存档结构化消息主键
+     * @return 结果
+     */
+    @Override
+    public int deleteQwMsgAuditMessageByIds(Long[] ids)
+    {
+        return baseMapper.deleteQwMsgAuditMessageByIds(ids);
+    }
+
+    /**
+     * 删除企微会话存档结构化消息信息
+     *
+     * @param id 企微会话存档结构化消息主键
+     * @return 结果
+     */
+    @Override
+    public int deleteQwMsgAuditMessageById(Long id)
+    {
+        return baseMapper.deleteQwMsgAuditMessageById(id);
+    }
+}

+ 29 - 0
fs-service/src/main/java/com/fs/qw/vo/QwMsgAuditConversationVO.java

@@ -0,0 +1,29 @@
+package com.fs.qw.vo;
+
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+/**
+ * 会话存档-会话列表
+ */
+@Data
+public class QwMsgAuditConversationVO {
+
+    private String conversationKey;
+
+    /** 客户 external_user_id */
+    private String externalUserId;
+
+    private String name;
+
+    private String remark;
+
+    /** 群聊 roomid;单聊为空 */
+    private String roomId;
+
+    /** 该会话最后一条消息时间(ms) */
+    private Long lastMsgTime;
+
+    /** 头像 */
+    private String avatar;
+}

+ 28 - 1
fs-service/src/main/resources/mapper/qw/QwMsgAuditMessageMapper.xml

@@ -14,6 +14,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="fromUser"    column="from_user"    />
         <result property="toList"    column="to_list"    />
         <result property="roomId"    column="room_id"    />
+        <result property="conversationKey"    column="conversation_key"    />
         <result property="fromUserRole"    column="from_user_role"    />
         <result property="textContent"    column="text_content"    />
         <result property="mediaSdkfileid"    column="media_sdkfileid"    />
@@ -30,7 +31,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </resultMap>
 
     <sql id="selectQwMsgAuditMessageVo">
-        select id, corp_id, seq, msg_id, msg_time, msg_type, from_user, to_list, room_id, from_user_role, text_content, media_sdkfileid, media_md5sum, media_size, media_play_length, media_width, media_height, media_file_name, media_file_ext, media_oss_url, raw_id, create_time from qw_msg_audit_message
+        select id, corp_id, seq, msg_id, msg_time, msg_type, from_user, to_list, room_id, conversation_key, from_user_role, text_content, media_sdkfileid, media_md5sum, media_size, media_play_length, media_width, media_height, media_file_name, media_file_ext, media_oss_url, raw_id, create_time from qw_msg_audit_message
     </sql>
 
     <select id="selectQwMsgAuditMessageList" parameterType="QwMsgAuditMessage" resultMap="QwMsgAuditMessageResult">
@@ -44,6 +45,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="fromUser != null  and fromUser != ''"> and from_user = #{fromUser}</if>
             <if test="toList != null  and toList != ''"> and to_list = #{toList}</if>
             <if test="roomId != null  and roomId != ''"> and room_id = #{roomId}</if>
+            <if test="conversationKey != null  and conversationKey != ''"> and conversation_key = #{conversationKey}</if>
             <if test="fromUserRole != null "> and from_user_role = #{fromUserRole}</if>
             <if test="textContent != null  and textContent != ''"> and text_content = #{textContent}</if>
             <if test="mediaSdkfileid != null  and mediaSdkfileid != ''"> and media_sdkfileid = #{mediaSdkfileid}</if>
@@ -56,6 +58,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="mediaFileExt != null  and mediaFileExt != ''"> and media_file_ext = #{mediaFileExt}</if>
             <if test="rawId != null "> and raw_id = #{rawId}</if>
         </where>
+            order by msg_time desc
     </select>
 
     <select id="selectQwMsgAuditMessageById" parameterType="Long" resultMap="QwMsgAuditMessageResult">
@@ -74,6 +77,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="fromUser != null">from_user,</if>
             <if test="toList != null">to_list,</if>
             <if test="roomId != null">room_id,</if>
+            <if test="conversationKey != null">conversation_key,</if>
             <if test="fromUserRole != null">from_user_role,</if>
             <if test="textContent != null">text_content,</if>
             <if test="mediaSdkfileid != null">media_sdkfileid,</if>
@@ -97,6 +101,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="fromUser != null">#{fromUser},</if>
             <if test="toList != null">#{toList},</if>
             <if test="roomId != null">#{roomId},</if>
+            <if test="conversationKey != null">#{conversationKey},</if>
             <if test="fromUserRole != null">#{fromUserRole},</if>
             <if test="textContent != null">#{textContent},</if>
             <if test="mediaSdkfileid != null">#{mediaSdkfileid},</if>
@@ -124,6 +129,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="fromUser != null">from_user = #{fromUser},</if>
             <if test="toList != null">to_list = #{toList},</if>
             <if test="roomId != null">room_id = #{roomId},</if>
+            <if test="conversationKey != null">conversation_key = #{conversationKey},</if>
             <if test="fromUserRole != null">from_user_role = #{fromUserRole},</if>
             <if test="textContent != null">text_content = #{textContent},</if>
             <if test="mediaSdkfileid != null">media_sdkfileid = #{mediaSdkfileid},</if>
@@ -152,6 +158,27 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </foreach>
     </delete>
 
+    <!-- 单聊会话列表 -->
+    <select id="selectSingleConversationList" resultType="com.fs.qw.dto.QwMsgAuditConversationDTO">
+        SELECT m.conversation_key AS conversationKey,
+               MAX(m.msg_time)    AS lastMsgTime
+        FROM qw_msg_audit_message m
+        WHERE m.corp_id = #{corpId}
+          AND (m.room_id IS NULL OR m.room_id = '')
+          AND m.conversation_key IS NOT NULL
+          AND m.conversation_key &lt;&gt; ''
+          AND m.conversation_key LIKE CONCAT(#{corpId}, ':p2p:%')
+          AND (
+            m.from_user = #{qwUserId}
+            OR (
+                m.to_list IS NOT NULL AND m.to_list &lt;&gt; '' AND m.to_list &lt;&gt; '[]'
+                AND JSON_CONTAINS(CAST(m.to_list AS JSON), JSON_QUOTE(#{qwUserId}), '$')
+                )
+            )
+        GROUP BY m.conversation_key
+        ORDER BY lastMsgTime DESC
+    </select>
+
 <!--    <insert id="batchInsertMessage" parameterType="com.fs.qw.domain.audit.QwMsgAuditMessage" useGeneratedKeys="true" keyProperty="id">-->
 <!--        insert into qw_msg_audit_message-->
 <!--        (corp_id, seq, msg_id, msg_time, msg_type, text_content,-->