Commit affc409022d066c4b53f8fcf33eb7690383bce04

Authored by YunaiV
1 parent 758dfcbb

✨ feat(friend): 新增 IM 好友管理功能,包括添加、删除和更新好友信息

实现了 IM 好友的增删改查功能,支持免打扰状态的更新。新增了相关的请求和响应对象,优化了好友列表的获取逻辑,并确保了好友关系的有效性校验。

- 新增 ImFriendRespVO 和 ImFriendUpdateReqVO 类
- 更新 ImFriendController,添加更新好友信息接口
- 实现 ImFriendService 中的好友管理逻辑
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendController.java
1 1 package cn.iocoder.yudao.module.im.controller.admin.friend;
2 2  
3 3 import cn.iocoder.yudao.framework.common.pojo.CommonResult;
4   -import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
5 4 import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendRespVO;
  5 +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO;
6 6 import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
7 7 import io.swagger.v3.oas.annotations.Operation;
8 8 import io.swagger.v3.oas.annotations.Parameter;
9 9 import io.swagger.v3.oas.annotations.tags.Tag;
10 10 import jakarta.annotation.Resource;
  11 +import jakarta.validation.Valid;
11 12 import jakarta.validation.constraints.NotNull;
12 13 import org.springframework.validation.annotation.Validated;
13 14 import org.springframework.web.bind.annotation.*;
... ... @@ -15,6 +16,7 @@ import org.springframework.web.bind.annotation.*;
15 16 import java.util.List;
16 17  
17 18 import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
  19 +import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId;
18 20  
19 21 @Tag(name = "管理后台 - IM 好友")
20 22 @RestController
... ... @@ -28,16 +30,14 @@ public class ImFriendController {
28 30 @GetMapping("/list")
29 31 @Operation(summary = "获得当前登录用户的好友列表")
30 32 public CommonResult<List<ImFriendRespVO>> getMyFriendList() {
31   - Long userId = SecurityFrameworkUtils.getLoginUserId();
32   - return success(friendService.getMyFriendList(userId));
  33 + return success(friendService.getMyFriendList(getLoginUserId()));
33 34 }
34 35  
35 36 @GetMapping("/get")
36 37 @Operation(summary = "获得好友详情")
37 38 @Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048")
38 39 public CommonResult<ImFriendRespVO> getFriend(@RequestParam("friendUserId") Long friendUserId) {
39   - Long userId = SecurityFrameworkUtils.getLoginUserId();
40   - return success(friendService.getFriend(userId, friendUserId));
  40 + return success(friendService.getFriend(getLoginUserId(), friendUserId));
41 41 }
42 42  
43 43 @PostMapping("/add")
... ... @@ -45,8 +45,7 @@ public class ImFriendController {
45 45 @Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048")
46 46 public CommonResult<Boolean> addFriend(
47 47 @RequestParam("friendUserId") @NotNull(message = "好友用户编号不能为空") Long friendUserId) {
48   - Long userId = SecurityFrameworkUtils.getLoginUserId();
49   - friendService.addFriend(userId, friendUserId);
  48 + friendService.addFriend(getLoginUserId(), friendUserId);
50 49 return success(true);
51 50 }
52 51  
... ... @@ -55,11 +54,15 @@ public class ImFriendController {
55 54 @Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048")
56 55 public CommonResult<Boolean> deleteFriend(
57 56 @RequestParam("friendUserId") @NotNull(message = "好友用户编号不能为空") Long friendUserId) {
58   - Long userId = SecurityFrameworkUtils.getLoginUserId();
59   - friendService.deleteFriend(userId, friendUserId);
  57 + friendService.deleteFriend(getLoginUserId(), friendUserId);
60 58 return success(true);
61 59 }
62 60  
63   - // TODO @AI:增加一个 updateFriend 接口,目前暂时就 dnd(免打扰)功能,后续可以增加备注等功能
  61 + @PutMapping("/update")
  62 + @Operation(summary = "更新好友信息(当前仅免打扰)")
  63 + public CommonResult<Boolean> updateFriend(@Valid @RequestBody ImFriendUpdateReqVO reqVO) {
  64 + friendService.updateFriend(getLoginUserId(), reqVO);
  65 + return success(true);
  66 + }
64 67  
65 68 }
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendRespVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.friend.vo;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import lombok.Data;
  5 +
  6 +import java.time.LocalDateTime;
  7 +
  8 +/**
  9 + * IM 好友 Response VO
  10 + *
  11 + * @author 芋道源码
  12 + */
  13 +@Schema(description = "管理后台 - IM 好友 Response VO")
  14 +@Data
  15 +public class ImFriendRespVO {
  16 +
  17 + @Schema(description = "关系记录编号", example = "1024")
  18 + private Long id;
  19 +
  20 + @Schema(description = "好友的用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048")
  21 + private Long friendUserId;
  22 +
  23 + @Schema(description = "是否免打扰", example = "false")
  24 + private Boolean muted;
  25 +
  26 + @Schema(description = "好友状态(0=正常,1=已删除)", example = "0")
  27 + private Integer status;
  28 +
  29 + @Schema(description = "添加好友时间")
  30 + private LocalDateTime addTime;
  31 +
  32 + @Schema(description = "删除好友时间")
  33 + private LocalDateTime deleteTime;
  34 +
  35 + // ========== 下面是聚合字段,方便前端显示 ==========
  36 +
  37 + @Schema(description = "好友昵称(实时聚合自 AdminUser)", example = "芋道")
  38 + private String nickname;
  39 +
  40 + @Schema(description = "好友头像(实时聚合自 AdminUser)")
  41 + private String avatar;
  42 +
  43 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendUpdateReqVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.friend.vo;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import jakarta.validation.constraints.NotNull;
  5 +import lombok.Data;
  6 +
  7 +@Schema(description = "管理后台 - IM 好友更新 Request VO")
  8 +@Data
  9 +public class ImFriendUpdateReqVO {
  10 +
  11 + @Schema(description = "好友的用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048")
  12 + @NotNull(message = "好友用户编号不能为空")
  13 + private Long friendUserId;
  14 +
  15 + @Schema(description = "是否免打扰", requiredMode = Schema.RequiredMode.REQUIRED, example = "true")
  16 + @NotNull(message = "免打扰状态不能为空")
  17 + private Boolean muted;
  18 +
  19 +}
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java
1 1 package cn.iocoder.yudao.module.im.dal.dataobject.friend;
2 2  
  3 +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
3 4 import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
4 5 import com.baomidou.mybatisplus.annotation.KeySequence;
5 6 import com.baomidou.mybatisplus.annotation.TableId;
6 7 import com.baomidou.mybatisplus.annotation.TableName;
7 8 import lombok.*;
8 9  
  10 +import java.time.LocalDateTime;
  11 +
9 12 /**
10 13 * IM 好友关系 DO
11 14 * <p>
12 15 * 业务语义:
13 16 * - 双向关系:A-B 互为好友会存 2 条记录(userId=A, friendUserId=B 和 userId=B, friendUserId=A)
14   - * - 软删除:`deleted=1` 表示单向解除好友;如果需要"全部不看到",双向都要 deleted=1
15   - * - 免打扰:`muted` 控制是否屏蔽来自该好友的通知
  17 + * - 状态管理:{@link #status} 使用 {@link CommonStatusEnum},ENABLE=正常,DISABLE=已删除
  18 + * - 免打扰:{@link #muted} 控制是否屏蔽来自该好友的通知
16 19 *
17 20 * @author 芋道源码
18 21 */
... ... @@ -47,12 +50,21 @@ public class ImFriendDO extends BaseDO {
47 50 * 是否免打扰
48 51 */
49 52 private Boolean muted;
50   -
51   - // TODO @AI:status 字段,枚举 CommonStatusEnum,表示正常 / 解散;如果是解散状态,则不允许任何操作了。
52   - // TODO @AI:addTime、deleteTime 字段,分别表示添加好友的时间和删除好友的时间;如果 deleteTime 不为 null,则表示已删除。不使用 deleted;
53   -
54   - // 说明:
55   - // - 删除状态用 BaseDO 的 deleted 字段表达(0=正常,1=已删除)
56   - // - 添加时间用 BaseDO 的 createTime;删除时间对应 updateTime
  53 + /**
  54 + * 好友状态
  55 + * <p>
  56 + * 枚举 {@link CommonStatusEnum}
  57 + */
  58 + private Integer status;
  59 + /**
  60 + * 添加好友时间
  61 + */
  62 + private LocalDateTime addTime;
  63 + /**
  64 + * 删除好友时间
  65 + * <p>
  66 + * 不为 null 时表示已删除
  67 + */
  68 + private LocalDateTime deleteTime;
57 69  
58 70 }
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java
1 1 package cn.iocoder.yudao.module.im.service.friend;
2 2  
  3 +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendRespVO;
  4 +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO;
  5 +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO;
  6 +
  7 +import java.util.List;
  8 +
3 9 /**
4 10 * IM 好友关系 Service 接口
5 11 *
... ... @@ -9,12 +15,75 @@ public interface ImFriendService {
9 15  
10 16 /**
11 17 * 校验两个用户是否是好友关系(未删除状态)
  18 + *
  19 + * @param userId 用户编号
  20 + * @param friendUserId 好友用户编号
  21 + */
  22 + void validateFriendExists(Long userId, Long friendUserId);
  23 +
  24 + /**
  25 + * 获得当前用户的好友列表(含昵称/头像聚合)
  26 + * <p>
  27 + * 返回的列表里**包含已删除状态**,前端按 status 区分;
  28 + * 原因是本地已经建立会话的好友被单向删除后,前端仍需要展示头像/昵称。
  29 + *
  30 + * @param userId 用户编号
  31 + * @return 好友列表(含聚合后的用户信息)
  32 + */
  33 + List<ImFriendRespVO> getMyFriendList(Long userId);
  34 +
  35 + /**
  36 + * 查询一个好友(仅限本人视角的好友关系)
  37 + * <p>
  38 + * 只返回"本人→对方"的关系记录;
  39 + * 对方不是本人好友时抛 {@link cn.iocoder.yudao.module.im.enums.ErrorCodeConstants#FRIEND_NOT_FRIEND}。
  40 + *
  41 + * @param userId 当前登录用户编号
  42 + * @param friendUserId 目标用户编号
  43 + * @return 好友信息(含聚合后的用户信息)
  44 + */
  45 + ImFriendRespVO getFriend(Long userId, Long friendUserId);
  46 +
  47 + /**
  48 + * 添加好友(双向绑定)
  49 + * <p>
  50 + * 核心流程:
  51 + * 1. 校验:不允许添加自己;对方必须存在
  52 + * 2. 双向插入/更新关系(若已存在则刷新为未删除)
  53 + * 3. 推送"你们已成为好友"系统提示(TODO)
  54 + *
  55 + * @param userId 当前登录用户编号
  56 + * @param friendUserId 好友的用户编号
  57 + */
  58 + void addFriend(Long userId, Long friendUserId);
  59 +
  60 + /**
  61 + * 删除好友(双向软删除)
  62 + * <p>
  63 + * 两边关系都标记为已删除状态。
  64 + *
  65 + * @param userId 当前登录用户编号
  66 + * @param friendUserId 好友的用户编号
  67 + */
  68 + void deleteFriend(Long userId, Long friendUserId);
  69 +
  70 + /**
  71 + * 更新好友信息(仅更新本人→对方的单边关系属性)
12 72 * <p>
13   - * 如果不是好友,直接抛出业务异常
  73 + * 目前支持:免打扰(muted);后续可扩展备注等字段。
  74 + *
  75 + * @param userId 当前登录用户编号
  76 + * @param reqVO 更新请求
  77 + */
  78 + void updateFriend(Long userId, ImFriendUpdateReqVO reqVO);
  79 +
  80 + /**
  81 + * 查询某个用户视角下的好友关系记录(仅单边)
14 82 *
15 83 * @param userId 用户编号
16 84 * @param friendUserId 好友用户编号
  85 + * @return 关系记录,不存在返回 null
17 86 */
18   - void validateIsFriend(Long userId, Long friendUserId);
  87 + ImFriendDO getFriendDO(Long userId, Long friendUserId);
19 88  
20 89 }
... ...
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java
1 1 package cn.iocoder.yudao.module.im.service.friend;
2 2  
3 3 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
  4 +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
  5 +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendRespVO;
  6 +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO;
4 7 import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO;
5 8 import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendMapper;
  9 +import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
  10 +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
6 11 import jakarta.annotation.Resource;
7 12 import org.springframework.stereotype.Service;
  13 +import org.springframework.transaction.annotation.Transactional;
8 14 import org.springframework.validation.annotation.Validated;
9 15  
  16 +import java.time.LocalDateTime;
  17 +import java.util.Collections;
  18 +import java.util.List;
  19 +import java.util.Map;
  20 +import java.util.Objects;
  21 +
10 22 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  23 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_ADD_SELF;
11 24 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_FRIEND;
12 25  
13 26 /**
... ... @@ -22,12 +35,160 @@ public class ImFriendServiceImpl implements ImFriendService {
22 35 @Resource
23 36 private ImFriendMapper imFriendMapper;
24 37  
  38 + @Resource
  39 + private AdminUserApi adminUserApi;
  40 +
25 41 @Override
26   - public void validateIsFriend(Long userId, Long friendUserId) {
  42 + public void validateFriendExists(Long userId, Long friendUserId) {
27 43 ImFriendDO friend = imFriendMapper.selectByUserIdAndFriendUserId(userId, friendUserId);
28   - if (friend == null || CommonStatusEnum.DISABLE.getStatus().equals(friend.getStatus())) {
  44 + if (friend == null || CommonStatusEnum.isDisable(friend.getStatus())) {
  45 + throw exception(FRIEND_NOT_FRIEND);
  46 + }
  47 + }
  48 +
  49 + // TODO @AI:这里只负责返回 List<ImFriendDO>;VO 组装在 controller 搞;
  50 + // TODO @AI:方法改成 getEnabledFriendList 之类的,明确只返回未删除的好友;如果需要展示已删除的好友,提供一个 getAllFriendList 之类的方法。
  51 + @Override
  52 + public List<ImFriendRespVO> getMyFriendList(Long userId) {
  53 + List<ImFriendDO> friends = imFriendMapper.selectListByUserId(userId);
  54 + if (friends.isEmpty()) {
  55 + return Collections.emptyList();
  56 + }
  57 + // 批量聚合 AdminUser 信息(昵称 / 头像),避免 N+1
  58 + List<Long> friendUserIds = CollectionUtils.convertList(friends, ImFriendDO::getFriendUserId);
  59 + Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(friendUserIds);
  60 + return CollectionUtils.convertList(friends, f -> buildRespVO(f, userMap.get(f.getFriendUserId())));
  61 + }
  62 +
  63 + // TODO @AI:这里只负责返回 ImFriendDO;
  64 + @Override
  65 + public ImFriendRespVO getFriend(Long userId, Long friendUserId) {
  66 + ImFriendDO friend = imFriendMapper.selectByUserIdAndFriendUserId(userId, friendUserId);
  67 + if (friend == null) {
  68 + throw exception(FRIEND_NOT_FRIEND);
  69 + }
  70 + AdminUserRespDTO user = adminUserApi.getUser(friendUserId);
  71 + return buildRespVO(friend, user);
  72 + }
  73 +
  74 + @Override
  75 + public ImFriendDO getFriendDO(Long userId, Long friendUserId) {
  76 + return imFriendMapper.selectByUserIdAndFriendUserId(userId, friendUserId);
  77 + }
  78 +
  79 + @Override
  80 + public void updateFriend(Long userId, ImFriendUpdateReqVO reqVO) {
  81 + // 1. 校验好友关系存在
  82 + ImFriendDO friend = imFriendMapper.selectByUserIdAndFriendUserId(userId, reqVO.getFriendUserId());
  83 + if (friend == null) {
29 84 throw exception(FRIEND_NOT_FRIEND);
30 85 }
  86 + // 2. 更新好友属性(目前仅免打扰)
  87 + ImFriendDO updateObj = new ImFriendDO();
  88 + updateObj.setId(friend.getId());
  89 + updateObj.setMuted(reqVO.getMuted());
  90 + imFriendMapper.updateById(updateObj);
  91 + }
  92 +
  93 + @Override
  94 + @Transactional(rollbackFor = Exception.class)
  95 + public void addFriend(Long userId, Long friendUserId) {
  96 + // 1.1 校验:不允许添加自己为好友
  97 + if (Objects.equals(userId, friendUserId)) {
  98 + throw exception(FRIEND_ADD_SELF);
  99 + }
  100 + // 1.2 校验对方存在(禁用/不存在的账号不允许添加)
  101 + adminUserApi.validateUser(friendUserId);
  102 +
  103 + // 双向绑定
  104 + // DONE @AI:改成 createOrUpdateFriend 之类的接口,内部自动处理"已删除的好友再加回来"的情况;或者直接在数据库层面通过唯一索引 + 物理更新来解决。
  105 + // 说明:bindFriend 内部已处理 DISABLE → ENABLE 的恢复逻辑
  106 + bindFriend(userId, friendUserId);
  107 + bindFriend(friendUserId, userId);
  108 +
  109 + // TODO @AI:推送异步化:1)好友的打招呼;2)通知好友发生变化的消息;
  110 +
  111 + // TODO @AI:实现如下:
  112 + // TODO @芋艿:【对齐】这里缺少了好友添加成功的系统提示消息,
  113 + // 需要通过 `ImPrivateMessageService` 插入一条 TIP_TEXT 类型的消息并走 WebSocket 推送给双方。
  114 + // 涉及 WebSocket 推送器的事件类型扩展(当前 `ImWsEventType` 无 FRIEND_NEW),待统一后补上。
  115 + }
  116 +
  117 + @Override
  118 + @Transactional(rollbackFor = Exception.class)
  119 + public void deleteFriend(Long userId, Long friendUserId) {
  120 + // 双向标记为 DISABLE + 记录 deleteTime
  121 + unbindFriend(userId, friendUserId);
  122 + unbindFriend(friendUserId, userId);
  123 +
  124 + // TODO @芋艿:【对齐】这里缺少了好友删除的系统提示 + FRIEND_DEL WebSocket 推送,
  125 + // 待 WS 事件类型扩展后补上。
  126 + }
  127 +
  128 + /**
  129 + * 单向绑定好友关系:
  130 + * - 首次:新增记录(status=ENABLE,addTime=now)
  131 + * - 已存在且 ENABLE:无需重复操作
  132 + * - 已存在但 DISABLE:恢复关系(status=ENABLE,刷新 addTime,清空 deleteTime)
  133 + */
  134 + // TODO @AI:spring cache;参考别的模块的写法;
  135 + private void bindFriend(Long userId, Long friendUserId) {
  136 + ImFriendDO exists = imFriendMapper.selectByUserIdAndFriendUserId(userId, friendUserId);
  137 + if (exists != null) {
  138 + if (CommonStatusEnum.isEnable(exists.getStatus())) {
  139 + return; // 已是好友,跳过
  140 + }
  141 + // 恢复已删除的好友关系
  142 + ImFriendDO updateObj = new ImFriendDO();
  143 + updateObj.setId(exists.getId());
  144 + updateObj.setStatus(CommonStatusEnum.ENABLE.getStatus());
  145 + updateObj.setAddTime(LocalDateTime.now());
  146 + updateObj.setDeleteTime(null);
  147 + imFriendMapper.updateById(updateObj);
  148 + return;
  149 + }
  150 + // 新增
  151 + ImFriendDO friend = ImFriendDO.builder()
  152 + .userId(userId)
  153 + .friendUserId(friendUserId)
  154 + .muted(false)
  155 + .status(CommonStatusEnum.ENABLE.getStatus())
  156 + .addTime(LocalDateTime.now())
  157 + .build();
  158 + imFriendMapper.insert(friend);
  159 + }
  160 +
  161 + /**
  162 + * 单向解除好友关系(status 设为 DISABLE,记录 deleteTime)
  163 + */
  164 + private void unbindFriend(Long userId, Long friendUserId) {
  165 + ImFriendDO exists = imFriendMapper.selectByUserIdAndFriendUserId(userId, friendUserId);
  166 + if (exists == null || CommonStatusEnum.isDisable(exists.getStatus())) {
  167 + return;
  168 + }
  169 + ImFriendDO updateObj = new ImFriendDO();
  170 + updateObj.setId(exists.getId());
  171 + updateObj.setStatus(CommonStatusEnum.DISABLE.getStatus());
  172 + updateObj.setDeleteTime(LocalDateTime.now());
  173 + imFriendMapper.updateById(updateObj);
  174 + }
  175 +
  176 + /**
  177 + * 聚合 ImFriendDO + AdminUser 构造响应 VO
  178 + */
  179 + private ImFriendRespVO buildRespVO(ImFriendDO friend, AdminUserRespDTO user) {
  180 + ImFriendRespVO vo = new ImFriendRespVO();
  181 + vo.setId(friend.getId());
  182 + vo.setFriendUserId(friend.getFriendUserId());
  183 + vo.setMuted(friend.getMuted());
  184 + vo.setStatus(friend.getStatus());
  185 + vo.setAddTime(friend.getAddTime());
  186 + vo.setDeleteTime(friend.getDeleteTime());
  187 + if (user != null) {
  188 + vo.setNickname(user.getNickname());
  189 + vo.setAvatar(user.getAvatar());
  190 + }
  191 + return vo;
31 192 }
32 193  
33 194 }
... ...