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 | package cn.iocoder.yudao.module.im.controller.admin.group; | 1 | package cn.iocoder.yudao.module.im.controller.admin.group; |
| 2 | 2 | ||
| 3 | import cn.hutool.core.collection.CollUtil; | 3 | import cn.hutool.core.collection.CollUtil; |
| 4 | +import cn.hutool.core.util.ObjUtil; | ||
| 4 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; | 5 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; |
| 5 | import cn.iocoder.yudao.framework.common.pojo.CommonResult; | 6 | import cn.iocoder.yudao.framework.common.pojo.CommonResult; |
| 6 | import cn.iocoder.yudao.framework.common.util.collection.MapUtils; | 7 | import cn.iocoder.yudao.framework.common.util.collection.MapUtils; |
| @@ -58,6 +59,7 @@ public class ImGroupMemberController { | @@ -58,6 +59,7 @@ public class ImGroupMemberController { | ||
| 58 | public CommonResult<ImGroupMemberRespVO> getGroupMember(@RequestParam(value = "id", required = false) Long id, | 59 | public CommonResult<ImGroupMemberRespVO> getGroupMember(@RequestParam(value = "id", required = false) Long id, |
| 59 | @RequestParam(value = "groupId", required = false) Long groupId, | 60 | @RequestParam(value = "groupId", required = false) Long groupId, |
| 60 | @RequestParam(value = "userId", required = false) Long userId) { | 61 | @RequestParam(value = "userId", required = false) Long userId) { |
| 62 | + // 1. 查询群成员 | ||
| 61 | ImGroupMemberDO member; | 63 | ImGroupMemberDO member; |
| 62 | if (id != null) { | 64 | if (id != null) { |
| 63 | member = groupMemberService.getGroupMember(id); | 65 | member = groupMemberService.getGroupMember(id); |
| @@ -67,7 +69,18 @@ public class ImGroupMemberController { | @@ -67,7 +69,18 @@ public class ImGroupMemberController { | ||
| 67 | // 避免 selectByGroupIdAndUserId 收到 null 参数走全表扫 / 抛 SQL 异常 | 69 | // 避免 selectByGroupIdAndUserId 收到 null 参数走全表扫 / 抛 SQL 异常 |
| 68 | throw new IllegalArgumentException("参数缺失:需传 id 或 (groupId, userId)"); | 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 | @GetMapping("/list") | 86 | @GetMapping("/list") |
| @@ -91,8 +104,18 @@ public class ImGroupMemberController { | @@ -91,8 +104,18 @@ public class ImGroupMemberController { | ||
| 91 | ImGroupMemberRespVO vo = BeanUtils.toBean(m, ImGroupMemberRespVO.class); | 104 | ImGroupMemberRespVO vo = BeanUtils.toBean(m, ImGroupMemberRespVO.class); |
| 92 | MapUtils.findAndThen(userMap, m.getUserId(), user -> | 105 | MapUtils.findAndThen(userMap, m.getUserId(), user -> |
| 93 | vo.setNickname(user.getNickname()).setAvatar(user.getAvatar())); | 106 | vo.setNickname(user.getNickname()).setAvatar(user.getAvatar())); |
| 107 | + hidePrivateFieldsIfNotSelf(vo, m.getUserId(), loginUserId); | ||
| 94 | return vo; | 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,6 +20,7 @@ public interface ErrorCodeConstants { | ||
| 20 | ErrorCode MESSAGE_NOT_IN_GROUP = new ErrorCode(1_040_300_009, "消息不属于该群"); | 20 | ErrorCode MESSAGE_NOT_IN_GROUP = new ErrorCode(1_040_300_009, "消息不属于该群"); |
| 21 | ErrorCode MESSAGE_PRIVATE_READ_DISABLED = new ErrorCode(1_040_300_010, "私聊已读功能已关闭"); | 21 | ErrorCode MESSAGE_PRIVATE_READ_DISABLED = new ErrorCode(1_040_300_010, "私聊已读功能已关闭"); |
| 22 | ErrorCode MESSAGE_GROUP_READ_DISABLED = new ErrorCode(1_040_300_011, "群聊已读功能已关闭"); | 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 | // ========== 群 (1-040-400-000) ========== | 25 | // ========== 群 (1-040-400-000) ========== |
| 25 | ErrorCode GROUP_NOT_EXISTS = new ErrorCode(1_040_400_000, "群不存在"); | 26 | ErrorCode GROUP_NOT_EXISTS = new ErrorCode(1_040_400_000, "群不存在"); |
| @@ -31,6 +32,7 @@ public interface ErrorCodeConstants { | @@ -31,6 +32,7 @@ public interface ErrorCodeConstants { | ||
| 31 | ErrorCode GROUP_MESSAGE_PIN_MAX_LIMIT = new ErrorCode(1_040_400_006, "群置顶消息数量不能超过 {} 条"); | 32 | ErrorCode GROUP_MESSAGE_PIN_MAX_LIMIT = new ErrorCode(1_040_400_006, "群置顶消息数量不能超过 {} 条"); |
| 32 | ErrorCode GROUP_MESSAGE_ALREADY_PINNED = new ErrorCode(1_040_400_007, "该消息已置顶"); | 33 | ErrorCode GROUP_MESSAGE_ALREADY_PINNED = new ErrorCode(1_040_400_007, "该消息已置顶"); |
| 33 | ErrorCode GROUP_MESSAGE_NOT_PINNED = new ErrorCode(1_040_400_008, "该消息未置顶"); | 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 | // ========== 群成员 (1-040-500-000) ========== | 37 | // ========== 群成员 (1-040-500-000) ========== |
| 36 | ErrorCode GROUP_MEMBER_NOT_IN_GROUP = new ErrorCode(1_040_500_001, "您已不在该群中"); | 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 | package cn.iocoder.yudao.module.im.service.friend; | 1 | package cn.iocoder.yudao.module.im.service.friend; |
| 2 | 2 | ||
| 3 | +import cn.hutool.core.util.BooleanUtil; | ||
| 3 | import cn.hutool.core.util.ObjUtil; | 4 | import cn.hutool.core.util.ObjUtil; |
| 4 | import cn.hutool.extra.spring.SpringUtil; | 5 | import cn.hutool.extra.spring.SpringUtil; |
| 5 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; | 6 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; |
| @@ -82,6 +83,11 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService { | @@ -82,6 +83,11 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService { | ||
| 82 | // 1.4 单向好友(我已删 + 对方仍把我当好友):静默重新启用我侧关系,避免对方感知我曾删除 | 83 | // 1.4 单向好友(我已删 + 对方仍把我当好友):静默重新启用我侧关系,避免对方感知我曾删除 |
| 83 | ImFriendDO peerFriend = friendService.getFriend(toUserId, fromUserId); | 84 | ImFriendDO peerFriend = friendService.getFriend(toUserId, fromUserId); |
| 84 | if (peerFriend != null && CommonStatusEnum.isEnable(peerFriend.getStatus())) { | 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 | friendService.silentReAddFriend(fromUserId, toUserId, reqVO.getDisplayName(), reqVO.getAddSource()); | 91 | friendService.silentReAddFriend(fromUserId, toUserId, reqVO.getDisplayName(), reqVO.getAddSource()); |
| 86 | return null; | 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,8 +249,9 @@ public class ImFriendServiceImpl implements ImFriendService { | ||
| 249 | 249 | ||
| 250 | /** | 250 | /** |
| 251 | * 单向绑定好友关系(内部方法,被 {@link #becomeFriends} / {@link #silentReAddFriend} 调用): | 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 | * <p> | 255 | * <p> |
| 255 | * 并发安全:agree 路径由 {@code friend_request.handle_result} 的乐观锁单边推进; | 256 | * 并发安全:agree 路径由 {@code friend_request.handle_result} 的乐观锁单边推进; |
| 256 | * 极端并发下若插入唯一键冲突,让 DuplicateKeyException 向外抛出,外层事务回滚。 | 257 | * 极端并发下若插入唯一键冲突,让 DuplicateKeyException 向外抛出,外层事务回滚。 |
| @@ -259,7 +260,11 @@ public class ImFriendServiceImpl implements ImFriendService { | @@ -259,7 +260,11 @@ public class ImFriendServiceImpl implements ImFriendService { | ||
| 259 | */ | 260 | */ |
| 260 | public void addFriend0(Long userId, Long friendUserId, String displayName, Integer addSource) { | 261 | public void addFriend0(Long userId, Long friendUserId, String displayName, Integer addSource) { |
| 261 | ImFriendDO exists = friendMapper.selectByUserIdAndFriendUserId(userId, friendUserId); | 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 | if (exists != null) { | 268 | if (exists != null) { |
| 264 | ImFriendDO update = new ImFriendDO().setId(exists.getId()) | 269 | ImFriendDO update = new ImFriendDO().setId(exists.getId()) |
| 265 | .setStatus(CommonStatusEnum.ENABLE.getStatus()).setAddTime(LocalDateTime.now()) | 270 | .setStatus(CommonStatusEnum.ENABLE.getStatus()).setAddTime(LocalDateTime.now()) |
| @@ -273,7 +278,7 @@ public class ImFriendServiceImpl implements ImFriendService { | @@ -273,7 +278,7 @@ public class ImFriendServiceImpl implements ImFriendService { | ||
| 273 | friendMapper.updateById(update); | 278 | friendMapper.updateById(update); |
| 274 | return; | 279 | return; |
| 275 | } | 280 | } |
| 276 | - // 情况二:不存在记录 → 直接插入新记录 | 281 | + // 情况三:不存在记录 → 直接插入新记录 |
| 277 | ImFriendDO friend = ImFriendDO.builder().userId(userId).friendUserId(friendUserId) | 282 | ImFriendDO friend = ImFriendDO.builder().userId(userId).friendUserId(friendUserId) |
| 278 | .silent(false).pinned(false).blocked(false) | 283 | .silent(false).pinned(false).blocked(false) |
| 279 | .displayName(displayName).addSource(addSource) | 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,8 +123,15 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService { | ||
| 123 | @Override | 123 | @Override |
| 124 | @Transactional(rollbackFor = Exception.class) | 124 | @Transactional(rollbackFor = Exception.class) |
| 125 | public void agreeGroupRequest(Long userId, Long requestId) { | 125 | public void agreeGroupRequest(Long userId, Long requestId) { |
| 126 | - // 1. 校验申请存在 + 未处理 + 操作人是 owner / admin | 126 | + // 1.1 校验申请存在 + 未处理 + 操作人是 owner / admin |
| 127 | ImGroupRequestDO request = validateRequestForHandle(userId, requestId); | 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 | // 2. 入群前校验人数上限;群已满抛错让操作人选择拒绝 | 135 | // 2. 入群前校验人数上限;群已满抛错让操作人选择拒绝 |
| 129 | groupService.validateMemberCountLimit(request.getGroupId(), 1); | 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,6 +192,7 @@ public class ImGroupServiceImpl implements ImGroupService { | ||
| 192 | // ==================== 群成员的写操作 ==================== | 192 | // ==================== 群成员的写操作 ==================== |
| 193 | 193 | ||
| 194 | @Override | 194 | @Override |
| 195 | + @Transactional(rollbackFor = Exception.class) | ||
| 195 | public void inviteGroupMember(Long userId, ImGroupMemberInviteReqVO inviteReqVO) { | 196 | public void inviteGroupMember(Long userId, ImGroupMemberInviteReqVO inviteReqVO) { |
| 196 | Long groupId = inviteReqVO.getGroupId(); | 197 | Long groupId = inviteReqVO.getGroupId(); |
| 197 | // 1.1 校验群存在 + 当前用户是群成员;同时拿到 role 供下面审批分支判断 | 198 | // 1.1 校验群存在 + 当前用户是群成员;同时拿到 role 供下面审批分支判断 |
| @@ -238,24 +239,28 @@ public class ImGroupServiceImpl implements ImGroupService { | @@ -238,24 +239,28 @@ public class ImGroupServiceImpl implements ImGroupService { | ||
| 238 | } | 239 | } |
| 239 | 240 | ||
| 240 | @Override | 241 | @Override |
| 242 | + @Transactional(rollbackFor = Exception.class) | ||
| 241 | public void quitGroup(Long groupId, Long userId) { | 243 | public void quitGroup(Long groupId, Long userId) { |
| 242 | - // 1. 校验群存在 | 244 | + // 1.1 校验群存在 |
| 243 | ImGroupDO group = validateGroupExists(groupId); | 245 | ImGroupDO group = validateGroupExists(groupId); |
| 244 | - // 2. 群主不可退群 | 246 | + // 1.2 群主不可退群 |
| 245 | if (ObjUtil.equal(group.getOwnerUserId(), userId)) { | 247 | if (ObjUtil.equal(group.getOwnerUserId(), userId)) { |
| 246 | throw exception(GROUP_OWNER_CANNOT_QUIT); | 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 | groupMessageService.sendGroupMessage(userId, ImGroupMessageSendDTO.ofGroupMemberQuit(groupId, userId)); | 254 | groupMessageService.sendGroupMessage(userId, ImGroupMessageSendDTO.ofGroupMemberQuit(groupId, userId)); |
| 251 | 255 | ||
| 252 | - // 4.1 移除群成员 | 256 | + // 3.1 移除群成员 |
| 253 | groupMemberService.removeGroupMember(groupId, userId); | 257 | groupMemberService.removeGroupMember(groupId, userId); |
| 254 | - // 4.2 清理已读缓存 | 258 | + // 3.2 清理已读缓存 |
| 255 | groupMessageService.deleteReadMaxMessageId(groupId, userId); | 259 | groupMessageService.deleteReadMaxMessageId(groupId, userId); |
| 256 | } | 260 | } |
| 257 | 261 | ||
| 258 | @Override | 262 | @Override |
| 263 | + @Transactional(rollbackFor = Exception.class) | ||
| 259 | public void removeGroupMember(Long userId, ImGroupMemberRemoveReqVO removeReqVO) { | 264 | public void removeGroupMember(Long userId, ImGroupMemberRemoveReqVO removeReqVO) { |
| 260 | Long groupId = removeReqVO.getGroupId(); | 265 | Long groupId = removeReqVO.getGroupId(); |
| 261 | Set<Long> targetUserIds = new HashSet<>(removeReqVO.getMemberUserIds()); | 266 | Set<Long> targetUserIds = new HashSet<>(removeReqVO.getMemberUserIds()); |
| @@ -400,9 +405,9 @@ public class ImGroupServiceImpl implements ImGroupService { | @@ -400,9 +405,9 @@ public class ImGroupServiceImpl implements ImGroupService { | ||
| 400 | @CacheEvict(cacheNames = GROUP, key = "#groupId") | 405 | @CacheEvict(cacheNames = GROUP, key = "#groupId") |
| 401 | @Transactional(rollbackFor = Exception.class) | 406 | @Transactional(rollbackFor = Exception.class) |
| 402 | public void pinGroupMessage(Long userId, Long groupId, Long messageId) { | 407 | public void pinGroupMessage(Long userId, Long groupId, Long messageId) { |
| 403 | - // 1. 校验群主 / 管理员;同时拿到 group 复用,避免再走一次 @Cacheable | 408 | + // 1.1 校验群主 / 管理员;同时拿到 group 复用,避免再走一次 @Cacheable |
| 404 | ImGroupDO group = validateOwnerOrAdminAndGetGroup(groupId, userId); | 409 | ImGroupDO group = validateOwnerOrAdminAndGetGroup(groupId, userId); |
| 405 | - // 2. 校验消息属于该群、是普通聊天消息(绕过前端菜单不允许置顶群事件 / 撤回事件)、且未被撤回 | 410 | + // 1.2 校验消息属于该群、是普通聊天消息(绕过前端菜单不允许置顶群事件 / 撤回事件)、且未被撤回 |
| 406 | ImGroupMessageDO message = groupMessageService.getGroupMessage(messageId); | 411 | ImGroupMessageDO message = groupMessageService.getGroupMessage(messageId); |
| 407 | if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId)) { | 412 | if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId)) { |
| 408 | throw exception(MESSAGE_NOT_IN_GROUP); | 413 | throw exception(MESSAGE_NOT_IN_GROUP); |
| @@ -411,7 +416,12 @@ public class ImGroupServiceImpl implements ImGroupService { | @@ -411,7 +416,12 @@ public class ImGroupServiceImpl implements ImGroupService { | ||
| 411 | || ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { | 416 | || ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { |
| 412 | throw exception(MESSAGE_NOT_IN_GROUP); | 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 | List<Long> pinned = new ArrayList<>(CollUtil.emptyIfNull(group.getPinnedMessageIds())); | 425 | List<Long> pinned = new ArrayList<>(CollUtil.emptyIfNull(group.getPinnedMessageIds())); |
| 416 | if (pinned.contains(messageId)) { | 426 | if (pinned.contains(messageId)) { |
| 417 | throw exception(GROUP_MESSAGE_ALREADY_PINNED); | 427 | throw exception(GROUP_MESSAGE_ALREADY_PINNED); |
| @@ -423,7 +433,7 @@ public class ImGroupServiceImpl implements ImGroupService { | @@ -423,7 +433,7 @@ public class ImGroupServiceImpl implements ImGroupService { | ||
| 423 | pinned.add(messageId); | 433 | pinned.add(messageId); |
| 424 | groupMapper.updateById(new ImGroupDO().setId(groupId).setPinnedMessageIds(pinned)); | 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 | groupMessageService.sendGroupMessage(userId, | 437 | groupMessageService.sendGroupMessage(userId, |
| 428 | ImGroupMessageSendDTO.ofGroupMessagePin(groupId, userId, message)); | 438 | ImGroupMessageSendDTO.ofGroupMessagePin(groupId, userId, message)); |
| 429 | } | 439 | } |
| @@ -590,6 +600,7 @@ public class ImGroupServiceImpl implements ImGroupService { | @@ -590,6 +600,7 @@ public class ImGroupServiceImpl implements ImGroupService { | ||
| 590 | 600 | ||
| 591 | @Override | 601 | @Override |
| 592 | @CacheEvict(cacheNames = GROUP, key = "#reqVO.groupId") | 602 | @CacheEvict(cacheNames = GROUP, key = "#reqVO.groupId") |
| 603 | + @Transactional(rollbackFor = Exception.class) | ||
| 593 | public void muteAll(Long userId, ImGroupMuteAllReqVO reqVO) { | 604 | public void muteAll(Long userId, ImGroupMuteAllReqVO reqVO) { |
| 594 | // 1. 校验群主或管理员 | 605 | // 1. 校验群主或管理员 |
| 595 | validateGroupOwnerOrAdmin(reqVO.getGroupId(), userId); | 606 | validateGroupOwnerOrAdmin(reqVO.getGroupId(), userId); |
| @@ -605,6 +616,7 @@ public class ImGroupServiceImpl implements ImGroupService { | @@ -605,6 +616,7 @@ public class ImGroupServiceImpl implements ImGroupService { | ||
| 605 | } | 616 | } |
| 606 | 617 | ||
| 607 | @Override | 618 | @Override |
| 619 | + @Transactional(rollbackFor = Exception.class) | ||
| 608 | public void muteMember(Long userId, ImGroupMuteMemberReqVO reqVO) { | 620 | public void muteMember(Long userId, ImGroupMuteMemberReqVO reqVO) { |
| 609 | // 1.1 不能禁言自己 | 621 | // 1.1 不能禁言自己 |
| 610 | if (ObjUtil.equal(userId, reqVO.getUserId())) { | 622 | if (ObjUtil.equal(userId, reqVO.getUserId())) { |
| @@ -629,6 +641,7 @@ public class ImGroupServiceImpl implements ImGroupService { | @@ -629,6 +641,7 @@ public class ImGroupServiceImpl implements ImGroupService { | ||
| 629 | } | 641 | } |
| 630 | 642 | ||
| 631 | @Override | 643 | @Override |
| 644 | + @Transactional(rollbackFor = Exception.class) | ||
| 632 | public void cancelMuteMember(Long userId, ImGroupCancelMuteMemberReqVO reqVO) { | 645 | public void cancelMuteMember(Long userId, ImGroupCancelMuteMemberReqVO reqVO) { |
| 633 | // 1.1 校验群存在且未封禁 | 646 | // 1.1 校验群存在且未封禁 |
| 634 | validateGroupExists(reqVO.getGroupId()); | 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,7 +177,9 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | ||
| 177 | 177 | ||
| 178 | // 3. 发送撤回事件 | 178 | // 3. 发送撤回事件 |
| 179 | return sendGroupMessage(userId, new ImGroupMessageSendDTO().setGroupId(message.getGroupId()) | 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 | @Override | 185 | @Override |
| @@ -374,10 +376,17 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | @@ -374,10 +376,17 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | ||
| 374 | throw exception(MESSAGE_GROUP_READ_DISABLED); | 376 | throw exception(MESSAGE_GROUP_READ_DISABLED); |
| 375 | } | 377 | } |
| 376 | // 1.1 校验用户在群中(权限校验) | 378 | // 1.1 校验用户在群中(权限校验) |
| 377 | - groupMemberService.validateMemberInGroup(groupId, userId); | ||
| 378 | - // 1.2 获取消息 | 379 | + ImGroupMemberDO operator = groupMemberService.validateMemberInGroup(groupId, userId); |
| 380 | + // 1.2 获取消息;并校验消息归属于该群、对调用者可见、调用者是发送方 | ||
| 379 | ImGroupMessageDO message = groupMessageMapper.selectById(messageId); | 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 | return Collections.emptyList(); | 390 | return Collections.emptyList(); |
| 382 | } | 391 | } |
| 383 | 392 | ||
| @@ -405,8 +414,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | @@ -405,8 +414,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | ||
| 405 | ImGroupMemberDO member = groupMemberService.validateMemberInGroup(reqVO.getGroupId(), userId); | 414 | ImGroupMemberDO member = groupMemberService.validateMemberInGroup(reqVO.getGroupId(), userId); |
| 406 | 415 | ||
| 407 | // 2. 查询历史消息(仅入群之后) | 416 | // 2. 查询历史消息(仅入群之后) |
| 408 | - return groupMessageMapper.selectHistoryList( | 417 | + List<ImGroupMessageDO> messages = groupMessageMapper.selectHistoryList( |
| 409 | reqVO.getGroupId(), reqVO.getMaxId(), reqVO.getLimit(), member.getJoinTime()); | 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 | // ========== 异步 WebSocket 推送 ========== | 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,6 +250,8 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { | ||
| 250 | if (!ImConversationTypeEnum.isGroup(call.getConversationType())) { | 250 | if (!ImConversationTypeEnum.isGroup(call.getConversationType())) { |
| 251 | throw exception(RTC_GROUP_REQUIRED); | 251 | throw exception(RTC_GROUP_REQUIRED); |
| 252 | } | 252 | } |
| 253 | + // 1.3 校验当前用户是该群有效成员;防止仅凭 room 就拿到 LiveKit token 越权入会 | ||
| 254 | + groupMemberService.validateMemberInGroup(call.getGroupId(), userId); | ||
| 253 | 255 | ||
| 254 | // 2. 入参与表:已有记录切回 JOINED;不在记录则以 ACTIVE_JOIN 角色 INSERT | 256 | // 2. 入参与表:已有记录切回 JOINED;不在记录则以 ACTIVE_JOIN 角色 INSERT |
| 255 | LocalDateTime now = LocalDateTime.now(); | 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,13 +87,16 @@ public class ImSensitiveWordServiceImpl implements ImSensitiveWordService { | ||
| 87 | 87 | ||
| 88 | @Override | 88 | @Override |
| 89 | public ListenableFuture<SensitiveWordBsCache> reload(Long tenantId, SensitiveWordBsCache oldValue) { | 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,6 +112,24 @@ public class ImFriendRequestServiceImplTest extends BaseMockitoUnitTest { | ||
| 112 | } | 112 | } |
| 113 | 113 | ||
| 114 | @Test | 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 | public void testApplyFriend_insertNew() { | 133 | public void testApplyFriend_insertNew() { |
| 116 | // 准备:双方都无关系,且无历史申请 | 134 | // 准备:双方都无关系,且无历史申请 |
| 117 | ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO(); | 135 | ImFriendRequestApplyReqVO reqVO = new ImFriendRequestApplyReqVO(); |
yudao-module-im/src/test/resources/sql/clean.sql
| @@ -10,4 +10,5 @@ DELETE FROM "im_face_pack_item"; | @@ -10,4 +10,5 @@ DELETE FROM "im_face_pack_item"; | ||
| 10 | DELETE FROM "im_face_user_item"; | 10 | DELETE FROM "im_face_user_item"; |
| 11 | DELETE FROM "im_rtc_call"; | 11 | DELETE FROM "im_rtc_call"; |
| 12 | DELETE FROM "im_rtc_participant"; | 12 | DELETE FROM "im_rtc_participant"; |
| 13 | +DELETE FROM "im_sensitive_word"; | ||
| 13 | DELETE FROM "system_users"; | 14 | DELETE FROM "system_users"; |
yudao-module-im/src/test/resources/sql/create_tables.sql
| @@ -13,7 +13,8 @@ CREATE TABLE IF NOT EXISTS "im_private_message" ( | @@ -13,7 +13,8 @@ CREATE TABLE IF NOT EXISTS "im_private_message" ( | ||
| 13 | "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, | 13 | "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| 14 | "deleted" bit NOT NULL DEFAULT FALSE, | 14 | "deleted" bit NOT NULL DEFAULT FALSE, |
| 15 | "tenant_id" bigint NOT NULL DEFAULT 0, | 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 | ) COMMENT 'IM 私聊消息表'; | 18 | ) COMMENT 'IM 私聊消息表'; |
| 18 | 19 | ||
| 19 | CREATE TABLE IF NOT EXISTS "im_group_message" ( | 20 | CREATE TABLE IF NOT EXISTS "im_group_message" ( |
| @@ -34,7 +35,8 @@ CREATE TABLE IF NOT EXISTS "im_group_message" ( | @@ -34,7 +35,8 @@ CREATE TABLE IF NOT EXISTS "im_group_message" ( | ||
| 34 | "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, | 35 | "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| 35 | "deleted" bit NOT NULL DEFAULT FALSE, | 36 | "deleted" bit NOT NULL DEFAULT FALSE, |
| 36 | "tenant_id" bigint NOT NULL DEFAULT 0, | 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 | ) COMMENT 'IM 群聊消息表'; | 40 | ) COMMENT 'IM 群聊消息表'; |
| 39 | 41 | ||
| 40 | CREATE TABLE IF NOT EXISTS "im_group" ( | 42 | CREATE TABLE IF NOT EXISTS "im_group" ( |
| @@ -80,7 +82,8 @@ CREATE TABLE IF NOT EXISTS "im_group_member" ( | @@ -80,7 +82,8 @@ CREATE TABLE IF NOT EXISTS "im_group_member" ( | ||
| 80 | "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, | 82 | "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| 81 | "deleted" bit NOT NULL DEFAULT FALSE, | 83 | "deleted" bit NOT NULL DEFAULT FALSE, |
| 82 | "tenant_id" bigint NOT NULL DEFAULT 0, | 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 | ) COMMENT 'IM 群成员表'; | 87 | ) COMMENT 'IM 群成员表'; |
| 85 | 88 | ||
| 86 | CREATE TABLE IF NOT EXISTS "im_friend" ( | 89 | CREATE TABLE IF NOT EXISTS "im_friend" ( |
| @@ -287,6 +290,20 @@ CREATE TABLE IF NOT EXISTS "im_channel_message" ( | @@ -287,6 +290,20 @@ CREATE TABLE IF NOT EXISTS "im_channel_message" ( | ||
| 287 | PRIMARY KEY ("id") | 290 | PRIMARY KEY ("id") |
| 288 | ) COMMENT 'IM 频道消息表'; | 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 | CREATE TABLE IF NOT EXISTS "system_users" ( | 307 | CREATE TABLE IF NOT EXISTS "system_users" ( |
| 291 | "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, | 308 | "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, |
| 292 | "username" varchar(30) NOT NULL DEFAULT '', | 309 | "username" varchar(30) NOT NULL DEFAULT '', |