diff --git a/yudao-framework/yudao-spring-boot-starter-websocket/src/main/java/cn/iocoder/yudao/framework/websocket/core/security/LoginUserHandshakeInterceptor.java b/yudao-framework/yudao-spring-boot-starter-websocket/src/main/java/cn/iocoder/yudao/framework/websocket/core/security/LoginUserHandshakeInterceptor.java index 3a31825..f3392f6 100644 --- a/yudao-framework/yudao-spring-boot-starter-websocket/src/main/java/cn/iocoder/yudao/framework/websocket/core/security/LoginUserHandshakeInterceptor.java +++ b/yudao-framework/yudao-spring-boot-starter-websocket/src/main/java/cn/iocoder/yudao/framework/websocket/core/security/LoginUserHandshakeInterceptor.java @@ -27,9 +27,10 @@ public class LoginUserHandshakeInterceptor implements HandshakeInterceptor { public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map attributes) { LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); - if (loginUser != null) { - WebSocketFrameworkUtils.setLoginUser(loginUser, attributes); + if (loginUser == null) { + return false; } + WebSocketFrameworkUtils.setLoginUser(loginUser, attributes); return true; } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/ImGroupManagerController.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/ImGroupManagerController.java index 0bf3d1c..46fd4eb 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/ImGroupManagerController.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/ImGroupManagerController.java @@ -90,4 +90,13 @@ public class ImGroupManagerController { return success(true); } + @DeleteMapping("/dissolve") + @Operation(summary = "解散群") + @Parameter(name = "id", description = "群编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('im:manager:group:dissolve')") + public CommonResult dissolveGroup(@RequestParam("id") Long id) { + groupService.dissolveGroupByManager(getLoginUserId(), id); + return success(true); + } + } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/face/ImFaceUserItemMapper.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/face/ImFaceUserItemMapper.java index 4518d94..5eb2dc7 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/face/ImFaceUserItemMapper.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/face/ImFaceUserItemMapper.java @@ -30,6 +30,10 @@ public interface ImFaceUserItemMapper extends BaseMapperX { .eq(ImFaceUserItemDO::getUrl, url)); } + default Long selectCountByUserId(Long userId) { + return selectCount(ImFaceUserItemDO::getUserId, userId); + } + default PageResult selectPage(ImFaceUserItemManagerPageReqVO reqVO) { return selectPage(reqVO, new LambdaQueryWrapperX() .eqIfPresent(ImFaceUserItemDO::getUserId, reqVO.getUserId()) diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapper.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapper.java index fd77e53..49d8c12 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapper.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/rtc/ImRtcParticipantMapper.java @@ -51,6 +51,13 @@ public interface ImRtcParticipantMapper extends BaseMapperX .in(ImRtcParticipantDO::getStatus, statuses)); } + default ImRtcParticipantDO selectLastOneByUserIdAndStatusInAndRoomNot(Long userId, Collection statuses, String room) { + return selectLastOne(new LambdaQueryWrapperX() + .eq(ImRtcParticipantDO::getUserId, userId) + .in(ImRtcParticipantDO::getStatus, statuses) + .ne(ImRtcParticipantDO::getRoom, room)); + } + @SuppressWarnings("UnusedReturnValue") default int updateByIdAndStatus(Long id, Integer oldStatus, ImRtcParticipantDO updateObj) { return update(updateObj, Wrappers.lambdaUpdate() diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java index eaa752e..d850e2c 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java @@ -81,6 +81,7 @@ public interface ErrorCodeConstants { ErrorCode FACE_USER_ITEM_NOT_EXISTS = new ErrorCode(1_040_800_010, "个人表情不存在"); ErrorCode FACE_USER_ITEM_NOT_OWN = new ErrorCode(1_040_800_011, "不能操作他人的表情"); ErrorCode FACE_USER_ITEM_DUPLICATED = new ErrorCode(1_040_800_013, "该表情已添加到个人表情"); + ErrorCode FACE_USER_ITEM_MAX_LIMIT = new ErrorCode(1_040_800_014, "个人表情数量不能超过 {} 个"); // ========== 频道 (1-040-810-000) ========== ErrorCode IM_CHANNEL_NOT_EXISTS = new ErrorCode(1_040_810_000, "频道不存在"); diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java index 7c206dc..8a8192f 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/framework/config/ImProperties.java @@ -11,7 +11,7 @@ import org.springframework.validation.annotation.Validated; /** * IM 模块全局配置 *

- * 各子模块用嵌套 inner class 区分(friend / group / message / 后续 sensitive 等), + * 各子模块用嵌套 inner class 区分(friend / group / face / message / rtc 等), * yaml 路径保持 yudao.im.{module}.{key} 与原有部署保持兼容 * * @author 芋道源码 @@ -26,6 +26,8 @@ public class ImProperties { private Group group = new Group(); + private Face face = new Face(); + private Message message = new Message(); @Valid @@ -71,6 +73,19 @@ public class ImProperties { } /** + * 表情模块配置 + */ + @Data + public static class Face { + + /** + * 个人表情数量上限 + */ + private int userItemMaxCount = 200; + + } + + /** * 消息模块配置 */ @Data diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/face/ImFaceUserItemServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/face/ImFaceUserItemServiceImpl.java index 556cf5d..e559805 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/face/ImFaceUserItemServiceImpl.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/face/ImFaceUserItemServiceImpl.java @@ -7,7 +7,9 @@ import cn.iocoder.yudao.module.im.controller.admin.face.vo.useritem.ImFaceUserIt import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.useritem.ImFaceUserItemManagerPageReqVO; import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFaceUserItemDO; import cn.iocoder.yudao.module.im.dal.mysql.face.ImFaceUserItemMapper; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; @@ -15,6 +17,7 @@ import java.util.List; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_DUPLICATED; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_MAX_LIMIT; import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_NOT_EXISTS; import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_NOT_OWN; @@ -29,6 +32,8 @@ public class ImFaceUserItemServiceImpl implements ImFaceUserItemService { @Resource private ImFaceUserItemMapper faceUserItemMapper; + @Resource + private ImProperties imProperties; @Override public List getFaceUserItemList(Long userId) { @@ -37,14 +42,23 @@ public class ImFaceUserItemServiceImpl implements ImFaceUserItemService { @Override public Long createFaceUserItem(Long userId, ImFaceUserItemSaveReqVO reqVO) { - // 1. 同 URL 已存在则报错;前端 catch 后 toast「已添加过」 + // 1.1 同 URL 已存在则报错 if (faceUserItemMapper.selectByUserIdAndUrl(userId, reqVO.getUrl()) != null) { throw exception(FACE_USER_ITEM_DUPLICATED); } + // 1.2 超过最大数量限制则报错 + int maxCount = imProperties.getFace().getUserItemMaxCount(); + if (faceUserItemMapper.selectCountByUserId(userId) >= maxCount) { + throw exception(FACE_USER_ITEM_MAX_LIMIT, maxCount); + } // 2. 入库 ImFaceUserItemDO item = BeanUtils.toBean(reqVO, ImFaceUserItemDO.class).setUserId(userId); - faceUserItemMapper.insert(item); + try { + faceUserItemMapper.insert(item); + } catch (DuplicateKeyException ex) { + throw exception(FACE_USER_ITEM_DUPLICATED); + } return item.getId(); } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java index 8cc1273..d3b4fd4 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java @@ -240,4 +240,12 @@ public interface ImGroupService { */ void unbanGroup(Long operatorUserId, Long id); + /** + * 【管理后台】解散群 + * + * @param operatorUserId 操作人用户编号 + * @param id 群编号 + */ + void dissolveGroupByManager(Long operatorUserId, Long id); + } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java index dc8045b..e2b8afc 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java @@ -175,17 +175,25 @@ public class ImGroupServiceImpl implements ImGroupService { @Transactional(rollbackFor = Exception.class) public void dissolveGroup(Long id, Long userId) { // 1. 校验群存在 + 当前用户是群主 - validateGroupOwner(id, userId); + ImGroupDO group = validateGroupNotDissolved(id); + if (ObjUtil.notEqual(group.getOwnerUserId(), userId)) { + throw exception(GROUP_NOT_OWNER); + } - // 2. 先发 GROUP_DISSOLVE 通知:放在成员移除前,sendGroupMessage 才能查到全员 + // 2. 解散群 + dissolveGroup0(id, userId); + } + + private void dissolveGroup0(Long id, Long userId) { + // 1. 先发 GROUP_DISSOLVE 通知:放在成员移除前,sendGroupMessage 才能查到全员 groupMessageService.sendGroupMessage(userId, ImGroupMessageSendDTO.ofGroupDissolve(id, userId)); - // 3.1 更新群状态为已解散 + // 2.1 更新群状态为已解散 groupMapper.updateById(new ImGroupDO().setId(id) .setStatus(CommonStatusEnum.DISABLE.getStatus()).setDissolvedTime(LocalDateTime.now())); - // 3.2 移除全部群成员 + // 2.2 移除全部群成员 groupMemberService.removeGroupMembersByGroupId(id); - // 3.3 清理已读缓存 + // 2.3 清理已读缓存 groupMessageService.deleteReadMaxMessageIdMap(id); } @@ -512,6 +520,17 @@ public class ImGroupServiceImpl implements ImGroupService { return group; } + private ImGroupDO validateGroupNotDissolved(Long id) { + ImGroupDO group = getSelf().getGroup(id); + if (group == null) { + throw exception(GROUP_NOT_EXISTS); + } + if (CommonStatusEnum.DISABLE.getStatus().equals(group.getStatus())) { + throw exception(GROUP_DISSOLVED); + } + return group; + } + /** * 校验当前用户是群主或管理员,否则抛 GROUP_NOT_OWNER_OR_ADMIN */ @@ -602,6 +621,17 @@ public class ImGroupServiceImpl implements ImGroupService { ImGroupMessageSendDTO.ofGroupBanned(id, operatorUserId, false)); } + @Override + @CacheEvict(cacheNames = GROUP, key = "#id") + @Transactional(rollbackFor = Exception.class) + public void dissolveGroupByManager(Long operatorUserId, Long id) { + // 1. 校验群存在且未解散 + validateGroupNotDissolved(id); + + // 2. 解散群 + dissolveGroup0(id, operatorUserId); + } + // ==================== 群禁言 ==================== @Override diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java index 275b83e..ae4efbf 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java @@ -252,6 +252,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { } // 1.3 校验当前用户是该群有效成员;防止仅凭 room 就拿到 LiveKit token 越权入会 groupMemberService.validateMemberInGroup(call.getGroupId(), userId); + validateUserNotJoinedOtherCall(userId, call.getRoom()); // 2. 入参与表:已有记录切回 JOINED;不在记录则以 ACTIVE_JOIN 角色 INSERT LocalDateTime now = LocalDateTime.now(); @@ -322,6 +323,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { if (ImRtcParticipantStatusEnum.isJoined(participant.getStatus())) { return call; } + validateUserNotJoinedOtherCall(userId, call.getRoom()); // 2.2 仅 INVITING → JOINED;其它状态拒 if (!ImRtcParticipantStatusEnum.isInviting(participant.getStatus())) { throw exception(RTC_SESSION_NOT_EXISTS); @@ -732,6 +734,14 @@ 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) { + throw exception(RTC_SELF_BUSY); + } + } + /** * 关房判定收口:私聊任一方离开必关;群通话仅在「无人在房 + 无人响铃」时关 *

diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFaceUserItemServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFaceUserItemServiceImplTest.java index ae4026b..3cf7f33 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFaceUserItemServiceImplTest.java +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFaceUserItemServiceImplTest.java @@ -5,12 +5,16 @@ import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; import cn.iocoder.yudao.module.im.controller.admin.face.vo.useritem.ImFaceUserItemSaveReqVO; import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFaceUserItemDO; import cn.iocoder.yudao.module.im.dal.mysql.face.ImFaceUserItemMapper; +import cn.iocoder.yudao.module.im.framework.config.ImProperties; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; +import org.springframework.dao.DuplicateKeyException; import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_DUPLICATED; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_MAX_LIMIT; import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_NOT_EXISTS; import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_NOT_OWN; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -33,6 +37,8 @@ public class ImFaceUserItemServiceImplTest extends BaseMockitoUnitTest { @Mock private ImFaceUserItemMapper faceUserItemMapper; + @Spy + private ImProperties imProperties = new ImProperties(); // ========== createFaceUserItem ========== @@ -42,6 +48,7 @@ public class ImFaceUserItemServiceImplTest extends BaseMockitoUnitTest { ImFaceUserItemSaveReqVO reqVO = new ImFaceUserItemSaveReqVO(); reqVO.setUrl("a.png").setName("doge").setWidth(100).setHeight(100); when(faceUserItemMapper.selectByUserIdAndUrl(1L, "a.png")).thenReturn(null); + when(faceUserItemMapper.selectCountByUserId(1L)).thenReturn(10L); // 调用 service.createFaceUserItem(1L, reqVO); @@ -68,6 +75,37 @@ public class ImFaceUserItemServiceImplTest extends BaseMockitoUnitTest { verify(faceUserItemMapper, never()).insert(any(ImFaceUserItemDO.class)); } + @Test + public void testCreateFaceUserItem_maxLimit() { + // 准备:个人表情已达上限 + ImFaceUserItemSaveReqVO reqVO = new ImFaceUserItemSaveReqVO(); + reqVO.setUrl("a.png").setWidth(100).setHeight(100); + imProperties.getFace().setUserItemMaxCount(20); + when(faceUserItemMapper.selectByUserIdAndUrl(1L, "a.png")).thenReturn(null); + when(faceUserItemMapper.selectCountByUserId(1L)).thenReturn(20L); + + // 调用 + 断言:抛上限异常,不落库 + ServiceException exception = assertThrows(ServiceException.class, + () -> service.createFaceUserItem(1L, reqVO)); + assertEquals(FACE_USER_ITEM_MAX_LIMIT.getCode(), exception.getCode()); + verify(faceUserItemMapper, never()).insert(any(ImFaceUserItemDO.class)); + } + + @Test + public void testCreateFaceUserItem_duplicateKey() { + // 准备:并发插入触发唯一约束 + ImFaceUserItemSaveReqVO reqVO = new ImFaceUserItemSaveReqVO(); + reqVO.setUrl("a.png").setWidth(100).setHeight(100); + when(faceUserItemMapper.selectByUserIdAndUrl(1L, "a.png")).thenReturn(null); + when(faceUserItemMapper.selectCountByUserId(1L)).thenReturn(10L); + when(faceUserItemMapper.insert(any(ImFaceUserItemDO.class))).thenThrow(new DuplicateKeyException("duplicate")); + + // 调用 + 断言:数据库唯一约束冲突转业务重复异常 + ServiceException exception = assertThrows(ServiceException.class, + () -> service.createFaceUserItem(1L, reqVO)); + assertEquals(FACE_USER_ITEM_DUPLICATED.getCode(), exception.getCode()); + } + // ========== deleteFaceUserItem(用户端) ========== @Test diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java index 55ee5eb..a5b9008 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java @@ -240,6 +240,50 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { } @Test + public void testDissolveGroup_banned_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + // 准备:已封禁但未解散的群,群主仍可解散 + ImGroupDO group = ImGroupDO.builder().id(10L).name("群").ownerUserId(1L) + .banned(true).status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + // 调用 + groupService.dissolveGroup(10L, 1L); + + // 断言:封禁状态不阻止解散 + verify(groupMapper).updateById(argThat((ImGroupDO update) -> + CommonStatusEnum.DISABLE.getStatus().equals(update.getStatus()))); + verify(groupMemberService).removeGroupMembersByGroupId(10L); + } + } + + @Test + public void testDissolveGroupByManager_success() { + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) + .thenReturn(groupService); + + // 准备:管理员解散封禁群,不要求管理员是群主 + ImGroupDO group = ImGroupDO.builder().id(10L).name("群").ownerUserId(1L) + .banned(true).status(CommonStatusEnum.ENABLE.getStatus()).build(); + when(groupMapper.selectById(10L)).thenReturn(group); + + // 调用 + groupService.dissolveGroupByManager(99L, 10L); + + // 断言:使用管理员编号发通知并完成清理 + ArgumentCaptor dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class); + verify(groupMessageService).sendGroupMessage(eq(99L), dtoCaptor.capture()); + assertEquals(ImMessageTypeEnum.GROUP_DISSOLVE.getType(), dtoCaptor.getValue().getType()); + verify(groupMemberService).removeGroupMembersByGroupId(10L); + verify(groupMessageService).deleteReadMaxMessageIdMap(10L); + } + } + + @Test public void testDissolveGroup_notOwner() { try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImplTest.java index 3f2714a..7177882 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImplTest.java +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImplTest.java @@ -1,6 +1,8 @@ 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.dal.dataobject.group.ImGroupMemberDO; import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO; import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO; import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcCallMapper; @@ -10,6 +12,7 @@ import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallStatusEnum; import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantRoleEnum; import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantStatusEnum; import cn.iocoder.yudao.module.im.framework.config.ImProperties; +import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; import cn.iocoder.yudao.module.system.api.user.AdminUserApi; @@ -25,6 +28,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.RTC_SELF_BUSY; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -44,6 +48,8 @@ public class ImRtcCallServiceImplTest extends BaseMockitoUnitTest { private ImWebSocketService webSocketService; @Mock private ImProperties imProperties; + @Mock + private ImGroupMemberService groupMemberService; // ========== timeoutInvitingParticipants(Job 入口)========== @@ -222,6 +228,44 @@ public class ImRtcCallServiceImplTest extends BaseMockitoUnitTest { verify(rtcCallMapper, never()).updateByIdAndStatusIn(any(), anyCollection(), any()); } + // ========== acceptCall / joinCall 忙线校验 ========== + + @Test + public void testAcceptCall_joinedOtherRoom_throwSelfBusy() { + // 准备:当前通话仍在邀请中,但用户已加入另一个房间 + when(imProperties.getRtc()).thenReturn(new ImProperties.Rtc()); + ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.PRIVATE, null); + when(rtcCallMapper.selectByRoom("r1")).thenReturn(call); + when(rtcParticipantMapper.selectByRoomAndUserId("r1", 100L)) + .thenReturn(buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.INVITING)); + when(rtcParticipantMapper.selectLastOneByUserIdAndStatusInAndRoomNot(eq(100L), anyCollection(), eq("r1"))) + .thenReturn(buildParticipant(11L, "r2", 100L, ImRtcParticipantStatusEnum.JOINED)); + + // 调用 + 断言:拒绝接听,不覆盖其它房间状态 + ServiceException exception = assertThrows(ServiceException.class, + () -> rtcCallService.acceptCall(100L, "r1")); + assertEquals(RTC_SELF_BUSY.getCode(), exception.getCode()); + verify(rtcParticipantMapper, never()).updateByIdAndStatus(eq(10L), any(), any()); + } + + @Test + public void testJoinCall_joinedOtherRoom_throwSelfBusy() { + // 准备:群通话活跃,用户已加入另一个房间 + 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(buildParticipant(11L, "r2", 100L, ImRtcParticipantStatusEnum.JOINED)); + + // 调用 + 断言:拒绝加入,不写参与者状态 + ServiceException exception = assertThrows(ServiceException.class, + () -> rtcCallService.joinCall(100L, "r1")); + assertEquals(RTC_SELF_BUSY.getCode(), exception.getCode()); + verify(rtcParticipantMapper, never()).insert(any(ImRtcParticipantDO.class)); + verify(rtcParticipantMapper, never()).updateById(any(ImRtcParticipantDO.class)); + } + // ========== 测试数据构造 ========== private ImRtcParticipantDO buildParticipant(Long id, String room, Long userId, ImRtcParticipantStatusEnum status) { diff --git a/yudao-module-im/src/test/resources/sql/create_tables.sql b/yudao-module-im/src/test/resources/sql/create_tables.sql index 7b09900..427522a 100644 --- a/yudao-module-im/src/test/resources/sql/create_tables.sql +++ b/yudao-module-im/src/test/resources/sql/create_tables.sql @@ -236,7 +236,8 @@ CREATE TABLE IF NOT EXISTS "im_face_user_item" ( "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_face_user_item_user_url_deleted" UNIQUE ("user_id", "url", "deleted") ) COMMENT 'IM 用户私有表情表'; CREATE TABLE IF NOT EXISTS "im_channel" (