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 | 1 | package cn.iocoder.yudao.module.im.controller.admin.group; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.collection.CollUtil; |
| 4 | +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; | |
| 4 | 5 | import cn.iocoder.yudao.framework.common.pojo.CommonResult; |
| 5 | 6 | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; |
| 6 | 7 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.*; |
| ... | ... | @@ -193,13 +194,16 @@ public class ImGroupController { |
| 193 | 194 | Map<Long, ImGroupMessageDO> messageMap = groupMessageService.getGroupMessageMap(allMessageIds); |
| 194 | 195 | // 转换输出 |
| 195 | 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 | 204 | // 按 pin 顺序输出,已被删除的消息(messageMap 没命中)跳过 |
| 201 | 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 | 47 | @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) |
| 48 | 48 | private LocalDateTime createTime; |
| 49 | 49 | |
| 50 | + @Schema(description = "当前登录用户在该群的成员状态:在群 / 已退群(历史退群群仍返回,供前端展示离线消息的群名 / 头像)") | |
| 51 | + private Integer joinStatus; // 参见 CommonStatusEnum 枚举 | |
| 52 | + | |
| 50 | 53 | @Schema(description = "群置顶消息列表,按 pin 顺序(最先置顶的在前);非该群有效成员时为空") |
| 51 | 54 | private List<ImGroupMessageRespVO> pinnedMessages; |
| 52 | 55 | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapper.java
| 1 | 1 | package cn.iocoder.yudao.module.im.dal.mysql.group; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.collection.CollUtil; |
| 4 | -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; | |
| 5 | 4 | import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; |
| 6 | 5 | import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; |
| 7 | 6 | import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; |
| ... | ... | @@ -59,20 +58,8 @@ public interface ImGroupMemberMapper extends BaseMapperX<ImGroupMemberDO> { |
| 59 | 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 | 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 | 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 | 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 | 105 | } |
| 106 | 106 | |
| 107 | 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 | 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 | 593 | return convertMap(groupMapper.selectByIds(ids), ImGroupDO::getId); |
| 594 | 594 | } |
| 595 | 595 | |
| 596 | - // TODO @AI:是不是可以拉退出的也可以哈?前端自己按需过滤! | |
| 597 | 596 | @Override |
| 598 | 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 | 600 | if (CollUtil.isEmpty(members)) { |
| 605 | 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 | 97 | sensitiveWordService.validateText(reqVO.getContent()); |
| 98 | 98 | } |
| 99 | 99 | |
| 100 | - // 2.1 固化发送当时可见成员快照(含发送者),新群消息 receiver_user_ids 不能为空;用户发送均为全员广播 | |
| 100 | + // 2.1 固化发送当时可见成员快照:用户发送均为全员广播;getReceiverUserIds 兜底纳入发送者,钉死发送者必可见 | |
| 101 | 101 | List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(reqVO.getGroupId()); |
| 102 | - // TODO @AI:这里是不是没必要过滤???因为发送者,肯定是群成员呀!!! | |
| 103 | 102 | Set<Long> receiverUserIds = getReceiverUserIds(null, senderId, memberUserIds); |
| 104 | 103 | // 2.2 引用 quote 消息规范化(含可见性子集校验,防止定向消息内容被广播引用泄漏) |
| 105 | 104 | reqVO.setContent(normalizeQuoteContent(reqVO, senderMember, receiverUserIds)); |
| ... | ... | @@ -135,8 +134,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { |
| 135 | 134 | String contentString = payload == null || payload instanceof String |
| 136 | 135 | ? (String) payload |
| 137 | 136 | : JsonUtils.toJsonString(payload); |
| 138 | - // TODO @AI:;receiver_user_ids 固化为推送目标(发送当时可见成员快照) 这种注释,没必要写哇? | |
| 139 | - // 1.2 构建并保存消息;receiver_user_ids 固化为推送目标(发送当时可见成员快照) | |
| 137 | + // 1.2 构建并保存消息 | |
| 140 | 138 | ImGroupMessageDO message = new ImGroupMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) |
| 141 | 139 | .setSenderId(senderId).setGroupId(dto.getGroupId()) |
| 142 | 140 | .setType(dto.getType()).setContent(contentString) |
| ... | ... | @@ -198,19 +196,14 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { |
| 198 | 196 | // 0. 拉取时间窗;超过窗口的老消息不再通过离线通道推送 |
| 199 | 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 | 202 | if (CollUtil.isEmpty(groupIds)) { |
| 209 | 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 | 207 | List<ImGroupMessageDO> messages = groupMessageMapper.selectListByMinId(userId, groupIds, minId, minSendTime, size); |
| 215 | 208 | |
| 216 | 209 | // 3. 按当前用户补齐:消息已读态、本人发送的回执消息的已读人数 |
| ... | ... | @@ -219,12 +212,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { |
| 219 | 212 | return messages; |
| 220 | 213 | } |
| 221 | 214 | |
| 222 | - // TODO @AI:要不要改下,把“回执已读人数(readCount):仅对本人发送的回执消息,计算可见成员中的已读人数”都计算?! | |
| 223 | 215 | /** |
| 224 | 216 | * 补全消息已读态和回执已读人数 |
| 225 | 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 | 221 | @SuppressWarnings({"StatementWithEmptyBody", "DataFlowIssue"}) |
| 230 | 222 | private void appendMessageStatusAndReceipt(Long userId, List<ImGroupMessageDO> messages) { |
| ... | ... | @@ -254,9 +246,10 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { |
| 254 | 246 | message.setStatus(ImMessageStatusEnum.UNREAD.getStatus()); |
| 255 | 247 | } |
| 256 | 248 | |
| 257 | - // 回执消息的已读人数(readCount):仅补齐本人发送的,其他消息不处理(回执消息才关心已读人数,且只对发送者可见) | |
| 249 | + // 回执已读人数(readCount):仅补本人发送、且需要回执(非 NO_RECEIPT)的消息 | |
| 250 | + // 读位置(status)所有消息都补;readCount 只服务「需要回执」的消息,不把已读位置和消息回执混为一谈 | |
| 258 | 251 | if (ObjUtil.notEqual(message.getSenderId(), userId) |
| 259 | - || ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus().equals(message.getReceiptStatus())) { | |
| 252 | + || ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus().equals(message.getReceiptStatus())) { | |
| 260 | 253 | continue; |
| 261 | 254 | } |
| 262 | 255 | Map<Long, Long> positions = readPositionsByGroup.computeIfAbsent(groupId, |
| ... | ... | @@ -286,7 +279,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { |
| 286 | 279 | ImGroupMessageDO message = groupMessageMapper.selectById(messageId); |
| 287 | 280 | if (message == null |
| 288 | 281 | || ObjUtil.notEqual(message.getGroupId(), groupId) |
| 289 | - || !isMessageVisible(message, userId)) { | |
| 282 | + || !isMessageReceived(message, userId)) { | |
| 290 | 283 | throw exception(MESSAGE_NOT_IN_GROUP); |
| 291 | 284 | } |
| 292 | 285 | |
| ... | ... | @@ -316,7 +309,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { |
| 316 | 309 | if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId)) { |
| 317 | 310 | return Collections.emptyList(); |
| 318 | 311 | } |
| 319 | - if (!isMessageVisible(message, userId)) { | |
| 312 | + if (!isMessageReceived(message, userId)) { | |
| 320 | 313 | return Collections.emptyList(); |
| 321 | 314 | } |
| 322 | 315 | // 1.3 仅消息发送方关心已读人数;非发送方查询直接返回空 |
| ... | ... | @@ -436,7 +429,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { |
| 436 | 429 | throw exception(MESSAGE_QUOTE_INVALID); |
| 437 | 430 | } |
| 438 | 431 | // 校验对发送人可见(按快照) |
| 439 | - if (!isMessageVisible(original, senderMember.getUserId())) { | |
| 432 | + if (!isMessageReceived(original, senderMember.getUserId())) { | |
| 440 | 433 | throw exception(MESSAGE_QUOTE_INVALID); |
| 441 | 434 | } |
| 442 | 435 | // 防泄漏:新消息可见集合必须是被引用消息可见集合的子集,否则禁止引用; |
| ... | ... | @@ -464,24 +457,22 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { |
| 464 | 457 | return message; |
| 465 | 458 | } |
| 466 | 459 | |
| 467 | - // TODO @AI:方法名,是不是可以优化下,对齐 ReceiverUserIds; | |
| 468 | 460 | /** |
| 469 | - * 判断一条群消息对某个用户是否可见 | |
| 461 | + * 判断一条群消息是否被某个用户接收到 | |
| 470 | 462 | * <p> |
| 471 | - * 可见性以发送当时固化的 receiver_user_ids 快照为准;发送者也在快照内。 | |
| 463 | + * 接收范围以发送当时固化的 receiver_user_ids 快照为准;发送者也在快照内。 | |
| 472 | 464 | * |
| 473 | 465 | * @param message 消息 |
| 474 | 466 | * @param userId 当前用户编号 |
| 475 | - * @return 是否可见 | |
| 467 | + * @return 是否接收到 | |
| 476 | 468 | */ |
| 477 | 469 | @SuppressWarnings("BooleanMethodIsAlwaysInverted") |
| 478 | - private boolean isMessageVisible(ImGroupMessageDO message, Long userId) { | |
| 470 | + private boolean isMessageReceived(ImGroupMessageDO message, Long userId) { | |
| 479 | 471 | return CollUtil.contains(message.getReceiverUserIds(), userId); |
| 480 | 472 | } |
| 481 | 473 | |
| 482 | - // TODO @AI: ∩ 是啥意思,有点奇怪。 | |
| 483 | 474 | /** |
| 484 | - * 计算一条群消息的可见成员集合(快照 ∩ 传入成员,含发送者) | |
| 475 | + * 计算一条群消息的可见成员集合(快照与传入成员的交集,含发送者) | |
| 485 | 476 | */ |
| 486 | 477 | private Set<Long> getReceiverUserIds(ImGroupMessageDO message, List<ImGroupMemberDO> members) { |
| 487 | 478 | if (CollUtil.isEmpty(message.getReceiverUserIds())) { |
| ... | ... | @@ -497,20 +488,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { |
| 497 | 488 | * 仅适用于「新消息」发送场景:无定向接收列表则全员可见,否则取定向用户与当前成员的交集; |
| 498 | 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 | 497 | if (senderId != null) { |
| 516 | 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 | 1100 | |
| 1101 | 1101 | @Test |
| 1102 | 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 | 1105 | List<ImGroupDO> result = groupService.getMyGroupList(1L); |
| 1108 | 1106 | assertTrue(result.isEmpty()); |
| ... | ... | @@ -1111,19 +1109,15 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { |
| 1111 | 1109 | |
| 1112 | 1110 | @Test |
| 1113 | 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 | 1114 | ImGroupMemberDO.builder().groupId(10L).userId(1L) |
| 1117 | 1115 | .status(CommonStatusEnum.ENABLE.getStatus()).build(), |
| 1118 | 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 | 1121 | List<ImGroupDO> groups = List.of( |
| 1128 | 1122 | ImGroupDO.builder().id(10L).status(CommonStatusEnum.ENABLE.getStatus()).build(), |
| 1129 | 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 | 122 | } |
| 123 | 123 | |
| 124 | 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 | 161 | public void testSendMessage_clientMessageIdIdempotent() { |
| 126 | 162 | // 准备 |
| 127 | 163 | ImGroupMessageSendReqVO reqVO = buildSendReqVO(); |
| ... | ... | @@ -224,9 +260,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 224 | 260 | .groupId(10L).userId(1L) |
| 225 | 261 | .status(CommonStatusEnum.ENABLE.getStatus()) |
| 226 | 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 | 265 | ImGroupMessageDO low = ImGroupMessageDO.builder() |
| 232 | 266 | .id(5L).groupId(10L).senderId(2L).receiverUserIds(List.of(1L)) |
| ... | ... | @@ -259,9 +293,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 259 | 293 | .groupId(10L).userId(1L) |
| 260 | 294 | .status(CommonStatusEnum.ENABLE.getStatus()) |
| 261 | 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 | 298 | ImGroupMessageDO receiptMsg = ImGroupMessageDO.builder() |
| 267 | 299 | .id(100L).groupId(10L).senderId(1L) |
| ... | ... | @@ -300,6 +332,37 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 300 | 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 | 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 | 320 | } |
| 321 | 321 | |
| 322 | 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 | 329 | privateMessageService.readPrivateMessages(1L, 2L, 5L); |
| 332 | 330 | |
| 333 | - // 断言:不推送 READ / RECEIPT | |
| 331 | + // 断言:读位置没前进,不推送 READ / RECEIPT | |
| 334 | 332 | verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); |
| 335 | 333 | } |
| 336 | 334 | ... | ... |