Commit 4b27620e86d388228d9f8cd54843a165f6efec9d
1 parent
6d8ad0c3
feat: 完善 IM 群历史消息拉取与历史群前端门控
- 后端群列表返回历史群成员状态 joinStatus,用于区分当前群和历史退群群 - 群消息拉取支持基于 receiver_user_ids 快照过滤可见消息 - 补充群消息 pull、群成员候选、私聊 pull 相关索引与 SQL 脚本 - 前端接入 joinStatus,并封装历史退群群判断 - 历史退群群禁发、隐藏群操作入口,并从通讯录、转发、推荐名片候选中排除 - 保留历史群会话展示能力,用于查看退群前历史消息
Showing
10 changed files
with
125 additions
and
99 deletions
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java
| 1 | package cn.iocoder.yudao.module.im.controller.admin.group; | 1 | package cn.iocoder.yudao.module.im.controller.admin.group; |
| 2 | 2 | ||
| 3 | import cn.hutool.core.collection.CollUtil; | 3 | import cn.hutool.core.collection.CollUtil; |
| 4 | +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; | ||
| 4 | import cn.iocoder.yudao.framework.common.pojo.CommonResult; | 5 | import cn.iocoder.yudao.framework.common.pojo.CommonResult; |
| 5 | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; | 6 | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; |
| 6 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.*; | 7 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.*; |
| @@ -193,13 +194,16 @@ public class ImGroupController { | @@ -193,13 +194,16 @@ public class ImGroupController { | ||
| 193 | Map<Long, ImGroupMessageDO> messageMap = groupMessageService.getGroupMessageMap(allMessageIds); | 194 | Map<Long, ImGroupMessageDO> messageMap = groupMessageService.getGroupMessageMap(allMessageIds); |
| 194 | // 转换输出 | 195 | // 转换输出 |
| 195 | return convertList(groups, group -> { | 196 | return convertList(groups, group -> { |
| 196 | - ImGroupRespVO vo = BeanUtils.toBean(group, ImGroupRespVO.class); | ||
| 197 | - if (!activeGroupIds.contains(group.getId()) || CollUtil.isEmpty(group.getPinnedMessageIds())) { | ||
| 198 | - return vo; | 197 | + ImGroupRespVO groupVO = BeanUtils.toBean(group, ImGroupRespVO.class); |
| 198 | + // 标记登录用户在该群的成员状态,供前端区分当前群与历史退群 | ||
| 199 | + boolean joined = activeGroupIds.contains(group.getId()); | ||
| 200 | + groupVO.setJoinStatus(joined ? CommonStatusEnum.ENABLE.getStatus() : CommonStatusEnum.DISABLE.getStatus()); | ||
| 201 | + if (!joined || CollUtil.isEmpty(group.getPinnedMessageIds())) { | ||
| 202 | + return groupVO; | ||
| 199 | } | 203 | } |
| 200 | // 按 pin 顺序输出,已被删除的消息(messageMap 没命中)跳过 | 204 | // 按 pin 顺序输出,已被删除的消息(messageMap 没命中)跳过 |
| 201 | List<ImGroupMessageDO> pinnedMesages = convertList(group.getPinnedMessageIds(), messageMap::get); | 205 | List<ImGroupMessageDO> pinnedMesages = convertList(group.getPinnedMessageIds(), messageMap::get); |
| 202 | - return vo.setPinnedMessages(BeanUtils.toBean(pinnedMesages, ImGroupMessageRespVO.class)); | 206 | + return groupVO.setPinnedMessages(BeanUtils.toBean(pinnedMesages, ImGroupMessageRespVO.class)); |
| 203 | }); | 207 | }); |
| 204 | } | 208 | } |
| 205 | 209 |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java
| @@ -47,6 +47,9 @@ public class ImGroupRespVO { | @@ -47,6 +47,9 @@ public class ImGroupRespVO { | ||
| 47 | @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) | 47 | @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) |
| 48 | private LocalDateTime createTime; | 48 | private LocalDateTime createTime; |
| 49 | 49 | ||
| 50 | + @Schema(description = "当前登录用户在该群的成员状态:在群 / 已退群(历史退群群仍返回,供前端展示离线消息的群名 / 头像)") | ||
| 51 | + private Integer joinStatus; // 参见 CommonStatusEnum 枚举 | ||
| 52 | + | ||
| 50 | @Schema(description = "群置顶消息列表,按 pin 顺序(最先置顶的在前);非该群有效成员时为空") | 53 | @Schema(description = "群置顶消息列表,按 pin 顺序(最先置顶的在前);非该群有效成员时为空") |
| 51 | private List<ImGroupMessageRespVO> pinnedMessages; | 54 | private List<ImGroupMessageRespVO> pinnedMessages; |
| 52 | 55 |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapper.java
| 1 | package cn.iocoder.yudao.module.im.dal.mysql.group; | 1 | package cn.iocoder.yudao.module.im.dal.mysql.group; |
| 2 | 2 | ||
| 3 | import cn.hutool.core.collection.CollUtil; | 3 | import cn.hutool.core.collection.CollUtil; |
| 4 | -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; | ||
| 5 | import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; | 4 | import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; |
| 6 | import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; | 5 | import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; |
| 7 | import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; | 6 | import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; |
| @@ -59,20 +58,8 @@ public interface ImGroupMemberMapper extends BaseMapperX<ImGroupMemberDO> { | @@ -59,20 +58,8 @@ public interface ImGroupMemberMapper extends BaseMapperX<ImGroupMemberDO> { | ||
| 59 | .eq(ImGroupMemberDO::getStatus, status)); | 58 | .eq(ImGroupMemberDO::getStatus, status)); |
| 60 | } | 59 | } |
| 61 | 60 | ||
| 62 | - /** | ||
| 63 | - * 查询用户已退群的成员记录 | ||
| 64 | - * <p> | ||
| 65 | - * 当 {@code minQuitTime} 非空时额外按 {@code quitTime ≥ minQuitTime} 过滤。 | ||
| 66 | - * | ||
| 67 | - * @param userId 用户编号 | ||
| 68 | - * @param minQuitTime 最早退群时间(含),可空 | ||
| 69 | - * @return 已退群成员记录列表 | ||
| 70 | - */ | ||
| 71 | - default List<ImGroupMemberDO> selectQuitListByUserId(Long userId, LocalDateTime minQuitTime) { | ||
| 72 | - return selectList(new LambdaQueryWrapperX<ImGroupMemberDO>() | ||
| 73 | - .eq(ImGroupMemberDO::getUserId, userId) | ||
| 74 | - .eq(ImGroupMemberDO::getStatus, CommonStatusEnum.DISABLE.getStatus()) | ||
| 75 | - .geIfPresent(ImGroupMemberDO::getQuitTime, minQuitTime)); | 61 | + default List<ImGroupMemberDO> selectListByUserId(Long userId) { |
| 62 | + return selectList(ImGroupMemberDO::getUserId, userId); | ||
| 76 | } | 63 | } |
| 77 | 64 | ||
| 78 | @SuppressWarnings("UnusedReturnValue") | 65 | @SuppressWarnings("UnusedReturnValue") |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java
| @@ -87,13 +87,12 @@ public interface ImGroupMemberService { | @@ -87,13 +87,12 @@ public interface ImGroupMemberService { | ||
| 87 | List<ImGroupMemberDO> getActiveGroupMemberListByUserId(Long userId); | 87 | List<ImGroupMemberDO> getActiveGroupMemberListByUserId(Long userId); |
| 88 | 88 | ||
| 89 | /** | 89 | /** |
| 90 | - * 查询用户已退群的群成员记录(DISABLE 状态) | 90 | + * 查询用户曾经加入的所有群成员记录(含已退群) |
| 91 | * | 91 | * |
| 92 | - * @param userId 用户编号,必传 | ||
| 93 | - * @param minQuitTime 最早退群时间(含),可空 | ||
| 94 | - * @return 已退群成员记录列表 | 92 | + * @param userId 用户编号 |
| 93 | + * @return 群成员记录列表 | ||
| 95 | */ | 94 | */ |
| 96 | - List<ImGroupMemberDO> getQuitGroupMemberListByUserId(Long userId, LocalDateTime minQuitTime); | 95 | + List<ImGroupMemberDO> getGroupMemberListByUserId(Long userId); |
| 97 | 96 | ||
| 98 | /** | 97 | /** |
| 99 | * 添加群成员(入群),角色默认 MEMBER | 98 | * 添加群成员(入群),角色默认 MEMBER |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java
| @@ -105,8 +105,8 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { | @@ -105,8 +105,8 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService { | ||
| 105 | } | 105 | } |
| 106 | 106 | ||
| 107 | @Override | 107 | @Override |
| 108 | - public List<ImGroupMemberDO> getQuitGroupMemberListByUserId(Long userId, LocalDateTime minQuitTime) { | ||
| 109 | - return groupMemberMapper.selectQuitListByUserId(userId, minQuitTime); | 108 | + public List<ImGroupMemberDO> getGroupMemberListByUserId(Long userId) { |
| 109 | + return groupMemberMapper.selectListByUserId(userId); | ||
| 110 | } | 110 | } |
| 111 | 111 | ||
| 112 | @Override | 112 | @Override |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java
| @@ -593,14 +593,10 @@ public class ImGroupServiceImpl implements ImGroupService { | @@ -593,14 +593,10 @@ public class ImGroupServiceImpl implements ImGroupService { | ||
| 593 | return convertMap(groupMapper.selectByIds(ids), ImGroupDO::getId); | 593 | return convertMap(groupMapper.selectByIds(ids), ImGroupDO::getId); |
| 594 | } | 594 | } |
| 595 | 595 | ||
| 596 | - // TODO @AI:是不是可以拉退出的也可以哈?前端自己按需过滤! | ||
| 597 | @Override | 596 | @Override |
| 598 | public List<ImGroupDO> getMyGroupList(Long userId) { | 597 | public List<ImGroupDO> getMyGroupList(Long userId) { |
| 599 | - // 1.1 查用户所在的、仍有效的群成员记录(仅 ENABLE 状态) | ||
| 600 | - List<ImGroupMemberDO> members = groupMemberService.getActiveGroupMemberListByUserId(userId); | ||
| 601 | - // 1.2 再查最近 N 天(与群消息离线拉取窗口一致)内退群的成员记录(退群前可能有离线消息需要展示,一并返回作为前端缓存) | ||
| 602 | - LocalDateTime minQuitTime = LocalDateTime.now().minusDays(imProperties.getMessage().getGroupPullMaxDays()); | ||
| 603 | - members.addAll(groupMemberService.getQuitGroupMemberListByUserId(userId, minQuitTime)); | 598 | + // 1. 查用户曾经加入的所有群(含退群,前端按需过滤);退群前的离线消息也要能展示 |
| 599 | + List<ImGroupMemberDO> members = groupMemberService.getGroupMemberListByUserId(userId); | ||
| 604 | if (CollUtil.isEmpty(members)) { | 600 | if (CollUtil.isEmpty(members)) { |
| 605 | return Collections.emptyList(); | 601 | return Collections.emptyList(); |
| 606 | } | 602 | } |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java
| @@ -97,9 +97,8 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | @@ -97,9 +97,8 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | ||
| 97 | sensitiveWordService.validateText(reqVO.getContent()); | 97 | sensitiveWordService.validateText(reqVO.getContent()); |
| 98 | } | 98 | } |
| 99 | 99 | ||
| 100 | - // 2.1 固化发送当时可见成员快照(含发送者),新群消息 receiver_user_ids 不能为空;用户发送均为全员广播 | 100 | + // 2.1 固化发送当时可见成员快照:用户发送均为全员广播;getReceiverUserIds 兜底纳入发送者,钉死发送者必可见 |
| 101 | List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(reqVO.getGroupId()); | 101 | List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(reqVO.getGroupId()); |
| 102 | - // TODO @AI:这里是不是没必要过滤???因为发送者,肯定是群成员呀!!! | ||
| 103 | Set<Long> receiverUserIds = getReceiverUserIds(null, senderId, memberUserIds); | 102 | Set<Long> receiverUserIds = getReceiverUserIds(null, senderId, memberUserIds); |
| 104 | // 2.2 引用 quote 消息规范化(含可见性子集校验,防止定向消息内容被广播引用泄漏) | 103 | // 2.2 引用 quote 消息规范化(含可见性子集校验,防止定向消息内容被广播引用泄漏) |
| 105 | reqVO.setContent(normalizeQuoteContent(reqVO, senderMember, receiverUserIds)); | 104 | reqVO.setContent(normalizeQuoteContent(reqVO, senderMember, receiverUserIds)); |
| @@ -135,8 +134,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | @@ -135,8 +134,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | ||
| 135 | String contentString = payload == null || payload instanceof String | 134 | String contentString = payload == null || payload instanceof String |
| 136 | ? (String) payload | 135 | ? (String) payload |
| 137 | : JsonUtils.toJsonString(payload); | 136 | : JsonUtils.toJsonString(payload); |
| 138 | - // TODO @AI:;receiver_user_ids 固化为推送目标(发送当时可见成员快照) 这种注释,没必要写哇? | ||
| 139 | - // 1.2 构建并保存消息;receiver_user_ids 固化为推送目标(发送当时可见成员快照) | 137 | + // 1.2 构建并保存消息 |
| 140 | ImGroupMessageDO message = new ImGroupMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) | 138 | ImGroupMessageDO message = new ImGroupMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) |
| 141 | .setSenderId(senderId).setGroupId(dto.getGroupId()) | 139 | .setSenderId(senderId).setGroupId(dto.getGroupId()) |
| 142 | .setType(dto.getType()).setContent(contentString) | 140 | .setType(dto.getType()).setContent(contentString) |
| @@ -198,19 +196,14 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | @@ -198,19 +196,14 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | ||
| 198 | // 0. 拉取时间窗;超过窗口的老消息不再通过离线通道推送 | 196 | // 0. 拉取时间窗;超过窗口的老消息不再通过离线通道推送 |
| 199 | LocalDateTime minSendTime = LocalDateTime.now().minusDays(imProperties.getMessage().getGroupPullMaxDays()); | 197 | LocalDateTime minSendTime = LocalDateTime.now().minusDays(imProperties.getMessage().getGroupPullMaxDays()); |
| 200 | 198 | ||
| 201 | - // 1. 候选群 = 当前在群 ∪ 窗口内退群;退群用户仍可拉到退群前已写入自己 ID 的历史消息 | ||
| 202 | - // TODO @AI:直接查询所有的群(一次性,这样性能也好点呀);然后这里不用过滤 minSendTime 吧; | ||
| 203 | - Set<Long> groupIds = new HashSet<>(); | ||
| 204 | - groupIds.addAll(convertList(groupMemberService.getActiveGroupMemberListByUserId(userId), | ||
| 205 | - ImGroupMemberDO::getGroupId)); | ||
| 206 | - groupIds.addAll(convertList(groupMemberService.getQuitGroupMemberListByUserId(userId, minSendTime), | ||
| 207 | - ImGroupMemberDO::getGroupId)); | 199 | + // 1. 候选群 = 用户曾经加入的所有群(含退群);可见性与时间窗都交给 SQL,退群群最多贡献 0 条 |
| 200 | + Set<Long> groupIds = convertSet(groupMemberService.getGroupMemberListByUserId(userId), | ||
| 201 | + ImGroupMemberDO::getGroupId); | ||
| 208 | if (CollUtil.isEmpty(groupIds)) { | 202 | if (CollUtil.isEmpty(groupIds)) { |
| 209 | return Collections.emptyList(); | 203 | return Collections.emptyList(); |
| 210 | } | 204 | } |
| 211 | 205 | ||
| 212 | - // TODO @AI:注释是不是优化下,不用按照“单次 SQL 按 receiver_user_ids 快照过滤可见性,结果天然按 id 升序、无需内存再过滤” | ||
| 213 | - // 2. 单次 SQL 按 receiver_user_ids 快照过滤可见性,结果天然按 id 升序、无需内存再过滤 | 206 | + // 2. 按 receiver_user_ids 快照过滤可见性,结果按 id 升序 |
| 214 | List<ImGroupMessageDO> messages = groupMessageMapper.selectListByMinId(userId, groupIds, minId, minSendTime, size); | 207 | List<ImGroupMessageDO> messages = groupMessageMapper.selectListByMinId(userId, groupIds, minId, minSendTime, size); |
| 215 | 208 | ||
| 216 | // 3. 按当前用户补齐:消息已读态、本人发送的回执消息的已读人数 | 209 | // 3. 按当前用户补齐:消息已读态、本人发送的回执消息的已读人数 |
| @@ -219,12 +212,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | @@ -219,12 +212,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | ||
| 219 | return messages; | 212 | return messages; |
| 220 | } | 213 | } |
| 221 | 214 | ||
| 222 | - // TODO @AI:要不要改下,把“回执已读人数(readCount):仅对本人发送的回执消息,计算可见成员中的已读人数”都计算?! | ||
| 223 | /** | 215 | /** |
| 224 | * 补全消息已读态和回执已读人数 | 216 | * 补全消息已读态和回执已读人数 |
| 225 | * <p> | 217 | * <p> |
| 226 | - * 1. 消息已读态(status):根据 im_conversation_read 读位置判断 READ / UNREAD | ||
| 227 | - * 2. 回执已读人数(readCount):仅对本人发送的回执消息,计算可见成员中的已读人数 | 218 | + * 1. 消息已读态(status):根据 im_conversation_read 读位置判断 READ / UNREAD,所有消息都补 |
| 219 | + * 2. 回执已读人数(readCount):仅对本人发送、且需要回执(非 NO_RECEIPT)的消息,计算可见成员中的已读人数 | ||
| 228 | */ | 220 | */ |
| 229 | @SuppressWarnings({"StatementWithEmptyBody", "DataFlowIssue"}) | 221 | @SuppressWarnings({"StatementWithEmptyBody", "DataFlowIssue"}) |
| 230 | private void appendMessageStatusAndReceipt(Long userId, List<ImGroupMessageDO> messages) { | 222 | private void appendMessageStatusAndReceipt(Long userId, List<ImGroupMessageDO> messages) { |
| @@ -254,9 +246,10 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | @@ -254,9 +246,10 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | ||
| 254 | message.setStatus(ImMessageStatusEnum.UNREAD.getStatus()); | 246 | message.setStatus(ImMessageStatusEnum.UNREAD.getStatus()); |
| 255 | } | 247 | } |
| 256 | 248 | ||
| 257 | - // 回执消息的已读人数(readCount):仅补齐本人发送的,其他消息不处理(回执消息才关心已读人数,且只对发送者可见) | 249 | + // 回执已读人数(readCount):仅补本人发送、且需要回执(非 NO_RECEIPT)的消息 |
| 250 | + // 读位置(status)所有消息都补;readCount 只服务「需要回执」的消息,不把已读位置和消息回执混为一谈 | ||
| 258 | if (ObjUtil.notEqual(message.getSenderId(), userId) | 251 | if (ObjUtil.notEqual(message.getSenderId(), userId) |
| 259 | - || ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus().equals(message.getReceiptStatus())) { | 252 | + || ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus().equals(message.getReceiptStatus())) { |
| 260 | continue; | 253 | continue; |
| 261 | } | 254 | } |
| 262 | Map<Long, Long> positions = readPositionsByGroup.computeIfAbsent(groupId, | 255 | Map<Long, Long> positions = readPositionsByGroup.computeIfAbsent(groupId, |
| @@ -286,7 +279,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | @@ -286,7 +279,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | ||
| 286 | ImGroupMessageDO message = groupMessageMapper.selectById(messageId); | 279 | ImGroupMessageDO message = groupMessageMapper.selectById(messageId); |
| 287 | if (message == null | 280 | if (message == null |
| 288 | || ObjUtil.notEqual(message.getGroupId(), groupId) | 281 | || ObjUtil.notEqual(message.getGroupId(), groupId) |
| 289 | - || !isMessageVisible(message, userId)) { | 282 | + || !isMessageReceived(message, userId)) { |
| 290 | throw exception(MESSAGE_NOT_IN_GROUP); | 283 | throw exception(MESSAGE_NOT_IN_GROUP); |
| 291 | } | 284 | } |
| 292 | 285 | ||
| @@ -316,7 +309,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | @@ -316,7 +309,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | ||
| 316 | if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId)) { | 309 | if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId)) { |
| 317 | return Collections.emptyList(); | 310 | return Collections.emptyList(); |
| 318 | } | 311 | } |
| 319 | - if (!isMessageVisible(message, userId)) { | 312 | + if (!isMessageReceived(message, userId)) { |
| 320 | return Collections.emptyList(); | 313 | return Collections.emptyList(); |
| 321 | } | 314 | } |
| 322 | // 1.3 仅消息发送方关心已读人数;非发送方查询直接返回空 | 315 | // 1.3 仅消息发送方关心已读人数;非发送方查询直接返回空 |
| @@ -436,7 +429,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | @@ -436,7 +429,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | ||
| 436 | throw exception(MESSAGE_QUOTE_INVALID); | 429 | throw exception(MESSAGE_QUOTE_INVALID); |
| 437 | } | 430 | } |
| 438 | // 校验对发送人可见(按快照) | 431 | // 校验对发送人可见(按快照) |
| 439 | - if (!isMessageVisible(original, senderMember.getUserId())) { | 432 | + if (!isMessageReceived(original, senderMember.getUserId())) { |
| 440 | throw exception(MESSAGE_QUOTE_INVALID); | 433 | throw exception(MESSAGE_QUOTE_INVALID); |
| 441 | } | 434 | } |
| 442 | // 防泄漏:新消息可见集合必须是被引用消息可见集合的子集,否则禁止引用; | 435 | // 防泄漏:新消息可见集合必须是被引用消息可见集合的子集,否则禁止引用; |
| @@ -464,24 +457,22 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | @@ -464,24 +457,22 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | ||
| 464 | return message; | 457 | return message; |
| 465 | } | 458 | } |
| 466 | 459 | ||
| 467 | - // TODO @AI:方法名,是不是可以优化下,对齐 ReceiverUserIds; | ||
| 468 | /** | 460 | /** |
| 469 | - * 判断一条群消息对某个用户是否可见 | 461 | + * 判断一条群消息是否被某个用户接收到 |
| 470 | * <p> | 462 | * <p> |
| 471 | - * 可见性以发送当时固化的 receiver_user_ids 快照为准;发送者也在快照内。 | 463 | + * 接收范围以发送当时固化的 receiver_user_ids 快照为准;发送者也在快照内。 |
| 472 | * | 464 | * |
| 473 | * @param message 消息 | 465 | * @param message 消息 |
| 474 | * @param userId 当前用户编号 | 466 | * @param userId 当前用户编号 |
| 475 | - * @return 是否可见 | 467 | + * @return 是否接收到 |
| 476 | */ | 468 | */ |
| 477 | @SuppressWarnings("BooleanMethodIsAlwaysInverted") | 469 | @SuppressWarnings("BooleanMethodIsAlwaysInverted") |
| 478 | - private boolean isMessageVisible(ImGroupMessageDO message, Long userId) { | 470 | + private boolean isMessageReceived(ImGroupMessageDO message, Long userId) { |
| 479 | return CollUtil.contains(message.getReceiverUserIds(), userId); | 471 | return CollUtil.contains(message.getReceiverUserIds(), userId); |
| 480 | } | 472 | } |
| 481 | 473 | ||
| 482 | - // TODO @AI: ∩ 是啥意思,有点奇怪。 | ||
| 483 | /** | 474 | /** |
| 484 | - * 计算一条群消息的可见成员集合(快照 ∩ 传入成员,含发送者) | 475 | + * 计算一条群消息的可见成员集合(快照与传入成员的交集,含发送者) |
| 485 | */ | 476 | */ |
| 486 | private Set<Long> getReceiverUserIds(ImGroupMessageDO message, List<ImGroupMemberDO> members) { | 477 | private Set<Long> getReceiverUserIds(ImGroupMessageDO message, List<ImGroupMemberDO> members) { |
| 487 | if (CollUtil.isEmpty(message.getReceiverUserIds())) { | 478 | if (CollUtil.isEmpty(message.getReceiverUserIds())) { |
| @@ -497,20 +488,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | @@ -497,20 +488,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | ||
| 497 | * 仅适用于「新消息」发送场景:无定向接收列表则全员可见,否则取定向用户与当前成员的交集; | 488 | * 仅适用于「新消息」发送场景:无定向接收列表则全员可见,否则取定向用户与当前成员的交集; |
| 498 | * 发送者始终纳入快照,保证 receiver_user_ids 非空且发送方可见。 | 489 | * 发送者始终纳入快照,保证 receiver_user_ids 非空且发送方可见。 |
| 499 | */ | 490 | */ |
| 500 | - private Set<Long> getReceiverUserIds(List<Long> receiverUserIds, Long senderId, Collection<Long> memberUserIds) { | ||
| 501 | - Set<Long> result = new HashSet<>(); | ||
| 502 | - // 无定向接收列表 → 全员可见 | ||
| 503 | - if (CollUtil.isEmpty(receiverUserIds)) { | ||
| 504 | - result.addAll(memberUserIds); | ||
| 505 | - } else { | ||
| 506 | - // 有定向接收列表 → 仅定向用户且仍为成员可见 | ||
| 507 | - Set<Long> allowed = new HashSet<>(receiverUserIds); | ||
| 508 | - for (Long userId : memberUserIds) { | ||
| 509 | - if (allowed.contains(userId)) { | ||
| 510 | - result.add(userId); | ||
| 511 | - } | ||
| 512 | - } | ||
| 513 | - } | 491 | + private Set<Long> getReceiverUserIds(List<Long> directedUserIds, Long senderId, Collection<Long> memberUserIds) { |
| 492 | + // 无定向接收列表 → 全员可见;否则取定向用户与当前成员的交集 | ||
| 493 | + Set<Long> result = CollUtil.isEmpty(directedUserIds) | ||
| 494 | + ? new HashSet<>(memberUserIds) | ||
| 495 | + : new HashSet<>(CollUtil.intersection(memberUserIds, directedUserIds)); | ||
| 514 | // 发送者始终可见(多端同步),即便成员缓存暂未包含 | 496 | // 发送者始终可见(多端同步),即便成员缓存暂未包含 |
| 515 | if (senderId != null) { | 497 | if (senderId != null) { |
| 516 | result.add(senderId); | 498 | result.add(senderId); |
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java
| @@ -1100,9 +1100,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { | @@ -1100,9 +1100,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { | ||
| 1100 | 1100 | ||
| 1101 | @Test | 1101 | @Test |
| 1102 | public void testGetMyGroupList_noMembers() { | 1102 | public void testGetMyGroupList_noMembers() { |
| 1103 | - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>()); | ||
| 1104 | - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) | ||
| 1105 | - .thenReturn(new ArrayList<>()); | 1103 | + when(groupMemberService.getGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>()); |
| 1106 | 1104 | ||
| 1107 | List<ImGroupDO> result = groupService.getMyGroupList(1L); | 1105 | List<ImGroupDO> result = groupService.getMyGroupList(1L); |
| 1108 | assertTrue(result.isEmpty()); | 1106 | assertTrue(result.isEmpty()); |
| @@ -1111,19 +1109,15 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { | @@ -1111,19 +1109,15 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { | ||
| 1111 | 1109 | ||
| 1112 | @Test | 1110 | @Test |
| 1113 | public void testGetMyGroupList_success() { | 1111 | public void testGetMyGroupList_success() { |
| 1114 | - // 活跃群成员 | ||
| 1115 | - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>(List.of( | 1112 | + // 曾经加入的所有群(含退群) |
| 1113 | + when(groupMemberService.getGroupMemberListByUserId(1L)).thenReturn(new ArrayList<>(List.of( | ||
| 1116 | ImGroupMemberDO.builder().groupId(10L).userId(1L) | 1114 | ImGroupMemberDO.builder().groupId(10L).userId(1L) |
| 1117 | .status(CommonStatusEnum.ENABLE.getStatus()).build(), | 1115 | .status(CommonStatusEnum.ENABLE.getStatus()).build(), |
| 1118 | ImGroupMemberDO.builder().groupId(20L).userId(1L) | 1116 | ImGroupMemberDO.builder().groupId(20L).userId(1L) |
| 1119 | - .status(CommonStatusEnum.ENABLE.getStatus()).build() | 1117 | + .status(CommonStatusEnum.ENABLE.getStatus()).build(), |
| 1118 | + ImGroupMemberDO.builder().groupId(30L).userId(1L) | ||
| 1119 | + .status(CommonStatusEnum.DISABLE.getStatus()).build() | ||
| 1120 | ))); | 1120 | ))); |
| 1121 | - // 最近退群成员(最近 30 天内) | ||
| 1122 | - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) | ||
| 1123 | - .thenReturn(new ArrayList<>(List.of( | ||
| 1124 | - ImGroupMemberDO.builder().groupId(30L).userId(1L) | ||
| 1125 | - .status(CommonStatusEnum.DISABLE.getStatus()).build() | ||
| 1126 | - ))); | ||
| 1127 | List<ImGroupDO> groups = List.of( | 1121 | List<ImGroupDO> groups = List.of( |
| 1128 | ImGroupDO.builder().id(10L).status(CommonStatusEnum.ENABLE.getStatus()).build(), | 1122 | ImGroupDO.builder().id(10L).status(CommonStatusEnum.ENABLE.getStatus()).build(), |
| 1129 | ImGroupDO.builder().id(20L).status(CommonStatusEnum.ENABLE.getStatus()).build(), | 1123 | ImGroupDO.builder().id(20L).status(CommonStatusEnum.ENABLE.getStatus()).build(), |
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java
| @@ -122,6 +122,42 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { | @@ -122,6 +122,42 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { | ||
| 122 | } | 122 | } |
| 123 | 123 | ||
| 124 | @Test | 124 | @Test |
| 125 | + public void testSendMessage_senderAlwaysVisible() { | ||
| 126 | + // 边界:成员缓存漏掉发送者(理论上 validateMemberInGroup 已挡,这里纯防御);发送者仍必须固化进 receiver_user_ids | ||
| 127 | + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) { | ||
| 128 | + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) | ||
| 129 | + .thenReturn(groupMessageService); | ||
| 130 | + | ||
| 131 | + ImGroupMessageSendReqVO reqVO = buildSendReqVO(); | ||
| 132 | + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) | ||
| 133 | + .thenReturn(null); | ||
| 134 | + ImGroupDO group = new ImGroupDO(); | ||
| 135 | + group.setId(10L); | ||
| 136 | + group.setName("测试群"); | ||
| 137 | + when(groupService.validateGroupExists(10L)).thenReturn(group); | ||
| 138 | + ImGroupMemberDO member = ImGroupMemberDO.builder() | ||
| 139 | + .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); | ||
| 140 | + when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); | ||
| 141 | + // 成员缓存只返回 {2,3},漏掉发送者 1 | ||
| 142 | + when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)) | ||
| 143 | + .thenReturn(List.of(2L, 3L)); | ||
| 144 | + when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenAnswer(invocation -> { | ||
| 145 | + ImGroupMessageDO msg = invocation.getArgument(0); | ||
| 146 | + msg.setId(99L); | ||
| 147 | + return 1; | ||
| 148 | + }); | ||
| 149 | + | ||
| 150 | + ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO); | ||
| 151 | + | ||
| 152 | + // 断言:固化快照必含发送者,推送目标补回发送者 = {1,2,3} | ||
| 153 | + assertTrue(result.getReceiverUserIds().contains(1L), "发送者必须在 receiver_user_ids 快照内"); | ||
| 154 | + verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection<Long> ids) -> | ||
| 155 | + ids.size() == 3 && ids.contains(1L) && ids.contains(2L) && ids.contains(3L)), | ||
| 156 | + any(ImGroupMessageDTO.class)); | ||
| 157 | + } | ||
| 158 | + } | ||
| 159 | + | ||
| 160 | + @Test | ||
| 125 | public void testSendMessage_clientMessageIdIdempotent() { | 161 | public void testSendMessage_clientMessageIdIdempotent() { |
| 126 | // 准备 | 162 | // 准备 |
| 127 | ImGroupMessageSendReqVO reqVO = buildSendReqVO(); | 163 | ImGroupMessageSendReqVO reqVO = buildSendReqVO(); |
| @@ -224,9 +260,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { | @@ -224,9 +260,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { | ||
| 224 | .groupId(10L).userId(1L) | 260 | .groupId(10L).userId(1L) |
| 225 | .status(CommonStatusEnum.ENABLE.getStatus()) | 261 | .status(CommonStatusEnum.ENABLE.getStatus()) |
| 226 | .joinTime(now.minusDays(10)).build(); | 262 | .joinTime(now.minusDays(10)).build(); |
| 227 | - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); | ||
| 228 | - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) | ||
| 229 | - .thenReturn(List.of()); | 263 | + when(groupMemberService.getGroupMemberListByUserId(1L)).thenReturn(List.of(member)); |
| 230 | 264 | ||
| 231 | ImGroupMessageDO low = ImGroupMessageDO.builder() | 265 | ImGroupMessageDO low = ImGroupMessageDO.builder() |
| 232 | .id(5L).groupId(10L).senderId(2L).receiverUserIds(List.of(1L)) | 266 | .id(5L).groupId(10L).senderId(2L).receiverUserIds(List.of(1L)) |
| @@ -259,9 +293,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { | @@ -259,9 +293,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { | ||
| 259 | .groupId(10L).userId(1L) | 293 | .groupId(10L).userId(1L) |
| 260 | .status(CommonStatusEnum.ENABLE.getStatus()) | 294 | .status(CommonStatusEnum.ENABLE.getStatus()) |
| 261 | .joinTime(now.minusDays(10)).build(); | 295 | .joinTime(now.minusDays(10)).build(); |
| 262 | - when(groupMemberService.getActiveGroupMemberListByUserId(1L)).thenReturn(List.of(member)); | ||
| 263 | - when(groupMemberService.getQuitGroupMemberListByUserId(eq(1L), any(LocalDateTime.class))) | ||
| 264 | - .thenReturn(List.of()); | 296 | + when(groupMemberService.getGroupMemberListByUserId(1L)).thenReturn(List.of(member)); |
| 265 | 297 | ||
| 266 | ImGroupMessageDO receiptMsg = ImGroupMessageDO.builder() | 298 | ImGroupMessageDO receiptMsg = ImGroupMessageDO.builder() |
| 267 | .id(100L).groupId(10L).senderId(1L) | 299 | .id(100L).groupId(10L).senderId(1L) |
| @@ -300,6 +332,37 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { | @@ -300,6 +332,37 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { | ||
| 300 | assertEquals(1, result.get(0).getReadCount()); | 332 | assertEquals(1, result.get(0).getReadCount()); |
| 301 | } | 333 | } |
| 302 | 334 | ||
| 335 | + @Test | ||
| 336 | + public void testPullMessages_noReceiptMessageSkipsReadCount() { | ||
| 337 | + // 准备:本人发送但不需要回执(NO_RECEIPT)的消息——只补已读位置,不补 readCount | ||
| 338 | + LocalDateTime now = LocalDateTime.now(); | ||
| 339 | + ImGroupMemberDO member = ImGroupMemberDO.builder() | ||
| 340 | + .groupId(10L).userId(1L) | ||
| 341 | + .status(CommonStatusEnum.ENABLE.getStatus()) | ||
| 342 | + .joinTime(now.minusDays(10)).build(); | ||
| 343 | + when(groupMemberService.getGroupMemberListByUserId(1L)).thenReturn(List.of(member)); | ||
| 344 | + | ||
| 345 | + ImGroupMessageDO noReceiptMsg = ImGroupMessageDO.builder() | ||
| 346 | + .id(100L).groupId(10L).senderId(1L) | ||
| 347 | + .receiverUserIds(List.of(1L, 2L, 3L)) | ||
| 348 | + .status(ImMessageStatusEnum.UNREAD.getStatus()) | ||
| 349 | + .receiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()) | ||
| 350 | + .sendTime(now.minusHours(1)).build(); | ||
| 351 | + when(groupMessageMapper.selectListByMinId(eq(1L), anyCollection(), eq(0L), | ||
| 352 | + any(LocalDateTime.class), eq(100))) | ||
| 353 | + .thenReturn(List.of(noReceiptMsg)); | ||
| 354 | + when(conversationReadService.getConversationReadMessageId(eq(1L), eq(ImConversationTypeEnum.GROUP.getType()), eq(10L))) | ||
| 355 | + .thenReturn(100L); | ||
| 356 | + | ||
| 357 | + List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100); | ||
| 358 | + | ||
| 359 | + // 断言:读位置照常补(status=READ),但 NO_RECEIPT 不补 readCount,也不查 readCount 相关的读位置映射 | ||
| 360 | + assertEquals(1, result.size()); | ||
| 361 | + assertEquals(ImMessageStatusEnum.READ.getStatus(), result.get(0).getStatus()); | ||
| 362 | + assertNull(result.get(0).getReadCount()); | ||
| 363 | + verify(conversationReadService, never()).getUserReadMessageIdMap(anyInt(), anyLong()); | ||
| 364 | + } | ||
| 365 | + | ||
| 303 | // ========== 撤回测试 ========== | 366 | // ========== 撤回测试 ========== |
| 304 | 367 | ||
| 305 | @Test | 368 | @Test |
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java
| @@ -320,17 +320,15 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { | @@ -320,17 +320,15 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { | ||
| 320 | } | 320 | } |
| 321 | 321 | ||
| 322 | @Test | 322 | @Test |
| 323 | - public void testReadMessages_noUnread() { | ||
| 324 | - // 准备:mapper 返回 0 表示 id <= messageId 范围内没有未读消息 | ||
| 325 | - when(privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus( | ||
| 326 | - eq(2L), eq(1L), eq(5L), | ||
| 327 | - eq(ImMessageStatusEnum.UNREAD.getStatus()), any(ImPrivateMessageDO.class))) | ||
| 328 | - .thenReturn(0); | 323 | + public void testReadMessages_notAdvanced() { |
| 324 | + // 准备:读位置未前进(已读过 / CAS 失败),不下发事件 | ||
| 325 | + when(conversationReadService.updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong())) | ||
| 326 | + .thenReturn(false); | ||
| 329 | 327 | ||
| 330 | // 调用 | 328 | // 调用 |
| 331 | privateMessageService.readPrivateMessages(1L, 2L, 5L); | 329 | privateMessageService.readPrivateMessages(1L, 2L, 5L); |
| 332 | 330 | ||
| 333 | - // 断言:不推送 READ / RECEIPT | 331 | + // 断言:读位置没前进,不推送 READ / RECEIPT |
| 334 | verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); | 332 | verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); |
| 335 | } | 333 | } |
| 336 | 334 |