mirror of
https://gitee.com/zhijiantianya/ruoyi-vue-pro.git
synced 2026-06-20 15:21:38 +08:00
Compare commits
17 Commits
master-jdk
...
feature/im
| Author | SHA1 | Date | |
|---|---|---|---|
| 9709621648 | |||
| 9ed7c050b0 | |||
| 79de92333f | |||
| 773d094144 | |||
| a0be8e2907 | |||
| 716738ac5d | |||
| 92a993b283 | |||
| bca66fee74 | |||
| 4f9dc0e426 | |||
| 3e38e77ee6 | |||
| 3dd7179393 | |||
| 210a8d5af6 | |||
| 927b64fc4e | |||
| 4b27620e86 | |||
| 6d8ad0c374 | |||
| 3fe400a5cf | |||
| 120e4415a9 |
File diff suppressed because it is too large
Load Diff
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -47,6 +47,9 @@ public class ImFriendRespVO {
|
||||
@Schema(description = "删除好友时间")
|
||||
private LocalDateTime deleteTime;
|
||||
|
||||
@Schema(description = "最近更新时间(增量拉取游标用)")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
// ========== 下面是聚合字段,方便前端显示 ==========
|
||||
|
||||
@Schema(description = "好友昵称(实时聚合自 AdminUser)", example = "芋道")
|
||||
|
||||
@ -41,6 +41,9 @@ public class ImFriendRequestRespVO {
|
||||
@Schema(description = "申请创建时间")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@Schema(description = "最近更新时间(增量拉取游标用)")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
// ========== 下面是聚合字段,方便前端显示 ==========
|
||||
|
||||
@Schema(description = "发起方昵称(实时聚合自 AdminUser)", example = "芋道")
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -42,6 +42,9 @@ public class ImGroupRequestRespVO {
|
||||
@Schema(description = "申请创建时间")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@Schema(description = "申请更新时间;增量拉取游标")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
// ========== 下面是聚合字段,方便前端显示 ==========
|
||||
|
||||
@Schema(description = "申请人 / 被邀请人昵称(实时聚合自 AdminUser)", example = "芋道")
|
||||
|
||||
@ -75,7 +75,6 @@ public class ImChannelManagerController {
|
||||
@GetMapping("/simple-list")
|
||||
@Operation(summary = "获得启用的频道精简列表;前端表单选择频道时调用")
|
||||
public CommonResult<List<ImChannelRespVO>> getSimpleChannelList() {
|
||||
// TODO DONE @AI:getChannelListByStatus 统一命名
|
||||
List<ImChannelDO> list = channelService.getChannelListByStatus(CommonStatusEnum.ENABLE.getStatus());
|
||||
return success(BeanUtils.toBean(list, ImChannelRespVO.class));
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 已读游标补 status;device 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());
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
/**
|
||||
* 发送时间
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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= 按需拉取。
|
||||
*/
|
||||
@ -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;
|
||||
/**
|
||||
@ -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;
|
||||
/**
|
||||
@ -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;
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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()));
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -154,7 +154,7 @@ public interface ImStatisticsManagerMapper {
|
||||
@Param("endTime") LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 区间内消息类型分布(私聊+群聊合并)
|
||||
* 区间内内容类型分布(私聊+群聊合并)
|
||||
*
|
||||
* @return [{type: 0, count: 123}, ...]
|
||||
*/
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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)服务端补一个「未读通知拉取」接口给前端冷启动调用。
|
||||
/**
|
||||
* 对应 OpenIM:FriendApplicationApprovedNotification 1201
|
||||
* 对应自己的类:FriendRequestApprovedNotification
|
||||
@ -285,9 +281,9 @@ public enum ImMessageTypeEnum implements ArrayValuable<Integer> {
|
||||
/**
|
||||
* 对应 OpenIM:sdkws.GroupMemberInfoSetTips(GroupMemberInfoSetNotification 1516,窄化到 displayUserName)
|
||||
* 对应自己的类:GroupMemberNicknameUpdateNotification
|
||||
* 场景:成员修改自己在群里的昵称,全员广播;前端按 displayUserName 局部更新对应 member
|
||||
* 场景:成员修改自己在群里的昵称,在线成员同步对应 member
|
||||
*/
|
||||
GROUP_MEMBER_NICKNAME_UPDATE(1516, "成员昵称变更", true, false),
|
||||
GROUP_MEMBER_NICKNAME_UPDATE(1516, "成员昵称变更", false, false),
|
||||
/**
|
||||
* 对应 OpenIM:GroupMemberSetToAdminNotification 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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
/**
|
||||
* 状态
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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, "已拒绝"), // 接通前点拒接
|
||||
|
||||
@ -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({}) 推送失败]",
|
||||
|
||||
@ -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);
|
||||
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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 单查申请记录;通用读接口,调用方自行做越权过滤
|
||||
*/
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 状态)
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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 事件给自己多端
|
||||
|
||||
@ -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 行 message;reqVO 同名字段(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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
// ==================== 管理后台 ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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 对方用户编号
|
||||
|
||||
@ -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,忽略发送方传入的 receipt(receipt 为 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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
}
|
||||
|
||||
@ -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_CALL(INVITING / JOINED / REJECTED / NO_ANSWER / LEFT 子类型)→ {@link ImWebSocketService#sendPrivateMessageAsync} 仅推参与方;
|
||||
* 1601 RTC_CALL(INVITING / 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())
|
||||
|
||||
@ -80,7 +80,7 @@ public interface ImStatisticsManagerService {
|
||||
Map<LocalDateTime, Long> getGroupMessageDailyCountMap(LocalDateTime beginTime, LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 获取区间内消息类型分布 Map(key 为消息类型)
|
||||
* 获取区间内内容类型分布 Map(key 为消息类型)
|
||||
*/
|
||||
Map<Integer, Long> getMessageTypeCountMap(LocalDateTime beginTime, LocalDateTime endTime);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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_CALL(INVITE / 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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 为被设管理员的成员)
|
||||
@ -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
Reference in New Issue
Block a user