协议示例与集成步骤
本页把分散的接口串成可检查的流程,说明调用顺序、需要保留的状态、重复请求及失败处理。请求契约来自聊天服务当前源码;这些示例不代表所有客户端已经采用相同实现。路由逐项定义见 API 参考,状态和限制见限制与错误。
示例环境与约定
命令使用隔离开发服务,测试账号、会话、消息和附件均应为合成数据。CHAT_TOKEN 必须由该环境内已经完成的测试登录获得。生产地址与本地地址的票据不能互换;原生授权还受系统回调与平台信任规则约束,尤其 Android 当前只允许正式 Account HTTPS 来源。
CHAT_BASE_URL='http://127.0.0.1:4210'
: "${CHAT_TOKEN:?请先在同一测试环境完成授权并设置聊天会话}"
CONVERSATION_ID='11111111-1111-4111-8111-111111111111'
CLIENT_MESSAGE_ID='22222222-2222-4222-8222-222222222222'
上面的两个 UUID 只表示字段格式。会话必须换成当前测试账号有权访问的真实测试会话;每条新消息需要新的 clientId。同一条消息的网络重试则继续使用原 clientId。不要把 Account access token、ID token、LiveKit token 或文档访问地址当作 CHAT_TOKEN。
curl --silent --show-error 保留失败信息且不输出传输进度。不要为排错启用会回显 Authorization 的详细网络日志;只需保存 HTTP 状态、错误码和请求阶段。示例 JSON 中的用户、编号和时间都是合成值。
一、恢复会话并取得同步起点
curl --silent --show-error --fail-with-body \
"${CHAT_BASE_URL}/me" \
-H "Authorization: Bearer ${CHAT_TOKEN}"
正常响应形状如下:
{
"user": {
"id": "33333333-3333-4333-8333-333333333333",
"displayName": "合成测试用户",
"friendId": "A1B2C3D4E5F"
},
"eventCursor": 42
}
| 结果 | 集成端动作 |
|---|---|
| 200 | 记录当前用户及 eventCursor,继续加载会话、群和其他有权资源 |
| 401 UNAUTHORIZED | 本聊天会话不可用,进入重新授权;没有公开 refresh-token 接口可供直接续期 |
| 网络超时 | 区分网络状态与登录失效;未收到 HTTP 响应不能直接解释为密码错误 |
| 500 或 503 | 保留错误码并检查相关服务,不循环创建授权 flow 代替定位依赖故障 |
一种可采用的集成顺序是先取得 eventCursor,再读取业务列表,随后从该 cursor 拉事件。列表加载期间产生的变化可能再次触发读取;通过对象 ID 合并即可避免重复。该顺序是集成建议,并非两端全部状态管理的逐行描述,实际实现见客户端生命周期。
二、构造原生授权证明
App→聊天服务的 verifier 由 32 字节安全随机值编码为无填充 base64url,challenge 为 verifier 的 SHA-256 摘要再做同样编码。下面只在本机生成挑战,不执行授权:
CODE_VERIFIER="$(python3 -c 'import base64,secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode().rstrip("="))')"
CODE_CHALLENGE="$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')"
这两个值在该生成方式下都是 43 字符。只把 challenge 放进开始请求,verifier 保存在当前设备的本次授权状态中。开始请求为:
curl --silent --show-error --fail-with-body \
-X POST "${CHAT_BASE_URL}/auth/native/start" \
-H 'Content-Type: application/json' \
--data "{\"app\":\"starline\",\"screen\":\"login\",\"codeChallenge\":\"${CODE_CHALLENGE}\"}"
服务端返回 authorizationURL、state、expiresAt。App 先保存 pending,再交系统浏览器打开授权地址;用户完成 Account 流程后,聊天服务通过 HTTPS 回调换码,再把一次性 ticket 和原 state 交回 App。最终换票 JSON 为:
{
"ticket": "ONE_USE_TICKET_FROM_CALLBACK",
"state": "EXACT_PENDING_STATE",
"codeVerifier": "VERIFIER_FROM_THE_SAME_FLOW"
}
该对象发送到 POST /auth/exchange。占位文本不能直接用于测试;ticket、state、verifier 必须来自同一未到期流程。回调解析失败、state 不同、重复参数或 ticket 过期时重新开始授权,不删除 proof 字段尝试兼容模式。iOS 与 Android 的 pending 持久化和换票中断恢复并不相同,见登录与会话。
三、发送文字并安全重试
curl --silent --show-error --fail-with-body \
-X POST "${CHAT_BASE_URL}/conversations/${CONVERSATION_ID}/messages" \
-H "Authorization: Bearer ${CHAT_TOKEN}" \
-H 'Content-Type: application/json' \
--data "{\"clientId\":\"${CLIENT_MESSAGE_ID}\",\"text\":\"合成测试消息\"}"
发送前需确认当前用户仍是好友、双方未屏蔽,且该会话的加密模式为 legacy。text 与 mediaId 恰选一种,文字 trim 后非空,最多 2,000 Unicode 码点。普通接口不能把文字与附件合并成一次发送。
| 请求情况 | 预期结果 | 需要保留的状态 |
|---|---|---|
| 首次有效发送 | 201,message.duplicate 为 false | 服务端 message.id 与本地 clientId 对应关系 |
| 同发送者、同会话、同 clientId、同正文与媒体重复发送 | 200,duplicate 为 true | 合并已有消息,不再新增一条气泡 |
| 已使用的 clientId 换正文、附件或会话 | 409 CLIENT_ID_REUSED | 不自动换 ID 继续盲发,先核对原发送意图 |
| 服务端已准备/启用/阻塞 MLS | 409 E2EE_REQUIRED | 不能降级为另一普通接口绕过模式 |
| 服务端可能已经写入,但客户端没收到响应 | 网络失败,结果未知 | 保留原 clientId 与相同内容,允许后续重试核对 |
唯一性在普通单聊消息表中按 senderId、clientId 检查。群消息使用独立消息表,集成端仍应为每个新发送意图生成独立 ID,不依赖不同表碰巧可以重复使用。权限变化后重试可能得到 403/404,并不意味着原消息从未写入。
四、上传附件再发送引用
先准备一份没有个人信息的 15 字节测试文件:
printf 'synthetic-data\n' > synthetic-note.txt
curl --silent --show-error --fail-with-body \
-X POST "${CHAT_BASE_URL}/media?kind=file&name=synthetic-note.txt" \
-H "Authorization: Bearer ${CHAT_TOKEN}" \
-H 'Content-Type: text/plain' \
--data-binary '@synthetic-note.txt'
上传使用原始字节,不能用 multipart 表单替代。成功 201 的 media 包括 id、kind、name、mimeType、size、createdAt;将返回 ID 放入下一条消息:
{
"clientId": "44444444-4444-4444-8444-444444444444",
"mediaId": "55555555-5555-4555-8555-555555555555"
}
此时不带 text。mediaId 必须属于发送者;上传成功并不代表已经向接收者发出消息。第二步失败时应明确呈现“文件已上传、消息未确认”的阶段,不能显示为双方都已收到。
文件下载路径是 GET /media/{id}。上传者可以读取自己的媒体;非所有者需要符合个人历史边界且未被本人隐藏的消息引用,群消息还要求当前成员资格。普通单聊媒体的读取分支不重新检查好友或屏蔽,因此删好友、屏蔽不等于过去仍可见的媒体立即不可读。本人隐藏、历史边界或退群可能改变引用授权;存在其他可见引用时也不能笼统认定该文件一定不可读。普通聊天媒体没有 Range 契约,不要把新事视频接口的分段能力套用到这里。
上传原始数据不等于客户端附件加密。服务器保存的字节与上传字节一致,附件 E2EE 边界见消息与媒体。聊天文件、贴纸、新事媒体与本机记忆附件的上限分别不同,见限制与错误。
五、翻页、增量与消息合并
curl --silent --show-error --fail-with-body \
"${CHAT_BASE_URL}/conversations/${CONVERSATION_ID}/messages?limit=50" \
-H "Authorization: Bearer ${CHAT_TOKEN}"
没有 after/before 时,服务端取最新一页,再以 ID 升序返回。例如可见消息为 11、18、25;显示顺序就是 11→18→25,ID 不要求连续。
| 需求 | 下一请求 | 合并规则 |
|---|---|---|
| 继续向前加载历史 | before=11 | 只取 ID 小于 11 的消息,按 ID 合并到前方 |
| 拉取 25 之后的新单聊消息 | after=25 | 只取 ID 大于 25 的消息,按 ID 去重追加 |
| 初始加载最新页 | 不带 after/before,或 after=0 | 不是从最早历史开始遍历 |
| 群历史翻页 | /groups/{id}/messages?before=... |
使用群 before 规则,不假定支持单聊 after 增量契约 |
非零 after 与 before 不可同时传。before 是严格排除边界,传 0 不表示“从最新开始”,通常得到空结果。空页只能说明当前权限和边界下没有该方向结果,不证明数据库从未有过消息。个人隐藏和历史清理会过滤记录。
服务端分页 ID、客户端 clientId 和普通事件 ID 用途不同。不要以时间戳作为这些游标,也不要把旧页里最后一条消息 ID 当作加载更早历史的 before。
六、长轮询与业务刷新
EVENT_CURSOR=42
curl --silent --show-error --fail-with-body \
"${CHAT_BASE_URL}/events?after=${EVENT_CURSOR}&wait=20" \
-H "Authorization: Bearer ${CHAT_TOKEN}"
服务端每批最多返回 100 个当前用户可见事件,按 ID 升序,并返回 cursor。空批次保持原 cursor。事件携带 kind、conversationId/groupId、可选 messageId 和 createdAt,不是消息正文的完整替代物。
集成端依次处理该批事件、刷新对应资源并保存 cursor;消费失败时不要只推进游标而丢弃需要恢复的业务状态。一次返回满批可继续用 wait=0 消费积压,再恢复正常等待。重连时复用已确认游标,允许对象 ID 去重,不把网络重连等同于清空历史。
普通事件游标与 MLS sequence 属于不同协议。实时位置也没有自动写入普通 events 的契约;不能仅通过等待普通消息事件完成位置更新。消息轮询、MLS 同步、位置读取和通话房间连接应分别处理,见实时同步与位置共享。
七、已读与删除的语义
POST /conversations/{id}/read 使用 {lastMessageId:25}。消息必须属于该会话;已读位置只前进,不因客户端提交更小 ID 回退。UI 应在真实展示/阅读策略满足后更新,不在仅收到通知时直接把对方消息全部标为已读。
DELETE /conversations/{id}/messages/{messageId} 是本人隐藏,不能当双方撤回。DELETE /conversations/{id} 会隐藏本人会话并推进历史边界,restore 只取消隐藏,不恢复该边界之前的内容。删除好友、屏蔽、退出群和解散群又是其他操作。有关成员转移和数据清理顺序,见好友与群。
八、检查失败而不改变业务数据
可在隔离环境检验以下只读或不成立请求,不需要创建真实内容:
| 请求 | 应观察的结果 |
|---|---|
无 Bearer 请求 /me |
401,而不是匿名返回用户 |
GET /events?after=-1&wait=0 |
400 INVALID_CURSOR |
单聊 ?after=25&before=11 |
400 INVALID_CURSOR |
单聊 ?limit=0 |
400 INVALID_LIMIT |
| 对未授权媒体 ID 发 GET | 404,不返回媒体正文 |
这些是待执行的契约检查清单,不是本文已经向真实服务发出请求的验收回执。多账号权限、并发发送、掉线恢复与资源回收需结合测试矩阵检查;发生实际故障时按故障处置先定位阶段,再处理状态。
源码入口
请求解析、状态码和权限入口位于 chat-service/src/server.mjs;普通消息事务、去重、可见历史与事件位于 chat-service/src/database.mjs。原生封装分别在 ios-app/Sources/Features/Chat/ChatAPI.swift 与 android-app/app/src/main/java/cn/thestarline/workbench/AppData.kt。修改流程时同时检查请求体、事务语义、客户端重试和API 参考,不能只修改示例。