Commit bb9a2b5382ae65e3bc92a7e4d93f620c24975b99

Authored by YunaiV
1 parent 513c1301

fix(im):加固好友、群成员与群消息边界流程

- 清理好友重加时的 deleteTime 和历史备注残留
- 清理群成员重入时的 quitTime、muteEndTime 和邀请来源残留
- 允许封禁群成员主动退群,仍拦截已解散群
- 校验群已读游标的消息归属和可见性
- 收窄群消息置顶通知为专用展示对象并同步前端展示
- 回填群成员单查接口的昵称和头像
- 补充相关回归测试并流转 IM bug 状态文档
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupMemberController.java
... ... @@ -79,6 +79,10 @@ public class ImGroupMemberController {
79 79  
80 80 // 3. 转化 VO
81 81 ImGroupMemberRespVO memberVO = BeanUtils.toBean(member, ImGroupMemberRespVO.class);
  82 + AdminUserRespDTO user = adminUserApi.getUser(member.getUserId());
  83 + if (user != null) {
  84 + memberVO.setNickname(user.getNickname()).setAvatar(user.getAvatar());
  85 + }
82 86 hidePrivateFieldsIfNotSelf(memberVO, member.getUserId(), loginUserId);
83 87 return success(memberVO);
84 88 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendMapper.java
... ... @@ -5,8 +5,10 @@ import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
5 5 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
6 6 import cn.iocoder.yudao.module.im.controller.admin.manager.friend.vo.ImFriendManagerPageReqVO;
7 7 import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO;
  8 +import com.baomidou.mybatisplus.core.toolkit.Wrappers;
8 9 import org.apache.ibatis.annotations.Mapper;
9 10  
  11 +import java.time.LocalDateTime;
10 12 import java.util.Collection;
11 13 import java.util.List;
12 14  
... ... @@ -55,4 +57,20 @@ public interface ImFriendMapper extends BaseMapperX<ImFriendDO> {
55 57 .orderByDesc(ImFriendDO::getId));
56 58 }
57 59  
  60 + @SuppressWarnings("UnusedReturnValue")
  61 + default int updateReAddFields(Long id, Integer status, LocalDateTime addTime,
  62 + Boolean silent, Boolean pinned, Boolean blocked,
  63 + String displayName, Integer addSource) {
  64 + return update(null, Wrappers.<ImFriendDO>lambdaUpdate()
  65 + .eq(ImFriendDO::getId, id)
  66 + .set(ImFriendDO::getStatus, status)
  67 + .set(ImFriendDO::getAddTime, addTime)
  68 + .set(ImFriendDO::getSilent, silent)
  69 + .set(ImFriendDO::getPinned, pinned)
  70 + .set(ImFriendDO::getBlocked, blocked)
  71 + .set(ImFriendDO::getDisplayName, displayName)
  72 + .set(ImFriendDO::getAddSource, addSource)
  73 + .set(ImFriendDO::getDeleteTime, null));
  74 + }
  75 +
58 76 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapper.java
... ... @@ -124,4 +124,18 @@ public interface ImGroupMemberMapper extends BaseMapperX&lt;ImGroupMemberDO&gt; {
124 124 .set(ImGroupMemberDO::getMuteEndTime, null));
125 125 }
126 126  
127   -}
128 127 \ No newline at end of file
  128 + @SuppressWarnings("UnusedReturnValue")
  129 + default int updateRejoinFields(Long id, Integer status, LocalDateTime joinTime,
  130 + Integer role, Integer addSource, Long inviterUserId) {
  131 + return update(null, Wrappers.<ImGroupMemberDO>lambdaUpdate()
  132 + .eq(ImGroupMemberDO::getId, id)
  133 + .set(ImGroupMemberDO::getStatus, status)
  134 + .set(ImGroupMemberDO::getJoinTime, joinTime)
  135 + .set(ImGroupMemberDO::getRole, role)
  136 + .set(ImGroupMemberDO::getAddSource, addSource)
  137 + .set(ImGroupMemberDO::getInviterUserId, inviterUserId)
  138 + .set(ImGroupMemberDO::getQuitTime, null)
  139 + .set(ImGroupMemberDO::getMuteEndTime, null));
  140 + }
  141 +
  142 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java
... ... @@ -266,16 +266,8 @@ public class ImFriendServiceImpl implements ImFriendService {
266 266 }
267 267 // 情况二:复用 DISABLE 旧记录 → 恢复 ENABLE + 重置 silent / pinned / blocked,对齐"重新加好友"语义
268 268 if (exists != null) {
269   - ImFriendDO update = new ImFriendDO().setId(exists.getId())
270   - .setStatus(CommonStatusEnum.ENABLE.getStatus()).setAddTime(LocalDateTime.now())
271   - .setSilent(false).setPinned(false).setBlocked(false);
272   - if (displayName != null) {
273   - update.setDisplayName(displayName);
274   - }
275   - if (addSource != null) {
276   - update.setAddSource(addSource);
277   - }
278   - friendMapper.updateById(update);
  269 + friendMapper.updateReAddFields(exists.getId(), CommonStatusEnum.ENABLE.getStatus(), LocalDateTime.now(),
  270 + false, false, false, displayName, addSource);
279 271 return;
280 272 }
281 273 // 情况三:不存在记录 → 直接插入新记录
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java
... ... @@ -128,7 +128,7 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService {
128 128 * 并发安全:依靠 im_group_member 表的唯一索引 uk_im_group_member_group_user(group_id, user_id) 保证幂等,
129 129 * 当并发 insert 触发 {@link DuplicateKeyException} 时降级为 select + update。
130 130 * <p>
131   - * 重置旧成员行时强制重置 role / addSource / inviterUserId,确保留痕反映「本次入群」事件
  131 + * 重置旧成员行时强制重置 role / addSource / inviterUserId / quitTime / muteEndTime
132 132 */
133 133 @Override
134 134 @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId")
... ... @@ -139,11 +139,11 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService {
139 139 ImGroupMemberDO exists = groupMemberMapper.selectByGroupIdAndUserId(groupId, userId);
140 140 if (exists != null) {
141 141 if (CommonStatusEnum.isDisable(exists.getStatus())) {
142   - groupMemberMapper.updateById(new ImGroupMemberDO().setId(exists.getId())
143   - .setStatus(CommonStatusEnum.ENABLE.getStatus()).setJoinTime(now)
144   - .setRole(role).setAddSource(addSource).setInviterUserId(inviterUserId));
  142 + groupMemberMapper.updateRejoinFields(exists.getId(), CommonStatusEnum.ENABLE.getStatus(), now,
  143 + role, addSource, inviterUserId);
145 144 exists.setStatus(CommonStatusEnum.ENABLE.getStatus()).setJoinTime(now).setRole(role)
146   - .setAddSource(addSource).setInviterUserId(inviterUserId);
  145 + .setAddSource(addSource).setInviterUserId(inviterUserId)
  146 + .setQuitTime(null).setMuteEndTime(null);
147 147 }
148 148 return exists;
149 149 }
... ... @@ -193,7 +193,10 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService {
193 193  
194 194 // 2.1 先做 update,update 没有并发冲突风险
195 195 if (CollUtil.isNotEmpty(updates)) {
196   - groupMemberMapper.updateBatch(updates);
  196 + for (ImGroupMemberDO update : updates) {
  197 + groupMemberMapper.updateRejoinFields(update.getId(), update.getStatus(), update.getJoinTime(),
  198 + update.getRole(), update.getAddSource(), update.getInviterUserId());
  199 + }
197 200 }
198 201 // 2.2 批量 insert。并发场景下若其它请求已先一步插入同一 (groupId, userId),
199 202 // 会触发唯一索引冲突,此时降级为逐个 addGroupMember(利用其兜底逻辑幂等处理)。
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java
... ... @@ -241,8 +241,14 @@ public class ImGroupServiceImpl implements ImGroupService {
241 241 @Override
242 242 @Transactional(rollbackFor = Exception.class)
243 243 public void quitGroup(Long groupId, Long userId) {
244   - // 1.1 校验群存在
245   - ImGroupDO group = validateGroupExists(groupId);
  244 + // 1.1 校验群存在且未解散
  245 + ImGroupDO group = getSelf().getGroup(groupId);
  246 + if (group == null) {
  247 + throw exception(GROUP_NOT_EXISTS);
  248 + }
  249 + if (CommonStatusEnum.DISABLE.getStatus().equals(group.getStatus())) {
  250 + throw exception(GROUP_DISSOLVED);
  251 + }
246 252 // 1.2 群主不可退群
247 253 if (ObjUtil.equal(group.getOwnerUserId(), userId)) {
248 254 throw exception(GROUP_OWNER_CANNOT_QUIT);
... ... @@ -433,7 +439,7 @@ public class ImGroupServiceImpl implements ImGroupService {
433 439 pinned.add(messageId);
434 440 groupMapper.updateById(new ImGroupDO().setId(groupId).setPinnedMessageIds(pinned));
435 441  
436   - // 3. 推送 GROUP_MESSAGE_PIN 通知给全员;payload 直接带消息对象,前端不用回查群详情绕开 @CacheEvict 时序
  442 + // 3. 推送 GROUP_MESSAGE_PIN 通知给全员
437 443 groupMessageService.sendGroupMessage(userId,
438 444 ImGroupMessageSendDTO.ofGroupMessagePin(groupId, userId, message));
439 445 }
... ... @@ -694,4 +700,4 @@ public class ImGroupServiceImpl implements ImGroupService {
694 700 .collect(Collectors.joining(","));
695 701 }
696 702  
697   -}
698 703 \ No newline at end of file
  704 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java
... ... @@ -354,8 +354,15 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
354 354 throw exception(MESSAGE_GROUP_READ_DISABLED);
355 355 }
356 356 Assert.notNull(messageId, "已读消息编号不能为空");
357   - // 1. 校验用户在群中(权限校验)
358   - groupMemberService.validateMemberInGroup(groupId, userId);
  357 + // 1.1 校验用户在群中(权限校验)
  358 + ImGroupMemberDO member = groupMemberService.validateMemberInGroup(groupId, userId);
  359 + // 1.2 校验消息属于当前群,且对当前用户可见
  360 + ImGroupMessageDO message = groupMessageMapper.selectById(messageId);
  361 + if (message == null
  362 + || ObjUtil.notEqual(message.getGroupId(), groupId)
  363 + || !isMessageVisible(message, member, userId)) {
  364 + throw exception(MESSAGE_NOT_IN_GROUP);
  365 + }
359 366  
360 367 // 2. 已读位置未前进,直接返回
361 368 Long prevMaxMessageId = groupMessageReadRedisDAO.getReadMaxMessageId(groupId, userId);
... ... @@ -664,4 +671,4 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
664 671 }
665 672 }
666 673  
667   -}
668 674 \ No newline at end of file
  675 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImGroupMessageSendDTO.java
1 1 package cn.iocoder.yudao.module.im.service.message.dto;
2 2  
  3 +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
3 4 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
4 5 import cn.iocoder.yudao.module.im.service.websocket.dto.notification.group.*;
5 6 import jakarta.validation.constraints.NotNull;
... ... @@ -166,11 +167,14 @@ public class ImGroupMessageSendDTO {
166 167 .setType(ImMessageTypeEnum.GROUP_MEMBER_SETTING_UPDATE.getType()).setContent(notification);
167 168 }
168 169  
169   - public static ImGroupMessageSendDTO ofGroupMessagePin(Long groupId, Long operatorUserId,
170   - cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO message) {
  170 + public static ImGroupMessageSendDTO ofGroupMessagePin(Long groupId, Long operatorUserId, ImGroupMessageDO message) {
171 171 GroupMessagePinNotification notification = new GroupMessagePinNotification();
  172 + GroupMessagePinNotification.PinnedMessage pinnedMessage = new GroupMessagePinNotification.PinnedMessage()
  173 + .setId(message.getId()).setSenderId(message.getSenderId()).setGroupId(message.getGroupId())
  174 + .setType(message.getType()).setContent(message.getContent()).setSendTime(message.getSendTime())
  175 + .setAtUserIds(message.getAtUserIds()).setReceiverUserIds(message.getReceiverUserIds());
172 176 notification.setOperatorUserId(operatorUserId);
173   - notification.setMessageId(message.getId()).setMessage(message);
  177 + notification.setMessageId(message.getId()).setMessage(pinnedMessage);
174 178 return new ImGroupMessageSendDTO().setGroupId(groupId)
175 179 .setType(ImMessageTypeEnum.GROUP_MESSAGE_PIN.getType()).setContent(notification);
176 180 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMessagePinNotification.java
1 1 package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
2 2  
3   -import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
4 3 import lombok.Data;
5 4  
  5 +import java.time.LocalDateTime;
  6 +import java.util.List;
  7 +
6 8 /**
7 9 * 群消息置顶事件通知
8   - * <p>
9   - * payload 直接携带被置顶的完整消息对象,前端按 message 直接 push 到 pinnedMessages,避免 GET /im/group/get 命中 @CacheEvict 触发前的旧 cache
10 10 */
11 11 @Data
12 12 public class GroupMessagePinNotification extends BaseGroupNotification {
... ... @@ -16,8 +16,49 @@ public class GroupMessagePinNotification extends BaseGroupNotification {
16 16 */
17 17 private Long messageId;
18 18 /**
19   - * 被置顶的完整消息对象(避免前端回查群详情)
  19 + * 被置顶的消息展示数据
  20 + */
  21 + private PinnedMessage message;
  22 +
  23 + /**
  24 + * 被置顶的消息展示数据
20 25 */
21   - private ImGroupMessageDO message;
  26 + @Data
  27 + public static class PinnedMessage {
  28 +
  29 + /**
  30 + * 消息编号
  31 + */
  32 + private Long id;
  33 + /**
  34 + * 发送人编号
  35 + */
  36 + private Long senderId;
  37 + /**
  38 + * 群编号
  39 + */
  40 + private Long groupId;
  41 + /**
  42 + * 消息类型
  43 + */
  44 + private Integer type;
  45 + /**
  46 + * 消息内容
  47 + */
  48 + private String content;
  49 + /**
  50 + * 发送时间
  51 + */
  52 + private LocalDateTime sendTime;
  53 + /**
  54 + * @ 目标用户编号列表
  55 + */
  56 + private List<Long> atUserIds;
  57 + /**
  58 + * 定向接收用户编号列表
  59 + */
  60 + private List<Long> receiverUserIds;
  61 +
  62 + }
22 63  
23 64 }
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImplTest.java
... ... @@ -21,9 +21,7 @@ import java.util.List;
21 21  
22 22 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_FRIEND;
23 23 import static org.junit.jupiter.api.Assertions.*;
24   -import static org.mockito.ArgumentMatchers.any;
25   -import static org.mockito.ArgumentMatchers.anyLong;
26   -import static org.mockito.ArgumentMatchers.eq;
  24 +import static org.mockito.ArgumentMatchers.*;
27 25 import static org.mockito.Mockito.never;
28 26 import static org.mockito.Mockito.verify;
29 27 import static org.mockito.Mockito.when;
... ... @@ -126,8 +124,8 @@ public class ImFriendServiceImplTest extends BaseMockitoUnitTest {
126 124 // ========== addFriend0(内部方法,被 becomeFriends / silentReAddFriend 调用) ==========
127 125  
128 126 @Test
129   - public void testAddFriend0_existingEnabledResetsFields() {
130   - // 准备:已存在且启用 —— 走复用旧记录路径,重置 silent / pinned / blocked
  127 + public void testAddFriend0_existingEnabledSkip() {
  128 + // 准备:已存在且启用
131 129 ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L)
132 130 .status(CommonStatusEnum.ENABLE.getStatus()).silent(true).pinned(true).blocked(true).build();
133 131 when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists);
... ... @@ -135,16 +133,11 @@ public class ImFriendServiceImplTest extends BaseMockitoUnitTest {
135 133 // 调用
136 134 friendService.addFriend0(1L, 2L, null, null);
137 135  
138   - // 断言:不插入;走 update 重置 silent / pinned / blocked 为 false,并刷新 addTime
  136 + // 断言:不插入也不更新
139 137 verify(imFriendMapper, never()).insert(any(ImFriendDO.class));
140   - ArgumentCaptor<ImFriendDO> captor = ArgumentCaptor.forClass(ImFriendDO.class);
141   - verify(imFriendMapper).updateById(captor.capture());
142   - assertEquals(10L, captor.getValue().getId());
143   - assertEquals(CommonStatusEnum.ENABLE.getStatus(), captor.getValue().getStatus());
144   - assertFalse(captor.getValue().getSilent());
145   - assertFalse(captor.getValue().getPinned());
146   - assertFalse(captor.getValue().getBlocked());
147   - assertNotNull(captor.getValue().getAddTime());
  138 + verify(imFriendMapper, never()).updateById(any(ImFriendDO.class));
  139 + verify(imFriendMapper, never()).updateReAddFields(anyLong(), anyInt(), any(LocalDateTime.class),
  140 + anyBoolean(), anyBoolean(), anyBoolean(), any(), any());
148 141 }
149 142  
150 143 @Test
... ... @@ -157,11 +150,9 @@ public class ImFriendServiceImplTest extends BaseMockitoUnitTest {
157 150 // 调用
158 151 friendService.addFriend0(1L, 2L, null, null);
159 152  
160   - // 断言:更新 status 为 ENABLE
161   - ArgumentCaptor<ImFriendDO> captor = ArgumentCaptor.forClass(ImFriendDO.class);
162   - verify(imFriendMapper).updateById(captor.capture());
163   - assertEquals(10L, captor.getValue().getId());
164   - assertEquals(CommonStatusEnum.ENABLE.getStatus(), captor.getValue().getStatus());
  153 + // 断言:恢复 ENABLE,并清空 deleteTime
  154 + verify(imFriendMapper).updateReAddFields(eq(10L), eq(CommonStatusEnum.ENABLE.getStatus()),
  155 + any(LocalDateTime.class), eq(false), eq(false), eq(false), isNull(), isNull());
165 156 verify(imFriendMapper, never()).insert(any(ImFriendDO.class));
166 157 }
167 158  
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java
... ... @@ -82,14 +82,12 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest {
82 82  
83 83 ImGroupMemberDO result = groupMemberService.addGroupMember(10L, 1L);
84 84  
85   - ArgumentCaptor<ImGroupMemberDO> captor = ArgumentCaptor.forClass(ImGroupMemberDO.class);
86   - verify(groupMemberMapper).updateById(captor.capture());
87   - assertEquals(50L, captor.getValue().getId());
88   - assertEquals(CommonStatusEnum.ENABLE.getStatus(), captor.getValue().getStatus());
89   - assertEquals(ImGroupMemberRoleEnum.NORMAL.getRole(), captor.getValue().getRole());
90   - assertNotNull(captor.getValue().getJoinTime());
  85 + verify(groupMemberMapper).updateRejoinFields(eq(50L), eq(CommonStatusEnum.ENABLE.getStatus()),
  86 + any(), eq(ImGroupMemberRoleEnum.NORMAL.getRole()), isNull(), isNull());
91 87 assertEquals(CommonStatusEnum.ENABLE.getStatus(), result.getStatus());
92 88 assertEquals(ImGroupMemberRoleEnum.NORMAL.getRole(), result.getRole());
  89 + assertNull(result.getQuitTime());
  90 + assertNull(result.getMuteEndTime());
93 91 }
94 92  
95 93 @Test
... ... @@ -124,9 +122,8 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest {
124 122 groupMemberService.addGroupMembers(10L, List.of(2L, 3L, 4L));
125 123  
126 124 // 断言:updates 只有用户 3;inserts 只有用户 2
127   - verify(groupMemberMapper).updateBatch(argThat((List<ImGroupMemberDO> list) ->
128   - list.size() == 1 && list.get(0).getId().equals(30L)
129   - && CommonStatusEnum.ENABLE.getStatus().equals(list.get(0).getStatus())));
  125 + verify(groupMemberMapper).updateRejoinFields(eq(30L), eq(CommonStatusEnum.ENABLE.getStatus()),
  126 + any(), eq(ImGroupMemberRoleEnum.NORMAL.getRole()), isNull(), isNull());
130 127 verify(groupMemberMapper).insertBatch(argThat((List<ImGroupMemberDO> list) ->
131 128 list.size() == 1 && list.get(0).getUserId().equals(2L)));
132 129 }
... ... @@ -145,7 +142,8 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest {
145 142  
146 143 groupMemberService.addGroupMembers(10L, List.of(2L, 3L));
147 144  
148   - verify(groupMemberMapper).updateBatch(anyList());
  145 + verify(groupMemberMapper, times(2)).updateRejoinFields(anyLong(), eq(CommonStatusEnum.ENABLE.getStatus()),
  146 + any(), eq(ImGroupMemberRoleEnum.NORMAL.getRole()), isNull(), isNull());
149 147 verify(groupMemberMapper, never()).insertBatch(anyList());
150 148 }
151 149  
... ... @@ -157,7 +155,7 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest {
157 155  
158 156 groupMemberService.addGroupMembers(10L, List.of(2L, 3L));
159 157  
160   - verify(groupMemberMapper, never()).updateBatch(anyList());
  158 + verify(groupMemberMapper, never()).updateRejoinFields(anyLong(), anyInt(), any(), anyInt(), any(), any());
161 159 verify(groupMemberMapper).insertBatch(anyList());
162 160 }
163 161  
... ... @@ -173,7 +171,7 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest {
173 171  
174 172 groupMemberService.addGroupMembers(10L, List.of(2L));
175 173  
176   - verify(groupMemberMapper, never()).updateBatch(anyList());
  174 + verify(groupMemberMapper, never()).updateRejoinFields(anyLong(), anyInt(), any(), anyInt(), any(), any());
177 175 verify(groupMemberMapper, never()).insertBatch(anyList());
178 176 }
179 177  
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java
... ... @@ -525,6 +525,24 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
525 525 }
526 526 }
527 527  
  528 + @Test
  529 + public void testQuitGroup_bannedGroupAllowed() {
  530 + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
  531 + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class)))
  532 + .thenReturn(groupService);
  533 +
  534 + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L)
  535 + .banned(true).status(CommonStatusEnum.ENABLE.getStatus()).build();
  536 + when(groupMapper.selectById(10L)).thenReturn(group);
  537 +
  538 + groupService.quitGroup(10L, 1L);
  539 +
  540 + verify(groupMemberService).removeGroupMember(10L, 1L);
  541 + verify(groupMessageService).deleteReadMaxMessageId(10L, 1L);
  542 + verify(groupMessageService).sendGroupMessage(eq(1L), any(ImGroupMessageSendDTO.class));
  543 + }
  544 + }
  545 +
528 546 // ========== removeGroupMember ==========
529 547  
530 548 @Test
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java
... ... @@ -11,6 +11,7 @@ import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
11 11 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
12 12 import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper;
13 13 import cn.iocoder.yudao.module.im.dal.redis.message.ImGroupMessageReadRedisDAO;
  14 +import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum;
14 15 import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
15 16 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
16 17 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
... ... @@ -588,6 +589,9 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
588 589 .status(ImMessageStatusEnum.UNREAD.getStatus())
589 590 .sendTime(LocalDateTime.now()).build();
590 591 when(groupMessageMapper.selectById(50L)).thenReturn(message);
  592 + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(
  593 + ImGroupMemberDO.builder().groupId(10L).userId(1L)
  594 + .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build());
591 595  
592 596 // 调用并断言
593 597 ServiceException exception = assertThrows(ServiceException.class,
... ... @@ -603,6 +607,9 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
603 607 .status(ImMessageStatusEnum.RECALL.getStatus())
604 608 .sendTime(LocalDateTime.now()).build();
605 609 when(groupMessageMapper.selectById(50L)).thenReturn(message);
  610 + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(
  611 + ImGroupMemberDO.builder().groupId(10L).userId(1L)
  612 + .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build());
606 613  
607 614 // 调用并断言
608 615 ServiceException exception = assertThrows(ServiceException.class,
... ... @@ -618,6 +625,9 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
618 625 .status(ImMessageStatusEnum.UNREAD.getStatus())
619 626 .sendTime(LocalDateTime.now().minusMinutes(10)).build();
620 627 when(groupMessageMapper.selectById(50L)).thenReturn(message);
  628 + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(
  629 + ImGroupMemberDO.builder().groupId(10L).userId(1L)
  630 + .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build());
621 631  
622 632 // 调用并断言
623 633 ServiceException exception = assertThrows(ServiceException.class,
... ... @@ -641,12 +651,12 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
641 651  
642 652 @Test
643 653 public void testGetReadUserIds_withVisibleScope() {
644   - // 准备:用户 1 是群成员
  654 + // 准备:发送者用户 5 是群成员
645 655 ImGroupMemberDO currentMember = ImGroupMemberDO.builder()
646   - .groupId(10L).userId(1L)
  656 + .groupId(10L).userId(5L)
647 657 .status(CommonStatusEnum.ENABLE.getStatus())
648 658 .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build();
649   - when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(currentMember);
  659 + when(groupMemberService.validateMemberInGroup(10L, 5L)).thenReturn(currentMember);
650 660  
651 661 // 准备:消息由用户 5 发,发送时间在 2026-04-12 10:00
652 662 ImGroupMessageDO message = ImGroupMessageDO.builder()
... ... @@ -661,6 +671,9 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
661 671 // 用户 5: 发送者,不计入回执
662 672 List<ImGroupMemberDO> allMembers = List.of(
663 673 currentMember,
  674 + ImGroupMemberDO.builder().groupId(10L).userId(1L)
  675 + .status(CommonStatusEnum.ENABLE.getStatus())
  676 + .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(),
664 677 ImGroupMemberDO.builder().groupId(10L).userId(2L)
665 678 .status(CommonStatusEnum.ENABLE.getStatus())
666 679 .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(),
... ... @@ -681,7 +694,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
681 694 when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions);
682 695  
683 696 // 调用:查询 messageId=80 的已读用户
684   - List<Long> readUsers = groupMessageService.getGroupReadUserIds(1L, 10L, 80L);
  697 + List<Long> readUsers = groupMessageService.getGroupReadUserIds(5L, 10L, 80L);
685 698  
686 699 // 断言:
687 700 // 用户 1: 可见 + readMaxId=100>=80 → 已读 ✓
... ... @@ -804,6 +817,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
804 817 .groupId(10L).userId(1L)
805 818 .status(CommonStatusEnum.ENABLE.getStatus()).build();
806 819 when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member);
  820 + when(groupMessageMapper.selectById(100L)).thenReturn(ImGroupMessageDO.builder()
  821 + .id(100L).groupId(10L).senderId(2L).sendTime(LocalDateTime.now()).build());
807 822 when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(5L);
808 823 // readGroupMessageEvent 内部会调 selectListByGroupIdAndPendingReceipt → 返回空简化流程
809 824 when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 5L, 100L)).thenReturn(List.of());
... ... @@ -876,6 +891,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
876 891 .groupId(10L).userId(1L)
877 892 .status(CommonStatusEnum.ENABLE.getStatus()).build();
878 893 when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member);
  894 + when(groupMessageMapper.selectById(100L)).thenReturn(ImGroupMessageDO.builder()
  895 + .id(100L).groupId(10L).senderId(2L).sendTime(LocalDateTime.now()).build());
879 896 when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(200L);
880 897  
881 898 // 调用
... ... @@ -886,6 +903,23 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
886 903 verify(imWebSocketService, never()).sendGroupMessageAsync(anyLong(), any(ImGroupMessageDTO.class));
887 904 }
888 905  
  906 + @Test
  907 + public void testReadGroupMessages_messageNotInGroup() {
  908 + // 准备:用户在群,但 messageId 属于其它群
  909 + ImGroupMemberDO member = ImGroupMemberDO.builder()
  910 + .groupId(10L).userId(1L)
  911 + .status(CommonStatusEnum.ENABLE.getStatus()).build();
  912 + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member);
  913 + when(groupMessageMapper.selectById(100L)).thenReturn(ImGroupMessageDO.builder()
  914 + .id(100L).groupId(20L).senderId(2L).sendTime(LocalDateTime.now()).build());
  915 +
  916 + // 调用并断言
  917 + ServiceException exception = assertThrows(ServiceException.class,
  918 + () -> groupMessageService.readGroupMessages(1L, 10L, 100L));
  919 + assertEquals(MESSAGE_NOT_IN_GROUP.getCode(), exception.getCode());
  920 + verify(groupMessageReadRedisDAO, never()).updateReadMaxMessageId(anyLong(), anyLong(), anyLong());
  921 + }
  922 +
889 923 // ========== 回执:DONE 状态迁移 ==========
890 924  
891 925 @Test
... ... @@ -1105,7 +1139,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
1105 1139 reqVO.setLimit(20);
1106 1140  
1107 1141 List<ImGroupMessageDO> mockList = List.of(
1108   - ImGroupMessageDO.builder().id(99L).groupId(10L).senderId(2L).build()
  1142 + ImGroupMessageDO.builder().id(99L).groupId(10L).senderId(2L)
  1143 + .sendTime(joinTime.plusMinutes(1)).build()
1109 1144 );
1110 1145 when(groupMessageMapper.selectHistoryList(10L, 100L, 20, joinTime)).thenReturn(mockList);
1111 1146  
... ...