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 一樣直接用。 ...
Schema 主表翻轉的 dual-write 過渡:一場不能 stop-the-world 的搬家
引言:當業務主表需要翻轉 某個 SaaS 系統長期以「客戶資料表」為核心:所有訂單、文件、操作紀錄都用客戶 ID 當外鍵。但這個客戶表的資料來源是外部 POS 系統匯入,每天同步幾百筆,schema 由廠商定義。 業務發展後問題浮現:自己 SaaS 的會員表(users)才是真正的「人」 — 有登入、有偏好、有應用內行為。新功能(個人化推薦、訂閱管理、社交綁定)都需要以 users 為主軸。 於是有了一個經典的 schema migration 需求:主表翻轉(primary table pivot)。把業務邏輯的中心從 customers(外部 POS 匯入)轉到 users(SaaS 自家會員),但歷史資料、新進資料、系統相依、回滾風險全部都要顧到。 名詞解釋 開始拆解之前,定義幾個會反覆出現的詞: 詞 定義 主表翻轉(Primary table pivot) 業務主要 entity 從表 A 改成表 B Dual-write 寫入時同時寫舊欄位 + 新欄位,回滾安全 Partial cut-over 分階段切換,read 跟 write 不同步切 Hard cut-over(stop-the-world) 一次切完,downtime 短但 risk 大 Backfill 歷史資料補齊新欄位的 batch update Idempotent migration 重跑無副作用,cron / retry 安全 DISTINCT ON PostgreSQL 專屬去重 syntax,搭 ORDER BY 取每組第一筆 Pseudo entity 為了統一查詢介面而建的「假」實體 Link table ORM 多對多 join 表(user_id + entity_id) 為什麼選 dual-write 而非 stop-the-world 最直接的搬家方式是「stop-the-world cut-over」:選一個維護窗口,停寫入、跑 batch script、改完所有 reference、開機。但這個 SaaS 的條件不允許: ...
可列印 A4 報表的 CSS 全攻略:頁碼、跨頁表頭、列印隔離
問題現場:想做一份「能印」的網頁報表 需求很常見:後台要一頁會計報表(明細列表 + 合計列),會計同事要能直接從瀏覽器按列印、出 A4。沒人想為這種小需求引入 PDF library 或產 PDF 的後端服務 —— 瀏覽器列印就好。 寫下去才發現坑滿地:頁碼從哪來?跨頁時表頭會不會消失?頁面外面那層 admin sidebar、左側選單會不會也跟著被印出來?螢幕上預覽是黑背景白字,印到紙上會變什麼樣? 這些問題的答案幾乎都不在「常見 CSS 教學」裡,而是藏在一份很少人讀的 W3C 規範:CSS Paged Media Module。本文用一份實際做出來的會計報表為例,把這些技巧串起來。 為什麼不選 PDF library? 開發者第一個反射動作往往是「裝個 jsPDF / pdfmake 直接產 PDF」。在跳進那條路之前,先看清各方案的代價: 方案 中文字型 排版精度 Bundle 適用情境 CSS @page(本文) ✅ 系統字型 高(向量字) 0 KB 後台報表、現場列印、人工流程 html2canvas + jsPDF ⚠️ 需嵌字型 中(光柵化) ~300 KB 簡單表單下載 pdfmake / React-PDF(向量 PDF) ⚠️ 需嵌字型 高 ~500 KB+ 給外部讀者的下載檔 後端 Puppeteer / WeasyPrint ✅ 系統字型 最高 0(前端) 批次寄信、加密簽章 前端 PDF library 最大的痛點是中文字型。系統列印免費借用作業系統字型;jsPDF 預設只內建 Helvetica,要中文不亂碼就得把整套思源黑體(壓縮後仍有 5-10 MB)打包進 bundle,否則出現方塊或缺字。 ...
ORM 在騙你:當 populate / include 悄悄失效
症狀:資料明明在,前端就是拿不到 一個 CMS 系統的文件列印預覽,每份文件 header 都固定顯示「上傳者:—」。DB 裡明明有資料,API 回 HTTP 200 OK,回應裡的其他欄位(日期、文件類型、附件)都對——唯獨 uploadedBy 是 null。 沒錯誤、沒警告,伺服器 log 也乾淨。前端碰到 uploadedBy?.realName || '-' 就乖乖畫 dash。使用者以為這份文件沒指派擁有者,作者本人以為畫面壞了。大家都錯,但系統看起來好好的。 這是最難纏的 bug——靜默失敗。 先看 ORM 層在整個架構哪裡 要理解為什麼這類 bug 發生、也為什麼解法是「繞過 ORM」,先把架構分層看清楚: 為什麼 bug 只會發生在 ORM 層? 上面的業務邏輯只負責說「給我文件,順便帶上傳者」——它不知道要怎麼 JOIN。下面的 DB 只回應被問到的 SQL——你沒 SELECT 的欄位它不會主動給你。只有 ORM 層有權力決定「要翻譯成什麼 SQL」,也只有它遇到不會處理的組合時,會選擇「悄悄回你 null」而不是「拋錯給你看」。 這就是為什麼靜默失敗只會出現在這一層。 用 curl 逼它露餡 把 query 簡化到只剩 filter + populate 兩件事,一個一個比對: # A: 用 id filter → 關聯欄位變 null ❌ curl "$URL?filters[owner][id][\$eq]=3807&populate[uploadedBy]=true" # { "uploadedBy": null } # B: 用 documentId filter → 關聯正常回來 ✅ curl "$URL?filters[owner][documentId][\$eq]=abc123&populate[uploadedBy]=true" # { "uploadedBy": { "id": 85, "realName": "J. Chen" } } 同樣的 populate、同樣的文件集合,差別只在「怎麼辨認 owner」。這種事應該是 ORM 內部細節,對使用者不該有差。但就是有。 ...
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: ...
Strapi 忘記密碼的安靜回應:Anti-Enumeration、Phishing-as-a-Service 與撞庫經濟學
問題現場:一個「成功」卻收不到信的忘記密碼 某個週一下午,QA 回報:在一個 Flutter + Strapi 架構的 App 上,用測試帳號 testuser@example.com 點「忘記密碼」,畫面跳出 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 倍? ...
頭貼切不回去?一個 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 的權限沒勾。 ...
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,這兩套身份來源沒有同步。 ...
Fastlane CI 突然壞了:一個被遺忘的 Keychain 依賴如何讓 Build 連續失敗
Build 突然壞了,但沒有人改過 CI 設定 iOS 的 Jenkins pipeline 突然開始失敗。上一版(058)還好好的,下一版(059)就掛了。再跑一次(060),還是掛。 第一時間檢查 git diff —— 兩版之間只有 Dart 程式碼改動,沒有任何人碰過 Fastfile、Jenkinsfile、或 CI 相關設定。程式碼改動是 Flutter 層的 bug fix,跟 iOS build 流程完全無關。 這是最令人困惑的情境:什麼都沒改,但 CI 壞了。 先看一下各版的 build 結果: Tag Build 結果 耗時 prod-0.1.04+2026000058 #1 SUCCESS 9.5 分鐘 prod-0.1.04+2026000059 #1 FAILURE 2.3 分鐘 prod-0.1.04+2026000060 #1 FAILURE 2.3 分鐘 059 和 060 都只跑了 2 分多鐘就掛了 —— 連 Flutter build 都沒跑到,在 Fastlane 的前期設定階段就失敗了。 先理解 Fastlane 的 Keychain 機制 在深入除錯之前,先理解 iOS code signing 在 CI 環境的運作方式。 ...
廠商說不支援 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 不同。 改動前後的流程差異,用一張圖看最清楚: ...