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,6 +79,10 @@ public class ImGroupMemberController {
79 79
80 // 3. 转化 VO 80 // 3. 转化 VO
81 ImGroupMemberRespVO memberVO = BeanUtils.toBean(member, ImGroupMemberRespVO.class); 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 hidePrivateFieldsIfNotSelf(memberVO, member.getUserId(), loginUserId); 86 hidePrivateFieldsIfNotSelf(memberVO, member.getUserId(), loginUserId);
83 return success(memberVO); 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,8 +5,10 @@ import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
5 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; 5 import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
6 import cn.iocoder.yudao.module.im.controller.admin.manager.friend.vo.ImFriendManagerPageReqVO; 6 import cn.iocoder.yudao.module.im.controller.admin.manager.friend.vo.ImFriendManagerPageReqVO;
7 import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; 7 import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO;
  8 +import com.baomidou.mybatisplus.core.toolkit.Wrappers;
8 import org.apache.ibatis.annotations.Mapper; 9 import org.apache.ibatis.annotations.Mapper;
9 10
  11 +import java.time.LocalDateTime;
10 import java.util.Collection; 12 import java.util.Collection;
11 import java.util.List; 13 import java.util.List;
12 14
@@ -55,4 +57,20 @@ public interface ImFriendMapper extends BaseMapperX<ImFriendDO> { @@ -55,4 +57,20 @@ public interface ImFriendMapper extends BaseMapperX<ImFriendDO> {
55 .orderByDesc(ImFriendDO::getId)); 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,4 +124,18 @@ public interface ImGroupMemberMapper extends BaseMapperX&lt;ImGroupMemberDO&gt; {
124 .set(ImGroupMemberDO::getMuteEndTime, null)); 124 .set(ImGroupMemberDO::getMuteEndTime, null));
125 } 125 }
126 126
127 -}  
128 \ No newline at end of file 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,16 +266,8 @@ public class ImFriendServiceImpl implements ImFriendService {
266 } 266 }
267 // 情况二:复用 DISABLE 旧记录 → 恢复 ENABLE + 重置 silent / pinned / blocked,对齐"重新加好友"语义 267 // 情况二:复用 DISABLE 旧记录 → 恢复 ENABLE + 重置 silent / pinned / blocked,对齐"重新加好友"语义
268 if (exists != null) { 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 return; 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,7 +128,7 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService {
128 * 并发安全:依靠 im_group_member 表的唯一索引 uk_im_group_member_group_user(group_id, user_id) 保证幂等, 128 * 并发安全:依靠 im_group_member 表的唯一索引 uk_im_group_member_group_user(group_id, user_id) 保证幂等,
129 * 当并发 insert 触发 {@link DuplicateKeyException} 时降级为 select + update。 129 * 当并发 insert 触发 {@link DuplicateKeyException} 时降级为 select + update。
130 * <p> 130 * <p>
131 - * 重置旧成员行时强制重置 role / addSource / inviterUserId,确保留痕反映「本次入群」事件 131 + * 重置旧成员行时强制重置 role / addSource / inviterUserId / quitTime / muteEndTime
132 */ 132 */
133 @Override 133 @Override
134 @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") 134 @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId")
@@ -139,11 +139,11 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { @@ -139,11 +139,11 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService {
139 ImGroupMemberDO exists = groupMemberMapper.selectByGroupIdAndUserId(groupId, userId); 139 ImGroupMemberDO exists = groupMemberMapper.selectByGroupIdAndUserId(groupId, userId);
140 if (exists != null) { 140 if (exists != null) {
141 if (CommonStatusEnum.isDisable(exists.getStatus())) { 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 exists.setStatus(CommonStatusEnum.ENABLE.getStatus()).setJoinTime(now).setRole(role) 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 return exists; 148 return exists;
149 } 149 }
@@ -193,7 +193,10 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { @@ -193,7 +193,10 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService {
193 193
194 // 2.1 先做 update,update 没有并发冲突风险 194 // 2.1 先做 update,update 没有并发冲突风险
195 if (CollUtil.isNotEmpty(updates)) { 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 // 2.2 批量 insert。并发场景下若其它请求已先一步插入同一 (groupId, userId), 201 // 2.2 批量 insert。并发场景下若其它请求已先一步插入同一 (groupId, userId),
199 // 会触发唯一索引冲突,此时降级为逐个 addGroupMember(利用其兜底逻辑幂等处理)。 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,8 +241,14 @@ public class ImGroupServiceImpl implements ImGroupService {
241 @Override 241 @Override
242 @Transactional(rollbackFor = Exception.class) 242 @Transactional(rollbackFor = Exception.class)
243 public void quitGroup(Long groupId, Long userId) { 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 // 1.2 群主不可退群 252 // 1.2 群主不可退群
247 if (ObjUtil.equal(group.getOwnerUserId(), userId)) { 253 if (ObjUtil.equal(group.getOwnerUserId(), userId)) {
248 throw exception(GROUP_OWNER_CANNOT_QUIT); 254 throw exception(GROUP_OWNER_CANNOT_QUIT);
@@ -433,7 +439,7 @@ public class ImGroupServiceImpl implements ImGroupService { @@ -433,7 +439,7 @@ public class ImGroupServiceImpl implements ImGroupService {
433 pinned.add(messageId); 439 pinned.add(messageId);
434 groupMapper.updateById(new ImGroupDO().setId(groupId).setPinnedMessageIds(pinned)); 440 groupMapper.updateById(new ImGroupDO().setId(groupId).setPinnedMessageIds(pinned));
435 441
436 - // 3. 推送 GROUP_MESSAGE_PIN 通知给全员;payload 直接带消息对象,前端不用回查群详情绕开 @CacheEvict 时序 442 + // 3. 推送 GROUP_MESSAGE_PIN 通知给全员
437 groupMessageService.sendGroupMessage(userId, 443 groupMessageService.sendGroupMessage(userId,
438 ImGroupMessageSendDTO.ofGroupMessagePin(groupId, userId, message)); 444 ImGroupMessageSendDTO.ofGroupMessagePin(groupId, userId, message));
439 } 445 }
@@ -694,4 +700,4 @@ public class ImGroupServiceImpl implements ImGroupService { @@ -694,4 +700,4 @@ public class ImGroupServiceImpl implements ImGroupService {
694 .collect(Collectors.joining(",")); 700 .collect(Collectors.joining(","));
695 } 701 }
696 702
697 -}  
698 \ No newline at end of file 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,8 +354,15 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
354 throw exception(MESSAGE_GROUP_READ_DISABLED); 354 throw exception(MESSAGE_GROUP_READ_DISABLED);
355 } 355 }
356 Assert.notNull(messageId, "已读消息编号不能为空"); 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 // 2. 已读位置未前进,直接返回 367 // 2. 已读位置未前进,直接返回
361 Long prevMaxMessageId = groupMessageReadRedisDAO.getReadMaxMessageId(groupId, userId); 368 Long prevMaxMessageId = groupMessageReadRedisDAO.getReadMaxMessageId(groupId, userId);
@@ -664,4 +671,4 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { @@ -664,4 +671,4 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
664 } 671 }
665 } 672 }
666 673
667 -}  
668 \ No newline at end of file 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 package cn.iocoder.yudao.module.im.service.message.dto; 1 package cn.iocoder.yudao.module.im.service.message.dto;
2 2
  3 +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
3 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; 4 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
4 import cn.iocoder.yudao.module.im.service.websocket.dto.notification.group.*; 5 import cn.iocoder.yudao.module.im.service.websocket.dto.notification.group.*;
5 import jakarta.validation.constraints.NotNull; 6 import jakarta.validation.constraints.NotNull;
@@ -166,11 +167,14 @@ public class ImGroupMessageSendDTO { @@ -166,11 +167,14 @@ public class ImGroupMessageSendDTO {
166 .setType(ImMessageTypeEnum.GROUP_MEMBER_SETTING_UPDATE.getType()).setContent(notification); 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 GroupMessagePinNotification notification = new GroupMessagePinNotification(); 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 notification.setOperatorUserId(operatorUserId); 176 notification.setOperatorUserId(operatorUserId);
173 - notification.setMessageId(message.getId()).setMessage(message); 177 + notification.setMessageId(message.getId()).setMessage(pinnedMessage);
174 return new ImGroupMessageSendDTO().setGroupId(groupId) 178 return new ImGroupMessageSendDTO().setGroupId(groupId)
175 .setType(ImMessageTypeEnum.GROUP_MESSAGE_PIN.getType()).setContent(notification); 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 package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; 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 import lombok.Data; 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 @Data 11 @Data
12 public class GroupMessagePinNotification extends BaseGroupNotification { 12 public class GroupMessagePinNotification extends BaseGroupNotification {
@@ -16,8 +16,49 @@ public class GroupMessagePinNotification extends BaseGroupNotification { @@ -16,8 +16,49 @@ public class GroupMessagePinNotification extends BaseGroupNotification {
16 */ 16 */
17 private Long messageId; 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,9 +21,7 @@ import java.util.List;
21 21
22 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_FRIEND; 22 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_FRIEND;
23 import static org.junit.jupiter.api.Assertions.*; 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 import static org.mockito.Mockito.never; 25 import static org.mockito.Mockito.never;
28 import static org.mockito.Mockito.verify; 26 import static org.mockito.Mockito.verify;
29 import static org.mockito.Mockito.when; 27 import static org.mockito.Mockito.when;
@@ -126,8 +124,8 @@ public class ImFriendServiceImplTest extends BaseMockitoUnitTest { @@ -126,8 +124,8 @@ public class ImFriendServiceImplTest extends BaseMockitoUnitTest {
126 // ========== addFriend0(内部方法,被 becomeFriends / silentReAddFriend 调用) ========== 124 // ========== addFriend0(内部方法,被 becomeFriends / silentReAddFriend 调用) ==========
127 125
128 @Test 126 @Test
129 - public void testAddFriend0_existingEnabledResetsFields() {  
130 - // 准备:已存在且启用 —— 走复用旧记录路径,重置 silent / pinned / blocked 127 + public void testAddFriend0_existingEnabledSkip() {
  128 + // 准备:已存在且启用
131 ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L) 129 ImFriendDO exists = ImFriendDO.builder().id(10L).userId(1L).friendUserId(2L)
132 .status(CommonStatusEnum.ENABLE.getStatus()).silent(true).pinned(true).blocked(true).build(); 130 .status(CommonStatusEnum.ENABLE.getStatus()).silent(true).pinned(true).blocked(true).build();
133 when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists); 131 when(imFriendMapper.selectByUserIdAndFriendUserId(1L, 2L)).thenReturn(exists);
@@ -135,16 +133,11 @@ public class ImFriendServiceImplTest extends BaseMockitoUnitTest { @@ -135,16 +133,11 @@ public class ImFriendServiceImplTest extends BaseMockitoUnitTest {
135 // 调用 133 // 调用
136 friendService.addFriend0(1L, 2L, null, null); 134 friendService.addFriend0(1L, 2L, null, null);
137 135
138 - // 断言:不插入;走 update 重置 silent / pinned / blocked 为 false,并刷新 addTime 136 + // 断言:不插入也不更新
139 verify(imFriendMapper, never()).insert(any(ImFriendDO.class)); 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 @Test 143 @Test
@@ -157,11 +150,9 @@ public class ImFriendServiceImplTest extends BaseMockitoUnitTest { @@ -157,11 +150,9 @@ public class ImFriendServiceImplTest extends BaseMockitoUnitTest {
157 // 调用 150 // 调用
158 friendService.addFriend0(1L, 2L, null, null); 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 verify(imFriendMapper, never()).insert(any(ImFriendDO.class)); 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,14 +82,12 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest {
82 82
83 ImGroupMemberDO result = groupMemberService.addGroupMember(10L, 1L); 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 assertEquals(CommonStatusEnum.ENABLE.getStatus(), result.getStatus()); 87 assertEquals(CommonStatusEnum.ENABLE.getStatus(), result.getStatus());
92 assertEquals(ImGroupMemberRoleEnum.NORMAL.getRole(), result.getRole()); 88 assertEquals(ImGroupMemberRoleEnum.NORMAL.getRole(), result.getRole());
  89 + assertNull(result.getQuitTime());
  90 + assertNull(result.getMuteEndTime());
93 } 91 }
94 92
95 @Test 93 @Test
@@ -124,9 +122,8 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest { @@ -124,9 +122,8 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest {
124 groupMemberService.addGroupMembers(10L, List.of(2L, 3L, 4L)); 122 groupMemberService.addGroupMembers(10L, List.of(2L, 3L, 4L));
125 123
126 // 断言:updates 只有用户 3;inserts 只有用户 2 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 verify(groupMemberMapper).insertBatch(argThat((List<ImGroupMemberDO> list) -> 127 verify(groupMemberMapper).insertBatch(argThat((List<ImGroupMemberDO> list) ->
131 list.size() == 1 && list.get(0).getUserId().equals(2L))); 128 list.size() == 1 && list.get(0).getUserId().equals(2L)));
132 } 129 }
@@ -145,7 +142,8 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest { @@ -145,7 +142,8 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest {
145 142
146 groupMemberService.addGroupMembers(10L, List.of(2L, 3L)); 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 verify(groupMemberMapper, never()).insertBatch(anyList()); 147 verify(groupMemberMapper, never()).insertBatch(anyList());
150 } 148 }
151 149
@@ -157,7 +155,7 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest { @@ -157,7 +155,7 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest {
157 155
158 groupMemberService.addGroupMembers(10L, List.of(2L, 3L)); 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 verify(groupMemberMapper).insertBatch(anyList()); 159 verify(groupMemberMapper).insertBatch(anyList());
162 } 160 }
163 161
@@ -173,7 +171,7 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest { @@ -173,7 +171,7 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest {
173 171
174 groupMemberService.addGroupMembers(10L, List.of(2L)); 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 verify(groupMemberMapper, never()).insertBatch(anyList()); 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,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 // ========== removeGroupMember ========== 546 // ========== removeGroupMember ==========
529 547
530 @Test 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,6 +11,7 @@ import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
11 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; 11 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
12 import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper; 12 import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper;
13 import cn.iocoder.yudao.module.im.dal.redis.message.ImGroupMessageReadRedisDAO; 13 import cn.iocoder.yudao.module.im.dal.redis.message.ImGroupMessageReadRedisDAO;
  14 +import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum;
14 import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; 15 import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
15 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; 16 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
16 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; 17 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
@@ -588,6 +589,9 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -588,6 +589,9 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
588 .status(ImMessageStatusEnum.UNREAD.getStatus()) 589 .status(ImMessageStatusEnum.UNREAD.getStatus())
589 .sendTime(LocalDateTime.now()).build(); 590 .sendTime(LocalDateTime.now()).build();
590 when(groupMessageMapper.selectById(50L)).thenReturn(message); 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 ServiceException exception = assertThrows(ServiceException.class, 597 ServiceException exception = assertThrows(ServiceException.class,
@@ -603,6 +607,9 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -603,6 +607,9 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
603 .status(ImMessageStatusEnum.RECALL.getStatus()) 607 .status(ImMessageStatusEnum.RECALL.getStatus())
604 .sendTime(LocalDateTime.now()).build(); 608 .sendTime(LocalDateTime.now()).build();
605 when(groupMessageMapper.selectById(50L)).thenReturn(message); 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 ServiceException exception = assertThrows(ServiceException.class, 615 ServiceException exception = assertThrows(ServiceException.class,
@@ -618,6 +625,9 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -618,6 +625,9 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
618 .status(ImMessageStatusEnum.UNREAD.getStatus()) 625 .status(ImMessageStatusEnum.UNREAD.getStatus())
619 .sendTime(LocalDateTime.now().minusMinutes(10)).build(); 626 .sendTime(LocalDateTime.now().minusMinutes(10)).build();
620 when(groupMessageMapper.selectById(50L)).thenReturn(message); 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 ServiceException exception = assertThrows(ServiceException.class, 633 ServiceException exception = assertThrows(ServiceException.class,
@@ -641,12 +651,12 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -641,12 +651,12 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
641 651
642 @Test 652 @Test
643 public void testGetReadUserIds_withVisibleScope() { 653 public void testGetReadUserIds_withVisibleScope() {
644 - // 准备:用户 1 是群成员 654 + // 准备:发送者用户 5 是群成员
645 ImGroupMemberDO currentMember = ImGroupMemberDO.builder() 655 ImGroupMemberDO currentMember = ImGroupMemberDO.builder()
646 - .groupId(10L).userId(1L) 656 + .groupId(10L).userId(5L)
647 .status(CommonStatusEnum.ENABLE.getStatus()) 657 .status(CommonStatusEnum.ENABLE.getStatus())
648 .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(); 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 // 准备:消息由用户 5 发,发送时间在 2026-04-12 10:00 661 // 准备:消息由用户 5 发,发送时间在 2026-04-12 10:00
652 ImGroupMessageDO message = ImGroupMessageDO.builder() 662 ImGroupMessageDO message = ImGroupMessageDO.builder()
@@ -661,6 +671,9 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -661,6 +671,9 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
661 // 用户 5: 发送者,不计入回执 671 // 用户 5: 发送者,不计入回执
662 List<ImGroupMemberDO> allMembers = List.of( 672 List<ImGroupMemberDO> allMembers = List.of(
663 currentMember, 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 ImGroupMemberDO.builder().groupId(10L).userId(2L) 677 ImGroupMemberDO.builder().groupId(10L).userId(2L)
665 .status(CommonStatusEnum.ENABLE.getStatus()) 678 .status(CommonStatusEnum.ENABLE.getStatus())
666 .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(), 679 .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(),
@@ -681,7 +694,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -681,7 +694,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
681 when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions); 694 when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions);
682 695
683 // 调用:查询 messageId=80 的已读用户 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 // 用户 1: 可见 + readMaxId=100>=80 → 已读 ✓ 700 // 用户 1: 可见 + readMaxId=100>=80 → 已读 ✓
@@ -804,6 +817,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -804,6 +817,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
804 .groupId(10L).userId(1L) 817 .groupId(10L).userId(1L)
805 .status(CommonStatusEnum.ENABLE.getStatus()).build(); 818 .status(CommonStatusEnum.ENABLE.getStatus()).build();
806 when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); 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 when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(5L); 822 when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(5L);
808 // readGroupMessageEvent 内部会调 selectListByGroupIdAndPendingReceipt → 返回空简化流程 823 // readGroupMessageEvent 内部会调 selectListByGroupIdAndPendingReceipt → 返回空简化流程
809 when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 5L, 100L)).thenReturn(List.of()); 824 when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 5L, 100L)).thenReturn(List.of());
@@ -876,6 +891,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -876,6 +891,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
876 .groupId(10L).userId(1L) 891 .groupId(10L).userId(1L)
877 .status(CommonStatusEnum.ENABLE.getStatus()).build(); 892 .status(CommonStatusEnum.ENABLE.getStatus()).build();
878 when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); 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 when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(200L); 896 when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(200L);
880 897
881 // 调用 898 // 调用
@@ -886,6 +903,23 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -886,6 +903,23 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
886 verify(imWebSocketService, never()).sendGroupMessageAsync(anyLong(), any(ImGroupMessageDTO.class)); 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 // ========== 回执:DONE 状态迁移 ========== 923 // ========== 回执:DONE 状态迁移 ==========
890 924
891 @Test 925 @Test
@@ -1105,7 +1139,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @@ -1105,7 +1139,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
1105 reqVO.setLimit(20); 1139 reqVO.setLimit(20);
1106 1140
1107 List<ImGroupMessageDO> mockList = List.of( 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 when(groupMessageMapper.selectHistoryList(10L, 100L, 20, joinTime)).thenReturn(mockList); 1145 when(groupMessageMapper.selectHistoryList(10L, 100L, 20, joinTime)).thenReturn(mockList);
1111 1146