Commit 26413e09f3fff91d320fe8365e04f02a367a1818
1 parent
37cf16c1
✨ feat(message): 评审群聊消息的实现
Showing
15 changed files
with
581 additions
and
533 deletions
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImGroupMessageController.java
| 1 | 1 | package cn.iocoder.yudao.module.im.controller.admin.message; |
| 2 | 2 | |
| 3 | 3 | import cn.iocoder.yudao.framework.common.pojo.CommonResult; |
| 4 | +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; | |
| 5 | +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageListReqVO; | |
| 4 | 6 | import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageRespVO; |
| 5 | 7 | import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; |
| 6 | 8 | import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; |
| ... | ... | @@ -14,7 +16,6 @@ import org.springframework.validation.annotation.Validated; |
| 14 | 16 | import org.springframework.web.bind.annotation.*; |
| 15 | 17 | |
| 16 | 18 | import java.util.List; |
| 17 | -import java.util.stream.Collectors; | |
| 18 | 19 | |
| 19 | 20 | import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; |
| 20 | 21 | import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; |
| ... | ... | @@ -29,73 +30,55 @@ import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUti |
| 29 | 30 | public class ImGroupMessageController { |
| 30 | 31 | |
| 31 | 32 | @Resource |
| 32 | - private ImGroupMessageService imGroupMessageService; | |
| 33 | + private ImGroupMessageService groupMessageService; | |
| 33 | 34 | |
| 34 | 35 | @PostMapping("/send") |
| 35 | 36 | @Operation(summary = "发送群聊消息") |
| 36 | 37 | public CommonResult<ImGroupMessageRespVO> sendGroupMessage(@Valid @RequestBody ImGroupMessageSendReqVO reqVO) { |
| 37 | - ImGroupMessageDO message = imGroupMessageService.sendGroupMessage(getLoginUserId(), reqVO); | |
| 38 | - return success(toRespVO(message)); | |
| 38 | + ImGroupMessageDO message = groupMessageService.sendGroupMessage(getLoginUserId(), reqVO); | |
| 39 | + return success(BeanUtils.toBean(message, ImGroupMessageRespVO.class)); | |
| 39 | 40 | } |
| 40 | 41 | |
| 41 | 42 | @GetMapping("/pull") |
| 42 | 43 | @Operation(summary = "拉取群聊消息(增量)") |
| 43 | 44 | @Parameter(name = "minId", description = "最小消息 id", required = true, example = "0") |
| 44 | 45 | @Parameter(name = "size", description = "拉取数量", required = true, example = "100") |
| 45 | - public CommonResult<List<ImGroupMessageRespVO>> pullGroupMessageList( | |
| 46 | - @RequestParam("minId") Long minId, | |
| 47 | - @RequestParam("size") Integer size) { | |
| 48 | - List<ImGroupMessageDO> messages = imGroupMessageService.pullGroupMessageList(getLoginUserId(), minId, size); | |
| 49 | - return success(messages.stream().map(this::toRespVO).collect(Collectors.toList())); | |
| 46 | + public CommonResult<List<ImGroupMessageRespVO>> pullGroupMessageList(@RequestParam("minId") Long minId, | |
| 47 | + @RequestParam("size") Integer size) { | |
| 48 | + List<ImGroupMessageDO> messages = groupMessageService.pullGroupMessageList(getLoginUserId(), minId, size); | |
| 49 | + return success(BeanUtils.toBean(messages, ImGroupMessageRespVO.class)); | |
| 50 | 50 | } |
| 51 | 51 | |
| 52 | 52 | @PutMapping("/read") |
| 53 | 53 | @Operation(summary = "标记群聊消息已读") |
| 54 | 54 | @Parameter(name = "groupId", description = "群编号", required = true, example = "1") |
| 55 | 55 | public CommonResult<Boolean> readGroupMessages(@RequestParam("groupId") Long groupId) { |
| 56 | - imGroupMessageService.readGroupMessages(getLoginUserId(), groupId); | |
| 56 | + groupMessageService.readGroupMessages(getLoginUserId(), groupId); | |
| 57 | 57 | return success(true); |
| 58 | 58 | } |
| 59 | 59 | |
| 60 | 60 | @DeleteMapping("/recall") |
| 61 | 61 | @Operation(summary = "撤回群聊消息") |
| 62 | 62 | @Parameter(name = "id", description = "消息编号", required = true, example = "1") |
| 63 | - public CommonResult<Boolean> recallGroupMessage(@RequestParam("id") Long id) { | |
| 64 | - imGroupMessageService.recallGroupMessage(getLoginUserId(), id); | |
| 65 | - return success(true); | |
| 63 | + public CommonResult<ImGroupMessageRespVO> recallGroupMessage(@RequestParam("id") Long id) { | |
| 64 | + ImGroupMessageDO message = groupMessageService.recallGroupMessage(getLoginUserId(), id); | |
| 65 | + return success(BeanUtils.toBean(message, ImGroupMessageRespVO.class)); | |
| 66 | 66 | } |
| 67 | 67 | |
| 68 | - // DONE @AI:这个接口,确认需要!用于群回执人数展示 | |
| 69 | 68 | @GetMapping("/read-users") |
| 70 | 69 | @Operation(summary = "获取群消息已读用户列表") |
| 71 | 70 | @Parameter(name = "groupId", description = "群编号", required = true, example = "1") |
| 72 | 71 | @Parameter(name = "messageId", description = "消息编号", required = true, example = "1") |
| 73 | - public CommonResult<List<String>> getGroupReadUsers( | |
| 74 | - @RequestParam("groupId") Long groupId, | |
| 75 | - @RequestParam("messageId") Long messageId) { | |
| 76 | - List<Long> readUserIds = imGroupMessageService.getGroupReadUsers(getLoginUserId(), groupId, messageId); | |
| 77 | - // Long -> String 转换 | |
| 78 | - return success(readUserIds.stream().map(String::valueOf).collect(Collectors.toList())); | |
| 72 | + public CommonResult<List<Long>> getGroupReadUsers(@RequestParam("groupId") Long groupId, | |
| 73 | + @RequestParam("messageId") Long messageId) { | |
| 74 | + return success(groupMessageService.getGroupReadUsers(getLoginUserId(), groupId, messageId)); | |
| 79 | 75 | } |
| 80 | 76 | |
| 81 | - /** | |
| 82 | - * DO 转换为 RespVO(Long id 统一转 String) | |
| 83 | - */ | |
| 84 | - // DONE @AI:BeanUtils?结论:因 id/senderId/groupId 需 Long→String 手动转换,不适合 BeanUtils | |
| 85 | - private ImGroupMessageRespVO toRespVO(ImGroupMessageDO message) { | |
| 86 | - ImGroupMessageRespVO vo = new ImGroupMessageRespVO(); | |
| 87 | - vo.setId(message.getId().toString()); | |
| 88 | - vo.setClientMessageId(message.getClientMessageId()); | |
| 89 | - vo.setSenderId(message.getSenderId().toString()); | |
| 90 | - vo.setGroupId(message.getGroupId().toString()); | |
| 91 | - vo.setType(message.getType()); | |
| 92 | - vo.setContent(message.getContent()); | |
| 93 | - vo.setStatus(message.getStatus()); | |
| 94 | - vo.setSendTime(message.getSendTime()); | |
| 95 | - vo.setAtUserIds(message.getAtUserIds()); | |
| 96 | - vo.setReceiverUserIds(message.getReceiverUserIds()); | |
| 97 | - vo.setReceiptStatus(message.getReceiptStatus()); | |
| 98 | - return vo; | |
| 77 | + @GetMapping("/list") | |
| 78 | + @Operation(summary = "查询群聊历史消息") | |
| 79 | + public CommonResult<List<ImGroupMessageRespVO>> getGroupMessageList(@Valid ImGroupMessageListReqVO reqVO) { | |
| 80 | + List<ImGroupMessageDO> messages = groupMessageService.getGroupMessageList(getLoginUserId(), reqVO); | |
| 81 | + return success(BeanUtils.toBean(messages, ImGroupMessageRespVO.class)); | |
| 99 | 82 | } |
| 100 | 83 | |
| 101 | 84 | } | ... | ... |
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageListReqVO.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.im.controller.admin.message.vo.group; | |
| 2 | + | |
| 3 | +import io.swagger.v3.oas.annotations.media.Schema; | |
| 4 | +import jakarta.validation.constraints.Max; | |
| 5 | +import jakarta.validation.constraints.Min; | |
| 6 | +import jakarta.validation.constraints.NotNull; | |
| 7 | +import lombok.Data; | |
| 8 | + | |
| 9 | +@Schema(description = "管理后台 - 群聊历史消息列表 Request VO") | |
| 10 | +@Data | |
| 11 | +public class ImGroupMessageListReqVO { | |
| 12 | + | |
| 13 | + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") | |
| 14 | + @NotNull(message = "群编号不能为空") | |
| 15 | + private Long groupId; | |
| 16 | + | |
| 17 | + @Schema(description = "起始消息编号,从该 id 往前拉取(不含)。为空则从最新消息开始", example = "1024") | |
| 18 | + private Long maxId; | |
| 19 | + | |
| 20 | + @Schema(description = "拉取数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "20") | |
| 21 | + @NotNull(message = "拉取数量不能为空") | |
| 22 | + @Min(value = 1, message = "拉取数量最小值为 1") | |
| 23 | + @Max(value = 200, message = "拉取数量最大值为 200") | |
| 24 | + private Integer limit; | |
| 25 | + | |
| 26 | +} | ... | ... |
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/group/ImGroupMessageRespVO.java
| ... | ... | @@ -14,16 +14,16 @@ import java.util.List; |
| 14 | 14 | public class ImGroupMessageRespVO { |
| 15 | 15 | |
| 16 | 16 | @Schema(description = "消息编号", example = "1") |
| 17 | - private String id; | |
| 17 | + private Long id; | |
| 18 | 18 | |
| 19 | 19 | @Schema(description = "客户端消息编号", example = "uuid-xxx") |
| 20 | 20 | private String clientMessageId; |
| 21 | 21 | |
| 22 | 22 | @Schema(description = "发送人编号", example = "1") |
| 23 | - private String senderId; | |
| 23 | + private Long senderId; | |
| 24 | 24 | |
| 25 | 25 | @Schema(description = "群编号", example = "1") |
| 26 | - private String groupId; | |
| 26 | + private Long groupId; | |
| 27 | 27 | |
| 28 | 28 | @Schema(description = "消息类型", example = "0") |
| 29 | 29 | private Integer type; |
| ... | ... | @@ -46,7 +46,4 @@ public class ImGroupMessageRespVO { |
| 46 | 46 | @Schema(description = "回执状态", example = "0") |
| 47 | 47 | private Integer receiptStatus; |
| 48 | 48 | |
| 49 | - @Schema(description = "已读人数(群回执使用)", example = "5") | |
| 50 | - private Integer readCount; | |
| 51 | - | |
| 52 | 49 | } | ... | ... |
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java
| ... | ... | @@ -4,6 +4,8 @@ import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; |
| 4 | 4 | import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; |
| 5 | 5 | import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; |
| 6 | 6 | import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; |
| 7 | +import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; | |
| 8 | +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; | |
| 7 | 9 | import org.apache.ibatis.annotations.Mapper; |
| 8 | 10 | |
| 9 | 11 | import java.util.List; |
| ... | ... | @@ -16,6 +18,14 @@ import java.util.List; |
| 16 | 18 | @Mapper |
| 17 | 19 | public interface ImGroupMessageMapper extends BaseMapperX<ImGroupMessageDO> { |
| 18 | 20 | |
| 21 | + /** | |
| 22 | + * 根据 minId 增量拉取群聊消息 | |
| 23 | + * | |
| 24 | + * @param groupIds 用户所在群编号列表 | |
| 25 | + * @param minId 最小消息 id(不含) | |
| 26 | + * @param size 拉取数量 | |
| 27 | + * @return 消息列表 | |
| 28 | + */ | |
| 19 | 29 | default List<ImGroupMessageDO> selectListByMinId(List<Long> groupIds, Long minId, Integer size) { |
| 20 | 30 | QueryWrapperX<ImGroupMessageDO> wrapper = new QueryWrapperX<>(); |
| 21 | 31 | wrapper.in("group_id", groupIds) |
| ... | ... | @@ -25,33 +35,64 @@ public interface ImGroupMessageMapper extends BaseMapperX<ImGroupMessageDO> { |
| 25 | 35 | return selectList(wrapper); |
| 26 | 36 | } |
| 27 | 37 | |
| 38 | + /** | |
| 39 | + * 查询群聊历史消息(游标拉取) | |
| 40 | + * | |
| 41 | + * @param groupId 群编号 | |
| 42 | + * @param maxId 起始消息 id(不含),为空则从最新开始 | |
| 43 | + * @param limit 拉取数量 | |
| 44 | + * @return 消息列表(按 id 倒序) | |
| 45 | + */ | |
| 46 | + default List<ImGroupMessageDO> selectHistoryList(Long groupId, Long maxId, Integer limit) { | |
| 47 | + QueryWrapperX<ImGroupMessageDO> wrapper = new QueryWrapperX<>(); | |
| 48 | + wrapper.eq("group_id", groupId) | |
| 49 | + .ne("status", ImMessageStatusEnum.RECALL.getStatus()) | |
| 50 | + .lt(maxId != null, "id", maxId) | |
| 51 | + .orderByDesc("id"); | |
| 52 | + wrapper.limitN(limit); | |
| 53 | + return selectList(wrapper); | |
| 54 | + } | |
| 55 | + | |
| 28 | 56 | default ImGroupMessageDO selectBySenderIdAndClientMessageId(Long senderId, String clientMessageId) { |
| 29 | 57 | return selectOne(new LambdaQueryWrapperX<ImGroupMessageDO>() |
| 30 | 58 | .eq(ImGroupMessageDO::getSenderId, senderId) |
| 31 | 59 | .eq(ImGroupMessageDO::getClientMessageId, clientMessageId)); |
| 32 | 60 | } |
| 33 | 61 | |
| 34 | - // TODO @芋艿:暂时没 review | |
| 35 | 62 | /** |
| 36 | 63 | * 获取群内最新消息 id |
| 64 | + * | |
| 65 | + * @param groupId 群编号 | |
| 66 | + * @return 最新消息 id,无消息时返回 null | |
| 37 | 67 | */ |
| 38 | 68 | default Long selectMaxIdByGroupId(Long groupId) { |
| 39 | 69 | QueryWrapperX<ImGroupMessageDO> wrapper = new QueryWrapperX<>(); |
| 40 | 70 | wrapper.eq("group_id", groupId) |
| 41 | - .orderByDesc("id"); | |
| 71 | + .orderByDesc("id") | |
| 72 | + .select("id"); | |
| 42 | 73 | wrapper.limitN(1); |
| 43 | 74 | ImGroupMessageDO msg = selectOne(wrapper); |
| 44 | 75 | return msg == null ? null : msg.getId(); |
| 45 | 76 | } |
| 46 | 77 | |
| 47 | - // TODO @芋艿:暂时没 review | |
| 48 | 78 | /** |
| 49 | - * 获取群内需要回执的消息(状态为 PENDING) | |
| 79 | + * 查询群内指定范围内待回执的消息 | |
| 80 | + * <p> | |
| 81 | + * 仅在用户"已读位置前进"时调用,避免全量扫描: | |
| 82 | + * 只有位于 (minId, maxId] 范围内、且仍处于 PENDING 的回执消息可能因本次已读而状态变化。 | |
| 83 | + * | |
| 84 | + * @param groupId 群编号 | |
| 85 | + * @param minId 起始消息 id(不含,上一次已读位置) | |
| 86 | + * @param maxId 结束消息 id(含,本次已读位置) | |
| 87 | + * @return 待回执消息列表 | |
| 50 | 88 | */ |
| 51 | - default List<ImGroupMessageDO> selectPendingReceiptMessages(Long groupId) { | |
| 89 | + default List<ImGroupMessageDO> selectPendingReceiptMessagesInRange(Long groupId, Long minId, Long maxId) { | |
| 52 | 90 | return selectList(new LambdaQueryWrapperX<ImGroupMessageDO>() |
| 53 | 91 | .eq(ImGroupMessageDO::getGroupId, groupId) |
| 54 | - .eq(ImGroupMessageDO::getReceiptStatus, 1)); // PENDING | |
| 92 | + .eq(ImGroupMessageDO::getReceiptStatus, ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) | |
| 93 | + .gt(ImGroupMessageDO::getId, minId) | |
| 94 | + .le(ImGroupMessageDO::getId, maxId) | |
| 95 | + .ne(ImGroupMessageDO::getStatus, ImMessageStatusEnum.RECALL.getStatus())); | |
| 55 | 96 | } |
| 56 | 97 | |
| 57 | 98 | } | ... | ... |
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/RedisKeyConstants.java
| ... | ... | @@ -8,20 +8,7 @@ package cn.iocoder.yudao.module.im.dal.redis; |
| 8 | 8 | */ |
| 9 | 9 | public interface RedisKeyConstants { |
| 10 | 10 | |
| 11 | - /** | |
| 12 | - * 收件箱序号生成器 | |
| 13 | - * KEY 格式: im:inbox:sequence:{userId} | |
| 14 | - * VALUE 数据类型: String | |
| 15 | - */ | |
| 16 | - String INBOX_SEQUENCE = "im_inbox_sequence:%s"; | |
| 17 | - | |
| 18 | - /** | |
| 19 | - * 收件箱的分布式锁 | |
| 20 | - * KEY 格式: im:inbox:lock:{userId} | |
| 21 | - * VALUE 数据类型: String | |
| 22 | - */ | |
| 23 | - String INBOX_LOCK = "im_inbox_lock:%s"; | |
| 24 | - | |
| 11 | + // TODO @AI:这个 key 可能要改下; | |
| 25 | 12 | /** |
| 26 | 13 | * 群已读位置 |
| 27 | 14 | * KEY 格式: im:group:read:{groupId} | ... | ... |
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/group/GroupReadPositionRedisDAO.java renamed to yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/dal/redis/message/GroupReadPositionRedisDAO.java
| 1 | -package cn.iocoder.yudao.module.im.dal.redis.group; | |
| 1 | +package cn.iocoder.yudao.module.im.dal.redis.message; | |
| 2 | 2 | |
| 3 | 3 | import jakarta.annotation.Resource; |
| 4 | 4 | import org.springframework.data.redis.core.StringRedisTemplate; |
| ... | ... | @@ -8,10 +8,11 @@ import java.util.Map; |
| 8 | 8 | |
| 9 | 9 | import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP_READ_POSITION; |
| 10 | 10 | |
| 11 | +// TODO @AI:需要改成,GroupMessageReadRedisDAO | |
| 11 | 12 | /** |
| 12 | 13 | * 群已读位置 Redis DAO |
| 13 | 14 | * <p> |
| 14 | - * 采用 Redis Hash 存储:key=im:group:read:{groupId}, field=userId, value=maxReadMessageId | |
| 15 | + * 采用 Redis Hash 存储:key=im:group:read:{groupId}, field=userId, value=maxReadMessageId TODO @AI:这里不用注释; | |
| 15 | 16 | * |
| 16 | 17 | * @author 芋道源码 |
| 17 | 18 | */ |
| ... | ... | @@ -21,6 +22,7 @@ public class GroupReadPositionRedisDAO { |
| 21 | 22 | @Resource |
| 22 | 23 | private StringRedisTemplate stringRedisTemplate; |
| 23 | 24 | |
| 25 | + // TODO @AI:updateReadMaxMessageId; | |
| 24 | 26 | /** |
| 25 | 27 | * 更新用户在某群的已读位置 |
| 26 | 28 | * |
| ... | ... | @@ -29,14 +31,18 @@ public class GroupReadPositionRedisDAO { |
| 29 | 31 | * @param maxMessageId 最大已读消息编号 |
| 30 | 32 | */ |
| 31 | 33 | public void updateReadPosition(Long groupId, Long userId, Long maxMessageId) { |
| 34 | + // TODO @AI:这里搞个 formatKey 方法,可以复用; | |
| 32 | 35 | String key = String.format(GROUP_READ_POSITION, groupId); |
| 33 | 36 | // 只在新值大于旧值时更新 |
| 34 | 37 | Object currentVal = stringRedisTemplate.opsForHash().get(key, userId.toString()); |
| 35 | 38 | if (currentVal == null || Long.parseLong(currentVal.toString()) < maxMessageId) { |
| 39 | + // TODO @AI:可以 put 的时候,就是 Long,Long 么? | |
| 36 | 40 | stringRedisTemplate.opsForHash().put(key, userId.toString(), maxMessageId.toString()); |
| 37 | 41 | } |
| 38 | 42 | } |
| 39 | 43 | |
| 44 | + // TODO @AI:不存在,则返回 null 更合理; | |
| 45 | + // TODO @AI:getReadMaxMessageId; | |
| 40 | 46 | /** |
| 41 | 47 | * 获取用户在某群的已读位置 |
| 42 | 48 | */ |
| ... | ... | @@ -46,28 +52,14 @@ public class GroupReadPositionRedisDAO { |
| 46 | 52 | return val == null ? 0L : Long.parseLong(val.toString()); |
| 47 | 53 | } |
| 48 | 54 | |
| 55 | + // TODO @AI:参考这个风格,优化下; | |
| 49 | 56 | /** |
| 50 | 57 | * 获取某群所有用户的已读位置 |
| 51 | 58 | */ |
| 52 | 59 | public Map<Object, Object> getAllReadPositions(Long groupId) { |
| 60 | + // TODO @AI:可以返回的时候,就是 Long,Long 么? | |
| 53 | 61 | String key = String.format(GROUP_READ_POSITION, groupId); |
| 54 | 62 | return stringRedisTemplate.opsForHash().entries(key); |
| 55 | 63 | } |
| 56 | 64 | |
| 57 | - /** | |
| 58 | - * 删除某群所有用户的已读位置(群解散时调用) | |
| 59 | - */ | |
| 60 | - public void deleteGroupPositions(Long groupId) { | |
| 61 | - String key = String.format(GROUP_READ_POSITION, groupId); | |
| 62 | - stringRedisTemplate.delete(key); | |
| 63 | - } | |
| 64 | - | |
| 65 | - /** | |
| 66 | - * 删除用户在某群的已读位置(退群时调用) | |
| 67 | - */ | |
| 68 | - public void deleteUserPosition(Long groupId, Long userId) { | |
| 69 | - String key = String.format(GROUP_READ_POSITION, groupId); | |
| 70 | - stringRedisTemplate.opsForHash().delete(key, userId.toString()); | |
| 71 | - } | |
| 72 | - | |
| 73 | 65 | } | ... | ... |
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java
| 1 | 1 | package cn.iocoder.yudao.module.im.service.message; |
| 2 | 2 | |
| 3 | +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageListReqVO; | |
| 3 | 4 | import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; |
| 4 | 5 | import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; |
| 5 | 6 | |
| ... | ... | @@ -44,8 +45,9 @@ public interface ImGroupMessageService { |
| 44 | 45 | * |
| 45 | 46 | * @param userId 当前用户编号 |
| 46 | 47 | * @param messageId 消息编号 |
| 48 | + * @return 撤回后的提示消息 | |
| 47 | 49 | */ |
| 48 | - void recallGroupMessage(Long userId, Long messageId); | |
| 50 | + ImGroupMessageDO recallGroupMessage(Long userId, Long messageId); | |
| 49 | 51 | |
| 50 | 52 | /** |
| 51 | 53 | * 获取群消息的已读用户列表 |
| ... | ... | @@ -57,4 +59,13 @@ public interface ImGroupMessageService { |
| 57 | 59 | */ |
| 58 | 60 | List<Long> getGroupReadUsers(Long userId, Long groupId, Long messageId); |
| 59 | 61 | |
| 62 | + /** | |
| 63 | + * 查询群聊历史消息(游标拉取) | |
| 64 | + * | |
| 65 | + * @param userId 当前用户编号 | |
| 66 | + * @param reqVO 拉取请求 | |
| 67 | + * @return 消息列表(按 id 倒序) | |
| 68 | + */ | |
| 69 | + List<ImGroupMessageDO> getGroupMessageList(Long userId, ImGroupMessageListReqVO reqVO); | |
| 70 | + | |
| 60 | 71 | } | ... | ... |
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.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.util.IdUtil; | |
| 5 | +import cn.hutool.core.util.ObjUtil; | |
| 6 | +import cn.hutool.extra.spring.SpringUtil; | |
| 7 | +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; | |
| 4 | 8 | import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; |
| 9 | +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | |
| 10 | +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; | |
| 11 | +import cn.iocoder.yudao.framework.websocket.core.sender.WebSocketMessageSender; | |
| 12 | +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageListReqVO; | |
| 5 | 13 | import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageSendReqVO; |
| 6 | -import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; | |
| 7 | 14 | import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; |
| 8 | 15 | import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; |
| 16 | +import cn.iocoder.yudao.module.im.dal.dataobject.message.content.RecallMessage; | |
| 9 | 17 | import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper; |
| 10 | -import cn.iocoder.yudao.module.im.dal.redis.group.GroupReadPositionRedisDAO; | |
| 11 | -import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; | |
| 18 | +import cn.iocoder.yudao.module.im.dal.redis.message.GroupReadPositionRedisDAO; | |
| 12 | 19 | import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; |
| 13 | 20 | import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; |
| 14 | -import cn.iocoder.yudao.module.im.enums.message.ImMessageSceneEnum; | |
| 15 | 21 | import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; |
| 16 | -import cn.iocoder.yudao.module.im.websocket.ImGroupMessageSendMessage; | |
| 17 | -import cn.iocoder.yudao.module.im.websocket.ImMessageReadMessage; | |
| 18 | -import cn.iocoder.yudao.module.im.websocket.ImMessageReceiptMessage; | |
| 19 | -import cn.iocoder.yudao.module.im.websocket.ImMessageRecallMessage; | |
| 20 | -import cn.iocoder.yudao.module.im.service.group.ImGroupService; | |
| 21 | 22 | import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; |
| 23 | +import cn.iocoder.yudao.module.im.service.group.ImGroupService; | |
| 22 | 24 | import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; |
| 23 | -import cn.iocoder.yudao.framework.websocket.core.sender.WebSocketMessageSender; | |
| 25 | +import cn.iocoder.yudao.module.im.websocket.ImGroupMessageDTO; | |
| 24 | 26 | import jakarta.annotation.Resource; |
| 25 | 27 | import lombok.extern.slf4j.Slf4j; |
| 28 | +import org.springframework.scheduling.annotation.Async; | |
| 26 | 29 | import org.springframework.stereotype.Service; |
| 30 | +import org.springframework.transaction.annotation.Transactional; | |
| 27 | 31 | import org.springframework.validation.annotation.Validated; |
| 28 | 32 | |
| 29 | 33 | import java.time.LocalDateTime; |
| 30 | 34 | import java.util.*; |
| 31 | -import java.util.stream.Collectors; | |
| 32 | 35 | |
| 33 | 36 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 37 | +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; | |
| 34 | 38 | import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; |
| 35 | -import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.*; | |
| 39 | +import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.MESSAGE_MAX_PULL_SIZE; | |
| 40 | +import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.MESSAGE_RECALL_TIMEOUT_MINUTES; | |
| 36 | 41 | |
| 37 | 42 | /** |
| 38 | 43 | * IM 群聊消息 Service 实现类 |
| ... | ... | @@ -45,60 +50,51 @@ import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.*; |
| 45 | 50 | public class ImGroupMessageServiceImpl implements ImGroupMessageService { |
| 46 | 51 | |
| 47 | 52 | @Resource |
| 48 | - private ImGroupMessageMapper imGroupMessageMapper; | |
| 53 | + private ImGroupMessageMapper groupMessageMapper; | |
| 54 | + // TODO @AI:变量名,可能要改下; | |
| 49 | 55 | @Resource |
| 50 | - private ImGroupService imGroupService; | |
| 56 | + private GroupReadPositionRedisDAO groupReadPositionRedisDAO; | |
| 57 | + | |
| 51 | 58 | @Resource |
| 52 | - private ImGroupMemberService imGroupMemberService; | |
| 59 | + private ImGroupService groupService; | |
| 53 | 60 | @Resource |
| 54 | - private ImSensitiveWordService imSensitiveWordService; | |
| 61 | + private ImGroupMemberService groupMemberService; | |
| 55 | 62 | @Resource |
| 56 | - private GroupReadPositionRedisDAO groupReadPositionRedisDAO; | |
| 63 | + private ImSensitiveWordService sensitiveWordService; | |
| 64 | + | |
| 57 | 65 | @Resource |
| 58 | 66 | private WebSocketMessageSender webSocketMessageSender; |
| 59 | 67 | |
| 60 | 68 | @Override |
| 61 | 69 | public ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendReqVO reqVO) { |
| 62 | - // 1. 幂等校验 | |
| 63 | - ImGroupMessageDO existing = imGroupMessageMapper.selectBySenderIdAndClientMessageId( | |
| 70 | + // 1.1 幂等校验:根据 senderId + clientMessageId 查重 | |
| 71 | + ImGroupMessageDO existing = groupMessageMapper.selectBySenderIdAndClientMessageId( | |
| 64 | 72 | senderId, reqVO.getClientMessageId()); |
| 65 | 73 | if (existing != null) { |
| 74 | + log.info("[sendGroupMessage][幂等命中 senderId({}) clientMessageId({}) 已存在消息({})]", | |
| 75 | + senderId, reqVO.getClientMessageId(), existing.getId()); | |
| 66 | 76 | return existing; |
| 67 | 77 | } |
| 68 | - | |
| 69 | - // 2. 校验群存在且未解散 | |
| 70 | - ImGroupDO group = imGroupService.validateGroupExists(reqVO.getGroupId()); | |
| 71 | - | |
| 72 | - // 3. 校验发送人在群中 | |
| 73 | - ImGroupMemberDO member = imGroupMemberService.getGroupMember(reqVO.getGroupId(), senderId); | |
| 74 | - if (member == null || CommonStatusEnum.DISABLE.getStatus().equals(member.getStatus())) { | |
| 75 | - throw exception(GROUP_MEMBER_NOT_IN_GROUP); | |
| 76 | - } | |
| 77 | - | |
| 78 | - // 4. 文本消息敏感词过滤 | |
| 78 | + // 1.2 校验群存在、发送人仍在群中 | |
| 79 | + groupService.validateGroupExists(reqVO.getGroupId()); | |
| 80 | + validateMemberInGroup(reqVO.getGroupId(), senderId); | |
| 81 | + // 1.3 文本消息敏感词过滤 | |
| 79 | 82 | if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) { |
| 80 | - imSensitiveWordService.validateText(reqVO.getContent()); | |
| 83 | + sensitiveWordService.validateText(reqVO.getContent()); | |
| 81 | 84 | } |
| 82 | 85 | |
| 83 | - // 5. 构建消息 | |
| 84 | - ImGroupMessageDO message = ImGroupMessageDO.builder() | |
| 85 | - .clientMessageId(reqVO.getClientMessageId()) | |
| 86 | - .senderId(senderId) | |
| 87 | - .groupId(reqVO.getGroupId()) | |
| 88 | - .type(reqVO.getType()) | |
| 89 | - .content(reqVO.getContent()) | |
| 90 | - .status(ImMessageStatusEnum.UNREAD.getStatus()) // 群聊正常状态 = 0 | |
| 91 | - .sendTime(LocalDateTime.now()) | |
| 92 | - .atUserIds(reqVO.getAtUserIds()) | |
| 93 | - .receiverUserIds(reqVO.getReceiverUserIds()) | |
| 94 | - .receiptStatus(Boolean.TRUE.equals(reqVO.getNeedReceipt()) | |
| 86 | + // 2. 构建并保存消息 | |
| 87 | + // TODO @AI:content 要 json 下; | |
| 88 | + ImGroupMessageDO message = BeanUtils.toBean(reqVO, ImGroupMessageDO.class, m -> m | |
| 89 | + .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()) | |
| 90 | + .setSendTime(LocalDateTime.now()) | |
| 91 | + .setReceiptStatus(Boolean.TRUE.equals(reqVO.getNeedReceipt()) | |
| 95 | 92 | ? ImGroupMessageReceiptStatusEnum.PENDING.getStatus() |
| 96 | - : ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()) | |
| 97 | - .build(); | |
| 98 | - imGroupMessageMapper.insert(message); | |
| 93 | + : ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus())); | |
| 94 | + groupMessageMapper.insert(message); | |
| 99 | 95 | |
| 100 | - // 6. WebSocket 推送给群成员 | |
| 101 | - sendGroupMessageEvent(message, reqVO.getGroupId()); | |
| 96 | + // 3. WebSocket 推送给群成员(异步) | |
| 97 | + getSelf().sendGroupMessageEvent(message); | |
| 102 | 98 | return message; |
| 103 | 99 | } |
| 104 | 100 | |
| ... | ... | @@ -107,272 +103,288 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { |
| 107 | 103 | if (size > MESSAGE_MAX_PULL_SIZE) { |
| 108 | 104 | throw exception(MESSAGE_PULL_SIZE_EXCEEDED); |
| 109 | 105 | } |
| 110 | - // 获取用户所在的所有群 | |
| 111 | - List<ImGroupMemberDO> members = imGroupMemberService.getGroupMembersByUserId(userId); | |
| 106 | + // 1. 获取用户所在的所有群 | |
| 107 | + List<ImGroupMemberDO> members = groupMemberService.getGroupMembersByUserId(userId); | |
| 112 | 108 | if (CollUtil.isEmpty(members)) { |
| 113 | 109 | return Collections.emptyList(); |
| 114 | 110 | } |
| 115 | - List<Long> groupIds = members.stream() | |
| 116 | - .map(ImGroupMemberDO::getGroupId) | |
| 117 | - .collect(Collectors.toList()); | |
| 118 | - | |
| 119 | - // 拉取消息 | |
| 120 | - List<ImGroupMessageDO> messages = imGroupMessageMapper.selectListByMinId(groupIds, minId, size); | |
| 121 | - | |
| 122 | - // 过滤:入群前消息不可见、定向接收过滤、退群后消息边界 | |
| 123 | - Map<Long, ImGroupMemberDO> memberMap = members.stream() | |
| 124 | - .collect(Collectors.toMap(ImGroupMemberDO::getGroupId, m -> m, (a, b) -> a)); | |
| 125 | - | |
| 126 | - return messages.stream().filter(msg -> { | |
| 127 | - ImGroupMemberDO m = memberMap.get(msg.getGroupId()); | |
| 128 | - if (m == null) { | |
| 129 | - return false; | |
| 130 | - } | |
| 131 | - // 入群前消息不可见 | |
| 132 | - if (m.getJoinTime() != null && msg.getSendTime().isBefore(m.getJoinTime())) { | |
| 133 | - return false; | |
| 134 | - } | |
| 135 | - // 退群后消息边界 | |
| 136 | - if (CommonStatusEnum.DISABLE.getStatus().equals(m.getStatus()) | |
| 137 | - && m.getQuitTime() != null && msg.getSendTime().isAfter(m.getQuitTime())) { | |
| 138 | - return false; | |
| 139 | - } | |
| 140 | - // 定向接收过滤 | |
| 141 | - if (CollUtil.isNotEmpty(msg.getReceiverUserIds())) { | |
| 142 | - if (!msg.getReceiverUserIds().contains(userId) && !msg.getSenderId().equals(userId)) { | |
| 143 | - return false; | |
| 144 | - } | |
| 145 | - } | |
| 146 | - return true; | |
| 147 | - }).collect(Collectors.toList()); | |
| 111 | + List<Long> groupIds = convertList(members, ImGroupMemberDO::getGroupId); | |
| 112 | + | |
| 113 | + // 2. 拉取消息 | |
| 114 | + List<ImGroupMessageDO> messages = groupMessageMapper.selectListByMinId(groupIds, minId, size); | |
| 115 | + | |
| 116 | + // 3. 过滤:入群前不可见、退群后不可见、定向接收过滤 | |
| 117 | + // TODO @AI:缺少各种逻辑; | |
| 118 | + Map<Long, ImGroupMemberDO> memberMap = CollUtil.toMap(members, new HashMap<>(), ImGroupMemberDO::getGroupId); | |
| 119 | + List<ImGroupMessageDO> result = messages.stream() | |
| 120 | + .filter(msg -> isMessageVisible(msg, memberMap.get(msg.getGroupId()), userId)) | |
| 121 | + .toList(); | |
| 122 | + log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]", | |
| 123 | + userId, minId, size, result.size()); | |
| 124 | + return result; | |
| 148 | 125 | } |
| 149 | 126 | |
| 127 | + // TODO @AI:增加 messageId 参数;允许空,则查询最大的一条; | |
| 150 | 128 | @Override |
| 151 | 129 | public void readGroupMessages(Long userId, Long groupId) { |
| 152 | - // 1. 校验用户在群中(权限校验) | |
| 153 | - ImGroupMemberDO member = imGroupMemberService.getGroupMember(groupId, userId); | |
| 154 | - if (member == null || CommonStatusEnum.DISABLE.getStatus().equals(member.getStatus())) { | |
| 155 | - throw exception(GROUP_MEMBER_NOT_IN_GROUP); | |
| 130 | + // 1.1 校验用户在群中(权限校验) | |
| 131 | + validateMemberInGroup(groupId, userId); | |
| 132 | + // 1.2 获取群内最新消息 id(倒序 LIMIT 1,避免全量扫描) | |
| 133 | + Long newMaxId = groupMessageMapper.selectMaxIdByGroupId(groupId); | |
| 134 | + if (newMaxId == null) { | |
| 135 | + return; | |
| 156 | 136 | } |
| 157 | - | |
| 158 | - // 2. 高效获取群内最新消息 id(倒序 LIMIT 1,非全量扫描) | |
| 159 | - Long maxMessageId = imGroupMessageMapper.selectMaxIdByGroupId(groupId); | |
| 160 | - if (maxMessageId == null) { | |
| 137 | + // 1.3 已读位置未前进,直接返回 | |
| 138 | + // TODO @AI:1.3 和 2 是不是可以合并?如果 update 失败,则直接回退;不然读取两次,貌似有 cas 问题; | |
| 139 | + Long prevMaxId = groupReadPositionRedisDAO.getReadPosition(groupId, userId); | |
| 140 | + if (newMaxId <= prevMaxId) { | |
| 161 | 141 | return; |
| 162 | 142 | } |
| 163 | 143 | |
| 164 | - // 3. 更新 Redis 群已读位置 | |
| 165 | - groupReadPositionRedisDAO.updateReadPosition(groupId, userId, maxMessageId); | |
| 144 | + // 2. 更新 Redis 群已读位置 | |
| 145 | + groupReadPositionRedisDAO.updateReadPosition(groupId, userId, newMaxId); | |
| 166 | 146 | |
| 167 | - // 4. 发送 READ 事件给自己的其他终端 | |
| 168 | - ImMessageReadMessage readMessage = new ImMessageReadMessage() | |
| 169 | - .setGroupId(groupId) | |
| 170 | - .setScene(ImMessageSceneEnum.GROUP.getScene()); | |
| 171 | - webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId, | |
| 172 | - ImMessageReadMessage.TYPE, readMessage); | |
| 173 | - | |
| 174 | - // 5. 刷新群回执 | |
| 175 | - refreshGroupReceipts(groupId); | |
| 147 | + // 3. 异步发送 READ 事件 + 刷新范围内的群回执 | |
| 148 | + getSelf().readGroupMessageEvent(userId, groupId, prevMaxId, newMaxId); | |
| 176 | 149 | } |
| 177 | 150 | |
| 178 | 151 | @Override |
| 179 | - public void recallGroupMessage(Long userId, Long messageId) { | |
| 180 | - // 1. 校验消息存在 | |
| 181 | - ImGroupMessageDO message = imGroupMessageMapper.selectById(messageId); | |
| 152 | + @Transactional(rollbackFor = Exception.class) | |
| 153 | + public ImGroupMessageDO recallGroupMessage(Long userId, Long messageId) { | |
| 154 | + // 1.1 校验消息存在 | |
| 155 | + // TODO @AI:抽成一个 validateXXXExists 方法; | |
| 156 | + ImGroupMessageDO message = groupMessageMapper.selectById(messageId); | |
| 182 | 157 | if (message == null) { |
| 183 | 158 | throw exception(MESSAGE_NOT_EXISTS); |
| 184 | 159 | } |
| 185 | - // 2. 只能撤回自己发送的消息 | |
| 186 | - if (!message.getSenderId().equals(userId)) { | |
| 160 | + // 1.2 只能撤回自己发送的消息 | |
| 161 | + if (ObjUtil.notEqual(message.getSenderId(), userId)) { | |
| 187 | 162 | throw exception(MESSAGE_RECALL_DENIED); |
| 188 | 163 | } |
| 189 | - // 3. 不能重复撤回 | |
| 164 | + // 1.3 不能重复撤回 | |
| 190 | 165 | if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { |
| 191 | 166 | throw exception(MESSAGE_ALREADY_RECALLED); |
| 192 | 167 | } |
| 193 | - // 4. 更新消息状态 | |
| 194 | - ImGroupMessageDO updateObj = new ImGroupMessageDO(); | |
| 195 | - updateObj.setId(messageId); | |
| 196 | - updateObj.setStatus(ImMessageStatusEnum.RECALL.getStatus()); | |
| 197 | - imGroupMessageMapper.updateById(updateObj); | |
| 198 | - | |
| 199 | - // 5. 发送 RECALL 事件给所有群成员 | |
| 200 | - List<ImGroupMemberDO> members = imGroupMemberService.selectByGroupId(message.getGroupId()); | |
| 201 | - ImMessageRecallMessage recallMessage = new ImMessageRecallMessage() | |
| 202 | - .setMessageId(messageId) | |
| 203 | - .setGroupId(message.getGroupId()) | |
| 204 | - .setSenderId(userId) | |
| 205 | - .setScene(ImMessageSceneEnum.GROUP.getScene()); | |
| 206 | - for (ImGroupMemberDO m : members) { | |
| 207 | - if (CommonStatusEnum.DISABLE.getStatus().equals(m.getStatus())) { | |
| 208 | - continue; | |
| 209 | - } | |
| 210 | - webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), m.getUserId(), | |
| 211 | - ImMessageRecallMessage.TYPE, recallMessage); | |
| 168 | + // 1.4 只允许撤回限定时间内的消息 | |
| 169 | + if (message.getSendTime().plusMinutes(MESSAGE_RECALL_TIMEOUT_MINUTES).isBefore(LocalDateTime.now())) { | |
| 170 | + throw exception(MESSAGE_RECALL_TIMEOUT, MESSAGE_RECALL_TIMEOUT_MINUTES); | |
| 212 | 171 | } |
| 172 | + // 1.5 校验撤回人仍在群中 | |
| 173 | + validateMemberInGroup(message.getGroupId(), userId); | |
| 174 | + | |
| 175 | + // 2. 更新原消息状态为撤回 | |
| 176 | + groupMessageMapper.updateById(new ImGroupMessageDO().setId(messageId) | |
| 177 | + .setStatus(ImMessageStatusEnum.RECALL.getStatus())); | |
| 178 | + | |
| 179 | + // 3. 插入一条 TIP_TEXT 消息作为撤回提示 | |
| 180 | + RecallMessage recallContent = new RecallMessage().setMessageId(messageId); | |
| 181 | + ImGroupMessageDO recallMessage = new ImGroupMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) | |
| 182 | + .setSenderId(userId).setGroupId(message.getGroupId()) | |
| 183 | + .setType(ImMessageTypeEnum.TIP_TEXT.getType()).setContent(JsonUtils.toJsonString(recallContent)) | |
| 184 | + .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) | |
| 185 | + .setReceiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()); | |
| 186 | + groupMessageMapper.insert(recallMessage); | |
| 187 | + | |
| 188 | + // 4. 异步推送撤回提示消息(前端据此更新原消息状态 + 插入撤回提示) | |
| 189 | + getSelf().sendGroupMessageEvent(recallMessage); | |
| 190 | + return recallMessage; | |
| 213 | 191 | } |
| 214 | 192 | |
| 215 | 193 | @Override |
| 216 | 194 | public List<Long> getGroupReadUsers(Long userId, Long groupId, Long messageId) { |
| 217 | - // 1. 校验用户在群中(权限校验) | |
| 218 | - ImGroupMemberDO currentMember = imGroupMemberService.getGroupMember(groupId, userId); | |
| 219 | - if (currentMember == null || CommonStatusEnum.DISABLE.getStatus().equals(currentMember.getStatus())) { | |
| 220 | - throw exception(GROUP_MEMBER_NOT_IN_GROUP); | |
| 221 | - } | |
| 222 | - | |
| 223 | - // 2. 获取消息 | |
| 224 | - ImGroupMessageDO message = imGroupMessageMapper.selectById(messageId); | |
| 195 | + // 1.1 校验用户在群中(权限校验) | |
| 196 | + validateMemberInGroup(groupId, userId); | |
| 197 | + // 1.2 获取消息 | |
| 198 | + ImGroupMessageDO message = groupMessageMapper.selectById(messageId); | |
| 225 | 199 | if (message == null) { |
| 226 | 200 | return Collections.emptyList(); |
| 227 | 201 | } |
| 228 | 202 | |
| 229 | 203 | // 2. 获取所有成员和已读位置 |
| 230 | - List<ImGroupMemberDO> allMembers = imGroupMemberService.selectByGroupId(groupId); | |
| 204 | + List<ImGroupMemberDO> allMembers = groupMemberService.selectByGroupId(groupId); | |
| 231 | 205 | Map<Object, Object> allPositions = groupReadPositionRedisDAO.getAllReadPositions(groupId); |
| 232 | 206 | |
| 233 | - // 3. 计算该消息的可见成员集合 | |
| 207 | + // 3. 计算该消息的可见成员集合(排除发送者自己) | |
| 234 | 208 | Set<Long> visibleUserIds = getVisibleUserIds(message, allMembers); |
| 209 | + visibleUserIds.remove(message.getSenderId()); | |
| 235 | 210 | |
| 236 | 211 | // 4. 只返回在可见范围内且已读位置 >= messageId 的用户 |
| 237 | 212 | List<Long> readUserIds = new ArrayList<>(); |
| 238 | - for (Map.Entry<Object, Object> entry : allPositions.entrySet()) { | |
| 239 | - Long uid = Long.parseLong(entry.getKey().toString()); | |
| 240 | - Long readMaxId = Long.parseLong(entry.getValue().toString()); | |
| 241 | - if (readMaxId >= messageId && visibleUserIds.contains(uid) | |
| 242 | - && !uid.equals(message.getSenderId())) { // 排除发送者自己 | |
| 213 | + allPositions.forEach((k, v) -> { | |
| 214 | + Long uid = Long.valueOf(k.toString()); | |
| 215 | + if (visibleUserIds.contains(uid) && Long.parseLong(v.toString()) >= messageId) { | |
| 243 | 216 | readUserIds.add(uid); |
| 244 | 217 | } |
| 245 | - } | |
| 218 | + }); | |
| 246 | 219 | return readUserIds; |
| 247 | 220 | } |
| 248 | 221 | |
| 222 | + @Override | |
| 223 | + public List<ImGroupMessageDO> getGroupMessageList(Long userId, ImGroupMessageListReqVO reqVO) { | |
| 224 | + // 1. 校验用户在群中 | |
| 225 | + ImGroupMemberDO member = validateMemberInGroup(reqVO.getGroupId(), userId); | |
| 226 | + | |
| 227 | + // 2. 查询历史消息(仅入群之后) | |
| 228 | + // TODO @AI:应该 mybatis 里就进行过滤!然后 joinTime 肯定不会是空的!!! | |
| 229 | + List<ImGroupMessageDO> messages = groupMessageMapper.selectHistoryList( | |
| 230 | + reqVO.getGroupId(), reqVO.getMaxId(), reqVO.getLimit()); | |
| 231 | + if (member.getJoinTime() == null) { | |
| 232 | + return messages; | |
| 233 | + } | |
| 234 | + return messages.stream() | |
| 235 | + .filter(msg -> !msg.getSendTime().isBefore(member.getJoinTime())) | |
| 236 | + .toList(); | |
| 237 | + } | |
| 238 | + | |
| 239 | + // ========== 异步 WebSocket 推送 ========== | |
| 240 | + | |
| 241 | + /** | |
| 242 | + * 发送群聊消息 WebSocket 事件 | |
| 243 | + */ | |
| 244 | + @Async | |
| 245 | + public void sendGroupMessageEvent(ImGroupMessageDO message) { | |
| 246 | + ImGroupMessageDTO dto = ImGroupMessageDTO.ofSend(message); | |
| 247 | + // 广播给群内可见成员(含发送方自己,支持多端同步) | |
| 248 | + // TODO @AI:getGroupMemberListByGroupId();默认过滤有效的; | |
| 249 | + List<ImGroupMemberDO> members = groupMemberService.selectByGroupId(message.getGroupId()); | |
| 250 | + List<Long> receiverList = message.getReceiverUserIds(); | |
| 251 | + for (ImGroupMemberDO member : members) { | |
| 252 | + if (CommonStatusEnum.DISABLE.getStatus().equals(member.getStatus())) { | |
| 253 | + continue; | |
| 254 | + } | |
| 255 | + // TODO @AI:这里是不是不用过滤啊?因为一般 send 的时候,肯定带了呀。按照群友就好了; | |
| 256 | + if (CollUtil.isNotEmpty(receiverList) && !receiverList.contains(member.getUserId()) | |
| 257 | + && ObjUtil.notEqual(member.getUserId(), message.getSenderId())) { | |
| 258 | + continue; | |
| 259 | + } | |
| 260 | + webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), member.getUserId(), | |
| 261 | + ImGroupMessageDTO.TYPE, dto); | |
| 262 | + } | |
| 263 | + } | |
| 264 | + | |
| 265 | + // TODO @AI:命名最后都是 pre 和 new 里面最好都有 messageId 的标识;包括上面的地方,更好理解吧! | |
| 249 | 266 | /** |
| 250 | - * 刷新群回执(仅处理 PENDING 状态的消息,无需全量扫描) | |
| 267 | + * 发送已读 + 刷新群回执 WebSocket 事件 | |
| 268 | + * | |
| 269 | + * @param userId 当前用户编号 | |
| 270 | + * @param groupId 群编号 | |
| 271 | + * @param prevMaxId 上次已读位置 | |
| 272 | + * @param newMaxId 本次已读位置 | |
| 251 | 273 | */ |
| 252 | - private void refreshGroupReceipts(Long groupId) { | |
| 253 | - // 1. 只查 PENDING 状态的回执消息 | |
| 254 | - List<ImGroupMessageDO> pendingMessages = imGroupMessageMapper.selectPendingReceiptMessages(groupId); | |
| 274 | + @Async | |
| 275 | + public void readGroupMessageEvent(Long userId, Long groupId, Long prevMaxId, Long newMaxId) { | |
| 276 | + // 1. 发送 READ 事件给自己的其他终端(多端同步) | |
| 277 | + ImGroupMessageDTO readDto = ImGroupMessageDTO.ofRead(userId, groupId, newMaxId); | |
| 278 | + webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId, | |
| 279 | + ImGroupMessageDTO.TYPE, readDto); | |
| 280 | + | |
| 281 | + // 2. 刷新 (prevMaxId, newMaxId] 区间内的待回执消息 | |
| 282 | + // TODO @AI:selectListByXXX;注意,prevMaxId 可能为空的情况; | |
| 283 | + List<ImGroupMessageDO> pendingMessages = groupMessageMapper.selectPendingReceiptMessagesInRange( | |
| 284 | + groupId, prevMaxId, newMaxId); | |
| 255 | 285 | if (CollUtil.isEmpty(pendingMessages)) { |
| 256 | 286 | return; |
| 257 | 287 | } |
| 258 | - | |
| 259 | - // 2. 加载成员和已读位置 | |
| 260 | - List<ImGroupMemberDO> allMembers = imGroupMemberService.selectByGroupId(groupId); | |
| 288 | + // TODO @AI:是不是要过滤 status ?因为可能退出了。 | |
| 289 | + List<ImGroupMemberDO> allMembers = groupMemberService.selectByGroupId(groupId); | |
| 290 | + // TODO @AI:变量也改下; | |
| 261 | 291 | Map<Object, Object> allPositions = groupReadPositionRedisDAO.getAllReadPositions(groupId); |
| 262 | - | |
| 263 | - for (ImGroupMessageDO msg : pendingMessages) { | |
| 264 | - // 3. 计算该消息的可见成员(排除发送者自己) | |
| 265 | - Set<Long> visibleUserIds = getVisibleUserIds(msg, allMembers); | |
| 266 | - visibleUserIds.remove(msg.getSenderId()); // 发送者不计入回执统计 | |
| 267 | - | |
| 292 | + for (ImGroupMessageDO message : pendingMessages) { | |
| 293 | + // 2.1 计算该消息的可见成员(排除发送者自己) | |
| 294 | + Set<Long> visibleUserIds = getVisibleUserIds(message, allMembers); | |
| 295 | + visibleUserIds.remove(message.getSenderId()); | |
| 296 | + // TODO @AI:CollUtil 来判断; | |
| 268 | 297 | if (visibleUserIds.isEmpty()) { |
| 269 | 298 | continue; |
| 270 | 299 | } |
| 271 | - | |
| 272 | - // 4. 统计可见成员中的已读人数 | |
| 300 | + // 2.2 统计可见成员中的已读人数 | |
| 273 | 301 | int readCount = 0; |
| 274 | 302 | for (Long uid : visibleUserIds) { |
| 303 | + // TODO @AI:根据返回结果,进行简化;应该都是 Long、Long; | |
| 275 | 304 | Object val = allPositions.get(uid.toString()); |
| 276 | - if (val != null && Long.parseLong(val.toString()) >= msg.getId()) { | |
| 305 | + if (val != null && Long.parseLong(val.toString()) >= message.getId()) { | |
| 277 | 306 | readCount++; |
| 278 | 307 | } |
| 279 | 308 | } |
| 280 | - | |
| 281 | - // 5. 判断是否全部已读 | |
| 309 | + // 2.3 全部已读 → 标记回执完成 | |
| 282 | 310 | boolean allRead = readCount >= visibleUserIds.size(); |
| 311 | + Integer newReceiptStatus = allRead | |
| 312 | + ? ImGroupMessageReceiptStatusEnum.DONE.getStatus() | |
| 313 | + : ImGroupMessageReceiptStatusEnum.PENDING.getStatus(); | |
| 283 | 314 | if (allRead) { |
| 284 | - ImGroupMessageDO update = new ImGroupMessageDO(); | |
| 285 | - update.setId(msg.getId()); | |
| 286 | - update.setReceiptStatus(ImGroupMessageReceiptStatusEnum.DONE.getStatus()); | |
| 287 | - imGroupMessageMapper.updateById(update); | |
| 315 | + groupMessageMapper.updateById(new ImGroupMessageDO().setId(message.getId()) | |
| 316 | + .setReceiptStatus(newReceiptStatus)); | |
| 288 | 317 | } |
| 318 | + // 2.4 发送 RECEIPT 事件给消息发送方(只有 ta 关心已读进度) | |
| 319 | + // TODO @AI:检查下别的 Dto = 的地方,都最好改成 DTO = ;仅限 im 模块; | |
| 320 | + ImGroupMessageDTO receiptDTO = ImGroupMessageDTO.ofReceipt( | |
| 321 | + message.getId(), groupId, readCount, newReceiptStatus); | |
| 322 | + webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), message.getSenderId(), | |
| 323 | + ImGroupMessageDTO.TYPE, receiptDTO); | |
| 324 | + } | |
| 325 | + } | |
| 289 | 326 | |
| 290 | - // 6. 广播 RECEIPT 事件给活跃成员 | |
| 291 | - ImMessageReceiptMessage receiptMessage = new ImMessageReceiptMessage() | |
| 292 | - .setMessageId(msg.getId()) | |
| 293 | - .setGroupId(groupId) | |
| 294 | - .setReadCount(readCount) | |
| 295 | - .setReceiptStatus(allRead | |
| 296 | - ? ImGroupMessageReceiptStatusEnum.DONE.getStatus() | |
| 297 | - : ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) | |
| 298 | - .setScene(ImMessageSceneEnum.GROUP.getScene()); | |
| 299 | - | |
| 300 | - for (ImGroupMemberDO m : allMembers) { | |
| 301 | - if (CommonStatusEnum.DISABLE.getStatus().equals(m.getStatus())) { | |
| 302 | - continue; | |
| 303 | - } | |
| 304 | - webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), m.getUserId(), | |
| 305 | - ImMessageReceiptMessage.TYPE, receiptMessage); | |
| 306 | - } | |
| 327 | + // ========== 私有工具方法 ========== | |
| 328 | + | |
| 329 | + // TODO @AI:抽到对应的 GroupMemberService 中,供其他地方复用 | |
| 330 | + /** | |
| 331 | + * 校验用户是否为群的有效成员 | |
| 332 | + * | |
| 333 | + * @param groupId 群编号 | |
| 334 | + * @param userId 用户编号 | |
| 335 | + * @return 群成员记录 | |
| 336 | + */ | |
| 337 | + private ImGroupMemberDO validateMemberInGroup(Long groupId, Long userId) { | |
| 338 | + ImGroupMemberDO member = groupMemberService.getGroupMember(groupId, userId); | |
| 339 | + if (member == null || CommonStatusEnum.DISABLE.getStatus().equals(member.getStatus())) { | |
| 340 | + throw exception(GROUP_MEMBER_NOT_IN_GROUP); | |
| 307 | 341 | } |
| 342 | + return member; | |
| 308 | 343 | } |
| 309 | 344 | |
| 310 | 345 | /** |
| 311 | - * 计算一条群消息的可见成员集合 | |
| 346 | + * 判断一条群消息对某个群成员是否可见 | |
| 312 | 347 | * <p> |
| 313 | 348 | * 规则: |
| 314 | - * 1. 成员必须是 NORMAL 状态,或虽已 QUIT 但消息发送时间在退群之前 | |
| 315 | - * 2. 成员的入群时间必须在消息发送之前(入群后才能看到消息) | |
| 316 | - * 3. 如果消息有 receiverUserIds 定向接收,则只有列表中的人可见 | |
| 349 | + * 1. 入群时间晚于消息发送时间 → 不可见 | |
| 350 | + * 2. 已退群且退群时间早于消息发送时间 → 不可见 | |
| 351 | + * 3. 存在定向接收列表且当前用户既不在列表中也不是发送者 → 不可见 | |
| 317 | 352 | */ |
| 318 | - private Set<Long> getVisibleUserIds(ImGroupMessageDO msg, List<ImGroupMemberDO> allMembers) { | |
| 319 | - // 定向接收集合 | |
| 320 | - List<Long> receiverList = msg.getReceiverUserIds(); | |
| 321 | - | |
| 322 | - Set<Long> visible = new HashSet<>(); | |
| 323 | - for (ImGroupMemberDO m : allMembers) { | |
| 324 | - // 入群时间在消息发送之后 → 不可见 | |
| 325 | - if (m.getJoinTime() != null && msg.getSendTime().isBefore(m.getJoinTime())) { | |
| 326 | - continue; | |
| 327 | - } | |
| 328 | - // 已退群且退群时间在消息发送之前 → 不可见 | |
| 329 | - if (CommonStatusEnum.DISABLE.getStatus().equals(m.getStatus()) | |
| 330 | - && m.getQuitTime() != null && msg.getSendTime().isAfter(m.getQuitTime())) { | |
| 331 | - continue; | |
| 332 | - } | |
| 333 | - // 定向接收过滤 | |
| 334 | - if (CollUtil.isNotEmpty(receiverList) && !receiverList.contains(m.getUserId()) | |
| 335 | - && !m.getUserId().equals(msg.getSenderId())) { | |
| 336 | - continue; | |
| 337 | - } | |
| 338 | - visible.add(m.getUserId()); | |
| 353 | + private boolean isMessageVisible(ImGroupMessageDO msg, ImGroupMemberDO member, Long userId) { | |
| 354 | + if (member == null) { | |
| 355 | + return false; | |
| 339 | 356 | } |
| 340 | - return visible; | |
| 357 | + if (member.getJoinTime() != null && msg.getSendTime().isBefore(member.getJoinTime())) { | |
| 358 | + return false; | |
| 359 | + } | |
| 360 | + if (CommonStatusEnum.DISABLE.getStatus().equals(member.getStatus()) | |
| 361 | + && member.getQuitTime() != null && msg.getSendTime().isAfter(member.getQuitTime())) { | |
| 362 | + return false; | |
| 363 | + } | |
| 364 | + if (CollUtil.isNotEmpty(msg.getReceiverUserIds()) | |
| 365 | + && !msg.getReceiverUserIds().contains(userId) | |
| 366 | + && ObjUtil.notEqual(msg.getSenderId(), userId)) { | |
| 367 | + return false; | |
| 368 | + } | |
| 369 | + return true; | |
| 341 | 370 | } |
| 342 | 371 | |
| 343 | 372 | /** |
| 344 | - * 发送群聊消息 WebSocket 事件 | |
| 373 | + * 计算一条群消息的可见成员集合(含发送者) | |
| 345 | 374 | */ |
| 346 | - private void sendGroupMessageEvent(ImGroupMessageDO message, Long groupId) { | |
| 347 | - List<ImGroupMemberDO> members = imGroupMemberService.selectByGroupId(groupId); | |
| 348 | - ImGroupMessageSendMessage sendMessage = new ImGroupMessageSendMessage() | |
| 349 | - .setId(message.getId()) | |
| 350 | - .setClientMessageId(message.getClientMessageId()) | |
| 351 | - .setSenderId(message.getSenderId()) | |
| 352 | - .setGroupId(message.getGroupId()) | |
| 353 | - .setType(message.getType()) | |
| 354 | - .setContent(message.getContent()) | |
| 355 | - .setStatus(message.getStatus()) | |
| 356 | - .setSendTime(message.getSendTime()) | |
| 357 | - .setAtUserIds(message.getAtUserIds()) | |
| 358 | - .setReceiverUserIds(message.getReceiverUserIds()) | |
| 359 | - .setReceiptStatus(message.getReceiptStatus()); | |
| 360 | - | |
| 361 | - // 确定推送目标 | |
| 362 | - List<Long> receiverList = message.getReceiverUserIds(); | |
| 363 | - | |
| 364 | - for (ImGroupMemberDO member : members) { | |
| 365 | - if (CommonStatusEnum.DISABLE.getStatus().equals(member.getStatus())) { | |
| 366 | - continue; | |
| 367 | - } | |
| 368 | - // 定向接收过滤 | |
| 369 | - if (CollUtil.isNotEmpty(receiverList) && !receiverList.contains(member.getUserId()) | |
| 370 | - && !member.getUserId().equals(message.getSenderId())) { | |
| 371 | - continue; | |
| 375 | + private Set<Long> getVisibleUserIds(ImGroupMessageDO msg, List<ImGroupMemberDO> allMembers) { | |
| 376 | + Set<Long> visible = new HashSet<>(); | |
| 377 | + // TODO @AI: CollectionUtils.converset | |
| 378 | + for (ImGroupMemberDO member : allMembers) { | |
| 379 | + if (isMessageVisible(msg, member, member.getUserId())) { | |
| 380 | + visible.add(member.getUserId()); | |
| 372 | 381 | } |
| 373 | - webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), member.getUserId(), | |
| 374 | - ImGroupMessageSendMessage.TYPE, sendMessage); | |
| 375 | 382 | } |
| 383 | + return visible; | |
| 384 | + } | |
| 385 | + | |
| 386 | + private ImGroupMessageServiceImpl getSelf() { | |
| 387 | + return SpringUtil.getBean(getClass()); | |
| 376 | 388 | } |
| 377 | 389 | |
| 378 | 390 | } | ... | ... |
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java
| ... | ... | @@ -22,6 +22,7 @@ import jakarta.annotation.Resource; |
| 22 | 22 | import lombok.extern.slf4j.Slf4j; |
| 23 | 23 | import org.springframework.scheduling.annotation.Async; |
| 24 | 24 | import org.springframework.stereotype.Service; |
| 25 | +import org.springframework.transaction.annotation.Transactional; | |
| 25 | 26 | import org.springframework.validation.annotation.Validated; |
| 26 | 27 | |
| 27 | 28 | import java.time.LocalDateTime; |
| ... | ... | @@ -92,6 +93,7 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { |
| 92 | 93 | return messages; |
| 93 | 94 | } |
| 94 | 95 | |
| 96 | + // TODO @AI:考虑到更稳妥,是不是前端传递 messageId?不然恰好发过来,然后读取了。【后续在优化】 | |
| 95 | 97 | @Override |
| 96 | 98 | public void readPrivateMessages(Long userId, Long receiverId) { |
| 97 | 99 | // 1.1 查询未读消息列表 |
| ... | ... | @@ -111,6 +113,7 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { |
| 111 | 113 | } |
| 112 | 114 | |
| 113 | 115 | @Override |
| 116 | + @Transactional(rollbackFor = Exception.class) | |
| 114 | 117 | public ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId) { |
| 115 | 118 | // 1.1 校验消息存在 |
| 116 | 119 | ImPrivateMessageDO message = privateMessageMapper.selectById(messageId); | ... | ... |
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImGroupMessageSendMessage.java renamed to yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImGroupMessageDTO.java
| 1 | 1 | package cn.iocoder.yudao.module.im.websocket; |
| 2 | 2 | |
| 3 | +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; | |
| 4 | +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; | |
| 5 | +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; | |
| 3 | 6 | import lombok.Data; |
| 4 | -import lombok.experimental.Accessors; | |
| 5 | 7 | |
| 6 | 8 | import java.time.LocalDateTime; |
| 7 | 9 | import java.util.List; |
| 8 | 10 | |
| 9 | 11 | /** |
| 10 | - * IM 群聊消息 WebSocket 推送消息 | |
| 12 | + * IM 群聊消息 WebSocket 统一推送 DTO | |
| 13 | + * <p> | |
| 14 | + * 发送、已读、回执都通过 {@link #type} 字段区分, | |
| 15 | + * 并统一复用 {@link #id} 字段表达目标消息或已读位置。 | |
| 11 | 16 | * |
| 12 | 17 | * @author 芋道源码 |
| 13 | 18 | */ |
| 14 | 19 | @Data |
| 15 | -@Accessors(chain = true) | |
| 16 | -public class ImGroupMessageSendMessage { | |
| 20 | +public class ImGroupMessageDTO { | |
| 17 | 21 | |
| 18 | - public static final String TYPE = "im-group-message-send"; | |
| 22 | + public static final String TYPE = "im-group-message"; | |
| 19 | 23 | |
| 20 | 24 | /** |
| 21 | 25 | * 消息编号 |
| 26 | + * <p> | |
| 27 | + * 普通消息:当前消息 id | |
| 28 | + * READ:无(群已读不需要) | |
| 29 | + * RECEIPT:需要回执的目标消息 id | |
| 22 | 30 | */ |
| 23 | 31 | private Long id; |
| 24 | 32 | /** |
| ... | ... | @@ -50,7 +58,7 @@ public class ImGroupMessageSendMessage { |
| 50 | 58 | */ |
| 51 | 59 | private LocalDateTime sendTime; |
| 52 | 60 | /** |
| 53 | - * @ 的用户编号列表 | |
| 61 | + * @ 目标用户编号列表 | |
| 54 | 62 | */ |
| 55 | 63 | private List<Long> atUserIds; |
| 56 | 64 | /** |
| ... | ... | @@ -61,5 +69,51 @@ public class ImGroupMessageSendMessage { |
| 61 | 69 | * 群回执状态 |
| 62 | 70 | */ |
| 63 | 71 | private Integer receiptStatus; |
| 72 | + /** | |
| 73 | + * 群回执已读人数 | |
| 74 | + */ | |
| 75 | + private Integer readCount; | |
| 76 | + | |
| 77 | + // ========== 静态工厂方法 ========== | |
| 78 | + | |
| 79 | + /** | |
| 80 | + * 构建发送消息 DTO | |
| 81 | + * | |
| 82 | + * @param message 群聊消息 DO | |
| 83 | + * @return 群聊 DTO | |
| 84 | + */ | |
| 85 | + public static ImGroupMessageDTO ofSend(ImGroupMessageDO message) { | |
| 86 | + return BeanUtils.toBean(message, ImGroupMessageDTO.class); | |
| 87 | + } | |
| 88 | + | |
| 89 | + /** | |
| 90 | + * 构建已读同步 DTO(多端同步:通知自己的其他终端"我已经读了某个群") | |
| 91 | + * | |
| 92 | + * @param senderId 当前用户编号 | |
| 93 | + * @param groupId 群编号 | |
| 94 | + * @param readId 已读位置(最大已读消息编号) | |
| 95 | + * @return 群聊 DTO | |
| 96 | + */ | |
| 97 | + public static ImGroupMessageDTO ofRead(Long senderId, Long groupId, Long readId) { | |
| 98 | + return new ImGroupMessageDTO() | |
| 99 | + .setId(readId).setType(ImMessageTypeEnum.READ.getType()) | |
| 100 | + .setSenderId(senderId).setGroupId(groupId); | |
| 101 | + } | |
| 102 | + | |
| 103 | + /** | |
| 104 | + * 构建群回执 DTO(广播回执状态和已读人数) | |
| 105 | + * | |
| 106 | + * @param messageId 消息编号 | |
| 107 | + * @param groupId 群编号 | |
| 108 | + * @param readCount 已读人数 | |
| 109 | + * @param receiptStatus 回执状态 | |
| 110 | + * @return 群聊 DTO | |
| 111 | + */ | |
| 112 | + public static ImGroupMessageDTO ofReceipt(Long messageId, Long groupId, | |
| 113 | + Integer readCount, Integer receiptStatus) { | |
| 114 | + return new ImGroupMessageDTO() | |
| 115 | + .setId(messageId).setType(ImMessageTypeEnum.RECEIPT.getType()) | |
| 116 | + .setGroupId(groupId).setReadCount(readCount).setReceiptStatus(receiptStatus); | |
| 117 | + } | |
| 64 | 118 | |
| 65 | 119 | } | ... | ... |
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImMessageReadMessage.java deleted
| 1 | -package cn.iocoder.yudao.module.im.websocket; | |
| 2 | - | |
| 3 | -import lombok.Data; | |
| 4 | -import lombok.experimental.Accessors; | |
| 5 | - | |
| 6 | -/** | |
| 7 | - * IM 消息已读 WebSocket 推送消息 | |
| 8 | - * <p> | |
| 9 | - * 用于多端同步:通知自己的其他终端"我已经读了某个会话" | |
| 10 | - * | |
| 11 | - * @author 芋道源码 | |
| 12 | - */ | |
| 13 | -@Data | |
| 14 | -@Accessors(chain = true) | |
| 15 | -public class ImMessageReadMessage { | |
| 16 | - | |
| 17 | - public static final String TYPE = "im-message-read"; | |
| 18 | - | |
| 19 | - /** | |
| 20 | - * 私聊场景:接收方编号 | |
| 21 | - */ | |
| 22 | - private Long receiverId; | |
| 23 | - /** | |
| 24 | - * 群聊场景:群编号 | |
| 25 | - */ | |
| 26 | - private Long groupId; | |
| 27 | - /** | |
| 28 | - * 消息场景 | |
| 29 | - * | |
| 30 | - * 关联 {@link cn.iocoder.yudao.module.im.enums.message.ImMessageSceneEnum} | |
| 31 | - */ | |
| 32 | - private Integer scene; | |
| 33 | - | |
| 34 | -} |
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImMessageRecallMessage.java deleted
| 1 | -package cn.iocoder.yudao.module.im.websocket; | |
| 2 | - | |
| 3 | -import lombok.Data; | |
| 4 | -import lombok.experimental.Accessors; | |
| 5 | - | |
| 6 | -/** | |
| 7 | - * IM 消息撤回 WebSocket 推送消息 | |
| 8 | - * | |
| 9 | - * @author 芋道源码 | |
| 10 | - */ | |
| 11 | -@Data | |
| 12 | -@Accessors(chain = true) | |
| 13 | -public class ImMessageRecallMessage { | |
| 14 | - | |
| 15 | - public static final String TYPE = "im-message-recall"; | |
| 16 | - | |
| 17 | - /** | |
| 18 | - * 消息编号 | |
| 19 | - */ | |
| 20 | - private Long messageId; | |
| 21 | - /** | |
| 22 | - * 发送人编号 | |
| 23 | - */ | |
| 24 | - private Long senderId; | |
| 25 | - /** | |
| 26 | - * 群聊场景:群编号 | |
| 27 | - */ | |
| 28 | - private Long groupId; | |
| 29 | - /** | |
| 30 | - * 消息场景 | |
| 31 | - * | |
| 32 | - * 关联 {@link cn.iocoder.yudao.module.im.enums.message.ImMessageSceneEnum} | |
| 33 | - */ | |
| 34 | - private Integer scene; | |
| 35 | - | |
| 36 | -} |
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImMessageReceiptMessage.java deleted
| 1 | -package cn.iocoder.yudao.module.im.websocket; | |
| 2 | - | |
| 3 | -import lombok.Data; | |
| 4 | -import lombok.experimental.Accessors; | |
| 5 | - | |
| 6 | -/** | |
| 7 | - * IM 已读回执 WebSocket 推送消息 | |
| 8 | - * <p> | |
| 9 | - * 私聊:通知对方"我已读了你的消息" | |
| 10 | - * 群聊:广播回执状态和已读人数 | |
| 11 | - * | |
| 12 | - * @author 芋道源码 | |
| 13 | - */ | |
| 14 | -@Data | |
| 15 | -@Accessors(chain = true) | |
| 16 | -public class ImMessageReceiptMessage { | |
| 17 | - | |
| 18 | - public static final String TYPE = "im-message-receipt"; | |
| 19 | - | |
| 20 | - /** | |
| 21 | - * 消息场景 | |
| 22 | - * | |
| 23 | - * 关联 {@link cn.iocoder.yudao.module.im.enums.message.ImMessageSceneEnum} | |
| 24 | - */ | |
| 25 | - private Integer scene; | |
| 26 | - | |
| 27 | - // ========== 私聊场景 ========== | |
| 28 | - | |
| 29 | - /** | |
| 30 | - * 私聊场景:已读方的用户编号 | |
| 31 | - */ | |
| 32 | - private Long receiverId; | |
| 33 | - | |
| 34 | - // ========== 群聊场景 ========== | |
| 35 | - | |
| 36 | - /** | |
| 37 | - * 群聊场景:消息编号 | |
| 38 | - */ | |
| 39 | - private Long messageId; | |
| 40 | - /** | |
| 41 | - * 群聊场景:群编号 | |
| 42 | - */ | |
| 43 | - private Long groupId; | |
| 44 | - /** | |
| 45 | - * 群聊场景:已读人数 | |
| 46 | - */ | |
| 47 | - private Integer readCount; | |
| 48 | - /** | |
| 49 | - * 群聊场景:回执状态 | |
| 50 | - */ | |
| 51 | - private Integer receiptStatus; | |
| 52 | - | |
| 53 | -} |
yudao-module-im/yudao-module-im-biz/src/main/java/cn/iocoder/yudao/module/im/websocket/ImPrivateMessageDTO.java
yudao-module-im/yudao-module-im-biz/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java
| 1 | 1 | package cn.iocoder.yudao.module.im.service.message; |
| 2 | 2 | |
| 3 | +import cn.hutool.extra.spring.SpringUtil; | |
| 3 | 4 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; |
| 4 | 5 | import cn.iocoder.yudao.framework.common.exception.ServiceException; |
| 5 | 6 | import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; |
| ... | ... | @@ -9,7 +10,7 @@ import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; |
| 9 | 10 | import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; |
| 10 | 11 | import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; |
| 11 | 12 | import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper; |
| 12 | -import cn.iocoder.yudao.module.im.dal.redis.group.GroupReadPositionRedisDAO; | |
| 13 | +import cn.iocoder.yudao.module.im.dal.redis.message.GroupReadPositionRedisDAO; | |
| 13 | 14 | import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; |
| 14 | 15 | import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; |
| 15 | 16 | import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; |
| ... | ... | @@ -18,9 +19,12 @@ import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService; |
| 18 | 19 | import org.junit.jupiter.api.Test; |
| 19 | 20 | import org.mockito.InjectMocks; |
| 20 | 21 | import org.mockito.Mock; |
| 22 | +import org.mockito.MockedStatic; | |
| 21 | 23 | |
| 22 | 24 | import java.time.LocalDateTime; |
| 23 | -import java.util.*; | |
| 25 | +import java.util.HashMap; | |
| 26 | +import java.util.List; | |
| 27 | +import java.util.Map; | |
| 24 | 28 | |
| 25 | 29 | import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; |
| 26 | 30 | import static org.junit.jupiter.api.Assertions.*; |
| ... | ... | @@ -38,13 +42,13 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 38 | 42 | private ImGroupMessageServiceImpl groupMessageService; |
| 39 | 43 | |
| 40 | 44 | @Mock |
| 41 | - private ImGroupMessageMapper imGroupMessageMapper; | |
| 45 | + private ImGroupMessageMapper groupMessageMapper; | |
| 42 | 46 | @Mock |
| 43 | - private ImGroupService imGroupService; | |
| 47 | + private ImGroupService groupService; | |
| 44 | 48 | @Mock |
| 45 | - private ImGroupMemberService imGroupMemberService; | |
| 49 | + private ImGroupMemberService groupMemberService; | |
| 46 | 50 | @Mock |
| 47 | - private ImSensitiveWordService imSensitiveWordService; | |
| 51 | + private ImSensitiveWordService sensitiveWordService; | |
| 48 | 52 | @Mock |
| 49 | 53 | private GroupReadPositionRedisDAO groupReadPositionRedisDAO; |
| 50 | 54 | @Mock |
| ... | ... | @@ -63,47 +67,50 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 63 | 67 | |
| 64 | 68 | @Test |
| 65 | 69 | public void testSendMessage_success() { |
| 66 | - // 准备 | |
| 67 | - ImGroupMessageSendReqVO reqVO = buildSendReqVO(); | |
| 68 | - when(imGroupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) | |
| 69 | - .thenReturn(null); | |
| 70 | - ImGroupDO group = new ImGroupDO(); | |
| 71 | - group.setId(10L); | |
| 72 | - group.setName("测试群"); | |
| 73 | - when(imGroupService.validateGroupExists(10L)).thenReturn(group); | |
| 74 | - | |
| 75 | - ImGroupMemberDO member = new ImGroupMemberDO(); | |
| 76 | - member.setGroupId(10L); | |
| 77 | - member.setUserId(1L); | |
| 78 | - member.setStatus(CommonStatusEnum.ENABLE.getStatus()); | |
| 79 | - when(imGroupMemberService.getGroupMember(10L, 1L)).thenReturn(member); | |
| 80 | - | |
| 81 | - List<ImGroupMemberDO> allMembers = List.of( | |
| 82 | - member, | |
| 83 | - ImGroupMemberDO.builder().groupId(10L).userId(2L) | |
| 84 | - .status(CommonStatusEnum.ENABLE.getStatus()).build(), | |
| 85 | - ImGroupMemberDO.builder().groupId(10L).userId(3L) | |
| 86 | - .status(CommonStatusEnum.ENABLE.getStatus()).build() | |
| 87 | - ); | |
| 88 | - when(imGroupMemberService.selectByGroupId(10L)).thenReturn(allMembers); | |
| 89 | - when(imGroupMessageMapper.insert(any(ImGroupMessageDO.class))).thenAnswer(invocation -> { | |
| 90 | - ImGroupMessageDO msg = invocation.getArgument(0); | |
| 91 | - msg.setId(99L); | |
| 92 | - return 1; | |
| 93 | - }); | |
| 94 | - | |
| 95 | - // 调用 | |
| 96 | - ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO); | |
| 97 | - | |
| 98 | - // 断言 | |
| 99 | - assertNotNull(result); | |
| 100 | - assertEquals(1L, result.getSenderId()); | |
| 101 | - assertEquals(10L, result.getGroupId()); | |
| 102 | - assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.getStatus()); | |
| 103 | - assertEquals(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), result.getReceiptStatus()); | |
| 104 | - | |
| 105 | - // 验证推送给 3 个群成员 | |
| 106 | - verify(webSocketMessageSender, times(3)).sendObject(anyInt(), anyLong(), anyString(), any()); | |
| 70 | + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) { | |
| 71 | + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) | |
| 72 | + .thenReturn(groupMessageService); | |
| 73 | + | |
| 74 | + // 准备 | |
| 75 | + ImGroupMessageSendReqVO reqVO = buildSendReqVO(); | |
| 76 | + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) | |
| 77 | + .thenReturn(null); | |
| 78 | + ImGroupDO group = new ImGroupDO(); | |
| 79 | + group.setId(10L); | |
| 80 | + group.setName("测试群"); | |
| 81 | + when(groupService.validateGroupExists(10L)).thenReturn(group); | |
| 82 | + | |
| 83 | + ImGroupMemberDO member = ImGroupMemberDO.builder() | |
| 84 | + .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); | |
| 85 | + when(groupMemberService.getGroupMember(10L, 1L)).thenReturn(member); | |
| 86 | + | |
| 87 | + List<ImGroupMemberDO> allMembers = List.of( | |
| 88 | + member, | |
| 89 | + ImGroupMemberDO.builder().groupId(10L).userId(2L) | |
| 90 | + .status(CommonStatusEnum.ENABLE.getStatus()).build(), | |
| 91 | + ImGroupMemberDO.builder().groupId(10L).userId(3L) | |
| 92 | + .status(CommonStatusEnum.ENABLE.getStatus()).build() | |
| 93 | + ); | |
| 94 | + when(groupMemberService.selectByGroupId(10L)).thenReturn(allMembers); | |
| 95 | + when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenAnswer(invocation -> { | |
| 96 | + ImGroupMessageDO msg = invocation.getArgument(0); | |
| 97 | + msg.setId(99L); | |
| 98 | + return 1; | |
| 99 | + }); | |
| 100 | + | |
| 101 | + // 调用 | |
| 102 | + ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO); | |
| 103 | + | |
| 104 | + // 断言 | |
| 105 | + assertNotNull(result); | |
| 106 | + assertEquals(1L, result.getSenderId()); | |
| 107 | + assertEquals(10L, result.getGroupId()); | |
| 108 | + assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.getStatus()); | |
| 109 | + assertEquals(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), result.getReceiptStatus()); | |
| 110 | + | |
| 111 | + // 验证推送给 3 个群成员(含发送者自己,用于多端同步) | |
| 112 | + verify(webSocketMessageSender, times(3)).sendObject(anyInt(), anyLong(), anyString(), any()); | |
| 113 | + } | |
| 107 | 114 | } |
| 108 | 115 | |
| 109 | 116 | @Test |
| ... | ... | @@ -114,7 +121,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 114 | 121 | .id(100L).clientMessageId("test-uuid-group-001").senderId(1L).groupId(10L) |
| 115 | 122 | .type(0).content("{\"content\":\"群聊你好\"}").status(0) |
| 116 | 123 | .sendTime(LocalDateTime.now()).build(); |
| 117 | - when(imGroupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) | |
| 124 | + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) | |
| 118 | 125 | .thenReturn(existing); |
| 119 | 126 | |
| 120 | 127 | // 调用 |
| ... | ... | @@ -122,19 +129,19 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 122 | 129 | |
| 123 | 130 | // 断言 |
| 124 | 131 | assertEquals(100L, result.getId()); |
| 125 | - verify(imGroupMessageMapper, never()).insert(any(ImGroupMessageDO.class)); | |
| 132 | + verify(groupMessageMapper, never()).insert(any(ImGroupMessageDO.class)); | |
| 126 | 133 | } |
| 127 | 134 | |
| 128 | 135 | @Test |
| 129 | 136 | public void testSendMessage_notInGroup() { |
| 130 | 137 | // 准备 |
| 131 | 138 | ImGroupMessageSendReqVO reqVO = buildSendReqVO(); |
| 132 | - when(imGroupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) | |
| 139 | + when(groupMessageMapper.selectBySenderIdAndClientMessageId(1L, "test-uuid-group-001")) | |
| 133 | 140 | .thenReturn(null); |
| 134 | 141 | ImGroupDO group = new ImGroupDO(); |
| 135 | 142 | group.setId(10L); |
| 136 | - when(imGroupService.validateGroupExists(10L)).thenReturn(group); | |
| 137 | - when(imGroupMemberService.getGroupMember(10L, 1L)).thenReturn(null); | |
| 143 | + when(groupService.validateGroupExists(10L)).thenReturn(group); | |
| 144 | + when(groupMemberService.getGroupMember(10L, 1L)).thenReturn(null); | |
| 138 | 145 | |
| 139 | 146 | // 调用并断言 |
| 140 | 147 | ServiceException exception = assertThrows(ServiceException.class, |
| ... | ... | @@ -152,7 +159,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 152 | 159 | .groupId(10L).userId(1L) |
| 153 | 160 | .status(CommonStatusEnum.ENABLE.getStatus()) |
| 154 | 161 | .joinTime(joinTime).build(); |
| 155 | - when(imGroupMemberService.getGroupMembersByUserId(1L)).thenReturn(List.of(member)); | |
| 162 | + when(groupMemberService.getGroupMembersByUserId(1L)).thenReturn(List.of(member)); | |
| 156 | 163 | |
| 157 | 164 | // 模拟一条入群前的消息和一条入群后的消息 |
| 158 | 165 | ImGroupMessageDO beforeJoin = ImGroupMessageDO.builder() |
| ... | ... | @@ -161,7 +168,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 161 | 168 | ImGroupMessageDO afterJoin = ImGroupMessageDO.builder() |
| 162 | 169 | .id(2L).groupId(10L).senderId(2L) |
| 163 | 170 | .sendTime(LocalDateTime.of(2026, 4, 12, 11, 0, 0)).build(); |
| 164 | - when(imGroupMessageMapper.selectListByMinId(List.of(10L), 0L, 100)) | |
| 171 | + when(groupMessageMapper.selectListByMinId(List.of(10L), 0L, 100)) | |
| 165 | 172 | .thenReturn(List.of(beforeJoin, afterJoin)); |
| 166 | 173 | |
| 167 | 174 | // 调用 |
| ... | ... | @@ -181,7 +188,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 181 | 188 | .groupId(10L).userId(1L) |
| 182 | 189 | .status(CommonStatusEnum.DISABLE.getStatus()) |
| 183 | 190 | .joinTime(joinTime).quitTime(quitTime).build(); |
| 184 | - when(imGroupMemberService.getGroupMembersByUserId(1L)).thenReturn(List.of(member)); | |
| 191 | + when(groupMemberService.getGroupMembersByUserId(1L)).thenReturn(List.of(member)); | |
| 185 | 192 | |
| 186 | 193 | // 退群前后各一条消息 |
| 187 | 194 | ImGroupMessageDO beforeQuit = ImGroupMessageDO.builder() |
| ... | ... | @@ -190,7 +197,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 190 | 197 | ImGroupMessageDO afterQuit = ImGroupMessageDO.builder() |
| 191 | 198 | .id(2L).groupId(10L).senderId(2L) |
| 192 | 199 | .sendTime(LocalDateTime.of(2026, 4, 13, 10, 0, 0)).build(); |
| 193 | - when(imGroupMessageMapper.selectListByMinId(List.of(10L), 0L, 100)) | |
| 200 | + when(groupMessageMapper.selectListByMinId(List.of(10L), 0L, 100)) | |
| 194 | 201 | .thenReturn(List.of(beforeQuit, afterQuit)); |
| 195 | 202 | |
| 196 | 203 | // 调用 |
| ... | ... | @@ -208,7 +215,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 208 | 215 | .groupId(10L).userId(1L) |
| 209 | 216 | .status(CommonStatusEnum.ENABLE.getStatus()) |
| 210 | 217 | .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(); |
| 211 | - when(imGroupMemberService.getGroupMembersByUserId(1L)).thenReturn(List.of(member)); | |
| 218 | + when(groupMemberService.getGroupMembersByUserId(1L)).thenReturn(List.of(member)); | |
| 212 | 219 | |
| 213 | 220 | // 定向接收消息:只给用户 2 和 3 |
| 214 | 221 | ImGroupMessageDO directedMsg = ImGroupMessageDO.builder() |
| ... | ... | @@ -219,7 +226,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 219 | 226 | ImGroupMessageDO allMsg = ImGroupMessageDO.builder() |
| 220 | 227 | .id(2L).groupId(10L).senderId(5L) |
| 221 | 228 | .sendTime(LocalDateTime.of(2026, 4, 12, 10, 0, 0)).build(); |
| 222 | - when(imGroupMessageMapper.selectListByMinId(List.of(10L), 0L, 100)) | |
| 229 | + when(groupMessageMapper.selectListByMinId(List.of(10L), 0L, 100)) | |
| 223 | 230 | .thenReturn(List.of(directedMsg, allMsg)); |
| 224 | 231 | |
| 225 | 232 | // 调用 |
| ... | ... | @@ -237,13 +244,13 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 237 | 244 | .groupId(10L).userId(1L) |
| 238 | 245 | .status(CommonStatusEnum.ENABLE.getStatus()) |
| 239 | 246 | .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(); |
| 240 | - when(imGroupMemberService.getGroupMembersByUserId(1L)).thenReturn(List.of(member)); | |
| 247 | + when(groupMemberService.getGroupMembersByUserId(1L)).thenReturn(List.of(member)); | |
| 241 | 248 | |
| 242 | 249 | ImGroupMessageDO atMsg = ImGroupMessageDO.builder() |
| 243 | 250 | .id(1L).groupId(10L).senderId(2L) |
| 244 | 251 | .atUserIds(List.of(1L, 3L)) |
| 245 | 252 | .sendTime(LocalDateTime.of(2026, 4, 12, 10, 0, 0)).build(); |
| 246 | - when(imGroupMessageMapper.selectListByMinId(List.of(10L), 0L, 100)) | |
| 253 | + when(groupMessageMapper.selectListByMinId(List.of(10L), 0L, 100)) | |
| 247 | 254 | .thenReturn(List.of(atMsg)); |
| 248 | 255 | |
| 249 | 256 | // 调用 |
| ... | ... | @@ -258,28 +265,88 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 258 | 265 | |
| 259 | 266 | @Test |
| 260 | 267 | public void testRecallMessage_success() { |
| 268 | + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) { | |
| 269 | + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(ImGroupMessageServiceImpl.class))) | |
| 270 | + .thenReturn(groupMessageService); | |
| 271 | + | |
| 272 | + // 准备:消息由用户 1 发送,刚发送 5 分钟内 | |
| 273 | + ImGroupMessageDO message = ImGroupMessageDO.builder() | |
| 274 | + .id(50L).senderId(1L).groupId(10L) | |
| 275 | + .status(ImMessageStatusEnum.UNREAD.getStatus()) | |
| 276 | + .sendTime(LocalDateTime.now()).build(); | |
| 277 | + when(groupMessageMapper.selectById(50L)).thenReturn(message); | |
| 278 | + // 撤回前需要校验用户仍在群中 | |
| 279 | + ImGroupMemberDO selfMember = ImGroupMemberDO.builder() | |
| 280 | + .groupId(10L).userId(1L).status(CommonStatusEnum.ENABLE.getStatus()).build(); | |
| 281 | + when(groupMemberService.getGroupMember(10L, 1L)).thenReturn(selfMember); | |
| 282 | + when(groupMessageMapper.updateById(any(ImGroupMessageDO.class))).thenReturn(1); | |
| 283 | + when(groupMessageMapper.insert(any(ImGroupMessageDO.class))).thenReturn(1); | |
| 284 | + | |
| 285 | + List<ImGroupMemberDO> members = List.of( | |
| 286 | + selfMember, | |
| 287 | + ImGroupMemberDO.builder().groupId(10L).userId(2L) | |
| 288 | + .status(CommonStatusEnum.ENABLE.getStatus()).build(), | |
| 289 | + ImGroupMemberDO.builder().groupId(10L).userId(3L) | |
| 290 | + .status(CommonStatusEnum.DISABLE.getStatus()).build() | |
| 291 | + ); | |
| 292 | + when(groupMemberService.selectByGroupId(10L)).thenReturn(members); | |
| 293 | + | |
| 294 | + // 调用 | |
| 295 | + ImGroupMessageDO result = groupMessageService.recallGroupMessage(1L, 50L); | |
| 296 | + | |
| 297 | + // 断言:返回 TIP_TEXT 消息 | |
| 298 | + assertNotNull(result); | |
| 299 | + // 验证:更新原消息状态 + 插入 tipMessage | |
| 300 | + verify(groupMessageMapper).updateById(any(ImGroupMessageDO.class)); | |
| 301 | + verify(groupMessageMapper).insert(any(ImGroupMessageDO.class)); | |
| 302 | + // 验证:只给 2 个活跃成员推送撤回提示(userId=3 已退群) | |
| 303 | + verify(webSocketMessageSender, times(2)).sendObject(anyInt(), anyLong(), anyString(), any()); | |
| 304 | + } | |
| 305 | + } | |
| 306 | + | |
| 307 | + @Test | |
| 308 | + public void testRecallMessage_notOwn() { | |
| 309 | + // 准备 | |
| 310 | + ImGroupMessageDO message = ImGroupMessageDO.builder() | |
| 311 | + .id(50L).senderId(2L).groupId(10L) | |
| 312 | + .status(ImMessageStatusEnum.UNREAD.getStatus()) | |
| 313 | + .sendTime(LocalDateTime.now()).build(); | |
| 314 | + when(groupMessageMapper.selectById(50L)).thenReturn(message); | |
| 315 | + | |
| 316 | + // 调用并断言 | |
| 317 | + ServiceException exception = assertThrows(ServiceException.class, | |
| 318 | + () -> groupMessageService.recallGroupMessage(1L, 50L)); | |
| 319 | + assertEquals(MESSAGE_RECALL_DENIED.getCode(), exception.getCode()); | |
| 320 | + } | |
| 321 | + | |
| 322 | + @Test | |
| 323 | + public void testRecallMessage_alreadyRecalled() { | |
| 261 | 324 | // 准备 |
| 262 | 325 | ImGroupMessageDO message = ImGroupMessageDO.builder() |
| 263 | 326 | .id(50L).senderId(1L).groupId(10L) |
| 264 | - .status(ImMessageStatusEnum.UNREAD.getStatus()).build(); | |
| 265 | - when(imGroupMessageMapper.selectById(50L)).thenReturn(message); | |
| 266 | - when(imGroupMessageMapper.updateById(any(ImGroupMessageDO.class))).thenReturn(1); | |
| 327 | + .status(ImMessageStatusEnum.RECALL.getStatus()) | |
| 328 | + .sendTime(LocalDateTime.now()).build(); | |
| 329 | + when(groupMessageMapper.selectById(50L)).thenReturn(message); | |
| 267 | 330 | |
| 268 | - List<ImGroupMemberDO> members = List.of( | |
| 269 | - ImGroupMemberDO.builder().groupId(10L).userId(1L) | |
| 270 | - .status(CommonStatusEnum.ENABLE.getStatus()).build(), | |
| 271 | - ImGroupMemberDO.builder().groupId(10L).userId(2L) | |
| 272 | - .status(CommonStatusEnum.ENABLE.getStatus()).build(), | |
| 273 | - ImGroupMemberDO.builder().groupId(10L).userId(3L) | |
| 274 | - .status(CommonStatusEnum.DISABLE.getStatus()).build() | |
| 275 | - ); | |
| 276 | - when(imGroupMemberService.selectByGroupId(10L)).thenReturn(members); | |
| 331 | + // 调用并断言 | |
| 332 | + ServiceException exception = assertThrows(ServiceException.class, | |
| 333 | + () -> groupMessageService.recallGroupMessage(1L, 50L)); | |
| 334 | + assertEquals(MESSAGE_ALREADY_RECALLED.getCode(), exception.getCode()); | |
| 335 | + } | |
| 277 | 336 | |
| 278 | - // 调用 | |
| 279 | - groupMessageService.recallGroupMessage(1L, 50L); | |
| 337 | + @Test | |
| 338 | + public void testRecallMessage_timeout() { | |
| 339 | + // 准备:消息发送于 10 分钟前(超过 5 分钟窗口) | |
| 340 | + ImGroupMessageDO message = ImGroupMessageDO.builder() | |
| 341 | + .id(50L).senderId(1L).groupId(10L) | |
| 342 | + .status(ImMessageStatusEnum.UNREAD.getStatus()) | |
| 343 | + .sendTime(LocalDateTime.now().minusMinutes(10)).build(); | |
| 344 | + when(groupMessageMapper.selectById(50L)).thenReturn(message); | |
| 280 | 345 | |
| 281 | - // 断言:只给 2 个活跃成员发送 RECALL 事件(userId=3 已退群) | |
| 282 | - verify(webSocketMessageSender, times(2)).sendObject(anyInt(), anyLong(), anyString(), any()); | |
| 346 | + // 调用并断言 | |
| 347 | + ServiceException exception = assertThrows(ServiceException.class, | |
| 348 | + () -> groupMessageService.recallGroupMessage(1L, 50L)); | |
| 349 | + assertEquals(MESSAGE_RECALL_TIMEOUT.getCode(), exception.getCode()); | |
| 283 | 350 | } |
| 284 | 351 | |
| 285 | 352 | // ========== 群已读测试 ========== |
| ... | ... | @@ -287,7 +354,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 287 | 354 | @Test |
| 288 | 355 | public void testReadMessages_notInGroup() { |
| 289 | 356 | // 准备:当前用户不在群中 |
| 290 | - when(imGroupMemberService.getGroupMember(10L, 1L)).thenReturn(null); | |
| 357 | + when(groupMemberService.getGroupMember(10L, 1L)).thenReturn(null); | |
| 291 | 358 | |
| 292 | 359 | // 调用并断言:越权校验 |
| 293 | 360 | ServiceException exception = assertThrows(ServiceException.class, |
| ... | ... | @@ -302,13 +369,13 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 302 | 369 | .groupId(10L).userId(1L) |
| 303 | 370 | .status(CommonStatusEnum.ENABLE.getStatus()) |
| 304 | 371 | .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build(); |
| 305 | - when(imGroupMemberService.getGroupMember(10L, 1L)).thenReturn(currentMember); | |
| 372 | + when(groupMemberService.getGroupMember(10L, 1L)).thenReturn(currentMember); | |
| 306 | 373 | |
| 307 | 374 | // 准备:消息由用户 5 发,发送时间在 2026-04-12 10:00 |
| 308 | 375 | ImGroupMessageDO message = ImGroupMessageDO.builder() |
| 309 | 376 | .id(80L).groupId(10L).senderId(5L) |
| 310 | 377 | .sendTime(LocalDateTime.of(2026, 4, 12, 10, 0, 0)).build(); |
| 311 | - when(imGroupMessageMapper.selectById(80L)).thenReturn(message); | |
| 378 | + when(groupMessageMapper.selectById(80L)).thenReturn(message); | |
| 312 | 379 | |
| 313 | 380 | // 准备:群成员一览 |
| 314 | 381 | // 用户 1: 正常,入群在消息之前 |
| ... | ... | @@ -327,7 +394,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 327 | 394 | .status(CommonStatusEnum.ENABLE.getStatus()) |
| 328 | 395 | .joinTime(LocalDateTime.of(2026, 1, 1, 0, 0, 0)).build() // 发送者 |
| 329 | 396 | ); |
| 330 | - when(imGroupMemberService.selectByGroupId(10L)).thenReturn(allMembers); | |
| 397 | + when(groupMemberService.selectByGroupId(10L)).thenReturn(allMembers); | |
| 331 | 398 | |
| 332 | 399 | // 准备:Redis 已读位置 — 用户 1 读到 100, 用户 2 读到 50, 用户 3 读到 200 |
| 333 | 400 | Map<Object, Object> positions = new HashMap<>(); |
| ... | ... | @@ -340,7 +407,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 340 | 407 | List<Long> readUsers = groupMessageService.getGroupReadUsers(1L, 10L, 80L); |
| 341 | 408 | |
| 342 | 409 | // 断言: |
| 343 | - // 用户 1: 可见 + readMaxId=100>=80 → 已读 ✓(但排除发送者?不是发送者,所以算入) | |
| 410 | + // 用户 1: 可见 + readMaxId=100>=80 → 已读 ✓ | |
| 344 | 411 | // 用户 2: 可见 + readMaxId=50<80 → 未读 ✗ |
| 345 | 412 | // 用户 3: 入群在消息之后 → 不可见 → 不算 |
| 346 | 413 | // 用户 5: 发送者 → 排除 |
| ... | ... | @@ -354,7 +421,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { |
| 354 | 421 | @Test |
| 355 | 422 | public void testGetReadUsers_notInGroup() { |
| 356 | 423 | // 准备:当前用户不在群中 |
| 357 | - when(imGroupMemberService.getGroupMember(10L, 99L)).thenReturn(null); | |
| 424 | + when(groupMemberService.getGroupMember(10L, 99L)).thenReturn(null); | |
| 358 | 425 | |
| 359 | 426 | // 调用并断言:越权校验 |
| 360 | 427 | ServiceException exception = assertThrows(ServiceException.class, | ... | ... |