140 lines
9.3 KiB
Markdown
140 lines
9.3 KiB
Markdown
# Web状态事件流开发说明
|
||
|
||
## 1. 设计目标
|
||
|
||
`GET /api/v1/events`用于把设备、通话、故事、媒体、导出、隐私请求和设备指令的状态变化推送到已登录的家庭工作台。该接口只承担“页面需要刷新哪些业务资源”的通知职责,不传输录音、转写、故事正文、设备指令参数、隐私申请原因或下载地址,也不替代各业务查询接口。浏览器收到事件后仍通过原有REST接口重新读取经过权限校验的最新状态。
|
||
|
||
本阶段采用“PostgreSQL事务事件表 + 短时续传窗口 + 认证SSE”的实现。状态变化和事件写入位于同一数据库事务,避免业务已提交但通知丢失;SSE连接失败不影响业务事务,客户端可使用最后事件编号恢复。
|
||
|
||
## 2. 事件模型
|
||
|
||
| 事件类型 | 产生条件 | 资源类型 | 状态示例 |
|
||
|---|---|---|---|
|
||
| `device.presence.changed` | 首次有效心跳使设备上线,或后台发现心跳超时使设备离线 | `device` | `online`、`offline` |
|
||
| `conversation.started` | 通话进入`ACTIVE` | `conversation` | `ACTIVE` |
|
||
| `conversation.completed` | 通话进入`COMPLETED` | `conversation` | `COMPLETED` |
|
||
| `conversation.failed` | 通话进入`FAILED` | `conversation` | `FAILED` |
|
||
| `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. 新的有效心跳可再次把离线记录切回在线并产生新的上线事件。
|
||
|
||
## 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检查还会确认认证连接成功、状态提示可见、无控制台错误和无横向溢出。
|