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