上一篇,我和 Codex 決定讓按鈕只帶一個短 session ID,位置、偏好與上一批店名則暫存在 Firestore。
接下來才是使用者真正感受得到的部分:按下「換一批」後,怎麼確保不是把同樣的咖啡廳再送一次?按下「更適合工作」後,下一輪搜尋又該怎麼接著用這個偏好?
這篇會把整條流程接起來,但不鑽進每個型別或 Firestore 欄位,重點放在幾個實際會遇到的問題。
原本的 LINE handler 主要處理文字和位置訊息。加入 Postback 後,我們先在事件入口把它分流出去:
if (event.type === 'postback') {
await handlePostbackEvent(event);
return;
}
新的 handler 收到按鈕資料後,不會立刻搜尋,而是先做幾個確認:
只要其中一項不符合,就不會往下呼叫 Gemini。
例如舊按鈕過期,Bot 會請使用者重新傳位置;按鈕屬於其他人,就請目前使用者建立自己的搜尋。比起統一顯示「發生錯誤」,直接告訴使用者下一步要做什麼,實際上好用很多。
如果座標和 prompt 完全相同,模型很可能再次推薦附近最熱門的幾間店。
所以每次搜尋完成後,我們會把這一批店名寫回 session。下一次按「換一批」時,再把它們當成排除清單:
const result = await findNearbyCafes(
session.latitude,
session.longitude,
{
preference,
excludeNames: session.previousCafeNames
}
);
Prompt 的語氣是「附近還有其他合理選擇時,優先避開上一批」,而不是一律禁止重複。
這個差別很重要。有些地方附近本來就沒有很多咖啡廳,如果為了硬湊不同店家,把搜尋範圍拉得太遠,結果反而更差。
「更適合工作」則會把 session 裡的偏好改成 work_friendly。之後再按「換一批」,這個偏好會繼續保留,不會突然跳回一般搜尋。
不過這顆按鈕只是調整搜尋方向,不是承諾每間店都有插座、Wi-Fi 或不限時。沒有明確 Maps 證據時,模型仍然不能自己補上這些資訊。
AI 搜尋需要一點時間。畫面還沒出現結果時,使用者很容易再點一次「換一批」。
如果每次點擊都真的呼叫 Gemini,不只聊天室會收到重複訊息,也會浪費 API 用量。
單純在 Node.js 裡放一個 isProcessing 並不夠,因為 Cloud Run 可能同時有多個 instance,彼此看不到對方記憶體裡的狀態。
所以我們把一個短暫的處理鎖放進 Firestore,並用 transaction 讀取與上鎖。兩個幾乎同時抵達的點擊,只有一個可以繼續;另一個會收到:
上一個搜尋還在進行中,請稍等結果出現。
搜尋完成或失敗後,程式會釋放鎖。這個設計不只防止畫面重複,也保護模型用量。

第一次位置搜尋完成後,程式才會嘗試建立 session。
用比較生活化的說法,就是續杯服務暫時壞了,第一杯咖啡還是應該端上桌。
Quick Reply 也會跟著後端能力變化。有 session 才顯示兩顆 Postback 按鈕;沒有 session,就不放一顆按了必定失敗的按鈕。
這讓 Postback 成為原本功能的加強,而不是新的單點故障。
Google Maps 資料和模型回答都可能改變,不適合把某間咖啡廳寫死在單元測試裡。
我們真正鎖住的是自己能控制的部分:
最後再執行 typecheck 和單元測試,確認加入 Postback 後沒有破壞原本流程。
測試不需要假裝外部世界永遠不變,而是把我們自己定義的規則固定下來。
到這裡,「換一批」已經不只是畫面上的按鈕,而是一條完整流程:
收到 Postback
→ 驗證按鈕與 session
→ 取得處理鎖
→ 帶著上一批店名重新搜尋
→ 更新 session
→ 推送新結果
這次最有感的不是多了一個 handler,而是每個失敗情況都有清楚邊界:過期就重新開始、連點就先等待、Firestore 寫入失敗也不拖垮第一次搜尋。
功能在本機完成後,最後還有一個現實問題:怎麼把新版接到正式 LINE Bot,又不讓原本可用的服務一起冒險?下一篇會記錄這次的平行部署、Webhook Verify 和自動回復流程。
https://github.com/zonawang/codex-postback-action