Commit ae36d1ad68c5ff7f5e84cb0e8e20bc30fb844e42

Authored by YunaiV
1 parent 5f88c9ca

✨ feat(im): 增加【消息引用】的功能

Showing 15 changed files with 268 additions and 16 deletions
yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/JsonUtils.java
@@ -22,6 +22,7 @@ import java.lang.reflect.Type; @@ -22,6 +22,7 @@ import java.lang.reflect.Type;
22 import java.time.LocalDateTime; 22 import java.time.LocalDateTime;
23 import java.util.ArrayList; 23 import java.util.ArrayList;
24 import java.util.List; 24 import java.util.List;
  25 +import java.util.Map;
25 26
26 /** 27 /**
27 * JSON 工具类 28 * JSON 工具类
@@ -173,6 +174,23 @@ public class JsonUtils { @@ -173,6 +174,23 @@ public class JsonUtils {
173 } 174 }
174 } 175 }
175 176
  177 + /**
  178 + * 解析 JSON 字符串成 Map,空字符串或解析失败返回 null
  179 + *
  180 + * @param text JSON 字符串
  181 + * @return Map 对象
  182 + */
  183 + public static Map<String, Object> parseMap(String text) {
  184 + if (StrUtil.isEmpty(text)) {
  185 + return null;
  186 + }
  187 + try {
  188 + return objectMapper.readValue(text, new TypeReference<Map<String, Object>>() {});
  189 + } catch (IOException e) {
  190 + return null;
  191 + }
  192 + }
  193 +
176 public static <T> List<T> parseArray(String text, Class<T> clazz) { 194 public static <T> List<T> parseArray(String text, Class<T> clazz) {
177 if (StrUtil.isEmpty(text)) { 195 if (StrUtil.isEmpty(text)) {
178 return new ArrayList<>(); 196 return new ArrayList<>();
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImGroupMessageController.java
@@ -64,13 +64,14 @@ public class ImGroupMessageController { @@ -64,13 +64,14 @@ public class ImGroupMessageController {
64 return success(BeanUtils.toBean(message, ImGroupMessageRespVO.class)); 64 return success(BeanUtils.toBean(message, ImGroupMessageRespVO.class));
65 } 65 }
66 66
  67 + // TODO @AI:改成 get-read-user-ids
67 @GetMapping("/read-users") 68 @GetMapping("/read-users")
68 @Operation(summary = "获取群消息已读用户列表") 69 @Operation(summary = "获取群消息已读用户列表")
69 @Parameter(name = "groupId", description = "群编号", required = true, example = "1") 70 @Parameter(name = "groupId", description = "群编号", required = true, example = "1")
70 @Parameter(name = "messageId", description = "消息编号", required = true, example = "1") 71 @Parameter(name = "messageId", description = "消息编号", required = true, example = "1")
71 - public CommonResult<List<Long>> getGroupReadUsers(@RequestParam("groupId") Long groupId,  
72 - @RequestParam("messageId") Long messageId) {  
73 - return success(groupMessageService.getGroupReadUsers(getLoginUserId(), groupId, messageId)); 72 + public CommonResult<List<Long>> getGroupReadUserIds(@RequestParam("groupId") Long groupId,
  73 + @RequestParam("messageId") Long messageId) {
  74 + return success(groupMessageService.getGroupReadUserIds(getLoginUserId(), groupId, messageId));
74 } 75 }
75 76
76 @GetMapping("/list") 77 @GetMapping("/list")
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java
@@ -16,6 +16,7 @@ public interface ErrorCodeConstants { @@ -16,6 +16,7 @@ public interface ErrorCodeConstants {
16 ErrorCode MESSAGE_SENSITIVE_WORD_BLOCKED = new ErrorCode(1_040_300_004, "消息包含敏感词,无法发送"); 16 ErrorCode MESSAGE_SENSITIVE_WORD_BLOCKED = new ErrorCode(1_040_300_004, "消息包含敏感词,无法发送");
17 ErrorCode MESSAGE_PULL_SIZE_EXCEEDED = new ErrorCode(1_040_300_005, "单次拉取消息数量不能超过 {} 条"); 17 ErrorCode MESSAGE_PULL_SIZE_EXCEEDED = new ErrorCode(1_040_300_005, "单次拉取消息数量不能超过 {} 条");
18 ErrorCode MESSAGE_RECALL_TIMEOUT = new ErrorCode(1_040_300_007, "超过 {} 分钟的消息无法撤回"); 18 ErrorCode MESSAGE_RECALL_TIMEOUT = new ErrorCode(1_040_300_007, "超过 {} 分钟的消息无法撤回");
  19 + ErrorCode MESSAGE_QUOTE_INVALID = new ErrorCode(1_040_300_008, "引用的消息不可用");
19 20
20 // ========== 群 (1-040-400-000) ========== 21 // ========== 群 (1-040-400-000) ==========
21 ErrorCode GROUP_NOT_EXISTS = new ErrorCode(1_040_400_000, "群不存在"); 22 ErrorCode GROUP_NOT_EXISTS = new ErrorCode(1_040_400_000, "群不存在");
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java
@@ -62,7 +62,7 @@ public interface ImGroupMessageService { @@ -62,7 +62,7 @@ public interface ImGroupMessageService {
62 * @param messageId 消息编号 62 * @param messageId 消息编号
63 * @return 已读用户编号列表 63 * @return 已读用户编号列表
64 */ 64 */
65 - List<Long> getGroupReadUsers(Long userId, Long groupId, Long messageId); 65 + List<Long> getGroupReadUserIds(Long userId, Long groupId, Long messageId);
66 66
67 /** 67 /**
68 * 查询群聊历史消息(游标拉取) 68 * 查询群聊历史消息(游标拉取)
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java
@@ -24,7 +24,9 @@ import cn.iocoder.yudao.module.im.service.group.ImGroupService; @@ -24,7 +24,9 @@ import cn.iocoder.yudao.module.im.service.group.ImGroupService;
24 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; 24 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
25 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; 25 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
26 import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; 26 import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO;
  27 +import cn.iocoder.yudao.module.im.service.websocket.dto.message.QuoteMessage;
27 import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage; 28 import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage;
  29 +import cn.iocoder.yudao.module.im.util.ImMessageUtils;
28 import jakarta.annotation.Resource; 30 import jakarta.annotation.Resource;
29 import lombok.extern.slf4j.Slf4j; 31 import lombok.extern.slf4j.Slf4j;
30 import org.springframework.scheduling.annotation.Async; 32 import org.springframework.scheduling.annotation.Async;
@@ -84,13 +86,15 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -84,13 +86,15 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
84 } 86 }
85 // 1.2 校验群存在、发送人仍在群中 87 // 1.2 校验群存在、发送人仍在群中
86 groupService.validateGroupExists(reqVO.getGroupId()); 88 groupService.validateGroupExists(reqVO.getGroupId());
87 - groupMemberService.validateMemberInGroup(reqVO.getGroupId(), senderId); 89 + ImGroupMemberDO senderMember = groupMemberService.validateMemberInGroup(reqVO.getGroupId(), senderId);
88 // 1.3 文本消息敏感词过滤 90 // 1.3 文本消息敏感词过滤
89 if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) { 91 if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) {
90 sensitiveWordService.validateText(reqVO.getContent()); 92 sensitiveWordService.validateText(reqVO.getContent());
91 } 93 }
92 94
93 - // 2. 构建并保存消息 95 + // 2.1 引用 quote 消息规范化
  96 + reqVO.setContent(normalizeQuoteContent(reqVO, senderMember));
  97 + // 2.2 构建并保存消息
94 ImGroupMessageDO message = BeanUtils.toBean(reqVO, ImGroupMessageDO.class, m -> m 98 ImGroupMessageDO message = BeanUtils.toBean(reqVO, ImGroupMessageDO.class, m -> m
95 .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) 99 .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now())
96 .setReceiptStatus(Boolean.TRUE.equals(reqVO.getReceipt()) 100 .setReceiptStatus(Boolean.TRUE.equals(reqVO.getReceipt())
@@ -319,9 +323,8 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -319,9 +323,8 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
319 return recallMessage; 323 return recallMessage;
320 } 324 }
321 325
322 - // TODO @芋艿:这个方法在优化下;  
323 @Override 326 @Override
324 - public List<Long> getGroupReadUsers(Long userId, Long groupId, Long messageId) { 327 + public List<Long> getGroupReadUserIds(Long userId, Long groupId, Long messageId) {
325 // 1.1 校验用户在群中(权限校验) 328 // 1.1 校验用户在群中(权限校验)
326 groupMemberService.validateMemberInGroup(groupId, userId); 329 groupMemberService.validateMemberInGroup(groupId, userId);
327 // 1.2 获取消息 330 // 1.2 获取消息
@@ -433,6 +436,42 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -433,6 +436,42 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
433 // ========== 私有工具方法 ========== 436 // ========== 私有工具方法 ==========
434 437
435 /** 438 /**
  439 + * 群聊引用消息规范化
  440 + *
  441 + * @param reqVO 发送请求
  442 + * @param senderMember 发送人成员
  443 + * @return 规范化后的 content
  444 + */
  445 + private String normalizeQuoteContent(ImGroupMessageSendReqVO reqVO, ImGroupMemberDO senderMember) {
  446 + // 解析客户端 content 里的 quote.messageId
  447 + Long quoteMessageId = ImMessageUtils.parseQuoteMessageId(reqVO.getContent());
  448 +
  449 + // 情况一:没有 quoteMessageId,直接 remove 掉 content 里可能伪造的 quote 字段
  450 + if (quoteMessageId == null) {
  451 + return ImMessageUtils.removeQuote(reqVO.getContent());
  452 + }
  453 +
  454 + // 情况二:有 quoteMessageId,加载原消息并校验
  455 + ImGroupMessageDO original = groupMessageMapper.selectById(quoteMessageId);
  456 + if (original == null
  457 + || ImMessageStatusEnum.RECALL.getStatus().equals(original.getStatus())) {
  458 + throw exception(MESSAGE_QUOTE_INVALID);
  459 + }
  460 + // 校验是同群
  461 + if (ObjUtil.notEqual(original.getGroupId(), reqVO.getGroupId())) {
  462 + throw exception(MESSAGE_QUOTE_INVALID);
  463 + }
  464 + // 校验对发送人可见(入群时间 / 退群时间 / 定向接收)
  465 + if (!isMessageVisible(original, senderMember, senderMember.getUserId())) {
  466 + throw exception(MESSAGE_QUOTE_INVALID);
  467 + }
  468 + // 构建 quote 对象并注入 content
  469 + QuoteMessage quote = ImMessageUtils.buildQuote(original.getId(),
  470 + original.getSenderId(), original.getType(), original.getContent());
  471 + return ImMessageUtils.appendQuote(reqVO.getContent(), quote);
  472 + }
  473 +
  474 + /**
436 * 校验群聊消息存在 475 * 校验群聊消息存在
437 * 476 *
438 * @param messageId 消息编号 477 * @param messageId 消息编号
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java
@@ -17,7 +17,9 @@ import cn.iocoder.yudao.module.im.service.friend.ImFriendService; @@ -17,7 +17,9 @@ import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
17 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; 17 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
18 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; 18 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
19 import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; 19 import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
  20 +import cn.iocoder.yudao.module.im.service.websocket.dto.message.QuoteMessage;
20 import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage; 21 import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage;
  22 +import cn.iocoder.yudao.module.im.util.ImMessageUtils;
21 import jakarta.annotation.Resource; 23 import jakarta.annotation.Resource;
22 import lombok.extern.slf4j.Slf4j; 24 import lombok.extern.slf4j.Slf4j;
23 import org.springframework.stereotype.Service; 25 import org.springframework.stereotype.Service;
@@ -71,7 +73,10 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { @@ -71,7 +73,10 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
71 sensitiveWordService.validateText(reqVO.getContent()); 73 sensitiveWordService.validateText(reqVO.getContent());
72 } 74 }
73 75
74 - // 2. 构建并保存消息 76 +
  77 + // 2.1 引用 quote 消息规范化
  78 + reqVO.setContent(normalizeQuoteContent(reqVO, senderId));
  79 + // 2.2 构建并保存消息
75 ImPrivateMessageDO message = BeanUtils.toBean(reqVO, ImPrivateMessageDO.class, m -> m 80 ImPrivateMessageDO message = BeanUtils.toBean(reqVO, ImPrivateMessageDO.class, m -> m
76 .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now())); 81 .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()));
77 privateMessageMapper.insert(message); 82 privateMessageMapper.insert(message);
@@ -83,6 +88,42 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { @@ -83,6 +88,42 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
83 return message; 88 return message;
84 } 89 }
85 90
  91 + /**
  92 + * 私聊引用消息规范化
  93 + *
  94 + * @param reqVO 发送请求
  95 + * @param senderId 发送人编号
  96 + * @return 规范化后的 content
  97 + */
  98 + private String normalizeQuoteContent(ImPrivateMessageSendReqVO reqVO, Long senderId) {
  99 + // 解析客户端 content 里的 quote.messageId
  100 + Long quoteMessageId = ImMessageUtils.parseQuoteMessageId(reqVO.getContent());
  101 +
  102 + // 情况一:没有 quoteMessageId,直接 remove 掉 content 里可能伪造的 quote 字段
  103 + if (quoteMessageId == null) {
  104 + return ImMessageUtils.removeQuote(reqVO.getContent());
  105 + }
  106 +
  107 + // 情况二:有 quoteMessageId,加载原消息并校验
  108 + ImPrivateMessageDO original = privateMessageMapper.selectById(quoteMessageId);
  109 + if (original == null
  110 + || ImMessageStatusEnum.RECALL.getStatus().equals(original.getStatus())) {
  111 + throw exception(MESSAGE_QUOTE_INVALID);
  112 + }
  113 + // 校验是同对话
  114 + boolean sameConversation = (ObjUtil.equal(original.getSenderId(), senderId) // 发送人是当前用户,接收人是对方
  115 + && ObjUtil.equal(original.getReceiverId(), reqVO.getReceiverId()))
  116 + || (ObjUtil.equal(original.getSenderId(), reqVO.getReceiverId()) // 发送人是对方,接收人是当前用户
  117 + && ObjUtil.equal(original.getReceiverId(), senderId));
  118 + if (!sameConversation) {
  119 + throw exception(MESSAGE_QUOTE_INVALID);
  120 + }
  121 + // 构建 quote 对象并注入 content
  122 + QuoteMessage quote = ImMessageUtils.buildQuote(original.getId(),
  123 + original.getSenderId(), original.getType(), original.getContent());
  124 + return ImMessageUtils.appendQuote(reqVO.getContent(), quote);
  125 + }
  126 +
86 @Override 127 @Override
87 public List<ImPrivateMessageDO> pullPrivateMessageList(Long userId, Long minId, Integer size) { 128 public List<ImPrivateMessageDO> pullPrivateMessageList(Long userId, Long minId, Integer size) {
88 if (size > MESSAGE_MAX_PULL_SIZE) { 129 if (size > MESSAGE_MAX_PULL_SIZE) {
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/AudioMessage.java
@@ -21,4 +21,9 @@ public class AudioMessage { @@ -21,4 +21,9 @@ public class AudioMessage {
21 */ 21 */
22 private Long size; 22 private Long size;
23 23
  24 + /**
  25 + * 引用消息(可选)
  26 + */
  27 + private QuoteMessage quote;
  28 +
24 } 29 }
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/FileMessage.java
@@ -25,4 +25,9 @@ public class FileMessage { @@ -25,4 +25,9 @@ public class FileMessage {
25 */ 25 */
26 private String type; 26 private String type;
27 27
  28 + /**
  29 + * 引用消息(可选)
  30 + */
  31 + private QuoteMessage quote;
  32 +
28 } 33 }
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/ImageMessage.java
@@ -29,4 +29,9 @@ public class ImageMessage { @@ -29,4 +29,9 @@ public class ImageMessage {
29 */ 29 */
30 private Long size; 30 private Long size;
31 31
  32 + /**
  33 + * 引用消息(可选)
  34 + */
  35 + private QuoteMessage quote;
  36 +
32 } 37 }
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/QuoteMessage.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto.message;
  2 +
  3 +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
  4 +import lombok.Data;
  5 +import lombok.experimental.Accessors;
  6 +
  7 +/**
  8 + * 引用消息内容
  9 + * <p>
  10 + * 客户端在 content.quote 里写入 messageId 表达"引用了哪条消息",服务端反查原消息后重算 QuoteMessage 覆盖客户端写入,
  11 + * 避免伪造发送人 / 类型 / 摘要
  12 + *
  13 + * @see TextMessage 等等消息内容里 quote 字段
  14 + */
  15 +@Data
  16 +@Accessors(chain = true)
  17 +public class QuoteMessage {
  18 +
  19 + /**
  20 + * content JSON 里 quote 字段名
  21 + */
  22 + public static final String FIELD_NAME = "quote";
  23 +
  24 + /**
  25 + * 被引用消息编号
  26 + */
  27 + private Long messageId;
  28 + /**
  29 + * 被引用消息发送人编号
  30 + * <p>
  31 + * 关联 AdminUserDO 的 id 字段
  32 + */
  33 + private Long senderId;
  34 + /**
  35 + * 被引用消息类型
  36 + * <p>
  37 + * 枚举 {@link ImMessageTypeEnum}
  38 + */
  39 + private Integer type;
  40 + /**
  41 + * 原消息完整 content(JSON);服务端复制时会 removeQuote 防嵌套
  42 + */
  43 + private String content;
  44 +
  45 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/TextMessage.java
@@ -13,4 +13,9 @@ public class TextMessage { @@ -13,4 +13,9 @@ public class TextMessage {
13 */ 13 */
14 private String content; 14 private String content;
15 15
  16 + /**
  17 + * 引用消息(可选)
  18 + */
  19 + private QuoteMessage quote;
  20 +
16 } 21 }
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/VideoMessage.java
@@ -33,4 +33,9 @@ public class VideoMessage { @@ -33,4 +33,9 @@ public class VideoMessage {
33 */ 33 */
34 private Long size; 34 private Long size;
35 35
  36 + /**
  37 + * 引用消息(可选)
  38 + */
  39 + private QuoteMessage quote;
  40 +
36 } 41 }
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/util/ImMessageUtils.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.util;
  2 +
  3 +import cn.hutool.core.convert.Convert;
  4 +import cn.hutool.core.util.StrUtil;
  5 +import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
  6 +import cn.iocoder.yudao.module.im.service.websocket.dto.message.QuoteMessage;
  7 +
  8 +import java.util.LinkedHashMap;
  9 +import java.util.Map;
  10 +
  11 +/**
  12 + * IM 消息内容相关工具类
  13 + */
  14 +public class ImMessageUtils {
  15 +
  16 + /**
  17 + * 从 content 解析客户端写入的 quote.messageId
  18 + *
  19 + * @param content 原 content(JSON)
  20 + * @return messageId,不存在 / 非法返回 null
  21 + */
  22 + public static Long parseQuoteMessageId(String content) {
  23 + Map<String, Object> map = JsonUtils.parseMap(content);
  24 + if (map == null) {
  25 + return null;
  26 + }
  27 + Object quoteRaw = map.get(QuoteMessage.FIELD_NAME);
  28 + if (!(quoteRaw instanceof Map)) {
  29 + return null;
  30 + }
  31 + return Convert.toLong(((Map<?, ?>) quoteRaw).get("messageId"));
  32 + }
  33 +
  34 + /**
  35 + * 把 quote 写入 content 的 quote 字段
  36 + *
  37 + * @param content 原 content(JSON)
  38 + * @param quote QuoteMessage 对象
  39 + * @return 注入 quote 后的 content(JSON)
  40 + */
  41 + public static String appendQuote(String content, QuoteMessage quote) {
  42 + Map<String, Object> map = JsonUtils.parseMap(content);
  43 + if (map == null) {
  44 + map = new LinkedHashMap<>();
  45 + }
  46 + map.put(QuoteMessage.FIELD_NAME, quote);
  47 + return JsonUtils.toJsonString(map);
  48 + }
  49 +
  50 + /**
  51 + * 移除 content 里的 quote 字段
  52 + *
  53 + * @param content 原 content(JSON)
  54 + * @return remove quote 后的 content(JSON)
  55 + */
  56 + public static String removeQuote(String content) {
  57 + if (StrUtil.isBlank(content) || !StrUtil.contains(content, "\"" + QuoteMessage.FIELD_NAME + "\"")) {
  58 + return content;
  59 + }
  60 + Map<String, Object> map = JsonUtils.parseMap(content);
  61 + if (map == null || !map.containsKey(QuoteMessage.FIELD_NAME)) {
  62 + return content;
  63 + }
  64 + map.remove(QuoteMessage.FIELD_NAME);
  65 + return JsonUtils.toJsonString(map);
  66 + }
  67 +
  68 + /**
  69 + * 构建 QuoteMessage 对象
  70 + *
  71 + * @param messageId 被引用消息编号
  72 + * @param senderId 被引用消息发送人编号
  73 + * @param type 被引用消息类型
  74 + * @param originalContent 被引用消息原 content(JSON),服务端会 removeQuote 防止嵌套
  75 + * @return QuoteMessage 对象
  76 + */
  77 + public static QuoteMessage buildQuote(Long messageId, Long senderId, Integer type, String originalContent) {
  78 + return new QuoteMessage().setMessageId(messageId).setSenderId(senderId).setType(type)
  79 + .setContent(removeQuote(originalContent));
  80 + }
  81 +
  82 +}
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImplTest.java
@@ -137,7 +137,7 @@ public class ImFriendServiceImplTest extends BaseMockitoUnitTest { @@ -137,7 +137,7 @@ public class ImFriendServiceImplTest extends BaseMockitoUnitTest {
137 137
138 @Test 138 @Test
139 public void testUpdateFriend_emptyRequest() { 139 public void testUpdateFriend_emptyRequest() {
140 - // 准备:muted 和 displayName 都不传 —— 短路返回,不打 SQL 也不发推送 140 + // 准备:muted 和 displayName 都不传 —— 直接返回,不打 SQL 也不发推送
141 ImFriendUpdateReqVO reqVO = new ImFriendUpdateReqVO(); 141 ImFriendUpdateReqVO reqVO = new ImFriendUpdateReqVO();
142 reqVO.setFriendUserId(2L); 142 reqVO.setFriendUserId(2L);
143 ImFriendDO friend = ImFriendDO.builder().id(100L).userId(1L).friendUserId(2L) 143 ImFriendDO friend = ImFriendDO.builder().id(100L).userId(1L).friendUserId(2L)
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java
@@ -636,7 +636,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -636,7 +636,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
636 } 636 }
637 637
638 @Test 638 @Test
639 - public void testGetReadUsers_withVisibleScope() { 639 + public void testGetReadUserIds_withVisibleScope() {
640 // 准备:用户 1 是群成员 640 // 准备:用户 1 是群成员
641 ImGroupMemberDO currentMember = ImGroupMemberDO.builder() 641 ImGroupMemberDO currentMember = ImGroupMemberDO.builder()
642 .groupId(10L).userId(1L) 642 .groupId(10L).userId(1L)
@@ -677,7 +677,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -677,7 +677,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
677 when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions); 677 when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions);
678 678
679 // 调用:查询 messageId=80 的已读用户 679 // 调用:查询 messageId=80 的已读用户
680 - List<Long> readUsers = groupMessageService.getGroupReadUsers(1L, 10L, 80L); 680 + List<Long> readUsers = groupMessageService.getGroupReadUserIds(1L, 10L, 80L);
681 681
682 // 断言: 682 // 断言:
683 // 用户 1: 可见 + readMaxId=100>=80 → 已读 ✓ 683 // 用户 1: 可见 + readMaxId=100>=80 → 已读 ✓
@@ -692,14 +692,14 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -692,14 +692,14 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
692 } 692 }
693 693
694 @Test 694 @Test
695 - public void testGetReadUsers_notInGroup() { 695 + public void testGetReadUserIds_notInGroup() {
696 // 准备:当前用户不在群中 696 // 准备:当前用户不在群中
697 when(groupMemberService.validateMemberInGroup(10L, 99L)) 697 when(groupMemberService.validateMemberInGroup(10L, 99L))
698 .thenThrow(new ServiceException(GROUP_MEMBER_NOT_IN_GROUP.getCode(), GROUP_MEMBER_NOT_IN_GROUP.getMsg())); 698 .thenThrow(new ServiceException(GROUP_MEMBER_NOT_IN_GROUP.getCode(), GROUP_MEMBER_NOT_IN_GROUP.getMsg()));
699 699
700 // 调用并断言:越权校验 700 // 调用并断言:越权校验
701 ServiceException exception = assertThrows(ServiceException.class, 701 ServiceException exception = assertThrows(ServiceException.class,
702 - () -> groupMessageService.getGroupReadUsers(99L, 10L, 80L)); 702 + () -> groupMessageService.getGroupReadUserIds(99L, 10L, 80L));
703 assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); 703 assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode());
704 } 704 }
705 705
@@ -787,7 +787,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -787,7 +787,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
787 verify(groupMessageMapper, never()).insert(any(ImGroupMessageDO.class)); 787 verify(groupMessageMapper, never()).insert(any(ImGroupMessageDO.class));
788 } 788 }
789 789
790 - // ========== 已读:happy path + 短路 ========== 790 + // ========== 已读:happy path + 跳过 ==========
791 791
792 @Test 792 @Test
793 public void testReadGroupMessages_success() { 793 public void testReadGroupMessages_success() {
@@ -815,7 +815,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -815,7 +815,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
815 815
816 @Test 816 @Test
817 public void testReadGroupMessages_cursorAlreadyAhead() { 817 public void testReadGroupMessages_cursorAlreadyAhead() {
818 - // 准备:已读游标已 >= 目标,直接短路返回 818 + // 准备:已读游标已 >= 目标,直接返回
819 ImGroupMemberDO member = ImGroupMemberDO.builder() 819 ImGroupMemberDO member = ImGroupMemberDO.builder()
820 .groupId(10L).userId(1L) 820 .groupId(10L).userId(1L)
821 .status(CommonStatusEnum.ENABLE.getStatus()).build(); 821 .status(CommonStatusEnum.ENABLE.getStatus()).build();