Commit 6a10ed7d88126438d12b2fe5967a6e43b90c7a26

Authored by YunaiV
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)))