# 协议示例与集成步骤

本页把分散的接口串成可检查的流程，说明调用顺序、需要保留的状态、重复请求及失败处理。请求契约来自聊天服务当前源码；这些示例不代表所有客户端已经采用相同实现。路由逐项定义见 [API 参考](https://document.thestarline.cn/app/api-reference.md)，状态和限制见[限制与错误](https://document.thestarline.cn/app/limits-and-errors.md)。

## 示例环境与约定

命令使用隔离开发服务，测试账号、会话、消息和附件均应为合成数据。`CHAT_TOKEN` 必须由该环境内已经完成的测试登录获得。生产地址与本地地址的票据不能互换；原生授权还受系统回调与平台信任规则约束，尤其 Android 当前只允许正式 Account HTTPS 来源。

```sh
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 中的用户、编号和时间都是合成值。

## 一、恢复会话并取得同步起点

```sh
curl --silent --show-error --fail-with-body \
  "${CHAT_BASE_URL}/me" \
  -H "Authorization: Bearer ${CHAT_TOKEN}"
```

正常响应形状如下：

```json
{
  "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 合并即可避免重复。该顺序是集成建议，并非两端全部状态管理的逐行描述，实际实现见[客户端生命周期](https://document.thestarline.cn/app/client-lifecycle.md)。

## 二、构造原生授权证明

App→聊天服务的 verifier 由 32 字节安全随机值编码为无填充 base64url，challenge 为 verifier 的 SHA-256 摘要再做同样编码。下面只在本机生成挑战，不执行授权：

```sh
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 保存在当前设备的本次授权状态中。开始请求为：

```sh
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 为：

```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 持久化和换票中断恢复并不相同，见[登录与会话](https://document.thestarline.cn/app/authentication.md)。

## 三、发送文字并安全重试

```sh
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 字节测试文件：

```sh
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 放入下一条消息：

```json
{
  "clientId": "44444444-4444-4444-8444-444444444444",
  "mediaId": "55555555-5555-4555-8555-555555555555"
}
```

此时不带 text。mediaId 必须属于发送者；上传成功并不代表已经向接收者发出消息。第二步失败时应明确呈现“文件已上传、消息未确认”的阶段，不能显示为双方都已收到。

文件下载路径是 `GET /media/{id}`。上传者可以读取自己的媒体；非所有者需要符合个人历史边界且未被本人隐藏的消息引用，群消息还要求当前成员资格。普通单聊媒体的读取分支不重新检查好友或屏蔽，因此删好友、屏蔽不等于过去仍可见的媒体立即不可读。本人隐藏、历史边界或退群可能改变引用授权；存在其他可见引用时也不能笼统认定该文件一定不可读。普通聊天媒体没有 Range 契约，不要把新事视频接口的分段能力套用到这里。

上传原始数据不等于客户端附件加密。服务器保存的字节与上传字节一致，附件 E2EE 边界见[消息与媒体](https://document.thestarline.cn/app/messaging-and-media.md)。聊天文件、贴纸、新事媒体与本机记忆附件的上限分别不同，见[限制与错误](https://document.thestarline.cn/app/limits-and-errors.md)。

## 五、翻页、增量与消息合并

```sh
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。

## 六、长轮询与业务刷新

```sh
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 同步、位置读取和通话房间连接应分别处理，见[实时同步](https://document.thestarline.cn/app/realtime-and-calls.md)与[位置共享](https://document.thestarline.cn/app/location-sharing.md)。

## 七、已读与删除的语义

`POST /conversations/{id}/read` 使用 `{lastMessageId:25}`。消息必须属于该会话；已读位置只前进，不因客户端提交更小 ID 回退。UI 应在真实展示/阅读策略满足后更新，不在仅收到通知时直接把对方消息全部标为已读。

`DELETE /conversations/{id}/messages/{messageId}` 是本人隐藏，不能当双方撤回。`DELETE /conversations/{id}` 会隐藏本人会话并推进历史边界，restore 只取消隐藏，不恢复该边界之前的内容。删除好友、屏蔽、退出群和解散群又是其他操作。有关成员转移和数据清理顺序，见[好友与群](https://document.thestarline.cn/app/contacts-and-groups.md)。

## 八、检查失败而不改变业务数据

可在隔离环境检验以下只读或不成立请求，不需要创建真实内容：

| 请求 | 应观察的结果 |
| --- | --- |
| 无 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，不返回媒体正文 |

这些是待执行的契约检查清单，不是本文已经向真实服务发出请求的验收回执。多账号权限、并发发送、掉线恢复与资源回收需结合[测试矩阵](https://document.thestarline.cn/app/qa-matrix.md)检查；发生实际故障时按[故障处置](https://document.thestarline.cn/app/incident-runbooks.md)先定位阶段，再处理状态。

## 源码入口

请求解析、状态码和权限入口位于 `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 参考](https://document.thestarline.cn/app/api-reference.md)，不能只修改示例。
