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 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
... ... @@ -10,4 +10,5 @@ DELETE FROM &quot;im_face_pack_item&quot;;
10 10 DELETE FROM "im_face_user_item";
11 11 DELETE FROM "im_rtc_call";
12 12 DELETE FROM "im_rtc_participant";
  13 +DELETE FROM "im_sensitive_word";
13 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 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 &quot;im_group_message&quot; (
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 &quot;im_group_member&quot; (
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 &quot;im_channel_message&quot; (
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 '',
... ...