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,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