From affc409022d066c4b53f8fcf33eb7690383bce04 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Tue, 21 Apr 2026 22:20:52 +0800 Subject: [PATCH] ✨ feat(friend): 新增 IM 好友管理功能,包括添加、删除和更新好友信息 --- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendController.java | 23 +++++++++++++---------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendRespVO.java | 43 +++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendUpdateReqVO.java | 19 +++++++++++++++++++ yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java | 30 +++++++++++++++++++++--------- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java | 165 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 6 files changed, 330 insertions(+), 23 deletions(-) create mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendRespVO.java create mode 100644 yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendUpdateReqVO.java diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendController.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendController.java index 2d87734..48fe16c 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendController.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendController.java @@ -1,13 +1,14 @@ package cn.iocoder.yudao.module.im.controller.admin.friend; import cn.iocoder.yudao.framework.common.pojo.CommonResult; -import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendRespVO; +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO; import cn.iocoder.yudao.module.im.service.friend.ImFriendService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -15,6 +16,7 @@ import org.springframework.web.bind.annotation.*; import java.util.List; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; @Tag(name = "管理后台 - IM 好友") @RestController @@ -28,16 +30,14 @@ public class ImFriendController { @GetMapping("/list") @Operation(summary = "获得当前登录用户的好友列表") public CommonResult> getMyFriendList() { - Long userId = SecurityFrameworkUtils.getLoginUserId(); - return success(friendService.getMyFriendList(userId)); + return success(friendService.getMyFriendList(getLoginUserId())); } @GetMapping("/get") @Operation(summary = "获得好友详情") @Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048") public CommonResult getFriend(@RequestParam("friendUserId") Long friendUserId) { - Long userId = SecurityFrameworkUtils.getLoginUserId(); - return success(friendService.getFriend(userId, friendUserId)); + return success(friendService.getFriend(getLoginUserId(), friendUserId)); } @PostMapping("/add") @@ -45,8 +45,7 @@ public class ImFriendController { @Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048") public CommonResult addFriend( @RequestParam("friendUserId") @NotNull(message = "好友用户编号不能为空") Long friendUserId) { - Long userId = SecurityFrameworkUtils.getLoginUserId(); - friendService.addFriend(userId, friendUserId); + friendService.addFriend(getLoginUserId(), friendUserId); return success(true); } @@ -55,11 +54,15 @@ public class ImFriendController { @Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048") public CommonResult deleteFriend( @RequestParam("friendUserId") @NotNull(message = "好友用户编号不能为空") Long friendUserId) { - Long userId = SecurityFrameworkUtils.getLoginUserId(); - friendService.deleteFriend(userId, friendUserId); + friendService.deleteFriend(getLoginUserId(), friendUserId); return success(true); } - // TODO @AI:增加一个 updateFriend 接口,目前暂时就 dnd(免打扰)功能,后续可以增加备注等功能 + @PutMapping("/update") + @Operation(summary = "更新好友信息(当前仅免打扰)") + public CommonResult updateFriend(@Valid @RequestBody ImFriendUpdateReqVO reqVO) { + friendService.updateFriend(getLoginUserId(), reqVO); + return success(true); + } } diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendRespVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendRespVO.java new file mode 100644 index 0000000..8e00c39 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendRespVO.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.im.controller.admin.friend.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * IM 好友 Response VO + * + * @author 芋道源码 + */ +@Schema(description = "管理后台 - IM 好友 Response VO") +@Data +public class ImFriendRespVO { + + @Schema(description = "关系记录编号", example = "1024") + private Long id; + + @Schema(description = "好友的用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + private Long friendUserId; + + @Schema(description = "是否免打扰", example = "false") + private Boolean muted; + + @Schema(description = "好友状态(0=正常,1=已删除)", example = "0") + private Integer status; + + @Schema(description = "添加好友时间") + private LocalDateTime addTime; + + @Schema(description = "删除好友时间") + private LocalDateTime deleteTime; + + // ========== 下面是聚合字段,方便前端显示 ========== + + @Schema(description = "好友昵称(实时聚合自 AdminUser)", example = "芋道") + private String nickname; + + @Schema(description = "好友头像(实时聚合自 AdminUser)") + private String avatar; + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendUpdateReqVO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendUpdateReqVO.java new file mode 100644 index 0000000..a44ae83 --- /dev/null +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendUpdateReqVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.im.controller.admin.friend.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - IM 好友更新 Request VO") +@Data +public class ImFriendUpdateReqVO { + + @Schema(description = "好友的用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + @NotNull(message = "好友用户编号不能为空") + private Long friendUserId; + + @Schema(description = "是否免打扰", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + @NotNull(message = "免打扰状态不能为空") + private Boolean muted; + +} diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java index 0057dcd..c61f0e1 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java @@ -1,18 +1,21 @@ package cn.iocoder.yudao.module.im.dal.dataobject.friend; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; import com.baomidou.mybatisplus.annotation.KeySequence; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.*; +import java.time.LocalDateTime; + /** * IM 好友关系 DO *

* 业务语义: * - 双向关系:A-B 互为好友会存 2 条记录(userId=A, friendUserId=B 和 userId=B, friendUserId=A) - * - 软删除:`deleted=1` 表示单向解除好友;如果需要"全部不看到",双向都要 deleted=1 - * - 免打扰:`muted` 控制是否屏蔽来自该好友的通知 + * - 状态管理:{@link #status} 使用 {@link CommonStatusEnum},ENABLE=正常,DISABLE=已删除 + * - 免打扰:{@link #muted} 控制是否屏蔽来自该好友的通知 * * @author 芋道源码 */ @@ -47,12 +50,21 @@ public class ImFriendDO extends BaseDO { * 是否免打扰 */ private Boolean muted; - - // TODO @AI:status 字段,枚举 CommonStatusEnum,表示正常 / 解散;如果是解散状态,则不允许任何操作了。 - // TODO @AI:addTime、deleteTime 字段,分别表示添加好友的时间和删除好友的时间;如果 deleteTime 不为 null,则表示已删除。不使用 deleted; - - // 说明: - // - 删除状态用 BaseDO 的 deleted 字段表达(0=正常,1=已删除) - // - 添加时间用 BaseDO 的 createTime;删除时间对应 updateTime + /** + * 好友状态 + *

+ * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + /** + * 添加好友时间 + */ + private LocalDateTime addTime; + /** + * 删除好友时间 + *

+ * 不为 null 时表示已删除 + */ + private LocalDateTime deleteTime; } diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java index 85b77dd..4a9cb0a 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java @@ -1,5 +1,11 @@ package cn.iocoder.yudao.module.im.service.friend; +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendRespVO; +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; + +import java.util.List; + /** * IM 好友关系 Service 接口 * @@ -9,12 +15,75 @@ public interface ImFriendService { /** * 校验两个用户是否是好友关系(未删除状态) + * + * @param userId 用户编号 + * @param friendUserId 好友用户编号 + */ + void validateFriendExists(Long userId, Long friendUserId); + + /** + * 获得当前用户的好友列表(含昵称/头像聚合) + *

+ * 返回的列表里**包含已删除状态**,前端按 status 区分; + * 原因是本地已经建立会话的好友被单向删除后,前端仍需要展示头像/昵称。 + * + * @param userId 用户编号 + * @return 好友列表(含聚合后的用户信息) + */ + List getMyFriendList(Long userId); + + /** + * 查询一个好友(仅限本人视角的好友关系) + *

+ * 只返回"本人→对方"的关系记录; + * 对方不是本人好友时抛 {@link cn.iocoder.yudao.module.im.enums.ErrorCodeConstants#FRIEND_NOT_FRIEND}。 + * + * @param userId 当前登录用户编号 + * @param friendUserId 目标用户编号 + * @return 好友信息(含聚合后的用户信息) + */ + ImFriendRespVO getFriend(Long userId, Long friendUserId); + + /** + * 添加好友(双向绑定) + *

+ * 核心流程: + * 1. 校验:不允许添加自己;对方必须存在 + * 2. 双向插入/更新关系(若已存在则刷新为未删除) + * 3. 推送"你们已成为好友"系统提示(TODO) + * + * @param userId 当前登录用户编号 + * @param friendUserId 好友的用户编号 + */ + void addFriend(Long userId, Long friendUserId); + + /** + * 删除好友(双向软删除) + *

+ * 两边关系都标记为已删除状态。 + * + * @param userId 当前登录用户编号 + * @param friendUserId 好友的用户编号 + */ + void deleteFriend(Long userId, Long friendUserId); + + /** + * 更新好友信息(仅更新本人→对方的单边关系属性) *

- * 如果不是好友,直接抛出业务异常 + * 目前支持:免打扰(muted);后续可扩展备注等字段。 + * + * @param userId 当前登录用户编号 + * @param reqVO 更新请求 + */ + void updateFriend(Long userId, ImFriendUpdateReqVO reqVO); + + /** + * 查询某个用户视角下的好友关系记录(仅单边) * * @param userId 用户编号 * @param friendUserId 好友用户编号 + * @return 关系记录,不存在返回 null */ - void validateIsFriend(Long userId, Long friendUserId); + ImFriendDO getFriendDO(Long userId, Long friendUserId); } diff --git a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java index 23a3345..fa0d9c4 100644 --- a/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java +++ b/yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java @@ -1,13 +1,26 @@ package cn.iocoder.yudao.module.im.service.friend; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendRespVO; +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO; import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendMapper; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; + import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_ADD_SELF; import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_FRIEND; /** @@ -22,12 +35,160 @@ public class ImFriendServiceImpl implements ImFriendService { @Resource private ImFriendMapper imFriendMapper; + @Resource + private AdminUserApi adminUserApi; + @Override - public void validateIsFriend(Long userId, Long friendUserId) { + public void validateFriendExists(Long userId, Long friendUserId) { ImFriendDO friend = imFriendMapper.selectByUserIdAndFriendUserId(userId, friendUserId); - if (friend == null || CommonStatusEnum.DISABLE.getStatus().equals(friend.getStatus())) { + if (friend == null || CommonStatusEnum.isDisable(friend.getStatus())) { + throw exception(FRIEND_NOT_FRIEND); + } + } + + // TODO @AI:这里只负责返回 List;VO 组装在 controller 搞; + // TODO @AI:方法改成 getEnabledFriendList 之类的,明确只返回未删除的好友;如果需要展示已删除的好友,提供一个 getAllFriendList 之类的方法。 + @Override + public List getMyFriendList(Long userId) { + List friends = imFriendMapper.selectListByUserId(userId); + if (friends.isEmpty()) { + return Collections.emptyList(); + } + // 批量聚合 AdminUser 信息(昵称 / 头像),避免 N+1 + List friendUserIds = CollectionUtils.convertList(friends, ImFriendDO::getFriendUserId); + Map userMap = adminUserApi.getUserMap(friendUserIds); + return CollectionUtils.convertList(friends, f -> buildRespVO(f, userMap.get(f.getFriendUserId()))); + } + + // TODO @AI:这里只负责返回 ImFriendDO; + @Override + public ImFriendRespVO getFriend(Long userId, Long friendUserId) { + ImFriendDO friend = imFriendMapper.selectByUserIdAndFriendUserId(userId, friendUserId); + if (friend == null) { + throw exception(FRIEND_NOT_FRIEND); + } + AdminUserRespDTO user = adminUserApi.getUser(friendUserId); + return buildRespVO(friend, user); + } + + @Override + public ImFriendDO getFriendDO(Long userId, Long friendUserId) { + return imFriendMapper.selectByUserIdAndFriendUserId(userId, friendUserId); + } + + @Override + public void updateFriend(Long userId, ImFriendUpdateReqVO reqVO) { + // 1. 校验好友关系存在 + ImFriendDO friend = imFriendMapper.selectByUserIdAndFriendUserId(userId, reqVO.getFriendUserId()); + if (friend == null) { throw exception(FRIEND_NOT_FRIEND); } + // 2. 更新好友属性(目前仅免打扰) + ImFriendDO updateObj = new ImFriendDO(); + updateObj.setId(friend.getId()); + updateObj.setMuted(reqVO.getMuted()); + imFriendMapper.updateById(updateObj); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void addFriend(Long userId, Long friendUserId) { + // 1.1 校验:不允许添加自己为好友 + if (Objects.equals(userId, friendUserId)) { + throw exception(FRIEND_ADD_SELF); + } + // 1.2 校验对方存在(禁用/不存在的账号不允许添加) + adminUserApi.validateUser(friendUserId); + + // 双向绑定 + // DONE @AI:改成 createOrUpdateFriend 之类的接口,内部自动处理"已删除的好友再加回来"的情况;或者直接在数据库层面通过唯一索引 + 物理更新来解决。 + // 说明:bindFriend 内部已处理 DISABLE → ENABLE 的恢复逻辑 + bindFriend(userId, friendUserId); + bindFriend(friendUserId, userId); + + // TODO @AI:推送异步化:1)好友的打招呼;2)通知好友发生变化的消息; + + // TODO @AI:实现如下: + // TODO @芋艿:【对齐】这里缺少了好友添加成功的系统提示消息, + // 需要通过 `ImPrivateMessageService` 插入一条 TIP_TEXT 类型的消息并走 WebSocket 推送给双方。 + // 涉及 WebSocket 推送器的事件类型扩展(当前 `ImWsEventType` 无 FRIEND_NEW),待统一后补上。 + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteFriend(Long userId, Long friendUserId) { + // 双向标记为 DISABLE + 记录 deleteTime + unbindFriend(userId, friendUserId); + unbindFriend(friendUserId, userId); + + // TODO @芋艿:【对齐】这里缺少了好友删除的系统提示 + FRIEND_DEL WebSocket 推送, + // 待 WS 事件类型扩展后补上。 + } + + /** + * 单向绑定好友关系: + * - 首次:新增记录(status=ENABLE,addTime=now) + * - 已存在且 ENABLE:无需重复操作 + * - 已存在但 DISABLE:恢复关系(status=ENABLE,刷新 addTime,清空 deleteTime) + */ + // TODO @AI:spring cache;参考别的模块的写法; + private void bindFriend(Long userId, Long friendUserId) { + ImFriendDO exists = imFriendMapper.selectByUserIdAndFriendUserId(userId, friendUserId); + if (exists != null) { + if (CommonStatusEnum.isEnable(exists.getStatus())) { + return; // 已是好友,跳过 + } + // 恢复已删除的好友关系 + ImFriendDO updateObj = new ImFriendDO(); + updateObj.setId(exists.getId()); + updateObj.setStatus(CommonStatusEnum.ENABLE.getStatus()); + updateObj.setAddTime(LocalDateTime.now()); + updateObj.setDeleteTime(null); + imFriendMapper.updateById(updateObj); + return; + } + // 新增 + ImFriendDO friend = ImFriendDO.builder() + .userId(userId) + .friendUserId(friendUserId) + .muted(false) + .status(CommonStatusEnum.ENABLE.getStatus()) + .addTime(LocalDateTime.now()) + .build(); + imFriendMapper.insert(friend); + } + + /** + * 单向解除好友关系(status 设为 DISABLE,记录 deleteTime) + */ + private void unbindFriend(Long userId, Long friendUserId) { + ImFriendDO exists = imFriendMapper.selectByUserIdAndFriendUserId(userId, friendUserId); + if (exists == null || CommonStatusEnum.isDisable(exists.getStatus())) { + return; + } + ImFriendDO updateObj = new ImFriendDO(); + updateObj.setId(exists.getId()); + updateObj.setStatus(CommonStatusEnum.DISABLE.getStatus()); + updateObj.setDeleteTime(LocalDateTime.now()); + imFriendMapper.updateById(updateObj); + } + + /** + * 聚合 ImFriendDO + AdminUser 构造响应 VO + */ + private ImFriendRespVO buildRespVO(ImFriendDO friend, AdminUserRespDTO user) { + ImFriendRespVO vo = new ImFriendRespVO(); + vo.setId(friend.getId()); + vo.setFriendUserId(friend.getFriendUserId()); + vo.setMuted(friend.getMuted()); + vo.setStatus(friend.getStatus()); + vo.setAddTime(friend.getAddTime()); + vo.setDeleteTime(friend.getDeleteTime()); + if (user != null) { + vo.setNickname(user.getNickname()); + vo.setAvatar(user.getAvatar()); + } + return vo; } } -- libgit2 0.21.4