前三篇讲了实时通道、可靠投递、消息存储。核心场景都是一对一私聊——A 发给 B,一目了然。
但实际工作场景里,审批通知要发到"全体员工群",项目讨论有"研发群",跨部门协作有"专项小组"——这些都需要群聊。
群聊和一己私聊的区别,不是简单地从"两个人"变成"多个人"。真正复杂的地方在于:
成员随时可以加入或离开
每个人看到的消息范围可能不同(新加入的能不能看历史?)
每个人未读多少条怎么算?
群主、管理员、普通成员权限怎么区分?
Workforce Hub 的 IM 模块在私聊基础上完整实现了一套群聊系统,这篇文章把其中最关键的设计讲清楚。
一、群和私聊的本质区别:一张表还是两张表?
先说一个设计上的决策问题:群聊是单独建表,还是复用私聊的会话表?
Workforce Hub 的答案是:复用同一张 im_conversation 表,通过 conversation_type 字段区分。
CREATE TABLE `im_conversation` (
`id` BIGINT NOT NULL COMMENT '会话ID',
`conversation_type` VARCHAR(32) NOT NULL COMMENT '会话类型: PRIVATE/GROUP',
`conversation_key` VARCHAR(256) DEFAULT NULL COMMENT '会话标识键',
`last_message_seq` BIGINT NOT NULL DEFAULT 0 COMMENT '最后消息序号',
`last_message_id` BIGINT DEFAULT NULL COMMENT '最后消息ID',
`last_message_at` DATETIME DEFAULT NULL COMMENT '最后消息时间',
`status` VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
`is_deleted` TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_conversation_key` (`conversation_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
conversation_type = 'PRIVATE' 时是私聊,conversation_type = 'GROUP' 时是群聊。两者的核心差异全部体现在其他关联表里——群有独立的成员表、群资料表,群没有会话 key(不需要搜索"和谁私聊")。
这样做的好处是:消息存储(im_message)和会话列表(im_conversation)的逻辑完全统一,不需要为群聊单独写一套发送逻辑。
二、群成员管理:谁在这个群里?
私聊的成员关系是隐式的——两个用户之间只要有会话 key 就能互发。群聊不一样,每个成员需要明确记录:谁进来的、什么角色、能不能说话。
CREATE TABLE `im_conversation_member` (
`id` BIGINT NOT NULL COMMENT '主键',
`conversation_id` BIGINT NOT NULL COMMENT '会话ID',
`member_type` VARCHAR(32) NOT NULL COMMENT '成员类型: USER/AGENT',
`member_id` BIGINT NOT NULL COMMENT '成员ID',
`member_role` VARCHAR(32) NOT NULL DEFAULT 'MEMBER' COMMENT '角色',
`join_source` VARCHAR(32) DEFAULT NULL COMMENT '加入来源',
`mute_flag` TINYINT NOT NULL DEFAULT 0 COMMENT '免打扰',
`pin_flag` TINYINT NOT NULL DEFAULT 0 COMMENT '置顶',
`last_read_seq` BIGINT NOT NULL DEFAULT 0 COMMENT '已读序号',
`joined_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`left_at` DATETIME DEFAULT NULL COMMENT '离开时间',
`status` VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_conversation_member` (`conversation_id`, `member_type`, `member_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会话成员';
几个关键字段:
member_role:OWNER(群主)、ADMIN(管理员)、MEMBER(普通成员)三级。群主可以转让、解散;管理员可以踢人、设置公告;普通成员只能看消息和发言。mute_flag和pin_flag:免打扰和置顶,客户端 UI 用这两个字段决定会话的展示顺序和通知策略。last_read_seq:这个字段是已读回执的核心,后文重点讲。left_at:软删除字段。用户退群时不真正删除记录,而是打上退群时间戳。这样如果该用户重新加群,可以选择是否恢复历史消息可见范围。
三、群类型:组织群、业务群和自定义群
不是所有群都一样。Workforce Hub 里有三种群,对应不同的业务场景:
群类型决定了能否解散、能否手动拉人、历史消息可见范围这些关键行为。
// 群资料表中的类型定义
public class ImGroupProfileRow {
String groupType; // ORG / BIZ / CUSTOM
String historyVisibleMode; // FULL / AFTER_JOIN ← 历史消息可见范围
String speakMode; // ALL / ADMIN_ONLY / MUTED ← 发言权限
}
historyVisibleMode 是我自己踩过的一个坑:员工 A 在 5 月 1 日加入"项目群",他能看到 5 月 1 日之前的历史消息吗?设成 FULL 可以,设成 AFTER_JOIN 就只能看加入后的。最开始没想清楚这个字段,上线后用户投诉"为什么我看不到之前的讨论",才回头补上了这个设计。
四、群事件:成员变了要通知谁?
私聊里只有两个人,一方操作直接通知另一方。群聊的逻辑复杂得多:有人入群,所有人要知道;有人被踢,所有人也要知道。
Workforce Hub 定义了 5 种群事件类型:
public class ImWebSocketEventTypes {
public static final String GROUP_PROFILE_UPDATED = "IM_GROUP_PROFILE_UPDATED"; // 群资料更新
public static final String GROUP_MEMBERS_ADDED = "IM_GROUP_MEMBERS_ADDED"; // 成员加入
public static final String GROUP_MEMBERS_REMOVED = "IM_GROUP_MEMBERS_REMOVED"; // 成员被移除
public static final String GROUP_MEMBER_LEFT = "IM_GROUP_MEMBER_LEFT"; // 成员主动退群
public static final String GROUP_DISMISSED = "IM_GROUP_DISMISSED"; // 群被解散
}
事件触发时的推送策略:全员推送 vs 增量推送。
这里有个容易忽略的细节:被踢的人和普通群成员,收到的推送内容不一样。被踢的人收到的是"您已被移出",其他成员收到的是"XXX 被移出"。推送结构里需要区分这两种上下文,客户端渲染时走不同的 UI 分支。

五、已读回执:未读数怎么算?
已读回执是 IM 里用户体验最敏感的细节之一。用户在群里收到 200 条消息,只需要看最后有没有"已读"就够了——这背后是怎么算出来的?
Workforce Hub 的计算方式简洁到不可思议:未读数 = 会话最后消息序号 - 成员已读序号。
-- 对话列表查询中的未读数计算
SELECT
c.id,
c.conversation_type,
GREATEST(c.last_message_seq - m.last_read_seq, 0) AS unread_count
FROM im_conversation c
JOIN im_conversation_member m ON c.id = m.conversation_id
WHERE m.member_type = 'USER'
AND m.member_id = ?
AND m.status = 'ACTIVE'
AND c.status = 'ACTIVE'
AND c.is_deleted = 0
ORDER BY c.last_message_at DESC
一个 GREATEST(..., 0) 直接解决了"没有消息时未读数不为负"的问题。
已读序号的更新逻辑也有讲究:
public void updateMemberLastReadSeq(Long conversationId, Long userId, Long lastReadSeq) {
jdbcTemplate.update("""
UPDATE im_conversation_member
SET last_read_seq = GREATEST(last_read_seq, ?)
WHERE conversation_id = ?
AND member_type = 'USER'
AND member_id = ?
AND status = 'ACTIVE'
""",
lastReadSeq,
conversationId,
userId
);
}
用 GREATEST(last_read_seq, ?) 而不是直接 last_read_seq = ?——这是防止多端同步时的回退问题。用户手机看了 50 条,PC 端还在看第 30 条,如果 PC 端先更新已读位,就会把手机端的未读数覆盖掉。GREATEST 保证了已读序号只往前推,不会回退。
六、已读回执的触发时机
什么时候更新已读序号?直觉答案是"用户看到消息就更新",但具体实现有几种策略:
策略一:滑动窗口触发(最常见)
用户滚动消息列表,滑到哪,更新到哪。这是微信、企业微信采用的策略——用户体验自然,但实现复杂,需要前端配合。
策略二:进入会话触发
用户点进群聊,立即把 last_read_seq 更新为当前会话最大序号。这是最简单的实现,但缺点是用户可能只是进来瞄一眼,未读数直接清零,用户体验不准确。
策略三:时间触发
用户在会话内停留超过 X 秒(比如 5 秒),认为是"已读",才更新已读序号。兼顾简单和准确性。
Workforce Hub 目前采用的是进入会话触发 + 前端配合滑动更新的混合模式。UpdateReadPositionRequest 接收客户端传上来的 lastReadSeq,服务端只负责存储,不主动推送清零。
// 服务端更新已读位点
public void updateReadPosition(Long conversationId, UpdateReadPositionRequest request) {
Long userId = requireActiveEmployeeUserId();
ensureCanAccessConversation(conversationId, userId);
Long safeReadSeq = Math.max(request.getLastReadSeq(), 0L);
imMessageRepository.updateMemberLastReadSeq(conversationId, userId, safeReadSeq);
// 推送已读事件给会话内的其他在线成员
webSocketPushService.pushToConversationMembers(
conversationId,
userId,
ImWebSocketEventTypes.MEMBER_READ_POSITION_UPDATED,
Map.of("userId", userId, "lastReadSeq", safeReadSeq)
);
}
最后一行推送 MEMBER_READ_POSITION_UPDATED 事件是可选功能:通知其他成员"某人的已读位在这里"。实现这个功能的好处是可以在 UI 上展示"张三已读"的小红点,坏处是推送量随群人数线性增长,大群要慎重。

七、发言权限:谁能在群里说话?
不是所有人都有权在群里发言。有些群设置了全员禁言(公告群),有些群只有管理员能说话。speak_mode 字段控制这个行为:
发言权限在发送消息时检查,而不是在入群时。这样群主可以在群里随时一键全员禁言,不需要踢人再重建。
八、群聊消息的推送:每个成员都要推吗?
这是群里发消息和私聊最大的区别。
私聊:一条消息推给一个人。
群聊:一条消息要推给 N 个人(当前群成员数)。
Workforce Hub 的推送路径是:
用户发送消息 → outbox 模式可靠存储(同第 5 篇)
消息持久化成功 → WebSocket 推送
IM_MESSAGE_SENT事件ImWebSocketPushService根据conversation_type决定推送范围:私聊:推给另一个成员
群聊:遍历所有
status = 'ACTIVE'的成员,分别推送
这里有个优化点:不在线的成员不需要推送。推送只针对当前 WebSocket 连接在线的成员,离线成员下次上线时从消息存储里拉取历史即可。这样避免了大量无意义的 WebSocket 连接和推送。
九、总结
这篇文章的核心是群聊和一己私聊的设计差异:
群聊里的每个设计点几乎都是从私聊扩展而来,但扩展的过程中有大量细节需要想清楚:成员离开是软删除还是硬删除?新成员能看多久的历史?发言权限变更要实时生效还是下次发消息才检查?
这些问题看起来小,上线后任何一个处理不好都会变成用户投诉。Workforce Hub 的群聊模块目前覆盖了从建群、成员管理、已读回执到发言控制的完整链路,下一步的优化方向是大群(500+ 成员)的推送性能——目前的全员遍历推送在大群里需要加入分片和优先级机制。
默认评论
Halo系统提供的评论