AI 互動行銷頁實戰 — 目標九宮格 — 第 05 章
job + 輪詢 + 等待 UX。學完:接一個要跑 1-3 分鐘的 AI 生圖任務,手機收訊閃一下、頁面重新整理,結果都不會丟。
核心觀念
快任務和慢任務是兩種工程。
讓一條 HTTP 連線開著等三分鐘,等於在賭:代理不逾時、手機網路不抖、使用者不切頁——三件事同時不出錯。
POST /v1/images/jobs → 202 {id, status:"queued"}
GET /v1/images/jobs/{id} → {status: queued|running|succeeded|failed,
images: [{url, expires_at}], error}
// 同 IP 同時只能一張在生(in-flight lock),搶不到鎖回 409
if (!(await acquireLock(env.DB, ip, "pending")))
return json({ error: "in_flight" }, 409, cors);
// 先扣後跑:每日生圖額度先 -1,確定失敗才退
const quota = await takeQuota(env.DB, { kind: "img", ip, limit: 3, day });
if (!quota.ok) {
await releaseLock(env.DB, ip);
return json({ error: "quota_exceeded", resetAt: taipeiResetAt() }, 429, cors);
}
const result = await submitJob(env, payload); // POST 上游 /v1/images/jobs
await putJob(env.DB, result.data.id, { ip, day }); // 記單號+建立時間(逾時判定)
return json({ jobId: result.data.id, mode: "job" }, 202, cors); // 202 = 已受理
鎖與扣額度是主路、備援共用;submitJob 之後是備援限定——有 OPENAI_API_KEY 時主路在這之前就同步打 OpenAI、直接回 {mode:'sync', images}。submitJob 還藏了一手:上游回 404(job API 未部署)就自動退回同步端點、85 秒截斷——Worker 和上游可以分開部署、互不卡死。
const st = res.data?.status;
if (st === "queued" || st === "running") {
if (jobTimedOut(job)) { // 建立超過 660 秒還沒好
await refundJob(env.DB, jobId); // 退額度 + 解鎖
return json({ status: "failed", error: "timeout", refunded: true }, 200, cors);
}
return json({ status: st }, 200, cors); // 還在做,前端繼續等
}
if (st === "succeeded") {
await releaseLock(env.DB, job.ip); // 成功:解鎖(額度照扣)
return json({ status: "succeeded", images: res.data.images || [] }, 200, cors);
}
await refundJob(env.DB, jobId); // 失敗:退額度 + 解鎖
上游卡死不能變成使用者的損失。refundJob 是冪等的:先查「退過沒」,前端連問十次逾時也只退一次,額度不會被退成負的。
while (true) {
const delay = attempt < 18 ? 5000 : 10000; // 5s × 18 次(90s)後改 10s
if (elapsed + delay > 720000) throw new ApiError(0, { error: 'poll_timeout' });
await sleep(delay); elapsed += delay; attempt += 1;
try {
res = await fetch(`${workerBase()}/image/${jobId}`);
body = await res.json();
} catch { // 網路抖一下不算失敗
if (++consecutiveErrors >= 4) throw new ApiError(0, { error: 'network' });
continue; // 連續錯 4 次才放棄
}
consecutiveErrors = 0;
if (body.status === 'succeeded') return body.images;
if (body.status === 'failed') throw new ApiError(200, { error: 'job_failed' });
} // queued / running → 下一輪
主路同步回 {mode:'sync', images} 時前端直接拿圖收工;收到 {mode:'job', jobId} 才進這個迴圈。前 90 秒多數圖會完成,問太慢使用者白等;之後問密一點也不會更快,只是浪費流量。
兩個數字的關係
前端 720 秒才放棄,比 Worker 的 660 秒寬。
使用者一定先收到「逾時、額度已退還」,而不是前端先放棄、留下懸念。
超時這種事,要讓管帳的那一方先開口。
// 單號到手立刻寫 localStorage
api.savePendingJob({ jobId: sub.jobId, createdAt: new Date().toISOString() });
images = await api.pollWallpaper(sub.jobId);
api.clearPendingJob(); // 終局(成功)才清掉
// 頁面載入時:有未終局的單 → 不重送,接著等
const pending = api.loadPendingJob();
if (pending) {
goToStep(4);
const elapsedMs = Date.now() - Date.parse(pending.createdAt);
startGenUi(Date.now() - elapsedMs); // 進度條從實際經過時間接著走,不歸零
const images = await api.pollWallpaper(pending.jobId, { elapsedMs });
api.clearPendingJob();
}
成功/失敗/逾時這些「終局」都清 pending 單;純網路錯誤不清,網路恢復還能續傳。再按一次「生成」會改成恢復輪詢而不是重送——重送只會撞 409。
const GEN_STAGES = ['AI 構圖中', 'AI 正在打草稿', '上色中', '收尾修飾中'];
const tick = () => {
const elapsed = Date.now() - startedAt;
const idx = Math.floor(elapsed / 9000) % GEN_STAGES.length; // 文案每 9 秒輪播
genStageEl.textContent =
`${GEN_STAGES[idx]}…(已 ${mm}:${ss},一般約 1-3 分鐘)`;
pbarFill.style.width =
`${Math.min(96, (elapsed / 180000) * 100).toFixed(1)}%`; // 封頂 96%
};
上游不會告訴你「畫到 73%」,進度條必然是演的——但可以演得誠實。
下一章:背景圖到手了,但九宮格的字呢?先看「叫 AI 連中文字一起畫」怎麼翻車,再用 canvas 一字不差地疊上去。
對照成品:worker-deploy/src/index.js · app/js/api.js · demos/03-wallpaper/← → 翻頁