ARCHITECTURE.md。
API 与数据管道
CORS — 所有 API 端点强制执行来源白名单。请参阅 CORS.md 了解允许的来源、实现细节以及如何为新端点添加 CORS。
Proto 优先 API 契约
整个 API 接口在 Protocol Buffer(.proto)文件中使用 sebuf HTTP 注解定义。代码生成从单一事实来源产出 TypeScript 客户端、服务端处理器桩代码和 OpenAPI 3.1.0 文档 — 消除前后端之间的请求/响应 schema 漂移。
服务目录覆盖每个数据垂直:
代码生成管道 — 一个
Makefile 驱动 buf generate 配合三个自定义 sebuf protoc 插件:
protoc-gen-ts-client→ 带类型的基于 fetch 的客户端类(src/generated/client/)protoc-gen-ts-server→ 处理器接口和路由描述符(src/generated/server/)protoc-gen-openapiv3→ YAML 和 JSON 格式的 OpenAPI 3.1.0 规范(docs/api/)
buf.validate 字段约束(例如,纬度 ∈ [−90, 90]),因此请求校验是自动生成的 — 处理器接收预校验数据。破坏性变更通过 buf breaking 对比 main 分支在 CI 时被捕获。
边缘网关 — proto 支持的领域入口点(api/[domain]/v1/[rpc].ts)将其生成的 createServiceRoutes() 描述符注册到一个扁平路径路由器。每个 RPC 使用其 proto/OpenAPI 契约中声明的 HTTP 方法:大多数读取端点为 GET(例如,GET /api/aviation/v1/list-airport-delays),而变更和批量操作使用 POST。共享网关添加 CORS 强制执行、隐藏 5xx 响应内部细节的顶层错误边界,以及限流支持(429 时 retryAfter)。同一个路由器通过 Vite dev-server 插件(vite.config.ts 中的 sebufApiPlugin)在本地运行,并在处理器变更时进行 HMR 失效。
引导水合
仪表盘通过在任何面板渲染前于单次 Redis pipeline 调用中预抓取 38 个常用数据集,消除了冷启动延迟。页面加载时,客户端并行发起两个请求 — 一个快速层和一个慢速层 — 到/api/bootstrap 边缘函数,两者均带 800ms 中止超时以避免阻塞首次渲染。
hydrationCache Map 里。面板初始化时调用 getHydratedData(key),返回预抓取的数据并从缓存中驱逐(一次性读取)以释放内存。找到水合数据的面板完全跳过其初始 API 调用,使用预加载内容即时渲染。在水合数据已被消费后挂载的面板回退到其正常抓取周期。
负哨兵缓存 — 当 Redis 键中无数据时,引导端点在响应中存储一个 __WM_NEG__ 哨兵值,而不是省略该键。这使消费者能够区分”数据尚未加载”(水合中不存在该键)和”数据源无内容”(负哨兵),防止对空数据源进行不必要的 RPC 回退调用。
分层 CDN 缓存 — 快速层使用 s-maxage=1200(20 分钟)配合 stale-while-revalidate=300,适用于地震和行情报价等近实时数据。慢速层使用 s-maxage=7200(2 小时)配合 stale-while-revalidate=1800,适用于 BIS 政策利率和气候异常等不常变化的数据。两层均包含 stale-if-error 指令,在源站暂时不可达时提供缓存响应。
选择性抓取 — 客户端可通过 ?keys=earthquakes,flightDelays,insights 请求自定义键子集进行定向水合,在特定面板需要重新初始化时实现部分引导恢复。
这将 38 次独立 API 调用(每次都有 DNS 查询、TLS 握手和 Redis 往返)减少为恰好 2 次,在典型连接上将首次有意义渲染时间缩短 2–4 秒。
SmartPollLoop — 自适应数据刷新
SmartPollLoop 是所有数据抓取面板使用的核心刷新编排原语。它不采用固定间隔轮询,而是根据网络条件、标签页可见性、面板可见性和失败历史进行适配:
自适应行为:
- 指数退避 — 连续失败将轮询间隔乘以可配置的
backoffMultiplier(默认 2×),最高到基础间隔的 4×。一次成功的抓取将乘数重置为 1× - 隐藏标签页节流 — 当
document.visibilityState为hidden时,轮询间隔乘以hiddenMultiplier(默认 5×)。前台每 60 秒轮询的面板在标签页后台化后放慢到每 5 分钟一次 - 手动触发 —
handle.triggerNow()无论当前间隔如何都强制立即轮询,在用户明确请求刷新或相关面板数据变化时使用 - 尝试追踪 — 连续失败计数器接入熔断器集成。
maxAttempts次失败后,轮询循环完全停止,熔断器提供缓存数据 - 原因标记 — 每次轮询携带一个
SmartPollReason('interval'、'resume'、'manual'、'startup'),以便处理器调整行为(例如,startup轮询可抓取更大数据集)
SmartPollLoop,在挂载时调用 handle.start(),在销毁时调用 handle.stop()。面板折叠或滚动出视图(通过 Intersection Observer)时循环自动暂停,重新出现时恢复。