Commit ae36d1ad68c5ff7f5e84cb0e8e20bc30fb844e42
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
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/FileMessage.java
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/ImageMessage.java
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
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/VideoMessage.java
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(); |