Commit 106cf1c9f719457769e93e7ef93150dab4c7779d

Authored by YunaiV
1 parent f5b07508

✨ feat(im): 好友/群成员添加增加唯一索引并发兼容

- ImFriendServiceImpl#addFriend0 捕获 DuplicateKeyException
  并发插入冲突时忽略(另一方插入必然为 ENABLE 状态)
- ImGroupMemberServiceImpl#addGroupMember 改为先查后写:
  已存在 DISABLE 复活为 ENABLE,新记录 insert 失败降级 select 返回
- ImGroupMemberServiceImpl#addGroupMembers 批量 insert 触发
  DuplicateKey 时降级为逐个 addGroupMember 走幂等兜底
- 依赖 im_friend.uk_user_friend 与 im_group_member.uk_group_user 唯一索引
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java
@@ -158,9 +158,8 @@ public class ImFriendServiceImpl implements ImFriendService { @@ -158,9 +158,8 @@ public class ImFriendServiceImpl implements ImFriendService {
158 try { 158 try {
159 imFriendMapper.insert(friend); 159 imFriendMapper.insert(friend);
160 } catch (DuplicateKeyException e) { 160 } catch (DuplicateKeyException e) {
161 - // 并发场景:另一个请求已先一步插入,降级走已存在逻辑  
162 - log.warn("[addFriend0][userId({}) friendUserId({}) 并发插入冲突,降级处理]", userId, friendUserId);  
163 - addFriend0(userId, friendUserId); 161 + // 并发场景:另一个请求已先一步插入,且其插入的必然是 ENABLE 状态(DISABLE 场景在上方分支已处理),直接忽略
  162 + log.warn("[addFriend0][userId({}) friendUserId({}) 并发插入冲突,忽略]", userId, friendUserId);
164 } 163 }
165 } 164 }
166 165
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java
@@ -9,6 +9,8 @@ import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMemberMapper; @@ -9,6 +9,8 @@ import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMemberMapper;
9 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; 9 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
10 import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; 10 import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO;
11 import jakarta.annotation.Resource; 11 import jakarta.annotation.Resource;
  12 +import lombok.extern.slf4j.Slf4j;
  13 +import org.springframework.dao.DuplicateKeyException;
12 import org.springframework.stereotype.Service; 14 import org.springframework.stereotype.Service;
13 import org.springframework.validation.annotation.Validated; 15 import org.springframework.validation.annotation.Validated;
14 16
@@ -27,6 +29,7 @@ import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.GROUP_MEMBER_N @@ -27,6 +29,7 @@ import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.GROUP_MEMBER_N
27 * 29 *
28 * @author 芋道源码 30 * @author 芋道源码
29 */ 31 */
  32 +@Slf4j
30 @Service 33 @Service
31 @Validated 34 @Validated
32 public class ImGroupMemberServiceImpl implements ImGroupMemberService { 35 public class ImGroupMemberServiceImpl implements ImGroupMemberService {
@@ -62,13 +65,34 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { @@ -62,13 +65,34 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService {
62 return groupMemberMapper.selectQuitListByUserId(userId, minQuitTime); 65 return groupMemberMapper.selectQuitListByUserId(userId, minQuitTime);
63 } 66 }
64 67
  68 + /**
  69 + * 并发安全:依靠 im_group_member 表的唯一索引 uk_group_user(group_id, user_id) 保证幂等,
  70 + * 当并发 insert 触发 {@link DuplicateKeyException} 时降级为 select + update。
  71 + */
65 @Override 72 @Override
66 public ImGroupMemberDO addGroupMember(Long groupId, Long userId) { 73 public ImGroupMemberDO addGroupMember(Long groupId, Long userId) {
  74 + LocalDateTime now = LocalDateTime.now();
  75 + // 情况一:已存在记录 → 复活或跳过
  76 + ImGroupMemberDO exists = groupMemberMapper.selectByGroupIdAndUserId(groupId, userId);
  77 + if (exists != null) {
  78 + if (CommonStatusEnum.isDisable(exists.getStatus())) {
  79 + groupMemberMapper.updateById(new ImGroupMemberDO().setId(exists.getId())
  80 + .setStatus(CommonStatusEnum.ENABLE.getStatus()).setJoinTime(now));
  81 + }
  82 + return groupMemberMapper.selectById(exists.getId());
  83 + }
  84 + // 情况二:新增成员
67 ImGroupMemberDO member = new ImGroupMemberDO() 85 ImGroupMemberDO member = new ImGroupMemberDO()
68 .setGroupId(groupId).setUserId(userId) 86 .setGroupId(groupId).setUserId(userId)
69 - .setStatus(CommonStatusEnum.ENABLE.getStatus()).setJoinTime(LocalDateTime.now());  
70 - groupMemberMapper.insert(member);  
71 - return member; 87 + .setStatus(CommonStatusEnum.ENABLE.getStatus()).setJoinTime(now);
  88 + try {
  89 + groupMemberMapper.insert(member);
  90 + return member;
  91 + } catch (DuplicateKeyException e) {
  92 + // 并发场景:另一个请求已先一步插入,且其插入的必然是 ENABLE 状态(DISABLE 场景在上方分支已处理),查询返回
  93 + log.warn("[addGroupMember][groupId({}) userId({}) 并发插入冲突,查询返回]", groupId, userId);
  94 + return groupMemberMapper.selectByGroupIdAndUserId(groupId, userId);
  95 + }
72 } 96 }
73 97
74 @Override 98 @Override
@@ -91,13 +115,22 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { @@ -91,13 +115,22 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService {
91 } 115 }
92 } 116 }
93 117
94 - // 2. 执行  
95 - if (CollUtil.isNotEmpty(inserts)) {  
96 - groupMemberMapper.insertBatch(inserts);  
97 - } 118 + // 2.1 先做 update,update 没有并发冲突风险
98 if (CollUtil.isNotEmpty(updates)) { 119 if (CollUtil.isNotEmpty(updates)) {
99 groupMemberMapper.updateBatch(updates); 120 groupMemberMapper.updateBatch(updates);
100 } 121 }
  122 + // 2.2 批量 insert。并发场景下若其它请求已先一步插入同一 (groupId, userId),
  123 + // 会触发唯一索引冲突,此时降级为逐个 addGroupMember(利用其兜底逻辑幂等处理)。
  124 + if (CollUtil.isNotEmpty(inserts)) {
  125 + try {
  126 + groupMemberMapper.insertBatch(inserts);
  127 + } catch (DuplicateKeyException e) {
  128 + log.warn("[addGroupMembers][groupId({}) userIds({}) 批量插入冲突,降级为逐个处理]", groupId, userIds);
  129 + for (ImGroupMemberDO insert : inserts) {
  130 + addGroupMember(groupId, insert.getUserId());
  131 + }
  132 + }
  133 + }
101 } 134 }
102 135
103 @Override 136 @Override
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java
@@ -11,6 +11,7 @@ import org.springframework.scheduling.annotation.Async; @@ -11,6 +11,7 @@ import org.springframework.scheduling.annotation.Async;
11 import org.springframework.stereotype.Service; 11 import org.springframework.stereotype.Service;
12 import org.springframework.transaction.support.TransactionSynchronization; 12 import org.springframework.transaction.support.TransactionSynchronization;
13 import org.springframework.transaction.support.TransactionSynchronizationManager; 13 import org.springframework.transaction.support.TransactionSynchronizationManager;
  14 +import org.springframework.validation.annotation.Validated;
14 15
15 import java.util.Collection; 16 import java.util.Collection;
16 17
@@ -24,6 +25,7 @@ import java.util.Collection; @@ -24,6 +25,7 @@ import java.util.Collection;
24 * @author 芋道源码 25 * @author 芋道源码
25 */ 26 */
26 @Service 27 @Service
  28 +@Validated
27 @Slf4j 29 @Slf4j
28 public class ImWebSocketServiceImpl implements ImWebSocketService { 30 public class ImWebSocketServiceImpl implements ImWebSocketService {
29 31