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,7 +73,7 @@ public class ImGroupController { | ||
| 73 | @GetMapping("/list") | 73 | @GetMapping("/list") |
| 74 | @Operation(summary = "获得当前登录用户的群列表") | 74 | @Operation(summary = "获得当前登录用户的群列表") |
| 75 | public CommonResult<List<ImGroupRespVO>> getMyGroupList() { | 75 | public CommonResult<List<ImGroupRespVO>> getMyGroupList() { |
| 76 | - List<ImGroupDO> groups = groupService.getActiveGroupList(getLoginUserId()); | 76 | + List<ImGroupDO> groups = groupService.getMyGroupList(getLoginUserId()); |
| 77 | return success(BeanUtils.toBean(groups, ImGroupRespVO.class)); | 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 | package cn.iocoder.yudao.module.im.controller.admin.group; | 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 | import cn.iocoder.yudao.framework.common.pojo.CommonResult; | 5 | import cn.iocoder.yudao.framework.common.pojo.CommonResult; |
| 4 | import cn.iocoder.yudao.framework.common.util.collection.MapUtils; | 6 | import cn.iocoder.yudao.framework.common.util.collection.MapUtils; |
| 5 | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; | 7 | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; |
| @@ -21,9 +23,11 @@ import org.springframework.web.bind.annotation.*; | @@ -21,9 +23,11 @@ import org.springframework.web.bind.annotation.*; | ||
| 21 | import java.util.List; | 23 | import java.util.List; |
| 22 | import java.util.Map; | 24 | import java.util.Map; |
| 23 | 25 | ||
| 26 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | ||
| 24 | import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; | 27 | import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; |
| 25 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; | 28 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; |
| 26 | import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; | 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 | @Tag(name = "管理后台 - 群成员") | 32 | @Tag(name = "管理后台 - 群成员") |
| 29 | @RestController | 33 | @RestController |
| @@ -58,8 +62,17 @@ public class ImGroupMemberController { | @@ -58,8 +62,17 @@ public class ImGroupMemberController { | ||
| 58 | @Operation(summary = "获得指定群的成员列表") | 62 | @Operation(summary = "获得指定群的成员列表") |
| 59 | @Parameter(name = "groupId", description = "群编号", required = true, example = "1024") | 63 | @Parameter(name = "groupId", description = "群编号", required = true, example = "1024") |
| 60 | public CommonResult<List<ImGroupMemberRespVO>> getGroupMemberList(@RequestParam("groupId") Long groupId) { | 64 | public CommonResult<List<ImGroupMemberRespVO>> getGroupMemberList(@RequestParam("groupId") Long groupId) { |
| 65 | + // 1.1 查询群成员列表(包含 DISABLE 已退群的成员,不按时间过滤) | ||
| 66 | + // 说明:保留已退群成员,是为了前端展示历史消息时,仍能通过该接口拿到已退群成员的昵称 / 头像信息,避免显示为空 | ||
| 61 | List<ImGroupMemberDO> members = groupMemberService.getGroupMemberListByGroupId(groupId); | 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 | Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap( | 76 | Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap( |
| 64 | convertList(members, ImGroupMemberDO::getUserId)); | 77 | convertList(members, ImGroupMemberDO::getUserId)); |
| 65 | return success(convertList(members, m -> { | 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 | package cn.iocoder.yudao.module.im.dal.mysql.group; | 1 | package cn.iocoder.yudao.module.im.dal.mysql.group; |
| 2 | 2 | ||
| 3 | import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; | 3 | import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; |
| 4 | -import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; | ||
| 5 | import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; | 4 | import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; |
| 6 | import org.apache.ibatis.annotations.Mapper; | 5 | import org.apache.ibatis.annotations.Mapper; |
| 7 | 6 | ||
| 8 | -import java.util.Collection; | ||
| 9 | -import java.util.List; | ||
| 10 | - | ||
| 11 | /** | 7 | /** |
| 12 | * IM 群 Mapper | 8 | * IM 群 Mapper |
| 13 | * | 9 | * |
| @@ -15,20 +11,4 @@ import java.util.List; | @@ -15,20 +11,4 @@ import java.util.List; | ||
| 15 | */ | 11 | */ |
| 16 | @Mapper | 12 | @Mapper |
| 17 | public interface ImGroupMapper extends BaseMapperX<ImGroupDO> { | 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 | \ No newline at end of file | 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,4 +31,14 @@ public interface RedisKeyConstants { | ||
| 31 | */ | 31 | */ |
| 32 | String GROUP = "group"; | 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,6 +40,18 @@ public interface ImGroupMemberService { | ||
| 40 | List<ImGroupMemberDO> getActiveGroupMemberListByGroupId(Long groupId); | 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 | * 查询用户所在的所有群的有效成员记录(仅 ENABLE 状态) | 55 | * 查询用户所在的所有群的有效成员记录(仅 ENABLE 状态) |
| 44 | * | 56 | * |
| 45 | * @param userId 用户编号 | 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,6 +10,8 @@ import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; | ||
| 10 | import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; | 10 | import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; |
| 11 | import jakarta.annotation.Resource; | 11 | import jakarta.annotation.Resource; |
| 12 | import lombok.extern.slf4j.Slf4j; | 12 | import lombok.extern.slf4j.Slf4j; |
| 13 | +import org.springframework.cache.annotation.CacheEvict; | ||
| 14 | +import org.springframework.cache.annotation.Cacheable; | ||
| 13 | import org.springframework.dao.DuplicateKeyException; | 15 | import org.springframework.dao.DuplicateKeyException; |
| 14 | import org.springframework.stereotype.Service; | 16 | import org.springframework.stereotype.Service; |
| 15 | import org.springframework.validation.annotation.Validated; | 17 | import org.springframework.validation.annotation.Validated; |
| @@ -21,7 +23,9 @@ import java.util.List; | @@ -21,7 +23,9 @@ import java.util.List; | ||
| 21 | import java.util.Map; | 23 | import java.util.Map; |
| 22 | 24 | ||
| 23 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | 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 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; | 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 | import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.GROUP_MEMBER_NOT_IN_GROUP; | 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,6 +59,24 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { | ||
| 55 | return groupMemberMapper.selectListByGroupIdAndStatus(groupId, CommonStatusEnum.ENABLE.getStatus()); | 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 | @Override | 80 | @Override |
| 59 | public List<ImGroupMemberDO> getActiveGroupMemberListByUserId(Long userId) { | 81 | public List<ImGroupMemberDO> getActiveGroupMemberListByUserId(Long userId) { |
| 60 | return groupMemberMapper.selectListByUserIdAndStatus(userId, CommonStatusEnum.ENABLE.getStatus()); | 82 | return groupMemberMapper.selectListByUserIdAndStatus(userId, CommonStatusEnum.ENABLE.getStatus()); |
| @@ -70,6 +92,7 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { | @@ -70,6 +92,7 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { | ||
| 70 | * 当并发 insert 触发 {@link DuplicateKeyException} 时降级为 select + update。 | 92 | * 当并发 insert 触发 {@link DuplicateKeyException} 时降级为 select + update。 |
| 71 | */ | 93 | */ |
| 72 | @Override | 94 | @Override |
| 95 | + @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") | ||
| 73 | public ImGroupMemberDO addGroupMember(Long groupId, Long userId) { | 96 | public ImGroupMemberDO addGroupMember(Long groupId, Long userId) { |
| 74 | LocalDateTime now = LocalDateTime.now(); | 97 | LocalDateTime now = LocalDateTime.now(); |
| 75 | // 情况一:已存在记录 → 复活或跳过 | 98 | // 情况一:已存在记录 → 复活或跳过 |
| @@ -96,6 +119,7 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { | @@ -96,6 +119,7 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { | ||
| 96 | } | 119 | } |
| 97 | 120 | ||
| 98 | @Override | 121 | @Override |
| 122 | + @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") | ||
| 99 | public void addGroupMembers(Long groupId, Collection<Long> userIds) { | 123 | public void addGroupMembers(Long groupId, Collection<Long> userIds) { |
| 100 | LocalDateTime now = LocalDateTime.now(); | 124 | LocalDateTime now = LocalDateTime.now(); |
| 101 | // 1.1 查询已有记录(含已退群的 DISABLE 记录) | 125 | // 1.1 查询已有记录(含已退群的 DISABLE 记录) |
| @@ -158,6 +182,7 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { | @@ -158,6 +182,7 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { | ||
| 158 | } | 182 | } |
| 159 | 183 | ||
| 160 | @Override | 184 | @Override |
| 185 | + @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") | ||
| 161 | public void removeGroupMember(Long groupId, Long userId) { | 186 | public void removeGroupMember(Long groupId, Long userId) { |
| 162 | // 1. 校验是群的有效成员 | 187 | // 1. 校验是群的有效成员 |
| 163 | ImGroupMemberDO member = validateMemberInGroup(groupId, userId); | 188 | ImGroupMemberDO member = validateMemberInGroup(groupId, userId); |
| @@ -168,6 +193,7 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { | @@ -168,6 +193,7 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { | ||
| 168 | } | 193 | } |
| 169 | 194 | ||
| 170 | @Override | 195 | @Override |
| 196 | + @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") | ||
| 171 | public void removeGroupMembers(Long groupId, Collection<Long> userIds) { | 197 | public void removeGroupMembers(Long groupId, Collection<Long> userIds) { |
| 172 | groupMemberMapper.updateByGroupIdAndUserIdsAndStatus(groupId, userIds, | 198 | groupMemberMapper.updateByGroupIdAndUserIdsAndStatus(groupId, userIds, |
| 173 | CommonStatusEnum.ENABLE.getStatus(), | 199 | CommonStatusEnum.ENABLE.getStatus(), |
| @@ -176,6 +202,7 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { | @@ -176,6 +202,7 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { | ||
| 176 | } | 202 | } |
| 177 | 203 | ||
| 178 | @Override | 204 | @Override |
| 205 | + @CacheEvict(cacheNames = GROUP_MEMBER_IDS, key = "#groupId") | ||
| 179 | public void removeGroupMembersByGroupId(Long groupId) { | 206 | public void removeGroupMembersByGroupId(Long groupId) { |
| 180 | groupMemberMapper.updateByGroupIdAndStatus(groupId, CommonStatusEnum.ENABLE.getStatus(), | 207 | groupMemberMapper.updateByGroupIdAndStatus(groupId, CommonStatusEnum.ENABLE.getStatus(), |
| 181 | new ImGroupMemberDO().setStatus(CommonStatusEnum.DISABLE.getStatus()) | 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,14 +77,15 @@ public interface ImGroupService { | ||
| 77 | ImGroupDO validateGroupOwner(Long groupId, Long userId); | 77 | ImGroupDO validateGroupOwner(Long groupId, Long userId); |
| 78 | 78 | ||
| 79 | /** | 79 | /** |
| 80 | - * 获取指定用户加入的所有"有效"群(未退群且未封禁/解散) | 80 | + * 获取指定用户的群列表 |
| 81 | * <p> | 81 | * <p> |
| 82 | - * 返回当前登录用户在群内身份仍有效的所有群。 | 82 | + * 返回用户当前仍有效的群,以及最近 {@link cn.iocoder.yudao.module.im.enums.ImCommonConstants#MESSAGE_GROUP_PULL_MAX_DAYS} |
| 83 | + * 天内退群的群 —— 退群前可能还有离线消息需要展示,前端需要把这些群信息作为缓存。 | ||
| 83 | * | 84 | * |
| 84 | * @param userId 用户编号 | 85 | * @param userId 用户编号 |
| 85 | * @return 群列表 | 86 | * @return 群列表 |
| 86 | */ | 87 | */ |
| 87 | - List<ImGroupDO> getActiveGroupList(Long userId); | 88 | + List<ImGroupDO> getMyGroupList(Long userId); |
| 88 | 89 | ||
| 89 | // ==================== 群成员的写操作 ==================== | 90 | // ==================== 群成员的写操作 ==================== |
| 90 | // 说明:群成员的写操作统一放在 ImGroupService,而非 ImGroupMemberService, | 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,19 +256,19 @@ public class ImGroupServiceImpl implements ImGroupService { | ||
| 256 | } | 256 | } |
| 257 | 257 | ||
| 258 | @Override | 258 | @Override |
| 259 | - public List<ImGroupDO> getActiveGroupList(Long userId) { | ||
| 260 | - // 1. 查用户所在的、仍有效的群成员记录(仅 ENABLE 状态) | 259 | + public List<ImGroupDO> getMyGroupList(Long userId) { |
| 260 | + // 1.1 查用户所在的、仍有效的群成员记录(仅 ENABLE 状态) | ||
| 261 | List<ImGroupMemberDO> members = groupMemberService.getActiveGroupMemberListByUserId(userId); | 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 | if (CollUtil.isEmpty(members)) { | 265 | if (CollUtil.isEmpty(members)) { |
| 263 | return Collections.emptyList(); | 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 | private ImGroupServiceImpl getSelf() { | 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,12 +361,23 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | ||
| 361 | 361 | ||
| 362 | /** | 362 | /** |
| 363 | * 发送群聊消息 WebSocket 事件 | 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 | public void sendGroupMessageEvent(ImGroupMessageDO message) { | 376 | public void sendGroupMessageEvent(ImGroupMessageDO message) { |
| 366 | ImGroupMessageDTO dto = ImGroupMessageDTO.ofSend(message); | 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 | imWebSocketService.sendGroupMessageAsync(targetUserIds, dto); | 381 | imWebSocketService.sendGroupMessageAsync(targetUserIds, dto); |
| 371 | } | 382 | } |
| 372 | 383 | ||
| @@ -466,9 +477,38 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | @@ -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 | @Override | 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,74 +21,6 @@ public class ImGroupMemberMapperTest extends BaseDbUnitTest { | ||
| 21 | @Resource | 21 | @Resource |
| 22 | private ImGroupMemberMapper mapper; | 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 | // ========== selectQuitListByUserId ========== | 24 | // ========== selectQuitListByUserId ========== |
| 93 | 25 | ||
| 94 | @Test | 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,21 +211,6 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { | ||
| 211 | assertEquals(after.getId(), result.get(0).getId()); | 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 | // ========== selectListByGroupIdAndPendingReceipt ========== | 214 | // ========== selectListByGroupIdAndPendingReceipt ========== |
| 230 | 215 | ||
| 231 | @Test | 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,29 +181,6 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest { | ||
| 181 | assertEquals(2L, result.get(0).getSenderId()); | 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 | private ImPrivateMessageDO buildMessage(Long senderId, Long receiverId, ImMessageStatusEnum status) { | 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,6 +130,52 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest { | ||
| 130 | } | 130 | } |
| 131 | 131 | ||
| 132 | @Test | 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 | public void testAddGroupMembers_batchInsertDuplicateFallback() { | 179 | public void testAddGroupMembers_batchInsertDuplicateFallback() { |
| 134 | // 准备:两个新增成员,批量插入失败时降级为逐个 addGroupMember | 180 | // 准备:两个新增成员,批量插入失败时降级为逐个 addGroupMember |
| 135 | when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) | 181 | when(groupMemberMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) |
| @@ -248,4 +294,37 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest { | @@ -248,4 +294,37 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest { | ||
| 248 | assertNotNull(captor.getValue().getQuitTime()); | 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,6 +279,43 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { | ||
| 279 | } | 279 | } |
| 280 | 280 | ||
| 281 | @Test | 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 | public void testInviteGroupMember_skipsMembersAlreadyInGroup() { | 319 | public void testInviteGroupMember_skipsMembersAlreadyInGroup() { |
| 283 | try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) { | 320 | try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) { |
| 284 | springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) | 321 | springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) |
| @@ -439,34 +476,43 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { | @@ -439,34 +476,43 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { | ||
| 439 | } | 476 | } |
| 440 | } | 477 | } |
| 441 | 478 | ||
| 442 | - // ========== getActiveGroupList ========== | 479 | + // ========== getMyGroupList ========== |
| 443 | 480 | ||
| 444 | @Test | 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 | assertTrue(result.isEmpty()); | 488 | assertTrue(result.isEmpty()); |
| 450 | - verify(groupMapper, never()).selectListByIds(anyCollection(), any(), any()); | 489 | + verify(groupMapper, never()).selectByIds(anyCollection()); |
| 451 | } | 490 | } |
| 452 | 491 | ||
| 453 | @Test | 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 | ImGroupMemberDO.builder().groupId(10L).userId(1L) | 496 | ImGroupMemberDO.builder().groupId(10L).userId(1L) |
| 457 | .status(CommonStatusEnum.ENABLE.getStatus()).build(), | 497 | .status(CommonStatusEnum.ENABLE.getStatus()).build(), |
| 458 | ImGroupMemberDO.builder().groupId(20L).userId(1L) | 498 | ImGroupMemberDO.builder().groupId(20L).userId(1L) |
| 459 | .status(CommonStatusEnum.ENABLE.getStatus()).build() | 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 | List<ImGroupDO> groups = List.of( | 507 | List<ImGroupDO> groups = List.of( |
| 462 | ImGroupDO.builder().id(10L).status(CommonStatusEnum.ENABLE.getStatus()).build(), | 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,6 +4,7 @@ import cn.hutool.extra.spring.SpringUtil; | ||
| 4 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; | 4 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; |
| 5 | import cn.iocoder.yudao.framework.common.exception.ServiceException; | 5 | import cn.iocoder.yudao.framework.common.exception.ServiceException; |
| 6 | import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | 6 | import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; |
| 7 | +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageListReqVO; | ||
| 7 | import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; | 8 | import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; |
| 8 | import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; | 9 | import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; |
| 9 | import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; | 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,12 +13,14 @@ import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper; | ||
| 12 | import cn.iocoder.yudao.module.im.dal.redis.message.GroupMessageReadRedisDAO; | 13 | import cn.iocoder.yudao.module.im.dal.redis.message.GroupMessageReadRedisDAO; |
| 13 | import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; | 14 | import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; |
| 14 | import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; | 15 | import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; |
| 16 | +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; | ||
| 15 | import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; | 17 | import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; |
| 16 | import cn.iocoder.yudao.module.im.service.group.ImGroupService; | 18 | import cn.iocoder.yudao.module.im.service.group.ImGroupService; |
| 17 | import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; | 19 | import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; |
| 18 | import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; | 20 | import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; |
| 19 | import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; | 21 | import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; |
| 20 | import org.junit.jupiter.api.Test; | 22 | import org.junit.jupiter.api.Test; |
| 23 | +import org.mockito.ArgumentCaptor; | ||
| 21 | import org.mockito.InjectMocks; | 24 | import org.mockito.InjectMocks; |
| 22 | import org.mockito.Mock; | 25 | import org.mockito.Mock; |
| 23 | import org.mockito.MockedStatic; | 26 | import org.mockito.MockedStatic; |
| @@ -27,6 +30,7 @@ import java.util.Collection; | @@ -27,6 +30,7 @@ import java.util.Collection; | ||
| 27 | import java.util.HashMap; | 30 | import java.util.HashMap; |
| 28 | import java.util.List; | 31 | import java.util.List; |
| 29 | import java.util.Map; | 32 | import java.util.Map; |
| 33 | +import java.util.Set; | ||
| 30 | 34 | ||
| 31 | import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; | 35 | import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; |
| 32 | import static org.junit.jupiter.api.Assertions.*; | 36 | import static org.junit.jupiter.api.Assertions.*; |
| @@ -86,14 +90,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { | @@ -86,14 +90,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { | ||
| 86 | .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); | 90 | .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); |
| 87 | when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); | 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 | when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenAnswer(invocation -> { | 95 | when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenAnswer(invocation -> { |
| 98 | ImGroupMessageDO msg = invocation.getArgument(0); | 96 | ImGroupMessageDO msg = invocation.getArgument(0); |
| 99 | msg.setId(99L); | 97 | msg.setId(99L); |
| @@ -560,12 +558,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { | @@ -560,12 +558,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { | ||
| 560 | when(groupMessageMapper.updateById(any(ImGroupMessageDO.class))).thenReturn(1); | 558 | when(groupMessageMapper.updateById(any(ImGroupMessageDO.class))).thenReturn(1); |
| 561 | when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenReturn(1); | 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 | ImGroupMessageDO result = groupMessageService.recallGroupMessage(1L, 50L); | 565 | ImGroupMessageDO result = groupMessageService.recallGroupMessage(1L, 50L); |
| @@ -709,4 +703,361 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { | @@ -709,4 +703,361 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { | ||
| 709 | assertEquals(GROUP_MEMBER_NOT_IN_GROUP.getCode(), exception.getCode()); | 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,6 +2,7 @@ package cn.iocoder.yudao.module.im.service.message; | ||
| 2 | 2 | ||
| 3 | import cn.iocoder.yudao.framework.common.exception.ServiceException; | 3 | import cn.iocoder.yudao.framework.common.exception.ServiceException; |
| 4 | import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | 4 | import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; |
| 5 | +import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageListReqVO; | ||
| 5 | import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO; | 6 | import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO; |
| 6 | import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; | 7 | import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; |
| 7 | import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper; | 8 | import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper; |
| @@ -245,4 +246,111 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { | @@ -245,4 +246,111 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { | ||
| 245 | assertEquals(MESSAGE_ALREADY_RECALLED.getCode(), exception.getCode()); | 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,8 +75,8 @@ public class ImSensitiveWordServiceImplTest extends BaseMockitoUnitTest { | ||
| 75 | 75 | ||
| 76 | @Test | 76 | @Test |
| 77 | public void testRefresh_reloadsFromMapper() { | 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 | when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus())) | 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,6 +137,24 @@ public class ImWebSocketServiceImplTest extends BaseMockitoUnitTest { | ||
| 137 | } | 137 | } |
| 138 | 138 | ||
| 139 | @Test | 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 | public void testSendGroupMessageAsync_singleUserDefaultOverload() { | 158 | public void testSendGroupMessageAsync_singleUserDefaultOverload() { |
| 141 | try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) { | 159 | try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) { |
| 142 | springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) | 160 | springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) |