Files
story/docs/web-event-stream.md
Codex Local 0c2151ace9
ci / verify (push) Canceled after 0s
chore: checkpoint 第一版
2026-09-06 17:04:11 +08:00

144 lines
9.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Web状态事件流开发说明
## 1. 设计目标
`GET /api/v1/events`用于把设备、通话、故事、媒体、导出、隐私请求和设备指令的状态变化推送到已登录的家庭工作台。该接口只承担“页面需要刷新哪些业务资源”的通知职责,不传输录音、转写、故事正文、设备指令参数、隐私申请原因或下载地址,也不替代各业务查询接口。浏览器收到事件后仍通过原有REST接口重新读取经过权限校验的最新状态。
本阶段采用“PostgreSQL事务事件表 + 短时续传窗口 + 认证SSE”的实现。状态变化和事件写入位于同一数据库事务,避免业务已提交但通知丢失;SSE连接失败不影响业务事务,客户端可使用最后事件编号恢复。
## 2. 事件模型
| 事件类型 | 产生条件 | 资源类型 | 状态示例 |
|---|---|---|---|
| `device.presence.changed` | 首次有效心跳使设备上线,或后台发现心跳超时使设备离线 | `device` | `online`、`offline` |
| `device.binding.reset` | 设备主动恢复出厂并解除当前用户绑定 | `device` | `CLAIMABLE` |
| `conversation.started` | 通话进入`ACTIVE` | `conversation` | `ACTIVE` |
| `conversation.completed` | 通话进入`COMPLETED` | `conversation` | `COMPLETED` |
| `conversation.failed` | 通话进入`FAILED` | `conversation` | `FAILED` |
上述通话事件由`conversation.status`统一触发,与底层运行时无关;`legacy_voicechat`和`volc_hardware_agent`都会产生同一套家庭侧SSE语义。硬件智能体的周期`session_updated`只作为平台租约和诊断证据,不逐次生成SSE事件。
| `story.created` | 新故事候选建立 | `story` | 故事当前状态 |
| `story.revised` | 故事版本增加 | `story` | 故事当前状态 |
| `media.upload.completed` | 媒体上传批次完成 | `upload_batch` | `COMPLETED` |
| `export.ready` | 私有导出文件就绪 | `export_task` | `READY` |
| `privacy_request.updated` | 隐私请求建立或状态变化 | `privacy_request` | 请求当前状态 |
| `device.command.updated` | 可靠设备指令建立或状态变化 | `device_command` | `QUEUED`、`DELIVERED`、`SUCCEEDED`等 |
事件公开字段固定为`id`、`type`、`resource_type`、`resource_id`、可选`subject_id`、`status`和`occurred_at`。其中资源和长者编号均为公开UUID,数据库内部编号、正文、对象存储引用、签名、令牌、操作人员编号和请求幂等键不得进入事件数据。
## 3. 数据库设计
V16迁移新增以下对象:
- `web_event`:保存15分钟内可续传的最小状态事件,使用递增`bigserial`作为`Last-Event-ID`游标;按客户组织、家庭和长者记录授权边界。
- `device_presence_state`:保存设备当前在线布尔值、最后心跳时间和最近一次状态变化时间。该表不保存心跳正文。
- `append_web_event(...)`:统一写入最小事件;同时发送PostgreSQL通知,为后续把轮询优化成`LISTEN/NOTIFY`预留条件。当前服务端读取仍以数据库轮询为准。
- 业务触发器:在会话、故事、媒体上传批次、导出、隐私请求和设备指令事务提交前生成事件。
- `record_device_presence(...)`:有效且序号前进的设备心跳调用该函数;连续在线心跳仅刷新`last_seen_at`,不会重复产生上线事件。
- `expire_device_presence(...)`:Worker按心跳租约扫描在线记录,超过阈值后原子切换为离线并产生一次离线事件;使用`FOR UPDATE SKIP LOCKED`支持多Worker安全竞争。
`web_event.subject_id`和`device_presence_state.subject_id`在主体物理清理时级联删除,确保15分钟临时事件不会阻塞隐私删除。Worker定期分批删除过期事件;主体隐私删除处理器也会主动清理该主体的历史事件。
## 4. 接口设计
### 4.1 请求
```http
GET /api/v1/events HTTP/1.1
Authorization: Bearer <OIDC access token>
Accept: text/event-stream
Last-Event-ID: 12345
```
`Authorization`必需。`Last-Event-ID`可选,但一旦提供必须是大于0的整数,并且必须仍处于当前账号可访问的家庭范围和15分钟续传窗口内。
### 4.2 响应
```text
retry: 2000
id: 12345
id: 12346
event: device.command.updated
data: {"id":12346,"type":"device.command.updated","resource_type":"device_command","resource_id":"...","subject_id":"...","status":"SUCCEEDED","occurred_at":"..."}
: heartbeat
```
首次连接不回放历史业务事件。服务端先返回当前账号可见的最新事件编号,浏览器保存该编号;此后发生的事件才能被推送。这样既避免登录后重复执行旧通知,也能覆盖“初始编号已收到、业务事件随后发生、连接中断”的恢复窗口。
### 4.3 状态码
| 状态码 | 错误码 | 处理方式 |
|---|---|---|
| 200 | — | 建立`text/event-stream` |
| 400 | `EVENT_CURSOR_INVALID` | 游标格式错误;客户端记录诊断信息,不使用该值继续请求 |
| 401 | `AUTHENTICATION_REQUIRED` | 登录失效;回到登录流程 |
| 410 | `EVENT_CURSOR_EXPIRED` | 游标过期或不属于当前权限范围;清除本地游标,完整刷新业务数据后重新连接 |
跨租户游标、已移除家庭成员使用的游标和真正过期的游标统一返回410,不向调用者区分资源存在性。
## 5. 服务端业务流程
```mermaid
sequenceDiagram
participant B as 业务事务
participant P as PostgreSQL
participant S as SSE接口
participant W as Web工作台
B->>P: 更新业务状态
B->>P: 同事务写入web_event
P-->>B: 提交成功
W->>S: GET /events + Last-Event-ID
S->>P: 校验账号、家庭成员和游标
loop 每500毫秒
S->>P: 查询id大于游标的可见事件,最多100条
P-->>S: 最小事件列表
S-->>W: id + event + data
end
W->>W: 保存最后编号并触发业务查询刷新
```
服务端每10秒发送注释心跳以维持链路,每25秒主动结束一次连接,由客户端重连。这一连接轮换是当前本地基线,用于避免长连接无限占用失效网络状态;后续500连接专项压测将决定生产网关、连接时长和轮询间隔参数。
## 6. 设备在线状态流程
1. 设备提交严格递增序号的通话心跳。
2. PostgreSQL更新权威租约和设备影子;同一事务调用`record_device_presence`。
3. 设备此前不存在在线记录或已离线时,写入一次`online`事件;连续心跳只延长最后出现时间。
4. Redis继续承担低延迟在线查询,PostgreSQL状态表承担状态转换和事件可靠性。
5. Worker每个心跳周期扫描一次;最后出现时间超过两倍会话租约的记录切换为`offline`并只写入一次事件。
6. 设备恢复出厂时,平台在同一事务内把旧用户侧在线状态改为`offline`、发送`device.binding.reset`并解除绑定。
7. 新的有效心跳可再次把离线记录切回在线并产生新的上线事件;恢复出厂后的设备要先完成重新绑定。
## 7. 浏览器重连与去重
原生`EventSource`不能可靠附带Bearer请求头,因此工作台使用`fetch`读取SSE字节流。客户端按当前登录账号集合隔离地把最后事件编号保存到`sessionStorage`,逐块解析`id`、`event`和`data`:
- 只接受编号大于本地最后编号的事件,重放事件不会触发重复刷新。
- 收到事件后先保存游标,再把页面数据刷新加入合并队列;通知本身不直接启动下载、删除、授权等有副作用的操作。
- 网络中断、服务端25秒轮换或临时错误后等待1秒重连,并显示“状态重连中”。
- 收到410时清除游标,把一次完整业务刷新加入同一合并队列,等待1秒后以无游标方式建立新基线。
- 页面展示“状态连接中 / 状态实时更新 / 状态重连中”,最近事件时间仅用于本地提示。
连续状态事件不会逐条触发成员、长者、授权、内容、设备和固件等整套REST查询。浏览器对SSE触发的刷新实行2秒最小间隔:第一条事件可立即刷新,间隔内后续事件合并到下一批;用户主动保存、撤回或重试成功后的刷新不进入该延迟队列。该策略保证最终读取仍来自服务端权威接口,同时避免500毫秒事件轮询放大为请求风暴并耗尽用户限流桶。
## 8. 安全与运维约束
- 每次建立连接及每批事件查询都按OIDC签发方、主体、活动账号和活动家庭成员关系过滤,不信任前端传入租户或家庭编号。
- 响应使用`Cache-Control: no-store`和`X-Accel-Buffering: no`,代理层不得缓存或聚合事件正文。
- 事件只用于状态失效通知;所有详情查询继续执行原业务权限、冻结、撤回和审计规则。
- 当前实现使用500毫秒数据库轮询,适合作为本地正确性基线;生产扩容前需完成500个并发SSE连接、断线重连风暴和事件突发压测,再决定是否启用通知唤醒和独立事件网关。
- 日志不得记录Bearer令牌、SSE数据正文或`Last-Event-ID`对应的资源内容;仅可记录请求编号、连接结果和稳定错误码。
## 9. 验证方式
```powershell
pwsh -NoProfile -File .\scripts\smoke-web-events.ps1
pwsh -NoProfile -File .\scripts\smoke-device-presence-events.ps1
$env:PLAYWRIGHT_REQUIRE_FROM='C:\Users\19926\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\node_modules'
node .\scripts\visual-sse-refresh-coalescing.cjs
```
第一组验证事务事件生成、断点续传、已处理事件不重放、活动家庭成员可见、跨租户隔离、非法游标、过期游标和敏感字段不泄漏;第二组验证上线只通知一次、连续心跳去重以及心跳超时产生离线事件。浏览器合并回归分散注入8条最小事件,要求核心数据最多刷新5批、总业务响应不超过90、无HTTP 429或其他失败,并在结束时删除事件夹具。实际Chrome检查还会确认认证连接成功、状态提示可见、无控制台错误和无横向溢出。