Commit 513c130151dff23a270a2d05efd6dd7f617ce2e6
1 parent
f0e46399
fix(im):加固群聊与好友部分流程
- 拦截对方已拉黑时的静默恢复好友关系 - 入群申请通过前复核群状态和申请人成员状态 - 群消息撤回保留定向可见范围 - 过滤群历史消息和已读用户查询中的定向消息 - 敏感词缓存刷新补充租户上下文 - 补充 IM 测试表约束和好友申请回归用例
Showing
12 changed files
with
139 additions
and
30 deletions
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupMemberController.java
| 1 | 1 | package cn.iocoder.yudao.module.im.controller.admin.group; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.collection.CollUtil; |
| 4 | +import cn.hutool.core.util.ObjUtil; | |
| 4 | 5 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; |
| 5 | 6 | import cn.iocoder.yudao.framework.common.pojo.CommonResult; |
| 6 | 7 | import cn.iocoder.yudao.framework.common.util.collection.MapUtils; |
| ... | ... | @@ -58,6 +59,7 @@ public class ImGroupMemberController { |
| 58 | 59 | public CommonResult<ImGroupMemberRespVO> getGroupMember(@RequestParam(value = "id", required = false) Long id, |
| 59 | 60 | @RequestParam(value = "groupId", required = false) Long groupId, |
| 60 | 61 | @RequestParam(value = "userId", required = false) Long userId) { |
| 62 | + // 1. 查询群成员 | |
| 61 | 63 | ImGroupMemberDO member; |
| 62 | 64 | if (id != null) { |
| 63 | 65 | member = groupMemberService.getGroupMember(id); |
| ... | ... | @@ -67,7 +69,18 @@ public class ImGroupMemberController { |
| 67 | 69 | // 避免 selectByGroupIdAndUserId 收到 null 参数走全表扫 / 抛 SQL 异常 |
| 68 | 70 | throw new IllegalArgumentException("参数缺失:需传 id 或 (groupId, userId)"); |
| 69 | 71 | } |
| 70 | - return success(BeanUtils.toBean(member, ImGroupMemberRespVO.class)); | |
| 72 | + if (member == null) { | |
| 73 | + return success(null); | |
| 74 | + } | |
| 75 | + | |
| 76 | + // 2. 校验当前登录用户是该成员所在群的有效成员 | |
| 77 | + Long loginUserId = getLoginUserId(); | |
| 78 | + groupMemberService.validateMemberInGroup(member.getGroupId(), loginUserId); | |
| 79 | + | |
| 80 | + // 3. 转化 VO | |
| 81 | + ImGroupMemberRespVO memberVO = BeanUtils.toBean(member, ImGroupMemberRespVO.class); | |
| 82 | + hidePrivateFieldsIfNotSelf(memberVO, member.getUserId(), loginUserId); | |
| 83 | + return success(memberVO); | |
| 71 | 84 | } |
| 72 | 85 | |
| 73 | 86 | @GetMapping("/list") |
| ... | ... | @@ -91,8 +104,18 @@ public class ImGroupMemberController { |
| 91 | 104 | ImGroupMemberRespVO vo = BeanUtils.toBean(m, ImGroupMemberRespVO.class); |
| 92 | 105 | MapUtils.findAndThen(userMap, m.getUserId(), user -> |
| 93 | 106 | vo.setNickname(user.getNickname()).setAvatar(user.getAvatar())); |
| 107 | + hidePrivateFieldsIfNotSelf(vo, m.getUserId(), loginUserId); | |
| 94 | 108 | return vo; |
| 95 | 109 | })); |
| 96 | 110 | } |
| 97 | 111 | |
| 112 | + /** | |
| 113 | + * 非本人查看时,置空成员的私人设置字段(groupRemark / silent) | |
| 114 | + */ | |
| 115 | + private void hidePrivateFieldsIfNotSelf(ImGroupMemberRespVO vo, Long memberUserId, Long loginUserId) { | |
| 116 | + if (ObjUtil.notEqual(loginUserId, memberUserId)) { | |
| 117 | + vo.setGroupRemark(null).setSilent(null); | |
| 118 | + } | |
| 119 | + } | |
| 120 | + | |
| 98 | 121 | } | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java
| ... | ... | @@ -20,6 +20,7 @@ public interface ErrorCodeConstants { |
| 20 | 20 | ErrorCode MESSAGE_NOT_IN_GROUP = new ErrorCode(1_040_300_009, "消息不属于该群"); |
| 21 | 21 | ErrorCode MESSAGE_PRIVATE_READ_DISABLED = new ErrorCode(1_040_300_010, "私聊已读功能已关闭"); |
| 22 | 22 | ErrorCode MESSAGE_GROUP_READ_DISABLED = new ErrorCode(1_040_300_011, "群聊已读功能已关闭"); |
| 23 | + ErrorCode MESSAGE_GROUP_READ_USERS_DENIED = new ErrorCode(1_040_300_012, "仅消息发送者可查看已读列表"); | |
| 23 | 24 | |
| 24 | 25 | // ========== 群 (1-040-400-000) ========== |
| 25 | 26 | ErrorCode GROUP_NOT_EXISTS = new ErrorCode(1_040_400_000, "群不存在"); |
| ... | ... | @@ -31,6 +32,7 @@ public interface ErrorCodeConstants { |
| 31 | 32 | ErrorCode GROUP_MESSAGE_PIN_MAX_LIMIT = new ErrorCode(1_040_400_006, "群置顶消息数量不能超过 {} 条"); |
| 32 | 33 | ErrorCode GROUP_MESSAGE_ALREADY_PINNED = new ErrorCode(1_040_400_007, "该消息已置顶"); |
| 33 | 34 | ErrorCode GROUP_MESSAGE_NOT_PINNED = new ErrorCode(1_040_400_008, "该消息未置顶"); |
| 35 | + ErrorCode GROUP_MESSAGE_PIN_DIRECTED_DENIED = new ErrorCode(1_040_400_009, "定向消息不支持置顶"); | |
| 34 | 36 | |
| 35 | 37 | // ========== 群成员 (1-040-500-000) ========== |
| 36 | 38 | ErrorCode GROUP_MEMBER_NOT_IN_GROUP = new ErrorCode(1_040_500_001, "您已不在该群中"); | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestServiceImpl.java
| 1 | 1 | package cn.iocoder.yudao.module.im.service.friend; |
| 2 | 2 | |
| 3 | +import cn.hutool.core.util.BooleanUtil; | |
| 3 | 4 | import cn.hutool.core.util.ObjUtil; |
| 4 | 5 | import cn.hutool.extra.spring.SpringUtil; |
| 5 | 6 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; |
| ... | ... | @@ -82,6 +83,11 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService { |
| 82 | 83 | // 1.4 单向好友(我已删 + 对方仍把我当好友):静默重新启用我侧关系,避免对方感知我曾删除 |
| 83 | 84 | ImFriendDO peerFriend = friendService.getFriend(toUserId, fromUserId); |
| 84 | 85 | if (peerFriend != null && CommonStatusEnum.isEnable(peerFriend.getStatus())) { |
| 86 | + // 对方已拉黑:静默恢复等于绕过拉黑回到好友列表,必须先拒掉; | |
| 87 | + // getFriendState 在我侧 DISABLE 时直接返回 NONE,拿不到 BLOCKED 信号,这里显式补一次校验 | |
| 88 | + if (BooleanUtil.isTrue(peerFriend.getBlocked())) { | |
| 89 | + throw exception(FRIEND_REQUEST_BLOCKED_BY_PEER); | |
| 90 | + } | |
| 85 | 91 | friendService.silentReAddFriend(fromUserId, toUserId, reqVO.getDisplayName(), reqVO.getAddSource()); |
| 86 | 92 | return null; |
| 87 | 93 | } | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java
| ... | ... | @@ -249,8 +249,9 @@ public class ImFriendServiceImpl implements ImFriendService { |
| 249 | 249 | |
| 250 | 250 | /** |
| 251 | 251 | * 单向绑定好友关系(内部方法,被 {@link #becomeFriends} / {@link #silentReAddFriend} 调用): |
| 252 | - * - 情况一:已存在记录(含 ENABLE / DISABLE)→ 复用并恢复 ENABLE,silent / pinned / blocked 一并重置为 false | |
| 253 | - * - 情况二:不存在记录 → 直接插入新记录 | |
| 252 | + * - 情况一:已存在 ENABLE 记录 → 已是好友,幂等跳过;不重置 silent / pinned / blocked,避免历史未处理申请再被同意时清掉用户的拉黑 / 置顶 / 免打扰设置 | |
| 253 | + * - 情况二:已存在 DISABLE 记录 → 复用并恢复 ENABLE,silent / pinned / blocked 一并重置为 false,对齐"重新加好友"语义 | |
| 254 | + * - 情况三:不存在记录 → 直接插入新记录 | |
| 254 | 255 | * <p> |
| 255 | 256 | * 并发安全:agree 路径由 {@code friend_request.handle_result} 的乐观锁单边推进; |
| 256 | 257 | * 极端并发下若插入唯一键冲突,让 DuplicateKeyException 向外抛出,外层事务回滚。 |
| ... | ... | @@ -259,7 +260,11 @@ public class ImFriendServiceImpl implements ImFriendService { |
| 259 | 260 | */ |
| 260 | 261 | public void addFriend0(Long userId, Long friendUserId, String displayName, Integer addSource) { |
| 261 | 262 | ImFriendDO exists = friendMapper.selectByUserIdAndFriendUserId(userId, friendUserId); |
| 262 | - // 情况一:复用旧记录 → 恢复 ENABLE + 重置 silent / pinned / blocked 与首次新增对齐 | |
| 263 | + // 情况一:已是 ENABLE 好友,幂等跳过;防御历史未处理申请被二次同意时把 blocked / silent / pinned 清回 false | |
| 264 | + if (exists != null && CommonStatusEnum.isEnable(exists.getStatus())) { | |
| 265 | + return; | |
| 266 | + } | |
| 267 | + // 情况二:复用 DISABLE 旧记录 → 恢复 ENABLE + 重置 silent / pinned / blocked,对齐"重新加好友"语义 | |
| 263 | 268 | if (exists != null) { |
| 264 | 269 | ImFriendDO update = new ImFriendDO().setId(exists.getId()) |
| 265 | 270 | .setStatus(CommonStatusEnum.ENABLE.getStatus()).setAddTime(LocalDateTime.now()) |
| ... | ... | @@ -273,7 +278,7 @@ public class ImFriendServiceImpl implements ImFriendService { |
| 273 | 278 | friendMapper.updateById(update); |
| 274 | 279 | return; |
| 275 | 280 | } |
| 276 | - // 情况二:不存在记录 → 直接插入新记录 | |
| 281 | + // 情况三:不存在记录 → 直接插入新记录 | |
| 277 | 282 | ImFriendDO friend = ImFriendDO.builder().userId(userId).friendUserId(friendUserId) |
| 278 | 283 | .silent(false).pinned(false).blocked(false) |
| 279 | 284 | .displayName(displayName).addSource(addSource) | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupRequestServiceImpl.java
| ... | ... | @@ -123,8 +123,15 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService { |
| 123 | 123 | @Override |
| 124 | 124 | @Transactional(rollbackFor = Exception.class) |
| 125 | 125 | public void agreeGroupRequest(Long userId, Long requestId) { |
| 126 | - // 1. 校验申请存在 + 未处理 + 操作人是 owner / admin | |
| 126 | + // 1.1 校验申请存在 + 未处理 + 操作人是 owner / admin | |
| 127 | 127 | ImGroupRequestDO request = validateRequestForHandle(userId, requestId); |
| 128 | + // 1.2 复核群当前状态:拒绝在封禁 / 解散的群继续放人 | |
| 129 | + groupService.validateGroupExists(request.getGroupId()); | |
| 130 | + // 1.3 复核申请人是否已在群中;幂等避免重复广播 1509 / 1510 入群事件 | |
| 131 | + ImGroupMemberDO applicant = groupMemberService.getGroupMember(request.getGroupId(), request.getUserId()); | |
| 132 | + if (applicant != null && CommonStatusEnum.ENABLE.getStatus().equals(applicant.getStatus())) { | |
| 133 | + throw exception(GROUP_REQUEST_ALREADY_MEMBER); | |
| 134 | + } | |
| 128 | 135 | // 2. 入群前校验人数上限;群已满抛错让操作人选择拒绝 |
| 129 | 136 | groupService.validateMemberCountLimit(request.getGroupId(), 1); |
| 130 | 137 | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java
| ... | ... | @@ -192,6 +192,7 @@ public class ImGroupServiceImpl implements ImGroupService { |
| 192 | 192 | // ==================== 群成员的写操作 ==================== |
| 193 | 193 | |
| 194 | 194 | @Override |
| 195 | + @Transactional(rollbackFor = Exception.class) | |
| 195 | 196 | public void inviteGroupMember(Long userId, ImGroupMemberInviteReqVO inviteReqVO) { |
| 196 | 197 | Long groupId = inviteReqVO.getGroupId(); |
| 197 | 198 | // 1.1 校验群存在 + 当前用户是群成员;同时拿到 role 供下面审批分支判断 |
| ... | ... | @@ -238,24 +239,28 @@ public class ImGroupServiceImpl implements ImGroupService { |
| 238 | 239 | } |
| 239 | 240 | |
| 240 | 241 | @Override |
| 242 | + @Transactional(rollbackFor = Exception.class) | |
| 241 | 243 | public void quitGroup(Long groupId, Long userId) { |
| 242 | - // 1. 校验群存在 | |
| 244 | + // 1.1 校验群存在 | |
| 243 | 245 | ImGroupDO group = validateGroupExists(groupId); |
| 244 | - // 2. 群主不可退群 | |
| 246 | + // 1.2 群主不可退群 | |
| 245 | 247 | if (ObjUtil.equal(group.getOwnerUserId(), userId)) { |
| 246 | 248 | throw exception(GROUP_OWNER_CANNOT_QUIT); |
| 247 | 249 | } |
| 250 | + // 1.3 校验当前用户是有效群成员;防止非成员触发广播 + 后续 remove 失败时无法回滚已推送的事件 | |
| 251 | + groupMemberService.validateMemberInGroup(groupId, userId); | |
| 248 | 252 | |
| 249 | - // 3. 先发广播,后移成员(见类 javadoc) | |
| 253 | + // 2. 先发广播,后移成员(见类 javadoc) | |
| 250 | 254 | groupMessageService.sendGroupMessage(userId, ImGroupMessageSendDTO.ofGroupMemberQuit(groupId, userId)); |
| 251 | 255 | |
| 252 | - // 4.1 移除群成员 | |
| 256 | + // 3.1 移除群成员 | |
| 253 | 257 | groupMemberService.removeGroupMember(groupId, userId); |
| 254 | - // 4.2 清理已读缓存 | |
| 258 | + // 3.2 清理已读缓存 | |
| 255 | 259 | groupMessageService.deleteReadMaxMessageId(groupId, userId); |
| 256 | 260 | } |
| 257 | 261 | |
| 258 | 262 | @Override |
| 263 | + @Transactional(rollbackFor = Exception.class) | |
| 259 | 264 | public void removeGroupMember(Long userId, ImGroupMemberRemoveReqVO removeReqVO) { |
| 260 | 265 | Long groupId = removeReqVO.getGroupId(); |
| 261 | 266 | Set<Long> targetUserIds = new HashSet<>(removeReqVO.getMemberUserIds()); |
| ... | ... | @@ -400,9 +405,9 @@ public class ImGroupServiceImpl implements ImGroupService { |
| 400 | 405 | @CacheEvict(cacheNames = GROUP, key = "#groupId") |
| 401 | 406 | @Transactional(rollbackFor = Exception.class) |
| 402 | 407 | public void pinGroupMessage(Long userId, Long groupId, Long messageId) { |
| 403 | - // 1. 校验群主 / 管理员;同时拿到 group 复用,避免再走一次 @Cacheable | |
| 408 | + // 1.1 校验群主 / 管理员;同时拿到 group 复用,避免再走一次 @Cacheable | |
| 404 | 409 | ImGroupDO group = validateOwnerOrAdminAndGetGroup(groupId, userId); |
| 405 | - // 2. 校验消息属于该群、是普通聊天消息(绕过前端菜单不允许置顶群事件 / 撤回事件)、且未被撤回 | |
| 410 | + // 1.2 校验消息属于该群、是普通聊天消息(绕过前端菜单不允许置顶群事件 / 撤回事件)、且未被撤回 | |
| 406 | 411 | ImGroupMessageDO message = groupMessageService.getGroupMessage(messageId); |
| 407 | 412 | if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId)) { |
| 408 | 413 | throw exception(MESSAGE_NOT_IN_GROUP); |
| ... | ... | @@ -411,7 +416,12 @@ public class ImGroupServiceImpl implements ImGroupService { |
| 411 | 416 | || ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { |
| 412 | 417 | throw exception(MESSAGE_NOT_IN_GROUP); |
| 413 | 418 | } |
| 414 | - // 3. 幂等 + 上限校验 | |
| 419 | + // 1.3 定向消息(receiverUserIds 非空)不允许置顶:置顶会向全群广播,泄露原本仅部分人可见的内容 | |
| 420 | + if (CollUtil.isNotEmpty(message.getReceiverUserIds())) { | |
| 421 | + throw exception(GROUP_MESSAGE_PIN_DIRECTED_DENIED); | |
| 422 | + } | |
| 423 | + | |
| 424 | + // 2. 幂等 + 上限校验 | |
| 415 | 425 | List<Long> pinned = new ArrayList<>(CollUtil.emptyIfNull(group.getPinnedMessageIds())); |
| 416 | 426 | if (pinned.contains(messageId)) { |
| 417 | 427 | throw exception(GROUP_MESSAGE_ALREADY_PINNED); |
| ... | ... | @@ -423,7 +433,7 @@ public class ImGroupServiceImpl implements ImGroupService { |
| 423 | 433 | pinned.add(messageId); |
| 424 | 434 | groupMapper.updateById(new ImGroupDO().setId(groupId).setPinnedMessageIds(pinned)); |
| 425 | 435 | |
| 426 | - // 4. 推送 GROUP_MESSAGE_PIN 通知给全员;payload 直接带消息对象,前端不用回查群详情绕开 @CacheEvict 时序 | |
| 436 | + // 3. 推送 GROUP_MESSAGE_PIN 通知给全员;payload 直接带消息对象,前端不用回查群详情绕开 @CacheEvict 时序 | |
| 427 | 437 | groupMessageService.sendGroupMessage(userId, |
| 428 | 438 | ImGroupMessageSendDTO.ofGroupMessagePin(groupId, userId, message)); |
| 429 | 439 | } |
| ... | ... | @@ -590,6 +600,7 @@ public class ImGroupServiceImpl implements ImGroupService { |
| 590 | 600 | |
| 591 | 601 | @Override |
| 592 | 602 | @CacheEvict(cacheNames = GROUP, key = "#reqVO.groupId") |
| 603 | + @Transactional(rollbackFor = Exception.class) | |
| 593 | 604 | public void muteAll(Long userId, ImGroupMuteAllReqVO reqVO) { |
| 594 | 605 | // 1. 校验群主或管理员 |
| 595 | 606 | validateGroupOwnerOrAdmin(reqVO.getGroupId(), userId); |
| ... | ... | @@ -605,6 +616,7 @@ public class ImGroupServiceImpl implements ImGroupService { |
| 605 | 616 | } |
| 606 | 617 | |
| 607 | 618 | @Override |
| 619 | + @Transactional(rollbackFor = Exception.class) | |
| 608 | 620 | public void muteMember(Long userId, ImGroupMuteMemberReqVO reqVO) { |
| 609 | 621 | // 1.1 不能禁言自己 |
| 610 | 622 | if (ObjUtil.equal(userId, reqVO.getUserId())) { |
| ... | ... | @@ -629,6 +641,7 @@ public class ImGroupServiceImpl implements ImGroupService { |
| 629 | 641 | } |
| 630 | 642 | |
| 631 | 643 | @Override |
| 644 | + @Transactional(rollbackFor = Exception.class) | |
| 632 | 645 | public void cancelMuteMember(Long userId, ImGroupCancelMuteMemberReqVO reqVO) { |
| 633 | 646 | // 1.1 校验群存在且未封禁 |
| 634 | 647 | validateGroupExists(reqVO.getGroupId()); | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java
| ... | ... | @@ -177,7 +177,9 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { |
| 177 | 177 | |
| 178 | 178 | // 3. 发送撤回事件 |
| 179 | 179 | return sendGroupMessage(userId, new ImGroupMessageSendDTO().setGroupId(message.getGroupId()) |
| 180 | - .setType(ImMessageTypeEnum.RECALL.getType()).setContent(new RecallMessage().setMessageId(messageId))); | |
| 180 | + .setType(ImMessageTypeEnum.RECALL.getType()) | |
| 181 | + .setReceiverUserIds(message.getReceiverUserIds()) | |
| 182 | + .setContent(new RecallMessage().setMessageId(messageId))); | |
| 181 | 183 | } |
| 182 | 184 | |
| 183 | 185 | @Override |
| ... | ... | @@ -374,10 +376,17 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { |
| 374 | 376 | throw exception(MESSAGE_GROUP_READ_DISABLED); |
| 375 | 377 | } |
| 376 | 378 | // 1.1 校验用户在群中(权限校验) |
| 377 | - groupMemberService.validateMemberInGroup(groupId, userId); | |
| 378 | - // 1.2 获取消息 | |
| 379 | + ImGroupMemberDO operator = groupMemberService.validateMemberInGroup(groupId, userId); | |
| 380 | + // 1.2 获取消息;并校验消息归属于该群、对调用者可见、调用者是发送方 | |
| 379 | 381 | ImGroupMessageDO message = groupMessageMapper.selectById(messageId); |
| 380 | - if (message == null) { | |
| 382 | + if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId)) { | |
| 383 | + return Collections.emptyList(); | |
| 384 | + } | |
| 385 | + if (!isMessageVisible(message, operator, userId)) { | |
| 386 | + return Collections.emptyList(); | |
| 387 | + } | |
| 388 | + // 1.3 仅消息发送方关心已读人数;非发送方查询直接返回空 | |
| 389 | + if (ObjUtil.notEqual(message.getSenderId(), userId)) { | |
| 381 | 390 | return Collections.emptyList(); |
| 382 | 391 | } |
| 383 | 392 | |
| ... | ... | @@ -405,8 +414,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { |
| 405 | 414 | ImGroupMemberDO member = groupMemberService.validateMemberInGroup(reqVO.getGroupId(), userId); |
| 406 | 415 | |
| 407 | 416 | // 2. 查询历史消息(仅入群之后) |
| 408 | - return groupMessageMapper.selectHistoryList( | |
| 417 | + List<ImGroupMessageDO> messages = groupMessageMapper.selectHistoryList( | |
| 409 | 418 | reqVO.getGroupId(), reqVO.getMaxId(), reqVO.getLimit(), member.getJoinTime()); |
| 419 | + | |
| 420 | + // 3. 过滤定向消息:仅保留当前用户可见的(receiverUserIds 为空 / 含当前用户 / 本人发送) | |
| 421 | + return filterList(messages, message -> isMessageVisible(message, member, userId)); | |
| 410 | 422 | } |
| 411 | 423 | |
| 412 | 424 | // ========== 异步 WebSocket 推送 ========== | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java
| ... | ... | @@ -250,6 +250,8 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { |
| 250 | 250 | if (!ImConversationTypeEnum.isGroup(call.getConversationType())) { |
| 251 | 251 | throw exception(RTC_GROUP_REQUIRED); |
| 252 | 252 | } |
| 253 | + // 1.3 校验当前用户是该群有效成员;防止仅凭 room 就拿到 LiveKit token 越权入会 | |
| 254 | + groupMemberService.validateMemberInGroup(call.getGroupId(), userId); | |
| 253 | 255 | |
| 254 | 256 | // 2. 入参与表:已有记录切回 JOINED;不在记录则以 ACTIVE_JOIN 角色 INSERT |
| 255 | 257 | LocalDateTime now = LocalDateTime.now(); | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImpl.java
| ... | ... | @@ -87,13 +87,16 @@ public class ImSensitiveWordServiceImpl implements ImSensitiveWordService { |
| 87 | 87 | |
| 88 | 88 | @Override |
| 89 | 89 | public ListenableFuture<SensitiveWordBsCache> reload(Long tenantId, SensitiveWordBsCache oldValue) { |
| 90 | - // 先比对 max(update_time);没变 → 复用旧实例,避免无谓地重建 trie | |
| 91 | - LocalDateTime currentMax = sensitiveWordMapper.selectMaxUpdateTime(tenantId); | |
| 92 | - if (Objects.equals(oldValue.getMaxUpdateTime(), currentMax)) { | |
| 93 | - return Futures.immediateFuture(oldValue); | |
| 94 | - } | |
| 95 | - // 变了 → 重新读词库并重建 trie | |
| 96 | - return Futures.immediateFuture(loadFresh(tenantId)); | |
| 90 | + // 异步刷新线程独立于业务线程,没有租户上下文;必须显式 TenantUtils.execute 设置,否则租户拦截器会按当前线程的空上下文拼 SQL | |
| 91 | + return Futures.immediateFuture(TenantUtils.execute(tenantId, () -> { | |
| 92 | + LocalDateTime currentMax = sensitiveWordMapper.selectMaxUpdateTime(tenantId); | |
| 93 | + // 没变 → 复用旧实例,避免无谓地重建 trie | |
| 94 | + if (Objects.equals(oldValue.getMaxUpdateTime(), currentMax)) { | |
| 95 | + return oldValue; | |
| 96 | + } | |
| 97 | + // 变了 → 重新读词库并重建 trie | |
| 98 | + return loadFresh(tenantId); | |
| 99 | + })); | |
| 97 | 100 | } |
| 98 | 101 | |
| 99 | 102 | }); | ... | ... |
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestServiceImplTest.java
| ... | ... | @@ -112,6 +112,24 @@ public class ImFriendRequestServiceImplTest extends BaseMockitoUnitTest { |
| 112 | 112 | } |
| 113 | 113 | |
| 114 | 114 | @Test |
| 115 | + public void testApplyFriend_blockedByPeerWhenMyselfDeleted() { | |
| 116 | + // 准备:我侧已删除(getFriendState=NONE),对方仍把我当好友但已拉黑 | |
| 117 | + ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO(); | |
| 118 | + reqVO.setToUserId(2L).setDisplayName("老张").setAddSource(1); | |
| 119 | + when(friendService.getFriendState(1L, 2L)).thenReturn(ImFriendStateEnum.NONE.getState()); | |
| 120 | + ImFriendDO peerFriend = ImFriendDO.builder().userId(2L).friendUserId(1L) | |
| 121 | + .status(CommonStatusEnum.ENABLE.getStatus()).blocked(true).build(); | |
| 122 | + when(friendService.getFriend(2L, 1L)).thenReturn(peerFriend); | |
| 123 | + | |
| 124 | + // 调用 + 断言:必须拒掉,不能走 silentReAddFriend 绕过拉黑 | |
| 125 | + ServiceException exception = assertThrows(ServiceException.class, | |
| 126 | + () -> friendRequestService.applyFriend(1L, reqVO)); | |
| 127 | + assertEquals(FRIEND_REQUEST_BLOCKED_BY_PEER.getCode(), exception.getCode()); | |
| 128 | + verify(friendService, never()).silentReAddFriend(anyLong(), anyLong(), anyString(), anyInt()); | |
| 129 | + verify(friendRequestMapper, never()).insert(any(ImFriendRequestDO.class)); | |
| 130 | + } | |
| 131 | + | |
| 132 | + @Test | |
| 115 | 133 | public void testApplyFriend_insertNew() { |
| 116 | 134 | // 准备:双方都无关系,且无历史申请 |
| 117 | 135 | ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO(); | ... | ... |
yudao-module-im/src/test/resources/sql/clean.sql
yudao-module-im/src/test/resources/sql/create_tables.sql
| ... | ... | @@ -13,7 +13,8 @@ CREATE TABLE IF NOT EXISTS "im_private_message" ( |
| 13 | 13 | "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| 14 | 14 | "deleted" bit NOT NULL DEFAULT FALSE, |
| 15 | 15 | "tenant_id" bigint NOT NULL DEFAULT 0, |
| 16 | - PRIMARY KEY ("id") | |
| 16 | + PRIMARY KEY ("id"), | |
| 17 | + CONSTRAINT "uk_im_private_message_sender_client" UNIQUE ("sender_id", "client_message_id", "tenant_id") | |
| 17 | 18 | ) COMMENT 'IM 私聊消息表'; |
| 18 | 19 | |
| 19 | 20 | CREATE TABLE IF NOT EXISTS "im_group_message" ( |
| ... | ... | @@ -34,7 +35,8 @@ CREATE TABLE IF NOT EXISTS "im_group_message" ( |
| 34 | 35 | "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| 35 | 36 | "deleted" bit NOT NULL DEFAULT FALSE, |
| 36 | 37 | "tenant_id" bigint NOT NULL DEFAULT 0, |
| 37 | - PRIMARY KEY ("id") | |
| 38 | + PRIMARY KEY ("id"), | |
| 39 | + CONSTRAINT "uk_im_group_message_sender_client" UNIQUE ("sender_id", "client_message_id", "tenant_id") | |
| 38 | 40 | ) COMMENT 'IM 群聊消息表'; |
| 39 | 41 | |
| 40 | 42 | CREATE TABLE IF NOT EXISTS "im_group" ( |
| ... | ... | @@ -80,7 +82,8 @@ CREATE TABLE IF NOT EXISTS "im_group_member" ( |
| 80 | 82 | "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| 81 | 83 | "deleted" bit NOT NULL DEFAULT FALSE, |
| 82 | 84 | "tenant_id" bigint NOT NULL DEFAULT 0, |
| 83 | - PRIMARY KEY ("id") | |
| 85 | + PRIMARY KEY ("id"), | |
| 86 | + CONSTRAINT "uk_im_group_member" UNIQUE ("group_id", "user_id", "tenant_id") | |
| 84 | 87 | ) COMMENT 'IM 群成员表'; |
| 85 | 88 | |
| 86 | 89 | CREATE TABLE IF NOT EXISTS "im_friend" ( |
| ... | ... | @@ -287,6 +290,20 @@ CREATE TABLE IF NOT EXISTS "im_channel_message" ( |
| 287 | 290 | PRIMARY KEY ("id") |
| 288 | 291 | ) COMMENT 'IM 频道消息表'; |
| 289 | 292 | |
| 293 | +CREATE TABLE IF NOT EXISTS "im_sensitive_word" ( | |
| 294 | + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', | |
| 295 | + "word" varchar(128) NOT NULL COMMENT '敏感词', | |
| 296 | + "status" tinyint NOT NULL DEFAULT 0 COMMENT '状态;0 启用 1 停用', | |
| 297 | + "creator" varchar(64) DEFAULT '', | |
| 298 | + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 299 | + "updater" varchar(64) DEFAULT '', | |
| 300 | + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 301 | + "deleted" bit NOT NULL DEFAULT FALSE, | |
| 302 | + "tenant_id" bigint NOT NULL DEFAULT 0, | |
| 303 | + PRIMARY KEY ("id"), | |
| 304 | + CONSTRAINT "uk_im_sensitive_word" UNIQUE ("word", "tenant_id") | |
| 305 | +) COMMENT 'IM 敏感词表'; | |
| 306 | + | |
| 290 | 307 | CREATE TABLE IF NOT EXISTS "system_users" ( |
| 291 | 308 | "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, |
| 292 | 309 | "username" varchar(30) NOT NULL DEFAULT '', | ... | ... |