【消息】私聊、群聊、频道消息
消息是 IM 的核心。消息模块由 yudao-module-im 后端模块的 message 包实现,用户侧前端在 @/views/im/home 的聊天工作台。
按会话类型(枚举 ImConversationTypeEnum:1=私聊,2=群聊,3=频道)分为三张消息表,外加一张记录「已读位点」的会话已读表:
- 私聊消息:一对一聊天,落
im_private_message。 - 群聊消息:群里聊天,落
im_group_message,支持 @ 成员、定向可见。 - 频道消息:运营向订阅者推送的图文素材,落
im_channel_message(详见 《频道》)。 - 会话已读:每个用户在每个会话里「已读到第几条」,落
im_conversation_read,是未读数与已读回执的基础。
本篇与《WebSocket》篇的分工
本篇讲消息「是什么、怎么发、怎么撤回、怎么算已读」;消息「怎么实时推到对端、断线后怎么补」属于通道层,统一在 《WebSocket 实时推送与离线消息》 讲,本篇用 详见 引过去,不重复。
# 1. 消息类型
消息类型由枚举 ImContentTypeEnum 定义,消息表的 type 字段存的就是它的整数值(如 101=文本、102=图片)。每个类型带两个关键标志:
persistent(是否入库):true落消息表、离线pull能拉到;false仅 WebSocket 在线推、离线丢弃。normal(是否聊天消息):true是用户主动发的聊天消息、计入会话未读数,也是用户发送入口唯一允许的类型;false是系统事件 / 信号。
用户能主动发送的聊天消息(normal=true),content 字段就是对应消息体类序列化成的 JSON:
| 值 | 类型 | 消息体类 | 说明 |
|---|---|---|---|
| 101 | 文本 | TextMessage | 纯文本;Unicode emoji 也走文本 |
| 102 | 图片 | ImageMessage | |
| 103 | 语音 | AudioMessage | |
| 104 | 视频 | VideoMessage | |
| 105 | 文件 | FileMessage | |
| 115 | 表情 | FaceMessage | 表情贴图,来自系统 / 用户表情包(详见 《内容》) |
| 108 | 名片 | CardMessage | 把用户 / 群名片推荐给其他会话 |
| 107 | 合并转发 | MergeMessage | 多条消息合并成一条「聊天记录」 |
| 125 | 素材 | MaterialMessage | 频道推送的图文卡片(详见 《频道》) |
除聊天消息外,还有一类系统事件消息(normal=false,用户不能直接发、由系统产生):撤回、已读、回执,以及好友 / 群 / 通话等通知。它们是否入库分两种:
- 入库(
persistent=true):作为聊天里的提示渲染、离线也能拉到。例如:新增好友 FRIEND_ADD(渲染「你们已经是好友了」气泡)、群事件 GROUP_CREATE / GROUP_MEMBER_INVITE / GROUP_MEMBER_QUIT(群创建、成员变动提示)、通话开始 / 结束 RTC_CALL_START / RTC_CALL_END、撤回 RECALL。 - 不入库(
persistent=false):纯信令 / 瞬时态,离线即丢弃。例如:已读 READ、回执 RECEIPT、通话邀请 RTC_CALL、参与者加入 / 离开 RTC_PARTICIPANT_CONNECTED / RTC_PARTICIPANT_DISCONNECTED、正在输入。
各类通知的推送范围与入库策略详见 《WebSocket 实时推送与离线消息》。
# 2. 表结构
省略 creator/create_time/updater/update_time/deleted/tenant_id 等通用字段
# 2.1 私聊消息
私聊消息,由 ImPrivateMessageController 提供接口(/im/message/private)。
CREATE TABLE `im_private_message` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
`client_message_id` varchar(100) NOT NULL COMMENT '客户端消息编号,用于发送幂等',
`sender_id` bigint NOT NULL COMMENT '发送人编号',
`receiver_id` bigint NOT NULL COMMENT '接收人编号',
`type` smallint NOT NULL COMMENT '消息类型',
`content` text NOT NULL COMMENT '消息内容,JSON 格式',
`status` tinyint NOT NULL DEFAULT '0' COMMENT '消息状态(0=正常,2=已撤回)',
`receipt_status` tinyint NOT NULL DEFAULT '0' COMMENT '回执状态(0=不需要,1=待完成,2=已完成)',
`send_time` datetime NOT NULL COMMENT '发送时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_im_private_sender_client_msg` (`sender_id`, `client_message_id`)
) ENGINE=InnoDB COMMENT='IM 私聊消息表';
① client_message_id 客户端生成的消息编号,发送时由客户端生成,配合唯一键 (sender_id, client_message_id) 做发送幂等(同一条重发不会落两条,见 §3.3)。
② type 消息类型(见 §1);content 消息内容,统一存 JSON 文本,按 type 反序列化为对应结构(文本 / 图片 / 文件 …)。
③ status 消息状态(枚举 ImMessageStatusEnum:0=正常,2=已撤回;另有 -1=发送中,仅客户端用);receipt_status 回执状态(枚举 ImMessageReceiptStatusEnum),对方已读时回写(见 §4)。
# 2.2 群聊消息
群聊消息,由 ImGroupMessageController 提供接口(/im/message/group)。结构与私聊基本一致,把「接收人」换成「群」,并多了定向可见与 @ 两个字段:
CREATE TABLE `im_group_message` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
`client_message_id` varchar(100) NOT NULL COMMENT '客户端消息编号,用于发送幂等',
`sender_id` bigint NOT NULL COMMENT '发送人编号',
`group_id` bigint NOT NULL COMMENT '群编号',
`type` smallint NOT NULL COMMENT '消息类型',
`content` text NOT NULL COMMENT '消息内容,JSON 格式',
`receiver_user_ids` text COMMENT '可见成员编号快照,逗号分隔;发送时固化当时全部可见成员',
`at_user_ids` text COMMENT '@目标用户编号列表,逗号分隔',
`status` tinyint NOT NULL DEFAULT '0' COMMENT '消息状态(0=正常,2=已撤回)',
`receipt_status` tinyint NOT NULL DEFAULT '0' COMMENT '回执状态',
`send_time` datetime NOT NULL COMMENT '发送时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_im_group_sender_client_msg` (`sender_id`, `client_message_id`)
) ENGINE=InnoDB COMMENT='IM 群聊消息表';
① receiver_user_ids 是发送当时的可见成员快照:群消息发送时,把当时全部可见成员的 id 逗号拼接存进来(不是留空表示全员),定向消息则存指定的接收人子集。pull 时用 FIND_IN_SET(:userId, receiver_user_ids) 判断当前用户能否看到这条消息。
为什么存全量快照、而不是留空或另建「用户 - 消息」索引表?本质是用一行的宽度,换掉用户级消息索引的写放大、以及「成员历史区间」JOIN 的复杂度——发一条群消息只写一行,可见性在落库那刻就定死,pull 一个 FIND_IN_SET 即可过滤;信任 MySQL 的能力,不把系统搞复杂。代价是行宽随群成员数增长,对当前规模可接受(机制详见 《WebSocket》§4.1)。
② at_user_ids 被 @ 的成员编号列表,前端据此高亮提醒;@ 用对方真实昵称(nickname)。
# 2.3 频道消息
频道消息,由 ImChannelMessageController 提供接口(/im/channel/message)。它是运营推送素材时生成的快照,用户侧只拉取与已读,不发送(运营侧推送详见 《频道》)。
CREATE TABLE `im_channel_message` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
`channel_id` bigint NOT NULL COMMENT '频道编号(冗余便于检索)',
`material_id` bigint NOT NULL COMMENT '关联素材编号',
`type` smallint NOT NULL COMMENT '消息类型',
`content` varchar(8192) DEFAULT NULL COMMENT '消息内容;推送 payload JSON 快照',
`receiver_user_ids` varchar(1024) DEFAULT NULL COMMENT '接收人编号列表,逗号分隔;为空表示全员',
`send_time` datetime NOT NULL COMMENT '发送时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='IM 频道消息表';
content 是推送时的图文卡片快照(标题 / 封面 / 摘要 / 链接),正文富文本不在此存(在素材表 im_channel_material)。
注意频道的 receiver_user_ids 语义和群聊相反:为空表示全员推送(推给全部订阅者),非空才是定向给指定用户。频道是「运营 → 订阅者」的单向广播,没有群聊那种「成员可见性快照」问题,所以保留「空 = 全员」的简单语义。
# 2.4 会话已读
会话已读,记录每个用户在每个会话里「已读到的最大消息编号」,是计算未读数与判断已读回执的依据。
CREATE TABLE `im_conversation_read` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
`user_id` bigint NOT NULL COMMENT '用户编号',
`conversation_type` tinyint NOT NULL COMMENT '会话类型(1=私聊,2=群聊,3=频道)',
`target_id` bigint NOT NULL COMMENT '目标编号:私聊=对端用户,群聊=群,频道=频道',
`message_id` bigint NOT NULL COMMENT '最大已读消息编号',
`read_time` datetime NOT NULL COMMENT '最近已读时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='IM 会话已读表';
(user_id, conversation_type, target_id) 定位一条会话已读记录:conversation_type 用枚举 ImConversationTypeEnum(1=私聊,2=群聊,3=频道),target_id 随类型变化——私聊是对端用户编号、群聊是群编号、频道是频道编号。message_id 是「已读位点」——位点之后的消息即未读。用「位点」而非「逐条标记」,让未读计算与已读上报都很轻量。
# 3. 发送、撤回与拉取
# 3.1 发送主流程
三类消息发送共用一套主流程(私聊 sendPrivateMessage、群聊 sendGroupMessage):
- 校验:校验会话关系(私聊校验好友 / 拉黑,见 《好友》;群聊校验成员 / 禁言)、敏感词(见 《内容》)。
- 落库:写入对应消息表,
id由服务端自增、client_message_id由客户端带入做幂等。 - 推送:通过 WebSocket 推给接收方(及发送方其它端),详见 《WebSocket》。
- 拉取:接收方在线即时收到;离线时下次通过
pull接口按minId游标增量补齐。
三类消息的差异:
| 维度 | 私聊 | 群聊 | 频道 |
|---|---|---|---|
| 接收范围 | 对端一人 | 全群 / 定向(receiver_user_ids) | 订阅者 / 定向 |
| 谁能发 | 互为好友的用户 | 群成员(未被禁言) | 仅运营推送 |
| 特有能力 | — | @ 成员、定向可见 | 图文素材卡片 |
# 3.2 撤回
撤回把消息状态置为「已撤回」,对端的气泡替换为「xxx 撤回了一条消息」提示。消息状态枚举 ImMessageStatusEnum:
| 状态值 | 枚举 | 说明 | 可执行操作 |
|---|---|---|---|
| 0 | NORMAL | 正常 | 撤回(2 分钟内,仅发送者)、引用、转发 |
| 2 | RECALL | 已撤回 | — |
撤回说明
发送 ──→ 正常(0) ──撤回(2 分钟内,仅本人)──→ 已撤回(2)(终态)
- 发送(
sendPrivateMessage/sendGroupMessage):消息落库,初始状态正常。 - 撤回(
recallPrivateMessage/recallGroupMessage):校验是本人发送且在 2 分钟内,把status置为已撤回并推送;前端渲染居中灰字提示。撤回是终态,不可恢复。
-1=发送中 是纯客户端态(消息尚未拿到服务端响应),不落库。
# 3.3 顺序性与唯一性
消息的顺序性(按服务端自增主键 id 定序、不依赖客户端时间)与唯一性(靠 client_message_id + 唯一键 (sender_id, client_message_id) 去重、重发不会落两条)由通道层统一保证,本篇不展开,详见 《WebSocket 实时推送与离线消息》§4。
# 4. 已读回执
已读由两部分配合:消息表的 receipt_status(这条消息是否被读)+ 会话已读表 im_conversation_read 的已读位点(读到第几条)。
- 上报已读:进入会话或滚动到底时,调用
readPrivateMessages/readGroupMessages/readChannelMessages上报「已读到的最大消息编号」,更新im_conversation_read并推送回执。 - 私聊:发送方在自己的消息气泡尾部看到「已读 / 未读」;多端 / 重连后用
getMaxReadMessageId补齐对方已读位点。 - 群聊:发送方看到「N 人已读」,点击可用
getGroupReadUserIds查看已读 / 未读成员名单。 - 频道:也记已读位点(
conversation_type=3)用于算未读红点,但频道是单向广播,不展示「谁已读」。
已读回执开关
私聊 / 群聊的已读回执可分别由配置项 yudao.im.message.private-read-enabled、yudao.im.message.group-read-enabled(默认都开)控制;关闭后对应 read 接口直接报错、服务端不再下发 READ / RECEIPT 信号(适合不想暴露「已读」的场景)。
# 5. 用户侧 UI
对应 [IM 即时通讯 -> 聊天] 工作台,对应 @/views/im/home/pages/conversation 目录。左侧会话列表(ConversationItem.vue,展示最后一条预览、未读红点、免打扰小点、草稿标记),右侧消息面板(MessagePanel.vue)。

底部输入区(MessageInput.vue)的工具栏依次是表情、图片、文件、语音、视频;文本框支持 Enter 发送、Shift+Enter 换行。群聊里输入 @ 弹出成员选择(MentionPicker.vue),可 @ 某人或 @全体成员。
不同消息类型由 MessageBubble.vue 按 type 分发渲染(文本 / 图片 / 语音 / 视频 / 文件 / 表情 / 名片 / 合并转发 / 素材卡片)。
在消息上右键打开操作菜单(ContextMenu.vue):撤回(本人 2 分钟内)、引用回复、转发、多选、复制、添加到表情、群消息置顶(群主 / 管理员)等。

私聊里自己发的消息尾部显示「已读 / 未读」;群聊里自己发的消息可点尾部「N 人已读」查看已读 / 未读成员名单。


# 6. 运营侧
运营侧在 [IM 即时通讯 -> 私聊管理 / 群聊管理 / 频道管理] 下,分别由 ImPrivateMessageManagerController、ImGroupMessageManagerController、ImChannelMessageManagerController 提供只读分页查询,用于排查会话内容;不提供代发 / 修改。三类消息管理页结构一致,下图以私聊消息为例;频道消息的推送另见 《频道》。
