症狀:選完手機照片,畫面卻還是貼圖
使用者回報的步驟很簡單:點頭像 → 選「從手機圖庫」→ 選張照片 → 儲存。跳回會員中心,頭像還是原本的系統貼圖。
再點進去編輯頁,看到的又是手機照片。明明存成功了,為什麼 UI 顯示不一致?
這個 bug 表面上是前端畫面問題,但用 log 逼出來的真相,藏在三層之外——包括一個會在幾乎所有 admin-panel 型後端都出現的經典陷阱。
偵錯:跟著 log 走,別信錯誤訊息
第一步是打開前端 log 重現流程。關鍵幾行:
StickerService -> Deactivating all stickers
StickerService -> Error: status code of 403
StickerService -> Error Response: {status: 403, message: Forbidden}
StickerService -> Deactivate all failed: You do not own this sticker
最後這句「You do not own this sticker」幾乎讓我停在錯誤方向——去查使用者到底擁有哪幾個貼圖。但翻前端程式碼才發現:那不是後端回的訊息,是前端 _handleDioError 把「403 Forbidden」硬翻譯成這句。
**這是第一個教訓:HTTP 狀態碼與文字訊息之間的語意對應是會說謊的。**真正的錯誤只有一個字——Forbidden。跟 ownership 無關,純粹是權限問題。
第一層根因:Admin Panel 的權限設定漂移
翻 Strapi admin → Settings → Users & Permissions → Roles → Authenticated → Sticker section,發現 deactivateAll 這支 route 的權限沒勾。
其他三支 endpoint(available、setAvatar、purchase)都開著,只有這一支漏掉。比對程式碼,這是幾週前新增的 route,開發時在 dev 環境勾過,部署到 staging 卻沒同步。
這是 admin-panel 型後端的典型陷阱:權限配置不在 git 裡,跟著資料庫走。 新增 route 寫在程式碼、會進 code review、會 merge——但那條資料庫裡的權限記錄,沒人顧得到。
第二層破壞:前端 Stale Cache 放大影響
為什麼 403 沒被注意到?因為前端的寫法是這樣:
Future<bool> deactivateAllStickers() async {
final response = await _dio.put('/api/stickers/deactivate-all');
if (response.statusCode == 200) {
await fetchStickers(); // 成功才刷新本地快取
return true;
}
return false; // 失敗 → 本地快取保持「貼圖仍 active」
}
更糟的是,後端的 afterUpdate lifecycle 在使用者更新 avatar 時,會自動停用貼圖。所以資料庫實際上是正確的——但前端手上那份 stickerResponse(上次呼叫 /api/stickers/available 取回、存在記憶體裡的快照)停留在「貼圖還 active」的舊狀態,沒有被重新抓。UI 優先顯示 active 貼圖,於是覆蓋了剛存進去的照片。
這就是所謂的快取未同步:資料庫是真相來源,但前端讀的是自己那份本地副本;副本沒更新到,UI 就會看到與後端不一致的畫面。
真正的 bug 是「失敗時缺少 graceful degradation」——既然後端 lifecycle 會兜底,前端失敗時至少也該 fetchStickers() 同步一次。
整個 cascade 畫出來就清楚了:
關鍵洞察:資料庫其實是對的,壞掉的只有那條「前端因為 API 失敗而沒刷新的本地快取」。真相永遠在 log 跟資料流裡,不在 UI 顯示的結果。
衍生 Bug:null 欄位觸發 Strapi ValidationError
修完權限後測試子帳號,又冒出 400:
ValidationError: realName must be a `string` type, but the final value was: `null`
前端把空字串轉成 null 送出,Strapi 不收 null。順手一起修——改成只把非 null 欄位放進 PUT body:
// Before
final data = {'realName': memberName, 'nickname': nickname, ...};
// After
final data = <String, dynamic>{};
if (memberName != null) data['realName'] = memberName;
if (nickname != null) data['nickname'] = nickname;
// ... 其他欄位同樣處理
這個 pattern 原本就用在 avatar 欄位,只是沒擴及其他欄位。典型的「部分正確」陷阱。
延伸:這不只是 Strapi 的問題
真正值得記住的是:權限配置存在 admin panel / metadata / rules file,而不是跟著 git 走,這個 anti-pattern 幾乎出現在每個 admin-panel 型後端。
容易中招的後端:
| 後端 | 權限位置 | 漂移場景 |
|---|---|---|
| Strapi、Directus、Keystone | Admin UI → DB | 環境間忘了同步 |
| Firebase Security Rules | Rules 文件,需手動 deploy | dev 改了 prod 沒改 |
| Supabase RLS | Dashboard 或 migration | policy 與 schema 分開管 |
| Hasura | Metadata | 沒 metadata export 就不會進 git |
| AWS IAM、API Gateway | Console 設定 | Policy 忘了綁 Role 是經典 |
相對防漏的做法:
- Code-first 權限(Django REST framework permissions、NestJS guards、Rails cancancan):權限寫在 controller 旁邊,跟著 git review
- IaC 管權限(Terraform、Hasura CLI metadata export、Strapi config-sync 套件):PR 時就看得到 diff
本質是**「權限是程式的一部分,還是資料的一部分」這個選擇**。admin panel 方便非工程師操作、代價是配置脫離版控,端點多了以後漂移幾乎不可避免。
結論:三個預防原則
- 把權限配置進 git:用 config sync / metadata export / IaC,只要 PR 能 diff,漂移就會被攔下
- 寫端對端權限測試:每支 endpoint 都該有「未授權使用者該被拒絕」的測試;CI 跑一遍,漂移會在 build 階段就噴
- 失敗時降級而非沉默:前端對關鍵 API 失敗應該有補償機制,不要預設「失敗 = 什麼都不做」,讓後端的 lifecycle 等補救機制有機會接手
核心心法:錯誤訊息會說謊,權限配置會漂移,前端快取不會自己同步。這三件事放在一起,就是那個「看起來明明存成功了卻顯示不對」的 bug。
