Commit bb9a2b5382ae65e3bc92a7e4d93f620c24975b99
1 parent
513c1301
fix(im):加固好友、群成员与群消息边界流程
- 清理好友重加时的 deleteTime 和历史备注残留 - 清理群成员重入时的 quitTime、muteEndTime 和邀请来源残留 - 允许封禁群成员主动退群,仍拦截已解散群 - 校验群已读游标的消息归属和可见性 - 收窄群消息置顶通知为专用展示对象并同步前端展示 - 回填群成员单查接口的昵称和头像 - 补充相关回归测试并流转 IM bug 状态文档
Showing
13 changed files
with
199 additions
and
68 deletions
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<ImGroupMemberDO> { |
| 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 | ... | ... |