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,6 +2,7 @@ package cn.iocoder.yudao.module.im.controller.admin.group.vo;
2 2
3 import io.swagger.v3.oas.annotations.media.Schema; 3 import io.swagger.v3.oas.annotations.media.Schema;
4 import jakarta.validation.constraints.NotBlank; 4 import jakarta.validation.constraints.NotBlank;
  5 +import jakarta.validation.constraints.Size;
5 import lombok.Data; 6 import lombok.Data;
6 7
7 import java.util.List; 8 import java.util.List;
@@ -12,6 +13,7 @@ public class ImGroupCreateReqVO { @@ -12,6 +13,7 @@ public class ImGroupCreateReqVO {
12 13
13 @Schema(description = "群名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋道技术交流群") 14 @Schema(description = "群名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋道技术交流群")
14 @NotBlank(message = "群名称不能为空") 15 @NotBlank(message = "群名称不能为空")
  16 + @Size(max = 64, message = "群名称长度不能超过 64")
15 private String name; 17 private String name;
16 18
17 @Schema(description = "初始成员用户编号列表(建群同时邀请的好友,不含创建者自己)", example = "[1024, 2048]") 19 @Schema(description = "初始成员用户编号列表(建群同时邀请的好友,不含创建者自己)", example = "[1024, 2048]")
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupUpdateReqVO.java
1 package cn.iocoder.yudao.module.im.controller.admin.group.vo; 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 import io.swagger.v3.oas.annotations.media.Schema; 5 import io.swagger.v3.oas.annotations.media.Schema;
  6 +import jakarta.validation.constraints.AssertTrue;
4 import jakarta.validation.constraints.NotNull; 7 import jakarta.validation.constraints.NotNull;
  8 +import jakarta.validation.constraints.Size;
5 import lombok.Data; 9 import lombok.Data;
6 10
7 @Schema(description = "管理后台 - 群更新 Request VO") 11 @Schema(description = "管理后台 - 群更新 Request VO")
@@ -13,15 +17,30 @@ public class ImGroupUpdateReqVO { @@ -13,15 +17,30 @@ public class ImGroupUpdateReqVO {
13 private Long id; 17 private Long id;
14 18
15 @Schema(description = "群名称", example = "芋道技术交流群") 19 @Schema(description = "群名称", example = "芋道技术交流群")
  20 + @Size(max = 64, message = "群名称长度不能超过 64")
16 private String name; 21 private String name;
17 22
18 @Schema(description = "群头像") 23 @Schema(description = "群头像")
  24 + @Size(max = 512, message = "群头像长度不能超过 512")
19 private String avatar; 25 private String avatar;
20 26
21 @Schema(description = "群公告") 27 @Schema(description = "群公告")
  28 + @Size(max = 2048, message = "群公告长度不能超过 2048")
22 private String notice; 29 private String notice;
23 30
24 @Schema(description = "进群是否需群主 / 管理员审批", example = "true") 31 @Schema(description = "进群是否需群主 / 管理员审批", example = "true")
25 private Boolean joinApproval; 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,6 +47,15 @@ public interface ImFriendMapper extends BaseMapperX<ImFriendDO> {
47 .eq(ImFriendDO::getStatus, status)); 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 default PageResult<ImFriendDO> selectPage(ImFriendManagerPageReqVO reqVO) { 59 default PageResult<ImFriendDO> selectPage(ImFriendManagerPageReqVO reqVO) {
51 return selectPage(reqVO, new LambdaQueryWrapperX<ImFriendDO>() 60 return selectPage(reqVO, new LambdaQueryWrapperX<ImFriendDO>()
52 .eqIfPresent(ImFriendDO::getUserId, reqVO.getUserId()) 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,7 +81,6 @@ public interface ImGroupMessageMapper extends BaseMapperX&lt;ImGroupMessageDO&gt; {
81 default List<ImGroupMessageDO> selectHistoryList(Long groupId, Long maxId, Integer limit, LocalDateTime joinTime) { 81 default List<ImGroupMessageDO> selectHistoryList(Long groupId, Long maxId, Integer limit, LocalDateTime joinTime) {
82 QueryWrapperX<ImGroupMessageDO> wrapper = new QueryWrapperX<>(); 82 QueryWrapperX<ImGroupMessageDO> wrapper = new QueryWrapperX<>();
83 wrapper.eq("group_id", groupId) 83 wrapper.eq("group_id", groupId)
84 - .ne("status", ImMessageStatusEnum.RECALL.getStatus())  
85 .lt(maxId != null, "id", maxId) 84 .lt(maxId != null, "id", maxId)
86 .ge(joinTime != null, "send_time", joinTime) 85 .ge(joinTime != null, "send_time", joinTime)
87 .orderByDesc("id"); 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,7 +6,6 @@ import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
6 import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; 6 import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX;
7 import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.privates.ImPrivateMessageManagerPageReqVO; 7 import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.privates.ImPrivateMessageManagerPageReqVO;
8 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; 8 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
9 -import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;  
10 import org.apache.ibatis.annotations.Mapper; 9 import org.apache.ibatis.annotations.Mapper;
11 10
12 import java.time.LocalDateTime; 11 import java.time.LocalDateTime;
@@ -56,7 +55,6 @@ public interface ImPrivateMessageMapper extends BaseMapperX&lt;ImPrivateMessageDO&gt; @@ -56,7 +55,6 @@ public interface ImPrivateMessageMapper extends BaseMapperX&lt;ImPrivateMessageDO&gt;
56 wrapper.and(w -> w.eq("sender_id", userId).eq("receiver_id", receiverId) 55 wrapper.and(w -> w.eq("sender_id", userId).eq("receiver_id", receiverId)
57 .or() 56 .or()
58 .eq("sender_id", receiverId).eq("receiver_id", userId)) 57 .eq("sender_id", receiverId).eq("receiver_id", userId))
59 - .ne("status", ImMessageStatusEnum.RECALL.getStatus())  
60 .lt(maxId != null, "id", maxId) 58 .lt(maxId != null, "id", maxId)
61 .orderByDesc("id"); 59 .orderByDesc("id");
62 wrapper.limitN(limit); 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,8 +35,8 @@ public class AdminUserProfileUpdateConsumer {
35 public void onMessage(AdminUserProfileUpdateMessage message) { 35 public void onMessage(AdminUserProfileUpdateMessage message) {
36 log.info("[onMessage][消息内容({})]", message); 36 log.info("[onMessage][消息内容({})]", message);
37 Long userId = message.getUserId(); 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 if (friends.isEmpty()) { 40 if (friends.isEmpty()) {
41 return; 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,10 +143,12 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService {
143 @Override 143 @Override
144 @Transactional(rollbackFor = Exception.class) 144 @Transactional(rollbackFor = Exception.class)
145 public void agreeFriendRequest(Long userId, Long requestId) { 145 public void agreeFriendRequest(Long userId, Long requestId) {
146 - // 1. 校验申请存在 + 未处理 + 操作人是接收方(fail-fast;并发场景仍由下面的乐观锁兜底) 146 + // 1.1 校验申请存在、未处理、操作人是接收方
147 ImFriendRequestDO request = validateRequestForHandle(userId, requestId); 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 ImFriendRequestDO updateObj = new ImFriendRequestDO() 152 ImFriendRequestDO updateObj = new ImFriendRequestDO()
151 .setHandleResult(ImFriendRequestHandleResultEnum.AGREED.getResult()).setHandleTime(LocalDateTime.now()); 153 .setHandleResult(ImFriendRequestHandleResultEnum.AGREED.getResult()).setHandleTime(LocalDateTime.now());
152 int affected = friendRequestMapper.updateByIdAndHandleResult(request.getId(), 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,6 +48,11 @@ public interface ImFriendService {
48 List<ImFriendDO> getEnableFriendList(Long userId); 48 List<ImFriendDO> getEnableFriendList(Long userId);
49 49
50 /** 50 /**
  51 + * 获得当前用户的双向有效好友列表(双方均 ENABLE 状态)
  52 + */
  53 + List<ImFriendDO> getMutualEnableFriendList(Long userId);
  54 +
  55 + /**
51 * 获得当前用户与指定用户之间的有效好友列表(仅 ENABLE 状态) 56 * 获得当前用户与指定用户之间的有效好友列表(仅 ENABLE 状态)
52 */ 57 */
53 List<ImFriendDO> getActiveFriendList(Long userId, Collection<Long> friendUserIds); 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,8 +31,11 @@ import java.time.LocalDateTime;
31 import java.util.Collection; 31 import java.util.Collection;
32 import java.util.Collections; 32 import java.util.Collections;
33 import java.util.List; 33 import java.util.List;
  34 +import java.util.Set;
34 35
35 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; 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 import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.FRIEND_STATE; 39 import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.FRIEND_STATE;
37 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_BLOCKED_BY_PEER; 40 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_BLOCKED_BY_PEER;
38 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_BLOCKED; 41 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_BLOCKED;
@@ -101,6 +104,24 @@ public class ImFriendServiceImpl implements ImFriendService { @@ -101,6 +104,24 @@ public class ImFriendServiceImpl implements ImFriendService {
101 } 104 }
102 105
103 @Override 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 public List<ImFriendDO> getActiveFriendList(Long userId, Collection<Long> friendUserIds) { 125 public List<ImFriendDO> getActiveFriendList(Long userId, Collection<Long> friendUserIds) {
105 if (CollUtil.isEmpty(friendUserIds)) { 126 if (CollUtil.isEmpty(friendUserIds)) {
106 return Collections.emptyList(); 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,6 +812,9 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
812 Set<Long> initial = new LinkedHashSet<>(reqVO.getInviteeIds()); 812 Set<Long> initial = new LinkedHashSet<>(reqVO.getInviteeIds());
813 // 发起人本人不进被邀请池 813 // 发起人本人不进被邀请池
814 initial.remove(inviterId); 814 initial.remove(inviterId);
  815 + if (CollUtil.isEmpty(initial)) {
  816 + throw exception(RTC_GROUP_INVITEE_REQUIRED);
  817 + }
815 int max = imProperties.getRtc().getGroupMaxParticipants(); 818 int max = imProperties.getRtc().getGroupMaxParticipants();
816 if (initial.size() + 1 > max) { 819 if (initial.size() + 1 > max) {
817 throw exception(RTC_GROUP_INVITEE_OVER_LIMIT); 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,6 +15,9 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
15 import org.springframework.validation.annotation.Validated; 15 import org.springframework.validation.annotation.Validated;
16 16
17 import java.util.Collection; 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 * IM WebSocket 推送 Service 实现类 23 * IM WebSocket 推送 Service 实现类
@@ -59,7 +62,7 @@ public class ImWebSocketServiceImpl implements ImWebSocketService { @@ -59,7 +62,7 @@ public class ImWebSocketServiceImpl implements ImWebSocketService {
59 */ 62 */
60 @Async 63 @Async
61 public void doSendPrivateMessage(Collection<Long> userIds, ImPrivateMessageDTO dto) { 64 public void doSendPrivateMessage(Collection<Long> userIds, ImPrivateMessageDTO dto) {
62 - for (Long userId : userIds) { 65 + for (Long userId : getDistinctUserIds(userIds)) {
63 try { 66 try {
64 webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId, 67 webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
65 ImPrivateMessageDTO.TYPE, dto); 68 ImPrivateMessageDTO.TYPE, dto);
@@ -74,7 +77,7 @@ public class ImWebSocketServiceImpl implements ImWebSocketService { @@ -74,7 +77,7 @@ public class ImWebSocketServiceImpl implements ImWebSocketService {
74 */ 77 */
75 @Async 78 @Async
76 public void doSendGroupMessage(Collection<Long> userIds, ImGroupMessageDTO dto) { 79 public void doSendGroupMessage(Collection<Long> userIds, ImGroupMessageDTO dto) {
77 - for (Long userId : userIds) { 80 + for (Long userId : getDistinctUserIds(userIds)) {
78 try { 81 try {
79 webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId, 82 webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
80 ImGroupMessageDTO.TYPE, dto); 83 ImGroupMessageDTO.TYPE, dto);
@@ -89,7 +92,7 @@ public class ImWebSocketServiceImpl implements ImWebSocketService { @@ -89,7 +92,7 @@ public class ImWebSocketServiceImpl implements ImWebSocketService {
89 */ 92 */
90 @Async 93 @Async
91 public void doSendChannelMessage(Collection<Long> userIds, ImChannelMessageDTO dto) { 94 public void doSendChannelMessage(Collection<Long> userIds, ImChannelMessageDTO dto) {
92 - for (Long userId : userIds) { 95 + for (Long userId : getDistinctUserIds(userIds)) {
93 try { 96 try {
94 webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId, 97 webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
95 ImChannelMessageDTO.TYPE, dto); 98 ImChannelMessageDTO.TYPE, dto);
@@ -113,6 +116,10 @@ public class ImWebSocketServiceImpl implements ImWebSocketService { @@ -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 * - 有事务:注册 afterCommit 回调,事务提交后再执行,防止客户端拿到消息去查库时数据还没落盘 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,7 +165,7 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest {
165 } 165 }
166 166
167 @Test 167 @Test
168 - public void testSelectHistoryList_excludeRecall() { 168 + public void testSelectHistoryList_includeRecall() {
169 ImGroupMessageDO normal = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD); 169 ImGroupMessageDO normal = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD);
170 mapper.insert(normal); 170 mapper.insert(normal);
171 ImGroupMessageDO recalled = buildMessage(10L, 1L, ImMessageStatusEnum.RECALL); 171 ImGroupMessageDO recalled = buildMessage(10L, 1L, ImMessageStatusEnum.RECALL);
@@ -173,8 +173,9 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { @@ -173,8 +173,9 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest {
173 173
174 List<ImGroupMessageDO> result = mapper.selectHistoryList(10L, null, 100, null); 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 @Test 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,7 +120,7 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest {
120 } 120 }
121 121
122 @Test 122 @Test
123 - public void testSelectHistoryList_excludeRecall() { 123 + public void testSelectHistoryList_includeRecall() {
124 // 准备 124 // 准备
125 ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); 125 ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD);
126 mapper.insert(msg1); 126 mapper.insert(msg1);
@@ -130,9 +130,10 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest { @@ -130,9 +130,10 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest {
130 // 调用 130 // 调用
131 List<ImPrivateMessageDO> result = mapper.selectHistoryList(1L, 2L, null, 100); 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 @Test 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,6 +19,8 @@ import org.mockito.ArgumentCaptor;
19 import org.mockito.InjectMocks; 19 import org.mockito.InjectMocks;
20 import org.mockito.Mock; 20 import org.mockito.Mock;
21 21
  22 +import java.util.List;
  23 +
22 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; 24 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
23 import static org.junit.jupiter.api.Assertions.*; 25 import static org.junit.jupiter.api.Assertions.*;
24 import static org.mockito.ArgumentMatchers.any; 26 import static org.mockito.ArgumentMatchers.any;
@@ -198,6 +200,7 @@ public class ImFriendRequestServiceImplTest extends BaseMockitoUnitTest { @@ -198,6 +200,7 @@ public class ImFriendRequestServiceImplTest extends BaseMockitoUnitTest {
198 friendRequestService.agreeFriendRequest(2L, 100L); 200 friendRequestService.agreeFriendRequest(2L, 100L);
199 201
200 // 断言:双向建立好友 + 推 APPROVED 给发起方 202 // 断言:双向建立好友 + 推 APPROVED 给发起方
  203 + verify(adminUserApi).validateUserList(List.of(1L, 2L));
201 verify(friendService).becomeFriends(request); 204 verify(friendService).becomeFriends(request);
202 verify(websocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); 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,4 +222,23 @@ public class ImFriendServiceImplTest extends BaseMockitoUnitTest {
222 verify(imFriendMapper, never()).selectListByUserIdAndFriendUserIdsAndStatus(anyLong(), anyCollection(), anyInt()); 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,11 +2,13 @@ package cn.iocoder.yudao.module.im.service.rtc;
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.rtc.vo.ImRtcCallCreateReqVO;
5 import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; 6 import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
6 import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO; 7 import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO;
7 import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO; 8 import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO;
8 import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcCallMapper; 9 import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcCallMapper;
9 import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcParticipantMapper; 10 import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcParticipantMapper;
  11 +import cn.iocoder.yudao.module.im.dal.redis.rtc.ImRtcCallLockRedisDAO;
10 import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum; 12 import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
11 import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallStatusEnum; 13 import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallStatusEnum;
12 import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantRoleEnum; 14 import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantRoleEnum;
@@ -27,7 +29,10 @@ import java.time.LocalDateTime; @@ -27,7 +29,10 @@ import java.time.LocalDateTime;
27 import java.util.Collections; 29 import java.util.Collections;
28 import java.util.List; 30 import java.util.List;
29 import java.util.Map; 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 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.RTC_SELF_BUSY; 36 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.RTC_SELF_BUSY;
32 import static org.junit.jupiter.api.Assertions.*; 37 import static org.junit.jupiter.api.Assertions.*;
33 import static org.mockito.ArgumentMatchers.*; 38 import static org.mockito.ArgumentMatchers.*;
@@ -43,6 +48,8 @@ public class ImRtcCallServiceImplTest extends BaseMockitoUnitTest { @@ -43,6 +48,8 @@ public class ImRtcCallServiceImplTest extends BaseMockitoUnitTest {
43 @Mock 48 @Mock
44 private ImRtcCallMapper rtcCallMapper; 49 private ImRtcCallMapper rtcCallMapper;
45 @Mock 50 @Mock
  51 + private ImRtcCallLockRedisDAO rtcCallLockRedisDAO;
  52 + @Mock
46 private AdminUserApi adminUserApi; 53 private AdminUserApi adminUserApi;
47 @Mock 54 @Mock
48 private ImWebSocketService webSocketService; 55 private ImWebSocketService webSocketService;
@@ -228,6 +235,28 @@ public class ImRtcCallServiceImplTest extends BaseMockitoUnitTest { @@ -228,6 +235,28 @@ public class ImRtcCallServiceImplTest extends BaseMockitoUnitTest {
228 verify(rtcCallMapper, never()).updateByIdAndStatusIn(any(), anyCollection(), any()); 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 // ========== acceptCall / joinCall 忙线校验 ========== 260 // ========== acceptCall / joinCall 忙线校验 ==========
232 261
233 @Test 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,6 +14,8 @@ import org.mockito.MockedStatic;
14 import org.springframework.transaction.support.TransactionSynchronization; 14 import org.springframework.transaction.support.TransactionSynchronization;
15 import org.springframework.transaction.support.TransactionSynchronizationManager; 15 import org.springframework.transaction.support.TransactionSynchronizationManager;
16 16
  17 +import java.util.Arrays;
  18 +import java.util.Collections;
17 import java.util.List; 19 import java.util.List;
18 20
19 import static org.junit.jupiter.api.Assertions.assertEquals; 21 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -137,6 +139,29 @@ public class ImWebSocketServiceImplTest extends BaseMockitoUnitTest { @@ -137,6 +139,29 @@ public class ImWebSocketServiceImplTest extends BaseMockitoUnitTest {
137 } 139 }
138 140
139 @Test 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 public void testSendPrivateMessageAsync_exceptionSwallowed() { 165 public void testSendPrivateMessageAsync_exceptionSwallowed() {
141 try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) { 166 try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
142 springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class))) 167 springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImWebSocketServiceImpl.class)))