我昨天的「優化」,今天把所有人的臉都變成了縮寫

你有沒有過那種,前一天還很得意自己做了個漂亮的優化,隔天它就在 prod 上當著所有人的面炸給你看的經驗?我這次炸得特別有畫面感——所有用 Google 登入的使用者,頭像全變成了名字縮寫。

事情要從昨天說起。昨天我做了件「正確的事」:把頭像那顆原始的 <img> 換成 next/image。理由很充分啦——自動 lazy load、自動尺寸最佳化、避免 layout shift,每一條都是教科書會教你做的優化。我換完、跑完測試、merge、出版本,心情很好地關掉電腦。

然後今天打開 prod,每一張 Google 頭像都不見了,全部退回那個「沒有照片時才該出現」的姓名縮寫圓圈。

我盯著螢幕想了三秒,才意識到這是我自己昨天種的雷。

next/image 有一個你平常不會想到、但它一定會提醒你的設計:對於外部網域的圖片,你必須在 next.config.tsremotePatterns 裡明確 allowlist。 這是一道安全機制——避免你的站台變成任意第三方圖片的最佳化代理。我的設定裡原本只有一條 **.supabase.co,因為 Futari 自己上傳的頭像都放在 Supabase storage。

但 Google OAuth 的頭像不在那裡啊。它們從 lh3.googleusercontent.com 來。這個網域沒被 allowlist,於是 next/image 直接回 400,觸發 onError 的 fallback,畫面默默退回縮寫。沒有紅色錯誤、沒有崩潰、沒有任何人尖叫——就只是大家的臉,安靜地消失了。

這就是 allowlist 這種機制最微妙的地方:它是那種你只有在它擋住你的時候,才會想起它存在的東西。 平常它沉默地保護你;某天你引進一個新的圖片來源,忘了把它加進名單,它就一視同仁地把你自己也擋在外面。安全機制不認識「這是站長本人」這回事,它只認名單。

修法當然是一行:

remotePatterns: [
  { protocol: 'https', hostname: '**.supabase.co' },
  // Google OAuth 頭像 (lh3.googleusercontent.com)
  // 沒這條,next/image 回 400,頭像 fallback 成縮寫
  { protocol: 'https', hostname: 'lh3.googleusercontent.com' },
],

一行。但這一行背後的教訓不是「記得加 remotePatterns」這麼表面。真正的教訓是:任何把「直接拿原始資源」換成「經過一層代理/最佳化」的改動,都默默引進了一個新的失敗模式——那層中間人有它自己的規則,而那些規則平常隱形。 我以為我只是把 <img> 換成一個更聰明的 <img>,其實我是在中間插了一個會檢查名單的守門員。守門員不會通靈,它只看我有沒有事先把訪客登記進去。我登記了 Supabase,漏了 Google。

更該記住的是這隻 bug 的「形狀」:它完全不在我的測試裡,因為本機跟 CI 我多半用上傳到 Supabase 的頭像在測;它不會丟錯誤,只會優雅地降級成縮寫,監控也不會叫。一個既不在測試、又不報警的失敗,唯一會發現的人是真的用 Google 登入、然後納悶「我的大頭貼呢」的使用者——或者,隔天早上手賤打開 prod 的我。降級得太優雅,有時候反而是壞事吧,因為它讓壞掉看起來像沒壞。

清掉這個雷之後,今天剩下的時間做了件相對開心的事:把 Futari v1.3.0 的行為分析事件補齊。橫跨 10 個 action 檔、十幾個事件,全部是 server-side 的 captureServer(),掛在既有的寫入接縫上,完全不需要 schema migration。分三層埋——P0 留存核心(record_created 帶 split_type / category / 有沒有掛愛物等欄位、settlement_createdincome_created)、P1 功能採用(trip 生命週期、七種愛物建立、定期規則)、P2 關係與流失訊號(group_lefthad_partner、伴侶問答、角色互換、base currency 變更)。一次關掉九張 issue,出 v1.3.0。

兩件事擺在一起看其實是同一個主題的兩面:頭像那隻 bug 是「我以為我看得見、其實看不見」;而這套 analytics 存在的理由,正是因為沒有埋點的產品,你對使用者到底卡在哪、用了什麼、為什麼離開,全是猜的。 一個是承認我對自己的程式盲了,一個是承認我對使用者的行為盲了。差別只在於,第二種盲,我這次主動裝了眼睛。

學到的一課:每次你在資料路徑上插一層「更好」的東西——image optimizer、cache、proxy、middleware——都先問一句:這層有沒有它自己的 allowlist/設定/規則,是我現在沒設、但它預設會擋的? 優化很少是純粹的免費午餐,它通常是拿「一個你熟悉的簡單失敗」換「一個你還不認識的複雜失敗」。

先不說了,我得去把每一個外部圖片來源都點一遍——這次,趁它們還沒當著使用者的面消失之前啊。

這段 code 寫於 2026 年 5 月 27 日,文章整理於同日深夜。