Compare commits

..

17 Commits

Author SHA1 Message Date
9709621648 merge:合并 master-jdk17 到 IM 开发分支 2026-06-19 18:23:44 -07:00
9ed7c050b0 feat(im):优化已读上报、群详情缓存与 RTC 通话状态
- 已读上报增加本地读位置覆盖判断,避免切换会话和当前会话自动已读时重复调用 read 接口
- 标记会话已读时同步推进本地 read 游标并写入 IndexedDB,接口失败仅记录日志
- 缓存私聊对方 maxReadMessageId,并在状态补拉、回执更新和退出 IM 时维护缓存
- 增加群详情 infoLoaded 内存标记,减少切群时重复拉取群详情,手动刷新和关键通知仍强制刷新
- 同步 GROUP_INFO_UPDATE 的 joinApproval,避免群审批配置在前端缓存中陈旧
- 优化群通话胶囊条状态,记录 participantsLoaded,按需补齐参与者并在通话无人时移除胶囊
- RTC_CALL_START 生成群通话最小胶囊条,后续由参与者事件和 getActiveCall 补齐
- 退出 IM 时清理 RTC 状态和群通话缓存
- Vben antd/antd-next 调整媒体元素为函数 ref,修复 MediaStream 与元素挂载时序问题
- 修复 Vben 消息历史弹窗回调类型标注
2026-06-19 10:05:37 -07:00
79de92333f fix(im): 将频道消息的 pull 改成 pullChannelMessageList 2026-06-18 19:58:42 -07:00
773d094144 fix(im): 修复群备注首屏展示和聊天列表名称覆盖
- 后端群 VO 返回当前用户维度的 groupRemark 和 silent
- 群列表构建时通过成员关系回填个人群设置,并继续仅对有效成员回填置顶消息
- Vue3 群列表同步时以接口返回的个人群设置为准,只保留成员缓存
- 会话名写入入口统一使用 getGroupDisplayName,避免群备注被原群名覆盖
- 空群头像且成员未加载时异步预拉群成员,用于合成群头像
- 启用 IM Maven 模块和 yudao-server 对 IM 模块的依赖
2026-06-18 08:59:05 -07:00
a0be8e2907 fix(im):修复同意好友申请误报用户不存在
- 移除 agreeFriendRequest 中对申请双方的重复 validateUserList 校验
- 保留申请存在、未处理、操作人必须为接收方的校验,避免削弱处理权限边界
- 调整单测,断言同意好友申请不再触发双方用户复验
2026-06-18 07:38:33 -07:00
716738ac5d fix(im):群昵称修改改为静默同步
- 后端将 GROUP_MEMBER_NICKNAME_UPDATE 改为非持久化事件,避免写入群聊历史消息
- 保留群昵称变更的 WebSocket 在线同步,继续更新成员 displayUserName
- Vue3 聊天侧栏从当前群成员的 displayUserName 回填「我在本群的昵称」
- Vue3 WebSocket 收到 GROUP_MEMBER_NICKNAME_UPDATE 时只同步 groupStore,不再插入消息列表
- 补充后端单测,覆盖群昵称变更事件不入库但仍推送
2026-06-18 06:57:05 -07:00
92a993b283 test:优化 IM 单测注释风格
- 统一 IM 单测类注释为 {@link Xxx} 的单元测试
- 保留方法分组注释,收敛不符合规范的分组文案
- 删除少量冗余字段说明注释
2026-06-18 03:45:53 -07:00
bca66fee74 fix(im): 修复历史退群群未读和成员加载问题
- 已读上报使用会话末条消息编号兜底
- 历史退群群不再请求群成员列表
- 群聊 read 放开当前成员校验,保留可见性校验
- Vben 群详情补齐 joinStatus
- 补充退群群已读边界测试
2026-06-17 01:38:53 +08:00
4f9dc0e426 !1559 同步代码
Merge pull request !1559 from 芋道源码/master-jdk17
2026-06-16 05:39:51 +00:00
3e38e77ee6 refactor: 扁平化 IM WebSocket 通知推送 API
- 将 WebSocket 推送入口统一为 userId/userIds + conversationType + contentType + payload
- 移除业务侧 ImNotificationWebSocketDTO 构造和无会话专用发送入口
- 收敛私聊、群聊、频道、好友、加群申请、RTC 通知调用路径
- 精简 ImNotificationWebSocketDTO,仅保留统一外壳字段
- 保留群消息 payload 的 receiptStatus、readCount、receiverUserIds
- 更新相关单元测试,覆盖群消息通知 payload 字段
2026-06-16 11:39:05 +08:00
3dd7179393 refactor: 扁平化 IM WebSocket 通知推送 API
- 将 WebSocket 推送入口统一为 userId/userIds + conversationType + contentType + payload
- 移除业务侧 ImNotificationWebSocketDTO 构造和无会话专用发送入口
- 收敛私聊、群聊、频道、好友、加群申请、RTC 通知调用路径
- 精简 ImNotificationWebSocketDTO,仅保留统一外壳字段
- 保留群消息 payload 的 receiptStatus、readCount、receiverUserIds
- 更新相关单元测试,覆盖群消息通知 payload 字段
2026-06-16 11:38:58 +08:00
210a8d5af6 feat(im): 增强消息拉取与状态补偿可靠性
- 新增会话读位置持久化接口与前端同步逻辑
- 增加好友、好友申请、加群申请的增量拉取补偿
- 统一前端 pull 编排,增加回扫窗口、落库等待和账号切换守卫
- 调整群成员为按群懒加载缓存,并移除全局成员增量链路
- 修复消息落库、读位置补偿、READ 事件乱序下的未读状态一致性
- 完善群申请红点快照刷新和管理员角色变化补偿
- 更新消息存储设计与修复记录文档
2026-06-15 08:26:32 +08:00
927b64fc4e feat(im): 统一消息读位置和回执状态模型
- 新增 im_conversation_read 会话读位置表,并补充消息存储推拉相关索引
- 群消息固化 receiver_user_ids 快照,按可见成员快照拉取和统计回执
- 统一消息 status 为 NORMAL/RECALL,新增私聊 receipt_status 并复用统一回执状态
- 前端改用 receiptStatus 展示私聊已读、群回执和频道已读态
- 补齐私聊、群聊、频道 WebSocket 已读同步和离线补偿逻辑
- 更新 IM 消息状态、回执状态字典和管理后台展示
- 调整相关单测和测试建表脚本
2026-06-14 09:34:17 +08:00
4b27620e86 feat: 完善 IM 群历史消息拉取与历史群前端门控
- 后端群列表返回历史群成员状态 joinStatus,用于区分当前群和历史退群群
- 群消息拉取支持基于 receiver_user_ids 快照过滤可见消息
- 补充群消息 pull、群成员候选、私聊 pull 相关索引与 SQL 脚本
- 前端接入 joinStatus,并封装历史退群群判断
- 历史退群群禁发、隐藏群操作入口,并从通讯录、转发、推荐名片候选中排除
- 保留历史群会话展示能力,用于查看退群前历史消息
2026-06-14 02:01:10 +08:00
6d8ad0c374 feat(im): 优化消息拉取与已读位置存储
- 固化群消息 receiver_user_ids 快照,并按快照过滤离线拉取和历史查询
- 新增 im_conversation_read,统一私聊、群聊、频道的已读位置存储
- 移除群聊、频道已读位置的 Redis 存储
- 补充群消息、群成员、私聊拉取相关索引
- 调整群回执统计、引用消息可见性和退群历史查询口径
- 补充消息拉取与会话已读位置相关测试
2026-06-13 23:25:46 +08:00
3fe400a5cf !1557 同步最新代码到 im 分支
Merge pull request !1557 from 芋道源码/master-jdk17
2026-06-13 12:19:20 +00:00
120e4415a9 feat(im):清理多余的 TODO 2026-06-13 18:05:19 +08:00
158 changed files with 2947 additions and 2688 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,51 @@
package cn.iocoder.yudao.module.im.controller.admin.conversation;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.im.controller.admin.conversation.vo.ImConversationReadRespVO;
import cn.iocoder.yudao.module.im.dal.dataobject.conversation.ImConversationReadDO;
import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId;
@Tag(name = "管理后台 - IM 会话读位置")
@RestController
@RequestMapping("/im/conversation-read")
@Validated
public class ImConversationReadController {
@Resource
private ImConversationReadService conversationReadService;
@GetMapping("/pull")
@Operation(summary = "增量拉取当前用户的会话读位置(重连 / 离线补偿)")
@Parameters({
@Parameter(name = "lastUpdateTime", description = "上次拉取到的最新更新时间(毫秒时间戳);首次拉取不传"),
@Parameter(name = "lastId", description = "上次拉取到的最后一条记录 id首次拉取不传"),
@Parameter(name = "limit", description = "单次拉取条数", required = true)
})
public CommonResult<List<ImConversationReadRespVO>> pullMyConversationRead(
@RequestParam(value = "lastUpdateTime", required = false) Long lastUpdateTime,
@RequestParam(value = "lastId", required = false) Long lastId,
@RequestParam("limit") @Min(1) @Max(200) Integer limit) {
List<ImConversationReadDO> list = conversationReadService.pullConversationReadList(
getLoginUserId(), lastUpdateTime, lastId, limit);
return success(BeanUtils.toBean(list, ImConversationReadRespVO.class));
}
}

View File

@ -0,0 +1,32 @@
package cn.iocoder.yudao.module.im.controller.admin.conversation.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* IM 会话读位置 Response VO
*
* @author 芋道源码
*/
@Schema(description = "管理后台 - IM 会话读位置 Response VO")
@Data
public class ImConversationReadRespVO {
@Schema(description = "读位置编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Long id;
@Schema(description = "会话类型", example = "1")
private Integer conversationType; // 参见 ImConversationTypeEnum 枚举
@Schema(description = "目标编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048")
private Long targetId;
@Schema(description = "最大已读消息编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "4096")
private Long messageId;
@Schema(description = "最近更新时间(增量拉取游标用)")
private LocalDateTime updateTime;
}

View File

@ -17,6 +17,8 @@ import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@ -46,11 +48,25 @@ public class ImFriendController {
@GetMapping("/list")
@Operation(summary = "获得当前登录用户的好友列表")
public CommonResult<List<ImFriendRespVO>> getMyFriendList() {
// 含 DISABLE 历史好友:保留给前端展示「已删除好友」的历史对话信息;前端按 status 决定会话级联清理
List<ImFriendDO> friends = friendService.getFriendList(getLoginUserId());
return success(buildFriendRespVOList(friends));
}
@GetMapping("/pull")
@Operation(summary = "增量拉取当前用户的好友关系(重连 / 离线补偿)")
@Parameters({
@Parameter(name = "lastUpdateTime", description = "上次拉取到的最新更新时间(毫秒时间戳);首次拉取不传"),
@Parameter(name = "lastId", description = "上次拉取到的最后一条记录 id首次拉取不传"),
@Parameter(name = "limit", description = "单次拉取条数", required = true)
})
public CommonResult<List<ImFriendRespVO>> pullMyFriendList(
@RequestParam(value = "lastUpdateTime", required = false) Long lastUpdateTime,
@RequestParam(value = "lastId", required = false) Long lastId,
@RequestParam("limit") @Min(1) @Max(200) Integer limit) {
List<ImFriendDO> list = friendService.pullFriendList(getLoginUserId(), lastUpdateTime, lastId, limit);
return success(buildFriendRespVOList(list));
}
@GetMapping("/get")
@Operation(summary = "获得好友详情")
@Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048")
@ -62,8 +78,8 @@ public class ImFriendController {
@DeleteMapping("/delete")
@Operation(summary = "删除好友(单向软删除)")
@Parameters({
@Parameter(description = "好友的用户编号", required = true, example = "2048"),
@Parameter(description = "是否级联清理本端相关数据(如私聊会话)")
@Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048"),
@Parameter(name = "clear", description = "是否级联清理本端相关数据(如私聊会话)")
})
public CommonResult<Boolean> deleteFriend(
@RequestParam("friendUserId") @NotNull(message = "好友用户编号不能为空") Long friendUserId,

View File

@ -13,6 +13,7 @@ import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
@ -79,15 +80,32 @@ public class ImFriendRequestController {
@GetMapping("/list")
@Operation(summary = "查询「我相关」的好友申请列表(游标分页:传 maxId 加载更多)")
@Parameters({
@Parameter(name = "maxId", description = "当前列表最旧记录的 id首页不传"),
@Parameter(name = "limit", description = "单次拉取条数", required = true)
})
public CommonResult<List<ImFriendRequestRespVO>> getMyFriendRequestList(
@Parameter(description = "当前列表最旧记录的 id首页不传")
@RequestParam(value = "maxId", required = false) Long maxId,
@Parameter(description = "单次拉取条数", required = true)
@RequestParam("limit") @Min(1) @Max(200) Integer limit) {
List<ImFriendRequestDO> list = friendRequestService.getMyFriendRequestList(getLoginUserId(), maxId, limit);
return success(buildList(list));
}
@GetMapping("/pull")
@Operation(summary = "增量拉取「我相关」的好友申请(重连 / 离线补偿)")
@Parameters({
@Parameter(name = "lastUpdateTime", description = "上次拉取到的最新更新时间(毫秒时间戳);首次拉取不传"),
@Parameter(name = "lastId", description = "上次拉取到的最后一条记录 id首次拉取不传"),
@Parameter(name = "limit", description = "单次拉取条数", required = true)
})
public CommonResult<List<ImFriendRequestRespVO>> pullMyFriendRequestList(
@RequestParam(value = "lastUpdateTime", required = false) Long lastUpdateTime,
@RequestParam(value = "lastId", required = false) Long lastId,
@RequestParam("limit") @Min(1) @Max(200) Integer limit) {
List<ImFriendRequestDO> list = friendRequestService.pullFriendRequestList(getLoginUserId(), lastUpdateTime, lastId, limit);
return success(buildList(list));
}
@GetMapping("/get")
@Operation(summary = "按 id 单查「我相关」的申请记录带越权过滤WebSocket 通知到达后用)")
@Parameter(name = "id", description = "申请记录编号", required = true)

View File

@ -47,6 +47,9 @@ public class ImFriendRespVO {
@Schema(description = "删除好友时间")
private LocalDateTime deleteTime;
@Schema(description = "最近更新时间(增量拉取游标用)")
private LocalDateTime updateTime;
// ========== 下面是聚合字段,方便前端显示 ==========
@Schema(description = "好友昵称(实时聚合自 AdminUser", example = "芋道")

View File

@ -41,6 +41,9 @@ public class ImFriendRequestRespVO {
@Schema(description = "申请创建时间")
private LocalDateTime createTime;
@Schema(description = "最近更新时间(增量拉取游标用)")
private LocalDateTime updateTime;
// ========== 下面是聚合字段,方便前端显示 ==========
@Schema(description = "发起方昵称(实时聚合自 AdminUser", example = "芋道")

View File

@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.im.controller.admin.group;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.im.controller.admin.group.vo.*;
import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberInviteReqVO;
@ -187,8 +188,10 @@ public class ImGroupController {
return Collections.emptyList();
}
// 仅当前用户是有效成员的群才允许回填置顶消息
Set<Long> activeGroupIds = convertSet(
groupMemberService.getActiveGroupMemberListByUserId(loginUserId), ImGroupMemberDO::getGroupId);
Map<Long, ImGroupMemberDO> memberMap = convertMap(
groupMemberService.getGroupMemberListByUserId(loginUserId), ImGroupMemberDO::getGroupId);
Set<Long> activeGroupIds = convertSet(memberMap.values(), ImGroupMemberDO::getGroupId,
member -> CommonStatusEnum.ENABLE.getStatus().equals(member.getStatus()));
Set<Long> allMessageIds = convertSetByFlatMap(groups, group -> activeGroupIds.contains(group.getId())
? CollUtil.emptyIfNull(group.getPinnedMessageIds()).stream() : Stream.empty());
Map<Long, ImGroupMessageDO> messageMap = groupMessageService.getGroupMessageMap(allMessageIds);
@ -198,6 +201,8 @@ public class ImGroupController {
// 标记登录用户在该群的成员状态,供前端区分当前群与历史退群
boolean joined = activeGroupIds.contains(group.getId());
groupVO.setJoinStatus(joined ? CommonStatusEnum.ENABLE.getStatus() : CommonStatusEnum.DISABLE.getStatus());
MapUtils.findAndThen(memberMap, group.getId(), member ->
groupVO.setGroupRemark(member.getGroupRemark()).setSilent(Boolean.TRUE.equals(member.getSilent())));
if (!joined || CollUtil.isEmpty(group.getPinnedMessageIds())) {
return groupVO;
}

View File

@ -21,6 +21,7 @@ import jakarta.validation.Valid;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@ -88,7 +89,7 @@ public class ImGroupMemberController {
}
@GetMapping("/list")
@Operation(summary = "获得指定群的成员列表")
@Operation(summary = "获得指定群的成员列表(按群全量拉取,作为前端本地成员 cache 的完整基线)")
@Parameter(name = "groupId", description = "群编号", required = true, example = "1024")
public CommonResult<List<ImGroupMemberRespVO>> getGroupMemberList(@RequestParam("groupId") Long groupId) {
// 1.1 查询群成员列表(包含 DISABLE 已退群的成员,不按时间过滤)
@ -101,16 +102,30 @@ public class ImGroupMemberController {
throw exception(GROUP_MEMBER_NOT_IN_GROUP);
}
// 2.批量聚合 AdminUser 信息(昵称 / 头像)
// 2. 批量聚合昵称 / 头像,并对非本人置空私人字段
return success(buildGroupMemberRespVOList(members));
}
// ========== 私有方法VO 组装 ==========
/**
* 群成员列表批量转 VO 关联回填昵称 头像,并对非本人置空私人字段
*/
private List<ImGroupMemberRespVO> buildGroupMemberRespVOList(List<ImGroupMemberDO> members) {
if (CollUtil.isEmpty(members)) {
return Collections.emptyList();
}
Long loginUserId = getLoginUserId();
// 批量聚合 AdminUser 信息(昵称 头像),避免 N+1
Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(
convertList(members, ImGroupMemberDO::getUserId));
return success(convertList(members, m -> {
ImGroupMemberRespVO vo = BeanUtils.toBean(m, ImGroupMemberRespVO.class);
MapUtils.findAndThen(userMap, m.getUserId(), user ->
return convertList(members, member -> {
ImGroupMemberRespVO vo = BeanUtils.toBean(member, ImGroupMemberRespVO.class);
MapUtils.findAndThen(userMap, member.getUserId(), user ->
vo.setNickname(user.getNickname()).setAvatar(user.getAvatar()));
hidePrivateFieldsIfNotSelf(vo, m.getUserId(), loginUserId);
hidePrivateFieldsIfNotSelf(vo, member.getUserId(), loginUserId);
return vo;
}));
});
}
/**

View File

@ -19,9 +19,12 @@ import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.springframework.validation.annotation.Validated;
@ -88,6 +91,22 @@ public class ImGroupRequestController {
return success(buildVOList(list));
}
@GetMapping("/pull")
@Operation(summary = "增量拉取「我管理的群」下的加群申请(重连 / 离线补偿)")
@Parameters({
@Parameter(name = "lastUpdateTime", description = "上次拉取到的最新更新时间(毫秒时间戳);首次拉取不传"),
@Parameter(name = "lastId", description = "上次拉取到的最后一条记录 id首次拉取不传"),
@Parameter(name = "limit", description = "单次拉取条数", required = true)
})
public CommonResult<List<ImGroupRequestRespVO>> pullMyGroupRequestList(
@RequestParam(value = "lastUpdateTime", required = false) Long lastUpdateTime,
@RequestParam(value = "lastId", required = false) Long lastId,
@RequestParam("limit") @Min(1) @Max(200) Integer limit) {
List<ImGroupRequestDO> list = groupRequestService.pullGroupRequestList(
getLoginUserId(), lastUpdateTime, lastId, limit);
return success(buildVOList(list));
}
@GetMapping("/list-by-group")
@Operation(summary = "查询指定群下的全部加群申请(含已处理);仅群主 / 管理员可查")
@Parameter(name = "groupId", description = "群编号", required = true, example = "1024")

View File

@ -50,6 +50,12 @@ public class ImGroupRespVO {
@Schema(description = "当前登录用户在该群的成员状态:在群 / 已退群(历史退群群仍返回,供前端展示离线消息的群名 / 头像)")
private Integer joinStatus; // 参见 CommonStatusEnum 枚举
@Schema(description = "当前登录用户对该群的备注")
private String groupRemark;
@Schema(description = "当前登录用户是否免打扰")
private Boolean silent;
@Schema(description = "群置顶消息列表,按 pin 顺序(最先置顶的在前);非该群有效成员时为空")
private List<ImGroupMessageRespVO> pinnedMessages;

View File

@ -42,6 +42,9 @@ public class ImGroupRequestRespVO {
@Schema(description = "申请创建时间")
private LocalDateTime createTime;
@Schema(description = "申请更新时间;增量拉取游标")
private LocalDateTime updateTime;
// ========== 下面是聚合字段,方便前端显示 ==========
@Schema(description = "申请人 / 被邀请人昵称(实时聚合自 AdminUser", example = "芋道")

View File

@ -75,7 +75,6 @@ public class ImChannelManagerController {
@GetMapping("/simple-list")
@Operation(summary = "获得启用的频道精简列表;前端表单选择频道时调用")
public CommonResult<List<ImChannelRespVO>> getSimpleChannelList() {
// TODO DONE @AIgetChannelListByStatus 统一命名
List<ImChannelDO> list = channelService.getChannelListByStatus(CommonStatusEnum.ENABLE.getStatus());
return success(BeanUtils.toBean(list, ImChannelRespVO.class));
}

View File

@ -29,7 +29,7 @@ public class ImChannelMessageRespVO {
private String materialCoverUrl;
@Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "125")
private Integer type; // 参见 ImMessageTypeEnum 枚举类
private Integer type; // 参见 ImContentTypeEnum 枚举类
@Schema(description = "消息内容payload JSON 快照")
private String content;

View File

@ -22,7 +22,7 @@ public class ImGroupMessageManagerPageReqVO extends PageParam {
private Long senderId;
@Schema(description = "消息类型", example = "1")
private Integer type; // 参见 ImMessageTypeEnum 枚举类
private Integer type; // 参见 ImContentTypeEnum 枚举类
@Schema(description = "消息内容", example = "你好")
private String content;

View File

@ -29,7 +29,7 @@ public class ImGroupMessageManagerRespVO {
private String senderNickname;
@Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Integer type; // 参见 ImMessageTypeEnum 枚举类
private Integer type; // 参见 ImContentTypeEnum 枚举类
@Schema(description = "消息内容JSON 格式)", requiredMode = Schema.RequiredMode.REQUIRED)
private String content;
@ -43,7 +43,7 @@ public class ImGroupMessageManagerRespVO {
private List<String> atUserNicknames;
@Schema(description = "回执状态", example = "0")
private Integer receiptStatus; // 参见 ImGroupMessageReceiptStatusEnum 枚举类
private Integer receiptStatus; // 参见 ImMessageReceiptStatusEnum 枚举类
@Schema(description = "发送时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime sendTime;

View File

@ -22,7 +22,7 @@ public class ImPrivateMessageManagerPageReqVO extends PageParam {
private Long receiverId;
@Schema(description = "消息类型", example = "1")
private Integer type; // 参见 ImMessageTypeEnum 枚举类
private Integer type; // 参见 ImContentTypeEnum 枚举类
@Schema(description = "消息内容", example = "你好")
private String content;

View File

@ -28,7 +28,7 @@ public class ImPrivateMessageManagerRespVO {
private String receiverNickname;
@Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Integer type; // 参见 ImMessageTypeEnum 枚举类
private Integer type; // 参见 ImContentTypeEnum 枚举类
@Schema(description = "消息内容JSON 格式)", requiredMode = Schema.RequiredMode.REQUIRED)
private String content;
@ -36,6 +36,9 @@ public class ImPrivateMessageManagerRespVO {
@Schema(description = "消息状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
private Integer status; // 参见 ImMessageStatusEnum 枚举类
@Schema(description = "回执状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
private Integer receiptStatus; // 参见 ImMessageReceiptStatusEnum 枚举类
@Schema(description = "发送时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime sendTime;

View File

@ -112,7 +112,7 @@ public class ImStatisticsManagerController {
}
@GetMapping("/message-type-distribution")
@Operation(summary = "获得消息类型分布(最近 30 天)")
@Operation(summary = "获得内容类型分布(最近 30 天)")
@PreAuthorize("@ss.hasPermission('im:manager:statistics:query')")
public CommonResult<List<ImStatisticsManagerMessageTypeRespVO>> getMessageTypeDistribution() {
LocalDateTime endTime = LocalDate.now().plusDays(1).atStartOfDay();

View File

@ -3,12 +3,12 @@ package cn.iocoder.yudao.module.im.controller.admin.manager.statistics.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - IM 数据看板消息类型分布项 Response VO")
@Schema(description = "管理后台 - IM 数据看板内容类型分布项 Response VO")
@Data
public class ImStatisticsManagerMessageTypeRespVO {
@Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
private Integer type; // 参见 ImMessageTypeEnum 枚举类
private Integer type; // 参见 ImContentTypeEnum 枚举类
@Schema(description = "消息数", requiredMode = Schema.RequiredMode.REQUIRED, example = "8000")
private Long value;

View File

@ -5,7 +5,7 @@ import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.im.controller.admin.message.vo.channel.ImChannelMessagePullRespVO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO;
import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum;
import cn.iocoder.yudao.module.im.service.message.ImChannelMessageService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@ -40,25 +40,25 @@ public class ImChannelMessageController {
@GetMapping("/pull")
@Operation(summary = "拉取频道消息(离线增量);按 minId 游标分页")
public CommonResult<List<ImChannelMessagePullRespVO>> pull(
public CommonResult<List<ImChannelMessagePullRespVO>> pullChannelMessageList(
@RequestParam(value = "minId", defaultValue = "0") @PositiveOrZero(message = "minId 不能小于 0") Long minId,
@RequestParam(value = "size", defaultValue = "100")
@Min(value = 1, message = "size 必须大于 0")
@Max(value = 200, message = "size 一次最多 200 条") Integer size) {
// 1. 拉取消息列表
Long userId = getLoginUserId();
List<ImChannelMessageDO> list = channelMessageService.getMessageListForPull(userId, minId, size);
List<ImChannelMessageDO> list = channelMessageService.pullChannelMessageList(userId, minId, size);
if (CollUtil.isEmpty(list)) {
return success(Collections.emptyList());
}
// 2. 按 Redis 已读游标补 statusdevice A 已读后 device B 拉到这条不再算入未读
// 2. 按已读游标补 receiptStatus已读 DONE / 未读 PENDING
Map<Long, Long> readMaxByChannel = channelMessageService.getChannelReadMaxMessageIdMap(
userId, convertSet(list, ImChannelMessageDO::getChannelId));
return success(BeanUtils.toBean(list, ImChannelMessagePullRespVO.class, vo -> {
Long readMax = readMaxByChannel.get(vo.getChannelId());
vo.setStatus(readMax != null && readMax >= vo.getId()
? ImMessageStatusEnum.READ.getStatus()
: ImMessageStatusEnum.UNREAD.getStatus());
vo.setReceiptStatus(readMax != null && readMax >= vo.getId()
? ImMessageReceiptStatusEnum.DONE.getStatus()
: ImMessageReceiptStatusEnum.PENDING.getStatus());
}));
}

View File

@ -61,7 +61,7 @@ public class ImPrivateMessageController {
@GetMapping("/max-read-message-id")
@Operation(summary = "查询对方已读到我发的最大消息 id",
description = "用于多端 / 离线场景下的已读位置补齐:进入会话或断线重连后调用,结果用于翻转本地自发消息状态")
description = "用于多端 / 离线场景下的已读位置补齐:进入会话或断线重连后调用,据此把本地自发消息更新为已读")
@Parameter(name = "peerId", description = "对方用户编号", required = true, example = "2")
public CommonResult<Long> getMaxReadMessageId(@RequestParam("peerId") Long peerId) {
return success(privateMessageService.getMaxReadMessageId(getLoginUserId(), peerId));

View File

@ -24,8 +24,8 @@ public class ImChannelMessagePullRespVO {
@Schema(description = "消息内容payload JSON 快照", requiredMode = Schema.RequiredMode.REQUIRED)
private String content;
@Schema(description = "当前用户已读态;按 Redis 游标计算填充", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
private Integer status; // 参见 ImMessageStatusEnum 枚举类
@Schema(description = "当前用户回执 / 已读态;按 Redis 读位置计算(已读 DONE未读 PENDING", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
private Integer receiptStatus; // 参见 ImMessageReceiptStatusEnum 枚举类
@Schema(description = "发送时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime sendTime;

View File

@ -1,7 +1,7 @@
package cn.iocoder.yudao.module.im.controller.admin.message.vo.group;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.AssertTrue;
@ -28,7 +28,7 @@ public class ImGroupMessageSendReqVO {
@Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
@NotNull(message = "消息类型不能为空")
@InEnum(ImMessageTypeEnum.class)
@InEnum(ImContentTypeEnum.class)
private Integer type;
@Schema(description = "消息内容JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{\"content\":\"你好\"}")
@ -47,7 +47,7 @@ public class ImGroupMessageSendReqVO {
@AssertTrue(message = "消息类型不允许")
@JsonIgnore
public boolean isTypeNormal() {
return type == null || ImMessageTypeEnum.validate(type).isNormal();
return type == null || ImContentTypeEnum.validate(type).isNormal();
}
}

View File

@ -31,7 +31,10 @@ public class ImPrivateMessageRespVO {
private String content;
@Schema(description = "消息状态", example = "0")
private Integer status;
private Integer status; // 参见 ImMessageStatusEnum 枚举类
@Schema(description = "回执状态", example = "0")
private Integer receiptStatus; // 参见 ImMessageReceiptStatusEnum 枚举类
@Schema(description = "发送时间")
private LocalDateTime sendTime;

View File

@ -1,7 +1,7 @@
package cn.iocoder.yudao.module.im.controller.admin.message.vo.privates;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.AssertTrue;
@ -26,20 +26,23 @@ public class ImPrivateMessageSendReqVO {
@Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
@NotNull(message = "消息类型不能为空")
@InEnum(ImMessageTypeEnum.class)
@InEnum(ImContentTypeEnum.class)
private Integer type;
@Schema(description = "消息内容JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{\"content\":\"你好\"}")
@NotEmpty(message = "消息内容不能为空")
private String content;
@Schema(description = "是否需要回执", example = "false")
private Boolean receipt;
/**
* 仅允许用户消息normal类型
*/
@AssertTrue(message = "消息类型不允许")
@JsonIgnore
public boolean isTypeNormal() {
return type == null || ImMessageTypeEnum.validate(type).isNormal();
return type == null || ImContentTypeEnum.validate(type).isNormal();
}
}

View File

@ -0,0 +1,68 @@
package cn.iocoder.yudao.module.im.dal.dataobject.conversation;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO;
import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.time.LocalDateTime;
/**
* IM 会话读位置 DO
* <p>
* 只表达「用户在某个会话的最大已读位置」,私聊 / 群聊 / 频道统一落这张表,是读位置的唯一权威。
*
* @author 芋道源码
*/
@TableName("im_conversation_read")
@KeySequence("im_conversation_read_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ImConversationReadDO extends BaseDO {
/**
* 编号
*/
@TableId
private Long id;
/**
* 用户编号
*
* 关联 AdminUserDO 的 id 字段
*/
private Long userId;
/**
* 会话类型
* <p>
* 枚举 {@link ImConversationTypeEnum}
*/
private Integer conversationType;
/**
* 目标编号
* <p>
* 私聊关联 AdminUserDO 的 id群聊关联 {@link ImGroupDO#getId()};频道关联 {@link ImChannelDO#getId()}
*/
private Long targetId;
/**
* 最大已读消息编号
* <p>
* 关联 {@link ImPrivateMessageDO#getId()}、{@link ImGroupMessageDO#getId()}、{@link ImChannelMessageDO#getId()}
*/
private Long messageId;
/**
* 最近已读时间
*/
private LocalDateTime readTime;
}

View File

@ -4,7 +4,7 @@ import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.framework.mybatis.core.type.LongListTypeHandler;
import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO;
import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
@ -54,7 +54,7 @@ public class ImChannelMessageDO extends BaseDO {
/**
* 消息类型
* <p>
* 枚举 {@link ImMessageTypeEnum}
* 枚举 {@link ImContentTypeEnum}
*/
private Integer type;
/**

View File

@ -3,9 +3,9 @@ package cn.iocoder.yudao.module.im.dal.dataobject.message;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.framework.mybatis.core.type.LongListTypeHandler;
import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO;
import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
@ -54,7 +54,7 @@ public class ImGroupMessageDO extends BaseDO {
/**
* 消息类型
* <p>
* 枚举 {@link ImMessageTypeEnum}
* 枚举 {@link ImContentTypeEnum}
*/
private Integer type;
/**
@ -67,8 +67,6 @@ public class ImGroupMessageDO extends BaseDO {
* 消息状态
* <p>
* 枚举 {@link ImMessageStatusEnum}
*
* 为什么没有 READ 状态?与单聊的差异:单聊用 UNREAD/READ 跟踪每条消息状态,群聊用 Redis 存储每个成员的已读位置(游标模型)
*/
private Integer status;
/**
@ -94,7 +92,7 @@ public class ImGroupMessageDO extends BaseDO {
/**
* 回执状态
* <p>
* 枚举 {@link ImGroupMessageReceiptStatusEnum}
* 枚举 {@link ImMessageReceiptStatusEnum}
*/
private Integer receiptStatus;

View File

@ -1,8 +1,9 @@
package cn.iocoder.yudao.module.im.dal.dataobject.message;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
@ -49,7 +50,7 @@ public class ImPrivateMessageDO extends BaseDO {
/**
* 消息类型
* <p>
* 枚举 {@link ImMessageTypeEnum}
* 枚举 {@link ImContentTypeEnum}
*/
private Integer type;
/**
@ -64,6 +65,12 @@ public class ImPrivateMessageDO extends BaseDO {
* 枚举 {@link ImMessageStatusEnum}
*/
private Integer status;
/**
* 回执状态
* <p>
* 枚举 {@link ImMessageReceiptStatusEnum}
*/
private Integer receiptStatus;
/**
* 发送时间
*/

View File

@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.message;
package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
import lombok.Data;

View File

@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.message;
package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
import lombok.Data;

View File

@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.message;
package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
import lombok.Data;

View File

@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.message;
package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
import lombok.Data;

View File

@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.message;
package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
import lombok.Data;

View File

@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.message;
package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
import lombok.Data;

View File

@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.message;
package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
import lombok.Data;
import lombok.experimental.Accessors;
@ -6,7 +6,7 @@ import lombok.experimental.Accessors;
/**
* 频道素材消息 payload
* <p>
* 对应 {@link cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum#MATERIAL}type=125
* 对应 {@link cn.iocoder.yudao.module.im.enums.ImContentTypeEnum#MATERIAL}type=125
* 客户端按本字段集渲染图文卡片
* 富文本正文不在本 payload 中传递点击详情时另调 /im/channel/material/get-content?id= 按需拉取
*/

View File

@ -1,6 +1,6 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.message;
package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import lombok.Data;
import java.util.List;
@ -47,7 +47,7 @@ public class MergeMessage {
/**
* 消息类型
* <p>
* 枚举 {@link ImMessageTypeEnum}
* 枚举 {@link ImContentTypeEnum}
*/
private Integer type;
/**

View File

@ -1,6 +1,6 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.message;
package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import lombok.Data;
import lombok.experimental.Accessors;
@ -39,7 +39,7 @@ public class QuoteMessage {
/**
* 被引用消息类型
* <p>
* 枚举 {@link ImMessageTypeEnum}
* 枚举 {@link ImContentTypeEnum}
*/
private Integer type;
/**

View File

@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.message;
package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
import lombok.Data;
import lombok.experimental.Accessors;

View File

@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.message;
package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
import lombok.Data;

View File

@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.message;
package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
import lombok.Data;

View File

@ -0,0 +1,83 @@
package cn.iocoder.yudao.module.im.dal.mysql.conversation;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.im.dal.dataobject.conversation.ImConversationReadDO;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
/**
* IM 会话读位置 Mapper
*
* @author 芋道源码
*/
@Mapper
public interface ImConversationReadMapper extends BaseMapperX<ImConversationReadDO> {
default ImConversationReadDO selectByUserIdAndConversation(Long userId, Integer conversationType, Long conversationId) {
return selectOne(new LambdaQueryWrapperX<ImConversationReadDO>()
.eq(ImConversationReadDO::getUserId, userId)
.eq(ImConversationReadDO::getConversationType, conversationType)
.eq(ImConversationReadDO::getTargetId, conversationId));
}
default List<ImConversationReadDO> selectListByConversation(Integer conversationType, Long conversationId) {
return selectList(new LambdaQueryWrapperX<ImConversationReadDO>()
.eq(ImConversationReadDO::getConversationType, conversationType)
.eq(ImConversationReadDO::getTargetId, conversationId));
}
default List<ImConversationReadDO> selectListByUserIdAndConversations(Long userId, Integer conversationType,
Collection<Long> conversationIds) {
return selectList(new LambdaQueryWrapperX<ImConversationReadDO>()
.eq(ImConversationReadDO::getUserId, userId)
.eq(ImConversationReadDO::getConversationType, conversationType)
.in(ImConversationReadDO::getTargetId, conversationIds));
}
/**
* 增量拉取当前用户的会话读位置(按 update_time + id 正向游标)
*
* @param userId 当前用户编号
* @param lastUpdateTime 上次拉取到的更新时间;首次拉取传 null
* @param lastId 上次拉取到的记录编号;首次拉取传 null
* @param limit 拉取数量
* @return 会话读位置列表
*/
default List<ImConversationReadDO> selectPullListByUserId(Long userId, Long lastUpdateTime, Long lastId, Integer limit) {
LambdaQueryWrapperX<ImConversationReadDO> query = new LambdaQueryWrapperX<ImConversationReadDO>()
.eq(ImConversationReadDO::getUserId, userId);
if (lastUpdateTime != null && lastId != null) {
LocalDateTime lastTime = LocalDateTimeUtil.of(lastUpdateTime);
query.and(w -> w.gt(ImConversationReadDO::getUpdateTime, lastTime)
.or(n -> n.eq(ImConversationReadDO::getUpdateTime, lastTime).gt(ImConversationReadDO::getId, lastId)));
}
return selectList(query.orderByAsc(ImConversationReadDO::getUpdateTime).orderByAsc(ImConversationReadDO::getId)
.last("LIMIT " + limit));
}
/**
* 单调递增更新读位置:仅当新位置更大时才更新
* <p>
* 通过 {@code read_message_id < messageId} 的 CAS 条件,保证乱序 / 并发上报时读位置不会回退。
*
* @param id 记录编号
* @param messageId 新的最大已读消息编号
* @param readTime 已读时间
* @return 影响行数0 表示新位置不大于已有位置,未更新
*/
default int updateReadMessageIdToLarger(Long id, Long messageId, LocalDateTime readTime) {
return update(null, Wrappers.<ImConversationReadDO>lambdaUpdate()
.set(ImConversationReadDO::getMessageId, messageId)
.set(ImConversationReadDO::getReadTime, readTime)
.set(ImConversationReadDO::getUpdateTime, readTime)
.eq(ImConversationReadDO::getId, id)
.lt(ImConversationReadDO::getMessageId, messageId));
}
}

View File

@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.im.dal.mysql.friend;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
@ -31,6 +32,28 @@ public interface ImFriendMapper extends BaseMapperX<ImFriendDO> {
.eq(ImFriendDO::getUserId, userId)
.orderByDesc(ImFriendDO::getId));
}
/**
* 增量拉取当前用户的好友关系(含已删除,按 update_time + id 正向游标)
*
* @param userId 当前用户编号
* @param lastUpdateTime 上次拉取到的更新时间;首次拉取传 null
* @param lastId 上次拉取到的记录编号;首次拉取传 null
* @param limit 拉取数量
* @return 好友关系列表
*/
default List<ImFriendDO> selectPullListByUserId(Long userId, Long lastUpdateTime, Long lastId, Integer limit) {
LambdaQueryWrapperX<ImFriendDO> query = new LambdaQueryWrapperX<ImFriendDO>()
.eq(ImFriendDO::getUserId, userId);
if (lastUpdateTime != null && lastId != null) {
LocalDateTime lastTime = LocalDateTimeUtil.of(lastUpdateTime);
query.and(w -> w.gt(ImFriendDO::getUpdateTime, lastTime)
.or(n -> n.eq(ImFriendDO::getUpdateTime, lastTime).gt(ImFriendDO::getId, lastId)));
}
return selectList(query.orderByAsc(ImFriendDO::getUpdateTime).orderByAsc(ImFriendDO::getId)
.last("LIMIT " + limit));
}
default List<ImFriendDO> selectListByUserIdAndStatus(Long userId, Integer status) {
return selectList(new LambdaQueryWrapperX<ImFriendDO>()
.eq(ImFriendDO::getUserId, userId)
@ -68,12 +91,14 @@ public interface ImFriendMapper extends BaseMapperX<ImFriendDO> {
@SuppressWarnings("UnusedReturnValue")
default int updateReAddFields(Long id, Integer status, LocalDateTime addTime,
LocalDateTime updateTime,
Boolean silent, Boolean pinned, Boolean blocked,
String displayName, Integer addSource) {
return update(null, Wrappers.<ImFriendDO>lambdaUpdate()
.eq(ImFriendDO::getId, id)
.set(ImFriendDO::getStatus, status)
.set(ImFriendDO::getAddTime, addTime)
.set(ImFriendDO::getUpdateTime, updateTime)
.set(ImFriendDO::getSilent, silent)
.set(ImFriendDO::getPinned, pinned)
.set(ImFriendDO::getBlocked, blocked)

View File

@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.im.dal.mysql.friend;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
@ -28,6 +29,12 @@ public interface ImFriendRequestMapper extends BaseMapperX<ImFriendRequestDO> {
/**
* 拉取「我相关」的好友申请列表
*
* @param userId 当前用户编号
* @param maxRequestUpdateTime 起始更新时间;首次拉取传 null
* @param maxId 起始记录编号;首次拉取传 null
* @param limit 拉取数量
* @return 好友申请列表
*/
default List<ImFriendRequestDO> selectMyList(Long userId, LocalDateTime maxRequestUpdateTime,
Long maxId, int limit) {
@ -45,6 +52,28 @@ public interface ImFriendRequestMapper extends BaseMapperX<ImFriendRequestDO> {
return selectList(wrapper);
}
/**
* 增量拉取「我相关」的好友申请(双向 OR按 update_time + id 正向游标)
*
* @param userId 当前用户编号
* @param lastUpdateTime 上次拉取到的更新时间;首次拉取传 null
* @param lastId 上次拉取到的记录编号;首次拉取传 null
* @param limit 拉取数量
* @return 好友申请列表
*/
default List<ImFriendRequestDO> selectPullListByUserId(Long userId, Long lastUpdateTime, Long lastId, Integer limit) {
LambdaQueryWrapperX<ImFriendRequestDO> query = new LambdaQueryWrapperX<>();
query.and(w -> w.eq(ImFriendRequestDO::getFromUserId, userId)
.or().eq(ImFriendRequestDO::getToUserId, userId));
if (lastUpdateTime != null && lastId != null) {
LocalDateTime lastTime = LocalDateTimeUtil.of(lastUpdateTime);
query.and(w -> w.gt(ImFriendRequestDO::getUpdateTime, lastTime)
.or(n -> n.eq(ImFriendRequestDO::getUpdateTime, lastTime).gt(ImFriendRequestDO::getId, lastId)));
}
return selectList(query.orderByAsc(ImFriendRequestDO::getUpdateTime).orderByAsc(ImFriendRequestDO::getId)
.last("LIMIT " + limit));
}
default int updateByIdAndHandleResult(Long id, Integer handleResult, ImFriendRequestDO updateObj) {
return update(updateObj, new LambdaUpdateWrapper<ImFriendRequestDO>()
.eq(ImFriendRequestDO::getId, id).eq(ImFriendRequestDO::getHandleResult, handleResult));

View File

@ -1,7 +1,6 @@
package cn.iocoder.yudao.module.im.dal.mysql.group;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
@ -59,20 +58,8 @@ public interface ImGroupMemberMapper extends BaseMapperX<ImGroupMemberDO> {
.eq(ImGroupMemberDO::getStatus, status));
}
/**
* 查询用户已退群的成员记录
* <p>
* 当 {@code minQuitTime} 非空时额外按 {@code quitTime ≥ minQuitTime} 过滤。
*
* @param userId 用户编号
* @param minQuitTime 最早退群时间(含),可空
* @return 已退群成员记录列表
*/
default List<ImGroupMemberDO> selectQuitListByUserId(Long userId, LocalDateTime minQuitTime) {
return selectList(new LambdaQueryWrapperX<ImGroupMemberDO>()
.eq(ImGroupMemberDO::getUserId, userId)
.eq(ImGroupMemberDO::getStatus, CommonStatusEnum.DISABLE.getStatus())
.geIfPresent(ImGroupMemberDO::getQuitTime, minQuitTime));
default List<ImGroupMemberDO> selectListByUserId(Long userId) {
return selectList(ImGroupMemberDO::getUserId, userId);
}
@SuppressWarnings("UnusedReturnValue")
@ -143,7 +130,8 @@ public interface ImGroupMemberMapper extends BaseMapperX<ImGroupMemberDO> {
.set(ImGroupMemberDO::getAddSource, addSource)
.set(ImGroupMemberDO::getInviterUserId, inviterUserId)
.set(ImGroupMemberDO::getQuitTime, null)
.set(ImGroupMemberDO::getMuteEndTime, null));
.set(ImGroupMemberDO::getMuteEndTime, null)
.set(ImGroupMemberDO::getUpdateTime, joinTime));
}
}

View File

@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.im.dal.mysql.group;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
@ -43,6 +44,28 @@ public interface ImGroupRequestMapper extends BaseMapperX<ImGroupRequestDO> {
.orderByDesc(ImGroupRequestDO::getId));
}
/**
* 增量拉取「我管理的群」下的加群申请(含已处理,按 update_time + id 正向游标)
*
* @param groupIds 群编号集合
* @param lastUpdateTime 上次拉取到的更新时间;首次拉取传 null
* @param lastId 上次拉取到的记录编号;首次拉取传 null
* @param limit 拉取数量
* @return 加群申请列表
*/
default List<ImGroupRequestDO> selectPullListByGroupIds(Collection<Long> groupIds, Long lastUpdateTime,
Long lastId, Integer limit) {
LambdaQueryWrapperX<ImGroupRequestDO> query = new LambdaQueryWrapperX<ImGroupRequestDO>()
.in(ImGroupRequestDO::getGroupId, groupIds);
if (lastUpdateTime != null && lastId != null) {
LocalDateTime lastTime = LocalDateTimeUtil.of(lastUpdateTime);
query.and(w -> w.gt(ImGroupRequestDO::getUpdateTime, lastTime)
.or(n -> n.eq(ImGroupRequestDO::getUpdateTime, lastTime).gt(ImGroupRequestDO::getId, lastId)));
}
return selectList(query.orderByAsc(ImGroupRequestDO::getUpdateTime).orderByAsc(ImGroupRequestDO::getId)
.last("LIMIT " + limit));
}
default int updateByIdAndHandleResult(Long id, Integer expectedHandleResult, ImGroupRequestDO updateObj) {
return update(updateObj, new LambdaUpdateWrapper<ImGroupRequestDO>()
.eq(ImGroupRequestDO::getId, id).eq(ImGroupRequestDO::getHandleResult, expectedHandleResult));

View File

@ -4,13 +4,15 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX;
import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.group.ImGroupMessageManagerPageReqVO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
/**
@ -22,48 +24,22 @@ import java.util.List;
public interface ImGroupMessageMapper extends BaseMapperX<ImGroupMessageDO> {
/**
* 根据 minId + 时间窗口增量拉取群聊消息(在群成员使用)
* 根据 minId + 时间窗口增量拉取群聊消息
*
* @param groupIds 用户当前仍在群内的群编号列表
* @param userId 当前用户编号
* @param groupIds 候选群编号集合(当前在群 窗口内退群),不能为空
* @param minId 最小消息 id不含
* @param minSendTime 最早发送时间(不含),限制离线消息时间窗口
* @param size 拉取数量
* @return 消息列表(按 id 升序)
*/
default List<ImGroupMessageDO> selectListByMinId(List<Long> groupIds, Long minId,
default List<ImGroupMessageDO> selectListByMinId(Long userId, Collection<Long> groupIds, Long minId,
LocalDateTime minSendTime, Integer size) {
QueryWrapperX<ImGroupMessageDO> wrapper = new QueryWrapperX<>();
wrapper.in("group_id", groupIds)
.gt("id", minId)
.gt("send_time", minSendTime)
.orderByAsc("id");
wrapper.limitN(size);
return selectList(wrapper);
}
/**
* 查询"退群前"的离线消息(退群成员使用)
* <p>
* 语义:用户已退出某群,但仍需把 {@code minId} 之后、{@code minSendTime} 之后、不晚于退群时间的消息补齐到本地,便于前端看到完整上下文。
* <p>
* 撤回消息status=RECALL保留返回与在群成员的 {@link #selectListByMinId} 行为一致,由前端按撤回信号渲染「消息已撤回」气泡。
*
* @param groupId 群编号
* @param minId 最小消息 id不含
* @param minSendTime 最早发送时间(不含)
* @param quitTime 退群时间(含),仅返回退群当时已存在的消息
* @param size 拉取数量
* @return 消息列表(按 id 升序)
*/
default List<ImGroupMessageDO> selectListByGroupIdAndMinIdAndQuitTimeBefore(Long groupId, Long minId,
LocalDateTime minSendTime,
LocalDateTime quitTime,
Integer size) {
QueryWrapperX<ImGroupMessageDO> wrapper = new QueryWrapperX<>();
wrapper.eq("group_id", groupId)
.gt("id", minId)
.gt("send_time", minSendTime)
.le("send_time", quitTime)
.apply(MyBatisUtils.findInSet("receiver_user_ids"), userId)
.orderByAsc("id");
wrapper.limitN(size);
return selectList(wrapper);
@ -72,17 +48,17 @@ public interface ImGroupMessageMapper extends BaseMapperX<ImGroupMessageDO> {
/**
* 查询群聊历史消息(游标拉取)
*
* @param groupId 群编号
* @param maxId 起始消息 id不含为空则从最新开始
* @param limit 拉取数量
* @param joinTime 入群时间,仅返回入群之后的消息
* @param userId 当前用户编号
* @param groupId 群编号
* @param maxId 起始消息 id不含为空则从最新开始
* @param limit 拉取数量
* @return 消息列表(按 id 倒序)
*/
default List<ImGroupMessageDO> selectHistoryList(Long groupId, Long maxId, Integer limit, LocalDateTime joinTime) {
default List<ImGroupMessageDO> selectHistoryListByUser(Long userId, Long groupId, Long maxId, Integer limit) {
QueryWrapperX<ImGroupMessageDO> wrapper = new QueryWrapperX<>();
wrapper.eq("group_id", groupId)
.lt(maxId != null, "id", maxId)
.ge(joinTime != null, "send_time", joinTime)
.apply(MyBatisUtils.findInSet("receiver_user_ids"), userId)
.orderByDesc("id");
wrapper.limitN(limit);
return selectList(wrapper);
@ -108,7 +84,7 @@ public interface ImGroupMessageMapper extends BaseMapperX<ImGroupMessageDO> {
default List<ImGroupMessageDO> selectListByGroupIdAndPendingReceipt(Long groupId, Long minId, Long maxId) {
return selectList(new LambdaQueryWrapperX<ImGroupMessageDO>()
.eq(ImGroupMessageDO::getGroupId, groupId)
.eq(ImGroupMessageDO::getReceiptStatus, ImGroupMessageReceiptStatusEnum.PENDING.getStatus())
.eq(ImGroupMessageDO::getReceiptStatus, ImMessageReceiptStatusEnum.PENDING.getStatus())
.gt(minId != null, ImGroupMessageDO::getId, minId)
.le(ImGroupMessageDO::getId, maxId)
.ne(ImGroupMessageDO::getStatus, ImMessageStatusEnum.RECALL.getStatus()));

View File

@ -67,23 +67,13 @@ public interface ImPrivateMessageMapper extends BaseMapperX<ImPrivateMessageDO>
.eq(ImPrivateMessageDO::getClientMessageId, clientMessageId));
}
default Long selectMaxIdBySenderIdAndReceiverIdAndStatus(Long senderId, Long receiverId, Integer status) {
ImPrivateMessageDO message = selectOne(new LambdaQueryWrapperX<ImPrivateMessageDO>()
.eq(ImPrivateMessageDO::getSenderId, senderId)
.eq(ImPrivateMessageDO::getReceiverId, receiverId)
.eq(ImPrivateMessageDO::getStatus, status)
.orderByDesc(ImPrivateMessageDO::getId)
.last("LIMIT 1"));
return message != null ? message.getId() : null;
}
default int updateBySenderIdAndReceiverIdAndIdLeAndStatus(Long senderId, Long receiverId, Long maxMessageId,
Integer whereStatus, ImPrivateMessageDO updateObj) {
default int updateBySenderIdAndReceiverIdAndIdLeAndReceiptStatus(Long senderId, Long receiverId, Long maxMessageId,
Integer whereReceiptStatus, ImPrivateMessageDO updateObj) {
return update(updateObj, new LambdaQueryWrapperX<ImPrivateMessageDO>()
.eq(ImPrivateMessageDO::getSenderId, senderId)
.eq(ImPrivateMessageDO::getReceiverId, receiverId)
.le(ImPrivateMessageDO::getId, maxMessageId)
.eq(ImPrivateMessageDO::getStatus, whereStatus));
.eq(ImPrivateMessageDO::getReceiptStatus, whereReceiptStatus));
}
default PageResult<ImPrivateMessageDO> selectPage(ImPrivateMessageManagerPageReqVO reqVO) {

View File

@ -154,7 +154,7 @@ public interface ImStatisticsManagerMapper {
@Param("endTime") LocalDateTime endTime);
/**
* 区间内消息类型分布(私聊+群聊合并)
* 区间内内容类型分布(私聊+群聊合并)
*
* @return [{type: 0, count: 123}, ...]
*/

View File

@ -8,20 +8,6 @@ package cn.iocoder.yudao.module.im.dal.redis;
*/
public interface RedisKeyConstants {
/**
* 群消息已读位置
* KEY 格式: im:group:message:read:{groupId}
* VALUE 数据类型: Hash (field: userId, value: maxReadMessageId)
*/
String GROUP_MESSAGE_READ = "im:group:message:read:%s";
/**
* 频道消息已读位置
* KEY 格式: im:channel:message:read:{channelId}
* VALUE 数据类型: Hash (field: userId, value: maxReadMessageId)
*/
String CHANNEL_MESSAGE_READ = "im:channel:message:read:%s";
/**
* 好友关系状态缓存(合并「是否好友」+「是否拉黑」两态)
* <p>
@ -44,7 +30,7 @@ public interface RedisKeyConstants {
* KEY 格式group_member_ids:{groupId}
* VALUE 数据类型List<Long>
* <p>
* 说明:只缓存轻量的 userId 列表,适合"群消息推送目标"这类只关心 userId 的场景
* 说明:只缓存轻量的 userId 列表,适合群消息推送目标这类只关心 userId 的场景
*/
String GROUP_MEMBER_IDS = "group_member_ids";

View File

@ -1,50 +0,0 @@
package cn.iocoder.yudao.module.im.dal.redis.message;
import cn.hutool.core.convert.Convert;
import jakarta.annotation.Resource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Repository;
import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.CHANNEL_MESSAGE_READ;
/**
* IM 频道消息已读位置 Redis DAO
*
* @author 芋道源码
*/
@Repository
public class ImChannelMessageReadRedisDAO {
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 更新用户在某频道的最大已读消息编号
*
* @param channelId 频道编号
* @param userId 用户编号
* @param maxMessageId 最大已读消息编号
*/
public void updateReadMaxMessageId(Long channelId, Long userId, Long maxMessageId) {
String key = formatKey(channelId);
stringRedisTemplate.opsForHash().put(key, userId.toString(), maxMessageId.toString());
}
/**
* 获取用户在某频道的最大已读消息编号
*
* @param channelId 频道编号
* @param userId 用户编号
* @return 最大已读消息编号;不存在则返回 null
*/
public Long getReadMaxMessageId(Long channelId, Long userId) {
String key = formatKey(channelId);
Object val = stringRedisTemplate.opsForHash().get(key, userId.toString());
return Convert.toLong(val);
}
private static String formatKey(Long channelId) {
return String.format(CHANNEL_MESSAGE_READ, channelId);
}
}

View File

@ -1,102 +0,0 @@
package cn.iocoder.yudao.module.im.dal.redis.message;
import cn.hutool.core.convert.Convert;
import jakarta.annotation.Resource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP_MESSAGE_READ;
/**
* IM 群消息已读位置 Redis DAO
*
* @author 芋道源码
*/
@Repository
public class ImGroupMessageReadRedisDAO {
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 更新用户在某群的最大已读消息编号
*
* @param groupId 群编号
* @param userId 用户编号
* @param maxMessageId 最大已读消息编号
*/
public void updateReadMaxMessageId(Long groupId, Long userId, Long maxMessageId) {
String key = formatKey(groupId);
stringRedisTemplate.opsForHash().put(key, userId.toString(), maxMessageId.toString());
}
/**
* 获取用户在某群的最大已读消息编号
*
* @param groupId 群编号
* @param userId 用户编号
* @return 最大已读消息编号,不存在则返回 null
*/
public Long getReadMaxMessageId(Long groupId, Long userId) {
String key = formatKey(groupId);
Object val = stringRedisTemplate.opsForHash().get(key, userId.toString());
return Convert.toLong(val);
}
/**
* 获取某群所有用户的已读位置
*
* @param groupId 群编号
* @return userId → maxReadMessageId 映射
*/
public Map<Long, Long> getReadMaxMessageIdMap(Long groupId) {
String key = formatKey(groupId);
Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(key);
// 转换为 Long → Long 的 Map
Map<Long, Long> result = new HashMap<>(entries.size());
entries.forEach((k, v) -> result.put(Long.parseLong(k.toString()), Long.parseLong(v.toString())));
return result;
}
/**
* 删除用户在某群的已读位置
*
* @param groupId 群编号
* @param userId 用户编号
*/
public void deleteReadMaxMessageId(Long groupId, Long userId) {
String key = formatKey(groupId);
stringRedisTemplate.opsForHash().delete(key, userId.toString());
}
/**
* 批量删除用户在某群的已读位置
*
* @param groupId 群编号
* @param userIds 用户编号集合
*/
public void deleteReadMaxMessageIds(Long groupId, Collection<Long> userIds) {
String key = formatKey(groupId);
Object[] hashKeys = userIds.stream().map(String::valueOf).toArray();
stringRedisTemplate.opsForHash().delete(key, hashKeys);
}
/**
* 删除某群所有用户的已读位置(整个 Hash Key
*
* @param groupId 群编号
*/
public void deleteReadMaxMessageIdMap(Long groupId) {
String key = formatKey(groupId);
stringRedisTemplate.delete(key);
}
private static String formatKey(Long groupId) {
return String.format(GROUP_MESSAGE_READ, groupId);
}
}

View File

@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.enums.message;
package cn.iocoder.yudao.module.im.enums;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.lang.Assert;
@ -11,13 +11,13 @@ import java.util.Arrays;
import java.util.Set;
/**
* IM 消息类型枚举
* IM 内容类型枚举
*
* @author 芋道源码
*/
@Getter
@RequiredArgsConstructor
public enum ImMessageTypeEnum implements ArrayValuable<Integer> {
public enum ImContentTypeEnum implements ArrayValuable<Integer> {
// ========== 用户聊天消息101-105 直接复用 OpenIM 段位编号 ==========
/**
@ -128,10 +128,6 @@ public enum ImMessageTypeEnum implements ArrayValuable<Integer> {
RTC_CALL_END(1611, "通话结束", true, false),
// ========== 好友通知1201-1210 直接复用 OpenIM 段位编号 ==========
// TODO @芋艿FRIEND_REQUEST_* GROUP_REQUEST_* 都是 persistent=false SysMsg离线 pull 拉不到
// 目前可能丢失实时 toast 提醒体验业务状态不丢前端上线 fetch{Friend,Group}RequestList 能补回来
// 未来思考下怎么优化候选方案 1 persistent=true 入私聊消息流 + 让客户端按 type 自渲染
// 2服务端补一个未读通知拉取接口给前端冷启动调用
/**
* 对应 OpenIMFriendApplicationApprovedNotification 1201
* 对应自己的类FriendRequestApprovedNotification
@ -285,9 +281,9 @@ public enum ImMessageTypeEnum implements ArrayValuable<Integer> {
/**
* 对应 OpenIMsdkws.GroupMemberInfoSetTipsGroupMemberInfoSetNotification 1516窄化到 displayUserName
* 对应自己的类GroupMemberNicknameUpdateNotification
* 场景成员修改自己在群里的昵称全员广播前端按 displayUserName 局部更新对应 member
* 场景成员修改自己在群里的昵称在线成员同步对应 member
*/
GROUP_MEMBER_NICKNAME_UPDATE(1516, "成员昵称变更", true, false),
GROUP_MEMBER_NICKNAME_UPDATE(1516, "成员昵称变更", false, false),
/**
* 对应 OpenIMGroupMemberSetToAdminNotification 1517
* 对应自己的类GroupAdminAddNotification
@ -339,7 +335,7 @@ public enum ImMessageTypeEnum implements ArrayValuable<Integer> {
*/
GROUP_BANNED(1533, "群封禁变更", true, false);
public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageTypeEnum::getType).toArray(Integer[]::new);
public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImContentTypeEnum::getType).toArray(Integer[]::new);
private static final Set<Integer> FRIEND_NOTIFICATION_TYPES = CollUtil.newHashSet(
FRIEND_REQUEST_APPROVED.type,
@ -396,8 +392,8 @@ public enum ImMessageTypeEnum implements ArrayValuable<Integer> {
* @param type 消息类型
* @return 枚举实例
*/
public static ImMessageTypeEnum validate(Integer type) {
ImMessageTypeEnum result = ArrayUtil.firstMatch(item -> item.type.equals(type), values());
public static ImContentTypeEnum validate(Integer type) {
ImContentTypeEnum result = ArrayUtil.firstMatch(item -> item.type.equals(type), values());
Assert.notNull(result, "未注册的消息类型 type={}", type);
return result;
}

View File

@ -16,6 +16,7 @@ import java.util.Objects;
@Getter
public enum ImConversationTypeEnum implements ArrayValuable<Integer> {
NONE(0, "无会话"), // 无会话
PRIVATE(1, "私聊"), // 私聊
GROUP(2, "群聊"), // 群聊
CHANNEL(3, "频道"); // 频道 / 公众号
@ -36,6 +37,10 @@ public enum ImConversationTypeEnum implements ArrayValuable<Integer> {
return ARRAYS;
}
public static boolean isNone(Integer type) {
return Objects.equals(NONE.type, type);
}
public static boolean isPrivate(Integer type) {
return Objects.equals(PRIVATE.type, type);
}

View File

@ -7,19 +7,19 @@ import lombok.Getter;
import java.util.Arrays;
/**
* IM 消息回执状态枚举
* IM 消息回执状态枚举
*
* @author 芋道源码
*/
@Getter
@AllArgsConstructor
public enum ImGroupMessageReceiptStatusEnum implements ArrayValuable<Integer> {
public enum ImMessageReceiptStatusEnum implements ArrayValuable<Integer> {
NO_RECEIPT(0, "不需要回执"),
PENDING(1, "待完成"),
DONE(2, "已完成");
public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImGroupMessageReceiptStatusEnum::getStatus).toArray(Integer[]::new);
public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageReceiptStatusEnum::getStatus).toArray(Integer[]::new);
/**
* 状态

View File

@ -8,9 +8,6 @@ import java.util.Arrays;
/**
* IM 消息状态枚举
* <p>
* 私聊SENDING(-1, 仅客户端) / UNREAD(0) / RECALL(2) / READ(3)
* 群聊SENDING(-1, 仅客户端) / UNREAD(0, 作为正常状态) / RECALL(2)
*
* @author 芋道源码
*/
@ -19,9 +16,8 @@ import java.util.Arrays;
public enum ImMessageStatusEnum implements ArrayValuable<Integer> {
SENDING(-1, "发送中"), // 仅客户端使用
UNREAD(0, "未读"), // 私聊=未读,群聊=正常(初始状态
RECALL(2, "已撤回"),
READ(3, "已读"); // 仅私聊使用;群聊已读通过 Redis 已读位置实现
NORMAL(0, "正常"), // 私聊 / 群聊正常(初始状态
RECALL(2, "已撤回");
public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageStatusEnum::getStatus).toArray(Integer[]::new);

View File

@ -24,7 +24,6 @@ import java.util.Objects;
@Getter
public enum ImRtcParticipantStatusEnum implements ArrayValuable<Integer> {
// TODO @芋艿hand up 要不要也搞下。
INVITING(10, "邀请中"), // 已发出 invite等被叫响应
JOINED(20, "已加入"), // 已 connect 进 LiveKit 房间
REJECTED(30, "已拒绝"), // 接通前点拒接

View File

@ -2,11 +2,11 @@ package cn.iocoder.yudao.module.im.mq.consumer.friend;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend.FriendInfoUpdatedNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.friend.FriendInfoUpdatedNotification;
import cn.iocoder.yudao.module.system.api.message.user.AdminUserProfileUpdateMessage;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
@ -52,8 +52,9 @@ public class AdminUserProfileUpdateConsumer {
try {
FriendInfoUpdatedNotification payload = (FriendInfoUpdatedNotification) new FriendInfoUpdatedNotification()
.setOperatorUserId(userId).setFriendUserId(userId);
websocketService.sendPrivateMessageAsync(friend.getFriendUserId(), ImPrivateMessageDTO.ofFriendNotification(
ImMessageTypeEnum.FRIEND_INFO_UPDATED.getType(), userId, friend.getFriendUserId(), payload));
websocketService.sendNotificationAsync(friend.getFriendUserId(),
ImConversationTypeEnum.NONE.getType(),
ImContentTypeEnum.FRIEND_INFO_UPDATED.getType(), payload);
successCount++;
} catch (Exception e) {
log.warn("[onMessage][userId({}) friendUserId({}) 推送失败]",

View File

@ -0,0 +1,67 @@
package cn.iocoder.yudao.module.im.service.conversation;
import cn.iocoder.yudao.module.im.dal.dataobject.conversation.ImConversationReadDO;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
* IM 会话读位置 Service 接口
*
* @author 芋道源码
*/
public interface ImConversationReadService {
/**
* 更新用户在某会话的最大已读位置(单调递增,乱序 / 并发上报不会回退)
*
* @param userId 用户编号
* @param conversationType 会话类型
* @param conversationId 会话编号
* @param readMessageId 已读到的最大消息编号
* @return 读位置是否前进true 时调用方才需要下发已读 / 回执事件
*/
boolean updateConversationReadPosition(Long userId, Integer conversationType, Long conversationId, Long readMessageId);
/**
* 获取用户在某会话的最大已读位置
*
* @param userId 用户编号
* @param conversationType 会话类型
* @param conversationId 会话编号
* @return 最大已读消息编号;不存在则返回 null
*/
Long getConversationReadMessageId(Long userId, Integer conversationType, Long conversationId);
/**
* 获取某会话内所有用户的读位置(用于群回执人数聚合)
*
* @param conversationType 会话类型
* @param conversationId 会话编号
* @return userId → 最大已读消息编号
*/
Map<Long, Long> getUserReadMessageIdMap(Integer conversationType, Long conversationId);
/**
* 批量获取某用户在多个会话的读位置(用于频道批量读位置、重连后按活跃会话补偿)
*
* @param userId 用户编号
* @param conversationType 会话类型
* @param conversationIds 会话编号集合
* @return conversationId → 最大已读消息编号
*/
Map<Long, Long> getConversationReadMessageIdMap(Long userId, Integer conversationType, Collection<Long> conversationIds);
/**
* 增量拉取当前用户的会话读位置(重连 / 离线补偿:按 update_time + id 游标)
*
* @param userId 用户编号
* @param lastUpdateTime 上次拉取到的最新更新时间(毫秒时间戳);首次拉取传 null
* @param lastId 上次拉取到的最后一条记录 id首次拉取传 null
* @param limit 单次拉取条数
* @return 会话读位置列表按更新时间、id 正序
*/
List<ImConversationReadDO> pullConversationReadList(Long userId, Long lastUpdateTime, Long lastId, Integer limit);
}

View File

@ -0,0 +1,86 @@
package cn.iocoder.yudao.module.im.service.conversation;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.module.im.dal.dataobject.conversation.ImConversationReadDO;
import cn.iocoder.yudao.module.im.dal.mysql.conversation.ImConversationReadMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
/**
* IM 会话读位置 Service 实现类
*
* @author 芋道源码
*/
@Service
@Slf4j
public class ImConversationReadServiceImpl implements ImConversationReadService {
@Resource
private ImConversationReadMapper conversationReadMapper;
@Override
public boolean updateConversationReadPosition(Long userId, Integer conversationType, Long conversationId, Long readMessageId) {
LocalDateTime now = LocalDateTime.now();
// 1. 不存在则插入;并发下唯一键冲突,降级为回查 + CAS 更新
ImConversationReadDO existing = conversationReadMapper.selectByUserIdAndConversation(
userId, conversationType, conversationId);
if (existing == null) {
try {
conversationReadMapper.insert(new ImConversationReadDO().setUserId(userId)
.setConversationType(conversationType).setTargetId(conversationId)
.setMessageId(readMessageId).setReadTime(now));
return true;
} catch (DuplicateKeyException e) {
log.warn("[updateConversationReadPosition][userId({}) type({}) conversationId({}) 并发插入冲突,回查更新]",
userId, conversationType, conversationId);
existing = conversationReadMapper.selectByUserIdAndConversation(userId, conversationType, conversationId);
}
}
if (existing == null) {
return false;
}
// 2. CAS 单调更新mapper 内 WHERE message_id < ? 保证乱序 / 并发不回退;影响行数 > 0 即读位置前进
return conversationReadMapper.updateReadMessageIdToLarger(existing.getId(), readMessageId, now) > 0;
}
@Override
public Long getConversationReadMessageId(Long userId, Integer conversationType, Long conversationId) {
ImConversationReadDO read = conversationReadMapper.selectByUserIdAndConversation(
userId, conversationType, conversationId);
return read != null ? read.getMessageId() : null;
}
@Override
public Map<Long, Long> getUserReadMessageIdMap(Integer conversationType, Long conversationId) {
return convertMap(conversationReadMapper.selectListByConversation(conversationType, conversationId),
ImConversationReadDO::getUserId, ImConversationReadDO::getMessageId);
}
@Override
public Map<Long, Long> getConversationReadMessageIdMap(Long userId, Integer conversationType,
Collection<Long> conversationIds) {
if (CollUtil.isEmpty(conversationIds)) {
return Collections.emptyMap();
}
List<ImConversationReadDO> list = conversationReadMapper.selectListByUserIdAndConversations(
userId, conversationType, conversationIds);
return convertMap(list, ImConversationReadDO::getTargetId, ImConversationReadDO::getMessageId);
}
@Override
public List<ImConversationReadDO> pullConversationReadList(Long userId, Long lastUpdateTime, Long lastId, Integer limit) {
return conversationReadMapper.selectPullListByUserId(userId, lastUpdateTime, lastId, limit);
}
}

View File

@ -50,6 +50,17 @@ public interface ImFriendRequestService {
*/
List<ImFriendRequestDO> getMyFriendRequestList(Long userId, Long maxId, Integer limit);
/**
* 增量拉取「我相关」的好友申请(重连 / 离线补偿:双向 OR按 update_time + id 游标)
*
* @param userId 用户编号
* @param lastUpdateTime 上次拉取到的最新更新时间(毫秒时间戳);首次拉取传 null
* @param lastId 上次拉取到的最后一条记录 id首次拉取传 null
* @param limit 单次拉取条数
* @return 申请记录列表按更新时间、id 正序
*/
List<ImFriendRequestDO> pullFriendRequestList(Long userId, Long lastUpdateTime, Long lastId, Integer limit);
/**
* 按 id 单查申请记录;通用读接口,调用方自行做越权过滤
*/

View File

@ -14,13 +14,13 @@ import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO;
import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendRequestMapper;
import cn.iocoder.yudao.module.im.enums.friend.ImFriendRequestHandleResultEnum;
import cn.iocoder.yudao.module.im.enums.friend.ImFriendStateEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
import cn.iocoder.yudao.module.im.framework.config.ImProperties;
import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend.FriendRequestApprovedNotification;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend.FriendRequestNotification;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend.FriendRequestRejectedNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.friend.FriendRequestApprovedNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.friend.FriendRequestNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.friend.FriendRequestRejectedNotification;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
@ -105,8 +105,8 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService {
if (fromUser != null) {
payload.setFromNickname(fromUser.getNickname()).setFromAvatar(fromUser.getAvatar());
}
websocketService.sendPrivateMessageAsync(toUserId, ImPrivateMessageDTO.ofFriendNotification(
ImMessageTypeEnum.FRIEND_REQUEST_RECEIVED.getType(), fromUserId, toUserId, payload));
websocketService.sendNotificationAsync(toUserId, ImConversationTypeEnum.NONE.getType(),
ImContentTypeEnum.FRIEND_REQUEST_RECEIVED.getType(), payload);
// 4. 全局自动通过开关:注册 afterCommit 回调,事务提交后再走同意流程
// 回调内 try/catch 兜底 —— afterCommit 异常会被 Spring 静默吞掉,否则同意失败时申请方永远等不到 APPROVED
@ -172,8 +172,6 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService {
public void agreeFriendRequest(Long userId, Long requestId) {
// 1.1 校验申请存在、未处理、操作人是接收方
ImFriendRequestDO request = validateRequestForHandle(userId, requestId);
// 1.2 复验双方用户有效
adminUserApi.validateUserList(ListUtil.of(request.getFromUserId(), request.getToUserId()));
// 2. 乐观锁更新申请处理结果
ImFriendRequestDO updateObj = new ImFriendRequestDO()
@ -192,8 +190,8 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService {
FriendRequestApprovedNotification payload = (FriendRequestApprovedNotification)
new FriendRequestApprovedNotification().setRequestId(request.getId())
.setOperatorUserId(userId).setFriendUserId(userId);
websocketService.sendPrivateMessageAsync(request.getFromUserId(), ImPrivateMessageDTO.ofFriendNotification(
ImMessageTypeEnum.FRIEND_REQUEST_APPROVED.getType(), userId, request.getFromUserId(), payload));
websocketService.sendNotificationAsync(request.getFromUserId(), ImConversationTypeEnum.NONE.getType(),
ImContentTypeEnum.FRIEND_REQUEST_APPROVED.getType(), payload);
}
@Override
@ -217,8 +215,8 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService {
new FriendRequestRejectedNotification().setRequestId(request.getId())
.setHandleContent(handleContent)
.setOperatorUserId(userId).setFriendUserId(userId);
websocketService.sendPrivateMessageAsync(request.getFromUserId(), ImPrivateMessageDTO.ofFriendNotification(
ImMessageTypeEnum.FRIEND_REQUEST_REJECTED.getType(), userId, request.getFromUserId(), payload));
websocketService.sendNotificationAsync(request.getFromUserId(), ImConversationTypeEnum.NONE.getType(),
ImContentTypeEnum.FRIEND_REQUEST_REJECTED.getType(), payload);
}
@Override
@ -233,6 +231,11 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService {
limit);
}
@Override
public List<ImFriendRequestDO> pullFriendRequestList(Long userId, Long lastUpdateTime, Long lastId, Integer limit) {
return friendRequestMapper.selectPullListByUserId(userId, lastUpdateTime, lastId, limit);
}
@Override
public ImFriendRequestDO getFriendRequest(Long id) {
return friendRequestMapper.selectById(id);

View File

@ -42,6 +42,11 @@ public interface ImFriendService {
*/
List<ImFriendDO> getFriendList(Long userId);
/**
* 增量拉取当前用户的好友关系(重连 / 离线补偿:含已删除,按 update_time + id 游标)
*/
List<ImFriendDO> pullFriendList(Long userId, Long lastUpdateTime, Long lastId, Integer limit);
/**
* 获得当前用户的有效好友列表(仅 ENABLE 状态)
*/

View File

@ -11,12 +11,12 @@ import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO;
import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO;
import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendMapper;
import cn.iocoder.yudao.module.im.enums.friend.ImFriendStateEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
import cn.iocoder.yudao.module.im.service.message.ImPrivateMessageService;
import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO;
import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend.*;
import cn.iocoder.yudao.module.im.service.websocket.notification.friend.*;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
@ -98,6 +98,11 @@ public class ImFriendServiceImpl implements ImFriendService {
return friendMapper.selectListByUserId(userId);
}
@Override
public List<ImFriendDO> pullFriendList(Long userId, Long lastUpdateTime, Long lastId, Integer limit) {
return friendMapper.selectPullListByUserId(userId, lastUpdateTime, lastId, limit);
}
@Override
public List<ImFriendDO> getEnableFriendList(Long userId) {
return friendMapper.selectListByUserIdAndStatus(userId, CommonStatusEnum.ENABLE.getStatus());
@ -155,8 +160,8 @@ public class ImFriendServiceImpl implements ImFriendService {
FriendUpdateNotification payload = (FriendUpdateNotification) new FriendUpdateNotification()
.setDisplayName(reqVO.getDisplayName()).setSilent(reqVO.getSilent()).setPinned(reqVO.getPinned())
.setOperatorUserId(userId).setFriendUserId(reqVO.getFriendUserId());
websocketService.sendPrivateMessageAsync(userId, ImPrivateMessageDTO.ofFriendNotification(
ImMessageTypeEnum.FRIEND_UPDATE.getType(), userId, userId, payload));
websocketService.sendNotificationAsync(userId, ImConversationTypeEnum.NONE.getType(),
ImContentTypeEnum.FRIEND_UPDATE.getType(), payload);
}
@Override
@ -178,7 +183,7 @@ public class ImFriendServiceImpl implements ImFriendService {
FriendAddNotification payload = (FriendAddNotification) new FriendAddNotification()
.setOperatorUserId(fromUserId).setFriendUserId(toUserId);
privateMessageService.sendPrivateMessage(fromUserId, new ImPrivateMessageSendDTO()
.setReceiverId(toUserId).setType(ImMessageTypeEnum.FRIEND_ADD.getType()).setContent(payload));
.setReceiverId(toUserId).setType(ImContentTypeEnum.FRIEND_ADD.getType()).setContent(payload));
}
@Override
@ -197,7 +202,7 @@ public class ImFriendServiceImpl implements ImFriendService {
FriendAddNotification payload = (FriendAddNotification) new FriendAddNotification()
.setOperatorUserId(friendUserId).setFriendUserId(friendUserId);
privateMessageService.sendPrivateMessage(userId, new ImPrivateMessageSendDTO()
.setReceiverId(friendUserId).setType(ImMessageTypeEnum.FRIEND_ADD.getType())
.setReceiverId(friendUserId).setType(ImContentTypeEnum.FRIEND_ADD.getType())
.setContent(payload).setPersistent(false));
}
@ -219,7 +224,7 @@ public class ImFriendServiceImpl implements ImFriendService {
FriendDeleteNotification payload = ((FriendDeleteNotification) new FriendDeleteNotification()
.setOperatorUserId(userId).setFriendUserId(friendUserId)).setClear(clear);
privateMessageService.sendPrivateMessage(userId, new ImPrivateMessageSendDTO()
.setReceiverId(friendUserId).setType(ImMessageTypeEnum.FRIEND_DELETE.getType())
.setReceiverId(friendUserId).setType(ImContentTypeEnum.FRIEND_DELETE.getType())
.setContent(payload).setPersistent(false));
}
@ -245,8 +250,8 @@ public class ImFriendServiceImpl implements ImFriendService {
// 3. 推 FRIEND_BLOCK 给 A 多端
FriendBlockNotification payload = (FriendBlockNotification) new FriendBlockNotification()
.setOperatorUserId(userId).setFriendUserId(friendUserId);
websocketService.sendPrivateMessageAsync(userId, ImPrivateMessageDTO.ofFriendNotification(
ImMessageTypeEnum.FRIEND_BLOCK.getType(), userId, userId, payload));
websocketService.sendNotificationAsync(userId, ImConversationTypeEnum.NONE.getType(),
ImContentTypeEnum.FRIEND_BLOCK.getType(), payload);
}
@Override
@ -271,8 +276,8 @@ public class ImFriendServiceImpl implements ImFriendService {
// 3. 推 FRIEND_UNBLOCK 给 A 多端
FriendUnblockNotification payload = (FriendUnblockNotification) new FriendUnblockNotification()
.setOperatorUserId(userId).setFriendUserId(friendUserId);
websocketService.sendPrivateMessageAsync(userId, ImPrivateMessageDTO.ofFriendNotification(
ImMessageTypeEnum.FRIEND_UNBLOCK.getType(), userId, userId, payload));
websocketService.sendNotificationAsync(userId, ImConversationTypeEnum.NONE.getType(),
ImContentTypeEnum.FRIEND_UNBLOCK.getType(), payload);
}
/**
@ -294,7 +299,8 @@ public class ImFriendServiceImpl implements ImFriendService {
}
// 情况二:复用 DISABLE 旧记录 → 恢复 ENABLE + 重置 silent / pinned / blocked对齐"重新加好友"语义
if (exists != null) {
friendMapper.updateReAddFields(exists.getId(), CommonStatusEnum.ENABLE.getStatus(), LocalDateTime.now(),
LocalDateTime now = LocalDateTime.now();
friendMapper.updateReAddFields(exists.getId(), CommonStatusEnum.ENABLE.getStatus(), now, now,
false, false, false, displayName, addSource);
return;
}

View File

@ -87,13 +87,12 @@ public interface ImGroupMemberService {
List<ImGroupMemberDO> getActiveGroupMemberListByUserId(Long userId);
/**
* 查询用户已退群的群成员记录DISABLE 状态
* 查询用户曾经加入的所有群成员记录(含已退群)
*
* @param userId 用户编号,必传
* @param minQuitTime 最早退群时间(含),可空
* @return 已退群成员记录列表
* @param userId 用户编号
* @return 群成员记录列表
*/
List<ImGroupMemberDO> getQuitGroupMemberListByUserId(Long userId, LocalDateTime minQuitTime);
List<ImGroupMemberDO> getGroupMemberListByUserId(Long userId);
/**
* 添加群成员(入群),角色默认 MEMBER

View File

@ -77,7 +77,6 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService {
@Override
public List<ImGroupMemberDO> getGroupMemberListByOwnerAndAdmin(Long groupId) {
// TODO DONE @AI把条件往下传这样减少加载数据量
return groupMemberMapper.selectListByGroupIdAndStatusAndRoles(groupId, CommonStatusEnum.ENABLE.getStatus(),
ListUtil.of(ImGroupMemberRoleEnum.OWNER.getRole(), ImGroupMemberRoleEnum.ADMIN.getRole()));
}
@ -106,8 +105,8 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService {
}
@Override
public List<ImGroupMemberDO> getQuitGroupMemberListByUserId(Long userId, LocalDateTime minQuitTime) {
return groupMemberMapper.selectQuitListByUserId(userId, minQuitTime);
public List<ImGroupMemberDO> getGroupMemberListByUserId(Long userId) {
return groupMemberMapper.selectListByUserId(userId);
}
@Override
@ -259,7 +258,7 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService {
.setId(member.getId());
groupMemberMapper.updateById(updateObj);
// 3.1 displayUserName 是公开字段,单独走 GROUP_MEMBER_NICKNAME_UPDATE 广播给全员;空串视为「清空昵称」也要广播;与旧值相同跳过
// 3.1 displayUserName 是公开字段,单独走 GROUP_MEMBER_NICKNAME_UPDATE 在线同步给全员;空串视为「清空昵称」也要同步;与旧值相同跳过
if (updateReqVO.getDisplayUserName() != null
&& ObjUtil.notEqual(updateReqVO.getDisplayUserName(), member.getDisplayUserName())) {
groupMessageService.sendGroupMessage(userId, ImGroupMessageSendDTO.ofGroupMemberNicknameUpdate(

View File

@ -63,6 +63,19 @@ public interface ImGroupRequestService {
*/
List<ImGroupRequestDO> getUnhandledRequestListByOwnerOrAdmin(Long userId);
/**
* 增量拉取「我管理的群」下的加群申请(重连 / 离线补偿:含已处理,按 update_time + id 游标)
* <p>
* 作用域与 {@link #getUnhandledRequestListByOwnerOrAdmin} 一致:按 ImGroupMember.role 取我作为 OWNER / ADMIN 的群
*
* @param userId 当前用户编号
* @param lastUpdateTime 游标:上次拉取的最后一条更新时间戳(毫秒)
* @param lastId 游标:上次拉取的最后一条申请编号
* @param limit 单次拉取条数
* @return 申请记录列表
*/
List<ImGroupRequestDO> pullGroupRequestList(Long userId, Long lastUpdateTime, Long lastId, Integer limit);
/**
* 拉取指定群下的全部加群申请(含已处理);仅群主 / 管理员可查
* <p>

View File

@ -13,15 +13,15 @@ import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupRequestMapper;
import cn.iocoder.yudao.module.im.enums.group.ImGroupAddSourceEnum;
import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum;
import cn.iocoder.yudao.module.im.enums.group.ImGroupRequestHandleResultEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService;
import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO;
import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.group.BaseGroupNotification;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.group.GroupRequestApprovedNotification;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.group.GroupRequestReceivedNotification;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.group.GroupRequestRejectedNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.group.BaseGroupNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.group.GroupRequestApprovedNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.group.GroupRequestReceivedNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.group.GroupRequestRejectedNotification;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
@ -97,12 +97,12 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
// 3. 情况二:群开启了审批,创建或复用一条主动申请记录
ImGroupRequestDO request = createOrResetApplyRequest(groupId, userId, reqVO);
// 4. 1503 私聊定向推群主 + 全部管理员多端同步payload 携带申请方昵称 / 头像
// 4. 1503 定向推群主 + 全部管理员多端同步payload 携带申请方昵称 / 头像
AdminUserRespDTO applyUser = adminUserApi.getUser(userId);
GroupRequestReceivedNotification payload = buildRequestNotification(group, request, applyUser);
for (Long receiverUserId : getGroupMemberListByOwnerAndAdminUserIds(group)) {
websocketService.sendPrivateMessageAsync(receiverUserId, ImPrivateMessageDTO.ofGroupNotification(
ImMessageTypeEnum.GROUP_REQUEST_RECEIVED.getType(), userId, receiverUserId, payload));
websocketService.sendNotificationAsync(receiverUserId, ImConversationTypeEnum.NONE.getType(),
ImContentTypeEnum.GROUP_REQUEST_RECEIVED.getType(), payload);
}
return request;
}
@ -139,12 +139,12 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
groupMemberService.addGroupMember(request.getGroupId(), request.getUserId(),
ImGroupMemberRoleEnum.NORMAL.getRole(), request.getAddSource(), request.getInviterUserId());
// 5.1 1505 私聊推送给申请人 + 群主 + 全部管理员(每端单推)
// 5.1 1505 定向推送给申请人 + 群主 + 全部管理员(每端单推)
GroupRequestApprovedNotification payload = (GroupRequestApprovedNotification) new GroupRequestApprovedNotification()
.setRequestId(request.getId()).setGroupId(request.getGroupId()).setUserId(request.getUserId())
.setOperatorUserId(userId);
broadcastToOwnerAdminsAndApplicant(request.getGroupId(), request.getUserId(), payload,
ImMessageTypeEnum.GROUP_REQUEST_APPROVED.getType(), userId);
ImContentTypeEnum.GROUP_REQUEST_APPROVED.getType(), userId);
// 5.2 群事件:主动申请 → 1510 自由进群;被邀请 → 1509 成员加入
if (request.getInviterUserId() == null) {
groupMessageService.sendGroupMessage(userId,
@ -174,12 +174,12 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
throw exception(GROUP_REQUEST_HANDLED);
}
// 3. 1506 私聊推送给申请人 + 群主 + 全部管理员
// 3. 1506 定向推送给申请人 + 群主 + 全部管理员
GroupRequestRejectedNotification payload = (GroupRequestRejectedNotification) new GroupRequestRejectedNotification()
.setRequestId(request.getId()).setGroupId(request.getGroupId()).setUserId(request.getUserId())
.setHandleContent(handleContent).setOperatorUserId(userId);
broadcastToOwnerAdminsAndApplicant(request.getGroupId(), request.getUserId(), payload,
ImMessageTypeEnum.GROUP_REQUEST_REJECTED.getType(), userId);
ImContentTypeEnum.GROUP_REQUEST_REJECTED.getType(), userId);
}
@Override
@ -201,8 +201,8 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
AdminUserRespDTO applyUser = userMap.get(request.getUserId());
GroupRequestReceivedNotification payload = buildRequestNotification(group, request, applyUser);
for (Long receiverUserId : ownerAndAdmins) {
websocketService.sendPrivateMessageAsync(receiverUserId, ImPrivateMessageDTO.ofGroupNotification(
ImMessageTypeEnum.GROUP_REQUEST_RECEIVED.getType(), inviterUserId, receiverUserId, payload));
websocketService.sendNotificationAsync(receiverUserId, ImConversationTypeEnum.NONE.getType(),
ImContentTypeEnum.GROUP_REQUEST_RECEIVED.getType(), payload);
}
}
}
@ -212,8 +212,7 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
// 1. 找出当前用户作为 OWNER / ADMIN 的所有群
List<ImGroupMemberDO> myMembers = groupMemberService.getActiveGroupMemberListByUserId(userId);
Set<Long> ownerOrAdminGroupIds = convertSet(myMembers,
ImGroupMemberDO::getGroupId,
m -> ImGroupMemberRoleEnum.isOwnerOrAdmin(m.getRole()));
ImGroupMemberDO::getGroupId, member -> ImGroupMemberRoleEnum.isOwnerOrAdmin(member.getRole()));
if (CollUtil.isEmpty(ownerOrAdminGroupIds)) {
return Collections.emptyList();
}
@ -222,6 +221,19 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
ownerOrAdminGroupIds, ImGroupRequestHandleResultEnum.UNHANDLED.getResult());
}
@Override
public List<ImGroupRequestDO> pullGroupRequestList(Long userId, Long lastUpdateTime, Long lastId, Integer limit) {
// 1. 找出当前用户作为 OWNER / ADMIN 的所有群
List<ImGroupMemberDO> myMembers = groupMemberService.getActiveGroupMemberListByUserId(userId);
Set<Long> ownerOrAdminGroupIds = convertSet(myMembers,
ImGroupMemberDO::getGroupId, member -> ImGroupMemberRoleEnum.isOwnerOrAdmin(member.getRole()));
if (CollUtil.isEmpty(ownerOrAdminGroupIds)) {
return Collections.emptyList();
}
// 2. 按游标增量拉取这些群下的申请
return groupRequestMapper.selectPullListByGroupIds(ownerOrAdminGroupIds, lastUpdateTime, lastId, limit);
}
@Override
public List<ImGroupRequestDO> getGroupRequestListByGroupId(Long userId, Long groupId) {
// 1. 校验群存在 + 当前用户是群主 / 管理员
@ -383,7 +395,7 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
}
/**
* 1505 / 1506 受众:申请人 + 群主 + 全部管理员;每端单独推一帧,前端按 receiver 是否申请人区分文案
* 1505 / 1506 受众:申请人 + 群主 + 全部管理员
*/
private void broadcastToOwnerAdminsAndApplicant(Long groupId, Long applicantUserId, BaseGroupNotification payload,
Integer messageType, Long operatorUserId) {
@ -394,8 +406,8 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
Set<Long> receivers = new LinkedHashSet<>(getGroupMemberListByOwnerAndAdminUserIds(group));
receivers.add(applicantUserId);
for (Long receiverUserId : receivers) {
websocketService.sendPrivateMessageAsync(receiverUserId, ImPrivateMessageDTO.ofGroupNotification(
messageType, operatorUserId, receiverUserId, payload));
websocketService.sendNotificationAsync(receiverUserId, ImConversationTypeEnum.NONE.getType(),
messageType, payload);
}
}

View File

@ -30,7 +30,7 @@ import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMapper;
import cn.iocoder.yudao.module.im.enums.group.ImGroupAddSourceEnum;
import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import cn.iocoder.yudao.module.im.framework.config.ImProperties;
import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService;
@ -149,7 +149,9 @@ public class ImGroupServiceImpl implements ImGroupService {
boolean nameChanged = StrUtil.isNotEmpty(updateReqVO.getName());
boolean noticeChanged = updateReqVO.getNotice() != null;
boolean avatarChanged = StrUtil.isNotEmpty(updateReqVO.getAvatar());
if (nameChanged || noticeChanged || avatarChanged) {
boolean joinApprovalChanged = updateReqVO.getJoinApproval() != null
&& ObjUtil.notEqual(group.getJoinApproval(), updateReqVO.getJoinApproval());
if (nameChanged || noticeChanged || avatarChanged || joinApprovalChanged) {
List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(groupId);
if (nameChanged) {
groupMessageService.sendGroupMessage(userId, memberUserIds, ImGroupMessageSendDTO.ofGroupNameUpdate(
@ -161,7 +163,11 @@ public class ImGroupServiceImpl implements ImGroupService {
}
if (avatarChanged) {
groupMessageService.sendGroupMessage(userId, memberUserIds, ImGroupMessageSendDTO.ofGroupInfoUpdate(
groupId, userId, group.getAvatar(), updateReqVO.getAvatar()));
groupId, userId, group.getAvatar(), updateReqVO.getAvatar(), null, null));
}
if (joinApprovalChanged) {
groupMessageService.sendGroupMessage(userId, memberUserIds, ImGroupMessageSendDTO.ofGroupInfoUpdate(
groupId, userId, null, null, group.getJoinApproval(), updateReqVO.getJoinApproval()));
}
}
@ -193,8 +199,6 @@ public class ImGroupServiceImpl implements ImGroupService {
.setStatus(CommonStatusEnum.DISABLE.getStatus()).setDissolvedTime(LocalDateTime.now()));
// 2.2 移除全部群成员
groupMemberService.removeGroupMembersByGroupId(id);
// 2.3 清理已读缓存
groupMessageService.deleteReadMaxMessageIdMap(id);
}
// ==================== 群成员的写操作 ====================
@ -267,10 +271,8 @@ public class ImGroupServiceImpl implements ImGroupService {
// 2. 先发广播,后移成员(见类 javadoc
groupMessageService.sendGroupMessage(userId, ImGroupMessageSendDTO.ofGroupMemberQuit(groupId, userId));
// 3.1 移除群成员
// 3. 移除群成员
groupMemberService.removeGroupMember(groupId, userId);
// 3.2 清理已读缓存
groupMessageService.deleteReadMaxMessageId(groupId, userId);
}
@Override
@ -307,10 +309,8 @@ public class ImGroupServiceImpl implements ImGroupService {
groupMessageService.sendGroupMessage(userId,
ImGroupMessageSendDTO.ofGroupMemberKick(groupId, userId, validTargetUserIds));
// 3.1 批量移除群成员
// 3. 批量移除群成员;不清理读位置(保留退群前历史已读,供离线补偿)
groupMemberService.removeGroupMembers(groupId, validTargetUserIds);
// 3.2 批量清理已读缓存
groupMessageService.deleteReadMaxMessageIds(groupId, validTargetUserIds);
}
@Override
@ -442,7 +442,7 @@ public class ImGroupServiceImpl implements ImGroupService {
if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId)) {
throw exception(MESSAGE_NOT_IN_GROUP);
}
if (!ImMessageTypeEnum.validate(message.getType()).isNormal()
if (!ImContentTypeEnum.validate(message.getType()).isNormal()
|| ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) {
throw exception(MESSAGE_NOT_IN_GROUP);
}
@ -601,11 +601,8 @@ public class ImGroupServiceImpl implements ImGroupService {
@Override
public List<ImGroupDO> getMyGroupList(Long userId) {
// 1.1 查用户所在的、仍有效的群成员记录(仅 ENABLE 状态)
List<ImGroupMemberDO> members = groupMemberService.getActiveGroupMemberListByUserId(userId);
// 1.2 再查最近 N 天(与群消息离线拉取窗口一致)内退群的成员记录(退群前可能有离线消息需要展示,一并返回作为前端缓存)
LocalDateTime minQuitTime = LocalDateTime.now().minusDays(imProperties.getMessage().getGroupPullMaxDays());
members.addAll(groupMemberService.getQuitGroupMemberListByUserId(userId, minQuitTime));
// 1. 查用户曾经加入的所有群(含退群,前端按需过滤);退群前的离线消息也要能展示
List<ImGroupMemberDO> members = groupMemberService.getGroupMemberListByUserId(userId);
if (CollUtil.isEmpty(members)) {
return Collections.emptyList();
}

View File

@ -27,7 +27,7 @@ public interface ImChannelMessageService {
* @param size 返回条数
* @return 频道消息列表;按 id 升序
*/
List<ImChannelMessageDO> getMessageListForPull(Long userId, Long minId, Integer size);
List<ImChannelMessageDO> pullChannelMessageList(Long userId, Long minId, Integer size);
/**
* 上报频道消息已读位置;同步推 READ 事件给自己多端

View File

@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.im.service.message;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
@ -11,12 +12,14 @@ import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.channel.Im
import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO;
import cn.iocoder.yudao.module.im.dal.mysql.message.ImChannelMessageMapper;
import cn.iocoder.yudao.module.im.dal.redis.message.ImChannelMessageReadRedisDAO;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import cn.iocoder.yudao.module.im.service.channel.ImChannelMaterialService;
import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService;
import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImChannelMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.dto.message.MaterialMessage;
import cn.iocoder.yudao.module.im.service.websocket.notification.message.ImChannelMessageNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.message.ImMessageReadNotification;
import cn.iocoder.yudao.module.im.dal.dataobject.message.content.MaterialMessage;
import jakarta.annotation.Resource;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@ -25,7 +28,6 @@ import org.springframework.validation.annotation.Validated;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -50,39 +52,41 @@ public class ImChannelMessageServiceImpl implements ImChannelMessageService {
private ImWebSocketService webSocketService;
@Resource
private ImChannelMessageReadRedisDAO channelMessageReadRedisDAO;
private ImConversationReadService conversationReadService;
// ==================== 用户端 ====================
@Override
public List<ImChannelMessageDO> getMessageListForPull(Long userId, Long minId, Integer size) {
public List<ImChannelMessageDO> pullChannelMessageList(Long userId, Long minId, Integer size) {
return channelMessageMapper.selectListByUserAndMinId(userId, minId, size);
}
@Override
public Map<Long, Long> getChannelReadMaxMessageIdMap(Long userId, Collection<Long> channelIds) {
Map<Long, Long> result = new HashMap<>(channelIds.size());
for (Long channelId : channelIds) {
Long max = channelMessageReadRedisDAO.getReadMaxMessageId(channelId, userId);
if (max != null) {
result.put(channelId, max);
}
}
return result;
return conversationReadService.getConversationReadMessageIdMap(
userId, ImConversationTypeEnum.CHANNEL.getType(), channelIds);
}
@Override
public void readChannelMessages(Long userId, Long channelId, Long messageId) {
Assert.notNull(channelId, "频道编号不能为空");
Assert.notNull(messageId, "已读消息编号不能为空");
// 1. 已读位置未前进,直接返回
Long prevMaxMessageId = channelMessageReadRedisDAO.getReadMaxMessageId(channelId, userId);
if (prevMaxMessageId != null && prevMaxMessageId >= messageId) {
// 1.1 校验消息真实存在且属于该频道,避免未来 / 伪造 messageId 污染读位置
ImChannelMessageDO message = channelMessageMapper.selectById(messageId);
if (message == null || ObjUtil.notEqual(message.getChannelId(), channelId)) {
return;
}
// 1.2 定向消息校验对当前用户可见receiver_user_ids 为空表示全员可见)
if (CollUtil.isNotEmpty(message.getReceiverUserIds()) && !message.getReceiverUserIds().contains(userId)) {
return;
}
// 2. 更新 Redis 频道已读位置
channelMessageReadRedisDAO.updateReadMaxMessageId(channelId, userId, messageId);
// 2. 更新频道已读位置;读位置未前进则不推
boolean advanced = conversationReadService.updateConversationReadPosition(
userId, ImConversationTypeEnum.CHANNEL.getType(), channelId, messageId);
if (!advanced) {
return;
}
// 3. 异步推 READ 事件给自己多端同步
getSelf().readChannelMessageEvent(userId, channelId, messageId);
@ -93,7 +97,8 @@ public class ImChannelMessageServiceImpl implements ImChannelMessageService {
*/
@Async
public void readChannelMessageEvent(Long userId, Long channelId, Long readId) {
webSocketService.sendChannelMessageAsync(userId, ImChannelMessageDTO.ofRead(channelId, readId));
webSocketService.sendNotificationAsync(userId, ImConversationTypeEnum.CHANNEL.getType(),
ImContentTypeEnum.READ.getType(), ImMessageReadNotification.ofChannel(channelId, readId));
}
private ImChannelMessageServiceImpl getSelf() {
@ -113,15 +118,16 @@ public class ImChannelMessageServiceImpl implements ImChannelMessageService {
String payloadJson = JsonUtils.toJsonString(payload);
// 2.2 落库 1 行 messagereqVO 同名字段materialId / receiverUserIds自动拷贝剩余字段补 set
ImChannelMessageDO message = BeanUtils.toBean(reqVO, ImChannelMessageDO.class).setChannelId(material.getChannelId())
.setType(ImMessageTypeEnum.MATERIAL.getType()).setContent(payloadJson).setSendTime(LocalDateTime.now());
.setType(ImContentTypeEnum.MATERIAL.getType()).setContent(payloadJson).setSendTime(LocalDateTime.now());
channelMessageMapper.insert(message);
// 3. 异步推 WebSocket指定用户走点对点全员receiverUserIds 为空)走广播
ImChannelMessageDTO dto = ImChannelMessageDTO.ofSend(message);
ImChannelMessageNotification dto = ImChannelMessageNotification.ofSend(message);
if (CollUtil.isNotEmpty(reqVO.getReceiverUserIds())) {
webSocketService.sendChannelMessageAsync(reqVO.getReceiverUserIds(), dto);
webSocketService.sendNotificationAsync(reqVO.getReceiverUserIds(), ImConversationTypeEnum.CHANNEL.getType(),
dto.getType(), dto);
} else {
webSocketService.broadcastChannelMessageAsync(dto);
webSocketService.broadcastNotificationAsync(ImConversationTypeEnum.CHANNEL.getType(), dto.getType(), dto);
}
return message.getId();
}

View File

@ -98,35 +98,6 @@ public interface ImGroupMessageService {
*/
List<ImGroupMessageDO> getGroupMessageList(Long userId, ImGroupMessageListReqVO reqVO);
/**
* 清理用户在某群的已读位置缓存
* <p>
* 用于成员退群场景
*
* @param groupId 群编号
* @param userId 用户编号
*/
void deleteReadMaxMessageId(Long groupId, Long userId);
/**
* 批量清理用户在某群的已读位置缓存
* <p>
* 用于批量踢出场景
*
* @param groupId 群编号
* @param userIds 用户编号集合
*/
void deleteReadMaxMessageIds(Long groupId, Collection<Long> userIds);
/**
* 清理某群所有用户的已读位置缓存
* <p>
* 用于群解散场景
*
* @param groupId 群编号
*/
void deleteReadMaxMessageIdMap(Long groupId);
// ==================== 管理后台 ====================
/**

View File

@ -6,7 +6,6 @@ import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
@ -17,23 +16,27 @@ import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO;
import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper;
import cn.iocoder.yudao.module.im.dal.redis.message.ImGroupMessageReadRedisDAO;
import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum;
import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import cn.iocoder.yudao.module.im.framework.config.ImProperties;
import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService;
import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService;
import cn.iocoder.yudao.module.im.service.group.ImGroupService;
import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO;
import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.dto.message.QuoteMessage;
import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage;
import cn.iocoder.yudao.module.im.service.websocket.notification.message.ImGroupMessageNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.message.ImMessageReadNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.message.ImMessageReceiptNotification;
import cn.iocoder.yudao.module.im.dal.dataobject.message.content.QuoteMessage;
import cn.iocoder.yudao.module.im.dal.dataobject.message.content.RecallMessage;
import cn.iocoder.yudao.module.im.util.ImMessageUtils;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -41,7 +44,6 @@ import org.springframework.validation.annotation.Validated;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*;
@ -57,17 +59,8 @@ import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
@Slf4j
public class ImGroupMessageServiceImpl implements ImGroupMessageService {
/**
* 仅用于规避群消息 pull 的"假空页"
* 首批消息可能因入群时间或定向接收过滤后变成空列表,但后续更大的 id 仍然存在可见消息。
* 因此仅在过滤结果为空时,按本轮消息最大 id 向后再试几次。
*/
private static final int PULL_GROUP_MESSAGE_EMPTY_RETRY_TIMES = 3;
@Resource
private ImGroupMessageMapper groupMessageMapper;
@Resource
private ImGroupMessageReadRedisDAO groupMessageReadRedisDAO;
@Resource
private ImGroupService groupService;
@ -75,6 +68,8 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
private ImGroupMemberService groupMemberService;
@Resource
private ImSensitiveWordService sensitiveWordService;
@Resource
private ImConversationReadService conversationReadService;
@Resource
private ImWebSocketService imWebSocketService;
@ -100,34 +95,44 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
// 1.4 禁言校验
validateMuteStatus(group, senderMember);
// 1.5 文本消息敏感词过滤
if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) {
if (ImContentTypeEnum.TEXT.getType().equals(reqVO.getType())) {
sensitiveWordService.validateText(reqVO.getContent());
}
// 2.1 引用 quote 消息规范化
reqVO.setContent(normalizeQuoteContent(reqVO, senderMember));
// 2.2 构建并保存消息
// 2.1 固化发送当时可见成员快照用户发送均为全员广播getReceiverUserIds 兜底纳入发送者,钉死发送者必可见
List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(reqVO.getGroupId());
Set<Long> receiverUserIds = getReceiverUserIds(null, senderId, memberUserIds);
// 2.2 引用 quote 消息规范化(含可见性子集校验,防止定向消息内容被广播引用泄漏)
reqVO.setContent(normalizeQuoteContent(reqVO, senderMember, receiverUserIds));
// 2.3 构建并保存消息;唯一键冲突时回查已存在消息返回
ImGroupMessageDO message = BeanUtils.toBean(reqVO, ImGroupMessageDO.class, m -> m
.setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now())
.setSenderId(senderId).setStatus(ImMessageStatusEnum.NORMAL.getStatus()).setSendTime(LocalDateTime.now())
.setReceiverUserIds(new ArrayList<>(receiverUserIds))
.setReceiptStatus(resolveReceiptStatus(reqVO.getReceipt())));
groupMessageMapper.insert(message);
try {
groupMessageMapper.insert(message);
} catch (DuplicateKeyException e) {
log.warn("[sendGroupMessage][senderId({}) clientMessageId({}) 并发插入冲突,回查返回]",
senderId, reqVO.getClientMessageId());
return groupMessageMapper.selectBySenderIdAndClientMessageId(senderId, reqVO.getClientMessageId());
}
// 3. WebSocket 异步推送(内可见成员 + 发送方多端同步)
List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(message.getGroupId());
Set<Long> targetUserIds = getVisibleUserIds(message.getReceiverUserIds(), senderId, memberUserIds);
imWebSocketService.sendGroupMessageAsync(targetUserIds, ImGroupMessageDTO.ofSend(message));
// 3. WebSocket 异步推送(快照内可见成员 + 发送方多端同步)
ImGroupMessageNotification notification = ImGroupMessageNotification.ofSend(message);
imWebSocketService.sendNotificationAsync(receiverUserIds, ImConversationTypeEnum.GROUP.getType(),
notification.getType(), notification);
return message;
}
@Override
public ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendDTO dto) {
List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(dto.getGroupId());
Set<Long> targetUserIds = getVisibleUserIds(dto.getReceiverUserIds(), senderId, memberUserIds);
return sendGroupMessage(senderId, targetUserIds, dto);
Set<Long> receiverUserIds = getReceiverUserIds(dto.getReceiverUserIds(), senderId, memberUserIds);
return sendGroupMessage(senderId, receiverUserIds, dto);
}
@Override
public ImGroupMessageDO sendGroupMessage(Long senderId, Collection<Long> targetUserIds, ImGroupMessageSendDTO dto) {
public ImGroupMessageDO sendGroupMessage(Long senderId, Collection<Long> receiverUserIds, ImGroupMessageSendDTO dto) {
// 1.1 content 序列化null / String 透传POJO 走 JSON
Object payload = dto.getContent();
String contentString = payload == null || payload instanceof String
@ -137,16 +142,18 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
ImGroupMessageDO message = new ImGroupMessageDO().setClientMessageId(IdUtil.fastSimpleUUID())
.setSenderId(senderId).setGroupId(dto.getGroupId())
.setType(dto.getType()).setContent(contentString)
.setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now())
.setAtUserIds(dto.getAtUserIds()).setReceiverUserIds(dto.getReceiverUserIds())
.setStatus(ImMessageStatusEnum.NORMAL.getStatus()).setSendTime(LocalDateTime.now())
.setAtUserIds(dto.getAtUserIds()).setReceiverUserIds(new ArrayList<>(receiverUserIds))
.setReceiptStatus(resolveReceiptStatus(dto.getReceipt()));
// 1.3 按 type.persistent 决定是否入库
if (ImMessageTypeEnum.validate(dto.getType()).isPersistent()) {
if (ImContentTypeEnum.validate(dto.getType()).isPersistent()) {
groupMessageMapper.insert(message);
}
// 2. WebSocket 异步推送
imWebSocketService.sendGroupMessageAsync(targetUserIds, ImGroupMessageDTO.ofSend(message));
ImGroupMessageNotification notification = ImGroupMessageNotification.ofSend(message);
imWebSocketService.sendNotificationAsync(receiverUserIds, ImConversationTypeEnum.GROUP.getType(),
notification.getType(), notification);
return message;
}
@ -180,7 +187,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
// 3. 发送撤回事件
return sendGroupMessage(userId, new ImGroupMessageSendDTO().setGroupId(message.getGroupId())
.setType(ImMessageTypeEnum.RECALL.getType())
.setType(ImContentTypeEnum.RECALL.getType())
.setReceiverUserIds(message.getReceiverUserIds())
.setContent(new RecallMessage().setMessageId(messageId)));
}
@ -195,155 +202,53 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
// 0. 拉取时间窗;超过窗口的老消息不再通过离线通道推送
LocalDateTime minSendTime = LocalDateTime.now().minusDays(imProperties.getMessage().getGroupPullMaxDays());
// 1.1 主查询:仅用"当前仍在群"的成员记录驱动;若首批消息过滤后为空,则允许内部重试
List<ImGroupMemberDO> activeMembers = groupMemberService.getActiveGroupMemberListByUserId(userId);
Map<Long, ImGroupMemberDO> memberMap = convertMap(activeMembers, ImGroupMemberDO::getGroupId);
List<ImGroupMessageDO> messages = new ArrayList<>();
if (CollUtil.isNotEmpty(activeMembers)) {
List<Long> groupIds = convertList(activeMembers, ImGroupMemberDO::getGroupId);
messages.addAll(pullActiveGroupMessageList(userId, groupIds, minId, size, minSendTime, memberMap));
// 1. 候选群 = 用户曾经加入的所有群(含退群);可见性与时间窗都交给 SQL退群群最多贡献 0 条
Set<Long> groupIds = convertSet(groupMemberService.getGroupMemberListByUserId(userId),
ImGroupMemberDO::getGroupId);
if (CollUtil.isEmpty(groupIds)) {
return Collections.emptyList();
}
// 1.2 补齐"退群前"的消息:
// - 继续基于用户原始 minId 单次查询,不能被主查询内部探测游标带着前进;
// - 若 minId > 0 且能查到对应消息,则进一步把下限抬到该消息的 sendTime避免把客户端已拥有的老消息再次推送。
messages.addAll(pullQuitGroupMessageList(userId, minId, size, minSendTime, memberMap));
// 2. 合并后统一过滤,得到当前用户可见的结果
List<ImGroupMessageDO> result = filterGroupMessageList(messages, memberMap, userId, size);
// 2. 按 receiver_user_ids 快照过滤可见性,结果按 id 升序
List<ImGroupMessageDO> messages = groupMessageMapper.selectListByMinId(userId, groupIds, minId, minSendTime, size);
// 3. 按当前用户补齐:消息已读态(相对 Redis 已读游标)、本人发送的回执消息的已读人数
appendMessageStatusAndReceipt(userId, result);
log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]", userId, minId, size, result.size());
return result;
}
/**
* 拉取当前仍在群的主路径消息。
*
* 仅当首批消息过滤后仍无可见消息时,才按消息最大 id 继续向后探测,
* 直到命中可见消息或确认该来源已耗尽。
*/
private List<ImGroupMessageDO> pullActiveGroupMessageList(Long userId, List<Long> groupIds, Long minId,
Integer size, LocalDateTime minSendTime,
Map<Long, ImGroupMemberDO> memberMap) {
// 1. 主查询内部探测游标:仅用于向后探测,不代表客户端真实已送达边界
Long activeMinId = minId;
for (int retryCount = 0; retryCount <= PULL_GROUP_MESSAGE_EMPTY_RETRY_TIMES; retryCount++) {
// 2. 查询本轮消息;若已无更多消息,则当前主路径直接结束
List<ImGroupMessageDO> messages = groupMessageMapper.selectListByMinId(groupIds, activeMinId,
minSendTime, size);
if (CollUtil.isEmpty(messages)) {
return Collections.emptyList();
}
boolean hasVisibleMessage = CollUtil.anyMatch(messages,
message -> isMessageVisible(message, memberMap.get(message.getGroupId()), userId));
boolean sourceExhausted = messages.size() < size;
// 3. 本轮已命中可见消息,或主查询来源已耗尽,直接返回这一轮消息
if (hasVisibleMessage || sourceExhausted) {
return messages;
}
// 4. 按本轮消息最大 id 推进内部游标,跳过这段不可见区间;若游标未前进则直接停止
Long maxMessageId = getMaxValue(messages, ImGroupMessageDO::getId);
if (maxMessageId == null || maxMessageId <= activeMinId) {
return Collections.emptyList();
}
activeMinId = maxMessageId;
}
return Collections.emptyList();
}
/**
* 拉取已离开群路径的消息(退群前消息)
*/
private List<ImGroupMessageDO> pullQuitGroupMessageList(Long userId, Long minId, Integer size,
LocalDateTime minSendTime,
Map<Long, ImGroupMemberDO> memberMap) {
// 1. 退群补齐始终基于用户原始 minId 计算时间边界,避免被主查询内部重试游标误伤
LocalDateTime minQuitTime = minSendTime;
if (minId != null && minId > 0) {
ImGroupMessageDO minMessage = groupMessageMapper.selectById(minId);
if (minMessage != null && minMessage.getSendTime() != null
&& minMessage.getSendTime().isAfter(minSendTime)) {
minQuitTime = minMessage.getSendTime();
}
}
// 2. 查询用户离开的群记录;若原始 minId 对应消息仍在窗口内,则用它的发送时间抬升退群筛选下限
List<ImGroupMessageDO> messages = new ArrayList<>();
List<ImGroupMemberDO> quitMembers = groupMemberService.getQuitGroupMemberListByUserId(userId, minQuitTime);
for (ImGroupMemberDO quitMember : quitMembers) {
// 3. 按原始 minId + 退群时间补齐该群退群前消息,并把成员记录写回 memberMap 供统一可见性过滤使用
List<ImGroupMessageDO> quitGroupMessages = groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(
quitMember.getGroupId(), minId, minSendTime, quitMember.getQuitTime(), size);
if (CollUtil.isEmpty(quitGroupMessages)) {
continue;
}
messages.addAll(quitGroupMessages);
memberMap.put(quitMember.getGroupId(), quitMember);
}
// 3. 补齐本人发送的回执消息的已读人数
appendMessageReceipt(userId, messages);
log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]", userId, minId, size, messages.size());
return messages;
}
/**
* 过滤一批原始群消息,得到当前用户可见的返回结果
* 补全本人发送消息的回执已读人数readCount
* <p>
* 仅对本人发送、且需要回执(非 NO_RECEIPT的消息按 receiver_user_ids 快照 ∩ 当前有效成员 - 发送者 算已读人数
*/
private List<ImGroupMessageDO> filterGroupMessageList(List<ImGroupMessageDO> messages,
Map<Long, ImGroupMemberDO> memberMap,
Long userId, Integer size) {
// 按可见性过滤(入群前不可见、定向消息排除),按 id 升序后仅取本页 size 条,
// 避免「在群 + 退群前」多路合并时一次响应跨度过大、游标直接跳到全局最大 id 而漏拉中间消息
return messages.stream()
.filter(msg -> isMessageVisible(msg, memberMap.get(msg.getGroupId()), userId))
.sorted(Comparator.comparing(ImGroupMessageDO::getId))
.limit(size)
.collect(Collectors.toList());
}
/**
* 补全消息已读态和回执已读人数
*
* 1. 消息已读态status根据 Redis 已读游标判断 READ / UNREAD
* 2. 回执已读人数readCount仅对本人发送的回执消息计算可见成员中的已读人数
*/
@SuppressWarnings({"StatementWithEmptyBody", "DataFlowIssue"})
private void appendMessageStatusAndReceipt(Long userId, List<ImGroupMessageDO> messages) {
@SuppressWarnings("DataFlowIssue")
private void appendMessageReceipt(Long userId, List<ImGroupMessageDO> messages) {
if (CollUtil.isEmpty(messages)) {
return;
}
// 群已读关闭:不查 Redis 已读游标status 保持 DB 原值(含撤回),readCount 不补齐
// 群已读关闭:不readCount
if (BooleanUtil.isFalse(imProperties.getMessage().isGroupReadEnabled())) {
return;
}
Map<Long, Long> readMaxMessageIdsByGroup = new HashMap<>(); // 群 → 已读位置
Map<Long, Map<Long, Long>> readPositionsByGroup = new HashMap<>(); // 群 → (用户 → 已读位置)
Map<Long, List<ImGroupMemberDO>> membersByGroup = new HashMap<>(); // 群 → 全部成员列表
for (ImGroupMessageDO message : messages) {
// 消息已读态status撤回 > 已读 > 未读
Long groupId = message.getGroupId();
long readMaxMessageId = readMaxMessageIdsByGroup.computeIfAbsent(groupId, gid -> {
Long readMaxMsgId = groupMessageReadRedisDAO.getReadMaxMessageId(gid, userId);
return readMaxMsgId != null ? readMaxMsgId : -1L;
});
if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) {
// 保持撤回态
} else if (readMaxMessageId >= message.getId()) {
message.setStatus(ImMessageStatusEnum.READ.getStatus());
} else {
message.setStatus(ImMessageStatusEnum.UNREAD.getStatus());
}
// 回执消息的已读人数readCount仅补齐本人发送的其他消息不处理回执消息才关心已读人数且只对发送者可见
// 仅补本人发送、且需要回执(非 NO_RECEIPT的消息
if (ObjUtil.notEqual(message.getSenderId(), userId)
|| ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus().equals(message.getReceiptStatus())) {
|| ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus().equals(message.getReceiptStatus())) {
continue;
}
Long groupId = message.getGroupId();
Map<Long, Long> positions = readPositionsByGroup.computeIfAbsent(groupId,
gid -> groupMessageReadRedisDAO.getReadMaxMessageIdMap(gid));
List<ImGroupMemberDO> allMembers = membersByGroup.computeIfAbsent(groupId,
groupMemberService::getGroupMemberListByGroupId);
Set<Long> visibleUserIds = getVisibleUserIds(message, allMembers);
visibleUserIds.remove(message.getSenderId());
int readCount = getSumValue(visibleUserIds,
gid -> conversationReadService.getUserReadMessageIdMap(ImConversationTypeEnum.GROUP.getType(), gid));
// 应读分母取当前有效成员(剔除已退群),与异步回执刷新、前端已读弹层口径一致
List<ImGroupMemberDO> activeMembers = membersByGroup.computeIfAbsent(groupId,
groupMemberService::getActiveGroupMemberListByGroupId);
Set<Long> receiverUserIds = getReceiverUserIds(message, activeMembers);
receiverUserIds.remove(message.getSenderId());
int readCount = getSumValue(receiverUserIds,
uid -> positions.getOrDefault(uid, -1L) >= message.getId() ? 1 : null,
Integer::sum, 0);
message.setReadCount(readCount);
@ -357,25 +262,24 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
throw exception(MESSAGE_GROUP_READ_DISABLED);
}
Assert.notNull(messageId, "已读消息编号不能为空");
// 1.1 获取群成员记录
ImGroupMemberDO member = groupMemberService.getGroupMember(groupId, userId);
// 1.2 校验消息属于当前群,且对当前用户可见
// 1. 校验消息属于当前群,且对当前用户可见(按快照)
ImGroupMessageDO message = groupMessageMapper.selectById(messageId);
if (message == null
|| ObjUtil.notEqual(message.getGroupId(), groupId)
|| !isMessageVisible(message, member, userId)) {
|| !isMessageReceived(message, userId)) {
throw exception(MESSAGE_NOT_IN_GROUP);
}
// 2. 读位置未前进,直接返回
Long prevMaxMessageId = groupMessageReadRedisDAO.getReadMaxMessageId(groupId, userId);
if (prevMaxMessageId != null && prevMaxMessageId >= messageId) {
// 2. 取旧读位置(用于回执刷新区间),再更新群已读位置;读位置未前进则不推
Long prevMaxMessageId = conversationReadService.getConversationReadMessageId(
userId, ImConversationTypeEnum.GROUP.getType(), groupId);
boolean advanced = conversationReadService.updateConversationReadPosition(
userId, ImConversationTypeEnum.GROUP.getType(), groupId, messageId);
if (!advanced) {
return;
}
// 3. 更新 Redis 群已读位置
groupMessageReadRedisDAO.updateReadMaxMessageId(groupId, userId, messageId);
// 4. 异步发送 READ 事件 + 刷新范围内的群回执
// 3. 异步发送 READ 事件 + 刷新范围内的群回执
getSelf().readGroupMessageEvent(userId, groupId, prevMaxMessageId, messageId);
}
@ -386,13 +290,13 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
throw exception(MESSAGE_GROUP_READ_DISABLED);
}
// 1.1 校验用户在群中(权限校验)
ImGroupMemberDO operator = groupMemberService.validateMemberInGroup(groupId, userId);
groupMemberService.validateMemberInGroup(groupId, userId);
// 1.2 获取消息;并校验消息归属于该群、对调用者可见、调用者是发送方
ImGroupMessageDO message = groupMessageMapper.selectById(messageId);
if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId)) {
return Collections.emptyList();
}
if (!isMessageVisible(message, operator, userId)) {
if (!isMessageReceived(message, userId)) {
return Collections.emptyList();
}
// 1.3 仅消息发送方关心已读人数;非发送方查询直接返回空
@ -400,18 +304,19 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
return Collections.emptyList();
}
// 2. 获取所有成员和已读位置
List<ImGroupMemberDO> allMembers = groupMemberService.getGroupMemberListByGroupId(groupId);
Map<Long, Long> allPositions = groupMessageReadRedisDAO.getReadMaxMessageIdMap(groupId);
// 2. 获取当前有效成员和已读位置(剔除已退群,与回执分母口径一致)
List<ImGroupMemberDO> activeMembers = groupMemberService.getActiveGroupMemberListByGroupId(groupId);
Map<Long, Long> allPositions = conversationReadService.getUserReadMessageIdMap(
ImConversationTypeEnum.GROUP.getType(), groupId);
// 3. 计算该消息的可见成员集合(排除发送者自己)
Set<Long> visibleUserIds = getVisibleUserIds(message, allMembers);
visibleUserIds.remove(message.getSenderId());
Set<Long> receiverUserIds = getReceiverUserIds(message, activeMembers);
receiverUserIds.remove(message.getSenderId());
// 4. 只返回在可见范围内且已读位置 >= messageId 的用户
List<Long> readUserIds = new ArrayList<>();
allPositions.forEach((uid, readMaxMessageId) -> {
if (visibleUserIds.contains(uid) && readMaxMessageId >= messageId) {
if (receiverUserIds.contains(uid) && readMaxMessageId >= messageId) {
readUserIds.add(uid);
}
});
@ -420,15 +325,14 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
@Override
public List<ImGroupMessageDO> getGroupMessageList(Long userId, ImGroupMessageListReqVO reqVO) {
// 1. 校验用户在群中
ImGroupMemberDO member = groupMemberService.validateMemberInGroup(reqVO.getGroupId(), userId);
// 1. 校验用户曾经在群(当前在群或已退群),与 pull 退群窗口口径一致;内容仍由 receiver_user_ids 快照过滤
if (groupMemberService.getGroupMember(reqVO.getGroupId(), userId) == null) {
throw exception(GROUP_MEMBER_NOT_IN_GROUP);
}
// 2. 查询历史消息(仅入群之后)
List<ImGroupMessageDO> messages = groupMessageMapper.selectHistoryList(
reqVO.getGroupId(), reqVO.getMaxId(), reqVO.getLimit(), member.getJoinTime());
// 3. 过滤定向消息仅保留当前用户可见的receiverUserIds 为空 / 含当前用户 / 本人发送)
return filterList(messages, message -> isMessageVisible(message, member, userId));
// 2. 查询历史消息SQL 已按 receiver_user_ids 快照过滤当前用户可见
return groupMessageMapper.selectHistoryListByUser(
userId, reqVO.getGroupId(), reqVO.getMaxId(), reqVO.getLimit());
}
// ========== 异步 WebSocket 推送 ==========
@ -445,8 +349,9 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
@SuppressWarnings("DataFlowIssue")
public void readGroupMessageEvent(Long userId, Long groupId, Long prevMaxMessageId, Long newMaxMessageId) {
// 1. 发送 READ 事件给自己的其他终端(多端同步)
imWebSocketService.sendGroupMessageAsync(userId,
ImGroupMessageDTO.ofRead(userId, groupId, newMaxMessageId));
imWebSocketService.sendNotificationAsync(userId, ImConversationTypeEnum.GROUP.getType(),
ImContentTypeEnum.READ.getType(),
ImMessageReadNotification.ofGroup(userId, groupId, newMaxMessageId));
// 2. 刷新 (prevMaxMessageId, newMaxMessageId] 区间内的待回执消息
List<ImGroupMessageDO> pendingMessages = groupMessageMapper.selectListByGroupIdAndPendingReceipt(
@ -455,28 +360,30 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
return;
}
List<ImGroupMemberDO> activeMembers = groupMemberService.getActiveGroupMemberListByGroupId(groupId);
Map<Long, Long> allPositions = groupMessageReadRedisDAO.getReadMaxMessageIdMap(groupId);
Map<Long, Long> allPositions = conversationReadService.getUserReadMessageIdMap(
ImConversationTypeEnum.GROUP.getType(), groupId);
for (ImGroupMessageDO message : pendingMessages) {
// 2.1.1 统计可见成员中的已读人数
Set<Long> visibleUserIds = getVisibleUserIds(message, activeMembers);
visibleUserIds.remove(message.getSenderId()); // 发送者自己不算已读
if (CollUtil.isEmpty(visibleUserIds)) {
// 2.1.1 统计可见成员中的已读人数(应读分母 = 快照 ∩ 当前有效成员 - sender
Set<Long> receiverUserIds = getReceiverUserIds(message, activeMembers);
receiverUserIds.remove(message.getSenderId()); // 发送者自己不算已读
if (CollUtil.isEmpty(receiverUserIds)) {
continue;
}
int readCount = getSumValue(visibleUserIds,
int readCount = getSumValue(receiverUserIds,
uid -> allPositions.getOrDefault(uid, -1L) >= message.getId() ? 1 : null,
Integer::sum, 0);
// 2.1.2 全部已读 → 标记回执完成
Integer newReceiptStatus = ImGroupMessageReceiptStatusEnum.PENDING.getStatus();
if (readCount >= visibleUserIds.size()) {
newReceiptStatus = ImGroupMessageReceiptStatusEnum.DONE.getStatus();
Integer newReceiptStatus = ImMessageReceiptStatusEnum.PENDING.getStatus();
if (readCount >= receiverUserIds.size()) {
newReceiptStatus = ImMessageReceiptStatusEnum.DONE.getStatus();
groupMessageMapper.updateById(new ImGroupMessageDO().setId(message.getId())
.setReceiptStatus(newReceiptStatus));
}
// 2.2 发送 RECEIPT 事件给消息发送方(只有 ta 关心已读进度)
imWebSocketService.sendGroupMessageAsync(message.getSenderId(),
ImGroupMessageDTO.ofReceipt(message.getId(), groupId, readCount, newReceiptStatus));
imWebSocketService.sendNotificationAsync(message.getSenderId(), ImConversationTypeEnum.GROUP.getType(),
ImContentTypeEnum.RECEIPT.getType(),
ImMessageReceiptNotification.ofGroup(message.getId(), groupId, readCount, newReceiptStatus));
}
}
@ -487,9 +394,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
*
* @param reqVO 发送请求
* @param senderMember 发送人成员
* @param newAudience 新消息可见成员集合(发送当时快照)
* @return 规范化后的 content
*/
private String normalizeQuoteContent(ImGroupMessageSendReqVO reqVO, ImGroupMemberDO senderMember) {
private String normalizeQuoteContent(ImGroupMessageSendReqVO reqVO, ImGroupMemberDO senderMember,
Set<Long> newAudience) {
// 解析客户端 content 里的 quote.messageId
Long quoteMessageId = ImMessageUtils.parseQuoteMessageId(reqVO.getContent());
@ -508,12 +417,13 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
if (ObjUtil.notEqual(original.getGroupId(), reqVO.getGroupId())) {
throw exception(MESSAGE_QUOTE_INVALID);
}
// 拒绝定向消息(仅发送人可见的内容若被全员广播 quote.content会泄漏给原本看不到的成员
if (CollUtil.isNotEmpty(original.getReceiverUserIds())) {
// 校验对发送人可见(按快照
if (!isMessageReceived(original, senderMember.getUserId())) {
throw exception(MESSAGE_QUOTE_INVALID);
}
// 校验对发送人可见(入群时间 / 退群时间)
if (!isMessageVisible(original, senderMember, senderMember.getUserId())) {
// 防泄漏:新消息可见集合必须是被引用消息可见集合的子集,否则禁止引用;
// 否则只对部分成员可见的定向消息内容,会随广播引用泄漏给原本看不到的成员
if (!new HashSet<>(original.getReceiverUserIds()).containsAll(newAudience)) {
throw exception(MESSAGE_QUOTE_INVALID);
}
// 构建 quote 对象并注入 content
@ -537,87 +447,48 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
}
/**
* 判断一条群消息对某个群成员是否可见
*
* @param msg 消息
* @param member 群成员
* @param userId 当前用户编号(用于定向消息过滤)
* @return 是否可见
*/
private boolean isMessageVisible(ImGroupMessageDO msg, ImGroupMemberDO member, Long userId) {
if (member == null) {
return false;
}
// 1. 入群时间晚于消息发送时间 → 不可见
if (member.getJoinTime() != null && msg.getSendTime().isBefore(member.getJoinTime())) {
return false;
}
// 2. 已退群且退群时间早于消息发送时间 → 不可见
if (CommonStatusEnum.DISABLE.getStatus().equals(member.getStatus())
&& member.getQuitTime() != null && msg.getSendTime().isAfter(member.getQuitTime())) {
return false;
}
// 3.1 无定向接收列表 → 全员可见
if (CollUtil.isEmpty(msg.getReceiverUserIds())) {
return true;
}
// 3.2 当前用户在定向列表中,或本人即发送者 → 可见
return msg.getReceiverUserIds().contains(userId)
|| ObjUtil.equal(msg.getSenderId(), userId);
}
/**
* 计算一条群消息的可见成员集合(含发送者)
*/
private Set<Long> getVisibleUserIds(ImGroupMessageDO message, List<ImGroupMemberDO> members) {
return convertSet(members, ImGroupMemberDO::getUserId,
member -> isMessageVisible(message, member, member.getUserId()));
}
/**
* 基于群成员 userId 列表,过滤出一条新消息的可见成员集合(含发送者)。
* 判断一条群消息是否被某个用户接收到
* <p>
* 仅适用于「新消息」推送场景({@code sendTime = now}),不涉及 joinTime / quitTime 判定,
* 只应用 {@code receiverUserIds} 定向过滤;语义与
* {@link #isMessageVisible(ImGroupMessageDO, ImGroupMemberDO, Long)} 的第 3 步保持一致。
* 接收范围以发送当时固化的 receiver_user_ids 快照为准;发送者也在快照内。
*
* @param message 消息
* @param userId 当前用户编号
* @return 是否接收到
*/
private Set<Long> getVisibleUserIds(List<Long> receiverUserIds, Long senderId, Collection<Long> memberUserIds) {
if (CollUtil.isEmpty(memberUserIds)) {
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
private boolean isMessageReceived(ImGroupMessageDO message, Long userId) {
return CollUtil.contains(message.getReceiverUserIds(), userId);
}
/**
* 计算一条群消息的可见成员集合(快照与传入成员的交集,含发送者)
*/
private Set<Long> getReceiverUserIds(ImGroupMessageDO message, List<ImGroupMemberDO> members) {
if (CollUtil.isEmpty(message.getReceiverUserIds())) {
return new HashSet<>();
}
// 无定向接收列表 → 全员可见
if (CollUtil.isEmpty(receiverUserIds)) {
return new HashSet<>(memberUserIds);
}
// 有定向接收列表 → 仅定向用户可见;发送者自己也能看到自己的消息(多端同步)
Set<Long> allowed = new HashSet<>(receiverUserIds);
Set<Long> snapshot = new HashSet<>(message.getReceiverUserIds());
return convertSet(members, ImGroupMemberDO::getUserId, member -> snapshot.contains(member.getUserId()));
}
/**
* 基于群成员 userId 列表,计算一条新消息的可见成员快照(含发送者)。
* <p>
* 仅适用于「新消息」发送场景:无定向接收列表则全员可见,否则取定向用户与当前成员的交集;
* 发送者始终纳入快照,保证 receiver_user_ids 非空且发送方可见。
*/
private Set<Long> getReceiverUserIds(List<Long> directedUserIds, Long senderId, Collection<Long> memberUserIds) {
// 无定向接收列表 → 全员可见;否则取定向用户与当前成员的交集
Set<Long> result = CollUtil.isEmpty(directedUserIds)
? new HashSet<>(memberUserIds)
: new HashSet<>(CollUtil.intersection(memberUserIds, directedUserIds));
// 发送者始终可见(多端同步),即便成员缓存暂未包含
if (senderId != null) {
allowed.add(senderId);
}
Set<Long> result = new HashSet<>();
for (Long userId : memberUserIds) {
if (allowed.contains(userId)) {
result.add(userId);
}
result.add(senderId);
}
return result;
}
@Override
public void deleteReadMaxMessageId(Long groupId, Long userId) {
groupMessageReadRedisDAO.deleteReadMaxMessageId(groupId, userId);
}
@Override
public void deleteReadMaxMessageIds(Long groupId, Collection<Long> userIds) {
groupMessageReadRedisDAO.deleteReadMaxMessageIds(groupId, userIds);
}
@Override
public void deleteReadMaxMessageIdMap(Long groupId) {
groupMessageReadRedisDAO.deleteReadMaxMessageIdMap(groupId);
}
// ==================== 管理后台 ====================
@Override
@ -652,11 +523,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
*/
private Integer resolveReceiptStatus(Boolean receipt) {
if (BooleanUtil.isFalse(imProperties.getMessage().isGroupReadEnabled())) {
return ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus();
return ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus();
}
return BooleanUtil.isTrue(receipt)
? ImGroupMessageReceiptStatusEnum.PENDING.getStatus()
: ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus();
? ImMessageReceiptStatusEnum.PENDING.getStatus()
: ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus();
}
/**

View File

@ -59,8 +59,8 @@ public interface ImPrivateMessageService {
/**
* 标记私聊消息已读
* <p>
* 语义:「对方发给当前用户、id <= messageId 的未读消息」一次性翻转为已读
* 与群聊 readGroupMessages 对称,避免"select-then-update"两步式带来的竞态。
* 语义:「对方发给当前用户、id <= messageId 的待回执消息」一次性更新为已完成DONE并前进读位置
* 与群聊 readGroupMessages 对称,避免select-then-update两步式带来的竞态。
*
* @param userId 当前用户编号
* @param receiverId 接收方用户编号(对方)
@ -72,7 +72,7 @@ public interface ImPrivateMessageService {
* 查询对方已读到我发的最大消息 id
* <p>
* 用于多端 / 离线场景下的已读位置补齐:客户端进入会话或断线重连后,
* 调用此接口拿到对方的 maxReadId再按 id <= maxReadId 翻转本地自发消息为已读,弥补离线期间错过的 RECEIPT 推送事件。
* 调用此接口拿到对方的 maxReadId再按 id <= maxReadId 更新本地自发消息的回执状态,弥补离线期间错过的 RECEIPT 推送事件。
*
* @param userId 当前用户编号
* @param peerId 对方用户编号

View File

@ -12,19 +12,25 @@ import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivate
import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper;
import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import cn.iocoder.yudao.module.im.framework.config.ImProperties;
import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService;
import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO;
import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.dto.message.QuoteMessage;
import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage;
import cn.iocoder.yudao.module.im.service.websocket.notification.message.ImMessageReadNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.message.ImMessageReceiptNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.message.ImPrivateMessageNotification;
import cn.iocoder.yudao.module.im.dal.dataobject.message.content.QuoteMessage;
import cn.iocoder.yudao.module.im.dal.dataobject.message.content.RecallMessage;
import cn.iocoder.yudao.module.im.util.ImMessageUtils;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
@ -52,6 +58,8 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
private ImFriendService friendService;
@Resource
private ImSensitiveWordService sensitiveWordService;
@Resource
private ImConversationReadService conversationReadService;
@Resource
private ImWebSocketService imWebSocketService;
@ -74,21 +82,32 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
// 1.3 好友校验
friendService.validateFriend(senderId, reqVO.getReceiverId());
// 1.4 文本消息敏感词过滤
if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) {
if (ImContentTypeEnum.TEXT.getType().equals(reqVO.getType())) {
sensitiveWordService.validateText(reqVO.getContent());
}
// 2.1 引用 quote 消息规范化
reqVO.setContent(normalizeQuoteContent(reqVO, senderId));
// 2.2 构建并保存消息
// 2.2 构建并保存消息;唯一键冲突时回查已存在消息返回
// 用户私聊消息默认需要回执receipt 不传按 true系统通知走 DTO 通道,默认不回执
Boolean receipt = reqVO.getReceipt() != null ? reqVO.getReceipt() : Boolean.TRUE;
ImPrivateMessageDO message = BeanUtils.toBean(reqVO, ImPrivateMessageDO.class, m -> m
.setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()));
privateMessageMapper.insert(message);
.setSenderId(senderId).setStatus(ImMessageStatusEnum.NORMAL.getStatus())
.setReceiptStatus(resolveReceiptStatus(receipt)).setSendTime(LocalDateTime.now()));
try {
privateMessageMapper.insert(message);
} catch (DuplicateKeyException e) {
log.warn("[sendPrivateMessage][senderId({}) clientMessageId({}) 并发插入冲突,回查返回]",
senderId, reqVO.getClientMessageId());
return privateMessageMapper.selectBySenderIdAndClientMessageId(senderId, reqVO.getClientMessageId());
}
// 3. WebSocket 异步推送:接收方 + 发送方多端同步
ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message);
imWebSocketService.sendPrivateMessageAsync(message.getReceiverId(), websocketMessage);
imWebSocketService.sendPrivateMessageAsync(senderId, websocketMessage);
ImPrivateMessageNotification notification = ImPrivateMessageNotification.ofSend(message);
imWebSocketService.sendNotificationAsync(message.getReceiverId(), ImConversationTypeEnum.PRIVATE.getType(),
notification.getType(), notification);
imWebSocketService.sendNotificationAsync(senderId, ImConversationTypeEnum.PRIVATE.getType(),
notification.getType(), notification);
return message;
}
@ -103,24 +122,39 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
ImPrivateMessageDO message = new ImPrivateMessageDO().setClientMessageId(IdUtil.fastSimpleUUID())
.setSenderId(senderId).setReceiverId(dto.getReceiverId())
.setType(dto.getType()).setContent(contentString)
.setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now());
.setStatus(ImMessageStatusEnum.NORMAL.getStatus())
.setReceiptStatus(resolveReceiptStatus(dto.getReceipt())).setSendTime(LocalDateTime.now());
// 1.3 决定是否持久化dto.persistent 优先;为 null 时按 type 默认
boolean persistent = dto.getPersistent() != null
? dto.getPersistent()
: ImMessageTypeEnum.validate(dto.getType()).isPersistent();
: ImContentTypeEnum.validate(dto.getType()).isPersistent();
if (persistent) {
privateMessageMapper.insert(message);
}
// 2. WebSocket 异步推送双向默认单边语义persistent=false下仅推 sender 多端,对方不感知
ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message);
ImPrivateMessageNotification notification = ImPrivateMessageNotification.ofSend(message);
if (persistent) {
imWebSocketService.sendPrivateMessageAsync(dto.getReceiverId(), websocketMessage);
imWebSocketService.sendNotificationAsync(dto.getReceiverId(), ImConversationTypeEnum.PRIVATE.getType(),
notification.getType(), notification);
}
imWebSocketService.sendPrivateMessageAsync(senderId, websocketMessage);
imWebSocketService.sendNotificationAsync(senderId, ImConversationTypeEnum.PRIVATE.getType(),
notification.getType(), notification);
return message;
}
/**
* 计算私聊消息回执 status私聊已读关闭时强制 NO_RECEIPT忽略发送方传入的 receiptreceipt 为 null 等价 false
*/
private Integer resolveReceiptStatus(Boolean receipt) {
if (BooleanUtil.isFalse(imProperties.getMessage().isPrivateReadEnabled())) {
return ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus();
}
return BooleanUtil.isTrue(receipt)
? ImMessageReceiptStatusEnum.PENDING.getStatus()
: ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus();
}
@Override
@Transactional(rollbackFor = Exception.class)
public ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId) {
@ -149,7 +183,7 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
// 3. 发送撤回事件
return sendPrivateMessage(userId, new ImPrivateMessageSendDTO().setReceiverId(message.getReceiverId())
.setType(ImMessageTypeEnum.RECALL.getType()).setContent(new RecallMessage().setMessageId(messageId)));
.setType(ImContentTypeEnum.RECALL.getType()).setContent(new RecallMessage().setMessageId(messageId)));
}
/**
@ -211,20 +245,26 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
throw exception(MESSAGE_PRIVATE_READ_DISABLED);
}
Assert.notNull(messageId, "已读消息编号不能为空");
// 2. 把 (receiverId → userId) 这条会话上、id <= messageId 的未读消息一步更新为已读
// 仅 UNREAD 行被命中,避免覆盖已撤回/已读的状态select-then-update 合成单条 SQL 后也消除了竞态窗口
int updated = privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus(
receiverId, userId, messageId, ImMessageStatusEnum.UNREAD.getStatus(),
new ImPrivateMessageDO().setStatus(ImMessageStatusEnum.READ.getStatus()));
if (updated == 0) {
// 2. 回执置 DONE把 (receiverId → userId) 上 id <= messageId、待回执(PENDING) 的消息标记为已完成
// status 不再表达已读(保持 NORMAL只翻 PENDING 行,避免覆盖 NO_RECEIPT / 已 DONE
privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndReceiptStatus(
receiverId, userId, messageId, ImMessageReceiptStatusEnum.PENDING.getStatus(),
new ImPrivateMessageDO().setReceiptStatus(ImMessageReceiptStatusEnum.DONE.getStatus()));
// 3. 同步写 im_conversation_read读位置唯一权威单调递增读位置前进才下发事件
boolean advanced = conversationReadService.updateConversationReadPosition(
userId, ImConversationTypeEnum.PRIVATE.getType(), receiverId, messageId);
if (!advanced) {
return;
}
// 3. 异步发送 READ + RECEIPT 事件(已读位置以前端上报为准,与多端 / 对方 UI 显示一致)
imWebSocketService.sendPrivateMessageAsync(userId,
ImPrivateMessageDTO.ofRead(userId, receiverId, messageId));
imWebSocketService.sendPrivateMessageAsync(receiverId,
ImPrivateMessageDTO.ofReceipt(userId, receiverId, messageId));
// 4. 异步发送 READ + RECEIPT 事件(已读位置以前端上报为准,与多端 / 对方 UI 显示一致)
imWebSocketService.sendNotificationAsync(userId, ImConversationTypeEnum.PRIVATE.getType(),
ImContentTypeEnum.READ.getType(), ImMessageReadNotification.ofPrivate(userId, receiverId, messageId));
imWebSocketService.sendNotificationAsync(receiverId, ImConversationTypeEnum.PRIVATE.getType(),
ImContentTypeEnum.RECEIPT.getType(),
ImMessageReceiptNotification.ofPrivate(userId, receiverId, messageId));
}
@Override
@ -232,8 +272,9 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
if (BooleanUtil.isFalse(imProperties.getMessage().isPrivateReadEnabled())) {
throw exception(MESSAGE_PRIVATE_READ_DISABLED);
}
return privateMessageMapper.selectMaxIdBySenderIdAndReceiverIdAndStatus(
userId, peerId, ImMessageStatusEnum.READ.getStatus());
// 对端 peer 在「与 userId 的会话」里的读位置 = peer 把 userId 发的消息读到哪
return conversationReadService.getConversationReadMessageId(
peerId, ImConversationTypeEnum.PRIVATE.getType(), userId);
}
@Override

View File

@ -1,8 +1,8 @@
package cn.iocoder.yudao.module.im.service.message.dto;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.group.*;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import cn.iocoder.yudao.module.im.service.websocket.notification.group.*;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@ -26,7 +26,7 @@ public class ImGroupMessageSendDTO {
/**
* 消息类型
* <p>
* 枚举 {@link ImMessageTypeEnum}
* 枚举 {@link ImContentTypeEnum}
*/
@NotNull(message = "消息类型不能为空")
private Integer type;
@ -53,30 +53,34 @@ public class ImGroupMessageSendDTO {
*/
private Boolean receipt;
// ========== 群广播事件静态工厂(对应 ImMessageTypeEnum 群事件) ==========
// ========== 群广播事件静态工厂(对应 ImContentTypeEnum 群事件) ==========
public static ImGroupMessageSendDTO ofGroupCreate(Long groupId, Long operatorUserId, Collection<Long> memberUserIds) {
GroupCreateNotification notification = new GroupCreateNotification();
notification.setOperatorUserId(operatorUserId);
notification.setMemberUserIds(new ArrayList<>(memberUserIds));
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_CREATE.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_CREATE.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupInfoUpdate(Long groupId, Long operatorUserId, String oldAvatar, String newAvatar) {
public static ImGroupMessageSendDTO ofGroupInfoUpdate(Long groupId, Long operatorUserId,
String oldAvatar, String newAvatar,
Boolean oldJoinApproval, Boolean newJoinApproval) {
GroupInfoUpdateNotification notification = new GroupInfoUpdateNotification();
notification.setOperatorUserId(operatorUserId);
notification.setOldAvatar(oldAvatar);
notification.setNewAvatar(newAvatar);
notification.setOldJoinApproval(oldJoinApproval);
notification.setNewJoinApproval(newJoinApproval);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_INFO_UPDATE.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_INFO_UPDATE.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupMemberQuit(Long groupId, Long operatorUserId) {
GroupMemberQuitNotification notification = new GroupMemberQuitNotification();
notification.setOperatorUserId(operatorUserId);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_MEMBER_QUIT.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_MEMBER_QUIT.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupOwnerTransfer(Long groupId, Long operatorUserId, Long newOwnerUserId) {
@ -84,7 +88,7 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setNewOwnerUserId(newOwnerUserId);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_OWNER_TRANSFER.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_OWNER_TRANSFER.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupMemberKick(Long groupId, Long operatorUserId, Collection<Long> memberUserIds) {
@ -92,7 +96,7 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setMemberUserIds(new ArrayList<>(memberUserIds));
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_MEMBER_KICK.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_MEMBER_KICK.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupMemberInvite(Long groupId, Long operatorUserId, Collection<Long> memberUserIds) {
@ -100,7 +104,7 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setMemberUserIds(new ArrayList<>(memberUserIds));
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_MEMBER_INVITE.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_MEMBER_INVITE.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupMemberEnter(Long groupId, Long entrantUserId, Integer addSource) {
@ -109,14 +113,14 @@ public class ImGroupMessageSendDTO {
notification.setEntrantUserId(entrantUserId);
notification.setAddSource(addSource);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_MEMBER_ENTER.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_MEMBER_ENTER.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupDissolve(Long groupId, Long operatorUserId) {
GroupDissolveNotification notification = new GroupDissolveNotification();
notification.setOperatorUserId(operatorUserId);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_DISSOLVE.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_DISSOLVE.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupMemberNicknameUpdate(Long groupId, Long operatorUserId, String displayUserName) {
@ -124,7 +128,7 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setDisplayUserName(displayUserName);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_MEMBER_NICKNAME_UPDATE.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_MEMBER_NICKNAME_UPDATE.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupAdminAdd(Long groupId, Long operatorUserId, Collection<Long> memberUserIds) {
@ -132,7 +136,7 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setMemberUserIds(new ArrayList<>(memberUserIds));
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_ADMIN_ADD.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_ADMIN_ADD.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupAdminRemove(Long groupId, Long operatorUserId, Collection<Long> memberUserIds) {
@ -140,7 +144,7 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setMemberUserIds(new ArrayList<>(memberUserIds));
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_ADMIN_REMOVE.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_ADMIN_REMOVE.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupNoticeUpdate(Long groupId, Long operatorUserId, String oldNotice, String newNotice) {
@ -148,7 +152,7 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setOldNotice(oldNotice).setNewNotice(newNotice);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_NOTICE_UPDATE.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_NOTICE_UPDATE.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupNameUpdate(Long groupId, Long operatorUserId, String oldName, String newName) {
@ -156,7 +160,7 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setOldName(oldName).setNewName(newName);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_NAME_UPDATE.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_NAME_UPDATE.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupMemberSettingUpdate(Long groupId, Long operatorUserId, Boolean silent, String groupRemark) {
@ -164,7 +168,7 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setSilent(silent).setGroupRemark(groupRemark);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_MEMBER_SETTING_UPDATE.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_MEMBER_SETTING_UPDATE.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupMessagePin(Long groupId, Long operatorUserId, ImGroupMessageDO message) {
@ -176,7 +180,7 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setMessageId(message.getId()).setMessage(pinnedMessage);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_MESSAGE_PIN.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_MESSAGE_PIN.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupMessageUnpin(Long groupId, Long operatorUserId, Long messageId) {
@ -184,7 +188,7 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setMessageId(messageId);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_MESSAGE_UNPIN.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_MESSAGE_UNPIN.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupMemberMuted(Long groupId, Long operatorUserId,
@ -195,7 +199,7 @@ public class ImGroupMessageSendDTO {
notification.setMutedUserId(mutedUserId);
notification.setMuteEndTime(muteEndTime);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_MEMBER_MUTED.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_MEMBER_MUTED.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupMemberCancelMuted(Long groupId, Long operatorUserId, Long mutedUserId) {
@ -203,21 +207,21 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setMutedUserId(mutedUserId);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_MEMBER_CANCEL_MUTED.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_MEMBER_CANCEL_MUTED.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupMuted(Long groupId, Long operatorUserId) {
GroupMutedNotification notification = new GroupMutedNotification();
notification.setOperatorUserId(operatorUserId);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_MUTED.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_MUTED.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupCancelMuted(Long groupId, Long operatorUserId) {
GroupCancelMutedNotification notification = new GroupCancelMutedNotification();
notification.setOperatorUserId(operatorUserId);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_CANCEL_MUTED.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_CANCEL_MUTED.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupBanned(Long groupId, Long operatorUserId, boolean banned) {
@ -225,7 +229,7 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setBanned(banned);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_BANNED.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_BANNED.getType()).setContent(notification);
}
}

View File

@ -1,6 +1,6 @@
package cn.iocoder.yudao.module.im.service.message.dto;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import lombok.Data;
/**
@ -18,7 +18,7 @@ public class ImPrivateMessageSendDTO {
/**
* 消息类型
* <p>
* 枚举 {@link ImMessageTypeEnum}
* 枚举 {@link ImContentTypeEnum}
*/
private Integer type;
/**
@ -30,10 +30,14 @@ public class ImPrivateMessageSendDTO {
/**
* 是否持久化 + 推送给接收方(单边语义开关)
* <p>
* null默认按 {@link ImMessageTypeEnum#isPersistent()} 决定是否入库 + 双向 WS 推送(保持原行为)<br>
* null默认按 {@link ImContentTypeEnum#isPersistent()} 决定是否入库 + 双向 WS 推送(保持原行为)<br>
* false覆盖为单边——不入库、仅推 sender 多端,对方不感知(如「你已删除 XXX」这类仅自己可见的 TIP<br>
* true覆盖为双向 + 入库
*/
private Boolean persistent;
/**
* 是否需要回执null 等价 false系统消息默认不需要回执
*/
private Boolean receipt;
}

View File

@ -15,7 +15,7 @@ import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcCallMapper;
import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcParticipantMapper;
import cn.iocoder.yudao.module.im.dal.redis.rtc.ImRtcCallLockRedisDAO;
import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallEndReasonEnum;
import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallStatusEnum;
import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantRoleEnum;
@ -30,8 +30,7 @@ import cn.iocoder.yudao.module.im.service.message.ImPrivateMessageService;
import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO;
import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO;
import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.rtc.*;
import cn.iocoder.yudao.module.im.service.websocket.notification.rtc.*;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
@ -57,7 +56,7 @@ import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
* 并发幂等:同好友对 / 同群活跃唯一性走 {@link ImRtcCallLockRedisDAO} 分布式锁 + 锁内 SELECT 兜底webhook 兜底走条件 UPDATE
* <p>
* 推送通道分流:
* 1601 RTC_CALLINVITING / JOINED / REJECTED / NO_ANSWER / LEFT 子类型)→ {@link ImWebSocketService#sendPrivateMessageAsync} 仅推参与方;
* 1601 RTC_CALLINVITING / JOINED / REJECTED / NO_ANSWER / LEFT 子类型)→ {@link ImWebSocketService#sendNotificationAsync} 仅推参与方;
* 1602 / 1603 PARTICIPANT_CONNECTED / DISCONNECTED → {@link ImWebSocketService} 推参与方 + 群通话场景广播全群;
* 1610 RTC_CALL_START + 1611 RTC_CALL_END → {@link ImPrivateMessageService} / {@link ImGroupMessageService} 入消息流当聊天 tip
* START 仅群通话;两者分别在 invite / cancel(leave) 事务里 INSERT自增 id 自然保证顺序)
@ -707,7 +706,6 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
}
// 3.1 群通话:推 RTC_CALL(NO_ANSWER) 让前端 banner 移除该人 + 级联关房判定
// TODO DONE @AI拆分独立 NO_ANSWER 信令,不再复用 REJECT
if (ImConversationTypeEnum.isGroup(call.getConversationType())) {
pushCallNoAnswerNotification(call, userId, userMap.get(userId));
endSessionIfTerminal(call, userId);
@ -1003,8 +1001,8 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
String token = signToken(inviteeId, resolveDisplayName(invitee, inviteeId), call.getRoom());
ImRtcCallNotification payload = ImRtcCallNotification.ofInvite(
call, inviter, imProperties.getRtc().getLivekitUrl(), token, inviteeIds);
webSocketService.sendPrivateMessageAsync(inviteeId, ImPrivateMessageDTO.ofRtcNotification(
ImMessageTypeEnum.RTC_CALL.getType(), call.getInviterUserId(), inviteeId, payload));
webSocketService.sendNotificationAsync(inviteeId, ImConversationTypeEnum.NONE.getType(),
ImContentTypeEnum.RTC_CALL.getType(), payload);
}
/**
@ -1019,8 +1017,8 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
AdminUserRespDTO operator = operatorUserId != null ? adminUserApi.getUser(operatorUserId) : null;
ImRtcCallNotification payload = ImRtcCallNotification.ofReject(call, operatorUserId, operator);
for (Long receiverUserId : getCallAudienceUserIdList(call)) {
webSocketService.sendPrivateMessageAsync(receiverUserId, ImPrivateMessageDTO.ofRtcNotification(
ImMessageTypeEnum.RTC_CALL.getType(), operatorUserId, receiverUserId, payload));
webSocketService.sendNotificationAsync(receiverUserId, ImConversationTypeEnum.NONE.getType(),
ImContentTypeEnum.RTC_CALL.getType(), payload);
}
}
@ -1036,8 +1034,8 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
private void pushCallNoAnswerNotification(ImRtcCallDO call, Long operatorUserId, AdminUserRespDTO operator) {
ImRtcCallNotification payload = ImRtcCallNotification.ofNoAnswer(call, operatorUserId, operator);
for (Long receiverUserId : getCallAudienceUserIdList(call)) {
webSocketService.sendPrivateMessageAsync(receiverUserId, ImPrivateMessageDTO.ofRtcNotification(
ImMessageTypeEnum.RTC_CALL.getType(), operatorUserId, receiverUserId, payload));
webSocketService.sendNotificationAsync(receiverUserId, ImConversationTypeEnum.NONE.getType(),
ImContentTypeEnum.RTC_CALL.getType(), payload);
}
}
@ -1048,7 +1046,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
* @param userId 加入的参与者用户编号
*/
private void pushParticipantConnectedNotification(ImRtcCallDO call, Long userId) {
pushParticipantNotification(call, ImMessageTypeEnum.RTC_PARTICIPANT_CONNECTED.getType(), userId,
pushParticipantNotification(call, ImContentTypeEnum.RTC_PARTICIPANT_CONNECTED.getType(), userId,
ImRtcParticipantConnectedNotification.of(call, userId));
}
@ -1059,7 +1057,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
* @param userId 离开的参与者用户编号
*/
private void pushParticipantDisconnectedNotification(ImRtcCallDO call, Long userId) {
pushParticipantNotification(call, ImMessageTypeEnum.RTC_PARTICIPANT_DISCONNECTED.getType(), userId,
pushParticipantNotification(call, ImContentTypeEnum.RTC_PARTICIPANT_DISCONNECTED.getType(), userId,
ImRtcParticipantDisconnectedNotification.of(call, userId));
}
@ -1076,8 +1074,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
if (CollUtil.isEmpty(receivers)) {
return;
}
ImPrivateMessageDTO dto = ImPrivateMessageDTO.ofRtcNotification(type, actorUserId, null, payload);
webSocketService.sendPrivateMessageAsync(receivers, dto);
webSocketService.sendNotificationAsync(receivers, ImConversationTypeEnum.NONE.getType(), type, payload);
}
/**
@ -1092,7 +1089,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
private void pushCallStartNotification(ImRtcCallDO call, AdminUserRespDTO inviter, Set<Long> invitees) {
ImRtcCallStartNotification payload = ImRtcCallStartNotification.of(call, inviter);
Long peerUserId = ImConversationTypeEnum.isGroup(call.getConversationType()) ? null : CollUtil.getFirst(invitees);
pushCallChatMessage(call, ImMessageTypeEnum.RTC_CALL_START, payload, peerUserId);
pushCallChatMessage(call, ImContentTypeEnum.RTC_CALL_START, payload, peerUserId);
}
/**
@ -1116,7 +1113,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
p -> ObjUtil.notEqual(p.getUserId(), call.getInviterUserId()));
peerUserId = peer != null ? peer.getUserId() : null;
}
pushCallChatMessage(call, ImMessageTypeEnum.RTC_CALL_END, payload, peerUserId);
pushCallChatMessage(call, ImContentTypeEnum.RTC_CALL_END, payload, peerUserId);
}
/**
@ -1129,7 +1126,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
* @param payload 推送 payload
* @param peerUserId 私聊对端用户编号;群聊忽略,私聊缺失时回退为 senderId
*/
private void pushCallChatMessage(ImRtcCallDO call, ImMessageTypeEnum type, Object payload, Long peerUserId) {
private void pushCallChatMessage(ImRtcCallDO call, ImContentTypeEnum type, Object payload, Long peerUserId) {
Long senderId = call.getInviterUserId();
if (ImConversationTypeEnum.isGroup(call.getConversationType())) {
ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO().setGroupId(call.getGroupId())

View File

@ -80,7 +80,7 @@ public interface ImStatisticsManagerService {
Map<LocalDateTime, Long> getGroupMessageDailyCountMap(LocalDateTime beginTime, LocalDateTime endTime);
/**
* 获取区间内消息类型分布 Mapkey 为消息类型)
* 获取区间内内容类型分布 Mapkey 为消息类型)
*/
Map<Integer, Long> getMessageTypeCountMap(LocalDateTime beginTime, LocalDateTime endTime);

View File

@ -1,82 +1,46 @@
package cn.iocoder.yudao.module.im.service.websocket;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImChannelMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
import java.util.Collection;
import java.util.Collections;
/**
* IM WebSocket 推送 Service 接口
* <p>
* 统一封装 WebSocket 消息推送,事务内调用时提交后异步执行。
* 统一封装 WebSocket 通知推送,事务内调用时提交后异步执行。
*
* @author 芋道源码
*/
public interface ImWebSocketService {
/**
* 异步推送私聊消息给指定用户
* 异步推送 WebSocket 通知给指定用户
*
* @param userId 目标用户编号
* @param dto 私聊消息 DTO
* @param userId 目标用户编号
* @param conversationType 会话类型,参见 ImConversationTypeEnum 枚举类
* @param contentType 内容类型,参见 ImContentTypeEnum 枚举类
* @param payload 通知 payload
*/
default void sendPrivateMessageAsync(Long userId, ImPrivateMessageDTO dto) {
sendPrivateMessageAsync(Collections.singleton(userId), dto);
default void sendNotificationAsync(Long userId, Integer conversationType, Integer contentType, Object payload) {
sendNotificationAsync(Collections.singleton(userId), conversationType, contentType, payload);
}
/**
* 异步批量推送私聊消息给多个用户;用于同一份 DTO 扇出到多个收件人
* <p>
* 相比逐个发送,仅注册一次 afterCommit 回调和异步任务。
* 异步批量推送 WebSocket 通知给多个用户
*
* @param userIds 目标用户编号列表
* @param dto 私聊消息 DTO
* @param userIds 目标用户编号列表
* @param conversationType 会话类型,参见 ImConversationTypeEnum 枚举类
* @param contentType 内容类型,参见 ImContentTypeEnum 枚举类
* @param payload 通知 payload
*/
void sendPrivateMessageAsync(Collection<Long> userIds, ImPrivateMessageDTO dto);
void sendNotificationAsync(Collection<Long> userIds, Integer conversationType, Integer contentType, Object payload);
/**
* 异步推送群聊消息给指定用户
* 异步广播 WebSocket 通知给当前所有在线管理端用户
*
* @param userId 目标用户编号
* @param dto 群聊消息 DTO
* @param conversationType 会话类型,参见 ImConversationTypeEnum 枚举类
* @param contentType 内容类型,参见 ImContentTypeEnum 枚举类
* @param payload 通知 payload
*/
default void sendGroupMessageAsync(Long userId, ImGroupMessageDTO dto) {
sendGroupMessageAsync(Collections.singleton(userId), dto);
}
/**
* 异步批量推送群聊消息给多个用户
*
* @param userIds 目标用户编号列表
* @param dto 群聊消息 DTO
*/
void sendGroupMessageAsync(Collection<Long> userIds, ImGroupMessageDTO dto);
/**
* 异步推送频道消息给指定用户
*
* @param userId 目标用户编号
* @param dto 频道消息 DTO
*/
default void sendChannelMessageAsync(Long userId, ImChannelMessageDTO dto) {
sendChannelMessageAsync(Collections.singleton(userId), dto);
}
/**
* 异步批量推送频道消息给多个用户
*
* @param userIds 目标用户编号列表
* @param dto 频道消息 DTO
*/
void sendChannelMessageAsync(Collection<Long> userIds, ImChannelMessageDTO dto);
/**
* 异步广播频道消息给当前所有在线管理端用户;用于全员推送
*
* @param dto 频道消息 DTO
*/
void broadcastChannelMessageAsync(ImChannelMessageDTO dto);
void broadcastNotificationAsync(Integer conversationType, Integer contentType, Object payload);
}

View File

@ -2,9 +2,7 @@ package cn.iocoder.yudao.module.im.service.websocket;
import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImChannelMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.notification.ImNotificationWebSocketDTO;
import cn.iocoder.yudao.module.infra.api.websocket.WebSocketSenderApi;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
@ -36,83 +34,51 @@ public class ImWebSocketServiceImpl implements ImWebSocketService {
private WebSocketSenderApi webSocketSenderApi;
@Override
public void sendPrivateMessageAsync(Collection<Long> userIds, ImPrivateMessageDTO dto) {
// 说明:通过 executeAfterTransaction 保证事务提交后再推送,避免客户端收到消息后查询数据库时事务尚未提交
// 通过 getSelf() 获取 Spring 代理对象调用 @Async 方法,确保异步 AOP 生效(直接 this 调用会绕过代理)
executeAfterTransaction(() -> getSelf().doSendPrivateMessage(userIds, dto));
public void sendNotificationAsync(Collection<Long> userIds, Integer conversationType, Integer contentType,
Object payload) {
ImNotificationWebSocketDTO notification = buildNotification(conversationType, contentType, payload);
executeAfterTransaction(() -> getSelf().doSendNotification(userIds, notification));
}
@Override
public void sendGroupMessageAsync(Collection<Long> userIds, ImGroupMessageDTO dto) {
executeAfterTransaction(() -> getSelf().doSendGroupMessage(userIds, dto));
public void broadcastNotificationAsync(Integer conversationType, Integer contentType, Object payload) {
ImNotificationWebSocketDTO notification = buildNotification(conversationType, contentType, payload);
executeAfterTransaction(() -> getSelf().doBroadcastNotification(notification));
}
@Override
public void sendChannelMessageAsync(Collection<Long> userIds, ImChannelMessageDTO dto) {
executeAfterTransaction(() -> getSelf().doSendChannelMessage(userIds, dto));
}
@Override
public void broadcastChannelMessageAsync(ImChannelMessageDTO dto) {
executeAfterTransaction(() -> getSelf().doBroadcastChannelMessage(dto));
private static ImNotificationWebSocketDTO buildNotification(Integer conversationType, Integer contentType,
Object payload) {
return new ImNotificationWebSocketDTO()
.setConversationType(conversationType)
.setContentType(contentType)
.setPayload(payload);
}
/**
* 异步发送私聊 WebSocket 消息;多收件人共享同一 dto避免按收件人重复注册 afterCommit 回调
* 异步发送 WebSocket 通知
*/
@Async
public void doSendPrivateMessage(Collection<Long> userIds, ImPrivateMessageDTO dto) {
public void doSendNotification(Collection<Long> userIds, ImNotificationWebSocketDTO notification) {
for (Long userId : getDistinctUserIds(userIds)) {
try {
webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
ImPrivateMessageDTO.TYPE, dto);
ImNotificationWebSocketDTO.TYPE, notification);
} catch (Exception e) {
log.error("[doSendPrivateMessage][userId({}) dto({}) 发送失败]", userId, dto, e);
log.error("[doSendNotification][userId({}) notification({}) 发送失败]", userId, notification, e);
}
}
}
/**
* 异步发送群聊 WebSocket 消息
* 异步广播 WebSocket 通知
*/
@Async
public void doSendGroupMessage(Collection<Long> userIds, ImGroupMessageDTO dto) {
for (Long userId : getDistinctUserIds(userIds)) {
try {
webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
ImGroupMessageDTO.TYPE, dto);
} catch (Exception e) {
log.error("[doSendGroupMessage][userId({}) dto({}) 发送失败]", userId, dto, e);
}
}
}
/**
* 异步发送频道 WebSocket 消息
*/
@Async
public void doSendChannelMessage(Collection<Long> userIds, ImChannelMessageDTO dto) {
for (Long userId : getDistinctUserIds(userIds)) {
try {
webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
ImChannelMessageDTO.TYPE, dto);
} catch (Exception e) {
log.error("[doSendChannelMessage][userId({}) dto({}) 发送失败]", userId, dto, e);
}
}
}
/**
* 异步广播频道 WebSocket 消息给当前所有在线管理端用户;
* 依赖 infra WebSocketSenderApi 按 UserType 广播能力,离线用户由客户端上线 pull 兜底
*/
@Async
public void doBroadcastChannelMessage(ImChannelMessageDTO dto) {
public void doBroadcastNotification(ImNotificationWebSocketDTO notification) {
try {
webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(),
ImChannelMessageDTO.TYPE, dto);
ImNotificationWebSocketDTO.TYPE, notification);
} catch (Exception e) {
log.error("[doBroadcastChannelMessage][dto({}) 广播失败]", dto, e);
log.error("[doBroadcastNotification][notification({}) 广播失败]", notification, e);
}
}
@ -121,19 +87,15 @@ public class ImWebSocketServiceImpl implements ImWebSocketService {
}
/**
* 事务感知的任务调度
* - 有事务:注册 afterCommit 回调,事务提交后再执行,防止客户端拿到消息去查库时数据还没落盘
* - 无事务:直接执行(如非 @Transactional 方法中的调用)
* 事务感知的任务调度
*
* @param task 待执行的推送任务(内部通过 getSelf() 走 @Async 异步执行)
* @param task 待执行的推送任务
*/
private void executeAfterTransaction(Runnable task) {
// 情况一:没有事务,直接执行
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
task.run();
return;
}
// 情况二:有事务,注册 afterCommit 事件,在事务提交后执行
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override

View File

@ -1,70 +0,0 @@
package cn.iocoder.yudao.module.im.service.websocket.dto;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import lombok.Data;
import lombok.experimental.Accessors;
import java.time.LocalDateTime;
/**
* IM 频道消息 WebSocket 推送 DTO
* <p>
* 单向:服务端运营推送 → C 端用户C 端不能向频道发消息。
* 字段分层:顶层是消息元数据 + 检索维度content 是 MaterialMessage payload 的 JSON 串。
*
* @author 芋道源码
*/
@Data
@Accessors(chain = true)
public class ImChannelMessageDTO {
public static final String TYPE = "im-channel-message";
/**
* 消息编号
*/
private Long id;
/**
* 频道编号
*/
private Long channelId;
/**
* 关联素材编号
*/
private Long materialId;
/**
* 消息类型
*/
private Integer type;
/**
* 消息内容payload JSON 串
*/
private String content;
/**
* 发送时间
*/
private LocalDateTime sendTime;
/**
* 由频道消息 DO 构建推送 DTO
*/
public static ImChannelMessageDTO ofSend(ImChannelMessageDO message) {
return BeanUtils.toBean(message, ImChannelMessageDTO.class);
}
/**
* 构建已读同步 DTO多端同步通知自己的其他终端「我已经读了某频道」
*
* @param channelId 频道编号
* @param readId 已读位置(最大已读消息编号)
* @return 频道 DTO
*/
public static ImChannelMessageDTO ofRead(Long channelId, Long readId) {
return new ImChannelMessageDTO()
.setId(readId).setType(ImMessageTypeEnum.READ.getType())
.setChannelId(channelId);
}
}

View File

@ -1,123 +0,0 @@
package cn.iocoder.yudao.module.im.service.websocket.dto;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* IM 群聊消息 WebSocket 统一推送 DTO
* <p>
* 发送、已读、回执都通过 {@link #type} 字段区分,
* 并统一复用 {@link #id} 字段表达目标消息或已读位置。
*
* @author 芋道源码
*/
@Data
public class ImGroupMessageDTO {
public static final String TYPE = "im-group-message";
/**
* 消息编号
* <p>
* 普通消息:当前消息 id
* READ群已读不需要
* RECEIPT需要回执的目标消息 id
*/
private Long id;
/**
* 客户端消息编号
*/
private String clientMessageId;
/**
* 发送人编号
*/
private Long senderId;
/**
* 群编号
*/
private Long groupId;
/**
* 消息类型
*/
private Integer type;
/**
* 消息内容
*/
private String content;
/**
* 消息状态
*/
private Integer status;
/**
* 发送时间
*/
private LocalDateTime sendTime;
/**
* @ 目标用户编号列表
*/
private List<Long> atUserIds;
/**
* 定向接收用户编号列表
*/
private List<Long> receiverUserIds;
/**
* 群回执状态
*/
private Integer receiptStatus;
/**
* 群回执已读人数
*/
private Integer readCount;
/**
* 已读位置
*/
private Long readId;
// ========== 静态工厂方法 ==========
/**
* 构建发送消息 DTO
*
* @param message 群聊消息 DO
* @return 群聊 DTO
*/
public static ImGroupMessageDTO ofSend(ImGroupMessageDO message) {
return BeanUtils.toBean(message, ImGroupMessageDTO.class).setReceiverUserIds(null);
}
/**
* 构建已读同步 DTO多端同步通知自己的其他终端"我已经读了某个群"
*
* @param senderId 当前用户编号
* @param groupId 群编号
* @param readId 已读位置(最大已读消息编号)
* @return 群聊 DTO
*/
public static ImGroupMessageDTO ofRead(Long senderId, Long groupId, Long readId) {
return new ImGroupMessageDTO()
.setId(readId).setReadId(readId).setType(ImMessageTypeEnum.READ.getType())
.setSenderId(senderId).setGroupId(groupId);
}
/**
* 构建群回执 DTO广播回执状态和已读人数
*
* @param messageId 消息编号
* @param groupId 群编号
* @param readCount 已读人数
* @param receiptStatus 回执状态
* @return 群聊 DTO
*/
public static ImGroupMessageDTO ofReceipt(Long messageId, Long groupId,
Integer readCount, Integer receiptStatus) {
return new ImGroupMessageDTO()
.setId(messageId).setType(ImMessageTypeEnum.RECEIPT.getType())
.setGroupId(groupId).setReadCount(readCount).setReceiptStatus(receiptStatus);
}
}

View File

@ -1,176 +0,0 @@
package cn.iocoder.yudao.module.im.service.websocket.dto;
import cn.hutool.core.lang.Assert;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend.BaseFriendNotification;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.group.BaseGroupNotification;
import lombok.Data;
import lombok.experimental.Accessors;
import java.time.LocalDateTime;
/**
* IM 私聊消息 WebSocket 统一推送 DTO
* <p>
* 发送、已读、回执、撤回都通过 {@link #type} 字段区分,
* 并统一复用 {@link #id} 字段表达目标消息或已读位置。
*
* @author 芋道源码
*/
@Data
@Accessors(chain = true)
public class ImPrivateMessageDTO {
public static final String TYPE = "im-private-message";
/**
* 消息编号
* <p>
* 普通消息:当前消息 id
* READ已读位置我已读到这条消息
* RECEIPT已读位置对方已读到这条消息
* RECALL被撤回的原消息 id
*/
private Long id;
/**
* 客户端消息编号
*/
private String clientMessageId;
/**
* 发送人编号
*/
private Long senderId;
/**
* 接收人编号
*/
private Long receiverId;
/**
* 消息类型
*/
private Integer type;
/**
* 消息内容
*/
private String content;
/**
* 消息状态
*/
private Integer status;
/**
* 发送时间
*/
private LocalDateTime sendTime;
// ========== 静态工厂方法 ==========
/**
* 构建发送消息 DTO
*
* @param message 私聊消息 DO
* @return 私聊 DTO
*/
public static ImPrivateMessageDTO ofSend(ImPrivateMessageDO message) {
return BeanUtils.toBean(message, ImPrivateMessageDTO.class);
}
/**
* 构建已读同步 DTO多端同步通知自己的其他终端"我已经读了某个会话"
*
* @param senderId 当前用户编号
* @param receiverId 对方用户编号
* @param readId 已读位置(最大已读消息编号)
* @return 私聊 DTO
*/
public static ImPrivateMessageDTO ofRead(Long senderId, Long receiverId, Long readId) {
return new ImPrivateMessageDTO()
.setId(readId).setType(ImMessageTypeEnum.READ.getType())
.setSenderId(senderId).setReceiverId(receiverId);
}
/**
* 构建已读回执 DTO通知对方"我已读了你的消息"
*
* @param senderId 已读方的用户编号
* @param receiverId 对方用户编号
* @param readId 已读位置(最大已读消息编号)
* @return 私聊 DTO
*/
public static ImPrivateMessageDTO ofReceipt(Long senderId, Long receiverId, Long readId) {
return new ImPrivateMessageDTO()
.setId(readId).setType(ImMessageTypeEnum.RECEIPT.getType())
.setSenderId(senderId).setReceiverId(receiverId);
}
// ==================== 好友变更相关 ====================
/**
* 构建好友通知推送 DTO统一入口
*
* @param type 消息类型;取自 {@link ImMessageTypeEnum} 中的 FRIEND_* 段
* @param operatorUserId 操作人用户编号;同时作为帧的 senderId 用于定位接收端 friendUserId
* @param receiverUserId 推送目标用户编号
* @param payload 好友通知 payload继承 {@link BaseFriendNotification}
* @return 私聊 DTO
*/
public static ImPrivateMessageDTO ofFriendNotification(Integer type, Long operatorUserId,
Long receiverUserId, BaseFriendNotification payload) {
validateNotification(type, payload, ImMessageTypeEnum.isFriendNotification(type));
return new ImPrivateMessageDTO().setType(type)
.setSenderId(operatorUserId).setReceiverId(receiverUserId)
.setContent(JsonUtils.toJsonString(payload)).setSendTime(LocalDateTime.now());
}
// ==================== 群定向私聊通知 ====================
// TODO DONE @AI群申请定向通知继续走私聊通道
/**
* 构建群通知推送 DTO走私聊通道定向推送不入群消息流
* <p>
* 用于 GROUP_REQUEST_RECEIVED / GROUP_REQUEST_APPROVED / GROUP_REQUEST_REJECTED 段位;
* 与 {@link cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO} 走 sendGroupMessage 群广播路径不同
*
* @param type 消息类型;取自 {@link ImMessageTypeEnum} 中的 GROUP_REQUEST_* 段
* @param operatorUserId 操作人用户编号;同时作为帧的 senderId
* @param receiverUserId 推送目标用户编号
* @param payload 群事件 payload继承 {@link BaseGroupNotification}
* @return 私聊 DTO
*/
public static ImPrivateMessageDTO ofGroupNotification(Integer type, Long operatorUserId,
Long receiverUserId, BaseGroupNotification payload) {
validateNotification(type, payload, ImMessageTypeEnum.isGroupRequestNotification(type));
return new ImPrivateMessageDTO().setType(type)
.setSenderId(operatorUserId).setReceiverId(receiverUserId)
.setContent(JsonUtils.toJsonString(payload)).setSendTime(LocalDateTime.now());
}
// ==================== 实时通话信令 ====================
/**
* 构建通话信令推送 DTO走私聊通道仅推参与方不入消息流
* <p>
* 用于 RTC_CALLINVITE / REJECT 等)/ RTC_PARTICIPANT_CONNECTED / RTC_PARTICIPANT_DISCONNECTED
*
* @param type 消息类型;取自 {@link ImMessageTypeEnum} 中的 RTC_* 段
* @param senderId 发送人编号INVITE 时为发起人REJECT 时为拒绝者,参与者事件时为加入 / 离开者
* @param receiverUserId 推送目标用户编号
* @param payload 通话事件 payload任意 RTC 通知 DTO
* @return 私聊 DTO
*/
public static ImPrivateMessageDTO ofRtcNotification(Integer type, Long senderId,
Long receiverUserId, Object payload) {
validateNotification(type, payload, ImMessageTypeEnum.isRtcNotification(type));
return new ImPrivateMessageDTO().setType(type)
.setSenderId(senderId).setReceiverId(receiverUserId)
.setContent(JsonUtils.toJsonString(payload)).setSendTime(LocalDateTime.now());
}
private static void validateNotification(Integer type, Object payload, boolean supported) {
Assert.notNull(type, "消息类型不能为空");
Assert.notNull(payload, "消息内容不能为空");
Assert.isTrue(supported, "消息类型不匹配 type={}", type);
}
}

View File

@ -1,20 +0,0 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
import lombok.Data;
/**
* 群信息变更事件通知当前承载头像变更NAME / NOTICE 走独立事件)
*/
@Data
public class GroupInfoUpdateNotification extends BaseGroupNotification {
/**
* 旧群头像
*/
private String oldAvatar;
/**
* 新群头像
*/
private String newAvatar;
}

View File

@ -0,0 +1,33 @@
package cn.iocoder.yudao.module.im.service.websocket.notification;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* IM WebSocket 在线通知外壳
* <p>
* conversationType 定位会话维度contentType 定位业务内容payload 承载对应通知对象。
* 会进入聊天流的私聊、群聊、频道事件走 message 子包;不进入聊天流的好友、加群申请、通话信令走 NONE 会话。
*
* @author 芋道源码
*/
@Data
@Accessors(chain = true)
public class ImNotificationWebSocketDTO {
public static final String TYPE = "im-notification";
/**
* 会话类型,参见 ImConversationTypeEnum 枚举类
*/
private Integer conversationType;
/**
* 内容类型,参见 ImContentTypeEnum 枚举类
*/
private Integer contentType;
/**
* 负载数据
*/
private Object payload;
}

View File

@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend;
package cn.iocoder.yudao.module.im.service.websocket.notification.friend;
import lombok.Data;

View File

@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend;
package cn.iocoder.yudao.module.im.service.websocket.notification.friend;
import lombok.Data;

View File

@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend;
package cn.iocoder.yudao.module.im.service.websocket.notification.friend;
import lombok.Data;

View File

@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend;
package cn.iocoder.yudao.module.im.service.websocket.notification.friend;
import lombok.Data;

View File

@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend;
package cn.iocoder.yudao.module.im.service.websocket.notification.friend;
import lombok.Data;

View File

@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend;
package cn.iocoder.yudao.module.im.service.websocket.notification.friend;
import lombok.Data;

View File

@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend;
package cn.iocoder.yudao.module.im.service.websocket.notification.friend;
import lombok.Data;

View File

@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend;
package cn.iocoder.yudao.module.im.service.websocket.notification.friend;
import lombok.Data;

View File

@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend;
package cn.iocoder.yudao.module.im.service.websocket.notification.friend;
import lombok.Data;

View File

@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend;
package cn.iocoder.yudao.module.im.service.websocket.notification.friend;
import lombok.Data;

View File

@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
package cn.iocoder.yudao.module.im.service.websocket.notification.group;
import lombok.Data;

View File

@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
package cn.iocoder.yudao.module.im.service.websocket.notification.group;
/**
* 添加管理员事件通知memberUserIds 为被设管理员的成员

View File

@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
package cn.iocoder.yudao.module.im.service.websocket.notification.group;
/**
* 撤销管理员事件通知memberUserIds 为被撤销管理员的成员

Some files were not shown because too many files have changed in this diff Show More