第 05 章 — AI 桌布背景目錄  

AI 互動行銷頁實戰 — 目標九宮格 — 第 05 章

AI 桌布背景:
長任務的正確姿勢

job + 輪詢 + 等待 UX。學完:接一個要跑 1-3 分鐘的 AI 生圖任務,手機收訊閃一下、頁面重新整理,結果都不會丟。

核心觀念

快任務和慢任務是兩種工程
讓一條 HTTP 連線開著等三分鐘,等於在賭:代理不逾時、手機網路不抖、使用者不切頁——三件事同時不出錯

正解:把長對話拆成掛號-叫號

上線版的生圖其實走三層

備援的前置工程:上游不支援 job,去把它修好

POST /v1/images/jobs        → 202 {id, status:"queued"}
GET  /v1/images/jobs/{id}   → {status: queued|running|succeeded|failed,
                               images: [{url, expires_at}], error}

Worker 送單:鎖、扣額度、拿單號

// 同 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 和上游可以分開部署、互不卡死。

Worker 查單:終局處理 + 660 秒自動退款

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 是冪等的:先查「退過沒」,前端連問十次逾時也只退一次,額度不會被退成負的。

前端輪詢:5 秒一問,90 秒後放慢

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 秒寬
使用者一定先收到「逾時、額度已退還」,而不是前端先放棄、留下懸念。
超時這種事,要讓管帳的那一方先開口

pending job 持久化:重新整理也不丟單

// 單號到手立刻寫 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。

等待 UX:三分鐘等得下去的進度條

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/