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

9.9 KiB
Raw Blame History

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 请求

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 响应

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. 服务端业务流程

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. 验证方式

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检查还会确认认证连接成功、状态提示可见、无控制台错误和无横向溢出。