Day 1 的「不做」清單第一條就是不做後端。今天要打破它。
先說為什麼現在才值得。26 天來我實際遇到的問題:
Day 17 的匯出/匯入是止血(使用者可以手動搬),但沒人會每天匯出一次。
而「現在」的另一個意義是:同步的核心邏輯我已經在 Day 17 寫過了(合併策略),基礎建設也齊了(Day 22 的 CI、Day 25 的安全 header)。這是最省力的時機。
這是今天最重要的一個決定。
❌ 常見做法:雲端是真相,本地是快取
→ 沒網路 = 不能用;後端掛掉 = 網站掛掉
✅ 我的做法:本地是真相,雲端是備援與傳輸媒介
→ 沒網路完全正常;後端掛掉只是不同步
具體規則:
這保護了前 26 天的所有成果。如果做成「雲端優先」,離線可用、file:// 可開這些特性全部作廢。
progress: { userId, lessonKey, doneAt }
就這樣。不存:使用者名稱、email、瀏覽紀錄、IP、裝置資訊、測驗答錯了什麼。
理由(Day 28 會展開):沒收集的資料不會洩漏。 而且這網站的功能不需要那些。
XP 與徽章要同步嗎?我決定只同步 progress,XP 由各裝置從 progress 重算。理由:XP 是衍生資料(完成 N 課 = 20N XP),同步衍生資料只會製造不一致。徽章同理。
(streak 是唯一的例外——它依賴「連續天數」,無法從 progress 重建。我選擇不同步它,各裝置各自算。這是有意識的取捨:streak 是激勵機制,不精確可以接受。)
三個端點:
POST /api/sync 同步(雙向):送本地變更,回合併後結果
GET /api/progress 取完整進度(新裝置首次登入用)
DELETE /api/account 刪除帳號與所有資料(Day 28 的合規基本盤)
POST /api/sync 的請求與回應:
// 請求
{
"since": 1754179200000, // 上次同步時間(毫秒)
"changes": { "ch01-1": 1754179300000, "ch09-2": 1754179400000 }
}
// 回應
{
"serverTime": 1754179500000,
"merged": { "ch01-1": 1754179300000, "ch05-3": 1754100000000, /* … */ },
"count": 47
}
值存完成時間戳而不是 true。這解決了兩件事:
這是整個功能的核心。考慮這個情境:
兩台裝置都離線:
電腦:完成 ch01-1, ch01-2, ch01-3
手機:完成 ch09-1, ch09-2
各自上線同步。
如果用整份文件的 last-write-wins(常見的簡單做法):後同步的那台會覆蓋前一台,其中一邊的進度直接消失。
正解是逐項合併,而且方向明確:
/* 合併規則:完成過就永遠算完成(單調遞增),時間取較早的 */
function mergeProgress(a, b) {
const out = { ...a };
for (const [k, t] of Object.entries(b)) {
if (!(k in out) || t < out[k]) out[k] = t;
}
return out;
}
為什麼是 union 而不是雙向同步?
因為「完成一課」在語意上是不可撤銷的事實。使用者不會想「取消完成」——就算想,那個需求的重要性遠低於「絕不弄丟進度」。
這讓合併變成一個 CRDT(grow-only set + min timestamp):無論同步順序如何、無論同步幾次、無論哪一邊先,結果都相同(可交換、可結合、幂等)。不需要 vector clock、不需要衝突解決 UI。
代價是「取消完成」無法跨裝置生效(本地會生效,下次同步又被拉回來)。我接受,並在 UI 上說明。
(這個合併邏輯與 Day 17 的匯入策略是同一套——那時的「XP 取 max、集合取聯集」就是為今天鋪的路。)
主站在 Cloudflare(Day 21),所以後端也放這裡,connect-src 可以維持同源。
-- schema.sql
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, -- 隨機 UUID,不含任何個資
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS progress (
user_id TEXT NOT NULL,
lesson_key TEXT NOT NULL,
done_at INTEGER NOT NULL,
PRIMARY KEY (user_id, lesson_key)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_progress_user_time ON progress(user_id, done_at);
WITHOUT ROWID 對這種「純複合主鍵查詢」的表能省空間並加快查詢。
/* functions/api/sync.js — Cloudflare Pages Functions */
const KEY_RE = /^[a-z0-9]{2,10}\d{0,2}-\d{1,3}$/; // 課文 key 的格式白名單
export async function onRequestPost({ request, env }) {
const userId = await requireUser(request, env); // Day 28 實作
if (!userId) return json({ error: "unauthorized" }, 401);
let body;
try { body = await request.json(); }
catch { return json({ error: "bad json" }, 400); }
const changes = body?.changes;
if (!changes || typeof changes !== "object" || Array.isArray(changes))
return json({ error: "bad changes" }, 400);
const entries = Object.entries(changes);
/* 上限保護:正常使用者一次同步不會超過幾十筆 */
if (entries.length > 500) return json({ error: "too many changes" }, 413);
const now = Date.now();
const valid = [];
for (const [k, t] of entries) {
if (typeof k !== "string" || !KEY_RE.test(k)) continue; // 格式不符直接丟
const ts = Number(t);
if (!Number.isFinite(ts) || ts <= 0 || ts > now + 86400000) continue; // 未來時間丟掉
valid.push([k, Math.floor(ts)]);
}
/* 寫入:ON CONFLICT 取較早的時間(合併規則在 SQL 層實現) */
if (valid.length) {
const stmt = env.DB.prepare(
`INSERT INTO progress (user_id, lesson_key, done_at) VALUES (?, ?, ?)
ON CONFLICT(user_id, lesson_key) DO UPDATE SET done_at = MIN(done_at, excluded.done_at)`
);
await env.DB.batch(valid.map(([k, t]) => stmt.bind(userId, k, t)));
}
/* 回傳完整進度(規模小,全量回傳最簡單也最不容易錯) */
const { results } = await env.DB
.prepare("SELECT lesson_key, done_at FROM progress WHERE user_id = ?")
.bind(userId).all();
const merged = {};
for (const r of results) merged[r.lesson_key] = r.done_at;
return json({ serverTime: now, merged, count: results.length });
}
const json = (obj, status = 200) => new Response(JSON.stringify(obj), {
status,
headers: {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
},
});
四個防護,每一個都對應一種實際攻擊:
| 防護 | 防什麼 |
|---|---|
KEY_RE 白名單 |
有人塞 10 萬個垃圾 key 把我的 D1 撐爆 |
entries.length > 500 |
單次請求的資源耗盡 |
| 未來時間戳過濾 | 竄改時間讓熱力圖顯示到 2099 年 |
ON CONFLICT ... MIN() |
竄改時間把「完成日」改早(只能改早不能改晚,且無法刪除) |
合併規則寫在 SQL 的 ON CONFLICT 裡,而不是「先讀出來、在 JS 合併、再寫回去」。後者有 race condition(兩個請求同時進來會互相覆蓋),前者是原子操作。
cache-control: no-store 別漏——API 回應絕不能被 CDN 或瀏覽器快取。
/* js/models/sync.js */
const Sync = {
KEY: "learnpath-sync-v1", // { lastSync, pending: {key: ts} }
_timer: null,
_inflight: false,
state() {
try { const v = JSON.parse(localStorage.getItem(this.KEY));
return (v && typeof v === "object") ? v : { lastSync: 0, pending: {} }; }
catch { return { lastSync: 0, pending: {} }; }
},
saveState(s) { try { localStorage.setItem(this.KEY, JSON.stringify(s)); } catch {} },
/* 每次完成一課就記進 pending,然後 debounce 同步 */
queue(lessonKey) {
const s = this.state();
s.pending[lessonKey] = s.pending[lessonKey] || Date.now();
this.saveState(s);
this.schedule();
},
schedule(delay = 3000) {
clearTimeout(this._timer);
this._timer = setTimeout(() => this.run(), delay);
},
async run() {
if (this._inflight || !Auth.isSignedIn()) return;
if (!navigator.onLine) { this.setBadge("offline"); return; }
const s = this.state();
/* 送 pending,外加完整本地進度(讓伺服器補上它沒有的) */
const changes = { ...s.pending };
const local = Progress.load();
for (const k of Object.keys(local)) if (!(k in changes)) changes[k] = 0; // 0 = 時間未知
if (!Object.keys(changes).length && s.lastSync) return;
this._inflight = true;
this.setBadge("syncing");
try {
const res = await fetch("/api/sync", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ since: s.lastSync, changes: this.stripZeros(changes) }),
});
if (res.status === 401) { Auth.signOut(); this.setBadge("signed-out"); return; }
if (!res.ok) throw new Error(`sync ${res.status}`);
const data = await res.json();
/* 把伺服器的合併結果寫回本地——同樣是 union,本地不會掉東西 */
const before = Progress.doneCount();
const localNow = Progress.load();
for (const k of Object.keys(data.merged)) localNow[k] = true;
Progress.save(localNow);
this.saveState({ lastSync: data.serverTime, pending: {} });
this.setBadge("ok");
if (Progress.doneCount() !== before) {
NavView.render();
this.toast(`已同步:從其他裝置取得 ${Progress.doneCount() - before} 課進度`);
}
} catch (e) {
this.setBadge("error");
this.schedule(this._backoff()); // 指數退避重試
} finally {
this._inflight = false;
}
},
_tries: 0,
_backoff() {
this._tries = Math.min(this._tries + 1, 6);
return Math.min(5 * 60_000, 2000 * 2 ** this._tries); // 4s, 8s, … 上限 5 分鐘
},
stripZeros(c) {
const out = {};
for (const [k, t] of Object.entries(c)) if (t > 0) out[k] = t;
return out;
},
init() {
if (!Auth.isSignedIn()) return;
window.addEventListener("online", () => this.schedule(500));
/* 分頁隱藏時把 pending 送出去(可能是最後機會) */
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") this.flushBeacon();
});
this.schedule(1500);
},
/* 分頁關閉時的最後嘗試:sendBeacon 不會被 unload 中斷 */
flushBeacon() {
const s = this.state();
const changes = this.stripZeros(s.pending);
if (!Object.keys(changes).length) return;
try {
navigator.sendBeacon("/api/sync",
new Blob([JSON.stringify({ since: s.lastSync, changes })],
{ type: "application/json" }));
} catch {}
},
};
掛在既有事件點上(加法式接入,不改既有邏輯):
/* chapter.js 的 onComplete —— 只多一行 */
Progress.setDone(chId, lessonId, true);
Activity.bump("lessons");
if (window.Gamify) Gamify.onLessonComplete(lessonKey(chId, lessonId));
if (window.Sync) Sync.queue(lessonKey(chId, lessonId)); // ← 新增
注意 Progress.setDone 仍然是第一行。 UI 更新完全不等網路,這是「本地是真相」的具體體現。
三個工程細節:
sendBeacon 在分頁隱藏時發送:fetch 在 unload 時會被中斷,sendBeacon 不會。這是唯一可靠的「離開前送出」手段。Day 25 設的 connect-src 'self'。API 同源(/api/sync 在同一個 Pages 專案),所以不需要改。
這是把後端放同源的實質好處:CSP 不用動、沒有 CORS、cookie 自動帶。如果 API 在別的網域,就得:
connect-src 'self' https://api.learnpath.example.com
並且處理 CORS 與 SameSite cookie —— 三個額外的出錯機會。同源優先。
我兩邊都寫了一版(同樣的 schema 與 API):
| Cloudflare Workers + D1 | API Gateway + Lambda + DynamoDB | |
|---|---|---|
| 冷啟動 | 無(isolate 模型,實測 P99 12 ms) | Node runtime 約 400–900 ms |
| 本機開發 | wrangler pages dev(含 D1 本機模擬) |
SAM CLI / LocalStack,較重 |
| 部署 | 與前端同一次(Pages Functions) | 另一套 IaC |
| SQL | ✅ D1 是 SQLite | ❌ DynamoDB 是 KV,ON CONFLICT MIN() 那招做不到 |
| 免費額度 | 10 萬請求/日 | 100 萬請求/月 |
| 成本(我的量) | $0 | $0 |
| 多區域一致性 | D1 單一主寫入 + 唯讀複本 | DynamoDB 全球表(更強但複雜) |
選 Workers + D1。 決定性因素是 SQL 的 ON CONFLICT ... DO UPDATE SET done_at = MIN(...)——它讓合併規則變成一個原子操作。DynamoDB 要達到同樣效果得用 conditional update 加重試迴圈,複雜度高好幾倍。
Lambda 版本留在 repo 的 alt/lambda/ 當對照組(文章寫得出來就有價值),但不部署。
時間戳是不可信的輸入。 第一版我直接信任前端送來的 done_at。用 console 改一下 localStorage,就能讓熱力圖顯示「2099 年完成了 200 課」。
修法是兩層:伺服器端過濾未來時間(ts > now + 86400000 丟棄),以及 MIN() 語意(只能改早不能改晚)。留 24 小時容差是為了處理裝置時鐘偏差——使用者的系統時間本來就可能不準,不要假設它精確。
「完整進度全量回傳」在規模上是對的選擇。 我一開始想做增量(只回 since 之後的變更),但:256 課的完整進度 JSON 約 8 KB,gzip 後 1.5 KB。增量的複雜度(要處理 since 的時鐘偏差、要處理刪除、要處理漏送)完全不值得。
先量再優化。 如果哪天有 10 萬課,再回來做增量。
visibilitychange 比 beforeunload 可靠。 iOS Safari 的 beforeunload 經常不觸發(分頁被系統回收時)。visibilitychange → hidden 是目前最可靠的「使用者要離開了」信號。
D1 的 batch() 不是 transaction。 它是「一次往返送多個 statement」,效能好,但不保證原子性。對我的 union 合併沒問題(每個 statement 獨立幂等,部分成功下次同步會補上)。如果邏輯需要真正的原子性,要用 env.DB.transaction() 或改寫成單一 statement。
未登入時一切必須照常。 我在 Sync.init() 第一行就 if (!Auth.isSignedIn()) return,而所有掛鉤都是 if (window.Sync)。實測:把 sync.js 從 HTML 移除,網站行為與 Day 26 完全一致。這個回歸測試每次改同步邏輯都要跑——它保證我沒有把「加值功能」變成「必要依賴」。
# 本機開發(含 D1 模擬)
npx wrangler pages dev . --d1 DB=learnpath-db
同步邏輯的單元測試(純函式,好測):
/* scripts/check-merge.js */
const merge = (a, b) => {
const out = { ...a };
for (const [k, t] of Object.entries(b)) if (!(k in out) || t < out[k]) out[k] = t;
return out;
};
const A = { "ch01-1": 100, "ch01-2": 200 };
const B = { "ch09-1": 150, "ch01-1": 50 };
const cases = [
["交換律", JSON.stringify(merge(A, B)) === JSON.stringify(merge(B, A))],
["幂等", JSON.stringify(merge(A, A)) === JSON.stringify(A)],
["union", Object.keys(merge(A, B)).length === 3],
["取較早", merge(A, B)["ch01-1"] === 50],
["結合律", JSON.stringify(merge(merge(A, B), { "x-1": 1 }))
=== JSON.stringify(merge(A, merge(B, { "x-1": 1 })))],
];
let bad = 0;
for (const [n, ok] of cases) { console.log(`${ok ? "✓" : "✗"} ${n}`); if (!ok) bad++; }
process.exit(bad ? 1 : 0);
驗 CRDT 的三個性質(交換、結合、幂等)比驗「同步能跑」有價值得多——它們保證了「無論同步順序如何,結果都一致」。接進 Day 22 的 CI。
端到端衝突測試(今天的驗收重點):
1. 裝置 A(Chrome)登入,完成 ch01-1, ch01-2, ch01-3,等同步完成。
2. 裝置 B(Firefox)登入,確認拉到 3 課。
3. 兩邊都開 DevTools > Network > Offline。
4. A 離線完成 ch01-4, ch01-5;B 離線完成 ch09-1, ch09-2。
5. A 先上線 → 同步。
6. B 後上線 → 同步。
7. 檢查:兩邊都應有 7 課。
第 7 步是整個功能的核心驗收。 如果用 last-write-wins,這裡會掉 2 課。
其他:
# API 防護
curl -X POST https://learnpath.example.com/api/sync \
-H 'content-type: application/json' \
-d '{"changes":{"'"$(python3 -c 'print("x"*200)')"'":1}}'
# 預期:垃圾 key 被靜默丟棄,不寫入
curl -X POST … -d '{"changes":{"ch01-1":99999999999999}}'
# 預期:未來時間被丟棄
# 未登入回歸
# 從 HTML 移除 sync.js → 網站行為與 Day 26 完全相同
今天的重點:
ON CONFLICT,原子操作,沒有 race condition。{userId, lessonKey, doneAt}。衍生資料(XP、徽章)各裝置自己算。sendBeacon + visibilitychange 是「離開前送出」的唯一可靠組合。明天做登入。重點不是「怎麼接 OAuth」,而是怎麼在收集最少資料的前提下做身分驗證——passkey(WebAuthn)為主、資料最小化、帳號刪除與資料匯出。
iThome鐵人賽