Commit 513c130151dff23a270a2d05efd6dd7f617ce2e6

Authored by YunaiV
1 parent f0e46399

fix(im):加固群聊与好友部分流程

- 拦截对方已拉黑时的静默恢复好友关系
- 入群申请通过前复核群状态和申请人成员状态
- 群消息撤回保留定向可见范围
- 过滤群历史消息和已读用户查询中的定向消息
- 敏感词缓存刷新补充租户上下文
- 补充 IM 测试表约束和好友申请回归用例
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 &quot;im_face_pack_item&quot;; @@ -10,4 +10,5 @@ DELETE FROM &quot;im_face_pack_item&quot;;
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 &quot;im_private_message&quot; ( @@ -13,7 +13,8 @@ CREATE TABLE IF NOT EXISTS &quot;im_private_message&quot; (
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 &quot;im_group_message&quot; ( @@ -34,7 +35,8 @@ CREATE TABLE IF NOT EXISTS &quot;im_group_message&quot; (
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 &quot;im_group_member&quot; ( @@ -80,7 +82,8 @@ CREATE TABLE IF NOT EXISTS &quot;im_group_member&quot; (
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 &quot;im_channel_message&quot; ( @@ -287,6 +290,20 @@ CREATE TABLE IF NOT EXISTS &quot;im_channel_message&quot; (
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 '',