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