Hao0321 遊戲大廳 14 款遊戲都共用同一套後端 — 登入、每日簽到、排行榜、金幣、成就系統。整個後端是一個 250 行左右的 worker.js,跑在 Cloudflare Workers 上,資料存在 Cloudflare D1(邊緣 SQLite)。這篇把整個架構拆解,順便分享 1 人工作室在後端設計上可以偷懶(或不能偷懶)的地方。
選擇 Cloudflare Workers 的理由
一開始我評估過幾個選項:
| 方案 | 優點 | 為什麼不選 |
|---|---|---|
| Vercel Serverless + Postgres | Next.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 端的工作是:
- 接收前端傳來的 Google ID token
- 呼叫 Google tokeninfo 端點驗證
- 檢查
aud等於我們的 GOOGLE_CLIENT_ID(避免任何別處的 Google ID token 都能換我們的 session) - 檢查
exp未過期、email_verified為 true - 在 D1
users表找 / 建使用者 - 用
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_SECRET 用 wrangler 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)
);
為什麼同時有 scores 和 best_scores?
每次 POST /score 都會 insert 一筆到 scores(歷史紀錄),並檢查是否更新 best_scores(排行榜用)。雙表設計的好處:
- 排行榜查詢快:直接
SELECT FROM best_scores ORDER BY score DESC,不用跨所有歷史紀錄 GROUP BY。 - 歷史資料完整:未來要做趨勢圖時不用回推。
關鍵端點:簽到邏輯
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 裡寫程式碼):
/auth/*— 每 IP 每分鐘 10 次(防止 token 暴力交換)/score— 每 IP 每分鐘 60 次(合理玩家上限)/play— 每 IP 每分鐘 120 次(匿名觸發但限制刷次數)
這些規則直接在 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 內。真正要開始付費的觸發點:
- Workers 請求 > 10 萬 / 天 → $5 USD 起跳
- D1 儲存 > 5GB → 每 GB $0.75 / 月
- D1 寫入 > 10 萬 / 月 → $1 USD / 百萬寫入
結語
Cloudflare Workers + D1 把「做一個 production 等級的後端」從「需要一個小型團隊」壓縮到「一個人一個週末」。這不是萬靈丹,但對工作室規模的應用是極佳的選擇。省下來的心力應該放在差異化的產品細節 — 遊戲好不好玩、設計有沒有記憶點、內容有沒有價值。