Commit 6a10ed7d88126438d12b2fe5967a6e43b90c7a26
1 parent
16d5c0b0
✨ feat(im): 更新群组和群成员相关接口,优化成员查询逻辑
- 修改获取当前用户群组列表的接口名称,提升语义清晰度 - 新增获取有效群成员 userId 列表的接口,支持缓存 - 优化群成员查询逻辑,确保历史消息可见性 - 更新相关文档,补充接口说明和使用示例
Showing
18 changed files
with
752 additions
and
173 deletions
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 { |
| 73 | 73 | @GetMapping("/list") |
| 74 | 74 | @Operation(summary = "获得当前登录用户的群列表") |
| 75 | 75 | public CommonResult<List<ImGroupRespVO>> getMyGroupList() { |
| 76 | - List<ImGroupDO> groups = groupService.getActiveGroupList(getLoginUserId()); | |
| 76 | + List<ImGroupDO> groups = groupService.getMyGroupList(getLoginUserId()); | |
| 77 | 77 | return success(BeanUtils.toBean(groups, ImGroupRespVO.class)); |
| 78 | 78 | } |
| 79 | 79 | ... | ... |
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupMemberController.java
| 1 | 1 | package cn.iocoder.yudao.module.im.controller.admin.group; |
| 2 | 2 | |
| 3 | +import cn.hutool.core.collection.CollUtil; | |
| 4 | +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; | |
| 3 | 5 | import cn.iocoder.yudao.framework.common.pojo.CommonResult; |
| 4 | 6 | import cn.iocoder.yudao.framework.common.util.collection.MapUtils; |
| 5 | 7 | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; |
| ... | ... | @@ -21,9 +23,11 @@ import org.springframework.web.bind.annotation.*; |
| 21 | 23 | import java.util.List; |
| 22 | 24 | import java.util.Map; |
| 23 | 25 | |
| 26 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 24 | 27 | import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; |
| 25 | 28 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; |
| 26 | 29 | import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; |
| 30 | +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.GROUP_MEMBER_NOT_IN_GROUP; | |
| 27 | 31 | |
| 28 | 32 | @Tag(name = "管理后台 - 群成员") |
| 29 | 33 | @RestController |
| ... | ... | @@ -58,8 +62,17 @@ public class ImGroupMemberController { |
| 58 | 62 | @Operation(summary = "获得指定群的成员列表") |
| 59 | 63 | @Parameter(name = "groupId", description = "群编号", required = true, example = "1024") |
| 60 | 64 | public CommonResult<List<ImGroupMemberRespVO>> getGroupMemberList(@RequestParam("groupId") Long groupId) { |
| 65 | + // 1.1 查询群成员列表(包含 DISABLE 已退群的成员,不按时间过滤) | |
| 66 | + // 说明:保留已退群成员,是为了前端展示历史消息时,仍能通过该接口拿到已退群成员的昵称 / 头像信息,避免显示为空 | |
| 61 | 67 | List<ImGroupMemberDO> members = groupMemberService.getGroupMemberListByGroupId(groupId); |
| 62 | - // 批量聚合 AdminUser 信息(昵称 / 头像) | |
| 68 | + // 1.2 校验当前登录用户是否为群的有效成员,非成员不可查看 | |
| 69 | + Long loginUserId = getLoginUserId(); | |
| 70 | + if (CollUtil.findOne(members, m -> loginUserId.equals(m.getUserId()) | |
| 71 | + && CommonStatusEnum.ENABLE.getStatus().equals(m.getStatus())) == null) { | |
| 72 | + throw exception(GROUP_MEMBER_NOT_IN_GROUP); | |
| 73 | + } | |
| 74 | + | |
| 75 | + // 2.批量聚合 AdminUser 信息(昵称 / 头像) | |
| 63 | 76 | Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap( |
| 64 | 77 | convertList(members, ImGroupMemberDO::getUserId)); |
| 65 | 78 | return success(convertList(members, m -> { | ... | ... |
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java
| 1 | 1 | package cn.iocoder.yudao.module.im.dal.mysql.group; |
| 2 | 2 | |
| 3 | 3 | import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; |
| 4 | -import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; | |
| 5 | 4 | import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; |
| 6 | 5 | import org.apache.ibatis.annotations.Mapper; |
| 7 | 6 | |
| 8 | -import java.util.Collection; | |
| 9 | -import java.util.List; | |
| 10 | - | |
| 11 | 7 | /** |
| 12 | 8 | * IM 群 Mapper |
| 13 | 9 | * |
| ... | ... | @@ -15,20 +11,4 @@ import java.util.List; |
| 15 | 11 | */ |
| 16 | 12 | @Mapper |
| 17 | 13 | public interface ImGroupMapper extends BaseMapperX<ImGroupDO> { |
| 18 | - | |
| 19 | - /** | |
| 20 | - * 根据群编号批量查询群,支持按状态和封禁过滤 | |
| 21 | - * | |
| 22 | - * @param ids 群编号集合 | |
| 23 | - * @param status 群状态(可空,空则不过滤) | |
| 24 | - * @param banned 是否封禁(可空,空则不过滤) | |
| 25 | - * @return 群列表 | |
| 26 | - */ | |
| 27 | - default List<ImGroupDO> selectListByIds(Collection<Long> ids, Integer status, Boolean banned) { | |
| 28 | - return selectList(new LambdaQueryWrapperX<ImGroupDO>() | |
| 29 | - .in(ImGroupDO::getId, ids) | |
| 30 | - .eqIfPresent(ImGroupDO::getStatus, status) | |
| 31 | - .eqIfPresent(ImGroupDO::getBanned, banned)); | |
| 32 | - } | |
| 33 | - | |
| 34 | 14 | } |
| 35 | 15 | \ No newline at end of file | ... | ... |
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 { |
| 31 | 31 | */ |
| 32 | 32 | String GROUP = "group"; |
| 33 | 33 | |
| 34 | + /** | |
| 35 | + * 群有效成员 userId 列表缓存(仅 ENABLE 状态) | |
| 36 | + * <p> | |
| 37 | + * KEY 格式:group_member_ids:{groupId} | |
| 38 | + * VALUE 数据类型:List<Long> | |
| 39 | + * <p> | |
| 40 | + * 说明:只缓存轻量的 userId 列表,适合"群消息推送目标"这类只关心 userId 的场景。 | |
| 41 | + */ | |
| 42 | + String GROUP_MEMBER_IDS = "group_member_ids"; | |
| 43 | + | |
| 34 | 44 | } | ... | ... |
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 { |
| 40 | 40 | List<ImGroupMemberDO> getActiveGroupMemberListByGroupId(Long groupId); |
| 41 | 41 | |
| 42 | 42 | /** |
| 43 | + * 根据群编号查询有效成员的 userId 列表(仅 ENABLE 状态) | |
| 44 | + * <p> | |
| 45 | + * 相比 {@link #getActiveGroupMemberListByGroupId(Long)},只返回 userId,结果体积小,并带有 Redis 缓存。 | |
| 46 | + * 适用于"群消息推送目标"等只需要 userId 的场景; | |
| 47 | + * 需要 joinTime/quitTime/status 等字段做历史消息可见性判断时,仍应使用完整列表方法。 | |
| 48 | + * | |
| 49 | + * @param groupId 群编号 | |
| 50 | + * @return 有效群成员 userId 列表 | |
| 51 | + */ | |
| 52 | + List<Long> getActiveGroupMemberUserIdsByGroupId(Long groupId); | |
| 53 | + | |
| 54 | + /** | |
| 43 | 55 | * 查询用户所在的所有群的有效成员记录(仅 ENABLE 状态) |
| 44 | 56 | * |
| 45 | 57 | * @param userId 用户编号 | ... | ... |
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; |
| 10 | 10 | import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; |
| 11 | 11 | import jakarta.annotation.Resource; |
| 12 | 12 | import lombok.extern.slf4j.Slf4j; |
| 13 | +import org.springframework.cache.annotation.CacheEvict; | |
| 14 | +import org.springframework.cache.annotation.Cacheable; | |
| 13 | 15 | import org.springframework.dao.DuplicateKeyException; |
| 14 | 16 | import org.springframework.stereotype.Service; |
| 15 | 17 | import org.springframework.validation.annotation.Validated; |
| ... | ... | @@ -21,7 +23,9 @@ import java.util.List; |
| 21 | 23 | import java.util.Map; |
| 22 | 24 | |
| 23 | 25 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 26 | +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; | |
| 24 | 27 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; |
| 28 | +import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP_MEMBER_IDS; | |
| 25 | 29 | import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.GROUP_MEMBER_NOT_IN_GROUP; |
| 26 | 30 | |
| 27 | 31 | /** |
| ... | ... | @@ -55,6 +59,24 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { |
| 55 | 59 | return groupMemberMapper.selectListByGroupIdAndStatus(groupId, CommonStatusEnum.ENABLE.getStatus()); |
| 56 | 60 | } |
| 57 | 61 | |
| 62 | + /** | |
| 63 | + * 只缓存 userId 列表而非整个 {@link ImGroupMemberDO},理由: | |
| 64 | + * <ul> | |
| 65 | + * <li>体积小:500 人群约 4KB,失效/序列化成本低;</li> | |
| 66 | + * <li>失效面窄:仅 {@link #addGroupMember}/{@link #addGroupMembers}/ | |
| 67 | + * {@link #removeGroupMember}/{@link #removeGroupMembers}/{@link #removeGroupMembersByGroupId} | |
| 68 | + * 这类影响成员集合的写操作需要失效; | |
| 69 | + * {@link #updateGroupMember}(昵称/备注/免打扰)不修改集合成员,不需要失效。</li> | |
| 70 | + * </ul> | |
| 71 | + */ | |
| 72 | + @Override | |
| 73 | + @Cacheable(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") | |
| 74 | + public List<Long> getActiveGroupMemberUserIdsByGroupId(Long groupId) { | |
| 75 | + List<ImGroupMemberDO> members = groupMemberMapper.selectListByGroupIdAndStatus( | |
| 76 | + groupId, CommonStatusEnum.ENABLE.getStatus()); | |
| 77 | + return convertList(members, ImGroupMemberDO::getUserId); | |
| 78 | + } | |
| 79 | + | |
| 58 | 80 | @Override |
| 59 | 81 | public List<ImGroupMemberDO> getActiveGroupMemberListByUserId(Long userId) { |
| 60 | 82 | return groupMemberMapper.selectListByUserIdAndStatus(userId, CommonStatusEnum.ENABLE.getStatus()); |
| ... | ... | @@ -70,6 +92,7 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { |
| 70 | 92 | * 当并发 insert 触发 {@link DuplicateKeyException} 时降级为 select + update。 |
| 71 | 93 | */ |
| 72 | 94 | @Override |
| 95 | + @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") | |
| 73 | 96 | public ImGroupMemberDO addGroupMember(Long groupId, Long userId) { |
| 74 | 97 | LocalDateTime now = LocalDateTime.now(); |
| 75 | 98 | // 情况一:已存在记录 → 复活或跳过 |
| ... | ... | @@ -96,6 +119,7 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { |
| 96 | 119 | } |
| 97 | 120 | |
| 98 | 121 | @Override |
| 122 | + @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") | |
| 99 | 123 | public void addGroupMembers(Long groupId, Collection<Long> userIds) { |
| 100 | 124 | LocalDateTime now = LocalDateTime.now(); |
| 101 | 125 | // 1.1 查询已有记录(含已退群的 DISABLE 记录) |
| ... | ... | @@ -158,6 +182,7 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { |
| 158 | 182 | } |
| 159 | 183 | |
| 160 | 184 | @Override |
| 185 | + @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") | |
| 161 | 186 | public void removeGroupMember(Long groupId, Long userId) { |
| 162 | 187 | // 1. 校验是群的有效成员 |
| 163 | 188 | ImGroupMemberDO member = validateMemberInGroup(groupId, userId); |
| ... | ... | @@ -168,6 +193,7 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { |
| 168 | 193 | } |
| 169 | 194 | |
| 170 | 195 | @Override |
| 196 | + @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") | |
| 171 | 197 | public void removeGroupMembers(Long groupId, Collection<Long> userIds) { |
| 172 | 198 | groupMemberMapper.updateByGroupIdAndUserIdsAndStatus(groupId, userIds, |
| 173 | 199 | CommonStatusEnum.ENABLE.getStatus(), |
| ... | ... | @@ -176,6 +202,7 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { |
| 176 | 202 | } |
| 177 | 203 | |
| 178 | 204 | @Override |
| 205 | + @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") | |
| 179 | 206 | public void removeGroupMembersByGroupId(Long groupId) { |
| 180 | 207 | groupMemberMapper.updateByGroupIdAndStatus(groupId, CommonStatusEnum.ENABLE.getStatus(), |
| 181 | 208 | new ImGroupMemberDO().setStatus(CommonStatusEnum.DISABLE.getStatus()) | ... | ... |
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 { |
| 77 | 77 | ImGroupDO validateGroupOwner(Long groupId, Long userId); |
| 78 | 78 | |
| 79 | 79 | /** |
| 80 | - * 获取指定用户加入的所有"有效"群(未退群且未封禁/解散) | |
| 80 | + * 获取指定用户的群列表 | |
| 81 | 81 | * <p> |
| 82 | - * 返回当前登录用户在群内身份仍有效的所有群。 | |
| 82 | + * 返回用户当前仍有效的群,以及最近 {@link cn.iocoder.yudao.module.im.enums.ImCommonConstants#MESSAGE_GROUP_PULL_MAX_DAYS} | |
| 83 | + * 天内退群的群 —— 退群前可能还有离线消息需要展示,前端需要把这些群信息作为缓存。 | |
| 83 | 84 | * |
| 84 | 85 | * @param userId 用户编号 |
| 85 | 86 | * @return 群列表 |
| 86 | 87 | */ |
| 87 | - List<ImGroupDO> getActiveGroupList(Long userId); | |
| 88 | + List<ImGroupDO> getMyGroupList(Long userId); | |
| 88 | 89 | |
| 89 | 90 | // ==================== 群成员的写操作 ==================== |
| 90 | 91 | // 说明:群成员的写操作统一放在 ImGroupService,而非 ImGroupMemberService, | ... | ... |
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 { |
| 256 | 256 | } |
| 257 | 257 | |
| 258 | 258 | @Override |
| 259 | - public List<ImGroupDO> getActiveGroupList(Long userId) { | |
| 260 | - // 1. 查用户所在的、仍有效的群成员记录(仅 ENABLE 状态) | |
| 259 | + public List<ImGroupDO> getMyGroupList(Long userId) { | |
| 260 | + // 1.1 查用户所在的、仍有效的群成员记录(仅 ENABLE 状态) | |
| 261 | 261 | List<ImGroupMemberDO> members = groupMemberService.getActiveGroupMemberListByUserId(userId); |
| 262 | + // 1.2 再查最近 MESSAGE_GROUP_PULL_MAX_DAYS 天内退群的成员记录(退群前可能有离线消息需要展示,一并返回作为前端缓存) | |
| 263 | + LocalDateTime minQuitTime = LocalDateTime.now().minusDays(MESSAGE_GROUP_PULL_MAX_DAYS); | |
| 264 | + members.addAll(groupMemberService.getQuitGroupMemberListByUserId(userId, minQuitTime)); | |
| 262 | 265 | if (CollUtil.isEmpty(members)) { |
| 263 | 266 | return Collections.emptyList(); |
| 264 | 267 | } |
| 265 | - Set<Long> groupIds = convertSet(members, ImGroupMemberDO::getGroupId); | |
| 266 | - if (CollUtil.isEmpty(groupIds)) { | |
| 267 | - return Collections.emptyList(); | |
| 268 | - } | |
| 269 | 268 | |
| 270 | - // 2. 批量查询群信息(仅 ENABLE 状态) | |
| 271 | - return groupMapper.selectListByIds(groupIds, CommonStatusEnum.ENABLE.getStatus(), false); | |
| 269 | + // 2. 批量查询群信息(不按 status / banned 过滤,已解散 / 封禁的群也要返回,供前端展示历史消息的群名 / 头像) | |
| 270 | + Set<Long> groupIds = convertSet(members, ImGroupMemberDO::getGroupId); | |
| 271 | + return groupMapper.selectByIds(groupIds); | |
| 272 | 272 | } |
| 273 | 273 | |
| 274 | 274 | private ImGroupServiceImpl getSelf() { | ... | ... |
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 { |
| 361 | 361 | |
| 362 | 362 | /** |
| 363 | 363 | * 发送群聊消息 WebSocket 事件 |
| 364 | + * <p> | |
| 365 | + * 这里仅用于"新消息"(首发 or 撤回提示),{@code message.sendTime = now}: | |
| 366 | + * <ul> | |
| 367 | + * <li>所有 ENABLE 成员的 {@code joinTime <= now},{@link #isMessageVisible} 的 joinTime 分支恒为真;</li> | |
| 368 | + * <li>ENABLE 成员 status 非 DISABLE,quitTime 分支不适用;</li> | |
| 369 | + * </ul> | |
| 370 | + * 因此只剩 {@code receiverUserIds} 定向过滤会真正起效。 | |
| 371 | + * 故使用只含 userId 的轻量缓存 {@link ImGroupMemberService#getActiveGroupMemberUserIdsByGroupId(Long)}, | |
| 372 | + * 在本地以 {@link #getVisibleUserIds} 完成定向过滤即可。 | |
| 373 | + * <p> | |
| 374 | + * 注意:对历史消息的可见性判断仍需 joinTime/quitTime,见 {@code readGroupMessageEvent} 等处。 | |
| 364 | 375 | */ |
| 365 | 376 | public void sendGroupMessageEvent(ImGroupMessageDO message) { |
| 366 | 377 | ImGroupMessageDTO dto = ImGroupMessageDTO.ofSend(message); |
| 367 | 378 | // 广播给群内有效成员中的可见用户(含发送方自己,支持多端同步;定向消息自动过滤) |
| 368 | - List<ImGroupMemberDO> members = groupMemberService.getActiveGroupMemberListByGroupId(message.getGroupId()); | |
| 369 | - Set<Long> targetUserIds = getVisibleUserIds(message, members); | |
| 379 | + List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(message.getGroupId()); | |
| 380 | + Set<Long> targetUserIds = getVisibleUserIds(message, memberUserIds); | |
| 370 | 381 | imWebSocketService.sendGroupMessageAsync(targetUserIds, dto); |
| 371 | 382 | } |
| 372 | 383 | |
| ... | ... | @@ -466,9 +477,38 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { |
| 466 | 477 | /** |
| 467 | 478 | * 计算一条群消息的可见成员集合(含发送者) |
| 468 | 479 | */ |
| 469 | - private Set<Long> getVisibleUserIds(ImGroupMessageDO msg, List<ImGroupMemberDO> allMembers) { | |
| 470 | - return convertSet(allMembers, ImGroupMemberDO::getUserId, | |
| 471 | - member -> isMessageVisible(msg, member, member.getUserId())); | |
| 480 | + private Set<Long> getVisibleUserIds(ImGroupMessageDO message, List<ImGroupMemberDO> members) { | |
| 481 | + return convertSet(members, ImGroupMemberDO::getUserId, | |
| 482 | + member -> isMessageVisible(message, member, member.getUserId())); | |
| 483 | + } | |
| 484 | + | |
| 485 | + /** | |
| 486 | + * 基于群成员 userId 列表,过滤出一条消息的可见成员集合(含发送者)。 | |
| 487 | + * <p> | |
| 488 | + * 仅适用于"新消息"推送场景({@code sendTime = now}),不涉及 joinTime/quitTime 判定, | |
| 489 | + * 只应用 {@code receiverUserIds} 定向过滤;语义与 | |
| 490 | + * {@link #isMessageVisible(ImGroupMessageDO, ImGroupMemberDO, Long)} 的第 3 步保持一致。 | |
| 491 | + */ | |
| 492 | + private Set<Long> getVisibleUserIds(ImGroupMessageDO message, Collection<Long> memberUserIds) { | |
| 493 | + if (CollUtil.isEmpty(memberUserIds)) { | |
| 494 | + return new HashSet<>(); | |
| 495 | + } | |
| 496 | + // 无定向接收列表 → 全员可见 | |
| 497 | + if (CollUtil.isEmpty(message.getReceiverUserIds())) { | |
| 498 | + return new HashSet<>(memberUserIds); | |
| 499 | + } | |
| 500 | + // 有定向接收列表 → 仅定向用户可见;发送者自己也能看到自己的消息(多端同步) | |
| 501 | + Set<Long> allowed = new HashSet<>(message.getReceiverUserIds()); | |
| 502 | + if (message.getSenderId() != null) { | |
| 503 | + allowed.add(message.getSenderId()); | |
| 504 | + } | |
| 505 | + Set<Long> result = new HashSet<>(); | |
| 506 | + for (Long userId : memberUserIds) { | |
| 507 | + if (allowed.contains(userId)) { | |
| 508 | + result.add(userId); | |
| 509 | + } | |
| 510 | + } | |
| 511 | + return result; | |
| 472 | 512 | } |
| 473 | 513 | |
| 474 | 514 | @Override | ... | ... |
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 { |
| 21 | 21 | @Resource |
| 22 | 22 | private ImGroupMemberMapper mapper; |
| 23 | 23 | |
| 24 | - // ========== selectListByGroupIdAndUserIds ========== | |
| 25 | - | |
| 26 | - @Test | |
| 27 | - public void testSelectListByGroupIdAndUserIds() { | |
| 28 | - // 准备:同一群 3 个成员 + 另一群 1 个成员 | |
| 29 | - ImGroupMemberDO u1 = buildMember(10L, 1L, CommonStatusEnum.ENABLE); | |
| 30 | - mapper.insert(u1); | |
| 31 | - ImGroupMemberDO u2 = buildMember(10L, 2L, CommonStatusEnum.ENABLE); | |
| 32 | - mapper.insert(u2); | |
| 33 | - ImGroupMemberDO u3 = buildMember(10L, 3L, CommonStatusEnum.DISABLE); | |
| 34 | - mapper.insert(u3); | |
| 35 | - // 不相关的另一个群 | |
| 36 | - mapper.insert(buildMember(20L, 1L, CommonStatusEnum.ENABLE)); | |
| 37 | - | |
| 38 | - List<ImGroupMemberDO> result = mapper.selectListByGroupIdAndUserIds(10L, List.of(1L, 2L, 3L)); | |
| 39 | - assertEquals(3, result.size()); | |
| 40 | - } | |
| 41 | - | |
| 42 | - @Test | |
| 43 | - public void testSelectListByGroupIdAndUserIds_mixedUserIds() { | |
| 44 | - mapper.insert(buildMember(10L, 1L, CommonStatusEnum.ENABLE)); | |
| 45 | - ImGroupMemberDO u2 = buildMember(10L, 2L, CommonStatusEnum.ENABLE); | |
| 46 | - mapper.insert(u2); | |
| 47 | - | |
| 48 | - // 查询不存在的用户 99,返回为空 | |
| 49 | - List<ImGroupMemberDO> result = mapper.selectListByGroupIdAndUserIds(10L, List.of(99L)); | |
| 50 | - assertTrue(result.isEmpty()); | |
| 51 | - | |
| 52 | - // 查询包含存在和不存在混合,只返回存在的 | |
| 53 | - List<ImGroupMemberDO> result2 = mapper.selectListByGroupIdAndUserIds(10L, List.of(2L, 99L)); | |
| 54 | - assertEquals(1, result2.size()); | |
| 55 | - assertEquals(u2.getId(), result2.get(0).getId()); | |
| 56 | - } | |
| 57 | - | |
| 58 | - // ========== selectListByGroupIdAndStatus ========== | |
| 59 | - | |
| 60 | - @Test | |
| 61 | - public void testSelectListByGroupIdAndStatus_onlyMatchingStatus() { | |
| 62 | - mapper.insert(buildMember(10L, 1L, CommonStatusEnum.ENABLE)); | |
| 63 | - mapper.insert(buildMember(10L, 2L, CommonStatusEnum.ENABLE)); | |
| 64 | - mapper.insert(buildMember(10L, 3L, CommonStatusEnum.DISABLE)); | |
| 65 | - | |
| 66 | - List<ImGroupMemberDO> active = mapper.selectListByGroupIdAndStatus( | |
| 67 | - 10L, CommonStatusEnum.ENABLE.getStatus()); | |
| 68 | - assertEquals(2, active.size()); | |
| 69 | - | |
| 70 | - List<ImGroupMemberDO> disabled = mapper.selectListByGroupIdAndStatus( | |
| 71 | - 10L, CommonStatusEnum.DISABLE.getStatus()); | |
| 72 | - assertEquals(1, disabled.size()); | |
| 73 | - } | |
| 74 | - | |
| 75 | - // ========== selectListByUserIdAndStatus ========== | |
| 76 | - | |
| 77 | - @Test | |
| 78 | - public void testSelectListByUserIdAndStatus() { | |
| 79 | - // 用户 1 在群 10、20 都有记录;在群 30 已退群 | |
| 80 | - mapper.insert(buildMember(10L, 1L, CommonStatusEnum.ENABLE)); | |
| 81 | - mapper.insert(buildMember(20L, 1L, CommonStatusEnum.ENABLE)); | |
| 82 | - mapper.insert(buildMember(30L, 1L, CommonStatusEnum.DISABLE)); | |
| 83 | - // 另一个用户的记录 | |
| 84 | - mapper.insert(buildMember(10L, 2L, CommonStatusEnum.ENABLE)); | |
| 85 | - | |
| 86 | - List<ImGroupMemberDO> active = mapper.selectListByUserIdAndStatus( | |
| 87 | - 1L, CommonStatusEnum.ENABLE.getStatus()); | |
| 88 | - assertEquals(2, active.size()); | |
| 89 | - assertTrue(active.stream().allMatch(m -> m.getUserId().equals(1L))); | |
| 90 | - } | |
| 91 | - | |
| 92 | 24 | // ========== selectQuitListByUserId ========== |
| 93 | 25 | |
| 94 | 26 | @Test | ... | ... |
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 { |
| 211 | 211 | assertEquals(after.getId(), result.get(0).getId()); |
| 212 | 212 | } |
| 213 | 213 | |
| 214 | - // ========== selectBySenderIdAndClientMessageId ========== | |
| 215 | - | |
| 216 | - @Test | |
| 217 | - public void testSelectBySenderIdAndClientMessageId() { | |
| 218 | - ImGroupMessageDO msg = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); | |
| 219 | - msg.setClientMessageId("group-uuid-001"); | |
| 220 | - mapper.insert(msg); | |
| 221 | - | |
| 222 | - ImGroupMessageDO result = mapper.selectBySenderIdAndClientMessageId(1L, "group-uuid-001"); | |
| 223 | - assertNotNull(result); | |
| 224 | - assertEquals(msg.getId(), result.getId()); | |
| 225 | - | |
| 226 | - assertNull(mapper.selectBySenderIdAndClientMessageId(1L, "not-exist")); | |
| 227 | - } | |
| 228 | - | |
| 229 | 214 | // ========== selectListByGroupIdAndPendingReceipt ========== |
| 230 | 215 | |
| 231 | 216 | @Test | ... | ... |
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 { |
| 181 | 181 | assertEquals(2L, result.get(0).getSenderId()); |
| 182 | 182 | } |
| 183 | 183 | |
| 184 | - // ========== selectBySenderIdAndClientMessageId ========== | |
| 185 | - | |
| 186 | - @Test | |
| 187 | - public void testSelectBySenderIdAndClientMessageId() { | |
| 188 | - // 准备 | |
| 189 | - ImPrivateMessageDO msg = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); | |
| 190 | - msg.setClientMessageId("uuid-001"); | |
| 191 | - mapper.insert(msg); | |
| 192 | - | |
| 193 | - // 调用 | |
| 194 | - ImPrivateMessageDO result = mapper.selectBySenderIdAndClientMessageId(1L, "uuid-001"); | |
| 195 | - | |
| 196 | - // 断言 | |
| 197 | - assertNotNull(result); | |
| 198 | - assertEquals(msg.getId(), result.getId()); | |
| 199 | - } | |
| 200 | - | |
| 201 | - @Test | |
| 202 | - public void testSelectBySenderIdAndClientMessageId_notFound() { | |
| 203 | - ImPrivateMessageDO result = mapper.selectBySenderIdAndClientMessageId(1L, "not-exist"); | |
| 204 | - assertNull(result); | |
| 205 | - } | |
| 206 | - | |
| 207 | 184 | // ========== 工具方法 ========== |
| 208 | 185 | |
| 209 | 186 | private ImPrivateMessageDO buildMessage(Long senderId, Long receiverId, ImMessageStatusEnum status) { | ... | ... |
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 { |
| 130 | 130 | } |
| 131 | 131 | |
| 132 | 132 | @Test |
| 133 | + public void testAddGroupMembers_allExisting_onlyUpdates() { | |
| 134 | + // 准备:传入的 3 个用户都已有记录(全部 DISABLE) → 只做 update,不做 insert | |
| 135 | + List<ImGroupMemberDO> existing = List.of( | |
| 136 | + ImGroupMemberDO.builder().id(1L).groupId(10L).userId(2L) | |
| 137 | + .status(CommonStatusEnum.DISABLE.getStatus()).build(), | |
| 138 | + ImGroupMemberDO.builder().id(2L).groupId(10L).userId(3L) | |
| 139 | + .status(CommonStatusEnum.DISABLE.getStatus()).build() | |
| 140 | + ); | |
| 141 | + when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) | |
| 142 | + .thenReturn(existing); | |
| 143 | + | |
| 144 | + groupMemberService.addGroupMembers(10L, List.of(2L, 3L)); | |
| 145 | + | |
| 146 | + verify(groupMemberMapper).updateBatch(anyList()); | |
| 147 | + verify(groupMemberMapper, never()).insertBatch(anyList()); | |
| 148 | + } | |
| 149 | + | |
| 150 | + @Test | |
| 151 | + public void testAddGroupMembers_allNew_onlyInserts() { | |
| 152 | + // 准备:都不存在 → 只做 insert | |
| 153 | + when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) | |
| 154 | + .thenReturn(List.of()); | |
| 155 | + | |
| 156 | + groupMemberService.addGroupMembers(10L, List.of(2L, 3L)); | |
| 157 | + | |
| 158 | + verify(groupMemberMapper, never()).updateBatch(anyList()); | |
| 159 | + verify(groupMemberMapper).insertBatch(anyList()); | |
| 160 | + } | |
| 161 | + | |
| 162 | + @Test | |
| 163 | + public void testAddGroupMembers_allExistingEnabled_nothingHappens() { | |
| 164 | + // 准备:都已存在且 ENABLE → 既不 update 也不 insert | |
| 165 | + List<ImGroupMemberDO> existing = List.of( | |
| 166 | + ImGroupMemberDO.builder().id(1L).groupId(10L).userId(2L) | |
| 167 | + .status(CommonStatusEnum.ENABLE.getStatus()).build() | |
| 168 | + ); | |
| 169 | + when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) | |
| 170 | + .thenReturn(existing); | |
| 171 | + | |
| 172 | + groupMemberService.addGroupMembers(10L, List.of(2L)); | |
| 173 | + | |
| 174 | + verify(groupMemberMapper, never()).updateBatch(anyList()); | |
| 175 | + verify(groupMemberMapper, never()).insertBatch(anyList()); | |
| 176 | + } | |
| 177 | + | |
| 178 | + @Test | |
| 133 | 179 | public void testAddGroupMembers_batchInsertDuplicateFallback() { |
| 134 | 180 | // 准备:两个新增成员,批量插入失败时降级为逐个 addGroupMember |
| 135 | 181 | when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) |
| ... | ... | @@ -248,4 +294,37 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest { |
| 248 | 294 | assertNotNull(captor.getValue().getQuitTime()); |
| 249 | 295 | } |
| 250 | 296 | |
| 297 | + // ========== getActiveGroupMemberUserIdsByGroupId ========== | |
| 298 | + | |
| 299 | + @Test | |
| 300 | + public void testGetActiveGroupMemberUserIdsByGroupId_extractsUserIds() { | |
| 301 | + // 准备:3 个 ENABLE 成员 | |
| 302 | + List<ImGroupMemberDO> members = List.of( | |
| 303 | + ImGroupMemberDO.builder().groupId(10L).userId(1L) | |
| 304 | + .status(CommonStatusEnum.ENABLE.getStatus()).build(), | |
| 305 | + ImGroupMemberDO.builder().groupId(10L).userId(2L) | |
| 306 | + .status(CommonStatusEnum.ENABLE.getStatus()).build(), | |
| 307 | + ImGroupMemberDO.builder().groupId(10L).userId(3L) | |
| 308 | + .status(CommonStatusEnum.ENABLE.getStatus()).build() | |
| 309 | + ); | |
| 310 | + when(groupMemberMapper.selectListByGroupIdAndStatus( | |
| 311 | + 10L, CommonStatusEnum.ENABLE.getStatus())).thenReturn(members); | |
| 312 | + | |
| 313 | + // 调用 | |
| 314 | + List<Long> userIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L); | |
| 315 | + | |
| 316 | + // 断言:只返回 userId、顺序保留 | |
| 317 | + assertEquals(List.of(1L, 2L, 3L), userIds); | |
| 318 | + } | |
| 319 | + | |
| 320 | + @Test | |
| 321 | + public void testGetActiveGroupMemberUserIdsByGroupId_emptyList() { | |
| 322 | + when(groupMemberMapper.selectListByGroupIdAndStatus( | |
| 323 | + 10L, CommonStatusEnum.ENABLE.getStatus())).thenReturn(List.of()); | |
| 324 | + | |
| 325 | + List<Long> userIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L); | |
| 326 | + | |
| 327 | + assertTrue(userIds.isEmpty()); | |
| 328 | + } | |
| 329 | + | |
| 251 | 330 | } | ... | ... |
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 { |
| 279 | 279 | } |
| 280 | 280 | |
| 281 | 281 | @Test |
| 282 | + public void testInviteGroupMember_memberExceed() { | |
| 283 | + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) { | |
| 284 | + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) | |
| 285 | + .thenReturn(groupService); | |
| 286 | + | |
| 287 | + // 准备:群 10 已有 499 人(逼近 MAX_GROUP_MEMBER=500),再邀请 2 人 → 501 超限 | |
| 288 | + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) | |
| 289 | + .status(CommonStatusEnum.ENABLE.getStatus()).build(); | |
| 290 | + when(groupMapper.selectById(10L)).thenReturn(group); | |
| 291 | + | |
| 292 | + List<ImGroupMemberDO> activeMembers = new ArrayList<>(); | |
| 293 | + for (long i = 1; i <= 499; i++) { | |
| 294 | + activeMembers.add(ImGroupMemberDO.builder().groupId(10L).userId(i) | |
| 295 | + .status(CommonStatusEnum.ENABLE.getStatus()).build()); | |
| 296 | + } | |
| 297 | + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(activeMembers); | |
| 298 | + | |
| 299 | + ImGroupMemberInviteReqVO reqVO = new ImGroupMemberInviteReqVO(); | |
| 300 | + reqVO.setGroupId(10L); | |
| 301 | + reqVO.setMemberUserIds(new ArrayList<>(List.of(600L, 601L))); | |
| 302 | + // 被邀请人都是好友 | |
| 303 | + when(friendService.getActiveFriendList(eq(1L), anyCollection())).thenReturn(List.of( | |
| 304 | + ImFriendDO.builder().userId(1L).friendUserId(600L) | |
| 305 | + .status(CommonStatusEnum.ENABLE.getStatus()).build(), | |
| 306 | + ImFriendDO.builder().userId(1L).friendUserId(601L) | |
| 307 | + .status(CommonStatusEnum.ENABLE.getStatus()).build() | |
| 308 | + )); | |
| 309 | + | |
| 310 | + ServiceException exception = assertThrows(ServiceException.class, | |
| 311 | + () -> groupService.inviteGroupMember(1L, reqVO)); | |
| 312 | + assertEquals(GROUP_MEMBER_EXCEED.getCode(), exception.getCode()); | |
| 313 | + // 断言:不加成员、不推送 | |
| 314 | + verify(groupMemberService, never()).addGroupMembers(anyLong(), anyCollection()); | |
| 315 | + } | |
| 316 | + } | |
| 317 | + | |
| 318 | + @Test | |
| 282 | 319 | public void testInviteGroupMember_skipsMembersAlreadyInGroup() { |
| 283 | 320 | try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) { |
| 284 | 321 | springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) |
| ... | ... | @@ -439,34 +476,43 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { |
| 439 | 476 | } |
| 440 | 477 | } |
| 441 | 478 | |
| 442 | - // ========== getActiveGroupList ========== | |
| 479 | + // ========== getMyGroupList ========== | |
| 443 | 480 | |
| 444 | 481 | @Test |
| 445 | - public void testGetActiveGroupList_noMembers() { | |
| 446 | - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of()); | |
| 482 | + public void testGetMyGroupList_noMembers() { | |
| 483 | + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>()); | |
| 484 | + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) | |
| 485 | + .thenReturn(new ArrayList<>()); | |
| 447 | 486 | |
| 448 | - List<ImGroupDO> result = groupService.getActiveGroupList(1L); | |
| 487 | + List<ImGroupDO> result = groupService.getMyGroupList(1L); | |
| 449 | 488 | assertTrue(result.isEmpty()); |
| 450 | - verify(groupMapper, never()).selectListByIds(anyCollection(), any(), any()); | |
| 489 | + verify(groupMapper, never()).selectByIds(anyCollection()); | |
| 451 | 490 | } |
| 452 | 491 | |
| 453 | 492 | @Test |
| 454 | - public void testGetActiveGroupList_success() { | |
| 455 | - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of( | |
| 493 | + public void testGetMyGroupList_success() { | |
| 494 | + // 活跃群成员 | |
| 495 | + when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>(List.of( | |
| 456 | 496 | ImGroupMemberDO.builder().groupId(10L).userId(1L) |
| 457 | 497 | .status(CommonStatusEnum.ENABLE.getStatus()).build(), |
| 458 | 498 | ImGroupMemberDO.builder().groupId(20L).userId(1L) |
| 459 | 499 | .status(CommonStatusEnum.ENABLE.getStatus()).build() |
| 460 | - )); | |
| 500 | + ))); | |
| 501 | + // 最近退群成员(最近 30 天内) | |
| 502 | + when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) | |
| 503 | + .thenReturn(new ArrayList<>(List.of( | |
| 504 | + ImGroupMemberDO.builder().groupId(30L).userId(1L) | |
| 505 | + .status(CommonStatusEnum.DISABLE.getStatus()).build() | |
| 506 | + ))); | |
| 461 | 507 | List<ImGroupDO> groups = List.of( |
| 462 | 508 | ImGroupDO.builder().id(10L).status(CommonStatusEnum.ENABLE.getStatus()).build(), |
| 463 | - ImGroupDO.builder().id(20L).status(CommonStatusEnum.ENABLE.getStatus()).build() | |
| 509 | + ImGroupDO.builder().id(20L).status(CommonStatusEnum.ENABLE.getStatus()).build(), | |
| 510 | + ImGroupDO.builder().id(30L).status(CommonStatusEnum.ENABLE.getStatus()).build() | |
| 464 | 511 | ); |
| 465 | - when(groupMapper.selectListByIds(anyCollection(), eq(CommonStatusEnum.ENABLE.getStatus()), eq(false))) | |
| 466 | - .thenReturn(groups); | |
| 512 | + when(groupMapper.selectByIds(anyCollection())).thenReturn(groups); | |
| 467 | 513 | |
| 468 | - List<ImGroupDO> result = groupService.getActiveGroupList(1L); | |
| 469 | - assertEquals(2, result.size()); | |
| 514 | + List<ImGroupDO> result = groupService.getMyGroupList(1L); | |
| 515 | + assertEquals(3, result.size()); | |
| 470 | 516 | } |
| 471 | 517 | |
| 472 | 518 | } | ... | ... |
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; |
| 4 | 4 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; |
| 5 | 5 | import cn.iocoder.yudao.framework.common.exception.ServiceException; |
| 6 | 6 | import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; |
| 7 | +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageListReqVO; | |
| 7 | 8 | import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; |
| 8 | 9 | import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; |
| 9 | 10 | import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; |
| ... | ... | @@ -12,12 +13,14 @@ import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper; |
| 12 | 13 | import cn.iocoder.yudao.module.im.dal.redis.message.GroupMessageReadRedisDAO; |
| 13 | 14 | import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; |
| 14 | 15 | import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; |
| 16 | +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; | |
| 15 | 17 | import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; |
| 16 | 18 | import cn.iocoder.yudao.module.im.service.group.ImGroupService; |
| 17 | 19 | import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; |
| 18 | 20 | import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; |
| 19 | 21 | import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; |
| 20 | 22 | import org.junit.jupiter.api.Test; |
| 23 | +import org.mockito.ArgumentCaptor; | |
| 21 | 24 | import org.mockito.InjectMocks; |
| 22 | 25 | import org.mockito.Mock; |
| 23 | 26 | import org.mockito.MockedStatic; |
| ... | ... | @@ -27,6 +30,7 @@ import java.util.Collection; |
| 27 | 30 | import java.util.HashMap; |
| 28 | 31 | import java.util.List; |
| 29 | 32 | import java.util.Map; |
| 33 | +import java.util.Set; | |
| 30 | 34 | |
| 31 | 35 | import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; |
| 32 | 36 | import static org.junit.jupiter.api.Assertions.*; |
| ... | ... | @@ -86,14 +90,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 86 | 90 | .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); |
| 87 | 91 | when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); |
| 88 | 92 | |
| 89 | - List<ImGroupMemberDO> allMembers = List.of( | |
| 90 | - member, | |
| 91 | - ImGroupMemberDO.builder().groupId(10L).userId(2L) | |
| 92 | - .status(CommonStatusEnum.ENABLE.getStatus()).build(), | |
| 93 | - ImGroupMemberDO.builder().groupId(10L).userId(3L) | |
| 94 | - .status(CommonStatusEnum.ENABLE.getStatus()).build() | |
| 95 | - ); | |
| 96 | - when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(allMembers); | |
| 93 | + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) | |
| 94 | + .thenReturn(List.of(1L, 2L, 3L)); | |
| 97 | 95 | when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenAnswer(invocation -> { |
| 98 | 96 | ImGroupMessageDO msg = invocation.getArgument(0); |
| 99 | 97 | msg.setId(99L); |
| ... | ... | @@ -560,12 +558,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 560 | 558 | when(groupMessageMapper.updateById(any(ImGroupMessageDO.class))).thenReturn(1); |
| 561 | 559 | when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenReturn(1); |
| 562 | 560 | |
| 563 | - List<ImGroupMemberDO> members = List.of( | |
| 564 | - selfMember, | |
| 565 | - ImGroupMemberDO.builder().groupId(10L).userId(2L) | |
| 566 | - .status(CommonStatusEnum.ENABLE.getStatus()).build() | |
| 567 | - ); | |
| 568 | - when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(members); | |
| 561 | + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) | |
| 562 | + .thenReturn(List.of(1L, 2L)); | |
| 569 | 563 | |
| 570 | 564 | // 调用 |
| 571 | 565 | ImGroupMessageDO result = groupMessageService.recallGroupMessage(1L, 50L); |
| ... | ... | @@ -709,4 +703,361 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 709 | 703 | assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); |
| 710 | 704 | } |
| 711 | 705 | |
| 706 | + // ========== 发送:补充边界 ========== | |
| 707 | + | |
| 708 | + @Test | |
| 709 | + public void testSendMessage_receiptPending() { | |
| 710 | + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) { | |
| 711 | + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) | |
| 712 | + .thenReturn(groupMessageService); | |
| 713 | + | |
| 714 | + // 准备:reqVO.receipt=true | |
| 715 | + ImGroupMessageSendReqVO reqVO = buildSendReqVO(); | |
| 716 | + reqVO.setReceipt(true); | |
| 717 | + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) | |
| 718 | + .thenReturn(null); | |
| 719 | + ImGroupDO group = new ImGroupDO(); | |
| 720 | + group.setId(10L); | |
| 721 | + when(groupService.validateGroupExists(10L)).thenReturn(group); | |
| 722 | + ImGroupMemberDO member = ImGroupMemberDO.builder() | |
| 723 | + .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); | |
| 724 | + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); | |
| 725 | + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L)); | |
| 726 | + when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenReturn(1); | |
| 727 | + | |
| 728 | + // 调用 | |
| 729 | + ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO); | |
| 730 | + | |
| 731 | + // 断言:receipt=true → 回执状态为 PENDING | |
| 732 | + assertEquals(ImGroupMessageReceiptStatusEnum.PENDING.getStatus(), result.getReceiptStatus()); | |
| 733 | + } | |
| 734 | + } | |
| 735 | + | |
| 736 | + @Test | |
| 737 | + public void testSendMessage_sensitiveWordBlocked() { | |
| 738 | + // 准备:文本消息命中敏感词 | |
| 739 | + ImGroupMessageSendReqVO reqVO = buildSendReqVO(); | |
| 740 | + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) | |
| 741 | + .thenReturn(null); | |
| 742 | + ImGroupDO group = new ImGroupDO(); | |
| 743 | + group.setId(10L); | |
| 744 | + when(groupService.validateGroupExists(10L)).thenReturn(group); | |
| 745 | + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( | |
| 746 | + ImGroupMemberDO.builder().groupId(10L).userId(1L) | |
| 747 | + .status(CommonStatusEnum.ENABLE.getStatus()).build()); | |
| 748 | + doThrow(new ServiceException(MESSAGE_SENSITIVE_WORD_BLOCKED)) | |
| 749 | + .when(sensitiveWordService).validateText(reqVO.getContent()); | |
| 750 | + | |
| 751 | + // 调用并断言 | |
| 752 | + ServiceException exception = assertThrows(ServiceException.class, | |
| 753 | + () -> groupMessageService.sendGroupMessage(1L, reqVO)); | |
| 754 | + assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode()); | |
| 755 | + // 断言:不入库、不推送 | |
| 756 | + verify(groupMessageMapper, never()).insert(any(ImGroupMessageDO.class)); | |
| 757 | + verify(imWebSocketService, never()).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); | |
| 758 | + } | |
| 759 | + | |
| 760 | + // ========== 撤回:补充边界 ========== | |
| 761 | + | |
| 762 | + @Test | |
| 763 | + public void testRecallMessage_notExists() { | |
| 764 | + when(groupMessageMapper.selectById(50L)).thenReturn(null); | |
| 765 | + | |
| 766 | + ServiceException exception = assertThrows(ServiceException.class, | |
| 767 | + () -> groupMessageService.recallGroupMessage(1L, 50L)); | |
| 768 | + assertEquals(MESSAGE_NOT_EXISTS.getCode(), exception.getCode()); | |
| 769 | + } | |
| 770 | + | |
| 771 | + @Test | |
| 772 | + public void testRecallMessage_senderNotInGroup() { | |
| 773 | + // 准备:消息合法、可撤回时间内,但发送人已退群 | |
| 774 | + ImGroupMessageDO message = ImGroupMessageDO.builder() | |
| 775 | + .id(50L).senderId(1L).groupId(10L) | |
| 776 | + .status(ImMessageStatusEnum.UNREAD.getStatus()) | |
| 777 | + .sendTime(LocalDateTime.now()).build(); | |
| 778 | + when(groupMessageMapper.selectById(50L)).thenReturn(message); | |
| 779 | + when(groupMemberService.validateMemberInGroup(10L, 1L)) | |
| 780 | + .thenThrow(new ServiceException(GROUP_MEMBER_NOT_IN_GROUP.getCode(), GROUP_MEMBER_NOT_IN_GROUP.getMsg())); | |
| 781 | + | |
| 782 | + ServiceException exception = assertThrows(ServiceException.class, | |
| 783 | + () -> groupMessageService.recallGroupMessage(1L, 50L)); | |
| 784 | + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); | |
| 785 | + // 断言:不执行更新、不插 tip | |
| 786 | + verify(groupMessageMapper, never()).updateById(any(ImGroupMessageDO.class)); | |
| 787 | + verify(groupMessageMapper, never()).insert(any(ImGroupMessageDO.class)); | |
| 788 | + } | |
| 789 | + | |
| 790 | + // ========== 已读:happy path + 短路 ========== | |
| 791 | + | |
| 792 | + @Test | |
| 793 | + public void testReadGroupMessages_success() { | |
| 794 | + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) { | |
| 795 | + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) | |
| 796 | + .thenReturn(groupMessageService); | |
| 797 | + | |
| 798 | + // 准备:用户在群;Redis 游标从 5 前进到 100 | |
| 799 | + ImGroupMemberDO member = ImGroupMemberDO.builder() | |
| 800 | + .groupId(10L).userId(1L) | |
| 801 | + .status(CommonStatusEnum.ENABLE.getStatus()).build(); | |
| 802 | + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); | |
| 803 | + when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(5L); | |
| 804 | + // readGroupMessageEvent 内部会调 selectListByGroupIdAndPendingReceipt → 返回空简化流程 | |
| 805 | + when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 5L, 100L)).thenReturn(List.of()); | |
| 806 | + | |
| 807 | + // 调用 | |
| 808 | + groupMessageService.readGroupMessages(1L, 10L, 100L); | |
| 809 | + | |
| 810 | + // 断言:Redis 游标更新 + READ 事件 | |
| 811 | + verify(groupMessageReadRedisDAO).updateReadMaxMessageId(10L, 1L, 100L); | |
| 812 | + verify(imWebSocketService).sendGroupMessageAsync(eq(1L), any(ImGroupMessageDTO.class)); | |
| 813 | + } | |
| 814 | + } | |
| 815 | + | |
| 816 | + @Test | |
| 817 | + public void testReadGroupMessages_cursorAlreadyAhead() { | |
| 818 | + // 准备:已读游标已 >= 目标,直接短路返回 | |
| 819 | + ImGroupMemberDO member = ImGroupMemberDO.builder() | |
| 820 | + .groupId(10L).userId(1L) | |
| 821 | + .status(CommonStatusEnum.ENABLE.getStatus()).build(); | |
| 822 | + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); | |
| 823 | + when(groupMessageReadRedisDAO.getReadMaxMessageId(10L, 1L)).thenReturn(200L); | |
| 824 | + | |
| 825 | + // 调用 | |
| 826 | + groupMessageService.readGroupMessages(1L, 10L, 100L); | |
| 827 | + | |
| 828 | + // 断言:不更新、不推送 | |
| 829 | + verify(groupMessageReadRedisDAO, never()).updateReadMaxMessageId(anyLong(), anyLong(), anyLong()); | |
| 830 | + verify(imWebSocketService, never()).sendGroupMessageAsync(anyLong(), any(ImGroupMessageDTO.class)); | |
| 831 | + } | |
| 832 | + | |
| 833 | + // ========== 回执:DONE 状态迁移 ========== | |
| 834 | + | |
| 835 | + @Test | |
| 836 | + public void testReadGroupMessageEvent_receiptDoneTransition() { | |
| 837 | + // 准备:消息 100 发送者 5,群 10 活跃成员 {5,1,2},可见用户中除发送者外 {1,2} | |
| 838 | + // Redis 位置 1→100、2→100 → 全部已读 → 迁移为 DONE | |
| 839 | + ImGroupMessageDO pending = ImGroupMessageDO.builder() | |
| 840 | + .id(100L).groupId(10L).senderId(5L) | |
| 841 | + .sendTime(LocalDateTime.now().minusMinutes(1)) | |
| 842 | + .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) | |
| 843 | + .status(ImMessageStatusEnum.UNREAD.getStatus()).build(); | |
| 844 | + when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 0L, 100L)) | |
| 845 | + .thenReturn(List.of(pending)); | |
| 846 | + List<ImGroupMemberDO> activeMembers = List.of( | |
| 847 | + ImGroupMemberDO.builder().groupId(10L).userId(5L) | |
| 848 | + .status(CommonStatusEnum.ENABLE.getStatus()) | |
| 849 | + .joinTime(LocalDateTime.now().minusDays(10)).build(), | |
| 850 | + ImGroupMemberDO.builder().groupId(10L).userId(1L) | |
| 851 | + .status(CommonStatusEnum.ENABLE.getStatus()) | |
| 852 | + .joinTime(LocalDateTime.now().minusDays(10)).build(), | |
| 853 | + ImGroupMemberDO.builder().groupId(10L).userId(2L) | |
| 854 | + .status(CommonStatusEnum.ENABLE.getStatus()) | |
| 855 | + .joinTime(LocalDateTime.now().minusDays(10)).build() | |
| 856 | + ); | |
| 857 | + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(activeMembers); | |
| 858 | + Map<Long, Long> positions = new HashMap<>(); | |
| 859 | + positions.put(1L, 100L); | |
| 860 | + positions.put(2L, 100L); | |
| 861 | + when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions); | |
| 862 | + | |
| 863 | + // 调用 | |
| 864 | + groupMessageService.readGroupMessageEvent(1L, 10L, 0L, 100L); | |
| 865 | + | |
| 866 | + // 断言:消息回执状态更新为 DONE | |
| 867 | + ArgumentCaptor<ImGroupMessageDO> captor = ArgumentCaptor.forClass(ImGroupMessageDO.class); | |
| 868 | + verify(groupMessageMapper).updateById(captor.capture()); | |
| 869 | + assertEquals(100L, captor.getValue().getId()); | |
| 870 | + assertEquals(ImGroupMessageReceiptStatusEnum.DONE.getStatus(), captor.getValue().getReceiptStatus()); | |
| 871 | + // 断言:给消息发送方(5)推送 RECEIPT 事件 | |
| 872 | + verify(imWebSocketService).sendGroupMessageAsync(eq(5L), any(ImGroupMessageDTO.class)); | |
| 873 | + } | |
| 874 | + | |
| 875 | + @Test | |
| 876 | + public void testReadGroupMessageEvent_receiptStaysPendingWhenPartialRead() { | |
| 877 | + // 准备:用户 2 还没读到 100 → 仍为 PENDING,不更新 DB | |
| 878 | + ImGroupMessageDO pending = ImGroupMessageDO.builder() | |
| 879 | + .id(100L).groupId(10L).senderId(5L) | |
| 880 | + .sendTime(LocalDateTime.now().minusMinutes(1)) | |
| 881 | + .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) | |
| 882 | + .status(ImMessageStatusEnum.UNREAD.getStatus()).build(); | |
| 883 | + when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 0L, 100L)) | |
| 884 | + .thenReturn(List.of(pending)); | |
| 885 | + List<ImGroupMemberDO> activeMembers = List.of( | |
| 886 | + ImGroupMemberDO.builder().groupId(10L).userId(5L) | |
| 887 | + .status(CommonStatusEnum.ENABLE.getStatus()) | |
| 888 | + .joinTime(LocalDateTime.now().minusDays(10)).build(), | |
| 889 | + ImGroupMemberDO.builder().groupId(10L).userId(1L) | |
| 890 | + .status(CommonStatusEnum.ENABLE.getStatus()) | |
| 891 | + .joinTime(LocalDateTime.now().minusDays(10)).build(), | |
| 892 | + ImGroupMemberDO.builder().groupId(10L).userId(2L) | |
| 893 | + .status(CommonStatusEnum.ENABLE.getStatus()) | |
| 894 | + .joinTime(LocalDateTime.now().minusDays(10)).build() | |
| 895 | + ); | |
| 896 | + when(groupMemberService.getActiveGroupMemberListByGroupId(10L)).thenReturn(activeMembers); | |
| 897 | + Map<Long, Long> positions = new HashMap<>(); | |
| 898 | + positions.put(1L, 100L); | |
| 899 | + positions.put(2L, 50L); // 还没读到 100 | |
| 900 | + when(groupMessageReadRedisDAO.getReadMaxMessageIdMap(10L)).thenReturn(positions); | |
| 901 | + | |
| 902 | + groupMessageService.readGroupMessageEvent(1L, 10L, 0L, 100L); | |
| 903 | + | |
| 904 | + // 断言:回执状态保持 PENDING,不更新 DB | |
| 905 | + verify(groupMessageMapper, never()).updateById(any(ImGroupMessageDO.class)); | |
| 906 | + // 仍然推送 RECEIPT 事件给发送方(携带已读人数) | |
| 907 | + verify(imWebSocketService).sendGroupMessageAsync(eq(5L), any(ImGroupMessageDTO.class)); | |
| 908 | + } | |
| 909 | + | |
| 910 | + // ========== sendGroupMessageEvent:receiverUserIds 定向过滤 ========== | |
| 911 | + | |
| 912 | + @Test | |
| 913 | + public void testSendGroupMessageEvent_noReceiverUserIds_broadcastsToAllMembers() { | |
| 914 | + // 准备:无定向 → 应发给所有 ENABLE 成员(含发送者自己,多端同步) | |
| 915 | + ImGroupMessageDO message = ImGroupMessageDO.builder() | |
| 916 | + .id(100L).groupId(10L).senderId(1L) | |
| 917 | + .sendTime(LocalDateTime.now()).build(); | |
| 918 | + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) | |
| 919 | + .thenReturn(List.of(1L, 2L, 3L, 4L)); | |
| 920 | + | |
| 921 | + groupMessageService.sendGroupMessageEvent(message); | |
| 922 | + | |
| 923 | + verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection<Long> ids) -> | |
| 924 | + ids.size() == 4 && ids.contains(1L) && ids.contains(2L) | |
| 925 | + && ids.contains(3L) && ids.contains(4L)), | |
| 926 | + any(ImGroupMessageDTO.class)); | |
| 927 | + } | |
| 928 | + | |
| 929 | + @Test | |
| 930 | + public void testSendGroupMessageEvent_withReceiverUserIds_onlyTargetedPlusSender() { | |
| 931 | + // 准备:定向给 {2,3},发送者 1;群成员 {1,2,3,4} | |
| 932 | + // 预期推送目标:{1,2,3}(发送者自己多端同步 + 定向列表);4 被过滤 | |
| 933 | + ImGroupMessageDO message = ImGroupMessageDO.builder() | |
| 934 | + .id(100L).groupId(10L).senderId(1L) | |
| 935 | + .receiverUserIds(List.of(2L, 3L)) | |
| 936 | + .sendTime(LocalDateTime.now()).build(); | |
| 937 | + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) | |
| 938 | + .thenReturn(List.of(1L, 2L, 3L, 4L)); | |
| 939 | + | |
| 940 | + groupMessageService.sendGroupMessageEvent(message); | |
| 941 | + | |
| 942 | + verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection<Long> ids) -> | |
| 943 | + ids.size() == 3 && ids.contains(1L) && ids.contains(2L) | |
| 944 | + && ids.contains(3L) && !ids.contains(4L)), | |
| 945 | + any(ImGroupMessageDTO.class)); | |
| 946 | + } | |
| 947 | + | |
| 948 | + @Test | |
| 949 | + public void testSendGroupMessageEvent_withReceiverUserIds_senderNotInGroup_senderStillExcluded() { | |
| 950 | + // 边界:发送者不在群的 userId 列表里(理论上应先被 validateMemberInGroup 挡住,这里纯防御) | |
| 951 | + // 预期:仅定向用户可见 | |
| 952 | + ImGroupMessageDO message = ImGroupMessageDO.builder() | |
| 953 | + .id(100L).groupId(10L).senderId(99L) | |
| 954 | + .receiverUserIds(List.of(2L, 3L)) | |
| 955 | + .sendTime(LocalDateTime.now()).build(); | |
| 956 | + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) | |
| 957 | + .thenReturn(List.of(1L, 2L, 3L, 4L)); | |
| 958 | + | |
| 959 | + groupMessageService.sendGroupMessageEvent(message); | |
| 960 | + | |
| 961 | + verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection<Long> ids) -> | |
| 962 | + ids.size() == 2 && ids.contains(2L) && ids.contains(3L)), | |
| 963 | + any(ImGroupMessageDTO.class)); | |
| 964 | + } | |
| 965 | + | |
| 966 | + // ========== sendTipGroupMessage ========== | |
| 967 | + | |
| 968 | + @Test | |
| 969 | + public void testSendTipGroupMessage_success() { | |
| 970 | + // 准备 | |
| 971 | + Set<Long> targets = Set.of(1L, 2L, 3L); | |
| 972 | + | |
| 973 | + // 调用 | |
| 974 | + groupMessageService.sendTipGroupMessage(1L, 10L, targets, "张三加入了群聊"); | |
| 975 | + | |
| 976 | + // 断言:插入一条 TIP_TEXT 提示消息 | |
| 977 | + ArgumentCaptor<ImGroupMessageDO> msgCaptor = ArgumentCaptor.forClass(ImGroupMessageDO.class); | |
| 978 | + verify(groupMessageMapper).insert(msgCaptor.capture()); | |
| 979 | + ImGroupMessageDO tip = msgCaptor.getValue(); | |
| 980 | + assertEquals(1L, tip.getSenderId()); | |
| 981 | + assertEquals(10L, tip.getGroupId()); | |
| 982 | + assertEquals(ImMessageTypeEnum.TIP_TEXT.getType(), tip.getType()); | |
| 983 | + assertEquals("张三加入了群聊", tip.getContent()); | |
| 984 | + assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), tip.getStatus()); | |
| 985 | + assertEquals(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), tip.getReceiptStatus()); | |
| 986 | + assertNotNull(tip.getClientMessageId()); | |
| 987 | + assertNotNull(tip.getSendTime()); | |
| 988 | + // 断言:推送给指定用户集合 | |
| 989 | + verify(imWebSocketService).sendGroupMessageAsync(eq(targets), any(ImGroupMessageDTO.class)); | |
| 990 | + } | |
| 991 | + | |
| 992 | + // ========== getGroupMessageList ========== | |
| 993 | + | |
| 994 | + @Test | |
| 995 | + public void testGetGroupMessageList_delegatesToMapperWithJoinTime() { | |
| 996 | + // 准备 | |
| 997 | + LocalDateTime joinTime = LocalDateTime.now().minusDays(5); | |
| 998 | + ImGroupMemberDO member = ImGroupMemberDO.builder() | |
| 999 | + .id(50L).groupId(10L).userId(1L) | |
| 1000 | + .status(CommonStatusEnum.ENABLE.getStatus()) | |
| 1001 | + .joinTime(joinTime).build(); | |
| 1002 | + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); | |
| 1003 | + | |
| 1004 | + ImGroupMessageListReqVO reqVO = new ImGroupMessageListReqVO(); | |
| 1005 | + reqVO.setGroupId(10L); | |
| 1006 | + reqVO.setMaxId(100L); | |
| 1007 | + reqVO.setLimit(20); | |
| 1008 | + | |
| 1009 | + List<ImGroupMessageDO> mockList = List.of( | |
| 1010 | + ImGroupMessageDO.builder().id(99L).groupId(10L).senderId(2L).build() | |
| 1011 | + ); | |
| 1012 | + when(groupMessageMapper.selectHistoryList(10L, 100L, 20, joinTime)).thenReturn(mockList); | |
| 1013 | + | |
| 1014 | + // 调用 | |
| 1015 | + List<ImGroupMessageDO> result = groupMessageService.getGroupMessageList(1L, reqVO); | |
| 1016 | + | |
| 1017 | + // 断言:使用成员的 joinTime 作为历史消息的下限 | |
| 1018 | + assertEquals(1, result.size()); | |
| 1019 | + verify(groupMessageMapper).selectHistoryList(10L, 100L, 20, joinTime); | |
| 1020 | + } | |
| 1021 | + | |
| 1022 | + @Test | |
| 1023 | + public void testGetGroupMessageList_notInGroup() { | |
| 1024 | + // 准备:用户不在群中 | |
| 1025 | + ImGroupMessageListReqVO reqVO = new ImGroupMessageListReqVO(); | |
| 1026 | + reqVO.setGroupId(10L); | |
| 1027 | + reqVO.setLimit(20); | |
| 1028 | + when(groupMemberService.validateMemberInGroup(10L, 1L)) | |
| 1029 | + .thenThrow(new ServiceException(GROUP_MEMBER_NOT_IN_GROUP.getCode(), | |
| 1030 | + GROUP_MEMBER_NOT_IN_GROUP.getMsg())); | |
| 1031 | + | |
| 1032 | + // 调用并断言:鉴权失败 | |
| 1033 | + ServiceException exception = assertThrows(ServiceException.class, | |
| 1034 | + () -> groupMessageService.getGroupMessageList(1L, reqVO)); | |
| 1035 | + assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); | |
| 1036 | + // 断言:不查询 DB | |
| 1037 | + verify(groupMessageMapper, never()).selectHistoryList(anyLong(), anyLong(), anyInt(), any()); | |
| 1038 | + } | |
| 1039 | + | |
| 1040 | + // ========== delete read cursor 委托 ========== | |
| 1041 | + | |
| 1042 | + @Test | |
| 1043 | + public void testDeleteReadMaxMessageId_delegatesToRedis() { | |
| 1044 | + groupMessageService.deleteReadMaxMessageId(10L, 1L); | |
| 1045 | + | |
| 1046 | + verify(groupMessageReadRedisDAO).deleteReadMaxMessageId(10L, 1L); | |
| 1047 | + } | |
| 1048 | + | |
| 1049 | + @Test | |
| 1050 | + public void testDeleteReadMaxMessageIds_delegatesToRedis() { | |
| 1051 | + groupMessageService.deleteReadMaxMessageIds(10L, List.of(1L, 2L)); | |
| 1052 | + | |
| 1053 | + verify(groupMessageReadRedisDAO).deleteReadMaxMessageIds(10L, List.of(1L, 2L)); | |
| 1054 | + } | |
| 1055 | + | |
| 1056 | + @Test | |
| 1057 | + public void testDeleteReadMaxMessageIdMap_delegatesToRedis() { | |
| 1058 | + groupMessageService.deleteReadMaxMessageIdMap(10L); | |
| 1059 | + | |
| 1060 | + verify(groupMessageReadRedisDAO).deleteReadMaxMessageIdMap(10L); | |
| 1061 | + } | |
| 1062 | + | |
| 712 | 1063 | } | ... | ... |
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; |
| 2 | 2 | |
| 3 | 3 | import cn.iocoder.yudao.framework.common.exception.ServiceException; |
| 4 | 4 | import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; |
| 5 | +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageListReqVO; | |
| 5 | 6 | import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO; |
| 6 | 7 | import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; |
| 7 | 8 | import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper; |
| ... | ... | @@ -245,4 +246,111 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { |
| 245 | 246 | assertEquals(MESSAGE_ALREADY_RECALLED.getCode(), exception.getCode()); |
| 246 | 247 | } |
| 247 | 248 | |
| 249 | + @Test | |
| 250 | + public void testRecallMessage_notExists() { | |
| 251 | + // 准备 | |
| 252 | + when(privateMessageMapper.selectById(10L)).thenReturn(null); | |
| 253 | + | |
| 254 | + // 调用并断言 | |
| 255 | + ServiceException exception = assertThrows(ServiceException.class, | |
| 256 | + () -> privateMessageService.recallPrivateMessage(1L, 10L)); | |
| 257 | + assertEquals(MESSAGE_NOT_EXISTS.getCode(), exception.getCode()); | |
| 258 | + } | |
| 259 | + | |
| 260 | + @Test | |
| 261 | + public void testRecallMessage_timeout() { | |
| 262 | + // 准备:消息发送于 10 分钟前(超过 5 分钟窗口) | |
| 263 | + ImPrivateMessageDO message = ImPrivateMessageDO.builder() | |
| 264 | + .id(10L).senderId(1L).receiverId(2L) | |
| 265 | + .status(ImMessageStatusEnum.UNREAD.getStatus()) | |
| 266 | + .sendTime(LocalDateTime.now().minusMinutes(10)).build(); | |
| 267 | + when(privateMessageMapper.selectById(10L)).thenReturn(message); | |
| 268 | + | |
| 269 | + // 调用并断言 | |
| 270 | + ServiceException exception = assertThrows(ServiceException.class, | |
| 271 | + () -> privateMessageService.recallPrivateMessage(1L, 10L)); | |
| 272 | + assertEquals(MESSAGE_RECALL_TIMEOUT.getCode(), exception.getCode()); | |
| 273 | + // 断言:不推送、不插 tipMessage | |
| 274 | + verify(privateMessageMapper, never()).insert(any(ImPrivateMessageDO.class)); | |
| 275 | + } | |
| 276 | + | |
| 277 | + @Test | |
| 278 | + public void testSendMessage_sensitiveWordBlocked() { | |
| 279 | + // 准备:文本消息命中敏感词 | |
| 280 | + ImPrivateMessageSendReqVO reqVO = buildSendReqVO(); | |
| 281 | + when(privateMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-001")) | |
| 282 | + .thenReturn(null); | |
| 283 | + when(friendService.isFriend(1L, 2L)).thenReturn(true); | |
| 284 | + doThrow(new ServiceException(MESSAGE_SENSITIVE_WORD_BLOCKED)) | |
| 285 | + .when(sensitiveWordService).validateText(reqVO.getContent()); | |
| 286 | + | |
| 287 | + // 调用并断言 | |
| 288 | + ServiceException exception = assertThrows(ServiceException.class, | |
| 289 | + () -> privateMessageService.sendPrivateMessage(1L, reqVO)); | |
| 290 | + assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode()); | |
| 291 | + // 断言:不入库、不推送 | |
| 292 | + verify(privateMessageMapper, never()).insert(any(ImPrivateMessageDO.class)); | |
| 293 | + verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); | |
| 294 | + } | |
| 295 | + | |
| 296 | + @Test | |
| 297 | + public void testReadMessages_noUnread() { | |
| 298 | + // 准备:没有未读消息 | |
| 299 | + when(privateMessageMapper.selectListBySenderIdAndReceiverIdAndStatus(2L, 1L, | |
| 300 | + ImMessageStatusEnum.UNREAD.getStatus())).thenReturn(List.of()); | |
| 301 | + | |
| 302 | + // 调用 | |
| 303 | + privateMessageService.readPrivateMessages(1L, 2L); | |
| 304 | + | |
| 305 | + // 断言:不更新、不推送 | |
| 306 | + verify(privateMessageMapper, never()).updateByIdsAndStatus(anyList(), anyInt(), any(ImPrivateMessageDO.class)); | |
| 307 | + verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); | |
| 308 | + } | |
| 309 | + | |
| 310 | + // ========== sendTipPrivateMessage ========== | |
| 311 | + | |
| 312 | + @Test | |
| 313 | + public void testSendTipPrivateMessage_success() { | |
| 314 | + // 调用 | |
| 315 | + privateMessageService.sendTipPrivateMessage(1L, 2L, "好友添加成功"); | |
| 316 | + | |
| 317 | + // 断言:插入一条 TIP_TEXT 提示消息 | |
| 318 | + ArgumentCaptor<ImPrivateMessageDO> captor = ArgumentCaptor.forClass(ImPrivateMessageDO.class); | |
| 319 | + verify(privateMessageMapper).insert(captor.capture()); | |
| 320 | + ImPrivateMessageDO tip = captor.getValue(); | |
| 321 | + assertEquals(1L, tip.getSenderId()); | |
| 322 | + assertEquals(2L, tip.getReceiverId()); | |
| 323 | + assertEquals(ImMessageTypeEnum.TIP_TEXT.getType(), tip.getType()); | |
| 324 | + assertEquals("好友添加成功", tip.getContent()); | |
| 325 | + assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), tip.getStatus()); | |
| 326 | + assertNotNull(tip.getClientMessageId()); | |
| 327 | + assertNotNull(tip.getSendTime()); | |
| 328 | + // 断言:给双方各推送一次 | |
| 329 | + verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); | |
| 330 | + verify(imWebSocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); | |
| 331 | + } | |
| 332 | + | |
| 333 | + // ========== getPrivateMessageList ========== | |
| 334 | + | |
| 335 | + @Test | |
| 336 | + public void testGetPrivateMessageList_delegatesToMapper() { | |
| 337 | + // 准备 | |
| 338 | + ImPrivateMessageListReqVO reqVO = new ImPrivateMessageListReqVO(); | |
| 339 | + reqVO.setReceiverId(2L); | |
| 340 | + reqVO.setMaxId(100L); | |
| 341 | + reqVO.setLimit(20); | |
| 342 | + List<ImPrivateMessageDO> mockList = List.of( | |
| 343 | + ImPrivateMessageDO.builder().id(99L).senderId(1L).receiverId(2L).build(), | |
| 344 | + ImPrivateMessageDO.builder().id(98L).senderId(2L).receiverId(1L).build() | |
| 345 | + ); | |
| 346 | + when(privateMessageMapper.selectHistoryList(1L, 2L, 100L, 20)).thenReturn(mockList); | |
| 347 | + | |
| 348 | + // 调用 | |
| 349 | + List<ImPrivateMessageDO> result = privateMessageService.getPrivateMessageList(1L, reqVO); | |
| 350 | + | |
| 351 | + // 断言:透传到 mapper,参数一致 | |
| 352 | + assertEquals(2, result.size()); | |
| 353 | + verify(privateMessageMapper).selectHistoryList(1L, 2L, 100L, 20); | |
| 354 | + } | |
| 355 | + | |
| 248 | 356 | } | ... | ... |
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 { |
| 75 | 75 | |
| 76 | 76 | @Test |
| 77 | 77 | public void testRefresh_reloadsFromMapper() { |
| 78 | - // 刷新前:第一次调用 mapper | |
| 79 | - verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); | |
| 78 | + // 刷新前:init 调用 mapper 一次 | |
| 79 | + verify(imSensitiveWordMapper).selectListByStatus(CommonStatusEnum.ENABLE.getStatus()); | |
| 80 | 80 | |
| 81 | 81 | // 准备:新列表不含原词 |
| 82 | 82 | when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus())) | ... | ... |
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 { |
| 137 | 137 | } |
| 138 | 138 | |
| 139 | 139 | @Test |
| 140 | + public void testSendPrivateMessageAsync_exceptionSwallowed() { | |
| 141 | + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) { | |
| 142 | + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) | |
| 143 | + .thenReturn(imWebSocketService); | |
| 144 | + | |
| 145 | + // 准备:sender 抛异常 | |
| 146 | + ImPrivateMessageDTO dto = new ImPrivateMessageDTO().setSenderId(1L).setReceiverId(2L); | |
| 147 | + doThrow(new RuntimeException("user offline")) | |
| 148 | + .when(webSocketMessageSender).sendObject(anyInt(), anyLong(), anyString(), any()); | |
| 149 | + | |
| 150 | + // 调用:异常应被吞掉,不向上抛 | |
| 151 | + imWebSocketService.sendPrivateMessageAsync(2L, dto); | |
| 152 | + | |
| 153 | + verify(webSocketMessageSender).sendObject(anyInt(), eq(2L), anyString(), any()); | |
| 154 | + } | |
| 155 | + } | |
| 156 | + | |
| 157 | + @Test | |
| 140 | 158 | public void testSendGroupMessageAsync_singleUserDefaultOverload() { |
| 141 | 159 | try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) { |
| 142 | 160 | springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) | ... | ... |