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,9 +27,10 @@ public class LoginUserHandshakeInterceptor implements HandshakeInterceptor {
27 public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, 27 public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
28 WebSocketHandler wsHandler, Map<String, Object> attributes) { 28 WebSocketHandler wsHandler, Map<String, Object> attributes) {
29 LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); 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 return true; 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,4 +90,13 @@ public class ImGroupManagerController {
90 return success(true); 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,6 +30,10 @@ public interface ImFaceUserItemMapper extends BaseMapperX&lt;ImFaceUserItemDO&gt; {
30 .eq(ImFaceUserItemDO::getUrl, url)); 30 .eq(ImFaceUserItemDO::getUrl, url));
31 } 31 }
32 32
  33 + default Long selectCountByUserId(Long userId) {
  34 + return selectCount(ImFaceUserItemDO::getUserId, userId);
  35 + }
  36 +
33 default PageResult<ImFaceUserItemDO> selectPage(ImFaceUserItemManagerPageReqVO reqVO) { 37 default PageResult<ImFaceUserItemDO> selectPage(ImFaceUserItemManagerPageReqVO reqVO) {
34 return selectPage(reqVO, new LambdaQueryWrapperX<ImFaceUserItemDO>() 38 return selectPage(reqVO, new LambdaQueryWrapperX<ImFaceUserItemDO>()
35 .eqIfPresent(ImFaceUserItemDO::getUserId, reqVO.getUserId()) 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,6 +51,13 @@ public interface ImRtcParticipantMapper extends BaseMapperX&lt;ImRtcParticipantDO&gt;
51 .in(ImRtcParticipantDO::getStatus, statuses)); 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 @SuppressWarnings("UnusedReturnValue") 61 @SuppressWarnings("UnusedReturnValue")
55 default int updateByIdAndStatus(Long id, Integer oldStatus, ImRtcParticipantDO updateObj) { 62 default int updateByIdAndStatus(Long id, Integer oldStatus, ImRtcParticipantDO updateObj) {
56 return update(updateObj, Wrappers.<ImRtcParticipantDO>lambdaUpdate() 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,6 +81,7 @@ public interface ErrorCodeConstants {
81 ErrorCode FACE_USER_ITEM_NOT_EXISTS = new ErrorCode(1_040_800_010, "个人表情不存在"); 81 ErrorCode FACE_USER_ITEM_NOT_EXISTS = new ErrorCode(1_040_800_010, "个人表情不存在");
82 ErrorCode FACE_USER_ITEM_NOT_OWN = new ErrorCode(1_040_800_011, "不能操作他人的表情"); 82 ErrorCode FACE_USER_ITEM_NOT_OWN = new ErrorCode(1_040_800_011, "不能操作他人的表情");
83 ErrorCode FACE_USER_ITEM_DUPLICATED = new ErrorCode(1_040_800_013, "该表情已添加到个人表情"); 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 // ========== 频道 (1-040-810-000) ========== 86 // ========== 频道 (1-040-810-000) ==========
86 ErrorCode IM_CHANNEL_NOT_EXISTS = new ErrorCode(1_040_810_000, "频道不存在"); 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,7 +11,7 @@ import org.springframework.validation.annotation.Validated;
11 /** 11 /**
12 * IM 模块全局配置 12 * IM 模块全局配置
13 * <p> 13 * <p>
14 - * 各子模块用嵌套 inner class 区分(friend / group / message / 后续 sensitive 等), 14 + * 各子模块用嵌套 inner class 区分(friend / group / face / message / rtc 等),
15 * yaml 路径保持 yudao.im.{module}.{key} 与原有部署保持兼容 15 * yaml 路径保持 yudao.im.{module}.{key} 与原有部署保持兼容
16 * 16 *
17 * @author 芋道源码 17 * @author 芋道源码
@@ -26,6 +26,8 @@ public class ImProperties { @@ -26,6 +26,8 @@ public class ImProperties {
26 26
27 private Group group = new Group(); 27 private Group group = new Group();
28 28
  29 + private Face face = new Face();
  30 +
29 private Message message = new Message(); 31 private Message message = new Message();
30 32
31 @Valid 33 @Valid
@@ -71,6 +73,19 @@ public class ImProperties { @@ -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 @Data 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 +7,9 @@ import cn.iocoder.yudao.module.im.controller.admin.face.vo.useritem.ImFaceUserIt
7 import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.useritem.ImFaceUserItemManagerPageReqVO; 7 import cn.iocoder.yudao.module.im.controller.admin.manager.face.vo.useritem.ImFaceUserItemManagerPageReqVO;
8 import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFaceUserItemDO; 8 import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFaceUserItemDO;
9 import cn.iocoder.yudao.module.im.dal.mysql.face.ImFaceUserItemMapper; 9 import cn.iocoder.yudao.module.im.dal.mysql.face.ImFaceUserItemMapper;
  10 +import cn.iocoder.yudao.module.im.framework.config.ImProperties;
10 import jakarta.annotation.Resource; 11 import jakarta.annotation.Resource;
  12 +import org.springframework.dao.DuplicateKeyException;
11 import org.springframework.stereotype.Service; 13 import org.springframework.stereotype.Service;
12 import org.springframework.validation.annotation.Validated; 14 import org.springframework.validation.annotation.Validated;
13 15
@@ -15,6 +17,7 @@ import java.util.List; @@ -15,6 +17,7 @@ import java.util.List;
15 17
16 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; 18 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
17 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_DUPLICATED; 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 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_NOT_EXISTS; 21 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_NOT_EXISTS;
19 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_NOT_OWN; 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,6 +32,8 @@ public class ImFaceUserItemServiceImpl implements ImFaceUserItemService {
29 32
30 @Resource 33 @Resource
31 private ImFaceUserItemMapper faceUserItemMapper; 34 private ImFaceUserItemMapper faceUserItemMapper;
  35 + @Resource
  36 + private ImProperties imProperties;
32 37
33 @Override 38 @Override
34 public List<ImFaceUserItemDO> getFaceUserItemList(Long userId) { 39 public List<ImFaceUserItemDO> getFaceUserItemList(Long userId) {
@@ -37,14 +42,23 @@ public class ImFaceUserItemServiceImpl implements ImFaceUserItemService { @@ -37,14 +42,23 @@ public class ImFaceUserItemServiceImpl implements ImFaceUserItemService {
37 42
38 @Override 43 @Override
39 public Long createFaceUserItem(Long userId, ImFaceUserItemSaveReqVO reqVO) { 44 public Long createFaceUserItem(Long userId, ImFaceUserItemSaveReqVO reqVO) {
40 - // 1. 同 URL 已存在则报错;前端 catch 后 toast「已添加过」 45 + // 1.1 同 URL 已存在则报错
41 if (faceUserItemMapper.selectByUserIdAndUrl(userId, reqVO.getUrl()) != null) { 46 if (faceUserItemMapper.selectByUserIdAndUrl(userId, reqVO.getUrl()) != null) {
42 throw exception(FACE_USER_ITEM_DUPLICATED); 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 // 2. 入库 55 // 2. 入库
46 ImFaceUserItemDO item = BeanUtils.toBean(reqVO, ImFaceUserItemDO.class).setUserId(userId); 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 return item.getId(); 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,4 +240,12 @@ public interface ImGroupService {
240 */ 240 */
241 void unbanGroup(Long operatorUserId, Long id); 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,17 +175,25 @@ public class ImGroupServiceImpl implements ImGroupService {
175 @Transactional(rollbackFor = Exception.class) 175 @Transactional(rollbackFor = Exception.class)
176 public void dissolveGroup(Long id, Long userId) { 176 public void dissolveGroup(Long id, Long userId) {
177 // 1. 校验群存在 + 当前用户是群主 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 groupMessageService.sendGroupMessage(userId, ImGroupMessageSendDTO.ofGroupDissolve(id, userId)); 189 groupMessageService.sendGroupMessage(userId, ImGroupMessageSendDTO.ofGroupDissolve(id, userId));
182 190
183 - // 3.1 更新群状态为已解散 191 + // 2.1 更新群状态为已解散
184 groupMapper.updateById(new ImGroupDO().setId(id) 192 groupMapper.updateById(new ImGroupDO().setId(id)
185 .setStatus(CommonStatusEnum.DISABLE.getStatus()).setDissolvedTime(LocalDateTime.now())); 193 .setStatus(CommonStatusEnum.DISABLE.getStatus()).setDissolvedTime(LocalDateTime.now()));
186 - // 3.2 移除全部群成员 194 + // 2.2 移除全部群成员
187 groupMemberService.removeGroupMembersByGroupId(id); 195 groupMemberService.removeGroupMembersByGroupId(id);
188 - // 3.3 清理已读缓存 196 + // 2.3 清理已读缓存
189 groupMessageService.deleteReadMaxMessageIdMap(id); 197 groupMessageService.deleteReadMaxMessageIdMap(id);
190 } 198 }
191 199
@@ -512,6 +520,17 @@ public class ImGroupServiceImpl implements ImGroupService { @@ -512,6 +520,17 @@ public class ImGroupServiceImpl implements ImGroupService {
512 return group; 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 * 校验当前用户是群主或管理员,否则抛 GROUP_NOT_OWNER_OR_ADMIN 535 * 校验当前用户是群主或管理员,否则抛 GROUP_NOT_OWNER_OR_ADMIN
517 */ 536 */
@@ -602,6 +621,17 @@ public class ImGroupServiceImpl implements ImGroupService { @@ -602,6 +621,17 @@ public class ImGroupServiceImpl implements ImGroupService {
602 ImGroupMessageSendDTO.ofGroupBanned(id, operatorUserId, false)); 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 @Override 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,6 +252,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
252 } 252 }
253 // 1.3 校验当前用户是该群有效成员;防止仅凭 room 就拿到 LiveKit token 越权入会 253 // 1.3 校验当前用户是该群有效成员;防止仅凭 room 就拿到 LiveKit token 越权入会
254 groupMemberService.validateMemberInGroup(call.getGroupId(), userId); 254 groupMemberService.validateMemberInGroup(call.getGroupId(), userId);
  255 + validateUserNotJoinedOtherCall(userId, call.getRoom());
255 256
256 // 2. 入参与表:已有记录切回 JOINED;不在记录则以 ACTIVE_JOIN 角色 INSERT 257 // 2. 入参与表:已有记录切回 JOINED;不在记录则以 ACTIVE_JOIN 角色 INSERT
257 LocalDateTime now = LocalDateTime.now(); 258 LocalDateTime now = LocalDateTime.now();
@@ -322,6 +323,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -322,6 +323,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
322 if (ImRtcParticipantStatusEnum.isJoined(participant.getStatus())) { 323 if (ImRtcParticipantStatusEnum.isJoined(participant.getStatus())) {
323 return call; 324 return call;
324 } 325 }
  326 + validateUserNotJoinedOtherCall(userId, call.getRoom());
325 // 2.2 仅 INVITING → JOINED;其它状态拒 327 // 2.2 仅 INVITING → JOINED;其它状态拒
326 if (!ImRtcParticipantStatusEnum.isInviting(participant.getStatus())) { 328 if (!ImRtcParticipantStatusEnum.isInviting(participant.getStatus())) {
327 throw exception(RTC_SESSION_NOT_EXISTS); 329 throw exception(RTC_SESSION_NOT_EXISTS);
@@ -732,6 +734,14 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -732,6 +734,14 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
732 return participant; 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 * <p> 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,12 +5,16 @@ import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
5 import cn.iocoder.yudao.module.im.controller.admin.face.vo.useritem.ImFaceUserItemSaveReqVO; 5 import cn.iocoder.yudao.module.im.controller.admin.face.vo.useritem.ImFaceUserItemSaveReqVO;
6 import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFaceUserItemDO; 6 import cn.iocoder.yudao.module.im.dal.dataobject.face.ImFaceUserItemDO;
7 import cn.iocoder.yudao.module.im.dal.mysql.face.ImFaceUserItemMapper; 7 import cn.iocoder.yudao.module.im.dal.mysql.face.ImFaceUserItemMapper;
  8 +import cn.iocoder.yudao.module.im.framework.config.ImProperties;
8 import org.junit.jupiter.api.Test; 9 import org.junit.jupiter.api.Test;
9 import org.mockito.ArgumentCaptor; 10 import org.mockito.ArgumentCaptor;
10 import org.mockito.InjectMocks; 11 import org.mockito.InjectMocks;
11 import org.mockito.Mock; 12 import org.mockito.Mock;
  13 +import org.mockito.Spy;
  14 +import org.springframework.dao.DuplicateKeyException;
12 15
13 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_DUPLICATED; 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 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_NOT_EXISTS; 18 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_NOT_EXISTS;
15 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_NOT_OWN; 19 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FACE_USER_ITEM_NOT_OWN;
16 import static org.junit.jupiter.api.Assertions.assertEquals; 20 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -33,6 +37,8 @@ public class ImFaceUserItemServiceImplTest extends BaseMockitoUnitTest { @@ -33,6 +37,8 @@ public class ImFaceUserItemServiceImplTest extends BaseMockitoUnitTest {
33 37
34 @Mock 38 @Mock
35 private ImFaceUserItemMapper faceUserItemMapper; 39 private ImFaceUserItemMapper faceUserItemMapper;
  40 + @Spy
  41 + private ImProperties imProperties = new ImProperties();
36 42
37 // ========== createFaceUserItem ========== 43 // ========== createFaceUserItem ==========
38 44
@@ -42,6 +48,7 @@ public class ImFaceUserItemServiceImplTest extends BaseMockitoUnitTest { @@ -42,6 +48,7 @@ public class ImFaceUserItemServiceImplTest extends BaseMockitoUnitTest {
42 ImFaceUserItemSaveReqVO reqVO = new ImFaceUserItemSaveReqVO(); 48 ImFaceUserItemSaveReqVO reqVO = new ImFaceUserItemSaveReqVO();
43 reqVO.setUrl("a.png").setName("doge").setWidth(100).setHeight(100); 49 reqVO.setUrl("a.png").setName("doge").setWidth(100).setHeight(100);
44 when(faceUserItemMapper.selectByUserIdAndUrl(1L, "a.png")).thenReturn(null); 50 when(faceUserItemMapper.selectByUserIdAndUrl(1L, "a.png")).thenReturn(null);
  51 + when(faceUserItemMapper.selectCountByUserId(1L)).thenReturn(10L);
45 52
46 // 调用 53 // 调用
47 service.createFaceUserItem(1L, reqVO); 54 service.createFaceUserItem(1L, reqVO);
@@ -68,6 +75,37 @@ public class ImFaceUserItemServiceImplTest extends BaseMockitoUnitTest { @@ -68,6 +75,37 @@ public class ImFaceUserItemServiceImplTest extends BaseMockitoUnitTest {
68 verify(faceUserItemMapper, never()).insert(any(ImFaceUserItemDO.class)); 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 // ========== deleteFaceUserItem(用户端) ========== 109 // ========== deleteFaceUserItem(用户端) ==========
72 110
73 @Test 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,6 +240,50 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
240 } 240 }
241 241
242 @Test 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 public void testDissolveGroup_notOwner() { 287 public void testDissolveGroup_notOwner() {
244 try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) { 288 try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
245 springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class))) 289 springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupServiceImpl.class)))
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImplTest.java
1 package cn.iocoder.yudao.module.im.service.rtc; 1 package cn.iocoder.yudao.module.im.service.rtc;
2 2
  3 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
3 import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; 4 import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  5 +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
4 import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO; 6 import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcCallDO;
5 import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO; 7 import cn.iocoder.yudao.module.im.dal.dataobject.rtc.ImRtcParticipantDO;
6 import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcCallMapper; 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,6 +12,7 @@ import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallStatusEnum;
10 import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantRoleEnum; 12 import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantRoleEnum;
11 import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantStatusEnum; 13 import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantStatusEnum;
12 import cn.iocoder.yudao.module.im.framework.config.ImProperties; 14 import cn.iocoder.yudao.module.im.framework.config.ImProperties;
  15 +import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService;
13 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; 16 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
14 import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; 17 import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
15 import cn.iocoder.yudao.module.system.api.user.AdminUserApi; 18 import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
@@ -25,6 +28,7 @@ import java.util.Collections; @@ -25,6 +28,7 @@ import java.util.Collections;
25 import java.util.List; 28 import java.util.List;
26 import java.util.Map; 29 import java.util.Map;
27 30
  31 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.RTC_SELF_BUSY;
28 import static org.junit.jupiter.api.Assertions.*; 32 import static org.junit.jupiter.api.Assertions.*;
29 import static org.mockito.ArgumentMatchers.*; 33 import static org.mockito.ArgumentMatchers.*;
30 import static org.mockito.Mockito.*; 34 import static org.mockito.Mockito.*;
@@ -44,6 +48,8 @@ public class ImRtcCallServiceImplTest extends BaseMockitoUnitTest { @@ -44,6 +48,8 @@ public class ImRtcCallServiceImplTest extends BaseMockitoUnitTest {
44 private ImWebSocketService webSocketService; 48 private ImWebSocketService webSocketService;
45 @Mock 49 @Mock
46 private ImProperties imProperties; 50 private ImProperties imProperties;
  51 + @Mock
  52 + private ImGroupMemberService groupMemberService;
47 53
48 // ========== timeoutInvitingParticipants(Job 入口)========== 54 // ========== timeoutInvitingParticipants(Job 入口)==========
49 55
@@ -222,6 +228,44 @@ public class ImRtcCallServiceImplTest extends BaseMockitoUnitTest { @@ -222,6 +228,44 @@ public class ImRtcCallServiceImplTest extends BaseMockitoUnitTest {
222 verify(rtcCallMapper, never()).updateByIdAndStatusIn(any(), anyCollection(), any()); 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 private ImRtcParticipantDO buildParticipant(Long id, String room, Long userId, ImRtcParticipantStatusEnum status) { 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,7 +236,8 @@ CREATE TABLE IF NOT EXISTS &quot;im_face_user_item&quot; (
236 "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 236 "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
237 "deleted" bit NOT NULL DEFAULT FALSE, 237 "deleted" bit NOT NULL DEFAULT FALSE,
238 "tenant_id" bigint NOT NULL DEFAULT 0, 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 ) COMMENT 'IM 用户私有表情表'; 241 ) COMMENT 'IM 用户私有表情表';
241 242
242 CREATE TABLE IF NOT EXISTS "im_channel" ( 243 CREATE TABLE IF NOT EXISTS "im_channel" (