# 实时同步、位置与音视频通话

聊天变更通过 HTTP 长轮询通知，音视频使用独立的 LiveKit 媒体连接，iOS 来电可通过 APNs VoIP 唤醒。三者承担不同责任：事件告诉客户端刷新什么，通话 API 维护参与状态，媒体服务承载实际音视频。任何单一环节成功都不能代表整个通话已经可用。

## 普通事件游标

`GET /events?after=0&wait=20` 要求聊天 Bearer token。`after` 是当前用户已经处理到的普通事件 ID，必须为非负安全整数；`wait` 为 0–25 秒，默认 20。服务端先查已存在事件；有结果立即返回，没有结果才在进程内登记等待者。登记后再次查询以补上查询与注册之间的竞争窗口。

响应为 `{events:[...],cursor}`。每批最多 100 条、按 ID 升序；空批次的 cursor 保持请求 after。事件包含 `id,conversationId,groupId,kind,messageId,createdAt`，具体引用可能为 null。常见 kind 包括 message、message_read、group_message、group_message_read、friend_request、friend_accepted、friend_removed、block_changed、group_members_changed、call_changed 和 e2ee。

登录交换及 `/me` 会返回用户最新 eventCursor，供初始化增量监听。客户端先获取当前列表快照，再连续消费事件；断线后沿用已处理游标重连，并按事件刷新对应资源。不要把消息 ID 当事件游标，也不要直接用 e2ee sequence 调用普通 `/events`。

iOS 当前循环使用 wait=20，收到事件后刷新会话、群、好友申请及相关消息；call_changed 触发通话重查。401 清理该会话，其他错误约等待 3 秒再重试，且核对异步响应仍属于原 token。事件是刷新信号，消息正文仍从资源接口读取。等待器保存在单进程 Map，当前没有跨实例唤醒或 WebSocket 订阅路由。

## MLS 的另一种游标

`GET /e2ee/{conversations|groups}/{id}/events?deviceId=...&after=...` 返回按设备收件的加密事件，每批最多 100 条，用 `sequence` 推进 cursor。它是立即查询接口，不使用普通事件的 wait 参数。普通 events 中的 e2ee 通知可以触发该查询，但两种游标必须分别保存。

客户端应在 MLS 验证及本地状态持久化成功后推进 sequence，Welcome 与 Commit 需要各自确认。iOS 独立传输器限制单次同步最多处理 100 批，超过后提示继续同步。若解密、发送者校验或状态保存失败，不应跳过密文并永久推进游标。详见[消息与媒体](https://document.thestarline.cn/app/messaging-and-media.md)。

## 实时位置是显式会话

单聊和群聊分别使用 `/conversations/{id}/live-locations`、`/groups/{id}/live-locations`。POST 显式开始，PUT 更新已经开启的共享，GET 查询当前参与者位置，DELETE 停止当前用户共享。`GET /live-locations/mine` 用于恢复自己的共享范围。

位置数据为 latitude、longitude、accuracy，必须是有限数字；纬度 -90–90、经度 -180–180、精度 0–100000 米。未先开始就 PUT 返回 409 `NOT_SHARING`。单聊开始和更新还要求仍是好友且未屏蔽，群操作要求当前成员。返回 startedAt、updatedAt，界面应展示更新时间，不能把静态最后坐标当实时移动。

当前服务端位置表没有自动到期字段，位置变更也未写入普通 events；客户端需单独刷新并在退出、停止共享和权限变化时处理生命周期。坐标是服务端可读数据。`GET /location/city` 仅提供基于请求公网地址的粗略城市与国家代码，不等于 GPS 定位，无法判断时返回 null。

## 通话创建与状态机

创建调用 `POST /calls`，发送 scopeKind（conversation/group）、scopeId、mode（audio/video）、UUID clientId。服务端要求当前成员；单聊额外要求好友及未屏蔽。同一个范围最多一场 ringing/active 通话，发起者不能同时占用另一场。相同发起者与 clientId 的相同请求返回同一通话；不同内容或冲突可能得到 `CALL_BUSY`。

| 状态 | 含义与转移 |
| --- | --- |
| ringing | 等待邀请者响应；发起者可 end 取消；接听后 active |
| active | 至少已完成接听状态；媒体仍需单独连接 |
| cancelled | 发起者在响铃阶段取消，原因 caller_cancelled |
| missed | 响铃达到约 60 秒无人接听，原因 unanswered |
| ended | 挂断、全员忙、拒接、成员变化或空媒体房间等终态 |

邀请另有 ringing/accepted/declined/busy/missed/cancelled 状态。若其余所有成员都忙，创建请求可以返回 201 但 call 已是 ended、原因 busy，因此客户端必须读取响应 state。首位受邀者 accept 使通话 active，并登记发起者与接听者为参与者；后续群成员可 accept 或在 active 时 join。join 仅支持群通话。

`GET /calls/active` 查询当前用户正在响铃或实际参与的通话；`/calls/current` 按聊天范围查询；`/calls/history` 返回该范围最近 100 条终态记录。普通 call 响应不含内部房间名及请求 clientId。旧事件只能触发重查，不应把旧 state 覆盖新的终态。

## 媒体加入与退出

接听或加入成功后，参与者调用 `/calls/{id}/media-token` 获取 `{url,token}`。服务端只为 active 且尚未离开的当前参与者签发凭证，TTL 为 30 秒，权限限定当前房间、发布音视频与订阅，不允许发布 data。客户端必须及时连接，过期后重新获取；不能把聊天 Bearer token 当媒体 token。

active 单聊结束时删除媒体房间；群成员离开则移除该媒体参与者，只有群里无人继续参与时才结束整场并清理房间。删除好友、屏蔽、群解散或成员移除等路径会先处理相关媒体权限；媒体服务故障可能使动作返回 `CALL_MEDIA_UNAVAILABLE`，客户端需展示失败并可重试。

服务端约每秒检查响铃超时；默认约每 10 秒检查 active 房间人数，连续空房达到默认 90 秒宽限后结束通话并记录 connection_lost。检查媒体人数失败时不会据此认定空房。状态机的 active 仍不证明麦克风、摄像头、远端轨道或网络质量正常。

## VoIP 推送与验收边界

iOS 通过 `POST /voip/devices` 注册设备 UUID 与十六进制 pushToken，记录绑定当前有效聊天 session。`DELETE /voip/devices` 注销设备。来电仅发给仍有有效 session 的待接听邀请者；APNs 返回 410、BadDeviceToken、Unregistered 或 DeviceTokenNotForTopic 时移除该 token。

推送载荷包含 aps 空对象，以及 callId、scopeKind、scopeId、mode、callerName，群通话另有 groupName。它没有聊天正文或媒体 token，但姓名、群名与通话关系仍属于元数据。推送类型为 voip、优先级 10、到期约 60 秒；缺少配置时不发送。APNs 的 HTTP 成功也不证明设备已经展示系统来电或成功接听。

仓库已有通话信令、媒体 SDK、系统通话桥接与推送发布器。真实双设备跨网络音视频、断线重连、后台与锁屏来电、生产 APNs、权限拒绝后的恢复仍需逐场景验收。请结合[测试](https://document.thestarline.cn/app/testing.md)、[运维](https://document.thestarline.cn/app/operations.md)、[iOS](https://document.thestarline.cn/app/ios.md)和[Android](https://document.thestarline.cn/app/android.md)读取具体结果。

城市定位与精确共享、两端停止失败及最后坐标保留的具体区别见[位置共享](https://document.thestarline.cn/app/location-sharing.md)。
