Commit 49241c3123a8ffc5148366b4c6f62a990bd94359
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<ImGroupMessageDO> { | @@ -81,7 +81,6 @@ public interface ImGroupMessageMapper extends BaseMapperX<ImGroupMessageDO> { | ||
| 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<ImPrivateMessageDO> | @@ -56,7 +55,6 @@ public interface ImPrivateMessageMapper extends BaseMapperX<ImPrivateMessageDO> | ||
| 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))) |