Commit f5ef0a8997e8a6d9c343c7d73356ef8d34f58f72

Authored by YunaiV
1 parent bda89227

fix(im): 批量修复 P0 安全边界和通话流程问题

- 拒绝匿名 WebSocket 握手,收紧 RTC 接听和入会忙线校验
- 支持封禁群解散,管理端解散改为独立权限码
- 增加个人表情数量配置、唯一约束和并发重复兜底
- 修复 RTC 异常断开上报、视频远端音频和好友选择大列表渲染
- 让个人表情添加失败透出后端业务错误
- 流转 P0 bug 文档,并按产品取舍记录 apiSecret 默认值不强制拦截
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 {
27 27 public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
28 28 WebSocketHandler wsHandler, Map<String, Object> attributes) {
29 29 LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
30   - if (loginUser != null) {
31   - WebSocketFrameworkUtils.setLoginUser(loginUser, attributes);
  30 + if (loginUser == null) {
  31 + return false;
32 32 }
  33 + WebSocketFrameworkUtils.setLoginUser(loginUser, attributes);
33 34 return true;
34 35 }
35 36  
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/group/ImGroupManagerController.java
... ... @@ -90,4 +90,13 @@ public class ImGroupManagerController {
90 90 return success(true);
91 91 }
92 92  
  93 + @DeleteMapping("/dissolve")
  94 + @Operation(summary = "解散群")
  95 + @Parameter(name = "id", description = "群编号", required = true, example = "1024")
  96 + @PreAuthorize("@ss.hasPermission('im:manager:group:dissolve')")
  97 + public CommonResult<Boolean> dissolveGroup(@RequestParam("id") Long id) {
  98 + groupService.dissolveGroupByManager(getLoginUserId(), id);
  99 + return success(true);
  100 + }
  101 +
93 102 }
... ...
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&lt;ImFaceUserItemDO&gt; {
30 30 .eq(ImFaceUserItemDO::getUrl, url));
31 31 }
32 32  
  33 + default Long selectCountByUserId(Long userId) {
  34 + return selectCount(ImFaceUserItemDO::getUserId, userId);
  35 + }
  36 +
33 37 default PageResult<ImFaceUserItemDO> selectPage(ImFaceUserItemManagerPageReqVO reqVO) {
34 38 return selectPage(reqVO, new LambdaQueryWrapperX<ImFaceUserItemDO>()
35 39 .eqIfPresent(ImFaceUserItemDO::getUserId, reqVO.getUserId())
... ...
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&lt;ImRtcParticipantDO&gt;
51 51 .in(ImRtcParticipantDO::getStatus, statuses));
52 52 }
53 53  
  54 + default ImRtcParticipantDO selectLastOneByUserIdAndStatusInAndRoomNot(Long userId, Collection<Integer> statuses, String room) {
  55 + return selectLastOne(new LambdaQueryWrapperX<ImRtcParticipantDO>()
  56 + .eq(ImRtcParticipantDO::getUserId, userId)
  57 + .in(ImRtcParticipantDO::getStatus, statuses)
  58 + .ne(ImRtcParticipantDO::getRoom, room));
  59 + }
  60 +
54 61 @SuppressWarnings("UnusedReturnValue")
55 62 default int updateByIdAndStatus(Long id, Integer oldStatus, ImRtcParticipantDO updateObj) {
56 63 return update(updateObj, Wrappers.<ImRtcParticipantDO>lambdaUpdate()
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java
... ... @@ -81,6 +81,7 @@ public interface ErrorCodeConstants {
81 81 ErrorCode FACE_USER_ITEM_NOT_EXISTS = new ErrorCode(1_040_800_010, "个人表情不存在");
82 82 ErrorCode FACE_USER_ITEM_NOT_OWN = new ErrorCode(1_040_800_011, "不能操作他人的表情");
83 83 ErrorCode FACE_USER_ITEM_DUPLICATED = new ErrorCode(1_040_800_013, "该表情已添加到个人表情");
  84 + ErrorCode FACE_USER_ITEM_MAX_LIMIT = new ErrorCode(1_040_800_014, "个人表情数量不能超过 {} 个");
84 85  
85 86 // ========== 频道 (1-040-810-000) ==========
86 87 ErrorCode IM_CHANNEL_NOT_EXISTS = new ErrorCode(1_040_810_000, "频道不存在");
... ...
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;
11 11 /**
12 12 * IM 模块全局配置
13 13 * <p>
14   - * 各子模块用嵌套 inner class 区分(friend / group / message / 后续 sensitive 等),
  14 + * 各子模块用嵌套 inner class 区分(friend / group / face / message / rtc 等),
15 15 * yaml 路径保持 yudao.im.{module}.{key} 与原有部署保持兼容
16 16 *
17 17 * @author 芋道源码
... ... @@ -26,6 +26,8 @@ public class ImProperties {
26 26  
27 27 private Group group = new Group();
28 28  
  29 + private Face face = new Face();
  30 +
29 31 private Message message = new Message();
30 32  
31 33 @Valid
... ... @@ -71,6 +73,19 @@ public class ImProperties {
71 73 }
72 74  
73 75 /**
  76 + * 表情模块配置
  77 + */
  78 + @Data
  79 + public static class Face {
  80 +
  81 + /**
  82 + * 个人表情数量上限
  83 + */
  84 + private int userItemMaxCount = 200;
  85 +
  86 + }
  87 +
  88 + /**
74 89 * 消息模块配置
75 90 */
76 91 @Data
... ...
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
7 7 import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.useritem.ImFaceUserItemManagerPageReqVO;
8 8 import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFaceUserItemDO;
9 9 import cn.iocoder.yudao.module.im.dal.mysql.face.ImFaceUserItemMapper;
  10 +import cn.iocoder.yudao.module.im.framework.config.ImProperties;
10 11 import jakarta.annotation.Resource;
  12 +import org.springframework.dao.DuplicateKeyException;
11 13 import org.springframework.stereotype.Service;
12 14 import org.springframework.validation.annotation.Validated;
13 15  
... ... @@ -15,6 +17,7 @@ import java.util.List;
15 17  
16 18 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
17 19 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_DUPLICATED;
  20 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_MAX_LIMIT;
18 21 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_NOT_EXISTS;
19 22 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_NOT_OWN;
20 23  
... ... @@ -29,6 +32,8 @@ public class ImFaceUserItemServiceImpl implements ImFaceUserItemService {
29 32  
30 33 @Resource
31 34 private ImFaceUserItemMapper faceUserItemMapper;
  35 + @Resource
  36 + private ImProperties imProperties;
32 37  
33 38 @Override
34 39 public List<ImFaceUserItemDO> getFaceUserItemList(Long userId) {
... ... @@ -37,14 +42,23 @@ public class ImFaceUserItemServiceImpl implements ImFaceUserItemService {
37 42  
38 43 @Override
39 44 public Long createFaceUserItem(Long userId, ImFaceUserItemSaveReqVO reqVO) {
40   - // 1. 同 URL 已存在则报错;前端 catch 后 toast「已添加过」
  45 + // 1.1 同 URL 已存在则报错
41 46 if (faceUserItemMapper.selectByUserIdAndUrl(userId, reqVO.getUrl()) != null) {
42 47 throw exception(FACE_USER_ITEM_DUPLICATED);
43 48 }
  49 + // 1.2 超过最大数量限制则报错
  50 + int maxCount = imProperties.getFace().getUserItemMaxCount();
  51 + if (faceUserItemMapper.selectCountByUserId(userId) >= maxCount) {
  52 + throw exception(FACE_USER_ITEM_MAX_LIMIT, maxCount);
  53 + }
44 54  
45 55 // 2. 入库
46 56 ImFaceUserItemDO item = BeanUtils.toBean(reqVO, ImFaceUserItemDO.class).setUserId(userId);
47   - faceUserItemMapper.insert(item);
  57 + try {
  58 + faceUserItemMapper.insert(item);
  59 + } catch (DuplicateKeyException ex) {
  60 + throw exception(FACE_USER_ITEM_DUPLICATED);
  61 + }
48 62 return item.getId();
49 63 }
50 64  
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java
... ... @@ -240,4 +240,12 @@ public interface ImGroupService {
240 240 */
241 241 void unbanGroup(Long operatorUserId, Long id);
242 242  
  243 + /**
  244 + * 【管理后台】解散群
  245 + *
  246 + * @param operatorUserId 操作人用户编号
  247 + * @param id 群编号
  248 + */
  249 + void dissolveGroupByManager(Long operatorUserId, Long id);
  250 +
243 251 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java
... ... @@ -175,17 +175,25 @@ public class ImGroupServiceImpl implements ImGroupService {
175 175 @Transactional(rollbackFor = Exception.class)
176 176 public void dissolveGroup(Long id, Long userId) {
177 177 // 1. 校验群存在 + 当前用户是群主
178   - validateGroupOwner(id, userId);
  178 + ImGroupDO group = validateGroupNotDissolved(id);
  179 + if (ObjUtil.notEqual(group.getOwnerUserId(), userId)) {
  180 + throw exception(GROUP_NOT_OWNER);
  181 + }
179 182  
180   - // 2. 先发 GROUP_DISSOLVE 通知:放在成员移除前,sendGroupMessage 才能查到全员
  183 + // 2. 解散群
  184 + dissolveGroup0(id, userId);
  185 + }
  186 +
  187 + private void dissolveGroup0(Long id, Long userId) {
  188 + // 1. 先发 GROUP_DISSOLVE 通知:放在成员移除前,sendGroupMessage 才能查到全员
181 189 groupMessageService.sendGroupMessage(userId, ImGroupMessageSendDTO.ofGroupDissolve(id, userId));
182 190  
183   - // 3.1 更新群状态为已解散
  191 + // 2.1 更新群状态为已解散
184 192 groupMapper.updateById(new ImGroupDO().setId(id)
185 193 .setStatus(CommonStatusEnum.DISABLE.getStatus()).setDissolvedTime(LocalDateTime.now()));
186   - // 3.2 移除全部群成员
  194 + // 2.2 移除全部群成员
187 195 groupMemberService.removeGroupMembersByGroupId(id);
188   - // 3.3 清理已读缓存
  196 + // 2.3 清理已读缓存
189 197 groupMessageService.deleteReadMaxMessageIdMap(id);
190 198 }
191 199  
... ... @@ -512,6 +520,17 @@ public class ImGroupServiceImpl implements ImGroupService {
512 520 return group;
513 521 }
514 522  
  523 + private ImGroupDO validateGroupNotDissolved(Long id) {
  524 + ImGroupDO group = getSelf().getGroup(id);
  525 + if (group == null) {
  526 + throw exception(GROUP_NOT_EXISTS);
  527 + }
  528 + if (CommonStatusEnum.DISABLE.getStatus().equals(group.getStatus())) {
  529 + throw exception(GROUP_DISSOLVED);
  530 + }
  531 + return group;
  532 + }
  533 +
515 534 /**
516 535 * 校验当前用户是群主或管理员,否则抛 GROUP_NOT_OWNER_OR_ADMIN
517 536 */
... ... @@ -602,6 +621,17 @@ public class ImGroupServiceImpl implements ImGroupService {
602 621 ImGroupMessageSendDTO.ofGroupBanned(id, operatorUserId, false));
603 622 }
604 623  
  624 + @Override
  625 + @CacheEvict(cacheNames = GROUP, key = "#id")
  626 + @Transactional(rollbackFor = Exception.class)
  627 + public void dissolveGroupByManager(Long operatorUserId, Long id) {
  628 + // 1. 校验群存在且未解散
  629 + validateGroupNotDissolved(id);
  630 +
  631 + // 2. 解散群
  632 + dissolveGroup0(id, operatorUserId);
  633 + }
  634 +
605 635 // ==================== 群禁言 ====================
606 636  
607 637 @Override
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java
... ... @@ -252,6 +252,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
252 252 }
253 253 // 1.3 校验当前用户是该群有效成员;防止仅凭 room 就拿到 LiveKit token 越权入会
254 254 groupMemberService.validateMemberInGroup(call.getGroupId(), userId);
  255 + validateUserNotJoinedOtherCall(userId, call.getRoom());
255 256  
256 257 // 2. 入参与表:已有记录切回 JOINED;不在记录则以 ACTIVE_JOIN 角色 INSERT
257 258 LocalDateTime now = LocalDateTime.now();
... ... @@ -322,6 +323,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
322 323 if (ImRtcParticipantStatusEnum.isJoined(participant.getStatus())) {
323 324 return call;
324 325 }
  326 + validateUserNotJoinedOtherCall(userId, call.getRoom());
325 327 // 2.2 仅 INVITING → JOINED;其它状态拒
326 328 if (!ImRtcParticipantStatusEnum.isInviting(participant.getStatus())) {
327 329 throw exception(RTC_SESSION_NOT_EXISTS);
... ... @@ -732,6 +734,14 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
732 734 return participant;
733 735 }
734 736  
  737 + private void validateUserNotJoinedOtherCall(Long userId, String room) {
  738 + ImRtcParticipantDO joined = rtcParticipantMapper.selectLastOneByUserIdAndStatusInAndRoomNot(
  739 + userId, Collections.singleton(ImRtcParticipantStatusEnum.JOINED.getStatus()), room);
  740 + if (joined != null) {
  741 + throw exception(RTC_SELF_BUSY);
  742 + }
  743 + }
  744 +
735 745 /**
736 746 * 关房判定收口:私聊任一方离开必关;群通话仅在「无人在房 + 无人响铃」时关
737 747 * <p>
... ...
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;
5 5 import cn.iocoder.yudao.module.im.controller.admin.face.vo.useritem.ImFaceUserItemSaveReqVO;
6 6 import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFaceUserItemDO;
7 7 import cn.iocoder.yudao.module.im.dal.mysql.face.ImFaceUserItemMapper;
  8 +import cn.iocoder.yudao.module.im.framework.config.ImProperties;
8 9 import org.junit.jupiter.api.Test;
9 10 import org.mockito.ArgumentCaptor;
10 11 import org.mockito.InjectMocks;
11 12 import org.mockito.Mock;
  13 +import org.mockito.Spy;
  14 +import org.springframework.dao.DuplicateKeyException;
12 15  
13 16 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_DUPLICATED;
  17 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_MAX_LIMIT;
14 18 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_NOT_EXISTS;
15 19 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_NOT_OWN;
16 20 import static org.junit.jupiter.api.Assertions.assertEquals;
... ... @@ -33,6 +37,8 @@ public class ImFaceUserItemServiceImplTest extends BaseMockitoUnitTest {
33 37  
34 38 @Mock
35 39 private ImFaceUserItemMapper faceUserItemMapper;
  40 + @Spy
  41 + private ImProperties imProperties = new ImProperties();
36 42  
37 43 // ========== createFaceUserItem ==========
38 44  
... ... @@ -42,6 +48,7 @@ public class ImFaceUserItemServiceImplTest extends BaseMockitoUnitTest {
42 48 ImFaceUserItemSaveReqVO reqVO = new ImFaceUserItemSaveReqVO();
43 49 reqVO.setUrl("a.png").setName("doge").setWidth(100).setHeight(100);
44 50 when(faceUserItemMapper.selectByUserIdAndUrl(1L, "a.png")).thenReturn(null);
  51 + when(faceUserItemMapper.selectCountByUserId(1L)).thenReturn(10L);
45 52  
46 53 // 调用
47 54 service.createFaceUserItem(1L, reqVO);
... ... @@ -68,6 +75,37 @@ public class ImFaceUserItemServiceImplTest extends BaseMockitoUnitTest {
68 75 verify(faceUserItemMapper, never()).insert(any(ImFaceUserItemDO.class));
69 76 }
70 77  
  78 + @Test
  79 + public void testCreateFaceUserItem_maxLimit() {
  80 + // 准备:个人表情已达上限
  81 + ImFaceUserItemSaveReqVO reqVO = new ImFaceUserItemSaveReqVO();
  82 + reqVO.setUrl("a.png").setWidth(100).setHeight(100);
  83 + imProperties.getFace().setUserItemMaxCount(20);
  84 + when(faceUserItemMapper.selectByUserIdAndUrl(1L, "a.png")).thenReturn(null);
  85 + when(faceUserItemMapper.selectCountByUserId(1L)).thenReturn(20L);
  86 +
  87 + // 调用 + 断言:抛上限异常,不落库
  88 + ServiceException exception = assertThrows(ServiceException.class,
  89 + () -> service.createFaceUserItem(1L, reqVO));
  90 + assertEquals(FACE_USER_ITEM_MAX_LIMIT.getCode(), exception.getCode());
  91 + verify(faceUserItemMapper, never()).insert(any(ImFaceUserItemDO.class));
  92 + }
  93 +
  94 + @Test
  95 + public void testCreateFaceUserItem_duplicateKey() {
  96 + // 准备:并发插入触发唯一约束
  97 + ImFaceUserItemSaveReqVO reqVO = new ImFaceUserItemSaveReqVO();
  98 + reqVO.setUrl("a.png").setWidth(100).setHeight(100);
  99 + when(faceUserItemMapper.selectByUserIdAndUrl(1L, "a.png")).thenReturn(null);
  100 + when(faceUserItemMapper.selectCountByUserId(1L)).thenReturn(10L);
  101 + when(faceUserItemMapper.insert(any(ImFaceUserItemDO.class))).thenThrow(new DuplicateKeyException("duplicate"));
  102 +
  103 + // 调用 + 断言:数据库唯一约束冲突转业务重复异常
  104 + ServiceException exception = assertThrows(ServiceException.class,
  105 + () -> service.createFaceUserItem(1L, reqVO));
  106 + assertEquals(FACE_USER_ITEM_DUPLICATED.getCode(), exception.getCode());
  107 + }
  108 +
71 109 // ========== deleteFaceUserItem(用户端) ==========
72 110  
73 111 @Test
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java
... ... @@ -240,6 +240,50 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
240 240 }
241 241  
242 242 @Test
  243 + public void testDissolveGroup_banned_success() {
  244 + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
  245 + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class)))
  246 + .thenReturn(groupService);
  247 +
  248 + // 准备:已封禁但未解散的群,群主仍可解散
  249 + ImGroupDO group = ImGroupDO.builder().id(10L).name("群").ownerUserId(1L)
  250 + .banned(true).status(CommonStatusEnum.ENABLE.getStatus()).build();
  251 + when(groupMapper.selectById(10L)).thenReturn(group);
  252 +
  253 + // 调用
  254 + groupService.dissolveGroup(10L, 1L);
  255 +
  256 + // 断言:封禁状态不阻止解散
  257 + verify(groupMapper).updateById(argThat((ImGroupDO update) ->
  258 + CommonStatusEnum.DISABLE.getStatus().equals(update.getStatus())));
  259 + verify(groupMemberService).removeGroupMembersByGroupId(10L);
  260 + }
  261 + }
  262 +
  263 + @Test
  264 + public void testDissolveGroupByManager_success() {
  265 + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
  266 + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class)))
  267 + .thenReturn(groupService);
  268 +
  269 + // 准备:管理员解散封禁群,不要求管理员是群主
  270 + ImGroupDO group = ImGroupDO.builder().id(10L).name("群").ownerUserId(1L)
  271 + .banned(true).status(CommonStatusEnum.ENABLE.getStatus()).build();
  272 + when(groupMapper.selectById(10L)).thenReturn(group);
  273 +
  274 + // 调用
  275 + groupService.dissolveGroupByManager(99L, 10L);
  276 +
  277 + // 断言:使用管理员编号发通知并完成清理
  278 + ArgumentCaptor<ImGroupMessageSendDTO> dtoCaptor = ArgumentCaptor.forClass(ImGroupMessageSendDTO.class);
  279 + verify(groupMessageService).sendGroupMessage(eq(99L), dtoCaptor.capture());
  280 + assertEquals(ImMessageTypeEnum.GROUP_DISSOLVE.getType(), dtoCaptor.getValue().getType());
  281 + verify(groupMemberService).removeGroupMembersByGroupId(10L);
  282 + verify(groupMessageService).deleteReadMaxMessageIdMap(10L);
  283 + }
  284 + }
  285 +
  286 + @Test
243 287 public void testDissolveGroup_notOwner() {
244 288 try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
245 289 springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class)))
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImplTest.java
1 1 package cn.iocoder.yudao.module.im.service.rtc;
2 2  
  3 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
3 4 import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  5 +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
4 6 import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO;
5 7 import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO;
6 8 import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcCallMapper;
... ... @@ -10,6 +12,7 @@ import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallStatusEnum;
10 12 import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantRoleEnum;
11 13 import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantStatusEnum;
12 14 import cn.iocoder.yudao.module.im.framework.config.ImProperties;
  15 +import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService;
13 16 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
14 17 import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
15 18 import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
... ... @@ -25,6 +28,7 @@ import java.util.Collections;
25 28 import java.util.List;
26 29 import java.util.Map;
27 30  
  31 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.RTC_SELF_BUSY;
28 32 import static org.junit.jupiter.api.Assertions.*;
29 33 import static org.mockito.ArgumentMatchers.*;
30 34 import static org.mockito.Mockito.*;
... ... @@ -44,6 +48,8 @@ public class ImRtcCallServiceImplTest extends BaseMockitoUnitTest {
44 48 private ImWebSocketService webSocketService;
45 49 @Mock
46 50 private ImProperties imProperties;
  51 + @Mock
  52 + private ImGroupMemberService groupMemberService;
47 53  
48 54 // ========== timeoutInvitingParticipants(Job 入口)==========
49 55  
... ... @@ -222,6 +228,44 @@ public class ImRtcCallServiceImplTest extends BaseMockitoUnitTest {
222 228 verify(rtcCallMapper, never()).updateByIdAndStatusIn(any(), anyCollection(), any());
223 229 }
224 230  
  231 + // ========== acceptCall / joinCall 忙线校验 ==========
  232 +
  233 + @Test
  234 + public void testAcceptCall_joinedOtherRoom_throwSelfBusy() {
  235 + // 准备:当前通话仍在邀请中,但用户已加入另一个房间
  236 + when(imProperties.getRtc()).thenReturn(new ImProperties.Rtc());
  237 + ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.PRIVATE, null);
  238 + when(rtcCallMapper.selectByRoom("r1")).thenReturn(call);
  239 + when(rtcParticipantMapper.selectByRoomAndUserId("r1", 100L))
  240 + .thenReturn(buildParticipant(10L, "r1", 100L, ImRtcParticipantStatusEnum.INVITING));
  241 + when(rtcParticipantMapper.selectLastOneByUserIdAndStatusInAndRoomNot(eq(100L), anyCollection(), eq("r1")))
  242 + .thenReturn(buildParticipant(11L, "r2", 100L, ImRtcParticipantStatusEnum.JOINED));
  243 +
  244 + // 调用 + 断言:拒绝接听,不覆盖其它房间状态
  245 + ServiceException exception = assertThrows(ServiceException.class,
  246 + () -> rtcCallService.acceptCall(100L, "r1"));
  247 + assertEquals(RTC_SELF_BUSY.getCode(), exception.getCode());
  248 + verify(rtcParticipantMapper, never()).updateByIdAndStatus(eq(10L), any(), any());
  249 + }
  250 +
  251 + @Test
  252 + public void testJoinCall_joinedOtherRoom_throwSelfBusy() {
  253 + // 准备:群通话活跃,用户已加入另一个房间
  254 + when(imProperties.getRtc()).thenReturn(new ImProperties.Rtc());
  255 + ImRtcCallDO call = buildCall("r1", 200L, ImConversationTypeEnum.GROUP, 999L);
  256 + when(rtcCallMapper.selectByRoom("r1")).thenReturn(call);
  257 + when(groupMemberService.validateMemberInGroup(999L, 100L)).thenReturn(new ImGroupMemberDO());
  258 + when(rtcParticipantMapper.selectLastOneByUserIdAndStatusInAndRoomNot(eq(100L), anyCollection(), eq("r1")))
  259 + .thenReturn(buildParticipant(11L, "r2", 100L, ImRtcParticipantStatusEnum.JOINED));
  260 +
  261 + // 调用 + 断言:拒绝加入,不写参与者状态
  262 + ServiceException exception = assertThrows(ServiceException.class,
  263 + () -> rtcCallService.joinCall(100L, "r1"));
  264 + assertEquals(RTC_SELF_BUSY.getCode(), exception.getCode());
  265 + verify(rtcParticipantMapper, never()).insert(any(ImRtcParticipantDO.class));
  266 + verify(rtcParticipantMapper, never()).updateById(any(ImRtcParticipantDO.class));
  267 + }
  268 +
225 269 // ========== 测试数据构造 ==========
226 270  
227 271 private ImRtcParticipantDO buildParticipant(Long id, String room, Long userId, ImRtcParticipantStatusEnum status) {
... ...
yudao-module-im/src/test/resources/sql/create_tables.sql
... ... @@ -236,7 +236,8 @@ CREATE TABLE IF NOT EXISTS &quot;im_face_user_item&quot; (
236 236 "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
237 237 "deleted" bit NOT NULL DEFAULT FALSE,
238 238 "tenant_id" bigint NOT NULL DEFAULT 0,
239   - PRIMARY KEY ("id")
  239 + PRIMARY KEY ("id"),
  240 + CONSTRAINT "uk_im_face_user_item_user_url_deleted" UNIQUE ("user_id", "url", "deleted")
240 241 ) COMMENT 'IM 用户私有表情表';
241 242  
242 243 CREATE TABLE IF NOT EXISTS "im_channel" (
... ...