真实场景 MVP:每个用户一个「价格提醒」Durable Object

交易 App 常见需求:「BTC 涨破 X 时通知我」。用户设好提醒后就算关掉 App,也要有人一直盯着价格,触发时推送到他所有在线设备。

已在本地 wrangler dev 实测跑通:两条提醒写进 SQLite,Alarm 10 秒后拉 Hyperliquid allMids,BTC 那条触发并通过 WebSocket 推送到客户端,ETH 那条没触发;重启 dev server 后数据还在;另一个用户地址查出来是空列表,互不干扰。

为什么这个场景特别适合 DO

按用户天然切分

DO 名字 = 钱包地址。每个用户的提醒、连接、定时器全在自己的 DO 里,规模扩展就是多开 DO。

Alarm 替代 cron + 队列

每个 DO 带一个持久闹钟。有未触发的提醒才续约,没活就停,DO 被回收不计费。

WebSocket 汇聚点

手机、网页同时在线,都连到同一个 DO;触发时遍历 getWebSockets() 广播。

不用锁

「新增提醒」和「Alarm 标记已触发」都在同一个单线程里串行执行,不会重复推送。

数据流

  1. POST /users/0xabc…/alerts:Worker 调 env.ALERTS.getByName(地址) 拿到这个用户的 DO,再 RPC addAlert(),写 SQLite,没有闹钟就挂一个。
  2. GET /users/0xabc…/ws:Worker 把升级请求转给同一个 DO,DO 用 ctx.acceptWebSocket() 接住;连接期间 DO 仍可休眠。
  3. 10 秒后平台调用 alarm():拉 HL 价格,命中的提醒写上 fired_at、广播;还有未触发的就再挂 10 秒。

代码 · src/index.ts(整份,138 行)

import { DurableObject } from "cloudflare:workers";

interface Env {
  ALERTS: DurableObjectNamespace<PriceAlerts>;
}

type Direction = "above" | "below";
interface Alert {
  id: number;
  coin: string;
  direction: Direction;
  price: string;
  fired_at: number | null;
}

const HL_INFO = "https://api.hyperliquid.xyz/info";
const CHECK_EVERY_MS = 10_000;

/**
 * 每个用户一个 DO:名字 = 用户地址。
 * - SQLite 存该用户的提醒列表(持久)
 * - Alarm 每 10s 拉一次 HL allMids 检查是否触发(有未触发提醒才续闹钟)
 * - 该用户所有在线设备的 WebSocket 都挂在这里,触发时广播
 */
export class PriceAlerts extends DurableObject<Env> {
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    ctx.blockConcurrencyWhile(async () => {
      ctx.storage.sql.exec(`
        CREATE TABLE IF NOT EXISTS alerts (
          id        INTEGER PRIMARY KEY AUTOINCREMENT,
          coin      TEXT NOT NULL,
          direction TEXT NOT NULL CHECK (direction IN ('above','below')),
          price     TEXT NOT NULL,
          fired_at  INTEGER
        )`);
    });
  }

  // ---------- RPC:Worker 直接调用 ----------

  async addAlert(coin: string, direction: Direction, price: string): Promise<Alert> {
    const row = this.ctx.storage.sql
      .exec<Alert>(
        "INSERT INTO alerts (coin, direction, price) VALUES (?, ?, ?) RETURNING *",
        coin.toUpperCase(),
        direction,
        price,
      )
      .one();
    // 没有闹钟就挂一个;已有则不动(同一 DO 只有一个闹钟)
    if ((await this.ctx.storage.getAlarm()) === null) {
      await this.ctx.storage.setAlarm(Date.now() + CHECK_EVERY_MS);
    }
    return row;
  }

  listAlerts(): Alert[] {
    return this.ctx.storage.sql.exec<Alert>("SELECT * FROM alerts ORDER BY id").toArray();
  }

  // ---------- WebSocket:用户设备订阅触发推送 ----------

  async fetch(req: Request): Promise<Response> {
    if (req.headers.get("Upgrade") !== "websocket") {
      return new Response("expected websocket", { status: 426 });
    }
    const { 0: client, 1: server } = new WebSocketPair();
    // Hibernation API:DO 可以休眠而连接不断
    this.ctx.acceptWebSocket(server);
    server.send(JSON.stringify({ type: "hello", pending: this.pending().length }));
    return new Response(null, { status: 101, webSocket: client });
  }

  webSocketMessage(ws: WebSocket, msg: string | ArrayBuffer) {
    if (msg === "ping") ws.send("pong");
  }

  // ---------- Alarm:定时检查 ----------

  async alarm() {
    const pending = this.pending();
    if (pending.length === 0) return; // 没活了,不续闹钟 → DO 可被回收

    const res = await fetch(HL_INFO, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ type: "allMids" }),
    });
    const mids = (await res.json()) as Record<string, string>;

    for (const a of pending) {
      const mid = mids[a.coin];
      if (mid === undefined) continue;
      // demo 用 Number 比较;Berth 仓库里金融比较必须走 BigNumber
      const hit =
        a.direction === "above" ? Number(mid) >= Number(a.price) : Number(mid) <= Number(a.price);
      if (!hit) continue;

      this.ctx.storage.sql.exec("UPDATE alerts SET fired_at = ? WHERE id = ?", Date.now(), a.id);
      const event = JSON.stringify({ type: "fired", alert: a, mid });
      for (const ws of this.ctx.getWebSockets()) ws.send(event);
    }

    if (this.pending().length > 0) {
      await this.ctx.storage.setAlarm(Date.now() + CHECK_EVERY_MS);
    }
    // alarm() 抛错时平台会自动重试
  }

  private pending(): Alert[] {
    return this.ctx.storage.sql
      .exec<Alert>("SELECT * FROM alerts WHERE fired_at IS NULL")
      .toArray();
  }
}

/** Worker:无状态路由,按用户地址把请求转给对应的 DO */
export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const url = new URL(req.url);
    const m = url.pathname.match(/^\/users\/(0x[0-9a-fA-F]{40})\/(alerts|ws)$/);
    if (!m) return new Response("not found", { status: 404 });

    const [, address, action] = m;
    const user = env.ALERTS.getByName(address.toLowerCase());

    if (action === "ws") return user.fetch(req);
    if (req.method === "GET") return Response.json(await user.listAlerts());
    if (req.method === "POST") {
      const body = (await req.json()) as { coin: string; direction: Direction; price: string };
      return Response.json(await user.addAlert(body.coin, body.direction, body.price), {
        status: 201,
      });
    }
    return new Response("method not allowed", { status: 405 });
  },
};

代码 · wrangler.jsonc

{
  "name": "price-alert-do",
  "main": "src/index.ts",
  "compatibility_date": "2025-09-01",
  "durable_objects": {
    "bindings": [{ "name": "ALERTS", "class_name": "PriceAlerts" }]
  },
  // 声明 DO 类,SQLite 后端。已发布的 tag 只增不改
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["PriceAlerts"] }]
}

实测记录(原样输出)

# 客户端先连 WS,然后加两条提醒
POST BTC above 1        → {"id":1,"coin":"BTC","direction":"above","price":"1","fired_at":null}
POST ETH above 1000000  → {"id":2,"coin":"ETH","direction":"above","price":"1000000","fired_at":null}

# WS 客户端收到
01:40:50 WS <- {"type":"hello","pending":0}
01:41:01 WS <- {"type":"fired","alert":{"id":1,"coin":"BTC",...},"mid":"80799.5"}   ← Alarm 约 10s 后触发

# 14s 后查询:BTC 已触发,ETH 仍在等
GET → [{"id":1,"coin":"BTC",...,"fired_at":1792806061771},{"id":2,"coin":"ETH",...,"fired_at":null}]

# 另一个用户 0x2222… → 独立 DO,空
GET → []

# 杀掉 wrangler dev 重启后再查 0x1111… → 数据还在(SQLite 持久化)
GET → [{"id":1,...,"fired_at":1792806061771},{"id":2,...,"fired_at":null}]

注:hello 里 pending:0 是因为 WS 先连上、提醒后加的,属于正常时序。

自己跑一遍

cd /private/tmp/claude-501/-Users-ziyi-repos-berthlab-berth/f99d6d2b-97ff-4c9d-8960-127c209a46c8/scratchpad/price-alert-do
/Users/ziyi/repos/berthlab/berth/node_modules/.bin/wrangler dev --port 8799

# 另开终端
node ws-client.mjs 0x1111111111111111111111111111111111111111 30000 &
curl -XPOST localhost:8799/users/0x1111111111111111111111111111111111111111/alerts \
  -d '{"coin":"BTC","direction":"above","price":"1"}'

从 MVP 到生产还差什么

缺口做法
没鉴权,谁都能写别人的地址Worker 先验会话,拿到 userId 再 getByName(就是 apps/bot 里 BotOwner 的做法)
价格比较用了 NumberBerth 铁律 3:改成 BigNumber
每个用户各拉一次 allMids,1 万用户 = 每 10 秒 1 万次请求拆一个全局「价格」DO 或 Worker 订阅 HL WS,按 coin 分发;用户 DO 只负责存储和推送
App 关闭时收不到 WS触发时额外发 APNs / FCM 推送
输入没校验zod 校验 coin / direction / price