跳转至

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 的透传格式约定