varchar(255) 陷阱:帳密正確,卻從 in-app 瀏覽器登入回 500

前言:帳密明明正確,卻回 500 某天收到回報:使用者從手機點開官網登入,帳號密碼都對,卻跳出 Internal Server Error。換其他瀏覽器再登入就成功了。 「帳密正確卻 500」這種錯誤特別惱人,因為它不是身分驗證的問題,而是驗證通過之後才出事。本文記錄怎麼從一行日誌追到根因,以及背後三個值得每個後端工程師記住的設計教訓。 從日誌追根因:三條線索 先分清楚登入失敗的兩種回應:帳密錯誤會回 400 Invalid identifier or password;而這次是 500。光這一點就暗示——帳密其實是對的,問題出在登入流程的後半段。 線索一:500 只在帳密正確時出現 撈出該帳號當天的登入紀錄,發現 500 全部集中在某個時段,之後突然出現一次成功,再之後才是幾筆「帳密錯誤」的 400。換句話說,那串 500 本身就證明了帳密一直是對的——錯的不是驗證。 線索二:那行 PostgreSQL 錯誤訊息 日誌裡每筆 500 都附帶同一句資料庫錯誤: insert into "refresh_tokens" (..., "user_agent") values (...) - value too long for type character varying(255) 真相浮現:登入驗證通過後,後端要寫一筆 refresh token 進資料庫,其中 user_agent 欄位是 varchar(255),而這次要寫入的值超過 255 字元,PostgreSQL 直接拒絕,未被攔截的例外往上冒成了 500。 線索三:為什麼有時成功、有時失敗 成功那次寫進資料庫的 user_agent 長度是 128;失敗那幾次的值都超過 255。差別就在 User-Agent 字串的長度——而它由「使用者用什麼瀏覽器」決定。 根本原因:varchar(255) 塞不下 in-app 瀏覽器的 UA 寫入 refresh_tokens 的欄位中,token 是固定長度的隨機字串、expires_at 是時間、device_type 是短列舉、ip_address 也很短。唯一長度會劇烈變動、且可能爆掉的,只有 user_agent。 ...

May 27, 2026 · 2 分鐘 · Peter

LLM 驅動的 E2E 驗證:為什麼 bot 比人更容易揭發 backend regression

前言:frontend 修補的驗證最常被推給人手點 修了一個 frontend modal 的提交守門 bug,把改動推上 STG(staging 環境),下一步「驗證」往往就是寄訊息給 QA 或產品經理:「請開瀏覽器點點看,看 UI 守門有沒有生效。」 這個步驟在 CI 跑完單元測試後通常還是被當成人類的責任,原因是 E2E(end-to-end,端到端)環境難搭、寫一次 Playwright 腳本要花的時間比人手點還久、而且 UI 本來就會變動。 但隨著 LLM agent 配上瀏覽器自動化能力,這個 trade-off 已經悄悄反轉了。Bot 不只能省掉人手點擊的時間,更會做一件人類常常忘了做的事:在 UI 看似成功之後,去資料庫驗證資料真的寫對了。 這篇文章記錄一次真實的 STG 驗收,原本要驗 frontend 修補,卻意外揭發 backend 的 dual-write regression(雙寫機制失效)。 Playwright MCP 是什麼,為什麼是它打破僵局 先把兩個關鍵字釐清: Playwright 是微軟釋出的瀏覽器自動化框架,原本是寫 JavaScript 或 Python 腳本控制 chromium。 MCP(Model Context Protocol) 是 Anthropic 推的開放協議,把外部工具用宣告式 schema 包成 LLM 可呼叫的 server,類似 LSP(Language Server Protocol)之於編輯器——讓不同 IDE 共用同一個語言分析後端。LLM 客戶端啟動時會跟 MCP server 握手取得可用 tool 清單,之後對話中就能像呼叫 function 一樣直接用。 ...

May 15, 2026 · 4 分鐘 · Peter

ORM 在騙你:當 populate / include 悄悄失效

症狀:資料明明在,前端就是拿不到 一個 CMS 系統的文件列印預覽,每份文件 header 都固定顯示「上傳者:—」。DB 裡明明有資料,API 回 HTTP 200 OK,回應裡的其他欄位(日期、文件類型、附件)都對——唯獨 uploadedBy 是 null。 沒錯誤、沒警告,伺服器 log 也乾淨。前端碰到 uploadedBy?.realName || '-' 就乖乖畫 dash。使用者以為這份文件沒指派擁有者,作者本人以為畫面壞了。大家都錯,但系統看起來好好的。 這是最難纏的 bug——靜默失敗。 先看 ORM 層在整個架構哪裡 要理解為什麼這類 bug 發生、也為什麼解法是「繞過 ORM」,先把架構分層看清楚: flowchart TD A[前端 / Client<br/>Vue, React, App] --> B[Web / API 層<br/>Controller, Route, 權限檢查] B --> C[業務邏輯層<br/>Service / Use Case] C --> D[ORM 層 ⭐<br/>Strapi Entity Service, Prisma, TypeORM,<br/>Mongoose, SQLAlchemy ORM] D --> E[Query Builder 層 可選<br/>knex, SQLAlchemy Core] E --> F[DB Driver<br/>pg, mysql2, mongodb] F --> G[資料庫<br/>PostgreSQL, MongoDB, ...] classDef default fill:#f5f5f5,stroke:#333,color:#333 classDef orm fill:#d1ecf1,stroke:#17a2b8,color:#0c5460 classDef db fill:#d4edda,stroke:#28a745,color:#155724 class D orm class G db 為什麼 bug 只會發生在 ORM 層? ...

April 24, 2026 · 2 分鐘 · Peter

Strapi v5's Silent populate Failure with Relation Filters

Symptom: A Relation Everyone Believes Is Empty A document management print preview kept rendering “Uploader: —” for every report. The database had the data. The API returned HTTP 200 OK. Yet the uploadedBy relation came back as null, with no error or warning anywhere. Users assumed reports had no uploader assigned. Authors assumed the UI was broken. Nobody suspected the API, because it was perfectly silent. Where the Bug Lives in the Stack Before the curl session, it helps to fix where the failure happens: ...

April 24, 2026 · 6 分鐘 · Peter

Strapi 忘記密碼的安靜回應:Anti-Enumeration、Phishing-as-a-Service 與撞庫經濟學

問題現場:一個「成功」卻收不到信的忘記密碼 某個週一下午,QA 回報:在一個 Flutter + Strapi 架構的 App 上,用測試帳號 [email protected] 點「忘記密碼」,畫面跳出 Success: Reset password link sent successfully on your email account.,但信箱(包含垃圾郵件匣)一封信都沒有。 直覺先排除幾個明顯可能:SMTP 設定壞掉、用戶被 block、信件被 Gmail 擋。但真正的答案比這些都有趣 — 這不是 bug,是 Strapi 一個刻意的安全設計,只是 App 前端把它翻譯錯了。 kubectl 診斷:20ms 與 800ms 的兩種人生 進 Strapi pod 撈 log,找 forgot-password 請求: kubectl logs -n default strapi-prod-xxxxxxxxx-xxxxx --since=6h \ | grep -E 'forgot-password|Reset password URL' 抽出的關鍵幾行: 04:04:52 info: Reset password URL generated: https://example.com/... 04:04:53 http: POST /api/auth/forgot-password (948 ms) 200 05:15:44 http: POST /api/auth/forgot-password (20 ms) 200 05:16:53 info: Reset password URL generated: https://example.com/... 05:16:54 http: POST /api/auth/forgot-password (664 ms) 200 05:17:30 http: POST /api/auth/forgot-password (26 ms) 200 一眼看出兩種節奏:800ms 級別的請求伴隨「Reset password URL generated」log,而 20-30ms 的請求則完全沒有。同一支 API,為什麼耗時差 30 倍? ...

April 21, 2026 · 4 分鐘 · Peter

頭貼切不回去?一個 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 · 3 分鐘 · Peter

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

一個「切換帳號」引爆四個 Bug 子母帳號功能很直覺:母帳號可以切換到子帳號視角,查看子帳號的健康日記、操作資料。技術上就是換一組 JWT,重新載入資料。 聽起來簡單,實際上踩了四個 GetX 狀態管理的地雷,每個都不是表面看得出來的問題。 Bug 連鎖反應全景圖 先看整體:四個 bug 環環相扣,每修一個就暴露下一個。 flowchart TD A[切換子帳號] --> B{JWT 更新了?} B -->|是| C{userId / documentId 更新了?} C -->|否| D[API 500 / 日記查錯帳號] C -->|修好| E{ViewModel 有重建?} E -->|刪除重建| F{UI widget 指向新 ViewModel?} F -->|否:持有舊參考| G[首頁資料不更新] F -->|改為原地刷新| H{全域變數也更新了?} H -->|否:top-level final| I[Dialog 功能全失效] H -->|改為 getter| J{App 重啟能恢復?} J -->|否:狀態在記憶體| K[母帳號控制權消失] J -->|持久化到 SharedPreferences| L[全部修好] classDef error fill:#f8d7da,stroke:#dc3545,color:#721c24 classDef success fill:#d4edda,stroke:#28a745,color:#155724 classDef warning fill:#fff3cd,stroke:#ffc107,color:#856404 class D,G,I,K error class L success class A,B,C,E,F,H,J warning 第一坑:JWT 換了,身份沒換 切換帳號的核心邏輯只做了一件事——替換 JWT: ...

April 16, 2026 · 2 分鐘 · Peter

Docker BuildKit Cache Mount 的隱形陷阱

症狀:部署成功,程式碼卻是舊的 一個新功能已經 commit 並推上 Git,Jenkins pipeline 顯示建置成功、部署完成,Kubernetes Pod 也順利啟動。進入 Pod 檢查——程式碼還是舊的。 這不是網路延遲、不是 image pull policy、不是 tag 衝突。問題藏在 Dockerfile 裡一行看起來「很聰明」的快取優化。 背景知識:Docker 建置的關鍵概念 在進入除錯過程之前,先釐清幾個 Docker 建置中的核心概念: Docker Image 與 Layer(映像檔與分層) Docker 映像檔由多個唯讀的「層」(layer)堆疊而成。Dockerfile 裡的每一條指令(如 COPY、RUN)都會產生一層。Docker 會對每一層計算 hash,下次建置時如果指令和輸入都沒變,就直接重用該層——這就是 layer cache。 BuildKit BuildKit 是 Docker 18.09 之後引入的新一代建置引擎(透過 DOCKER_BUILDKIT=1 啟用)。相比傳統引擎,BuildKit 支援平行建置、更聰明的快取策略,以及本文主角——--mount=type=cache(cache mount)語法。 Cache Mount(--mount=type=cache) BuildKit 專有的功能。它在 RUN 指令執行期間,將一個持久化的目錄掛載到容器內的指定路徑。這個目錄由 BuildKit 管理,不會被寫入最終的 image layer,但內容會跨越不同次的 docker build 保留下來。常見用途是快取套件管理器的下載目錄(如 yarn cache),避免每次建置都重新下載。 Inline Cache 與 --cache-from 另一種 BuildKit 快取策略。透過 --build-arg BUILDKIT_INLINE_CACHE=1 把快取 metadata 嵌入產出的 image,再用 --cache-from 從遠端 registry 拉取。這讓不同機器(例如 CI runner)也能共享建置快取。 ...

February 18, 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

功能部署後憑空消失?一場 Jenkins Workspace 的除錯之旅

問題:功能「先在後不在」 我們替 Strapi 後台新增了 MFA(Multi-Factor Authentication)雙因素認證功能。第一次部署到 STG 時一切正常——登入攔截、TOTP 驗證、QR Code 設定流程都運作良好。 但幾天後,因為其他功能的修改推了新的 git tag stg-1.24,部署流程順利完成,Jenkins 顯示綠燈——打開 STG 後台,MFA 的登入流程卻完全不見了。 沒有錯誤訊息、沒有 crash log,功能就這樣無聲無息地消失。 這比「功能從未出現」更令人困惑:明明之前部署是好的,程式碼也沒有人動過 MFA 相關的部分,為什麼會突然不見? 調查過程:逐層排除 確認 Git Tag 內容 第一個直覺是——程式碼真的有被包進 tag 嗎? # 確認 tag 指向的 commit 包含 MFA 程式碼 git show stg-1.24:src/index.ts | grep -i mfa 結果確認 src/index.ts 中有 import { registerMfaRoutes } from './mfa/controller',src/mfa/ 目錄也完整存在。Git tag 本身沒問題。 檢查 Pod 內的實際檔案 既然 tag 正確,問題可能出在建置或部署階段。直接進到 Kubernetes Pod 裡看: # 進入 STG pod 檢查 kubectl exec -it deployment/strapi-stg -- sh # 檢查編譯後的檔案 ls dist/src/mfa/ # 結果:ls: dist/src/mfa/: No such file or directory # 檢查原始碼 ls src/mfa/ # 結果:ls: src/mfa/: No such file or directory MFA 相關檔案完全不存在於 Pod 中。 甚至連原始碼都沒有被複製進 Docker image。 ...

February 12, 2026 · 3 分鐘 · Peter