Commit 9720f208654f13f4e6278503874905ba003030ff
1 parent
ee01a16f
✨ feat(im):增加好友删除时,增加是否删除本地聊天的选项
Showing
9 changed files
with
79 additions
and
22 deletions
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendController.java
| ... | ... | @@ -13,6 +13,7 @@ import cn.iocoder.yudao.module.system.api.user.AdminUserApi; |
| 13 | 13 | import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; |
| 14 | 14 | import io.swagger.v3.oas.annotations.Operation; |
| 15 | 15 | import io.swagger.v3.oas.annotations.Parameter; |
| 16 | +import io.swagger.v3.oas.annotations.Parameters; | |
| 16 | 17 | import io.swagger.v3.oas.annotations.tags.Tag; |
| 17 | 18 | import jakarta.annotation.Resource; |
| 18 | 19 | import jakarta.validation.Valid; |
| ... | ... | @@ -45,6 +46,7 @@ public class ImFriendController { |
| 45 | 46 | @GetMapping("/list") |
| 46 | 47 | @Operation(summary = "获得当前登录用户的好友列表") |
| 47 | 48 | public CommonResult<List<ImFriendRespVO>> getMyFriendList() { |
| 49 | + // 含 DISABLE 历史好友:保留给前端展示「已删除好友」的历史对话信息;前端按 status 决定会话级联清理 | |
| 48 | 50 | List<ImFriendDO> friends = friendService.getFriendList(getLoginUserId()); |
| 49 | 51 | return success(buildFriendRespVOList(friends)); |
| 50 | 52 | } |
| ... | ... | @@ -59,10 +61,14 @@ public class ImFriendController { |
| 59 | 61 | |
| 60 | 62 | @DeleteMapping("/delete") |
| 61 | 63 | @Operation(summary = "删除好友(单向软删除)") |
| 62 | - @Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048") | |
| 64 | + @Parameters({ | |
| 65 | + @Parameter(description = "好友的用户编号", required = true, example = "2048"), | |
| 66 | + @Parameter(description = "是否级联清理本端相关数据(如私聊会话)") | |
| 67 | + }) | |
| 63 | 68 | public CommonResult<Boolean> deleteFriend( |
| 64 | - @RequestParam("friendUserId") @NotNull(message = "好友用户编号不能为空") Long friendUserId) { | |
| 65 | - friendService.deleteFriend(getLoginUserId(), friendUserId); | |
| 69 | + @RequestParam("friendUserId") @NotNull(message = "好友用户编号不能为空") Long friendUserId, | |
| 70 | + @RequestParam(value = "clear", required = false) Boolean clear) { | |
| 71 | + friendService.deleteFriend(getLoginUserId(), friendUserId, clear); | |
| 66 | 72 | return success(true); |
| 67 | 73 | } |
| 68 | 74 | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ImCommonConstants.java
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestServiceImpl.java
| ... | ... | @@ -86,12 +86,19 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService { |
| 86 | 86 | return null; |
| 87 | 87 | } |
| 88 | 88 | |
| 89 | - // 2. 落库:复用最新一条未处理记录;否则新建 | |
| 89 | + // 2. 落库:复用最新一条未处理记录走条件 update;否则新建 | |
| 90 | 90 | ImFriendRequestDO request = friendRequestMapper.selectLatestByFromUserIdAndToUserId(fromUserId, toUserId); |
| 91 | 91 | if (request != null && ImFriendRequestHandleResultEnum.isUnhandled(request.getHandleResult())) { |
| 92 | - // 复用未处理记录:刷新申请理由 / 备注 / 来源;createTime 维持首次申请时间不变(与列表 id DESC 排序一致) | |
| 92 | + ImFriendRequestDO updateObj = new ImFriendRequestDO().setApplyContent(reqVO.getApplyContent()) | |
| 93 | + .setDisplayName(reqVO.getDisplayName()).setAddSource(reqVO.getAddSource()); | |
| 94 | + int affected = friendRequestMapper.updateByIdAndHandleResult(request.getId(), | |
| 95 | + ImFriendRequestHandleResultEnum.UNHANDLED.getResult(), updateObj); | |
| 96 | + if (affected == 0) { | |
| 97 | + // 并发场景下另一方刚 agree/refuse 推进了状态,条件 update affected=0 抛已处理错让前端重试 | |
| 98 | + throw exception(FRIEND_REQUEST_HANDLED); | |
| 99 | + } | |
| 100 | + // 同步更新字段,下面推送 payload 用 | |
| 93 | 101 | BeanUtil.copyProperties(reqVO, request, CopyOptions.create().setIgnoreNullValue(true)); |
| 94 | - friendRequestMapper.updateById(request); | |
| 95 | 102 | } else { |
| 96 | 103 | request = BeanUtils.toBean(reqVO, ImFriendRequestDO.class) |
| 97 | 104 | .setFromUserId(fromUserId).setToUserId(toUserId) |
| ... | ... | @@ -103,9 +110,10 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService { |
| 103 | 110 | AdminUserRespDTO fromUser = adminUserApi.getUser(fromUserId); |
| 104 | 111 | FriendRequestNotification payload = (FriendRequestNotification) new FriendRequestNotification() |
| 105 | 112 | .setRequestId(request.getId()).setApplyContent(request.getApplyContent()).setAddSource(request.getAddSource()) |
| 106 | - .setFromNickname(fromUser != null ? fromUser.getNickname() : null) | |
| 107 | - .setFromAvatar(fromUser != null ? fromUser.getAvatar() : null) | |
| 108 | 113 | .setOperatorUserId(fromUserId).setFriendUserId(fromUserId); |
| 114 | + if (fromUser != null) { | |
| 115 | + payload.setFromNickname(fromUser.getNickname()).setFromAvatar(fromUser.getAvatar()); | |
| 116 | + } | |
| 109 | 117 | websocketService.sendPrivateMessageAsync(toUserId, ImPrivateMessageDTO.ofFriendNotification( |
| 110 | 118 | ImMessageTypeEnum.FRIEND_REQUEST_RECEIVED.getType(), fromUserId, toUserId, payload)); |
| 111 | 119 | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java
| ... | ... | @@ -79,8 +79,10 @@ public interface ImFriendService { |
| 79 | 79 | * 删除好友(单向软删除) |
| 80 | 80 | * <p> |
| 81 | 81 | * 仅删除 userId 视角下的好友关系;对端 friendUserId 的视角不受影响(单边删除语义) |
| 82 | + * | |
| 83 | + * @param clear 是否级联清理本端相关数据(当前包含私聊会话;通过 FRIEND_DELETE 通知透传给多端) | |
| 82 | 84 | */ |
| 83 | - void deleteFriend(Long userId, Long friendUserId); | |
| 85 | + void deleteFriend(Long userId, Long friendUserId, Boolean clear); | |
| 84 | 86 | |
| 85 | 87 | /** |
| 86 | 88 | * 更新好友单边属性(备注 / 免打扰 / 联系人置顶) | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java
| ... | ... | @@ -33,6 +33,7 @@ import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.FRIEND_STAT |
| 33 | 33 | import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_BLOCKED; |
| 34 | 34 | import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_FRIEND; |
| 35 | 35 | import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.FRIEND_ADD_TIP_MESSAGE; |
| 36 | +import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.FRIEND_DELETE_TIP_MESSAGE; | |
| 36 | 37 | |
| 37 | 38 | /** |
| 38 | 39 | * IM 好友关系 Service 实现类 |
| ... | ... | @@ -171,14 +172,17 @@ public class ImFriendServiceImpl implements ImFriendService { |
| 171 | 172 | @CacheEvict(cacheNames = FRIEND_STATE, key = "#friendUserId + '_' + #userId") |
| 172 | 173 | }) |
| 173 | 174 | @Transactional(rollbackFor = Exception.class) |
| 174 | - public void deleteFriend(Long userId, Long friendUserId) { | |
| 175 | + public void deleteFriend(Long userId, Long friendUserId, Boolean clear) { | |
| 175 | 176 | // 1. 单边软删:仅 userId 视角的关系置 DISABLE;friendUserId 视角不动 |
| 176 | - // 不推 TIP 系统消息:单边删除语义下对方不应感知;userId 端反馈由前端 toast 承担,多端同步靠下面的 FRIEND_DELETE | |
| 177 | 177 | deleteFriend0(userId, friendUserId); |
| 178 | 178 | |
| 179 | - // 2. 推 FRIEND_DELETE 给 userId 多端做同步(friendUserId 不感知) | |
| 180 | - FriendDeleteNotification payload = (FriendDeleteNotification) new FriendDeleteNotification() | |
| 181 | - .setOperatorUserId(userId).setFriendUserId(friendUserId); | |
| 179 | + // 2. 推 TIP「你已删除好友」走单边语义(persistent=false): | |
| 180 | + // 不入库 + 仅推 userId 多端,对方完全不感知;clear=true 时前端会清对话连带这条 TIP 一起清 | |
| 181 | + privateMessageService.sendTipPrivateMessage(userId, friendUserId, FRIEND_DELETE_TIP_MESSAGE, false); | |
| 182 | + | |
| 183 | + // 3. 推 FRIEND_DELETE 给 userId 多端做同步(friendUserId 不感知);clear 透传让多端清理动作一致 | |
| 184 | + FriendDeleteNotification payload = ((FriendDeleteNotification) new FriendDeleteNotification() | |
| 185 | + .setOperatorUserId(userId).setFriendUserId(friendUserId)).setClear(clear); | |
| 182 | 186 | websocketService.sendPrivateMessageAsync(userId, ImPrivateMessageDTO.ofFriendNotification( |
| 183 | 187 | ImMessageTypeEnum.FRIEND_DELETE.getType(), userId, userId, payload)); |
| 184 | 188 | } |
| ... | ... | @@ -237,7 +241,7 @@ public class ImFriendServiceImpl implements ImFriendService { |
| 237 | 241 | |
| 238 | 242 | /** |
| 239 | 243 | * 单向绑定好友关系(内部方法,被 {@link #becomeFriends} / {@link #silentReAddFriend} 调用): |
| 240 | - * - 情况一:已存在记录(含 ENABLE / DISABLE)→ 复用并恢复 ENABLE,重置 muted / pinned / blocked 为 false(删好友再加回来不沿袭旧设置) | |
| 244 | + * - 情况一:已存在记录(含 ENABLE / DISABLE)→ 复用并恢复 ENABLE,muted / pinned / blocked 一并重置为 false | |
| 241 | 245 | * - 情况二:不存在记录 → 直接插入新记录 |
| 242 | 246 | * <p> |
| 243 | 247 | * 并发安全:agree 路径由 {@code friend_request.handle_result} 的乐观锁单边推进; | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java
| ... | ... | @@ -47,6 +47,16 @@ public interface ImPrivateMessageService { |
| 47 | 47 | void sendTipPrivateMessage(Long senderId, Long receiverId, String content); |
| 48 | 48 | |
| 49 | 49 | /** |
| 50 | + * 【系统调用】发送系统提示消息(TIP_TEXT),可覆盖 persistent | |
| 51 | + * | |
| 52 | + * @param senderId 发送人编号 | |
| 53 | + * @param receiverId 接收人编号 | |
| 54 | + * @param content 提示文本 | |
| 55 | + * @param persistent null 走默认;false 单边(不入库 + 仅推 sender 多端,对方不感知);true 强制双向 + 入库 | |
| 56 | + */ | |
| 57 | + void sendTipPrivateMessage(Long senderId, Long receiverId, String content, Boolean persistent); | |
| 58 | + | |
| 59 | + /** | |
| 50 | 60 | * 【用户调用】撤回私聊消息 |
| 51 | 61 | * |
| 52 | 62 | * @param userId 当前用户编号 | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java
| ... | ... | @@ -100,27 +100,37 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { |
| 100 | 100 | String contentString = payload == null || payload instanceof String |
| 101 | 101 | ? (String) payload |
| 102 | 102 | : JsonUtils.toJsonString(payload); |
| 103 | - // 1.2 构建并保存消息 | |
| 103 | + // 1.2 构建消息 | |
| 104 | 104 | ImPrivateMessageDO message = new ImPrivateMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) |
| 105 | 105 | .setSenderId(senderId).setReceiverId(dto.getReceiverId()) |
| 106 | 106 | .setType(dto.getType()).setContent(contentString) |
| 107 | 107 | .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()); |
| 108 | - // 1.3 按 type.persistent 决定是否入库 | |
| 109 | - if (ImMessageTypeEnum.validate(dto.getType()).isPersistent()) { | |
| 108 | + // 1.3 决定是否持久化:dto.persistent 优先;为 null 时按 type 默认 | |
| 109 | + boolean persistent = dto.getPersistent() != null | |
| 110 | + ? dto.getPersistent() | |
| 111 | + : ImMessageTypeEnum.validate(dto.getType()).isPersistent(); | |
| 112 | + if (persistent) { | |
| 110 | 113 | privateMessageMapper.insert(message); |
| 111 | 114 | } |
| 112 | 115 | |
| 113 | - // 2. WebSocket 异步推送(接收方 + 发送方多端同步) | |
| 116 | + // 2. WebSocket 异步推送:双向(默认);单边语义(persistent=false)下仅推 sender 多端,对方不感知 | |
| 114 | 117 | ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message); |
| 115 | - imWebSocketService.sendPrivateMessageAsync(dto.getReceiverId(), websocketMessage); | |
| 118 | + if (persistent) { | |
| 119 | + imWebSocketService.sendPrivateMessageAsync(dto.getReceiverId(), websocketMessage); | |
| 120 | + } | |
| 116 | 121 | imWebSocketService.sendPrivateMessageAsync(senderId, websocketMessage); |
| 117 | 122 | return message; |
| 118 | 123 | } |
| 119 | 124 | |
| 120 | 125 | @Override |
| 121 | 126 | public void sendTipPrivateMessage(Long senderId, Long receiverId, String content) { |
| 127 | + sendTipPrivateMessage(senderId, receiverId, content, null); | |
| 128 | + } | |
| 129 | + | |
| 130 | + @Override | |
| 131 | + public void sendTipPrivateMessage(Long senderId, Long receiverId, String content, Boolean persistent) { | |
| 122 | 132 | sendPrivateMessage(senderId, new ImPrivateMessageSendDTO().setReceiverId(receiverId) |
| 123 | - .setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(content)); | |
| 133 | + .setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(content).setPersistent(persistent)); | |
| 124 | 134 | } |
| 125 | 135 | |
| 126 | 136 | @Override | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImPrivateMessageSendDTO.java
| ... | ... | @@ -27,5 +27,13 @@ public class ImPrivateMessageSendDTO { |
| 27 | 27 | * 支持 String / POJO;非 String 时由 service 序列化为 JSON |
| 28 | 28 | */ |
| 29 | 29 | private Object content; |
| 30 | + /** | |
| 31 | + * 是否持久化 + 推送给接收方(单边语义开关) | |
| 32 | + * <p> | |
| 33 | + * null:默认按 {@link ImMessageTypeEnum#isPersistent()} 决定是否入库 + 双向 WS 推送(保持原行为)<br> | |
| 34 | + * false:覆盖为单边——不入库、仅推 sender 多端,对方不感知(如「你已删除 XXX」这类仅自己可见的 TIP)<br> | |
| 35 | + * true:覆盖为双向 + 入库 | |
| 36 | + */ | |
| 37 | + private Boolean persistent; | |
| 30 | 38 | |
| 31 | 39 | } | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendDeleteNotification.java
| ... | ... | @@ -5,9 +5,14 @@ import lombok.Data; |
| 5 | 5 | /** |
| 6 | 6 | * 好友删除通知 |
| 7 | 7 | * <p> |
| 8 | - * 仅推送给操作人多端做同步(对端不感知,与单边删除语义对齐);前端清除本地好友 + 级联清理私聊会话 | |
| 8 | + * 仅推送给操作人多端做同步(对端不感知,与单边删除语义对齐);前端清除本地好友 + 按 clear 决定级联清理 | |
| 9 | 9 | */ |
| 10 | 10 | @Data |
| 11 | 11 | public class FriendDeleteNotification extends BaseFriendNotification { |
| 12 | 12 | |
| 13 | + /** | |
| 14 | + * 是否级联清理本端相关数据(当前包含私聊会话;未来可能扩展更多 clear 项) | |
| 15 | + */ | |
| 16 | + private Boolean clear; | |
| 17 | + | |
| 13 | 18 | } | ... | ... |