diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java index e9a7477..370fdab 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.im.controller.admin.group; import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.im.controller.admin.group.vo.*; @@ -193,13 +194,16 @@ public class ImGroupController { Map messageMap = groupMessageService.getGroupMessageMap(allMessageIds); // 转换输出 return convertList(groups, group -> { - ImGroupRespVO vo = BeanUtils.toBean(group, ImGroupRespVO.class); - if (!activeGroupIds.contains(group.getId()) || CollUtil.isEmpty(group.getPinnedMessageIds())) { - return vo; + ImGroupRespVO groupVO = BeanUtils.toBean(group, ImGroupRespVO.class); + // 标记登录用户在该群的成员状态,供前端区分当前群与历史退群 + boolean joined = activeGroupIds.contains(group.getId()); + groupVO.setJoinStatus(joined ? CommonStatusEnum.ENABLE.getStatus() : CommonStatusEnum.DISABLE.getStatus()); + if (!joined || CollUtil.isEmpty(group.getPinnedMessageIds())) { + return groupVO; } // 按 pin 顺序输出,已被删除的消息(messageMap 没命中)跳过 List pinnedMesages = convertList(group.getPinnedMessageIds(), messageMap::get); - return vo.setPinnedMessages(BeanUtils.toBean(pinnedMesages, ImGroupMessageRespVO.class)); + return groupVO.setPinnedMessages(BeanUtils.toBean(pinnedMesages, ImGroupMessageRespVO.class)); }); } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java index 2dac146..5f1ec5d 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java @@ -47,7 +47,10 @@ public class ImGroupRespVO { @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) private LocalDateTime createTime; + @Schema(description = "当前登录用户在该群的成员状态:在群 / 已退群(历史退群群仍返回,供前端展示离线消息的群名 / 头像)") + private Integer joinStatus; // 参见 CommonStatusEnum 枚举 + @Schema(description = "群置顶消息列表,按 pin 顺序(最先置顶的在前);非该群有效成员时为空") private List pinnedMessages; -} \ No newline at end of file +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java index bfdefc9..2ed32cd 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java @@ -357,8 +357,8 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { throw exception(MESSAGE_GROUP_READ_DISABLED); } Assert.notNull(messageId, "已读消息编号不能为空"); - // 1.1 校验用户在群中(权限校验) - ImGroupMemberDO member = groupMemberService.validateMemberInGroup(groupId, userId); + // 1.1 获取群成员记录 + ImGroupMemberDO member = groupMemberService.getGroupMember(groupId, userId); // 1.2 校验消息属于当前群,且对当前用户可见 ImGroupMessageDO message = groupMessageMapper.selectById(messageId); if (message == null diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java index 5ccce80..dc5aba0 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java @@ -640,15 +640,17 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { // ========== 群已读测试 ========== @Test - public void testReadMessages_notInGroup() { - // 准备:当前用户不在群中 - when(groupMemberService.validateMemberInGroup(10L, 1L)) - .thenThrow(new ServiceException(GROUP_MEMBER_NOT_IN_GROUP.getCode(), GROUP_MEMBER_NOT_IN_GROUP.getMsg())); + public void testReadMessages_messageInvisible() { + // 准备:用户不是群成员 + when(groupMemberService.getGroupMember(10L, 1L)).thenReturn(null); + when(groupMessageMapper.selectById(100L)).thenReturn(ImGroupMessageDO.builder() + .id(100L).groupId(10L).senderId(2L).sendTime(LocalDateTime.now()).build()); - // 调用并断言:越权校验 + // 调用并断言 ServiceException exception = assertThrows(ServiceException.class, () -> groupMessageService.readGroupMessages(1L, 10L, 100L)); - assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); + assertEquals(MESSAGE_NOT_IN_GROUP.getCode(), exception.getCode()); + verify(groupMessageReadRedisDAO, never()).updateReadMaxMessageId(anyLong(), anyLong(), anyLong()); } @Test @@ -817,8 +819,9 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { // 准备:用户在群;Redis 游标从 5 前进到 100 ImGroupMemberDO member = ImGroupMemberDO.builder() .groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.now().minusDays(1)).build(); + when(groupMemberService.getGroupMember(10L, 1L)).thenReturn(member); when(groupMessageMapper.selectById(100L)).thenReturn(ImGroupMessageDO.builder() .id(100L).groupId(10L).senderId(2L).sendTime(LocalDateTime.now()).build()); when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(5L); @@ -891,8 +894,9 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { // 准备:已读游标已 >= 目标,直接返回 ImGroupMemberDO member = ImGroupMemberDO.builder() .groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.now().minusDays(1)).build(); + when(groupMemberService.getGroupMember(10L, 1L)).thenReturn(member); when(groupMessageMapper.selectById(100L)).thenReturn(ImGroupMessageDO.builder() .id(100L).groupId(10L).senderId(2L).sendTime(LocalDateTime.now()).build()); when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(200L); @@ -910,8 +914,9 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { // 准备:用户在群,但 messageId 属于其它群 ImGroupMemberDO member = ImGroupMemberDO.builder() .groupId(10L).userId(1L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(); - when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.now().minusDays(1)).build(); + when(groupMemberService.getGroupMember(10L, 1L)).thenReturn(member); when(groupMessageMapper.selectById(100L)).thenReturn(ImGroupMessageDO.builder() .id(100L).groupId(20L).senderId(2L).sendTime(LocalDateTime.now()).build()); @@ -922,6 +927,54 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { verify(groupMessageReadRedisDAO, never()).updateReadMaxMessageId(anyLong(), anyLong(), anyLong()); } + @Test + public void testReadGroupMessages_quitMemberVisibleMessage_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) + .thenReturn(groupMessageService); + + LocalDateTime now = LocalDateTime.now(); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.DISABLE.getStatus()) + .joinTime(now.minusDays(1)) + .quitTime(now.plusMinutes(1)).build(); + when(groupMemberService.getGroupMember(10L, 1L)).thenReturn(member); + when(groupMessageMapper.selectById(100L)).thenReturn(ImGroupMessageDO.builder() + .id(100L).groupId(10L).senderId(2L).sendTime(now) + .receiverUserIds(ListUtil.of(1L, 2L)).build()); + when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(0L); + when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 0L, 100L)).thenReturn(ListUtil.of()); + + // 调用 + groupMessageService.readGroupMessages(1L, 10L, 100L); + + // 断言:不校验当前在群,直接推进已读位置 + verify(groupMemberService, never()).validateMemberInGroup(10L, 1L); + verify(groupMessageReadRedisDAO).updateReadMaxMessageId(10L, 1L, 100L); + } + } + + @Test + public void testReadGroupMessages_quitMemberInvisibleMessage() { + LocalDateTime now = LocalDateTime.now(); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.DISABLE.getStatus()) + .joinTime(now.minusDays(2)) + .quitTime(now.minusDays(1)).build(); + when(groupMemberService.getGroupMember(10L, 1L)).thenReturn(member); + when(groupMessageMapper.selectById(100L)).thenReturn(ImGroupMessageDO.builder() + .id(100L).groupId(10L).senderId(2L).sendTime(now) + .receiverUserIds(ListUtil.of(1L, 2L)).build()); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.readGroupMessages(1L, 10L, 100L)); + assertEquals(MESSAGE_NOT_IN_GROUP.getCode(), exception.getCode()); + verify(groupMessageReadRedisDAO, never()).updateReadMaxMessageId(anyLong(), anyLong(), anyLong()); + } + // ========== 回执:DONE 状态迁移 ========== @Test