Commit 927b64fc4e7dbc582c31ee80a15d610b832daff9

Authored by YunaiV
1 parent 4b27620e

feat(im): 统一消息读位置和回执状态模型

- 新增 im_conversation_read 会话读位置表,并补充消息存储推拉相关索引
- 群消息固化 receiver_user_ids 快照,按可见成员快照拉取和统计回执
- 统一消息 status 为 NORMAL/RECALL,新增私聊 receipt_status 并复用统一回执状态
- 前端改用 receiptStatus 展示私聊已读、群回执和频道已读态
- 补齐私聊、群聊、频道 WebSocket 已读同步和离线补偿逻辑
- 更新 IM 消息状态、回执状态字典和管理后台展示
- 调整相关单测和测试建表脚本
Showing 25 changed files with 203 additions and 201 deletions
sql/mysql/ruoyi-vue-pro.sql
... ... @@ -1377,14 +1377,11 @@ INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `st
1377 1377 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');
1378 1378 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');
1379 1379 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');
1380   -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');
1381   -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');
1382   -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');
1383   -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');
1384   -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');
1385   -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');
1386   -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');
1387   -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');
  1380 +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');
  1381 +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');
  1382 +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');
  1383 +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');
  1384 +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');
1388 1385 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');
1389 1386 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');
1390 1387 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
1693 1690 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);
1694 1691 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');
1695 1692 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);
1696   -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);
1697   -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);
  1693 +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);
1698 1694 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);
1699 1695 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);
1700   -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);
  1696 +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);
1701 1697 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);
1702 1698 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);
1703 1699 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);
... ...
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 {
43 43 private List<String> atUserNicknames;
44 44  
45 45 @Schema(description = "回执状态", example = "0")
46   - private Integer receiptStatus; // 参见 ImGroupMessageReceiptStatusEnum 枚举类
  46 + private Integer receiptStatus; // 参见 ImMessageReceiptStatusEnum 枚举类
47 47  
48 48 @Schema(description = "发送时间", requiredMode = Schema.RequiredMode.REQUIRED)
49 49 private LocalDateTime sendTime;
... ...
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 {
36 36 @Schema(description = "消息状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
37 37 private Integer status; // 参见 ImMessageStatusEnum 枚举类
38 38  
  39 + @Schema(description = "回执状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
  40 + private Integer receiptStatus; // 参见 ImMessageReceiptStatusEnum 枚举类
  41 +
39 42 @Schema(description = "发送时间", requiredMode = Schema.RequiredMode.REQUIRED)
40 43 private LocalDateTime sendTime;
41 44  
... ...
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;
5 5 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
6 6 import cn.iocoder.yudao.module.im.controller.admin.message.vo.channel.ImChannelMessagePullRespVO;
7 7 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO;
8   -import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
  8 +import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum;
9 9 import cn.iocoder.yudao.module.im.service.message.ImChannelMessageService;
10 10 import io.swagger.v3.oas.annotations.Operation;
11 11 import io.swagger.v3.oas.annotations.Parameter;
... ... @@ -51,14 +51,14 @@ public class ImChannelMessageController {
51 51 if (CollUtil.isEmpty(list)) {
52 52 return success(Collections.emptyList());
53 53 }
54   - // 2. 按 Redis 已读游标补 status;device A 已读后 device B 拉到这条不再算入未读
  54 + // 2. 按已读游标补 receiptStatus(已读 DONE / 未读 PENDING)
55 55 Map<Long, Long> readMaxByChannel = channelMessageService.getChannelReadMaxMessageIdMap(
56 56 userId, convertSet(list, ImChannelMessageDO::getChannelId));
57 57 return success(BeanUtils.toBean(list, ImChannelMessagePullRespVO.class, vo -> {
58 58 Long readMax = readMaxByChannel.get(vo.getChannelId());
59   - vo.setStatus(readMax != null && readMax >= vo.getId()
60   - ? ImMessageStatusEnum.READ.getStatus()
61   - : ImMessageStatusEnum.UNREAD.getStatus());
  59 + vo.setReceiptStatus(readMax != null && readMax >= vo.getId()
  60 + ? ImMessageReceiptStatusEnum.DONE.getStatus()
  61 + : ImMessageReceiptStatusEnum.PENDING.getStatus());
62 62 }));
63 63 }
64 64  
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/message/ImPrivateMessageController.java
... ... @@ -61,7 +61,7 @@ public class ImPrivateMessageController {
61 61  
62 62 @GetMapping("/max-read-message-id")
63 63 @Operation(summary = "查询对方已读到我发的最大消息 id",
64   - description = "用于多端 / 离线场景下的已读位置补齐:进入会话或断线重连后调用,结果用于翻转本地自发消息状态")
  64 + description = "用于多端 / 离线场景下的已读位置补齐:进入会话或断线重连后调用,据此把本地自发消息更新为已读")
65 65 @Parameter(name = "peerId", description = "对方用户编号", required = true, example = "2")
66 66 public CommonResult<Long> getMaxReadMessageId(@RequestParam("peerId") Long peerId) {
67 67 return success(privateMessageService.getMaxReadMessageId(getLoginUserId(), peerId));
... ...
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 {
24 24 @Schema(description = "消息内容;payload JSON 快照", requiredMode = Schema.RequiredMode.REQUIRED)
25 25 private String content;
26 26  
27   - @Schema(description = "当前用户的已读态;按 Redis 游标计算填充", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
28   - private Integer status; // 参见 ImMessageStatusEnum 枚举类
  27 + @Schema(description = "当前用户回执 / 已读态;按 Redis 读位置计算(已读 DONE,未读 PENDING)", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
  28 + private Integer receiptStatus; // 参见 ImMessageReceiptStatusEnum 枚举类
29 29  
30 30 @Schema(description = "发送时间", requiredMode = Schema.RequiredMode.REQUIRED)
31 31 private LocalDateTime sendTime;
... ...
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 {
31 31 private String content;
32 32  
33 33 @Schema(description = "消息状态", example = "0")
34   - private Integer status;
  34 + private Integer status; // 参见 ImMessageStatusEnum 枚举类
  35 +
  36 + @Schema(description = "回执状态", example = "0")
  37 + private Integer receiptStatus; // 参见 ImMessageReceiptStatusEnum 枚举类
35 38  
36 39 @Schema(description = "发送时间")
37 40 private LocalDateTime sendTime;
... ...
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 {
33 33 @NotEmpty(message = "消息内容不能为空")
34 34 private String content;
35 35  
  36 + @Schema(description = "是否需要回执", example = "false")
  37 + private Boolean receipt;
  38 +
36 39 /**
37 40 * 仅允许用户消息(normal)类型
38 41 */
... ...
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;
3 3 import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
4 4 import cn.iocoder.yudao.framework.mybatis.core.type.LongListTypeHandler;
5 5 import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO;
6   -import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
  6 +import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum;
7 7 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
8 8 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
9 9 import com.baomidou.mybatisplus.annotation.KeySequence;
... ... @@ -67,8 +67,6 @@ public class ImGroupMessageDO extends BaseDO {
67 67 * 消息状态
68 68 * <p>
69 69 * 枚举 {@link ImMessageStatusEnum}
70   - *
71   - * 为什么没有 READ 状态?与单聊的差异:单聊用 UNREAD/READ 跟踪每条消息状态,群聊用 Redis 存储每个成员的已读位置(游标模型)
72 70 */
73 71 private Integer status;
74 72 /**
... ... @@ -94,7 +92,7 @@ public class ImGroupMessageDO extends BaseDO {
94 92 /**
95 93 * 回执状态
96 94 * <p>
97   - * 枚举 {@link ImGroupMessageReceiptStatusEnum}
  95 + * 枚举 {@link ImMessageReceiptStatusEnum}
98 96 */
99 97 private Integer receiptStatus;
100 98  
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/message/ImPrivateMessageDO.java
1 1 package cn.iocoder.yudao.module.im.dal.dataobject.message;
2 2  
3 3 import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
  4 +import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum;
4 5 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
5 6 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
6 7 import com.baomidou.mybatisplus.annotation.KeySequence;
... ... @@ -65,6 +66,12 @@ public class ImPrivateMessageDO extends BaseDO {
65 66 */
66 67 private Integer status;
67 68 /**
  69 + * 回执状态
  70 + * <p>
  71 + * 枚举 {@link ImMessageReceiptStatusEnum}
  72 + */
  73 + private Integer receiptStatus;
  74 + /**
68 75 * 发送时间
69 76 */
70 77 private LocalDateTime sendTime;
... ...
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;
7 7 import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
8 8 import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.group.ImGroupMessageManagerPageReqVO;
9 9 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
10   -import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
  10 +import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum;
11 11 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
12 12 import org.apache.ibatis.annotations.Mapper;
13 13  
... ... @@ -84,7 +84,7 @@ public interface ImGroupMessageMapper extends BaseMapperX&lt;ImGroupMessageDO&gt; {
84 84 default List<ImGroupMessageDO> selectListByGroupIdAndPendingReceipt(Long groupId, Long minId, Long maxId) {
85 85 return selectList(new LambdaQueryWrapperX<ImGroupMessageDO>()
86 86 .eq(ImGroupMessageDO::getGroupId, groupId)
87   - .eq(ImGroupMessageDO::getReceiptStatus, ImGroupMessageReceiptStatusEnum.PENDING.getStatus())
  87 + .eq(ImGroupMessageDO::getReceiptStatus, ImMessageReceiptStatusEnum.PENDING.getStatus())
88 88 .gt(minId != null, ImGroupMessageDO::getId, minId)
89 89 .le(ImGroupMessageDO::getId, maxId)
90 90 .ne(ImGroupMessageDO::getStatus, ImMessageStatusEnum.RECALL.getStatus()));
... ...
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&lt;ImPrivateMessageDO&gt;
67 67 .eq(ImPrivateMessageDO::getClientMessageId, clientMessageId));
68 68 }
69 69  
70   - default Long selectMaxIdBySenderIdAndReceiverIdAndStatus(Long senderId, Long receiverId, Integer status) {
71   - ImPrivateMessageDO message = selectOne(new LambdaQueryWrapperX<ImPrivateMessageDO>()
72   - .eq(ImPrivateMessageDO::getSenderId, senderId)
73   - .eq(ImPrivateMessageDO::getReceiverId, receiverId)
74   - .eq(ImPrivateMessageDO::getStatus, status)
75   - .orderByDesc(ImPrivateMessageDO::getId)
76   - .last("LIMIT 1"));
77   - return message != null ? message.getId() : null;
78   - }
79   -
80   - default int updateBySenderIdAndReceiverIdAndIdLeAndStatus(Long senderId, Long receiverId, Long maxMessageId,
81   - Integer whereStatus, ImPrivateMessageDO updateObj) {
  70 + default int updateBySenderIdAndReceiverIdAndIdLeAndReceiptStatus(Long senderId, Long receiverId, Long maxMessageId,
  71 + Integer whereReceiptStatus, ImPrivateMessageDO updateObj) {
82 72 return update(updateObj, new LambdaQueryWrapperX<ImPrivateMessageDO>()
83 73 .eq(ImPrivateMessageDO::getSenderId, senderId)
84 74 .eq(ImPrivateMessageDO::getReceiverId, receiverId)
85 75 .le(ImPrivateMessageDO::getId, maxMessageId)
86   - .eq(ImPrivateMessageDO::getStatus, whereStatus));
  76 + .eq(ImPrivateMessageDO::getReceiptStatus, whereReceiptStatus));
87 77 }
88 78  
89 79 default PageResult<ImPrivateMessageDO> selectPage(ImPrivateMessageManagerPageReqVO reqVO) {
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImGroupMessageReceiptStatusEnum.java renamed to yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageReceiptStatusEnum.java
... ... @@ -7,19 +7,19 @@ import lombok.Getter;
7 7 import java.util.Arrays;
8 8  
9 9 /**
10   - * IM 群消息回执状态枚举
  10 + * IM 消息回执状态枚举
11 11 *
12 12 * @author 芋道源码
13 13 */
14 14 @Getter
15 15 @AllArgsConstructor
16   -public enum ImGroupMessageReceiptStatusEnum implements ArrayValuable<Integer> {
  16 +public enum ImMessageReceiptStatusEnum implements ArrayValuable<Integer> {
17 17  
18 18 NO_RECEIPT(0, "不需要回执"),
19 19 PENDING(1, "待完成"),
20 20 DONE(2, "已完成");
21 21  
22   - public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImGroupMessageReceiptStatusEnum::getStatus).toArray(Integer[]::new);
  22 + public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageReceiptStatusEnum::getStatus).toArray(Integer[]::new);
23 23  
24 24 /**
25 25 * 状态
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageStatusEnum.java
... ... @@ -8,9 +8,6 @@ import java.util.Arrays;
8 8  
9 9 /**
10 10 * IM 消息状态枚举
11   - * <p>
12   - * 私聊:SENDING(-1, 仅客户端) / UNREAD(0) / RECALL(2) / READ(3)
13   - * 群聊:SENDING(-1, 仅客户端) / UNREAD(0, 作为正常状态) / RECALL(2)
14 11 *
15 12 * @author 芋道源码
16 13 */
... ... @@ -19,9 +16,8 @@ import java.util.Arrays;
19 16 public enum ImMessageStatusEnum implements ArrayValuable<Integer> {
20 17  
21 18 SENDING(-1, "发送中"), // 仅客户端使用
22   - UNREAD(0, "未读"), // 私聊=未读,群聊=正常(初始状态)
23   - RECALL(2, "已撤回"),
24   - READ(3, "已读"); // 仅私聊使用;群聊已读通过 Redis 已读位置实现
  19 + NORMAL(0, "正常"), // 私聊 / 群聊的正常(初始)状态
  20 + RECALL(2, "已撤回");
25 21  
26 22 public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageStatusEnum::getStatus).toArray(Integer[]::new);
27 23  
... ...
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;
18 18 import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper;
19 19 import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
20 20 import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum;
21   -import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
  21 +import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum;
22 22 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
23 23 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
24 24 import cn.iocoder.yudao.module.im.framework.config.ImProperties;
... ... @@ -104,7 +104,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
104 104 reqVO.setContent(normalizeQuoteContent(reqVO, senderMember, receiverUserIds));
105 105 // 2.3 构建并保存消息;唯一键冲突时回查已存在消息返回
106 106 ImGroupMessageDO message = BeanUtils.toBean(reqVO, ImGroupMessageDO.class, m -> m
107   - .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now())
  107 + .setSenderId(senderId).setStatus(ImMessageStatusEnum.NORMAL.getStatus()).setSendTime(LocalDateTime.now())
108 108 .setReceiverUserIds(new ArrayList<>(receiverUserIds))
109 109 .setReceiptStatus(resolveReceiptStatus(reqVO.getReceipt())));
110 110 try {
... ... @@ -138,7 +138,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
138 138 ImGroupMessageDO message = new ImGroupMessageDO().setClientMessageId(IdUtil.fastSimpleUUID())
139 139 .setSenderId(senderId).setGroupId(dto.getGroupId())
140 140 .setType(dto.getType()).setContent(contentString)
141   - .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now())
  141 + .setStatus(ImMessageStatusEnum.NORMAL.getStatus()).setSendTime(LocalDateTime.now())
142 142 .setAtUserIds(dto.getAtUserIds()).setReceiverUserIds(new ArrayList<>(receiverUserIds))
143 143 .setReceiptStatus(resolveReceiptStatus(dto.getReceipt()));
144 144 // 1.3 按 type.persistent 决定是否入库
... ... @@ -206,52 +206,35 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
206 206 // 2. 按 receiver_user_ids 快照过滤可见性,结果按 id 升序
207 207 List<ImGroupMessageDO> messages = groupMessageMapper.selectListByMinId(userId, groupIds, minId, minSendTime, size);
208 208  
209   - // 3. 按当前用户补齐:消息已读态、本人发送的回执消息的已读人数
210   - appendMessageStatusAndReceipt(userId, messages);
  209 + // 3. 补齐本人发送的回执消息的已读人数
  210 + appendMessageReceipt(userId, messages);
211 211 log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]", userId, minId, size, messages.size());
212 212 return messages;
213 213 }
214 214  
215 215 /**
216   - * 补全消息已读态和回执已读人数
  216 + * 补全本人发送消息的回执已读人数(readCount)
217 217 * <p>
218   - * 1. 消息已读态(status):根据 im_conversation_read 读位置判断 READ / UNREAD,所有消息都补
219   - * 2. 回执已读人数(readCount):仅对本人发送、且需要回执(非 NO_RECEIPT)的消息,计算可见成员中的已读人数
  218 + * 仅对本人发送、且需要回执(非 NO_RECEIPT)的消息,按 receiver_user_ids 快照 ∩ 当前有效成员 - 发送者 算已读人数
220 219 */
221   - @SuppressWarnings({"StatementWithEmptyBody", "DataFlowIssue"})
222   - private void appendMessageStatusAndReceipt(Long userId, List<ImGroupMessageDO> messages) {
  220 + @SuppressWarnings("DataFlowIssue")
  221 + private void appendMessageReceipt(Long userId, List<ImGroupMessageDO> messages) {
223 222 if (CollUtil.isEmpty(messages)) {
224 223 return;
225 224 }
226   - // 群已读关闭:不查读位置,status 保持 DB 原值(含撤回),readCount 不补齐
  225 + // 群已读关闭:不补 readCount
227 226 if (BooleanUtil.isFalse(imProperties.getMessage().isGroupReadEnabled())) {
228 227 return;
229 228 }
230   - Map<Long, Long> readMaxMessageIdsByGroup = new HashMap<>(); // 群 → 当前用户已读位置
231 229 Map<Long, Map<Long, Long>> readPositionsByGroup = new HashMap<>(); // 群 → (用户 → 已读位置)
232 230 Map<Long, List<ImGroupMemberDO>> membersByGroup = new HashMap<>(); // 群 → 全部成员列表
233 231 for (ImGroupMessageDO message : messages) {
234   - // 消息已读态(status):撤回 > 已读 > 未读
235   - Long groupId = message.getGroupId();
236   - long readMaxMessageId = readMaxMessageIdsByGroup.computeIfAbsent(groupId, gid -> {
237   - Long readMaxMsgId = conversationReadService.getConversationReadMessageId(
238   - userId, ImConversationTypeEnum.GROUP.getType(), gid);
239   - return readMaxMsgId != null ? readMaxMsgId : -1L;
240   - });
241   - if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) {
242   - // 保持撤回态
243   - } else if (readMaxMessageId >= message.getId()) {
244   - message.setStatus(ImMessageStatusEnum.READ.getStatus());
245   - } else {
246   - message.setStatus(ImMessageStatusEnum.UNREAD.getStatus());
247   - }
248   -
249   - // 回执已读人数(readCount):仅补本人发送、且需要回执(非 NO_RECEIPT)的消息
250   - // 读位置(status)所有消息都补;readCount 只服务「需要回执」的消息,不把已读位置和消息回执混为一谈
  232 + // 仅补本人发送、且需要回执(非 NO_RECEIPT)的消息
251 233 if (ObjUtil.notEqual(message.getSenderId(), userId)
252   - || ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus().equals(message.getReceiptStatus())) {
  234 + || ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus().equals(message.getReceiptStatus())) {
253 235 continue;
254 236 }
  237 + Long groupId = message.getGroupId();
255 238 Map<Long, Long> positions = readPositionsByGroup.computeIfAbsent(groupId,
256 239 gid -> conversationReadService.getUserReadMessageIdMap(ImConversationTypeEnum.GROUP.getType(), gid));
257 240 // 应读分母取当前有效成员(剔除已退群),与异步回执刷新、前端已读弹层口径一致
... ... @@ -385,9 +368,9 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
385 368 uid -> allPositions.getOrDefault(uid, -1L) >= message.getId() ? 1 : null,
386 369 Integer::sum, 0);
387 370 // 2.1.2 全部已读 → 标记回执完成
388   - Integer newReceiptStatus = ImGroupMessageReceiptStatusEnum.PENDING.getStatus();
  371 + Integer newReceiptStatus = ImMessageReceiptStatusEnum.PENDING.getStatus();
389 372 if (readCount >= receiverUserIds.size()) {
390   - newReceiptStatus = ImGroupMessageReceiptStatusEnum.DONE.getStatus();
  373 + newReceiptStatus = ImMessageReceiptStatusEnum.DONE.getStatus();
391 374 groupMessageMapper.updateById(new ImGroupMessageDO().setId(message.getId())
392 375 .setReceiptStatus(newReceiptStatus));
393 376 }
... ... @@ -534,11 +517,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
534 517 */
535 518 private Integer resolveReceiptStatus(Boolean receipt) {
536 519 if (BooleanUtil.isFalse(imProperties.getMessage().isGroupReadEnabled())) {
537   - return ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus();
  520 + return ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus();
538 521 }
539 522 return BooleanUtil.isTrue(receipt)
540   - ? ImGroupMessageReceiptStatusEnum.PENDING.getStatus()
541   - : ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus();
  523 + ? ImMessageReceiptStatusEnum.PENDING.getStatus()
  524 + : ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus();
542 525 }
543 526  
544 527 /**
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageService.java
... ... @@ -59,8 +59,8 @@ public interface ImPrivateMessageService {
59 59 /**
60 60 * 标记私聊消息已读
61 61 * <p>
62   - * 语义:将「对方发给当前用户、id <= messageId 的未读消息」一次性翻转为已读,
63   - * 与群聊 readGroupMessages 对称,避免"select-then-update"两步式带来的竞态。
  62 + * 语义:把「对方发给当前用户、id <= messageId 的待回执消息」一次性更新为已完成(DONE)并前进读位置,
  63 + * 与群聊 readGroupMessages 对称,避免「select-then-update」两步式带来的竞态。
64 64 *
65 65 * @param userId 当前用户编号
66 66 * @param receiverId 接收方用户编号(对方)
... ... @@ -72,7 +72,7 @@ public interface ImPrivateMessageService {
72 72 * 查询对方已读到我发的最大消息 id
73 73 * <p>
74 74 * 用于多端 / 离线场景下的已读位置补齐:客户端进入会话或断线重连后,
75   - * 调用此接口拿到对方的 maxReadId,再按 id <= maxReadId 翻转本地自发消息为已读,弥补离线期间错过的 RECEIPT 推送事件。
  75 + * 调用此接口拿到对方的 maxReadId,再按 id <= maxReadId 更新本地自发消息的回执状态,弥补离线期间错过的 RECEIPT 推送事件。
76 76 *
77 77 * @param userId 当前用户编号
78 78 * @param peerId 对方用户编号
... ...
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
13 13 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
14 14 import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper;
15 15 import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
  16 +import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum;
16 17 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
17 18 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
18 19 import cn.iocoder.yudao.module.im.framework.config.ImProperties;
... ... @@ -86,8 +87,11 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
86 87 // 2.1 引用 quote 消息规范化
87 88 reqVO.setContent(normalizeQuoteContent(reqVO, senderId));
88 89 // 2.2 构建并保存消息;唯一键冲突时回查已存在消息返回
  90 + // 用户私聊消息默认需要回执(receipt 不传按 true);系统通知走 DTO 通道,默认不回执
  91 + Boolean receipt = reqVO.getReceipt() != null ? reqVO.getReceipt() : Boolean.TRUE;
89 92 ImPrivateMessageDO message = BeanUtils.toBean(reqVO, ImPrivateMessageDO.class, m -> m
90   - .setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()));
  93 + .setSenderId(senderId).setStatus(ImMessageStatusEnum.NORMAL.getStatus())
  94 + .setReceiptStatus(resolveReceiptStatus(receipt)).setSendTime(LocalDateTime.now()));
91 95 try {
92 96 privateMessageMapper.insert(message);
93 97 } catch (DuplicateKeyException e) {
... ... @@ -114,7 +118,8 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
114 118 ImPrivateMessageDO message = new ImPrivateMessageDO().setClientMessageId(IdUtil.fastSimpleUUID())
115 119 .setSenderId(senderId).setReceiverId(dto.getReceiverId())
116 120 .setType(dto.getType()).setContent(contentString)
117   - .setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now());
  121 + .setStatus(ImMessageStatusEnum.NORMAL.getStatus())
  122 + .setReceiptStatus(resolveReceiptStatus(dto.getReceipt())).setSendTime(LocalDateTime.now());
118 123 // 1.3 决定是否持久化:dto.persistent 优先;为 null 时按 type 默认
119 124 boolean persistent = dto.getPersistent() != null
120 125 ? dto.getPersistent()
... ... @@ -132,6 +137,18 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
132 137 return message;
133 138 }
134 139  
  140 + /**
  141 + * 计算私聊消息回执 status:私聊已读关闭时强制 NO_RECEIPT,忽略发送方传入的 receipt(receipt 为 null 等价 false)
  142 + */
  143 + private Integer resolveReceiptStatus(Boolean receipt) {
  144 + if (BooleanUtil.isFalse(imProperties.getMessage().isPrivateReadEnabled())) {
  145 + return ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus();
  146 + }
  147 + return BooleanUtil.isTrue(receipt)
  148 + ? ImMessageReceiptStatusEnum.PENDING.getStatus()
  149 + : ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus();
  150 + }
  151 +
135 152 @Override
136 153 @Transactional(rollbackFor = Exception.class)
137 154 public ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId) {
... ... @@ -223,11 +240,11 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
223 240 }
224 241 Assert.notNull(messageId, "已读消息编号不能为空");
225 242  
226   - // 2. status 双写:把 (receiverId → userId) 上 id <= messageId 的未读消息置为已读
227   - // 仅 UNREAD 行被命中,避免覆盖已撤回/已读的状态;select-then-update 合成单条 SQL 后也消除了竞态窗口
228   - privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus(
229   - receiverId, userId, messageId, ImMessageStatusEnum.UNREAD.getStatus(),
230   - new ImPrivateMessageDO().setStatus(ImMessageStatusEnum.READ.getStatus()));
  243 + // 2. 回执置 DONE:把 (receiverId → userId) 上 id <= messageId、待回执(PENDING) 的消息标记为已完成
  244 + // status 不再表达已读(保持 NORMAL);只翻 PENDING 行,避免覆盖 NO_RECEIPT / 已 DONE
  245 + privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndReceiptStatus(
  246 + receiverId, userId, messageId, ImMessageReceiptStatusEnum.PENDING.getStatus(),
  247 + new ImPrivateMessageDO().setReceiptStatus(ImMessageReceiptStatusEnum.DONE.getStatus()));
231 248  
232 249 // 3. 同步写 im_conversation_read(读位置唯一权威,单调递增);读位置前进才下发事件
233 250 boolean advanced = conversationReadService.updateConversationReadPosition(
... ... @@ -248,8 +265,9 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
248 265 if (BooleanUtil.isFalse(imProperties.getMessage().isPrivateReadEnabled())) {
249 266 throw exception(MESSAGE_PRIVATE_READ_DISABLED);
250 267 }
251   - return privateMessageMapper.selectMaxIdBySenderIdAndReceiverIdAndStatus(
252   - userId, peerId, ImMessageStatusEnum.READ.getStatus());
  268 + // 对端 peer 在「与 userId 的会话」里的读位置 = peer 把 userId 发的消息读到哪
  269 + return conversationReadService.getConversationReadMessageId(
  270 + peerId, ImConversationTypeEnum.PRIVATE.getType(), userId);
253 271 }
254 272  
255 273 @Override
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImPrivateMessageSendDTO.java
... ... @@ -35,5 +35,9 @@ public class ImPrivateMessageSendDTO {
35 35 * true:覆盖为双向 + 入库
36 36 */
37 37 private Boolean persistent;
  38 + /**
  39 + * 是否需要回执;null 等价 false(系统消息默认不需要回执)
  40 + */
  41 + private Boolean receipt;
38 42  
39 43 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java
... ... @@ -60,6 +60,10 @@ public class ImPrivateMessageDTO {
60 60 */
61 61 private Integer status;
62 62 /**
  63 + * 回执状态
  64 + */
  65 + private Integer receiptStatus;
  66 + /**
63 67 * 发送时间
64 68 */
65 69 private LocalDateTime sendTime;
... ...
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;
2 2  
3 3 import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
4 4 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
5   -import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
  5 +import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum;
6 6 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
7 7 import jakarta.annotation.Resource;
8 8 import org.junit.jupiter.api.Test;
... ... @@ -29,11 +29,11 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest {
29 29 @Test
30 30 public void testSelectListByMinId_filterByReceiverSnapshot() {
31 31 // 准备:群 10 三条消息,快照分别命中 / 不命中用户 1
32   - ImGroupMessageDO visible = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L, 2L));
  32 + ImGroupMessageDO visible = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L, 2L));
33 33 mapper.insert(visible);
34   - ImGroupMessageDO invisible = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(2L, 3L));
  34 + ImGroupMessageDO invisible = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(2L, 3L));
35 35 mapper.insert(invisible);
36   - ImGroupMessageDO selfSend = buildMessage(10L, 1L, ImMessageStatusEnum.UNREAD, List.of(1L, 2L));
  36 + ImGroupMessageDO selfSend = buildMessage(10L, 1L, ImMessageStatusEnum.NORMAL, List.of(1L, 2L));
37 37 mapper.insert(selfSend);
38 38  
39 39 // 调用:用户 1 拉取群 10
... ... @@ -48,9 +48,9 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest {
48 48 @Test
49 49 public void testSelectListByMinId_excludeNullAndEmptySnapshot() {
50 50 // 准备:快照为 null / 空字符串的老数据,不应被任何用户拉到
51   - ImGroupMessageDO nullSnapshot = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, null);
  51 + ImGroupMessageDO nullSnapshot = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, null);
52 52 mapper.insert(nullSnapshot);
53   - ImGroupMessageDO emptySnapshot = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of());
  53 + ImGroupMessageDO emptySnapshot = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of());
54 54 mapper.insert(emptySnapshot);
55 55  
56 56 List<ImGroupMessageDO> result = mapper.selectListByMinId(1L, List.of(10L), 0L, FAR_PAST, 100);
... ... @@ -61,9 +61,9 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest {
61 61 @Test
62 62 public void testSelectListByMinId_onlyReturnsGroupsInIdList() {
63 63 // 准备:群 10 + 群 20 各一条,用户 1 都在快照内
64   - ImGroupMessageDO msg10 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L, 2L));
  64 + ImGroupMessageDO msg10 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L, 2L));
65 65 mapper.insert(msg10);
66   - ImGroupMessageDO msg20 = buildMessage(20L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L, 2L));
  66 + ImGroupMessageDO msg20 = buildMessage(20L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L, 2L));
67 67 mapper.insert(msg20);
68 68  
69 69 // 调用:候选群只含群 10
... ... @@ -76,7 +76,7 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest {
76 76 @Test
77 77 public void testSelectListByMinId_includesRecall() {
78 78 // 准备:撤回消息也要返回,由客户端按 status 渲染「此消息已撤回」占位
79   - ImGroupMessageDO normal = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
  79 + ImGroupMessageDO normal = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L));
80 80 mapper.insert(normal);
81 81 ImGroupMessageDO recalled = buildMessage(10L, 2L, ImMessageStatusEnum.RECALL, List.of(1L));
82 82 mapper.insert(recalled);
... ... @@ -89,10 +89,10 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest {
89 89 @Test
90 90 public void testSelectListByMinId_sendTimeWindow() {
91 91 // 准备:一条在窗口内,一条在窗口外
92   - ImGroupMessageDO inWindow = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
  92 + ImGroupMessageDO inWindow = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L));
93 93 inWindow.setSendTime(LocalDateTime.now().minusDays(1));
94 94 mapper.insert(inWindow);
95   - ImGroupMessageDO outWindow = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
  95 + ImGroupMessageDO outWindow = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L));
96 96 outWindow.setSendTime(LocalDateTime.now().minusDays(40));
97 97 mapper.insert(outWindow);
98 98  
... ... @@ -106,11 +106,11 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest {
106 106 @Test
107 107 public void testSelectListByMinId_sortAscLimit() {
108 108 // 准备:插入 3 条可见消息
109   - ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
  109 + ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L));
110 110 mapper.insert(m1);
111   - ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
  111 + ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L));
112 112 mapper.insert(m2);
113   - ImGroupMessageDO m3 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
  113 + ImGroupMessageDO m3 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L));
114 114 mapper.insert(m3);
115 115  
116 116 // 调用:size=2,返回 id 最小的 2 条
... ... @@ -125,9 +125,9 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest {
125 125 @Test
126 126 public void testSelectListByMinId_minIdExclusive() {
127 127 // 准备:游标之前的消息不返回
128   - ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
  128 + ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L));
129 129 mapper.insert(m1);
130   - ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
  130 + ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L));
131 131 mapper.insert(m2);
132 132  
133 133 List<ImGroupMessageDO> result = mapper.selectListByMinId(1L, List.of(10L), m1.getId(), FAR_PAST, 100);
... ... @@ -141,14 +141,14 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest {
141 141 @Test
142 142 public void testSelectHistoryListByUser_filterByReceiverSnapshot() {
143 143 // 准备:群 10 三条,用户 1 在前两条快照内、第三条定向给别人
144   - ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L, 2L));
  144 + ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L, 2L));
145 145 mapper.insert(m1);
146   - ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L, 2L));
  146 + ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L, 2L));
147 147 mapper.insert(m2);
148   - ImGroupMessageDO directedOther = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(2L, 3L));
  148 + ImGroupMessageDO directedOther = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(2L, 3L));
149 149 mapper.insert(directedOther);
150 150 // 别的群
151   - ImGroupMessageDO other = buildMessage(20L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
  151 + ImGroupMessageDO other = buildMessage(20L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L));
152 152 mapper.insert(other);
153 153  
154 154 List<ImGroupMessageDO> result = mapper.selectHistoryListByUser(1L, 10L, null, 100);
... ... @@ -161,11 +161,11 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest {
161 161  
162 162 @Test
163 163 public void testSelectHistoryListByUser_maxIdCursor() {
164   - ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
  164 + ImGroupMessageDO m1 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L));
165 165 mapper.insert(m1);
166   - ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
  166 + ImGroupMessageDO m2 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L));
167 167 mapper.insert(m2);
168   - ImGroupMessageDO m3 = buildMessage(10L, 2L, ImMessageStatusEnum.UNREAD, List.of(1L));
  168 + ImGroupMessageDO m3 = buildMessage(10L, 2L, ImMessageStatusEnum.NORMAL, List.of(1L));
169 169 mapper.insert(m3);
170 170  
171 171 List<ImGroupMessageDO> result = mapper.selectHistoryListByUser(1L, 10L, m3.getId(), 100);
... ... @@ -188,7 +188,7 @@ public class ImGroupMessageMapperTest extends BaseDbUnitTest {
188 188 .status(status.getStatus())
189 189 .sendTime(LocalDateTime.now())
190 190 .receiverUserIds(receiverUserIds)
191   - .receiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus())
  191 + .receiptStatus(ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus())
192 192 .build();
193 193 }
194 194  
... ...
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 {
31 31 @Test
32 32 public void testSelectListByMinId() {
33 33 // 准备:用户 1 发给用户 2
34   - ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD);
  34 + ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL);
35 35 mapper.insert(msg1);
36 36 // 用户 2 发给用户 1
37   - ImPrivateMessageDO msg2 = buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD);
  37 + ImPrivateMessageDO msg2 = buildMessage(2L, 1L, ImMessageStatusEnum.NORMAL);
38 38 mapper.insert(msg2);
39 39 // 用户 3 发给用户 4(不相关)
40   - ImPrivateMessageDO msg3 = buildMessage(3L, 4L, ImMessageStatusEnum.UNREAD);
  40 + ImPrivateMessageDO msg3 = buildMessage(3L, 4L, ImMessageStatusEnum.NORMAL);
41 41 mapper.insert(msg3);
42 42  
43 43 // 调用:用户 1 从 id=0 拉取
... ... @@ -52,9 +52,9 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest {
52 52 @Test
53 53 public void testSelectListByMinId_withMinId() {
54 54 // 准备
55   - ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD);
  55 + ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL);
56 56 mapper.insert(msg1);
57   - ImPrivateMessageDO msg2 = buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD);
  57 + ImPrivateMessageDO msg2 = buildMessage(2L, 1L, ImMessageStatusEnum.NORMAL);
58 58 mapper.insert(msg2);
59 59  
60 60 // 调用:从 msg1.id 之后拉取
... ... @@ -68,9 +68,9 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest {
68 68 @Test
69 69 public void testSelectListByMinId_limitSize() {
70 70 // 准备:插入 3 条
71   - mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD));
72   - mapper.insert(buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD));
73   - mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD));
  71 + mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL));
  72 + mapper.insert(buildMessage(2L, 1L, ImMessageStatusEnum.NORMAL));
  73 + mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL));
74 74  
75 75 // 调用:limit 2
76 76 List<ImPrivateMessageDO> result = mapper.selectListByMinId(1L, 0L, FAR_PAST, 2);
... ... @@ -82,10 +82,10 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest {
82 82 @Test
83 83 public void testSelectListByMinId_sendTimeFilter() {
84 84 // 准备:一条落在窗口内,一条落在窗口外
85   - ImPrivateMessageDO newMsg = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD);
  85 + ImPrivateMessageDO newMsg = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL);
86 86 newMsg.setSendTime(LocalDateTime.now().minusDays(1));
87 87 mapper.insert(newMsg);
88   - ImPrivateMessageDO oldMsg = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD);
  88 + ImPrivateMessageDO oldMsg = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL);
89 89 oldMsg.setSendTime(LocalDateTime.now().minusDays(40));
90 90 mapper.insert(oldMsg);
91 91  
... ... @@ -103,12 +103,12 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest {
103 103 @Test
104 104 public void testSelectHistoryList_basic() {
105 105 // 准备:用户 1 <-> 用户 2 的消息
106   - ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD);
  106 + ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL);
107 107 mapper.insert(msg1);
108   - ImPrivateMessageDO msg2 = buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD);
  108 + ImPrivateMessageDO msg2 = buildMessage(2L, 1L, ImMessageStatusEnum.NORMAL);
109 109 mapper.insert(msg2);
110 110 // 用户 1 <-> 用户 3 的消息(不相关)
111   - ImPrivateMessageDO msg3 = buildMessage(1L, 3L, ImMessageStatusEnum.UNREAD);
  111 + ImPrivateMessageDO msg3 = buildMessage(1L, 3L, ImMessageStatusEnum.NORMAL);
112 112 mapper.insert(msg3);
113 113  
114 114 // 调用
... ... @@ -122,7 +122,7 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest {
122 122 @Test
123 123 public void testSelectHistoryList_includeRecall() {
124 124 // 准备
125   - ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD);
  125 + ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL);
126 126 mapper.insert(msg1);
127 127 ImPrivateMessageDO msg2 = buildMessage(1L, 2L, ImMessageStatusEnum.RECALL);
128 128 mapper.insert(msg2);
... ... @@ -139,11 +139,11 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest {
139 139 @Test
140 140 public void testSelectHistoryList_withMaxId() {
141 141 // 准备
142   - ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD);
  142 + ImPrivateMessageDO msg1 = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL);
143 143 mapper.insert(msg1);
144   - ImPrivateMessageDO msg2 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD);
  144 + ImPrivateMessageDO msg2 = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL);
145 145 mapper.insert(msg2);
146   - ImPrivateMessageDO msg3 = buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD);
  146 + ImPrivateMessageDO msg3 = buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL);
147 147 mapper.insert(msg3);
148 148  
149 149 // 调用:从 msg3 往前拉
... ... @@ -157,9 +157,9 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest {
157 157 @Test
158 158 public void testSelectHistoryList_limit() {
159 159 // 准备
160   - mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD));
161   - mapper.insert(buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD));
162   - mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.UNREAD));
  160 + mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL));
  161 + mapper.insert(buildMessage(2L, 1L, ImMessageStatusEnum.NORMAL));
  162 + mapper.insert(buildMessage(1L, 2L, ImMessageStatusEnum.NORMAL));
163 163  
164 164 // 调用:limit 2
165 165 List<ImPrivateMessageDO> result = mapper.selectHistoryList(1L, 2L, null, 2);
... ... @@ -171,7 +171,7 @@ public class ImPrivateMessageMapperTest extends BaseDbUnitTest {
171 171 @Test
172 172 public void testSelectHistoryList_bidirectional() {
173 173 // 准备:验证双向查询
174   - ImPrivateMessageDO msg1 = buildMessage(2L, 1L, ImMessageStatusEnum.UNREAD); // 对方发的
  174 + ImPrivateMessageDO msg1 = buildMessage(2L, 1L, ImMessageStatusEnum.NORMAL); // 对方发的
175 175 mapper.insert(msg1);
176 176  
177 177 // 调用:以用户 1 的视角查
... ...
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 {
245 245 .senderId(senderId).receiverId(receiverId)
246 246 .type(ImMessageTypeEnum.TEXT.getType())
247 247 .content("{}")
248   - .status(ImMessageStatusEnum.UNREAD.getStatus())
  248 + .status(ImMessageStatusEnum.NORMAL.getStatus())
249 249 .sendTime(sendTime).build();
250 250 }
251 251  
... ... @@ -255,7 +255,7 @@ public class ImStatisticsManagerMapperTest extends BaseDbUnitTest {
255 255 .setSenderId(senderId).setGroupId(groupId)
256 256 .setType(ImMessageTypeEnum.TEXT.getType())
257 257 .setContent("{}")
258   - .setStatus(ImMessageStatusEnum.UNREAD.getStatus())
  258 + .setStatus(ImMessageStatusEnum.NORMAL.getStatus())
259 259 .setSendTime(sendTime);
260 260 }
261 261  
... ...
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;
12 12 import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper;
13 13 import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
14 14 import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum;
15   -import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
  15 +import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum;
16 16 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
17 17 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
18 18 import cn.iocoder.yudao.module.im.framework.config.ImProperties;
... ... @@ -111,8 +111,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
111 111 assertNotNull(result);
112 112 assertEquals(1L, result.getSenderId());
113 113 assertEquals(10L, result.getGroupId());
114   - assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.getStatus());
115   - assertEquals(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), result.getReceiptStatus());
  114 + assertEquals(ImMessageStatusEnum.NORMAL.getStatus(), result.getStatus());
  115 + assertEquals(ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), result.getReceiptStatus());
116 116  
117 117 // 验证推送给 3 个群成员(含发送者自己,用于多端同步)
118 118 verify(imWebSocketService).sendGroupMessageAsync(argThat((Collection<Long> ids) ->
... ... @@ -209,7 +209,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
209 209 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L, 2L, 3L));
210 210 // 被引用原消息:定向给 {1,2}(对发送人 1 可见,但不覆盖广播受众 {1,2,3})
211 211 ImGroupMessageDO original = ImGroupMessageDO.builder().id(500L).groupId(10L).senderId(2L)
212   - .status(ImMessageStatusEnum.UNREAD.getStatus()).receiverUserIds(List.of(1L, 2L))
  212 + .status(ImMessageStatusEnum.NORMAL.getStatus()).receiverUserIds(List.of(1L, 2L))
213 213 .type(ImMessageTypeEnum.TEXT.getType()).content("{\"content\":\"密\"}")
214 214 .sendTime(LocalDateTime.now()).build();
215 215 when(groupMessageMapper.selectById(500L)).thenReturn(original);
... ... @@ -235,7 +235,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
235 235 when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(member);
236 236 when(groupMemberService.getActiveGroupMemberUserIdsByGroupId(10L)).thenReturn(List.of(1L, 2L, 3L));
237 237 ImGroupMessageDO original = ImGroupMessageDO.builder().id(500L).groupId(10L).senderId(2L)
238   - .status(ImMessageStatusEnum.UNREAD.getStatus()).receiverUserIds(List.of(1L, 2L, 3L))
  238 + .status(ImMessageStatusEnum.NORMAL.getStatus()).receiverUserIds(List.of(1L, 2L, 3L))
239 239 .type(ImMessageTypeEnum.TEXT.getType()).content("{\"content\":\"公开\"}")
240 240 .sendTime(LocalDateTime.now()).build();
241 241 when(groupMessageMapper.selectById(500L)).thenReturn(original);
... ... @@ -253,8 +253,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
253 253 // ========== pull 测试 ==========
254 254  
255 255 @Test
256   - public void testPullMessages_pullSetsReadFromCursor() {
257   - // 准备:当前在群 10,无退群;拉到两条消息,已读位置为 7
  256 + public void testPullMessages_doesNotOverwriteStatus() {
  257 + // 准备:拉到两条消息;Phase 2 起 pull 不再按读位置覆盖 status,已读由前端按读位置判断
258 258 LocalDateTime now = LocalDateTime.now();
259 259 ImGroupMemberDO member = ImGroupMemberDO.builder()
260 260 .groupId(10L).userId(1L)
... ... @@ -264,25 +264,22 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
264 264  
265 265 ImGroupMessageDO low = ImGroupMessageDO.builder()
266 266 .id(5L).groupId(10L).senderId(2L).receiverUserIds(List.of(1L))
267   - .status(ImMessageStatusEnum.UNREAD.getStatus())
  267 + .status(ImMessageStatusEnum.NORMAL.getStatus())
268 268 .sendTime(now.minusHours(2)).build();
269 269 ImGroupMessageDO high = ImGroupMessageDO.builder()
270 270 .id(10L).groupId(10L).senderId(2L).receiverUserIds(List.of(1L))
271   - .status(ImMessageStatusEnum.UNREAD.getStatus())
  271 + .status(ImMessageStatusEnum.NORMAL.getStatus())
272 272 .sendTime(now.minusHours(1)).build();
273 273 when(groupMessageMapper.selectListByMinId(eq(1L), anyCollection(), eq(0L),
274 274 any(LocalDateTime.class), eq(100)))
275 275 .thenReturn(List.of(low, high));
276 276  
277   - when(conversationReadService.getConversationReadMessageId(eq(1L), eq(ImConversationTypeEnum.GROUP.getType()), eq(10L)))
278   - .thenReturn(7L);
279   -
280 277 List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100);
281 278  
282   - // 断言:已读位置 7 → id<=7 为 READ,id>7 为 UNREAD
  279 + // 断言:status 保持 DB 原值(都 NORMAL),pull 不覆盖
283 280 assertEquals(2, result.size());
284   - assertEquals(ImMessageStatusEnum.READ.getStatus(), result.get(0).getStatus());
285   - assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.get(1).getStatus());
  281 + assertEquals(ImMessageStatusEnum.NORMAL.getStatus(), result.get(0).getStatus());
  282 + assertEquals(ImMessageStatusEnum.NORMAL.getStatus(), result.get(1).getStatus());
286 283 }
287 284  
288 285 @Test
... ... @@ -298,15 +295,13 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
298 295 ImGroupMessageDO receiptMsg = ImGroupMessageDO.builder()
299 296 .id(100L).groupId(10L).senderId(1L)
300 297 .receiverUserIds(List.of(1L, 2L, 3L))
301   - .status(ImMessageStatusEnum.UNREAD.getStatus())
302   - .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus())
  298 + .status(ImMessageStatusEnum.NORMAL.getStatus())
  299 + .receiptStatus(ImMessageReceiptStatusEnum.PENDING.getStatus())
303 300 .sendTime(now.minusHours(1)).build();
304 301 when(groupMessageMapper.selectListByMinId(eq(1L), anyCollection(), eq(0L),
305 302 any(LocalDateTime.class), eq(100)))
306 303 .thenReturn(List.of(receiptMsg));
307 304  
308   - when(conversationReadService.getConversationReadMessageId(eq(1L), eq(ImConversationTypeEnum.GROUP.getType()), eq(10L)))
309   - .thenReturn(100L);
310 305 Map<Long, Long> positions = new HashMap<>();
311 306 positions.put(1L, 100L);
312 307 positions.put(2L, 100L);
... ... @@ -334,7 +329,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
334 329  
335 330 @Test
336 331 public void testPullMessages_noReceiptMessageSkipsReadCount() {
337   - // 准备:本人发送但不需要回执(NO_RECEIPT)的消息——只补已读位置,不补 readCount
  332 + // 准备:本人发送但不需要回执(NO_RECEIPT)的消息——不补 readCount,status 也不被覆盖
338 333 LocalDateTime now = LocalDateTime.now();
339 334 ImGroupMemberDO member = ImGroupMemberDO.builder()
340 335 .groupId(10L).userId(1L)
... ... @@ -345,20 +340,18 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
345 340 ImGroupMessageDO noReceiptMsg = ImGroupMessageDO.builder()
346 341 .id(100L).groupId(10L).senderId(1L)
347 342 .receiverUserIds(List.of(1L, 2L, 3L))
348   - .status(ImMessageStatusEnum.UNREAD.getStatus())
349   - .receiptStatus(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus())
  343 + .status(ImMessageStatusEnum.NORMAL.getStatus())
  344 + .receiptStatus(ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus())
350 345 .sendTime(now.minusHours(1)).build();
351 346 when(groupMessageMapper.selectListByMinId(eq(1L), anyCollection(), eq(0L),
352 347 any(LocalDateTime.class), eq(100)))
353 348 .thenReturn(List.of(noReceiptMsg));
354   - when(conversationReadService.getConversationReadMessageId(eq(1L), eq(ImConversationTypeEnum.GROUP.getType()), eq(10L)))
355   - .thenReturn(100L);
356 349  
357 350 List<ImGroupMessageDO> result = groupMessageService.pullGroupMessageList(1L, 0L, 100);
358 351  
359   - // 断言:读位置照常补(status=READ),但 NO_RECEIPT 不补 readCount,也不查 readCount 相关的读位置映射
  352 + // 断言:status 保持 NORMAL(pull 不覆盖),NO_RECEIPT 不补 readCount,也不查 readCount 相关的读位置映射
360 353 assertEquals(1, result.size());
361   - assertEquals(ImMessageStatusEnum.READ.getStatus(), result.get(0).getStatus());
  354 + assertEquals(ImMessageStatusEnum.NORMAL.getStatus(), result.get(0).getStatus());
362 355 assertNull(result.get(0).getReadCount());
363 356 verify(conversationReadService, never()).getUserReadMessageIdMap(anyInt(), anyLong());
364 357 }
... ... @@ -374,7 +367,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
374 367 // 准备:消息由用户 1 发送,刚发送 5 分钟内
375 368 ImGroupMessageDO message = ImGroupMessageDO.builder()
376 369 .id(50L).senderId(1L).groupId(10L)
377   - .status(ImMessageStatusEnum.UNREAD.getStatus())
  370 + .status(ImMessageStatusEnum.NORMAL.getStatus())
378 371 .sendTime(LocalDateTime.now()).build();
379 372 when(groupMessageMapper.selectById(50L)).thenReturn(message);
380 373 // 撤回前需要校验用户仍在群中
... ... @@ -407,7 +400,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
407 400 // 准备
408 401 ImGroupMessageDO message = ImGroupMessageDO.builder()
409 402 .id(50L).senderId(2L).groupId(10L)
410   - .status(ImMessageStatusEnum.UNREAD.getStatus())
  403 + .status(ImMessageStatusEnum.NORMAL.getStatus())
411 404 .sendTime(LocalDateTime.now()).build();
412 405 when(groupMessageMapper.selectById(50L)).thenReturn(message);
413 406 when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(
... ... @@ -443,7 +436,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
443 436 // 准备:消息发送于 10 分钟前(超过 5 分钟窗口)
444 437 ImGroupMessageDO message = ImGroupMessageDO.builder()
445 438 .id(50L).senderId(1L).groupId(10L)
446   - .status(ImMessageStatusEnum.UNREAD.getStatus())
  439 + .status(ImMessageStatusEnum.NORMAL.getStatus())
447 440 .sendTime(LocalDateTime.now().minusMinutes(10)).build();
448 441 when(groupMessageMapper.selectById(50L)).thenReturn(message);
449 442 when(groupMemberService.validateMemberInGroup(10L, 1L)).thenReturn(
... ... @@ -569,7 +562,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
569 562 ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO);
570 563  
571 564 // 断言:receipt=true → 回执状态为 PENDING
572   - assertEquals(ImGroupMessageReceiptStatusEnum.PENDING.getStatus(), result.getReceiptStatus());
  565 + assertEquals(ImMessageReceiptStatusEnum.PENDING.getStatus(), result.getReceiptStatus());
573 566 }
574 567 }
575 568  
... ... @@ -613,7 +606,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
613 606 // 准备:消息合法、可撤回时间内,但发送人已退群
614 607 ImGroupMessageDO message = ImGroupMessageDO.builder()
615 608 .id(50L).senderId(1L).groupId(10L)
616   - .status(ImMessageStatusEnum.UNREAD.getStatus())
  609 + .status(ImMessageStatusEnum.NORMAL.getStatus())
617 610 .sendTime(LocalDateTime.now()).build();
618 611 when(groupMessageMapper.selectById(50L)).thenReturn(message);
619 612 when(groupMemberService.validateMemberInGroup(10L, 1L))
... ... @@ -706,7 +699,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
706 699  
707 700 ImGroupMessageDO result = groupMessageService.sendGroupMessage(1L, reqVO);
708 701  
709   - assertEquals(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), result.getReceiptStatus(),
  702 + assertEquals(ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), result.getReceiptStatus(),
710 703 "群已读关闭时即使发送方传 receipt=true 也强制落 NO_RECEIPT");
711 704 }
712 705 }
... ... @@ -762,8 +755,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
762 755 .id(100L).groupId(10L).senderId(5L)
763 756 .receiverUserIds(List.of(5L, 1L, 2L))
764 757 .sendTime(LocalDateTime.now().minusMinutes(1))
765   - .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus())
766   - .status(ImMessageStatusEnum.UNREAD.getStatus()).build();
  758 + .receiptStatus(ImMessageReceiptStatusEnum.PENDING.getStatus())
  759 + .status(ImMessageStatusEnum.NORMAL.getStatus()).build();
767 760 when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 0L, 100L))
768 761 .thenReturn(List.of(pending));
769 762 List<ImGroupMemberDO> activeMembers = List.of(
... ... @@ -791,7 +784,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
791 784 ArgumentCaptor<ImGroupMessageDO> captor = ArgumentCaptor.forClass(ImGroupMessageDO.class);
792 785 verify(groupMessageMapper).updateById(captor.capture());
793 786 assertEquals(100L, captor.getValue().getId());
794   - assertEquals(ImGroupMessageReceiptStatusEnum.DONE.getStatus(), captor.getValue().getReceiptStatus());
  787 + assertEquals(ImMessageReceiptStatusEnum.DONE.getStatus(), captor.getValue().getReceiptStatus());
795 788 // 断言:给消息发送方(5)推送 RECEIPT 事件
796 789 verify(imWebSocketService).sendGroupMessageAsync(eq(5L), any(ImGroupMessageDTO.class));
797 790 }
... ... @@ -803,8 +796,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
803 796 .id(100L).groupId(10L).senderId(5L)
804 797 .receiverUserIds(List.of(5L, 1L, 2L))
805 798 .sendTime(LocalDateTime.now().minusMinutes(1))
806   - .receiptStatus(ImGroupMessageReceiptStatusEnum.PENDING.getStatus())
807   - .status(ImMessageStatusEnum.UNREAD.getStatus()).build();
  799 + .receiptStatus(ImMessageReceiptStatusEnum.PENDING.getStatus())
  800 + .status(ImMessageStatusEnum.NORMAL.getStatus()).build();
808 801 when(groupMessageMapper.selectListByGroupIdAndPendingReceipt(10L, 0L, 100L))
809 802 .thenReturn(List.of(pending));
810 803 List<ImGroupMemberDO> activeMembers = List.of(
... ... @@ -910,8 +903,8 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
910 903 assertEquals(10L, message.getGroupId());
911 904 assertEquals(ImMessageTypeEnum.RECALL.getType(), message.getType());
912 905 assertEquals("{\"messageId\":50}", message.getContent());
913   - assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), message.getStatus());
914   - assertEquals(ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), message.getReceiptStatus());
  906 + assertEquals(ImMessageStatusEnum.NORMAL.getStatus(), message.getStatus());
  907 + assertEquals(ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus(), message.getReceiptStatus());
915 908 assertNotNull(message.getClientMessageId());
916 909 assertNotNull(message.getSendTime());
917 910 }
... ... @@ -928,7 +921,7 @@ public class ImGroupMessageServiceImplTest extends BaseMockitoUnitTest {
928 921  
929 922 ArgumentCaptor<ImGroupMessageDO> captor = ArgumentCaptor.forClass(ImGroupMessageDO.class);
930 923 verify(groupMessageMapper).insert(captor.capture());
931   - assertEquals(ImGroupMessageReceiptStatusEnum.PENDING.getStatus(), captor.getValue().getReceiptStatus());
  924 + assertEquals(ImMessageReceiptStatusEnum.PENDING.getStatus(), captor.getValue().getReceiptStatus());
932 925 }
933 926  
934 927 @Test
... ...
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
7 7 import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO;
8 8 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
9 9 import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper;
  10 +import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum;
10 11 import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
11 12 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
12 13 import cn.iocoder.yudao.module.im.framework.config.ImProperties;
... ... @@ -87,7 +88,9 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
87 88 assertEquals(1L, result.getSenderId());
88 89 assertEquals(2L, result.getReceiverId());
89 90 assertEquals(ImMessageTypeEnum.TEXT.getType(), result.getType());
90   - assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), result.getStatus());
  91 + assertEquals(ImMessageStatusEnum.NORMAL.getStatus(), result.getStatus());
  92 + assertEquals(ImMessageReceiptStatusEnum.PENDING.getStatus(), result.getReceiptStatus(),
  93 + "用户私聊消息默认需要回执(PENDING)");
91 94 assertNotNull(result.getSendTime());
92 95  
93 96 // 验证调用
... ... @@ -165,10 +168,10 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
165 168  
166 169 @Test
167 170 public void testReadMessages_success() {
168   - // 准备:前端上报已读到 messageId=5;mapper 返回更新行数 2 表示有未读被翻转
169   - when(privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus(
  171 + // 准备:前端上报已读到 messageId=5;mapper 返回更新行数 2 表示有待回执消息被标记完成
  172 + when(privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndReceiptStatus(
170 173 eq(2L), eq(1L), eq(5L),
171   - eq(ImMessageStatusEnum.UNREAD.getStatus()), any(ImPrivateMessageDO.class)))
  174 + eq(ImMessageReceiptStatusEnum.PENDING.getStatus()), any(ImPrivateMessageDO.class)))
172 175 .thenReturn(2);
173 176 // 读位置前进 → 才下发事件
174 177 when(conversationReadService.updateConversationReadPosition(anyLong(), anyInt(), anyLong(), anyLong()))
... ... @@ -177,10 +180,10 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
177 180 // 调用
178 181 privateMessageService.readPrivateMessages(1L, 2L, 5L);
179 182  
180   - // 断言:更新了消息状态
181   - verify(privateMessageMapper).updateBySenderIdAndReceiverIdAndIdLeAndStatus(
  183 + // 断言:把待回执(PENDING)消息标记为已完成(DONE);status 不再写 READ
  184 + verify(privateMessageMapper).updateBySenderIdAndReceiverIdAndIdLeAndReceiptStatus(
182 185 eq(2L), eq(1L), eq(5L),
183   - eq(ImMessageStatusEnum.UNREAD.getStatus()), any(ImPrivateMessageDO.class));
  186 + eq(ImMessageReceiptStatusEnum.PENDING.getStatus()), any(ImPrivateMessageDO.class));
184 187  
185 188 // 断言:发送了 READ + RECEIPT 事件,payload 字段正确
186 189 ArgumentCaptor<Long> userCaptor = ArgumentCaptor.forClass(Long.class);
... ... @@ -210,7 +213,7 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
210 213 // 准备
211 214 ImPrivateMessageDO message = ImPrivateMessageDO.builder()
212 215 .id(10L).senderId(1L).receiverId(2L)
213   - .status(ImMessageStatusEnum.UNREAD.getStatus())
  216 + .status(ImMessageStatusEnum.NORMAL.getStatus())
214 217 .sendTime(LocalDateTime.now()).build(); // 刚发送,5 分钟内
215 218 when(privateMessageMapper.selectById(10L)).thenReturn(message);
216 219 when(privateMessageMapper.updateById(any(ImPrivateMessageDO.class))).thenReturn(1);
... ... @@ -233,7 +236,7 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
233 236 // 准备
234 237 ImPrivateMessageDO message = ImPrivateMessageDO.builder()
235 238 .id(10L).senderId(2L).receiverId(1L)
236   - .status(ImMessageStatusEnum.UNREAD.getStatus())
  239 + .status(ImMessageStatusEnum.NORMAL.getStatus())
237 240 .sendTime(LocalDateTime.now()).build();
238 241 when(privateMessageMapper.selectById(10L)).thenReturn(message);
239 242  
... ... @@ -274,7 +277,7 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
274 277 // 准备:消息发送于 10 分钟前(超过 5 分钟窗口)
275 278 ImPrivateMessageDO message = ImPrivateMessageDO.builder()
276 279 .id(10L).senderId(1L).receiverId(2L)
277   - .status(ImMessageStatusEnum.UNREAD.getStatus())
  280 + .status(ImMessageStatusEnum.NORMAL.getStatus())
278 281 .sendTime(LocalDateTime.now().minusMinutes(10)).build();
279 282 when(privateMessageMapper.selectById(10L)).thenReturn(message);
280 283  
... ... @@ -314,7 +317,7 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
314 317 () -> privateMessageService.readPrivateMessages(1L, 2L, 5L));
315 318 assertEquals(MESSAGE_PRIVATE_READ_DISABLED.getCode(), exception.getCode());
316 319 // 断言:不更新消息状态、不推送
317   - verify(privateMessageMapper, never()).updateBySenderIdAndReceiverIdAndIdLeAndStatus(
  320 + verify(privateMessageMapper, never()).updateBySenderIdAndReceiverIdAndIdLeAndReceiptStatus(
318 321 anyLong(), anyLong(), anyLong(), anyInt(), any(ImPrivateMessageDO.class));
319 322 verify(imWebSocketService, never()).sendPrivateMessageAsync(anyLong(), any(ImPrivateMessageDTO.class));
320 323 }
... ... @@ -336,9 +339,9 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
336 339  
337 340 @Test
338 341 public void testGetMaxReadMessageId_hit() {
339   - // 准备:对方读到我发的最大 id=10
340   - when(privateMessageMapper.selectMaxIdBySenderIdAndReceiverIdAndStatus(
341   - 1L, 2L, ImMessageStatusEnum.READ.getStatus())).thenReturn(10L);
  342 + // 准备:对方(2) 在与我(1) 的会话里读位置=10
  343 + when(conversationReadService.getConversationReadMessageId(eq(2L), anyInt(), eq(1L)))
  344 + .thenReturn(10L);
342 345  
343 346 // 调用
344 347 Long result = privateMessageService.getMaxReadMessageId(1L, 2L);
... ... @@ -349,9 +352,9 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
349 352  
350 353 @Test
351 354 public void testGetMaxReadMessageId_miss() {
352   - // 准备:对方一条都没读过
353   - when(privateMessageMapper.selectMaxIdBySenderIdAndReceiverIdAndStatus(
354   - 1L, 2L, ImMessageStatusEnum.READ.getStatus())).thenReturn(null);
  355 + // 准备:对方一条都没读过(读位置为 null)
  356 + when(conversationReadService.getConversationReadMessageId(eq(2L), anyInt(), eq(1L)))
  357 + .thenReturn(null);
355 358  
356 359 // 调用
357 360 Long result = privateMessageService.getMaxReadMessageId(1L, 2L);
... ... @@ -390,7 +393,7 @@ public class ImPrivateMessageServiceImplTest extends BaseMockitoUnitTest {
390 393 assertEquals(2L, message.getReceiverId());
391 394 assertEquals(ImMessageTypeEnum.RECALL.getType(), message.getType());
392 395 assertEquals("{\"messageId\":50}", message.getContent());
393   - assertEquals(ImMessageStatusEnum.UNREAD.getStatus(), message.getStatus());
  396 + assertEquals(ImMessageStatusEnum.NORMAL.getStatus(), message.getStatus());
394 397 assertNotNull(message.getClientMessageId());
395 398 assertNotNull(message.getSendTime());
396 399 // 断言:sender + receiver 双端推送
... ...
yudao-module-im/src/test/resources/sql/create_tables.sql
... ... @@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS &quot;im_private_message&quot; (
6 6 "type" smallint NOT NULL COMMENT '消息类型',
7 7 "content" varchar(8192) DEFAULT NULL COMMENT '消息内容',
8 8 "status" tinyint NOT NULL COMMENT '消息状态',
  9 + "receipt_status" tinyint NOT NULL DEFAULT 0 COMMENT '回执状态',
9 10 "send_time" timestamp NOT NULL COMMENT '发送时间',
10 11 "creator" varchar(64) DEFAULT '',
11 12 "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
... ...