2026-06-30 24681c81c09022f584a57006f2534b5f74723414
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
package cn.iocoder.yudao.module.im.service.friend;
 
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO;
import cn.iocoder.yudao.module.im.controller.admin.manager.friend.vo.ImFriendManagerPageReqVO;
import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO;
import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO;
import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendMapper;
import cn.iocoder.yudao.module.im.enums.friend.ImFriendStateEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
import cn.iocoder.yudao.module.im.service.message.ImPrivateMessageService;
import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO;
import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
import cn.iocoder.yudao.module.im.service.websocket.notification.friend.*;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
 
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Set;
 
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.filterList;
import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.FRIEND_STATE;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_BLOCKED_BY_PEER;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_BLOCKED;
import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_FRIEND;
 
/**
 * IM 好友关系 Service 实现类
 *
 * @author 芋道源码
 */
@Slf4j
@Service
@Validated
public class ImFriendServiceImpl implements ImFriendService {
 
    @Resource
    private ImFriendMapper friendMapper;
 
    @Resource
    private ImWebSocketService websocketService;
    @Resource
    @Lazy
    private ImPrivateMessageService privateMessageService;
 
    @Override
    @Cacheable(cacheNames = FRIEND_STATE, key = "#userId + '_' + #friendUserId", unless = "#result == null")
    public Integer getFriendState(Long userId, Long friendUserId) {
        // 1.1 我侧记录:我方删了,都算非好友
        ImFriendDO mine = friendMapper.selectByUserIdAndFriendUserId(userId, friendUserId);
        if (mine == null || !CommonStatusEnum.isEnable(mine.getStatus())) {
            return ImFriendStateEnum.NONE.getState();
        }
        // 1.2 对方侧记录:对方删了 = 不是好友
        ImFriendDO peer = friendMapper.selectByUserIdAndFriendUserId(friendUserId, userId);
        if (peer == null || !CommonStatusEnum.isEnable(peer.getStatus())) {
            return ImFriendStateEnum.NONE.getState();
        }
        // 2. 仅当双方都是 ENABLE 状态,才算好友关系;此时对方拉黑我,则是 BLOCKED
        return BooleanUtil.isTrue(peer.getBlocked()) ? ImFriendStateEnum.BLOCKED.getState() : ImFriendStateEnum.FRIEND.getState();
    }
 
    @Override
    public void validateFriend(Long userId, Long peerUserId) {
        // 好友 / 黑名单校验:和私聊消息发送同一套语义;NONE 已删 / 未加,BLOCKED 被对方拉黑
        Integer state = getSelf().getFriendState(userId, peerUserId);
        if (ImFriendStateEnum.isNone(state)) {
            throw exception(FRIEND_NOT_FRIEND);
        }
        if (ImFriendStateEnum.isBlocked(state)) {
            throw exception(FRIEND_BLOCKED_BY_PEER);
        }
    }
 
    private ImFriendService getSelf() {
        return SpringUtil.getBean(getClass());
    }
 
    @Override
    public List<ImFriendDO> getFriendList(Long userId) {
        return friendMapper.selectListByUserId(userId);
    }
 
    @Override
    public List<ImFriendDO> pullFriendList(Long userId, Long lastUpdateTime, Long lastId, Integer limit) {
        return friendMapper.selectPullListByUserId(userId, lastUpdateTime, lastId, limit);
    }
 
    @Override
    public List<ImFriendDO> getEnableFriendList(Long userId) {
        return friendMapper.selectListByUserIdAndStatus(userId, CommonStatusEnum.ENABLE.getStatus());
    }
 
    @Override
    public List<ImFriendDO> getMutualEnableFriendList(Long userId) {
        // 1. 查询本端启用好友
        List<ImFriendDO> friends = getEnableFriendList(userId);
        if (CollUtil.isEmpty(friends)) {
            return Collections.emptyList();
        }
 
        // 2. 查询对端启用好友关系
        Set<Long> friendUserIds = convertSet(friends, ImFriendDO::getFriendUserId);
        List<ImFriendDO> mutualFriends = friendMapper.selectListByUserIdsAndFriendUserIdAndStatus(friendUserIds, userId,
                CommonStatusEnum.ENABLE.getStatus());
        Set<Long> mutualUserIds = convertSet(mutualFriends, ImFriendDO::getUserId);
 
        // 3. 过滤双向启用好友
        return filterList(friends, friend -> mutualUserIds.contains(friend.getFriendUserId()));
    }
 
    @Override
    public List<ImFriendDO> getActiveFriendList(Long userId, Collection<Long> friendUserIds) {
        if (CollUtil.isEmpty(friendUserIds)) {
            return Collections.emptyList();
        }
        return friendMapper.selectListByUserIdAndFriendUserIdsAndStatus(userId, friendUserIds,
                CommonStatusEnum.ENABLE.getStatus());
    }
 
    @Override
    public ImFriendDO getFriend(Long userId, Long friendUserId) {
        return friendMapper.selectByUserIdAndFriendUserId(userId, friendUserId);
    }
 
    @Override
    public void updateFriend(Long userId, ImFriendUpdateReqVO reqVO) {
        // 1.1 校验:至少改一个字段(无字段变更,直接结束)
        if (reqVO.getDisplayName() == null && reqVO.getSilent() == null && reqVO.getPinned() == null) {
            return;
        }
        // 1.2 校验好友关系启用
        ImFriendDO friend = friendMapper.selectByUserIdAndFriendUserId(userId, reqVO.getFriendUserId());
        if (friend == null || !CommonStatusEnum.isEnable(friend.getStatus())) {
            throw exception(FRIEND_NOT_FRIEND);
        }
 
        // 2. 更新好友属性(备注 / 免打扰 / 联系人置顶)
        friendMapper.updateById(new ImFriendDO().setId(friend.getId())
                .setSilent(reqVO.getSilent()).setDisplayName(reqVO.getDisplayName()).setPinned(reqVO.getPinned()));
 
        // 3. 推 FRIEND_UPDATE 给 A 多端:所有单边属性变更合并为单条通知,避免多通知顺序竞争
        FriendUpdateNotification payload = (FriendUpdateNotification) new FriendUpdateNotification()
                .setDisplayName(reqVO.getDisplayName()).setSilent(reqVO.getSilent()).setPinned(reqVO.getPinned())
                .setOperatorUserId(userId).setFriendUserId(reqVO.getFriendUserId());
        websocketService.sendNotificationAsync(userId, ImConversationTypeEnum.NONE.getType(),
                ImContentTypeEnum.FRIEND_UPDATE.getType(), payload);
    }
 
    @Override
    @Caching(evict = {
            @CacheEvict(cacheNames = FRIEND_STATE, key = "#request.fromUserId + '_' + #request.toUserId"),
            @CacheEvict(cacheNames = FRIEND_STATE, key = "#request.toUserId + '_' + #request.fromUserId")
    })
    @Transactional(rollbackFor = Exception.class)
    public void becomeFriends(ImFriendRequestDO request) {
        Long fromUserId = request.getFromUserId();
        Long toUserId = request.getToUserId();
        // 1. 双向建立关系:A 侧带申请的 displayName / addSource;B 侧 displayName 为空、addSource 同来源
        //    FRIEND_STATE 双向失效由方法上的 @Caching 注解处理;framework 已开 transactionAware 自动延迟到 afterCommit
        addFriend0(fromUserId, toUserId, request.getDisplayName(), request.getAddSource());
        addFriend0(toUserId, fromUserId, null, request.getAddSource());
 
        // 2. 发送 FRIEND_ADD 入库(双方拉历史都能看到「你们已成为好友」会话气泡)+ 双向 WebSocket 自动覆盖双方多端
        //    operatorUserId=fromUserId 标记申请发起方;前端按 (currentUserId === operatorUserId) 区分视角,文案固定不依赖此字段
        FriendAddNotification payload = (FriendAddNotification) new FriendAddNotification()
                .setOperatorUserId(fromUserId).setFriendUserId(toUserId);
        privateMessageService.sendPrivateMessage(fromUserId, new ImPrivateMessageSendDTO()
                .setReceiverId(toUserId).setType(ImContentTypeEnum.FRIEND_ADD.getType()).setContent(payload));
    }
 
    @Override
    @Caching(evict = {
            @CacheEvict(cacheNames = FRIEND_STATE, key = "#userId + '_' + #friendUserId"),
            @CacheEvict(cacheNames = FRIEND_STATE, key = "#friendUserId + '_' + #userId")
    })
    @Transactional(rollbackFor = Exception.class)
    public void silentReAddFriend(Long userId, Long friendUserId, String displayName, Integer addSource) {
        // 1. 单边重新启用我侧好友关系
        addFriend0(userId, friendUserId, displayName, addSource);
 
        // 2. 走 sendPrivateMessage + persistent=false:不入库 + 仅推 userId 多端(对方完全不感知,保持「对方一直把我当好友」错觉)
        //    operatorUserId 填 friendUserId(对方):让 userId 多端 UI 呈现「对方加了我」视角,与 silent 语义对齐
        //    前端按 type=FRIEND_ADD 渲染会话气泡(瞬时,不入库刷新即消失)
        FriendAddNotification payload = (FriendAddNotification) new FriendAddNotification()
                .setOperatorUserId(friendUserId).setFriendUserId(friendUserId);
        privateMessageService.sendPrivateMessage(userId, new ImPrivateMessageSendDTO()
                .setReceiverId(friendUserId).setType(ImContentTypeEnum.FRIEND_ADD.getType())
                .setContent(payload).setPersistent(false));
    }
 
    @Override
    @Caching(evict = {
            @CacheEvict(cacheNames = FRIEND_STATE, key = "#userId + '_' + #friendUserId"),
            @CacheEvict(cacheNames = FRIEND_STATE, key = "#friendUserId + '_' + #userId")
    })
    @Transactional(rollbackFor = Exception.class)
    public void deleteFriend(Long userId, Long friendUserId, Boolean clear) {
        // 1. 单边软删:仅 userId 视角的关系置 DISABLE;friendUserId 视角不动
        if (!deleteFriend0(userId, friendUserId)) {
            return;
        }
 
        // 2. 走 sendPrivateMessage + persistent=false:不入库 + 仅推 userId 多端(friendUserId 不感知);clear 透传让多端清理动作一致
        //    clear=false 时前端按 type=FRIEND_DELETE 渲染「你已删除好友」会话气泡(瞬时);
        //    clear=true 时前端按 clear 字段直接清会话,跳过气泡渲染
        FriendDeleteNotification payload = ((FriendDeleteNotification) new FriendDeleteNotification()
                .setOperatorUserId(userId).setFriendUserId(friendUserId)).setClear(clear);
        privateMessageService.sendPrivateMessage(userId, new ImPrivateMessageSendDTO()
                .setReceiverId(friendUserId).setType(ImContentTypeEnum.FRIEND_DELETE.getType())
                .setContent(payload).setPersistent(false));
    }
 
    @Override
    @Caching(evict = {
            @CacheEvict(cacheNames = FRIEND_STATE, key = "#userId + '_' + #friendUserId"),
            @CacheEvict(cacheNames = FRIEND_STATE, key = "#friendUserId + '_' + #userId")
    })
    public void blockFriend(Long userId, Long friendUserId) {
        // 1.1 校验是好友
        ImFriendDO friend = friendMapper.selectByUserIdAndFriendUserId(userId, friendUserId);
        if (friend == null || !CommonStatusEnum.isEnable(friend.getStatus())) {
            throw exception(FRIEND_NOT_FRIEND);
        }
        // 1.2 已拉黑直接返回,幂等
        if (BooleanUtil.isTrue(friend.getBlocked())) {
            return;
        }
 
        // 2. 单边更新
        friendMapper.updateById(new ImFriendDO().setId(friend.getId()).setBlocked(true));
 
        // 3. 推 FRIEND_BLOCK 给 A 多端
        FriendBlockNotification payload = (FriendBlockNotification) new FriendBlockNotification()
                .setOperatorUserId(userId).setFriendUserId(friendUserId);
        websocketService.sendNotificationAsync(userId, ImConversationTypeEnum.NONE.getType(),
                ImContentTypeEnum.FRIEND_BLOCK.getType(), payload);
    }
 
    @Override
    @Caching(evict = {
            @CacheEvict(cacheNames = FRIEND_STATE, key = "#userId + '_' + #friendUserId"),
            @CacheEvict(cacheNames = FRIEND_STATE, key = "#friendUserId + '_' + #userId")
    })
    public void unblockFriend(Long userId, Long friendUserId) {
        // 1.1 校验是好友
        ImFriendDO friend = friendMapper.selectByUserIdAndFriendUserId(userId, friendUserId);
        if (friend == null || !CommonStatusEnum.isEnable(friend.getStatus())) {
            throw exception(FRIEND_NOT_FRIEND);
        }
        // 1.2 未拉黑则报错
        if (!BooleanUtil.isTrue(friend.getBlocked())) {
            throw exception(FRIEND_NOT_BLOCKED);
        }
 
        // 2. 单边更新
        friendMapper.updateById(new ImFriendDO().setId(friend.getId()).setBlocked(false));
 
        // 3. 推 FRIEND_UNBLOCK 给 A 多端
        FriendUnblockNotification payload = (FriendUnblockNotification) new FriendUnblockNotification()
                .setOperatorUserId(userId).setFriendUserId(friendUserId);
        websocketService.sendNotificationAsync(userId, ImConversationTypeEnum.NONE.getType(),
                ImContentTypeEnum.FRIEND_UNBLOCK.getType(), payload);
    }
 
    /**
     * 单向绑定好友关系(内部方法,被 {@link #becomeFriends} / {@link #silentReAddFriend} 调用):
     * - 情况一:已存在 ENABLE 记录 → 已是好友,幂等跳过;不重置 silent / pinned / blocked,避免历史未处理申请再被同意时清掉用户的拉黑 / 置顶 / 免打扰设置
     * - 情况二:已存在 DISABLE 记录 → 复用并恢复 ENABLE,silent / pinned / blocked 一并重置为 false,对齐"重新加好友"语义
     * - 情况三:不存在记录 → 直接插入新记录
     * <p>
     * 并发安全:agree 路径由 {@code friend_request.handle_result} 的乐观锁单边推进;
     *         极端并发下若插入唯一键冲突,让 DuplicateKeyException 向外抛出,外层事务回滚。
     * <p>
     * FRIEND_STATE 缓存失效由调用方的 @Caching 注解统一处理,本方法不主动 evict
     */
    public void addFriend0(Long userId, Long friendUserId, String displayName, Integer addSource) {
        ImFriendDO exists = friendMapper.selectByUserIdAndFriendUserId(userId, friendUserId);
        // 情况一:已是 ENABLE 好友,幂等跳过;防御历史未处理申请被二次同意时把 blocked / silent / pinned 清回 false
        if (exists != null && CommonStatusEnum.isEnable(exists.getStatus())) {
            return;
        }
        // 情况二:复用 DISABLE 旧记录 → 恢复 ENABLE + 重置 silent / pinned / blocked,对齐"重新加好友"语义
        if (exists != null) {
            LocalDateTime now = LocalDateTime.now();
            friendMapper.updateReAddFields(exists.getId(), CommonStatusEnum.ENABLE.getStatus(), now, now,
                    false, false, false, displayName, addSource);
            return;
        }
        // 情况三:不存在记录 → 直接插入新记录
        ImFriendDO friend = ImFriendDO.builder().userId(userId).friendUserId(friendUserId)
                .silent(false).pinned(false).blocked(false)
                .displayName(displayName).addSource(addSource)
                .status(CommonStatusEnum.ENABLE.getStatus()).addTime(LocalDateTime.now()).build();
        friendMapper.insert(friend);
    }
 
    /**
     * 单向解除好友关系(status 设为 DISABLE,记录 deleteTime)
     * <p>
     * blocked 不主动重置:删好友期间保留拉黑状态;如果未来再 addFriend0,由 addFriend0 统一重置
     * <p>
     * FRIEND_STATE 缓存失效由调用方的 @Caching 注解统一处理,本方法不主动 evict
     */
    public boolean deleteFriend0(Long userId, Long friendUserId) {
        ImFriendDO exists = friendMapper.selectByUserIdAndFriendUserId(userId, friendUserId);
        if (exists == null || CommonStatusEnum.isDisable(exists.getStatus())) {
            return false;
        }
        friendMapper.updateById(new ImFriendDO().setId(exists.getId())
                .setStatus(CommonStatusEnum.DISABLE.getStatus()).setDeleteTime(LocalDateTime.now()));
        return true;
    }
 
    // ==================== 管理后台 ====================
 
    @Override
    public PageResult<ImFriendDO> getFriendPage(ImFriendManagerPageReqVO reqVO) {
        return friendMapper.selectPage(reqVO);
    }
 
}