Commit f5ef0a8997e8a6d9c343c7d73356ef8d34f58f72
1 parent
bda89227
fix(im): 批量修复 P0 安全边界和通话流程问题
- 拒绝匿名 WebSocket 握手,收紧 RTC 接听和入会忙线校验 - 支持封禁群解散,管理端解散改为独立权限码 - 增加个人表情数量配置、唯一约束和并发重复兜底 - 修复 RTC 异常断开上报、视频远端音频和好友选择大列表渲染 - 让个人表情添加失败透出后端业务错误 - 流转 P0 bug 文档,并按产品取舍记录 apiSecret 默认值不强制拦截
Showing
14 changed files
with
237 additions
and
11 deletions
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<ImFaceUserItemDO> { | @@ -30,6 +30,10 @@ public interface ImFaceUserItemMapper extends BaseMapperX<ImFaceUserItemDO> { | ||
| 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<ImRtcParticipantDO> | @@ -51,6 +51,13 @@ public interface ImRtcParticipantMapper extends BaseMapperX<ImRtcParticipantDO> | ||
| 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 "im_face_user_item" ( | @@ -236,7 +236,8 @@ CREATE TABLE IF NOT EXISTS "im_face_user_item" ( | ||
| 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" ( |