# Production Realtime SSE Patch Design > 日期: 2026-05-26 > 范围: PolyWeather 网站终端图表的生产级实时观测增量层 > 目标服务器: 2 vCPU / 8 GB RAM / 50 GB 系统盘 ## 背景 当前网站已经有 SSE patch 通道,但它更像一个进程内即时广播层: - 后端 `web/sse_manager.py` 用进程内 `asyncio.Queue` 管连接。 - 采集器通过 `/api/internal/collector-patch` 推送城市温度变化。 - 前端 `use-sse-patches.ts` 用 `EventSource` 接收 patch,并把温度合并到终端列表和图表。 这个结构能做轻量实时更新,但还不是生产级“像股票行情一样”的实时架构。主要缺口是: - 断线后没有 replay event log。 - 连接不能按城市订阅,只能广播后让前端筛选。 - patch schema 没有版本化,后续扩展跑道、站点、最高温字段风险较高。 - 多实例共享 pub/sub 还没有抽象边界。 ## 目标 把当前实时层升级为“HTTP 快照基线 + SSE 增量观测事件 + 可重放事件日志”的架构: - 页面首屏仍通过 HTTP snapshot 加载完整 terminal rows / city detail。 - 实测温度、站点观测、跑道观测通过 SSE 增量追加到图表。 - 浏览器断线重连后可以通过 `since_revision` 补发错过的事件。 - 前端只订阅当前可见城市,例如 2x2 图表中的四个城市。 - patch payload 明确使用 `city_observation_patch.v1` schema。 - 第一版用 SQLite event log 适配当前服务器规格,预留 Redis/Postgres event bus 接口。 ## 非目标 第一版不做这些事: - 不把全部 terminal state 改成 event sourcing。 - 不用 SSE 取代 `/api/scan/terminal` 或 `/api/city/{name}/detail`。 - 不在第一版强依赖 Redis。 - 不把 DEB、多模型、概率分布改成分钟级实时更新。 - 不做 WebSocket;继续使用 SSE,因为浏览器端简单、代理友好、适合单向行情流。 ## 推荐架构 ```mermaid flowchart LR Collector["WeatherDataCollector"] --> Ingest["POST /api/internal/collector-patch"] Ingest --> Normalize["Patch schema normalizer"] Normalize --> Log["SQLite observation_patch_events"] Normalize --> Bus["PatchEventBus"] Bus --> SSE["GET /api/events?cities=...&since_revision=..."] API["HTTP snapshot APIs"] --> FE["Frontend terminal/chart state"] SSE --> FE FE -->|gap / stale / reconnect| API ``` ## 事件存储 新增 SQLite 表 `observation_patch_events`,由 `DBManager` 初始化。 字段: - `revision INTEGER PRIMARY KEY AUTOINCREMENT` - `schema_type TEXT NOT NULL` - `schema_version INTEGER NOT NULL` - `city TEXT NOT NULL` - `source TEXT NOT NULL` - `obs_time TEXT` - `payload_json TEXT NOT NULL` - `created_at TEXT NOT NULL` 索引: - `idx_observation_patch_events_city_revision(city, revision)` - `idx_observation_patch_events_created_at(created_at)` 保留策略: - 默认保留 6 小时。 - event log 只用于 SSE 断线 replay,不作为日内历史曲线归档。 - 完整日内曲线仍由 `/api/city/{name}/detail` 从现有观测存储和 city cache 构建。 - 6 小时窗口覆盖浏览器短线重连、页面休眠恢复和用户午后交易窗口内的 replay;超过窗口直接触发 HTTP resync。 - 每次写入后低频触发清理,避免每条事件都扫表。 - 环境变量:`POLYWEATHER_PATCH_EVENT_RETENTION_HOURS=6`。 ## Patch Schema v1 事件类型固定为: ```json { "type": "city_observation_patch.v1", "revision": 12345, "city": "shanghai", "source": "amsc_awos", "obs_time": "2026-05-26T10:02:00Z", "ts": 1780000000000, "payload": { "temp": 30.4, "max_so_far": 31.0, "station_code": "ZSPD", "station_label": "Shanghai Pudong", "series_key": "airport_primary", "unit": "celsius", "runway_points": [ { "runway": "17L/35R", "temp": 30.8, "tdz_temp": 30.5, "mid_temp": 30.6, "end_temp": 30.8, "is_settlement": true } ] } } ``` 兼容要求: - 前端仍接受旧 `city_patch` 一段时间,避免部署顺序问题。 - 新事件统一转换成前端内部 `CityPatch`。 - 后端 ingest 接受旧字段 `changes`,但写入 event log 时规范化成 v1。 ## SSE API `GET /api/events` 查询参数: - `cities`: 逗号分隔城市 key,例如 `shanghai,hong kong,taipei` - `since_revision`: 客户端上次处理过的最大 revision - `replay_limit`: 最大补发数量,默认 500,上限 2000 连接行为: 1. 建立连接。 2. 返回 `connected` event,包含当前 server revision。 3. 如果传入 `since_revision`,先从 SQLite 补发匹配城市的历史事件。 4. 进入 live stream,只推送匹配城市的事件。 5. 每 30 秒发送 heartbeat。 gap 处理: - 如果 `since_revision` 太旧,超过保留窗口或补发上限,服务端发送: ```json { "type": "resync_required", "reason": "replay_window_exceeded", "latest_revision": 12345 } ``` - 前端收到后对当前可见城市调用 HTTP full detail resync。 ## 后端模块边界 新增或调整模块: - `web/realtime_patch_schema.py` - 负责规范化旧 `city_patch` 输入为 `city_observation_patch.v1`。 - 负责校验城市、温度、时间、source。 - `web/realtime_event_store.py` - 封装 SQLite event log 写入、查询、保留清理。 - 使用 `DBManager` 的 DB path。 - `web/sse_manager.py` - 从纯进程队列升级为基于 `PatchEventBus` 的连接管理。 - 保留进程内 live fanout。 - 每个连接记录订阅城市集合。 - `web/routers/sse_router.py` - 解析 `cities`、`since_revision`、`replay_limit`。 - ingest 后先写 event log,再广播 live event。 第一版 `PatchEventBus`: - `SQLitePatchEventBus` - 写入 SQLite event log。 - 进程内广播给当前 worker 的连接。 后续可新增: - `RedisPatchEventBus` - `PostgresPatchEventBus` ## 前端模块边界 调整: - `frontend/hooks/use-sse-patches.ts` - 支持 `city_observation_patch.v1`。 - 记录 `lastRevision`。 - 支持按可见城市集合建立 SSE URL。 - 收到 `resync_required` 时通知图表重拉。 - `frontend/components/dashboard/scan-terminal/use-scan-terminal-query.ts` - 继续负责 terminal rows 的 HTTP snapshot。 - 消费 patch 时只更新轻量字段:当前温度、最高温、观测时间。 - `frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx` - 消费 patch 时追加分钟级观测点。 - 对 runway patch 更新对应 runway series。 - resync required 或长时间无 patch 时重拉 `/api/city/{name}/detail`。 城市订阅策略: - 2x2 / 3x3 图表只订阅当前 slots 中的城市。 - 移动端只订阅当前主图城市。 - 切换布局或城市后重建 SSE 连接。 ## 数据流 ### 首屏加载 1. 前端调用 `/api/scan/terminal` 得到 terminal rows。 2. 每个可见图表调用 `/api/city/{name}/detail` 得到完整基线曲线。 3. 前端用可见城市列表连接 `/api/events?cities=...&since_revision=...`。 ### 新观测进入 1. `WeatherDataCollector` 发现温度或跑道观测变化。 2. POST `/api/internal/collector-patch`。 3. 后端规范化为 `city_observation_patch.v1`。 4. 写入 SQLite event log,生成 revision。 5. 广播给订阅该城市的 SSE 连接。 6. 前端追加图表点并更新 terminal row。 ### 断线重连 1. 前端保留 `lastRevision`。 2. EventSource 断线后指数退避重连。 3. 重连 URL 带 `since_revision=lastRevision`。 4. 后端 replay missed events。 5. 如果 replay 不完整,返回 `resync_required`,前端 HTTP 重拉。 ## 错误处理 - ingest payload 无 city 或无有效 temp/runway point:返回 400。 - event log 写入失败:返回 500,不广播,避免前端看到不可 replay 的事件。 - SSE replay 查询失败:发送 `resync_required`,然后继续 heartbeat/live stream。 - 前端收到未知 schema:忽略并记录 debug log。 - 前端 revision 倒退:忽略。 - 前端 revision 跳号:标记 gap,并触发 HTTP resync。 ## 测试策略 后端: - `tests/test_realtime_patch_schema.py` - 旧 `city_patch` 输入能规范化成 `city_observation_patch.v1`。 - 跑道 payload 保留 runway point。 - 无效 city/temp 返回校验失败。 - `tests/test_realtime_event_store.py` - append event 生成单调 revision。 - replay 按 city 过滤。 - retention cleanup 删除旧事件。 - `tests/test_sse_replay.py` - `/api/events?cities=...&since_revision=...` 只 replay 匹配城市。 - replay window 超限返回 `resync_required`。 前端: - `frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts` - 确认 v1 schema、city subscription、since_revision URL 存在。 - `frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts` - 确认 patch 追加分钟级观测点。 - 确认 runway patch 更新对应 runway series。 验收命令: ```powershell pytest tests/test_realtime_patch_schema.py tests/test_realtime_event_store.py tests/test_sse_replay.py cd frontend npm run test:business npm run typecheck npm run build ``` ## 部署与容量 当前服务器 2 vCPU / 8 GB / 50 GB 足够第一版 SQLite-first: - 30 个高频城市,每分钟 1 条事件,约 43,200 条/天。 - 默认只保留 6 小时,约 10,800 条事件。 - payload 控制在 1-3 KB,SQLite event log 体积预计维持在几十 MB 内。 - PM 最高温预测市场不需要把当天所有 patch 都保留在 event log;超过 replay 窗口时用 HTTP detail 重建当前画面。 运行建议: - SQLite 使用 WAL。 - event log 与现有 `POLYWEATHER_DB_PATH` 同库。 - 先保持单 backend worker,避免进程内 fanout 跨 worker 不一致。 - 多实例或多 worker 时再启用 Redis bus。 ## 后续 Redis 扩展 当部署变成多实例或多 worker 后,新增 `RedisPatchEventBus`: - SQLite 仍作为 replay log。 - Redis Pub/Sub 或 Redis Stream 负责跨实例 live fanout。 - 每个 worker 从 Redis 订阅 live event,再投递给本进程 SSE 连接。 这个扩展不改变前端协议。 ## 验收标准 - 页面加载后可见图表只订阅当前城市。 - 高频城市新观测进入后,图表在无需手动刷新下追加新点。 - 浏览器断线重连后,能补齐断线期间的事件。 - replay 超窗时,前端自动 HTTP resync。 - 未订阅城市的事件不会推送到该连接。 - 旧 `city_patch` 输入在迁移期仍可用。 - 所有新增行为有自动化测试覆盖。