Commit 0ce9eddedb339477335bdfcffec793a50ac97e3f
1 parent
bf8eb273
✨ feat(message): 优化群聊的 pullGroupMessageList 方法,解决空分页的风险
Showing
2 changed files
with
89 additions
and
13 deletions
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java
| @@ -4,12 +4,20 @@ import org.springframework.stereotype.Service; | @@ -4,12 +4,20 @@ import org.springframework.stereotype.Service; | ||
| 4 | import jakarta.annotation.Resource; | 4 | import jakarta.annotation.Resource; |
| 5 | import org.springframework.validation.annotation.Validated; | 5 | import org.springframework.validation.annotation.Validated; |
| 6 | 6 | ||
| 7 | + | ||
| 8 | +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; | ||
| 7 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.*; | 9 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.*; |
| 8 | import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; | 10 | import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; |
| 11 | +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; | ||
| 9 | import cn.iocoder.yudao.framework.common.pojo.PageResult; | 12 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| 10 | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; | 13 | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; |
| 11 | 14 | ||
| 12 | import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMapper; | 15 | import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMapper; |
| 16 | +import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMemberMapper; | ||
| 17 | + | ||
| 18 | +import java.util.Collections; | ||
| 19 | +import java.util.List; | ||
| 20 | +import java.util.Set; | ||
| 13 | 21 | ||
| 14 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | 22 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 15 | import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; | 23 | import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; |
| @@ -26,6 +34,9 @@ public class ImGroupServiceImpl implements ImGroupService { | @@ -26,6 +34,9 @@ public class ImGroupServiceImpl implements ImGroupService { | ||
| 26 | @Resource | 34 | @Resource |
| 27 | private ImGroupMapper imGroupMapper; | 35 | private ImGroupMapper imGroupMapper; |
| 28 | 36 | ||
| 37 | + @Resource | ||
| 38 | + private ImGroupMemberMapper imGroupMemberMapper; | ||
| 39 | + | ||
| 29 | @Override | 40 | @Override |
| 30 | public Long createGroup(ImGroupSaveReqVO createReqVO) { | 41 | public Long createGroup(ImGroupSaveReqVO createReqVO) { |
| 31 | // 插入 | 42 | // 插入 |
| @@ -77,4 +88,21 @@ public class ImGroupServiceImpl implements ImGroupService { | @@ -77,4 +88,21 @@ public class ImGroupServiceImpl implements ImGroupService { | ||
| 77 | return imGroupMapper.selectPage(pageReqVO); | 88 | return imGroupMapper.selectPage(pageReqVO); |
| 78 | } | 89 | } |
| 79 | 90 | ||
| 91 | + @Override | ||
| 92 | + public List<ImGroupDO> getMyGroupList(Long userId) { | ||
| 93 | + // 1. 查用户所在的、仍有效的群成员记录(仅 ENABLE 状态) | ||
| 94 | + List<ImGroupMemberDO> members = imGroupMemberMapper.selectEnabledListByUserId(userId); | ||
| 95 | + if (members.isEmpty()) { | ||
| 96 | + return Collections.emptyList(); | ||
| 97 | + } | ||
| 98 | + Set<Long> groupIds = CollectionUtils.convertSet(members, ImGroupMemberDO::getGroupId); | ||
| 99 | + if (groupIds.isEmpty()) { | ||
| 100 | + return Collections.emptyList(); | ||
| 101 | + } | ||
| 102 | + // 2. 查群并过滤掉已封禁/解散的 | ||
| 103 | + List<ImGroupDO> groups = imGroupMapper.selectByIds(groupIds); | ||
| 104 | + groups.removeIf(g -> Boolean.TRUE.equals(g.getBanned()) || Boolean.TRUE.equals(g.getDissolved())); | ||
| 105 | + return groups; | ||
| 106 | + } | ||
| 107 | + | ||
| 80 | } | 108 | } |
| 81 | \ No newline at end of file | 109 | \ No newline at end of file |
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java
| @@ -49,6 +49,13 @@ import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.*; | @@ -49,6 +49,13 @@ import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.*; | ||
| 49 | @Slf4j | 49 | @Slf4j |
| 50 | public class ImGroupMessageServiceImpl implements ImGroupMessageService { | 50 | public class ImGroupMessageServiceImpl implements ImGroupMessageService { |
| 51 | 51 | ||
| 52 | + /** | ||
| 53 | + * 仅用于规避群消息 pull 的"假空页": | ||
| 54 | + * 首批 raw 消息可能因入群时间或定向接收过滤后变成空列表,但后续更大的 id 仍然存在可见消息。 | ||
| 55 | + * 因此仅在过滤结果为空时,按本轮 raw 最大 id 向后再试几次。 | ||
| 56 | + */ | ||
| 57 | + private static final int PULL_GROUP_MESSAGE_EMPTY_RETRY_TIMES = 3; | ||
| 58 | + | ||
| 52 | @Resource | 59 | @Resource |
| 53 | private ImGroupMessageMapper groupMessageMapper; | 60 | private ImGroupMessageMapper groupMessageMapper; |
| 54 | @Resource | 61 | @Resource |
| @@ -100,12 +107,50 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | @@ -100,12 +107,50 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | ||
| 100 | if (size > MESSAGE_MAX_PULL_SIZE) { | 107 | if (size > MESSAGE_MAX_PULL_SIZE) { |
| 101 | throw exception(MESSAGE_PULL_SIZE_EXCEEDED); | 108 | throw exception(MESSAGE_PULL_SIZE_EXCEEDED); |
| 102 | } | 109 | } |
| 110 | + | ||
| 103 | // 0. 拉取时间窗:超过窗口的老消息不再通过离线通道推送 | 111 | // 0. 拉取时间窗:超过窗口的老消息不再通过离线通道推送 |
| 104 | LocalDateTime minSendTime = LocalDateTime.now().minusDays(MESSAGE_GROUP_PULL_MAX_DAYS); | 112 | LocalDateTime minSendTime = LocalDateTime.now().minusDays(MESSAGE_GROUP_PULL_MAX_DAYS); |
| 105 | 113 | ||
| 114 | + // 1. 拉取一批原始消息(包含当前在群、退群前补齐两部分),并过滤得到当前用户可见的结果 | ||
| 115 | + List<ImGroupMessageDO> result = Collections.emptyList(); | ||
| 116 | + Long currentMinId = minId; // 当前轮次的下限消息编号;初始为用户传入的 minId,后续会根据拉取结果更新 | ||
| 117 | + for (int retryCount = 0; retryCount <= PULL_GROUP_MESSAGE_EMPTY_RETRY_TIMES; retryCount++) { | ||
| 118 | + // 1.1 查询一批原始消息(包含当前在群、退群前补齐两部分) | ||
| 119 | + Map<Long, ImGroupMemberDO> memberMap = new HashMap<>(); // 群编号 → 群成员记录(包含当前在群、退群前补齐两部分) | ||
| 120 | + List<ImGroupMessageDO> messages = getGroupMessageList(userId, currentMinId, size, minSendTime, memberMap); | ||
| 121 | + if (CollUtil.isEmpty(messages)) { | ||
| 122 | + break; | ||
| 123 | + } | ||
| 124 | + // 1.2 过滤得到当前用户可见的结果(按 id 升序) | ||
| 125 | + result = filterGroupMessageList(messages, memberMap, userId, size); | ||
| 126 | + // 已拿到可见消息,或原始消息已不足一页(说明没有继续重试的必要) | ||
| 127 | + if (CollUtil.isNotEmpty(result) || messages.size() < size) { | ||
| 128 | + break; | ||
| 129 | + } | ||
| 130 | + | ||
| 131 | + // 1.3 更新 currentMinId,进行下一轮查询;如果本轮查询的最大消息编号没有前进,则说明后续没有更多消息了,直接 break | ||
| 132 | + Long maxMessageId = getMaxValue(messages, ImGroupMessageDO::getId); | ||
| 133 | + if (maxMessageId == null || maxMessageId <= currentMinId) { | ||
| 134 | + break; | ||
| 135 | + } | ||
| 136 | + currentMinId = maxMessageId; | ||
| 137 | + } | ||
| 138 | + | ||
| 139 | + // 2. 按当前用户补齐:消息已读态(相对 Redis 已读游标)、本人发送的回执消息的已读人数 | ||
| 140 | + appendMessageStatusAndReceipt(userId, result); | ||
| 141 | + log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]", userId, minId, size, result.size()); | ||
| 142 | + return result; | ||
| 143 | + } | ||
| 144 | + | ||
| 145 | + /** | ||
| 146 | + * 查询一批原始群消息(包含当前在群、退群前补齐两部分) | ||
| 147 | + */ | ||
| 148 | + private List<ImGroupMessageDO> getGroupMessageList(Long userId, Long minId, Integer size, | ||
| 149 | + LocalDateTime minSendTime, | ||
| 150 | + Map<Long, ImGroupMemberDO> memberMap) { | ||
| 106 | // 1. 主查询:仅用"当前仍在群"的成员记录驱动(已退群的群由步骤 2 补齐) | 151 | // 1. 主查询:仅用"当前仍在群"的成员记录驱动(已退群的群由步骤 2 补齐) |
| 107 | List<ImGroupMemberDO> activeMembers = groupMemberService.getActiveGroupMemberListByUserId(userId); | 152 | List<ImGroupMemberDO> activeMembers = groupMemberService.getActiveGroupMemberListByUserId(userId); |
| 108 | - Map<Long, ImGroupMemberDO> memberMap = convertMap(activeMembers, ImGroupMemberDO::getGroupId); | 153 | + memberMap.putAll(convertMap(activeMembers, ImGroupMemberDO::getGroupId)); |
| 109 | List<ImGroupMessageDO> messages = new ArrayList<>(); | 154 | List<ImGroupMessageDO> messages = new ArrayList<>(); |
| 110 | if (CollUtil.isNotEmpty(activeMembers)) { | 155 | if (CollUtil.isNotEmpty(activeMembers)) { |
| 111 | List<Long> groupIds = convertList(activeMembers, ImGroupMemberDO::getGroupId); | 156 | List<Long> groupIds = convertList(activeMembers, ImGroupMemberDO::getGroupId); |
| @@ -128,25 +173,28 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | @@ -128,25 +173,28 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | ||
| 128 | for (ImGroupMemberDO quitMember : quitMembers) { | 173 | for (ImGroupMemberDO quitMember : quitMembers) { |
| 129 | List<ImGroupMessageDO> quitGroupMessages = groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore( | 174 | List<ImGroupMessageDO> quitGroupMessages = groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore( |
| 130 | quitMember.getGroupId(), minId, minSendTime, quitMember.getQuitTime(), size); | 175 | quitMember.getGroupId(), minId, minSendTime, quitMember.getQuitTime(), size); |
| 131 | - if (CollUtil.isNotEmpty(quitGroupMessages)) { | ||
| 132 | - messages.addAll(quitGroupMessages); | ||
| 133 | - memberMap.put(quitMember.getGroupId(), quitMember); | 176 | + if (CollUtil.isEmpty(quitGroupMessages)) { |
| 177 | + continue; | ||
| 134 | } | 178 | } |
| 179 | + messages.addAll(quitGroupMessages); | ||
| 180 | + memberMap.put(quitMember.getGroupId(), quitMember); | ||
| 135 | } | 181 | } |
| 182 | + return messages; | ||
| 183 | + } | ||
| 136 | 184 | ||
| 137 | - // 3.1 按可见性过滤(入群前不可见、定向消息排除),按 id 升序后仅取本页 size 条, | ||
| 138 | - // 避免「在群 + 退群前」多路合并时一次响应跨度过大、游标直接跳到全局最大 id 而漏拉中间消息 | ||
| 139 | - List<ImGroupMessageDO> result = messages.stream() | 185 | + /** |
| 186 | + * 过滤一批原始群消息,得到当前用户可见的返回结果 | ||
| 187 | + */ | ||
| 188 | + private List<ImGroupMessageDO> filterGroupMessageList(List<ImGroupMessageDO> messages, | ||
| 189 | + Map<Long, ImGroupMemberDO> memberMap, | ||
| 190 | + Long userId, Integer size) { | ||
| 191 | + // 按可见性过滤(入群前不可见、定向消息排除),按 id 升序后仅取本页 size 条, | ||
| 192 | + // 避免「在群 + 退群前」多路合并时一次响应跨度过大、游标直接跳到全局最大 id 而漏拉中间消息 | ||
| 193 | + return messages.stream() | ||
| 140 | .filter(msg -> isMessageVisible(msg, memberMap.get(msg.getGroupId()), userId)) | 194 | .filter(msg -> isMessageVisible(msg, memberMap.get(msg.getGroupId()), userId)) |
| 141 | .sorted(Comparator.comparing(ImGroupMessageDO::getId)) | 195 | .sorted(Comparator.comparing(ImGroupMessageDO::getId)) |
| 142 | .limit(size) | 196 | .limit(size) |
| 143 | .toList(); | 197 | .toList(); |
| 144 | - // 3.2 按当前用户补齐:消息已读态(相对 Redis 已读游标)、本人发送的回执消息的已读人数 | ||
| 145 | - appendMessageStatusAndReceipt(userId, result); | ||
| 146 | - | ||
| 147 | - log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]", | ||
| 148 | - userId, minId, size, result.size()); | ||
| 149 | - return result; | ||
| 150 | } | 198 | } |
| 151 | 199 | ||
| 152 | /** | 200 | /** |