改了設定要不要重啟?關鍵在「每次請求即時讀」還是「啟動時快取」

現在 code 都讓 AI 寫了,這種判斷它靠得住嗎? 你在資料庫改了一筆設定——權限、feature flag、某個閾值——接著要決定:改完到底要不要重啟服務才生效? 放在以前,你會憑經驗猜:保守派一律重啟(常常多此一舉、白白製造一次中斷),樂觀派賭它即時生效(結果改半天前端還吃舊行為,debug 一小時才發現要重啟)。但現在多了一個選項,也是大多數人實際在做的——直接問 AI。於是真正的問題變成:這種「要不要重啟」的判斷,AI 答得對嗎? AI 會怎麼判斷——而且為什麼常常自信地答錯 先說結論:這題剛好是 AI 最容易出錯的類型,而且會錯得很有自信。 AI 寫 code 的本質,是從訓練資料的 pattern 推測最可能的答案。而「改設定要重啟」在無數系統裡都成立,於是它傾向很篤定地叫你重新部署,卻沒去確認你眼前這份程式碼到底是 live 讀還是快取。版本差異更是重災區——同一個框架舊版快取、新版改成 live 讀,AI 常常混為一談,給你一個「看起來很對」的錯答案。 問題的本質其實沒變,只是換了位置:從前要自己判斷,現在要判斷「AI 的判斷對不對」。而要驗證它、或反過來把它導對,你得先有一把尺——一條不靠經驗、可以拿原始碼直接對答案的準則。 那把尺:即時讀,還是啟動時快取? 決定「改完要不要重啟」的,從來不是設定存在哪裡,而是程式什麼時候去讀它: 讀取時機 改完是否即時生效 典型例子 每個請求即時讀(per-request live read) ✅ 即時,不必重啟 每次查 DB 的權限、即時拉的 feature flag 啟動時載入記憶體後快取(boot / in-memory cache) ❌ 必須重啟或等過期 bootstrap 載入的設定、process 內的計數器 換句話說:改 DB 要不要重啟,等於問「這個值是 live 讀還是被快取了」。把這句話記住,後面所有案例都是它的推論。 麻煩的是,框架很少在文件裡明講某個值屬於哪一類,AI 也答不準。所以正確的態度不是查文件、更不是憑經驗猜,而是翻原始碼,看那個值是在「請求路徑」上被讀,還是在「啟動路徑」上被讀。下面用一正一反兩個真實案例,把這把尺磨利。 實例一:Strapi 權限「改 DB 即刻生效」 需求很單純:把兩個 content-type(就叫它 article 與 category)的 CUD(Create / Update / Delete,建立/更新/刪除)權限,授給幾個自訂角色。Strapi(一個 Node.js 的 headless CMS)的權限存在資料庫的 up_permissions 表,所以最直接的做法就是下 SQL 把對應的 grant 寫進去。 ...

June 19, 2026 · 3 分鐘 · Peter

Fiverr 假接案陷阱:拆解藏在國旗 SVG 裡的 Node.js 後門

前言:一個「幫我看看 repo」的接案邀請 接案平台上來了一個看似正常的全端案子:一個既有的 Next.js + Node.js 網站,要我接手完成。對方很客氣地寄來 GitHub repo 邀請,請我「先看看 homepage、評估報價與里程碑」。 這是再普通不過的接案流程。但有一個習慣救了我:任何陌生的 repo,我都先靜態審查,絕不先 npm install 跑起來。 這次掃下去,在一個名字無害到不行的檔案 server/lib/serverStartup.js 裡,我看到了這輩子最不想在「客戶專案」裡看到的東西——eval()。 順著這條線往下挖,是一套設計相當精巧的隱寫術(steganography)後門:惡意碼完全不在 .js 檔裡,而是藏在 21 個國旗 SVG 圖檔的註解中。這篇文章完整拆解它的三層結構、實際行為,以及背後那套針對開發者的社交工程攻擊。 ⚠️ 本文所有惡意網域均以資安界慣例 defang(example[.]dev)處理,程式碼片段皆為去活化的分析用途,不含可直接執行的下載指令。 攻擊全貌:社交工程 + 隱寫術 + 多階段載入 這類攻擊在業界稱為 Contagious Interview(假面試/假接案),核心不是去駭你的伺服器,而是騙你親手把惡意碼跑在自己的開發機上。一個 npm run dev 就中。 整條感染鏈長這樣: flowchart TD A[接案邀請<br/>GitHub repo] --> B[npm run dev<br/>啟動 server] B --> C[env.js 載入時<br/>呼叫 runServerStartupLogs] C --> D[serverStartup.js<br/>eval Check validation] D --> E[validation 讀取<br/>21 個國旗 SVG 註解] E --> F[串接 + base64 解碼<br/>= 載入器] F --> G[回報主機 + VM 偵測<br/>拉取各竊取模組] G --> M1[剪貼簿挾持<br/>clipper] G --> M2[瀏覽器錢包<br/>+ 憑證竊取] G --> M3[檔案 / 密鑰<br/>外洩模組] G --> M4[socket.io<br/>遠端 shell RAT] classDef bait fill:#d1ecf1,stroke:#17a2b8,color:#0c5460 classDef trap fill:#fff3cd,stroke:#ffc107,color:#856404 classDef evil fill:#f8d7da,stroke:#dc3545,color:#721c24 class A,B bait class C,D,E,F,G trap class M1,M2,M3,M4 evil 精巧之處有兩層。第一層是偽裝:每個檔案單獨看都「像正常程式」——檔名叫 serverStartup、validation,函式做的事看起來是「讀國旗檔做驗證」。第二層是模組化:載入器本身很小,真正的惡意能力被拆成數個可獨立拉取的竊取模組,這正是 Contagious Interview(針對開發者的假面試/假接案)攻擊家族的典型架構。只有把整條鏈串起來,才看得出它是一套完整的竊取工具包。 ...

June 4, 2026 · 5 分鐘 · Peter

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

頭貼切不回去?一個 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

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 環境的運作方式。 ...

April 11, 2026 · 5 分鐘 · 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 · 6 分鐘 · Peter