Commit bd29116e454f83af00fd88a099b8891be8dc2499
1 parent
a06fb9e9
fix: 修复 IM 申请与 RTC 边界问题
- 复用好友申请、群申请和群邀请唯一键冲突后的旧记录,并补充测试 - 收敛 RTC 旁观者加入、忙线校验、追加邀请超员和群通话通知逻辑 - 为 RTC 参与者补充房间用户唯一约束与 MySQL 迁移 - 统一群本体管理请求的 id 字段,并同步前端调用 - 修复前端来电活跃态守卫和 LiveKit 重连前断开旧房间 - 清理群成员通知基类命名和相关注释
Showing
27 changed files
with
435 additions
and
165 deletions
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java
| ... | ... | @@ -134,14 +134,14 @@ public class ImGroupController { |
| 134 | 134 | @PutMapping("/pin-message") |
| 135 | 135 | @Operation(summary = "置顶群消息(群主 / 管理员)") |
| 136 | 136 | public CommonResult<Boolean> pinGroupMessage(@Valid @RequestBody ImGroupMessagePinReqVO reqVO) { |
| 137 | - groupService.pinGroupMessage(getLoginUserId(), reqVO.getGroupId(), reqVO.getMessageId()); | |
| 137 | + groupService.pinGroupMessage(getLoginUserId(), reqVO.getId(), reqVO.getMessageId()); | |
| 138 | 138 | return success(true); |
| 139 | 139 | } |
| 140 | 140 | |
| 141 | 141 | @PutMapping("/unpin-message") |
| 142 | 142 | @Operation(summary = "取消置顶群消息(群主 / 管理员)") |
| 143 | 143 | public CommonResult<Boolean> unpinGroupMessage(@Valid @RequestBody ImGroupMessagePinReqVO reqVO) { |
| 144 | - groupService.unpinGroupMessage(getLoginUserId(), reqVO.getGroupId(), reqVO.getMessageId()); | |
| 144 | + groupService.unpinGroupMessage(getLoginUserId(), reqVO.getId(), reqVO.getMessageId()); | |
| 145 | 145 | return success(true); |
| 146 | 146 | } |
| 147 | 147 | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupAdminAddReqVO.java
| ... | ... | @@ -13,7 +13,7 @@ public class ImGroupAdminAddReqVO { |
| 13 | 13 | |
| 14 | 14 | @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13279") |
| 15 | 15 | @NotNull(message = "群编号不能为空") |
| 16 | - private Long groupId; | |
| 16 | + private Long id; | |
| 17 | 17 | |
| 18 | 18 | @Schema(description = "目标用户编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[101, 102]") |
| 19 | 19 | @NotEmpty(message = "目标用户编号列表不能为空") | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupAdminRemoveReqVO.java
| ... | ... | @@ -13,7 +13,7 @@ public class ImGroupAdminRemoveReqVO { |
| 13 | 13 | |
| 14 | 14 | @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13279") |
| 15 | 15 | @NotNull(message = "群编号不能为空") |
| 16 | - private Long groupId; | |
| 16 | + private Long id; | |
| 17 | 17 | |
| 18 | 18 | @Schema(description = "目标用户编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[101, 102]") |
| 19 | 19 | @NotEmpty(message = "目标用户编号列表不能为空") | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupCancelMuteMemberReqVO.java
| ... | ... | @@ -10,7 +10,7 @@ public class ImGroupCancelMuteMemberReqVO { |
| 10 | 10 | |
| 11 | 11 | @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") |
| 12 | 12 | @NotNull(message = "群编号不能为空") |
| 13 | - private Long groupId; | |
| 13 | + private Long id; | |
| 14 | 14 | |
| 15 | 15 | @Schema(description = "被取消禁言的用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") |
| 16 | 16 | @NotNull(message = "用户编号不能为空") | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupMessagePinReqVO.java
| ... | ... | @@ -13,7 +13,7 @@ public class ImGroupMessagePinReqVO { |
| 13 | 13 | |
| 14 | 14 | @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13279") |
| 15 | 15 | @NotNull(message = "群编号不能为空") |
| 16 | - private Long groupId; | |
| 16 | + private Long id; | |
| 17 | 17 | |
| 18 | 18 | @Schema(description = "消息编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "9527") |
| 19 | 19 | @NotNull(message = "消息编号不能为空") | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupMuteAllReqVO.java
| ... | ... | @@ -8,10 +8,9 @@ import lombok.Data; |
| 8 | 8 | @Data |
| 9 | 9 | public class ImGroupMuteAllReqVO { |
| 10 | 10 | |
| 11 | - // TODO @AI:改成 id 是不是更好? | |
| 12 | 11 | @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") |
| 13 | 12 | @NotNull(message = "群编号不能为空") |
| 14 | - private Long groupId; | |
| 13 | + private Long id; | |
| 15 | 14 | |
| 16 | 15 | @Schema(description = "是否全群禁言", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") |
| 17 | 16 | @NotNull(message = "是否全群禁言不能为空") | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupMuteMemberReqVO.java
| ... | ... | @@ -11,7 +11,7 @@ public class ImGroupMuteMemberReqVO { |
| 11 | 11 | |
| 12 | 12 | @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") |
| 13 | 13 | @NotNull(message = "群编号不能为空") |
| 14 | - private Long groupId; | |
| 14 | + private Long id; | |
| 15 | 15 | |
| 16 | 16 | @Schema(description = "被禁言的用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") |
| 17 | 17 | @NotNull(message = "用户编号不能为空") | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupTransferOwnerReqVO.java
| ... | ... | @@ -10,7 +10,7 @@ public class ImGroupTransferOwnerReqVO { |
| 10 | 10 | |
| 11 | 11 | @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13279") |
| 12 | 12 | @NotNull(message = "群编号不能为空") |
| 13 | - private Long groupId; | |
| 13 | + private Long id; | |
| 14 | 14 | |
| 15 | 15 | @Schema(description = "新群主用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "202") |
| 16 | 16 | @NotNull(message = "新群主用户编号不能为空") | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupRequestMapper.java
| ... | ... | @@ -27,12 +27,6 @@ public interface ImGroupRequestMapper extends BaseMapperX<ImGroupRequestDO> { |
| 27 | 27 | .eq(ImGroupRequestDO::getUserId, userId)); |
| 28 | 28 | } |
| 29 | 29 | |
| 30 | - default List<ImGroupRequestDO> selectListByGroupIdAndUserIds(Long groupId, Collection<Long> userIds) { | |
| 31 | - return selectList(new LambdaQueryWrapperX<ImGroupRequestDO>() | |
| 32 | - .eq(ImGroupRequestDO::getGroupId, groupId) | |
| 33 | - .in(ImGroupRequestDO::getUserId, userIds)); | |
| 34 | - } | |
| 35 | - | |
| 36 | 30 | default List<ImGroupRequestDO> selectListByGroupIdsAndHandleResult(Collection<Long> groupIds, Integer handleResult) { |
| 37 | 31 | return selectList(new LambdaQueryWrapperX<ImGroupRequestDO>() |
| 38 | 32 | .in(ImGroupRequestDO::getGroupId, groupIds) |
| ... | ... | @@ -73,14 +67,9 @@ public interface ImGroupRequestMapper extends BaseMapperX<ImGroupRequestDO> { |
| 73 | 67 | .set(ImGroupRequestDO::getUpdateTime, updateTime)); |
| 74 | 68 | } |
| 75 | 69 | |
| 76 | - /** | |
| 77 | - * 复用邀请审批的旧记录(批量):盖写 inviterUserId / addSource,重置为未处理 + 清空旧处理痕迹 + 刷 update_time | |
| 78 | - */ | |
| 79 | - default int updateInviteByGroupIdAndUserIdsReset(Long groupId, Collection<Long> userIds, | |
| 80 | - Long inviterUserId, Integer addSource, LocalDateTime updateTime) { | |
| 70 | + default int updateInviteByIdReset(Long id, Long inviterUserId, Integer addSource, LocalDateTime updateTime) { | |
| 81 | 71 | return update(null, new LambdaUpdateWrapper<ImGroupRequestDO>() |
| 82 | - .eq(ImGroupRequestDO::getGroupId, groupId) | |
| 83 | - .in(ImGroupRequestDO::getUserId, userIds) | |
| 72 | + .eq(ImGroupRequestDO::getId, id) | |
| 84 | 73 | .set(ImGroupRequestDO::getInviterUserId, inviterUserId) |
| 85 | 74 | .set(ImGroupRequestDO::getAddSource, addSource) |
| 86 | 75 | .set(ImGroupRequestDO::getHandleResult, ImGroupRequestHandleResultEnum.UNHANDLED.getResult()) | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/rtc/ImRtcParticipantRoleEnum.java
| ... | ... | @@ -17,8 +17,7 @@ public enum ImRtcParticipantRoleEnum implements ArrayValuable<Integer> { |
| 17 | 17 | |
| 18 | 18 | INVITER(1, "发起人"), |
| 19 | 19 | INVITEE(2, "被邀请者"), |
| 20 | - // TODO @AI:改成 joiner?还是 joinee?看看这样拼写是不是对的。 | |
| 21 | - ACTIVE_JOIN(3, "主动加入者"); // 仅群通话场景:旁观者点胶囊条加入已有通话 | |
| 20 | + JOINER(3, "主动加入者"); // 仅群通话场景:旁观者点胶囊条加入已有通话 | |
| 22 | 21 | |
| 23 | 22 | public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImRtcParticipantRoleEnum::getRole).toArray(Integer[]::new); |
| 24 | 23 | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestServiceImpl.java
| ... | ... | @@ -24,6 +24,7 @@ import cn.iocoder.yudao.module.system.api.user.AdminUserApi; |
| 24 | 24 | import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; |
| 25 | 25 | import jakarta.annotation.Resource; |
| 26 | 26 | import lombok.extern.slf4j.Slf4j; |
| 27 | +import org.springframework.dao.DuplicateKeyException; | |
| 27 | 28 | import org.springframework.stereotype.Service; |
| 28 | 29 | import org.springframework.transaction.annotation.Transactional; |
| 29 | 30 | import org.springframework.transaction.support.TransactionSynchronization; |
| ... | ... | @@ -92,22 +93,8 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService { |
| 92 | 93 | return null; |
| 93 | 94 | } |
| 94 | 95 | |
| 95 | - // 2. 落库:upsert 语义;同一对 (from, to) 唯一,已有记录覆盖申请理由 / 备注 / 来源 + 重置为未处理 + 清空旧处理痕迹 | |
| 96 | - ImFriendRequestDO request = friendRequestMapper.selectByFromUserIdAndToUserId(fromUserId, toUserId); | |
| 97 | - if (request != null) { | |
| 98 | - LocalDateTime now = LocalDateTime.now(); | |
| 99 | - friendRequestMapper.updateByIdReset(request.getId(), | |
| 100 | - reqVO.getApplyContent(), reqVO.getDisplayName(), reqVO.getAddSource(), now); | |
| 101 | - request.setApplyContent(reqVO.getApplyContent()).setDisplayName(reqVO.getDisplayName()) | |
| 102 | - .setAddSource(reqVO.getAddSource()) | |
| 103 | - .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()) | |
| 104 | - .setHandleContent(null).setHandleTime(null).setUpdateTime(now); | |
| 105 | - } else { | |
| 106 | - request = BeanUtils.toBean(reqVO, ImFriendRequestDO.class) | |
| 107 | - .setFromUserId(fromUserId).setToUserId(toUserId) | |
| 108 | - .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()); | |
| 109 | - friendRequestMapper.insert(request); | |
| 110 | - } | |
| 96 | + // 2. 落库:同一申请人和接收人唯一,已有记录覆盖申请内容并重置为未处理 | |
| 97 | + ImFriendRequestDO request = createOrResetRequest(fromUserId, reqVO); | |
| 111 | 98 | |
| 112 | 99 | // 3. 推送 FRIEND_REQUEST_RECEIVED 给 toUser 多端;payload 携带申请方昵称 / 头像,前端按 requestId 直推 push 进列表 |
| 113 | 100 | AdminUserRespDTO fromUser = adminUserApi.getUser(fromUserId); |
| ... | ... | @@ -141,6 +128,44 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService { |
| 141 | 128 | return request; |
| 142 | 129 | } |
| 143 | 130 | |
| 131 | + /** | |
| 132 | + * 创建或重置好友申请 | |
| 133 | + * | |
| 134 | + * @param fromUserId 申请人用户编号 | |
| 135 | + * @param reqVO 申请请求 | |
| 136 | + * @return 申请记录 | |
| 137 | + */ | |
| 138 | + private ImFriendRequestDO createOrResetRequest(Long fromUserId, ImFriendRequestApplyReqVO reqVO) { | |
| 139 | + Long toUserId = reqVO.getToUserId(); | |
| 140 | + ImFriendRequestDO request = friendRequestMapper.selectByFromUserIdAndToUserId(fromUserId, toUserId); | |
| 141 | + if (request == null) { | |
| 142 | + // 1. 无旧申请:创建新申请;唯一键冲突时回查并复用并发写入的记录 | |
| 143 | + request = BeanUtils.toBean(reqVO, ImFriendRequestDO.class) | |
| 144 | + .setFromUserId(fromUserId).setToUserId(toUserId) | |
| 145 | + .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()); | |
| 146 | + try { | |
| 147 | + friendRequestMapper.insert(request); | |
| 148 | + return request; | |
| 149 | + } catch (DuplicateKeyException ex) { | |
| 150 | + request = friendRequestMapper.selectByFromUserIdAndToUserId(fromUserId, toUserId); | |
| 151 | + if (request == null) { | |
| 152 | + throw ex; | |
| 153 | + } | |
| 154 | + } | |
| 155 | + } | |
| 156 | + | |
| 157 | + // 2. 复用旧申请:覆盖本次申请内容,并重置为未处理 | |
| 158 | + LocalDateTime now = LocalDateTime.now(); | |
| 159 | + friendRequestMapper.updateByIdReset(request.getId(), | |
| 160 | + reqVO.getApplyContent(), reqVO.getDisplayName(), reqVO.getAddSource(), now); | |
| 161 | + // 同步内存对象,后续通知和自动通过直接复用 | |
| 162 | + request.setApplyContent(reqVO.getApplyContent()).setDisplayName(reqVO.getDisplayName()) | |
| 163 | + .setAddSource(reqVO.getAddSource()) | |
| 164 | + .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()) | |
| 165 | + .setHandleContent(null).setHandleTime(null).setUpdateTime(now); | |
| 166 | + return request; | |
| 167 | + } | |
| 168 | + | |
| 144 | 169 | @Override |
| 145 | 170 | @Transactional(rollbackFor = Exception.class) |
| 146 | 171 | public void agreeFriendRequest(Long userId, Long requestId) { | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupRequestService.java
| ... | ... | @@ -19,7 +19,7 @@ public interface ImGroupRequestService { |
| 19 | 19 | /** |
| 20 | 20 | * 用户主动申请加群 |
| 21 | 21 | * <p> |
| 22 | - * 群未开启审批时直接入群 + 1510 全员广播;开启审批则 upsert 一条 inviterUserId=null 的待审批记录 + 1503 推送 | |
| 22 | + * 群未开启审批时直接入群 + 1510 全员广播;开启审批则创建或复用一条待审批记录 + 1503 推送 | |
| 23 | 23 | * |
| 24 | 24 | * @param userId 申请人用户编号 |
| 25 | 25 | * @param reqVO 申请请求 |
| ... | ... | @@ -45,7 +45,7 @@ public interface ImGroupRequestService { |
| 45 | 45 | void refuseGroupRequest(Long userId, Long requestId, String handleContent); |
| 46 | 46 | |
| 47 | 47 | /** |
| 48 | - * 邀请创建审批申请;inviteGroupMember 在群开启审批时调用,每个被邀请人 upsert 一条 inviterUserId=操作人 的记录 | |
| 48 | + * 邀请创建审批申请;inviteGroupMember 在群开启审批时调用,每个被邀请人创建或复用一条待审批记录 | |
| 49 | 49 | * |
| 50 | 50 | * @param groupId 群编号 |
| 51 | 51 | * @param inviterUserId 邀请人用户编号 | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupRequestServiceImpl.java
| ... | ... | @@ -27,6 +27,7 @@ import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; |
| 27 | 27 | import jakarta.annotation.Resource; |
| 28 | 28 | import lombok.extern.slf4j.Slf4j; |
| 29 | 29 | import org.springframework.context.annotation.Lazy; |
| 30 | +import org.springframework.dao.DuplicateKeyException; | |
| 30 | 31 | import org.springframework.stereotype.Service; |
| 31 | 32 | import org.springframework.transaction.annotation.Transactional; |
| 32 | 33 | import org.springframework.validation.annotation.Validated; |
| ... | ... | @@ -93,22 +94,8 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService { |
| 93 | 94 | return null; |
| 94 | 95 | } |
| 95 | 96 | |
| 96 | - // 3. 情况二:群开启了审批;upsert 一条主动申请记录(inviterUserId=null) | |
| 97 | - ImGroupRequestDO request = groupRequestMapper.selectByGroupIdAndUserId(groupId, userId); | |
| 98 | - if (request != null) { | |
| 99 | - LocalDateTime now = LocalDateTime.now(); | |
| 100 | - groupRequestMapper.updateApplyByIdReset(request.getId(), | |
| 101 | - reqVO.getApplyContent(), reqVO.getAddSource(), now); | |
| 102 | - request.setApplyContent(reqVO.getApplyContent()).setAddSource(reqVO.getAddSource()) | |
| 103 | - .setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult()) | |
| 104 | - .setInviterUserId(null).setHandleUserId(null) | |
| 105 | - .setHandleContent(null).setHandleTime(null).setUpdateTime(now); | |
| 106 | - } else { | |
| 107 | - request = BeanUtils.toBean(reqVO, ImGroupRequestDO.class) | |
| 108 | - .setUserId(userId).setInviterUserId(null) | |
| 109 | - .setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult()); | |
| 110 | - groupRequestMapper.insert(request); | |
| 111 | - } | |
| 97 | + // 3. 情况二:群开启了审批,创建或复用一条主动申请记录 | |
| 98 | + ImGroupRequestDO request = createOrResetApplyRequest(groupId, userId, reqVO); | |
| 112 | 99 | |
| 113 | 100 | // 4. 1503 私聊定向推群主 + 全部管理员(多端同步);payload 携带申请方昵称 / 头像 |
| 114 | 101 | AdminUserRespDTO applyUser = adminUserApi.getUser(userId); |
| ... | ... | @@ -203,39 +190,13 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService { |
| 203 | 190 | } |
| 204 | 191 | ImGroupDO group = groupService.validateGroupExists(groupId); |
| 205 | 192 | Integer inviteSource = ImGroupAddSourceEnum.INVITE.getSource(); |
| 206 | - Integer unhandled = ImGroupRequestHandleResultEnum.UNHANDLED.getResult(); | |
| 207 | - LocalDateTime now = LocalDateTime.now(); | |
| 193 | + // 1. 逐条创建或复用邀请申请 | |
| 194 | + List<ImGroupRequestDO> requests = convertList(invitedUserIds, userId -> | |
| 195 | + createOrResetInviteRequest(groupId, inviterUserId, userId, inviteSource)); | |
| 208 | 196 | |
| 209 | - // 1. 批量查现有记录,分流为「复用旧记录」+「新增」 | |
| 210 | - List<ImGroupRequestDO> existingList = groupRequestMapper.selectListByGroupIdAndUserIds(groupId, invitedUserIds); | |
| 211 | - Set<Long> existingUserIds = convertSet(existingList, ImGroupRequestDO::getUserId); | |
| 212 | - Collection<Long> newUserIds = CollUtil.subtract(invitedUserIds, existingUserIds); | |
| 213 | - | |
| 214 | - // 2. 复用旧记录:一条 update WHERE IN 完成 | |
| 215 | - if (CollUtil.isNotEmpty(existingList)) { | |
| 216 | - groupRequestMapper.updateInviteByGroupIdAndUserIdsReset(groupId, existingUserIds, | |
| 217 | - inviterUserId, inviteSource, now); | |
| 218 | - // 同步内存对象,下面 buildRequestNotification 要读 inviterUserId / addSource | |
| 219 | - existingList.forEach(r -> r.setInviterUserId(inviterUserId).setAddSource(inviteSource) | |
| 220 | - .setHandleResult(unhandled) | |
| 221 | - .setHandleUserId(null).setHandleContent(null).setHandleTime(null) | |
| 222 | - .setUpdateTime(now)); | |
| 223 | - } | |
| 224 | - | |
| 225 | - // 3. 新增:批量 insert | |
| 226 | - List<ImGroupRequestDO> newList = convertList(newUserIds, userId -> new ImGroupRequestDO() | |
| 227 | - .setGroupId(groupId).setUserId(userId).setInviterUserId(inviterUserId) | |
| 228 | - .setAddSource(inviteSource).setHandleResult(unhandled)); | |
| 229 | - if (CollUtil.isNotEmpty(newList)) { | |
| 230 | - groupRequestMapper.insertBatch(newList); | |
| 231 | - } | |
| 232 | - | |
| 233 | - // 4. 推 1503 给群主 + 全部管理员;多端同步;每条申请单独推一帧 | |
| 197 | + // 2. 推 1503 给群主 + 全部管理员;多端同步;每条申请单独推一帧 | |
| 234 | 198 | Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(invitedUserIds); |
| 235 | 199 | List<Long> ownerAndAdmins = getGroupMemberListByOwnerAndAdminUserIds(group); |
| 236 | - List<ImGroupRequestDO> requests = new ArrayList<>(existingList.size() + newList.size()); | |
| 237 | - requests.addAll(existingList); | |
| 238 | - requests.addAll(newList); | |
| 239 | 200 | for (ImGroupRequestDO request : requests) { |
| 240 | 201 | AdminUserRespDTO applyUser = userMap.get(request.getUserId()); |
| 241 | 202 | GroupRequestReceivedNotification payload = buildRequestNotification(group, request, applyUser); |
| ... | ... | @@ -284,6 +245,109 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService { |
| 284 | 245 | } |
| 285 | 246 | |
| 286 | 247 | /** |
| 248 | + * 创建或重置主动加群申请 | |
| 249 | + * | |
| 250 | + * @param groupId 群编号 | |
| 251 | + * @param userId 申请人用户编号 | |
| 252 | + * @param reqVO 申请请求 | |
| 253 | + * @return 申请记录 | |
| 254 | + */ | |
| 255 | + private ImGroupRequestDO createOrResetApplyRequest(Long groupId, Long userId, ImGroupRequestApplyReqVO reqVO) { | |
| 256 | + // 1. 已有申请:覆盖本次申请内容,并重置为未处理 | |
| 257 | + ImGroupRequestDO request = groupRequestMapper.selectByGroupIdAndUserId(groupId, userId); | |
| 258 | + if (request != null) { | |
| 259 | + resetApplyRequest(request, reqVO); | |
| 260 | + return request; | |
| 261 | + } | |
| 262 | + // 2. 无旧申请:创建主动申请记录 | |
| 263 | + request = BeanUtils.toBean(reqVO, ImGroupRequestDO.class) | |
| 264 | + .setUserId(userId).setInviterUserId(null) | |
| 265 | + .setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult()); | |
| 266 | + try { | |
| 267 | + groupRequestMapper.insert(request); | |
| 268 | + return request; | |
| 269 | + } catch (DuplicateKeyException ex) { | |
| 270 | + // 3. 唯一键冲突:回查并复用并发写入的记录 | |
| 271 | + request = groupRequestMapper.selectByGroupIdAndUserId(groupId, userId); | |
| 272 | + if (request == null) { | |
| 273 | + throw ex; | |
| 274 | + } | |
| 275 | + resetApplyRequest(request, reqVO); | |
| 276 | + return request; | |
| 277 | + } | |
| 278 | + } | |
| 279 | + | |
| 280 | + /** | |
| 281 | + * 创建或重置邀请加群申请 | |
| 282 | + * | |
| 283 | + * @param groupId 群编号 | |
| 284 | + * @param inviterUserId 邀请人用户编号 | |
| 285 | + * @param userId 被邀请人用户编号 | |
| 286 | + * @param inviteSource 邀请来源 | |
| 287 | + * @return 申请记录 | |
| 288 | + */ | |
| 289 | + private ImGroupRequestDO createOrResetInviteRequest(Long groupId, Long inviterUserId, | |
| 290 | + Long userId, Integer inviteSource) { | |
| 291 | + // 1. 已有申请:覆盖邀请人和来源,并重置为未处理 | |
| 292 | + ImGroupRequestDO request = groupRequestMapper.selectByGroupIdAndUserId(groupId, userId); | |
| 293 | + if (request != null) { | |
| 294 | + resetInviteRequest(request, inviterUserId, inviteSource); | |
| 295 | + return request; | |
| 296 | + } | |
| 297 | + // 2. 无旧申请:创建邀请申请记录 | |
| 298 | + request = new ImGroupRequestDO().setGroupId(groupId).setUserId(userId).setInviterUserId(inviterUserId) | |
| 299 | + .setAddSource(inviteSource).setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult()); | |
| 300 | + try { | |
| 301 | + groupRequestMapper.insert(request); | |
| 302 | + return request; | |
| 303 | + } catch (DuplicateKeyException ex) { | |
| 304 | + // 3. 唯一键冲突:回查并复用并发写入的记录 | |
| 305 | + request = groupRequestMapper.selectByGroupIdAndUserId(groupId, userId); | |
| 306 | + if (request == null) { | |
| 307 | + throw ex; | |
| 308 | + } | |
| 309 | + resetInviteRequest(request, inviterUserId, inviteSource); | |
| 310 | + return request; | |
| 311 | + } | |
| 312 | + } | |
| 313 | + | |
| 314 | + /** | |
| 315 | + * 重置主动加群申请 | |
| 316 | + * | |
| 317 | + * @param request 申请记录 | |
| 318 | + * @param reqVO 申请请求 | |
| 319 | + */ | |
| 320 | + private void resetApplyRequest(ImGroupRequestDO request, ImGroupRequestApplyReqVO reqVO) { | |
| 321 | + // 1. 更新申请内容、来源和处理状态 | |
| 322 | + LocalDateTime now = LocalDateTime.now(); | |
| 323 | + groupRequestMapper.updateApplyByIdReset(request.getId(), | |
| 324 | + reqVO.getApplyContent(), reqVO.getAddSource(), now); | |
| 325 | + // 2. 同步内存对象,后续通知构建直接复用 | |
| 326 | + request.setApplyContent(reqVO.getApplyContent()).setAddSource(reqVO.getAddSource()) | |
| 327 | + .setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult()) | |
| 328 | + .setInviterUserId(null).setHandleUserId(null) | |
| 329 | + .setHandleContent(null).setHandleTime(null).setUpdateTime(now); | |
| 330 | + } | |
| 331 | + | |
| 332 | + /** | |
| 333 | + * 重置邀请加群申请 | |
| 334 | + * | |
| 335 | + * @param request 申请记录 | |
| 336 | + * @param inviterUserId 邀请人用户编号 | |
| 337 | + * @param inviteSource 邀请来源 | |
| 338 | + */ | |
| 339 | + private void resetInviteRequest(ImGroupRequestDO request, Long inviterUserId, Integer inviteSource) { | |
| 340 | + // 1. 更新邀请人、来源和处理状态 | |
| 341 | + LocalDateTime now = LocalDateTime.now(); | |
| 342 | + groupRequestMapper.updateInviteByIdReset(request.getId(), inviterUserId, inviteSource, now); | |
| 343 | + // 2. 同步内存对象,后续通知构建直接复用 | |
| 344 | + request.setInviterUserId(inviterUserId).setAddSource(inviteSource) | |
| 345 | + .setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult()) | |
| 346 | + .setHandleUserId(null).setHandleContent(null).setHandleTime(null) | |
| 347 | + .setUpdateTime(now); | |
| 348 | + } | |
| 349 | + | |
| 350 | + /** | |
| 287 | 351 | * 校验申请可被「当前用户」处理:申请存在 + 未处理 + 操作人是群主 / 管理员 |
| 288 | 352 | */ |
| 289 | 353 | private ImGroupRequestDO validateRequestForHandle(Long userId, Long requestId) { | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java
| ... | ... | @@ -316,7 +316,7 @@ public class ImGroupServiceImpl implements ImGroupService { |
| 316 | 316 | @Override |
| 317 | 317 | @Transactional(rollbackFor = Exception.class) |
| 318 | 318 | public void addGroupAdmin(Long userId, ImGroupAdminAddReqVO reqVO) { |
| 319 | - Long groupId = reqVO.getGroupId(); | |
| 319 | + Long groupId = reqVO.getId(); | |
| 320 | 320 | Set<Long> targetUserIds = new HashSet<>(reqVO.getUserIds()); |
| 321 | 321 | // 1.1 仅群主可操作 |
| 322 | 322 | validateGroupOwnerForUpdate(groupId, userId); |
| ... | ... | @@ -354,7 +354,7 @@ public class ImGroupServiceImpl implements ImGroupService { |
| 354 | 354 | @Override |
| 355 | 355 | @Transactional(rollbackFor = Exception.class) |
| 356 | 356 | public void removeGroupAdmin(Long userId, ImGroupAdminRemoveReqVO reqVO) { |
| 357 | - Long groupId = reqVO.getGroupId(); | |
| 357 | + Long groupId = reqVO.getId(); | |
| 358 | 358 | Set<Long> targetUserIds = new HashSet<>(reqVO.getUserIds()); |
| 359 | 359 | // 1.1 仅群主可操作 |
| 360 | 360 | validateGroupOwnerForUpdate(groupId, userId); |
| ... | ... | @@ -398,10 +398,10 @@ public class ImGroupServiceImpl implements ImGroupService { |
| 398 | 398 | } |
| 399 | 399 | |
| 400 | 400 | @Override |
| 401 | - @CacheEvict(cacheNames = GROUP, key = "#transferReqVO.groupId") | |
| 401 | + @CacheEvict(cacheNames = GROUP, key = "#transferReqVO.id") | |
| 402 | 402 | @Transactional(rollbackFor = Exception.class) |
| 403 | 403 | public void transferGroupOwner(Long userId, ImGroupTransferOwnerReqVO transferReqVO) { |
| 404 | - Long groupId = transferReqVO.getGroupId(); | |
| 404 | + Long groupId = transferReqVO.getId(); | |
| 405 | 405 | Long newOwnerUserId = transferReqVO.getNewOwnerUserId(); |
| 406 | 406 | // 1.1 仅老群主可执行 |
| 407 | 407 | validateGroupOwnerForUpdate(groupId, userId); |
| ... | ... | @@ -677,19 +677,19 @@ public class ImGroupServiceImpl implements ImGroupService { |
| 677 | 677 | // ==================== 群禁言 ==================== |
| 678 | 678 | |
| 679 | 679 | @Override |
| 680 | - @CacheEvict(cacheNames = GROUP, key = "#reqVO.groupId") | |
| 680 | + @CacheEvict(cacheNames = GROUP, key = "#reqVO.id") | |
| 681 | 681 | @Transactional(rollbackFor = Exception.class) |
| 682 | 682 | public void muteAll(Long userId, ImGroupMuteAllReqVO reqVO) { |
| 683 | 683 | // 1. 校验群主或管理员 |
| 684 | - validateGroupOwnerOrAdmin(reqVO.getGroupId(), userId); | |
| 684 | + validateGroupOwnerOrAdmin(reqVO.getId(), userId); | |
| 685 | 685 | |
| 686 | 686 | // 2. 更新 mutedAll |
| 687 | - groupMapper.updateById(new ImGroupDO().setId(reqVO.getGroupId()).setMutedAll(reqVO.getMutedAll())); | |
| 687 | + groupMapper.updateById(new ImGroupDO().setId(reqVO.getId()).setMutedAll(reqVO.getMutedAll())); | |
| 688 | 688 | |
| 689 | 689 | // 3. 广播通知 |
| 690 | 690 | ImGroupMessageSendDTO messageSendDTO = Boolean.TRUE.equals(reqVO.getMutedAll()) |
| 691 | - ? ImGroupMessageSendDTO.ofGroupMuted(reqVO.getGroupId(), userId) | |
| 692 | - : ImGroupMessageSendDTO.ofGroupCancelMuted(reqVO.getGroupId(), userId); | |
| 691 | + ? ImGroupMessageSendDTO.ofGroupMuted(reqVO.getId(), userId) | |
| 692 | + : ImGroupMessageSendDTO.ofGroupCancelMuted(reqVO.getId(), userId); | |
| 693 | 693 | groupMessageService.sendGroupMessage(userId, messageSendDTO); |
| 694 | 694 | } |
| 695 | 695 | |
| ... | ... | @@ -701,21 +701,21 @@ public class ImGroupServiceImpl implements ImGroupService { |
| 701 | 701 | throw exception(GROUP_MUTE_MEMBER_SELF); |
| 702 | 702 | } |
| 703 | 703 | // 1.2 校验群存在且未封禁 |
| 704 | - validateGroupExists(reqVO.getGroupId()); | |
| 704 | + validateGroupExists(reqVO.getId()); | |
| 705 | 705 | // 1.3 校验操作人和目标都在群中 |
| 706 | - ImGroupMemberDO operatorMember = groupMemberService.validateMemberInGroup(reqVO.getGroupId(), userId); | |
| 707 | - ImGroupMemberDO targetMember = groupMemberService.validateMemberInGroup(reqVO.getGroupId(), reqVO.getUserId()); | |
| 706 | + ImGroupMemberDO operatorMember = groupMemberService.validateMemberInGroup(reqVO.getId(), userId); | |
| 707 | + ImGroupMemberDO targetMember = groupMemberService.validateMemberInGroup(reqVO.getId(), reqVO.getUserId()); | |
| 708 | 708 | // 1.4 三档权限校验 |
| 709 | 709 | validateMutePermission(operatorMember, targetMember); |
| 710 | 710 | |
| 711 | 711 | // 2. 设置 muteEndTime |
| 712 | 712 | LocalDateTime muteEndTime = reqVO.getMutedSeconds() == 0 |
| 713 | 713 | ? ImGroupMemberDO.PERMANENT_MUTE_END_TIME : LocalDateTime.now().plusSeconds(reqVO.getMutedSeconds()); |
| 714 | - groupMemberService.updateGroupMemberMuteEndTime(reqVO.getGroupId(), reqVO.getUserId(), muteEndTime); | |
| 714 | + groupMemberService.updateGroupMemberMuteEndTime(reqVO.getId(), reqVO.getUserId(), muteEndTime); | |
| 715 | 715 | |
| 716 | 716 | // 3. 广播通知 |
| 717 | 717 | groupMessageService.sendGroupMessage(userId, |
| 718 | - ImGroupMessageSendDTO.ofGroupMemberMuted(reqVO.getGroupId(), userId, | |
| 718 | + ImGroupMessageSendDTO.ofGroupMemberMuted(reqVO.getId(), userId, | |
| 719 | 719 | reqVO.getUserId(), muteEndTime)); |
| 720 | 720 | } |
| 721 | 721 | |
| ... | ... | @@ -723,19 +723,19 @@ public class ImGroupServiceImpl implements ImGroupService { |
| 723 | 723 | @Transactional(rollbackFor = Exception.class) |
| 724 | 724 | public void cancelMuteMember(Long userId, ImGroupCancelMuteMemberReqVO reqVO) { |
| 725 | 725 | // 1.1 校验群存在且未封禁 |
| 726 | - validateGroupExists(reqVO.getGroupId()); | |
| 726 | + validateGroupExists(reqVO.getId()); | |
| 727 | 727 | // 1.2 校验操作人和目标都在群中 |
| 728 | - ImGroupMemberDO operatorMember = groupMemberService.validateMemberInGroup(reqVO.getGroupId(), userId); | |
| 729 | - ImGroupMemberDO targetMember = groupMemberService.validateMemberInGroup(reqVO.getGroupId(), reqVO.getUserId()); | |
| 728 | + ImGroupMemberDO operatorMember = groupMemberService.validateMemberInGroup(reqVO.getId(), userId); | |
| 729 | + ImGroupMemberDO targetMember = groupMemberService.validateMemberInGroup(reqVO.getId(), reqVO.getUserId()); | |
| 730 | 730 | // 1.3 三档权限校验 |
| 731 | 731 | validateMutePermission(operatorMember, targetMember); |
| 732 | 732 | |
| 733 | 733 | // 2. 取消禁言(清空 muteEndTime) |
| 734 | - groupMemberService.updateGroupMemberMuteEndTime(reqVO.getGroupId(), reqVO.getUserId(), null); | |
| 734 | + groupMemberService.updateGroupMemberMuteEndTime(reqVO.getId(), reqVO.getUserId(), null); | |
| 735 | 735 | |
| 736 | 736 | // 3. 广播通知 |
| 737 | 737 | groupMessageService.sendGroupMessage(userId, |
| 738 | - ImGroupMessageSendDTO.ofGroupMemberCancelMuted(reqVO.getGroupId(), userId, reqVO.getUserId())); | |
| 738 | + ImGroupMessageSendDTO.ofGroupMemberCancelMuted(reqVO.getId(), userId, reqVO.getUserId())); | |
| 739 | 739 | } |
| 740 | 740 | |
| 741 | 741 | /** | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallService.java
| ... | ... | @@ -34,7 +34,7 @@ public interface ImRtcCallService { |
| 34 | 34 | void inviteCall(Long userId, ImRtcCallInviteReqVO reqVO); |
| 35 | 35 | |
| 36 | 36 | /** |
| 37 | - * 加入已有群通话:用于群胶囊条「加入」按钮;旁观者作为 ACTIVE_JOIN 加入,邀请池内成员转 JOINED | |
| 37 | + * 加入已有群通话:用于群胶囊条「加入」按钮;旁观者作为 JOINER 加入,邀请池内成员转 JOINED | |
| 38 | 38 | * |
| 39 | 39 | * @param userId 加入者用户编号 |
| 40 | 40 | * @param room 业务通话编号;从胶囊条 activeCall 拿 | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java
| ... | ... | @@ -37,6 +37,7 @@ import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; |
| 37 | 37 | import jakarta.annotation.Resource; |
| 38 | 38 | import lombok.SneakyThrows; |
| 39 | 39 | import lombok.extern.slf4j.Slf4j; |
| 40 | +import org.springframework.dao.DuplicateKeyException; | |
| 40 | 41 | import org.springframework.stereotype.Service; |
| 41 | 42 | import org.springframework.transaction.annotation.Transactional; |
| 42 | 43 | import org.springframework.validation.annotation.Validated; |
| ... | ... | @@ -256,24 +257,12 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { |
| 256 | 257 | } |
| 257 | 258 | // 1.3 校验当前用户是该群有效成员;防止仅凭 room 就拿到 LiveKit token 越权入会 |
| 258 | 259 | groupMemberService.validateMemberInGroup(call.getGroupId(), userId); |
| 259 | - validateUserNotJoinedOtherCall(userId, call.getRoom()); | |
| 260 | + // 1.4 校验当前用户没有其它活跃通话 | |
| 261 | + validateUserNotInOtherCall(userId, call.getRoom()); | |
| 260 | 262 | |
| 261 | - // 2. 入参与表:已有记录切回 JOINED;不在记录则以 ACTIVE_JOIN 角色 INSERT | |
| 263 | + // 2. 入参与表:已有记录切回 JOINED;不在记录则以 JOINER 角色 INSERT | |
| 262 | 264 | LocalDateTime now = LocalDateTime.now(); |
| 263 | - ImRtcParticipantDO existing = rtcParticipantMapper.selectByRoomAndUserId(call.getRoom(), userId); | |
| 264 | - if (existing != null) { | |
| 265 | - // 非 JOINED 状态切回 JOINED + accept_time;INVITER / INVITEE 重连、旁观者点击胶囊条加入,都走这条 | |
| 266 | - if (!ImRtcParticipantStatusEnum.isJoined(existing.getStatus())) { | |
| 267 | - rtcParticipantMapper.updateById(new ImRtcParticipantDO().setId(existing.getId()) | |
| 268 | - .setStatus(ImRtcParticipantStatusEnum.JOINED.getStatus()).setAcceptTime(now)); | |
| 269 | - } | |
| 270 | - } else { | |
| 271 | - // 旁观者主动加入:INSERT role=ACTIVE_JOIN | |
| 272 | - rtcParticipantMapper.insert(new ImRtcParticipantDO() | |
| 273 | - .setCallId(call.getId()).setRoom(call.getRoom()) | |
| 274 | - .setUserId(userId).setRole(ImRtcParticipantRoleEnum.ACTIVE_JOIN.getRole()) | |
| 275 | - .setStatus(ImRtcParticipantStatusEnum.JOINED.getStatus()).setInviteTime(now).setAcceptTime(now)); | |
| 276 | - } | |
| 265 | + joinParticipant(call, userId, now); | |
| 277 | 266 | |
| 278 | 267 | // 3. 主表 CREATED → RUNNING(首次有非发起人加入) |
| 279 | 268 | maybeMarkOngoing(call, userId, now); |
| ... | ... | @@ -291,13 +280,19 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { |
| 291 | 280 | // 1.1 校验被邀请人都是群活跃成员 |
| 292 | 281 | groupMemberService.validateMembersInGroup(call.getGroupId(), inviteeIds); |
| 293 | 282 | // 1.2 排除已在通话池的;剩余即本次新邀请 |
| 294 | - Set<Long> existingUserIds = CollectionUtils.convertSet( | |
| 295 | - rtcParticipantMapper.selectListByRoom(call.getRoom()), ImRtcParticipantDO::getUserId); | |
| 283 | + List<ImRtcParticipantDO> existingParticipants = rtcParticipantMapper.selectListByRoom(call.getRoom()); | |
| 284 | + Set<Long> existingUserIds = CollectionUtils.convertSet(existingParticipants, ImRtcParticipantDO::getUserId); | |
| 296 | 285 | Set<Long> incomingUserIds = new LinkedHashSet<>(inviteeIds); |
| 297 | 286 | incomingUserIds.removeAll(existingUserIds); |
| 298 | 287 | if (CollUtil.isEmpty(incomingUserIds)) { |
| 299 | 288 | return; |
| 300 | 289 | } |
| 290 | + long activeCount = existingParticipants.stream() | |
| 291 | + .filter(participant -> ImRtcParticipantStatusEnum.ACTIVE_STATUSES.contains(participant.getStatus())) | |
| 292 | + .count(); | |
| 293 | + if (activeCount + incomingUserIds.size() > imProperties.getRtc().getGroupMaxParticipants()) { | |
| 294 | + throw exception(RTC_GROUP_INVITEE_OVER_LIMIT); | |
| 295 | + } | |
| 301 | 296 | |
| 302 | 297 | // 2. 批量 INSERT 新邀请人 |
| 303 | 298 | LocalDateTime now = LocalDateTime.now(); |
| ... | ... | @@ -328,7 +323,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { |
| 328 | 323 | if (ImRtcParticipantStatusEnum.isJoined(participant.getStatus())) { |
| 329 | 324 | return call; |
| 330 | 325 | } |
| 331 | - validateUserNotJoinedOtherCall(userId, call.getRoom()); | |
| 326 | + validateUserNotInOtherCall(userId, call.getRoom()); | |
| 332 | 327 | // 2.2 仅 INVITING → JOINED;其它状态拒 |
| 333 | 328 | if (!ImRtcParticipantStatusEnum.isInviting(participant.getStatus())) { |
| 334 | 329 | throw exception(RTC_SESSION_NOT_EXISTS); |
| ... | ... | @@ -760,15 +755,70 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { |
| 760 | 755 | return participant; |
| 761 | 756 | } |
| 762 | 757 | |
| 763 | - private void validateUserNotJoinedOtherCall(Long userId, String room) { | |
| 764 | - ImRtcParticipantDO joined = rtcParticipantMapper.selectLastOneByUserIdAndStatusInAndRoomNot( | |
| 765 | - userId, Collections.singleton(ImRtcParticipantStatusEnum.JOINED.getStatus()), room); | |
| 766 | - if (joined != null) { | |
| 758 | + /** | |
| 759 | + * 校验用户不在其它活跃通话中 | |
| 760 | + * | |
| 761 | + * @param userId 用户编号 | |
| 762 | + * @param room 当前房间标识 | |
| 763 | + */ | |
| 764 | + private void validateUserNotInOtherCall(Long userId, String room) { | |
| 765 | + // 查询当前房间外的活跃参与记录 | |
| 766 | + ImRtcParticipantDO participant = rtcParticipantMapper.selectLastOneByUserIdAndStatusInAndRoomNot( | |
| 767 | + userId, ImRtcParticipantStatusEnum.ACTIVE_STATUSES, room); | |
| 768 | + // 存在活跃参与记录,则当前用户忙线 | |
| 769 | + if (participant != null) { | |
| 767 | 770 | throw exception(RTC_SELF_BUSY); |
| 768 | 771 | } |
| 769 | 772 | } |
| 770 | 773 | |
| 771 | 774 | /** |
| 775 | + * 加入群通话参与者列表 | |
| 776 | + * | |
| 777 | + * @param call 通话主表 | |
| 778 | + * @param userId 用户编号 | |
| 779 | + * @param now 当前时间 | |
| 780 | + */ | |
| 781 | + private void joinParticipant(ImRtcCallDO call, Long userId, LocalDateTime now) { | |
| 782 | + // 1. 已有参与记录:切回 JOINED | |
| 783 | + ImRtcParticipantDO existing = rtcParticipantMapper.selectByRoomAndUserId(call.getRoom(), userId); | |
| 784 | + if (existing != null) { | |
| 785 | + updateParticipantJoined(existing, now); | |
| 786 | + return; | |
| 787 | + } | |
| 788 | + | |
| 789 | + // 2. 无参与记录:以主动加入者身份新增 | |
| 790 | + try { | |
| 791 | + rtcParticipantMapper.insert(new ImRtcParticipantDO() | |
| 792 | + .setCallId(call.getId()).setRoom(call.getRoom()) | |
| 793 | + .setUserId(userId).setRole(ImRtcParticipantRoleEnum.JOINER.getRole()) | |
| 794 | + .setStatus(ImRtcParticipantStatusEnum.JOINED.getStatus()).setInviteTime(now).setAcceptTime(now)); | |
| 795 | + } catch (DuplicateKeyException ex) { | |
| 796 | + // 3. 唯一键冲突:回查并复用并发写入的记录 | |
| 797 | + existing = rtcParticipantMapper.selectByRoomAndUserId(call.getRoom(), userId); | |
| 798 | + if (existing == null) { | |
| 799 | + throw ex; | |
| 800 | + } | |
| 801 | + updateParticipantJoined(existing, now); | |
| 802 | + } | |
| 803 | + } | |
| 804 | + | |
| 805 | + /** | |
| 806 | + * 将参与者更新为已加入 | |
| 807 | + * | |
| 808 | + * @param participant 参与者记录 | |
| 809 | + * @param now 当前时间 | |
| 810 | + */ | |
| 811 | + private void updateParticipantJoined(ImRtcParticipantDO participant, LocalDateTime now) { | |
| 812 | + // 已是 JOINED 直接返回 | |
| 813 | + if (ImRtcParticipantStatusEnum.isJoined(participant.getStatus())) { | |
| 814 | + return; | |
| 815 | + } | |
| 816 | + // 更新状态和接听时间 | |
| 817 | + rtcParticipantMapper.updateById(new ImRtcParticipantDO().setId(participant.getId()) | |
| 818 | + .setStatus(ImRtcParticipantStatusEnum.JOINED.getStatus()).setAcceptTime(now)); | |
| 819 | + } | |
| 820 | + | |
| 821 | + /** | |
| 772 | 822 | * 关房判定收口:私聊任一方离开必关;群通话仅在「无人在房 + 无人响铃」时关 |
| 773 | 823 | * <p> |
| 774 | 824 | * end reason 按 call.status 自动推:CREATED(没人接通过)= CANCEL;RUNNING(有人接通过)= HANGUP |
| ... | ... | @@ -958,7 +1008,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { |
| 958 | 1008 | } |
| 959 | 1009 | |
| 960 | 1010 | /** |
| 961 | - * RTC_CALL(REJECT):仅群通话场景;走 webSocketService 直推主叫 | |
| 1011 | + * RTC_CALL(REJECT):仅群通话场景;走 webSocketService 推给群通话受众 | |
| 962 | 1012 | * <p> |
| 963 | 1013 | * 私聊拒绝走 endSession → RTC_CALL_END(reason=REJECT) 入消息流,不在此推 |
| 964 | 1014 | * |
| ... | ... | @@ -968,12 +1018,14 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { |
| 968 | 1018 | private void pushCallRejectNotification(ImRtcCallDO call, Long operatorUserId) { |
| 969 | 1019 | AdminUserRespDTO operator = operatorUserId != null ? adminUserApi.getUser(operatorUserId) : null; |
| 970 | 1020 | ImRtcCallNotification payload = ImRtcCallNotification.ofReject(call, operatorUserId, operator); |
| 971 | - webSocketService.sendPrivateMessageAsync(call.getInviterUserId(), ImPrivateMessageDTO.ofRtcNotification( | |
| 972 | - ImMessageTypeEnum.RTC_CALL.getType(), operatorUserId, call.getInviterUserId(), payload)); | |
| 1021 | + for (Long receiverUserId : getCallAudienceUserIdList(call)) { | |
| 1022 | + webSocketService.sendPrivateMessageAsync(receiverUserId, ImPrivateMessageDTO.ofRtcNotification( | |
| 1023 | + ImMessageTypeEnum.RTC_CALL.getType(), operatorUserId, receiverUserId, payload)); | |
| 1024 | + } | |
| 973 | 1025 | } |
| 974 | 1026 | |
| 975 | 1027 | /** |
| 976 | - * RTC_CALL(NO_ANSWER):仅群通话场景;振铃超时由 Job 触发;走 webSocketService 直推主叫 | |
| 1028 | + * RTC_CALL(NO_ANSWER):仅群通话场景;振铃超时由 Job 触发;走 webSocketService 推给群通话受众 | |
| 977 | 1029 | * <p> |
| 978 | 1030 | * 私聊未接听走 endSession → RTC_CALL_END(reason=NO_ANSWER) 入消息流,不在此推 |
| 979 | 1031 | * |
| ... | ... | @@ -983,8 +1035,10 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { |
| 983 | 1035 | */ |
| 984 | 1036 | private void pushCallNoAnswerNotification(ImRtcCallDO call, Long operatorUserId, AdminUserRespDTO operator) { |
| 985 | 1037 | ImRtcCallNotification payload = ImRtcCallNotification.ofNoAnswer(call, operatorUserId, operator); |
| 986 | - webSocketService.sendPrivateMessageAsync(call.getInviterUserId(), ImPrivateMessageDTO.ofRtcNotification( | |
| 987 | - ImMessageTypeEnum.RTC_CALL.getType(), operatorUserId, call.getInviterUserId(), payload)); | |
| 1038 | + for (Long receiverUserId : getCallAudienceUserIdList(call)) { | |
| 1039 | + webSocketService.sendPrivateMessageAsync(receiverUserId, ImPrivateMessageDTO.ofRtcNotification( | |
| 1040 | + ImMessageTypeEnum.RTC_CALL.getType(), operatorUserId, receiverUserId, payload)); | |
| 1041 | + } | |
| 988 | 1042 | } |
| 989 | 1043 | |
| 990 | 1044 | /** | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupAdminAddNotification.java
| ... | ... | @@ -3,5 +3,5 @@ package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; |
| 3 | 3 | /** |
| 4 | 4 | * 添加管理员事件通知(memberUserIds 为被设管理员的成员) |
| 5 | 5 | */ |
| 6 | -public class GroupAdminAddNotification extends GroupMembersAffectedNotification { | |
| 6 | +public class GroupAdminAddNotification extends GroupMemberListNotification { | |
| 7 | 7 | } | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupAdminRemoveNotification.java
| ... | ... | @@ -3,5 +3,5 @@ package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; |
| 3 | 3 | /** |
| 4 | 4 | * 撤销管理员事件通知(memberUserIds 为被撤销管理员的成员) |
| 5 | 5 | */ |
| 6 | -public class GroupAdminRemoveNotification extends GroupMembersAffectedNotification { | |
| 6 | +public class GroupAdminRemoveNotification extends GroupMemberListNotification { | |
| 7 | 7 | } | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupCreateNotification.java
| ... | ... | @@ -3,5 +3,5 @@ package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; |
| 3 | 3 | /** |
| 4 | 4 | * 群创建事件通知(memberUserIds 含创建者 + 初始邀请成员) |
| 5 | 5 | */ |
| 6 | -public class GroupCreateNotification extends GroupMembersAffectedNotification { | |
| 6 | +public class GroupCreateNotification extends GroupMemberListNotification { | |
| 7 | 7 | } | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberInviteNotification.java
| ... | ... | @@ -3,5 +3,5 @@ package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; |
| 3 | 3 | /** |
| 4 | 4 | * 成员加入事件通知(memberUserIds 为被邀请人) |
| 5 | 5 | */ |
| 6 | -public class GroupMemberInviteNotification extends GroupMembersAffectedNotification { | |
| 6 | +public class GroupMemberInviteNotification extends GroupMemberListNotification { | |
| 7 | 7 | } | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberKickNotification.java
| ... | ... | @@ -3,5 +3,5 @@ package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group; |
| 3 | 3 | /** |
| 4 | 4 | * 成员被移出事件通知(memberUserIds 为被移出成员) |
| 5 | 5 | */ |
| 6 | -public class GroupMemberKickNotification extends GroupMembersAffectedNotification { | |
| 6 | +public class GroupMemberKickNotification extends GroupMemberListNotification { | |
| 7 | 7 | } | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMembersAffectedNotification.java renamed to yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberListNotification.java
| ... | ... | @@ -4,13 +4,13 @@ import lombok.Data; |
| 4 | 4 | |
| 5 | 5 | import java.util.List; |
| 6 | 6 | |
| 7 | -// TODO DONE @AI:本身已是 BaseGroupNotification 的子类,名字不带 Group 前缀是因为是抽象基类、被多种 Group* 通知共用 | |
| 8 | -// TODO @AI:它目前只被 group 使用。别过度设计,名字按照我说的调整下; | |
| 9 | 7 | /** |
| 10 | - * 涉及成员列表的群事件通知基类:用于 Create / Invite / Kick / AdminAdd / AdminRemove | |
| 8 | + * 群事件成员列表通知基类 | |
| 9 | + * | |
| 10 | + * @author 芋道源码 | |
| 11 | 11 | */ |
| 12 | 12 | @Data |
| 13 | -public abstract class GroupMembersAffectedNotification extends BaseGroupNotification { | |
| 13 | +public abstract class GroupMemberListNotification extends BaseGroupNotification { | |
| 14 | 14 | |
| 15 | 15 | /** |
| 16 | 16 | * 受影响的成员用户编号列表 | ... | ... |
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestServiceImplTest.java
| ... | ... | @@ -18,6 +18,7 @@ import org.junit.jupiter.api.Test; |
| 18 | 18 | import org.mockito.ArgumentCaptor; |
| 19 | 19 | import org.mockito.InjectMocks; |
| 20 | 20 | import org.mockito.Mock; |
| 21 | +import org.springframework.dao.DuplicateKeyException; | |
| 21 | 22 | |
| 22 | 23 | import java.time.LocalDateTime; |
| 23 | 24 | import java.util.List; |
| ... | ... | @@ -187,6 +188,31 @@ public class ImFriendRequestServiceImplTest extends BaseMockitoUnitTest { |
| 187 | 188 | assertNull(result.getHandleTime()); |
| 188 | 189 | } |
| 189 | 190 | |
| 191 | + @Test | |
| 192 | + public void testApplyFriend_insertDuplicateKey_reuseOldRequest() { | |
| 193 | + // 准备:首次查询不存在,插入时命中唯一键,回查到并发写入的旧申请 | |
| 194 | + ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO(); | |
| 195 | + reqVO.setToUserId(2L).setApplyContent("并发申请").setDisplayName("老张").setAddSource(2); | |
| 196 | + when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.NONE.getState()); | |
| 197 | + when(friendService.getFriend(2L, 1L)).thenReturn(null); | |
| 198 | + ImFriendRequestDO old = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L) | |
| 199 | + .setHandleResult(ImFriendRequestHandleResultEnum.REFUSED.getResult()); | |
| 200 | + when(friendRequestMapper.selectByFromUserIdAndToUserId(1L, 2L)).thenReturn(null, old); | |
| 201 | + when(friendRequestMapper.insert(any(ImFriendRequestDO.class))).thenThrow(new DuplicateKeyException("dup")); | |
| 202 | + when(adminUserApi.getUser(1L)).thenReturn(null); | |
| 203 | + when(imProperties.getFriend()).thenReturn(new ImProperties.Friend()); | |
| 204 | + | |
| 205 | + // 调用 | |
| 206 | + ImFriendRequestDO result = friendRequestService.applyFriend(1L, reqVO); | |
| 207 | + | |
| 208 | + // 断言:复用并重置旧申请,不向上抛数据库异常 | |
| 209 | + verify(friendRequestMapper).updateByIdReset(eq(100L), eq("并发申请"), eq("老张"), eq(2), | |
| 210 | + any(LocalDateTime.class)); | |
| 211 | + assertEquals(100L, result.getId()); | |
| 212 | + assertEquals(ImFriendRequestHandleResultEnum.UNHANDLED.getResult(), result.getHandleResult()); | |
| 213 | + verify(websocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); | |
| 214 | + } | |
| 215 | + | |
| 190 | 216 | // ========== agreeFriendRequest ========== |
| 191 | 217 | |
| 192 | 218 | @Test | ... | ... |
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupRequestServiceImplTest.java
| ... | ... | @@ -22,6 +22,7 @@ import org.junit.jupiter.api.Test; |
| 22 | 22 | import org.mockito.ArgumentCaptor; |
| 23 | 23 | import org.mockito.InjectMocks; |
| 24 | 24 | import org.mockito.Mock; |
| 25 | +import org.springframework.dao.DuplicateKeyException; | |
| 25 | 26 | |
| 26 | 27 | import java.util.Collection; |
| 27 | 28 | import java.util.List; |
| ... | ... | @@ -118,6 +119,39 @@ public class ImGroupRequestServiceImplTest extends BaseMockitoUnitTest { |
| 118 | 119 | } |
| 119 | 120 | |
| 120 | 121 | @Test |
| 122 | + public void testApplyJoinGroup_insertDuplicateKey_reuseOldRequest() { | |
| 123 | + // 准备:群是 APPLY 模式,首次查询不存在,插入时命中唯一键 | |
| 124 | + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L) | |
| 125 | + .joinApproval(true) | |
| 126 | + .status(CommonStatusEnum.ENABLE.getStatus()).build(); | |
| 127 | + when(groupService.validateGroupExists(10L)).thenReturn(group); | |
| 128 | + when(groupMemberService.getGroupMemberListByOwnerAndAdmin(10L)).thenReturn(List.of( | |
| 129 | + ImGroupMemberDO.builder().groupId(10L).userId(99L) | |
| 130 | + .role(ImGroupMemberRoleEnum.OWNER.getRole()) | |
| 131 | + .status(CommonStatusEnum.ENABLE.getStatus()).build())); | |
| 132 | + when(adminUserApi.getUser(1L)).thenReturn(buildUser(1L, "申请人")); | |
| 133 | + ImGroupRequestDO old = new ImGroupRequestDO().setId(50L).setGroupId(10L).setUserId(1L) | |
| 134 | + .setHandleResult(ImGroupRequestHandleResultEnum.REFUSED.getResult()); | |
| 135 | + when(groupRequestMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(null, old); | |
| 136 | + when(groupRequestMapper.insert(any(ImGroupRequestDO.class))).thenThrow(new DuplicateKeyException("dup")); | |
| 137 | + | |
| 138 | + ImGroupRequestApplyReqVO reqVO = new ImGroupRequestApplyReqVO(); | |
| 139 | + reqVO.setGroupId(10L); | |
| 140 | + reqVO.setApplyContent("我想进群"); | |
| 141 | + reqVO.setAddSource(ImGroupAddSourceEnum.SEARCH.getSource()); | |
| 142 | + | |
| 143 | + // 调用 | |
| 144 | + ImGroupRequestDO result = groupRequestService.applyJoinGroup(1L, reqVO); | |
| 145 | + | |
| 146 | + // 断言:复用并重置旧申请 | |
| 147 | + verify(groupRequestMapper).updateApplyByIdReset(eq(50L), eq("我想进群"), | |
| 148 | + eq(ImGroupAddSourceEnum.SEARCH.getSource()), any()); | |
| 149 | + assertEquals(50L, result.getId()); | |
| 150 | + assertEquals(ImGroupRequestHandleResultEnum.UNHANDLED.getResult(), result.getHandleResult()); | |
| 151 | + verify(websocketService).sendPrivateMessageAsync(eq(99L), any(ImPrivateMessageDTO.class)); | |
| 152 | + } | |
| 153 | + | |
| 154 | + @Test | |
| 121 | 155 | public void testApplyJoinGroup_alreadyMember_throws() { |
| 122 | 156 | ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L) |
| 123 | 157 | .joinApproval(true) |
| ... | ... | @@ -254,8 +288,7 @@ public class ImGroupRequestServiceImplTest extends BaseMockitoUnitTest { |
| 254 | 288 | .joinApproval(true) |
| 255 | 289 | .status(CommonStatusEnum.ENABLE.getStatus()).build(); |
| 256 | 290 | when(groupService.validateGroupExists(10L)).thenReturn(group); |
| 257 | - when(groupRequestMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection())) | |
| 258 | - .thenReturn(List.of()); | |
| 291 | + when(groupRequestMapper.selectByGroupIdAndUserId(eq(10L), anyLong())).thenReturn(null); | |
| 259 | 292 | when(groupMemberService.getGroupMemberListByOwnerAndAdmin(10L)).thenReturn(List.of( |
| 260 | 293 | ImGroupMemberDO.builder().groupId(10L).userId(99L) |
| 261 | 294 | .role(ImGroupMemberRoleEnum.OWNER.getRole()) |
| ... | ... | @@ -267,13 +300,12 @@ public class ImGroupRequestServiceImplTest extends BaseMockitoUnitTest { |
| 267 | 300 | // 调用:邀请人 1L 邀请 2L、3L(都没有旧记录) |
| 268 | 301 | groupRequestService.createInviteRequestList(10L, 1L, List.of(2L, 3L)); |
| 269 | 302 | |
| 270 | - // 断言:批量插入 2 条 + 推 1503 给 owner(每条 1 帧)共 2 帧 | |
| 271 | - @SuppressWarnings("unchecked") | |
| 272 | - ArgumentCaptor<Collection<ImGroupRequestDO>> captor = ArgumentCaptor.forClass(Collection.class); | |
| 273 | - verify(groupRequestMapper).insertBatch(captor.capture()); | |
| 303 | + // 断言:插入 2 条 + 推 1503 给 owner(每条 1 帧)共 2 帧 | |
| 304 | + ArgumentCaptor<ImGroupRequestDO> captor = ArgumentCaptor.forClass(ImGroupRequestDO.class); | |
| 305 | + verify(groupRequestMapper, times(2)).insert(captor.capture()); | |
| 274 | 306 | verify(websocketService, times(2)).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); |
| 275 | 307 | // 断言:每条记录 inviterUserId=1 + addSource=INVITE,避免审批通过后回写群成员留痕的来源为空 / 脏带旧值 |
| 276 | - Collection<ImGroupRequestDO> inserted = captor.getValue(); | |
| 308 | + Collection<ImGroupRequestDO> inserted = captor.getAllValues(); | |
| 277 | 309 | assertEquals(2, inserted.size()); |
| 278 | 310 | inserted.forEach(insert -> { |
| 279 | 311 | assertEquals(1L, insert.getInviterUserId()); |
| ... | ... | @@ -281,6 +313,31 @@ public class ImGroupRequestServiceImplTest extends BaseMockitoUnitTest { |
| 281 | 313 | }); |
| 282 | 314 | } |
| 283 | 315 | |
| 316 | + @Test | |
| 317 | + public void testCreateInviteRequestList_insertDuplicateKey_reuseOldRequest() { | |
| 318 | + ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L) | |
| 319 | + .joinApproval(true) | |
| 320 | + .status(CommonStatusEnum.ENABLE.getStatus()).build(); | |
| 321 | + when(groupService.validateGroupExists(10L)).thenReturn(group); | |
| 322 | + ImGroupRequestDO old = new ImGroupRequestDO().setId(50L).setGroupId(10L).setUserId(2L) | |
| 323 | + .setHandleResult(ImGroupRequestHandleResultEnum.REFUSED.getResult()); | |
| 324 | + when(groupRequestMapper.selectByGroupIdAndUserId(10L, 2L)).thenReturn(null, old); | |
| 325 | + when(groupRequestMapper.insert(any(ImGroupRequestDO.class))).thenThrow(new DuplicateKeyException("dup")); | |
| 326 | + when(groupMemberService.getGroupMemberListByOwnerAndAdmin(10L)).thenReturn(List.of( | |
| 327 | + ImGroupMemberDO.builder().groupId(10L).userId(99L) | |
| 328 | + .role(ImGroupMemberRoleEnum.OWNER.getRole()) | |
| 329 | + .status(CommonStatusEnum.ENABLE.getStatus()).build())); | |
| 330 | + when(adminUserApi.getUserMap(anyCollection())).thenReturn(java.util.Map.of(2L, buildUser(2L, "用户A"))); | |
| 331 | + | |
| 332 | + // 调用 | |
| 333 | + groupRequestService.createInviteRequestList(10L, 1L, List.of(2L)); | |
| 334 | + | |
| 335 | + // 断言:复用并重置旧邀请申请 | |
| 336 | + verify(groupRequestMapper).updateInviteByIdReset(eq(50L), eq(1L), | |
| 337 | + eq(ImGroupAddSourceEnum.INVITE.getSource()), any()); | |
| 338 | + verify(websocketService).sendPrivateMessageAsync(eq(99L), any(ImPrivateMessageDTO.class)); | |
| 339 | + } | |
| 340 | + | |
| 284 | 341 | private AdminUserRespDTO buildUser(Long id, String nickname) { |
| 285 | 342 | AdminUserRespDTO user = new AdminUserRespDTO(); |
| 286 | 343 | user.setId(id); | ... | ... |
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java
| ... | ... | @@ -769,7 +769,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { |
| 769 | 769 | eq(ImGroupMemberRoleEnum.ADMIN.getRole()))).thenReturn(1); |
| 770 | 770 | |
| 771 | 771 | ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO(); |
| 772 | - reqVO.setGroupId(10L); | |
| 772 | + reqVO.setId(10L); | |
| 773 | 773 | reqVO.setUserIds(List.of(3L)); |
| 774 | 774 | |
| 775 | 775 | groupService.addGroupAdmin(1L, reqVO); |
| ... | ... | @@ -797,7 +797,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { |
| 797 | 797 | .thenReturn(3L); |
| 798 | 798 | |
| 799 | 799 | ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO(); |
| 800 | - reqVO.setGroupId(10L); | |
| 800 | + reqVO.setId(10L); | |
| 801 | 801 | reqVO.setUserIds(List.of(5L)); |
| 802 | 802 | |
| 803 | 803 | ServiceException exception = assertThrows(ServiceException.class, |
| ... | ... | @@ -821,7 +821,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { |
| 821 | 821 | .role(ImGroupMemberRoleEnum.OWNER.getRole()).build())); |
| 822 | 822 | |
| 823 | 823 | ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO(); |
| 824 | - reqVO.setGroupId(10L); | |
| 824 | + reqVO.setId(10L); | |
| 825 | 825 | reqVO.setUserIds(List.of(1L)); |
| 826 | 826 | |
| 827 | 827 | ServiceException exception = assertThrows(ServiceException.class, |
| ... | ... | @@ -845,7 +845,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { |
| 845 | 845 | .role(ImGroupMemberRoleEnum.ADMIN.getRole()).build())); |
| 846 | 846 | |
| 847 | 847 | ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO(); |
| 848 | - reqVO.setGroupId(10L); | |
| 848 | + reqVO.setId(10L); | |
| 849 | 849 | reqVO.setUserIds(List.of(2L)); |
| 850 | 850 | |
| 851 | 851 | groupService.addGroupAdmin(1L, reqVO); |
| ... | ... | @@ -873,7 +873,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { |
| 873 | 873 | eq(ImGroupMemberRoleEnum.NORMAL.getRole()))).thenReturn(1); |
| 874 | 874 | |
| 875 | 875 | ImGroupAdminRemoveReqVO reqVO = new ImGroupAdminRemoveReqVO(); |
| 876 | - reqVO.setGroupId(10L); | |
| 876 | + reqVO.setId(10L); | |
| 877 | 877 | reqVO.setUserIds(List.of(2L)); |
| 878 | 878 | |
| 879 | 879 | groupService.removeGroupAdmin(1L, reqVO); |
| ... | ... | @@ -899,7 +899,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { |
| 899 | 899 | .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build())); |
| 900 | 900 | |
| 901 | 901 | ImGroupAdminRemoveReqVO reqVO = new ImGroupAdminRemoveReqVO(); |
| 902 | - reqVO.setGroupId(10L); | |
| 902 | + reqVO.setId(10L); | |
| 903 | 903 | reqVO.setUserIds(List.of(2L)); |
| 904 | 904 | |
| 905 | 905 | groupService.removeGroupAdmin(1L, reqVO); |
| ... | ... | @@ -928,7 +928,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { |
| 928 | 928 | eq(ImGroupMemberRoleEnum.NORMAL.getRole()))).thenReturn(1); |
| 929 | 929 | |
| 930 | 930 | ImGroupTransferOwnerReqVO reqVO = new ImGroupTransferOwnerReqVO(); |
| 931 | - reqVO.setGroupId(10L); | |
| 931 | + reqVO.setId(10L); | |
| 932 | 932 | reqVO.setNewOwnerUserId(2L); |
| 933 | 933 | |
| 934 | 934 | groupService.transferGroupOwner(1L, reqVO); |
| ... | ... | @@ -956,7 +956,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { |
| 956 | 956 | when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group); |
| 957 | 957 | |
| 958 | 958 | ImGroupTransferOwnerReqVO reqVO = new ImGroupTransferOwnerReqVO(); |
| 959 | - reqVO.setGroupId(10L); | |
| 959 | + reqVO.setId(10L); | |
| 960 | 960 | reqVO.setNewOwnerUserId(1L); |
| 961 | 961 | |
| 962 | 962 | ServiceException exception = assertThrows(ServiceException.class, |
| ... | ... | @@ -977,7 +977,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { |
| 977 | 977 | when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group); |
| 978 | 978 | |
| 979 | 979 | ImGroupTransferOwnerReqVO reqVO = new ImGroupTransferOwnerReqVO(); |
| 980 | - reqVO.setGroupId(10L); | |
| 980 | + reqVO.setId(10L); | |
| 981 | 981 | reqVO.setNewOwnerUserId(2L); |
| 982 | 982 | |
| 983 | 983 | ServiceException exception = assertThrows(ServiceException.class, |
| ... | ... | @@ -1046,7 +1046,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { |
| 1046 | 1046 | .role(ImGroupMemberRoleEnum.ADMIN.getRole()).build()); |
| 1047 | 1047 | |
| 1048 | 1048 | ImGroupMuteMemberReqVO reqVO = new ImGroupMuteMemberReqVO(); |
| 1049 | - reqVO.setGroupId(10L).setUserId(2L).setMutedSeconds(60); | |
| 1049 | + reqVO.setId(10L).setUserId(2L).setMutedSeconds(60); | |
| 1050 | 1050 | |
| 1051 | 1051 | ServiceException exception = assertThrows(ServiceException.class, |
| 1052 | 1052 | () -> groupService.muteMember(1L, reqVO)); | ... | ... |
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImplTest.java
| ... | ... | @@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.im.service.rtc; |
| 3 | 3 | import cn.iocoder.yudao.framework.common.exception.ServiceException; |
| 4 | 4 | import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; |
| 5 | 5 | import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallCreateReqVO; |
| 6 | +import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteReqVO; | |
| 6 | 7 | import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; |
| 7 | 8 | import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO; |
| 8 | 9 | import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO; |
| ... | ... | @@ -23,6 +24,7 @@ import org.junit.jupiter.api.Test; |
| 23 | 24 | import org.mockito.ArgumentCaptor; |
| 24 | 25 | import org.mockito.InjectMocks; |
| 25 | 26 | import org.mockito.Mock; |
| 27 | +import org.springframework.dao.DuplicateKeyException; | |
| 26 | 28 | |
| 27 | 29 | import java.time.Duration; |
| 28 | 30 | import java.time.LocalDateTime; |
| ... | ... | @@ -33,6 +35,7 @@ import java.util.Set; |
| 33 | 35 | import java.util.concurrent.Callable; |
| 34 | 36 | |
| 35 | 37 | import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.RTC_GROUP_INVITEE_REQUIRED; |
| 38 | +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.RTC_GROUP_INVITEE_OVER_LIMIT; | |
| 36 | 39 | import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.RTC_SELF_BUSY; |
| 37 | 40 | import static org.junit.jupiter.api.Assertions.*; |
| 38 | 41 | import static org.mockito.ArgumentMatchers.*; |
| ... | ... | @@ -125,6 +128,7 @@ public class ImRtcCallServiceImplTest extends BaseMockitoUnitTest { |
| 125 | 128 | .thenReturn(1); |
| 126 | 129 | ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.GROUP, 999L); |
| 127 | 130 | when(rtcCallMapper.selectByRoom("r1")).thenReturn(call); |
| 131 | + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(999L)).thenReturn(List.of(200L, 201L)); | |
| 128 | 132 | // 房内 2 个 JOINED + 1 个 INVITING → shouldCloseGroupRoom 返 false |
| 129 | 133 | when(rtcParticipantMapper.selectListByRoom("r1")).thenReturn(List.of( |
| 130 | 134 | buildParticipant(20L, "r1", 200L, ImRtcParticipantStatusEnum.JOINED), |
| ... | ... | @@ -221,6 +225,7 @@ public class ImRtcCallServiceImplTest extends BaseMockitoUnitTest { |
| 221 | 225 | .thenReturn(1); |
| 222 | 226 | ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.GROUP, 999L); |
| 223 | 227 | when(rtcCallMapper.selectByRoom("r1")).thenReturn(call); |
| 228 | + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(999L)).thenReturn(List.of(200L, 201L)); | |
| 224 | 229 | // 让 shouldCloseGroupRoom 返 false |
| 225 | 230 | when(rtcParticipantMapper.selectListByRoom("r1")).thenReturn(List.of( |
| 226 | 231 | buildParticipant(20L, "r1", 200L, ImRtcParticipantStatusEnum.JOINED), |
| ... | ... | @@ -295,6 +300,57 @@ public class ImRtcCallServiceImplTest extends BaseMockitoUnitTest { |
| 295 | 300 | verify(rtcParticipantMapper, never()).updateById(any(ImRtcParticipantDO.class)); |
| 296 | 301 | } |
| 297 | 302 | |
| 303 | + @Test | |
| 304 | + public void testJoinCall_insertDuplicateKey_reuseExistingParticipant() { | |
| 305 | + // 准备:群通话活跃,首次查询无参与者,插入时命中唯一键 | |
| 306 | + when(imProperties.getRtc()).thenReturn(new ImProperties.Rtc()); | |
| 307 | + ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.GROUP, 999L); | |
| 308 | + when(rtcCallMapper.selectByRoom("r1")).thenReturn(call); | |
| 309 | + when(groupMemberService.validateMemberInGroup(999L, 100L)).thenReturn(new ImGroupMemberDO()); | |
| 310 | + when(rtcParticipantMapper.selectLastOneByUserIdAndStatusInAndRoomNot(eq(100L), anyCollection(), eq("r1"))) | |
| 311 | + .thenReturn(null); | |
| 312 | + when(rtcParticipantMapper.selectByRoomAndUserId("r1", 100L)) | |
| 313 | + .thenReturn(null, buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.JOINED)); | |
| 314 | + when(rtcParticipantMapper.insert(any(ImRtcParticipantDO.class))).thenThrow(new DuplicateKeyException("dup")); | |
| 315 | + | |
| 316 | + // 调用 | |
| 317 | + ImRtcCallDO result = rtcCallService.joinCall(100L, "r1"); | |
| 318 | + | |
| 319 | + // 断言:不向上抛数据库异常 | |
| 320 | + assertSame(call, result); | |
| 321 | + verify(rtcParticipantMapper, never()).updateById(any(ImRtcParticipantDO.class)); | |
| 322 | + } | |
| 323 | + | |
| 324 | + @Test | |
| 325 | + public void testInviteCall_overLimit_throws() throws Exception { | |
| 326 | + ImProperties.Rtc rtcConfig = new ImProperties.Rtc(); | |
| 327 | + rtcConfig.setGroupMaxParticipants(3); | |
| 328 | + when(imProperties.getRtc()).thenReturn(rtcConfig); | |
| 329 | + ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.GROUP, 999L); | |
| 330 | + when(rtcCallMapper.selectByRoom("r1")).thenReturn(call); | |
| 331 | + when(rtcParticipantMapper.selectByRoomAndUserId("r1", 200L)) | |
| 332 | + .thenReturn(buildParticipant(10L, "r1", 200L, ImRtcParticipantStatusEnum.JOINED)); | |
| 333 | + when(rtcCallLockRedisDAO.lockGroup(eq(999L), any())).thenAnswer(invocation -> { | |
| 334 | + @SuppressWarnings("unchecked") | |
| 335 | + Callable<Void> callable = invocation.getArgument(1); | |
| 336 | + return callable.call(); | |
| 337 | + }); | |
| 338 | + when(rtcParticipantMapper.selectListByRoom("r1")).thenReturn(List.of( | |
| 339 | + buildParticipant(10L, "r1", 200L, ImRtcParticipantStatusEnum.JOINED), | |
| 340 | + buildParticipant(11L, "r1", 201L, ImRtcParticipantStatusEnum.JOINED), | |
| 341 | + buildParticipant(12L, "r1", 202L, ImRtcParticipantStatusEnum.INVITING) | |
| 342 | + )); | |
| 343 | + ImRtcCallInviteReqVO reqVO = new ImRtcCallInviteReqVO(); | |
| 344 | + reqVO.setRoom("r1"); | |
| 345 | + reqVO.setInviteeIds(Set.of(203L)); | |
| 346 | + | |
| 347 | + ServiceException exception = assertThrows(ServiceException.class, | |
| 348 | + () -> rtcCallService.inviteCall(200L, reqVO)); | |
| 349 | + | |
| 350 | + assertEquals(RTC_GROUP_INVITEE_OVER_LIMIT.getCode(), exception.getCode()); | |
| 351 | + verify(rtcParticipantMapper, never()).insertBatch(anyList()); | |
| 352 | + } | |
| 353 | + | |
| 298 | 354 | // ========== 测试数据构造 ========== |
| 299 | 355 | |
| 300 | 356 | private ImRtcParticipantDO buildParticipant(Long id, String room, Long userId, ImRtcParticipantStatusEnum status) { | ... | ... |
yudao-module-im/src/test/resources/sql/create_tables.sql
| ... | ... | @@ -219,7 +219,8 @@ CREATE TABLE IF NOT EXISTS "im_rtc_participant" ( |
| 219 | 219 | "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| 220 | 220 | "deleted" bit NOT NULL DEFAULT FALSE, |
| 221 | 221 | "tenant_id" bigint NOT NULL DEFAULT 0, |
| 222 | - PRIMARY KEY ("id") | |
| 222 | + PRIMARY KEY ("id"), | |
| 223 | + CONSTRAINT "uk_im_rtc_participant_room_user" UNIQUE ("room", "user_id", "tenant_id") | |
| 223 | 224 | ) COMMENT 'IM 通话参与者表'; |
| 224 | 225 | |
| 225 | 226 | CREATE TABLE IF NOT EXISTS "im_face_user_item" ( | ... | ... |