AI 互動行銷頁實戰 — 目標九宮格 · 第 08 章
Worker 端點是公開網址,任何人十行迴圈就能整夜打它。四層防線的目標:讓正常人的一天剛好夠用、讓腳本的一秒鐘必然碰壁。
核心觀念
免費給人玩,
不等於免費給腳本玩。
/fill 燒 Groq 額度;/image 主路每張都是 OpenAI Images API 按張計費的帳單,備援燒自架服務的 ChatGPT 訂閱——防線同時護著兩者。你照本課自己部署一套時,燒的就是你的錢。
| 端點 | 燒什麼 | 每日配額(每 IP) |
|---|---|---|
| POST /fill AI 補格/展開 | Groq API 額度 | 12 次 |
| POST /image AI 桌布背景 | OpenAI Images API 按張計費;備援燒 ChatGPT 訂閱 | 3 次 |
| POST /signup email 留資 | D1 寫入(便宜,但會被灌垃圾) | 10 次 |
數字放 wrangler.toml 的 [vars],要調不用改程式。原則:最貴的資源,配額壓最低。
// worker-deploy/src/lib/turnstile.js
export async function verifyTurnstile(env, token, ip) {
if (!env.TURNSTILE_SECRET) {
console.warn("TURNSTILE_SECRET 未設,跳過 Turnstile 驗證");
return true; // fail-open:部署過渡期不擋人
}
if (!token) return false;
const form = new URLSearchParams();
form.set("secret", env.TURNSTILE_SECRET);
form.set("response", token);
const res = await fetch(VERIFY_URL, { method: "POST", body: form });
return (await res.json()).success === true;
}
secret 與 sitekey 要兩邊同時就位,部署總有先後 — secret 未設就硬擋,過渡期會全站 403。fail-open 的代價是暫時少一層,但後面還有三層,每層都更硬。
// 第一版設計:看起來會動,單人測試都過
const used = Number(await env.QUOTA.get(key)) || 0;
// 請求 A、B 同時讀到 2
if (used >= limit) return tooMany();
// 兩個都通過檢查
await env.QUOTA.put(key, String(used + 1));
// 兩個都寫 3:多跑一次,少算一次
「讀、檢查、寫」是三個動作,沒有任何機制阻止插隊 — 同時發 20 個請求,大半都在「還沒寫回」的窗口通過。何況 KV 是最終一致儲存,本來就不是計數器。這顆雷是上線前的對抗式 review 抓出來的:「看起來會動」不等於「攻擊下仍然正確」。
// worker-deploy/src/lib/quota.js — 改用 D1(免費 SQLite)
export async function takeQuota(db, { kind, ip, limit, day }) {
const row = await db.prepare(
`INSERT INTO quota_counts (day, ip, kind, count) VALUES (?, ?, ?, 1)
ON CONFLICT(day, ip, kind) DO UPDATE SET count = count + 1
WHERE count < ? RETURNING count`,
).bind(day, ip, kind, limit).first();
if (!row) return { ok: false }; // 沒有列回來 = 超限
return { ok: true, count: row.count };
}
資料庫保證兩個併發請求必有先後,誰都插不了隊。超限回 429 並附 resetAt(台北時間明日 00:00),前端講得出「幾點回來」,並引導 BYO 自帶金鑰。
// 先清過期鎖,再搶鎖;RETURNING 無列 = 沒搶到(回 409)
export async function acquireLock(db, ip, jobId, now = Date.now()) {
await db.prepare("DELETE FROM locks WHERE expires_at < ?")
.bind(now).run();
const row = await db.prepare(
`INSERT INTO locks (ip, job_id, expires_at) VALUES (?, ?, ?)
ON CONFLICT(ip) DO NOTHING RETURNING ip`,
).bind(ip, jobId, now + 900_000).first();
return !!row;
}
鎖一定要有期限(這裡 15 分鐘),不然使用者關掉網頁,IP 就永遠被鎖死。409 的文案要講人話:「另一個生成正在進行(可能是你稍早送出的請求),最多 15 分鐘後自動解鎖」。
// worker-deploy/src/index.js — /image 的順序,一行都不能換
if (!(await verifyTurnstile(env, body.turnstileToken, ip)))
return json({ error: "turnstile_failed" }, 403, cors); // 1
if (!(await acquireLock(env.DB, ip, "pending")))
return json({ error: "in_flight" }, 409, cors); // 2
const quota = await takeQuota(env.DB, { kind: "img", ip, limit, day });
if (!quota.ok) { // 3 先扣
await releaseLock(env.DB, ip);
return json({ error: "quota_exceeded", resetAt: taipeiResetAt() }, 429, cors);
}
// 4. 扣完才呼叫上游;送單失敗 → 退 1 + 解鎖
「跑完才扣」的話,生圖跑著的那幾十秒到幾分鐘裡,同 IP 的請求個個都在「還沒扣」窗口通過。先扣的不公平用退款補回:主路 OpenAI 回錯或撞 90 秒截斷、備援送單失敗、job 失敗、逾時 660 秒,Worker 都自動退額並解鎖。
// 搶到 refunded 0→1 這個翻轉的那一次,才真的退
const marked = await db.prepare(
"UPDATE jobs SET refunded = 1 WHERE id = ? AND refunded = 0 RETURNING id",
).bind(jobId).first();
if (!marked) return false; // 已退過,跳過
await refundQuota(db, { kind: "img", ip: job.ip, day: job.day });
await releaseLock(db, job.ip);
前端每 5 秒輪詢一次,失敗狀態會被讀到很多次 — 每次都退 1,失敗一次反而變成送額度。配額、lock、退款:三層原子操作,同一個 ON CONFLICT / RETURNING 句型。
curl -s -X POST https://goal-grid.yazelinj303.workers.dev/fill \
-H 'Content-Type: application/json' \
-d '{"mode":"core","cells":[null,null,null,null,"學好英文",
null,null,null,null]}'
# 不經瀏覽器、沒有 Origin —— 請求照樣進來
CORS 是瀏覽器的自律規範,只有瀏覽器會遵守;它保護的是「使用者在別的網站上不被偷偷代打」,不是你的額度。接住 curl 的是配額層 — 這就是四層一層都不能省的原因。
← → 翻頁