From 6a10ed7d88126438d12b2fe5967a6e43b90c7a26 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Thu, 23 Apr 2026 18:43:53 +0800 Subject: [PATCH] ✨ feat(im): 更新群组和群成员相关接口,优化成员查询逻辑 --- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java | 2 +- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupMemberController.java | 15 ++++++++++++++- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java | 20 -------------------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java | 10 ++++++++++ yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java | 12 ++++++++++++ yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java | 27 +++++++++++++++++++++++++++ yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java | 7 ++++--- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java | 16 ++++++++-------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java | 50 +++++++++++++++++++++++++++++++++++++++++++++----- yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapperTest.java | 68 -------------------------------------------------------------------- yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java | 15 --------------- yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java | 23 ----------------------- yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------- yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java | 379 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------- yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java | 4 ++-- yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImplTest.java | 18 ++++++++++++++++++ 18 files changed, 752 insertions(+), 173 deletions(-) diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java index 15cf5b8..4867775 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java @@ -73,7 +73,7 @@ public class ImGroupController { @GetMapping("/list") @Operation(summary = "获得当前登录用户的群列表") public CommonResult> getMyGroupList() { - List groups = groupService.getActiveGroupList(getLoginUserId()); + List groups = groupService.getMyGroupList(getLoginUserId()); return success(BeanUtils.toBean(groups, ImGroupRespVO.class)); } diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupMemberController.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupMemberController.java index 6c66369..c967166 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupMemberController.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupMemberController.java @@ -1,5 +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.collection.MapUtils; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; @@ -21,9 +23,11 @@ import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.GROUP_MEMBER_NOT_IN_GROUP; @Tag(name = "管理后台 - 群成员") @RestController @@ -58,8 +62,17 @@ public class ImGroupMemberController { @Operation(summary = "获得指定群的成员列表") @Parameter(name = "groupId", description = "群编号", required = true, example = "1024") public CommonResult> getGroupMemberList(@RequestParam("groupId") Long groupId) { + // 1.1 查询群成员列表(包含 DISABLE 已退群的成员,不按时间过滤) + // 说明:保留已退群成员,是为了前端展示历史消息时,仍能通过该接口拿到已退群成员的昵称 / 头像信息,避免显示为空 List members = groupMemberService.getGroupMemberListByGroupId(groupId); - // 批量聚合 AdminUser 信息(昵称 / 头像) + // 1.2 校验当前登录用户是否为群的有效成员,非成员不可查看 + Long loginUserId = getLoginUserId(); + if (CollUtil.findOne(members, m -> loginUserId.equals(m.getUserId()) + && CommonStatusEnum.ENABLE.getStatus().equals(m.getStatus())) == null) { + throw exception(GROUP_MEMBER_NOT_IN_GROUP); + } + + // 2.批量聚合 AdminUser 信息(昵称 / 头像) Map userMap = adminUserApi.getUserMap( convertList(members, ImGroupMemberDO::getUserId)); return success(convertList(members, m -> { diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java index 94c831e..8861658 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java @@ -1,13 +1,9 @@ package cn.iocoder.yudao.module.im.dal.mysql.group; import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; -import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; import org.apache.ibatis.annotations.Mapper; -import java.util.Collection; -import java.util.List; - /** * IM 群 Mapper * @@ -15,20 +11,4 @@ import java.util.List; */ @Mapper public interface ImGroupMapper extends BaseMapperX { - - /** - * 根据群编号批量查询群,支持按状态和封禁过滤 - * - * @param ids 群编号集合 - * @param status 群状态(可空,空则不过滤) - * @param banned 是否封禁(可空,空则不过滤) - * @return 群列表 - */ - default List selectListByIds(Collection ids, Integer status, Boolean banned) { - return selectList(new LambdaQueryWrapperX() - .in(ImGroupDO::getId, ids) - .eqIfPresent(ImGroupDO::getStatus, status) - .eqIfPresent(ImGroupDO::getBanned, banned)); - } - } \ No newline at end of file diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java index 445d7fc..d2d7149 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java @@ -31,4 +31,14 @@ public interface RedisKeyConstants { */ String GROUP = "group"; + /** + * 群有效成员 userId 列表缓存(仅 ENABLE 状态) + *

+ * KEY 格式:group_member_ids:{groupId} + * VALUE 数据类型:List + *

+ * 说明:只缓存轻量的 userId 列表,适合"群消息推送目标"这类只关心 userId 的场景。 + */ + String GROUP_MEMBER_IDS = "group_member_ids"; + } diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java index 9470fba..fbd4bb7 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java @@ -40,6 +40,18 @@ public interface ImGroupMemberService { List getActiveGroupMemberListByGroupId(Long groupId); /** + * 根据群编号查询有效成员的 userId 列表(仅 ENABLE 状态) + *

+ * 相比 {@link #getActiveGroupMemberListByGroupId(Long)},只返回 userId,结果体积小,并带有 Redis 缓存。 + * 适用于"群消息推送目标"等只需要 userId 的场景; + * 需要 joinTime/quitTime/status 等字段做历史消息可见性判断时,仍应使用完整列表方法。 + * + * @param groupId 群编号 + * @return 有效群成员 userId 列表 + */ + List getActiveGroupMemberUserIdsByGroupId(Long groupId); + + /** * 查询用户所在的所有群的有效成员记录(仅 ENABLE 状态) * * @param userId 用户编号 diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java index ce17112..0b92f0d 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java @@ -10,6 +10,8 @@ import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; @@ -21,7 +23,9 @@ import java.util.List; import java.util.Map; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP_MEMBER_IDS; import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.GROUP_MEMBER_NOT_IN_GROUP; /** @@ -55,6 +59,24 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { return groupMemberMapper.selectListByGroupIdAndStatus(groupId, CommonStatusEnum.ENABLE.getStatus()); } + /** + * 只缓存 userId 列表而非整个 {@link ImGroupMemberDO},理由: + *

    + *
  • 体积小:500 人群约 4KB,失效/序列化成本低;
  • + *
  • 失效面窄:仅 {@link #addGroupMember}/{@link #addGroupMembers}/ + * {@link #removeGroupMember}/{@link #removeGroupMembers}/{@link #removeGroupMembersByGroupId} + * 这类影响成员集合的写操作需要失效; + * {@link #updateGroupMember}(昵称/备注/免打扰)不修改集合成员,不需要失效。
  • + *
+ */ + @Override + @Cacheable(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") + public List getActiveGroupMemberUserIdsByGroupId(Long groupId) { + List members = groupMemberMapper.selectListByGroupIdAndStatus( + groupId, CommonStatusEnum.ENABLE.getStatus()); + return convertList(members, ImGroupMemberDO::getUserId); + } + @Override public List getActiveGroupMemberListByUserId(Long userId) { return groupMemberMapper.selectListByUserIdAndStatus(userId, CommonStatusEnum.ENABLE.getStatus()); @@ -70,6 +92,7 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { * 当并发 insert 触发 {@link DuplicateKeyException} 时降级为 select + update。 */ @Override + @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") public ImGroupMemberDO addGroupMember(Long groupId, Long userId) { LocalDateTime now = LocalDateTime.now(); // 情况一:已存在记录 → 复活或跳过 @@ -96,6 +119,7 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { } @Override + @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") public void addGroupMembers(Long groupId, Collection userIds) { LocalDateTime now = LocalDateTime.now(); // 1.1 查询已有记录(含已退群的 DISABLE 记录) @@ -158,6 +182,7 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { } @Override + @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") public void removeGroupMember(Long groupId, Long userId) { // 1. 校验是群的有效成员 ImGroupMemberDO member = validateMemberInGroup(groupId, userId); @@ -168,6 +193,7 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { } @Override + @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") public void removeGroupMembers(Long groupId, Collection userIds) { groupMemberMapper.updateByGroupIdAndUserIdsAndStatus(groupId, userIds, CommonStatusEnum.ENABLE.getStatus(), @@ -176,6 +202,7 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { } @Override + @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") public void removeGroupMembersByGroupId(Long groupId) { groupMemberMapper.updateByGroupIdAndStatus(groupId, CommonStatusEnum.ENABLE.getStatus(), new ImGroupMemberDO().setStatus(CommonStatusEnum.DISABLE.getStatus()) diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java index 5f3b15b..b38991e 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java @@ -77,14 +77,15 @@ public interface ImGroupService { ImGroupDO validateGroupOwner(Long groupId, Long userId); /** - * 获取指定用户加入的所有"有效"群(未退群且未封禁/解散) + * 获取指定用户的群列表 *

- * 返回当前登录用户在群内身份仍有效的所有群。 + * 返回用户当前仍有效的群,以及最近 {@link cn.iocoder.yudao.module.im.enums.ImCommonConstants#MESSAGE_GROUP_PULL_MAX_DAYS} + * 天内退群的群 —— 退群前可能还有离线消息需要展示,前端需要把这些群信息作为缓存。 * * @param userId 用户编号 * @return 群列表 */ - List getActiveGroupList(Long userId); + List getMyGroupList(Long userId); // ==================== 群成员的写操作 ==================== // 说明:群成员的写操作统一放在 ImGroupService,而非 ImGroupMemberService, diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java index 1870a9e..3801f4b 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java @@ -256,19 +256,19 @@ public class ImGroupServiceImpl implements ImGroupService { } @Override - public List getActiveGroupList(Long userId) { - // 1. 查用户所在的、仍有效的群成员记录(仅 ENABLE 状态) + public List getMyGroupList(Long userId) { + // 1.1 查用户所在的、仍有效的群成员记录(仅 ENABLE 状态) List members = groupMemberService.getActiveGroupMemberListByUserId(userId); + // 1.2 再查最近 MESSAGE_GROUP_PULL_MAX_DAYS 天内退群的成员记录(退群前可能有离线消息需要展示,一并返回作为前端缓存) + LocalDateTime minQuitTime = LocalDateTime.now().minusDays(MESSAGE_GROUP_PULL_MAX_DAYS); + members.addAll(groupMemberService.getQuitGroupMemberListByUserId(userId, minQuitTime)); if (CollUtil.isEmpty(members)) { return Collections.emptyList(); } - Set groupIds = convertSet(members, ImGroupMemberDO::getGroupId); - if (CollUtil.isEmpty(groupIds)) { - return Collections.emptyList(); - } - // 2. 批量查询群信息(仅 ENABLE 状态) - return groupMapper.selectListByIds(groupIds, CommonStatusEnum.ENABLE.getStatus(), false); + // 2. 批量查询群信息(不按 status / banned 过滤,已解散 / 封禁的群也要返回,供前端展示历史消息的群名 / 头像) + Set groupIds = convertSet(members, ImGroupMemberDO::getGroupId); + return groupMapper.selectByIds(groupIds); } private ImGroupServiceImpl getSelf() { diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java index 0a02bc0..8a932ff 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java @@ -361,12 +361,23 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { /** * 发送群聊消息 WebSocket 事件 + *

+ * 这里仅用于"新消息"(首发 or 撤回提示),{@code message.sendTime = now}: + *

    + *
  • 所有 ENABLE 成员的 {@code joinTime <= now},{@link #isMessageVisible} 的 joinTime 分支恒为真;
  • + *
  • ENABLE 成员 status 非 DISABLE,quitTime 分支不适用;
  • + *
+ * 因此只剩 {@code receiverUserIds} 定向过滤会真正起效。 + * 故使用只含 userId 的轻量缓存 {@link ImGroupMemberService#getActiveGroupMemberUserIdsByGroupId(Long)}, + * 在本地以 {@link #getVisibleUserIds} 完成定向过滤即可。 + *

+ * 注意:对历史消息的可见性判断仍需 joinTime/quitTime,见 {@code readGroupMessageEvent} 等处。 */ public void sendGroupMessageEvent(ImGroupMessageDO message) { ImGroupMessageDTO dto = ImGroupMessageDTO.ofSend(message); // 广播给群内有效成员中的可见用户(含发送方自己,支持多端同步;定向消息自动过滤) - List members = groupMemberService.getActiveGroupMemberListByGroupId(message.getGroupId()); - Set targetUserIds = getVisibleUserIds(message, members); + List memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(message.getGroupId()); + Set targetUserIds = getVisibleUserIds(message, memberUserIds); imWebSocketService.sendGroupMessageAsync(targetUserIds, dto); } @@ -466,9 +477,38 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { /** * 计算一条群消息的可见成员集合(含发送者) */ - private Set getVisibleUserIds(ImGroupMessageDO msg, List allMembers) { - return convertSet(allMembers, ImGroupMemberDO::getUserId, - member -> isMessageVisible(msg, member, member.getUserId())); + private Set getVisibleUserIds(ImGroupMessageDO message, List members) { + return convertSet(members, ImGroupMemberDO::getUserId, + member -> isMessageVisible(message, member, member.getUserId())); + } + + /** + * 基于群成员 userId 列表,过滤出一条消息的可见成员集合(含发送者)。 + *

+ * 仅适用于"新消息"推送场景({@code sendTime = now}),不涉及 joinTime/quitTime 判定, + * 只应用 {@code receiverUserIds} 定向过滤;语义与 + * {@link #isMessageVisible(ImGroupMessageDO, ImGroupMemberDO, Long)} 的第 3 步保持一致。 + */ + private Set getVisibleUserIds(ImGroupMessageDO message, Collection memberUserIds) { + if (CollUtil.isEmpty(memberUserIds)) { + return new HashSet<>(); + } + // 无定向接收列表 → 全员可见 + if (CollUtil.isEmpty(message.getReceiverUserIds())) { + return new HashSet<>(memberUserIds); + } + // 有定向接收列表 → 仅定向用户可见;发送者自己也能看到自己的消息(多端同步) + Set allowed = new HashSet<>(message.getReceiverUserIds()); + if (message.getSenderId() != null) { + allowed.add(message.getSenderId()); + } + Set result = new HashSet<>(); + for (Long userId : memberUserIds) { + if (allowed.contains(userId)) { + result.add(userId); + } + } + return result; } @Override diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapperTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapperTest.java index 155b4b8..2ce7dec 100644 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapperTest.java +++ b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapperTest.java @@ -21,74 +21,6 @@ public class ImGroupMemberMapperTest extends BaseDbUnitTest { @Resource private ImGroupMemberMapper mapper; - // ========== selectListByGroupIdAndUserIds ========== - - @Test - public void testSelectListByGroupIdAndUserIds() { - // 准备:同一群 3 个成员 + 另一群 1 个成员 - ImGroupMemberDO u1 = buildMember(10L, 1L, CommonStatusEnum.ENABLE); - mapper.insert(u1); - ImGroupMemberDO u2 = buildMember(10L, 2L, CommonStatusEnum.ENABLE); - mapper.insert(u2); - ImGroupMemberDO u3 = buildMember(10L, 3L, CommonStatusEnum.DISABLE); - mapper.insert(u3); - // 不相关的另一个群 - mapper.insert(buildMember(20L, 1L, CommonStatusEnum.ENABLE)); - - List result = mapper.selectListByGroupIdAndUserIds(10L, List.of(1L, 2L, 3L)); - assertEquals(3, result.size()); - } - - @Test - public void testSelectListByGroupIdAndUserIds_mixedUserIds() { - mapper.insert(buildMember(10L, 1L, CommonStatusEnum.ENABLE)); - ImGroupMemberDO u2 = buildMember(10L, 2L, CommonStatusEnum.ENABLE); - mapper.insert(u2); - - // 查询不存在的用户 99,返回为空 - List result = mapper.selectListByGroupIdAndUserIds(10L, List.of(99L)); - assertTrue(result.isEmpty()); - - // 查询包含存在和不存在混合,只返回存在的 - List result2 = mapper.selectListByGroupIdAndUserIds(10L, List.of(2L, 99L)); - assertEquals(1, result2.size()); - assertEquals(u2.getId(), result2.get(0).getId()); - } - - // ========== selectListByGroupIdAndStatus ========== - - @Test - public void testSelectListByGroupIdAndStatus_onlyMatchingStatus() { - mapper.insert(buildMember(10L, 1L, CommonStatusEnum.ENABLE)); - mapper.insert(buildMember(10L, 2L, CommonStatusEnum.ENABLE)); - mapper.insert(buildMember(10L, 3L, CommonStatusEnum.DISABLE)); - - List active = mapper.selectListByGroupIdAndStatus( - 10L, CommonStatusEnum.ENABLE.getStatus()); - assertEquals(2, active.size()); - - List disabled = mapper.selectListByGroupIdAndStatus( - 10L, CommonStatusEnum.DISABLE.getStatus()); - assertEquals(1, disabled.size()); - } - - // ========== selectListByUserIdAndStatus ========== - - @Test - public void testSelectListByUserIdAndStatus() { - // 用户 1 在群 10、20 都有记录;在群 30 已退群 - mapper.insert(buildMember(10L, 1L, CommonStatusEnum.ENABLE)); - mapper.insert(buildMember(20L, 1L, CommonStatusEnum.ENABLE)); - mapper.insert(buildMember(30L, 1L, CommonStatusEnum.DISABLE)); - // 另一个用户的记录 - mapper.insert(buildMember(10L, 2L, CommonStatusEnum.ENABLE)); - - List active = mapper.selectListByUserIdAndStatus( - 1L, CommonStatusEnum.ENABLE.getStatus()); - assertEquals(2, active.size()); - assertTrue(active.stream().allMatch(m -> m.getUserId().equals(1L))); - } - // ========== selectQuitListByUserId ========== @Test diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java index 4e075ae..c14b761 100644 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java +++ b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java @@ -211,21 +211,6 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { assertEquals(after.getId(), result.get(0).getId()); } - // ========== selectBySenderIdAndClientMessageId ========== - - @Test - public void testSelectBySenderIdAndClientMessageId() { - ImGroupMessageDO msg = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); - msg.setClientMessageId("group-uuid-001"); - mapper.insert(msg); - - ImGroupMessageDO result = mapper.selectBySenderIdAndClientMessageId(1L, "group-uuid-001"); - assertNotNull(result); - assertEquals(msg.getId(), result.getId()); - - assertNull(mapper.selectBySenderIdAndClientMessageId(1L, "not-exist")); - } - // ========== selectListByGroupIdAndPendingReceipt ========== @Test diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java index c00ebe8..f49d18c 100644 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java +++ b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java @@ -181,29 +181,6 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest { assertEquals(2L, result.get(0).getSenderId()); } - // ========== selectBySenderIdAndClientMessageId ========== - - @Test - public void testSelectBySenderIdAndClientMessageId() { - // 准备 - ImPrivateMessageDO msg = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); - msg.setClientMessageId("uuid-001"); - mapper.insert(msg); - - // 调用 - ImPrivateMessageDO result = mapper.selectBySenderIdAndClientMessageId(1L, "uuid-001"); - - // 断言 - assertNotNull(result); - assertEquals(msg.getId(), result.getId()); - } - - @Test - public void testSelectBySenderIdAndClientMessageId_notFound() { - ImPrivateMessageDO result = mapper.selectBySenderIdAndClientMessageId(1L, "not-exist"); - assertNull(result); - } - // ========== 工具方法 ========== private ImPrivateMessageDO buildMessage(Long senderId, Long receiverId, ImMessageStatusEnum status) { diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java index 185aaa8..37a7250 100644 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java +++ b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java @@ -130,6 +130,52 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest { } @Test + public void testAddGroupMembers_allExisting_onlyUpdates() { + // 准备:传入的 3 个用户都已有记录(全部 DISABLE) → 只做 update,不做 insert + List existing = List.of( + ImGroupMemberDO.builder().id(1L).groupId(10L).userId(2L) + .status(CommonStatusEnum.DISABLE.getStatus()).build(), + ImGroupMemberDO.builder().id(2L).groupId(10L).userId(3L) + .status(CommonStatusEnum.DISABLE.getStatus()).build() + ); + when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) + .thenReturn(existing); + + groupMemberService.addGroupMembers(10L, List.of(2L, 3L)); + + verify(groupMemberMapper).updateBatch(anyList()); + verify(groupMemberMapper, never()).insertBatch(anyList()); + } + + @Test + public void testAddGroupMembers_allNew_onlyInserts() { + // 准备:都不存在 → 只做 insert + when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) + .thenReturn(List.of()); + + groupMemberService.addGroupMembers(10L, List.of(2L, 3L)); + + verify(groupMemberMapper, never()).updateBatch(anyList()); + verify(groupMemberMapper).insertBatch(anyList()); + } + + @Test + public void testAddGroupMembers_allExistingEnabled_nothingHappens() { + // 准备:都已存在且 ENABLE → 既不 update 也不 insert + List existing = List.of( + ImGroupMemberDO.builder().id(1L).groupId(10L).userId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build() + ); + when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) + .thenReturn(existing); + + groupMemberService.addGroupMembers(10L, List.of(2L)); + + verify(groupMemberMapper, never()).updateBatch(anyList()); + verify(groupMemberMapper, never()).insertBatch(anyList()); + } + + @Test public void testAddGroupMembers_batchInsertDuplicateFallback() { // 准备:两个新增成员,批量插入失败时降级为逐个 addGroupMember when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) @@ -248,4 +294,37 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest { assertNotNull(captor.getValue().getQuitTime()); } + // ========== getActiveGroupMemberUserIdsByGroupId ========== + + @Test + public void testGetActiveGroupMemberUserIdsByGroupId_extractsUserIds() { + // 准备:3 个 ENABLE 成员 + List members = List.of( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupMemberDO.builder().groupId(10L).userId(3L) + .status(CommonStatusEnum.ENABLE.getStatus()).build() + ); + when(groupMemberMapper.selectListByGroupIdAndStatus( + 10L, CommonStatusEnum.ENABLE.getStatus())).thenReturn(members); + + // 调用 + List userIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L); + + // 断言:只返回 userId、顺序保留 + assertEquals(List.of(1L, 2L, 3L), userIds); + } + + @Test + public void testGetActiveGroupMemberUserIdsByGroupId_emptyList() { + when(groupMemberMapper.selectListByGroupIdAndStatus( + 10L, CommonStatusEnum.ENABLE.getStatus())).thenReturn(List.of()); + + List userIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L); + + assertTrue(userIds.isEmpty()); + } + } diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java index ae6150d..a68b897 100644 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java +++ b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java @@ -279,6 +279,43 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { } @Test + public void testInviteGroupMember_memberExceed() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + // 准备:群 10 已有 499 人(逼近 MAX_GROUP_MEMBER=500),再邀请 2 人 → 501 超限 + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + List activeMembers = new ArrayList<>(); + for (long i = 1; i <= 499; i++) { + activeMembers.add(ImGroupMemberDO.builder().groupId(10L).userId(i) + .status(CommonStatusEnum.ENABLE.getStatus()).build()); + } + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(activeMembers); + + ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); + reqVO.setGroupId(10L); + reqVO.setMemberUserIds(new ArrayList<>(List.of(600L, 601L))); + // 被邀请人都是好友 + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of( + ImFriendDO.builder().userId(1L).friendUserId(600L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImFriendDO.builder().userId(1L).friendUserId(601L) + .status(CommonStatusEnum.ENABLE.getStatus()).build() + )); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupService.inviteGroupMember(1L, reqVO)); + assertEquals(GROUP_MEMBER_EXCEED.getCode(), exception.getCode()); + // 断言:不加成员、不推送 + verify(groupMemberService, never()).addGroupMembers(anyLong(), anyCollection()); + } + } + + @Test public void testInviteGroupMember_skipsMembersAlreadyInGroup() { try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) @@ -439,34 +476,43 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { } } - // ========== getActiveGroupList ========== + // ========== getMyGroupList ========== @Test - public void testGetActiveGroupList_noMembers() { - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of()); + public void testGetMyGroupList_noMembers() { + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>()); + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(new ArrayList<>()); - List result = groupService.getActiveGroupList(1L); + List result = groupService.getMyGroupList(1L); assertTrue(result.isEmpty()); - verify(groupMapper, never()).selectListByIds(anyCollection(), any(), any()); + verify(groupMapper, never()).selectByIds(anyCollection()); } @Test - public void testGetActiveGroupList_success() { - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of( + public void testGetMyGroupList_success() { + // 活跃群成员 + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>(List.of( ImGroupMemberDO.builder().groupId(10L).userId(1L) .status(CommonStatusEnum.ENABLE.getStatus()).build(), ImGroupMemberDO.builder().groupId(20L).userId(1L) .status(CommonStatusEnum.ENABLE.getStatus()).build() - )); + ))); + // 最近退群成员(最近 30 天内) + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) + .thenReturn(new ArrayList<>(List.of( + ImGroupMemberDO.builder().groupId(30L).userId(1L) + .status(CommonStatusEnum.DISABLE.getStatus()).build() + ))); List groups = List.of( ImGroupDO.builder().id(10L).status(CommonStatusEnum.ENABLE.getStatus()).build(), - ImGroupDO.builder().id(20L).status(CommonStatusEnum.ENABLE.getStatus()).build() + ImGroupDO.builder().id(20L).status(CommonStatusEnum.ENABLE.getStatus()).build(), + ImGroupDO.builder().id(30L).status(CommonStatusEnum.ENABLE.getStatus()).build() ); - when(groupMapper.selectListByIds(anyCollection(), eq(CommonStatusEnum.ENABLE.getStatus()), eq(false))) - .thenReturn(groups); + when(groupMapper.selectByIds(anyCollection())).thenReturn(groups); - List result = groupService.getActiveGroupList(1L); - assertEquals(2, result.size()); + List result = groupService.getMyGroupList(1L); + assertEquals(3, result.size()); } } diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java index d538594..9fa3d9d 100644 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java +++ b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java @@ -4,6 +4,7 @@ import cn.hutool.extra.spring.SpringUtil; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageListReqVO; import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; @@ -12,12 +13,14 @@ import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper; import cn.iocoder.yudao.module.im.dal.redis.message.GroupMessageReadRedisDAO; import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; import cn.iocoder.yudao.module.im.service.group.ImGroupService; import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockedStatic; @@ -27,6 +30,7 @@ import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; import static org.junit.jupiter.api.Assertions.*; @@ -86,14 +90,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); - List allMembers = List.of( - member, - ImGroupMemberDO.builder().groupId(10L).userId(2L) - .status(CommonStatusEnum.ENABLE.getStatus()).build(), - ImGroupMemberDO.builder().groupId(10L).userId(3L) - .status(CommonStatusEnum.ENABLE.getStatus()).build() - ); - when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(allMembers); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) + .thenReturn(List.of(1L, 2L, 3L)); when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenAnswer(invocation -> { ImGroupMessageDO msg = invocation.getArgument(0); msg.setId(99L); @@ -560,12 +558,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { when(groupMessageMapper.updateById(any(ImGroupMessageDO.class))).thenReturn(1); when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenReturn(1); - List members = List.of( - selfMember, - ImGroupMemberDO.builder().groupId(10L).userId(2L) - .status(CommonStatusEnum.ENABLE.getStatus()).build() - ); - when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(members); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) + .thenReturn(List.of(1L, 2L)); // 调用 ImGroupMessageDO result = groupMessageService.recallGroupMessage(1L, 50L); @@ -709,4 +703,361 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); } + // ========== 发送:补充边界 ========== + + @Test + public void testSendMessage_receiptPending() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) + .thenReturn(groupMessageService); + + // 准备:reqVO.receipt=true + ImGroupMessageSendReqVO reqVO = buildSendReqVO(); + reqVO.setReceipt(true); + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) + .thenReturn(null); + ImGroupDO group = new ImGroupDO(); + group.setId(10L); + when(groupService.validateGroupExists(10L)).thenReturn(group); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L)); + when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenReturn(1); + + // 调用 + ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO); + + // 断言:receipt=true → 回执状态为 PENDING + assertEquals(ImGroupMessageReceiptStatusEnum.PENDING.getStatus(), result.getReceiptStatus()); + } + } + + @Test + public void testSendMessage_sensitiveWordBlocked() { + // 准备:文本消息命中敏感词 + ImGroupMessageSendReqVO reqVO = buildSendReqVO(); + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) + .thenReturn(null); + ImGroupDO group = new ImGroupDO(); + group.setId(10L); + when(groupService.validateGroupExists(10L)).thenReturn(group); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build()); + doThrow(new ServiceException(MESSAGE_SENSITIVE_WORD_BLOCKED)) + .when(sensitiveWordService).validateText(reqVO.getContent()); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.sendGroupMessage(1L, reqVO)); + assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode()); + // 断言:不入库、不推送 + verify(groupMessageMapper, never()).insert(any(ImGroupMessageDO.class)); + verify(imWebSocketService, never()).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); + } + + // ========== 撤回:补充边界 ========== + + @Test + public void testRecallMessage_notExists() { + when(groupMessageMapper.selectById(50L)).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.recallGroupMessage(1L, 50L)); + assertEquals(MESSAGE_NOT_EXISTS.getCode(), exception.getCode()); + } + + @Test + public void testRecallMessage_senderNotInGroup() { + // 准备:消息合法、可撤回时间内,但发送人已退群 + ImGroupMessageDO message = ImGroupMessageDO.builder() + .id(50L).senderId(1L).groupId(10L) + .status(ImMessageStatusEnum.UNREAD.getStatus()) + .sendTime(LocalDateTime.now()).build(); + when(groupMessageMapper.selectById(50L)).thenReturn(message); + when(groupMemberService.validateMemberInGroup(10L, 1L)) + .thenThrow(new ServiceException(GROUP_MEMBER_NOT_IN_GROUP.getCode(), GROUP_MEMBER_NOT_IN_GROUP.getMsg())); + + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.recallGroupMessage(1L, 50L)); + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); + // 断言:不执行更新、不插 tip + verify(groupMessageMapper, never()).updateById(any(ImGroupMessageDO.class)); + verify(groupMessageMapper, never()).insert(any(ImGroupMessageDO.class)); + } + + // ========== 已读:happy path + 短路 ========== + + @Test + public void testReadGroupMessages_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) + .thenReturn(groupMessageService); + + // 准备:用户在群;Redis 游标从 5 前进到 100 + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); + when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(5L); + // readGroupMessageEvent 内部会调 selectListByGroupIdAndPendingReceipt → 返回空简化流程 + when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 5L, 100L)).thenReturn(List.of()); + + // 调用 + groupMessageService.readGroupMessages(1L, 10L, 100L); + + // 断言:Redis 游标更新 + READ 事件 + verify(groupMessageReadRedisDAO).updateReadMaxMessageId(10L, 1L, 100L); + verify(imWebSocketService).sendGroupMessageAsync(eq(1L), any(ImGroupMessageDTO.class)); + } + } + + @Test + public void testReadGroupMessages_cursorAlreadyAhead() { + // 准备:已读游标已 >= 目标,直接短路返回 + ImGroupMemberDO member = ImGroupMemberDO.builder() + .groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); + when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(200L); + + // 调用 + groupMessageService.readGroupMessages(1L, 10L, 100L); + + // 断言:不更新、不推送 + verify(groupMessageReadRedisDAO, never()).updateReadMaxMessageId(anyLong(), anyLong(), anyLong()); + verify(imWebSocketService, never()).sendGroupMessageAsync(anyLong(), any(ImGroupMessageDTO.class)); + } + + // ========== 回执:DONE 状态迁移 ========== + + @Test + public void testReadGroupMessageEvent_receiptDoneTransition() { + // 准备:消息 100 发送者 5,群 10 活跃成员 {5,1,2},可见用户中除发送者外 {1,2} + // Redis 位置 1→100、2→100 → 全部已读 → 迁移为 DONE + ImGroupMessageDO pending = ImGroupMessageDO.builder() + .id(100L).groupId(10L).senderId(5L) + .sendTime(LocalDateTime.now().minusMinutes(1)) + .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) + .status(ImMessageStatusEnum.UNREAD.getStatus()).build(); + when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 0L, 100L)) + .thenReturn(List.of(pending)); + List activeMembers = List.of( + ImGroupMemberDO.builder().groupId(10L).userId(5L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.now().minusDays(10)).build(), + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.now().minusDays(10)).build(), + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.now().minusDays(10)).build() + ); + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(activeMembers); + Map positions = new HashMap<>(); + positions.put(1L, 100L); + positions.put(2L, 100L); + when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions); + + // 调用 + groupMessageService.readGroupMessageEvent(1L, 10L, 0L, 100L); + + // 断言:消息回执状态更新为 DONE + ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMessageDO.class); + verify(groupMessageMapper).updateById(captor.capture()); + assertEquals(100L, captor.getValue().getId()); + assertEquals(ImGroupMessageReceiptStatusEnum.DONE.getStatus(), captor.getValue().getReceiptStatus()); + // 断言:给消息发送方(5)推送 RECEIPT 事件 + verify(imWebSocketService).sendGroupMessageAsync(eq(5L), any(ImGroupMessageDTO.class)); + } + + @Test + public void testReadGroupMessageEvent_receiptStaysPendingWhenPartialRead() { + // 准备:用户 2 还没读到 100 → 仍为 PENDING,不更新 DB + ImGroupMessageDO pending = ImGroupMessageDO.builder() + .id(100L).groupId(10L).senderId(5L) + .sendTime(LocalDateTime.now().minusMinutes(1)) + .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) + .status(ImMessageStatusEnum.UNREAD.getStatus()).build(); + when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 0L, 100L)) + .thenReturn(List.of(pending)); + List activeMembers = List.of( + ImGroupMemberDO.builder().groupId(10L).userId(5L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.now().minusDays(10)).build(), + ImGroupMemberDO.builder().groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.now().minusDays(10)).build(), + ImGroupMemberDO.builder().groupId(10L).userId(2L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(LocalDateTime.now().minusDays(10)).build() + ); + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(activeMembers); + Map positions = new HashMap<>(); + positions.put(1L, 100L); + positions.put(2L, 50L); // 还没读到 100 + when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions); + + groupMessageService.readGroupMessageEvent(1L, 10L, 0L, 100L); + + // 断言:回执状态保持 PENDING,不更新 DB + verify(groupMessageMapper, never()).updateById(any(ImGroupMessageDO.class)); + // 仍然推送 RECEIPT 事件给发送方(携带已读人数) + verify(imWebSocketService).sendGroupMessageAsync(eq(5L), any(ImGroupMessageDTO.class)); + } + + // ========== sendGroupMessageEvent:receiverUserIds 定向过滤 ========== + + @Test + public void testSendGroupMessageEvent_noReceiverUserIds_broadcastsToAllMembers() { + // 准备:无定向 → 应发给所有 ENABLE 成员(含发送者自己,多端同步) + ImGroupMessageDO message = ImGroupMessageDO.builder() + .id(100L).groupId(10L).senderId(1L) + .sendTime(LocalDateTime.now()).build(); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) + .thenReturn(List.of(1L, 2L, 3L, 4L)); + + groupMessageService.sendGroupMessageEvent(message); + + verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> + ids.size() == 4 && ids.contains(1L) && ids.contains(2L) + && ids.contains(3L) && ids.contains(4L)), + any(ImGroupMessageDTO.class)); + } + + @Test + public void testSendGroupMessageEvent_withReceiverUserIds_onlyTargetedPlusSender() { + // 准备:定向给 {2,3},发送者 1;群成员 {1,2,3,4} + // 预期推送目标:{1,2,3}(发送者自己多端同步 + 定向列表);4 被过滤 + ImGroupMessageDO message = ImGroupMessageDO.builder() + .id(100L).groupId(10L).senderId(1L) + .receiverUserIds(List.of(2L, 3L)) + .sendTime(LocalDateTime.now()).build(); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) + .thenReturn(List.of(1L, 2L, 3L, 4L)); + + groupMessageService.sendGroupMessageEvent(message); + + verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> + ids.size() == 3 && ids.contains(1L) && ids.contains(2L) + && ids.contains(3L) && !ids.contains(4L)), + any(ImGroupMessageDTO.class)); + } + + @Test + public void testSendGroupMessageEvent_withReceiverUserIds_senderNotInGroup_senderStillExcluded() { + // 边界:发送者不在群的 userId 列表里(理论上应先被 validateMemberInGroup 挡住,这里纯防御) + // 预期:仅定向用户可见 + ImGroupMessageDO message = ImGroupMessageDO.builder() + .id(100L).groupId(10L).senderId(99L) + .receiverUserIds(List.of(2L, 3L)) + .sendTime(LocalDateTime.now()).build(); + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) + .thenReturn(List.of(1L, 2L, 3L, 4L)); + + groupMessageService.sendGroupMessageEvent(message); + + verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> + ids.size() == 2 && ids.contains(2L) && ids.contains(3L)), + any(ImGroupMessageDTO.class)); + } + + // ========== sendTipGroupMessage ========== + + @Test + public void testSendTipGroupMessage_success() { + // 准备 + Set targets = Set.of(1L, 2L, 3L); + + // 调用 + groupMessageService.sendTipGroupMessage(1L, 10L, targets, "张三加入了群聊"); + + // 断言:插入一条 TIP_TEXT 提示消息 + ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(ImGroupMessageDO.class); + verify(groupMessageMapper).insert(msgCaptor.capture()); + ImGroupMessageDO tip = msgCaptor.getValue(); + assertEquals(1L, tip.getSenderId()); + assertEquals(10L, tip.getGroupId()); + assertEquals(ImMessageTypeEnum.TIP_TEXT.getType(), tip.getType()); + assertEquals("张三加入了群聊", tip.getContent()); + assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), tip.getStatus()); + assertEquals(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), tip.getReceiptStatus()); + assertNotNull(tip.getClientMessageId()); + assertNotNull(tip.getSendTime()); + // 断言:推送给指定用户集合 + verify(imWebSocketService).sendGroupMessageAsync(eq(targets), any(ImGroupMessageDTO.class)); + } + + // ========== getGroupMessageList ========== + + @Test + public void testGetGroupMessageList_delegatesToMapperWithJoinTime() { + // 准备 + LocalDateTime joinTime = LocalDateTime.now().minusDays(5); + ImGroupMemberDO member = ImGroupMemberDO.builder() + .id(50L).groupId(10L).userId(1L) + .status(CommonStatusEnum.ENABLE.getStatus()) + .joinTime(joinTime).build(); + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); + + ImGroupMessageListReqVO reqVO = new ImGroupMessageListReqVO(); + reqVO.setGroupId(10L); + reqVO.setMaxId(100L); + reqVO.setLimit(20); + + List mockList = List.of( + ImGroupMessageDO.builder().id(99L).groupId(10L).senderId(2L).build() + ); + when(groupMessageMapper.selectHistoryList(10L, 100L, 20, joinTime)).thenReturn(mockList); + + // 调用 + List result = groupMessageService.getGroupMessageList(1L, reqVO); + + // 断言:使用成员的 joinTime 作为历史消息的下限 + assertEquals(1, result.size()); + verify(groupMessageMapper).selectHistoryList(10L, 100L, 20, joinTime); + } + + @Test + public void testGetGroupMessageList_notInGroup() { + // 准备:用户不在群中 + ImGroupMessageListReqVO reqVO = new ImGroupMessageListReqVO(); + reqVO.setGroupId(10L); + reqVO.setLimit(20); + when(groupMemberService.validateMemberInGroup(10L, 1L)) + .thenThrow(new ServiceException(GROUP_MEMBER_NOT_IN_GROUP.getCode(), + GROUP_MEMBER_NOT_IN_GROUP.getMsg())); + + // 调用并断言:鉴权失败 + ServiceException exception = assertThrows(ServiceException.class, + () -> groupMessageService.getGroupMessageList(1L, reqVO)); + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); + // 断言:不查询 DB + verify(groupMessageMapper, never()).selectHistoryList(anyLong(), anyLong(), anyInt(), any()); + } + + // ========== delete read cursor 委托 ========== + + @Test + public void testDeleteReadMaxMessageId_delegatesToRedis() { + groupMessageService.deleteReadMaxMessageId(10L, 1L); + + verify(groupMessageReadRedisDAO).deleteReadMaxMessageId(10L, 1L); + } + + @Test + public void testDeleteReadMaxMessageIds_delegatesToRedis() { + groupMessageService.deleteReadMaxMessageIds(10L, List.of(1L, 2L)); + + verify(groupMessageReadRedisDAO).deleteReadMaxMessageIds(10L, List.of(1L, 2L)); + } + + @Test + public void testDeleteReadMaxMessageIdMap_delegatesToRedis() { + groupMessageService.deleteReadMaxMessageIdMap(10L); + + verify(groupMessageReadRedisDAO).deleteReadMaxMessageIdMap(10L); + } + } diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java index db41b08..ae40b55 100644 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java +++ b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java @@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.im.service.message; import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageListReqVO; import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO; import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper; @@ -245,4 +246,111 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { assertEquals(MESSAGE_ALREADY_RECALLED.getCode(), exception.getCode()); } + @Test + public void testRecallMessage_notExists() { + // 准备 + when(privateMessageMapper.selectById(10L)).thenReturn(null); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> privateMessageService.recallPrivateMessage(1L, 10L)); + assertEquals(MESSAGE_NOT_EXISTS.getCode(), exception.getCode()); + } + + @Test + public void testRecallMessage_timeout() { + // 准备:消息发送于 10 分钟前(超过 5 分钟窗口) + ImPrivateMessageDO message = ImPrivateMessageDO.builder() + .id(10L).senderId(1L).receiverId(2L) + .status(ImMessageStatusEnum.UNREAD.getStatus()) + .sendTime(LocalDateTime.now().minusMinutes(10)).build(); + when(privateMessageMapper.selectById(10L)).thenReturn(message); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> privateMessageService.recallPrivateMessage(1L, 10L)); + assertEquals(MESSAGE_RECALL_TIMEOUT.getCode(), exception.getCode()); + // 断言:不推送、不插 tipMessage + verify(privateMessageMapper, never()).insert(any(ImPrivateMessageDO.class)); + } + + @Test + public void testSendMessage_sensitiveWordBlocked() { + // 准备:文本消息命中敏感词 + ImPrivateMessageSendReqVO reqVO = buildSendReqVO(); + when(privateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001")) + .thenReturn(null); + when(friendService.isFriend(1L, 2L)).thenReturn(true); + doThrow(new ServiceException(MESSAGE_SENSITIVE_WORD_BLOCKED)) + .when(sensitiveWordService).validateText(reqVO.getContent()); + + // 调用并断言 + ServiceException exception = assertThrows(ServiceException.class, + () -> privateMessageService.sendPrivateMessage(1L, reqVO)); + assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode()); + // 断言:不入库、不推送 + verify(privateMessageMapper, never()).insert(any(ImPrivateMessageDO.class)); + verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); + } + + @Test + public void testReadMessages_noUnread() { + // 准备:没有未读消息 + when(privateMessageMapper.selectListBySenderIdAndReceiverIdAndStatus(2L, 1L, + ImMessageStatusEnum.UNREAD.getStatus())).thenReturn(List.of()); + + // 调用 + privateMessageService.readPrivateMessages(1L, 2L); + + // 断言:不更新、不推送 + verify(privateMessageMapper, never()).updateByIdsAndStatus(anyList(), anyInt(), any(ImPrivateMessageDO.class)); + verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); + } + + // ========== sendTipPrivateMessage ========== + + @Test + public void testSendTipPrivateMessage_success() { + // 调用 + privateMessageService.sendTipPrivateMessage(1L, 2L, "好友添加成功"); + + // 断言:插入一条 TIP_TEXT 提示消息 + ArgumentCaptor captor = ArgumentCaptor.forClass(ImPrivateMessageDO.class); + verify(privateMessageMapper).insert(captor.capture()); + ImPrivateMessageDO tip = captor.getValue(); + assertEquals(1L, tip.getSenderId()); + assertEquals(2L, tip.getReceiverId()); + assertEquals(ImMessageTypeEnum.TIP_TEXT.getType(), tip.getType()); + assertEquals("好友添加成功", tip.getContent()); + assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), tip.getStatus()); + assertNotNull(tip.getClientMessageId()); + assertNotNull(tip.getSendTime()); + // 断言:给双方各推送一次 + verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); + verify(imWebSocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); + } + + // ========== getPrivateMessageList ========== + + @Test + public void testGetPrivateMessageList_delegatesToMapper() { + // 准备 + ImPrivateMessageListReqVO reqVO = new ImPrivateMessageListReqVO(); + reqVO.setReceiverId(2L); + reqVO.setMaxId(100L); + reqVO.setLimit(20); + List mockList = List.of( + ImPrivateMessageDO.builder().id(99L).senderId(1L).receiverId(2L).build(), + ImPrivateMessageDO.builder().id(98L).senderId(2L).receiverId(1L).build() + ); + when(privateMessageMapper.selectHistoryList(1L, 2L, 100L, 20)).thenReturn(mockList); + + // 调用 + List result = privateMessageService.getPrivateMessageList(1L, reqVO); + + // 断言:透传到 mapper,参数一致 + assertEquals(2, result.size()); + verify(privateMessageMapper).selectHistoryList(1L, 2L, 100L, 20); + } + } diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java index 3c2965c..49b264b 100644 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java +++ b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java @@ -75,8 +75,8 @@ public class ImSensitiveWordServiceImplTest extends BaseMockitoUnitTest { @Test public void testRefresh_reloadsFromMapper() { - // 刷新前:第一次调用 mapper - verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); + // 刷新前:init 调用 mapper 一次 + verify(imSensitiveWordMapper).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); // 准备:新列表不含原词 when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus())) diff --git a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImplTest.java b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImplTest.java index 9e26b7e..aa42d35 100644 --- a/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImplTest.java +++ b/yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImplTest.java @@ -137,6 +137,24 @@ public class ImWebSocketServiceImplTest extends BaseMockitoUnitTest { } @Test + public void testSendPrivateMessageAsync_exceptionSwallowed() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) + .thenReturn(imWebSocketService); + + // 准备:sender 抛异常 + ImPrivateMessageDTO dto = new ImPrivateMessageDTO().setSenderId(1L).setReceiverId(2L); + doThrow(new RuntimeException("user offline")) + .when(webSocketMessageSender).sendObject(anyInt(), anyLong(), anyString(), any()); + + // 调用:异常应被吞掉,不向上抛 + imWebSocketService.sendPrivateMessageAsync(2L, dto); + + verify(webSocketMessageSender).sendObject(anyInt(), eq(2L), anyString(), any()); + } + } + + @Test public void testSendGroupMessageAsync_singleUserDefaultOverload() { try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) -- libgit2 0.21.4