Commit bd29116e454f83af00fd88a099b8891be8dc2499

Authored by YunaiV
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&lt;ImGroupRequestDO&gt; {
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&lt;ImGroupRequestDO&gt; {
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&lt;Integer&gt; {
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 &quot;im_rtc_participant&quot; (
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" (
... ...