fix: 修复 IM 申请与 RTC 边界问题

- 复用好友申请、群申请和群邀请唯一键冲突后的旧记录,并补充测试
- 收敛 RTC 旁观者加入、忙线校验、追加邀请超员和群通话通知逻辑
- 为 RTC 参与者补充房间用户唯一约束与 MySQL 迁移
- 统一群本体管理请求的 id 字段,并同步前端调用
- 修复前端来电活跃态守卫和 LiveKit 重连前断开旧房间
- 清理群成员通知基类命名和相关注释
This commit is contained in:
YunaiV
2026-05-25 20:54:12 +08:00
parent a06fb9e995
commit bd29116e45
28 changed files with 451 additions and 181 deletions
@@ -134,14 +134,14 @@ public class ImGroupController {
@PutMapping("/pin-message")
@Operation(summary = "置顶群消息(群主 / 管理员)")
public CommonResult<Boolean> pinGroupMessage(@Valid @RequestBody ImGroupMessagePinReqVO reqVO) {
groupService.pinGroupMessage(getLoginUserId(), reqVO.getGroupId(), reqVO.getMessageId());
groupService.pinGroupMessage(getLoginUserId(), reqVO.getId(), reqVO.getMessageId());
return success(true);
}
@PutMapping("/unpin-message")
@Operation(summary = "取消置顶群消息(群主 / 管理员)")
public CommonResult<Boolean> unpinGroupMessage(@Valid @RequestBody ImGroupMessagePinReqVO reqVO) {
groupService.unpinGroupMessage(getLoginUserId(), reqVO.getGroupId(), reqVO.getMessageId());
groupService.unpinGroupMessage(getLoginUserId(), reqVO.getId(), reqVO.getMessageId());
return success(true);
}
@@ -13,7 +13,7 @@ public class ImGroupAdminAddReqVO {
@Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13279")
@NotNull(message = "群编号不能为空")
private Long groupId;
private Long id;
@Schema(description = "目标用户编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[101, 102]")
@NotEmpty(message = "目标用户编号列表不能为空")
@@ -13,7 +13,7 @@ public class ImGroupAdminRemoveReqVO {
@Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13279")
@NotNull(message = "群编号不能为空")
private Long groupId;
private Long id;
@Schema(description = "目标用户编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[101, 102]")
@NotEmpty(message = "目标用户编号列表不能为空")
@@ -10,7 +10,7 @@ public class ImGroupCancelMuteMemberReqVO {
@Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "群编号不能为空")
private Long groupId;
private Long id;
@Schema(description = "被取消禁言的用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048")
@NotNull(message = "用户编号不能为空")
@@ -13,7 +13,7 @@ public class ImGroupMessagePinReqVO {
@Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13279")
@NotNull(message = "群编号不能为空")
private Long groupId;
private Long id;
@Schema(description = "消息编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "9527")
@NotNull(message = "消息编号不能为空")
@@ -8,10 +8,9 @@ import lombok.Data;
@Data
public class ImGroupMuteAllReqVO {
// TODO @AI:改成 id 是不是更好?
@Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "群编号不能为空")
private Long groupId;
private Long id;
@Schema(description = "是否全群禁言", requiredMode = Schema.RequiredMode.REQUIRED, example = "true")
@NotNull(message = "是否全群禁言不能为空")
@@ -11,7 +11,7 @@ public class ImGroupMuteMemberReqVO {
@Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "群编号不能为空")
private Long groupId;
private Long id;
@Schema(description = "被禁言的用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048")
@NotNull(message = "用户编号不能为空")
@@ -10,7 +10,7 @@ public class ImGroupTransferOwnerReqVO {
@Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13279")
@NotNull(message = "群编号不能为空")
private Long groupId;
private Long id;
@Schema(description = "新群主用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "202")
@NotNull(message = "新群主用户编号不能为空")
@@ -27,12 +27,6 @@ public interface ImGroupRequestMapper extends BaseMapperX<ImGroupRequestDO> {
.eq(ImGroupRequestDO::getUserId, userId));
}
default List<ImGroupRequestDO> selectListByGroupIdAndUserIds(Long groupId, Collection<Long> userIds) {
return selectList(new LambdaQueryWrapperX<ImGroupRequestDO>()
.eq(ImGroupRequestDO::getGroupId, groupId)
.in(ImGroupRequestDO::getUserId, userIds));
}
default List<ImGroupRequestDO> selectListByGroupIdsAndHandleResult(Collection<Long> groupIds, Integer handleResult) {
return selectList(new LambdaQueryWrapperX<ImGroupRequestDO>()
.in(ImGroupRequestDO::getGroupId, groupIds)
@@ -73,14 +67,9 @@ public interface ImGroupRequestMapper extends BaseMapperX<ImGroupRequestDO> {
.set(ImGroupRequestDO::getUpdateTime, updateTime));
}
/**
* 复用邀请审批的旧记录(批量):盖写 inviterUserId / addSource,重置为未处理 + 清空旧处理痕迹 + 刷 update_time
*/
default int updateInviteByGroupIdAndUserIdsReset(Long groupId, Collection<Long> userIds,
Long inviterUserId, Integer addSource, LocalDateTime updateTime) {
default int updateInviteByIdReset(Long id, Long inviterUserId, Integer addSource, LocalDateTime updateTime) {
return update(null, new LambdaUpdateWrapper<ImGroupRequestDO>()
.eq(ImGroupRequestDO::getGroupId, groupId)
.in(ImGroupRequestDO::getUserId, userIds)
.eq(ImGroupRequestDO::getId, id)
.set(ImGroupRequestDO::getInviterUserId, inviterUserId)
.set(ImGroupRequestDO::getAddSource, addSource)
.set(ImGroupRequestDO::getHandleResult, ImGroupRequestHandleResultEnum.UNHANDLED.getResult())
@@ -17,8 +17,7 @@ public enum ImRtcParticipantRoleEnum implements ArrayValuable<Integer> {
INVITER(1, "发起人"),
INVITEE(2, "被邀请者"),
// TODO @AI:改成 joiner?还是 joinee?看看这样拼写是不是对的。
ACTIVE_JOIN(3, "主动加入者"); // 仅群通话场景:旁观者点胶囊条加入已有通话
JOINER(3, "主动加入者"); // 仅群通话场景:旁观者点胶囊条加入已有通话
public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImRtcParticipantRoleEnum::getRole).toArray(Integer[]::new);
@@ -24,6 +24,7 @@ import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
@@ -92,22 +93,8 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService {
return null;
}
// 2. 落库:upsert 语义;同一对 (from, to) 唯一,已有记录覆盖申请理由 / 备注 / 来源 + 重置为未处理 + 清空旧处理痕迹
ImFriendRequestDO request = friendRequestMapper.selectByFromUserIdAndToUserId(fromUserId, toUserId);
if (request != null) {
LocalDateTime now = LocalDateTime.now();
friendRequestMapper.updateByIdReset(request.getId(),
reqVO.getApplyContent(), reqVO.getDisplayName(), reqVO.getAddSource(), now);
request.setApplyContent(reqVO.getApplyContent()).setDisplayName(reqVO.getDisplayName())
.setAddSource(reqVO.getAddSource())
.setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult())
.setHandleContent(null).setHandleTime(null).setUpdateTime(now);
} else {
request = BeanUtils.toBean(reqVO, ImFriendRequestDO.class)
.setFromUserId(fromUserId).setToUserId(toUserId)
.setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult());
friendRequestMapper.insert(request);
}
// 2. 落库:同一申请人和接收人唯一,已有记录覆盖申请内容并重置为未处理
ImFriendRequestDO request = createOrResetRequest(fromUserId, reqVO);
// 3. 推送 FRIEND_REQUEST_RECEIVED 给 toUser 多端;payload 携带申请方昵称 / 头像,前端按 requestId 直推 push 进列表
AdminUserRespDTO fromUser = adminUserApi.getUser(fromUserId);
@@ -141,6 +128,44 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService {
return request;
}
/**
* 创建或重置好友申请
*
* @param fromUserId 申请人用户编号
* @param reqVO 申请请求
* @return 申请记录
*/
private ImFriendRequestDO createOrResetRequest(Long fromUserId, ImFriendRequestApplyReqVO reqVO) {
Long toUserId = reqVO.getToUserId();
ImFriendRequestDO request = friendRequestMapper.selectByFromUserIdAndToUserId(fromUserId, toUserId);
if (request == null) {
// 1. 无旧申请:创建新申请;唯一键冲突时回查并复用并发写入的记录
request = BeanUtils.toBean(reqVO, ImFriendRequestDO.class)
.setFromUserId(fromUserId).setToUserId(toUserId)
.setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult());
try {
friendRequestMapper.insert(request);
return request;
} catch (DuplicateKeyException ex) {
request = friendRequestMapper.selectByFromUserIdAndToUserId(fromUserId, toUserId);
if (request == null) {
throw ex;
}
}
}
// 2. 复用旧申请:覆盖本次申请内容,并重置为未处理
LocalDateTime now = LocalDateTime.now();
friendRequestMapper.updateByIdReset(request.getId(),
reqVO.getApplyContent(), reqVO.getDisplayName(), reqVO.getAddSource(), now);
// 同步内存对象,后续通知和自动通过直接复用
request.setApplyContent(reqVO.getApplyContent()).setDisplayName(reqVO.getDisplayName())
.setAddSource(reqVO.getAddSource())
.setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult())
.setHandleContent(null).setHandleTime(null).setUpdateTime(now);
return request;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void agreeFriendRequest(Long userId, Long requestId) {
@@ -19,7 +19,7 @@ public interface ImGroupRequestService {
/**
* 用户主动申请加群
* <p>
* 群未开启审批时直接入群 + 1510 全员广播;开启审批则 upsert 一条 inviterUserId=null 的待审批记录 + 1503 推送
* 群未开启审批时直接入群 + 1510 全员广播;开启审批则创建或复用一条待审批记录 + 1503 推送
*
* @param userId 申请人用户编号
* @param reqVO 申请请求
@@ -45,7 +45,7 @@ public interface ImGroupRequestService {
void refuseGroupRequest(Long userId, Long requestId, String handleContent);
/**
* 邀请创建审批申请;inviteGroupMember 在群开启审批时调用,每个被邀请人 upsert 一条 inviterUserId=操作人 的记录
* 邀请创建审批申请;inviteGroupMember 在群开启审批时调用,每个被邀请人创建或复用一条待审批记录
*
* @param groupId 群编号
* @param inviterUserId 邀请人用户编号
@@ -27,6 +27,7 @@ import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
@@ -93,22 +94,8 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
return null;
}
// 3. 情况二:群开启了审批;upsert 一条主动申请记录(inviterUserId=null)
ImGroupRequestDO request = groupRequestMapper.selectByGroupIdAndUserId(groupId, userId);
if (request != null) {
LocalDateTime now = LocalDateTime.now();
groupRequestMapper.updateApplyByIdReset(request.getId(),
reqVO.getApplyContent(), reqVO.getAddSource(), now);
request.setApplyContent(reqVO.getApplyContent()).setAddSource(reqVO.getAddSource())
.setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult())
.setInviterUserId(null).setHandleUserId(null)
.setHandleContent(null).setHandleTime(null).setUpdateTime(now);
} else {
request = BeanUtils.toBean(reqVO, ImGroupRequestDO.class)
.setUserId(userId).setInviterUserId(null)
.setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult());
groupRequestMapper.insert(request);
}
// 3. 情况二:群开启了审批,创建或复用一条主动申请记录
ImGroupRequestDO request = createOrResetApplyRequest(groupId, userId, reqVO);
// 4. 1503 私聊定向推群主 + 全部管理员(多端同步);payload 携带申请方昵称 / 头像
AdminUserRespDTO applyUser = adminUserApi.getUser(userId);
@@ -203,39 +190,13 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
}
ImGroupDO group = groupService.validateGroupExists(groupId);
Integer inviteSource = ImGroupAddSourceEnum.INVITE.getSource();
Integer unhandled = ImGroupRequestHandleResultEnum.UNHANDLED.getResult();
LocalDateTime now = LocalDateTime.now();
// 1. 逐条创建或复用邀请申请
List<ImGroupRequestDO> requests = convertList(invitedUserIds, userId ->
createOrResetInviteRequest(groupId, inviterUserId, userId, inviteSource));
// 1. 批量查现有记录,分流为「复用旧记录」+「新增」
List<ImGroupRequestDO> existingList = groupRequestMapper.selectListByGroupIdAndUserIds(groupId, invitedUserIds);
Set<Long> existingUserIds = convertSet(existingList, ImGroupRequestDO::getUserId);
Collection<Long> newUserIds = CollUtil.subtract(invitedUserIds, existingUserIds);
// 2. 复用旧记录:一条 update WHERE IN 完成
if (CollUtil.isNotEmpty(existingList)) {
groupRequestMapper.updateInviteByGroupIdAndUserIdsReset(groupId, existingUserIds,
inviterUserId, inviteSource, now);
// 同步内存对象,下面 buildRequestNotification 要读 inviterUserId / addSource
existingList.forEach(r -> r.setInviterUserId(inviterUserId).setAddSource(inviteSource)
.setHandleResult(unhandled)
.setHandleUserId(null).setHandleContent(null).setHandleTime(null)
.setUpdateTime(now));
}
// 3. 新增:批量 insert
List<ImGroupRequestDO> newList = convertList(newUserIds, userId -> new ImGroupRequestDO()
.setGroupId(groupId).setUserId(userId).setInviterUserId(inviterUserId)
.setAddSource(inviteSource).setHandleResult(unhandled));
if (CollUtil.isNotEmpty(newList)) {
groupRequestMapper.insertBatch(newList);
}
// 4. 推 1503 给群主 + 全部管理员;多端同步;每条申请单独推一帧
// 2. 推 1503 给群主 + 全部管理员;多端同步;每条申请单独推一帧
Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(invitedUserIds);
List<Long> ownerAndAdmins = getGroupMemberListByOwnerAndAdminUserIds(group);
List<ImGroupRequestDO> requests = new ArrayList<>(existingList.size() + newList.size());
requests.addAll(existingList);
requests.addAll(newList);
for (ImGroupRequestDO request : requests) {
AdminUserRespDTO applyUser = userMap.get(request.getUserId());
GroupRequestReceivedNotification payload = buildRequestNotification(group, request, applyUser);
@@ -283,6 +244,109 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
return groupRequestMapper.selectPage(reqVO);
}
/**
* 创建或重置主动加群申请
*
* @param groupId 群编号
* @param userId 申请人用户编号
* @param reqVO 申请请求
* @return 申请记录
*/
private ImGroupRequestDO createOrResetApplyRequest(Long groupId, Long userId, ImGroupRequestApplyReqVO reqVO) {
// 1. 已有申请:覆盖本次申请内容,并重置为未处理
ImGroupRequestDO request = groupRequestMapper.selectByGroupIdAndUserId(groupId, userId);
if (request != null) {
resetApplyRequest(request, reqVO);
return request;
}
// 2. 无旧申请:创建主动申请记录
request = BeanUtils.toBean(reqVO, ImGroupRequestDO.class)
.setUserId(userId).setInviterUserId(null)
.setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult());
try {
groupRequestMapper.insert(request);
return request;
} catch (DuplicateKeyException ex) {
// 3. 唯一键冲突:回查并复用并发写入的记录
request = groupRequestMapper.selectByGroupIdAndUserId(groupId, userId);
if (request == null) {
throw ex;
}
resetApplyRequest(request, reqVO);
return request;
}
}
/**
* 创建或重置邀请加群申请
*
* @param groupId 群编号
* @param inviterUserId 邀请人用户编号
* @param userId 被邀请人用户编号
* @param inviteSource 邀请来源
* @return 申请记录
*/
private ImGroupRequestDO createOrResetInviteRequest(Long groupId, Long inviterUserId,
Long userId, Integer inviteSource) {
// 1. 已有申请:覆盖邀请人和来源,并重置为未处理
ImGroupRequestDO request = groupRequestMapper.selectByGroupIdAndUserId(groupId, userId);
if (request != null) {
resetInviteRequest(request, inviterUserId, inviteSource);
return request;
}
// 2. 无旧申请:创建邀请申请记录
request = new ImGroupRequestDO().setGroupId(groupId).setUserId(userId).setInviterUserId(inviterUserId)
.setAddSource(inviteSource).setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult());
try {
groupRequestMapper.insert(request);
return request;
} catch (DuplicateKeyException ex) {
// 3. 唯一键冲突:回查并复用并发写入的记录
request = groupRequestMapper.selectByGroupIdAndUserId(groupId, userId);
if (request == null) {
throw ex;
}
resetInviteRequest(request, inviterUserId, inviteSource);
return request;
}
}
/**
* 重置主动加群申请
*
* @param request 申请记录
* @param reqVO 申请请求
*/
private void resetApplyRequest(ImGroupRequestDO request, ImGroupRequestApplyReqVO reqVO) {
// 1. 更新申请内容、来源和处理状态
LocalDateTime now = LocalDateTime.now();
groupRequestMapper.updateApplyByIdReset(request.getId(),
reqVO.getApplyContent(), reqVO.getAddSource(), now);
// 2. 同步内存对象,后续通知构建直接复用
request.setApplyContent(reqVO.getApplyContent()).setAddSource(reqVO.getAddSource())
.setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult())
.setInviterUserId(null).setHandleUserId(null)
.setHandleContent(null).setHandleTime(null).setUpdateTime(now);
}
/**
* 重置邀请加群申请
*
* @param request 申请记录
* @param inviterUserId 邀请人用户编号
* @param inviteSource 邀请来源
*/
private void resetInviteRequest(ImGroupRequestDO request, Long inviterUserId, Integer inviteSource) {
// 1. 更新邀请人、来源和处理状态
LocalDateTime now = LocalDateTime.now();
groupRequestMapper.updateInviteByIdReset(request.getId(), inviterUserId, inviteSource, now);
// 2. 同步内存对象,后续通知构建直接复用
request.setInviterUserId(inviterUserId).setAddSource(inviteSource)
.setHandleResult(ImGroupRequestHandleResultEnum.UNHANDLED.getResult())
.setHandleUserId(null).setHandleContent(null).setHandleTime(null)
.setUpdateTime(now);
}
/**
* 校验申请可被「当前用户」处理:申请存在 + 未处理 + 操作人是群主 / 管理员
*/
@@ -316,7 +316,7 @@ public class ImGroupServiceImpl implements ImGroupService {
@Override
@Transactional(rollbackFor = Exception.class)
public void addGroupAdmin(Long userId, ImGroupAdminAddReqVO reqVO) {
Long groupId = reqVO.getGroupId();
Long groupId = reqVO.getId();
Set<Long> targetUserIds = new HashSet<>(reqVO.getUserIds());
// 1.1 仅群主可操作
validateGroupOwnerForUpdate(groupId, userId);
@@ -354,7 +354,7 @@ public class ImGroupServiceImpl implements ImGroupService {
@Override
@Transactional(rollbackFor = Exception.class)
public void removeGroupAdmin(Long userId, ImGroupAdminRemoveReqVO reqVO) {
Long groupId = reqVO.getGroupId();
Long groupId = reqVO.getId();
Set<Long> targetUserIds = new HashSet<>(reqVO.getUserIds());
// 1.1 仅群主可操作
validateGroupOwnerForUpdate(groupId, userId);
@@ -398,10 +398,10 @@ public class ImGroupServiceImpl implements ImGroupService {
}
@Override
@CacheEvict(cacheNames = GROUP, key = "#transferReqVO.groupId")
@CacheEvict(cacheNames = GROUP, key = "#transferReqVO.id")
@Transactional(rollbackFor = Exception.class)
public void transferGroupOwner(Long userId, ImGroupTransferOwnerReqVO transferReqVO) {
Long groupId = transferReqVO.getGroupId();
Long groupId = transferReqVO.getId();
Long newOwnerUserId = transferReqVO.getNewOwnerUserId();
// 1.1 仅老群主可执行
validateGroupOwnerForUpdate(groupId, userId);
@@ -677,19 +677,19 @@ public class ImGroupServiceImpl implements ImGroupService {
// ==================== 群禁言 ====================
@Override
@CacheEvict(cacheNames = GROUP, key = "#reqVO.groupId")
@CacheEvict(cacheNames = GROUP, key = "#reqVO.id")
@Transactional(rollbackFor = Exception.class)
public void muteAll(Long userId, ImGroupMuteAllReqVO reqVO) {
// 1. 校验群主或管理员
validateGroupOwnerOrAdmin(reqVO.getGroupId(), userId);
validateGroupOwnerOrAdmin(reqVO.getId(), userId);
// 2. 更新 mutedAll
groupMapper.updateById(new ImGroupDO().setId(reqVO.getGroupId()).setMutedAll(reqVO.getMutedAll()));
groupMapper.updateById(new ImGroupDO().setId(reqVO.getId()).setMutedAll(reqVO.getMutedAll()));
// 3. 广播通知
ImGroupMessageSendDTO messageSendDTO = Boolean.TRUE.equals(reqVO.getMutedAll())
? ImGroupMessageSendDTO.ofGroupMuted(reqVO.getGroupId(), userId)
: ImGroupMessageSendDTO.ofGroupCancelMuted(reqVO.getGroupId(), userId);
? ImGroupMessageSendDTO.ofGroupMuted(reqVO.getId(), userId)
: ImGroupMessageSendDTO.ofGroupCancelMuted(reqVO.getId(), userId);
groupMessageService.sendGroupMessage(userId, messageSendDTO);
}
@@ -701,21 +701,21 @@ public class ImGroupServiceImpl implements ImGroupService {
throw exception(GROUP_MUTE_MEMBER_SELF);
}
// 1.2 校验群存在且未封禁
validateGroupExists(reqVO.getGroupId());
validateGroupExists(reqVO.getId());
// 1.3 校验操作人和目标都在群中
ImGroupMemberDO operatorMember = groupMemberService.validateMemberInGroup(reqVO.getGroupId(), userId);
ImGroupMemberDO targetMember = groupMemberService.validateMemberInGroup(reqVO.getGroupId(), reqVO.getUserId());
ImGroupMemberDO operatorMember = groupMemberService.validateMemberInGroup(reqVO.getId(), userId);
ImGroupMemberDO targetMember = groupMemberService.validateMemberInGroup(reqVO.getId(), reqVO.getUserId());
// 1.4 三档权限校验
validateMutePermission(operatorMember, targetMember);
// 2. 设置 muteEndTime
LocalDateTime muteEndTime = reqVO.getMutedSeconds() == 0
? ImGroupMemberDO.PERMANENT_MUTE_END_TIME : LocalDateTime.now().plusSeconds(reqVO.getMutedSeconds());
groupMemberService.updateGroupMemberMuteEndTime(reqVO.getGroupId(), reqVO.getUserId(), muteEndTime);
groupMemberService.updateGroupMemberMuteEndTime(reqVO.getId(), reqVO.getUserId(), muteEndTime);
// 3. 广播通知
groupMessageService.sendGroupMessage(userId,
ImGroupMessageSendDTO.ofGroupMemberMuted(reqVO.getGroupId(), userId,
ImGroupMessageSendDTO.ofGroupMemberMuted(reqVO.getId(), userId,
reqVO.getUserId(), muteEndTime));
}
@@ -723,19 +723,19 @@ public class ImGroupServiceImpl implements ImGroupService {
@Transactional(rollbackFor = Exception.class)
public void cancelMuteMember(Long userId, ImGroupCancelMuteMemberReqVO reqVO) {
// 1.1 校验群存在且未封禁
validateGroupExists(reqVO.getGroupId());
validateGroupExists(reqVO.getId());
// 1.2 校验操作人和目标都在群中
ImGroupMemberDO operatorMember = groupMemberService.validateMemberInGroup(reqVO.getGroupId(), userId);
ImGroupMemberDO targetMember = groupMemberService.validateMemberInGroup(reqVO.getGroupId(), reqVO.getUserId());
ImGroupMemberDO operatorMember = groupMemberService.validateMemberInGroup(reqVO.getId(), userId);
ImGroupMemberDO targetMember = groupMemberService.validateMemberInGroup(reqVO.getId(), reqVO.getUserId());
// 1.3 三档权限校验
validateMutePermission(operatorMember, targetMember);
// 2. 取消禁言(清空 muteEndTime)
groupMemberService.updateGroupMemberMuteEndTime(reqVO.getGroupId(), reqVO.getUserId(), null);
groupMemberService.updateGroupMemberMuteEndTime(reqVO.getId(), reqVO.getUserId(), null);
// 3. 广播通知
groupMessageService.sendGroupMessage(userId,
ImGroupMessageSendDTO.ofGroupMemberCancelMuted(reqVO.getGroupId(), userId, reqVO.getUserId()));
ImGroupMessageSendDTO.ofGroupMemberCancelMuted(reqVO.getId(), userId, reqVO.getUserId()));
}
/**
@@ -34,7 +34,7 @@ public interface ImRtcCallService {
void inviteCall(Long userId, ImRtcCallInviteReqVO reqVO);
/**
* 加入已有群通话:用于群胶囊条「加入」按钮;旁观者作为 ACTIVE_JOIN 加入,邀请池内成员转 JOINED
* 加入已有群通话:用于群胶囊条「加入」按钮;旁观者作为 JOINER 加入,邀请池内成员转 JOINED
*
* @param userId 加入者用户编号
* @param room 业务通话编号;从胶囊条 activeCall 拿
@@ -37,6 +37,7 @@ import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
@@ -256,24 +257,12 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
}
// 1.3 校验当前用户是该群有效成员;防止仅凭 room 就拿到 LiveKit token 越权入会
groupMemberService.validateMemberInGroup(call.getGroupId(), userId);
validateUserNotJoinedOtherCall(userId, call.getRoom());
// 1.4 校验当前用户没有其它活跃通话
validateUserNotInOtherCall(userId, call.getRoom());
// 2. 入参与表:已有记录切回 JOINED;不在记录则以 ACTIVE_JOIN 角色 INSERT
// 2. 入参与表:已有记录切回 JOINED;不在记录则以 JOINER 角色 INSERT
LocalDateTime now = LocalDateTime.now();
ImRtcParticipantDO existing = rtcParticipantMapper.selectByRoomAndUserId(call.getRoom(), userId);
if (existing != null) {
// 非 JOINED 状态切回 JOINED + accept_time;INVITER / INVITEE 重连、旁观者点击胶囊条加入,都走这条
if (!ImRtcParticipantStatusEnum.isJoined(existing.getStatus())) {
rtcParticipantMapper.updateById(new ImRtcParticipantDO().setId(existing.getId())
.setStatus(ImRtcParticipantStatusEnum.JOINED.getStatus()).setAcceptTime(now));
}
} else {
// 旁观者主动加入:INSERT role=ACTIVE_JOIN
rtcParticipantMapper.insert(new ImRtcParticipantDO()
.setCallId(call.getId()).setRoom(call.getRoom())
.setUserId(userId).setRole(ImRtcParticipantRoleEnum.ACTIVE_JOIN.getRole())
.setStatus(ImRtcParticipantStatusEnum.JOINED.getStatus()).setInviteTime(now).setAcceptTime(now));
}
joinParticipant(call, userId, now);
// 3. 主表 CREATED → RUNNING(首次有非发起人加入)
maybeMarkOngoing(call, userId, now);
@@ -291,13 +280,19 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
// 1.1 校验被邀请人都是群活跃成员
groupMemberService.validateMembersInGroup(call.getGroupId(), inviteeIds);
// 1.2 排除已在通话池的;剩余即本次新邀请
Set<Long> existingUserIds = CollectionUtils.convertSet(
rtcParticipantMapper.selectListByRoom(call.getRoom()), ImRtcParticipantDO::getUserId);
List<ImRtcParticipantDO> existingParticipants = rtcParticipantMapper.selectListByRoom(call.getRoom());
Set<Long> existingUserIds = CollectionUtils.convertSet(existingParticipants, ImRtcParticipantDO::getUserId);
Set<Long> incomingUserIds = new LinkedHashSet<>(inviteeIds);
incomingUserIds.removeAll(existingUserIds);
if (CollUtil.isEmpty(incomingUserIds)) {
return;
}
long activeCount = existingParticipants.stream()
.filter(participant -> ImRtcParticipantStatusEnum.ACTIVE_STATUSES.contains(participant.getStatus()))
.count();
if (activeCount + incomingUserIds.size() > imProperties.getRtc().getGroupMaxParticipants()) {
throw exception(RTC_GROUP_INVITEE_OVER_LIMIT);
}
// 2. 批量 INSERT 新邀请人
LocalDateTime now = LocalDateTime.now();
@@ -328,7 +323,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
if (ImRtcParticipantStatusEnum.isJoined(participant.getStatus())) {
return call;
}
validateUserNotJoinedOtherCall(userId, call.getRoom());
validateUserNotInOtherCall(userId, call.getRoom());
// 2.2 仅 INVITING → JOINED;其它状态拒
if (!ImRtcParticipantStatusEnum.isInviting(participant.getStatus())) {
throw exception(RTC_SESSION_NOT_EXISTS);
@@ -760,14 +755,69 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
return participant;
}
private void validateUserNotJoinedOtherCall(Long userId, String room) {
ImRtcParticipantDO joined = rtcParticipantMapper.selectLastOneByUserIdAndStatusInAndRoomNot(
userId, Collections.singleton(ImRtcParticipantStatusEnum.JOINED.getStatus()), room);
if (joined != null) {
/**
* 校验用户不在其它活跃通话中
*
* @param userId 用户编号
* @param room 当前房间标识
*/
private void validateUserNotInOtherCall(Long userId, String room) {
// 查询当前房间外的活跃参与记录
ImRtcParticipantDO participant = rtcParticipantMapper.selectLastOneByUserIdAndStatusInAndRoomNot(
userId, ImRtcParticipantStatusEnum.ACTIVE_STATUSES, room);
// 存在活跃参与记录,则当前用户忙线
if (participant != null) {
throw exception(RTC_SELF_BUSY);
}
}
/**
* 加入群通话参与者列表
*
* @param call 通话主表
* @param userId 用户编号
* @param now 当前时间
*/
private void joinParticipant(ImRtcCallDO call, Long userId, LocalDateTime now) {
// 1. 已有参与记录:切回 JOINED
ImRtcParticipantDO existing = rtcParticipantMapper.selectByRoomAndUserId(call.getRoom(), userId);
if (existing != null) {
updateParticipantJoined(existing, now);
return;
}
// 2. 无参与记录:以主动加入者身份新增
try {
rtcParticipantMapper.insert(new ImRtcParticipantDO()
.setCallId(call.getId()).setRoom(call.getRoom())
.setUserId(userId).setRole(ImRtcParticipantRoleEnum.JOINER.getRole())
.setStatus(ImRtcParticipantStatusEnum.JOINED.getStatus()).setInviteTime(now).setAcceptTime(now));
} catch (DuplicateKeyException ex) {
// 3. 唯一键冲突:回查并复用并发写入的记录
existing = rtcParticipantMapper.selectByRoomAndUserId(call.getRoom(), userId);
if (existing == null) {
throw ex;
}
updateParticipantJoined(existing, now);
}
}
/**
* 将参与者更新为已加入
*
* @param participant 参与者记录
* @param now 当前时间
*/
private void updateParticipantJoined(ImRtcParticipantDO participant, LocalDateTime now) {
// 已是 JOINED 直接返回
if (ImRtcParticipantStatusEnum.isJoined(participant.getStatus())) {
return;
}
// 更新状态和接听时间
rtcParticipantMapper.updateById(new ImRtcParticipantDO().setId(participant.getId())
.setStatus(ImRtcParticipantStatusEnum.JOINED.getStatus()).setAcceptTime(now));
}
/**
* 关房判定收口:私聊任一方离开必关;群通话仅在「无人在房 + 无人响铃」时关
* <p>
@@ -958,7 +1008,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
}
/**
* RTC_CALL(REJECT):仅群通话场景;走 webSocketService 直推主叫
* RTC_CALL(REJECT):仅群通话场景;走 webSocketService 推给群通话受众
* <p>
* 私聊拒绝走 endSession → RTC_CALL_END(reason=REJECT) 入消息流,不在此推
*
@@ -968,12 +1018,14 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
private void pushCallRejectNotification(ImRtcCallDO call, Long operatorUserId) {
AdminUserRespDTO operator = operatorUserId != null ? adminUserApi.getUser(operatorUserId) : null;
ImRtcCallNotification payload = ImRtcCallNotification.ofReject(call, operatorUserId, operator);
webSocketService.sendPrivateMessageAsync(call.getInviterUserId(), ImPrivateMessageDTO.ofRtcNotification(
ImMessageTypeEnum.RTC_CALL.getType(), operatorUserId, call.getInviterUserId(), payload));
for (Long receiverUserId : getCallAudienceUserIdList(call)) {
webSocketService.sendPrivateMessageAsync(receiverUserId, ImPrivateMessageDTO.ofRtcNotification(
ImMessageTypeEnum.RTC_CALL.getType(), operatorUserId, receiverUserId, payload));
}
}
/**
* RTC_CALL(NO_ANSWER):仅群通话场景;振铃超时由 Job 触发;走 webSocketService 直推主叫
* RTC_CALL(NO_ANSWER):仅群通话场景;振铃超时由 Job 触发;走 webSocketService 推给群通话受众
* <p>
* 私聊未接听走 endSession → RTC_CALL_END(reason=NO_ANSWER) 入消息流,不在此推
*
@@ -983,8 +1035,10 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
*/
private void pushCallNoAnswerNotification(ImRtcCallDO call, Long operatorUserId, AdminUserRespDTO operator) {
ImRtcCallNotification payload = ImRtcCallNotification.ofNoAnswer(call, operatorUserId, operator);
webSocketService.sendPrivateMessageAsync(call.getInviterUserId(), ImPrivateMessageDTO.ofRtcNotification(
ImMessageTypeEnum.RTC_CALL.getType(), operatorUserId, call.getInviterUserId(), payload));
for (Long receiverUserId : getCallAudienceUserIdList(call)) {
webSocketService.sendPrivateMessageAsync(receiverUserId, ImPrivateMessageDTO.ofRtcNotification(
ImMessageTypeEnum.RTC_CALL.getType(), operatorUserId, receiverUserId, payload));
}
}
/**
@@ -3,5 +3,5 @@ package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
/**
* 添加管理员事件通知(memberUserIds 为被设管理员的成员)
*/
public class GroupAdminAddNotification extends GroupMembersAffectedNotification {
public class GroupAdminAddNotification extends GroupMemberListNotification {
}
@@ -3,5 +3,5 @@ package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
/**
* 撤销管理员事件通知(memberUserIds 为被撤销管理员的成员)
*/
public class GroupAdminRemoveNotification extends GroupMembersAffectedNotification {
public class GroupAdminRemoveNotification extends GroupMemberListNotification {
}
@@ -3,5 +3,5 @@ package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
/**
* 群创建事件通知(memberUserIds 含创建者 + 初始邀请成员)
*/
public class GroupCreateNotification extends GroupMembersAffectedNotification {
public class GroupCreateNotification extends GroupMemberListNotification {
}
@@ -3,5 +3,5 @@ package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
/**
* 成员加入事件通知(memberUserIds 为被邀请人)
*/
public class GroupMemberInviteNotification extends GroupMembersAffectedNotification {
public class GroupMemberInviteNotification extends GroupMemberListNotification {
}
@@ -3,5 +3,5 @@ package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
/**
* 成员被移出事件通知(memberUserIds 为被移出成员)
*/
public class GroupMemberKickNotification extends GroupMembersAffectedNotification {
public class GroupMemberKickNotification extends GroupMemberListNotification {
}
@@ -0,0 +1,20 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
import lombok.Data;
import java.util.List;
/**
* 群事件成员列表通知基类
*
* @author 芋道源码
*/
@Data
public abstract class GroupMemberListNotification extends BaseGroupNotification {
/**
* 受影响的成员用户编号列表
*/
private List<Long> memberUserIds;
}
@@ -1,20 +0,0 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
import lombok.Data;
import java.util.List;
// TODO DONE @AI:本身已是 BaseGroupNotification 的子类,名字不带 Group 前缀是因为是抽象基类、被多种 Group* 通知共用
// TODO @AI:它目前只被 group 使用。别过度设计,名字按照我说的调整下;
/**
* 涉及成员列表的群事件通知基类:用于 Create / Invite / Kick / AdminAdd / AdminRemove
*/
@Data
public abstract class GroupMembersAffectedNotification extends BaseGroupNotification {
/**
* 受影响的成员用户编号列表
*/
private List<Long> memberUserIds;
}
@@ -18,6 +18,7 @@ import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.dao.DuplicateKeyException;
import java.time.LocalDateTime;
import java.util.List;
@@ -187,6 +188,31 @@ public class ImFriendRequestServiceImplTest extends BaseMockitoUnitTest {
assertNull(result.getHandleTime());
}
@Test
public void testApplyFriend_insertDuplicateKey_reuseOldRequest() {
// 准备:首次查询不存在,插入时命中唯一键,回查到并发写入的旧申请
ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO();
reqVO.setToUserId(2L).setApplyContent("并发申请").setDisplayName("老张").setAddSource(2);
when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.NONE.getState());
when(friendService.getFriend(2L, 1L)).thenReturn(null);
ImFriendRequestDO old = new ImFriendRequestDO().setId(100L).setFromUserId(1L).setToUserId(2L)
.setHandleResult(ImFriendRequestHandleResultEnum.REFUSED.getResult());
when(friendRequestMapper.selectByFromUserIdAndToUserId(1L, 2L)).thenReturn(null, old);
when(friendRequestMapper.insert(any(ImFriendRequestDO.class))).thenThrow(new DuplicateKeyException("dup"));
when(adminUserApi.getUser(1L)).thenReturn(null);
when(imProperties.getFriend()).thenReturn(new ImProperties.Friend());
// 调用
ImFriendRequestDO result = friendRequestService.applyFriend(1L, reqVO);
// 断言:复用并重置旧申请,不向上抛数据库异常
verify(friendRequestMapper).updateByIdReset(eq(100L), eq("并发申请"), eq("老张"), eq(2),
any(LocalDateTime.class));
assertEquals(100L, result.getId());
assertEquals(ImFriendRequestHandleResultEnum.UNHANDLED.getResult(), result.getHandleResult());
verify(websocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class));
}
// ========== agreeFriendRequest ==========
@Test
@@ -22,6 +22,7 @@ import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.dao.DuplicateKeyException;
import java.util.Collection;
import java.util.List;
@@ -117,6 +118,39 @@ public class ImGroupRequestServiceImplTest extends BaseMockitoUnitTest {
verify(websocketService, times(2)).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class));
}
@Test
public void testApplyJoinGroup_insertDuplicateKey_reuseOldRequest() {
// 准备:群是 APPLY 模式,首次查询不存在,插入时命中唯一键
ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L)
.joinApproval(true)
.status(CommonStatusEnum.ENABLE.getStatus()).build();
when(groupService.validateGroupExists(10L)).thenReturn(group);
when(groupMemberService.getGroupMemberListByOwnerAndAdmin(10L)).thenReturn(List.of(
ImGroupMemberDO.builder().groupId(10L).userId(99L)
.role(ImGroupMemberRoleEnum.OWNER.getRole())
.status(CommonStatusEnum.ENABLE.getStatus()).build()));
when(adminUserApi.getUser(1L)).thenReturn(buildUser(1L, "申请人"));
ImGroupRequestDO old = new ImGroupRequestDO().setId(50L).setGroupId(10L).setUserId(1L)
.setHandleResult(ImGroupRequestHandleResultEnum.REFUSED.getResult());
when(groupRequestMapper.selectByGroupIdAndUserId(10L, 1L)).thenReturn(null, old);
when(groupRequestMapper.insert(any(ImGroupRequestDO.class))).thenThrow(new DuplicateKeyException("dup"));
ImGroupRequestApplyReqVO reqVO = new ImGroupRequestApplyReqVO();
reqVO.setGroupId(10L);
reqVO.setApplyContent("我想进群");
reqVO.setAddSource(ImGroupAddSourceEnum.SEARCH.getSource());
// 调用
ImGroupRequestDO result = groupRequestService.applyJoinGroup(1L, reqVO);
// 断言:复用并重置旧申请
verify(groupRequestMapper).updateApplyByIdReset(eq(50L), eq("我想进群"),
eq(ImGroupAddSourceEnum.SEARCH.getSource()), any());
assertEquals(50L, result.getId());
assertEquals(ImGroupRequestHandleResultEnum.UNHANDLED.getResult(), result.getHandleResult());
verify(websocketService).sendPrivateMessageAsync(eq(99L), any(ImPrivateMessageDTO.class));
}
@Test
public void testApplyJoinGroup_alreadyMember_throws() {
ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L)
@@ -254,8 +288,7 @@ public class ImGroupRequestServiceImplTest extends BaseMockitoUnitTest {
.joinApproval(true)
.status(CommonStatusEnum.ENABLE.getStatus()).build();
when(groupService.validateGroupExists(10L)).thenReturn(group);
when(groupRequestMapper.selectListByGroupIdAndUserIds(eq(10L), anyCollection()))
.thenReturn(List.of());
when(groupRequestMapper.selectByGroupIdAndUserId(eq(10L), anyLong())).thenReturn(null);
when(groupMemberService.getGroupMemberListByOwnerAndAdmin(10L)).thenReturn(List.of(
ImGroupMemberDO.builder().groupId(10L).userId(99L)
.role(ImGroupMemberRoleEnum.OWNER.getRole())
@@ -267,13 +300,12 @@ public class ImGroupRequestServiceImplTest extends BaseMockitoUnitTest {
// 调用:邀请人 1L 邀请 2L、3L(都没有旧记录)
groupRequestService.createInviteRequestList(10L, 1L, List.of(2L, 3L));
// 断言:批量插入 2 条 + 推 1503 给 owner(每条 1 帧)共 2 帧
@SuppressWarnings("unchecked")
ArgumentCaptor<Collection<ImGroupRequestDO>> captor = ArgumentCaptor.forClass(Collection.class);
verify(groupRequestMapper).insertBatch(captor.capture());
// 断言:插入 2 条 + 推 1503 给 owner(每条 1 帧)共 2 帧
ArgumentCaptor<ImGroupRequestDO> captor = ArgumentCaptor.forClass(ImGroupRequestDO.class);
verify(groupRequestMapper, times(2)).insert(captor.capture());
verify(websocketService, times(2)).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class));
// 断言:每条记录 inviterUserId=1 + addSource=INVITE,避免审批通过后回写群成员留痕的来源为空 / 脏带旧值
Collection<ImGroupRequestDO> inserted = captor.getValue();
Collection<ImGroupRequestDO> inserted = captor.getAllValues();
assertEquals(2, inserted.size());
inserted.forEach(insert -> {
assertEquals(1L, insert.getInviterUserId());
@@ -281,6 +313,31 @@ public class ImGroupRequestServiceImplTest extends BaseMockitoUnitTest {
});
}
@Test
public void testCreateInviteRequestList_insertDuplicateKey_reuseOldRequest() {
ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L)
.joinApproval(true)
.status(CommonStatusEnum.ENABLE.getStatus()).build();
when(groupService.validateGroupExists(10L)).thenReturn(group);
ImGroupRequestDO old = new ImGroupRequestDO().setId(50L).setGroupId(10L).setUserId(2L)
.setHandleResult(ImGroupRequestHandleResultEnum.REFUSED.getResult());
when(groupRequestMapper.selectByGroupIdAndUserId(10L, 2L)).thenReturn(null, old);
when(groupRequestMapper.insert(any(ImGroupRequestDO.class))).thenThrow(new DuplicateKeyException("dup"));
when(groupMemberService.getGroupMemberListByOwnerAndAdmin(10L)).thenReturn(List.of(
ImGroupMemberDO.builder().groupId(10L).userId(99L)
.role(ImGroupMemberRoleEnum.OWNER.getRole())
.status(CommonStatusEnum.ENABLE.getStatus()).build()));
when(adminUserApi.getUserMap(anyCollection())).thenReturn(java.util.Map.of(2L, buildUser(2L, "用户A")));
// 调用
groupRequestService.createInviteRequestList(10L, 1L, List.of(2L));
// 断言:复用并重置旧邀请申请
verify(groupRequestMapper).updateInviteByIdReset(eq(50L), eq(1L),
eq(ImGroupAddSourceEnum.INVITE.getSource()), any());
verify(websocketService).sendPrivateMessageAsync(eq(99L), any(ImPrivateMessageDTO.class));
}
private AdminUserRespDTO buildUser(Long id, String nickname) {
AdminUserRespDTO user = new AdminUserRespDTO();
user.setId(id);
@@ -769,7 +769,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
eq(ImGroupMemberRoleEnum.ADMIN.getRole()))).thenReturn(1);
ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO();
reqVO.setGroupId(10L);
reqVO.setId(10L);
reqVO.setUserIds(List.of(3L));
groupService.addGroupAdmin(1L, reqVO);
@@ -797,7 +797,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(3L);
ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO();
reqVO.setGroupId(10L);
reqVO.setId(10L);
reqVO.setUserIds(List.of(5L));
ServiceException exception = assertThrows(ServiceException.class,
@@ -821,7 +821,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
.role(ImGroupMemberRoleEnum.OWNER.getRole()).build()));
ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO();
reqVO.setGroupId(10L);
reqVO.setId(10L);
reqVO.setUserIds(List.of(1L));
ServiceException exception = assertThrows(ServiceException.class,
@@ -845,7 +845,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
.role(ImGroupMemberRoleEnum.ADMIN.getRole()).build()));
ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO();
reqVO.setGroupId(10L);
reqVO.setId(10L);
reqVO.setUserIds(List.of(2L));
groupService.addGroupAdmin(1L, reqVO);
@@ -873,7 +873,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
eq(ImGroupMemberRoleEnum.NORMAL.getRole()))).thenReturn(1);
ImGroupAdminRemoveReqVO reqVO = new ImGroupAdminRemoveReqVO();
reqVO.setGroupId(10L);
reqVO.setId(10L);
reqVO.setUserIds(List.of(2L));
groupService.removeGroupAdmin(1L, reqVO);
@@ -899,7 +899,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
.role(ImGroupMemberRoleEnum.NORMAL.getRole()).build()));
ImGroupAdminRemoveReqVO reqVO = new ImGroupAdminRemoveReqVO();
reqVO.setGroupId(10L);
reqVO.setId(10L);
reqVO.setUserIds(List.of(2L));
groupService.removeGroupAdmin(1L, reqVO);
@@ -928,7 +928,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
eq(ImGroupMemberRoleEnum.NORMAL.getRole()))).thenReturn(1);
ImGroupTransferOwnerReqVO reqVO = new ImGroupTransferOwnerReqVO();
reqVO.setGroupId(10L);
reqVO.setId(10L);
reqVO.setNewOwnerUserId(2L);
groupService.transferGroupOwner(1L, reqVO);
@@ -956,7 +956,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
ImGroupTransferOwnerReqVO reqVO = new ImGroupTransferOwnerReqVO();
reqVO.setGroupId(10L);
reqVO.setId(10L);
reqVO.setNewOwnerUserId(1L);
ServiceException exception = assertThrows(ServiceException.class,
@@ -977,7 +977,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
ImGroupTransferOwnerReqVO reqVO = new ImGroupTransferOwnerReqVO();
reqVO.setGroupId(10L);
reqVO.setId(10L);
reqVO.setNewOwnerUserId(2L);
ServiceException exception = assertThrows(ServiceException.class,
@@ -1046,7 +1046,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
.role(ImGroupMemberRoleEnum.ADMIN.getRole()).build());
ImGroupMuteMemberReqVO reqVO = new ImGroupMuteMemberReqVO();
reqVO.setGroupId(10L).setUserId(2L).setMutedSeconds(60);
reqVO.setId(10L).setUserId(2L).setMutedSeconds(60);
ServiceException exception = assertThrows(ServiceException.class,
() -> groupService.muteMember(1L, reqVO));
@@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.im.service.rtc;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallCreateReqVO;
import cn.iocoder.yudao.module.im.controller.admin.rtc.vo.ImRtcCallInviteReqVO;
import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO;
import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO;
@@ -23,6 +24,7 @@ import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.dao.DuplicateKeyException;
import java.time.Duration;
import java.time.LocalDateTime;
@@ -33,6 +35,7 @@ import java.util.Set;
import java.util.concurrent.Callable;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.RTC_GROUP_INVITEE_REQUIRED;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.RTC_GROUP_INVITEE_OVER_LIMIT;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.RTC_SELF_BUSY;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
@@ -125,6 +128,7 @@ public class ImRtcCallServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(1);
ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.GROUP, 999L);
when(rtcCallMapper.selectByRoom("r1")).thenReturn(call);
when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(999L)).thenReturn(List.of(200L, 201L));
// 房内 2 个 JOINED + 1 个 INVITING → shouldCloseGroupRoom 返 false
when(rtcParticipantMapper.selectListByRoom("r1")).thenReturn(List.of(
buildParticipant(20L, "r1", 200L, ImRtcParticipantStatusEnum.JOINED),
@@ -221,6 +225,7 @@ public class ImRtcCallServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(1);
ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.GROUP, 999L);
when(rtcCallMapper.selectByRoom("r1")).thenReturn(call);
when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(999L)).thenReturn(List.of(200L, 201L));
// 让 shouldCloseGroupRoom 返 false
when(rtcParticipantMapper.selectListByRoom("r1")).thenReturn(List.of(
buildParticipant(20L, "r1", 200L, ImRtcParticipantStatusEnum.JOINED),
@@ -295,6 +300,57 @@ public class ImRtcCallServiceImplTest extends BaseMockitoUnitTest {
verify(rtcParticipantMapper, never()).updateById(any(ImRtcParticipantDO.class));
}
@Test
public void testJoinCall_insertDuplicateKey_reuseExistingParticipant() {
// 准备:群通话活跃,首次查询无参与者,插入时命中唯一键
when(imProperties.getRtc()).thenReturn(new ImProperties.Rtc());
ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.GROUP, 999L);
when(rtcCallMapper.selectByRoom("r1")).thenReturn(call);
when(groupMemberService.validateMemberInGroup(999L, 100L)).thenReturn(new ImGroupMemberDO());
when(rtcParticipantMapper.selectLastOneByUserIdAndStatusInAndRoomNot(eq(100L), anyCollection(), eq("r1")))
.thenReturn(null);
when(rtcParticipantMapper.selectByRoomAndUserId("r1", 100L))
.thenReturn(null, buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.JOINED));
when(rtcParticipantMapper.insert(any(ImRtcParticipantDO.class))).thenThrow(new DuplicateKeyException("dup"));
// 调用
ImRtcCallDO result = rtcCallService.joinCall(100L, "r1");
// 断言:不向上抛数据库异常
assertSame(call, result);
verify(rtcParticipantMapper, never()).updateById(any(ImRtcParticipantDO.class));
}
@Test
public void testInviteCall_overLimit_throws() throws Exception {
ImProperties.Rtc rtcConfig = new ImProperties.Rtc();
rtcConfig.setGroupMaxParticipants(3);
when(imProperties.getRtc()).thenReturn(rtcConfig);
ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.GROUP, 999L);
when(rtcCallMapper.selectByRoom("r1")).thenReturn(call);
when(rtcParticipantMapper.selectByRoomAndUserId("r1", 200L))
.thenReturn(buildParticipant(10L, "r1", 200L, ImRtcParticipantStatusEnum.JOINED));
when(rtcCallLockRedisDAO.lockGroup(eq(999L), any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
Callable<Void> callable = invocation.getArgument(1);
return callable.call();
});
when(rtcParticipantMapper.selectListByRoom("r1")).thenReturn(List.of(
buildParticipant(10L, "r1", 200L, ImRtcParticipantStatusEnum.JOINED),
buildParticipant(11L, "r1", 201L, ImRtcParticipantStatusEnum.JOINED),
buildParticipant(12L, "r1", 202L, ImRtcParticipantStatusEnum.INVITING)
));
ImRtcCallInviteReqVO reqVO = new ImRtcCallInviteReqVO();
reqVO.setRoom("r1");
reqVO.setInviteeIds(Set.of(203L));
ServiceException exception = assertThrows(ServiceException.class,
() -> rtcCallService.inviteCall(200L, reqVO));
assertEquals(RTC_GROUP_INVITEE_OVER_LIMIT.getCode(), exception.getCode());
verify(rtcParticipantMapper, never()).insertBatch(anyList());
}
// ========== 测试数据构造 ==========
private ImRtcParticipantDO buildParticipant(Long id, String room, Long userId, ImRtcParticipantStatusEnum status) {
@@ -219,7 +219,8 @@ CREATE TABLE IF NOT EXISTS "im_rtc_participant" (
"update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" bit NOT NULL DEFAULT FALSE,
"tenant_id" bigint NOT NULL DEFAULT 0,
PRIMARY KEY ("id")
PRIMARY KEY ("id"),
CONSTRAINT "uk_im_rtc_participant_room_user" UNIQUE ("room", "user_id", "tenant_id")
) COMMENT 'IM 通话参与者表';
CREATE TABLE IF NOT EXISTS "im_face_user_item" (