Commit 49241c3123a8ffc5148366b4c6f62a990bd94359

Authored by YunaiV
1 parent f5ef0a89

fix(im): 强化好友关系、消息历史和前端交互

- 校验群资料字段长度,并在同意好友申请时复验双方用户
- 仅向双向有效好友推送资料更新通知
- WebSocket 推送收件人去重,并忽略空用户编号
- 群聊和私聊历史保留撤回消息记录
- 校验群通话排除发起人后仍需存在被邀请人
- 统一 IM 前端接口参数传递方式
- 抽取全局 URL 安全打开工具,并复用到消息预览
- 防止好友申请同意和拒绝按钮重复操作
- 补充好友、消息、RTC、WebSocket 相关测试
Showing 17 changed files with 160 additions and 17 deletions
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupCreateReqVO.java
... ... @@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.im.controller.admin.group.vo;
2 2  
3 3 import io.swagger.v3.oas.annotations.media.Schema;
4 4 import jakarta.validation.constraints.NotBlank;
  5 +import jakarta.validation.constraints.Size;
5 6 import lombok.Data;
6 7  
7 8 import java.util.List;
... ... @@ -12,6 +13,7 @@ public class ImGroupCreateReqVO {
12 13  
13 14 @Schema(description = "群名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋道技术交流群")
14 15 @NotBlank(message = "群名称不能为空")
  16 + @Size(max = 64, message = "群名称长度不能超过 64")
15 17 private String name;
16 18  
17 19 @Schema(description = "初始成员用户编号列表(建群同时邀请的好友,不含创建者自己)", example = "[1024, 2048]")
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupUpdateReqVO.java
1 1 package cn.iocoder.yudao.module.im.controller.admin.group.vo;
2 2  
  3 +import cn.hutool.core.util.StrUtil;
  4 +import com.fasterxml.jackson.annotation.JsonIgnore;
3 5 import io.swagger.v3.oas.annotations.media.Schema;
  6 +import jakarta.validation.constraints.AssertTrue;
4 7 import jakarta.validation.constraints.NotNull;
  8 +import jakarta.validation.constraints.Size;
5 9 import lombok.Data;
6 10  
7 11 @Schema(description = "管理后台 - 群更新 Request VO")
... ... @@ -13,15 +17,30 @@ public class ImGroupUpdateReqVO {
13 17 private Long id;
14 18  
15 19 @Schema(description = "群名称", example = "芋道技术交流群")
  20 + @Size(max = 64, message = "群名称长度不能超过 64")
16 21 private String name;
17 22  
18 23 @Schema(description = "群头像")
  24 + @Size(max = 512, message = "群头像长度不能超过 512")
19 25 private String avatar;
20 26  
21 27 @Schema(description = "群公告")
  28 + @Size(max = 2048, message = "群公告长度不能超过 2048")
22 29 private String notice;
23 30  
24 31 @Schema(description = "进群是否需群主 / 管理员审批", example = "true")
25 32 private Boolean joinApproval;
26 33  
  34 + @AssertTrue(message = "群名称不能为空")
  35 + @JsonIgnore
  36 + public boolean isNameValid() {
  37 + return name == null || StrUtil.isNotBlank(name);
  38 + }
  39 +
  40 + @AssertTrue(message = "群头像不能为空")
  41 + @JsonIgnore
  42 + public boolean isAvatarValid() {
  43 + return avatar == null || StrUtil.isNotBlank(avatar);
  44 + }
  45 +
27 46 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendMapper.java
... ... @@ -47,6 +47,15 @@ public interface ImFriendMapper extends BaseMapperX<ImFriendDO> {
47 47 .eq(ImFriendDO::getStatus, status));
48 48 }
49 49  
  50 + default List<ImFriendDO> selectListByUserIdsAndFriendUserIdAndStatus(Collection<Long> userIds,
  51 + Long friendUserId,
  52 + Integer status) {
  53 + return selectList(new LambdaQueryWrapperX<ImFriendDO>()
  54 + .in(ImFriendDO::getUserId, userIds)
  55 + .eq(ImFriendDO::getFriendUserId, friendUserId)
  56 + .eq(ImFriendDO::getStatus, status));
  57 + }
  58 +
50 59 default PageResult<ImFriendDO> selectPage(ImFriendManagerPageReqVO reqVO) {
51 60 return selectPage(reqVO, new LambdaQueryWrapperX<ImFriendDO>()
52 61 .eqIfPresent(ImFriendDO::getUserId, reqVO.getUserId())
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java
... ... @@ -81,7 +81,6 @@ public interface ImGroupMessageMapper extends BaseMapperX&lt;ImGroupMessageDO&gt; {
81 81 default List<ImGroupMessageDO> selectHistoryList(Long groupId, Long maxId, Integer limit, LocalDateTime joinTime) {
82 82 QueryWrapperX<ImGroupMessageDO> wrapper = new QueryWrapperX<>();
83 83 wrapper.eq("group_id", groupId)
84   - .ne("status", ImMessageStatusEnum.RECALL.getStatus())
85 84 .lt(maxId != null, "id", maxId)
86 85 .ge(joinTime != null, "send_time", joinTime)
87 86 .orderByDesc("id");
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java
... ... @@ -6,7 +6,6 @@ import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
6 6 import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX;
7 7 import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.privates.ImPrivateMessageManagerPageReqVO;
8 8 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
9   -import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
10 9 import org.apache.ibatis.annotations.Mapper;
11 10  
12 11 import java.time.LocalDateTime;
... ... @@ -56,7 +55,6 @@ public interface ImPrivateMessageMapper extends BaseMapperX&lt;ImPrivateMessageDO&gt;
56 55 wrapper.and(w -> w.eq("sender_id", userId).eq("receiver_id", receiverId)
57 56 .or()
58 57 .eq("sender_id", receiverId).eq("receiver_id", userId))
59   - .ne("status", ImMessageStatusEnum.RECALL.getStatus())
60 58 .lt(maxId != null, "id", maxId)
61 59 .orderByDesc("id");
62 60 wrapper.limitN(limit);
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/mq/consumer/friend/AdminUserProfileUpdateConsumer.java
... ... @@ -35,8 +35,8 @@ public class AdminUserProfileUpdateConsumer {
35 35 public void onMessage(AdminUserProfileUpdateMessage message) {
36 36 log.info("[onMessage][消息内容({})]", message);
37 37 Long userId = message.getUserId();
38   - // 1. SQL 层过滤 ENABLE 好友
39   - List<ImFriendDO> friends = friendService.getEnableFriendList(userId);
  38 + // 1. 过滤双向有效好友
  39 + List<ImFriendDO> friends = friendService.getMutualEnableFriendList(userId);
40 40 if (friends.isEmpty()) {
41 41 return;
42 42 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestServiceImpl.java
... ... @@ -143,10 +143,12 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService {
143 143 @Override
144 144 @Transactional(rollbackFor = Exception.class)
145 145 public void agreeFriendRequest(Long userId, Long requestId) {
146   - // 1. 校验申请存在 + 未处理 + 操作人是接收方(fail-fast;并发场景仍由下面的乐观锁兜底)
  146 + // 1.1 校验申请存在、未处理、操作人是接收方
147 147 ImFriendRequestDO request = validateRequestForHandle(userId, requestId);
  148 + // 1.2 复验双方用户有效
  149 + adminUserApi.validateUserList(List.of(request.getFromUserId(), request.getToUserId()));
148 150  
149   - // 2. 乐观锁更新申请:handleResult=AGREED + handleTime;并发同意会有一方 affectedRows=0
  151 + // 2. 乐观锁更新申请处理结果
150 152 ImFriendRequestDO updateObj = new ImFriendRequestDO()
151 153 .setHandleResult(ImFriendRequestHandleResultEnum.AGREED.getResult()).setHandleTime(LocalDateTime.now());
152 154 int affected = friendRequestMapper.updateByIdAndHandleResult(request.getId(),
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java
... ... @@ -48,6 +48,11 @@ public interface ImFriendService {
48 48 List<ImFriendDO> getEnableFriendList(Long userId);
49 49  
50 50 /**
  51 + * 获得当前用户的双向有效好友列表(双方均 ENABLE 状态)
  52 + */
  53 + List<ImFriendDO> getMutualEnableFriendList(Long userId);
  54 +
  55 + /**
51 56 * 获得当前用户与指定用户之间的有效好友列表(仅 ENABLE 状态)
52 57 */
53 58 List<ImFriendDO> getActiveFriendList(Long userId, Collection<Long> friendUserIds);
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java
... ... @@ -31,8 +31,11 @@ import java.time.LocalDateTime;
31 31 import java.util.Collection;
32 32 import java.util.Collections;
33 33 import java.util.List;
  34 +import java.util.Set;
34 35  
35 36 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  37 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
  38 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.filterList;
36 39 import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.FRIEND_STATE;
37 40 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_BLOCKED_BY_PEER;
38 41 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_BLOCKED;
... ... @@ -101,6 +104,24 @@ public class ImFriendServiceImpl implements ImFriendService {
101 104 }
102 105  
103 106 @Override
  107 + public List<ImFriendDO> getMutualEnableFriendList(Long userId) {
  108 + // 1. 查询本端启用好友
  109 + List<ImFriendDO> friends = getEnableFriendList(userId);
  110 + if (CollUtil.isEmpty(friends)) {
  111 + return Collections.emptyList();
  112 + }
  113 +
  114 + // 2. 查询对端启用好友关系
  115 + Set<Long> friendUserIds = convertSet(friends, ImFriendDO::getFriendUserId);
  116 + List<ImFriendDO> mutualFriends = friendMapper.selectListByUserIdsAndFriendUserIdAndStatus(friendUserIds, userId,
  117 + CommonStatusEnum.ENABLE.getStatus());
  118 + Set<Long> mutualUserIds = convertSet(mutualFriends, ImFriendDO::getUserId);
  119 +
  120 + // 3. 过滤双向启用好友
  121 + return filterList(friends, friend -> mutualUserIds.contains(friend.getFriendUserId()));
  122 + }
  123 +
  124 + @Override
104 125 public List<ImFriendDO> getActiveFriendList(Long userId, Collection<Long> friendUserIds) {
105 126 if (CollUtil.isEmpty(friendUserIds)) {
106 127 return Collections.emptyList();
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java
... ... @@ -812,6 +812,9 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
812 812 Set<Long> initial = new LinkedHashSet<>(reqVO.getInviteeIds());
813 813 // 发起人本人不进被邀请池
814 814 initial.remove(inviterId);
  815 + if (CollUtil.isEmpty(initial)) {
  816 + throw exception(RTC_GROUP_INVITEE_REQUIRED);
  817 + }
815 818 int max = imProperties.getRtc().getGroupMaxParticipants();
816 819 if (initial.size() + 1 > max) {
817 820 throw exception(RTC_GROUP_INVITEE_OVER_LIMIT);
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java
... ... @@ -15,6 +15,9 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
15 15 import org.springframework.validation.annotation.Validated;
16 16  
17 17 import java.util.Collection;
  18 +import java.util.Set;
  19 +
  20 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertLinkedSet;
18 21  
19 22 /**
20 23 * IM WebSocket 推送 Service 实现类
... ... @@ -59,7 +62,7 @@ public class ImWebSocketServiceImpl implements ImWebSocketService {
59 62 */
60 63 @Async
61 64 public void doSendPrivateMessage(Collection<Long> userIds, ImPrivateMessageDTO dto) {
62   - for (Long userId : userIds) {
  65 + for (Long userId : getDistinctUserIds(userIds)) {
63 66 try {
64 67 webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
65 68 ImPrivateMessageDTO.TYPE, dto);
... ... @@ -74,7 +77,7 @@ public class ImWebSocketServiceImpl implements ImWebSocketService {
74 77 */
75 78 @Async
76 79 public void doSendGroupMessage(Collection<Long> userIds, ImGroupMessageDTO dto) {
77   - for (Long userId : userIds) {
  80 + for (Long userId : getDistinctUserIds(userIds)) {
78 81 try {
79 82 webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
80 83 ImGroupMessageDTO.TYPE, dto);
... ... @@ -89,7 +92,7 @@ public class ImWebSocketServiceImpl implements ImWebSocketService {
89 92 */
90 93 @Async
91 94 public void doSendChannelMessage(Collection<Long> userIds, ImChannelMessageDTO dto) {
92   - for (Long userId : userIds) {
  95 + for (Long userId : getDistinctUserIds(userIds)) {
93 96 try {
94 97 webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
95 98 ImChannelMessageDTO.TYPE, dto);
... ... @@ -113,6 +116,10 @@ public class ImWebSocketServiceImpl implements ImWebSocketService {
113 116 }
114 117 }
115 118  
  119 + private static Set<Long> getDistinctUserIds(Collection<Long> userIds) {
  120 + return convertLinkedSet(userIds, userId -> userId);
  121 + }
  122 +
116 123 /**
117 124 * 事务感知的任务调度:
118 125 * - 有事务:注册 afterCommit 回调,事务提交后再执行,防止客户端拿到消息去查库时数据还没落盘
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java
... ... @@ -165,7 +165,7 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest {
165 165 }
166 166  
167 167 @Test
168   - public void testSelectHistoryList_excludeRecall() {
  168 + public void testSelectHistoryList_includeRecall() {
169 169 ImGroupMessageDO normal = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);
170 170 mapper.insert(normal);
171 171 ImGroupMessageDO recalled = buildMessage(10L, 1L, ImMessageStatusEnum.RECALL);
... ... @@ -173,8 +173,9 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest {
173 173  
174 174 List<ImGroupMessageDO> result = mapper.selectHistoryList(10L, null, 100, null);
175 175  
176   - assertEquals(1, result.size());
177   - assertEquals(normal.getId(), result.get(0).getId());
  176 + assertEquals(2, result.size());
  177 + assertEquals(recalled.getId(), result.get(0).getId());
  178 + assertEquals(normal.getId(), result.get(1).getId());
178 179 }
179 180  
180 181 @Test
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java
... ... @@ -120,7 +120,7 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest {
120 120 }
121 121  
122 122 @Test
123   - public void testSelectHistoryList_excludeRecall() {
  123 + public void testSelectHistoryList_includeRecall() {
124 124 // 准备
125 125 ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD);
126 126 mapper.insert(msg1);
... ... @@ -130,9 +130,10 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest {
130 130 // 调用
131 131 List<ImPrivateMessageDO> result = mapper.selectHistoryList(1L, 2L, null, 100);
132 132  
133   - // 断言:撤回的不返回
134   - assertEquals(1, result.size());
135   - assertEquals(msg1.getId(), result.get(0).getId());
  133 + // 断言:撤回消息一并返回
  134 + assertEquals(2, result.size());
  135 + assertEquals(msg2.getId(), result.get(0).getId());
  136 + assertEquals(msg1.getId(), result.get(1).getId());
136 137 }
137 138  
138 139 @Test
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestServiceImplTest.java
... ... @@ -19,6 +19,8 @@ import org.mockito.ArgumentCaptor;
19 19 import org.mockito.InjectMocks;
20 20 import org.mockito.Mock;
21 21  
  22 +import java.util.List;
  23 +
22 24 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
23 25 import static org.junit.jupiter.api.Assertions.*;
24 26 import static org.mockito.ArgumentMatchers.any;
... ... @@ -198,6 +200,7 @@ public class ImFriendRequestServiceImplTest extends BaseMockitoUnitTest {
198 200 friendRequestService.agreeFriendRequest(2L, 100L);
199 201  
200 202 // 断言:双向建立好友 + 推 APPROVED 给发起方
  203 + verify(adminUserApi).validateUserList(List.of(1L, 2L));
201 204 verify(friendService).becomeFriends(request);
202 205 verify(websocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class));
203 206 }
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImplTest.java
... ... @@ -222,4 +222,23 @@ public class ImFriendServiceImplTest extends BaseMockitoUnitTest {
222 222 verify(imFriendMapper, never()).selectListByUserIdAndFriendUserIdsAndStatus(anyLong(), anyCollection(), anyInt());
223 223 }
224 224  
  225 + @Test
  226 + public void testGetMutualEnableFriendList_filterSingleSideDeleted() {
  227 + ImFriendDO friend2 = ImFriendDO.builder().id(1L).userId(1L).friendUserId(2L)
  228 + .status(CommonStatusEnum.ENABLE.getStatus()).build();
  229 + ImFriendDO friend3 = ImFriendDO.builder().id(2L).userId(1L).friendUserId(3L)
  230 + .status(CommonStatusEnum.ENABLE.getStatus()).build();
  231 + when(imFriendMapper.selectListByUserIdAndStatus(1L, CommonStatusEnum.ENABLE.getStatus()))
  232 + .thenReturn(List.of(friend2, friend3));
  233 + when(imFriendMapper.selectListByUserIdsAndFriendUserIdAndStatus(anyCollection(), eq(1L),
  234 + eq(CommonStatusEnum.ENABLE.getStatus()))).thenReturn(List.of(
  235 + ImFriendDO.builder().userId(2L).friendUserId(1L)
  236 + .status(CommonStatusEnum.ENABLE.getStatus()).build()));
  237 +
  238 + List<ImFriendDO> result = friendService.getMutualEnableFriendList(1L);
  239 +
  240 + assertEquals(1, result.size());
  241 + assertEquals(2L, result.get(0).getFriendUserId());
  242 + }
  243 +
225 244 }
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImplTest.java
... ... @@ -2,11 +2,13 @@ package cn.iocoder.yudao.module.im.service.rtc;
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.rtc.vo.ImRtcCallCreateReqVO;
5 6 import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
6 7 import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO;
7 8 import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO;
8 9 import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcCallMapper;
9 10 import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcParticipantMapper;
  11 +import cn.iocoder.yudao.module.im.dal.redis.rtc.ImRtcCallLockRedisDAO;
10 12 import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
11 13 import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallStatusEnum;
12 14 import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantRoleEnum;
... ... @@ -27,7 +29,10 @@ import java.time.LocalDateTime;
27 29 import java.util.Collections;
28 30 import java.util.List;
29 31 import java.util.Map;
  32 +import java.util.Set;
  33 +import java.util.concurrent.Callable;
30 34  
  35 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.RTC_GROUP_INVITEE_REQUIRED;
31 36 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.RTC_SELF_BUSY;
32 37 import static org.junit.jupiter.api.Assertions.*;
33 38 import static org.mockito.ArgumentMatchers.*;
... ... @@ -43,6 +48,8 @@ public class ImRtcCallServiceImplTest extends BaseMockitoUnitTest {
43 48 @Mock
44 49 private ImRtcCallMapper rtcCallMapper;
45 50 @Mock
  51 + private ImRtcCallLockRedisDAO rtcCallLockRedisDAO;
  52 + @Mock
46 53 private AdminUserApi adminUserApi;
47 54 @Mock
48 55 private ImWebSocketService webSocketService;
... ... @@ -228,6 +235,28 @@ public class ImRtcCallServiceImplTest extends BaseMockitoUnitTest {
228 235 verify(rtcCallMapper, never()).updateByIdAndStatusIn(any(), anyCollection(), any());
229 236 }
230 237  
  238 + // ========== createCall ==========
  239 +
  240 + @Test
  241 + public void testCreateCall_groupOnlyInviteSelf_throwInviteeRequired() throws Exception {
  242 + when(imProperties.getRtc()).thenReturn(new ImProperties.Rtc());
  243 + when(rtcCallLockRedisDAO.lockGroup(eq(10L), any())).thenAnswer(invocation -> {
  244 + @SuppressWarnings("unchecked")
  245 + Callable<ImRtcCallDO> callable = invocation.getArgument(1);
  246 + return callable.call();
  247 + });
  248 + ImRtcCallCreateReqVO reqVO = new ImRtcCallCreateReqVO();
  249 + reqVO.setConversationType(ImConversationTypeEnum.GROUP.getType());
  250 + reqVO.setGroupId(10L);
  251 + reqVO.setInviteeIds(Set.of(100L));
  252 +
  253 + ServiceException exception = assertThrows(ServiceException.class,
  254 + () -> rtcCallService.createCall(100L, reqVO));
  255 +
  256 + assertEquals(RTC_GROUP_INVITEE_REQUIRED.getCode(), exception.getCode());
  257 + verify(rtcParticipantMapper, never()).insertBatch(anyList());
  258 + }
  259 +
231 260 // ========== acceptCall / joinCall 忙线校验 ==========
232 261  
233 262 @Test
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImplTest.java
... ... @@ -14,6 +14,8 @@ import org.mockito.MockedStatic;
14 14 import org.springframework.transaction.support.TransactionSynchronization;
15 15 import org.springframework.transaction.support.TransactionSynchronizationManager;
16 16  
  17 +import java.util.Arrays;
  18 +import java.util.Collections;
17 19 import java.util.List;
18 20  
19 21 import static org.junit.jupiter.api.Assertions.assertEquals;
... ... @@ -137,6 +139,29 @@ public class ImWebSocketServiceImplTest extends BaseMockitoUnitTest {
137 139 }
138 140  
139 141 @Test
  142 + public void testDoSendGroupMessage_emptyUserIds_noSend() {
  143 + ImGroupMessageDTO dto = new ImGroupMessageDTO().setGroupId(10L);
  144 +
  145 + imWebSocketService.doSendGroupMessage(Collections.emptyList(), dto);
  146 + imWebSocketService.doSendGroupMessage(null, dto);
  147 +
  148 + verifyNoInteractions(webSocketMessageSender);
  149 + }
  150 +
  151 + @Test
  152 + public void testDoSendGroupMessage_distinctUserIds() {
  153 + ImGroupMessageDTO dto = new ImGroupMessageDTO().setGroupId(10L);
  154 +
  155 + imWebSocketService.doSendGroupMessage(Arrays.asList(1L, 2L, 1L, null), dto);
  156 +
  157 + verify(webSocketMessageSender).sendObject(
  158 + eq(UserTypeEnum.ADMIN.getValue()), eq(1L), eq(ImGroupMessageDTO.TYPE), eq(dto));
  159 + verify(webSocketMessageSender).sendObject(
  160 + eq(UserTypeEnum.ADMIN.getValue()), eq(2L), eq(ImGroupMessageDTO.TYPE), eq(dto));
  161 + verifyNoMoreInteractions(webSocketMessageSender);
  162 + }
  163 +
  164 + @Test
140 165 public void testSendPrivateMessageAsync_exceptionSwallowed() {
141 166 try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
142 167 springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class)))
... ...