Commit 479af56a08cbb982d2de38dc467f1cf0dc0ac53a

Authored by YunaiV
1 parent bfd374ee

✨ feat(im): 增加好友申请的逻辑(v1)

Showing 51 changed files with 1248 additions and 199 deletions
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendController.java
@@ -57,17 +57,8 @@ public class ImFriendController { @@ -57,17 +57,8 @@ public class ImFriendController {
57 return success(buildFriendRespVO(friend)); 57 return success(buildFriendRespVO(friend));
58 } 58 }
59 59
60 - @PostMapping("/add")  
61 - @Operation(summary = "添加好友(双向建立关系)")  
62 - @Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048")  
63 - public CommonResult<Boolean> addFriend(  
64 - @RequestParam("friendUserId") @NotNull(message = "好友用户编号不能为空") Long friendUserId) {  
65 - friendService.addFriend(getLoginUserId(), friendUserId);  
66 - return success(true);  
67 - }  
68 -  
69 @DeleteMapping("/delete") 60 @DeleteMapping("/delete")
70 - @Operation(summary = "删除好友(双向软删除)") 61 + @Operation(summary = "删除好友(单向软删除)")
71 @Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048") 62 @Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048")
72 public CommonResult<Boolean> deleteFriend( 63 public CommonResult<Boolean> deleteFriend(
73 @RequestParam("friendUserId") @NotNull(message = "好友用户编号不能为空") Long friendUserId) { 64 @RequestParam("friendUserId") @NotNull(message = "好友用户编号不能为空") Long friendUserId) {
@@ -76,12 +67,30 @@ public class ImFriendController { @@ -76,12 +67,30 @@ public class ImFriendController {
76 } 67 }
77 68
78 @PutMapping("/update") 69 @PutMapping("/update")
79 - @Operation(summary = "更新好友信息") 70 + @Operation(summary = "更新好友单边属性(备注 / 免打扰 / 联系人置顶)")
80 public CommonResult<Boolean> updateFriend(@Valid @RequestBody ImFriendUpdateReqVO reqVO) { 71 public CommonResult<Boolean> updateFriend(@Valid @RequestBody ImFriendUpdateReqVO reqVO) {
81 friendService.updateFriend(getLoginUserId(), reqVO); 72 friendService.updateFriend(getLoginUserId(), reqVO);
82 return success(true); 73 return success(true);
83 } 74 }
84 75
  76 + @PutMapping("/block")
  77 + @Operation(summary = "拉黑好友(必须先是好友;单边屏蔽对方私聊消息)")
  78 + @Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048")
  79 + public CommonResult<Boolean> blockFriend(
  80 + @RequestParam("friendUserId") @NotNull(message = "好友用户编号不能为空") Long friendUserId) {
  81 + friendService.blockFriend(getLoginUserId(), friendUserId);
  82 + return success(true);
  83 + }
  84 +
  85 + @PutMapping("/unblock")
  86 + @Operation(summary = "移出黑名单")
  87 + @Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048")
  88 + public CommonResult<Boolean> unblockFriend(
  89 + @RequestParam("friendUserId") @NotNull(message = "好友用户编号不能为空") Long friendUserId) {
  90 + friendService.unblockFriend(getLoginUserId(), friendUserId);
  91 + return success(true);
  92 + }
  93 +
85 // ========== 私有方法:VO 组装 ========== 94 // ========== 私有方法:VO 组装 ==========
86 95
87 private List<ImFriendRespVO> buildFriendRespVOList(Collection<ImFriendDO> friends) { 96 private List<ImFriendRespVO> buildFriendRespVOList(Collection<ImFriendDO> friends) {
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendRequestController.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.friend;
  2 +
  3 +import cn.hutool.core.collection.CollUtil;
  4 +import cn.iocoder.yudao.framework.common.pojo.CommonResult;
  5 +import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
  6 +import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
  7 +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.request.ImFriendRequestApplyReqVO;
  8 +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.request.ImFriendRequestRespVO;
  9 +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO;
  10 +import cn.iocoder.yudao.module.im.service.friend.ImFriendRequestService;
  11 +import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
  12 +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
  13 +import io.swagger.v3.oas.annotations.Operation;
  14 +import io.swagger.v3.oas.annotations.Parameter;
  15 +import io.swagger.v3.oas.annotations.tags.Tag;
  16 +import jakarta.annotation.Resource;
  17 +import jakarta.validation.Valid;
  18 +import jakarta.validation.constraints.NotNull;
  19 +import jakarta.validation.constraints.Size;
  20 +import org.springframework.validation.annotation.Validated;
  21 +import org.springframework.web.bind.annotation.*;
  22 +
  23 +import java.util.Collections;
  24 +import java.util.List;
  25 +import java.util.Map;
  26 +import java.util.Set;
  27 +import java.util.stream.Stream;
  28 +
  29 +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
  30 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
  31 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSetByFlatMap;
  32 +import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId;
  33 +
  34 +/**
  35 + * IM 好友申请记录 Controller
  36 + *
  37 + * @author 芋道源码
  38 + */
  39 +@Tag(name = "管理后台 - IM 好友申请")
  40 +@RestController
  41 +@RequestMapping("/im/friend-request")
  42 +@Validated
  43 +public class ImFriendRequestController {
  44 +
  45 + @Resource
  46 + private ImFriendRequestService friendRequestService;
  47 +
  48 + @Resource
  49 + private AdminUserApi adminUserApi;
  50 +
  51 + @PostMapping("/apply")
  52 + @Operation(summary = "发起好友申请")
  53 + public CommonResult<Long> applyFriend(@Valid @RequestBody ImFriendRequestApplyReqVO reqVO) {
  54 + ImFriendRequestDO request = friendRequestService.applyFriend(getLoginUserId(), reqVO);
  55 + return success(request != null ? request.getId() : null);
  56 + }
  57 +
  58 + @PutMapping("/agree")
  59 + @Operation(summary = "同意好友申请")
  60 + @Parameter(name = "id", description = "申请编号", required = true, example = "1024")
  61 + public CommonResult<Boolean> agreeFriendRequest(
  62 + @RequestParam("id") @NotNull(message = "申请编号不能为空") Long id) {
  63 + friendRequestService.agreeFriendRequest(getLoginUserId(), id);
  64 + return success(true);
  65 + }
  66 +
  67 + @PutMapping("/refuse")
  68 + @Operation(summary = "拒绝好友申请")
  69 + public CommonResult<Boolean> refuseFriendRequest(
  70 + @RequestParam("id") @NotNull(message = "申请编号不能为空") Long id,
  71 + @RequestParam(value = "handleContent", required = false)
  72 + @Size(max = 255, message = "处理理由最多 255 个字符") String handleContent) {
  73 + friendRequestService.refuseFriendRequest(getLoginUserId(), id, handleContent);
  74 + return success(true);
  75 + }
  76 +
  77 + @GetMapping("/list")
  78 + @Operation(summary = "查询「我相关」的好友申请列表(含我发起的、别人加我的)")
  79 + public CommonResult<List<ImFriendRequestRespVO>> getMyFriendRequestList() {
  80 + List<ImFriendRequestDO> list = friendRequestService.getMyFriendRequestList(getLoginUserId());
  81 + return success(buildList(list));
  82 + }
  83 +
  84 + // ========== 私有方法:VO 组装 ==========
  85 +
  86 + private List<ImFriendRequestRespVO> buildList(List<ImFriendRequestDO> list) {
  87 + if (CollUtil.isEmpty(list)) {
  88 + return Collections.emptyList();
  89 + }
  90 + Set<Long> userIds = convertSetByFlatMap(list, r -> Stream.of(r.getFromUserId(), r.getToUserId()));
  91 + Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(userIds);
  92 + return convertList(list, r -> {
  93 + ImFriendRequestRespVO vo = BeanUtils.toBean(r, ImFriendRequestRespVO.class);
  94 + MapUtils.findAndThen(userMap, r.getFromUserId(), user ->
  95 + vo.setFromNickname(user.getNickname()).setFromAvatar(user.getAvatar()));
  96 + MapUtils.findAndThen(userMap, r.getToUserId(), user ->
  97 + vo.setToNickname(user.getNickname()).setToAvatar(user.getAvatar()));
  98 + return vo;
  99 + });
  100 + }
  101 +
  102 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendRespVO.java
@@ -29,6 +29,15 @@ public class ImFriendRespVO { @@ -29,6 +29,15 @@ public class ImFriendRespVO {
29 @Schema(description = "好友展示备注的拼音(小写无空格)", example = "laozhang") 29 @Schema(description = "好友展示备注的拼音(小写无空格)", example = "laozhang")
30 private String displayNamePinyin; 30 private String displayNamePinyin;
31 31
  32 + @Schema(description = "添加来源", example = "1")
  33 + private Integer addSource; // 参见 ImFriendAddSourceEnum 枚举
  34 +
  35 + @Schema(description = "是否置顶联系人", example = "false")
  36 + private Boolean pinned;
  37 +
  38 + @Schema(description = "是否拉黑(仅自己可见)", example = "false")
  39 + private Boolean blocked;
  40 +
32 @Schema(description = "好友状态", example = "0") 41 @Schema(description = "好友状态", example = "0")
33 private Integer status; 42 private Integer status;
34 43
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/ImFriendUpdateReqVO.java
@@ -20,4 +20,7 @@ public class ImFriendUpdateReqVO { @@ -20,4 +20,7 @@ public class ImFriendUpdateReqVO {
20 @Size(max = 16, message = "好友备注最多 16 个字符") 20 @Size(max = 16, message = "好友备注最多 16 个字符")
21 private String displayName; 21 private String displayName;
22 22
  23 + @Schema(description = "是否置顶联系人;不传表示不修改", example = "true")
  24 + private Boolean pinned;
  25 +
23 } 26 }
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/request/ImFriendRequestApplyReqVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.friend.vo.request;
  2 +
  3 +import cn.iocoder.yudao.framework.common.validation.InEnum;
  4 +import cn.iocoder.yudao.module.im.enums.friend.ImFriendAddSourceEnum;
  5 +import io.swagger.v3.oas.annotations.media.Schema;
  6 +import jakarta.validation.constraints.NotNull;
  7 +import jakarta.validation.constraints.Size;
  8 +import lombok.Data;
  9 +
  10 +/**
  11 + * IM 好友申请 - 发起 Request VO
  12 + *
  13 + * @author 芋道源码
  14 + */
  15 +@Schema(description = "管理后台 - IM 好友申请发起 Request VO")
  16 +@Data
  17 +public class ImFriendRequestApplyReqVO {
  18 +
  19 + @Schema(description = "接收方用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048")
  20 + @NotNull(message = "接收方用户编号不能为空")
  21 + private Long toUserId;
  22 +
  23 + @Schema(description = "申请理由", example = "我是芋艿(一种食材)")
  24 + @Size(max = 255, message = "申请理由最多 255 个字符")
  25 + private String applyContent;
  26 +
  27 + @Schema(description = "对接收方的备注(仅自己可见)", example = "老张")
  28 + @Size(max = 16, message = "好友备注最多 16 个字符")
  29 + private String displayName;
  30 +
  31 + @Schema(description = "添加来源", example = "1")
  32 + @InEnum(ImFriendAddSourceEnum.class)
  33 + private Integer addSource;
  34 +
  35 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/vo/request/ImFriendRequestRespVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.friend.vo.request;
  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 ImFriendRequestRespVO {
  16 +
  17 + @Schema(description = "申请编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
  18 + private Long id;
  19 +
  20 + @Schema(description = "发起方用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "100")
  21 + private Long fromUserId;
  22 +
  23 + @Schema(description = "接收方用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "200")
  24 + private Long toUserId;
  25 +
  26 + @Schema(description = "处理结果", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
  27 + private Integer handleResult; // 参见 ImFriendRequestHandleResultEnum 枚举
  28 +
  29 + @Schema(description = "申请理由", example = "我是芋艿(一种食材)")
  30 + private String applyContent;
  31 +
  32 + @Schema(description = "处理理由(接收方拒绝时可选填)", example = "暂不通过")
  33 + private String handleContent;
  34 +
  35 + @Schema(description = "添加来源", example = "1")
  36 + private Integer addSource; // 参见 ImFriendAddSourceEnum 枚举
  37 +
  38 + @Schema(description = "处理时间")
  39 + private LocalDateTime handleTime;
  40 +
  41 + @Schema(description = "申请创建时间")
  42 + private LocalDateTime createTime;
  43 +
  44 + // ========== 下面是聚合字段,方便前端显示 ==========
  45 +
  46 + @Schema(description = "发起方昵称(实时聚合自 AdminUser)", example = "芋道")
  47 + private String fromNickname;
  48 +
  49 + @Schema(description = "发起方头像(实时聚合自 AdminUser)")
  50 + private String fromAvatar;
  51 +
  52 + @Schema(description = "接收方昵称(实时聚合自 AdminUser)", example = "老张")
  53 + private String toNickname;
  54 +
  55 + @Schema(description = "接收方头像(实时聚合自 AdminUser)")
  56 + private String toAvatar;
  57 +
  58 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendDO.java
@@ -16,6 +16,8 @@ import java.time.LocalDateTime; @@ -16,6 +16,8 @@ import java.time.LocalDateTime;
16 * - 双向关系:A-B 互为好友会存 2 条记录(userId=A, friendUserId=B 和 userId=B, friendUserId=A) 16 * - 双向关系:A-B 互为好友会存 2 条记录(userId=A, friendUserId=B 和 userId=B, friendUserId=A)
17 * - 状态管理:{@link #status} 使用 {@link CommonStatusEnum},ENABLE=正常,DISABLE=已删除 17 * - 状态管理:{@link #status} 使用 {@link CommonStatusEnum},ENABLE=正常,DISABLE=已删除
18 * - 免打扰:{@link #muted} 控制是否屏蔽来自该好友的通知 18 * - 免打扰:{@link #muted} 控制是否屏蔽来自该好友的通知
  19 + * - 联系人置顶:{@link #pinned} 单边,影响联系人 / 会话排序
  20 + * - 黑名单:{@link #blocked} 弱关联 friend,单边屏蔽对方消息(必须先是好友)
19 * 21 *
20 * @author 芋道源码 22 * @author 芋道源码
21 */ 23 */
@@ -55,6 +57,20 @@ public class ImFriendDO extends BaseDO { @@ -55,6 +57,20 @@ public class ImFriendDO extends BaseDO {
55 */ 57 */
56 private String displayName; 58 private String displayName;
57 /** 59 /**
  60 + * 添加来源
  61 + * <p>
  62 + * 枚举 {@link cn.iocoder.yudao.module.im.enums.friend.ImFriendAddSourceEnum}
  63 + */
  64 + private Integer addSource;
  65 + /**
  66 + * 是否置顶联系人
  67 + */
  68 + private Boolean pinned;
  69 + /**
  70 + * 是否拉黑(弱关联 friend,单边屏蔽对方私聊消息)
  71 + */
  72 + private Boolean blocked;
  73 + /**
58 * 好友状态 74 * 好友状态
59 * <p> 75 * <p>
60 * 枚举 {@link CommonStatusEnum} 76 * 枚举 {@link CommonStatusEnum}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/friend/ImFriendRequestDO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.dataobject.friend;
  2 +
  3 +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
  4 +import cn.iocoder.yudao.module.im.enums.friend.ImFriendAddSourceEnum;
  5 +import cn.iocoder.yudao.module.im.enums.friend.ImFriendRequestHandleResultEnum;
  6 +import com.baomidou.mybatisplus.annotation.KeySequence;
  7 +import com.baomidou.mybatisplus.annotation.TableId;
  8 +import com.baomidou.mybatisplus.annotation.TableName;
  9 +import lombok.*;
  10 +
  11 +import java.time.LocalDateTime;
  12 +
  13 +/**
  14 + * IM 好友申请记录 DO
  15 + * <p>
  16 + * 配合「申请 - 审批」流程:
  17 + * - 发起方调 apply 接口落库(handleResult=UNHANDLED)
  18 + * - 接收方调 agree / refuse 处理(更新 handleResult / handleTime / handleContent)
  19 + * - 申请通过后,displayName / addSource 同步写入 {@link ImFriendDO}
  20 + *
  21 + * @author 芋道源码
  22 + */
  23 +@TableName("im_friend_request")
  24 +@KeySequence("im_friend_request_seq")
  25 +@Data
  26 +@EqualsAndHashCode(callSuper = true)
  27 +@ToString(callSuper = true)
  28 +@Builder
  29 +@NoArgsConstructor
  30 +@AllArgsConstructor
  31 +public class ImFriendRequestDO extends BaseDO {
  32 +
  33 + /**
  34 + * 编号
  35 + */
  36 + @TableId
  37 + private Long id;
  38 + /**
  39 + * 发起方用户编号
  40 + * <p>
  41 + * 关联 AdminUserDO 的 id 字段
  42 + */
  43 + private Long fromUserId;
  44 + /**
  45 + * 接收方用户编号
  46 + * <p>
  47 + * 关联 AdminUserDO 的 id 字段
  48 + */
  49 + private Long toUserId;
  50 +
  51 + // ========== 申请发起阶段(发起方填写) ==========
  52 + /**
  53 + * 申请理由
  54 + */
  55 + private String applyContent;
  56 + /**
  57 + * 发起方对接收方的备注
  58 + * <p>
  59 + * 同意后写入 A 侧 {@link ImFriendDO#getDisplayName()};B 侧不动
  60 + */
  61 + private String displayName;
  62 + /**
  63 + * 添加来源
  64 + * <p>
  65 + * 枚举 {@link ImFriendAddSourceEnum}
  66 + */
  67 + private Integer addSource;
  68 +
  69 + // ========== 申请处理阶段(接收方填写) ==========
  70 + /**
  71 + * 处理结果
  72 + * <p>
  73 + * 枚举 {@link ImFriendRequestHandleResultEnum}
  74 + */
  75 + private Integer handleResult;
  76 + /**
  77 + * 处理理由(接收方拒绝时可选填)
  78 + */
  79 + private String handleContent;
  80 + /**
  81 + * 处理时间
  82 + */
  83 + private LocalDateTime handleTime;
  84 +
  85 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendRequestMapper.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.mysql.friend;
  2 +
  3 +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
  4 +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
  5 +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO;
  6 +import org.apache.ibatis.annotations.Mapper;
  7 +
  8 +import java.util.List;
  9 +
  10 +/**
  11 + * IM 好友申请记录 Mapper
  12 + *
  13 + * @author 芋道源码
  14 + */
  15 +@Mapper
  16 +public interface ImFriendRequestMapper extends BaseMapperX<ImFriendRequestDO> {
  17 +
  18 + default ImFriendRequestDO selectLatestByFromUserIdAndToUserId(Long fromUserId, Long toUserId) {
  19 + return selectOne(new LambdaQueryWrapperX<ImFriendRequestDO>()
  20 + .eq(ImFriendRequestDO::getFromUserId, fromUserId)
  21 + .eq(ImFriendRequestDO::getToUserId, toUserId)
  22 + .orderByDesc(ImFriendRequestDO::getId)
  23 + .last("LIMIT 1"));
  24 + }
  25 +
  26 + default List<ImFriendRequestDO> selectMyList(Long userId, int limit) {
  27 + // 先放扩展过滤再放双向 OR;否则 .and() 返回 LambdaQueryWrapper 基类,丢失 eqIfPresent 等扩展方法
  28 + LambdaQueryWrapperX<ImFriendRequestDO> wrapper = new LambdaQueryWrapperX<>();
  29 + wrapper.and(w -> w.eq(ImFriendRequestDO::getFromUserId, userId)
  30 + .or().eq(ImFriendRequestDO::getToUserId, userId))
  31 + .orderByDesc(ImFriendRequestDO::getId)
  32 + .last("LIMIT " + limit);
  33 + return selectList(wrapper);
  34 + }
  35 +
  36 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java
@@ -16,12 +16,12 @@ public interface RedisKeyConstants { @@ -16,12 +16,12 @@ public interface RedisKeyConstants {
16 String GROUP_MESSAGE_READ = "im:group:message:read:%s"; 16 String GROUP_MESSAGE_READ = "im:group:message:read:%s";
17 17
18 /** 18 /**
19 - * 好友关系是否存在的缓存 19 + * 好友关系状态缓存(合并「是否好友」+「是否拉黑」两态)
20 * <p> 20 * <p>
21 - * KEY 格式:friend:{userId}_{friendUserId}  
22 - * VALUE 数据类型:Boolean 21 + * KEY 格式:friend_state:{userId}_{friendUserId}
  22 + * VALUE 数据类型:{@link cn.iocoder.yudao.module.im.enums.friend.ImFriendStateEnum}
23 */ 23 */
24 - String FRIEND = "friend"; 24 + String FRIEND_STATE = "friend_state";
25 25
26 /** 26 /**
27 * 群信息缓存 27 * 群信息缓存
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java
@@ -45,6 +45,14 @@ public interface ErrorCodeConstants { @@ -45,6 +45,14 @@ public interface ErrorCodeConstants {
45 // ========== 好友 (1-040-600-000) ========== 45 // ========== 好友 (1-040-600-000) ==========
46 ErrorCode FRIEND_NOT_FRIEND = new ErrorCode(1_040_600_001, "对方不是您的好友"); 46 ErrorCode FRIEND_NOT_FRIEND = new ErrorCode(1_040_600_001, "对方不是您的好友");
47 ErrorCode FRIEND_ADD_SELF = new ErrorCode(1_040_600_002, "不允许添加自己为好友"); 47 ErrorCode FRIEND_ADD_SELF = new ErrorCode(1_040_600_002, "不允许添加自己为好友");
  48 + ErrorCode FRIEND_NOT_BLOCKED = new ErrorCode(1_040_600_003, "对方未在黑名单中");
  49 + ErrorCode FRIEND_BLOCKED_BY_PEER = new ErrorCode(1_040_600_004, "您已被对方拉入黑名单,无法发送消息");
  50 +
  51 + // ========== 好友申请 (1-040-610-000) ==========
  52 + ErrorCode FRIEND_REQUEST_NOT_EXISTS = new ErrorCode(1_040_610_001, "好友申请不存在");
  53 + ErrorCode FRIEND_REQUEST_HANDLED = new ErrorCode(1_040_610_002, "好友申请已处理");
  54 + ErrorCode FRIEND_REQUEST_NOT_TO_ME = new ErrorCode(1_040_610_003, "不能处理别人的好友申请");
  55 + ErrorCode FRIEND_REQUEST_TARGET_INVALID = new ErrorCode(1_040_610_004, "好友申请的对方不存在或已禁用");
48 56
49 // ========== 敏感词 (1-040-700-000) ========== 57 // ========== 敏感词 (1-040-700-000) ==========
50 ErrorCode SENSITIVE_WORD_NOT_EXISTS = new ErrorCode(1_040_700_000, "敏感词不存在"); 58 ErrorCode SENSITIVE_WORD_NOT_EXISTS = new ErrorCode(1_040_700_000, "敏感词不存在");
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/friend/ImFriendAddSourceEnum.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.enums.friend;
  2 +
  3 +import cn.iocoder.yudao.framework.common.core.ArrayValuable;
  4 +import lombok.Getter;
  5 +import lombok.RequiredArgsConstructor;
  6 +
  7 +import java.util.Arrays;
  8 +
  9 +/**
  10 + * IM 好友添加来源枚举
  11 + * <p>
  12 + * 由发起方调用 apply 接口时传入;同意后同步写入 im_friend.add_source(双向)
  13 + *
  14 + * @author 芋道源码
  15 + */
  16 +@RequiredArgsConstructor
  17 +@Getter
  18 +public enum ImFriendAddSourceEnum implements ArrayValuable<Integer> {
  19 +
  20 + // TODO DONE @AI:GROUP 提前到 source=2(先实现);BUSINESS_CARD 简化为 CARD
  21 + SEARCH(1, "搜索"), // FriendAddDialog 搜索流程
  22 + GROUP(2, "群聊"), // TODO @AI:群成员主页加好友,待落 UI 入口
  23 + QR_CODE(3, "扫码"), // TODO @芋艿:后续实现扫码加好友
  24 + CARD(4, "名片"); // TODO @芋艿:后续实现通过名片加好友,类似微信的「扫一扫 - 名片」功能,或者「通讯录 - 推荐好友」功能
  25 +
  26 + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImFriendAddSourceEnum::getSource).toArray(Integer[]::new);
  27 +
  28 + /**
  29 + * 来源
  30 + */
  31 + private final Integer source;
  32 + /**
  33 + * 名字
  34 + */
  35 + private final String name;
  36 +
  37 + @Override
  38 + public Integer[] array() {
  39 + return ARRAYS;
  40 + }
  41 +
  42 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/friend/ImFriendRequestHandleResultEnum.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.enums.friend;
  2 +
  3 +import cn.iocoder.yudao.framework.common.core.ArrayValuable;
  4 +import lombok.Getter;
  5 +import lombok.RequiredArgsConstructor;
  6 +
  7 +import java.util.Arrays;
  8 +import java.util.Objects;
  9 +
  10 +/**
  11 + * IM 好友申请处理结果枚举
  12 + *
  13 + * @author 芋道源码
  14 + */
  15 +@RequiredArgsConstructor
  16 +@Getter
  17 +public enum ImFriendRequestHandleResultEnum implements ArrayValuable<Integer> {
  18 +
  19 + UNHANDLED(0, "未处理"),
  20 + AGREED(1, "同意"),
  21 + REFUSED(2, "拒绝");
  22 +
  23 + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImFriendRequestHandleResultEnum::getResult).toArray(Integer[]::new);
  24 +
  25 + /**
  26 + * 结果
  27 + */
  28 + private final Integer result;
  29 + /**
  30 + * 名字
  31 + */
  32 + private final String name;
  33 +
  34 + @Override
  35 + public Integer[] array() {
  36 + return ARRAYS;
  37 + }
  38 +
  39 + /**
  40 + * 判断申请是否还未处理
  41 + */
  42 + public static boolean isUnhandled(Integer result) {
  43 + return Objects.equals(UNHANDLED.result, result);
  44 + }
  45 +
  46 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/friend/ImFriendStateEnum.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.enums.friend;
  2 +
  3 +import cn.iocoder.yudao.framework.common.core.ArrayValuable;
  4 +import lombok.Getter;
  5 +import lombok.RequiredArgsConstructor;
  6 +
  7 +import java.util.Arrays;
  8 +
  9 +/**
  10 + * IM 好友关系状态(合并「是否好友」+「是否拉黑」两态)
  11 + * <p>
  12 + * 用 state 而非 status:避免和 ImFriendDO 的 statue 物理字段(CommonStatusEnum:ENABLE / DISABLE)混淆。
  13 + * <p>
  14 + * 用于 ImFriendService.getFriendState 返回值与 FRIEND_STATE 缓存值:私聊发送热点路径下,sender 调缓存即可同时判定「能否发」和「是否屏蔽接收方」
  15 + *
  16 + * @author 芋道源码
  17 + */
  18 +@RequiredArgsConstructor
  19 +@Getter
  20 +public enum ImFriendStateEnum implements ArrayValuable<Integer> {
  21 +
  22 + NONE(0, "非好友 / 已删除"),
  23 + FRIEND(1, "好友"),
  24 + BLOCKED(2, "好友且已被对方拉黑");
  25 +
  26 + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImFriendStateEnum::getState).toArray(Integer[]::new);
  27 +
  28 + /**
  29 + * 状态值
  30 + */
  31 + private final Integer state;
  32 + /**
  33 + * 名字
  34 + */
  35 + private final String name;
  36 +
  37 + @Override
  38 + public Integer[] array() {
  39 + return ARRAYS;
  40 + }
  41 +
  42 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java
@@ -33,10 +33,63 @@ public enum ImMessageTypeEnum implements ArrayValuable&lt;Integer&gt; { @@ -33,10 +33,63 @@ public enum ImMessageTypeEnum implements ArrayValuable&lt;Integer&gt; {
33 TIP_TIME(20, "时间分隔", false, false), // 暂无(前端本地生成) 33 TIP_TIME(20, "时间分隔", false, false), // 暂无(前端本地生成)
34 TIP_TEXT(21, "系统提示", true, false), // 对应 TextMessage 类 34 TIP_TEXT(21, "系统提示", true, false), // 对应 TextMessage 类
35 35
36 - // ========== 好友变更 ==========  
37 - FRIEND_ADD(100, "好友添加", false, false), // 暂无对应类  
38 - FRIEND_DELETE(101, "好友删除", false, false), // 暂无对应类  
39 - FRIEND_UPDATE(102, "好友更新", false, false), // 暂无对应类(客户端收到后自行拉取) 36 + // ========== 好友通知(1201-1210 直接复用 OpenIM 段位编号) ==========
  37 + /**
  38 + * 对应 OpenIM:FriendApplicationApprovedNotification 1201
  39 + * 对应自己的类:FriendRequestApprovedNotification
  40 + * 场景:B 同意 A 的好友申请,推给 A 多端
  41 + */
  42 + FRIEND_REQUEST_APPROVED(1201, "好友申请被同意", false, false),
  43 + /**
  44 + * 对应 OpenIM:FriendApplicationRejectedNotification 1202
  45 + * 对应自己的类:FriendRequestRejectedNotification
  46 + * 场景:B 拒绝 A 的好友申请,推给 A 多端
  47 + */
  48 + FRIEND_REQUEST_REJECTED(1202, "好友申请被拒绝", false, false),
  49 + /**
  50 + * 对应 OpenIM:FriendApplicationNotification 1203
  51 + * 对应自己的类:FriendRequestNotification
  52 + * 场景:A 申请加 B,推给 B 多端,前端落到「新的朋友」列表
  53 + */
  54 + FRIEND_APPLICATION(1203, "收到新的好友申请", false, false),
  55 + /**
  56 + * 对应 OpenIM:FriendAddedNotification 1204
  57 + * 对应自己的类:FriendAddNotification
  58 + * 场景:双方建立好友关系(同意申请 / 管理员导入),推给 A、B 双方多端
  59 + */
  60 + FRIEND_ADD(1204, "新增好友", false, false),
  61 + /**
  62 + * 对应 OpenIM:FriendDeletedNotification 1205
  63 + * 对应自己的类:FriendDeleteNotification
  64 + * 场景:A 删除 B,推给 A、B 双方多端
  65 + */
  66 + FRIEND_DELETE(1205, "好友被删除", false, false),
  67 + // 1206 对应 OpenIM FriendRemarkSetNotification;本系统并入 FRIEND_UPDATE(1210) 统一推送,单一字段变更不再独立通道
  68 + /**
  69 + * 对应 OpenIM:BlackAddedNotification 1207
  70 + * 对应自己的类:FriendBlockNotification
  71 + * 场景:A 拉黑 B,仅推 A 多端
  72 + */
  73 + FRIEND_BLOCK(1207, "加入黑名单", false, false),
  74 + /**
  75 + * 对应 OpenIM:BlackDeletedNotification 1208
  76 + * 对应自己的类:FriendUnblockNotification
  77 + * 场景:A 移出 B 的黑名单,仅推 A 多端
  78 + */
  79 + FRIEND_UNBLOCK(1208, "移出黑名单", false, false),
  80 + /**
  81 + * 对应 OpenIM:FriendInfoUpdatedNotification 1209
  82 + * 对应自己的类:FriendInfoUpdatedNotification
  83 + * 场景:B 改了昵称 / 头像后,推给 B 的所有好友
  84 + * TODO @AI:未实现;待 system 模块改昵称 / 头像时回调 IM 模块批量推此事件,前端 dispatcher 已就绪
  85 + */
  86 + FRIEND_INFO_UPDATED(1209, "好友资料变更", false, false),
  87 + /**
  88 + * 对应 OpenIM:FriendsInfoUpdateNotification 1210(窄化到 muted / pinned 单边属性)
  89 + * 对应自己的类:FriendUpdateNotification
  90 + * 场景:A 改了 muted / pinned 等单边属性,推 A 多端
  91 + */
  92 + FRIEND_UPDATE(1210, "好友信息批量更新", false, false),
40 93
41 // ========== 群事件(1501-1520 直接复用 OpenIM 段位编号;1530+ 我们独有扩展) ========== 94 // ========== 群事件(1501-1520 直接复用 OpenIM 段位编号;1530+ 我们独有扩展) ==========
42 // 1500 对应 OpenIM GroupNotificationBegin 起始位,仅作占位,不使用 95 // 1500 对应 OpenIM GroupNotificationBegin 起始位,仅作占位,不使用
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestService.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.friend;
  2 +
  3 +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.request.ImFriendRequestApplyReqVO;
  4 +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO;
  5 +
  6 +import java.util.List;
  7 +
  8 +/**
  9 + * IM 好友申请 Service 接口
  10 + *
  11 + * @author 芋道源码
  12 + */
  13 +public interface ImFriendRequestService {
  14 +
  15 + /**
  16 + * 发起好友申请
  17 + *
  18 + * @param fromUserId 发起方用户编号
  19 + * @param reqVO 申请请求
  20 + * @return 申请记录
  21 + */
  22 + ImFriendRequestDO applyFriend(Long fromUserId, ImFriendRequestApplyReqVO reqVO);
  23 +
  24 + /**
  25 + * 同意好友申请
  26 + *
  27 + * @param userId 操作人用户编号
  28 + * @param requestId 申请记录编号
  29 + */
  30 + void agreeFriendRequest(Long userId, Long requestId);
  31 +
  32 + /**
  33 + * 拒绝好友申请
  34 + *
  35 + * @param userId 操作人用户编号
  36 + * @param requestId 申请记录编号
  37 + * @param handleContent 拒绝理由
  38 + */
  39 + void refuseFriendRequest(Long userId, Long requestId, String handleContent);
  40 +
  41 + /**
  42 + * 查询「我相关」的申请列表(含我发起的、别人加我的)
  43 + *
  44 + * @param userId 用户编号
  45 + * @return 申请记录列表
  46 + */
  47 + List<ImFriendRequestDO> getMyFriendRequestList(Long userId);
  48 +
  49 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestServiceImpl.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.friend;
  2 +
  3 +import cn.hutool.core.util.BooleanUtil;
  4 +import cn.hutool.core.util.ObjUtil;
  5 +import cn.hutool.extra.spring.SpringUtil;
  6 +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
  7 +import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
  8 +import cn.iocoder.yudao.module.im.controller.admin.friend.vo.request.ImFriendRequestApplyReqVO;
  9 +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO;
  10 +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO;
  11 +import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendRequestMapper;
  12 +import cn.iocoder.yudao.module.im.enums.friend.ImFriendRequestHandleResultEnum;
  13 +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
  14 +import cn.iocoder.yudao.module.im.framework.config.ImProperties;
  15 +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
  16 +import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
  17 +import cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend.FriendRequestApprovedNotification;
  18 +import cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend.FriendRequestNotification;
  19 +import cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend.FriendRequestRejectedNotification;
  20 +import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
  21 +import jakarta.annotation.Resource;
  22 +import lombok.extern.slf4j.Slf4j;
  23 +import org.springframework.stereotype.Service;
  24 +import org.springframework.transaction.annotation.Transactional;
  25 +import org.springframework.transaction.support.TransactionSynchronization;
  26 +import org.springframework.transaction.support.TransactionSynchronizationManager;
  27 +import org.springframework.validation.annotation.Validated;
  28 +
  29 +import java.time.LocalDateTime;
  30 +import java.util.List;
  31 +import java.util.Objects;
  32 +
  33 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  34 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
  35 +
  36 +/**
  37 + * IM 好友申请 Service 实现类
  38 + *
  39 + * @author 芋道源码
  40 + */
  41 +@Slf4j
  42 +@Service
  43 +@Validated
  44 +public class ImFriendRequestServiceImpl implements ImFriendRequestService {
  45 +
  46 + @Resource
  47 + private ImFriendRequestMapper friendRequestMapper;
  48 +
  49 + @Resource
  50 + private ImFriendService friendService;
  51 + @Resource
  52 + private ImWebSocketService websocketService;
  53 +
  54 + @Resource
  55 + private ImProperties imProperties;
  56 +
  57 + @Resource
  58 + private AdminUserApi adminUserApi;
  59 +
  60 + @Override
  61 + @Transactional(rollbackFor = Exception.class)
  62 + public ImFriendRequestDO applyFriend(Long fromUserId, ImFriendRequestApplyReqVO reqVO) {
  63 + Long toUserId = reqVO.getToUserId();
  64 + // 1.1 校验:不能加自己
  65 + if (Objects.equals(fromUserId, toUserId)) {
  66 + throw exception(FRIEND_ADD_SELF);
  67 + }
  68 + // 1.2 校验对方存在且启用
  69 + adminUserApi.validateUser(toUserId);
  70 + // 1.3 已是好友:直接幂等返回
  71 + ImFriendDO friend = friendService.getFriend(fromUserId, toUserId);
  72 + if (friend != null && CommonStatusEnum.isEnable(friend.getStatus())) {
  73 + return null;
  74 + }
  75 + // 1.4 被对方拉黑:静默成功,不暴露
  76 + // TODO @AI:是不是 peerFriend?这样更好理解。
  77 + // TODO @AI:是不是应该提示被拉黑了?返回 null 给前端很奇怪。
  78 + ImFriendDO reverse = friendService.getFriend(toUserId, fromUserId);
  79 + if (reverse != null && BooleanUtil.isTrue(reverse.getBlocked())) {
  80 + log.info("[applyFriend][fromUserId({}) 被 toUserId({}) 拉黑,静默忽略]", fromUserId, toUserId);
  81 + return null;
  82 + }
  83 +
  84 + // 2. 落库:复用最新一条未处理记录;否则新建
  85 + ImFriendRequestDO request = friendRequestMapper.selectLatestByFromUserIdAndToUserId(fromUserId, toUserId);
  86 + if (request != null && ImFriendRequestHandleResultEnum.isUnhandled(request.getHandleResult())) {
  87 + // 复用未处理记录:刷新申请理由 / 备注 / 来源;createTime 维持首次申请时间不变(与列表 id DESC 排序一致)
  88 + BeanUtils.copyProperties(reqVO, request);
  89 + friendRequestMapper.updateById(request);
  90 + } else {
  91 + request = BeanUtils.toBean(reqVO, ImFriendRequestDO.class)
  92 + .setFromUserId(fromUserId).setToUserId(toUserId)
  93 + .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult());
  94 + friendRequestMapper.insert(request);
  95 + }
  96 +
  97 + // 3. 推送 FRIEND_APPLICATION 给 toUser 多端
  98 + FriendRequestNotification payload = (FriendRequestNotification) new FriendRequestNotification()
  99 + .setRequestId(request.getId()).setApplyContent(request.getApplyContent()).setAddSource(request.getAddSource())
  100 + .setOperatorUserId(fromUserId).setFriendUserId(fromUserId);
  101 + websocketService.sendPrivateMessageAsync(toUserId, ImPrivateMessageDTO.ofFriendNotification(
  102 + ImMessageTypeEnum.FRIEND_APPLICATION.getType(), fromUserId, toUserId, payload));
  103 +
  104 + // 4. 全局自动通过开关:注册 afterCommit 回调,事务提交后再走同意流程
  105 + if (imProperties.getFriend().isAutoAccept()) {
  106 + Long requestId = request.getId();
  107 + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
  108 +
  109 + @Override
  110 + public void afterCommit() {
  111 + getSelf().agreeFriendRequest(toUserId, requestId);
  112 + }
  113 +
  114 + });
  115 + }
  116 + return request;
  117 + }
  118 +
  119 + @Override
  120 + @Transactional(rollbackFor = Exception.class)
  121 + public void agreeFriendRequest(Long userId, Long requestId) {
  122 + // 1. 校验申请存在 + 未处理 + 操作人是接收方
  123 + ImFriendRequestDO request = validateRequestForHandle(userId, requestId);
  124 +
  125 + // 2. 更新申请:handleResult=AGREED + handleTime
  126 + LocalDateTime now = LocalDateTime.now();
  127 + friendRequestMapper.updateById(new ImFriendRequestDO().setId(request.getId())
  128 + .setHandleResult(ImFriendRequestHandleResultEnum.AGREED.getResult()).setHandleTime(now));
  129 + request.setHandleResult(ImFriendRequestHandleResultEnum.AGREED.getResult()).setHandleTime(now);
  130 +
  131 + // 3. 双向建立好友关系
  132 + friendService.becomeFriends(request);
  133 +
  134 + // 4. 推 FRIEND_REQUEST_APPROVED 给 fromUser 多端
  135 + FriendRequestApprovedNotification payload = (FriendRequestApprovedNotification)
  136 + new FriendRequestApprovedNotification().setRequestId(request.getId())
  137 + .setOperatorUserId(userId).setFriendUserId(userId);
  138 + websocketService.sendPrivateMessageAsync(request.getFromUserId(), ImPrivateMessageDTO.ofFriendNotification(
  139 + ImMessageTypeEnum.FRIEND_REQUEST_APPROVED.getType(), userId, request.getFromUserId(), payload));
  140 + }
  141 +
  142 + @Override
  143 + @Transactional(rollbackFor = Exception.class)
  144 + public void refuseFriendRequest(Long userId, Long requestId, String handleContent) {
  145 + // 1. 校验申请存在 + 未处理 + 操作人是接收方
  146 + ImFriendRequestDO request = validateRequestForHandle(userId, requestId);
  147 +
  148 + // 2. 更新申请:handleResult=REFUSED + handleContent + handleTime
  149 + LocalDateTime now = LocalDateTime.now();
  150 + friendRequestMapper.updateById(new ImFriendRequestDO().setId(request.getId())
  151 + .setHandleResult(ImFriendRequestHandleResultEnum.REFUSED.getResult())
  152 + .setHandleContent(handleContent).setHandleTime(now));
  153 +
  154 + // 3. 推 FRIEND_REQUEST_REJECTED 给 fromUser 多端
  155 + FriendRequestRejectedNotification payload = (FriendRequestRejectedNotification)
  156 + new FriendRequestRejectedNotification().setRequestId(request.getId())
  157 + .setHandleContent(handleContent)
  158 + .setOperatorUserId(userId).setFriendUserId(userId);
  159 + websocketService.sendPrivateMessageAsync(request.getFromUserId(), ImPrivateMessageDTO.ofFriendNotification(
  160 + ImMessageTypeEnum.FRIEND_REQUEST_REJECTED.getType(), userId, request.getFromUserId(), payload));
  161 + }
  162 +
  163 + @Override
  164 + public List<ImFriendRequestDO> getMyFriendRequestList(Long userId) {
  165 + return friendRequestMapper.selectMyList(userId, imProperties.getFriend().getRequestListMaxSize());
  166 + }
  167 +
  168 + /**
  169 + * 校验申请可被「当前用户」处理:申请存在 + 未处理 + 操作人 = 接收方
  170 + */
  171 + private ImFriendRequestDO validateRequestForHandle(Long userId, Long requestId) {
  172 + ImFriendRequestDO request = friendRequestMapper.selectById(requestId);
  173 + if (request == null) {
  174 + throw exception(FRIEND_REQUEST_NOT_EXISTS);
  175 + }
  176 + if (ObjUtil.notEqual(request.getToUserId(), userId)) {
  177 + throw exception(FRIEND_REQUEST_NOT_TO_ME);
  178 + }
  179 + if (!ImFriendRequestHandleResultEnum.isUnhandled(request.getHandleResult())) {
  180 + throw exception(FRIEND_REQUEST_HANDLED);
  181 + }
  182 + return request;
  183 + }
  184 +
  185 + private ImFriendRequestServiceImpl getSelf() {
  186 + return SpringUtil.getBean(getClass());
  187 + }
  188 +
  189 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendService.java
@@ -4,88 +4,81 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult; @@ -4,88 +4,81 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult;
4 import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO; 4 import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO;
5 import cn.iocoder.yudao.module.im.controller.admin.manager.friend.vo.ImFriendManagerPageReqVO; 5 import cn.iocoder.yudao.module.im.controller.admin.manager.friend.vo.ImFriendManagerPageReqVO;
6 import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; 6 import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO;
  7 +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO;
  8 +import cn.iocoder.yudao.module.im.enums.friend.ImFriendStateEnum;
7 9
8 import java.util.Collection; 10 import java.util.Collection;
9 import java.util.List; 11 import java.util.List;
10 12
11 /** 13 /**
12 * IM 好友关系 Service 接口 14 * IM 好友关系 Service 接口
  15 + * <p>
  16 + * 注意:用户端「加好友」走 {@link ImFriendRequestService#applyFriend} 申请-审批流程,
  17 + * 不再开放直接 add 接口;只有 {@link #becomeFriends} 是内部入口(被 agree 同意 / 管理员 import 触发)。
13 * 18 *
14 * @author 芋道源码 19 * @author 芋道源码
15 */ 20 */
16 public interface ImFriendService { 21 public interface ImFriendService {
17 22
18 /** 23 /**
19 - * 判断两个用户是否是好友关系(未删除状态)  
20 - *  
21 - * @param userId 用户编号  
22 - * @param friendUserId 好友用户编号  
23 - * @return 是否好友 24 + * 获取 userId 视角下与 friendUserId 的好友关系状态(私聊发送热点路径)
  25 + * <p>
  26 + * 合并「是否好友」+「是否被自己拉黑」两态,供调用方一次缓存查询完成判定,详见 {@link ImFriendStateEnum}
24 */ 27 */
25 - boolean isFriend(Long userId, Long friendUserId); 28 + ImFriendStateEnum getFriendState(Long userId, Long friendUserId);
26 29
27 /** 30 /**
28 * 获得当前用户的好友列表(含已删除状态) 31 * 获得当前用户的好友列表(含已删除状态)
29 - * <p>  
30 - * 返回的列表里**包含已删除状态**,前端按 status 区分展示(已删除的置灰);  
31 - * 原因是本地已经建立会话的好友被单向删除后,前端仍需要展示头像/昵称。  
32 - *  
33 - * @param userId 用户编号  
34 - * @return 好友关系列表(含已删除的记录)  
35 */ 32 */
36 List<ImFriendDO> getFriendList(Long userId); 33 List<ImFriendDO> getFriendList(Long userId);
37 34
38 /** 35 /**
39 * 获得当前用户与指定用户之间的有效好友列表(仅 ENABLE 状态) 36 * 获得当前用户与指定用户之间的有效好友列表(仅 ENABLE 状态)
40 - *  
41 - * @param userId 用户编号  
42 - * @param friendUserIds 需要校验的好友用户编号集合  
43 - * @return 有效好友关系列表  
44 */ 37 */
45 List<ImFriendDO> getActiveFriendList(Long userId, Collection<Long> friendUserIds); 38 List<ImFriendDO> getActiveFriendList(Long userId, Collection<Long> friendUserIds);
46 39
47 /** 40 /**
48 - * 查询一个好友关系记录(仅限本人视角)  
49 - *  
50 - * @param userId 当前登录用户编号  
51 - * @param friendUserId 目标用户编号  
52 - * @return 好友关系记录,不存在返回 null 41 + * 查询一个好友关系记录
53 */ 42 */
54 ImFriendDO getFriend(Long userId, Long friendUserId); 43 ImFriendDO getFriend(Long userId, Long friendUserId);
55 44
  45 + // ==================== 内部入口 ====================
  46 +
56 /** 47 /**
57 - * 添加好友(双向绑定) 48 + * 双向建立好友关系(内部入口)
58 * <p> 49 * <p>
59 - * 核心流程:  
60 - * 1. 校验:不允许添加自己;对方必须存在  
61 - * 2. 双向插入/更新关系(若已存在则刷新为未删除)  
62 - * 3. 推送"你们已成为好友"系统提示(TODO) 50 + * 由 {@link ImFriendRequestService#agreeFriendRequest} 同意申请 / 管理后台导入触发;
  51 + * A 侧 displayName / addSource 取自申请记录;B 侧 displayName 为空、addSource 同来源。
  52 + * 写库后推送 FRIEND_ADD 通知给 A、B 双方多端,并下发 TIP 系统消息。
63 * 53 *
64 - * @param userId 当前登录用户编号  
65 - * @param friendUserId 好友的用户编号 54 + * @param request 已同意的申请记录(决定 fromUserId / toUserId / addSource / displayName)
66 */ 55 */
67 - void addFriend(Long userId, Long friendUserId); 56 + void becomeFriends(ImFriendRequestDO request);
  57 +
  58 + // ==================== 用户端 ====================
68 59
69 /** 60 /**
70 - * 删除好友(双向软删除) 61 + * 删除好友(单向软删除)
71 * <p> 62 * <p>
72 - * 两边关系都标记为已删除状态。  
73 - *  
74 - * @param userId 当前登录用户编号  
75 - * @param friendUserId 好友的用户编号 63 + * 仅删除 userId 视角下的好友关系;对端 friendUserId 的视角不受影响(与 OpenIM 单边删除语义对齐)
76 */ 64 */
77 void deleteFriend(Long userId, Long friendUserId); 65 void deleteFriend(Long userId, Long friendUserId);
78 66
79 /** 67 /**
80 - * 更新好友信息(仅更新本人→对方的单边关系属性)  
81 - * <p>  
82 - * 目前支持:免打扰(muted);后续可扩展备注等字段。  
83 - *  
84 - * @param userId 当前登录用户编号  
85 - * @param reqVO 更新请求 68 + * 更新好友单边属性(备注 / 免打扰 / 联系人置顶)
86 */ 69 */
87 void updateFriend(Long userId, ImFriendUpdateReqVO reqVO); 70 void updateFriend(Long userId, ImFriendUpdateReqVO reqVO);
88 71
  72 + /**
  73 + * 拉黑好友(必须先是好友)
  74 + */
  75 + void blockFriend(Long userId, Long friendUserId);
  76 +
  77 + /**
  78 + * 移出黑名单
  79 + */
  80 + void unblockFriend(Long userId, Long friendUserId);
  81 +
89 // ==================== 管理后台 ==================== 82 // ==================== 管理后台 ====================
90 83
91 /** 84 /**
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendServiceImpl.java
1 package cn.iocoder.yudao.module.im.service.friend; 1 package cn.iocoder.yudao.module.im.service.friend;
2 2
  3 +import cn.hutool.core.util.BooleanUtil;
3 import cn.hutool.extra.spring.SpringUtil; 4 import cn.hutool.extra.spring.SpringUtil;
4 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; 5 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
5 import cn.iocoder.yudao.framework.common.pojo.PageResult; 6 import cn.iocoder.yudao.framework.common.pojo.PageResult;
6 import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO; 7 import cn.iocoder.yudao.module.im.controller.admin.friend.vo.ImFriendUpdateReqVO;
7 import cn.iocoder.yudao.module.im.controller.admin.manager.friend.vo.ImFriendManagerPageReqVO; 8 import cn.iocoder.yudao.module.im.controller.admin.manager.friend.vo.ImFriendManagerPageReqVO;
8 import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; 9 import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO;
  10 +import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO;
9 import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendMapper; 11 import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendMapper;
  12 +import cn.iocoder.yudao.module.im.enums.friend.ImFriendStateEnum;
  13 +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
10 import cn.iocoder.yudao.module.im.service.message.ImPrivateMessageService; 14 import cn.iocoder.yudao.module.im.service.message.ImPrivateMessageService;
11 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; 15 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
12 import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; 16 import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
13 -import cn.iocoder.yudao.module.system.api.user.AdminUserApi; 17 +import cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend.*;
14 import jakarta.annotation.Resource; 18 import jakarta.annotation.Resource;
15 import lombok.extern.slf4j.Slf4j; 19 import lombok.extern.slf4j.Slf4j;
16 import org.springframework.cache.annotation.CacheEvict; 20 import org.springframework.cache.annotation.CacheEvict;
17 import org.springframework.cache.annotation.Cacheable; 21 import org.springframework.cache.annotation.Cacheable;
  22 +import org.springframework.cache.annotation.Caching;
18 import org.springframework.context.annotation.Lazy; 23 import org.springframework.context.annotation.Lazy;
19 -import org.springframework.dao.DuplicateKeyException;  
20 import org.springframework.stereotype.Service; 24 import org.springframework.stereotype.Service;
21 import org.springframework.transaction.annotation.Transactional; 25 import org.springframework.transaction.annotation.Transactional;
22 import org.springframework.validation.annotation.Validated; 26 import org.springframework.validation.annotation.Validated;
@@ -24,11 +28,10 @@ import org.springframework.validation.annotation.Validated; @@ -24,11 +28,10 @@ import org.springframework.validation.annotation.Validated;
24 import java.time.LocalDateTime; 28 import java.time.LocalDateTime;
25 import java.util.Collection; 29 import java.util.Collection;
26 import java.util.List; 30 import java.util.List;
27 -import java.util.Objects;  
28 31
29 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; 32 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
30 -import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.FRIEND;  
31 -import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_ADD_SELF; 33 +import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.FRIEND_STATE;
  34 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_BLOCKED;
32 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_FRIEND; 35 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.FRIEND_NOT_FRIEND;
33 import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.FRIEND_ADD_TIP_MESSAGE; 36 import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.FRIEND_ADD_TIP_MESSAGE;
34 import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.FRIEND_DELETE_TIP_MESSAGE; 37 import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.FRIEND_DELETE_TIP_MESSAGE;
@@ -44,138 +47,218 @@ import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.FRIEND_DELETE_T @@ -44,138 +47,218 @@ import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.FRIEND_DELETE_T
44 public class ImFriendServiceImpl implements ImFriendService { 47 public class ImFriendServiceImpl implements ImFriendService {
45 48
46 @Resource 49 @Resource
47 - private ImFriendMapper imFriendMapper; 50 + private ImFriendMapper friendMapper;
48 51
49 @Resource 52 @Resource
50 - private AdminUserApi adminUserApi;  
51 -  
52 - @Resource  
53 - private ImWebSocketService imWebSocketService; 53 + private ImWebSocketService websocketService;
54 @Resource 54 @Resource
55 @Lazy 55 @Lazy
56 private ImPrivateMessageService privateMessageService; 56 private ImPrivateMessageService privateMessageService;
57 57
58 @Override 58 @Override
59 - @Cacheable(cacheNames = FRIEND, key = "#userId + '_' + #friendUserId", unless = "#result == null")  
60 - public boolean isFriend(Long userId, Long friendUserId) {  
61 - ImFriendDO friend = imFriendMapper.selectByUserIdAndFriendUserId(userId, friendUserId);  
62 - return friend != null && CommonStatusEnum.isEnable(friend.getStatus()); 59 + @Cacheable(cacheNames = FRIEND_STATE, key = "#userId + '_' + #friendUserId", unless = "#result == null")
  60 + public ImFriendStateEnum getFriendState(Long userId, Long friendUserId) {
  61 + // 1.1 我侧记录:我方删了,都算非好友
  62 + ImFriendDO mine = friendMapper.selectByUserIdAndFriendUserId(userId, friendUserId);
  63 + if (mine == null || !CommonStatusEnum.isEnable(mine.getStatus())) {
  64 + return ImFriendStateEnum.NONE;
  65 + }
  66 + // 1.2 对方侧记录:对方删了 = 不是好友
  67 + ImFriendDO peer = friendMapper.selectByUserIdAndFriendUserId(friendUserId, userId);
  68 + if (peer == null || !CommonStatusEnum.isEnable(peer.getStatus())) {
  69 + return ImFriendStateEnum.NONE;
  70 + }
  71 + // 2. 仅当双方都是 ENABLE 状态,才算好友关系;此时对方拉黑我,则是 BLOCKED
  72 + return BooleanUtil.isTrue(peer.getBlocked()) ? ImFriendStateEnum.BLOCKED : ImFriendStateEnum.FRIEND;
63 } 73 }
64 74
65 @Override 75 @Override
66 public List<ImFriendDO> getFriendList(Long userId) { 76 public List<ImFriendDO> getFriendList(Long userId) {
67 - return imFriendMapper.selectListByUserId(userId); 77 + return friendMapper.selectListByUserId(userId);
68 } 78 }
69 79
70 @Override 80 @Override
71 public List<ImFriendDO> getActiveFriendList(Long userId, Collection<Long> friendUserIds) { 81 public List<ImFriendDO> getActiveFriendList(Long userId, Collection<Long> friendUserIds) {
72 - return imFriendMapper.selectListByUserIdAndFriendUserIdsAndStatus(userId, friendUserIds, 82 + return friendMapper.selectListByUserIdAndFriendUserIdsAndStatus(userId, friendUserIds,
73 CommonStatusEnum.ENABLE.getStatus()); 83 CommonStatusEnum.ENABLE.getStatus());
74 } 84 }
75 85
76 @Override 86 @Override
77 public ImFriendDO getFriend(Long userId, Long friendUserId) { 87 public ImFriendDO getFriend(Long userId, Long friendUserId) {
78 - return imFriendMapper.selectByUserIdAndFriendUserId(userId, friendUserId); 88 + return friendMapper.selectByUserIdAndFriendUserId(userId, friendUserId);
79 } 89 }
80 90
81 @Override 91 @Override
82 public void updateFriend(Long userId, ImFriendUpdateReqVO reqVO) { 92 public void updateFriend(Long userId, ImFriendUpdateReqVO reqVO) {
83 - // 1. 校验好友关系存在  
84 - ImFriendDO friend = imFriendMapper.selectByUserIdAndFriendUserId(userId, reqVO.getFriendUserId()); 93 + // 1.1 校验:至少改一个字段(无字段变更,直接结束)
  94 + if (reqVO.getDisplayName() == null && reqVO.getMuted() == null && reqVO.getPinned() == null) {
  95 + return;
  96 + }
  97 + // 1.2 校验好友关系存在
  98 + ImFriendDO friend = friendMapper.selectByUserIdAndFriendUserId(userId, reqVO.getFriendUserId());
85 if (friend == null) { 99 if (friend == null) {
86 throw exception(FRIEND_NOT_FRIEND); 100 throw exception(FRIEND_NOT_FRIEND);
87 } 101 }
88 102
89 - // 2. 更新好友属性  
90 - imFriendMapper.updateById(new ImFriendDO().setId(friend.getId())  
91 - .setMuted(reqVO.getMuted()).setDisplayName(reqVO.getDisplayName())); 103 + // 2. 更新好友属性(备注 / 免打扰 / 联系人置顶)
  104 + friendMapper.updateById(new ImFriendDO().setId(friend.getId())
  105 + .setMuted(reqVO.getMuted())
  106 + .setDisplayName(reqVO.getDisplayName())
  107 + .setPinned(reqVO.getPinned()));
92 108
93 - // 3. 推送好友更新通知(多端同步)  
94 - imWebSocketService.sendPrivateMessageAsync(userId,  
95 - ImPrivateMessageDTO.ofFriendUpdate(userId, reqVO.getFriendUserId())); 109 + // 3. 推 FRIEND_UPDATE 给 A 多端:所有单边属性变更合并为单条通知,避免多通知顺序竞争
  110 + FriendUpdateNotification payload = (FriendUpdateNotification) new FriendUpdateNotification()
  111 + .setDisplayName(reqVO.getDisplayName()).setMuted(reqVO.getMuted()).setPinned(reqVO.getPinned())
  112 + .setOperatorUserId(userId).setFriendUserId(reqVO.getFriendUserId());
  113 + websocketService.sendPrivateMessageAsync(userId, ImPrivateMessageDTO.ofFriendNotification(
  114 + ImMessageTypeEnum.FRIEND_UPDATE.getType(), userId, userId, payload));
96 } 115 }
97 116
98 @Override 117 @Override
99 @Transactional(rollbackFor = Exception.class) 118 @Transactional(rollbackFor = Exception.class)
100 - public void addFriend(Long userId, Long friendUserId) {  
101 - // 1.1 校验:不允许添加自己为好友  
102 - if (Objects.equals(userId, friendUserId)) {  
103 - throw exception(FRIEND_ADD_SELF);  
104 - }  
105 - // 1.2 校验对方存在(禁用/不存在的账号不允许添加)  
106 - adminUserApi.validateUser(friendUserId);  
107 -  
108 - // 2. 双向绑定  
109 - getSelf().addFriend0(userId, friendUserId);  
110 - getSelf().addFriend0(friendUserId, userId);  
111 -  
112 - // 3.1 插入 TIP_TEXT 系统提示消息并推送双方  
113 - privateMessageService.sendTipPrivateMessage(userId, friendUserId, FRIEND_ADD_TIP_MESSAGE);  
114 - // 3.2 推送好友添加通知给双方  
115 - imWebSocketService.sendPrivateMessageAsync(userId,  
116 - ImPrivateMessageDTO.ofFriendAdd(friendUserId, userId));  
117 - imWebSocketService.sendPrivateMessageAsync(friendUserId,  
118 - ImPrivateMessageDTO.ofFriendAdd(userId, friendUserId)); 119 + public void becomeFriends(ImFriendRequestDO request) {
  120 + Long fromUserId = request.getFromUserId();
  121 + Long toUserId = request.getToUserId();
  122 + // 1. 双向建立关系:A 侧带申请的 displayName / addSource;B 侧 displayName 为空、addSource 同来源
  123 + getSelf().addFriend0(fromUserId, toUserId, request.getDisplayName(), request.getAddSource());
  124 + getSelf().addFriend0(toUserId, fromUserId, null, request.getAddSource());
  125 +
  126 + // TODO DONE @AI:删除「在 openim 里」措辞;TIP 与 FRIEND_ADD 目的不同保持双发
  127 + // TODO @AI:在确认一次;是否需要发送 tip?而是通过 FRIEND_ADD 可以满足了。。。(讨论一下)
  128 + // 2.1 推 TIP 系统消息(双方私聊会话里看到「你们已成为好友」);TIP 走会话入库,FRIEND_ADD 走事件通知
  129 + privateMessageService.sendTipPrivateMessage(fromUserId, toUserId, FRIEND_ADD_TIP_MESSAGE);
  130 +
  131 + // 2.2.1 推 FRIEND_ADD 给 fromUser 多端
  132 + FriendAddNotification toFrom = (FriendAddNotification) new FriendAddNotification()
  133 + .setOperatorUserId(toUserId).setFriendUserId(toUserId);
  134 + websocketService.sendPrivateMessageAsync(fromUserId, ImPrivateMessageDTO.ofFriendNotification(
  135 + ImMessageTypeEnum.FRIEND_ADD.getType(), toUserId, fromUserId, toFrom));
  136 + // 2.2.2 推 FRIEND_ADD 给 toUser 多端
  137 + FriendAddNotification toTo = (FriendAddNotification) new FriendAddNotification()
  138 + .setOperatorUserId(fromUserId).setFriendUserId(fromUserId);
  139 + websocketService.sendPrivateMessageAsync(toUserId, ImPrivateMessageDTO.ofFriendNotification(
  140 + ImMessageTypeEnum.FRIEND_ADD.getType(), fromUserId, toUserId, toTo));
119 } 141 }
120 142
121 @Override 143 @Override
122 @Transactional(rollbackFor = Exception.class) 144 @Transactional(rollbackFor = Exception.class)
123 public void deleteFriend(Long userId, Long friendUserId) { 145 public void deleteFriend(Long userId, Long friendUserId) {
124 - // 1. 双向标记为 DISABLE + 记录 deleteTime 146 + // 1. 单边软删:仅 userId 视角的关系置 DISABLE;friendUserId 视角不动
125 getSelf().deleteFriend0(userId, friendUserId); 147 getSelf().deleteFriend0(userId, friendUserId);
126 - getSelf().deleteFriend0(friendUserId, userId);  
127 148
128 - // 2.1 插入 TIP_TEXT 系统提示消息并推送双方 149 + // 2.1 推 TIP 系统消息
129 privateMessageService.sendTipPrivateMessage(userId, friendUserId, FRIEND_DELETE_TIP_MESSAGE); 150 privateMessageService.sendTipPrivateMessage(userId, friendUserId, FRIEND_DELETE_TIP_MESSAGE);
130 - // 2.2 推送好友删除通知给双方  
131 - imWebSocketService.sendPrivateMessageAsync(userId,  
132 - ImPrivateMessageDTO.ofFriendDelete(userId, friendUserId));  
133 - imWebSocketService.sendPrivateMessageAsync(friendUserId,  
134 - ImPrivateMessageDTO.ofFriendDelete(friendUserId, userId)); 151 +
  152 + // 2.2 推 FRIEND_DELETE 给 userId 多端做同步(friendUserId 不感知)
  153 + FriendDeleteNotification payload = (FriendDeleteNotification) new FriendDeleteNotification()
  154 + .setOperatorUserId(userId).setFriendUserId(friendUserId);
  155 + websocketService.sendPrivateMessageAsync(userId, ImPrivateMessageDTO.ofFriendNotification(
  156 + ImMessageTypeEnum.FRIEND_DELETE.getType(), userId, userId, payload));
  157 + }
  158 +
  159 + @Override
  160 + @Caching(evict = {
  161 + @CacheEvict(cacheNames = FRIEND_STATE, key = "#userId + '_' + #friendUserId"),
  162 + @CacheEvict(cacheNames = FRIEND_STATE, key = "#friendUserId + '_' + #userId")
  163 + })
  164 + public void blockFriend(Long userId, Long friendUserId) {
  165 + // 1.1 校验是好友
  166 + ImFriendDO friend = friendMapper.selectByUserIdAndFriendUserId(userId, friendUserId);
  167 + if (friend == null || !CommonStatusEnum.isEnable(friend.getStatus())) {
  168 + throw exception(FRIEND_NOT_FRIEND);
  169 + }
  170 + // 1.2 已拉黑直接返回,幂等
  171 + if (BooleanUtil.isTrue(friend.getBlocked())) {
  172 + return;
  173 + }
  174 +
  175 + // 2. 单边更新
  176 + friendMapper.updateById(new ImFriendDO().setId(friend.getId()).setBlocked(true));
  177 +
  178 + // 3. 推 FRIEND_BLOCK 给 A 多端
  179 + FriendBlockNotification payload = (FriendBlockNotification) new FriendBlockNotification()
  180 + .setOperatorUserId(userId).setFriendUserId(friendUserId);
  181 + websocketService.sendPrivateMessageAsync(userId, ImPrivateMessageDTO.ofFriendNotification(
  182 + ImMessageTypeEnum.FRIEND_BLOCK.getType(), userId, userId, payload));
  183 + }
  184 +
  185 + @Override
  186 + @Caching(evict = {
  187 + @CacheEvict(cacheNames = FRIEND_STATE, key = "#userId + '_' + #friendUserId"),
  188 + @CacheEvict(cacheNames = FRIEND_STATE, key = "#friendUserId + '_' + #userId")
  189 + })
  190 + public void unblockFriend(Long userId, Long friendUserId) {
  191 + // 1.1 校验是好友
  192 + ImFriendDO friend = friendMapper.selectByUserIdAndFriendUserId(userId, friendUserId);
  193 + if (friend == null || !CommonStatusEnum.isEnable(friend.getStatus())) {
  194 + throw exception(FRIEND_NOT_FRIEND);
  195 + }
  196 + // 1.2 未拉黑则报错
  197 + if (!BooleanUtil.isTrue(friend.getBlocked())) {
  198 + throw exception(FRIEND_NOT_BLOCKED);
  199 + }
  200 +
  201 + // 2. 单边更新
  202 + friendMapper.updateById(new ImFriendDO().setId(friend.getId()).setBlocked(false));
  203 +
  204 + // 3. 推 FRIEND_UNBLOCK 给 A 多端
  205 + FriendUnblockNotification payload = (FriendUnblockNotification) new FriendUnblockNotification()
  206 + .setOperatorUserId(userId).setFriendUserId(friendUserId);
  207 + websocketService.sendPrivateMessageAsync(userId, ImPrivateMessageDTO.ofFriendNotification(
  208 + ImMessageTypeEnum.FRIEND_UNBLOCK.getType(), userId, userId, payload));
135 } 209 }
136 210
137 /** 211 /**
138 - * 单向绑定好友关系:  
139 - * - 首次:新增记录(status=ENABLE,addTime=now)  
140 - * - 已存在且 ENABLE:无需重复操作  
141 - * - 已存在但 DISABLE:恢复关系(status=ENABLE,刷新 addTime,清空 deleteTime) 212 + * 单向绑定好友关系(内部方法,被 {@link #becomeFriends} 调用):
  213 + * - 情况一:已存在记录(含 ENABLE / DISABLE)→ 复用并恢复 ENABLE,重置 muted / pinned / blocked 为 false(删好友再加回来不沿袭旧设置)
  214 + * - 情况二:不存在记录 → 直接插入新记录
142 * <p> 215 * <p>
143 - * 并发安全:依靠 im_friend 表的唯一索引 uk_im_friend_user_friend(user_id, friend_user_id) 保证幂等,  
144 - * 当并发 insert 触发 {@link DuplicateKeyException} 时降级为 select + update。 216 + * 并发安全:业务侧通过 friend_request.handle_result 已保证 agree 不会并发触发;
  217 + * 极端并发下若插入唯一键冲突,让 DuplicateKeyException 向外抛出,外层事务回滚。
145 */ 218 */
146 - @CacheEvict(cacheNames = FRIEND, key = "#userId + '_' + #friendUserId")  
147 - public void addFriend0(Long userId, Long friendUserId) {  
148 - ImFriendDO exists = imFriendMapper.selectByUserIdAndFriendUserId(userId, friendUserId);  
149 - // 情况一:已存在记录 → 恢复或跳过 219 + @Caching(evict = {
  220 + @CacheEvict(cacheNames = FRIEND_STATE, key = "#userId + '_' + #friendUserId"),
  221 + @CacheEvict(cacheNames = FRIEND_STATE, key = "#friendUserId + '_' + #userId")
  222 + })
  223 + public void addFriend0(Long userId, Long friendUserId, String displayName, Integer addSource) {
  224 + ImFriendDO exists = friendMapper.selectByUserIdAndFriendUserId(userId, friendUserId);
  225 + // 情况一:复用旧记录 → 恢复 ENABLE + 重置 muted / pinned / blocked 与首次新增对齐
150 if (exists != null) { 226 if (exists != null) {
151 - if (CommonStatusEnum.isEnable(exists.getStatus())) {  
152 - return; 227 + ImFriendDO update = new ImFriendDO().setId(exists.getId())
  228 + .setStatus(CommonStatusEnum.ENABLE.getStatus()).setAddTime(LocalDateTime.now())
  229 + .setMuted(false).setPinned(false).setBlocked(false);
  230 + if (displayName != null) {
  231 + update.setDisplayName(displayName);
153 } 232 }
154 - imFriendMapper.updateById(new ImFriendDO().setId(exists.getId())  
155 - .setStatus(CommonStatusEnum.ENABLE.getStatus()).setAddTime(LocalDateTime.now())); 233 + if (addSource != null) {
  234 + update.setAddSource(addSource);
  235 + }
  236 + friendMapper.updateById(update);
156 return; 237 return;
157 } 238 }
158 - // 情况二:新增好友关系 239 + // 情况二:不存在记录 → 直接插入新记录
159 ImFriendDO friend = ImFriendDO.builder().userId(userId).friendUserId(friendUserId) 240 ImFriendDO friend = ImFriendDO.builder().userId(userId).friendUserId(friendUserId)
160 - .muted(false).status(CommonStatusEnum.ENABLE.getStatus()).addTime(LocalDateTime.now()).build();  
161 - try {  
162 - imFriendMapper.insert(friend);  
163 - } catch (DuplicateKeyException e) {  
164 - // 并发场景:另一个请求已先一步插入,且其插入的必然是 ENABLE 状态(DISABLE 场景在上方分支已处理),直接忽略  
165 - log.warn("[addFriend0][userId({}) friendUserId({}) 并发插入冲突,忽略]", userId, friendUserId);  
166 - } 241 + .muted(false).pinned(false).blocked(false)
  242 + .displayName(displayName).addSource(addSource)
  243 + .status(CommonStatusEnum.ENABLE.getStatus()).addTime(LocalDateTime.now()).build();
  244 + friendMapper.insert(friend);
167 } 245 }
168 246
169 /** 247 /**
170 * 单向解除好友关系(status 设为 DISABLE,记录 deleteTime) 248 * 单向解除好友关系(status 设为 DISABLE,记录 deleteTime)
  249 + * <p>
  250 + * blocked 不主动重置:删好友期间保留拉黑状态;如果未来再 addFriend0,由 addFriend0 统一重置
171 */ 251 */
172 - @CacheEvict(cacheNames = FRIEND, key = "#userId + '_' + #friendUserId") 252 + @Caching(evict = {
  253 + @CacheEvict(cacheNames = FRIEND_STATE, key = "#userId + '_' + #friendUserId"),
  254 + @CacheEvict(cacheNames = FRIEND_STATE, key = "#friendUserId + '_' + #userId")
  255 + })
173 public void deleteFriend0(Long userId, Long friendUserId) { 256 public void deleteFriend0(Long userId, Long friendUserId) {
174 - ImFriendDO exists = imFriendMapper.selectByUserIdAndFriendUserId(userId, friendUserId); 257 + ImFriendDO exists = friendMapper.selectByUserIdAndFriendUserId(userId, friendUserId);
175 if (exists == null || CommonStatusEnum.isDisable(exists.getStatus())) { 258 if (exists == null || CommonStatusEnum.isDisable(exists.getStatus())) {
176 return; 259 return;
177 } 260 }
178 - imFriendMapper.updateById(new ImFriendDO().setId(exists.getId()) 261 + friendMapper.updateById(new ImFriendDO().setId(exists.getId())
179 .setStatus(CommonStatusEnum.DISABLE.getStatus()).setDeleteTime(LocalDateTime.now())); 262 .setStatus(CommonStatusEnum.DISABLE.getStatus()).setDeleteTime(LocalDateTime.now()));
180 } 263 }
181 264
@@ -187,7 +270,7 @@ public class ImFriendServiceImpl implements ImFriendService { @@ -187,7 +270,7 @@ public class ImFriendServiceImpl implements ImFriendService {
187 270
188 @Override 271 @Override
189 public PageResult<ImFriendDO> getFriendPage(ImFriendManagerPageReqVO reqVO) { 272 public PageResult<ImFriendDO> getFriendPage(ImFriendManagerPageReqVO reqVO) {
190 - return imFriendMapper.selectPage(reqVO); 273 + return friendMapper.selectPage(reqVO);
191 } 274 }
192 275
193 } 276 }
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java
@@ -13,6 +13,7 @@ import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; @@ -13,6 +13,7 @@ import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
13 import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper; 13 import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper;
14 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; 14 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
15 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; 15 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
  16 +import cn.iocoder.yudao.module.im.enums.friend.ImFriendStateEnum;
16 import cn.iocoder.yudao.module.im.service.friend.ImFriendService; 17 import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
17 import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO; 18 import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO;
18 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; 19 import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
@@ -66,9 +67,13 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { @@ -66,9 +67,13 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
66 return existing; 67 return existing;
67 } 68 }
68 // 1.2 好友校验 69 // 1.2 好友校验
69 - if (!friendService.isFriend(senderId, reqVO.getReceiverId())) { 70 + ImFriendStateEnum state = friendService.getFriendState(senderId, reqVO.getReceiverId());
  71 + if (state == ImFriendStateEnum.NONE) {
70 throw exception(FRIEND_NOT_FRIEND); 72 throw exception(FRIEND_NOT_FRIEND);
71 } 73 }
  74 + if (state == ImFriendStateEnum.BLOCKED) {
  75 + throw exception(FRIEND_BLOCKED_BY_PEER);
  76 + }
72 // 1.3 文本消息敏感词过滤 77 // 1.3 文本消息敏感词过滤
73 if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) { 78 if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) {
74 sensitiveWordService.validateText(reqVO.getContent()); 79 sensitiveWordService.validateText(reqVO.getContent());
@@ -81,7 +86,7 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { @@ -81,7 +86,7 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
81 .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now())); 86 .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()));
82 privateMessageMapper.insert(message); 87 privateMessageMapper.insert(message);
83 88
84 - // 3. WebSocket 异步推送(接收方 + 发送方多端同步) 89 + // 3. WebSocket 异步推送:接收方 + 发送方多端同步
85 ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message); 90 ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message);
86 imWebSocketService.sendPrivateMessageAsync(message.getReceiverId(), websocketMessage); 91 imWebSocketService.sendPrivateMessageAsync(message.getReceiverId(), websocketMessage);
87 imWebSocketService.sendPrivateMessageAsync(senderId, websocketMessage); 92 imWebSocketService.sendPrivateMessageAsync(senderId, websocketMessage);
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImGroupMessageSendDTO.java
1 package cn.iocoder.yudao.module.im.service.message.dto; 1 package cn.iocoder.yudao.module.im.service.message.dto;
2 2
3 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; 3 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
4 -import cn.iocoder.yudao.module.im.service.websocket.dto.notification.*; 4 +import cn.iocoder.yudao.module.im.service.websocket.dto.notification.group.*;
5 import jakarta.validation.constraints.NotNull; 5 import jakarta.validation.constraints.NotNull;
6 import lombok.Data; 6 import lombok.Data;
7 7
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java
1 package cn.iocoder.yudao.module.im.service.websocket.dto; 1 package cn.iocoder.yudao.module.im.service.websocket.dto;
2 2
  3 +import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
3 import cn.iocoder.yudao.framework.common.util.object.BeanUtils; 4 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
4 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; 5 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
5 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; 6 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
  7 +import cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend.BaseFriendNotification;
6 import lombok.Data; 8 import lombok.Data;
7 import lombok.experimental.Accessors; 9 import lombok.experimental.Accessors;
8 10
@@ -103,45 +105,19 @@ public class ImPrivateMessageDTO { @@ -103,45 +105,19 @@ public class ImPrivateMessageDTO {
103 // ==================== 好友变更相关 ==================== 105 // ==================== 好友变更相关 ====================
104 106
105 /** 107 /**
106 - * 构建好友添加推送 DTO(通知对方"你有一位新好友") 108 + * 构建好友通知推送 DTO(统一入口)
107 * 109 *
108 - * @param userId 当前用户编号(即发起添加的用户)  
109 - * @param friendUserId 好友的用户编号(即接收通知的用户) 110 + * @param type 消息类型;取自 {@link ImMessageTypeEnum} 中的 FRIEND_* 段
  111 + * @param operatorUserId 操作人用户编号;同时作为帧的 senderId 用于定位接收端 friendUserId
  112 + * @param receiverUserId 推送目标用户编号
  113 + * @param payload 好友通知 payload(继承 {@link BaseFriendNotification})
110 * @return 私聊 DTO 114 * @return 私聊 DTO
111 */ 115 */
112 - public static ImPrivateMessageDTO ofFriendAdd(Long userId, Long friendUserId) {  
113 - return new ImPrivateMessageDTO()  
114 - .setType(ImMessageTypeEnum.FRIEND_ADD.getType())  
115 - .setSenderId(userId).setReceiverId(friendUserId)  
116 - .setSendTime(LocalDateTime.now());  
117 - }  
118 -  
119 - /**  
120 - * 构建好友删除推送 DTO(通知对方"好友关系已解除")  
121 - *  
122 - * @param userId 当前用户编号(即发起删除的用户)  
123 - * @param friendUserId 好友的用户编号(即接收通知的用户)  
124 - * @return 私聊 DTO  
125 - */  
126 - public static ImPrivateMessageDTO ofFriendDelete(Long userId, Long friendUserId) {  
127 - return new ImPrivateMessageDTO()  
128 - .setType(ImMessageTypeEnum.FRIEND_DELETE.getType())  
129 - .setSenderId(userId).setReceiverId(friendUserId)  
130 - .setSendTime(LocalDateTime.now());  
131 - }  
132 -  
133 - /**  
134 - * 构建好友更新推送 DTO(通知自己多端同步好友属性变更)  
135 - *  
136 - * @param userId 当前用户编号  
137 - * @param friendUserId 好友的用户编号  
138 - * @return 私聊 DTO  
139 - */  
140 - public static ImPrivateMessageDTO ofFriendUpdate(Long userId, Long friendUserId) {  
141 - return new ImPrivateMessageDTO()  
142 - .setType(ImMessageTypeEnum.FRIEND_UPDATE.getType())  
143 - .setSenderId(userId).setReceiverId(friendUserId)  
144 - .setSendTime(LocalDateTime.now()); 116 + public static ImPrivateMessageDTO ofFriendNotification(Integer type, Long operatorUserId,
  117 + Long receiverUserId, BaseFriendNotification payload) {
  118 + return new ImPrivateMessageDTO().setType(type)
  119 + .setSenderId(operatorUserId).setReceiverId(receiverUserId)
  120 + .setContent(JsonUtils.toJsonString(payload)).setSendTime(LocalDateTime.now());
145 } 121 }
146 122
147 } 123 }
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/BaseFriendNotification.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend;
  2 +
  3 +import lombok.Data;
  4 +
  5 +/**
  6 + * 好友事件通知基类
  7 + * <p>
  8 + * 所有好友事件 payload 共享:
  9 + * - operatorUserId 标识"谁触发的",用于多端同步时本端识别"是我自己触发的"还是"对方触发的"
  10 + * - friendUserId 标识"好友是谁",前端按它定位 / 更新本地 friend 缓存
  11 + */
  12 +@Data
  13 +public abstract class BaseFriendNotification {
  14 +
  15 + /**
  16 + * 操作人用户编号
  17 + */
  18 + private Long operatorUserId;
  19 + /**
  20 + * 好友用户编号
  21 + */
  22 + private Long friendUserId;
  23 +
  24 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendAddNotification.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend;
  2 +
  3 +import lombok.Data;
  4 +
  5 +/**
  6 + * 新增好友通知
  7 + * <p>
  8 + * 双向建立好友关系后,推送给 A、B 双方多端;前端拉取好友信息入库
  9 + */
  10 +@Data
  11 +public class FriendAddNotification extends BaseFriendNotification {
  12 +
  13 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendBlockNotification.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend;
  2 +
  3 +import lombok.Data;
  4 +
  5 +/**
  6 + * 加入黑名单通知
  7 + * <p>
  8 + * A 拉黑 B 后仅推 A 多端;B 端不感知(B 那边 blocked=0 不变)
  9 + */
  10 +@Data
  11 +public class FriendBlockNotification extends BaseFriendNotification {
  12 +
  13 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendDeleteNotification.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend;
  2 +
  3 +import lombok.Data;
  4 +
  5 +/**
  6 + * 好友删除通知
  7 + * <p>
  8 + * 仅推送给操作人多端做同步(对端不感知,与单边删除语义对齐);前端清除本地好友 + 级联清理私聊会话
  9 + */
  10 +@Data
  11 +public class FriendDeleteNotification extends BaseFriendNotification {
  12 +
  13 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendInfoUpdatedNotification.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend;
  2 +
  3 +import lombok.Data;
  4 +
  5 +// TODO @AI:1209 暂未实现;好友信息在 system 模块,需要 system 改昵称 / 头像后发 ApplicationEvent,IM 模块监听后回调发送此通知
  6 +/**
  7 + * 好友资料变更通知(对端改了昵称 / 头像)
  8 + * <p>
  9 + * 推送给资料被改的人的所有好友;前端清缓存 + 重拉资料
  10 + * <p>
  11 + * 此处 friendUserId 表示「资料被更新的那个人」(即对端),与 operatorUserId 一致
  12 + */
  13 +@Data
  14 +public class FriendInfoUpdatedNotification extends BaseFriendNotification {
  15 +
  16 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendRequestApprovedNotification.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend;
  2 +
  3 +import lombok.Data;
  4 +
  5 +/**
  6 + * 好友申请被同意通知
  7 + * <p>
  8 + * 推送给原申请发起方多端;前端按 requestId 把对应申请记录 handleResult 更新为「已同意」
  9 + */
  10 +@Data
  11 +public class FriendRequestApprovedNotification extends BaseFriendNotification {
  12 +
  13 + /**
  14 + * 已处理的申请记录编号
  15 + */
  16 + private Long requestId;
  17 +
  18 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendRequestNotification.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend;
  2 +
  3 +import lombok.Data;
  4 +
  5 +/**
  6 + * 收到新的好友申请通知
  7 + * <p>
  8 + * 推送给申请的接收方多端;前端拿到 requestId 后按需 fetch 完整申请记录,避免 payload 体积过大
  9 + */
  10 +@Data
  11 +public class FriendRequestNotification extends BaseFriendNotification {
  12 +
  13 + /**
  14 + * 申请记录编号
  15 + */
  16 + private Long requestId;
  17 + /**
  18 + * 申请理由
  19 + */
  20 + private String applyContent;
  21 + /**
  22 + * 添加来源
  23 + */
  24 + private Integer addSource;
  25 +
  26 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendRequestRejectedNotification.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend;
  2 +
  3 +import lombok.Data;
  4 +
  5 +/**
  6 + * 好友申请被拒绝通知
  7 + * <p>
  8 + * 推送给原申请发起方多端;前端按 requestId 把对应申请记录 handleResult 更新为「已拒绝」
  9 + */
  10 +@Data
  11 +public class FriendRequestRejectedNotification extends BaseFriendNotification {
  12 +
  13 + /**
  14 + * 已处理的申请记录编号
  15 + */
  16 + private Long requestId;
  17 + /**
  18 + * 拒绝理由(可选)
  19 + */
  20 + private String handleContent;
  21 +
  22 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendUnblockNotification.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend;
  2 +
  3 +import lombok.Data;
  4 +
  5 +/**
  6 + * 移出黑名单通知
  7 + * <p>
  8 + * A 把 B 移出黑名单后仅推 A 多端
  9 + */
  10 +@Data
  11 +public class FriendUnblockNotification extends BaseFriendNotification {
  12 +
  13 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/friend/FriendUpdateNotification.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend;
  2 +
  3 +import lombok.Data;
  4 +
  5 +/**
  6 + * 好友信息批量更新通知(备注 / 免打扰 / 联系人置顶等单边属性)
  7 + * <p>
  8 + * A 改了对 B 的备注 / 免打扰 / 置顶等单边属性后仅推 A 多端做同步;
  9 + * 一次 update 涉及多个字段时合并为单条通知,避免多通知顺序竞争。
  10 + */
  11 +@Data
  12 +public class FriendUpdateNotification extends BaseFriendNotification {
  13 +
  14 + /**
  15 + * 备注;不为空则更新(空串表示清空)
  16 + */
  17 + private String displayName;
  18 + /**
  19 + * 免打扰;不为空则更新
  20 + */
  21 + private Boolean muted;
  22 + /**
  23 + * 联系人置顶;不为空则更新
  24 + */
  25 + private Boolean pinned;
  26 +
  27 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/BaseGroupNotification.java renamed to yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/BaseGroupNotification.java
1 -package cn.iocoder.yudao.module.im.service.websocket.dto.notification; 1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
2 2
3 import lombok.Data; 3 import lombok.Data;
4 4
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupAdminAddNotification.java renamed to yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupAdminAddNotification.java
1 -package cn.iocoder.yudao.module.im.service.websocket.dto.notification; 1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
2 2
3 /** 3 /**
4 * 添加管理员事件通知(memberUserIds 为被设管理员的成员) 4 * 添加管理员事件通知(memberUserIds 为被设管理员的成员)
5 */ 5 */
6 -public class GroupAdminAddNotification extends MembersAffectedNotification { 6 +public class GroupAdminAddNotification extends GroupMembersAffectedNotification {
7 } 7 }
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupAdminRemoveNotification.java renamed to yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupAdminRemoveNotification.java
1 -package cn.iocoder.yudao.module.im.service.websocket.dto.notification; 1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
2 2
3 /** 3 /**
4 * 撤销管理员事件通知(memberUserIds 为被撤销管理员的成员) 4 * 撤销管理员事件通知(memberUserIds 为被撤销管理员的成员)
5 */ 5 */
6 -public class GroupAdminRemoveNotification extends MembersAffectedNotification { 6 +public class GroupAdminRemoveNotification extends GroupMembersAffectedNotification {
7 } 7 }
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupCreateNotification.java renamed to yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupCreateNotification.java
1 -package cn.iocoder.yudao.module.im.service.websocket.dto.notification; 1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
2 2
3 /** 3 /**
4 * 群创建事件通知(memberUserIds 含创建者 + 初始邀请成员) 4 * 群创建事件通知(memberUserIds 含创建者 + 初始邀请成员)
5 */ 5 */
6 -public class GroupCreateNotification extends MembersAffectedNotification { 6 +public class GroupCreateNotification extends GroupMembersAffectedNotification {
7 } 7 }
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupDissolveNotification.java renamed to yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupDissolveNotification.java
1 -package cn.iocoder.yudao.module.im.service.websocket.dto.notification; 1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
2 2
3 /** 3 /**
4 * 群解散事件通知 4 * 群解散事件通知
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupInfoUpdateNotification.java renamed to yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupInfoUpdateNotification.java
1 -package cn.iocoder.yudao.module.im.service.websocket.dto.notification; 1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
2 2
3 import lombok.Data; 3 import lombok.Data;
4 4
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupMemberInviteNotification.java renamed to yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberInviteNotification.java
1 -package cn.iocoder.yudao.module.im.service.websocket.dto.notification; 1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
2 2
3 /** 3 /**
4 * 成员加入事件通知(memberUserIds 为被邀请人) 4 * 成员加入事件通知(memberUserIds 为被邀请人)
5 */ 5 */
6 -public class GroupMemberInviteNotification extends MembersAffectedNotification { 6 +public class GroupMemberInviteNotification extends GroupMembersAffectedNotification {
7 } 7 }
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupMemberKickNotification.java renamed to yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberKickNotification.java
1 -package cn.iocoder.yudao.module.im.service.websocket.dto.notification; 1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
2 2
3 /** 3 /**
4 * 成员被移出事件通知(memberUserIds 为被移出成员) 4 * 成员被移出事件通知(memberUserIds 为被移出成员)
5 */ 5 */
6 -public class GroupMemberKickNotification extends MembersAffectedNotification { 6 +public class GroupMemberKickNotification extends GroupMembersAffectedNotification {
7 } 7 }
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupMemberNicknameUpdateNotification.java renamed to yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberNicknameUpdateNotification.java
1 -package cn.iocoder.yudao.module.im.service.websocket.dto.notification; 1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
2 2
3 import lombok.Data; 3 import lombok.Data;
4 4
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupMemberQuitNotification.java renamed to yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberQuitNotification.java
1 -package cn.iocoder.yudao.module.im.service.websocket.dto.notification; 1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
2 2
3 /** 3 /**
4 * 成员退群事件通知 4 * 成员退群事件通知
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupMemberSettingUpdateNotification.java renamed to yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMemberSettingUpdateNotification.java
1 -package cn.iocoder.yudao.module.im.service.websocket.dto.notification; 1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
2 2
3 import lombok.Data; 3 import lombok.Data;
4 4
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/MembersAffectedNotification.java renamed to yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMembersAffectedNotification.java
1 -package cn.iocoder.yudao.module.im.service.websocket.dto.notification; 1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
2 2
3 import lombok.Data; 3 import lombok.Data;
4 4
5 import java.util.List; 5 import java.util.List;
6 6
  7 +// TODO DONE @AI:本身已是 BaseGroupNotification 的子类,名字不带 Group 前缀是因为是抽象基类、被多种 Group* 通知共用
  8 +// TODO @AI:它目前只被 group 使用。别过度设计,名字按照我说的调整下;
7 /** 9 /**
8 * 涉及成员列表的群事件通知基类:用于 Create / Invite / Kick / AdminAdd / AdminRemove 10 * 涉及成员列表的群事件通知基类:用于 Create / Invite / Kick / AdminAdd / AdminRemove
9 */ 11 */
10 @Data 12 @Data
11 -public abstract class MembersAffectedNotification extends BaseGroupNotification { 13 +public abstract class GroupMembersAffectedNotification extends BaseGroupNotification {
12 14
13 /** 15 /**
14 * 受影响的成员用户编号列表 16 * 受影响的成员用户编号列表
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupMessagePinNotification.java renamed to yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMessagePinNotification.java
1 -package cn.iocoder.yudao.module.im.service.websocket.dto.notification; 1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
2 2
3 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; 3 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
4 import lombok.Data; 4 import lombok.Data;
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupMessageUnpinNotification.java renamed to yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupMessageUnpinNotification.java
1 -package cn.iocoder.yudao.module.im.service.websocket.dto.notification; 1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
2 2
3 import lombok.Data; 3 import lombok.Data;
4 4
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupNameUpdateNotification.java renamed to yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupNameUpdateNotification.java
1 -package cn.iocoder.yudao.module.im.service.websocket.dto.notification; 1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
2 2
3 import lombok.Data; 3 import lombok.Data;
4 4
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupNoticeUpdateNotification.java renamed to yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupNoticeUpdateNotification.java
1 -package cn.iocoder.yudao.module.im.service.websocket.dto.notification; 1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
2 2
3 import lombok.Data; 3 import lombok.Data;
4 4
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupOwnerTransferNotification.java renamed to yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/group/GroupOwnerTransferNotification.java
1 -package cn.iocoder.yudao.module.im.service.websocket.dto.notification; 1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
2 2
3 import lombok.Data; 3 import lombok.Data;
4 4
yudao-module-im/src/test/resources/sql/clean.sql
@@ -3,3 +3,4 @@ DELETE FROM &quot;im_group_message&quot;; @@ -3,3 +3,4 @@ DELETE FROM &quot;im_group_message&quot;;
3 DELETE FROM "im_group"; 3 DELETE FROM "im_group";
4 DELETE FROM "im_group_member"; 4 DELETE FROM "im_group_member";
5 DELETE FROM "im_friend"; 5 DELETE FROM "im_friend";
  6 +DELETE FROM "im_friend_request";
yudao-module-im/src/test/resources/sql/create_tables.sql
@@ -84,6 +84,9 @@ CREATE TABLE IF NOT EXISTS &quot;im_friend&quot; ( @@ -84,6 +84,9 @@ CREATE TABLE IF NOT EXISTS &quot;im_friend&quot; (
84 "friend_user_id" bigint NOT NULL COMMENT '好友用户编号', 84 "friend_user_id" bigint NOT NULL COMMENT '好友用户编号',
85 "muted" bit DEFAULT FALSE COMMENT '是否免打扰', 85 "muted" bit DEFAULT FALSE COMMENT '是否免打扰',
86 "display_name" varchar(64) NOT NULL DEFAULT '' COMMENT '好友展示备注(仅自己可见)', 86 "display_name" varchar(64) NOT NULL DEFAULT '' COMMENT '好友展示备注(仅自己可见)',
  87 + "add_source" tinyint DEFAULT NULL COMMENT '添加来源',
  88 + "pinned" bit DEFAULT FALSE COMMENT '是否置顶联系人',
  89 + "blocked" bit DEFAULT FALSE COMMENT '是否拉黑',
87 "status" tinyint NOT NULL COMMENT '好友状态', 90 "status" tinyint NOT NULL COMMENT '好友状态',
88 "add_time" timestamp DEFAULT NULL COMMENT '添加好友时间', 91 "add_time" timestamp DEFAULT NULL COMMENT '添加好友时间',
89 "delete_time" timestamp DEFAULT NULL COMMENT '删除好友时间', 92 "delete_time" timestamp DEFAULT NULL COMMENT '删除好友时间',
@@ -95,3 +98,22 @@ CREATE TABLE IF NOT EXISTS &quot;im_friend&quot; ( @@ -95,3 +98,22 @@ CREATE TABLE IF NOT EXISTS &quot;im_friend&quot; (
95 "tenant_id" bigint NOT NULL DEFAULT 0, 98 "tenant_id" bigint NOT NULL DEFAULT 0,
96 PRIMARY KEY ("id") 99 PRIMARY KEY ("id")
97 ) COMMENT 'IM 好友关系表'; 100 ) COMMENT 'IM 好友关系表';
  101 +
  102 +CREATE TABLE IF NOT EXISTS "im_friend_request" (
  103 + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
  104 + "from_user_id" bigint NOT NULL COMMENT '发起方用户编号',
  105 + "to_user_id" bigint NOT NULL COMMENT '接收方用户编号',
  106 + "handle_result" tinyint NOT NULL DEFAULT 0 COMMENT '处理结果;0未处理;1同意;2拒绝',
  107 + "apply_content" varchar(255) DEFAULT NULL COMMENT '申请理由',
  108 + "handle_content" varchar(255) DEFAULT NULL COMMENT '处理理由',
  109 + "display_name" varchar(64) DEFAULT NULL COMMENT '发起方对接收方的备注',
  110 + "add_source" tinyint DEFAULT NULL COMMENT '添加来源',
  111 + "handle_time" timestamp DEFAULT NULL COMMENT '处理时间',
  112 + "creator" varchar(64) DEFAULT '',
  113 + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  114 + "updater" varchar(64) DEFAULT '',
  115 + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  116 + "deleted" bit NOT NULL DEFAULT FALSE,
  117 + "tenant_id" bigint NOT NULL DEFAULT 0,
  118 + PRIMARY KEY ("id")
  119 +) COMMENT 'IM 好友申请记录表';