Backend

用 Cloudflare Workers + D1 打造 1 人可營運的遊戲後端

2026 年 4 月 21 日 約 12 分鐘閱讀 作者:Hao0321 Studio

Hao0321 遊戲大廳 14 款遊戲都共用同一套後端 — 登入、每日簽到、排行榜、金幣、成就系統。整個後端是一個 250 行左右的 worker.js,跑在 Cloudflare Workers 上,資料存在 Cloudflare D1(邊緣 SQLite)。這篇把整個架構拆解,順便分享 1 人工作室在後端設計上可以偷懶(或不能偷懶)的地方。

選擇 Cloudflare Workers 的理由

一開始我評估過幾個選項:

方案優點為什麼不選
Vercel Serverless + PostgresNext.js 體驗流暢冷啟動慢、DB 費用貴
Fly.io + SQLite多區部署、便宜要管 container,複雜度上升
自架 VPS完全控制一個人沒時間管作業系統
Firebase開箱即用vendor lock-in 嚴重
Cloudflare Workers + D1邊緣運算、零冷啟動、免費額度大(選了這個)

對遊戲排行榜這種讀多寫少、對延遲敏感、流量可預測的應用,Cloudflare Workers 是甜蜜點。

Workers 的核心優勢:V8 Isolates

跟 AWS Lambda 不同,Cloudflare Workers 不是啟動 container,而是在 V8 engine 裡建立一個 isolated context。這個 context 大約 5ms 啟動,幾乎感受不到冷啟動。台灣使用者打 api.hao0321.com 的端到端延遲大約 60ms。

整體架構

           [ Game Client ]                [ Game Hall ]
                │                              │
                ├─ POST /play (beacon)         ├─ Google OAuth
                ├─ POST /score (JWT auth)      ├─ GET /leaderboard
                │                              └─ GET /profile
                ▼                              ▼
    ┌────────────────────────────────────────────────┐
    │   Cloudflare Worker: api.hao0321.com           │
    │   • Route dispatch                             │
    │   • JWT verify / sign                          │
    │   • Google ID token aud / exp validation       │
    │   • Achievement detection                      │
    └────────────────────────────────────────────────┘
                       │
                       ▼
    ┌────────────────────────────────────────────────┐
    │   Cloudflare D1 (SQLite on edge)               │
    └────────────────────────────────────────────────┘

路由設計

Worker 裡不用 framework(Hono、itty-router 之類),直接手寫 if 判斷:

export default {
  async fetch(req, env) {
    if (req.method === 'OPTIONS') return new Response(null, { headers: CORS });
    if (!env.JWT_SECRET) return err('JWT_SECRET not set', 500);

    const url = new URL(req.url);
    const path = url.pathname.replace(/^\/+|\/+$/g, '');

    try {
      if (path === 'auth/google') { /* ... */ }
      if (path === 'checkin' && req.method === 'POST') { /* ... */ }
      if (path === 'score' && req.method === 'POST') { /* ... */ }
      if (path.startsWith('leaderboard/')) { /* ... */ }
      return err('Not found', 404);
    } catch (e) {
      return err('Server error', 500);
    }
  },
};

看起來很土,但 9 個端點手寫 if 完全不會亂。framework 的 routing DSL 要花時間學、要維護版本、要擔心 middleware 執行順序,對這個規模不值得。

JWT 與 Google OAuth:為什麼不自己管密碼

遊戲大廳 100% 靠 Google Sign-In,Worker 端的工作是:

  1. 接收前端傳來的 Google ID token
  2. 呼叫 Google tokeninfo 端點驗證
  3. 檢查 aud 等於我們的 GOOGLE_CLIENT_ID(避免任何別處的 Google ID token 都能換我們的 session)
  4. 檢查 exp 未過期、email_verified 為 true
  5. 在 D1 users 表找 / 建使用者
  6. JWT_SECRET 簽一個 30 天的 session JWT 回給前端

JWT 簽章用 Web Crypto API 的 HMAC-SHA256:

async function signToken(payload, secret) {
  const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
  const body = btoa(JSON.stringify({
    ...payload,
    exp: Date.now() + 30 * 24 * 3600 * 1000,
  }));
  const key = await crypto.subtle.importKey(
    'raw', new TextEncoder().encode(secret),
    { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
  );
  const sig = await crypto.subtle.sign('HMAC', key,
    new TextEncoder().encode(header + '.' + body));
  return header + '.' + body + '.' + btoa(String.fromCharCode(...new Uint8Array(sig)));
}
安全提示JWT_SECRETwrangler secret put JWT_SECRET 存在 Cloudflare 加密環境變數,絕對不要寫在 wrangler.toml。我們踩過一次,rotate 後才逃過一劫。同樣地,aud 驗證如果忘了做,等於開著大門讓任何持有 Google ID token 的攻擊者拿到你網站的 session — 這是實際在很多教學文裡都漏掉的步驟。

D1 Schema 設計

CREATE TABLE users (
  id TEXT PRIMARY KEY,
  google_id TEXT UNIQUE NOT NULL,
  name TEXT NOT NULL,
  avatar TEXT,
  coins INTEGER DEFAULT 0,
  created_at TEXT DEFAULT (datetime('now')),
  last_login TEXT DEFAULT (datetime('now'))
);

CREATE TABLE checkins (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id TEXT NOT NULL,
  date TEXT NOT NULL,
  streak INTEGER DEFAULT 1,
  reward INTEGER DEFAULT 10,
  UNIQUE(user_id, date)
);

CREATE TABLE scores (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id TEXT NOT NULL,
  game_id TEXT NOT NULL,
  score INTEGER NOT NULL,
  created_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE best_scores (
  user_id TEXT NOT NULL,
  game_id TEXT NOT NULL,
  score INTEGER NOT NULL,
  PRIMARY KEY (user_id, game_id)
);

CREATE TABLE play_counts (
  game_id TEXT PRIMARY KEY,
  count INTEGER DEFAULT 0
);

CREATE TABLE achievements (
  user_id TEXT NOT NULL,
  achievement_id TEXT NOT NULL,
  unlocked_at TEXT DEFAULT (datetime('now')),
  PRIMARY KEY (user_id, achievement_id)
);

為什麼同時有 scoresbest_scores

每次 POST /score 都會 insert 一筆到 scores(歷史紀錄),並檢查是否更新 best_scores(排行榜用)。雙表設計的好處:

關鍵端點:簽到邏輯

if (path === 'checkin' && req.method === 'POST') {
  const u = await getUser(req, env);
  if (!u) return err('Unauthorized', 401);

  const today = new Date().toISOString().split('T')[0];
  const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];

  const existing = await env.DB.prepare(
    'SELECT * FROM checkins WHERE user_id = ? AND date = ?'
  ).bind(u.uid, today).first();
  if (existing) return json({ already: true, streak: existing.streak });

  const prev = await env.DB.prepare(
    'SELECT streak FROM checkins WHERE user_id = ? AND date = ?'
  ).bind(u.uid, yesterday).first();
  const streak = prev ? prev.streak + 1 : 1;
  const reward = Math.min(10 + (streak - 1) * 5, 50);

  await env.DB.prepare(
    'INSERT INTO checkins (user_id, date, streak, reward) VALUES (?, ?, ?, ?)'
  ).bind(u.uid, today, streak, reward).run();

  return json({ streak, reward, already: false });
}

連續簽到的 streak 計算靠查昨天的紀錄 — 如果昨天有簽、streak += 1;沒有、從 1 開始。

分數驗證與防作弊

為了防止前端被改,伺服器端做最小限度的驗證:

const body = await readJson(req);
if (!body) return err('body required');
const { game, score } = body;
if (!game || score === undefined) return err('game and score required');
if (typeof score !== 'number' || !Number.isFinite(score)
    || score < 0 || score > 1e9) return err('invalid score');

// 限制只允許已知遊戲 ID
const ALLOWED_GAMES = new Set(['cat-battle','dodge-master',/* ... */]);
if (!ALLOWED_GAMES.has(game)) return err('unknown game');

這擋不住有心作弊的玩家(前端本來就會執行他們可以改的 JS),但擋住大部分意外錯誤(NaN、字串、負數、未知 game)。真要完全防弊,得把遊戲邏輯搬到伺服器,那是另一個量級的工程。

速率限制(Rate Limiting)

用 Cloudflare 邊緣的 WAF Rate Limiting Rules(不在 Worker 裡寫程式碼):

這些規則直接在 Cloudflare Dashboard → Security → WAF 設定,比在 Worker 裡用 D1 做 leaky bucket 省力且免費。

部署與 DNS 踩坑

重要踩坑:一開始 route 設成 api.game.hao0321.com/*(三層子網域)。結果 Cloudflare Universal SSL 只涵蓋 *.hao0321.com,瀏覽器打開會回 SSL 錯誤。最後遷到 api.hao0321.com。教訓:免費 Cloudflare 用戶自己用子網域要留在兩層以內。

成本

目前每月 Workers 請求約 1500、D1 讀取 5000、D1 寫入 200。全部在 free tier 內。真正要開始付費的觸發點:

結語

Cloudflare Workers + D1 把「做一個 production 等級的後端」從「需要一個小型團隊」壓縮到「一個人一個週末」。這不是萬靈丹,但對工作室規模的應用是極佳的選擇。省下來的心力應該放在差異化的產品細節 — 遊戲好不好玩、設計有沒有記憶點、內容有沒有價值。