05 聊天室与控制消息¶
状态:可实施 更新日期:2026-08-14 上游契约:
docs/PLAN.md§6.6、§7.12、§7.15、§13、§14 实施决策:ADR-0007(聊天室进程内环形缓冲)
0. 文档边界¶
本文定义:房间广播实现、回放窗口、控制消息与自定义消息注册表、 表情回应执行器、RTC 信令转发。
本文不得重新定义:room_seq 格式(§6.6)、§13.6 的回应投递语义、
§12.9 控制事件表的行为、ephemeral_aggregate 的定义。
1. 聊天室¶
1.1 与普通群的分界¶
普通群 全员持久邮箱引用、完整历史、conversation_seq 权威顺序
聊天室 只有 room_seq、实时广播、短期回放;**不产生 UserMailboxEntry**
不进会话列表投影、不计未读、不触发离线推送
普通群历史的可见区间(ADR-0021,2026-09-26):成员区间
(joined_at, left_at)与会话提交 事实在同一原子区写入。建群初始成员joined_at = 0;入群/退群/移除各是一条占用conversation_seq的CONTROL消息(custom_type = qim.membership.v1),入群joined_at = J − 1、退群/移除left_at = J。历史、定义式未读与会话头都按区间裁剪;退群者仍可读退群点之前的历史,从未入群者拒绝。 此前“仅 initial cohort 可读全量历史”的过渡策略已被取代。
聊天室消息有 message_id(用于客户端去重与举报追踪)但无 conversation_seq(§6.6)。
1.2 广播路径¶
RoomWriter(单写,持有房间租约)
1. 分配 room_seq = (room_epoch << 48) | counter
2. 写进程内环形缓冲(§1.3)
3. 查该房间的在线连接分布 -> 按 ConnectionShard 分组
4. 每个有在线成员的 ConnectionShard 发一个合并批次
**不向 0 在线的 ConnectionShard 发送**
ConnectionNode
5. 展开为 ROOM_BATCH,写入本节点该房间的 Socket
公共正文编码一次,每 Socket 只生成轻量帧头(与 §10.3 同构)
房间成员集合不做持久化 Bitmap——它随连接生灭,直接由 ConnectionNode 的
room_id -> connections 内存索引承载。这是与大群 fanout 的根本差别:
大群要保证离线可达所以需要成员版本快照,聊天室不保证。
1.3 环形缓冲(ADR-0007)¶
struct RoomRing {
buf: VecDeque<RoomRecord>, // 按 room_seq 递增
max_items: usize, // 条数上限
retention: Duration, // room_log_retention_minutes(30)
}
// 双上限,先到者触发淘汰:条数 与 时间
// 不写盘、不跨节点复制、不进检查点
重启/迁移的语义闭环:
RoomWriter 重启 -> room_epoch 递增(§6.6)
-> 客户端在 ROOM_BATCH 中看到 epoch 变化
-> 按 §14 直接跳到 latest_room_seq,置 replay_truncated=true
-> 与"离线超出回放窗口"完全同一条路径,不需要任何新增规则
这是全设计唯一允许丢失的持久化数据,正当性来自 §14 已声明的
「超出回放窗口是聊天室的正常稳态」。若产品要求回放跨重启存活,
才需引入外部存储(Redis zset 是合适载体),届时按 ADR-0007 复评。
1.4 回放¶
ROOM_REPLAY{room_id, after_room_seq}
after_room_seq 在窗口内 -> 回放该区间
超出窗口或 room_epoch 不符 -> 空 events[] + replay_truncated=true
客户端跳到 latest_room_seq
ROOM_BATCH 兼作 ROOM_JOIN 与 ROOM_REPLAY 的应答(附录 A.4)
earliest_replay_room_seq 让客户端 O(1) 判断本地缺口是否可补
不用 ERROR 表达"超出窗口"——那不是错误,是聊天室的正常稳态。
1.5 限速(§14 的硬约束)¶
room_msg_rate 20 msg/s / 房间 写入侧限速
room_outbound_frame_rate 10 frame/s / 连接 出向合并后限速
max_rooms_per_connection 20
为什么 §11.3 的水位不够:它是事后被动降级,不是事前限速。 100 万在线房间的广播可挤占共享 ConnectionNode 并触发全局重连风暴, 必须在写入侧与出向侧各设一道硬限。
出向合并:同房间 room_outbound_frame_rate 窗口内的多条消息合并进一个
ROOM_BATCH 的 events[],而不是发多帧。
1.6 提升为持久群会话(§14.4)¶
提升时刻起该会话开始分配 conversation_seq,从 1 开始
提升前的房间消息**不进入持久历史**
成员 joined_at_conversation_seq = 0(提升时刻尚未分配任何 seq)
提升后按普通群走 §10 的 fanout 路径
2. 控制消息¶
2.1 分类(§13.1、§13.2)¶
持久控制事件 进邮箱、可离线恢复
MARK_READ 的跨设备同步、RECALL、EDIT、成员变更
瞬时消息 只发在线连接、不进邮箱、不影响会话列表
TYPING、PRESENCE_SUB、RTC 信令、房间互动效果
聚合瞬时 不进邮箱,但持久的是聚合结果(§13.6)
REACT / REACTION_UPDATE
2.2 已读同步(§9.7)¶
邮箱是**用户级**键空间,游标是**设备级** -> 已读事件会产生自回声
解决三件套:
UserMailboxEntry.origin_device_id 标记发起设备
服务端不向发起设备回推该事件
read_sync_merge_window(3s) 窗口内同一会话只写最后一条
read_conversation_seq 只进不退(取 max)
跨设备未读**下降**通过重算而非负增量实现(§12.5 的三态判定)
2.3 撤回与编辑(§13.4)¶
固化顺序(存储侧动作见 docs/02 §5.3):
1. 先写 CONTROL 事件到提交日志(分配自己的 conversation_seq)
2. 再 UPDATE MessageRecord 的 state 与 recall_event_conversation_seq
顺序不可颠倒:recall_event_conversation_seq 非空 == "事件已入日志"
崩溃恢复据此判定是否补发事件;反向顺序会在崩溃窗口内永久丢失撤回事件
时间轴锚点取 target_conversation_seq(§6.9.1):撤回/编辑原地更新已有行,
不在时间轴新增行。但它分配自己的 conversation_seq(因为要可靠投递给离线成员),
两者不矛盾——分配 seq 是为了投递,锚点取 target 是为了渲染。
anchor_target 事件不推进投影的"最新位置"(§12.4.2),否则连续两次撤回会
使预览修复失效。
2.4 窗口¶
recall_self_window 2 min 本人撤回
edit_self_window 15 min 本人编辑
管理员撤回不受窗口限制,走 ModerationService 路径
3. 自定义消息注册表(§13.3)¶
每种自定义消息必须在注册表中声明,服务端据此决定投递属性—— 不解析载荷(E2EE 下也无法解析):
custom_type string,租户内唯一
schema_version int
delivery_class persistent | ephemeral | ephemeral_aggregate
counts_unread bool
affects_session_order bool
timeline_visible bool
anchor_mode own_seq | anchor_target | none
preview_template string,E2EE 下用通用占位
max_payload_bytes <= max_custom_payload_bytes(32 KiB)
minimum_client_version string
类型级契约的意义:counts_unread / affects_session_order 是类型级属性,
不需要解析明文。这正是 §22.3 判定"E2EE 不影响未读计数与排序"的依据。
未知 custom_type 的客户端行为:按 preview_template 展示通用占位,
不得导致整个同步批次失败(§27.1.2)。
4. 表情回应执行器(§13.6)¶
4.1 写路径¶
REACT{conversation_id, conversation_seq, reaction_key, action}
1. 限流 per_user_reaction_rate(5/s),与 per_user_msg_rate 独立计量
2. 幂等:(tenant, conversation, seq, user, reaction_key) 为键
重复 add 或对不存在的 remove 一律返回成功,不改变聚合值
3. 明细:会话成员数 <= reaction_detail_max_members(1000)
且单条明细数 <= reaction_detail_max_per_message(10000)
时写 MessageReaction;否则只累加聚合
4. 聚合:UPDATE message_reaction_summary SET counts=?, summary_version=summary_version+1
5. 推送:只推该会话**当前在线**的成员
4.2 推送与合并¶
REACTION_UPDATE 是幂等绝对值帧:counts 是全量映射,不是增量
合并窗口 reaction_push_merge_window(2s):窗口内只发最后一帧
—— 因为是绝对值,合并即丢弃前帧,无累加风险
永不写 UserMailboxEntry、永不计未读、永不改排序、永不触发离线推送
离线成员在下次读消息时随聚合取回(docs/02 §4.4 的同批取回)
4.3 与其他机制¶
| 机制 | 回应的行为 |
|---|---|
| 撤回/删除消息 | 目标消息 state=RECALLED/DELETED 时聚合与明细一并物理删除 |
| 加密擦除 | 聚合不含正文、无 dek_id,物理删除即可 |
| E2EE | reaction_key 是短枚举字符串,不加密(不构成消息内容,且服务端需聚合) |
mention_only |
不受影响(回应本就不写邮箱,两档一致) |
5. RTC 信令(§13.5)¶
RTC_SIGNAL{conversation_id, target_user_id, signal_payload}
只在**在线连接**间转发:不入邮箱、不影响会话列表、不计未读
被叫离线 -> 走 §16 的高优先级推送(iOS VoIP push,Android FCM high priority)
该推送**不写 UserBadgeState**、不改角标
呼叫生命周期:振铃超时 rtc_ring_timeout(60s)、忙线、取消
VoIP 配额:rtc_voip_push_daily_quota(200 次/设备/天) + per_user_msg_rate 双重限制
媒体流**不经过** IM 系统任何组件,由独立 SFU/TURN 承载,
IM 只在 signal_payload 中透传地址与凭证
通话记录是普通持久消息,不是信令:通话结束后写一条
custom_type = call_record 的持久消息走 §13.3 契约,counts_unread 按产品配置
(未接来电通常为 true)。这样"通话记录出现在聊天记录里、可离线同步、跨设备一致"
是自然结果,不需要给 RTC 信令加任何持久语义。
6. 验收¶
C-1【发布阻断】回应不产生邮箱写入
10 万人群一条消息、5000 个用户各 REACT 一次:
新增 UserMailboxEntry 条数 == 0
任何成员 unread/角标/排序变化次数 == 0
离线推送 PushTask 产生数 == 0
REACTION_UPDATE 只发给在线成员,离线成员收到次数 == 0
同一 conversation_seq 在 reaction_push_merge_window 内下发帧数 <= 1
C-2 环形缓冲重启语义
RoomWriter 重启后:room_epoch 递增,客户端收到 replay_truncated=true
并跳到 latest_room_seq;不产生 ERROR 帧;消息顺序无倒错
C-3 聊天室限速
单房间注入 200 msg/s:写入侧按 room_msg_rate 拒绝超额
单连接出向帧率 <= room_outbound_frame_rate,超额消息被合并进同一 ROOM_BATCH
共享 ConnectionNode 上的单聊投递延迟 P99 不受影响(隔离验证)
C-4 已读自回声
设备 A 标记已读:A 自己收到该 CONTROL 事件的次数 == 0
设备 B 收到并推进 read_conversation_seq
3 秒内连续标记 10 次:写入邮箱的事件数 <= 1(合并窗口)
C-5 撤回固化顺序
在"写日志后、改 state 前"注入崩溃:
恢复后据 recall_event_conversation_seq 为空补发事件
撤回事件最终可见,丢失次数 == 0
C-6 未知 custom_type
下发注册表中不存在的 custom_type:
客户端渲染占位、游标正常推进、批次不失败、断连次数 == 0
7. 待办¶
[ ] 自定义消息注册表的存储与热更新通道(租户级配置)
[ ] 房间成员在线数的对外接口(当前只有 Bitmap 可算 count,无 API)
[ ] SFU/TURN 凭证签发与 signal_payload 的透传格式约定