Commit f938362b04fde4296f541a58fe440ef79c4d5b2d

Authored by YunaiV
1 parent b18281fc

✨ feat(im): 增加频道消息的已读状态

yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/ImChannelMessageManagerController.java
... ... @@ -72,7 +72,8 @@ public class ImChannelMessageManagerController {
72 72 convertSet(pageResult.getList(), ImChannelMessageDO::getMaterialId));
73 73 return success(BeanUtils.toBean(pageResult, ImChannelMessageRespVO.class, vo -> {
74 74 MapUtils.findAndThen(channelMap, vo.getChannelId(), c -> vo.setChannelName(c.getName()));
75   - MapUtils.findAndThen(materialMap, vo.getMaterialId(), m -> vo.setMaterialTitle(m.getTitle()));
  75 + MapUtils.findAndThen(materialMap, vo.getMaterialId(),
  76 + material -> vo.setMaterialTitle(material.getTitle()).setMaterialCoverUrl(material.getCoverUrl()));
76 77 }));
77 78 }
78 79  
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImChannelMessageController.java
1 1 package cn.iocoder.yudao.module.im.controller.admin.message;
2 2  
  3 +import cn.hutool.core.collection.CollUtil;
3 4 import cn.iocoder.yudao.framework.common.pojo.CommonResult;
4 5 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
5 6 import cn.iocoder.yudao.module.im.controller.admin.message.vo.channel.ImChannelMessagePullRespVO;
6 7 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO;
  8 +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
7 9 import cn.iocoder.yudao.module.im.service.message.ImChannelMessageService;
8 10 import io.swagger.v3.oas.annotations.Operation;
  11 +import io.swagger.v3.oas.annotations.Parameter;
9 12 import io.swagger.v3.oas.annotations.tags.Tag;
10 13 import jakarta.annotation.Resource;
11 14 import jakarta.validation.constraints.Max;
... ... @@ -13,13 +16,17 @@ import jakarta.validation.constraints.Min;
13 16 import jakarta.validation.constraints.PositiveOrZero;
14 17 import org.springframework.validation.annotation.Validated;
15 18 import org.springframework.web.bind.annotation.GetMapping;
  19 +import org.springframework.web.bind.annotation.PutMapping;
16 20 import org.springframework.web.bind.annotation.RequestMapping;
17 21 import org.springframework.web.bind.annotation.RequestParam;
18 22 import org.springframework.web.bind.annotation.RestController;
19 23  
  24 +import java.util.Collections;
20 25 import java.util.List;
  26 +import java.util.Map;
21 27  
22 28 import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
  29 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
23 30 import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId;
24 31  
25 32 @Tag(name = "用户 APP - IM 频道消息")
... ... @@ -38,8 +45,31 @@ public class ImChannelMessageController {
38 45 @RequestParam(value = "size", defaultValue = "100")
39 46 @Min(value = 1, message = "size 必须大于 0")
40 47 @Max(value = 200, message = "size 一次最多 200 条") Integer size) {
41   - List<ImChannelMessageDO> list = channelMessageService.getMessageListForPull(getLoginUserId(), minId, size);
42   - return success(BeanUtils.toBean(list, ImChannelMessagePullRespVO.class));
  48 + // 1. 拉取消息列表
  49 + Long userId = getLoginUserId();
  50 + List<ImChannelMessageDO> list = channelMessageService.getMessageListForPull(userId, minId, size);
  51 + if (CollUtil.isEmpty(list)) {
  52 + return success(Collections.emptyList());
  53 + }
  54 + // 2. 按 Redis 已读游标补 status;device A 已读后 device B 拉到这条不再算入未读
  55 + Map<Long, Long> readMaxByChannel = channelMessageService.getChannelReadMaxMessageIdMap(
  56 + userId, convertSet(list, ImChannelMessageDO::getChannelId));
  57 + return success(BeanUtils.toBean(list, ImChannelMessagePullRespVO.class, vo -> {
  58 + Long readMax = readMaxByChannel.get(vo.getChannelId());
  59 + vo.setStatus(readMax != null && readMax >= vo.getId()
  60 + ? ImMessageStatusEnum.READ.getStatus()
  61 + : ImMessageStatusEnum.UNREAD.getStatus());
  62 + }));
  63 + }
  64 +
  65 + @PutMapping("/read")
  66 + @Operation(summary = "标记频道消息已读")
  67 + @Parameter(name = "channelId", description = "频道编号", required = true, example = "1")
  68 + @Parameter(name = "messageId", description = "已读到的消息编号", required = true, example = "100")
  69 + public CommonResult<Boolean> readChannelMessages(@RequestParam("channelId") Long channelId,
  70 + @RequestParam("messageId") Long messageId) {
  71 + channelMessageService.readChannelMessages(getLoginUserId(), channelId, messageId);
  72 + return success(true);
43 73 }
44 74  
45 75 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/channel/ImChannelMessagePullRespVO.java
... ... @@ -24,6 +24,9 @@ public class ImChannelMessagePullRespVO {
24 24 @Schema(description = "消息内容;payload JSON 快照", requiredMode = Schema.RequiredMode.REQUIRED)
25 25 private String content;
26 26  
  27 + @Schema(description = "当前用户的已读态;按 Redis 游标计算填充", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
  28 + private Integer status; // 参见 ImMessageStatusEnum 枚举类
  29 +
27 30 @Schema(description = "发送时间", requiredMode = Schema.RequiredMode.REQUIRED)
28 31 private LocalDateTime sendTime;
29 32  
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java
... ... @@ -16,6 +16,13 @@ public interface RedisKeyConstants {
16 16 String GROUP_MESSAGE_READ = "im:group:message:read:%s";
17 17  
18 18 /**
  19 + * 频道消息已读位置
  20 + * KEY 格式: im:channel:message:read:{channelId}
  21 + * VALUE 数据类型: Hash (field: userId, value: maxReadMessageId)
  22 + */
  23 + String CHANNEL_MESSAGE_READ = "im:channel:message:read:%s";
  24 +
  25 + /**
19 26 * 好友关系状态缓存(合并「是否好友」+「是否拉黑」两态)
20 27 * <p>
21 28 * KEY 格式:friend_state:{userId}_{friendUserId}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImChannelMessageReadRedisDAO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.redis.message;
  2 +
  3 +import cn.hutool.core.convert.Convert;
  4 +import jakarta.annotation.Resource;
  5 +import org.springframework.data.redis.core.StringRedisTemplate;
  6 +import org.springframework.stereotype.Repository;
  7 +
  8 +import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.CHANNEL_MESSAGE_READ;
  9 +
  10 +/**
  11 + * IM 频道消息已读位置 Redis DAO
  12 + *
  13 + * @author 芋道源码
  14 + */
  15 +@Repository
  16 +public class ImChannelMessageReadRedisDAO {
  17 +
  18 + @Resource
  19 + private StringRedisTemplate stringRedisTemplate;
  20 +
  21 + /**
  22 + * 更新用户在某频道的最大已读消息编号
  23 + *
  24 + * @param channelId 频道编号
  25 + * @param userId 用户编号
  26 + * @param maxMessageId 最大已读消息编号
  27 + */
  28 + public void updateReadMaxMessageId(Long channelId, Long userId, Long maxMessageId) {
  29 + String key = formatKey(channelId);
  30 + stringRedisTemplate.opsForHash().put(key, userId.toString(), maxMessageId.toString());
  31 + }
  32 +
  33 + /**
  34 + * 获取用户在某频道的最大已读消息编号
  35 + *
  36 + * @param channelId 频道编号
  37 + * @param userId 用户编号
  38 + * @return 最大已读消息编号;不存在则返回 null
  39 + */
  40 + public Long getReadMaxMessageId(Long channelId, Long userId) {
  41 + String key = formatKey(channelId);
  42 + Object val = stringRedisTemplate.opsForHash().get(key, userId.toString());
  43 + return Convert.toLong(val);
  44 + }
  45 +
  46 + private static String formatKey(Long channelId) {
  47 + return String.format(CHANNEL_MESSAGE_READ, channelId);
  48 + }
  49 +
  50 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/GroupMessageReadRedisDAO.java renamed to yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/ImGroupMessageReadRedisDAO.java
... ... @@ -12,12 +12,12 @@ import java.util.Map;
12 12 import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP_MESSAGE_READ;
13 13  
14 14 /**
15   - * 群消息已读位置 Redis DAO
  15 + * IM 群消息已读位置 Redis DAO
16 16 *
17 17 * @author 芋道源码
18 18 */
19 19 @Repository
20   -public class GroupMessageReadRedisDAO {
  20 +public class ImGroupMessageReadRedisDAO {
21 21  
22 22 @Resource
23 23 private StringRedisTemplate stringRedisTemplate;
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImChannelMessageService.java
... ... @@ -6,7 +6,9 @@ import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.channel.Im
6 6 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO;
7 7 import jakarta.validation.Valid;
8 8  
  9 +import java.util.Collection;
9 10 import java.util.List;
  11 +import java.util.Map;
10 12  
11 13 /**
12 14 * IM 频道消息 Service 接口
... ... @@ -27,6 +29,23 @@ public interface ImChannelMessageService {
27 29 */
28 30 List<ImChannelMessageDO> getMessageListForPull(Long userId, Long minId, Integer size);
29 31  
  32 + /**
  33 + * 上报频道消息已读位置;同步推 READ 事件给自己多端
  34 + *
  35 + * @param userId 当前用户编号
  36 + * @param channelId 频道编号
  37 + * @param messageId 已读到的最大消息编号
  38 + */
  39 + void readChannelMessages(Long userId, Long channelId, Long messageId);
  40 +
  41 + /**
  42 + * 批量查询用户在多个频道下的已读游标
  43 + *
  44 + * @param userId 当前用户编号
  45 + * @param channelIds 频道编号集合
  46 + * @return channelId → 已读到的最大消息编号(未读到的频道不出现在返回 map 中)
  47 + */
  48 + Map<Long, Long> getChannelReadMaxMessageIdMap(Long userId, Collection<Long> channelIds);
30 49  
31 50 // ==================== 管理后台 ====================
32 51  
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImChannelMessageServiceImpl.java
1 1 package cn.iocoder.yudao.module.im.service.message;
2 2  
3 3 import cn.hutool.core.collection.CollUtil;
  4 +import cn.hutool.core.lang.Assert;
  5 +import cn.hutool.extra.spring.SpringUtil;
4 6 import cn.iocoder.yudao.framework.common.pojo.PageResult;
5 7 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
6 8 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
... ... @@ -9,18 +11,23 @@ import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.channel.Im
9 11 import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO;
10 12 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO;
11 13 import cn.iocoder.yudao.module.im.dal.mysql.message.ImChannelMessageMapper;
  14 +import cn.iocoder.yudao.module.im.dal.redis.message.ImChannelMessageReadRedisDAO;
12 15 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
13 16 import cn.iocoder.yudao.module.im.service.channel.ImChannelMaterialService;
14 17 import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
15 18 import cn.iocoder.yudao.module.im.service.websocket.dto.ImChannelMessageDTO;
16 19 import cn.iocoder.yudao.module.im.service.websocket.dto.message.MaterialMessage;
17 20 import jakarta.annotation.Resource;
  21 +import org.springframework.scheduling.annotation.Async;
18 22 import org.springframework.stereotype.Service;
19 23 import org.springframework.transaction.annotation.Transactional;
20 24 import org.springframework.validation.annotation.Validated;
21 25  
22 26 import java.time.LocalDateTime;
  27 +import java.util.Collection;
  28 +import java.util.HashMap;
23 29 import java.util.List;
  30 +import java.util.Map;
24 31  
25 32 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
26 33 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.IM_CHANNEL_MESSAGE_NOT_EXISTS;
... ... @@ -36,11 +43,15 @@ public class ImChannelMessageServiceImpl implements ImChannelMessageService {
36 43  
37 44 @Resource
38 45 private ImChannelMessageMapper channelMessageMapper;
  46 +
39 47 @Resource
40 48 private ImChannelMaterialService channelMaterialService;
41 49 @Resource
42 50 private ImWebSocketService webSocketService;
43 51  
  52 + @Resource
  53 + private ImChannelMessageReadRedisDAO channelMessageReadRedisDAO;
  54 +
44 55 // ==================== 用户端 ====================
45 56  
46 57 @Override
... ... @@ -48,6 +59,47 @@ public class ImChannelMessageServiceImpl implements ImChannelMessageService {
48 59 return channelMessageMapper.selectListByUserAndMinId(userId, minId, size);
49 60 }
50 61  
  62 + @Override
  63 + public Map<Long, Long> getChannelReadMaxMessageIdMap(Long userId, Collection<Long> channelIds) {
  64 + Map<Long, Long> result = new HashMap<>(channelIds.size());
  65 + for (Long channelId : channelIds) {
  66 + Long max = channelMessageReadRedisDAO.getReadMaxMessageId(channelId, userId);
  67 + if (max != null) {
  68 + result.put(channelId, max);
  69 + }
  70 + }
  71 + return result;
  72 + }
  73 +
  74 + @Override
  75 + public void readChannelMessages(Long userId, Long channelId, Long messageId) {
  76 + Assert.notNull(channelId, "频道编号不能为空");
  77 + Assert.notNull(messageId, "已读消息编号不能为空");
  78 + // 1. 已读位置未前进,直接返回
  79 + Long prevMaxMessageId = channelMessageReadRedisDAO.getReadMaxMessageId(channelId, userId);
  80 + if (prevMaxMessageId != null && prevMaxMessageId >= messageId) {
  81 + return;
  82 + }
  83 +
  84 + // 2. 更新 Redis 频道已读位置
  85 + channelMessageReadRedisDAO.updateReadMaxMessageId(channelId, userId, messageId);
  86 +
  87 + // 3. 异步推 READ 事件给自己多端同步
  88 + getSelf().readChannelMessageEvent(userId, channelId, messageId);
  89 + }
  90 +
  91 + /**
  92 + * 发送频道已读 READ 事件给自己其他终端;频道无「给发送方刷回执」概念,不广播
  93 + */
  94 + @Async
  95 + public void readChannelMessageEvent(Long userId, Long channelId, Long readId) {
  96 + webSocketService.sendChannelMessageAsync(userId, ImChannelMessageDTO.ofRead(channelId, readId));
  97 + }
  98 +
  99 + private ImChannelMessageServiceImpl getSelf() {
  100 + return SpringUtil.getBean(getClass());
  101 + }
  102 +
51 103 // ==================== 管理后台 ====================
52 104  
53 105 @Override
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java
... ... @@ -17,7 +17,7 @@ import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO;
17 17 import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
18 18 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
19 19 import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper;
20   -import cn.iocoder.yudao.module.im.dal.redis.message.GroupMessageReadRedisDAO;
  20 +import cn.iocoder.yudao.module.im.dal.redis.message.ImGroupMessageReadRedisDAO;
21 21 import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum;
22 22 import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
23 23 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
... ... @@ -66,7 +66,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
66 66 @Resource
67 67 private ImGroupMessageMapper groupMessageMapper;
68 68 @Resource
69   - private GroupMessageReadRedisDAO groupMessageReadRedisDAO;
  69 + private ImGroupMessageReadRedisDAO groupMessageReadRedisDAO;
70 70  
71 71 @Resource
72 72 private ImGroupService groupService;
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImChannelMessageDTO.java
... ... @@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.im.service.websocket.dto;
2 2  
3 3 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
4 4 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO;
  5 +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
5 6 import lombok.Data;
6 7 import lombok.experimental.Accessors;
7 8  
... ... @@ -53,4 +54,17 @@ public class ImChannelMessageDTO {
53 54 return BeanUtils.toBean(message, ImChannelMessageDTO.class);
54 55 }
55 56  
  57 + /**
  58 + * 构建已读同步 DTO;多端同步:通知自己的其他终端「我已经读了某频道」
  59 + *
  60 + * @param channelId 频道编号
  61 + * @param readId 已读位置(最大已读消息编号)
  62 + * @return 频道 DTO
  63 + */
  64 + public static ImChannelMessageDTO ofRead(Long channelId, Long readId) {
  65 + return new ImChannelMessageDTO()
  66 + .setId(readId).setType(ImMessageTypeEnum.READ.getType())
  67 + .setChannelId(channelId);
  68 + }
  69 +
56 70 }
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java
... ... @@ -10,7 +10,7 @@ import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO;
10 10 import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
11 11 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
12 12 import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper;
13   -import cn.iocoder.yudao.module.im.dal.redis.message.GroupMessageReadRedisDAO;
  13 +import cn.iocoder.yudao.module.im.dal.redis.message.ImGroupMessageReadRedisDAO;
14 14 import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
15 15 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
16 16 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
... ... @@ -56,7 +56,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
56 56 @Mock
57 57 private ImSensitiveWordService sensitiveWordService;
58 58 @Mock
59   - private GroupMessageReadRedisDAO groupMessageReadRedisDAO;
  59 + private ImGroupMessageReadRedisDAO groupMessageReadRedisDAO;
60 60 @Mock
61 61 private ImWebSocketService imWebSocketService;
62 62  
... ...