Commit 348b97047d8cdc94dc1730269eb331a1b45a686f
1 parent
3d8b8792
✨ feat(im): 拆出私聊 / 群聊已读两个全局开关,关闭后禁用接口与所有 UI 入口(含群回执)
ImProperties.message 新增 privateReadEnabled / groupReadEnabled,前端 config.ts 同步镜像。关闭后: - 后端:read 系列接口(read / getMaxReadMessageId / getGroupReadUserIds)抛业务异常;sendGroupMessage 强制 NO_RECEIPT 忽略 receipt=true;pull 群消息跳过 Redis 已读游标读取与 readCount 补齐 - 前端:气泡已读标签 / 群回执 popover / 「发送回执消息」下拉入口 / admin 列表「状态」「回执」列与详情对应字段按开关隐藏;自动上报 / 冷启动同步对方已读位置 / WS READ & RECEIPT handler 全部按开关短路兜底,避免打到禁用接口 - 单测:补 @Spy ImProperties 修复原本就在的 NPE,加 disabled 分支断言
Showing
6 changed files
with
147 additions
and
9 deletions
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java
| @@ -18,6 +18,8 @@ public interface ErrorCodeConstants { | @@ -18,6 +18,8 @@ public interface ErrorCodeConstants { | ||
| 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 | ErrorCode MESSAGE_QUOTE_INVALID = new ErrorCode(1_040_300_008, "引用的消息不可用"); |
| 20 | ErrorCode MESSAGE_NOT_IN_GROUP = new ErrorCode(1_040_300_009, "消息不属于该群"); | 20 | ErrorCode MESSAGE_NOT_IN_GROUP = new ErrorCode(1_040_300_009, "消息不属于该群"); |
| 21 | + ErrorCode MESSAGE_PRIVATE_READ_DISABLED = new ErrorCode(1_040_300_010, "私聊已读功能已关闭"); | ||
| 22 | + ErrorCode MESSAGE_GROUP_READ_DISABLED = new ErrorCode(1_040_300_011, "群聊已读功能已关闭"); | ||
| 21 | 23 | ||
| 22 | // ========== 群 (1-040-400-000) ========== | 24 | // ========== 群 (1-040-400-000) ========== |
| 23 | ErrorCode GROUP_NOT_EXISTS = new ErrorCode(1_040_400_000, "群不存在"); | 25 | ErrorCode GROUP_NOT_EXISTS = new ErrorCode(1_040_400_000, "群不存在"); |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java
| @@ -69,6 +69,23 @@ public class ImProperties { | @@ -69,6 +69,23 @@ public class ImProperties { | ||
| 69 | public static class Message { | 69 | public static class Message { |
| 70 | 70 | ||
| 71 | /** | 71 | /** |
| 72 | + * 是否启用私聊已读功能 | ||
| 73 | + * <p> | ||
| 74 | + * 关闭后:private read 接口直接抛业务异常;服务端不再下发私聊 READ / RECEIPT 事件信号。 | ||
| 75 | + * 客户端侧需镜像此开关,隐藏私聊气泡的「已读 / 未读」标签 | ||
| 76 | + */ | ||
| 77 | + private boolean privateReadEnabled = true; | ||
| 78 | + | ||
| 79 | + /** | ||
| 80 | + * 是否启用群聊已读功能(含群消息回执) | ||
| 81 | + * <p> | ||
| 82 | + * 关闭后:group read 接口直接抛业务异常;服务端不再下发群 READ / RECEIPT 事件信号; | ||
| 83 | + * 群消息回执 receiptStatus 一并停用(即使发送方传 receipt=true 也强制落 NO_RECEIPT,不再算「N 人已读」)。 | ||
| 84 | + * 客户端侧需镜像此开关,隐藏群回执 popover 与「发送回执消息」入口 | ||
| 85 | + */ | ||
| 86 | + private boolean groupReadEnabled = true; | ||
| 87 | + | ||
| 88 | + /** | ||
| 72 | * pull 最大拉取数量 | 89 | * pull 最大拉取数量 |
| 73 | */ | 90 | */ |
| 74 | private int maxPullSize = 1000; | 91 | private int maxPullSize = 1000; |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java
| @@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.im.service.message; | @@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.im.service.message; | ||
| 2 | 2 | ||
| 3 | import cn.hutool.core.collection.CollUtil; | 3 | import cn.hutool.core.collection.CollUtil; |
| 4 | import cn.hutool.core.lang.Assert; | 4 | import cn.hutool.core.lang.Assert; |
| 5 | +import cn.hutool.core.util.BooleanUtil; | ||
| 5 | import cn.hutool.core.util.IdUtil; | 6 | import cn.hutool.core.util.IdUtil; |
| 6 | import cn.hutool.core.util.ObjUtil; | 7 | import cn.hutool.core.util.ObjUtil; |
| 7 | import cn.hutool.extra.spring.SpringUtil; | 8 | import cn.hutool.extra.spring.SpringUtil; |
| @@ -105,9 +106,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | @@ -105,9 +106,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | ||
| 105 | // 2.2 构建并保存消息 | 106 | // 2.2 构建并保存消息 |
| 106 | ImGroupMessageDO message = BeanUtils.toBean(reqVO, ImGroupMessageDO.class, m -> m | 107 | ImGroupMessageDO message = BeanUtils.toBean(reqVO, ImGroupMessageDO.class, m -> m |
| 107 | .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) | 108 | .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) |
| 108 | - .setReceiptStatus(Boolean.TRUE.equals(reqVO.getReceipt()) | ||
| 109 | - ? ImGroupMessageReceiptStatusEnum.PENDING.getStatus() | ||
| 110 | - : ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus())); | 109 | + .setReceiptStatus(resolveReceiptStatus(reqVO.getReceipt()))); |
| 111 | groupMessageMapper.insert(message); | 110 | groupMessageMapper.insert(message); |
| 112 | 111 | ||
| 113 | // 3. WebSocket 异步推送(群内可见成员 + 发送方多端同步) | 112 | // 3. WebSocket 异步推送(群内可见成员 + 发送方多端同步) |
| @@ -137,9 +136,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | @@ -137,9 +136,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | ||
| 137 | .setType(dto.getType()).setContent(contentString) | 136 | .setType(dto.getType()).setContent(contentString) |
| 138 | .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) | 137 | .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) |
| 139 | .setAtUserIds(dto.getAtUserIds()).setReceiverUserIds(dto.getReceiverUserIds()) | 138 | .setAtUserIds(dto.getAtUserIds()).setReceiverUserIds(dto.getReceiverUserIds()) |
| 140 | - .setReceiptStatus(Boolean.TRUE.equals(dto.getReceipt()) | ||
| 141 | - ? ImGroupMessageReceiptStatusEnum.PENDING.getStatus() | ||
| 142 | - : ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()); | 139 | + .setReceiptStatus(resolveReceiptStatus(dto.getReceipt())); |
| 143 | // 1.3 按 type.persistent 决定是否入库 | 140 | // 1.3 按 type.persistent 决定是否入库 |
| 144 | if (ImMessageTypeEnum.validate(dto.getType()).isPersistent()) { | 141 | if (ImMessageTypeEnum.validate(dto.getType()).isPersistent()) { |
| 145 | groupMessageMapper.insert(message); | 142 | groupMessageMapper.insert(message); |
| @@ -305,6 +302,10 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | @@ -305,6 +302,10 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | ||
| 305 | if (CollUtil.isEmpty(messages)) { | 302 | if (CollUtil.isEmpty(messages)) { |
| 306 | return; | 303 | return; |
| 307 | } | 304 | } |
| 305 | + // 群已读关闭:不查 Redis 已读游标,status 保持 DB 原值(含撤回),readCount 不补齐 | ||
| 306 | + if (BooleanUtil.isFalse(imProperties.getMessage().isGroupReadEnabled())) { | ||
| 307 | + return; | ||
| 308 | + } | ||
| 308 | Map<Long, Long> readMaxMessageIdsByGroup = new HashMap<>(); // 群 → 已读位置 | 309 | Map<Long, Long> readMaxMessageIdsByGroup = new HashMap<>(); // 群 → 已读位置 |
| 309 | Map<Long, Map<Long, Long>> readPositionsByGroup = new HashMap<>(); // 群 → (用户 → 已读位置) | 310 | Map<Long, Map<Long, Long>> readPositionsByGroup = new HashMap<>(); // 群 → (用户 → 已读位置) |
| 310 | Map<Long, List<ImGroupMemberDO>> membersByGroup = new HashMap<>(); // 群 → 全部成员列表 | 311 | Map<Long, List<ImGroupMemberDO>> membersByGroup = new HashMap<>(); // 群 → 全部成员列表 |
| @@ -343,6 +344,10 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | @@ -343,6 +344,10 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | ||
| 343 | 344 | ||
| 344 | @Override | 345 | @Override |
| 345 | public void readGroupMessages(Long userId, Long groupId, Long messageId) { | 346 | public void readGroupMessages(Long userId, Long groupId, Long messageId) { |
| 347 | + // 0. 全局开关校验 | ||
| 348 | + if (BooleanUtil.isFalse(imProperties.getMessage().isGroupReadEnabled())) { | ||
| 349 | + throw exception(MESSAGE_GROUP_READ_DISABLED); | ||
| 350 | + } | ||
| 346 | Assert.notNull(messageId, "已读消息编号不能为空"); | 351 | Assert.notNull(messageId, "已读消息编号不能为空"); |
| 347 | // 1. 校验用户在群中(权限校验) | 352 | // 1. 校验用户在群中(权限校验) |
| 348 | groupMemberService.validateMemberInGroup(groupId, userId); | 353 | groupMemberService.validateMemberInGroup(groupId, userId); |
| @@ -361,6 +366,10 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | @@ -361,6 +366,10 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | ||
| 361 | 366 | ||
| 362 | @Override | 367 | @Override |
| 363 | public List<Long> getGroupReadUserIds(Long userId, Long groupId, Long messageId) { | 368 | public List<Long> getGroupReadUserIds(Long userId, Long groupId, Long messageId) { |
| 369 | + // 0. 全局开关校验 | ||
| 370 | + if (BooleanUtil.isFalse(imProperties.getMessage().isGroupReadEnabled())) { | ||
| 371 | + throw exception(MESSAGE_GROUP_READ_DISABLED); | ||
| 372 | + } | ||
| 364 | // 1.1 校验用户在群中(权限校验) | 373 | // 1.1 校验用户在群中(权限校验) |
| 365 | groupMemberService.validateMemberInGroup(groupId, userId); | 374 | groupMemberService.validateMemberInGroup(groupId, userId); |
| 366 | // 1.2 获取消息 | 375 | // 1.2 获取消息 |
| @@ -614,6 +623,18 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | @@ -614,6 +623,18 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | ||
| 614 | } | 623 | } |
| 615 | 624 | ||
| 616 | /** | 625 | /** |
| 626 | + * 计算群消息回执 status:群已读关闭时强制 NO_RECEIPT,忽略发送方传入的 receipt(receipt 为 null 等价 false) | ||
| 627 | + */ | ||
| 628 | + private Integer resolveReceiptStatus(Boolean receipt) { | ||
| 629 | + if (BooleanUtil.isFalse(imProperties.getMessage().isGroupReadEnabled())) { | ||
| 630 | + return ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(); | ||
| 631 | + } | ||
| 632 | + return BooleanUtil.isTrue(receipt) | ||
| 633 | + ? ImGroupMessageReceiptStatusEnum.PENDING.getStatus() | ||
| 634 | + : ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(); | ||
| 635 | + } | ||
| 636 | + | ||
| 637 | + /** | ||
| 617 | * 禁言状态校验:全群禁言 → 成员禁言;群主 / 管理员豁免全群禁言 | 638 | * 禁言状态校验:全群禁言 → 成员禁言;群主 / 管理员豁免全群禁言 |
| 618 | */ | 639 | */ |
| 619 | private void validateMuteStatus(ImGroupDO group, ImGroupMemberDO senderMember) { | 640 | private void validateMuteStatus(ImGroupDO group, ImGroupMemberDO senderMember) { |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java
| 1 | package cn.iocoder.yudao.module.im.service.message; | 1 | package cn.iocoder.yudao.module.im.service.message; |
| 2 | 2 | ||
| 3 | import cn.hutool.core.lang.Assert; | 3 | import cn.hutool.core.lang.Assert; |
| 4 | +import cn.hutool.core.util.BooleanUtil; | ||
| 4 | import cn.hutool.core.util.IdUtil; | 5 | import cn.hutool.core.util.IdUtil; |
| 5 | import cn.hutool.core.util.ObjUtil; | 6 | import cn.hutool.core.util.ObjUtil; |
| 6 | import cn.iocoder.yudao.framework.common.pojo.PageResult; | 7 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| @@ -210,9 +211,13 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { | @@ -210,9 +211,13 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { | ||
| 210 | 211 | ||
| 211 | @Override | 212 | @Override |
| 212 | public void readPrivateMessages(Long userId, Long receiverId, Long messageId) { | 213 | public void readPrivateMessages(Long userId, Long receiverId, Long messageId) { |
| 214 | + // 1. 全局开关校验 | ||
| 215 | + if (BooleanUtil.isFalse(imProperties.getMessage().isPrivateReadEnabled())) { | ||
| 216 | + throw exception(MESSAGE_PRIVATE_READ_DISABLED); | ||
| 217 | + } | ||
| 213 | Assert.notNull(messageId, "已读消息编号不能为空"); | 218 | Assert.notNull(messageId, "已读消息编号不能为空"); |
| 214 | - // 1. 一步翻转:把 (receiverId → userId) 这条会话上、id <= messageId 的未读消息全部置为已读 | ||
| 215 | - // 仅 UNREAD 行被命中,避免覆盖已撤回/已读的状态;select-then-update 拆成单条 SQL 后也消除了竞态窗口 | 219 | + // 2. 把 (receiverId → userId) 这条会话上、id <= messageId 的未读消息一步更新为已读 |
| 220 | + // 仅 UNREAD 行被命中,避免覆盖已撤回/已读的状态;select-then-update 合成单条 SQL 后也消除了竞态窗口 | ||
| 216 | int updated = privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus( | 221 | int updated = privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus( |
| 217 | receiverId, userId, messageId, ImMessageStatusEnum.UNREAD.getStatus(), | 222 | receiverId, userId, messageId, ImMessageStatusEnum.UNREAD.getStatus(), |
| 218 | new ImPrivateMessageDO().setStatus(ImMessageStatusEnum.READ.getStatus())); | 223 | new ImPrivateMessageDO().setStatus(ImMessageStatusEnum.READ.getStatus())); |
| @@ -220,7 +225,7 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { | @@ -220,7 +225,7 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { | ||
| 220 | return; | 225 | return; |
| 221 | } | 226 | } |
| 222 | 227 | ||
| 223 | - // 2. 异步发送 READ + RECEIPT 事件(已读位置以前端上报为准,与多端 / 对方 UI 显示一致) | 228 | + // 3. 异步发送 READ + RECEIPT 事件(已读位置以前端上报为准,与多端 / 对方 UI 显示一致) |
| 224 | imWebSocketService.sendPrivateMessageAsync(userId, | 229 | imWebSocketService.sendPrivateMessageAsync(userId, |
| 225 | ImPrivateMessageDTO.ofRead(userId, receiverId, messageId)); | 230 | ImPrivateMessageDTO.ofRead(userId, receiverId, messageId)); |
| 226 | imWebSocketService.sendPrivateMessageAsync(receiverId, | 231 | imWebSocketService.sendPrivateMessageAsync(receiverId, |
| @@ -229,6 +234,9 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { | @@ -229,6 +234,9 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { | ||
| 229 | 234 | ||
| 230 | @Override | 235 | @Override |
| 231 | public Long getMaxReadMessageId(Long userId, Long peerId) { | 236 | public Long getMaxReadMessageId(Long userId, Long peerId) { |
| 237 | + if (BooleanUtil.isFalse(imProperties.getMessage().isPrivateReadEnabled())) { | ||
| 238 | + throw exception(MESSAGE_PRIVATE_READ_DISABLED); | ||
| 239 | + } | ||
| 232 | return privateMessageMapper.selectMaxIdBySenderIdAndReceiverIdAndStatus( | 240 | return privateMessageMapper.selectMaxIdBySenderIdAndReceiverIdAndStatus( |
| 233 | userId, peerId, ImMessageStatusEnum.READ.getStatus()); | 241 | userId, peerId, ImMessageStatusEnum.READ.getStatus()); |
| 234 | } | 242 | } |
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java
| @@ -14,6 +14,7 @@ import cn.iocoder.yudao.module.im.dal.redis.message.GroupMessageReadRedisDAO; | @@ -14,6 +14,7 @@ import cn.iocoder.yudao.module.im.dal.redis.message.GroupMessageReadRedisDAO; | ||
| 14 | import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; | 14 | import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; |
| 15 | import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; | 15 | import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; |
| 16 | import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; | 16 | import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; |
| 17 | +import cn.iocoder.yudao.module.im.framework.config.ImProperties; | ||
| 17 | import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; | 18 | import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; |
| 18 | import cn.iocoder.yudao.module.im.service.group.ImGroupService; | 19 | import cn.iocoder.yudao.module.im.service.group.ImGroupService; |
| 19 | import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; | 20 | import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; |
| @@ -26,6 +27,7 @@ import org.mockito.ArgumentCaptor; | @@ -26,6 +27,7 @@ import org.mockito.ArgumentCaptor; | ||
| 26 | import org.mockito.InjectMocks; | 27 | import org.mockito.InjectMocks; |
| 27 | import org.mockito.Mock; | 28 | import org.mockito.Mock; |
| 28 | import org.mockito.MockedStatic; | 29 | import org.mockito.MockedStatic; |
| 30 | +import org.mockito.Spy; | ||
| 29 | 31 | ||
| 30 | import java.time.LocalDateTime; | 32 | import java.time.LocalDateTime; |
| 31 | import java.util.*; | 33 | import java.util.*; |
| @@ -58,6 +60,10 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { | @@ -58,6 +60,10 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { | ||
| 58 | @Mock | 60 | @Mock |
| 59 | private ImWebSocketService imWebSocketService; | 61 | private ImWebSocketService imWebSocketService; |
| 60 | 62 | ||
| 63 | + /** 用真实实例避免 NPE;默认值与生产保持一致(recallTimeoutMinutes=5、private/group read enabled=true、maxPullSize=1000);个别用例可改字段测分支 */ | ||
| 64 | + @Spy | ||
| 65 | + private ImProperties imProperties = new ImProperties(); | ||
| 66 | + | ||
| 61 | private ImGroupMessageSendReqVO buildSendReqVO() { | 67 | private ImGroupMessageSendReqVO buildSendReqVO() { |
| 62 | ImGroupMessageSendReqVO reqVO = new ImGroupMessageSendReqVO(); | 68 | ImGroupMessageSendReqVO reqVO = new ImGroupMessageSendReqVO(); |
| 63 | reqVO.setClientMessageId("test-uuid-group-001"); | 69 | reqVO.setClientMessageId("test-uuid-group-001"); |
| @@ -812,6 +818,58 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { | @@ -812,6 +818,58 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { | ||
| 812 | } | 818 | } |
| 813 | 819 | ||
| 814 | @Test | 820 | @Test |
| 821 | + public void testReadGroupMessages_disabled() { | ||
| 822 | + // 准备:关闭群聊已读 | ||
| 823 | + imProperties.getMessage().setGroupReadEnabled(false); | ||
| 824 | + | ||
| 825 | + // 调用并断言 | ||
| 826 | + ServiceException exception = assertThrows(ServiceException.class, | ||
| 827 | + () -> groupMessageService.readGroupMessages(1L, 10L, 100L)); | ||
| 828 | + assertEquals(MESSAGE_GROUP_READ_DISABLED.getCode(), exception.getCode()); | ||
| 829 | + // 断言:Redis 不写、不推送 | ||
| 830 | + verify(groupMessageReadRedisDAO, never()).updateReadMaxMessageId(anyLong(), anyLong(), anyLong()); | ||
| 831 | + verify(imWebSocketService, never()).sendGroupMessageAsync(anyLong(), any(ImGroupMessageDTO.class)); | ||
| 832 | + } | ||
| 833 | + | ||
| 834 | + @Test | ||
| 835 | + public void testGetGroupReadUserIds_disabled() { | ||
| 836 | + // 准备:关闭群聊已读 | ||
| 837 | + imProperties.getMessage().setGroupReadEnabled(false); | ||
| 838 | + | ||
| 839 | + // 调用并断言 | ||
| 840 | + ServiceException exception = assertThrows(ServiceException.class, | ||
| 841 | + () -> groupMessageService.getGroupReadUserIds(1L, 10L, 100L)); | ||
| 842 | + assertEquals(MESSAGE_GROUP_READ_DISABLED.getCode(), exception.getCode()); | ||
| 843 | + } | ||
| 844 | + | ||
| 845 | + @Test | ||
| 846 | + public void testSendGroupMessage_groupReadDisabled_forcesNoReceipt() { | ||
| 847 | + // 关闭群已读:发送方传 receipt=true 也强制落 NO_RECEIPT | ||
| 848 | + imProperties.getMessage().setGroupReadEnabled(false); | ||
| 849 | + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) { | ||
| 850 | + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) | ||
| 851 | + .thenReturn(groupMessageService); | ||
| 852 | + | ||
| 853 | + ImGroupMessageSendReqVO reqVO = buildSendReqVO(); | ||
| 854 | + reqVO.setReceipt(true); // 发送方明确要求回执 | ||
| 855 | + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) | ||
| 856 | + .thenReturn(null); | ||
| 857 | + ImGroupDO group = new ImGroupDO(); | ||
| 858 | + group.setId(10L); | ||
| 859 | + when(groupService.validateGroupExists(10L)).thenReturn(group); | ||
| 860 | + ImGroupMemberDO member = ImGroupMemberDO.builder() | ||
| 861 | + .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); | ||
| 862 | + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); | ||
| 863 | + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L, 2L)); | ||
| 864 | + | ||
| 865 | + ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO); | ||
| 866 | + | ||
| 867 | + assertEquals(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), result.getReceiptStatus(), | ||
| 868 | + "群已读关闭时即使发送方传 receipt=true 也强制落 NO_RECEIPT"); | ||
| 869 | + } | ||
| 870 | + } | ||
| 871 | + | ||
| 872 | + @Test | ||
| 815 | public void testReadGroupMessages_cursorAlreadyAhead() { | 873 | public void testReadGroupMessages_cursorAlreadyAhead() { |
| 816 | // 准备:已读游标已 >= 目标,直接返回 | 874 | // 准备:已读游标已 >= 目标,直接返回 |
| 817 | ImGroupMemberDO member = ImGroupMemberDO.builder() | 875 | ImGroupMemberDO member = ImGroupMemberDO.builder() |
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java
| @@ -9,6 +9,7 @@ import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper; | @@ -9,6 +9,7 @@ import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper; | ||
| 9 | import cn.iocoder.yudao.module.im.enums.friend.ImFriendStateEnum; | 9 | import cn.iocoder.yudao.module.im.enums.friend.ImFriendStateEnum; |
| 10 | import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; | 10 | import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; |
| 11 | import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; | 11 | import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; |
| 12 | +import cn.iocoder.yudao.module.im.framework.config.ImProperties; | ||
| 12 | import cn.iocoder.yudao.module.im.service.friend.ImFriendService; | 13 | import cn.iocoder.yudao.module.im.service.friend.ImFriendService; |
| 13 | import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; | 14 | import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; |
| 14 | import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO; | 15 | import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO; |
| @@ -19,6 +20,7 @@ import org.junit.jupiter.api.Test; | @@ -19,6 +20,7 @@ import org.junit.jupiter.api.Test; | ||
| 19 | import org.mockito.ArgumentCaptor; | 20 | import org.mockito.ArgumentCaptor; |
| 20 | import org.mockito.InjectMocks; | 21 | import org.mockito.InjectMocks; |
| 21 | import org.mockito.Mock; | 22 | import org.mockito.Mock; |
| 23 | +import org.mockito.Spy; | ||
| 22 | 24 | ||
| 23 | import java.time.LocalDateTime; | 25 | import java.time.LocalDateTime; |
| 24 | import java.util.List; | 26 | import java.util.List; |
| @@ -47,6 +49,10 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { | @@ -47,6 +49,10 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { | ||
| 47 | @Mock | 49 | @Mock |
| 48 | private ImWebSocketService imWebSocketService; | 50 | private ImWebSocketService imWebSocketService; |
| 49 | 51 | ||
| 52 | + /** 用真实实例避免 NPE;默认值与生产保持一致(recallTimeoutMinutes=5、private/group read enabled=true);个别用例可改字段测分支 */ | ||
| 53 | + @Spy | ||
| 54 | + private ImProperties imProperties = new ImProperties(); | ||
| 55 | + | ||
| 50 | private ImPrivateMessageSendReqVO buildSendReqVO() { | 56 | private ImPrivateMessageSendReqVO buildSendReqVO() { |
| 51 | ImPrivateMessageSendReqVO reqVO = new ImPrivateMessageSendReqVO(); | 57 | ImPrivateMessageSendReqVO reqVO = new ImPrivateMessageSendReqVO(); |
| 52 | reqVO.setClientMessageId("test-uuid-001"); | 58 | reqVO.setClientMessageId("test-uuid-001"); |
| @@ -294,6 +300,21 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { | @@ -294,6 +300,21 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { | ||
| 294 | } | 300 | } |
| 295 | 301 | ||
| 296 | @Test | 302 | @Test |
| 303 | + public void testReadMessages_disabled() { | ||
| 304 | + // 准备:关闭私聊已读 | ||
| 305 | + imProperties.getMessage().setPrivateReadEnabled(false); | ||
| 306 | + | ||
| 307 | + // 调用并断言 | ||
| 308 | + ServiceException exception = assertThrows(ServiceException.class, | ||
| 309 | + () -> privateMessageService.readPrivateMessages(1L, 2L, 5L)); | ||
| 310 | + assertEquals(MESSAGE_PRIVATE_READ_DISABLED.getCode(), exception.getCode()); | ||
| 311 | + // 断言:不更新消息状态、不推送 | ||
| 312 | + verify(privateMessageMapper, never()).updateBySenderIdAndReceiverIdAndIdLeAndStatus( | ||
| 313 | + anyLong(), anyLong(), anyLong(), anyInt(), any(ImPrivateMessageDO.class)); | ||
| 314 | + verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); | ||
| 315 | + } | ||
| 316 | + | ||
| 317 | + @Test | ||
| 297 | public void testReadMessages_noUnread() { | 318 | public void testReadMessages_noUnread() { |
| 298 | // 准备:mapper 返回 0 表示 id <= messageId 范围内没有未读消息 | 319 | // 准备:mapper 返回 0 表示 id <= messageId 范围内没有未读消息 |
| 299 | when(privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus( | 320 | when(privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus( |
| @@ -336,6 +357,17 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { | @@ -336,6 +357,17 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { | ||
| 336 | assertNull(result); | 357 | assertNull(result); |
| 337 | } | 358 | } |
| 338 | 359 | ||
| 360 | + @Test | ||
| 361 | + public void testGetMaxReadMessageId_disabled() { | ||
| 362 | + // 准备:关闭私聊已读 | ||
| 363 | + imProperties.getMessage().setPrivateReadEnabled(false); | ||
| 364 | + | ||
| 365 | + // 调用并断言 | ||
| 366 | + ServiceException exception = assertThrows(ServiceException.class, | ||
| 367 | + () -> privateMessageService.getMaxReadMessageId(1L, 2L)); | ||
| 368 | + assertEquals(MESSAGE_PRIVATE_READ_DISABLED.getCode(), exception.getCode()); | ||
| 369 | + } | ||
| 370 | + | ||
| 339 | // ========== sendPrivateMessage(senderId, dto):helper 行为 ========== | 371 | // ========== sendPrivateMessage(senderId, dto):helper 行为 ========== |
| 340 | 372 | ||
| 341 | @Test | 373 | @Test |