From 927b64fc4e7dbc582c31ee80a15d610b832daff9 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sun, 14 Jun 2026 09:34:17 +0800 Subject: [PATCH] feat(im): 统一消息读位置和回执状态模型 --- sql/mysql/ruoyi-vue-pro.sql | 18 +++++++----------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/group/ImGroupMessageManagerRespVO.java | 2 +- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/privates/ImPrivateMessageManagerRespVO.java | 3 +++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImChannelMessageController.java | 10 +++++----- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java | 2 +- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/channel/ImChannelMessagePullRespVO.java | 4 ++-- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageRespVO.java | 5 ++++- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java | 3 +++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImGroupMessageDO.java | 6 ++---- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImPrivateMessageDO.java | 7 +++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java | 4 ++-- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java | 16 +++------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImGroupMessageReceiptStatusEnum.java | 39 --------------------------------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageReceiptStatusEnum.java | 39 +++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageStatusEnum.java | 8 ++------ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java | 53 ++++++++++++++++++----------------------------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java | 6 +++--- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java | 36 +++++++++++++++++++++++++++--------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImPrivateMessageSendDTO.java | 4 ++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java | 4 ++++ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java | 48 ++++++++++++++++++++++++------------------------ yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java | 42 +++++++++++++++++++++--------------------- yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/statistics/ImStatisticsManagerMapperTest.java | 4 ++-- yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java | 73 +++++++++++++++++++++++++++++++++---------------------------------------- yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java | 39 +++++++++++++++++++++------------------ yudao-module-im/src/test/resources/sql/create_tables.sql | 1 + 26 files changed, 239 insertions(+), 237 deletions(-) delete mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImGroupMessageReceiptStatusEnum.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageReceiptStatusEnum.java diff --git a/sql/mysql/ruoyi-vue-pro.sql b/sql/mysql/ruoyi-vue-pro.sql index 5d2959c..f6773c7 100644 --- a/sql/mysql/ruoyi-vue-pro.sql +++ b/sql/mysql/ruoyi-vue-pro.sql @@ -1377,14 +1377,11 @@ INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `st INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (3446, 0, '草稿', '0', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', b'0'); INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (3447, 1, '已完成', '4', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', b'0'); INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (3448, 2, '已取消', '5', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (3520, 1, '未读', '0', 'im_private_message_status', 0, 'warning', '', '私聊=未读,群聊=正常', 'admin', '2026-04-30 11:35:07', 'admin', '2026-04-30 15:14:36', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (3521, 2, '已撤回', '2', 'im_private_message_status', 0, 'danger', '', 'RECALL', 'admin', '2026-04-30 11:35:07', 'admin', '2026-04-30 15:14:36', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (3522, 3, '已读', '3', 'im_private_message_status', 0, 'success', '', 'READ(仅私聊)', 'admin', '2026-04-30 11:35:07', 'admin', '2026-04-30 15:14:36', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (3525, 1, '正常', '0', 'im_group_message_status', 0, 'success', '', '群聊正常(初始状态)', 'admin', '2026-04-30 15:14:36', 'admin', '2026-04-30 15:14:36', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (3526, 2, '已撤回', '2', 'im_group_message_status', 0, 'danger', '', '群聊已撤回', 'admin', '2026-04-30 15:14:36', 'admin', '2026-04-30 15:14:36', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (3530, 1, '不需要回执', '0', 'im_group_message_receipt_status', 0, 'info', '', 'NO_RECEIPT', 'admin', '2026-04-30 11:35:07', 'admin', '2026-04-30 11:35:07', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (3531, 2, '待完成', '1', 'im_group_message_receipt_status', 0, 'warning', '', 'PENDING', 'admin', '2026-04-30 11:35:07', 'admin', '2026-04-30 11:35:07', b'0'); -INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (3532, 3, '已完成', '2', 'im_group_message_receipt_status', 0, 'success', '', 'DONE', 'admin', '2026-04-30 11:35:07', 'admin', '2026-04-30 11:35:07', b'0'); +INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (3525, 1, '正常', '0', 'im_message_status', 0, 'success', '', '正常(初始状态)', 'admin', '2026-04-30 15:14:36', 'admin', '2026-04-30 15:14:36', b'0'); +INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (3526, 2, '已撤回', '2', 'im_message_status', 0, 'danger', '', '已撤回', 'admin', '2026-04-30 15:14:36', 'admin', '2026-04-30 15:14:36', b'0'); +INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (3530, 1, '不需要回执', '0', 'im_message_receipt_status', 0, 'info', '', 'NO_RECEIPT', 'admin', '2026-04-30 11:35:07', 'admin', '2026-04-30 11:35:07', b'0'); +INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (3531, 2, '待完成', '1', 'im_message_receipt_status', 0, 'warning', '', 'PENDING', 'admin', '2026-04-30 11:35:07', 'admin', '2026-04-30 11:35:07', b'0'); +INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (3532, 3, '已完成', '2', 'im_message_receipt_status', 0, 'success', '', 'DONE', 'admin', '2026-04-30 11:35:07', 'admin', '2026-04-30 11:35:07', b'0'); INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (3540, 1, '正常', '0', 'im_friend_status', 0, 'success', '', '正常好友关系', 'admin', '2026-04-30 11:35:07', 'admin', '2026-04-30 11:35:07', b'0'); INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (3541, 2, '已删除', '1', 'im_friend_status', 0, 'danger', '', '已删除好友关系', 'admin', '2026-04-30 11:35:07', 'admin', '2026-04-30 11:35:07', b'0'); INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (3550, 1, '正常', '0', 'im_group_status', 0, 'success', '', '群正常', 'admin', '2026-04-30 11:35:07', 'admin', '2026-04-30 11:35:07', b'0'); @@ -1693,11 +1690,10 @@ INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creat INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`) VALUES (2135, 'MES 上下工状态类型', 'mes_pro_work_record_type', 0, 'MES 上下工状态类型', '1', '2026-04-05 14:07:27', '1', '2026-04-05 14:07:27', b'0', NULL); INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`) VALUES (2138, 'MES 生产入库单状态', 'mes_wm_product_produce_status', 0, 'MES 生产入库单状态', '1', '2026-04-05 15:53:46', '1', '2026-04-05 15:53:46', b'0', '1970-01-01 00:00:00'); INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`) VALUES (2200, 'IM 消息类型', 'im_message_type', 0, '对应 ImMessageTypeEnum', 'admin', '2026-04-30 11:35:07', 'admin', '2026-04-30 11:35:07', b'0', NULL); -INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`) VALUES (2201, 'IM 私聊消息状态', 'im_private_message_status', 0, '对应 ImMessageStatusEnum;私聊 0=未读 / 2=已撤回 / 3=已读', 'admin', '2026-04-30 11:35:07', 'admin', '2026-04-30 15:14:36', b'0', NULL); -INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`) VALUES (2202, 'IM 群消息回执状态', 'im_group_message_receipt_status', 0, '对应 ImGroupMessageReceiptStatusEnum', 'admin', '2026-04-30 11:35:07', 'admin', '2026-04-30 11:35:07', b'0', NULL); +INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`) VALUES (2202, 'IM 消息回执状态', 'im_message_receipt_status', 0, '对应 ImMessageReceiptStatusEnum', 'admin', '2026-04-30 11:35:07', 'admin', '2026-04-30 11:35:07', b'0', NULL); INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`) VALUES (2203, 'IM 好友状态', 'im_friend_status', 0, '0=正常 / 1=已删除', 'admin', '2026-04-30 11:35:07', 'admin', '2026-04-30 11:35:07', b'0', NULL); INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`) VALUES (2204, 'IM 群状态', 'im_group_status', 0, '0=正常 / 1=已解散', 'admin', '2026-04-30 11:35:07', 'admin', '2026-04-30 11:35:07', b'0', NULL); -INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`) VALUES (2205, 'IM 群聊消息状态', 'im_group_message_status', 0, '对应 ImMessageStatusEnum;群聊 0=正常 / 2=已撤回(无未读概念)', 'admin', '2026-04-30 15:14:36', 'admin', '2026-04-30 15:14:36', b'0', NULL); +INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`) VALUES (2205, 'IM 消息状态', 'im_message_status', 0, '对应 ImMessageStatusEnum;0=正常 / 2=已撤回(私聊 / 群聊共用)', 'admin', '2026-04-30 15:14:36', 'admin', '2026-04-30 15:14:36', b'0', NULL); INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`) VALUES (2206, 'IM 群成员角色', 'im_group_member_role', 0, NULL, '1', '2026-05-02 02:14:12', '1', '2026-05-02 02:14:12', b'0', NULL); INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`) VALUES (2207, 'IM 好友添加来源', 'im_friend_add_source', 0, NULL, '1', '2026-05-04 02:43:41', '1', '2026-05-04 02:43:41', b'0', NULL); INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`) VALUES (2208, 'IM 好友申请处理结果', 'im_friend_request_handle_result', 0, NULL, '1', '2026-05-04 02:43:41', '1', '2026-05-04 02:43:41', b'0', NULL); diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/group/ImGroupMessageManagerRespVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/group/ImGroupMessageManagerRespVO.java index 836b3b3..a231a0e 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/group/ImGroupMessageManagerRespVO.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/group/ImGroupMessageManagerRespVO.java @@ -43,7 +43,7 @@ public class ImGroupMessageManagerRespVO { private List atUserNicknames; @Schema(description = "回执状态", example = "0") - private Integer receiptStatus; // 参见 ImGroupMessageReceiptStatusEnum 枚举类 + private Integer receiptStatus; // 参见 ImMessageReceiptStatusEnum 枚举类 @Schema(description = "发送时间", requiredMode = Schema.RequiredMode.REQUIRED) private LocalDateTime sendTime; diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/privates/ImPrivateMessageManagerRespVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/privates/ImPrivateMessageManagerRespVO.java index c467a0a..895aded 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/privates/ImPrivateMessageManagerRespVO.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/message/vo/privates/ImPrivateMessageManagerRespVO.java @@ -36,6 +36,9 @@ public class ImPrivateMessageManagerRespVO { @Schema(description = "消息状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") private Integer status; // 参见 ImMessageStatusEnum 枚举类 + @Schema(description = "回执状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer receiptStatus; // 参见 ImMessageReceiptStatusEnum 枚举类 + @Schema(description = "发送时间", requiredMode = Schema.RequiredMode.REQUIRED) private LocalDateTime sendTime; diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImChannelMessageController.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImChannelMessageController.java index 08e557a..4b5eecc 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImChannelMessageController.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImChannelMessageController.java @@ -5,7 +5,7 @@ import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.im.controller.admin.message.vo.channel.ImChannelMessagePullRespVO; import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO; -import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum; import cn.iocoder.yudao.module.im.service.message.ImChannelMessageService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -51,14 +51,14 @@ public class ImChannelMessageController { if (CollUtil.isEmpty(list)) { return success(Collections.emptyList()); } - // 2. 按 Redis 已读游标补 status;device A 已读后 device B 拉到这条不再算入未读 + // 2. 按已读游标补 receiptStatus(已读 DONE / 未读 PENDING) Map readMaxByChannel = channelMessageService.getChannelReadMaxMessageIdMap( userId, convertSet(list, ImChannelMessageDO::getChannelId)); return success(BeanUtils.toBean(list, ImChannelMessagePullRespVO.class, vo -> { Long readMax = readMaxByChannel.get(vo.getChannelId()); - vo.setStatus(readMax != null && readMax >= vo.getId() - ? ImMessageStatusEnum.READ.getStatus() - : ImMessageStatusEnum.UNREAD.getStatus()); + vo.setReceiptStatus(readMax != null && readMax >= vo.getId() + ? ImMessageReceiptStatusEnum.DONE.getStatus() + : ImMessageReceiptStatusEnum.PENDING.getStatus()); })); } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java index d375ec7..277dc97 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java @@ -61,7 +61,7 @@ public class ImPrivateMessageController { @GetMapping("/max-read-message-id") @Operation(summary = "查询对方已读到我发的最大消息 id", - description = "用于多端 / 离线场景下的已读位置补齐:进入会话或断线重连后调用,结果用于翻转本地自发消息状态") + description = "用于多端 / 离线场景下的已读位置补齐:进入会话或断线重连后调用,据此把本地自发消息更新为已读") @Parameter(name = "peerId", description = "对方用户编号", required = true, example = "2") public CommonResult getMaxReadMessageId(@RequestParam("peerId") Long peerId) { return success(privateMessageService.getMaxReadMessageId(getLoginUserId(), peerId)); diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/channel/ImChannelMessagePullRespVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/channel/ImChannelMessagePullRespVO.java index 41bbbf9..8a30fcb 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/channel/ImChannelMessagePullRespVO.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/channel/ImChannelMessagePullRespVO.java @@ -24,8 +24,8 @@ public class ImChannelMessagePullRespVO { @Schema(description = "消息内容;payload JSON 快照", requiredMode = Schema.RequiredMode.REQUIRED) private String content; - @Schema(description = "当前用户的已读态;按 Redis 游标计算填充", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") - private Integer status; // 参见 ImMessageStatusEnum 枚举类 + @Schema(description = "当前用户回执 / 已读态;按 Redis 读位置计算(已读 DONE,未读 PENDING)", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Integer receiptStatus; // 参见 ImMessageReceiptStatusEnum 枚举类 @Schema(description = "发送时间", requiredMode = Schema.RequiredMode.REQUIRED) private LocalDateTime sendTime; diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageRespVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageRespVO.java index 3484b12..9b62689 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageRespVO.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageRespVO.java @@ -31,7 +31,10 @@ public class ImPrivateMessageRespVO { private String content; @Schema(description = "消息状态", example = "0") - private Integer status; + private Integer status; // 参见 ImMessageStatusEnum 枚举类 + + @Schema(description = "回执状态", example = "0") + private Integer receiptStatus; // 参见 ImMessageReceiptStatusEnum 枚举类 @Schema(description = "发送时间") private LocalDateTime sendTime; diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java index d6c703f..860a61e 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/vo/privates/ImPrivateMessageSendReqVO.java @@ -33,6 +33,9 @@ public class ImPrivateMessageSendReqVO { @NotEmpty(message = "消息内容不能为空") private String content; + @Schema(description = "是否需要回执", example = "false") + private Boolean receipt; + /** * 仅允许用户消息(normal)类型 */ diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImGroupMessageDO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImGroupMessageDO.java index f6c3d60..f68ee07 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImGroupMessageDO.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImGroupMessageDO.java @@ -3,7 +3,7 @@ package cn.iocoder.yudao.module.im.dal.dataobject.message; import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; import cn.iocoder.yudao.framework.mybatis.core.type.LongListTypeHandler; import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; -import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; import com.baomidou.mybatisplus.annotation.KeySequence; @@ -67,8 +67,6 @@ public class ImGroupMessageDO extends BaseDO { * 消息状态 *

* 枚举 {@link ImMessageStatusEnum} - * - * 为什么没有 READ 状态?与单聊的差异:单聊用 UNREAD/READ 跟踪每条消息状态,群聊用 Redis 存储每个成员的已读位置(游标模型) */ private Integer status; /** @@ -94,7 +92,7 @@ public class ImGroupMessageDO extends BaseDO { /** * 回执状态 *

- * 枚举 {@link ImGroupMessageReceiptStatusEnum} + * 枚举 {@link ImMessageReceiptStatusEnum} */ private Integer receiptStatus; diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImPrivateMessageDO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImPrivateMessageDO.java index bb87939..855b054 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImPrivateMessageDO.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImPrivateMessageDO.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.im.dal.dataobject.message; import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; import com.baomidou.mybatisplus.annotation.KeySequence; @@ -65,6 +66,12 @@ public class ImPrivateMessageDO extends BaseDO { */ private Integer status; /** + * 回执状态 + *

+ * 枚举 {@link ImMessageReceiptStatusEnum} + */ + private Integer receiptStatus; + /** * 发送时间 */ private LocalDateTime sendTime; diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java index e37165c..d0d5584 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapper.java @@ -7,7 +7,7 @@ import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils; import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.group.ImGroupMessageManagerPageReqVO; import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; -import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; import org.apache.ibatis.annotations.Mapper; @@ -84,7 +84,7 @@ public interface ImGroupMessageMapper extends BaseMapperX { default List selectListByGroupIdAndPendingReceipt(Long groupId, Long minId, Long maxId) { return selectList(new LambdaQueryWrapperX() .eq(ImGroupMessageDO::getGroupId, groupId) - .eq(ImGroupMessageDO::getReceiptStatus, ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) + .eq(ImGroupMessageDO::getReceiptStatus, ImMessageReceiptStatusEnum.PENDING.getStatus()) .gt(minId != null, ImGroupMessageDO::getId, minId) .le(ImGroupMessageDO::getId, maxId) .ne(ImGroupMessageDO::getStatus, ImMessageStatusEnum.RECALL.getStatus())); diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java index a33f29f..b666dfb 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapper.java @@ -67,23 +67,13 @@ public interface ImPrivateMessageMapper extends BaseMapperX .eq(ImPrivateMessageDO::getClientMessageId, clientMessageId)); } - default Long selectMaxIdBySenderIdAndReceiverIdAndStatus(Long senderId, Long receiverId, Integer status) { - ImPrivateMessageDO message = selectOne(new LambdaQueryWrapperX() - .eq(ImPrivateMessageDO::getSenderId, senderId) - .eq(ImPrivateMessageDO::getReceiverId, receiverId) - .eq(ImPrivateMessageDO::getStatus, status) - .orderByDesc(ImPrivateMessageDO::getId) - .last("LIMIT 1")); - return message != null ? message.getId() : null; - } - - default int updateBySenderIdAndReceiverIdAndIdLeAndStatus(Long senderId, Long receiverId, Long maxMessageId, - Integer whereStatus, ImPrivateMessageDO updateObj) { + default int updateBySenderIdAndReceiverIdAndIdLeAndReceiptStatus(Long senderId, Long receiverId, Long maxMessageId, + Integer whereReceiptStatus, ImPrivateMessageDO updateObj) { return update(updateObj, new LambdaQueryWrapperX() .eq(ImPrivateMessageDO::getSenderId, senderId) .eq(ImPrivateMessageDO::getReceiverId, receiverId) .le(ImPrivateMessageDO::getId, maxMessageId) - .eq(ImPrivateMessageDO::getStatus, whereStatus)); + .eq(ImPrivateMessageDO::getReceiptStatus, whereReceiptStatus)); } default PageResult selectPage(ImPrivateMessageManagerPageReqVO reqVO) { diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImGroupMessageReceiptStatusEnum.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImGroupMessageReceiptStatusEnum.java deleted file mode 100644 index 256560c..0000000 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImGroupMessageReceiptStatusEnum.java +++ /dev/null @@ -1,39 +0,0 @@ -package cn.iocoder.yudao.module.im.enums.message; - -import cn.iocoder.yudao.framework.common.core.ArrayValuable; -import lombok.AllArgsConstructor; -import lombok.Getter; - -import java.util.Arrays; - -/** - * IM 群消息回执状态枚举 - * - * @author 芋道源码 - */ -@Getter -@AllArgsConstructor -public enum ImGroupMessageReceiptStatusEnum implements ArrayValuable { - - NO_RECEIPT(0, "不需要回执"), - PENDING(1, "待完成"), - DONE(2, "已完成"); - - public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImGroupMessageReceiptStatusEnum::getStatus).toArray(Integer[]::new); - - /** - * 状态 - */ - private final Integer status; - - /** - * 名字 - */ - private final String name; - - @Override - public Integer[] array() { - return ARRAYS; - } - -} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageReceiptStatusEnum.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageReceiptStatusEnum.java new file mode 100644 index 0000000..41e3cf0 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageReceiptStatusEnum.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.im.enums.message; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * IM 消息回执状态枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum ImMessageReceiptStatusEnum implements ArrayValuable { + + NO_RECEIPT(0, "不需要回执"), + PENDING(1, "待完成"), + DONE(2, "已完成"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageReceiptStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 状态 + */ + private final Integer status; + + /** + * 名字 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageStatusEnum.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageStatusEnum.java index 79cb8d8..537f95c 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageStatusEnum.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageStatusEnum.java @@ -8,9 +8,6 @@ import java.util.Arrays; /** * IM 消息状态枚举 - *

- * 私聊:SENDING(-1, 仅客户端) / UNREAD(0) / RECALL(2) / READ(3) - * 群聊:SENDING(-1, 仅客户端) / UNREAD(0, 作为正常状态) / RECALL(2) * * @author 芋道源码 */ @@ -19,9 +16,8 @@ import java.util.Arrays; public enum ImMessageStatusEnum implements ArrayValuable { SENDING(-1, "发送中"), // 仅客户端使用 - UNREAD(0, "未读"), // 私聊=未读,群聊=正常(初始状态) - RECALL(2, "已撤回"), - READ(3, "已读"); // 仅私聊使用;群聊已读通过 Redis 已读位置实现 + NORMAL(0, "正常"), // 私聊 / 群聊的正常(初始)状态 + RECALL(2, "已撤回"); public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageStatusEnum::getStatus).toArray(Integer[]::new); diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java index 7db34f8..102ff71 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java @@ -18,7 +18,7 @@ import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper; import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum; import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum; -import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; import cn.iocoder.yudao.module.im.framework.config.ImProperties; @@ -104,7 +104,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { reqVO.setContent(normalizeQuoteContent(reqVO, senderMember, receiverUserIds)); // 2.3 构建并保存消息;唯一键冲突时回查已存在消息返回 ImGroupMessageDO message = BeanUtils.toBean(reqVO, ImGroupMessageDO.class, m -> m - .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) + .setSenderId(senderId).setStatus(ImMessageStatusEnum.NORMAL.getStatus()).setSendTime(LocalDateTime.now()) .setReceiverUserIds(new ArrayList<>(receiverUserIds)) .setReceiptStatus(resolveReceiptStatus(reqVO.getReceipt()))); try { @@ -138,7 +138,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { ImGroupMessageDO message = new ImGroupMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) .setSenderId(senderId).setGroupId(dto.getGroupId()) .setType(dto.getType()).setContent(contentString) - .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()) + .setStatus(ImMessageStatusEnum.NORMAL.getStatus()).setSendTime(LocalDateTime.now()) .setAtUserIds(dto.getAtUserIds()).setReceiverUserIds(new ArrayList<>(receiverUserIds)) .setReceiptStatus(resolveReceiptStatus(dto.getReceipt())); // 1.3 按 type.persistent 决定是否入库 @@ -206,52 +206,35 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { // 2. 按 receiver_user_ids 快照过滤可见性,结果按 id 升序 List messages = groupMessageMapper.selectListByMinId(userId, groupIds, minId, minSendTime, size); - // 3. 按当前用户补齐:消息已读态、本人发送的回执消息的已读人数 - appendMessageStatusAndReceipt(userId, messages); + // 3. 补齐本人发送的回执消息的已读人数 + appendMessageReceipt(userId, messages); log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]", userId, minId, size, messages.size()); return messages; } /** - * 补全消息已读态和回执已读人数 + * 补全本人发送消息的回执已读人数(readCount) *

- * 1. 消息已读态(status):根据 im_conversation_read 读位置判断 READ / UNREAD,所有消息都补 - * 2. 回执已读人数(readCount):仅对本人发送、且需要回执(非 NO_RECEIPT)的消息,计算可见成员中的已读人数 + * 仅对本人发送、且需要回执(非 NO_RECEIPT)的消息,按 receiver_user_ids 快照 ∩ 当前有效成员 - 发送者 算已读人数 */ - @SuppressWarnings({"StatementWithEmptyBody", "DataFlowIssue"}) - private void appendMessageStatusAndReceipt(Long userId, List messages) { + @SuppressWarnings("DataFlowIssue") + private void appendMessageReceipt(Long userId, List messages) { if (CollUtil.isEmpty(messages)) { return; } - // 群已读关闭:不查读位置,status 保持 DB 原值(含撤回),readCount 不补齐 + // 群已读关闭:不补 readCount if (BooleanUtil.isFalse(imProperties.getMessage().isGroupReadEnabled())) { return; } - Map readMaxMessageIdsByGroup = new HashMap<>(); // 群 → 当前用户已读位置 Map> readPositionsByGroup = new HashMap<>(); // 群 → (用户 → 已读位置) Map> membersByGroup = new HashMap<>(); // 群 → 全部成员列表 for (ImGroupMessageDO message : messages) { - // 消息已读态(status):撤回 > 已读 > 未读 - Long groupId = message.getGroupId(); - long readMaxMessageId = readMaxMessageIdsByGroup.computeIfAbsent(groupId, gid -> { - Long readMaxMsgId = conversationReadService.getConversationReadMessageId( - userId, ImConversationTypeEnum.GROUP.getType(), gid); - return readMaxMsgId != null ? readMaxMsgId : -1L; - }); - if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { - // 保持撤回态 - } else if (readMaxMessageId >= message.getId()) { - message.setStatus(ImMessageStatusEnum.READ.getStatus()); - } else { - message.setStatus(ImMessageStatusEnum.UNREAD.getStatus()); - } - - // 回执已读人数(readCount):仅补本人发送、且需要回执(非 NO_RECEIPT)的消息 - // 读位置(status)所有消息都补;readCount 只服务「需要回执」的消息,不把已读位置和消息回执混为一谈 + // 仅补本人发送、且需要回执(非 NO_RECEIPT)的消息 if (ObjUtil.notEqual(message.getSenderId(), userId) - || ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus().equals(message.getReceiptStatus())) { + || ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus().equals(message.getReceiptStatus())) { continue; } + Long groupId = message.getGroupId(); Map positions = readPositionsByGroup.computeIfAbsent(groupId, gid -> conversationReadService.getUserReadMessageIdMap(ImConversationTypeEnum.GROUP.getType(), gid)); // 应读分母取当前有效成员(剔除已退群),与异步回执刷新、前端已读弹层口径一致 @@ -385,9 +368,9 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { uid -> allPositions.getOrDefault(uid, -1L) >= message.getId() ? 1 : null, Integer::sum, 0); // 2.1.2 全部已读 → 标记回执完成 - Integer newReceiptStatus = ImGroupMessageReceiptStatusEnum.PENDING.getStatus(); + Integer newReceiptStatus = ImMessageReceiptStatusEnum.PENDING.getStatus(); if (readCount >= receiverUserIds.size()) { - newReceiptStatus = ImGroupMessageReceiptStatusEnum.DONE.getStatus(); + newReceiptStatus = ImMessageReceiptStatusEnum.DONE.getStatus(); groupMessageMapper.updateById(new ImGroupMessageDO().setId(message.getId()) .setReceiptStatus(newReceiptStatus)); } @@ -534,11 +517,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { */ private Integer resolveReceiptStatus(Boolean receipt) { if (BooleanUtil.isFalse(imProperties.getMessage().isGroupReadEnabled())) { - return ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(); + return ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus(); } return BooleanUtil.isTrue(receipt) - ? ImGroupMessageReceiptStatusEnum.PENDING.getStatus() - : ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(); + ? ImMessageReceiptStatusEnum.PENDING.getStatus() + : ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus(); } /** diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java index b16257f..23ac9b3 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java @@ -59,8 +59,8 @@ public interface ImPrivateMessageService { /** * 标记私聊消息已读 *

- * 语义:将「对方发给当前用户、id <= messageId 的未读消息」一次性翻转为已读, - * 与群聊 readGroupMessages 对称,避免"select-then-update"两步式带来的竞态。 + * 语义:把「对方发给当前用户、id <= messageId 的待回执消息」一次性更新为已完成(DONE)并前进读位置, + * 与群聊 readGroupMessages 对称,避免「select-then-update」两步式带来的竞态。 * * @param userId 当前用户编号 * @param receiverId 接收方用户编号(对方) @@ -72,7 +72,7 @@ public interface ImPrivateMessageService { * 查询对方已读到我发的最大消息 id *

* 用于多端 / 离线场景下的已读位置补齐:客户端进入会话或断线重连后, - * 调用此接口拿到对方的 maxReadId,再按 id <= maxReadId 翻转本地自发消息为已读,弥补离线期间错过的 RECEIPT 推送事件。 + * 调用此接口拿到对方的 maxReadId,再按 id <= maxReadId 更新本地自发消息的回执状态,弥补离线期间错过的 RECEIPT 推送事件。 * * @param userId 当前用户编号 * @param peerId 对方用户编号 diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java index d04f8ba..8b5af38 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java @@ -13,6 +13,7 @@ import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivate import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper; import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; import cn.iocoder.yudao.module.im.framework.config.ImProperties; @@ -86,8 +87,11 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { // 2.1 引用 quote 消息规范化 reqVO.setContent(normalizeQuoteContent(reqVO, senderId)); // 2.2 构建并保存消息;唯一键冲突时回查已存在消息返回 + // 用户私聊消息默认需要回执(receipt 不传按 true);系统通知走 DTO 通道,默认不回执 + Boolean receipt = reqVO.getReceipt() != null ? reqVO.getReceipt() : Boolean.TRUE; ImPrivateMessageDO message = BeanUtils.toBean(reqVO, ImPrivateMessageDO.class, m -> m - .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now())); + .setSenderId(senderId).setStatus(ImMessageStatusEnum.NORMAL.getStatus()) + .setReceiptStatus(resolveReceiptStatus(receipt)).setSendTime(LocalDateTime.now())); try { privateMessageMapper.insert(message); } catch (DuplicateKeyException e) { @@ -114,7 +118,8 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { ImPrivateMessageDO message = new ImPrivateMessageDO().setClientMessageId(IdUtil.fastSimpleUUID()) .setSenderId(senderId).setReceiverId(dto.getReceiverId()) .setType(dto.getType()).setContent(contentString) - .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()); + .setStatus(ImMessageStatusEnum.NORMAL.getStatus()) + .setReceiptStatus(resolveReceiptStatus(dto.getReceipt())).setSendTime(LocalDateTime.now()); // 1.3 决定是否持久化:dto.persistent 优先;为 null 时按 type 默认 boolean persistent = dto.getPersistent() != null ? dto.getPersistent() @@ -132,6 +137,18 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { return message; } + /** + * 计算私聊消息回执 status:私聊已读关闭时强制 NO_RECEIPT,忽略发送方传入的 receipt(receipt 为 null 等价 false) + */ + private Integer resolveReceiptStatus(Boolean receipt) { + if (BooleanUtil.isFalse(imProperties.getMessage().isPrivateReadEnabled())) { + return ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus(); + } + return BooleanUtil.isTrue(receipt) + ? ImMessageReceiptStatusEnum.PENDING.getStatus() + : ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus(); + } + @Override @Transactional(rollbackFor = Exception.class) public ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId) { @@ -223,11 +240,11 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { } Assert.notNull(messageId, "已读消息编号不能为空"); - // 2. status 双写:把 (receiverId → userId) 上 id <= messageId 的未读消息置为已读 - // 仅 UNREAD 行被命中,避免覆盖已撤回/已读的状态;select-then-update 合成单条 SQL 后也消除了竞态窗口 - privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus( - receiverId, userId, messageId, ImMessageStatusEnum.UNREAD.getStatus(), - new ImPrivateMessageDO().setStatus(ImMessageStatusEnum.READ.getStatus())); + // 2. 回执置 DONE:把 (receiverId → userId) 上 id <= messageId、待回执(PENDING) 的消息标记为已完成 + // status 不再表达已读(保持 NORMAL);只翻 PENDING 行,避免覆盖 NO_RECEIPT / 已 DONE + privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndReceiptStatus( + receiverId, userId, messageId, ImMessageReceiptStatusEnum.PENDING.getStatus(), + new ImPrivateMessageDO().setReceiptStatus(ImMessageReceiptStatusEnum.DONE.getStatus())); // 3. 同步写 im_conversation_read(读位置唯一权威,单调递增);读位置前进才下发事件 boolean advanced = conversationReadService.updateConversationReadPosition( @@ -248,8 +265,9 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService { if (BooleanUtil.isFalse(imProperties.getMessage().isPrivateReadEnabled())) { throw exception(MESSAGE_PRIVATE_READ_DISABLED); } - return privateMessageMapper.selectMaxIdBySenderIdAndReceiverIdAndStatus( - userId, peerId, ImMessageStatusEnum.READ.getStatus()); + // 对端 peer 在「与 userId 的会话」里的读位置 = peer 把 userId 发的消息读到哪 + return conversationReadService.getConversationReadMessageId( + peerId, ImConversationTypeEnum.PRIVATE.getType(), userId); } @Override diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImPrivateMessageSendDTO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImPrivateMessageSendDTO.java index f1c5375..5f8b025 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImPrivateMessageSendDTO.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImPrivateMessageSendDTO.java @@ -35,5 +35,9 @@ public class ImPrivateMessageSendDTO { * true:覆盖为双向 + 入库 */ private Boolean persistent; + /** + * 是否需要回执;null 等价 false(系统消息默认不需要回执) + */ + private Boolean receipt; } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java index 7c708d9..823e637 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java @@ -60,6 +60,10 @@ public class ImPrivateMessageDTO { */ private Integer status; /** + * 回执状态 + */ + private Integer receiptStatus; + /** * 发送时间 */ private LocalDateTime sendTime; diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java index 3fd7554..5dcee93 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImGroupMessageMapperTest.java @@ -2,7 +2,7 @@ package cn.iocoder.yudao.module.im.dal.mysql.message; import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; -import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; @@ -29,11 +29,11 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { @Test public void testSelectListByMinId_filterByReceiverSnapshot() { // 准备:群 10 三条消息,快照分别命中 / 不命中用户 1 - ImGroupMessageDO visible = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L, 2L)); + ImGroupMessageDO visible = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L, 2L)); mapper.insert(visible); - ImGroupMessageDO invisible = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(2L, 3L)); + ImGroupMessageDO invisible = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(2L, 3L)); mapper.insert(invisible); - ImGroupMessageDO selfSend = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD, List.of(1L, 2L)); + ImGroupMessageDO selfSend = buildMessage(10L, 1L, ImMessageStatusEnum.NORMAL, List.of(1L, 2L)); mapper.insert(selfSend); // 调用:用户 1 拉取群 10 @@ -48,9 +48,9 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { @Test public void testSelectListByMinId_excludeNullAndEmptySnapshot() { // 准备:快照为 null / 空字符串的老数据,不应被任何用户拉到 - ImGroupMessageDO nullSnapshot = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, null); + ImGroupMessageDO nullSnapshot = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, null); mapper.insert(nullSnapshot); - ImGroupMessageDO emptySnapshot = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of()); + ImGroupMessageDO emptySnapshot = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of()); mapper.insert(emptySnapshot); List result = mapper.selectListByMinId(1L, List.of(10L), 0L, FAR_PAST, 100); @@ -61,9 +61,9 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { @Test public void testSelectListByMinId_onlyReturnsGroupsInIdList() { // 准备:群 10 + 群 20 各一条,用户 1 都在快照内 - ImGroupMessageDO msg10 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L, 2L)); + ImGroupMessageDO msg10 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L, 2L)); mapper.insert(msg10); - ImGroupMessageDO msg20 = buildMessage(20L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L, 2L)); + ImGroupMessageDO msg20 = buildMessage(20L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L, 2L)); mapper.insert(msg20); // 调用:候选群只含群 10 @@ -76,7 +76,7 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { @Test public void testSelectListByMinId_includesRecall() { // 准备:撤回消息也要返回,由客户端按 status 渲染「此消息已撤回」占位 - ImGroupMessageDO normal = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L)); + ImGroupMessageDO normal = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L)); mapper.insert(normal); ImGroupMessageDO recalled = buildMessage(10L, 2L, ImMessageStatusEnum.RECALL, List.of(1L)); mapper.insert(recalled); @@ -89,10 +89,10 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { @Test public void testSelectListByMinId_sendTimeWindow() { // 准备:一条在窗口内,一条在窗口外 - ImGroupMessageDO inWindow = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L)); + ImGroupMessageDO inWindow = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L)); inWindow.setSendTime(LocalDateTime.now().minusDays(1)); mapper.insert(inWindow); - ImGroupMessageDO outWindow = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L)); + ImGroupMessageDO outWindow = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L)); outWindow.setSendTime(LocalDateTime.now().minusDays(40)); mapper.insert(outWindow); @@ -106,11 +106,11 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { @Test public void testSelectListByMinId_sortAscLimit() { // 准备:插入 3 条可见消息 - ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L)); + ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L)); mapper.insert(m1); - ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L)); + ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L)); mapper.insert(m2); - ImGroupMessageDO m3 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L)); + ImGroupMessageDO m3 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L)); mapper.insert(m3); // 调用:size=2,返回 id 最小的 2 条 @@ -125,9 +125,9 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { @Test public void testSelectListByMinId_minIdExclusive() { // 准备:游标之前的消息不返回 - ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L)); + ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L)); mapper.insert(m1); - ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L)); + ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L)); mapper.insert(m2); List result = mapper.selectListByMinId(1L, List.of(10L), m1.getId(), FAR_PAST, 100); @@ -141,14 +141,14 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { @Test public void testSelectHistoryListByUser_filterByReceiverSnapshot() { // 准备:群 10 三条,用户 1 在前两条快照内、第三条定向给别人 - ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L, 2L)); + ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L, 2L)); mapper.insert(m1); - ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L, 2L)); + ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L, 2L)); mapper.insert(m2); - ImGroupMessageDO directedOther = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(2L, 3L)); + ImGroupMessageDO directedOther = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(2L, 3L)); mapper.insert(directedOther); // 别的群 - ImGroupMessageDO other = buildMessage(20L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L)); + ImGroupMessageDO other = buildMessage(20L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L)); mapper.insert(other); List result = mapper.selectHistoryListByUser(1L, 10L, null, 100); @@ -161,11 +161,11 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { @Test public void testSelectHistoryListByUser_maxIdCursor() { - ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L)); + ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L)); mapper.insert(m1); - ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L)); + ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L)); mapper.insert(m2); - ImGroupMessageDO m3 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L)); + ImGroupMessageDO m3 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L)); mapper.insert(m3); List result = mapper.selectHistoryListByUser(1L, 10L, m3.getId(), 100); @@ -188,7 +188,7 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest { .status(status.getStatus()) .sendTime(LocalDateTime.now()) .receiverUserIds(receiverUserIds) - .receiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()) + .receiptStatus(ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus()) .build(); } diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java index e67b3e5..3702bc4 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/message/ImPrivateMessageMapperTest.java @@ -31,13 +31,13 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest { @Test public void testSelectListByMinId() { // 准备:用户 1 发给用户 2 - ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL); mapper.insert(msg1); // 用户 2 发给用户 1 - ImPrivateMessageDO msg2 = buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD); + ImPrivateMessageDO msg2 = buildMessage(2L, 1L, ImMessageStatusEnum.NORMAL); mapper.insert(msg2); // 用户 3 发给用户 4(不相关) - ImPrivateMessageDO msg3 = buildMessage(3L, 4L, ImMessageStatusEnum.UNREAD); + ImPrivateMessageDO msg3 = buildMessage(3L, 4L, ImMessageStatusEnum.NORMAL); mapper.insert(msg3); // 调用:用户 1 从 id=0 拉取 @@ -52,9 +52,9 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest { @Test public void testSelectListByMinId_withMinId() { // 准备 - ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL); mapper.insert(msg1); - ImPrivateMessageDO msg2 = buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD); + ImPrivateMessageDO msg2 = buildMessage(2L, 1L, ImMessageStatusEnum.NORMAL); mapper.insert(msg2); // 调用:从 msg1.id 之后拉取 @@ -68,9 +68,9 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest { @Test public void testSelectListByMinId_limitSize() { // 准备:插入 3 条 - mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD)); - mapper.insert(buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD)); - mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD)); + mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL)); + mapper.insert(buildMessage(2L, 1L, ImMessageStatusEnum.NORMAL)); + mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL)); // 调用:limit 2 List result = mapper.selectListByMinId(1L, 0L, FAR_PAST, 2); @@ -82,10 +82,10 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest { @Test public void testSelectListByMinId_sendTimeFilter() { // 准备:一条落在窗口内,一条落在窗口外 - ImPrivateMessageDO newMsg = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + ImPrivateMessageDO newMsg = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL); newMsg.setSendTime(LocalDateTime.now().minusDays(1)); mapper.insert(newMsg); - ImPrivateMessageDO oldMsg = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + ImPrivateMessageDO oldMsg = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL); oldMsg.setSendTime(LocalDateTime.now().minusDays(40)); mapper.insert(oldMsg); @@ -103,12 +103,12 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest { @Test public void testSelectHistoryList_basic() { // 准备:用户 1 <-> 用户 2 的消息 - ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL); mapper.insert(msg1); - ImPrivateMessageDO msg2 = buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD); + ImPrivateMessageDO msg2 = buildMessage(2L, 1L, ImMessageStatusEnum.NORMAL); mapper.insert(msg2); // 用户 1 <-> 用户 3 的消息(不相关) - ImPrivateMessageDO msg3 = buildMessage(1L, 3L, ImMessageStatusEnum.UNREAD); + ImPrivateMessageDO msg3 = buildMessage(1L, 3L, ImMessageStatusEnum.NORMAL); mapper.insert(msg3); // 调用 @@ -122,7 +122,7 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest { @Test public void testSelectHistoryList_includeRecall() { // 准备 - ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL); mapper.insert(msg1); ImPrivateMessageDO msg2 = buildMessage(1L, 2L, ImMessageStatusEnum.RECALL); mapper.insert(msg2); @@ -139,11 +139,11 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest { @Test public void testSelectHistoryList_withMaxId() { // 准备 - ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL); mapper.insert(msg1); - ImPrivateMessageDO msg2 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + ImPrivateMessageDO msg2 = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL); mapper.insert(msg2); - ImPrivateMessageDO msg3 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD); + ImPrivateMessageDO msg3 = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL); mapper.insert(msg3); // 调用:从 msg3 往前拉 @@ -157,9 +157,9 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest { @Test public void testSelectHistoryList_limit() { // 准备 - mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD)); - mapper.insert(buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD)); - mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD)); + mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL)); + mapper.insert(buildMessage(2L, 1L, ImMessageStatusEnum.NORMAL)); + mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL)); // 调用:limit 2 List result = mapper.selectHistoryList(1L, 2L, null, 2); @@ -171,7 +171,7 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest { @Test public void testSelectHistoryList_bidirectional() { // 准备:验证双向查询 - ImPrivateMessageDO msg1 = buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD); // 对方发的 + ImPrivateMessageDO msg1 = buildMessage(2L, 1L, ImMessageStatusEnum.NORMAL); // 对方发的 mapper.insert(msg1); // 调用:以用户 1 的视角查 diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/statistics/ImStatisticsManagerMapperTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/statistics/ImStatisticsManagerMapperTest.java index 4808da4..d967aed 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/statistics/ImStatisticsManagerMapperTest.java +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/statistics/ImStatisticsManagerMapperTest.java @@ -245,7 +245,7 @@ public class ImStatisticsManagerMapperTest extends BaseDbUnitTest { .senderId(senderId).receiverId(receiverId) .type(ImMessageTypeEnum.TEXT.getType()) .content("{}") - .status(ImMessageStatusEnum.UNREAD.getStatus()) + .status(ImMessageStatusEnum.NORMAL.getStatus()) .sendTime(sendTime).build(); } @@ -255,7 +255,7 @@ public class ImStatisticsManagerMapperTest extends BaseDbUnitTest { .setSenderId(senderId).setGroupId(groupId) .setType(ImMessageTypeEnum.TEXT.getType()) .setContent("{}") - .setStatus(ImMessageStatusEnum.UNREAD.getStatus()) + .setStatus(ImMessageStatusEnum.NORMAL.getStatus()) .setSendTime(sendTime); } diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java index 112ea7e..2af9378 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImplTest.java @@ -12,7 +12,7 @@ import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper; import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum; import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum; -import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum; +import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; import cn.iocoder.yudao.module.im.framework.config.ImProperties; @@ -111,8 +111,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { assertNotNull(result); assertEquals(1L, result.getSenderId()); assertEquals(10L, result.getGroupId()); - assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.getStatus()); - assertEquals(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), result.getReceiptStatus()); + assertEquals(ImMessageStatusEnum.NORMAL.getStatus(), result.getStatus()); + assertEquals(ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), result.getReceiptStatus()); // 验证推送给 3 个群成员(含发送者自己,用于多端同步) verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection ids) -> @@ -209,7 +209,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L, 2L, 3L)); // 被引用原消息:定向给 {1,2}(对发送人 1 可见,但不覆盖广播受众 {1,2,3}) ImGroupMessageDO original = ImGroupMessageDO.builder().id(500L).groupId(10L).senderId(2L) - .status(ImMessageStatusEnum.UNREAD.getStatus()).receiverUserIds(List.of(1L, 2L)) + .status(ImMessageStatusEnum.NORMAL.getStatus()).receiverUserIds(List.of(1L, 2L)) .type(ImMessageTypeEnum.TEXT.getType()).content("{\"content\":\"密\"}") .sendTime(LocalDateTime.now()).build(); when(groupMessageMapper.selectById(500L)).thenReturn(original); @@ -235,7 +235,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member); when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L, 2L, 3L)); ImGroupMessageDO original = ImGroupMessageDO.builder().id(500L).groupId(10L).senderId(2L) - .status(ImMessageStatusEnum.UNREAD.getStatus()).receiverUserIds(List.of(1L, 2L, 3L)) + .status(ImMessageStatusEnum.NORMAL.getStatus()).receiverUserIds(List.of(1L, 2L, 3L)) .type(ImMessageTypeEnum.TEXT.getType()).content("{\"content\":\"公开\"}") .sendTime(LocalDateTime.now()).build(); when(groupMessageMapper.selectById(500L)).thenReturn(original); @@ -253,8 +253,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { // ========== pull 测试 ========== @Test - public void testPullMessages_pullSetsReadFromCursor() { - // 准备:当前在群 10,无退群;拉到两条消息,已读位置为 7 + public void testPullMessages_doesNotOverwriteStatus() { + // 准备:拉到两条消息;Phase 2 起 pull 不再按读位置覆盖 status,已读由前端按读位置判断 LocalDateTime now = LocalDateTime.now(); ImGroupMemberDO member = ImGroupMemberDO.builder() .groupId(10L).userId(1L) @@ -264,25 +264,22 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { ImGroupMessageDO low = ImGroupMessageDO.builder() .id(5L).groupId(10L).senderId(2L).receiverUserIds(List.of(1L)) - .status(ImMessageStatusEnum.UNREAD.getStatus()) + .status(ImMessageStatusEnum.NORMAL.getStatus()) .sendTime(now.minusHours(2)).build(); ImGroupMessageDO high = ImGroupMessageDO.builder() .id(10L).groupId(10L).senderId(2L).receiverUserIds(List.of(1L)) - .status(ImMessageStatusEnum.UNREAD.getStatus()) + .status(ImMessageStatusEnum.NORMAL.getStatus()) .sendTime(now.minusHours(1)).build(); when(groupMessageMapper.selectListByMinId(eq(1L), anyCollection(), eq(0L), any(LocalDateTime.class), eq(100))) .thenReturn(List.of(low, high)); - when(conversationReadService.getConversationReadMessageId(eq(1L), eq(ImConversationTypeEnum.GROUP.getType()), eq(10L))) - .thenReturn(7L); - List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); - // 断言:已读位置 7 → id<=7 为 READ,id>7 为 UNREAD + // 断言:status 保持 DB 原值(都 NORMAL),pull 不覆盖 assertEquals(2, result.size()); - assertEquals(ImMessageStatusEnum.READ.getStatus(), result.get(0).getStatus()); - assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.get(1).getStatus()); + assertEquals(ImMessageStatusEnum.NORMAL.getStatus(), result.get(0).getStatus()); + assertEquals(ImMessageStatusEnum.NORMAL.getStatus(), result.get(1).getStatus()); } @Test @@ -298,15 +295,13 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { ImGroupMessageDO receiptMsg = ImGroupMessageDO.builder() .id(100L).groupId(10L).senderId(1L) .receiverUserIds(List.of(1L, 2L, 3L)) - .status(ImMessageStatusEnum.UNREAD.getStatus()) - .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) + .status(ImMessageStatusEnum.NORMAL.getStatus()) + .receiptStatus(ImMessageReceiptStatusEnum.PENDING.getStatus()) .sendTime(now.minusHours(1)).build(); when(groupMessageMapper.selectListByMinId(eq(1L), anyCollection(), eq(0L), any(LocalDateTime.class), eq(100))) .thenReturn(List.of(receiptMsg)); - when(conversationReadService.getConversationReadMessageId(eq(1L), eq(ImConversationTypeEnum.GROUP.getType()), eq(10L))) - .thenReturn(100L); Map positions = new HashMap<>(); positions.put(1L, 100L); positions.put(2L, 100L); @@ -334,7 +329,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { @Test public void testPullMessages_noReceiptMessageSkipsReadCount() { - // 准备:本人发送但不需要回执(NO_RECEIPT)的消息——只补已读位置,不补 readCount + // 准备:本人发送但不需要回执(NO_RECEIPT)的消息——不补 readCount,status 也不被覆盖 LocalDateTime now = LocalDateTime.now(); ImGroupMemberDO member = ImGroupMemberDO.builder() .groupId(10L).userId(1L) @@ -345,20 +340,18 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { ImGroupMessageDO noReceiptMsg = ImGroupMessageDO.builder() .id(100L).groupId(10L).senderId(1L) .receiverUserIds(List.of(1L, 2L, 3L)) - .status(ImMessageStatusEnum.UNREAD.getStatus()) - .receiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus()) + .status(ImMessageStatusEnum.NORMAL.getStatus()) + .receiptStatus(ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus()) .sendTime(now.minusHours(1)).build(); when(groupMessageMapper.selectListByMinId(eq(1L), anyCollection(), eq(0L), any(LocalDateTime.class), eq(100))) .thenReturn(List.of(noReceiptMsg)); - when(conversationReadService.getConversationReadMessageId(eq(1L), eq(ImConversationTypeEnum.GROUP.getType()), eq(10L))) - .thenReturn(100L); List result = groupMessageService.pullGroupMessageList(1L, 0L, 100); - // 断言:读位置照常补(status=READ),但 NO_RECEIPT 不补 readCount,也不查 readCount 相关的读位置映射 + // 断言:status 保持 NORMAL(pull 不覆盖),NO_RECEIPT 不补 readCount,也不查 readCount 相关的读位置映射 assertEquals(1, result.size()); - assertEquals(ImMessageStatusEnum.READ.getStatus(), result.get(0).getStatus()); + assertEquals(ImMessageStatusEnum.NORMAL.getStatus(), result.get(0).getStatus()); assertNull(result.get(0).getReadCount()); verify(conversationReadService, never()).getUserReadMessageIdMap(anyInt(), anyLong()); } @@ -374,7 +367,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { // 准备:消息由用户 1 发送,刚发送 5 分钟内 ImGroupMessageDO message = ImGroupMessageDO.builder() .id(50L).senderId(1L).groupId(10L) - .status(ImMessageStatusEnum.UNREAD.getStatus()) + .status(ImMessageStatusEnum.NORMAL.getStatus()) .sendTime(LocalDateTime.now()).build(); when(groupMessageMapper.selectById(50L)).thenReturn(message); // 撤回前需要校验用户仍在群中 @@ -407,7 +400,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { // 准备 ImGroupMessageDO message = ImGroupMessageDO.builder() .id(50L).senderId(2L).groupId(10L) - .status(ImMessageStatusEnum.UNREAD.getStatus()) + .status(ImMessageStatusEnum.NORMAL.getStatus()) .sendTime(LocalDateTime.now()).build(); when(groupMessageMapper.selectById(50L)).thenReturn(message); when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( @@ -443,7 +436,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { // 准备:消息发送于 10 分钟前(超过 5 分钟窗口) ImGroupMessageDO message = ImGroupMessageDO.builder() .id(50L).senderId(1L).groupId(10L) - .status(ImMessageStatusEnum.UNREAD.getStatus()) + .status(ImMessageStatusEnum.NORMAL.getStatus()) .sendTime(LocalDateTime.now().minusMinutes(10)).build(); when(groupMessageMapper.selectById(50L)).thenReturn(message); when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn( @@ -569,7 +562,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO); // 断言:receipt=true → 回执状态为 PENDING - assertEquals(ImGroupMessageReceiptStatusEnum.PENDING.getStatus(), result.getReceiptStatus()); + assertEquals(ImMessageReceiptStatusEnum.PENDING.getStatus(), result.getReceiptStatus()); } } @@ -613,7 +606,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { // 准备:消息合法、可撤回时间内,但发送人已退群 ImGroupMessageDO message = ImGroupMessageDO.builder() .id(50L).senderId(1L).groupId(10L) - .status(ImMessageStatusEnum.UNREAD.getStatus()) + .status(ImMessageStatusEnum.NORMAL.getStatus()) .sendTime(LocalDateTime.now()).build(); when(groupMessageMapper.selectById(50L)).thenReturn(message); when(groupMemberService.validateMemberInGroup(10L, 1L)) @@ -706,7 +699,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO); - assertEquals(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), result.getReceiptStatus(), + assertEquals(ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), result.getReceiptStatus(), "群已读关闭时即使发送方传 receipt=true 也强制落 NO_RECEIPT"); } } @@ -762,8 +755,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { .id(100L).groupId(10L).senderId(5L) .receiverUserIds(List.of(5L, 1L, 2L)) .sendTime(LocalDateTime.now().minusMinutes(1)) - .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) - .status(ImMessageStatusEnum.UNREAD.getStatus()).build(); + .receiptStatus(ImMessageReceiptStatusEnum.PENDING.getStatus()) + .status(ImMessageStatusEnum.NORMAL.getStatus()).build(); when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 0L, 100L)) .thenReturn(List.of(pending)); List activeMembers = List.of( @@ -791,7 +784,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMessageDO.class); verify(groupMessageMapper).updateById(captor.capture()); assertEquals(100L, captor.getValue().getId()); - assertEquals(ImGroupMessageReceiptStatusEnum.DONE.getStatus(), captor.getValue().getReceiptStatus()); + assertEquals(ImMessageReceiptStatusEnum.DONE.getStatus(), captor.getValue().getReceiptStatus()); // 断言:给消息发送方(5)推送 RECEIPT 事件 verify(imWebSocketService).sendGroupMessageAsync(eq(5L), any(ImGroupMessageDTO.class)); } @@ -803,8 +796,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { .id(100L).groupId(10L).senderId(5L) .receiverUserIds(List.of(5L, 1L, 2L)) .sendTime(LocalDateTime.now().minusMinutes(1)) - .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus()) - .status(ImMessageStatusEnum.UNREAD.getStatus()).build(); + .receiptStatus(ImMessageReceiptStatusEnum.PENDING.getStatus()) + .status(ImMessageStatusEnum.NORMAL.getStatus()).build(); when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 0L, 100L)) .thenReturn(List.of(pending)); List activeMembers = List.of( @@ -910,8 +903,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { assertEquals(10L, message.getGroupId()); assertEquals(ImMessageTypeEnum.RECALL.getType(), message.getType()); assertEquals("{\"messageId\":50}", message.getContent()); - assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), message.getStatus()); - assertEquals(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), message.getReceiptStatus()); + assertEquals(ImMessageStatusEnum.NORMAL.getStatus(), message.getStatus()); + assertEquals(ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), message.getReceiptStatus()); assertNotNull(message.getClientMessageId()); assertNotNull(message.getSendTime()); } @@ -928,7 +921,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest { ArgumentCaptor captor = ArgumentCaptor.forClass(ImGroupMessageDO.class); verify(groupMessageMapper).insert(captor.capture()); - assertEquals(ImGroupMessageReceiptStatusEnum.PENDING.getStatus(), captor.getValue().getReceiptStatus()); + assertEquals(ImMessageReceiptStatusEnum.PENDING.getStatus(), captor.getValue().getReceiptStatus()); } @Test diff --git a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java index f3c2635..1d8c3b6 100644 --- a/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java +++ b/yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImplTest.java @@ -7,6 +7,7 @@ import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivate import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO; import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper; +import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; import cn.iocoder.yudao.module.im.framework.config.ImProperties; @@ -87,7 +88,9 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { assertEquals(1L, result.getSenderId()); assertEquals(2L, result.getReceiverId()); assertEquals(ImMessageTypeEnum.TEXT.getType(), result.getType()); - assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.getStatus()); + assertEquals(ImMessageStatusEnum.NORMAL.getStatus(), result.getStatus()); + assertEquals(ImMessageReceiptStatusEnum.PENDING.getStatus(), result.getReceiptStatus(), + "用户私聊消息默认需要回执(PENDING)"); assertNotNull(result.getSendTime()); // 验证调用 @@ -165,10 +168,10 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { @Test public void testReadMessages_success() { - // 准备:前端上报已读到 messageId=5;mapper 返回更新行数 2 表示有未读被翻转 - when(privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus( + // 准备:前端上报已读到 messageId=5;mapper 返回更新行数 2 表示有待回执消息被标记完成 + when(privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndReceiptStatus( eq(2L), eq(1L), eq(5L), - eq(ImMessageStatusEnum.UNREAD.getStatus()), any(ImPrivateMessageDO.class))) + eq(ImMessageReceiptStatusEnum.PENDING.getStatus()), any(ImPrivateMessageDO.class))) .thenReturn(2); // 读位置前进 → 才下发事件 when(conversationReadService.updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong())) @@ -177,10 +180,10 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { // 调用 privateMessageService.readPrivateMessages(1L, 2L, 5L); - // 断言:更新了消息状态 - verify(privateMessageMapper).updateBySenderIdAndReceiverIdAndIdLeAndStatus( + // 断言:把待回执(PENDING)消息标记为已完成(DONE);status 不再写 READ + verify(privateMessageMapper).updateBySenderIdAndReceiverIdAndIdLeAndReceiptStatus( eq(2L), eq(1L), eq(5L), - eq(ImMessageStatusEnum.UNREAD.getStatus()), any(ImPrivateMessageDO.class)); + eq(ImMessageReceiptStatusEnum.PENDING.getStatus()), any(ImPrivateMessageDO.class)); // 断言:发送了 READ + RECEIPT 事件,payload 字段正确 ArgumentCaptor userCaptor = ArgumentCaptor.forClass(Long.class); @@ -210,7 +213,7 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { // 准备 ImPrivateMessageDO message = ImPrivateMessageDO.builder() .id(10L).senderId(1L).receiverId(2L) - .status(ImMessageStatusEnum.UNREAD.getStatus()) + .status(ImMessageStatusEnum.NORMAL.getStatus()) .sendTime(LocalDateTime.now()).build(); // 刚发送,5 分钟内 when(privateMessageMapper.selectById(10L)).thenReturn(message); when(privateMessageMapper.updateById(any(ImPrivateMessageDO.class))).thenReturn(1); @@ -233,7 +236,7 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { // 准备 ImPrivateMessageDO message = ImPrivateMessageDO.builder() .id(10L).senderId(2L).receiverId(1L) - .status(ImMessageStatusEnum.UNREAD.getStatus()) + .status(ImMessageStatusEnum.NORMAL.getStatus()) .sendTime(LocalDateTime.now()).build(); when(privateMessageMapper.selectById(10L)).thenReturn(message); @@ -274,7 +277,7 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { // 准备:消息发送于 10 分钟前(超过 5 分钟窗口) ImPrivateMessageDO message = ImPrivateMessageDO.builder() .id(10L).senderId(1L).receiverId(2L) - .status(ImMessageStatusEnum.UNREAD.getStatus()) + .status(ImMessageStatusEnum.NORMAL.getStatus()) .sendTime(LocalDateTime.now().minusMinutes(10)).build(); when(privateMessageMapper.selectById(10L)).thenReturn(message); @@ -314,7 +317,7 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { () -> privateMessageService.readPrivateMessages(1L, 2L, 5L)); assertEquals(MESSAGE_PRIVATE_READ_DISABLED.getCode(), exception.getCode()); // 断言:不更新消息状态、不推送 - verify(privateMessageMapper, never()).updateBySenderIdAndReceiverIdAndIdLeAndStatus( + verify(privateMessageMapper, never()).updateBySenderIdAndReceiverIdAndIdLeAndReceiptStatus( anyLong(), anyLong(), anyLong(), anyInt(), any(ImPrivateMessageDO.class)); verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class)); } @@ -336,9 +339,9 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { @Test public void testGetMaxReadMessageId_hit() { - // 准备:对方读到我发的最大 id=10 - when(privateMessageMapper.selectMaxIdBySenderIdAndReceiverIdAndStatus( - 1L, 2L, ImMessageStatusEnum.READ.getStatus())).thenReturn(10L); + // 准备:对方(2) 在与我(1) 的会话里读位置=10 + when(conversationReadService.getConversationReadMessageId(eq(2L), anyInt(), eq(1L))) + .thenReturn(10L); // 调用 Long result = privateMessageService.getMaxReadMessageId(1L, 2L); @@ -349,9 +352,9 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { @Test public void testGetMaxReadMessageId_miss() { - // 准备:对方一条都没读过 - when(privateMessageMapper.selectMaxIdBySenderIdAndReceiverIdAndStatus( - 1L, 2L, ImMessageStatusEnum.READ.getStatus())).thenReturn(null); + // 准备:对方一条都没读过(读位置为 null) + when(conversationReadService.getConversationReadMessageId(eq(2L), anyInt(), eq(1L))) + .thenReturn(null); // 调用 Long result = privateMessageService.getMaxReadMessageId(1L, 2L); @@ -390,7 +393,7 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest { assertEquals(2L, message.getReceiverId()); assertEquals(ImMessageTypeEnum.RECALL.getType(), message.getType()); assertEquals("{\"messageId\":50}", message.getContent()); - assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), message.getStatus()); + assertEquals(ImMessageStatusEnum.NORMAL.getStatus(), message.getStatus()); assertNotNull(message.getClientMessageId()); assertNotNull(message.getSendTime()); // 断言:sender + receiver 双端推送 diff --git a/yudao-module-im/src/test/resources/sql/create_tables.sql b/yudao-module-im/src/test/resources/sql/create_tables.sql index 6e33f96..86579c4 100644 --- a/yudao-module-im/src/test/resources/sql/create_tables.sql +++ b/yudao-module-im/src/test/resources/sql/create_tables.sql @@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS "im_private_message" ( "type" smallint NOT NULL COMMENT '消息类型', "content" varchar(8192) DEFAULT NULL COMMENT '消息内容', "status" tinyint NOT NULL COMMENT '消息状态', + "receipt_status" tinyint NOT NULL DEFAULT 0 COMMENT '回执状态', "send_time" timestamp NOT NULL COMMENT '发送时间', "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, -- libgit2 0.21.4