diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java index 3a41d45..213ff2a 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java @@ -18,6 +18,8 @@ public interface ErrorCodeConstants { ErrorCode MESSAGE_RECALL_TIMEOUT = new ErrorCode(1_040_300_007, "超过 {} 分钟的消息无法撤回"); ErrorCode MESSAGE_QUOTE_INVALID = new ErrorCode(1_040_300_008, "引用的消息不可用"); ErrorCode MESSAGE_NOT_IN_GROUP = new ErrorCode(1_040_300_009, "消息不属于该群"); + ErrorCode MESSAGE_PRIVATE_READ_DISABLED = new ErrorCode(1_040_300_010, "私聊已读功能已关闭"); + ErrorCode MESSAGE_GROUP_READ_DISABLED = new ErrorCode(1_040_300_011, "群聊已读功能已关闭"); // ========== 群 (1-040-400-000) ========== ErrorCode GROUP_NOT_EXISTS = new ErrorCode(1_040_400_000, "群不存在"); diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java index 63f332e..0d65caf 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java @@ -69,6 +69,23 @@ public class ImProperties { public static class Message { /** + * 是否启用私聊已读功能 + *

+ * 关闭后:private read 接口直接抛业务异常;服务端不再下发私聊 READ / RECEIPT 事件信号。 + * 客户端侧需镜像此开关,隐藏私聊气泡的「已读 / 未读」标签 + */ + private boolean privateReadEnabled = true; + + /** + * 是否启用群聊已读功能(含群消息回执) + *

+ * 关闭后:group read 接口直接抛业务异常;服务端不再下发群 READ / RECEIPT 事件信号; + * 群消息回执 receiptStatus 一并停用(即使发送方传 receipt=true 也强制落 NO_RECEIPT,不再算「N 人已读」)。 + * 客户端侧需镜像此开关,隐藏群回执 popover 与「发送回执消息」入口 + */ + private boolean groupReadEnabled = true; + + /** * pull 最大拉取数量 */ private int maxPullSize = 1000; diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java index bd8527c..e252fa1 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java +++ b/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; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.BooleanUtil; import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.ObjUtil; import cn.hutool.extra.spring.SpringUtil; @@ -105,9 +106,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { // 2.2 构建并保存消息 ImGroupMessageDO message = BeanUtils.toBean(reqVO, ImGroupMessageDO.class, m -> m .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) - .setReceiptStatus(Boolean.TRUE.equals(reqVO.getReceipt()) - ? ImGroupMessageReceiptStatusEnum.PENDING.getStatus() - : ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus())); + .setReceiptStatus(resolveReceiptStatus(reqVO.getReceipt()))); groupMessageMapper.insert(message); // 3. WebSocket 异步推送(群内可见成员 + 发送方多端同步) @@ -137,9 +136,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { .setType(dto.getType()).setContent(contentString) .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) .setAtUserIds(dto.getAtUserIds()).setReceiverUserIds(dto.getReceiverUserIds()) - .setReceiptStatus(Boolean.TRUE.equals(dto.getReceipt()) - ? ImGroupMessageReceiptStatusEnum.PENDING.getStatus() - : ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()); + .setReceiptStatus(resolveReceiptStatus(dto.getReceipt())); // 1.3 按 type.persistent 决定是否入库 if (ImMessageTypeEnum.validate(dto.getType()).isPersistent()) { groupMessageMapper.insert(message); @@ -305,6 +302,10 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { if (CollUtil.isEmpty(messages)) { return; } + // 群已读关闭:不查 Redis 已读游标,status 保持 DB 原值(含撤回),readCount 不补齐 + if (BooleanUtil.isFalse(imProperties.getMessage().isGroupReadEnabled())) { + return; + } Map readMaxMessageIdsByGroup = new HashMap<>(); // 群 → 已读位置 Map> readPositionsByGroup = new HashMap<>(); // 群 → (用户 → 已读位置) Map> membersByGroup = new HashMap<>(); // 群 → 全部成员列表 @@ -343,6 +344,10 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @Override public void readGroupMessages(Long userId, Long groupId, Long messageId) { + // 0. 全局开关校验 + if (BooleanUtil.isFalse(imProperties.getMessage().isGroupReadEnabled())) { + throw exception(MESSAGE_GROUP_READ_DISABLED); + } Assert.notNull(messageId, "已读消息编号不能为空"); // 1. 校验用户在群中(权限校验) groupMemberService.validateMemberInGroup(groupId, userId); @@ -361,6 +366,10 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @Override public List getGroupReadUserIds(Long userId, Long groupId, Long messageId) { + // 0. 全局开关校验 + if (BooleanUtil.isFalse(imProperties.getMessage().isGroupReadEnabled())) { + throw exception(MESSAGE_GROUP_READ_DISABLED); + } // 1.1 校验用户在群中(权限校验) groupMemberService.validateMemberInGroup(groupId, userId); // 1.2 获取消息 @@ -614,6 +623,18 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { } /** + * 计算群消息回执 status:群已读关闭时强制 NO_RECEIPT,忽略发送方传入的 receipt(receipt 为 null 等价 false) + */ + private Integer resolveReceiptStatus(Boolean receipt) { + if (BooleanUtil.isFalse(imProperties.getMessage().isGroupReadEnabled())) { + return ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(); + } + return BooleanUtil.isTrue(receipt) + ? ImGroupMessageReceiptStatusEnum.PENDING.getStatus() + : ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(); + } + + /** * 禁言状态校验:全群禁言 → 成员禁言;群主 / 管理员豁免全群禁言 */ private void validateMuteStatus(ImGroupDO group, ImGroupMemberDO senderMember) { diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java index ebcc544..a8d78fa 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.im.service.message; import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.BooleanUtil; import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.ObjUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; @@ -210,9 +211,13 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { @Override public void readPrivateMessages(Long userId, Long receiverId, Long messageId) { + // 1. 全局开关校验 + if (BooleanUtil.isFalse(imProperties.getMessage().isPrivateReadEnabled())) { + throw exception(MESSAGE_PRIVATE_READ_DISABLED); + } Assert.notNull(messageId, "已读消息编号不能为空"); - // 1. 一步翻转:把 (receiverId → userId) 这条会话上、id <= messageId 的未读消息全部置为已读 - // 仅 UNREAD 行被命中,避免覆盖已撤回/已读的状态;select-then-update 拆成单条 SQL 后也消除了竞态窗口 + // 2. 把 (receiverId → userId) 这条会话上、id <= messageId 的未读消息一步更新为已读 + // 仅 UNREAD 行被命中,避免覆盖已撤回/已读的状态;select-then-update 合成单条 SQL 后也消除了竞态窗口 int updated = privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus( receiverId, userId, messageId, ImMessageStatusEnum.UNREAD.getStatus(), new ImPrivateMessageDO().setStatus(ImMessageStatusEnum.READ.getStatus())); @@ -220,7 +225,7 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { return; } - // 2. 异步发送 READ + RECEIPT 事件(已读位置以前端上报为准,与多端 / 对方 UI 显示一致) + // 3. 异步发送 READ + RECEIPT 事件(已读位置以前端上报为准,与多端 / 对方 UI 显示一致) imWebSocketService.sendPrivateMessageAsync(userId, ImPrivateMessageDTO.ofRead(userId, receiverId, messageId)); imWebSocketService.sendPrivateMessageAsync(receiverId, @@ -229,6 +234,9 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { @Override public Long getMaxReadMessageId(Long userId, Long peerId) { + if (BooleanUtil.isFalse(imProperties.getMessage().isPrivateReadEnabled())) { + throw exception(MESSAGE_PRIVATE_READ_DISABLED); + } return privateMessageMapper.selectMaxIdBySenderIdAndReceiverIdAndStatus( userId, peerId, ImMessageStatusEnum.READ.getStatus()); } diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java index c654634..13b0c74 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java +++ b/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; import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; import cn.iocoder.yudao.module.im.service.group.ImGroupService; import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; @@ -26,6 +27,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockedStatic; +import org.mockito.Spy; import java.time.LocalDateTime; import java.util.*; @@ -58,6 +60,10 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @Mock private ImWebSocketService imWebSocketService; + /** 用真实实例避免 NPE;默认值与生产保持一致(recallTimeoutMinutes=5、private/group read enabled=true、maxPullSize=1000);个别用例可改字段测分支 */ + @Spy + private ImProperties imProperties = new ImProperties(); + private ImGroupMessageSendReqVO buildSendReqVO() { ImGroupMessageSendReqVO reqVO = new ImGroupMessageSendReqVO(); reqVO.setClientMessageId("test-uuid-group-001"); @@ -812,6 +818,58 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { } @Test + public void testReadGroupMessages_disabled() { + // 准备:关闭群聊已读 + imProperties.getMessage().setGroupReadEnabled(false); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.readGroupMessages(1L, 10L, 100L)); + assertEquals(MESSAGE_GROUP_READ_DISABLED.getCode(), exception.getCode()); + // 断言:Redis 不写、不推送 + verify(groupMessageReadRedisDAO, never()).updateReadMaxMessageId(anyLong(), anyLong(), anyLong()); + verify(imWebSocketService, never()).sendGroupMessageAsync(anyLong(), any(ImGroupMessageDTO.class)); + } + + @Test + public void testGetGroupReadUserIds_disabled() { + // 准备:关闭群聊已读 + imProperties.getMessage().setGroupReadEnabled(false); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.getGroupReadUserIds(1L, 10L, 100L)); + assertEquals(MESSAGE_GROUP_READ_DISABLED.getCode(), exception.getCode()); + } + + @Test + public void testSendGroupMessage_groupReadDisabled_forcesNoReceipt() { + // 关闭群已读:发送方传 receipt=true 也强制落 NO_RECEIPT + imProperties.getMessage().setGroupReadEnabled(false); + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) + .thenReturn(groupMessageService); + + ImGroupMessageSendReqVO reqVO = buildSendReqVO(); + reqVO.setReceipt(true); // 发送方明确要求回执 + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) + .thenReturn(null); + ImGroupDO group = new ImGroupDO(); + group.setId(10L); + when(groupService.validateGroupExists(10L)).thenReturn(group); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L, 2L)); + + ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO); + + assertEquals(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), result.getReceiptStatus(), + "群已读关闭时即使发送方传 receipt=true 也强制落 NO_RECEIPT"); + } + } + + @Test public void testReadGroupMessages_cursorAlreadyAhead() { // 准备:已读游标已 >= 目标,直接返回 ImGroupMemberDO member = ImGroupMemberDO.builder() diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java index e344b12..ac912ff 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java +++ b/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; import cn.iocoder.yudao.module.im.enums.friend.ImFriendStateEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; import cn.iocoder.yudao.module.im.service.friend.ImFriendService; import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO; @@ -19,6 +20,7 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import java.time.LocalDateTime; import java.util.List; @@ -47,6 +49,10 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { @Mock private ImWebSocketService imWebSocketService; + /** 用真实实例避免 NPE;默认值与生产保持一致(recallTimeoutMinutes=5、private/group read enabled=true);个别用例可改字段测分支 */ + @Spy + private ImProperties imProperties = new ImProperties(); + private ImPrivateMessageSendReqVO buildSendReqVO() { ImPrivateMessageSendReqVO reqVO = new ImPrivateMessageSendReqVO(); reqVO.setClientMessageId("test-uuid-001"); @@ -294,6 +300,21 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { } @Test + public void testReadMessages_disabled() { + // 准备:关闭私聊已读 + imProperties.getMessage().setPrivateReadEnabled(false); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> privateMessageService.readPrivateMessages(1L, 2L, 5L)); + assertEquals(MESSAGE_PRIVATE_READ_DISABLED.getCode(), exception.getCode()); + // 断言:不更新消息状态、不推送 + verify(privateMessageMapper, never()).updateBySenderIdAndReceiverIdAndIdLeAndStatus( + anyLong(), anyLong(), anyLong(), anyInt(), any(ImPrivateMessageDO.class)); + verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); + } + + @Test public void testReadMessages_noUnread() { // 准备:mapper 返回 0 表示 id <= messageId 范围内没有未读消息 when(privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus( @@ -336,6 +357,17 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { assertNull(result); } + @Test + public void testGetMaxReadMessageId_disabled() { + // 准备:关闭私聊已读 + imProperties.getMessage().setPrivateReadEnabled(false); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> privateMessageService.getMaxReadMessageId(1L, 2L)); + assertEquals(MESSAGE_PRIVATE_READ_DISABLED.getCode(), exception.getCode()); + } + // ========== sendPrivateMessage(senderId, dto):helper 行为 ========== @Test