Commit 348b97047d8cdc94dc1730269eb331a1b45a686f

Authored by YunaiV
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 分支断言
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java
... ... @@ -18,6 +18,8 @@ public interface ErrorCodeConstants {
18 18 ErrorCode MESSAGE_RECALL_TIMEOUT = new ErrorCode(1_040_300_007, "超过 {} 分钟的消息无法撤回");
19 19 ErrorCode MESSAGE_QUOTE_INVALID = new ErrorCode(1_040_300_008, "引用的消息不可用");
20 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 24 // ========== 群 (1-040-400-000) ==========
23 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 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 89 * pull 最大拉取数量
73 90 */
74 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 2  
3 3 import cn.hutool.core.collection.CollUtil;
4 4 import cn.hutool.core.lang.Assert;
  5 +import cn.hutool.core.util.BooleanUtil;
5 6 import cn.hutool.core.util.IdUtil;
6 7 import cn.hutool.core.util.ObjUtil;
7 8 import cn.hutool.extra.spring.SpringUtil;
... ... @@ -105,9 +106,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
105 106 // 2.2 构建并保存消息
106 107 ImGroupMessageDO message = BeanUtils.toBean(reqVO, ImGroupMessageDO.class, m -> m
107 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 110 groupMessageMapper.insert(message);
112 111  
113 112 // 3. WebSocket 异步推送(群内可见成员 + 发送方多端同步)
... ... @@ -137,9 +136,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
137 136 .setType(dto.getType()).setContent(contentString)
138 137 .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now())
139 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 140 // 1.3 按 type.persistent 决定是否入库
144 141 if (ImMessageTypeEnum.validate(dto.getType()).isPersistent()) {
145 142 groupMessageMapper.insert(message);
... ... @@ -305,6 +302,10 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
305 302 if (CollUtil.isEmpty(messages)) {
306 303 return;
307 304 }
  305 + // 群已读关闭:不查 Redis 已读游标,status 保持 DB 原值(含撤回),readCount 不补齐
  306 + if (BooleanUtil.isFalse(imProperties.getMessage().isGroupReadEnabled())) {
  307 + return;
  308 + }
308 309 Map<Long, Long> readMaxMessageIdsByGroup = new HashMap<>(); // 群 → 已读位置
309 310 Map<Long, Map<Long, Long>> readPositionsByGroup = new HashMap<>(); // 群 → (用户 → 已读位置)
310 311 Map<Long, List<ImGroupMemberDO>> membersByGroup = new HashMap<>(); // 群 → 全部成员列表
... ... @@ -343,6 +344,10 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
343 344  
344 345 @Override
345 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 351 Assert.notNull(messageId, "已读消息编号不能为空");
347 352 // 1. 校验用户在群中(权限校验)
348 353 groupMemberService.validateMemberInGroup(groupId, userId);
... ... @@ -361,6 +366,10 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
361 366  
362 367 @Override
363 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 373 // 1.1 校验用户在群中(权限校验)
365 374 groupMemberService.validateMemberInGroup(groupId, userId);
366 375 // 1.2 获取消息
... ... @@ -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 640 private void validateMuteStatus(ImGroupDO group, ImGroupMemberDO senderMember) {
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java
1 1 package cn.iocoder.yudao.module.im.service.message;
2 2  
3 3 import cn.hutool.core.lang.Assert;
  4 +import cn.hutool.core.util.BooleanUtil;
4 5 import cn.hutool.core.util.IdUtil;
5 6 import cn.hutool.core.util.ObjUtil;
6 7 import cn.iocoder.yudao.framework.common.pojo.PageResult;
... ... @@ -210,9 +211,13 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
210 211  
211 212 @Override
212 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 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 221 int updated = privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus(
217 222 receiverId, userId, messageId, ImMessageStatusEnum.UNREAD.getStatus(),
218 223 new ImPrivateMessageDO().setStatus(ImMessageStatusEnum.READ.getStatus()));
... ... @@ -220,7 +225,7 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
220 225 return;
221 226 }
222 227  
223   - // 2. 异步发送 READ + RECEIPT 事件(已读位置以前端上报为准,与多端 / 对方 UI 显示一致)
  228 + // 3. 异步发送 READ + RECEIPT 事件(已读位置以前端上报为准,与多端 / 对方 UI 显示一致)
224 229 imWebSocketService.sendPrivateMessageAsync(userId,
225 230 ImPrivateMessageDTO.ofRead(userId, receiverId, messageId));
226 231 imWebSocketService.sendPrivateMessageAsync(receiverId,
... ... @@ -229,6 +234,9 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
229 234  
230 235 @Override
231 236 public Long getMaxReadMessageId(Long userId, Long peerId) {
  237 + if (BooleanUtil.isFalse(imProperties.getMessage().isPrivateReadEnabled())) {
  238 + throw exception(MESSAGE_PRIVATE_READ_DISABLED);
  239 + }
232 240 return privateMessageMapper.selectMaxIdBySenderIdAndReceiverIdAndStatus(
233 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 14 import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
15 15 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
16 16 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
  17 +import cn.iocoder.yudao.module.im.framework.config.ImProperties;
17 18 import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService;
18 19 import cn.iocoder.yudao.module.im.service.group.ImGroupService;
19 20 import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO;
... ... @@ -26,6 +27,7 @@ import org.mockito.ArgumentCaptor;
26 27 import org.mockito.InjectMocks;
27 28 import org.mockito.Mock;
28 29 import org.mockito.MockedStatic;
  30 +import org.mockito.Spy;
29 31  
30 32 import java.time.LocalDateTime;
31 33 import java.util.*;
... ... @@ -58,6 +60,10 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
58 60 @Mock
59 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 67 private ImGroupMessageSendReqVO buildSendReqVO() {
62 68 ImGroupMessageSendReqVO reqVO = new ImGroupMessageSendReqVO();
63 69 reqVO.setClientMessageId("test-uuid-group-001");
... ... @@ -812,6 +818,58 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
812 818 }
813 819  
814 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 873 public void testReadGroupMessages_cursorAlreadyAhead() {
816 874 // 准备:已读游标已 >= 目标,直接返回
817 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 9 import cn.iocoder.yudao.module.im.enums.friend.ImFriendStateEnum;
10 10 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
11 11 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
  12 +import cn.iocoder.yudao.module.im.framework.config.ImProperties;
12 13 import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
13 14 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
14 15 import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO;
... ... @@ -19,6 +20,7 @@ import org.junit.jupiter.api.Test;
19 20 import org.mockito.ArgumentCaptor;
20 21 import org.mockito.InjectMocks;
21 22 import org.mockito.Mock;
  23 +import org.mockito.Spy;
22 24  
23 25 import java.time.LocalDateTime;
24 26 import java.util.List;
... ... @@ -47,6 +49,10 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
47 49 @Mock
48 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 56 private ImPrivateMessageSendReqVO buildSendReqVO() {
51 57 ImPrivateMessageSendReqVO reqVO = new ImPrivateMessageSendReqVO();
52 58 reqVO.setClientMessageId("test-uuid-001");
... ... @@ -294,6 +300,21 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
294 300 }
295 301  
296 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 318 public void testReadMessages_noUnread() {
298 319 // 准备:mapper 返回 0 表示 id <= messageId 范围内没有未读消息
299 320 when(privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus(
... ... @@ -336,6 +357,17 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
336 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 371 // ========== sendPrivateMessage(senderId, dto):helper 行为 ==========
340 372  
341 373 @Test
... ...