Commit 37dfd6c07b937581a887d6d4e555a1c003ba6900
1 parent
41762f0a
✨ feat(im): ImMessageTypeEnum 显式化 persistent + normal,sendXxxMessage 收敛 insert 与推送
Showing
11 changed files
with
498 additions
and
218 deletions
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageSendReqVO.java
| 1 | 1 | package cn.iocoder.yudao.module.im.controller.admin.message.vo.group; |
| 2 | 2 | |
| 3 | +import cn.iocoder.yudao.framework.common.validation.InEnum; | |
| 4 | +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; | |
| 5 | +import com.fasterxml.jackson.annotation.JsonIgnore; | |
| 3 | 6 | import io.swagger.v3.oas.annotations.media.Schema; |
| 7 | +import jakarta.validation.constraints.AssertTrue; | |
| 4 | 8 | import jakarta.validation.constraints.NotEmpty; |
| 5 | 9 | import jakarta.validation.constraints.NotNull; |
| 6 | 10 | import lombok.Data; |
| ... | ... | @@ -24,6 +28,7 @@ public class ImGroupMessageSendReqVO { |
| 24 | 28 | |
| 25 | 29 | @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") |
| 26 | 30 | @NotNull(message = "消息类型不能为空") |
| 31 | + @InEnum(ImMessageTypeEnum.class) | |
| 27 | 32 | private Integer type; |
| 28 | 33 | |
| 29 | 34 | @Schema(description = "消息内容,JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{\"content\":\"你好\"}") |
| ... | ... | @@ -36,4 +41,13 @@ public class ImGroupMessageSendReqVO { |
| 36 | 41 | @Schema(description = "是否需要回执", example = "false") |
| 37 | 42 | private Boolean receipt; |
| 38 | 43 | |
| 44 | + /** | |
| 45 | + * 仅允许用户消息(normal)类型 | |
| 46 | + */ | |
| 47 | + @AssertTrue(message = "消息类型不允许") | |
| 48 | + @JsonIgnore | |
| 49 | + public boolean isTypeNormal() { | |
| 50 | + return type == null || ImMessageTypeEnum.validate(type).isNormal(); | |
| 51 | + } | |
| 52 | + | |
| 39 | 53 | } | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java
| 1 | 1 | package cn.iocoder.yudao.module.im.controller.admin.message.vo.privates; |
| 2 | 2 | |
| 3 | +import cn.iocoder.yudao.framework.common.validation.InEnum; | |
| 4 | +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; | |
| 5 | +import com.fasterxml.jackson.annotation.JsonIgnore; | |
| 3 | 6 | import io.swagger.v3.oas.annotations.media.Schema; |
| 7 | +import jakarta.validation.constraints.AssertTrue; | |
| 4 | 8 | import jakarta.validation.constraints.NotEmpty; |
| 5 | 9 | import jakarta.validation.constraints.NotNull; |
| 6 | 10 | import lombok.Data; |
| ... | ... | @@ -22,10 +26,20 @@ public class ImPrivateMessageSendReqVO { |
| 22 | 26 | |
| 23 | 27 | @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") |
| 24 | 28 | @NotNull(message = "消息类型不能为空") |
| 29 | + @InEnum(ImMessageTypeEnum.class) | |
| 25 | 30 | private Integer type; |
| 26 | 31 | |
| 27 | 32 | @Schema(description = "消息内容,JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{\"content\":\"你好\"}") |
| 28 | 33 | @NotEmpty(message = "消息内容不能为空") |
| 29 | 34 | private String content; |
| 30 | 35 | |
| 36 | + /** | |
| 37 | + * 仅允许用户消息(normal)类型 | |
| 38 | + */ | |
| 39 | + @AssertTrue(message = "消息类型不允许") | |
| 40 | + @JsonIgnore | |
| 41 | + public boolean isTypeNormal() { | |
| 42 | + return type == null || ImMessageTypeEnum.validate(type).isNormal(); | |
| 43 | + } | |
| 44 | + | |
| 31 | 45 | } | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java
| 1 | 1 | package cn.iocoder.yudao.module.im.enums.message; |
| 2 | 2 | |
| 3 | +import cn.hutool.core.lang.Assert; | |
| 4 | +import cn.hutool.core.util.ArrayUtil; | |
| 3 | 5 | import cn.iocoder.yudao.framework.common.core.ArrayValuable; |
| 4 | -import lombok.AllArgsConstructor; | |
| 5 | 6 | import lombok.Getter; |
| 7 | +import lombok.RequiredArgsConstructor; | |
| 6 | 8 | |
| 7 | 9 | import java.util.Arrays; |
| 8 | 10 | |
| ... | ... | @@ -12,30 +14,35 @@ import java.util.Arrays; |
| 12 | 14 | * @author 芋道源码 |
| 13 | 15 | */ |
| 14 | 16 | @Getter |
| 15 | -@AllArgsConstructor | |
| 17 | +@RequiredArgsConstructor | |
| 16 | 18 | public enum ImMessageTypeEnum implements ArrayValuable<Integer> { |
| 17 | 19 | |
| 18 | - TEXT(0, "文本"), // 对应 TextMessage 类 | |
| 19 | - IMAGE(1, "图片"), // 对应 ImageMessage 类 | |
| 20 | - FILE(2, "文件"), // 对应 FileMessage 类 | |
| 21 | - VOICE(3, "语音"), // 对应 AudioMessage 类 | |
| 22 | - VIDEO(4, "视频"), // 对应 VideoMessage 类 | |
| 20 | + // ========== 用户聊天消息 ========== | |
| 21 | + TEXT(0, "文本", true, true), // 对应 TextMessage 类 | |
| 22 | + IMAGE(1, "图片", true, true), // 对应 ImageMessage 类 | |
| 23 | + FILE(2, "文件", true, true), // 对应 FileMessage 类 | |
| 24 | + VOICE(3, "语音", true, true), // 对应 AudioMessage 类 | |
| 25 | + VIDEO(4, "视频", true, true), // 对应 VideoMessage 类 | |
| 23 | 26 | |
| 24 | - RECALL(10, "撤回"), // 对应 RecallMessage 类 | |
| 25 | - READ(11, "已读"), // 暂无 | |
| 26 | - RECEIPT(12, "回执"), // 暂无 | |
| 27 | - TIP_TEXT(21, "提示文本"), // 对应 TextMessage 类 | |
| 27 | + // ========== 信号类 ========== | |
| 28 | + RECALL(10, "撤回", true, false), // 对应 RecallMessage 类 | |
| 29 | + READ(11, "已读", false, false), // 暂无 | |
| 30 | + RECEIPT(12, "回执", false, false), // 暂无 | |
| 28 | 31 | |
| 29 | - // ==================== 非单聊、群聊存储的类型,100 开始 ==================== | |
| 32 | + // ========== 系统提示 ========== | |
| 33 | + TIP_TIME(20, "时间分隔", false, false), // 暂无(前端本地生成) | |
| 34 | + TIP_TEXT(21, "系统提示", true, false), // 对应 TextMessage 类 | |
| 30 | 35 | |
| 31 | - FRIEND_ADD(100, "好友添加"), // 暂无 | |
| 32 | - FRIEND_DELETE(101, "好友删除"), // 暂无 | |
| 33 | - FRIEND_UPDATE(102, "好友更新"), // 暂无(客户端收到后自行拉取) | |
| 36 | + // ========== 好友变更 ========== | |
| 37 | + FRIEND_ADD(100, "好友添加", false, false), // 暂无 | |
| 38 | + FRIEND_DELETE(101, "好友删除", false, false), // 暂无 | |
| 39 | + FRIEND_UPDATE(102, "好友更新", false, false), // 暂无(客户端收到后自行拉取) | |
| 34 | 40 | |
| 35 | - GROUP_CREATE(200, "群创建"), // 暂无 | |
| 36 | - GROUP_UPDATE(201, "群信息变更"), // 暂无 | |
| 37 | - GROUP_DELETE(202, "群删除"), // 暂无(解散/退群/踢出均用此类型) | |
| 38 | - GROUP_MEMBER_UPDATE(203, "群成员信息变更"); // 暂无(客户端收到后自行拉取) | |
| 41 | + // ========== 群变更 ========== | |
| 42 | + GROUP_CREATE(200, "群创建", false, false), // 暂无 | |
| 43 | + GROUP_UPDATE(201, "群信息变更", false, false), // 暂无 | |
| 44 | + GROUP_DELETE(202, "群删除", false, false), // 暂无(解散/退群/踢出均用此类型) | |
| 45 | + GROUP_MEMBER_UPDATE(203, "群成员信息变更", false, false); // 暂无(客户端收到后自行拉取) | |
| 39 | 46 | |
| 40 | 47 | public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageTypeEnum::getType).toArray(Integer[]::new); |
| 41 | 48 | |
| ... | ... | @@ -43,15 +50,40 @@ public enum ImMessageTypeEnum implements ArrayValuable<Integer> { |
| 43 | 50 | * 类型 |
| 44 | 51 | */ |
| 45 | 52 | private final Integer type; |
| 46 | - | |
| 47 | 53 | /** |
| 48 | 54 | * 名字 |
| 49 | 55 | */ |
| 50 | 56 | private final String name; |
| 57 | + /** | |
| 58 | + * 是否入库 | |
| 59 | + * <p> | |
| 60 | + * true:插入 im_xxx_message 消息表,离线 pull 能拉到; | |
| 61 | + * false:仅 WebSocket 推送,离线丢弃;状态由专用存储维护(如 READ 走 Redis 游标) | |
| 62 | + */ | |
| 63 | + private final boolean persistent; | |
| 64 | + /** | |
| 65 | + * 是不是用户聊天消息(normal vs event 二分) | |
| 66 | + * <p> | |
| 67 | + * true:用户主动发的聊天消息,计入会话未读数(接收方非激活会话时 unreadCount + 1);用户发送入口仅允许这类; | |
| 68 | + * false:系统事件 / 信号 / 提示,不参与未读计数 | |
| 69 | + */ | |
| 70 | + private final boolean normal; | |
| 51 | 71 | |
| 52 | 72 | @Override |
| 53 | 73 | public Integer[] array() { |
| 54 | 74 | return ARRAYS; |
| 55 | 75 | } |
| 56 | 76 | |
| 77 | + /** | |
| 78 | + * 校验 type 已注册,并返回对应枚举;未注册立刻抛异常,避免新增 type 时漏配 persistent / normal 属性 | |
| 79 | + * | |
| 80 | + * @param type 消息类型 | |
| 81 | + * @return 枚举实例 | |
| 82 | + */ | |
| 83 | + public static ImMessageTypeEnum validate(Integer type) { | |
| 84 | + ImMessageTypeEnum result = ArrayUtil.firstMatch(item -> item.type.equals(type), values()); | |
| 85 | + Assert.notNull(result, "未注册的消息类型 type={}", type); | |
| 86 | + return result; | |
| 87 | + } | |
| 88 | + | |
| 57 | 89 | } | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java
| ... | ... | @@ -5,6 +5,7 @@ import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.group.ImGr |
| 5 | 5 | import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageListReqVO; |
| 6 | 6 | import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; |
| 7 | 7 | import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; |
| 8 | +import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; | |
| 8 | 9 | |
| 9 | 10 | import java.util.Collection; |
| 10 | 11 | import java.util.List; |
| ... | ... | @@ -18,7 +19,10 @@ import java.util.Set; |
| 18 | 19 | public interface ImGroupMessageService { |
| 19 | 20 | |
| 20 | 21 | /** |
| 21 | - * 发送群聊消息 | |
| 22 | + * 【用户调用】发送群聊消息 | |
| 23 | + * <p> | |
| 24 | + * 用户在 IM 客户端发送 TEXT / IMAGE 等消息时调用,含幂等、敏感词、quote、@ 解析等业务校验。 | |
| 25 | + * type 校验由 VO 层 {@code @InEnum} + {@code @AssertTrue} 完成(仅允许 normal 类型)。 | |
| 22 | 26 | * |
| 23 | 27 | * @param senderId 发送人编号 |
| 24 | 28 | * @param reqVO 发送请求 |
| ... | ... | @@ -27,6 +31,34 @@ public interface ImGroupMessageService { |
| 27 | 31 | ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendReqVO reqVO); |
| 28 | 32 | |
| 29 | 33 | /** |
| 34 | + * 【系统调用】发送群聊消息:自动算可见用户 + 推送 | |
| 35 | + * | |
| 36 | + * @param senderId 发送人编号 | |
| 37 | + * @param dto 消息 DTO | |
| 38 | + * @return 消息 | |
| 39 | + */ | |
| 40 | + ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendDTO dto); | |
| 41 | + | |
| 42 | + /** | |
| 43 | + * 【系统调用】发送群聊消息:显式指定推送目标 | |
| 44 | + * | |
| 45 | + * @param senderId 发送人编号 | |
| 46 | + * @param targetUserIds 推送目标用户编号集合(调用方在变更成员状态前抓取的快照) | |
| 47 | + * @param dto 消息 DTO | |
| 48 | + * @return 构造的消息 DO(持久化时 id 已回填) | |
| 49 | + */ | |
| 50 | + ImGroupMessageDO sendGroupMessage(Long senderId, Set<Long> targetUserIds, ImGroupMessageSendDTO dto); | |
| 51 | + | |
| 52 | + /** | |
| 53 | + * 【用户调用】撤回群聊消息 | |
| 54 | + * | |
| 55 | + * @param userId 当前用户编号 | |
| 56 | + * @param messageId 消息编号 | |
| 57 | + * @return 撤回后的提示消息 | |
| 58 | + */ | |
| 59 | + ImGroupMessageDO recallGroupMessage(Long userId, Long messageId); | |
| 60 | + | |
| 61 | + /** | |
| 30 | 62 | * 拉取群聊消息(增量) |
| 31 | 63 | * |
| 32 | 64 | * @param userId 当前用户编号 |
| ... | ... | @@ -46,15 +78,6 @@ public interface ImGroupMessageService { |
| 46 | 78 | void readGroupMessages(Long userId, Long groupId, Long messageId); |
| 47 | 79 | |
| 48 | 80 | /** |
| 49 | - * 撤回群聊消息 | |
| 50 | - * | |
| 51 | - * @param userId 当前用户编号 | |
| 52 | - * @param messageId 消息编号 | |
| 53 | - * @return 撤回后的提示消息 | |
| 54 | - */ | |
| 55 | - ImGroupMessageDO recallGroupMessage(Long userId, Long messageId); | |
| 56 | - | |
| 57 | - /** | |
| 58 | 81 | * 获取群消息的已读用户列表 |
| 59 | 82 | * |
| 60 | 83 | * @param userId 当前用户编号 | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java
| ... | ... | @@ -21,6 +21,7 @@ import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; |
| 21 | 21 | import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; |
| 22 | 22 | import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; |
| 23 | 23 | import cn.iocoder.yudao.module.im.service.group.ImGroupService; |
| 24 | +import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; | |
| 24 | 25 | import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; |
| 25 | 26 | import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; |
| 26 | 27 | import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; |
| ... | ... | @@ -102,12 +103,82 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { |
| 102 | 103 | : ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus())); |
| 103 | 104 | groupMessageMapper.insert(message); |
| 104 | 105 | |
| 105 | - // 3. WebSocket 推送给群成员(异步) | |
| 106 | - sendGroupMessageEvent(message); | |
| 106 | + // 3. WebSocket 异步推送(群内可见成员 + 发送方多端同步) | |
| 107 | + List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(message.getGroupId()); | |
| 108 | + Set<Long> targetUserIds = getVisibleUserIds(message.getReceiverUserIds(), senderId, memberUserIds); | |
| 109 | + imWebSocketService.sendGroupMessageAsync(targetUserIds, ImGroupMessageDTO.ofSend(message)); | |
| 107 | 110 | return message; |
| 108 | 111 | } |
| 109 | 112 | |
| 110 | 113 | @Override |
| 114 | + public ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendDTO dto) { | |
| 115 | + List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(dto.getGroupId()); | |
| 116 | + Set<Long> targetUserIds = getVisibleUserIds(dto.getReceiverUserIds(), senderId, memberUserIds); | |
| 117 | + return sendGroupMessage(senderId, targetUserIds, dto); | |
| 118 | + } | |
| 119 | + | |
| 120 | + @Override | |
| 121 | + public ImGroupMessageDO sendGroupMessage(Long senderId, Set<Long> targetUserIds, ImGroupMessageSendDTO dto) { | |
| 122 | + // 1.1 content 序列化:null / String 透传,POJO 走 JSON | |
| 123 | + Object payload = dto.getContent(); | |
| 124 | + String contentString = payload == null || payload instanceof String | |
| 125 | + ? (String) payload | |
| 126 | + : JsonUtils.toJsonString(payload); | |
| 127 | + // 1.2 构建并保存消息 | |
| 128 | + ImGroupMessageDO message = new ImGroupMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) | |
| 129 | + .setSenderId(senderId).setGroupId(dto.getGroupId()) | |
| 130 | + .setType(dto.getType()).setContent(contentString) | |
| 131 | + .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) | |
| 132 | + .setAtUserIds(dto.getAtUserIds()).setReceiverUserIds(dto.getReceiverUserIds()) | |
| 133 | + .setReceiptStatus(Boolean.TRUE.equals(dto.getReceipt()) | |
| 134 | + ? ImGroupMessageReceiptStatusEnum.PENDING.getStatus() | |
| 135 | + : ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()); | |
| 136 | + // 1.3 按 type.persistent 决定是否入库 | |
| 137 | + if (ImMessageTypeEnum.validate(dto.getType()).isPersistent()) { | |
| 138 | + groupMessageMapper.insert(message); | |
| 139 | + } | |
| 140 | + | |
| 141 | + // 2. WebSocket 异步推送 | |
| 142 | + imWebSocketService.sendGroupMessageAsync(targetUserIds, ImGroupMessageDTO.ofSend(message)); | |
| 143 | + return message; | |
| 144 | + } | |
| 145 | + | |
| 146 | + @Override | |
| 147 | + public void sendTipGroupMessage(Long senderId, Long groupId, Set<Long> memberUserIds, String content) { | |
| 148 | + sendGroupMessage(senderId, memberUserIds, new ImGroupMessageSendDTO() | |
| 149 | + .setGroupId(groupId).setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(content)); | |
| 150 | + } | |
| 151 | + | |
| 152 | + @Override | |
| 153 | + @Transactional(rollbackFor = Exception.class) | |
| 154 | + public ImGroupMessageDO recallGroupMessage(Long userId, Long messageId) { | |
| 155 | + // 1.1 校验消息存在 | |
| 156 | + ImGroupMessageDO message = validateGroupMessageExists(messageId); | |
| 157 | + // 1.2 只能撤回自己发送的消息 | |
| 158 | + if (ObjUtil.notEqual(message.getSenderId(), userId)) { | |
| 159 | + throw exception(MESSAGE_RECALL_DENIED); | |
| 160 | + } | |
| 161 | + // 1.3 不能重复撤回 | |
| 162 | + if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { | |
| 163 | + throw exception(MESSAGE_ALREADY_RECALLED); | |
| 164 | + } | |
| 165 | + // 1.4 只允许撤回限定时间内的消息 | |
| 166 | + if (message.getSendTime().plusMinutes(MESSAGE_RECALL_TIMEOUT_MINUTES).isBefore(LocalDateTime.now())) { | |
| 167 | + throw exception(MESSAGE_RECALL_TIMEOUT, MESSAGE_RECALL_TIMEOUT_MINUTES); | |
| 168 | + } | |
| 169 | + // 1.5 校验撤回人仍在群中 | |
| 170 | + groupMemberService.validateMemberInGroup(message.getGroupId(), userId); | |
| 171 | + | |
| 172 | + // 2. 更新原消息状态为撤回 | |
| 173 | + groupMessageMapper.updateById(new ImGroupMessageDO().setId(messageId) | |
| 174 | + .setStatus(ImMessageStatusEnum.RECALL.getStatus())); | |
| 175 | + | |
| 176 | + // 3. 发送撤回事件 | |
| 177 | + return sendGroupMessage(userId, new ImGroupMessageSendDTO().setGroupId(message.getGroupId()) | |
| 178 | + .setType(ImMessageTypeEnum.RECALL.getType()).setContent(new RecallMessage().setMessageId(messageId))); | |
| 179 | + } | |
| 180 | + | |
| 181 | + @Override | |
| 111 | 182 | public List<ImGroupMessageDO> pullGroupMessageList(Long userId, Long minId, Integer size) { |
| 112 | 183 | if (size > MESSAGE_MAX_PULL_SIZE) { |
| 113 | 184 | throw exception(MESSAGE_PULL_SIZE_EXCEEDED, MESSAGE_MAX_PULL_SIZE); |
| ... | ... | @@ -286,44 +357,6 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { |
| 286 | 357 | } |
| 287 | 358 | |
| 288 | 359 | @Override |
| 289 | - @Transactional(rollbackFor = Exception.class) | |
| 290 | - public ImGroupMessageDO recallGroupMessage(Long userId, Long messageId) { | |
| 291 | - // 1.1 校验消息存在 | |
| 292 | - ImGroupMessageDO message = validateGroupMessageExists(messageId); | |
| 293 | - // 1.2 只能撤回自己发送的消息 | |
| 294 | - if (ObjUtil.notEqual(message.getSenderId(), userId)) { | |
| 295 | - throw exception(MESSAGE_RECALL_DENIED); | |
| 296 | - } | |
| 297 | - // 1.3 不能重复撤回 | |
| 298 | - if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { | |
| 299 | - throw exception(MESSAGE_ALREADY_RECALLED); | |
| 300 | - } | |
| 301 | - // 1.4 只允许撤回限定时间内的消息 | |
| 302 | - if (message.getSendTime().plusMinutes(MESSAGE_RECALL_TIMEOUT_MINUTES).isBefore(LocalDateTime.now())) { | |
| 303 | - throw exception(MESSAGE_RECALL_TIMEOUT, MESSAGE_RECALL_TIMEOUT_MINUTES); | |
| 304 | - } | |
| 305 | - // 1.5 校验撤回人仍在群中 | |
| 306 | - groupMemberService.validateMemberInGroup(message.getGroupId(), userId); | |
| 307 | - | |
| 308 | - // 2. 更新原消息状态为撤回 | |
| 309 | - groupMessageMapper.updateById(new ImGroupMessageDO().setId(messageId) | |
| 310 | - .setStatus(ImMessageStatusEnum.RECALL.getStatus())); | |
| 311 | - | |
| 312 | - // 3. 插入一条 RECALL 消息作为撤回信号(content 为 RecallMessage 序列化,前端据此找到原消息更新状态) | |
| 313 | - RecallMessage recallContent = new RecallMessage().setMessageId(messageId); | |
| 314 | - ImGroupMessageDO recallMessage = new ImGroupMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) | |
| 315 | - .setSenderId(userId).setGroupId(message.getGroupId()) | |
| 316 | - .setType(ImMessageTypeEnum.RECALL.getType()).setContent(JsonUtils.toJsonString(recallContent)) | |
| 317 | - .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) | |
| 318 | - .setReceiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()); | |
| 319 | - groupMessageMapper.insert(recallMessage); | |
| 320 | - | |
| 321 | - // 4. 异步推送撤回信号(前端据此更新原消息状态 + 插入撤回提示) | |
| 322 | - sendGroupMessageEvent(recallMessage); | |
| 323 | - return recallMessage; | |
| 324 | - } | |
| 325 | - | |
| 326 | - @Override | |
| 327 | 360 | public List<Long> getGroupReadUserIds(Long userId, Long groupId, Long messageId) { |
| 328 | 361 | // 1.1 校验用户在群中(权限校验) |
| 329 | 362 | groupMemberService.validateMemberInGroup(groupId, userId); |
| ... | ... | @@ -365,28 +398,6 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { |
| 365 | 398 | // ========== 异步 WebSocket 推送 ========== |
| 366 | 399 | |
| 367 | 400 | /** |
| 368 | - * 发送群聊消息 WebSocket 事件 | |
| 369 | - * <p> | |
| 370 | - * 这里仅用于"新消息"(首发 or 撤回提示),{@code message.sendTime = now}: | |
| 371 | - * <ul> | |
| 372 | - * <li>所有 ENABLE 成员的 {@code joinTime <= now},{@link #isMessageVisible} 的 joinTime 分支恒为真;</li> | |
| 373 | - * <li>ENABLE 成员 status 非 DISABLE,quitTime 分支不适用;</li> | |
| 374 | - * </ul> | |
| 375 | - * 因此只剩 {@code receiverUserIds} 定向过滤会真正起效。 | |
| 376 | - * 故使用只含 userId 的轻量缓存 {@link ImGroupMemberService#getActiveGroupMemberUserIdsByGroupId(Long)}, | |
| 377 | - * 在本地以 {@link #getVisibleUserIds} 完成定向过滤即可。 | |
| 378 | - * <p> | |
| 379 | - * 注意:对历史消息的可见性判断仍需 joinTime/quitTime,见 {@code readGroupMessageEvent} 等处。 | |
| 380 | - */ | |
| 381 | - public void sendGroupMessageEvent(ImGroupMessageDO message) { | |
| 382 | - ImGroupMessageDTO dto = ImGroupMessageDTO.ofSend(message); | |
| 383 | - // 广播给群内有效成员中的可见用户(含发送方自己,支持多端同步;定向消息自动过滤) | |
| 384 | - List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(message.getGroupId()); | |
| 385 | - Set<Long> targetUserIds = getVisibleUserIds(message, memberUserIds); | |
| 386 | - imWebSocketService.sendGroupMessageAsync(targetUserIds, dto); | |
| 387 | - } | |
| 388 | - | |
| 389 | - /** | |
| 390 | 401 | * 发送已读 + 刷新群回执 WebSocket 事件 |
| 391 | 402 | * |
| 392 | 403 | * @param userId 当前用户编号 |
| ... | ... | @@ -528,24 +539,24 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { |
| 528 | 539 | } |
| 529 | 540 | |
| 530 | 541 | /** |
| 531 | - * 基于群成员 userId 列表,过滤出一条消息的可见成员集合(含发送者)。 | |
| 542 | + * 基于群成员 userId 列表,过滤出一条新消息的可见成员集合(含发送者)。 | |
| 532 | 543 | * <p> |
| 533 | - * 仅适用于"新消息"推送场景({@code sendTime = now}),不涉及 joinTime/quitTime 判定, | |
| 544 | + * 仅适用于「新消息」推送场景({@code sendTime = now}),不涉及 joinTime / quitTime 判定, | |
| 534 | 545 | * 只应用 {@code receiverUserIds} 定向过滤;语义与 |
| 535 | 546 | * {@link #isMessageVisible(ImGroupMessageDO, ImGroupMemberDO, Long)} 的第 3 步保持一致。 |
| 536 | 547 | */ |
| 537 | - private Set<Long> getVisibleUserIds(ImGroupMessageDO message, Collection<Long> memberUserIds) { | |
| 548 | + private Set<Long> getVisibleUserIds(List<Long> receiverUserIds, Long senderId, Collection<Long> memberUserIds) { | |
| 538 | 549 | if (CollUtil.isEmpty(memberUserIds)) { |
| 539 | 550 | return new HashSet<>(); |
| 540 | 551 | } |
| 541 | 552 | // 无定向接收列表 → 全员可见 |
| 542 | - if (CollUtil.isEmpty(message.getReceiverUserIds())) { | |
| 553 | + if (CollUtil.isEmpty(receiverUserIds)) { | |
| 543 | 554 | return new HashSet<>(memberUserIds); |
| 544 | 555 | } |
| 545 | 556 | // 有定向接收列表 → 仅定向用户可见;发送者自己也能看到自己的消息(多端同步) |
| 546 | - Set<Long> allowed = new HashSet<>(message.getReceiverUserIds()); | |
| 547 | - if (message.getSenderId() != null) { | |
| 548 | - allowed.add(message.getSenderId()); | |
| 557 | + Set<Long> allowed = new HashSet<>(receiverUserIds); | |
| 558 | + if (senderId != null) { | |
| 559 | + allowed.add(senderId); | |
| 549 | 560 | } |
| 550 | 561 | Set<Long> result = new HashSet<>(); |
| 551 | 562 | for (Long userId : memberUserIds) { |
| ... | ... | @@ -571,22 +582,6 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { |
| 571 | 582 | groupMessageReadRedisDAO.deleteReadMaxMessageIdMap(groupId); |
| 572 | 583 | } |
| 573 | 584 | |
| 574 | - @Override | |
| 575 | - public void sendTipGroupMessage(Long senderId, Long groupId, Set<Long> memberUserIds, String content) { | |
| 576 | - // 1. 插入 TIP_TEXT 消息 | |
| 577 | - ImGroupMessageDO tipMessage = new ImGroupMessageDO() | |
| 578 | - .setClientMessageId(IdUtil.fastSimpleUUID()) | |
| 579 | - .setSenderId(senderId).setGroupId(groupId) | |
| 580 | - .setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(content) | |
| 581 | - .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) | |
| 582 | - .setReceiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()); | |
| 583 | - groupMessageMapper.insert(tipMessage); | |
| 584 | - | |
| 585 | - // 2. 推送给指定用户 | |
| 586 | - imWebSocketService.sendGroupMessageAsync(memberUserIds, | |
| 587 | - ImGroupMessageDTO.ofSend(tipMessage)); | |
| 588 | - } | |
| 589 | - | |
| 590 | 585 | // ==================== 管理后台 ==================== |
| 591 | 586 | |
| 592 | 587 | @Override | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java
| ... | ... | @@ -5,6 +5,7 @@ import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.privates.I |
| 5 | 5 | import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageListReqVO; |
| 6 | 6 | import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO; |
| 7 | 7 | import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; |
| 8 | +import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO; | |
| 8 | 9 | |
| 9 | 10 | import java.util.List; |
| 10 | 11 | |
| ... | ... | @@ -16,7 +17,10 @@ import java.util.List; |
| 16 | 17 | public interface ImPrivateMessageService { |
| 17 | 18 | |
| 18 | 19 | /** |
| 19 | - * 发送私聊消息 | |
| 20 | + * 【用户调用】发送私聊消息 | |
| 21 | + * <p> | |
| 22 | + * 用户在 IM 客户端发送 TEXT / IMAGE 等消息时调用,含幂等、好友校验、敏感词、quote 解析等业务校验。 | |
| 23 | + * type 校验由 VO 层 {@code @InEnum} + {@code @AssertTrue} 完成(仅允许 normal 类型)。 | |
| 20 | 24 | * |
| 21 | 25 | * @param senderId 发送人编号 |
| 22 | 26 | * @param reqVO 发送请求 |
| ... | ... | @@ -25,6 +29,33 @@ public interface ImPrivateMessageService { |
| 25 | 29 | ImPrivateMessageDO sendPrivateMessage(Long senderId, ImPrivateMessageSendReqVO reqVO); |
| 26 | 30 | |
| 27 | 31 | /** |
| 32 | + * 【系统调用】发送私聊消息 | |
| 33 | + * | |
| 34 | + * @param senderId 发送人编号 | |
| 35 | + * @param dto 消息 DTO | |
| 36 | + * @return 构造的消息 DO(持久化时 id 已回填) | |
| 37 | + */ | |
| 38 | + ImPrivateMessageDO sendPrivateMessage(Long senderId, ImPrivateMessageSendDTO dto); | |
| 39 | + | |
| 40 | + /** | |
| 41 | + * 【系统调用】发送系统提示消息(TIP_TEXT) | |
| 42 | + * | |
| 43 | + * @param senderId 发送人编号 | |
| 44 | + * @param receiverId 接收人编号 | |
| 45 | + * @param content 提示文本 | |
| 46 | + */ | |
| 47 | + void sendTipPrivateMessage(Long senderId, Long receiverId, String content); | |
| 48 | + | |
| 49 | + /** | |
| 50 | + * 【用户调用】撤回私聊消息 | |
| 51 | + * | |
| 52 | + * @param userId 当前用户编号 | |
| 53 | + * @param messageId 消息编号 | |
| 54 | + * @return 撤回后的消息 | |
| 55 | + */ | |
| 56 | + ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId); | |
| 57 | + | |
| 58 | + /** | |
| 28 | 59 | * 拉取私聊消息(增量) |
| 29 | 60 | * |
| 30 | 61 | * @param userId 当前用户编号 |
| ... | ... | @@ -59,15 +90,6 @@ public interface ImPrivateMessageService { |
| 59 | 90 | Long getMaxReadMessageId(Long userId, Long peerId); |
| 60 | 91 | |
| 61 | 92 | /** |
| 62 | - * 撤回私聊消息 | |
| 63 | - * | |
| 64 | - * @param userId 当前用户编号 | |
| 65 | - * @param messageId 消息编号 | |
| 66 | - * @return 撤回后的消息 | |
| 67 | - */ | |
| 68 | - ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId); | |
| 69 | - | |
| 70 | - /** | |
| 71 | 93 | * 查询私聊历史消息(游标拉取) |
| 72 | 94 | * |
| 73 | 95 | * @param userId 当前用户编号 |
| ... | ... | @@ -76,17 +98,6 @@ public interface ImPrivateMessageService { |
| 76 | 98 | */ |
| 77 | 99 | List<ImPrivateMessageDO> getPrivateMessageList(Long userId, ImPrivateMessageListReqVO reqVO); |
| 78 | 100 | |
| 79 | - /** | |
| 80 | - * 发送系统提示消息(TIP_TEXT) | |
| 81 | - * <p> | |
| 82 | - * 跳过好友校验、敏感词过滤,用于系统自动生成的提示消息。 | |
| 83 | - * | |
| 84 | - * @param senderId 发送人编号 | |
| 85 | - * @param receiverId 接收人编号 | |
| 86 | - * @param content 提示文本 | |
| 87 | - */ | |
| 88 | - void sendTipPrivateMessage(Long senderId, Long receiverId, String content); | |
| 89 | - | |
| 90 | 101 | // ==================== 管理后台 ==================== |
| 91 | 102 | |
| 92 | 103 | /** | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java
| ... | ... | @@ -14,6 +14,7 @@ import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper; |
| 14 | 14 | import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; |
| 15 | 15 | import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; |
| 16 | 16 | import cn.iocoder.yudao.module.im.service.friend.ImFriendService; |
| 17 | +import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO; | |
| 17 | 18 | import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; |
| 18 | 19 | import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; |
| 19 | 20 | import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; |
| ... | ... | @@ -73,7 +74,6 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { |
| 73 | 74 | sensitiveWordService.validateText(reqVO.getContent()); |
| 74 | 75 | } |
| 75 | 76 | |
| 76 | - | |
| 77 | 77 | // 2.1 引用 quote 消息规范化 |
| 78 | 78 | reqVO.setContent(normalizeQuoteContent(reqVO, senderId)); |
| 79 | 79 | // 2.2 构建并保存消息 |
| ... | ... | @@ -84,10 +84,70 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { |
| 84 | 84 | // 3. WebSocket 异步推送(接收方 + 发送方多端同步) |
| 85 | 85 | ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message); |
| 86 | 86 | imWebSocketService.sendPrivateMessageAsync(message.getReceiverId(), websocketMessage); |
| 87 | - imWebSocketService.sendPrivateMessageAsync(message.getSenderId(), websocketMessage); | |
| 87 | + imWebSocketService.sendPrivateMessageAsync(senderId, websocketMessage); | |
| 88 | + return message; | |
| 89 | + } | |
| 90 | + | |
| 91 | + @Override | |
| 92 | + public ImPrivateMessageDO sendPrivateMessage(Long senderId, ImPrivateMessageSendDTO dto) { | |
| 93 | + // 1.1 content 序列化:null / String 透传,POJO 走 JSON | |
| 94 | + Object payload = dto.getContent(); | |
| 95 | + String contentString = payload == null || payload instanceof String | |
| 96 | + ? (String) payload | |
| 97 | + : JsonUtils.toJsonString(payload); | |
| 98 | + // 1.2 构建并保存消息 | |
| 99 | + ImPrivateMessageDO message = new ImPrivateMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) | |
| 100 | + .setSenderId(senderId).setReceiverId(dto.getReceiverId()) | |
| 101 | + .setType(dto.getType()).setContent(contentString) | |
| 102 | + .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()); | |
| 103 | + // 1.3 按 type.persistent 决定是否入库 | |
| 104 | + if (ImMessageTypeEnum.validate(dto.getType()).isPersistent()) { | |
| 105 | + privateMessageMapper.insert(message); | |
| 106 | + } | |
| 107 | + | |
| 108 | + // 2. WebSocket 异步推送(接收方 + 发送方多端同步) | |
| 109 | + ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message); | |
| 110 | + imWebSocketService.sendPrivateMessageAsync(dto.getReceiverId(), websocketMessage); | |
| 111 | + imWebSocketService.sendPrivateMessageAsync(senderId, websocketMessage); | |
| 88 | 112 | return message; |
| 89 | 113 | } |
| 90 | 114 | |
| 115 | + @Override | |
| 116 | + public void sendTipPrivateMessage(Long senderId, Long receiverId, String content) { | |
| 117 | + sendPrivateMessage(senderId, new ImPrivateMessageSendDTO().setReceiverId(receiverId) | |
| 118 | + .setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(content)); | |
| 119 | + } | |
| 120 | + | |
| 121 | + @Override | |
| 122 | + @Transactional(rollbackFor = Exception.class) | |
| 123 | + public ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId) { | |
| 124 | + // 1.1 校验消息存在 | |
| 125 | + ImPrivateMessageDO message = privateMessageMapper.selectById(messageId); | |
| 126 | + if (message == null) { | |
| 127 | + throw exception(MESSAGE_NOT_EXISTS); | |
| 128 | + } | |
| 129 | + // 1.2 只能撤回自己发送的消息 | |
| 130 | + if (ObjUtil.notEqual(message.getSenderId(), userId)) { | |
| 131 | + throw exception(MESSAGE_RECALL_DENIED); | |
| 132 | + } | |
| 133 | + // 1.3 不能重复撤回 | |
| 134 | + if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { | |
| 135 | + throw exception(MESSAGE_ALREADY_RECALLED); | |
| 136 | + } | |
| 137 | + // 1.4 只允许撤回限定时间内的消息 | |
| 138 | + if (message.getSendTime().plusMinutes(MESSAGE_RECALL_TIMEOUT_MINUTES).isBefore(LocalDateTime.now())) { | |
| 139 | + throw exception(MESSAGE_RECALL_TIMEOUT, MESSAGE_RECALL_TIMEOUT_MINUTES); | |
| 140 | + } | |
| 141 | + | |
| 142 | + // 2. 更新原消息状态为撤回 | |
| 143 | + privateMessageMapper.updateById(new ImPrivateMessageDO().setId(messageId) | |
| 144 | + .setStatus(ImMessageStatusEnum.RECALL.getStatus())); | |
| 145 | + | |
| 146 | + // 3. 发送撤回事件 | |
| 147 | + return sendPrivateMessage(userId, new ImPrivateMessageSendDTO().setReceiverId(message.getReceiverId()) | |
| 148 | + .setType(ImMessageTypeEnum.RECALL.getType()).setContent(new RecallMessage().setMessageId(messageId))); | |
| 149 | + } | |
| 150 | + | |
| 91 | 151 | /** |
| 92 | 152 | * 私聊引用消息规范化 |
| 93 | 153 | * |
| ... | ... | @@ -139,7 +199,6 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { |
| 139 | 199 | return messages; |
| 140 | 200 | } |
| 141 | 201 | |
| 142 | - // TODO DONE @AI:考虑到更稳妥,是不是前端传递 messageId?不然恰好发过来,然后读取了。【后续在优化】 | |
| 143 | 202 | @Override |
| 144 | 203 | public void readPrivateMessages(Long userId, Long receiverId, Long messageId) { |
| 145 | 204 | Assert.notNull(messageId, "已读消息编号不能为空"); |
| ... | ... | @@ -166,53 +225,12 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { |
| 166 | 225 | } |
| 167 | 226 | |
| 168 | 227 | @Override |
| 169 | - @Transactional(rollbackFor = Exception.class) | |
| 170 | - public ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId) { | |
| 171 | - // 1.1 校验消息存在 | |
| 172 | - ImPrivateMessageDO message = privateMessageMapper.selectById(messageId); | |
| 173 | - if (message == null) { | |
| 174 | - throw exception(MESSAGE_NOT_EXISTS); | |
| 175 | - } | |
| 176 | - // 1.2 只能撤回自己发送的消息 | |
| 177 | - if (ObjUtil.notEqual(message.getSenderId(), userId)) { | |
| 178 | - throw exception(MESSAGE_RECALL_DENIED); | |
| 179 | - } | |
| 180 | - // 1.3 不能重复撤回 | |
| 181 | - if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { | |
| 182 | - throw exception(MESSAGE_ALREADY_RECALLED); | |
| 183 | - } | |
| 184 | - // 1.4 只允许撤回限定时间内的消息 | |
| 185 | - if (message.getSendTime().plusMinutes(MESSAGE_RECALL_TIMEOUT_MINUTES).isBefore(LocalDateTime.now())) { | |
| 186 | - throw exception(MESSAGE_RECALL_TIMEOUT, MESSAGE_RECALL_TIMEOUT_MINUTES); | |
| 187 | - } | |
| 188 | - | |
| 189 | - // 2. 更新原消息状态为撤回 | |
| 190 | - privateMessageMapper.updateById(new ImPrivateMessageDO().setId(messageId) | |
| 191 | - .setStatus(ImMessageStatusEnum.RECALL.getStatus())); | |
| 192 | - | |
| 193 | - // 3. 插入一条 RECALL 消息作为撤回信号(content 为 RecallMessage 序列化,前端据此找到原消息更新状态) | |
| 194 | - RecallMessage recallContent = new RecallMessage().setMessageId(messageId); | |
| 195 | - ImPrivateMessageDO recallMessage = new ImPrivateMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) | |
| 196 | - .setSenderId(userId).setReceiverId(message.getReceiverId()) | |
| 197 | - .setType(ImMessageTypeEnum.RECALL.getType()).setContent(JsonUtils.toJsonString(recallContent)) | |
| 198 | - .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()); | |
| 199 | - privateMessageMapper.insert(recallMessage); | |
| 200 | - | |
| 201 | - // 4. 异步推送撤回信号(前端据此更新原消息状态 + 插入撤回提示) | |
| 202 | - ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(recallMessage); | |
| 203 | - imWebSocketService.sendPrivateMessageAsync(recallMessage.getReceiverId(), websocketMessage); | |
| 204 | - imWebSocketService.sendPrivateMessageAsync(recallMessage.getSenderId(), websocketMessage); | |
| 205 | - return recallMessage; | |
| 206 | - } | |
| 207 | - | |
| 208 | - @Override | |
| 209 | 228 | public List<ImPrivateMessageDO> getPrivateMessageList(Long userId, ImPrivateMessageListReqVO reqVO) { |
| 210 | 229 | return privateMessageMapper.selectHistoryList(userId, reqVO.getReceiverId(), reqVO.getMaxId(), reqVO.getLimit()); |
| 211 | 230 | } |
| 212 | 231 | |
| 213 | 232 | // ==================== 管理后台 ==================== |
| 214 | 233 | |
| 215 | - // DONE @AI:这些挪到 controller 拼接 VO | |
| 216 | 234 | @Override |
| 217 | 235 | public PageResult<ImPrivateMessageDO> getPrivateMessagePage(ImPrivateMessageManagerPageReqVO reqVO) { |
| 218 | 236 | return privateMessageMapper.selectPage(reqVO); |
| ... | ... | @@ -223,19 +241,4 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { |
| 223 | 241 | return privateMessageMapper.selectById(id); |
| 224 | 242 | } |
| 225 | 243 | |
| 226 | - @Override | |
| 227 | - public void sendTipPrivateMessage(Long senderId, Long receiverId, String content) { | |
| 228 | - // 1. 插入 TIP_TEXT 消息 | |
| 229 | - ImPrivateMessageDO tipMessage = new ImPrivateMessageDO() | |
| 230 | - .setClientMessageId(IdUtil.fastSimpleUUID()).setSenderId(senderId).setReceiverId(receiverId) | |
| 231 | - .setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(content) | |
| 232 | - .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()); | |
| 233 | - privateMessageMapper.insert(tipMessage); | |
| 234 | - | |
| 235 | - // 2. 推送给双方 | |
| 236 | - ImPrivateMessageDTO dto = ImPrivateMessageDTO.ofSend(tipMessage); | |
| 237 | - imWebSocketService.sendPrivateMessageAsync(receiverId, dto); | |
| 238 | - imWebSocketService.sendPrivateMessageAsync(senderId, dto); | |
| 239 | - } | |
| 240 | - | |
| 241 | 244 | } | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImGroupMessageSendDTO.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.im.service.message.dto; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; | |
| 4 | +import lombok.Data; | |
| 5 | + | |
| 6 | +import java.util.List; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * IM 群聊消息发送 DTO | |
| 10 | + * | |
| 11 | + * @author 芋道源码 | |
| 12 | + */ | |
| 13 | +@Data | |
| 14 | +public class ImGroupMessageSendDTO { | |
| 15 | + | |
| 16 | + /** | |
| 17 | + * 群编号 | |
| 18 | + */ | |
| 19 | + private Long groupId; | |
| 20 | + /** | |
| 21 | + * 消息类型 | |
| 22 | + * <p> | |
| 23 | + * 枚举 {@link ImMessageTypeEnum} | |
| 24 | + */ | |
| 25 | + private Integer type; | |
| 26 | + /** | |
| 27 | + * 消息内容 | |
| 28 | + * <p> | |
| 29 | + * 支持 String / POJO;非 String 时由 service 序列化为 JSON | |
| 30 | + */ | |
| 31 | + private Object content; | |
| 32 | + /** | |
| 33 | + * @ 目标用户编号列表 | |
| 34 | + */ | |
| 35 | + private List<Long> atUserIds; | |
| 36 | + /** | |
| 37 | + * 定向接收用户编号列表 | |
| 38 | + * <p> | |
| 39 | + * 为空表示全员可见 | |
| 40 | + */ | |
| 41 | + private List<Long> receiverUserIds; | |
| 42 | + /** | |
| 43 | + * 是否需要回执 | |
| 44 | + * <p> | |
| 45 | + * 缺省视为无需回执(false) | |
| 46 | + */ | |
| 47 | + private Boolean receipt; | |
| 48 | + | |
| 49 | +} | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImPrivateMessageSendDTO.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.im.service.message.dto; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; | |
| 4 | +import lombok.Data; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * IM 私聊消息发送 DTO | |
| 8 | + * | |
| 9 | + * @author 芋道源码 | |
| 10 | + */ | |
| 11 | +@Data | |
| 12 | +public class ImPrivateMessageSendDTO { | |
| 13 | + | |
| 14 | + /** | |
| 15 | + * 接收人编号 | |
| 16 | + */ | |
| 17 | + private Long receiverId; | |
| 18 | + /** | |
| 19 | + * 消息类型 | |
| 20 | + * <p> | |
| 21 | + * 枚举 {@link ImMessageTypeEnum} | |
| 22 | + */ | |
| 23 | + private Integer type; | |
| 24 | + /** | |
| 25 | + * 消息内容 | |
| 26 | + * <p> | |
| 27 | + * 支持 String / POJO;非 String 时由 service 序列化为 JSON | |
| 28 | + */ | |
| 29 | + private Object content; | |
| 30 | + | |
| 31 | +} | ... | ... |
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java
| ... | ... | @@ -16,9 +16,11 @@ import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; |
| 16 | 16 | import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; |
| 17 | 17 | import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; |
| 18 | 18 | import cn.iocoder.yudao.module.im.service.group.ImGroupService; |
| 19 | +import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; | |
| 19 | 20 | import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; |
| 20 | 21 | import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; |
| 21 | 22 | import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; |
| 23 | +import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage; | |
| 22 | 24 | import org.junit.jupiter.api.Test; |
| 23 | 25 | import org.mockito.ArgumentCaptor; |
| 24 | 26 | import org.mockito.InjectMocks; |
| ... | ... | @@ -26,11 +28,7 @@ import org.mockito.Mock; |
| 26 | 28 | import org.mockito.MockedStatic; |
| 27 | 29 | |
| 28 | 30 | import java.time.LocalDateTime; |
| 29 | -import java.util.Collection; | |
| 30 | -import java.util.HashMap; | |
| 31 | -import java.util.List; | |
| 32 | -import java.util.Map; | |
| 33 | -import java.util.Set; | |
| 31 | +import java.util.*; | |
| 34 | 32 | |
| 35 | 33 | import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; |
| 36 | 34 | import static org.junit.jupiter.api.Assertions.*; |
| ... | ... | @@ -907,18 +905,18 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 907 | 905 | verify(imWebSocketService).sendGroupMessageAsync(eq(5L), any(ImGroupMessageDTO.class)); |
| 908 | 906 | } |
| 909 | 907 | |
| 910 | - // ========== sendGroupMessageEvent:receiverUserIds 定向过滤 ========== | |
| 908 | + // ========== sendGroupMessage(senderId, dto):receiverUserIds 定向过滤 ========== | |
| 911 | 909 | |
| 912 | 910 | @Test |
| 913 | - public void testSendGroupMessageEvent_noReceiverUserIds_broadcastsToAllMembers() { | |
| 911 | + public void testSendGroupMessage_noReceiverUserIds_broadcastsToAllMembers() { | |
| 914 | 912 | // 准备:无定向 → 应发给所有 ENABLE 成员(含发送者自己,多端同步) |
| 915 | - ImGroupMessageDO message = ImGroupMessageDO.builder() | |
| 916 | - .id(100L).groupId(10L).senderId(1L) | |
| 917 | - .sendTime(LocalDateTime.now()).build(); | |
| 913 | + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() | |
| 914 | + .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType()) | |
| 915 | + .setContent("{\"messageId\":1}"); | |
| 918 | 916 | when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) |
| 919 | 917 | .thenReturn(List.of(1L, 2L, 3L, 4L)); |
| 920 | 918 | |
| 921 | - groupMessageService.sendGroupMessageEvent(message); | |
| 919 | + groupMessageService.sendGroupMessage(1L, dto); | |
| 922 | 920 | |
| 923 | 921 | verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection<Long> ids) -> |
| 924 | 922 | ids.size() == 4 && ids.contains(1L) && ids.contains(2L) |
| ... | ... | @@ -927,17 +925,17 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 927 | 925 | } |
| 928 | 926 | |
| 929 | 927 | @Test |
| 930 | - public void testSendGroupMessageEvent_withReceiverUserIds_onlyTargetedPlusSender() { | |
| 928 | + public void testSendGroupMessage_withReceiverUserIds_onlyTargetedPlusSender() { | |
| 931 | 929 | // 准备:定向给 {2,3},发送者 1;群成员 {1,2,3,4} |
| 932 | 930 | // 预期推送目标:{1,2,3}(发送者自己多端同步 + 定向列表);4 被过滤 |
| 933 | - ImGroupMessageDO message = ImGroupMessageDO.builder() | |
| 934 | - .id(100L).groupId(10L).senderId(1L) | |
| 935 | - .receiverUserIds(List.of(2L, 3L)) | |
| 936 | - .sendTime(LocalDateTime.now()).build(); | |
| 931 | + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() | |
| 932 | + .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType()) | |
| 933 | + .setContent("{\"messageId\":1}") | |
| 934 | + .setReceiverUserIds(List.of(2L, 3L)); | |
| 937 | 935 | when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) |
| 938 | 936 | .thenReturn(List.of(1L, 2L, 3L, 4L)); |
| 939 | 937 | |
| 940 | - groupMessageService.sendGroupMessageEvent(message); | |
| 938 | + groupMessageService.sendGroupMessage(1L, dto); | |
| 941 | 939 | |
| 942 | 940 | verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection<Long> ids) -> |
| 943 | 941 | ids.size() == 3 && ids.contains(1L) && ids.contains(2L) |
| ... | ... | @@ -946,23 +944,91 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 946 | 944 | } |
| 947 | 945 | |
| 948 | 946 | @Test |
| 949 | - public void testSendGroupMessageEvent_withReceiverUserIds_senderNotInGroup_senderStillExcluded() { | |
| 947 | + public void testSendGroupMessage_withReceiverUserIds_senderNotInGroup_senderStillExcluded() { | |
| 950 | 948 | // 边界:发送者不在群的 userId 列表里(理论上应先被 validateMemberInGroup 挡住,这里纯防御) |
| 951 | 949 | // 预期:仅定向用户可见 |
| 952 | - ImGroupMessageDO message = ImGroupMessageDO.builder() | |
| 953 | - .id(100L).groupId(10L).senderId(99L) | |
| 954 | - .receiverUserIds(List.of(2L, 3L)) | |
| 955 | - .sendTime(LocalDateTime.now()).build(); | |
| 950 | + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() | |
| 951 | + .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType()) | |
| 952 | + .setContent("{\"messageId\":1}") | |
| 953 | + .setReceiverUserIds(List.of(2L, 3L)); | |
| 956 | 954 | when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) |
| 957 | 955 | .thenReturn(List.of(1L, 2L, 3L, 4L)); |
| 958 | 956 | |
| 959 | - groupMessageService.sendGroupMessageEvent(message); | |
| 957 | + groupMessageService.sendGroupMessage(99L, dto); | |
| 960 | 958 | |
| 961 | 959 | verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection<Long> ids) -> |
| 962 | 960 | ids.size() == 2 && ids.contains(2L) && ids.contains(3L)), |
| 963 | 961 | any(ImGroupMessageDTO.class)); |
| 964 | 962 | } |
| 965 | 963 | |
| 964 | + // ========== sendGroupMessage(senderId, dto):helper 行为 ========== | |
| 965 | + | |
| 966 | + @Test | |
| 967 | + public void testSendGroupMessage_dto_persistsAndSerializesPojoContent() { | |
| 968 | + // 准备:persistent=true 类型 + POJO content | |
| 969 | + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() | |
| 970 | + .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType()) | |
| 971 | + .setContent(new RecallMessage().setMessageId(50L)); | |
| 972 | + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L)); | |
| 973 | + | |
| 974 | + groupMessageService.sendGroupMessage(1L, dto); | |
| 975 | + | |
| 976 | + // 断言:入库 + 系统字段兜底 + content 序列化为 JSON + receiptStatus=NO_RECEIPT | |
| 977 | + ArgumentCaptor<ImGroupMessageDO> captor = ArgumentCaptor.forClass(ImGroupMessageDO.class); | |
| 978 | + verify(groupMessageMapper).insert(captor.capture()); | |
| 979 | + ImGroupMessageDO message = captor.getValue(); | |
| 980 | + assertEquals(1L, message.getSenderId()); | |
| 981 | + assertEquals(10L, message.getGroupId()); | |
| 982 | + assertEquals(ImMessageTypeEnum.RECALL.getType(), message.getType()); | |
| 983 | + assertEquals("{\"messageId\":50}", message.getContent()); | |
| 984 | + assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), message.getStatus()); | |
| 985 | + assertEquals(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), message.getReceiptStatus()); | |
| 986 | + assertNotNull(message.getClientMessageId()); | |
| 987 | + assertNotNull(message.getSendTime()); | |
| 988 | + } | |
| 989 | + | |
| 990 | + @Test | |
| 991 | + public void testSendGroupMessage_dto_receiptPending() { | |
| 992 | + // 准备:dto.receipt=true → receiptStatus=PENDING | |
| 993 | + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() | |
| 994 | + .setGroupId(10L).setType(ImMessageTypeEnum.RECALL.getType()) | |
| 995 | + .setContent("{\"messageId\":50}").setReceipt(true); | |
| 996 | + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L)); | |
| 997 | + | |
| 998 | + groupMessageService.sendGroupMessage(1L, dto); | |
| 999 | + | |
| 1000 | + ArgumentCaptor<ImGroupMessageDO> captor = ArgumentCaptor.forClass(ImGroupMessageDO.class); | |
| 1001 | + verify(groupMessageMapper).insert(captor.capture()); | |
| 1002 | + assertEquals(ImGroupMessageReceiptStatusEnum.PENDING.getStatus(), captor.getValue().getReceiptStatus()); | |
| 1003 | + } | |
| 1004 | + | |
| 1005 | + @Test | |
| 1006 | + public void testSendGroupMessage_dto_nonPersistentTypeNotInserted() { | |
| 1007 | + // 准备:persistent=false 类型(GROUP_CREATE)→ 不入库,仅推送 | |
| 1008 | + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() | |
| 1009 | + .setGroupId(10L).setType(ImMessageTypeEnum.GROUP_CREATE.getType()); | |
| 1010 | + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L, 2L)); | |
| 1011 | + | |
| 1012 | + groupMessageService.sendGroupMessage(1L, dto); | |
| 1013 | + | |
| 1014 | + verify(groupMessageMapper, never()).insert(any(ImGroupMessageDO.class)); | |
| 1015 | + verify(imWebSocketService).sendGroupMessageAsync(anyCollection(), any(ImGroupMessageDTO.class)); | |
| 1016 | + } | |
| 1017 | + | |
| 1018 | + @Test | |
| 1019 | + public void testSendGroupMessage_threeArg_explicitTargetsBypassActiveMembers() { | |
| 1020 | + // 准备:调用方传入显式 targets(覆盖跨成员 disable 边界场景:解散 / 退群 / 踢人) | |
| 1021 | + Set<Long> targets = Set.of(1L, 2L, 3L); | |
| 1022 | + ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO() | |
| 1023 | + .setGroupId(10L).setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent("解散通知"); | |
| 1024 | + | |
| 1025 | + groupMessageService.sendGroupMessage(1L, targets, dto); | |
| 1026 | + | |
| 1027 | + // 断言:不读取 active members,按调用方 targets 推送 | |
| 1028 | + verify(groupMemberService, never()).getActiveGroupMemberUserIdsByGroupId(anyLong()); | |
| 1029 | + verify(imWebSocketService).sendGroupMessageAsync(eq(targets), any(ImGroupMessageDTO.class)); | |
| 1030 | + } | |
| 1031 | + | |
| 966 | 1032 | // ========== sendTipGroupMessage ========== |
| 967 | 1033 | |
| 968 | 1034 | @Test | ... | ... |
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java
| ... | ... | @@ -10,8 +10,10 @@ import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; |
| 10 | 10 | import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; |
| 11 | 11 | import cn.iocoder.yudao.module.im.service.friend.ImFriendService; |
| 12 | 12 | import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; |
| 13 | +import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO; | |
| 13 | 14 | import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; |
| 14 | 15 | import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; |
| 16 | +import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage; | |
| 15 | 17 | import org.junit.jupiter.api.Test; |
| 16 | 18 | import org.mockito.ArgumentCaptor; |
| 17 | 19 | import org.mockito.InjectMocks; |
| ... | ... | @@ -333,6 +335,46 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { |
| 333 | 335 | assertNull(result); |
| 334 | 336 | } |
| 335 | 337 | |
| 338 | + // ========== sendPrivateMessage(senderId, dto):helper 行为 ========== | |
| 339 | + | |
| 340 | + @Test | |
| 341 | + public void testSendPrivateMessage_dto_persistsAndSerializesPojoContent() { | |
| 342 | + // 准备:persistent=true 类型 + POJO content | |
| 343 | + ImPrivateMessageSendDTO dto = new ImPrivateMessageSendDTO() | |
| 344 | + .setReceiverId(2L).setType(ImMessageTypeEnum.RECALL.getType()) | |
| 345 | + .setContent(new RecallMessage().setMessageId(50L)); | |
| 346 | + | |
| 347 | + privateMessageService.sendPrivateMessage(1L, dto); | |
| 348 | + | |
| 349 | + // 断言:入库 + 系统字段兜底 + content 序列化为 JSON | |
| 350 | + ArgumentCaptor<ImPrivateMessageDO> captor = ArgumentCaptor.forClass(ImPrivateMessageDO.class); | |
| 351 | + verify(privateMessageMapper).insert(captor.capture()); | |
| 352 | + ImPrivateMessageDO message = captor.getValue(); | |
| 353 | + assertEquals(1L, message.getSenderId()); | |
| 354 | + assertEquals(2L, message.getReceiverId()); | |
| 355 | + assertEquals(ImMessageTypeEnum.RECALL.getType(), message.getType()); | |
| 356 | + assertEquals("{\"messageId\":50}", message.getContent()); | |
| 357 | + assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), message.getStatus()); | |
| 358 | + assertNotNull(message.getClientMessageId()); | |
| 359 | + assertNotNull(message.getSendTime()); | |
| 360 | + // 断言:sender + receiver 双端推送 | |
| 361 | + verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); | |
| 362 | + verify(imWebSocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); | |
| 363 | + } | |
| 364 | + | |
| 365 | + @Test | |
| 366 | + public void testSendPrivateMessage_dto_nonPersistentTypeNotInserted() { | |
| 367 | + // 准备:persistent=false 类型(FRIEND_ADD)→ 不入库,仅推送 | |
| 368 | + ImPrivateMessageSendDTO dto = new ImPrivateMessageSendDTO() | |
| 369 | + .setReceiverId(2L).setType(ImMessageTypeEnum.FRIEND_ADD.getType()); | |
| 370 | + | |
| 371 | + privateMessageService.sendPrivateMessage(1L, dto); | |
| 372 | + | |
| 373 | + verify(privateMessageMapper, never()).insert(any(ImPrivateMessageDO.class)); | |
| 374 | + verify(imWebSocketService).sendPrivateMessageAsync(eq(1L), any(ImPrivateMessageDTO.class)); | |
| 375 | + verify(imWebSocketService).sendPrivateMessageAsync(eq(2L), any(ImPrivateMessageDTO.class)); | |
| 376 | + } | |
| 377 | + | |
| 336 | 378 | // ========== sendTipPrivateMessage ========== |
| 337 | 379 | |
| 338 | 380 | @Test | ... | ... |