頭貼切不回去?一個 Bug 揪出 Admin Panel 權限漂移的跨後端通病

症狀:選完手機照片,畫面卻還是貼圖 使用者回報的步驟很簡單:點頭像 → 選「從手機圖庫」→ 選張照片 → 儲存。跳回會員中心,頭像還是原本的系統貼圖。 再點進去編輯頁,看到的又是手機照片。明明存成功了,為什麼 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 的權限沒勾。 ...

April 17, 2026 · 2 分鐘 · Peter

Flutter 子母帳號切換踩了四個坑:GetX 狀態管理的隱藏地雷

一個「切換帳號」引爆四個 Bug 子母帳號功能很直覺:母帳號可以切換到子帳號視角,查看子帳號的健康日記、操作資料。技術上就是換一組 JWT,重新載入資料。 聽起來簡單,實際上踩了四個 GetX 狀態管理的地雷,每個都不是表面看得出來的問題。 Bug 連鎖反應全景圖 先看整體:四個 bug 環環相扣,每修一個就暴露下一個。 第一坑:JWT 換了,身份沒換 切換帳號的核心邏輯只做了一件事——替換 JWT: // 切換到子帳號 await tokenService.saveToken(childJwt); tokenService.token = childJwt; await tokenService.saveRefreshToken(childRefreshToken); 看起來沒問題?但 App 裡所有 API 呼叫不只靠 JWT,還依賴 userId 和 documentId。這兩個值存在 SharedPreferences 裡,切換帳號時完全沒有更新。 結果:子帳號的 JWT 搭配母帳號的 userId 去呼叫 API → 後端權限不符 → 500 錯誤。日記的 GraphQL 查詢用母帳號的 documentId 過濾 → 查到的是母帳號的資料,不是子帳號的。 為什麼容易忽略? 因為 JWT 本身就攜帶使用者身份,直覺上換了 JWT 就等於換了身份。但 App 端的 REST API 和 GraphQL 查詢是用 SharedPreferences 裡的 userId / documentId 組裝 URL 和 filter,這兩套身份來源沒有同步。 ...

April 16, 2026 · 2 分鐘 · Peter

廠商說不支援 URL Scheme:跨 App 綁定從自訂協議遷移到 Universal Link 的完整實錄

廠商一句話,整個回調機制要重寫 我們的健康管理 App 整合了一台第三方氣酮檢測裝置。綁定流程很單純:使用者從我們的 App 跳到廠商的 App 完成綁定,綁定完成後跳回來,顯示成功或失敗。 原本的回調方式是 URL Scheme: myapp://device-bindback?status=success iOS 上一直運作正常,直到廠商的 Android App 更新後回報:他們不支援 URL Scheme 回調,要求改用 Universal Link。 這不是改一行 URL 的事。Universal Link 牽涉到網域驗證、靜態檔案部署、平台設定、安全機制,幾乎是把整個回調架構重新設計。 在深入之前,先釐清幾個關鍵術語: 術語 說明 URL Scheme 自訂的 URL 前綴(如 myapp://),讓其他 App 可以透過這個 URL 開啟你的 App。缺點是任何 App 都可以註冊相同的 scheme,沒有驗證機制。 Universal Link(iOS) Apple 的機制,把 HTTPS URL 和特定 App 綁定。系統透過 AASA 檔案驗證網域和 App 的關聯,確保只有合法的 App 能攔截該 URL。 App Links(Android) Google 對應 Universal Link 的機制,透過 assetlinks.json 驗證網域和 App 的關聯,原理相同。 AASA Apple App Site Association,放在 https://你的網域/.well-known/apple-app-site-association 的 JSON 檔案,告訴 iOS「哪些路徑要交給哪個 App 開啟」。 assetlinks.json Android 版的 AASA,放在 https://你的網域/.well-known/assetlinks.json,功能相同。 Provisioning Profile Apple 的簽名設定檔,記錄 App 被允許使用哪些功能(如 Push Notifications、Associated Domains)。新增功能時必須重新產生。 Deep Link 泛指所有能從外部開啟 App 並導到特定頁面的連結,URL Scheme 和 Universal Link 都是 Deep Link 的實作方式。 SHA-256 指紋 App 簽名憑證的雜湊值(32 組 hex,如 AA:BB:CC:...),用來唯一識別一個 App 的簽名身份。assetlinks.json 靠它比對「宣稱的 App」和「裝置上的 App」是否同一個。 Upload Key 開發者本地 keystore 的私鑰,用來簽名上傳到 Play Console 的 AAB/APK。本地開發、CI、模擬器都用這把。 App Signing Key Google Play 持有的私鑰,用來對上架版 App 做最終簽名。使用者從 Play Store 安裝的 App 是這把 key 簽的,指紋跟 upload key 不同。 改動前後的流程差異,用一張圖看最清楚: ...

April 10, 2026 · 5 分鐘 · Peter

從 53 種食物到 13,152 種:五國食物資料庫 ETL 管線設計實錄

前言:手動維護 53 種食物的極限 我正在開發一款健康追蹤 App,核心功能之一是飲食記錄。一開始,食物資料庫是手動維護的 JSON — 53 種台灣常見小吃,從滷肉飯到珍珠奶茶。 53 種夠用嗎?使用者搜尋「鮭魚」找不到、搜尋「優格」找不到、搜尋「oatmeal」更不用說。手動新增不可能跟上需求,我需要一條自動化管線,把全世界的食物營養資料拉進來。 這就是 ETL 管線登場的時機。 什麼是 ETL?用食物資料庫解釋 ETL 是 Extract(擷取)、Transform(轉換)、Load(載入) 的縮寫,是資料工程最基礎的設計模式。與其抽象解釋,直接用這個專案的食物資料庫來看: 四個來源的原始資料格式完全不同 — 台灣用中文欄位名(粗蛋白)、日本用 FAO 代碼(prot)、USDA 用數字編號(203)。Transform 階段把它們統一成 App 需要的 {id, name, emoji, category, nutrition} 結構。 為什麼不直接在 App 端串接 API?因為食物營養資料是靜態的 — 雞蛋的蛋白質含量不會每天變。離線 JSON 零延遲、不吃流量、無網路也能用。ETL 只在開發階段跑一次,產出的 JSON 跟著 App 一起發布。 USDA API 的格式陷阱:同一個服務,兩套規則 USDA FoodData Central 是美國農業部的食物營養資料庫,涵蓋近 8,000 種食物。我選擇 foods/list endpoint 批量下載 SR Legacy 和 Foundation Foods 資料。 然而,這個 API 有一個文件沒有明確說明的陷阱:foods/list 和 foods/search 兩個 endpoint 回傳的營養素格式完全不同。 ...

March 17, 2026 · 2 分鐘 · Peter

Google Play 警告消不掉:Fastlane 上傳 Native Debug Symbols 的三個陷阱

那個怎麼都消不掉的警告 Google Play Console 上掛著一則警告:「App Bundle 含有原生程式碼,而您尚未上傳偵錯符號檔」。 先釐清幾個名詞,因為 Google Play 的警告訊息把不同的東西混在一起講: 檔案 用途 來源 Native Debug Symbols (.so) 還原 C/C++ native crash 的堆疊追蹤 Flutter 引擎、NDK、第三方 SDK R8 Mapping (mapping.txt) 還原 Java/Kotlin 被 R8 混淆後的類別名稱 Gradle build(minifyEnabled true) 兩者都要上傳,Google Play 才不會抱怨。Flutter 專案兩者都需要:引擎帶了 .so,Gradle 開了 R8。 整體流程:Build → Detect → Upload 在看個別陷阱前,先理解正確的 CI 流程應該長什麼樣: 關鍵原則:上傳邏輯綁定 build 產物,不綁定部署環境。只要 build 裡有 .so 或 mapping.txt,就上傳。不管是 staging 還是 production。 但從這個目標到實際跑通,踩了三個完全不同性質的坑。 陷阱一:File.expand_path 的基準不是你以為的那個 第一版的 Fastfile 寫死了路徑: ...

February 24, 2026 · 2 分鐘 · Peter

後端修好了,前端卻沒跟上:Vue 網頁版的 JWT Refresh 機制從零補上

結帳頁突然報錯:Missing or invalid credentials 使用者在 App 內開啟網頁版結帳頁,輸入折扣碼後收到一個刺眼的錯誤訊息:「Missing or invalid credentials」。 不是每個人都遇到,也不是每次都發生。但只要在頁面上停留超過一段時間再操作,就一定會觸發。 聽起來很熟悉?沒錯,這跟前一篇文章的問題同源——JWT 15 分鐘過期。差別在於,上次是後端的 refresh API 被權限系統擋掉,這次是前端根本沒有呼叫 refresh 的能力。 架構背景:App 開網頁的 SSO 流程 我們的 Flutter App 會透過 in-app browser 開啟 Vue 網頁。token 的傳遞方式很直接: https://web.example.com/checkout/xxx?token=eyJhbG... Vue 從 URL query param 取得 JWT,存進 sessionStorage,之後的 API 呼叫都帶這個 token。 問題在於:只傳了 JWT,沒有傳 refresh token。 JWT 有 15 分鐘的壽命。使用者開啟頁面、慢慢填寫發票資料、比較方案——15 分鐘一晃就過了。接下來任何 API 呼叫都會收到 401,但 Vue 手上沒有 refresh token,無法換新的 JWT。只能眼睜睜看著使用者被擋在門外。 為什麼 Flutter 不需要擔心,但 Vue 必須處理 Flutter App 本身有完整的 token refresh 機制:interceptor 攔截 401、用 refresh token 換新 JWT、重送失敗的 request。整個流程無縫,使用者無感。 ...

February 24, 2026 · 3 分鐘 · Peter

Flutter iOS 冷啟動閃退:Debug 模式的隱藏陷阱

問題現象:Xcode 正常,冷啟動閃退 最近在開發 Flutter iOS App 時遇到一個詭異的問題:透過 Xcode 按下 Run 按鈕啟動 App 完全正常,但當我停止 Debugger、從多工畫面滑掉 App、再從主畫面點擊圖示重新開啟時,App 只顯示 Launch Screen 就立刻閃退。 這個問題讓我走了不少彎路,最後發現答案簡單得令人意外。 誤導方向:UIScene 與 Plugin 註冊 由於 Xcode 26 開始顯示 UIScene lifecycle will soon be required 警告,我最初懷疑是 UIScene 遷移不完整導致的問題。接著又懷疑是 Plugin 註冊的 race condition,甚至在 GeneratedPluginRegistrant.m 中加入大量 debug log 來追蹤每個 Plugin 的註冊狀態。 嘗試過的「修復」包括: 完整實作 SceneDelegate 與 FlutterSceneDelegate 在 Flutter Engine 初始化前加入 0.5 秒延遲 逐一排除可能有問題的 Plugin 這些方向全部是錯的。 找到根本原因:iOS Console Log 關鍵轉折點是查看 iOS 的 Console log。使用 Xcode 的 Devices and Simulators > Open Console 或 macOS 的 Console.app 連接 iPhone,重現冷啟動閃退後,看到了這段關鍵訊息: ...

February 17, 2026 · 2 分鐘 · Peter

Flutter CI/CD Debugging: Three Build Failures in One Day

前言:當建置一直紅燈 CI/CD pipeline 亮紅燈是開發日常,但連續遇到三個不同層面的問題,從 iOS codesigning、Android Gradle、到 Google Play API,這就值得記錄下來了。 這篇文章記錄我在同一天內遇到的三個建置失敗,以及逐步排除的過程。每個問題都有其獨特的根因,但也反映出 CI/CD 環境的複雜性。 問題一:iOS Keychain 解鎖失敗 症狀 Jenkins 建置在 iOS 階段失敗,錯誤訊息: [!] Error unlocking keychain at path: fastlane_keychain Command failed with exit status 51 macOS Keychain 運作機制 在深入問題之前,先了解 macOS Keychain 的運作方式: 關鍵概念: macOS 可以有多個 Keychain,每個都有獨立密碼 憑證必須在「已解鎖」的 Keychain 中才能被 codesign 使用 CI 環境通常會建立專用的 Keychain,避免影響系統 Keychain Fastlane Match 與 Keychain 的互動流程 調查過程 Exit status 51 代表「密碼錯誤」。SSH 進 Jenkins Mac mini 確認: ...

January 17, 2026 · 2 分鐘 · Peter

同樣的功能,為什麼 Flutter 比 Vue 難 Debug 十倍?從實戰到架構的六層反思

前言:同一個功能,截然不同的 Debug 體驗 最近在維護一個同時有 Vue 前端和 Flutter App 的專案。兩邊都要實作「關於我們」頁面的選單過濾邏輯——根據不同情境顯示或隱藏特定項目。 Vue 那邊:兩天內改了十幾個 commit,每次都是小幅調整,順順地完成。 Flutter 這邊:卡了一整天,改了一個地方沒效果,懷疑方向錯誤,來回折騰。 同樣的業務邏輯,為什麼 debug 體驗差這麼多? 這篇文章從 debug 實戰出發,一路延伸到架構層面的反思。我們會探討 Domain Model 的防禦能力、Clean Architecture 的責任邊界、扁平架構的取捨、BFF 的可靠性價值,最後揭露這次 debug 困難的真正原因——交接代碼的信任陷阱。 Part 1:Debug 實戰 Vue:問題在 UI 顯示層 Vue 那邊的典型修正長這樣: // Vue - 在 computed 裡加一個 filter const filteredPageTabs = computed(() => { return response.value.pageTabs .filter(item => item.subtitle !== 'Service') .map(item => { if (item.subtitle === 'ABOUT_US') { return { ...item, subTabs: item.subTabs?.filter( subTab => subTab.title !== '醫療團隊' ) || [] } } return item }) }) 問題本質:資料從 API 回來是正確且完整的,只需要決定「哪些要顯示在畫面上」。 Debug 過程:打開 Vue DevTools → 看 store 資料 → 加個 filter → 完成。整個過程不超過 10 分鐘。 ...

January 4, 2026 · 5 分鐘 · Peter

Flutter App 首頁 Banner 消失之謎:一場無效 API Token 的偵探之旅

前言:當 Banner 在眾目睽睽下消失 如果你曾經盯著一個「昨天還好好的」功能,然後花了幾小時才發現問題根本不在你想的地方——恭喜你,你已經正式成為資深工程師了。 這次的主角是一個 Flutter App 的首頁輪播 Banner。用戶回報說「Banner 不見了」,而我的第一反應是:「一定是最近的 commit 搞壞的!」 (劇透:不是。) 第一階段:追蹤嫌疑犯 嫌疑人一號:最近的 Git Commit 最近剛好有一個 commit 修改了後端的 middleware,用來處理認證相關的端點。自然而然,我先從這裡開始查: git show abc123 # fix: add middleware to strip auth header for public endpoints 看起來這個 middleware 只處理 /api/auth/forgot-password 這類認證端點,跟 Banner API 完全無關。 結論:無罪釋放。 嫌疑人二號:Strapi 權限設定 接下來檢查 Strapi Admin 的權限設定。Public 角色的 app-home-page 權限: 角色 find 權限 Authenticated ✅ 已開啟 Public ✅ 已開啟 權限設定完全正確。 結論:也不是兇手。 第二階段:真相大白 測試 REST API 直接用 curl 測試 REST API: ...

December 30, 2025 · 2 分鐘 · Peter