01

全球雷达概览

想象有一个仪表盘,能同时监控全球军事冲突、金融市场、自然灾害、网络威胁——WorldMonitor 就是这样一个系统

你打开 worldmonitor.app,发生了什么?

想象一下:你打开网站,眼前是一个旋转的 3D 地球,上面闪烁着数百个事件标记——一场正在进行的军事演习、一个飙升的股票指数异常、一场森林火灾的热点图……

这些实时数据来自 65+ 个外部数据源,经过聚合、AI 分析、缓存,在你打开页面的 3 秒内全部呈现。这背后是一套精密的工程系统。

🌍
WorldMonitor 的野心

500+ 策划新闻源、AI 合成摘要、双地图引擎、12 类信号指数、21 种语言、原生桌面应用——全部来自一个 TypeScript 单页应用。

五个专业变体,一套代码库

WorldMonitor 最聪明的产品设计之一:一套代码,五个不同的专业版本。

🌐
worldmonitor.app — 综合版

地缘政治 + 军事 + 灾难 + 金融,全品类情报

💻
tech.worldmonitor.app — 科技版

AI 动态、芯片战争、网络安全威胁

📈
finance.worldmonitor.app — 金融版

92 个股票交易所、大宗商品、加密货币实时行情

🛢️
commodity.worldmonitor.app — 大宗商品版

能源、农产品、关键矿产追踪

😊
happy.worldmonitor.app — 积极新闻版

反刷机器:只展示正面新闻,对抗信息疲劳

技术栈一览

WorldMonitor 选择了一套非常现代、但也挺"反潮流"的技术栈:

🗂️

原生 TypeScript

没有 React/Vue/Angular。纯 TypeScript + Vite 构建,Panel 类继承体系——86 个面板类,没有框架

🗺️

双地图引擎

deck.gl 的 WebGL 平面地图 + globe.gl 的 3D 地球仪,45 个数据图层可切换

Edge Functions

60+ Vercel 边缘函数,每个端点独立部署在全球节点,无需管理服务器

🤖

本地 AI

支持 Ollama 在本地运行 AI 摘要,完全不需要 API 密钥

02

前端架构

没有 React 的大型复杂前端——WorldMonitor 如何用面向对象设计管理 86 个面板

8 阶段启动:一次打开的背后

当你访问 worldmonitor.app,App.init() 按顺序执行 8 个阶段,每个阶段有特定的职责:

1
存储 + 国际化

初始化 IndexedDB、检测语言(支持 21 种)、加载翻译文件

2
ML Worker 准备

在后台线程预加载 ONNX 机器学习模型(情感分析、摘要生成、命名实体识别)

3
Bootstrap 数据加载

并发请求两个 /api/bootstrap 端点(快速 3s 超时 + 完整 5s 超时),批量从 Redis 获取缓存数据

4
布局渲染

PanelLayoutManager 渲染地图和初始面板网格

5
数据 + 智能轮询

并行加载所有数据,启动基于变体配置的智能刷新循环

Panel 类:86 个面板的父亲

WorldMonitor 最核心的设计模式:所有面板都继承自一个基类 Panel,就像一个模板。每个专业面板只需要实现"我显示什么内容",其他都由基类处理。

设计模式

// 基类 Panel 处理所有通用逻辑
class Panel {
  setContent(html) { /* 防抖 150ms 渲染 */ }
  refresh()    { /* 子类实现具体刷新逻辑 */ }
  resize()     { /* 响应大小变化,持久化到 localStorage */ }
}

// 每个专业面板只关心自己的数据逻辑
class ConflictPanel extends Panel {
  refresh() {
    const data = getHydratedData('acled-events')
    this.setContent(renderConflictList(data))
  }
}
            
PLAIN ENGLISH

Panel 基类是所有面板的"模板父亲",处理通用功能

setContent 负责把 HTML 渲染到面板里,防抖避免频繁重绘

refresh 是"刷新数据"的接口,每个子类自己实现

resize 让用户可以拖拽调整面板大小,记忆到浏览器存储

ConflictPanel 是冲突数据面板,继承所有通用功能

它只需要告诉父类:我要用 acled-events 数据,我的 HTML 长这样

💡
为什么不用 React?

原生 TypeScript + 类继承在这个场景下更快、更可控。WorldMonitor 需要精确控制每个面板的渲染时机和 WebGL 地图的生命周期,框架反而会制造障碍。

双地图引擎:平面与球形

WorldMonitor 同时维护两套地图:

🗺️

DeckGLMap(平面地图)

deck.gl + MapLibre GL,支持散点图层、热力图、路径图、弧线图。PMTiles 自托管底图切片,Supercluster 标记聚合

🌍

GlobeMap(3D 地球)

globe.gl + Three.js,地球纹理 + 大气着色器 + 空闲时自动旋转。所有数据点合并为单一渲染数组,用 _kind 字段区分类型

你想给 WorldMonitor 新增一个"地震数据"面板。为了复用现有的渲染、防抖、大小调整逻辑,你应该继承哪个类?

03

数据管道

65+ 个外部数据源怎么变成屏幕上那个流畅的实时仪表盘——数据的完整旅程

数据从哪里来,怎么到达你眼前

全球数据到达你的浏览器,要经过一条完整的流水线。理解这条流水线,你就能判断"为什么这个数据是 1 分钟前的"或者"为什么加载慢了"。

🌐
上游 API
🌱
Seed 脚本
Redis 缓存
🖥️
浏览器
点击"下一步"追踪数据旅程

Bootstrap:一次请求,所有数据

WorldMonitor 最聪明的性能优化之一:Bootstrap 端点把所有缓存数据一次性批量读出,避免几十个串行请求。

Bootstrap 策略

// 同时发两个请求,不等对方
const [fast, slow] = await Promise.allSettled([
  fetchBootstrap({ timeout: 3000 }),  // 3 秒超时
  fetchBootstrap({ timeout: 5000 }),  // 5 秒超时
])
// Redis 批量读取所有缓存键
const all_data = await redis.mget(allBootstrapKeys)
            
PLAIN ENGLISH

同时发起两个 Bootstrap 请求,互不等待

第一个:3 秒超时,先快速加载部分数据,让界面先显示出来

第二个:5 秒超时,加载更完整的数据集

服务端用 Redis mget 一次命令读取所有缓存键——一次网络请求 vs 几十次串行请求

健康监控:数据是否新鲜?

每次往 Redis 写数据,WorldMonitor 同时写一条 seed-meta 记录,记录写入时间和条目数量:

OK 数据在预期时间内更新,一切正常
STALE 数据超过最大允许过期时间,上游可能挂了
WARN 数据有点旧了,但还在可接受范围内,观察中
EMPTY Redis 里根本没有这条数据,Seed 脚本可能从未成功运行过

/api/health 显示某个数据源的状态是 EMPTY。这最可能意味着什么,你应该优先检查哪里?

04

缓存与 API 层

四层缓存、ETag 验证、速率限制——WorldMonitor 如何在高并发下保持低延迟

四层缓存:从快到慢

缓存是 WorldMonitor 能处理大量用户访问的关键。它用了四层嵌套缓存,每层都有自己的 TTL(生存时间):

1
Bootstrap 预热(Railway 写 Redis)

Railway 上的 Seed 脚本定时把上游数据写入 Redis,用户请求到来时 Redis 已经有数据

2
Vercel 实例内存缓存(短 TTL)

每个 Edge Function 实例在内存里缓存最近的数据,同一实例的重复请求直接命中

3
Redis(Upstash,跨实例共享)

cachedFetchJson 合并并发请求:同一时刻多个请求只向上游发一次请求,然后一起共享结果

4
上游 API(最慢,最后兜底)

前三层都未命中时才请求真实数据源,结果写回 Redis 和 seed-meta

缓存分级:数据有多"鲜"很重要

不同类型的数据"新鲜度要求"不同——飞机位置每秒都在变,历史灾难数据几天才更新一次。WorldMonitor 据此分了 6 个缓存级别:

fast · 5min 实时事件流、航班状态——每 5 分钟刷新
medium · 10min 市场报价、股票分析——10 分钟缓存
slow · 30min ACLED 冲突事件、网络威胁——半小时缓存
daily · 24h 关键矿产、静态参考数据——每天缓存
no-store · 0 船舶快照、飞机实时追踪——不缓存,每次直接拉取

ETag:省流量的聪明设计

WorldMonitor 的 API 网关有个精妙的优化:ETag 协商缓存

缓存机制知识测试

Redis 缓存刚过期,100 个用户同时请求同一个 API 端点。如果不处理,会向上游发 100 个请求。WorldMonitor 用哪个机制把 100 个请求变成 1 个上游请求?

05

桌面端与变体系统

Tauri 2 壳 + Node.js 旁载服务器——WorldMonitor 如何变成一个离线可用的原生桌面应用

为什么要做桌面应用?

WorldMonitor 既是网页应用,也是桌面应用。一套前端代码,两种运行环境。Tauri 2 让这成为可能:

🖥️

原生系统集成

系统托盘图标、开机自启、原生窗口管理——用户不需要打开浏览器

🔑

安全密钥管理

API Key 存储在系统 Keyring(macOS 钥匙串 / Windows Credential Manager),而不是明文文件

📡

本地 API 服务器

桌面版内置一个 Node.js 旁载服务器,把所有 /api/* 请求转发给本地处理——不依赖 Vercel

三层架构:Rust 壳 → Node.js 旁载 → 前端

桌面版有三个独立进程,各司其职:

1
Tauri 2 Rust 壳(主进程)

管理窗口生命周期、系统托盘、IPC 命令注册。从 Keyring 读写 API Key,通过 invoke() 把密钥传给前端

2
Node.js 旁载(Sidecar)

local-api-server.mjs——Tauri 启动时同步拉起,监听本地端口。动态加载 Edge Function 处理器,注入从 Keyring 拿到的密钥

3
前端(WebView)

和网页版完全相同的前端代码。启动时检测运行环境,安装 fetch 补丁,把 /api/* 请求重定向到本地旁载服务器

fetch 补丁:一行改变请求目的地

桌面版最精妙的设计:前端代码不需要改动,只在启动时打一个 fetch 补丁,把云端 API 请求无缝切换到本地:

installRuntimeFetchPatch()

const originalFetch = window.fetch
window.fetch = async (url, options) => {
  if (url.startsWith('/api/')) {
    // 把 /api/markets 重定向到本地旁载
    const localUrl = `http://127.0.0.1:${port}${url}`
    return originalFetch(localUrl, {
      ...options,
      headers: { 'Authorization': `Bearer ${jwt}` }
    })
  }
  return originalFetch(url, options) // 其他请求不变
}
            
PLAIN ENGLISH

保存原始 fetch 函数

用自定义函数替换 window.fetch

如果请求 URL 以 /api/ 开头(如 /api/markets)

把目的地从 Vercel 云端改为 127.0.0.1 本地旁载端口

同时附上 JWT token 供旁载服务器验证身份

其他 URL(外部资源等)原封不动,不影响正常请求

启动握手:旁载服务器怎么和前端建立信任

五变体系统:同一套代码,五种专业版

WorldMonitor 通过检测域名决定激活哪个变体——变体系统在代码加载后第一时间运行:

worldmonitor.app default 变体:地缘政治 + 军事 + 灾难 + 金融全品类,全部 86 个面板可用
tech.* tech 变体:AI 动态、芯片战争、网络安全,隐藏金融和冲突面板
finance.* finance 变体:92 个交易所、加密货币、大宗商品实时行情,5 分钟刷新
commodity.* commodity 变体:能源、农产品、关键矿产追踪,30 分钟刷新(数据更新慢)
happy.* happy 变体:仅展示正面新闻,过滤所有冲突和灾难数据——反刷机器
💡
变体不是多套代码

每个变体只是一个配置对象:激活哪些面板、默认哪些地图图层、刷新间隔多久、主题色用哪套。所有业务逻辑、缓存层、API 层完全共享。

桌面端与变体系统知识测试

WorldMonitor 桌面版和网页版共用同一套前端代码。桌面版如何在不修改任何业务代码的情况下,把 /api/markets 这样的请求从 Vercel 云端切换到本地旁载服务器?