SpringBoot+Vue3 企业 IM 即时通讯系统设计:WebSocket、会话三表、未读数与在线状态全流程拆解
🌐 文档地址:ruoyioffice.com | 📦 源码1:gitcode.com/zhouzhongya… | 📦 源码2:gitcode.com/zhouzhongya… | 📦 源码3:github.com/yuqing2026/…
企业内部沟通最怕的不是“没有聊天工具”,而是聊天、审批、通知、组织架构各自分散:流程催办在 OA,临时沟通在微信群,文件在网盘,员工信息在通讯录。RuoYi Office 的 IM 即时通讯不是单独做一个聊天页面,而是把 WebSocket 长连接、会话三表、组织选人、未读数、在线状态和 OA 场景入口打通,让企业协同从“发消息”升级为“业务上下文内的即时沟通”。
▲ IM WebSocket 架构:前端通过 /infra/ws 建立长连接,消息发送、已读回执走 WebSocket;会话列表、历史消息、成员配置走 REST API;后端统一更新会话摘要、成员未读数并向在线用户推送消息和会话变更。
引言:企业 IM 到底难在哪?
“做个聊天窗口不就是 WebSocket + 输入框吗?”如果只是 Demo,确实可以很快;但企业 IM 真正难的是可靠性、权限边界和业务融合。
会话不能重复:A 找 B 聊天、B 找 A 聊天,本质上应该进入同一个单聊会话。如果没有唯一约束,系统里会出现两个会话,历史消息被拆散。
消息不能乱入:用户只能往自己所在的会话发消息。后端必须校验会话成员关系,不能相信前端传来的 conversationId。
刷新后不能丢上下文:WebSocket 负责实时推送,但历史消息、会话分页、未读数、置顶免打扰等状态必须落库,不能只存在浏览器内存里。
未读数要按人计算:同一条消息对发送人是已读,对其他成员是未读;同一个会话里,不同成员的未读数也完全不同。
在线状态要基于连接事实:企业 IM 的在线/离线不能靠前端自己猜,而应该由服务端 WebSocket Session 判断。
| 痛点场景 | 简单做法 | 企业级后果 |
|---|---|---|
| 单聊重复 | 每次点击用户都新建会话 | 历史记录分裂,未读数混乱 |
| 只做 WebSocket | 消息只在内存广播 | 刷新丢消息,无法分页查询 |
| 不校验成员 | 前端传什么就发什么 | 越权向任意会话发消息 |
| 未读数放前端 | 浏览器本地累加 | 多端登录、刷新、重连后不一致 |
| 在线状态放前端 | 心跳时间自行判断 | 无法反映真实连接状态 |
本文就以 RuoYi Office 的 IM 模块为例,完整拆解一套 Spring Boot + Vue3 企业即时通讯系统的真实实现。
一、业务场景:IM 不是聊天工具,而是协同入口
1.1 企业内部 IM 的三类核心场景
企业 IM 和社交通讯工具不同,它天然和组织、流程、文件、通知绑定。
| 场景 | 用户动作 | 系统能力 |
|---|---|---|
| 同事单聊 | 从组织架构选择某个同事 | 自动创建或复用单聊会话 |
| 项目群聊 | 选择多名成员并填写群名称 | 创建群聊,成员共同接收消息 |
| 审批协同 | 从审批上下文进入聊天 | 携带 conversationId、流程名称等上下文 |
| 文件图片沟通 | 粘贴截图、上传附件 | 复用统一文件上传,再发送图片/文件消息 |
| 消息提醒 | 不在当前会话时收到新消息 | 会话未读数增加,列表排序更新 |
RuoYi Office 当前实现聚焦内部 IM 的 MVP 闭环:单聊、群聊、文本消息、图片消息、文件消息、会话列表、历史消息、未读数、已读回执、置顶、免打扰、在线状态。
1.2 为什么企业 IM 要放在 OA 里?
传统企业沟通经常出现一个割裂链路:
- 审批在 OA 中流转;
- 业务沟通跑到微信或钉钉群;
- 文件又被单独发到网盘;
- 最后回到 OA 手动填写处理意见。
这会带来一个问题:业务证据和沟通上下文分离。当某个审批、会议、用印、公文发生争议时,系统里只有流程节点,没有沟通过程。
内置 IM 的价值不在于替代所有社交工具,而是让关键业务场景具备“即时沟通 + 留痕 + 可回到业务单据”的能力。
1.3 本文拆解的真实代码范围
本文基于 RuoYi Office 当前实现展开,核心文件包括:
| 层级 | 文件 | 作用 |
|---|---|---|
| 前端页面 | apps/web-antd/src/views/oa/im/index.vue | IM 主界面、WebSocket、消息发送、会话操作 |
| 选人弹窗 | components/user-picker-modal.vue | 按组织架构筛选用户,创建单聊/群聊 |
| WebSocket 工具 | src/utils/websocket.ts | 构建 ws/wss 地址并附带 token |
| 后端服务 | ImConversationServiceImpl.java | 会话创建、消息发送、未读数、在线状态 |
| 后端接口 | ImConversationController.java | 会话分页、历史消息、已读、成员配置 |
| WS 监听 | ImMessageSendMessageListener.java | 接收 im.message.send |
| WS 监听 | ImConversationReadMessageListener.java | 接收 im.conversation.read |
| 数据库 | create_infra_im_tables.sql | 会话、成员、消息三张表 |
二、系统设计:REST 管状态,WebSocket 管实时
2.1 整体架构
RuoYi Office 没有把所有能力都塞进 WebSocket,而是把“实时”和“查询”分开:
| 通道 | 承载内容 | 原因 |
|---|---|---|
| REST API | 会话列表、历史消息、创建会话、成员配置、未读总数 | 请求响应模型清晰,方便分页、权限、错误处理 |
| WebSocket | 发送消息、接收消息、会话更新、已读回执 | 低延迟推送,适合实时互动 |
| 文件上传 API | 图片、文件内容上传 | 复用基础设施文件服务,消息只保存 URL/元数据 |
这是一种更稳的企业 IM 架构:WebSocket 不承担复杂查询,也不承担大文件传输;它只负责实时事件流转。
2.2 四类 WebSocket 消息
后端通过 ImWebSocketMessageTypeConstants 定义了 IM 消息类型:
| 类型 | 方向 | 含义 |
|---|---|---|
im.message.send | 前端 → 后端 | 用户发送消息 |
im.message.receive | 后端 → 前端 | 服务端推送新消息 |
im.conversation.read | 前端 → 后端 | 用户把会话标记为已读 |
im.conversation.update | 后端 → 前端 | 服务端推送会话摘要、未读数、在线状态变化 |
这里有一个重要设计:发送成功不是靠前端本地乐观追加,而是等服务端推回 im.message.receive。这样前端展示的是服务端落库后的消息 ID、发送时间和发送人信息,避免客户端状态和数据库状态不一致。
2.3 三张表的职责边界
IM 的核心不是一张消息表,而是三张表协同:
| 表 | 职责 | 关键字段 |
|---|---|---|
infra_im_conversation | 会话主表,描述单聊/群聊和最后一条消息摘要 | type, single_key, last_message_id, last_message_preview |
infra_im_conversation_member | 会话成员表,描述每个人在会话中的个人状态 | user_id, top_flag, mute_flag, last_read_message_id, unread_count |
infra_im_message | 消息明细表,保存每一条消息内容 | sender_id, message_type, content, client_msg_id, send_time |
为什么要拆成三张表?因为“会话属性”“成员视角”“消息明细”是三类不同生命周期的数据。
- 会话摘要是公共的:最后一条消息是什么、什么时候发的。
- 未读数是个人的:同一会话对不同成员不一样。
- 消息明细是追加式的:需要按会话分页查询。
2.4 单聊防重复:minUserId:maxUserId
单聊会话最容易出错的地方是重复创建。RuoYi Office 使用 single_key 保存排序后的用户对:
| 用户 A | 用户 B | single_key |
|---|---|---|
| 100 | 200 | 100:200 |
| 200 | 100 | 100:200 |
数据库上再配合唯一索引 uk_infra_im_conversation_single,就能保证同一租户下同一对用户只有一个单聊会话。
三、流程设计:从点击发送到双方会话更新
3.1 发送消息流程
一次文本消息发送,大致经历 9 个步骤:
| 步骤 | 发生位置 | 动作 |
|---|---|---|
| 1 | 前端 | 用户在输入框按 Enter 或点击发送 |
| 2 | 前端 | 校验当前会话、消息内容、WebSocket 连接状态 |
| 3 | 前端 | 发送 im.message.send,附带 conversationId、messageType、content、clientMsgId |
| 4 | 后端 WS Listener | 根据 Session 取登录用户和租户 |
| 5 | 后端 Service | 校验消息类型、内容、会话成员关系 |
| 6 | 后端 Service | 根据 clientMsgId 做幂等判断 |
| 7 | 后端 Service | 插入 infra_im_message |
| 8 | 后端 Service | 更新会话摘要,并给其他成员 unread_count + 1 |
| 9 | 后端 Service | 向成员推送 im.message.receive 和 im.conversation.update |
这个流程的核心是:前端只负责发起动作,最终状态以后端落库结果为准。
3.2 已读流程
当用户切换到某个会话,或当前会话收到新消息时,前端会触发已读:
| 场景 | 前端动作 | 后端效果 |
|---|---|---|
| 打开会话 | 调用 REST readConversation | unread_count = 0,last_read_message_id = last_message_id |
| 当前会话收到消息 | 发送 WS im.conversation.read | 同上,并推送会话更新 |
| WebSocket 未连接 | 回退调用 REST 已读接口 | 保证状态仍可落库 |
已读状态放在 infra_im_conversation_member,因为它是成员维度数据:同一个会话里,你已读了,不代表其他成员也已读。
3.3 在线状态流程
在线状态没有单独落库,而是在返回会话时通过 WebSocket Session 判断:
- 后端拿到单聊会话的对方用户;
- 调用
webSocketSessionManager.getSessionList(UserTypeEnum.ADMIN, userId); - 如果用户有活跃 Session,则
targetOnline = true; - 前端在会话列表和标题区展示“在线/离线”。
这种方案的优点是状态实时、无需定时清理;缺点是它表达的是“当前节点/当前会话管理器可见的连接状态”。如果后续做多节点部署,就需要结合统一的 WebSocket Session 管理或 Redis 在线状态。
四、PC 端功能实现:Vue3 聊天页如何组织

▲ IM 即时通讯页面:左侧为会话列表和未读数,右侧为消息区、输入区和会话操作区,支持文本、图片、文件、置顶、免打扰、组织选人创建会话。
4.1 页面结构
index.vue 采用典型的双栏聊天布局:
| 区域 | 功能 |
|---|---|
| 左侧会话栏 | 搜索会话、展示未读数、置顶标记、免打扰标记、在线状态 |
| 右侧标题栏 | 展示当前会话名称、成员数、来自审批的上下文 |
| 消息列表 | 分页加载历史消息,接收实时消息后滚动到底部 |
| 输入区 | 文本输入、Enter 发送、Shift+Enter 换行、粘贴图片 |
| 操作区 | 创建单聊、创建群聊、成员查看、置顶、免打扰 |
用户选择器 user-picker-modal.vue 则负责组织架构筛选:左边部门树,右边用户列表,支持单选创建单聊,也支持多选并填写群名创建群聊。
4.2 WebSocket URL 构建
生产环境前端可能部署在 /web 子路径下,但 WebSocket 需要连接站点根路径代理的 /infra/ws。因此前端没有直接拼接 VITE_BASE_URL + /infra/ws,而是用 window.location.origin 或绝对地址的 origin 生成连接地址。
/**
* 构建 WebSocket 地址。
*
* 生产环境前端部署在 /web 子路径下,VITE_BASE_URL=/web 只适用于 iframe 等前端子路径资源;
* WebSocket 需要连到站点根路径下的后端代理 /infra/ws。
*/
export function buildWebSocketUrl(path: string, token?: string) {
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
const baseUrl = import.meta.env.VITE_BASE_URL as string;
const origin = /^https?:\/\//.test(baseUrl)
? new URL(baseUrl).origin
: window.location.origin;
const url = new URL(normalizedPath, origin);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
if (token) {
url.searchParams.set('token', token);
}
return url.toString();
}
这一段看似很小,却解决了线上部署中最常见的问题:页面路径可以是 /web/,但长连接代理通常挂在根路径 /infra/ws。
4.3 前端监听消息
前端使用 @vueuse/core 的 useWebSocket,开启自动重连和心跳;收到数据后区分消息推送和会话更新。
const socketServer = buildWebSocketUrl('/infra/ws', refreshToken);
const { status, data, send, open } = useWebSocket(socketServer, {
autoReconnect: true,
heartbeat: true,
});
const isSocketOpen = computed(() => status.value === 'OPEN');
watchEffect(() => {
if (!data.value || data.value === 'pong') {
return;
}
try {
const jsonMessage = JSON.parse(data.value);
const content = parseMessageContent(jsonMessage.content);
if (jsonMessage.type === 'im.message.receive') {
handleIncomingMessage(content as InfraImApi.ImMessage);
return;
}
if (jsonMessage.type === 'im.conversation.update') {
upsertConversation(content as InfraImApi.ImConversation);
}
} catch (error) {
console.error(error);
}
});
这里有两个细节值得注意:
pong是心跳响应,不进入业务处理。- 后端推送的
content可能是字符串,也可能已经是对象,前端统一用parseMessageContent兼容。
4.4 消息发送与输入体验
文本消息发送时,前端只做必要校验,然后通过 WebSocket 发送:
| 字段 | 含义 |
|---|---|
conversationId | 当前会话 ID |
messageType | text、image、file |
content | 文本内容、图片 URL 或文件 JSON |
clientMsgId | 客户端消息 ID,用于后端幂等 |
文本消息的 clientMsgId 使用 web-${Date.now()};图片和文件分别用 web-image-${Date.now()}、web-file-${Date.now()}。它不是业务主键,但足够支撑浏览器重试场景下的重复提交保护。
4.5 图片和文件消息
图片和文件不直接走 WebSocket 二进制传输,而是:
- 先调用
uploadFile上传到文件服务; - 图片消息把
content设置为图片 URL; - 文件消息把
content设置为包含name、size、type、url的 JSON 字符串; - 最后发送普通的
im.message.send。
这样可以避免大文件占用 WebSocket 通道,也能复用系统已有文件存储、权限和下载能力。
五、后端核心实现:消息发送为什么要一口气做完?
5.1 WebSocket Listener 只做转发
ImMessageSendMessageListener 的职责非常克制:从 WebSocket Session 里拿登录用户和租户,然后把消息交给业务服务。
| 类 | 职责 |
|---|---|
ImMessageSendMessageListener | 监听 im.message.send,调用 sendMessage |
ImConversationReadMessageListener | 监听 im.conversation.read,调用 markConversationRead |
ImConversationServiceImpl | 执行权限校验、落库、未读数、推送 |
这样 WebSocket 层不会混入复杂业务逻辑,后续如果增加 APP 端或其他客户端,也能复用同一套服务。
5.2 sendMessage 后端逻辑
后端 sendMessage 是整个 IM 的核心,它必须在一个事务里完成消息落库、会话摘要更新、未读数更新和推送准备。
@Override
@Transactional(rollbackFor = Exception.class)
public void sendMessage(Long loginUserId, Long tenantId, ImMessageSendMessage request) {
if (!ImMessageTypeEnum.isSupported(request.getMessageType())) {
throw exception(IM_MESSAGE_TYPE_INVALID);
}
if (StrUtil.isBlank(request.getContent())) {
throw exception(IM_MESSAGE_CONTENT_EMPTY);
}
ImConversationDO conversation = validateConversationMember(loginUserId, tenantId, request.getConversationId());
if (StrUtil.isNotBlank(request.getClientMsgId())) {
ImMessageDO existing = messageMapper.selectByClientMsgId(
tenantId, request.getConversationId(), loginUserId, request.getClientMsgId());
if (existing != null) {
return;
}
}
LocalDateTime now = LocalDateTime.now();
ImMessageDO message = ImMessageDO.builder()
.conversationId(request.getConversationId())
.senderId(loginUserId)
.messageType(request.getMessageType())
.content(StrUtil.trim(request.getContent()))
.clientMsgId(StrUtil.blankToDefault(request.getClientMsgId(), null))
.sendTime(now)
.status(MESSAGE_STATUS_NORMAL)
.build();
message.setTenantId(tenantId);
messageMapper.insert(message);
conversation.setLastMessageId(message.getId());
conversation.setLastMessagePreview(buildPreview(message.getMessageType(), message.getContent()));
conversation.setLastMessageType(message.getMessageType());
conversation.setLastSenderId(loginUserId);
conversation.setLastMessageTime(now);
conversationMapper.updateById(conversation);
}
这段代码体现了几个关键原则:
- 先校验类型和内容:不支持的消息类型不能进入数据库。
- 校验会话成员:只有会话成员才能发消息。
- 用
clientMsgId幂等:浏览器重试不会重复插入。 - 消息和会话摘要同步更新:会话列表不需要每次再查最后一条消息。
5.3 未读数与推送
消息插入后,系统会遍历会话成员:发送人不加未读数,其他成员 unreadCount++,然后给每个成员推送消息和会话更新。
List<ImConversationMemberDO> members =
conversationMemberMapper.selectListByConversationId(tenantId, conversation.getId());
for (ImConversationMemberDO member : members) {
if (!Objects.equals(member.getUserId(), loginUserId)) {
member.setUnreadCount(ObjectUtil.defaultIfNull(member.getUnreadCount(), 0) + 1);
}
conversationMemberMapper.updateById(member);
}
Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(members.stream()
.map(ImConversationMemberDO::getUserId)
.collect(Collectors.toSet()));
AdminUserRespDTO sender = userMap.get(loginUserId);
for (ImConversationMemberDO member : members) {
pushMessage(member.getUserId(), buildMessageResp(member.getUserId(), message, sender));
pushConversationUpdate(member.getUserId(), tenantId, conversation.getId());
}
这里推送了两种事件:
| 推送事件 | 前端处理 |
|---|---|
im.message.receive | 如果正在打开该会话,则追加消息并发送已读 |
im.conversation.update | 更新会话标题、摘要、未读数、排序、在线状态 |
为什么不只推送消息?因为不在当前会话的用户也需要更新左侧会话列表的摘要和未读数。
5.4 已读逻辑
已读逻辑落在成员表上:
@Override
@Transactional(rollbackFor = Exception.class)
public void markConversationRead(Long loginUserId, Long tenantId, Long conversationId) {
ImConversationDO conversation = validateConversationMember(loginUserId, tenantId, conversationId);
ImConversationMemberDO member =
conversationMemberMapper.selectByConversationIdAndUserId(tenantId, conversationId, loginUserId);
if (member == null) {
throw exception(IM_CONVERSATION_NO_PERMISSION);
}
member.setUnreadCount(0);
member.setLastReadMessageId(conversation.getLastMessageId());
conversationMemberMapper.updateById(member);
pushConversationUpdate(loginUserId, tenantId, conversationId);
}
这段逻辑没有修改消息表,因为“是否已读”不是消息的全局属性,而是用户在会话中的个人进度。
5.5 在线状态
单聊会话返回时,后端会计算目标用户在线状态:
private boolean isUserOnline(Long userId) {
if (webSocketSessionManager == null || userId == null) {
return false;
}
return CollUtil.isNotEmpty(
webSocketSessionManager.getSessionList(UserTypeEnum.ADMIN.getValue(), userId));
}
前端只消费 targetOnline,不自行判断连接状态。这样同事是否在线由服务端统一认定,避免多个页面、多端登录时出现口径不一致。
六、数据结构:三表支撑会话、成员和消息
6.1 三表 SQL 片段
下面是 IM 模块最核心的三张表结构片段,来自 create_infra_im_tables.sql:
CREATE TABLE IF NOT EXISTS `infra_im_conversation` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '会话编号',
`tenant_id` bigint DEFAULT NULL COMMENT '租户编号',
`type` tinyint NOT NULL COMMENT '会话类型,1单聊 2群聊',
`single_key` varchar(64) DEFAULT NULL COMMENT '单聊唯一键,格式:minUserId:maxUserId',
`last_message_id` bigint DEFAULT NULL COMMENT '最后一条消息编号',
`last_message_preview` varchar(255) DEFAULT NULL COMMENT '最后一条消息预览',
`last_message_type` varchar(32) DEFAULT NULL COMMENT '最后一条消息类型',
`last_sender_id` bigint DEFAULT NULL COMMENT '最后发言人编号',
`last_message_time` datetime DEFAULT NULL COMMENT '最后一条消息时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_infra_im_conversation_single` (`tenant_id`, `single_key`, `deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='内部IM会话表';
CREATE TABLE IF NOT EXISTS `infra_im_conversation_member` (
`conversation_id` bigint NOT NULL COMMENT '会话编号',
`user_id` bigint NOT NULL COMMENT '用户编号',
`top_flag` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否置顶',
`mute_flag` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否免打扰',
`last_read_message_id` bigint DEFAULT '0' COMMENT '最后已读消息编号',
`unread_count` int NOT NULL DEFAULT '0' COMMENT '未读数量',
UNIQUE KEY `uk_infra_im_member_user` (`tenant_id`, `conversation_id`, `user_id`, `deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='内部IM会话成员表';
CREATE TABLE IF NOT EXISTS `infra_im_message` (
`conversation_id` bigint NOT NULL COMMENT '会话编号',
`sender_id` bigint NOT NULL COMMENT '发送人编号',
`message_type` varchar(32) NOT NULL COMMENT '消息类型',
`content` text NOT NULL COMMENT '消息内容',
`client_msg_id` varchar(64) DEFAULT NULL COMMENT '客户端消息编号',
`send_time` datetime NOT NULL COMMENT '发送时间',
UNIQUE KEY `uk_infra_im_message_client`
(`tenant_id`, `conversation_id`, `sender_id`, `client_msg_id`, `deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='内部IM消息表';
6.2 infra_im_conversation:会话主表
| 字段 | 说明 | 设计价值 |
|---|---|---|
type | 1 单聊,2 群聊 | 区分标题、头像、成员展示逻辑 |
single_key | 单聊唯一键 | 防止 A-B 与 B-A 重复会话 |
name | 群聊名称 | 群聊直接使用会话名称 |
last_message_id | 最后一条消息 ID | 快速定位最新消息 |
last_message_preview | 最后一条消息摘要 | 会话列表无需关联消息表 |
last_message_type | 最后一条消息类型 | 图片/文件可展示 [图片]、[文件] |
last_sender_id | 最后发言人 | 支持前端展示发送人 |
last_message_time | 最后消息时间 | 会话列表排序依据 |
会话主表的重点是“摘要冗余”。IM 列表是高频页面,如果每次都按会话关联消息表查最后一条消息,性能和实现复杂度都会上升。
6.3 infra_im_conversation_member:成员视角表
| 字段 | 说明 | 为什么放在成员表 |
|---|---|---|
user_id | 成员用户 | 会话和用户的关系 |
top_flag | 是否置顶 | 每个人置顶不同 |
mute_flag | 是否免打扰 | 每个人提醒偏好不同 |
last_read_message_id | 最后已读消息 | 每个人阅读进度不同 |
unread_count | 未读数 | 每个人未读数不同 |
成员表是企业 IM 里最容易被低估的一张表。很多系统一开始只做 conversation 和 message,等要做未读数、置顶、免打扰、已读进度时才发现必须补一张“用户-会话关系表”。
6.4 infra_im_message:消息明细表
| 字段 | 说明 | 设计价值 |
|---|---|---|
conversation_id | 所属会话 | 支持按会话分页 |
sender_id | 发送人 | 前端区分自己和他人消息 |
message_type | 消息类型 | 支持文本、图片、文件扩展 |
content | 消息内容 | 文本、URL 或文件 JSON |
client_msg_id | 客户端消息 ID | 支持幂等 |
send_time | 发送时间 | 消息排序和展示 |
client_msg_id 的唯一索引范围是 tenant_id + conversation_id + sender_id + client_msg_id + deleted,这意味着同一个用户在同一个会话内重复提交同一个客户端消息 ID,只会落一条消息。
七、RuoYi Office 创新设计
7.1 用“会话三表”替代单表聊天记录
很多聊天 Demo 只有一张 message 表,但企业 IM 不能停留在“能发消息”。RuoYi Office 用三表拆分后,可以自然支撑:
| 能力 | 单消息表 | 三表模型 |
|---|---|---|
| 会话列表摘要 | 需要反查最新消息 | 会话表直接读取 |
| 单聊防重复 | 需要额外逻辑 | single_key + 唯一索引 |
| 未读数 | 难以按用户维护 | 成员表天然支持 |
| 置顶/免打扰 | 无处存储 | 成员表按用户保存 |
| 在线状态 | 与消息混杂 | 会话响应单独装配 |
这种模型虽然比 Demo 多两张表,但换来的是后续扩展空间。
7.2 WebSocket 与 REST 分工明确
RuoYi Office 没有把历史分页、创建会话、配置修改都做成 WebSocket 指令,而是保留 REST API:
POST /infra/im/conversation/create-single:创建或获取单聊;POST /infra/im/conversation/create-group:创建群聊;GET /infra/im/conversation/my-page:我的会话分页;GET /infra/im/conversation/messages:会话消息分页;PUT /infra/im/conversation/read:标记已读;PUT /infra/im/conversation/member-config:置顶/免打扰。
WebSocket 只处理实时消息和已读事件。这样的边界更清楚,也更符合后台管理系统的工程习惯。
7.3 会话摘要实时推送
每次发送消息后,后端不仅推送新消息,还推送会话更新。这让前端会话列表可以立即刷新:
| 更新内容 | 用户可见效果 |
|---|---|
lastMessagePreview | 左侧列表显示最新消息 |
lastMessageTime | 会话自动排到前面 |
unreadCount | 未打开会话显示未读徽标 |
targetOnline | 单聊在线状态同步刷新 |
这也是为什么 im.conversation.update 很重要:IM 的体验不只在右侧消息区,左侧会话列表同样需要实时。
7.4 和组织架构天然融合
创建会话时,前端不是让用户输入账号,而是通过组织架构选人:
| 能力 | 实现 |
|---|---|
| 部门筛选 | handleTree(depts) 构造部门树 |
| 用户搜索 | 昵称、用户名、手机号、邮箱模糊匹配 |
| 单聊选择 | mode = single,只能选择一个用户 |
| 群聊选择 | mode = multiple,至少两位成员并填写群名称 |
这让 IM 更像企业通讯录的一部分,而不是孤立聊天工具。
7.5 审批上下文入口
index.vue 中读取了路由参数 conversationId 和 processInstanceName,可以从其他业务页面带着上下文进入聊天页。标题区展示“来自审批:xxx”,让用户知道这次沟通是围绕哪个审批发起的。
这就是 OA 内置 IM 的价值:聊天不是目的,围绕业务单据即时协作才是目的。
八、核心代码之外的工程细节
8.1 会话排序
后端和前端都遵循类似排序逻辑:
- 置顶会话优先;
- 最近有消息的会话优先;
- 没有消息时按 ID 倒序兜底。
这样新创建的会话不会沉底,有新消息的会话会自动上浮,置顶会话则固定在前面。
8.2 消息预览
后端 buildPreview 对不同类型消息生成摘要:
| 消息类型 | 会话摘要 |
|---|---|
| 文本 | 最多保留 120 字 |
| 图片 | [图片] |
| 文件 | [文件] 文件名 |
这让会话列表不用理解消息内容结构,只需要展示 lastMessagePreview。
8.3 草稿保存
前端按用户和会话保存草稿 key:
im:draft:{currentUserId}:{activeConversationId}
这解决了一个真实体验问题:用户在 A 会话打了一半,切换到 B 会话,再切回来时,草稿仍然在。
8.4 当前会话自动已读
前端收到新消息时,如果这条消息属于当前打开的会话,会立即追加消息并发送已读:
| 条件 | 动作 |
|---|---|
| 当前会话收到消息 | appendMessage + sendReadMessage |
| 非当前会话收到消息 | 不追加消息,等待 conversation.update 更新列表未读数 |
这符合用户直觉:正在看的聊天窗口不应该继续累积未读数。
九、可扩展方向:从 MVP 到企业级 IM 平台
当前 RuoYi Office IM 已经完成企业内部聊天的基础闭环,后续还可以沿着几个方向增强:
| 方向 | 可扩展能力 | 设计建议 |
|---|---|---|
| 消息状态 | 发送中、发送失败、撤回、删除 | 增加消息状态枚举和操作记录 |
| 群管理 | 群主、管理员、邀请、踢人 | 扩展成员表角色字段 |
| 多端同步 | PC、APP、移动 H5 同时在线 | 统一用户连接管理和设备标识 |
| 离线推送 | 用户离线后转系统通知/短信 | 结合通知中心或消息队列 |
| 搜索能力 | 按会话、成员、内容搜索 | 消息表加全文索引或接入搜索引擎 |
| 消息安全 | 敏感词、审计、导出 | 增加内容治理和审计模块 |
| 大群性能 | 千人群、批量未读 | 异步化成员未读数更新 |
企业 IM 的演进路径通常不是一开始就追求“像微信一样完整”,而是先把内部协同最高频的链路跑顺:组织选人、单聊群聊、消息落库、未读数、在线状态、业
