11. 客户端 SDK(通信层)¶
目标形态:一份 Rust 核心服务所有客户端,绑定一律由工具生成。 一期实现桌面(Windows / macOS / Linux),移动端与浏览器按
ADR-0010§3 的可移植约束预留,加平台不改核心。 契约来源:docs/PLAN.md§6.8 游标、§6.9 排序、§9.3 同步、§9.6 REBUILD、 §27.3 本地存储、附录 A 帧总表、附录 B 参数表;ADR-0002传输、ADR-0008登录对账、ADR-0010本 SDK 的形态与绑定策略。
1. 这个 SDK 存在的理由¶
不是"把帧收发包一层"。协议帧只占实现量的一小部分,真正难的是一组一旦写错就 静默丢消息或重复气泡的端上规则——它们分散在 §6.8 / §9.3 / §9.6 / §27.3, 每接入一个客户端就要重写一遍,而错误不会在联调时暴露,会在用户断网重连、 换设备、翻页到边界时暴露。
SDK 的价值判据只有一条:上层应用即使想违反这些规则也做不到。 凡是"上层自觉遵守"的约定,都不算落地(§5 逐条给出结构性保证的手段)。
1.1 一期实现范围¶
"通用"指核心只有一份,不指一期把所有端都实现完。
下表区分「现在实现」与「接口现在定、实现按需补」——后者不需要改核心,
前提是 ADR-0010 §3 的五条可移植约束从第一天就遵守。
| 项 | 一期 | 说明 |
|---|---|---|
| 桌面(Win/macOS/Linux) | 实现 | Transport = TCP+TLS,LocalStore = SQLite |
| iOS / Android | 绑定就绪 | UniFFI 的 Swift/Kotlin 目标已生成(scripts/gen-bindings.sh);只需按目标平台重新 cargo build(aarch64-apple-ios / aarch64-linux-android) |
| 浏览器 / WASM | 接口就绪 | 需补 Transport = WebSocket、LocalStore = IndexedDB 两个实现 |
| E2EE | 不做 | §21 的 sender-key 模型服务端未接入;本地库预留密文列 |
| 媒体上传下载 | 不做 | MediaService 未实现;local_media_cache 建表不实现回源 |
| 离线推送 | 不做 | NotificationService 未实现(§16) |
2. 与服务端共用契约代码(最重要的结构性收益)¶
qim-proto 常量 / ID 位布局 / 路由 / opcode / 编译期断言
qim-codec 20 字节帧头 + CRC + 分片 + 压缩
↑ ↑
服务端 5 个主链路可执行体 qim-sdk
SDK 直接依赖服务端同一份 qim-proto 与 qim-codec,不另写一套。
后果是帧布局、opcode 值、mailbox_seq 位宽、lane_id 推导、bit63 断言
在两侧不可能漂移——协议不一致这类最常见的联调故障被编译期消灭。
代价是 SDK 继承了这两个 crate 的依赖。可接受:二者都无 IO、无运行时依赖。
docs/01§5.3.5 要求 TCP 与 WebSocket 两条路径复用同一 codec 实现。 一期只做 TCP,但 codec 已在独立 crate,将来加 WS 承载不触碰 SDK 核心。
3. 分层与 crate 划分¶
绑定层(全部生成,无手写代码)
qim-sdk-uniffi Swift / Kotlin / Python ← 移动端与脚本层
qim-sdk-ffi C ABI cdylib + cbindgen 头 ← C/C++/Tauri/任意 dlopen
qim-sdk-wasm JS / TS(浏览器端立项时打开)
↓ 只依赖公开面:句柄 + 值类型 + 单向事件流
qim-sdk 纯 Rust 核心(Rust 应用可直接依赖)
├── api 命令、事件、状态快照 ← 唯一公开面
├── conn 握手、心跳、重连退避、顶号(帧收发在 Transport 后)
├── sync 登录同步、登录对账、REBUILD、游标推进
├── state 客户端状态机(自 qim-loadgen 抽出)
└── port 平台抽象:Transport / LocalStore / Clock / Spawn
↓
平台实现(按目标编译,核心不感知)
native: TCP+TLS SQLite tokio
wasm: WebSocket IndexedDB 宿主事件循环 ← 接口就绪,实现按需补
test: 内存回环 内存表 确定性时钟 ← 单元测试与 loadgen
qim-codec / qim-proto ← 与服务端同一份
port 层是"一份核心服务所有端"的落点。加一个平台 = 加一组
Transport / LocalStore 实现;加一门语言 = 加一个生成器目标。
两者都不改 api 与 sync——端上契约只有一份实现,这正是不维护多套的含义。
测试实现(内存回环 + 确定性时钟)不是附赠品:注入时钟后状态机的重连退避、 心跳超时、pending 老化都能在毫秒内确定性验证,不必靠 sleep 等真实时间。
qim-loadgen 依赖 qim-sdk,并划出一条 SDK 回归泳道:压测总连接的
5%(--sdk-clients 可调,0 = 关闭)由真实 SDK 驱动,两两配对互发,
收尾断言三条不变量——PUSH_EVENTS 未推进游标、message_id 去重无漏、
local_pending 已清零。这不是省代码,是回归保护:SDK 的游标、去重、
对账逻辑从此每次性能门禁都在真实负载下被执行,而不是只有单元测试覆盖。
(泳道用 MemoryStore,避免上千个 SQLite 实例。)
泳道占用连接而非追加,总发送速率因此不变,分位数与历史结果可比较。
两点必须说清,否则会得出错误结论:
- 其余连接刻意留在帧层(
net::Client)。压测要的是SEND_ACK与PUSH_EVENTS的精确时刻,以及只经 ConnectionNode 的 PING 对照组—— 后者是把接入层排队与下游处理分开归因的唯一手段(writer→mailbox 长尾 就是靠它定位的)。全量走 SDK 会丢掉对照组,并把客户端落库耗时混进 "端到端时延",让一次客户端抖动被读成服务端退化。 - 泳道不能自发自收。SDK 对自己发的消息走对账(
SEND_ACK→ResolvePending),回声到达时按message_id去重后静默丢弃, 不产生MessagesAdded(否则 UI 会把同一条显示两遍)。也就是说 自聊时"消息是否真的走完全链路回到本端"在 SDK 公开面上不可观测, 必须配对互发。同理,泳道的到达判重只能按message_id, 不能按conversation_seq——后者只在单会话内唯一,登录同步拉回的 其他会话历史会与本会话 seq 空间重合,看起来像去重失效。
实测代价:1200 连接 / 15,000 msg/s / 30 s 下,开泳道端到端 P99 166 ms、
关泳道 141 ms(60 个完整 SDK 栈 + 每条消息一个 MARK_READ 上行跑在同一台
机器上),两者都在 300 ms 上限内。做容量结论时用 --sdk-clients 0 取同口径。
4. 线程模型与 API 形态¶
上层永远看不到 async。 运行时由绑定层持有(native 为 tokio 独立线程,
wasm 为宿主事件循环;核心自己不持有运行时,见 ADR-0010 §3 约束 1),
公开面是三件套:
命令 qim_send_message(h, conv_id, payload, out_cmid) -> Result
立即返回(只做校验与入队),执行结果一律经事件流回来
事件 qim_next_event(h, timeout_ms) -> Event | Timeout
单条有序流,上层自己 pump;已提供可选回调包装
状态 qim_get_state(h) -> StateSnapshot
不可变快照,随时可取,用于渲染而非驱动逻辑
为什么事件用拉取而不是回调:回调要从 SDK 的 Rust 线程回调进宿主运行时, 跨 FFI 时的重入与线程亲和性问题是这类 SDK 最常见的崩溃来源 (宿主 UI 线程亲和、GC 语言的附加线程、回调里再调 SDK 造成死锁)。 拉取式把线程归属完全交给上层,每种语言都能用自己的方式包成回调或 Stream; 反过来不成立。回调包装作为可选项提供,实现上就是 SDK 内起一个 pump 线程。
事件流必须有序且不丢:有界队列 + 满时阻塞生产者(不丢弃)。 事件流丢事件等于 UI 与本地库不一致,而本地库已经落盘——那是无法自愈的分歧。 队列积压是上层消费太慢,正确处置是让它变慢,不是替它丢数据。
4.1 命令表(一期)¶
下表与 api.rs 的 Command 枚举逐项对应。协议里存在但 SDK 未提供的
能力见 §8——文档列出不存在的命令,绑定作者会照着写然后编译不过。
| 命令 | 说明 |
|---|---|
Connect / Disconnect |
建连与主动断开;断开不清本地库。令牌在 ClientConfig 里给,SDK 不负责登录流程 |
SendMessage { conversation_id, payload, client_message_id, kind, custom_type } |
立即落 local_pending 并返回,UI 可即刻上屏。client_message_id 由 Handle::next_client_message_id 分配 |
Resend { client_message_id } |
用于已标记失败的 pending;成功路径由 SDK 在重连后自动对账重发 |
PullHistory { conversation_id, before_seq } |
向更旧翻页,结果落库并经 MessagesAdded 回来 |
PullSessionList |
拉服务端会话列表(未读会用本地定义式重算后再下发) |
MarkRead { conversation_id, conversation_seq } |
已读水位,本地乐观 + 上行,只进不退 |
PullMembers { conversation_id, offset, limit } |
群成员分页 |
RoomJoin { room_id, after_room_seq } / RoomLeave { room_id } |
加入/退出聊天室并请求短期回放 |
Handle::query_conversations / query_messages / query_conversation / list_pending(本地只读) |
直接读本地库,不产生网络请求。不是 Command 而是句柄方法:它们要立即返回结果,走命令队列则要等一轮事件才能拿到 |
聊天室不写本地库:RoomJoin 的结果走 RoomEvents 而非 MessagesAdded
(后者的语义是"已去重、已落库、已计入会话列表",聊天室三样都没有)。
4.2 事件表(一期)¶
下表与 api.rs 的 Event 枚举逐项对应。
| 事件 | 触发 |
|---|---|
ConnectionChanged { state, reconnect_attempt, next_retry_ms } |
连接状态迁移(含 REBUILD:state = Rebuilding) |
SyncProgress { applied, done } |
登录同步推进 |
MessagesAdded { conversation_id, messages } |
已去重、已落库的新消息(推送 / 同步 / 历史翻页同一出口) |
MessageStateChanged { client_message_id, state, conversation_seq } |
pending → committed / failed |
ConversationsUpdated { conversations } |
会话行的当前绝对值(partial 列表,按会话覆盖) |
ReadStateChanged { conversation_id, read_seq } |
已读水位前进 |
MembersUpdated { .. } |
群成员页 |
Kicked { reason } |
顶号(REPLACED)等。SDK 不自动重连 |
Failed { error, retry_after_ms } |
服务端 ERROR 与本地不可恢复错误 |
RoomEvents { room_id, events, latest_room_seq, replay_truncated } |
聊天室广播或回放。不落库、不计未读、不改会话列表;replay_truncated 必须呈现给用户,否则他以为看到了全部回放 |
LocalStoreReset |
本地库已整库重置(连同游标),上层应清空内存态重新查询 |
MessagesAdded 是新消息的唯一出口:实时推送与登录同步走同一条路径,
上层无从区分,也就无从对二者写出不同的(且必然不一致的)处理。
ConversationsUpdated 携带的未读永远是本地定义式的当前值,
包括服务端列表到达时那一次(见 §6.1)。上层直接覆盖即可,
不要做任何累加或"取较大者"——那会让刚清零的会话被重新标回未读。
同一批新消息,SDK 先发 ConversationsUpdated(已含这批)再发
MessagesAdded。上层若在 MessagesAdded 里再给未读 +1,同一条消息就数了
两遍(qim-gui 曾如此,为"非当前会话"补计数)。新消息的未读增量由前一个
事件全权负责,上层只需要覆盖。
4.3 状态快照字段¶
connection_state、sync_state、reconnect_attempt、next_retry_at_ms、
pending_count、oldest_pending_age_ms、unread_total、server_time_skew_ms、
last_error。
故意不暴露游标(见 §5)。
5. SDK 必须结构性保证的契约不变量¶
每条给出「契约出处 → 违反后果 → 本 SDK 用什么手段让上层违反不了」。 这一节是本文档的核心,实现评审逐条对照。
| # | 不变量 | 违反后果 | 结构性手段 |
|---|---|---|---|
| 1 | 游标只由 MAILBOX_BATCH 应用完成后推进(§6.8) |
游标越过未应用区间 = 静默丢消息 | 公开 API 里没有游标:不可读、不可写、不可注入。游标是 store 私有列,只有 sync 模块在整批落库成功后推进 |
| 2 | PUSH_EVENTS 不推进游标(§6.8) |
同上 | 推送与同步走不同内部入口,推送入口在类型上拿不到游标推进能力 |
| 3 | 去重按 message_id 与 (mailbox_seq, event_ordinal, event_id) 双键,窗口 > fanout_retry_max_window(§27.3.1) |
重复气泡 | 去重在落库事务内完成;窗口下界写成 const _: () = assert!(...) 编译期断言 |
| 4 | 重连后重发任何 pending 前必须先完成邮箱同步与对账(ADR-0008) |
重复消息,或用户以为发失败 | 状态机上 Syncing → Reconciling → Online,Online 之前重试器根本不运行;无"跳过对账"的公开开关 |
| 5 | 会话列表是绝对值覆盖,禁止累加(§27.3.2) | 未读永远清不掉或对不上 | 本地库无"未读 +1"这类写法;ConversationsUpdated 携带整条记录,按 projection_mailbox_seq 大者胜 |
| 6 | 本地库迁移失败必须连同游标一起丢(§27.3.3) | 服务端认为已同步、本地却没有数据 —— 评审 001 的 B-1 同类静默丢消息 | 迁移失败路径只有一条:reset_all_including_cursor();不存在只清消息的函数 |
| 7 | 冲突只允许"丢弃本地、以服务端为准"(§27.3.2) | 本地脏数据永久覆盖服务端事实 | 合并函数签名为 fn merge(server: X, local: X) -> X 且实现只可能返回服务端派生值。例外是三个活跃度字段(last_activity_id / last_message_id / latest_conversation_seq)取 MAX:服务端投影是可重建物化视图、允许滞后于消息本身,而这些值本就源自服务端且天然单调,拿旧快照整行覆盖会让会话行倒退(用户看到"最后一条消息是倒数第二条")。未读与投影版本仍完全由服务端说了算 |
| 8 | 排序只用服务端下发的排序键,客户端不得自造序号(§27.3.1) | 分页重复或漏项 | 查询 API 只提供"按 §6.9 排序键分页",不提供任意插入位置 |
| 9 | 禁止把客户端时间戳作为增量同步下界(禁止方案清单) | 晚到消息永久丢失 | 同步只接受 mailbox_seq 游标;client_send_ts 仅存于 local_pending,不参与任何查询下界 |
6. 本地库¶
SQLite(rusqlite bundled,WAL)。七张表按 §27.3.1 逐字落地:
local_message、local_pending、local_conversation、local_cursor、
local_dedup、local_media_cache、local_meta。
local_schema_version只前向迁移,逐版本执行,不跨版本跳跃。- 迁移失败或版本高于当前代码(降级安装)→ 不变量 6 的单一路径。
local_pending在任何迁移与重置中必须保留:它是尚未提交到服务端的 用户数据。这是唯一不可丢的本地表。- 落库与去重、游标推进在同一事务内提交。分开提交就会出现 "去重记了、消息没落"或"消息落了、游标没推"的半状态。
trait LocalStore(§3 的 port 层)三个实现:SqliteStore(native 默认)、
MemoryStore(单元测试与 loadgen)、IndexedDbStore(浏览器端立项时补)。
都是 SDK 内的实现,不对上层暴露注入点——§27.3.2 的冲突规则与迁移纪律
正是最容易写错、且写错就静默丢消息的部分,交给应用层实现等于把 SDK
存在的理由让渡出去。
七张表的结构与迁移逻辑在 trait 之上、与后端无关:LocalStore 的方法
是"应用一批邮箱条目""推进游标""按排序键翻页"这类语义操作,
不是"执行这条 SQL"。否则每个后端都要重写一遍冲突规则,那就是维护多套。
一期不做落盘加密。密钥托管(Keychain / DPAPI / libsecret)与 SQLCipher
的构建代价留 ADR-0011 单独裁决,届时只换 SqliteStore 一处。
6.1 未读的两个权威输入¶
未读在读路径按定义式现算,不存缓存列(docs/PLAN.md §12.5.1):
unread(u, c) = |{ m | m.conversation_seq > read_conversation_seq
∧ m.sender_id ≠ u }|
两项都不是可选的,各自对应一个曾经出过的现场:
sender_id ≠ u:缺了它,自己发的消息在自己这侧算成自己的未读。 "发送即已读"(SEND_ACK到达后把水位推到自己那条)只是让它不显形 ——本地水位一丢(换设备、清缓存、GUI 用内存库重登),会话列表的未读数 就恰好等于自己上一次发出去的条数。因此LocalStore::open必须收self_user_id:本地库要算未读,就得知道"自己是谁"。read_conversation_seq:它是 user 级、跨设备共享的主动状态, 只存在于服务端(read:{user})。协议若不下发,客户端换设备后只能从 0 起算,把同步回来的历史全计成未读。故SESSION_LIST_BATCH.sessions[]回带该字段(附录 A.4),登录序列自动拉一次会话列表把它取回来—— 不能等宿主开口,否则每个接入方都要自己记得拉,忘了就是一屏假红点。
合入规则只进不退(advance_read 内部取 MAX)。本地反而更高时,
说明离线期间标过已读而 MARK_READ 没送达,此时反向回补一次上行,
两侧收敛。水位是绝对值、幂等,重复发无害。
服务端一期的未读(session/unread.rs 的 ZCOUNT)没有 sender 项:
Redis 的 zset 计数无法按发送者过滤,要过滤就得把区间读出来解码,
而那条路径是 P0 的会话列表首屏(§2.4,500 ms)。在"发送即已读"成立时
两者等价——服务端那份本就是缓存(§12.5.1 明写"以定义式为准"),
端上渲染用的是本地这一份。
7. 连接生命周期¶
Disconnected ──connect──> Connecting ──TLS+AUTH──> Authenticating
↑ │ AUTH_OK(has_offline)
│ ↓
│ Syncing(PULL_MAILBOX 流水线)
│ ↓
│ Reconciling(ADR-0008 对账)
│ ↓
Kicked / 网络断 ◄────────────────────────────────── Online
│ CURSOR_EXPIRED
↓
Rebuilding(§9.6)
- 心跳:起始 60 s,前台上限 120 s,后台上限 240 s;间隔与超时由服务端
经
PONG下发,SDK 只执行不自定(§6.2)。 - 发送超时:3 s 无
SEND_ACK触发 probe,端到端不可用检测 ≤ 7 s(§2.4)。 - 重连退避:指数退避 + 抖动。抖动不是可选项——§5.3.4 的重连风暴 抑制要求错峰,无抖动的整齐退避会让全网客户端在同一时刻一起重连。
- 顶号:收到
KICKED{REPLACED}后不自动重连,转终态并发事件, 由上层决定(自动重连会与另一台设备形成互踢循环)。
8. 一期服务端能力边界(SDK 的降级形态)¶
gateway 当前实际处理的帧决定 SDK 能力上界。已核对代码:
| 帧 | 服务端 | SDK 一期形态 |
|---|---|---|
SESSION_DELTA / BADGE_UPDATE |
未实现 | 会话列表与角标无推送,SDK 在收到新消息后按需重拉 PULL_SESSION_LIST;ConversationsUpdated 事件语义不变,将来服务端补齐推送后上层无感 |
SYNC_COMPLETE(上行) |
未实现 | 同步完成以本端"批次取空"判定,不上报 |
REDIRECT |
未实现 | 不处理重定向;SHARD_MOVED 走通用退避重连 |
TYPING / PRESENCE_SUB / REACTION_* / RTC_SIGNAL |
未实现 | 一期不提供对应 API(提供了也是空转) |
READ_SYNC(0x0406) |
契约已定,服务端未实现 | 一期没有接收方,见下方说明。SDK 不处理该帧(Decoded::Ignored),跨设备已读靠下次 PULL_SESSION_LIST 收敛 |
| 其余(AUTH / PULL_MAILBOX / SEND / PUSH / HISTORY / SESSION_LIST / MARK_READ / MEMBERS / ROOM_*) | 已实现 | 全量支持 |
把"服务端没实现"写进 SDK 文档而不是留给上层试,是因为这类能力缺失 在联调时表现为"调了没反应",最容易被误判成 SDK 缺陷。
8.1 READ_SYNC 为什么定了契约却不实现¶
READ_SYNC 的语义是"推给该用户的其他在线设备"。而一期
gateway/frames.rs 的顶号逻辑是 evict_device(uid, keep)——把该
user_id 下除本连接外的全部连接踢掉,注释写明"一期 user 与 device
合一,按 uid 摘"(§15.3)。
因此一期同一用户不可能有两个设备同时在线,这条帧永远没有接收方。
现在把它实现出来,得到的是一段永远不执行、也永远没被验证过的推送路径。
那比不实现更危险:代码里看起来是"已实现",等真上多设备时第一次执行
就在生产环境。本项目已经吃过一次同类亏——seq.rs::complete() 的注释
写着"热路径不扫描"而代码在扫,正因为没人验证过注释描述的那条路径。
前置条件是多设备本身,不是这条帧:连接注册要从 uid 改成
(uid, device_id)、顶号语义从"顶用户"改成"顶同设备"、AUTH.device_id
要真正参与路由。那是独立的一块工作,且会牵动 §15.3 的契约。
在此之前,跨设备已读的收敛路径是:设备 B 下次 PULL_SESSION_LIST 时
拿回服务端的权威水位 read_conversation_seq(user 级共享的
read:{user},设备 A 标的已读对 B 天然生效),合入本地后按定义式重算
(§6.1)。只是不实时——READ_SYNC 要解决的正是这个实时性,
而不是正确性。
9. 里程碑与验收判据¶
一次交付完整 SDK,内部按风险从高到低排序推进(前两项先做,是因为它们 一旦不成立会推翻后面全部工作量估计):
| # | 工作项 | 状态 | 验收判据 |
|---|---|---|---|
| 1 | port 层接口 + 测试实现(内存回环/内存表/确定性时钟) |
已完成 | 核心可在无网络无磁盘下跑完整状态机 |
| 2 | 公开面定型 + C ABI 绑定 | 已完成 | qim-sdk-ffi 出 cdylib/staticlib;examples/demo.c 连真实服务端跑通全链路 |
| 3 | native 平台实现:TCP+TLS、心跳、重连退避、顶号 | 已完成 | 退避带抖动且不同设备错开(有用例);KICKED 是终态不自动重连 |
| 4 | 本地库七表 + 前向迁移(SQLite) | 已完成 | 重置必然连同游标;local_pending 必然保留(两条用例) |
| 5 | 状态机自 loadgen 抽出 + 落库 | 已完成 | 原状态机用例全部迁入并通过 |
| 6 | 登录同步 + 对账 + REBUILD | 已完成 | Reconciling 是必经态;对账发生在去重之前(有用例) |
| 7 | 不变量测试 | 已完成 | §5 九条逐条有测试,共 17 项 |
| 8 | UniFFI 绑定(Swift/Kotlin/Python) | 已完成 | qim-sdk-uniffi + scripts/gen-bindings.sh 出三端产物;Python 绑定连真实服务端跑通收发/自定义类型/未读/对账(examples/demo.py);核心一行未改 |
| 9 | loadgen 改依赖 SDK + SDK 回归泳道 | 已完成 | loadgen 三份重复实现(状态机/游标/去重,982 行)已删;泳道三条不变量进门禁;15,000 msg/s 不回归(实测 15,011 msg/s,端到端 P99 166 ms) |
| 10 | 契约一致性测试 | 待做 | SDK 与服务端跑同一组编解码用例向量(§5.3.5 同款) |
| 11 | 可移植性守门 | 已完成 | cargo check -p qim-sdk --no-default-features --target wasm32-unknown-unknown 已通过 |
功能覆盖(PC 端实测逐项)¶
| 功能 | SDK | 桌面端 | 实测 |
|---|---|---|---|
| 登录 / 同步 / 对账 / 在线 | ✓ | ✓ | 状态迁移与 §7 一致 |
| 发送 / SEND_ACK / 幂等重发 | ✓ | ✓ | 含重启后不撞幂等键 |
| 实时接收(PUSH_EVENTS) | ✓ | ✓ | 双客户端互通 |
| 离线同步(MAILBOX_BATCH) | ✓ | ✓ | 登录时补齐 |
| 会话列表(网络) | ✓ | /list |
未读数正确 |
| 会话列表(本地) | ✓ | /local |
冷启动即可渲染 |
| 会话内历史(本地) | ✓ | 启动即载入 | 重启后仍在(SQLite) |
| 历史翻页(PULL_HISTORY) | ✓ | /history |
结果落库、按会话身份去重 |
| 已读水位 | ✓ | /read |
只进不退 |
| 未确认列表 / 手动重发 | ✓ | /pending /resend |
|
| 顶号 | ✓ | ✓ | 终态、不自动重连 |
| 重连退避 | ✓ | ✓ | 带抖动 |
| 群聊消息收发 | ✓ | /gmsg |
群会话只是另一个 conversation_id,路径与单聊相同 |
| 群成员分页 | ✓ | /members |
PULL_MEMBERS → MembersUpdated |
| 聊天室 | ✓ | /join /rmsg |
ROOM_* → RoomEvents;不落库、不计未读 |
| 撤回 / 编辑 / 表情回应 / 输入中 | ✗ | ✗ | 服务端亦未实现(§8) |
| 媒体 / 离线推送 / E2EE | ✗ | ✗ | 服务端未实现 |
服务端已实现的帧,SDK 已全部接入。仍缺的能力都缺在服务端(§8):
撤回 / 编辑 / 表情回应 / 输入中 / 媒体 / 离线推送 / E2EE,
以及 READ_SYNC(帧已进契约,但一期 user/device 合一、顶号会踢掉同 uid
的其他连接,没有第二台设备能收,故实现推迟,见 §8.1)。
已交付的 crate¶
qim-sdk 纯 Rust 核心(api/client/state/cursor/dedup/wire/port + native 实现)
qim-sdk-uniffi Swift / Kotlin / Python 绑定(scripts/gen-bindings.sh)
qim-sdk-ffi C ABI 绑定:cdylib + staticlib + 可运行的 examples/demo.c
qim-desktop 桌面客户端:SDK 的最小完整宿主,**只依赖 SDK**
qim-desktop 的"只依赖 SDK"是纪律不是巧合:凡是它写起来别扭的地方
都是公开面的缺陷。首次接入即抓到三处(会话 id 未导出、正文未接上、
client_message_id 跨重启重复),见 ADR-0010 的后果小节。
第 1、2 项排在最前不是因为简单,而是因为它们一旦不成立会推翻后面
全部工作量估计:port 接口划错会渗进每个 async fn 签名,
公开面越界会逼出手写胶水。第 10 项是这两条的持续守门——
ADR-0010 §3 的可移植约束靠 CI 断言而不是靠自觉,
否则第一个 std::thread::spawn 混进核心时没人会发现。
总验收:新增门禁 6「SDK 端到端」——SDK 对真实四服务跑通登录、收发、
离线同步、对账、翻页、REBUILD,与 --e2e 同为逐项断言。
10. 待裁决(不阻塞开工)¶
| 议题 | 何时必须定 |
|---|---|
| 上层应用语言 | 只影响先打开哪个生成器目标(UniFFI / C ABI / wasm),核心与公开面不受影响;里程碑 2 之前定即可 |
| 本地库落盘加密与密钥托管 | ADR-0011,移动端立项前必须定 |
浏览器端的 Transport(WebSocket) 与 LocalStore(IndexedDB) 实现 |
浏览器端立项时;接口一期已定,届时只补实现 |
不在此表内的事:加一门语言、加一个平台。它们已被 ADR-0010 §2/§3
化解为「加一个生成器目标」与「加一组 port 实现」,不构成待裁决议题——
这正是"一个通用 SDK"要买到的东西。